Starts a session on api.anthropic.com so that Remote Control can register, then points it at the local Model Gateway once Remote Control has registered or on…

A Claude Code mod that keeps a session on api.anthropic.com until Remote Control has registered, then routes it through a local Model Gateway, and routes it 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/model-gateway-rc.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.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 | | none, or status | Says whether the session is waiting, on, suspended or off |
/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./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/model-gateway-rc 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 380 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 fixed part of model-gateway's env block: always the base layer, which
30 * the block `env` prints overrides key by key.
31 */
32const STATIC_ENV = {
33 CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
34 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
35 ENABLE_TOOL_SEARCH: 'true',
36 CLAUDE_CODE_MAX_OUTPUT_TOKENS: '64000',
37} as const
38
39/**
40 * Every variable model-gateway wires. The pins are absent when the gateway's
41 * `env` printed none, and are then left as the session had them.
42 */
43type GatewayEnv = {
44 ANTHROPIC_BASE_URL: string
45 CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: string
46 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: string
47 ENABLE_TOOL_SEARCH: string
48 CLAUDE_CODE_MAX_OUTPUT_TOKENS: string
49 ANTHROPIC_DEFAULT_OPUS_MODEL?: string
50 ANTHROPIC_DEFAULT_SONNET_MODEL?: string
51 ANTHROPIC_DEFAULT_FABLE_MODEL?: string
52}
53
54/** The values the session had before wiring, restored by `unwire`. */
55type Snapshot = Record<keyof GatewayEnv, string | undefined>
56
57/**
58 * `waiting`: Remote Control has not registered yet. `on`: the gateway is
59 * wanted, wired or suspended after it stopped answering. `off`: the user
60 * turned it off, or the session was already pointed elsewhere.
61 */
62type Mode = 'waiting' | 'on' | 'off'
63
64const state = {
65 mode: 'waiting' as Mode,
66 wired: false,
67 gateway: DEFAULT_GATEWAY,
68 saved: undefined as Snapshot | undefined,
69 failures: 0,
70 successes: 0,
71 lastEnsureMs: 0,
72 isDegraded: false,
73 isBusy: false,
74 projectDir: undefined as string | undefined,
75 rcHold: false,
76}
77
78let ticker: { cancel: () => void } | undefined
79
80async function cliOf($: EngineInterface): Promise<string> {
81 return `${await $.env.get('HOME')}/.claude/model-gateway/model-gateway.js`
82}
83
84/**
85 * Reads the env block from `model-gateway.js env`, which prints it as the
86 * one JSON object whose braces start a line, among lines of advice.
87 */
88function envBlockOf(stdout: string): Partial<GatewayEnv> | undefined {
89 const match = /^\{\n[\s\S]*?\n\}$/m.exec(stdout)
90 if (match === null) return undefined
91 try {
92 const parsed = JSON.parse(match[0]) as { env?: Record<string, unknown> }
93 const env = parsed.env ?? {}
94 const block: Record<string, string> = {}
95 for (const [key, value] of Object.entries(env)) {
96 if (typeof value === 'string') block[key] = value
97 }
98 return block as Partial<GatewayEnv>
99 } catch {
100 return undefined
101 }
102}
103
104async function gatewayEnvOf($: EngineInterface): Promise<GatewayEnv> {
105 const run = await $.process.run(['node', await cliOf($), 'env'], { timeoutMs: 30_000 })
106 const block = run.exitCode === 0 ? envBlockOf(run.stdout) : undefined
107 if (block === undefined) $.ui.log('gateway: `model-gateway.js env` printed no env block; using the fixed values without pins', { to: 'debug' })
108 return {
109 ...STATIC_ENV,
110 ...block,
111 ANTHROPIC_BASE_URL: block?.ANTHROPIC_BASE_URL ?? DEFAULT_GATEWAY,
112 }
113}
114
115/**
116 * The gateway URL model-gateway would wire, read without starting anything,
117 * so that a session already wired through settings is recognized as such.
118 */
119async function gatewayUrlOf($: EngineInterface): Promise<string> {
120 return gatewayEnvOf($).then((env) => env.ANTHROPIC_BASE_URL, () => DEFAULT_GATEWAY)
121}
122
123/**
124 * Asks the shim's health endpoint. `reachable` is whether anything answered
125 * in time; `ok` is the gateway's own verdict, false when only the proxy
126 * behind it is down, which still lets Claude models through. The sleep that
127 * bounds the wait is aborted once either side wins, so that no host wait
128 * outlives the check.
129 */
130async function probe($: EngineInterface, gateway: string): Promise<{ reachable: boolean; ok: boolean }> {
131 const answer = $.http.fetch(`${gateway}/healthz`).then(
132 (response) => {
133 try {
134 return { reachable: true, ok: response.ok && (JSON.parse(response.text) as { ok?: unknown }).ok === true }
135 } catch {
136 return { reachable: true, ok: false }
137 }
138 },
139 () => ({ reachable: false, ok: false }),
140 )
141 const timer = new AbortController()
142 const timeout = $.clock.sleep(PROBE_TIMEOUT_MS, { signal: timer.signal }).then(() => ({ reachable: false, ok: false }))
143 try {
144 return await Promise.race([answer, timeout])
145 } finally {
146 timer.abort()
147 timeout.catch(() => {})
148 }
149}
150
151async function ensureGateway($: EngineInterface): Promise<void> {
152 state.lastEnsureMs = await $.clock.now()
153 await $.process.run(['node', await cliOf($), 'ensure', '--quiet'], { timeoutMs: 60_000 })
154}
155
156async function snapshotOf($: EngineInterface): Promise<Snapshot> {
157 return {
158 ANTHROPIC_BASE_URL: await $.env.get('ANTHROPIC_BASE_URL'),
159 CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: await $.env.get('CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'),
160 CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: await $.env.get('CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK'),
161 ENABLE_TOOL_SEARCH: await $.env.get('ENABLE_TOOL_SEARCH'),
162 CLAUDE_CODE_MAX_OUTPUT_TOKENS: await $.env.get('CLAUDE_CODE_MAX_OUTPUT_TOKENS'),
163 ANTHROPIC_DEFAULT_OPUS_MODEL: await $.env.get('ANTHROPIC_DEFAULT_OPUS_MODEL'),
164 ANTHROPIC_DEFAULT_SONNET_MODEL: await $.env.get('ANTHROPIC_DEFAULT_SONNET_MODEL'),
165 ANTHROPIC_DEFAULT_FABLE_MODEL: await $.env.get('ANTHROPIC_DEFAULT_FABLE_MODEL'),
166 }
167}
168
169/**
170 * Sets every variable of `env`. The base URL goes last so that no request
171 * reaches the gateway before its pins and switches are in place.
172 */
173async function applyEnv($: EngineInterface, env: GatewayEnv | Snapshot): Promise<void> {
174 await $.env.set('CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY', env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY)
175 await $.env.set('CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK', env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK)
176 await $.env.set('ENABLE_TOOL_SEARCH', env.ENABLE_TOOL_SEARCH)
177 await $.env.set('CLAUDE_CODE_MAX_OUTPUT_TOKENS', env.CLAUDE_CODE_MAX_OUTPUT_TOKENS)
178 if ('ANTHROPIC_DEFAULT_OPUS_MODEL' in env) await $.env.set('ANTHROPIC_DEFAULT_OPUS_MODEL', env.ANTHROPIC_DEFAULT_OPUS_MODEL)
179 if ('ANTHROPIC_DEFAULT_SONNET_MODEL' in env) await $.env.set('ANTHROPIC_DEFAULT_SONNET_MODEL', env.ANTHROPIC_DEFAULT_SONNET_MODEL)
180 if ('ANTHROPIC_DEFAULT_FABLE_MODEL' in env) await $.env.set('ANTHROPIC_DEFAULT_FABLE_MODEL', env.ANTHROPIC_DEFAULT_FABLE_MODEL)
181 await $.env.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL)
182}
183
184/**
185 * Points the session at the gateway once its shim answers; leaves the
186 * session as it was and says why when it does not.
187 */
188async function wire($: EngineInterface): Promise<string> {
189 if (state.wired) return `requests already go through ${state.gateway}`
190 await ensureGateway($)
191 const env = await gatewayEnvOf($)
192 const health = await probe($, env.ANTHROPIC_BASE_URL)
193 if (!health.reachable) return `the gateway at ${env.ANTHROPIC_BASE_URL} is not answering; requests stay on api.anthropic.com`
194 state.saved ??= await snapshotOf($)
195 await applyEnv($, env)
196 state.gateway = env.ANTHROPIC_BASE_URL
197 state.wired = true
198 state.failures = 0
199 state.successes = 0
200 state.isDegraded = !health.ok
201 const pins = [env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_DEFAULT_SONNET_MODEL, env.ANTHROPIC_DEFAULT_FABLE_MODEL].filter(Boolean)
202 const note = health.ok ? '' : ' (the gateway reports a problem behind it; gateway models may fail)'
203 return `requests now go through ${state.gateway}, pins ${pins.length > 0 ? pins.join(', ') : 'unchanged'}${note}`
204}
205
206/**
207 * Restores what the session had before wiring. Without a snapshot (the
208 * module reloaded while wired) only the base URL is cleared.
209 */
210async function unwire($: EngineInterface): Promise<string> {
211 if (!state.wired) return 'requests already go to api.anthropic.com'
212 if (state.saved === undefined) await $.env.set('ANTHROPIC_BASE_URL', undefined)
213 else await applyEnv($, state.saved)
214 state.wired = false
215 state.saved = undefined
216 state.failures = 0
217 state.successes = 0
218 return 'requests now go to api.anthropic.com'
219}
220
221/**
222 * Finds the projects folder that holds this session's transcript, which is
223 * also where Remote Control keeps its bridge-pointer.json.
224 */
225async function projectDirOf($: EngineInterface): Promise<string | undefined> {
226 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
227 const projects = `${configDir}/projects`
228 const id = await $.session.id()
229 for (const entry of await $.fs.list(projects)) {
230 if (entry.kind === 'dir' && (await $.fs.exists(`${projects}/${entry.name}/${id}.jsonl`))) {
231 return `${projects}/${entry.name}`
232 }
233 }
234 return undefined
235}
236
237/**
238 * True once this process registered a Remote Control environment: the bridge
239 * writes bridge-pointer.json after registration, naming the pid that owns it,
240 * and the pid is the file name of this session's messaging socket.
241 */
242async function isBridgeRegistered($: EngineInterface, projectDir: string): Promise<boolean> {
243 const socket = await $.env.get('CLAUDE_CODE_MESSAGING_SOCKET')
244 const pid = socket?.match(/\/(\d+)\.sock$/)?.[1]
245 if (pid === undefined) return false
246 const text = await $.fs.read(`${projectDir}/bridge-pointer.json`).catch(() => undefined)
247 if (text === undefined) return false
248 try {
249 return (JSON.parse(text) as { pid?: unknown }).pid === Number(pid)
250 } catch {
251 return false
252 }
253}
254
255/**
256 * One check: start the gateway once Remote Control registers, leave it after
257 * FAILURES_TO_UNWIRE unreachable probes, return after SUCCESSES_TO_REWIRE
258 * reachable ones. Nothing moves while `/gateway rc` holds the gateway back.
259 */
260async function tick($: EngineInterface): Promise<void> {
261 if (state.mode === 'off' || state.rcHold) return
262 if (state.mode === 'waiting') {
263 if (state.projectDir === undefined || !(await isBridgeRegistered($, state.projectDir))) return
264 state.mode = 'on'
265 $.ui.toast(`gateway: Remote Control registered; ${await wire($)}`)
266 return
267 }
268 const health = await probe($, state.gateway)
269 if (state.wired) {
270 if (health.reachable) {
271 state.failures = 0
272 if (!health.ok && !state.isDegraded) $.ui.toast('gateway: the gateway reports a problem behind it; gateway models may fail')
273 state.isDegraded = !health.ok
274 return
275 }
276 state.failures += 1
277 if (state.failures < FAILURES_TO_UNWIRE) return
278 $.ui.toast(`gateway: ${state.gateway} stopped answering; ${await unwire($)}`)
279 return
280 }
281 if (!health.reachable) {
282 state.successes = 0
283 if ((await $.clock.now()) - state.lastEnsureMs >= ENSURE_INTERVAL_MS) await ensureGateway($)
284 return
285 }
286 state.successes += 1
287 if (state.successes < SUCCESSES_TO_REWIRE) return
288 $.ui.toast(`gateway: the gateway answers again; ${await wire($)}`)
289}
290
291/**
292 * Opens Claude Code's own Remote Control dialog from a wired session.
293 *
294 * `/remote-control` is enabled only while `ANTHROPIC_BASE_URL` points at
295 * api.anthropic.com, and the command list is rebuilt only when plugins
296 * reload, so a session wired after it was built keeps a stale entry that
297 * `Unknown command` answers. The gateway is unwired, the list rebuilt with
298 * `/reload-plugins`, the dialog run, and the gateway wired again once the
299 * dialog closes, unless the person turned it off meanwhile.
300 */
301async function remoteControlThroughFirstParty($: EngineInterface): Promise<void> {
302 const wasWired = state.wired
303 state.rcHold = true
304 try {
305 if (wasWired) await unwire($)
306 await $.command.run({ command: 'reload-plugins' })
307 const listed = (await $.command.list()).some((command) => command.name === 'remote-control')
308 if (!listed) {
309 $.ui.toast('gateway: /remote-control is still unavailable; see `claude --debug` for why Remote Control is disabled')
310 return
311 }
312 await $.command.run({ command: 'remote-control' })
313 } catch (error) {
314 $.ui.toast(`gateway: /remote-control failed: ${String(error)}`)
315 } finally {
316 state.rcHold = false
317 if (wasWired && state.mode === 'on') $.ui.toast(`gateway: ${await wire($)}`)
318 }
319}
320
321function statusOf(): string {
322 if (state.mode === 'off') return 'off: requests go to api.anthropic.com until `/gateway on`'
323 if (state.mode === 'waiting') return 'waiting for Remote Control to register; `/gateway on` switches now'
324 if (state.rcHold) return 'paused for /remote-control; the gateway returns when the dialog closes'
325 if (state.wired) return `on: requests go through ${state.gateway}${state.isDegraded ? ' (degraded)' : ''}`
326 return `suspended: ${state.gateway} is not answering; requests go to api.anthropic.com until it does`
327}
328
329export const register: Register = (on) => {
330 on('session.start', async ($, e, next) => {
331 await $.command.register({
332 name: 'gateway',
333 description: 'Routes requests through the local model gateway; `rc` opens Remote Control from a wired session.',
334 argumentHint: 'on|off|rc|status',
335 immediate: true,
336 })
337 const current = await $.env.get('ANTHROPIC_BASE_URL')
338 if (current !== undefined && current === (await gatewayUrlOf($))) {
339 state.mode = 'on'
340 state.wired = true
341 state.gateway = current
342 } else if (current !== undefined) {
343 state.mode = 'off'
344 $.ui.log(`gateway: ANTHROPIC_BASE_URL is already ${current}; this session is left alone`, { to: 'debug' })
345 }
346 state.projectDir = await projectDirOf($)
347 ticker?.cancel()
348 ticker = $.clock.every(TICK_MS, () => {
349 if (state.isBusy) return
350 state.isBusy = true
351 tick($)
352 .catch((error: unknown) => $.ui.log(`gateway: check failed: ${String(error)}`, { to: 'debug' }))
353 .finally(() => {
354 state.isBusy = false
355 })
356 })
357 return next(e)
358 })
359
360 on('command.run', { command: 'gateway' }, async ($, e) => {
361 const arg = e.args.trim()
362 if (arg === 'off') {
363 state.mode = 'off'
364 return { text: await unwire($) }
365 }
366 if (arg === 'on') {
367 state.mode = 'on'
368 return { text: await wire($) }
369 }
370 if (arg === 'rc') {
371 if (state.rcHold) return { text: '/remote-control is already open' }
372 $.clock.after(0, () => {
373 void remoteControlThroughFirstParty($)
374 })
375 return { text: state.wired ? 'opening /remote-control with the gateway paused' : 'opening /remote-control' }
376 }
377 return { text: statusOf() }
378 })
379}
380