akm-cli 0.9.0-rc.1 → 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 (228) hide show
  1. package/CHANGELOG.md +223 -9
  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/stash-skeleton/README.md +28 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  23. package/dist/assets/tasks/core/backup.yml +1 -0
  24. package/dist/assets/tasks/core/extract.yml +1 -0
  25. package/dist/assets/tasks/core/improve.yml +1 -0
  26. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  27. package/dist/assets/tasks/core/sync.yml +1 -0
  28. package/dist/assets/tasks/core/version-check.yml +1 -0
  29. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  30. package/dist/assets/templates/html/health.html +5 -1
  31. package/dist/cli/config-migrate.js +31 -138
  32. package/dist/cli/config-validate.js +10 -8
  33. package/dist/cli.js +48 -14
  34. package/dist/commands/agent/agent-dispatch.js +17 -16
  35. package/dist/commands/agent/agent-support.js +0 -24
  36. package/dist/commands/agent/contribute-cli.js +5 -15
  37. package/dist/commands/backup-cli.js +54 -0
  38. package/dist/commands/config-cli.js +45 -159
  39. package/dist/commands/env/secret.js +8 -5
  40. package/dist/commands/health/checks.js +130 -83
  41. package/dist/commands/health/html-report.js +4 -0
  42. package/dist/commands/health/improve-metrics.js +30 -32
  43. package/dist/commands/health/llm-usage.js +19 -19
  44. package/dist/commands/health/md-report.js +4 -0
  45. package/dist/commands/health/metrics.js +2 -1
  46. package/dist/commands/health/surfaces.js +5 -4
  47. package/dist/commands/health.js +1 -1
  48. package/dist/commands/improve/consolidate/chunking.js +2 -2
  49. package/dist/commands/improve/consolidate.js +28 -25
  50. package/dist/commands/improve/distill/promote-memory.js +5 -12
  51. package/dist/commands/improve/distill/quality-gate.js +5 -7
  52. package/dist/commands/improve/distill.js +16 -5
  53. package/dist/commands/improve/eligibility.js +22 -12
  54. package/dist/commands/improve/extract-cli.js +47 -19
  55. package/dist/commands/improve/extract.js +110 -62
  56. package/dist/commands/improve/improve-cli.js +38 -16
  57. package/dist/commands/improve/improve-result-file.js +30 -24
  58. package/dist/commands/improve/improve-strategies.js +137 -0
  59. package/dist/commands/improve/improve.js +60 -30
  60. package/dist/commands/improve/locks.js +66 -45
  61. package/dist/commands/improve/loop-stages.js +75 -33
  62. package/dist/commands/improve/memory/memory-belief.js +79 -7
  63. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  64. package/dist/commands/improve/preparation.js +71 -73
  65. package/dist/commands/improve/procedural.js +3 -2
  66. package/dist/commands/improve/recombine.js +2 -1
  67. package/dist/commands/improve/reflect.js +119 -214
  68. package/dist/commands/improve/shared.js +11 -5
  69. package/dist/commands/lint/base-linter.js +152 -42
  70. package/dist/commands/mv-cli.js +809 -0
  71. package/dist/commands/proposal/proposal-cli.js +18 -8
  72. package/dist/commands/proposal/propose.js +64 -69
  73. package/dist/commands/read/knowledge.js +436 -4
  74. package/dist/commands/read/remember-cli.js +39 -2
  75. package/dist/commands/read/search-cli.js +6 -1
  76. package/dist/commands/registry-cli.js +29 -14
  77. package/dist/commands/remember.js +2 -0
  78. package/dist/commands/sources/init.js +13 -14
  79. package/dist/commands/sources/schema-repair.js +2 -4
  80. package/dist/commands/sources/source-add.js +62 -73
  81. package/dist/commands/sources/source-manage.js +50 -46
  82. package/dist/commands/sources/stash-cli.js +41 -4
  83. package/dist/commands/tasks/default-tasks.js +12 -12
  84. package/dist/commands/tasks/tasks-cli.js +7 -3
  85. package/dist/commands/tasks/tasks.js +113 -18
  86. package/dist/commands/wiki-cli.js +9 -10
  87. package/dist/core/asset/frontmatter.js +12 -2
  88. package/dist/core/common.js +5 -3
  89. package/dist/core/config/config-io.js +28 -17
  90. package/dist/core/config/config-schema.js +358 -66
  91. package/dist/core/config/config-types.js +3 -3
  92. package/dist/core/config/config-version.js +29 -0
  93. package/dist/core/config/config-walker.js +98 -27
  94. package/dist/core/config/config.js +132 -266
  95. package/dist/core/config/deep-merge.js +41 -0
  96. package/dist/core/config/engine-semantics.js +32 -0
  97. package/dist/core/errors.js +2 -2
  98. package/dist/core/extra-params.js +61 -0
  99. package/dist/core/file-lock.js +201 -56
  100. package/dist/core/improve-result.js +178 -0
  101. package/dist/core/maintenance-barrier.js +119 -0
  102. package/dist/core/migration-backup.js +416 -0
  103. package/dist/core/paths.js +3 -0
  104. package/dist/core/redaction.js +358 -0
  105. package/dist/core/state/migrations.js +17 -2
  106. package/dist/core/state-db.js +44 -1
  107. package/dist/indexer/db/db.js +116 -1
  108. package/dist/indexer/graph/graph-extraction.js +28 -16
  109. package/dist/indexer/index-writer-lock.js +31 -24
  110. package/dist/indexer/index-written-assets.js +15 -6
  111. package/dist/indexer/indexer.js +47 -2
  112. package/dist/indexer/passes/memory-inference.js +10 -6
  113. package/dist/indexer/passes/metadata.js +250 -0
  114. package/dist/indexer/search/db-search.js +111 -44
  115. package/dist/indexer/search/fts-query.js +41 -0
  116. package/dist/indexer/search/ranking-contributors.js +48 -0
  117. package/dist/indexer/search/ranking.js +36 -23
  118. package/dist/indexer/search/search-fields.js +11 -1
  119. package/dist/integrations/agent/builder-shared.js +7 -0
  120. package/dist/integrations/agent/builders.js +5 -52
  121. package/dist/integrations/agent/config.js +3 -143
  122. package/dist/integrations/agent/detect.js +17 -2
  123. package/dist/integrations/agent/engine-resolution.js +202 -0
  124. package/dist/integrations/agent/index.js +1 -2
  125. package/dist/integrations/agent/model-aliases.js +7 -2
  126. package/dist/integrations/agent/profiles.js +6 -99
  127. package/dist/integrations/agent/runner-dispatch.js +76 -13
  128. package/dist/integrations/agent/runner.js +76 -207
  129. package/dist/integrations/agent/spawn.js +4 -6
  130. package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
  131. package/dist/integrations/harnesses/aider/index.js +0 -1
  132. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
  133. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  134. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  135. package/dist/integrations/harnesses/claude/index.js +0 -2
  136. package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
  137. package/dist/integrations/harnesses/codex/index.js +0 -1
  138. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
  139. package/dist/integrations/harnesses/copilot/index.js +0 -1
  140. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
  141. package/dist/integrations/harnesses/gemini/index.js +0 -1
  142. package/dist/integrations/harnesses/index.js +1 -24
  143. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  144. package/dist/integrations/harnesses/opencode/index.js +0 -6
  145. package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
  146. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  147. package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
  148. package/dist/integrations/harnesses/openhands/index.js +0 -1
  149. package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
  150. package/dist/integrations/harnesses/pi/index.js +0 -1
  151. package/dist/integrations/harnesses/types.js +1 -32
  152. package/dist/integrations/lockfile.js +32 -21
  153. package/dist/llm/client.js +48 -14
  154. package/dist/llm/feature-gate.js +15 -47
  155. package/dist/llm/graph-extract.js +1 -1
  156. package/dist/llm/index-passes.js +8 -42
  157. package/dist/llm/memory-infer-impl.js +1 -1
  158. package/dist/llm/usage-persist.js +4 -0
  159. package/dist/llm/usage-telemetry.js +35 -5
  160. package/dist/output/shapes/helpers.js +2 -1
  161. package/dist/output/shapes/passthrough.js +2 -0
  162. package/dist/output/text/helpers.js +3 -1
  163. package/dist/schemas/akm-config.json +11013 -8600
  164. package/dist/schemas/akm-task.json +87 -0
  165. package/dist/schemas/akm-workflow.json +57 -13
  166. package/dist/scripts/migrate-storage.js +8591 -509
  167. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8623 -484
  168. package/dist/setup/detected-engines.js +142 -0
  169. package/dist/setup/engine-config.js +89 -0
  170. package/dist/setup/setup.js +236 -132
  171. package/dist/setup/steps/connection.js +61 -32
  172. package/dist/setup/steps/platforms.js +4 -4
  173. package/dist/setup/steps.js +3 -2
  174. package/dist/storage/database.js +13 -1
  175. package/dist/storage/engines/sqlite-migrations.js +1 -0
  176. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  177. package/dist/storage/repositories/task-history-repository.js +78 -0
  178. package/dist/storage/repositories/workflow-runs-repository.js +9 -8
  179. package/dist/tasks/parser.js +138 -52
  180. package/dist/tasks/runner.js +71 -75
  181. package/dist/tasks/schema.js +1 -1
  182. package/dist/tasks/validator.js +11 -6
  183. package/dist/wiki/wiki.js +9 -8
  184. package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
  185. package/dist/workflows/concurrency-policy.js +15 -0
  186. package/dist/workflows/db.js +65 -13
  187. package/dist/workflows/exec/brief.js +18 -24
  188. package/dist/workflows/exec/frozen-judge.js +47 -0
  189. package/dist/workflows/exec/native-executor.js +124 -65
  190. package/dist/workflows/exec/report.js +93 -33
  191. package/dist/workflows/exec/run-workflow.js +38 -25
  192. package/dist/workflows/exec/scheduler.js +12 -41
  193. package/dist/workflows/exec/step-work.js +91 -35
  194. package/dist/workflows/ir/compile.js +13 -26
  195. package/dist/workflows/ir/freeze.js +243 -0
  196. package/dist/workflows/ir/plan-hash.js +40 -5
  197. package/dist/workflows/ir/schema.js +542 -1
  198. package/dist/workflows/parser.js +7 -0
  199. package/dist/workflows/program/parser.js +132 -23
  200. package/dist/workflows/program/project.js +3 -4
  201. package/dist/workflows/program/schema.js +2 -2
  202. package/dist/workflows/resource-limits.js +20 -0
  203. package/dist/workflows/runtime/plan-classifier.js +187 -0
  204. package/dist/workflows/runtime/runs.js +54 -47
  205. package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
  206. package/dist/workflows/validator.js +25 -0
  207. package/docs/data-and-telemetry.md +2 -2
  208. package/docs/migration/release-notes/0.6.0.md +1 -1
  209. package/docs/migration/release-notes/0.7.0.md +5 -4
  210. package/docs/migration/v0.8-to-v0.9.md +401 -0
  211. package/package.json +4 -2
  212. package/schemas/akm-config.json +16638 -0
  213. package/schemas/akm-task.json +87 -0
  214. package/schemas/akm-workflow.json +372 -0
  215. package/dist/commands/improve/improve-profiles.js +0 -168
  216. package/dist/core/config/config-migration.js +0 -602
  217. package/dist/core/deep-merge.js +0 -38
  218. package/dist/llm/call-ai.js +0 -62
  219. package/dist/setup/legacy-config.js +0 -106
  220. package/docs/README.md +0 -104
  221. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  222. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  223. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  224. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  225. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  226. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  227. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  228. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -6,10 +6,10 @@
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
14
  *
