clauderipple 0.8.10 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +226 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +119 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +6 -1
  55. package/dist/router/src/providers/chatgpt/translate.js +1 -1
  56. package/dist/router/src/providers/google/index.js +6 -2
  57. package/dist/router/src/providers/openai/index.js +4 -1
  58. package/dist/router/src/providers/openai/translate.js +1 -1
  59. package/dist/router/src/proxy.js +14 -3
  60. package/dist/router/src/version.js +1 -1
  61. package/dist/ui/app.js +908 -209
  62. package/dist/ui/favicon-32.png +0 -0
  63. package/dist/ui/favicon.svg +12 -0
  64. package/dist/ui/i18n.js +919 -233
  65. package/dist/ui/index.html +159 -87
  66. package/dist/ui/style.css +789 -410
  67. package/docs/ARCHITECTURE.md +128 -0
  68. package/package.json +7 -3
@@ -1,311 +1,1513 @@
1
- // What did ClaudeRipple do with this session's model requests? The router records each request
2
- // under the Claude Code session that sent it (`X-Claude-Code-Session-Id`). A one-line band above
3
- // the prompt shows where the last one went, with a button to the request log and an × that puts
4
- // the band away (`/ripple-bar` brings it back); `/ripple-log` opens the log too. The log is the
5
- // router's request records, not its text log: one entry per model request with what matters
6
- // (model, provider, effort, tokens, cache, time, result). All of that only reads the router's
7
- // admin API. The one thing the mod changes is a spawn of a ClaudeRipple worker (./worker.ts).
8
-
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.
9
3
  import { atom, read, update } from 'claude-code'
10
- import type { EngineInterface, Register } from 'claude-code'
11
-
12
- import type { StatusRequest } from '../types'
13
- import { agentModel, fixSpawn } from './worker'
4
+ import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
5
+ import type { AgentRec, Effort, JudgeInput, JudgeKind, JudgeRun, ModSettingsSnapshot, Pick, Settings, StatusRequest, TierTarget, Verdict, WorkerCandidate, WorkerPick, WorkerVerdict } from '../types'
6
+ import { MOD_SETTINGS_SCHEMA_VERSION, applySettingsChanges, normalizeSettings, settingsChanges } from './settings'
7
+ import { debugText, langFrom, t } from './i18n'
8
+ import type { Lang } from './i18n'
9
+ import { EFFORT_JUDGE_SYSTEM, finalEffort, isCacheSafeModel, isShortFollowUp, jevEffortRequest, jevFailure, jevUsage, jevWorkerRequest, judgeContext, parseEffortVerdict, parseJevEffort, parseJevKey, parseJevWorker, shouldJudge } from './judge'
10
+ import { AUTO_DESCRIPTION, WORKER_JUDGE_SYSTEM, agentProvider, candidateUsable, chatgptUsable, claudeCandidates, ladderOptionsFrom, ladderPick, parseWorkerVerdict, readMarker, routerModelOptions, spawnModel, spawnTitle, takesOver, workerJudgeInput, zenJev } from './workers'
11
+ import { MARKER, agentModel, fixSpawn } from './worker'
12
+ import { activeModeOf, claudeWindow, eagerNote, modeKey, modeOver, quotaWindows, resetProvider, routingSettings } from './modes'
13
+ import type { QuotaWindow } from './modes'
14
14
  import type { Spawn, WorkerFix } from './worker'
15
+ import { afterPing, nextMemo, restoreFromResume, shouldPing } from './cache'
16
+ import { bandPriority, coldAlert, hotAlert, hottest, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
17
+ import { COMPACT_SYSTEM, compactResult, serializeTranscript } from './compact'
18
+ import { ADVICE_SYSTEM, FULL_PROMPT, QUICK_PROMPT, adviceDue, adviceInput, parseAdvice, wrapForNewChat } from './handoff'
19
+ import { addJudge, addPrompt, addRequest, addWarm, addWorker, emptyStats, formatStats, weighted } from './stats'
20
+ import { besideBand, renderBand, renderFooter } from './ui/band'
21
+ import { renderAlert } from './ui/alerts'
22
+ import { renderSettings } from './ui/settings'
23
+ import { renderSetup } from './ui/setup'
24
+ import { renderAgents } from './ui/agents'
25
+ import { renderLog } from './ui/log'
26
+ import { EFFORTS, modelName, nextMinuteChange, routeOf, targetLabel } from './ui/view'
27
+ import type { AlertActions, AlertView, BandActions, DashboardView, Frame, SettingsActions, SettingsView, SetupActions } from './ui/view'
28
+
29
+ const line = atom({ plugin: 'ripple', key: 'line' } as const, null)
30
+ const last = atom({ plugin: 'ripple', key: 'last' } as const, null)
31
+ const requests = atom({ plugin: 'ripple', key: 'requests' } as const, [])
32
+ const logOpen = atom({ plugin: 'ripple', key: 'logOpen' } as const, false)
33
+ const onlySession = atom({ plugin: 'ripple', key: 'onlySession' } as const, false)
34
+ const bandHidden = atom({ plugin: 'ripple', key: 'bandHidden' } as const, false)
35
+ const settings = atom({ plugin: 'ripple', key: 'settings' } as const, null)
36
+ const settingsSync = atom({ plugin: 'ripple', key: 'settingsSync' } as const, { schemaVersion: MOD_SETTINGS_SCHEMA_VERSION, revision: null, pending: [], migrationChecked: false, migration: null })
37
+ const settingsBase = atom({ plugin: 'ripple', key: 'settingsBase' } as const, null)
38
+ const lang = atom({ plugin: 'ripple', key: 'lang' } as const, 'en')
39
+ const pick = atom({ plugin: 'ripple', key: 'pick' } as const, null)
40
+ const deciding = atom({ plugin: 'ripple', key: 'deciding' } as const, false)
41
+ const decidingSince = atom({ plugin: 'ripple', key: 'decidingSince' } as const, null)
42
+ const paused = atom({ plugin: 'ripple', key: 'paused' } as const, false)
43
+ const heldPick = atom({ plugin: 'ripple', key: 'heldPick' } as const, null)
44
+ const appEffort = atom({ plugin: 'ripple', key: 'appEffort' } as const, null)
45
+ const modelIs = atom({ plugin: 'ripple', key: 'modelIs' } as const, null)
46
+ const lastRequest = atom({ plugin: 'ripple', key: 'lastRequest' } as const, null)
47
+ const lastSpawn = atom({ plugin: 'ripple', key: 'lastSpawn' } as const, null)
48
+ const agents = atom({ plugin: 'ripple', key: 'agents' } as const, [])
49
+ const cache = atom({ plugin: 'ripple', key: 'cache' } as const, null)
50
+ const warm = atom({ plugin: 'ripple', key: 'warm' } as const, { pings: 0, cost: 0 })
51
+ const lastRealReplyAt = atom({ plugin: 'ripple', key: 'lastRealReplyAt' } as const, null)
52
+ const context = atom({ plugin: 'ripple', key: 'context' } as const, null)
53
+ const hot = atom({ plugin: 'ripple', key: 'hot' } as const, null)
54
+ const limit = atom({ plugin: 'ripple', key: 'limit' } as const, null)
55
+ const hotHidden = atom({ plugin: 'ripple', key: 'hotHidden' } as const, null)
56
+ const saveUntil = atom({ plugin: 'ripple', key: 'saveUntil' } as const, null)
57
+ const swampHidden = atom({ plugin: 'ripple', key: 'swampHidden' } as const, null)
58
+ const coldHidden = atom({ plugin: 'ripple', key: 'coldHidden' } as const, false)
59
+ const judgeDown = atom({ plugin: 'ripple', key: 'judgeDown' } as const, null)
60
+ const judgeDownHidden = atom({ plugin: 'ripple', key: 'judgeDownHidden' } as const, null)
61
+ const judgeSeen = atom({ plugin: 'ripple', key: 'judgeSeen' } as const, [])
62
+ const judgeRuns = atom({ plugin: 'ripple', key: 'judgeRuns' } as const, [])
63
+ // The standing judge failure was a dropped connection or a timeout: the card blames the network, not the judge.
64
+ const judgeDownNetwork = atom({ plugin: 'ripple', key: 'judgeDownNetwork' } as const, false)
65
+ const advice = atom({ plugin: 'ripple', key: 'advice' } as const, null)
66
+ const adviceQueue = atom({ plugin: 'ripple', key: 'adviceQueue' } as const, null)
67
+ const promptCount = atom({ plugin: 'ripple', key: 'promptCount' } as const, 0)
68
+ const handoff = atom({ plugin: 'ripple', key: 'handoff' } as const, null)
69
+ const handoffQueued = atom({ plugin: 'ripple', key: 'handoffQueued' } as const, false)
70
+ const handoffWaiting = atom({ plugin: 'ripple', key: 'handoffWaiting' } as const, false)
71
+ const handoffChoiceKind = atom({ plugin: 'ripple', key: 'handoffChoiceKind' } as const, 'quick')
72
+ const handoffChoiceAfter = atom({ plugin: 'ripple', key: 'handoffChoiceAfter' } as const, 'continue')
73
+ const resultCard = atom({ plugin: 'ripple', key: 'result' } as const, null)
74
+ const panel = atom({ plugin: 'ripple', key: 'panel' } as const, null)
75
+ const busy = atom({ plugin: 'ripple', key: 'busy' } as const, false)
76
+ const stats = atom({ plugin: 'ripple', key: 'stats' } as const, emptyStats())
77
+ const settingsDraft = atom({ plugin: 'ripple', key: 'settingsDraft' } as const, null)
78
+ const setupStep = atom({ plugin: 'ripple', key: 'setupStep' } as const, 1)
79
+ const compactNote = atom({ plugin: 'ripple', key: 'compactNote' } as const, '')
80
+ const effortMenuOpen = atom({ plugin: 'ripple', key: 'effortMenuOpen' } as const, false)
81
+ const agentsOpen = atom({ plugin: 'ripple', key: 'agentsOpen' } as const, false)
82
+ const agentsShowDone = atom({ plugin: 'ripple', key: 'agentsShowDone' } as const, false)
83
+ const jev = atom({ plugin: 'ripple', key: 'jev' } as const, { source: null, test: { state: 'idle' } })
84
+ const routerModels = atom({ plugin: 'ripple', key: 'routerModels' } as const, [])
85
+ const ladderOptions = atom({ plugin: 'ripple', key: 'ladderOptions' } as const, null)
86
+ const modeNoted = atom({ plugin: 'ripple', key: 'modeNoted' } as const, null)
87
+ const demo = atom({ plugin: 'ripple', key: 'demo' } as const, null)
88
+
89
+ /**
90
+ * Whether a state write would change anything. A write redraws each reader, and a redraw that
91
+ * hands the host new pictures reads as a blink (2026-10-10: the band flashed on polls and steps),
92
+ * so the writes on every step and poll are skipped when they would store what is there. The
93
+ * engine's scan wants each atom named at its read and update, hence a test rather than a wrapper.
94
+ */
95
+ function same(a: unknown, b: unknown): boolean {
96
+ return JSON.stringify(a) === JSON.stringify(b)
97
+ }
15
98
 
16
- // The router's admin port, as the router itself works it out (packages/router/src/admin.ts
17
- // `adminPort`): `admin.port`, else one above the proxy port; 8792 when the config cannot be read.
99
+ const LOG_PANE = 'ripple-log'
100
+ const AGENTS_PANE = 'ripple-agents'
101
+ const HAIKU = 'claude-haiku-5-5'
102
+ const 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.'
103
+ let timersStarted = false
104
+ let polling = false
105
+ let ticking = false
106
+ let forking = false
107
+ let forkUsage: TurnUsage | null = null
108
+ let countdownTimer: { at: number; cancel: () => void } | null = null
109
+ let judgeSequence = 0
110
+ /** Jev's network blips and timeouts in a row. */
111
+ let jevBlips = 0
112
+ let generation = 0
113
+ let carriedPick = false
114
+ // Serialize settings reads and writes so a slow poll cannot overwrite a queued edit.
115
+ let settingsWork: Promise<void> = Promise.resolve()
116
+ function settingsTask<T>(work: () => Promise<T>): Promise<T> {
117
+ const result = settingsWork.then(work)
118
+ settingsWork = result.then(() => undefined, () => undefined)
119
+ return result
120
+ }
121
+
122
+ function object(value: unknown): Record<string, unknown> {
123
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
124
+ }
125
+ function strings(value: unknown): string[] {
126
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
127
+ }
128
+ function effortOf(value: unknown): Effort | undefined {
129
+ return typeof value === 'string' && EFFORTS.includes(value as Effort) ? value as Effort : undefined
130
+ }
131
+ function reason(error: unknown): string {
132
+ return error instanceof Error ? error.message : String(error)
133
+ }
134
+ async function report($: EngineInterface, where: string, error: unknown): Promise<void> {
135
+ await $.ui.log(t(await read($, lang), 'commands.debugLog', { where, reason: reason(error) }), { to: 'debug' })
136
+ }
18
137
  async function userHome($: EngineInterface): Promise<string> {
19
138
  return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~'
20
139
  }
21
-
22
- /** Where ClaudeRipple keeps its config (packages/router/src/config.ts `homeDir`). */
23
140
  async function rippleHome($: EngineInterface): Promise<string> {
24
141
  return (await $.env.get('CLAUDERIPPLE_HOME')) ?? `${await userHome($)}/.clauderipple`
25
142
  }
26
-
27
- /** Claude Code's own folder, where the generated agent files live. */
143
+ // The fs API has no append: serialize read/write pairs so concurrent picks in this mod survive.
144
+ let workerLogWork: Promise<void> = Promise.resolve()
145
+ async function persistWorkerPick($: EngineInterface, entry: Record<string, unknown>): Promise<void> {
146
+ const work = workerLogWork.then(async () => {
147
+ try {
148
+ const path = `${await rippleHome($)}/logs/worker-picks.jsonl`
149
+ const old = await $.fs.exists(path) ? await $.fs.read(path) : ''
150
+ const lines = old.split('\n').filter(line => line.trim()).slice(-299)
151
+ await $.fs.write(path, [...lines, JSON.stringify(entry)].join('\n') + '\n')
152
+ } catch (error) { await report($, 'worker pick log', error) }
153
+ })
154
+ // A reporting failure must not poison the queue or undo the selected spawn either.
155
+ workerLogWork = work.catch(() => undefined)
156
+ await workerLogWork
157
+ }
28
158
  async function claudeHome($: EngineInterface): Promise<string> {
29
159
  return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await userHome($)}/.claude`
30
160
  }
31
-
32
- /**
33
- * The fix for a spawn of one of ClaudeRipple's workers (./worker.ts), or null. A worker is a name
34
- * the router lists in generated-agents.json; anything else, Claude's own agents included, is left
35
- * alone, as is every spawn when either file cannot be read.
36
- */
37
- async function workerFix($: EngineInterface, spawn: Spawn): Promise<WorkerFix | null> {
161
+ async function optionalFile($: EngineInterface, path: string): Promise<string | undefined> {
162
+ try { return await $.fs.read(path) }
163
+ catch (error) { await report($, 'optional file', error); return undefined }
164
+ }
165
+ async function adminUrl($: EngineInterface): Promise<string> {
166
+ const text = await optionalFile($, `${await rippleHome($)}/config.json`)
167
+ if (!text) return 'http://127.0.0.1:8792'
168
+ const config = object(JSON.parse(text))
169
+ const admin = object(config.admin).port
170
+ const listen = object(config.listen).port
171
+ const port = typeof admin === 'number' ? admin : (typeof listen === 'number' ? listen : 8791) + 1
172
+ return `http://127.0.0.1:${port}`
173
+ }
174
+ /** Opens a URL with the system's opener (a browser page, or a claude:// link the desktop app takes); throws when it cannot. */
175
+ async function openUrl($: EngineInterface, url: string): Promise<void> {
176
+ let argv: string[]
177
+ // cmd reads & as a command separator: escaped, a link's query reaches start whole.
178
+ if (await $.env.get('OS') === 'Windows_NT') argv = ['cmd', '/c', 'start', '', url.replace(/&/g, '^&')]
179
+ else {
180
+ const platform = await $.process.run(['uname', '-s'], { timeoutMs: 2000 })
181
+ if (platform.exitCode !== 0) throw new Error(platform.stderr || 'uname failed')
182
+ argv = [platform.stdout.trim() === 'Darwin' ? 'open' : 'xdg-open', url]
183
+ }
184
+ const result = await $.process.run(argv, { timeoutMs: 2000 })
185
+ if (result.exitCode !== 0) throw new Error(result.stderr || `opener exited ${result.exitCode}`)
186
+ }
187
+ async function openAllSettings($: EngineInterface): Promise<void> {
188
+ const url = `${await adminUrl($)}/#mod`
38
189
  try {
39
- const listed = JSON.parse(await $.fs.read(`${await rippleHome($)}/generated-agents.json`)) as { agents?: string[] }
40
- if (!listed.agents?.includes(spawn.subagentType)) return null
41
- const fileModel = agentModel(await $.fs.read(`${await claudeHome($)}/agents/${spawn.subagentType}.md`))
42
- return fileModel ? fixSpawn(spawn, fileModel) : null
43
- } catch {
44
- return null
190
+ await openUrl($, url)
191
+ } catch (error) {
192
+ await report($, 'open mod settings', error)
193
+ await $.ui.toast(url)
45
194
  }
46
195
  }
