akm-cli 0.10.26101201-alpha → 0.10.26101202-alpha

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,22 @@ 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.26101202] - 2026-10-12
10
+
11
+ ### Fixed
12
+
13
+ - **A signal to `akm agent` no longer leaves the CLI harness running (#1049).** `akm agent` on the `opencode`,
14
+ `claude` and `codex` CLI engines runs the harness in its own process group, so SIGTERM or SIGINT to akm ended akm and
15
+ left `opencode run` (and, on OpenCode 2, its `serve --stdio` child) running. The managed-subprocess primitive now runs
16
+ the timeout kill ladder on every live group child when akm gets such a signal with no handler of its own, then
17
+ re-raises it so akm still ends by the signal.
18
+ - **An OpenCode 2 SDK dispatch ends when akm rejects a permission request (#1049).** OpenCode 2 interrupts the step
19
+ (`aborted`, "Step interrupted") and goes idle without writing the closing `idle` message, so the dispatch waited out
20
+ its whole deadline. It now returns at once with `reason: "aborted"` and the declined tool call in `error`.
21
+ - **Documented: OpenCode permission defaults in a dispatch (#1049).** OpenCode 1 asks before reading or writing outside
22
+ the project directory; OpenCode 2 does not unless its config says `ask`, so akm's permission rejection only happens
23
+ on OpenCode 2 once you add that rule. akm changes no default. See [Configuration](docs/reference/configuration.md#engines).
24
+
9
25
  ## [0.10.26101201] - 2026-10-12
10
26
 
11
27
  ### Removed
@@ -116,6 +116,51 @@ export function scheduleKillLadder(proc, opts) {
116
116
  if (typeof sigkillTimer !== "number")
117
117
  sigkillTimer.unref?.();
118
118
  }
119
+ const liveGroupChildren = new Set();
120
+ let signalGuardHandlers;
121
+ function removeSignalGuard() {
122
+ for (const [signal, handler] of signalGuardHandlers ?? [])
123
+ process.off(signal, handler);
124
+ signalGuardHandlers = undefined;
125
+ }
126
+ /**
127
+ * A captured child runs in its own process group, so the terminal's Ctrl-C and
128
+ * a `kill <akm-pid>` no longer reach it: without this, SIGTERM/SIGINT to akm
129
+ * ended akm and left the harness CLI (and everything it started) running. Each
130
+ * live child is registered here and, on the first such signal, takes the same
131
+ * ladder as a timeout; once every child is gone the signal is re-raised so akm
132
+ * still ends by the signal itself.
133
+ *
134
+ * The listeners are installed only while a child is live and only when nothing
135
+ * else handles the signal. A command that handles it itself (`workflow run`,
136
+ * `improve`, task runs) unwinds through its own cleanup, which already aborts
137
+ * its children, and must not have its handling pre-empted by a re-raise.
138
+ */
139
+ function registerLiveGroupChild(child) {
140
+ liveGroupChildren.add(child);
141
+ if (!signalGuardHandlers && process.listenerCount("SIGINT") === 0 && process.listenerCount("SIGTERM") === 0) {
142
+ const handlers = [];
143
+ for (const signal of ["SIGINT", "SIGTERM"]) {
144
+ const handler = () => {
145
+ const children = [...liveGroupChildren];
146
+ for (const live of children)
147
+ live.terminate();
148
+ removeSignalGuard();
149
+ void Promise.allSettled(children.map((live) => live.proc.exited)).then(() => {
150
+ process.kill(process.pid, signal);
151
+ });
152
+ };
153
+ process.once(signal, handler);
154
+ handlers.push([signal, handler]);
155
+ }
156
+ signalGuardHandlers = handlers;
157
+ }
158
+ return () => {
159
+ liveGroupChildren.delete(child);
160
+ if (liveGroupChildren.size === 0)
161
+ removeSignalGuard();
162
+ };
163
+ }
119
164
  /**
120
165
  * The joined, human-readable reason ONE managed run's capture is incomplete, or
121
166
  * `undefined` when both pipes drained cleanly.
@@ -401,6 +446,8 @@ export async function runManagedSubprocess(cmd, opts) {
401
446
  else
402
447
  abortSignal.addEventListener("abort", onAbort, { once: true });
403
448
  }
449
+ // A termination signal to this process takes the own-group child down too.
450
+ const unregisterLiveChild = capture && spawnsOwnProcessGroup() ? registerLiveGroupChild({ proc, terminate: onAbort }) : () => { };
404
451
  // Stream-drain timeout: a short grace once nothing living owns the pipe any
405
452
  // more, or a one-hour safety bound when execution itself is unbounded.
406
453
  //
@@ -454,6 +501,7 @@ export async function runManagedSubprocess(cmd, opts) {
454
501
  if (timer !== undefined)
455
502
  clearTimeoutImpl(timer);
456
503
  abortSignal?.removeEventListener("abort", onAbort);
504
+ unregisterLiveChild();
457
505
  // Drain readers so they don't surface as unhandled rejections. The streams
458
506
  // carry their own drain timeout so this cannot block indefinitely.
459
507
  await Promise.allSettled([stdoutPromise, stderrPromise]);
@@ -470,6 +518,7 @@ export async function runManagedSubprocess(cmd, opts) {
470
518
  }
471
519
  clearTimeoutImpl(timer);
472
520
  abortSignal?.removeEventListener("abort", onAbort);
521
+ unregisterLiveChild();
473
522
  const [stdoutRead, stderrRead] = await Promise.all([stdoutPromise, stderrPromise]);
474
523
  return {
475
524
  exitCode,
@@ -57,6 +57,27 @@ export function v2Turn(messages, userId) {
57
57
  }
58
58
  return undefined;
59
59
  }
60
+ /**
61
+ * The turn that began with the user message `userId`, when OpenCode 2 ended it
62
+ * by interrupting a step instead of writing the closing `idle` message. Only
63
+ * meaningful once `session.wait` has returned (the session is not running).
64
+ *
65
+ * Recorded from 2.0.26: when akm rejects a permission request the step fails
66
+ * with `{ type: "aborted", message: "Step interrupted" }` (`finish: "error"`),
67
+ * the tool call is `error` ("The user declined this tool call"),
68
+ * `session.execution.interrupted` fires and the session goes idle, but no
69
+ * `idle` message is written and `session.idle` is not emitted.
70
+ */
71
+ export function v2InterruptedTurn(messages, userId) {
72
+ const start = messages.findIndex((m) => m.id === userId);
73
+ if (start < 0)
74
+ return undefined;
75
+ const assistants = messages.slice(start + 1).filter((m) => m.type === "assistant");
76
+ const last = assistants.at(-1);
77
+ return last?.finish === "error" && last.error?.type === "aborted"
78
+ ? { outcome: "interrupted", assistants }
79
+ : undefined;
80
+ }
60
81
  /** Decode a finished turn into the answer, the usage of every step and the failure, if any. */
61
82
  function v2PromptResult(turn) {
62
83
  const text = turn.assistants
@@ -82,7 +103,11 @@ function v2PromptResult(turn) {
82
103
  }
83
104
  function v2Failure(turn) {
84
105
  if (turn.outcome === "interrupted") {
85
- return { reason: "aborted", message: "OpenCode session was interrupted" };
106
+ // Say why when the last step says so, e.g. "Step interrupted: The user declined this tool call".
107
+ const last = turn.assistants.at(-1);
108
+ const declined = last?.content.flatMap((c) => c.type === "tool" && c.state.status === "error" ? [c.state.error.message] : []);
109
+ const detail = [last?.error?.message, ...(declined ?? [])].filter(Boolean).join(": ");
110
+ return { reason: "aborted", message: `OpenCode session was interrupted${detail ? ` (${detail})` : ""}` };
86
111
  }
87
112
  const last = turn.assistants.at(-1);
88
113
  if (turn.outcome === "failed") {
@@ -154,7 +179,8 @@ export const V2_ADAPTER = {
154
179
  const user = await c.session.prompt({ sessionID: sessionId, text }, { signal });
155
180
  for (;;) {
156
181
  await c.session.wait({ sessionID: sessionId }, { signal });
157
- const turn = v2Turn(await c.session.context({ sessionID: sessionId }, { signal }), user.id);
182
+ const messages = await c.session.context({ sessionID: sessionId }, { signal });
183
+ const turn = v2Turn(messages, user.id) ?? v2InterruptedTurn(messages, user.id);
158
184
  if (turn)
159
185
  return v2PromptResult(turn);
160
186
  // `wait` can return before the queued prompt starts; look again.
@@ -168,6 +168,23 @@ working and logs a warning recommending OpenCode 2. A `--version` akm cannot rea
168
168
  with the raw output. To run a particular OpenCode, set the engine's `bin`, e.g.
169
169
  `{"kind":"agent","platform":"opencode-sdk","bin":"/opt/opencode1/bin/opencode"}`.
170
170
 
171
+ **Permissions in a dispatch.** akm answers every OpenCode permission request of a
172
+ dispatch with "reject" (there is no one to approve it), and the rejection reaches
173
+ the model as a tool error. akm changes no OpenCode permission default, so what
174
+ gets asked is OpenCode's own setting:
175
+
176
+ - **OpenCode 1** asks before reading or writing outside the project directory
177
+ (`external_directory`), so akm rejects it.
178
+ - **OpenCode 2** (2.0.26) does not ask: a read or write outside the project
179
+ directory just runs, and akm's rejection never happens, unless your OpenCode
180
+ config says to ask. Add a rule to the global `opencode.json`, e.g.
181
+ `"permissions": [{"action": "external_directory", "resource": "*", "effect": "ask"}]`
182
+ (`"read"` and `"shell"` are actions too).
183
+
184
+ On OpenCode 2 a rejection ends the turn: the dispatch returns `ok: false` with
185
+ `reason: "aborted"` and the declined tool call in `error`. On OpenCode 1 the model
186
+ carries on.
187
+
171
188
  On an engine without `timeoutMs`, model work (an improve process, a quality or
172
189
  triage judge, or an index pass) stops after 600 seconds, whatever the engine's
173
190
  kind; other work on an agent engine runs until it finishes. An improve stage's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.10.26101201-alpha",
3
+ "version": "0.10.26101202-alpha",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [