@ikon85/agent-workflow-kit 0.44.1 → 0.45.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.
Files changed (67) hide show
  1. package/.agents/skills/audit-skills/SKILL.md +7 -4
  2. package/.agents/skills/code-review/SKILL.md +7 -4
  3. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  4. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  5. package/.agents/skills/improve-codebase-architecture/SKILL.md +7 -4
  6. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  7. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  8. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  9. package/.agents/skills/research/SKILL.md +7 -4
  10. package/.agents/skills/to-issues/SKILL.md +25 -4
  11. package/.claude/skills/audit-skills/SKILL.md +7 -4
  12. package/.claude/skills/code-review/SKILL.md +7 -4
  13. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  14. package/.claude/skills/codex-build/SKILL.md +13 -0
  15. package/.claude/skills/codex-review/SKILL.md +13 -0
  16. package/.claude/skills/grill-me-codex/SKILL.md +14 -0
  17. package/.claude/skills/grill-with-docs-codex/SKILL.md +14 -0
  18. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  19. package/.claude/skills/improve-codebase-architecture/SKILL.md +7 -4
  20. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  21. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  22. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  23. package/.claude/skills/research/SKILL.md +7 -4
  24. package/.claude/skills/skill-manifest.json +34 -23
  25. package/.claude/skills/to-issues/SKILL.md +25 -4
  26. package/README.md +71 -0
  27. package/agent-workflow-kit.package.json +149 -45
  28. package/package.json +1 -1
  29. package/scripts/doctrine-migration/index.mjs +296 -0
  30. package/scripts/kit-release.mjs +41 -9
  31. package/src/cli.mjs +521 -80
  32. package/src/commands/routing-status.mjs +288 -0
  33. package/src/commands/update.mjs +9 -1
  34. package/src/consumer-migrations.json +23 -1
  35. package/src/lib/bundle.mjs +158 -2
  36. package/src/lib/consumerMigrations.mjs +55 -0
  37. package/src/lib/dispatchJournal.mjs +300 -0
  38. package/src/lib/dispatchPlan.mjs +286 -0
  39. package/src/lib/dispatchReceipt.mjs +226 -89
  40. package/src/lib/frontendWorkloads.mjs +35 -33
  41. package/src/lib/routeDispatcher.mjs +367 -70
  42. package/src/lib/routingAccessGraph.mjs +265 -20
  43. package/src/lib/routingAccessGraphStore.mjs +300 -0
  44. package/src/lib/routingAdapters/claude.mjs +104 -4
  45. package/src/lib/routingAdapters/codex.mjs +132 -7
  46. package/src/lib/routingAdapters/hostBridge.mjs +291 -0
  47. package/src/lib/routingCatalog.mjs +201 -24
  48. package/src/lib/routingDispatchLease.mjs +253 -0
  49. package/src/lib/routingEvidenceCache.mjs +78 -0
  50. package/src/lib/routingIntent.mjs +176 -10
  51. package/src/lib/routingIntentClassifier.mjs +137 -0
  52. package/src/lib/routingInventory/snapshots/claude.json +49 -0
  53. package/src/lib/routingInventory/snapshots/codex.json +109 -0
  54. package/src/lib/routingInventory.mjs +182 -0
  55. package/src/lib/routingPolicy.mjs +193 -6
  56. package/src/lib/routingProfile.mjs +1251 -123
  57. package/src/lib/routingProfilePolicy.mjs +156 -0
  58. package/src/lib/routingProfileStorage.mjs +299 -0
  59. package/src/lib/routingResolver.mjs +369 -86
  60. package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
  61. package/src/lib/routingSources/benchlm.mjs +5 -0
  62. package/src/lib/routingSources/codeArena.mjs +13 -3
  63. package/src/lib/routingSources/deepswe.mjs +19 -5
  64. package/src/lib/routingSources/openhands.mjs +17 -4
  65. package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
  66. package/src/lib/safeText.mjs +26 -0
  67. package/src/lib/updateCandidate.mjs +36 -3
