forgedock 1.6.0 → 1.7.0
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/README.md +13 -2
- package/bin/engine/orchestrate-canary.mjs +15 -0
- package/bin/engine/orchestrate-canary.test.mjs +28 -0
- package/bin/forgedock.mjs +70 -1
- package/bin/opencode-adapter.mjs +435 -0
- package/bin/registry.mjs +2 -1
- package/bin/runner.mjs +207 -59
- package/commands/orchestrate/phase-3-dependency.md +23 -3
- package/commands/orchestrate/phase-4-execution.md +116 -55
- package/commands/work-on/build.md +10 -1
- package/commands/work-on.md +10 -1
- package/docs/OPENCODE.md +132 -252
- package/package.json +3 -2
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.
|
|
@@ -153,12 +155,9 @@ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
|
|
|
153
155
|
const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
|
|
154
156
|
const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
|
|
155
157
|
const cliAvailabilityCache = new Map();
|
|
156
|
-
// Windows
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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.
|
|
158
|
+
// Windows path types emitted by npm's shim generator. Native executables are
|
|
159
|
+
// directly spawnable; .cmd/.bat files must be resolved to their trusted native
|
|
160
|
+
// target before spawning because Node cannot exec them with shell:false.
|
|
162
161
|
const WINDOWS_SPAWNABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".com"]);
|
|
163
162
|
// Cap tool-result payloads so a large file read or verbose command does not
|
|
164
163
|
// blow the context window in a single turn.
|
|
@@ -216,13 +215,10 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
216
215
|
// ---------------------------------------------------------------------------
|
|
217
216
|
|
|
218
217
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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.
|
|
218
|
+
* Read path candidates from the explicitly delimited resolver section of the
|
|
219
|
+
* combined CLI probe. Version output is untrusted for parsing purposes: update
|
|
220
|
+
* notices can contain path-like URLs or filenames, so shape-based inference
|
|
221
|
+
* must never allow lines after the sentinel to become executable candidates.
|
|
226
222
|
*
|
|
227
223
|
* Pure/side-effect-free so it can be unit tested directly against
|
|
228
224
|
* synthetic multi-line `where` output without spawning anything.
|
|
@@ -230,20 +226,16 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
230
226
|
* @param {string} raw
|
|
231
227
|
* @returns {string[]} Leading path-like lines, in original order.
|
|
232
228
|
*/
|
|
233
|
-
function parseProbeOutput(raw) {
|
|
229
|
+
export function parseProbeOutput(raw) {
|
|
234
230
|
const lines = String(raw ?? "")
|
|
235
231
|
.split(/\r?\n/)
|
|
236
232
|
.map((line) => line.trim())
|
|
237
233
|
.filter(Boolean);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return pathLines;
|
|
234
|
+
const boundary = lines.indexOf(CLI_PROBE_OUTPUT_SENTINEL);
|
|
235
|
+
if (boundary < 0) return [];
|
|
236
|
+
return lines
|
|
237
|
+
.slice(0, boundary)
|
|
238
|
+
.filter((line) => line.includes("/") || line.includes("\\"));
|
|
247
239
|
}
|
|
248
240
|
|
|
249
241
|
/**
|
|
@@ -266,9 +258,12 @@ function parseProbeOutput(raw) {
|
|
|
266
258
|
* @param {string[]} pathLines
|
|
267
259
|
* @returns {string|null}
|
|
268
260
|
*/
|
|
269
|
-
function selectResolvedCliPath(
|
|
261
|
+
export function selectResolvedCliPath(
|
|
262
|
+
pathLines,
|
|
263
|
+
{ platform = process.platform, existsImpl = existsSync } = {},
|
|
264
|
+
) {
|
|
270
265
|
if (pathLines.length === 0) return null;
|
|
271
|
-
if (
|
|
266
|
+
if (platform !== "win32") return pathLines[0];
|
|
272
267
|
|
|
273
268
|
const withExt = pathLines.find((line) =>
|
|
274
269
|
WINDOWS_SPAWNABLE_EXTENSIONS.has(extname(line).toLowerCase()),
|
|
@@ -280,11 +275,49 @@ function selectResolvedCliPath(pathLines) {
|
|
|
280
275
|
const base = basename(bare);
|
|
281
276
|
for (const ext of [".cmd", ".exe", ".bat", ".com"]) {
|
|
282
277
|
const candidate = join(dir, `${base}${ext}`);
|
|
283
|
-
if (
|
|
278
|
+
if (existsImpl(candidate)) return candidate;
|
|
284
279
|
}
|
|
285
280
|
return bare;
|
|
286
281
|
}
|
|
287
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Resolve an npm-generated Windows command shim to the native executable it
|
|
285
|
+
* delegates to. This keeps shell:false and argv-array isolation intact instead
|
|
286
|
+
* of interpolating untrusted command arguments into cmd.exe.
|
|
287
|
+
*/
|
|
288
|
+
export function resolveDirectCliExecutable(
|
|
289
|
+
cliPath,
|
|
290
|
+
{
|
|
291
|
+
platform = process.platform,
|
|
292
|
+
existsImpl = existsSync,
|
|
293
|
+
readImpl = readFileSync,
|
|
294
|
+
} = {},
|
|
295
|
+
) {
|
|
296
|
+
if (!cliPath || platform !== "win32") return cliPath || null;
|
|
297
|
+
const extension = extname(cliPath).toLowerCase();
|
|
298
|
+
if (extension === ".exe" || extension === ".com") return cliPath;
|
|
299
|
+
if (extension !== ".cmd" && extension !== ".bat") return null;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const shim = readImpl(cliPath, "utf-8");
|
|
303
|
+
// npm emits several equivalent native-target wrappers. Only accept a
|
|
304
|
+
// bounded %dp0%-relative .exe/.com target; never evaluate batch syntax.
|
|
305
|
+
const directMatch = shim.match(
|
|
306
|
+
/(?:^|\r?\n)\s*"%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"\s+%\*\s*(?:\r?\n|$)/im,
|
|
307
|
+
);
|
|
308
|
+
const variableMatch = shim.match(
|
|
309
|
+
/(?:^|\r?\n)\s*set\s+"[^"=]+=%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"[\s\S]*?\r?\n\s*"%[^"\r\n]+%"\s+%\*\s*(?:\r?\n|$)/im,
|
|
310
|
+
);
|
|
311
|
+
const match = directMatch || variableMatch;
|
|
312
|
+
if (!match) return null;
|
|
313
|
+
const pathApi = platform === "win32" ? win32Path : { join, dirname };
|
|
314
|
+
const target = pathApi.join(pathApi.dirname(cliPath), ...match[1].split(/[\\/]+/));
|
|
315
|
+
return existsImpl(target) ? target : null;
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
288
321
|
/**
|
|
289
322
|
* Probe whether the Claude Code CLI (`claude`) is present and responds on
|
|
290
323
|
* this machine, so `forgedock run` can drive it directly instead of
|
|
@@ -345,7 +378,10 @@ function selectResolvedCliPath(pathLines) {
|
|
|
345
378
|
* `runCliBackend()`.
|
|
346
379
|
* @returns {boolean}
|
|
347
380
|
*/
|
|
348
|
-
export function isClaudeCliAvailable(
|
|
381
|
+
export function isClaudeCliAvailable(
|
|
382
|
+
cwd = process.cwd(),
|
|
383
|
+
{ execImpl = execSync, platform = process.platform, existsImpl = existsSync } = {},
|
|
384
|
+
) {
|
|
349
385
|
const cached = cliAvailabilityCache.get(cwd);
|
|
350
386
|
if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
|
|
351
387
|
return cached.available;
|
|
@@ -354,14 +390,17 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
|
|
|
354
390
|
let available;
|
|
355
391
|
let cliPath = null;
|
|
356
392
|
try {
|
|
357
|
-
const raw = execImpl(
|
|
393
|
+
const raw = execImpl(
|
|
394
|
+
`${resolveCmd} && echo ${CLI_PROBE_OUTPUT_SENTINEL} && claude --version`,
|
|
395
|
+
{
|
|
358
396
|
cwd,
|
|
359
397
|
stdio: ["ignore", "pipe", "ignore"],
|
|
360
398
|
encoding: "utf-8",
|
|
361
399
|
timeout: CLI_PROBE_TIMEOUT_MS,
|
|
362
|
-
|
|
400
|
+
},
|
|
401
|
+
);
|
|
363
402
|
available = true;
|
|
364
|
-
cliPath = selectResolvedCliPath(parseProbeOutput(raw));
|
|
403
|
+
cliPath = selectResolvedCliPath(parseProbeOutput(raw), { platform, existsImpl });
|
|
365
404
|
} catch {
|
|
366
405
|
available = false;
|
|
367
406
|
}
|
|
@@ -397,7 +436,102 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
|
|
|
397
436
|
*/
|
|
398
437
|
export function resolveClaudeCliBinary(cwd = process.cwd(), opts = {}) {
|
|
399
438
|
isClaudeCliAvailable(cwd, opts);
|
|
400
|
-
|
|
439
|
+
let cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
|
|
440
|
+
if (!cliPath) return null;
|
|
441
|
+
|
|
442
|
+
const existsImpl = opts.existsImpl ?? existsSync;
|
|
443
|
+
try {
|
|
444
|
+
if (existsImpl(cliPath)) {
|
|
445
|
+
const executable = resolveDirectCliExecutable(cliPath, { ...opts, existsImpl });
|
|
446
|
+
if (executable) return executable;
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
// Treat an unverifiable cached path as stale and make one bounded refresh.
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
cliAvailabilityCache.delete(cwd);
|
|
453
|
+
isClaudeCliAvailable(cwd, opts);
|
|
454
|
+
cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
|
|
455
|
+
if (!cliPath) return null;
|
|
456
|
+
try {
|
|
457
|
+
return existsImpl(cliPath)
|
|
458
|
+
? resolveDirectCliExecutable(cliPath, { ...opts, existsImpl })
|
|
459
|
+
: null;
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Invoke the CLI with one recovery attempt for a path that disappeared after
|
|
467
|
+
* resolution. The availability cache is intentionally retained, so only an
|
|
468
|
+
* observed ENOENT triggers a fresh resolution.
|
|
469
|
+
*/
|
|
470
|
+
export function runCliWithStalePathRecovery({
|
|
471
|
+
backend,
|
|
472
|
+
cwd,
|
|
473
|
+
bin,
|
|
474
|
+
cliOptions,
|
|
475
|
+
runCliFn = runCliBackend,
|
|
476
|
+
resolveCliFn = resolveClaudeCliBinary,
|
|
477
|
+
}) {
|
|
478
|
+
try {
|
|
479
|
+
return runCliFn({ ...cliOptions, bin });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (backend !== "auto" || error?.code !== "ENOENT") throw error;
|
|
482
|
+
const freshBin = resolveCliFn(cwd);
|
|
483
|
+
if (!freshBin) throw error;
|
|
484
|
+
return runCliFn({ ...cliOptions, bin: freshBin });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Check whether the configured execution backend is locally usable without
|
|
490
|
+
* making a paid model request. CLI checks reuse the production resolver;
|
|
491
|
+
* API checks verify that a key is configured, not that the key is accepted.
|
|
492
|
+
*/
|
|
493
|
+
export function checkExecutionBackend({
|
|
494
|
+
requested = process.env.FORGEDOCK_BACKEND || "auto",
|
|
495
|
+
cwd = process.cwd(),
|
|
496
|
+
apiKey = process.env.ANTHROPIC_API_KEY,
|
|
497
|
+
resolveCliFn = resolveClaudeCliBinary,
|
|
498
|
+
spawnImpl = spawnSync,
|
|
499
|
+
sdkAvailableFn = () => {
|
|
500
|
+
try {
|
|
501
|
+
createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
|
|
502
|
+
return true;
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
} = {}) {
|
|
508
|
+
if (!VALID_BACKENDS.has(requested)) {
|
|
509
|
+
return { ready: false, backend: requested, reason: "invalid-backend" };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (requested !== "api") {
|
|
513
|
+
const cliPath = resolveCliFn(cwd);
|
|
514
|
+
if (cliPath) {
|
|
515
|
+
const result = spawnImpl(cliPath, ["--version"], {
|
|
516
|
+
cwd,
|
|
517
|
+
shell: false,
|
|
518
|
+
encoding: "utf-8",
|
|
519
|
+
timeout: CLI_PROBE_TIMEOUT_MS,
|
|
520
|
+
});
|
|
521
|
+
if (!result?.error && result?.status === 0) {
|
|
522
|
+
return { ready: true, backend: "cli", reason: "resolved-cli" };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (requested === "cli") {
|
|
526
|
+
return { ready: false, backend: "cli", reason: "cli-unavailable" };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (apiKey && sdkAvailableFn()) {
|
|
531
|
+
return { ready: true, backend: "api", reason: "api-key-and-sdk-configured" };
|
|
532
|
+
}
|
|
533
|
+
if (apiKey) return { ready: false, backend: "api", reason: "api-sdk-missing" };
|
|
534
|
+
return { ready: false, backend: "api", reason: "api-key-missing" };
|
|
401
535
|
}
|
|
402
536
|
|
|
403
537
|
/**
|
|
@@ -512,15 +646,11 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
512
646
|
* arbitrary shell commands with this process's privileges). Do not
|
|
513
647
|
* reintroduce `shell: true` or string-command interpolation here.
|
|
514
648
|
*
|
|
515
|
-
* Windows `.cmd`/`.bat`
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
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.
|
|
649
|
+
* Windows npm `.cmd`/`.bat` shims cannot be launched by Node with
|
|
650
|
+
* `shell:false`. `resolveDirectCliExecutable()` therefore parses only the
|
|
651
|
+
* strict npm-shim form that delegates directly to a native `.exe`/`.com` and
|
|
652
|
+
* returns that trusted target. Unknown batch scripts fail closed instead of
|
|
653
|
+
* requiring cmd.exe interpolation of the untrusted user message.
|
|
524
654
|
*
|
|
525
655
|
* CORRECTED (issue #2741): the claim that a bare `"claude"` name resolves
|
|
526
656
|
* via "spawnSync's own PATH/PATHEXT resolution" was overconfident and
|
|
@@ -534,8 +664,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
534
664
|
* above); it is resolving the *exact* absolute path the probe validated
|
|
535
665
|
* once, via `resolveClaudeCliBinary()`, and passing that resolved path as
|
|
536
666
|
* `bin` — `spawnSync(shell:false)` launches an already-fully-resolved
|
|
537
|
-
*
|
|
538
|
-
*
|
|
667
|
+
* native executable path (including the target extracted from a recognized
|
|
668
|
+
* npm shim) without needing any further PATH/PATHEXT
|
|
539
669
|
* resolution of its own. See `isClaudeCliAvailable()`/
|
|
540
670
|
* `resolveClaudeCliBinary()` above for how that resolution happens.
|
|
541
671
|
*
|
|
@@ -971,10 +1101,9 @@ export function runCliBackend({
|
|
|
971
1101
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
972
1102
|
// unparsed elements — see the SECURITY note above. `bin` is expected to
|
|
973
1103
|
// already be a fully-resolved absolute path (see `resolveClaudeCliBinary()`
|
|
974
|
-
// and the `opts.bin` doc comment above, issue #2741) —
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
// further shell/PATHEXT resolution of its own.
|
|
1104
|
+
// and the `opts.bin` doc comment above, issue #2741) — Windows npm shims
|
|
1105
|
+
// have already been reduced to their native target, so spawnSync never
|
|
1106
|
+
// needs shell/PATHEXT resolution.
|
|
978
1107
|
const result = spawnFn(bin, cliArgs, {
|
|
979
1108
|
cwd,
|
|
980
1109
|
encoding: "utf-8",
|
|
@@ -1015,7 +1144,9 @@ export function runCliBackend({
|
|
|
1015
1144
|
);
|
|
1016
1145
|
}
|
|
1017
1146
|
if (result.error) {
|
|
1018
|
-
|
|
1147
|
+
const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
|
|
1148
|
+
error.code = result.error.code;
|
|
1149
|
+
throw error;
|
|
1019
1150
|
}
|
|
1020
1151
|
|
|
1021
1152
|
const output = (stdout + stderr).trim();
|
|
@@ -1973,7 +2104,12 @@ export async function runCommand(opts = {}) {
|
|
|
1973
2104
|
// misbehaving. An explicitly invalid `backend` value DOES throw here —
|
|
1974
2105
|
// surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
|
|
1975
2106
|
// work is attempted, rather than silently ignoring it.
|
|
1976
|
-
|
|
2107
|
+
let resolvedBackend = resolveBackend({ requested: backend, cwd });
|
|
2108
|
+
|
|
2109
|
+
if (resolvedBackend === "cli" && backend === "auto") {
|
|
2110
|
+
const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey });
|
|
2111
|
+
if (readiness.backend === "api") resolvedBackend = "api";
|
|
2112
|
+
}
|
|
1977
2113
|
|
|
1978
2114
|
if (dryRun) {
|
|
1979
2115
|
logger.log(
|
|
@@ -2050,16 +2186,28 @@ export async function runCommand(opts = {}) {
|
|
|
2050
2186
|
// the bare "claude" name only if resolution genuinely found nothing
|
|
2051
2187
|
// (e.g. an injected-execImpl test scenario with no path-like output),
|
|
2052
2188
|
// preserving prior behavior in that edge case.
|
|
2053
|
-
const resolvedBin = resolveClaudeCliBinary(cwd)
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2189
|
+
const resolvedBin = resolveClaudeCliBinary(cwd);
|
|
2190
|
+
if (!resolvedBin && backend === "auto") {
|
|
2191
|
+
resolvedBackend = "api";
|
|
2192
|
+
} else if (!resolvedBin) {
|
|
2193
|
+
const err = new Error("The configured claude CLI path disappeared before invocation.");
|
|
2194
|
+
err.code = "CLI_BACKEND_UNAVAILABLE";
|
|
2195
|
+
throw err;
|
|
2196
|
+
} else {
|
|
2197
|
+
return runCliWithStalePathRecovery({
|
|
2198
|
+
backend,
|
|
2199
|
+
cwd,
|
|
2200
|
+
bin: resolvedBin,
|
|
2201
|
+
cliOptions: {
|
|
2202
|
+
spec,
|
|
2203
|
+
userMessage,
|
|
2204
|
+
systemPrompt: cliSystemPrompt,
|
|
2205
|
+
args,
|
|
2206
|
+
cwd,
|
|
2207
|
+
logger,
|
|
2208
|
+
},
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2063
2211
|
}
|
|
2064
2212
|
|
|
2065
2213
|
if (!apiKey) {
|
|
@@ -1136,8 +1136,23 @@ 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; Claude background dispatch is not required."
|
|
1149
|
+
BACKGROUND_DISPATCH_ENABLED=false
|
|
1150
|
+
fi
|
|
1151
|
+
|
|
1139
1152
|
# Feature gate check — run once before Phase 4 dispatch begins
|
|
1140
|
-
BACKGROUND_DISPATCH_ENABLED
|
|
1153
|
+
if [ "${BACKGROUND_DISPATCH_ENABLED:-}" != "false" ]; then
|
|
1154
|
+
BACKGROUND_DISPATCH_ENABLED=true
|
|
1155
|
+
fi
|
|
1141
1156
|
|
|
1142
1157
|
if [ -n "${CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:-}" ]; then
|
|
1143
1158
|
echo "Background dispatch disabled: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set."
|
|
@@ -1147,7 +1162,7 @@ fi
|
|
|
1147
1162
|
# Version check: if the Claude Code version can be read, compare it.
|
|
1148
1163
|
# If the version cannot be determined, default to ENABLED (optimistic).
|
|
1149
1164
|
CC_VERSION=$(claude --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "")
|
|
1150
|
-
if [ -n "$CC_VERSION" ]; then
|
|
1165
|
+
if [ "$FORGE_RUNTIME" != "opencode" ] && [ -n "$CC_VERSION" ]; then
|
|
1151
1166
|
# Compare major.minor.patch numerically
|
|
1152
1167
|
IFS='.' read -r CC_MAJOR CC_MINOR CC_PATCH <<< "$CC_VERSION"
|
|
1153
1168
|
if [ "$CC_MAJOR" -lt 2 ] || \
|
|
@@ -1159,6 +1174,12 @@ if [ -n "$CC_VERSION" ]; then
|
|
|
1159
1174
|
fi
|
|
1160
1175
|
```
|
|
1161
1176
|
|
|
1177
|
+
When `FORGE_RUNTIME=opencode` (or an OpenCode runtime marker is present), do not
|
|
1178
|
+
interpret a missing `claude` executable as an environment failure. The Phase 4
|
|
1179
|
+
dispatcher must use OpenCode's inline continuation contract instead of the
|
|
1180
|
+
Claude `Agent(...)` fallback. Claude remains the default when no runtime marker
|
|
1181
|
+
is present, preserving the existing Claude behavior.
|
|
1182
|
+
|
|
1162
1183
|
**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
1184
|
|
|
1164
1185
|
**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.
|
|
@@ -1359,4 +1380,3 @@ done
|
|
|
1359
1380
|
**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
1381
|
|
|
1361
1382
|
---
|
|
1362
|
-
|