akm-cli 0.9.0-rc.0 → 0.9.0-rc.2

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 (262) hide show
  1. package/CHANGELOG.md +339 -2
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  11. package/dist/assets/stash-skeleton/README.md +28 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  24. package/dist/assets/tasks/core/backup.yml +1 -0
  25. package/dist/assets/tasks/core/extract.yml +1 -0
  26. package/dist/assets/tasks/core/improve.yml +1 -0
  27. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  28. package/dist/assets/tasks/core/sync.yml +1 -0
  29. package/dist/assets/tasks/core/version-check.yml +1 -0
  30. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  31. package/dist/assets/templates/html/health.html +5 -1
  32. package/dist/cli/config-migrate.js +31 -138
  33. package/dist/cli/config-validate.js +10 -8
  34. package/dist/cli.js +48 -14
  35. package/dist/commands/agent/agent-dispatch.js +17 -16
  36. package/dist/commands/agent/agent-support.js +0 -24
  37. package/dist/commands/agent/contribute-cli.js +5 -15
  38. package/dist/commands/backup-cli.js +54 -0
  39. package/dist/commands/config-cli.js +45 -159
  40. package/dist/commands/env/env-binding.js +95 -0
  41. package/dist/commands/env/env-cli.js +8 -65
  42. package/dist/commands/env/secret.js +8 -5
  43. package/dist/commands/health/checks.js +130 -83
  44. package/dist/commands/health/html-report.js +4 -0
  45. package/dist/commands/health/improve-metrics.js +30 -32
  46. package/dist/commands/health/llm-usage.js +19 -19
  47. package/dist/commands/health/md-report.js +4 -0
  48. package/dist/commands/health/metrics.js +2 -1
  49. package/dist/commands/health/surfaces.js +5 -4
  50. package/dist/commands/health.js +1 -1
  51. package/dist/commands/improve/consolidate/chunking.js +2 -2
  52. package/dist/commands/improve/consolidate.js +28 -25
  53. package/dist/commands/improve/distill/promote-memory.js +5 -12
  54. package/dist/commands/improve/distill/quality-gate.js +5 -7
  55. package/dist/commands/improve/distill.js +16 -5
  56. package/dist/commands/improve/eligibility.js +22 -12
  57. package/dist/commands/improve/extract-cli.js +47 -19
  58. package/dist/commands/improve/extract.js +110 -62
  59. package/dist/commands/improve/improve-cli.js +38 -16
  60. package/dist/commands/improve/improve-result-file.js +30 -24
  61. package/dist/commands/improve/improve-strategies.js +137 -0
  62. package/dist/commands/improve/improve.js +60 -30
  63. package/dist/commands/improve/locks.js +66 -45
  64. package/dist/commands/improve/loop-stages.js +75 -33
  65. package/dist/commands/improve/memory/memory-belief.js +79 -7
  66. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  67. package/dist/commands/improve/preparation.js +71 -73
  68. package/dist/commands/improve/procedural.js +3 -2
  69. package/dist/commands/improve/recombine.js +2 -1
  70. package/dist/commands/improve/reflect.js +119 -214
  71. package/dist/commands/improve/shared.js +11 -5
  72. package/dist/commands/lint/base-linter.js +152 -42
  73. package/dist/commands/mv-cli.js +809 -0
  74. package/dist/commands/proposal/proposal-cli.js +18 -8
  75. package/dist/commands/proposal/propose.js +64 -69
  76. package/dist/commands/read/knowledge.js +436 -4
  77. package/dist/commands/read/remember-cli.js +39 -2
  78. package/dist/commands/read/search-cli.js +6 -1
  79. package/dist/commands/registry-cli.js +29 -14
  80. package/dist/commands/remember.js +2 -0
  81. package/dist/commands/sources/init.js +13 -14
  82. package/dist/commands/sources/migration-help.js +7 -4
  83. package/dist/commands/sources/schema-repair.js +2 -4
  84. package/dist/commands/sources/source-add.js +62 -73
  85. package/dist/commands/sources/source-manage.js +50 -46
  86. package/dist/commands/sources/stash-cli.js +41 -4
  87. package/dist/commands/tasks/default-tasks.js +12 -12
  88. package/dist/commands/tasks/tasks-cli.js +7 -3
  89. package/dist/commands/tasks/tasks.js +113 -18
  90. package/dist/commands/wiki-cli.js +9 -10
  91. package/dist/commands/workflow-cli.js +276 -12
  92. package/dist/core/asset/asset-spec.js +58 -1
  93. package/dist/core/asset/frontmatter.js +12 -2
  94. package/dist/core/common.js +5 -3
  95. package/dist/core/config/config-io.js +28 -17
  96. package/dist/core/config/config-schema.js +379 -66
  97. package/dist/core/config/config-types.js +3 -3
  98. package/dist/core/config/config-version.js +29 -0
  99. package/dist/core/config/config-walker.js +98 -27
  100. package/dist/core/config/config.js +132 -266
  101. package/dist/core/config/deep-merge.js +41 -0
  102. package/dist/core/config/engine-semantics.js +32 -0
  103. package/dist/core/errors.js +2 -2
  104. package/dist/core/extra-params.js +61 -0
  105. package/dist/core/file-lock.js +201 -56
  106. package/dist/core/improve-result.js +178 -0
  107. package/dist/core/json-schema.js +142 -0
  108. package/dist/core/maintenance-barrier.js +119 -0
  109. package/dist/core/migration-backup.js +416 -0
  110. package/dist/core/paths.js +3 -0
  111. package/dist/core/redaction.js +358 -0
  112. package/dist/core/state/migrations.js +17 -2
  113. package/dist/core/state-db.js +44 -1
  114. package/dist/indexer/db/db.js +118 -2
  115. package/dist/indexer/graph/graph-extraction.js +28 -16
  116. package/dist/indexer/index-writer-lock.js +31 -24
  117. package/dist/indexer/index-written-assets.js +15 -6
  118. package/dist/indexer/indexer.js +47 -2
  119. package/dist/indexer/passes/memory-inference.js +10 -6
  120. package/dist/indexer/passes/metadata.js +250 -0
  121. package/dist/indexer/search/db-search.js +111 -44
  122. package/dist/indexer/search/fts-query.js +41 -0
  123. package/dist/indexer/search/ranking-contributors.js +48 -0
  124. package/dist/indexer/search/ranking.js +36 -23
  125. package/dist/indexer/search/search-fields.js +11 -1
  126. package/dist/indexer/walk/matchers.js +39 -0
  127. package/dist/integrations/agent/builder-shared.js +7 -0
  128. package/dist/integrations/agent/builders.js +5 -50
  129. package/dist/integrations/agent/config.js +3 -143
  130. package/dist/integrations/agent/detect.js +17 -2
  131. package/dist/integrations/agent/engine-resolution.js +202 -0
  132. package/dist/integrations/agent/index.js +1 -2
  133. package/dist/integrations/agent/model-aliases.js +16 -2
  134. package/dist/integrations/agent/profiles.js +36 -62
  135. package/dist/integrations/agent/runner-dispatch.js +91 -4
  136. package/dist/integrations/agent/runner.js +76 -207
  137. package/dist/integrations/agent/spawn.js +141 -20
  138. package/dist/integrations/harnesses/aider/agent-builder.js +112 -0
  139. package/dist/integrations/harnesses/aider/index.js +57 -0
  140. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  141. package/dist/integrations/harnesses/amazonq/agent-builder.js +152 -0
  142. package/dist/integrations/harnesses/amazonq/index.js +58 -0
  143. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  144. package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
  145. package/dist/integrations/harnesses/claude/index.js +25 -25
  146. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  147. package/dist/integrations/harnesses/codex/agent-builder.js +136 -0
  148. package/dist/integrations/harnesses/codex/index.js +62 -0
  149. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  150. package/dist/integrations/harnesses/copilot/agent-builder.js +121 -0
  151. package/dist/integrations/harnesses/copilot/index.js +59 -0
  152. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  153. package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
  154. package/dist/integrations/harnesses/gemini/index.js +59 -0
  155. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  156. package/dist/integrations/harnesses/index.js +27 -28
  157. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  158. package/dist/integrations/harnesses/opencode/index.js +15 -22
  159. package/dist/integrations/harnesses/opencode-sdk/harness.js +60 -0
  160. package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
  161. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
  162. package/dist/integrations/harnesses/openhands/agent-builder.js +125 -0
  163. package/dist/integrations/harnesses/openhands/index.js +57 -0
  164. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  165. package/dist/integrations/harnesses/pi/agent-builder.js +103 -0
  166. package/dist/integrations/harnesses/pi/index.js +57 -0
  167. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  168. package/dist/integrations/harnesses/types.js +8 -32
  169. package/dist/integrations/lockfile.js +32 -21
  170. package/dist/integrations/session-logs/index.js +24 -11
  171. package/dist/llm/client.js +48 -14
  172. package/dist/llm/feature-gate.js +15 -47
  173. package/dist/llm/graph-extract.js +1 -1
  174. package/dist/llm/index-passes.js +8 -42
  175. package/dist/llm/memory-infer-impl.js +1 -1
  176. package/dist/llm/usage-persist.js +4 -0
  177. package/dist/llm/usage-telemetry.js +35 -5
  178. package/dist/output/renderers.js +3 -2
  179. package/dist/output/shapes/helpers.js +2 -1
  180. package/dist/output/shapes/passthrough.js +6 -0
  181. package/dist/output/text/helpers.js +215 -2
  182. package/dist/output/text/workflow.js +3 -1
  183. package/dist/schemas/akm-config.json +16638 -0
  184. package/dist/schemas/akm-task.json +87 -0
  185. package/dist/schemas/akm-workflow.json +372 -0
  186. package/dist/scripts/migrate-storage.js +10944 -8801
  187. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9247 -350
  188. package/dist/setup/detected-engines.js +142 -0
  189. package/dist/setup/engine-config.js +89 -0
  190. package/dist/setup/setup.js +236 -132
  191. package/dist/setup/steps/connection.js +61 -32
  192. package/dist/setup/steps/platforms.js +4 -4
  193. package/dist/setup/steps.js +3 -2
  194. package/dist/storage/database.js +13 -1
  195. package/dist/storage/engines/sqlite-migrations.js +1 -0
  196. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  197. package/dist/storage/repositories/task-history-repository.js +78 -0
  198. package/dist/storage/repositories/workflow-runs-repository.js +190 -1
  199. package/dist/tasks/parser.js +138 -52
  200. package/dist/tasks/runner.js +71 -75
  201. package/dist/tasks/schema.js +1 -1
  202. package/dist/tasks/validator.js +11 -6
  203. package/dist/text-import-hook.mjs +1 -1
  204. package/dist/wiki/wiki.js +9 -8
  205. package/dist/workflows/authoring/authoring.js +123 -10
  206. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  207. package/dist/workflows/cli.js +4 -0
  208. package/dist/workflows/concurrency-policy.js +15 -0
  209. package/dist/workflows/db.js +200 -13
  210. package/dist/workflows/exec/brief.js +478 -0
  211. package/dist/workflows/exec/frozen-judge.js +47 -0
  212. package/dist/workflows/exec/native-executor.js +1034 -0
  213. package/dist/workflows/exec/param-secrets.js +115 -0
  214. package/dist/workflows/exec/report.js +1355 -0
  215. package/dist/workflows/exec/run-workflow.js +609 -0
  216. package/dist/workflows/exec/scheduler.js +71 -0
  217. package/dist/workflows/exec/step-work.js +1212 -0
  218. package/dist/workflows/exec/unit-writer.js +23 -0
  219. package/dist/workflows/exec/watch.js +116 -0
  220. package/dist/workflows/exec/worktree.js +171 -0
  221. package/dist/workflows/ir/compile.js +375 -0
  222. package/dist/workflows/ir/freeze.js +243 -0
  223. package/dist/workflows/ir/params.js +54 -0
  224. package/dist/workflows/ir/plan-hash.js +68 -0
  225. package/dist/workflows/ir/schema.js +545 -0
  226. package/dist/workflows/parser.js +10 -1
  227. package/dist/workflows/program/expressions.js +369 -0
  228. package/dist/workflows/program/parser.js +869 -0
  229. package/dist/workflows/program/project.js +104 -0
  230. package/dist/workflows/program/schema.js +54 -0
  231. package/dist/workflows/renderer.js +82 -5
  232. package/dist/workflows/resource-limits.js +20 -0
  233. package/dist/workflows/runtime/agent-identity.js +59 -14
  234. package/dist/workflows/runtime/plan-classifier.js +187 -0
  235. package/dist/workflows/runtime/runs.js +246 -69
  236. package/dist/workflows/runtime/unit-checkin.js +45 -0
  237. package/dist/workflows/runtime/workflow-asset-loader.js +42 -1
  238. package/dist/workflows/validate-summary.js +24 -3
  239. package/dist/workflows/validator.js +26 -1
  240. package/docs/data-and-telemetry.md +4 -3
  241. package/docs/migration/release-notes/0.6.0.md +1 -1
  242. package/docs/migration/release-notes/0.7.0.md +5 -4
  243. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  244. package/docs/migration/v0.8-to-v0.9.md +401 -0
  245. package/package.json +4 -2
  246. package/schemas/akm-config.json +16638 -0
  247. package/schemas/akm-task.json +87 -0
  248. package/schemas/akm-workflow.json +372 -0
  249. package/dist/commands/improve/improve-profiles.js +0 -168
  250. package/dist/core/config/config-migration.js +0 -602
  251. package/dist/core/deep-merge.js +0 -38
  252. package/dist/llm/call-ai.js +0 -62
  253. package/dist/setup/legacy-config.js +0 -106
  254. package/docs/README.md +0 -104
  255. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  256. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  257. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  258. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  259. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  260. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  261. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  262. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -6,17 +6,109 @@