15
15
  * ## Per-call cwd and env (redesign addendum R2, open seam decision 1)
@@ -29,19 +29,12 @@
29
29
  *
30
30
  * - **env is PER-SERVER (keyed registry).** The SDK exposes NO per-call or
31
31
  * per-session env surface; the only way env reaches tool child processes
32
- * is the `opencode serve` process environment, which
33
- * `createOpencodeServer` copies from `process.env` **synchronously**
34
- * (its `spawn` call runs before its first `await`, so the snapshot is
35
- * taken inside our call frame). {@link getOrStartServer} therefore keys
36
- * servers by a hash of the FULL env binding entries (keys AND values —
37
- * two bindings that share keys but differ in values must not share a
38
- * server), overlays the bindings onto `process.env` for exactly the
39
- * synchronous prefix of the `createOpencode` call, and restores the
40
- * previous values before awaiting. JavaScript's single-threaded event
41
- * loop makes that overlay window atomic: no concurrently-running akm
42
- * code can observe the mutated environment. Units with the same
43
- * bindings share one server; units with none share the default server
44
- * (byte-identical to the pre-R2 singleton behavior).
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.
45
38
  *
46
39
  * This is what removed the workflow engine's `env_unsupported` hard-fail for
47
40
  * the sdk runner: injection genuinely reaches the child, because tool
