akm-cli 0.10.261017-beta → 0.10.261019

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.261019] - 2026-10-29
10
+
11
+ The first stable release of the 0.10 line, published to npm `latest`. Its
12
+ changes are in the 0.10 sections below; the upgrade from 0.9 is in
13
+ [the 0.9 to 0.10 migration guide](docs/migration/v0.9-to-v0.10.md) and
14
+ `akm help migrate 0.10.261019`.
15
+
16
+ ## [0.10.261018] - 2026-10-29
17
+
18
+ ### Fixed
19
+
20
+ - A run on akm's own runtime whose model makes the same tool call and gets the
21
+ same result three turns in a row now tells the model so, in a message that
22
+ names the tool and asks it to use the result it has or take a different
23
+ approach, in a request that raises a connection's temperature below 0.7 to
24
+ 0.7 (that one request only; every other keeps the connection's settings, and
25
+ a connection that sets no temperature sends none), and fails with a message
26
+ that names the tool only if the model makes the call again, instead of
27
+ running to its deadline or, with no `timeoutMs`, for ever. Each session of a
28
+ run, a subagent's too, is told for itself. OpenCode has no check of its own
29
+ for this. The runtime
30
+ moves from OpenCode 2.1.4 to 2.2.0 with it: the next agent run fetches the
31
+ binary again, into `<akm cache dir>/opencode/2.2.0/`.
32
+ - Agent work on akm's own runtime runs in the directory it is given: the one
33
+ `akm agent --cwd` names, and the bundle root for a workflow's agent units.
34
+ It ran in the directory akm was started from, because OpenCode takes its
35
+ directory from `PWD`, which akm now sets for the run.
36
+ - A harness run in a directory (`akm agent --cwd`, a harness's `workspace`, the
37
+ scratch directory of model work) now has `PWD` naming that directory, as it
38
+ does for a program started after `cd`. OpenCode takes its directory from
39
+ `PWD`, not the working directory, so an `opencode` harness ran its shell and
40
+ file tools in the directory akm was started in. The spawn every harness
41
+ shares sets it, so any program that reads `PWD` gets the directory it runs in.
42
+ - OpenCode model work leaves out OpenCode's built-in browser plugin. From
43
+ OpenCode 2.2.0 its rules kept the `skill` tool listed for the read-only
44
+ `akm-model-work` agent (a call to it was refused).
45
+ - `akm migrate apply` keeps agent engines. 0.10.261014 to 0.10.261017 dropped
46
+ every `opencode-sdk` engine, so agent work moved from the user's own OpenCode
47
+ and model to akm's own runtime, and `harness:` selectors naming them failed.
48
+ An `opencode-sdk` engine with no `llmEngine` now becomes an `opencode` harness
49
+ with its `model`, `bin`, `workspace`, `agent` and `timeoutMs`; one over an
50
+ `llmEngine` stays akm's own runtime. A harness engine's own `model` stands
51
+ over `models.json`, and the notes say where agent work ran with no
52
+ `defaults.engine`, where a gate's judge ran and runs now, and, with no
53
+ `defaults.llmEngine` and no connection, that model work (improve, index
54
+ passes, extraction, judges) now runs on the default harness where 0.9 ran
55
+ none, and how to turn that off with `modelWork: false`. An `opencode-sdk`
56
+ engine's `args`, which 0.9 did not pass, are dropped with a note. A
57
+ config a beta already migrated is repaired from its pre-migration copy in
58
+ `<cache dir>/config-backups/` (migration guide, step 5).
59
+
9
60
  ## [0.10.261017] - 2026-10-27
10
61
 
11
62
  ### Changed
@@ -18,6 +18,13 @@
18
18
  // transport sends them. OpenCode sizes every request's output cap from the model's limit and sends it as
19
19
  // `max_tokens` or `max_completion_tokens`; that is its own accounting, not a cap anyone asked for, so it is
20
20
  // dropped, and the connection's fields carry a cap only when the connection writes one.
21
+ // - A run that has stopped getting anywhere: when the model has made the same tool calls three turns in a row and
22
+ // been given the same results each time, the next request carries a message from the user that says so and asks it to
23
+ // use the result it has or take another route, and a temperature it carries below 0.7 is raised to 0.7, because a
24
+ // model that is sampled greedily answers the same thing to the same context however it is asked; if the model then
25
+ // makes the same calls again, the run fails with a message that says so, before the next model request is sent.
26
+ // OpenCode has no such check of its own (its `doom_loop` setting is read and ignored), and a run that never ends has
27
+ // nothing else to stop it when the connection sets no `timeoutMs`.
21
28
 
22
29
  import { execFile } from "node:child_process";
23
30
 
@@ -128,6 +135,69 @@ function withFields(base, over) {
128
135
  return merged;
129
136
  }
130
137
 
