ruvnet-brain 4.5.5 → 4.5.7

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 (43) 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/kb/forge-update.mjs +50 -9
  13. package/package.json +4 -3
  14. package/plugin/.claude-plugin/plugin.json +1 -1
  15. package/plugin/.codex-plugin/plugin.json +1 -1
  16. package/plugin/commands/configure.md +13 -1
  17. package/plugin/hooks/hook-contracts.json +1 -1
  18. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  19. package/plugin/mcp/managed-cli-interface.mjs +45 -16
  20. package/plugin/mcp/server.mjs +3 -2
  21. package/plugin/scripts/capability-claim-evidence.mjs +9 -2
  22. package/plugin/scripts/continuation-gate.mjs +3 -1
  23. package/plugin/scripts/continuity-journal.mjs +11 -11
  24. package/plugin/scripts/hook-shim.mjs +2 -1
  25. package/plugin/scripts/kb-copy-proof.mjs +4 -3
  26. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  27. package/plugin/scripts/project-capture-queue.mjs +23 -8
  28. package/plugin/scripts/project-progression-hook.mjs +16 -7
  29. package/plugin/scripts/project-progression-outbox.mjs +116 -19
  30. package/plugin/scripts/project-progression-producer.mjs +12 -1
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-store.mjs +53 -1
  33. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  34. package/plugin/scripts/project-transition-hook.mjs +44 -16
  35. package/plugin/scripts/session-snapshot-hook.mjs +16 -8
  36. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  37. package/scripts/claude-terminal-mod.mjs +89 -0
  38. package/scripts/model-routing-gateway.mjs +47 -14
  39. package/scripts/model-routing-launchers.mjs +36 -1
  40. package/scripts/model-terminal-gateway.mjs +310 -0
  41. package/scripts/model-terminal-launchers.mjs +300 -0
  42. package/scripts/release-qualification-contract.mjs +37 -6
  43. package/scripts/release-qualification.mjs +9 -6
@@ -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
 
@@ -9,6 +9,7 @@ import os from 'node:os';
9
9
  import crypto from 'node:crypto';
10
10
  import { spawn } from 'node:child_process';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { createRequire } from 'node:module';
12
13
 
13
14
  const SELF = fileURLToPath(import.meta.url);
14
15
  const sha = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
@@ -90,6 +91,27 @@ export function runtimeSnapshot(sourceRoot) {
90
91
  }
91
92
  };
92
93
  for (const relative of ['scripts/model-routing-gateway.mjs', 'scripts/model-router-engine.mjs', 'scripts/metaharness-router.mjs']) visit(relative);
94
+ for (const relative of ['scripts/model-terminal-gateway.mjs', 'scripts/claude-terminal-mod.mjs', 'scripts/model-terminal-launchers.mjs']) {
95
+ if (fs.existsSync(path.join(root, relative))) visit(relative);
96
+ }
97
+ const mod = 'config/model-router/claude-terminal-mod';
98
+ if (fs.existsSync(path.join(root, mod))) {
99
+ for (const relative of ['.claude-plugin/plugin.json', 'hooks/hooks.json', 'hooks/register.js', 'hooks/routing.js', 'hooks/runtime.js']) visit(`${mod}/${relative}`);
100
+ }
101
+ // The socket adapter must resolve its transport from the same immutable closure.
102
+ // Optional native accelerators are deliberately excluded; ws supports pure JS.
103
+ if (files.has('scripts/model-terminal-gateway.mjs')) {
104
+ const require = createRequire(path.join(root, 'package.json'));
105
+ const packageRoot = path.dirname(regular(require.resolve('ws/package.json')));
106
+ const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json')));
107
+ if (packageJson.name !== 'ws') throw new Error('Unexpected WebSocket dependency identity');
108
+ for (const relative of ['package.json', 'LICENSE', 'index.js', 'wrapper.mjs', 'browser.js',
109
+ ...fs.readdirSync(path.join(packageRoot, 'lib')).filter((name) => name.endsWith('.js')).map((name) => `lib/${name}`)]) {
110
+ const file = regular(path.join(packageRoot, relative));
111
+ if (!file.startsWith(`${packageRoot}${path.sep}`)) throw new Error('WebSocket dependency escaped package root');
112
+ files.set(`node_modules/ws/${relative}`, fs.readFileSync(file));
113
+ }
114
+ }
93
115
  for (const name of fs.readdirSync(path.join(root, 'config/model-router'))) {
94
116
  if (/\.(?:json|mjs)$/.test(name)) visit(`config/model-router/${name}`);
95
117
  }
