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

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 (90) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  3. package/dist/commands/env/env-binding.js +95 -0
  4. package/dist/commands/env/env-cli.js +8 -65
  5. package/dist/commands/sources/migration-help.js +7 -4
  6. package/dist/commands/workflow-cli.js +276 -12
  7. package/dist/core/asset/asset-spec.js +58 -1
  8. package/dist/core/config/config-schema.js +21 -0
  9. package/dist/core/json-schema.js +142 -0
  10. package/dist/indexer/db/db.js +2 -1
  11. package/dist/indexer/walk/matchers.js +39 -0
  12. package/dist/integrations/agent/builders.js +7 -5
  13. package/dist/integrations/agent/model-aliases.js +9 -0
  14. package/dist/integrations/agent/profiles.js +72 -5
  15. package/dist/integrations/agent/runner-dispatch.js +25 -1
  16. package/dist/integrations/agent/spawn.js +137 -14
  17. package/dist/integrations/harnesses/aider/agent-builder.js +113 -0
  18. package/dist/integrations/harnesses/aider/index.js +58 -0
  19. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  20. package/dist/integrations/harnesses/amazonq/agent-builder.js +153 -0
  21. package/dist/integrations/harnesses/amazonq/index.js +59 -0
  22. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  23. package/dist/integrations/harnesses/claude/agent-builder.js +45 -6
  24. package/dist/integrations/harnesses/claude/index.js +25 -23
  25. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  26. package/dist/integrations/harnesses/codex/agent-builder.js +137 -0
  27. package/dist/integrations/harnesses/codex/index.js +63 -0
  28. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  29. package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
  30. package/dist/integrations/harnesses/copilot/index.js +60 -0
  31. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  32. package/dist/integrations/harnesses/gemini/agent-builder.js +121 -0
  33. package/dist/integrations/harnesses/gemini/index.js +60 -0
  34. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  35. package/dist/integrations/harnesses/index.js +26 -4
  36. package/dist/integrations/harnesses/opencode/index.js +15 -16
  37. package/dist/integrations/harnesses/opencode-sdk/harness.js +65 -0
  38. package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
  39. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +581 -91
  40. package/dist/integrations/harnesses/openhands/agent-builder.js +126 -0
  41. package/dist/integrations/harnesses/openhands/index.js +58 -0
  42. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  43. package/dist/integrations/harnesses/pi/agent-builder.js +104 -0
  44. package/dist/integrations/harnesses/pi/index.js +58 -0
  45. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  46. package/dist/integrations/harnesses/types.js +7 -0
  47. package/dist/integrations/session-logs/index.js +24 -11
  48. package/dist/output/renderers.js +3 -2
  49. package/dist/output/shapes/passthrough.js +4 -0
  50. package/dist/output/text/helpers.js +212 -1
  51. package/dist/output/text/workflow.js +3 -1
  52. package/dist/schemas/akm-config.json +14225 -0
  53. package/dist/schemas/akm-workflow.json +328 -0
  54. package/dist/scripts/migrate-storage.js +1034 -6973
  55. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +768 -10
  56. package/dist/storage/repositories/workflow-runs-repository.js +189 -1
  57. package/dist/text-import-hook.mjs +1 -1
  58. package/dist/workflows/authoring/authoring.js +123 -10
  59. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  60. package/dist/workflows/cli.js +4 -0
  61. package/dist/workflows/db.js +135 -0
  62. package/dist/workflows/exec/brief.js +484 -0
  63. package/dist/workflows/exec/native-executor.js +975 -0
  64. package/dist/workflows/exec/param-secrets.js +115 -0
  65. package/dist/workflows/exec/report.js +1295 -0
  66. package/dist/workflows/exec/run-workflow.js +596 -0
  67. package/dist/workflows/exec/scheduler.js +100 -0
  68. package/dist/workflows/exec/step-work.js +1156 -0
  69. package/dist/workflows/exec/unit-writer.js +23 -0
  70. package/dist/workflows/exec/watch.js +116 -0
  71. package/dist/workflows/exec/worktree.js +171 -0
  72. package/dist/workflows/ir/compile.js +388 -0
  73. package/dist/workflows/ir/params.js +54 -0
  74. package/dist/workflows/ir/plan-hash.js +33 -0
  75. package/dist/workflows/ir/schema.js +4 -0
  76. package/dist/workflows/parser.js +3 -1
  77. package/dist/workflows/program/expressions.js +369 -0
  78. package/dist/workflows/program/parser.js +760 -0
  79. package/dist/workflows/program/project.js +105 -0
  80. package/dist/workflows/program/schema.js +54 -0
  81. package/dist/workflows/renderer.js +82 -5
  82. package/dist/workflows/runtime/agent-identity.js +59 -14
  83. package/dist/workflows/runtime/runs.js +206 -36
  84. package/dist/workflows/runtime/unit-checkin.js +45 -0
  85. package/dist/workflows/runtime/workflow-asset-loader.js +64 -1
  86. package/dist/workflows/validate-summary.js +24 -3
  87. package/dist/workflows/validator.js +1 -1
  88. package/docs/data-and-telemetry.md +2 -1
  89. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  90. package/package.json +1 -1