138
+ /** How many turns in a row may make the same tool calls and get the same results before the model is told so. */
139
+ const REPEATED_TURNS = 3;
140
+
141
+ /**
142
+ * The temperature the one request that tells the model it is repeating itself is raised to, when it is sent at a lower
143
+ * one. A Qwen 27B model sampled greedily (temperature 0) gave the same call again to every wording of the message in
144
+ * every try (0 of 16 replays of a captured request); sampled at 0.7 it left the repeat in about half of them (22 of 44).
145
+ * A request that carries no temperature is left to the endpoint's default: some endpoints refuse any temperature but
146
+ * their own (OpenAI's reasoning models answer anything but 1 with a 400).
147
+ */
148
+ const RECOVERY_TEMPERATURE = 0.7;
149
+
150
+ /**
151
+ * A tool result as the check compares it. OpenCode saves an output too long to send whole to a file of its own for each
152
+ * call and names that file in the result (`[showing … of …; full output saved to <file>]`), so the name is left out:
153
+ * the same long output is the same result.
154
+ */
155
+ const comparable = (content) =>
156
+ JSON.stringify(content).replace(/full output saved to [^\]]*\]/g, "full output saved to <file>]");
157
+
158
+ /**
159
+ * What the last turns of a chat request repeat, when REPEATED_TURNS or more of them in a row made the same calls (the
160
+ * same name and arguments, ids ignored, parallel calls as a set) and were answered with the same results, else
161
+ * undefined: how many turns, the names of the tools called, the calls and results themselves (`signature`) and the id
162
+ * of the first call of the oldest turn (`since`), which tells one run of repeats from the next. A model that does this
163
+ * has settled on one answer to what it is told and does not leave it; a call whose result changes (a poll) is not
164
+ * repeated.
165
+ */
166
+ function repeatedToolCalls(messages) {
167
+ if (!Array.isArray(messages)) return undefined;
168
+ const turns = [];
169
+ let names = [];
170
+ let since;
171
+ let results = new Map();
172
+ // From the newest message back: the tool results, then the assistant message that made the calls they answer.
173
+ for (let i = messages.length - 1; i >= 0; i--) {
174
+ const message = messages[i];
175
+ if (message?.role === "tool") {
176
+ results.set(message.tool_call_id, comparable(message.content));
177
+ continue;
178
+ }
179
+ if (message?.role !== "assistant" || !Array.isArray(message.tool_calls) || message.tool_calls.length === 0) break;
180
+ const calls = message.tool_calls.map((call) => [
181
+ call?.function?.name,
182
+ call?.function?.arguments,
183
+ results.get(call?.id),
184
+ ]);
185
+ if (calls.some(([name, , result]) => name === undefined || result === undefined)) break;
186
+ const turn = calls
187
+ .map((call) => JSON.stringify(call))
188
+ .sort()
189
+ .join("\n");
190
+ if (turns.length > 0 && turn !== turns[0]) break;
191
+ if (turns.length === 0) names = [...new Set(calls.map(([name]) => name))];
192
+ turns.push(turn);
193
+ since = message.tool_calls[0]?.id;
194
+ results = new Map();
195
+ }
196
+ return turns.length >= REPEATED_TURNS ? { turns: turns.length, names, signature: turns[0], since } : undefined;
197
+ }
198
+
199
+ const quoted = (names) => names.map((name) => `\`${name}\``).join(", ");
200
+
131
201
  export default {
132
202
  id: "akm",
133
203
  setup: async (ctx) => {
@@ -161,6 +231,9 @@ export default {
161
231
  }
162
232
 
163
233
  const fields = ctx.options?.requestFields;
234
+ // The repeats the model has been told about, by session and what they repeat: where they began and how many turns
235
+ // there were. One process runs a run's every session (a subagent's too), and each session's model is told for itself.
236
+ const told = new Map();
164
237
  await ctx.session.hook("http.request", async (event) => {
165
238
  const request = event.request;
166
239
  if (request.body === null) return;
@@ -172,12 +245,41 @@ export default {
172
245
  }
173
246
  if (!isObject(body)) return;
174
247
  const { max_tokens: _own, max_completion_tokens: _ownCompletion, ...kept } = body;
248
+ const stuck = repeatedToolCalls(body.messages);
249
+ if (stuck) {
250
+ const key = `${event.sessionID}\n${stuck.signature}`;
251
+ const was = told.get(key);
252
+ // The model has been told about these calls and made them again, or has come back to them since.
253
+ if (was && (was.since !== stuck.since || stuck.turns > was.turns)) {
254
+ // Measured on OpenCode 2.2.0: an error thrown from this hook fails the run's turn before the request is sent,
255
+ // and `opencode run` exits 1 with the error's message (an unknown error in the session, which akm reports as
256
+ // a non-zero exit). Answering the request with a 4xx instead also ends the run, but only after the request
257
+ // has gone to the model.
258
+ throw new Error(
259
+ `akm stopped this run: the model called ${quoted(stuck.names)} with the same input ${stuck.turns} times in a row and got the same result each time, although it had been told it was repeating itself.`,
260
+ );
261
+ }
262
+ // A request that is sent again unchanged (a retry) is told again, the same way.
263
+ told.set(key, { since: stuck.since, turns: stuck.turns });
264
+ // OpenCode keeps no trace of this message: it does not store what a request's body is changed to.
265
+ kept.messages = [
266
+ ...kept.messages,
267
+ {
268
+ role: "user",
269
+ content: `You have called ${quoted(stuck.names)} with the same input ${stuck.turns} times in a row and got the same result each time, so calling it again will not give you anything new. Use the result you already have, or take a different approach to finish the task. If you call it again with the same input, this run will be stopped.`,
270
+ },
271
+ ];
272
+ }
273
+ const forwarded = isObject(fields) ? withFields(kept, fields) : kept;
274
+ if (stuck && typeof forwarded.temperature === "number" && forwarded.temperature < RECOVERY_TEMPERATURE) {
275
+ forwarded.temperature = RECOVERY_TEMPERATURE;
276
+ }
175
277
  const headers = new Headers(request.headers);
176
278
  headers.delete("content-length");
177
279
  event.request = new Request(request.url, {
178
280
  method: request.method,
179
281
  headers,
180
- body: JSON.stringify(isObject(fields) ? withFields(kept, fields) : kept),
282
+ body: JSON.stringify(forwarded),
181
283
  signal: request.signal,
182
284
  });
183
285
  });
@@ -85,14 +85,30 @@ export function improveStrategyDefault(out, defaults, improve) {
85
85
  out.improve = { ...(isRecord(improve) ? improve : {}) };
86
86
  out.improve.strategy ??= defaults.improveStrategy;
87
87
  }
88
- /** `workflow.judgeEngine`: a gate's judge is the default connection or harness now. */
89
- export function dropJudgeEngine(out, conversion) {
88
+ /**
89
+ * `workflow.judgeEngine`, else `defaults.engine`, is where 0.9 ran a gate's judge. It is model work now and runs where
90
+ * model work does, `judge`: the default connection, else the default harness. `workflow.judgeEngine` is dropped, and
91
+ * a note says whether the judge moves.
92
+ */
93
+ export function dropJudgeEngine(out, conversion, target, defaultEngine, judge) {
90
94
  const workflow = out.workflow;
91
- if (!isRecord(workflow) || !("judgeEngine" in workflow))
92
- return;
93
- delete workflow.judgeEngine;
94
- conversion.changed = true;
95
- conversion.notes.push("workflow.judgeEngine: a gate's judge is the default connection or harness now; it is dropped.");
95
+ const named = isRecord(workflow) && "judgeEngine" in workflow;
96
+ const engine = named ? str(workflow.judgeEngine) : defaultEngine;
97
+ if (named) {
98
+ delete workflow.judgeEngine;
99
+ conversion.changed = true;
100
+ }
101
+ const became = engine === undefined ? undefined : target(engine);
102
+ const stays = became !== undefined && judge !== undefined && isDeepStrictEqual(became, judge);
103
+ const kind = judge === undefined ? undefined : "connection" in judge ? "connection" : "harness";
104
+ if (named) {
105
+ conversion.notes.push(stays
106
+ ? `workflow.judgeEngine: "${engine}" is the default ${kind}, where a gate's judge runs now; the setting is dropped.`
107
+ : `workflow.judgeEngine${engine === undefined ? "" : ` "${engine}"`}: dropped. A gate's judge runs on the default connection, else the default harness, so a judge that ran on another engine has moved; to keep it, make that engine the default.`);
108
+ }
109
+ else if (became !== undefined && judge !== undefined && !stays) {
110
+ conversion.notes.push(`workflow: with no workflow.judgeEngine, a gate's judge ran on the default engine "${engine}"; it is model work now and runs on the default ${kind}, "${"connection" in judge ? judge.connection : judge.harness}".`);
111
+ }
96
112
  }
97
113
  /** `index.defaults.*` and `index.<pass>.engine`: `index.*` and `connection`; a harness cannot run an index pass. */
98
114
  export function convertIndexEngines(out, conversion, retarget) {
@@ -55,17 +55,32 @@ function implicitKeyVariables(name, defaults) {
55
55
  return name === str(defaults.llmEngine) ? [own, "AKM_LLM_API_KEY"] : [own];
56
56
  }
57
57
  // ── engines → connections / harnesses ────────────────────────────────────────
58
- /** `engines.<name>` with `kind: "agent"` and `platform: "opencode-sdk"`: akm's own runtime has no entry of its own. */
59
- function runtimeEngine(name, engine, converted, conversion) {
60
- converted.runtimes.set(name, str(engine.llmEngine));
61
- conversion.notes.push(`engines.${name}: opencode-sdk is akm's own runtime now, with no entry of its own; its model, timeout and workspace are dropped.`);
58
+ /**
59
+ * `engines.<name>` with `kind: "agent"` and `platform: "opencode-sdk"` over an `llmEngine`: OpenCode ran over
60
+ * akm's own connection, which is what akm's own runtime is now, so it has no entry of its own.
61
+ */
62
+ function runtimeEngine(name, engine, llmEngine, converted, conversion) {
63
+ converted.runtimes.set(name, llmEngine);
64
+ const dropped = ["model", "timeoutMs", "workspace", "bin", "agent"].filter((key) => engine[key] !== undefined);
65
+ const gone = dropped.length === 0 ? "" : `; its ${dropped.join(", ")} ${dropped.length === 1 ? "is" : "are"} dropped`;
66
+ conversion.notes.push(`engines.${name}: opencode-sdk over llmEngine "${llmEngine}" is akm's own runtime over connection "${llmEngine}" now, with no entry of its own${gone}.`);
62
67
  }
63
- /** `engines.<name>` with `kind: "agent"`, any other platform: `harnesses.<name>`. */
64
- function agentEngineToHarness(name, engine, converted) {
65
- const harness = { platform: engine.platform };
68
+ /**
69
+ * `engines.<name>` with `kind: "agent"`: `harnesses.<name>`. An `opencode-sdk` engine with no `llmEngine` ran the
70
+ * user's own OpenCode (provider, model and login from its configuration), which the `opencode` harness runs now.
71
+ */
72
+ function agentEngineToHarness(name, engine, converted, conversion) {
73
+ const sdk = engine.platform === "opencode-sdk";
74
+ const harness = { platform: sdk ? "opencode" : engine.platform };
75
+ if (sdk)
76
+ converted.sdkHarnesses.add(name);
77
+ // 0.9 started `opencode serve` without an engine's `args`; the harness would pass them in place of `run`.
66
78
  for (const key of ["bin", "args", "workspace", "agent", "timeoutMs"])
67
- if (engine[key] !== undefined)
79
+ if (engine[key] !== undefined && !(sdk && key === "args"))
68
80
  harness[key] = engine[key];
81
+ if (sdk && engine.args !== undefined) {
82
+ conversion.notes.push(`engines.${name}: args is dropped. 0.9 did not pass it to OpenCode, and the opencode harness would pass it in place of \`run\`.`);
83
+ }
69
84
  const model = str(engine.model);
70
85
  if (model !== undefined)
71
86
  converted.ownModelOf.push({ kind: "harnesses", engine: name, model });
@@ -123,6 +138,7 @@ function convertEngines(engines, defaults, env, conversion) {
123
138
  connections: {},
124
139
  harnesses: {},
125
140
  runtimes: new Map(),
141
+ sdkHarnesses: new Set(),
126
142
  keyHolders: [],
127
143
  ownModelOf: [],
128
144
  };
@@ -132,12 +148,13 @@ function convertEngines(engines, defaults, env, conversion) {
132
148
  const unknown = Object.keys(engine).filter((k) => !(engine.kind === "agent" ? AGENT_ENGINE_KEYS : LLM_ENGINE_KEYS).has(k));
133
149
  if (unknown.length > 0)
134
150
  conversion.notes.push(`engines.${name}: dropped ${unknown.join(", ")}, which akm does not read.`);
151
+ const llmEngine = str(engine.llmEngine);
135
152
  if (engine.kind !== "agent")
136
153
  llmEngineToConnection(name, engine, defaults, env, converted, conversion);
137
- else if (engine.platform === "opencode-sdk")
138
- runtimeEngine(name, engine, converted, conversion);
154
+ else if (engine.platform === "opencode-sdk" && llmEngine !== undefined)
155
+ runtimeEngine(name, engine, llmEngine, converted, conversion);
139
156
  else
140
- agentEngineToHarness(name, engine, converted);
157
+ agentEngineToHarness(name, engine, converted, conversion);
141
158
  }
142
159
  return converted;
143
160
  }
@@ -161,12 +178,37 @@ function renameMap(names, taken, from) {
161
178
  }
162
179
  return (name) => moved.get(name) ?? name;
163
180
  }
181
+ /** The entry the loader reads as the default: the one called `default`, else the only one. */
182
+ const defaultName = (entries) => "default" in entries ? "default" : Object.keys(entries).length === 1 ? Object.keys(entries)[0] : undefined;
183
+ /**
184
+ * The engine 0.9 ran agent work on when `defaults.engine` named none and OpenCode was installed: the one called
185
+ * `opencode-sdk`, else a bare OpenCode of the user's own.
186
+ */
187
+ const IMPLICIT_AGENT_ENGINE = "opencode-sdk";
188
+ /**
189
+ * Where agent work ran when `defaults.engine` named no harness, if that is not where it runs now. A runtime was akm's
190
+ * own; with no `defaults.engine` it was the user's OpenCode, whenever it was installed.
191
+ */
192
+ function agentWorkMoved(defaultEngine, runtimes, harness) {
193
+ if (defaultEngine !== undefined) {
194
+ return runtimes.has(defaultEngine) && harness !== undefined
195
+ ? `Agent work ran on akm's own runtime (engines.${defaultEngine}); with the harness "${harness}" configured it now runs there. Remove that harness to keep agent work on akm's runtime.`
196
+ : undefined;
197
+ }
198
+ const ran = "Without defaults.engine, agent work ran on your own OpenCode (the implicit fallback), when it was installed";
199
+ return harness !== undefined
200
+ ? `${ran}; with the harness "${harness}" configured it now runs there.`
201
+ : `${ran}; it now runs on akm's own runtime. To keep it on your OpenCode, enable the harness: \`akm setup --harness opencode\`.`;
202
+ }
164
203
  /** `defaults.llmEngine`, else `defaults.engine`: that connection is `default`; `defaults.engine` naming one of several harnesses: that harness is. */
165
204
  function nameDefaults(defaults, converted, conversion, written) {
166
- const { connections, harnesses, runtimes, keyHolders } = converted;
167
- const defaultEngine = str(defaults.engine);
205
+ const { connections, harnesses, runtimes, sdkHarnesses, keyHolders } = converted;
206
+ const implicit = sdkHarnesses.has(IMPLICIT_AGENT_ENGINE) || runtimes.has(IMPLICIT_AGENT_ENGINE);
207
+ const defaultEngine = str(defaults.engine) ?? (implicit ? IMPLICIT_AGENT_ENGINE : undefined);
208
+ const modelWorkEngine = str(defaults.llmEngine);
168
209
  const runtimeLlm = defaultEngine === undefined ? undefined : runtimes.get(defaultEngine);
169
- const defaultConnection = [str(defaults.llmEngine), defaultEngine, runtimeLlm].find((n) => n !== undefined && n in connections);
210
+ const modelWorkRuntimeLlm = modelWorkEngine === undefined ? undefined : runtimes.get(modelWorkEngine);
211
+ const defaultConnection = [modelWorkEngine, modelWorkRuntimeLlm, defaultEngine, runtimeLlm].find((n) => n !== undefined && n in connections);
170
212
  const defaultHarness = defaultEngine !== undefined && defaultEngine in harnesses && Object.keys(harnesses).length > 1
171
213
  ? defaultEngine
172
214
  : undefined;
@@ -177,13 +219,18 @@ function nameDefaults(defaults, converted, conversion, written) {
177
219
  connections: renamed(connections, connectionName),
178
220
  harnesses: renamed(harnesses, harnessName),
179
221
  };
180
- const defaultHarnessName = "default" in named.harnesses
181
- ? "default"
182
- : Object.keys(named.harnesses).length === 1
183
- ? Object.keys(named.harnesses)[0]
184
- : undefined;
185
- if (defaultHarnessName !== undefined && (defaultEngine === undefined || runtimes.has(defaultEngine))) {
186
- conversion.notes.push(`Agent work ran on akm's own runtime${defaultEngine === undefined ? "" : ` (engines.${defaultEngine})`}; with the harness "${defaultHarnessName}" configured it now runs there. Remove that harness to keep agent work on akm's runtime.`);
222
+ const defaultHarnessName = defaultName(named.harnesses);
223
+ const hasEngines = Object.keys(connections).length + Object.keys(harnesses).length + runtimes.size > 0;
224
+ const moved = hasEngines ? agentWorkMoved(defaultEngine, runtimes, defaultHarnessName) : undefined;
225
+ if (moved !== undefined)
226
+ conversion.notes.push(moved);
227
+ // `defaults.llmEngine` could name an agent engine, which then ran akm's model work; model work runs on the default
228
+ // connection, else the default harness, whichever engine that was.
229
+ if (modelWorkEngine !== undefined && modelWorkEngine in harnesses) {
230
+ const now = harnessName(modelWorkEngine);
231
+ if (defaultName({ ...named.connections, ...written.connections }) !== undefined || defaultHarnessName !== now) {
232
+ conversion.notes.push(`defaults.llmEngine "${modelWorkEngine}" named an agent engine, which ran akm's model work. Model work now runs on the default connection, else the default harness; to keep it on the harness "${now}", set improve.harness to it.`);
233
+ }
187
234
  }
188
235
  for (const [kind, rename] of [
189
236
  ["connections", connectionName],
@@ -195,10 +242,9 @@ function nameDefaults(defaults, converted, conversion, written) {
195
242
  conversion.notes.push(`engines.default is now ${kind}.${aside}, and "default" names another engine: an engine: default key is rewritten by \`akm migrate apply\` where it can be, but a script or scheduled command that passes --engine default now reaches the new "default"; change it to the name ${aside}.`);
196
243
  }
197
244
  const target = (engine) => {
198
- if (runtimes.has(engine)) {
199
- const runtime = runtimes.get(engine);
200
- return runtime !== undefined && runtime in connections ? { connection: connectionName(runtime) } : undefined;
201
- }
245
+ const runtime = runtimes.get(engine);
246
+ if (runtime !== undefined)
247
+ return runtime in connections ? { connection: connectionName(runtime) } : undefined;
202
248
  if (engine in harnesses)
203
249
  return { harness: harnessName(engine) };
204
250
  if (engine in connections)
@@ -210,7 +256,7 @@ function nameDefaults(defaults, converted, conversion, written) {
210
256
  connection: connectionName(engine),
211
257
  field,
212
258
  }));
213
- return { defaultConnection, defaultHarness, connectionName, harnessName, ...named, keys, target };
259
+ return { defaultEngine, defaultConnection, defaultHarness, connectionName, harnessName, ...named, keys, target };
214
260
  }
215
261
  /** What the file writes under `connections` / `harnesses` itself, read with the converted entries: the written entry wins field by field. */
216
262
  function mergeWritten(out, naming) {
@@ -230,6 +276,24 @@ function ownModelsOf(converted, naming) {
230
276
  }
231
277
  return ownModels;
232
278
  }
279
+ /**
280
+ * Where a gate's judge and akm's other model work run now when nothing names a place: the default connection, else
281
+ * the default harness. With no `defaults.llmEngine`, 0.9 ran none of that work unless a setting named an engine, so
282
+ * a default harness that takes it now gets a note saying so and how to turn it off.
283
+ */
284
+ function modelWorkTarget(defaults, naming, merged, inherited, conversion) {
285
+ const connection = defaultName({ ...inherited?.connections, ...merged.connections });
286
+ if (connection !== undefined)
287
+ return { connection };
288
+ const harness = defaultName({ ...inherited?.harnesses, ...merged.harnesses });
289
+ const entry = harness === undefined ? undefined : merged.harnesses[harness];
290
+ if (harness === undefined || !isRecord(entry))
291
+ return undefined;
292
+ if (str(defaults.llmEngine) === undefined && harness in naming.harnesses && entry.modelWork === undefined) {
293
+ conversion.notes.push(`harnesses.${harness}: akm's own model work (improve, index passes, extraction, judges) now runs on this harness, the default for it with no connection. Without defaults.llmEngine, 0.9 ran none of it unless a setting named an engine (a workflow gate's judge ran on defaults.engine). To keep the 0.9 behaviour, set \`modelWork: false\` on this harness: a call that names it still runs there, but a workflow gate's judge then needs a connection (\`akm setup --endpoint URL\`).`);
294
+ }
295
+ return entry.modelWork === false ? undefined : { harness };
296
+ }
233
297
  // ── the whole conversion ─────────────────────────────────────────────────────
234
298
  export function convertEngineConfig(raw, options = {}) {
235
299
  const out = structuredClone(raw);
@@ -254,8 +318,9 @@ export function convertEngineConfig(raw, options = {}) {
254
318
  inherited: { connections: options.inherited?.connections ?? {}, harnesses: options.inherited?.harnesses ?? {} },
255
319
  renamed: {
256
320
  runtime: [...converted.runtimes.values()]
257
- .filter((name) => name !== undefined && name in converted.connections)
321
+ .filter((name) => name in converted.connections)
258
322
  .map((name) => naming.connectionName(name)),
323
+ sdkHarness: [...converted.sdkHarnesses].map((name) => naming.harnessName(name)),
259
324
  connection: (engine) => (engine in converted.connections ? naming.connectionName(engine) : undefined),
260
325
  },
261
326
  });
@@ -265,7 +330,8 @@ export function convertEngineConfig(raw, options = {}) {
265
330
  if (isRecord(improve))
266
331
  moveStrategyExecution(improve, conversion, retarget);
267
332
  improveStrategyDefault(out, defaults, improve);
268
- dropJudgeEngine(out, conversion);
333
+ const judge = modelWorkTarget(defaults, naming, merged, options.inherited, conversion);
334
+ dropJudgeEngine(out, conversion, naming.target, naming.defaultEngine, judge);
269
335
  convertIndexEngines(out, conversion, retarget);
270
336
  // Assemble, keeping the file's key order.
271
337
  const config = {};
@@ -44,6 +44,7 @@ function overlayRows(modelsOverlay) {
44
44
  /**
45
45
  * `models.json`: an alias is set by, nearest first, the config's own `models`, models.json's column for that
46
46
  * connection or harness, models.json's `*` column (and the flat shorthand, which is that), the engine's own model.
47
+ * The exception is a harness engine's own model: it ran when a request named none, so it stands over every column.
47
48
  */
48
49
  export function foldModelsOverlay(modelsOverlay, fold) {
49
50
  const { conversion, merged, written, inherited, ownModels, renamed } = fold;
@@ -57,6 +58,10 @@ export function foldModelsOverlay(modelsOverlay, fold) {
57
58
  /** Whether the config itself, the file or its base, already names a model for the alias. */
58
59
  const configNames = (own, base, name, alias) => hasModel(own[name], alias) || hasModel(base[name], alias);
59
60
  const platformOf = (name) => String((mergedHarnesses[name] ?? inheritedHarnesses[name])?.platform ?? name);
61
+ /** The column of models.json that named a harness: an `opencode-sdk` engine's was `opencode-sdk`, not its platform's. */
62
+ const columnOf = (name) => (renamed.sdkHarness.includes(name) ? "opencode-sdk" : platformOf(name));
63
+ /** Harnesses whose engine had a `model` of its own, which names every alias and so stands over the column (noted once each). */
64
+ const notedOwn = new Set();
60
65
  let folded = false;
61
66
  for (const { alias, column, profile } of overlayRows(modelsOverlay)) {
62
67
  const p = typeof profile === "string" ? { model: profile } : isRecord(profile) ? profile : {};
@@ -76,7 +81,7 @@ export function foldModelsOverlay(modelsOverlay, fold) {
76
81
  : column in writtenConnections || column in inheritedConnections
77
82
  ? [column]
78
83
  : [];
79
- const harnessNamed = [...new Set([...Object.keys(mergedHarnesses), ...Object.keys(inheritedHarnesses)])].filter((name) => platformOf(name) === column);
84
+ const harnessNamed = [...new Set([...Object.keys(mergedHarnesses), ...Object.keys(inheritedHarnesses)])].filter((name) => columnOf(name) === column);
80
85
  if (candidates.length === 0 && harnessNamed.length === 0) {
81
86
  notes.push(`${where}: names no configured connection or harness, so it is not carried over.`);
82
87
  continue;
@@ -84,7 +89,14 @@ export function foldModelsOverlay(modelsOverlay, fold) {
84
89
  // The config's own choice stands, and `*` leaves what an explicit column already set.
85
90
  const connectionNames = candidates.filter((name) => !configNames(writtenConnections, inheritedConnections, name, alias) &&
86
91
  !(column === "*" && hasModel(mergedConnections[name], alias)));
87
- const harnessNames = harnessNamed.filter((name) => !configNames(writtenHarnesses, inheritedHarnesses, name, alias));
92
+ const unnamed = harnessNamed.filter((name) => !configNames(writtenHarnesses, inheritedHarnesses, name, alias));
93
+ // The model a harness engine named is what ran when a request named none, and a harness runs its first model then,
94
+ // so it stands over the column, which would put the column's models first.
95
+ const harnessNames = unnamed.filter((name) => !ownModels.harnesses.has(name));
96
+ for (const name of unnamed.filter((n) => ownModels.harnesses.has(n) && !notedOwn.has(`${column}:${n}`))) {
97
+ notedOwn.add(`${column}:${name}`);
98
+ notes.push(`models.json (${column === "*" ? "all" : column}): harness "${name}" has a model of its own, which names every alias, so the column is not carried over.`);
99
+ }
88
100
  if (connectionNames.length === 0 && harnessNames.length === 0) {
89
101
  // Only the config's own choice is worth a note; an explicit column that came first is the overlay's own.
90
102
  const keptByConfig = candidates.some((name) => configNames(writtenConnections, inheritedConnections, name, alias)) ||
@@ -14,6 +14,7 @@
14
14
  * The command comes from the harness (`harness.build`); this module never
15
15
  * touches a model. Agents are reachable only via shell-out.
16
16
  */
17
+ import path from "node:path";
17
18
  import { ConfigError } from "../../core/errors.js";
18
19
  import { childProcessEnv } from "../../core/paths.js";
19
20
  import { runManagedSubprocess, streamCaptureFailure } from "../../core/subprocess.js";
@@ -86,10 +87,14 @@ export async function runBuiltCommand(label, { argv, env, stdin }, options, stdi
86
87
  // and the optional stdin payload. This keeps the agent-specific surface:
87
88
  // the failure taxonomy.
88
89
  const start = Date.now();
90
+ // A program started after `cd <dir>` has PWD equal to <dir>, and OpenCode takes its directory from PWD before its
91
+ // own working directory: the PWD akm inherits names where akm was started, so a run given a directory names it. One
92
+ // path is both, so they agree even where the kernel would resolve a `..` after a symbolic link elsewhere.
93
+ const cwd = options.cwd ? path.resolve(options.cwd) : undefined;
89
94
  const result = await runManagedSubprocess([...argv], {
90
95
  capture: stdioMode === "captured",
91
- env: { ...env },
92
- ...(options.cwd ? { cwd: options.cwd } : {}),
96
+ env: { ...env, ...(cwd ? { PWD: cwd } : {}) },
97
+ ...(cwd ? { cwd } : {}),
93
98
  ...(stdin !== undefined ? { stdin } : {}),
94
99
  timeoutMs,
95
100
  ...(options.signal ? { signal: options.signal } : {}),
@@ -60,9 +60,17 @@ const MODEL_WORK_AGENT_DEFINITION = {
60
60
  ],
61
61
  };
62
62
  const CONFIG_CONTENT_ENV = "OPENCODE_CONFIG_CONTENT";
63
+ /**
64
+ * OpenCode's built-in browser plugin, removed from model work (a `-` entry in `plugins` removes a plugin, OpenCode 2.0.0
65
+ * and later). From 2.2.0 it appends a deny of its own skill after every agent's rules, and a rule on one skill keeps
66
+ * the `skill` tool listed for an agent that denies every tool; without the plugin, the `*` deny removes the tool again.
67
+ * Model work denies the browser anyway.
68
+ */
69
+ const WITHOUT_BROWSER_PLUGIN = "-opencode.browser";
63
70
  /**
64
71
  * The inline config model work runs with: the user's own `OPENCODE_CONFIG_CONTENT` as it is, with akm's agent set on
65
- * `agents.akm-model-work`, the one key akm's definition wins. OpenCode 2 reads that native `agents` map over the
72
+ * `agents.akm-model-work`, the one key akm's definition wins, and {@link WITHOUT_BROWSER_PLUGIN} after the user's
73
+ * `plugins`. OpenCode 2 reads that native `agents` map over the
66
74
  * legacy `agent` one, so a same-named entry the user keeps in `agent` is left out rather than left to conflict. The
67
75
  * child's value is built from the parent's, so a provider, model or key the user keeps only there stays. It is read as
68
76
  * OpenCode reads it, as JSONC (comments and trailing commas), and written back as JSON; content that reads only once
@@ -78,6 +86,7 @@ function modelWorkConfig() {
78
86
  }
79
87
  const config = {
80
88
  ...user,
89
+ plugins: [...(Array.isArray(user?.plugins) ? user.plugins : []), WITHOUT_BROWSER_PLUGIN],
81
90
  agents: { ...asRecord(user?.agents), [MODEL_WORK_AGENT]: MODEL_WORK_AGENT_DEFINITION },
82
91
  };
83
92
  const legacy = asRecord(user?.agent);
@@ -25,10 +25,13 @@ import { info } from "../../../../core/warn.js";
25
25
  * The OpenCode release akm's own runtime runs. It is pinned because the runtime depends on things no OpenCode release
26
26
  * promises: the inline config's shape, the `--standalone` flag, the plugin API, the text `run` prints, and the
27
27
  * `opencode.db` tables that `../session-log-v2.ts` reads. To move it, change this version and `PACKAGES` if the release
28
- * adds or drops a platform package, then run `tests/integration/opencode-runtime-sessions.test.ts` against the new
29
- * binary (it fetches it) and read `docs/reference/configuration.md` ("Without a harness") for what the version changes.
28
+ * adds or drops a platform package, then run `tests/integration/opencode-runtime-sessions.test.ts` and
29
+ * `tests/integration/opencode-runtime-loop-guard.test.ts` against the new binary (they fetch it; the plugin ends a
30
+ * looping run by throwing from a hook, which OpenCode might one day handle differently, and tells the model by changing
31
+ * the body of a request, which it might one day stop letting a plugin do) and read
32
+ * `docs/reference/configuration.md` ("Without a harness") for what the version changes.
30
33
  */
31
- export const OPENCODE_RUNTIME_VERSION = "2.1.4";
34
+ export const OPENCODE_RUNTIME_VERSION = "2.2.0";
32
35
  /** The platform packages of `@opencode/cli` at {@link OPENCODE_RUNTIME_VERSION}. */
33
36
  const PACKAGES = new Set([
34
37
  "@opencode/cli-linux-x64",
@@ -23,7 +23,7 @@ import { openDatabase } from "../../../../storage/database.js";
23
23
  import { resolveAkmInvocation } from "../../../../tasks/resolve-akm-bin.js";
24
24
  import { buildChildEnv, runBuiltCommand, } from "../../../agent/spawn.js";
25
25
  import { akmOpencodeDatabasePath, akmRuntimeDir } from "../roots.js";
26
- import { v2SessionTokens } from "../session-log-v2.js";
26
+ import { v2SessionFailure, v2SessionTokens } from "../session-log-v2.js";
27
27
  import { ensureRuntimeBinary, OPENCODE_RUNTIME_VERSION, runtimeBinaryPath } from "./binary.js";
28
28
  import { runtimeArgv, runtimeConfig, runtimeEnv, runtimePrepareArgv } from "./command.js";
29
29
  /**
@@ -61,10 +61,11 @@ function installPlugin(directory) {
61
61
  /**
62
62
  * OpenCode makes and migrates its database when a process starts, and two processes doing that to one database break
63
63
  * each other (one fails with "Standalone server exited before reporting readiness"; on 2.1.4 two runs started together
64
- * on a fresh directory lose one of them every time). So the first start of this OpenCode version is a preparing run of
65
- * its own, under a lock file in the runtime directory that every run, of this akm process or another, waits on, and it
66
- * leaves a marker naming the version; a run that finds the marker and the database starts at once. A dead holder's
67
- * lock is reclaimed. The run itself reports anything the preparation does not.
64
+ * on a fresh directory lose one of them every time; on 2.2.0, 4 of 24 starts in groups of eight failed). So the first
65
+ * start of this OpenCode version is a preparing run of its own, under a lock file in the runtime directory that every
66
+ * run, of this akm process or another, waits on, and it leaves a marker naming the version; a run that finds the marker
67
+ * and the database starts at once. A dead holder's lock is reclaimed. The run itself reports anything the preparation
68
+ * does not.
68
69
  */
69
70
  async function prepareDatabase(directory, command, opts) {
70
71
  const marker = path.join(directory, `prepared-${OPENCODE_RUNTIME_VERSION}`);
@@ -100,9 +101,9 @@ async function prepareDatabase(directory, command, opts) {
100
101
  }
101
102
  }
102
103
  /**
103
- * The run's session as OpenCode recorded it, read once the process has ended: its id and the tokens its turn used,
104
- * which a workflow's `budget.max_tokens` and the unit journal count. Nothing when the run made no session. Accounting
105
- * is best-effort: a database that cannot be read leaves the run's result as it is.
104
+ * The run's session as OpenCode recorded it, read once the process has ended: its id, the tokens its turn used, which a
105
+ * workflow's `budget.max_tokens` and the unit journal count, and the message its turn failed with, if it did. Nothing
106
+ * when the run made no session. Best-effort: a database that cannot be read leaves the run's result as it is.
106
107
  */
107
108
  function sessionRecord(sessionId) {
108
109
  try {
@@ -111,9 +112,11 @@ function sessionRecord(sessionId) {
111
112
  const tokens = v2SessionTokens(db, sessionId);
112
113
  if (!tokens)
113
114
  return {};
115
+ const failure = v2SessionFailure(db, sessionId);
114
116
  return {
115
117
  sessionId,
116
118
  usage: { inputTokens: tokens.input, outputTokens: tokens.output, reasoningTokens: tokens.reasoning },
119
+ ...(failure ? { failure } : {}),
117
120
  };
118
121
  }
119
122
  finally {
@@ -160,7 +163,13 @@ export const opencodeRuntime = {
160
163
  writeKeyFile(keyFile, connection.apiKey);
161
164
  await prepareDatabase(directory, { argv: runtimePrepareArgv(binary), env }, opts);
162
165
  const result = await runBuiltCommand(label, { argv: runtimeArgv(connection, dispatch, binary, session), env, stdin: dispatch.prompt }, opts, "captured");
163
- return { ...result, ...sessionRecord(session) };
166
+ const { failure, ...record } = sessionRecord(session);
167
+ return {
168
+ ...result,
169
+ ...record,
170
+ // OpenCode prints why the turn failed on stderr, which a workflow does not keep; the error carries it.
171
+ ...(result.reason === "non_zero_exit" && failure ? { error: `${result.error}: ${failure}` } : {}),
172
+ };
164
173
  }
165
174
  finally {
166
175
  fs.rmSync(keyFile, { force: true });