@aiwg/cli 2026.10.1 → 2026.10.3

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 (166) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +270 -0
  2. package/agentic/code/providers/model-capabilities.v1.json +3091 -24
  3. package/bin/aiwg.mjs +86 -21
  4. package/dist/src/a2a/codecs.js +9 -0
  5. package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
  6. package/dist/src/artifacts/repair.js +9 -4
  7. package/dist/src/channel/manager.mjs +59 -10
  8. package/dist/src/cli/child-process-result.js +40 -0
  9. package/dist/src/cli/env.js +38 -16
  10. package/dist/src/cli/flag-parsing.js +236 -0
  11. package/dist/src/cli/handlers/decision.js +210 -4
  12. package/dist/src/cli/handlers/diagnose.js +9 -2
  13. package/dist/src/cli/handlers/feedback.js +14 -5
  14. package/dist/src/cli/handlers/init.js +245 -30
  15. package/dist/src/cli/handlers/mc.js +23 -0
  16. package/dist/src/cli/handlers/models.js +69 -6
  17. package/dist/src/cli/handlers/ralph.js +3 -0
  18. package/dist/src/cli/handlers/refresh.js +16 -0
  19. package/dist/src/cli/handlers/regenerate.js +14 -0
  20. package/dist/src/cli/handlers/runtime-info.js +24 -9
  21. package/dist/src/cli/handlers/script-runner.js +16 -6
  22. package/dist/src/cli/handlers/session.js +396 -62
  23. package/dist/src/cli/handlers/sessions.js +146 -40
  24. package/dist/src/cli/handlers/setup-manifest.js +439 -39
  25. package/dist/src/cli/handlers/setup.js +139 -39
  26. package/dist/src/cli/handlers/subcommands.js +3 -1
  27. package/dist/src/cli/handlers/team.js +18 -8
  28. package/dist/src/cli/handlers/use.js +13 -0
  29. package/dist/src/cli/handlers/utilities.js +106 -23
  30. package/dist/src/cli/prompt-utils.js +72 -0
  31. package/dist/src/cli/provider-resolution.js +11 -0
  32. package/dist/src/config/aiwg-config.js +268 -4
  33. package/dist/src/config/cli.js +146 -19
  34. package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
  35. package/dist/src/config/project-artifacts-health.mjs +14 -3
  36. package/dist/src/config/user-config.js +36 -10
  37. package/dist/src/decision/adapters/jev.js +75 -27
  38. package/dist/src/decision/driver.js +3 -1
  39. package/dist/src/decision/entry.js +38 -6
  40. package/dist/src/decision/heldout/collector.js +10 -1
  41. package/dist/src/decision/index.js +1 -0
  42. package/dist/src/decision/interop.js +60 -8
  43. package/dist/src/decision/ledger.js +839 -0
  44. package/dist/src/decision/validate.js +6 -3
  45. package/dist/src/effects/dispatch-verifier.js +252 -0
  46. package/dist/src/effects/index.js +1 -0
  47. package/dist/src/features/installer.js +2 -2
  48. package/dist/src/flow/graph-metadata.js +5 -0
  49. package/dist/src/gates/providers/loader.js +3 -1
  50. package/dist/src/installation/manager-command.mjs +1 -1
  51. package/dist/src/installation/manager.mjs +17 -2
  52. package/dist/src/lint/runner.js +2 -2
  53. package/dist/src/mcp/adapters/codex-runtime.js +360 -56
  54. package/dist/src/mcp/cli.mjs +587 -77
  55. package/dist/src/mcp/config-file.mjs +279 -0
  56. package/dist/src/mcp/config-layers.mjs +348 -0
  57. package/dist/src/mcp/credentials.mjs +484 -0
  58. package/dist/src/mcp/grok-build-config.mjs +16 -26
  59. package/dist/src/mcp/helpers.mjs +106 -2
  60. package/dist/src/mcp/omp-config.mjs +50 -29
  61. package/dist/src/mcp/profiles.js +97 -23
  62. package/dist/src/mcp/profiles.mjs +99 -27
  63. package/dist/src/mcp/registry.js +264 -57
  64. package/dist/src/mcp/registry.mjs +198 -45
  65. package/dist/src/mcp/server.mjs +403 -20
  66. package/dist/src/mcp/toml-string.mjs +9 -0
  67. package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
  68. package/dist/src/mcp/tool-filters.mjs +357 -0
  69. package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
  70. package/dist/src/mcp/tools/decision.mjs +21 -3
  71. package/dist/src/mcp/tools/orchestration.mjs +38 -0
  72. package/dist/src/mcp/tools/subsystems.mjs +39 -8
  73. package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
  74. package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
  75. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
  76. package/dist/src/memory/project-registry.js +2 -2
  77. package/dist/src/mission-protocol/codecs.js +639 -1
  78. package/dist/src/mission-protocol/types.js +110 -0
  79. package/dist/src/mission-protocol/worker-status.js +2 -0
  80. package/dist/src/models/config-loader.js +70 -13
  81. package/dist/src/models/grok-build-models.js +93 -41
  82. package/dist/src/models/model-capabilities.v1.json +3091 -24
  83. package/dist/src/models/model-discovery.js +862 -63
  84. package/dist/src/models/provider-policy.js +625 -11
  85. package/dist/src/models/qualification-digest.mjs +17 -0
  86. package/dist/src/models/resolver.js +259 -0
  87. package/dist/src/models/worker-direct-readiness.js +529 -0
  88. package/dist/src/models/worker-dispatch-offer.js +169 -0
  89. package/dist/src/models/worker-health-readiness.js +131 -0
  90. package/dist/src/models/worker-installation.mjs +879 -0
  91. package/dist/src/models/wrapper-route.js +18 -0
  92. package/dist/src/ops/registry.js +9 -14
  93. package/dist/src/plugins/standalone-packager.js +4 -3
  94. package/dist/src/process/bounded-child-process.mjs +387 -0
  95. package/dist/src/process/cli-command.mjs +75 -0
  96. package/dist/src/providers/capability-matrix.js +8 -0
  97. package/dist/src/providers/capability-matrix.yaml +270 -0
  98. package/dist/src/providers/grok-build-headless.js +43 -21
  99. package/dist/src/providers/provider-definitions.js +9 -1
  100. package/dist/src/providers/provider-definitions.mjs +9 -2
  101. package/dist/src/serve/executor-registry.js +16 -24
  102. package/dist/src/serve/fleet-mission-conductor.js +9 -0
  103. package/dist/src/serve/mission-conductor.js +9 -0
  104. package/dist/src/serve/shared-host-scheduler.js +2 -0
  105. package/dist/src/serve/worker-invocation.js +538 -0
  106. package/dist/src/serve/worker-stdin-frame.mjs +107 -0
  107. package/dist/src/sessions/adapter-registry.js +45 -0
  108. package/dist/src/sessions/adapters/generic.js +37 -3
  109. package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
  110. package/dist/src/sessions/adapters/grokbot.js +423 -24
  111. package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
  112. package/dist/src/sessions/adapters/muse.js +68 -29
  113. package/dist/src/sessions/adapters/pi.js +1 -0
  114. package/dist/src/sessions/batch-contracts.js +13 -1
  115. package/dist/src/sessions/batch-import.js +23 -42
  116. package/dist/src/sessions/candidates.js +47 -11
  117. package/dist/src/sessions/contracts.js +35 -0
  118. package/dist/src/sessions/coverage-preflight.js +134 -0
  119. package/dist/src/sessions/discovery.js +3 -0
  120. package/dist/src/sessions/grok-build-cli.js +67 -5
  121. package/dist/src/sessions/importer.js +24 -8
  122. package/dist/src/sessions/index.js +2 -0
  123. package/dist/src/sessions/repository.js +54 -13
  124. package/dist/src/sessions/workspace-discovery.js +99 -37
  125. package/dist/src/skills/adapters/clawhub.js +10 -8
  126. package/dist/src/skills/adapters/openclaw.js +10 -7
  127. package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
  128. package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
  129. package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
  130. package/dist/src/smiths/mcpsmith/example.js +1 -1
  131. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
  132. package/dist/src/storage/backends/logseq.js +68 -7
  133. package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
  134. package/dist/src/testing/generators/test-code-generator.js +57 -30
  135. package/dist/src/uhp/client.js +11 -1
  136. package/dist/src/uhp/mission.js +5 -0
  137. package/dist/src/update/checker.mjs +5 -5
  138. package/dist/src/update/notifier.mjs +54 -2
  139. package/dist/src/writing/fidelity.js +18 -1
  140. package/package.json +18 -1
  141. package/schemas/mission-v1.schema.json +2429 -0
  142. package/schemas/models/model-catalog.v1.schema.json +301 -0
  143. package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
  144. package/schemas/models/model-policy.v1.schema.json +14 -0
  145. package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
  146. package/tools/_resolve-impl.mjs +39 -0
  147. package/tools/agents/deploy-agents.mjs +15 -2
  148. package/tools/agents/providers/base.mjs +49 -32
  149. package/tools/agents/providers/grokbot.mjs +11 -12
  150. package/tools/agents/providers/hermes.mjs +6 -3
  151. package/tools/agents/providers/omp.mjs +5 -2
  152. package/tools/agents/providers/openclaw-translator.mjs +19 -5
  153. package/tools/providers/deepseek-harness-transport.mjs +358 -0
  154. package/tools/providers/omp-transport.mjs +192 -0
  155. package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
  156. package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
  157. package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
  158. package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
  159. package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
  160. package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
  161. package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
  162. package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
  163. package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
  164. package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
  165. package/tools/ralph-external/lib/worker-health.mjs +439 -0
  166. package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