@@ -1,8 +1,93 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+
1
4
  import { adaptClaudeRoutingInventory } from '../capabilityMatrix.mjs';
5
+ import {
6
+ attestAccessPath,
7
+ capabilityPathMatchesPair,
8
+ selectCapabilityPath,
9
+ } from '../routingAccessGraph.mjs';
10
+
11
+ /**
12
+ * `claude-code × native` — the in-session Agent primitive. It can enforce a
13
+ * model, but it has no effort axis at all and reports no applied pair, so it can
14
+ * never produce an honest applied-effort receipt. Attested unavailable rather
15
+ * than bridged; the dated verify-spike carries the recorded evidence.
16
+ */
17
+ export const CLAUDE_NATIVE_UNAVAILABLE = Object.freeze({
18
+ surfaceId: 'claude-code',
19
+ transportId: 'native',
20
+ reason: 'effort-axis-absent',
21
+ detail: 'the in-session Agent primitive enforces a model but exposes no effort '
22
+ + 'parameter and returns no applied pair',
23
+ evidence: 'verify-spike-18c',
24
+ observedAt: '2026-07-27',
25
+ });
26
+
27
+ /**
28
+ * The Claude host as a bridged child process. Model and effort are enforced per
29
+ * spawn and read back from the persisted session transcript, whose
30
+ * `message.model` is server-returned — the strongest attestation either host
31
+ * offers. `--output-format json` omits effort entirely, so the transcript, not
32
+ * stdout, is the readback channel.
33
+ *
34
+ * The Claude CLI silently ignores an unknown `--effort` and runs at its default,
35
+ * so the effort is validated against the model's own domain before the spawn and
36
+ * compared against the transcript after it.
37
+ */
38
+ export const CLAUDE_CLI_HOST = Object.freeze({
39
+ transportId: 'claude-cli',
40
+ command: 'claude',
41
+ /** Anthropic models live under the `claude-code` surface of the pinned inventory. */
42
+ inventorySurface: 'claude-code',
43
+ attestationStrength: 'provider-attested',
44
+ forbiddenArgs: Object.freeze([]),
45
+ buildArgv({ modelId, effort, runId }) {
46
+ const argv = ['--print', '--model', modelId, '--session-id', runId,
47
+ '--output-format', 'json'];
48
+ if (effort !== null) argv.push('--effort', effort);
49
+ return Object.freeze(argv);
50
+ },
51
+ degraded() {
52
+ return false;
53
+ },
54
+ readApplied({ home, cwd, runId }) {
55
+ return readClaudeAppliedPair({ home, cwd, runId });
56
+ },
57
+ });
2
58
 
