SLOPSHOPPER

model-relay

Per-window model switcher, subagent router, and automatic 429 failover for Claude Code — switch any window from the /provider panel, route subagents to any…

panebandcommandtoaststatusprocess
★ 45v0.7.5MITupdated 2026-10-24cablate/model-relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-relay
│ ┃ model-relay-avatar ✕ › 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 │ │ › /provider │ ⎿ model-relay: 還沒有 model-relay.json。執行 /provider setup 讓 Claude 帶你 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="docs/assets/banner-light.svg" width="900" alt="model-relay: per-window model switcher, subagent router, and 429 failover for Claude Code">

<b>Switch any Claude Code window to another model from an interactive panel, route subagents to any provider,<br>and automatically fail over when Claude hits a usage limit—all on localhost, with Remote Control and statuslines intact.</b>

<a href="https://github.com/cablate/model-relay/releases"><img src="https://img.shields.io/github/v/release/cablate/model-relay?include_prereleases&display_name=release&label=release&color=38bdf8" alt="Latest release"></a> <a href="https://github.com/cablate/model-relay/actions/workflows/ci.yml"><img src="https://github.com/cablate/model-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <img src="https://img.shields.io/badge/Claude_Code-2.1.287%2B-a78bfa" alt="Claude Code 2.1.287 or later"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>

<b>English</b> · <a href="README.zh-TW.md">繁體中文</a> · <a href="#install">Install</a> · <a href="#whats-inside">Features</a> · <a href="#configuration">Configuration</a> · <a href="CHANGELOG.md">Changelog</a>

<img src="docs/assets/panel.svg" width="900" alt="Animation: on the left, main chat uses Claude while subagents route to any provider and local gateways auto-launch; on the right, the interactive AbovePrompt panel lets you test providers and switch only the current window with one click">

Why

Claude Code binds model settings globally across every terminal window. model-relay runs a lightweight router on localhost (127.0.0.1:8950) so each window and subagent can use the model you want—with zero external cloud proxies.

When…Without model-relayWith model-relay
You want to switch just one window to another modelEditing settings.json switches every open terminal at onceType /provider to switch only the current window from the panel above your prompt (and write its active model to model-relay-status/<session id> for your statusline)
You want haiku or a specific subagent on another modelChanging global env vars forces your main Opus/Sonnet chat onto that endpoint too/provider alias haiku <name> offloads haiku globally, or model-relay:<name> spawns a one-off subagent on that provider with all tools intact
Claude hits a 5-hour usage limit (HTTP 429) or a provider hiccupsYour turn fails mid-task until quota resetsAutomatic retries and failover.chain hand the request to your backup provider and pin only that window while new windows return to Claude after cooldown
You use Remote Control (claude.ai/code) from your phone or browserCustom ANTHROPIC_BASE_URL blocks Remote Control from connectingLocal HTTPS proxy mode (/provider rc on) keeps Remote Control working while routing each window independently
Third-party gateways lose history/tools or reject ToolSearch / cache_controlmessage-threads deltas cause mid-chat amnesia, and ToolSearch hides tools or triggers HTTP 400Automatically sends full conversations keeps ToolSearch working, and cleans cache_control per provider on the fly

Who this is for

model-relay is built for Claude Code users who combine a Claude subscription with backup or specialized endpoints (z.ai, DeepSeek, Kimi, MiniMax, OpenRouter, or a gateway on your own machine). Each provider has to accept Anthropic's /v1/messages format; a service that only speaks OpenAI Chat Completions needs a local translator (see Limitations). If you need a multi-tenant cloud proxy for an entire organization, use LiteLLM instead—model-relay is a single-user localhost daemon.

Install

Requires Node.js 20+ and Claude Code 2.1.287 or later (tested on Node.js 22.23.2 and Claude Code 2.1.306).