47
-
48
- async function adminUrl($: EngineInterface): Promise<string> {
196
+ async function currentSettings($: EngineInterface): Promise<Settings> {
197
+ return normalizeSettings(await read($, settings))
198
+ }
199
+ async function resolveLanguage($: EngineInterface, s: Settings): Promise<Lang> {
200
+ if (s.language !== 'auto') return s.language
49
201
  try {
50
- const config = JSON.parse(await $.fs.read(`${await rippleHome($)}/config.json`)) as { admin?: { port?: number }; listen?: { port?: number } }
51
- const port = config.admin?.port ?? (config.listen?.port ?? 8791) + 1
52
- return `http://127.0.0.1:${port}`
53
- } catch {
54
- return 'http://127.0.0.1:8792'
202
+ const value = (await $.settings.read()).language
203
+ const host = langFrom(typeof value === 'string' ? value : undefined)
204
+ if (host) return host
205
+ } catch (error) { await report($, 'language settings', error) }
206
+ // On a Mac the system language comes before LANG: a session the desktop app starts can carry
207
+ // LANG=en_US on a Korean Mac (seen 2026-10-10: Korean headless, English in the Code tab).
208
+ // The engine exposes no platform noun. Avoid running macOS defaults elsewhere.
209
+ if (await $.env.get('OS') !== 'Windows_NT') {
210
+ try {
211
+ const platform = await $.process.run(['uname', '-s'], { timeoutMs: 2000 })
212
+ if (platform.exitCode === 0 && platform.stdout.trim() === 'Darwin') {
213
+ const apple = await $.process.run(['defaults', 'read', '-g', 'AppleLocale'], { timeoutMs: 2000 })
214
+ const mac = apple.exitCode === 0 ? langFrom(apple.stdout) : undefined
215
+ if (mac) return mac
216
+ if (apple.exitCode !== 0) await report($, 'language locale', apple.stderr)
217
+ }
218
+ } catch (error) { await report($, 'language locale', error) }
55
219
  }
220
+ return langFrom(await $.env.get('LANG')) ?? langFrom(await $.env.get('LC_ALL')) ?? 'en'
56
221
  }
57
- const EVERY_MS = 3000
58
- const LOG_ROWS = 60
59
- const PANE = 'ripple-log'
60
- const BAND_HIDDEN_KEY = 'bandHidden'
61
-
62
- const OK = '#16a34a'
63
- const FAIL = '#dc2626'
64
- const WARN = '#d97706'
65
- const ACCENT = '#2563eb'
66
-
67
- // A provider is named by whoever added it, so most names are not known here. The presets' default
68
- // names keep their vendor's hue; any other name gets one from PALETTE picked by a hash of the name,
69
- // so it keeps that color from one session to the next. The palette leaves out the status
70
- // red/green/amber, so a provider is never read as a result.
71
- const KNOWN: Record<string, string> = {
72
- anthropic: '#d97757',
73
- chatgpt: '#10a37f',
74
- 'google-gemini': '#4285f4',
75
- deepseek: '#4d6bfe',
76
- 'opencode-go': '#9333ea',
77
- 'opencode-zen': '#9333ea',
78
- }
79
- const PALETTE = ['#0891b2', '#db2777', '#7c3aed', '#0d9488', '#c026d3', '#4f46e5', '#0284c7', '#be185d']
80
-
81
- export function providerColor(provider: string): string {
82
- if (provider === 'refused') return FAIL
83
- const known = KNOWN[provider]
84
- if (known) return known
85
- let hash = 0
86
- for (const char of provider) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
87
- return PALETTE[hash % PALETTE.length]!
222
+ async function registerAuto($: EngineInterface, s: Settings): Promise<void> {
223
+ if (s.agentAuto === 'off') return
224
+ await $.agent.register({ name: 'auto', description: AUTO_DESCRIPTION, prompt: EXECUTOR, disallowedTools: ['Agent'] })
88
225
  }
89
-
90
- // The acceptance bar for translated providers is a 90% cache hit.
91
- function cacheColor(percent: number): string {
92
- return percent >= 90 ? OK : percent >= 50 ? WARN : FAIL
226
+ async function installSettings($: EngineInterface, s: Settings): Promise<void> {
227
+ const previous = await currentSettings($)
228
+ await update($, settings, () => s)
229
+ const resolved = await resolveLanguage($, s)
230
+ await update($, lang, () => resolved)
231
+ // A router poll can land before the session binds, when register rejects; session.start
232
+ // registers again, so a rejection here must not cut the rest of the install short.
233
+ try { await registerAuto($, s) } catch (error) { await report($, 'agent register', error) }
234
+ if (!s.effortAuto) await update($, heldPick, () => null)
235
+ if (s.keepWarm && !previous.keepWarm) await update($, warm, value => ({ ...value, stopped: undefined }))
93
236
  }
94
-
95
- // Set by session.start; lets a press redraw the log at once instead of on the next tick.
96
- let refreshNow: (() => Promise<void>) | undefined
97
-
98
- const line = atom({ plugin: 'clauderipple-status', key: 'line' } as const, null)
99
- const last = atom({ plugin: 'clauderipple-status', key: 'last' } as const, null)
100
- const requests = atom({ plugin: 'clauderipple-status', key: 'requests' } as const, [])
101
- const logOpen = atom({ plugin: 'clauderipple-status', key: 'logOpen' } as const, false)
102
- const onlySession = atom({ plugin: 'clauderipple-status', key: 'onlySession' } as const, false)
103
- const bandHidden = atom({ plugin: 'clauderipple-status', key: 'bandHidden' } as const, false)
104
-
105
- type Usage = NonNullable<StatusRequest['usage']>
106
-
107
- function promptTokens(usage: Usage): number {
108
- return usage.input + usage.cached + (usage.cacheWrite ?? 0)
237
+ function settingsSnapshot(text: string): ModSettingsSnapshot {
238
+ const value = object(JSON.parse(text))
239
+ 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')
240
+ return { settings: normalizeSettings(value.settings), revision: Number(value.revision), stored: value.stored }
109
241
  }
110
-
111
- function cachePercent(usage: Usage | undefined): number | undefined {
112
- if (!usage) return undefined
113
- const prompt = promptTokens(usage)
114
- return prompt > 0 ? Math.round((usage.cached / prompt) * 100) : undefined
242
+ async function routerSettingsDown($: EngineInterface, error: unknown): Promise<void> {
243
+ const notice = t(await read($, lang), 'band.routerDown')
244
+ if (await read($, line) !== notice) await update($, line, () => notice)
245
+ await report($, 'router settings', error)
115
246
  }
116
-
117
- function compact(n: number): string {
118
- return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
247
+ async function acceptSettings($: EngineInterface, snapshot: ModSettingsSnapshot, sent = 0): Promise<void> {
248
+ const sync = await update($, settingsSync, value => ({ ...value, revision: snapshot.revision, pending: value.pending.slice(sent), migration: snapshot.stored ? null : value.migration }))
249
+ const base = snapshot.stored ? snapshot.settings : sync.migration ?? snapshot.settings
250
+ await installSettings($, applySettingsChanges(base, sync.pending))
251
+ await rebaseDraft($)
252
+ }
253
+ async function putSettings($: EngineInterface, admin: string, base: Settings): Promise<void> {
254
+ for (let attempt = 0; attempt < 2; attempt++) {
255
+ const sync = await read($, settingsSync)
256
+ // A migration that lost a CAS race is not a user edit: never retry it as a full save.
257
+ if (!sync.pending.length && !sync.migration) return
258
+ const count = sync.pending.length
259
+ 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 }) })
260
+ if (!response.ok && response.status !== 409) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
261
+ const snapshot = settingsSnapshot(response.text)
262
+ await acceptSettings($, snapshot, response.ok ? count : 0)
263
+ if (response.ok) return
264
+ base = snapshot.settings
265
+ if (attempt === 1) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: 409 }))
266
+ }
267
+ }
268
+ async function syncSettings($: EngineInterface, admin: string): Promise<void> {
269
+ const response = await $.http.fetch(`${admin}/api/mod-settings`)
270
+ if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
271
+ const snapshot = settingsSnapshot(response.text)
272
+ let sync = await read($, settingsSync)
273
+ // Session atoms survive reloads. Old pending paths/drafts cannot prove user intent
274
+ // under this format; discard them rather than granting an old overwrite a new version.
275
+ if (sync.schemaVersion !== MOD_SETTINGS_SCHEMA_VERSION) {
276
+ sync = await update($, settingsSync, () => ({ schemaVersion: MOD_SETTINGS_SCHEMA_VERSION, revision: null, pending: [], migrationChecked: false, migration: null }))
277
+ await update($, settingsBase, () => null)
278
+ }
279
+ if (snapshot.stored && sync.migration) sync = await update($, settingsSync, value => ({ ...value, migration: null }))
280
+ if (!sync.migrationChecked) {
281
+ let migration: Settings | null = null
282
+ if (!snapshot.stored) {
283
+ const legacy = await $.store.get('settings')
284
+ const setupDone = await $.store.get('setupDone')
285
+ if (legacy !== undefined && legacy !== null || setupDone === true) {
286
+ migration = normalizeSettings(legacy)
287
+ if (setupDone === true) migration.setupDone = true
288
+ }
289
+ }
290
+ sync = await update($, settingsSync, value => ({ ...value, migrationChecked: true, migration }))
291
+ }
292
+ if (sync.revision !== snapshot.revision || await read($, settings) === null) await acceptSettings($, snapshot)
293
+ sync = await read($, settingsSync)
294
+ if (sync.pending.length || sync.migration) await putSettings($, admin, snapshot.stored ? snapshot.settings : sync.migration ?? snapshot.settings)
295
+ }
296
+ async function loadSettings($: EngineInterface): Promise<void> {
297
+ try { await settingsTask(async () => { await syncSettings($, await adminUrl($)) }) }
298
+ catch (error) { await installSettings($, await currentSettings($)); await routerSettingsDown($, error) }
299
+ const hidden = (await $.store.get('bandHidden')) === true
300
+ await update($, bandHidden, () => hidden)
301
+ if (!(await read($, pick))) {
302
+ const carried = object(await $.store.get('pick'))
303
+ const level = effortOf(carried.effort)
304
+ if (level && ['haiku', 'jev', 'model', 'manual', 'followup'].includes(String(carried.by))) {
305
+ carriedPick = true
306
+ 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 }))
307
+ }
308
+ }
309
+ }
310
+ async function saveSettings($: EngineInterface, s: Settings, baseline?: Settings): Promise<boolean> {
311
+ // Both sides use the same format: normalization alone is never an edit.
312
+ const changes = settingsChanges(normalizeSettings(baseline ?? await currentSettings($)), normalizeSettings(s))
313
+ try {
314
+ await settingsTask(async () => {
315
+ await update($, settingsSync, value => ({ ...value, pending: [...value.pending, ...changes] }))
316
+ await installSettings($, applySettingsChanges(await currentSettings($), changes))
317
+ const admin = await adminUrl($)
318
+ // Always rebase on the router, never PUT the normalized local atom wholesale.
319
+ await syncSettings($, admin)
320
+ })
321
+ return true
322
+ } catch (error) { await routerSettingsDown($, error); return false }
323
+ }
324
+ async function installPick($: EngineInterface, value: Pick): Promise<void> {
325
+ carriedPick = false
326
+ await update($, pick, () => value)
327
+ await $.store.set('pick', value)
119
328
  }
120
329
 
