SLOPSHOPPER

rnb-terminal-routing

Reviewed per-user native terminal routing

newprompt
★ 73v0.1.0NOASSERTIONupdated 2026-10-05stuinfla/ruvnet-brain/config/model-router/claude-terminal-mod
A shopper browsing a rack in a slop shop
README

Updated: 2026-10-04 16:10:00 EDT | Version 0.1.1 Created: 2026-10-04 16:10:00 EDT

Native terminal routing prototype

Tested against the installed Claude Code 2.1.289 native binary and its generated mod declarations. Mods require 2.1.287 or later. This routes the native host's own turn.step middleware: it passes model and effort to yield* next(...), preserving the native streaming result and TUI. It does not call a separate inference API or inject slash commands.

prepareClaudeTerminalMod({ destination }) in scripts/claude-terminal-mod.mjs materializes a private plugin directory, generates absolute Node/helper/engine paths, and copies the approved pure classifier into the mod. The source directory is deliberately unprepared. No Node APIs run inside sandboxed mod code. $.process.run invokes the bridge with prompt JSON on stdin, never in arguments. The existing policy engine validates current per-user catalog, profile, reviewed model/effort allocation and subscription eligibility. Review age remains evidence for the weekly refresh and does not revoke the owner-approved allocation; it supplies no native/default/metered fallback.

A launcher must provide RNB_CLAUDE_MOD_NONCE (64 lowercase hexadecimal characters) and RNB_CLAUDE_MOD_RECEIPT (absolute receipt filename in an already prepared private directory). session.start calls the bridge to write an atomic, mode-0600 activation receipt. The launcher must compare nonce, exact real plugin root, native version, source digest, timestamp and live process state before accepting startup. prepareClaudeTerminalMod returns modDigest; terminalModDigest recomputes it. The receipt's pid is the bridge parent's mod worker, not an asserted main CLI PID. --health supports the same bounded receipt schema; it does not invent a native crash guard.

prompt.submit approves the prompt once. Its optional turnId denotes a running turn, not the upcoming idle turn. A bounded FIFO stores pending text and decisions in RAM only. turn.start binds the next approved prompt to the newly minted exact turn ID. If native settings hooks settled different text, the bridge reclassifies that text using the original task class as a minimum. Image-only submissions use a conservative hard-review classification while passing original attachments and text unchanged. Every backend step keeps the bound model/effort and exact turn ID/index. Completion and session end remove decisions. Queue/active maps cap at 32 entries; pending prompts expire after five minutes. Direct mid-turn deliveries (a running turnId without wait) refuse because no new authoritative turn.start exists; queue the prompt to start its own turn. Subagent steps and unbound continuations refuse instead of borrowing the main turn's decision.

A prompt/helper failure returns { drop } without next. A step failure returns an immediate native refusal result without next, retaining turn ID/index. Every consequential hook has a .catch handler whose refusal does no process, filesystem or model work. Missing activation refuses subsequent prompts and steps.

Failure boundary

Claude Code can skip all hooks if the mod fails to load, the native mod worker crashes, hooks are disabled, another guard refuses registration, or a hook and its catch handler both fail. Other mods later in the chain can rewrite model/effort. A startup receipt proves that the activation hook ran for that nonce and source digest; it is not a perpetual guarantee that routing hooks remain active. This prototype cannot claim universal fail-closed enforcement. The root CLI wrapper owns the startup guard and any further runtime monitoring. Native model inference and rendered TUI acceptance remain separate checks; plugin tests stub the engine boundary and incur no inference.

Checks

Run claude plugin validate config/model-router/claude-terminal-mod, claude plugin test config/model-router/claude-terminal-mod, and vitest run tests/unit/claude-terminal-mod.test.mjs. The native tests exercise the installed mod runtime, including two different turn allocations, high coding effort, final-text class floors, missing decisions and helper exceptions. Unit tests call the actual policy engine against isolated catalog/profile/policy fixtures, including stale allocation, invalid policy, absent hard model and disabled subscription states.

