forgedock 1.6.0 → 1.7.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.
package/bin/registry.mjs CHANGED
@@ -513,12 +513,13 @@ export function getInstalledCommandsDriftStatus(forgeHome) {
513
513
  let sourceVersion = "";
514
514
  try {
515
515
  const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
516
- sourceVersion = pkg.version || "";
516
+ sourceVersion = typeof pkg.version === "string" ? pkg.version.trim() : "";
517
517
  } catch {
518
518
  // Missing/unreadable/corrupt package.json — cannot determine the source
519
519
  // version at all; degrade to unknown rather than guessing.
520
520
  return notFound;
521
521
  }
522
+ if (!sourceVersion) return notFound;
522
523
 
523
524
  // Prefer the version registry.mjs last recorded for persistHome()'s write
524
525
  // (the single source of truth update()/install also write through),
package/bin/runner.mjs CHANGED
@@ -47,9 +47,10 @@ import {
47
47
  rmSync,
48
48
  realpathSync,
49
49
  } from "fs";
50
- import { join, dirname, basename, relative, isAbsolute, extname } from "path";
50
+ import { join, dirname, basename, relative, isAbsolute, extname, win32 as win32Path } from "path";
51
51
  import os from "os";
52
52
  import { execSync, spawnSync } from "child_process";
53
+ import { createRequire } from "module";
53
54
  import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
54
55
  import { DEFAULT_SPAWN_MAX_BUFFER_BYTES } from "./cli-spawn-shared.mjs";
55
56
 
@@ -70,6 +71,7 @@ const DEFAULT_CLI_TIMEOUT_MS = 15 * 60 * 1000;
70
71
  // it is far shorter than DEFAULT_CLI_TIMEOUT_MS. Mirrors the timeout already
71
72
  // used by the `claude --version` doctor check in bin/forgedock.mjs.
72
73
  const CLI_PROBE_TIMEOUT_MS = 5000;
74
+ export const CLI_PROBE_OUTPUT_SENTINEL = "__FORGEDOCK_CLI_PATHS_END__";
73
75
  /**
74
76
  * Wraps a Set in a Proxy that blocks add()/delete()/clear(), so the
75
77
  * returned collection is genuinely read-only — not just Object.frozen.
@@ -125,6 +127,35 @@ function readOnlySet(values) {
125
127
  * simultaneously. Treat it as immutable; mutation attempts throw.
126
128
  */
127
129
  export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
130
+
131
+ export const FORGE_OPENCODE_CAPABILITY_ERROR = "FORGE_OPENCODE_CAPABILITY_ERROR";
132
+
133
+ /**
134
+ * Whether the caller is an OpenCode session. The adapter sets this marker on
135
+ * every shell environment; keeping the predicate small makes all Claude
136
+ * backend entrypoints agree on the same runtime boundary.
137
+ */
138
+ export function isOpenCodeRuntime(env = process.env) {
139
+ return String(env?.FORGE_RUNTIME || "").toLowerCase() === "opencode";
140
+ }
141
+
142
+ /**
143
+ * Refuse Claude-backed execution from an OpenCode workflow. OpenCode has its
144
+ * own Skill/Task execution path; falling through to this runner would create a
145
+ * competing controller and may consume Claude credentials or quota.
146
+ */
147
+ export function assertOpenCodeNativeRuntime({
148
+ env = process.env,
149
+ operation = "Claude-backed ForgeDock execution",
150
+ } = {}) {
151
+ if (!isOpenCodeRuntime(env)) return;
152
+ const error = new Error(
153
+ `${FORGE_OPENCODE_CAPABILITY_ERROR}: ${operation} is unavailable when FORGE_RUNTIME=opencode. Use native ForgeDock Skill/Task dispatch instead.`,
154
+ );
155
+ error.code = FORGE_OPENCODE_CAPABILITY_ERROR;
156
+ throw error;
157
+ }
158
+
128
159
  // Per-process memoization for isClaudeCliAvailable(), keyed by `cwd` (issue
129
160
  // #2011). `runCommand()` calls resolveBackend() — and therefore, for the
130
161
  // default "auto" backend, isClaudeCliAvailable() — unconditionally on every
@@ -153,12 +184,9 @@ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
153
184
  const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
154
185
  const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
155
186
  const cliAvailabilityCache = new Map();
156
- // Windows file extensions `spawnSync(shell:false)` can actually launch:
157
- // native executables (`.exe`, `.com`) directly, and `.cmd`/`.bat` via Node's
158
- // own built-in special-cased re-exec through cmd.exe (see the comment on
159
- // `runCliBackend()` below). An extensionless file — e.g. the POSIX shell
160
- // shim `npm`/`npx` also drop alongside these on Windows — cannot be
161
- // launched by `CreateProcess` at all, with or without a full absolute path.
187
+ // Windows path types emitted by npm's shim generator. Native executables are
188
+ // directly spawnable; .cmd/.bat files must be resolved to their trusted native
189
+ // target before spawning because Node cannot exec them with shell:false.
162
190
  const WINDOWS_SPAWNABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".com"]);
