SLOPSHOPPER

styx

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

newguardcommandtoaststatusprompt
v0.1.0MITupdated 2026-10-09Trosfy/claude-styx
A shopper browsing a rack in a slop shop
README

<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.

Quickstart

  1. Install the mod. Clone this repository into a folder you will keep, since Claude Code loads styx from there. You do not need 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
  1. Copy the example config. It defines one provider, 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
  1. Store your key. In the same folder, run the command below. It asks for the key without echoing it, stores it in the macOS Keychain (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
  1. Start a new Claude Code session, or run /reload-plugins.
  2. Type /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.

Let your AI set it up

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.

What does it do?

  • It moves the main conversation. /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.
  • It runs subagents on another model, through the 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.
  • It keeps your tools local. Tool calls run in Claude Code and go through the usual permission checks; only the model runs elsewhere. styx never reroutes your Claude login or its traffic.
  • It keeps keys out of Claude Code. A credential helper (the macOS Keychain, say) hands the key to a helper process; Security says where the key never goes.

What you need

Claude Code2.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.
Bun1.3.11 tested. The helper process runs on it. styx needs no packages at run time.
A providerAn endpoint that speaks one of the three kinds, a model id and an API key.
A keyringThe macOS Keychain, or secret-tool on Linux. Any command that prints the key also works (pass, op read).
SystemmacOS or Linux, with /usr/bin/curl. Optional: git and trash, for subagents that work in a git worktree.

Which provider kind should I pick?

A provider's kind is the protocol it speaks. If you are unsure, pick openai, which most endpoints speak.

KindPick it forstyx callsStatus
openaiOpenAI, OpenRouter, Ollama, vLLM, other OpenAI-compatible servers and gateways, Bedrock's non-Claude models<baseUrl>/chat/completionsused live on a self-hosted gateway and on Bedrock's OpenAI endpoint; not yet run against OpenAI itself
anthropicThe Anthropic API, a gateway's /v1/messages<baseUrl>/v1/messageslive-tested on a gateway; not yet run against api.anthropic.com
bedrockAmazon Bedrock: Claude, GPT, Kimi, Grok<baseUrl>/model/<id>/converse-streamlive-tested on all four; tool loops tested on Claude

Recipes with the URLs: docs/PROVIDERS.md.

Using it

  • Switch the main conversation with /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.
  • Start a subagent by asking for it by alias ("run an Explore agent on gpt"). Claude calls 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.
  • While main runs on an alias, a plain 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.
  • A routed request uses your session's effort, mapped to the nearest level the model declares (Effort). Claude models on the 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.

How does it work?

  • The mod decides. A native turn passes through unchanged. When /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.
  • styxd talks to the provider. It is one helper process per session, started when the session starts (if your config declares a provider) and again on use after it exits. It encodes the request for the provider's kind, runs the key command, streams the answer back and reports timing and usage. It is ready in about 20 ms and idles at about 20 MB (macOS footprint, Bun 1.3.11). It exits after 10 idle minutes (65 when a model or alias sets "cache": "1h"; see Thinking), or when Claude Code does.
  • Native requests change in one way: they carry the mcp__styx__agent tool definition. That costs one prompt-cache miss when styx loads, and another each time the config changes.

Security

  • Keys come from a credential helper only. No key is in the config, the environment, a command line or a log. bun run auth login passes the key on stdin to the Keychain (secret-tool on Linux).
  • You approve each provider once. The prompt shows the address and the key command word for word. The approval covers kind, address and command, so changing any of them asks again. Until you approve, styx runs no command and sends nothing.
  • Plain http is opt-in. Set "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.
  • The helper's socket is private. It sits in a folder only you can open, and every call carries a random token that is never in a command line, the environment or a file.
  • A routed turn sends everything in it to that endpoint: prompts, files the model reads, tool output. Route only to endpoints you may send that data to.
  • styx allows some 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.
  • Limit: Claude Code's Bash tool can read your Keychain item, as it can read any same-user store. Use scoped keys (a key limited to the models you route to, where your provider offers that) and deny security find-generic-password in /permissions.

To report a vulnerability, see SECURITY.md.

Credits and license

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.

Source 24 files
hooks/register.ts 291 lines
1// 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}
291
hooks/backend.ts 332 lines
1// 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}
332
hooks/advert.ts 74 lines
1// 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}
74
hooks/config.ts 398 lines
1// 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}
398
hooks/inherit.ts 219 lines
1// 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}
219
hooks/leave.ts 47 lines
1// 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}
47
hooks/load.ts 125 lines
1// 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}
125
hooks/pool.ts 84 lines
1// 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}
84
hooks/prompts.ts 395 lines
1// 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}
395
hooks/protocol.ts 80 lines
1// 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)}`
80
hooks/routing.ts 400 lines
1// 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}
400
hooks/session.ts 98 lines
1// 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