@@ -104,6 +126,19 @@ function stableNode() {
104
126
  return process.execPath;
105
127
  }
106
128
 
129
+ /** Terminal routing does not require a VS Code extension installation. */
130
+ export function installRoutingRuntime({ sourceRoot, home = os.homedir(), apply = false } = {}) {
131
+ const snapshot = runtimeSnapshot(sourceRoot);
132
+ const runtimeRoot = path.join(home, '.cache/ruvnet-brain/model-routing/versions', snapshot.digest);
133
+ if (apply) for (const [relative, bytes] of snapshot.files) {
134
+ const file = path.join(runtimeRoot, relative);
135
+ if (fs.existsSync(file)) {
136
+ if (sha(fs.readFileSync(regular(file))) !== sha(bytes)) throw new Error('Immutable runtime snapshot mismatch');
137
+ } else atomic(file, bytes);
138
+ }
139
+ return { apply, runtimeRoot, runtimeDigest: snapshot.digest, runtimeFiles: snapshot.identity };
140
+ }
141
+
107
142
  export function installNativeLaunchers({ sourceRoot, home = os.homedir(), extensionsRoot = path.join(home, '.vscode-server/extensions'), nodeBinary = stableNode(), apply = false } = {}) {
108
143
  // VS Code spawns its executable directly. A .cmd/.sh surrogate is not a proved
109
144
  // Windows executable adapter; refuse before writing a misleading registration.
@@ -113,7 +148,7 @@ export function installNativeLaunchers({ sourceRoot, home = os.homedir(), extens
113
148
  const runtime = path.join(base, 'versions', snapshot.digest);
114
149
  const runner = path.join(base, 'model-routing-launchers.mjs');
115
150
  const configPath = path.join(base, 'launcher-config.json');
116
- const config = { extensionsRoot: fs.realpathSync(extensionsRoot), nodeBinary: (regular(fs.realpathSync(nodeBinary)), nodeBinary), gatewayPath: path.join(runtime, 'scripts/model-routing-gateway.mjs'), runtimeDigest: snapshot.digest };
151
+ const config = { extensionsRoot: fs.realpathSync(extensionsRoot), nodeBinary: (regular(fs.realpathSync(nodeBinary)), nodeBinary), gatewayPath: path.join(runtime, 'scripts/model-routing-gateway.mjs'), terminalGatewayPath: path.join(runtime, 'scripts/model-terminal-gateway.mjs'), runtimeRoot: runtime, runtimeDigest: snapshot.digest };
117
152
  const codexBinary = discoverNativeCodex(config.extensionsRoot);
118
153
  const launchers = Object.fromEntries(Object.keys(KEYS).map((host) => [host, path.join(home, '.local/bin', `ruvnet-brain-${host === 'codex' ? 'codex' : 'claude'}-gateway`)]));
119
154
  const texts = Object.fromEntries(Object.entries(launchers).map(([host]) => [host, `#!/bin/sh\n# Managed RuvNet Brain native routing launcher\nexec ${shellQuote(config.nodeBinary)} ${shellQuote(runner)} --launch ${shellQuote(host)} --config ${shellQuote(configPath)} --launcher "$0" -- "$@"\n`]));
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // Per-launch Unix WebSocket adapter for the native subscription TUI. No daemon lifecycle ownership.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import http from 'node:http';
7
+ import { Duplex, PassThrough, Writable } from 'node:stream';
8
+ import { EventEmitter } from 'node:events';
9
+ import { StringDecoder } from 'node:string_decoder';
10
+ import { spawn } from 'node:child_process';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { connectNativeGateway, decideNativeTurn, nativeGatewayLaunch } from './model-routing-gateway.mjs';
13
+ import { subscriptionEnvironment, assertSubscriptionAuth } from './model-router-dispatch.mjs';
14
+
15
+ export const MAX_TERMINAL_BYTES = 64 * 1024 * 1024;
16
+ const REFUSED = 'Native terminal routing unavailable; request was not sent.';
17
+ const COMMANDS = new Set(['agents', 'exec', 'e', 'review', 'login', 'logout', 'mcp', 'plugin', 'app-server',
18
+ 'remote-control', 'app', 'completion', 'update', 'doctor', 'sandbox', 'debug', 'apply', 'a', 'resume',
19
+ 'queue', 'archive', 'delete', 'migrate-rollouts', 'unarchive', 'fork', 'cloud', 'exec-server', 'features', 'help']);
20
+ const ADMIN = new Set(['login', 'logout', 'mcp', 'plugin', 'completion', 'update', 'doctor', 'features', 'help']);
21
+ const VALUES = new Set(['-c', '--config', '--enable', '--disable', '-i', '--image', '-m', '--model',
22
+ '-p', '--profile', '-s', '--sandbox', '-C', '--cd', '--add-dir', '-a', '--ask-for-approval']);
23
+
24
+ /** Short Unix roots avoid long environment-provided temp paths; Windows has no qualified transport. */
25
+ export function terminalTempRoot(platform = process.platform) {
26
+ if (!['darwin', 'linux'].includes(platform)) throw new Error('Native Unix terminal routing requires macOS or Linux');
27
+ return platform === 'darwin' ? '/private/tmp' : '/tmp';
28
+ }
29
+
30
+ /** Keep native arguments verbatim; only proved interactive paths may enter the routed transport. */
31
+ export function classifyTerminalArguments(args = []) {
32
+ if (args.some((arg) => /^--(?:remote(?:-auth-token-env)?|no-daemon)(?:=|$)/.test(arg))) {
33
+ throw new Error('Explicit remote or no-daemon transport conflicts with terminal routing');
34
+ }
35
+ let first;
36
+ for (let index = 0; index < args.length; index++) {
37
+ const arg = args[index];
38
+ if (arg === '--') return 'interactive'; // Native '--' ends subcommand parsing: following words are prompts.
39
+ if (VALUES.has(arg)) { index++; continue; }
40
+ if (arg.startsWith('-')) continue;
41
+ first = arg; break;
42
+ }
43
+ if (ADMIN.has(first) || (!first && args.some((arg) => ['--help', '-h', '--version', '-V'].includes(arg)))) return 'admin';
44
+ if (COMMANDS.has(first) && !['resume', 'fork'].includes(first)) {
45
+ throw new Error(`Native command ${first} has no proved terminal routing transport`);
46
+ }
47
+ return 'interactive';
48
+ }
49
+
50
+ /** Reject symlinks and foreign/group-accessible endpoints, including their containing directory. */
51
+ export function validateUpstreamSocket(socketPath, { uid = process.getuid?.() } = {}) {
52
+ if (!path.isAbsolute(socketPath || '') || uid == null) throw new Error('Owned absolute Unix socket required');
53
+ const normalized = path.normalize(socketPath);
54
+ let current = normalized;
55
+ while (true) {
56
+ const entry = fs.lstatSync(current);
57
+ if (entry.isSymbolicLink()) throw new Error('Symlink in upstream socket path refused');
58
+ if (current === normalized && (!entry.isSocket() || entry.uid !== uid || (entry.mode & 0o077))) {
59
+ throw new Error('Upstream must be an owned private Unix socket');
60
+ }
61
+ if (current === path.dirname(normalized) && (!entry.isDirectory() || entry.uid !== uid || (entry.mode & 0o077))) {
62
+ throw new Error('Upstream socket directory must be owned and private');
63
+ }
64
+ if (current === path.dirname(current)) break;
65
+ current = path.dirname(current);
66
+ }
67
+ return normalized;
68
+ }
69
+
70
+ function jsonPacket(text) {
71
+ const packet = JSON.parse(text);
72
+ if (!packet || typeof packet !== 'object' || Array.isArray(packet)
73
+ || !(typeof packet.method === 'string' || (Object.hasOwn(packet, 'id')
74
+ && (Object.hasOwn(packet, 'result') || Object.hasOwn(packet, 'error'))))) throw new Error(REFUSED);
75
+ if (Object.hasOwn(packet, 'id') && !(typeof packet.id === 'string' || Number.isSafeInteger(packet.id))) throw new Error(REFUSED);
76
+ return packet;
77
+ }
78
+
79
+ /** Official proxy is a byte tunnel: perform the daemon's /rpc WebSocket handshake over its stdio. */
80
+ export async function connectProxyWebSocket(proxy, { startupMs = 10000, maxBytes = MAX_TERMINAL_BYTES } = {}) {
81
+ const { WebSocket } = await import('ws');
82
+ const tunnel = Duplex.from({ readable: proxy.stdout, writable: proxy.stdin });
83
+ // createConnection always returns the owned tunnel; even the placeholder endpoint is Unix-only.
84
+ const ws = new WebSocket('ws+unix:///native-proxy:/rpc', { createConnection: () => tunnel,
85
+ perMessageDeflate: false, maxPayload: maxBytes, handshakeTimeout: startupMs, followRedirects: false });
86
+ const child = new EventEmitter(); child.on('error', () => {});
87
+ child.stdout = new PassThrough({ highWaterMark: 65536 }); child.stderr = proxy.stderr;
88
+ child.exitCode = null; child.signalCode = null;
89
+ let closed = false, ready = false, buffer = '';
90
+ const decoder = new StringDecoder('utf8');
91
+ const fail = () => { if (!closed) { child.emit('error', new Error(REFUSED)); child.kill('SIGTERM'); } };
92
+ child.kill = (signal) => {
93
+ if (closed && signal !== 'SIGKILL') return;
94
+ if (!closed) { closed = true; ws.terminate(); tunnel.destroy(); }
95
+ const sent = proxy.kill(signal);
96
+ if (signal !== 'SIGKILL') {
97
+ const killer = setTimeout(() => { if (proxy.exitCode == null && proxy.signalCode == null) proxy.kill('SIGKILL'); }, 1000);
98
+ killer.unref(); proxy.once('exit', () => clearTimeout(killer));
99
+ } child.exitCode = proxy.exitCode; child.signalCode = proxy.signalCode;
100
+ return sent;
101
+ };
102
+ child.stdin = new Writable({ highWaterMark: 65536, write(chunk, _encoding, callback) {
103
+ try {
104
+ buffer += decoder.write(chunk); if (Buffer.byteLength(buffer) > maxBytes) throw new Error(REFUSED);
105
+ const lines = buffer.split('\n'); buffer = lines.pop(); let index = 0;
106
+ const send = () => {
107
+ if (index === lines.length) { callback(); return; }
108
+ const line = lines[index++]; jsonPacket(line);
109
+ if (closed || ws.readyState !== WebSocket.OPEN || ws.bufferedAmount + Buffer.byteLength(line) > maxBytes) throw new Error(REFUSED);
110
+ ws.send(line, { binary: false }, (error) => {
111
+ if (error) { callback(error); fail(); return; }
112
+ try { send(); } catch (error) { callback(error); fail(); }
113
+ });
114
+ }; send();
115
+ } catch (error) { callback(error); fail(); }
116
+ } });
117
+ child.stdin.on('error', fail); child.stdout.on('error', fail);
118
+ ws.on('message', (bytes, binary) => {
119
+ try {
120
+ if (binary || bytes.length > maxBytes || child.stdout.writableLength + bytes.length + 1 > maxBytes) throw new Error(REFUSED);
121
+ const line = new TextDecoder('utf-8', { fatal: true }).decode(bytes); jsonPacket(line);
122
+ if (!child.stdout.write(line + '\n')) ws.pause();
123
+ } catch { fail(); }
124
+ });
125
+ child.stdout.on('drain', () => { if (!closed) ws.resume(); });
126
+ proxy.once('error', (error) => child.emit('error', error));
127
+ proxy.once('exit', (code, signal) => {
128
+ child.exitCode = code; child.signalCode = signal; child.emit('exit', code, signal);
129
+ if (!closed) { closed = true; ws.terminate(); tunnel.destroy(); }
130
+ });
131
+ ws.on('error', () => { if (ready) fail(); });
132
+ ws.once('close', () => { if (ready && !closed) fail(); });
133
+ try {
134
+ await new Promise((resolve, reject) => {
135
+ const rejectProxy = () => reject(new Error(REFUSED));
136
+ proxy.once('exit', rejectProxy); proxy.once('error', rejectProxy);
137
+ ws.once('error', rejectProxy); ws.once('close', rejectProxy);
138
+ ws.once('open', () => { proxy.removeListener('exit', rejectProxy); proxy.removeListener('error', rejectProxy);
139
+ ws.removeListener('error', rejectProxy); ws.removeListener('close', rejectProxy); ready = true; resolve(); });
140
+ });
141
+ } catch (error) { child.kill('SIGTERM'); throw error; }
142
+ return child;
143
+ }
144
+
145
+ /** Real HTTP/WebSocket framing over one private Unix socket, converted to native JSONL streams. */
146
+ export async function createTerminalTransport({ child, diagnostics = process.stderr, gatewayOptions = {},
147
+ tempRoot, maxBytes = MAX_TERMINAL_BYTES, startupMs = 10000, onFailure = () => {} } = {}) {
148
+ const defaultTempRoot = terminalTempRoot();
149
+ const { WebSocketServer, WebSocket } = await import('ws');
150
+ if (!child?.stdin || !child?.stdout) throw new Error('Native proxy child required');
151
+ if (!(maxBytes > 0 && maxBytes <= MAX_TERMINAL_BYTES)) throw new Error('Invalid transport byte bound');
152
+ const directory = fs.mkdtempSync(path.join(fs.realpathSync(tempRoot ?? defaultTempRoot), 'crt-'));
153
+ fs.chmodSync(directory, 0o700);
154
+ const socketPath = path.join(directory, 'r.sock');
155
+ if (Buffer.byteLength(socketPath) > 100) { fs.rmSync(directory, { recursive: true }); throw new Error('Unix socket path too long'); }
156
+ const server = http.createServer((_request, response) => { response.writeHead(403); response.end(); });
157
+ const wss = new WebSocketServer({ noServer: true, maxPayload: maxBytes, perMessageDeflate: false });
158
+ const input = new PassThrough({ highWaterMark: 65536 });
159
+ let client, claimed = false, gateway, closed = false, outputBuffer = '', failed = false;
160
+ const decoder = new StringDecoder('utf8'), initializations = new Set();
161
+ let resolveConnected, resolveReady;
162
+ const connected = new Promise((resolve) => { resolveConnected = resolve; });
163
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
164
+ const close = () => {
165
+ if (closed) return;
166
+ closed = true; clearTimeout(timer); gateway?.close(); input.destroy(); output.destroy();
167
+ client?.terminate(); wss.close(); server.close();
168
+ // This is the only child and filesystem tree owned by this launch.
169
+ child.kill?.('SIGTERM');
170
+ const killer = setTimeout(() => { if (child.exitCode == null && child.signalCode == null) child.kill?.('SIGKILL'); }, 1000);
171
+ killer.unref(); child.once?.('exit', () => clearTimeout(killer));
172
+ fs.rmSync(directory, { recursive: true, force: true }); resolveConnected(false); resolveReady(false);
173
+ };
174
+ const fail = () => {
175
+ if (closed) return;
176
+ failed = true; diagnostics.write(`[native-terminal-routing] ${REFUSED}\n`); close(); onFailure();
177
+ };
178
+ const output = new Writable({ highWaterMark: 65536, write(chunk, _encoding, callback) {
179
+ try {
180
+ outputBuffer += decoder.write(chunk);
181
+ if (Buffer.byteLength(outputBuffer) > maxBytes) throw new Error(REFUSED);
182
+ const lines = outputBuffer.split('\n'); outputBuffer = lines.pop();
183
+ let index = 0;
184
+ const send = () => {
185
+ if (index >= lines.length) { callback(); return; }
186
+ const line = lines[index++], packet = jsonPacket(line);
187
+ if (initializations.has(packet.id)) {
188
+ if (packet.error) throw new Error(REFUSED);
189
+ initializations.delete(packet.id); clearTimeout(timer); resolveReady(true);
190
+ }
191
+ if (closed || client?.readyState !== WebSocket.OPEN || client.bufferedAmount + Buffer.byteLength(line) > maxBytes) throw new Error(REFUSED);
192
+ client.send(line, { binary: false }, (error) => {
193
+ if (error) { callback(error); fail(); return; }
194
+ try { send(); } catch (error) { callback(error); fail(); }
195
+ });
196
+ };
197
+ send();
198
+ } catch (error) { callback(error); fail(); }
199
+ } });
200
+ output.on('error', fail); input.on('error', fail);
201
+ const timer = setTimeout(fail, startupMs);
202
+ server.on('error', fail); wss.on('error', fail);
203
+ server.on('upgrade', (request, socket, head) => {
204
+ // Refuse a second client for this launch, including reconnect after disconnect.
205
+ if (closed || claimed || request.url !== '/rpc' || request.headers.origin) {
206
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
207
+ }
208
+ claimed = true;
209
+ wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws));
210
+ });
211
+ wss.on('connection', (ws) => {
212
+ client = ws;
213
+ gateway = connectNativeGateway({ ...gatewayOptions, harness: 'codex', child, input, output, diagnostics });
214
+ ws.on('message', (bytes, binary) => {
215
+ try {
216
+ if (binary || bytes.length > maxBytes || closed) throw new Error(REFUSED);
217
+ const line = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
218
+ const packet = jsonPacket(line);
219
+ if (packet.method === 'initialize' && Object.hasOwn(packet, 'id')) initializations.add(packet.id);
220
+ // Shared daemon ownership never crosses this per-client boundary.
221
+ if (['shutdown', 'exit'].includes(packet.method)) {
222
+ if (Object.hasOwn(packet, 'id')) ws.send(JSON.stringify({ id: packet.id, error: { code: -32001, message: 'Shared daemon shutdown refused' } }));
223
+ return;
224
+ }
225
+ if (gateway.spoolState().queuedBytes + input.writableLength + Buffer.byteLength(line) + 1 > maxBytes) throw new Error(REFUSED);
226
+ if (!input.write(JSON.stringify(packet) + '\n')) ws.pause();
227
+ } catch { fail(); }
228
+ });
229
+ input.on('drain', () => { if (!closed) ws.resume(); });
230
+ ws.once('close', close); ws.on('error', fail); resolveConnected(true);
231
+ });
232
+ child.once('error', fail); child.once('exit', fail);
233
+ child.stdin.on('error', fail); child.stdout.on('error', fail);
234
+ try {
235
+ await new Promise((resolve, reject) => {
236
+ server.once('error', reject); server.listen(socketPath, () => { server.removeListener('error', reject); resolve(); });
237
+ });
238
+ if (closed) throw new Error(REFUSED);
239
+ fs.chmodSync(socketPath, 0o600);
240
+ } catch (error) { close(); throw error; }
241
+ return { socketPath, directory, remote: `unix://${socketPath}`, connected, ready, close,
242
+ failed: () => failed, gateway: () => gateway };
243
+ }
244
+
245
+ export function parseTerminalInvocation(argv, env = process.env) {
246
+ const args = [...argv]; let realBinary = env.MODEL_ROUTER_REAL_CODEX, upstreamSocket;
247
+ while (args.length && args[0] !== '--') {
248
+ const flag = args.shift();
249
+ if (!['--real-binary', '--upstream-socket'].includes(flag) || !args.length) throw new Error('Explicit terminal gateway options required');
250
+ if (flag === '--real-binary') realBinary = args.shift(); else upstreamSocket = args.shift();
251
+ }
252
+ if (args[0] === '--') args.shift();
253
+ return { realBinary, upstreamSocket, args, env };
254
+ }
255
+
256
+ export async function runTerminalGateway({ realBinary, upstreamSocket, args = [], env = process.env,
257
+ spawnNative = spawn, adaptProxy = connectProxyWebSocket, gatewayOptions = {}, startupMs = 10000, tempRoot,
258
+ signalSource = process, diagnostics = process.stderr } = {}) {
259
+ if (!path.isAbsolute(realBinary || '')) throw new Error('Explicit absolute native executable required');
260
+ const binary = fs.realpathSync(realBinary), clean = subscriptionEnvironment(env);
261
+ if (env.MODEL_ROUTER_TERMINAL_ACTIVE || env.MODEL_ROUTER_GATEWAY_ACTIVE) throw new Error('Terminal routing recursion refused');
262
+ const mode = classifyTerminalArguments(args);
263
+ if (mode === 'admin') {
264
+ const child = spawnNative(binary, args, { env: clean, shell: false, stdio: 'inherit' });
265
+ return await exitOf(child);
266
+ }
267
+ const defaultTempRoot = terminalTempRoot();
268
+ tempRoot ??= defaultTempRoot;
269
+ // Reuse the established provider/config guards; this does not spawn app-server.
270
+ const normalized = nativeGatewayLaunch({ harness: 'codex', realBinary: binary, args: ['app-server', ...args], env: clean });
271
+ // Native global options precede the original subcommand/prompt, including a literal '--'.
272
+ const standardOverrides = normalized.args.slice(args.length + 1);
273
+ const socketPath = validateUpstreamSocket(upstreamSocket || path.join(clean.CODEX_HOME || path.join(os.homedir(), '.codex'), 'app-server-control', 'app-server-control.sock'));
274
+ const auth = () => assertSubscriptionAuth('codex', { env: clean }); auth();
275
+ clean.MODEL_ROUTER_TERMINAL_ACTIVE = '1';
276
+ const proxy = spawnNative(binary, ['app-server', 'proxy', '--sock', socketPath], { env: clean, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
277
+ let tui, transport, forcedFailure = false;
278
+ const handlers = new Map();
279
+ try {
280
+ const backend = await adaptProxy(proxy, { startupMs });
281
+ transport = await createTerminalTransport({ child: backend, diagnostics, startupMs, tempRoot,
282
+ onFailure: () => { forcedFailure = true; tui?.kill('SIGTERM'); },
283
+ gatewayOptions: { ...gatewayOptions, checkAuth: auth,
284
+ decide: gatewayOptions.decide || ((prompt, harness, metadata) => decideNativeTurn(prompt, harness, { env: clean, ...metadata })) } });
285
+ if (transport.failed()) throw new Error(REFUSED);
286
+ tui = spawnNative(binary, ['--remote', transport.remote, ...standardOverrides, ...args], { env: clean, shell: false, stdio: 'inherit' });
287
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
288
+ const handler = () => { transport.close(); tui.kill(signal); };
289
+ handlers.set(signal, handler); signalSource.on(signal, handler);
290
+ }
291
+ const result = await exitOf(tui);
292
+ return forcedFailure ? { code: 1, signal: null } : result;
293
+ } finally {
294
+ handlers.forEach((handler, signal) => signalSource.removeListener(signal, handler));
295
+ if (transport) transport.close(); else proxy.kill('SIGTERM');
296
+ }
297
+ }
298
+
299
+ function exitOf(child) {
300
+ return new Promise((resolve, reject) => {
301
+ child.once('error', reject); child.once('exit', (code, signal) => resolve({ code, signal }));
302
+ });
303
+ }
304
+
305
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
306
+ runTerminalGateway(parseTerminalInvocation(process.argv.slice(2))).then(({ code, signal }) => {
307
+ if (signal) { process.removeAllListeners(signal); process.kill(process.pid, signal); }
308
+ else process.exitCode = code ?? 1;
309
+ }).catch((error) => { process.stderr.write(`[native-terminal-routing] ${error.message}\n`); process.exitCode = 1; });
310
+ }