SLOPSHOPPER

model-gateway-rc

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…

newcommandtoastprocessnetworktimer
★ 1v0.1.0Apache-2.0updated 2026-10-09zchee/claude-code-mods/mods/model-gateway-rc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-gateway-rc
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gateway ⎿ model-gateway-rc: waiting for Remote Control to register; `/gateway on` switches now ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

model-gateway-rc

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.

Setup

  1. Install and set up the model-gateway plugin as its README says.
  2. Leave 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.
  3. Load this mod, for example claude --plugin-dir mods/model-gateway-rc.

What it does

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.

  • Remote Control detection. After Remote Control registers an environment, Claude Code writes 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.
  • Wiring. It runs 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.
  • Health watchdog. Two checks in a row that get no answer within three seconds restore the remembered values, which sends requests back to 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 session

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

Limits

  • Remote Control detection by 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.
  • A request already retrying when the gateway goes down may keep retrying against it; requests that start after the switch go to api.anthropic.com.
  • claude -p and the Agent SDK never register Remote Control, so they are wired only by /gateway on.
  • The /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.

Tests

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.

Source 1 files
hooks/register.ts 380 lines
1import 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