@@ -50,9 +43,9 @@
50
43
  * Registry hygiene (peer-review fixes):
51
44
  *
52
45
  * - **Ports.** `createOpencodeServer` binds a FIXED default port (4096),
53
- * so coexisting registry entries would contend for the same bind. The
54
- * default key keeps the SDK default; every env-keyed entry is started on
55
- * its own OS-assigned free port (see {@link startServer}).
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}).
56
49
  * - **Shutdown.** {@link closeServer} closes resolved servers
57
50
  * SYNCHRONOUSLY — it runs from `process.once('exit')`, where Bun never
58
51
  * drains microtasks, so a `.then()`-based close would orphan every
@@ -64,7 +57,8 @@
64
57
  * cannot free a process the child is keeping alive (a deadlock that hangs the
65
58
  * caller after an otherwise-successful run). The registry is therefore drained
66
59
  * PROACTIVELY at the end of a dispatching command: the workflow engine calls
67
- * `disposeDispatchResources()` (→ {@link closeServer}) in its run `finally`.
60
+ * `disposeDispatchResources()` (→ {@link closeServer}) in composition-root and
61
+ * workflow `finally` blocks.
68
62
  * The `process.once('exit')` hook stays as the last-resort backstop for paths
69
63
  * that never reach that drain.
70
64
  *
