clauderipple 0.9.4 → 0.10.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 +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// callbacks it hands in. Every renderer in ./ is a pure function of (elements, view, actions);
|
|
3
3
|
// none of them calls the engine, so they can be drawn and tested anywhere.
|
|
4
4
|
|
|
5
|
-
import type { AgentRec, Effort, HandoffKind, JudgeKind, JudgeRun, LadderOptions, Pick, Settings, StatusRequest, TierTarget } from '../../types'
|
|
5
|
+
import type { AgentRec, Effort, HandoffKind, JudgeKind, JudgeRun, LadderOptions, LimitItem, Pick, Settings, Skin, StatusRequest, TierTarget } from '../../types'
|
|
6
6
|
import type { Appearance } from './theme'
|
|
7
7
|
import { t } from '../i18n'
|
|
8
8
|
import type { Lang } from '../i18n'
|
|
@@ -19,6 +19,8 @@ export type Frame = {
|
|
|
19
19
|
/** Epoch ms when drawn; countdowns are computed from it. */
|
|
20
20
|
now: number
|
|
21
21
|
appearance: Appearance
|
|
22
|
+
/** The desktop's skin (look.ts); the terminal draws text whatever it is. */
|
|
23
|
+
skin: Skin
|
|
22
24
|
lang: Lang
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -69,6 +71,8 @@ export type DashboardView = {
|
|
|
69
71
|
warm: { on: boolean; pings: number; cost: number; stopped?: string; here?: boolean }
|
|
70
72
|
/** The hottest plan window, shown when present. */
|
|
71
73
|
limit: { kind: string; percent: number; resetsAt?: string; save: boolean } | null
|
|
74
|
+
/** Every plan window for the second row, Claude's own first. Absent until read. */
|
|
75
|
+
limits?: LimitItem[]
|
|
72
76
|
route: RouteView | null
|
|
73
77
|
/** A line from the router side ("router not answering"), shown in place of the route. */
|
|
74
78
|
notice: string | null
|
|
@@ -201,14 +205,37 @@ export type SetupActions = {
|
|
|
201
205
|
|
|
202
206
|
// ---- Panes -----------------------------------------------------------------------------------
|
|
203
207
|
|
|
208
|
+
/** One entry of an agent's conversation as the pane draws it (agents.tsx `talkOf`). */
|
|
209
|
+
export type AgentEntry =
|
|
210
|
+
/** What it was asked: its first prompt, or a later message sent to it. */
|
|
211
|
+
| { kind: 'prompt'; text: string }
|
|
212
|
+
/** Its own words. */
|
|
213
|
+
| { kind: 'text'; text: string }
|
|
214
|
+
/** A tool it called: the tool, a short line of its input, and how it stands. */
|
|
215
|
+
| { kind: 'tool'; id: string; tool: string; detail: string; state: 'running' | 'ok' | 'error' }
|
|
216
|
+
|
|
217
|
+
/** An agent's conversation as read (`$.session.messages({ agentId })`), or why it could not be. */
|
|
218
|
+
export type AgentTalk = { entries: readonly AgentEntry[] } | { deny: string }
|
|
219
|
+
|
|
204
220
|
export type AgentsView = {
|
|
205
221
|
agents: readonly AgentRec[]
|
|
206
222
|
now: number
|
|
223
|
+
/** Every finished agent, not only the latest few. */
|
|
207
224
|
showDone: boolean
|
|
225
|
+
/** The agent whose conversation is open, what of it was read, and how many earlier pages show. */
|
|
226
|
+
open?: { id: string; talk: AgentTalk; earlier: number; runsOpen?: readonly string[] }
|
|
208
227
|
}
|
|
209
228
|
|
|
210
229
|
export type AgentsActions = {
|
|
211
230
|
toggleDone: () => void
|
|
231
|
+
/** Opens an agent's conversation in the pane. */
|
|
232
|
+
open: (id: string) => void
|
|
233
|
+
/** Back to the list. */
|
|
234
|
+
back: () => void
|
|
235
|
+
/** One more page of the open conversation's earlier entries. */
|
|
236
|
+
earlier: () => void
|
|
237
|
+
/** Folds or unfolds a run of tool calls, named by its first call's id. */
|
|
238
|
+
toggleRun: (id: string) => void
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
export type LogView = {
|
|
@@ -242,10 +269,10 @@ export function effortLabel(value: string, lang: Lang): string {
|
|
|
242
269
|
/** "claude-opus-5-5[1m]" → "Opus 5.5"; ids it does not know pass through, trimmed. */
|
|
243
270
|
export function modelName(id: string): string {
|
|
244
271
|
const bare = id.replace(/\[.*?\]/g, '').replace(/^claude-/, '')
|
|
245
|
-
const m = /^(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?/.exec(bare)
|
|
272
|
+
const m = /^(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?/.exec(bare)
|
|
246
273
|
if (m) return m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1) + ' ' + m[2] + (m[3] ? '.' + m[3] : '')
|
|
247
274
|
// A bare alias ("sonnet") reads as its family's name.
|
|
248
|
-
if (/^(opus|sonnet|haiku|fable)\b/.test(bare) && !bare.includes('-')) return bare.charAt(0).toUpperCase() + bare.slice(1)
|
|
275
|
+
if (/^(opus|sonnet|haiku|fable|mythos)\b/.test(bare) && !bare.includes('-')) return bare.charAt(0).toUpperCase() + bare.slice(1)
|
|
249
276
|
return bare
|
|
250
277
|
}
|
|
251
278
|
|
|
@@ -377,3 +404,89 @@ export function limitName(kind: string, lang: Lang): string {
|
|
|
377
404
|
if (kind === 'seven_day') return t(lang, 'band.weekly')
|
|
378
405
|
return kind.replace(/_/g, ' ')
|
|
379
406
|
}
|
|
407
|
+
|
|
408
|
+
// ---- Limits row ----------------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/** A plan window's short name as the limits row shows it: the window, or the model's own name. */
|
|
411
|
+
export function limitItemName(item: Pick<LimitItem, 'kind' | 'label' | 'windowMinutes'>, lang: Lang): string {
|
|
412
|
+
if (item.kind === 'five_hour' || item.kind === 'seven_day') return limitName(item.kind, lang)
|
|
413
|
+
if (item.kind === 'scoped') return item.label || item.kind
|
|
414
|
+
const minutes = item.windowMinutes
|
|
415
|
+
if (minutes === 300) return limitName('five_hour', lang)
|
|
416
|
+
if (minutes === 10080) return limitName('seven_day', lang)
|
|
417
|
+
if (typeof minutes === 'number' && minutes > 0) {
|
|
418
|
+
if (minutes % 1440 === 0) return t(lang, 'units.days', { n: minutes / 1440 })
|
|
419
|
+
if (minutes % 60 === 0) return t(lang, 'units.hours', { n: minutes / 60 })
|
|
420
|
+
return t(lang, 'units.minutes', { n: minutes })
|
|
421
|
+
}
|
|
422
|
+
return item.label || item.kind.replace(/_/g, ' ')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** One limits group as the row heads it: a provider's own, or one of its accounts ("GPT", "GPT 1", …). */
|
|
426
|
+
export type LimitGroup = { key: string; provider: string; type?: string; title?: string; account: number; items: LimitItem[] }
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The groups the row draws, one per provider account, Claude's plan first (the row keeps it to the
|
|
430
|
+
* last when narrow) and otherwise the router's answer order. Each window's item is unchanged.
|
|
431
|
+
*/
|
|
432
|
+
export function limitGroups(items: readonly LimitItem[]): LimitGroup[] {
|
|
433
|
+
const groups: LimitGroup[] = []
|
|
434
|
+
const index = new Map<string, LimitGroup>()
|
|
435
|
+
for (const item of items) {
|
|
436
|
+
const key = item.provider + '#' + item.account
|
|
437
|
+
let group = index.get(key)
|
|
438
|
+
if (!group) {
|
|
439
|
+
group = { key, provider: item.provider, ...(item.type ? { type: item.type } : {}), ...(item.title ? { title: item.title } : {}), account: item.account, items: [] }
|
|
440
|
+
index.set(key, group)
|
|
441
|
+
groups.push(group)
|
|
442
|
+
}
|
|
443
|
+
group.items.push(item)
|
|
444
|
+
}
|
|
445
|
+
// Claude's own plan comes first however the router ordered it; a stable sort keeps the rest.
|
|
446
|
+
const rank = (g: LimitGroup) => (g.type === 'anthropic' || g.provider === 'claude' ? 0 : 1)
|
|
447
|
+
return groups.sort((a, b) => rank(a) - rank(b))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The head's name when the item carries none: from its kind, else its config name. */
|
|
451
|
+
function groupTitle(group: Pick<LimitGroup, 'provider' | 'type' | 'title'>): string {
|
|
452
|
+
if (group.title) return group.title
|
|
453
|
+
if (group.type === 'anthropic' || group.provider === 'claude') return 'Claude'
|
|
454
|
+
if (group.type === 'chatgpt' || group.provider === 'chatgpt') return 'GPT'
|
|
455
|
+
if (group.type === 'google') return 'Gemini'
|
|
456
|
+
return group.provider
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** A group's head: "Claude", "GPT", "Gemini", or its config name; "GPT 1"/"GPT 2" when numbered. */
|
|
460
|
+
export function limitGroupName(group: Pick<LimitGroup, 'provider' | 'type' | 'title' | 'account'>, numbered: boolean): string {
|
|
461
|
+
const title = groupTitle(group)
|
|
462
|
+
return numbered ? title + ' ' + (group.account + 1) : title
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The groups that fit `budget`, narrowing in this order: the scoped windows (Fable, 3P, …) go
|
|
467
|
+
* first, one group's at a time from the last group back, so Claude's own Fable is the last of them
|
|
468
|
+
* to go (all of them going at once hid Fable for Gemini's two 3P windows, 2026-10-15); then a later
|
|
469
|
+
* provider's secondary accounts (the last one first), then a later provider's whole group —
|
|
470
|
+
* Claude's own group (the first) stays to the last. Null when even that does not fit (no row).
|
|
471
|
+
* `width` measures a set of groups the way the row draws it.
|
|
472
|
+
*/
|
|
473
|
+
export function narrowLimitGroups(groups: readonly LimitGroup[], budget: number, width: (gs: readonly LimitGroup[]) => number): LimitGroup[] | null {
|
|
474
|
+
let out = [...groups]
|
|
475
|
+
for (let i = out.length - 1; i >= 0 && width(out) > budget; i--) {
|
|
476
|
+
if (!out[i]!.items.some(item => item.kind === 'scoped')) continue
|
|
477
|
+
out = out.map((g, j) => j === i ? { ...g, items: g.items.filter(item => item.kind !== 'scoped') } : g).filter(g => g.items.length > 0)
|
|
478
|
+
}
|
|
479
|
+
while (width(out) > budget && out.some(g => g.account > 0)) {
|
|
480
|
+
const at = out.map(g => g.account > 0).lastIndexOf(true)
|
|
481
|
+
out = out.filter((_, i) => i !== at)
|
|
482
|
+
}
|
|
483
|
+
while (width(out) > budget && out.length > 1) out = out.slice(0, -1)
|
|
484
|
+
if (!out.length || width(out) > budget) return null
|
|
485
|
+
return out
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A window's hover tip: "Claude 주간 88% · 4시간 뒤 초기화". */
|
|
489
|
+
export function limitItemTip(item: LimitItem, group: string, lang: Lang, now: number): string {
|
|
490
|
+
const reset = resetsLabel(item.resetsAt, now, lang)
|
|
491
|
+
return t(lang, 'band.limitsTip', { provider: group, window: limitItemName(item, lang), value: Math.round(item.percent) + '%', reset: reset ? ' · ' + reset : '' })
|
|
492
|
+
}
|
|
@@ -7,11 +7,32 @@ import { kindOf, parseEffortVerdict, verdictObject } from './judge'
|
|
|
7
7
|
import { claudeChoices, rungAbove, rungOf, tickedChoices } from './ladder'
|
|
8
8
|
|
|
9
9
|
/** The one agent the main chat delegates to; the judge picks the worker (docs/specs/2026-10-12-worker-tiers.md). */
|
|
10
|
-
export const AUTO_DESCRIPTION = 'Delegate any task here; ClaudeRipple
|
|
10
|
+
export const AUTO_DESCRIPTION = 'Delegate any task here; ClaudeRipple picks the model. If a worker fails or returns nothing usable, delegate again here once and say the earlier attempt failed. ' +
|
|
11
|
+
'If you know how hard the task is, put [[ripple: light]], [[ripple: standard]], [[ripple: hard]] or [[ripple: max]] on the prompt\'s first line: ' +
|
|
12
|
+
'light = lookups, mechanical edits, one small fix; standard = carry out a designed change (even across several files), research, summarize, read-only review; ' +
|
|
13
|
+
'hard = subtle debugging, security review, or work where a wrong call is costly; max = planning, design or architecture where the deliverable is the decision itself. ' +
|
|
14
|
+
'Size, file count or the brief\'s warnings do not by themselves make a task hard. Leave the marker out when unsure, and ClaudeRipple judges it.'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The tier markers the main chat may write on a ripple:auto prompt, as the judge's verdict they
|
|
18
|
+
* stand for (`rungOf` gives light, standard, hard, plan). The main chat knows the whole
|
|
19
|
+
* conversation, the judge one brief: re-judging 62 delegations (2026-10-15), the judge sent 32 to
|
|
20
|
+
* hard, Opus under this description 18. Not model names, so never routed as one.
|
|
21
|
+
*/
|
|
22
|
+
export const TIER_MARKERS: Readonly<Record<string, Pick<WorkerVerdict, 'level' | 'kind'>>> = {
|
|
23
|
+
light: { level: 'light', kind: 'do' },
|
|
24
|
+
standard: { level: 'standard', kind: 'do' },
|
|
25
|
+
hard: { level: 'deep', kind: 'do' },
|
|
26
|
+
max: { level: 'deep', kind: 'plan' },
|
|
27
|
+
}
|
|
28
|
+
/** The tier a marker names, if it is one. */
|
|
29
|
+
export function tierOf(marker: { name: string } | null): string | null {
|
|
30
|
+
return marker && Object.hasOwn(TIER_MARKERS, marker.name) ? marker.name : null
|
|
31
|
+
}
|
|
11
32
|
|
|
12
33
|
export const WORKER_JUDGE_SYSTEM = `Choose a domain, capability level, kind of work and the cheapest effort that still does this delegated task well. Evaluate actual scope and risk, not prompt length.
|
|
13
34
|
Domains: design (visual taste, composition, brand), frontend (UI implementation), backend (APIs and server logic), infra (deployment and operations), data (queries and processing), test (verification), docs (writing), research (investigation), general (other or cross-domain).
|
|
14
|
-
Levels are independent of domain: light (chores,
|
|
35
|
+
Levels are independent of domain: light (chores, repeated edits, simple lookups, one small fix), standard (carry out a designed change, even across several files; research, summarize, read-only review), deep (subtle debugging, security review, or architecture where a wrong call is costly; size, file count, or warnings and prohibitions in the brief do not by themselves make work deep). A task that says an earlier attempt failed is one level higher than it would otherwise be.
|
|
15
36
|
Kind is determined by the deliverable, not preparatory steps. plan = pure planning: the deliverable is a decision, design, specification, or review verdict, NOT implemented code. do = carry out work: implement, fix, run, look up, write. Any task whose deliverable includes implemented code, files, builds or fixes is do, even if it says "read the spec/design first" or requires architecture/design reasoning before implementation. Writing a design/spec document alone remains plan; producing implementation files is do. Examples: "implement host listing of sessions" => do; "build an iOS app" => do; "read the spec first, then implement it" => do; "decide the session-listing architecture; do not implement" => plan. If planning and implementation are mixed, choose do. Deep plan is the hardest work, which runs on the strongest model, reserved for pure architecture, system design or consequential design decisions.
|
|
16
37
|
Effort choices are low, medium, high, xhigh, max; reserve the last two for exceptionally hard work. Return only JSON {"domain":"design|frontend|backend|infra|data|test|docs|research|general","level":"light|standard|deep","kind":"plan|do","effort":"low|medium|high|xhigh|max","sure":0.0,"why":"brief reason"}. Confidence is zero to one and is about the level; explain in the user's language in at most six words.`
|
|
17
38
|
|
|
@@ -192,12 +213,13 @@ const TITLE_PREFIX = /^[^\s·]+(?:·[a-z]+)? · /
|
|
|
192
213
|
*/
|
|
193
214
|
export function titleName(model: string, models: readonly RouterModel[] = []): string {
|
|
194
215
|
const id = baseModel(model).replace(/\[.*?\]/g, '')
|
|
195
|
-
const claude = /^(?:claude-)?(opus|sonnet|haiku|fable)(?:-(\d+)(?:-(\d{1,2}))?)?/i.exec(id)
|
|
216
|
+
const claude = /^(?:claude-)?(opus|sonnet|haiku|fable|mythos)(?:-(\d+)(?:-(\d{1,2}))?)?/i.exec(id)
|
|
196
217
|
if (claude) return claude[1]!.charAt(0).toUpperCase() + claude[1]!.slice(1).toLowerCase() + (claude[2] ? claude[2] + (claude[3] ? '.' + claude[3] : '') : '')
|
|
218
|
+
// A hosted id's vendor path ("deepseek-ai/") is dropped: the title has room for the model only.
|
|
197
219
|
const named = models.find(m => m.value.toLowerCase() === id.toLowerCase())?.name
|
|
198
|
-
if (named) return named.trim().split(/\s+/).pop()
|
|
220
|
+
if (named) return named.trim().split(/\s+/).pop()!.replace(/^.*\//, '')
|
|
199
221
|
const gpt = /^gpt-[\d.]+-([a-z]+)$/i.exec(id)
|
|
200
|
-
return gpt ? gpt[1]!.charAt(0).toUpperCase() + gpt[1]!.slice(1) : id
|
|
222
|
+
return gpt ? gpt[1]!.charAt(0).toUpperCase() + gpt[1]!.slice(1) : id.replace(/^.*\//, '')
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
/**
|
|
@@ -207,8 +229,13 @@ export function titleName(model: string, models: readonly RouterModel[] = []): s
|
|
|
207
229
|
*/
|
|
208
230
|
export function spawnTitle(description: string, model: string, effort: string | undefined, models: readonly RouterModel[] = []): string {
|
|
209
231
|
const tag = titleName(model, models) + (effort ? '·' + effort : '')
|
|
232
|
+
return `${tag} · ${bareTitle(description)}`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A title without the model tag spawnTitle put in front ("Sol·high · Port it" → "Port it"). */
|
|
236
|
+
export function bareTitle(description: string): string {
|
|
210
237
|
const bare = description.replace(TITLE_PREFIX, '')
|
|
211
|
-
return
|
|
238
|
+
return bare.trim() ? bare : description
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
export function takesOver(spawn: { subagentType: string; model?: string; fork: boolean }, settings: Settings): boolean {
|
|
@@ -4,7 +4,7 @@ import type { Engine } from 'claude-code/testing'
|
|
|
4
4
|
import type { AgentSpawnInput, ModelCompleteRequest, ModelUsage, On, SessionMessage, ToolCallInput, TurnStepInput } from 'claude-code'
|
|
5
5
|
import { MOD_SETTINGS_SCHEMA_VERSION, DEFAULTS, normalizeModes, normalizeSettings } from '../hooks/settings'
|
|
6
6
|
import type { Lang } from '../hooks/i18n'
|
|
7
|
-
import type { ModSettingsSnapshot, Settings } from '../types'
|
|
7
|
+
import type { AgentRec, ModSettingsSnapshot, Settings } from '../types'
|
|
8
8
|
|
|
9
9
|
const NOW = 1000000
|
|
10
10
|
const USAGE: ModelUsage = { input_tokens: 100, output_tokens: 10, cache_read_input_tokens: 40000, cache_creation_input_tokens: 0 }
|
|
@@ -34,7 +34,7 @@ const CONFIG = { admin: { port: 9992 }, providers: { deepseek: { models: [{ id:
|
|
|
34
34
|
const SPAWN: AgentSpawnInput = { tool_use_id: 'tool1', subagentType: 'ripple:auto', prompt: 'Rename one local variable and verify it.', description: 'Rename variable', parentModel: 'claude-opus-5-5', provider: { plugin: 'engine', tier: 'core' }, background: false, fork: false }
|
|
35
35
|
const MESSAGES: SessionMessage[] = [{ role: 'user', text: 'Implement the specified change and verify it.', toolUses: [] }, { role: 'assistant', text: 'The implementation is ready.', toolUses: [] }]
|
|
36
36
|
|
|
37
|
-
function boundaries(on: On, patch: Partial<Settings> = {}, options: { config?: unknown; paths?: string[]; key?: string; envFile?: string | (() => string); language?: unknown; env?: Record<string, string>; platform?: string; browserFailure?: 'exit' | 'throw'; appleLocale?: string; appleFail?: boolean; limits?: { kind: string; percentUsed: number; resetsAt?: string }[] } = {}) {
|
|
37
|
+
function boundaries(on: On, patch: Partial<Settings> = {}, options: { config?: unknown; paths?: string[]; key?: string; envFile?: string | (() => string); language?: unknown; env?: Record<string, string>; platform?: string; browserFailure?: 'exit' | 'throw'; appleLocale?: string; appleFail?: boolean; limits?: { kind: string; percentUsed: number; resetsAt?: string }[]; agentMessages?: (agentId: string) => SessionMessage[] } = {}) {
|
|
38
38
|
const clock = mock.clock(on, { now: NOW })
|
|
39
39
|
const saved = new Map<string, unknown>()
|
|
40
40
|
const storeWrites: string[] = []
|
|
@@ -116,7 +116,7 @@ function boundaries(on: On, patch: Partial<Settings> = {}, options: { config?: u
|
|
|
116
116
|
on('command.list', () => ({ value: [{ name: 'clear', description: 'Clear', source: 'builtin' }, { name: 'compact', description: 'Compact', source: 'builtin' }, { name: 'model', description: 'Model', source: 'builtin' }] }))
|
|
117
117
|
on('session.id', () => ({ value: 'session-test' }))
|
|
118
118
|
on('session.model', () => ({ value: model }))
|
|
119
|
-
on('session.messages', () => ({ value: MESSAGES }))
|
|
119
|
+
on('session.messages', ($, e) => ({ value: e.agentId && options.agentMessages ? options.agentMessages(e.agentId) : MESSAGES }))
|
|
120
120
|
on('session.usage', () => ({ value: { startedAt: NOW, context: { percent, tokens: 40000, window: 200000 }, rateLimits: options.limits ?? [] } }))
|
|
121
121
|
on('ui.status', () => ({ value: undefined }))
|
|
122
122
|
on('ui.toast', ($, e) => { toasts.push(e.text); return { value: undefined } })
|
|
@@ -500,27 +500,29 @@ test('ripple:auto is judged and rewritten to the light rung\'s agent at the rung
|
|
|
500
500
|
const persisted = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!
|
|
501
501
|
expect(persisted.endsWith('\n')).toBe(true)
|
|
502
502
|
expect(persisted.trim().split('\n').length).toBe(1)
|
|
503
|
-
|
|
503
|
+
const row = JSON.parse(persisted)
|
|
504
|
+
expect(typeof row.id).toBe('string')
|
|
505
|
+
expect(row).toEqual({
|
|
504
506
|
...JSON.parse(b.logs[0]!.slice('ClaudeRipple worker pick: '.length)),
|
|
505
|
-
at: new Date(NOW).toISOString(), by: 'judge', judge: 'haiku', ms: 0,
|
|
507
|
+
type: 'pick', id: row.id, at: new Date(NOW).toISOString(), by: 'judge', judge: 'haiku', ms: 0, promptChars: SPAWN.prompt.length,
|
|
506
508
|
verdict: { domain: 'general', level: 'light', kind: 'do', effort: 'low', sure: 0.9, why: 'small rename' },
|
|
507
509
|
})
|
|
508
510
|
const stats = await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'stats' })
|
|
509
511
|
expect(stats.text).toContain('light 1')
|
|
510
512
|
})
|
|
511
513
|
|
|
512
|
-
test('worker pick persistence keeps the last
|
|
514
|
+
test('worker pick persistence keeps the last 3,000 lines and concurrent marker picks, never the prompt', async ($, on) => {
|
|
513
515
|
const b = boundaries(on, {}, { env: { CLAUDERIPPLE_HOME: '/custom/ripple' } })
|
|
514
516
|
http(on)
|
|
515
517
|
const judges = judgeStub(on)
|
|
516
518
|
spawnStub(on)
|
|
517
519
|
const path = '/custom/ripple/logs/worker-picks.jsonl'
|
|
518
|
-
b.files.set(path, Array.from({ length:
|
|
520
|
+
b.files.set(path, Array.from({ length: 3005 }, (_, n) => JSON.stringify({ old: n })).join('\n') + '\n')
|
|
519
521
|
await start($)
|
|
520
522
|
await Promise.all(['first', 'second'].map(label => $.agent.spawn({ ...SPAWN, description: label + 'x'.repeat(220), prompt: '[[ripple: gpt-6-1-sol@xhigh]]\nPRIVATE_TASK_PROMPT' })))
|
|
521
523
|
const text = b.files.get(path)!
|
|
522
524
|
const rows = text.trim().split('\n').map(line => JSON.parse(line))
|
|
523
|
-
expect(rows.length).toBe(
|
|
525
|
+
expect(rows.length).toBe(3000)
|
|
524
526
|
expect(rows[0]).toEqual({ old: 7 })
|
|
525
527
|
expect(rows.slice(-2).map(row => row.task).sort()).toEqual(['first' + 'x'.repeat(195), 'second' + 'x'.repeat(194)])
|
|
526
528
|
for (const row of rows.slice(-2)) expect(row).toMatchObject({ by: 'marker', verdict: null, judge: null, ms: 0, model: 'gpt-6.1-sol@xhigh', effort: 'xhigh' })
|
|
@@ -588,6 +590,69 @@ for (const prefix of ['ripple:', '']) for (const [level, kind, rung, agent, mode
|
|
|
588
590
|
}
|
|
589
591
|
})
|
|
590
592
|
|
|
593
|
+
// The main chat may name the tier itself; the judge then only rates it in the shadow (2026-10-15).
|
|
594
|
+
for (const [tier, rung, agent, model] of [
|
|
595
|
+
['light', 'light', 'deepseek-v4-1-flash', 'deepseek-v4.1-flash@max'],
|
|
596
|
+
['standard', 'standard', 'gpt-6-1-sol', 'gpt-6.1-sol@medium'],
|
|
597
|
+
['hard', 'hard', 'gpt-6-astra', 'gpt-6-astra@high'],
|
|
598
|
+
['max', 'plan', 'general-purpose', 'opus'],
|
|
599
|
+
] as const) test(`a [[ripple: ${tier}]] marker runs the ${rung} rung, the judge rating it only in the log`, async ($, on) => {
|
|
600
|
+
const b = boundaries(on)
|
|
601
|
+
http(on)
|
|
602
|
+
const judges = judgeStub(on, JSON.stringify({ domain: 'backend', level: 'deep', kind: 'do', effort: 'high', sure: 0.8, why: 'judged' }))
|
|
603
|
+
const sent = spawnStub(on)
|
|
604
|
+
await start($)
|
|
605
|
+
await $.agent.spawn({ ...SPAWN, prompt: `[[ripple: ${tier}]]\nRename one local variable and verify it.` })
|
|
606
|
+
await b.clock.settle()
|
|
607
|
+
// The tier marker is the main chat's to the mod, never a model name the router would see.
|
|
608
|
+
expect(sent[0]).toMatchObject({ subagentType: agent, model, prompt: 'Rename one local variable and verify it.' })
|
|
609
|
+
const rows = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
|
|
610
|
+
const pick = rows.find(row => row.type === 'pick')
|
|
611
|
+
expect(pick).toMatchObject({ by: 'session', tier, rung, judge: null, verdict: null })
|
|
612
|
+
// The judge still rated the same brief, beside the pick, without holding the spawn.
|
|
613
|
+
expect(judges.length).toBe(1)
|
|
614
|
+
expect(rows.find(row => row.type === 'shadow')).toMatchObject({ pick: pick.id, rung: 'hard', judge: 'haiku', verdict: { level: 'deep', kind: 'do' } })
|
|
615
|
+
// The worker's end joins the same pick.
|
|
616
|
+
await complete($, 'worker-1', 'Renamed and tested.')
|
|
617
|
+
const after = b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').map(line => JSON.parse(line))
|
|
618
|
+
expect(after.find(row => row.type === 'outcome')).toMatchObject({ pick: pick.id, agent: 'worker-1', reason: 'answer', aborted: false, ms: 1000, answerChars: 19 })
|
|
619
|
+
// Once: a second completion of the same agent writes no second outcome.
|
|
620
|
+
await complete($, 'worker-1')
|
|
621
|
+
expect(b.files.get('/home/test/.clauderipple/logs/worker-picks.jsonl')!.trim().split('\n').filter(line => line.includes('"outcome"')).length).toBe(1)
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
test('the limits row shows Claude\'s plan on a chat that runs on another provider', async ($, on) => {
|
|
625
|
+
const b = boundaries(on, { extras: ['limits'] })
|
|
626
|
+
b.setModel('muse-spark-1.3-contributor')
|
|
627
|
+
// The unified `/api/limits` body: Claude's plan regardless of the chat's routed provider.
|
|
628
|
+
const limits = { providers: [{ provider: 'claude', type: 'anthropic', title: 'Claude', accounts: [{ account: 0, at: NOW, windows: [
|
|
629
|
+
{ kind: 'five_hour', used_percent: 1, window_minutes: 300, reset_at: (NOW + 3_600_000) / 1000 },
|
|
630
|
+
{ kind: 'seven_day', used_percent: 88, window_minutes: 10080, reset_at: (NOW + 7_200_000) / 1000 },
|
|
631
|
+
{ kind: 'scoped', label: 'Fable', used_percent: 2, window_minutes: 10080 },
|
|
632
|
+
] }] }] }
|
|
633
|
+
on('http.fetch', ($, e, next) => {
|
|
634
|
+
if (e.url.endsWith('/api/mod-settings')) return next(e)
|
|
635
|
+
return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(e.url.endsWith('/api/limits') ? limits : { requests: [] }) } }
|
|
636
|
+
})
|
|
637
|
+
await start($)
|
|
638
|
+
await b.clock.advance(15000)
|
|
639
|
+
const ui = await band($, 'desktop')
|
|
640
|
+
for (const text of [/^Claude$/, /^88%$/, /^Fable$/]) expect(await ui.find({ type: 'Text', text }), String(text)).toBeDefined()
|
|
641
|
+
await ui.unmount()
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
test('a tier marker on a worker other than ripple:auto is dropped, not routed as a model', async ($, on) => {
|
|
645
|
+
boundaries(on)
|
|
646
|
+
http(on)
|
|
647
|
+
const judges = judgeStub(on)
|
|
648
|
+
const sent = spawnStub(on)
|
|
649
|
+
await start($)
|
|
650
|
+
await $.agent.spawn({ ...SPAWN, subagentType: 'Explore', prompt: '[[ripple: hard]]\nFind the router entry point.' })
|
|
651
|
+
expect(judges.length).toBe(0)
|
|
652
|
+
expect(sent[0]).toMatchObject({ subagentType: 'Explore', prompt: 'Find the router entry point.' })
|
|
653
|
+
expect(sent[0]?.model).toBeUndefined()
|
|
654
|
+
})
|
|
655
|
+
|
|
591
656
|
test('a judged spawn uses the default general ladder when general is inherit', async ($, on) => {
|
|
592
657
|
boundaries(on, { rules: normalizeSettings({ rules: { general: { mode: 'inherit' } } }).rules })
|
|
593
658
|
http(on)
|
|
@@ -648,7 +713,7 @@ for (const agentAuto of ['off', 'auto', 'all'] as const) test('only ripple:auto
|
|
|
648
713
|
await start($)
|
|
649
714
|
expect([...new Set(registered.map(e => e.name))]).toEqual(agentAuto === 'off' ? [] : ['auto'])
|
|
650
715
|
for (const entry of registered) {
|
|
651
|
-
expect(entry.description).toContain('
|
|
716
|
+
expect(entry.description).toContain('[[ripple: standard]]')
|
|
652
717
|
expect(entry.disallowedTools).toEqual(['Agent'])
|
|
653
718
|
}
|
|
654
719
|
await $.agent.spawn(SPAWN)
|
|
@@ -1449,9 +1514,88 @@ test('known agents show waiting after an eight-second tool and finish with cost'
|
|
|
1449
1514
|
await b.clock.advance(15000)
|
|
1450
1515
|
await tool
|
|
1451
1516
|
await complete($, 'worker-1')
|
|
1452
|
-
await ui.press({ key: 'done' })
|
|
1453
1517
|
expect(await ui.find({ type: 'Text', text: 'Rename variable' })).toBeDefined()
|
|
1454
|
-
|
|
1518
|
+
// The API's own counts (100 + 40000 cached in, 10 out), not the weighted cost.
|
|
1519
|
+
expect(await ui.find({ type: 'Text', text: 'in 40k · cache 100% · out 10 · tools 1' })).toBeDefined()
|
|
1520
|
+
})
|
|
1521
|
+
|
|
1522
|
+
test('an agent keeps the API\'s token counts, its tools and the effort it was sent at; its card opens its conversation and goes back', async ($, on) => {
|
|
1523
|
+
const reads: string[] = []
|
|
1524
|
+
boundaries(on, {}, { agentMessages: id => {
|
|
1525
|
+
reads.push(id)
|
|
1526
|
+
return [{ role: 'user', text: 'Rename one local variable and verify it.', toolUses: [] }, { role: 'assistant', text: 'Renamed it.', toolUses: [{ tool_use_id: 't1', tool: 'Bash', input: { command: 'npm test' }, text: 'ok' }] }]
|
|
1527
|
+
} })
|
|
1528
|
+
http(on)
|
|
1529
|
+
judgeStub(on)
|
|
1530
|
+
spawnStub(on)
|
|
1531
|
+
stepStub(on, { input_tokens: 300, output_tokens: 50, cache_read_input_tokens: 9000, cache_creation_input_tokens: 700 })
|
|
1532
|
+
on('tool.call', () => ({ result: 'ok' }))
|
|
1533
|
+
let written: AgentRec[] = []
|
|
1534
|
+
on('state.set', { plugin: 'ripple', key: 'agents' }, async ($, e, next) => {
|
|
1535
|
+
const result = await next(e)
|
|
1536
|
+
written = e.value as AgentRec[]
|
|
1537
|
+
return result
|
|
1538
|
+
})
|
|
1539
|
+
await start($)
|
|
1540
|
+
await $.agent.spawn(SPAWN)
|
|
1541
|
+
// The routed id's @low is what the router sends, whatever the request's own effort says.
|
|
1542
|
+
await step($, 'deepseek-v4.1-flash@low', 'high', 'worker-1')
|
|
1543
|
+
await step($, 'deepseek-v4.1-flash@low', 'high', 'worker-1')
|
|
1544
|
+
const input: ToolCallInput = { tool: 'Bash', command: 'npm test', agentId: 'worker-1', tool_use_id: 'tool-a' }
|
|
1545
|
+
await $.tool.call(input)
|
|
1546
|
+
const rec = written.find(a => a.id === 'worker-1')
|
|
1547
|
+
expect(rec?.usage).toEqual({ input: 600, cached: 18_000, cacheWrite: 1400, output: 100 })
|
|
1548
|
+
expect(rec?.tools).toBe(1)
|
|
1549
|
+
expect(rec?.effort).toBe('low')
|
|
1550
|
+
expect(rec?.answeredBy).toBe('deepseek-v4.1-flash@low')
|
|
1551
|
+
expect(rec?.provider).toBe('deepseek')
|
|
1552
|
+
const ui = await $.ui.mount({ plugin: 'ripple', component: 'Pane', requestId: 'ripple-agents', surface: 'terminal', props: { title: 'Agents', isFocused: true, bodyColumns: 100, placement: 'inline', scroll: { offset: 0, bodyRows: 30 }, view: {} } })
|
|
1553
|
+
expect(await ui.find({ type: 'Text', text: /in 20k · cache 90% · out 100 · tools 1/ })).toBeDefined()
|
|
1554
|
+
expect(reads).toEqual([])
|
|
1555
|
+
await ui.press({ key: 'open-worker-1' })
|
|
1556
|
+
expect(reads).toContain('worker-1')
|
|
1557
|
+
expect(await ui.find({ type: 'Text', text: 'Renamed it.' })).toBeDefined()
|
|
1558
|
+
expect(await ui.find({ type: 'Text', text: 'npm test' })).toBeDefined()
|
|
1559
|
+
await ui.press({ key: 'back' })
|
|
1560
|
+
expect(await ui.find({ type: 'Text', text: 'Renamed it.' })).toBeUndefined()
|
|
1561
|
+
expect(await ui.find({ type: 'Button', key: 'open-worker-1' })).toBeDefined()
|
|
1562
|
+
})
|
|
1563
|
+
|
|
1564
|
+
test('an agent\'s run of tool calls unfolds on press, its paths short against the session folder, and folds again once the pane goes back', async ($, on) => {
|
|
1565
|
+
boundaries(on, {}, { agentMessages: () => [
|
|
1566
|
+
{ role: 'user', text: 'Run the tests.', toolUses: [] },
|
|
1567
|
+
{ role: 'assistant', text: 'Running.', toolUses: [
|
|
1568
|
+
{ tool_use_id: 'c1', tool: 'Bash', input: { command: 'cd /work/android && ./gradlew test' }, text: 'ok' },
|
|
1569
|
+
{ tool_use_id: 'c2', tool: 'Read', input: { file_path: '/work/core/A.kt' }, text: 'ok' },
|
|
1570
|
+
] },
|
|
1571
|
+
] })
|
|
1572
|
+
http(on)
|
|
1573
|
+
judgeStub(on)
|
|
1574
|
+
spawnStub(on)
|
|
1575
|
+
stepStub(on)
|
|
1576
|
+
const runs: unknown[] = []
|
|
1577
|
+
on('state.set', { plugin: 'ripple', key: 'agentRunsOpen' }, async ($, e, next) => {
|
|
1578
|
+
runs.push(e.value)
|
|
1579
|
+
return next(e)
|
|
1580
|
+
})
|
|
1581
|
+
await start($)
|
|
1582
|
+
await $.agent.spawn(SPAWN)
|
|
1583
|
+
const ui = await $.ui.mount({ plugin: 'ripple', component: 'Pane', requestId: 'ripple-agents', surface: 'terminal', props: { title: 'Agents', isFocused: true, bodyColumns: 100, placement: 'inline', scroll: { offset: 0, bodyRows: 30 }, view: {} } })
|
|
1584
|
+
await ui.press({ key: 'open-worker-1' })
|
|
1585
|
+
expect(await ui.find({ type: 'Button', text: /^Ran 1 command, Read 1 file/ })).toBeDefined()
|
|
1586
|
+
expect(await ui.find({ type: 'Text', text: './gradlew test' })).toBeUndefined()
|
|
1587
|
+
await ui.press({ key: 'run-c1' })
|
|
1588
|
+
expect(await ui.find({ type: 'Text', text: /^\.\/gradlew test$/ })).toBeDefined()
|
|
1589
|
+
expect(await ui.find({ type: 'Text', text: /^core\/A\.kt$/ })).toBeDefined()
|
|
1590
|
+
await ui.press({ key: 'run-c1' })
|
|
1591
|
+
expect(await ui.find({ type: 'Text', text: './gradlew test' })).toBeUndefined()
|
|
1592
|
+
await ui.press({ key: 'run-c1' })
|
|
1593
|
+
expect(runs.at(-1)).toEqual(['c1'])
|
|
1594
|
+
await ui.press({ key: 'back' })
|
|
1595
|
+
// Back on the list, the unfolded run is forgotten.
|
|
1596
|
+
expect(runs.at(-1)).toEqual([])
|
|
1597
|
+
await ui.press({ key: 'open-worker-1' })
|
|
1598
|
+
expect(await ui.find({ type: 'Text', text: './gradlew test' })).toBeUndefined()
|
|
1455
1599
|
})
|
|
1456
1600
|
|
|
1457
1601
|
/** The claude:// link a handoff opened, and its query. */
|