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.
Files changed (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. 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 judges how hard it is (low/medium/high/max) and picks the model. If a worker fails or returns nothing usable, delegate again here once and say the earlier attempt failed.'
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, mechanical work, simple lookups), standard (designed implementation, ordinary research and review), deep (careful high-stakes reasoning, subtle bugs, architecture, security). A task that says an earlier attempt failed is one level higher than it would otherwise be.
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 `${tag} · ${bare.trim() ? bare : description}`
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
- expect(JSON.parse(persisted)).toEqual({
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 300 lines and concurrent marker picks, never the prompt', async ($, on) => {
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: 305 }, (_, n) => JSON.stringify({ old: n })).join('\n') + '\n')
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(300)
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('low/medium/high/max')
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
- expect(await ui.find({ type: 'Text', text: /≈4/ })).toBeDefined()
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. */