ruvnet-brain 4.5.5 → 4.5.6

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 (30) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +24 -6
  3. package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
  4. package/config/model-router/claude-terminal-mod/README.md +22 -0
  5. package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
  6. package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
  7. package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
  8. package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
  9. package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
  10. package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
  11. package/config/model-router/policy.default.mjs +4 -1
  12. package/package.json +4 -3
  13. package/plugin/.claude-plugin/plugin.json +1 -1
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/mcp/managed-cli-interface.mjs +9 -4
  16. package/plugin/scripts/capability-claim-evidence.mjs +9 -2
  17. package/plugin/scripts/project-capture-queue.mjs +7 -1
  18. package/plugin/scripts/project-progression-hook.mjs +16 -7
  19. package/plugin/scripts/project-progression-outbox.mjs +96 -15
  20. package/plugin/scripts/project-progression-producer.mjs +12 -1
  21. package/plugin/scripts/project-progression-store.mjs +53 -1
  22. package/plugin/scripts/project-transition-hook.mjs +19 -8
  23. package/plugin/scripts/session-snapshot-hook.mjs +2 -1
  24. package/scripts/claude-terminal-mod.mjs +89 -0
  25. package/scripts/model-routing-gateway.mjs +47 -14
  26. package/scripts/model-routing-launchers.mjs +36 -1
  27. package/scripts/model-terminal-gateway.mjs +310 -0
  28. package/scripts/model-terminal-launchers.mjs +300 -0
  29. package/scripts/release-qualification-contract.mjs +10 -2
  30. package/scripts/release-qualification.mjs +9 -6