@@ -11,6 +11,8 @@
11
11
  import { defaultRendererRegistry } from "../../core/asset/asset-registry.js";
12
12
  import { SCRIPT_EXTENSIONS } from "../../core/asset/asset-spec.js";
13
13
  import { looksLikeWorkflow } from "../../workflows/parser.js";
14
+ import { looksLikeWorkflowProgram } from "../../workflows/program/parser.js";
15
+ import { WORKFLOW_PROGRAM_RENDERER_NAME } from "../../workflows/program/project.js";
14
16
  import { registerMatcher } from "./file-context.js";
15
17
  // ---------------------------------------------------------------------------
16
18
  // Private data
@@ -178,6 +180,35 @@ function classifyBySmartMd(ctx) {
178
180
  }
179
181
  return { type: "knowledge", specificity: 5 };
180
182
  }
183
+ /** YAML workflow *programs* (redesign addendum, R1): `.yaml`/`.yml` files. */
184
+ const WORKFLOW_PROGRAM_EXTENSIONS = new Set([".yaml", ".yml"]);
185
+ /**
186
+ * Classify YAML workflow programs. Two claims, mirroring the markdown rules:
187
+ *
188
+ * - any `.yaml`/`.yml` under a `workflows/` dir (the directory rule —
189
+ * specificity 15 when `workflows` is the immediate parent, 10 for a
190
+ * deeper ancestor, same ladder as `matchDirectoryHint`);
191
+ * - anywhere else, a content probe via `looksLikeWorkflowProgram`
192
+ * (`version: 1` + `steps:` at column 0) at specificity 19, the same
193
+ * level as `classifyBySmartMd`'s `looksLikeWorkflow` claim.
194
+ *
195
+ * The fact does NOT go through `toMatchResult` — the workflow TYPE maps to
196
+ * the markdown renderer by default, so this classifier names the
197
+ * `workflow-program-yaml` renderer on its result directly.
198
+ */
199
+ function classifyByWorkflowProgram(ctx) {
200
+ if (!WORKFLOW_PROGRAM_EXTENSIONS.has(ctx.ext))
201
+ return null;
202
+ if (isTypedDirDocFile(ctx.fileName))
203
+ return null;
204
+ if (ctx.parentDir === "workflows")
205
+ return { type: "workflow", specificity: 15 };
206
+ if (ctx.ancestorDirs.includes("workflows"))
207
+ return { type: "workflow", specificity: 10 };
208
+ if (looksLikeWorkflowProgram(ctx.content()))
209
+ return { type: "workflow", specificity: 19 };
210
+ return null;
211
+ }
181
212
  function classifyByWiki(ctx) {
182
213
  if (ctx.ext !== ".md")
183
214
  return null;
@@ -223,12 +254,20 @@ export function smartMdMatcher(ctx) {
223
254
  export function wikiMatcher(ctx) {
224
255
  return toMatchResult(ctx, classifyByWiki);
225
256
  }
257
+ export function workflowProgramMatcher(ctx) {
258
+ const fact = classifyByWorkflowProgram(ctx);
259
+ if (!fact)
260
+ return null;
261
+ // Named directly (not via rendererNameFor) — see classifyByWorkflowProgram.
262
+ return { type: fact.type, specificity: fact.specificity, renderer: WORKFLOW_PROGRAM_RENDERER_NAME };
263
+ }
226
264
  const builtinMatchers = [
227
265
  extensionMatcher,
228
266
  directoryMatcher,
229
267
  parentDirHintMatcher,
230
268
  smartMdMatcher,
231
269
  wikiMatcher,
270
+ workflowProgramMatcher,
232
271
  ];
233
272
  export function registerBuiltinMatchers() {
234
273
  for (const matcher of builtinMatchers) {
@@ -77,11 +77,13 @@ const BUILTIN_BUILDERS = (() => {
77
77
  * A *custom* profile (unknown platform) falls back to the default builder —
78
78
  * that generic `--system-prompt`/`--model`/`--` shape is the documented
79
79
  * contract for user-defined wrappers. A *known built-in agent CLI* with no
80
- * dedicated builder (codex, gemini, aider + their `-headless` variants) is a
81
- * loud `ConfigError` instead: the default flag shape is wrong for those CLIs
82
- * (aider, for one, treats positionals as file names), so the old silent
83
- * fallback produced a broken command that "ran" and failed downstream.
84
- * Custom builders injected via tests can be passed as `registry`.
80
+ * dedicated builder is a loud `ConfigError` instead: the default flag shape
81
+ * is wrong for those CLIs (aider, for one, treats positionals as file names),
82
+ * so the old silent fallback produced a broken command that "ran" and failed
83
+ * downstream. As of the P2 harness-adapter integration every built-in profile
84
+ * has a registry-derived builder, so this branch only fires if a future
85
+ * builtin profile ships without one. Custom builders injected via tests can
86
+ * be passed as `registry`.
85
87
  */
86
88
  export function getCommandBuilder(platform, registry = BUILTIN_BUILDERS) {
87
89
  const found = registry[platform];
@@ -11,6 +11,15 @@
11
11
  * always resolve to the full name for determinism)
12
12
  */
13
13
  const BUILTIN_ALIASES = [
14
+ {
15
+ // Anthropic's Mythos-class tier above Opus — the recommended resolution
16
+ // target for the `deep` workflow tier (see docs/features/workflows.md).
17
+ alias: "fable",
18
+ platforms: {
19
+ claude: "claude-fable-5",
20
+ opencode: "opencode/claude-fable-5",
21
+ },
22
+ },
14
23
  {
15
24
  alias: "opus",
16
25
  platforms: {
@@ -9,9 +9,10 @@
9
9
  // every lane's read-back (GRR). It is a provenance tag, never a secret.
10
10
  const COMMON_PASSTHROUGH = ["HOME", "PATH", "USER", "LANG", "LC_ALL", "TERM", "TMPDIR", "AKM_EVENT_SOURCE"];
11
11
  /**
12
- * Built-in profiles for the five agent CLIs the v1 spec calls out
13
- * explicitly. The fields here are conservative defaults every value is
14
- * overridable from user config.
12
+ * Built-in profiles for the agent CLIs akm knows out of the box: the five the
13
+ * v1 spec calls out explicitly, plus the P2 harness adapters (copilot, pi,
14
+ * amazonq, openhands plan §"Capability matrix"). The fields here are
15
+ * conservative defaults — every value is overridable from user config.
15
16
  *
16
17
  * For headless/automation use (propose, reflect, tasks), use the '-headless' variant.
17
18
  */
@@ -56,9 +57,42 @@ const BUILTINS = {
56
57
  envPassthrough: [...COMMON_PASSTHROUGH, "OPENAI_API_KEY", "ANTHROPIC_API_KEY"],
57
58
  parseOutput: "text",
58
59
  },
60
+ // ── P2 harness-adapter profiles (plan §"Capability matrix") ────────────────
61
+ copilot: {
62
+ name: "copilot",
63
+ bin: "copilot",
64
+ args: [],
65
+ stdio: "interactive",
66
+ envPassthrough: [...COMMON_PASSTHROUGH, "GH_TOKEN", "GITHUB_TOKEN"],
67
+ parseOutput: "text",
68
+ },
69
+ pi: {
70
+ name: "pi",
71
+ bin: "pi",
72
+ args: [],
73
+ stdio: "interactive",
74
+ envPassthrough: [...COMMON_PASSTHROUGH, "PI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
75
+ parseOutput: "text",
76
+ },
77
+ amazonq: {
78
+ name: "amazonq",
79
+ bin: "q",
80
+ args: [],
81
+ stdio: "interactive",
82
+ envPassthrough: [...COMMON_PASSTHROUGH, "AWS_PROFILE", "AWS_REGION"],
83
+ parseOutput: "text",
84
+ },
85
+ openhands: {
86
+ name: "openhands",
87
+ bin: "openhands",
88
+ args: [],
89
+ stdio: "interactive",
90
+ envPassthrough: [...COMMON_PASSTHROUGH, "LLM_MODEL", "LLM_API_KEY", "LLM_BASE_URL"],
91
+ parseOutput: "text",
92
+ },
59
93
  };
60
94
  /**
61
- * Headless variants of the five base profiles for automation use (propose, reflect, tasks).
95
+ * Headless variants of the base profiles for automation use (propose, reflect, tasks).
62
96
  *
63
97
  * These profiles use `stdio: "captured"` and `parseOutput: "json"` so the
64
98
  * agent's response can be read from stdout. They share the same `bin` and
@@ -110,9 +144,42 @@ const HEADLESS_BUILTINS = {
110
144
  envPassthrough: [...COMMON_PASSTHROUGH, "OPENAI_API_KEY", "ANTHROPIC_API_KEY"],
111
145
  parseOutput: "json",
112
146
  },
147
+ // ── P2 harness-adapter headless variants (plan §"Capability matrix") ───────
148
+ "copilot-headless": {
149
+ name: "copilot-headless",
150
+ bin: "copilot",
151
+ args: [],
152
+ stdio: "captured",
153
+ envPassthrough: [...COMMON_PASSTHROUGH, "GH_TOKEN", "GITHUB_TOKEN"],
154
+ parseOutput: "json",
155
+ },
156
+ "pi-headless": {
157
+ name: "pi-headless",
158
+ bin: "pi",
159
+ args: [],
160
+ stdio: "captured",
161
+ envPassthrough: [...COMMON_PASSTHROUGH, "PI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
162
+ parseOutput: "json",
163
+ },
164
+ "amazonq-headless": {
165
+ name: "amazonq-headless",
166
+ bin: "q",
167
+ args: [],
168
+ stdio: "captured",
169
+ envPassthrough: [...COMMON_PASSTHROUGH, "AWS_PROFILE", "AWS_REGION"],
170
+ parseOutput: "json",
171
+ },
172
+ "openhands-headless": {
173
+ name: "openhands-headless",
174
+ bin: "openhands",
175
+ args: [],
176
+ stdio: "captured",
177
+ envPassthrough: [...COMMON_PASSTHROUGH, "LLM_MODEL", "LLM_API_KEY", "LLM_BASE_URL"],
178
+ parseOutput: "json",
179
+ },
113
180
  };
114
181
  /**
115
- * Names of the five primary built-in profiles. Stable, sorted. Does NOT
182
+ * Names of the primary built-in profiles. Stable, sorted. Does NOT
116
183
  * include the `-headless` variants (those are resolvable by name but are
117
184
  * excluded from detection/enumeration flows).
118
185
  */
@@ -28,8 +28,32 @@
28
28
  * (X2) can wrap `executeRunner` without changing this contract.
29
29
  */
30
30
  import { assertNever } from "../../core/assert.js";
31
- import { runOpencodeSdk } from "../harnesses/opencode-sdk/index.js";
31
+ import { closeServer as disposeOpencodeSdkServers, runOpencodeSdk } from "../harnesses/opencode-sdk/index.js";
32
32
  import { runAgent } from "./spawn.js";
33
+ /**
34
+ * Release every long-lived resource the dispatch runners CACHE for reuse, so a
35
+ * one-shot process (the CLI) can exit cleanly once dispatching is done.
36
+ *
37
+ * The `sdk` runner keeps a per-env registry of `opencode serve` CHILD
38
+ * PROCESSES (see `opencode-sdk/sdk-runner.ts`), started lazily and reused
39
+ * across units within a process. Each live child is an OS handle that keeps
40
+ * Bun's event loop open — and the registry's own teardown is wired ONLY to
41
+ * `process.once('exit')`, which never fires while such a child holds the loop
42
+ * open. That is a deadlock: a successful `akm workflow run` that dispatched via
43
+ * the SDK path would hang the CLI (owner finding 4) because the process is
44
+ * never idle enough for the exit hook to run and close the children it is
45
+ * waiting on.
46
+ *
47
+ * The engine therefore calls this in its run `finally` (every exit path —
48
+ * success, gate rejection, failure, abort) to drain the registry deterministically
49
+ * BEFORE relying on the event loop to drain. The close is synchronous and
50
+ * idempotent; when no SDK server was ever started it is a no-op, so the `agent`
51
+ * / `llm` dispatch paths pay nothing. Servers are re-created lazily on the next
52
+ * dispatch, so calling this between independent dispatch invocations is safe.
53
+ */
54
+ export function disposeDispatchResources() {
55
+ disposeOpencodeSdkServers();
56
+ }
33
57
  /**
34
58
  * Dispatch a {@link RunnerSpec} to its runner and return the raw
35
59
  * {@link AgentRunResult}. `opts` is the {@link RunAgentOptions} for the profile
@@ -143,21 +143,93 @@ function buildChildEnv(profile, options) {
143
143
  }
144
144
  return env;
145
145
  }
146
+ const STREAM_READ_TIMEOUT = Symbol("stream-read-timeout");
146
147
  async function readStream(stream, opts) {
147
148
  if (!stream)
148
- return "";
149
- const readPromise = new Response(stream).text().catch(() => "");
150
- if (!opts?.timeoutMs)
151
- return readPromise;
149
+ return { text: "", timedOut: false };
150
+ const reader = stream.getReader();
151
+ const decoder = new TextDecoder();
152
+ let text = "";
153
+ if (!opts?.timeoutMs) {
154
+ try {
155
+ while (true) {
156
+ const chunk = await reader.read();
157
+ if (chunk.done)
158
+ break;
159
+ text += decoder.decode(chunk.value, { stream: true });
160
+ }
161
+ text += decoder.decode();
162
+ return { text, timedOut: false };
163
+ }
164
+ catch (error) {
165
+ return { text, timedOut: false, error };
166
+ }
167
+ finally {
168
+ try {
169
+ reader.releaseLock();
170
+ }
171
+ catch {
172
+ /* ignore */
173
+ }
174
+ }
175
+ }
152
176
  // Race the stream read against a timeout so a process that is killed via
153
177
  // SIGTERM/SIGKILL but whose pipe endpoints stay open (e.g. background
154
178
  // threads still holding the fd) cannot block the caller indefinitely.
155
- // On timeout we return whatever we received so far (empty string here since
156
- // `readPromise` is all-or-nothing with `Response.text()`).
179
+ // On timeout we return whatever was decoded before the pipe stopped draining.
180
+ const setTimeoutImpl = opts.setTimeoutFn ?? setTimeout;
181
+ const clearTimeoutImpl = opts.clearTimeoutFn ?? clearTimeout;
182
+ let timer;
157
183
  const timeoutPromise = new Promise((resolve) => {
158
- setTimeout(() => resolve(""), opts.timeoutMs);
184
+ timer = setTimeoutImpl(() => {
185
+ timer = undefined;
186
+ resolve(STREAM_READ_TIMEOUT);
187
+ }, opts.timeoutMs);
188
+ if (typeof timer !== "number")
189
+ timer.unref?.();
159
190
  });
160
- return Promise.race([readPromise, timeoutPromise]);
191
+ try {
192
+ while (true) {
193
+ const chunk = await Promise.race([reader.read(), timeoutPromise]);
194
+ if (chunk === STREAM_READ_TIMEOUT) {
195
+ void reader.cancel().catch(() => { });
196
+ return { text, timedOut: true };
197
+ }
198
+ if (chunk.done)
199
+ break;
200
+ text += decoder.decode(chunk.value, { stream: true });
201
+ }
202
+ text += decoder.decode();
203
+ return { text, timedOut: false };
204
+ }
205
+ catch (error) {
206
+ return { text, timedOut: false, error };
207
+ }
208
+ finally {
209
+ if (timer !== undefined) {
210
+ clearTimeoutImpl(timer);
211
+ }
212
+ try {
213
+ reader.releaseLock();
214
+ }
215
+ catch {
216
+ /* ignore */
217
+ }
218
+ }
219
+ }
220
+ function streamFailureMessage(profileName, stdout, stderr) {
221
+ const failures = [];
222
+ if (stdout.error)
223
+ failures.push(`stdout read failed: ${stdout.error instanceof Error ? stdout.error.message : String(stdout.error)}`);
224
+ if (stderr.error)
225
+ failures.push(`stderr read failed: ${stderr.error instanceof Error ? stderr.error.message : String(stderr.error)}`);
226
+ if (stdout.timedOut)
227
+ failures.push("stdout drain timed out");
228
+ if (stderr.timedOut)
229
+ failures.push("stderr drain timed out");
230
+ if (failures.length === 0)
231
+ return undefined;
232
+ return `agent CLI "${profileName}" output capture failed: ${failures.join("; ")}`;
161
233
  }
162
234
  /**
163
235
  * Spawn the agent CLI described by `profile` with `prompt` (forwarded as
@@ -184,6 +256,17 @@ export async function runAgent(profile, prompt, options = {}) {
184
256
  const parseOutput = options.parseOutput ?? profile.parseOutput;
185
257
  const setTimeoutImpl = options.setTimeoutFn ?? setTimeout;
186
258
  const clearTimeoutImpl = options.clearTimeoutFn ?? clearTimeout;
259
+ // Observability seam — ids/status only, best-effort (see RunAgentOptions.onEvent).
260
+ const emitSpawnEvent = (type, data) => {
261
+ if (!options.onEvent)
262
+ return;
263
+ try {
264
+ options.onEvent({ type, data });
265
+ }
266
+ catch {
267
+ // Observability must never break the dispatch.
268
+ }
269
+ };
187
270
  // Build argv via the platform-specific builder when dispatch params are
188
271
  // provided; fall back to the legacy positional-prompt form otherwise.
189
272
  let builtArgv;
@@ -247,6 +330,10 @@ export async function runAgent(profile, prompt, options = {}) {
247
330
  error: err instanceof Error ? err.message : String(err),
248
331
  };
249
332
  }
333
+ emitSpawnEvent("spawn_start", {
334
+ profile: profile.name,
335
+ ...(typeof proc.pid === "number" ? { pid: proc.pid } : {}),
336
+ });
250
337
  // Hard timeout. We prefer SIGTERM, then SIGKILL if SIGTERM is ignored,
251
338
  // but the subprocess only exposes a single .kill() — one signal is enough
252
339
  // for the structured-failure contract.
@@ -267,11 +354,13 @@ export async function runAgent(profile, prompt, options = {}) {
267
354
  timedOut = true;
268
355
  killGroup(proc, "SIGTERM");
269
356
  // Follow up with SIGKILL after 5 s in case the process ignores SIGTERM.
270
- setTimeoutImpl(() => {
357
+ const sigkillTimer = setTimeoutImpl(() => {
271
358
  if (!proc || proc.exitCode !== null)
272
359
  return;
273
360
  killGroup(proc, "SIGKILL");
274
361
  }, 5000);
362
+ if (typeof sigkillTimer !== "number")
363
+ sigkillTimer.unref?.();
275
364
  }, timeoutMs);
276
365
  }
277
366
  // Cooperative cancel: same SIGTERM→SIGKILL discipline as the timeout, but
@@ -308,11 +397,19 @@ export async function runAgent(profile, prompt, options = {}) {
308
397
  // When there is no kill timer, allow up to 30 s for streams to drain.
309
398
  const streamDrainTimeoutMs = timeoutMs !== null ? timeoutMs + 2_000 : 30_000;
310
399
  const stdoutPromise = stdioMode === "captured"
311
- ? readStream(proc.stdout ?? null, { timeoutMs: streamDrainTimeoutMs })
312
- : Promise.resolve("");
400
+ ? readStream(proc.stdout ?? null, {
401
+ timeoutMs: streamDrainTimeoutMs,
402
+ setTimeoutFn: setTimeoutImpl,
403
+ clearTimeoutFn: clearTimeoutImpl,
404
+ })
405
+ : Promise.resolve({ text: "", timedOut: false });
313
406
  const stderrPromise = stdioMode === "captured"
314
- ? readStream(proc.stderr ?? null, { timeoutMs: streamDrainTimeoutMs })
315
- : Promise.resolve("");
407
+ ? readStream(proc.stderr ?? null, {
408
+ timeoutMs: streamDrainTimeoutMs,
409
+ setTimeoutFn: setTimeoutImpl,
410
+ clearTimeoutFn: clearTimeoutImpl,
411
+ })
412
+ : Promise.resolve({ text: "", timedOut: false });
316
413
  // Optional stdin payload (captured mode only).
317
414
  //
318
415
  // BUG-H1: race the stdin write/close against `proc.exited` and the
@@ -352,6 +449,12 @@ export async function runAgent(profile, prompt, options = {}) {
352
449
  // will not block indefinitely.
353
450
  await Promise.allSettled([stdoutPromise, stderrPromise]);
354
451
  const durationMs = Date.now() - start;
452
+ emitSpawnEvent("spawn_exit", {
453
+ profile: profile.name,
454
+ ...(typeof proc.pid === "number" ? { pid: proc.pid } : {}),
455
+ exitCode: null,
456
+ status: "spawn_failed",
457
+ });
355
458
  return {
356
459
  ok: false,
357
460
  exitCode: null,
@@ -364,7 +467,15 @@ export async function runAgent(profile, prompt, options = {}) {
364
467
  }
365
468
  clearTimeoutImpl(timer);
366
469
  abortSignal?.removeEventListener("abort", onAbort);
367
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
470
+ emitSpawnEvent("spawn_exit", {
471
+ profile: profile.name,
472
+ ...(typeof proc.pid === "number" ? { pid: proc.pid } : {}),
473
+ exitCode,
474
+ status: aborted ? "aborted" : timedOut ? "timeout" : exitCode !== 0 ? "non_zero_exit" : "ok",
475
+ });
476
+ const [stdoutRead, stderrRead] = await Promise.all([stdoutPromise, stderrPromise]);
477
+ const stdout = stdoutRead.text;
478
+ const stderr = stderrRead.text;
368
479
  const durationMs = Date.now() - start;
369
480
  if (aborted) {
370
481
  return {
@@ -388,6 +499,18 @@ export async function runAgent(profile, prompt, options = {}) {
388
499
  error: `agent CLI "${profile.name}" timed out after ${timeoutMs ?? 0}ms`,
389
500
  };
390
501
  }
502
+ const captureFailure = streamFailureMessage(profile.name, stdoutRead, stderrRead);
503
+ if (captureFailure) {
504
+ return {
505
+ ok: false,
506
+ exitCode,
507
+ stdout,
508
+ stderr,
509
+ durationMs,
510
+ reason: "spawn_failed",
511
+ error: captureFailure,
512
+ };
513
+ }
391
514
  if (exitCode !== 0) {
392
515
  return {
393
516
  ok: false,
@@ -0,0 +1,113 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Aider CLI agent command builder (P2, plan §"The adapter contract" step 2 /
6
+ * §"Capability matrix").
7
+ *
8
+ * Translates a platform-agnostic {@link AgentDispatchRequest} into the exact
9
+ * headless argv the `aider` CLI expects. Per the capability matrix the
10
+ * headless invocation is:
11
+ *
12
+ * aider -m "<p>" --yes-always
13
+ *
14
+ * with `--model <m>` for model selection, NO structured-output mode (Aider is
15
+ * the "none" tier — prompt-injected schema + embedded-JSON extraction), and
16
+ * NO session flag (resume is chat-history files, not an id).
17
+ *
18
+ * Platform-specific mapping decisions (all localized here, per the adapter
19
+ * contract):
20
+ *
21
+ * - **prompt** — `--message` is Aider's one-shot non-interactive mode (the
22
+ * matrix's `-m`; the long form is used for self-documenting argv). Unlike
23
+ * claude/codex/pi there is NO positional prompt and NO `--` end-of-options
24
+ * separator to hide behind: the prompt is a *flag value*. The glued
25
+ * `--message=<payload>` form is therefore used so a payload that begins
26
+ * with `-`/`--` binds to the flag lexically and can never be parsed as a
27
+ * separate option by Aider's argument parser.
28
+ * - **--yes-always** — always emitted: auto-confirms every interactive
29
+ * prompt (create file? run command? …), which is what makes a captured,
30
+ * unattended run possible. This is the matrix's documented headless shape.
31
+ * - **--no-pretty** — always emitted: disables colored/pretty terminal
32
+ * rendering so captured stdout is clean text for `./result-extractor.ts`
33
+ * (the same "dispatch is the captured path" reasoning as the Claude
34
+ * builder's unconditional `--print` and Codex's unconditional `--json`).
35
+ * - **systemPrompt** — Aider has no system-prompt flag (its nearest concept
36
+ * is conventions files via `--read`, which take a path, not text). Folded
37
+ * into the message payload — system text first, blank line, then the task —
38
+ * mirroring the codex builder's treatment.
39
+ * - **schema** — the matrix places Aider in the "via prompt+validate" tier
40
+ * with *no* structured output mode at all (plan §"Structured-output
41
+ * normalization", tier "none"): there is no schema flag and no JSON output
42
+ * flag, so the JSON Schema is injected into the message payload using the
43
+ * exact directive wording of the engine's prompt assembly
44
+ * (`step-work.ts` `buildUnitPrompt`) and the pi builder, so all
45
+ * dispatch paths speak one dialect. Downstream, embedded-JSON extraction +
46
+ * the engine's shared retry-until-valid loop supply the validation Aider
47
+ * lacks. No temp schema file is written — that is Codex's native-schema
48
+ * mechanism (`--output-schema`), which Aider does not have.
49
+ * - **tools** — deliberately unconsumed. Aider has no per-tool allowlist
50
+ * flag; tool-ish behaviour is governed by its own switches (`--yes-always`,
51
+ * git integration, shell-command confirmation). A restrictive policy is
52
+ * therefore dropped rather than approximated — never silently widened.
53
+ * - **resume/session** — NOT expressible: Aider persists context in
54
+ * chat-history files (`.aider.chat.history.md`), not session ids, so there
55
+ * is no flag-shaped `HarnessResumeSupport` to export and the extractor
56
+ * never yields a `sessionId`. akm's `workflow_run_units` remains the
57
+ * durable source of truth; resume works even against a harness with no
58
+ * session model (plan §"Session, MCP, and identity across harnesses" —
59
+ * Aider is the plan's named example).
60
+ * - **effort** — stays unconsumed (reserved; the shared request contract's
61
+ * "no builder consumes it yet" note stays true).
62
+ *
63
+ * NOT registered anywhere: `builders.ts` / `harnesses/index.ts` wiring is a
64
+ * follow-up integration task (as is the registry entry declaring
65
+ * `structuredOutput: "none"` and no `resume`). Exported standalone so that
66
+ * task only adds a registry entry.
67
+ */
68
+ import { assertNotFlag } from "../../agent/builder-shared.js";
69
+ import { resolveModel } from "../../agent/model-aliases.js";
70
+ /** Canonical harness/platform id used for model-alias resolution. */
71
+ export const AIDER_PLATFORM = "aider";
72
+ /**
73
+ * Assemble the `--message` payload: optional system text, the task prompt,
74
+ * and — when a schema is requested — the same schema directive the workflow
75
+ * engine's prompt assembly uses (Aider has no native structured output, so
76
+ * the prompt is the only channel; plan §"Structured-output normalization",
77
+ * tier "none").
78
+ */
79
+ function buildMessagePayload(req) {
80
+ const sections = [];
81
+ if (req.systemPrompt)
82
+ sections.push(req.systemPrompt);
83
+ sections.push(req.prompt);
84
+ if (req.schema) {
85
+ sections.push(`Respond with ONLY a JSON value matching this JSON Schema (no prose, no code fences):\n${JSON.stringify(req.schema)}`);
86
+ }
87
+ return sections.join("\n\n");
88
+ }
89
+ /**
90
+ * Aider builder.
91
+ * Command shape:
92
+ * aider [--model <m>] --yes-always --no-pretty --message=<[system\n\n]prompt[\n\nschema directive]>
93
+ */
94
+ export const aiderBuilder = {
95
+ platform: AIDER_PLATFORM,
96
+ build(profile, req) {
97
+ assertNotFlag(req.systemPrompt, "systemPrompt");
98
+ assertNotFlag(req.model, "model");
99
+ const args = [...profile.args];
100
+ if (req.model) {
101
+ const resolved = resolveModel(req.model, AIDER_PLATFORM, profile.modelAliases, profile.globalModelAliases);
102
+ args.push("--model", resolved);
103
+ }
104
+ // Headless essentials (matrix shape): auto-confirm everything, and keep
105
+ // captured stdout free of pretty/ANSI rendering for the extractor.
106
+ args.push("--yes-always");
107
+ args.push("--no-pretty");
108
+ // Glued form: the payload is a flag VALUE (no positional prompt exists),
109
+ // so `=` binding keeps a dash-leading payload from parsing as an option.
110
+ args.push(`--message=${buildMessagePayload(req)}`);
111
+ return { argv: [profile.bin, ...args] };
112
+ },
113
+ };
@@ -0,0 +1,58 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Aider CLI harness (P2 integration, plan §"The adapter contract").
6
+ *
7
+ * Per-harness barrel gathering the Aider integration surfaces:
8
+ * - agent command builder → ./agent-builder.ts (aiderBuilder)
9
+ * - result extractor → ./result-extractor.ts (aiderResultExtractor)
10
+ *
11
+ * It also defines {@link AiderHarness}, the {@link AkmHarness} descriptor that
12
+ * `HARNESS_REGISTRY` registers. Dispatch-only: no native session-log reader or
13
+ * config importer yet.
14
+ */
15
+ import { BaseHarness } from "../types.js";
16
+ import { aiderBuilder } from "./agent-builder.js";
17
+ import { aiderResultExtractor } from "./result-extractor.js";
18
+ export { AIDER_PLATFORM, aiderBuilder } from "./agent-builder.js";
19
+ export { aiderResultExtractor } from "./result-extractor.js";
20
+ function caps(c) {
21
+ return {
22
+ sessionLogs: false,
23
+ agentDispatch: false,
24
+ detection: false,
25
+ configImport: false,
26
+ runtimeIdentity: false,
27
+ v1Migration: false,
28
+ ...c,
29
+ };
30
+ }
31
+ /**
32
+ * Aider.
33
+ *
34
+ * Canonical id is `'aider'`; no alias or distinct runtime identity.
35
+ */
36
+ export class AiderHarness extends BaseHarness {
37
+ id = "aider";
38
+ displayName = "Aider";
39
+ aliases = [];
40
+ agentBuilder = aiderBuilder;
41
+ resultExtractor = aiderResultExtractor;
42
+ // ── Workflow-engine descriptor (plan §"Capability matrix", P2) ────────────
43
+ // akm spawns the `aider` CLI locally per unit ⇒ local-runner.
44
+ pattern = "local-runner";
45
+ // No structured-output mode at all (the matrix's "none — parse output"):
46
+ // akm injects the schema into the prompt and extracts embedded JSON.
47
+ structuredOutput = "none";
48
+ // No `resume`: Aider persists context in chat-history files
49
+ // (`.aider.chat.history.md`), not session ids — the plan's named example of
50
+ // a harness with no session model. akm's `workflow_run_units` remains the
51
+ // durable resume source of truth.
52
+ // No `identityEnv`: the matrix lists Aider's identity markers as uncertain,
53
+ // and Aider stamps no session var onto child processes.
54
+ capabilities = caps({
55
+ agentDispatch: true,
56
+ detection: true,
57
+ });
58
+ }