@humwick/runner 0.0.0-stage → 0.130.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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +46 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,169 @@
1
+ import { relayEnv } from "@humwick/sdk/env";
2
+ import { OPENCODE_STATIC_BACKENDS, getAllManifests, getManifest, resolveOpenCodeModelId, splitQualifiedOpenCodeModel, type OpenCodeModelId, type OpenCodeModelRegistry, type ProviderManifest } from "@humwick/providers";
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { ResolvedModelRecord } from "@humwick/sdk/provider-catalog";
7
+ export type { OpenCodeModelId } from "@humwick/providers";
8
+
9
+ export interface OpenCodeBackend {
10
+ manifest: ProviderManifest;
11
+ backend: NonNullable<ProviderManifest["opencode"]>;
12
+ }
13
+
14
+ /** Resolve a manifest-backed OpenCode provider once, with a useful error at the adapter boundary. */
15
+ export function openCodeBackend(provider: string): OpenCodeBackend {
16
+ const manifest = getManifest(provider);
17
+ if (!manifest?.opencode) throw new Error(`Provider ${provider} is not configured for the OpenCode adapter.`);
18
+ return { manifest, backend: manifest.opencode };
19
+ }
20
+
21
+ /**
22
+ * #2599 — resolve the payload record for THIS provider, or reject an unknown provider LOUD.
23
+ * A record whose providerModel/alias resolves against a DIFFERENT registered provider is a
24
+ * routing error, not a fallback: silently running it under the wrong backend namespace would
25
+ * launch the wrong model. The manifest fallback only applies when NO record is present at all.
26
+ */
27
+ export function openCodeModelRecord(record: ResolvedModelRecord | undefined, provider: string): ResolvedModelRecord | undefined {
28
+ if (!record) return undefined;
29
+ openCodeBackend(provider); // hard, clear error for an unknown provider — same boundary as below.
30
+ return record;
31
+ }
32
+
33
+ /** The credential env var for a recognized backend id, or undefined for an unknown id. */
34
+ export function openCodeBackendApiKeyEnvVar(backendID: string): string | undefined {
35
+ return OPENCODE_STATIC_BACKENDS[backendID]?.envVar;
36
+ }
37
+
38
+ /** Catalog alias → provider model (older runner protocols carry the alias without a record). */
39
+ function openCodeAliasResolved(model: string | undefined, manifest: ProviderManifest, provider: string): string {
40
+ const alias = model?.trim() || manifest.catalog.defaultModel;
41
+ const raw = manifest.catalog.models.find((entry) => entry.alias === alias)?.providerModel || alias;
42
+ if (!raw) throw new Error(`OpenCode provider ${provider} has no default model.`);
43
+ return raw;
44
+ }
45
+
46
+ /**
47
+ * #2659 — the launch model: THE one normalization of a spawn's model through the shared seam
48
+ * (`resolveOpenCodeModelId`). A server-threaded record's qualified name wins over the alias.
49
+ * With a host registry (spawn), unknown/ambiguous ids throw with candidates; without one
50
+ * (static fallback) a known-backend first segment is structural, anything else is a
51
+ * default-backend model id.
52
+ */
53
+ export function openCodeLaunchModel(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord, registry?: OpenCodeModelRegistry): OpenCodeModelId {
54
+ const { manifest, backend } = openCodeBackend(provider);
55
+ const input = record?.qualifiedModel || openCodeAliasResolved(model, manifest, provider);
56
+ return resolveOpenCodeModelId(input, backend.opencodeProviderID, registry);
57
+ }
58
+
59
+ /** Qualified runtime name (`providerID/modelID`) for a model/record, static resolution. */
60
+ export function openCodeQualifiedModel(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord): string {
61
+ return openCodeLaunchModel(model, provider, record).qualified;
62
+ }
63
+
64
+ /** v1.18.21's session API separates provider id from model id; only the first slash is structural. */
65
+ export function openCodeModelDescriptor(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord): { providerID: string; modelID: string } {
66
+ const { backendID, modelID } = openCodeLaunchModel(model, provider, record);
67
+ return { providerID: backendID, modelID };
68
+ }
69
+
70
+ /**
71
+ * #2659 — the per-turn descriptor for an ALREADY-normalized qualified name (process meta.model).
72
+ * Never re-resolves: re-running the static split here re-prefixed live-only backends with the
73
+ * default backend on every chat turn.
74
+ */
75
+ export function openCodeTurnDescriptor(qualified: string, provider = "opencode"): { providerID: string; modelID: string } {
76
+ const { backend } = openCodeBackend(provider);
77
+ const { backendID, modelID } = splitQualifiedOpenCodeModel(qualified, backend.opencodeProviderID);
78
+ return { providerID: backendID, modelID };
79
+ }
80
+
81
+ /**
82
+ * #2600 — the config-block + credential-env half of the per-model backend decision: the
83
+ * openCodeProviderID to key `provider.<id>` under, the apiKey env var the config references and
84
+ * the launch env must carry (undefined for a live-registry backend Relay has no credential
85
+ * mapping for — opencode then authenticates it from the inherited env itself), and any
86
+ * hand-configured npm/baseURL/extraOptions carried over from the Relay provider's own backend
87
+ * (the `zai` custom-endpoint shape).
88
+ */
89
+ export function openCodeResolvedBackend(backendID: string, provider = "opencode"): {
90
+ opencodeProviderID: string;
91
+ apiKeyEnvVar?: string;
92
+ hostAuth?: boolean;
93
+ npm?: string;
94
+ baseURL?: string;
95
+ extraOptions?: Record<string, unknown>;
96
+ } {
97
+ const { backend } = openCodeBackend(provider);
98
+ // Default backend: reuse the manifest's own hand-configuration byte-for-byte.
99
+ if (backendID === backend.opencodeProviderID) return { ...backend };
100
+ // A custom Relay provider aliased onto this backend (e.g. `zai/...` on `opencode` reuses the
101
+ // `zai` manifest's npm/baseURL/apiKeyEnvVar) so the config block matches a native launch.
102
+ const aliased = getAllManifests().find((entry) => entry.opencode?.opencodeProviderID === backendID)?.opencode;
103
+ if (aliased) return { ...aliased };
104
+ // Built-in backend: opencode ships the def; declare only the credential reference.
105
+ const apiKeyEnvVar = openCodeBackendApiKeyEnvVar(backendID);
106
+ return { opencodeProviderID: backendID, ...(apiKeyEnvVar ? { apiKeyEnvVar } : {}) };
107
+ }
108
+
109
+ /** Backend ids Relay itself configures (custom manifests) — always valid registry providers. */
110
+ export function openCodeCustomBackendIDs(): string[] {
111
+ return getAllManifests().flatMap((entry) => entry.opencode && OPENCODE_STATIC_BACKENDS[entry.opencode.opencodeProviderID]?.custom ? [entry.opencode.opencodeProviderID] : []);
112
+ }
113
+
114
+ /** Read the current backend's declared context window from the manifest catalog. */
115
+ export function openCodeContextWindowTokens(model: string | undefined, provider = "opencode", record?: ResolvedModelRecord): number | undefined {
116
+ // #2599 — precedence payload > manifest > undefined: the record's window wins; a malformed
117
+ // record window never reaches here (the shared parser drops it), so no second gate is needed.
118
+ if (typeof record?.contextWindowTokens === "number" && Number.isFinite(record.contextWindowTokens) && record.contextWindowTokens > 0) {
119
+ openCodeBackend(provider); // keep the unknown-provider hard error on the record path too.
120
+ return record.contextWindowTokens;
121
+ }
122
+ const { manifest } = openCodeBackend(provider);
123
+ const { modelID } = openCodeLaunchModel(model, provider);
124
+ const value = manifest.catalog.models.find((entry) => entry.alias === modelID || entry.providerModel === modelID)?.limits?.contextWindowTokens?.value;
125
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
126
+ }
127
+
128
+ /**
129
+ * #2599 — the record's explicit threshold, when present (precedence payload > manifest-derived).
130
+ * Returns undefined when the record carries none — the caller then derives from the window.
131
+ */
132
+ export function openCodeContextThreshold(record: ResolvedModelRecord | undefined, provider = "opencode"): ResolvedModelRecord["contextThreshold"] {
133
+ if (!record?.contextThreshold) return undefined;
134
+ openCodeBackend(provider); // keep the unknown-provider hard error on the record path too.
135
+ return record.contextThreshold;
136
+ }
137
+
138
+ /**
139
+ * #2600/#2659 — the selected backend's credential from the runner or provider-config env
140
+ * (`||`: an empty runner var must not mask a provider-config key). A live-registry backend
141
+ * without a Relay credential mapping has none — opencode authenticates it from the inherited env.
142
+ */
143
+ export function openCodeBackendCredential(apiKeyEnvVar: string | undefined, runnerEnv: Record<string, string>, providerEnv: Record<string, string>, hostAuthBackendID?: string): string | undefined {
144
+ if (!apiKeyEnvVar) return undefined;
145
+ const apiKey = relayEnv(runnerEnv, apiKeyEnvVar) || relayEnv(providerEnv, apiKeyEnvVar) || (hostAuthBackendID ? openCodeHostAuthKey(hostAuthBackendID, runnerEnv) : undefined);
146
+ if (!apiKey) throw new Error(`OpenCode requires ${apiKeyEnvVar} in the runner process or provider config environment${hostAuthBackendID ? ` (or an \`opencode auth login\` entry for ${hostAuthBackendID} on this host)` : ""}.`);
147
+ return apiKey;
148
+ }
149
+
150
+ /**
151
+ * #2866 — the host's own `opencode auth login` key (`<data home>/opencode/auth.json`), which the
152
+ * isolated XDG launch hides from the server. Only for manifests with `opencode.hostAuth`.
153
+ */
154
+ export function openCodeHostAuthKey(backendID: string, env: Record<string, string>): string | undefined {
155
+ const dataHome = env.XDG_DATA_HOME || process.env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share");
156
+ try {
157
+ const entry = (JSON.parse(readFileSync(join(dataHome, "opencode", "auth.json"), "utf8")) as Record<string, unknown>)[backendID];
158
+ const key = typeof entry === "object" && entry !== null ? (entry as { key?: unknown }).key : undefined;
159
+ return typeof key === "string" && key.trim() ? key.trim() : undefined;
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ /** #2659 — is the session still on its launch model (no terminal model switch adopted)? */
166
+ export function openCodeOnLaunchModel(meta: Record<string, unknown> | undefined, model: string | undefined): boolean {
167
+ const launchModel = typeof meta?.launchModel === "string" ? meta.launchModel : undefined;
168
+ return !launchModel || !model || model === launchModel;
169
+ }
@@ -0,0 +1,50 @@
1
+ // #2775 — OpenCode's OWN in-session retry. On a retryable provider error OpenCode does not end the
2
+ // turn: it reports `session.status` `{ type: "retry", attempt, message, next, action? }` and keeps
3
+ // the synchronous /message POST open until it succeeds. A free-tier wall schedules `next` for the
4
+ // daily reset (hours out), so the agent sat busy and silent. These pure helpers decide when that
5
+ // retry is a wall to hold on, and what the operator sees meanwhile.
6
+
7
+ import { isRecord, stringValue, type ProviderState } from "@humwick/sdk";
8
+ import { buildRateLimitProviderState, isProviderUsageLimit } from "../rate-limit";
9
+
10
+ export interface OpenCodeServerRetry {
11
+ attempt: number;
12
+ message: string;
13
+ /** `action.reason` when OpenCode classifies the refusal (e.g. `free_tier_limit`). */
14
+ reason?: string;
15
+ /** Unix ms of OpenCode's next attempt. */
16
+ next?: number;
17
+ }
18
+
19
+ /** A retry scheduled at least this far out is a wall, not a transient backoff. */
20
+ export const OPENCODE_SERVER_RETRY_WALL_DELAY_MS = 5 * 60_000;
21
+ /** OpenCode retries a throttle forever; past this many attempts we stop waiting and hold. */
22
+ export const OPENCODE_SERVER_RETRY_MAX_ATTEMPTS = 5;
23
+
24
+ export function parseOpenCodeServerRetry(status: unknown): OpenCodeServerRetry | undefined {
25
+ if (!isRecord(status) || status.type !== "retry") return undefined;
26
+ const action = isRecord(status.action) ? status.action : undefined;
27
+ const reason = stringValue(action?.reason);
28
+ return {
29
+ attempt: typeof status.attempt === "number" ? status.attempt : 1,
30
+ message: stringValue(status.message) ?? stringValue(action?.title) ?? "OpenCode is retrying the provider request",
31
+ ...(reason ? { reason } : {}),
32
+ ...(typeof status.next === "number" && Number.isFinite(status.next) ? { next: status.next } : {}),
33
+ };
34
+ }
35
+
36
+ export function isOpenCodeServerRetryWall(retry: OpenCodeServerRetry, nowMs: number = Date.now()): boolean {
37
+ return isProviderUsageLimit({ message: retry.message, ...(retry.reason ? { errorType: retry.reason } : {}) })
38
+ || retry.attempt > OPENCODE_SERVER_RETRY_MAX_ATTEMPTS
39
+ || (retry.next !== undefined && retry.next - nowMs >= OPENCODE_SERVER_RETRY_WALL_DELAY_MS);
40
+ }
41
+
42
+ /** The visible "still retrying" state for a transient OpenCode retry (see openCodeUpstreamRetryState for why the reason is not `rate_limit`). */
43
+ export function openCodeServerRetryState(retry: OpenCodeServerRetry): ProviderState {
44
+ return {
45
+ ...buildRateLimitProviderState({ kind: "transient_overload", errorType: retry.reason ?? "provider_error", message: retry.message, source: "opencode" }),
46
+ reason: "provider-error",
47
+ label: `OpenCode provider error, retrying (attempt ${retry.attempt}): ${retry.message}`,
48
+ resumeAttempt: retry.attempt,
49
+ };
50
+ }
@@ -0,0 +1,302 @@
1
+ // #2539 Phase B — OpenCode/ZAI non-destructive soft-steer lane.
2
+ //
3
+ // The soft op is `POST /session/:id/message` with a CALLER messageID + `noReply:true`: it
4
+ // persists a user row WITHOUT joining/cancelling the active run, and the run's loop reloads
5
+ // full messages at its next step, consuming the row there. LIVE-PROVEN on the pinned 1.18.21
6
+ // binary (zai backend): ~9-21ms persist, zero aborts across mid-tool/multi/at-stop scenarios,
7
+ // consumption visible as assistant steps with `info.parentID == the soft batch's user id`, and
8
+ // the at-turn-stop race stranding the row until the NEXT turn consumes it — which is exactly
9
+ // what the bounded idle-wake fallback covers.
10
+ //
11
+ // This module owns the pure/serial parts of that lane: the kill switch, batch composition, the
12
+ // messageID mint, and the durable-until-consumed ledger (persistence != delivery). The adapter
13
+ // (opencode.ts) owns the seams: the client POST, the SSE consumption door, the turn-close wake
14
+ // hook and the session-swap call sites. Everything here is deliberately free of provider I/O so
15
+ // the whole state machine is unit-testable without a server.
16
+
17
+ import { randomBytes } from "node:crypto";
18
+ import { errMessage, type Message } from "@humwick/sdk";
19
+ import { providerMessageText } from "../adapter";
20
+ import type { ProviderInputObserver } from "../adapter";
21
+ import { logger } from "../logger";
22
+
23
+ /**
24
+ * The one-row-per-batch marker the design mandates: tells the model the payload is a steer, not
25
+ * a new assignment, and that completed work must not be discarded to act on it.
26
+ */
27
+ export const OPENCODE_SOFT_STEER_MARKER = "Agent Relay soft steer: incorporate at next safe step; do not discard completed work.";
28
+
29
+ /** Short deadlines only — never the 60m whole-turn budget (#2539 design F). */
30
+ export const OPENCODE_SOFT_STEER_POST_TIMEOUT_MS = 5_000;
31
+ export const OPENCODE_SOFT_STEER_RECONCILE_TIMEOUT_MS = 5_000;
32
+
33
+ /** Backpressure caps (design F): overflow defers back to the normal queue instead of unbounded ledger growth. */
34
+ export const OPENCODE_SOFT_STEER_MAX_BATCHES = 32;
35
+ export const OPENCODE_SOFT_STEER_MAX_BATCH_BYTES = 48 * 1024;
36
+
37
+ /**
38
+ * The bounded idle-wake fallback: exactly ONE content-free wake turn when the provider goes idle
39
+ * with unconsumed batches. It carries NO new content (the steer rows are already persisted in the
40
+ * session context — proven live) and never resends the steer payload. Going through the normal
41
+ * `send()` turn gives the wake a real busy/idle pair and the full retry semantics instead of a
42
+ * second, parallel turn machinery.
43
+ */
44
+ export const OPENCODE_SOFT_STEER_WAKE_TEXT = "Agent Relay: soft-steer guidance above is pending. Incorporate it now at this safe step; do not discard completed work. If nothing needs incorporating, reply with a single line: ACK.";
45
+
46
+ /**
47
+ * Caller-side OpenCode messageID for a soft batch — `msg_` prefix (the shape the server accepts
48
+ * and echoes back verbatim, live-verified) plus a `relay` brand so an operator can recognize
49
+ * relay-injected rows in the session transcript at a glance.
50
+ */
51
+ export function newSoftSteerMessageID(): string {
52
+ return `msg_relay${randomBytes(8).toString("hex")}`;
53
+ }
54
+
55
+ export interface ComposedSoftSteerBatch {
56
+ text: string;
57
+ included: Message[];
58
+ deferred: Message[];
59
+ }
60
+
61
+ /**
62
+ * Compose ALL selected messages into ONE OpenCode user row, sorted by Relay id (arrival order),
63
+ * under the byte cap. Messages that would overflow the cap are returned as `deferred` so the
64
+ * caller backpressures them into the normal queue instead of silently dropping them. At least
65
+ * one message is always included: an oversized single message rides the soft lane truncated by
66
+ * the provider's own rendering limits rather than being dropped.
67
+ */
68
+ export function composeSoftSteerText(messages: Message[]): ComposedSoftSteerBatch {
69
+ const sorted = [...messages].sort((a, b) => a.id - b.id);
70
+ const sections: string[] = [];
71
+ const included: Message[] = [];
72
+ const deferred: Message[] = [];
73
+ // Per-message candidates are only the BYTE PROJECTION for the cap decision (they over-count
74
+ // by the guidance each per-message call appends, so the cap stays a safe upper bound); the
75
+ // final row is rendered ONCE below so the notification guidance appears once
76
+ // per batch, not once per message.
77
+ for (const message of sorted) {
78
+ const candidate = providerMessageText([message]);
79
+ const projected = sections.length === 0 ? candidate : `${sections.join("\n\n")}\n\n${candidate}`;
80
+ if (sections.length > 0 && Buffer.byteLength(projected, "utf8") > OPENCODE_SOFT_STEER_MAX_BATCH_BYTES) {
81
+ deferred.push(message);
82
+ continue;
83
+ }
84
+ sections.push(candidate);
85
+ included.push(message);
86
+ }
87
+ const text = sections.length > 0
88
+ ? `${OPENCODE_SOFT_STEER_MARKER}\n\n${providerMessageText(included)}`
89
+ : "";
90
+ return { text, included, deferred };
91
+ }
92
+
93
+ export interface SoftSteerEntry {
94
+ inputObserver?: ProviderInputObserver;
95
+ messageID: string;
96
+ relayIds: number[];
97
+ sessionId: string;
98
+ acceptedAt: number;
99
+ }
100
+
101
+ /**
102
+ * #2539 Phase A reconciliation — the ledger's settlement exits are the RUNNER's seams, handed in
103
+ * through ProviderDeliverContext (settleMidTurnConsumed / reofferMidTurnIds). The ledger never
104
+ * settles on its own: an absent callback is a fail-safe hold, never a silent settle.
105
+ */
106
+ export interface SoftSteerCallbacks {
107
+ settleMidTurnConsumed?: (relayIds: number[]) => void;
108
+ reofferMidTurnIds?: (relayIds: number[]) => void;
109
+ }
110
+
111
+ export interface ArmedWake {
112
+ targets: string[];
113
+ }
114
+
115
+ /**
116
+ * Durable-until-consumed ledger for positively-persisted soft batches. "Durable" is scoped to
117
+ * the adapter instance lifetime on purpose: the runner holds the only authoritative queue, and
118
+ * every ledger exit (consumption, session replacement, shutdown) hands relay ids BACK to the
119
+ * runner through the settlement callbacks — the ledger itself never drops an id silently.
120
+ */
121
+ export class OpenCodeSoftSteerLedger {
122
+ private entries = new Map<string, SoftSteerEntry>();
123
+ private callbacks: SoftSteerCallbacks = {};
124
+ private wake: ArmedWake | undefined;
125
+
126
+ setCallbacks(callbacks: SoftSteerCallbacks): void {
127
+ this.callbacks = callbacks;
128
+ }
129
+
130
+ isTrackedUserId(messageID: string): boolean {
131
+ return this.entries.has(messageID);
132
+ }
133
+
134
+ awaitingCount(): number {
135
+ return this.entries.size;
136
+ }
137
+
138
+ hasAwaiting(sessionId: string): boolean {
139
+ for (const entry of this.entries.values()) if (entry.sessionId === sessionId) return true;
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Record a batch. Returns false when the ledger is at its backpressure cap — the caller must
145
+ * treat a POSITIVE persist as deferred (normal queue) instead of tracking it, so caps degrade
146
+ * to the old behavior rather than to dropped messages. An UNKNOWN-outcome batch must always be
147
+ * tracked (`force: true`): the cap gates new positive persists, never the resolvability of an
148
+ * already-attempted POST — an untracked unknown batch could never be resolved by the
149
+ * consumption door, session replacement, or shutdown reoffer, yet the deliverMidTurn contract
150
+ * reported it as held (silent loss).
151
+ */
152
+ noteBatch(messageID: string, relayIds: number[], sessionId: string, opts?: { force?: boolean; inputObserver?: ProviderInputObserver }): boolean {
153
+ if (!opts?.force && this.entries.size >= OPENCODE_SOFT_STEER_MAX_BATCHES) return false;
154
+ this.entries.set(messageID, { messageID, relayIds, sessionId, acceptedAt: Date.now(), inputObserver: opts?.inputObserver });
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * Consumption door: an assistant message whose `info.parentID` equals a tracked batch's user
160
+ * id proves the model consumed that batch (live-proven). FIFO settle: the matched batch and
161
+ * every OLDER pending batch settle together (the loop consumes rows in order, and an older
162
+ * row still unconsumed when a newer one is consumed is consumed by that same step's reload).
163
+ * Returns the settled relay ids — empty when the parent matches nothing tracked, or when
164
+ * settlement failed and the entries were re-inserted for a later attempt.
165
+ */
166
+ async consumeByParent(parentMessageID: string, sessionId: string): Promise<number[]> {
167
+ const matched = this.entries.get(parentMessageID);
168
+ if (!matched || matched.sessionId !== sessionId) return [];
169
+ const collected: SoftSteerEntry[] = [];
170
+ for (const [id, entry] of this.entries) {
171
+ collected.push(entry);
172
+ this.entries.delete(id);
173
+ if (id === parentMessageID) break;
174
+ }
175
+ return this.settle(collected, "consumed");
176
+ }
177
+
178
+ /**
179
+ * Session replacement (clear swap / reroll): batches bound to any OTHER session can never be
180
+ * consumed on the new one. Drop them and reoffer their relay ids to the normal queue — never a
181
+ * repost into the new session (that would duplicate content the new session intentionally
182
+ * starts without). Returns the reoffered relay ids — empty when nothing was pending, or when
183
+ * settlement failed and the entries were re-inserted for a later attempt.
184
+ */
185
+ async sessionReplaced(activeSessionId: string): Promise<number[]> {
186
+ const collected: SoftSteerEntry[] = [];
187
+ for (const [id, entry] of this.entries) {
188
+ if (entry.sessionId !== activeSessionId) {
189
+ collected.push(entry);
190
+ this.entries.delete(id);
191
+ }
192
+ }
193
+ return this.settle(collected, "reoffer");
194
+ }
195
+
196
+ /** Teardown: everything left goes back to the runner via the settlement callback. */
197
+ async reofferAll(): Promise<number[]> {
198
+ const collected = [...this.entries.values()];
199
+ this.entries.clear();
200
+ return this.settle(collected, "reoffer");
201
+ }
202
+
203
+ /**
204
+ * Arm the single bounded idle-wake over the awaiting batches of ONE session. Exactly one wake
205
+ * at a time (design F): a second call while one is pending is a no-op. Returns the wake token
206
+ * (target message ids snapshot) or undefined when there is nothing to wake for.
207
+ */
208
+ armWake(sessionId: string): ArmedWake | undefined {
209
+ if (this.wake) return undefined;
210
+ const targets: string[] = [];
211
+ for (const [id, entry] of this.entries) if (entry.sessionId === sessionId) targets.push(id);
212
+ if (targets.length === 0) return undefined;
213
+ this.wake = { targets };
214
+ return this.wake;
215
+ }
216
+
217
+ /**
218
+ * Wake turn completed successfully: settle its target batches (what they carried is provably
219
+ * in the session context the wake turn just ran against). Skips targets already settled by the
220
+ * consumption door mid-flight. Returns the settled relay ids — empty when a target was already
221
+ * settled, or when settlement failed and the entries were re-inserted for a later attempt.
222
+ * A FAILED wake turn must not call this at all: its entries stay pending for session
223
+ * replacement / shutdown reoffer (a failed session must not mark anything delivered).
224
+ */
225
+ async settleWakeTargets(wake: ArmedWake): Promise<number[]> {
226
+ const collected: SoftSteerEntry[] = [];
227
+ for (const id of wake.targets) {
228
+ const entry = this.entries.get(id);
229
+ if (!entry) continue;
230
+ collected.push(entry);
231
+ this.entries.delete(id);
232
+ }
233
+ return this.settle(collected, "consumed");
234
+ }
235
+
236
+ endWake(): void {
237
+ this.wake = undefined;
238
+ }
239
+
240
+ isWakePending(): boolean {
241
+ return this.wake !== undefined;
242
+ }
243
+
244
+ /**
245
+ * Fire settleMidTurnConsumed. Awaits the callback (the runner's settlement fns are async) and
246
+ * reports failure instead of swallowing it — a failed delivery report must fall back to reoffer,
247
+ * never vanish behind a log line. An ABSENT callback is a failure too (fail-safe): a runner
248
+ * wiring miss must hold the ids in the ledger for a later exit, never silently "settle" them.
249
+ */
250
+ async notifyConsumed(relayIds: number[]): Promise<boolean> {
251
+ if (relayIds.length === 0) return true;
252
+ if (!this.callbacks.settleMidTurnConsumed) {
253
+ logger.error("opencode", `soft-steer settleMidTurnConsumed callback ABSENT — holding ${relayIds.length} relay id(s) in the ledger (fail-safe, never silently settled)`);
254
+ return false;
255
+ }
256
+ try {
257
+ await this.callbacks.settleMidTurnConsumed(relayIds);
258
+ return true;
259
+ } catch (error) {
260
+ logger.error("opencode", `soft-steer settleMidTurnConsumed callback failed: ${errMessage(error)}`);
261
+ return false;
262
+ }
263
+ }
264
+
265
+ /** Fire onReoffer — same failure-visible contract as notifyConsumed, absent-callback fail-safe included. */
266
+ async notifyReoffer(relayIds: number[]): Promise<boolean> {
267
+ if (relayIds.length === 0) return true;
268
+ if (!this.callbacks.reofferMidTurnIds) {
269
+ logger.error("opencode", `soft-steer reofferMidTurnIds callback ABSENT — holding ${relayIds.length} relay id(s) in the ledger (fail-safe, never silently settled)`);
270
+ return false;
271
+ }
272
+ try {
273
+ await this.callbacks.reofferMidTurnIds(relayIds);
274
+ return true;
275
+ } catch (error) {
276
+ logger.error("opencode", `soft-steer reofferMidTurnIds callback failed: ${errMessage(error)}`);
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Sole ledger-exit settlement path: a failed settleMidTurnConsumed falls back to
283
+ * reofferMidTurnIds (at-least-once
284
+ * — a duplicate delivery beats a silent loss), and if BOTH settlement callbacks fail the
285
+ * entries are re-inserted so a later exit (consumption door, session replacement, shutdown)
286
+ * can still resolve them. Robust to sync throws AND async rejections: the awaits observe
287
+ * both. Returns the settled relay ids, or [] when the entries were re-inserted.
288
+ */
289
+ private async settle(collected: SoftSteerEntry[], kind: "consumed" | "reoffer"): Promise<number[]> {
290
+ const relayIds = collected.flatMap((entry) => entry.relayIds);
291
+ if (relayIds.length === 0) return [];
292
+ if (kind === "consumed") for (const entry of collected) entry.inputObserver?.consumed();
293
+ const primary = kind === "consumed" ? await this.notifyConsumed(relayIds) : await this.notifyReoffer(relayIds);
294
+ if (primary) return relayIds;
295
+ if (kind === "consumed" && (await this.notifyReoffer(relayIds))) return relayIds;
296
+ // Map.set appends: a re-inserted OLDER entry may temporarily sit after newer batches —
297
+ // latency-only (the FIFO door settles it alongside a newer parent, and wake/reoffer exits
298
+ // scan the whole map). Intentional: every entry stays resolvable.
299
+ for (const entry of collected) this.entries.set(entry.messageID, entry);
300
+ return [];
301
+ }
302
+ }
@@ -0,0 +1,145 @@
1
+ import { writeChildEnv } from "@humwick/sdk/env";
2
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { sanitizeFsName } from "@humwick/sdk/fs-name";
5
+ import { providerScratchEnvOf } from "@humwick/sdk/provider-scratch";
6
+ import { openCodeProjectShellEnv } from "./project-shell";
7
+ import { providerHomeRootFromEnv } from "../config";
8
+ import { materializeProviderHome } from "../profile-home";
9
+ import { syncOpenCodeRedactPlugin } from "../agent-redact";
10
+ import type { ProviderConfig, RunnerSpawnConfig, SpawnArgs } from "../adapter";
11
+ import { allocateLoopbackPort } from "../free-port";
12
+ import { assembleLaunch, openCodeMcpConfigForLaunch } from "../launch-assembly";
13
+ import { openCodePermissions, openCodeToolsConfig } from "./opencode-tools-config";
14
+ import { openCodeApiKey } from "./opencode-api";
15
+ import { openCodeBackend, openCodeBackendCredential, openCodeResolvedBackend, type OpenCodeModelId } from "./opencode-provider";
16
+
17
+ export function assertOpenCodeApprovalMode(approvalMode: string | undefined): asserts approvalMode is "open" {
18
+ if (approvalMode !== "open") {
19
+ throw new Error(`OpenCode only supports approvalMode=open; received ${approvalMode ?? "unset"}. Guarded and read-only are not available and will not fall back to open.`);
20
+ }
21
+ }
22
+
23
+ // The relay owns this server's network boundary. In particular, keeping these values at the
24
+ // front of argv is not enough: OpenCode accepts later values as overrides. Reject every spelling
25
+ // that can alter the listener or turn on discovery/cross-origin access instead of trying to
26
+ // sanitize and then silently changing a caller's intended command line.
27
+ const MANAGED_SERVER_ARGUMENTS = new Set([
28
+ "--hostname", "--host", "-h",
29
+ "--port", "-p",
30
+ "--mdns", "--no-mdns",
31
+ "--cors", "--no-cors",
32
+ ]);
33
+
34
+ export function assertOpenCodeManagedServerArgs(providerArgs: string[]): void {
35
+ for (const raw of providerArgs) {
36
+ const option = raw.split("=", 1)[0]!.toLowerCase();
37
+ if (MANAGED_SERVER_ARGUMENTS.has(option)) {
38
+ throw new Error(`OpenCode manages ${option}; providerArgs cannot override the loopback listener, managed port, mDNS, or CORS.`);
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Prevent config substitution in caller-supplied model names (#2378). */
44
+ export function assertOpenCodeSafeModel(model: string | undefined): void {
45
+ if (model?.includes("{")) {
46
+ throw new Error(`OpenCode model must not contain "{" (would be spliced by OpenCode's own {env:...}/{file:...} config substitution): received ${JSON.stringify(model)}`);
47
+ }
48
+ }
49
+
50
+ /** Preserve host gh auth while isolating OpenCode XDG state (#2531). */
51
+ export function resolveGhConfigDir(inputEnv: Record<string, string | undefined>): string | undefined {
52
+ const explicit = inputEnv.GH_CONFIG_DIR;
53
+ if (explicit) return explicit;
54
+ const hostConfigHome = inputEnv.XDG_CONFIG_HOME;
55
+ if (hostConfigHome) return join(hostConfigHome, "gh");
56
+ const home = inputEnv.HOME;
57
+ if (home) return join(home, ".config", "gh");
58
+ return undefined;
59
+ }
60
+
61
+ export function buildOpenCodeSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig, provider: string, launch: OpenCodeModelId): SpawnArgs {
62
+ assertOpenCodeApprovalMode(config.approvalMode);
63
+ assertOpenCodeManagedServerArgs(config.providerArgs);
64
+ assertOpenCodeSafeModel(config.model);
65
+ assertOpenCodeSafeModel(config.modelRecord?.qualifiedModel);
66
+ openCodeBackend(provider);
67
+ // The selected model owns its backend and credential (#2599/#2600).
68
+ const resolved = openCodeResolvedBackend(launch.backendID, provider);
69
+ // `||`, not `??`: an empty-string runner env var must not mask a valid providerConfig key.
70
+ const apiKey = openCodeApiKey(config, launch.qualified) ?? openCodeBackendCredential(resolved.apiKeyEnvVar, config.env, providerConfig.env, resolved.hostAuth ? resolved.opencodeProviderID : undefined);
71
+ const assembled = assembleLaunch(provider, config, providerConfig);
72
+ const profile = sanitizeFsName(config.agentProfile?.name || config.profile || "default", { replacement: "-", lowercase: true });
73
+ const instanceId = sanitizeFsName(config.instanceId || config.runnerId, { replacement: "-", lowercase: true });
74
+ const root = join(providerHomeRootFromEnv(), provider, profile, instanceId);
75
+ materializeProviderHome(root, { provider: provider, profile, instanceId, agentId: config.agentId }, 0o700);
76
+ const xdgConfig = join(root, "config");
77
+ const xdgData = join(root, "data");
78
+ const xdgState = join(root, "state");
79
+ const xdgCache = join(root, "cache");
80
+ const configDir = join(xdgConfig, "opencode");
81
+ for (const path of [xdgConfig, xdgData, xdgState, xdgCache, configDir]) mkdirSync(path, { recursive: true, mode: 0o700 });
82
+ // Permission is pinned in the isolated server config. A guarded/read-only request rejects
83
+ // before this file is written, so a policy downgrade can never become a permissive launch.
84
+ const configFile = join(configDir, "opencode.json");
85
+ const mcp = openCodeMcpConfigForLaunch(config);
86
+ // Keep credentials out of disk config using native env references (#2378).
87
+ writeFileSync(configFile, `${JSON.stringify({
88
+ $schema: "https://opencode.ai/config.json",
89
+ model: launch.qualified,
90
+ ...(launch.backendID === "openai" ? { small_model: launch.qualified, enabled_providers: ["openai"] } : {}),
91
+ provider: {
92
+ [resolved.opencodeProviderID]: {
93
+ // A custom Relay backend (e.g. zai) carries its own adapter package; built-in
94
+ // backends (opencode, openrouter, anthropic, ...) ship inside opencode itself.
95
+ ...(resolved.npm ? { npm: resolved.npm } : {}),
96
+ options: { ...resolved.extraOptions, ...(launch.backendID === "openai" ? { baseURL: "https://api.openai.com/v1", serviceTier: "default" } : {}), ...(resolved.baseURL ? { baseURL: resolved.baseURL } : {}), ...(resolved.apiKeyEnvVar ? { apiKey: `{env:${resolved.apiKeyEnvVar}}` } : {}) },
97
+ models: { [launch.modelID]: launch.backendID === "openai" ? { limit: { context: 272_000, output: 128_000 } } : {} },
98
+ },
99
+ },
100
+ permission: openCodePermissions(config), ...(Object.keys(mcp).length ? { mcp } : {}), ...openCodeToolsConfig(config, Object.keys(mcp).length > 0),
101
+ }, null, 2)}\n`, { mode: 0o600 });
102
+ chmodSync(configFile, 0o600);
103
+ // #3025 — `--pure` skips every plugin, so it is dropped only when agent-redact's plugin is in place.
104
+ const pure = syncOpenCodeRedactPlugin(configDir, assembled.redaction) ? [] : ["--pure"];
105
+ // Bridge gh from the host env before overlaying isolated XDG roots (#2531).
106
+ const ghConfigDir = resolveGhConfigDir({ ...config.env, ...providerConfig.env, ...assembled.env });
107
+ // #2719 — allocated, never derived from another port. `controlPort + 1_000` overflowed 65535
108
+ // whenever the OS handed the runner a high ephemeral control port (macOS: 49152-65535), and
109
+ // `serve --port 66178` did not fail loudly: opencode bound a different port, so readiness
110
+ // polled the wrong URL for 20s and the runner exited 1 with no report.
111
+ const port = allocateLoopbackPort();
112
+ const serverUrl = `http://127.0.0.1:${port}`;
113
+ const env: SpawnArgs["env"] = writeChildEnv({}, {
114
+ ...config.env,
115
+ ...providerConfig.env,
116
+ // #3084 — the runner already nested the agent's private TMPDIR under any provider-config one;
117
+ // the shell hooks keep it (and #2947's toolchain PATH) after a login profile runs.
118
+ ...providerScratchEnvOf(config.env),
119
+ ...openCodeProjectShellEnv(config.env),
120
+ ...assembled.env,
121
+ // #2531 — gh (and git credential helpers wrapping gh) read the HOST gh config even
122
+ // under the isolated XDG roots below. No other credential surface is bridged.
123
+ ...(ghConfigDir ? { GH_CONFIG_DIR: ghConfigDir } : {}),
124
+ XDG_CONFIG_HOME: xdgConfig,
125
+ XDG_DATA_HOME: xdgData,
126
+ XDG_STATE_HOME: xdgState,
127
+ XDG_CACHE_HOME: xdgCache,
128
+ // The server subprocess receives a deliberately assembled environment; retain the
129
+ // executable search path while keeping its XDG state isolated.
130
+ PATH: config.env.PATH ?? providerConfig.env.PATH ?? process.env.PATH ?? "",
131
+ ...(resolved.apiKeyEnvVar && apiKey ? { [resolved.apiKeyEnvVar]: apiKey } : {}),
132
+ OPENCODE_SERVER_PASSWORD: crypto.randomUUID(),
133
+ OPENCODE_SERVER_URL: serverUrl,
134
+ AGENT_RELAY_PROVIDER: provider,
135
+ });
136
+ if (launch.backendID === "openai") {
137
+ for (const key of ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENCODE_API_KEY"]) writeChildEnv(env, {}, [key]);
138
+ }
139
+ return {
140
+ command: providerConfig.command || "opencode",
141
+ args: ["serve", ...pure, "--hostname", "127.0.0.1", "--port", String(port), ...assembled.args, ...config.providerArgs],
142
+ cwd: config.cwd,
143
+ env,
144
+ };
145
+ }