@@ -84,17 +78,16 @@
84
78
  * ({@link SERVER_KILL_GRACE_MS}) that escalates to SIGKILL and is cleared
85
79
  * on cooperative exit, so stubborn children cannot survive parent exit and
86
80
  * stale timers cannot signal a reused PID;
87
- * - the spawn (and its `process.env` snapshot) stays in the SYNCHRONOUS
88
- * prefix of the factory call, preserving the env-overlay contract above.
81
+ * - the spawn receives the immutable per-dispatch environment directly, so
82
+ * asynchronous factory setup cannot race a temporary process-wide overlay.
89
83
  */
90
84
  import { spawn } from "node:child_process";
91
85
  import { createHash } from "node:crypto";
92
- import { resolveSecret } from "../../../core/config/config.js";
93
86
  import { DEFAULT_AGENT_TIMEOUT_MS } from "../../agent/config.js";
94
87
  import { resolveModel } from "../../agent/model-aliases.js";
95
- // Server registry — one server per env-binding signature, started lazily and
96
- // reused across calls. The default (no env bindings) key is "" and behaves
97
- // exactly like the pre-R2 process-wide singleton.
88
+ // Server registry — one server per complete server-material signature. Caller
89
+ // deadlines race the shared promise independently; they never become startup
90
+ // configuration inherited by later callers.
98
91
  const _servers = new Map();
99
92
  // Resolved servers by registry key, mirrored from `_servers` as each start
100
93
  // promise settles. This exists so closeServer() can close started servers
@@ -106,6 +99,8 @@ const _resolvedServers = new Map();
106
99
  // tracked so two coexisting servers in this process can never be assigned
107
100
  // the same port.
108
101
  const _serverPorts = new Map();
102
+ // Serializes probe-and-reserve so concurrent starts cannot claim one port.
103
+ let _portAllocationTail = Promise.resolve();
109
104
  /** The port `createOpencodeServer` binds when none is passed (SDK 1.2.20). */
110
105
  const DEFAULT_SDK_PORT = 4096;
111
106
  // Test override: when set, every call uses this server (all keys) and no real