@@ -1,6 +1,7 @@
1
1
  import { compileModelPolicy, } from './provider-policy.js';
2
2
  import { MODEL_WRAPPERS } from './provider-models.js';
3
3
  import { routeModelTier } from './router.js';
4
+ import { resolveWorkerPlan } from './resolver.js';
4
5
  function roleForTier(tier) {
5
6
  if (tier === 'economy')
6
7
  return 'efficiency';
@@ -14,6 +15,23 @@ export function buildWrapperRouteEnvelope(request) {
14
15
  if (!request.assignment.trim())
15
16
  throw new Error('Wrapper routing requires a bounded assignment.');
16
17
  const decision = routeModelTier(request);
18
+ if (request.worker?.snapshot.config.parallelism?.worker_dispatch && !request.deterministic) {
19
+ const worker = resolveWorkerPlan(request.worker.snapshot, {
20
+ ...request.worker.request,
21
+ authority: request,
22
+ });
23
+ const role = worker.role;
24
+ const wrapper = role ? MODEL_WRAPPERS[role] : null;
25
+ return {
26
+ version: 1, provider: request.provider, decision, role,
27
+ tier: role ? worker.policy?.roleGuidance?.[role]?.tier ?? null : null,
28
+ wrapper, capability: request.capability, assignment: request.assignment.trim(), model: null, worker,
29
+ launch: { mechanism: request.launchMechanism, target: worker.selected ? wrapper : null,
30
+ prompt: worker.selected
31
+ ? `Run this bounded assignment through ${wrapper}.\nSelected worker: ${worker.selected.group}/${worker.selected.member}; model ${worker.selected.model}; effort ${worker.selected.effort ?? 'not-applicable'}.\nAssignment: ${request.assignment.trim()}\nRevalidate current input digests before invocation; return claims for conductor verification.`
32
+ : 'Worker resolution is blocked; inspect the plan diagnostics and exclusions.' },
33
+ };
34
+ }
17
35
  const tier = decision.modelTier;
18
36
  const role = roleForTier(tier);
19
37
  const wrapper = role ? MODEL_WRAPPERS[role] : null;
@@ -10,7 +10,7 @@ import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
10
10
  import { resolve, dirname, basename, sep } from 'path';
11
11
  import { homedir } from 'os';
12
12
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
13
- import { execSync } from 'child_process';
13
+ import { boundedExecFileSync } from '../process/bounded-child-process.mjs';
14
14
  import { resolveConfigDir } from '../config/user-config.js';
15
15
  import { resolveWorkspace } from '../config/workspace.js';
16
16
  import { projectControlPath } from '../config/project-artifacts.js';
@@ -495,7 +495,10 @@ export class OpsRegistry {
495
495
  if (remote) {
496
496
  console.log(` Pushing ${repoName} to ${remote}/${branch}...`);
497
497
  try {
498
- execSync(`git push ${shellEscape(remote)} ${shellEscape(branch)}`, {
498
+ if (remote.startsWith('-') || branch.startsWith('-')) {
499
+ throw new Error('Remote and branch must not start with "-"');
500
+ }
501
+ boundedExecFileSync('git', ['push', '--end-of-options', remote, branch], 'network', {
499
502
  cwd: repo.path,
500
503
  stdio: 'pipe',
501
504
  });
@@ -541,23 +544,15 @@ async function provisionRepo(repoPath, fromUrl, silent) {
541
544
  }
542
545
  }
543
546
  await mkdir(dirname(repoPath), { recursive: true });
544
- execSync(`git clone ${shellEscape(fromUrl)} ${shellEscape(repoPath)}`, { stdio: silent ? 'pipe' : 'inherit' });
547
+ // Cloning a full repository; long tier (#3062).
548
+ boundedExecFileSync('git', ['clone', '--', fromUrl, repoPath], 'long', { stdio: silent ? 'pipe' : 'inherit' });
545
549
  return { action: 'Cloned', remote: readGitRemote(repoPath) };
546
550
  }
547
551
  // Case 3: fresh init.
548
552
  await mkdir(repoPath, { recursive: true });
549
- execSync('git init', { cwd: repoPath, stdio: 'pipe' });
553
+ boundedExecFileSync('git', ['init'], 'quick', { cwd: repoPath, stdio: 'pipe' });
550
554
  return { action: 'Created', remote: undefined };
551
555
  }
552
- /**
553
- * Minimal shell escaping for git URLs and paths passed via execSync.
554
- * URLs are URL-encoded already, but paths may contain spaces.
555
- */
556
- function shellEscape(s) {
557
- if (/^[A-Za-z0-9_@:./~+,=-]+$/.test(s))
558
- return s;
559
- return `'${s.replace(/'/g, "'\\''")}'`;
560
- }
561
556
  /**
562
557
  * Return the `origin` remote URL for a git repo rooted at `dirPath`, or
563
558
  * undefined if not a git repo or no origin configured.
@@ -566,7 +561,7 @@ function readGitRemote(dirPath) {
566
561
  if (!existsSync(resolve(dirPath, '.git')))
567
562
  return undefined;
568
563
  try {
569
- const out = execSync('git config --get remote.origin.url', {
564
+ const out = boundedExecFileSync('git', ['config', '--get', 'remote.origin.url'], 'quick', {
570
565
  cwd: dirPath,
571
566
  stdio: ['ignore', 'pipe', 'ignore'],
572
567
  })
@@ -1,5 +1,5 @@
1
1
  // @ts-nocheck
2
- import { spawnSync } from 'node:child_process';
2
+ import { boundedSpawnSync } from '../process/bounded-child-process.mjs';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
@@ -65,7 +65,8 @@ function writeProviderMetadata(root, provider, manifest) {
65
65
  }
66
66
  }
67
67
  function createDeterministicArchive(sourceDir, archivePath) {
68
- const result = spawnSync('tar', [
68
+ // Packaging a local plugin directory; quick tier (#3062).
69
+ const result = boundedSpawnSync('tar', [
69
70
  '--sort=name',
70
71
  '--mtime=@0',
71
72
  '--owner=0',
@@ -76,7 +77,7 @@ function createDeterministicArchive(sourceDir, archivePath) {
76
77
  '-C',
77
78
  path.dirname(sourceDir),
78
79
  path.basename(sourceDir),
79
- ], { encoding: 'utf8' });
80
+ ], 'quick', { encoding: 'utf8' });
80
81
  if (result.status !== 0)
81
82
  throw new Error(`Could not create deterministic archive: ${result.stderr || 'tar failed'}`);
82
83
  }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Bounded synchronous child-process wrappers (#3062).
3
+ *
4
+ * `execSync`/`execFileSync`/`spawnSync` block the calling thread for as long
5
+ * as the child — or any grandchild still holding an inherited pipe — lives.
6
+ * With no timeout that is unbounded: it hung the test suite once and can
7
+ * hang the CLI when it runs without a terminal. These wrappers make every
8
+ * call bounded by construction: a required `tier` selects the wall-clock
9
+ * cap, the call never inherits stdin, and a timeout always surfaces as the
10
+ * normal Node `ETIMEDOUT` error/result with a message that names the
11
+ * command, tier and bound.
12
+ *
13
+ * Tiers (see `SYNC_PROCESS_TIER_BOUNDS_MS`):
14
+ * - `quick` 30s — local queries: git status/rev-parse/config, version
15
+ * probes, `which`/`where`, `df`, tmux queries.
16
+ * - `network` 2m — git fetch/pull/ls-remote/push, `gh`/API calls.
17
+ * - `long` 10m — installs, clones, docker, builds, test runs launched
18
+ * by scripts. Overridable via `AIWG_SYNC_LONG_TIMEOUT_MS`
19
+ * (see `resolveSyncProcessTimeoutMs`); `quick`/`network`
20
+ * are not overridable.
21
+ *
22
+ * Choose the tier by what the command does, not by where the call site
23
+ * lives.
24
+ *
25
+ * Interactive-by-design calls (an editor, a login flow, launching a harness
26
+ * binary the user is meant to talk to) do not belong here — bounding them
27
+ * would just make the interactive session time out. Leave those sites on
28
+ * the plain Node APIs.
29
+ *
30
+ * ## Why timeout kills with SIGKILL, not SIGTERM-then-SIGKILL
31
+ *
32
+ * Node's own `timeout`/`killSignal` option sends `killSignal` exactly once
33
+ * when the bound elapses, then keeps blocking until the child actually
34
+ * exits. If the child ignores that signal — trivial to do for SIGTERM, and
35
+ * exactly the "grandchild holding an inherited pipe" scenario this gate
36
+ * exists to catch — the call hangs forever regardless of the configured
37
+ * timeout; killSignal only picks *which* signal is sent once, not whether
38
+ * the wait ever ends. There is no portable way to schedule a second signal
39
+ * from inside a single blocking native call: the calling thread is blocked
40
+ * in a C++ synchronous spawn, so no JS timer can run to escalate.
41
+ *
42
+ * These wrappers therefore always kill with `killSignal: 'SIGKILL'` and
43
+ * ignore any `killSignal` a caller passes in `options` — honoring a
44
+ * caller-supplied `'SIGTERM'` would silently reopen exactly the hang this
45
+ * module exists to close for a child that traps and ignores it. SIGKILL
46
+ * cannot be caught, blocked or ignored, so the *direct child* reliably dies
47
+ * at the bound — confirmed with a real child that traps and ignores SIGTERM
48
+ * (see `test/unit/process/bounded-child-process.test.mjs`): with
49
+ * `killSignal: 'SIGTERM'` the call hangs indefinitely; with `'SIGKILL'` it
50
+ * returns at the bound, every time.
51
+ *
52
+ * ## What SIGKILL does *not* guarantee: descendant cleanup
53
+ *
54
+ * These wrappers bound one synchronous call to one direct child process.
55
+ * SIGKILL is sent to that child only — never to a process group or a
56
+ * descendant tree. A direct child that itself spawned a grandchild (for
57
+ * example a backgrounded, detached, or double-forked process) is not
58
+ * supervised here: that grandchild is not signaled, is not waited for, and
59
+ * may keep running after this call returns (see
60
+ * `test/fixtures/process/spawn-detached-pipe-holder.mjs` and its test for a
61
+ * concrete case). A synchronous Node call cannot supervise a process tree —
62
+ * doing so needs an asynchronous process-group/session owner, which is a
63
+ * different mechanism than what `execSync`/`execFileSync`/`spawnSync` give
64
+ * us. Callers that spawn a tool known to background work of its own are
65
+ * responsible for that tool's own cleanup contract; this module only
66
+ * guarantees the direct child is gone and the calling thread is unblocked
67
+ * by the stated bound.
68
+ *
69
+ * @module src/process/bounded-child-process
70
+ */
71
+
72
+ import {
73
+ execSync as nodeExecSync,
74
+ execFileSync as nodeExecFileSync,
75
+ spawnSync as nodeSpawnSync,
76
+ } from 'node:child_process';
77
+
78
+ /** Env var that raises or disables the `long` tier bound. `0` disables it (no timeout). */
79
+ export const AIWG_SYNC_LONG_TIMEOUT_MS_ENV = 'AIWG_SYNC_LONG_TIMEOUT_MS';
80
+
81
+ /**
82
+ * Default wall-clock bounds per tier, in milliseconds. `quick` and `network`
83
+ * are fixed; `long` is the default absent `AIWG_SYNC_LONG_TIMEOUT_MS`.
84
+ */
85
+ export const SYNC_PROCESS_TIER_BOUNDS_MS = Object.freeze({
86
+ quick: 30_000,
87
+ network: 120_000,
88
+ long: 600_000,
89
+ });
90
+
91
+ const VALID_TIERS = new Set(Object.keys(SYNC_PROCESS_TIER_BOUNDS_MS));
92
+
93
+ let warnedInvalidLongTimeoutEnv = false;
94
+
95
+ /**
96
+ * Resolve the `long` tier bound, honoring `AIWG_SYNC_LONG_TIMEOUT_MS`.
97
+ *
98
+ * - Unset/empty → the default (`SYNC_PROCESS_TIER_BOUNDS_MS.long`).
99
+ * - `"0"` → disabled: no bound is applied to the `long` tier at all.
100
+ * - A positive integer → that many milliseconds.
101
+ * - Anything else → the default, with one warning to stderr per process.
102
+ *
103
+ * @returns {number} Milliseconds, or `0` meaning "no bound".
104
+ */
105
+ function resolveLongTierTimeoutMs() {
106
+ const raw = process.env[AIWG_SYNC_LONG_TIMEOUT_MS_ENV];
107
+ if (raw === undefined || raw.trim() === '') return SYNC_PROCESS_TIER_BOUNDS_MS.long;
108
+ const trimmed = raw.trim();
109
+ if (trimmed === '0') return 0;
110
+ const parsed = Number(trimmed);
111
+ if (!Number.isInteger(parsed) || parsed <= 0) {
112
+ if (!warnedInvalidLongTimeoutEnv) {
113
+ warnedInvalidLongTimeoutEnv = true;
114
+ console.warn(
115
+ `[aiwg] ${AIWG_SYNC_LONG_TIMEOUT_MS_ENV}=${JSON.stringify(raw)} is not a positive integer or "0"; ` +
116
+ `using the default long-tier bound (${SYNC_PROCESS_TIER_BOUNDS_MS.long}ms).`,
117
+ );
118
+ }
119
+ return SYNC_PROCESS_TIER_BOUNDS_MS.long;
120
+ }
121
+ return parsed;
122
+ }
123
+
124
+ /**
125
+ * Exposed for tests and for the gate/report tooling: resolve the effective
126
+ * bound for a tier right now (reads the env override for `long` each call,
127
+ * same as the wrappers do).
128
+ *
129
+ * @param {'quick'|'network'|'long'} tier
130
+ * @returns {number} Milliseconds; `0` means "no bound" (long tier disabled).
131
+ */
132
+ export function resolveSyncProcessTimeoutMs(tier) {
133
+ assertValidTier(tier);
134
+ if (tier === 'long') return resolveLongTierTimeoutMs();
135
+ return SYNC_PROCESS_TIER_BOUNDS_MS[tier];
136
+ }
137
+
138
+ function assertValidTier(tier) {
139
+ if (!VALID_TIERS.has(tier)) {
140
+ throw new TypeError(
141
+ `Sync process tier must be one of ${[...VALID_TIERS].map((t) => `'${t}'`).join(', ')}; got ${JSON.stringify(tier)}.`,
142
+ );
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The bound actually applied to one call: the tier bound, unless the
148
+ * caller's own `options.timeout` is a smaller positive number (an explicit
149
+ * smaller timeout always wins; a zero/negative/non-numeric one is ignored —
150
+ * a call site cannot silently opt out of the tier bound by passing
151
+ * `timeout: 0` itself; only the `long`-tier env var can disable a bound).
152
+ *
153
+ * @param {'quick'|'network'|'long'} tier
154
+ * @param {{timeout?: number}} [options]
155
+ * @returns {number}
156
+ */
157
+ function effectiveTimeoutMs(tier, options) {
158
+ const bound = resolveSyncProcessTimeoutMs(tier);
159
+ const explicit = options?.timeout;
160
+ if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0 && (bound === 0 || explicit < bound)) {
161
+ return explicit;
162
+ }
163
+ return bound;
164
+ }
165
+
166
+ /**
167
+ * Clamp a caller-controlled value to a guaranteed-positive millisecond
168
+ * bound, for the one case `boundedExecSync`/`boundedExecFileSync`/
169
+ * `boundedSpawnSync` don't cover by themselves: a *raw* `execSync`/
170
+ * `execFileSync`/`spawnSync` call site whose `timeout` option is computed
171
+ * from caller input (a CLI flag, a config value, a converted unit) rather
172
+ * than a literal — `timeout: options.timeout` or `timeout: timeoutSec *
173
+ * 1000` with an untrusted `timeoutSec`, for example. `tools/testing/
174
+ * check-sync-child-process-timeouts.mjs` (#3062 B/C finding 8) recognizes
175
+ * a call to this function, by binding, as a provably positive timeout —
176
+ * so wrap the dynamic value here instead of passing it through directly.
177
+ *
178
+ * `value` passes through only when it is itself a finite number `> 0`;
179
+ * anything else (`0`, a negative number, `NaN`, `undefined`, a string,
180
+ * ...) falls back to `fallbackMs` — so a caller-controlled `timeoutSec: 0`
181
+ * cannot disable the bound. `fallbackMs` itself falls back to the `quick`
182
+ * tier bound if it is not a valid positive number, so this function can
183
+ * never itself return a non-positive or non-finite value.
184
+ *
185
+ * @param {unknown} value
186
+ * @param {number} fallbackMs
187
+ * @returns {number}
188
+ */
189
+ export function positiveTimeoutMs(value, fallbackMs) {
190
+ const resolvedFallback = typeof fallbackMs === 'number' && Number.isFinite(fallbackMs) && fallbackMs > 0
191
+ ? fallbackMs
192
+ : SYNC_PROCESS_TIER_BOUNDS_MS.quick;
193
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : resolvedFallback;
194
+ }
195
+
196
+ /**
197
+ * Whether a `stdio[0]` (stdin slot) value describes the parent's own stdin —
198
+ * the form Node would otherwise inherit from. Covers the string shorthand,
199
+ * the literal fd `0`, and the `process.stdin` stream object itself; leaves
200
+ * `'pipe'`, `'ignore'`, other fds, and unrelated stream objects alone.
201
+ *
202
+ * @param {unknown} value
203
+ * @returns {boolean}
204
+ */
205
+ function isParentStdinDescriptor(value) {
206
+ return value === 'inherit' || value === 0 || value === process.stdin;
207
+ }
208
+
209
+ /**
210
+ * A bounded call never inherits stdin; stdout/stderr may still be inherited
211
+ * for progress output. When the caller supplies `input`, an inherited
212
+ * stdin form is normalized to `'pipe'` instead of `'ignore'` — `input`
213
+ * needs a real pipe to deliver its payload through, and `'ignore'` would
214
+ * silently drop it (#3062 B/C finding 9). `options` is otherwise left
215
+ * untouched when stdin was not going to be inherited anyway.
216
+ *
217
+ * @param {Record<string, unknown>} options
218
+ * @returns {Record<string, unknown>}
219
+ */
220
+ function withoutInheritedStdin(options) {
221
+ if (options.input !== undefined) {
222
+ // `input` needs stdin to actually be a pipe to deliver its payload —
223
+ // a caller-specified inherited-stdin form in `stdio[0]` must not win
224
+ // over that (#3062 B/C finding 9: an earlier version returned
225
+ // `options` completely untouched here, so `input` combined with
226
+ // `stdio: 'inherit'`/fd `0`/`process.stdin` could still leave stdin
227
+ // connected to the parent's real stdin instead of the pipe `input`
228
+ // is written to). stdout/stderr and the payload itself are untouched.
229
+ if (options.stdio === 'inherit') {
230
+ return { ...options, stdio: ['pipe', 'inherit', 'inherit'] };
231
+ }
232
+ if (Array.isArray(options.stdio) && isParentStdinDescriptor(options.stdio[0])) {
233
+ const stdio = options.stdio.slice();
234
+ stdio[0] = 'pipe';
235
+ return { ...options, stdio };
236
+ }
237
+ return options;
238
+ }
239
+ if (options.stdio === 'inherit') {
240
+ return { ...options, stdio: ['ignore', 'inherit', 'inherit'] };
241
+ }
242
+ if (Array.isArray(options.stdio) && isParentStdinDescriptor(options.stdio[0])) {
243
+ const stdio = options.stdio.slice();
244
+ stdio[0] = 'ignore';
245
+ return { ...options, stdio };
246
+ }
247
+ return options;
248
+ }
249
+
250
+ /**
251
+ * Build the final Node options: bounded stdin, resolved timeout, and a
252
+ * forced `killSignal: 'SIGKILL'`. Unlike `timeout`, `killSignal` is not
253
+ * negotiable: a caller-supplied `killSignal` (e.g. `'SIGTERM'`) is
254
+ * overwritten, not honored, because a child that ignores it would defeat
255
+ * the bound this module exists to guarantee (see the module header).
256
+ *
257
+ * @param {Record<string, unknown> | undefined} options
258
+ * @param {number} boundMs
259
+ * @returns {Record<string, unknown>}
260
+ */
261
+ function toNodeOptions(options, boundMs) {
262
+ const base = withoutInheritedStdin(options ?? {});
263
+ return {
264
+ ...base,
265
+ timeout: boundMs,
266
+ killSignal: 'SIGKILL',
267
+ };
268
+ }
269
+
270
+ function describeCommand(command, args) {
271
+ if (args === undefined) return command;
272
+ return [command, ...args].join(' ');
273
+ }
274
+
275
+ /**
276
+ * On timeout (`error.code === 'ETIMEDOUT'`), prepend a message naming the
277
+ * command, tier and bound. Leaves every other field (`code`, `signal`,
278
+ * `status`, `killed`) exactly as Node set them, and leaves non-timeout
279
+ * errors untouched, so existing catch blocks that inspect those fields (or
280
+ * just catch-and-ignore) keep working.
281
+ *
282
+ * `signal` disambiguates *why* `ETIMEDOUT` fired: when it is `'SIGKILL'` the
283
+ * direct child itself was still running and got killed at the bound: the
284
+ * common case. When it is anything else (including `null`), the direct
285
+ * child had already exited — `status`/`signal` on the result reflect its
286
+ * real exit — but this call was still blocked waiting for its stdout/stderr
287
+ * pipe to reach EOF, which only happens once every process holding that
288
+ * pipe's write end (the child *and* any surviving descendant it handed the
289
+ * fd to) has closed it. A surviving, pipe-holding descendant reproduces
290
+ * that even though the child it came from is long gone (see
291
+ * `test/fixtures/process/spawn-detached-pipe-holder.mjs`).
292
+ *
293
+ * @template {Error & {code?: string, signal?: string | null}} E
294
+ * @param {E | null | undefined} error
295
+ * @param {{description: string, tier: string, boundMs: number, signal?: string | null}} context
296
+ * @returns {E | null | undefined}
297
+ */
298
+ function enrichTimeoutError(error, { description, tier, boundMs, signal }) {
299
+ if (!error || error.code !== 'ETIMEDOUT') return error;
300
+ const effectiveSignal = signal !== undefined ? signal : error.signal;
301
+ const killClause = effectiveSignal === 'SIGKILL'
302
+ ? 'the direct child was killed (SIGKILL)'
303
+ : 'the direct child had already exited, but this call was still waiting for its stdout/stderr pipe to close';
304
+ error.message =
305
+ `\`${description}\` exceeded the '${tier}' tier bound of ${boundMs}ms; ${killClause}. ` +
306
+ `Any descendant the child spawned (detached, backgrounded, or otherwise ignoring signals) is not supervised ` +
307
+ `by this call and may still be running — holding that pipe open is enough to trigger this timeout even after ` +
308
+ `the child itself has exited. ` +
309
+ (tier === 'long' ? `Set ${AIWG_SYNC_LONG_TIMEOUT_MS_ENV} to a larger number of milliseconds, or 0 to remove the bound. ` : '') +
310
+ error.message;
311
+ return error;
312
+ }
313
+
314
+ /**
315
+ * Bounded `child_process.execSync`. Same arguments and return value as
316
+ * Node's `execSync(command, options)`, with a required `tier` inserted
317
+ * before `options`.
318
+ *
319
+ * @param {string} command
320
+ * @param {'quick'|'network'|'long'} tier
321
+ * @param {Record<string, unknown>} [options]
322
+ * @returns {Buffer | string}
323
+ */
324
+ export function boundedExecSync(command, tier, options) {
325
+ assertValidTier(tier);
326
+ const boundMs = effectiveTimeoutMs(tier, options);
327
+ const nodeOptions = toNodeOptions(options, boundMs);
328
+ try {
329
+ return nodeExecSync(command, nodeOptions);
330
+ } catch (error) {
331
+ throw enrichTimeoutError(error, { description: command, tier, boundMs });
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Bounded `child_process.execFileSync`. Same arguments and return value as
337
+ * Node's `execFileSync(file, args, options)`, with a required `tier`
338
+ * inserted before `options`. Unlike the native API, `args` is always
339
+ * required (pass `[]` for none) so tier can occupy a fixed position.
340
+ *
341
+ * @param {string} file
342
+ * @param {readonly string[]} args
343
+ * @param {'quick'|'network'|'long'} tier
344
+ * @param {Record<string, unknown>} [options]
345
+ * @returns {Buffer | string}
346
+ */
347
+ export function boundedExecFileSync(file, args, tier, options) {
348
+ assertValidTier(tier);
349
+ const boundMs = effectiveTimeoutMs(tier, options);
350
+ const nodeOptions = toNodeOptions(options, boundMs);
351
+ try {
352
+ return nodeExecFileSync(file, args, nodeOptions);
353
+ } catch (error) {
354
+ throw enrichTimeoutError(error, { description: describeCommand(file, args), tier, boundMs });
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Bounded `child_process.spawnSync`. Same arguments and return shape as
360
+ * Node's `spawnSync(command, args, options)`, with a required `tier`
361
+ * inserted before `options`. Unlike the native API, `args` is always
362
+ * required (pass `[]` for none) so tier can occupy a fixed position.
363
+ *
364
+ * `spawnSync` never throws; on timeout the returned object has an `error`
365
+ * with `code: 'ETIMEDOUT'` whose message has been enriched in place. In the
366
+ * common case the direct child was still running and gets killed at the
367
+ * bound, so `status: null` and `signal: 'SIGKILL'`. If the direct child had
368
+ * already exited but a surviving descendant is still holding its
369
+ * stdout/stderr pipe open, `status`/`signal` instead reflect that real exit
370
+ * (e.g. `status: 0`, `signal: null`) while `error.code` is still
371
+ * `'ETIMEDOUT'` — check `error`, not just `status`/`signal`, to detect the
372
+ * timeout in that case.
373
+ *
374
+ * @param {string} command
375
+ * @param {readonly string[]} args
376
+ * @param {'quick'|'network'|'long'} tier
377
+ * @param {Record<string, unknown>} [options]
378
+ * @returns {import('node:child_process').SpawnSyncReturns<Buffer | string>}
379
+ */
380
+ export function boundedSpawnSync(command, args, tier, options) {
381
+ assertValidTier(tier);
382
+ const boundMs = effectiveTimeoutMs(tier, options);
383
+ const nodeOptions = toNodeOptions(options, boundMs);
384
+ const result = nodeSpawnSync(command, args, nodeOptions);
385
+ enrichTimeoutError(result.error, { description: describeCommand(command, args), tier, boundMs, signal: result.signal });
386
+ return result;
387
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Share PATH/PATHEXT lookup with callers that can launch arbitrary batch files.
5
+ function* executableCandidates(command, searchPath, pathExt, preferNative = false, cwd = process.cwd()) {
6
+ const candidates = path.isAbsolute(command)
7
+ ? [command]
8
+ : /[\\/]/.test(command)
9
+ ? [path.resolve(cwd, ...command.split(/[\\/]/))]
10
+ : searchPath.split(';').filter(Boolean).map(dir => path.join(dir, command));
11
+ for (const candidate of candidates) {
12
+ const pathExtensions = pathExt.split(';').filter(Boolean).map(ext => ext.toLowerCase());
13
+ const extension = path.extname(candidate).toLowerCase();
14
+ const extensions = extension && (pathExtensions.includes(extension) || existsSync(candidate))
15
+ ? ['']
16
+ : pathExtensions;
17
+ if (preferNative) {
18
+ extensions.sort((a, b) => Number(!/\.(?:exe|com)$/i.test(a)) - Number(!/\.(?:exe|com)$/i.test(b)));
19
+ }
20
+ for (const extension of extensions) {
21
+ const executable = `${candidate}${extension}`;
22
+ if (existsSync(executable)) yield executable;
23
+ }
24
+ }
25
+ }
26
+
27
+ export function resolveExecutable(
28
+ command,
29
+ platform = process.platform,
30
+ searchPath = process.env.PATH ?? '',
31
+ pathExt = process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD',
32
+ cwd = process.cwd(),
33
+ ) {
34
+ if (platform !== 'win32') return command;
35
+ for (const executable of executableCandidates(command, searchPath, pathExt, true, cwd)) {
36
+ return executable;
37
+ }
38
+ return command;
39
+ }
40
+
41
+ // npm's Windows shims cannot be spawned directly without a shell. Resolve only
42
+ // their standard Node invocation; never evaluate batch contents or shell args.
43
+ export function cliCommand(
44
+ command,
45
+ args,
46
+ platform = process.platform,
47
+ searchPath = process.env.PATH ?? '',
48
+ pathExt = process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD',
49
+ cwd = process.cwd(),
50
+ ) {
51
+ if (platform !== 'win32') return [command, args];
52
+ for (const shim of executableCandidates(command, searchPath, pathExt, false, cwd)) {
53
+ if (!/\.(?:cmd|bat)$/i.test(shim)) return [shim, args];
54
+ const contents = readFileSync(shim, 'utf8');
55
+ const entry = contents.match(
56
+ /^\s*(?:endLocal & goto #_undefined_# 2>NUL \|\| title %COMSPEC% & )?(?:set PATHEXT=%PATHEXT:;.JS;=;% & )?"%_prog%"\s+((?:(?:"[^"\r\n&|<>^%]*"|[^\s"&|<>^%]+)\s+)*)"%dp0%\\([^"\r\n]+)"\s+%\*\s*$/m,
57
+ );
58
+ // Node distributions bundle npm/npx launchers with named entry variables
59
+ // rather than cmd-shim's _prog invocation. Resolve their local JS entry.
60
+ const bundled = !entry && /^(?:npm|npx)\.cmd$/i.test(path.basename(shim))
61
+ ? contents.match(/^SET "(NPM|NPX)_CLI_JS=%~dp0\\(node_modules\\npm\\bin\\(?:npm|npx)-cli\.js)"\s*$/im)
62
+ : null;
63
+ if (!entry && (!bundled || !contents.includes(`"%NODE_EXE%" "%${bundled[1]}_CLI_JS%" %*`))) {
64
+ throw new Error(`Unsupported Windows CLI shim: ${shim}`);
65
+ }
66
+ const nodeArgs = entry
67
+ ? Array.from(entry[1].matchAll(/"([^"]*)"|([^\s"]+)/g), match => match[1] ?? match[2])
68
+ : [];
69
+ const script = path.resolve(path.dirname(shim), ...(entry ? entry[2] : bundled[2]).split('\\'));
70
+ if (!existsSync(script)) throw new Error(`Missing Windows CLI entry point: ${script}`);
71
+ const localNode = path.join(path.dirname(shim), 'node.exe');
72
+ return [existsSync(localNode) ? localNode : process.execPath, [...nodeArgs, script, ...args]];
73
+ }
74
+ return [command, args];
75
+ }
@@ -82,6 +82,14 @@ export function getProviderCapabilities(providerKey) {
82
82
  }
83
83
  return undefined;
84
84
  }
85
+ /**
86
+ * Get the S0 route-feasibility row for a provider by key or alias.
87
+ * Returns undefined when the matrix carries no row (e.g. registry-only
88
+ * `generic`, which is not a matrix provider).
89
+ */
90
+ export function getRouteFeasibility(providerKey) {
91
+ return getProviderCapabilities(providerKey)?.route_feasibility;
92
+ }
85
93
  export function getAgentCapabilities(providerKey, agentType = 'default') {
86
94
  const caps = getProviderCapabilities(providerKey);
87
95
  if (!caps?.agent_capabilities)