Routes each prompt to the cheapest capable model. Simple and coding work runs in a headless child Claude Code on an Anthropic-compatible gateway (GLM 5.3, Kimi…

A mixture of models for Claude Code. Maggy's model routing, rebuilt as a Claude Code mod: it runs inside Claude Code, so the hand-off to a cheaper model is automatic instead of an instruction Claude has to follow.
claude plugin marketplace add alinaqi/mixture-of-models-claude-mod
claude plugin install mixture-of-models@mixture-of-models-claude-mod
Design rule: the main session is never touched. It stays on your claude.ai subscription with no ANTHROPIC_BASE_URL. When a prompt is routed to GLM or Kimi, the mod answers that turn itself by running a separate, headless claude -p child whose environment alone points at the gateway (srooter). The child's output becomes the turn's answer in your transcript. Nothing about the main process, its model, its auth or its requests changes.
Per prompt the mod:
simple | coding | analysis | critical with a local Ollama model (free), falling back to $.model.classify on claude-haiku-4-5 (your plan), then the last cached label.use claude or execute the plan force critical; go ahead, yes, or a prompt of six words or fewer while tools were just used keeps the current route instead of re-classifying.claude -p --model glm-5.3 on the gateway, fed a brief over stdin with the last few exchanges of your session plus the task.critical turns never start a child. They run in the main session, on the subscription, exactly as if the mod were not there.
Claude and the gateway are both visible, all the time:
| Surface | Main-session turn | Routed turn | | :- | :- | :- | | Band above the prompt | mixture-of-models · auto · last: claude-opus-5 (critical via rule) 1: auto 2: glm 3: kimi 4: claude 5: off | … · running glm-5.3 in a child… while it runs | | Spinner | Thinking… | Thinking · via glm-5.3 (child)… | | Reply in the transcript | unchanged (or tagged ⇢ claude-opus-5 · main session with ui.tags: "all") | a dim ⇢ glm-5.3 · child on gateway line above the reply, kept in scrollback | | Line under the answer | route: claude-opus-5 · critical via rule · 23.1k in / 0.9k out · cache 91% · main session · today: claude-opus-5 ×3, glm-5.3 ×5 | route: glm-5.3 · coding via ollama · child 42s · today: … | | /route | mixture-of-models live · mode auto · current route: … · today: … | same |
The band's buttons have digit hotkeys: with an empty prompt, type 3 and pause to pin Kimi, 1 to go back to auto. Turn the band off with "ui": { "band": false }.
claude --version). Mods do not load on older builds; claude update.routes.ollama pull qwen2.5-coder:3b). Without it the classifier falls back to Haiku on your plan.See GETTING_STARTED.md for the full walkthrough, or ./install.sh.
ps): echo 'SROOTER_API_KEY=srt_…' >> ~/.maggy/.env
~/.claude/model-config.json under router.child.baseUrl (full example below).claude --plugin-dir ., then run /route in the session.Until both the base URL and the key are present the mod is observe-only: it classifies and /route shows what it would do, but no child starts.
The mod reads Maggy's single source of truth, ~/.claude/model-config.json, and adds an optional router block. It never writes to that file.
{
"primary": "glm",
"router": {
"routes": { "simple": "glm-5.3", "coding": "glm-5.3", "analysis": "kimi-k3", "critical": "claude" },
"ollama": { "base": "http://localhost:11434", "model": "qwen2.5-coder:3b" },
"classifier": "claude-haiku-4-5",
"child": {
"command": ["claude"],
"baseUrl": "https://api.srooter.ai/anthropic",
"keyFile": "~/.maggy/.env",
"keyVar": "SROOTER_API_KEY",
"args": ["--bare", "--permission-mode", "acceptEdits"],
"maxTurns": 25,
"timeoutMs": 600000,
"contextMessages": 6
},
"ui": { "band": true, "tags": "routed" },
"summary": true
}
}
routes: tier → model id the gateway serves. "claude" means the tier stays in the main session.primary (Maggy's followed model, set with /model-config): glm, kimi or deepseek makes the coding tier follow it unless routes.coding is set. With primary: claude only simple and analysis leave the main session.classifier: the Claude model used when Ollama is unreachable. Undated aliases only (claude-haiku-4-5, claude-sonnet-5, claude-opus-5).child.command: the executable. Use a launcher that sets its own auth (such as the ~/bin/claude-<provider> launchers Maggy writes) and the mod skips the key lookup.child.baseUrl, keyFile, keyVar: where the child's ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY come from. Set only in the child's environment.child.args: extra flags. The default is --bare (no inherited hooks or plugins, API-key auth only, so the child can never fall back to your subscription) plus acceptEdits so it can edit files without prompting. The mod always adds --add-dir <cwd>, which keeps the project's CLAUDE.md in reach under --bare. Add --dangerously-skip-permissions only if you want the child to run commands unattended; drop --bare if you want your hooks and plugins inside the child.child.maxTurns, timeoutMs: the child's agentic budget. timeoutMs caps at ten minutes, the limit of $.process.run.child.contextMessages: how many recent exchanges go into the brief.ui.band: draw the band above the prompt (default true). ui.tags: which replies get the provenance tag, routed (default), all or off.| Command | Effect | | :- | :- | | /route | Live or observe-only, mode, current decision, today's counts | | /route auto | Classify every prompt (default) | | /route off | Never start a child | | /route simple\|coding\|analysis\|critical | Pin a tier | | /route glm, /route kimi, /route kimi-k3, /route claude | Pin a model (a prefix expands to the configured model) |
Pins persist across sessions in the plugin's store.
contextMessages exchanges (truncated) and the task.--bare by default, so your hooks and plugins stay out of it. The mod also detects the MAGGY_ROUTER_CHILD marker and does nothing inside a child.-p output does not expose which, so the mod cannot show it.$.process.run, so a routed answer is not streamed token by token.npm test # pure-function tests under Node, no Claude Code needed
claude plugin validate --strict . # manifest + static analysis of the hooks module
claude plugin test # needs v2.1.287+; fires events through the hooks with no session or network
tests/classify, config, routing, stats, child and ui cover the pure logic in hooks/lib/. tests/mod.test.ts drives the hooks end to end: a simple prompt is answered by a child with the gateway in its environment and the brief on stdin, a critical prompt never starts one, a failed child falls back to the main model, a missing key means observe-only, a child session disables the mod, and /route off and /route kimi behave.
mixture-of-models-claude-mod/
├── .claude-plugin/
│ ├── plugin.json # the plugin manifest
│ └── marketplace.json # lets `claude plugin install` find it in this repo
├── docs/adr/ # architecture decisions (0001: child-process delegation)
├── scripts/test-pure.mjs # runs the pure tests under Node
├── hooks/
│ ├── hooks.json # points at register.js
│ ├── register.js # the 9 hooks; the only file that touches the mods API ($)
│ └── lib/ # pure, unit-tested
│ ├── config.js # defaults and the model-config.json overlay
│ ├── classify.js # Maggy's pre-routing rules, classifier prompt, Ollama wire format
│ ├── routing.js # tier → model, /route grammar, summary line
│ ├── child.js # child argv, scoped env, readiness, env-file parsing, the brief
│ └── stats.js # per-day counts
└── tests/
Mods require every $ call to live in register.js (a $ passed to another file fails claude plugin validate), which is why that file holds more functions than the usual limit.
See ADR 0001. In short: a mod can rename a request's model but not change where it goes, and routing the subscription token through a proxy was rejected. A child process with its own environment is the clean split.
| Maggy | Here | | :- | :- | | hooks/route-task-hook (UserPromptSubmit, qwen3 classifier, continuation guard, cache) | prompt.submit + lib/classify.js, $.http.fetch to Ollama, $.store cache | | "You MUST delegate — run ~/bin/glm" injected into context | turn.step runs the child and returns its answer; Claude is never asked to delegate | | ~/bin/claude-<provider> launchers from model_routing.py write-launcher | child.command can point at one; otherwise the mod scopes the env itself | | hooks/usage-summary-hook (Stop) | turn.complete returns the summary line | | /model-config followed model | primary in model-config.json drives the coding tier | | srooter gateway | the child's endpoint; the main session never sees it |
Tested with Claude Code 2.1.274 for claude plugin validate only; types used are from the 2.1.277 declarations. Check .claude-plugin/types/ after the first load on a newer build, in particular whether $.process.spawn is available to stream the child's output.
hooks/register.js 201 lines1// mixture-of-models: Maggy's model routing as a Claude Code mod, child-process edition.
2// Classify each prompt → for a routed tier, answer the turn with a headless
3// `claude -p` child on the gateway → the main session never leaves its own model.
4import { parseConfig } from './lib/config.js'
5import { classifierPrompt, ollamaBody, ollamaReply, parseLabel, preClassify } from './lib/classify.js'
6import { parseRouteArg, resolveModel, summaryLine } from './lib/routing.js'
7import { buildBrief, childArgv, childEnv, childReady, expandHome, parseEnvFile } from './lib/child.js'
8import { bumpStats, statsLine } from './lib/stats.js'
9import { bandTree, reportText, tagText, tagTree } from './lib/ui.js'
10
11let cfg = parseConfig('')
12let disabled = false
13let ready = false
14let key = ''
15let sessionModel = ''
16let pin = { mode: 'auto' }
17let decision = { label: 'critical', model: '', source: 'default' }
18let lastPrompt = ''
19const answered = new Map()
20
21async function readFile($, path) {
22 try {
23 return await $.fs.read(path)
24 } catch {
25 return ''
26 }
27}
28
29async function loadConfig($) {
30 const home = (await $.env.get('HOME')) || ''
31 cfg = parseConfig(await readFile($, home + '/.claude/model-config.json'))
32 key = parseEnvFile(await readFile($, expandHome(cfg.child.keyFile, home)))[cfg.child.keyVar] || ''
33 ready = childReady(cfg.child, key)
34 sessionModel = await $.session.model()
35 pin = (await $.store.get('pin')) || { mode: 'auto' }
36}
37
38async function askOllama($, text) {
39 try {
40 const url = cfg.ollama.base + '/api/chat'
41 const r = await $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: ollamaBody(cfg.ollama.model, classifierPrompt(text)) })
42 return r.ok ? parseLabel(ollamaReply(r.text)) : undefined
43 } catch {
44 return undefined
45 }
46}
47
48async function askClaude($, text) {
49 try {
50 return await $.model.classify(classifierPrompt(text), ['simple', 'coding', 'analysis', 'critical'], { model: cfg.classifier })
51 } catch {
52 return undefined
53 }
54}
55
56async function classify($, text) {
57 const local = await askOllama($, text)
58 if (local) return { label: local, source: 'ollama' }
59 const remote = await askClaude($, text)
60 if (remote) return { label: remote, source: 'claude-classify' }
61 const cached = await $.store.get('last-label')
62 return { label: cached || 'critical', source: cached ? 'cache' : 'default' }
63}
64
65async function lastTurnUsedTools($) {
66 const messages = await $.session.messages()
67 const last = messages.filter((m) => m.role === 'assistant').pop()
68 return Boolean(last && last.toolUses.length)
69}
70
71function setDecision(label, source) {
72 decision = { label, source, model: resolveModel(label, cfg.routes, sessionModel) }
73}
74
75// A /route pin wins over classification: a label pins a tier, a model id pins the model itself.
76function applyPin() {
77 if (pin.mode === 'label') setDecision(pin.label, 'pin')
78 if (pin.mode === 'model') decision = { label: 'pinned', source: 'pin', model: pin.model === 'claude' ? sessionModel : pin.model }
79}
80
81async function decide($, text) {
82 if (pin.mode === 'model' || pin.mode === 'label') return applyPin()
83 const pre = preClassify(text, await lastTurnUsedTools($))
84 if (pre.kind === 'label') return setDecision(pre.label, 'rule')
85 if (pre.kind !== 'classify') return
86 const { label, source } = await classify($, text)
87 await $.store.set('last-label', label)
88 setDecision(label, source)
89}
90
91// The main session's own request goes out whenever the child is not the answer.
92function shouldDelegate(e) {
93 if (disabled || !ready || pin.mode === 'off' || e.agentId || e.index > 0) return false
94 return Boolean(decision.model) && decision.model !== sessionModel
95}
96
97async function runChild($) {
98 const brief = buildBrief(await $.session.messages(), lastPrompt, cfg.child.contextMessages)
99 const started = Date.now()
100 try {
101 const argv = childArgv(cfg.child, decision.model, await $.session.cwd())
102 const r = await $.process.run(argv, { env: childEnv(cfg.child, key), stdin: brief, timeoutMs: cfg.child.timeoutMs })
103 const ok = r.exitCode === 0 && r.stdout.trim() !== ''
104 decision = { ...decision, child: { ms: Date.now() - started, exitCode: ok ? 0 : r.exitCode || 1 } }
105 return ok ? r.stdout.trim() : ''
106 } catch {
107 decision = { ...decision, child: { ms: Date.now() - started, exitCode: 1 } }
108 return ''
109 }
110}
111
112async function pinFrom($, arg) {
113 pin = parseRouteArg(arg, cfg.routes)
114 await $.store.set('pin', pin)
115 applyPin()
116 $.ui.invalidate('ui.render')
117 return 'routing set to ' + pin.mode + (pin.model ? ' ' + pin.model : pin.label ? ' ' + pin.label : '')
118}
119
120function bandView(e, theirs) {
121 return { disabled, ready, sessionModel, pin, decision, routes: cfg.routes, isWorking: e.props.isWorking, theirs }
122}
123
124function taggedModel(props) {
125 if (cfg.ui.tags === 'off' || !props.isFirstOfReply) return undefined
126 return answered.get(props.text.trim()) || (cfg.ui.tags === 'all' ? sessionModel : undefined)
127}
128
129export function register(on) {
130 on('session.start', async ($, e, next) => {
131 disabled = Boolean(await $.env.get('MAGGY_ROUTER_CHILD'))
132 if (disabled) return next(e)
133 await loadConfig($)
134 applyPin()
135 try {
136 await $.command.register({ name: 'route', description: 'Show or pin the model route: /route [auto|off|simple|coding|analysis|critical|<model>]', argumentHint: '[auto|off|label|model]', immediate: true })
137 } catch {
138 $.ui.log('/route is taken by another plugin')
139 }
140 return next(e)
141 })
142
143 // Fail open: a classifier that throws or times out must never swallow the prompt.
144 on('prompt.submit', async ($, e, next) => {
145 if (disabled) return next(e)
146 lastPrompt = e.text
147 await decide($, e.text)
148 return next(e)
149 }).catch(async ($, e, next) => next(e))
150
151 on('turn.start', async ($, e, next) => {
152 decision = { label: decision.label, model: decision.model, source: decision.source }
153 return next(e)
154 })
155
156 on('turn.step', async function* ($, e, next) {
157 if (!shouldDelegate(e)) return yield* next(e)
158 const answer = await runChild($)
159 if (!answer) {
160 $.ui.log('child on ' + decision.model + ' failed, main model takes this turn')
161 return yield* next(e)
162 }
163 answered.set(answer, decision.model)
164 if (answered.size > 50) answered.delete(answered.keys().next().value)
165 yield { kind: 'text', index: 0, text: answer }
166 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: 'end_turn', usage: null }
167 })
168
169 on('turn.complete', async ($, e, next) => {
170 const result = await next(e)
171 if (disabled || e.agentId || !cfg.summary || !decision.model) return result
172 const day = new Date().toISOString().slice(0, 10)
173 const stats = bumpStats((await $.store.get('stats')) || {}, day, decision.model)
174 await $.store.set('stats', stats)
175 return { ...result, text: summaryLine(decision, e.usage) + ' · ' + statsLine(stats, day) }
176 })
177
178 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179 if (!cfg.ui.band || e.props.hasSurvey) return next(e)
180 const theirs = await next(e)
181 return bandTree($.ui.resolve(e), bandView(e, theirs), (arg) => pinFrom($, arg))
182 })
183
184 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
185 const model = taggedModel(e.props)
186 if (!model) return next(e)
187 return tagTree($.ui.resolve(e), tagText(model, sessionModel), await next(e))
188 })
189
190 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
191 if (!shouldDelegate({ index: 0 })) return next(e)
192 return next({ ...e, props: { ...e.props, suffix: ' · via ' + decision.model + ' (child)…' } })
193 })
194
195 on('command.run', { command: 'route' }, async ($, e) => {
196 const parsed = parseRouteArg(e.args, cfg.routes)
197 if (parsed.mode === 'show') return { text: reportText(bandView({ props: {} })) + ' · ' + statsLine((await $.store.get('stats')) || {}, new Date().toISOString().slice(0, 10)) }
198 return { text: await pinFrom($, e.args) }
199 })
200}
201hooks/lib/config.js 74 lines1// Routing configuration: defaults and the ~/.claude/model-config.json overlay.
2// Pure: no mods API here, so every function is unit-testable.
3
4export const LABELS = ['simple', 'coding', 'analysis', 'critical']
5
6// 'claude' means "keep the session's own model".
7export const DEFAULT_ROUTES = {
8 simple: 'glm-5.3',
9 coding: 'glm-5.3',
10 analysis: 'kimi-k3',
11 critical: 'claude',
12}
13
14// Maggy's "followed" primary, as model-config.json names it, to the model id a gateway resolves.
15export const PRIMARY_MODELS = {
16 claude: 'claude',
17 glm: 'glm-5.3',
18 kimi: 'kimi-k3',
19 deepseek: 'deepseek-v4-pro',
20}
21
22export const DEFAULTS = {
23 routes: DEFAULT_ROUTES,
24 subagents: { Explore: 'simple' },
25 ollama: { base: 'http://localhost:11434', model: 'qwen2.5-coder:3b' },
26 // Fallback classifier when Ollama is down. Undated alias: Claude Code resolves it to the current snapshot.
27 classifier: 'claude-haiku-4-5',
28 // The delegate: a headless claude on the gateway, in its own process and environment.
29 child: {
30 command: ['claude'],
31 baseUrl: '',
32 keyFile: '~/.maggy/.env',
33 keyVar: 'SROOTER_API_KEY',
34 // --bare: no inherited hooks or plugins, strictly API-key auth, so the child never touches the subscription.
35 args: ['--bare', '--permission-mode', 'acceptEdits'],
36 maxTurns: 25,
37 timeoutMs: 600000,
38 contextMessages: 6,
39 },
40 // The band above the prompt, and which replies get a provenance tag: 'routed', 'all' or 'off'.
41 ui: { band: true, tags: 'routed' },
42 summary: true,
43}
44
45function parseJson(text) {
46 try {
47 return JSON.parse(text || '{}')
48 } catch {
49 return {}
50 }
51}
52
53function codingRoute(primary, routes) {
54 const model = PRIMARY_MODELS[primary]
55 if (!model) return routes.coding
56 return model
57}
58
59export function parseConfig(text) {
60 const raw = parseJson(text)
61 const router = raw.router || {}
62 const routes = { ...DEFAULT_ROUTES, ...(router.routes || {}) }
63 if (raw.primary && !(router.routes || {}).coding) routes.coding = codingRoute(raw.primary, routes)
64 return {
65 routes,
66 subagents: { ...DEFAULTS.subagents, ...(router.subagents || {}) },
67 ollama: { ...DEFAULTS.ollama, ...(router.ollama || {}) },
68 classifier: router.classifier || DEFAULTS.classifier,
69 child: { ...DEFAULTS.child, ...(router.child || {}) },
70 ui: { ...DEFAULTS.ui, ...(router.ui || {}) },
71 summary: router.summary === undefined ? DEFAULTS.summary : router.summary,
72 }
73}
74hooks/lib/classify.js 60 lines1// Prompt classification: the heuristics Maggy's route-task-hook applies before
2// asking a model, the classifier prompt, and the Ollama wire format. Pure.
3import { LABELS } from './config.js'
4
5const CONTINUATIONS = new Set([
6 'go ahead', 'go', 'go on', 'continue', 'proceed', 'keep going', 'carry on', 'do it', 'run it', 'run',
7 'ok', 'okay', 'k', 'yes', 'yep', 'yeah', 'yes please', 'sure', 'next', 'next step', 'start', 'begin', 'execute',
8])
9const EXECUTION_INTENT = /validate[- ]plan|execute the plan|run the plan|implement the plan|start executing|begin implementation/
10const SHORT_WORDS = 6
11
12function normalize(text) {
13 return text.toLowerCase().trim().replace(/[.!?]+$/, '')
14}
15
16// What to do with a prompt before any model is asked.
17export function preClassify(text, lastHadTools) {
18 const lower = normalize(text)
19 if (lower.startsWith('/')) return { kind: 'skip' }
20 if (/\buse claude\b/.test(lower) || EXECUTION_INTENT.test(lower)) return { kind: 'label', label: 'critical' }
21 if (CONTINUATIONS.has(lower)) return { kind: 'sticky' }
22 const words = lower.split(/\s+/).filter(Boolean).length
23 if (words <= SHORT_WORDS && lastHadTools) return { kind: 'sticky' }
24 return { kind: 'classify' }
25}
26
27export function classifierPrompt(text) {
28 return [
29 'Classify this coding-assistant task into exactly ONE tier. Reply with the tier name only.',
30 '- simple: grep, find, shell one-liners, syntax lookups, log reading, short summaries, git status/log/diff',
31 '- coding: single-file fixes, boilerplate, CRUD endpoints, tests, config changes, small refactors, docs',
32 '- analysis: reviewing a diff or long document, summarising, research questions, commit or changelog text',
33 '- critical: architecture, security-sensitive code, complex multi-service debugging, system design, ADRs',
34 '',
35 'Task: ' + text,
36 ].join('\n')
37}
38
39export function parseLabel(raw) {
40 const match = (raw || '').toLowerCase().match(new RegExp('\\b(' + LABELS.join('|') + ')\\b'))
41 return match ? match[1] : undefined
42}
43
44export function ollamaBody(model, prompt) {
45 return JSON.stringify({
46 model,
47 stream: false,
48 messages: [{ role: 'user', content: prompt }],
49 options: { temperature: 0.1, num_predict: 20 },
50 })
51}
52
53export function ollamaReply(text) {
54 try {
55 return JSON.parse(text).message.content || ''
56 } catch {
57 return ''
58 }
59}
60hooks/lib/routing.js 52 lines1// Route resolution, the /route argument grammar, and the summary line. Pure.
2import { LABELS } from './config.js'
3
4export function resolveModel(label, routes, sessionModel) {
5 const model = routes[label]
6 if (!model || model === 'claude') return sessionModel
7 return model
8}
9
10function modelByPrefix(arg, routes) {
11 return Object.values(routes).find((m) => m.startsWith(arg))
12}
13
14export function parseRouteArg(arg, routes) {
15 const word = (arg || '').trim().toLowerCase()
16 if (word === '') return { mode: 'show' }
17 if (word === 'auto' || word === 'off') return { mode: word }
18 if (LABELS.includes(word)) return { mode: 'label', label: word }
19 return { mode: 'model', model: modelByPrefix(word, routes) || word }
20}
21
22function fmtTokens(n) {
23 return (n / 1000).toFixed(1) + 'k'
24}
25
26// The API's input_tokens excludes cached tokens, so the context size is the three summed.
27function contextTokens(usage) {
28 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
29}
30
31function cachePercent(usage) {
32 const total = contextTokens(usage)
33 return total ? Math.round((usage.cache_read_input_tokens / total) * 100) : 0
34}
35
36function childNote(child) {
37 if (child.exitCode !== 0) return ' · child failed, main model answered'
38 return ' · child ' + Math.round(child.ms / 1000) + 's'
39}
40
41function usageNote(usage) {
42 if (!usage) return ''
43 return ' · ' + fmtTokens(contextTokens(usage)) + ' in / ' + fmtTokens(usage.output_tokens) + ' out · cache ' + cachePercent(usage) + '%'
44}
45
46// Every turn gets a line: where it ran (child or main session) and what it cost.
47export function summaryLine(decision, usage) {
48 const head = 'route: ' + decision.model + ' · ' + decision.label + ' via ' + decision.source
49 if (decision.child) return head + childNote(decision.child)
50 return head + usageNote(usage) + ' · main session'
51}
52hooks/lib/child.js 51 lines1// The delegate: a headless `claude -p` child on the gateway. Argument vector,
2// scoped environment, readiness, env-file parsing and the context brief. Pure.
3
4const MESSAGE_CHARS = 600
5
6export function expandHome(path, home) {
7 return path.startsWith('~/') ? home + path.slice(1) : path
8}
9
10export function parseEnvFile(text) {
11 const out = {}
12 for (const line of (text || '').split('\n')) {
13 const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)=["']?([^"'\n]*?)["']?\s*$/)
14 if (m && !line.trim().startsWith('#')) out[m[1]] = m[2]
15 }
16 return out
17}
18
19// A custom launcher is trusted to bring its own auth; plain `claude` needs the gateway and a key.
20export function childReady(child, key) {
21 if (child.command[0] !== 'claude') return true
22 return Boolean(child.baseUrl && key)
23}
24
25export function childEnv(child, key) {
26 const env = { MAGGY_ROUTER_CHILD: '1' }
27 if (child.baseUrl) Object.assign(env, { ANTHROPIC_BASE_URL: child.baseUrl, ANTHROPIC_API_KEY: key })
28 return env
29}
30
31// --add-dir keeps the project's CLAUDE.md in reach even when --bare skips auto-discovery.
32export function childArgv(child, model, cwd) {
33 return [...child.command, '-p', '--model', model, '--output-format', 'text', '--max-turns', String(child.maxTurns), '--add-dir', cwd, ...child.args]
34}
35
36function describe(m) {
37 const text = m.text.length > MESSAGE_CHARS ? m.text.slice(0, MESSAGE_CHARS) + '…' : m.text
38 const tools = (m.toolUses || []).map((t) => t.name)
39 return m.role + ': ' + text + (tools.length ? ' [tools: ' + tools.join(', ') + ']' : '')
40}
41
42// What the child reads on stdin: the last few exchanges of the main session, then the task.
43export function buildBrief(messages, prompt, limit) {
44 let history = messages.filter((m) => m.text)
45 const last = history[history.length - 1]
46 if (last && last.role === 'user' && last.text === prompt) history = history.slice(0, -1)
47 history = history.slice(-limit)
48 if (!history.length) return prompt
49 return 'Context from the main session:\n' + history.map(describe).join('\n') + '\n\nTask:\n' + prompt
50}
51hooks/lib/stats.js 14 lines1// Per-day routing counts kept in $.store, the usage-summary idea from Maggy. Pure.
2
3export function bumpStats(stats, day, model) {
4 const today = { ...(stats[day] || {}) }
5 today[model] = (today[model] || 0) + 1
6 return { ...stats, [day]: today }
7}
8
9export function statsLine(stats, day) {
10 const today = stats[day]
11 if (!today) return 'today: no routed turns yet'
12 return 'today: ' + Object.entries(today).map(([m, n]) => m + ' ×' + n).join(', ')
13}
14hooks/lib/ui.js 67 lines1// What the mod draws: the band above the prompt and the provenance tag on a
2// reply. Pure: elements come in as a parameter, trees go out as data.
3
4import { summaryLine } from './routing.js'
5
6const NAME = 'mixture-of-models'
7
8function provider(model) {
9 return model.split('-')[0]
10}
11
12// One button per provider in the routes, framed by auto, claude and off.
13export function bandButtons(routes) {
14 const providers = [...new Set(Object.values(routes).filter((m) => m !== 'claude').map(provider))]
15 const args = ['auto', ...providers, 'claude', 'off']
16 return args.map((arg, i) => ({ key: 'pin-' + arg, label: arg, hotkey: String(i + 1), arg }))
17}
18
19function activeArg(pin) {
20 if (pin.mode === 'model') return pin.model === 'claude' ? 'claude' : provider(pin.model)
21 return pin.mode === 'label' ? '' : pin.mode
22}
23
24function lastDecision(d) {
25 if (!d.model) return 'no prompt yet'
26 return 'last: ' + d.model + ' (' + d.label + ' via ' + d.source + ')'
27}
28
29export function bandText(v) {
30 if (v.disabled) return NAME + ' · inactive inside a child session'
31 if (!v.ready) return NAME + ' · observe-only: no gateway key for the child'
32 if (v.pin.mode === 'off') return NAME + ' · off · everything on ' + v.sessionModel
33 const routed = v.decision.model && v.decision.model !== v.sessionModel
34 if (v.isWorking) return NAME + ' · running ' + (routed ? v.decision.model + ' in a child…' : v.sessionModel + ' in the main session…')
35 if (v.pin.mode === 'model') return NAME + ' · pinned ' + v.decision.model
36 if (v.pin.mode === 'label') return NAME + ' · pinned ' + v.pin.label + ' → ' + v.decision.model
37 return NAME + ' · auto · ' + lastDecision(v.decision)
38}
39
40export function bandTree(el, v, onPin) {
41 const active = activeArg(v.pin)
42 const buttons = bandButtons(v.routes).map((b) =>
43 el.Button({ key: b.key, label: b.label, hotkey: b.hotkey, plain: true, dimColor: b.arg !== active, onPress: () => onPin(b.arg) }),
44 )
45 const row = el.Box({ flexDirection: 'row', columnGap: 2, children: [el.Text({ dimColor: true, children: [bandText(v) + ' '] }), ...buttons] })
46 return el.Box({ flexDirection: 'column', children: [row, v.theirs].filter(Boolean) })
47}
48
49export function tagText(model, sessionModel) {
50 return '⇢ ' + model + (model === sessionModel ? ' · main session' : ' · child on gateway')
51}
52
53export function tagTree(el, text, theirs) {
54 return el.Box({ flexDirection: 'column', children: [el.Text({ dimColor: true, children: [text] }), theirs] })
55}
56
57function pinWord(pin) {
58 if (pin.mode === 'auto') return 'auto'
59 return pin.mode + (pin.model ? ' ' + pin.model : pin.label ? ' ' + pin.label : '')
60}
61
62// The /route answer.
63export function reportText(v) {
64 const live = v.disabled ? 'disabled (child session)' : v.ready ? 'live' : 'observe-only (no gateway key for the child)'
65 return NAME + ' ' + live + ' · mode ' + pinWord(v.pin) + ' · current ' + summaryLine(v.decision, null)
66}
67