3
- function matchesRoute(path, route) {
4
- return ['surfaceId', 'providerId', 'modelId', 'transportId']
5
- .every((field) => path[field] === route[field]);
59
+ /** Claude's project directory name: the absolute cwd with `/` and `.` as `-`. */
60
+ export function claudeProjectSlug(cwd) {
61
+ return resolve(cwd).replace(/[/.]/g, '-');
62
+ }
63
+
64
+ /**
65
+ * Read the applied pair out of `~/.claude/projects/<cwd-slug>/<session-id>.jsonl`:
66
+ * the last server-returned `message.model` and the last top-level `effort`.
67
+ */
68
+ export async function readClaudeAppliedPair({ home, cwd, runId }) {
69
+ let raw;
70
+ try {
71
+ raw = await readFile(
72
+ join(home, '.claude', 'projects', claudeProjectSlug(cwd), `${runId}.jsonl`), 'utf8',
73
+ );
74
+ } catch {
75
+ return null;
76
+ }
77
+ let modelId = null;
78
+ let effort = null;
79
+ for (const line of raw.split('\n')) {
80
+ if (line.trim() === '') continue;
81
+ let record;
82
+ try {
83
+ record = JSON.parse(line);
84
+ } catch {
85
+ continue;
86
+ }
87
+ if (typeof record?.message?.model === 'string') modelId = record.message.model;
88
+ if (typeof record?.effort === 'string') effort = record.effort;
89
+ }
90
+ return modelId === null ? null : Object.freeze({ modelId, effort, runId });
6
91
  }
7
92
 
8
93
  function appliedRoute(path, requestedRoute) {
@@ -25,13 +110,28 @@ function mismatchReason(path, requested, applied) {
25
110
  return null;
26
111
  }
27
112
 
113
+ /**
114
+ * Attest the Claude surface's access paths for the Access-graph builder. The
115
+ * attestation carries capability facts and their observation dates only —
116
+ * authorization stays with the Routing profile and the capability probe.
117
+ */
118
+ export function claudeAccessAttestations(inventory, dates) {
119
+ return Object.freeze(adaptClaudeRoutingInventory(inventory).paths
120
+ .map((path) => attestAccessPath(path, dates)));
121
+ }
122
+
28
123
  export function createClaudeRoutingAdapter({ inventory, dispatchers = {} }) {
29
124
  const capabilities = adaptClaudeRoutingInventory(inventory);
30
125
  return Object.freeze({
31
126
  async prepare(requestedRoute) {
32
- const path = capabilities.paths.find((candidate) => matchesRoute(candidate, requestedRoute));
127
+ const path = selectCapabilityPath(capabilities.paths, requestedRoute);
33
128
  if (!path) throw new Error('Claude route capability is not attested');
34
129
  if (!path.verified) throw new Error(path.verificationFailures.join('; '));
130
+ if (!capabilityPathMatchesPair(path, requestedRoute)) {
131
+ throw new Error(
132
+ `access pair is not attested: ${requestedRoute.modelId}+${requestedRoute.effort}`,
133
+ );
134
+ }
35
135
  const invoke = dispatchers[path.transportId];
36
136
  if (typeof invoke !== 'function') {
37
137
  throw new Error(`transport has no approved dispatcher: ${path.transportId}`);
@@ -1,4 +1,12 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+
1
4
  import { adaptClaudeRoutingInventory } from '../capabilityMatrix.mjs';
5
+ import {
6
+ attestAccessPath,
7
+ capabilityPathMatchesPair,
8
+ selectCapabilityPath,
9
+ } from '../routingAccessGraph.mjs';
2
10
 
3
11
  const MODEL_SELECTORS = ['model'];
4
12
  const EFFORT_SELECTORS = ['effort', 'reasoning_effort', 'model_reasoning_effort'];
@@ -60,11 +68,6 @@ function applySpawnSchemaEvidence(path, properties) {
60
68
  return candidate;
61
69
  }
62
70
 
63
- function matchesRoute(path, route) {
64
- return ['surfaceId', 'providerId', 'modelId', 'transportId']
65
- .every((field) => path[field] === route[field]);
66
- }
67
-
68
71
  function appliedRoute(path, requestedRoute) {
69
72
  return Object.freeze({
70
73
  ...requestedRoute,
@@ -85,6 +88,114 @@ function mismatchReason(path, requested, applied) {
85
88
  return null;
86
89
  }
87
90
 
91
+ /**
92
+ * `codex × native` — the in-session Codex spawn primitive. The dated host
93
+ * inventory (2026-07-23, `.codex/agents/README.md`) exposes `task_name`,
94
+ * `message` and `fork_turns` and no per-spawn model or effort selector, so it
95
+ * can start a task but never prove a differentiated pair was applied. Attested
96
+ * unavailable rather than bridged.
97
+ */
98
+ export const CODEX_NATIVE_UNAVAILABLE = Object.freeze({
99
+ surfaceId: 'codex',
100
+ transportId: 'native',
101
+ reason: 'spawn-schema-exposes-no-model-or-effort-selector',
102
+ detail: 'the native spawn schema carries task_name, message and fork_turns only',
103
+ evidence: 'codex-host-inventory',
104
+ observedAt: '2026-07-23',
105
+ });
106
+
107
+ /**
108
+ * The Codex host as a bridged child process. The applied pair lives in the
109
+ * rollout file's `turn_context`, which the client writes — strictly stronger
110
+ * than an argv echo, strictly weaker than Claude's server-returned model, so the
111
+ * attestation is `client-attested` and a receipt must not claim otherwise. The
112
+ * `--json` event stream carries neither model nor effort, and `--ephemeral`
113
+ * destroys the rollout file altogether, so it is forbidden here.
114
+ */
115
+ export const CODEX_CLI_HOST = Object.freeze({
116
+ transportId: 'codex-cli',
117
+ command: 'codex',
118
+ /** OpenAI models live under the `codex` surface of the pinned inventory. */
119
+ inventorySurface: 'codex',
120
+ attestationStrength: 'client-attested',
121
+ forbiddenArgs: Object.freeze(['--ephemeral']),
122
+ buildArgv({ modelId, effort, cwd }) {
123
+ const argv = ['exec', '--json', '--model', modelId, '--cd', cwd];
124
+ if (effort !== null) argv.push('-c', `model_reasoning_effort="${effort}"`);
125
+ argv.push('-');
126
+ return Object.freeze(argv);
127
+ },
128
+ degraded(result) {
129
+ return codexRunDegraded(result?.stdout);
130
+ },
131
+ readApplied({ home, result }) {
132
+ const threadId = codexThreadId(result?.stdout);
133
+ return threadId === null ? null : readCodexAppliedPair({ home, threadId });
134
+ },
135
+ });
136
+
137
+ function* codexEvents(stdout) {
138
+ for (const line of String(stdout ?? '').split('\n')) {
139
+ if (line.trim() === '') continue;
140
+ try {
141
+ yield JSON.parse(line);
142
+ } catch {
143
+ continue;
144
+ }
145
+ }
146
+ }
147
+
148
+ /** The durable run identity Codex announces on its own event stream. */
149
+ export function codexThreadId(stdout) {
150
+ for (const event of codexEvents(stdout)) {
151
+ if (event?.type !== 'thread.started') continue;
152
+ const id = event.thread_id ?? event.payload?.thread_id;
153
+ if (typeof id === 'string' && id !== '') return id;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * Codex emits a soft `item.completed` metadata error before a hard failure. A
160
+ * consumer reading only the terminal status would take that degraded run for a
161
+ * clean one, so the bridge treats it as a failed dispatch.
162
+ */
163
+ export function codexRunDegraded(stdout) {
164
+ for (const event of codexEvents(stdout)) {
165
+ if (event?.type !== 'item.completed') continue;
166
+ if (/Model metadata for .+ not found/i.test(JSON.stringify(event))) return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /** Read the applied pair out of the dated `rollout-…-<threadId>.jsonl` session file. */
172
+ export async function readCodexAppliedPair({ home, threadId }) {
173
+ const root = join(home, '.codex', 'sessions');
174
+ let entries;
175
+ try {
176
+ entries = await readdir(root, { recursive: true });
177
+ } catch {
178
+ return null;
179
+ }
180
+ const file = entries.find((entry) => entry.endsWith(`-${threadId}.jsonl`)
181
+ && basename(entry).startsWith('rollout-'));
182
+ if (file === undefined) return null;
183
+ let raw;
184
+ try {
185
+ raw = await readFile(join(root, file), 'utf8');
186
+ } catch {
187
+ return null;
188
+ }
189
+ let modelId = null;
190
+ let effort = null;
191
+ for (const event of codexEvents(raw)) {
192
+ if (event?.type !== 'turn_context') continue;
193
+ if (typeof event.payload?.model === 'string') modelId = event.payload.model;
194
+ effort = typeof event.payload?.effort === 'string' ? event.payload.effort : null;
195
+ }
196
+ return modelId === null ? null : Object.freeze({ modelId, effort, runId: threadId });
197
+ }
198
+
88
199
  export function adaptCodexRoutingInventory(inventory) {
89
200
  const source = object(inventory, 'Codex host attestation');
90
201
  timestamp(source.observedAt, 'Codex host attestation observedAt');
@@ -103,14 +214,28 @@ export function adaptCodexRoutingInventory(inventory) {
103
214
  });
104
215
  }
105
216
 
217
+ /**
218
+ * Attest the Codex surface's access paths for the Access-graph builder. A host
219
+ * whose spawn schema exposes no selector attests no control, so the path never
220
+ * becomes a dispatchable Access-graph path.
221
+ */
222
+ export function codexAccessAttestations(inventory, dates) {
223
+ return Object.freeze(adaptCodexRoutingInventory(inventory).paths
224
+ .map((path) => attestAccessPath(path, dates)));
225
+ }
226
+
106
227
  export function createCodexRoutingAdapter({ inventory, dispatchers = {} }) {
107
228
  const capabilities = adaptCodexRoutingInventory(inventory);
108
229
  return Object.freeze({
109
230
  async prepare(requestedRoute) {
110
- const path = capabilities.paths.find((candidate) =>
111
- matchesRoute(candidate, requestedRoute));
231
+ const path = selectCapabilityPath(capabilities.paths, requestedRoute);
112
232
  if (!path) throw new Error('Codex route capability is not attested');
113
233
  if (!path.verified) throw new Error(path.verificationFailures.join('; '));
234
+ if (!capabilityPathMatchesPair(path, requestedRoute)) {
235
+ throw new Error(
236
+ `access pair is not attested: ${requestedRoute.modelId}+${requestedRoute.effort}`,
237
+ );
238
+ }
114
239
  const invoke = dispatchers[path.transportId];
115
240
  if (typeof invoke !== 'function') {
116
241
  throw new Error(`transport has no approved dispatcher: ${path.transportId}`);
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The host bridge — the security boundary between a Route decision and the host
3
+ * process that actually runs it.
4
+ *
5
+ * Every `(surface, transport)` pair in the agent-surface registry is accounted
6
+ * for exactly once: it is either bridged to a host command, or it carries a
7
+ * dated unavailable attestation. The census fails closed, so a transport added
8
+ * to the registry without either cannot quietly become undispatchable.
9
+ *
10
+ * The boundary itself is fixed, not per-call policy:
11
+ *
12
+ * - Invocation is `execFile`-style — a bare command plus an argv array. No shell
13
+ * string is ever constructed, and a command that is not a bare binary name is
14
+ * refused.
15
+ * - **Task text never rides in argv.** It goes to the child's stdin, because an
16
+ * argv element is readable in the process list and bounded by argument-size
17
+ * limits. A guard rejects argv that contains the task text.
18
+ * - The model-and-effort pair is schema-validated against the pinned inventory
19
+ * before the spawn, and read back from the host's own record after it. A
20
+ * surface that silently substitutes a default cannot pass.
21
+ * - cwd is bounded to the authorized workspace.
22
+ * - The child environment is an explicit allowlist, never the credential-rich
23
+ * parent.
24
+ * - Every failure is reduced to a closed reason before it can reach a log or a
25
+ * Dispatch receipt.
26
+ */
27
+ import { execFile } from 'node:child_process';
28
+ import { homedir } from 'node:os';
29
+ import { isAbsolute, relative, resolve } from 'node:path';
30
+ import { randomUUID } from 'node:crypto';
31
+
32
+ import { AGENT_SURFACE_REGISTRY } from '../agentSurfaceRegistry.mjs';
33
+ import {
34
+ CLAUDE_CLI_HOST,
35
+ CLAUDE_NATIVE_UNAVAILABLE,
36
+ createClaudeRoutingAdapter,
37
+ } from './claude.mjs';
38
+ import {
39
+ CODEX_CLI_HOST,
40
+ CODEX_NATIVE_UNAVAILABLE,
41
+ createCodexRoutingAdapter,
42
+ } from './codex.mjs';
43
+
44
+ export const HOST_BRIDGE_VERSION = 1;
45
+
46
+ /** The only environment names a bridged child inherits from its parent. */
47
+ export const CHILD_ENV_ALLOWLIST = Object.freeze([
48
+ 'PATH', 'HOME', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR',
49
+ ]);
50
+
51
+ /** The closed set of reasons a bridge failure may name. */
52
+ export const BRIDGE_FAILURE_REASONS = Object.freeze([
53
+ 'host bridge pair is not in the pinned inventory',
54
+ 'host bridge effort is outside the model effort domain',
55
+ 'host bridge cwd is outside the authorized workspace',
56
+ 'host bridge refuses a shell invocation',
57
+ 'host bridge refuses task text in argv',
58
+ 'host bridge refuses an argument that destroys the readback channel',
59
+ 'host bridge has no adapter for the agent surface',
60
+ 'host bridge child exited with a failure',
61
+ 'host bridge run was degraded by the host',
62
+ 'host bridge could not read back the applied pair',
63
+ 'host bridge applied pair differs from the requested pair',
64
+ ]);
65
+
66
+ const BRIDGED_HOSTS = Object.freeze([CLAUDE_CLI_HOST, CODEX_CLI_HOST]);
67
+ const UNAVAILABLE_TRANSPORTS = Object.freeze([
68
+ CLAUDE_NATIVE_UNAVAILABLE, CODEX_NATIVE_UNAVAILABLE,
69
+ ]);
70
+ const ADAPTER_FACTORIES = Object.freeze({
71
+ 'claude-code': createClaudeRoutingAdapter,
72
+ codex: createCodexRoutingAdapter,
73
+ });
74
+
75
+ const BARE_COMMAND = /^[A-Za-z0-9._-]+$/;
76
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
77
+
78
+ /** Reduce any error to its closed reason; nothing else survives to a log. */
79
+ export function redactBridgeError(error) {
80
+ const message = error instanceof Error ? error.message : '';
81
+ return new Error(BRIDGE_FAILURE_REASONS.find((reason) => message.startsWith(reason))
82
+ ?? 'host bridge rejected the dispatch');
83
+ }
84
+
85
+ /** The child inherits allowlisted names only — never an inherited parent env. */
86
+ export function childEnvironment(parentEnv = {}, allowlist = CHILD_ENV_ALLOWLIST) {
87
+ const child = {};
88
+ for (const name of allowlist) {
89
+ const value = parentEnv?.[name];
90
+ if (typeof value === 'string' && value !== '') child[name] = value;
91
+ }
92
+ return Object.freeze(child);
93
+ }
94
+
95
+ /** The child runs inside the authorized worktree or it does not run. */
96
+ export function assertBoundedCwd(cwd, workspaceRoot) {
97
+ if (typeof cwd !== 'string' || cwd === '' || typeof workspaceRoot !== 'string'
98
+ || workspaceRoot === '') {
99
+ throw new Error('host bridge cwd is outside the authorized workspace');
100
+ }
101
+ const target = resolve(cwd);
102
+ const step = relative(resolve(workspaceRoot), target);
103
+ if (step !== '' && (step.startsWith('..') || isAbsolute(step))) {
104
+ throw new Error('host bridge cwd is outside the authorized workspace');
105
+ }
106
+ return target;
107
+ }
108
+
109
+ /** argv carries validated controls only: no shell, no task text, no readback loss. */
110
+ export function assertArgvSafe(host, argv, taskText) {
111
+ if (typeof host?.command !== 'string' || !BARE_COMMAND.test(host.command)
112
+ || !Array.isArray(argv) || argv.some((item) => typeof item !== 'string')) {
113
+ throw new Error('host bridge refuses a shell invocation');
114
+ }
115
+ const task = typeof taskText === 'string' ? taskText.trim() : '';
116
+ if (task !== '' && argv.some((item) => item.includes(task))) {
117
+ throw new Error('host bridge refuses task text in argv');
118
+ }
119
+ if ((host.forbiddenArgs ?? []).some((flag) => argv.includes(flag))) {
120
+ throw new Error('host bridge refuses an argument that destroys the readback channel');
121
+ }
122
+ return argv;
123
+ }
124
+
125
+ /**
126
+ * The roster identification rule. One host reports one model under several
127
+ * forms (`opus`, `opus[1m]`, `claude-opus-5`, `claude-opus-5[1m]`), so an
128
+ * observed id counts as identified only when the pinned inventory lists it as an
129
+ * identifier of exactly one model. An unlisted form is never guessed.
130
+ */
131
+ export function canonicalModelId(inventory, surface, observed) {
132
+ if (typeof observed !== 'string' || observed === '') return null;
133
+ for (const snapshot of inventory?.snapshots ?? []) {
134
+ if (snapshot.surface !== surface) continue;
135
+ for (const model of snapshot.models ?? []) {
136
+ if (model.modelId === observed || (model.identifiers ?? []).includes(observed)) {
137
+ return model.modelId;
138
+ }
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Validate a requested pair against the pinned inventory. The surface is the
146
+ * host being driven, not the dispatching surface: a Codex session bridging to
147
+ * the Claude host must name an Anthropic pair.
148
+ */
149
+ export function requireInventoryPair(inventory, surface, route) {
150
+ const modelId = canonicalModelId(inventory, surface, route?.modelId);
151
+ if (modelId === null) throw new Error('host bridge pair is not in the pinned inventory');
152
+ const effort = route?.effort ?? null;
153
+ const match = (inventory?.pairs ?? []).find((pair) => pair.surface === surface
154
+ && pair.modelId === modelId && (pair.effort ?? null) === effort);
155
+ if (match === undefined) {
156
+ throw new Error('host bridge effort is outside the model effort domain');
157
+ }
158
+ return Object.freeze({ surface, provider: match.provider, modelId, effort });
159
+ }
160
+
161
+ function assertAppliedPair(inventory, host, requested, applied) {
162
+ const modelId = canonicalModelId(inventory, host.inventorySurface, applied?.modelId);
163
+ if (modelId === null || typeof applied?.runId !== 'string' || applied.runId === '') {
164
+ throw new Error('host bridge could not read back the applied pair');
165
+ }
166
+ if (modelId !== requested.modelId || (applied.effort ?? null) !== requested.effort) {
167
+ throw new Error('host bridge applied pair differs from the requested pair');
168
+ }
169
+ }
170
+
171
+ /** The default runner: `execFile` argv, `shell` off, task text on stdin. */
172
+ function spawnChild(command, argv, { cwd, env, stdin, timeoutMs }) {
173
+ return new Promise((resolvePromise, rejectPromise) => {
174
+ const child = execFile(command, argv, { cwd, env, shell: false, timeout: timeoutMs },
175
+ (error, stdout, stderr) => (error
176
+ ? rejectPromise(new Error('host bridge child exited with a failure'))
177
+ : resolvePromise({ stdout, stderr })));
178
+ child.stdin?.end(stdin ?? '');
179
+ });
180
+ }
181
+
182
+ async function runBridgedDispatch(host, context, route) {
183
+ const pair = requireInventoryPair(context.routingInventory, host.inventorySurface, route);
184
+ const cwd = assertBoundedCwd(context.cwd, context.workspaceRoot);
185
+ const argv = host.buildArgv({ ...pair, runId: context.runId, cwd });
186
+ assertArgvSafe(host, argv, context.task);
187
+ let result;
188
+ try {
189
+ result = await context.runChild(host.command, argv, {
190
+ cwd,
191
+ env: childEnvironment(context.env),
192
+ stdin: context.task,
193
+ timeoutMs: context.timeoutMs,
194
+ });
195
+ } catch {
196
+ throw new Error('host bridge child exited with a failure');
197
+ }
198
+ if (host.degraded(result)) throw new Error('host bridge run was degraded by the host');
199
+ const applied = await host.readApplied({ home: context.home, cwd, runId: context.runId, result });
200
+ assertAppliedPair(context.routingInventory, host, pair, applied);
201
+ return Object.freeze({
202
+ taskId: applied.runId,
203
+ appliedPair: pair,
204
+ attestationStrength: host.attestationStrength,
205
+ });
206
+ }
207
+
208
+ function bridgeContext(options) {
209
+ return Object.freeze({
210
+ routingInventory: options.routingInventory,
211
+ task: options.task,
212
+ cwd: options.cwd ?? process.cwd(),
213
+ workspaceRoot: options.workspaceRoot ?? options.cwd ?? process.cwd(),
214
+ home: options.home ?? homedir(),
215
+ env: options.env ?? process.env,
216
+ runId: options.runId ?? randomUUID(),
217
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
218
+ runChild: options.runChild ?? spawnChild,
219
+ });
220
+ }
221
+
222
+ /**
223
+ * The census: one entry per registry `(surface, transport)` pair, each bridged
224
+ * or attested unavailable. A pair with neither — or with both — throws.
225
+ */
226
+ export function hostTransportCensus({ registry = AGENT_SURFACE_REGISTRY } = {}) {
227
+ const entries = [];
228
+ for (const surface of registry) {
229
+ for (const transportId of surface.adapter?.transports ?? []) {
230
+ const host = BRIDGED_HOSTS.find((bridge) => bridge.transportId === transportId);
231
+ const attestation = UNAVAILABLE_TRANSPORTS.find((entry) =>
232
+ entry.surfaceId === surface.id && entry.transportId === transportId);
233
+ if ((host === undefined) === (attestation === undefined)) {
234
+ throw new Error(
235
+ `host bridge census is undecided for ${surface.id}/${transportId}`,
236
+ );
237
+ }
238
+ entries.push(Object.freeze(host
239
+ ? {
240
+ surfaceId: surface.id, transportId, status: 'bridged',
241
+ command: host.command, attestationStrength: host.attestationStrength,
242
+ reason: null, evidence: null, observedAt: null,
243
+ }
244
+ : {
245
+ surfaceId: surface.id, transportId, status: 'unavailable',
246
+ command: null, attestationStrength: null,
247
+ reason: attestation.reason,
248
+ evidence: attestation.evidence,
249
+ observedAt: attestation.observedAt,
250
+ }));
251
+ }
252
+ }
253
+ return Object.freeze(entries);
254
+ }
255
+
256
+ /**
257
+ * The dispatcher map a surface adapter consumes: one entry per bridged transport
258
+ * of that surface. An attested-unavailable transport gets no entry, so the
259
+ * adapter refuses it as a transport with no approved dispatcher.
260
+ *
261
+ * The map is bound to exactly one task text, because that text is handed to the
262
+ * child's stdin and never to argv.
263
+ */
264
+ export function createHostBridgeDispatchers(options) {
265
+ const context = bridgeContext(options);
266
+ const dispatchers = {};
267
+ for (const entry of hostTransportCensus({ registry: options.registry })) {
268
+ if (entry.surfaceId !== options.surfaceId || entry.status !== 'bridged') continue;
269
+ const host = BRIDGED_HOSTS.find((bridge) => bridge.transportId === entry.transportId);
270
+ dispatchers[entry.transportId] = async ({ route }) => {
271
+ try {
272
+ return await runBridgedDispatch(host, context, route);
273
+ } catch (error) {
274
+ throw redactBridgeError(error);
275
+ }
276
+ };
277
+ }
278
+ return Object.freeze(dispatchers);
279
+ }
280
+
281
+ /** A surface routing adapter whose approved dispatchers are the host bridges. */
282
+ export function createBridgedRoutingAdapter(options) {
283
+ const factory = ADAPTER_FACTORIES[options?.surfaceId];
284
+ if (factory === undefined) {
285
+ throw new Error('host bridge has no adapter for the agent surface');
286
+ }
287
+ return factory({
288
+ inventory: options.capabilityInventory,
289
+ dispatchers: createHostBridgeDispatchers(options),
290
+ });
291
+ }