121
- // The requested name's `@effort` is the agent file's default, not what was sent; the sent effort is
122
- // its own field.
123
- function modelOf(record: StatusRequest): string {
124
- const source = record.source.replace(/@[^@]*$/, '')
125
- return source === record.target ? record.target : `${record.target} (${source})`
330
+ // The engine's pane list is authoritative: a reload can close panes without sending ui.close.
331
+ async function isLogUp($: EngineInterface): Promise<boolean> {
332
+ return (await $.ui.panes()).some(p => p.id === LOG_PANE)
333
+ }
334
+ async function syncPanes($: EngineInterface): Promise<void> {
335
+ const panes = await $.ui.panes()
336
+ const log = panes.some(p => p.id === LOG_PANE)
337
+ const agent = panes.some(p => p.id === AGENTS_PANE)
338
+ if (log !== await read($, logOpen)) await update($, logOpen, () => log)
339
+ if (agent !== await read($, agentsOpen)) await update($, agentsOpen, () => agent)
340
+ }
341
+ async function toggleLog($: EngineInterface): Promise<boolean> {
342
+ const language = await read($, lang)
343
+ const open = await isLogUp($)
344
+ if (open) await $.ui.close({ id: LOG_PANE })
345
+ else await $.ui.open({ id: LOG_PANE, title: t(language, 'commands.logTitle') })
346
+ await syncPanes($)
347
+ if (!open) await refreshRouter($)
348
+ return !open
349
+ }
350
+ async function toggleAgents($: EngineInterface): Promise<void> {
351
+ const language = await read($, lang)
352
+ const open = (await $.ui.panes()).some(p => p.id === AGENTS_PANE)
353
+ if (open) await $.ui.close({ id: AGENTS_PANE })
354
+ else await $.ui.open({ id: AGENTS_PANE, title: t(language, 'commands.agentsTitle') })
355
+ await syncPanes($)
356
+ }
357
+ async function toggleBar($: EngineInterface): Promise<boolean> {
358
+ const hidden = !await read($, bandHidden)
359
+ await $.store.set('bandHidden', hidden)
360
+ await update($, bandHidden, () => hidden)
361
+ return !hidden
362
+ }
363
+ async function refreshRouter($: EngineInterface): Promise<void> {
364
+ const language = await read($, lang)
365
+ if (polling) return
366
+ polling = true
367
+ try {
368
+ await syncPanes($)
369
+ const admin = await adminUrl($)
370
+ await settingsTask(() => syncSettings($, admin))
371
+ await endModeIfOver($)
372
+ const open = await isLogUp($)
373
+ if (!open && await read($, bandHidden)) { await update($, line, () => null); return }
374
+ const session = await $.session.id()
375
+ const own = await $.http.fetch(`${admin}/api/requests?kind=messages&n=${open ? 60 : 1}&session=${encodeURIComponent(session)}`)
376
+ if (!own.ok) throw new Error(t(language, 'commands.routerHttp', { status: own.status }))
377
+ const mine = (object(JSON.parse(own.text)).requests ?? []) as StatusRequest[]
378
+ const newest = mine[0] ?? null
379
+ const notice = newest && newest.session === undefined ? t(language, 'band.routerRestart') : null
380
+ if (notice !== await read($, line)) await update($, line, () => notice)
381
+ // Do not show a route belonging to another chat from a router without session support.
382
+ const route = notice ? null : newest
383
+ if (JSON.stringify(route) !== JSON.stringify(await read($, last))) await update($, last, () => route)
384
+ if (!open) return
385
+ let shown = mine
386
+ if (!await read($, onlySession)) {
387
+ const all = await $.http.fetch(`${admin}/api/requests?kind=messages&n=60`)
388
+ if (!all.ok) throw new Error(t(language, 'commands.routerHttp', { status: all.status }))
389
+ shown = (object(JSON.parse(all.text)).requests ?? []) as StatusRequest[]
390
+ }
391
+ const rows = shown.map(record => ({ ...record, mine: record.session === session }))
392
+ if (JSON.stringify(rows) !== JSON.stringify(await read($, requests))) await update($, requests, () => rows)
393
+ } catch (error) {
394
+ if (await read($, line) !== t(language, 'band.routerDown')) await update($, line, () => t(language, 'band.routerDown'))
395
+ await report($, 'router poll', error)
396
+ } finally { polling = false }
126
397
  }
127
398
 
128
- function timeOf(at: string): string {
129
- const date = new Date(at)
130
- return [date.getHours(), date.getMinutes(), date.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
399
+ // Files and availability are sampled at spawn, not cached across quota/account changes.
400
+ async function workerFiles($: EngineInterface): Promise<Map<string, string>> {
401
+ const text = await optionalFile($, `${await rippleHome($)}/generated-agents.json`)
402
+ const names = text ? strings(object(JSON.parse(text)).agents) : []
403
+ const files = new Map<string, string>()
404
+ for (const name of names) {
405
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) continue
406
+ const file = await optionalFile($, `${await claudeHome($)}/agents/${name}.md`)
407
+ if (file && agentModel(file)) files.set(name, file)
408
+ }
409
+ return files
410
+ }
411
+ async function workerFix($: EngineInterface, spawn: Spawn): Promise<WorkerFix | null> {
412
+ const text = await optionalFile($, `${await rippleHome($)}/generated-agents.json`)
413
+ if (!text || !strings(object(JSON.parse(text)).agents).includes(spawn.subagentType)) return null
414
+ if (!/^[A-Za-z0-9_.-]+$/.test(spawn.subagentType)) return null
415
+ const file = await optionalFile($, `${await claudeHome($)}/agents/${spawn.subagentType}.md`)
416
+ const model = file ? agentModel(file) : undefined
417
+ return model ? fixSpawn(spawn, model) : null
418
+ }
419
+ async function availability($: EngineInterface): Promise<boolean> {
420
+ try {
421
+ const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
422
+ return status.ok ? chatgptUsable(JSON.parse(status.text)) : true
423
+ } catch (error) { await report($, 'availability unknown', error); return true }
131
424
  }
425
+ async function refreshModels($: EngineInterface): Promise<void> {
426
+ const response = await $.http.fetch(`${await adminUrl($)}/api/config`)
427
+ if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
428
+ await update($, routerModels, () => routerModelOptions(JSON.parse(response.text)))
429
+ }
430
+ /** How long the rung choices are kept: ten minutes. */
431
+ const KEEP_MS = 600000
132
432
 
133
- // Whether the pane is up is the engine's record, not ours: a reload drops the pane without a
134
- // `ui.close` this module hears, while `$.state` keeps `logOpen` (seen 2026-10-05: the button stuck
135
- // on "Close log" and the pane could not be reopened).
136
- async function isLogUp($: EngineInterface): Promise<boolean> {
137
- return (await $.ui.panes()).some(pane => pane.id === PANE)
433
+ /**
434
+ * The quick settings' rung choices, read again each time the panel opens: a model ticked on the
435
+ * dashboard shows in every Select without a restart. A failure keeps what was there.
436
+ */
437
+ async function refreshLadder($: EngineInterface): Promise<void> {
438
+ try {
439
+ const response = await $.http.fetch(`${await adminUrl($)}/api/mod-options`)
440
+ if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
441
+ const fresh = ladderOptionsFrom(JSON.parse(response.text), await $.clock.now() + KEEP_MS, (await currentSettings($)).allow)
442
+ if (!same(await read($, ladderOptions), fresh)) await update($, ladderOptions, () => fresh)
443
+ } catch (error) { await report($, 'ladder options', error) }
444
+ }
445
+ /** Turns a mode on (stamping when, for a mode that runs until the quota resets), or off with ''. */
446
+ async function setMode($: EngineInterface, id: string): Promise<void> {
447
+ const change: Partial<Settings> = id ? { activeMode: id, activeSince: await $.clock.now() } : { activeMode: undefined, activeSince: undefined }
448
+ await settingsActions($).patch(change)
449
+ }
450
+ /** Keep observed reset deadlines while a mode is on, even if the next snapshot rolls forward. */
451
+ let modeQuotaAt = 0
452
+ let modeQuotaKey: string | null = null
453
+ let modeQuotaWindows: QuotaWindow[] = []
454
+ let endingMode = false
455
+ /** The chosen provider's windows: Claude uses the band's binding limit; ChatGPT its account quota API. */
456
+ async function modeWindows($: EngineInterface, provider: string): Promise<QuotaWindow[]> {
457
+ if (provider === 'anthropic') return claudeWindow(await read($, limit))
458
+ const response = await $.http.fetch(`${await adminUrl($)}/api/chatgpt-accounts?provider=${encodeURIComponent(provider)}`)
459
+ if (!response.ok) return []
460
+ const body: unknown = JSON.parse(response.text)
461
+ // The router answers for its first ChatGPT provider when the name is not one: not this provider.
462
+ return object(body).provider === provider ? quotaWindows(body) : []
463
+ }
464
+ /** A mode whose time is up turns itself off and says so (on each poll and prompt). */
465
+ async function endModeIfOver($: EngineInterface): Promise<void> {
466
+ if (endingMode) return
467
+ endingMode = true
468
+ try {
469
+ const s = await currentSettings($)
470
+ const mode = activeModeOf(s)
471
+ const key = modeKey(s) + JSON.stringify([mode?.rungs, mode?.until])
472
+ if (modeQuotaKey !== key) { modeQuotaKey = key; modeQuotaAt = 0; modeQuotaWindows = [] }
473
+ if (!mode || mode.until === 'none') return
474
+ const now = await $.clock.now()
475
+ let windows = modeQuotaWindows
476
+ const provider = resetProvider(mode)
477
+ if (provider) {
478
+ // Claude's limit arrives with the session; read it on every check, not once a minute.
479
+ if (provider === 'anthropic') windows = [...windows, ...claudeWindow(await read($, limit))]
480
+ if (!modeOver(mode, s.activeSince, now, windows) && now - modeQuotaAt >= 60_000) {
481
+ const fresh = await modeWindows($, provider)
482
+ modeQuotaAt = now
483
+ // Keep only the earliest observed deadline after activation, plus current snapshots.
484
+ const first = [...windows, ...fresh].filter(w => w.at > (s.activeSince ?? now)).sort((a, b) => a.at - b.at)[0]
485
+ modeQuotaWindows = [...(first ? [first] : []), ...fresh]
486
+ windows = modeQuotaWindows
487
+ }
488
+ }
489
+ if (!modeOver(mode, s.activeSince, now, windows)) return
490
+ await saveSettings($, { ...s, activeMode: undefined, activeSince: undefined }, s)
491
+ await $.ui.toast(t(await read($, lang), 'toasts.modeEnded', { name: mode.name }))
492
+ } catch (error) { await report($, 'mode end', error) }
493
+ finally { endingMode = false }
494
+ }
495
+ async function jevKey($: EngineInterface, jevSource?: Settings['jevSource']): Promise<string | undefined> {
496
+ const selected = jevSource ?? normalizeSettings(await read($, settingsDraft) ?? await read($, settings)).jevSource
497
+ if (selected === 'zen') {
498
+ const file = await optionalFile($, `${await rippleHome($)}/config.json`)
499
+ const key = file ? zenJev(JSON.parse(file)).authorization : undefined
500
+ await update($, jev, value => ({ ...value, source: key ? 'zen' as const : null }))
501
+ return key
502
+ }
503
+ const env = (await $.env.get('TYPESAFE_API_KEY'))?.trim()
504
+ if (env) {
505
+ if ((await read($, jev)).source !== 'env') await update($, jev, value => ({ ...value, source: 'env' as const }))
506
+ return env
507
+ }
508
+ const file = await optionalFile($, `${await userHome($)}/.config/jev/.env`)
509
+ const key = file ? parseJevKey(file) : undefined
510
+ const source: 'pasted' | 'file' | null = key ? ((await read($, jev)).source === 'pasted' ? 'pasted' : 'file') : null
511
+ if ((await read($, jev)).source !== source) await update($, jev, value => ({ ...value, source }))
512
+ return key
513
+ }
514
+ /** OpenCode Zen's key from the router's config, when Zen lists a Jev model there; else none. */
515
+ async function zenJevKey($: EngineInterface): Promise<string | undefined> {
516
+ const file = await optionalFile($, `${await rippleHome($)}/config.json`)
517
+ if (!file) return undefined
518
+ const config: unknown = JSON.parse(file)
519
+ const models = object(object(object(config).providers)['opencode-zen']).models
520
+ const listed = Array.isArray(models) && models.some(m => { const id = object(m).id; return typeof id === 'string' && id.toLowerCase().includes('jev') })
521
+ return listed ? zenJev(config).authorization : undefined
522
+ }
523
+ async function saveJevKey($: EngineInterface, input: string): Promise<void> {
524
+ const language = await read($, lang)
525
+ const key = parseJevKey(input) ?? input.trim()
526
+ if (!key || /[\r\n]/.test(key)) throw new Error(t(language, 'toasts.keySingle'))
527
+ const path = `${await userHome($)}/.config/jev/.env`
528
+ const old = await optionalFile($, path) ?? ''
529
+ const entry = `TYPESAFE_API_KEY=${key}`
530
+ const text = /^[ \t]*TYPESAFE_API_KEY[ \t]*=/m.test(old)
531
+ ? old.replace(/^[ \t]*TYPESAFE_API_KEY[ \t]*=[^\r\n]*/gm, () => entry)
532
+ : old + (old && !old.endsWith('\n') ? '\n' : '') + entry + '\n'
533
+ await $.fs.write(path, text)
534
+ const mode = await $.process.run(['chmod', '600', path])
535
+ if (mode.exitCode !== 0) throw new Error(t(language, 'toasts.keyPrivateFailed'))
536
+ await update($, jev, () => ({ source: 'pasted' as const, test: { state: 'idle' as const } }))
537
+ await update($, judgeDown, () => null)
538
+ await $.ui.toast(t(language, 'toasts.keySaved'))
539
+ }
540
+ /**
541
+ * A judge failure is said once per reason in a session (a toast and the band); the same reason
542
+ * again stays quiet, so a judge that keeps failing does not speak on every message.
543
+ */
544
+ async function down($: EngineInterface, message: string, network = false): Promise<void> {
545
+ if ((await read($, judgeSeen)).includes(message)) return
546
+ await update($, judgeSeen, value => [...value, message].slice(-20))
547
+ await update($, judgeDownNetwork, () => network)
548
+ await update($, judgeDown, () => message)
549
+ await $.ui.toast(t(await read($, lang), 'toasts.judgeDown', { reason: message }))
138
550
  }
139
551
 
140
- async function openLog($: EngineInterface) {
141
- await update($, logOpen, () => true)
142
- await $.ui.open({ id: PANE, title: 'ClaudeRipple' })
552
+ type Judged = { verdict: Verdict | WorkerVerdict | undefined; by: JudgeKind; ms: number; error?: string }
553
+ function judgeFailure(answer: ModelCompleteResult): string | undefined {
554
+ if (answer.isAnswered) return undefined
555
+ return answer.reason === 'api-error' ? `${answer.reason}: ${answer.error} (${answer.status ?? 'no response'})` : answer.reason
556
+ }
557
+ async function completeJudge($: EngineInterface, model: string, system: string, prompt: string): Promise<ModelCompleteResult> {
558
+ return $.model.complete({ model, system, prompt, maxTokens: 160, timeoutMs: 6000, effort: 'low' })
559
+ }
560
+ async function judge($: EngineInterface, s: Settings, input: JudgeInput | { description: string; prompt: string }, worker: boolean, testing = false): Promise<Judged> {
561
+ const language = await read($, lang)
562
+ const started = await $.clock.now()
563
+ const system = worker ? WORKER_JUDGE_SYSTEM : EFFORT_JUDGE_SYSTEM
564
+ const prompt = 'description' in input ? workerJudgeInput(input) : judgeContext(input)
565
+ let failure: string | undefined
566
+ let by: JudgeKind = s.judge
567
+ let verdict: Verdict | WorkerVerdict | undefined
568
+ let blip = false
569
+ if (s.judge === 'jev') {
570
+ try {
571
+ let source = s.jevSource
572
+ let key = await jevKey($, source)
573
+ // No TypeSafe key, but the router's OpenCode Zen offers Jev: ask Jev there.
574
+ if (!key && source === 'typesafe') {
575
+ key = await zenJevKey($)
576
+ if (key) source = 'zen'
577
+ }
578
+ if (!key) throw new Error(t(language, source === 'zen' ? 'jev.zenMissing' : 'toasts.keyMissing'))
579
+ const body = 'description' in input ? jevWorkerRequest(input) : jevEffortRequest(input)
580
+ let model = 'jev-latest'
581
+ if (source === 'zen') {
582
+ const file = await optionalFile($, `${await rippleHome($)}/config.json`)
583
+ model = file ? zenJev(JSON.parse(file)).model : model
584
+ }
585
+ const response = await Promise.race([
586
+ $.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 }) })
587
+ .catch(() => 'network' as const),
588
+ $.clock.sleep(3000).then(() => null),
589
+ ])
590
+ // A dropped connection or a slow answer is often one bad moment, not a broken judge.
591
+ if (!response || response === 'network') {
592
+ blip = true
593
+ throw new Error(jevFailure(response ? 'network' : 'timeout', language))
594
+ }
595
+ if (!response.ok) throw new Error(jevFailure(response.status, language))
596
+ const json: unknown = JSON.parse(response.text)
597
+ // 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.
598
+ verdict = worker ? parseJevWorker(json) : parseJevEffort(json, 'current' in input ? input.current : null, language)
599
+ await update($, stats, value => addJudge(value, 'jev', 0, jevUsage(json)))
600
+ if (!verdict) throw new Error(t(language, 'toasts.jevNoVerdict'))
601
+ } catch (error) { failure = reason(error); verdict = undefined }
602
+ }
603
+ if (s.judge === 'model') {
604
+ try {
605
+ const answer = await completeJudge($, s.judgeModel, system, prompt)
606
+ await update($, stats, value => addJudge(value, 'model', 0, answer.usage.input_tokens + answer.usage.output_tokens))
607
+ verdict = answer.isAnswered ? (worker ? parseWorkerVerdict(answer.text) : parseEffortVerdict(answer.text)) : undefined
608
+ if (!verdict) failure = judgeFailure(answer) ?? t(language, 'toasts.routerNoVerdict')
609
+ } catch (error) { failure = reason(error) }
610
+ }
611
+ if (!verdict) {
612
+ by = 'haiku'
613
+ try {
614
+ let answer = await completeJudge($, HAIKU, system, prompt)
615
+ await update($, stats, value => addJudge(value, 'haiku', 0, answer.usage.input_tokens + answer.usage.output_tokens))
616
+ if (!answer.isAnswered) {
617
+ failure ??= judgeFailure(answer)
618
+ answer = await completeJudge($, 'haiku', system, prompt)
619
+ await update($, stats, value => addJudge(value, 'haiku', 0, answer.usage.input_tokens + answer.usage.output_tokens))
620
+ }
621
+ verdict = answer.isAnswered ? (worker ? parseWorkerVerdict(answer.text) : parseEffortVerdict(answer.text)) : undefined
622
+ if (!verdict) failure ??= t(language, 'toasts.haikuNoVerdict')
623
+ } catch (error) { failure ??= reason(error) }
624
+ }
625
+ const ms = Math.max(0, await $.clock.now() - started)
626
+ await update($, stats, value => ({ ...value, judge: { ...value.judge, ms: value.judge.ms + ms } }))
627
+ if (!testing) {
628
+ // The log pane lists it: a Jev call never reaches the router, so nothing else would show it.
629
+ 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 } : {}) }
630
+ await update($, judgeRuns, value => [...value, run].slice(-40))
631
+ // A network blip or timeout is said only when it happens twice running; Haiku covers the one prompt.
632
+ jevBlips = blip ? jevBlips + 1 : 0
633
+ if (failure && !(blip && jevBlips < 2)) await down($, failure, blip)
634
+ else if (await read($, judgeDown)) await update($, judgeDown, () => null)
635
+ }
636
+ return { verdict, by, ms, ...(failure ? { error: failure } : {}) }
637
+ }
638
+ async function testJudge($: EngineInterface): Promise<void> {
639
+ const language = await read($, lang)
640
+ await update($, jev, value => ({ ...value, test: { state: 'testing' as const } }))
641
+ try {
642
+ const s = normalizeSettings(await read($, settingsDraft) ?? await read($, settings))
643
+ const judged = await judge($, s, { messages: [], next: 'Rename a local variable in one function and verify the result.' }, false, true)
644
+ const ok = !!judged.verdict && !judged.error
645
+ 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}` } }))
646
+ } catch (error) {
647
+ await update($, jev, value => ({ ...value, test: { state: 'fail' as const, message: reason(error) } }))
648
+ }
143
649
  }
144
650
 
145
- async function closeLog($: EngineInterface) {
146
- await update($, logOpen, () => false)
147
- await $.ui.close({ id: PANE })
651
+ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings): Promise<{ input: AgentSpawnInput; pick: WorkerPick } | { deny: string }> {
652
+ const language = await read($, lang)
653
+ const files = await workerFiles($)
654
+ try { await refreshModels($) } catch (error) { await report($, 'worker catalogue', error) }
655
+ // Claude's own models are candidates as well: general-purpose on the model, effort by turn.step.
656
+ const pinnedCandidates = [...[...files].flatMap(([agent, file]) => { const model = agentModel(file)?.replace(/@[^@]*$/, ''); const provider = agentProvider(file); return model && provider ? [{ target: { agent }, model, provider }] : [] }), ...claudeCandidates()]
657
+ const usable = await availability($)
658
+ const marker = readMarker(e.prompt)
659
+ const explicit = marker && !['auto', 'ripple'].includes(marker.name) ? marker : null
660
+ let verdict: WorkerVerdict = { domain: 'general', level: 'standard', effort: 'high', why: t(language, 'toasts.judgeUnavailable') }
661
+ let by: WorkerPick['by'] = 'default'
662
+ let ms = 0
663
+ let rawVerdict: WorkerVerdict | null = null
664
+ let judgeError: string | undefined
665
+ let target: TierTarget | undefined
666
+ if (explicit) {
667
+ // Agent names and their file-model ids are both valid explicit routing notes.
668
+ const name = files.has(explicit.name) ? explicit.name : [...files].find(([, f]) => agentModel(f)?.replace(/@[^@]*$/, '').toLowerCase() === explicit.name)?.[0]
669
+ target = name ? { agent: name } : { agent: 'general-purpose', model: explicit.name }
670
+ verdict = { domain: 'general', level: 'standard', effort: effortOf(explicit.level) ?? 'high', why: t(language, 'toasts.explicitMarker') }
671
+ by = 'marker'
672
+ } else {
673
+ const judged = await judge($, s, { description: e.description, prompt: e.prompt }, true)
674
+ if (judged.verdict && 'domain' in judged.verdict) { rawVerdict = judged.verdict; verdict = judged.verdict; by = judged.by }
675
+ ms = judged.ms
676
+ judgeError = judged.error
677
+ }
678
+ // Availability is request-time state, not the ten-minute panel catalogue. Refresh after the
679
+ // judge (which may itself take a while), before every pick, even when no mode is active.
680
+ await refreshLadder($)
681
+ const now = await $.clock.now()
682
+ const options = await read($, ladderOptions)
683
+ const providers = options && options.until > now ? Object.fromEntries(Object.entries(options.providers ?? {}).filter(([, p]) => p.exhaustedUntil === undefined || p.exhaustedUntil > now)) : {}
684
+ 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
685
+ if (explicitCandidate && s.providers[explicitCandidate.provider] === false) return { deny: t(language, 'workers.disabled', { provider: explicitCandidate.provider }) }
686
+ // An active mode moves the tiers; an explicit marker still names its own model.
687
+ const mode = activeModeOf(s)
688
+ const routing = routingSettings(s)
689
+ const resolved = explicitCandidate && candidateUsable(explicitCandidate, s, usable, providers)
690
+ ? { target: target!, effort: verdict.effort }
691
+ : ladderPick(verdict, routing.rules.general, routing, pinnedCandidates, usable, language, routing.allow, providers)
692
+ const selected = resolved.target
693
+ if (!selected) return { deny: t(language, 'workers.none') }
694
+ const fallbackReason = resolved.fallbackReason
695
+ verdict = { ...verdict, effort: resolved.effort }
696
+ const model = spawnModel(selected, verdict.effort, files.has(selected.agent) ? agentModel(files.get(selected.agent)!) : undefined)
697
+ let prompt = e.prompt
698
+ if (marker) prompt = prompt.replace(MARKER, '').replace(/^\s*\n/, '')
699
+ const value: WorkerPick = { ...verdict, ...resolved, target: selected, by, ms, ...(fallbackReason ? { fallbackReason } : {}) }
700
+ // The verdict behind a pick is otherwise only in the agents panel; one debug line per pick keeps
701
+ // it checkable: what the judge said, the rung it landed on and what ran.
702
+ 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 } : {}) }
703
+ await $.ui.log('ClaudeRipple worker pick: ' + JSON.stringify(entry), { to: 'debug' })
704
+ await persistWorkerPick($, { ...entry, at: new Date(now).toISOString(), verdict: rawVerdict, by: by === 'marker' || by === 'default' ? by : 'judge', judge: by === 'marker' || by === 'default' ? null : by, ms, ...(judgeError ? { error: judgeError } : {}) })
705
+ if (fallbackReason) await $.ui.toast(t(language, 'toasts.workerFallback', { reason: fallbackReason, name: targetLabel(selected, language) }))
706
+ await update($, lastSpawn, () => value)
707
+ await update($, stats, current => addWorker(current, verdict.level))
708
+ return { input: { ...e, subagentType: selected.agent, model, prompt }, pick: value }
148
709
  }
149
710
 
150
- async function toggleLog($: EngineInterface): Promise<boolean> {
151
- if (await isLogUp($)) {
152
- await closeLog($)
153
- return false
711
+ async function toggleAuto($: EngineInterface): Promise<void> {
712
+ const s = await currentSettings($)
713
+ await saveSettings($, { ...s, effortAuto: !s.effortAuto })
714
+ }
715
+ async function toggleWarm($: EngineInterface): Promise<void> {
716
+ const s = await currentSettings($)
717
+ await saveSettings($, { ...s, keepWarm: !s.keepWarm })
718
+ }
719
+ async function toggleSave($: EngineInterface): Promise<void> {
720
+ const now = await $.clock.now()
721
+ const active = (await read($, saveUntil) ?? 0) > now
722
+ const until = active ? null : limitSaveUntil(await read($, hot), now)
723
+ await update($, saveUntil, () => until)
724
+ }
725
+ async function manualPick($: EngineInterface, effort: Effort): Promise<void> {
726
+ const language = await read($, lang)
727
+ await saveSettings($, { ...await currentSettings($), effortAuto: false })
728
+ await installPick($, { effort, by: 'manual', why: t(language, 'toasts.yourPick'), at: await $.clock.now() })
729
+ const draft = await $.prompt.read()
730
+ if (!draft.text.trim() || /^\/effort\s+\w+\s*$/.test(draft.text)) {
731
+ await $.prompt.fill({ text: `/effort ${effort}`, mode: 'replace' })
732
+ await $.ui.toast(t(language, 'toasts.effortEnter'))
154
733
  }
155
- await openLog($)
156
- return true
734
+ }
735
+ // Only first-run setup has a draft; quick settings read the saved state directly.
736
+ async function startPanel($: EngineInterface, which: 'settings' | 'setup'): Promise<void> {
737
+ try { await settingsTask(async () => { await syncSettings($, await adminUrl($)) }) }
738
+ catch (error) { await report($, 'panel settings', error) }
739
+ const s = await currentSettings($)
740
+ await update($, settingsDraft, () => which === 'setup' ? normalizeSettings(s) : null)
741
+ await update($, settingsBase, () => which === 'setup' ? normalizeSettings(s) : null)
742
+ if (which === 'setup') await update($, setupStep, () => 1)
743
+ await update($, panel, () => which)
744
+ await jevKey($)
745
+ if (which === 'settings') await refreshLadder($)
746
+ }
747
+ /**
748
+ * An open panel's draft follows changes saved elsewhere (another session, the web page): the
749
+ * person's own edits (draft against base) are replayed on the settings now held, and the base
750
+ * moves to them. Without a base (a draft from an older mod) the draft starts over from them.
751
+ */
752
+ async function rebaseDraft($: EngineInterface): Promise<void> {
753
+ if (await read($, panel) !== 'setup') return
754
+ const draft = await read($, settingsDraft)
755
+ if (!draft) return
756
+ const fresh = await currentSettings($)
757
+ const base = await read($, settingsBase)
758
+ const next = base ? applySettingsChanges(fresh, settingsChanges(normalizeSettings(base), normalizeSettings(draft))) : normalizeSettings(fresh)
759
+ if (base && JSON.stringify(normalizeSettings(base)) === JSON.stringify(fresh)) return
760
+ await update($, settingsBase, () => normalizeSettings(fresh))
761
+ if (JSON.stringify(next) !== JSON.stringify(normalizeSettings(draft))) await update($, settingsDraft, () => next)
762
+ }
763
+ async function patchDraft($: EngineInterface, change: Partial<Settings>): Promise<void> {
764
+ const current = normalizeSettings(await read($, settingsDraft) ?? await read($, settings))
765
+ await update($, settingsDraft, () => normalizeSettings({ ...current, ...change }))
766
+ if (change.jevSource) { await update($, jev, value => ({ ...value, test: { state: 'idle' as const } })); await jevKey($, change.jevSource) }
767
+ }
768
+ async function finishSetup($: EngineInterface, skip = false): Promise<void> {
769
+ // A field whose rule picks as the main choice does is stored as no exception.
770
+ const draft = normalizeSettings(skip ? await read($, settings) : await read($, settingsDraft))
771
+ const saved = await saveSettings($, { ...draft, setupDone: true }, skip ? undefined : normalizeSettings(await read($, settingsBase) ?? await currentSettings($)))
772
+ await update($, panel, () => null)
773
+ await update($, settingsDraft, () => null)
774
+ await update($, settingsBase, () => null)
775
+ await $.ui.toast(t(await read($, lang), saved ? 'toasts.settingsSaved' : 'band.routerDown'))
776
+ }
777
+ async function openHandoff($: EngineInterface): Promise<void> {
778
+ const active = await read($, handoff)
779
+ if (active && ['writing', 'clearing'].includes(active.stage)) return
780
+ const choice = object(await $.store.get('handoffChoice'))
781
+ await update($, handoffChoiceKind, () => choice.kind === 'full' ? 'full' : 'quick')
782
+ const after = ['continue', 'confirm', 'copy'].includes(String(choice.after)) ? choice.after as Settings['handoffAfter'] : (await currentSettings($)).handoffAfter
783
+ await update($, handoffChoiceAfter, () => after)
784
+ await update($, panel, value => value === 'handoff' ? null : 'handoff')
785
+ }
786
+ async function queueHandoff($: EngineInterface, kind?: 'quick' | 'full'): Promise<void> {
787
+ const active = await read($, handoff)
788
+ if (active && ['writing', 'clearing'].includes(active.stage)) return
789
+ const selected = kind ?? await read($, handoffChoiceKind)
790
+ const after = kind ? (await currentSettings($)).handoffAfter : await read($, handoffChoiceAfter)
791
+ await update($, handoffChoiceKind, () => selected)
792
+ await update($, handoffChoiceAfter, () => after)
793
+ await $.store.set('handoffChoice', { kind: selected, after })
794
+ await update($, handoff, () => ({ stage: 'writing', kind: selected }))
795
+ await update($, panel, () => null)
796
+ await update($, advice, () => null)
797
+ await update($, handoffQueued, () => true)
798
+ }
799
+ async function copyHandoff($: EngineInterface): Promise<void> {
800
+ const language = await read($, lang)
801
+ const value = await read($, handoff)
802
+ if (!value?.text) return
803
+ const text = wrapForNewChat(value.text, 'continue', value.kind, !!(await currentSettings($)).handoffSkill)
804
+ try {
805
+ const copied = await $.ui.copy({ text })
806
+ if (!copied.isCopied) throw new Error(copied.reason)
807
+ } catch (error) {
808
+ await $.prompt.fill({ text, mode: 'replace' })
809
+ await $.ui.toast(t(language, 'toasts.clipboardFallback'))
810
+ await report($, 'copy fallback', error)
811
+ }
812
+ await update($, handoff, () => ({ ...value, stage: 'copied' }))
813
+ }
814
+ /**
815
+ * The summary is written: Copy only copies it; Confirm first stops on a card that asks before
816
+ * moving; Continue moves to a new session at once. This chat is never cleared (2026-10-13: the
817
+ * owner read "Confirm first" as asking before the clear, and lost the chat to it).
818
+ */
819
+ async function finishHandoff($: EngineInterface): Promise<void> {
820
+ const value = await read($, handoff)
821
+ if (!value?.text || value.stage !== 'writing') return
822
+ const after = await read($, handoffChoiceAfter)
823
+ if (after === 'copy') { await copyHandoff($); return }
824
+ if (after === 'confirm') { await update($, handoff, () => ({ ...value, stage: 'ready' })); return }
825
+ await moveToNewSession($)
826
+ }
827
+ /** A summary longer than this goes to a file the new session reads; the app takes ~14k characters in a link. */
828
+ const LINK_TEXT = 12_000
829
+ /**
830
+ * Opens a new Code session in this session's folder with the handoff in its prompt box
831
+ * (claude://code/new?folder=…&q=…, the desktop app's own link: it fills the box and waits for
832
+ * Enter, never sends by itself). A long summary is saved under ~/.clauderipple/handoffs and the box
833
+ * says to read it. Where no link can be opened, the text is copied instead.
834
+ */
835
+ async function moveToNewSession($: EngineInterface): Promise<void> {
836
+ const value = await read($, handoff)
837
+ if (!value?.text) return
838
+ const language = await read($, lang)
839
+ let text = wrapForNewChat(value.text, 'continue', value.kind, !!(await currentSettings($)).handoffSkill)
840
+ try {
841
+ if (text.length > LINK_TEXT) {
842
+ const path = `${await rippleHome($)}/handoffs/handoff-${await $.clock.now()}.md`
843
+ await $.fs.write(path, text + '\n')
844
+ text = t(language, 'alerts.readHandoffFile', { path })
845
+ }
846
+ const folder = await $.session.cwd()
847
+ const url = 'claude://code/new?' + new URLSearchParams({ ...(folder ? { folder } : {}), q: text, source: 'services' }).toString()
848
+ await openUrl($, url)
849
+ await update($, handoff, () => ({ ...value, stage: 'opened' }))
850
+ } catch (error) {
851
+ await report($, 'open new session', error)
852
+ await copyHandoff($)
853
+ await $.ui.toast(t(language, 'toasts.newSessionFallback'))
854
+ }
855
+ }
856
+ async function fork($: EngineInterface, prompt: string): Promise<ModelForkResult | null> {
857
+ const epoch = generation
858
+ forking = true
859
+ forkUsage = null
860
+ try {
861
+ const answer = await $.model.fork({ prompt })
862
+ // Real forks dispatch turn.step; test stubs and older engines can provide only the result.
863
+ const usage = answer && 'usage' in answer ? answer.usage : forkUsage
864
+ if (usage && epoch === generation) {
865
+ await update($, stats, value => addWarm(value, usage))
866
+ if ((usage.cache_read_input_tokens ?? 0) > 0) {
867
+ const memo = nextMemo(await read($, cache), usage, await $.clock.now())
868
+ await update($, cache, () => memo)
869
+ }
870
+ }
871
+ return answer
872
+ } finally { forking = false; forkUsage = null }
873
+ }
874
+ async function driveHandoff($: EngineInterface): Promise<void> {
875
+ const language = await read($, lang)
876
+ const value = await read($, handoff)
877
+ if (!value || value.stage !== 'writing' || await read($, busy)) return
878
+ if (!value.text && !await read($, handoffQueued)) return
879
+ try {
880
+ if (value.text) { await finishHandoff($); return }
881
+ await update($, handoffQueued, () => false)
882
+ if (value.kind === 'quick') {
883
+ let answer: ModelForkResult | null = null
884
+ try { answer = await fork($, QUICK_PROMPT) }
885
+ catch (error) { await report($, 'quick fork fallback', error) }
886
+ if (answer?.isAnswered && answer.text.trim()) {
887
+ await update($, handoff, () => ({ ...value, text: answer.text }))
888
+ await finishHandoff($)
889
+ return
890
+ }
891
+ }
892
+ await update($, handoffWaiting, () => true)
893
+ const s = await currentSettings($)
894
+ if (value.kind === 'full' && s.handoffSkill) {
895
+ const [command, ...args] = s.handoffSkill.split(/\s+/)
896
+ await $.command.run({ command: command!, args: args.join(' ') })
897
+ } else await $.prompt.submit({ text: value.kind === 'full' ? FULL_PROMPT : QUICK_PROMPT })
898
+ } catch (error) {
899
+ await update($, handoff, () => ({ ...value, stage: 'failed' }))
900
+ await update($, handoffWaiting, () => false)
901
+ await $.ui.toast(t(language, 'toasts.handoffFailed'))
902
+ await report($, 'handoff', error)
903
+ }
904
+ }
905
+ async function runCompact($: EngineInterface, note = ''): Promise<void> {
906
+ const language = await read($, lang)
907
+ await update($, panel, () => null)
908
+ await update($, advice, () => null)
909
+ await update($, resultCard, () => ({ stage: 'compacting' }))
910
+ try {
911
+ await $.command.run({ command: 'compact', args: note })
912
+ await update($, resultCard, () => ({ stage: 'compacted' }))
913
+ } catch (error) {
914
+ await update($, resultCard, () => ({ stage: 'failed' as const, detail: t(language, 'alerts.compactFailed') }))
915
+ await $.ui.toast(t(language, 'toasts.compactFailed'))
916
+ await report($, 'compact command', error)
917
+ }
918
+ }
919
+ async function openCompact($: EngineInterface, surface: Frame['surface']): Promise<void> {
920
+ if (surface === 'terminal') { await runCompact($); return }
921
+ await update($, compactNote, () => '')
922
+ await update($, panel, () => 'compact')
923
+ }
924
+ async function refreshUsage($: EngineInterface): Promise<void> {
925
+ const usage = await $.session.usage()
926
+ const c = usage.context
927
+ const percent = c.percent ?? (c.tokens !== undefined && c.window > 0 ? c.tokens / c.window * 100 : undefined)
928
+ const ctx = c.tokens !== undefined && percent !== undefined ? { tokens: c.tokens, percent, window: c.window } : null
929
+ if (JSON.stringify(ctx) !== JSON.stringify(await read($, context))) await update($, context, () => ctx)
930
+ // Below the line again (a compact, a clear), the next crossing shows the heavy-context band once.
931
+ const putOff = await read($, swampHidden)
932
+ if (putOff !== null && (putOff > 100 || !ctx || ctx.percent < (await currentSettings($)).swampAt)) await update($, swampHidden, () => null)
933
+ const h = hottest(usage.rateLimits)
934
+ const previous = await read($, hot)
935
+ if (previous && (!h || previous.kind !== h.kind || previous.resetsAt !== h.resetsAt)) await update($, hotHidden, () => null)
936
+ if (JSON.stringify(h) !== JSON.stringify(previous)) await update($, hot, () => h)
937
+ const top = usage.rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
938
+ const l = top ? { kind: top.kind, percent: top.percentUsed, ...(top.resetsAt ? { resetsAt: top.resetsAt } : {}) } : null
939
+ // Check the previous binding deadline before a rolled-forward snapshot replaces it.
940
+ await endModeIfOver($)
941
+ if (JSON.stringify(l) !== JSON.stringify(await read($, limit))) await update($, limit, () => l)
942
+ await endModeIfOver($)
943
+ const until = await read($, saveUntil)
944
+ if (until !== null && until <= await $.clock.now()) await update($, saveUntil, () => null)
945
+ }
946
+ /**
947
+ * Redraws when the countdown's shown minute changes and at expiry (the cold alert), at no other
948
+ * time: the desktop draws each answer afresh (see ui/band.tsx), so a redraw that shows nothing new
949
+ * is wasted at best. With the timer not on show, only the expiry. The 15-second tick re-aims it
950
+ * after a reply moved the expiry.
951
+ */
952
+ async function scheduleCountdown($: EngineInterface): Promise<void> {
953
+ const memo = await read($, cache)
954
+ const now = await $.clock.now()
955
+ const shown = !(await currentSettings($)).hide.includes('timer') && !await read($, bandHidden)
956
+ const at = !memo || memo.expiresAt <= now ? null : shown ? nextMinuteChange(memo.expiresAt, now) : memo.expiresAt
957
+ if (countdownTimer?.at === at) return
958
+ countdownTimer?.cancel()
959
+ countdownTimer = null
960
+ if (at === null) return
961
+ const timer = $.clock.after(Math.max(1, at - now), () => {
962
+ countdownTimer = null
963
+ void (async () => {
964
+ await $.ui.invalidate('ui.render')
965
+ await scheduleCountdown($)
966
+ })().catch(error => report($, 'countdown', error))
967
+ })
968
+ countdownTimer = { at, cancel: () => timer.cancel() }
969
+ }
970
+ async function tick($: EngineInterface): Promise<void> {
971
+ const language = await read($, lang)
972
+ if (ticking) return
973
+ ticking = true
974
+ try {
975
+ await refreshUsage($)
976
+ const now = await $.clock.now()
977
+ const list = await read($, agents)
978
+ if (list.some(a => a.state === 'running' && a.toolSince !== undefined && now - a.toolSince >= 8000)) {
979
+ await update($, agents, value => value.map(a => a.state === 'running' && a.toolSince !== undefined && now - a.toolSince >= 8000 ? { ...a, state: 'waiting' } : a))
980
+ }
981
+ await scheduleCountdown($)
982
+ if (await read($, handoff)) await driveHandoff($)
983
+ const queued = await read($, adviceQueue)
984
+ if (queued && !await read($, busy)) {
985
+ await update($, adviceQueue, () => null)
986
+ const answer = await $.model.complete({ model: HAIKU, system: ADVICE_SYSTEM, prompt: queued, effort: 'low', maxTokens: 60, timeoutMs: 8000 })
987
+ if (answer.isAnswered) await update($, advice, () => parseAdvice(answer.text))
988
+ }
989
+ const s = await currentSettings($)
990
+ 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,
991
+ contextTokens: (await read($, context))?.tokens ?? 0, lastRealReplyAt: await read($, lastRealReplyAt) })
992
+ if (decision.ping) {
993
+ let answer: ModelForkResult | null = null
994
+ try { answer = await fork($, 'Reply with just: ok') }
995
+ catch (error) { await report($, 'warm fork', error) }
996
+ const before = await read($, warm)
997
+ const after = afterPing(before, answer, await $.clock.now(), language)
998
+ await update($, warm, () => after)
999
+ if (after.stopped && !before.stopped) await $.ui.toast(t(language, 'toasts.warmStopped', { reason: after.stopped }))
1000
+ } else if (s.keepWarm && !await read($, busy) && decision.reason === 'keep warm time bound reached' && !(await read($, warm)).stopped) {
1001
+ await update($, warm, value => ({ ...value, stopped: t(language, 'toasts.warmBoundReason') }))
1002
+ await $.ui.toast(t(language, 'toasts.warmBound'))
1003
+ }
1004
+ } catch (error) { await report($, 'session tick', error) }
1005
+ finally { ticking = false }
1006
+ }
1007
+ function startTimers($: EngineInterface): void {
1008
+ if (timersStarted) return
1009
+ timersStarted = true
1010
+ $.clock.after(1, async () => { await refreshRouter($); await tick($) })
1011
+ $.clock.every(3000, async () => { await refreshRouter($) })
1012
+ $.clock.every(15000, async () => { await tick($) })
157
1013
  }
158
1014
 
159
- async function hideBand($: EngineInterface) {
160
- await update($, bandHidden, () => true)
161
- await $.store.set(BAND_HIDDEN_KEY, true)
1015
+ async function dashboard($: EngineInterface, working: boolean): Promise<DashboardView> {
1016
+ const s = await currentSettings($)
1017
+ const p = await read($, pick)
1018
+ const list = await read($, agents)
1019
+ const record = await read($, last)
1020
+ const saving = (await read($, saveUntil) ?? 0) > await $.clock.now()
1021
+ const l = await read($, limit)
1022
+ const w = await read($, warm)
1023
+ return {
1024
+ effort: { level: p?.effort ?? effortOf(await read($, appEffort)) ?? null,
1025
+ mode: await read($, deciding) ? 'deciding' : await read($, paused) ? 'paused' : p?.by === 'manual' ? 'manual' : s.effortAuto ? 'auto' : 'off',
1026
+ ...(p?.why ? { why: p.why, by: p.by } : {}), ...(await read($, decidingSince) !== null ? { since: (await read($, decidingSince))! } : {}) },
1027
+ model: await read($, modelIs) ? modelName((await read($, modelIs))!) : null,
1028
+ context: await read($, context), cache: await read($, cache), warm: { ...w, on: s.keepWarm },
1029
+ limit: l ? { ...l, save: saving } : null, route: record ? routeOf(record) : null, notice: await read($, line),
1030
+ auto: s.effortAuto, handoff: await read($, advice) ? 'advised' : s.handoffButton === 'always' ? 'always' : 'off',
1031
+ agents: { live: list.filter(a => ['running', 'waiting'].includes(a.state)).length, total: list.length },
1032
+ logOpen: await read($, logOpen), agentsOpen: await read($, agentsOpen), effortMenu: await read($, effortMenuOpen),
1033
+ swampAt: s.swampAt, hide: s.hide, extras: s.extras, isWorking: working,
1034
+ ...(activeModeOf(s) ? { mode: activeModeOf(s)!.name } : {}),
1035
+ }
1036
+ }
1037
+ async function settingsView($: EngineInterface): Promise<SettingsView> {
1038
+ return { settings: await currentSettings($), jev: await read($, jev), ladder: await read($, ladderOptions) }
1039
+ }
1040
+ async function frameOf($: EngineInterface, e: RenderInput): Promise<Frame> {
1041
+ const s = await currentSettings($)
1042
+ const columns = 'bodyColumns' in e.props ? e.props.bodyColumns : e.viewport?.columns ?? 100
1043
+ const maxRows = 'maxRows' in e.props ? e.props.maxRows : 'scroll' in e.props ? e.props.scroll.bodyRows : 1
1044
+ return { surface: e.surface, columns, maxRows, now: await $.clock.now(), appearance: s.appearance, lang: await read($, lang) }
1045
+ }
1046
+ async function alertView($: EngineInterface, now: number): Promise<AlertView | null> {
1047
+ const language = await read($, lang)
1048
+ const s = await currentSettings($)
1049
+ const c = await read($, context)
1050
+ const m = await read($, cache)
1051
+ const h = await read($, hot)
1052
+ const writing = await read($, handoff)
1053
+ const result = await read($, resultCard)
1054
+ const downReason = await read($, judgeDown)
1055
+ const d = await read($, demo)
1056
+ const which = bandPriority({ panel: await read($, panel), judgeDown: d === 'down' || (!!downReason && downReason !== await read($, judgeDownHidden)),
1057
+ hot: d === 'hot' || hotAlert(h, await read($, hotHidden)), result: !!writing || !!result,
1058
+ cold: d === 'cold' || coldAlert(m, c?.tokens ?? 0, now, await read($, coldHidden)),
1059
+ swamp: d === 'swamp' || (s.swampAlert && !await read($, busy) && swampAlert(swamped(c, s.swampAt), await read($, swampHidden))) })
1060
+ if (which === 'compact') return { kind: 'compactNote', draft: await read($, compactNote) }
1061
+ if (which === 'handoff') return { kind: 'handoffChoice', handoffKind: await read($, handoffChoiceKind), after: await read($, handoffChoiceAfter) }
1062
+ if (which === 'judgeDown') return { kind: 'judgeDown', judge: s.judge, reason: d === 'down' ? t(language, 'alerts.demoDown') : downReason!, network: d !== 'down' && await read($, judgeDownNetwork) }
1063
+ if (which === 'hot') return { kind: 'hot', limitKind: h?.kind ?? 'five_hour', percent: d === 'hot' ? 86 : h!.percent, resetsAt: h?.resetsAt, save: (await read($, saveUntil) ?? 0) > now }
1064
+ if (which === 'result') {
1065
+ if (!writing) return { kind: 'result', ...result! }
1066
+ const stage = ['writing', 'clearing'].includes(writing.stage) ? 'handingOff' : writing.stage === 'failed' ? 'failed' : writing.stage === 'ready' ? 'handoffReady' : 'handoffDone'
1067
+ const detail = writing.stage === 'copied' ? t(language, 'alerts.copied') : writing.stage === 'opened' ? t(language, 'alerts.openedBody') : undefined
1068
+ return { kind: 'result', stage, handoffKind: writing.kind, ...(detail ? { detail } : {}) }
1069
+ }
1070
+ if (which === 'cold') return { kind: 'cold', tokens: d === 'cold' ? 180000 : c!.tokens, idleMinutes: m ? Math.round((now - m.lastReplyAt) / 60000) : 61 }
1071
+ if (which === 'swamp') return { kind: 'swamp', percent: d === 'swamp' ? 65 : c!.percent, tokens: d === 'swamp' ? 160000 : c!.tokens }
1072
+ return null
1073
+ }
1074
+ /**
1075
+ * A prompt sent while the cold, heavy or hot band shows counts as having seen it: it comes back
1076
+ * only on its own next occasion (the next expiry, 15 more points of context, 10 more of the plan
1077
+ * window), never after every message.
1078
+ */
1079
+ async function markSeen($: EngineInterface): Promise<void> {
1080
+ const view = await alertView($, await $.clock.now())
1081
+ if (view && (view.kind === 'cold' || view.kind === 'swamp' || view.kind === 'hot')) await dismiss($, view)
1082
+ }
1083
+ async function dismiss($: EngineInterface, view: AlertView): Promise<void> {
1084
+ await update($, demo, () => null)
1085
+ if (view.kind === 'hot') await update($, hotHidden, () => view.percent)
1086
+ else if (view.kind === 'cold') await update($, coldHidden, () => true)
1087
+ else if (view.kind === 'swamp') await update($, swampHidden, () => view.percent)
1088
+ else if (view.kind === 'judgeDown') await update($, judgeDownHidden, () => view.reason)
1089
+ else if (view.kind === 'result') { await update($, resultCard, () => null); await update($, handoff, () => null) }
1090
+ else await update($, panel, () => null)
1091
+ }
1092
+ function bandActions($: EngineInterface, surface: Frame['surface']): BandActions {
1093
+ return { toggleAuto: () => toggleAuto($), toggleWarm: () => toggleWarm($), compact: () => openCompact($, surface), handoff: () => openHandoff($), toggleLog: () => toggleLog($), toggleAgents: () => toggleAgents($), openSettings: () => startPanel($, 'settings'),
1094
+ toggleEffortMenu: () => update($, effortMenuOpen, value => !value), pickEffort: effort => manualPick($, effort) }
1095
+ }
1096
+ function settingsActions($: EngineInterface): SettingsActions {
1097
+ return { patch: async change => {
1098
+ const s = await currentSettings($)
1099
+ const saved = await saveSettings($, { ...s, ...change }, s)
1100
+ if (change.judge || change.jevSource) await jevKey($)
1101
+ if (!saved) await $.ui.toast(t(await read($, lang), 'band.routerDown'))
1102
+ },
1103
+ jevKey: async key => { try { await saveJevKey($, key) } catch (error) { await $.ui.toast(t(await read($, lang), 'toasts.error', { reason: reason(error) })) } }, testJev: () => testJudge($),
1104
+ setMode: id => setMode($, id),
1105
+ openAllSettings: () => openAllSettings($), close: async () => { await update($, panel, () => null); await update($, settingsDraft, () => null); await update($, settingsBase, () => null) } }
1106
+ }
1107
+ function setupActions($: EngineInterface): SetupActions {
1108
+ const a = settingsActions($)
1109
+ return { patch: change => patchDraft($, change), jevKey: a.jevKey, testJev: a.testJev, openAllSettings: a.openAllSettings,
1110
+ back: () => update($, setupStep, step => Math.max(1, step - 1) as 1 | 2 | 3 | 4), next: () => update($, setupStep, step => Math.min(4, step + 1) as 1 | 2 | 3 | 4), finish: () => finishSetup($), skip: () => finishSetup($, true) }
1111
+ }
1112
+ function alertActions($: EngineInterface, view: AlertView): AlertActions {
1113
+ return { compact: note => runCompact($, note), handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
1114
+ noteInput: text => update($, compactNote, () => text), setHandoffKind: kind => update($, handoffChoiceKind, () => kind), setHandoffAfter: after => update($, handoffChoiceAfter, () => after), go: () => queueHandoff($), copy: () => copyHandoff($), openNew: () => moveToNewSession($) }
1115
+ }
1116
+ function toolLine(e: ToolCallInput): string {
1117
+ const fields = object(e)
1118
+ const detail = fields.command ?? fields.file_path ?? fields.pattern ?? fields.query ?? fields.url ?? fields.description ?? ''
1119
+ return `${e.tool} ${String(detail).split('\n')[0]}`.trim().slice(0, 70)
1120
+ }
1121
+ async function prepareStep($: EngineInterface, e: TurnStepInput): Promise<TurnStepInput> {
1122
+ const language = await read($, lang)
1123
+ if (e.agentId) {
1124
+ const a = (await read($, agents)).find(a => a.id === e.agentId)
1125
+ return a?.pick && a.pick.target.agent === 'general-purpose' ? { ...e, effort: a.pick.effort } : e
1126
+ }
1127
+ const previousModel = await read($, modelIs)
1128
+ const oldEffort = await read($, appEffort)
1129
+ const changedModel = previousModel !== e.model
1130
+ if (changedModel) {
1131
+ await update($, modelIs, () => e.model)
1132
+ await update($, appEffort, () => e.effort ?? null)
1133
+ if (previousModel !== null) {
1134
+ await update($, cache, () => null)
1135
+ await update($, lastRealReplyAt, () => null)
1136
+ }
1137
+ }
1138
+ const safe = isCacheSafeModel(e.model)
1139
+ if (await read($, paused) !== !safe) await update($, paused, () => !safe)
1140
+ let s = await currentSettings($)
1141
+ if ((changedModel || oldEffort === null) && carriedPick) {
1142
+ carriedPick = false
1143
+ const level = effortOf(e.effort)
1144
+ const carried = await read($, pick)
1145
+ if (!s.effortAuto && carried?.by === 'manual' && level && carried.effort !== level) {
1146
+ await installPick($, { effort: level, by: 'manual', why: t(language, 'toasts.appEffort'), at: await $.clock.now() })
1147
+ }
1148
+ }
1149
+ if (!changedModel && oldEffort !== null && e.effort !== undefined && oldEffort !== e.effort) {
1150
+ await update($, appEffort, () => e.effort ?? null)
1151
+ await saveSettings($, { ...s, effortAuto: false })
1152
+ const level = effortOf(e.effort)
1153
+ if (level) await installPick($, { effort: level, by: 'manual', why: t(language, 'toasts.appEffortChanged'), at: await $.clock.now() })
1154
+ await $.ui.toast(t(language, 'toasts.autoOff'))
1155
+ return e
1156
+ }
1157
+ if (!changedModel && oldEffort === null && e.effort !== undefined) await update($, appEffort, () => e.effort!)
1158
+ s = await currentSettings($)
1159
+ const p = await read($, pick)
1160
+ if (!await read($, busy)) await update($, busy, () => true)
1161
+ if (!safe || !p || (!s.effortAuto && p.by !== 'manual')) return e
1162
+ const saving = s.effortAuto && (await read($, saveUntil) ?? 0) > await $.clock.now()
1163
+ return { ...e, effort: saving && EFFORTS.indexOf(p.effort) > EFFORTS.indexOf('medium') ? 'medium' : p.effort }
162
1164
  }
163
1165
 
164
1166
  export const register: Register = on => {
165
1167
  on('session.start', async ($, e, next) => {
166
1168
  const result = await next(e)
167
- const session = await $.session.id()
168
- // An earlier version of this mod pinned a status line; this one draws nothing under the prompt.
169
- $.ui.status(undefined)
170
- const hidden = (await $.store.get(BAND_HIDDEN_KEY)) === true
171
- await update($, bandHidden, () => hidden)
172
- await $.command.register({ name: 'ripple-log', description: 'Open or close the ClaudeRipple request log' })
173
- await $.command.register({ name: 'ripple-bar', description: 'Show or hide the ClaudeRipple line above the prompt' })
174
- const ADMIN = await adminUrl($)
175
-
176
- const refresh = async () => {
177
- const isOpen = await isLogUp($)
178
- if (isOpen !== (await read($, logOpen))) await update($, logOpen, () => isOpen)
179
- if (!isOpen && (await read($, bandHidden))) return
180
- try {
181
- const own = await $.http.fetch(`${ADMIN}/api/requests?kind=messages&n=${isOpen ? LOG_ROWS : 1}&session=${encodeURIComponent(session)}`)
182
- const mine = (JSON.parse(own.text) as { requests?: StatusRequest[] }).requests ?? []
183
- const newest = mine[0] ?? null
184
- // A router older than the session field answers every session's requests, which would show
185
- // another conversation's route here: say so rather than show it.
186
- const notice = newest && newest.session === undefined ? 'ClaudeRipple: restart the router to show routes here' : null
187
- if (notice !== (await read($, line))) await update($, line, () => notice)
188
- if (JSON.stringify(newest) !== JSON.stringify(await read($, last))) await update($, last, () => newest)
189
- if (!isOpen) return
190
- const shown = (await read($, onlySession))
191
- ? mine
192
- : (JSON.parse((await $.http.fetch(`${ADMIN}/api/requests?kind=messages&n=${LOG_ROWS}`)).text) as { requests?: StatusRequest[] }).requests ?? []
193
- await update($, requests, () => shown.map(record => ({ ...record, mine: record.session === session })))
194
- } catch {
195
- await update($, line, () => 'ClaudeRipple: router not answering')
196
- }
197
- }
198
- refreshNow = refresh
199
-
200
- $.clock.after(1, () => void refresh())
201
- $.clock.every(EVERY_MS, () => void refresh())
1169
+ jevBlips = 0
1170
+ try {
1171
+ await loadSettings($)
1172
+ const language = await read($, lang)
1173
+ await $.ui.status(undefined)
1174
+ await $.command.register({ name: 'ripple', description: t(language, 'commands.description'), argumentHint: t(language, 'commands.argumentHint'), immediate: true })
1175
+ await $.command.register({ name: 'ripple-log', description: t(language, 'commands.logDescription'), immediate: true })
1176
+ await $.command.register({ name: 'ripple-bar', description: t(language, 'commands.barDescription'), immediate: true })
1177
+ // loadSettings skips the install when an earlier poll already took this revision, and that
1178
+ // poll may have run before the session bound: register here, where it is bound.
1179
+ await registerAuto($, await currentSettings($))
1180
+ startTimers($)
1181
+ // Setup keeps its draft across a reload; quick settings never keep one.
1182
+ const open = await read($, panel)
1183
+ if (open === 'setup') await rebaseDraft($)
1184
+ if (open === 'settings') { await update($, settingsDraft, () => null); await update($, settingsBase, () => null); await jevKey($) }
1185
+ if (!(await currentSettings($)).setupDone && !open) await startPanel($, 'setup')
1186
+ } catch (error) { await report($, 'session.start', error) }
202
1187
  return result
203
1188
  })
204
-
205
- // A worker runs on its own agent file's model, at the level its marker asks for wherever the
206
- // marker sits (./worker.ts). A fork inherits the parent's model and is not a worker spawn.
1189
+ on('classic.SessionStart', async ($, e, next) => {
1190
+ try {
1191
+ if (['clear', 'resume', 'fork'].includes(e.source)) {
1192
+ generation++
1193
+ judgeSequence++
1194
+ await loadSettings($)
1195
+ await update($, busy, () => false)
1196
+ await update($, appEffort, () => null)
1197
+ await update($, heldPick, () => null)
1198
+ await update($, deciding, () => false)
1199
+ if (e.source === 'clear') {
1200
+ await update($, cache, () => null)
1201
+ await update($, context, () => null)
1202
+ await update($, lastRealReplyAt, () => null)
1203
+ }
1204
+ if (e.model) await update($, modelIs, () => e.model!)
1205
+ await update($, paused, () => e.model ? !isCacheSafeModel(e.model) : false)
1206
+ }
1207
+ if (['resume', 'fork'].includes(e.source)) {
1208
+ const memo = restoreFromResume(e.seconds_since_last_response, e.prompt_cache_likely_expired, (await read($, cache))?.ttlMs ?? 3600000, await $.clock.now())
1209
+ await update($, cache, () => memo)
1210
+ await update($, lastRealReplyAt, () => memo?.lastReplyAt ?? null)
1211
+ }
1212
+ startTimers($)
1213
+ } catch (error) { await report($, 'classic.SessionStart', error) }
1214
+ return next(e)
1215
+ })
1216
+ on('prompt.submit', async ($, e, next) => {
1217
+ const sequence = ++judgeSequence
1218
+ // The eager mode's note, beside this prompt only (modes.ts eagerNote).
1219
+ let note: string | undefined
1220
+ try {
1221
+ const s = await currentSettings($)
1222
+ const model = await $.session.model()
1223
+ if (await read($, paused) !== !isCacheSafeModel(model)) await update($, paused, () => !isCacheSafeModel(model))
1224
+ const messages = await $.session.messages()
1225
+ const current = await read($, pick)
1226
+ const assistant = messages.findLast(m => m.role === 'assistant')?.text.slice(-2000) ?? ''
1227
+ let hostCommand = false
1228
+ if (e.text.trim().startsWith('/')) {
1229
+ const name = e.text.trim().split(/\s+/)[0]!.slice(1)
1230
+ const command = (await $.command.list()).find(c => c.name === name)
1231
+ hostCommand = !command || command.source === 'builtin' || name.startsWith('ripple')
1232
+ }
1233
+ const human = ['composer', 'bridge', 'sdk'].includes(e.origin.kind)
1234
+ if (human && !hostCommand) {
1235
+ await markSeen($)
1236
+ await endModeIfOver($)
1237
+ const noted = await read($, modeNoted)
1238
+ const eager = eagerNote(await currentSettings($), noted)
1239
+ if (eager.noted !== noted) await update($, modeNoted, () => eager.noted)
1240
+ note = eager.note
1241
+ }
1242
+ if (human && !hostCommand && (e.text.trim() || e.attachments?.length) && (!isShortFollowUp(e.text) || !!e.attachments?.length)) {
1243
+ const count = await read($, promptCount) + 1
1244
+ await update($, promptCount, () => count)
1245
+ const c = await read($, context)
1246
+ if (c && adviceDue(count, c.percent)) {
1247
+ const warmCache = ((await read($, cache))?.expiresAt ?? 0) > await $.clock.now()
1248
+ await update($, adviceQueue, () => adviceInput({ messages, next: e.text, contextPercent: c.percent, warm: warmCache }))
1249
+ }
1250
+ }
1251
+ if (shouldJudge({ text: e.text, origin: e.origin.kind, auto: s.effortAuto, modelId: model, hostCommand, current, attachments: e.attachments, assistantTail: assistant })) {
1252
+ const now = await $.clock.now()
1253
+ await update($, decidingSince, () => now)
1254
+ await update($, deciding, () => true)
1255
+ const c = await read($, context)
1256
+ const input: JudgeInput = { messages, next: e.text, attachments: e.attachments, current: { model, effort: current?.effort ?? effortOf(await read($, appEffort)) ?? 'medium' }, contextPercent: c?.percent, midTurn: !!e.turnId }
1257
+ const judged = await judge($, s, input, false)
1258
+ if (sequence === judgeSequence && (await currentSettings($)).effortAuto && judged.verdict) {
1259
+ const final = finalEffort(judged.verdict, s, (await read($, saveUntil) ?? 0) > await $.clock.now(), await read($, lang))
1260
+ const value: Pick = { ...judged.verdict, ...final, by: judged.by, at: await $.clock.now() }
1261
+ if (e.turnId && current && EFFORTS.indexOf(value.effort) < EFFORTS.indexOf(current.effort)) await update($, heldPick, () => value)
1262
+ else { await update($, heldPick, () => null); await installPick($, value) }
1263
+ await update($, stats, old => addPrompt(old, value.effort))
1264
+ }
1265
+ } else if (human && !hostCommand && s.effortAuto && current && isCacheSafeModel(model)) {
1266
+ await update($, stats, old => addPrompt(old, current.effort))
1267
+ }
1268
+ if (!hostCommand && !await read($, busy)) await update($, busy, () => true)
1269
+ } catch (error) { await report($, 'prompt.submit', error) }
1270
+ finally { if (sequence === judgeSequence && await read($, deciding)) await update($, deciding, () => false) }
1271
+ return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
1272
+ })
1273
+ on('turn.step', async function* ($, e, next) {
1274
+ const ownFork = forking && !e.agentId
1275
+ const epoch = generation
1276
+ let request = e
1277
+ try {
1278
+ if (!ownFork) request = await prepareStep($, e)
1279
+ const sent = { model: request.model, appEffort: e.effort, sentEffort: request.effort }
1280
+ if (!e.agentId && !ownFork && !same(await read($, lastRequest), sent)) await update($, lastRequest, () => sent)
1281
+ } catch (error) { request = e; await report($, 'turn.step prepare', error) }
1282
+ const answer = yield* next(request)
1283
+ try {
1284
+ if (epoch !== generation) return answer
1285
+ if (ownFork) { if (answer.usage) forkUsage = answer.usage; return answer }
1286
+ if (e.agentId && answer.usage) {
1287
+ await update($, agents, list => list.map(a => a.id === e.agentId ? { ...a, cost: a.cost + weighted(answer.usage!) } : a))
1288
+ } else if (!e.agentId && answer.usage) {
1289
+ const now = await $.clock.now()
1290
+ if (answer.usage.model === await $.session.model()) {
1291
+ const memo = nextMemo(await read($, cache), answer.usage, now)
1292
+ await update($, cache, () => memo)
1293
+ if (await read($, coldHidden)) await update($, coldHidden, () => false)
1294
+ await update($, lastRealReplyAt, () => now)
1295
+ if ((await read($, warm)).stopped !== undefined) await update($, warm, value => ({ ...value, stopped: undefined }))
1296
+ }
1297
+ const p = await read($, pick)
1298
+ if (p && (await currentSettings($)).effortAuto && isCacheSafeModel(request.model)) await update($, stats, value => addRequest(value, answer.usage!, effortOf(request.effort) ?? p.effort))
1299
+ }
1300
+ } catch (error) { await report($, 'turn.step record', error) }
1301
+ return answer
1302
+ })
1303
+ on('turn.complete', async ($, e, next) => {
1304
+ try {
1305
+ const language = await read($, lang)
1306
+ if (e.agentId) {
1307
+ const now = await $.clock.now()
1308
+ await update($, agents, list => list.map(a => a.id === e.agentId ? { ...a, state: e.reason === 'answer' ? 'done' : 'failed', endedAt: now, now: undefined, toolSince: undefined } : a))
1309
+ } else if (!forking) {
1310
+ if (await read($, busy)) await update($, busy, () => false)
1311
+ const held = await read($, heldPick)
1312
+ if (held && (await currentSettings($)).effortAuto) await installPick($, held)
1313
+ await update($, heldPick, () => null)
1314
+ const value = await read($, handoff)
1315
+ if (value?.stage === 'writing' && await read($, handoffWaiting)) {
1316
+ await update($, handoffWaiting, () => false)
1317
+ if (e.reason === 'answer' && e.answer.trim()) await update($, handoff, () => ({ ...value, text: e.answer }))
1318
+ else {
1319
+ await update($, handoff, () => ({ ...value, stage: 'failed' }))
1320
+ await $.ui.toast(t(language, 'toasts.handoffEmpty'))
1321
+ }
1322
+ }
1323
+ }
1324
+ } catch (error) { await report($, 'turn.complete', error) }
1325
+ return next(e)
1326
+ })
207
1327
  on('agent.spawn', async ($, e, next) => {
208
- const fix = e.fork ? null : await workerFix($, e)
209
- if (!fix) return next(e)
210
- if (fix.dropped) $.ui.toast(`ClaudeRipple: ${e.subagentType} runs on its own model, not "${fix.dropped}"`)
211
- return next({ ...e, prompt: fix.prompt, model: fix.model })
1328
+ let input = e
1329
+ let chosen: WorkerPick | undefined
1330
+ try {
1331
+ // Only the main chat delegates. A worker that starts its own worker waits on it idle, and a
1332
+ // Claude worker's 5-minute cache lapses meanwhile: 324k tokens rewritten in one request after
1333
+ // an 8.5-minute wait on 2026-10-11. Forks, workflows and teammates are the engine's, not this.
1334
+ const workflow = 'workflow' in e && !!e.workflow
1335
+ const teammate = 'isTeammate' in e && !!e.isTeammate
1336
+ if (e.parentAgentId && !e.fork && !workflow && !teammate) return { deny: t(await read($, lang), 'workers.nested') }
1337
+ const s = await currentSettings($)
1338
+ if (!workflow && !teammate && takesOver(e, s)) {
1339
+ const routed = await requestPick($, e, s)
1340
+ if ('deny' in routed) return routed
1341
+ input = routed.input
1342
+ chosen = routed.pick
1343
+ } else if (!e.fork && !workflow) {
1344
+ const files = await workerFiles($)
1345
+ try { await refreshModels($) } catch (error) { await report($, 'explicit worker catalogue', error) }
1346
+ const file = files.get(e.subagentType)
1347
+ const provider = file ? agentProvider(file) : e.model && !/^(?:sonnet|opus|haiku|claude-)/.test(e.model) ? (await read($, routerModels)).find(m => m.value === e.model?.replace(/@[^@]*$/, ''))?.provider : 'anthropic'
1348
+ if (provider && s.providers[provider] === false) return { deny: t(await read($, lang), 'workers.disabled', { provider }) }
1349
+ const marker = readMarker(e.prompt)
1350
+ if (marker) {
1351
+ const markerFile = files.get(marker.name) ?? [...files.values()].find(f => agentModel(f)?.replace(/@[^@]*$/, '') === marker.name)
1352
+ const markerProvider = markerFile ? agentProvider(markerFile) : (await read($, routerModels)).find(m => m.value === marker.name)?.provider
1353
+ if (markerProvider && s.providers[markerProvider] === false) return { deny: t(await read($, lang), 'workers.disabled', { provider: markerProvider }) }
1354
+ }
1355
+ const fix = await workerFix($, e)
1356
+ if (fix) {
1357
+ if (fix.dropped) await $.ui.toast(t(await read($, lang), 'toasts.ownModel', { name: e.subagentType, model: fix.dropped }))
1358
+ input = { ...e, prompt: fix.prompt, model: fix.model }
1359
+ }
1360
+ }
1361
+ } catch (error) { input = e; await report($, 'agent.spawn prepare', error) }
1362
+ try {
1363
+ // The title names what really runs: the model set above, else the worker file's, else the parent's.
1364
+ if (!e.fork && input.description?.trim()) {
1365
+ const file = (await workerFiles($)).get(input.subagentType)
1366
+ const model = input.model ?? (file ? agentModel(file) : undefined) ?? e.parentModel
1367
+ const effort = chosen?.effort ?? /@([a-z]+)$/.exec(model)?.[1]
1368
+ input = { ...input, description: spawnTitle(input.description, model, effort, await read($, routerModels)) }
1369
+ }
1370
+ } catch (error) { await report($, 'agent.spawn title', error) }
1371
+ const result = await next(input)
1372
+ try {
1373
+ if (result.agentId) {
1374
+ const rec: AgentRec = { id: result.agentId, description: input.description, type: input.subagentType, model: result.model, ...(chosen ? { pick: chosen } : {}), state: 'running', startedAt: await $.clock.now(), cost: 0 }
1375
+ await update($, agents, list => [...list.filter(a => a.id !== rec.id), rec].slice(-40))
1376
+ }
1377
+ } catch (error) { await report($, 'agent.spawn record', error) }
1378
+ return result
212
1379
  })
213
-
214
- on('command.run', { command: 'ripple-log' }, async $ => ({ text: (await toggleLog($)) ? 'ClaudeRipple log opened.' : 'ClaudeRipple log closed.' }))
215
-
216
- on('command.run', { command: 'ripple-bar' }, async $ => {
217
- if (await read($, bandHidden)) {
218
- await update($, bandHidden, () => false)
219
- await $.store.set(BAND_HIDDEN_KEY, false)
220
- return { text: 'ClaudeRipple line shown above the prompt.' }
1380
+ on('tool.call', async ($, e, next) => {
1381
+ let started = 0
1382
+ try {
1383
+ started = await $.clock.now()
1384
+ if (e.agentId) await update($, agents, list => list.map(a => a.id === e.agentId ? { ...a, now: toolLine(e), toolSince: started, state: 'running' } : a))
1385
+ } catch (error) { await report($, 'tool.call start', error) }
1386
+ try { return await next(e) }
1387
+ finally {
1388
+ try {
1389
+ if (e.agentId) await update($, agents, list => list.map(a => a.id === e.agentId && a.toolSince === started ? { ...a, now: undefined, toolSince: undefined, state: a.state === 'waiting' ? 'running' : a.state } : a))
1390
+ } catch (error) { await report($, 'tool.call finish', error) }
221
1391
  }
222
- await hideBand($)
223
- return { text: 'ClaudeRipple line hidden. /ripple-bar shows it again.' }
224
1392
  })
225
-
226
- on('ui.close', { id: PANE }, async ($, e, next) => {
227
- await update($, logOpen, () => false)
1393
+ on('session.compact', async ($, e, next) => {
1394
+ try {
1395
+ const language = await read($, lang)
1396
+ // The summary drops the eager note: the first prompt after it carries it again.
1397
+ if (!e.agentId && e.trigger !== 'precompute' && await read($, modeNoted) !== null) await update($, modeNoted, () => null)
1398
+ if (e.agentId || (await currentSettings($)).compactWith !== 'haiku') return next(e)
1399
+ if (e.trigger === 'precompute') return { skip: t(language, 'commands.compactSkip') }
1400
+ const transcript = serializeTranscript(e.messages.length ? e.messages : await $.session.messages())
1401
+ if (!transcript) throw new Error(t(language, 'toasts.transcriptEmpty'))
1402
+ const answer = await $.model.complete({ model: HAIKU, system: COMPACT_SYSTEM, prompt: transcript + (e.instructions ? `\n\nKeep especially:\n${e.instructions}` : ''), maxTokens: 16000, timeoutMs: 240000, effort: 'medium' })
1403
+ if (!answer.isAnswered || answer.text.trim().length <= 200) throw new Error(t(language, 'toasts.summaryEmpty'))
1404
+ await update($, cache, () => null)
1405
+ await update($, context, () => null)
1406
+ await update($, resultCard, () => ({ stage: 'compacted' }))
1407
+ return compactResult(answer.text)
1408
+ } catch (error) {
1409
+ await $.ui.toast(t(await read($, lang), 'toasts.compactFallback'))
1410
+ await report($, 'session.compact', error)
1411
+ }
1412
+ return next(e)
1413
+ })
1414
+ on('command.run', { command: 'ripple' }, async ($, e, next) => {
1415
+ try {
1416
+ const language = await read($, lang)
1417
+ const [sub = 'settings', arg] = e.args.trim().toLowerCase().split(/\s+/)
1418
+ if (sub === 'stats') return { text: formatStats(await read($, stats), language) }
1419
+ if (sub === 'debug') return { text: debugText(language, { judge: (await currentSettings($)).judge, judgeModel: (await currentSettings($)).judgeModel, pick: await read($, pick), paused: await read($, paused), cache: await read($, cache), warm: await read($, warm), lastSpawn: await read($, lastSpawn), lastRequest: await read($, lastRequest) }) }
1420
+ if (sub === 'settings' || sub === 'setup') await startPanel($, sub)
1421
+ else if (sub === 'auto') await toggleAuto($)
1422
+ else if (sub === 'warm') await toggleWarm($)
1423
+ else if (sub === 'save') await toggleSave($)
1424
+ else if (sub === 'handoff') await queueHandoff($, arg === 'full' ? 'full' : 'quick')
1425
+ else if (sub === 'log') await toggleLog($)
1426
+ else if (sub === 'agents') await toggleAgents($)
1427
+ else if (sub === 'bar') await toggleBar($)
1428
+ else if (['cold', 'swamp', 'hot', 'down'].includes(sub)) { await update($, panel, () => null); await update($, demo, () => sub as 'cold' | 'swamp' | 'hot' | 'down') }
1429
+ else return { text: t(language, 'commands.help') }
1430
+ return { text: t(language, 'commands.done', { command: sub }) }
1431
+ } catch (error) { await report($, 'ripple command', error); return next(e) }
1432
+ })
1433
+ on('command.run', { command: 'ripple-log' }, async ($, e, next) => {
1434
+ try {
1435
+ const language = await read($, lang)
1436
+ return { text: await toggleLog($) ? t(language, 'commands.logOpened') : t(language, 'commands.logClosed') } }
1437
+ catch (error) { await report($, 'ripple-log', error); return next(e) }
1438
+ })
1439
+ on('command.run', { command: 'ripple-bar' }, async ($, e, next) => {
1440
+ try {
1441
+ const language = await read($, lang)
1442
+ return { text: await toggleBar($) ? t(language, 'commands.barShown') : t(language, 'commands.barHidden') } }
1443
+ catch (error) { await report($, 'ripple-bar', error); return next(e) }
1444
+ })
1445
+ on('ui.close', { id: LOG_PANE }, async ($, e, next) => {
1446
+ try { await update($, logOpen, () => false) } catch (error) { await report($, 'log close', error) }
1447
+ return next(e)
1448
+ })
1449
+ on('ui.close', { id: AGENTS_PANE }, async ($, e, next) => {
1450
+ try { await update($, agentsOpen, () => false) } catch (error) { await report($, 'agents close', error) }
228
1451
  return next(e)
229
1452
  })
230
-
231
1453
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
232
- if (await read($, bandHidden)) return next(e)
233
- const notice = await read($, line)
234
- const record = await read($, last)
235
- if (!notice && !record) return next(e)
236
- const isOpen = await read($, logOpen)
237
- const { Box, Button, Text } = $.ui.resolve(e)
238
- const cache = record ? cachePercent(record.usage) : undefined
239
- return (
240
- <Box flexDirection="row" alignItems="center" gap={1}>
241
- {notice || !record ? (
242
- <Text dimColor>{notice}</Text>
243
- ) : (
244
- <Box flexDirection="row" gap={1}>
245
- {record.resent ? <Text dimColor>↻</Text> : <Text color={record.ok ? OK : FAIL}>{record.ok ? '↳' : '✕'}</Text>}
246
- <Text bold>{modelOf(record)}</Text>
247
- <Text color={providerColor(record.provider)}>{record.provider}</Text>
248
- {record.effort ? <Text dimColor>{record.effort}</Text> : null}
249
- {record.ok ? null : record.resent ? <Text dimColor>{String(record.status)} resent</Text> : <Text color={FAIL}>{String(record.status)}</Text>}
250
- {record.ok && cache !== undefined ? <Text color={cacheColor(cache)}>cache {cache}%</Text> : null}
251
- <Text dimColor>{(record.ms / 1000).toFixed(1)}s</Text>
1454
+ try {
1455
+ const language = await read($, lang)
1456
+ startTimers($)
1457
+ if (e.props.hasSurvey) return next(e)
1458
+ const which = await read($, panel)
1459
+ const frame = await frameOf($, e)
1460
+ const el = $.ui.resolve(e)
1461
+ if (which === 'settings') return renderSettings(el, frame, await settingsView($), settingsActions($)) as RenderElement
1462
+ if (which === 'compact' || which === 'handoff') {
1463
+ const choice = await alertView($, frame.now)
1464
+ if (choice) return renderAlert(el, frame, choice, alertActions($, choice)) as RenderElement
1465
+ }
1466
+ if (which === 'setup') {
1467
+ return renderSetup(el, frame, { draft: normalizeSettings(await read($, settingsDraft) ?? await read($, settings)), jev: await read($, jev), step: await read($, setupStep) }, setupActions($)) as RenderElement
1468
+ }
1469
+ if (await read($, bandHidden)) return next(e)
1470
+ const alert = await alertView($, frame.now)
1471
+ if (alert) return renderAlert(el, frame, alert, alertActions($, alert)) as RenderElement
1472
+ const s = await currentSettings($)
1473
+ if (s.layout === 'minimal') return next(e)
1474
+ const { Box, Button, Text } = el
1475
+ const actions = bandActions($, e.surface)
1476
+ // The desktop draws the band as one surface with its × inside; the terminal keeps the row
1477
+ // of facts and controls with the close control (and its effort button) after it.
1478
+ if ((e.surface === 'desktop' || e.surface === 'vscode') && 'Svg' in el) {
1479
+ return renderBand(el, frame, await dashboard($, e.props.isWorking), { ...actions, hide: () => { void toggleBar($) } }) as RenderElement
1480
+ }
1481
+ return (
1482
+ <Box flexDirection="row" alignItems={e.surface === 'terminal' || e.surface === 'mobile' ? 'flex-start' : 'center'} columnGap={2}>
1483
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>{renderBand(el, { ...frame, columns: frame.columns - besideBand(frame) }, await dashboard($, e.props.isWorking), actions)}</Box>
1484
+ <Box flexDirection="row" columnGap={2} flexShrink={0}>
1485
+ {e.surface === 'terminal' || e.surface === 'mobile' ? <Button key="effort" plain hotkey="e" label={t(language, 'band.effort')} onPress={actions.toggleEffortMenu} /> : null}
1486
+ <Button key="hide" plain role="dismiss" label="×" onPress={() => toggleBar($)} />
252
1487
  </Box>
253
- )}
254
- <Button key="log" plain label={isOpen ? 'Close log' : 'Log'} onPress={() => toggleLog($)} />
255
- <Button key="hide" plain role="dismiss" label="×" onPress={() => hideBand($)} />
256
- </Box>
257
- )
258
- })
259
-
260
- on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
261
- const { Box, Button, Text } = $.ui.resolve(e)
262
- const mineOnly = await read($, onlySession)
263
- const rows = await read($, requests)
264
- return (
265
- <Box flexDirection="column" gap={1}>
266
- <Box flexDirection="row" gap={1}>
267
- <Text bold>{mineOnly ? 'This session' : 'All sessions'}</Text>
268
- <Button
269
- key="scope"
270
- plain
271
- label={mineOnly ? 'Show all sessions' : 'This session only'}
272
- onPress={async () => {
273
- await update($, onlySession, value => !value)
274
- await refreshNow?.()
275
- }}
276
- />
277
1488
  </Box>
278
- {!mineOnly && <Text dimColor>● this session ○ other sessions</Text>}
279
- {rows.length === 0 && <Text dimColor>No model requests yet.</Text>}
280
- {rows.map(record => {
281
- const cache = cachePercent(record.usage)
282
- return (
283
- <Box key={`${record.at}-${record.target}`} flexDirection="column">
284
- <Box flexDirection="row" gap={1}>
285
- {mineOnly ? null : <Text color={record.mine ? ACCENT : undefined} dimColor={!record.mine}>{record.mine ? '●' : '○'}</Text>}
286
- <Text dimColor>{timeOf(record.at)}</Text>
287
- <Text bold wrap="truncate-end">{modelOf(record)}</Text>
288
- <Text color={providerColor(record.provider)}>{record.provider}</Text>
289
- {record.resent
290
- ? <Text dimColor>↻ {String(record.status)} resent</Text>
291
- : <Text color={record.ok ? OK : FAIL} bold={!record.ok}>{record.ok ? '✓' : `✕ ${record.status}`}</Text>}
292
- </Box>
293
- <Box flexDirection="row" gap={1}>
294
- <Text dimColor>{record.effort ?? '-'}</Text>
295
- {record.usage ? <Text dimColor>in {compact(promptTokens(record.usage))}</Text> : null}
296
- {cache !== undefined ? <Text color={cacheColor(cache)}>cache {cache}%</Text> : null}
297
- {record.usage ? <Text dimColor>out {compact(record.usage.output)}</Text> : null}
298
- <Text dimColor>{(record.ms / 1000).toFixed(1)}s</Text>
299
- </Box>
300
- {!record.ok && record.note ? (
301
- <Text color={record.resent ? undefined : FAIL} dimColor={record.resent} wrap="wrap">
302
- {record.note}
303
- </Text>
304
- ) : null}
305
- </Box>
306
- )
307
- })}
308
- </Box>
309
- )
1489
+ )
1490
+ } catch (error) { await report($, 'AbovePrompt render', error); return next(e) }
1491
+ })
1492
+ on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1493
+ const engine = await next(e)
1494
+ try {
1495
+ startTimers($)
1496
+ if ((await currentSettings($)).layout !== 'minimal' || await read($, bandHidden)) return engine
1497
+ return renderFooter($.ui.resolve(e), await frameOf($, e), await dashboard($, await read($, busy)), bandActions($, e.surface), engine) as RenderElement
1498
+ } catch (error) { await report($, 'SessionMode render', error); return engine }
1499
+ })
1500
+ on('ui.render', { component: 'Pane', requestId: LOG_PANE }, async ($, e, next) => {
1501
+ try {
1502
+ startTimers($)
1503
+ return renderLog($.ui.resolve(e), await frameOf($, e), { rows: await read($, requests), mineOnly: await read($, onlySession), judges: await read($, judgeRuns) }, { toggleScope: async () => { await update($, onlySession, value => !value); await refreshRouter($) } }) as RenderElement
1504
+ } catch (error) { await report($, 'log render', error); return next(e) }
1505
+ })
1506
+ on('ui.render', { component: 'Pane', requestId: AGENTS_PANE }, async ($, e, next) => {
1507
+ try {
1508
+ startTimers($)
1509
+ const frame = await frameOf($, e)
1510
+ return renderAgents($.ui.resolve(e), frame, { agents: await read($, agents), now: frame.now, showDone: await read($, agentsShowDone) }, { toggleDone: () => update($, agentsShowDone, value => !value) }) as RenderElement
1511
+ } catch (error) { await report($, 'agents render', error); return next(e) }
310
1512
  })
311
1513
  }