@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,62 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { errMessage, isRecord } from "@humwick/sdk";
3
+ import { logger } from "../logger";
4
+ import type { OpenCodeServerEvent } from "./opencode";
5
+
6
+ export async function subscribeOpenCodeEvents(request: () => Promise<Response>, signal: AbortSignal, onEvent: (event: OpenCodeServerEvent) => void, retryDelay: (ms: number, signal: AbortSignal) => Promise<unknown> = (ms, abort) => delay(ms, undefined, { signal: abort })): Promise<void> {
7
+ let failures = 0;
8
+ while (!signal.aborted) {
9
+ try {
10
+ await consumeEvents(request, signal, onEvent, () => { failures = 0; });
11
+ if (signal.aborted) return;
12
+ throw new Error("event stream ended");
13
+ } catch (error) {
14
+ if (signal.aborted) return;
15
+ const delayMs = Math.min(30_000, 1_000 * 2 ** Math.min(failures++, 5));
16
+ logger.error("opencode", `SSE subscription failed: ${errMessage(error)}; reconnecting in ${delayMs}ms`);
17
+ try {
18
+ await retryDelay(delayMs, signal);
19
+ } catch (delayError) {
20
+ if (signal.aborted) return;
21
+ throw delayError;
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ async function consumeEvents(request: () => Promise<Response>, signal: AbortSignal, onEvent: (event: OpenCodeServerEvent) => void, onReceived: () => void): Promise<void> {
28
+ const response = await request();
29
+ if (!response.body) throw new Error("event stream has no body");
30
+ const reader = response.body.getReader();
31
+ const decoder = new TextDecoder();
32
+ let buffered = "";
33
+ try {
34
+ while (!signal.aborted) {
35
+ const read = await reader.read();
36
+ if (read.done) return;
37
+ buffered += decoder.decode(read.value, { stream: true });
38
+ const frames = buffered.split("\n\n");
39
+ buffered = frames.pop() ?? "";
40
+ for (const frame of frames) {
41
+ const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
42
+ if (!data) continue;
43
+ let event: unknown;
44
+ try {
45
+ event = JSON.parse(data);
46
+ } catch {
47
+ continue; // malformed event is isolated; the stream remains usable
48
+ }
49
+ if (isRecord(event) && typeof event.type === "string") {
50
+ onReceived();
51
+ try {
52
+ onEvent({ type: event.type, ...(isRecord(event.properties) ? { properties: event.properties } : {}) });
53
+ } catch (error) {
54
+ logger.error("opencode", `SSE event handler failed for ${event.type}: ${errMessage(error)}`);
55
+ }
56
+ }
57
+ }
58
+ }
59
+ } finally {
60
+ reader.releaseLock();
61
+ }
62
+ }
@@ -0,0 +1,187 @@
1
+ // #2484 — per-turn OpenCode/Zai token-usage capture, reusing the #2482/#2485 spine seam
2
+ // (ProviderTokenUsageReporter + POST /api/agents/:id/token-usage, validated live via Claude's
3
+ // #2491 capture). Unlike Claude, OpenCode never hands the runner a transcript file to tail: the
4
+ // `message.updated` SSE door already carries a structured per-assistant-message `tokens` object
5
+ // once a message finalizes — `{ input, output, reasoning, cache: { read, write } }`, confirmed
6
+ // live against a real captured v1.18.21 session (see opencode-context.ts's #2355 note beside the
7
+ // same wire shape). So there is no incremental-file tail here: this module is a pure decoder from
8
+ // that object to the spine's required breakdown, called straight from the SSE handler.
9
+ //
10
+ // Dedupe: the door fires more than once per message as it streams toward its final total (#2427).
11
+ // Keying on the message id makes every echo collapse onto the SAME spine row via the dedupe_key
12
+ // upsert (`created_at >= existing` replaces) — the row simply refines towards the message's true
13
+ // total rather than posting a new one per frame, exactly like Claude's uuid-keyed entries collapse
14
+ // repeated capture passes onto one row.
15
+ import { OPENAI_API_MODEL, isRecord, stringValue } from "@humwick/sdk";
16
+ import type { ProviderTokenUsageRecord, ProviderTokenUsageReporter } from "../adapter";
17
+
18
+ export type OpenCodeTokenUsageSpineRecord = ProviderTokenUsageRecord;
19
+
20
+ function numberOrZero(value: unknown): number {
21
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
22
+ }
23
+
24
+ /**
25
+ * Decompose OpenCode's per-message `tokens` object into the spine's required input/output/cache
26
+ * breakdown. Returns `undefined` when the shape is not the recognised record (absent, a bare
27
+ * number, or every leaf zero/unreadable) — never fabricating a breakdown it cannot actually read,
28
+ * mirroring the "only claim what was understood" rule `openCodeCompletion` already applies to this
29
+ * same wire family.
30
+ */
31
+ export function openCodeTokenUsageRecord(input: {
32
+ sessionId: string;
33
+ provider: string;
34
+ model: string;
35
+ messageId: string;
36
+ tokens: unknown;
37
+ createdAt?: number;
38
+ /** #3351 — the message is final, so unreadable counts mean unknown spend, not "not yet". */
39
+ finished?: boolean;
40
+ }): OpenCodeTokenUsageSpineRecord | undefined {
41
+ const unknown = (): OpenCodeTokenUsageSpineRecord | undefined => input.finished ? {
42
+ sessionId: input.sessionId, provider: input.provider, model: input.model,
43
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
44
+ dedupeKey: input.messageId, createdAt: input.createdAt ?? Date.now(), usageStatus: "unknown",
45
+ } : undefined;
46
+ if (!isRecord(input.tokens)) return unknown();
47
+ const cache = isRecord(input.tokens.cache) ? input.tokens.cache : undefined;
48
+ if (input.model === OPENAI_API_MODEL && ![input.tokens.input, input.tokens.output, cache?.read, cache?.write, input.tokens.reasoning === undefined ? 0 : input.tokens.reasoning]
49
+ .every(n => typeof n === "number" && Number.isSafeInteger(n) && n >= 0)) return unknown();
50
+ const inputTokens = numberOrZero(input.tokens.input);
51
+ const outputTokens = numberOrZero(input.tokens.output);
52
+ const reasoningOutputTokens = numberOrZero(input.tokens.reasoning);
53
+ const cacheReadTokens = numberOrZero(cache?.read);
54
+ const cacheWriteTokens = numberOrZero(cache?.write);
55
+ // A recognised-but-entirely-empty object carries no signal — the same no-signal rule
56
+ // `openCodeContextState` applies to a `tokensUsed:0` reading of this identical shape (#2355
57
+ // review MED-1: a genuinely engaged assistant message always has input > 0).
58
+ if (inputTokens === 0 && outputTokens === 0 && reasoningOutputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return unknown();
59
+ return {
60
+ sessionId: input.sessionId,
61
+ provider: input.provider,
62
+ model: input.model,
63
+ inputTokens,
64
+ outputTokens,
65
+ cacheReadTokens,
66
+ cacheWriteTokens,
67
+ ...(reasoningOutputTokens > 0 ? { reasoningOutputTokens } : {}),
68
+ dedupeKey: input.messageId,
69
+ createdAt: input.createdAt ?? Date.now(),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * #2484 round 1 review (F6) — the message's OWN observed usage time: `time.completed` once
75
+ * finalized, else `time.created` (present on every AssistantMessage, per opencode-context.ts's
76
+ * #2355 note on this same wire family). Reading this instead of `Date.now()` at report time
77
+ * removes the equal-millisecond upsert race for the TERMINAL echo specifically: every
78
+ * streaming echo of a still-open message shares the SAME `time.created`, and the terminal
79
+ * echo's own `time.completed` is always `>=` it — so the spine's `created_at >= existing`
80
+ * upsert can never let a stale reprocessing of an earlier, still-open echo clobber the
81
+ * terminal, complete read the way a receipt-time `Date.now()` could. (Round 2 review, LOW-1:
82
+ * two PRE-completion echoes of the same still-open message still share that same
83
+ * `time.created` and so are not individually ordered against each other by this alone — the
84
+ * debounced poster's own last-write-wins `schedule()` is what settles that case, since only
85
+ * the most recently scheduled record ever reaches `flush()`.)
86
+ */
87
+ export function openCodeMessageObservedAt(info: unknown): number | undefined {
88
+ if (!isRecord(info) || !isRecord(info.time)) return undefined;
89
+ const completed = info.time.completed;
90
+ if (typeof completed === "number" && Number.isFinite(completed)) return completed;
91
+ const created = info.time.created;
92
+ return typeof created === "number" && Number.isFinite(created) ? created : undefined;
93
+ }
94
+
95
+ /** Terminal messages can carry billed usage even when the turn errored or was aborted. */
96
+ export function openCodeMessageFinished(info: unknown): boolean {
97
+ return isRecord(info) && ((isRecord(info.time) && typeof info.time.completed === "number") || isRecord(info.error));
98
+ }
99
+
100
+ /**
101
+ * #2484 round 1 review (F5) — the model the message was actually generated with, read off its own
102
+ * `providerID`/`modelID` (the same shape `openCodeModelDescriptor` sends on the request) rather
103
+ * than the session's currently-configured model, which can have moved on (a mid-session
104
+ * model/provider swap, a #2397 retry re-roll) by the time this echo is processed.
105
+ */
106
+ export function openCodeMessageModel(info: unknown): string | undefined {
107
+ if (!isRecord(info)) return undefined;
108
+ const providerID = stringValue(info.providerID);
109
+ const modelID = stringValue(info.modelID);
110
+ return providerID && modelID ? `${providerID}/${modelID}` : undefined;
111
+ }
112
+
113
+ const TOKEN_USAGE_DEBOUNCE_MS = 300;
114
+
115
+ /**
116
+ * #2484 round 1 review (F2/F3) — the `message.updated` door fires repeatedly as one message
117
+ * streams to completion; posting on every echo means an HTTP POST + SQLite write per frame,
118
+ * fleet-wide, for a value the very next echo immediately supersedes. Trailing-debounce per
119
+ * message id: each new echo replaces the pending record and resets the timer, so a whole
120
+ * streaming burst collapses to ONE post carrying the terminal value. `flush`/`flushAll` let the
121
+ * adapter force an immediate post at a real turn boundary (turn close, shutdown) rather than
122
+ * risk losing the last burst's report to a timer that never gets to fire.
123
+ */
124
+ export class OpenCodeTokenUsagePoster {
125
+ private readonly pending = new Map<string, { record: OpenCodeTokenUsageSpineRecord; timer: ReturnType<typeof setTimeout> }>();
126
+
127
+ constructor(
128
+ private readonly reporter: () => ProviderTokenUsageReporter | undefined,
129
+ private readonly debounceMs = TOKEN_USAGE_DEBOUNCE_MS,
130
+ ) {}
131
+
132
+ schedule(record: OpenCodeTokenUsageSpineRecord): void {
133
+ const prior = this.pending.get(record.dedupeKey);
134
+ if (prior) clearTimeout(prior.timer);
135
+ const timer = setTimeout(() => this.flush(record.dedupeKey), this.debounceMs);
136
+ if (typeof timer.unref === "function") timer.unref();
137
+ this.pending.set(record.dedupeKey, { record, timer });
138
+ }
139
+
140
+ // #2484 round 2 review (MED-1) — returns the in-flight delivery so a teardown caller can
141
+ // actually await it (previously fire-and-forget: a caller that returned right after calling
142
+ // this had no guarantee the POST had even started, let alone landed — the repo's
143
+ // persisted-is-not-delivered shape). A live turn-boundary call (closeSuppressedTurn/
144
+ // closeEventTurn/failTurn) still doesn't need to await it: the promise's own `.catch` already
145
+ // routes failures to `reporter.reportFailure`, so nothing is lost by not awaiting there.
146
+ flush(dedupeKey: string): Promise<void> {
147
+ const entry = this.pending.get(dedupeKey);
148
+ if (!entry) return Promise.resolve();
149
+ this.pending.delete(dedupeKey);
150
+ clearTimeout(entry.timer);
151
+ const reporter = this.reporter();
152
+ if (!reporter) return Promise.resolve();
153
+ return reporter
154
+ .report(entry.record)
155
+ .then(() => reporter.debug(`token-usage capture: posted ${entry.record.dedupeKey}`))
156
+ .catch((error) => reporter.reportFailure(error));
157
+ }
158
+
159
+ flushAll(): Promise<void> {
160
+ return Promise.all([...this.pending.keys()].map((dedupeKey) => this.flush(dedupeKey))).then(() => undefined);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Decode the `message.updated` door's `info` into a spine record and schedule it through
166
+ * `poster`. A no-op whenever the session/model is unknown or {@link openCodeTokenUsageRecord}
167
+ * couldn't decompose the tokens object — never fabricating a report from a shape it didn't
168
+ * understand, and never throwing when no reporter has been configured yet (the poster itself
169
+ * absorbs that at flush time).
170
+ */
171
+ export function scheduleOpenCodeTokenUsage(
172
+ poster: OpenCodeTokenUsagePoster,
173
+ input: { provider: string; sessionId: string | undefined; model: string | undefined; messageId: string; tokens: unknown; createdAt: number | undefined; finished?: boolean },
174
+ ): void {
175
+ if (!input.sessionId || !input.model) return;
176
+ const record = openCodeTokenUsageRecord({
177
+ sessionId: input.sessionId,
178
+ provider: input.provider,
179
+ model: input.model,
180
+ messageId: input.messageId,
181
+ tokens: input.tokens,
182
+ createdAt: input.createdAt,
183
+ finished: input.finished,
184
+ });
185
+ if (!record) return;
186
+ poster.schedule(record);
187
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * #2354 — decode OpenCode's `message.part.updated` tool parts (v1.18.21 `ToolPart`) into the
3
+ * provider-neutral tool-step shape the chat/timeline already renders (TraceView `ToolCallRow`).
4
+ *
5
+ * `opencode.ts` is near the module-size ceiling, so the wire-decoding lives here as a small,
6
+ * pure, separately-testable unit; the adapter keeps only the stateful dedup/ordering and the
7
+ * `sessionEventCb` emit. Modelled on the Codex adapter (stable item-id running/completed upserts),
8
+ * per the operator's "handle like we do in codex" instruction.
9
+ *
10
+ * ToolPart wire shape (pinned v1.18.21):
11
+ * { id, callID, tool, state: { status: "pending"|"running"|"completed"|"error", … } }
12
+ * - running: input, title, metadata
13
+ * - completed: output, title, metadata, time
14
+ * - error: error, metadata, and an `interrupted` flag when a running tool was aborted
15
+ */
16
+
17
+ import { isRecord, stringValue, type ToolCallCategory } from "@humwick/sdk";
18
+ import type { ProviderSessionEvent } from "../adapter";
19
+ import { absoluteToolPaths, clipToolSummary, oneLineText, redactAndCapParams, summarizeToolInput } from "./tool-summary";
20
+
21
+ /** The provider-neutral tool-step lifecycle status. `cancelled` is an interrupted-before-finished
22
+ * tool (ToolPart error state with `interrupted:true`), distinct from `failed` (ran and errored). */
23
+ export type OpenCodeToolStepStatus = "running" | "completed" | "failed" | "cancelled";
24
+
25
+ export interface OpenCodeToolStep {
26
+ /** Stable part id → session-step `stepId`, so running→completed upserts one row in place. */
27
+ stepId: string;
28
+ /** The raw OpenCode tool name (e.g. "bash", "read", an MCP tool) — short row label. */
29
+ label: string;
30
+ category: ToolCallCategory;
31
+ status: OpenCodeToolStepStatus;
32
+ /** Safe one-line summary (redacted + clipped) of what the tool is doing. */
33
+ body: string;
34
+ /** #2437 — redacted, size-capped tool input. Only present on the "running" state (the ToolPart
35
+ * wire shape drops `input` once the tool completes) — undefined here means "not captured on
36
+ * THIS decode", not "no params exist"; see `emitToolStep`'s stepId cache for the carry-forward
37
+ * onto the terminal state. */
38
+ params?: unknown;
39
+ /** #3411 — absolute paths a file tool reads or edits, from the raw input (running state only). */
40
+ paths?: string[];
41
+ }
42
+
43
+ /** OpenCode built-in tool name → the provider-independent category taxonomy (#1498). Anything
44
+ * not a known built-in (an MCP tool, a future built-in) falls to the mcp/generic default —
45
+ * never force-fit, so nothing is hidden from the trace. */
46
+ const OPENCODE_TOOL_CATEGORIES: Record<string, ToolCallCategory> = {
47
+ bash: "cli-bash",
48
+ edit: "edit",
49
+ write: "edit",
50
+ patch: "edit",
51
+ multiedit: "edit",
52
+ read: "read",
53
+ list: "read",
54
+ task: "agent-spawn",
55
+ todowrite: "plan",
56
+ todoread: "plan",
57
+ };
58
+
59
+ /** #3411 — OpenCode's file tools; each names its file in `input.filePath`. */
60
+ const OPENCODE_FILE_TOOLS = new Set(["read", "edit", "write", "multiedit"]);
61
+
62
+ /** Extra input-field names OpenCode's built-in tools use that aren't in the shared candidate
63
+ * list — its file tools use camelCase `filePath` (already shared) and `todo`/`glob` shapes. */
64
+ const OPENCODE_INPUT_KEYS = ["filePath", "cwd"] as const;
65
+
66
+ export function openCodeToolCategory(tool: string): ToolCallCategory {
67
+ const known = OPENCODE_TOOL_CATEGORIES[tool.toLowerCase()];
68
+ if (known) return known;
69
+ // MCP tools surface through OpenCode with a namespaced name; the `__` convention (shared with
70
+ // Claude's `mcp__server__tool`) is the safe signal. Everything else is generic (#2354 rule:
71
+ // unknown tool → generic), so a mystery tool still renders rather than vanishing.
72
+ if (tool.includes("__") || tool.toLowerCase().startsWith("mcp")) return "mcp-tool-call";
73
+ return "generic";
74
+ }
75
+
76
+ /**
77
+ * Map the ToolPart state to the shared lifecycle status. `pending` (queued but not yet started)
78
+ * collapses to `running` — the trace shows one "in-flight" state, matching Codex, which emits a
79
+ * single running upsert. An `error` state splits: an interrupted tool is `cancelled` (we/the
80
+ * operator aborted it — not the model's fault), a non-interrupted error is `failed`. An
81
+ * unrecognised/missing status is treated as in-flight (`running`) rather than dropped.
82
+ */
83
+ export function openCodeToolStatus(state: Record<string, unknown> | undefined): OpenCodeToolStepStatus {
84
+ const rawStatus = stringValue(state?.status);
85
+ if (rawStatus === "completed") return "completed";
86
+ if (rawStatus === "error") return state?.interrupted === true ? "cancelled" : "failed";
87
+ return "running";
88
+ }
89
+
90
+ /** Lifecycle rank for the adapter's terminal-lock/dedup: a terminal state (2) must never be
91
+ * regressed to running (1) by a duplicate/reordered update. Mirrors the server's
92
+ * `sessionStepStatusRank`. */
93
+ export function openCodeToolStatusRank(status: OpenCodeToolStepStatus): number {
94
+ return status === "running" ? 1 : 2;
95
+ }
96
+
97
+ /** The message the ToolPart error state carries, defensively (string, `{message}`, `{data.message}`,
98
+ * or a `{name}` fallback). */
99
+ function openCodeToolErrorText(error: unknown): string {
100
+ if (typeof error === "string") return oneLineText(error);
101
+ if (!isRecord(error)) return "";
102
+ const direct = oneLineText(error.message);
103
+ if (direct) return direct;
104
+ const nested = isRecord(error.data) ? oneLineText(error.data.message) : "";
105
+ if (nested) return nested;
106
+ return oneLineText(error.name);
107
+ }
108
+
109
+ /**
110
+ * The safe body summary for a tool step, in priority order: the input args' most meaningful field
111
+ * (present while running), then the server-supplied human `title`, then the error text (error
112
+ * state), then the tool name. Raw tool OUTPUT is deliberately never surfaced — it can be huge and
113
+ * unbounded (Codex applies the same rule), and `title` already names the result. Everything routes
114
+ * through `clipToolSummary`, so redaction can't be skipped.
115
+ */
116
+ function openCodeToolBody(tool: string, state: Record<string, unknown> | undefined): string {
117
+ if (!state) return tool;
118
+ const input = isRecord(state.input) ? state.input : undefined;
119
+ const inputSummary = summarizeToolInput(input, OPENCODE_INPUT_KEYS);
120
+ if (inputSummary) return inputSummary;
121
+ const title = oneLineText(state.title);
122
+ if (title) return title;
123
+ const error = openCodeToolErrorText(state.error);
124
+ if (error) return error;
125
+ return tool;
126
+ }
127
+
128
+ /**
129
+ * Decode one `message.part.updated` part into a tool step, or `undefined` when the part is not a
130
+ * decodable tool part (wrong type, or missing the stable id/name we key the upsert on). Pure — the
131
+ * adapter owns the dedup/ordering state and the emit.
132
+ */
133
+ export function decodeOpenCodeToolPart(part: unknown): OpenCodeToolStep | undefined {
134
+ if (!isRecord(part)) return undefined;
135
+ if (stringValue(part.type) !== "tool") return undefined;
136
+ const stepId = stringValue(part.id);
137
+ const tool = stringValue(part.tool);
138
+ if (!stepId || !tool) return undefined;
139
+ const state = isRecord(part.state) ? part.state : undefined;
140
+ const input = isRecord(state?.input) ? state.input : undefined;
141
+ const paths = input && OPENCODE_FILE_TOOLS.has(tool) ? absoluteToolPaths([input.filePath]) : undefined;
142
+ return {
143
+ stepId,
144
+ label: tool,
145
+ category: openCodeToolCategory(tool),
146
+ status: openCodeToolStatus(state),
147
+ body: clipToolSummary(openCodeToolBody(tool, state)),
148
+ ...(input !== undefined ? { params: redactAndCapParams(input) } : {}),
149
+ ...(paths ? { paths } : {}),
150
+ };
151
+ }
152
+
153
+ /** Build the `tool` session event the chat/timeline consumes from a decoded step. `streaming` is
154
+ * true only while running; `stepId` drives the server's in-place running→terminal upsert. */
155
+ export function openCodeToolSessionEvent(step: OpenCodeToolStep, turnId: string | undefined): ProviderSessionEvent {
156
+ return {
157
+ type: "tool",
158
+ origin: "provider",
159
+ body: step.body,
160
+ label: step.label,
161
+ category: step.category,
162
+ status: step.status,
163
+ streaming: step.status === "running",
164
+ stepId: step.stepId,
165
+ ...(turnId ? { turnId } : {}),
166
+ ...(step.params !== undefined ? { params: step.params } : {}),
167
+ ...(step.paths ? { paths: step.paths } : {}),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * #2413 — concatenate the terminal assistant message's text parts (wire order) into the turn-final
173
+ * response text. Non-text parts (tool, step-start/finish, reasoning) are skipped; the `\n\n` join
174
+ * matches the dashboard's block delimiter, so a multi-part response's leading blocks dedupe against
175
+ * the streamed narration. `undefined` when there is no assistant text.
176
+ */
177
+ export function openCodeAssistantText(parts: unknown[] | undefined): string | undefined {
178
+ if (!parts) return undefined;
179
+ const blocks: string[] = [];
180
+ for (const part of parts) {
181
+ if (!isRecord(part) || stringValue(part.type) !== "text") continue;
182
+ const text = stringValue(part.text)?.trim();
183
+ if (text) blocks.push(text);
184
+ }
185
+ return blocks.length ? blocks.join("\n\n") : undefined;
186
+ }
187
+
188
+ /**
189
+ * #2605 — decode one `message.part.updated` reasoning part (`{ type: "reasoning", text }`,
190
+ * pinned v1.18.21, live-verified in the provider's own SQLite: opencode/minimax-m2.5-free AND
191
+ * kimi-for-coding sessions persist `{type:"reasoning",text}` parts) into the provider-neutral
192
+ * emit fields. Pure — the adapter owns the dedup/emission, exactly like decodeOpenCodeToolPart.
193
+ * Returns `undefined` for non-reasoning parts or empty text (an empty reasoning row is backend
194
+ * muzak, not operator signal — e.g. a session's first persisted reasoning part was `""`).
195
+ */
196
+ export function decodeOpenCodeReasoningPart(part: unknown): { stepId: string; body: string } | undefined {
197
+ if (!isRecord(part)) return undefined;
198
+ if (stringValue(part.type) !== "reasoning") return undefined;
199
+ const stepId = stringValue(part.id);
200
+ const body = stringValue(part.text)?.trim();
201
+ if (!stepId || !body) return undefined;
202
+ return { stepId, body };
203
+ }
204
+
205
+ /** #2605 — build the `reasoning` session event the chat/timeline consumes (TraceView reasoning
206
+ * row): `stepId` = the stable part id, so the server upserts the row in place on growth instead
207
+ * of appending a duplicate — the same stable-key shape the Codex adapter flushes with.
208
+ * `streaming` stays true while the part is unfinished (`time.end` unset), false on finalize. */
209
+ export function openCodeReasoningSessionEvent(step: { stepId: string; body: string }, turnId: string | undefined, streaming: boolean): ProviderSessionEvent {
210
+ return {
211
+ type: "reasoning",
212
+ origin: "provider",
213
+ body: step.body,
214
+ stepId: step.stepId,
215
+ streaming,
216
+ ...(turnId ? { turnId } : {}),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * #2605 — one decoded reasoning part with its provider-side identity. The `id` is the wire that
222
+ * makes the turn-final backstop upsert instead of duplicate: the server keys step rows on
223
+ * `stepId` ONLY, so every turn-final reasoning row must carry the SAME stepId the live SSE door
224
+ * already emitted for that part (see openCodeReasoningSessionEvent) — a merged/concatenated
225
+ * body under a synthetic stepId would land as a second row on every turn whose SSE was
226
+ * observed. Thread `{id,text}[]` (not a joined string) through the completion for exactly this.
227
+ */
228
+ export interface OpenCodeReasoningStep {
229
+ stepId: string;
230
+ body: string;
231
+ }
232
+
233
+ /**
234
+ * #2605 — decode the completed assistant message's reasoning parts (wire order) into
235
+ * per-part steps. Mirrors openCodeAssistantText's part walk, but keeps identity: one entry per
236
+ * non-empty reasoning part (empty text is backend muzak — dropped by decodeOpenCodeReasoningPart
237
+ * — so it never occupies a row). `[]` when the message carried no reasoning.
238
+ */
239
+ export function openCodeAssistantReasoningSteps(parts: unknown[] | undefined): OpenCodeReasoningStep[] {
240
+ if (!parts) return [];
241
+ const steps: OpenCodeReasoningStep[] = [];
242
+ for (const part of parts) {
243
+ const step = decodeOpenCodeReasoningPart(part);
244
+ if (step) steps.push(step);
245
+ }
246
+ return steps;
247
+ }
248
+
249
+ /**
250
+ * #2605 — concatenate the terminal assistant message's reasoning parts (wire order) into the
251
+ * turn-final reasoning text. `undefined` when the completed message carried no (non-empty)
252
+ * reasoning. Kept for the completion log line / describeCompletion only — the EMIT path uses
253
+ * openCodeAssistantReasoningSteps (per-part identity) so live+final collapse to one row.
254
+ */
255
+ export function openCodeAssistantReasoning(parts: unknown[] | undefined): string | undefined {
256
+ const steps = openCodeAssistantReasoningSteps(parts);
257
+ return steps.length ? steps.map((step) => step.body).join("\n\n") : undefined;
258
+ }
259
+
260
+ /** #2413 — build the turn-final `response` event (marks it for Final Response capture / auto-forward
261
+ * and styles it apart from reasoning). `undefined` when the turn produced no assistant text. */
262
+ export function openCodeTurnFinalResponseEvent(text: string | undefined, turnId: string | undefined): ProviderSessionEvent | undefined {
263
+ const body = text?.trim();
264
+ if (!body) return undefined;
265
+ return { type: "response", origin: "provider", body, final: true, confidence: "definitive", endedBy: "completed", ...(turnId ? { turnId } : {}) };
266
+ }
267
+
268
+ /**
269
+ * #2605 — emit the turn-final `reasoning` rows (`completion.reasoningSteps`, read from the SAME
270
+ * synchronous POST body as the response below) plus that response, in order. Each reasoning row
271
+ * carries its OWN part id as stepId — the exact key the live SSE door already emitted — so the
272
+ * server upserts live+final into ONE row instead of storing the turn's thinking twice. Pure
273
+ * event construction — the caller owns the ledger write + emit, like the tool/response builders.
274
+ *
275
+ * The parts are the model's thinking as of turn end — the completed message persists every
276
+ * reasoning part with its full text — so the backstop covers a POST-only reader, a dropped
277
+ * subscription, or frames racing the close. A part whose live row already carries this exact
278
+ * body is skipped here (same-body no-op, matching the SSE door's dedup); a part that GREW
279
+ * since its last live frame re-emits the current full body under the same stepId (upsert).
280
+ * The caller passes the per-part bodies it already emitted (or an empty record on the POST-only
281
+ * path) — comparison is scoped to THOSE part ids, never to a global body pool, so an identical
282
+ * body in a later turn is never misread as already-seen.
283
+ *
284
+ * The text is ground truth per #2389: the POST returning IS the provider-native turn-end edge,
285
+ * hence `confidence:"definitive"` on the response; `final:true` is what makes it a turn-final
286
+ * capture downstream. Emits reasoning first so the trace reads thinking-then-answer, the order
287
+ * the model produced them in. Either half may be absent (reasoning-only or text-only turns).
288
+ */
289
+ export function openCodeTurnFinalEvents(
290
+ completion: { text?: string; reasoningSteps?: OpenCodeReasoningStep[] },
291
+ turnId: string | undefined,
292
+ emittedBodies: Record<string, string> = {},
293
+ ): ProviderSessionEvent[] {
294
+ const events: ProviderSessionEvent[] = [];
295
+ for (const step of completion.reasoningSteps ?? []) {
296
+ if (emittedBodies[step.stepId] === step.body) continue;
297
+ events.push({ type: "reasoning", origin: "provider", body: step.body, stepId: step.stepId, streaming: false, ...(turnId ? { turnId } : {}) });
298
+ }
299
+ const response = openCodeTurnFinalResponseEvent(completion.text, turnId);
300
+ if (response) events.push(response);
301
+ return events;
302
+ }
@@ -0,0 +1,17 @@
1
+ import { harnessSubagentsEnabled } from "@humwick/sdk";
2
+ import type { RunnerSpawnConfig } from "../adapter";
3
+ import { openCodeNativeWebPermissions, openCodeWebToolsConfig } from "../web-tools";
4
+
5
+ export function openCodeHarnessTools(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">): Record<string, false> {
6
+ return harnessSubagentsEnabled(config) ? {} : { task: false };
7
+ }
8
+
9
+ /** The opencode.json `tools` map: harness built-ins plus, with MCP present, the switched-off web tools. */
10
+ export function openCodeToolsConfig(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">, hasMcp: boolean): { tools?: Record<string, false> } {
11
+ const tools = { ...openCodeHarnessTools(config), ...(hasMcp ? openCodeWebToolsConfig(config).tools : {}) };
12
+ return Object.keys(tools).length ? { tools } : {};
13
+ }
14
+
15
+ export function openCodePermissions(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">): Record<string, "allow" | "deny"> {
16
+ return { "*": "allow", ...openCodeNativeWebPermissions(config) };
17
+ }