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.
- package/CHANGELOG.md +36 -0
- package/README.es.md +0 -4
- package/README.ja.md +0 -4
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +0 -4
- package/README.zh-CN.md +0 -4
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +226 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +370 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +170 -52
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +119 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/chatgpt/index.js +6 -1
- package/dist/router/src/providers/chatgpt/translate.js +1 -1
- package/dist/router/src/providers/google/index.js +6 -2
- package/dist/router/src/providers/openai/index.js +4 -1
- package/dist/router/src/providers/openai/translate.js +1 -1
- package/dist/router/src/proxy.js +14 -3
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +908 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +919 -233
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -410
- package/docs/ARCHITECTURE.md +128 -0
- package/package.json +7 -3
|
@@ -1,311 +1,1513 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
|
13
|
-
import {
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
const
|
|
52
|
-
return
|
|
53
|
-
} catch {
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
146
|
-
await
|
|
147
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
await
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
}
|