SLOPSHOPPER

ClaudeRipple

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…

newpanebandspinnerguardcommand
★ 52v0.7.0GPL-3.0-onlyupdated 2026-10-16PBJ-2/clauderipple/packages/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ripple
│ ┃ ClaudeRipple ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ All sessions │ ripple │ │ ┃ No model requests yet. ● ripple: ClaudeRipple│ ClaudeRipple: Haiku returned no verdict; │ │ ┃ ● this session ○ other sessions ● ripple: ClaudeRipple│ keeping the current effort or asking │ │ ┃ a: This session only ⏺ Read(src/auth.ts) │ Haiku. │ │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ripple │ ⎿ ripple: ClaudeRipple: settings, setup, auto, warm, save, handoff │ ● ripple: ClaudeRipple optional file: ENOENT: no such file /Users/dev │ ● ripple: ClaudeRipple panel settings: router HTTP 0 │ │ Welcome to ClaudeRipple ● ○ ○ A small, fast judge picks each prompt’s effort and each task’s worker. Judge ● Haiku ○ Jev ○ Router model n: Next k: Skip setup ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Welcome to ClaudeRipple ● ○ ○ A small, fast judge picks each prompt’s effort and each task’s worker. Judge ● Haiku ○ Jev ○ Router model n: Next k: Skip setup
Pane · ClaudeRipple
All sessions No model requests yet. ● this session ○ other sessions a: This session only
Pane · ripple-agents
No agents yet Tasks Claude delegates appear here, with the worker Ripple picked and why.
README

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


1P, not 3P: the difference this whole project exists for

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:

  • The window stops loading claude.ai and loads a local bundle instead. claude.ai /api/ and /v1/ calls answer custom_3p_not_available 503.
  • "Chat" is no longer claude.ai chat. It is a Claude Code local-agent session.
  • Remote Control and side sessions are switched off outright (shouldEnableSessionsBridge() returns false).
  • Anthropic's own claude.ai connectors, Claude Design, mobile continuity and chat search go with it.

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:

  • Code from your phone, on GPT. Remote Control is off in 3P. It is on here, so you can pick a model and send a task from a phone and watch the commits land.
  • Connectors alive while another model works. 3P stubs claude.ai calls, which takes Anthropic's own connectors with them. Read a Google Drive doc and have GPT write the code.
  • Cloud and side sessions. Gone in 3P. Untouched here.

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.

FAQ

Can I use GPT in the Claude Desktop app?

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

How do I use GPT in Claude Code?

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.

Do I have to turn on Claude Desktop's "third-party inference" (gateway) setting?

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.

Will I lose claude.ai chat, Remote Control or my connectors?

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.

Can I still use Claude itself?

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.

Do I need an OpenAI API key?

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

Which models can I use?

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.

Does it work on Windows?

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.

Is my code sent anywhere else?

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.

Will my Claude account get banned? Is this against Anthropic's terms?

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.

What about Gemini through a Google account?

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.

Can I use several ChatGPT accounts?

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.

Can I use Claude inside Codex?

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.

Can it generate images?

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.

Does GPT do worse inside 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.

How is this different from CC Switch, opencodex or claude-code-router?

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.

Is it free?

Yes. ClaudeRipple is open source under GPL-3.0. You pay only for the subscriptions or API keys you already use.

One tool instead of four

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.

ClaudeRippleCC Switch (checked 2026-09-24)opencodex / openclaude (checked 2026-09-16)claude-code-routerClaude 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 providers94–99 % measurednot measurednot measuredvariesn/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.

What you get

  • Any model in Claude Desktop and Claude Code. GPT-6 Sol / Luna / Astra and GPT-5.6 Terra / Sol / Luna through your ChatGPT Plus/Pro subscription (new models appear the day OpenAI serves them), Google Gemini, or DeepSeek, Kimi, GLM, MiniMax, Qwen, Grok, Mistral, Groq, Together, Fireworks, OpenRouter (400+ models) and local Ollama / LM Studio. Under their real names in the picker, or mapped onto a Claude name.
  • Claude in the Codex app and Codex CLI. A local OpenAI-compatible endpoint that Codex treats as a provider; Claude models appear in Codex's own model list. Uses your current Claude Code login or an Anthropic API key.
  • Multiple Claude accounts without breaking the prompt cache. Opt a native Claude provider into account rotation, add subscriptions from the dashboard, and each conversation stays on the account that answered. Before the response starts, a quota or authentication refusal moves that turn to the next account. This native Claude Desktop/Code path is separate from the translated Codex ingress, which picks one available login and does not rotate accounts.
  • Several ChatGPT accounts. Each 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.
  • The full Claude Code harness, untouched. Skills, hooks, MCP, CLAUDE.md, subagents, plan mode, Remote Control on your phone: nothing is turned off.
  • Correct by construction. Prompt caching preserved (Anthropic cache breakpoints and stable OpenAI prefixes), Claude Code's server-side threads handled, tool calls and images round-tripped, reasoning effort clamped to what each model accepts, Anthropic-only request fields stripped for compatible vendors.
  • A real request log. Who asked, which model answered, tokens in / cached / out, latency, status, per request, with a one-hour summary.
  • Subagent name tags. The background-task panel shows Terra·high · Review instead of a generic "Agent".
  • The ripple mod, above the prompt. A band shows the reasoning effort, context, cache timer, and which model answered your last request with its cache hit. A small judge model (Haiku 5.5 or Jev) reads each prompt with the recent turns and sets the effort, low through max. Delegated work goes to 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.
  • Images from your ChatGPT subscription. 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.
  • Lean workers, if you want them. One switch gives generated subagents only the tools a worker uses instead of every MCP server and plugin of the session: one worker's first request fell from 33k to 9k tokens.
  • Made for people who don't want a terminal. Provider presets with one-click connection tests and model discovery, drop-down model mapping, auto-save, a UI in English, Korean, Chinese, Japanese, Spanish and Portuguese. A menu-bar app that carries its own runtime and sets itself up on first launch. Signed and notarized.

<img src="docs/media/mapping.png" width="880" alt="Model mapping: which model answers for each Claude name, with per-model reasoning effort">

Install

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-desktop prints 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>".

Clients

Claude Desktop

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.

Claude Code (terminal, Remote Control, subagents)

Same router, same mapping. /model gpt-5.6-terra li

Source 28 files
hooks/register.tsx 1703 lines
1// 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 lines
1import 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}
329
hooks/ladder.ts 175 lines
1// 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}
175
hooks/i18n.ts 2831 lines
1// 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 lines
1import 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}
250
hooks/workers.ts 239 lines
1import 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}
239
hooks/worker.ts 50 lines
1// 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}
50
hooks/modes.ts 149 lines
1// 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}
149
hooks/cache.ts 61 lines
1import 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}
61
hooks/session.ts 194 lines
1import 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}
194
hooks/compact.ts 41 lines
1import 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}
41
hooks/handoff.ts 44 lines
1import 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