⚠️ Upgrading from 0.6 or earlier? Updating the plugin alone is not enough. Version 0.7+ reads only "format": 2 in ~/.claude/model-relay.json. Until you migrate your config and restart the router, an old 0.6 router still running in the background keeps running 0.6 code (without 0.7's conversation/tool loss fix or localhost security check), and once that process stops, the plugin will not start a new router until you migrate. Run /provider setup upgrade my model-relay config and Claude does it with you, or follow UPGRADING.md by hand (node tools/setup.mjs migrate, then migrate --go, then /provider restart).

claude plugin marketplace add cablate/model-relay
claude plugin install model-relay@model-relay
  1. Set your provider's API key before starting Claude Code. The relay reads it from the environment of Claude Code, so a variable set in a shell that is already running Claude Code is not seen. Make it permanent, for example export BACKUP_API_KEY="your-api-key" in ~/.zshrc (macOS/Linux) or setx BACKUP_API_KEY "your-api-key" (Windows), then open a new terminal.
  2. Open Claude Code and let it set things up with you. Run /reload-plugins first if Claude Code was already open.
   /provider setup

Claude runs the setup tool for you, never asks you to paste a key into the chat, and asks before it changes ~/.claude/settings.json. Add what you want, such as /provider setup use deepseek for haiku.

  1. Check that it works.
   /provider doctor --ping

You should see Config OK, your provider under Providers, and a ✓ for it under Upstream ping:

   Config OK (~/.claude/model-relay.json)
     Port: 8950
     Providers: backup -> your-model-id
     Failover: backup (back to Claude after 10 min)    Retry: on
     Aliases:  none (run "node tools/setup.mjs alias haiku <provider>" to route haiku)
     Claude settings: ✓ settings.json ANTHROPIC_BASE_URL=http://127.0.0.1:8950
     Router health: ✓ up on 127.0.0.1:8950 (pid 38780, <version>, pinned sessions: 0, 429 failover: idle, proxy off (0 routed / 0 tunnelled))
     Upstream ping:
       ✓ backup     (your-model-id) -> answers, first byte after 640 ms

Anything doctor finds is listed with a ⚠ and what fixes it; /provider setup can run those fixes with you. doctor only reports and changes nothing; add --fix (/provider doctor --fix) to let it apply the repairs it can make itself, such as pointing ANTHROPIC_BASE_URL in ~/.claude/settings.json at the relay. doctor --json prints the same findings for an assistant or a script, each with the setup.mjs arguments that fix it (fix) and whether that command changes something you should agree to first (needsConsent).

Nothing goes to a provider unless you say so. A new config keeps haiku and every other request on Claude; the first provider you add becomes the one that takes over on a 429 (failover.chain). To send Claude Code's haiku calls (subagents, WebFetch summaries, internal helpers) to a provider, run node tools/setup.mjs alias haiku <name>.

Setting it up by hand: run /provider init (creates ~/.claude/model-relay.json with no provider yet, so every request still goes to Claude, and points ANTHROPIC_BASE_URL in ~/.claude/settings.json at the relay). Add a provider with node tools/setup.mjs add <name> --preset <preset> --model <model id> --token env:<VAR> (presets lists the built-in ones; --url takes any other Anthropic-compatible address), restart Claude Code, then run /provider doctor --ping.

Where node tools/setup.mjs runs: the node tools/setup.mjs ... commands later in this README are the same tool. They run from the plugin's install directory, which is ~/.claude/plugins/cache/model-relay/model-relay/<version>/ ($CLAUDE_CONFIG_DIR/plugins/cache/model-relay/model-relay/<version>/ when you set CLAUDE_CONFIG_DIR; ls that folder for your version). Elsewhere, use the full path to tools/setup.mjs inside it. The command acts on the config directory in $CLAUDE_CONFIG_DIR, or ~/.claude if unset. /provider setup runs them for you from anywhere.

A key exported later is not seen: env:VAR tokens are read from the router process's own environment, which it inherits from whichever Claude Code session (or setup command) started it. A variable exported after the router started is not seen until the router restarts: stop the router process (doctor prints its pid) and start a new Claude Code session. doctor reads your shell, so it can report the key as set while the router cannot see it.

Updating: run claude plugin update model-relay@model-relay. The plugin then restarts the relay by itself once no request is in flight through it; a relay from 0.6 or earlier cannot report that, so a notice asks you to run /provider restart when no Claude Code window has a reply in progress.

What's inside

Interactive AbovePrompt control panel and provider test

Type /provider to open a panel right above your prompt input box. It does one thing: pick which model this window uses, one button per row. Once a switch takes, the panel closes and a notice names the model now in use. Click a button, or press ctrl+x tab and then the key shown on it (1–9 to switch, p to test every provider, and r to try Claude again while a 429 cooldown runs). Switching a pinned window back to Claude takes a second press to confirm. The switches every window shares stay folded under Settings [s]: press it to show 429 failover, retry and Remote Control (f / t / x) and the model aliases. They turn on or off without editing JSON: failover and retry apply from the next request, while Remote Control asks first because it rewrites settings.json: turning it on reaches new sessions only, and turning it off disconnects sessions already running in proxy mode until they are restarted.

Each provider row shows two things apart: the last real request (last 200 · 45.0s) and the last test (test ok 640ms). The test sends one 1-token request and stops at the first byte (15 seconds at most), so a long reply never reads as a slow provider, and a failure says what is wrong in words: the key was refused, the model name is wrong, the quota is used up, the local gateway is not running, or there was no answer in time. The router keeps the provider's key out of anything it shows. When the router skips a provider (its env: variable is unset for the router), the panel says so.

Automatic 429 failover and per-window session pinning

<img src="docs/assets/relay-flow.svg" width="900" alt="Animation: a Claude Code session hits a 429 usage limit; model-relay seamlessly fails over to a backup provider and pins that window while new windows return to Claude after cooldown">

When Anthropic returns a 429 usage-limit response, the router immediately forwards the request to the first provider in failover.chain and pins that session ID for 7 days. After failover.cooldownMs (default 10 minutes), new sessions probe Claude again while existing pinned sessions stay on their backup provider. failover.enabled switches the whole thing off.

Transparent provider retries

A provider answering 429 or 5xx is retried on the SAME provider with exponential backoff (retry.attempts/delayMs/backoff), capped by retry.maxDelayMs; a longer Retry-After keeps polling at that cap. Set retry.attempts: 0 to retry forever (keep hammering until it succeeds — the client connection is watched, so retries stop once the caller disconnects). Claude Code never sees the intermediate failures. retry.enabled turns retrying off everywhere in one go. Turn on retry.failover and a request that still fails once its retries run out is handed to the next provider in failover.chain (or to that provider's own fallback list), never back to one it already tried.

Built-in provider presets and local gateway auto-launch

Built-in presets (zai, deepseek, kimi, minimax, openrouter) fill in a service's address and the compatibility switches it needs. You always choose the model, because model IDs go out of date, and give the token (or "env:VAR_NAME"). node tools/setup.mjs presets lists them. For local gateways on 127.0.0.1, set launch: { command, args } and the router will automatically spawn the gateway process whenever its port is closed.

Model alias routing (pointing haiku / sonnet / opus at a provider)

model-relay separates three routing layers so they never get mixed up:

  1. **Per-request model alias (matchModels + ANTHROPIC_DEFAULT_*_MODEL):** Routes only requests whose model matches (such as haiku subagents or WebFetch summaries) without pinning the main conversation.
  2. Per-conversation pin (/provider <name>): Pins only the current conversation ID (x-claude-code-session-id) for 7 days; other windows stay on Claude.
  3. 429 failover chain (failover.chain): Takes over only when Anthropic returns a 429 usage-limit error.

Pointing a Claude tier like haiku at a provider (for example backup, with model your-model-id[1m]) coordinates two places at once:

  • ~/.claude/settings.json → env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "your-model-id[1m]" so Claude Code itself resolves the haiku alias to that model (including the [1m] context window) and sends "model": "your-model-id".
  • ~/.claude/model-relay.json → providers.backup.matchModels gains "haiku", so the router also catches internal Claude Code calls that hardcode "claude-haiku-*", rewriting the wire model field to "your-model-id". A request for a provider's own model always reaches that provider. node tools/setup.mjs alias haiku claude undoes both.

How a request finds its provider: a request whose model is some provider's own model goes to that provider first, whatever the order in the config. Only then are the matchModels patterns tried, in config order, and the first match wins. A request caught by a pattern keeps the model name it came with, except a Claude name (claude-*, haiku, sonnet, opus), which is replaced by the provider's model. So a pattern should match the real model ID the upstream knows, not the provider's name in this file: ^backup would catch requests named after the provider and pass on a name the upstream has never heard of.

Use setup.mjs alias (or /provider alias) to inspect or set both files in one step:

node tools/setup.mjs alias                       # inspect haiku / sonnet / opus / subagent default
node tools/setup.mjs alias haiku backup          # point haiku at backup in both settings.json and model-relay.json
node tools/setup.mjs alias haiku claude          # reset haiku back to Anthropic Claude
node tools/setup.mjs alias subagent sonnet       # set CLAUDE_CODE_SUBAGENT_MODEL=sonnet in settings.json

For per-call subagent routing, model-relay also dynamically registers a full-capability Agent type model-relay:<name> for each provider the router serves (such as model-relay:backup) via $.agent.register with all parent tools and model set to that provider's model ID, and locks that model in on('agent.spawn')—so you can spawn subagent_type: "model-relay:backup" on the fly without creating .claude/agents/*.md files or restarting the session.

Each provider can shape its agent with an agent object in ~/.claude/model-relay.json. The fields are the ones an agent file's frontmatter takes, used as written; name and model always stay the provider's. description is the line Claude reads in its list of agent types when it decides which one to spawn, so say when to pick this agent:

"providers": {
  "backup": {
    "model": "your-model-id",
    "agent": {
      "description": "Use for routine, well-scoped tasks: code search, summaries, small edits.",
      "effort": "medium",
      "maxTurns": 40
    }
  }
}

A change to agent applies from your next message, without restarting Claude Code. Tested on Claude Code 2.1.308: permissionMode cannot loosen or tighten a parent running in bypassPermissions; initialPrompt is not applied to subagents started by the Agent tool (it is for claude --agent); memory had no effect, the same as in an ordinary .claude/agents/*.md file.

Claude Code can still run such a subagent on another model without saying so (a model missing from availableModels is swapped, with a line only in its debug log). The plugin checks twice: when the subagent starts, against the model Claude Code reports it started on, and when it finishes, against the models the router actually received. Either mismatch shows a notice that points to /provider doctor.

Edit the config from the command line

add, set, providers, remove and migrate change the file without hand-editing JSON. Every edit keeps a .model-relay-backup copy next to the file, tokens are never printed, and the running router picks the change up on its next request. On an older config (see format under Configuration), add, set, providers, remove, alias, init, restart, rc and proxy enable refuse and tell you to run migrate first; migrate is the only command that understands it. doctor also warns about an older config, a failover name that matches no provider, two providers that name the same model (the first one in the file gets it), an availableModels list in ~/.claude/settings.json that leaves a provider's model out, and a running router older than the plugin.

node tools/setup.mjs providers
node tools/setup.mjs add local --url http://127.0.0.1:4000 --model your-local-model-id --token env:LOCAL_GATEWAY_TOKEN
node tools/setup.mjs set failover.chain backup,local
node tools/setup.mjs set providers.local.fallback none
node tools/setup.mjs migrate --go

remove <name> takes the provider out of failover.chain and out of other providers' fallback lists, but it does not touch the ANTHROPIC_DEFAULT_*_MODEL aliases in ~/.claude/settings.json. If haiku, sonnet or opus pointed at the removed provider's model, Claude Code keeps sending that model name and, with no provider left to claim it, the request goes to Claude. Reset each of those aliases yourself with node tools/setup.mjs alias haiku claude (or alias haiku <name> to give it to another provider; likewise for sonnet and opus). doctor warns about an alias that no provider matches.

Remote Control without giving up the relay

Remote Control only starts when Claude Code talks to api.anthropic.com, which a base URL pointing at the relay breaks. HTTPS proxy mode keeps that address and makes the relay the proxy instead: Claude Code connects through HTTPS_PROXY, the relay answers for api.anthropic.com with a certificate that Claude Code trusts through NODE_EXTRA_CA_CERTS, and model calls are routed exactly as before (providers, sticky failover, retries). Everything else, token refresh and Remote Control traffic included, passes to Anthropic unchanged.

node tools/setup.mjs proxy enable --go
node tools/setup.mjs rc
  • Contained trust: The CA the relay creates can only vouch for api.anthropic.com (a critical name constraint), its private key is discarded right after signing, and only Claude Code processes that carry NODE_EXTRA_CA_CERTS load it. The system certificate store is never touched.
  • Safe fallback: Without a usable certificate the relay tunnels api.anthropic.com untouched, so Claude Code keeps working, just unrouted. A stopped router is different: the plugin's automatic direct fallback only works in ANTHROPIC_BASE_URL mode, so in proxy mode Claude Code cannot connect until the router is restarted (node tools/setup.mjs rc).
  • Reversible: proxy disable --go restores ANTHROPIC_BASE_URL, and the previous settings.json is kept as settings.json.model-relay-backup. Sessions already running in proxy mode lose their connection until you restart them.

HTTPS_PROXY lives in settings.json env, so it also applies to commands Claude Code runs (git, curl, gh): they go through the relay as blind tunnels, and fail while the router is down.

Configuration

Configuration lives in ~/.claude/model-relay.json. The directory defaults to $CLAUDE_CONFIG_DIR when set, falling back to $USERPROFILE/.claude or $HOME/.claude. model-relay.example.json shows a provider from a preset and a local gateway.

FieldDefaultMeaning
format(none)2 for a config in the current format (init, add and migrate --go set it), the only format the router reads. A file without it, or one named custom-agent.json, is an older config: until migrate --go rewrites it, the plugin starts and replaces no router for it, so a 0.6 router still running keeps using it, and with none answering the session talks to Claude directly. The plugin says so in a notice, the panel and, with statusBar on, the status line; see UPGRADING.md
port8950Localhost port for the relay HTTP server (1025–65535). Change it with node tools/setup.mjs set port <n>, which also updates settings.json; the router already running keeps the old port until you stop it (the command prints its pid), and it refuses a port that already answers
statusBarfalseAlso show the relay state in Claude Code's plugin status line, the yellow line above the input. Off by default: each window's state is written to a status file for your own statusline instead (see below). Notices about upgrades and outages still appear as toasts
claudeUrl"https://api.anthropic.com"Upstream Anthropic API base URL
idleTimeoutMs300000How long an upstream may send nothing before the request counts as failed and follows the retry and failover rules (1000–3600000); a streaming answer that keeps sending data is never cut
proxy.enabledfalseAccept HTTPS-proxy connections for Remote Control. Off by default. When on, a CONNECT to any host other than api.anthropic.com is a blind tunnel that any local process can use; node tools/setup.mjs proxy enable --go turns it on and proxy disable --go off
failover.enabledtrueAutomatically switch to the failover chain on Claude 429 usage limits
failover.chain[]Ordered providers that take over when Claude is out of quota or a provider keeps failing; unknown names are skipped. Empty means a 429 from Claude is passed back as it is (add puts the first provider of a new config here)
failover.cooldownMs600000Milliseconds before probing Claude again for new sessions (10 minutes). Kept between 60000 (1 minute) and 7200000 (2 hours): a value outside that range is moved to the nearest limit
retry.enabledtrueMaster switch for retrying a failing provider; false keeps the other retry settings for later
retry.attempts3Times the SAME provider is retried with backoff when it answers 429/5xx; 0 means retry forever. Any other value is capped at 30
retry.delayMs2000First retry delay; multiplied by retry.backoff each attempt. Capped at 60000 (60 seconds), or 300000 (300 seconds) when set in a provider's own retry
retry.backoff2Multiplier between retries (2s → 4s → …), capped by retry.maxDelayMs. Kept between 1 and 4
retry.maxDelayMs10000Longest single wait; a longer Retry-After keeps polling at this interval. Capped at 300000 (300 seconds)
retry.failoverfalseOpt-in: after retries are exhausted, forward the original request to the next provider in the chain instead of returning the error
providers.<name>.retry
Source 5 files
hooks/register.ts 712 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiOpenResult } from 'claude-code'
3import type { AvatarEntry, AvatarView, PanelNote, PanelUi } from '../types'
4import {
5  AVATAR_EMPTY,
6  AVATAR_START,
7  CONFIG_NAME,
8  OLD_CONFIG_NAME,
9  PING_LIMIT_S,
10  avatarArgv,
11  avatarBytes,
12  avatarLabel,
13  avatarMade,
14  avatarName,
15  avatarScratch,
16  avatarSource,
17  avatarWanted,
18  base64ToBytes,
19  buildPanelData,
20  elsewhereText,
21  foreignConfigDir,
22  foreignStatusText,
23  has,
24  healthArgv,
25  healthWaitArgv,
26  helpText,
27  missedRelayText,
28  outdatedText,
29  parseHealth,
30  parseLocal,
31  pingArgv,
32  pingReport,
33  probeArgv,
34  providerAgentSpecs,
35  providerAgentOptions,
36  providerReport,
37  rasterCells,
38  reachedRelay,
39  relayAgent,
40  routerIsStale,
41  routerScript,
42  routerUrl,
43  sessionArgv,
44  setupBrief,
45  setupCliArgv,
46  setupCommandArgs,
47  setupScript,
48  spawnedElsewhere,
49  staleStatusText,
50  startArgv,
51  startFailedText,
52  statusFields,
53  statusFile,
54  statusText,
55  switchedText,
56  toggleArgv,
57  type AvatarAim,
58  type AvatarWant,
59  type Health,
60  type LocalCfg,
61} from './logic'
62import { avatarTree } from './avatar'
63import { panelTree, type PanelActions } from './panel'
64
65const panelUi = atom(
66  { plugin: 'model-relay', key: 'panelUi' } as const,
67  { open: false, confirmingClaude: false, pinging: false } satisfies PanelUi,
68)
69const panelData = atom({ plugin: 'model-relay', key: 'panelData' } as const, null)
70// Change the tag when AvatarView's fields change: a value an earlier version stored outlives a reload
71const avatarView = atom({ plugin: 'model-relay', key: 'avatarView' } as const, AVATAR_EMPTY, { shape: 'avatar-2' })
72// Asks for a wide dock: the picture has more cells to draw glyphs in the wider it is (the engine keeps it to 45% of the terminal)
73const AVATAR_PANE = 'model-relay-avatar'
74const AVATAR_OPEN = { id: AVATAR_PANE, title: 'model-relay avatar', columns: 80 }
75
76let ticking = false
77let shownStatus: string | undefined = ''
78let statusBar = false // the config's "statusBar", read each tick
79let writtenStatus = '' // path and line last written, so a new session id writes its own file at once
80let statusDir: string | undefined
81let writtenStatusAt = 0
82let lastStart = 0
83let direct = false // this session bypasses a dead router and talks to Anthropic itself
84let startFailed = false // the router was started and did not answer; said once until it answers again
85let registeredSig: string | null = null
86let pluginVersion: string | null = null
87let lastStaleTry = 0
88let staleBusy = false // the router runs older code but was answering a request when we last tried to replace it
89let outdatedTold = false // the toast about an older config, once per session
90// The providers our own router last reported; kept through an outage so a model-relay:<name> spawn still gets its model
91let knownProviders: Record<string, string> = {}
92// model-relay:<name> subagents started and not finished yet, by agentId: checked against the router when their turn ends
93const pending = new Map<string, { name: string; model: string; since: number }>()
94let avatarEntries: Record<string, AvatarEntry> = {} // the config's "avatars", read each tick
95let avatarAim: AvatarAim = AVATAR_START // what the avatar pane is asked to show; its view catches up
96let avatarBusy = false // a conversion is running: one at a time
97let avatarSwitch: boolean | null = null // the config's "avatarPane" (with pictures to show) as last followed; null before the first tick
98// The last pictures made, by what they were made from, so switching back to a model draws at once; reopening the pane empties it
99const avatarMemo = new Map<string, string>()
100const AVATAR_MEMO_SIZE = 8
101
102const COMMAND_WORDS = ['claude', 'status', 'help', 'ping', 'setup', 'doctor', 'restart', 'failover', 'retry', 'rc', 'alias', 'avatar']
103
104async function myVersion($: EngineInterface): Promise<string> {
105  if (pluginVersion === null) {
106    try {
107      pluginVersion = String(JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)).version ?? '')
108    } catch {
109      pluginVersion = ''
110    }
111  }
112  return pluginVersion
113}
114
115// One model-relay:<name> agent per provider, and the provider names in /provider's hint; redone when the router's list changes
116async function syncProviders($: EngineInterface, providers: Record<string, string>): Promise<void> {
117  knownProviders = providers
118  let options: ReturnType<typeof providerAgentOptions> = {}
119  try {
120    options = providerAgentOptions(JSON.parse(await $.fs.read(`${await configDir($)}/${CONFIG_NAME}`)))
121  } catch {}
122  const specs = providerAgentSpecs(providers, options)
123  const sig = JSON.stringify(specs)
124  if (sig === registeredSig) return
125  const plain = providerAgentSpecs(providers)
126  for (const [i, spec] of specs.entries()) {
127    const ok = await $.agent.register(spec).then(() => true, () => false)
128    // Claude Code refused this provider's "agent" fields: keep the agent with the defaults rather than lose it
129    if (!ok && has(options, spec.name)) {
130      await $.agent.register(plain[i]!).catch(() => undefined)
131      const lang = await detectLang($)
132      $.ui.toast(lang === 'en'
133        ? `Claude Code refused providers.${spec.name}.agent in model-relay.json; model-relay:${spec.name} uses the defaults`
134        : `Claude Code 不接受 model-relay.json 的 providers.${spec.name}.agent,model-relay:${spec.name} 先用預設值`)
135    }
136  }
137  await $.command.register({
138    name: 'provider',
139    description: '切換這個視窗的模型(不帶參數開面板,/provider help 看全部指令)',
140    argumentHint: `[${[...Object.keys(providers), ...COMMAND_WORDS].join('|')}]`,
141  })
142  registeredSig = sig
143}
144
145async function isWindows($: EngineInterface): Promise<boolean> {
146  const os = await $.env.get('OS')
147  const up = await $.env.get('USERPROFILE')
148  return Boolean((os && /windows/i.test(os)) || up)
149}
150
151async function detectLang($: EngineInterface): Promise<'zh' | 'en'> {
152  try {
153    const s = await $.settings.read()
154    const lang = String((s as Record<string, unknown>)?.language ?? '')
155    if (/zh|繁體|中文|traditional/i.test(lang)) return 'zh'
156    if (/en|english/i.test(lang)) return 'en'
157  } catch {}
158  return 'zh'
159}
160
161async function configDir($: EngineInterface): Promise<string> {
162  const dir = await $.env.get('CLAUDE_CONFIG_DIR')
163  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
164  return (dir || `${home}/.claude`).replace(/\\/g, '/')
165}
166
167async function readLocal($: EngineInterface): Promise<LocalCfg | null> {
168  const base = await configDir($)
169  const read = async (name: string) => JSON.parse(await $.fs.read(`${base}/${name}`)) as unknown
170  let cfg: LocalCfg | null = null
171  try {
172    cfg = parseLocal(await read(CONFIG_NAME))
173  } catch {}
174  if (!cfg) {
175    try {
176      cfg = parseLocal(await read(OLD_CONFIG_NAME), true)
177    } catch {}
178  }
179  if (cfg) {
180    statusBar = cfg.statusBar
181    avatarEntries = cfg.avatars
182  }
183  return cfg
184}
185
186async function health($: EngineInterface, cfg: LocalCfg): Promise<Health | null> {
187  try {
188    const win = await isWindows($)
189    const r = await $.process.run(healthArgv(cfg, await $.session.id(), win), { timeoutMs: 5000 })
190    return r.exitCode === 0 ? parseHealth(r.stdout) : null
191  } catch {
192    return null
193  }
194}
195
196// Writes this session's relay state for statusline tools (statusFields), when it changes and at least daily so the
197// router's sweep of week-old files never takes a live session's; then draws the plugin status line, only when the
198// config asks for it ("statusBar": true).
199async function showStatus($: EngineInterface, text: string, fields: [string, string]): Promise<void> {
200  // the session id is asked each time: /clear and an in-process resume go on under a new one without session.start
201  statusDir ??= await configDir($)
202  const file = statusFile(statusDir, await $.session.id(), fields)
203  if (file && (`${file.path}\n${file.line}` !== writtenStatus || Date.now() - writtenStatusAt > 86400000)) {
204    try {
205      await $.fs.write(file.path, file.line)
206      writtenStatus = `${file.path}\n${file.line}`
207      writtenStatusAt = Date.now()
208    } catch {}
209  }
210  const shown = statusBar ? text : undefined
211  if (shown === shownStatus) return
212  shownStatus = shown
213  $.ui.status(shown)
214}
215
216// One avatar view from what is wanted: the pixels from ffmpeg as cells, or a view that says why there are none
217async function makeAvatar($: EngineInterface, want: AvatarWant): Promise<AvatarView> {
218  const view: AvatarView = { ...want, cells: null, error: null }
219  if (!want.entry) return { ...view, error: 'unset' }
220  if (want.cols < 1) return view
221  const key = JSON.stringify([want.entry, want.mode, want.cols, want.rows])
222  const known = avatarMemo.get(key)
223  if (known) return { ...view, cells: known }
224  try {
225    const dir = await configDir($)
226    const out = avatarScratch(dir, await $.session.id())
227    await $.fs.write(out, '') // makes the folder ffmpeg writes into
228    const run = await $.process.run(avatarArgv(avatarSource(dir, want.entry.image), out, want.mode, want.entry.head, want.cols, want.rows), { timeoutMs: 20000 })
229    if (run.exitCode !== 0) return { ...view, error: 'failed' }
230    const rgba = base64ToBytes((await $.fs.read(out, { as: 'bytes' })).base64)
231    await $.fs.write(out, '').catch(() => undefined) // the pixels are held now; one empty file per window stays
232    if (rgba.length !== avatarBytes(want.cols, want.rows)) return { ...view, error: 'failed' }
233    const cells = rasterCells(rgba, want.cols, want.rows)
234    avatarMemo.set(key, cells)
235    if (avatarMemo.size > AVATAR_MEMO_SIZE) avatarMemo.delete(avatarMemo.keys().next().value!)
236    return { ...view, cells }
237  } catch {
238    return { ...view, error: 'failed' }
239  }
240}
241
242// Brings the avatar view up to what is wanted, one conversion at a time. A result is kept only if the wish did not move
243// while ffmpeg ran, and the loop then goes round for the new wish; the pass limit is only a stop for a loop that cannot end.
244async function pumpAvatar($: EngineInterface): Promise<void> {
245  if (avatarBusy) return
246  avatarBusy = true
247  try {
248    for (let pass = 0; pass < 8; pass++) {
249      const have = await read($, avatarView)
250      if (avatarMade(have, avatarWanted(avatarAim, avatarEntries))) return
251      const view = await makeAvatar($, avatarWanted(avatarAim, avatarEntries))
252      if (avatarMade(view, avatarWanted(avatarAim, avatarEntries))) await update($, avatarView, () => view)
253    }
254  } catch {
255  } finally {
256    avatarBusy = false
257  }
258}
259
260// Points the avatar pane at the persona this window talks to now. Until the pane has drawn once there is no body to
261// convert for, and its first draw starts the conversion.
262function followAvatar($: EngineInterface, h: Health | null, foreign = false): void {
263  const name = avatarName(h, foreign)
264  avatarAim = { ...avatarAim, name, label: avatarLabel(name, h?.providers ?? {}) }
265  if (avatarAim.body) void pumpAvatar($)
266}
267
268// The pane's draw is what tells the body its picture goes in
269function sizeAvatar($: EngineInterface, props: { bodyColumns: number; placement: 'dock' | 'inline'; scroll: { bodyRows: number } }): void {
270  avatarAim = { ...avatarAim, body: { placement: props.placement, columns: props.bodyColumns, rows: props.scroll.bodyRows } }
271  void pumpAvatar($)
272}
273
274// A reopened pane tries again: the image may have changed, or ffmpeg got onto PATH
275async function openAvatar($: EngineInterface): Promise<UiOpenResult> {
276  avatarMemo.clear()
277  await update($, avatarView, () => AVATAR_EMPTY)
278  return $.ui.open(AVATAR_OPEN)
279}
280
281async function closeAvatar($: EngineInterface): Promise<void> {
282  avatarAim = { ...avatarAim, body: null }
283  await $.ui.close({ id: AVATAR_PANE }).catch(() => undefined)
284}
285
286// The "avatarPane" switch every window shares: turned on (or found on at the start) it opens the pane here, turned off it
287// closes it, within a tick of the change. Unchanged, it leaves alone a pane /provider avatar opened or closed.
288async function followAvatarSwitch($: EngineInterface, cfg: LocalCfg): Promise<void> {
289  const on = cfg.avatarPane && Object.keys(cfg.avatars).length > 0
290  if (on === avatarSwitch) return
291  const was = avatarSwitch
292  avatarSwitch = on
293  if (on) await openAvatar($).catch(() => undefined)
294  else if (was) await closeAvatar($)
295}
296
297// /provider avatar closes the pane when it is drawn and opens it otherwise; a command is the person asking, so it is placed at any width
298async function toggleAvatar($: EngineInterface, lang: 'zh' | 'en'): Promise<string> {
299  const en = lang === 'en'
300  if ((await $.ui.panes()).some((p) => p.id === AVATAR_PANE && p.isPlaced)) {
301    await closeAvatar($)
302    return en ? 'Avatar pane closed.' : '頭像面板已關閉。'
303  }
304  const opened = await openAvatar($)
305  if (opened.isPlaced) return en ? 'Avatar pane open.' : '頭像面板已開啟。'
306  return en ? `Avatar pane is open but not drawn yet: ${opened.reason}` : `頭像面板已開啟,但還沒畫出來:${opened.reason}`
307}
308
309async function syncPanelData($: EngineInterface, cfg: LocalCfg, h: Health | null, lang: 'zh' | 'en'): Promise<void> {
310  try {
311    const ui = await read($, panelUi)
312    if (!ui.open) return
313    const snap = buildPanelData(h, cfg.port, direct, lang, Date.now(), cfg.outdated)
314    await update($, panelData, () => snap)
315  } catch {}
316}
317
318async function setPanelNote($: EngineInterface, note: PanelNote | undefined): Promise<void> {
319  try {
320    await update($, panelUi, ({ confirmingClaude: _c, confirmingProxy: _p, note: _n, ...u }) => (note ? { ...u, confirmingClaude: false, note } : { ...u, confirmingClaude: false }))
321  } catch {}
322}
323
324// Opened fresh each time: no note or open confirmation from last time, the shared settings folded away
325async function openPanel($: EngineInterface, cfg: LocalCfg, h: Health | null, lang: 'zh' | 'en'): Promise<void> {
326  await update($, panelData, () => buildPanelData(h, cfg.port, direct, lang, Date.now(), cfg.outdated))
327  await update($, panelUi, ({ note: _n, ...u }) => ({ ...u, open: true, confirmingClaude: false, confirmingProxy: false, showSettings: false }))
328}
329
330// Our router answered: it is the one source of what providers exist
331async function ours($: EngineInterface, cfg: LocalCfg, h: Health, lang: 'zh' | 'en'): Promise<void> {
332  startFailed = false
333  await syncProviders($, h.providers)
334  await syncPanelData($, cfg, h, lang)
335  followAvatar($, h)
336}
337
338// Keeps the router alive; when it cannot be brought back, this session talks to
339// Anthropic directly until the router answers again.
340async function tick($: EngineInterface): Promise<void> {
341  if (ticking) return
342  ticking = true
343  try {
344    const cfg = await readLocal($)
345    if (!cfg) return
346    await followAvatarSwitch($, cfg)
347    const lang = await detectLang($)
348    const seen = await health($, cfg)
349    // Another config dir's router on our port is not ours: never route through it, same as a router that is down
350    const other = foreignConfigDir(seen, await configDir($), await isWindows($))
351    if (other) {
352      if (!direct && (await $.env.get('ANTHROPIC_BASE_URL')) === routerUrl(cfg)) {
353        direct = true
354        await $.env.set('ANTHROPIC_BASE_URL', undefined)
355        $.ui.toast(lang === 'en' ? `Port ${cfg.port} belongs to another relay (${other}); connected directly to Claude` : `連接埠 ${cfg.port} 屬於另一個轉發站(${other}),主線直連 Claude`)
356      }
357      await showStatus($, foreignStatusText(other, lang), statusFields(null, direct, true))
358      followAvatar($, null, true)
359      return
360    }
361    const h = seen
362    if (cfg.outdated && !outdatedTold) {
363      outdatedTold = true
364      $.ui.toast(outdatedText(lang))
365    }
366    // `plugin update` leaves the old router running, and the hook only starts one when none answers; replace it here,
367    // but only while it answers nothing (setup.mjs restart --if-idle), so no other session loses a reply. A router from
368    // 0.6.0 or earlier cannot report that, so it is only pointed out: /provider restart is the person's call. With an
369    // older config nothing is replaced: the router that answers may be the last one that still reads it.
370    const stale = !cfg.outdated && routerIsStale(h, await myVersion($))
371    if (!stale) staleBusy = false
372    else if (h && typeof h.inflight !== 'number') {
373      if (!staleBusy) $.ui.toast(staleStatusText(lang))
374      staleBusy = true
375    } else if (Date.now() - lastStaleTry > 60000) {
376      lastStaleTry = Date.now()
377      const r = await $.process.run([cfg.nodePath, setupScript($.plugin.root), 'restart', '--if-stale', '--if-idle'], { timeoutMs: 30000 }).catch(() => null)
378      staleBusy = r?.exitCode === 3
379      if (r?.exitCode === 0) {
380        $.ui.toast(lang === 'en' ? 'The relay was restarted to run this plugin version' : '轉發站已重啟,改跑這個外掛版本的程式')
381        return // the next tick reads the new router
382      }
383    }
384    if (h) {
385      if (direct) {
386        direct = false
387        await $.env.set('ANTHROPIC_BASE_URL', routerUrl(cfg))
388        $.ui.toast(lang === 'en' ? 'Relay recovered; routing through local relay' : '轉發站恢復,主線重新經由轉發站')
389      }
390      const note = cfg.outdated ? outdatedText(lang) : staleBusy ? staleStatusText(lang) : ''
391      await showStatus($, note ? `${statusText(h, false, lang)} · ${note}` : statusText(h, false, lang), statusFields(h, false))
392      await ours($, cfg, h, lang)
393      return
394    }
395    // This version's router does not read an older config, so none is started for one
396    if (!cfg.outdated && Date.now() - lastStart > 15000) {
397      lastStart = Date.now()
398      const win = await isWindows($)
399      const dir = await configDir($)
400      await $.process.run(startArgv(cfg, routerScript($.plugin.root), dir, win), { timeoutMs: 15000 }).catch(() => undefined)
401      const up = await $.process.run(healthWaitArgv(cfg, await $.session.id(), win), { timeoutMs: 20000 }).catch(() => null)
402      const h2 = up && up.exitCode === 0 ? parseHealth(up.stdout) : null
403      if (h2) {
404        await showStatus($, statusText(h2, false, lang), statusFields(h2, false))
405        await ours($, cfg, h2, lang)
406        return
407      }
408      if (!startFailed) {
409        startFailed = true
410        $.ui.toast(startFailedText(dir, lang))
411      }
412    }
413    if (!direct && (await $.env.get('ANTHROPIC_BASE_URL')) === routerUrl(cfg)) {
414      direct = true
415      await $.env.set('ANTHROPIC_BASE_URL', undefined)
416    }
417    await showStatus($, cfg.outdated ? `${statusText(null, direct, lang)} · ${outdatedText(lang)}` : statusText(null, direct, lang), statusFields(null, direct))
418    await syncPanelData($, cfg, null, lang)
419    followAvatar($, null)
420  } finally {
421    ticking = false
422  }
423}
424
425// After a model-relay:<name> subagent's turn: a router that reports what reached it says whether its model did
426async function checkReached($: EngineInterface, p: { name: string; model: string; since: number }): Promise<void> {
427  const cfg = await readLocal($)
428  if (!cfg) return
429  if (reachedRelay(await health($, cfg), p.model, p.since) === false) $.ui.toast(missedRelayText(p.name, p.model, await detectLang($)))
430}
431
432function panelActions($: EngineInterface): PanelActions {
433  const good = (text: string): PanelNote => ({ text, ok: true })
434  const bad = (text: string): PanelNote => ({ text, ok: false })
435  const run = (work: () => Promise<PanelNote | undefined>) => {
436    void work()
437      .catch((err) => bad(String(err)))
438      .then(async (note) => {
439        const cfg = await readLocal($)
440        if (cfg) {
441          const lang = await detectLang($)
442          const h = await health($, cfg)
443          if (h) {
444            await showStatus($, statusText(h, false, lang), statusFields(h, false))
445            followAvatar($, h)
446          }
447          await syncPanelData($, cfg, h, lang)
448        }
449        await setPanelNote($, note)
450      })
451  }
452  const close = () => update($, panelUi, (u) => ({ ...u, open: false, confirmingClaude: false, confirmingProxy: false }))
453  return {
454    // A switch that took is the panel's job done: it closes, and a toast (and the status line, when on) says where this window goes now
455    switchTo: (target) =>
456      run(async () => {
457        const cfg = await readLocal($)
458        const lang = await detectLang($)
459        if (!cfg) return bad(lang === 'en' ? 'Config missing.' : '設定檔不存在。')
460        const win = await isWindows($)
461        const r = await $.process.run(sessionArgv(cfg, await $.session.id(), target, win), { timeoutMs: 8000 }).catch(() => null)
462        if (!r || r.exitCode !== 0 || !r.stdout.includes('"ok":true')) {
463          return bad(lang === 'en' ? 'Relay did not respond; nothing changed.' : '轉發站沒有回應,沒有改變。')
464        }
465        const h = await health($, cfg)
466        await close()
467        $.ui.toast(switchedText(target, h && has(h.providers, target) ? h.providers[target]! : target, lang))
468        return undefined
469      }),
470    askClaude: (ask) => {
471      void update($, panelUi, ({ note: _n, ...u }) => ({ ...u, confirmingClaude: ask }))
472    },
473    // The rows show what each test came to, so a test that ran needs no note of its own
474    ping: () =>
475      run(async () => {
476        const ui = await read($, panelUi)
477        if (ui.pinging) return undefined
478        const cfg = await readLocal($)
479        const lang = await detectLang($)
480        if (!cfg) return undefined
481        await update($, panelUi, ({ note: _n, ...u }) => ({ ...u, pinging: true }))
482        try {
483          const win = await isWindows($)
484          const r = await $.process.run(pingArgv(cfg, '', win), { timeoutMs: (PING_LIMIT_S + 5) * 1000 }).catch(() => null)
485          if (!r || r.exitCode !== 0) return bad(lang === 'en' ? 'Test failed: relay unreachable.' : '測試失敗:轉發站未回應。')
486          return undefined
487        } finally {
488          await update($, panelUi, (u) => ({ ...u, pinging: false }))
489        }
490      }),
491    probe: () =>
492      run(async () => {
493        const cfg = await readLocal($)
494        const lang = await detectLang($)
495        if (!cfg) return undefined
496        const win = await isWindows($)
497        await $.process.run(probeArgv(cfg, win), { timeoutMs: 5000 }).catch(() => null)
498        return good(lang === 'en' ? '✓ Cooldown reset; the next new session tries Claude.' : '✓ 已重置冷卻,下一個新對話會先試 Claude。')
499      }),
500    settings: (show) => {
501      void update($, panelUi, ({ note: _n, ...u }) => ({ ...u, showSettings: show, confirmingProxy: false }))
502    },
503    askProxy: (ask) => {
504      void update($, panelUi, ({ note: _n, ...u }) => ({ ...u, confirmingProxy: ask }))
505    },
506    toggle: (which, on) =>
507      run(async () => {
508        const cfg = await readLocal($)
509        const lang = await detectLang($)
510        if (!cfg) return bad(lang === 'en' ? 'Config missing.' : '設定檔不存在。')
511        const argv = toggleArgv(cfg, setupScript($.plugin.root), which, on)
512        // The proxy switch restarts the router and tests the certificate, which takes a few seconds
513        const r = await $.process.run(argv, { timeoutMs: which === 'proxy' ? 60000 : 15000 }).catch(() => null)
514        if (!r || r.exitCode !== 0) {
515          const why = (r?.stderr || r?.stdout || '').trim().split(/\r?\n/).pop() ?? ''
516          return bad(lang === 'en' ? `Not changed: ${why || 'setup command failed'}` : `沒有變更:${why || '設定指令失敗'}`)
517        }
518        if (which === 'proxy') {
519          return good(on
520            ? (lang === 'en' ? '✓ Remote Control on; new Claude Code sessions use it.' : '✓ 已開啟手機遙控;開新的 Claude Code 對話後生效。')
521            : (lang === 'en' ? '✓ Remote Control off; sessions already in proxy mode lose their connection.' : '✓ 已關閉手機遙控;已在代理模式執行的對話會斷線。'))
522        }
523        const name = which === 'failover' ? (lang === 'en' ? '429 failover' : '429 備援') : lang === 'en' ? 'Retry' : '失敗重試'
524        return good(lang === 'en' ? `✓ ${name} ${on ? 'on' : 'off'} from the next request.` : `✓ ${name}已${on ? '開啟' : '關閉'},下一個請求起生效。`)
525      }),
526    close: () => {
527      void close()
528    },
529  }
530}
531
532export const register: Register = (on) => {
533  on('session.start', async ($, e, next) => {
534    await syncProviders($, {}) // /provider exists even before the router answers; tick adds the providers
535    await tick($)
536    $.clock.every(10000, () => {
537      void tick($).catch(() => {})
538    })
539    return next(e)
540  })
541
542  on('agent.spawn', async ($, e, next) => {
543    if (e.fork || e.resume) return next(e)
544    const relay = relayAgent(e.subagentType, knownProviders)
545    if (!relay) return next(e)
546    const since = Date.now()
547    const r = await next({ ...e, model: relay.model })
548    try {
549      if (spawnedElsewhere(relay.model, r.model)) $.ui.toast(elsewhereText(relay.name, relay.model, r.model ?? '', await detectLang($)))
550      else if (r.agentId) {
551        pending.set(r.agentId, { ...relay, since })
552        if (pending.size > 50) pending.delete(pending.keys().next().value!)
553      }
554    } catch {}
555    return r
556  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'model-relay agent.spawn failed' }))
557
558  on('turn.complete', ($, e, next) => {
559    const p = e.agentId ? pending.get(e.agentId) : undefined
560    if (p && e.agentId) {
561      pending.delete(e.agentId)
562      if (!e.isAborted) void checkReached($, p).catch(() => {})
563    }
564    return next(e)
565  })
566
567  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
568    try {
569      const ui = await read($, panelUi)
570      const data = ui.open && !e.props.hasSurvey ? await read($, panelData) : null
571      return data
572        ? panelTree($.ui.resolve(e), { ...data, ...ui, columns: e.props.bodyColumns }, panelActions($))
573        : next(e)
574    } catch {
575      return next(e)
576    }
577  })
578
579  on('ui.render', { component: 'Pane', requestId: AVATAR_PANE }, async ($, e, next) => {
580    try {
581      sizeAvatar($, e.props)
582      return avatarTree($.ui.resolve(e), await read($, avatarView), e.props.bodyColumns, await detectLang($))
583    } catch {
584      return next(e)
585    }
586  })
587
588  on('command.run', { command: 'provider' }, async ($, e) => {
589    const cfg = await readLocal($)
590    const lang = await detectLang($)
591    const arg = e.args.trim().toLowerCase()
592    // First-run setup works without a config, and without knowing where the plugin was installed
593    const first = arg.split(/\s+/)[0] ?? ''
594    if (first === 'setup') {
595      const goal = e.args.trim().slice('setup'.length).trim()
596      void $.prompt.submit({ text: setupBrief(setupScript($.plugin.root), cfg?.nodePath ?? 'node', goal, !!cfg, lang) })
597      return { text: lang === 'en' ? 'Handing setup to Claude…' : '已交給 Claude 協助設定…' }
598    }
599    if (first === 'init' || first === 'doctor' || first === 'presets' || first === 'restart') {
600      const r = await $.process.run([cfg?.nodePath ?? 'node', setupScript($.plugin.root), ...setupCommandArgs(first, arg)], { timeoutMs: 60000 }).catch(() => null)
601      const out = ((r?.stdout || '') + (r?.stderr || '')).trim()
602      return { text: out || (lang === 'en' ? 'Setup command did not respond.' : '設定指令沒有回應。') }
603    }
604    if (arg === 'help') return { text: helpText(Object.keys(knownProviders), lang) }
605    if (!cfg) {
606      return {
607        text: lang === 'en'
608          ? 'There is no model-relay.json yet. Run /provider setup to let Claude walk you through it, or /provider init to create an empty one.'
609          : '還沒有 model-relay.json。執行 /provider setup 讓 Claude 帶你設定,或 /provider init 建立一個空的設定檔。',
610      }
611    }
612    const win = await isWindows($)
613
614    // Bare `/provider` opens the panel and says only that; `panel` toggles it
615    if (arg === '' || arg === 'panel') {
616      if (arg === 'panel' && (await read($, panelUi)).open) {
617        await update($, panelUi, (u) => ({ ...u, open: false, confirmingClaude: false, confirmingProxy: false }))
618        return { text: lang === 'en' ? 'Panel closed.' : '面板已關閉。' }
619      }
620      await openPanel($, cfg, await health($, cfg), lang)
621      const opened = lang === 'en' ? 'Panel open above the prompt (all commands: /provider help)' : '面板已開在輸入框上方(所有指令:/provider help)'
622      return { text: cfg.outdated ? `${outdatedText(lang)}\n${opened}` : opened }
623    }
624
625    if (arg === 'status') {
626      return { text: `${cfg.outdated ? `${outdatedText(lang)}\n\n` : ''}${providerReport(await health($, cfg), Date.now(), lang)}` }
627    }
628
629    if (arg === 'avatar') return { text: await toggleAvatar($, lang) }
630
631    if (arg === 'ping') {
632      await $.process.run(pingArgv(cfg, '', win), { timeoutMs: (PING_LIMIT_S + 5) * 1000 }).catch(() => null)
633      const h = await health($, cfg)
634      await syncPanelData($, cfg, h, lang)
635      return { text: pingReport(h, lang) }
636    }
637
638    if (arg === 'probe') {
639      const r = await $.process.run(probeArgv(cfg, win), { timeoutMs: 5000 }).catch(() => null)
640      await syncPanelData($, cfg, await health($, cfg), lang)
641      if (!r || r.exitCode !== 0) return { text: lang === 'en' ? 'Relay did not respond; nothing changed.' : '轉發站沒有回應,沒有改變。' }
642      return { text: lang === 'en' ? '✓ 429 cooldown reset; the next new session tries Claude.' : '✓ 已重置 429 冷卻,下一個新對話會先試 Claude。' }
643    }
644
645    const parts = arg.split(/\s+/).filter(Boolean)
646    const head = parts[0] ?? ''
647    // /provider avatar on|off: the switch every window shares, written to the config and followed here at once
648    if (head === 'avatar') {
649      const r = await $.process.run(setupCliArgv(cfg, setupScript($.plugin.root), ['set', 'avatarPane', parts[1] ?? '']), { timeoutMs: 15000 }).catch(() => null)
650      const out = ((r?.stdout || '') + (r?.stderr || '')).trim()
651      const cfgNext = (await readLocal($)) ?? cfg
652      await followAvatarSwitch($, cfgNext)
653      const none = cfgNext.avatarPane && Object.keys(cfgNext.avatars).length === 0
654      const hint = none ? (lang === 'en' ? '\nThere is no picture to show yet: add "avatars" to model-relay.json (see the README).' : '\n還沒有可顯示的頭像:在 model-relay.json 加上 "avatars"(見 README)。') : ''
655      return { text: (out || (lang === 'en' ? 'Setup command did not respond.' : '設定指令沒有回應。')) + hint }
656    }
657    if (head === 'alias' || head === 'failover' || head === 'retry' || head === 'rc' || head === 'proxy') {
658      const script = setupScript($.plugin.root)
659      let cliArgs: string[]
660      if (head === 'alias') {
661        cliArgs = ['alias', ...parts.slice(1)]
662      } else if (head === 'failover') {
663        const val = parts[1] ?? ''
664        const isBool = ['on', 'off', 'true', 'false', '1', '0'].includes(val)
665        cliArgs = val ? ['set', isBool ? 'failover.enabled' : 'failover.chain', val] : ['providers']
666      } else if (head === 'retry') {
667        cliArgs = parts[1] ? ['set', 'retry.enabled', parts[1]] : ['providers']
668      } else {
669        const val = parts[1] ?? 'status'
670        const sub = ['on', 'enable', 'true', '1'].includes(val)
671          ? 'enable'
672          : ['off', 'disable', 'false', '0'].includes(val)
673            ? 'disable'
674            : val
675        cliArgs = sub === 'enable' || sub === 'disable' ? ['proxy', sub, '--go'] : ['proxy', sub]
676      }
677      const r = await $.process.run(setupCliArgv(cfg, script, cliArgs), { timeoutMs: head === 'rc' || head === 'proxy' ? 60000 : 15000 }).catch(() => null)
678      const out = ((r?.stdout || '') + (r?.stderr || '')).trim()
679      const cfgNext = (await readLocal($)) ?? cfg
680      const h = await health($, cfgNext)
681      if (h) {
682        await showStatus($, statusText(h, false, lang), statusFields(h, false))
683        followAvatar($, h)
684      }
685      await syncPanelData($, cfgNext, h, lang)
686      return { text: out || (lang === 'en' ? 'Setup command did not respond.' : '設定指令沒有回應。') }
687    }
688
689    const h0 = await health($, cfg)
690    const providers = h0?.providers ?? {}
691    if (arg === 'claude' || has(providers, arg)) {
692      const r = await $.process.run(sessionArgv(cfg, await $.session.id(), arg, win), { timeoutMs: 8000 }).catch(() => null)
693      if (!r || r.exitCode !== 0 || !r.stdout.includes('"ok":true')) {
694        return { text: lang === 'en' ? 'Relay did not respond; nothing changed.' : '轉發站沒有回應,沒有改變。' }
695      }
696      const h = await health($, cfg)
697      if (h) {
698        await showStatus($, statusText(h, false, lang), statusFields(h, false))
699        followAvatar($, h)
700      }
701      await syncPanelData($, cfg, h, lang)
702      return { text: switchedText(arg, providers[arg] ?? arg, lang) }
703    }
704    const names = [...Object.keys(providers), 'claude'].join(lang === 'en' ? ', ' : '、')
705    return {
706      text: lang === 'en'
707        ? `No provider or command "${arg}". Switch with /provider <${names}>; all commands: /provider help`
708        : `沒有「${arg}」這個供應商或指令。切換用 /provider <${names}>;所有指令:/provider help`,
709    }
710  }).catch(() => ({ text: '/provider 執行失敗,設定未變更。' }))
711}
712
hooks/logic.ts 1002 lines
1import type { AvatarEntry, AvatarMode, AvatarView, PanelData, PingStat, ProviderStat } from '../types'
2
3// What the hook takes from the config file: where the router listens and which node runs it. Providers, aliases and the
4// failover chain come from the router's /__health only, so the plugin never second-guesses how the router reads a config.
5// outdated: the file is from an older version, which this version's router does not read (no "format": 2, or the older
6// file name); the hook then starts and replaces no router, so a router that still reads it keeps working until migrate
7// statusBar: also draw the relay state in Claude Code's plugin status line ("statusBar": true); off by default, since
8// the per-session status file (statusFields) lets a statusline tool show it instead
9// avatars: the pictures of the avatar pane, by persona (parseAvatars); the router ignores the key
10// avatarPane: open the avatar pane in every window ("avatarPane": true); off by default, so pictures in the config show
11// nothing until it is turned on (/provider avatar on)
12export interface LocalCfg {
13  port: number
14  nodePath: string
15  outdated: boolean
16  statusBar: boolean
17  avatars: Record<string, AvatarEntry>
18  avatarPane: boolean
19}
20
21export const CONFIG_NAME = 'model-relay.json'
22// legacy:begin the file name of 0.6.0 and earlier, read for its port and node only
23export const OLD_CONFIG_NAME = 'custom-agent.json'
24// legacy:end
25
26export function parseLocal(raw: unknown, oldName = false): LocalCfg | null {
27  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
28  const r = raw as Record<string, unknown>
29  const port = typeof r.port === 'number' && Number.isInteger(r.port) && r.port > 1024 && r.port < 65536 ? r.port : 8950
30  const nodePath = typeof r.nodePath === 'string' && r.nodePath.length > 0 ? r.nodePath : 'node'
31  return { port, nodePath, outdated: oldName || r.format !== 2, statusBar: r.statusBar === true, avatars: parseAvatars(r.avatars), avatarPane: r.avatarPane === true }
32}
33
34// The agent fields a provider's "agent" object in the config may set, with the shape each must have; `name` and `model`
35// are not among them: the type is named after the provider and runs on its model. Claude Code validates the rest.
36const isStr = (v: unknown) => typeof v === 'string' && v.trim().length > 0
37const isStrList = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === 'string')
38const AGENT_FIELDS: Record<string, (v: unknown) => boolean> = {
39  description: isStr,
40  prompt: isStr,
41  tools: isStrList,
42  disallowedTools: isStrList,
43  skills: isStrList,
44  effort: (v) => isStr(v) || (typeof v === 'number' && Number.isInteger(v) && v > 0),
45  permissionMode: isStr,
46  maxTurns: (v) => typeof v === 'number' && Number.isInteger(v) && v > 0,
47  autoCompactWindow: (v) => typeof v === 'number' && Number.isInteger(v) && v > 0,
48  initialPrompt: isStr,
49  memory: (v) => v === 'user' || v === 'project' || v === 'local',
50  isolation: (v) => v === 'worktree' || v === 'remote',
51  background: (v) => v === true,
52  omitClaudeMd: (v) => v === true,
53  mcpServers: (v) => Array.isArray(v) && v.every((x) => typeof x === 'string' || (typeof x === 'object' && x !== null && !Array.isArray(x))),
54  hooks: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
55}
56
57export type AgentOptions = Record<string, unknown>
58
59// Each provider's "agent" object from the config, by name, keeping only the fields above in the right shape
60export function providerAgentOptions(raw: unknown): Record<string, AgentOptions> {
61  const out: Record<string, AgentOptions> = {}
62  const providers = (raw as { providers?: unknown } | null)?.providers
63  if (typeof providers !== 'object' || providers === null || Array.isArray(providers)) return out
64  for (const [name, p] of Object.entries(providers as Record<string, unknown>)) {
65    const agent = (p as { agent?: unknown } | null)?.agent
66    if (typeof agent !== 'object' || agent === null || Array.isArray(agent)) continue
67    const kept: AgentOptions = {}
68    for (const [k, v] of Object.entries(agent as Record<string, unknown>)) {
69      if (has(AGENT_FIELDS, k) && AGENT_FIELDS[k]!(v)) kept[k] = v
70    }
71    if (Object.keys(kept).length) out[name] = kept
72  }
73  return out
74}
75
76export function outdatedText(lang: 'zh' | 'en' = 'zh'): string {
77  return lang === 'en'
78    ? 'Config from an older version, not read by this one: run /provider setup upgrade my model-relay config'
79    : '設定檔是舊版格式,這一版不讀它:執行 /provider setup 升級我的 model-relay 設定'
80}
81
82// A name typed by a person is looked up as an own key only: "constructor" is not a provider
83export const has = (o: Record<string, unknown>, k: string) => Object.hasOwn(o, k)
84
85const cells = (ch: string) => ((ch.codePointAt(0) ?? 0) >= 0x1100 ? 2 : 1)
86
87// Cuts text to a terminal width in cells (CJK counts two), ending in … when it had to cut
88export function fit(s: string, width: number): string {
89  const text = s.replace(/\s+/g, ' ').trim()
90  let used = 0
91  let out = ''
92  for (const ch of text) {
93    const w = cells(ch)
94    if (used + w > width - 1) return `${out}…`
95    used += w
96    out += ch
97  }
98  return out
99}
100
101// ---- local router ----
102
103export function curlBin(isWin = true): string {
104  return isWin ? 'C:/Windows/System32/curl.exe' : 'curl'
105}
106
107export const routerUrl = (cfg: LocalCfg) => `http://127.0.0.1:${cfg.port}`
108
109const healthUrl = (cfg: LocalCfg, sid: string) => `${routerUrl(cfg)}/__health?sid=${encodeURIComponent(sid)}`
110
111export function healthArgv(cfg: LocalCfg, sid: string, isWin = true): string[] {
112  return [curlBin(isWin), '-s', '-m', '2', healthUrl(cfg, sid)]
113}
114
115export function healthWaitArgv(cfg: LocalCfg, sid: string, isWin = true): string[] {
116  return [curlBin(isWin), '-s', '-m', '2', '--retry', '6', '--retry-connrefused', '--retry-delay', '1', healthUrl(cfg, sid)]
117}
118
119export function sessionArgv(cfg: LocalCfg, sid: string, on: string, isWin = true): string[] {
120  return [curlBin(isWin), '-s', '-m', '3', '-X', 'POST', '-H', 'content-type: application/json', '-d', JSON.stringify({ sid, on }), `${routerUrl(cfg)}/__session`]
121}
122
123// The router tests each provider for up to 15 s, after waiting up to 10 s for a local one it starts itself
124export const PING_LIMIT_S = 30
125
126export function pingArgv(cfg: LocalCfg, provider = '', isWin = true): string[] {
127  return [curlBin(isWin), '-s', '-m', String(PING_LIMIT_S), '-X', 'POST', '-H', 'content-type: application/json', '-d', JSON.stringify({ provider }), `${routerUrl(cfg)}/__ping`]
128}
129
130export function probeArgv(cfg: LocalCfg, isWin = true): string[] {
131  return [curlBin(isWin), '-s', '-m', '3', '-X', 'POST', `${routerUrl(cfg)}/__probe`]
132}
133
134const pluginBase = (pluginRoot: string) => pluginRoot.replace(/[\\/]\.claude-plugin[\\/]?$/, '').replace(/\\/g, '/')
135
136export const routerScript = (pluginRoot: string) => `${pluginBase(pluginRoot)}/router/router.js`
137
138export const setupScript = (pluginRoot: string) => `${pluginBase(pluginRoot)}/tools/setup.mjs`
139
140export type Toggle = 'failover' | 'retry' | 'proxy'
141
142// The panel and `/provider` subcommands change settings through the same CLI a person would use
143export function toggleArgv(cfg: LocalCfg, script: string, which: Toggle, on: boolean): string[] {
144  if (which === 'proxy') return [cfg.nodePath, script, 'proxy', on ? 'enable' : 'disable', '--go']
145  return [cfg.nodePath, script, 'set', which === 'failover' ? 'failover.enabled' : 'retry.enabled', on ? 'on' : 'off']
146}
147
148// The turn /provider setup hands to the model: where the CLI is, what the person asked for, and the rules that keep keys and settings safe.
149export function setupBrief(script: string, nodePath: string, goal: string, hasCfg: boolean, lang: 'zh' | 'en'): string {
150  const cli = `"${nodePath}" "${script}"`
151  return [
152    lang === 'en'
153      ? 'Help me set up the model-relay plugin for Claude Code. Reply in English.'
154      : '請幫我設定 Claude Code 的 model-relay 外掛,用繁體中文(台灣)回覆。',
155    goal ? `What I want: ${goal}` : 'What I want: a working setup with one provider; ask me which provider if it is not clear.',
156    '',
157    `The setup CLI is ${cli} (run it by this absolute path; it is not in the current folder). Run it with no arguments for usage, and "presets" for the built-in providers.`,
158    hasCfg
159      ? `A config already exists. Start with ${cli} doctor --json and ${cli} providers to see the current state; a finding's "fix" is the arguments to run this CLI with, and "needsConsent" means ask me first.`
160      : `There is no config yet. Start with ${cli} presets, then create it with "init --fix" and "add <name> --preset <preset> --model <model id> --token env:<VAR>" (or --url for any other Anthropic-compatible address). Ask me which model I want; there is no default.`,
161    '',
162    'Rules:',
163    '- Never ask me to paste an API key into this chat, and never print one. Use "env:VAR_NAME" tokens; tell me which variable to set, how to make it permanent on my OS, and that Claude Code must be restarted to see it.',
164    '- Commands that accept --go (migrate, remove, proxy): run without --go first, show me the result, and add --go only after I agree.',
165    '- Tell me before anything changes ~/.claude/settings.json (init --fix, doctor --fix, alias, proxy) and wait for my yes.',
166    `- If doctor lists the "outdated" finding, my config is from an older version: follow ${script.replace(/tools\/setup\.mjs$/, 'UPGRADING.md')} step by step.`,
167    `- Finish with ${cli} doctor --ping and tell me in plain words what works, what does not, and what I still have to do.`,
168  ].join('\n')
169}
170
171export function setupCliArgv(cfg: LocalCfg, script: string, cliArgs: string[]): string[] {
172  return [cfg.nodePath, script, ...cliArgs]
173}
174
175// What /provider init|doctor|presets|restart runs. init is setting up, so it wires settings.json; doctor only looks until the person types --fix
176export function setupCommandArgs(first: string, arg: string): string[] {
177  const typed = arg.split(/\s+/)
178  if (first === 'init') return ['init', '--fix']
179  if (first === 'doctor') return ['doctor', ...['--fix', '--ping'].filter((flag) => typed.includes(flag))]
180  return [first]
181}
182
183// Detaches the router process on both Windows and POSIX platforms.
184export function startArgv(cfg: LocalCfg, script: string, configDir: string, isWin = true): string[] {
185  if (isWin) {
186    const q = (s: string) => `'${s.replace(/'/g, "''")}'`
187    return [
188      'powershell.exe',
189      '-NoProfile',
190      '-Command',
191      `Start-Process -WindowStyle Hidden -FilePath ${q(cfg.nodePath)} -ArgumentList @(${q(`"${script}"`)}, ${q(`"${configDir}"`)})`,
192    ]
193  }
194  const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
195  return ['sh', '-c', `nohup ${shQuote(cfg.nodePath)} ${shQuote(script)} ${shQuote(configDir)} >/dev/null 2>&1 &`]
196}
197
198export interface Health {
199  session: string // 'claude' or a configured provider key
200  failoverSince: number
201  nextClaudeTry: number
202  sessionsOnCustom?: number
203  providers: Record<string, string> // provider name -> model id; empty when none is usable
204  aliases: Record<string, string>
205  failoverProvider?: string // absent when failover has nobody to hand over to
206  failoverChain: string[]
207  configDirs?: string[] // the directory this router serves, as it resolves it and as it was given; absent on older routers
208  version?: string // the plugin version the router's code came from; absent on 0.6.0 and earlier
209  inflight?: number // model and passthrough requests being answered right now; absent on 0.6.0 and earlier
210  tunnels?: number
211  stats?: Record<string, ProviderStat> // the last real request per provider
212  ping?: Record<string, PingStat> // the last test per provider; absent on older routers
213  seenModels?: Record<string, number> // when each model id last reached the router; absent on older routers
214  unresolved: { provider: string; env: string }[] // providers skipped because their env:VAR is unset for the router
215  // What the router is doing right now; absent when it predates the switches
216  switches?: { failover: boolean; retry: boolean; proxy: boolean; proxyReady: boolean }
217}
218
219const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
220
221export function parseHealth(stdout: string): Health | null {
222  try {
223    const j = JSON.parse(stdout) as Record<string, unknown>
224    if (!isObj(j) || j.ok !== true) return null
225    const num = (v: unknown) => (typeof v === 'number' ? v : 0)
226    const str = (v: unknown) => (typeof v === 'string' && v.length > 0 ? v : '')
227    const lowerMap = (v: unknown, lowerValues: boolean) => {
228      const out: Record<string, string> = {}
229      if (isObj(v)) for (const [k, x] of Object.entries(v)) if (str(x)) out[k.toLowerCase()] = lowerValues ? str(x).toLowerCase() : str(x)
230      return out
231    }
232    const stats: Record<string, ProviderStat> = {}
233    if (isObj(j.stats)) {
234      for (const [k, v] of Object.entries(j.stats)) if (isObj(v)) stats[k.toLowerCase()] = { status: num(v.status), ms: num(v.ms), at: num(v.at) }
235    }
236    let ping: Record<string, PingStat> | undefined
237    if (isObj(j.ping)) {
238      ping = {}
239      for (const [k, v] of Object.entries(j.ping)) {
240        if (isObj(v) && str(v.verdict)) ping[k.toLowerCase()] = { verdict: str(v.verdict), status: num(v.status), firstByteMs: num(v.firstByteMs), at: num(v.at), detail: str(v.detail) }
241      }
242    }
243    let seenModels: Record<string, number> | undefined
244    if (isObj(j.seenModels)) {
245      seenModels = {}
246      for (const [k, v] of Object.entries(j.seenModels)) if (typeof v === 'number') seenModels[k.toLowerCase()] = v
247    }
248    const failoverProvider = str(j.failoverProvider).toLowerCase()
249    const failoverChain = Array.isArray(j.failoverChain) ? j.failoverChain.map(str).filter(Boolean).map((n) => n.toLowerCase()) : []
250    const configDirs = [j.configDir, j.configDirArg].map(str).filter(Boolean)
251    const conf = isObj(j.config) ? j.config : null
252    const px = isObj(j.proxy) ? j.proxy : null
253    const switches = conf && typeof conf.failoverEnabled === 'boolean' && typeof conf.retryEnabled === 'boolean' && typeof conf.proxyEnabled === 'boolean'
254      ? { failover: conf.failoverEnabled, retry: conf.retryEnabled, proxy: conf.proxyEnabled, proxyReady: px?.certificate === true }
255      : undefined
256    const unresolved = Array.isArray(j.unresolved)
257      ? j.unresolved.filter(isObj).map((u) => ({ provider: str(u.provider), env: str(u.env) })).filter((u) => u.provider)
258      : []
259    return {
260      session: str(j.session).toLowerCase() || 'claude',
261      failoverSince: num(j.failoverSince),
262      nextClaudeTry: num(j.nextClaudeTry),
263      sessionsOnCustom: num(j.sessionsOnCustom),
264      providers: lowerMap(j.providers, false),
265      aliases: lowerMap(j.aliases, true),
266      ...(failoverProvider ? { failoverProvider } : {}),
267      failoverChain,
268      ...(configDirs.length > 0 ? { configDirs } : {}),
269      ...(str(j.version) ? { version: str(j.version) } : {}),
270      ...(typeof j.inflight === 'number' ? { inflight: j.inflight } : {}),
271      ...(typeof j.tunnels === 'number' ? { tunnels: j.tunnels } : {}),
272      stats,
273      ...(ping ? { ping } : {}),
274      ...(seenModels ? { seenModels } : {}),
275      unresolved,
276      ...(switches ? { switches } : {}),
277    }
278  } catch {
279    return null
280  }
281}
282
283// One line per thing the person should act on: an older config, a provider the router skips
284export function healthHints(h: Health | null, lang: 'zh' | 'en', outdated = false): string[] {
285  const en = lang === 'en'
286  const out = outdated ? [outdatedText(lang)] : []
287  for (const u of h ? h.unresolved : []) {
288    out.push(en
289      ? `${u.provider} is skipped: ${u.env ? `the relay was started without ${u.env}` : 'its token "env:" names no variable'}`
290      : `${u.provider} 被略過:${u.env ? `轉發站啟動時沒有 ${u.env}` : '它的 token「env:」沒寫變數名稱'}`)
291  }
292  return out
293}
294
295export function buildPanelData(h: Health | null, port: number, direct: boolean, lang: 'zh' | 'en', now: number, outdated = false): PanelData {
296  return {
297    online: Boolean(h),
298    direct,
299    port,
300    session: h?.session ?? 'claude',
301    failoverProvider: h?.failoverProvider ?? null,
302    failoverSince: h?.failoverSince ?? 0,
303    nextClaudeTry: h?.nextClaudeTry ?? 0,
304    sessionsOnCustom: h?.sessionsOnCustom ?? 0,
305    providers: h?.providers ?? {},
306    aliases: h?.aliases ?? {},
307    failoverChain: h?.failoverChain ?? [],
308    switches: h?.switches ?? null,
309    stats: h?.stats ?? {},
310    ping: h?.ping ?? {},
311    hints: healthHints(h, lang, outdated),
312    lang,
313    now,
314  }
315}
316
317// The directory of a router that answers on our port but serves a different config dir; null when it is ours.
318// A router that reports no directory is an older relay and still counts as ours.
319export function foreignConfigDir(h: Health | null, myDir: string, isWin = true): string | null {
320  if (!h || !h.configDirs || h.configDirs.length === 0) return null
321  const key = (d: string) => {
322    const s = d.replace(/\\/g, '/').replace(/\/+$/, '')
323    return isWin ? s.toLowerCase() : s
324  }
325  return h.configDirs.some((d) => key(d) === key(myDir)) ? null : h.configDirs[0]!
326}
327
328// -1, 0 or 1 for dotted numeric versions; a missing part counts as 0
329export function compareVersions(a: string, b: string): number {
330  const pa = a.split('.').map((n) => parseInt(n, 10) || 0)
331  const pb = b.split('.').map((n) => parseInt(n, 10) || 0)
332  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
333    const d = (pa[i] ?? 0) - (pb[i] ?? 0)
334    if (d !== 0) return d < 0 ? -1 : 1
335  }
336  return 0
337}
338
339// The router runs older code than this plugin: one from 0.6.0 or earlier (no configDir) or a lower version. A newer router is
340// left alone, so two sessions on different plugin versions never take turns restarting it.
341export function routerIsStale(h: Health | null, pluginVersion: string): boolean {
342  if (!h) return false
343  if (!h.configDirs || h.configDirs.length === 0) return true
344  if (!h.version || !pluginVersion) return false
345  return compareVersions(h.version, pluginVersion) < 0
346}
347
348export function staleStatusText(lang: 'zh' | 'en' = 'zh'): string {
349  return lang === 'en'
350    ? 'The relay runs older code; run /provider restart when no reply is in progress'
351    : '轉發站還在跑舊版程式;沒有回覆進行中時執行 /provider restart 更新'
352}
353
354export function foreignStatusText(dir: string, lang: 'zh' | 'en' = 'zh'): string {
355  return lang === 'en'
356    ? `Port is held by another relay (config dir ${dir}): connected directly to Claude`
357    : `連接埠被另一個轉發站(設定目錄 ${dir})佔用:主線暫時直連 Claude`
358}
359
360// The router was started and did not answer: say where its own log explains why, once per outage
361export function startFailedText(configDir: string, lang: 'zh' | 'en' = 'zh'): string {
362  return lang === 'en'
363    ? `The relay did not start, so this session talks to Claude directly; the reason is in ${configDir}/model-relay.log (or run /provider doctor)`
364    : `轉發站沒有啟動,這個對話暫時直連 Claude;原因記在 ${configDir}/model-relay.log(或執行 /provider doctor)`
365}
366
367export function statusText(h: Health | null, direct: boolean, lang: 'zh' | 'en' = 'zh'): string {
368  if (!h) {
369    if (lang === 'en') return direct ? 'Relay offline: connected directly to Claude' : 'Relay offline, restarting…'
370    return direct ? '轉發站離線:主線暫時直連 Claude,model-relay 子代理無法使用' : '轉發站離線,正在重啟…'
371  }
372  const pinned = h.session !== 'claude' && has(h.providers, h.session) ? h.providers[h.session] : undefined
373  if (pinned) return lang === 'en' ? `Main: ${pinned} (pinned for this window)` : `主線:${pinned}(這個視窗固定用它)`
374  if (h.failoverSince) {
375    const to = h.failoverProvider ? h.providers[h.failoverProvider] ?? h.failoverProvider : ''
376    if (to) return lang === 'en' ? `Claude limit reached: routing to ${to}` : `Claude 額度用完:下一個請求起改用 ${to}`
377    return lang === 'en' ? 'Claude limit reached' : 'Claude 額度用完'
378  }
379  return lang === 'en' ? 'Main: Claude' : '主線:Claude'
380}
381
382// The relay state of this session for statusline tools (coralline's relay segment), written by the hook to
383// <config dir>/model-relay-status/<session id> as one line "kind<TAB>label":
384//   claude   <tab> claude     this session goes to Claude through the relay
385//   pinned   <tab> <model>    pinned to a provider (/provider, or a failover that pinned it)
386//   failover <tab> <model>    Claude's limit is reached; the next request goes to that model
387//   limit    <tab> claude     Claude's limit is reached and the failover chain has nobody to hand over to
388//   offline  <tab> offline    no router of ours answers for this window
389//   direct   <tab> claude     no router of ours answers, so this window talks to Claude directly
390// foreign: the port answers for another config dir's router, which this window never counts as its own
391export function statusFields(h: Health | null, direct: boolean, foreign = false): [string, string] {
392  if (foreign || !h) return direct ? ['direct', 'claude'] : ['offline', 'offline']
393  const pinned = h.session !== 'claude' && has(h.providers, h.session) ? h.providers[h.session] : undefined
394  if (pinned) return ['pinned', pinned]
395  if (h.failoverSince) {
396    const to = h.failoverProvider ? h.providers[h.failoverProvider] ?? h.failoverProvider : ''
397    return to ? ['failover', to] : ['limit', 'claude']
398  }
399  return ['claude', 'claude']
400}
401
402// The status file's path and line; null for a session id that is not a plain file name. Tabs and line breaks in a
403// model id would split the line, so they become spaces.
404export function statusFile(configDir: string, sid: string, fields: [string, string]): { path: string; line: string } | null {
405  if (!/^[A-Za-z0-9._-]+$/.test(sid) || sid === '.' || sid === '..') return null
406  const clean = (s: string) => s.replace(/[\t\r\n]+/g, ' ')
407  return { path: `${configDir}/model-relay-status/${sid}`, line: `${clean(fields[0])}\t${clean(fields[1])}\n` }
408}
409
410// ---- avatar pane ----
411
412const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
413
414// The head square of an avatar entry, [centre x, centre y, side] in whole source pixels; null for anything else
415function parseHead(v: unknown): [number, number, number] | null {
416  if (!Array.isArray(v) || v.length !== 3 || !v.every(isNum)) return null
417  const [x, y, side] = v.map(Math.round) as [number, number, number]
418  return x >= 0 && y >= 0 && side > 0 ? [x, y, side] : null
419}
420
421// The config's "avatars": a persona name (claude or a provider, lower case like the router's) to an image path, or to
422// { image, head }. An entry without a usable image is dropped; a head that is not three numbers is dropped alone, so the
423// picture still shows.
424export function parseAvatars(raw: unknown): Record<string, AvatarEntry> {
425  if (!isObj(raw)) return {}
426  const entries: [string, AvatarEntry][] = []
427  for (const [key, v] of Object.entries(raw)) {
428    const name = key.trim().toLowerCase()
429    const image = typeof v === 'string' ? v : isObj(v) ? v.image : undefined
430    if (!name || typeof image !== 'string' || !image.trim()) continue
431    entries.push([name, { image: image.trim(), head: isObj(v) ? parseHead(v.head) : null }])
432  }
433  return Object.fromEntries(entries)
434}
435
436// The persona of this window for the avatar pane, by statusFields' rules: the pinned provider, else the one a Claude
437// limit handed the window to, else Claude (also with no router answering for it)
438export function avatarName(h: Health | null, foreign = false): string {
439  if (foreign || !h) return 'claude'
440  if (h.session !== 'claude' && has(h.providers, h.session)) return h.session
441  if (h.failoverSince && h.failoverProvider) return h.failoverProvider
442  return 'claude'
443}
444
445export function avatarLabel(name: string, providers: Record<string, string>): string {
446  if (name === 'claude') return 'Claude'
447  return has(providers, name) ? `${name} · ${providers[name]}` : name
448}
449
450// What the avatar pane is asked to show: the persona and the pane body the picture goes in (null until the pane has drawn
451// once). The pane shows the head square, or the whole picture for a persona with none.
452export type AvatarBody = { placement: 'dock' | 'inline'; columns: number; rows: number }
453export type AvatarAim = { name: string; label: string; body: AvatarBody | null }
454export type AvatarWant = Omit<AvatarView, 'cells' | 'error'>
455
456export const AVATAR_START: AvatarAim = { name: 'claude', label: 'Claude', body: null }
457export const AVATAR_EMPTY: AvatarView = { name: '', label: '', entry: null, mode: 'full', cols: 0, rows: 0, cells: null, error: null }
458
459// The picture's box in cells: as wide as the pane's body and as tall as it leaves after the label row under the picture.
460// The picture is fitted inside it at its true shape, a cell being
461// taken as 10 x 20 pixels. Inline above the prompt it stays short, to leave the transcript its room. 0 x 0 when nothing fits.
462export const AVATAR_MAX_COLS = 160
463export const AVATAR_MAX_ROWS = 100
464export const AVATAR_INLINE_ROWS = 12
465
466export const AVATAR_LABEL_ROWS = 1
467
468export function avatarSize(placement: 'dock' | 'inline', bodyColumns: number, bodyRows: number, labelRows: number): { cols: number; rows: number } {
469  const cols = Math.min(Math.floor(bodyColumns), AVATAR_MAX_COLS)
470  const rows = Math.min(Math.floor(bodyRows) - labelRows, placement === 'inline' ? AVATAR_INLINE_ROWS : AVATAR_MAX_ROWS)
471  return cols >= 1 && rows >= 1 ? { cols, rows } : { cols: 0, rows: 0 }
472}
473
474export function avatarWanted(aim: AvatarAim, entries: Record<string, AvatarEntry>): AvatarWant {
475  const entry = has(entries, aim.name) ? entries[aim.name]! : null
476  const { body } = aim
477  const size = body ? avatarSize(body.placement, body.columns, body.rows, AVATAR_LABEL_ROWS) : { cols: 0, rows: 0 }
478  return { name: aim.name, label: aim.label, entry, mode: entry?.head ? 'head' : 'full', ...size }
479}
480
481// A view is current when it was made from exactly what is wanted now, config entry included
482export const avatarMade = (v: AvatarView, w: AvatarWant): boolean =>
483  JSON.stringify([v.name, v.label, v.entry, v.mode, v.cols, v.rows]) === JSON.stringify([w.name, w.label, w.entry, w.mode, w.cols, w.rows])
484
485// Config paths are relative to the config directory unless they start from a root
486export function avatarSource(configDir: string, image: string): string {
487  const path = image.replace(/\\/g, '/')
488  return /^([A-Za-z]:)?\//.test(path) ? path : `${configDir}/${path}`
489}
490
491// One scratch file per window, so two windows converting at once never share an output
492export const avatarScratch = (configDir: string, sid: string): string => `${configDir}/model-relay-avatars/.cache/${sid.replace(/[^A-Za-z0-9_-]/g, '_') || 'window'}.rgba`
493
494const SAMPLES = 8 // samples across and down one cell: what its glyph and colors are judged on
495
496// How far the head crop may narrow from its square to take a tall box's shape (or flatten, for a wide one): further
497// would cut the hair beside the face
498export const AVATAR_MIN_SIDE = 0.75
499
500// The head crop in the box's shape, [width, height, centre x, centre y] in source pixels: narrowed (or flattened) toward
501// the box's shape no further than AVATAR_MIN_SIDE of the square, then grown along the other side to the box's shape, so
502// the face fills the box with no bands and is larger than the square alone would draw it
503export function avatarCrop(head: [number, number, number], cols: number, rows: number): [number, number, number, number] {
504  const [x, y, side] = head
505  const aspect = (cols * 10) / (rows * 20)
506  if (aspect < 1) {
507    const w = Math.round(side * Math.max(aspect, AVATAR_MIN_SIDE))
508    return [w, Math.round(w / aspect), x, y]
509  }
510  const h = Math.round(side * Math.max(1 / aspect, AVATAR_MIN_SIDE))
511  return [Math.round(h * aspect), h, x, y]
512}
513
514// The video filter that makes the box's samples: the head crop first when asked for (held inside the picture, its quoted
515// expressions keeping their commas from the filter chain), then the image fitted inside the box at its true shape with
516// transparent padding (a cell is taken to be 10 x 20 screen pixels), then scaled to the 8 x 8 samples each cell is judged
517// on, sharpened a little and given a little more color
518export function avatarFilter(mode: AvatarMode, head: [number, number, number] | null, cols: number, rows: number): string {
519  const [w, h] = [cols * 10, rows * 20]
520  let crop = ''
521  if (mode === 'head' && head) {
522    const [cw, ch, cx, cy] = avatarCrop(head, cols, rows)
523    crop = `crop=w='min(${cw},iw)':h='min(${ch},ih)':x='max(0,min(${cx}-ow/2,iw-ow))':y='max(0,min(${cy}-oh/2,ih-oh))',`
524  }
525  return `${crop}scale=${w}:${h}:force_original_aspect_ratio=decrease:flags=area,format=rgba,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,scale=${cols * SAMPLES}:${rows * SAMPLES}:flags=area,unsharp=3:3:0.6,eq=saturation=1.12:contrast=1.04`
526}
527
528// The bytes of the raw RGBA frame the filter makes
529export const avatarBytes = (cols: number, rows: number): number => cols * SAMPLES * rows * SAMPLES * 4
530
531// ffmpeg writes one raw RGBA frame of cols*8 x rows*8 samples to `out`
532export function avatarArgv(input: string, out: string, mode: AvatarMode, head: [number, number, number] | null, cols: number, rows: number): string[] {
533  return ['ffmpeg', '-v', 'error', '-y', '-i', input, '-vf', avatarFilter(mode, head, cols, rows), '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgba', out]
534}
535
536const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
537
538// Standard padded base64, written out because the engine's environment and plain Node do not share a built-in for it
539export function bytesToBase64(bytes: Uint8Array): string {
540  let out = ''
541  for (let i = 0; i < bytes.length; i += 3) {
542    const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
543    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + (i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? B64[n & 63]! : '=')
544  }
545  return out
546}
547
548export function base64ToBytes(text: string): Uint8Array {
549  const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
550  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
551  let acc = 0
552  let bits = 0
553  let at = 0
554  for (const ch of clean) {
555    acc = (acc << 6) | B64.indexOf(ch)
556    bits += 6
557    if (bits >= 8) {
558      bits -= 8
559      out[at++] = (acc >> bits) & 255
560      acc &= (1 << bits) - 1
561    }
562  }
563  return out
564}
565
566const CELL_DEFAULT = 0x01000000
567
568// A cell's 8 x 8 samples are summed as rows, columns and quarters (ROW + y, COL + x, QUARTER + q, left to right and top to
569// bottom): every glyph paints a union of these as its foreground, so what it paints and what it leaves come from a few sums
570// rather than from the 64 samples again
571const [ROW, COL, QUARTER] = [0, SAMPLES, 2 * SAMPLES]
572const GROUPS = 2 * SAMPLES + 4
573// Per group: samples, opaque samples (alpha 128 and up), the sums of r, g, b over the opaque ones, the sums of their squares
574const [N, SOLID, SUM, SQ, STAT] = [0, 1, 2, 5, 8]
575
576const range = (from: number, to: number): number[] => Array.from({ length: to - from }, (_, i) => from + i)
577
578// A glyph a cell may be drawn with, and the groups it paints as its foreground. `flip` is the glyph that paints the rest
579// instead, for a cell whose see-through part has to be the background: the terminal's default foreground is its text color,
580// so a glyph never carries it. A shape with no such glyph is left out for such a cell.
581type Shape = { cp: number; flip: number | null; parts: number[] }
582
583// Block shapes only: the full block, lower and left eighths, the quadrants. No triangles and no shades: both read as grain.
584const SHAPES: Shape[] = [
585  { cp: 0x2588, flip: 0x20, parts: range(ROW, ROW + SAMPLES) },
586  ...[1, 2, 3, 4, 5, 6, 7].map((k) => ({ cp: 0x2580 + k, flip: k === 4 ? 0x2580 : k === 7 ? 0x2594 : null, parts: range(ROW + SAMPLES - k, ROW + SAMPLES) })),
587  ...[1, 2, 3, 4, 5, 6, 7].map((k) => ({ cp: 0x2590 - k, flip: k === 4 ? 0x2590 : k === 7 ? 0x2595 : null, parts: range(COL, COL + k) })),
588  { cp: 0x2598, flip: 0x259f, parts: [QUARTER] },
589  { cp: 0x259d, flip: 0x2599, parts: [QUARTER + 1] },
590  { cp: 0x2596, flip: 0x259c, parts: [QUARTER + 2] },
591  { cp: 0x2597, flip: 0x259b, parts: [QUARTER + 3] },
592  { cp: 0x259a, flip: 0x259e, parts: [QUARTER, QUARTER + 3] },
593  { cp: 0x259e, flip: 0x259a, parts: [QUARTER + 1, QUARTER + 2] },
594]
595
596// A Raster paints 1024 distinct color pairs exactly and the rest as their nearest, so the palette is the largest of these
597// whose cells use at most that many (31 colors and the default make 32 x 32, which never goes over)
598const PALETTE_SIZES = [192, 160, 128, 96, 72, 56, 44, 31]
599const MAX_PAIRS = 1024
600// A box with more cells than this starts the search at 160 colors, to bound the work
601const BIG_BOX_CELLS = 2200
602const WEIGHTS = [0.3, 0.59, 0.11]
603// A sample that is painted over, or left without a color, counts as this much error (half of 255 squared)
604const HALF_ERR = 65025 * 0.5
605// A shape has to beat the best so far by this much: a flat cell fits every shape equally, up to rounding, and stays the
606// full block (one color pair) instead of one of the others at random
607const TIE_ERR = 1e-3
608
609// Median cut over the opaque samples (every fourth pixel): the palette after each of `sizes` splits, as flat r, g, b
610// lists. The splits run once, since a smaller palette is the same cut stopped earlier.
611function medianCut(rgba: Uint8Array, sizes: number[]): Map<number, number[]> {
612  const rgb: number[] = []
613  for (let i = 0; i < rgba.length; i += 16) if ((rgba[i + 3] ?? 0) >= 128) rgb.push(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
614  type Box = { items: number[]; span: number; channel: number }
615  const measure = (items: number[]): Box => {
616    let span = -1
617    let channel = 0
618    if (items.length >= 2) {
619      for (let c = 0; c < 3; c++) {
620        let [lo, hi] = [255, 0]
621        for (const i of items) {
622          const v = rgb[i * 3 + c]!
623          if (v < lo) lo = v
624          if (v > hi) hi = v
625        }
626        if (hi - lo > span) [span, channel] = [hi - lo, c]
627      }
628    }
629    return { items, span, channel }
630  }
631  const mean = (boxes: Box[]): number[] =>
632    boxes.filter((b) => b.items.length).flatMap((b) => [0, 1, 2].map((c) => b.items.reduce((s, i) => s + rgb[i * 3 + c]!, 0) / b.items.length))
633  const out = new Map<number, number[]>()
634  const boxes = [measure(Array.from({ length: rgb.length / 3 }, (_, i) => i))]
635  for (const n of [...sizes].sort((a, b) => a - b)) {
636    while (boxes.length < n) {
637      let pick = -1
638      boxes.forEach((b, i) => {
639        if (b.span >= 0 && (pick < 0 || b.span > boxes[pick]!.span)) pick = i
640      })
641      if (pick < 0) break
642      const { items, channel } = boxes[pick]!
643      const sorted = [...items].sort((p, q) => rgb[p * 3 + channel]! - rgb[q * 3 + channel]!)
644      boxes.splice(pick, 1, measure(sorted.slice(0, sorted.length >> 1)), measure(sorted.slice(sorted.length >> 1)))
645    }
646    out.set(n, mean(boxes))
647  }
648  return out
649}
650
651type Snap = (r: number, g: number, b: number) => number[]
652
653// The cells as u32 triplets [code point, foreground, background], and how many distinct color pairs they use
654type Painted = { words: Uint32Array; pairs: number }
655
656// What one side of a glyph costs, from the sums of its samples: the error it leaves, and the color to paint it in
657// `colors[at]`. A side mostly see-through is the terminal's default color; the rest take the palette's color nearest their
658// mean. Without a `snap` the color is the mean itself, and the error the least any color leaves: a floor under the real one.
659function judge(v: Float64Array, snap: Snap | null, colors: number[], at: number): number {
660  const total = v[N]!
661  const solid = v[SOLID]!
662  if (solid * 2 < total || total === 0) {
663    colors[at] = CELL_DEFAULT
664    return solid * HALF_ERR
665  }
666  let r = v[SUM]! / solid
667  let g = v[SUM + 1]! / solid
668  let b = v[SUM + 2]! / solid
669  if (snap) {
670    const near = snap(r, g, b)
671    r = near[0]!
672    g = near[1]!
673    b = near[2]!
674  }
675  colors[at] = (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
676  let err = (total - solid) * HALF_ERR
677  err += WEIGHTS[0]! * (v[SQ]! - 2 * r * v[SUM]! + solid * r * r)
678  err += WEIGHTS[1]! * (v[SQ + 1]! - 2 * g * v[SUM + 1]! + solid * g * g)
679  err += WEIGHTS[2]! * (v[SQ + 2]! - 2 * b * v[SUM + 2]! + solid * b * b)
680  return err
681}
682
683const [whole, painted, rest] = [new Float64Array(STAT), new Float64Array(STAT), new Float64Array(STAT)]
684const picked = [0, 0]
685
686// The glyph and the two colors with the least error for one cell, from its groups' sums: [code point, foreground, background]
687function bestCell(groups: Float64Array, snap: Snap): [number, number, number] {
688  whole.fill(0)
689  for (let g = ROW; g < ROW + SAMPLES; g++) for (let j = 0; j < STAT; j++) whole[j]! += groups[g * STAT + j]!
690  let best = Infinity
691  let cell: [number, number, number] = [0x20, CELL_DEFAULT, CELL_DEFAULT]
692  for (const { cp, flip, parts } of SHAPES) {
693    painted.fill(0)
694    for (const g of parts) for (let j = 0; j < STAT; j++) painted[j]! += groups[g * STAT + j]!
695    for (let j = 0; j < STAT; j++) rest[j] = whole[j]! - painted[j]!
696    // A shape that cannot beat the best even with the exact mean for colors is not worth finding its palette colors
697    if (judge(rest, null, picked, 0) + judge(painted, null, picked, 1) >= best - TIE_ERR) continue
698    const err = judge(rest, snap, picked, 0) + judge(painted, snap, picked, 1)
699    if (err >= best - TIE_ERR) continue
700    const bg = picked[0]!
701    const fg = picked[1]!
702    if (fg !== CELL_DEFAULT) [best, cell] = [err, [cp, fg, bg]]
703    else if (bg === CELL_DEFAULT) [best, cell] = [err, [0x20, CELL_DEFAULT, CELL_DEFAULT]]
704    else if (flip !== null) [best, cell] = [err, [flip, bg, CELL_DEFAULT]]
705  }
706  return cell
707}
708
709function paintCells(rgba: Uint8Array, cols: number, rows: number, palette: number[]): Painted {
710  const snap: Snap = (r, g, b) => {
711    let best = Infinity
712    let near = -1
713    for (let i = 0; i < palette.length; i += 3) {
714      let d = palette[i]! - r
715      d = WEIGHTS[0]! * (d * d)
716      if (d >= best) continue
717      let e = palette[i + 1]! - g
718      d += WEIGHTS[1]! * (e * e)
719      if (d >= best) continue
720      e = palette[i + 2]! - b
721      d += WEIGHTS[2]! * (e * e)
722      if (d < best) {
723        best = d
724        near = i
725      }
726    }
727    return near < 0 ? [r, g, b] : [palette[near]!, palette[near + 1]!, palette[near + 2]!]
728  }
729  const words = new Uint32Array(cols * rows * 3)
730  const pairs = new Set<number>()
731  const groups = new Float64Array(GROUPS * STAT)
732  // The sample at byte `i` of the frame, opaque or not, counted into a group
733  const add = (group: number, i: number, solid: number): void => {
734    const at = group * STAT
735    const r = solid * rgba[i]!
736    const g = solid * rgba[i + 1]!
737    const b = solid * rgba[i + 2]!
738    groups[at + N]!++
739    groups[at + SOLID]! += solid
740    groups[at + SUM]! += r
741    groups[at + SUM + 1]! += g
742    groups[at + SUM + 2]! += b
743    groups[at + SQ]! += r * r
744    groups[at + SQ + 1]! += g * g
745    groups[at + SQ + 2]! += b * b
746  }
747  const width = cols * SAMPLES
748  for (let cy = 0; cy < rows; cy++) {
749    for (let cx = 0; cx < cols; cx++) {
750      groups.fill(0)
751      for (let y = 0; y < SAMPLES; y++) {
752        for (let x = 0; x < SAMPLES; x++) {
753          const i = ((cy * SAMPLES + y) * width + cx * SAMPLES + x) * 4
754          const solid = (rgba[i + 3] ?? 0) >= 128 ? 1 : 0
755          const quarter = (y >= SAMPLES / 2 ? 2 : 0) + (x >= SAMPLES / 2 ? 1 : 0)
756          add(ROW + y, i, solid)
757          add(COL + x, i, solid)
758          add(QUARTER + quarter, i, solid)
759        }
760      }
761      const [cp, fg, bg] = bestCell(groups, snap)
762      words.set([cp, fg, bg], (cy * cols + cx) * 3)
763      pairs.add(fg * 0x2000000 + bg)
764    }
765  }
766  return { words, pairs: pairs.size }
767}
768
769// RGBA samples (cols*8 wide, rows*8 tall) as cells: per cell the block glyph and two colors that match its 8 x 8 samples
770// best, every color snapped to a median-cut palette of the largest size whose cells use at most 1024 distinct color pairs
771export function glyphCells(rgba: Uint8Array, cols: number, rows: number): Painted {
772  const sizes = cols * rows > BIG_BOX_CELLS ? PALETTE_SIZES.filter((n) => n <= 160) : PALETTE_SIZES
773  const palettes = medianCut(rgba, sizes)
774  let made: Painted = { words: new Uint32Array(0), pairs: 0 }
775  let tried = -1
776  for (const n of sizes) {
777    const palette = palettes.get(n)!
778    if (palette.length === tried) continue // the cut ran out of colors: this is the palette just tried
779    tried = palette.length
780    made = paintCells(rgba, cols, rows, palette)
781    if (made.pairs <= MAX_PAIRS) break
782  }
783  return made
784}
785
786// The cells of glyphCells as a Raster takes them: little-endian u32 triplets [code point, foreground, background], base64
787export function rasterCells(rgba: Uint8Array, cols: number, rows: number): string {
788  const { words } = glyphCells(rgba, cols, rows)
789  const view = new DataView(new ArrayBuffer(words.length * 4))
790  words.forEach((word, i) => view.setUint32(i * 4, word, true))
791  return bytesToBase64(new Uint8Array(view.buffer))
792}
793
794export function fmtLatency(ms: number): string {
795  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
796}
797
798// The last test of a provider, in a few words, kept apart from the last real request
799export function pingText(p: PingStat | undefined, lang: 'zh' | 'en'): { text: string; color: string } | null {
800  if (!p) return null
801  const en = lang === 'en'
802  if (p.verdict === 'ok') return { text: `${en ? 'test ok' : '測試正常'} ${fmtLatency(p.firstByteMs)}`, color: 'green' }
803  const words: Record<string, [string, string]> = {
804    key: ['key refused', '金鑰被拒'],
805    model: ['unknown model', '模型名稱不對'],
806    quota: ['quota or rate limit', '額度用完或被限流'],
807    'not-running': ['local service not running', '本機服務沒開'],
808    unreachable: ['unreachable', '連不到'],
809    timeout: ['timed out', '逾時'],
810  }
811  const w = has(words, p.verdict) ? words[p.verdict]! : [`HTTP ${p.status}`, `HTTP ${p.status}`]
812  return { text: `${en ? 'test' : '測試'}: ${en ? w[0] : w[1]}`, color: 'red' }
813}
814
815export function statText(st: ProviderStat | undefined, lang: 'zh' | 'en'): { text: string; color: string } | null {
816  if (!st || !st.at) return null
817  const head = lang === 'en' ? 'last' : '上次'
818  if (st.status >= 200 && st.status < 300) return { text: `${head} ${st.status} · ${fmtLatency(st.ms)}`, color: 'green' }
819  if (st.status === 0) return { text: `${head} ${lang === 'en' ? 'no connection' : '未連線'}`, color: 'red' }
820  return { text: `${head} HTTP ${st.status}`, color: 'red' }
821}
822
823// What one switch came to, in one line: the status line and the panel already show the rest
824export function switchedText(target: string, model: string, lang: 'zh' | 'en' = 'zh'): string {
825  if (target === 'claude') {
826    return lang === 'en'
827      ? '✓ This window is back on Claude (the next turn rebuilds the prompt cache)'
828      : '✓ 這個視窗切回 Claude(下一輪會重建一次快取)'
829  }
830  return lang === 'en' ? `✓ This window now uses ${target} (${model})` : `✓ 這個視窗改用 ${target}(${model})`
831}
832
833// One row per route, each written as the command that picks it
834function routeRows(h: Health, lang: 'zh' | 'en', withClaude: boolean): string[] {
835  const en = lang === 'en'
836  const active = h.session || 'claude'
837  const rows: string[] = []
838  if (withClaude) {
839    const desc = active === 'claude'
840      ? (en ? 'Anthropic Claude  ← in use' : 'Anthropic Claude  ← 使用中')
841      : (en ? 'Anthropic Claude (switching back rebuilds the prompt cache)' : 'Anthropic Claude(切回時長對話會重建一次快取)')
842    rows.push(`  ${active === 'claude' ? '●' : '○'} /provider ${'claude'.padEnd(8)} ${desc}`)
843  }
844  for (const [k, m] of Object.entries(h.providers)) {
845    const notes = [statText(h.stats?.[k], lang), pingText(h.ping?.[k], lang)].filter((x) => x !== null).map((x) => x.text)
846    const noteText = notes.length ? (en ? ` (${notes.join(' · ')})` : `(${notes.join(' · ')})`) : ''
847    const mine = active === k ? (en ? '  ← in use' : '  ← 使用中') : ''
848    rows.push(`  ${active === k ? '●' : '○'} /provider ${k.padEnd(8)} ${m}${noteText}${mine}`)
849  }
850  if (Object.keys(h.providers).length === 0) {
851    rows.push(en
852      ? '  (no provider is usable yet: every request goes to Claude; /provider setup adds one)'
853      : '  (還沒有可用的供應商:所有請求都走 Claude;/provider setup 可以新增)')
854  }
855  return rows
856}
857
858// The switches every window shares, and where each Claude alias goes, on one line
859function sharedLine(h: Health, lang: 'zh' | 'en'): string {
860  const en = lang === 'en'
861  const sw = h.switches
862  const onOff = (on: boolean) => (on ? `● ${en ? 'on' : '開啟'}` : `○ ${en ? 'off' : '關閉'}`)
863  const foOn = sw ? sw.failover : true
864  const chain = h.failoverChain.length ? h.failoverChain.join(' → ') : en ? 'nobody to hand over to' : '沒有接手者'
865  const parts = [
866    `${en ? '429 failover' : '429 備援'} ${onOff(foOn)}${foOn ? (en ? ` (${chain})` : `(${chain})`) : ''}`,
867    `${en ? 'Retry' : '失敗重試'} ${onOff(!sw || sw.retry)}`,
868    `${en ? 'Remote Control' : '手機遙控'} ${onOff(!!sw?.proxy)}`,
869  ]
870  const aliases = Object.entries(h.aliases).filter(([, p]) => has(h.providers, p)).map(([slot, p]) => `${slot} → ${p}`)
871  if (aliases.length) parts.push(`${en ? 'Aliases' : '別名'} ${aliases.join(en ? ', ' : '、')}`)
872  return `${en ? 'Shared by every window: ' : '所有視窗共用:'}${parts.join(en ? ' · ' : '・')}`
873}
874
875// /provider status: where this window goes, what every window shares, and what to fix; the commands are in /provider help
876export function providerReport(h: Health | null, now: number, lang: 'zh' | 'en' = 'zh'): string {
877  const en = lang === 'en'
878  if (!h) {
879    return en
880      ? 'Relay is not responding; auto-restarting every 10s.'
881      : '轉發站沒有回應;每 10 秒會自動重啟,稍後再試。'
882  }
883  let head = statusText(h, false, lang)
884  if (h.failoverSince) {
885    const mins = Math.max(0, Math.round((h.nextClaudeTry - now) / 60000))
886    head += en
887      ? ` (retrying Claude in ~${mins}m; only new sessions switch back)`
888      : `(約 ${mins} 分鐘後再試 Claude,恢復後只有新對話回到 Claude)`
889  }
890  return [
891    head,
892    sharedLine(h, lang),
893    ...healthHints(h, lang).map((x) => `⚠ ${x}`),
894    '',
895    en ? 'Switch this window:' : '切換這個視窗:',
896    ...routeRows(h, lang, true),
897    '',
898    en ? 'All commands: /provider help' : '所有指令:/provider help',
899  ].join('\n')
900}
901
902// /provider ping: each provider with what its test came to
903export function pingReport(h: Health | null, lang: 'zh' | 'en' = 'zh'): string {
904  if (!h) return providerReport(null, 0, lang)
905  return [lang === 'en' ? 'Test results:' : '測試結果:', ...routeRows(h, lang, false)].join('\n')
906}
907
908// /provider help: every command, grouped by what it reaches
909export function helpText(providers: string[], lang: 'zh' | 'en' = 'zh'): string {
910  const en = lang === 'en'
911  const groups: [string, [string, string][]][] = [
912    [en ? 'This window:' : '這個視窗:', [
913      ['/provider', en ? "Open the panel to switch this window's model" : '開啟面板,切換這個視窗的模型'],
914      ['/provider <name> | claude', en ? 'Switch this window directly (e.g. /provider claude)' : '直接切換這個視窗(例:/provider claude)'],
915      ['/provider status', en ? 'The same status as text' : '文字版狀態'],
916    ]],
917    [en ? 'Shared by every window:' : '所有視窗共用:', [
918      ['/provider failover <on|off|a,b>', en ? 'Turn 429 failover on or off, or set who takes over' : '開關 429 備援或設定接手順序'],
919      ['/provider retry <on|off>', en ? 'Turn automatic provider retries on or off' : '開關供應商失敗自動重試'],
920      ['/provider rc <on|off>', en ? 'Turn Remote Control proxy mode on or off (new sessions)' : '開關手機遙控代理模式(新對話生效)'],
921      ['/provider alias [haiku <name|claude>]', en ? 'View or point a Claude model alias' : '查看或設定別名分流'],
922      ['/provider probe', en ? 'Reset the 429 cooldown so the next new session tries Claude' : '重置 429 冷卻,下一個新對話先試 Claude'],
923    ]],
924    [en ? 'Setup and checks:' : '設定與檢查:', [
925      ['/provider ping', en ? 'Test every provider: does it answer, and how soon' : '測試每個供應商:能不能回應、多快開始回應'],
926      ['/provider setup [goal]', en ? 'Let Claude walk you through setup (e.g. /provider setup add deepseek)' : '讓 Claude 帶你設定(例:/provider setup 加上 deepseek)'],
927      ['/provider doctor [--ping] [--fix]', en ? 'Check the config, Claude Code settings and the relay; changes nothing unless --fix (wires settings.json)' : '檢查設定檔、Claude Code 設定與轉發站;不加 --fix 不會改任何東西(--fix 會設定 settings.json)'],
928      ['/provider restart', en ? 'Restart the relay so it runs this plugin version' : '重啟轉發站,讓它跑這個外掛版本的程式'],
929      ['/provider init', en ? 'Create an empty config' : '建立空的設定檔'],
930    ]],
931  ]
932  const width = Math.max(...groups.flatMap(([, rows]) => rows.map(([cmd]) => cmd.length)))
933  const lines = groups.flatMap(([title, rows]) => [title, ...rows.map(([cmd, desc]) => `  ${cmd.padEnd(width)}  ${desc}`)])
934  if (providers.length) {
935    const names = providers.map((k) => `model-relay:${k}`).join(en ? ', ' : '、')
936    lines.push(en
937      ? `Subagents: subagent_type "model-relay:<name>" runs a full Agent on that provider (${names})`
938      : `子代理:subagent_type "model-relay:<名稱>" 派出等同預設 Agent 的子代理並走該供應商(${names})`)
939  }
940  return lines.join('\n')
941}
942
943export interface ProviderAgentSpec extends AgentOptions {
944  name: string
945  description: string
946  prompt: string
947  model: string
948}
949
950const DEFAULT_AGENT_PROMPT =
951  "You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, use the available tools to complete the task fully and report your findings and changes clearly and concisely."
952
953// Builds one `$.agent.register` spec per provider the router serves. `tools` is omitted so the subagent inherits every
954// tool the parent has (identical to the built-in general-purpose Agent), while `model` is set to the provider's model ID.
955// `options` holds each provider's "agent" object from the config: what it sets replaces these defaults as written.
956export function providerAgentSpecs(providers: Record<string, string>, options: Record<string, AgentOptions> = {}): ProviderAgentSpec[] {
957  return Object.entries(providers).map(([name, model]) => ({
958    description: `General-purpose agent routed to ${name} (${model}) via model-relay. Same full toolset as the default Agent tool.`,
959    prompt: DEFAULT_AGENT_PROMPT,
960    ...(has(options, name) ? options[name] : {}),
961    name,
962    model,
963  }))
964}
965
966// The provider a `model-relay:<name>` subagent type names, with its model; null for any other type. When the parent calls
967// `Agent({ subagent_type: "model-relay:<name>", model: "sonnet" })`, the explicit `model` would otherwise override the
968// agent definition's, so `on('agent.spawn')` locks it back to the provider's model.
969export function relayAgent(subagentType: string, providers: Record<string, string>): { name: string; model: string } | null {
970  const m = /^model-relay:([a-z0-9_-]+)$/i.exec((subagentType || '').trim())
971  if (!m) return null
972  const name = m[1]!.toLowerCase()
973  return has(providers, name) ? { name, model: providers[name]! } : null
974}
975
976// A model id as it travels: without a "[1m]"-style suffix, lower case (the router's seenModels uses the same key)
977export const wireKey = (model: string) => model.replace(/\[[^\]]*\]$/, '').toLowerCase()
978
979// Claude Code can run a model-relay:<name> subagent on another model (one missing from availableModels is swapped with
980// only a debug-log line); the spawn result names the model it really got
981export const spawnedElsewhere = (wanted: string, got: string | undefined) => Boolean(got) && wireKey(got!) !== wireKey(wanted)
982
983// After the subagent's turn: did a request for its model reach the relay since it started? null when the router cannot
984// say (down, or an older router without seenModels), so nobody is warned on a guess. Two seconds of slack for the two clocks' reads.
985export function reachedRelay(h: Health | null, wanted: string, since: number): boolean | null {
986  if (!h || !h.seenModels) return null
987  const at = h.seenModels[wireKey(wanted)]
988  return typeof at === 'number' && at >= since - 2000
989}
990
991export function elsewhereText(name: string, wanted: string, got: string, lang: 'zh' | 'en'): string {
992  return lang === 'en'
993    ? `The model-relay:${name} subagent runs on ${got}, not ${wanted}. Usually availableModels in a settings file does not list ${wanted}; run /provider doctor`
994    : `model-relay:${name} 子代理實際跑在 ${got},不是 ${wanted}。常見原因是設定檔的 availableModels 沒列出 ${wanted};執行 /provider doctor 檢查`
995}
996
997export function missedRelayText(name: string, wanted: string, lang: 'zh' | 'en'): string {
998  return lang === 'en'
999    ? `The model-relay:${name} subagent finished, but no request for ${wanted} reached the relay, so it did not run on ${name}. Run /provider doctor (a Bedrock/Vertex setting or another ANTHROPIC_BASE_URL can cause this)`
1000    : `model-relay:${name} 子代理跑完了,但轉發站沒收到 ${wanted} 的請求,所以它沒有走 ${name}。執行 /provider doctor 檢查(Bedrock/Vertex 設定或別的 ANTHROPIC_BASE_URL 都可能造成)`
1001}
1002
hooks/avatar.tsx 36 lines
1// The avatar pane of model-relay: the head of the model this window talks to, drawn as a Raster, with its name under it.
2// Pure view function, like panel.tsx: resolved elements and the view.
3import type { EngineInterface } from 'claude-code'
4import type { AvatarView } from '../types'
5import { fit } from './logic'
6
7type Elements = ReturnType<EngineInterface['ui']['resolve']>
8
9// Under the picture: the label, one row (AVATAR_LABEL_ROWS in logic.ts is what the picture's box leaves for it)
10export function avatarTree(ui: Elements, v: AvatarView, columns: number, lang: 'zh' | 'en') {
11  const { Box, Text } = ui
12  // Only the terminal draws a Raster; elsewhere the pane is the label alone
13  const Raster = 'Raster' in ui ? ui.Raster : null
14  const en = lang === 'en'
15  if (!v.name) return <Box />
16
17  const note =
18    v.error === 'unset'
19      ? (en
20          ? `No picture for ${v.name} yet: add avatars.${v.name} to model-relay.json (see the README)`
21          : `${v.name} 還沒有頭像:在 model-relay.json 加上 avatars.${v.name}(見 README)`)
22      : v.error === 'failed'
23        ? (en
24            ? 'No picture: is ffmpeg on PATH, and does the image file exist?'
25            : '做不出頭像:ffmpeg 在 PATH 上嗎?圖片檔存在嗎?')
26        : null
27
28  return (
29    <Box flexDirection="column">
30      {Raster && v.cells && v.cols > 0 ? <Raster key="avatar" columns={v.cols} rows={v.rows} cells={v.cells} /> : null}
31      {note ? <Text dimColor>{note}</Text> : null}
32      <Text bold>{fit(v.label, columns)}</Text>
33    </Box>
34  )
35}
36
hooks/panel.tsx 218 lines
1// Interactive AbovePrompt panel for model-relay (/provider).
2// Pure view function: receives resolved UI elements, snapshot data, and action callbacks.
3// It is for one thing, picking this window's model; what every window shares stays folded behind Settings [s].
4import type { EngineInterface } from 'claude-code'
5import type { PanelData, PanelUi } from '../types'
6import { fit, pingText, statText } from './logic'
7
8type Elements = ReturnType<EngineInterface['ui']['resolve']>
9
10export type PanelView = PanelData & PanelUi & { columns: number }
11
12export type PanelActions = {
13  switchTo: (target: string) => void
14  askClaude: (ask: boolean) => void
15  ping: () => void
16  probe: () => void
17  settings: (show: boolean) => void
18  toggle: (which: 'failover' | 'retry' | 'proxy', on: boolean) => void
19  askProxy: (ask: boolean) => void
20  close: () => void
21}
22
23const ACCENT = 'cyan'
24const FRAME_CELLS = 4
25
26export function panelTree(ui: Elements, v: PanelView, act: PanelActions) {
27  const { Box, Text, Button } = ui
28  const inner = Math.max(32, v.columns - FRAME_CELLS)
29  const en = v.lang === 'en'
30
31  const fo = v.failoverProvider
32  const foText = fo ? `${fo} (${v.providers[fo] ?? fo})` : (en ? 'nobody to hand over to' : '沒有接手者')
33  const mins = v.failoverSince ? Math.max(0, Math.round((v.nextClaudeTry - v.now) / 60000)) : 0
34  const aliasPairs = Object.entries(v.aliases ?? {})
35    .filter(([, p]) => v.providers[p])
36    .map(([slot, p]) => `${slot} → ${p} (${v.providers[p]})`)
37
38  // Only what changes where this window's requests go gets a line of its own
39  const alert = !v.online
40    ? (en
41        ? (v.direct ? 'Relay offline: this window talks to Claude directly' : 'Relay offline: restarting every 10s…')
42        : (v.direct ? '轉發站離線:這個視窗暫時直連 Claude' : '轉發站離線:每 10 秒自動重啟中…'))
43    : v.failoverSince
44      ? (en
45          ? `Claude limit reached: requests go to ${foText}; Claude again in ~${mins}m`
46          : `Claude 額度用完:請求改走 ${foText},約 ${mins} 分鐘後再試 Claude`)
47      : null
48
49  const inUse = <Text dimColor>{en ? 'In use' : '使用中'}</Text>
50  const mark = (active: boolean) => <Text color={active ? 'green' : 'gray'} bold>{active ? '● ' : '○ '}</Text>
51
52  const isClaudeActive = v.session === 'claude'
53  const claudeRow = (
54    <Box key="route:claude">
55      <Box flexGrow={1}>
56        <Text>
57          {mark(isClaudeActive)}
58          <Text bold={isClaudeActive}>{'claude'.padEnd(8)}</Text>{' '}
59          <Text dimColor>Anthropic Claude</Text>
60        </Text>
61      </Box>
62      {isClaudeActive
63        ? inUse
64        : v.confirmingClaude
65          ? [
66              <Button
67                key="confirm-claude"
68                label={en ? 'Confirm (rebuilds cache)' : '確定切回(重建快取)'}
69                variant="primary"
70                onPress={() => act.switchTo('claude')}
71              />,
72              <Button key="cancel-claude" label={en ? 'Cancel' : '取消'} onPress={() => act.askClaude(false)} />,
73            ]
74          : <Button key="btn:claude" label={en ? 'Use [1]' : '切換 [1]'} hotkey="1" onPress={() => act.askClaude(true)} />}
75    </Box>
76  )
77
78  const providerRows = Object.entries(v.providers).map(([name, model], idx) => {
79    const active = v.session === name
80    // the last real request and the last test are shown apart: a long reply is not a slow provider
81    const notes = [statText(v.stats[name], v.lang), pingText(v.ping?.[name], v.lang)].filter((x) => x !== null)
82    const hk = String(idx + 2)
83    return (
84      <Box key={`route:${name}`}>
85        <Box flexGrow={1}>
86          <Text>
87            {mark(active)}
88            <Text bold={active}>{name.padEnd(8)}</Text>{' '}
89            <Text color={active ? ACCENT : undefined}>{model}</Text>
90            {notes.map((n, i) => <Text key={`note:${i}`} color={n.color}>{` · ${n.text}`}</Text>)}
91          </Text>
92        </Box>
93        {active
94          ? inUse
95          : <Button key={`btn:${name}`} label={en ? `Use [${hk}]` : `切換 [${hk}]`} hotkey={hk} variant="primary" onPress={() => act.switchTo(name)} />}
96      </Box>
97    )
98  })
99
100  // One row per switch: what it is, whether it is on, and what flipping it does. failover and retry apply on the
101  // router's next request; the proxy rewrites settings.json, so it asks first and only reaches new sessions.
102  const sw = v.switches
103  const chainText = v.failoverChain.length ? v.failoverChain.join(' → ') : (en ? 'nobody to hand over to' : '沒有接手者')
104  const switchRow = (
105    id: 'failover' | 'retry' | 'proxy',
106    name: string,
107    on: boolean,
108    detail: string,
109    button: ReturnType<typeof Button> | ReturnType<typeof Button>[],
110  ) => (
111    <Box key={`switch:${id}`}>
112      <Box flexGrow={1}>
113        <Text>
114          {mark(on)}
115          <Text bold>{name}</Text>{' '}
116          <Text dimColor>{fit(`· ${detail}`, Math.max(20, inner - 30))}</Text>
117        </Text>
118      </Box>
119      {button}
120    </Box>
121  )
122  const flipLabel = (on: boolean, key: string) => (on ? (en ? `Turn off [${key}]` : `關閉 [${key}]`) : (en ? `Turn on [${key}]` : `開啟 [${key}]`))
123  const hasSettings = !!sw || aliasPairs.length > 0
124  const settings = v.showSettings && hasSettings
125    ? (
126      <Box flexDirection="column" marginTop={1}>
127        <Text bold>{en ? 'Shared by every window' : '所有視窗共用'}</Text>
128        {sw
129          ? [
130              switchRow(
131                'failover',
132                en ? '429 failover' : '429 備援',
133                sw.failover,
134                sw.failover ? (en ? `on (${chainText})` : `開啟(${chainText})`) : (en ? 'off (429 returns error)' : '關閉(額度用完直接回報)'),
135                <Button key="btn:failover" label={flipLabel(sw.failover, 'f')} hotkey="f" onPress={() => act.toggle('failover', !sw.failover)} />,
136              ),
137              switchRow(
138                'retry',
139                en ? 'Retry' : '失敗重試',
140                sw.retry,
141                sw.retry ? (en ? 'on (backoff on 429/5xx)' : '開啟(429/5xx 自動退避重試)') : (en ? 'off (fail immediately)' : '關閉(出錯立即回報)'),
142                <Button key="btn:retry" label={flipLabel(sw.retry, 't')} hotkey="t" onPress={() => act.toggle('retry', !sw.retry)} />,
143              ),
144              switchRow(
145                'proxy',
146                en ? 'Remote Control' : '手機遙控',
147                sw.proxy,
148                sw.proxy ? (en ? 'on (HTTPS proxy · new sessions)' : '開啟(HTTPS 代理・新對話生效)') : (en ? 'off (base-URL mode)' : '關閉(一般 API 位址模式)'),
149                v.confirmingProxy
150                  ? [
151                      <Button
152                        key="confirm-proxy"
153                        label={sw.proxy
154                          ? (en ? 'Confirm (running remote sessions drop)' : '確定關閉(執行中的遙控對話會斷線)')
155                          : (en ? 'Confirm (edits settings.json)' : '確定開啟(會改 settings.json)')}
156                        variant="primary"
157                        onPress={() => act.toggle('proxy', !sw.proxy)}
158                      />,
159                      <Button key="cancel-proxy" label={en ? 'Cancel' : '取消'} onPress={() => act.askProxy(false)} />,
160                    ]
161                  : <Button key="btn:proxy" label={flipLabel(sw.proxy, 'x')} hotkey="x" onPress={() => act.askProxy(true)} />,
162              ),
163            ]
164          : null}
165        {aliasPairs.length > 0
166          ? <Text dimColor>{fit(`${en ? 'Aliases: ' : '別名分流:'}${aliasPairs.join(en ? ', ' : '、')}`, inner)}</Text>
167          : null}
168      </Box>
169    )
170    : null
171
172  return (
173    <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
174      <Box>
175        <Box flexGrow={1}>
176          <Text color={ACCENT} bold>model-relay</Text>
177          <Text dimColor>{en ? " · this window's model" : '・這個視窗用哪個模型'}</Text>
178        </Box>
179        <Button key="ping" label={v.pinging ? (en ? 'Testing…' : '測速中…') : (en ? 'Ping [p]' : '測速 [p]')} hotkey="p" onPress={act.ping} />
180        {v.failoverSince > 0
181          ? <Button key="probe" label={en ? 'Retry Claude [r]' : '重試 Claude [r]'} hotkey="r" onPress={act.probe} />
182          : null}
183        {hasSettings
184          ? (
185            <Button
186              key="settings"
187              label={v.showSettings ? (en ? 'Hide settings [s]' : '收起設定 [s]') : (en ? 'Settings [s]' : '設定 [s]')}
188              hotkey="s"
189              onPress={() => act.settings(!v.showSettings)}
190            />
191          )
192          : null}
193        <Button key="close" label={en ? 'Close' : '關閉'} role="dismiss" dimColor onPress={act.close} />
194      </Box>
195
196      {alert ? <Text color="yellow">{fit(alert, inner)}</Text> : null}
197      {v.note ? <Text color={v.note.ok ? 'green' : 'yellow'}>{fit(v.note.text, inner)}</Text> : null}
198      {(v.hints ?? []).slice(0, 2).map((hint, i) => (
199        <Text key={`hint:${i}`} color="yellow">{fit(`⚠ ${hint}`, inner)}</Text>
200      ))}
201      {providerRows.length === 0 && v.online
202        ? <Text dimColor>{fit(en ? 'No provider is usable yet: every request goes to Claude; /provider setup adds one' : '還沒有可用的供應商:所有請求都走 Claude;/provider setup 可以新增', inner)}</Text>
203        : null}
204
205      <Box flexDirection="column" marginTop={1}>
206        {claudeRow}
207        {providerRows}
208      </Box>
209
210      {settings}
211
212      <Box marginTop={1}>
213        <Text dimColor>{fit(en ? 'Keyboard: ctrl+x tab, then the key shown on a button' : '鍵盤:先按 ctrl+x tab,再按按鈕上標的鍵', inner)}</Text>
214      </Box>
215    </Box>
216  )
217}
218
types/index.d.ts 82 lines
1export type ProviderStat = {
2  status: number
3  ms: number
4  at: number
5}
6
7// The last test of a provider (/provider ping): ok | key | model | quota | not-running | unreachable | timeout | error
8export type PingStat = {
9  verdict: string
10  status: number
11  firstByteMs: number
12  at: number
13  detail: string
14}
15
16export type PanelData = {
17  online: boolean
18  direct: boolean
19  port: number
20  session: string // 'claude' or provider name
21  failoverProvider: string | null // null when failover has nobody to hand over to
22  failoverSince: number
23  nextClaudeTry: number
24  sessionsOnCustom: number
25  providers: Record<string, string>
26  aliases: Record<string, string>
27  failoverChain: string[]
28  // The router's own view of its switches; null when it is offline or too old to report them
29  switches: { failover: boolean; retry: boolean; proxy: boolean; proxyReady: boolean } | null
30  stats: Record<string, ProviderStat>
31  ping: Record<string, PingStat>
32  hints: string[] // things to act on: an older config, a provider the router skips
33  lang: 'zh' | 'en'
34  now: number
35}
36
37// What the last panel action came to: green when it did what was asked, yellow when it did not
38export type PanelNote = { text: string; ok: boolean }
39
40export type PanelUi = {
41  open: boolean
42  confirmingClaude: boolean
43  confirmingProxy?: boolean
44  pinging: boolean
45  // The switches every window shares stay folded away until asked for: the panel is for this window's model
46  showSettings?: boolean
47  note?: PanelNote
48}
49
50// One persona's picture from the config's "avatars": the image path as written, and the square of it that is the head
51// (centre x, centre y, side, in source pixels) when the config gives one
52export type AvatarEntry = { image: string; head: [number, number, number] | null }
53
54// full: the whole image fitted into the box; head: the head square fitted into it
55export type AvatarMode = 'full' | 'head'
56
57// unset: the persona has no entry in "avatars"; failed: ffmpeg or the image did not give pixels
58export type AvatarError = 'unset' | 'failed'
59
60// What the avatar pane draws: the last finished conversion, with everything it was made from, so what is drawn always
61// belongs together. cells (cols x rows, packed as a Raster's) is null while there is none or when it could not be made.
62export type AvatarView = {
63  name: string // 'claude' or a provider name
64  label: string // provider name and model id
65  entry: AvatarEntry | null
66  mode: AvatarMode
67  cols: number
68  rows: number
69  cells: string | null
70  error: AvatarError | null
71}
72
73declare module 'claude-code' {
74  interface PluginState {
75    'model-relay': {
76      panelUi: PanelUi
77      panelData: PanelData | null
78      avatarView: Shaped<AvatarView>
79    }
80  }
81}
82