Switches a running session between api.anthropic.com and the local Model Gateway: onto the gateway once Remote Control has registered, on /gateway on, or as…

A Claude Code mod that switches a running session between api.anthropic.com and a local Model Gateway: onto the gateway once Remote Control has registered, on /gateway on, or as the session starts after /gateway setup, and back to api.anthropic.com while the gateway does not answer.
Remote Control refuses to start in a session whose ANTHROPIC_BASE_URL points anywhere but api.anthropic.com, and Model Gateway works by setting that variable in the project's settings. This mod sets it on the running process instead, after Remote Control is up, so one session can have both.
It does not replace Model Gateway. The gateway itself (the proxy and shim that route ChatGPT/Codex, Grok, Kimi, Cursor and OpenCode Go models), its supervisor, its CLI and its skill all stay with the model-gateway plugin, which must be installed. This mod only decides when the session uses it.
ANTHROPIC_BASE_URL out of every settings file the project reads. The mod sets the whole env block itself, so the project's .claude/settings.local.json can be empty. Run model-gateway's setup as setup --preserve-wiring: plain setup writes the project's wiring, and a session that starts wired cannot start Remote Control. A wiring it wrote by mistake is undone by removing ANTHROPIC_BASE_URL from the file. model-gateway's SessionStart line saying the project is not wired is expected.claude --plugin-dir mods/gateway-switch./gateway setup once in the project so that every session started there turns the gateway on at once (see On at every start).On session.start it registers /gateway and starts a check every two seconds. A session whose ANTHROPIC_BASE_URL already points elsewhere is left alone; one already pointed at the gateway counts as wired.
bridge-pointer.json into the project's folder under ~/.claude/projects (the one that holds the session's transcript), naming the pid of the process that owns it. Every session also listens on a messaging socket named after its own pid (CLAUDE_CODE_MESSAGING_SOCKET, .../cc-socks/<pid>.sock). When the pointer names this process, the mod wires the gateway.model-gateway.js ensure --quiet, reads the env block model-gateway.js env prints (the base URL, the fixed switches and the Claude alias pins), checks /healthz, remembers the values the session had, and sets the block on the process with ANTHROPIC_BASE_URL last. Claude Code builds its API client for every request and reads the variable each time, so the next request goes to the gateway./gateway on in a lead never reaches the teammates it spawns: they start on api.anthropic.com, and one whose agent pins a gateway model falls back to the lead's. The mod therefore has each lead write its mode and the env block it wired to ~/.claude/gateway-switch/leads/<session id>.json on every change and again just before it spawns a teammate (under each id it has had, since /clear gives the process a new one), and a session whose command line carries --agent-id and --parent-session-id (how Claude Code starts a tmux teammate) reads its lead's file. It wires at session start from that block, without starting the gateway CLI (a few hundred milliseconds), and holds each model request at turn.step until that is done, so its first request already goes through the gateway. A hook may hold a request for at most 10 seconds of its own time; a lead file without an env block makes the teammate run the gateway CLI instead, which can take longer, and its first request then goes out unwired. Every check after that wires it when the lead is on and unwires it when the lead was turned off. While the lead still waits for Remote Control, a teammate wired some other way (a gateway URL in the tmux environment) is left as it is, and so is every teammate of a lead that wrote no file (an older version of this mod).api.anthropic.com. Two answering checks wire the gateway again. While it is down, ensure --quiet runs at most every 30 seconds. A gateway that answers with ok: false stays wired, since only the proxy behind it is down and Claude models still pass through; a toast says so once./gateway| Argument | Effect | | :- | :- | | on | Wires the gateway now, without waiting for Remote Control | | off | Restores the remembered values and stays off, through Remote Control and gateway recovery alike | | rc | Opens Claude Code's own /remote-control dialog from a wired session (connect, or disconnect before switching accounts), then wires the gateway again | | setup | Adds a SessionStart hook to the project's .claude/settings.local.json that turns the gateway on as each session starts there; the current session is left as it is | | none, or status | Says whether the session is waiting, following its lead, on, suspended or off |
In a teammate, on and off also stop it from following its lead.
/gateway setup adds this group to hooks.SessionStart in the project's .claude/settings.local.json, keeping everything else the file holds, and does nothing when the group is already there:
{
"matcher": "startup|resume|fork",
"hooks": [
{
"type": "command",
"command": ": gateway-switch turns the gateway on as each session here starts"
}
]
}
The group marks the project; the hook itself does nothing (: is the shell's no-op) and sends Claude Code nothing. As each session starts, the mod reads the project's .claude/settings.local.json, and when the group is there it turns the gateway on itself, the same way in every kind of session, claude -p and the Agent SDK included. It wires without holding session.start, and each model request waits for the wiring as a teammate's does.
The mod reads the file instead of watching the hook run. Claude Code can seat its security default plugin (cc-plugin-sec-default) before the plugins a person installs: on a Team or Enterprise plan, on a machine with managed settings or a managed MCP server file, and by default on any plan while it cannot yet tell that neither applies, until the process ends. That plugin passes every classic.* hook of theirs over, classic.SessionStart included. Since 2.1.304 Claude Code says so in the transcript (its classic.SessionStart hook was skipped. "cc-plugin-sec-default" runs first and sent the event on past it.), the line earlier versions of this mod, which watched the hook, caused.
startup), a resumed one (claude --resume) and a forked one. /clear, compaction and /resume inside a running session keep the process, and with it the mode the session already had; a session still waiting for Remote Control stays waiting through them./gateway setup wrote a hook that answered {"hookSpecificOutput":{"hookEventName":"SessionStart","initialUserMessage":"/gateway on"}}. The mod still reads that group as the same mark, and a lead whose project holds it says so in a toast at start. Run /gateway setup again to replace it: Claude Code puts that message before the prompt of a claude -p run, where /gateway on also wires a session whose ANTHROPIC_BASE_URL points at another endpoint, and without this mod it answers Unknown command. setup removes the old hook, and its group when nothing else is left in it.setup rewrites the file as two-space JSON. A change Claude Code writes to the same file between its read and its write (an "always allow" from another session in the project) is lost./remote-control does not work in a wired session (see below). /gateway rc opens it.ANTHROPIC_BASE_URL already points at another endpoint is left alone, as it is without the group./gateway on switches the current one. To stop it, delete the group from the file./remote-control in a wired sessionClaude Code enables /remote-control only while ANTHROPIC_BASE_URL points at api.anthropic.com, and it rebuilds the command list only when plugins reload. A session wired after the list was built still offers the command, and running it answers Unknown command: /remote-control; after the next reload the command is gone. Remote Control itself stays connected while the session is wired.
/gateway rc works around both: it unwires the gateway, runs /reload-plugins so that the list is rebuilt while the session is first-party, runs /remote-control, and wires the gateway again when the dialog closes. The health watchdog holds still meanwhile, and a /gateway off given during the dialog keeps the session unwired. If the rebuilt list still has no /remote-control, Remote Control is disabled for another reason (sign-in, organization policy, a feature flag) and a toast says so.
bridge-pointer.json did not fire in a live 2.1.292 session: Claude Code connected Remote Control without writing the file where the mod looks (it may keep the pointer in its v5 storage). Use /gateway on once Remote Control is up.api.anthropic.com.claude -p and the Agent SDK never register Remote Control, so they are wired only by /gateway on.~/.claude/gateway-switch/leads/ after the session ends; each is a few hundred bytes.turn.step hook passes every session's model responses through the mod's hooks worker, whether or not the session is a teammate, because a hook cannot be registered for teammates alone./gateway on tries again. A session that started already pointed at the gateway clears only the base URL on /gateway off, and does not notice when that one write is held./model picker shows gateway rows from Model Gateway's discovery cache (~/.claude/cache/gateway-models.json), which Claude Code reads only while the base URL matches it; models the gateway added since the cache was written appear after a session that started wired.claude plugin test mods/gateway-switch runs the tests in tests/ without a session or network access: the environment, the gateway CLI, the health endpoint, the file system and the clock are answered from memory.
hooks/register.ts 755 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const DEFAULT_GATEWAY = 'http://127.0.0.1:18764'
4
5/** How often the bridge pointer and the gateway's health are checked. */
6const TICK_MS = 2000
7
8/**
9 * `$.http.fetch` takes no timeout, so a shim that accepts the connection and
10 * never answers would hold the check forever without this bound.
11 */
12const PROBE_TIMEOUT_MS = 3000
13
14/**
15 * Consecutive unreachable checks before the session leaves the gateway, and
16 * reachable ones before it returns, so that one slow answer does not flap it.
17 */
18const FAILURES_TO_UNWIRE = 2
19const SUCCESSES_TO_REWIRE = 2
20
21/**
22 * How often a suspended session asks the gateway's own CLI to start whatever
23 * is not running; the supervisor recovers the proxy itself, but a dead
24 * supervisor needs `ensure`.
25 */
26const ENSURE_INTERVAL_MS = 30_000
27
28/**
29 * The settings hook `/gateway setup` adds to a project, which marks it as one
30 * whose sessions turn the gateway on as they start. The mod reads the
31 * project's settings.local.json for it at session.start instead of watching
32 * the hook run: Claude Code's security default plugin, seated before the
33 * person's plugins on Team and Enterprise plans and on machines with managed
34 * settings, passes every `classic.*` hook of theirs over. The command is the
35 * shell's no-op, so the hook sends Claude Code nothing, and does nothing
36 * without this mod.
37 */
38const AUTO_ON_COMMAND = ': gateway-switch turns the gateway on as each session here starts'
39
40const AUTO_ON_HOOK = {
41 matcher: 'startup|resume|fork',
42 hooks: [{ type: 'command', command: AUTO_ON_COMMAND }],
43}
44
45/**
46 * The command earlier versions of `/gateway setup` wrote, which answered
47 * `/gateway on` as the session's initial message. Still read as the same
48 * mark; `/gateway setup` replaces it, because Claude Code puts that message
49 * before a `-p` run's prompt, where it would wire even a session pointed at
50 * another endpoint.
51 */
52const LEGACY_AUTO_ON_COMMAND = `printf '%s\\n' '${JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', initialUserMessage: '/gateway on' } })}'`
53
54/**
55 * The fixed part of model-gateway's env block: always the base layer, which
56 * the block `env` prints overrides key by key.
57 */
58const STATIC_ENV = {
59 CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
60 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
61 ENABLE_TOOL_SEARCH: 'true',
62 CLAUDE_CODE_MAX_OUTPUT_TOKENS: '64000',
63} as const
64
65/**
66 * Every variable model-gateway wires. The pins are absent when the gateway's
67 * `env` printed none, and are then left as the session had them.
68 */
69type GatewayEnv = {
70 ANTHROPIC_BASE_URL: string
71 CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: string
72 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: string
73 ENABLE_TOOL_SEARCH: string
74 CLAUDE_CODE_MAX_OUTPUT_TOKENS: string
75 ANTHROPIC_DEFAULT_OPUS_MODEL?: string
76 ANTHROPIC_DEFAULT_SONNET_MODEL?: string
77 ANTHROPIC_DEFAULT_FABLE_MODEL?: string
78}
79
80/** The values the session had before wiring, restored by `unwire`. */
81type Snapshot = Record<keyof GatewayEnv, string | undefined>
82
83/**
84 * `waiting`: Remote Control has not registered yet. `on`: the gateway is
85 * wanted, wired or suspended after it stopped answering. `off`: the user
86 * turned it off, or the session was already pointed elsewhere.
87 */
88type Mode = 'waiting' | 'on' | 'off'
89
90const state = {
91 mode: 'waiting' as Mode,
92 wired: false,
93 gateway: DEFAULT_GATEWAY,
94 saved: undefined as Snapshot | undefined,
95 failures: 0,
96 successes: 0,
97 lastEnsureMs: 0,
98 isDegraded: false,
99 isBusy: false,
100 projectDir: undefined as string | undefined,
101 rcHold: false,
102 /** Every session id this lead has had, `/clear` adding one each time. */
103 leadIds: [] as string[],
104 /** Whether leadIds was read back from the files an earlier load of this module wrote. */
105 hasLeadIds: false,
106 /**
107 * The lead's session id while this session is a teammate that follows it;
108 * undefined for a lead, and for a teammate told `/gateway on` or `off`.
109 */
110 leader: undefined as string | undefined,
111 /** The env block the lead last wrote, which a teammate wires with again on recovery. */
112 leadEnv: undefined as GatewayEnv | undefined,
113 /** The env block the last wiring set, for the lead file. */
114 env: undefined as GatewayEnv | undefined,
115 /** The lead file text last written, so that an unchanged mode is not rewritten. */
116 published: undefined as string | undefined,
117 /**
118 * The session start's setup, a teammate's wiring included. A teammate's
119 * first turn can begin before session.start settles, and turn.start does
120 * not hold a turn back, so each model request waits on this at turn.step.
121 */
122 starting: undefined as Promise<void> | undefined,
123}
124
125let ticker: { cancel: () => void } | undefined
126
127let transitions: Promise<unknown> = Promise.resolve()
128
129/**
130 * Runs `change` after every change queued before it. A check, a `/gateway`
131 * command and a teammate's following each read the state across awaits and
132 * then write it, so two at once would leave a `/gateway off` wired.
133 */
134function serially<T>(change: () => Promise<T>): Promise<T> {
135 const run = transitions.then(change, change)
136 transitions = run.catch(() => {})
137 return run
138}
139
140
141async function cliOf($: EngineInterface): Promise<string> {
142 return `${await $.env.get('HOME')}/.claude/model-gateway/model-gateway.js`
143}
144
145async function configDirOf($: EngineInterface): Promise<string> {
146 return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
147}
148
149/** This process's pid, the file name of the session's messaging socket. */
150async function pidOf($: EngineInterface): Promise<string | undefined> {
151 return (await $.env.get('CLAUDE_CODE_MESSAGING_SOCKET'))?.match(/\/(\d+)\.sock$/)?.[1]
152}
153
154/**
155 * Where a lead session says whether it routes through the gateway, for the
156 * teammates it spawns to follow.
157 */
158async function leadFileOf($: EngineInterface, sessionId: string): Promise<string> {
159 return `${await configDirOf($)}/gateway-switch/leads/${sessionId}.json`
160}
161
162/**
163 * The session id of the lead that spawned this process as a tmux teammate,
164 * read from its command line: Claude Code starts a teammate as `claude
165 * --agent-id <name@team> ... --parent-session-id <lead's session id>`.
166 *
167 * A teammate has to wire itself, because Claude Code builds a teammate's
168 * `env` prefix with every variable a person's plugin wrote set back to the
169 * value it had before the write, so the lead's gateway never reaches it.
170 */
171async function leaderOf($: EngineInterface): Promise<string | undefined> {
172 const pid = await pidOf($)
173 if (pid === undefined) return undefined
174 const run = await $.process.run(['ps', '-o', 'command=', '-p', pid], { timeoutMs: 5000 }).catch(() => undefined)
175 if (run === undefined || run.exitCode !== 0 || !/(?:^|\s)--agent-id[=\s]/.test(run.stdout)) return undefined
176 return /(?:^|\s)--parent-session-id[=\s]+([\w-]+)/.exec(run.stdout)?.[1]
177}
178
179/**
180 * What the lead last wrote: its mode, and the env block it wired, which
181 * lets a teammate wire without starting the gateway CLI twice. Undefined
182 * when it wrote none.
183 */
184async function leadOf($: EngineInterface, leader: string): Promise<{ mode: Mode; env?: GatewayEnv } | undefined> {
185 const text = await $.fs.read(await leadFileOf($, leader)).catch(() => undefined)
186 if (text === undefined) return undefined
187 try {
188 const parsed = JSON.parse(text) as { mode?: unknown; env?: unknown }
189 const mode = parsed.mode
190 if (mode !== 'waiting' && mode !== 'on' && mode !== 'off') return undefined
191 const block = stringsOf(parsed.env)
192 if (block.ANTHROPIC_BASE_URL === undefined) return { mode }
193 return { mode, env: { ...STATIC_ENV, ...block, ANTHROPIC_BASE_URL: block.ANTHROPIC_BASE_URL } }
194 } catch {
195 return undefined
196 }
197}
198
199/**
200 * Writes this lead's mode for its teammates. `on` covers a suspended gateway
201 * and an open `/gateway rc`, which each teammate's own watchdog handles.
202 *
203 * The session id is read each time: `/clear` gives the process a new one
204 * without a session.start, and teammates spawned after it name the new id.
205 * Every id the process has had keeps its file, for the teammates spawned
206 * under it; the file names the pid, so that a hot reload, which starts this
207 * module with fresh state, reads the ids back.
208 */
209async function publish($: EngineInterface): Promise<void> {
210 if (state.leader !== undefined) return
211 if (!state.hasLeadIds) {
212 state.leadIds = await ownLeadIdsOf($)
213 state.hasLeadIds = true
214 }
215 const id = await $.session.id()
216 if (!state.leadIds.includes(id)) {
217 state.leadIds.push(id)
218 state.published = undefined
219 }
220 const pid = await pidOf($)
221 const text = JSON.stringify({ mode: state.mode, gateway: state.gateway, env: state.env, pid: pid === undefined ? undefined : Number(pid) })
222 if (text === state.published) return
223 for (const leadId of state.leadIds) await $.fs.write(await leadFileOf($, leadId), text)
224 state.published = text
225}
226
227/** The session ids whose lead files this process wrote, read from the files. */
228async function ownLeadIdsOf($: EngineInterface): Promise<string[]> {
229 const pid = await pidOf($)
230 if (pid === undefined) return []
231 const dir = `${await configDirOf($)}/gateway-switch/leads`
232 const ids: string[] = []
233 for (const entry of await $.fs.list(dir).catch(() => [])) {
234 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
235 const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => undefined)
236 try {
237 if (text !== undefined && (JSON.parse(text) as { pid?: unknown }).pid === Number(pid)) ids.push(entry.name.slice(0, -'.json'.length))
238 } catch {
239 continue
240 }
241 }
242 return ids
243}
244
245async function publishOrLog($: EngineInterface): Promise<void> {
246 await publish($).catch((error: unknown) => $.ui.log(`gateway: could not write the lead file: ${String(error)}`, { to: 'debug' }))
247}
248
249/**
250 * Reads the env block from `model-gateway.js env`, which prints it as the
251 * one JSON object whose braces start a line, among lines of advice.
252 */
253function envBlockOf(stdout: string): Partial<GatewayEnv> | undefined {
254 const match = /^\{\n[\s\S]*?\n\}$/m.exec(stdout)
255 if (match === null) return undefined
256 try {
257 return stringsOf((JSON.parse(match[0]) as { env?: unknown }).env ?? {})
258 } catch {
259 return undefined
260 }
261}
262
263/** The string-valued entries of a parsed env object, the rest dropped. */
264function stringsOf(env: unknown): Partial<GatewayEnv> {
265 const block: Record<string, string> = {}
266 if (typeof env !== 'object' || env === null) return block
267 for (const [key, value] of Object.entries(env)) {
268 if (typeof value === 'string') block[key] = value
269 }
270 return block as Partial<GatewayEnv>
271}
272
273async function gatewayEnvOf($: EngineInterface): Promise<GatewayEnv> {
274 const run = await $.process.run(['node', await cliOf($), 'env'], { timeoutMs: 30_000 })
275 const block = run.exitCode === 0 ? envBlockOf(run.stdout) : undefined
276 if (block === undefined) $.ui.log('gateway: `model-gateway.js env` printed no env block; using the fixed values without pins', { to: 'debug' })
277 return {
278 ...STATIC_ENV,
279 ...block,
280 ANTHROPIC_BASE_URL: block?.ANTHROPIC_BASE_URL ?? DEFAULT_GATEWAY,
281 }
282}
283
284
285/**
286 * Asks the shim's health endpoint. `reachable` is whether anything answered
287 * in time; `ok` is the gateway's own verdict, false when only the proxy
288 * behind it is down, which still lets Claude models through. The sleep that
289 * bounds the wait is aborted once either side wins, so that no host wait
290 * outlives the check.
291 */
292async function probe($: EngineInterface, gateway: string): Promise<{ reachable: boolean; ok: boolean }> {
293 const answer = $.http.fetch(`${gateway}/healthz`).then(
294 (response) => {
295 try {
296 return { reachable: true, ok: response.ok && (JSON.parse(response.text) as { ok?: unknown }).ok === true }
297 } catch {
298 return { reachable: true, ok: false }
299 }
300 },
301 () => ({ reachable: false, ok: false }),
302 )
303 const timer = new AbortController()
304 const timeout = $.clock.sleep(PROBE_TIMEOUT_MS, { signal: timer.signal }).then(() => ({ reachable: false, ok: false }))
305 try {
306 return await Promise.race([answer, timeout])
307 } finally {
308 timer.abort()
309 timeout.catch(() => {})
310 }
311}
312
313async function ensureGateway($: EngineInterface): Promise<void> {
314 state.lastEnsureMs = await $.clock.now()
315 await $.process.run(['node', await cliOf($), 'ensure', '--quiet'], { timeoutMs: 60_000 })
316}
317
318async function snapshotOf($: EngineInterface): Promise<Snapshot> {
319 return {
320 ANTHROPIC_BASE_URL: await $.env.get('ANTHROPIC_BASE_URL'),
321 CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: await $.env.get('CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'),
322 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: await $.env.get('CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK'),
323 ENABLE_TOOL_SEARCH: await $.env.get('ENABLE_TOOL_SEARCH'),
324 CLAUDE_CODE_MAX_OUTPUT_TOKENS: await $.env.get('CLAUDE_CODE_MAX_OUTPUT_TOKENS'),
325 ANTHROPIC_DEFAULT_OPUS_MODEL: await $.env.get('ANTHROPIC_DEFAULT_OPUS_MODEL'),
326 ANTHROPIC_DEFAULT_SONNET_MODEL: await $.env.get('ANTHROPIC_DEFAULT_SONNET_MODEL'),
327 ANTHROPIC_DEFAULT_FABLE_MODEL: await $.env.get('ANTHROPIC_DEFAULT_FABLE_MODEL'),
328 }
329}
330
331/**
332 * Sets the variables of `env` in order and stops at the first write Claude
333 * Code holds, returning why; a session whose organization manages its
334 * settings holds a variable the person could not set there. The base URL
335 * goes last so that no request reaches the gateway before its pins and
336 * switches are in place, nor at all when one of them is held.
337 */
338async function applyEnv($: EngineInterface, env: GatewayEnv | Snapshot): Promise<string | undefined> {
339 const writes = [
340 () => $.env.set('CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY', env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY),
341 () => $.env.set('CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK', env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK),
342 () => $.env.set('ENABLE_TOOL_SEARCH', env.ENABLE_TOOL_SEARCH),
343 () => $.env.set('CLAUDE_CODE_MAX_OUTPUT_TOKENS', env.CLAUDE_CODE_MAX_OUTPUT_TOKENS),
344 ]
345 if ('ANTHROPIC_DEFAULT_OPUS_MODEL' in env) writes.push(() => $.env.set('ANTHROPIC_DEFAULT_OPUS_MODEL', env.ANTHROPIC_DEFAULT_OPUS_MODEL))
346 if ('ANTHROPIC_DEFAULT_SONNET_MODEL' in env) writes.push(() => $.env.set('ANTHROPIC_DEFAULT_SONNET_MODEL', env.ANTHROPIC_DEFAULT_SONNET_MODEL))
347 if ('ANTHROPIC_DEFAULT_FABLE_MODEL' in env) writes.push(() => $.env.set('ANTHROPIC_DEFAULT_FABLE_MODEL', env.ANTHROPIC_DEFAULT_FABLE_MODEL))
348 writes.push(() => $.env.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL))
349 for (const write of writes) {
350 const { deny } = await write()
351 if (deny !== undefined) return deny
352 }
353 return undefined
354}
355
356/**
357 * Points the session at the gateway once its shim answers; leaves the
358 * session as it was and says why when it does not. A held write turns the
359 * gateway off, since checking again would only meet the same hold.
360 *
361 * `given` is the env block a lead wired, which a teammate takes in place of
362 * asking the gateway CLI: the lead already started the gateway.
363 */
364async function wire($: EngineInterface, given?: GatewayEnv): Promise<string> {
365 if (state.wired) return `requests already go through ${state.gateway}`
366 if (given === undefined) await ensureGateway($)
367 const env = given ?? (await gatewayEnvOf($))
368 const health = await probe($, env.ANTHROPIC_BASE_URL)
369 state.gateway = env.ANTHROPIC_BASE_URL
370 if (!health.reachable) return `the gateway at ${env.ANTHROPIC_BASE_URL} is not answering; requests stay on api.anthropic.com`
371 state.saved ??= await snapshotOf($)
372 const held = await applyEnv($, env)
373 if (held !== undefined) {
374 await applyEnv($, state.saved)
375 state.saved = undefined
376 state.mode = 'off'
377 state.leader = undefined
378 return `Claude Code held a gateway variable (${held}); requests stay on api.anthropic.com`
379 }
380 state.gateway = env.ANTHROPIC_BASE_URL
381 state.env = env
382 state.wired = true
383 state.failures = 0
384 state.successes = 0
385 state.isDegraded = !health.ok
386 const pins = [env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_DEFAULT_SONNET_MODEL, env.ANTHROPIC_DEFAULT_FABLE_MODEL].filter(Boolean)
387 const note = health.ok ? '' : ' (the gateway reports a problem behind it; gateway models may fail)'
388 return `requests now go through ${state.gateway}, pins ${pins.length > 0 ? pins.join(', ') : 'unchanged'}${note}`
389}
390
391/**
392 * Restores what the session had before wiring. Without a snapshot (the
393 * module reloaded while wired) only the base URL is cleared.
394 */
395async function unwire($: EngineInterface): Promise<string> {
396 if (!state.wired) return 'requests already go to api.anthropic.com'
397 if (state.saved === undefined) await $.env.set('ANTHROPIC_BASE_URL', undefined)
398 else await applyEnv($, state.saved)
399 state.wired = false
400 state.saved = undefined
401 state.failures = 0
402 state.successes = 0
403 return 'requests now go to api.anthropic.com'
404}
405
406/**
407 * Finds the projects folder that holds this session's transcript, which is
408 * also where Remote Control keeps its bridge-pointer.json.
409 */
410async function projectDirOf($: EngineInterface): Promise<string | undefined> {
411 const projects = `${await configDirOf($)}/projects`
412 const id = await $.session.id()
413 for (const entry of await $.fs.list(projects)) {
414 if (entry.kind === 'dir' && (await $.fs.exists(`${projects}/${entry.name}/${id}.jsonl`))) {
415 return `${projects}/${entry.name}`
416 }
417 }
418 return undefined
419}
420
421/**
422 * True once this process registered a Remote Control environment: the bridge
423 * writes bridge-pointer.json after registration, naming the pid that owns it,
424 * and the pid is the file name of this session's messaging socket.
425 */
426async function isBridgeRegistered($: EngineInterface, projectDir: string): Promise<boolean> {
427 const pid = await pidOf($)
428 if (pid === undefined) return false
429 const text = await $.fs.read(`${projectDir}/bridge-pointer.json`).catch(() => undefined)
430 if (text === undefined) return false
431 try {
432 return (JSON.parse(text) as { pid?: unknown }).pid === Number(pid)
433 } catch {
434 return false
435 }
436}
437
438/**
439 * Moves a teammate with its lead: on when the lead is on, back to
440 * api.anthropic.com when the lead was turned off. A lead still waiting for
441 * Remote Control has turned nothing on or off, so a teammate wired some
442 * other way (the tmux environment) is left as it is; so is every teammate
443 * of a lead that wrote no file (an older version of this mod). Returns
444 * whether it moved.
445 */
446async function follow($: EngineInterface, leader: string): Promise<boolean> {
447 const lead = await leadOf($, leader)
448 // A `/gateway on` or `off` while the file was read ended the following.
449 if (lead === undefined || state.leader !== leader) return false
450 state.leadEnv = lead.env
451 if (lead.mode === 'on' && state.mode !== 'on') {
452 state.mode = 'on'
453 $.ui.toast(`gateway: the lead routes through the gateway; ${await wire($, lead.env)}`)
454 return true
455 }
456 if (lead.mode === 'off' && state.mode === 'on') {
457 state.mode = 'waiting'
458 $.ui.toast(`gateway: the lead turned the gateway off; ${await unwire($)}`)
459 return true
460 }
461 return false
462}
463
464/**
465 * One check: a teammate follows its lead, and a lead starts the gateway once
466 * Remote Control registers; either leaves it after FAILURES_TO_UNWIRE
467 * unreachable probes and returns after SUCCESSES_TO_REWIRE reachable ones.
468 * Nothing moves while `/gateway rc` holds the gateway back.
469 */
470async function tick($: EngineInterface): Promise<void> {
471 if (state.rcHold) return
472 if (state.leader !== undefined) {
473 if (await follow($, state.leader)) return
474 if (state.mode !== 'on') return
475 }
476 if (state.mode === 'off') return
477 if (state.mode === 'waiting') {
478 if (state.projectDir === undefined || !(await isBridgeRegistered($, state.projectDir))) return
479 state.mode = 'on'
480 $.ui.toast(`gateway: Remote Control registered; ${await wire($)}`)
481 return
482 }
483 const health = await probe($, state.gateway)
484 if (state.wired) {
485 if (health.reachable) {
486 state.failures = 0
487 if (!health.ok && !state.isDegraded) $.ui.toast('gateway: the gateway reports a problem behind it; gateway models may fail')
488 state.isDegraded = !health.ok
489 return
490 }
491 state.failures += 1
492 if (state.failures < FAILURES_TO_UNWIRE) return
493 $.ui.toast(`gateway: ${state.gateway} stopped answering; ${await unwire($)}`)
494 return
495 }
496 if (!health.reachable) {
497 state.successes = 0
498 if ((await $.clock.now()) - state.lastEnsureMs >= ENSURE_INTERVAL_MS) await ensureGateway($)
499 return
500 }
501 state.successes += 1
502 if (state.successes < SUCCESSES_TO_REWIRE) return
503 $.ui.toast(`gateway: the gateway answers again; ${await wire($, state.leader === undefined ? undefined : state.leadEnv)}`)
504}
505
506/**
507 * Opens Claude Code's own Remote Control dialog from a wired session.
508 *
509 * `/remote-control` is enabled only while `ANTHROPIC_BASE_URL` points at
510 * api.anthropic.com, and the command list is rebuilt only when plugins
511 * reload, so a session wired after it was built keeps a stale entry that
512 * `Unknown command` answers. The gateway is unwired, the list rebuilt with
513 * `/reload-plugins`, the dialog run, and the gateway wired again once the
514 * dialog closes, unless the person turned it off meanwhile.
515 */
516async function remoteControlThroughFirstParty($: EngineInterface): Promise<void> {
517 const wasWired = state.wired
518 state.rcHold = true
519 try {
520 if (wasWired) await serially(() => unwire($))
521 await $.command.run({ command: 'reload-plugins' })
522 const listed = (await $.command.list()).some((command) => command.name === 'remote-control')
523 if (!listed) {
524 $.ui.toast('gateway: /remote-control is still unavailable; see `claude --debug` for why Remote Control is disabled')
525 return
526 }
527 await $.command.run({ command: 'remote-control' })
528 } catch (error) {
529 $.ui.toast(`gateway: /remote-control failed: ${String(error)}`)
530 } finally {
531 await serially(async () => {
532 state.rcHold = false
533 if (wasWired && state.mode === 'on') $.ui.toast(`gateway: ${await wire($, state.leader === undefined ? undefined : state.leadEnv)}`)
534 })
535 }
536}
537
538function statusOf(): string {
539 if (state.mode === 'off') return 'off: requests go to api.anthropic.com until `/gateway on`'
540 if (state.mode === 'waiting' && state.leader !== undefined) return `following the lead session ${state.leader}, which has not turned the gateway on; \`/gateway on\` switches this teammate alone`
541 if (state.mode === 'waiting') return 'waiting for Remote Control to register; `/gateway on` switches now'
542 if (state.rcHold) return 'paused for /remote-control; the gateway returns when the dialog closes'
543 if (state.wired) return `on: requests go through ${state.gateway}${state.isDegraded ? ' (degraded)' : ''}`
544 return `suspended: ${state.gateway} is not answering; requests go to api.anthropic.com until it does`
545}
546
547/**
548 * Turns the gateway on as the session starts, for the hook `/gateway setup`
549 * wrote. A teammate keeps following its lead; a session already on, or
550 * already pointed at another endpoint, is left as it is.
551 */
552async function switchOnAtStart($: EngineInterface): Promise<void> {
553 await serially(async () => {
554 if (state.leader !== undefined || state.mode !== 'waiting') return
555 state.mode = 'on'
556 $.ui.toast(`gateway: on from session start; ${await wire($)}`)
557 await publishOrLog($)
558 }).catch((error: unknown) => $.ui.toast(`gateway: could not turn on at session start: ${String(error)}`))
559}
560
561function isObject(value: unknown): value is Record<string, unknown> {
562 return typeof value === 'object' && value !== null && !Array.isArray(value)
563}
564
565async function localSettingsPathOf($: EngineInterface): Promise<string> {
566 return `${await $.session.root()}/.claude/settings.local.json`
567}
568
569/** The hooks of a settings file's `hooks.SessionStart` groups, those not objects left out. */
570function startHooksOf(starts: unknown[]): Record<string, unknown>[] {
571 return starts.flatMap((group) => (isObject(group) && Array.isArray(group.hooks) ? group.hooks.filter(isObject) : []))
572}
573
574/**
575 * Which marks of `/gateway setup` the project's settings.local.json holds:
576 * AUTO_ON_HOOK's command, and the one earlier versions wrote. Neither when
577 * the file is missing, is not JSON, or has no `hooks.SessionStart` list.
578 */
579async function autoOnMarksOf($: EngineInterface): Promise<{ isCurrent: boolean; isLegacy: boolean }> {
580 const none = { isCurrent: false, isLegacy: false }
581 const text = await $.fs.read(await localSettingsPathOf($)).catch(() => undefined)
582 if (text === undefined) return none
583 let settings: unknown
584 try {
585 settings = JSON.parse(text)
586 } catch {
587 return none
588 }
589 const starts = isObject(settings) && isObject(settings.hooks) ? settings.hooks.SessionStart : undefined
590 if (!Array.isArray(starts)) return none
591 const commands = startHooksOf(starts).map((hook) => hook.command)
592 return { isCurrent: commands.includes(AUTO_ON_COMMAND), isLegacy: commands.includes(LEGACY_AUTO_ON_COMMAND) }
593}
594
595/**
596 * Turns a lead on as it starts when its project holds the mark `/gateway
597 * setup` writes, and asks for the earlier mark to be replaced.
598 */
599async function switchOnIfMarked($: EngineInterface): Promise<void> {
600 const marks = await autoOnMarksOf($).catch((error: unknown) => {
601 $.ui.toast(`gateway: could not read whether this project turns the gateway on at start: ${String(error)}`)
602 return { isCurrent: false, isLegacy: false }
603 })
604 if (!marks.isCurrent && !marks.isLegacy) return
605 if (marks.isLegacy && state.leader === undefined) {
606 $.ui.toast('gateway: this project\'s settings.local.json holds the SessionStart hook an earlier `/gateway setup` wrote, which puts `/gateway on` before a `-p` prompt; `/gateway setup` replaces it')
607 }
608 await switchOnAtStart($)
609}
610
611/**
612 * Adds AUTO_ON_HOOK to the project's `.claude/settings.local.json`, keeping
613 * everything the file already holds, and removes the hook earlier versions
614 * wrote, and any group that removal leaves empty. A file that is not a JSON object, or
615 * whose `hooks.SessionStart` is not a list, is left alone.
616 */
617async function writeAutoOnHook($: EngineInterface): Promise<string> {
618 const path = await localSettingsPathOf($)
619 let settings: unknown = {}
620 if (await $.fs.exists(path)) {
621 let text: string
622 try {
623 text = await $.fs.read(path)
624 } catch (error) {
625 return `could not read ${path} (${String(error)}); it is unchanged`
626 }
627 try {
628 settings = JSON.parse(text)
629 } catch (error) {
630 return `${path} is not JSON (${String(error)}); it is unchanged`
631 }
632 }
633 const hooks = isObject(settings) ? (settings.hooks ?? {}) : undefined
634 const starts = isObject(hooks) ? (hooks.SessionStart ?? []) : undefined
635 if (!isObject(settings) || !isObject(hooks) || !Array.isArray(starts)) return `${path} does not hold a settings object with a hooks.SessionStart list; it is unchanged`
636 const commands = startHooksOf(starts).map((hook) => hook.command)
637 const isCurrent = commands.includes(AUTO_ON_COMMAND)
638 const isLegacy = commands.includes(LEGACY_AUTO_ON_COMMAND)
639 if (isCurrent && !isLegacy) return `${path} already turns the gateway on as a session starts`
640 const kept = starts.flatMap((group) => {
641 if (!isObject(group) || !Array.isArray(group.hooks)) return [group]
642 const groupHooks = group.hooks.filter((hook) => !isObject(hook) || hook.command !== LEGACY_AUTO_ON_COMMAND)
643 if (groupHooks.length === group.hooks.length) return [group]
644 return groupHooks.length === 0 ? [] : [{ ...group, hooks: groupHooks }]
645 })
646 if (!isCurrent) kept.push(AUTO_ON_HOOK)
647 hooks.SessionStart = kept
648 settings.hooks = hooks
649 try {
650 await $.fs.write(path, `${JSON.stringify(settings, null, 2)}\n`)
651 } catch (error) {
652 return `could not write ${path} (${String(error)})`
653 }
654 if (isLegacy) return `replaced the SessionStart hook an earlier \`/gateway setup\` wrote in ${path} with one that sends no prompt; sessions started, resumed or forked here still turn the gateway on at once`
655 return `added a SessionStart hook to ${path}: sessions started, resumed or forked here from now on turn the gateway on at once, without waiting for Remote Control, which \`/gateway rc\` then opens. \`/gateway on\` switches this session.`
656}
657
658/**
659 * Registers `/gateway`, finds out whether this session is a lead or a
660 * teammate, wires a teammate whose lead is on, and starts the check.
661 */
662async function setUp($: EngineInterface): Promise<void> {
663 await $.command.register({
664 name: 'gateway',
665 description: 'Routes requests through the local model gateway; `rc` opens Remote Control from a wired session; `setup` turns it on as every session here starts.',
666 argumentHint: 'on|off|rc|setup|status',
667 immediate: true,
668 })
669 state.leader = await leaderOf($)
670 const current = await $.env.get('ANTHROPIC_BASE_URL')
671 // What model-gateway would wire, read without starting anything, so that
672 // a session already wired through settings is recognized as such.
673 const env = current === undefined ? undefined : await gatewayEnvOf($).catch(() => undefined)
674 if (current !== undefined && current === (env?.ANTHROPIC_BASE_URL ?? DEFAULT_GATEWAY)) {
675 state.mode = 'on'
676 state.wired = true
677 state.gateway = current
678 state.env = env
679 } else if (current !== undefined) {
680 state.mode = 'off'
681 state.leader = undefined
682 $.ui.log(`gateway: ANTHROPIC_BASE_URL is already ${current}; this session is left alone`, { to: 'debug' })
683 }
684 const leader = state.leader
685 if (leader !== undefined) {
686 await serially(() => follow($, leader))
687 } else {
688 state.projectDir = await projectDirOf($)
689 await publishOrLog($)
690 }
691 ticker?.cancel()
692 ticker = $.clock.every(TICK_MS, () => {
693 if (state.isBusy) return
694 state.isBusy = true
695 serially(async () => {
696 await tick($)
697 await publishOrLog($)
698 })
699 .catch((error: unknown) => $.ui.log(`gateway: check failed: ${String(error)}`, { to: 'debug' }))
700 .finally(() => {
701 state.isBusy = false
702 })
703 })
704}
705
706export const register: Register = (on) => {
707 on('session.start', async ($, e, next) => {
708 // The switch at start runs after setUp and is not awaited here, so that
709 // session.start is not held while the gateway CLI runs; each model
710 // request waits for it at turn.step.
711 const setUpRun = setUp($)
712 state.starting = setUpRun.then(() => switchOnIfMarked($))
713 state.starting.catch(() => {})
714 await setUpRun
715 return next(e)
716 })
717
718 on('agent.spawn', async ($, e, next) => {
719 // A teammate reads the lead file as it starts, so it must name this
720 // session's current id and mode before the teammate's process exists.
721 if (e.isTeammate === true) await serially(() => publishOrLog($))
722 return next(e)
723 })
724
725 on('turn.step', async function* ($, e, next) {
726 await state.starting?.catch(() => {})
727 return yield* next(e)
728 })
729
730 on('command.run', { command: 'gateway' }, async ($, e) => {
731 const arg = e.args.trim()
732 if (arg === 'off' || arg === 'on') {
733 // The intent is recorded at once, so that a change already queued
734 // (a teammate's following, a check) sees it and stands down.
735 state.leader = undefined
736 const text = await serially(async () => {
737 state.mode = arg
738 const text = arg === 'off' ? await unwire($) : await wire($)
739 await publishOrLog($)
740 return text
741 })
742 return { text }
743 }
744 if (arg === 'setup') return { text: await writeAutoOnHook($) }
745 if (arg === 'rc') {
746 if (state.rcHold) return { text: '/remote-control is already open' }
747 $.clock.after(0, () => {
748 void remoteControlThroughFirstParty($)
749 })
750 return { text: state.wired ? 'opening /remote-control with the gateway paused' : 'opening /remote-control' }
751 }
752 return { text: statusOf() }
753 })
754}
755