@@ -2,6 +2,66 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  const OUTBOX_NAME = 'project-progression-outbox.jsonl';
5
+ const READ_CHUNK_BYTES = 64 * 1024;
6
+
7
+ // Decode only complete records, so a multibyte character crossing reads stays intact.
8
+ function* readJsonl(file) {
9
+ let fd;
10
+ try { fd = fs.openSync(file, 'r'); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
11
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
12
+ let parts = [];
13
+ let line = 0;
14
+ const parse = (bytes, terminated) => {
15
+ if (!bytes.length) return undefined;
16
+ const text = bytes.toString('utf8');
17
+ if (!text.trim()) return undefined;
18
+ try { return JSON.parse(text); } catch {
19
+ if (!terminated) return undefined;
20
+ throw new Error(`malformed outbox record at line ${line}`);
21
+ }
22
+ };
23
+ try {
24
+ let length;
25
+ while ((length = fs.readSync(fd, chunk, 0, chunk.length, null))) {
26
+ let start = 0;
27
+ for (let end = 0; end < length; end += 1) {
28
+ if (chunk[end] !== 10) continue;
29
+ parts.push(Buffer.from(chunk.subarray(start, end)));
30
+ line += 1;
31
+ const record = parse(Buffer.concat(parts), true);
32
+ if (record !== undefined) yield record;
33
+ parts = []; start = end + 1;
34
+ }
35
+ if (start < length) parts.push(Buffer.from(chunk.subarray(start, length)));
36
+ }
37
+ if (parts.length) {
38
+ line += 1;
39
+ const record = parse(Buffer.concat(parts), false);
40
+ if (record !== undefined) yield record;
41
+ }
42
+ } finally { fs.closeSync(fd); }
43
+ }
44
+
45
+ // Inspect only the final record; preceding history is never decoded or copied.
46
+ function finalRecord(fd, size) {
47
+ const parts = [];
48
+ let position = size;
49
+ while (position > 0) {
50
+ const length = Math.min(READ_CHUNK_BYTES, position);
51
+ position -= length;
52
+ const chunk = Buffer.alloc(length);
53
+ let read = 0;
54
+ while (read < length) {
55
+ const count = fs.readSync(fd, chunk, read, length - read, position + read);
56
+ if (!count) throw new Error('outbox tail changed during inspection');
57
+ read += count;
58
+ }
59
+ const delimiter = chunk.lastIndexOf(10);
60
+ parts.unshift(delimiter < 0 ? chunk : chunk.subarray(delimiter + 1));
61
+ if (delimiter >= 0) break;
62
+ }
63
+ return Buffer.concat(parts).toString('utf8');
64
+ }
5
65
 
6
66
  function requireIdentity(value, label) {
7
67
  if (typeof value !== 'string' || !value) throw new TypeError(`${label} must be a non-empty string`);
@@ -31,8 +91,7 @@ export class ProgressionOutbox {
31
91
  const last = Buffer.alloc(1);
32
92
  fs.readSync(fd, last, 0, 1, size - 1);
33
93
  if (last[0] !== 10) {
34
- const content = fs.readFileSync(fd, 'utf8');
35
- try { JSON.parse(content.slice(content.lastIndexOf('\n') + 1)); }
94
+ try { JSON.parse(finalRecord(fd, size)); }
36
95
  catch { throw new Error('incomplete outbox final record; preserve and recover the torn tail before appending'); }
37
96
  separator = '\n';
38
97
  }
@@ -66,18 +125,23 @@ export class ProgressionOutbox {
66
125
  return this.appendRecord({ type: 'commit', ...receipt });
67
126
  }
68
127
 
69
- records() {
70
- if (!fs.existsSync(this.path)) return [];
71
- const content = fs.readFileSync(this.path, 'utf8');
72
- const lines = content.split('\n');
73
- if (lines.at(-1) === '') lines.pop();
74
- else {
75
- // A missing delimiter is not a missing record; only a crash-torn JSON suffix is incomplete.
76
- try { JSON.parse(lines.at(-1)); } catch { lines.pop(); }
128
+ /** Disposition of one frozen payload, never a commit for its conflicting original key. */
129
+ markRecovered(snapshot, receipt) {
130
+ for (const [label, value] of Object.entries({ eventKey: snapshot?.eventKey,
131
+ payloadDigest: snapshot?.payloadDigest, recoveryEventKey: receipt?.eventKey,
132
+ recoveryPayloadDigest: receipt?.payloadDigest, readbackDigest: receipt?.readbackDigest,
133
+ committedAt: receipt?.committedAt })) requireIdentity(value, label);
134
+ if (receipt.eventKey === snapshot.eventKey || receipt.readbackDigest !== receipt.payloadDigest) {
135
+ throw new Error('unverified recovery disposition');
77
136
  }
78
- return lines.filter(Boolean).map((line, index) => {
79
- try { return JSON.parse(line); } catch { throw new Error(`malformed outbox record at line ${index + 1}`); }
80
- });
137
+ return this.appendRecord({ type: 'recovery', eventKey: snapshot.eventKey,
138
+ payloadDigest: snapshot.payloadDigest, recoveryEventKey: receipt.eventKey,
139
+ recoveryPayloadDigest: receipt.payloadDigest, readbackDigest: receipt.readbackDigest,
140
+ committedAt: receipt.committedAt });
141
+ }
142
+
143
+ records() {
144
+ return [...readJsonl(this.path)];
81
145
  }
82
146
 
83
147
  /**
@@ -92,7 +156,8 @@ export class ProgressionOutbox {
92
156
  const snapshots = new Map();
93
157
  const committed = new Map();
94
158
  const quarantined = new Map();
95
- for (const record of this.records()) {
159
+ const recoveries = [];
160
+ for (const record of readJsonl(this.path)) {
96
161
  requireIdentity(record?.eventKey, 'outbox eventKey');
97
162
  requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
98
163
  if (record.type === 'snapshot') {
@@ -103,6 +168,13 @@ export class ProgressionOutbox {
103
168
  const prior = committed.get(record.eventKey);
104
169
  if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
105
170
  committed.set(record.eventKey, record.payloadDigest);
171
+ } else if (record.type === 'recovery') {
172
+ requireIdentity(record.recoveryEventKey, 'recovery event key');
173
+ requireIdentity(record.recoveryPayloadDigest, 'recovery payload digest');
174
+ if (record.eventKey === record.recoveryEventKey || record.readbackDigest !== record.recoveryPayloadDigest) {
175
+ throw new Error('unverified recovery disposition');
176
+ }
177
+ recoveries.push(record);
106
178
  } else {
107
179
  throw new Error('unsupported outbox record');
108
180
  }
@@ -113,9 +185,18 @@ export class ProgressionOutbox {
113
185
  quarantined.set(record.eventKey, 'outbox commit digest mismatch');
114
186
  }
115
187
  }
188
+ const recovered = new Set(recoveries.filter((record) => {
189
+ const target = snapshots.get(record.recoveryEventKey)?.snapshot;
190
+ return !quarantined.has(record.recoveryEventKey)
191
+ && committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
192
+ && target?.payloadDigest === record.recoveryPayloadDigest
193
+ && target.recoveryDiagnostics?.originalEventKey === record.eventKey
194
+ && target.recoveryDiagnostics?.frozenPayloadDigest === record.payloadDigest;
195
+ }).map((record) => `${record.eventKey}:${record.payloadDigest}`));
116
196
  this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
117
197
  return [...snapshots.values()]
118
- .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey))
198
+ .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
199
+ && !recovered.has(`${record.eventKey}:${record.payloadDigest}`))
119
200
  .sort((left, right) => left.eventKey.localeCompare(right.eventKey))
120
201
  .map((record) => record.snapshot);
121
202
  }
@@ -29,6 +29,7 @@
29
29
  * within those bounds.
30
30
  */
31
31
  import crypto from 'node:crypto';
32
+ import { enrichStateWithObservation } from './project-progression-hook.mjs';
32
33
  import fs from 'node:fs';
33
34
  import path from 'node:path';
34
35
  import { digestCanonical, fieldAuthorityAllows, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
@@ -250,6 +251,16 @@ export function buildProjectProgression({
250
251
  completeProjectState,
251
252
  });
252
253
  const projectProgression = redactedProgression;
254
+ // Compare the same redacted observation the native writer will append, without returning it
255
+ // twice. Tool input and terminal evidence must participate before the no-op decision.
256
+ const observedState = redactProgression(enrichStateWithObservation(
257
+ projectProgression.completeProjectState, { ...payload, hook_event_name: trigger },
258
+ )).value;
259
+ // A committed sequence is not an event identity: several boundaries may freeze before replay.
260
+ // Bind all immutable captured fields (including the native observation and occurrence time).
261
+ projectProgression.dedupId += `:${digestCanonical({
262
+ ...projectProgression, completeProjectState: observedState,
263
+ })}`;
253
264
 
254
265
  // RETENTION (ADR-073). Three capture boundaries per session times every session is unbounded
255
266
  // growth unless a capture that changes nothing writes nothing. Compare what a snapshot MEANS —
@@ -257,7 +268,7 @@ export function buildProjectProgression({
257
268
  // differ (sequence, timestamp, dedup id, the trigger that happens to be firing, and the evidence
258
269
  // block's own timestamps), because comparing those would make every capture look novel.
259
270
  const meaning = digestCanonical({
260
- state: { ...projectProgression.completeProjectState, activeStep: null, evidence: null },
271
+ state: { ...observedState, activeStep: null, evidence: null },
261
272
  source: projectProgression.sourceIdentity,
262
273
  });
263
274
  const priorMeaning = heads.length === 1 ? digestCanonical({
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { ProgressionOutbox } from './project-progression-outbox.mjs';
7
7
  import {
8
+ createProgressionSnapshot,
8
9
  digestCanonical,
9
10
  restoreProjectProgression,
10
11
  validateProgressionSnapshot,
@@ -87,10 +88,26 @@ export function projectResumePayloadToBound(payload, maxOutputBytes) {
87
88
  });
88
89
  }
89
90
 
91
+ if (size() > maxOutputBytes && Array.isArray(summary.state?.resumeConflicts)) {
92
+ const groups = new Map();
93
+ for (const conflict of payload.state.resumeConflicts) {
94
+ if (!groups.has(conflict.field)) groups.set(conflict.field, []);
95
+ groups.get(conflict.field).push(conflict);
96
+ }
97
+ if (groups.size < payload.state.resumeConflicts.length) {
98
+ summary.state.resumeConflicts = [...groups].map(([field, conflicts]) => ({
99
+ field, conflictCount: conflicts.length,
100
+ valueCount: conflicts.reduce((count, conflict) => count + (conflict.values ?? []).length, 0),
101
+ conflictsDigest: digestCanonical(conflicts),
102
+ }));
103
+ omissions.push({ path: 'state.resumeConflicts', ...omissionSummary(payload.state.resumeConflicts) });
104
+ }
105
+ }
106
+
90
107
  if (size() > maxOutputBytes) recordOmission(summary.state, 'journalHeads', 'state.journalHeads');
91
108
  // Least central detail first. currentGoal and nextAction are deliberately absent from this list.
92
109
  const stateFields = [
93
- 'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
110
+ 'observations', 'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
94
111
  'plan', 'inProgress', 'blockers', 'failures', 'acceptanceContract', 'provenance',
95
112
  'evidence', 'activeStep', 'activeProcess', 'sourceIdentity',
96
113
  ];
@@ -450,6 +467,41 @@ export class ProjectProgressionStore {
450
467
  return receipt;
451
468
  }
452
469
 
470
+ /** Frozen queue recovery never rewrites the conflicting row or marks its key committed. */
471
+ captureFrozen(snapshot, { canCommit } = {}) {
472
+ if (typeof canCommit !== 'function') throw new Error('frozen recovery requires replay fencing');
473
+ const fenced = () => { if (!canCommit()) throw new Error('progression recovery lost replay fencing'); };
474
+ fenced();
475
+ try { return { snapshot, receipt: this.capture(snapshot, { onPhase: fenced }) }; } catch (error) {
476
+ if (error.message !== 'progression readback digest mismatch') throw error;
477
+ }
478
+ // An independently retrieved exact canonical value must be valid for this project. A failed,
479
+ // absent, malformed or foreign read is never evidence that the immutable key collided.
480
+ const exact = this.retrieveSnapshots([snapshot.eventKey]);
481
+ if (exact.rejected.length || exact.snapshots.length !== 1) throw new Error('immutable collision is not verified');
482
+ const existing = exact.snapshots[0];
483
+ this.validateSnapshot(existing);
484
+ if (existing.eventKey !== snapshot.eventKey || existing.payloadDigest === snapshot.payloadDigest) {
485
+ throw new Error('immutable collision is not verified');
486
+ }
487
+ const identity = { originalEventKey: snapshot.eventKey,
488
+ frozenPayloadDigest: snapshot.payloadDigest, existingPayloadDigest: existing.payloadDigest };
489
+ const created = createProgressionSnapshot({ ...snapshot,
490
+ dedupId: `immutable-recovery:${digestCanonical(identity)}` });
491
+ // Preserve the frozen state and source exactly; diagnostics carry no instruction authority.
492
+ const { payloadDigest: _digest, ...body } = created;
493
+ body.redactions = snapshot.redactions;
494
+ body.recoveryDiagnostics = { kind: 'immutable-event-key-collision', authoritative: false, ...identity };
495
+ const recovered = Object.freeze({ ...body, payloadDigest: digestCanonical(body) });
496
+ this.validateSnapshot(recovered);
497
+ this.requireCaptureConsent(recovered);
498
+ fenced();
499
+ const receipt = this.capture(recovered, { onPhase: fenced });
500
+ fenced();
501
+ this.outbox.markRecovered(snapshot, receipt);
502
+ return { snapshot: recovered, receipt };
503
+ }
504
+
453
505
  replay() {
454
506
  const receipts = [];
455
507
  for (const snapshot of this.outbox.pendingSnapshots()) {
@@ -75,20 +75,25 @@ export function normalizeTransition(payload, event, { now = () => new Date().toI
75
75
  const toolInput = input.tool_input ?? {};
76
76
  const response = input.tool_response && typeof input.tool_response === 'object' ? input.tool_response : {};
77
77
  const exitCode = [response.exit_code, response.exitCode, response.status].find(Number.isSafeInteger);
78
- const failure = event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
79
- const interrupted = response.interrupted === true || response.signal === 'SIGINT';
78
+ const error = typeof response.error === 'string' && response.error ? redactText(response.error).slice(0, 4096) : undefined;
79
+ const signal = typeof response.signal === 'string' && response.signal ? redactText(response.signal).slice(0, 100) : undefined;
80
+ const failure = Boolean(error) || event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
81
+ const interrupted = response.interrupted === true || Boolean(signal);
82
+ const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(response.outcome) ? response.outcome : null;
80
83
  // Measured native Claude Bash PostToolUse has no exit code. Its completion envelope
81
84
  // is host-reported success, never an inferred exit-code zero. Other hosts stay unknown.
82
85
  const claudeBashCompletion = host === 'claude' && input.tool_name === 'Bash' && event === 'PostToolUse'
83
86
  && typeof response.stdout === 'string' && typeof response.stderr === 'string'
84
87
  && response.interrupted === false && typeof response.isImage === 'boolean'
85
88
  && typeof response.noOutputExpected === 'boolean';
86
- const outcome = event === 'PreToolUse' ? 'pending' : interrupted ? 'interrupted' : failure ? 'failure'
87
- : Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown';
89
+ const outcome = event === 'PreToolUse' ? 'pending' : error ? 'failure' : interrupted ? 'interrupted' : failure ? 'failure'
90
+ : declaredOutcome || (Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown');
88
91
  return { ...common, kind: 'tool-observation', tool: String(input.tool_name).split('__').at(-1).slice(0, 100),
89
92
  intent: semanticIntent(toolInput.description ?? toolInput.command ?? toolInput.cmd ?? toolInput.file_path), outcome,
90
93
  ...(outcome === 'success' && claudeBashCompletion ? { outcomeEvidence: 'claude-bash-completion' } : {}),
91
- ...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : {}) };
94
+ ...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : event !== 'PreToolUse' && (response.exit_code === null || response.exitCode === null) ? { exitCode: null } : {}),
95
+ ...(event !== 'PreToolUse' && error ? { error } : {}),
96
+ ...(event !== 'PreToolUse' && signal ? { signal } : {}) };
92
97
  }
93
98
 
94
99
  export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}) {
@@ -107,6 +112,11 @@ export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}
107
112
  export function buildTransitionProgression({ resolution, observation, snapshots = [], sessionIdentity, host, sourceIdentity: originalSourceIdentity }) {
108
113
  const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
109
114
  if (snapshots.length && !restored.ok) throw new Error('nonempty transition journal has no coherent ancestry');
115
+ return buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored);
116
+ }
117
+
118
+ // Only the validated result computed in this module can reach this private builder.
119
+ function buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored) {
110
120
  const heads = snapshots.filter((snapshot) => restored.heads.includes(snapshot.eventKey));
111
121
  const prior = restored.state;
112
122
  const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map((key) => [key, []]));
@@ -145,7 +155,8 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
145
155
  if (!normalized || normalized.observation?.authoritative !== false || !normalized.observation?.id
146
156
  || normalized.sourceIdentity?.checkoutPath !== resolution.checkoutRoot) throw new Error('invalid normalized transition binding');
147
157
  const snapshots = readHistory(resolution, { deadlineAt });
148
- if (snapshots.length && !restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity }).ok) {
158
+ const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
159
+ if (snapshots.length && !restored.ok) {
149
160
  throw new Error('nonempty transition journal has no coherent ancestry');
150
161
  }
151
162
  // A failed first store already fsynced a snapshot to the outbox. If startup/replay committed
@@ -159,9 +170,9 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
159
170
  if (committed) return { progressionCaptured: true, eventId: normalized.observation.id,
160
171
  receipt: { eventKey: committed.eventKey, payloadDigest: committed.payloadDigest,
161
172
  readbackDigest: committed.payloadDigest, readPath: 'canonical progression reader' } };
162
- const progression = buildTransitionProgression({ resolution, observation: normalized.observation,
173
+ const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
163
174
  snapshots, sessionIdentity: job.payload.session_id, host: job.host,
164
- sourceIdentity: normalized.sourceIdentity });
175
+ sourceIdentity: normalized.sourceIdentity }, restored);
165
176
  const result = capture(job.originProjectDir, job.event, { host: job.host, budgetMs: Math.max(0, deadlineAt - now()),
166
177
  ...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
167
178
  rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
@@ -163,7 +163,8 @@ export function runSessionSnapshotHook(projectDir, event, {
163
163
  if (payload.hook_event_name !== event) {
164
164
  throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
165
165
  }
166
- const result = captureProgression({ host, payload, projectDir, storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
166
+ const result = captureProgression({ host, payload, projectDir, recoverFrozen: Boolean(ordered),
167
+ canCommit: () => Boolean(ordered) && refreshReplayLock(resolveProjectStore({ projectDir }).projectRoot, ordered), storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
167
168
  return { ...idle, progressionCaptured: true, receipt: result.receipt };
168
169
  }
169
170
 
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // Materialize the sandboxed native mod and bridge it to the existing reviewed policy engine.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { classify } from '../config/model-router/policy.default.mjs';
9
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ export const MOD_SOURCE = path.join(ROOT, 'config/model-router/claude-terminal-mod');
11
+ export function terminalModDigest(pluginRoot) {
12
+ const hash = crypto.createHash('sha256');
13
+ for (const relative of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js', 'hooks/policy.default.mjs', 'hooks/runtime.js']) {
14
+ hash.update(relative); hash.update('\0'); hash.update(fs.readFileSync(path.join(pluginRoot, relative))); hash.update('\0');
15
+ }
16
+ return hash.digest('hex');
17
+ }
18
+ export function prepareClaudeTerminalMod({ destination, nodePath = process.execPath,
19
+ helperPath = fileURLToPath(import.meta.url), enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs') } = {}) {
20
+ if (!destination || !path.isAbsolute(destination)) throw new Error('Absolute mod destination required');
21
+ for (const file of [nodePath, helperPath, enginePath]) if (!path.isAbsolute(file) || !fs.existsSync(file)) throw new Error('Native mod dependency unavailable');
22
+ fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
23
+ for (const part of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js']) {
24
+ const dest = path.join(destination, part);
25
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
26
+ fs.copyFileSync(path.join(MOD_SOURCE, part), dest);
27
+ }
28
+ const policyPath = path.join(destination, 'hooks/policy.default.mjs');
29
+ fs.copyFileSync(path.join(ROOT, 'config/model-router/policy.default.mjs'), policyPath);
30
+ fs.writeFileSync(path.join(destination, 'hooks/runtime.js'), `export default ${JSON.stringify({ nodePath, helperPath, enginePath, policyPath })};\n`, { mode: 0o600 });
31
+ return { pluginDir: destination, policyPath, modDigest: terminalModDigest(destination) };
32
+ }
33
+ export function validateDecision(decision, now = Date.now()) {
34
+ const age = now - Date.parse(decision?.selectionReviewedAt);
35
+ const maxAge = decision?.selectionMaxAgeMs;
36
+ if (decision?.harness !== 'claude-code' || decision.provider !== 'anthropic' || decision.subscriptionCovered !== true ||
37
+ !/^claude-[a-z0-9][a-z0-9.-]*$/.test(decision.model || '') || !['fast', 'medium', 'hard'].includes(decision.taskClass) ||
38
+ !['low', 'medium', 'high', 'xhigh', 'max'].includes(decision.effort) ||
39
+ !Number.isSafeInteger(maxAge) || maxAge <= 0 || maxAge > 604800000 || !Number.isFinite(age) || age < 0 ||
40
+ !/^[a-f0-9]{64}$/.test(decision.selectionRouteDigest || '')) throw new Error('Native terminal routing decision invalid; no fallback');
41
+ return decision;
42
+ }
43
+ export function routeNativePrompt({ prompt, turnId, minimumClass, enginePath = path.join(ROOT, 'scripts/model-router-engine.mjs'),
44
+ policyPath = path.join(ROOT, 'config/model-router/policy.default.mjs'), env = process.env } = {}) {
45
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 200000) throw new Error('Native routing requires a bounded text prompt');
46
+ if (minimumClass !== undefined && !['fast', 'medium', 'hard'].includes(minimumClass)) throw new Error('Invalid class floor');
47
+ const codeFences = Math.floor((prompt.match(/```/g) || []).length / 2);
48
+ const hasCode = codeFences > 0 || /\b(function|const|let|def|class|import|=>|SELECT|async)\b/.test(prompt) || /[{};]\s*$/m.test(prompt);
49
+ const rank = { fast: 0, medium: 1, hard: 2 };
50
+ const canonicalFloor = classify({ taskHints: prompt, hasCode }, 'claude-code');
51
+ const floor = rank[minimumClass] > rank[canonicalFloor] ? minimumClass : canonicalFloor;
52
+ const classificationPrompt = floor === 'hard' ? 'final substantive review\n' + prompt : prompt;
53
+ // Let the engine honor the user's policy.mjs; the bundled copy is only the
54
+ // deterministic classification floor used by the sandboxed hook.
55
+ const result = spawnSync(process.execPath, [enginePath, '--harness', 'claude-code', '--request-json', '--policy-only', '--json'],
56
+ { input: JSON.stringify({ prompt: classificationPrompt }), encoding: 'utf8', env, timeout: 8000, maxBuffer: 262144 });
57
+ if (result.error || result.status !== 0) throw new Error('Reviewed native routing policy unavailable; no fallback');
58
+ const d = validateDecision(JSON.parse(result.stdout));
59
+ if (rank[d.taskClass] < rank[floor]) throw new Error('Reviewed native routing policy below canonical floor; no fallback');
60
+ // No prompt, reasoning, credentials, or arbitrary engine output crosses back to the sandbox.
61
+ return { schemaVersion: 1, turnId: typeof turnId === 'string' ? turnId : null, model: d.model, effort: d.effort,
62
+ taskClass: d.taskClass, expiresAt: Date.parse(d.selectionReviewedAt) + d.selectionMaxAgeMs,
63
+ subscriptionCovered: true, routeDigest: d.selectionRouteDigest };
64
+ }
65
+ export function writeReadinessReceipt({ nonce, receiptPath, pluginRoot, version, sessionId, status = 'ready' } = {}) {
66
+ if (!/^[a-f0-9]{64}$/.test(nonce || '') || !path.isAbsolute(receiptPath || '') ||
67
+ !path.isAbsolute(pluginRoot || '') || (!/^2\.1\.\d+$/.test(version || '') || Number(version.split('.')[2]) < 287) || typeof sessionId !== 'string' ||
68
+ !['ready', 'health'].includes(status)) throw new Error('Invalid native mod readiness request');
69
+ const receipt = { schemaVersion: 1, nonce, pluginRoot: fs.realpathSync(pluginRoot), nativeVersion: version,
70
+ sessionId, status, modDigest: terminalModDigest(pluginRoot), observedAt: new Date().toISOString(), pid: process.ppid,
71
+ scope: 'hook activation only; native load failure or worker crash can skip hooks' };
72
+ const parent = path.dirname(receiptPath);
73
+ if (!fs.existsSync(parent)) throw new Error('Readiness directory must be prepared by launcher');
74
+ const temporary = path.join(parent, `.rnb-ready-${crypto.randomBytes(16).toString('hex')}`);
75
+ fs.writeFileSync(temporary, JSON.stringify(receipt), { mode: 0o600, flag: 'wx' });
76
+ fs.renameSync(temporary, receiptPath);
77
+ return receipt;
78
+ }
79
+ async function main() {
80
+ const mode = process.argv[2];
81
+ const request = JSON.parse(fs.readFileSync(0, 'utf8'));
82
+ if (mode === '--decision') process.stdout.write(JSON.stringify(routeNativePrompt(request)) + '\n');
83
+ else if (mode === '--ready' || mode === '--health') process.stdout.write(JSON.stringify(writeReadinessReceipt({ ...request, status: mode === '--ready' ? 'ready' : 'health' })) + '\n');
84
+ else if (mode === '--prepare') process.stdout.write(JSON.stringify(prepareClaudeTerminalMod(request)) + '\n');
85
+ else throw new Error('Expected --prepare, --decision, --ready, or --health');
86
+ }
87
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(() => {
88
+ process.stderr.write('claude-terminal-mod: routing or activation unavailable; no fallback\n'); process.exitCode = 1;
89
+ });
@@ -15,6 +15,21 @@ import { subscriptionEnvironment, assertSubscriptionAuth, validateDispatchDecisi
15
15
  const HERE = path.dirname(fileURLToPath(import.meta.url));
16
16
  const REFUSED = 'Current reviewed native model/effort allocation unavailable; new turn was not forwarded.';
17
17
  const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
18
+ // Native inference surfaces without qualified execution-time routing are denied.
19
+ const UNQUALIFIED = new Set(['review/start', 'thread/queue/add', 'thread/queue/update', 'thread/queue/start',
20
+ 'thread/realtime/start', 'thread/realtime/appendAudio', 'thread/realtime/appendSpeech', 'thread/realtime/appendText',
21
+ 'thread/goal/set', 'thread/goal/create', 'thread/goal/resume', 'thread/compact/start', 'turn/addUserMessage', 'thread/startAeon']);
22
+
23
+ function canonicalCodexConfig(config) {
24
+ if (!config || (config.model_provider ?? 'openai') !== 'openai') return false;
25
+ // Native 0.160 gives serviceTierForTurn precedence over daemon/thread Fast defaults.
26
+ // routeCodexTurn supplies both standard overrides; provider/auth checks remain mandatory.
27
+ // A provider with the built-in name can still be replaced by a custom endpoint.
28
+ if (config.model_providers && Object.hasOwn(config.model_providers, 'openai') && config.model_providers.openai != null) return false;
29
+ if (config.chatgpt_base_url != null && config.chatgpt_base_url !== 'https://chatgpt.com/backend-api/') return false;
30
+ // Native Config serializes openai_base_url:null when the override is unset.
31
+ return !Object.entries(config).some(([key, value]) => value != null && /^(model_providers\.openai|base_url|baseUrl|openai_base_url)(\.|$)/.test(key));
32
+ }
18
33
 
19
34
  /** Fresh policy-only subprocess: prompt stays on stdin, never argv, receipts or diagnostics. */
20
35
  export function decideNativeTurn(prompt, harness, { env = process.env, spawnEngine = spawn, timeoutMs = 4000, multimodal = false } = {}) {
@@ -107,9 +122,9 @@ function unsafeSettings(value, prefix = '', routing = false) {
107
122
  if (!value || typeof value !== 'object') return false;
108
123
  return Object.entries(value).some(([key, entry]) => {
109
124
  const name = prefix ? `${prefix}.${key}` : key;
110
- if (/^(model_providers|apiKeyHelper|api_key|apiKey|base_url|baseUrl|auth_token|authToken|customHeaders|env|modelSettings|alwaysThinkingEnabled|maxEffortLevel)(\.|$)/.test(name)) return true;
125
+ if (/^(model_providers|apiKeyHelper|api_key|apiKey|base_url|baseUrl|openai_base_url|chatgpt_base_url|auth_token|authToken|customHeaders|env|modelSettings|alwaysThinkingEnabled|maxEffortLevel)(\.|$)/.test(name)) return true;
111
126
  if (/^(model_provider|modelProvider)$/.test(name) && entry !== 'openai') return true;
112
- if (/^(service_tier|serviceTier)$/.test(name) && entry != null && entry !== 'default') return true;
127
+ if (/^(service_tier|serviceTier|service_tier_for_turn|serviceTierForTurn)$/.test(name) && entry != null && entry !== 'default') return true;
113
128
  if (/^(features\.fast_mode|fastMode)$/.test(name) && entry !== false) return true;
114
129
  if (routing && /^(model|effort|effortLevel|reasoning_effort|collaborationMode)(\.|$)/.test(name)) return true;
115
130
  return unsafeSettings(entry, name, routing);
@@ -123,7 +138,7 @@ export function verifyNativeVision(decision, models = loadNativeCodexModels()) {
123
138
 
124
139
  /** Pure native override: preserve every context, tool, approval and collaboration instruction. */
125
140
  export function routeCodexTurn(message, decision) {
126
- const params = { ...message.params, model: decision.model, effort: decision.effort, serviceTier: 'default' };
141
+ const params = { ...message.params, model: decision.model, effort: decision.effort, serviceTier: 'default', serviceTierForTurn: 'default' };
127
142
  if (params.collaborationMode) params.collaborationMode = { ...params.collaborationMode,
128
143
  settings: { ...params.collaborationMode.settings, model: decision.model, reasoning_effort: decision.effort } };
129
144
  return { ...message, params };
@@ -135,7 +150,7 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
135
150
  receipt = appendGatewayReceipt, timeoutMs = 5000, maxDeferredBytes = 64 * 1024 * 1024, tempRoot = os.tmpdir(), now = () => new Date().toISOString() } = {}) {
136
151
  const nonce = crypto.randomUUID();
137
152
  const pending = new Map(), initIds = new Set(), ownIds = new Set(), held = new Set();
138
- const accepted = new Map(), starts = new Map(), inactiveWaiters = new Set();
153
+ const accepted = new Map(), starts = new Map(), inactiveWaiters = new Set(), boundedWaiters = new Set();
139
154
  let serial = Promise.resolve(), ready = harness === 'codex', active = false, closed = false, serialNumber = 0;
140
155
  let readiness = null, resolveReady;
141
156
  let queuedBytes = 0, spoolDirectory = null, spoolSequence = 0, declineUsers = false;
@@ -218,9 +233,10 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
218
233
  };
219
234
  function bounded(work) {
220
235
  return new Promise((resolve, reject) => {
221
- const timer = setTimeout(() => reject(new Error(REFUSED)), timeoutMs);
222
- Promise.resolve(work).then((value) => { clearTimeout(timer); resolve(value); },
223
- (error) => { clearTimeout(timer); reject(error); });
236
+ const finish = (error, value) => { clearTimeout(timer); boundedWaiters.delete(cancel); error ? reject(error) : resolve(value); };
237
+ const cancel = () => finish(new Error(REFUSED));
238
+ const timer = setTimeout(cancel, timeoutMs); boundedWaiters.add(cancel);
239
+ Promise.resolve(work).then((value) => finish(null, value), (error) => finish(error));
224
240
  });
225
241
  }
226
242
  const control = (request) => new Promise((resolve, reject) => {
@@ -262,7 +278,7 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
262
278
  if (harness === 'codex') {
263
279
  if (message.method !== 'turn/start') {
264
280
  const pair = accepted.get(message.params?.threadId);
265
- if (!pair || pair.model !== decision.model || pair.effort !== decision.effort) {
281
+ if (!pair || pair.turnId !== message.params?.expectedTurnId || pair.model !== decision.model || pair.effort !== decision.effort) {
266
282
  fail(message, 'Input not submitted: requires a new turn with reviewed allocation; current work continues.'); return;
267
283
  }
268
284
  record('active-input-approved', decision, false, { evidence: 'native-turn-start.accepted-configured-pair' });
@@ -271,6 +287,14 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
271
287
  const account = await control({ method: 'account/read', params: { refreshToken: false } });
272
288
  assertLive();
273
289
  if (account?.account?.type !== 'chatgpt') throw new Error(REFUSED);
290
+ const thread = await control({ method: 'thread/read', params: { threadId: message.params?.threadId, includeTurns: false } });
291
+ assertLive();
292
+ if (thread?.thread?.id !== message.params?.threadId || thread.thread.modelProvider !== 'openai') throw new Error(REFUSED);
293
+ const cwd = message.params?.cwd ?? thread.thread.cwd;
294
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) throw new Error(REFUSED);
295
+ const effective = await control({ method: 'config/read', params: { includeLayers: false, cwd } });
296
+ assertLive();
297
+ if (!canonicalCodexConfig(effective?.config)) throw new Error(REFUSED);
274
298
  const allowance = await control({ method: 'account/rateLimits/read', params: { excludeResetCreditDetails: true, supportsLunaReserve: false } });
275
299
  assertLive();
276
300
  if (allowance?.ordinaryUsageAllowed !== true) throw new Error(REFUSED);
@@ -310,10 +334,14 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
310
334
  stream.on('end', () => { buffer += decoder.end(); if (buffer) handler(buffer); });
311
335
  };
312
336
  listenLines(input, (line) => {
337
+ if (closed) return;
313
338
  let message;
314
339
  try { message = JSON.parse(line); } catch { if (!closed) writeHost(line); return; }
315
340
  if (harness === 'claude-code' && message.type === 'control_request' && message.request?.subtype === 'initialize') initIds.add(message.request_id);
316
- const routable = harness === 'codex' ? ['turn/start', 'turn/steer', 'thread/queue/add'].includes(message.method) : message.type === 'user';
341
+ if (harness === 'codex' && UNQUALIFIED.has(message.method)) {
342
+ fail(message, 'Native inference method is not qualified for gateway routing; request not forwarded.'); return;
343
+ }
344
+ const routable = harness === 'codex' ? ['turn/start', 'turn/steer'].includes(message.method) : message.type === 'user';
317
345
  if (routable && harness === 'claude-code' && (declineUsers || queuedBytes + Buffer.byteLength(line) > maxDeferredBytes)) {
318
346
  declineUsers = true;
319
347
  const reason = 'Deferred input capacity (64 MiB) exceeded; new user packet not submitted; current work continues.';
@@ -334,9 +362,11 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
334
362
  let unsafe = false;
335
363
  if (harness === 'codex') {
336
364
  const params = message.params || {};
337
- unsafe = unsafeSettings(params.config) || unsafeSettings({ modelProvider: params.modelProvider ?? 'openai', serviceTier: params.serviceTier ?? 'default' });
365
+ unsafe = unsafeSettings(params.config) || unsafeSettings({ modelProvider: params.modelProvider ?? 'openai',
366
+ serviceTier: params.serviceTier, service_tier: params.service_tier,
367
+ serviceTierForTurn: params.serviceTierForTurn, service_tier_for_turn: params.service_tier_for_turn });
338
368
  if (message.method === 'account/login/start' && !['chatgpt', 'chatgptDeviceCode', 'chatgptAuthTokens'].includes(params.type)) unsafe = true;
339
- if (message.method === 'thread/settings/update') unsafe ||= unsafeSettings(params, '', true);
369
+ if (['thread/settings/update', 'turn/settings/update'].includes(message.method)) unsafe ||= unsafeSettings(params, '', true);
340
370
  const edits = message.method === 'config/value/write' ? [params] : message.method === 'config/batchWrite' ? params.edits || [] : [];
341
371
  if (message.method === 'config/batchWrite' && params.reloadUserConfig === true) unsafe = true;
342
372
  unsafe ||= edits.some((edit) => unsafeSettings({ [edit.keyPath]: edit.value }, '', true));
@@ -366,9 +396,9 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
366
396
  }
367
397
  if (starts.has(id)) {
368
398
  const pair = starts.get(id); starts.delete(id);
369
- if (!message.error && message.result?.turn) accepted.set(pair.threadId, pair);
399
+ if (!message.error && typeof message.result?.turn?.id === 'string' && message.result.turn.id) accepted.set(pair.threadId, { ...pair, turnId: message.result.turn.id });
370
400
  }
371
- if (message.method === 'turn/completed') accepted.delete(message.params?.threadId);
401
+ if (message.method === 'turn/completed' && accepted.get(message.params?.threadId)?.turnId === message.params?.turn?.id) accepted.delete(message.params.threadId);
372
402
  if (ownIds.has(id)) return; // late own-control replies never escape into the host client
373
403
  if (initIds.has(id) && message.response?.subtype === 'success') {
374
404
  ready = true; active = message.response.response?.session_state && message.response.response.session_state !== 'idle'; resolveReady?.();
@@ -379,7 +409,9 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
379
409
  });
380
410
  child.stderr?.on('data', (chunk) => diagnostics.write(chunk));
381
411
  const close = () => {
412
+ if (closed) return;
382
413
  closed = true; hostWriter.close(); inactiveWaiters.forEach((resolve) => resolve()); inactiveWaiters.clear();
414
+ boundedWaiters.forEach((cancel) => cancel()); boundedWaiters.clear(); accepted.clear(); starts.clear();
383
415
  for (const token of [...held]) { token.cancelled = true; retire(token); }
384
416
  if (spoolDirectory) fs.rmSync(spoolDirectory, { recursive: true, force: true });
385
417
  spoolKey.fill(0);
@@ -387,7 +419,8 @@ export function connectNativeGateway({ harness, child, input, output, diagnostic
387
419
  pending.clear();
388
420
  };
389
421
  child.once('error', close); child.once('exit', close); child.stdin.on('error', close); child.stdout.on('error', close);
390
- input.on('end', () => { serial.finally(async () => { await hostWriter.idle(); if (!closed) child.stdin.end(); }); });
422
+ const disconnect = () => { close(); if (!child.stdin.destroyed) child.stdin.end(); };
423
+ input.on('end', disconnect); input.on('error', disconnect); input.on('close', disconnect);
391
424
  return { idle: () => serial, close, spoolState: () => ({ directory: spoolDirectory, queuedBytes }) };
392
425
  }
393
426