@desplega.ai/agent-swarm 1.171.0 → 1.172.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/dist/{acp-adapter-1z9p3fvx.js → acp-adapter-fpecx1g1.js} +23 -5
- package/dist/{actions-rc2yjk8a.js → actions-pw87a1a8.js} +24 -25
- package/dist/{amp-adapter-ypyf5s1x.js → amp-adapter-8jem2bc0.js} +1 -1
- package/dist/{anthropic-messages-etkbxhhy.js → anthropic-messages-8ee2f22q.js} +55 -22
- package/dist/{app-04ab2s0z.js → app-wmf0m843.js} +7 -8
- package/dist/{assistant-vhmjd8wf.js → assistant-emp1e108.js} +26 -27
- package/dist/{azure-openai-responses-xap57x4b.js → azure-openai-responses-ap1gwxgr.js} +3 -3
- package/dist/{boot-reembed-3p0m259x.js → boot-reembed-1de4f4aj.js} +6 -7
- package/dist/{boot-reembed-wew3gwk8.js → boot-reembed-4ymn9atn.js} +6 -7
- package/dist/{boot-scrub-sweep-5ytwtq24.js → boot-scrub-sweep-adhk1s8c.js} +7 -8
- package/dist/{boot-seeds-s8p2xry8.js → boot-seeds-8q637aew.js} +9 -10
- package/dist/{claude-adapter-akrzg6jh.js → claude-adapter-t26cv8a4.js} +16 -17
- package/dist/{claude-managed-adapter-kqqqhp2q.js → claude-managed-adapter-xgxhqrt2.js} +2 -2
- package/dist/{claude-sdk-session-exq1jadx.js → claude-sdk-session-gsfsh662.js} +16 -17
- package/dist/{cli-n8ybnv1k.js → cli-04emfe2q.js} +1 -1
- package/dist/{cli-w0bwte75.js → cli-104zsxba.js} +2 -1
- package/dist/{cli-x6z90nq9.js → cli-19njxg5g.js} +2 -4
- package/dist/{cli-8mr695sb.js → cli-1hgat6pa.js} +5 -5
- package/dist/{cli-1qtgd1h1.js → cli-1m2xxxe2.js} +2 -2
- package/dist/{cli-231av4y6.js → cli-1x7da0de.js} +2 -4
- package/dist/{cli-x3fy2tgp.js → cli-388egyf6.js} +1 -1
- package/dist/{cli-j0w59p15.js → cli-4fnb6884.js} +4 -4
- package/dist/{cli-y1j1ggbf.js → cli-4nz8p1p5.js} +1372 -588
- package/dist/{cli-s88my3br.js → cli-5rv1j9ga.js} +6 -6
- package/dist/{cli-mn8qkd8v.js → cli-61625k3x.js} +1 -1
- package/dist/{cli-mzj3aqac.js → cli-6jb2rf77.js} +3 -3
- package/dist/{cli-ec7hrgqp.js → cli-8hzfb6xz.js} +8 -8
- package/dist/{cli-n805bb98.js → cli-8vs9trd4.js} +1 -1
- package/dist/{cli-xf713zz0.js → cli-9fxpr83v.js} +1 -1
- package/dist/{cli-q1vnz8wv.js → cli-9s17naqf.js} +1 -1
- package/dist/{cli-nkjwe4z7.js → cli-9xsmdpdt.js} +59 -54
- package/dist/{cli-6xxxb8qt.js → cli-aspcaef3.js} +5 -5
- package/dist/cli-bf7485pm.js +514 -0
- package/dist/{cli-s9y81s7j.js → cli-bt8k5h3s.js} +625 -150
- package/dist/{cli-jxsgrnmq.js → cli-c2pq2rjb.js} +2 -2
- package/dist/{cli-13xwcqvj.js → cli-c5km5znr.js} +2 -2
- package/dist/{cli-5y67yrt6.js → cli-cceskcvt.js} +112 -176
- package/dist/{cli-ph3145b7.js → cli-cf69s3ew.js} +4 -4
- package/dist/{cli-k2v5x13t.js → cli-d603qf86.js} +1 -1
- package/dist/{cli-r455yd58.js → cli-e0a9sk00.js} +1 -1
- package/dist/{cli-cgj4hc12.js → cli-eh1ytktb.js} +1872 -190
- package/dist/{cli-erbp6b4g.js → cli-esnpehf5.js} +7 -8
- package/dist/cli-ev91t6xp.js +148 -0
- package/dist/{cli-e2prcx5m.js → cli-fdvbjtbw.js} +235 -5
- package/dist/{cli-62bwwc2q.js → cli-g4f5pj26.js} +3 -3
- package/dist/{cli-tg8q0b5y.js → cli-g6t9texm.js} +5 -5
- package/dist/{cli-p2pjfq1r.js → cli-gj81e47t.js} +1 -1
- package/dist/{cli-srmej5tp.js → cli-gp1rtyvk.js} +2 -2
- package/dist/{cli-rb9m6rpe.js → cli-h6vtvt5c.js} +139 -1
- package/dist/cli-hd27d2md.js +1057 -0
- package/dist/{cli-hsk5gd15.js → cli-hwwvy36w.js} +3 -3
- package/dist/{cli-2rbt7at8.js → cli-hz99wfxp.js} +1 -1
- package/dist/cli-j1pmc07z.js +706 -0
- package/dist/{cli-01qndrvw.js → cli-j6kpj9az.js} +14 -8
- package/dist/{cli-2qbs2tvf.js → cli-k46r9hmk.js} +6 -6
- package/dist/{cli-wfvedq5w.js → cli-kb4ehzx5.js} +5 -5
- package/dist/{cli-agamgm0p.js → cli-mvzyxwg3.js} +1 -1
- package/dist/{cli-280gnz4v.js → cli-nv4zd0b9.js} +1 -1
- package/dist/{cli-rrbctenq.js → cli-nyhb1r6x.js} +3 -5
- package/dist/{cli-g7k7zbcz.js → cli-p7z6q2m6.js} +2 -1
- package/dist/{cli-yn0a417a.js → cli-phre6gz0.js} +22 -4
- package/dist/{cli-m4csbs67.js → cli-pj6x9qce.js} +1 -1
- package/dist/{cli-w0zx9ya1.js → cli-prrt9fa0.js} +4 -4
- package/dist/{cli-ctret352.js → cli-pstq9888.js} +33 -33
- package/dist/{cli-y195hv1a.js → cli-pxeeyc53.js} +1 -1
- package/dist/{cli-4bm25xtw.js → cli-qsztpfry.js} +1 -1
- package/dist/{cli-5t5my0h8.js → cli-qybkr9n3.js} +278 -1267
- package/dist/{cli-xezn9nkh.js → cli-r44t9wqc.js} +29 -472
- package/dist/{cli-x207g4w1.js → cli-r8smj8b8.js} +1 -1
- package/dist/{cli-bxqez1p0.js → cli-rcp5aqdw.js} +9 -39
- package/dist/{cli-vr5avem2.js → cli-smcrrmhy.js} +14 -18
- package/dist/{cli-0d2rqhvq.js → cli-tgpfnm6r.js} +15 -17
- package/dist/cli-vb8g5x02.js +549 -0
- package/dist/{cli-gt4hxb8p.js → cli-vv0qtfpx.js} +1 -2
- package/dist/{cli-95zs84qw.js → cli-wd0hz6ah.js} +671 -388
- package/dist/{cli-nkqjj42q.js → cli-x4m6feqt.js} +1 -1
- package/dist/{cli-x61srfpp.js → cli-xczpdp9e.js} +2 -2
- package/dist/cli-z1kd834t.js +36 -0
- package/dist/{cli-p8f7dqgt.js → cli-ztrv9y21.js} +48 -33
- package/dist/cli.js +24 -25
- package/dist/{cloudflare-workers-ai-system-one-sta45jf9.js → cloudflare-workers-ai-system-one-vnpyfr7e.js} +3 -3
- package/dist/{codex-adapter-9ag75whm.js → codex-adapter-m4tgdysy.js} +12 -12
- package/dist/{codex-hook-7ba15hjd.js → codex-hook-xch6z5wt.js} +2 -3
- package/dist/{codex-session-runner-9fp7y4cf.js → codex-session-runner-ymwzm6ea.js} +12 -12
- package/dist/{commands-d7eah0y9.js → commands-n0hshcvs.js} +6 -7
- package/dist/{controllers-connect-8t0k65x2.js → controllers-connect-y07cqcvy.js} +24 -25
- package/dist/{cursor-adapter-t4pr1m3c.js → cursor-adapter-q8pgv5qa.js} +2 -3
- package/dist/{db-sp39qv72.js → db-4w7tmt1g.js} +6 -7
- package/dist/{db-2rmd23wq.js → db-hkqvrdcb.js} +7 -8
- package/dist/{dispatcher-g7fsybsd.js → dispatcher-5sd69y14.js} +23 -24
- package/dist/{easter-egg-3d-66zxervv.js → easter-egg-3d-m8rvz40s.js} +4 -3
- package/dist/execute-107xtsp1.js +3745 -0
- package/dist/{google-generative-ai-pvhm0dq1.js → google-generative-ai-7pavrbv7.js} +3 -3
- package/dist/{google-vertex-tek9sqe2.js → google-vertex-3f1jk41f.js} +3 -3
- package/dist/{grok-adapter-1err1me7.js → grok-adapter-8m9yqzbq.js} +24 -6
- package/dist/{handlers-c7yggm37.js → handlers-s20xr10r.js} +26 -27
- package/dist/{heartbeat-dkm924fk.js → heartbeat-3k31q4g2.js} +25 -26
- package/dist/{hook-wgx4exw6.js → hook-gdn6a2fn.js} +13 -14
- package/dist/{hook-g8e7sfnf.js → hook-ygwerrdh.js} +14 -15
- package/dist/{http-1d23k928.js → http-jdteeg81.js} +53 -55
- package/dist/{index-y5vvczrs.js → index-6ksewd5y.js} +20 -18
- package/dist/{index-kkeftv5h.js → index-82rk11v7.js} +4 -4
- package/dist/{index-gm10tmye.js → index-e0snym4t.js} +23 -24
- package/dist/{index-vertaxs1.js → index-ffqzqgwb.js} +6 -7
- package/dist/{index-q7cj9bn2.js → index-tsszt7g9.js} +25 -26
- package/dist/{index-w097eg0x.js → index-vccv018g.js} +1 -1
- package/dist/{index-va4zbvzp.js → index-vm3bm9t7.js} +24 -25
- package/dist/{keepalive-1jc5e7ct.js → keepalive-jekc896t.js} +8 -9
- package/dist/{lead-yxw2f1sg.js → lead-4k9s9szx.js} +40 -41
- package/dist/{maintenance-h7w2m10y.js → maintenance-shazch4n.js} +6 -7
- package/dist/{mistral-conversations-rjxa9s1j.js → mistral-conversations-vjc1ggpd.js} +29 -15
- package/dist/{oauth-refresh-sweep-wh9ypd1b.js → oauth-refresh-sweep-5gb36kn8.js} +7 -8
- package/dist/{onboard-dfr9j762.js → onboard-ydrbnvww.js} +2 -2
- package/dist/{openai-codex-responses-pgvtddvv.js → openai-codex-responses-33qhkkdy.js} +7 -39
- package/dist/{openai-completions-44gb0zsk.js → openai-completions-s88xm3ns.js} +2 -2
- package/dist/{openai-decisions-vj9nkpf8.js → openai-decisions-fctnj8xd.js} +2 -2
- package/dist/{openai-responses-9dyzwzkr.js → openai-responses-7xd2f2e2.js} +3 -3
- package/dist/opencode-adapter-5zh0he20.js +55 -0
- package/dist/{otel-impl-and31m5s.js → otel-impl-6pf5cv86.js} +1 -1
- package/dist/{pi-mono-adapter-fx6k5wwh.js → pi-mono-adapter-ef9wwx9m.js} +25 -500
- package/dist/{pricing-refresh-23zr6j3w.js → pricing-refresh-s52nsa5q.js} +8 -9
- package/dist/{rbac-roles-wnx35x4x.js → rbac-roles-aq3msh4e.js} +6 -7
- package/dist/{rbac-roles-nc7k9d0t.js → rbac-roles-q5gb5e8v.js} +7 -8
- package/dist/{render-v2-81cg1y52.js → render-v2-pd1rg54q.js} +7 -8
- package/dist/{resume-d4b6j2n2.js → resume-gw4a2c6v.js} +14 -15
- package/dist/{runtime-h9ha1mq6.js → runtime-qfry88v2.js} +4 -7
- package/dist/{setup-g68r1w28.js → setup-jda1ksdh.js} +2 -2
- package/dist/{typesafe-system-one-e988vty7.js → typesafe-system-one-zzzcx4s3.js} +3 -3
- package/dist/{virtual-modules-rdjmcg7q.js → virtual-modules-b3ga2m65.js} +13 -12
- package/dist/{work-objects-0zzdnbt9.js → work-objects-tb7ycye8.js} +26 -27
- package/dist/{worker-p1g8qm6h.js → worker-njjccz5d.js} +40 -41
- package/openapi.json +1 -1
- package/package.json +7 -8
- package/src/be/scripts/extract-schema.ts +4 -0
- package/src/github/handlers.ts +61 -18
- package/src/github/types.ts +2 -1
- package/src/prompts/goal-reminder.ts +30 -0
- package/src/prompts/session-templates.ts +22 -0
- package/src/providers/acp-targets.ts +3 -4
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/harness-version.ts +0 -1
- package/src/providers/opencode-adapter.ts +442 -723
- package/src/providers/opencode-swarm-plugin.ts +88 -0
- package/src/providers/pi-mono-extension.ts +58 -37
- package/src/scripts-runtime/executors/native.ts +6 -5
- package/src/scripts-runtime/executors/quickjs-bundle.ts +18 -11
- package/src/scripts-runtime/extract-args-schema.ts +22 -4
- package/src/scripts-runtime/import-allowlist.ts +1 -1
- package/src/scripts-runtime/zod-mini-bundle.ts +5 -0
- package/src/slack/render-v2.ts +2 -2
- package/src/tests/entrypoint-acp-target-binary.test.ts +19 -1
- package/src/tests/github-handlers-inline-comments.test.ts +131 -2
- package/src/tests/github-handlers-reactions.test.ts +9 -0
- package/src/tests/harness-version.test.ts +0 -20
- package/src/tests/opencode-adapter.test.ts +536 -1389
- package/src/tests/opencode-mcp-admission-sdk.test.ts +106 -0
- package/src/tests/opencode-swarm-plugin.test.ts +130 -0
- package/src/tests/pi-mono-extension.test.ts +59 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/provider-registration.test.ts +3 -0
- package/src/tests/script-executor-quickjs.test.ts +17 -12
- package/src/tests/scripts-http.test.ts +24 -16
- package/src/tests/scripts-import-allowlist.test.ts +1 -1
- package/src/tests/scripts-runtime.test.ts +78 -40
- package/src/tests/seed-scripts.test.ts +13 -0
- package/src/tests/slack-render-v2.test.ts +4 -2
- package/src/tests/ui-logs-parser.test.ts +28 -0
- package/src/tools/script-upsert.ts +1 -1
- package/src/utils/memory-raters.ts +7 -3
- package/src/utils/openrouter-base-url.ts +0 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +4 -6
- package/templates/skills/heartbeat-runbook/content.md +2 -0
- package/templates/skills/swarm-scripts/SKILL.md +1 -0
- package/templates/skills/swarm-scripts/content.md +1 -0
- package/dist/cli-5bfdm1ah.js +0 -462
- package/dist/cli-5c2j5yvx.js +0 -222
- package/dist/cli-rhy7z1rp.js +0 -532
- package/dist/cli-ywh2a87k.js +0 -2359
- package/dist/execute-h4jgevyw.js +0 -1387
- package/dist/opencode-adapter-f1zby94p.js +0 -34
- package/plugin/opencode-plugins/lib/memory-raters.ts +0 -7
- package/src/tests/opencode-plugin.test.ts +0 -580
- package/src/tests/providers/opencode-cost.test.ts +0 -74
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Opencode provider adapter.
|
|
2
|
+
* Opencode provider adapter (OpenCode 2).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* heartbeat, identity sync, system.transform, compacting, and idle hooks.
|
|
4
|
+
* OpenCode 2 runs in-process through `@opencode/sdk`: one embedded host per
|
|
5
|
+
* task, no `opencode serve` child, no port, no config or data-home files.
|
|
6
|
+
* Config travels as a string, the database is in memory, and the swarm plugin
|
|
7
|
+
* is a plain object (`opencode-swarm-plugin.ts`) passed to `OpenCode.create`.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
|
-
import { existsSync,
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import type {
|
|
14
|
-
import { createOpencode } from "@opencode-ai/sdk";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import type { OpenCode, OpenCodeEvent } from "@opencode/sdk";
|
|
15
13
|
import {
|
|
16
14
|
CONTEXT_FORMULA,
|
|
17
15
|
clampContextPercent,
|
|
@@ -29,7 +27,15 @@ import {
|
|
|
29
27
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
30
28
|
import { resolveSlashSkillPrompt } from "./codex-skill-resolver";
|
|
31
29
|
import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
|
|
32
|
-
import {
|
|
30
|
+
import { createOpencodeSwarmPlugin, type OpencodeToolLister } from "./opencode-swarm-plugin";
|
|
31
|
+
import {
|
|
32
|
+
apiHeaders,
|
|
33
|
+
autoIndexMemoryFile,
|
|
34
|
+
fireAndForget,
|
|
35
|
+
type SwarmHooksConfig,
|
|
36
|
+
summarizeTranscript,
|
|
37
|
+
syncIdentityFilesToServer,
|
|
38
|
+
} from "./pi-mono-extension";
|
|
33
39
|
import { applyReasoningEffort, type ReasoningEffort } from "./reasoning-effort";
|
|
34
40
|
import type {
|
|
35
41
|
CostData,
|
|
@@ -45,6 +51,22 @@ import type {
|
|
|
45
51
|
SteerDeliveryResult,
|
|
46
52
|
} from "./types";
|
|
47
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The subset of OpenCode's V1-shaped config we write. OpenCode 2 still reads
|
|
56
|
+
* V1 config (`opencode.json`, `OPENCODE_CONFIG_CONTENT`) and normalizes it in
|
|
57
|
+
* memory, so the ACP target and this adapter share one shape.
|
|
58
|
+
*/
|
|
59
|
+
export type OpencodeConfig = {
|
|
60
|
+
provider?: Record<
|
|
61
|
+
string,
|
|
62
|
+
{
|
|
63
|
+
options?: Record<string, unknown>;
|
|
64
|
+
models?: Record<string, { options?: Record<string, unknown> }>;
|
|
65
|
+
}
|
|
66
|
+
>;
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
48
70
|
/**
|
|
49
71
|
* Map opencode model strings to the env var that satisfies them. Opencode
|
|
50
72
|
* uses the same `provider/model-id` shape as pi-mono — the prefix tells us
|
|
@@ -64,7 +86,7 @@ function opencodeModelToCredKey(modelStr: string | undefined): string | null {
|
|
|
64
86
|
/**
|
|
65
87
|
* Opencode is satisfied by ANY of:
|
|
66
88
|
* 1. `~/.local/share/opencode/auth.json` exists (the file `opencode auth login`
|
|
67
|
-
* writes).
|
|
89
|
+
* writes; OpenCode 2 imports it into its credential store on start).
|
|
68
90
|
* 2. `MODEL_OVERRIDE` resolves to a provider-prefixed model — only that
|
|
69
91
|
* provider's key is required.
|
|
70
92
|
* 3. Otherwise any one of OPENROUTER_API_KEY / ANTHROPIC_API_KEY /
|
|
@@ -103,79 +125,58 @@ export function checkOpencodeCredentials(
|
|
|
103
125
|
};
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
function isAssistantMessage(msg: unknown): msg is AssistantMessage {
|
|
107
|
-
return (
|
|
108
|
-
typeof msg === "object" &&
|
|
109
|
-
msg !== null &&
|
|
110
|
-
(msg as AssistantMessage).role === "assistant" &&
|
|
111
|
-
typeof (msg as AssistantMessage).cost === "number"
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
type FinalizedMessageUsage = {
|
|
116
|
-
cost: number;
|
|
117
|
-
inputTokens: number;
|
|
118
|
-
outputTokens: number;
|
|
119
|
-
reasoningOutputTokens: number;
|
|
120
|
-
cacheReadTokens: number;
|
|
121
|
-
cacheWriteTokens: number;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const DOCKER_PLUGIN_PATH = "/home/worker/.config/opencode/plugins/agent-swarm.ts";
|
|
125
|
-
|
|
126
128
|
function defaultOpencodeSkillsDir(): string {
|
|
127
129
|
if (process.env.OPENCODE_SKILLS_DIR) {
|
|
128
130
|
return process.env.OPENCODE_SKILLS_DIR;
|
|
129
131
|
}
|
|
130
132
|
return join(process.env.HOME ?? "/home/worker", ".opencode", "skills");
|
|
131
133
|
}
|
|
132
|
-
const MODEL_CACHE_REFRESH_TIMEOUT_MS = 15_000;
|
|
133
|
-
// opencode cold-start on E2B disk regularly exceeds the SDK's 5s default
|
|
134
|
-
// server-start timeout (@opencode-ai/sdk dist/server.js), failing the spawn with
|
|
135
|
-
// "Timeout waiting for server to start after 5000ms". Override via
|
|
136
|
-
// OPENCODE_SERVER_TIMEOUT_MS.
|
|
137
|
-
const DEFAULT_SERVER_START_TIMEOUT_MS = 30_000;
|
|
138
|
-
// Event-stream reconnects before the session gives up on a dead local server.
|
|
139
|
-
// The SDK backs off 3s, 6s, 12s, 24s between attempts, so ~45s in total.
|
|
140
|
-
export const OPENCODE_SSE_MAX_RETRY_ATTEMPTS = 5;
|
|
141
|
-
|
|
142
|
-
function serverStartTimeoutMs(): number {
|
|
143
|
-
return Number(process.env.OPENCODE_SERVER_TIMEOUT_MS) || DEFAULT_SERVER_START_TIMEOUT_MS;
|
|
144
|
-
}
|
|
145
134
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
135
|
+
// The worker image installs `@opencode/sdk` with `npm install -g`. It stays on
|
|
136
|
+
// disk instead of being bundled into the compiled binary because it carries
|
|
137
|
+
// native modules (pty, file watcher, tree-sitter).
|
|
138
|
+
const OPENCODE_SDK_GLOBAL_ROOTS = ["/usr/lib/node_modules", "/usr/local/lib/node_modules"];
|
|
139
|
+
const OPENCODE_SDK_SUBPATH = "@opencode/sdk/dist/index.js";
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `OPENCODE_SDK_PATH` wins, then the global npm roots, then the bare package
|
|
143
|
+
* name (resolved from the repo's node_modules when running from source).
|
|
144
|
+
*/
|
|
145
|
+
export function resolveOpencodeSdkSpecifier(): string {
|
|
146
|
+
const override = process.env.OPENCODE_SDK_PATH;
|
|
147
|
+
if (override) return override;
|
|
148
|
+
for (const root of OPENCODE_SDK_GLOBAL_ROOTS) {
|
|
149
|
+
const candidate = join(root, OPENCODE_SDK_SUBPATH);
|
|
150
|
+
if (existsSync(candidate)) return candidate;
|
|
161
151
|
}
|
|
152
|
+
return "@opencode/sdk";
|
|
162
153
|
}
|
|
163
154
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
155
|
+
type OpencodeSdk = typeof import("@opencode/sdk");
|
|
156
|
+
type LoadedOpencodeSdk = { sdk: OpencodeSdk; version?: string };
|
|
167
157
|
|
|
168
|
-
|
|
169
|
-
|
|
158
|
+
/** The embedded host reports no version of its own; read the SDK's package.json. */
|
|
159
|
+
function readOpencodeSdkVersion(entry: string): string | undefined {
|
|
160
|
+
try {
|
|
161
|
+
const pkg = JSON.parse(readFileSync(join(dirname(entry), "..", "package.json"), "utf8"));
|
|
162
|
+
return typeof pkg.version === "string" ? pkg.version : undefined;
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
170
166
|
}
|
|
171
167
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
// A variable specifier keeps `bun build --compile` from bundling the SDK.
|
|
169
|
+
async function loadOpencodeSdk(): Promise<LoadedOpencodeSdk> {
|
|
170
|
+
const specifier = resolveOpencodeSdkSpecifier();
|
|
171
|
+
const sdk = (await import(specifier)) as OpencodeSdk;
|
|
172
|
+
const entry = specifier.startsWith("/") ? specifier : Bun.resolveSync(specifier, import.meta.dir);
|
|
173
|
+
return { sdk, version: readOpencodeSdkVersion(entry) };
|
|
175
174
|
}
|
|
176
175
|
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
let loadOpencodeSdkImpl = loadOpencodeSdk;
|
|
177
|
+
|
|
178
|
+
export function _setOpencodeSdkLoaderForTests(fn: (() => Promise<LoadedOpencodeSdk>) | null): void {
|
|
179
|
+
loadOpencodeSdkImpl = fn ?? loadOpencodeSdk;
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
/**
|
|
@@ -185,7 +186,7 @@ function formatUnknownError(err: unknown): string {
|
|
|
185
186
|
* place, and attribute direct OpenRouter requests to Agent Swarm.
|
|
186
187
|
*/
|
|
187
188
|
export function applyOpenRouterBaseUrlOverride(
|
|
188
|
-
opencodeConfig:
|
|
189
|
+
opencodeConfig: OpencodeConfig,
|
|
189
190
|
env: Record<string, string | undefined> = process.env,
|
|
190
191
|
): void {
|
|
191
192
|
const openRouterBaseUrl = getOpenRouterBaseUrl(env as NodeJS.ProcessEnv);
|
|
@@ -215,164 +216,132 @@ export function applyOpenRouterBaseUrlOverride(
|
|
|
215
216
|
};
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
242
|
-
readSpawnOutput(proc.stdout),
|
|
243
|
-
readSpawnOutput(proc.stderr),
|
|
244
|
-
proc.exited,
|
|
245
|
-
]).finally(() => clearTimeout(timeout));
|
|
246
|
-
|
|
247
|
-
if (timedOut) {
|
|
248
|
-
throw new Error(
|
|
249
|
-
`opencode models --refresh openrouter timed out after ${MODEL_CACHE_REFRESH_TIMEOUT_MS}ms`,
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
if (exitCode !== 0) {
|
|
253
|
-
const detail = scrubSecrets([stderr.trim(), stdout.trim()].filter(Boolean).join("\n"));
|
|
254
|
-
throw new Error(
|
|
255
|
-
`opencode models --refresh openrouter exited with code ${exitCode}${detail ? `: ${detail}` : ""}`,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
let refreshOpenRouterModelCacheImpl = refreshOpenRouterModelCache;
|
|
219
|
+
const SWARM_AGENT_NAME = "swarm";
|
|
220
|
+
const TRANSCRIPT_TAIL_CHARS = 20_000;
|
|
221
|
+
const TOOL_RESULT_PREVIEW_CHARS = 500;
|
|
222
|
+
|
|
223
|
+
/** The swarm MCP server's name in the OpenCode config. */
|
|
224
|
+
const SWARM_MCP_SERVER = "swarm";
|
|
225
|
+
/** Swarm tools a session cannot report without. OpenCode keys them `swarm_<tool>`. */
|
|
226
|
+
const REQUIRED_SWARM_TOOLS = ["store-progress"];
|
|
227
|
+
const ADMISSION_POLL_MS = 250;
|
|
228
|
+
|
|
229
|
+
type SessionEndHooks = {
|
|
230
|
+
/** Cap on session-end network work; host close and completion never wait longer. */
|
|
231
|
+
budgetMs: number;
|
|
232
|
+
/** Cap on swarm MCP connect plus tool discovery before the first prompt. */
|
|
233
|
+
admissionTimeoutMs: number;
|
|
234
|
+
syncIdentity: typeof syncIdentityFilesToServer;
|
|
235
|
+
};
|
|
236
|
+
const DEFAULT_SESSION_HOOKS: SessionEndHooks = {
|
|
237
|
+
budgetMs: 10_000,
|
|
238
|
+
admissionTimeoutMs: 30_000,
|
|
239
|
+
syncIdentity: syncIdentityFilesToServer,
|
|
240
|
+
};
|
|
241
|
+
let sessionHooks = DEFAULT_SESSION_HOOKS;
|
|
261
242
|
|
|
262
|
-
export function
|
|
263
|
-
|
|
264
|
-
): void {
|
|
265
|
-
refreshOpenRouterModelCacheImpl = fn ?? refreshOpenRouterModelCache;
|
|
243
|
+
export function _setOpencodeSessionHooksForTests(hooks: Partial<SessionEndHooks> | null): void {
|
|
244
|
+
sessionHooks = hooks ? { ...DEFAULT_SESSION_HOOKS, ...hooks } : DEFAULT_SESSION_HOOKS;
|
|
266
245
|
}
|
|
267
246
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
247
|
+
/** Resolve after `promise` settles or `ms` elapses, whichever is first. Never rejects. */
|
|
248
|
+
async function settleWithin(promise: Promise<unknown>, ms: number, signal?: AbortSignal) {
|
|
249
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
250
|
+
const cut = new Promise<void>((resolve) => {
|
|
251
|
+
timer = setTimeout(resolve, ms);
|
|
252
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
253
|
+
});
|
|
254
|
+
try {
|
|
255
|
+
await Promise.race([promise.catch(() => {}), cut]);
|
|
256
|
+
} finally {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
}
|
|
273
259
|
}
|
|
274
260
|
|
|
275
|
-
// context-mode is installed globally via `npm install -g` (Dockerfile.worker),
|
|
276
|
-
// which places it under the npm global modules dir. opencode resolves bare
|
|
277
|
-
// plugin names with `import(await Bun.resolve(name, ...))`, which does NOT walk
|
|
278
|
-
// the npm global dir — a bare "context-mode" entry only resolves if Bun
|
|
279
|
-
// auto-installs from the registry at runtime, which fails on network-sandboxed
|
|
280
|
-
// workers. So we hand opencode the ABSOLUTE path to the package's built
|
|
281
|
-
// opencode-plugin entry, which imports cleanly with no network.
|
|
282
|
-
const CONTEXT_MODE_GLOBAL_ROOTS = ["/usr/lib/node_modules", "/usr/local/lib/node_modules"];
|
|
283
|
-
const CONTEXT_MODE_PLUGIN_SUBPATH = "context-mode/build/adapters/opencode/plugin.js";
|
|
284
|
-
|
|
285
261
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
262
|
+
* Wait until the swarm MCP server is connected and its required tools are
|
|
263
|
+
* registered. OpenCode turns a refused or unauthorized server into a status
|
|
264
|
+
* and keeps going, so without this check a session would run without
|
|
265
|
+
* `store-progress`. Returns null when admitted, else the reason.
|
|
290
266
|
*/
|
|
291
|
-
export function
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
267
|
+
export async function checkSwarmMcpAdmission(
|
|
268
|
+
host: Pick<OpenCode.Interface, "mcp">,
|
|
269
|
+
directory: string,
|
|
270
|
+
listTools: () => OpencodeToolLister | undefined,
|
|
271
|
+
timeoutMs: number,
|
|
272
|
+
isCancelled: () => boolean = () => false,
|
|
273
|
+
): Promise<string | null> {
|
|
274
|
+
const deadline = Date.now() + timeoutMs;
|
|
275
|
+
let last = "swarm MCP server is not registered";
|
|
276
|
+
while (!isCancelled()) {
|
|
277
|
+
const servers = await host.mcp.list({ location: { directory } });
|
|
278
|
+
const status = servers.data.find((server) => server.name === SWARM_MCP_SERVER)?.status;
|
|
279
|
+
if (status?.status === "failed" || status?.status === "needs_auth") {
|
|
280
|
+
return `swarm MCP server ${status.status}: ${status.error}`;
|
|
281
|
+
}
|
|
282
|
+
if (status?.status === "disabled") return "swarm MCP server is disabled";
|
|
283
|
+
if (status?.status === "connected") {
|
|
284
|
+
const lister = listTools();
|
|
285
|
+
const ids = new Set(lister ? (await lister()).map((tool) => tool.id) : []);
|
|
286
|
+
const missing = REQUIRED_SWARM_TOOLS.filter((tool) => !ids.has(`swarm_${tool}`));
|
|
287
|
+
if (missing.length === 0) return null;
|
|
288
|
+
last = `swarm MCP server connected without required tools: ${missing.join(", ")}`;
|
|
289
|
+
} else if (status) {
|
|
290
|
+
last = `swarm MCP server still ${status.status}`;
|
|
291
|
+
}
|
|
292
|
+
if (Date.now() >= deadline) return `${last} after ${timeoutMs}ms`;
|
|
293
|
+
await Bun.sleep(ADMISSION_POLL_MS);
|
|
297
294
|
}
|
|
298
295
|
return null;
|
|
299
296
|
}
|
|
300
297
|
|
|
298
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
299
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
300
|
+
return String((error as { message?: unknown }).message ?? fallback);
|
|
301
|
+
}
|
|
302
|
+
return error === undefined ? fallback : String(error);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function toolResultText(content: ReadonlyArray<{ type: string; text?: string }> | undefined) {
|
|
306
|
+
return (content ?? [])
|
|
307
|
+
.map((part) => (part.type === "text" ? (part.text ?? "") : `[${part.type}]`))
|
|
308
|
+
.join("");
|
|
309
|
+
}
|
|
310
|
+
|
|
301
311
|
export class OpencodeSession implements ProviderSession {
|
|
302
|
-
private _sessionId: string;
|
|
303
312
|
private listeners: Array<(event: ProviderEvent) => void> = [];
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// resolves, but events queued via Promise.resolve().then(...) inside
|
|
307
|
-
// createSession fire on the next microtask — *before* that listener call —
|
|
308
|
-
// so the runner would miss session_init and never PUT /session,
|
|
309
|
-
// leaving agent_tasks.provider/.model NULL. Buffer + flush on first attach.
|
|
313
|
+
// The runner attaches its listener after `createSession` resolves; buffer
|
|
314
|
+
// events (session_init first) until then so none are lost.
|
|
310
315
|
private pendingEvents: ProviderEvent[] = [];
|
|
311
316
|
private completionResolve!: (result: ProviderResult) => void;
|
|
312
|
-
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: reserved for future error-propagation paths; symmetric with completionResolve.
|
|
313
|
-
private completionReject!: (err: Error) => void;
|
|
314
317
|
private completionPromise: Promise<ProviderResult>;
|
|
315
|
-
private client: Awaited<ReturnType<typeof createOpencode>>["client"];
|
|
316
|
-
private server: { url: string; close(): void };
|
|
317
318
|
private aborted = false;
|
|
318
319
|
private completed = false;
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// Keep the latest snapshot per message so those replays do not double-count.
|
|
322
|
-
private finalizedMessages = new Map<string, FinalizedMessageUsage>();
|
|
323
|
-
private missingMessageIdCounter = 0;
|
|
324
|
-
// Assistant text for the runner's final-message capture (`trackAssistantText`),
|
|
325
|
-
// which is how codex tasks with an outputSchema get their final message
|
|
326
|
-
// validated. Text parts stream in around the message's own updates, so buffer
|
|
327
|
-
// the latest snapshot per part and emit per message once its role is known.
|
|
328
|
-
private textParts = new Map<string, Map<string, string>>();
|
|
329
|
-
private assistantMessageIds = new Set<string>();
|
|
330
|
-
private emittedText = new Map<string, string>();
|
|
320
|
+
// Cancels session-end network work when the session is aborted mid-finish.
|
|
321
|
+
private readonly sessionEnd = new AbortController();
|
|
331
322
|
private startedAt = Date.now();
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
private
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
private
|
|
339
|
-
private
|
|
340
|
-
|
|
341
|
-
private
|
|
342
|
-
|
|
343
|
-
private appliedReasoningEffort: ReasoningEffort | null;
|
|
344
|
-
|
|
345
|
-
// Track which tool callIDs have already emitted tool_start, so transitions
|
|
346
|
-
// through pending → running → completed don't fire duplicate events.
|
|
347
|
-
private toolStartsEmitted = new Set<string>();
|
|
323
|
+
|
|
324
|
+
// `session.usage.updated` carries the cumulative session totals.
|
|
325
|
+
private usage = {
|
|
326
|
+
cost: 0,
|
|
327
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
328
|
+
};
|
|
329
|
+
private steps = 0;
|
|
330
|
+
private toolNames = new Map<string, string>();
|
|
331
|
+
// Assistant text by message, in part order; the runner keeps the last emit.
|
|
332
|
+
private textByMessage = new Map<string, string[]>();
|
|
333
|
+
private transcript: string[] = [];
|
|
348
334
|
|
|
349
335
|
constructor(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
agentFilePath: string,
|
|
357
|
-
configFilePath: string,
|
|
358
|
-
dataHomePath: string,
|
|
359
|
-
retryAfterModelRefresh?: () => Promise<boolean>,
|
|
360
|
-
appliedReasoningEffort: ReasoningEffort | null = null,
|
|
336
|
+
private readonly host: OpenCode.Interface,
|
|
337
|
+
private readonly _sessionId: string,
|
|
338
|
+
private model: string,
|
|
339
|
+
private readonly swarm: SwarmHooksConfig,
|
|
340
|
+
private readonly cwd: string,
|
|
341
|
+
private readonly appliedReasoningEffort: ReasoningEffort | null = null,
|
|
361
342
|
) {
|
|
362
|
-
this.
|
|
363
|
-
this.client = client;
|
|
364
|
-
this.server = server;
|
|
365
|
-
this.model = model;
|
|
366
|
-
this.agentId = agentId;
|
|
367
|
-
this.taskId = taskId;
|
|
368
|
-
this.agentFilePath = agentFilePath;
|
|
369
|
-
this.configFilePath = configFilePath;
|
|
370
|
-
this.dataHomePath = dataHomePath;
|
|
371
|
-
this.retryAfterModelRefresh = retryAfterModelRefresh;
|
|
372
|
-
this.appliedReasoningEffort = appliedReasoningEffort;
|
|
373
|
-
this.completionPromise = new Promise<ProviderResult>((resolve, reject) => {
|
|
343
|
+
this.completionPromise = new Promise<ProviderResult>((resolve) => {
|
|
374
344
|
this.completionResolve = resolve;
|
|
375
|
-
this.completionReject = reject;
|
|
376
345
|
});
|
|
377
346
|
}
|
|
378
347
|
|
|
@@ -384,13 +353,11 @@ export class OpencodeSession implements ProviderSession {
|
|
|
384
353
|
return this.completed;
|
|
385
354
|
}
|
|
386
355
|
|
|
387
|
-
|
|
388
|
-
* after construction; buffers if no listener is attached yet. */
|
|
389
|
-
emitSessionInit(provider: "opencode", harnessVariantMeta?: Record<string, unknown>): void {
|
|
356
|
+
emitSessionInit(harnessVariantMeta?: Record<string, unknown>): void {
|
|
390
357
|
this.emit({
|
|
391
358
|
type: "session_init",
|
|
392
359
|
sessionId: this._sessionId,
|
|
393
|
-
provider,
|
|
360
|
+
provider: "opencode",
|
|
394
361
|
harnessVariant: "stock",
|
|
395
362
|
...(harnessVariantMeta ? { harnessVariantMeta } : {}),
|
|
396
363
|
});
|
|
@@ -400,6 +367,10 @@ export class OpencodeSession implements ProviderSession {
|
|
|
400
367
|
this.emit(event);
|
|
401
368
|
}
|
|
402
369
|
|
|
370
|
+
recordUserPrompt(text: string): void {
|
|
371
|
+
this.transcript.push(`[user]: ${text}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
403
374
|
onEvent(listener: (event: ProviderEvent) => void): void {
|
|
404
375
|
const wasEmpty = this.listeners.length === 0;
|
|
405
376
|
this.listeners.push(listener);
|
|
@@ -410,19 +381,6 @@ export class OpencodeSession implements ProviderSession {
|
|
|
410
381
|
}
|
|
411
382
|
}
|
|
412
383
|
|
|
413
|
-
private emit(event: ProviderEvent): void {
|
|
414
|
-
if (this.listeners.length === 0) {
|
|
415
|
-
this.pendingEvents.push(event);
|
|
416
|
-
} else {
|
|
417
|
-
for (const l of this.listeners) l(event);
|
|
418
|
-
}
|
|
419
|
-
// Also emit a raw_log for every event (scrubbed)
|
|
420
|
-
if (event.type !== "raw_log") {
|
|
421
|
-
const raw = scrubSecrets(JSON.stringify(event));
|
|
422
|
-
this.emitDirect({ type: "raw_log", content: raw });
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
384
|
private emitDirect(event: ProviderEvent): void {
|
|
427
385
|
if (this.listeners.length === 0) {
|
|
428
386
|
this.pendingEvents.push(event);
|
|
@@ -431,294 +389,184 @@ export class OpencodeSession implements ProviderSession {
|
|
|
431
389
|
for (const l of this.listeners) l(event);
|
|
432
390
|
}
|
|
433
391
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
});
|
|
392
|
+
/** Emit `event` plus its scrubbed `raw_log` mirror. */
|
|
393
|
+
private emit(event: ProviderEvent): void {
|
|
394
|
+
this.emitDirect(event);
|
|
395
|
+
this.emitDirect({ type: "raw_log", content: scrubSecrets(JSON.stringify(event)) });
|
|
439
396
|
}
|
|
440
397
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
398
|
+
/** Process one OpenCode 2 event. */
|
|
399
|
+
handleOpencodeEvent(ev: OpenCodeEvent): void {
|
|
400
|
+
if (this.aborted || this.completed) return;
|
|
444
401
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
this.emitError(message);
|
|
402
|
+
if (ev.type === "filesystem.changed") {
|
|
403
|
+
this.onFileChanged(ev.data.file);
|
|
448
404
|
return;
|
|
449
405
|
}
|
|
450
|
-
|
|
451
|
-
this.
|
|
452
|
-
this.retryAfterModelRefresh()
|
|
453
|
-
.then((retried) => {
|
|
454
|
-
if (!retried) this.emitError(message);
|
|
455
|
-
})
|
|
456
|
-
.catch((err: unknown) => {
|
|
457
|
-
this.emitModelCacheRefreshFailure(message, err);
|
|
458
|
-
});
|
|
459
|
-
}
|
|
406
|
+
const data = (ev as { data?: { sessionID?: string } }).data;
|
|
407
|
+
if (data?.sessionID !== this._sessionId) return;
|
|
460
408
|
|
|
461
|
-
|
|
462
|
-
private async cleanupFiles(): Promise<void> {
|
|
463
|
-
try {
|
|
464
|
-
await Bun.$`rm -f ${this.agentFilePath}`.quiet().nothrow();
|
|
465
|
-
} catch {
|
|
466
|
-
// best-effort
|
|
467
|
-
}
|
|
468
|
-
try {
|
|
469
|
-
await Bun.$`rm -f ${this.configFilePath}`.quiet().nothrow();
|
|
470
|
-
} catch {
|
|
471
|
-
// best-effort
|
|
472
|
-
}
|
|
473
|
-
try {
|
|
474
|
-
await Bun.$`rm -rf ${this.dataHomePath}`.quiet().nothrow();
|
|
475
|
-
} catch {
|
|
476
|
-
// best-effort
|
|
477
|
-
}
|
|
478
|
-
}
|
|
409
|
+
this.emitDirect({ type: "raw_log", content: scrubSecrets(JSON.stringify(ev)) });
|
|
479
410
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
411
|
+
switch (ev.type) {
|
|
412
|
+
case "session.step.started": {
|
|
413
|
+
// OpenCode 2 runs its default model when the configured one is not in
|
|
414
|
+
// its catalog, with no error event. Fail instead of billing the task
|
|
415
|
+
// to a model nobody asked for.
|
|
416
|
+
const actual = `${ev.data.model.providerID}/${ev.data.model.id}`;
|
|
417
|
+
if (this.model.includes("/") && actual !== this.model) {
|
|
418
|
+
this.fail(
|
|
419
|
+
`opencode ran ${actual} instead of the requested model ${this.model}; the requested model is not in OpenCode's catalog`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
483
424
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
425
|
+
case "session.tool.input.started":
|
|
426
|
+
this.toolNames.set(ev.data.id, ev.data.name);
|
|
427
|
+
break;
|
|
487
428
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
outputTokens: msg.tokens?.output ?? 0,
|
|
514
|
-
reasoningOutputTokens: msg.tokens?.reasoning ?? 0,
|
|
515
|
-
cacheReadTokens: msg.tokens?.cache?.read ?? 0,
|
|
516
|
-
cacheWriteTokens: msg.tokens?.cache?.write ?? 0,
|
|
429
|
+
case "session.tool.called": {
|
|
430
|
+
const toolName = this.toolNames.get(ev.data.id) ?? "tool";
|
|
431
|
+
this.transcript.push(`[tool:${toolName}]: ${JSON.stringify(ev.data.input)}`);
|
|
432
|
+
this.emit({
|
|
433
|
+
type: "tool_start",
|
|
434
|
+
toolCallId: ev.data.id,
|
|
435
|
+
toolName,
|
|
436
|
+
args: ev.data.input,
|
|
437
|
+
});
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
case "session.tool.success":
|
|
442
|
+
case "session.tool.failed": {
|
|
443
|
+
const isError = ev.type === "session.tool.failed";
|
|
444
|
+
const result = isError
|
|
445
|
+
? errorMessage(ev.data.error, "tool failed")
|
|
446
|
+
: toolResultText(ev.data.content);
|
|
447
|
+
this.transcript.push(`[tool result]: ${result.slice(0, TOOL_RESULT_PREVIEW_CHARS)}`);
|
|
448
|
+
this.emit({
|
|
449
|
+
type: "tool_end",
|
|
450
|
+
toolCallId: ev.data.id,
|
|
451
|
+
toolName: this.toolNames.get(ev.data.id) ?? "tool",
|
|
452
|
+
result,
|
|
453
|
+
isError,
|
|
517
454
|
});
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case "session.text.ended": {
|
|
459
|
+
const parts = this.textByMessage.get(ev.data.assistantMessageID) ?? [];
|
|
460
|
+
parts[ev.data.ordinal] = ev.data.text;
|
|
461
|
+
this.textByMessage.set(ev.data.assistantMessageID, parts);
|
|
462
|
+
const text = parts.join("").trim();
|
|
463
|
+
if (!text) break;
|
|
464
|
+
this.transcript.push(`[assistant]: ${ev.data.text}`);
|
|
465
|
+
this.emit({
|
|
466
|
+
type: "message",
|
|
467
|
+
role: "assistant",
|
|
468
|
+
content: text,
|
|
469
|
+
messageId: ev.data.assistantMessageID,
|
|
470
|
+
});
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
case "session.step.ended": {
|
|
475
|
+
this.steps++;
|
|
476
|
+
// Per-step usage drives the dashboard's context gauge.
|
|
477
|
+
const t = ev.data.tokens;
|
|
478
|
+
const contextUsed = t.input + t.cache.read + t.cache.write + t.output;
|
|
479
|
+
const contextTotal = getContextWindowSize(this.model || "default");
|
|
537
480
|
if (contextTotal > 0 && contextUsed > 0) {
|
|
538
481
|
this.emit({
|
|
539
482
|
type: "context_usage",
|
|
540
483
|
contextUsedTokens: contextUsed,
|
|
541
484
|
contextTotalTokens: contextTotal,
|
|
542
|
-
// Phase 8: clamp so a turn that briefly overshoots (e.g. due to
|
|
543
|
-
// a stale total) doesn't render as a 130% gauge in the UI.
|
|
544
485
|
contextPercent: clampContextPercent(contextUsed, contextTotal) ?? 0,
|
|
545
|
-
outputTokens:
|
|
486
|
+
outputTokens: t.output,
|
|
546
487
|
contextFormula: CONTEXT_FORMULA,
|
|
547
488
|
});
|
|
548
489
|
}
|
|
549
490
|
break;
|
|
550
491
|
}
|
|
551
492
|
|
|
552
|
-
case "
|
|
553
|
-
|
|
554
|
-
// (checked in `recordTextPart`), not by the tool branch's check below.
|
|
555
|
-
if (ev.properties.part?.type === "text") {
|
|
556
|
-
this.recordTextPart(ev.properties.part);
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
|
-
// Bridge opencode's part.state lifecycle to swarm's tool_start/tool_end
|
|
560
|
-
// so the dashboard's Activity timeline mirrors what other providers
|
|
561
|
-
// emit. We fire tool_start the first time we see a tool part (any
|
|
562
|
-
// status); tool_end fires once when state transitions to "completed".
|
|
563
|
-
const props = (ev as { properties: { sessionID?: string; part?: unknown } }).properties;
|
|
564
|
-
if (props.sessionID !== this._sessionId) break;
|
|
565
|
-
const part = props.part as
|
|
566
|
-
| {
|
|
567
|
-
type?: string;
|
|
568
|
-
tool?: string;
|
|
569
|
-
callID?: string;
|
|
570
|
-
id?: string;
|
|
571
|
-
state?: { status?: string; input?: unknown; output?: unknown };
|
|
572
|
-
}
|
|
573
|
-
| undefined;
|
|
574
|
-
if (!part || part.type !== "tool") break;
|
|
575
|
-
const callId = part.callID ?? part.id ?? "";
|
|
576
|
-
if (!callId) break;
|
|
577
|
-
const toolName = part.tool ?? "tool";
|
|
578
|
-
|
|
579
|
-
if (!this.toolStartsEmitted.has(callId)) {
|
|
580
|
-
this.toolStartsEmitted.add(callId);
|
|
581
|
-
this.emit({
|
|
582
|
-
type: "tool_start",
|
|
583
|
-
toolCallId: callId,
|
|
584
|
-
toolName,
|
|
585
|
-
args: part.state?.input,
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
if (part.state?.status === "completed") {
|
|
589
|
-
this.emit({
|
|
590
|
-
type: "tool_end",
|
|
591
|
-
toolCallId: callId,
|
|
592
|
-
toolName,
|
|
593
|
-
result: part.state.output,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
493
|
+
case "session.usage.updated":
|
|
494
|
+
this.usage = { cost: ev.data.cost, tokens: ev.data.tokens };
|
|
596
495
|
break;
|
|
597
|
-
}
|
|
598
496
|
|
|
599
|
-
case "session.
|
|
600
|
-
if (ev.properties.sessionID !== this._sessionId) break;
|
|
497
|
+
case "session.execution.succeeded": {
|
|
601
498
|
const cost = this.buildCostData(false);
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
cost,
|
|
605
|
-
isError: false,
|
|
606
|
-
};
|
|
607
|
-
// Emit result (raw_log will be auto-emitted via emit())
|
|
608
|
-
for (const l of this.listeners) l(resultEvent);
|
|
609
|
-
const raw = scrubSecrets(JSON.stringify(resultEvent));
|
|
610
|
-
this.emitDirect({ type: "raw_log", content: raw });
|
|
611
|
-
void this.finish({
|
|
612
|
-
exitCode: 0,
|
|
613
|
-
sessionId: this._sessionId,
|
|
614
|
-
cost,
|
|
615
|
-
isError: false,
|
|
616
|
-
});
|
|
499
|
+
this.emit({ type: "result", cost, isError: false });
|
|
500
|
+
void this.finish({ exitCode: 0, sessionId: this._sessionId, cost, isError: false });
|
|
617
501
|
break;
|
|
618
502
|
}
|
|
619
503
|
|
|
620
|
-
case "session.
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
: "opencode session error";
|
|
627
|
-
if (isModelNotFoundError(errMsg)) {
|
|
628
|
-
this.recoverFromModelNotFound(errMsg);
|
|
629
|
-
break;
|
|
630
|
-
}
|
|
631
|
-
this.emitError(errMsg);
|
|
504
|
+
case "session.execution.failed":
|
|
505
|
+
this.fail(errorMessage(ev.data.error, "opencode session error"));
|
|
506
|
+
break;
|
|
507
|
+
|
|
508
|
+
case "session.execution.interrupted":
|
|
509
|
+
this.fail("opencode session was interrupted");
|
|
632
510
|
break;
|
|
633
|
-
}
|
|
634
511
|
|
|
635
|
-
case "permission.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
`Permission request received in headless mode (id=${ev.properties.id}); aborting session`,
|
|
512
|
+
case "permission.asked":
|
|
513
|
+
// A headless worker cannot answer a permission prompt.
|
|
514
|
+
this.fail(
|
|
515
|
+
`Permission request received in headless mode (id=${ev.data.id}); aborting session`,
|
|
640
516
|
);
|
|
641
517
|
break;
|
|
642
|
-
}
|
|
643
518
|
|
|
644
519
|
default:
|
|
645
520
|
break;
|
|
646
521
|
}
|
|
647
522
|
}
|
|
648
523
|
|
|
649
|
-
/**
|
|
650
|
-
private
|
|
651
|
-
|
|
652
|
-
if (
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
524
|
+
/** Same file triggers the claude and pi hooks act on. */
|
|
525
|
+
private onFileChanged(file: string): void {
|
|
526
|
+
const path = file.startsWith("/") ? file : join(this.cwd, file);
|
|
527
|
+
if (
|
|
528
|
+
path === "/workspace/SOUL.md" ||
|
|
529
|
+
path === "/workspace/IDENTITY.md" ||
|
|
530
|
+
path === "/workspace/TOOLS.md" ||
|
|
531
|
+
path === "/workspace/CLAUDE.md"
|
|
532
|
+
) {
|
|
533
|
+
void syncIdentityFilesToServer(this.swarm, "self_edit");
|
|
534
|
+
}
|
|
535
|
+
if (
|
|
536
|
+
path.startsWith("/workspace/personal/memory/") ||
|
|
537
|
+
path.startsWith("/workspace/shared/memory/")
|
|
538
|
+
) {
|
|
539
|
+
void autoIndexMemoryFile(this.swarm, path);
|
|
658
540
|
}
|
|
659
|
-
parts.set(part.id, part.text ?? "");
|
|
660
|
-
// A part can land after its message was finalized; re-emit the fuller text.
|
|
661
|
-
if (this.finalizedMessages.has(part.messageID)) this.emitAssistantText(part.messageID);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Emit an assistant message's text as a normal `message` event. Only assistant
|
|
666
|
-
* messages qualify, and unchanged text is not re-emitted (opencode replays
|
|
667
|
-
* finalized snapshots). The runner keeps the last non-empty one.
|
|
668
|
-
*/
|
|
669
|
-
private emitAssistantText(messageId: string): void {
|
|
670
|
-
if (!this.assistantMessageIds.has(messageId)) return;
|
|
671
|
-
const parts = this.textParts.get(messageId);
|
|
672
|
-
if (!parts) return;
|
|
673
|
-
const text = [...parts.values()].join("").trim();
|
|
674
|
-
if (!text || this.emittedText.get(messageId) === text) return;
|
|
675
|
-
this.emittedText.set(messageId, text);
|
|
676
|
-
this.emit({ type: "message", role: "assistant", content: text, messageId });
|
|
677
541
|
}
|
|
678
542
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
this.emitDirect({ type: "raw_log", content: raw });
|
|
684
|
-
const cost = this.buildCostData(true);
|
|
543
|
+
/** Settle the session as failed; also used when the event stream dies. */
|
|
544
|
+
fail(message: string): void {
|
|
545
|
+
if (this.aborted || this.completed) return;
|
|
546
|
+
this.emit({ type: "error", message });
|
|
685
547
|
void this.finish({
|
|
686
548
|
exitCode: 1,
|
|
687
549
|
sessionId: this._sessionId,
|
|
688
|
-
cost,
|
|
550
|
+
cost: this.buildCostData(true),
|
|
689
551
|
isError: true,
|
|
690
552
|
failureReason: message,
|
|
691
553
|
});
|
|
692
554
|
}
|
|
693
555
|
|
|
694
556
|
private buildCostData(isError: boolean): CostData {
|
|
695
|
-
|
|
696
|
-
let inputTokens = 0;
|
|
697
|
-
let outputTokens = 0;
|
|
698
|
-
let reasoningOutputTokens = 0;
|
|
699
|
-
let cacheReadTokens = 0;
|
|
700
|
-
let cacheWriteTokens = 0;
|
|
701
|
-
for (const message of this.finalizedMessages.values()) {
|
|
702
|
-
totalCostUsd += message.cost;
|
|
703
|
-
inputTokens += message.inputTokens;
|
|
704
|
-
outputTokens += message.outputTokens;
|
|
705
|
-
reasoningOutputTokens += message.reasoningOutputTokens;
|
|
706
|
-
cacheReadTokens += message.cacheReadTokens;
|
|
707
|
-
cacheWriteTokens += message.cacheWriteTokens;
|
|
708
|
-
}
|
|
709
|
-
|
|
557
|
+
const { cost, tokens } = this.usage;
|
|
710
558
|
return {
|
|
711
559
|
sessionId: this._sessionId,
|
|
712
|
-
taskId: this.taskId,
|
|
713
|
-
agentId: this.agentId,
|
|
714
|
-
totalCostUsd,
|
|
715
|
-
inputTokens,
|
|
716
|
-
outputTokens,
|
|
717
|
-
reasoningOutputTokens,
|
|
718
|
-
cacheReadTokens,
|
|
719
|
-
cacheWriteTokens,
|
|
560
|
+
taskId: this.swarm.taskId,
|
|
561
|
+
agentId: this.swarm.agentId,
|
|
562
|
+
totalCostUsd: cost,
|
|
563
|
+
inputTokens: tokens.input,
|
|
564
|
+
outputTokens: tokens.output,
|
|
565
|
+
reasoningOutputTokens: tokens.reasoning,
|
|
566
|
+
cacheReadTokens: tokens.cache.read,
|
|
567
|
+
cacheWriteTokens: tokens.cache.write,
|
|
720
568
|
durationMs: Date.now() - this.startedAt,
|
|
721
|
-
numTurns: this.
|
|
569
|
+
numTurns: this.steps,
|
|
722
570
|
model: this.model,
|
|
723
571
|
isError,
|
|
724
572
|
provider: "opencode",
|
|
@@ -729,21 +577,51 @@ export class OpencodeSession implements ProviderSession {
|
|
|
729
577
|
return this.completionPromise;
|
|
730
578
|
}
|
|
731
579
|
|
|
580
|
+
/** Identity sync, session summary, and session close (Stop-hook parity). */
|
|
581
|
+
private async runSessionEnd(signal: AbortSignal): Promise<void> {
|
|
582
|
+
await sessionHooks.syncIdentity(this.swarm, "session_sync", signal);
|
|
583
|
+
if (process.env.SKIP_SESSION_SUMMARY) {
|
|
584
|
+
console.debug("session_summary skipped (opencode): SKIP_SESSION_SUMMARY is set");
|
|
585
|
+
} else {
|
|
586
|
+
const transcript = this.transcript.join("\n").slice(-TRANSCRIPT_TAIL_CHARS);
|
|
587
|
+
void summarizeTranscript(this.swarm, "opencode", transcript);
|
|
588
|
+
}
|
|
589
|
+
fireAndForget(`${this.swarm.apiUrl}/api/sessions/${this._sessionId}/close`, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: apiHeaders(this.swarm),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Settle the session. Session-end work is bounded and cancellable, so the
|
|
597
|
+
* host always closes and completion always resolves.
|
|
598
|
+
*/
|
|
732
599
|
private async finish(result: ProviderResult): Promise<void> {
|
|
733
600
|
if (this.completed) return;
|
|
734
601
|
this.completed = true;
|
|
602
|
+
const { signal } = this.sessionEnd;
|
|
735
603
|
try {
|
|
736
|
-
this.
|
|
737
|
-
|
|
738
|
-
|
|
604
|
+
if (!this.aborted) {
|
|
605
|
+
await settleWithin(this.runSessionEnd(signal), sessionHooks.budgetMs, signal);
|
|
606
|
+
}
|
|
607
|
+
} finally {
|
|
608
|
+
this.sessionEnd.abort();
|
|
609
|
+
await settleWithin(this.host.close(), sessionHooks.budgetMs);
|
|
610
|
+
this.completionResolve({ ...result, appliedReasoningEffort: this.appliedReasoningEffort });
|
|
739
611
|
}
|
|
740
|
-
await this.cleanupFiles();
|
|
741
|
-
this.completionResolve({ ...result, appliedReasoningEffort: this.appliedReasoningEffort });
|
|
742
612
|
}
|
|
743
613
|
|
|
744
614
|
async abort(): Promise<void> {
|
|
745
615
|
if (this.aborted) return;
|
|
616
|
+
if (this.completed) {
|
|
617
|
+
// Already finishing: cut the session-end work short and wait for cleanup.
|
|
618
|
+
this.aborted = true;
|
|
619
|
+
this.sessionEnd.abort();
|
|
620
|
+
await this.completionPromise;
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
746
623
|
this.aborted = true;
|
|
624
|
+
await this.host.sessions.interrupt({ sessionID: this._sessionId }).catch(() => {});
|
|
747
625
|
await this.finish({
|
|
748
626
|
exitCode: 1,
|
|
749
627
|
sessionId: this._sessionId,
|
|
@@ -754,23 +632,16 @@ export class OpencodeSession implements ProviderSession {
|
|
|
754
632
|
|
|
755
633
|
async deliverSteering({ mode, text }: SteerDelivery): Promise<SteerDeliveryResult> {
|
|
756
634
|
if (this.completed || this.aborted) {
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
// observes while falsely reporting `delivered`. Fail closed so the
|
|
760
|
-
// server promotes the message to a follow-up task (same as pi).
|
|
635
|
+
// The host is closed; fail closed so the server promotes the message
|
|
636
|
+
// to a follow-up task (same as pi).
|
|
761
637
|
return { delivered: false, reason: "opencode session already completed" };
|
|
762
638
|
}
|
|
763
639
|
try {
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
// was lost to the undeliverable path. `steerModes` advertises queue only,
|
|
767
|
-
// so a "steer" request has already been degraded upstream — this branch
|
|
768
|
-
// exists for a caller that reaches us anyway, and reports queue honestly.
|
|
640
|
+
// `steerModes` advertises queue only, so a "steer" request has already
|
|
641
|
+
// been degraded upstream. Say so explicitly: OpenCode 2 defaults to steer.
|
|
769
642
|
void mode;
|
|
770
|
-
await this.
|
|
771
|
-
|
|
772
|
-
body: { parts: [{ type: "text", text }] },
|
|
773
|
-
});
|
|
643
|
+
await this.host.sessions.prompt({ sessionID: this._sessionId, text, delivery: "queue" });
|
|
644
|
+
this.recordUserPrompt(text);
|
|
774
645
|
return { delivered: true, mode: "queue" };
|
|
775
646
|
} catch (err) {
|
|
776
647
|
return { delivered: false, reason: String(err) };
|
|
@@ -797,18 +668,7 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
797
668
|
|
|
798
669
|
async createSession(config: ProviderSessionConfig): Promise<ProviderSession> {
|
|
799
670
|
const taskId = config.taskId;
|
|
800
|
-
const
|
|
801
|
-
const agentFilePath = join(config.cwd, ".opencode", "agents", `${agentName}.md`);
|
|
802
|
-
const configFilePath = `/tmp/opencode-${taskId}.json`;
|
|
803
|
-
const dataHomePath = `/tmp/opencode-data-${taskId}`;
|
|
804
|
-
|
|
805
|
-
// Write per-task agent file (best-effort; contains the system prompt)
|
|
806
|
-
try {
|
|
807
|
-
mkdirSync(join(config.cwd, ".opencode", "agents"), { recursive: true });
|
|
808
|
-
await Bun.write(agentFilePath, config.systemPrompt ?? "");
|
|
809
|
-
} catch {
|
|
810
|
-
// best-effort
|
|
811
|
-
}
|
|
671
|
+
const env = config.env ?? process.env;
|
|
812
672
|
|
|
813
673
|
// Build MCP config: swarm endpoint + installed MCP servers
|
|
814
674
|
const installedMcp =
|
|
@@ -818,17 +678,15 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
818
678
|
// tools require it in multi-runtime mode, and it travels as request
|
|
819
679
|
// context, not as a tool argument.
|
|
820
680
|
const runtimeInstanceId = swarmRuntimeInstanceId();
|
|
821
|
-
const
|
|
681
|
+
const mcp: Record<string, unknown> = {
|
|
822
682
|
swarm: {
|
|
823
683
|
type: "remote",
|
|
824
684
|
url: `${config.apiUrl}/mcp`,
|
|
825
685
|
headers: {
|
|
826
686
|
Authorization: `Bearer ${config.apiKey}`,
|
|
827
687
|
"X-Agent-ID": config.agentId,
|
|
828
|
-
// Per-task identity, same as claude/codex
|
|
829
|
-
//
|
|
830
|
-
// copy a sibling's or parent's id from the prompt. The config file
|
|
831
|
-
// and `opencode serve` are already per task, so this is not shared.
|
|
688
|
+
// Per-task identity, same as claude/codex: gives `store-progress` a
|
|
689
|
+
// default task. The host is per task, so this is not shared.
|
|
832
690
|
"X-Source-Task-Id": taskId,
|
|
833
691
|
...(config.contextKey ? { "X-Context-Key": config.contextKey } : {}),
|
|
834
692
|
...(runtimeInstanceId ? { "X-Runtime-Instance-ID": runtimeInstanceId } : {}),
|
|
@@ -836,45 +694,20 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
836
694
|
},
|
|
837
695
|
...installedMcp,
|
|
838
696
|
};
|
|
839
|
-
|
|
840
|
-
//
|
|
841
|
-
// 1. OPENCODE_SWARM_PLUGIN_PATH env (explicit override)
|
|
842
|
-
// 2. The well-known Docker location (Dockerfile.worker COPYs the plugin
|
|
843
|
-
// to /home/worker/.config/opencode/plugins/agent-swarm.ts)
|
|
844
|
-
// 3. The dev path relative to this source file
|
|
845
|
-
// The previous one-liner used `import.meta.dir` only, which resolves to
|
|
846
|
-
// `/usr/local/bin` for the bundled binary and produced the non-existent
|
|
847
|
-
// `/plugin/opencode-plugins/agent-swarm.ts`. Docker only worked because
|
|
848
|
-
// opencode auto-discovers plugins from ~/.config/opencode/plugins/ —
|
|
849
|
-
// an accident, not a contract.
|
|
850
|
-
const pluginPath = resolvePluginPath();
|
|
851
|
-
|
|
852
|
-
// context-mode ships as an in-process opencode plugin (NOT an MCP server).
|
|
853
|
-
// Its built plugin entry registers both the native ctx_* tools and the 5
|
|
854
|
-
// hook surrogates. It must NOT also appear in the `mcp` block — dual
|
|
855
|
-
// registration yields zero tools. We push the ABSOLUTE path to the globally
|
|
856
|
-
// installed package's opencode plugin entry, not the bare name (see
|
|
857
|
-
// resolveContextModePluginPath for why a bare name fails to resolve offline).
|
|
858
|
-
// Gated by CONTEXT_MODE_DISABLED so builds/deploys without it opt out.
|
|
859
|
-
const plugins = [pluginPath];
|
|
697
|
+
// context-mode ships only a V1 opencode plugin, which OpenCode 2 cannot
|
|
698
|
+
// load, so wire its stdio MCP server instead (same as codex).
|
|
860
699
|
if (!isEnvFlagEnabled("CONTEXT_MODE_DISABLED", false)) {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
"[opencode] context-mode is enabled but its opencode plugin entry was not found on disk; " +
|
|
867
|
-
"skipping it for this session. Set CONTEXT_MODE_OPENCODE_PLUGIN_PATH to override, or " +
|
|
868
|
-
"CONTEXT_MODE_DISABLED=true to silence.",
|
|
869
|
-
);
|
|
870
|
-
}
|
|
700
|
+
mcp["context-mode"] = {
|
|
701
|
+
type: "local",
|
|
702
|
+
command: ["context-mode"],
|
|
703
|
+
environment: { CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY: CTX_MODE_NUDGE_EVERY },
|
|
704
|
+
};
|
|
871
705
|
}
|
|
872
706
|
|
|
873
|
-
|
|
874
|
-
const opencodeConfig: Config & { plugin?: string[] } = {
|
|
707
|
+
const opencodeConfig: OpencodeConfig = {
|
|
875
708
|
$schema: "https://opencode.ai/config.json",
|
|
876
709
|
model: config.model,
|
|
877
|
-
mcp
|
|
710
|
+
mcp,
|
|
878
711
|
permission: {
|
|
879
712
|
edit: "allow",
|
|
880
713
|
bash: "allow",
|
|
@@ -882,13 +715,16 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
882
715
|
doom_loop: "allow",
|
|
883
716
|
external_directory: "allow",
|
|
884
717
|
},
|
|
885
|
-
|
|
718
|
+
// The system prompt lives on a config-defined agent; nothing is written
|
|
719
|
+
// into the task's working directory.
|
|
720
|
+
agent: {
|
|
721
|
+
[SWARM_AGENT_NAME]: { mode: "primary", prompt: config.systemPrompt ?? "" },
|
|
722
|
+
},
|
|
886
723
|
};
|
|
887
724
|
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
// provider's own default applies.
|
|
725
|
+
// Splice the provider-keyed reasoning options into
|
|
726
|
+
// `provider[providerId].models[modelId].options`. `off` (and any
|
|
727
|
+
// capability-rejected level) resolves to `noop`.
|
|
892
728
|
const reasoningApplication = applyReasoningEffort(
|
|
893
729
|
"opencode",
|
|
894
730
|
config.model,
|
|
@@ -898,225 +734,108 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
898
734
|
reasoningApplication.kind === "opencode-options" ? (config.reasoningEffort ?? null) : null;
|
|
899
735
|
if (reasoningApplication.kind === "opencode-options") {
|
|
900
736
|
const { providerId, modelId, options } = reasoningApplication;
|
|
737
|
+
const provider = opencodeConfig.provider?.[providerId];
|
|
901
738
|
opencodeConfig.provider = {
|
|
902
739
|
...opencodeConfig.provider,
|
|
903
740
|
[providerId]: {
|
|
904
|
-
...
|
|
741
|
+
...provider,
|
|
905
742
|
models: {
|
|
906
|
-
...
|
|
743
|
+
...provider?.models,
|
|
907
744
|
[modelId]: {
|
|
908
|
-
...
|
|
909
|
-
options: {
|
|
910
|
-
...opencodeConfig.provider?.[providerId]?.models?.[modelId]?.options,
|
|
911
|
-
...options,
|
|
912
|
-
},
|
|
745
|
+
...provider?.models?.[modelId],
|
|
746
|
+
options: { ...provider?.models?.[modelId]?.options, ...options },
|
|
913
747
|
},
|
|
914
748
|
},
|
|
915
749
|
},
|
|
916
750
|
};
|
|
917
751
|
}
|
|
918
752
|
|
|
919
|
-
applyOpenRouterBaseUrlOverride(opencodeConfig,
|
|
920
|
-
|
|
921
|
-
// Write per-task config file
|
|
922
|
-
try {
|
|
923
|
-
await Bun.write(configFilePath, JSON.stringify(opencodeConfig, null, 2));
|
|
924
|
-
} catch {
|
|
925
|
-
// best-effort
|
|
926
|
-
}
|
|
753
|
+
applyOpenRouterBaseUrlOverride(opencodeConfig, env);
|
|
927
754
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
SWARM_AGENT_ID: process.env.SWARM_AGENT_ID,
|
|
936
|
-
SWARM_TASK_ID: process.env.SWARM_TASK_ID,
|
|
937
|
-
SWARM_IS_LEAD: process.env.SWARM_IS_LEAD,
|
|
938
|
-
OPENROUTER_BASE_URL: process.env.OPENROUTER_BASE_URL,
|
|
755
|
+
const swarm: SwarmHooksConfig = {
|
|
756
|
+
apiUrl: config.apiUrl,
|
|
757
|
+
apiKey: config.apiKey,
|
|
758
|
+
agentId: config.agentId,
|
|
759
|
+
taskId,
|
|
760
|
+
isLead: config.role === "lead",
|
|
761
|
+
env: config.env,
|
|
939
762
|
};
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
}
|
|
956
|
-
process.env.CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY = CTX_MODE_NUDGE_EVERY;
|
|
957
|
-
|
|
958
|
-
// Set OPENCODE_CONFIG scoped to the spawn call (save + restore)
|
|
959
|
-
const prevOpencodeConfig = process.env.OPENCODE_CONFIG;
|
|
960
|
-
process.env.OPENCODE_CONFIG = configFilePath;
|
|
961
|
-
|
|
962
|
-
let client: Awaited<ReturnType<typeof createOpencode>>["client"];
|
|
963
|
-
let server: Awaited<ReturnType<typeof createOpencode>>["server"];
|
|
964
|
-
try {
|
|
965
|
-
({ client, server } = await createOpencode({
|
|
966
|
-
hostname: "127.0.0.1",
|
|
967
|
-
port: 0,
|
|
968
|
-
timeout: serverStartTimeoutMs(),
|
|
969
|
-
config: opencodeConfig,
|
|
970
|
-
}));
|
|
971
|
-
} finally {
|
|
972
|
-
// Restore OPENCODE_CONFIG after the process has been spawned
|
|
973
|
-
if (prevOpencodeConfig === undefined) {
|
|
974
|
-
delete process.env.OPENCODE_CONFIG;
|
|
975
|
-
} else {
|
|
976
|
-
process.env.OPENCODE_CONFIG = prevOpencodeConfig;
|
|
977
|
-
}
|
|
978
|
-
// Restore SWARM_* env vars
|
|
979
|
-
for (const [key, val] of Object.entries(swarmEnvSnapshot)) {
|
|
980
|
-
if (val === undefined) {
|
|
981
|
-
delete process.env[key];
|
|
982
|
-
} else {
|
|
983
|
-
process.env[key] = val;
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
}
|
|
763
|
+
const { sdk, version: opencodeVersion } = await loadOpencodeSdkImpl();
|
|
764
|
+
let listTools: OpencodeToolLister | undefined;
|
|
765
|
+
const host = await sdk.OpenCode.create({
|
|
766
|
+
config: { content: JSON.stringify(opencodeConfig) },
|
|
767
|
+
plugins: [
|
|
768
|
+
createOpencodeSwarmPlugin(swarm, {
|
|
769
|
+
onToolRegistry: (lister) => {
|
|
770
|
+
listTools = lister;
|
|
771
|
+
},
|
|
772
|
+
}),
|
|
773
|
+
],
|
|
774
|
+
log: {
|
|
775
|
+
level: "warn",
|
|
776
|
+
emit: (entry) => console.warn(scrubSecrets(`[opencode] ${entry.message}`)),
|
|
777
|
+
},
|
|
778
|
+
});
|
|
987
779
|
|
|
988
|
-
|
|
989
|
-
// session in a fresh data home installs plugins and fetches the models
|
|
990
|
-
// list, and on a cold container that call has hung with no opencode events
|
|
991
|
-
// at all. Bound it with the same budget as the server start so a hang
|
|
992
|
-
// fails fast as a spawn failure instead of stalling the worker.
|
|
993
|
-
const sessionCreateTimeoutMs = serverStartTimeoutMs();
|
|
994
|
-
let createResult: Awaited<ReturnType<typeof client.session.create>>;
|
|
780
|
+
let sessionId: string;
|
|
995
781
|
try {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
782
|
+
const created = await host.sessions.create({
|
|
783
|
+
location: { directory: config.cwd },
|
|
784
|
+
agent: SWARM_AGENT_NAME,
|
|
785
|
+
});
|
|
786
|
+
sessionId = created.id;
|
|
1001
787
|
} catch (err) {
|
|
1002
|
-
|
|
788
|
+
await host.close().catch(() => {});
|
|
1003
789
|
throw err;
|
|
1004
790
|
}
|
|
1005
|
-
if (!createResult.data) {
|
|
1006
|
-
server.close();
|
|
1007
|
-
throw new Error("Failed to create opencode session");
|
|
1008
|
-
}
|
|
1009
|
-
const opencodeSession = createResult.data;
|
|
1010
|
-
const sessionId = opencodeSession.id;
|
|
1011
|
-
|
|
1012
|
-
let promptRefreshAttempted = false;
|
|
1013
|
-
let promptRefreshPromise: Promise<boolean> | undefined;
|
|
1014
|
-
let session: OpencodeSession | undefined;
|
|
1015
|
-
const sendPrompt = async () => {
|
|
1016
|
-
const resolvedPrompt = await resolveSlashSkillPrompt(config.prompt, {
|
|
1017
|
-
providerLabel: "opencode",
|
|
1018
|
-
skillsDir: defaultOpencodeSkillsDir(),
|
|
1019
|
-
emit: (event) => session?.emitProviderEvent(event),
|
|
1020
|
-
onInline: config.onPromptSkill,
|
|
1021
|
-
});
|
|
1022
|
-
await client.session.prompt({
|
|
1023
|
-
path: { id: sessionId },
|
|
1024
|
-
query: { directory: config.cwd },
|
|
1025
|
-
body: {
|
|
1026
|
-
agent: agentName,
|
|
1027
|
-
parts: [{ type: "text", text: resolvedPrompt }],
|
|
1028
|
-
},
|
|
1029
|
-
});
|
|
1030
|
-
};
|
|
1031
|
-
const refreshOpenRouterAndRetryPrompt = async (): Promise<boolean> => {
|
|
1032
|
-
if (promptRefreshPromise) return await promptRefreshPromise;
|
|
1033
|
-
if (promptRefreshAttempted || !isOpenRouterModel(config.model)) return false;
|
|
1034
|
-
promptRefreshAttempted = true;
|
|
1035
|
-
promptRefreshPromise = (async () => {
|
|
1036
|
-
await refreshOpenRouterModelCacheImpl(opencodeConfig, configFilePath, dataHomePath);
|
|
1037
|
-
await sendPrompt();
|
|
1038
|
-
return true;
|
|
1039
|
-
})();
|
|
1040
|
-
return await promptRefreshPromise;
|
|
1041
|
-
};
|
|
1042
791
|
|
|
1043
|
-
session = new OpencodeSession(
|
|
792
|
+
const session = new OpencodeSession(
|
|
793
|
+
host,
|
|
1044
794
|
sessionId,
|
|
1045
|
-
client,
|
|
1046
|
-
server,
|
|
1047
795
|
config.model,
|
|
1048
|
-
|
|
1049
|
-
config.
|
|
1050
|
-
agentFilePath,
|
|
1051
|
-
configFilePath,
|
|
1052
|
-
dataHomePath,
|
|
1053
|
-
isOpenRouterModel(config.model) ? refreshOpenRouterAndRetryPrompt : undefined,
|
|
796
|
+
swarm,
|
|
797
|
+
config.cwd,
|
|
1054
798
|
appliedReasoningEffort,
|
|
1055
799
|
);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
// serve` child would keep the stream (and the session promise) pending
|
|
1065
|
-
// and hold the worker's slot. Cap the retries so the stream ends.
|
|
1066
|
-
client.event
|
|
1067
|
-
.subscribe({
|
|
1068
|
-
query: { directory: config.cwd },
|
|
1069
|
-
sseMaxRetryAttempts: OPENCODE_SSE_MAX_RETRY_ATTEMPTS,
|
|
1070
|
-
})
|
|
1071
|
-
.then(async ({ stream }) => {
|
|
1072
|
-
for await (const event of stream) {
|
|
1073
|
-
session.handleOpencodeEvent(event as OpencodeEvent);
|
|
800
|
+
session.emitSessionInit(opencodeVersion ? { version: opencodeVersion } : undefined);
|
|
801
|
+
|
|
802
|
+
// Drive the session from the host's event stream. Closing the host ends
|
|
803
|
+
// the stream; ending without a terminal event means the host died.
|
|
804
|
+
void (async () => {
|
|
805
|
+
try {
|
|
806
|
+
for await (const event of host.events.subscribe()) {
|
|
807
|
+
session.handleOpencodeEvent(event);
|
|
1074
808
|
if (session.isFinished) break;
|
|
1075
809
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
properties: { sessionID: sessionId, error: { message: String(err) } as never },
|
|
1094
|
-
});
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
// Fire-and-forget: send the prompt using the per-task agent
|
|
1098
|
-
sendPrompt().catch((err: unknown) => {
|
|
1099
|
-
const message = formatUnknownError(err);
|
|
1100
|
-
if (isModelNotFoundError(message) && isOpenRouterModel(config.model)) {
|
|
1101
|
-
session.emitModelCacheRefreshProgress();
|
|
1102
|
-
refreshOpenRouterAndRetryPrompt()
|
|
1103
|
-
.then((retried) => {
|
|
1104
|
-
if (retried) return;
|
|
1105
|
-
session.handleOpencodeEvent({
|
|
1106
|
-
type: "session.error",
|
|
1107
|
-
properties: { sessionID: sessionId, error: { message } as never },
|
|
1108
|
-
});
|
|
1109
|
-
})
|
|
1110
|
-
.catch((retryErr: unknown) => {
|
|
1111
|
-
session.emitModelCacheRefreshFailure(message, retryErr);
|
|
1112
|
-
});
|
|
810
|
+
session.fail("runner exited without result: opencode event stream ended");
|
|
811
|
+
} catch (err) {
|
|
812
|
+
session.fail(String(err));
|
|
813
|
+
}
|
|
814
|
+
})();
|
|
815
|
+
|
|
816
|
+
void (async () => {
|
|
817
|
+
// Without the swarm tools the model cannot report; fail before prompting.
|
|
818
|
+
const refused = await checkSwarmMcpAdmission(
|
|
819
|
+
host,
|
|
820
|
+
config.cwd,
|
|
821
|
+
() => listTools,
|
|
822
|
+
sessionHooks.admissionTimeoutMs,
|
|
823
|
+
() => session.isFinished,
|
|
824
|
+
);
|
|
825
|
+
if (refused) {
|
|
826
|
+
session.fail(scrubSecrets(`swarm MCP admission failed: ${refused}`));
|
|
1113
827
|
return;
|
|
1114
828
|
}
|
|
1115
|
-
session.
|
|
1116
|
-
|
|
1117
|
-
|
|
829
|
+
if (session.isFinished) return;
|
|
830
|
+
const prompt = await resolveSlashSkillPrompt(config.prompt, {
|
|
831
|
+
providerLabel: "opencode",
|
|
832
|
+
skillsDir: defaultOpencodeSkillsDir(),
|
|
833
|
+
emit: (event) => session.emitProviderEvent(event),
|
|
834
|
+
onInline: config.onPromptSkill,
|
|
1118
835
|
});
|
|
1119
|
-
|
|
836
|
+
session.recordUserPrompt(prompt);
|
|
837
|
+
await host.sessions.prompt({ sessionID: sessionId, text: prompt, delivery: "queue" });
|
|
838
|
+
})().catch((err: unknown) => session.fail(scrubSecrets(errorMessage(err, String(err)))));
|
|
1120
839
|
|
|
1121
840
|
return session;
|
|
1122
841
|
}
|