Picks the reasoning effort for every prompt and the worker for every delegated task, can keep the prompt cache warm, and shows where ClaudeRipple sent each…

<img src="brand/mark.svg" width="120" height="120" alt="The ClaudeRipple mascot: a hollow ripple ring with a curling wave crest">
<h1 align="center">ClaudeRipple</h1>
<b>Chat stays Claude. Only Code becomes GPT.</b><br> Claude Desktop's own setting for other models switches the <b>whole app</b> — chat, phone Remote Control and connectors go with it.<br>ClaudeRipple turns nothing off: you stay signed in to your Claude subscription while GPT, DeepSeek, Kimi, Grok<br>or 400+ other models answer in the <b>Code tab</b>. Claude inside the <b>Codex app</b> and <b>Codex CLI</b> too.
<a href="https://www.npmjs.com/package/clauderipple"><img alt="npm" src="https://img.shields.io/npm/v/clauderipple?label=npm"></a> <img alt="Alpha" src="https://img.shields.io/badge/status-alpha-orange"> <a href="LICENSE"><img alt="GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-blue"></a> <img alt="macOS" src="https://img.shields.io/badge/macOS-arm64%20%7C%20x64-black"> <img alt="Windows" src="https://img.shields.io/badge/Windows-arm64%20%7C%20x64-0078D4"> <img alt="Linux" src="https://img.shields.io/badge/Linux-x64%20(systemd)-FCC624"> <img alt="Node" src="https://img.shields.io/badge/node-24%2B-success"> <a href="README.ko.md"><img alt="한국어" src="https://img.shields.io/badge/docs-한국어-red"></a>
<a href="https://github.com/PBJ-2/clauderipple/releases/download/v0.9.0/clauderipple-demo.mp4"><img src="docs/media/demo.webp" width="880" alt="A one-minute tour: other models in Claude Desktop's own model menu, effort set for every prompt, and each subagent sent to the model that fits. Click for the full video."></a><br> <sub>A one-minute tour: other models in Claude Desktop's own model menu, effort set for every prompt, and each subagent sent to the model that fits. Click for the full video.</sub>
<table align="center"> <tr> <td align="center" width="34%"><img src="docs/media/picker.png" width="300" alt="GPT, DeepSeek, GLM and Gemini in Claude Desktop's own model menu"><br><sub>GPT, DeepSeek, GLM and Gemini in Claude Desktop's own model menu</sub></td> <td align="center" width="66%"><img src="docs/media/answer.png" alt="GPT-6 Astra working in the Code tab, every request logged on the right"><br><sub>GPT-6 Astra working in the Code tab, every request logged on the right</sub></td> </tr> <tr> <td colspan="2" align="center"><img src="docs/media/subagents.png" alt="Subagents on DeepSeek and GLM, named in the background-task panel"><br><sub>Subagents on DeepSeek and GLM, named in the background-task panel</sub></td> </tr> <tr> <td colspan="2" align="center"><img src="docs/media/mod-band.png" alt="The ripple mod: effort, context, cache timer, and which model answered with its cache hit"><br><sub>The ripple mod: effort, context, cache timer, and which model answered with its cache hit</sub></td> </tr> <tr> <td align="center" width="50%"><img src="docs/media/mod-alerts.png" alt="Warnings when the cache goes cold or a limit is near, and a handoff to a fresh chat"><br><sub>Warnings when the cache goes cold or a limit is near, and a handoff to a fresh chat</sub></td> <td align="center" width="50%"><img src="docs/media/mod-settings.png" alt="Quick settings: automatic effort, the judge, worker modes"><br><sub>Quick settings: automatic effort, the judge, worker modes</sub></td> </tr> </table>
Claude Desktop already ships an official way to use other models — the inference gateway setting, what the app calls third-party or 3P mode. Turning it on is not a per-session choice. It switches the whole app into a different deployment mode at launch, and that mode is a different product:
claude.ai and loads a local bundle instead. claude.ai /api/ and /v1/ calls answer custom_3p_not_available 503.shouldEnableSessionsBridge() returns false).There is no middle setting. "Chat on Claude, Code on the gateway" is impossible for anyone, because 1P mode routes nothing through the gateway at all. (All of this is read out of the app's own bundle; see docs/ARCHITECTURE.md §2.)
ClaudeRipple never touches that setting. The app stays signed in to your Claude subscription in 1P mode, and another model answers in the Code tab and its subagents — under its real name in the model picker, at the reasoning effort you chose. You do not trade your Claude account for a GPT one. You keep both, in one app.
Three things follow from staying in 1P, and no gateway-based tool can offer any of them:
If you pay for Claude, 3P is not an option — it throws away the half of the subscription you are paying for. This is the only way to keep it and still choose the model.
Yes — in the Code tab and its subagents. Install ClaudeRipple, sign in with a ChatGPT Plus or Pro subscription, and GPT (GPT-6 Sol, Luna, Astra, GPT-5.6 Terra and the rest) shows up under its real name in the model picker, with the reasoning effort you pick. The app itself is not modified and not switched into any other mode. The general chat tab stays Claude: no tool can route it without turning the whole app into gateway mode (see 1P, not 3P).
The same install covers the terminal claude CLI. ClaudeRipple is a local proxy that Claude Code is pointed at through two lines in ~/.claude/settings.json, so your skills, hooks, MCP servers, CLAUDE.md and subagents keep working while another model answers.
No — and you should not. That setting switches the whole app into 3P mode, which stops loading claude.ai, replaces chat with a local-agent session and turns Remote Control off. ClaudeRipple never touches it.
No. The app stays signed in to your Claude subscription, so chat, phone Remote Control, cloud sessions and Anthropic's own connectors keep working. That is the whole point of staying in 1P.
Yes. Model mapping is per model name: leave a name mapped to Claude and it answers as Claude. Most people map one or two names to GPT and leave the rest alone.
No. Any ChatGPT plan that includes Codex works — Plus, Pro and Business, and Enterprise or Edu where the workspace admin has turned Codex on. You sign in from the app and no API key is involved. The plan's usage limits apply; once they are used up, Codex credits you have bought carry on. API keys are for the other providers (DeepSeek, Kimi, GLM, OpenRouter and anything else with an Anthropic- or OpenAI-compatible endpoint).
GPT through a ChatGPT subscription, and DeepSeek, Kimi, GLM, Grok, Qwen and 400+ more through OpenRouter or a direct API key. Claude models keep working as they are.
Yes, on Windows and macOS, arm64 and x64. Install, dashboard, restart and uninstall are measured on Windows 11 arm64; the x64 runtime is measured under emulation. Linux works too where there is a systemd user session: install, a Code-tab request through the router, a restart in the middle of a streaming answer and uninstall are measured on Ubuntu 24.04 x64 with Claude Desktop 2.2553.13, and so is picker mode: models from cli.extraModels show in the Code-tab picker next to Claude's own.
No. The proxy runs on your own machine. Requests go to the provider you configured and nowhere else, and credentials stay in your home directory. See Privacy.
What ClaudeRipple does with Claude is small and checkable: a request that stays on Claude leaves your machine byte for byte as Claude Code sent it, and a request routed to GPT or another provider never reaches Anthropic at all. It works through the proxy and certificate settings Claude Code documents for corporate networks (HTTPS_PROXY, NODE_EXTRA_CA_CERTS — Enterprise network configuration), not by patching the app. The reports of banned accounts are about the opposite direction — a Claude subscription login used from another program. ClaudeRipple has one optional feature of that kind: Claude inside Codex using your subscription login. That use is subject to Anthropic's terms; an Anthropic API key avoids the question. Rotating several Claude subscriptions is likewise your call under those terms. Only Anthropic can say how an account is treated, so nothing here is a promise.
An AI Studio API key is the way Google supports, and the safe one. The Google account sign-in (Antigravity) is different: Google's Antigravity terms (section 6) and the Gemini CLI FAQ name using those credentials from another program as a breach. In February 2026 Google suspended accounts used this way — the suspension also cut off Gemini CLI and Code Assist — and said a second violation is permanent. ClaudeRipple offers the sign-in because others do and people asked, shows this warning before it starts, and does nothing to hide the traffic. Use a secondary account if you use it at all.
Yes. Each clauderipple login (or + Add ChatGPT account in the dashboard) adds one. When an account hits its usage limit, the same request moves to the next account and the spent one rests until its window resets; a conversation stays on the account that answered, to keep its prompt cache. This applies to the Claude Desktop Code tab, Claude Code, and GPT in the Codex app and CLI.
Yes. clauderipple codex on adds ClaudeRipple as a provider, and Claude models appear in the Codex app's and CLI's model list under their own names. They answer through your Claude Code login or an Anthropic API key. See Codex app and Codex CLI.
Yes, with a ChatGPT subscription and no API key: clauderipple image "a red paper boat" -o boat.png --aspect square. --ref FILE adds a reference image and --transparent a transparent background. One image takes about 30 seconds, and the subscription chooses the resolution and quality. Subagents ClaudeRipple generates are told the same call, so they can make images too. See Claude Code.
Claude Code's system prompt and tool descriptions are written for Claude, so another model reads instructions tuned for someone else, and it may behave a little differently than in its own client. ClaudeRipple tells each model its own name and effort, reshapes tool schemas a vendor would reject, and lets you append instructions per provider. A common setup is Claude as the main model with GPT or DeepSeek as subagents.
See the comparison table. In short: ClaudeRipple reaches the Claude Desktop Code tab while the app stays signed in to Claude, and shows other models in its picker under their real names. CC Switch also reaches the desktop app, but by turning on its gateway (3P) mode.
Yes. ClaudeRipple is open source under GPL-3.0. You pay only for the subscriptions or API keys you already use.
Similar tools let the Claude Code CLI or Codex CLI use other models, but they either cannot reach the Claude desktop app or reach it only through its gateway (3P) mode, and the moment you switch models you lose the Claude-subscription side (claude.ai chat, Remote Control, cloud sessions). ClaudeRipple covers the desktop app, the terminal and Codex from one menu-bar app, runs both subscriptions side by side, and keeps the Claude Code harness intact: your skills, hooks, MCP servers, CLAUDE.md, subagents and claude.ai connectors keep working while another model does the thinking.
| ClaudeRipple | CC Switch (checked 2026-09-24) | opencodex / openclaude (checked 2026-09-16) | claude-code-router | Claude Desktop gateway (3P) mode | |
|---|---|---|---|---|---|
| Claude Desktop Code tab without gateway (3P) mode | ✅ | ❌ ² | ❌ ¹ | ❌ | ❌ by definition |
| Keeps claude.ai chat, Remote Control, cloud sessions, connectors | ✅ | ❌ | ❌ | ❌ | ❌ |
| Claude and GPT subscriptions side by side | ✅ | ❌ one provider at a time | ❌ all-or-nothing | ❌ | ❌ |
| Real model names in the Desktop picker | ✅ | ❌ claude-* role names ² | ❌ | ❌ | partial |
| Several ChatGPT accounts, switching when one runs out (Desktop and Codex) | ✅ | ❌ switched by hand ³ | ✅ | ❌ | ❌ |
Terminal claude CLI | ✅ | ✅ | ✅ | ✅ | ✅ |
| Codex app and Codex CLI → Claude | ✅ | ✅ | ✅ | ❌ | ❌ |
| Prompt cache on translated providers | 94–99 % measured | not measured | not measured | varies | n/a |
| Subagents named by real model in the task panel | ✅ | ❌ | ❌ | ❌ | ❌ |
| Settings GUI, no terminal needed | ✅ | ✅ | ❌ | ❌ | ❌ |
| Signed, notarized app with its own runtime | ✅ | ✅ | ❌ | ❌ | – |
¹ opencodex's README showed Claude Desktop in a demo but published no setup steps for it, in the repository or on its documentation site (checked 2026-09-16). At the time the only public way into the desktop app was the official gateway setting — the last column.
² CC Switch reaches Claude Desktop by writing the app's third-party profile (Claude-3p/claude_desktop_config.json, "inferenceProvider": "gateway"), which is the last column. Through its local gateway the picker shows only claude-sonnet-*, claude-opus-* and claude-haiku-* role names (its manual). CC Switch manages more tools than this table covers (Gemini CLI, OpenCode and others) and syncs MCP servers and skills across them.
³ Each ChatGPT account is bound to its own provider card, and official ChatGPT cards are kept out of the failover queue (v3.20.0 release notes).
The desktop app's own "third-party inference" setting flips the whole app into another mode: you lose claude.ai chat, Remote Control and cloud sessions. Tools that replace ANTHROPIC_BASE_URL never reach the desktop app, and the ones that do reach it write that same setting. ClaudeRipple instead is a tiny HTTPS proxy that only the Claude Code process trusts: requests for the models you map go to your provider, everything else goes to Anthropic byte for byte.
clauderipple login (or + Add ChatGPT account in the dashboard) adds one. When an account hits its limit, the next one takes over the same request and the spent one rests until its window resets; a conversation stays on the account that answered, to keep its cache. This covers the Claude Desktop Code tab and subagents and GPT in the Codex app and CLI — Codex keeps its own sign-in and moves to the next account without signing out.CLAUDE.md, subagents, plan mode, Remote Control on your phone: nothing is turned off.Terra·high · Review instead of a generic "Agent".ripple:auto, and the judge sends each task to a worker by difficulty: light work to DeepSeek or GLM, hard work to GPT or Opus. Cards warn when the cache has gone cold, the context gets heavy or a plan window passes 80%, and offer compact or a handoff to a fresh session in the same folder. Log (or /ripple-log) opens the request log in a side pane. One switch on the Clients screen, or clauderipple mod on.clauderipple image "<prompt>", with reference images and transparent backgrounds. Subagents are told how, so a GPT or DeepSeek worker can make an image and look at it without an image tool.<img src="docs/media/mapping.png" width="880" alt="Model mapping: which model answers for each Claude name, with per-model reasoning effort">
macOS
curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.sh | sh
Windows (PowerShell)
irm https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.ps1 | iex
Linux (a desktop session with systemd)
curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.sh | sh
The router runs as a per-user systemd service: systemctl --user status clauderipple, and journalctl --user -u clauderipple for what it printed. Picker mode trusts the local CA in the NSS database Chromium reads (~/.pki/nssdb, your user only, no sudo), which takes certutil: run sudo apt install libnss3-tools (Fedora: sudo dnf install nss-tools) before clauderipple picker on.
That is the whole prerequisite list. The script uses a Node 24+ you already have, and downloads the official build into ~/.clauderipple/runtime when you have none, verifying it against the checksum nodejs.org publishes. Then it sets ClaudeRipple up: a local certificate, two lines in ~/.claude/settings.json, and a background router that starts with your computer. No administrator rights, no password.
With Node already installed you can skip the script:
npm install -g clauderipple
clauderipple install
Then open the dashboard and add a provider:
clauderipple ui
Providers → add ChatGPT or paste an API key → Model mapping.
The menu-bar / tray app is optional. It shows the router's state and opens the dashboard in a click:
clauderipple tray
It runs on Electron, which is about 270MB and is therefore not installed by default. clauderipple tray --install fetches it once; everything else works without it.
Updating. The tray offers a new version when there is one (Check for Updates…), or:
clauderipple update
It updates the way you installed — the install script again, or npm — and restarts the router on the new version. The tray's Electron is kept across updates.
Optional, for real names in the Desktop picker: Clients → Claude Desktop → Model picker → on, then quit and reopen Claude Desktop. To trust the local certificate for your user only, macOS asks for your login password and Windows shows a confirmation dialog with the fingerprint; Linux adds it to your NSS database without asking. ClaudeRipple never sees a password, and no platform needs administrator rights.
Closing Claude Desktop's window is not enough. It keeps running, and the next launch reuses it without reading the new setting. Quit it properly (macOS: ⌘Q; Windows: the tray icon, or Task Manager; Linux: Ctrl+Q or the tray icon, until
pgrep -f claude-desktopprints nothing) or the picker will silently not change.
git clone https://github.com/PBJ-2/clauderipple && cd clauderipple && npm install
node packages/cli/src/index.ts install # certs, settings.json env, supervisor (launchd / Task Scheduler), end-to-end probe
node packages/cli/src/index.ts ui # open the local GUI in your browser
uninstall reverses everything and restores ~/.claude/settings.json from a backup. Other commands: status, start, stop, restart, logs -f, login, logout, claude-login, claude-logout, picker on|off, agent-title on|off, mod on|off, codex on|off, update, image "<prompt>".
Works out of the box after install. Map models on Model mapping (auto-saves), or turn on picker mode to see provider models by name in the app's own picker. Effort chosen in the app is passed through; unsupported levels are clamped.
Same router, same mapping. /model gpt-5.6-terra li
hooks/register.tsx 1703 lines1// Engine wiring lives here: imported logic and drawings never receive the engine interface.
2// Timers own background work; hooks only admit/rewrite a request and record its result.
3import { atom, read, update } from 'claude-code'
4import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, SessionRateLimit, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
5import type { AgentRec, Effort, JudgeInput, JudgeKind, JudgeRun, LimitItem, ModSettingsSnapshot, Pick, Settings, StatusRequest, TierTarget, Verdict, WorkerCandidate, WorkerPick, WorkerVerdict } from '../types'
6import { MOD_SETTINGS_SCHEMA_VERSION, RUNG_EFFORT, applySettingsChanges, normalizeSettings, settingsChanges } from './settings'
7import { rungOf } from './ladder'
8import { debugText, langFrom, t } from './i18n'
9import type { Lang } from './i18n'
10import { EFFORT_JUDGE_SYSTEM, finalEffort, isCacheSafeModel, isShortFollowUp, jevEffortRequest, jevFailure, jevUsage, jevWorkerRequest, judgeContext, parseEffortVerdict, parseJevEffort, parseJevKey, parseJevWorker, shouldJudge } from './judge'
11import { AUTO_DESCRIPTION, WORKER_JUDGE_SYSTEM, agentProvider, candidateUsable, chatgptUsable, claudeCandidates, ladderOptionsFrom, ladderPick, parseWorkerVerdict, readMarker, routerModelOptions, spawnModel, spawnTitle, takesOver, TIER_MARKERS, tierOf, workerJudgeInput, zenJev } from './workers'
12import { MARKER, agentModel, fixSpawn } from './worker'
13import { activeModeOf, chatgptProviders, claudeWindow, eagerNote, gptLimitsFrom, modeKey, modeOver, quotaWindows, resetProvider, routingSettings } from './modes'
14import type { QuotaWindow } from './modes'
15import type { Spawn, WorkerFix } from './worker'
16import { afterPing, nextMemo, restoreFromResume, shouldPing } from './cache'
17import { bandPriority, claudeLimitsFrom, claudeScopedFrom, coldAlert, hotAlert, hottest, hotWindowMoved, keptAlert, routerLimitItems, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
18import type { ScopedLimit } from './session'
19import { COMPACT_SYSTEM, compactResult, serializeTranscript } from './compact'
20import { ADVICE_SYSTEM, FULL_PROMPT, QUICK_PROMPT, adviceDue, adviceInput, parseAdvice, wrapForNewChat } from './handoff'
21import { addJudge, addPrompt, addRequest, addWarm, addWorker, emptyStats, formatStats, weighted } from './stats'
22import { besideBand, renderBand, renderFooter } from './ui/band'
23import { renderAlert } from './ui/alerts'
24import { renderSettings } from './ui/settings'
25import { renderSetup } from './ui/setup'
26import { renderAgents } from './ui/agents'
27import { renderLog } from './ui/log'
28import { EFFORTS, modelName, nextMinuteChange, routeOf, targetLabel } from './ui/view'
29import type { AlertActions, AlertView, BandActions, DashboardView, Frame, SettingsActions, SettingsView, SetupActions } from './ui/view'
30
31const line = atom({ plugin: 'ripple', key: 'line' } as const, null)
32const last = atom({ plugin: 'ripple', key: 'last' } as const, null)
33const requests = atom({ plugin: 'ripple', key: 'requests' } as const, [])
34const logOpen = atom({ plugin: 'ripple', key: 'logOpen' } as const, false)
35const onlySession = atom({ plugin: 'ripple', key: 'onlySession' } as const, false)
36const bandHidden = atom({ plugin: 'ripple', key: 'bandHidden' } as const, false)
37const settings = atom({ plugin: 'ripple', key: 'settings' } as const, null)
38const settingsSync = atom({ plugin: 'ripple', key: 'settingsSync' } as const, { schemaVersion: MOD_SETTINGS_SCHEMA_VERSION, revision: null, pending: [], migrationChecked: false, migration: null })
39const settingsBase = atom({ plugin: 'ripple', key: 'settingsBase' } as const, null)
40const lang = atom({ plugin: 'ripple', key: 'lang' } as const, 'en')
41const pick = atom({ plugin: 'ripple', key: 'pick' } as const, null)
42const deciding = atom({ plugin: 'ripple', key: 'deciding' } as const, false)
43const decidingSince = atom({ plugin: 'ripple', key: 'decidingSince' } as const, null)
44const paused = atom({ plugin: 'ripple', key: 'paused' } as const, false)
45const heldPick = atom({ plugin: 'ripple', key: 'heldPick' } as const, null)
46const appEffort = atom({ plugin: 'ripple', key: 'appEffort' } as const, null)
47const modelIs = atom({ plugin: 'ripple', key: 'modelIs' } as const, null)
48const lastRequest = atom({ plugin: 'ripple', key: 'lastRequest' } as const, null)
49const lastSpawn = atom({ plugin: 'ripple', key: 'lastSpawn' } as const, null)
50const agents = atom({ plugin: 'ripple', key: 'agents' } as const, [])
51const cache = atom({ plugin: 'ripple', key: 'cache' } as const, null)
52const warm = atom({ plugin: 'ripple', key: 'warm' } as const, { pings: 0, cost: 0 })
53const warmHere = atom({ plugin: 'ripple', key: 'warmHere' } as const, null)
54const lastRealReplyAt = atom({ plugin: 'ripple', key: 'lastRealReplyAt' } as const, null)
55const context = atom({ plugin: 'ripple', key: 'context' } as const, null)
56const hot = atom({ plugin: 'ripple', key: 'hot' } as const, null)
57const limit = atom({ plugin: 'ripple', key: 'limit' } as const, null)
58const limits = atom({ plugin: 'ripple', key: 'limits' } as const, null)
59const hotHidden = atom({ plugin: 'ripple', key: 'hotHidden' } as const, null)
60const hotHiddenFor = atom({ plugin: 'ripple', key: 'hotHiddenFor' } as const, null)
61const saveUntil = atom({ plugin: 'ripple', key: 'saveUntil' } as const, null)
62const swampHidden = atom({ plugin: 'ripple', key: 'swampHidden' } as const, null)
63const coldHidden = atom({ plugin: 'ripple', key: 'coldHidden' } as const, false)
64const kept = atom({ plugin: 'ripple', key: 'kept' } as const, null)
65const judgeDown = atom({ plugin: 'ripple', key: 'judgeDown' } as const, null)
66const judgeDownHidden = atom({ plugin: 'ripple', key: 'judgeDownHidden' } as const, null)
67const judgeSeen = atom({ plugin: 'ripple', key: 'judgeSeen' } as const, [])
68const judgeRuns = atom({ plugin: 'ripple', key: 'judgeRuns' } as const, [])
69// The standing judge failure was a dropped connection or a timeout: the card blames the network, not the judge.
70const judgeDownNetwork = atom({ plugin: 'ripple', key: 'judgeDownNetwork' } as const, false)
71const advice = atom({ plugin: 'ripple', key: 'advice' } as const, null)
72const adviceQueue = atom({ plugin: 'ripple', key: 'adviceQueue' } as const, null)
73const promptCount = atom({ plugin: 'ripple', key: 'promptCount' } as const, 0)
74const handoff = atom({ plugin: 'ripple', key: 'handoff' } as const, null)
75const handoffQueued = atom({ plugin: 'ripple', key: 'handoffQueued' } as const, false)
76const handoffWaiting = atom({ plugin: 'ripple', key: 'handoffWaiting' } as const, false)
77const handoffChoiceKind = atom({ plugin: 'ripple', key: 'handoffChoiceKind' } as const, 'quick')
78const handoffChoiceAfter = atom({ plugin: 'ripple', key: 'handoffChoiceAfter' } as const, 'continue')
79const resultCard = atom({ plugin: 'ripple', key: 'result' } as const, null)
80const panel = atom({ plugin: 'ripple', key: 'panel' } as const, null)
81const busy = atom({ plugin: 'ripple', key: 'busy' } as const, false)
82const stats = atom({ plugin: 'ripple', key: 'stats' } as const, emptyStats())
83const settingsDraft = atom({ plugin: 'ripple', key: 'settingsDraft' } as const, null)
84const setupStep = atom({ plugin: 'ripple', key: 'setupStep' } as const, 1)
85const compactNote = atom({ plugin: 'ripple', key: 'compactNote' } as const, '')
86const effortMenuOpen = atom({ plugin: 'ripple', key: 'effortMenuOpen' } as const, false)
87const agentsOpen = atom({ plugin: 'ripple', key: 'agentsOpen' } as const, false)
88const agentsShowDone = atom({ plugin: 'ripple', key: 'agentsShowDone' } as const, false)
89const jev = atom({ plugin: 'ripple', key: 'jev' } as const, { source: null, test: { state: 'idle' } })
90const routerModels = atom({ plugin: 'ripple', key: 'routerModels' } as const, [])
91const ladderOptions = atom({ plugin: 'ripple', key: 'ladderOptions' } as const, null)
92const modeNoted = atom({ plugin: 'ripple', key: 'modeNoted' } as const, null)
93const demo = atom({ plugin: 'ripple', key: 'demo' } as const, null)
94
95/**
96 * Whether a state write would change anything. A write redraws each reader, and a redraw that
97 * hands the host new pictures reads as a blink (2026-10-10: the band flashed on polls and steps),
98 * so the writes on every step and poll are skipped when they would store what is there. The
99 * engine's scan wants each atom named at its read and update, hence a test rather than a wrapper.
100 */
101function same(a: unknown, b: unknown): boolean {
102 return JSON.stringify(a) === JSON.stringify(b)
103}
104
105const LOG_PANE = 'ripple-log'
106const AGENTS_PANE = 'ripple-agents'
107const HAIKU = 'claude-haiku-5-5'
108const EXECUTOR = 'Do the assigned task yourself. Inspect the relevant files, implement what was asked, and verify the result. Report the outcome concisely, citing evidence and marking uncertainty. Never delegate to another agent. Do not create unrequested files or commit unless explicitly asked.'
109let timersStarted = false
110let polling = false
111let ticking = false
112let forking = false
113let forkUsage: TurnUsage | null = null
114let countdownTimer: { at: number; cancel: () => void } | null = null
115let judgeSequence = 0
116/** Jev's network blips and timeouts in a row. */
117let jevBlips = 0
118let generation = 0
119let carriedPick = false
120// Serialize settings reads and writes so a slow poll cannot overwrite a queued edit.
121let settingsWork: Promise<void> = Promise.resolve()
122function settingsTask<T>(work: () => Promise<T>): Promise<T> {
123 const result = settingsWork.then(work)
124 settingsWork = result.then(() => undefined, () => undefined)
125 return result
126}
127
128function object(value: unknown): Record<string, unknown> {
129 return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
130}
131function strings(value: unknown): string[] {
132 return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
133}
134function effortOf(value: unknown): Effort | undefined {
135 return typeof value === 'string' && EFFORTS.includes(value as Effort) ? value as Effort : undefined
136}
137function reason(error: unknown): string {
138 return error instanceof Error ? error.message : String(error)
139}
140async function report($: EngineInterface, where: string, error: unknown): Promise<void> {
141 await $.ui.log(t(await read($, lang), 'commands.debugLog', { where, reason: reason(error) }), { to: 'debug' })
142}
143async function userHome($: EngineInterface): Promise<string> {
144 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~'
145}
146async function rippleHome($: EngineInterface): Promise<string> {
147 return (await $.env.get('CLAUDERIPPLE_HOME')) ?? `${await userHome($)}/.clauderipple`
148}
149// The fs API has no append: serialize read/write pairs so concurrent picks in this mod survive.
150let workerLogWork: Promise<void> = Promise.resolve()
151/**
152 * Lines the pick log keeps. Each delegation writes up to three (pick, shadow verdict, outcome), so
153 * 300 lines held only a few days; weighing the judge against the main chat's tier markers needs
154 * weeks (2026-10-15).
155 */
156const PICK_LOG_LINES = 3000
157async function persistWorkerPick($: EngineInterface, entry: Record<string, unknown>): Promise<void> {
158 const work = workerLogWork.then(async () => {
159 try {
160 const path = `${await rippleHome($)}/logs/worker-picks.jsonl`
161 const old = await $.fs.exists(path) ? await $.fs.read(path) : ''
162 const lines = old.split('\n').filter(line => line.trim()).slice(-(PICK_LOG_LINES - 1))
163 await $.fs.write(path, [...lines, JSON.stringify(entry)].join('\n') + '\n')
164 } catch (error) { await report($, 'worker pick log', error) }
165 })
166 // A reporting failure must not poison the queue or undo the selected spawn either.
167 workerLogWork = work.catch(() => undefined)
168 await workerLogWork
169}
170async function claudeHome($: EngineInterface): Promise<string> {
171 return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await userHome($)}/.claude`
172}
173async function optionalFile($: EngineInterface, path: string): Promise<string | undefined> {
174 try { return await $.fs.read(path) }
175 catch (error) { await report($, 'optional file', error); return undefined }
176}
177async function adminUrl($: EngineInterface): Promise<string> {
178 const text = await optionalFile($, `${await rippleHome($)}/config.json`)
179 if (!text) return 'http://127.0.0.1:8792'
180 const config = object(JSON.parse(text))
181 const admin = object(config.admin).port
182 const listen = object(config.listen).port
183 const port = typeof admin === 'number' ? admin : (typeof listen === 'number' ? listen : 8791) + 1
184 return `http://127.0.0.1:${port}`
185}
186/** Opens a URL with the system's opener (a browser page, or a claude:// link the desktop app takes); throws when it cannot. */
187async function openUrl($: EngineInterface, url: string): Promise<void> {
188 let argv: string[]
189 // cmd reads & as a command separator: escaped, a link's query reaches start whole.
190 if (await $.env.get('OS') === 'Windows_NT') argv = ['cmd', '/c', 'start', '', url.replace(/&/g, '^&')]
191 else {
192 const platform = await $.process.run(['uname', '-s'], { timeoutMs: 2000 })
193 if (platform.exitCode !== 0) throw new Error(platform.stderr || 'uname failed')
194 argv = [platform.stdout.trim() === 'Darwin' ? 'open' : 'xdg-open', url]
195 }
196 const result = await $.process.run(argv, { timeoutMs: 2000 })
197 if (result.exitCode !== 0) throw new Error(result.stderr || `opener exited ${result.exitCode}`)
198}
199async function openAllSettings($: EngineInterface): Promise<void> {
200 const url = `${await adminUrl($)}/#mod`
201 try {
202 await openUrl($, url)
203 } catch (error) {
204 await report($, 'open mod settings', error)
205 await $.ui.toast(url)
206 }
207}
208async function currentSettings($: EngineInterface): Promise<Settings> {
209 return normalizeSettings(await read($, settings))
210}
211async function resolveLanguage($: EngineInterface, s: Settings): Promise<Lang> {
212 if (s.language !== 'auto') return s.language
213 try {
214 const value = (await $.settings.read()).language
215 const host = langFrom(typeof value === 'string' ? value : undefined)
216 if (host) return host
217 } catch (error) { await report($, 'language settings', error) }
218 // On a Mac the system language comes before LANG: a session the desktop app starts can carry
219 // LANG=en_US on a Korean Mac (seen 2026-10-10: Korean headless, English in the Code tab).
220 // The engine exposes no platform noun. Avoid running macOS defaults elsewhere.
221 if (await $.env.get('OS') !== 'Windows_NT') {
222 try {
223 const platform = await $.process.run(['uname', '-s'], { timeoutMs: 2000 })
224 if (platform.exitCode === 0 && platform.stdout.trim() === 'Darwin') {
225 const apple = await $.process.run(['defaults', 'read', '-g', 'AppleLocale'], { timeoutMs: 2000 })
226 const mac = apple.exitCode === 0 ? langFrom(apple.stdout) : undefined
227 if (mac) return mac
228 if (apple.exitCode !== 0) await report($, 'language locale', apple.stderr)
229 }
230 } catch (error) { await report($, 'language locale', error) }
231 }
232 return langFrom(await $.env.get('LANG')) ?? langFrom(await $.env.get('LC_ALL')) ?? 'en'
233}
234async function registerAuto($: EngineInterface, s: Settings): Promise<void> {
235 if (s.agentAuto === 'off') return
236 await $.agent.register({ name: 'auto', description: AUTO_DESCRIPTION, prompt: EXECUTOR, disallowedTools: ['Agent'] })
237}
238async function installSettings($: EngineInterface, s: Settings): Promise<void> {
239 const previous = await currentSettings($)
240 await update($, settings, () => s)
241 const resolved = await resolveLanguage($, s)
242 await update($, lang, () => resolved)
243 // A router poll can land before the session binds, when register rejects; session.start
244 // registers again, so a rejection here must not cut the rest of the install short.
245 try { await registerAuto($, s) } catch (error) { await report($, 'agent register', error) }
246 if (!s.effortAuto) await update($, heldPick, () => null)
247 if (s.keepWarm && !previous.keepWarm) await update($, warm, value => ({ ...value, stopped: undefined }))
248 // Settings caught up with this chat's own choice: it follows Settings again.
249 if (await read($, warmHere) === s.keepWarm) await update($, warmHere, () => null)
250}
251function settingsSnapshot(text: string): ModSettingsSnapshot {
252 const value = object(JSON.parse(text))
253 if (!Number.isSafeInteger(value.revision) || Number(value.revision) < 0 || typeof value.stored !== 'boolean' || !value.settings || typeof value.settings !== 'object' || Array.isArray(value.settings)) throw new Error('invalid router settings snapshot')
254 return { settings: normalizeSettings(value.settings), revision: Number(value.revision), stored: value.stored }
255}
256async function routerSettingsDown($: EngineInterface, error: unknown): Promise<void> {
257 const notice = t(await read($, lang), 'band.routerDown')
258 if (await read($, line) !== notice) await update($, line, () => notice)
259 await report($, 'router settings', error)
260}
261async function acceptSettings($: EngineInterface, snapshot: ModSettingsSnapshot, sent = 0): Promise<void> {
262 const sync = await update($, settingsSync, value => ({ ...value, revision: snapshot.revision, pending: value.pending.slice(sent), migration: snapshot.stored ? null : value.migration }))
263 const base = snapshot.stored ? snapshot.settings : sync.migration ?? snapshot.settings
264 await installSettings($, applySettingsChanges(base, sync.pending))
265 await rebaseDraft($)
266}
267async function putSettings($: EngineInterface, admin: string, base: Settings): Promise<void> {
268 for (let attempt = 0; attempt < 2; attempt++) {
269 const sync = await read($, settingsSync)
270 // A migration that lost a CAS race is not a user edit: never retry it as a full save.
271 if (!sync.pending.length && !sync.migration) return
272 const count = sync.pending.length
273 const response = await $.http.fetch(`${admin}/api/mod-settings`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ schemaVersion: MOD_SETTINGS_SCHEMA_VERSION, settings: applySettingsChanges(base, sync.pending), revision: sync.revision }) })
274 if (!response.ok && response.status !== 409) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
275 const snapshot = settingsSnapshot(response.text)
276 await acceptSettings($, snapshot, response.ok ? count : 0)
277 if (response.ok) return
278 base = snapshot.settings
279 if (attempt === 1) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: 409 }))
280 }
281}
282async function syncSettings($: EngineInterface, admin: string): Promise<void> {
283 const response = await $.http.fetch(`${admin}/api/mod-settings`)
284 if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
285 const snapshot = settingsSnapshot(response.text)
286 let sync = await read($, settingsSync)
287 // Session atoms survive reloads. Old pending paths/drafts cannot prove user intent
288 // under this format; discard them rather than granting an old overwrite a new version.
289 if (sync.schemaVersion !== MOD_SETTINGS_SCHEMA_VERSION) {
290 sync = await update($, settingsSync, () => ({ schemaVersion: MOD_SETTINGS_SCHEMA_VERSION, revision: null, pending: [], migrationChecked: false, migration: null }))
291 await update($, settingsBase, () => null)
292 }
293 if (snapshot.stored && sync.migration) sync = await update($, settingsSync, value => ({ ...value, migration: null }))
294 if (!sync.migrationChecked) {
295 let migration: Settings | null = null
296 if (!snapshot.stored) {
297 const legacy = await $.store.get('settings')
298 const setupDone = await $.store.get('setupDone')
299 if (legacy !== undefined && legacy !== null || setupDone === true) {
300 migration = normalizeSettings(legacy)
301 if (setupDone === true) migration.setupDone = true
302 }
303 }
304 sync = await update($, settingsSync, value => ({ ...value, migrationChecked: true, migration }))
305 }
306 if (sync.revision !== snapshot.revision || await read($, settings) === null) await acceptSettings($, snapshot)
307 sync = await read($, settingsSync)
308 if (sync.pending.length || sync.migration) await putSettings($, admin, snapshot.stored ? snapshot.settings : sync.migration ?? snapshot.settings)
309}
310async function loadSettings($: EngineInterface): Promise<void> {
311 try { await settingsTask(async () => { await syncSettings($, await adminUrl($)) }) }
312 catch (error) { await installSettings($, await currentSettings($)); await routerSettingsDown($, error) }
313 const hidden = (await $.store.get('bandHidden')) === true
314 await update($, bandHidden, () => hidden)
315 if (!(await read($, pick))) {
316 const carried = object(await $.store.get('pick'))
317 const level = effortOf(carried.effort)
318 if (level && ['haiku', 'jev', 'model', 'manual', 'followup'].includes(String(carried.by))) {
319 carriedPick = true
320 await update($, pick, () => ({ effort: level, why: typeof carried.why === 'string' ? carried.why : '', by: carried.by as Pick['by'], at: typeof carried.at === 'number' ? carried.at : 0 }))
321 }
322 }
323}
324async function saveSettings($: EngineInterface, s: Settings, baseline?: Settings): Promise<boolean> {
325 // Both sides use the same format: normalization alone is never an edit.
326 const changes = settingsChanges(normalizeSettings(baseline ?? await currentSettings($)), normalizeSettings(s))
327 try {
328 await settingsTask(async () => {
329 await update($, settingsSync, value => ({ ...value, pending: [...value.pending, ...changes] }))
330 await installSettings($, applySettingsChanges(await currentSettings($), changes))
331 const admin = await adminUrl($)
332 // Always rebase on the router, never PUT the normalized local atom wholesale.
333 await syncSettings($, admin)
334 })
335 return true
336 } catch (error) { await routerSettingsDown($, error); return false }
337}
338async function installPick($: EngineInterface, value: Pick): Promise<void> {
339 carriedPick = false
340 await update($, pick, () => value)
341 await $.store.set('pick', value)
342}
343
344// The engine's pane list is authoritative: a reload can close panes without sending ui.close.
345async function isLogUp($: EngineInterface): Promise<boolean> {
346 return (await $.ui.panes()).some(p => p.id === LOG_PANE)
347}
348async function syncPanes($: EngineInterface): Promise<void> {
349 const panes = await $.ui.panes()
350 const log = panes.some(p => p.id === LOG_PANE)
351 const agent = panes.some(p => p.id === AGENTS_PANE)
352 if (log !== await read($, logOpen)) await update($, logOpen, () => log)
353 if (agent !== await read($, agentsOpen)) await update($, agentsOpen, () => agent)
354}
355async function toggleLog($: EngineInterface): Promise<boolean> {
356 const language = await read($, lang)
357 const open = await isLogUp($)
358 if (open) await $.ui.close({ id: LOG_PANE })
359 else {
360 // The surface may decline to draw the pane; say why rather than doing nothing on a press.
361 const opened = await $.ui.open({ id: LOG_PANE, title: t(language, 'commands.logTitle') })
362 if (!opened.isPlaced) {
363 const why = 'reason' in opened && opened.reason ? String(opened.reason) : 'not placed'
364 $.ui.toast(t(language, 'toasts.logNotPlaced', { reason: why }))
365 await report($, 'log pane', why)
366 }
367 }
368 await syncPanes($)
369 if (!open) await refreshRouter($)
370 return !open
371}
372async function toggleAgents($: EngineInterface): Promise<void> {
373 const language = await read($, lang)
374 const open = (await $.ui.panes()).some(p => p.id === AGENTS_PANE)
375 if (open) await $.ui.close({ id: AGENTS_PANE })
376 else await $.ui.open({ id: AGENTS_PANE, title: t(language, 'commands.agentsTitle') })
377 await syncPanes($)
378}
379async function toggleBar($: EngineInterface): Promise<boolean> {
380 const hidden = !await read($, bandHidden)
381 await $.store.set('bandHidden', hidden)
382 await update($, bandHidden, () => hidden)
383 return !hidden
384}
385async function refreshRouter($: EngineInterface): Promise<void> {
386 const language = await read($, lang)
387 if (polling) return
388 polling = true
389 try {
390 await syncPanes($)
391 const admin = await adminUrl($)
392 await settingsTask(() => syncSettings($, admin))
393 await endModeIfOver($)
394 const open = await isLogUp($)
395 if (!open && await read($, bandHidden)) { await update($, line, () => null); return }
396 const session = await $.session.id()
397 const own = await $.http.fetch(`${admin}/api/requests?kind=messages&n=${open ? 60 : 1}&session=${encodeURIComponent(session)}`)
398 if (!own.ok) throw new Error(t(language, 'commands.routerHttp', { status: own.status }))
399 const mine = (object(JSON.parse(own.text)).requests ?? []) as StatusRequest[]
400 const newest = mine[0] ?? null
401 const notice = newest && newest.session === undefined ? t(language, 'band.routerRestart') : null
402 if (notice !== await read($, line)) await update($, line, () => notice)
403 // Do not show a route belonging to another chat from a router without session support.
404 const route = notice ? null : newest
405 if (JSON.stringify(route) !== JSON.stringify(await read($, last))) await update($, last, () => route)
406 if (!open) return
407 let shown = mine
408 if (!await read($, onlySession)) {
409 const all = await $.http.fetch(`${admin}/api/requests?kind=messages&n=60`)
410 if (!all.ok) throw new Error(t(language, 'commands.routerHttp', { status: all.status }))
411 shown = (object(JSON.parse(all.text)).requests ?? []) as StatusRequest[]
412 }
413 const rows = shown.map(record => ({ ...record, mine: record.session === session }))
414 if (JSON.stringify(rows) !== JSON.stringify(await read($, requests))) await update($, requests, () => rows)
415 } catch (error) {
416 if (await read($, line) !== t(language, 'band.routerDown')) await update($, line, () => t(language, 'band.routerDown'))
417 await report($, 'router poll', error)
418 } finally { polling = false }
419}
420
421// Files and availability are sampled at spawn, not cached across quota/account changes.
422async function workerFiles($: EngineInterface): Promise<Map<string, string>> {
423 const text = await optionalFile($, `${await rippleHome($)}/generated-agents.json`)
424 const names = text ? strings(object(JSON.parse(text)).agents) : []
425 const files = new Map<string, string>()
426 for (const name of names) {
427 if (!/^[A-Za-z0-9_.-]+$/.test(name)) continue
428 const file = await optionalFile($, `${await claudeHome($)}/agents/${name}.md`)
429 if (file && agentModel(file)) files.set(name, file)
430 }
431 return files
432}
433async function workerFix($: EngineInterface, spawn: Spawn): Promise<WorkerFix | null> {
434 const text = await optionalFile($, `${await rippleHome($)}/generated-agents.json`)
435 if (!text || !strings(object(JSON.parse(text)).agents).includes(spawn.subagentType)) return null
436 if (!/^[A-Za-z0-9_.-]+$/.test(spawn.subagentType)) return null
437 const file = await optionalFile($, `${await claudeHome($)}/agents/${spawn.subagentType}.md`)
438 const model = file ? agentModel(file) : undefined
439 return model ? fixSpawn(spawn, model) : null
440}
441async function availability($: EngineInterface): Promise<boolean> {
442 try {
443 const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
444 return status.ok ? chatgptUsable(JSON.parse(status.text)) : true
445 } catch (error) { await report($, 'availability unknown', error); return true }
446}
447async function refreshModels($: EngineInterface): Promise<void> {
448 const response = await $.http.fetch(`${await adminUrl($)}/api/config`)
449 if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
450 await update($, routerModels, () => routerModelOptions(JSON.parse(response.text)))
451}
452/** How long the rung choices are kept: ten minutes. */
453const KEEP_MS = 600000
454
455/**
456 * The quick settings' rung choices, read again each time the panel opens: a model ticked on the
457 * dashboard shows in every Select without a restart. A failure keeps what was there.
458 */
459async function refreshLadder($: EngineInterface): Promise<void> {
460 try {
461 const response = await $.http.fetch(`${await adminUrl($)}/api/mod-options`)
462 if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
463 const fresh = ladderOptionsFrom(JSON.parse(response.text), await $.clock.now() + KEEP_MS, (await currentSettings($)).allow, (await currentSettings($)).allowKnown)
464 if (!same(await read($, ladderOptions), fresh)) await update($, ladderOptions, () => fresh)
465 } catch (error) { await report($, 'ladder options', error) }
466}
467/** Turns a mode on (stamping when, for a mode that runs until the quota resets), or off with ''. */
468async function setMode($: EngineInterface, id: string): Promise<void> {
469 const change: Partial<Settings> = id ? { activeMode: id, activeSince: await $.clock.now() } : { activeMode: undefined, activeSince: undefined }
470 await settingsActions($).patch(change)
471}
472/** Keep observed reset deadlines while a mode is on, even if the next snapshot rolls forward. */
473let modeQuotaAt = 0
474let modeQuotaKey: string | null = null
475let modeQuotaWindows: QuotaWindow[] = []
476let endingMode = false
477/** The chosen provider's windows: Claude uses the band's binding limit; ChatGPT its account quota API. */
478async function modeWindows($: EngineInterface, provider: string): Promise<QuotaWindow[]> {
479 if (provider === 'anthropic') return claudeWindow(await read($, limit))
480 const response = await $.http.fetch(`${await adminUrl($)}/api/chatgpt-accounts?provider=${encodeURIComponent(provider)}`)
481 if (!response.ok) return []
482 const body: unknown = JSON.parse(response.text)
483 // The router answers for its first ChatGPT provider when the name is not one: not this provider.
484 return object(body).provider === provider ? quotaWindows(body) : []
485}
486/** A mode whose time is up turns itself off and says so (on each poll and prompt). */
487async function endModeIfOver($: EngineInterface): Promise<void> {
488 if (endingMode) return
489 endingMode = true
490 try {
491 const s = await currentSettings($)
492 const mode = activeModeOf(s)
493 const key = modeKey(s) + JSON.stringify([mode?.rungs, mode?.until])
494 if (modeQuotaKey !== key) { modeQuotaKey = key; modeQuotaAt = 0; modeQuotaWindows = [] }
495 if (!mode || mode.until === 'none') return
496 const now = await $.clock.now()
497 let windows = modeQuotaWindows
498 const provider = resetProvider(mode)
499 if (provider) {
500 // Claude's limit arrives with the session; read it on every check, not once a minute.
501 if (provider === 'anthropic') windows = [...windows, ...claudeWindow(await read($, limit))]
502 if (!modeOver(mode, s.activeSince, now, windows) && now - modeQuotaAt >= 60_000) {
503 const fresh = await modeWindows($, provider)
504 modeQuotaAt = now
505 // Keep only the earliest observed deadline after activation, plus current snapshots.
506 const first = [...windows, ...fresh].filter(w => w.at > (s.activeSince ?? now)).sort((a, b) => a.at - b.at)[0]
507 modeQuotaWindows = [...(first ? [first] : []), ...fresh]
508 windows = modeQuotaWindows
509 }
510 }
511 if (!modeOver(mode, s.activeSince, now, windows)) return
512 await saveSettings($, { ...s, activeMode: undefined, activeSince: undefined }, s)
513 await $.ui.toast(t(await read($, lang), 'toasts.modeEnded', { name: mode.name }))
514 } catch (error) { await report($, 'mode end', error) }
515 finally { endingMode = false }
516}
517async function jevKey($: EngineInterface, jevSource?: Settings['jevSource']): Promise<string | undefined> {
518 const selected = jevSource ?? normalizeSettings(await read($, settingsDraft) ?? await read($, settings)).jevSource
519 if (selected === 'zen') {
520 const file = await optionalFile($, `${await rippleHome($)}/config.json`)
521 const key = file ? zenJev(JSON.parse(file)).authorization : undefined
522 await update($, jev, value => ({ ...value, source: key ? 'zen' as const : null }))
523 return key
524 }
525 const env = (await $.env.get('TYPESAFE_API_KEY'))?.trim()
526 if (env) {
527 if ((await read($, jev)).source !== 'env') await update($, jev, value => ({ ...value, source: 'env' as const }))
528 return env
529 }
530 const file = await optionalFile($, `${await userHome($)}/.config/jev/.env`)
531 const key = file ? parseJevKey(file) : undefined
532 const source: 'pasted' | 'file' | null = key ? ((await read($, jev)).source === 'pasted' ? 'pasted' : 'file') : null
533 if ((await read($, jev)).source !== source) await update($, jev, value => ({ ...value, source }))
534 return key
535}
536/** OpenCode Zen's key from the router's config, when Zen lists a Jev model there; else none. */
537async function zenJevKey($: EngineInterface): Promise<string | undefined> {
538 const file = await optionalFile($, `${await rippleHome($)}/config.json`)
539 if (!file) return undefined
540 const config: unknown = JSON.parse(file)
541 const models = object(object(object(config).providers)['opencode-zen']).models
542 const listed = Array.isArray(models) && models.some(m => { const id = object(m).id; return typeof id === 'string' && id.toLowerCase().includes('jev') })
543 return listed ? zenJev(config).authorization : undefined
544}
545async function saveJevKey($: EngineInterface, input: string): Promise<void> {
546 const language = await read($, lang)
547 const key = parseJevKey(input) ?? input.trim()
548 if (!key || /[\r\n]/.test(key)) throw new Error(t(language, 'toasts.keySingle'))
549 const path = `${await userHome($)}/.config/jev/.env`
550 const old = await optionalFile($, path) ?? ''
551 const entry = `TYPESAFE_API_KEY=${key}`
552 const text = /^[ \t]*TYPESAFE_API_KEY[ \t]*=/m.test(old)
553 ? old.replace(/^[ \t]*TYPESAFE_API_KEY[ \t]*=[^\r\n]*/gm, () => entry)
554 : old + (old && !old.endsWith('\n') ? '\n' : '') + entry + '\n'
555 await $.fs.write(path, text)
556 const mode = await $.process.run(['chmod', '600', path])
557 if (mode.exitCode !== 0) throw new Error(t(language, 'toasts.keyPrivateFailed'))
558 await update($, jev, () => ({ source: 'pasted' as const, test: { state: 'idle' as const } }))
559 await update($, judgeDown, () => null)
560 await $.ui.toast(t(language, 'toasts.keySaved'))
561}
562/**
563 * A judge failure is said once per reason in a session (a toast and the band); the same reason
564 * again stays quiet, so a judge that keeps failing does not speak on every message.
565 */
566async function down($: EngineInterface, message: string, network = false): Promise<void> {
567 if ((await read($, judgeSeen)).includes(message)) return
568 await update($, judgeSeen, value => [...value, message].slice(-20))
569 await update($, judgeDownNetwork, () => network)
570 await update($, judgeDown, () => message)
571 await $.ui.toast(t(await read($, lang), 'toasts.judgeDown', { reason: message }))
572}
573
574type Judged = { verdict: Verdict | WorkerVerdict | undefined; by: JudgeKind; ms: number; error?: string }
575function judgeFailure(answer: ModelCompleteResult): string | undefined {
576 if (answer.isAnswered) return undefined
577 return answer.reason === 'api-error' ? `${answer.reason}: ${answer.error} (${answer.status ?? 'no response'})` : answer.reason
578}
579async function completeJudge($: EngineInterface, model: string, system: string, prompt: string): Promise<ModelCompleteResult> {
580 return $.model.complete({ model, system, prompt, maxTokens: 160, timeoutMs: 6000, effort: 'low' })
581}
582async function judge($: EngineInterface, s: Settings, input: JudgeInput | { description: string; prompt: string }, worker: boolean, testing = false): Promise<Judged> {
583 const language = await read($, lang)
584 const started = await $.clock.now()
585 const system = worker ? WORKER_JUDGE_SYSTEM : EFFORT_JUDGE_SYSTEM
586 const prompt = 'description' in input ? workerJudgeInput(input) : judgeContext(input)
587 let failure: string | undefined
588 let by: JudgeKind = s.judge
589 let verdict: Verdict | WorkerVerdict | undefined
590 let blip = false
591 if (s.judge === 'jev') {
592 try {
593 let source = s.jevSource
594 let key = await jevKey($, source)
595 // No TypeSafe key, but the router's OpenCode Zen offers Jev: ask Jev there.
596 if (!key && source === 'typesafe') {
597 key = await zenJevKey($)
598 if (key) source = 'zen'
599 }
600 if (!key) throw new Error(t(language, source === 'zen' ? 'jev.zenMissing' : 'toasts.keyMissing'))
601 const body = 'description' in input ? jevWorkerRequest(input) : jevEffortRequest(input)
602 let model = 'jev-latest'
603 if (source === 'zen') {
604 const file = await optionalFile($, `${await rippleHome($)}/config.json`)
605 model = file ? zenJev(JSON.parse(file)).model : model
606 }
607 const response = await Promise.race([
608 $.http.fetch(source === 'zen' ? 'https://opencode.ai/zen/v1/systemone' : 'https://api.typesafe.ai/v1/systemone', { method: 'POST', headers: { 'content-type': 'application/json', authorization: source === 'zen' ? key : `Bearer ${key}` }, body: JSON.stringify({ ...body, model }) })
609 .catch(() => 'network' as const),
610 $.clock.sleep(3000).then(() => null),
611 ])
612 // A dropped connection or a slow answer is often one bad moment, not a broken judge.
613 if (!response || response === 'network') {
614 blip = true
615 throw new Error(jevFailure(response ? 'network' : 'timeout', language))
616 }
617 if (!response.ok) throw new Error(jevFailure(response.status, language))
618 const json: unknown = JSON.parse(response.text)
619 // An unsure Jev still answered: its odds settle it here and the bias setting tips it later. Haiku is only for a Jev that failed.
620 verdict = worker ? parseJevWorker(json) : parseJevEffort(json, 'current' in input ? input.current : null, language)
621 await update($, stats, value => addJudge(value, 'jev', 0, jevUsage(json)))
622 if (!verdict) throw new Error(t(language, 'toasts.jevNoVerdict'))
623 } catch (error) { failure = reason(error); verdict = undefined }
624 }
625 if (s.judge === 'model') {
626 try {
627 const answer = await completeJudge($, s.judgeModel, system, prompt)
628 await update($, stats, value => addJudge(value, 'model', 0, answer.usage.input_tokens + answer.usage.output_tokens))
629 verdict = answer.isAnswered ? (worker ? parseWorkerVerdict(answer.text) : parseEffortVerdict(answer.text)) : undefined
630 if (!verdict) failure = judgeFailure(answer) ?? t(language, 'toasts.routerNoVerdict')
631 } catch (error) { failure = reason(error) }
632 }
633 if (!verdict) {
634 by = 'haiku'
635 try {
636 let answer = await completeJudge($, HAIKU, system, prompt)
637 await update($, stats, value => addJudge(value, 'haiku', 0, answer.usage.input_tokens + answer.usage.output_tokens))
638 if (!answer.isAnswered) {
639 failure ??= judgeFailure(answer)
640 answer = await completeJudge($, 'haiku', system, prompt)
641 await update($, stats, value => addJudge(value, 'haiku', 0, answer.usage.input_tokens + answer.usage.output_tokens))
642 }
643 verdict = answer.isAnswered ? (worker ? parseWorkerVerdict(answer.text) : parseEffortVerdict(answer.text)) : undefined
644 if (!verdict) failure ??= t(language, 'toasts.haikuNoVerdict')
645 } catch (error) { failure ??= reason(error) }
646 }
647 const ms = Math.max(0, await $.clock.now() - started)
648 await update($, stats, value => ({ ...value, judge: { ...value.judge, ms: value.judge.ms + ms } }))
649 if (!testing) {
650 // The log pane lists it: a Jev call never reaches the router, so nothing else would show it.
651 const run: JudgeRun = { at: started, for: worker ? 'worker' : 'prompt', asked: s.judge, by, ms, ...(verdict?.effort ? { effort: verdict.effort } : {}), ...(verdict?.sure !== undefined ? { sure: verdict.sure } : {}), ...(failure ? { error: failure } : {}) }
652 await update($, judgeRuns, value => [...value, run].slice(-40))
653 // A network blip or timeout is said only when it happens twice running; Haiku covers the one prompt.
654 jevBlips = blip ? jevBlips + 1 : 0
655 if (failure && !(blip && jevBlips < 2)) await down($, failure, blip)
656 else if (await read($, judgeDown)) await update($, judgeDown, () => null)
657 }
658 return { verdict, by, ms, ...(failure ? { error: failure } : {}) }
659}
660async function testJudge($: EngineInterface): Promise<void> {
661 const language = await read($, lang)
662 await update($, jev, value => ({ ...value, test: { state: 'testing' as const } }))
663 try {
664 const s = normalizeSettings(await read($, settingsDraft) ?? await read($, settings))
665 const judged = await judge($, s, { messages: [], next: 'Rename a local variable in one function and verify the result.' }, false, true)
666 const ok = !!judged.verdict && !judged.error
667 await update($, jev, value => ({ ...value, test: { state: ok ? 'ok' as const : 'fail' as const, message: `${judged.ms} ms · ${judged.verdict?.effort ? t(language, 'band.' + judged.verdict.effort).toLowerCase() : t(language, 'toasts.noVerdict')} · ${judged.error ?? judged.by}` } }))
668 } catch (error) {
669 await update($, jev, value => ({ ...value, test: { state: 'fail' as const, message: reason(error) } }))
670 }
671}
672
673async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings): Promise<{ input: AgentSpawnInput; pick: WorkerPick } | { deny: string }> {
674 const language = await read($, lang)
675 const files = await workerFiles($)
676 try { await refreshModels($) } catch (error) { await report($, 'worker catalogue', error) }
677 // Claude's own models are candidates as well: general-purpose on the model, effort by turn.step.
678 const pinnedCandidates = [...[...files].flatMap(([agent, file]) => { const model = agentModel(file)?.replace(/@[^@]*$/, ''); const provider = agentProvider(file); return model && provider ? [{ target: { agent }, model, provider }] : [] }), ...claudeCandidates()]
679 const usable = await availability($)
680 const marker = readMarker(e.prompt)
681 const tier = tierOf(marker)
682 const explicit = marker && !tier && !['auto', 'ripple'].includes(marker.name) ? marker : null
683 const id = (await $.clock.now()).toString(36) + Math.random().toString(36).slice(2, 8)
684 let verdict: WorkerVerdict = { domain: 'general', level: 'standard', effort: 'high', why: t(language, 'toasts.judgeUnavailable') }
685 let by: WorkerPick['by'] = 'default'
686 let ms = 0
687 let rawVerdict: WorkerVerdict | null = null
688 let judgeError: string | undefined
689 let target: TierTarget | undefined
690 if (explicit) {
691 // Agent names and their file-model ids are both valid explicit routing notes.
692 const name = files.has(explicit.name) ? explicit.name : [...files].find(([, f]) => agentModel(f)?.replace(/@[^@]*$/, '').toLowerCase() === explicit.name)?.[0]
693 target = name ? { agent: name } : { agent: 'general-purpose', model: explicit.name }
694 verdict = { domain: 'general', level: 'standard', effort: effortOf(explicit.level) ?? 'high', why: t(language, 'toasts.explicitMarker') }
695 by = 'marker'
696 } else if (tier) {
697 // The main chat named the tier. The judge still rates the same brief, unawaited, so the log
698 // can later say which of the two was right (see shadowJudge).
699 const named = TIER_MARKERS[tier]!
700 verdict = { domain: 'general', ...named, effort: RUNG_EFFORT[rungOf(named)], why: t(language, 'toasts.sessionTier', { tier }) }
701 by = 'session'
702 void shadowJudge($, s, id, { description: e.description, prompt: e.prompt })
703 } else {
704 const judged = await judge($, s, { description: e.description, prompt: e.prompt }, true)
705 if (judged.verdict && 'domain' in judged.verdict) { rawVerdict = judged.verdict; verdict = judged.verdict; by = judged.by }
706 ms = judged.ms
707 judgeError = judged.error
708 }
709 // Availability is request-time state, not the ten-minute panel catalogue. Refresh after the
710 // judge (which may itself take a while), before every pick, even when no mode is active.
711 await refreshLadder($)
712 const now = await $.clock.now()
713 const options = await read($, ladderOptions)
714 const providers = options && options.until > now ? Object.fromEntries(Object.entries(options.providers ?? {}).filter(([, p]) => p.exhaustedUntil === undefined || p.exhaustedUntil > now)) : {}
715 const explicitCandidate = target ? pinnedCandidates.find(c => c.target.agent === target!.agent) ?? (target.agent === 'general-purpose' ? { target, model: target.model ?? 'sonnet', provider: (await read($, routerModels)).find(m => m.value.toLowerCase() === target!.model)?.provider ?? 'anthropic' } : undefined) : undefined
716 if (explicitCandidate && s.providers[explicitCandidate.provider] === false) return { deny: t(language, 'workers.disabled', { provider: explicitCandidate.provider }) }
717 // An active mode moves the tiers; an explicit marker still names its own model.
718 const mode = activeModeOf(s)
719 const routing = routingSettings(s)
720 const resolved = explicitCandidate && candidateUsable(explicitCandidate, s, usable, providers)
721 ? { target: target!, effort: verdict.effort }
722 : ladderPick(verdict, routing.rules.general, routing, pinnedCandidates, usable, language, routing.allow, providers, routing.allowKnown)
723 const selected = resolved.target
724 if (!selected) return { deny: t(language, 'workers.none') }
725 const fallbackReason = resolved.fallbackReason
726 verdict = { ...verdict, effort: resolved.effort }
727 const model = spawnModel(selected, verdict.effort, files.has(selected.agent) ? agentModel(files.get(selected.agent)!) : undefined)
728 let prompt = e.prompt
729 if (marker) prompt = prompt.replace(MARKER, '').replace(/^\s*\n/, '')
730 const value: WorkerPick = { ...verdict, ...resolved, target: selected, by, ms, id, ...(fallbackReason ? { fallbackReason } : {}) }
731 // The verdict behind a pick is otherwise only in the agents panel; one debug line per pick keeps
732 // it checkable: what the judge said, the rung it landed on and what ran.
733 const entry = { task: e.description.slice(0, 200), domain: value.domain, level: value.level, kind: value.kind ?? 'do', sure: value.sure, rung: value.rung, position: value.position, skipped: value.skipped, target: value.target, model: model ?? null, effort: value.effort, by, ...(mode ? { mode: mode.name } : {}), ...(fallbackReason ? { fallbackReason } : {}) }
734 await $.ui.log('ClaudeRipple worker pick: ' + JSON.stringify(entry), { to: 'debug' })
735 // The brief itself is never logged (it can hold anything); its length is. To re-judge a pick,
736 // its brief is in the session's own transcript under the same description.
737 const named = by === 'marker' || by === 'default' || by === 'session'
738 await persistWorkerPick($, { type: 'pick', id, ...entry, at: new Date(now).toISOString(), verdict: rawVerdict, by: named ? by : 'judge', judge: named ? null : by, ...(tier ? { tier } : {}), ms, ...(judgeError ? { error: judgeError } : {}), promptChars: e.prompt.length })
739 if (fallbackReason) await $.ui.toast(t(language, 'toasts.workerFallback', { reason: fallbackReason, name: targetLabel(selected, language) }))
740 await update($, lastSpawn, () => value)
741 await update($, stats, current => addWorker(current, verdict.level))
742 return { input: { ...e, subagentType: selected.agent, model, prompt }, pick: value }
743}
744
745/**
746 * What the judge would have said of a brief the main chat already tiered: logged beside the pick
747 * (same `id`), never awaited by the spawn, never acted on. Together with each pick's outcome it is
748 * what decides later whether the judge stays.
749 */
750async function shadowJudge($: EngineInterface, s: Settings, id: string, input: { description: string; prompt: string }): Promise<void> {
751 try {
752 const judged = await judge($, s, input, true)
753 const verdict = judged.verdict && 'domain' in judged.verdict ? judged.verdict : null
754 await persistWorkerPick($, { type: 'shadow', pick: id, at: new Date(await $.clock.now()).toISOString(), verdict, rung: verdict ? rungOf(verdict) : null, judge: judged.by, ms: judged.ms, ...(judged.error ? { error: judged.error } : {}) })
755 } catch (error) { await report($, 'shadow judge', error) }
756}
757
758async function toggleAuto($: EngineInterface): Promise<void> {
759 const s = await currentSettings($)
760 await saveSettings($, { ...s, effortAuto: !s.effortAuto })
761}
762/** The band's flame turns keep-warm on or off for this chat only; Settings is what a new chat starts with. */
763async function keepWarmOn($: EngineInterface, s: Settings): Promise<boolean> {
764 return await read($, warmHere) ?? s.keepWarm
765}
766async function toggleWarm($: EngineInterface): Promise<void> {
767 const s = await currentSettings($)
768 const on = !await keepWarmOn($, s)
769 await update($, warmHere, () => on === s.keepWarm ? null : on)
770 if (on) await update($, warm, value => ({ ...value, stopped: undefined }))
771}
772async function toggleSave($: EngineInterface): Promise<void> {
773 const now = await $.clock.now()
774 const active = (await read($, saveUntil) ?? 0) > now
775 const until = active ? null : limitSaveUntil(await read($, hot), now)
776 await update($, saveUntil, () => until)
777}
778async function manualPick($: EngineInterface, effort: Effort): Promise<void> {
779 const language = await read($, lang)
780 await saveSettings($, { ...await currentSettings($), effortAuto: false })
781 await installPick($, { effort, by: 'manual', why: t(language, 'toasts.yourPick'), at: await $.clock.now() })
782 const draft = await $.prompt.read()
783 if (!draft.text.trim() || /^\/effort\s+\w+\s*$/.test(draft.text)) {
784 await $.prompt.fill({ text: `/effort ${effort}`, mode: 'replace' })
785 await $.ui.toast(t(language, 'toasts.effortEnter'))
786 }
787}
788// Only first-run setup has a draft; quick settings read the saved state directly.
789async function startPanel($: EngineInterface, which: 'settings' | 'setup'): Promise<void> {
790 try { await settingsTask(async () => { await syncSettings($, await adminUrl($)) }) }
791 catch (error) { await report($, 'panel settings', error) }
792 const s = await currentSettings($)
793 await update($, settingsDraft, () => which === 'setup' ? normalizeSettings(s) : null)
794 await update($, settingsBase, () => which === 'setup' ? normalizeSettings(s) : null)
795 if (which === 'setup') await update($, setupStep, () => 1)
796 await update($, panel, () => which)
797 await jevKey($)
798 if (which === 'settings') await refreshLadder($)
799}
800/**
801 * An open panel's draft follows changes saved elsewhere (another session, the web page): the
802 * person's own edits (draft against base) are replayed on the settings now held, and the base
803 * moves to them. Without a base (a draft from an older mod) the draft starts over from them.
804 */
805async function rebaseDraft($: EngineInterface): Promise<void> {
806 if (await read($, panel) !== 'setup') return
807 const draft = await read($, settingsDraft)
808 if (!draft) return
809 const fresh = await currentSettings($)
810 const base = await read($, settingsBase)
811 const next = base ? applySettingsChanges(fresh, settingsChanges(normalizeSettings(base), normalizeSettings(draft))) : normalizeSettings(fresh)
812 if (base && JSON.stringify(normalizeSettings(base)) === JSON.stringify(fresh)) return
813 await update($, settingsBase, () => normalizeSettings(fresh))
814 if (JSON.stringify(next) !== JSON.stringify(normalizeSettings(draft))) await update($, settingsDraft, () => next)
815}
816async function patchDraft($: EngineInterface, change: Partial<Settings>): Promise<void> {
817 const current = normalizeSettings(await read($, settingsDraft) ?? await read($, settings))
818 await update($, settingsDraft, () => normalizeSettings({ ...current, ...change }))
819 if (change.jevSource) { await update($, jev, value => ({ ...value, test: { state: 'idle' as const } })); await jevKey($, change.jevSource) }
820}
821async function finishSetup($: EngineInterface, skip = false): Promise<void> {
822 // A field whose rule picks as the main choice does is stored as no exception.
823 const draft = normalizeSettings(skip ? await read($, settings) : await read($, settingsDraft))
824 const saved = await saveSettings($, { ...draft, setupDone: true }, skip ? undefined : normalizeSettings(await read($, settingsBase) ?? await currentSettings($)))
825 await update($, panel, () => null)
826 await update($, settingsDraft, () => null)
827 await update($, settingsBase, () => null)
828 await $.ui.toast(t(await read($, lang), saved ? 'toasts.settingsSaved' : 'band.routerDown'))
829}
830async function openHandoff($: EngineInterface): Promise<void> {
831 const active = await read($, handoff)
832 if (active && ['writing', 'clearing'].includes(active.stage)) return
833 const choice = object(await $.store.get('handoffChoice'))
834 await update($, handoffChoiceKind, () => choice.kind === 'full' ? 'full' : 'quick')
835 const after = ['continue', 'confirm', 'copy'].includes(String(choice.after)) ? choice.after as Settings['handoffAfter'] : (await currentSettings($)).handoffAfter
836 await update($, handoffChoiceAfter, () => after)
837 await update($, panel, value => value === 'handoff' ? null : 'handoff')
838}
839async function queueHandoff($: EngineInterface, kind?: 'quick' | 'full'): Promise<void> {
840 const active = await read($, handoff)
841 if (active && ['writing', 'clearing'].includes(active.stage)) return
842 const selected = kind ?? await read($, handoffChoiceKind)
843 const after = kind ? (await currentSettings($)).handoffAfter : await read($, handoffChoiceAfter)
844 await update($, handoffChoiceKind, () => selected)
845 await update($, handoffChoiceAfter, () => after)
846 await $.store.set('handoffChoice', { kind: selected, after })
847 await update($, handoff, () => ({ stage: 'writing', kind: selected }))
848 await update($, panel, () => null)
849 await update($, advice, () => null)
850 await update($, handoffQueued, () => true)
851}
852async function copyHandoff($: EngineInterface): Promise<void> {
853 const language = await read($, lang)
854 const value = await read($, handoff)
855 if (!value?.text) return
856 const text = wrapForNewChat(value.text, 'continue', value.kind, !!(await currentSettings($)).handoffSkill)
857 try {
858 const copied = await $.ui.copy({ text })
859 if (!copied.isCopied) throw new Error(copied.reason)
860 } catch (error) {
861 await $.prompt.fill({ text, mode: 'replace' })
862 await $.ui.toast(t(language, 'toasts.clipboardFallback'))
863 await report($, 'copy fallback', error)
864 }
865 await update($, handoff, () => ({ ...value, stage: 'copied' }))
866}
867/**
868 * The summary is written: Copy only copies it; Confirm first stops on a card that asks before
869 * moving; Continue moves to a new session at once. This chat is never cleared (2026-10-13: the
870 * owner read "Confirm first" as asking before the clear, and lost the chat to it).
871 */
872async function finishHandoff($: EngineInterface): Promise<void> {
873 const value = await read($, handoff)
874 if (!value?.text || value.stage !== 'writing') return
875 const after = await read($, handoffChoiceAfter)
876 if (after === 'copy') { await copyHandoff($); return }
877 if (after === 'confirm') { await update($, handoff, () => ({ ...value, stage: 'ready' })); return }
878 await moveToNewSession($)
879}
880/** A summary longer than this goes to a file the new session reads; the app takes ~14k characters in a link. */
881const LINK_TEXT = 12_000
882/**
883 * Opens a new Code session in this session's folder with the handoff in its prompt box
884 * (claude://code/new?folder=…&q=…, the desktop app's own link: it fills the box and waits for
885 * Enter, never sends by itself). A long summary is saved under ~/.clauderipple/handoffs and the box
886 * says to read it. Where no link can be opened, the text is copied instead.
887 */
888async function moveToNewSession($: EngineInterface): Promise<void> {
889 const value = await read($, handoff)
890 if (!value?.text) return
891 const language = await read($, lang)
892 let text = wrapForNewChat(value.text, 'continue', value.kind, !!(await currentSettings($)).handoffSkill)
893 try {
894 if (text.length > LINK_TEXT) {
895 const path = `${await rippleHome($)}/handoffs/handoff-${await $.clock.now()}.md`
896 await $.fs.write(path, text + '\n')
897 text = t(language, 'alerts.readHandoffFile', { path })
898 }
899 const folder = await $.session.cwd()
900 const url = 'claude://code/new?' + new URLSearchParams({ ...(folder ? { folder } : {}), q: text, source: 'services' }).toString()
901 await openUrl($, url)
902 await update($, handoff, () => ({ ...value, stage: 'opened' }))
903 } catch (error) {
904 await report($, 'open new session', error)
905 await copyHandoff($)
906 await $.ui.toast(t(language, 'toasts.newSessionFallback'))
907 }
908}
909async function fork($: EngineInterface, prompt: string): Promise<ModelForkResult | null> {
910 const epoch = generation
911 forking = true
912 forkUsage = null
913 try {
914 const answer = await $.model.fork({ prompt })
915 // Real forks dispatch turn.step; test stubs and older engines can provide only the result.
916 const usage = answer && 'usage' in answer ? answer.usage : forkUsage
917 if (usage && epoch === generation) {
918 await update($, stats, value => addWarm(value, usage))
919 if ((usage.cache_read_input_tokens ?? 0) > 0) {
920 const memo = nextMemo(await read($, cache), usage, await $.clock.now())
921 await update($, cache, () => memo)
922 }
923 }
924 return answer
925 } finally { forking = false; forkUsage = null }
926}
927async function driveHandoff($: EngineInterface): Promise<void> {
928 const language = await read($, lang)
929 const value = await read($, handoff)
930 if (!value || value.stage !== 'writing' || await read($, busy)) return
931 if (!value.text && !await read($, handoffQueued)) return
932 try {
933 if (value.text) { await finishHandoff($); return }
934 await update($, handoffQueued, () => false)
935 if (value.kind === 'quick') {
936 let answer: ModelForkResult | null = null
937 try { answer = await fork($, QUICK_PROMPT) }
938 catch (error) { await report($, 'quick fork fallback', error) }
939 if (answer?.isAnswered && answer.text.trim()) {
940 await update($, handoff, () => ({ ...value, text: answer.text }))
941 await finishHandoff($)
942 return
943 }
944 }
945 await update($, handoffWaiting, () => true)
946 const s = await currentSettings($)
947 if (value.kind === 'full' && s.handoffSkill) {
948 const [command, ...args] = s.handoffSkill.split(/\s+/)
949 await $.command.run({ command: command!, args: args.join(' ') })
950 } else await $.prompt.submit({ text: value.kind === 'full' ? FULL_PROMPT : QUICK_PROMPT })
951 } catch (error) {
952 await update($, handoff, () => ({ ...value, stage: 'failed' }))
953 await update($, handoffWaiting, () => false)
954 await $.ui.toast(t(language, 'toasts.handoffFailed'))
955 await report($, 'handoff', error)
956 }
957}
958async function runCompact($: EngineInterface, note = ''): Promise<void> {
959 const language = await read($, lang)
960 await update($, panel, () => null)
961 await update($, advice, () => null)
962 await update($, resultCard, () => ({ stage: 'compacting' }))
963 try {
964 await $.command.run({ command: 'compact', args: note })
965 await update($, resultCard, () => ({ stage: 'compacted' }))
966 } catch (error) {
967 await update($, resultCard, () => ({ stage: 'failed' as const, detail: t(language, 'alerts.compactFailed') }))
968 await $.ui.toast(t(language, 'toasts.compactFailed'))
969 await report($, 'compact command', error)
970 }
971}
972async function openCompact($: EngineInterface, surface: Frame['surface']): Promise<void> {
973 if (surface === 'terminal') { await runCompact($); return }
974 await update($, compactNote, () => '')
975 await update($, panel, () => 'compact')
976}
977async function openClear($: EngineInterface): Promise<void> {
978 await update($, panel, () => 'clear')
979}
980/** The router's figure for this login's plan windows; nothing when the router cannot say. */
981async function routerClaudeLimits($: EngineInterface): Promise<SessionRateLimit[]> {
982 try {
983 const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
984 return status.ok ? claudeLimitsFrom(JSON.parse(status.text), await $.clock.now()) : []
985 } catch (error) { await report($, 'claude limits', error); return [] }
986}
987/** The router's figures for the Claude login (5-hour, weekly, per-model weeklies), at most one
988 * status lookup a minute and only while the limits row can show them; a failure keeps the last
989 * good numbers rather than blanking them. */
990let routerClaudeAt = 0
991let routerClaudeCache: { limits: SessionRateLimit[]; scoped: ScopedLimit[] } = { limits: [], scoped: [] }
992async function routerClaude($: EngineInterface): Promise<{ limits: SessionRateLimit[]; scoped: ScopedLimit[] }> {
993 const now = await $.clock.now()
994 if (!(await currentSettings($)).extras.includes('limits') || now - routerClaudeAt < 60_000) return routerClaudeCache
995 routerClaudeAt = now
996 try {
997 const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
998 if (status.ok) {
999 const body: unknown = JSON.parse(status.text)
1000 routerClaudeCache = { limits: claudeLimitsFrom(body, now), scoped: claudeScopedFrom(body, now) }
1001 }
1002 } catch (error) { await report($, 'claude limits row', error) }
1003 return routerClaudeCache
1004}
1005/**
1006 * The limits row as a router that predates `GET /api/limits` still answers it: Claude's plan in the
1007 * unified shape, from the older `/api/status` and `/api/chatgpt-accounts` routes. Kept so the row
1008 * shows exactly as before until the router is restarted onto the new endpoint.
1009 */
1010async function fallbackLimits($: EngineInterface): Promise<LimitItem[]> {
1011 const now = await $.clock.now()
1012 const fromRouter = await routerClaude($)
1013 const claudeChat = /^claude-/.test(await $.session.model())
1014 // The 5-hour and the weekly (one each, the hotter reading winning when both sources name it), then
1015 // the per-model weeklies the router reports. Claude's plan shows whatever this chat runs on.
1016 const usage = claudeChat ? (await $.session.usage()).rateLimits : []
1017 const mains = new Map<'five_hour' | 'seven_day', SessionRateLimit>()
1018 for (const w of [...usage, ...fromRouter.limits]) {
1019 if (w.kind !== 'five_hour' && w.kind !== 'seven_day') continue
1020 const prev = mains.get(w.kind)
1021 if (!prev || w.percentUsed > prev.percentUsed) mains.set(w.kind, w)
1022 }
1023 const items: LimitItem[] = (['five_hour', 'seven_day'] as const)
1024 .flatMap(kind => mains.get(kind) ? [mains.get(kind)!] : [])
1025 .map(w => ({ provider: 'claude', type: 'anthropic', title: 'Claude', account: 0, kind: w.kind as 'five_hour' | 'seven_day', label: '', percent: w.percentUsed, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) }))
1026 for (const s of fromRouter.scoped) items.push({ provider: 'claude', type: 'anthropic', title: 'Claude', account: 0, kind: 'scoped', label: s.name, percent: s.percent, ...(s.resetsAt ? { resetsAt: s.resetsAt } : {}) })
1027 const admin = await adminUrl($)
1028 const status = await $.http.fetch(`${admin}/api/status`)
1029 if (status.ok) for (const provider of chatgptProviders(JSON.parse(status.text))) {
1030 const response = await $.http.fetch(`${admin}/api/chatgpt-accounts?provider=${encodeURIComponent(provider)}`)
1031 if (!response.ok) continue
1032 const body: unknown = JSON.parse(response.text)
1033 // The router answers for its first ChatGPT provider when the name is not one: not this provider.
1034 if (object(body).provider !== provider) continue
1035 items.push(...gptLimitsFrom(body, provider, now))
1036 }
1037 return items
1038}
1039/**
1040 * The whole limits row from the router's `GET /api/limits` — every provider whose quota it can read,
1041 * Claude first — at most one round a minute and only while the row can show it. On a router that
1042 * predates the endpoint (404) the older per-provider routes answer instead, so the row shows before
1043 * the router is restarted. A failure keeps the last good row rather than blanking it, and the state
1044 * is written only when it moved: a redraw that shows nothing new is wasted (each answer is drawn
1045 * afresh).
1046 */
1047let limitsAt = 0
1048async function refreshLimits($: EngineInterface): Promise<void> {
1049 if (!(await currentSettings($)).extras.includes('limits')) return
1050 const now = await $.clock.now()
1051 if (now - limitsAt < 60_000) return
1052 limitsAt = now
1053 try {
1054 const admin = await adminUrl($)
1055 const response = await $.http.fetch(`${admin}/api/limits`)
1056 let items: LimitItem[] | null = null
1057 if (response.ok) items = routerLimitItems(JSON.parse(response.text), now)
1058 else if (response.status === 404) items = await fallbackLimits($)
1059 if (items && !same(items, await read($, limits))) await update($, limits, () => items)
1060 } catch (error) { await report($, 'limits', error) }
1061}
1062async function refreshUsage($: EngineInterface): Promise<void> {
1063 const usage = await $.session.usage()
1064 const c = usage.context
1065 const percent = c.percent ?? (c.tokens !== undefined && c.window > 0 ? c.tokens / c.window * 100 : undefined)
1066 const ctx = c.tokens !== undefined && percent !== undefined ? { tokens: c.tokens, percent, window: c.window } : null
1067 if (JSON.stringify(ctx) !== JSON.stringify(await read($, context))) await update($, context, () => ctx)
1068 // Below the line again (a compact, a clear), the next crossing shows the heavy-context band once.
1069 const putOff = await read($, swampHidden)
1070 if (putOff !== null && (putOff > 100 || !ctx || ctx.percent < (await currentSettings($)).swampAt)) await update($, swampHidden, () => null)
1071 // The host can leave the plan windows out though the replies carried them; on a Claude chat the
1072 // router's figure for the same login stands in.
1073 const own = usage.rateLimits.some(l => ['five_hour', 'seven_day'].includes(l.kind))
1074 const rateLimits = own || !/^claude-/.test(await $.session.model()) ? usage.rateLimits : [...usage.rateLimits, ...await routerClaudeLimits($)]
1075 const h = hottest(rateLimits)
1076 const previous = await read($, hot)
1077 // A put-off lasts until the window it was put off for moves on (session.ts hotWindowMoved). A
1078 // put-off from an older mod carries no window: it adopts the current one.
1079 if (await read($, hotHidden) !== null) {
1080 const putOffFor = await read($, hotHiddenFor)
1081 if (!putOffFor) { if (h) await update($, hotHiddenFor, () => h) }
1082 else if (hotWindowMoved(putOffFor, h)) { await update($, hotHidden, () => null); await update($, hotHiddenFor, () => null) }
1083 }
1084 if (JSON.stringify(h) !== JSON.stringify(previous)) await update($, hot, () => h)
1085 const top = rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
1086 const l = top ? { kind: top.kind, percent: top.percentUsed, ...(top.resetsAt ? { resetsAt: top.resetsAt } : {}) } : null
1087 // The limits row itself is read from the router's `/api/limits` (refreshLimits); the host's own
1088 // windows still bind the first row's limit and the hot alert here, as they always have.
1089 // Check the previous binding deadline before a rolled-forward snapshot replaces it.
1090 await endModeIfOver($)
1091 if (JSON.stringify(l) !== JSON.stringify(await read($, limit))) await update($, limit, () => l)
1092 await endModeIfOver($)
1093 const until = await read($, saveUntil)
1094 if (until !== null && until <= await $.clock.now()) await update($, saveUntil, () => null)
1095}
1096/**
1097 * Redraws when the countdown's shown minute changes and at expiry (the cold alert), at no other
1098 * time: the desktop draws each answer afresh (see ui/band.tsx), so a redraw that shows nothing new
1099 * is wasted at best. With the timer not on show, only the expiry. The 15-second tick re-aims it
1100 * after a reply moved the expiry.
1101 */
1102async function scheduleCountdown($: EngineInterface): Promise<void> {
1103 const memo = await read($, cache)
1104 const now = await $.clock.now()
1105 const shown = !(await currentSettings($)).hide.includes('timer') && !await read($, bandHidden)
1106 const at = !memo || memo.expiresAt <= now ? null : shown ? nextMinuteChange(memo.expiresAt, now) : memo.expiresAt
1107 if (countdownTimer?.at === at) return
1108 countdownTimer?.cancel()
1109 countdownTimer = null
1110 if (at === null) return
1111 const timer = $.clock.after(Math.max(1, at - now), () => {
1112 countdownTimer = null
1113 void (async () => {
1114 await $.ui.invalidate('ui.render')
1115 await scheduleCountdown($)
1116 })().catch(error => report($, 'countdown', error))
1117 })
1118 countdownTimer = { at, cancel: () => timer.cancel() }
1119}
1120async function tick($: EngineInterface): Promise<void> {
1121 const language = await read($, lang)
1122 if (ticking) return
1123 ticking = true
1124 try {
1125 await refreshUsage($)
1126 await refreshLimits($)
1127 const now = await $.clock.now()
1128 const list = await read($, agents)
1129 if (list.some(a => a.state === 'running' && a.toolSince !== undefined && now - a.toolSince >= 8000)) {
1130 await update($, agents, value => value.map(a => a.state === 'running' && a.toolSince !== undefined && now - a.toolSince >= 8000 ? { ...a, state: 'waiting' } : a))
1131 }
1132 await scheduleCountdown($)
1133 if (await read($, handoff)) await driveHandoff($)
1134 const queued = await read($, adviceQueue)
1135 if (queued && !await read($, busy)) {
1136 await update($, adviceQueue, () => null)
1137 const answer = await $.model.complete({ model: HAIKU, system: ADVICE_SYSTEM, prompt: queued, effort: 'low', maxTokens: 60, timeoutMs: 8000 })
1138 if (answer.isAnswered) await update($, advice, () => parseAdvice(answer.text))
1139 }
1140 const s = { ...await currentSettings($) }
1141 s.keepWarm = await keepWarmOn($, s)
1142 const decision = shouldPing({ settings: s, memo: await read($, cache), warm: await read($, warm), now: await $.clock.now(), idle: !await read($, busy) && !await read($, deciding) && !forking,
1143 contextTokens: (await read($, context))?.tokens ?? 0, lastRealReplyAt: await read($, lastRealReplyAt) })
1144 if (decision.ping) {
1145 let answer: ModelForkResult | null = null
1146 try { answer = await fork($, 'Reply with just: ok') }
1147 catch (error) { await report($, 'warm fork', error) }
1148 const before = await read($, warm)
1149 const after = afterPing(before, answer, await $.clock.now(), language)
1150 await update($, warm, () => after)
1151 if (after.stopped && !before.stopped) await $.ui.toast(t(language, 'toasts.warmStopped', { reason: after.stopped }))
1152 // A ping read cache tokens: the cache held while the person was away. One card counts them all
1153 // (a real reply or the expiry clears it; register.tsx kept), until then each ping adds up.
1154 const usage = answer && 'usage' in answer ? answer.usage : null
1155 if ((usage?.cache_read_input_tokens ?? 0) > 0) {
1156 await update($, kept, prev => ({
1157 pings: (prev?.pings ?? 0) + 1,
1158 tokens: usage!.cache_read_input_tokens,
1159 }))
1160 }
1161 } else if (s.keepWarm && !await read($, busy) && decision.reason === 'keep warm time bound reached' && !(await read($, warm)).stopped) {
1162 await update($, warm, value => ({ ...value, stopped: t(language, 'toasts.warmBoundReason') }))
1163 await $.ui.toast(t(language, 'toasts.warmBound'))
1164 }
1165 } catch (error) { await report($, 'session tick', error) }
1166 finally { ticking = false }
1167}
1168function startTimers($: EngineInterface): void {
1169 if (timersStarted) return
1170 timersStarted = true
1171 $.clock.after(1, async () => { await refreshRouter($); await tick($) })
1172 $.clock.every(3000, async () => { await refreshRouter($) })
1173 $.clock.every(15000, async () => { await tick($) })
1174}
1175
1176async function dashboard($: EngineInterface, working: boolean): Promise<DashboardView> {
1177 const s = await currentSettings($)
1178 const p = await read($, pick)
1179 const list = await read($, agents)
1180 const record = await read($, last)
1181 const saving = (await read($, saveUntil) ?? 0) > await $.clock.now()
1182 const l = await read($, limit)
1183 const w = await read($, warm)
1184 return {
1185 effort: { level: p?.effort ?? effortOf(await read($, appEffort)) ?? null,
1186 mode: await read($, deciding) ? 'deciding' : await read($, paused) ? 'paused' : p?.by === 'manual' ? 'manual' : s.effortAuto ? 'auto' : 'off',
1187 ...(p?.why ? { why: p.why, by: p.by } : {}), ...(await read($, decidingSince) !== null ? { since: (await read($, decidingSince))! } : {}) },
1188 model: await read($, modelIs) ? modelName((await read($, modelIs))!) : null,
1189 context: await read($, context), cache: await read($, cache), warm: { ...w, on: await keepWarmOn($, s), ...(await read($, warmHere) !== null ? { here: true } : {}) },
1190 limit: l ? { ...l, save: saving } : null, route: record ? routeOf(record) : null, notice: await read($, line),
1191 limits: await read($, limits) ?? [],
1192 auto: s.effortAuto, handoff: await read($, advice) ? 'advised' : s.handoffButton === 'always' ? 'always' : 'off',
1193 agents: { live: list.filter(a => ['running', 'waiting'].includes(a.state)).length, total: list.length },
1194 logOpen: await read($, logOpen), agentsOpen: await read($, agentsOpen), effortMenu: await read($, effortMenuOpen),
1195 swampAt: s.swampAt, hide: s.hide, extras: s.extras, isWorking: working,
1196 ...(activeModeOf(s) ? { mode: activeModeOf(s)!.name } : {}),
1197 }
1198}
1199async function settingsView($: EngineInterface): Promise<SettingsView> {
1200 return { settings: await currentSettings($), jev: await read($, jev), ladder: await read($, ladderOptions) }hooks/settings.ts 329 lines1import type { Calibration, Domain, Effort, Level, Mode, Rule, Rung, Settings, SettingsChange, Skin, Step, TierTarget } from '../types/index.d.ts'
2
3/** Bump whenever a writer's normalization can lose another format's fields. */
4export const MOD_SETTINGS_SCHEMA_VERSION = 1
5
6export const DOMAINS: readonly Domain[] = ['general', 'design', 'frontend', 'backend', 'infra', 'data', 'test', 'docs', 'research']
7export const LEVELS: readonly Level[] = ['light', 'standard', 'deep']
8export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
9/** The worker ladder, bottom to top (docs/specs/2026-10-11-worker-ladder.md). */
10export const RUNGS: readonly Rung[] = ['light', 'standard', 'hard', 'plan']
11/** The rung each judged level lands on before kind and confidence move it. */
12export const LEVEL_RUNG: Readonly<Record<Level, Rung>> = { light: 'light', standard: 'standard', deep: 'hard' }
13/** A rung's effort when nothing else says one: a stored rung without one, a migrated pin. */
14export const RUNG_EFFORT: Readonly<Record<Rung, Effort>> = { light: 'low', standard: 'medium', hard: 'high', plan: 'high' }
15
16const claude = (model: string, effort: Effort): Step => ({ target: { agent: 'general-purpose', model }, effort })
17
18export const DEFAULTS: Settings = {
19 effortAuto: true, judge: 'haiku', judgeModel: 'deepseek-v4.1-flash', jevSource: 'typesafe',
20 bias: 0, floor: 'low', ceiling: 'max', agentAuto: 'auto',
21 rules: {
22 // The spec's "Claude + GPT" example; a rung whose worker is missing moves up and says so.
23 // Only the general rule: the four workers replaced the per-field rules (2026-10-12).
24 general: { mode: 'ladder', rungs: {
25 light: [{ target: { agent: 'deepseek-v4-1-flash' }, effort: 'max' }], standard: [{ target: { agent: 'gpt-6-1-sol' }, effort: 'medium' }],
26 hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
27 } },
28 },
29 allow: [], allowKnown: [], modes: [],
30 providers: {},
31 fallback: { agent: 'general-purpose', model: 'sonnet' },
32 keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
33 compactWith: 'haiku', handoffSkill: '', handoffAfter: 'continue', handoffButton: 'advised',
34 swampAt: 50, swampAlert: false, layout: 'dashboard', appearance: 'auto', skin: 'deep', language: 'auto', hide: ['reason'], extras: [], setupDone: false,
35}
36
37/** The desktop's skins, in the order the panel offers them. */
38export const SKINS: readonly Skin[] = ['deep', 'flat']
39
40/** Band chips that show only when switched on in Look. */
41export const EXTRAS: readonly string[] = ['context', 'limits']
42
43/**
44 * Claude's own models as workers: the built-in general-purpose agent run on the model, `model` its
45 * current id. The key is what the allow-list stores. Mythos is not here: the Agent tool's `model`
46 * takes only sonnet, opus, haiku and fable (Claude Code 2.1.295, read 2026-10-14), so a
47 * general-purpose worker on it would be refused.
48 */
49export const CLAUDE_WORKERS: readonly { name: string; model: string; provider: string; target: TierTarget }[] = [
50 { name: 'general-purpose:haiku', model: 'claude-haiku-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'haiku' } },
51 { name: 'general-purpose:sonnet', model: 'claude-sonnet-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'sonnet' } },
52 { name: 'general-purpose:opus', model: 'claude-opus-5-5', provider: 'anthropic', target: { agent: 'general-purpose', model: 'opus' } },
53 { name: 'general-purpose:fable', model: 'claude-fable-5-1', provider: 'anthropic', target: { agent: 'general-purpose', model: 'fable' } },
54]
55
56/** The allow-list key of a target: the agent's name, or `general-purpose:<model>` for Claude's own. */
57export function candidateKey(target: TierTarget): string {
58 return target.model ? target.agent + ':' + target.model : target.agent
59}
60
61function object(raw: unknown): Record<string, unknown> {
62 return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}
63}
64function names(raw: unknown): string[] {
65 return Array.isArray(raw) ? [...new Set(raw.filter((v): v is string => typeof v === 'string' && !!v.trim()).map(v => v.trim()))] : []
66}
67function target(raw: unknown, fallback?: TierTarget): TierTarget | undefined {
68 const value = object(raw)
69 const agent = typeof value.agent === 'string' && value.agent.trim() ? value.agent.trim() : fallback?.agent
70 const model = typeof value.model === 'string' && value.model.trim() ? value.model.trim() : fallback?.model
71 return agent ? { agent, ...(model ? { model } : {}) } : undefined
72}
73function calibration(value: Record<string, unknown>): Calibration {
74 const out: Calibration = {}
75 if ([-2, -1, 0, 1, 2].includes(Number(value.effortOffset)) && typeof value.effortOffset === 'number') out.effortOffset = value.effortOffset as NonNullable<Calibration['effortOffset']>
76 for (const field of ['floor', 'ceiling'] as const) if (EFFORTS.includes(value[field] as Effort)) out[field] = value[field] as Effort
77 return out
78}
79function copy<T>(value: T): T {
80 return JSON.parse(JSON.stringify(value)) as T
81}
82function sameTarget(a: TierTarget | undefined, b: TierTarget | undefined): boolean {
83 return !!a && !!b && a.agent === b.agent && a.model === b.model
84}
85/** An effort moved by calibration steps, then held between floor and ceiling (the floor wins). */
86function calibrated(effort: Effort, cal: Calibration): Effort {
87 const min = EFFORTS.indexOf(cal.floor ?? 'low')
88 const max = Math.max(min, EFFORTS.indexOf(cal.ceiling ?? 'max'))
89 const shifted = Math.max(0, Math.min(4, EFFORTS.indexOf(effort) + (cal.effortOffset ?? 0)))
90 return EFFORTS[Math.max(min, Math.min(max, shifted))]!
91}
92/** One effort step up, max staying max. */
93export function effortUp(effort: Effort): Effort {
94 return EFFORTS[Math.min(4, EFFORTS.indexOf(effort) + 1)]!
95}
96function step(raw: unknown, rung: Rung): Step | undefined {
97 const v = object(raw)
98 const chosen = target(v.target)
99 if (!chosen) return undefined
100 // A rung's backup model is dropped on load: the four workers have none (the next one up stands in).
101 return { target: chosen, effort: EFFORTS.includes(v.effort as Effort) ? v.effort as Effort : RUNG_EFFORT[rung] }
102}
103/** The effort a migrated rung starts at: the default ladder's when it runs the same model there. */
104function migratedEffort(rung: Rung, chosen: TierTarget): Effort {
105 const own = DEFAULTS.rules.general.rungs[rung]?.[0]
106 return own && sameTarget(own.target, chosen) ? own.effort : RUNG_EFFORT[rung]
107}
108/**
109 * A pin stored before 2026-10-11 as a ladder: light/standard/deep become Light/Standard/Hard, a
110 * missing level takes the nearest one above (as the pin climbed) or else below, each rung's effort
111 * is its default calibrated as the pin was, and Plan is Hard's model one effort higher.
112 */
113export function ladderFromPin(targets: Partial<Record<Level, TierTarget>>, cal: Calibration): Rule {
114 const known = LEVELS.filter(level => targets[level])
115 if (!known.length) return copy(DEFAULTS.rules.general)
116 const rungs: Partial<Record<Rung, Step>> = {}
117 LEVELS.forEach((level, at) => {
118 const above = LEVELS.slice(at).find(l => targets[l])
119 const chosen = targets[above ?? [...LEVELS.slice(0, at)].reverse().find(l => targets[l])!]!
120 const rung = LEVEL_RUNG[level]
121 rungs[rung] = { target: { ...chosen }, effort: calibrated(migratedEffort(rung, chosen), cal) }
122 })
123 rungs.plan = { target: { ...rungs.hard!.target }, effort: effortUp(rungs.hard!.effort) }
124 return { mode: 'ladder', rungs: ladderRungs(rungs) }
125}
126/** Single steps from older saves become one-item lists; malformed items are dropped in place. */
127function ladderRungs(raw: unknown): Rule['rungs'] {
128 const source = object(raw)
129 const rungs: Rule['rungs'] = {}
130 for (const rung of RUNGS) {
131 const items = Array.isArray(source[rung]) ? source[rung] : [source[rung]]
132 const parsed = items.flatMap(item => { const value = step(item, rung); return value ? [value] : [] })
133 if (parsed.length) rungs[rung] = parsed
134 }
135 return rungs
136}
137/**
138 * The one rule left, the general ladder (the four workers). A ladder keeps its rungs; a pin from
139 * before the ladder becomes one; Auto (experimental, removed 2026-10-12) becomes a ladder with no
140 * rungs set, which the router fills from the ticked models (`fillLadder`). Field rules are gone.
141 */
142function generalRule(raw: unknown): Rule {
143 const v = object(raw)
144 if (v.mode === 'ladder') return { mode: 'ladder', rungs: ladderRungs(v.rungs) }
145 if (v.mode === 'auto') return { mode: 'ladder', rungs: {} }
146 if (v.mode === 'pin') {
147 const source = object(v.targets)
148 const targets: Partial<Record<Level, TierTarget>> = {}
149 for (const level of LEVELS) { const parsed = target(source[level]); if (parsed) targets[level] = parsed }
150 return ladderFromPin(targets, calibration(v))
151 }
152 return copy(DEFAULTS.rules.general)
153}
154
155type Worker = { name: string; provider: string }
156
157/**
158 * Auto's allow-list names worker agents. Settings saved before 2026-10-10 named providers there;
159 * with the worker list known, a provider name becomes that provider's workers (names it does not
160 * know are kept: a worker may be missing only for now).
161 */
162export function migrateAllow(allow: readonly string[], workers: readonly Worker[]): string[] {
163 const agents = new Set(workers.map(w => w.name))
164 return [...new Set(allow.flatMap(entry => agents.has(entry) || !workers.some(w => w.provider === entry) ? [entry] : workers.filter(w => w.provider === entry).map(w => w.name)))]
165}
166
167/** The workers switched on by an allow-list; empty means every worker. A worker that did not exist when the list was last written (absent from `known`) counts as on. */
168export function allowedWorkers(allow: readonly string[], workers: readonly Worker[], known: readonly string[] = []): string[] {
169 if (!allow.length) return workers.map(w => w.name)
170 const on = new Set(migrateAllow(allow, workers))
171 return workers.filter(w => on.has(w.name) || known.length > 0 && !known.includes(w.name)).map(w => w.name)
172}
173
174/** Switches one worker; every worker on again is stored as [], and the last one cannot go off. `known` is the allow-list keys the writer knew. */
175export function toggleAllow(allow: readonly string[], name: string, workers: readonly Worker[], known: readonly string[] = []): string[] {
176 const on = allowedWorkers(allow, workers, known)
177 const next = on.includes(name) ? on.filter(n => n !== name) : workers.filter(w => w.name === name || on.includes(w.name)).map(w => w.name)
178 if (!next.length) return [...allow]
179 return next.length === workers.length ? [] : next
180}
181
182/**
183 * Whether a stored model is ticked: its key (an agent name, or `general-purpose:<model>`) or, for
184 * settings from before 2026-10-10, its provider is listed; an empty list ticks every model. A key
185 * absent from a non-empty `known` list (a worker added after the list was last written) ticks on.
186 */
187export function isAllowed(key: string, provider: string, allow: readonly string[], known: readonly string[] = []): boolean {
188 return !allow.length || allow.includes(key) || allow.includes(provider) || known.length > 0 && !known.includes(key)
189}
190
191/** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
192const RETIRED_DESIGN = JSON.stringify([claude('sonnet', 'medium'), claude('sonnet', 'high'), claude('opus', 'high'), claude('opus', 'high')].map(s => [s.target.agent, s.target.model, s.effort]))
193
194/**
195 * Whether stored settings still carry what the four workers replaced: Auto (experimental), or a
196 * field rule that is not the design ladder every save used to store. The dashboard says so once.
197 */
198export function hadRetiredRouting(raw: unknown): boolean {
199 const rules = object(object(raw).rules)
200 if (object(rules.general).mode === 'auto') return true
201 return DOMAINS.some(domain => {
202 if (domain === 'general') return false
203 const stored = object(rules[domain])
204 if (stored.mode === undefined || stored.mode === 'inherit') return false
205 if (domain !== 'design' || stored.mode !== 'ladder') return true
206 const rungs = ladderRungs(stored.rungs)
207 return JSON.stringify(RUNGS.map(rung => { const s = rungs[rung]?.[0]; return s ? [s.target.agent, s.target.model, s.effort] : null })) !== RETIRED_DESIGN
208 })
209}
210
211/** How long a mode's name may be: it shows in the band. */
212export const MODE_NAME_MAX = 40
213/** How many modes are kept. */
214export const MODES_MAX = 20
215
216/** How long a mode's reset provider name may be. */
217const PROVIDER_NAME_MAX = 64
218
219/**
220 * A mode's end: a time, one provider's next quota reset, or none. Until 2026-10-12 `'reset'` meant
221 * any used provider's; it becomes the first that could have offered it, ChatGPT then Claude. Settings
222 * do not carry a worker's provider, and ChatGPT is the only worker provider with a known reset, so
223 * any worker that is not Claude's own stands for ChatGPT.
224 */
225function modeUntil(raw: unknown, rungs: Rule['rungs']): Mode['until'] {
226 if (typeof raw === 'number') return Number.isFinite(raw) && raw > 0 ? raw : 'none'
227 if (raw === 'reset') return { reset: Object.values(rungs).some(steps => steps?.some(step => step.target.agent !== 'general-purpose')) ? 'chatgpt' : 'anthropic' }
228 const reset = object(raw).reset
229 return typeof reset === 'string' && reset.trim() ? { reset: reset.trim().slice(0, PROVIDER_NAME_MAX) } : 'none'
230}
231
232/**
233 * Saved worker sets. Old prefer/avoid modes copy the base ladder on load, dropping their policy.
234 * Every entry owns its lists: later base edits cannot mutate a saved mode.
235 */
236export function normalizeModes(raw: unknown, base: Rule['rungs'] = DEFAULTS.rules.general.rungs): Mode[] {
237 if (!Array.isArray(raw)) return []
238 const out: Mode[] = []
239 for (const entry of raw) {
240 const v = object(entry)
241 const id = typeof v.id === 'string' ? v.id.trim().slice(0, 64) : ''
242 const name = typeof v.name === 'string' ? v.name.trim().slice(0, MODE_NAME_MAX) : ''
243 if (!id || !name || out.some(m => m.id === id)) continue
244 const rungs = ladderRungs(v.rungs === undefined ? base : v.rungs)
245 out.push({ id, name, rungs, delegation: v.delegation === 'eager' ? 'eager' : 'normal', until: modeUntil(v.until, rungs) })
246 if (out.length === MODES_MAX) break
247 }
248 return out
249}
250
251/** The general pin every install had before the ladder: what stored `tiers` filled in. */
252const LEGACY_GENERAL: Readonly<Record<Level, TierTarget>> = { light: { agent: 'deepseek-v4-1-flash' }, standard: { agent: 'gpt-6-1-sol' }, deep: { agent: 'gpt-6-astra' } }
253
254/**
255 * Persisted settings may be old; migrate tiers and pins once and never alias nested default data.
256 * Only the general ladder survives: Auto becomes a ladder with no rungs set (its candidates become
257 * the ticked models); field rules and Auto's own fields (preference, autoFloors, autoTieBand) are
258 * dropped. With `workers`, provider names in an allow-list become those providers' workers.
259 */
260export function normalizeSettings(raw: unknown, workers?: readonly Worker[]): Settings {
261 const value = object(raw)
262 const stored = object(value.rules)
263 const rules: Settings['rules'] = { general: generalRule(stored.general) }
264 const old = object(value.tiers)
265 if (!Object.hasOwn(stored, 'general') && Object.keys(old).length) {
266 const targets: Partial<Record<Level, TierTarget>> = copy(LEGACY_GENERAL)
267 for (const level of LEVELS) { const parsed = target(old[level]); if (parsed) targets[level] = parsed }
268 rules.general = ladderFromPin(targets, {})
269 }
270 const ticked = names(Array.isArray(value.allow) ? value.allow : object(stored.general).mode === 'auto' ? object(stored.general).allow : [])
271 const savedKnown = names(value.allowKnown)
272 const result: Settings = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, allowKnown: savedKnown, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } }
273 for (const [name, enabled] of Object.entries(object(value.providers))) if (typeof enabled === 'boolean') result.providers[name] = enabled
274 for (const key of ['effortAuto', 'keepWarm', 'setupDone', 'swampAlert'] as const) if (typeof value[key] === 'boolean') result[key] = value[key]
275 if (Array.isArray(value.extras)) result.extras = [...new Set(value.extras.filter((part): part is string => typeof part === 'string' && EXTRAS.includes(part)))]
276 const choices = {
277 judge: ['haiku', 'jev', 'model'], jevSource: ['typesafe', 'zen'], bias: [-2, -1, 0, 1, 2], floor: EFFORTS,
278 ceiling: EFFORTS, agentAuto: ['off', 'auto', 'all'], keepWarmHours: [1, 2, 4, 8], compactWith: ['haiku', 'session'],
279 handoffAfter: ['continue', 'confirm', 'copy'], handoffButton: ['advised', 'always'], layout: ['dashboard', 'minimal'],
280 appearance: ['auto', 'dark', 'light'], skin: SKINS, language: ['auto', 'ko', 'en', 'zh', 'ja', 'es', 'pt'],
281 } as const
282 for (const key of Object.keys(choices) as (keyof typeof choices)[]) if ((choices[key] as readonly unknown[]).includes(value[key])) Object.assign(result, { [key]: value[key] })
283 if (typeof value.judgeModel === 'string' && value.judgeModel.trim()) result.judgeModel = value.judgeModel.trim()
284 if (typeof value.handoffSkill === 'string') result.handoffSkill = value.handoffSkill.trim().replace(/^\//, '')
285 if (typeof value.keepWarmMinTokens === 'number' && Number.isFinite(value.keepWarmMinTokens) && value.keepWarmMinTokens >= 0) result.keepWarmMinTokens = value.keepWarmMinTokens
286 if (typeof value.swampAt === 'number' && Number.isFinite(value.swampAt) && value.swampAt >= 0 && value.swampAt <= 100) result.swampAt = value.swampAt
287 if (Array.isArray(value.hide)) result.hide = [...new Set(value.hide.filter((part): part is string => typeof part === 'string' && ['timer', 'reason', 'route', 'limits'].includes(part)))]
288 result.fallback = target(value.fallback, DEFAULTS.fallback)!
289 result.modes = normalizeModes(value.modes, rules.general.rungs)
290 // An active mode must be one of the modes; its start time stays only with it.
291 if (typeof value.activeMode === 'string' && result.modes.some(m => m.id === value.activeMode)) {
292 result.activeMode = value.activeMode
293 if (typeof value.activeSince === 'number' && Number.isFinite(value.activeSince) && value.activeSince > 0) result.activeSince = value.activeSince
294 }
295 return result
296}
297
298/** Preserve unrelated remote edits, including sibling domains and provider switches. */
299export function settingsChanges(before: Settings, after: Settings): SettingsChange[] {
300 function diff(a: unknown, b: unknown, path: string[]): SettingsChange[] {
301 if (JSON.stringify(a) === JSON.stringify(b)) return []
302 if (b === undefined) return [{ path, remove: true }]
303 if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
304 const left = object(a), right = object(b)
305 // Changing a discriminated rule replaces it, rather than retaining the old mode's fields.
306 if (left.mode === right.mode) return [...new Set([...Object.keys(left), ...Object.keys(right)])].flatMap(key => diff(left[key], right[key], [...path, key]))
307 }
308 return [{ path, value: b }]
309 }
310 return diff(before, after, [])
311}
312
313export function applySettingsChanges(base: Settings, changes: readonly SettingsChange[]): Settings {
314 const result = object(normalizeSettings(base))
315 for (const change of changes) {
316 let parent = result
317 for (const key of change.path.slice(0, -1)) {
318 const child = object(parent[key])
319 parent[key] = child
320 parent = child
321 }
322 const key = change.path.at(-1)
323 if (key === undefined) continue
324 if (change.remove) delete parent[key]
325 else parent[key] = change.value
326 }
327 return normalizeSettings(result)
328}
329hooks/ladder.ts 175 lines1// The four workers (docs/specs/2026-10-12-worker-tiers.md): which rung a verdict lands on, and the
2// fill that gives every rung a model from the ticked, connected ones. Pure, with type-only imports,
3// so the router fills settings with the same rule the mod uses.
4
5import type { Billing, Effort, LadderChoice, Preset, PresetId, Rung, Rungs, Step, WorkerVerdict } from '../types/index.d.ts'
6import { CLAUDE_WORKERS, EFFORTS, LEVEL_RUNG, RUNGS, candidateKey, isAllowed } from './settings.ts'
7
8/** Below this the judge is unsure enough that a task takes the next rung. */
9export const SURE_ENOUGH = 0.5
10
11/**
12 * Doing work lands on Low, Medium and High by level. Planning — a deliverable that is a decision,
13 * design, strategy or verdict — is the Max worker's job from standard up (a business strategy
14 * judged "standard plan" went to the Medium worker before, 2026-10-12 pick log); a light plan
15 * (rename a section of a spec) is still light work.
16 * An unsure verdict moves up one rung (Low → Medium → High); High and Plan stay, so only a
17 * planning verdict ever reaches Plan.
18 */
19export function rungOf(verdict: Pick<WorkerVerdict, 'level' | 'kind' | 'sure'>): Rung {
20 const rung = verdict.kind === 'plan' && verdict.level !== 'light' ? 'plan' : LEVEL_RUNG[verdict.level]
21 if (verdict.sure === undefined || !(verdict.sure < SURE_ENOUGH)) return rung
22 return rung === 'light' ? 'standard' : rung === 'standard' ? 'hard' : rung
23}
24
25/** The rung above, none above Plan. */
26export function rungAbove(rung: Rung): Rung | undefined {
27 return RUNGS[RUNGS.indexOf(rung) + 1]
28}
29
30/**
31 * The nearest effort a model takes, as the router clamps one (compat.ts clampEffort): ties go up,
32 * an unknown ladder keeps the effort.
33 */
34export function nearestEffort(effort: Effort, ladder: readonly string[]): Effort {
35 const known = ladder.filter((value): value is Effort => EFFORTS.includes(value as Effort))
36 if (!known.length || known.includes(effort)) return effort
37 const at = EFFORTS.indexOf(effort)
38 return known.reduce((best, next) => {
39 const d = Math.abs(EFFORTS.indexOf(next) - at)
40 const bd = Math.abs(EFFORTS.indexOf(best) - at)
41 return d < bd || (d === bd && EFFORTS.indexOf(next) > EFFORTS.indexOf(best)) ? next : best
42 })
43}
44
45/** How a provider is paid for, from its configuration (type, auth, preset, address). */
46export function billingOf(provider: { type?: string; auth?: string; preset?: string; url?: string }, model?: string): Billing {
47 if (model && /(?::|-)free$/i.test(model)) return 'free'
48 if (provider.type === 'chatgpt') return 'subscription'
49 if (provider.type === 'anthropic') return provider.auth === 'api-key' ? 'payg' : 'subscription'
50 if (provider.type === 'google') return provider.auth === 'antigravity' ? 'subscription' : 'payg'
51 if (provider.preset === 'opencode-go') return 'subscription'
52 if (provider.preset === 'ollama' || provider.preset === 'lmstudio') return 'free'
53 // A named hosted service is paid for wherever it is reached through; only a hand-entered local server is free.
54 if (provider.preset) return 'payg'
55 if (provider.url) {
56 try {
57 const host = new URL(provider.url).hostname
58 if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host.endsWith('.localhost')) return 'free'
59 } catch { /* not an address: paid like any other API */ }
60 }
61 return 'payg'
62}
63
64/** A family's newest model: gpt-6.1-sol over gpt-6-sol. */
65function newest(choices: readonly LadderChoice[], family: RegExp): LadderChoice | undefined {
66 const version = (model: string): number[] => (/(\d+(?:[.-]\d+)*)/.exec(model)?.[1] ?? '0').split(/[.-]/).map(Number)
67 const later = (a: number[], b: number[]): number => { for (let i = 0; i < Math.max(a.length, b.length); i++) { const d = (b[i] ?? 0) - (a[i] ?? 0); if (d) return d } return 0 }
68 return choices.filter(c => family.test(c.model) && !c.target.model).sort((a, b) => later(version(a.model), version(b.model)) || a.model.localeCompare(b.model))[0]
69}
70
71/** The cheapest model paid by the token or free: free first, then by measured cost; unmeasured last. */
72export function cheapest(choices: readonly LadderChoice[]): LadderChoice | undefined {
73 const cost = (c: LadderChoice): number => c.billing === 'free' ? 0 : c.cost ?? Number.POSITIVE_INFINITY
74 return choices.filter(c => (c.billing === 'payg' || c.billing === 'free') && !c.target.model)
75 .sort((a, b) => cost(a) - cost(b) || a.model.localeCompare(b.model))[0]
76}
77
78type Want = { pick: (choices: readonly LadderChoice[]) => LadderChoice | undefined; effort: Effort }
79
80const claudeModel = (alias: string) => (choices: readonly LadderChoice[]) => choices.find(c => c.target.agent === 'general-purpose' && c.target.model === alias)
81const family = (pattern: RegExp) => (choices: readonly LadderChoice[]) => newest(choices, pattern)
82const LUNA = family(/\bluna\b/i)
83const SOL = family(/\bsol\b/i)
84const ASTRA = family(/\bastra\b/i)
85const HAIKU = claudeModel('haiku')
86const SONNET = claudeModel('sonnet')
87const OPUS = claudeModel('opus')
88
89/**
90 * The fill rule's wishes (a preset's, by name; only `claudeGpt` is used): each rung's in order: the first is what the preset names, the rest what stands in
91 * when it is not connected (the nearest choice, a GPT model's Claude counterpart).
92 */
93const PRESETS: Readonly<Record<PresetId, Readonly<Record<Rung, readonly Want[]>>>> = {
94 claude: {
95 light: [{ pick: HAIKU, effort: 'low' }], standard: [{ pick: SONNET, effort: 'medium' }],
96 hard: [{ pick: OPUS, effort: 'high' }], plan: [{ pick: OPUS, effort: 'high' }],
97 },
98 claudeGpt: {
99 light: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
100 standard: [{ pick: SOL, effort: 'medium' }, { pick: SONNET, effort: 'medium' }],
101 hard: [{ pick: ASTRA, effort: 'high' }, { pick: OPUS, effort: 'high' }],
102 plan: [{ pick: OPUS, effort: 'high' }],
103 },
104 gpt: {
105 light: [{ pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
106 standard: [{ pick: SOL, effort: 'medium' }, { pick: SONNET, effort: 'medium' }],
107 hard: [{ pick: ASTRA, effort: 'high' }, { pick: OPUS, effort: 'high' }],
108 plan: [{ pick: ASTRA, effort: 'xhigh' }, { pick: OPUS, effort: 'high' }],
109 },
110 cheap: {
111 light: [{ pick: cheapest, effort: 'high' }, { pick: LUNA, effort: 'low' }, { pick: HAIKU, effort: 'low' }],
112 standard: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'medium' }, { pick: HAIKU, effort: 'medium' }],
113 hard: [{ pick: cheapest, effort: 'max' }, { pick: LUNA, effort: 'high' }, { pick: SONNET, effort: 'medium' }],
114 plan: [{ pick: SONNET, effort: 'high' }],
115 },
116}
117
118/** Claude's own models as rung choices: always there, since the session itself runs on Claude. */
119export function claudeChoices(efforts: readonly string[]): LadderChoice[] {
120 const ladder = efforts.filter((e): e is Effort => EFFORTS.includes(e as Effort))
121 return CLAUDE_WORKERS.map(w => ({ target: { ...w.target }, model: w.model, provider: w.provider, billing: 'subscription' as const, efforts: ladder }))
122}
123
124/**
125 * A preset's rule applied to the connected, ticked models: each rung takes its first wish that is
126 * there, at that wish's effort moved to the nearest the model takes. A rung that had to take a
127 * later wish is listed in `substituted`.
128 */
129export function preset(id: PresetId, choices: readonly LadderChoice[]): Preset {
130 const rungs = {} as Record<Rung, Step>
131 const substituted: Rung[] = []
132 for (const rung of RUNGS) {
133 const wants = PRESETS[id][rung]
134 let filled = false
135 for (const [at, want] of wants.entries()) {
136 const choice = want.pick(choices)
137 if (!choice) continue
138 rungs[rung] = { target: { ...choice.target }, effort: nearestEffort(want.effort, choice.efforts) }
139 if (at > 0) substituted.push(rung)
140 filled = true
141 break
142 }
143 // Claude is always there, so only a choice list without it lands here: the session model.
144 if (!filled) { rungs[rung] = { target: { agent: 'general-purpose' }, effort: wants[0]!.effort }; substituted.push(rung) }
145 }
146 return { rungs, substituted }
147}
148
149/** The choices a person ticked; an empty list ticks every one, and a key absent from `known` ticks on too. */
150export function tickedChoices(choices: readonly LadderChoice[], allow: readonly string[], known: readonly string[] = []): LadderChoice[] {
151 return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow, known))
152}
153
154/** Only empty lists need filling; unticked entries stay in order and are skipped at dispatch. */
155export function ladderNeedsFill(rungs: Rungs, _allow: readonly string[] = []): boolean {
156 return RUNGS.some(rung => !rungs[rung]?.length)
157}
158
159/**
160 * Fill empty lists from the ticked, connected choices. Existing ordered lists are never rewritten
161 * for temporary availability or shared allow-list changes; dispatch explains each skipped entry.
162 */
163export function fillLadder(rungs: Rungs, allow: readonly string[], choices: readonly LadderChoice[], known: readonly string[] = []): { rungs: Record<Rung, Step[]>; filled: Rung[] } {
164 const pick = preset('claudeGpt', tickedChoices(choices, allow, known))
165 const out = {} as Record<Rung, Step[]>
166 const filled: Rung[] = []
167 for (const rung of RUNGS) {
168 const own = rungs[rung]
169 if (own?.length) { out[rung] = own; continue }
170 out[rung] = [pick.rungs[rung]]
171 filled.push(rung)
172 }
173 return { rungs: out, filled }
174}
175hooks/i18n.ts 2831 lines1// Pure display strings. Model-facing prompts intentionally stay in English.
2import type { Lang } from '../types'
3export type { Lang } from '../types'
4
5const EN = {
6 // Band (including shared display labels and accessibility text).
7 'band.brand': 'ClaudeRipple',
8 'band.effort': 'Effort',
9 'band.model': 'Model',
10 'band.auto': 'Auto',
11 'band.manual': 'Manual',
12 'band.paused': 'Paused',
13 'band.off': 'Off',
14 'band.on': 'On',
15 'band.autoOff': 'Auto off',
16 'band.autoPaused': 'auto paused',
17 'band.deciding': 'Deciding…',
18 'band.decidingText': '◌ deciding…',
19 'band.low': 'Low',
20 'band.medium': 'Medium',
21 'band.high': 'High',
22 'band.xhigh': 'X-High',
23 'band.max': 'Max',
24 'band.pausedHint': 'Auto pauses on this model: effort changes there are not known to keep the cache.',
25 'band.offHint': 'Auto is off; the effort is yours.',
26 'band.decidingHint': 'The judge is picking the effort for this prompt.',
27 'band.effortTitle': 'Effort {value}{hint} · Click to pick an effort',
28 'band.effortAlt': 'Effort {value} ({mode}){why}',
29 'band.context': 'Context',
30 'band.contextAlt': 'Context {value} of {window} ({tokens} tokens)',
31 'band.cache': 'Cache',
32 'band.cacheWarm': 'Cache · Warm',
33 'band.cacheStopped': 'Cache · Warm stopped',
34 'band.cold': 'cold',
35 'band.cacheTip': 'Prompt cache {state}{warm}',
36 'band.warmFor': 'warm for {time}',
37 'band.expired': 'expired',
38 'band.warmStoppedTip': ' · keep-warm stopped: {reason}',
39 'band.warmOnTip': ' · keep-warm on, {count} pings',
40 'band.noReply': 'No reply yet',
41 'band.cacheAlt': 'Cache {state}{warm}',
42 'band.timeLeft': '{time} left',
43 'band.unknown': 'unknown',
44 'band.keepWarmAlt': ', keep warm on{count}',
45 'band.limitCaption': '{window} limit{save}',
46 'band.saveCaption': ' · SAVE',
47 'band.limitAlt': '{window} limit {value}{reset}',
48 'band.limitsTip': '{provider} {window} {value}{reset}',
49 'band.fiveHour': '5-hour',
50 'band.weekly': 'Weekly',
51 'band.resetting': 'resetting',
52 'band.resetsIn': 'resets in {time}',
53 'band.resetsAt': 'resets {time}',
54 'band.router': 'Router',
55 'band.routerAlt': 'Router: {value}',
56 'band.resentCaption': 'RESENT · {status}',
57 'band.failedCaption': 'FAILED · {status}',
58 'band.lastRoute': 'LAST ROUTE',
59 'band.logHint': 'Click for the request log',
60 'band.routeAlt': 'Last route: {model} via {provider}{effort}{cache}, {time}{status}',
61 'band.routeCache': ', cache {percent}%',
62 'band.routeResent': ', resent {status}',
63 'band.routeFailed': ', failed {status}',
64 'band.warm': 'Warm',
65 'band.handoff': 'Handoff',
66 'band.compact': 'Compact',
67 'band.clear': 'Clear',
68 'band.compactAlt': 'Compact — summarise the conversation to free context',
69 'band.clearAlt': 'New start (/clear) — clear the conversation and start fresh',
70 'band.log': 'Log',
71 'band.agents': 'Agents',
72 'band.settings': 'Settings',
73 'band.effortShort': 'Effort',
74 'band.hit': 'hit {percent}%',
75 'band.effortAutoAlt': 'Effort Auto {state}',
76 'band.warmAlt': 'Keep cache warm {state}',
77 'band.warmHere': 'this chat only; new chats follow Settings',
78 'band.handoffAdvised': 'Handoff (advised: a fresh chat pays now)',
79 'band.closeLog': 'Close the request log',
80 'band.openLog': 'Open the request log',
81 'band.agentsAlt': 'Agents: {count} running',
82 'band.settingsAlt': 'Ripple settings',
83 'band.warmStopped': 'warm stopped',
84 'band.warmText': '≈warm',
85 'band.save': 'save',
86 'band.resent': '{status} resent',
87 'band.cachePercent': 'cache {percent}%',
88 'band.closeLogText': 'Close log',
89 'band.footerPaused': ' (paused)',
90 'band.footerOff': ' (auto off)',
91 'band.footerManual': ' (manual)',
92 'band.footerContext': 'ctx {percent}%',
93 'band.routerRestart': 'ClaudeRipple: restart the router to show routes here',
94 'band.routerDown': 'ClaudeRipple: router not answering',
95 // Alerts.
96 'alerts.coldTitle': 'The cache went cold',
97 'alerts.coldBody': 'It sat {minutes} min, so the next prompt re-reads all {tokens} tokens at full price.',
98 'alerts.keptTitle': 'Cache kept warm while you were away',
99 'alerts.keptBody': '{pings} ping(s) held {tokens} tokens in cache, so your next prompt reads them at the cached rate. {minutes} min left.',
100 'alerts.gotIt': 'Got it',
101 'alerts.swampTitle': 'Context {percent}% full',
102 'alerts.swampBody': '{tokens} tokens ride along with every prompt: slower, dearer, blurrier.',
103 'alerts.hotTitle': '{window} limit at {percent}%',
104 'alerts.saveOnBody': 'Save mode is on: Auto stays at medium or below until the reset.',
105 'alerts.saveBody': 'Save mode holds Auto at medium or below until the reset.',
106 'alerts.saveOff': 'Turn save mode off',
107 'alerts.save': 'Save mode',
108 'alerts.notNow': 'Not now',
109 'alerts.judgeTitle': 'The effort judge is not answering',
110 'alerts.judgeNetworkTitle': 'The connection is unsteady; Haiku is judging for now',
111 'alerts.routerModel': 'The router model',
112 'alerts.judgeBody': '{judge}: {reason}. {fallback}',
113 'alerts.keepEffort': 'The current effort stays meanwhile.',
114 'alerts.askHaiku': 'Haiku judges meanwhile.',
115 'alerts.openSettings': 'Open settings',
116 'alerts.compactTitle': 'Compact this chat',
117 'alerts.compactBody': 'Say what the summary must keep, or leave it to the summary.',
118 'alerts.cancel': 'Cancel',
119 'alerts.handoffTitle': 'Hand off to a fresh chat',
120 'alerts.quickBody': 'Quick: a summary from the cache that the new chat picks up.',
121 'alerts.fullBody': 'Full: the handoff skill checks git and writes HANDOFF.md first.',
122 'alerts.go': 'Go',
123 'alerts.dismiss': 'Dismiss',
124 'alerts.handingOff': 'Handing off…',
125 'alerts.fullProgress': 'Running the handoff: checking git and writing HANDOFF.md.',
126 'alerts.quickProgress': 'Writing a summary a fresh chat can resume from.',
127 'alerts.handoffDone': 'Handoff done',
128 'alerts.summaryReady': 'The summary is ready.',
129 'alerts.copyAgain': 'Copy again',
130 'alerts.compacting': 'Compacting…',
131 'alerts.compactingBody': 'Summarizing the chat; it continues from the summary.',
132 'alerts.compacted': 'Compacted',
133 'alerts.compactedBody': 'The chat continues from the summary.',
134 'alerts.failed': 'That did not finish',
135 'alerts.unchanged': 'Nothing was changed.',
136 'alerts.keep': 'Keep',
137 'alerts.keepPlaceholder': 'decisions, open questions, file names… (optional)',
138 'alerts.compactSubmit': 'compact',
139 'alerts.quick': 'Quick',
140 'alerts.full': 'Full',
141 'alerts.continue': 'New session now',
142 'alerts.confirm': 'Ask first',
143 'alerts.copy': 'Copy only',
144 'alerts.continueHint': 'Opens a new session in this folder with the handoff filled in; press Enter there to go on. This chat stays.',
145 'alerts.confirmHint': 'Writes the summary and stops; you choose whether to move to a new session. This chat stays.',
146 'alerts.copyHint': 'Copies the summary to the clipboard; nothing else changes.',
147 'alerts.handoffReady': 'Handoff ready',
148 'alerts.handoffReadyBody': 'Move to a new session in this folder? This chat stays as it is.',
149 'alerts.openNew': 'Move to a new session',
150 'alerts.openedBody': 'A new session opened with the handoff filled in: press Enter there to go on.',
151 'alerts.readHandoffFile': 'Read the handoff in {path} and continue from it.',
152 'alerts.copyText': 'Copy',
153 'toasts.newSessionFallback': 'Could not open a new session; the handoff is copied instead.',
154 'alerts.kind': 'Kind ',
155 'alerts.then': 'Then ',
156 'alerts.demoDown': 'Jev out of credits (demo)',
157 'alerts.copied': 'The resume note was copied; this chat was kept.',
158 'alerts.compactFailed': 'Compaction did not finish.',
159 'alerts.clearTitle': 'Start this conversation over?',
160 'alerts.clearBody': 'The conversation\'s context is emptied; a fresh chat starts with nothing.',
161 'alerts.clearGo': 'Start over',
162 // Settings.
163 'settings.effortBlurb': 'How Auto picks the effort for each prompt you send.',
164 'settings.workers': 'Workers',
165 'settings.mode': 'Mode',
166 'settings.modeNone': 'Base',
167 'band.mode': 'Mode',
168 'band.modeTip': 'Mode “{name}” is on: the workers follow it. Change it in quick settings.',
169 'settings.workersBlurb': 'Which agent runs each kind of delegated task.',
170 'settings.judge': 'Judge',
171 'settings.judgeBlurb': 'Who decides: effort for prompts, tier for tasks.',
172 'settings.cacheBlurb': 'Keep the prompt cache warm while you step away.',
173 'settings.handoff': 'Compact & Handoff',
174 'settings.handoffBlurb': 'How a heavy chat is compacted or handed to a fresh one.',
175 'settings.look': 'Look',
176 'settings.lookBlurb': 'What the band shows and how.',
177 'settings.light': 'Light',
178 'settings.lightBlurb': 'chores, repetition, lookups',
179 'settings.standard': 'Standard',
180 'settings.standardBlurb': 'designed builds, research, review',
181 'settings.deep': 'Deep',
182 'settings.deepBlurb': 'careful, high-stakes, hard reasoning',
183 'settings.design': 'Design',
184 'settings.designBlurb': 'UI, visual, brand',
185 'settings.tierAlt': '{name} tier',
186 'settings.biasAlt': 'Bias {value}{selected}',
187 'settings.selected': ' (selected)',
188 'settings.onSuffix': ' (on)',
189 'settings.offSuffix': ' (off)',
190 'settings.mixedSuffix': ' (some on)',
191 'settings.cheaper': 'Cheaper',
192 'settings.smarter': 'Smarter',
193 'settings.autoHint': 'picks per prompt; your own pick turns it off',
194 'settings.lean': 'Lean',
195 'settings.leanHint': 'tips close calls only',
196 'settings.floor': 'Never below',
197 'settings.ceiling': 'Never above',
198 'settings.floorWins': 'below the floor: the floor wins',
199 'settings.unavailable': ' (unavailable)',
200 'settings.takesOver': 'Takes over',
201 'settings.autoAgent': 'ripple:auto',
202 'settings.allSpawns': 'All spawns',
203 'settings.fallback': 'Fallback',
204 'settings.fallbackHint': 'when a tier’s provider is out of quota',
205 'settings.haiku': 'Haiku',
206 'settings.jev': 'Jev',
207 'settings.routerModel': 'Router model',
208 'settings.haikuHint': 'Haiku 5.5 on your Claude login · about a second a prompt',
209 'settings.keyEnv': 'from TYPESAFE_API_KEY',
210 'settings.keyFile': 'saved in ~/.config/jev/.env',
211 'settings.keySaved': 'saved',
212 'settings.keyPaste': 'paste a TypeSafe API key',
213 'settings.saveKey': 'save',
214 'settings.testing': 'Testing…',
215 'settings.keyWorks': 'Key works',
216 'settings.keyRejected': 'Key rejected',
217 'settings.key': 'Key',
218 'settings.test': 'Test',
219 'settings.noModels': 'no router models listed',
220 'settings.haikuFallback': 'falls back to Haiku',
221 'settings.keepWarm': 'Keep warm',
222 'settings.keepWarmHint': 'pings the cache just before it expires',
223 'settings.for': 'For',
224 'settings.afterReply': 'after the last reply',
225 'settings.chatsFrom': 'Chats from',
226 'settings.tokens': 'tokens',
227 'settings.warmCost': 'Each ping re-reads the whole chat from cache: about a tenth of its input price, and the same share of plan limits.',
228 'settings.noSkill': 'None: a turn writes HANDOFF.md',
229 'settings.compactWith': 'Compact with',
230 'settings.sessionCompactor': 'The session’s own',
231 'settings.afterHandoff': 'After handoff',
232 'settings.handoffButton': 'Handoff button',
233 'settings.whenAdvised': 'When advised',
234 'settings.always': 'Always',
235 'settings.fullHandoff': 'Full handoff',
236 'settings.heavyAt': 'Heavy at',
237 'settings.context': 'context',
238 'settings.cacheTimer': 'Cache timer',
239 'settings.reason': 'Reason',
240 'settings.route': 'Route',
241 'settings.limits': 'Limits',
242 'settings.layout': 'Layout',
243 'settings.dashboard': 'Dashboard',
244 'settings.minimal': 'Minimal',
245 'settings.footerHint': 'one line in the footer',
246 'settings.appearance': 'Appearance',
247 'settings.dark': 'Dark',
248 'settings.lightAppearance': 'Light',
249 'settings.skin': 'Skin',
250 'settings.skinLine': 'How the band and the cards are drawn.',
251 'settings.skinDeep': 'Deep',
252 'settings.skinFlat': 'Flat',
253 'settings.show': 'Show',
254 'settings.language': 'Language',
255 'settings.title': 'settings',
256 'settings.quick': 'Quick settings',
257 'settings.effortAuto': 'Automatic effort',
258 'settings.openAll': 'Open all settings',
259 'setup.workersNote': 'Workers start on the ladder; set it up on the dashboard.',
260 'setup.changeDashboard': 'Use the band or /ripple for quick changes; open all settings for the dashboard.',
261 'settings.save': 'Save',
262 'settings.discard': 'Discard',
263 'settings.close': 'Close',
264 'settings.autoLine': 'Picks the effort for every prompt by itself.',
265 'settings.autoOffNote': 'The level you pick in the app’s effort menu stays.',
266 'settings.judgeLine': 'Sets each prompt’s effort and each delegated task’s tier.',
267 'settings.modeLine': 'The set of agents that delegated tasks go to.',
268 'settings.warmLine': 'Pings the cache just before it goes cold.',
269 'settings.warmCostShort': 'Each ping costs about a tenth of the input price.',
270 'settings.sayAuto': '{judge} picks the effort for every prompt.',
271 'settings.sayManual': 'Effort stays where you set it in the app.',
272 'settings.sayWarm': 'And I’ll keep the cache warm.',
273 'band.hideBand': 'Hide the band',
274 'settings.unsaved': '● unsaved',
275 // Setup.
276 'setup.judgeTitle': 'Who decides',
277 'setup.judgeBlurb': 'A small, fast judge picks each prompt’s effort and each task’s worker.',
278 'setup.workersTitle': 'Who does the work',
279 'setup.cacheTitle': 'Keep the cache warm?',
280 'setup.cacheBlurb': 'An idle chat’s cache expires; Ripple can ping it while you are away.',
281 'setup.effortSummary': 'Auto {state}, judged by {judge}',
282 'setup.cacheSummary': 'kept warm for {hours}h after each reply',
283 'setup.cacheOff': 'not kept warm',
284 'setup.ready': 'ClaudeRipple is ready',
285 'setup.welcome': 'Welcome to ClaudeRipple',
286 'setup.start': 'Start',
287 'setup.finish': 'Finish',
288 'setup.next': 'Next',
289 'setup.back': 'Back',
290 'setup.skip': 'Skip setup',
291 // Agents.
292 'agents.waiting': 'waiting',
293 'agents.routerModel': 'router model',
294 'agents.marker': 'marker',
295 'agents.default': 'default',
296 'agents.session': 'the session’s model',
297 'agents.by': 'by {name}',
298 'agents.thinking': 'thinking',
299 'agents.fallback': '▲ ran the fallback: {reason}',
300 'agents.emptyTitle': 'No agents yet',
301 'agents.emptyBody': 'Tasks Claude delegates appear here, with the worker Ripple picked and why.',
302 'agents.liveCount': '{count} running',
303 'agents.noneRunning': 'None running',
304 'agents.doneCount': '{count} finished',
305 // Log.
306 'log.resent': '↻ {status} resent',
307 'log.input': 'in {tokens}',
308 'log.output': 'out {tokens}',
309 'log.thisSession': 'This session',
310 'log.allSessions': 'All sessions',
311 'log.showAll': 'Show all sessions',
312 'log.onlySession': 'This session only',
313 'log.legend': '● this session ○ other sessions',
314 'log.empty': 'No model requests yet.',
315 'log.judge': 'Effort judge',
316 'log.judgeWorker': 'Worker judge',
317 'log.judgeFell': '{asked} failed → {by}',
318 'log.sure': 'sure {percent}%',
319 // Toasts and locally generated failure/reason text.
320 'toasts.keySingle': 'Paste a single TypeSafe API key',
321 'toasts.keyPrivateFailed': 'Could not make the Jev key file private (chmod 600)',
322 'toasts.keySaved': 'ClaudeRipple: Jev key saved privately; environment keys still take priority.',
323 'toasts.judgeDown': 'ClaudeRipple: {reason}; keeping the current effort or asking Haiku.',
324 'toasts.keyMissing': 'Jev key missing',
325 'toasts.jevNoVerdict': 'Jev returned no verdict',
326 'judge.keptUnsure': 'Unsure, kept the running effort',
327 'toasts.routerNoVerdict': 'Router judge returned no verdict',
328 'toasts.haikuNoVerdict': 'Haiku returned no verdict',
329 'toasts.noVerdict': 'no verdict',
330 'toasts.judgeUnavailable': 'judge unavailable',
331 'toasts.explicitMarker': 'explicit worker marker',
332 'toasts.sessionTier': 'tier named by the main chat: {tier}',
333 'toasts.workerFallback': 'ClaudeRipple: {reason}; running {name}.',
334 'toasts.modeEnded': 'The {name} mode has ended.',
335 'toasts.yourPick': 'your pick',
336 'toasts.effortEnter': 'ClaudeRipple: press Enter so the app effort control follows your pick.',
337 'toasts.settingsSaved': 'ClaudeRipple: settings saved.',
338 'toasts.clipboardFallback': 'ClaudeRipple: clipboard unavailable; the resume note is in the prompt.',
339 'toasts.handoffFailed': 'ClaudeRipple: handoff failed; nothing was cleared.',
340 'toasts.compactFailed': 'ClaudeRipple: compaction failed; the chat was kept.',
341 'toasts.warmStopped': 'ClaudeRipple: {reason}',
342 'toasts.warmBoundReason': 'Keep warm stopped: time bound reached',
343 'toasts.warmBound': 'ClaudeRipple: keep warm stopped at its time bound.',
344 'toasts.appEffort': 'app effort',
345 'toasts.appEffortChanged': 'app effort changed',
346 'toasts.autoOff': 'ClaudeRipple: your app effort change turned Auto off.',
347 'toasts.handoffEmpty': 'ClaudeRipple: handoff produced no answer; nothing was cleared.',
348 'toasts.ownModel': 'ClaudeRipple: {name} runs on its own model, not "{model}"',
349 'toasts.transcriptEmpty': 'Transcript is empty or over the compaction budget',
350 'toasts.summaryEmpty': 'Haiku returned no usable summary',
351 'toasts.compactFallback': 'ClaudeRipple: Haiku compaction failed; using the session compactor.',
352 'toasts.error': 'ClaudeRipple: {reason}',
353 'toasts.jevTimeout': 'Jev timed out',
354 'toasts.jevRejected': 'Jev key rejected',
355 'toasts.jevCredits': 'Jev out of credits',
356 'toasts.jevRateLimited': 'Jev rate limited',
357 'toasts.jevHttp': 'Jev request failed ({status})',
358 'toasts.warmNoReply': 'Keep warm stopped: no fork reply',
359 'toasts.warmNoCache': 'Keep warm stopped: cache was not read',
360 'toasts.providerQuota': 'ChatGPT accounts are unavailable or out of quota',
361 'toasts.logNotPlaced': 'The log could not open here: {reason}',
362 'toasts.saveMode': 'save mode',
363 'toasts.yourSettings': 'your settings',
364 'toasts.jevNetwork': 'Jev network unavailable',
365 'toasts.workerMissing': '{name} is not installed',
366 // Commands (stats and debug labels included).
367 'commands.description': 'ClaudeRipple effort, workers, cache and session tools',
368 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
369 'commands.logDescription': 'Open or close the ClaudeRipple request log',
370 'commands.barDescription': 'Show or hide the ClaudeRipple band',
371 'commands.logTitle': 'ClaudeRipple',
372 'commands.agentsTitle': 'ClaudeRipple Agents',
373 'commands.help': 'ClaudeRipple: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
374 'commands.done': 'ClaudeRipple: {command}.',
375 'commands.logOpened': 'ClaudeRipple log opened.',
376 'commands.logClosed': 'ClaudeRipple log closed.',
377 'commands.barShown': 'ClaudeRipple line shown above the prompt.',
378 'commands.barShownMinimal': 'The layout is Minimal, so ClaudeRipple shows as a short line under the prompt. For the full band above the prompt, set Layout to Dashboard in /ripple settings.',
379 'commands.barHidden': 'ClaudeRipple line hidden. /ripple-bar shows it again.',
380 'commands.statsAuto': 'Auto: {prompts} prompts, {requests} requests; {total} weighted tokens',
381 'commands.statsTokens': 'Tokens: input {input}, write {write}, read {read}, output {output}',
382 'commands.statsEffort': '{effort} {prompts} prompts / {cost} weighted',
383 'commands.statsJudges': 'Judges: haiku {haiku}, jev {jev}, model {model}; {tokens} tokens, {ms} ms ({avg} ms avg)',
384 'commands.statsWorkers': 'Workers: {workers}',
385 'commands.statsWarm': 'Warm: {pings} pings, {cost} weighted tokens',
386 'commands.debugJudge': 'judge',
387 'commands.debugJudgeModel': 'judgeModel',
388 'commands.debugPick': 'pick',
389 'commands.debugPaused': 'paused',
390 'commands.debugCache': 'cache',
391 'commands.debugWarm': 'warm',
392 'commands.debugLastSpawn': 'lastSpawn',
393 'commands.debugLastRequest': 'lastRequest',
394 'commands.debugEffort': 'effort',
395 'commands.debugWhy': 'why',
396 'commands.debugBy': 'by',
397 'commands.debugAt': 'at',
398 'commands.debugSure': 'sure',
399 'commands.debugTtlMs': 'ttlMs',
400 'commands.debugLastReplyAt': 'lastReplyAt',
401 'commands.debugExpiresAt': 'expiresAt',
402 'commands.debugPings': 'pings',
403 'commands.debugCost': 'cost',
404 'commands.debugStopped': 'stopped',
405 'commands.debugLastPingAt': 'lastPingAt',
406 'commands.debugTier': 'tier',
407 'commands.debugTarget': 'target',
408 'commands.debugAgent': 'agent',
409 'commands.debugModel': 'model',
410 'commands.debugMs': 'ms',
411 'commands.debugFallbackReason': 'fallbackReason',
412 'commands.debugAppEffort': 'appEffort',
413 'commands.debugSentEffort': 'sentEffort',
414 'commands.debugLog': 'ClaudeRipple {where}: {reason}',
415 'commands.routerHttp': 'router HTTP {status}',
416 'commands.compactSkip': 'ClaudeRipple summarizes with Haiku when compaction is needed.',
417 // Workers card, Jev source, and worker picks in the Agents pane.
418 'workers.previous': 'Previous',
419 'workers.next': 'Next',
420 'workers.page': 'Page {page} of {pages}',
421 'workers.general': 'General',
422 'workers.design': 'Design',
423 'workers.frontend': 'Frontend',
424 'workers.backend': 'Backend',
425 'workers.infra': 'Infrastructure',
426 'workers.data': 'Data',
427 'workers.test': 'Tests',
428 'workers.docs': 'Documentation',
429 'workers.research': 'Research',
430 'workers.mode': 'Mode',
431 'workers.rung.light': 'Low worker',
432 'workers.rung.standard': 'Medium worker',
433 'workers.rung.hard': 'High worker',
434 'workers.rung.plan': 'Max worker',
435 'workers.rungHint.light': 'Finding files, quick lookups, skimming logs, mechanical edits',
436 'workers.rungHint.standard': 'Specified implementation, refactors, summaries, drafts',
437 'workers.rungHint.hard': 'Hard debugging, multi-file changes, independent review and rebuttal',
438 'workers.rungHint.plan': 'Best result available: planning, design, architecture, opinions',
439 'workers.rungEmpty': '{rung} has no model set',
440 'workers.notCandidate': '{name} is not a candidate here',
441 'workers.sessionModel': 'the session\'s model',
442 'workers.offset': 'Effort offset',
443 'workers.anyEffort': 'Any effort',
444 'workers.noTarget': 'Use next level / fallback',
445 'workers.judgeBlurb': 'Who decides: effort for prompts, domain and level for tasks.',
446 'workers.disabled': '{provider} is switched off',
447 'workers.nested': 'A subagent may not start another subagent: do this part yourself, then report back.',
448 'workers.none': 'No enabled worker is available',
449 'workers.loading': 'Loading the router\'s workers…',
450 'workers.providerSub': '{on} of {count} on',
451 'workers.fallbackHint': 'when pinned targets cannot run',
452 'workers.pinDetails': 'More options',
453 'workers.back': 'Back',
454 'settings.ladderWaiting': 'Waiting for the router\'s model list.',
455 'jev.zenMissing': 'No OpenCode Zen authorization key in ClaudeRipple config',
456 'agents.domainLevel': '{domain} / {level}',
457 'agents.tokens': '≈{tokens} tok',
458 // Time units: countdowns, reset times, durations.
459 'units.seconds': '{n}s',
460 'units.minutes': '{n}m',
461 'units.hours': '{n}h',
462 'units.days': '{n}d',
463 'units.minSec': '{m}m {s}s',
464 'units.hourMin': '{h}h {m}m',
465} as const
466
467export type Key = keyof typeof EN
468
469const KO: Record<Key, string> = {
470 // Band (including shared display labels and accessibility text).
471 'band.brand': 'ClaudeRipple',
472 'band.effort': '추론 강도',
473 'band.model': '모델',
474 'band.auto': '자동',
475 'band.manual': '수동',
476 'band.paused': '일시 중지',
477 'band.off': '꺼짐',
478 'band.on': '켜짐',
479 'band.autoOff': '자동 꺼짐',
480 'band.autoPaused': '자동 일시 중지',
481 'band.deciding': '판정 중…',
482 'band.decidingText': '◌ 판정 중…',
483 'band.low': '낮음',
484 'band.medium': '중간',
485 'band.high': '높음',
486 'band.xhigh': '매우 높음',
487 'band.max': '최대',
488 'band.pausedHint': '이 모델에서는 자동 선택이 일시 중지됩니다. 해당 모델에서 추론 강도를 변경할 때 캐시가 유지되는지 확인되지 않았습니다.',
489 'band.offHint': '자동 선택이 꺼져 있습니다. 추론 강도를 직접 설정하세요.',
490 'band.decidingHint': '판정 모델이 프롬프트의 추론 강도를 결정하는 중입니다.',
491 'band.effortTitle': '추론 강도 {value}{hint} · 클릭하여 강도 선택',
492 'band.effortAlt': '추론 강도 {value} ({mode}){why}',
493 'band.context': '컨텍스트',
494 'band.contextAlt': '컨텍스트 {value} / {window} ({tokens} 토큰)',
495 'band.cache': '캐시',
496 'band.cacheWarm': '캐시 · 유지 중',
497 'band.cacheStopped': '캐시 · 유지 중단',
498 'band.cold': '만료',
499 'band.cacheTip': '프롬프트 캐시 {state}{warm}',
500 'band.warmFor': '{time} 후 만료',
501 'band.expired': '만료됨',
502 'band.warmStoppedTip': ' · 캐시 유지 중단: {reason}',
503 'band.warmOnTip': ' · 캐시 유지 켜짐, 핑 {count}회',
504 'band.noReply': '아직 응답 없음',
505 'band.cacheAlt': '캐시 {state}{warm}',
506 'band.timeLeft': '{time} 남음',
507 'band.unknown': '알 수 없음',
508 'band.keepWarmAlt': ', 캐시 유지 켜짐{count}',
509 'band.limitCaption': '{window} 한도{save}',
510 'band.saveCaption': ' · 절약',
511 'band.limitAlt': '{window} 한도 {value}{reset}',
512 'band.limitsTip': '{provider} {window} {value}{reset}',
513 'band.fiveHour': '5시간',
514 'band.weekly': '주간',
515 'band.resetting': '초기화 중',
516 'band.resetsIn': '{time} 뒤 초기화',
517 'band.resetsAt': '{time} 초기화',
518 'band.router': '라우터',
519 'band.routerAlt': '라우터: {value}',
520 'band.resentCaption': '재전송 · {status}',
521 'band.failedCaption': '실패 · {status}',
522 'band.lastRoute': '최근 라우팅',
523 'band.logHint': '클릭하여 요청 로그 조회',
524 'band.routeAlt': '최근 라우팅: {model}({provider} 경유){effort}{cache}, {time}{status}',
525 'band.routeCache': ', 캐시 {percent}%',
526 'band.routeResent': ', {status} 재전송',
527 'band.routeFailed': ', {status} 실패',
528 'band.warm': '캐시 유지',
529 'band.handoff': '인수인계',
530 'band.compact': '압축',
531 'band.clear': '새로 시작',
532 'band.compactAlt': '대화 압축 — 지금까지 대화를 요약해 컨텍스트를 줄입니다',
533 'band.clearAlt': '새로 시작 (/clear) — 대화 맥락을 비우고 새로 시작합니다',
534 'band.log': '로그',
535 'band.agents': '에이전트',
536 'band.settings': '설정',
537 'band.effortShort': '강도',
538 'band.hit': '적중 {percent}%',
539 'band.effortAutoAlt': '추론 강도 자동 {state}',
540 'band.warmAlt': '캐시 유지 {state}',
541 'band.warmHere': '이 세션만 적용, 새 세션은 설정을 따릅니다',
542 'band.handoffAdvised': '인수인계 (권장: 지금은 새 대화가 더 효율적입니다)',
543 'band.closeLog': '요청 로그 닫기',
544 'band.openLog': '요청 로그 열기',
545 'band.agentsAlt': '에이전트: {count}개 실행 중',
546 'band.settingsAlt': 'Ripple 설정',
547 'band.warmStopped': '유지 중단',
548 'band.warmText': '≈유지',
549 'band.save': '절약',
550 'band.resent': '{status} 재전송',
551 'band.cachePercent': '캐시 {percent}%',
552 'band.closeLogText': '로그 닫기',
553 'band.footerPaused': ' (일시 중지)',
554 'band.footerOff': ' (자동 꺼짐)',
555 'band.footerManual': ' (수동)',
556 'band.footerContext': 'ctx {percent}%',
557 'band.routerRestart': 'ClaudeRipple: 라우팅 경로를 표시하려면 라우터를 재시작하세요',
558 'band.routerDown': 'ClaudeRipple: 라우터가 응답하지 않습니다',
559 // Alerts.
560 'alerts.coldTitle': '캐시가 만료되었습니다',
561 'alerts.coldBody': '{minutes}분 동안 대기하여 캐시가 만료되었습니다. 다음 프롬프트는 {tokens} 토큰 전체를 새로 읽어 비용이 발생합니다.',
562 'alerts.keptTitle': '자리를 비운 동안 캐시를 유지했습니다',
563 'alerts.keptBody': '핑 {pings}회로 {tokens} 토큰을 캐시에 붙잡아 두었습니다. 다음 프롬프트도 캐시로 읽습니다. 만료까지 {minutes}분 남았습니다.',
564 'alerts.gotIt': '확인',
565 'alerts.swampTitle': '컨텍스트 {percent}% 사용',
566 'alerts.swampBody': '프롬프트마다 {tokens} 토큰이 함께 전송되어 응답 속도와 정확도, 비용에 영향을 줍니다.',
567 'alerts.hotTitle': '{window} 한도 {percent}% 도달',
568 'alerts.saveOnBody': '절약 모드 켜짐: 초기화까지 자동 강도는 중간 이하입니다.',
569 'alerts.saveBody': '절약 모드는 초기화까지 자동 강도를 중간 이하로 유지합니다.',
570 'alerts.saveOff': '절약 모드 끄기',
571 'alerts.save': '절약 모드',
572 'alerts.notNow': '나중에',
573 'alerts.judgeTitle': '추론 강도 판정 모델이 응답하지 않습니다',
574 'alerts.judgeNetworkTitle': '네트워크 연결이 불안정하여 임시로 Haiku가 판정합니다',
575 'alerts.routerModel': '라우터 모델',
576 'alerts.judgeBody': '{judge}: {reason}. {fallback}',
577 'alerts.keepEffort': '복구 전까지는 현재 강도를 유지합니다.',
578 'alerts.askHaiku': '복구 전까지는 Haiku가 판정합니다.',
579 'alerts.openSettings': '설정 열기',
580 'alerts.compactTitle': '대화 압축',
581 'alerts.compactBody': '요약에 꼭 남길 내용을 입력하세요. 비워 두면 자동으로 요약합니다.',
582 'alerts.cancel': '취소',
583 'alerts.handoffTitle': '새 대화로 인수인계',
584 'alerts.quickBody': '간단: 캐시로 요약을 생성해 새 대화에서 이어받습니다.',
585 'alerts.fullBody': '전체: 인수인계 스킬이 git을 확인하고 HANDOFF.md를 먼저 작성합니다.',
586 'alerts.go': '실행',
587 'alerts.dismiss': '닫기',
588 'alerts.handingOff': '인수인계 중…',
589 'alerts.fullProgress': '인수인계 실행 중: git을 확인하고 HANDOFF.md를 작성합니다.',
590 'alerts.quickProgress': '새 대화가 이어받을 요약을 작성하는 중입니다.',
591 'alerts.handoffDone': '인수인계 완료',
592 'alerts.summaryReady': '요약이 준비되었습니다.',
593 'alerts.copyAgain': '다시 복사',
594 'alerts.compacting': '압축 중…',
595 'alerts.compactingBody': '대화를 요약하는 중입니다. 이후에는 요약을 바탕으로 대화를 이어갑니다.',
596 'alerts.compacted': '압축 완료',
597 'alerts.compactedBody': '요약을 바탕으로 대화를 이어갑니다.',
598 'alerts.failed': '완료되지 않았습니다',
599 'alerts.unchanged': '아무것도 변경되지 않았습니다.',
600 'alerts.keep': '남길 내용',
601 'alerts.keepPlaceholder': '결정 사항, 남은 질문, 파일 이름… (선택)',
602 'alerts.compactSubmit': '압축',
603 'alerts.quick': '간단',
604 'alerts.full': '전체',
605 'alerts.continue': '새 세션에서 바로',
606 'alerts.confirm': '확인 후 이동',
607 'alerts.copy': '복사만',
608 'alerts.continueHint': '같은 폴더에 새 세션을 열고 인수인계를 입력창에 채워 둡니다. 거기서 Enter를 누르면 이어집니다. 지금 대화는 그대로 둡니다.',
609 'alerts.confirmHint': '요약까지만 만들고 멈춥니다. 새 세션으로 넘어갈지 직접 고릅니다. 지금 대화는 그대로 둡니다.',
610 'alerts.copyHint': '요약을 클립보드에 복사만 하고 다른 것은 바꾸지 않습니다.',
611 'alerts.handoffReady': '인수인계 준비 완료',
612 'alerts.handoffReadyBody': '같은 폴더의 새 세션으로 넘어갈까요? 지금 대화는 그대로 둡니다.',
613 'alerts.openNew': '새 세션으로 넘어가기',
614 'alerts.openedBody': '새 세션을 열고 인수인계를 채워 두었습니다. 거기서 Enter를 누르면 이어집니다.',
615 'alerts.readHandoffFile': '{path}의 인수인계를 읽고 이어서 진행해 주세요.',
616 'alerts.copyText': '복사',
617 'toasts.newSessionFallback': '새 세션을 열지 못해 인수인계를 대신 복사했습니다.',
618 'alerts.kind': '유형 ',
619 'alerts.then': '이후 동작 ',
620 'alerts.demoDown': 'Jev 크레딧 소진 (데모)',
621 'alerts.copied': '재개용 요약 메모를 복사했습니다. 현재 대화는 보존됩니다.',
622 'alerts.compactFailed': '대화 압축을 마치지 못했습니다.',
623 'alerts.clearTitle': '대화를 새로 시작할까요?',
624 'alerts.clearBody': '지금 대화 맥락이 비워집니다. 새 대화가 아무것도 없는 상태에서 시작됩니다.',
625 'alerts.clearGo': '새로 시작',
626 // Settings.
627 'settings.effortBlurb': '전송하는 프롬프트마다 추론 강도를 자동으로 선택합니다.',
628 'settings.workers': '워커',
629 'settings.mode': '모드',
630 'settings.modeNone': '기본',
631 'band.mode': '모드',
632 'band.modeTip': '“{name}” 모드가 켜져 있습니다. 워커가 이 모드를 따릅니다. 빠른 설정에서 바꿀 수 있습니다.',
633 'settings.workersBlurb': '위임 작업 유형별로 실행할 워커를 설정합니다.',
634 'settings.judge': '판정 모델',
635 'settings.judgeBlurb': '프롬프트의 추론 강도와 위임 작업의 등급을 결정하는 모델입니다.',
636 'settings.cacheBlurb': '자리를 비운 동안에도 프롬프트 캐시를 유지합니다.',
637 'settings.handoff': '압축·인수인계',
638 'settings.handoffBlurb': '컨텍스트가 큰 대화를 압축하거나 새 대화로 인수인계합니다.',
639 'settings.look': '화면 표시',
640 'settings.lookBlurb': '상태 바에 표시할 항목과 형태를 설정합니다.',
641 'settings.light': '경량',
642 'settings.lightBlurb': '단순 반복 작업, 파일 조회',
643 'settings.standard': '표준',
644 'settings.standardBlurb': '명세 기반 구현, 조사, 코드 리뷰',
645 'settings.deep': '심층',
646 'settings.deepBlurb': '신중한 판단, 고위험 작업, 복잡한 추론',
647 'settings.design': '디자인',
648 'settings.designBlurb': 'UI, 시각 디자인, 브랜드',
649 'settings.tierAlt': '{name} 등급',
650 'settings.biasAlt': '성향 {value}{selected}',
651 'settings.selected': ' (선택됨)',
652 'settings.onSuffix': ' (켜짐)',
653 'settings.offSuffix': ' (꺼짐)',
654 'settings.mixedSuffix': ' (일부 켜짐)',
655 'settings.cheaper': '저렴하게',
656 'settings.smarter': '똑똑하게',
657 'settings.autoHint': '프롬프트마다 자동 선택하며, 직접 변경 시 비활성화됩니다.',
658 'settings.lean': '성향',
659 'settings.leanHint': '판단이 모호할 때만 적용',
660 'settings.floor': '최저 강도',
661 'settings.ceiling': '최고 강도',
662 'settings.floorWins': '최저 강도 미만이므로 최저 강도를 적용합니다.',
663 'settings.unavailable': ' (사용 불가)',
664 'settings.takesOver': '적용 대상',
665 'settings.autoAgent': 'ripple:auto',
666 'settings.allSpawns': '모든 서브에이전트',
667 'settings.fallback': '대체 워커',
668 'settings.fallbackHint': '해당 등급의 프로바이더 한도가 소진되었을 때',
669 'settings.haiku': 'Haiku',
670 'settings.jev': 'Jev',
671 'settings.routerModel': '라우터 모델',
672 'settings.haikuHint': 'Claude 로그인으로 Haiku 5.5 사용 · 프롬프트당 약 1초',
673 'settings.keyEnv': 'TYPESAFE_API_KEY에서 읽음',
674 'settings.keyFile': '~/.config/jev/.env에 저장됨',
675 'settings.keySaved': '저장됨',
676 'settings.keyPaste': 'TypeSafe API 키 붙여넣기',
677 'settings.saveKey': '저장',
678 'settings.testing': '확인 중…',
679 'settings.keyWorks': 'API 키 인증 성공',
680 'settings.keyRejected': 'API 키 인증 실패',
681 'settings.key': 'API 키',
682 'settings.test': '테스트',
683 'settings.noModels': '표시할 라우터 모델이 없습니다.',
684 'settings.haikuFallback': '실패 시 Haiku로 대체',
685 'settings.keepWarm': '캐시 유지',
686 'settings.keepWarmHint': '만료 직전에 캐시 핑 전송',
687 'settings.for': '유지 시간',
688 'settings.afterReply': '마지막 응답 기준',
689 'settings.chatsFrom': '대상 대화',
690 'settings.tokens': '토큰 이상',
691 'settings.warmCost': '핑을 보낼 때마다 캐시에서 대화 전체를 다시 읽습니다. 입력 비용의 약 1/10이며, 요금제 한도도 동일 비율로 소진합니다.',
692 'settings.noSkill': '스킬 없음: 1턴의 대화로 HANDOFF.md 작성',
693 'settings.compactWith': '압축 담당 모델',
694 'settings.sessionCompactor': '세션 기본',
695 'settings.afterHandoff': '인수인계 후 동작',
696 'settings.handoffButton': '인수인계 버튼 표시',
697 'settings.whenAdvised': '권장 시',
698 'settings.always': '항상',
699 'settings.fullHandoff': '전체 인수인계',
700 'settings.heavyAt': '대화 압축 기준',
701 'settings.context': '컨텍스트',
702 'settings.cacheTimer': '캐시 타이머',
703 'settings.reason': '판정 사유',
704 'settings.route': '라우팅 경로',
705 'settings.limits': '한도',
706 'settings.layout': '레이아웃',
707 'settings.dashboard': '대시보드',
708 'settings.minimal': '최소화',
709 'settings.footerHint': '하단 한 줄 표시',
710 'settings.appearance': '테마',
711 'settings.dark': '다크',
712 'settings.lightAppearance': '라이트',
713 'settings.skin': '스킨',
714 'settings.skinLine': '상태 바와 카드의 모양을 정합니다.',
715 'settings.skinDeep': '딥',
716 'settings.skinFlat': '플랫',
717 'settings.show': '표시 항목',
718 'settings.language': '언어',
719 'settings.title': '설정',
720 'settings.quick': '빠른 설정',
721 'settings.effortAuto': '자동 추론 강도',
722 'settings.openAll': '전체 설정 열기',
723 'setup.workersNote': '워커는 기본 사다리 규칙으로 시작하며, 대시보드에서 맞춤 설정할 수 있습니다.',
724 'setup.changeDashboard': '상태 바나 /ripple에서 빠르게 변경하고, 전체 설정 열기로 대시보드로 이동할 수 있습니다.',
725 'settings.save': '저장',
726 'settings.discard': '변경 취소',
727 'settings.close': '닫기',
728 'settings.autoLine': '프롬프트마다 알맞은 강도를 자동으로 고릅니다.',
729 'settings.autoOffNote': '앱의 강도 메뉴에서 고른 값을 그대로 씁니다.',
730 'settings.judgeLine': '추론 강도와 위임 작업 등급을 정합니다.',
731 'settings.modeLine': '위임 작업을 맡길 워커 묶음을 고릅니다.',
732 'settings.warmLine': '캐시가 식기 직전에 핑을 보내 데워 둡니다.',
733 'settings.warmCostShort': '핑마다 입력 비용의 약 1/10이 듭니다.',
734 'settings.sayAuto': '{judge} 판정으로 프롬프트마다 추론 강도를 골라요.',
735 'settings.sayManual': '추론 강도는 앱에서 고른 대로 둘게요.',
736 'settings.sayWarm': '캐시는 식기 전에 데워 둘게요.',
737 'band.hideBand': '띠 숨기기',
738 'settings.unsaved': '● 저장되지 않음',
739 // Setup.
740 'setup.judgeTitle': '판정 방식 설정',
741 'setup.judgeBlurb': '작고 빠른 판정 모델이 프롬프트마다 강도를, 작업마다 워커를 결정합니다.',
742 'setup.workersTitle': '작업 실행 주체',
743 'setup.cacheTitle': '캐시를 유지할까요?',
744 'setup.cacheBlurb': '대화가 유휴 상태일 때 캐시가 만료됩니다. 자리를 비워도 Ripple이 핑을 보내 캐시를 유지할 수 있습니다.',
745 'setup.effortSummary': '자동 강도 {state}, 판정 모델: {judge}',
746 'setup.cacheSummary': '응답 후 {hours}시간 동안 캐시 유지',
747 'setup.cacheOff': '캐시 유지 사용 안 함',
748 'setup.ready': 'ClaudeRipple 준비 완료',
749 'setup.welcome': 'ClaudeRipple을 시작합니다',
750 'setup.start': '시작',
751 'setup.finish': '완료',
752 'setup.next': '다음',
753 'setup.back': '이전',
754 'setup.skip': '설정 건너뛰기',
755 // Agents.
756 'agents.waiting': '대기 중',
757 'agents.routerModel': '라우터 모델',
758 'agents.marker': '마커',
759 'agents.default': '기본값',
760 'agents.session': '세션 모델',
761 'agents.by': '{name} 배정',
762 'agents.thinking': '추론 중',
763 'agents.fallback': '▲ 대체 워커 실행: {reason}',
764 'agents.emptyTitle': '실행 중인 에이전트 없음',
765 'agents.emptyBody': 'Claude가 위임한 작업이 이곳에 표시되며, Ripple이 선정한 워커와 사유를 확인할 수 있습니다.',
766 'agents.liveCount': '{count}개 실행 중',
767 'agents.noneRunning': '실행 중인 에이전트 없음',
768 'agents.doneCount': '{count}개 완료',
769 // Log.
770 'log.resent': '↻ {status} 재전송',
771 'log.input': '입력 {tokens}',
772 'log.output': '출력 {tokens}',
773 'log.thisSession': '현재 세션',
774 'log.allSessions': '전체 세션',
775 'log.showAll': '전체 세션 표시',
776 'log.onlySession': '현재 세션만 표시',
777 'log.legend': '● 현재 세션 ○ 다른 세션',
778 'log.empty': '아직 모델 요청 내역이 없습니다.',
779 'log.judge': '강도 판정',
780 'log.judgeWorker': '워커 판정',
781 'log.judgeFell': '{asked} 실패 → {by}',
782 'log.sure': '확신도 {percent}%',
783 // Toasts and locally generated failure/reason text.
784 'toasts.keySingle': '단일 TypeSafe API 키를 입력하세요',
785 'toasts.keyPrivateFailed': 'Jev 키 파일 권한을 비공개로 변경하지 못했습니다 (chmod 600)',
786 'toasts.keySaved': 'ClaudeRipple: Jev 키를 안전하게 저장했습니다. 환경 변수 키가 우선 적용됩니다.',
787 'toasts.judgeDown': 'ClaudeRipple: {reason}. 현재 추론 강도를 유지하거나 Haiku에 판정을 요청합니다.',
788 'toasts.keyMissing': 'Jev 키 누락',
789 'toasts.jevNoVerdict': 'Jev 판정 결과 없음',
790 'judge.keptUnsure': '판정 결과가 모호하여 현재 강도 유지',
791 'toasts.routerNoVerdict': '라우터 판정 모델 결과 없음',
792 'toasts.haikuNoVerdict': 'Haiku 판정 결과 없음',
793 'toasts.noVerdict': '판정 결과 없음',
794 'toasts.judgeUnavailable': '판정 모델 사용 불가',
795 'toasts.explicitMarker': '명시적 워커 마커',
796 'toasts.sessionTier': '메인 대화가 정한 등급: {tier}',
797 'toasts.workerFallback': 'ClaudeRipple: {reason}. {name} 워커로 대체 실행합니다.',
798 'toasts.modeEnded': '{name} 모드가 종료되었습니다.',
799 'toasts.yourPick': '사용자 직접 선택',
800 'toasts.effortEnter': 'ClaudeRipple: Enter를 누르면 앱의 추론 강도 설정도 선택한 값을 반영합니다.',
801 'toasts.settingsSaved': 'ClaudeRipple: 설정을 저장했습니다.',
802 'toasts.clipboardFallback': 'ClaudeRipple: 클립보드를 사용할 수 없어 재개 메모를 프롬프트에 삽입했습니다.',
803 'toasts.handoffFailed': 'ClaudeRipple: 인수인계에 실패했습니다. 기존 대화는 유지됩니다.',
804 'toasts.compactFailed': 'ClaudeRipple: 대화 압축에 실패했습니다. 기존 대화는 유지됩니다.',
805 'toasts.warmStopped': 'ClaudeRipple: {reason}',
806 'toasts.warmBoundReason': '캐시 유지 중단: 설정된 시간 한도 도달',
807 'toasts.warmBound': 'ClaudeRipple: 시간 한도에 도달하여 캐시 유지를 중단했습니다.',
808 'toasts.appEffort': '앱 추론 강도',
809 'toasts.appEffortChanged': '앱 추론 강도 변경됨',
810 'toasts.autoOff': 'ClaudeRipple: 앱에서 추론 강도를 변경하여 자동 선택이 비활성화되었습니다.',
811 'toasts.handoffEmpty': 'ClaudeRipple: 인수인계 결과가 비어 있어 작업을 취소했습니다.',
812 'toasts.ownModel': 'ClaudeRipple: {name} 에이전트는 "{model}" 대신 지정된 자체 모델로 실행됩니다.',
813 'toasts.transcriptEmpty': '대화 기록이 비어 있거나 압축 예산 한도를 초과했습니다.',
814 'toasts.summaryEmpty': 'Haiku에서 유효한 요약 결과를 수신하지 못했습니다.',
815 'toasts.compactFallback': 'ClaudeRipple: Haiku 압축 실패로 세션 기본 압축기를 사용합니다.',
816 'toasts.error': 'ClaudeRipple: {reason}',
817 'toasts.jevTimeout': 'Jev 요청 시간 초과',
818 'toasts.jevRejected': 'Jev 키 인증 거부됨',
819 'toasts.jevCredits': 'Jev 크레딧 소진됨',
820 'toasts.jevRateLimited': 'Jev 속도 제한 초과',
821 'toasts.jevHttp': 'Jev 요청 실패 ({status})',
822 'toasts.warmNoReply': '캐시 유지 중단: 포크 응답 없음',
823 'toasts.warmNoCache': '캐시 유지 중단: 캐시 미적중',
824 'toasts.providerQuota': 'ChatGPT 계정을 사용할 수 없거나 사용량 한도가 소진되었습니다.',
825 'toasts.logNotPlaced': '로그 창을 열 수 없습니다: {reason}',
826 'toasts.saveMode': '절약 모드',
827 'toasts.yourSettings': '사용자 설정',
828 'toasts.jevNetwork': 'Jev 네트워크 연결 불가',
829 'toasts.workerMissing': '{name} 에이전트 미설치',
830 // Commands (stats and debug labels included).
831 'commands.description': 'ClaudeRipple 추론 강도, 워커, 캐시 및 세션 도구',
832 'commands.argumentHint': 'settings|setup|auto|warm|save|handoff [full]|stats|debug|agents|log|bar|cold|swamp|hot|down|kept',
833 'commands.logDescription': 'ClaudeRipple 요청 로그 열기/닫기',
834 'commands.barDescription': 'ClaudeRipple 상태 바 표시/숨기기',
835 'commands.logTitle': 'ClaudeRipple',
836 'commands.agentsTitle': 'ClaudeRipple 에이전트',
837 'commands.help': 'ClaudeRipple 하위 명령: settings, setup, auto, warm, save, handoff [full], stats, debug, agents, log, bar, cold, swamp, hot, down, kept.',
838 'commands.done': 'ClaudeRipple: {command} 완료.',
839 'commands.logOpened': 'ClaudeRipple 로그를 열었습니다.',
840 'commands.logClosed': 'ClaudeRipple 로그를 닫았습니다.',
841 'commands.barShown': '프롬프트 상단에 ClaudeRipple 상태 바를 표시합니다.',
842 'commands.barShownMinimal': '레이아웃이 최소화로 되어 있어 ClaudeRipple은 프롬프트 아래 한 줄로만 표시됩니다. 프롬프트 위에 상태 바를 띄우려면 /ripple settings에서 레이아웃을 대시보드로 바꾸십시오.',
843 'commands.barHidden': 'ClaudeRipple 상태 바를 숨겼습니다. /ripple-bar로 다시 표시할 수 있습니다.',
844 'commands.statsAuto': '자동: 프롬프트 {prompts}개, 요청 {requests}개, 가중 토큰 {total}',
845 'commands.statsTokens': '토큰: 입력 {input}, 쓰기 {write}, 읽기 {read}, 출력 {output}',
846 'commands.statsEffort': '{effort} 프롬프트 {prompts}개 / 가중치 {cost}',
847 'commands.statsJudges': '판정 모델: haiku {haiku}, jev {jev}, 모델 {model}; 토큰 {tokens}, {ms} ms (평균 {avg} ms)',
848 'commands.statsWorkers': '워커: {workers}',
849 'commands.statsWarm': '캐시 유지: 핑 {pings}회, 가중 토큰 {cost}',
850 'commands.debugJudge': '판정 모델',
851 'commands.debugJudgeModel': '판정용 라우터 모델',
852 'commands.debugPick': '선택',
853 'commands.debugPaused': '일시 중지',
854 'commands.debugCache': '캐시',
855 'commands.debugWarm': '캐시 유지',
856 'commands.debugLastSpawn': '최근 생성',
857 'commands.debugLastRequest': '최근 요청',
858 'commands.debugEffort': '추론 강도',
859 'commands.debugWhy': '사유',
860 'commands.debugBy': '선택 주체',
861 'commands.debugAt': '시각',
862 'commands.debugSure': '확신도',
863 'commands.debugTtlMs': 'TTL(ms)',
864 'commands.debugLastReplyAt': '최근 응답 시각',
865 'commands.debugExpiresAt': '만료 시각',
866 'commands.debugPings': '핑 횟수',
867 'commands.debugCost': '비용',
868 'commands.debugStopped': '중단 사유',
869 'commands.debugLastPingAt': '최근 핑 시각',
870 'commands.debugTier': '등급',
871 'commands.debugTarget': '대상',
872 'commands.debugAgent': '에이전트',
873 'commands.debugModel': '모델',
874 'commands.debugMs': 'ms',
875 'commands.debugFallbackReason': '대체 사유',
876 'commands.debugAppEffort': '앱 강도',
877 'commands.debugSentEffort': '전송 강도',
878 'commands.debugLog': 'ClaudeRipple {where}: {reason}',
879 'commands.routerHttp': '라우터 HTTP {status}',
880 'commands.compactSkip': '압축이 필요할 때 ClaudeRipple이 Haiku로 요약합니다.',
881 // Workers card, Jev source, and worker picks in the Agents pane.
882 'workers.previous': '이전',
883 'workers.next': '다음',
884 'workers.page': '{page} / {pages}페이지',
885 'workers.general': '일반',
886 'workers.design': '디자인',
887 'workers.frontend': '프론트엔드',
888 'workers.backend': '백엔드',
889 'workers.infra': '인프라',
890 'workers.data': '데이터',
891 'workers.test': '테스트',
892 'workers.docs': '문서',
893 'workers.research': '조사',
894 'workers.mode': '모드',
895 'workers.rung.light': '로우 워커',
896 'workers.rung.standard': '미디움 워커',
897 'workers.rung.hard': '하이 워커',
898 'workers.rung.plan': '맥스 워커',
899 'workers.rungHint.light': '파일 검색, 단건 조회, 로그 검토, 기계적 수정',
900 'workers.rungHint.standard': '명세 기반 구현, 리팩터링, 요약, 초안 작성',
901 'workers.rungHint.hard': '고난도 디버깅, 다중 파일 변경, 독립적 검토 및 반박',
902 'workers.rungHint.plan': '최상의 품질: 기획, 설계, 아키텍처 수립, 심층 의견 제시',
903 'workers.rungEmpty': '{rung}에 설정된 모델이 없습니다',
904 'workers.notCandidate': '이 분야 후보에 없는 모델입니다: {name}',
905 'workers.sessionModel': '세션 기본 모델',
906 'workers.offset': '추론 강도 보정',
907 'workers.anyEffort': '제한 없음',
908 'workers.noTarget': '상위 등급 또는 대체 워커 사용',
909 'workers.judgeBlurb': '프롬프트의 추론 강도와 작업 분야 및 등급을 결정하는 모델입니다.',
910 'workers.disabled': '{provider} 비활성화됨',
911 'workers.nested': '서브에이전트는 하위 서브에이전트를 생성할 수 없습니다. 이 부분은 직접 실행하고 결과를 보고하세요.',
912 'workers.none': '사용 가능한 활성 워커 없음',
913 'workers.loading': '라우터의 워커 목록을 불러오는 중…',
914 'workers.providerSub': '{count}개 중 {on}개 활성',
915 'workers.fallbackHint': '고정된 대상을 실행할 수 없을 때',
916 'workers.pinDetails': '추가 옵션',
917 'workers.back': '뒤로 가기',
918 'settings.ladderWaiting': '라우터에서 모델 목록을 수신하는 중입니다.',
919 'jev.zenMissing': 'ClaudeRipple 설정에 OpenCode Zen 인증 키가 없습니다',
920 'agents.domainLevel': '{domain} / {level}',
921 'agents.tokens': '≈{tokens} 토큰',
922 // Time units: countdowns, reset times, durations.
923 'units.seconds': '{n}초',
924 'units.minutes': '{n}분',
925 'units.hours': '{n}시간',
926 'units.days': '{n}일',
927 'units.minSec': '{m}분 {s}초',
928 'units.hourMin': '{h}시간 {m}분',
929}
930
931const ZH: Record<Key, string> = {
932 // Band (including shared display labels and accessibility text).
933 'band.brand': 'ClaudeRipple',
934 'band.effort': '推理强度',
935 'band.model': '模型',
936 'band.auto': '自动',
937 'band.manual': '手动',
938 'band.paused': '已暂停',
939 'band.off': '关闭',
940 'band.on': '开启',
941 'band.autoOff': '自动模式关闭',
942 'band.autoPaused': '自动模式已暂停',
943 'band.deciding': '判定中…',
944 'band.decidingText': '◌ 判定中…',
945 'band.low': '低',
946 'band.medium': '中',
947 'band.high': '高',
948 'band.xhigh': '超高',
949 'band.max': '最高',
950 'band.pausedHint': '此模型上自动模式已暂停:尚不确定在此模型上切换推理强度能否保住缓存。',
951 'band.offHint': '自动模式已关闭,推理强度由你决定。',
952 'band.decidingHint': '判定模型正在为这条提示词选择推理强度。',
953 'band.effortTitle': '推理强度 {value}{hint} · 点击选择推理强度',
954 'band.effortAlt': '推理强度 {value}({mode}){why}',
955 'band.context': '上下文',
956 'band.contextAlt': '上下文 {value},共 {window}({tokens} token)',
957 'band.cache': '缓存',
958 'band.cacheWarm': '缓存 · 保温中',
959 'band.cacheStopped': '缓存 · 保温已停',
960 'band.cold': '已过期',
961 'band.cacheTip': '提示词缓存{state}{warm}',
962 'band.warmFor': '{time}后过期',
963 'band.expired': '已过期',
964 'band.warmStoppedTip': ' · 保温已停止:{reason}',
965 'band.warmOnTip': ' · 保温开启,已 ping {count} 次',
966 'band.noReply': '尚无回复',
967 'band.cacheAlt': '缓存{state}{warm}',
968 'band.timeLeft': '剩余 {time}',
969 'band.unknown': '未知',
970 'band.keepWarmAlt': ',保温开启{count}',
971 'band.limitCaption': '{window}限额{save}',
972 'band.saveCaption': ' · 节省',
973 'band.limitAlt': '{window}限额 {value}{reset}',
974 'band.limitsTip': '{provider} {window} {value}{reset}',
975 'band.fiveHour': '5 小时',
976 'band.weekly': '每周',
977 'band.resetting': '重置中',
978 'band.resetsIn': '{time}后重置',
979 'band.resetsAt': '{time} 重置',
980 'band.router': '路由服务',
981 'band.routerAlt': '路由服务:{value}',
982 'band.resentCaption': '已重发 · {status}',
983 'band.failedCaption': '失败 · {status}',
984 'band.lastRoute': '最近路由',
985 'band.logHint': '点击查看请求日志',
986 'band.routeAlt': '最近路由:{model} 经由 {provider}{effort}{cache}, {time}{status}',
987 'band.routeCache': ', 缓存 {percent}%',
988 'band.routeResent': ', 已重发 {status}',
989 'band.routeFailed': ', 失败 {status}',
990 'band.warm': '保温',
991 'band.handoff': '交接',
992 'band.compact': '压缩',
993 'band.clear': '新开',
994 'band.compactAlt': '压缩对话 — 总结对话以释放上下文',
995 'band.clearAlt': '重新开始 (/clear) — 清空对话上下文,重新开始',
996 'band.log': '日志',
997 'band.agents': '代理',
998 'band.settings': '设置',
999 'band.effortShort': '强度',
1000 'band.hit': '命中 {percent}%',
1001 'band.effortAutoAlt': '推理强度自动{state}',
1002 'band.warmAlt': '缓存保温{state}',
1003 'band.warmHere': '仅限本会话,新会话按设置',
1004 'band.handoffAdvised': '交接(建议:现在换新对话更划算)',
1005 'band.closeLog': '关闭请求日志',
1006 'band.openLog': '打开请求日志',
1007 'band.agentsAlt': '代理:{count} 个运行中',
1008 'band.settingsAlt': 'Ripple 设置',
1009 'band.warmStopped': '保温已停',
1010 'band.warmText': '≈保温',
1011 'band.save': '节省',
1012 'band.resent': '{status} 已重发',
1013 'band.cachePercent': '缓存 {percent}%',
1014 'band.closeLogText': '关闭日志',
1015 'band.footerPaused': ' (已暂停)',
1016 'band.footerOff': ' (自动模式关闭)',
1017 'band.footerManual': ' (手动)',
1018 'band.footerContext': '上下文 {percent}%',
1019 'band.routerRestart': 'ClaudeRipple: 重启路由服务后,此处才会显示路由',
1020 'band.routerDown': 'ClaudeRipple: 路由服务无响应',
1021 // Alerts.
1022 'alerts.coldTitle': '缓存已过期',
1023 'alerts.coldBody': '闲置了 {minutes} 分钟,下一条提示词会按全价重读全部 {tokens} token。',
1024 'alerts.keptTitle': '您离开期间缓存已保持温热',
1025 'alerts.keptBody': '保温 ping {pings} 次,把 {tokens} token 留在了缓存里,下一条提示词仍按缓存价读取。还有 {minutes} 分钟过期。',
1026 'alerts.gotIt': '知道了',
1027 'alerts.swampTitle': '上下文已用 {percent}%',
1028 'alerts.swampBody': '每条提示词都带着 {tokens} token:更慢、更贵、更模糊。',
1029 'alerts.hotTitle': '{window}限额已达 {percent}%',
1030 'alerts.saveOnBody': '节省模式已开启:重置前,自动强度保持在“中”或以下。',
1031 'alerts.saveBody': '节省模式会在重置前把自动强度保持在“中”或以下。',
1032 'alerts.saveOff': '关闭节省模式',
1033 'alerts.save': '节省模式',
1034 'alerts.notNow': '暂不',
1035 'alerts.judgeTitle': '推理强度判定模型无响应',
1036 'alerts.judgeNetworkTitle': '网络连接不稳定,暂由 Haiku 判定',
1037 'alerts.routerModel': '路由模型',
1038 'alerts.judgeBody': '{judge}:{reason}。{fallback}',
1039 'alerts.keepEffort': '在此期间保持当前强度。',
1040 'alerts.askHaiku': '在此期间由 Haiku 判定。',
1041 'alerts.openSettings': '打开设置',
1042 'alerts.compactTitle': '压缩此对话',
1043 'alerts.compactBody': '请输入摘要必须保留的内容;留空则自动总结。',
1044 'alerts.cancel': '取消',
1045 'alerts.handoffTitle': '交接到新对话',
1046 'alerts.quickBody': '快速:基于缓存写一份摘要,新对话从这里接着做。',
1047 'alerts.fullBody': '完整:交接技能先检查 git 并写入 HANDOFF.md。',
1048 'alerts.go': '开始',
1049 'alerts.dismiss': '关闭',
1050 'alerts.handingOff': '正在交接…',
1051 'alerts.fullProgress': '正在交接:检查 git 并写入 HANDOFF.md。',
1052 'alerts.quickProgress': '正在写一份新对话可以接续的摘要。',
1053 'alerts.handoffDone': '交接完成',
1054 'alerts.summaryReady': '摘要已就绪。',
1055 'alerts.copyAgain': '再次复制',
1056 'alerts.compacting': '正在压缩…',
1057 'alerts.compactingBody': '正在总结对话,之后从摘要继续。',
1058 'alerts.compacted': '已压缩',
1059 'alerts.compactedBody': '对话将从摘要继续。',
1060 'alerts.failed': '未能完成',
1061 'alerts.unchanged': '没有做任何更改。',
1062 'alerts.keep': '保留',
1063 'alerts.keepPlaceholder': '决定、待解决的问题、文件名…(可选)',
1064 'alerts.compactSubmit': '压缩',
1065 'alerts.quick': '快速',
1066 'alerts.full': '完整',
1067 'alerts.continue': '立即开新会话',
1068 'alerts.confirm': '先问我',
1069 'alerts.copy': '仅复制',
1070 'alerts.continueHint': '在此文件夹打开新会话并填好交接内容,在那里按 Enter 即可继续。当前对话保留。',
1071 'alerts.confirmHint': '只写好摘要就停下,由你决定是否转到新会话。当前对话保留。',
1072 'alerts.copyHint': '只把摘要复制到剪贴板,其他不变。',
1073 'alerts.handoffReady': '交接已就绪',
1074 'alerts.handoffReadyBody': '要转到此文件夹的新会话吗?当前对话保持不变。',
1075 'alerts.openNew': '转到新会话',
1076 'alerts.openedBody': '已打开新会话并填好交接内容:在那里按 Enter 继续。',
1077 'alerts.readHandoffFile': '请阅读 {path} 中的交接内容并接着进行。',
1078 'alerts.copyText': '复制',
1079 'toasts.newSessionFallback': '无法打开新会话,已改为复制交接内容。',
1080 'alerts.kind': '方式 ',
1081 'alerts.then': '然后 ',
1082 'alerts.demoDown': 'Jev 额度用尽(演示)',
1083 'alerts.copied': '已复制续接说明,此对话已保留。',
1084 'alerts.compactFailed': '压缩未完成。',
1085 'alerts.clearTitle': '重新开始此对话?',
1086 'alerts.clearBody': '当前对话的上下文将被清空;新对话从空白开始。',
1087 'alerts.clearGo': '重新开始',
1088 // Settings.
1089 'settings.effortBlurb': '自动模式如何为你发送的每条提示词选择推理强度。',
1090 'settings.workers': 'Worker',
1091 'settings.mode': '模式',
1092 'settings.modeNone': '默认',
1093 'band.mode': '模式',
1094 'band.modeTip': '“{name}”模式已开启,Worker 按它分配。可在快捷设置中更改。',
1095 'settings.workersBlurb': '每类委派任务由哪个代理执行。',
1096 'settings.judge': '判定',
1097 'settings.judgeBlurb': '由谁决定:提示词的推理强度、任务的等级。',
1098 'settings.cacheBlurb': '离开时让提示词缓存保持有效。',
1099 'settings.handoff': '压缩与交接',
1100 'settings.handoffBlurb': '较重的对话如何压缩或交接到新对话。',
1101 'settings.look': '显示',
1102 'settings.lookBlurb': '状态栏显示什么、如何显示。',
1103 'settings.light': '轻量',
1104 'settings.lightBlurb': '杂务、重复、查询',
1105 'settings.standard': '标准',
1106 'settings.standardBlurb': '按既定设计实现、调研、评审',
1107 'settings.deep': '深度',
1108 'settings.deepBlurb': '需谨慎处理的任务、高风险任务、复杂推理',
1109 'settings.design': '设计',
1110 'settings.designBlurb': 'UI、视觉、品牌',
1111 'settings.tierAlt': '{name}等级',
1112 'settings.biasAlt': '倾向 {value}{selected}',
1113 'settings.selected': ' (已选)',
1114 'settings.onSuffix': ' (开启)',
1115 'settings.offSuffix': ' (关闭)',
1116 'settings.mixedSuffix': ' (部分开启)',
1117 'settings.cheaper': '更省',
1118 'settings.smarter': '更聪明',
1119 'settings.autoHint': '按提示词选择;你手动选择后即关闭',
1120 'settings.lean': '倾向',
1121 'settings.leanHint': '只影响难分的情况',
1122 'settings.floor': '不低于',
1123 'settings.ceiling': '不高于',
1124 'settings.floorWins': '低于下限:以下限为准',
1125 'settings.unavailable': ' (不可用)',
1126 'settings.takesOver': '接管范围',
1127 'settings.autoAgent': 'ripple:auto',
1128 'settings.allSpawns': '所有子代理',
1129 'settings.fallback': '备用',
1130 'settings.fallbackHint': '当某等级的供应商额度用尽时',
1131 'settings.haiku': 'Haiku',
1132 'settings.jev': 'Jev',
1133 'settings.routerModel': '路由模型',
1134 'settings.haikuHint': '使用你的 Claude 登录运行 Haiku 5.5 · 每条提示词约 1 秒',
1135 'settings.keyEnv': '来自 TYPESAFE_API_KEY',
1136 'settings.keyFile': '已保存在 ~/.config/jev/.env',
1137 'settings.keySaved': '已保存',
1138 'settings.keyPaste': '粘贴 TypeSafe API 密钥',
1139 'settings.saveKey': '保存',
1140 'settings.testing': '测试中…',
1141 'settings.keyWorks': '密钥有效',
1142 'settings.keyRejected': '密钥被拒绝',
1143 'settings.key': '密钥',
1144 'settings.test': '测试',
1145 'settings.noModels': '路由服务未列出任何模型',
1146 'settings.haikuFallback': '失败时回退到 Haiku',
1147 'settings.keepWarm': '缓存保温',
1148 'settings.keepWarmHint': '在缓存过期前 ping 一次',
1149 'settings.for': '持续',
1150 'settings.afterReply': '自最后一次回复起',
1151 'settings.chatsFrom': '对话规模',
1152 'settings.tokens': 'token 起',
1153 'settings.warmCost': '每次 ping 都会从缓存重新读取整个对话:约为其输入价格的十分之一,并按同样比例占用套餐限额。',
1154 'settings.noSkill': '无:由一轮对话写入 HANDOFF.md',
1155 'settings.compactWith': '压缩方式',
1156 'settings.sessionCompactor': '会话自带',
1157 'settings.afterHandoff': '交接之后',
1158 'settings.handoffButton': '交接按钮',
1159 'settings.whenAdvised': '建议时',
1160 'settings.always': '始终',
1161 'settings.fullHandoff': '完整交接',
1162 'settings.heavyAt': '使用率阈值',
1163 'settings.context': '上下文',
1164 'settings.cacheTimer': '缓存计时',
1165 'settings.reason': '原因',
1166 'settings.route': '路由',
1167 'settings.limits': '限额',
1168 'settings.layout': '布局',
1169 'settings.dashboard': '仪表盘',
1170 'settings.minimal': '极简',
1171 'settings.footerHint': '在底栏显示一行',
1172 'settings.appearance': '外观',
1173 'settings.dark': '深色',
1174 'settings.lightAppearance': '浅色',
1175 'settings.skin': '皮肤',
1176 'settings.skinLine': '设置状态栏和卡片的样式。',
1177 'settings.skinDeep': '深海',
1178 'settings.skinFlat': '平面',
1179 'settings.show': '显示',
1180 'settings.language': '语言',
1181 'settings.title': '设置',
1182 'settings.quick': '快捷设置',
1183 'settings.effortAuto': '自动推理强度',
1184 'settings.openAll': '打开全部设置',
1185 'setup.workersNote': 'Worker 默认使用阶梯;详细设置在控制面板中。',
1186 'setup.changeDashboard': '在状态栏或 /ripple 中快速调整;打开全部设置可前往控制面板。',
1187 'settings.save': '保存',
1188 'settings.discard': '放弃',
1189 'settings.close': '关闭',
1190 'settings.autoLine': '为每条提示词自动选择合适的推理强度。',
1191 'settings.autoOffNote': '沿用你在应用强度菜单中选择的级别。',
1192 'settings.judgeLine': '决定提示词的推理强度和委派任务的等级。',
1193 'settings.modeLine': '选择执行委派任务的一组代理。',
1194 'settings.warmLine': '在缓存过期前 ping 一次,让它保持温热。',
1195 'settings.warmCostShort': '每次 ping 约为输入价格的十分之一。',
1196 'settings.sayAuto': '由 {judge} 判定,为每条提示词选择推理强度。',
1197 'settings.sayManual': '推理强度保持你在应用中设置的级别。',
1198 'settings.sayWarm': '缓存我会替你保温。',
1199 'band.hideBand': '隐藏横条',
1200 'settings.unsaved': '● 未保存',hooks/judge.ts 250 lines1import type { Domain, Effort, JudgeInput, Kind, Level, Pick, Settings, Verdict, WorkerVerdict } from '../types'
2import { DOMAINS, LEVELS } from './settings.ts'
3import { t } from './i18n.ts'
4import type { Lang } from './i18n.ts'
5
6const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
7
8export const EFFORT_JUDGE_SYSTEM = `Choose the cheapest reasoning effort that can do this work well. This is a coding agent able to inspect files, run commands, and edit code, not merely answer a chat question.
9Assess work volume, uncertainty, and the cost of mistakes, not message length. A short choice approving a build inherits the size of that build. Corrections and steering retain at least the running effort; closing remarks need little effort. "Think hard" or thorough reasoning requires at least high; an explicit quick answer calls for low.
10Low and medium are only for turns that need no judgment: low for chit-chat, thanks, or a direct answer needing no inspection; medium for one fully specified, low-risk step the user spelled out (run this command, rename this). Anything that asks the agent to decide, explain why, review, verify, debug, plan, delegate, or change more than one thing needs at least high; most coding-agent turns are high. xhigh is for unusually difficult investigation, and max only for the hardest open-ended problems. Consider the current model's strength.
11Return only JSON {"effort":"low|medium|high|xhigh|max","sure":0.0,"why":"short reason"}. Confidence is between zero and one. The reason uses the user's language and at most six words.`
12
13/** A terse continuation usually carries no new scope; answers to questions are handled separately. */
14export function isShortFollowUp(text: string): boolean {
15 const trimmed = text.trim()
16 return trimmed.length > 0 && trimmed.length <= 12 && trimmed.split(/\s+/).length <= 2
17}
18
19export function looksLikeQuestion(assistantTail: string): boolean {
20 return assistantTail.slice(-400).includes('?')
21}
22
23/** The caller resolves slash commands so skills can be judged while host commands stay untouched. */
24export function shouldJudge(input: {
25 text: string; origin: string; auto: boolean; hostCommand?: boolean; modelId?: string
26 attachments?: readonly { type: string }[]; current?: Pick | null; assistantTail?: string
27}): boolean {
28 if (!input.auto || !['composer', 'bridge', 'sdk'].includes(input.origin) || (!input.text.trim() && !input.attachments?.length) || input.hostCommand) return false
29 if (input.modelId !== undefined && !isCacheSafeModel(input.modelId)) return false
30 const followup = input.current && input.current.by !== 'manual' && !input.text.trim().startsWith('/') &&
31 !input.attachments?.length && isShortFollowUp(input.text) && !looksLikeQuestion(input.assistantTail ?? '')
32 return !followup
33}
34
35function recent(input: JudgeInput): string {
36 const user = input.messages.findLast(message => message.role === 'user' && message.text.trim())
37 const assistant = input.messages.findLast(message => message.role === 'assistant' && message.text.trim())
38 return [user && `user: ${user.text.slice(0, 400)}`, assistant && `assistant: ${assistant.text.slice(-2000)}`].filter(Boolean).join('\n')
39}
40
41function nextText(input: JudgeInput, limit: number): string {
42 const counts: Record<string, number> = {}
43 for (const attachment of input.attachments ?? []) counts[attachment.type] = (counts[attachment.type] ?? 0) + 1
44 const attachments = Object.entries(counts).map(([type, count]) => `${type}: ${count}`).join(', ')
45 return [input.next.slice(0, limit), attachments && `Attachments (${attachments}).`,
46 input.midTurn && 'Steering a running turn: keep its effort for corrections and additions.'].filter(Boolean).join('\n')
47}
48
49/** Keep options in the assistant tail, since a one-letter answer may approve substantial work. */
50export function judgeContext(input: JudgeInput): string {
51 return [
52 `Current: ${input.current?.model ?? 'unknown'} / ${input.current?.effort ?? 'medium'}`,
53 recent(input) && `Recent conversation:\n${recent(input)}`,
54 input.contextPercent !== undefined && Number.isFinite(input.contextPercent) && `Context: ${Math.round(input.contextPercent)}%`,
55 `Next message:\n${nextText(input, 2000)}`,
56 ].filter(Boolean).join('\n\n')
57}
58
59function record(value: unknown): Record<string, unknown> | undefined {
60 return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
61}
62
63function confidence(value: unknown): number | undefined {
64 return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : undefined
65}
66
67function effort(value: unknown): Effort | undefined {
68 return EFFORTS.includes(value as Effort) ? value as Effort : undefined
69}
70
71/** JSON is a boundary: prose and fences are harmless, malformed objects are not verdicts. */
72export function parseEffortVerdict(text: string): Verdict | undefined {
73 const value = verdictObject(text)
74 const level = effort(value?.effort)
75 if (!level) return undefined
76 const sure = confidence(value?.sure)
77 return { effort: level, ...(sure !== undefined ? { sure } : {}), why: typeof value?.why === 'string' ? value.why.slice(0, 60) : '' }
78}
79
80/** Shared boundary decoder keeps effort and worker fields on the same object. */
81export function verdictObject(text: string): Record<string, unknown> | undefined {
82 for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
83 for (let end = text.lastIndexOf('}'); end > start; end = text.lastIndexOf('}', end - 1)) {
84 try {
85 const value: unknown = JSON.parse(text.slice(start, end + 1))
86 const parsed = record(value)
87 if (parsed) return parsed
88 } catch {
89 // Try a smaller span: a judge can put braces in its surrounding prose.
90 }
91 }
92 }
93 return looseVerdictObject(text)
94}
95
96/**
97 * A small judge sometimes breaks its JSON (Haiku answered `…"why":"…","}`, #53). Its complete
98 * `"key": value` pairs still carry the verdict, so they are read one by one; the parsers above
99 * check every field they use, so nothing here is trusted beyond a JSON scalar.
100 */
101function looseVerdictObject(text: string): Record<string, unknown> | undefined {
102 const start = text.indexOf('{')
103 if (start === -1) return undefined
104 const value: Record<string, unknown> = {}
105 const pair = /"(\w+)"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)/g
106 for (const match of text.slice(start).matchAll(pair)) {
107 try { value[match[1]!] ??= JSON.parse(match[2]!) } catch { /* a malformed scalar adds nothing */ }
108 }
109 return Object.keys(value).length ? value : undefined
110}
111
112const effortCriteria = {
113 low: 'Small direct work with little uncertainty or planning.',
114 medium: 'Ordinary bounded coding with a few inspections or edits.',
115 high: 'Substantial multi-step coding or debugging where mistakes matter.',
116 xhigh: 'Difficult extended investigation with interacting constraints.',
117 max: 'Exceptional open-ended research or the hardest reasoning problems.',
118}
119// Read with the brief, the old wording sent 49 of 64 delegated tasks to deep, against 19 from their
120// one-line descriptions alone: a brief's warnings ("do not restart", "read-only", "no guessing")
121// read as high stakes. Re-judged with this wording the same briefs give 28 (2026-10-15, pick log).
122const levelCriteria = {
123 light: 'Mechanical chores, repeated edits, simple lookups, or one small fix.',
124 standard: 'Carry out a designed change, even across several files; research, summarize, or review read-only.',
125 deep: 'Subtle debugging, security review, or architecture where a wrong call is costly. Size, file count, or warnings and prohibitions in the brief do not by themselves make work deep.',
126}
127const domainCriteria: Record<Domain, string> = {
128 design: 'Visual composition, branding, taste, look and voice of copy.',
129 frontend: 'Frontend UI implementation, components, styles and interactions.',
130 backend: 'Server application logic, APIs and authentication.', infra: 'Infrastructure, deployment, networks and operations.',
131 data: 'Data processing, schemas, queries and analysis.', test: 'Tests, quality assurance and verification.',
132 docs: 'Documentation, technical writing and summaries.', research: 'Investigation and research.', general: 'Other or cross-domain work.',
133}
134/** Plan decides what to build; do carries it out. The ladder sends plans at standard or deep to its Plan rung. */
135export const kindCriteria: Record<Kind, string> = {
136 plan: 'Decide what to build: architecture, design decisions, specifications, or a review whose verdict decides.',
137 do: 'Carry out what is decided: implement, fix, run, look up, rename, write, or check.',
138}
139export function kindOf(value: unknown): Kind | undefined {
140 return value === 'plan' || value === 'do' ? value : undefined
141}
142
143function jevBody(input: JudgeInput, task: string) {
144 return {
145 model: 'jev-latest' as const,
146 state: { task, current_model: input.current?.model ?? null, current_effort: input.current?.effort ?? null,
147 recent_conversation: recent(input), next_message: nextText(input, 4000) },
148 questions: { effort: { type: 'choice' as const, instructions: 'Select the effort needed for this work.', criteria: { ...effortCriteria } } },
149 }
150}
151
152export function jevEffortRequest(input: JudgeInput) {
153 return jevBody(input, EFFORT_JUDGE_SYSTEM)
154}
155
156/** Capability and kind of work are independent; the judge never chooses a provider. */
157export function jevWorkerRequest(input: { description: string; prompt: string }) {
158 const body = jevBody({ messages: [], next: `${input.description}\n${input.prompt.slice(0, 3000)}` }, 'Choose the domain, capability level, kind of work and reasoning effort for a delegated coding task. Judge scope and risk independently of the domain.')
159 return { ...body, questions: { ...body.questions,
160 domain: { type: 'choice' as const, instructions: 'Select the domain of this task.', criteria: { ...domainCriteria } },
161 level: { type: 'choice' as const, instructions: 'Select the capability level needed.', criteria: { ...levelCriteria } },
162 kind: { type: 'choice' as const, instructions: 'Select whether this task decides what to build or carries it out.', criteria: { ...kindCriteria } },
163 } }
164}
165
166function answers(json: unknown): Record<string, unknown> | undefined {
167 const root = record(json)
168 return record(root?.answers) ?? record(record(root?.result)?.answers)
169}
170
171function answerSure(answer: Record<string, unknown>): number | undefined {
172 return confidence(answer.confidence ?? record(answer.probabilities)?.[String(answer.choice)])
173}
174
175/** Torn between levels, Jev leaves a session at the effort it runs when that effort is still a plausible answer. */
176export function parseJevEffort(json: unknown, current?: { effort: Effort } | null, lang: Lang = 'en'): Verdict | undefined {
177 const answer = record(answers(json)?.effort)
178 const level = effort(answer?.choice)
179 if (!answer || !level) return undefined
180 const sure = answerSure(answer)
181 const odds = record(answer.probabilities)
182 const plausible = current && (!odds || (confidence(odds[current.effort]) ?? 0) >= 0.25)
183 if (sure !== undefined && sure < 0.5 && current && current.effort !== level && plausible) return { effort: current.effort, sure, why: t(lang, 'judge.keptUnsure') }
184 return { effort: level, ...(sure !== undefined ? { sure } : {}), why: '' }
185}
186
187export function parseJevWorker(json: unknown): WorkerVerdict | undefined {
188 const reply = answers(json)
189 const domainAnswer = record(reply?.domain)
190 const levelAnswer = record(reply?.level)
191 const effortAnswer = record(reply?.effort)
192 const selectedEffort = effort(effortAnswer?.choice)
193 if (!domainAnswer || !DOMAINS.includes(domainAnswer.choice as Domain) || !levelAnswer || !LEVELS.includes(levelAnswer.choice as Level) || !effortAnswer || !selectedEffort) return undefined
194 // Confidence moves a task up the ladder, so it is the level's question that counts. The lowest of
195 // all three counted before: torn only between frontend and backend, or high and xhigh, a task
196 // judged standard was sent a rung up (4 of 16 standard verdicts, pick log 2026-10-12..15).
197 const kindAnswer = record(reply?.kind)
198 const kind = kindOf(kindAnswer?.choice) ?? 'do'
199 const odds = { domain: answerSure(domainAnswer), level: answerSure(levelAnswer), kind: kindAnswer ? answerSure(kindAnswer) : undefined, effort: answerSure(effortAnswer) }
200 const known = Object.fromEntries(Object.entries(odds).filter(([, score]) => score !== undefined)) as WorkerVerdict['odds']
201 return { domain: domainAnswer.choice as Domain, level: levelAnswer.choice as Level, kind, effort: selectedEffort, ...(odds.level !== undefined ? { sure: odds.level } : {}), ...(Object.keys(known!).length ? { odds: known } : {}), why: '' }
202}
203
204export function jevUsage(json: unknown): number {
205 const usage = record(record(json)?.usage)
206 return [usage?.input_tokens, usage?.output_tokens].reduce<number>((sum, count) => sum + (typeof count === 'number' && Number.isFinite(count) && count >= 0 ? count : 0), 0)
207}
208
209export function jevFailure(status: number | 'timeout' | 'network', lang: Lang = 'en'): string {
210 if (status === 401 || status === 403) return t(lang, 'toasts.jevRejected')
211 if (status === 402) return t(lang, 'toasts.jevCredits')
212 if (status === 429) return t(lang, 'toasts.jevRateLimited')
213 if (status === 'timeout') return t(lang, 'toasts.jevTimeout')
214 if (status === 'network') return t(lang, 'toasts.jevNetwork')
215 return t(lang, 'toasts.jevHttp', { status })
216}
217
218export function parseJevKey(fileText: string): string | undefined {
219 const value = /^[ \t]*TYPESAFE_API_KEY[ \t]*=[ \t]*([^\r\n]*)/m.exec(fileText)?.[1]?.trim()
220 if (!value) return undefined
221 const unquoted = ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1).trim() : value
222 return unquoted || undefined
223}
224
225export function tipped(level: Effort, sure: number | undefined, bias: Settings['bias']): Effort {
226 if (!bias || sure === undefined || !Number.isFinite(sure) || sure < 0 || sure >= (Math.abs(bias) === 2 ? 0.85 : 0.65)) return level
227 return EFFORTS[Math.max(0, Math.min(4, EFFORTS.indexOf(level) + Math.sign(bias)))]!
228}
229
230export function bounded(level: Effort, floor: Effort, ceiling: Effort): Effort {
231 const min = EFFORTS.indexOf(floor)
232 const max = Math.max(min, EFFORTS.indexOf(ceiling))
233 return EFFORTS[Math.max(min, Math.min(max, EFFORTS.indexOf(level)))]!
234}
235
236export function capped(level: Effort, saving: boolean): Effort {
237 return saving && EFFORTS.indexOf(level) > 1 ? 'medium' : level
238}
239
240/** Save is last, so even a high floor cannot defeat the subscription-saving cap. */
241export function finalEffort(verdict: Verdict, settings: Settings, saving: boolean, lang: Lang = 'en'): { effort: Effort; why: string } {
242 const adjusted = bounded(tipped(verdict.effort, verdict.sure, settings.bias), settings.floor, settings.ceiling)
243 const result = capped(adjusted, saving)
244 return { effort: result, why: result !== adjusted ? t(lang, 'toasts.saveMode') : adjusted !== verdict.effort ? t(lang, 'toasts.yourSettings') : verdict.why }
245}
246
247export function isCacheSafeModel(modelId: string): boolean {
248 return /(?:opus|sonnet|haiku)-5-5(?:$|[^\d])/.test(modelId)
249}
250hooks/workers.ts 239 lines1import type { Billing, Domain, Effort, LadderChoice, LadderOptions, Level, ModelScore, RouterModel, Rule, Rung, Settings, TierTarget, WorkerCandidate, WorkerVerdict } from '../types'
2import { CLAUDE_WORKERS, DOMAINS, EFFORTS, LEVELS, candidateKey, isAllowed } from './settings'
3import { MARKER } from './worker'
4import { t } from './i18n'
5import type { Lang } from './i18n'
6import { kindOf, parseEffortVerdict, verdictObject } from './judge'
7import { claudeChoices, rungAbove, rungOf, tickedChoices } from './ladder'
8
9/** The one agent the main chat delegates to; the judge picks the worker (docs/specs/2026-10-12-worker-tiers.md). */
10export const AUTO_DESCRIPTION = 'Delegate any task here; ClaudeRipple picks the model. If a worker fails or returns nothing usable, delegate again here once and say the earlier attempt failed. ' +
11 'If you know how hard the task is, put [[ripple: light]], [[ripple: standard]], [[ripple: hard]] or [[ripple: max]] on the prompt\'s first line: ' +
12 'light = lookups, mechanical edits, one small fix; standard = carry out a designed change (even across several files), research, summarize, read-only review; ' +
13 'hard = subtle debugging, security review, or work where a wrong call is costly; max = planning, design or architecture where the deliverable is the decision itself. ' +
14 'Size, file count or the brief\'s warnings do not by themselves make a task hard. Leave the marker out when unsure, and ClaudeRipple judges it.'
15
16/**
17 * The tier markers the main chat may write on a ripple:auto prompt, as the judge's verdict they
18 * stand for (`rungOf` gives light, standard, hard, plan). The main chat knows the whole
19 * conversation, the judge one brief: re-judging 62 delegations (2026-10-15), the judge sent 32 to
20 * hard, Opus under this description 18. Not model names, so never routed as one.
21 */
22export const TIER_MARKERS: Readonly<Record<string, Pick<WorkerVerdict, 'level' | 'kind'>>> = {
23 light: { level: 'light', kind: 'do' },
24 standard: { level: 'standard', kind: 'do' },
25 hard: { level: 'deep', kind: 'do' },
26 max: { level: 'deep', kind: 'plan' },
27}
28/** The tier a marker names, if it is one. */
29export function tierOf(marker: { name: string } | null): string | null {
30 return marker && Object.hasOwn(TIER_MARKERS, marker.name) ? marker.name : null
31}
32
33export const WORKER_JUDGE_SYSTEM = `Choose a domain, capability level, kind of work and the cheapest effort that still does this delegated task well. Evaluate actual scope and risk, not prompt length.
34Domains: design (visual taste, composition, brand), frontend (UI implementation), backend (APIs and server logic), infra (deployment and operations), data (queries and processing), test (verification), docs (writing), research (investigation), general (other or cross-domain).
35Levels are independent of domain: light (chores, repeated edits, simple lookups, one small fix), standard (carry out a designed change, even across several files; research, summarize, read-only review), deep (subtle debugging, security review, or architecture where a wrong call is costly; size, file count, or warnings and prohibitions in the brief do not by themselves make work deep). A task that says an earlier attempt failed is one level higher than it would otherwise be.
36Kind is determined by the deliverable, not preparatory steps. plan = pure planning: the deliverable is a decision, design, specification, or review verdict, NOT implemented code. do = carry out work: implement, fix, run, look up, write. Any task whose deliverable includes implemented code, files, builds or fixes is do, even if it says "read the spec/design first" or requires architecture/design reasoning before implementation. Writing a design/spec document alone remains plan; producing implementation files is do. Examples: "implement host listing of sessions" => do; "build an iOS app" => do; "read the spec first, then implement it" => do; "decide the session-listing architecture; do not implement" => plan. If planning and implementation are mixed, choose do. Deep plan is the hardest work, which runs on the strongest model, reserved for pure architecture, system design or consequential design decisions.
37Effort choices are low, medium, high, xhigh, max; reserve the last two for exceptionally hard work. Return only JSON {"domain":"design|frontend|backend|infra|data|test|docs|research|general","level":"light|standard|deep","kind":"plan|do","effort":"low|medium|high|xhigh|max","sure":0.0,"why":"brief reason"}. Confidence is zero to one and is about the level; explain in the user's language in at most six words.`
38
39export function workerJudgeInput(input: { description: string; prompt: string }): string {
40 return `Task: ${input.description}\n\nInstructions:\n${input.prompt.slice(0, 3000)}`
41}
42/**
43 * A kind the judge leaves out or garbles reads as do: the verdict's level still stands, and losing
44 * it over the kind would send the task to the default instead.
45 */
46export function parseWorkerVerdict(text: string): WorkerVerdict | undefined {
47 const verdict = parseEffortVerdict(text)
48 const raw = verdictObject(text)
49 if (!verdict || !DOMAINS.includes(raw?.domain as Domain) || !LEVELS.includes(raw?.level as Level)) return undefined
50 return { ...verdict, domain: raw!.domain as Domain, level: raw!.level as Level, kind: kindOf(raw!.kind) ?? 'do' }
51}
52export function readMarker(prompt: string): { name: string; level?: string } | null {
53 const marker = MARKER.exec(prompt)
54 return marker ? { name: marker[1]!.toLowerCase(), ...(marker[2] ? { level: marker[2].toLowerCase() } : {}) } : null
55}
56export function agentProvider(agentFileText: string): string | undefined {
57 const head = /^---\r?\n([\s\S]*?)\r?\n---/.exec(agentFileText)?.[1]
58 const description = head && /^description:\s*(.*)$/m.exec(head)?.[1]
59 return description ? /\bvia\s+([A-Za-z0-9_-]+)\./.exec(description)?.[1]?.toLowerCase() : undefined
60}
61function record(value: unknown): Record<string, unknown> {
62 return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
63}
64function list(value: unknown): unknown[] { return Array.isArray(value) ? value : [] }
65const baseModel = (model: string): string => model.replace(/@[^@]*$/, '')
66
67/** Unknown availability is not exhaustion; known accounts must pass every quota window. */
68export function chatgptUsable(status: unknown): boolean {
69 const accounts = record(record(record(status).chatgpt).accounts).chatgpt
70 if (!Array.isArray(accounts)) return true
71 return accounts.some(value => {
72 const account = record(value)
73 if (account.state !== 'ready' || account.paused || account.needsReauth) return false
74 const limits = record(record(account.quota).rate_limits)
75 return ['primary', 'secondary'].every(name => {
76 const window = record(limits[name])
77 return typeof window.used_percent !== 'number' || window.used_percent < 100
78 })
79 })
80}
81/** Config is the router's catalogue, including native Anthropic declarations. Never retain headers. */
82export function routerModelOptions(config: unknown): RouterModel[] {
83 const out: RouterModel[] = []
84 for (const [provider, value] of Object.entries(record(record(config).providers))) {
85 for (const model of list(record(value).models)) {
86 const { id, name } = record(model)
87 if (typeof id === 'string' && id) out.push({ value: id, label: `${id} · ${provider}`, provider, ...(typeof name === 'string' && name.trim() ? { name } : {}) })
88 }
89 }
90 return out
91}
92export function zenJev(config: unknown): { authorization?: string; model: string } {
93 const provider = record(record(record(config).providers)['opencode-zen'])
94 const key = record(provider.headers).authorization
95 const model = list(provider.models).map(m => record(m).id).find(id => typeof id === 'string' && id.toLowerCase().includes('jev'))
96 return { ...(typeof key === 'string' && key.trim() ? { authorization: key } : {}), model: typeof model === 'string' ? model : 'jev-latest' }
97}
98/** Validate HTTP score data at the boundary; malformed entries are not selectable measurements. */
99export function scoreModels(raw: unknown): ModelScore[] {
100 return list(record(raw).models).flatMap(value => {
101 const model = record(value)
102 if (typeof model.model !== 'string' || typeof model.provider !== 'string') return []
103 const variants = list(model.variants).flatMap(v => {
104 const variant = record(v)
105 if (variant.effort !== null && !EFFORTS.includes(variant.effort as Effort)) return []
106 const valid = (n: unknown): n is number | null => n === null || typeof n === 'number' && Number.isFinite(n) && n >= 0
107 if (!valid(variant.intelligence) || !valid(variant.costPerTask)) return []
108 return [{ effort: variant.effort as Effort | null, intelligence: variant.intelligence, costPerTask: variant.costPerTask }]
109 })
110 return [{ model: model.model, provider: model.provider, variants }]
111 })
112}
113/**
114 * Allow-lists name worker agents, and Claude's own models as `general-purpose:<model>`; provider
115 * names from settings saved before 2026-10-10 still match.
116 */
117export function allowed(candidate: WorkerCandidate, allow: readonly string[], known: readonly string[] = []): boolean {
118 return isAllowed(candidateKey(candidate.target), candidate.provider, allow, known)
119}
120/** Claude's own models as candidates: general-purpose on each. */
121export function claudeCandidates(): WorkerCandidate[] {
122 return CLAUDE_WORKERS.map(w => ({ target: { ...w.target }, model: w.model, provider: w.provider }))
123}
124const BILLINGS: readonly Billing[] = ['free', 'payg', 'subscription']
125/**
126 * The router's rung choices (GET /api/mod-options), validated at the boundary: its workers whose
127 * provider can run now, with ladders, billing and cheapest measured cost, then Claude's own
128 * models; only the ticked ones (`allow`, empty for every model).
129 */
130export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly string[] = [], allowKnown: readonly string[] = []): LadderOptions {
131 const root = record(raw)
132 const efforts = (value: unknown): Effort[] => list(value).filter((e): e is Effort => EFFORTS.includes(e as Effort))
133 const usable = new Set(list(root.providers).flatMap(p => { const v = record(p); return typeof v.name === 'string' && v.usable !== false ? [v.name] : [] }))
134 const catalogue = list(root.providers).length > 0
135 const scores = scoreModels(root.scores)
136 const choices: LadderChoice[] = list(root.agents).flatMap(value => {
137 const a = record(value)
138 if (typeof a.name !== 'string' || !a.name || typeof a.model !== 'string' || typeof a.provider !== 'string') return []
139 if (catalogue && !usable.has(a.provider)) return []
140 const costs = (scores.find(s => s.model === a.model && s.provider === a.provider)?.variants ?? []).flatMap(v => v.costPerTask === null ? [] : [v.costPerTask])
141 const billing = BILLINGS.includes(a.billing as Billing) ? a.billing as Billing : 'payg'
142 return [{ target: { agent: a.name }, model: a.model, provider: a.provider, billing, efforts: efforts(a.efforts), ...(costs.length ? { cost: Math.min(...costs) } : {}) }]
143 })
144 const providers = Object.fromEntries(list(root.providers).flatMap(value => {
145 const p = record(value)
146 if (typeof p.name !== 'string' || typeof p.usable !== 'boolean') return []
147 return [[p.name, { usable: p.usable, ...(typeof p.reason === 'string' ? { reason: p.reason } : {}), ...(typeof p.exhaustedUntil === 'number' ? { exhaustedUntil: p.exhaustedUntil } : {}) }]]
148 }))
149 return { until, choices: tickedChoices([...choices, ...claudeChoices(efforts(root.claudeEfforts))], allow, allowKnown), providers }
150}
151export function candidateUsable(candidate: WorkerCandidate, settings: Settings, chatgpt: boolean, providers: NonNullable<LadderOptions['providers']> = {}): boolean {
152 const live = providers[candidate.provider]
153 return settings.providers[candidate.provider] !== false && (live ? live.usable : candidate.provider !== 'chatgpt' || chatgpt)
154}
155function unavailableReason(provider: string, providers: NonNullable<LadderOptions['providers']>, lang: Lang): string {
156 const reason = providers[provider]?.reason
157 if (!reason && provider === 'chatgpt') return t(lang, 'toasts.providerQuota')
158 const labels: Record<string, string> = lang === 'ko'
159 ? { insufficient_balance: '잔액 부족', billing_exhausted: '결제 한도 소진', quota_exhausted: '사용량 한도 소진' }
160 : { insufficient_balance: 'insufficient balance', billing_exhausted: 'billing exhausted', quota_exhausted: 'quota exhausted' }
161 return `${provider}: ${reason && labels[reason] || (lang === 'ko' ? '현재 사용 불가' : 'currently unavailable')}`
162}
163function sameTarget(a: TierTarget, b: TierTarget): boolean { return a.agent === b.agent && a.model === b.model }
164function candidateOf(target: TierTarget, candidates: readonly WorkerCandidate[]): WorkerCandidate | undefined {
165 return candidates.find(c => sameTarget(c.target, target)) ?? (target.agent === 'general-purpose' ? { target, model: target.model ?? 'sonnet', provider: candidates.find(c => c.model === baseModel(target.model ?? 'sonnet'))?.provider ?? 'anthropic' } : undefined)
166}
167export type Resolution = { target?: TierTarget; effort: Effort; rung?: Rung; position?: number; skipped?: { rung: Rung; position: number; model: string; reason: string }[]; fallbackReason?: string }
168
169/** Claude Code's own agent with no model: the subagent runs on the session's model. */
170export const SESSION_MODEL: TierTarget = { agent: 'general-purpose' }
171
172/**
173 * The ladder: the verdict's rung, else one rung up, else the session model, never down. Whatever
174 * ran instead says why (the fallback toast).
175 */
176export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Settings, candidates: readonly WorkerCandidate[], chatgpt: boolean, lang: Lang, allow: readonly string[] = [], providers: NonNullable<LadderOptions['providers']> = {}, known: readonly string[] = []): Resolution {
177 const wanted = rungOf(verdict)
178 const skipped: NonNullable<Resolution['skipped']> = []
179 const reasons: string[] = []
180 for (const rung of [wanted, rungAbove(wanted)]) {
181 if (!rung) continue
182 const steps = rule.rungs[rung] ?? []
183 if (!steps.length) reasons.push(t(lang, 'workers.rungEmpty', { rung: t(lang, 'workers.rung.' + rung) }))
184 for (const [index, step] of steps.entries()) {
185 const target = step.target
186 const candidate = candidateOf(target, candidates)
187 const ticked = target.agent === 'general-purpose' && !target.model || candidate && allowed(candidate, allow, known)
188 if (candidate && candidateUsable(candidate, settings, chatgpt, providers) && ticked) {
189 return { target: { ...target }, effort: step.effort, rung, position: index + 1, skipped, ...(reasons.length ? { fallbackReason: reasons.join('; ') } : {}) }
190 }
191 const reason = !candidate ? t(lang, 'toasts.workerMissing', { name: target.agent }) : settings.providers[candidate.provider] === false ? t(lang, 'workers.disabled', { provider: candidate.provider }) : !ticked ? t(lang, 'workers.notCandidate', { name: candidate.model }) : providers[candidate.provider]?.usable === false ? unavailableReason(candidate.provider, providers, lang) : t(lang, 'toasts.providerQuota')
192 const model = candidate?.model ?? target.model ?? target.agent
193 skipped.push({ rung, position: index + 1, model, reason })
194 reasons.push(`${model}: ${reason}`)
195 }
196 }
197 const effort = rule.rungs[wanted]?.[0]?.effort ?? verdict.effort
198 // The session's model is Claude's: with Anthropic switched off nothing is left to run it.
199 if (settings.providers.anthropic === false) return { effort, rung: wanted, skipped, fallbackReason: t(lang, 'workers.none') }
200 return { target: { ...SESSION_MODEL }, effort, rung: wanted, skipped, fallbackReason: reasons.join('; ') || t(lang, 'workers.none') }
201}
202
203export function spawnModel(target: TierTarget, effort: Effort, fileModel?: string): string | undefined {
204 return fileModel && target.agent !== 'general-purpose' ? `${baseModel(fileModel)}@${effort}` : target.model
205}
206/** A prefix this mod (or the retired agent-title hook) put on a title: "Sol·high · ". */
207const TITLE_PREFIX = /^[^\s·]+(?:·[a-z]+)? · /
208
209/**
210 * The model a title names: a Claude id or alias as its family and version ("Opus5.5"), a routed id
211 * as its configured name's last word ("GPT-6.1 Sol" → "Sol"), else a gpt-* id's last part, else
212 * the id.
213 */
214export function titleName(model: string, models: readonly RouterModel[] = []): string {
215 const id = baseModel(model).replace(/\[.*?\]/g, '')
216 const claude = /^(?:claude-)?(opus|sonnet|haiku|fable|mythos)(?:-(\d+)(?:-(\d{1,2}))?)?/i.exec(id)
217 if (claude) return claude[1]!.charAt(0).toUpperCase() + claude[1]!.slice(1).toLowerCase() + (claude[2] ? claude[2] + (claude[3] ? '.' + claude[3] : '') : '')
218 const named = models.find(m => m.value.toLowerCase() === id.toLowerCase())?.name
219 if (named) return named.trim().split(/\s+/).pop()!
220 const gpt = /^gpt-[\d.]+-([a-z]+)$/i.exec(id)
221 return gpt ? gpt[1]!.charAt(0).toUpperCase() + gpt[1]!.slice(1) : id
222}
223
224/**
225 * The background-task title with the model that really runs it in front: "Sol·medium · Fix X".
226 * The app names only Claude models, and for a routed one shows the parent's; the mod is the one
227 * that knows, since it picks. A prefix already there (the parent's guess) is replaced.
228 */
229export function spawnTitle(description: string, model: string, effort: string | undefined, models: readonly RouterModel[] = []): string {
230 const tag = titleName(model, models) + (effort ? '·' + effort : '')
231 const bare = description.replace(TITLE_PREFIX, '')
232 return `${tag} · ${bare.trim() ? bare : description}`
233}
234
235export function takesOver(spawn: { subagentType: string; model?: string; fork: boolean }, settings: Settings): boolean {
236 if (spawn.fork || settings.agentAuto === 'off') return false
237 return ['ripple:auto', 'auto'].includes(spawn.subagentType) || (settings.agentAuto === 'all' && spawn.subagentType === 'general-purpose' && spawn.model === undefined)
238}
239hooks/worker.ts 50 lines1// How a spawn of one of ClaudeRipple's workers is set right before it starts. Plain functions, no
2// engine: the hook in register.tsx feeds them and applies the answer, and the tests import them.
3//
4// A worker is an agent file the router generated, whose `model:` is a routed id with an optional
5// `@effort` (packages/router/src/agents.ts). Two things went wrong at spawn time, both silently:
6// - A `model` argument on the Agent call replaced that id with a Claude alias, so the worker ran on
7// Claude and its output was cut to 64 tokens (2026-09).
8// - The `[[ripple: name@level]]` marker that sets a worker's effort counts only as the prompt's
9// first line (packages/router/src/routing.ts); anywhere else it is prose and the level is lost.
10// Measured on CLI 2.1.286 (2026-10-05): an `agent.spawn` hook that sets `model` to
11// `deepseek-v4.1-flash@low` runs the worker on that id, and the router records effort low.
12
13/** The router's marker, found anywhere in the prompt rather than only at its top. */
14export const MARKER = /\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/
15
16export type Spawn = { subagentType: string; prompt: string; model?: string }
17export type WorkerFix = { prompt: string; model: string; dropped?: string }
18
19/** The `model:` line of an agent file's frontmatter. */
20export function agentModel(file: string): string | undefined {
21 const head = /^---\n([\s\S]*?)\n---/.exec(file)?.[1]
22 return head ? /^model:\s*(\S+)\s*$/m.exec(head)?.[1] : undefined
23}
24
25/**
26 * What the spawn should run with, or null to leave it alone. `fileModel` is the worker's agent
27 * file `model:` (an id, maybe with `@effort`).
28 */
29export function fixSpawn(spawn: Spawn, fileModel: string): WorkerFix | null {
30 const base = fileModel.replace(/@[^@]*$/, '')
31 const marker = MARKER.exec(spawn.prompt)
32 const dropped = spawn.model !== undefined && spawn.model !== fileModel && spawn.model.replace(/@[^@]*$/, '') !== base ? spawn.model : undefined
33 if (marker) {
34 const name = marker[1]!.toLowerCase()
35 const level = marker[2]?.toLowerCase()
36 const rest = (spawn.prompt.slice(0, marker.index) + spawn.prompt.slice(marker.index + marker[0].length)).replace(/^\s*\n/, '')
37 // The marker names this worker: its level goes on the model id, where the router reads it as
38 // the agent file's own suffix, and the worker no longer reads a routing note.
39 if (name === spawn.subagentType.toLowerCase() || name === base.toLowerCase()) {
40 return { prompt: rest, model: level ? `${base}@${level}` : fileModel, ...(dropped ? { dropped } : {}) }
41 }
42 // It names another model or alias, which only the router can resolve: put it where the router
43 // looks. Already there, there is nothing to move.
44 const atTop = MARKER.exec(spawn.prompt.replace(/^\s+/, ''))?.index === 0
45 if (atTop && !dropped) return null
46 return { prompt: atTop ? spawn.prompt : `${marker[0]}\n${rest}`, model: fileModel, ...(dropped ? { dropped } : {}) }
47 }
48 return dropped ? { prompt: spawn.prompt, model: fileModel, dropped } : null
49}
50hooks/modes.ts 149 lines1// Operating modes in the session (docs/specs/2026-10-12-worker-tiers.md "Operating modes"): which
2// mode is on, when it ends by itself, and the eager-delegation note. Pure; register.tsx wires them.
3
4import type { Hot, LimitItem, Mode, Settings } from '../types'
5
6/** The mode that is on, if the settings name one that exists. */
7export function activeModeOf(s: Pick<Settings, 'modes' | 'activeMode'>): Mode | undefined {
8 return s.activeMode ? s.modes.find(m => m.id === s.activeMode) : undefined
9}
10
11/** One turning-on of a mode: the same mode turned off and on again is a new one. */
12export function modeKey(s: Pick<Settings, 'activeMode' | 'activeSince'>): string | null {
13 return s.activeMode ? `${s.activeMode}@${s.activeSince ?? 0}` : null
14}
15
16/** What the main chat reads beside the prompt while an eager mode is on. */
17export function modeNote(name: string): string {
18 return `ClaudeRipple mode ${name}: delegate everything except judgment and final review to ripple:auto.`
19}
20
21/**
22 * The eager note for this prompt: sent on the first prompt after the mode turned on, and again on
23 * the first after a compaction (which clears `noted`), never on the turns between.
24 */
25export function eagerNote(s: Pick<Settings, 'modes' | 'activeMode' | 'activeSince'>, noted: string | null): { note?: string; noted: string | null } {
26 const mode = activeModeOf(s)
27 const key = modeKey(s)
28 if (!mode || mode.delegation !== 'eager') return { noted }
29 return noted === key ? { noted } : { note: modeNote(mode.name), noted: key }
30}
31
32/** Modes replace the base worker set, without changing fallback, allow-list or provider policy. */
33export function routingSettings(s: Settings): Settings {
34 const mode = activeModeOf(s)
35 return mode ? { ...s, rules: { general: { mode: 'ladder', rungs: mode.rungs } } } : s
36}
37
38/** The provider whose quota reset ends the mode: 'anthropic' is Claude, any other a ChatGPT provider. */
39export function resetProvider(mode: Pick<Mode, 'until'>): string | undefined {
40 return typeof mode.until === 'object' ? mode.until.reset : undefined
41}
42
43/** Same binding limit as the band: highest percent used, input order breaking ties. */
44export function claudeWindow(limit: Hot | null): QuotaWindow[] {
45 const at = limit?.resetsAt ? Date.parse(limit.resetsAt) : NaN
46 return Number.isFinite(at) ? [{ at }] : []
47}
48
49/** One account's quota window: when it resets next, and how long it is when known. */
50export type QuotaWindow = { at: number; windowMs?: number }
51
52function record(value: unknown): Record<string, unknown> {
53 return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
54}
55
56/**
57 * Each signed-in, unpaused ChatGPT account's known windows, from GET
58 * /api/chatgpt-accounts: `quota.rate_limits.{primary,secondary}` with `reset_at` (epoch seconds,
59 * or ms) or `reset_after_seconds` from the snapshot's `at`, and `window_minutes`
60 * (packages/router/src/providers/chatgpt/index.ts rateLimitsFromHeaders/rateLimitsFromUsage).
61 * An account whose reset is unknown is left out.
62 */
63export function quotaWindows(body: unknown): QuotaWindow[] {
64 const accounts = record(body).accounts
65 if (!Array.isArray(accounts)) return []
66 return accounts.flatMap(value => {
67 const account = record(value)
68 if (account.state === 'paused' || account.state === 'needs-login') return []
69 const quota = record(account.quota)
70 const limits = record(quota.rate_limits)
71 return ['primary', 'secondary'].flatMap(key => {
72 const window = record(limits[key])
73 const resetAt = typeof window.reset_at === 'number' ? (window.reset_at > 1e12 ? window.reset_at : window.reset_at * 1000)
74 : typeof window.reset_after_seconds === 'number' && typeof quota.at === 'number' ? quota.at + window.reset_after_seconds * 1000 : undefined
75 if (resetAt === undefined || !Number.isFinite(resetAt)) return []
76 const minutes = window.window_minutes
77 return [{ at: resetAt, ...(typeof minutes === 'number' && minutes > 0 ? { windowMs: minutes * 60_000 } : {}) }]
78 })
79 })
80}
81
82/**
83 * End at the first known reset after activation of the mode's chosen provider (`windows` are only
84 * that provider's), not after every account resets. ChatGPT's fixed window length also identifies a
85 * reset when a fresh snapshot has rolled forward. Claude windows are not extrapolated: the
86 * five-hour window can start lazily after idle.
87 */
88export function modeOver(mode: Pick<Mode, 'until'>, since: number | undefined, now: number, windows: readonly QuotaWindow[] = []): boolean {
89 if (typeof mode.until === 'number') return now >= mode.until
90 if (!resetProvider(mode) || since === undefined || !windows.length) return false
91 const passed = (at: number) => at > since && at <= now
92 return windows.some(w => passed(w.at) || (w.windowMs !== undefined && passed(w.at - w.windowMs)))
93}
94
95/** The configured providers of ChatGPT type, from GET /api/status `providers` (name → {type}). */
96export function chatgptProviders(status: unknown): string[] {
97 const providers = record(status).providers
98 if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return []
99 return Object.entries(providers as Record<string, unknown>)
100 .filter(([, value]) => record(value).type === 'chatgpt')
101 .map(([name]) => name)
102}
103
104/**
105 * Every signed-in ChatGPT account's known windows as the limits row reads them, from GET
106 * /api/chatgpt-accounts (`quota.rate_limits.{primary,secondary}` with `reset_at` in epoch
107 * seconds or ms, or `reset_after_seconds` from the snapshot's `at`, and `window_minutes`;
108 * the same shape `quotaWindows` reads). Paused and needs-login accounts are left out (and take
109 * no number), and so is an account with no snapshot. `account` numbers the accounts kept, in
110 * answer order. A snapshot is not aged out: the router takes it from the account's own replies,
111 * so an account idle for an hour holds an hour-old figure that is still right (measured
112 * 2026-10-15: 30 minutes old between uses). Only a window whose reset has passed is dropped,
113 * since its figure no longer stands.
114 */
115export function gptLimitsFrom(body: unknown, provider: string, now: number): LimitItem[] {
116 const accounts = record(body).accounts
117 if (!Array.isArray(accounts)) return []
118 const kept = accounts.map(record).filter(account => account.state !== 'paused' && account.state !== 'needs-login' && account.paused !== true)
119 const out: LimitItem[] = []
120 kept.forEach((account, index) => {
121 const quota = record(account.quota)
122 const at = quota.at
123 if (typeof at !== 'number') return
124 const limits = record(quota.rate_limits)
125 for (const key of ['primary', 'secondary']) {
126 const window = record(limits[key])
127 const used = window.used_percent
128 if (typeof used !== 'number' || !Number.isFinite(used)) continue
129 const minutes = window.window_minutes
130 const resetAt = typeof window.reset_at === 'number' ? (window.reset_at > 1e12 ? window.reset_at : window.reset_at * 1000)
131 : typeof window.reset_after_seconds === 'number' && typeof at === 'number' ? at + window.reset_after_seconds * 1000 : NaN
132 if (Number.isFinite(resetAt) && resetAt <= now) continue
133 const kind = minutes === 300 ? 'five_hour' : minutes === 10080 ? 'seven_day' : 'window'
134 out.push({
135 provider,
136 type: 'chatgpt',
137 title: 'GPT',
138 account: index,
139 kind,
140 label: '',
141 ...(typeof minutes === 'number' && minutes > 0 ? { windowMinutes: minutes } : {}),
142 percent: Math.round(used * 10) / 10,
143 ...(Number.isFinite(resetAt) ? { resetsAt: new Date(resetAt).toISOString() } : {}),
144 })
145 }
146 })
147 return out
148}
149hooks/cache.ts 61 lines1import type { ModelForkResult } from 'claude-code'
2import type { CacheMemo, Settings, Usage, WarmState } from '../types'
3import { weighted } from './stats'
4import { t } from './i18n.ts'
5import type { Lang } from './i18n.ts'
6
7const HOUR = 3600000
8const FIVE_MINUTES = 300000
9
10export function ttlFromUsage(usage: Usage): number {
11 if ((usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0) return HOUR
12 if ((usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0) return FIVE_MINUTES
13 return HOUR
14}
15
16/** A five-minute tail must not shorten a mostly-read hour-long prefix. */
17export function nextMemo(prev: CacheMemo | null, usage: Usage, now: number): CacheMemo {
18 const total = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)
19 const mostlyRead = (usage.cache_read_input_tokens ?? 0) > total / 2
20 const explicit = (usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0 || (usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0
21 let ttlMs = explicit ? ttlFromUsage(usage) : prev?.ttlMs ?? HOUR
22 if (prev?.ttlMs === HOUR && mostlyRead) ttlMs = HOUR
23 if (!explicit && prev && now - prev.lastReplyAt > FIVE_MINUTES && mostlyRead) ttlMs = HOUR
24 return { ttlMs, lastReplyAt: now, expiresAt: now + ttlMs }
25}
26
27export function secondsLeft(memo: CacheMemo | null, now: number): number | null {
28 return memo ? Math.max(0, Math.ceil((memo.expiresAt - now) / 1000)) : null
29}
30
31/** Never pay to revive an expired cache or extend the last-real-reply bound with our own pings. */
32export function shouldPing(input: {
33 settings: Settings; memo: CacheMemo | null; warm: WarmState; now: number; idle: boolean
34 contextTokens: number; lastRealReplyAt: number | null
35}): { ping: true } | { ping: false; reason?: string } {
36 const { settings, memo, warm, now } = input
37 if (!settings.keepWarm) return { ping: false, reason: 'keep warm is off' }
38 if (warm.stopped) return { ping: false, reason: warm.stopped }
39 if (!input.idle) return { ping: false, reason: 'chat is busy' }
40 if (!memo || memo.expiresAt <= now) return { ping: false, reason: 'cache is not warm' }
41 if (input.lastRealReplyAt === null || now >= input.lastRealReplyAt + settings.keepWarmHours * HOUR) return { ping: false, reason: 'keep warm time bound reached' }
42 if (input.contextTokens < settings.keepWarmMinTokens) return { ping: false, reason: 'chat is too small' }
43 const lead = memo.ttlMs === FIVE_MINUTES ? 60000 : 240000
44 return memo.expiresAt - now <= lead ? { ping: true } : { ping: false }
45}
46
47/** Failed forks stop once; answered cache misses still count the real tokens they spent. */
48export function afterPing(warm: WarmState, forkResult: ModelForkResult | null, now: number, lang: Lang = 'en'): WarmState {
49 if (!forkResult?.isAnswered) return { ...warm, stopped: t(lang, 'toasts.warmNoReply') }
50 const next: WarmState = { pings: warm.pings + 1, cost: warm.cost + weighted(forkResult.usage), lastPingAt: now }
51 if (forkResult.usage.cache_read_input_tokens <= 0) next.stopped = t(lang, 'toasts.warmNoCache')
52 return next
53}
54
55export function restoreFromResume(secondsSinceLastResponse: number | undefined, likelyExpired: boolean | undefined, ttlMs: number, now: number): CacheMemo | null {
56 if (secondsSinceLastResponse === undefined || !Number.isFinite(secondsSinceLastResponse) || secondsSinceLastResponse < 0) return null
57 const lastReplyAt = now - secondsSinceLastResponse * 1000
58 const expiresAt = lastReplyAt + ttlMs
59 return { ttlMs, lastReplyAt, expiresAt: likelyExpired || expiresAt <= now ? now - 1 : expiresAt }
60}
61hooks/session.ts 194 lines1import type { SessionContextUsage, SessionRateLimit } from 'claude-code'
2import type { CacheMemo, Hot, LimitItem } from '../types'
3
4export function hottest(rateLimits: readonly SessionRateLimit[]): Hot | null {
5 const eligible = rateLimits.filter(limit => ['five_hour', 'seven_day'].includes(limit.kind) && limit.percentUsed >= 80)
6 const limit = eligible.reduce<SessionRateLimit | undefined>((hot, next) => !hot || next.percentUsed > hot.percentUsed ? next : hot, undefined)
7 return limit ? { kind: limit.kind, percent: limit.percentUsed, ...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {}) } : null
8}
9
10const WINDOW_KIND: Record<number, string> = { 300: 'five_hour', 10080: 'seven_day' }
11/** A router snapshot older than this is not shown as the plan's figure. */
12export const CLAUDE_USAGE_STALE_MS = 15 * 60_000
13
14/**
15 * The plan windows of the Claude login this session's requests pass through, from the router's
16 * GET /api/status (`claude.accounts[id=current].quota`, packages/router/src/admin.ts
17 * claudeAccountUsage): for when the host leaves `rateLimits` empty though the requests carry them
18 * (measured 2026-10-14: the router passes the `anthropic-ratelimit-unified-*` headers through).
19 * Percent as the router reports it; a stale or unknown snapshot gives nothing.
20 */
21export function claudeLimitsFrom(status: unknown, now: number): SessionRateLimit[] {
22 const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
23 const accounts = object(object(status).claude).accounts
24 const current = Array.isArray(accounts) ? accounts.map(object).find(a => a.id === 'current') : undefined
25 const quota = object(current?.quota)
26 if (typeof quota.at !== 'number' || now - quota.at > CLAUDE_USAGE_STALE_MS) return []
27 const limits = object(quota.rate_limits)
28 return [limits.primary, limits.secondary].map(object).flatMap(w => {
29 const kind = typeof w.window_minutes === 'number' ? WINDOW_KIND[w.window_minutes] : undefined
30 if (!kind || typeof w.used_percent !== 'number' || !Number.isFinite(w.used_percent)) return []
31 const reset = typeof w.reset_at === 'number' ? w.reset_at * (w.reset_at < 1e12 ? 1000 : 1) : NaN
32 return [{ kind, percentUsed: Math.round(w.used_percent * 10) / 10, ...(Number.isFinite(reset) ? { resetsAt: new Date(reset).toISOString() } : {}) }]
33 })
34}
35
36/** One per-model weekly window the router reports beside `rate_limits` (see `scopedFromLimits`). */
37export type ScopedLimit = { name: string; percent: number; resetsAt?: string }
38
39/**
40 * The per-model weekly windows of the Claude login, from the router's GET /api/status
41 * (`claude.accounts[id=current].quota.scoped`: `{name, used_percent, window_minutes, reset_at}`,
42 * reset in epoch seconds or ms). A stale or unknown snapshot gives nothing, like claudeLimitsFrom.
43 */
44export function claudeScopedFrom(status: unknown, now: number): ScopedLimit[] {
45 const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
46 const accounts = object(object(status).claude).accounts
47 const current = Array.isArray(accounts) ? accounts.map(object).find(a => a.id === 'current') : undefined
48 const quota = object(current?.quota)
49 if (typeof quota.at !== 'number' || now - quota.at > CLAUDE_USAGE_STALE_MS) return []
50 const scoped = quota.scoped
51 if (!Array.isArray(scoped)) return []
52 return scoped.map(object).flatMap(entry => {
53 if (typeof entry.name !== 'string' || !entry.name || typeof entry.used_percent !== 'number' || !Number.isFinite(entry.used_percent)) return []
54 const reset = typeof entry.reset_at === 'number' ? entry.reset_at * (entry.reset_at < 1e12 ? 1000 : 1) : NaN
55 return [{ name: entry.name, percent: Math.round(entry.used_percent * 10) / 10, ...(Number.isFinite(reset) ? { resetsAt: new Date(reset).toISOString() } : {}) }]
56 })
57}
58
59const KINDS = ['five_hour', 'seven_day', 'scoped', 'window'] as const
60type LimitKind = (typeof KINDS)[number]
61
62/** One window of `GET /api/limits` as a limits-row item, or null when it carries nothing to show. */
63function limitItemFromWindow(w: unknown, provider: string, type: string | undefined, title: string | undefined, account: number, now: number): LimitItem | null {
64 const o = w && typeof w === 'object' && !Array.isArray(w) ? w as Record<string, unknown> : {}
65 const kind = o.kind
66 if (typeof kind !== 'string' || !(KINDS as readonly string[]).includes(kind)) return null
67 const used = o.used_percent
68 if (typeof used !== 'number' || !Number.isFinite(used)) return null
69 const minutes = typeof o.window_minutes === 'number' && o.window_minutes > 0 ? o.window_minutes : undefined
70 const reset = typeof o.reset_at === 'number' && Number.isFinite(o.reset_at) ? o.reset_at * (o.reset_at < 1e12 ? 1000 : 1) : NaN
71 // A window whose reset has passed no longer stands (as the ChatGPT reader has always said).
72 if (Number.isFinite(reset) && reset <= now) return null
73 return {
74 provider,
75 ...(type ? { type } : {}),
76 ...(title ? { title } : {}),
77 account,
78 kind: kind as LimitKind,
79 label: typeof o.label === 'string' ? o.label : '',
80 ...(minutes !== undefined ? { windowMinutes: minutes } : {}),
81 percent: Math.round(used * 10) / 10,
82 ...(Number.isFinite(reset) ? { resetsAt: new Date(reset).toISOString() } : {}),
83 }
84}
85
86/**
87 * Every provider's windows from the router's `GET /api/limits`: one shape for Claude, each ChatGPT
88 * account and each Antigravity account (see `buildLimits` in packages/router/src/admin.ts). The
89 * providers keep the router's order (Claude first), and each account's windows keep theirs.
90 */
91export function routerLimitItems(body: unknown, now: number): LimitItem[] {
92 const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
93 const providers = object(body).providers
94 if (!Array.isArray(providers)) return []
95 const out: LimitItem[] = []
96 for (const p of providers) {
97 const entry = object(p)
98 const provider = typeof entry.provider === 'string' ? entry.provider : ''
99 if (!provider) continue
100 const type = typeof entry.type === 'string' ? entry.type : undefined
101 const title = typeof entry.title === 'string' ? entry.title : undefined
102 const accounts = entry.accounts
103 if (!Array.isArray(accounts)) continue
104 for (const a of accounts) {
105 const account = object(a)
106 const index = typeof account.account === 'number' ? account.account : 0
107 const windows = account.windows
108 if (!Array.isArray(windows)) continue
109 for (const w of windows) {
110 const item = limitItemFromWindow(w, provider, type, title, index, now)
111 if (item) out.push(item)
112 }
113 }
114 }
115 return out
116}
117
118/**
119 * The reset is read in the person's own clock: `offsetMinutes` is `Date#getTimezoneOffset` (minutes
120 * behind UTC), passed in so tests do not depend on the machine's zone.
121 */
122export function limitLabel(hot: Hot, now: number, offsetMinutes = new Date(now).getTimezoneOffset()): string {
123 const label = `${hot.kind === 'five_hour' ? '5h' : 'weekly'} ${Math.round(hot.percent)}%`
124 const reset = hot.resetsAt ? Date.parse(hot.resetsAt) : NaN
125 if (!Number.isFinite(reset)) return label
126 const date = new Date(reset - offsetMinutes * 60000)
127 const time = `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
128 const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getUTCDay()]
129 return `${label} · resets ${reset - now < 20 * 3600000 ? time : `${day} ${time}`}`
130}
131
132export function saveUntil(hot: Hot | null, now: number): number {
133 const reset = hot?.resetsAt ? Date.parse(hot.resetsAt) : NaN
134 return Number.isFinite(reset) && reset > now ? reset : now + 5 * 3600000
135}
136
137/** The context percent when it is at or past `swampAt`, else null. */
138export function swamped(context: SessionContextUsage | null, swampAt: number): number | null {
139 if (!context || context.tokens === undefined) return null
140 const percent = context.percent ?? (context.window > 0 ? context.tokens / context.window * 100 : undefined)
141 return percent !== undefined && percent >= swampAt ? percent : null
142}
143
144export function coldAlert(memo: CacheMemo | null, contextTokens: number, now: number, hidden: boolean): boolean {
145 return !hidden && memo !== null && memo.expiresAt <= now && contextTokens >= 150000
146}
147
148/**
149 * Keep-warm held the cache while the person was away: at least one ping read cache tokens and the
150 * memo says the cache is still warm. Once it has expired the cold card is the truth, never this.
151 */
152export function keptAlert(kept: { pings: number; tokens: number } | null, memo: CacheMemo | null, now: number): boolean {
153 return kept !== null && kept.pings > 0 && memo !== null && memo.expiresAt > now
154}
155
156/**
157 * Whether a put-off hot card should be forgotten: only when the window itself has moved on — another
158 * window became the hottest, or its reset moved forward by more than an hour. A reading that is
159 * missing for a while (a routed model's session carries no plan windows) or that comes from the
160 * other source with its reset spelled to the second rather than the minute is the same window, and
161 * forgetting on either brought the card back on every turn (2026-10-14, weekly at 87%).
162 */
163export function hotWindowMoved(previous: Hot | null, next: Hot | null): boolean {
164 if (!previous || !next) return false
165 if (previous.kind !== next.kind) return true
166 const before = previous.resetsAt ? Date.parse(previous.resetsAt) : NaN
167 const after = next.resetsAt ? Date.parse(next.resetsAt) : NaN
168 return Number.isFinite(before) && Number.isFinite(after) && after - before > 3_600_000
169}
170
171export function hotAlert(hot: Hot | null, hidden: number | null): boolean {
172 return hot !== null && hot.percent >= 80 && (hidden === null || hot.percent >= hidden + 10)
173}
174
175/**
176 * The heavy-context band shows once per crossing: seen (or put off) at `hidden` percent, it waits
177 * until the context grows SWAMP_STEP points more. A hidden value over 100 is a token count kept by
178 * an older mod and is ignored.
179 */
180export const SWAMP_STEP = 15
181export function swampAlert(percent: number | null, hidden: number | null): boolean {
182 return percent !== null && (hidden === null || hidden > 100 || percent >= hidden + SWAMP_STEP)
183}
184
185/** Rendering consumes eligibility, keeping dismissal and idle decisions outside drawing code. */
186export function bandPriority(state: {
187 panel?: 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | null
188 judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean; kept?: boolean
189}): 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
190 if (state.panel) return state.panel
191 for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp', 'kept'] as const) if (state[band]) return band
192 return 'dashboard'
193}
194hooks/compact.ts 41 lines1import type { SessionCompacted, SessionMessage } from 'claude-code'
2
3export const COMPACT_SYSTEM = `Create a faithful continuation record of this coding conversation. Keep the user's intent and changes of mind, decisions and constraints, relevant concepts, files and code touched, errors and their fixes, and user feedback. List user requests in order, distinguish solved from pending work, and state exactly what is happening now. Include a next step only when the conversation justifies it. Preserve useful exact paths and commands, but omit secrets, redundant logs, and obsolete detail. Do not invent verification or claim unfinished work is complete. Write in the user's language.`
4
5function clipped(text: string, limit: number): string {
6 return text.length > limit ? `${text.slice(0, limit)}\n[truncated]` : text
7}
8
9function render(messages: readonly SessionMessage[], tight: boolean): string {
10 return messages.map((message, index) => {
11 const tail = index >= messages.length - 6
12 const inputLimit = tail ? (tight ? 4000 : 20000) : 400
13 const resultLimit = tail ? (tight ? 4000 : 20000) : (tight ? 300 : 2000)
14 const parts = [`${message.role}:\n${message.text}`]
15 for (const use of message.toolUses) {
16 parts.push(`tool ${use.tool} (${use.tool_use_id}):\n${clipped(JSON.stringify(use.input), inputLimit)}`)
17 const result = use.text ?? (use.result !== undefined ? JSON.stringify(use.result) : undefined)
18 if (result !== undefined) parts.push(`result${use.isError ? ' ERROR' : ''}:\n${clipped(result, resultLimit)}`)
19 }
20 if (!message.toolUses.length) {
21 for (const result of message.toolResults ?? []) parts.push(`result ${result.tool_use_id}${result.isError ? ' ERROR' : ''}:\n${clipped(result.text, resultLimit)}`)
22 }
23 return parts.join('\n\n')
24 }).join('\n\n---\n\n')
25}
26
27/** Shrink only tool bulk; losing user intent to hit a budget is worse than engine fallback. */
28export function serializeTranscript(messages: readonly SessionMessage[], budget = 2400000): string | null {
29 if (!messages.length) return null
30 const full = render(messages, false)
31 if (full.length <= budget) return full
32 const tight = render(messages, true)
33 return tight.length <= budget ? tight : null
34}
35
36/** No handle: the engine builds this replacement from role, text and tool blocks. */
37export function compactResult(summary: string): SessionCompacted {
38 return { messages: [{ role: 'user', toolUses: [], text:
39 `Continuation record from the previous conversation:\n\n${summary.trim()}\n\nResume the pending work from this record. Do not repeat completed work or ask for information already recorded.` }] }
40}
41hooks/handoff.ts 44 lines1import type { SessionMessage } from 'claude-code'
2import type { HandoffKind, Settings } from '../types'
3
4export const QUICK_PROMPT = `Prepare a concise resume note for a fresh coding chat, in the user's language, about forty lines of bullets. Use only this conversation; do not run tools. Record project, folder and branch if known, the goal, verified completed work, unfinished work and blockers, git state as last checked, decisions and failed approaches, permissions or resume details, and the highest-priority runnable next action. Mark actions requiring the user. Keep only current relevant state and point to files or specs instead of copying them. Exclude secrets, environment values and raw logs. Output only the note, without an introduction or farewell.`
5
6export const FULL_PROMPT = `Write a durable handoff for the next coding chat. First verify the working directory, git status, recent commits and whether changes are pushed; check open pull requests if gh is available. Rebuild project-root HANDOFF.md from the current facts, fewer than eighty lines, in the user's language. Include the goal, verified done and undone work, blockers, changed files, decisions and failed approaches, permissions, exact current state and a prioritized next action. Distinguish facts from unverified claims. Never expose secrets, environment values or raw logs. Finish with the saved file path and a concise status for the next chat.`
7
8export function wrapForNewChat(text: string, after: Settings['handoffAfter'], kind: HandoffKind, hasSkill: boolean): string {
9 const source = kind === 'full' && hasSkill ?
10 'First read the saved handoff files named below; if no file is named, read HANDOFF.md. The last answer alone may not contain the handoff.\n\n' : ''
11 const action = after === 'confirm' ?
12 'State the current status and proposed next action in two lines, then wait for my confirmation without starting work.' :
13 'Continue with the justified next action. If it needs my permission or input, explain what is needed and wait.'
14 return `${source}Resume context from the previous chat:\n\n${text.trim()}\n\n${action}`
15}
16
17export const ADVICE_SYSTEM = `Decide whether starting a fresh chat clearly helps this coding work. Recommend it only when completed work is followed by a different task, unrelated history is dead weight, the topic changed, or repeated failed approaches are going in circles. A long chat, high context usage, or an ordinary next step is not enough. When uncertain, do not recommend it. Output only JSON {"handoff":null} or {"handoff":"reason"}, using the user's language and at most eight words.`
18
19/** Cadence counts real eligible prompts, not tool steps, pings or judge requests. */
20export function adviceDue(count: number, percent: number): boolean {
21 return percent >= 30 && Number.isInteger(count) && count > 0 && count % 2 === 0
22}
23
24export function adviceInput(input: { messages: readonly SessionMessage[]; next: string; contextPercent: number; warm: boolean }): string {
25 const users = input.messages.filter(message => message.role === 'user' && message.text.trim())
26 const assistant = input.messages.findLast(message => message.role === 'assistant' && message.text.trim())
27 const trail = users.slice(-8, -1).map(message => message.text.replace(/\s+/g, ' ').slice(0, 160)).join('\n')
28 return [`Context: ${Math.round(input.contextPercent)}%; user messages: ${users.length}; cache: ${input.warm ? 'warm' : 'cold'}`,
29 `Initial request: ${users[0]?.text.slice(0, 500) ?? ''}`, `Earlier requests:\n${trail}`,
30 `Assistant tail: ${assistant?.text.slice(-700) ?? ''}`, `Next request: ${input.next.slice(0, 600)}`].join('\n\n')
31}
32
33export function parseAdvice(text: string): string | null {
34 const span = /\{[\s\S]*\}/.exec(text)?.[0]
35 if (!span) return null
36 try {
37 const value: unknown = JSON.parse(span)
38 if (!value || typeof value !== 'object' || !('handoff' in value)) return null
39 return typeof value.handoff === 'string' && value.handoff.trim() ? value.handoff.trim().slice(0, 70) : null
40 } catch {
41 return null
42 }
43}
44