Source 5 files
hooks/register.js 67 lines
1import runtime from './runtime.js';
2import { createTurnCache, inspectDecision, classificationText, refusalStream, REFUSAL } from './routing.js';
3export function register(on) {
4  const cache = createTurnCache();
5  let ready = false;
6  on('session.start', async ($, e, next) => {
7    ready = false; cache.clear();
8    const nonce = await $.env.get('RNB_CLAUDE_MOD_NONCE');
9    const receiptPath = await $.env.get('RNB_CLAUDE_MOD_RECEIPT');
10    const version = (await $.session.version()).version;
11    const sessionId = await $.session.id();
12    const pluginRoot = $.plugin.root;
13    const result = await $.process.run([runtime.nodePath, runtime.helperPath, '--ready'], {
14      stdin: JSON.stringify({ nonce, receiptPath, version, sessionId, pluginRoot }), timeoutMs: 8000,
15    });
16    if (result.exitCode !== 0) throw new Error(REFUSAL);
17    const receipt = JSON.parse(result.stdout);
18    if (receipt.nonce !== nonce || receipt.status !== 'ready') throw new Error(REFUSAL);
19    ready = true; return next(e);
20  }).catch(($, e) => { ready = false; return { cwd: e.cwd }; });
21  on('prompt.submit', async ($, e, next) => {
22    // Direct delivery into a running turn has no authoritative new turn.start boundary.
23    if (e.turnId && !e.wait) return { drop: REFUSAL };
24    if (!ready || typeof e.text !== 'string' || (!e.text.trim() && !e.attachments?.length) || e.text.length > 200000) return { drop: REFUSAL };
25    const result = await $.process.run([runtime.nodePath, runtime.helperPath, '--decision'], {
26      stdin: JSON.stringify({ prompt: classificationText(e.text, e.attachments), enginePath: runtime.enginePath, policyPath: runtime.policyPath }), timeoutMs: 8000,
27    });
28    if (result.exitCode !== 0) return { drop: REFUSAL };
29    const now = await $.clock.now();
30    const decision = inspectDecision(JSON.parse(result.stdout), classificationText(e.text, e.attachments), now);
31    const entry = cache.enqueue(e.text, decision, now);
32    try {
33      const answer = await next(e);
34      if ('drop' in answer) cache.remove(entry);
35      return answer;
36    } catch (error) { cache.remove(entry); throw error; }
37  }).catch(() => ({ drop: REFUSAL }));
38  on('turn.start', async ($, e, next) => {
39    if (!ready) throw new Error(REFUSAL);
40    const now = await $.clock.now();
41    const pending = cache.pending();
42    if (!pending) throw new Error(REFUSAL);
43    if (pending?.text === e.text) cache.bind(e, now);
44    else {
45      // Settings hooks may settle a different prompt. Reassess with the original class floor.
46      const minimumClass = pending?.decision.taskClass;
47      const text = classificationText(e.text, pending ? [{}] : undefined);
48      const result = await $.process.run([runtime.nodePath, runtime.helperPath, '--decision'], {
49        stdin: JSON.stringify({ prompt: text, minimumClass, enginePath: runtime.enginePath, policyPath: runtime.policyPath }), timeoutMs: 8000,
50      });
51      if (result.exitCode !== 0) throw new Error(REFUSAL);
52      const decision = inspectDecision(JSON.parse(result.stdout), text, now, minimumClass);
53      cache.bind(e, now, decision);
54    }
55    return next(e);
56  }).catch(($, e) => ({ turnId: e.turnId }));
57  on('turn.step', async function* ($, e, next) {
58    if (!ready) return yield* refusalStream(e);
59    const decision = cache.get(e, await $.clock.now());
60    return yield* next({ ...e, model: decision.model, effort: decision.effort });
61  }).catch(async function* ($, e) { return yield* refusalStream(e); });
62  on('turn.complete', async ($, e, next) => { cache.complete(e.turnId); return next(e); })
63    .catch(($, e) => { cache.complete(e.turnId); return { text: REFUSAL }; });
64  on('session.end', async ($, e, next) => { ready = false; cache.clear(); return next(e); })
65    .catch(($, e) => { ready = false; cache.clear(); return { sessionId: e.sessionId }; });
66}
67
hooks/runtime.js 3 lines
1// This source directory refuses activation until prepareClaudeTerminalMod generates real paths.
2export default { nodePath: 'node', helperPath: '/unprepared-rnb-terminal-mod/helper.mjs', enginePath: '', policyPath: '' };
3
hooks/routing.js 56 lines
1import { classify } from './policy.default.mjs';
2export const REFUSAL = 'Reviewed terminal routing unavailable; request refused without model fallback.';
3export function refusal(e) {
4  return { turnId: e.turnId, index: e.index, answer: REFUSAL, toolUses: [], stopReason: 'refusal', usage: null };
5}
6export async function* refusalStream(e) {
7  yield { kind: 'text', index: 0, text: REFUSAL };
8  yield { kind: 'stop', stopReason: 'refusal', usage: null };
9  return refusal(e);
10}
11export function classificationText(text, attachments) {
12  return text.trim() ? text : attachments?.length ? 'final substantive review of supplied attachments' : text;
13}
14export function inspectDecision(value, text, now, minimumClass) {
15  if (value?.schemaVersion !== 1 || value.subscriptionCovered !== true ||
16      !/^claude-[a-z0-9][a-z0-9.-]*$/.test(value.model || '') ||
17      !['low', 'medium', 'high', 'xhigh', 'max'].includes(value.effort) ||
18      !Number.isFinite(value.expiresAt) ||
19      !/^[a-f0-9]{64}$/.test(value.routeDigest || '')) throw new Error(REFUSAL);
20  text = minimumClass === 'hard' ? 'final substantive review\n' + text : minimumClass === 'medium' ? 'review task\n' + text : text;
21  const codeFences = Math.floor((text.match(/```/g) || []).length / 2);
22  const hasCode = codeFences > 0 || /\b(function|const|let|def|class|import|=>|SELECT|async)\b/.test(text) || /[{};]\s*$/m.test(text);
23  const rank = { fast: 0, medium: 1, hard: 2 };
24  const floor = classify({ taskHints: text, hasCode }, 'claude-code');
25  if (!Object.hasOwn(rank, value.taskClass) || rank[value.taskClass] < rank[floor]) throw new Error(REFUSAL);
26  return Object.freeze({ model: value.model, effort: value.effort, taskClass: value.taskClass, expiresAt: value.expiresAt });
27}
28// Prompt text exists only in this bounded in-memory queue; Claude mints turnId later.
29export function createTurnCache() {
30  const pending = [];
31  const active = new Map();
32  return {
33    enqueue(text, decision, now) {
34      while (pending.length && now - pending[0].createdAt > 300000) pending.shift();
35      if (pending.length >= 32) throw new Error(REFUSAL);
36      const entry = { text, decision, createdAt: now }; pending.push(entry); return entry;
37    },
38    remove(entry) { const index = pending.indexOf(entry); if (index >= 0) pending.splice(index, 1); },
39    pending() { return pending[0]; },
40    bind(e, now, replacement) {
41      // Never substitute the most recent prompt or the model shown in the TUI.
42      const entry = pending[0];
43      if ((!entry && !replacement) || (!replacement && entry.text !== e.text) || (entry && now - entry.createdAt > 300000) ||
44          active.has(e.turnId) || active.size >= 32) throw new Error(REFUSAL);
45      if (entry) pending.shift(); active.set(e.turnId, replacement || entry.decision);
46    },
47    get(e, now) {
48      const decision = active.get(e.turnId);
49      if (!decision || e.agentId || !Number.isSafeInteger(e.index) || e.index < 0) throw new Error(REFUSAL);
50      return decision;
51    },
52    complete(turnId) { active.delete(turnId); },
53    clear() { pending.length = 0; active.clear(); },
54  };
55}
56
hooks/policy.default.mjs 3 lines
1// Source-only adapter. Materialization copies the canonical classifier into the sandbox.
2export { classify } from '../../policy.default.mjs';
3
../policy.default.mjs 106 lines
1// Per-user reviewed allocation: correctness first, subscription allowance second, completion time third.
2// Free-text classification is a conservative heuristic, not an optimality or uncertainty detector.
3// Structured taskFacts describe the caller's assessment; missing information/environment trouble alone
4// never imply difficult reasoning. Claude keeps its separately reviewed three-class policy.
5const CODING = /\b(implement|implementation|code|coding|debug|refactor|test|endpoint|API|repository|module|function)\b/i;
6const HARD = /cryptograph|consensus|race condition|irreversible|final review|independent review|difficult planning|complex architecture|security audit|security vulnerability|unresolved architectur|production incident|prove correctness/i;
7const MECHANICAL = /\b(summari[sz]e|classify|extract|translate|rephrase|format|typo|alphabetical order|two-column|markdown table)\b/i;
8const WORK = /\b(implement|build|add|replace|repair|fix|debug|trace|investigate|determine|choose|design|plan|review|assess|recommend)\b/i;
9
10// Conjunctions describe consequence and requested reasoning, not difficulty from length or a
11// single domain word. They remain incomplete heuristics; callers should supply assessed facts.
12function consequenceFloor(text) {
13  const money = /\b(card|charg\w*|payment\w*|billing|settlement|ledger|funds)\b/i.test(text);
14  const financialFailure = money && /\b(twice|duplicate\w*|double[- ]?charg\w*|los[est]\w*|failover|failed|rollback)\b/i.test(text);
15  const liveMigration = /\b(live|production|both versions|concurrent)\b/i.test(text) &&
16    /\b(migrat\w*|schema|moving|move)\b/i.test(text) && /\b(records|payments|data|traffic)\b/i.test(text);
17  const isolation = /\b(tenant|account|user)\b/i.test(text) &&
18    /\b(another|different|cross[- ]?(?:tenant|account)|other (?:tenant|account|user)|unauthori[sz]ed)\b/i.test(text) &&
19    /\b(see|read|access|expos\w*|leak\w*|bind|replay\w*)\b/i.test(text);
20  const verification = /\b(signed|signature|token|verifier|credential|authentication)\b/i.test(text) &&
21    /\b(replay\w*|forg\w*|bypass\w*|bind|binding)\b/i.test(text);
22  const durability = /\b(durable|durability|replicat\w*|acknowledg\w*|writer\w*|leader)\b/i.test(text) &&
23    /\b(choose|choosing|trade[- ]?off|design|loss|losing|fail\w*|vanish\w*|survive)\b/i.test(text);
24  const coupledSystems = [/\b(scheduler|queue)\b/i, /\b(worker|consumer)\b/i, /\b(database|storage|broker)\b/i]
25    .filter((signal) => signal.test(text)).length >= 2;
26  const coupledFailure = coupledSystems && /\b(failover|retri\w*|reconnect\w*|restart\w*)\b/i.test(text) &&
27    /\b(vanish\w*|los[est]\w*|interaction|only when|duplicate\w*)\b/i.test(text);
28  return financialFailure || liveMigration || isolation || verification || durability || coupledFailure;
29}
30
31function assessmentText(text) {
32  // An explicitly supplied document title is data in a headings-only transformation, not an audit.
33  // Remove only that title clause, leaving every other requested action/consequence visible.
34  if (/^\s*(summari[sz]e|extract|copy)\b/i.test(text) && /\bsupplied document\b/i.test(text) &&
35      /\bdo not assess\b/i.test(text)) return text.replace(/\btitled\s+[^;\n]+(?=[;\n])/i, '');
36  return text;
37}
38
39export function validateTaskFacts(facts) {
40  if (facts === undefined) return undefined;
41  if (!facts || typeof facts !== 'object' || Array.isArray(facts)) throw new Error('taskFacts must be an object');
42  const keys = new Set(['taskType','scope','uncertainty','consequentialPlanning','finalSubstantiveReview','exceptionalReason','verifiedTaskQualityFailure']);
43  if (Object.keys(facts).some((key) => !keys.has(key))) throw new Error('Unknown taskFacts field');
44  if (facts.taskType !== undefined && !['mechanical','coding','research','planning','review'].includes(facts.taskType)) throw new Error('Invalid taskFacts taskType');
45  if (facts.scope !== undefined && !['routine','substantial'].includes(facts.scope)) throw new Error('Invalid taskFacts scope');
46  if (facts.uncertainty !== undefined && !['none','architecture','coupled-implementation','missing-information','environment'].includes(facts.uncertainty)) throw new Error('Invalid taskFacts uncertainty');
47  for (const key of ['consequentialPlanning','finalSubstantiveReview','verifiedTaskQualityFailure']) {
48    if (facts[key] !== undefined && typeof facts[key] !== 'boolean') throw new Error(`taskFacts ${key} must be boolean`);
49  }
50  if (facts.exceptionalReason !== undefined && !/^[a-z][a-z0-9-]{2,79}$/.test(facts.exceptionalReason)) {
51    throw new Error('exceptionalReason must be an explicit named reason slug (3-80 characters)');
52  }
53  return facts;
54}
55
56export function classify(features, harness = features.harness || 'codex') {
57  const text = String(features.taskHints || '');
58  const coding = features.hasCode || CODING.test(text);
59  const assessedText = assessmentText(text);
60  const securityActions = assessedText.replace(/\bdo not (?:assess|recommend)[^.;\n]*/gi, '');
61  const securityReview = /\b(security|risks?)\b/i.test(securityActions) &&
62    /\b(review|assess(?:ment)?|audit|evaluat\w*|analy[sz]\w*|identify|determine)\b/i.test(securityActions);
63  const consequential = /\b(consequential planning|substantive planning|substantive review|final substantive review|plan (?:a |the )?new system|design (?:a |the )?new architecture|ambiguous architecture|architecture ambiguity|architectur\w* tradeoff|tightly coupled uncertain implementation|uncertain tightly coupled implementation)\b/i.test(text);
64  const architectureAmbiguity = /architectur\w*/i.test(text) && /\b(ambiguous|ambiguity|unresolved|uncertain|trade[- ]?off)\b/i.test(text);
65  const coupledUncertainty = /tightly coupled/i.test(text) && /implementation|coding/i.test(text) && /uncertain|unresolved|ambiguous/i.test(text);
66  const hardText = HARD.test(assessedText) || securityReview || consequenceFloor(assessedText) || consequential || architectureAmbiguity || coupledUncertainty;
67  const substantialText = /\b(substantial (?:implementation|coding|feature|task)|cross-module (?:implementation|feature|refactor)|multi-file (?:implementation|feature|refactor)|end-to-end implementation|broad refactor)\b/i.test(text);
68  const facts = validateTaskFacts(features.taskFacts);
69  // Partial caller metadata supplements the assessment; it cannot lower explicit high-consequence text.
70  if (facts?.exceptionalReason) return harness === 'claude-code' ? 'hard' : 'exceptional';
71  if (hardText || facts?.verifiedTaskQualityFailure) return 'hard';
72  if (facts && (['architecture','coupled-implementation'].includes(facts.uncertainty) ||
73      facts.consequentialPlanning || facts.finalSubstantiveReview ||
74      ((facts.scope === 'substantial' || substantialText) && ['planning','review'].includes(facts.taskType)))) return 'hard';
75  const implementation = /\b(implement|build|add|replace|refactor)\b/i.test(text);
76  const surfaces = [/\b(storage|database|backend|importer\w*)\b/i, /\b(endpoint\w*|API|permissions)\b/i,
77    /\b(UI|client|dashboard)\b/i, /\b(integration|coverage|fixtures)\b/i].filter((signal) => signal.test(text)).length;
78  const broadImplementation = implementation && (surfaces >= 3 ||
79    (/\b(every|all|across)\b/i.test(text) && surfaces >= 2 && /\b(compatibility|integration|fixtures)\b/i.test(text)));
80  if (harness !== 'claude-code' && (facts?.scope === 'substantial' || substantialText || broadImplementation)) return 'substantial';
81  // Metadata alone never proves a closed-input transformation. Routine reviews and operative
82  // repair/planning requests retain ordinary effort even when they also contain mechanical words.
83  const permittedActions = assessedText.replace(/\bdo not (?:assess|recommend)[^.;\n]*/gi, '');
84  const mechanical = MECHANICAL.test(text) && !coding && facts?.taskType !== 'review' &&
85    (!WORK.test(permittedActions) || /^\s*fix only the typo\b/i.test(text));
86  return mechanical ? 'fast' : 'medium';
87
88}
89
90export function choose({ features, candidates, harness, profile, selection }) {
91  const taskClass = classify(features, harness);
92  const reviewed = selection?.routes?.[harness];
93  const allocation = profile?.allocation?.[harness]?.[taskClass] || reviewed?.[taskClass];
94  const model = typeof allocation === 'string' ? allocation : allocation?.model;
95  let effort = typeof allocation === 'object' ? allocation.effort : reviewed?.[taskClass]?.effort;
96  if (harness === 'claude-code' && taskClass === 'medium' && (features.hasCode || CODING.test(features.taskHints || ''))) {
97    effort = profile?.allocation?.[harness]?.codingEffort || reviewed?.codingEffort || effort;
98  }
99  const pick = candidates.find((m) => m.id === model && (m.harness || []).includes(harness) && (m.subscription || []).includes(harness));
100  return { model: pick?.id || null, provider: pick?.provider || null, tier: pick?.tier || null,
101    taskClass, effort, exceptionalReason: taskClass === 'exceptional' ? features.taskFacts?.exceptionalReason : undefined,
102    classificationSource: harness === 'codex' && features.taskFacts ? 'caller-task-facts' : 'free-text-heuristic', confidence: 0.5,
103    reason: pick ? `task-fit allocation: ${taskClass}, ${effort} effort; native subscription only`
104      : `requested qualified ${taskClass} native subscription route unavailable: ${model}; no medium or paid fallback` };
105}
106