A Claude Code mod that routes the main conversation and subagents to other model providers through user-defined model aliases, on your own API key

<h1 align="center">styx</h1>
Claude Code 2.1.292 or later (tested on 2.1.293) and Bun · macOS or Linux · MIT license</p>
styx is a Claude Code mod, a plugin built on Claude Code's function hooks. Your Claude login and subscription keep serving native turns. With your own API key for OpenAI, OpenRouter, the Anthropic API, Amazon Bedrock or any OpenAI-compatible endpoint (a local Ollama or vLLM included), you can send the main conversation (/model <alias>) or a subagent to that provider. Claude Code still runs your tools, and the key stays in your OS keyring.
bun install; the dev dependencies are for contributors. To update, run git pull in the folder, then /reload-plugins. git clone https://github.com/Trosfy/claude-styx.git /path/to/claude-styx
claude plugin marketplace add /path/to/claude-styx
claude plugin install styx@claude-styx
openai, and one alias, gpt. (The folder is $CLAUDE_CONFIG_DIR when that is set.) cd /path/to/claude-styx
cp example.styx.json ~/.claude/styx.json
secret-tool on Linux) and reads it back. On Linux, add --write-config so it also writes the matching key command into your config. bun run auth login openai
/reload-plugins./model gpt. The first time, styx asks you to approve the provider's address and key command. Choose Allow. The status line then reads gpt · openai. Type /model opus (or any native model) to go back.For another provider, edit the config. docs/PROVIDERS.md has a recipe for each one, and docs/CONFIG.md explains every key.
Start Claude Code in the claude-styx folder and paste this:
Set up styx for me: follow docs/AI_SETUP.md in this folder.
The assistant checks for Bun, writes a styx.json for the provider you describe and gives you the command that stores your key. You type the key yourself, so the assistant never sees it, and only you can approve a provider. docs/AI_SETUP.md has the steps it follows.
/model gpt runs the next turns on openai/gpt-5, billed to your API key. /model opus moves back, and the conversation comes with you.mcp__styx__agent tool. Explore, Plan and general-purpose get prompts that styx wrote for them (they are in hooks/agents.ts; Anthropic did not write them). To use your own prompt for one, add an agent file whose name: is the type, for example name: Explore, and styx uses that file instead (a file styx cannot read keeps the type off the alias). Your custom agents keep their own files. Each subagent names the routed model as its identity, not Claude.| Claude Code | 2.1.292 or later. The tool schemas (hooks/schemas.gen.ts, generated from the types Claude Code writes beside the mod) are for 2.1.293; on another build /styx says when they are stale. |
| Bun | 1.3.11 tested. The helper process runs on it. styx needs no packages at run time. |
| A provider | An endpoint that speaks one of the three kinds, a model id and an API key. |
| A keyring | The macOS Keychain, or secret-tool on Linux. Any command that prints the key also works (pass, op read). |
| System | macOS or Linux, with /usr/bin/curl. Optional: git and trash, for subagents that work in a git worktree. |
A provider's kind is the protocol it speaks. If you are unsure, pick openai, which most endpoints speak.
| Kind | Pick it for | styx calls | Status |
|---|---|---|---|
openai | OpenAI, OpenRouter, Ollama, vLLM, other OpenAI-compatible servers and gateways, Bedrock's non-Claude models | <baseUrl>/chat/completions | used live on a self-hosted gateway and on Bedrock's OpenAI endpoint; not yet run against OpenAI itself |
anthropic | The Anthropic API, a gateway's /v1/messages | <baseUrl>/v1/messages | live-tested on a gateway; not yet run against api.anthropic.com |
bedrock | Amazon Bedrock: Claude, GPT, Kimi, Grok | <baseUrl>/model/<id>/converse-stream | live-tested on all four; tool loops tested on Claude |
Recipes with the URLs: docs/PROVIDERS.md.
/model <alias>. Tab completes your aliases. /model opus (or any native model) switches back. A bare /model opens the picker and leaves styx. When you leave a route that answered turns, styx tells the native model which turns were answered elsewhere, so it does not read them as its own.mcp__styx__agent, the subagent runs in the background, and its report arrives as a message. Add isolation: "worktree" to give it a git worktree of its own. While main is native, allow mcp__styx__agent in /permissions to skip the dialog.Agent call with no model runs on that alias too, as Claude Code runs a subagent on its parent's model; some calls stay native. Forks work where Claude Code offers them, auto mode included: a fork of a routed parent runs on its parent's alias or styx refuses it, and a fork of a native parent stays native. Which subagents run on an alias has the rules./styx shows the state: providers (kind, address, key state, approved), aliases with their effort levels, routed subagents, and each recent step's time and tokens. /styx reload re-reads styx.json.anthropic and bedrock kinds can also use the prompt cache and think with signed blocks carried across tool calls (Thinking).Every styx error is one line that ends with its fix, and /styx names the next step when something is wrong. docs/TROUBLESHOOTING.md lists each message and explains the styx step line that claude --debug logs.
/model or the styx agent tool names an alias, the mod builds the prompt (the main conversation's own, read from the session with the model's identity rewritten; a subagent's as What does it do? says) and hands the turn to styxd."cache": "1h"; see Thinking), or when Claude Code does.mcp__styx__agent tool definition. That costs one prompt-cache miss when styx loads, and another each time the config changes.bun run auth login passes the key on stdin to the Keychain (secret-tool on Linux)."allowHttp": true on that provider only. The prompt warns that the key and your data travel unencrypted unless the network is private. Redirects are never followed.Agent calls where Claude Code would ask with no rule or hook behind the question: the ones it makes from a routed model's mcp__styx__agent call, and a plain Agent fork of a routed conversation, which runs on its parent's alias or is refused, never natively. Your deny rules, ask rules and hooks still apply. Which subagents run on an alias has the details.security find-generic-password in /permissions.To report a vulnerability, see SECURITY.md.
styx is open source under the MIT License. Its config shape follows OpenCode's provider model. Claude Code belongs to Anthropic, and styx is an independent mod. styx reads nothing from the Claude Code binary. Each prompt and tool text it sends a model comes from one of four places: styx itself; you (an agent file's body); the running session, read through Claude Code's function hooks (the main conversation's prompt, the transcript, the tools' descriptions); or the types Claude Code writes beside the mod for its authors, from which hooks/schemas.gen.ts (the input schemas of Claude Code's tools) is generated. Where styx must recognise one of Claude Code's own lines, such as the model notice it rewrites or the report reminder it reads, it matches the words that identify the line.
To remove styx, follow Remove styx. To work on styx itself, see CONTRIBUTING.md.
hooks/register.ts 291 lines1// Styx: routes the main conversation (`/model <alias>`) and subagents (the `mcp__styx__agent` tool) to
2// remote providers, answering their turn.step requests itself through the backend. Native requests pass
3// through untouched. The composition root, and the one module that talks to the engine (`$`): it builds the
4// small ports each module works through and wires the modules to the engine's events. The logic lives in
5// load, routing, pool, leave, spawn, inherit, trust, worktree, prompts and ui, over one Session of memory.
6import type { CommandRunResult, EngineInterface, Register } from 'claude-code'
7
8import { createBackend } from './backend'
9import type { Host } from './backend'
10import { typeahead } from './advert'
11import { own } from './config'
12import { dropCall, inheritSpawn, noteCall, routedFork } from './inherit'
13import type { InheritPort } from './inherit'
14import { ended, leftNote } from './leave'
15import { reload } from './load'
16import type { LoadPort, ReloadPort } from './load'
17import { guard, guardFailed } from './pool'
18import type { PromptsPort } from './prompts'
19import type { Backend } from './protocol'
20import { modelCommand, modelFailed, modelSwitched, stepFailed, turnStep, WRAPPER } from './routing'
21import type { ModelPort, RouteStore, Say, Sleeper, StepIo, Transcripts } from './routing'
22import { createSession } from './session'
23import type { Session } from './session'
24import { AGENT_INTERNAL, agentCall, completed, translatedOf, untranslated } from './spawn'
25import type { AgentPort, AgentTypes } from './spawn'
26import { isTrusted } from './trust'
27import type { TrustPort } from './trust'
28import { report, showStatus } from './ui'
29import type { ReportPort, StatusPort } from './ui'
30import { findTrash } from './worktree'
31import type { WorktreePort } from './worktree'
32
33const MAIN = { plugin: 'styx', key: 'main' } as const
34const MAIN_PIN = { plugin: 'styx', key: 'mainPin' } as const
35const ROUTED = { plugin: 'styx', key: 'routed' } as const
36const TRANSLATED = { plugin: 'styx', key: 'translated' } as const
37
38// --- ports: each module's view of `$` ------------------------------------------------------------------
39
40const debug = ($: EngineInterface, text: string) => $.ui.log(text, { to: 'debug' })
41const say = ($: EngineInterface): Say => ({ toast: text => $.ui.toast(text), debug: text => debug($, text) })
42const statusPort = ($: EngineInterface): StatusPort => ({ status: text => $.ui.status(text) })
43
44// Claude Code's configuration directory: $CLAUDE_CONFIG_DIR when set and not empty, else $HOME/.claude.
45async function configDir($: EngineInterface): Promise<string> {
46 return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
47}
48
49// A command run to its end; one that cannot start answers exit code 127 with the reason as stderr.
50const run = ($: EngineInterface) => async (argv: readonly string[], opts?: { timeoutMs?: number }) => {
51 try {
52 return await $.process.run(argv, opts)
53 } catch (err) {
54 return { exitCode: 127, stdout: '', stderr: String(err) }
55 }
56}
57
58// The host the backend works through.
59const host = ($: EngineInterface, s: Session): Host => ({
60 run: (argv, init) => $.process.run(argv, init),
61 spawn: req => $.process.spawn(req),
62 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
63 debug: text => debug($, text),
64 root: $.plugin.root,
65 userAgent: s.userAgent,
66})
67
68const loadPort = ($: EngineInterface): LoadPort => ({
69 configDir: () => configDir($),
70 home: async () => (await $.env.get('HOME').catch(() => undefined)) ?? '',
71 exists: path => $.fs.exists(path),
72 read: async path => String(await $.fs.read(path)),
73 policy: () => $.settings.read({ source: 'policy' }),
74})
75
76// The engine's version, or undefined when it cannot be read.
77async function engineVersion($: EngineInterface): Promise<string | undefined> {
78 try {
79 return (await $.session.version()).version
80 } catch {
81 return undefined
82 }
83}
84
85const reloadPort = ($: EngineInterface): ReloadPort => ({
86 ...loadPort($),
87 pluginRoot: $.plugin.root,
88 version: () => engineVersion($),
89 entrypoint: () => $.env.get('CLAUDE_CODE_ENTRYPOINT').catch(() => undefined),
90 registerAgentTool: async (description, inputSchema) => void (await $.tool.register({ name: 'agent', description, inputSchema })),
91 toast: text => $.ui.toast(text),
92})
93
94const trustPort = ($: EngineInterface): TrustPort => ({
95 trusted: async key => (await $.store.get(key)) === true,
96 remember: key => $.store.set(key, true),
97 ask: (question, options) => $.ui.ask(question, { header: 'styx', options: [...options] }),
98})
99
100const worktreePort = ($: EngineInterface): WorktreePort => ({
101 run: run($),
102 cwd: () => $.session.cwd(),
103 toast: text => $.ui.toast(text),
104 log: text => $.ui.log(text),
105 debug: text => debug($, text),
106})
107
108const promptsPort = ($: EngineInterface): PromptsPort => ({
109 compose: async (model, tools) => (await $.prompt.compose({ model, tools: [...tools] })).sections,
110 cwd: () => $.session.cwd(),
111 configDir: () => configDir($),
112 exists: path => $.fs.exists(path),
113 read: async path => String(await $.fs.read(path)),
114 listDir: async dir => ((await $.fs.exists(dir)) ? $.fs.list(dir) : []),
115 run: run($),
116 now: () => $.clock.now(),
117 debug: text => debug($, text),
118})
119
120const routeStore = ($: EngineInterface): RouteStore => ({
121 main: async () => (await $.state.get(MAIN)).value,
122 setMain: async target => void (await $.state.set(MAIN, target)),
123 pin: async () => (await $.state.get(MAIN_PIN)).value,
124 setPin: async pin => void (await $.state.set(MAIN_PIN, pin)),
125 route: async agentId => (await $.state.get({ ...ROUTED, id: agentId })).value,
126 setRoute: async (agentId, route) => void (await $.state.set({ ...ROUTED, id: agentId }, route)),
127 translated: async who => (await $.state.get({ ...TRANSLATED, id: who })).value,
128 setTranslated: async (who, calls) => void (await $.state.set({ ...TRANSLATED, id: who }, calls)),
129})
130
131// The agent type the engine lists a subagent as, which it started it as.
132const agentTypes = ($: EngineInterface): AgentTypes => ({ agentType: async agentId => (await $.agent.list()).find(a => a.id === agentId)?.type })
133
134const inheritPort = ($: EngineInterface): InheritPort => ({
135 ...promptsPort($),
136 ...routeStore($),
137 ...agentTypes($),
138})
139
140const sleeper = ($: EngineInterface): Sleeper => ({ sleep: (ms, signal) => $.clock.sleep(ms, { signal }) })
141
142const transcripts = ($: EngineInterface): Transcripts => ({
143 transcript: agentId => (agentId === undefined ? $.session.messages({ as: 'api' }) : $.session.messages({ as: 'api', agentId })) as ReturnType<Transcripts['transcript']>,
144})
145
146const stepIo = ($: EngineInterface): StepIo => ({
147 ...say($),
148 ...promptsPort($),
149 ...transcripts($),
150 trusted: trustPort($).trusted,
151 list: () => $.tool.list(),
152 loadedMcp: async () => new Set(((await $.session.usage({ breakdown: 'summary' })).context.breakdown?.mcpTools ?? []).filter(m => m.isLoaded).map(m => m.name)),
153})
154
155const modelPort = ($: EngineInterface): ModelPort => ({
156 ...trustPort($),
157 ...statusPort($),
158 debug: text => debug($, text),
159 setMain: routeStore($).setMain,
160 contextTokens: async () => (await $.session.usage()).context.tokens ?? 0,
161})
162
163const agentPort = ($: EngineInterface): AgentPort => ({
164 ...trustPort($),
165 ...promptsPort($),
166 ...worktreePort($),
167 ...agentTypes($),
168 route: routeStore($).route,
169 setRoute: routeStore($).setRoute,
170 spawn: args => $.agent.spawn(args),
171})
172
173const reportPort = ($: EngineInterface, backend: Backend): ReportPort => ({
174 engineModel: async () => {
175 try {
176 return await $.session.model()
177 } catch {
178 return 'unknown'
179 }
180 },
181 keys: () => backend.status(),
182 trusted: p => isTrusted(trustPort($), p),
183})
184
185// --- hooks -----------------------------------------------------------------------------------------------
186
187export const register: Register = on => {
188 const s = createSession()
189 const backend = createBackend()
190
191 on('session.start', async ($, e, next) => {
192 const b = backend(host($, s))
193 await reload(reloadPort($), s, b)
194 await $.command.register({ name: 'styx', description: 'Show styx routing, providers, aliases and gaps; `/styx reload` re-reads styx.json', argumentHint: '[reload]' })
195 s.trashPath = await findTrash(worktreePort($))
196 debug($, `styx trash: ${s.trashPath ?? 'none'}`)
197 s.main = (await $.state.get(MAIN)).value ?? null
198 showStatus(statusPort($), s)
199 const started = await next(e)
200 // The backend sets its styxd-spawning loop going here, in the hook the session outlives: a styxd spawned
201 // while a step runs would die with that step.
202 await b.start()
203 return started
204 })
205
206 on('tool.describe', { tool: 'mcp__styx__agent' }, ($, e) => ({ ...e, isDeferred: false }))
207
208 // A routed conversation's call is made an Agent call at its step (routing.ts); this answers the rest. A hook-spawned
209 // subagent's call raises the event beneath this hook (re-entry), where nothing can be read: its answer is the reason the step left it.
210 on('tool.call', { tool: 'mcp__styx__agent' }, ($, e) => agentCall(agentPort($), s, e)).catch(($, e, next) =>
211 next.error.kind === 're-entry'
212 ? String(e.tool) === WRAPPER
213 ? untranslated(s, e)
214 : next(e)
215 : { deny: AGENT_INTERNAL },
216 )
217
218 // A plain Agent call under a routed parent: its effort and worktree isolation are kept, and its spawn claims the parent's route.
219 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
220 noteCall(s, e)
221 const done = await next(e)
222 dropCall(s, e, done)
223 return done
224 })
225 on('agent.spawn', ($, e, next) => inheritSpawn(inheritPort($), s, e, next.origin.plugin, next))
226
227 // A routed turn has no classifier verdict for auto mode, so an Agent call styx made of a routed model's styx agent
228 // call is allowed where the engine would ask with no rule or hook behind the question. A deny, an ask a rule
229 // or a hook made, and every other Agent call, stand.
230 // A call whose note a reload took is known by the record in state. A native Agent fork call from a routed
231 // conversation is allowed too: its spawn claims the parent's target or refuses, never native.
232 on('tool.check', { tool: 'Agent' }, async ($, e, next) => {
233 const verdict = await next(e)
234 const plainAsk = verdict.decision === 'ask' && verdict.rule === undefined && verdict.hook === undefined
235 if (!plainAsk) return verdict
236 const id = String(e.tool_use_id)
237 const made = s.calls.get(id)?.target !== undefined || own(await translatedOf({ ...say($), ...routeStore($) }, s, e.agentId ?? 'main'), id) !== undefined
238 const fork = made ? undefined : await routedFork(routeStore($), s, e)
239 if (fork !== undefined) debug($, `styx check ${id}: a fork of ${e.agentId ?? 'main'} on ${fork} is allowed where auto mode would ask; its spawn is claimed on ${fork} or refused`)
240 return made || fork !== undefined ? { ...verdict, decision: 'allow' } : verdict
241 }).catch(($, e, next) => (next.called ? next(e) : { decision: 'deny', reason: 'styx: could not check this Agent call, so it was denied; retry, or see the debug log' }))
242
243 on('tool.call', async ($, e, next) => (await guard({ ...stepIo($), ...routeStore($) }, s, e)) ?? next(e)).catch(($, e, next) => (next.called ? next(e) : (guardFailed(s, e) ?? next(e))))
244
245 on('turn.complete', async ($, e, next) => {
246 const done = await next(e)
247 if (e.agentId !== undefined) await completed({ ...worktreePort($), ...routeStore($) }, s, e.agentId)
248 return done
249 })
250
251 on('turn.step', async function* ($, e, next) {
252 return yield* turnStep({ ...stepIo($), ...routeStore($), ...sleeper($), ...agentTypes($) }, s, backend(host($, s)), e, next.signal, () => next(e))
253 }).catch(async function* ($, e, next) {
254 if (next.called) return yield* next(e)
255 return yield* stepFailed({ ...say($), ...transcripts($) }, s, e, next.error, () => next(e))
256 })
257
258 // The engine shows a command's text under the mod's name ("styx: …"), so the text drops its own prefix.
259 const asCommand = (r: CommandRunResult): CommandRunResult => (r.text?.startsWith('styx: ') ? { ...r, text: r.text.slice(6) } : r)
260 on('command.run', { command: 'model' }, async ($, e, next) => asCommand(await modelCommand(modelPort($), s, e, next)))
261 .catch(async ($, e, next) => asCommand(await modelFailed(s, e, next.called, next)))
262
263 on('command.run', { command: 'styx' }, async ($, e) => {
264 if (e.args.trim() === 'reload') {
265 await reload(reloadPort($), s, backend(host($, s)))
266 $.ui.invalidate('tool.describe')
267 showStatus(statusPort($), s)
268 }
269 for (const line of await report(reportPort($, backend(host($, s))), s)) $.ui.log(line)
270 return {}
271 })
272
273 on('prompt.autocomplete', async ($, e, next) => {
274 const r = await next(e)
275 const rows = s.loaded.config === undefined ? [] : typeahead(s.loaded.config, e.text, e.start, e.token)
276 return rows.length === 0 ? r : { suggestions: [...r.suggestions, ...rows] }
277 })
278
279 // /clear and a resume end the conversation without a session.start, so the notes owed for it end with it.
280 on('session.end', ($, e, next) => (ended(s), next(e)))
281
282 // The first main prompt after a route was left tells native Claude which turns another model answered.
283 on('prompt.submit', ($, e, next) => leftNote(s, e, next)).catch(($, e, next) => next(e))
284
285 // A switch made outside /model (the /config Model row, the SDK) leaves styx too.
286 on('classic.PostModelSwitch', async ($, e, next) => {
287 await modelSwitched({ ...routeStore($), ...say($), ...statusPort($) }, s, e.source)
288 return next(e)
289 })
290}
291hooks/backend.ts 332 lines1// The Backend of this build: a client of styxd (styxd/main.ts), the per-session helper process that holds
2// the codecs, the credential helpers and their keys, and the provider connections. The engine ends a child
3// with the dispatch in whose async context it was spawned, whichever `$` made the call, so a styxd spawned
4// while a step runs ends when that step is aborted (Esc, TaskStop) and takes every other routed step with
5// it. `start`, called in session.start, therefore sets going a loop that lives as long as the session, and
6// every spawn of styxd (the first, and each after a crash, an idle exit or a hot reload) is made in that
7// loop, on use when it is gone. A step styxd dies under before yielding anything is sent once more, unless
8// styxd crashed. styxd answers `ready <socket> <token>` and is reached through curl over that socket; the
9// token goes only in each call's body, on curl's stdin, never in an argv, an environment or a file. A step
10// streams the events styxd writes, one per line; closing the stream kills curl, which cancels the provider
11// request.
12import type { ProcessRunInit, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnRequest, ProcessSpawnResult } from 'claude-code'
13
14import { parseConfig } from './config'
15import { failedStep } from './protocol'
16import type { Backend, ProviderStatus, StepEvent, StepRequest, StepWire } from './protocol'
17import { firstLine } from './redact'
18
19// The host as the backend reaches it: a command run to its end, a command spawned and streamed, a wait, the
20// debug log, the mod's directory, and the User-Agent of provider requests. The hooks module builds it
21// from `$`, which no other module may take.
22export type Host = {
23 run(argv: readonly string[], init: ProcessRunInit): Promise<ProcessRunResult>
24 spawn(req: ProcessSpawnRequest): AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult>
25 sleep(ms: number, signal: AbortSignal): Promise<void>
26 debug(text: string): void
27 root: string
28 userAgent: string | undefined
29}
30
31// A styxd that answered ready: its socket and token, and (once it has exited) whether it crashed.
32type Daemon = { sock: string; token: string; exited: Promise<boolean> }
33type Failed = { error: string }
34// A styxd this module spawned: its handshake, the socket and token once it answered ready, whether it exited
35// in a crash, whether styx stopped it, and how to stop it.
36type Launch = { ready: Promise<Daemon | Failed>; daemon?: Daemon; exited: Promise<boolean>; stopped: boolean; stop(): void }
37
38export const NO_BUN = 'styx: bun not found — install bun (https://bun.sh), then retry'
39const NO_START = 'styx: styxd did not start (see the debug log); run /styx reload, then retry'
40const PAUSED = 'styx: styxd crashed twice; routed models paused until /styx reload'
41const READY_MS = 3000
42const EXIT_MS = 1000
43const CRASH_WINDOW_MS = 60_000
44const curl = (sock: string, call: 'step' | 'status') => ['/usr/bin/curl', '-q', '-sS', '-N', '--unix-socket', sock, '--data-binary', '@-', `http://styxd/${call}`]
45const UA_TOKEN_RE = /^[0-9A-Za-z.+-]{1,64}$/
46
47// The User-Agent of styx's requests, in Claude Code's own form: `claude-code/<version>`, then
48// ` (<entrypoint>)` when the entrypoint is known. Each part must be a plain token, so the header is always
49// printable ASCII; with no usable version it is undefined.
50export function userAgent(version: string | undefined, entrypoint: string | undefined): string | undefined {
51 if (version === undefined || !UA_TOKEN_RE.test(version)) return undefined
52 return entrypoint !== undefined && UA_TOKEN_RE.test(entrypoint) ? `claude-code/${version} (${entrypoint})` : `claude-code/${version}`
53}
54
55// curl's stderr on one line: its own error line when it printed one.
56const curlLine = (stderr: string) => (stderr.split('\n').find(l => l.startsWith('curl: (')) ?? stderr).replace(/\s+/g, ' ').trim().slice(0, 200)
57
58// Where bun is: on PATH, else bun's own install directory; null when it is nowhere, or the lookup cannot run.
59export async function findBun(run: (argv: readonly string[], init: { timeoutMs: number }) => Promise<{ exitCode: number; stdout: string }>): Promise<string | null> {
60 try {
61 const r = await run(['/bin/sh', '-c', 'command -v bun || { test -x "$HOME/.bun/bin/bun" && echo "$HOME/.bun/bin/bun"; }'], { timeoutMs: 5000 })
62 const path = firstLine(r.stdout)
63 return r.exitCode === 0 && path.startsWith('/') ? path : null
64 } catch {
65 return null
66 }
67}
68
69const failure = (text: string, started: number) => failedStep(text, Math.round(performance.now() - started))
70
71// What `p` settles to, or `late` once `ms` have passed (the wait ends with `p`).
72async function within<T, L>(host: Host, ms: number, p: Promise<T>, late: L): Promise<T | L> {
73 const timer = new AbortController()
74 const after = host.sleep(ms, timer.signal).then(
75 () => late,
76 () => late,
77 )
78 const result = await Promise.race([p, after])
79 timer.abort()
80 return result
81}
82
83// The backend's memory is one registration's: the config text and approvals every call carries, the bun
84// found, the styxd in use, and the crashes counted. `createBackend()` makes it; the result works through
85// the host of each call.
86export function createBackend() {
87 let configText = '{}'
88 let approved: readonly string[] = []
89 let bun: string | null | undefined // the bun path; null when none was found
90 let launch: Launch | undefined // the styxd spawned last, until it exits
91 let crashes: number[] = [] // when styxd exited on its own with a failure, within the window
92 let paused = false
93 let home: Host | undefined // the host of the session.start that ran `start`; its loop spawns styxd
94 let starting: Promise<Launch> | undefined // the spawn of styxd the loop was last asked for, until it is made
95 const jobs: (() => void)[] = [] // what the loop was asked to run, oldest first
96 let wake = () => {}
97
98 // Reads a spawned styxd for its life: its ready line (answered once), its stderr into the debug log, and how
99 // it ended (told to `exited`). An exit on its own with a failure counts as a crash; a second within a minute
100 // pauses routing.
101 async function watch(host: Host, child: AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult>, l: Launch, answer: (d: Daemon | Failed) => void, exited: (crashed: boolean) => void) {
102 let out = ''
103 let err = ''
104 let end: ProcessSpawnResult | undefined
105 try {
106 for (;;) {
107 const piece = await child.next()
108 if (piece.done) {
109 end = piece.value
110 break
111 }
112 if (piece.value.stream === 'stderr') {
113 const lines = (err + piece.value.text).split('\n')
114 err = (lines.pop() ?? '').slice(-8192)
115 for (const line of lines) if (line !== '') host.debug(line)
116 } else if (l.daemon === undefined) {
117 out = (out + piece.value.text).slice(0, 4096)
118 const ready = /^ready (\S+) (\S+)\n/.exec(out)
119 if (ready === null) continue
120 answer((l.daemon = { sock: ready[1] as string, token: ready[2] as string, exited: l.exited }))
121 host.debug(`styx styxd ready at ${l.daemon.sock}`)
122 }
123 }
124 } catch (e) {
125 host.debug(`styx styxd: ${firstLine(String(e))}`)
126 }
127 if (launch === l) launch = undefined
128 answer({ error: NO_START })
129 host.debug(`styx styxd exited (code ${end?.code ?? '-'}, signal ${end?.signal ?? '-'})`)
130 // An idle or orphaned exit, a kill from outside (the engine ending the child) and styx's own stop are no crash.
131 const crashed = !(l.stopped || (end !== undefined && (end.code === 0 || end.signal !== null)))
132 if (crashed) {
133 const now = performance.now()
134 crashes = [...crashes.filter(t => now - t < CRASH_WINDOW_MS), now]
135 if (crashes.length >= 2) paused = true
136 }
137 exited(crashed)
138 }
139
140 // Spawns styxd with the bun at `exe`, read for its life by a loop that runs on after the calling hook returns.
141 function spawnStyxd(host: Host, exe: string): Launch {
142 const child = host.spawn({ argv: [exe, `${host.root}/styxd/main.ts`] })
143 let answer: (d: Daemon | Failed) => void = () => {}
144 let exited: (crashed: boolean) => void = () => {}
145 const l: Launch = {
146 ready: new Promise(resolve => (answer = resolve)),
147 exited: new Promise(resolve => (exited = resolve)),
148 stopped: false,
149 stop() {
150 l.stopped = true
151 if (launch === l) launch = undefined
152 void child.return(undefined as never).catch(() => {})
153 },
154 }
155 void watch(host, child, l, answer, exited)
156 return l
157 }
158
159 // Sets going, once, the loop that runs what `inSession` is given, in the dispatch this is called in (the
160 // session.start whose host this is, which the session outlives).
161 function hold(host: Host) {
162 if (home !== undefined) return
163 home = host
164 void (async () => {
165 for (;;) {
166 for (let job = jobs.shift(); job !== undefined; job = jobs.shift()) job()
167 await new Promise<void>(resolve => (wake = resolve))
168 }
169 })()
170 }
171
172 // Runs `job` in that loop, with the host the loop belongs to, so that a child it spawns lasts as long as the
173 // session and not as long as the step asking. With no loop (`start` not called) it runs here, with `host`.
174 function inSession<T>(host: Host, job: (host: Host) => T): Promise<T> {
175 const owner = home
176 if (owner === undefined) return Promise.resolve().then(() => job(host))
177 return new Promise<T>((resolve, reject) => {
178 jobs.push(() => {
179 try {
180 resolve(job(owner))
181 } catch (e) {
182 reject(e)
183 }
184 })
185 wake()
186 })
187 }
188
189 // The styxd in use, spawned (not waited for) when there is none.
190 async function launched(host: Host): Promise<Launch | Failed> {
191 if (launch !== undefined) return launch
192 if (paused) return { error: PAUSED }
193 // Only a found bun is remembered: a lookup cut short by an aborted step is retried on the next step.
194 const exe = bun ?? (await findBun(host.run))
195 if (exe === null) return { error: NO_BUN }
196 bun = exe
197 return (starting ??= inSession(host, h => (launch ??= spawnStyxd(h, exe))).finally(() => (starting = undefined)))
198 }
199
200 // The styxd in use, once it has said ready (at most 3 s after it was spawned).
201 async function ensure(host: Host): Promise<Daemon | Failed> {
202 const l = await launched(host)
203 if ('error' in l) return l
204 if (l.daemon !== undefined) return l.daemon
205 const result = await within<Daemon | Failed, Failed>(host, READY_MS, l.ready, { error: NO_START })
206 if ('error' in result) {
207 host.debug(`styx styxd: no ready line within ${READY_MS} ms`)
208 l.stop()
209 }
210 return result
211 }
212
213 // One call of /step: the events styxd writes, ended with an error and stats of its own when its answer
214 // stopped short. Returns 'gone' when nothing came back, styxd is gone without a crash and `last` is not set:
215 // the caller sends the step again.
216 async function* call(host: Host, d: Daemon, req: StepRequest, signal: AbortSignal, started: number, last: boolean): AsyncGenerator<StepEvent, 'gone' | undefined> {
217 const wire: StepWire = { token: d.token, configText, approved, ...(host.userAgent === undefined ? {} : { userAgent: host.userAgent }), req }
218 const child = host.spawn({ argv: curl(d.sock, 'step'), input: JSON.stringify(wire) })
219 let buf = ''
220 let stderr = ''
221 let yielded = false
222 let ended: ProcessSpawnResult | undefined
223 let finished = false
224 let complete = false
225 const close = () => void child.return(undefined as never).catch(() => {})
226 signal.addEventListener('abort', close, { once: true })
227 try {
228 for (;;) {
229 if (signal.aborted) return undefined
230 const piece = await child.next()
231 if (piece.done) {
232 ended = piece.value
233 break
234 }
235 if (piece.value.stream === 'stderr') {
236 stderr = (stderr + piece.value.text).slice(-4096)
237 continue
238 }
239 const lines = (buf + piece.value.text).split('\n')
240 buf = lines.pop() ?? ''
241 for (const l of lines) {
242 if (l.trim() === '') continue
243 let ev: StepEvent
244 try {
245 ev = JSON.parse(l) as StepEvent
246 } catch {
247 host.debug(`styx styxd sent an unreadable line: ${JSON.stringify(l.slice(0, 120))}`)
248 continue
249 }
250 if (ev.type === 'stop' || ev.type === 'error') finished = true
251 if (ev.type === 'stats') complete = true
252 yielded = true
253 yield ev
254 }
255 }
256 } finally {
257 signal.removeEventListener('abort', close)
258 if (ended === undefined) close()
259 }
260 if (complete || signal.aborted) return undefined
261 // styxd is gone when curl could not connect (7), its connection closed with no reply (52) or curl was
262 // killed; any other end (a plain-text reply and exit 0, say) leaves a live styxd alone.
263 const how = ended.code ?? ended.signal
264 const resend = !yielded && !last && (ended.signal !== null || ended.code === 7 || ended.code === 52)
265 // A connection that closed on this step was styxd ending under it: if that was a crash the step is not
266 // sent again, or the step that crashed styxd would crash the next one too and pause routing.
267 const crashed = resend && ended.code !== 7 && (await within(host, EXIT_MS, d.exited, false))
268 if (resend && !crashed) return 'gone'
269 host.debug(`styx styxd call ended short (curl exit ${how}${stderr === '' ? '' : `: ${curlLine(stderr)}`})`)
270 const text =
271 yielded || crashed
272 ? 'styx: styxd stopped during the step; retry'
273 : ended.code === 0
274 ? 'styx: styxd sent a reply styx could not read (see the debug log); run /styx reload, then retry'
275 : `styx: styxd did not answer (curl exit ${how}); run /styx reload, then retry`
276 const tail = failure(text, started)
277 yield* finished ? tail.slice(1) : tail
278 return undefined
279 }
280
281 async function* step(host: Host, req: StepRequest, signal: AbortSignal): AsyncGenerator<StepEvent, void> {
282 const started = performance.now()
283 for (let attempt = 0; !signal.aborted; attempt++) {
284 const d = await ensure(host)
285 if ('error' in d) return yield* failure(d.error, started)
286 if ((yield* call(host, d, req, signal, started, attempt > 0)) !== 'gone') return
287 // A styxd that answered nothing is gone: started again, and the step sent to it once.
288 if (launch?.daemon === d) launch = undefined
289 }
290 }
291
292 // Each provider's key state as styxd holds it; with no styxd running, no helper has run (a keyless provider has none to run).
293 async function status(host: Host): Promise<ProviderStatus[]> {
294 const none = Object.values(parseConfig(configText).config?.providers ?? {}).map(p => ({ provider: p.id, key: p.auth === 'none' ? ('none' as const) : ('not-run' as const) }))
295 const d = launch?.daemon
296 if (d === undefined) return none
297 try {
298 const r = await host.run(curl(d.sock, 'status'), { stdin: JSON.stringify({ token: d.token, configText, approved }), timeoutMs: 5000 })
299 const states: unknown = JSON.parse(r.stdout)
300 return r.exitCode === 0 && Array.isArray(states) ? (states as ProviderStatus[]) : none
301 } catch {
302 return none
303 }
304 }
305
306 // Takes the config and the approved fingerprints, sent with every call. A configure approving nothing (a
307 // reload) also lifts a crash pause and looks for bun again.
308 function configure(c: { configText: string; approved: readonly string[] }) {
309 configText = c.configText
310 approved = [...c.approved]
311 if (approved.length > 0) return
312 paused = false
313 crashes = []
314 if (bun === null) bun = undefined
315 }
316
317 return (host: Host): Backend => ({
318 configure: async c => configure(c),
319 start: async () => {
320 hold(host)
321 if (Object.keys(parseConfig(configText).config?.providers ?? {}).length === 0) return
322 try {
323 await launched(host)
324 } catch (e) {
325 host.debug(`styx styxd: ${firstLine(String(e))}`)
326 }
327 },
328 step: (req, signal) => step(host, req, signal),
329 status: () => status(host),
330 })
331}
332hooks/advert.ts 74 lines1// What styx offers, derived from a config: every model name, the /model typeahead rows, and the styx agent
2// tool's description and input schema. Pure.
3import { NATIVE_MODELS } from './config'
4import type { Alias, Config, JsonSchema } from './config'
5
6// Every model name the styx agent tool offers: native models, aliases and declared provider/models, sorted.
7export function modelNames(config: Config): string[] {
8 const declared = Object.values(config.providers).flatMap(p => Object.keys(p.models).map(m => `${p.id}/${m}`))
9 return [...new Set([...NATIVE_MODELS, ...Object.keys(config.aliases), ...declared])].sort()
10}
11
12// The /model typeahead rows for the token at `start`: styx aliases and declared models after `/model `. An
13// alias row reads `<alias> <note> (<provider>/<model>)`, a model row `<provider>/<model> styx model`.
14export function typeahead(config: Config, text: string, start: number, token: string) {
15 if (!/^\/model\s+$/.test(text.slice(0, start))) return []
16 return modelNames(config)
17 .filter(n => !NATIVE_MODELS.includes(n) && n.startsWith(token))
18 .map(n => {
19 const a = config.aliases[n]
20 return { text: n, description: a === undefined ? 'styx model' : a.note ? `${a.note} (${a.target})` : `(${a.target})` }
21 })
22}
23
24// The styx agent tool's description: deterministic for a config, aliases sorted by name.
25export function advert(config: Config): string {
26 const names = Object.keys(config.aliases).sort()
27 const rows = names.length
28 ? names.map(n => {
29 const a = config.aliases[n] as Alias
30 return `- ${n}: ${a.target}${a.note ? ` — ${a.note}` : ''}`
31 })
32 : modelNames(config)
33 .filter(n => n.includes('/'))
34 .map(n => `- ${n}`)
35 return [
36 'Launch a subagent like the Agent tool, choosing its model, including custom models (styx).',
37 'Use this instead of Agent when the user asks for a custom model:',
38 ...rows,
39 'Native: opus, sonnet, haiku, fable.',
40 ].join('\n')
41}
42
43const BACKGROUND_DESCRIPTION =
44 'From the main conversation a subagent runs in the background by default, and you are notified when it completes; from a subagent the call waits for the result. false waits for the result, and works only while main or a subagent runs on a styx alias; omit it otherwise.'
45const MODEL_DESCRIPTION =
46 "The model: a native alias or a styx alias from this tool's description. Required, except for subagent_type fork, which runs on the model of the agent that calls it (name that one or none)."
47const ISOLATION_DESCRIPTION =
48 'Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo; it is removed when the agent finishes without changes.'
49// The native Agent parameters a styx agent call can carry (to $.agent.spawn, or to the Agent call styx makes of
50// it), plus styx's own effort and worktree isolation.
51const AGENT_KEYS = ['description', 'prompt', 'subagent_type', 'model', 'effort', 'name', 'isolation', 'run_in_background']
52
53// The styx agent tool's input schema: native Agent's, narrowed to AGENT_KEYS, with `model` widened to
54// modelNames(config) (a call must name one, a fork apart: it runs on its caller's model, so the tool checks
55// that), `isolation` offering worktree only, and `run_in_background` saying where false works.
56export function agentSchema(config: Config, base: JsonSchema): JsonSchema {
57 const properties = (base['properties'] ?? {}) as Readonly<Record<string, JsonSchema>>
58 const required = (base['required'] as readonly string[] | undefined) ?? []
59 const kept = AGENT_KEYS.filter(k => properties[k] !== undefined)
60 const shaped = (k: string): JsonSchema =>
61 k === 'model'
62 ? { ...properties[k], enum: modelNames(config), description: MODEL_DESCRIPTION }
63 : k === 'isolation'
64 ? { type: 'string', enum: ['worktree'], description: ISOLATION_DESCRIPTION }
65 : k === 'run_in_background'
66 ? { type: 'boolean', description: BACKGROUND_DESCRIPTION }
67 : (properties[k] as JsonSchema)
68 return {
69 ...base,
70 properties: Object.fromEntries(kept.map(k => [k, shaped(k)])),
71 required: required.filter(k => kept.includes(k)),
72 }
73}
74hooks/config.ts 398 lines1// Styx's config (styx.json in Claude Code's config directory): parsing, all-or-nothing validation, model-name resolution, and the
2// pure texts and schemas derived from a config. No I/O.
3import type { Effort } from '../types'
4
5export type JsonSchema = Readonly<Record<string, unknown>>
6type Json = Record<string, unknown>
7
8export type ModelConfig = {
9 id: string
10 contextWindow: number
11 maxInputTokens?: number
12 maxOutputTokens: number
13 maxTokensParam?: 'max_tokens' | 'max_completion_tokens'
14 systemRole: 'system' | 'developer' | 'user'
15 effort?: Partial<Record<Effort, Json>>
16 // How long the prompt cache lives, on the kinds that take cache points; absent is off.
17 cache?: Cache
18 tools: boolean
19 parallelToolCalls: boolean
20 vision: boolean
21 params: Json
22 // Body params an alias lays over `params`, kept apart so a null deletes a key from every layer below it.
23 overrides?: Json
24 headers: Record<string, string>
25}
26
27// A prompt-cache lifetime: five minutes or one hour.
28export type Cache = '5m' | '1h'
29// The provider protocols: Chat Completions, the Anthropic Messages API, and Amazon Bedrock Converse.
30export type Kind = 'openai' | 'anthropic' | 'bedrock'
31// A provider's key source: a credential helper run with no shell, whose standard output is the key, kept
32// for ttlSeconds; or "none", for a server that checks no key (no authorization header is sent).
33export type Auth = { command: readonly string[]; ttlSeconds: number } | 'none'
34
35export type ProviderConfig = {
36 id: string
37 kind: Kind
38 baseUrl: string
39 origin: string
40 allowHttp: boolean
41 auth: Auth
42 // How the key is sent: `Authorization: Bearer`, or Anthropic's `x-api-key`.
43 authHeader: 'bearer' | 'x-api-key'
44 headers: Record<string, string>
45 params: Json
46 timeoutMs?: number
47 streamUsage: boolean
48 maxTools: number
49 models: Readonly<Record<string, ModelConfig>>
50}
51
52// An alias names a target and may carry the keys a model takes for a request (see REQUEST_KEYS), which apply
53// only to requests made through the alias.
54// An alias's `cache` key, when present, replaces its model's: absent `cache` there means the cache is off.
55export type Alias = { target: string; note?: string; params?: Json; effort?: ModelConfig['effort']; cache?: Cache; headers?: Record<string, string> }
56export type Config = { providers: Readonly<Record<string, ProviderConfig>>; aliases: Readonly<Record<string, Alias>> }
57
58// What a model name names: a native model (passed to the engine) or a declared remote one. A remote
59// target's `label` is the name it was asked for (an alias or `provider/model`), which routes keep and steps
60// send, so that an alias's overlay is applied again at each step; `target` is the `provider/model` it names.
61export type Target =
62 | { kind: 'native'; model: string; label: string }
63 | { kind: 'remote'; provider: ProviderConfig; model: ModelConfig; target: string; label: string }
64
65// What each kind adds to the common provider and model keys, the request body keys styx sets (refused in
66// params), whether its requests always carry a max-tokens value, and whether it takes cache points (the
67// others cache on their own).
68type KindSpec = { providerKeys: readonly string[]; modelKeys: readonly string[]; reservedParams: readonly string[]; sendsMaxTokens: boolean; cache: boolean }
69export const KINDS: Readonly<Record<Kind, KindSpec>> = {
70 openai: {
71 providerKeys: ['streamUsage'],
72 modelKeys: ['maxTokensParam', 'systemRole', 'parallelToolCalls'],
73 reservedParams: ['model', 'messages', 'tools', 'stream', 'stream_options', 'n', 'tool_choice', 'functions', 'function_call'],
74 sendsMaxTokens: false,
75 cache: false,
76 },
77 anthropic: {
78 providerKeys: ['authHeader'],
79 modelKeys: ['parallelToolCalls'],
80 reservedParams: ['model', 'messages', 'system', 'tools', 'tool_choice', 'stream', 'max_tokens'],
81 sendsMaxTokens: true,
82 cache: true,
83 },
84 bedrock: { providerKeys: [], modelKeys: [], reservedParams: ['messages', 'system', 'toolConfig'], sendsMaxTokens: true, cache: true },
85}
86
87export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
88// The native Agent tool's `model` values.
89export const NATIVE_MODELS: readonly string[] = ['fable', 'haiku', 'opus', 'sonnet']
90
91const ID_RE = /^[a-z][a-z0-9-]{0,31}$/
92// An alias may carry dots (`model.v1-mini`), but never `/` (that splits provider/model), a leading dot or `..`.
93export const ALIAS_RE = /^(?!.*\.\.)[a-z][a-z0-9.-]{0,63}$/
94const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
95const SECRET_HEADER_RE = /auth|key|token|secret|password|credential|cookie|session|signature/i
96const HEADER_VALUE_RE = /^[\x20-\x7e]*$/
97// A DNS host name: two or more ASCII labels of letters, digits and dashes, no label starting or ending with
98// a dash, and no trailing dot.
99const HOST_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/
100export const RESERVED_ALIASES = new Set(['fable', 'opus', 'sonnet', 'haiku', 'default', 'inherit', 'native'])
101const PROVIDER_KEYS = ['kind', 'baseUrl', 'allowHttp', 'auth', 'headers', 'params', 'timeoutMs', 'maxTools', 'models']
102// The keys a model and an alias share: what a request is built from.
103const REQUEST_KEYS = ['params', 'effort', 'cache', 'headers']
104const MODEL_KEYS = ['contextWindow', 'maxInputTokens', 'maxOutputTokens', 'tools', 'vision', ...REQUEST_KEYS]
105const KEY_TTL_S = 300
106// The longest timer a runtime takes (2^31 - 1 ms); a longer one fires at once.
107export const MAX_TIMEOUT_MS = 2 ** 31 - 1
108
109export const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
110// A map's own entry: a name such as `toString` or `constructor` is never read from the prototype.
111export const own = <T>(map: Readonly<Record<string, T>>, key: string): T | undefined => (Object.hasOwn(map, key) ? map[key] : undefined)
112const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v > 0
113const isKind = (v: unknown): v is Kind => typeof v === 'string' && Object.hasOwn(KINDS, v)
114
115// Unknown keys of `obj`, each reported with its path.
116function allowKeys(obj: Json, keys: readonly string[], path: string, errors: string[]) {
117 for (const k of Object.keys(obj)) if (!keys.includes(k)) errors.push(`${path}.${k}: unknown key`)
118}
119
120function params(v: unknown, kind: Kind, path: string, errors: string[]): Json {
121 if (v === undefined) return {}
122 if (!isObject(v)) return errors.push(`${path}: must be an object`), {}
123 for (const k of KINDS[kind].reservedParams) if (k in v) errors.push(`${path}.${k}: reserved; styx sets it`)
124 return v
125}
126
127// An effort map: each level (low to max) with the body params it applies.
128function effortMap(v: unknown, kind: Kind, path: string, errors: string[]): Partial<Record<Effort, Json>> | undefined {
129 if (v === undefined) return undefined
130 if (!isObject(v)) return errors.push(`${path}: must be an object`), undefined
131 allowKeys(v, EFFORTS, path, errors)
132 const levels: Partial<Record<Effort, Json>> = {}
133 for (const level of EFFORTS) {
134 if (!(level in v)) continue
135 if (!isObject(v[level])) errors.push(`${path}.${level}: must be an object of params`)
136 else levels[level] = params(v[level], kind, `${path}.${level}`, errors)
137 }
138 return levels
139}
140
141// A prompt-cache lifetime; absent is off. A kind that caches on its own takes none. An alias (`off`) may
142// give null, which turns off the cache its model sets and is read as an absent lifetime.
143function cacheOf(v: unknown, kind: Kind, path: string, errors: string[], off: boolean): Cache | undefined {
144 if (v === undefined) return undefined
145 if (!KINDS[kind].cache) errors.push(`${path}: not supported on the ${kind} kind (it caches on its own)`)
146 else if (v === null && off) return undefined
147 else if (v !== '5m' && v !== '1h') errors.push(`${path}: must be "5m" or "1h"${off ? ' (null turns the model\'s cache off)' : ''}`)
148 return v as Cache
149}
150
151// A `thinking.budget_tokens` in `p` (Bedrock's additionalModelRequestFields hold it too) counts toward the
152// response's max tokens, so it must be below `max`.
153function thinkingBudget(p: Json, max: number, path: string, errors: string[]) {
154 const nested = p['additionalModelRequestFields']
155 for (const holder of [p, isObject(nested) ? nested : {}]) {
156 const t = holder['thinking']
157 const budget = isObject(t) ? t['budget_tokens'] : undefined
158 if (typeof budget === 'number' && budget >= max) errors.push(`${path}.thinking.budget_tokens: ${budget} must be below maxOutputTokens (${max})`)
159 }
160}
161
162// The request keys of a model or an alias, checked the same way for both; `max` is the model's
163// maxOutputTokens, and a `cache` key is kept only when given (an alias's replaces its model's).
164function requests(v: Json, kind: Kind, path: string, errors: string[], max: number, alias = false) {
165 const effort = effortMap(v['effort'], kind, `${path}.effort`, errors)
166 const p = params(v['params'], kind, `${path}.params`, errors)
167 thinkingBudget(p, max, `${path}.params`, errors)
168 for (const [level, lp] of Object.entries(effort ?? {})) thinkingBudget(lp, max, `${path}.effort.${level}`, errors)
169 return { effort, params: p, ...('cache' in v ? { cache: cacheOf(v['cache'], kind, `${path}.cache`, errors, alias) } : {}), headers: headers(v['headers'], `${path}.headers`, errors) }
170}
171
172function headers(v: unknown, path: string, errors: string[]): Record<string, string> {
173 if (v === undefined) return {}
174 if (!isObject(v)) return errors.push(`${path}: must be an object`), {}
175 const out: Record<string, string> = {}
176 for (const [name, value] of Object.entries(v)) {
177 if (!HEADER_NAME_RE.test(name)) errors.push(`${path}.${name}: not a header name`)
178 else if (SECRET_HEADER_RE.test(name)) errors.push(`${path}.${name}: auth-bearing headers are not allowed; the key comes from auth.command`)
179 else if (typeof value !== 'string' || !HEADER_VALUE_RE.test(value)) errors.push(`${path}.${name}: must be printable ASCII text`)
180 else out[name] = value
181 }
182 return out
183}
184
185function bool(v: unknown, fallback: boolean, path: string, errors: string[]): boolean {
186 if (v === undefined) return fallback
187 if (typeof v !== 'boolean') errors.push(`${path}: must be true or false`)
188 return v === true
189}
190
191// The provider's credential helper: an argv whose first entry is an absolute path (nothing expands `~` or
192// searches PATH), shown verbatim when the provider is approved; or "none". The approval, not this check, is
193// what stands between an edited config and a helper run.
194function auth(id: string, v: Json, path: string, errors: string[]): Auth {
195 const login = `bun run auth login ${id}, which stores the key in the keychain and writes "auth": { "command": [...] }`
196 const a = v['auth']
197 if (v['apiKeyEnv'] !== undefined) errors.push(`${path}.apiKeyEnv: no longer read; keys come from a credential helper. Run ${login} in its place`)
198 else if (a === undefined) errors.push(`${path}.auth: missing; run ${login}, or set "auth": "none" for a server that checks no key`)
199 if (a === undefined) return { command: [], ttlSeconds: KEY_TTL_S }
200 if (a === 'none') return a
201 if (!isObject(a)) return errors.push(`${path}.auth: must be { "command": ["/absolute/helper", ...] } or "none"`), { command: [], ttlSeconds: KEY_TTL_S }
202 for (const k of Object.keys(a)) {
203 if (k === 'env') errors.push(`${path}.auth.env: not supported; keys come from "command" (run ${login})`)
204 else if (k !== 'command' && k !== 'ttlSeconds') errors.push(`${path}.auth.${k}: unknown key`)
205 }
206 const { command, ttlSeconds } = a
207 const argv = Array.isArray(command) && command.every(s => typeof s === 'string') ? (command as string[]) : undefined
208 if (argv === undefined || argv.length === 0) errors.push(`${path}.auth.command: must be a non-empty list of strings`)
209 else if (!(argv[0] as string).startsWith('/')) errors.push(`${path}.auth.command: the first entry must be an absolute path (no shell expands ~ or searches PATH)`)
210 else if (argv.some(s => /\p{Cc}/u.test(s))) errors.push(`${path}.auth.command: must not contain control characters`)
211 if (ttlSeconds !== undefined && !isCount(ttlSeconds)) errors.push(`${path}.auth.ttlSeconds: must be a positive integer`)
212 return { command: argv ?? [], ttlSeconds: (ttlSeconds as number | undefined) ?? KEY_TTL_S }
213}
214
215function model(id: string, kind: Kind, v: unknown, path: string, errors: string[]): ModelConfig | undefined {
216 if (!isObject(v)) return errors.push(`${path}: must be an object`), undefined
217 const spec = KINDS[kind]
218 allowKeys(v, [...MODEL_KEYS, ...spec.modelKeys], path, errors)
219 // A key of another kind is reported above and read as absent.
220 const mine = (k: string) => (spec.modelKeys.includes(k) ? v[k] : undefined)
221 const { contextWindow, maxInputTokens, maxOutputTokens } = v
222 const maxTokensParam = mine('maxTokensParam')
223 const systemRole = mine('systemRole')
224 if (!isCount(contextWindow)) errors.push(`${path}.contextWindow: must be a positive integer`)
225 if (!isCount(maxOutputTokens)) errors.push(`${path}.maxOutputTokens: must be a positive integer`)
226 if (maxInputTokens !== undefined && (!isCount(maxInputTokens) || (isCount(contextWindow) && maxInputTokens > contextWindow))) {
227 errors.push(`${path}.maxInputTokens: must be a positive integer no larger than contextWindow`)
228 }
229 if (maxTokensParam !== undefined && maxTokensParam !== 'max_tokens' && maxTokensParam !== 'max_completion_tokens') {
230 errors.push(`${path}.maxTokensParam: must be max_tokens or max_completion_tokens`)
231 }
232 if (systemRole !== undefined && systemRole !== 'system' && systemRole !== 'developer' && systemRole !== 'user') {
233 errors.push(`${path}.systemRole: must be system, developer or user`)
234 }
235 const out: ModelConfig = {
236 id,
237 contextWindow: contextWindow as number,
238 ...(maxInputTokens === undefined ? {} : { maxInputTokens: maxInputTokens as number }),
239 maxOutputTokens: maxOutputTokens as number,
240 ...(maxTokensParam === undefined ? {} : { maxTokensParam: maxTokensParam as ModelConfig['maxTokensParam'] & string }),
241 systemRole: (systemRole as ModelConfig['systemRole'] | undefined) ?? 'system',
242 tools: bool(v['tools'], true, `${path}.tools`, errors),
243 parallelToolCalls: bool(mine('parallelToolCalls'), true, `${path}.parallelToolCalls`, errors),
244 vision: bool(v['vision'], true, `${path}.vision`, errors),
245 ...requests(v, kind, path, errors, isCount(maxOutputTokens) ? maxOutputTokens : Infinity),
246 }
247 if (isCount(contextWindow) && isCount(maxOutputTokens) && inputBudget(out, kind) <= 0) {
248 errors.push(`${path}: maxOutputTokens leaves no input budget inside contextWindow`)
249 }
250 return out
251}
252
253function provider(id: string, v: unknown, path: string, errors: string[]): ProviderConfig | undefined {
254 if (!ID_RE.test(id) || id === 'native') errors.push(`${path}: provider ids are lowercase letters, digits and dashes (not "native")`)
255 if (!isObject(v)) return errors.push(`${path}: must be an object`), undefined
256 const { kind: rawKind, baseUrl, timeoutMs, maxTools, models } = v
257 if (!isKind(rawKind)) errors.push(`${path}.kind: must be "openai", "anthropic" or "bedrock"`)
258 // An unknown kind's keys are checked as openai's.
259 const kind = isKind(rawKind) ? rawKind : 'openai'
260 const spec = KINDS[kind]
261 // A retired apiKeyEnv is reported by auth(), with what replaced it.
262 allowKeys(v, [...PROVIDER_KEYS, ...spec.providerKeys, 'apiKeyEnv'], path, errors)
263 const allowHttp = bool(v['allowHttp'], false, `${path}.allowHttp`, errors)
264 let origin = ''
265 let base = ''
266 if (typeof baseUrl !== 'string') errors.push(`${path}.baseUrl: must be ${allowHttp ? 'an http or https' : 'an https'} URL`)
267 else if (/[\\\s]/.test(baseUrl)) errors.push(`${path}.baseUrl: must not contain a backslash or whitespace`)
268 else if (/[[\]{}]/.test(baseUrl)) errors.push(`${path}.baseUrl: must not contain [, ], { or } (URL templates and IPv6 literals are not supported)`)
269 else {
270 let url: URL | undefined
271 try {
272 url = new URL(baseUrl)
273 } catch {
274 errors.push(`${path}.baseUrl: not a URL`)
275 }
276 if (url !== undefined) {
277 const plain = url.protocol === 'http:'
278 if (url.protocol !== 'https:' && !(plain && allowHttp)) errors.push(`${path}.baseUrl: must use https${plain ? ' (plain http needs "allowHttp": true on the provider)' : ''}`)
279 else if (!HOST_RE.test(url.hostname)) errors.push(`${path}.baseUrl: the host must be a DNS name (letters, digits and dashes, with no trailing dot)`)
280 if (url.username !== '' || url.password !== '') errors.push(`${path}.baseUrl: must not carry credentials`)
281 if (/[?#]/.test(baseUrl)) errors.push(`${path}.baseUrl: must not carry a query or fragment`)
282 origin = url.origin
283 // The parsed URL, serialized: curl requests exactly the URL whose origin the approval names.
284 base = url.href.replace(/\/+$/, '')
285 }
286 }
287 const key = auth(id, v, path, errors)
288 if (key === 'none' && kind === 'bedrock') errors.push(`${path}.auth: "none" is not allowed on a bedrock provider; Bedrock needs a key`)
289 // An anthropic provider sends `x-api-key` unless it names bearer (a gateway, bedrock-mantle); the others
290 // send bearer.
291 const authHeader = spec.providerKeys.includes('authHeader') ? v['authHeader'] : undefined
292 if (authHeader !== undefined && authHeader !== 'bearer' && authHeader !== 'x-api-key') errors.push(`${path}.authHeader: must be "bearer" or "x-api-key"`)
293 if (timeoutMs !== undefined && !(isCount(timeoutMs) && timeoutMs <= MAX_TIMEOUT_MS)) errors.push(`${path}.timeoutMs: must be a positive integer of at most ${MAX_TIMEOUT_MS}`)
294 if (maxTools !== undefined && !isCount(maxTools)) errors.push(`${path}.maxTools: must be a positive integer`)
295 const out: Record<string, ModelConfig> = {}
296 if (!isObject(models)) errors.push(`${path}.models: must be a map of model ids`)
297 else {
298 if (Object.keys(models).length === 0) errors.push(`${path}.models: declares no models`)
299 for (const [mid, m] of Object.entries(models)) {
300 if (mid === '') errors.push(`${path}.models: a model id is empty`)
301 const parsed = model(mid, kind, m, `${path}.models.${mid}`, errors)
302 if (parsed !== undefined) out[mid] = parsed
303 }
304 }
305 const shared = params(v['params'], kind, `${path}.params`, errors)
306 thinkingBudget(shared, Math.min(...Object.values(out).map(m => m.maxOutputTokens)), `${path}.params`, errors)
307 return {
308 id,
309 kind,
310 baseUrl: base,
311 origin,
312 allowHttp,
313 auth: key,
314 authHeader: (authHeader as ProviderConfig['authHeader'] | undefined) ?? (kind === 'anthropic' ? 'x-api-key' : 'bearer'),
315 headers: headers(v['headers'], `${path}.headers`, errors),
316 params: shared,
317 ...(timeoutMs === undefined ? {} : { timeoutMs: timeoutMs as number }),
318 streamUsage: bool(spec.providerKeys.includes('streamUsage') ? v['streamUsage'] : undefined, true, `${path}.streamUsage`, errors),
319 maxTools: (maxTools as number | undefined) ?? 128,
320 models: out,
321 }
322}
323
324// Parses and validates a config file's text. Any error leaves `config` undefined: styx routes nothing.
325export function parseConfig(text: string): { config?: Config; errors: string[] } {
326 let raw: unknown
327 try {
328 raw = JSON.parse(text)
329 } catch (err) {
330 return { errors: [`not JSON: ${String(err)}`] }
331 }
332 const errors: string[] = []
333 if (!isObject(raw)) return { errors: ['the config must be a JSON object'] }
334 allowKeys(raw, ['$schema', 'version', 'providers', 'aliases'], '$', errors)
335 if (raw['version'] !== undefined && raw['version'] !== 1) errors.push('version: unsupported (styx reads version 1)')
336 const providers: Record<string, ProviderConfig> = {}
337 if (raw['providers'] !== undefined && !isObject(raw['providers'])) errors.push('providers: must be an object')
338 for (const [id, p] of Object.entries(isObject(raw['providers']) ? raw['providers'] : {})) {
339 const parsed = provider(id, p, `providers.${id}`, errors)
340 if (parsed !== undefined) providers[id] = parsed
341 }
342 const aliases: Record<string, Alias> = {}
343 const partial: Config = { providers, aliases: {} }
344 if (raw['aliases'] !== undefined && !isObject(raw['aliases'])) errors.push('aliases: must be an object')
345 for (const [name, a] of Object.entries(isObject(raw['aliases']) ? raw['aliases'] : {})) {
346 const path = `aliases.${name}`
347 if (!ALIAS_RE.test(name)) errors.push(`${path}: alias names start with a lowercase letter and hold lowercase letters, digits, dashes and dots (no "..", at most 64 characters)`)
348 if (RESERVED_ALIASES.has(name)) errors.push(`${path}: "${name}" is a built-in model name`)
349 const alias = typeof a === 'string' ? { target: a } : isObject(a) ? a : undefined
350 if (alias === undefined) {
351 errors.push(`${path}: must be "provider/model" or { target, note }`)
352 continue
353 }
354 if (isObject(a)) allowKeys(a, ['target', 'note', ...REQUEST_KEYS], path, errors)
355 const { target, note } = alias as Json
356 if (note !== undefined && (typeof note !== 'string' || note.length > 120 || /[\r\n]/.test(note))) {
357 errors.push(`${path}.note: must be one line of at most 120 characters`)
358 }
359 if (typeof target !== 'string') {
360 errors.push(`${path}.target: must be "provider/model"`)
361 continue
362 }
363 const why = undeclared(partial, target)
364 if (why !== undefined) errors.push(`${path} → ${why}`)
365 // The request keys are checked against the kind and the output limit of the model the alias targets.
366 const slash = target.indexOf('/')
367 const host = why === undefined ? own(providers, target.slice(0, slash)) : undefined
368 const set = REQUEST_KEYS.some(k => k in alias)
369 if (set && why === undefined && host === undefined) errors.push(`${path}: ${REQUEST_KEYS.join(', ')} need a provider/model target, not a native one`)
370 const max = host === undefined ? Infinity : (own(host.models, target.slice(slash + 1))?.maxOutputTokens ?? Infinity)
371 aliases[name] = { target, ...(typeof note === 'string' ? { note } : {}), ...(set && host !== undefined ? requests(alias as Json, host.kind, path, errors, max, true) : {}) }
372 }
373 return errors.length > 0 ? { errors } : { config: { providers, aliases }, errors }
374}
375
376// Why `name` (`provider/model`) is not a declared target, or undefined when it is one.
377export function undeclared(config: Config, name: string): string | undefined {
378 const slash = name.indexOf('/')
379 if (slash <= 0) return `"${name}" is not provider/model`
380 const [pid, mid] = [name.slice(0, slash), name.slice(slash + 1)]
381 if (pid === 'native') return mid === '' ? `"${name}" names no model` : undefined
382 const p = own(config.providers, pid)
383 const declared = Object.keys(config.providers).join(', ') || 'none'
384 if (p === undefined) return `provider "${pid}" is not declared (declared: ${declared})`
385 if (own(p.models, mid) === undefined) return `"${mid}" is not declared under provider ${pid} (declared: ${Object.keys(p.models).join(', ')})`
386 return undefined
387}
388
389// The trust-on-first-use identity of a provider: an approval covers exactly this kind, origin and helper
390// argv (or "none"), so any change to the helper asks again.
391export const fingerprint = (p: ProviderConfig) => `${p.kind}|${p.origin}|${p.auth === 'none' ? 'none' : `cmd:${JSON.stringify(p.auth.command)}`}`
392
393// The tokens a request may carry: the input cap, and no more than the window leaves after a sent max-tokens
394// value (always sent by the anthropic and bedrock kinds; by openai when maxTokensParam names its key).
395export function inputBudget(m: Pick<ModelConfig, 'contextWindow' | 'maxInputTokens' | 'maxOutputTokens' | 'maxTokensParam'>, kind: Kind): number {
396 return Math.min(m.maxInputTokens ?? Infinity, m.contextWindow - (KINDS[kind].sendsMaxTokens || m.maxTokensParam ? m.maxOutputTokens : 0))
397}
398hooks/inherit.ts 219 lines1// A plain Agent call under a routed parent. Natively a subagent with no model of its own runs on its parent's
2// model, but the engine believes a routed parent is native, so styx claims the spawn: when the parent (main
3// for the turn under way, or a routed subagent) is routed, the subagent gets the parent's route, which the
4// turn.step hook then answers as it answers a styx agent tool subagent's. What the Agent tool decided itself
5// stands: an explicit model, a definition's own model, a cwd, a teammate and a workflow agent stay native, and so
6// does a subagent the engine resolved to any model but its parent's. So does one styx cannot reproduce faithfully,
7// which stays native with one debug line: a custom type it has no definition for (no prompt or tool list) or finds
8// more than one of, a built-in type styx cannot run (NOT_REPRODUCED), a built-in type that more than one agent file
9// names, a type a hook beneath rewrote, a spawn from a subagent that works in a worktree, and an isolated subagent
10// whose worktree the engine did not make.
11// A fork is held to a stricter rule, because it carries its parent's whole conversation: under a routed parent it
12// runs on that parent's own target, with its parent's prompt, or it is refused. It never runs natively there and
13// never on another target. Only a fork whose parent is known to be native is left alone, as the engine runs it.
14// Pure over a port.
15import type { AgentSpawnInput, AgentSpawnResult } from 'claude-code'
16
17import type { Effort, Route } from '../types'
18import { FORK, isFork } from './agents'
19import { EFFORTS, own } from './config'
20import { resolve } from './names'
21import { claimable } from './prompts'
22import type { PromptsPort } from './prompts'
23import type { RouteStore } from './routing'
24import type { CallNote, Session } from './session'
25import { persistRoute, translatedOf, unclaimable, whileSpawning } from './spawn'
26import { engineWorktree } from './worktree'
27
28export type InheritPort = PromptsPort &
29 Pick<RouteStore, 'pin' | 'route' | 'setRoute' | 'translated'> & {
30 // The agent type the engine started the subagent `agentId` as, when it lists it.
31 agentType(agentId: string): Promise<string | undefined>
32 }
33
34const INHERIT = 'inherit'
35
36// Keeps an Agent call's `effort` and isolation until its spawn takes them (an isolation other than
37// `worktree`, such as `remote`, is one styx cannot reproduce, so that spawn stays native): the spawn event
38// carries neither. They go beside what the call already has noted (the target of a call styx made). A call
39// refused or failed is dropped by `dropCall`; any other that never spawns leaves its note behind, so only
40// the latest 64 stay.
41export function noteCall(s: Session, e: { tool_use_id: string; effort?: unknown; isolation?: unknown }) {
42 const iso = typeof e.isolation === 'string' ? e.isolation : undefined
43 const note: CallNote = {
44 ...s.calls.get(e.tool_use_id),
45 ...(EFFORTS.includes(e.effort as Effort) ? { effort: e.effort as Effort } : {}),
46 ...(iso === 'worktree' ? { isolated: true } : iso === undefined ? {} : { elsewhere: iso }),
47 }
48 if (Object.keys(note).length > 0) s.note(e.tool_use_id, note)
49}
50
51// Forgets the note of an Agent call whose result is a refusal or an error: no spawn follows it.
52export function dropCall(s: Session, e: { tool_use_id: string }, result: { deny?: unknown; isError?: unknown }) {
53 if (result.deny !== undefined || result.isError === true) s.calls.delete(e.tool_use_id)
54}
55
56type ParentRoute = Pick<Route, 'target' | 'effort' | 'worktree' | 'wasIsolated'>
57
58// The route the spawning agent runs on, with no `route` when it is native: main's for the turn under way (its
59// pin, else the persisted one), or the route of the subagent that spawns (a subagent styx holds no route for runs
60// natively). `unreadable` says why it is not known: a read that failed, or main with no pin recorded. A plain spawn
61// takes that as native, as it always has; a fork is refused on it, since it may carry a routed conversation.
62async function parentRoute(io: Pick<InheritPort, 'pin' | 'route'>, s: Session, parent: string | undefined): Promise<{ route?: ParentRoute } | { unreadable: string }> {
63 try {
64 if (parent !== undefined) {
65 const route = s.routes.get(parent) ?? (await io.route(parent))
66 return route === undefined ? {} : { route }
67 }
68 const pin = s.pin ?? (await io.pin())
69 if (pin === null || pin === undefined) return { unreadable: "main's route for this turn is not recorded" }
70 return pin.target === null ? {} : { route: { target: pin.target } }
71 } catch (err) {
72 return { unreadable: String(err) }
73 }
74}
75
76// The styx target a native Agent fork call `e` would be claimed on: its type is a fork (any spelling isFork reads) and
77// its parent (main for the turn under way (pin), or the subagent that calls (route)) runs on a styx target that can be
78// read. Such a fork is claimed on that target or refused at its spawn, never run natively, so the auto-mode check may
79// allow it as it allows a call styx made. Undefined for any other type, a native parent, or a parent whose route cannot be read.
80export async function routedFork(io: Pick<InheritPort, 'pin' | 'route'>, s: Session, e: { input?: unknown; agentId?: string }): Promise<string | undefined> {
81 if (!isFork((e.input as Record<string, unknown> | undefined)?.['subagent_type'])) return undefined
82 const read = await parentRoute(io, s, e.agentId)
83 return 'route' in read && typeof read.route?.target === 'string' ? read.route.target : undefined
84}
85
86// The line a fork under a routed parent is refused with, before it starts, and the one its first step hands back
87// when it started but could not be claimed. Either way it does not run, natively or on another target.
88const forkDenied = (why: string) => `styx: this fork was refused: ${why}; a fork runs only on its parent's model, never natively under a routed parent`
89const forkElsewhere = (asked: string | null, at: string | null) => forkDenied(`it was asked for on ${asked ?? 'a native model'}, but its parent runs on ${at ?? 'a native model'}`)
90
91// A model id without its context-size suffix (`[1m]`), lowercase.
92const bare = (model: string) => model.replace(/\[[^\]]*\]$/, '').trim().toLowerCase()
93const sameModel = (a: string | undefined, b: string | undefined) => a !== undefined && b !== undefined && bare(a) === bare(b)
94
95// The note of an Agent call styx made whose note is gone from memory (a hot reload, or the cap): the target of
96// the model the call's record in state names; a refusal when that model no longer resolves. Undefined for a
97// call that has no record, which is any other Agent call.
98async function recovered(io: InheritPort, s: Session, e: AgentSpawnInput): Promise<{ target: string | null } | { deny: string } | undefined> {
99 const model = own(await translatedOf(io, s, e.parentAgentId ?? 'main'), e.tool_use_id)
100 if (model === undefined) return undefined
101 const t = s.loaded.config === undefined ? undefined : resolve(s.loaded.config, model)
102 if (t === undefined) return { deny: `styx agent: ${model} is not configured any more, so the subagent did not start; fix ${s.loaded.path} and ask again` }
103 return { target: t.kind === 'remote' ? t.label : null }
104}
105
106// An agent.spawn: `spawn` starts the subagent beneath (with the event it is given), and `origin` names the
107// plugin that raised the spawn. Only the Agent tool's own (`engine`) is claimed, never styx's agent tool's. A
108// subagent that claims a route has it written in memory before its first step looks, then persisted like the
109// styx agent tool's. A subagent isolated by the call (`isolation: "worktree"`) or by its definition's
110// frontmatter is claimed only when the engine made its worktree, which then is the route's `worktree`: the
111// directory the subagent's prompt names as its own. The route of a child of a subagent names it as `parent`
112// (a child of main has none), which holds the child's tools within the parent's (pool.ts). A call noted with a
113// styx target (an Agent call styx made of a styx agent call) is claimed for that target, whatever its parent
114// runs on, and started on its parent's engine model; when styx cannot claim it before it starts, the spawn is
115// denied with one line. A fork under a routed parent is claimed on that parent's target alone, whatever was noted
116// for it, with the parent's effort (the engine ignores a fork's own); what stops the claim refuses it, before it
117// starts or at its first step, and a parent whose route cannot be read refuses it too.
118export async function inheritSpawn(io: InheritPort, s: Session, e: AgentSpawnInput, origin: string, spawn: (e: AgentSpawnInput) => Promise<AgentSpawnResult>): Promise<AgentSpawnResult> {
119 let note = s.calls.get(e.tool_use_id)
120 s.calls.delete(e.tool_use_id)
121 // The model's own Agent call: the engine's, or one the engine attributes to styx because the loop it was made in
122 // was started by styx's own spawn (every dispatch of that loop carries styx as its origin); styx's own spawn is
123 // the one with no parent loop.
124 const own = origin === 'engine' || (origin === 'styx' && e.parentAgentId !== undefined)
125 if (note?.target === undefined && own && e.workflow === undefined) {
126 const back = await recovered(io, s, e)
127 if (back !== undefined && 'deny' in back) return back
128 if (back !== undefined) note = { ...note, ...back }
129 }
130 // The engine starts any spelling of `fork` as a fork; either sign makes this spawn one.
131 const fork = e.fork || isFork(e.subagentType)
132 // A fork takes no model of its own (the engine ignores it), so a fork is as plain as a call that names none.
133 const plain = own && (e.model === undefined || e.model === INHERIT || fork) && e.isTeammate === undefined && e.workflow === undefined
134 const read = plain || fork ? await parentRoute(io, s, e.parentAgentId) : {}
135 const parent = 'route' in read ? read.route : undefined
136 const noted = own ? note?.target : undefined
137 if (fork) {
138 if ('unreadable' in read) {
139 io.debug(`styx inherit: the route of the parent of a fork cannot be read (${read.unreadable}); the call is refused`)
140 return { deny: forkDenied(`styx cannot read its parent's route (${read.unreadable}); retry`) }
141 }
142 // A noted target is what a styx agent call asked for; a fork takes its parent's model only, so any other is refused.
143 if (noted !== undefined && noted !== (parent?.target ?? null)) return { deny: forkElsewhere(noted, parent?.target ?? null) }
144 if ((parent?.target ?? null) === null) return spawn(e)
145 }
146 // The target a spawn must run on or be refused: a fork's parent's, else the one a styx agent call asked for.
147 const chosen = fork ? (parent?.target ?? undefined) : typeof noted === 'string' ? noted : undefined
148 const effort = fork ? parent?.effort : note?.effort
149 const target = chosen ?? parent?.target ?? null
150 // The child's prompt would name the session's directory, not its parent's worktree or the call's cwd, and its
151 // edits would leave that directory: a child of a subagent that works in a worktree (or worked in one that is
152 // gone, as a resumed subagent did), and a call that sets a cwd, are left to the engine, and a fork is refused.
153 const unlike =
154 (parent?.worktree !== undefined || parent?.wasIsolated === true
155 ? 'is spawned by a subagent in a worktree'
156 : e.cwd !== undefined
157 ? `sets its own cwd (${e.cwd})`
158 : note?.elsewhere !== undefined
159 ? `runs isolated as ${note.elsewhere}`
160 : fork && note?.isolated === true
161 ? 'asks for a worktree, and a fork cannot be isolated through styx'
162 : undefined) ?? (chosen !== undefined && !plain ? (fork ? "is a teammate, a workflow agent or another plugin's spawn" : 'is a teammate or a workflow agent') : undefined)
163 const fate = chosen === undefined ? 'it stays native' : 'the call is refused'
164 const after = chosen === undefined ? 'it stays native' : 'its first step hands the failure back'
165 if (target !== null && unlike !== undefined) io.debug(`styx inherit: ${e.subagentType} ${unlike}; ${fate}`)
166 if (fork && unlike !== undefined) return { deny: forkDenied(`it ${unlike}`) }
167 const asked = target === null ? undefined : unlike !== undefined ? { no: unlike } : await claimable(io, s, e.subagentType, fate, chosen !== undefined)
168 if (target === null || asked === undefined || 'no' in asked) {
169 if (chosen !== undefined && asked !== undefined && 'no' in asked) return { deny: unclaimable(e.subagentType, asked.no, chosen) }
170 return spawn(e)
171 }
172 const { started, claim } = await whileSpawning(s, async (started): Promise<{ started: AgentSpawnResult; claim?: { id: string; route: Route } }> => {
173 const spawned = await spawn(typeof noted === 'string' ? { ...e, model: e.parentModel } : e)
174 if (spawned.agentId !== undefined) started(spawned.agentId)
175 // A started subagent styx cannot claim runs native when it inherits a route; one asked for on a styx model, and
176 // a fork under a routed parent, are given a route that refuses, so the first step hands `why` back as its report
177 // instead of answering on the engine's model. Native would not be what the caller asked for, and a fork would
178 // carry a routed conversation there.
179 const unclaimed = (why: string) => {
180 const id = spawned.agentId
181 if (chosen === undefined || id === undefined) return { started: spawned }
182 const refused = fork ? forkDenied(`${why}, so styx did not run it on ${chosen}`) : `styx agent: ${why}, so styx did not run the subagent on ${chosen}; retry, or use a native model`
183 const route: Route = { target: chosen, label: chosen, type: fork ? FORK : e.subagentType, prompt: e.prompt, refused }
184 s.routes.set(id, route)
185 return { started: spawned, claim: { id, route } }
186 }
187 if (spawned.agentId === undefined || !sameModel(spawned.model, e.parentModel)) {
188 // Nothing started, or (for any spawn but a fork, which must not run unclaimed) the engine named no model.
189 if (spawned.agentId === undefined || (spawned.model === undefined && !fork)) return { started: spawned }
190 const on = spawned.model === undefined ? 'named no model' : `resolved ${spawned.model}`
191 io.debug(`styx inherit: the engine ${on} for ${e.subagentType}, not its parent's ${e.parentModel}; ${after}`)
192 return unclaimed(spawned.model === undefined ? 'the engine did not say which model it started it on' : `the engine started it on ${spawned.model}, not its parent's ${e.parentModel}`)
193 }
194 const id = spawned.agentId
195 // A hook beneath this one may have rewritten the agent type, which the engine lists as it started it. styx
196 // checked the type of the call only, so a type that differs is not claimed.
197 const listed = await io.agentType(id).catch(() => undefined)
198 if (listed !== undefined && (fork ? !isFork(listed) : listed !== e.subagentType)) {
199 io.debug(`styx inherit: a hook changed ${e.subagentType} to ${listed}; ${after}`)
200 return unclaimed(`a hook changed ${e.subagentType} to ${listed}`)
201 }
202 const isolated = note?.isolated === true || asked.isolated
203 const worktree = isolated ? await engineWorktree(io, id) : undefined
204 if (isolated && worktree === undefined) {
205 io.debug(`styx inherit: the engine made no worktree for ${id} (${e.subagentType}); ${after}`)
206 return unclaimed('the engine made no worktree for the isolated subagent')
207 }
208 const route: Route = { target, label: target, type: fork ? FORK : e.subagentType, prompt: e.prompt, ...(effort === undefined ? {} : { effort }), ...(worktree === undefined ? {} : { worktree }), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }), ...(fork ? { forkOf: e.tool_use_id } : {}) }
209 s.routes.set(id, route)
210 return { started: spawned, claim: { id, route } }
211 }, chosen, fork)
212 if (claim !== undefined) {
213 const { id, route } = claim
214 if (route.refused === undefined) io.debug(`styx spawn ${id} → ${target} ${typeof noted === 'string' ? 'requested' : 'inherited'} by ${route.type} from ${e.parentAgentId ?? 'main'} effort=${effort ?? 'none'}${route.worktree ? ` cwd=${route.worktree.path}` : ''}`)
215 await persistRoute(io, id, route)
216 }
217 return started
218}
219hooks/leave.ts 47 lines1// Telling native Claude when the conversation leaves a styx route. Native Claude reads the routed turns in its
2// history as its own, and rewriting stored history is not possible, so the next main prompt carries a note.
3// Pure over the Session.
4import type { PromptSubmitInput, PromptSubmitResult } from 'claude-code'
5
6import type { Session } from './session'
7import { labelOf, modelOf } from './ui'
8
9// Called when main's target changes, before `s.main` takes it. The route main leaves, when it answered turns, joins
10// the routes owed a note (turns on the same route one after another add up); the turn count starts again.
11export function switched(s: Session, target: string | null) {
12 if (target === s.main) return
13 if (s.main && s.routedTurns > 0) {
14 const { config } = s.loaded
15 const route = { label: labelOf(config, s.main), target: modelOf(config, s.main), turns: s.routedTurns }
16 const last = s.left.at(-1)
17 s.left = last?.label === route.label && last.target === route.target ? [...s.left.slice(0, -1), { ...route, turns: last.turns + route.turns }] : [...s.left, route]
18 }
19 s.routedTurns = 0
20}
21
22// The conversation the notes are about is over (session.end: /clear, a resume, an exit), so none is owed for it.
23export function ended(s: Session) {
24 s.left = []
25 s.routedTurns = 0
26}
27
28// main's prompt.submit: the first one that reaches a native main after routes were left carries one context block
29// saying so. A routed main's prompt, and a prompt delivered into a running routed turn (a model switch made
30// mid-turn leaves that turn routed), carry none and leave the note owed. A prompt that did not enter (`drop`)
31// leaves the note for the next. prompt.submit has no agent field: it is main's alone.
32export async function leftNote(s: Session, e: PromptSubmitInput, next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>): Promise<PromptSubmitResult> {
33 const running = e.turnId !== undefined && e.turnId === s.pin?.turnId && s.pin.target !== null
34 if (s.left.length === 0 || s.main || running) return next(e)
35 const left = s.left
36 const [total, several] = [left.reduce((n, l) => n + l.turns, 0), left.length > 1]
37 const by = left.map(l => `${l.label === l.target ? l.target : `${l.label} (${l.target})`}${several ? ` for ${l.turns}` : ''}`)
38 const who = several ? `${by.slice(0, -1).join(', ')} and ${by.at(-1)}` : by.join('')
39 const turns = total === 1 ? 'the previous assistant turn was' : `the previous ${total} assistant turns were`
40 const note = `styx: ${turns} answered by ${who} through styx, not by you; their self-descriptions are that model's, and any Agent calls among them were mcp__styx__agent calls it made. From here you answer as yourself.`
41 const context = [...(e.context ?? []), note]
42 s.left = []
43 const entered = await next({ ...e, context })
44 if (entered.drop !== undefined) s.left = [...left, ...s.left]
45 return entered
46}
47hooks/load.ts 125 lines1// Loading what styx runs on: styx.json in Claude Code's configuration directory (parsed, then checked against the managed policy), the
2// generated MCP schema table, and the styx agent tool's registration. Pure over a port: register.ts reads
3// the files, the settings and the engine's version.
4import { userAgent } from './backend'
5import { advert, agentSchema } from './advert'
6import { parseConfig } from './config'
7import type { Config, JsonSchema } from './config'
8import { declaredAliases } from './names'
9import type { Backend } from './protocol'
10import { SCHEMAS, STAMP } from './schemas.gen'
11import type { Session } from './session'
12
13// A config load: the valid config and its text, or its errors and the alias names the broken file still
14// declares.
15// `path` is where the file was looked for, as messages show it: `~` for the home directory when it is under it.
16export type Loaded = { config?: Config; text?: string; errors: string[]; missing: boolean; declared?: readonly string[]; path: string }
17
18export type LoadPort = {
19 // Claude Code's configuration directory, where styx.json, plugins/installed_plugins.json and agents/ live.
20 configDir(): Promise<string>
21 home(): Promise<string>
22 exists(path: string): Promise<boolean>
23 read(path: string): Promise<string>
24 // The managed policy settings.
25 policy(): Promise<Readonly<Record<string, unknown>>>
26}
27
28export type ReloadPort = LoadPort & {
29 pluginRoot: string
30 version(): Promise<string | undefined>
31 entrypoint(): Promise<string | undefined>
32 // Registers the styx agent tool with its description and input schema.
33 registerAgentTool(description: string, inputSchema: JsonSchema): Promise<void>
34 toast(text: string): void
35}
36
37// A policy allow-list or deny rule that excludes a provider's host, as config errors. Unreadable managed
38// settings are an error too: no provider host is checked against a policy styx could not read.
39async function policyErrors(io: Pick<LoadPort, 'policy'>, config: Config): Promise<string[]> {
40 let policy: Readonly<Record<string, unknown>>
41 try {
42 policy = await io.policy()
43 } catch (err) {
44 return [`the managed policy settings are unreadable (${String(err)}), so no provider host can be checked against them`]
45 }
46 const sandbox = policy['sandbox'] as { network?: { allowedDomains?: unknown } } | undefined
47 const allowed = sandbox?.network?.allowedDomains
48 const deny = (policy['permissions'] as { deny?: unknown } | undefined)?.deny
49 // A domain rule names the host itself, or with a leading `*.` any host beneath it.
50 const covers = (host: string, d: unknown) => typeof d === 'string' && (d === host || (d.startsWith('*.') && host.endsWith(d.slice(1))))
51 const denies = (host: string, r: unknown) => r === 'WebFetch' || (typeof r === 'string' && r.startsWith('WebFetch(domain:') && r.endsWith(')') && covers(host, r.slice('WebFetch(domain:'.length, -1)))
52 const errors: string[] = []
53 for (const p of Object.values(config.providers)) {
54 const host = new URL(p.origin).hostname
55 if (Array.isArray(allowed) && !allowed.some(d => covers(host, d))) errors.push(`providers.${p.id}.baseUrl: ${host} is not in the managed policy's sandbox.network.allowedDomains`)
56 if (Array.isArray(deny) && deny.some(r => denies(host, r))) errors.push(`providers.${p.id}.baseUrl: the managed policy denies WebFetch to ${host}`)
57 }
58 return errors
59}
60
61// A directory without trailing slashes, so joining and comparing never see `//`.
62const bare = (dir: string) => dir.replace(/\/+$/, '')
63
64// A file in a directory as a user would type it: under the home directory it starts with `~`.
65export function display(dir: string, home: string, file: string): string {
66 const d = bare(dir)
67 const h = bare(home)
68 return h !== '' && (d === h || d.startsWith(`${h}/`)) ? `~${d.slice(h.length)}/${file}` : `${d}/${file}`
69}
70
71async function loadConfig(io: LoadPort): Promise<Loaded> {
72 const dir = await io.configDir()
73 const full = `${bare(dir)}/styx.json`
74 const path = display(dir, await io.home(), 'styx.json')
75 if (!(await io.exists(full))) return { errors: [], missing: true, path }
76 let text: string
77 try {
78 text = await io.read(full)
79 } catch (err) {
80 return { errors: [`${path}: unreadable (${String(err)})`], missing: false, path }
81 }
82 const parsed = parseConfig(text)
83 if (parsed.config === undefined) return { errors: parsed.errors, missing: false, declared: declaredAliases(text), path }
84 const denied = await policyErrors(io, parsed.config)
85 return denied.length > 0 ? { errors: denied, missing: false, declared: declaredAliases(text), path } : { config: parsed.config, text, errors: [], missing: false, path }
86}
87
88// The MCP schema table the generator wrote beside this mod, and a note when it cannot be used.
89async function loadMcpSchemas(io: Pick<ReloadPort, 'exists' | 'read' | 'pluginRoot'>): Promise<{ table: Readonly<Record<string, JsonSchema>>; note?: string }> {
90 const path = `${io.pluginRoot}/hooks/schemas.mcp.gen.json`
91 try {
92 if (!(await io.exists(path))) return { table: {} }
93 const table: unknown = JSON.parse(await io.read(path))
94 if (typeof table !== 'object' || table === null || Array.isArray(table)) return { table: {}, note: 'MCP schemas: hooks/schemas.mcp.gen.json is not a JSON object; MCP tools go out permissive' }
95 return { table: table as Record<string, JsonSchema> }
96 } catch (err) {
97 return { table: {}, note: `MCP schemas: hooks/schemas.mcp.gen.json unreadable (${String(err)}); MCP tools go out permissive` }
98 }
99}
100
101// Re-reads the config and the MCP schema table, hands the config to the backend with no provider approved
102// yet (each is approved again at its next routed step), re-registers the styx agent tool, and toasts a
103// config error. A healthy load says nothing.
104export async function reload(io: ReloadPort, s: Session, backend: Pick<Backend, 'configure'>) {
105 const loaded = await loadConfig(io)
106 s.resetForReload()
107 s.loaded = loaded
108 await backend.configure({ configText: loaded.text ?? '{}', approved: [] })
109 const mcp = await loadMcpSchemas(io)
110 s.mcpSchemas = mcp.table
111 if (mcp.note !== undefined) s.notes.push(mcp.note)
112 const version = await io.version()
113 if (version !== undefined && version !== STAMP.engineVersion) {
114 s.notes.push(`schemas: generated on ${STAMP.engineVersion} · engine ${version} · regenerate with bun scripts/gen-schemas.ts`)
115 }
116 s.userAgent = userAgent(version, await io.entrypoint())
117 const config = s.loaded.config
118 if (config !== undefined) {
119 s.goodAliases = Object.keys(config.aliases)
120 await io.registerAgentTool(advert(config), agentSchema(config, SCHEMAS['Agent'] ?? {}))
121 } else if (s.loaded.errors.length > 0) {
122 io.toast(`styx: routing off, config error: ${s.loaded.errors[0]}. Fix ${s.loaded.path}, then run /styx reload`)
123 }
124}
125hooks/pool.ts 84 lines1// The tools a routed subagent may be offered, and the tool.call guard that holds it to them. Claude Code builds a
2// native subagent's tool pool from the context that spawned it, so a routed one is given no tool its caller lacks:
3// its pool is what its agent type allows, less what the routed subagent that started it (`Route.parent`) is not
4// allowed, up the chain to main, whose tools are the whole list. Pure over ports.
5import { HANDBACK_TOOL, isFork, toolFilter } from './agents'
6import { definitionOf } from './prompts'
7import type { PromptsPort } from './prompts'
8import type { RouteStore, StepIo } from './routing'
9import type { Session } from './session'
10
11// Whether the tool of a given name is one a routed subagent may be offered. It tests names alone, so main's
12// tool list, which can change between steps, is read where the pool is used.
13export type Pool = (tool: string) => boolean
14type PoolIo = PromptsPort & Pick<RouteStore, 'route'>
15
16// What a subagent is offered when the route of a subagent above it cannot be read: SubagentHandback, so it can
17// still report, and nothing else. A pool built without that route could exceed the caller's.
18const CLOSED: Pool = tool => tool === HANDBACK_TOOL.name
19
20// The pool of `agentId` from its route (memory, else state) and the routes above it, or undefined when one of
21// them is missing, native or loops. A pool is kept per agent until the agent is forgotten or the config is
22// reloaded. A route read from state is not put in memory (a finished subagent above would be listed as routed
23// again): only the pool drawn from it is kept.
24async function derive(io: PoolIo, s: Session, agentId: string, above: ReadonlySet<string>): Promise<Pool | undefined> {
25 const kept = s.pools.get(agentId)
26 if (kept !== undefined) return kept
27 const route = s.routes.get(agentId) ?? (await io.route(agentId))
28 if (route === undefined || route.target == null || above.has(agentId)) return undefined
29 const own = toolFilter(route.type, await definitionOf(io, s, route.type))
30 const caller = route.parent === undefined ? undefined : await derive(io, s, route.parent, new Set([...above, agentId]))
31 if (route.parent !== undefined && caller === undefined) return undefined
32 // Claude Code offers a fork no SubagentHandback, so a fork's pool holds none.
33 const hands = !isFork(route.type)
34 const pool: Pool = tool => (tool === HANDBACK_TOOL.name ? hands : own(tool) && (caller?.(tool) ?? true))
35 s.pools.set(agentId, pool)
36 return pool
37}
38
39// The tools the routed subagent `agentId` may be offered: SubagentHandback, and the tools its type allows that
40// the subagent which started it (if any) may be offered too. When a route above it cannot be read (it is in
41// neither memory nor state, as after a reload that followed a refused write; it is native; or the chain loops),
42// SubagentHandback alone, so a missing route never widens a pool; that pool is not kept, and the next step reads
43// again. A rejected read of state is not caught: the step or the guard that asked fails and answers as it does
44// for any internal error.
45export async function poolOf(io: PoolIo, s: Session, agentId: string): Promise<Pool> {
46 const pool = await derive(io, s, agentId, new Set())
47 if (pool === undefined) io.debug(`styx pool ${agentId}: the route of a subagent above it cannot be read, so it is offered SubagentHandback alone`)
48 return pool ?? CLOSED
49}
50
51// Denies a routed subagent a tool its last remote step did not offer: the engine would run it, since a
52// subagent's tools are cut from main's by styx alone. Main and native agents pass. A routed agent with no
53// offered set in memory (a hot reload emptied it) has its route read back from state and its set rebuilt
54// from main's tools by its pool; it is denied when that cannot be done. An agent with no route in
55// state is native, and kept in memory as such until it is forgotten, so its tool calls read no state again.
56export async function guard(io: PoolIo & Pick<StepIo, 'list'>, s: Session, e: { tool: string; agentId?: string }): Promise<{ deny: string } | undefined> {
57 if (e.agentId === undefined) return undefined
58 // The engine is the authority on SubagentHandback: it runs or refuses the call itself.
59 if (e.tool === HANDBACK_TOOL.name) {
60 io.debug(`styx guard ${e.agentId} SubagentHandback passed to the engine`)
61 return undefined
62 }
63 let offered = s.offered.get(e.agentId)
64 if (offered === undefined) {
65 const route = s.routes.get(e.agentId) ?? (await io.route(e.agentId)) ?? { target: null, label: 'native' }
66 s.routes.set(e.agentId, route)
67 if (route.target == null) return undefined
68 const pool = await poolOf(io, s, e.agentId)
69 offered = new Set([HANDBACK_TOOL.name, ...(await io.list()).map(t => t.name)].filter(pool))
70 s.offered.set(e.agentId, offered)
71 }
72 const allowed = offered.has(e.tool)
73 io.debug(`styx guard ${e.agentId} ${e.tool} ${allowed ? 'allowed' : 'denied'}`)
74 return allowed ? undefined : { deny: `styx: ${e.tool} is not available to the ${s.routes.get(e.agentId)?.type ?? 'general-purpose'} subagent` }
75}
76
77// What the guard answers when it fails: a deny for a routed subagent or one styx has not cached (its route read
78// may be what failed), so a failed check never lets a call through; undefined for main and for an agent known
79// to be native, whose call goes on.
80export function guardFailed(s: Session, e: { tool: string; agentId?: string }): { deny: string } | undefined {
81 const routed = e.agentId !== undefined && (s.offered.has(e.agentId) || s.routes.get(e.agentId)?.target !== null)
82 return routed ? { deny: `styx: could not check ${e.tool} for this subagent, so it was denied; retry, or see the debug log` } : undefined
83}
84hooks/prompts.ts 395 lines1// The system prompt of a routed step, and the model identity its transcript states. Main gets the prompt
2// the engine composes for its own model, with the lines that name that model rewritten to name the routed
3// model and provider. A built-in agent type gets the prompt styx writes for it (hooks/agents.ts); the `claude`
4// type, which natively runs on main's own prompt, gets main's rewritten prompt; a custom agent type, or a
5// built-in one with exactly one agent file naming it, gets the body of that file, and a custom one whose
6// definition does not read runs as general-purpose.
7// A subagent's prompt closes with the notes, its environment, how it hands back, and what answers it.
8// `systemFor` is the one place a step's prompt is chosen. Pure over a port.
9import type { Route } from '../types'
10import { agentSystem, BUILTIN_AGENTS, BUILTIN_MODELS, BUILTIN_NOTES, builtinPrompt, dateOf, HANDBACK_TOOL, identityLine, isFork, NO_FRONTMATTER, NOT_REPRODUCED, parseAgentFile, parseInstalled, pluginRoot } from './agents'
11import type { AgentDef, Parsed } from './agents'
12import { own } from './config'
13import type { ApiMessage } from './protocol'
14import type { Session } from './session'
15
16// One section of a composed system prompt: its id and its text.
17type Section = { id: string; text: string }
18
19export type PromptsPort = {
20 // The sections of the prompt the engine composes for `model` with `tools` offered, in order.
21 compose(model: string, tools: readonly string[]): Promise<readonly Section[]>
22 cwd(): Promise<string>
23 configDir(): Promise<string>
24 exists(path: string): Promise<boolean>
25 read(path: string): Promise<string>
26 // The entries of a directory by name and kind (a symbolic link is `other`); none when it is not there.
27 listDir(dir: string): Promise<readonly { name: string; kind: 'file' | 'dir' | 'other' }[]>
28 // Runs a command to its end; a command that cannot start answers exit code 127.
29 run(argv: readonly string[], opts?: { timeoutMs?: number }): Promise<{ exitCode: number; stdout: string; stderr: string }>
30 now(): Promise<number>
31 debug(text: string): void
32}
33
34const AGENT_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
35const INHERIT = 'inherit'
36
37// Says `text` once per session (a reload says it again): in /styx, and in the debug log. Its key is apart
38// from the ones definitionOf logs under (`def <type>`).
39function noteOnce(io: Pick<PromptsPort, 'debug'>, s: Session, key: string, text: string) {
40 if (s.agentNotes.has(`note ${key}`)) return
41 s.agentNotes.add(`note ${key}`)
42 s.notes.push(text)
43 io.debug(`styx ${text}`)
44}
45
46// --- model identity ------------------------------------------------------------------------------------------
47
48// The sentence the engine states its own model in (section `env_info_model`, which 2.1.292 sends in the
49// transcript as the session's model notice), with its knowledge cutoff; a knowledge-cutoff sentence alone; the
50// environment's line on the latest Claude model family; and the sections that are nothing but the engine
51// model's identity.
52const POWERED_BY = /You are powered by the model (?:named [^\n]*?\. The exact model ID is \S+?|\S+?)\.(?=\s|$)(?: Assistant knowledge cutoff is [^\n]*?\.)?/g
53const CUTOFF = / ?Assistant knowledge cutoff is [^\n]*?\./g
54const FAMILY = /^.*The most recent Claude models\b.*(?:\n|$)/gm
55const IDENTITY_SECTIONS = new Set(['fable_identity'])
56
57// The composed main prompt for a routed model: `env_info_model` says `identity` in place of the engine
58// model's, identity-only sections are left out, and the environment sections lose the engine model's
59// knowledge cutoff and the Claude model-family line. Every other section is sent as composed. For a prompt
60// that goes to a subagent working in another directory than the session's (`elsewhere`), the other
61// environment sections are left out as well, since they may name the session's directory.
62export function rewriteMain(sections: readonly Section[], identity: string, elsewhere = false): string {
63 return sections
64 .flatMap(x => {
65 if (x.id === 'env_info_model') return [identity]
66 if (IDENTITY_SECTIONS.has(x.id)) return []
67 if (!x.id.startsWith('env_info')) return [x.text]
68 if (elsewhere) return []
69 const text = x.text.replace(POWERED_BY, identity).replace(CUTOFF, '').replace(FAMILY, '')
70 return text.trim() === '' ? [] : [text]
71 })
72 .join('\n\n')
73}
74
75// A transcript as a routed model is sent it: the engine's notice of its own model, in a user message, names
76// `model` served by `provider` instead. Messages without one are passed as they are.
77export function withIdentity(messages: readonly ApiMessage[], model: string, provider: string): readonly ApiMessage[] {
78 const identity = identityLine(model, provider)
79 const fix = (text: string) => text.replace(POWERED_BY, identity)
80 let changed = false
81 const out = messages.map(m => {
82 if (m.role !== 'user') return m
83 if (typeof m.content === 'string') {
84 const text = fix(m.content)
85 if (text === m.content) return m
86 changed = true
87 return { ...m, content: text }
88 }
89 let hit = false
90 const content = m.content.map(b => {
91 const text = b.type === 'text' && typeof b['text'] === 'string' ? b['text'] : undefined
92 const fixed = text === undefined ? undefined : fix(text)
93 if (fixed === text) return b
94 hit = true
95 return { ...b, text: fixed }
96 })
97 if (!hit) return m
98 changed = true
99 return { ...m, content }
100 })
101 return changed ? out : messages
102}
103
104// --- custom agent types --------------------------------------------------------------------------------------
105
106// `path` parsed by `parse`, read once per session: null when there is no file, an error when unreadable.
107async function readParsed<T>(io: PromptsPort, s: Session, path: string, parse: (text: string) => Parsed<T>): Promise<Parsed<T> | null> {
108 if (s.agentFiles.has(path)) return s.agentFiles.get(path) as Parsed<T> | null
109 let value: Parsed<T> | null
110 try {
111 value = (await io.exists(path)) ? parse(await io.read(path)) : null
112 } catch (err) {
113 value = { error: `unreadable (${String(err)})` }
114 }
115 s.agentFiles.set(path, value)
116 return value
117}
118
119// The `.claude/agents` directories of the project and of the configuration directory, in Claude Code's order.
120const ownAgentDirs = async (io: PromptsPort) => [`${await io.cwd()}/.claude/agents`, `${await io.configDir()}/agents`]
121
122// The agents directories Claude Code may read the definition of `type` from, in its order: a plugin agent
123// `<plugin>:<name>` in its plugin's `agents`, any other in the session's `.claude/agents`, then in the
124// configuration directory's `agents`. A plugin's directory is read off installed_plugins.json. `plugin` says
125// which kind they are, because Claude Code reads the files of the two kinds by different rules.
126async function agentDirs(io: PromptsPort, s: Session, type: string): Promise<Parsed<{ name: string; dirs: string[]; plugin: boolean }>> {
127 const dir = await io.configDir()
128 const cwd = await io.cwd()
129 const colon = type.indexOf(':')
130 const name = type.slice(colon + 1)
131 if (!AGENT_NAME_RE.test(name)) return { error: `"${name}" is not an agent file name` }
132 if (colon < 0) return { name, dirs: await ownAgentDirs(io), plugin: false }
133 const file = `${dir}/plugins/installed_plugins.json`
134 const installed = await readParsed(io, s, file, parseInstalled)
135 if (installed === null) return { error: `${file}: no file` }
136 if ('error' in installed) return { error: `${file}: ${installed.error}` }
137 const root = pluginRoot(installed.plugins, type.slice(0, colon), cwd)
138 if ('error' in root) return { error: `${file}: ${root.error}` }
139 return { name, dirs: [`${root.root}/agents`], plugin: true }
140}
141
142// An agent file, with the name Claude Code knows its agent by: the frontmatter's `name`, else its file name less
143// `.md`. `skipped` is why Claude Code ignores the file. In a `.claude/agents` directory it requires a `name` and a
144// `description`, takes no agent from a file without either, and passes over a file with no frontmatter. A plugin's
145// agent file needs neither field: its name falls back to the file name and its description to a default.
146type AgentFile = { path: string; name: string; def: Parsed<AgentDef>; skipped?: string }
147// How many entries styx lists in one agents directory and beneath it: a directory with more is one it cannot be
148// sure to have read whole.
149const AGENT_DIR_MAX = 128
150
151// Every `*.md` file in the agents directory `dir`, in the directories beneath it too (Claude Code reads those),
152// parsed and in path order; an error when a listing or the count fails. Read once per session. `plugin` says the
153// directory is a plugin's, whose files Claude Code reads by the plugin rule (see AgentFile).
154function agentFilesIn(io: PromptsPort, s: Session, dir: string, plugin: boolean): Promise<Parsed<AgentFile[]>> {
155 const key = `agents ${dir}`
156 let files = s.agentFiles.get(key) as Promise<Parsed<AgentFile[]>> | undefined
157 if (files === undefined) s.agentFiles.set(key, (files = loadAgentDir(io, s, dir, plugin)))
158 return files
159}
160
161async function loadAgentDir(io: PromptsPort, s: Session, dir: string, plugin: boolean): Promise<Parsed<AgentFile[]>> {
162 try {
163 const paths: string[] = []
164 let seen = 0
165 const walk = async (at: string) => {
166 for (const entry of await io.listDir(at)) {
167 if (++seen > AGENT_DIR_MAX) throw new Error(`more than ${AGENT_DIR_MAX} entries`)
168 if (entry.kind === 'dir') await walk(`${at}/${entry.name}`)
169 else if (entry.name.endsWith('.md')) paths.push(`${at}/${entry.name}`)
170 }
171 }
172 await walk(dir)
173 return await Promise.all(
174 paths.sort().map(async path => {
175 let def: Parsed<AgentDef>
176 try {
177 def = parseAgentFile(await io.read(path))
178 } catch (err) {
179 def = { error: `unreadable (${String(err)})` }
180 }
181 const missing = plugin || 'error' in def ? [] : (['name', 'description'] as const).filter(k => !def[k])
182 const bare = !plugin && 'error' in def && def.error === NO_FRONTMATTER
183 const skipped = bare ? 'no frontmatter' : missing.length === 0 ? undefined : `no ${missing.join(' or ')}`
184 if (skipped !== undefined && !bare) noteOnce(io, s, `skipped ${path}`, `agents: ${path} has ${skipped}, which Claude Code requires of a file in .claude/agents, so it skips the file and so does styx`)
185 return { path, name: ('error' in def ? undefined : def.name) || path.slice(path.lastIndexOf('/') + 1, -3), def, ...(skipped === undefined ? {} : { skipped }) }
186 }),
187 )
188 } catch (err) {
189 return { error: `${dir}: ${String(err)}` }
190 }
191}
192
193// The agent files that name `type` (as its frontmatter `name`, whatever the file is called), in Claude Code's
194// order of preference, the files among the others that styx could not read (any of them may define `type`
195// under a name styx cannot see), and the directories they were looked for in.
196async function definingFiles(io: PromptsPort, s: Session, type: string): Promise<Parsed<{ name: string; dirs: string[]; files: AgentFile[]; unread: AgentFile[] }>> {
197 const found = await agentDirs(io, s, type)
198 if ('error' in found) return found
199 const files: AgentFile[] = []
200 const unread: AgentFile[] = []
201 for (const dir of found.dirs) {
202 const listed = await agentFilesIn(io, s, dir, found.plugin)
203 if ('error' in listed) return listed
204 const kept = listed.filter(f => f.skipped === undefined)
205 files.push(...kept.filter(f => f.name === found.name))
206 unread.push(...kept.filter(f => f.name !== found.name && 'error' in f.def))
207 }
208 return { name: found.name, dirs: found.dirs, files, unread }
209}
210
211// A custom agent type's definition file: the first of the files that name it, which is the one Claude Code
212// takes when no other of them is in play (see `contested`).
213async function findDefinition(io: PromptsPort, s: Session, type: string): Promise<Parsed<{ def: AgentDef; path: string }>> {
214 const found = await definingFiles(io, s, type)
215 if ('error' in found) return found
216 const [first] = found.files
217 if (first === undefined) return { error: `no definition found (no agent file in ${found.dirs.join(' or ')} has the name ${found.name})` }
218 return 'error' in first.def ? { error: `${first.path}: ${first.def.error}` } : { def: first.def, path: first.path }
219}
220
221// Whether Claude Code may define `type` from other than the one file styx reads, as far as styx can see: a type
222// that more than one file names (it picks by a precedence styx does not follow). A file whose text does not read
223// counts under its file name, since styx cannot tell it is not the definition; an agent that no file shows (an
224// `--agents` or SDK one) is not seen.
225export async function contested(io: PromptsPort, s: Session, type: string): Promise<boolean> {
226 try {
227 const found = await definingFiles(io, s, type)
228 return 'error' in found || found.files.length > 1
229 } catch {
230 return true
231 }
232}
233
234// The built-in agent type that runs on main's own system prompt (the engine's `appendSystemPrompt`).
235const MAIN_PROMPT_TYPE = 'claude'
236
237// A routed subagent's own definition when an agent file names its type and the definition reads, else
238// undefined. Each type's outcome is logged once; a custom type whose definition does not read is also said in
239// /styx, as it runs as general-purpose. A built-in type with no file, or with more than one, has no definition
240// and says nothing: it runs on styx's own prompt. A built-in type with one file that reads says in /styx that it
241// runs on that file; a file that does not read, and so may define one under a name styx cannot see, is said too. A
242// fork has no definition of its own: it runs on its parent's prompt.
243export async function definitionOf(io: PromptsPort, s: Session, type: string | undefined): Promise<AgentDef | undefined> {
244 if (type === undefined || isFork(type)) return undefined
245 const builtin = BUILTIN_AGENTS.includes(type)
246 let single = false
247 if (builtin) {
248 try {
249 const named = await definingFiles(io, s, type)
250 if ('error' in named) return undefined
251 for (const f of named.unread) {
252 noteOnce(io, s, `unread ${f.path}`, `agents: ${f.path} cannot be read (${(f.def as { error: string }).error}); if it defines a built-in agent type, that type runs on styx's prompt instead of the file`)
253 }
254 if (named.files.length !== 1) return undefined
255 single = true
256 } catch {
257 return undefined
258 }
259 }
260 let found: Parsed<{ def: AgentDef; path: string }>
261 try {
262 found = await findDefinition(io, s, type)
263 } catch (err) {
264 found = { error: String(err) }
265 }
266 if (!s.agentNotes.has(`def ${type}`)) {
267 s.agentNotes.add(`def ${type}`)
268 io.debug('error' in found ? `styx agent-def ${type}: ${found.error}` : `styx agent-def ${type} from ${found.path}`)
269 const unreadable = `agents: ${type} has ${single ? 'one agent file styx cannot read' : 'no readable definition'}`
270 if ('error' in found) s.notes.push(single ? `${unreadable} (${found.error}); a plain call stays native and a styx agent call is refused` : `${unreadable} (${found.error}); it runs ${builtin ? `on ${type === MAIN_PROMPT_TYPE ? "main's" : "styx's"} prompt` : 'as general-purpose'}`)
271 else if (builtin) s.notes.push(`agents: ${type} runs on ${found.path}, which overrides the built-in`)
272 }
273 return 'error' in found ? undefined : found.def
274}
275
276// Whether styx may run an agent type on a styx route, and whether its definition isolates it; else why not. Every
277// route to a subagent on a styx model asks this, the plain Agent call under a routed parent (inherit.ts) and the
278// styx agent tool alike, so the same call is answered the same from any conversation.
279// A type styx cannot run itself (NOT_REPRODUCED) qualifies never. A fork qualifies always: it runs on its parent's
280// prompt, and no agent file defines it. Any other built-in type qualifies when it inherits its parent's model:
281// styx writes its prompt, or uses the one agent file that names it (`name: Explore`), whose model:, isolation: and
282// tools then apply as a custom type's do. A custom type qualifies when its definition is one styx reads and names
283// no model of its own. A custom type without a readable definition (a plugin loaded with --plugin-dir, an
284// --agents or SDK agent, no agent file whose `name:` is the type) is left native, because styx would answer it with
285// the general-purpose prompt and tools; so is a built-in type whose one agent file styx cannot read, and a type that more than one agent file names (`contested`).
286// These back-offs keep a spawn the caller did not ask a model for native. `named` is a spawn whose caller named
287// the styx model (a styx agent call): it starts on the parent's model whatever the definition says, so a type's
288// own model and missing definition do not stop it.
289// `fate` ends the debug line that says why.
290export async function claimable(io: PromptsPort, s: Session, type: string, fate: string, named: boolean): Promise<{ isolated: boolean } | { no: string }> {
291 if (isFork(type)) return { isolated: false }
292 if (NOT_REPRODUCED.includes(type)) {
293 io.debug(`styx inherit: ${type} is not a type styx can run; ${fate}`)
294 return { no: 'is not a type styx can run' }
295 }
296 const builtin = BUILTIN_AGENTS.includes(type)
297 const def = await definitionOf(io, s, type)
298 // A built-in type that exactly one agent file names, and styx cannot read, may be defined by that file: not claimable.
299 const files = builtin && def === undefined ? await definingFiles(io, s, type).catch(() => undefined) : undefined
300 if (files !== undefined && !('error' in files) && files.files.length === 1 && 'error' in (files.files[0] as AgentFile).def) {
301 io.debug(`styx inherit: ${type} has one agent file styx cannot read; ${fate}`)
302 return { no: 'has one agent file styx cannot read' }
303 }
304 if (!builtin && def === undefined && !named) {
305 io.debug(`styx inherit: ${type} has no readable definition; ${fate}`)
306 return { no: 'has no readable definition' }
307 }
308 const model = def?.model ?? (builtin && def === undefined ? own(BUILTIN_MODELS, type) : undefined)
309 if (!named && model !== undefined && model.trim().toLowerCase() !== INHERIT) return { no: 'names a model of its own' }
310 // A named spawn of a custom type with no definition runs general-purpose whatever files name it.
311 if (!(named && !builtin && def === undefined) && (await contested(io, s, type))) {
312 io.debug(`styx inherit: ${type} has more than one definition (two agent files name it, or styx could not read them all); ${fate}`)
313 return { no: 'has more than one definition' }
314 }
315 if (def?.isolation !== undefined && def.isolation !== 'worktree') {
316 io.debug(`styx inherit: ${type} runs isolated as ${def.isolation}; ${fate}`)
317 return { no: `runs isolated as ${def.isolation}` }
318 }
319 return { isolated: def?.isolation === 'worktree' }
320}
321
322// The agent type a styx agent call's `subagent_type` names, resolved as the engine resolves an Agent call's, for
323// the styx agent tool's own spawn: `$.agent.spawn` takes a type only as exactly spelled, and every check and the
324// route must use the type the engine runs. None is general-purpose. A type spelled exactly as a built-in type or
325// an agent file's name is itself; another spelling is the one known type it matches ignoring case, and a spelling
326// that matches more than one is denied with them listed. A plugin agent, and a type styx knows of no file for (an
327// `--agents` or SDK agent), are passed on as they are, and the engine refuses a name that matches none.
328export async function resolveType(io: PromptsPort, s: Session, type: string | undefined): Promise<{ type: string } | { deny: string }> {
329 if (type === undefined || type === '') return { type: 'general-purpose' }
330 if (BUILTIN_AGENTS.includes(type) || type.includes(':')) return { type }
331 const known = new Set(BUILTIN_AGENTS.filter(t => !isFork(t)))
332 for (const dir of await ownAgentDirs(io)) {
333 const listed = await agentFilesIn(io, s, dir, false)
334 if (!('error' in listed)) for (const f of listed) if (f.skipped === undefined) known.add(f.name)
335 }
336 if (known.has(type)) return { type }
337 const fold = (t: string) => t.normalize('NFKC').toLowerCase()
338 const matches = [...known].filter(k => fold(k) === fold(type)).sort()
339 if (matches.length > 1) return { deny: `styx agent: subagent_type ${JSON.stringify(type)} matches more than one agent type (${matches.join(', ')}); name one of them exactly` }
340 return { type: matches[0] ?? type }
341}
342
343// --- the prompt of a step ------------------------------------------------------------------------------------
344
345// The platform as `uname -s` names it, lowercased (darwin, linux); undefined when it cannot be read.
346async function platformOf(io: Pick<PromptsPort, 'run'>, s: Session): Promise<string | undefined> {
347 if (s.platform === undefined) {
348 const r = await io.run(['/usr/bin/uname', '-s'])
349 const name = r.stdout.trim().toLowerCase()
350 s.platform = r.exitCode === 0 && /^[a-z0-9_-]{1,32}$/.test(name) ? name : null
351 }
352 return s.platform ?? undefined
353}
354
355// What a step's prompt is chosen from: the agent file's definition when one names the type, the subagent's route
356// (none for main), the engine model the main prompt is composed for, the routed target (`provider/model`),
357// its model and provider, and the tools offered. For a fork these are its parent's: the nearest ancestor's route
358// and definition that is not itself a fork (none when that is main), and `fork` says so.
359type SystemInput = { def?: AgentDef; route?: Route; fork?: boolean; engineModel: string; target: string; model: string; provider: string; tools: readonly string[] }
360
361// A routed step's system prompt and where it came from: `main` (the composed prompt, identity rewritten: main's
362// own, and a `claude` subagent's when no agent file names it; one in a worktree is not told the session's
363// directory), `custom` (an agent file's body: a custom type's, or a built-in type's that one file names) or
364// `styx` (the prompt styx writes for a built-in type). A subagent's prompt is completed by agentSystem. A fork's is
365// its parent's, as `fork of <source>`: a fork of main gets main's prompt alone, and a fork of a subagent that
366// subagent's prompt less the handback guidance. Every fork is told how it reports in its messages (fork.ts), and
367// Claude Code offers a fork no SubagentHandback, so no fork is told of it.
368export async function systemFor(io: PromptsPort, s: Session, i: SystemInput): Promise<{ text: string; source: string }> {
369 const fromMain = async (elsewhere = false) => rewriteMain(await io.compose(i.engineModel, i.tools), identityLine(i.model, i.provider), elsewhere)
370 const handback = i.tools.includes(HANDBACK_TOOL.name)
371 if (i.route === undefined) {
372 const text = await fromMain()
373 return { text, source: i.fork ? 'fork of main' : 'main' }
374 }
375 const type = i.route.type ?? 'general-purpose'
376 const prompt =
377 i.def !== undefined
378 ? { body: i.def.prompt, source: 'custom', notes: BUILTIN_NOTES }
379 : type === MAIN_PROMPT_TYPE
380 ? { body: await fromMain(i.route.worktree !== undefined), source: 'main', notes: BUILTIN_NOTES }
381 : { body: builtinPrompt(BUILTIN_AGENTS.includes(type) ? type : 'general-purpose', new Set(i.tools)), source: 'styx', notes: BUILTIN_NOTES }
382 const text = agentSystem(prompt.body, {
383 cwd: i.route.worktree?.path ?? (await io.cwd()),
384 platform: await platformOf(io, s),
385 date: dateOf(await io.now()),
386 model: i.model,
387 provider: i.provider,
388 type,
389 alias: i.route.label === i.target ? undefined : i.route.label,
390 notes: prompt.notes,
391 handback: handback && !i.fork,
392 })
393 return { text, source: i.fork ? `fork of ${prompt.source}` : prompt.source }
394}
395hooks/protocol.ts 80 lines1// The contract between the mod and the backend that answers its routed steps: what a step asks for, the
2// events its answer streams back, and each provider's key state. Pure: no engine types and no I/O, so the
3// helper process that serves steps imports it as the mod does.
4import type { Effort } from '../types'
5import type { JsonSchema } from './config'
6
7// A transcript message as `$.session.messages({ as: "api" })` gives it: blocks, or (read as one text
8// block) text.
9export type ApiMessage = { role: 'user' | 'assistant'; content: string | readonly ({ type: string } & Record<string, unknown>)[] }
10// A tool the remote model is offered: its name, description and input schema.
11export type RemoteTool = { name: string; description: string; schema: JsonSchema }
12
13// One routed step: its target (the alias or `provider/model` the route was made with, which the backend
14// resolves against the config it holds), the system prompt, the tools offered, the transcript (a subagent's
15// opening on its task), the effort the step asked for, and who asks (`main` or an agentId).
16export type StepRequest = {
17 target: string
18 system: string
19 tools: readonly RemoteTool[]
20 transcript: readonly ApiMessage[]
21 effort?: Effort | number
22 who: string
23}
24
25// Why a response stopped, in the engine's words; `tool_use` when it ended on tool calls.
26export type StopReason = 'end_turn' | 'max_tokens' | 'refusal' | 'tool_use'
27// A response's tokens: input not read from cache, output, cache reads and writes, and the reasoning share
28// of the output when the provider reports one.
29export type Usage = { in: number; out: number; cacheRead: number; cacheWrite: number; reasoning?: number }
30// How one step went: ms to the first response byte (null when none came) and in all, the request body's
31// bytes, the prompt tokens (cache reads and writes included) and output tokens of a completed response (else null),
32// its reasoning tokens, and the provider's finish reason as sent (`none` when it gave none, `error` when
33// the step failed).
34export type StepStats = { ttfbMs: number | null; totalMs: number; reqBytes: number; in: number | null; out: number | null; reasoning?: number; finish: string }
35
36// One event of a step's answer, in order. `text` and `thinking` are deltas of the answer and the reasoning;
37// a `tool_use` is a whole call with its input parsed, so a call cut short never appears. The events end
38// with exactly one `stop` or `error`, then `stats`. An error's text is one line naming the cause and the
39// fix: a `request` error had no usable response (not sent, refused, or cut in transit), a `response` error
40// is one the response itself reported, or a response that broke its own format.
41export type StepEvent =
42 | { type: 'text'; text: string }
43 | { type: 'thinking'; text: string }
44 | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
45 | ({ type: 'usage' } & Usage)
46 | { type: 'stop'; reason: StopReason }
47 | { type: 'error'; kind: 'request' | 'response'; text: string }
48 | ({ type: 'stats' } & StepStats)
49
50// A provider's key as the backend holds it: cached, not fetched yet (its helper has not run since the
51// provider was approved), failed with that failure's line, or none (`auth: "none"`: no key, no helper).
52export type ProviderStatus = { provider: string; key: 'cached' | 'not-run' | 'failed' | 'none'; failure?: string }
53
54// What answers routed steps. `configure` takes a validated config's text and the fingerprints of the
55// providers approved so far: a helper runs, and a key is kept, only for an approved provider. `start`, called
56// in a hook the session outlives, binds the helper process to the session, and brings it up ahead of the
57// first step when the config declares a provider, saying nothing when it cannot. `step` streams one step's
58// events; aborting `signal` cancels its request. `status` reads key states and never runs a helper.
59export type Backend = {
60 configure(c: { configText: string; approved: readonly string[] }): Promise<void>
61 start(): Promise<void>
62 step(req: StepRequest, signal: AbortSignal): AsyncIterable<StepEvent>
63 status(): Promise<readonly ProviderStatus[]>
64}
65
66// What the mod sends styxd, the helper process that serves steps, with every call: the session's token,
67// the validated config text, the fingerprints approved so far, and the User-Agent of provider requests. A
68// step adds its request, and is answered with one StepEvent per line.
69export type Wire = { token: string; configText: string; approved: readonly string[]; userAgent?: string }
70export type StepWire = Wire & { req: StepRequest }
71
72// A step that failed before it could be answered: its one error, and the stats that end every step.
73export const failedStep = (text: string, totalMs = 0): StepEvent[] => [
74 { type: 'error', kind: 'request', text },
75 { type: 'stats', ttfbMs: null, totalMs, reqBytes: 0, in: null, out: null, finish: 'error' },
76]
77
78// A tool-call id styx mints for a remote call: Anthropic-shaped, 35 characters, under OpenAI's 40.
79export const mintToolId = () => `toolu_styx_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`
80hooks/routing.ts 400 lines1// Which model answers: main's per-turn pin and /model, each subagent's route, the remote step (the request
2// it builds, its guards, its events as chunks, a styx agent call among them made an Agent call), and the
3// failures that end a step. Pure over ports.
4import type { CommandRunInput, CommandRunResult, ToolInfo, TurnStepChunk, TurnStepInput, TurnStepResult } from 'claude-code'
5
6import type { Effort, MainPin, Route } from '../types'
7import { FORK_REPORT, HANDBACK_TOOL, isFork } from './agents'
8import { FORK_HISTORY, forkHistory, forkParent } from './fork'
9import { advert, agentSchema } from './advert'
10import { EFFORTS, inputBudget, isObject } from './config'
11import type { Config, Target } from './config'
12import { switched } from './leave'
13import { explain, isStyxShaped, resolve } from './names'
14import { poolOf } from './pool'
15import { definitionOf, systemFor, withIdentity } from './prompts'
16import type { PromptsPort } from './prompts'
17import type { ApiMessage, Backend, RemoteTool, StepRequest } from './protocol'
18import { firstLine, redact } from './redact'
19import { SCHEMAS } from './schemas.gen'
20import type { Assembler, Session, StepRecord } from './session'
21import { translatedOf, translateUse, unclaimedFork, unstarted } from './spawn'
22import type { AgentTypes } from './spawn'
23import { createAssembler } from './step'
24import { firstUserText, handbackState, withoutTool, withTask, withTranslated } from './transcript'
25import { approve, ensureTrust, isTrusted } from './trust'
26import type { TrustPort } from './trust'
27import { labelOf, showStatus, shownTarget } from './ui'
28import type { StatusPort } from './ui'
29
30export const WRAPPER = 'mcp__styx__agent'
31// Half of HookBudget.ms (10_000): both the module-promise await and $.clock.sleep spend the hook's budget.
32const SPAWN_WAIT_MS = 5000
33const TOOL_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
34
35type Remote = Extract<Target, { kind: 'remote' }>
36type StepResult = AsyncGenerator<TurnStepChunk, TurnStepResult>
37
38export type Say = { toast(text: string): void; debug(text: string): void }
39// What routing keeps in the engine's state: main's target and its pin for a turn, each subagent's route, and
40// the Agent calls styx made of each conversation's styx agent calls (`who`: main, or an agentId).
41export type RouteStore = {
42 main(): Promise<string | null | undefined>
43 setMain(target: string | null): Promise<void>
44 pin(): Promise<MainPin | null | undefined>
45 setPin(pin: MainPin): Promise<void>
46 route(agentId: string): Promise<Route | undefined>
47 setRoute(agentId: string, route: Route): Promise<void>
48 translated(who: string): Promise<Readonly<Record<string, string>> | undefined>
49 setTranslated(who: string, calls: Readonly<Record<string, string>>): Promise<void>
50}
51// The tools main has, and which of its MCP tools are loaded.
52type ToolSource = { list(): Promise<readonly ToolInfo[]>; loadedMcp(): Promise<ReadonlySet<string>> }
53// A conversation's transcript in the Messages API's form (main's, or an agent's), or why it cannot be read.
54export type Transcripts = { transcript(agentId: string | undefined): Promise<readonly ApiMessage[] | { deny: string }> }
55export type Sleeper = { sleep(ms: number, signal: AbortSignal): Promise<void> }
56export type StepIo = Say & ToolSource & Transcripts & PromptsPort & Pick<TrustPort, 'trusted'>
57
58const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
59const stepKey = (e: Pick<TurnStepInput, 'agentId' | 'turnId' | 'index'>) => `${e.agentId ?? 'main'}:${e.turnId}:${e.index}`
60
61// The alias names /model keeps from the native command while the config is broken: the last valid
62// config's, and the broken file's own.
63const knownAliases = (s: Session) => [...s.goodAliases, ...(s.loaded.declared ?? [])]
64
65// --- main and subagent routes ----------------------------------------------------------------------------
66
67// Selects main's target (null: native); when the target changes, forgets main's last remote prompt size and
68// notes a route left (leave.ts).
69// A selection holds once written. A clear holds in memory first, then in state, then on the status line;
70// a rejected write of it is logged, not thrown: main stays native in this session, though a new session
71// may read the old target back.
72async function setMain(io: Pick<RouteStore, 'setMain'> & Pick<Say, 'debug'> & StatusPort, s: Session, target: string | null) {
73 if (s.main === null && target === null) return
74 if (target !== s.main) s.promptTokens.delete('main')
75 if (target !== null) await io.setMain(target)
76 switched(s, target)
77 s.main = target
78 if (target === null) {
79 try {
80 await io.setMain(null)
81 } catch (err) {
82 io.debug(`styx: could not persist clearing main (${String(err)}); main is native in this session`)
83 }
84 }
85 showStatus(io, s)
86}
87
88// The main conversation's route for this step: fixed per turn at its first step and persisted, so a later
89// selection or a reload never changes a turn already under way.
90async function mainRoute(io: Pick<RouteStore, 'main' | 'pin' | 'setPin'>, s: Session, e: TurnStepInput): Promise<string | null> {
91 if (s.pin?.turnId === e.turnId) return s.pin.target
92 const persisted = await io.pin()
93 if (persisted?.turnId === e.turnId) return (s.pin = persisted).target
94 if (e.index > 0) return null
95 if (s.main === undefined) s.main = (await io.main()) ?? null
96 s.pin = { turnId: e.turnId, target: s.main }
97 if (s.main !== null) s.routedTurns++
98 await io.setPin(s.pin)
99 return s.pin.target
100}
101
102// A subagent's route: memory, else (while a styx spawn is in flight) a bounded wait, else the persisted one; else, when
103// the wait ran out with a spawn asked for on a styx target (or a fork under a routed parent) still starting, a route
104// that refuses.
105async function subRoute(io: Pick<RouteStore, 'route'> & Sleeper & AgentTypes & Pick<Say, 'debug'>, s: Session, agentId: string, signal: AbortSignal): Promise<Route | undefined> {
106 const hit = s.routes.get(agentId)
107 if (hit !== undefined) return hit
108 let timedOut = false
109 if (s.pendingSpawns.size > 0) {
110 const ac = new AbortController()
111 const outcome = await Promise.race([
112 Promise.allSettled([...s.pendingSpawns]).then(() => 'settled'),
113 io.sleep(SPAWN_WAIT_MS, AbortSignal.any([ac.signal, signal])).then(
114 () => 'timeout',
115 () => 'aborted',
116 ),
117 ])
118 ac.abort()
119 const after = s.routes.get(agentId)
120 io.debug(`styx spawn-wait ${agentId} outcome=${outcome} routed=${after !== undefined}`)
121 if (after !== undefined) return after
122 timedOut = outcome === 'timeout'
123 }
124 const persisted = await io.route(agentId)
125 if (persisted !== undefined) s.routes.set(agentId, persisted)
126 return persisted ?? (timedOut ? (unstarted(s, agentId) ?? (await unclaimedFork(io, s, agentId))) : undefined)
127}
128
129// --- remote steps ----------------------------------------------------------------------------------------
130
131// The tool names the `tool_reference` blocks of a transcript load: what ToolSearch returned to its caller.
132function referencedTools(messages: readonly ApiMessage[]): Set<string> {
133 const names = new Set<string>()
134 const scan = (block: unknown) => {
135 if (!isObject(block)) return
136 if (block['type'] === 'tool_reference' && typeof block['tool_name'] === 'string') names.add(block['tool_name'])
137 else if (block['type'] === 'tool_result' && Array.isArray(block['content'])) block['content'].forEach(scan)
138 }
139 for (const m of messages) if (typeof m.content !== 'string') m.content.forEach(scan)
140 return names
141}
142
143// The tools a remote request offers: the non-MCP and loaded MCP tools `offered` keeps, the styx agent tool
144// when the loop may call both Agent (which its calls become) and the tool itself (the tool list holds them
145// and `offered` keeps them), and SubagentHandback to a subagent unless `noHandback` (a fork, which Claude Code offers
146// none; a subagent the engine did not say it delivers through it; one the engine refused it; inside the cap). Each goes with its generated
147// schema or a permissive one. An MCP tool is loaded when main's usage says so (`mainMcp`: main, or a fork of main,
148// whose tools are main's: `$.tool.list()` and the usage are main's) or when the transcript sent holds a
149// `tool_reference` to it, which is how a subagent loads one.
150async function toolSet(io: ToolSource, s: Session, config: Config, t: Remote, isSub: boolean, mainMcp: boolean, noHandback: boolean, offered: (name: string) => boolean, referenced: ReadonlySet<string>): Promise<RemoteTool[]> {
151 const handback = { name: HANDBACK_TOOL.name, description: HANDBACK_TOOL.description, schema: HANDBACK_TOOL.schema }
152 // A model without tools is offered none, SubagentHandback included: its text-only end is handed back by styx (step.ts).
153 if (!t.model.tools) return []
154 const listed = await io.list()
155 const loadedMcp = new Set([...(mainMcp ? await io.loadedMcp() : []), ...referenced])
156 const tools: RemoteTool[] = []
157 const seen = new Set<string>([HANDBACK_TOOL.name])
158 const schemaless: string[] = []
159 const long: string[] = []
160 const wrapped = listed.some(x => x.name === 'Agent') && offered('Agent') && offered(WRAPPER)
161 for (const tool of listed) {
162 if (seen.has(tool.name)) continue
163 seen.add(tool.name)
164 if (tool.name === WRAPPER) {
165 if (wrapped) tools.push({ name: WRAPPER, description: advert(config), schema: agentSchema(config, SCHEMAS['Agent'] ?? {}) })
166 continue
167 }
168 if (!offered(tool.name)) continue
169 if (tool.mcp && !loadedMcp.has(tool.name)) continue
170 if (!TOOL_NAME_RE.test(tool.name)) {
171 long.push(tool.name)
172 continue
173 }
174 const schema = SCHEMAS[tool.name] ?? s.mcpSchemas[tool.name]
175 if (schema === undefined) schemaless.push(tool.name)
176 tools.push({ name: tool.name, description: tool.description, schema: schema ?? { type: 'object' } })
177 }
178 const cap = t.provider.maxTools
179 const hands = isSub && !noHandback
180 const room = hands ? cap - 1 : cap
181 const kept = tools.slice(0, Math.max(0, room))
182 s.toolReport = { schemaless, capped: tools.slice(Math.max(0, room)).map(x => x.name), long, cap }
183 if (hands && cap > 0) kept.push(handback)
184 return kept
185}
186
187// The tool that delivers a subagent step's failure as its report: SubagentHandback, when the step's request
188// offers it (`offered`) and the subagent's transcript (`read`, else read here) says the engine delivers
189// through it and shows no call of it refused since, so a run hands back at most once. A transcript that cannot be
190// read cannot show a refusal, so styx's own record stands in: the first failure of a turn is handed back, and
191// the failure of a later step of that turn, which the engine only runs after refusing the call, is not.
192// Undefined for main and otherwise: the failure is then answered as text.
193async function handbackFor(io: Transcripts, s: Session, e: TurnStepInput, offered: boolean, read?: unknown): Promise<string | undefined> {
194 if (e.agentId === undefined || !offered) return undefined
195 let messages: unknown
196 try {
197 messages = read ?? (await io.transcript(e.agentId))
198 } catch {}
199 if (Array.isArray(messages)) return handbackState(messages as ApiMessage[], HANDBACK_TOOL.name) === 'offered' ? HANDBACK_TOOL.name : undefined
200 if (s.blindHandbacks.get(e.agentId) === e.turnId) return undefined
201 s.blindHandbacks.set(e.agentId, e.turnId)
202 return HANDBACK_TOOL.name
203}
204
205// Answers a step with `text` in place of a response, after whatever `assembler` has yielded: as text, or
206// as one call of `handback` when it names one and no tool call was yielded.
207async function* failStep(e: TurnStepInput, assembler: Assembler, text: string, handback?: string): StepResult {
208 const end = assembler.end({ failure: text, ...(handback === undefined ? {} : { handback }) })
209 yield* end.chunks
210 return { turnId: e.turnId, index: e.index, answer: end.answer, toolUses: end.toolUses, stopReason: end.stopReason, usage: null }
211}
212
213async function* remoteStep(io: StepIo & Pick<RouteStore, 'route' | 'translated' | 'setTranslated'>, s: Session, backend: Backend, e: TurnStepInput, target: string, route: Route | undefined, signal: AbortSignal): StepResult {
214 const key = stepKey(e)
215 const record: StepRecord = { target }
216 s.steps.set(key, record)
217 const who = e.agentId ?? 'main'
218 // A failure before the request is sent; a subagent's goes back through `handback` when it names one, and
219 // is toasted (a main step's text is the answer already).
220 const fail = async function* (text: string, handback: string | undefined) {
221 s.steps.delete(key)
222 if (e.agentId !== undefined) io.toast(text)
223 return yield* failStep(e, createAssembler(target), text, handback)
224 }
225 // A fork is offered no SubagentHandback, so its failures are answered as text, its final message.
226 const fork = isFork(route?.type)
227 // A subagent styx started but could not run on the styx model its caller asked for hands that back as its report.
228 if (route?.refused !== undefined) return yield* fail(route.refused, await handbackFor(io, s, e, !fork))
229 const snap = s.loaded
230 const t = snap.config === undefined ? undefined : resolve(snap.config, target)
231 if (snap.config === undefined || t?.kind !== 'remote') {
232 return yield* fail(
233 `styx: ${target} is not available (${snap.errors[0] ?? 'no longer configured'}); the step was not sent. Fix ${snap.path}, run /styx reload, or pick another model`,
234 await handbackFor(io, s, e, !fork),
235 )
236 }
237 if (!(await isTrusted(io, t.provider))) {
238 return yield* fail(`styx: provider ${t.provider.id} is not approved; run /model ${labelOf(snap.config, target)} to approve it. The step was not sent`, await handbackFor(io, s, e, !fork))
239 }
240 await approve(s, backend, t.provider)
241 // A custom agent type's subagent gets its definition's prompt and tools, and so does a built-in type's that one
242 // agent file names; any other built-in type's gets the prompt styx writes for it and the tools its type allows;
243 // main the composed prompt; a fork its parent's prompt. Each names the routed model.
244 const isSub = e.agentId !== undefined
245 const read = await io.transcript(e.agentId)
246 const forkBase = fork && route !== undefined ? await forkParent(io, s, route) : undefined
247 if (fork && forkBase === undefined) return yield* fail(`styx: the route of the parent of this fork cannot be read; the step was not sent; retry`, undefined)
248 // What the prompt is the prompt of: the route itself, or for a fork its parent's (undefined: main).
249 const shape = forkBase === undefined ? route : forkBase.route
250 const def = isSub ? await definitionOf(io, s, shape?.type) : undefined
251 // Main keeps its own tools; only a subagent is cut from them: by its type, and by the subagent that started it.
252 const pool = e.agentId === undefined ? () => true : await poolOf(io, s, e.agentId)
253 // A fork is sent its parent's history joined to its own (fork.ts), told there how it reports; a fork whose history
254 // cannot be rebuilt whole is not sent. The tool_reference blocks of that history load MCP tools.
255 const history =
256 'deny' in read
257 ? undefined
258 : fork && route !== undefined
259 ? await forkHistory(io, s, who, route, read, WRAPPER, [FORK_REPORT])
260 : withTranslated(e.agentId === undefined ? read : withTask(read, route?.prompt), await translatedOf(io, s, who), WRAPPER)
261 const referenced = history === undefined ? new Set<string>() : referencedTools(history)
262 // A subagent is offered SubagentHandback only while the engine's transcript says it delivers through it (in 2.1.294,
263 // only in auto permission mode) and no call of it has been refused since; else its final text is its report. A
264 // `tools: false` subagent is offered none: styx hands its final text back as the call (`synth`).
265 const synth = isSub && !fork && !t.model.tools
266 const state = !isSub ? 'unsaid' : 'deny' in read ? 'offered' : handbackState(read, HANDBACK_TOOL.name)
267 if (isSub && !fork && state !== 'offered' && (s.offered.get(who)?.has(HANDBACK_TOOL.name) ?? true)) io.debug(`styx: ${who} is offered no SubagentHandback (${state === 'unsaid' ? 'the engine did not say it delivers through it; its final text is its report' : 'the engine refused a call of it'})`)
268 const tools = await toolSet(io, s, snap.config, t, isSub, !isSub || (fork && forkBase?.route === undefined), fork || state !== 'offered', pool, referenced)
269 const names = tools.map(x => x.name)
270 if (e.agentId !== undefined) s.offered.set(e.agentId, new Set(names))
271 const prompt = await systemFor(io, s, { def, route: shape, fork: forkBase !== undefined, engineModel: e.model, target: t.target, model: t.model.id, provider: t.provider.id, tools: names })
272 const handback = await handbackFor(io, s, e, names.includes(HANDBACK_TOOL.name) || synth, read)
273 if ('deny' in read) return yield* fail(`styx: the transcript of ${who} is unreadable (${read.deny}); the step was not sent; retry`, handback)
274 if (history === undefined) return yield* fail(FORK_HISTORY, handback)
275 const messages = withIdentity(t.model.tools ? history : withoutTool(history, HANDBACK_TOOL.name), t.model.id, t.provider.id)
276 io.debug(
277 `styx req ${who} msgs=${messages.length} firstUser=${JSON.stringify(redact(firstUserText(messages)).slice(0, 60))} tools=${tools.length}[${tools
278 .slice(0, 8)
279 .map(x => x.name)
280 .join('|')}]`,
281 )
282 // The styx agent call's effort, else the level a custom agent's definition declares, else the step's own.
283 const declared = def?.effort !== undefined && EFFORTS.includes(def.effort as Effort) ? (def.effort as Effort) : undefined
284 const effort = route?.effort ?? declared ?? e.effort
285 const req: StepRequest = { target, system: prompt.text, tools, transcript: messages, ...(effort === undefined ? {} : { effort }), who }
286 const budget = inputBudget(t.model, t.provider.kind)
287 // The last response's prompt size counts while the transcript has not shrunk since (a /compact shrinks it);
288 // else the request's own size.
289 const last = s.promptTokens.get(who)
290 const estimate = last !== undefined && e.messageCount >= last.messageCount ? last.tokens : Math.ceil(JSON.stringify(req).length / 3.5)
291 if (estimate > 0.95 * budget) {
292 const toolTokens = Math.ceil(JSON.stringify(tools).length / 3.5)
293 return yield* fail(
294 toolTokens > 0.5 * budget
295 ? `styx: tool schemas alone take ~${k(toolTokens)} of ${target}'s ${k(budget)} input budget; set "tools": false on the model or use a larger one`
296 : `styx: context ~${k(estimate)} exceeds ${target}'s ${k(budget)} input budget; run /compact`,
297 handback,
298 )
299 }
300 const assembler = createAssembler(t.target)
301 record.assembler = assembler
302 for await (const ev of backend.step(req, signal)) {
303 if (ev.type === 'stats') {
304 s.lastSteps.delete(who)
305 s.lastSteps.set(who, { target, stats: ev, prompt: prompt.source })
306 }
307 yield* assembler.feed(ev.type === 'tool_use' && ev.name === WRAPPER ? await translateUse(io, s, who, target, ev, names.includes(WRAPPER)) : ev)
308 }
309 // A subagent offered SubagentHandback hands a request failure back as its report, so its run ends with
310 // the failure delivered rather than re-asked; after a refused handback it answers text, and the run ends.
311 const end = assembler.end({ ...(handback === undefined ? {} : { handback }), ...(synth ? { deliver: true } : {}) })
312 if (end.delivered === true) io.debug(`styx: ${who} ended on text; styx handed it back as one ${HANDBACK_TOOL.name} call (tools: false)`)
313 const stop = end.chunks.pop() as TurnStepChunk
314 yield* end.chunks
315 const u = end.usage
316 if (u !== null) s.promptTokens.set(who, { tokens: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, messageCount: e.messageCount })
317 if (end.failure !== undefined && e.agentId !== undefined) io.toast(firstLine(end.failure))
318 const result: TurnStepResult = { turnId: e.turnId, index: e.index, answer: end.answer, toolUses: end.toolUses, stopReason: end.stopReason, usage: end.usage }
319 yield stop
320 s.steps.delete(key)
321 return result
322}
323
324// A turn.step: native when no styx target holds it, else the remote step. `native` is the step beneath.
325export async function* turnStep(io: StepIo & Pick<RouteStore, 'main' | 'pin' | 'setPin' | 'route' | 'translated' | 'setTranslated'> & Sleeper & AgentTypes, s: Session, backend: Backend, e: TurnStepInput, signal: AbortSignal, native: () => StepResult): StepResult {
326 const route = e.agentId === undefined ? undefined : await subRoute(io, s, e.agentId, signal)
327 const target = e.agentId === undefined ? await mainRoute(io, s, e) : (route?.target ?? null)
328 if (target === null) return yield* native()
329 return yield* remoteStep(io, s, backend, e, target, route, signal)
330}
331
332// A turn.step whose hook failed (`error`): a step styx routes is answered with the internal-error text,
333// handed back when it is a subagent's, and never passed to another model; any other goes on beneath.
334export async function* stepFailed(io: Say & Transcripts, s: Session, e: TurnStepInput, error: { kind: string; message?: string }, native: () => StepResult): StepResult {
335 const key = stepKey(e)
336 const record = s.steps.get(key)
337 const target = record?.target ?? (e.agentId !== undefined ? (s.routes.get(e.agentId)?.target ?? null) : s.pin?.turnId === e.turnId ? s.pin.target : (s.main ?? null))
338 try {
339 io.debug(`styx: turn.step ${key} failed (${error.kind}: ${error.message ?? ''}); ${target === null ? 'passed to the native model' : `answered with the internal-error text for ${target}`}`)
340 } catch {}
341 if (target === null) return yield* native()
342 s.steps.delete(key)
343 const handback = await handbackFor(io, s, e, !isFork(s.routes.get(e.agentId ?? '')?.type))
344 return yield* failStep(e, record?.assembler ?? createAssembler(target), `styx: internal error on ${target}; the step was not sent to another model (see the debug log)`, handback)
345}
346
347// --- /model ----------------------------------------------------------------------------------------------
348
349export type ModelPort = TrustPort & Pick<RouteStore, 'setMain'> & Pick<Say, 'debug'> & StatusPort & { contextTokens(): Promise<number> }
350
351// /model: a styx alias or `provider/model` selects main's target, one line and no toast; any other argument
352// goes to the native command (`next`), and leaves styx when styx held main.
353export async function modelCommand(io: ModelPort, s: Session, e: CommandRunInput, next: (e: CommandRunInput) => Promise<CommandRunResult>): Promise<CommandRunResult> {
354 const withLine = (text: string | undefined, line: string) => (text ? `${text}\n${line}` : line)
355 const leaving = () => (s.main ? `styx: left ${shownTarget(s.loaded.config, s.main)}` : undefined)
356 // The native /model with `args`, then main cleared; its output says so when styx held main.
357 const native = async (args: string) => {
358 const left = leaving()
359 const r = await next({ ...e, args })
360 await setMain(io, s, null)
361 return left === undefined ? r : { ...r, text: withLine(r.text, left) }
362 }
363 const arg = e.args.trim()
364 if (arg === '') {
365 // The picker. Its pick lands after `next(e)` resolves, so styx leaves before opening it: a pick, an
366 // unchanged pick and Esc all leave styx alike, and /model <alias> returns to the alias.
367 const left = leaving()
368 if (left !== undefined) await setMain(io, s, null)
369 const r = await next(e)
370 return left === undefined ? r : { ...r, text: withLine(r.text, left) }
371 }
372 const snap = s.loaded
373 if (snap.missing || !isStyxShaped(snap.config, arg, knownAliases(s))) return native(e.args)
374 if (snap.config === undefined && arg.startsWith('native/') && arg.length > 'native/'.length) return native(arg.slice('native/'.length))
375 if (snap.config === undefined) return { text: `styx: can't switch to ${arg}: config error, ${snap.errors[0]}. Native model unchanged; fix ${snap.path}, then run /styx reload` }
376 const t = resolve(snap.config, arg)
377 if (t === undefined) return { text: explain(snap.config, arg) }
378 if (t.kind === 'native') return native(t.model)
379 if (!(await ensureTrust(io, t.provider))) return { text: `styx: provider ${t.provider.id} not approved; native model unchanged. Run /model ${arg} again and choose Allow` }
380 const context = Math.max(await io.contextTokens(), s.promptTokens.get('main')?.tokens ?? 0)
381 const budget = inputBudget(t.model, t.provider.kind)
382 if (context > 0.85 * budget) return { text: `styx: transcript ~${k(context)} tokens exceeds ${t.target}'s ${k(budget)} input budget; run /compact, then /model ${arg}` }
383 await setMain(io, s, t.label)
384 return { text: `Set model to ${t.label === t.target ? t.target : `${t.label} (${t.target})`}` }
385}
386
387// When /model fails inside styx: a styx-shaped argument is answered, the native command not run; any other
388// goes on beneath (`next`), and so does a failure after `next` was called.
389export function modelFailed(s: Session, e: CommandRunInput, called: boolean, next: (e: CommandRunInput) => Promise<CommandRunResult>): Promise<CommandRunResult> | CommandRunResult {
390 return called || s.loaded.missing || !isStyxShaped(s.loaded.config, e.args.trim(), knownAliases(s))
391 ? next(e)
392 : { text: `styx: couldn't switch to ${e.args.trim()} (internal error; see the debug log). Native model unchanged` }
393}
394
395// A model switch made outside /model (the /config Model row, the SDK) leaves styx too: main is cleared, and
396// the status line says so.
397export async function modelSwitched(io: Pick<RouteStore, 'setMain'> & Pick<Say, 'debug'> & StatusPort, s: Session, source: string) {
398 if ((source === 'command' || source === 'picker' || source === 'sdk') && s.main) await setMain(io, s, null)
399}
400hooks/session.ts 98 lines1// All of styx's mutable session memory in one object, and the one reset a config reload performs. Pure:
2// register.ts creates one Session per registration and hands it to the modules that read and write it.
3import type { Effort, MainPin, Route } from '../types'
4import type { JsonSchema } from './config'
5import type { Loaded } from './load'
6import type { Pool } from './pool'
7import type { ApiMessage, StepStats } from './protocol'
8import type { createAssembler } from './step'
9
10export type Assembler = ReturnType<typeof createAssembler>
11// A remote step in flight, for the turn.step .catch: its target, and the assembler of what it has yielded.
12export type StepRecord = { target: string; assembler?: Assembler }
13// A conversation's last routed step: where it went, how it went, and where its system prompt came from.
14type LastStep = { target: string; stats: StepStats; prompt: string }
15// What an Agent call says that its spawn event does not carry: its effort, and whether it asked for a worktree.
16// `target` marks a call styx made from a routed model's styx agent call: the styx target the caller chose
17// (null: a native model, which the call names itself). `deny` is why styx left a styx agent call as it was, for
18// the tool to answer.
19export type CallNote = { effort?: Effort; isolated?: boolean; elsewhere?: string; target?: string | null; deny?: string }
20// The notes kept for Agent calls whose spawn has not taken them: only the latest 64.
21const CALLS_MAX = 64
22// A route main has left: its alias (or `provider/model`), the `provider/model` behind it, and how many main turns
23// it answered since it was chosen. Native Claude is told of the routes left, once, with the next prompt.
24export type Left = { label: string; target: string; turns: number }
25// The last tool set a remote request was built with: what it left out, and why.
26type ToolReport = { schemaless: string[]; capped: string[]; long: string[]; cap: number }
27
28export function createSession() {
29 const s = {
30 loaded: { errors: [], missing: true, path: '~/.claude/styx.json' } as Loaded, // until the first load says where it looked
31 goodAliases: [] as readonly string[], // the alias names of the last valid config loaded
32 mcpSchemas: {} as Readonly<Record<string, JsonSchema>>,
33 notes: [] as string[], // what a load found worth saying in /styx
34 toolReport: { schemaless: [], capped: [], long: [], cap: 0 } as ToolReport,
35 // Files read for agent types this session (agent files, installed_plugins.json), parsed, by path (null: no file).
36 agentFiles: new Map<string, unknown>(),
37 agentNotes: new Set<string>(), // the agent types (custom, and built-in ones an agent file may define) whose definition lookup is logged, and the notes said once
38 approved: new Set<string>(), // the fingerprints the backend has been told are approved
39 userAgent: undefined as string | undefined, // the User-Agent of remote requests
40 trashPath: undefined as string | undefined,
41 platform: undefined as string | null | undefined, // `uname -s`, lowercased; null when it could not be read
42 main: undefined as string | null | undefined, // main's target; undefined until read from state after a load
43 pin: null as MainPin | null,
44 // In memory only: a hot reload of the hooks module loses both (accepted; `/styx reload` keeps them).
45 routedTurns: 0, // the main turns pinned to the current route since it was chosen (a turn, not its steps)
46 left: [] as Left[], // the routes main left, oldest first, until a native main's prompt carries their note
47 routes: new Map<string, Route>(), // agentId → route
48 pendingSpawns: new Set<Promise<void>>(),
49 // the pending spawns of a styx agent call styx made, or of a fork under a routed parent (`fork`), by their barrier → the target asked for, and the id the engine started once it has
50 requested: new Map<Promise<void>, { target: string; id?: string; fork?: boolean }>(),
51 calls: new Map<string, CallNote>(), // tool_use_id → an Agent call's note, until its spawn takes it
52 // main (as "main") or an agentId → the styx agent calls of its routed steps that styx made Agent calls of: tool_use id → the model named
53 translated: new Map<string, Readonly<Record<string, string>>>(),
54 steps: new Map<string, StepRecord>(), // `${agentId ?? "main"}:${turnId}:${index}` → remote step
55 offered: new Map<string, ReadonlySet<string>>(), // agentId → the tool names its last remote step offered
56 pools: new Map<string, Pool>(), // agentId → the tools it may be offered: its type's, within its caller's (pool.ts)
57 // agentId or "main" → the last remote prompt_tokens, and the messageCount of the request they counted
58 promptTokens: new Map<string, { tokens: number; messageCount: number }>(),
59 lastSteps: new Map<string, LastStep>(), // agentId or "main" → its last routed step, least recent first
60 blindHandbacks: new Map<string, string>(), // agentId → the turn whose failure was handed back with its transcript unreadable
61 // agentId → a fork's inherited history as first rebuilt: its parent's through the cut turn and the turn after it (if read), translated.
62 // Memory only: a hooks reload loses it and the next step rebuilds. A reload keeps it, a running fork needs it.
63 forkPrefixes: new Map<string, { messages: readonly ApiMessage[]; cut: number }>(),
64
65 // Keeps an Agent call's note until its spawn takes it; the oldest goes when more than 64 are kept.
66 note(id: string, note: CallNote) {
67 s.calls.set(id, note)
68 const [oldest] = s.calls.keys()
69 if (s.calls.size > CALLS_MAX && oldest !== undefined) s.calls.delete(oldest)
70 },
71
72 // Forgets what a subagent's finished turn leaves: its route, offered tools, tool pool, last step, prompt
73 // size, blind handback, translated calls, and any step recorded for it that never ended (an aborted one).
74 // Its route and translated calls stay in state, so a later message to it routes again.
75 forget(agentId: string) {
76 for (const m of [s.routes, s.offered, s.pools, s.lastSteps, s.promptTokens, s.blindHandbacks, s.translated, s.forkPrefixes]) m.delete(agentId)
77 for (const key of s.steps.keys()) if (key.startsWith(`${agentId}:`)) s.steps.delete(key)
78 },
79
80 // Forgets what the config text decides, ahead of loading it again: the agent files read and the tool pools
81 // drawn from them, the approvals told to the backend, the MCP schemas, the load notes and the tool report.
82 // `loaded` is replaced by the load itself, so a step racing the reload keeps the config it started with.
83 // Routes, pins, steps, offered sets, prompt sizes and last steps are what a running turn still needs, so
84 // they stay.
85 resetForReload() {
86 s.agentFiles.clear()
87 s.agentNotes.clear()
88 s.pools.clear()
89 s.approved.clear()
90 s.mcpSchemas = {}
91 s.notes = []
92 s.toolReport = { schemaless: [], capped: [], long: [], cap: 0 }
93 },
94 }
95 return s
96}
97export type Session = ReturnType<typeof createSession>
98