@@ -127,11 +122,9 @@ export function __setTestServer(server) {
127
122
  /**
128
123
  * Test-only seam: replace the `createOpencode` factory so the env-keyed
129
124
  * server registry (module doc, *Per-call cwd and env*) can be exercised
130
- * without the real SDK. The fake MUST read whatever `process.env` state it
131
- * cares about in its SYNCHRONOUS prefix exactly like the real
132
- * `createOpencodeServer`, whose `spawn` snapshot happens before its first
133
- * await — because the runner restores the env overlay as soon as the factory
134
- * call returns its promise. Pass `null` to clear.
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.
135
128
  */
136
129
  export function __setServerFactory(factory) {
137
130
  _serverFactory = factory;
@@ -145,44 +138,38 @@ export function __setServerFactory(factory) {
145
138
  * `opencode serve` children (leaking processes AND keeping their ports
146
139
  * bound for the next invocation).
147
140
  */
148
- export function closeServer() {
149
- for (const [key, pending] of _servers) {
141
+ export async function closeServer() {
142
+ const closes = [];
143
+ for (const [key, entry] of _servers) {
150
144
  const resolved = _resolvedServers.get(key);
151
145
  if (resolved) {
152
146
  // Synchronous close — safe from the 'exit' hook.
153
147
  try {
154
- resolved.server.close();
148
+ closes.push(Promise.resolve(resolved.server.close()));
155
149
  }
156
150
  catch {
157
151
  /* ignore */
158
152
  }
159
153
  }
160
154
  else {
161
- // Still starting: close on arrival. This branch can never complete
162
- // inside the 'exit' hook (no microtasks there), but it keeps
163
- // mid-start test teardown leak-free.
164
- pending
165
- .then((s) => {
166
- try {
167
- s.server.close();
168
- }
169
- catch {
170
- /* ignore */
171
- }
172
- })
173
- .catch(() => { });
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();
174
159
  }
175
160
  }
176
161
  _servers.clear();
177
162
  _resolvedServers.clear();
178
163
  _serverPorts.clear();
179
164
  try {
180
- _testServer?.server.close();
165
+ if (_testServer)
166
+ closes.push(Promise.resolve(_testServer.server.close()));
181
167
  }
182
168
  catch {
183
169
  /* ignore */
184
170
  }
185
171
  _testServer = null;
172
+ await Promise.allSettled(closes);
186
173
  }
187
174
  /**
188
175
  * Convert an `AgentDispatchRequest.tools` policy into the SDK's tool-allowlist
@@ -241,12 +228,14 @@ function toolsToSdkAllowlist(tools) {
241
228
  * and config-root alias tables apply here.
242
229
  */
243
230
  export function buildSdkConfig(profile, llmConfig) {
244
- // Resolve endpoint and model: profile fields take precedence over config.llm
245
- const endpoint = profile.endpoint ?? llmConfig?.endpoint;
246
- const apiKey = resolveSecret(profile.apiKey ?? llmConfig?.apiKey);
247
- const model = profile.model
248
- ? 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)
249
237
  : undefined;
238
+ const model = profileModel ?? llmConfig?.model;
250
239
  const sdkConfig = {};
251
240
  if (model)
252
241
  sdkConfig.model = model;
@@ -256,48 +245,74 @@ export function buildSdkConfig(profile, llmConfig) {
256
245
  "akm-custom": {
257
246
  npm: "@ai-sdk/openai-compatible",
258
247
  options: {
259
- baseURL: endpoint?.replace(/\/chat\/completions$/, "").replace(/\/$/, ""),
248
+ baseURL: canonicalProviderBase(endpoint) ?? undefined,
260
249
  ...(apiKey ? { apiKey } : {}),
261
250
  },
262
251
  },
263
252
  };
264
- // Use the custom provider's model if not already qualified
265
- if (model && !model.includes("/")) {
266
- sdkConfig.model = `akm-custom/${model}`;
267
- }
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}`;
268
257
  }
269
258
  return sdkConfig;
270
259
  }
271
- /**
272
- * Stable key for the env-keyed server registry: sha256 over the SORTED
273
- * binding entries (keys AND values — see module doc), "" when no bindings.
274
- */
275
- function envServerKey(env) {
276
- if (!env || Object.keys(env).length === 0)
277
- return "";
278
- const entries = Object.entries(env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
279
- return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
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");
280
266
  }
281
- /**
282
- * Overlay `env` onto `process.env`, returning a restore function. The
283
- * overlay is intended to live only for the SYNCHRONOUS prefix of the server
284
- * factory call (module doc): mutation → factory() → restore happens in one
285
- * uninterruptible event-loop turn, so no other code observes it.
286
- */
287
- function overlayProcessEnv(env) {
288
- const previous = new Map();
289
- for (const [key, value] of Object.entries(env)) {
290
- previous.set(key, process.env[key]);
291
- process.env[key] = value;
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;
292
289
  }
293
- return () => {
294
- for (const [key, prior] of previous) {
295
- if (prior === undefined)
296
- delete process.env[key];
297
- else
298
- process.env[key] = prior;
299
- }
300
- };
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;
301
316
  }
302
317
  /**
303
318
  * Ask the OS for a currently-free localhost port (bind :0, read the assigned
@@ -307,33 +322,44 @@ function overlayProcessEnv(env) {
307
322
  * acceptable here because the failure mode is a clean `spawn_failed` on the
308
323
  * next dispatch, not corruption.
309
324
  */
310
- async function allocateFreePort() {
311
- const { createServer } = await import("node:net");
312
- const taken = new Set(_serverPorts.values());
313
- for (let attempt = 0; attempt < 10; attempt++) {
314
- const port = await new Promise((resolve, reject) => {
315
- const probe = createServer();
316
- probe.unref();
317
- probe.on("error", reject);
318
- probe.listen(0, "127.0.0.1", () => {
319
- const address = probe.address();
320
- probe.close(() => {
321
- if (address && typeof address === "object")
322
- resolve(address.port);
323
- else
324
- reject(new Error("could not read the probe socket's port"));
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
+ });
325
348
  });
326
349
  });
327
- });
328
- if (port !== DEFAULT_SDK_PORT && !taken.has(port))
329
- return port;
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();
330
359
  }
331
- throw new Error("could not allocate a free port for the OpenCode SDK server");
332
360
  }
333
361
  /** Grace between SIGTERM and SIGKILL when closing a managed server child. */
334
362
  const SERVER_KILL_GRACE_MS = 2_000;
335
- /** How long the managed spawn waits for the server's listening handshake. */
336
- const SERVER_START_TIMEOUT_MS = 5_000;
337
363
  // Test seam: override the argv used to spawn the server child ("opencode"
338
364
  // plus serve flags by default) so the managed-spawn lifecycle (handshake,
339
365
  // unref, SIGTERM→SIGKILL escalation) is testable without the real binary.
@@ -345,7 +371,7 @@ export function __setServeCommand(argv) {
345
371
  /**
346
372
  * Spawn-owning replacement for the SDK's `createOpencode` (module doc,
347
373
  * *Managed server spawn*). Mirrors `createOpencodeServer`'s contract — the
348
- * `spawn` (and its `process.env` snapshot) happens in the SYNCHRONOUS prefix,
374
+ * `spawn` receives the factory's immutable environment directly,
349
375
  * `OPENCODE_CONFIG_CONTENT` carries the config, the handshake parses the
350
376
  * "opencode server listening on <url>" line — but manages the child so its
351
377
  * handle can never pin akm's event loop:
@@ -359,11 +385,9 @@ async function createManagedOpencode(options) {
359
385
  throw new Error("OpenCode SDK not available. Install @opencode-ai/sdk or configure a CLI agent instead.");
360
386
  }));
361
387
  const port = options.port ?? DEFAULT_SDK_PORT;
362
- const argv = _serveCommand ?? ["opencode", "serve", "--hostname=127.0.0.1", `--port=${port}`];
363
- // Synchronous prefix: the env snapshot (incl. any binding overlay in the
364
- // caller's frame) is taken HERE, before the first await below.
388
+ const argv = _serveCommand ?? [options.bin ?? "opencode", "serve", "--hostname=127.0.0.1", `--port=${port}`];
365
389
  const proc = spawn(argv[0], argv.slice(1), {
366
- env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}) },
390
+ env: options.env,
367
391
  stdio: ["ignore", "pipe", "pipe"],
368
392
  });
369
393
  let closeStarted = false;
@@ -405,17 +429,13 @@ async function createManagedOpencode(options) {
405
429
  };
406
430
  const url = await new Promise((resolve, reject) => {
407
431
  let output = "";
408
- let timer;
409
432
  let settled = false;
410
433
  const cleanupStartup = () => {
411
- if (timer !== undefined) {
412
- clearTimeout(timer);
413
- timer = undefined;
414
- }
415
434
  proc.stdout?.off("data", onStdoutData);
416
435
  proc.stderr?.off("data", onStderrData);
417
436
  proc.off("exit", onExit);
418
437
  proc.off("error", onError);
438
+ options.startupSignal.removeEventListener("abort", onAbort);
419
439
  };
420
440
  const fail = (err) => {
421
441
  if (settled)
@@ -459,13 +479,15 @@ async function createManagedOpencode(options) {
459
479
  const onError = (err) => {
460
480
  fail(err instanceof Error ? err : new Error(String(err)));
461
481
  };
462
- timer = setTimeout(() => {
463
- fail(new Error(`Timeout waiting for the OpenCode server to start after ${SERVER_START_TIMEOUT_MS}ms`));
464
- }, SERVER_START_TIMEOUT_MS);
482
+ const onAbort = () => fail(new Error("OpenCode server startup cancelled because no callers are waiting"));
465
483
  proc.stdout?.on("data", onStdoutData);
466
484
  proc.stderr?.on("data", onStderrData);
467
485
  proc.on("exit", onExit);
468
486
  proc.on("error", onError);
487
+ if (options.startupSignal.aborted)
488
+ onAbort();
489
+ else
490
+ options.startupSignal.addEventListener("abort", onAbort, { once: true });
469
491
  });
470
492
  // Handshake done: from here on the child must never hold akm open. Its
471
493
  // lifetime is managed explicitly (closeServer → closeManaged), not by the
@@ -475,77 +497,83 @@ async function createManagedOpencode(options) {
475
497
  proc.unref();
476
498
  return { client: createOpencodeClient({ baseUrl: url }), server: { close: closeManaged } };
477
499
  }
478
- async function startServer(profile, llmConfig, env, registryKey) {
500
+ async function startServer(profile, sdkConfig, env, registryKey, startupSignal) {
479
501
  const factory = _serverFactory ?? createManagedOpencode;
480
- const sdkConfig = buildSdkConfig(profile, llmConfig);
481
- const options = Object.keys(sdkConfig).length > 0 ? { config: sdkConfig } : {};
482
- // Port discipline: `createOpencodeServer` defaults to a FIXED port (4096),
483
- // so two coexisting servers — the default one plus any env-keyed one —
484
- // would contend for the same bind and the second start would fail. The
485
- // default key keeps the SDK default (byte-identical to the pre-R2
486
- // singleton); every other registry entry gets its own OS-assigned free
487
- // port, allocated BEFORE the env overlay below (allocation awaits).
488
- if (registryKey !== "") {
489
- const port = await allocateFreePort();
490
- _serverPorts.set(registryKey, port);
491
- options.port = port;
492
- }
493
- // Env injection (module doc): the SDK's createOpencodeServer snapshots
494
- // process.env synchronously (its spawn precedes its first await), so the
495
- // overlay only needs to survive the factory's synchronous prefix. Restore
496
- // BEFORE awaiting, so nothing else ever runs under the mutated env.
497
- let pending;
498
- if (env && Object.keys(env).length > 0) {
499
- const restore = overlayProcessEnv(env);
500
- try {
501
- pending = factory(options);
502
- }
503
- finally {
504
- restore();
505
- }
506
- }
507
- else {
508
- pending = factory(options);
509
- }
510
- const server = await pending;
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);
511
514
  if (!server)
512
515
  throw new Error("Failed to initialise OpenCode SDK server.");
513
516
  if (!_exitHookInstalled) {
514
517
  _exitHookInstalled = true;
515
518
  process.once("exit", () => {
516
- closeServer();
519
+ void closeServer();
517
520
  });
518
521
  }
519
522
  return server;
520
523
  }
521
524
  /**
522
- * Get (or lazily start) the server for this call's env bindings. Servers are
523
- * keyed by {@link envServerKey}; concurrent callers of the same key share one
525
+ * Get (or lazily start) the server for this call's complete server material.
526
+ * Concurrent callers of the same key share one
524
527
  * start (the registry stores the in-flight promise). A failed start is
525
528
  * evicted so the next call can retry instead of caching the error forever.
526
529
  */
527
- async function getOrStartServer(profile, llmConfig, env) {
530
+ function getOrStartServer(profile, llmConfig, env, envSource = process.env) {
528
531
  if (_testServer)
529
- return _testServer;
530
- const key = envServerKey(env);
531
- let pending = _servers.get(key);
532
- if (!pending) {
533
- pending = startServer(profile, llmConfig, env, key);
534
- _servers.set(key, pending);
535
- pending.then((server) => {
536
- // Mirror into the synchronously-closable registry (see closeServer)
537
- // but only while this start is still the live entry (closeServer
538
- // may have cleared the registry mid-start).
539
- if (_servers.get(key) === pending)
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;
540
549
  _resolvedServers.set(key, server);
550
+ }
551
+ else {
552
+ void server.server.close();
553
+ }
541
554
  }, () => {
542
- if (_servers.get(key) === pending) {
555
+ if (_servers.get(key) === started) {
543
556
  _servers.delete(key);
544
557
  _serverPorts.delete(key);
545
558
  }
546
559
  });
547
560
  }
548
- return pending;
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
+ };
549
577
  }
550
578
  /**
551
579
  * Extract best-effort token usage from a prompt response. Only numeric
@@ -572,10 +600,20 @@ const SDK_SESSION_DELETE_TIMEOUT_MS = 5_000;
572
600
  async function raceSdkOperation(operation, opts) {
573
601
  let timer;
574
602
  let onAbort;
603
+ let raceFinished = false;
575
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
+ });
576
612
  if (opts.timeoutMs !== null) {
577
613
  racers.push(new Promise((resolve) => {
578
614
  timer = opts.setTimeoutFn(() => resolve(SDK_OPERATION_TIMED_OUT), opts.timeoutMs ?? 0);
615
+ if (typeof timer !== "number")
616
+ timer.unref?.();
579
617
  }));
580
618
  }
581
619
  if (opts.signal) {
@@ -591,6 +629,7 @@ async function raceSdkOperation(operation, opts) {
591
629
  return racers.length === 1 ? await operation : await Promise.race(racers);
592
630
  }
593
631
  finally {
632
+ raceFinished = true;
594
633
  if (timer !== undefined)
595
634
  opts.clearTimeoutFn(timer);
596
635
  if (opts.signal && onAbort)
@@ -621,6 +660,11 @@ async function deleteSessionBestEffort(client, sessionId, query, setTimeoutFn, c
621
660
  }
622
661
  export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
623
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;
624
668
  if (opts.signal?.aborted) {
625
669
  return {
626
670
  ok: false,
@@ -633,32 +677,63 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
633
677
  };
634
678
  }
635
679
  let client;
636
- try {
637
- ({ client } = await getOrStartServer(profile, llmConfig, opts.env));
680
+ if (_testServer) {
681
+ client = _testServer.client;
638
682
  }
639
- catch (e) {
640
- return {
641
- ok: false,
642
- stdout: "",
643
- stderr: String(e),
644
- durationMs: Date.now() - start,
645
- exitCode: 1,
646
- reason: "spawn_failed",
647
- error: String(e),
648
- };
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
+ }
649
730
  }
650
731
  // #564 bug fix (3): enforce a hard timeout like the CLI path (runAgent).
651
732
  // Previously runOpencodeSdk() awaited SDK calls with no timeout, so a stalled
652
733
  // local-model endpoint or wedged server could block the caller indefinitely.
653
- // We resolve the same budget runAgent uses (opts.timeoutMs override
654
- // profile.timeoutMs DEFAULT_AGENT_TIMEOUT_MS) and race both session.create
655
- // and session.prompt against it. null disables the timer (parity with
656
- // runAgent's "no timeout" contract). Session cleanup is separately bounded
657
- // by a short best-effort timer so timeout/abort results cannot be pinned in
658
- // the finally path by a hung delete call.
659
- const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : (profile.timeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS);
660
- const setTimeoutImpl = opts.setTimeoutFn ?? setTimeout;
661
- const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout;
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.
662
737
  // Per-call working directory (module doc): forwarded as the SDK's
663
738
  // `query.directory` on every session call, so worktree-isolated units run
664
739
  // in their own checkout without a per-cwd server.
@@ -670,10 +745,15 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
670
745
  let sessionId;
671
746
  try {
672
747
  const created = await raceSdkOperation(client.session.create({ body: { title: "akm" }, ...(query ? { query } : {}) }), {
673
- timeoutMs,
748
+ timeoutMs: remainingTimeoutMs(),
674
749
  setTimeoutFn: setTimeoutImpl,
675
750
  clearTimeoutFn: clearTimeoutImpl,
676
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
+ },
677
757
  });
678
758
  if (created === SDK_OPERATION_ABORTED) {
679
759
  return {
@@ -736,10 +816,13 @@ export async function runOpencodeSdk(profile, prompt, opts = {}, llmConfig) {
736
816
  let result;
737
817
  try {
738
818
  const prompted = await raceSdkOperation(client.session.prompt({ path: { id: sessionId }, body, ...(query ? { query } : {}) }), {
739
- timeoutMs,
819
+ timeoutMs: remainingTimeoutMs(),
740
820
  setTimeoutFn: setTimeoutImpl,
741
821
  clearTimeoutFn: clearTimeoutImpl,
742
822
  signal: abortSignal,
823
+ onLateSettle: () => {
824
+ void deleteSessionBestEffort(client, sessionId, query, setTimeoutImpl, clearTimeoutImpl);
825
+ },
743
826
  });
744
827
  if (prompted === SDK_OPERATION_ABORTED) {
745
828
  result = {