163
191
  // Cap tool-result payloads so a large file read or verbose command does not
164
192
  // blow the context window in a single turn.
@@ -216,13 +244,10 @@ export function resolveConfiguredDefaultModel(cwd) {
216
244
  // ---------------------------------------------------------------------------
217
245
 
218
246
  /**
219
- * Split the combined probe command's stdout (issue #2741 — see
220
- * `isClaudeCliAvailable()`) into the leading "path-like" lines (from the
221
- * `where`/`command -v` half of the command) versus everything after them
222
- * (the `claude --version` output). A path-like line is one containing a
223
- * path separator (`/` or `\`); `claude --version` output (e.g.
224
- * `"1.2.3 (Claude Code)"`) never does, so the first non-path-like line
225
- * marks the boundary.
247
+ * Read path candidates from the explicitly delimited resolver section of the
248
+ * combined CLI probe. Version output is untrusted for parsing purposes: update
249
+ * notices can contain path-like URLs or filenames, so shape-based inference
250
+ * must never allow lines after the sentinel to become executable candidates.
226
251
  *
227
252
  * Pure/side-effect-free so it can be unit tested directly against
228
253
  * synthetic multi-line `where` output without spawning anything.
@@ -230,20 +255,16 @@ export function resolveConfiguredDefaultModel(cwd) {
230
255
  * @param {string} raw
231
256
  * @returns {string[]} Leading path-like lines, in original order.
232
257
  */
233
- function parseProbeOutput(raw) {
258
+ export function parseProbeOutput(raw) {
234
259
  const lines = String(raw ?? "")
235
260
  .split(/\r?\n/)
236
261
  .map((line) => line.trim())
237
262
  .filter(Boolean);
238
- const pathLines = [];
239
- for (const line of lines) {
240
- if (line.includes("/") || line.includes("\\")) {
241
- pathLines.push(line);
242
- } else {
243
- break;
244
- }
245
- }
246
- return pathLines;
263
+ const boundary = lines.indexOf(CLI_PROBE_OUTPUT_SENTINEL);
264
+ if (boundary < 0) return [];
265
+ return lines
266
+ .slice(0, boundary)
267
+ .filter((line) => line.includes("/") || line.includes("\\"));
247
268
  }
248
269
 
249
270
  /**
@@ -266,9 +287,12 @@ function parseProbeOutput(raw) {
266
287
  * @param {string[]} pathLines
267
288
  * @returns {string|null}
268
289
  */
269
- function selectResolvedCliPath(pathLines) {
290
+ export function selectResolvedCliPath(
291
+ pathLines,
292
+ { platform = process.platform, existsImpl = existsSync } = {},
293
+ ) {
270
294
  if (pathLines.length === 0) return null;
271
- if (process.platform !== "win32") return pathLines[0];
295
+ if (platform !== "win32") return pathLines[0];
272
296
 
273
297
  const withExt = pathLines.find((line) =>
274
298
  WINDOWS_SPAWNABLE_EXTENSIONS.has(extname(line).toLowerCase()),
@@ -280,11 +304,49 @@ function selectResolvedCliPath(pathLines) {
280
304
  const base = basename(bare);
281
305
  for (const ext of [".cmd", ".exe", ".bat", ".com"]) {
282
306
  const candidate = join(dir, `${base}${ext}`);
283
- if (existsSync(candidate)) return candidate;
307
+ if (existsImpl(candidate)) return candidate;
284
308
  }
285
309
  return bare;
286
310
  }
287
311
 
312
+ /**
313
+ * Resolve an npm-generated Windows command shim to the native executable it
314
+ * delegates to. This keeps shell:false and argv-array isolation intact instead
315
+ * of interpolating untrusted command arguments into cmd.exe.
316
+ */
317
+ export function resolveDirectCliExecutable(
318
+ cliPath,
319
+ {
320
+ platform = process.platform,
321
+ existsImpl = existsSync,
322
+ readImpl = readFileSync,
323
+ } = {},
324
+ ) {
325
+ if (!cliPath || platform !== "win32") return cliPath || null;
326
+ const extension = extname(cliPath).toLowerCase();
327
+ if (extension === ".exe" || extension === ".com") return cliPath;
328
+ if (extension !== ".cmd" && extension !== ".bat") return null;
329
+
330
+ try {
331
+ const shim = readImpl(cliPath, "utf-8");
332
+ // npm emits several equivalent native-target wrappers. Only accept a
333
+ // bounded %dp0%-relative .exe/.com target; never evaluate batch syntax.
334
+ const directMatch = shim.match(
335
+ /(?:^|\r?\n)\s*"%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"\s+%\*\s*(?:\r?\n|$)/im,
336
+ );
337
+ const variableMatch = shim.match(
338
+ /(?:^|\r?\n)\s*set\s+"[^"=]+=%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"[\s\S]*?\r?\n\s*"%[^"\r\n]+%"\s+%\*\s*(?:\r?\n|$)/im,
339
+ );
340
+ const match = directMatch || variableMatch;
341
+ if (!match) return null;
342
+ const pathApi = platform === "win32" ? win32Path : { join, dirname };
343
+ const target = pathApi.join(pathApi.dirname(cliPath), ...match[1].split(/[\\/]+/));
344
+ return existsImpl(target) ? target : null;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
288
350
  /**
289
351
  * Probe whether the Claude Code CLI (`claude`) is present and responds on
290
352
  * this machine, so `forgedock run` can drive it directly instead of
@@ -345,7 +407,10 @@ function selectResolvedCliPath(pathLines) {
345
407
  * `runCliBackend()`.
346
408
  * @returns {boolean}
347
409
  */
348
- export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync } = {}) {
410
+ export function isClaudeCliAvailable(
411
+ cwd = process.cwd(),
412
+ { execImpl = execSync, platform = process.platform, existsImpl = existsSync } = {},
413
+ ) {
349
414
  const cached = cliAvailabilityCache.get(cwd);
350
415
  if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
351
416
  return cached.available;
@@ -354,14 +419,17 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
354
419
  let available;
355
420
  let cliPath = null;
356
421
  try {
357
- const raw = execImpl(`${resolveCmd} && claude --version`, {
422
+ const raw = execImpl(
423
+ `${resolveCmd} && echo ${CLI_PROBE_OUTPUT_SENTINEL} && claude --version`,
424
+ {
358
425
  cwd,
359
426
  stdio: ["ignore", "pipe", "ignore"],
360
427
  encoding: "utf-8",
361
428
  timeout: CLI_PROBE_TIMEOUT_MS,
362
- });
429
+ },
430
+ );
363
431
  available = true;
364
- cliPath = selectResolvedCliPath(parseProbeOutput(raw));
432
+ cliPath = selectResolvedCliPath(parseProbeOutput(raw), { platform, existsImpl });
365
433
  } catch {
366
434
  available = false;
367
435
  }
@@ -397,7 +465,107 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
397
465
  */
398
466
  export function resolveClaudeCliBinary(cwd = process.cwd(), opts = {}) {
399
467
  isClaudeCliAvailable(cwd, opts);
400
- return cliAvailabilityCache.get(cwd)?.cliPath ?? null;
468
+ let cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
469
+ if (!cliPath) return null;
470
+
471
+ const existsImpl = opts.existsImpl ?? existsSync;
472
+ try {
473
+ if (existsImpl(cliPath)) {
474
+ const executable = resolveDirectCliExecutable(cliPath, { ...opts, existsImpl });
475
+ if (executable) return executable;
476
+ }
477
+ } catch {
478
+ // Treat an unverifiable cached path as stale and make one bounded refresh.
479
+ }
480
+
481
+ cliAvailabilityCache.delete(cwd);
482
+ isClaudeCliAvailable(cwd, opts);
483
+ cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
484
+ if (!cliPath) return null;
485
+ try {
486
+ return existsImpl(cliPath)
487
+ ? resolveDirectCliExecutable(cliPath, { ...opts, existsImpl })
488
+ : null;
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Invoke the CLI with one recovery attempt for a path that disappeared after
496
+ * resolution. The availability cache is intentionally retained, so only an
497
+ * observed ENOENT triggers a fresh resolution.
498
+ */
499
+ export function runCliWithStalePathRecovery({
500
+ backend,
501
+ cwd,
502
+ bin,
503
+ cliOptions,
504
+ runCliFn = runCliBackend,
505
+ resolveCliFn = resolveClaudeCliBinary,
506
+ }) {
507
+ try {
508
+ return runCliFn({ ...cliOptions, bin });
509
+ } catch (error) {
510
+ if (backend !== "auto" || error?.code !== "ENOENT") throw error;
511
+ const freshBin = resolveCliFn(cwd);
512
+ if (!freshBin) throw error;
513
+ return runCliFn({ ...cliOptions, bin: freshBin });
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Check whether the configured execution backend is locally usable without
519
+ * making a paid model request. CLI checks reuse the production resolver;
520
+ * API checks verify that a key is configured, not that the key is accepted.
521
+ */
522
+ export function checkExecutionBackend({
523
+ env = process.env,
524
+ requested = env.FORGEDOCK_BACKEND || "auto",
525
+ cwd = process.cwd(),
526
+ apiKey = env.ANTHROPIC_API_KEY,
527
+ resolveCliFn = resolveClaudeCliBinary,
528
+ spawnImpl = spawnSync,
529
+ sdkAvailableFn = () => {
530
+ try {
531
+ createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
532
+ return true;
533
+ } catch {
534
+ return false;
535
+ }
536
+ },
537
+ } = {}) {
538
+ if (!VALID_BACKENDS.has(requested)) {
539
+ return { ready: false, backend: requested, reason: "invalid-backend" };
540
+ }
541
+
542
+ if (isOpenCodeRuntime(env)) {
543
+ return { ready: false, backend: "opencode", reason: "native-opencode-required" };
544
+ }
545
+
546
+ if (requested !== "api") {
547
+ const cliPath = resolveCliFn(cwd);
548
+ if (cliPath) {
549
+ const result = spawnImpl(cliPath, ["--version"], {
550
+ cwd,
551
+ shell: false,
552
+ encoding: "utf-8",
553
+ timeout: CLI_PROBE_TIMEOUT_MS,
554
+ });
555
+ if (!result?.error && result?.status === 0) {
556
+ return { ready: true, backend: "cli", reason: "resolved-cli" };
557
+ }
558
+ }
559
+ if (requested === "cli") {
560
+ return { ready: false, backend: "cli", reason: "cli-unavailable" };
561
+ }
562
+ }
563
+
564
+ if (apiKey && sdkAvailableFn()) {
565
+ return { ready: true, backend: "api", reason: "api-key-and-sdk-configured" };
566
+ }
567
+ if (apiKey) return { ready: false, backend: "api", reason: "api-sdk-missing" };
568
+ return { ready: false, backend: "api", reason: "api-key-missing" };
401
569
  }
402
570
 
403
571
  /**
@@ -472,14 +640,16 @@ export function resolveBackendLadder({
472
640
  * @param {object} [opts]
473
641
  * @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
474
642
  * @param {string} [opts.cwd] - Working directory for the CLI probe.
643
+ * @param {object} [opts.env] - Runtime environment used for capability checks.
475
644
  * @returns {"cli"|"api"}
476
645
  */
477
- export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {}) {
646
+ export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env } = {}) {
478
647
  if (!VALID_BACKENDS.has(requested)) {
479
648
  throw new Error(
480
649
  `Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
481
650
  );
482
651
  }
652
+ assertOpenCodeNativeRuntime({ env, operation: "Claude-backed backend resolution" });
483
653
  return resolveBackendLadder({
484
654
  override: requested === "auto" ? undefined : requested,
485
655
  validOverrides: new Set(["cli", "api"]),
@@ -512,15 +682,11 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
512
682
  * arbitrary shell commands with this process's privileges). Do not
513
683
  * reintroduce `shell: true` or string-command interpolation here.
514
684
  *
515
- * Windows `.cmd`/`.bat` shim resolution works WITHOUT `shell: true` — BUT
516
- * only when `bin` is already a resolvable target: Node's spawn/spawnSync
517
- * safely re-invoke through cmd.exe internally when the *given* target is a
518
- * `.cmd`/`.bat` file, using a properly escaped mechanism, and argv elements
519
- * are never parsed as shell syntax (verified empirically on Windows: a
520
- * malicious-looking argv element is delivered to the child process
521
- * byte-for-byte, not executed). This is the correct fix for the
522
- * `execFileSync("claude", [...])` ENOENT-on-Windows regression from issue
523
- * #382 for a `claude.cmd` sitting directly on a real PATH entry.
685
+ * Windows npm `.cmd`/`.bat` shims cannot be launched by Node with
686
+ * `shell:false`. `resolveDirectCliExecutable()` therefore parses only the
687
+ * strict npm-shim form that delegates directly to a native `.exe`/`.com` and
688
+ * returns that trusted target. Unknown batch scripts fail closed instead of
689
+ * requiring cmd.exe interpolation of the untrusted user message.
524
690
  *
525
691
  * CORRECTED (issue #2741): the claim that a bare `"claude"` name resolves
526
692
  * via "spawnSync's own PATH/PATHEXT resolution" was overconfident and
@@ -534,8 +700,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
534
700
  * above); it is resolving the *exact* absolute path the probe validated
535
701
  * once, via `resolveClaudeCliBinary()`, and passing that resolved path as
536
702
  * `bin` — `spawnSync(shell:false)` launches an already-fully-resolved
537
- * absolute path (native `.exe`, or `.cmd`/`.bat` via the special-cased
538
- * re-exec described above) without needing any further PATH/PATHEXT
703
+ * native executable path (including the target extracted from a recognized
704
+ * npm shim) without needing any further PATH/PATHEXT
539
705
  * resolution of its own. See `isClaudeCliAvailable()`/
540
706
  * `resolveClaudeCliBinary()` above for how that resolution happens.
541
707
  *
@@ -971,10 +1137,9 @@ export function runCliBackend({
971
1137
  // No `shell` option (defaults to false): argv is passed as discrete,
972
1138
  // unparsed elements — see the SECURITY note above. `bin` is expected to
973
1139
  // already be a fully-resolved absolute path (see `resolveClaudeCliBinary()`
974
- // and the `opts.bin` doc comment above, issue #2741) — spawnSync launches
975
- // an already-resolved `.exe`/`.cmd`/`.bat` path directly (with Node's
976
- // built-in special-cased `.cmd`/`.bat` re-exec) without needing any
977
- // further shell/PATHEXT resolution of its own.
1140
+ // and the `opts.bin` doc comment above, issue #2741) — Windows npm shims
1141
+ // have already been reduced to their native target, so spawnSync never
1142
+ // needs shell/PATHEXT resolution.
978
1143
  const result = spawnFn(bin, cliArgs, {
979
1144
  cwd,
980
1145
  encoding: "utf-8",
@@ -1015,7 +1180,9 @@ export function runCliBackend({
1015
1180
  );
1016
1181
  }
1017
1182
  if (result.error) {
1018
- throw new Error(`Failed to invoke claude CLI: ${result.error.message}`);
1183
+ const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
1184
+ error.code = result.error.code;
1185
+ throw error;
1019
1186
  }
1020
1187
 
1021
1188
  const output = (stdout + stderr).trim();
@@ -1955,9 +2122,15 @@ export async function runCommand(opts = {}) {
1955
2122
  maxIterations = DEFAULT_MAX_ITERATIONS,
1956
2123
  dryRun = false,
1957
2124
  backend = process.env.FORGEDOCK_BACKEND || "auto",
2125
+ runtimeEnv = process.env,
1958
2126
  logger = console,
1959
2127
  } = opts;
1960
2128
 
2129
+ assertOpenCodeNativeRuntime({
2130
+ env: runtimeEnv,
2131
+ operation: "Claude-backed ForgeDock runner",
2132
+ });
2133
+
1961
2134
  const spec = loadCommandSpec(commandsDir, commandName);
1962
2135
  const systemPrompt = buildSystemPrompt(spec, { repoRoot: cwd });
1963
2136
  // CLI-backend-specific prompt (issue #2019) — see buildCliSystemPrompt's
@@ -1973,7 +2146,12 @@ export async function runCommand(opts = {}) {
1973
2146
  // misbehaving. An explicitly invalid `backend` value DOES throw here —
1974
2147
  // surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
1975
2148
  // work is attempted, rather than silently ignoring it.
1976
- const resolvedBackend = resolveBackend({ requested: backend, cwd });
2149
+ let resolvedBackend = resolveBackend({ requested: backend, cwd, env: runtimeEnv });
2150
+
2151
+ if (resolvedBackend === "cli" && backend === "auto") {
2152
+ const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey, env: runtimeEnv });
2153
+ if (readiness.backend === "api") resolvedBackend = "api";
2154
+ }
1977
2155
 
1978
2156
  if (dryRun) {
1979
2157
  logger.log(
@@ -2050,16 +2228,28 @@ export async function runCommand(opts = {}) {
2050
2228
  // the bare "claude" name only if resolution genuinely found nothing
2051
2229
  // (e.g. an injected-execImpl test scenario with no path-like output),
2052
2230
  // preserving prior behavior in that edge case.
2053
- const resolvedBin = resolveClaudeCliBinary(cwd) ?? "claude";
2054
- return runCliBackend({
2055
- spec,
2056
- userMessage,
2057
- systemPrompt: cliSystemPrompt,
2058
- args,
2059
- cwd,
2060
- logger,
2061
- bin: resolvedBin,
2062
- });
2231
+ const resolvedBin = resolveClaudeCliBinary(cwd);
2232
+ if (!resolvedBin && backend === "auto") {
2233
+ resolvedBackend = "api";
2234
+ } else if (!resolvedBin) {
2235
+ const err = new Error("The configured claude CLI path disappeared before invocation.");
2236
+ err.code = "CLI_BACKEND_UNAVAILABLE";
2237
+ throw err;
2238
+ } else {
2239
+ return runCliWithStalePathRecovery({
2240
+ backend,
2241
+ cwd,
2242
+ bin: resolvedBin,
2243
+ cliOptions: {
2244
+ spec,
2245
+ userMessage,
2246
+ systemPrompt: cliSystemPrompt,
2247
+ args,
2248
+ cwd,
2249
+ logger,
2250
+ },
2251
+ });
2252
+ }
2063
2253
  }
2064
2254
 
2065
2255
  if (!apiKey) {
@@ -1136,8 +1136,30 @@ Background dispatch (via `run_in_background=true` on each `Agent()` call) is the
1136
1136
  Check both at the start of Phase 4, before the first dispatch:
1137
1137
 
1138
1138
  ```bash
1139
+ # Runtime selection is explicit for non-Claude adapters. OpenCode runs nested
1140
+ # work-on phases through its native task path, so it must not be treated as a
1141
+ # failed Claude probe.
1142
+ FORGE_RUNTIME="${FORGE_RUNTIME:-}"
1143
+ if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_PID:-}" ] || [ -n "${OPENCODE:-}" ]; then
1144
+ FORGE_RUNTIME="opencode"
1145
+ fi
1146
+
1147
+ if [ "$FORGE_RUNTIME" = "opencode" ]; then
1148
+ echo "OpenCode runtime detected: native task dispatch selected."
1149
+ # ForgeDock's OpenCode plugin opts into background task events by default so
1150
+ # each completed DAG node can wake this parent without a wave barrier.
1151
+ if [ "${OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS:-true}" = "false" ]; then
1152
+ echo "OpenCode background subagents explicitly disabled: orchestration is degraded to foreground task dispatch."
1153
+ BACKGROUND_DISPATCH_ENABLED=false
1154
+ else
1155
+ BACKGROUND_DISPATCH_ENABLED=true
1156
+ fi
1157
+ fi
1158
+
1139
1159
  # Feature gate check — run once before Phase 4 dispatch begins
1140
- BACKGROUND_DISPATCH_ENABLED=true
1160
+ if [ "${BACKGROUND_DISPATCH_ENABLED:-}" != "false" ]; then
1161
+ BACKGROUND_DISPATCH_ENABLED=true
1162
+ fi
1141
1163
 
1142
1164
  if [ -n "${CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:-}" ]; then
1143
1165
  echo "Background dispatch disabled: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set."
@@ -1147,7 +1169,7 @@ fi
1147
1169
  # Version check: if the Claude Code version can be read, compare it.
1148
1170
  # If the version cannot be determined, default to ENABLED (optimistic).
1149
1171
  CC_VERSION=$(claude --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "")
1150
- if [ -n "$CC_VERSION" ]; then
1172
+ if [ "$FORGE_RUNTIME" != "opencode" ] && [ -n "$CC_VERSION" ]; then
1151
1173
  # Compare major.minor.patch numerically
1152
1174
  IFS='.' read -r CC_MAJOR CC_MINOR CC_PATCH <<< "$CC_VERSION"
1153
1175
  if [ "$CC_MAJOR" -lt 2 ] || \
@@ -1159,9 +1181,25 @@ if [ -n "$CC_VERSION" ]; then
1159
1181
  fi
1160
1182
  ```
1161
1183
 
1162
- **When `BACKGROUND_DISPATCH_ENABLED=false`**: fall back to the current streaming dispatch behavior — `run_in_background=true` is still set on each `Agent()` call (existing behavior, already correct), but treat completions as synchronous and do not rely on `agent_completed` notifications. Poll issue labels for terminal state instead.
1163
-
1164
- **When `BACKGROUND_DISPATCH_ENABLED=true`**: use `run_in_background=true` (already in the Step 4A Agent() template) and react to `agent_completed` notifications as documented in Step 4B. Do NOT poll.
1184
+ When `FORGE_RUNTIME=opencode` (or an OpenCode runtime marker is present), do not
1185
+ interpret a missing `claude` executable as an environment failure. The Phase 4
1186
+ dispatcher must use OpenCode's native `task` contract instead of the Claude
1187
+ `Agent(...)` fallback. With background subagents enabled, each task is launched
1188
+ with `background=true` and its injected task-result event immediately drives
1189
+ predecessor classification and newly-ready dispatch. Claude remains the default
1190
+ when no runtime marker is present, preserving the existing Claude behavior.
1191
+
1192
+ **When `BACKGROUND_DISPATCH_ENABLED=false`**: Claude falls back to its existing
1193
+ synchronous/polling behavior. OpenCode reaches this branch only after an
1194
+ explicit `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false`; use independent
1195
+ foreground `task` calls where the host can execute them concurrently, and
1196
+ report that per-completion streaming is unavailable. Do not use Claude's
1197
+ `Agent(...)` fallback and do not claim the event-driven DAG guarantee.
1198
+
1199
+ **When `BACKGROUND_DISPATCH_ENABLED=true`**: Claude uses
1200
+ `run_in_background=true` and reacts to `agent_completed` notifications. OpenCode
1201
+ uses `task(..., background=true)` and reacts to each injected task-result event.
1202
+ Do NOT poll or wait for a whole wave in either enabled path.
1165
1203
 
1166
1204
  ### Orchestrator state reconstruction on wake / after compaction
1167
1205
 
@@ -1359,4 +1397,3 @@ done
1359
1397
  **Why this keeps context small**: Each `Agent()` call returns an agent ID stored only in `AGENT_ISSUE_MAP`, which is rebuilt per Step 4A.pre dispatch batch. After compaction, the map is gone — but the DAG state, including `blocked-on-human-merge` tracking (a durable `FORGE:BLOCKED_ON_HUMAN_MERGE` comment plus label, not an in-context variable), is fully on GitHub. The reconstruction above re-derives the ready set, the gated set, and the blocked-on-human-merge set from labels and comments alone, so the orchestrator context never needs to hold cumulative dispatch history.
1360
1398
 
1361
1399
  ---
1362
-