@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.
Files changed (183) hide show
  1. package/dist/{acp-adapter-1z9p3fvx.js → acp-adapter-fpecx1g1.js} +23 -5
  2. package/dist/{actions-rc2yjk8a.js → actions-pw87a1a8.js} +24 -25
  3. package/dist/{amp-adapter-ypyf5s1x.js → amp-adapter-8jem2bc0.js} +1 -1
  4. package/dist/{anthropic-messages-etkbxhhy.js → anthropic-messages-8ee2f22q.js} +55 -22
  5. package/dist/{app-04ab2s0z.js → app-wmf0m843.js} +7 -8
  6. package/dist/{assistant-vhmjd8wf.js → assistant-emp1e108.js} +26 -27
  7. package/dist/{azure-openai-responses-xap57x4b.js → azure-openai-responses-ap1gwxgr.js} +3 -3
  8. package/dist/{boot-reembed-3p0m259x.js → boot-reembed-1de4f4aj.js} +6 -7
  9. package/dist/{boot-reembed-wew3gwk8.js → boot-reembed-4ymn9atn.js} +6 -7
  10. package/dist/{boot-scrub-sweep-5ytwtq24.js → boot-scrub-sweep-adhk1s8c.js} +7 -8
  11. package/dist/{boot-seeds-s8p2xry8.js → boot-seeds-8q637aew.js} +9 -10
  12. package/dist/{claude-adapter-akrzg6jh.js → claude-adapter-t26cv8a4.js} +16 -17
  13. package/dist/{claude-managed-adapter-kqqqhp2q.js → claude-managed-adapter-xgxhqrt2.js} +2 -2
  14. package/dist/{claude-sdk-session-exq1jadx.js → claude-sdk-session-gsfsh662.js} +16 -17
  15. package/dist/{cli-n8ybnv1k.js → cli-04emfe2q.js} +1 -1
  16. package/dist/{cli-w0bwte75.js → cli-104zsxba.js} +2 -1
  17. package/dist/{cli-x6z90nq9.js → cli-19njxg5g.js} +2 -4
  18. package/dist/{cli-8mr695sb.js → cli-1hgat6pa.js} +5 -5
  19. package/dist/{cli-1qtgd1h1.js → cli-1m2xxxe2.js} +2 -2
  20. package/dist/{cli-231av4y6.js → cli-1x7da0de.js} +2 -4
  21. package/dist/{cli-x3fy2tgp.js → cli-388egyf6.js} +1 -1
  22. package/dist/{cli-j0w59p15.js → cli-4fnb6884.js} +4 -4
  23. package/dist/{cli-y1j1ggbf.js → cli-4nz8p1p5.js} +1372 -588
  24. package/dist/{cli-s88my3br.js → cli-5rv1j9ga.js} +6 -6
  25. package/dist/{cli-mn8qkd8v.js → cli-61625k3x.js} +1 -1
  26. package/dist/{cli-mzj3aqac.js → cli-6jb2rf77.js} +3 -3
  27. package/dist/{cli-ec7hrgqp.js → cli-8hzfb6xz.js} +8 -8
  28. package/dist/{cli-n805bb98.js → cli-8vs9trd4.js} +1 -1
  29. package/dist/{cli-xf713zz0.js → cli-9fxpr83v.js} +1 -1
  30. package/dist/{cli-q1vnz8wv.js → cli-9s17naqf.js} +1 -1
  31. package/dist/{cli-nkjwe4z7.js → cli-9xsmdpdt.js} +59 -54
  32. package/dist/{cli-6xxxb8qt.js → cli-aspcaef3.js} +5 -5
  33. package/dist/cli-bf7485pm.js +514 -0
  34. package/dist/{cli-s9y81s7j.js → cli-bt8k5h3s.js} +625 -150
  35. package/dist/{cli-jxsgrnmq.js → cli-c2pq2rjb.js} +2 -2
  36. package/dist/{cli-13xwcqvj.js → cli-c5km5znr.js} +2 -2
  37. package/dist/{cli-5y67yrt6.js → cli-cceskcvt.js} +112 -176
  38. package/dist/{cli-ph3145b7.js → cli-cf69s3ew.js} +4 -4
  39. package/dist/{cli-k2v5x13t.js → cli-d603qf86.js} +1 -1
  40. package/dist/{cli-r455yd58.js → cli-e0a9sk00.js} +1 -1
  41. package/dist/{cli-cgj4hc12.js → cli-eh1ytktb.js} +1872 -190
  42. package/dist/{cli-erbp6b4g.js → cli-esnpehf5.js} +7 -8
  43. package/dist/cli-ev91t6xp.js +148 -0
  44. package/dist/{cli-e2prcx5m.js → cli-fdvbjtbw.js} +235 -5
  45. package/dist/{cli-62bwwc2q.js → cli-g4f5pj26.js} +3 -3
  46. package/dist/{cli-tg8q0b5y.js → cli-g6t9texm.js} +5 -5
  47. package/dist/{cli-p2pjfq1r.js → cli-gj81e47t.js} +1 -1
  48. package/dist/{cli-srmej5tp.js → cli-gp1rtyvk.js} +2 -2
  49. package/dist/{cli-rb9m6rpe.js → cli-h6vtvt5c.js} +139 -1
  50. package/dist/cli-hd27d2md.js +1057 -0
  51. package/dist/{cli-hsk5gd15.js → cli-hwwvy36w.js} +3 -3
  52. package/dist/{cli-2rbt7at8.js → cli-hz99wfxp.js} +1 -1
  53. package/dist/cli-j1pmc07z.js +706 -0
  54. package/dist/{cli-01qndrvw.js → cli-j6kpj9az.js} +14 -8
  55. package/dist/{cli-2qbs2tvf.js → cli-k46r9hmk.js} +6 -6
  56. package/dist/{cli-wfvedq5w.js → cli-kb4ehzx5.js} +5 -5
  57. package/dist/{cli-agamgm0p.js → cli-mvzyxwg3.js} +1 -1
  58. package/dist/{cli-280gnz4v.js → cli-nv4zd0b9.js} +1 -1
  59. package/dist/{cli-rrbctenq.js → cli-nyhb1r6x.js} +3 -5
  60. package/dist/{cli-g7k7zbcz.js → cli-p7z6q2m6.js} +2 -1
  61. package/dist/{cli-yn0a417a.js → cli-phre6gz0.js} +22 -4
  62. package/dist/{cli-m4csbs67.js → cli-pj6x9qce.js} +1 -1
  63. package/dist/{cli-w0zx9ya1.js → cli-prrt9fa0.js} +4 -4
  64. package/dist/{cli-ctret352.js → cli-pstq9888.js} +33 -33
  65. package/dist/{cli-y195hv1a.js → cli-pxeeyc53.js} +1 -1
  66. package/dist/{cli-4bm25xtw.js → cli-qsztpfry.js} +1 -1
  67. package/dist/{cli-5t5my0h8.js → cli-qybkr9n3.js} +278 -1267
  68. package/dist/{cli-xezn9nkh.js → cli-r44t9wqc.js} +29 -472
  69. package/dist/{cli-x207g4w1.js → cli-r8smj8b8.js} +1 -1
  70. package/dist/{cli-bxqez1p0.js → cli-rcp5aqdw.js} +9 -39
  71. package/dist/{cli-vr5avem2.js → cli-smcrrmhy.js} +14 -18
  72. package/dist/{cli-0d2rqhvq.js → cli-tgpfnm6r.js} +15 -17
  73. package/dist/cli-vb8g5x02.js +549 -0
  74. package/dist/{cli-gt4hxb8p.js → cli-vv0qtfpx.js} +1 -2
  75. package/dist/{cli-95zs84qw.js → cli-wd0hz6ah.js} +671 -388
  76. package/dist/{cli-nkqjj42q.js → cli-x4m6feqt.js} +1 -1
  77. package/dist/{cli-x61srfpp.js → cli-xczpdp9e.js} +2 -2
  78. package/dist/cli-z1kd834t.js +36 -0
  79. package/dist/{cli-p8f7dqgt.js → cli-ztrv9y21.js} +48 -33
  80. package/dist/cli.js +24 -25
  81. package/dist/{cloudflare-workers-ai-system-one-sta45jf9.js → cloudflare-workers-ai-system-one-vnpyfr7e.js} +3 -3
  82. package/dist/{codex-adapter-9ag75whm.js → codex-adapter-m4tgdysy.js} +12 -12
  83. package/dist/{codex-hook-7ba15hjd.js → codex-hook-xch6z5wt.js} +2 -3
  84. package/dist/{codex-session-runner-9fp7y4cf.js → codex-session-runner-ymwzm6ea.js} +12 -12
  85. package/dist/{commands-d7eah0y9.js → commands-n0hshcvs.js} +6 -7
  86. package/dist/{controllers-connect-8t0k65x2.js → controllers-connect-y07cqcvy.js} +24 -25
  87. package/dist/{cursor-adapter-t4pr1m3c.js → cursor-adapter-q8pgv5qa.js} +2 -3
  88. package/dist/{db-sp39qv72.js → db-4w7tmt1g.js} +6 -7
  89. package/dist/{db-2rmd23wq.js → db-hkqvrdcb.js} +7 -8
  90. package/dist/{dispatcher-g7fsybsd.js → dispatcher-5sd69y14.js} +23 -24
  91. package/dist/{easter-egg-3d-66zxervv.js → easter-egg-3d-m8rvz40s.js} +4 -3
  92. package/dist/execute-107xtsp1.js +3745 -0
  93. package/dist/{google-generative-ai-pvhm0dq1.js → google-generative-ai-7pavrbv7.js} +3 -3
  94. package/dist/{google-vertex-tek9sqe2.js → google-vertex-3f1jk41f.js} +3 -3
  95. package/dist/{grok-adapter-1err1me7.js → grok-adapter-8m9yqzbq.js} +24 -6
  96. package/dist/{handlers-c7yggm37.js → handlers-s20xr10r.js} +26 -27
  97. package/dist/{heartbeat-dkm924fk.js → heartbeat-3k31q4g2.js} +25 -26
  98. package/dist/{hook-wgx4exw6.js → hook-gdn6a2fn.js} +13 -14
  99. package/dist/{hook-g8e7sfnf.js → hook-ygwerrdh.js} +14 -15
  100. package/dist/{http-1d23k928.js → http-jdteeg81.js} +53 -55
  101. package/dist/{index-y5vvczrs.js → index-6ksewd5y.js} +20 -18
  102. package/dist/{index-kkeftv5h.js → index-82rk11v7.js} +4 -4
  103. package/dist/{index-gm10tmye.js → index-e0snym4t.js} +23 -24
  104. package/dist/{index-vertaxs1.js → index-ffqzqgwb.js} +6 -7
  105. package/dist/{index-q7cj9bn2.js → index-tsszt7g9.js} +25 -26
  106. package/dist/{index-w097eg0x.js → index-vccv018g.js} +1 -1
  107. package/dist/{index-va4zbvzp.js → index-vm3bm9t7.js} +24 -25
  108. package/dist/{keepalive-1jc5e7ct.js → keepalive-jekc896t.js} +8 -9
  109. package/dist/{lead-yxw2f1sg.js → lead-4k9s9szx.js} +40 -41
  110. package/dist/{maintenance-h7w2m10y.js → maintenance-shazch4n.js} +6 -7
  111. package/dist/{mistral-conversations-rjxa9s1j.js → mistral-conversations-vjc1ggpd.js} +29 -15
  112. package/dist/{oauth-refresh-sweep-wh9ypd1b.js → oauth-refresh-sweep-5gb36kn8.js} +7 -8
  113. package/dist/{onboard-dfr9j762.js → onboard-ydrbnvww.js} +2 -2
  114. package/dist/{openai-codex-responses-pgvtddvv.js → openai-codex-responses-33qhkkdy.js} +7 -39
  115. package/dist/{openai-completions-44gb0zsk.js → openai-completions-s88xm3ns.js} +2 -2
  116. package/dist/{openai-decisions-vj9nkpf8.js → openai-decisions-fctnj8xd.js} +2 -2
  117. package/dist/{openai-responses-9dyzwzkr.js → openai-responses-7xd2f2e2.js} +3 -3
  118. package/dist/opencode-adapter-5zh0he20.js +55 -0
  119. package/dist/{otel-impl-and31m5s.js → otel-impl-6pf5cv86.js} +1 -1
  120. package/dist/{pi-mono-adapter-fx6k5wwh.js → pi-mono-adapter-ef9wwx9m.js} +25 -500
  121. package/dist/{pricing-refresh-23zr6j3w.js → pricing-refresh-s52nsa5q.js} +8 -9
  122. package/dist/{rbac-roles-wnx35x4x.js → rbac-roles-aq3msh4e.js} +6 -7
  123. package/dist/{rbac-roles-nc7k9d0t.js → rbac-roles-q5gb5e8v.js} +7 -8
  124. package/dist/{render-v2-81cg1y52.js → render-v2-pd1rg54q.js} +7 -8
  125. package/dist/{resume-d4b6j2n2.js → resume-gw4a2c6v.js} +14 -15
  126. package/dist/{runtime-h9ha1mq6.js → runtime-qfry88v2.js} +4 -7
  127. package/dist/{setup-g68r1w28.js → setup-jda1ksdh.js} +2 -2
  128. package/dist/{typesafe-system-one-e988vty7.js → typesafe-system-one-zzzcx4s3.js} +3 -3
  129. package/dist/{virtual-modules-rdjmcg7q.js → virtual-modules-b3ga2m65.js} +13 -12
  130. package/dist/{work-objects-0zzdnbt9.js → work-objects-tb7ycye8.js} +26 -27
  131. package/dist/{worker-p1g8qm6h.js → worker-njjccz5d.js} +40 -41
  132. package/openapi.json +1 -1
  133. package/package.json +7 -8
  134. package/src/be/scripts/extract-schema.ts +4 -0
  135. package/src/github/handlers.ts +61 -18
  136. package/src/github/types.ts +2 -1
  137. package/src/prompts/goal-reminder.ts +30 -0
  138. package/src/prompts/session-templates.ts +22 -0
  139. package/src/providers/acp-targets.ts +3 -4
  140. package/src/providers/amp-adapter.ts +1 -1
  141. package/src/providers/harness-version.ts +0 -1
  142. package/src/providers/opencode-adapter.ts +442 -723
  143. package/src/providers/opencode-swarm-plugin.ts +88 -0
  144. package/src/providers/pi-mono-extension.ts +58 -37
  145. package/src/scripts-runtime/executors/native.ts +6 -5
  146. package/src/scripts-runtime/executors/quickjs-bundle.ts +18 -11
  147. package/src/scripts-runtime/extract-args-schema.ts +22 -4
  148. package/src/scripts-runtime/import-allowlist.ts +1 -1
  149. package/src/scripts-runtime/zod-mini-bundle.ts +5 -0
  150. package/src/slack/render-v2.ts +2 -2
  151. package/src/tests/entrypoint-acp-target-binary.test.ts +19 -1
  152. package/src/tests/github-handlers-inline-comments.test.ts +131 -2
  153. package/src/tests/github-handlers-reactions.test.ts +9 -0
  154. package/src/tests/harness-version.test.ts +0 -20
  155. package/src/tests/opencode-adapter.test.ts +536 -1389
  156. package/src/tests/opencode-mcp-admission-sdk.test.ts +106 -0
  157. package/src/tests/opencode-swarm-plugin.test.ts +130 -0
  158. package/src/tests/pi-mono-extension.test.ts +59 -0
  159. package/src/tests/prompt-template-session.test.ts +4 -3
  160. package/src/tests/provider-registration.test.ts +3 -0
  161. package/src/tests/script-executor-quickjs.test.ts +17 -12
  162. package/src/tests/scripts-http.test.ts +24 -16
  163. package/src/tests/scripts-import-allowlist.test.ts +1 -1
  164. package/src/tests/scripts-runtime.test.ts +78 -40
  165. package/src/tests/seed-scripts.test.ts +13 -0
  166. package/src/tests/slack-render-v2.test.ts +4 -2
  167. package/src/tests/ui-logs-parser.test.ts +28 -0
  168. package/src/tools/script-upsert.ts +1 -1
  169. package/src/utils/memory-raters.ts +7 -3
  170. package/src/utils/openrouter-base-url.ts +0 -2
  171. package/templates/schedules/weekly-harness-upgrade-check/content.md +4 -6
  172. package/templates/skills/heartbeat-runbook/content.md +2 -0
  173. package/templates/skills/swarm-scripts/SKILL.md +1 -0
  174. package/templates/skills/swarm-scripts/content.md +1 -0
  175. package/dist/cli-5bfdm1ah.js +0 -462
  176. package/dist/cli-5c2j5yvx.js +0 -222
  177. package/dist/cli-rhy7z1rp.js +0 -532
  178. package/dist/cli-ywh2a87k.js +0 -2359
  179. package/dist/execute-h4jgevyw.js +0 -1387
  180. package/dist/opencode-adapter-f1zby94p.js +0 -34
  181. package/plugin/opencode-plugins/lib/memory-raters.ts +0 -7
  182. package/src/tests/opencode-plugin.test.ts +0 -580
  183. 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