6
6
  *
7
7
  * Uses the embedded `@opencode-ai/sdk` instead of `Bun.spawn`. Requires no
8
8
  * agent CLI binary to be installed. The user provides an OpenAI-compatible
9
- * endpoint (or inherits from config.llm) for the SDK.
9
+ * endpoint (or inherits from the selected fallback LLM engine) for the SDK.
10
10
  *
11
11
  * This is the runtime surface of the {@link OpencodeSdkHarness} (`id =
12
- * 'opencode-sdk'`). It is the dispatch path for `sdkMode` profiles; it exposes
12
+ * 'opencode-sdk'`). It is the dispatch path for SDK runner specs; it exposes
13
13
  * no native session logs of its own (`capabilities.sessionLogs = false`).
14
+ *
15
+ * ## Per-call cwd and env (redesign addendum R2, open seam decision 1)
16
+ *
17
+ * The plan left one decision open: per-call cwd/env forwarding vs a server
18
+ * keyed by `(cwd, envKeysHash)`. Reading the SDK settled it as a SPLIT — the
19
+ * two halves have different API realities (verified against
20
+ * `@opencode-ai/sdk` 1.2.20):
21
+ *
22
+ * - **cwd is PER-CALL.** `session.create` / `session.prompt` /
23
+ * `session.delete` all accept a `query.directory` parameter that scopes
24
+ * the session's working directory, so a single server can host sessions
25
+ * in any number of working directories. {@link RunAgentOptions.cwd} is
26
+ * forwarded as `query: { directory }` on every session call — no
27
+ * per-cwd server processes, no server-key explosion for worktree
28
+ * isolation (which mints a fresh directory per unit attempt).
29
+ *
30
+ * - **env is PER-SERVER (keyed registry).** The SDK exposes NO per-call or
31
+ * per-session env surface; the only way env reaches tool child processes
32
+ * is the `opencode serve` process environment. {@link getOrStartServer}
33
+ * snapshots and materializes the complete child environment for each
34
+ * dispatch, passes it directly to the managed spawn (never mutating
35
+ * `process.env`), and hashes that exact canonical environment for registry
36
+ * identity. Identical child material shares one server; any material
37
+ * difference receives a separate server.
38
+ *
39
+ * This is what removed the workflow engine's `env_unsupported` hard-fail for
40
+ * the sdk runner: injection genuinely reaches the child, because tool
41
+ * subprocesses (bash etc.) inherit the server process environment.
42
+ *
43
+ * Registry hygiene (peer-review fixes):
44
+ *
45
+ * - **Ports.** `createOpencodeServer` binds a FIXED default port (4096),
46
+ * so coexisting registry entries would contend for the same bind. Every
47
+ * entry is started on its own reserved OS-assigned port (see
48
+ * {@link startServer}).
49
+ * - **Shutdown.** {@link closeServer} closes resolved servers
50
+ * SYNCHRONOUSLY — it runs from `process.once('exit')`, where Bun never
51
+ * drains microtasks, so a `.then()`-based close would orphan every
52
+ * `opencode serve` child.
53
+ *
54
+ * Process-lifecycle note (owner finding 4): a cached `opencode serve` child is
55
+ * a live OS handle that keeps Bun's event loop OPEN, so a one-shot CLI never
56
+ * becomes idle and `process.once('exit')` never fires — the exit hook alone
57
+ * cannot free a process the child is keeping alive (a deadlock that hangs the
58
+ * caller after an otherwise-successful run). The registry is therefore drained
59
+ * PROACTIVELY at the end of a dispatching command: the workflow engine calls
60
+ * `disposeDispatchResources()` (→ {@link closeServer}) in composition-root and
61
+ * workflow `finally` blocks.
62
+ * The `process.once('exit')` hook stays as the last-resort backstop for paths
63
+ * that never reach that drain.
64
+ *
65
+ * ## Managed server spawn (owner finding 4, live-harness follow-up)
66
+ *
67
+ * Draining the registry is necessary but NOT sufficient with the SDK's own
68
+ * `createOpencodeServer`: its `close()` merely sends SIGTERM and it never
69
+ * `unref()`s the child or its stdio pipes, so akm's event loop stays pinned
70
+ * until the child ACTUALLY exits — and a real `opencode serve` (a live HTTP
71
+ * server with provider children) can outlive SIGTERM long enough to hang the
72
+ * caller indefinitely. {@link createManagedOpencode} therefore owns the spawn
73
+ * (the SDK package is used only for `createOpencodeClient`):
74
+ *
75
+ * - after the URL handshake, the child and its stdio are `unref()`ed /
76
+ * destroyed, so the handle can never hold akm open;
77
+ * - `close()` sends SIGTERM and arms a bounded grace timer
78
+ * ({@link SERVER_KILL_GRACE_MS}) that escalates to SIGKILL and is cleared
79
+ * on cooperative exit, so stubborn children cannot survive parent exit and
80
+ * stale timers cannot signal a reused PID;
81
+ * - the spawn receives the immutable per-dispatch environment directly, so
82
+ * asynchronous factory setup cannot race a temporary process-wide overlay.
14
83
  */
15
- import { resolveSecret } from "../../../core/config/config.js";
84
+ import { spawn } from "node:child_process";
85
+ import { createHash } from "node:crypto";
16
86
  import { DEFAULT_AGENT_TIMEOUT_MS } from "../../agent/config.js";
17
87
  import { resolveModel } from "../../agent/model-aliases.js";
18
- // Singleton serverstarted once per process, reused across calls
19
- let _server = null;
88
+ // Server registryone server per complete server-material signature. Caller
89
+ // deadlines race the shared promise independently; they never become startup
90
+ // configuration inherited by later callers.
91
+ const _servers = new Map();
92
+ // Resolved servers by registry key, mirrored from `_servers` as each start
93
+ // promise settles. This exists so closeServer() can close started servers
94
+ // SYNCHRONOUSLY: it is wired to `process.once('exit')`, and Bun does not
95
+ // drain microtasks scheduled inside 'exit' handlers, so a `.then()`-based
96
+ // close never runs there and would orphan every `opencode serve` child.
97
+ const _resolvedServers = new Map();
98
+ // Listen ports handed to non-default registry entries (see startServer) —
99
+ // tracked so two coexisting servers in this process can never be assigned
100
+ // the same port.
101
+ const _serverPorts = new Map();
102
+ // Serializes probe-and-reserve so concurrent starts cannot claim one port.
103
+ let _portAllocationTail = Promise.resolve();
104
+ /** The port `createOpencodeServer` binds when none is passed (SDK 1.2.20). */
105
+ const DEFAULT_SDK_PORT = 4096;
106
+ // Test override: when set, every call uses this server (all keys) and no real
107
+ // server is ever started.
108
+ let _testServer = null;
109
+ // Test seam replacing the real `createOpencode` import (see __setServerFactory).
110
+ let _serverFactory = null;
111
+ let _exitHookInstalled = false;
20
112
  /**
21
113
  * Test-only seam: inject a fake {@link SdkServer} so `runOpencodeSdk` can be
22
114
  * exercised without the real `@opencode-ai/sdk` (which would spin up a server).
@@ -25,20 +117,59 @@ let _server = null;
25
117
  * leading underscores mark it as internal.
26
118
  */
27
119
  export function __setTestServer(server) {
28
- _server = server;
120
+ _testServer = server;
121
+ }
122
+ /**
123
+ * Test-only seam: replace the `createOpencode` factory so the env-keyed
124
+ * server registry (module doc, *Per-call cwd and env*) can be exercised
125
+ * without the real SDK. The complete child environment is supplied as
126
+ * `options.env`, so async factories observe the same immutable values as the
127
+ * production spawn. Pass `null` to clear.
128
+ */
129
+ export function __setServerFactory(factory) {
130
+ _serverFactory = factory;
29
131
  }
30
132
  /**
31
- * Close the singleton OpenCode SDK server and reset the handle.
32
- * Primarily for use in tests to ensure clean teardown between test runs.
133
+ * Close every started OpenCode SDK server and reset the registry (and any
134
+ * injected test server). Used by tests for clean teardown between runs and
135
+ * wired to `process.once('exit')` — which is why resolved servers MUST be
136
+ * closed synchronously here: Bun never drains microtasks scheduled inside
137
+ * 'exit' handlers, so a promise-based close would silently orphan the
138
+ * `opencode serve` children (leaking processes AND keeping their ports
139
+ * bound for the next invocation).
33
140
  */
34
- export function closeServer() {
141
+ export async function closeServer() {
142
+ const closes = [];
143
+ for (const [key, entry] of _servers) {
144
+ const resolved = _resolvedServers.get(key);
145
+ if (resolved) {
146
+ // Synchronous close — safe from the 'exit' hook.
147
+ try {
148
+ closes.push(Promise.resolve(resolved.server.close()));
149
+ }
150
+ catch {
151
+ /* ignore */
152
+ }
153
+ }
154
+ else {
155
+ // Still starting: cancel the real managed spawn immediately. A custom
156
+ // factory may ignore the signal; the registry settlement handler closes
157
+ // any late result without awaiting it and pinning shutdown indefinitely.
158
+ entry.controller.abort();
159
+ }
160
+ }
161
+ _servers.clear();
162
+ _resolvedServers.clear();
163
+ _serverPorts.clear();
35
164
  try {
36
- _server?.server.close();
165
+ if (_testServer)
166
+ closes.push(Promise.resolve(_testServer.server.close()));
37
167
  }
38
168
  catch {
39
169
  /* ignore */
40
170
  }
41
- _server = null;
171
+ _testServer = null;
172
+ await Promise.allSettled(closes);
42
173
  }
43
174
  /**
44
175
  * Convert an `AgentDispatchRequest.tools` policy into the SDK's tool-allowlist
@@ -97,12 +228,14 @@ function toolsToSdkAllowlist(tools) {
97
228
  * and config-root alias tables apply here.
98
229
  */
99
230
  export function buildSdkConfig(profile, llmConfig) {
100
- // Resolve endpoint and model: profile fields take precedence over config.llm
101
- const endpoint = profile.endpoint ?? llmConfig?.endpoint;
102
- const apiKey = resolveSecret(profile.apiKey ?? llmConfig?.apiKey);
103
- const model = profile.model
104
- ? resolveModel(profile.model, "opencode-sdk", profile.modelAliases, profile.globalModelAliases)
231
+ const endpoint = llmConfig?.endpoint;
232
+ const apiKey = llmConfig?.apiKey;
233
+ const profileModel = profile.model
234
+ ? profile.modelIsExact
235
+ ? profile.model
236
+ : resolveModel(profile.model, "opencode-sdk", profile.modelAliases, profile.globalModelAliases)
105
237
  : undefined;
238
+ const model = profileModel ?? llmConfig?.model;
106
239
  const sdkConfig = {};
107
240
  if (model)
108
241
  sdkConfig.model = model;
@@ -112,32 +245,335 @@ export function buildSdkConfig(profile, llmConfig) {
112
245
  "akm-custom": {
113
246
  npm: "@ai-sdk/openai-compatible",
114
247
  options: {
115
- baseURL: endpoint?.replace(/\/chat\/completions$/, "").replace(/\/$/, ""),
248
+ baseURL: canonicalProviderBase(endpoint) ?? undefined,
116
249
  ...(apiKey ? { apiKey } : {}),
117
250
  },
118
251
  },
119
252
  };
120
- // Use the custom provider's model if not already qualified
121
- if (model && !model.includes("/")) {
122
- sdkConfig.model = `akm-custom/${model}`;
123
- }
253
+ // The first path segment selects the OpenCode provider. Model IDs may
254
+ // themselves contain slashes, but still belong to this custom endpoint.
255
+ if (model)
256
+ sdkConfig.model = model.startsWith("akm-custom/") ? model : `akm-custom/${model}`;
124
257
  }
125
258
  return sdkConfig;
126
259
  }
127
- async function getOrStartServer(profile, llmConfig) {
128
- if (_server)
129
- return _server;
130
- const { createOpencode } = await import("@opencode-ai/sdk").catch(() => {
260
+ /** Digest the executable and exact environment received by the child. */
261
+ function serverRegistryKey(profile, env) {
262
+ const material = { bin: profile.bin, env };
263
+ return createHash("sha256")
264
+ .update(JSON.stringify(canonicalize(material)))
265
+ .digest("hex");
266
+ }
267
+ function buildServerEnv(profile, config, bindings, envSource) {
268
+ const env = {};
269
+ const inheritedNames = new Set([
270
+ "HOME",
271
+ "PATH",
272
+ "USER",
273
+ "LANG",
274
+ "LC_ALL",
275
+ "TERM",
276
+ "TMPDIR",
277
+ "SYSTEMROOT",
278
+ "COMSPEC",
279
+ "PATHEXT",
280
+ "WINDIR",
281
+ "TEMP",
282
+ "TMP",
283
+ ...(profile.envPassthrough ?? []),
284
+ ]);
285
+ for (const key of inheritedNames) {
286
+ const value = envSource[key];
287
+ if (value !== undefined)
288
+ env[key] = value;
289
+ }
290
+ for (const [key, value] of Object.entries(bindings ?? {}))
291
+ env[key] = value;
292
+ env.OPENCODE_CONFIG_CONTENT = JSON.stringify(config);
293
+ return env;
294
+ }
295
+ function canonicalProviderBase(endpoint) {
296
+ if (!endpoint)
297
+ return null;
298
+ try {
299
+ const url = new URL(endpoint);
300
+ url.pathname = url.pathname.replace(/\/chat\/completions$/, "").replace(/\/$/, "");
301
+ return url.toString().replace(/\/$/, "");
302
+ }
303
+ catch {
304
+ return endpoint.replace(/\/chat\/completions$/, "").replace(/\/$/, "");
305
+ }
306
+ }
307
+ function canonicalize(value) {
308
+ if (Array.isArray(value))
309
+ return value.map(canonicalize);
310
+ if (value && typeof value === "object") {
311
+ return Object.fromEntries(Object.entries(value)
312
+ .sort(([a], [b]) => a.localeCompare(b))
313
+ .map(([key, child]) => [key, canonicalize(child)]));
314
+ }
315
+ return value;
316
+ }
317
+ /**
318
+ * Ask the OS for a currently-free localhost port (bind :0, read the assigned
319
+ * port, release it). Skips the SDK's fixed default port and any port already
320
+ * handed to another registry entry in this process, so coexisting servers
321
+ * never contend. The probe-then-use gap is the standard free-port race —
322
+ * acceptable here because the failure mode is a clean `spawn_failed` on the
323
+ * next dispatch, not corruption.
324
+ */
325
+ async function allocateFreePort(registryKey) {
326
+ let release;
327
+ const previous = _portAllocationTail;
328
+ _portAllocationTail = new Promise((resolve) => {
329
+ release = resolve;
330
+ });
331
+ await previous;
332
+ try {
333
+ const { createServer } = await import("node:net");
334
+ const taken = new Set(_serverPorts.values());
335
+ for (let attempt = 0; attempt < 10; attempt++) {
336
+ const port = await new Promise((resolve, reject) => {
337
+ const probe = createServer();
338
+ probe.unref();
339
+ probe.on("error", reject);
340
+ probe.listen(0, "127.0.0.1", () => {
341
+ const address = probe.address();
342
+ probe.close(() => {
343
+ if (address && typeof address === "object")
344
+ resolve(address.port);
345
+ else
346
+ reject(new Error("could not read the probe socket's port"));
347
+ });
348
+ });
349
+ });
350
+ if (port !== DEFAULT_SDK_PORT && !taken.has(port)) {
351
+ _serverPorts.set(registryKey, port);
352
+ return port;
353
+ }
354
+ }
355
+ throw new Error("could not allocate a free port for the OpenCode SDK server");
356
+ }
357
+ finally {
358
+ release();
359
+ }
360
+ }
361
+ /** Grace between SIGTERM and SIGKILL when closing a managed server child. */
362
+ const SERVER_KILL_GRACE_MS = 2_000;
363
+ // Test seam: override the argv used to spawn the server child ("opencode"
364
+ // plus serve flags by default) so the managed-spawn lifecycle (handshake,
365
+ // unref, SIGTERM→SIGKILL escalation) is testable without the real binary.
366
+ let _serveCommand = null;
367
+ /** Test-only seam: replace the `opencode serve` argv. Pass `null` to clear. */
368
+ export function __setServeCommand(argv) {
369
+ _serveCommand = argv;
370
+ }
371
+ /**
372
+ * Spawn-owning replacement for the SDK's `createOpencode` (module doc,
373
+ * *Managed server spawn*). Mirrors `createOpencodeServer`'s contract — the
374
+ * `spawn` receives the factory's immutable environment directly,
375
+ * `OPENCODE_CONFIG_CONTENT` carries the config, the handshake parses the
376
+ * "opencode server listening on <url>" line — but manages the child so its
377
+ * handle can never pin akm's event loop:
378
+ *
379
+ * - handshake success → stdio destroyed, listeners dropped, `proc.unref()`;
380
+ * - `close()` → SIGTERM now, SIGKILL after an unref'ed grace timer;
381
+ * - handshake failure → the child is killed and unref'ed before rejecting.
382
+ */
383
+ async function createManagedOpencode(options) {
384
+ const { createOpencodeClient } = (await import("@opencode-ai/sdk").catch(() => {
131
385
  throw new Error("OpenCode SDK not available. Install @opencode-ai/sdk or configure a CLI agent instead.");
386
+ }));
387
+ const port = options.port ?? DEFAULT_SDK_PORT;
388
+ const argv = _serveCommand ?? [options.bin ?? "opencode", "serve", "--hostname=127.0.0.1", `--port=${port}`];
389
+ const proc = spawn(argv[0], argv.slice(1), {
390
+ env: options.env,
391
+ stdio: ["ignore", "pipe", "pipe"],
132
392
  });
133
- const sdkConfig = buildSdkConfig(profile, llmConfig);
134
- _server = (await createOpencode(Object.keys(sdkConfig).length > 0 ? { config: sdkConfig } : {}));
135
- process.once("exit", () => {
136
- closeServer();
393
+ let closeStarted = false;
394
+ let closeEscalation;
395
+ const childExited = () => proc.exitCode !== null || proc.signalCode !== null;
396
+ const clearCloseEscalation = () => {
397
+ if (closeEscalation !== undefined) {
398
+ clearTimeout(closeEscalation);
399
+ closeEscalation = undefined;
400
+ }
401
+ };
402
+ const closeManaged = () => {
403
+ if (closeStarted)
404
+ return;
405
+ closeStarted = true;
406
+ if (childExited())
407
+ return;
408
+ proc.once("exit", clearCloseEscalation);
409
+ try {
410
+ proc.kill("SIGTERM");
411
+ }
412
+ catch {
413
+ proc.off("exit", clearCloseEscalation);
414
+ return;
415
+ }
416
+ if (childExited())
417
+ return;
418
+ closeEscalation = setTimeout(() => {
419
+ closeEscalation = undefined;
420
+ if (childExited())
421
+ return;
422
+ try {
423
+ proc.kill("SIGKILL");
424
+ }
425
+ catch {
426
+ /* already dead */
427
+ }
428
+ }, SERVER_KILL_GRACE_MS);
429
+ };
430
+ const url = await new Promise((resolve, reject) => {
431
+ let output = "";
432
+ let settled = false;
433
+ const cleanupStartup = () => {
434
+ proc.stdout?.off("data", onStdoutData);
435
+ proc.stderr?.off("data", onStderrData);
436
+ proc.off("exit", onExit);
437
+ proc.off("error", onError);
438
+ options.startupSignal.removeEventListener("abort", onAbort);
439
+ };
440
+ const fail = (err) => {
441
+ if (settled)
442
+ return;
443
+ settled = true;
444
+ cleanupStartup();
445
+ closeManaged();
446
+ proc.stdout?.destroy();
447
+ proc.stderr?.destroy();
448
+ proc.unref();
449
+ reject(err);
450
+ };
451
+ const succeed = (serverUrl) => {
452
+ if (settled)
453
+ return;
454
+ settled = true;
455
+ cleanupStartup();
456
+ resolve(serverUrl);
457
+ };
458
+ const onStdoutData = (chunk) => {
459
+ output += chunk.toString();
460
+ for (const line of output.split("\n")) {
461
+ if (line.startsWith("opencode server listening")) {
462
+ const match = line.match(/on\s+(https?:\/\/\S+)/);
463
+ if (!match?.[1]) {
464
+ fail(new Error(`Failed to parse the OpenCode server url from: ${line}`));
465
+ return;
466
+ }
467
+ succeed(match[1]);
468
+ return;
469
+ }
470
+ }
471
+ };
472
+ const onStderrData = (chunk) => {
473
+ output += chunk.toString();
474
+ };
475
+ const onExit = (code, signal) => {
476
+ const status = code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
477
+ fail(new Error(`OpenCode server exited with ${status}${output.trim() ? `\nServer output: ${output}` : ""}`));
478
+ };
479
+ const onError = (err) => {
480
+ fail(err instanceof Error ? err : new Error(String(err)));
481
+ };
482
+ const onAbort = () => fail(new Error("OpenCode server startup cancelled because no callers are waiting"));
483
+ proc.stdout?.on("data", onStdoutData);
484
+ proc.stderr?.on("data", onStderrData);
485
+ proc.on("exit", onExit);
486
+ proc.on("error", onError);
487
+ if (options.startupSignal.aborted)
488
+ onAbort();
489
+ else
490
+ options.startupSignal.addEventListener("abort", onAbort, { once: true });
137
491
  });
138
- if (!_server)
492
+ // Handshake done: from here on the child must never hold akm open. Its
493
+ // lifetime is managed explicitly (closeServer → closeManaged), not by the
494
+ // event loop. Destroying the pipes also releases their loop handles.
495
+ proc.stdout?.destroy();
496
+ proc.stderr?.destroy();
497
+ proc.unref();
498
+ return { client: createOpencodeClient({ baseUrl: url }), server: { close: closeManaged } };
499
+ }
500
+ async function startServer(profile, sdkConfig, env, registryKey, startupSignal) {
501
+ const factory = _serverFactory ?? createManagedOpencode;
502
+ const options = {
503
+ bin: profile.bin,
504
+ ...(Object.keys(sdkConfig).length > 0 ? { config: sdkConfig } : {}),
505
+ env,
506
+ startupSignal,
507
+ };
508
+ // Every cached server receives a separately reserved port. This avoids both
509
+ // inter-entry contention and collisions with an unrelated process on 4096.
510
+ options.port = await allocateFreePort(registryKey);
511
+ if (startupSignal.aborted)
512
+ throw new Error("OpenCode server startup cancelled because no callers are waiting");
513
+ const server = await factory(options);
514
+ if (!server)
139
515
  throw new Error("Failed to initialise OpenCode SDK server.");
140
- return _server;
516
+ if (!_exitHookInstalled) {
517
+ _exitHookInstalled = true;
518
+ process.once("exit", () => {
519
+ void closeServer();
520
+ });
521
+ }
522
+ return server;
523
+ }
524
+ /**
525
+ * Get (or lazily start) the server for this call's complete server material.
526
+ * Concurrent callers of the same key share one
527
+ * start (the registry stores the in-flight promise). A failed start is
528
+ * evicted so the next call can retry instead of caching the error forever.
529
+ */
530
+ function getOrStartServer(profile, llmConfig, env, envSource = process.env) {
531
+ if (_testServer)
532
+ return { promise: Promise.resolve(_testServer), release() { } };
533
+ const sdkConfig = buildSdkConfig(profile, llmConfig);
534
+ const serverEnv = buildServerEnv(profile, sdkConfig, env, envSource);
535
+ const key = serverRegistryKey(profile, serverEnv);
536
+ let entry = _servers.get(key);
537
+ if (!entry) {
538
+ const controller = new AbortController();
539
+ entry = {
540
+ promise: startServer(profile, sdkConfig, serverEnv, key, controller.signal),
541
+ controller,
542
+ waiters: 0,
543
+ };
544
+ const started = entry;
545
+ _servers.set(key, started);
546
+ started.promise.then((server) => {
547
+ if (_servers.get(key) === started) {
548
+ started.server = server;
549
+ _resolvedServers.set(key, server);
550
+ }
551
+ else {
552
+ void server.server.close();
553
+ }
554
+ }, () => {
555
+ if (_servers.get(key) === started) {
556
+ _servers.delete(key);
557
+ _serverPorts.delete(key);
558
+ }
559
+ });
560
+ }
561
+ entry.waiters++;
562
+ let released = false;
563
+ return {
564
+ promise: entry.promise,
565
+ release() {
566
+ if (released)
567
+ return;
568
+ released = true;
569
+ entry.waiters--;
570
+ if (entry.waiters === 0 && !entry.server && _servers.get(key) === entry) {
571
+ _servers.delete(key);
572
+ _serverPorts.delete(key);
573
+ entry.controller.abort();
574
+ }
575
+ },
576
+ };
141
577
  }
142
578
  /**
143
579
  * Extract best-effort token usage from a prompt response. Only numeric
@@ -158,8 +594,77 @@ function extractUsage(info) {
158
594
  }
159
595
  return Object.keys(usage).length > 0 ? usage : undefined;
160
596
  }
597
+ const SDK_OPERATION_TIMED_OUT = Symbol("opencode-sdk-operation-timeout");
598
+ const SDK_OPERATION_ABORTED = Symbol("opencode-sdk-operation-aborted");
599
+ const SDK_SESSION_DELETE_TIMEOUT_MS = 5_000;
600
+ async function raceSdkOperation(operation, opts) {
601
+ let timer;
602
+ let onAbort;
603
+ let raceFinished = false;
604
+ const racers = [operation];
605
+ void operation.then((value) => {
606
+ if (raceFinished && opts.onLateSettle)
607
+ void Promise.resolve(opts.onLateSettle({ status: "fulfilled", value })).catch(() => { });
608
+ }, (reason) => {
609
+ if (raceFinished && opts.onLateSettle)
610
+ void Promise.resolve(opts.onLateSettle({ status: "rejected", reason })).catch(() => { });
611
+ });
612
+ if (opts.timeoutMs !== null) {
613
+ racers.push(new Promise((resolve) => {
614
+ timer = opts.setTimeoutFn(() => resolve(SDK_OPERATION_TIMED_OUT), opts.timeoutMs ?? 0);
615
+ if (typeof timer !== "number")
616
+ timer.unref?.();
617
+ }));
618
+ }
619
+ if (opts.signal) {
620
+ racers.push(new Promise((resolve) => {
621
+ onAbort = () => resolve(SDK_OPERATION_ABORTED);
622
+ if (opts.signal?.aborted)
623
+ onAbort();
624
+ else
625
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
626
+ }));
627
+ }
628
+ try {
629
+ return racers.length === 1 ? await operation : await Promise.race(racers);
630
+ }
631
+ finally {
632
+ raceFinished = true;
633
+ if (timer !== undefined)
634
+ opts.clearTimeoutFn(timer);
635
+ if (opts.signal && onAbort)
636
+ opts.signal.removeEventListener("abort", onAbort);
637
+ }
638
+ }
639
+ function errorText(err) {
640
+ return err instanceof Error ? err.message : String(err);
641
+ }
642
+ function appendStderr(stderr, message) {
643
+ return stderr ? `${stderr}\n${message}` : message;
644
+ }
645
+ async function deleteSessionBestEffort(client, sessionId, query, setTimeoutFn, clearTimeoutFn) {
646
+ try {
647
+ const deleted = await raceSdkOperation(client.session.delete({ path: { id: sessionId }, ...(query ? { query } : {}) }), {
648
+ timeoutMs: SDK_SESSION_DELETE_TIMEOUT_MS,
649
+ setTimeoutFn,
650
+ clearTimeoutFn,
651
+ });
652
+ if (deleted === SDK_OPERATION_TIMED_OUT) {
653
+ return `OpenCode session cleanup timed out after ${SDK_SESSION_DELETE_TIMEOUT_MS}ms`;
654
+ }
655
+ return undefined;
656
+ }
657
+ catch (err) {
658
+ return `OpenCode session cleanup failed: ${errorText(err)}`;
659
+ }
660
+ }
161
661
  export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
162
662
  const start = Date.now();
663
+ const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : DEFAULT_AGENT_TIMEOUT_MS;
664
+ const deadline = timeoutMs === null ? null : start + timeoutMs;
665
+ const remainingTimeoutMs = () => (deadline === null ? null : Math.max(0, deadline - Date.now()));
666
+ const setTimeoutImpl = opts.setTimeoutFn ?? setTimeout;
667
+ const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout;
163
668
  if (opts.signal?.aborted) {
164
669
  return {
165
670
  ok: false,
@@ -172,23 +677,119 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
172
677
  };
173
678
  }
174
679
  let client;
680
+ if (_testServer) {
681
+ client = _testServer.client;
682
+ }
683
+ else {
684
+ const startupHandle = getOrStartServer(profile, llmConfig, opts.env, opts.envSource);
685
+ try {
686
+ const startup = await raceSdkOperation(startupHandle.promise, {
687
+ timeoutMs: remainingTimeoutMs(),
688
+ setTimeoutFn: setTimeoutImpl,
689
+ clearTimeoutFn: clearTimeoutImpl,
690
+ signal: opts.signal,
691
+ });
692
+ if (startup === SDK_OPERATION_ABORTED) {
693
+ return {
694
+ ok: false,
695
+ stdout: "",
696
+ stderr: "",
697
+ durationMs: Date.now() - start,
698
+ exitCode: null,
699
+ reason: "aborted",
700
+ error: `opencode-sdk agent "${profile.name}" aborted by caller signal during server startup`,
701
+ };
702
+ }
703
+ if (startup === SDK_OPERATION_TIMED_OUT) {
704
+ return {
705
+ ok: false,
706
+ stdout: "",
707
+ stderr: "",
708
+ durationMs: Date.now() - start,
709
+ exitCode: null,
710
+ reason: "timeout",
711
+ error: `opencode-sdk agent "${profile.name}" timed out during server startup after ${timeoutMs}ms`,
712
+ };
713
+ }
714
+ client = startup.client;
715
+ }
716
+ catch (e) {
717
+ return {
718
+ ok: false,
719
+ stdout: "",
720
+ stderr: String(e),
721
+ durationMs: Date.now() - start,
722
+ exitCode: 1,
723
+ reason: "spawn_failed",
724
+ error: String(e),
725
+ };
726
+ }
727
+ finally {
728
+ startupHandle.release();
729
+ }
730
+ }
731
+ // #564 bug fix (3): enforce a hard timeout like the CLI path (runAgent).
732
+ // Previously runOpencodeSdk() awaited SDK calls with no timeout, so a stalled
733
+ // local-model endpoint or wedged server could block the caller indefinitely.
734
+ // The same absolute deadline covers server startup, session creation, and
735
+ // prompting. null disables every dispatch timer. Session cleanup remains a
736
+ // separately bounded best-effort operation.
737
+ // Per-call working directory (module doc): forwarded as the SDK's
738
+ // `query.directory` on every session call, so worktree-isolated units run
739
+ // in their own checkout without a per-cwd server.
740
+ const query = opts.cwd ? { directory: opts.cwd } : undefined;
741
+ // One session per call — do NOT reuse (history accumulates, token costs grow).
742
+ // Session creation is startup plumbing, so failures map to spawn_failed rather
743
+ // than bubbling out as a generic workflow dispatch exception.
744
+ const abortSignal = opts.signal;
745
+ let sessionId;
175
746
  try {
176
- ({ client } = await getOrStartServer(profile, llmConfig));
747
+ const created = await raceSdkOperation(client.session.create({ body: { title: "akm" }, ...(query ? { query } : {}) }), {
748
+ timeoutMs: remainingTimeoutMs(),
749
+ setTimeoutFn: setTimeoutImpl,
750
+ clearTimeoutFn: clearTimeoutImpl,
751
+ signal: abortSignal,
752
+ onLateSettle: (late) => {
753
+ if (late.status === "fulfilled" && late.value.data?.id) {
754
+ void deleteSessionBestEffort(client, late.value.data.id, query, setTimeoutImpl, clearTimeoutImpl);
755
+ }
756
+ },
757
+ });
758
+ if (created === SDK_OPERATION_ABORTED) {
759
+ return {
760
+ ok: false,
761
+ stdout: "",
762
+ stderr: "",
763
+ durationMs: Date.now() - start,
764
+ exitCode: null,
765
+ reason: "aborted",
766
+ error: `opencode-sdk agent "${profile.name}" aborted by caller signal`,
767
+ };
768
+ }
769
+ if (created === SDK_OPERATION_TIMED_OUT) {
770
+ return {
771
+ ok: false,
772
+ stdout: "",
773
+ stderr: "",
774
+ durationMs: Date.now() - start,
775
+ exitCode: null,
776
+ reason: "timeout",
777
+ error: `opencode-sdk agent "${profile.name}" timed out creating a session after ${timeoutMs}ms`,
778
+ };
779
+ }
780
+ sessionId = created.data?.id;
177
781
  }
178
- catch (e) {
782
+ catch (err) {
179
783
  return {
180
784
  ok: false,
181
785
  stdout: "",
182
- stderr: String(e),
786
+ stderr: errorText(err),
183
787
  durationMs: Date.now() - start,
184
788
  exitCode: 1,
185
789
  reason: "spawn_failed",
186
- error: String(e),
790
+ error: errorText(err),
187
791
  };
188
792
  }
189
- // One session per call — do NOT reuse (history accumulates, token costs grow)
190
- const sessionRes = await client.session.create({ body: { title: "akm" } });
191
- const sessionId = sessionRes.data?.id;
192
793
  if (!sessionId) {
193
794
  return {
194
795
  ok: false,
@@ -212,47 +813,19 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
212
813
  body.system = system;
213
814
  if (tools)
214
815
  body.tools = tools;
215
- // #564 bug fix (3): enforce a hard timeout like the CLI path (runAgent).
216
- // Previously runOpencodeSdk() awaited session.prompt() with no timeout, so a
217
- // hung SDK call (e.g. a stalled local-model endpoint) blocked the caller
218
- // indefinitely while the CLI path would have killed the process. We resolve
219
- // the same budget runAgent uses (opts.timeoutMs override → profile.timeoutMs
220
- // → DEFAULT_AGENT_TIMEOUT_MS) and race the prompt against it. null disables
221
- // the timer (parity with runAgent's "no timeout" contract). There is no
222
- // OS process to SIGTERM/SIGKILL here, so on timeout we best-effort delete the
223
- // session (the SDK's equivalent of reaping the in-flight work) and return a
224
- // structured `timeout` failure with the same reason vocabulary as the CLI.
225
- const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : (profile.timeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS);
226
- const setTimeoutImpl = opts.setTimeoutFn ?? setTimeout;
227
- const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout;
228
- let timer;
229
- const TIMED_OUT = Symbol("opencode-sdk-timeout");
230
- const ABORTED = Symbol("opencode-sdk-aborted");
231
- // Cooperative cancel: there is no OS process to signal, so an abort simply
232
- // wins the race below; the finally block reaps the in-flight session, same
233
- // as the timeout path.
234
- let onAbort;
235
- const abortSignal = opts.signal;
816
+ let result;
236
817
  try {
237
- const promptPromise = client.session.prompt({ path: { id: sessionId }, body });
238
- const racers = [promptPromise];
239
- if (timeoutMs !== null) {
240
- racers.push(new Promise((resolve) => {
241
- timer = setTimeoutImpl(() => resolve(TIMED_OUT), timeoutMs);
242
- }));
243
- }
244
- if (abortSignal) {
245
- racers.push(new Promise((resolve) => {
246
- onAbort = () => resolve(ABORTED);
247
- if (abortSignal.aborted)
248
- onAbort();
249
- else
250
- abortSignal.addEventListener("abort", onAbort, { once: true });
251
- }));
252
- }
253
- const result = racers.length === 1 ? await promptPromise : await Promise.race(racers);
254
- if (result === ABORTED) {
255
- return {
818
+ const prompted = await raceSdkOperation(client.session.prompt({ path: { id: sessionId }, body, ...(query ? { query } : {}) }), {
819
+ timeoutMs: remainingTimeoutMs(),
820
+ setTimeoutFn: setTimeoutImpl,
821
+ clearTimeoutFn: clearTimeoutImpl,
822
+ signal: abortSignal,
823
+ onLateSettle: () => {
824
+ void deleteSessionBestEffort(client, sessionId, query, setTimeoutImpl, clearTimeoutImpl);
825
+ },
826
+ });
827
+ if (prompted === SDK_OPERATION_ABORTED) {
828
+ result = {
256
829
  ok: false,
257
830
  stdout: "",
258
831
  stderr: "",
@@ -263,8 +836,8 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
263
836
  sessionId,
264
837
  };
265
838
  }
266
- if (result === TIMED_OUT) {
267
- return {
839
+ else if (prompted === SDK_OPERATION_TIMED_OUT) {
840
+ result = {
268
841
  ok: false,
269
842
  stdout: "",
270
843
  stderr: "",
@@ -275,41 +848,41 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
275
848
  sessionId,
276
849
  };
277
850
  }
278
- const parts = result.data?.parts ?? [];
279
- const textPart = parts.find((p) => p.type === "text");
280
- const stdout = textPart?.text ?? "";
281
- // Token accounting from the AssistantMessage (previously discarded) —
282
- // the seam that makes workflow budget.maxTokens meterable on the
283
- // default sdk runner.
284
- const usage = extractUsage(result.data?.info);
285
- return {
286
- ok: true,
287
- stdout,
288
- stderr: "",
289
- durationMs: Date.now() - start,
290
- exitCode: 0,
291
- sessionId,
292
- ...(usage ? { usage } : {}),
293
- };
851
+ else {
852
+ const parts = prompted.data?.parts ?? [];
853
+ const textPart = parts.find((p) => p.type === "text");
854
+ const stdout = textPart?.text ?? "";
855
+ // Token accounting from the AssistantMessage (previously discarded)
856
+ // the seam that makes workflow budget.maxTokens meterable on the
857
+ // default sdk runner.
858
+ const usage = extractUsage(prompted.data?.info);
859
+ result = {
860
+ ok: true,
861
+ stdout,
862
+ stderr: "",
863
+ durationMs: Date.now() - start,
864
+ exitCode: 0,
865
+ sessionId,
866
+ ...(usage ? { usage } : {}),
867
+ };
868
+ }
294
869
  }
295
- catch (e) {
296
- return {
870
+ catch (err) {
871
+ result = {
297
872
  ok: false,
298
873
  stdout: "",
299
- stderr: String(e),
874
+ stderr: errorText(err),
300
875
  durationMs: Date.now() - start,
301
876
  exitCode: 1,
302
877
  reason: "non_zero_exit",
303
- error: String(e),
878
+ error: errorText(err),
304
879
  sessionId,
305
880
  };
306
881
  }
307
- finally {
308
- if (timer !== undefined)
309
- clearTimeoutImpl(timer);
310
- if (abortSignal && onAbort)
311
- abortSignal.removeEventListener("abort", onAbort);
312
- // Clean up session to prevent disk accumulation in ~/.local/share/opencode/
313
- await client.session.delete({ path: { id: sessionId } }).catch(() => { });
314
- }
882
+ // Clean up session to prevent disk accumulation in ~/.local/share/opencode/.
883
+ // Failures are non-fatal to the agent result but must not be invisible.
884
+ const cleanupWarning = await deleteSessionBestEffort(client, sessionId, query, setTimeoutImpl, clearTimeoutImpl);
885
+ if (cleanupWarning)
886
+ result.stderr = appendStderr(result.stderr, cleanupWarning);
887
+ return result;
315
888
  }