- * Sub-4 added the OpencodeAdapter skeleton; sub-5 added the full session
5
- * lifecycle (SSE events, cost accumulation, raw_log persistence); sub-6 (DES-300)
6
- * adds per-task isolation: agent file, OPENCODE_CONFIG, OPENCODE_DATA_HOME.
7
- * Sub-7 (DES-301) wires the agent-swarm opencode plugin for cancellation,
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, mkdirSync } from "node:fs";
12
- import { join } from "node:path";
13
- import type { AssistantMessage, Config, Event as OpencodeEvent, TextPart } from "@opencode-ai/sdk";
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 { readPkgVersion } from "./harness-version";
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
- /** Reject with `message` if `promise` has not settled within `ms`. */
147
- async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
148
- let timer: ReturnType<typeof setTimeout> | undefined;
149
- const timeout = new Promise<never>((_, reject) => {
150
- timer = setTimeout(() => {
151
- // The abandoned promise may still settle later; keep it from surfacing
152
- // as an unhandled rejection.
153
- promise.catch(() => {});
154
- reject(new Error(message));
155
- }, ms);
156
- });
157
- try {
158
- return await Promise.race([promise, timeout]);
159
- } finally {
160
- clearTimeout(timer);
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
- function isOpenRouterModel(model: string | undefined): boolean {
165
- return Boolean(model?.toLowerCase().startsWith("openrouter/"));
166
- }
155
+ type OpencodeSdk = typeof import("@opencode/sdk");
156
+ type LoadedOpencodeSdk = { sdk: OpencodeSdk; version?: string };
167
157
 
168
- function isModelNotFoundError(message: string): boolean {
169
- return /model not found:/i.test(message);
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
- async function readSpawnOutput(stream: ReadableStream<Uint8Array> | null): Promise<string> {
173
- if (!stream) return "";
174
- return await new Response(stream).text();
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
- function formatUnknownError(err: unknown): string {
178
- return err instanceof Error ? err.message : String(err);
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: Config,
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
- async function refreshOpenRouterModelCache(
219
- opencodeConfig: Config & { plugin?: string[] },
220
- configFilePath: string,
221
- dataHomePath: string,
222
- ): Promise<void> {
223
- const binary = process.env.OPENCODE_BINARY || "opencode";
224
- const proc = Bun.spawn([binary, "models", "--refresh", "openrouter"], {
225
- stdout: "pipe",
226
- stderr: "pipe",
227
- env: {
228
- ...process.env,
229
- OPENCODE_CONFIG: configFilePath,
230
- OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig),
231
- OPENCODE_DATA_HOME: dataHomePath,
232
- },
233
- });
234
-
235
- let timedOut = false;
236
- const timeout = setTimeout(() => {
237
- timedOut = true;
238
- proc.kill();
239
- }, MODEL_CACHE_REFRESH_TIMEOUT_MS);
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 _setOpenRouterModelCacheRefreshForTests(
263
- fn: typeof refreshOpenRouterModelCache | null,
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
- function resolvePluginPath(): string {
269
- const override = process.env.OPENCODE_SWARM_PLUGIN_PATH;
270
- if (override) return override;
271
- if (existsSync(DOCKER_PLUGIN_PATH)) return DOCKER_PLUGIN_PATH;
272
- return join(import.meta.dir, "../../plugin/opencode-plugins/agent-swarm.ts");
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
- * Resolve the absolute path to context-mode's opencode plugin entry, or `null`
287
- * if it can't be found on disk. `CONTEXT_MODE_OPENCODE_PLUGIN_PATH` overrides
288
- * the lookup (and must itself exist). Returning `null` lets the caller skip the
289
- * plugin gracefully instead of handing opencode an unresolvable entry.
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 resolveContextModePluginPath(): string | null {
292
- const override = process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
293
- if (override) return existsSync(override) ? override : null;
294
- for (const root of CONTEXT_MODE_GLOBAL_ROOTS) {
295
- const candidate = join(root, CONTEXT_MODE_PLUGIN_SUBPATH);
296
- if (existsSync(candidate)) return candidate;
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
- // Buffer for events emitted before any listener is attached.
305
- // The runner attaches its listener after `await adapter.createSession(...)`
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
- // Opencode can re-emit finalized snapshots for the same assistant message.
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
- private model: string;
333
- private agentId: string;
334
- private taskId: string;
335
-
336
- // Per-task isolation paths (for cleanup)
337
- private agentFilePath: string;
338
- private configFilePath: string;
339
- private dataHomePath: string;
340
- private retryAfterModelRefresh?: () => Promise<boolean>;
341
- private modelRefreshRecoveryInFlight = false;
342
- /** Reasoning/effort level actually applied (Phase 4) — null when `applyReasoningEffort()` returned noop. */
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
- sessionId: string,
351
- client: Awaited<ReturnType<typeof createOpencode>>["client"],
352
- server: { url: string; close(): void },
353
- model: string,
354
- agentId: string,
355
- taskId: string,
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._sessionId = sessionId;
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
- /** Emit the synthetic session_init event. Called by the adapter immediately
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
- emitModelCacheRefreshProgress(): void {
435
- this.emit({
436
- type: "progress",
437
- message: "opencode model cache is stale; refreshing OpenRouter models and retrying once",
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
- emitModelCacheRefreshFailure(message: string, err: unknown): void {
442
- this.emitError(`${message}; OpenRouter model cache refresh failed: ${formatUnknownError(err)}`);
443
- }
398
+ /** Process one OpenCode 2 event. */
399
+ handleOpencodeEvent(ev: OpenCodeEvent): void {
400
+ if (this.aborted || this.completed) return;
444
401
 
445
- private recoverFromModelNotFound(message: string): void {
446
- if (!this.retryAfterModelRefresh || this.modelRefreshRecoveryInFlight) {
447
- this.emitError(message);
402
+ if (ev.type === "filesystem.changed") {
403
+ this.onFileChanged(ev.data.file);
448
404
  return;
449
405
  }
450
- this.modelRefreshRecoveryInFlight = true;
451
- this.emitModelCacheRefreshProgress();
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
- /** Best-effort cleanup of per-task isolation files and directories. */
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
- /** Process a single opencode SSE event */
481
- handleOpencodeEvent(ev: OpencodeEvent): void {
482
- if (this.aborted || this.completed) return;
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
- // Always emit the raw event as a scrubbed raw_log
485
- const rawContent = scrubSecrets(JSON.stringify(ev));
486
- this.emitDirect({ type: "raw_log", content: rawContent });
425
+ case "session.tool.input.started":
426
+ this.toolNames.set(ev.data.id, ev.data.name);
427
+ break;
487
428
 
488
- switch (ev.type) {
489
- case "message.updated": {
490
- const msg = ev.properties.info;
491
- if (!isAssistantMessage(msg) || msg.sessionID !== this._sessionId) break;
492
- if (typeof msg.id === "string") this.assistantMessageIds.add(msg.id);
493
- // Phase 9 fix: opencode fires `message.updated` repeatedly during a single
494
- // assistant turn (streaming text deltas, tool transitions, etc.) and only
495
- // populates `tokens`/`cost` on the FINAL update once `time.completed` is
496
- // set. Accumulating on every event would either no-op (zero tokens) or —
497
- // if opencode ever back-fills intermediate snapshots — multi-count. Gate
498
- // the accumulator AND the context emit on the finalized signal so both
499
- // paths see the same canonical "this turn is done" moment.
500
- const messageFinalized = msg.time?.completed != null;
501
- if (!messageFinalized) break;
502
- // Keep the latest finalized snapshot for each assistant message.
503
- // A missing/empty id must not collapse distinct messages into one
504
- // entry (silent undercount) — degrade to counting them separately,
505
- // which matches the pre-dedup over-count-safe behavior.
506
- const messageKey =
507
- typeof msg.id === "string" && msg.id.length > 0
508
- ? msg.id
509
- : `missing-id-${this.missingMessageIdCounter++}`;
510
- this.finalizedMessages.set(messageKey, {
511
- cost: msg.cost,
512
- inputTokens: msg.tokens?.input ?? 0,
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
- this.emitAssistantText(msg.id);
519
- if (!this.model && msg.modelID) this.model = msg.modelID;
520
-
521
- // Emit context_usage so the runner can POST /api/tasks/:id/context
522
- // (drives the dashboard's context-usage progress bar). The runner-side
523
- // throttle (CONTEXT_THROTTLE_MS = 30s) means the FIRST emit wins for any
524
- // short task — so this MUST carry real numbers, not the zero-tokens
525
- // placeholder opencode sends on intermediate streaming updates. The
526
- // `time.completed` gate above (in the accumulator block) guarantees we
527
- // only land here for finalized messages.
528
- const turnInput = msg.tokens?.input ?? 0;
529
- const turnOutput = msg.tokens?.output ?? 0;
530
- const turnCacheRead = msg.tokens?.cache?.read ?? 0;
531
- const turnCacheWrite = msg.tokens?.cache?.write ?? 0;
532
- // Phase 8 + Phase 9: unified `input + cache + output` formula instead
533
- // of the previous `input + cache_read + cache_write` (which omitted
534
- // output and slightly mis-counted vs every other adapter).
535
- const contextUsed = turnInput + turnCacheRead + turnCacheWrite + turnOutput;
536
- const contextTotal = getContextWindowSize(this.model || msg.modelID || "default");
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: turnOutput,
486
+ outputTokens: t.output,
546
487
  contextFormula: CONTEXT_FORMULA,
547
488
  });
548
489
  }
549
490
  break;
550
491
  }
551
492
 
552
- case "message.part.updated": {
553
- // Text parts are matched to this session by their own `sessionID`
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.idle": {
600
- if (ev.properties.sessionID !== this._sessionId) break;
497
+ case "session.execution.succeeded": {
601
498
  const cost = this.buildCostData(false);
602
- const resultEvent: ProviderEvent = {
603
- type: "result",
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.error": {
621
- if (ev.properties.sessionID !== undefined && ev.properties.sessionID !== this._sessionId)
622
- break;
623
- const errMsg =
624
- ev.properties.error && "message" in ev.properties.error
625
- ? String((ev.properties.error as { message?: string }).message ?? "unknown error")
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.updated": {
636
- if (ev.properties.sessionID !== this._sessionId) break;
637
- // Headless worker cannot interactively approve permissions — treat as error
638
- this.emitError(
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
- /** Keep the latest snapshot of a text part (`part.text` is cumulative). */
650
- private recordTextPart(part: TextPart): void {
651
- // Synthetic and ignored parts are opencode-injected context, not model output.
652
- if (part.sessionID !== this._sessionId || part.synthetic || part.ignored) return;
653
- if (typeof part.messageID !== "string" || typeof part.id !== "string") return;
654
- let parts = this.textParts.get(part.messageID);
655
- if (!parts) {
656
- parts = new Map();
657
- this.textParts.set(part.messageID, parts);
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
- private emitError(message: string): void {
680
- const errorEvent: ProviderEvent = { type: "error", message };
681
- for (const l of this.listeners) l(errorEvent);
682
- const raw = scrubSecrets(JSON.stringify(errorEvent));
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
- let totalCostUsd = 0;
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.finalizedMessages.size,
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.server.close();
737
- } catch {
738
- // best-effort
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
- // `finish()` has closed the local server and `handleOpencodeEvent()`
758
- // ignores further events — a promptAsync now would start a turn nobody
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
- // Always queue. `steer` used to abort first and re-prompt, but E2E showed
765
- // the re-prompt fails once the session has been aborted, so the message
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.client.session.promptAsync({
771
- path: { id: this._sessionId },
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 agentName = `swarm-${taskId}`;
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 mcpConfig: Config["mcp"] = {
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. Without it `store-progress`
829
- // has no default task, so the model must hand-type a UUID and can
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
- // Resolve the agent-swarm plugin path. Three layers, in priority order:
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
- const contextModePluginPath = resolveContextModePluginPath();
862
- if (contextModePluginPath) {
863
- plugins.push(contextModePluginPath);
864
- } else {
865
- console.warn(
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
- // Build per-task opencode config (plugin field carries the swarm plugin)
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: mcpConfig,
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
- plugin: plugins,
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
- // Phase 4 (reasoning-effort plan): splice the provider-keyed reasoning
889
- // options into `provider[providerId].models[modelId].options`. `off`
890
- // (and any capability-rejected level) resolves to `noop` — no keys added,
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
- ...opencodeConfig.provider?.[providerId],
741
+ ...provider,
905
742
  models: {
906
- ...opencodeConfig.provider?.[providerId]?.models,
743
+ ...provider?.models,
907
744
  [modelId]: {
908
- ...opencodeConfig.provider?.[providerId]?.models?.[modelId],
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, config.env ?? process.env);
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
- // Set per-task data home before spawning the opencode process
929
- process.env.OPENCODE_DATA_HOME = dataHomePath;
930
-
931
- // Set SWARM_* env vars so the plugin can read them (inherited by child process)
932
- const swarmEnvSnapshot = {
933
- SWARM_API_URL: process.env.SWARM_API_URL,
934
- SWARM_API_KEY: process.env.SWARM_API_KEY,
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
- process.env.SWARM_API_URL = config.apiUrl;
941
- process.env.SWARM_API_KEY = config.apiKey;
942
- process.env.SWARM_AGENT_ID = config.agentId;
943
- process.env.SWARM_TASK_ID = config.taskId;
944
- process.env.SWARM_IS_LEAD = config.role === "lead" ? "true" : "false";
945
- // Mirror the resolved OpenRouter gateway into the spawn env: the
946
- // summarize plugin (and any other plugin OpenRouter fetch) reads its own
947
- // process.env inside the opencode subprocess, which inherits OURS — a
948
- // per-task swarm_config value in `config.env` would otherwise be
949
- // invisible there and the plugin would bypass the gateway.
950
- const spawnOpenRouterBaseUrl = getOpenRouterBaseUrl(
951
- (config.env ?? process.env) as NodeJS.ProcessEnv,
952
- );
953
- if (spawnOpenRouterBaseUrl !== DEFAULT_OPENROUTER_BASE_URL) {
954
- process.env.OPENROUTER_BASE_URL = spawnOpenRouterBaseUrl;
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
- // Create the opencode session (project directory = config.cwd). The first
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
- createResult = await withTimeout(
997
- client.session.create({ query: { directory: config.cwd } }),
998
- sessionCreateTimeoutMs,
999
- `opencode session create timed out after ${sessionCreateTimeoutMs}ms`,
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
- server.close();
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
- config.agentId,
1049
- config.taskId,
1050
- agentFilePath,
1051
- configFilePath,
1052
- dataHomePath,
1053
- isOpenRouterModel(config.model) ? refreshOpenRouterAndRetryPrompt : undefined,
796
+ swarm,
797
+ config.cwd,
1054
798
  appliedReasoningEffort,
1055
799
  );
1056
-
1057
- // Emit session_init synchronously; the session buffers events until the
1058
- // runner's `onEvent(listener)` call attaches a listener.
1059
- const opcVersion = readPkgVersion("@opencode-ai/sdk");
1060
- session.emitSessionInit("opencode", opcVersion ? { version: opcVersion } : undefined);
1061
-
1062
- // Subscribe to SSE events and drive the session. The SDK's SSE client
1063
- // retries a dropped connection forever by default, so a dead `opencode
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
- // The stream ended without session.idle: the opencode server closed
1077
- // or died. Settle the session so the runner releases the slot.
1078
- if (!session.isFinished) {
1079
- session.handleOpencodeEvent({
1080
- type: "session.error",
1081
- properties: {
1082
- sessionID: sessionId,
1083
- error: {
1084
- message: "runner exited without result: opencode event stream ended",
1085
- } as never,
1086
- },
1087
- });
1088
- }
1089
- })
1090
- .catch((err: unknown) => {
1091
- session.handleOpencodeEvent({
1092
- type: "session.error",
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.handleOpencodeEvent({
1116
- type: "session.error",
1117
- properties: { sessionID: sessionId, error: { message } as never },
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
  }