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
@@ -1,7 +1,7 @@
1
1
  // Operating modes in the session (docs/specs/2026-10-12-worker-tiers.md "Operating modes"): which
2
2
  // mode is on, when it ends by itself, and the eager-delegation note. Pure; register.tsx wires them.
3
3
 
4
- import type { Hot, Mode, Settings } from '../types'
4
+ import type { Hot, LimitItem, Mode, Settings } from '../types'
5
5
 
6
6
  /** The mode that is on, if the settings name one that exists. */
7
7
  export function activeModeOf(s: Pick<Settings, 'modes' | 'activeMode'>): Mode | undefined {
@@ -91,3 +91,58 @@ export function modeOver(mode: Pick<Mode, 'until'>, since: number | undefined, n
91
91
  const passed = (at: number) => at > since && at <= now
92
92
  return windows.some(w => passed(w.at) || (w.windowMs !== undefined && passed(w.at - w.windowMs)))
93
93
  }
94
+
95
+ /** The configured providers of ChatGPT type, from GET /api/status `providers` (name → {type}). */
96
+ export function chatgptProviders(status: unknown): string[] {
97
+ const providers = record(status).providers
98
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return []
99
+ return Object.entries(providers as Record<string, unknown>)
100
+ .filter(([, value]) => record(value).type === 'chatgpt')
101
+ .map(([name]) => name)
102
+ }
103
+
104
+ /**
105
+ * Every signed-in ChatGPT account's known windows as the limits row reads them, from GET
106
+ * /api/chatgpt-accounts (`quota.rate_limits.{primary,secondary}` with `reset_at` in epoch
107
+ * seconds or ms, or `reset_after_seconds` from the snapshot's `at`, and `window_minutes`;
108
+ * the same shape `quotaWindows` reads). Paused and needs-login accounts are left out (and take
109
+ * no number), and so is an account with no snapshot. `account` numbers the accounts kept, in
110
+ * answer order. A snapshot is not aged out: the router takes it from the account's own replies,
111
+ * so an account idle for an hour holds an hour-old figure that is still right (measured
112
+ * 2026-10-15: 30 minutes old between uses). Only a window whose reset has passed is dropped,
113
+ * since its figure no longer stands.
114
+ */
115
+ export function gptLimitsFrom(body: unknown, provider: string, now: number): LimitItem[] {
116
+ const accounts = record(body).accounts
117
+ if (!Array.isArray(accounts)) return []
118
+ const kept = accounts.map(record).filter(account => account.state !== 'paused' && account.state !== 'needs-login' && account.paused !== true)
119
+ const out: LimitItem[] = []
120
+ kept.forEach((account, index) => {
121
+ const quota = record(account.quota)
122
+ const at = quota.at
123
+ if (typeof at !== 'number') return
124
+ const limits = record(quota.rate_limits)
125
+ for (const key of ['primary', 'secondary']) {
126
+ const window = record(limits[key])
127
+ const used = window.used_percent
128
+ if (typeof used !== 'number' || !Number.isFinite(used)) continue
129
+ const minutes = window.window_minutes
130
+ const resetAt = typeof window.reset_at === 'number' ? (window.reset_at > 1e12 ? window.reset_at : window.reset_at * 1000)
131
+ : typeof window.reset_after_seconds === 'number' && typeof at === 'number' ? at + window.reset_after_seconds * 1000 : NaN
132
+ if (Number.isFinite(resetAt) && resetAt <= now) continue
133
+ const kind = minutes === 300 ? 'five_hour' : minutes === 10080 ? 'seven_day' : 'window'
134
+ out.push({
135
+ provider,
136
+ type: 'chatgpt',
137
+ title: 'GPT',
138
+ account: index,
139
+ kind,
140
+ label: '',
141
+ ...(typeof minutes === 'number' && minutes > 0 ? { windowMinutes: minutes } : {}),
142
+ percent: Math.round(used * 10) / 10,
143
+ ...(Number.isFinite(resetAt) ? { resetsAt: new Date(resetAt).toISOString() } : {}),
144
+ })
145
+ }
146
+ })
147
+ return out
148
+ }
@@ -2,29 +2,32 @@
2
2
  // Timers own background work; hooks only admit/rewrite a request and record its result.
3
3
  import { atom, read, update } from 'claude-code'
4
4
  import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, SessionRateLimit, 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'
5
+ import type { AgentRec, Effort, JudgeInput, JudgeKind, JudgeRun, LimitItem, ModSettingsSnapshot, Pick, Settings, StatusRequest, TierTarget, Verdict, WorkerCandidate, WorkerPick, WorkerVerdict } from '../types'
6
+ import { MOD_SETTINGS_SCHEMA_VERSION, RUNG_EFFORT, applySettingsChanges, normalizeSettings, settingsChanges } from './settings'
7
+ import { rungOf } from './ladder'
7
8
  import { debugText, langFrom, t } from './i18n'
8
9
  import type { Lang } from './i18n'
9
10
  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 { AUTO_DESCRIPTION, WORKER_JUDGE_SYSTEM, agentProvider, candidateUsable, chatgptUsable, claudeCandidates, ladderOptionsFrom, ladderPick, parseWorkerVerdict, readMarker, routerModelOptions, spawnModel, spawnTitle, takesOver, TIER_MARKERS, tierOf, workerJudgeInput, zenJev } from './workers'
11
12
  import { MARKER, agentModel, fixSpawn } from './worker'
12
- import { activeModeOf, claudeWindow, eagerNote, modeKey, modeOver, quotaWindows, resetProvider, routingSettings } from './modes'
13
+ import { activeModeOf, chatgptProviders, claudeWindow, eagerNote, gptLimitsFrom, modeKey, modeOver, quotaWindows, resetProvider, routingSettings } from './modes'
13
14
  import type { QuotaWindow } from './modes'
14
15
  import type { Spawn, WorkerFix } from './worker'
15
16
  import { afterPing, nextMemo, restoreFromResume, shouldPing } from './cache'
16
- import { bandPriority, claudeLimitsFrom, coldAlert, hotAlert, hottest, keptAlert, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
17
+ import { bandPriority, claudeLimitsFrom, claudeScopedFrom, coldAlert, hotAlert, hottest, hotWindowMoved, keptAlert, routerLimitItems, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
18
+ import type { ScopedLimit } from './session'
17
19
  import { COMPACT_SYSTEM, compactResult, serializeTranscript } from './compact'
18
20
  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'
21
+ import { addAgentStep, addJudge, addPrompt, addRequest, addWarm, addWorker, emptyStats, formatStats, sentEffort } from './stats'
20
22
  import { besideBand, renderBand, renderFooter } from './ui/band'
21
23
  import { renderAlert } from './ui/alerts'
22
24
  import { renderSettings } from './ui/settings'
23
25
  import { renderSetup } from './ui/setup'
24
- import { renderAgents } from './ui/agents'
26
+ import { renderAgents, talkOf } from './ui/agents'
27
+ import { toolDetail } from './ui/tools'
25
28
  import { renderLog } from './ui/log'
26
29
  import { EFFORTS, modelName, nextMinuteChange, routeOf, targetLabel } from './ui/view'
27
- import type { AlertActions, AlertView, BandActions, DashboardView, Frame, SettingsActions, SettingsView, SetupActions } from './ui/view'
30
+ import type { AgentTalk, AgentsActions, AlertActions, AlertView, BandActions, DashboardView, Frame, SettingsActions, SettingsView, SetupActions } from './ui/view'
28
31
 
29
32
  const line = atom({ plugin: 'ripple', key: 'line' } as const, null)
30
33
  const last = atom({ plugin: 'ripple', key: 'last' } as const, null)
@@ -53,7 +56,9 @@ const lastRealReplyAt = atom({ plugin: 'ripple', key: 'lastRealReplyAt' } as con
53
56
  const context = atom({ plugin: 'ripple', key: 'context' } as const, null)
54
57
  const hot = atom({ plugin: 'ripple', key: 'hot' } as const, null)
55
58
  const limit = atom({ plugin: 'ripple', key: 'limit' } as const, null)
59
+ const limits = atom({ plugin: 'ripple', key: 'limits' } as const, null)
56
60
  const hotHidden = atom({ plugin: 'ripple', key: 'hotHidden' } as const, null)
61
+ const hotHiddenFor = atom({ plugin: 'ripple', key: 'hotHiddenFor' } as const, null)
57
62
  const saveUntil = atom({ plugin: 'ripple', key: 'saveUntil' } as const, null)
58
63
  const swampHidden = atom({ plugin: 'ripple', key: 'swampHidden' } as const, null)
59
64
  const coldHidden = atom({ plugin: 'ripple', key: 'coldHidden' } as const, false)
@@ -82,6 +87,10 @@ const compactNote = atom({ plugin: 'ripple', key: 'compactNote' } as const, '')
82
87
  const effortMenuOpen = atom({ plugin: 'ripple', key: 'effortMenuOpen' } as const, false)
83
88
  const agentsOpen = atom({ plugin: 'ripple', key: 'agentsOpen' } as const, false)
84
89
  const agentsShowDone = atom({ plugin: 'ripple', key: 'agentsShowDone' } as const, false)
90
+ const agentOpen = atom({ plugin: 'ripple', key: 'agentOpen' } as const, null)
91
+ const agentEarlier = atom({ plugin: 'ripple', key: 'agentEarlier' } as const, 0)
92
+ const agentRunsOpen = atom({ plugin: 'ripple', key: 'agentRunsOpen' } as const, [])
93
+ const agentsTick = atom({ plugin: 'ripple', key: 'agentsTick' } as const, 0)
85
94
  const jev = atom({ plugin: 'ripple', key: 'jev' } as const, { source: null, test: { state: 'idle' } })
86
95
  const routerModels = atom({ plugin: 'ripple', key: 'routerModels' } as const, [])
87
96
  const ladderOptions = atom({ plugin: 'ripple', key: 'ladderOptions' } as const, null)
@@ -144,12 +153,18 @@ async function rippleHome($: EngineInterface): Promise<string> {
144
153
  }
145
154
  // The fs API has no append: serialize read/write pairs so concurrent picks in this mod survive.
146
155
  let workerLogWork: Promise<void> = Promise.resolve()
156
+ /**
157
+ * Lines the pick log keeps. Each delegation writes up to three (pick, shadow verdict, outcome), so
158
+ * 300 lines held only a few days; weighing the judge against the main chat's tier markers needs
159
+ * weeks (2026-10-15).
160
+ */
161
+ const PICK_LOG_LINES = 3000
147
162
  async function persistWorkerPick($: EngineInterface, entry: Record<string, unknown>): Promise<void> {
148
163
  const work = workerLogWork.then(async () => {
149
164
  try {
150
165
  const path = `${await rippleHome($)}/logs/worker-picks.jsonl`
151
166
  const old = await $.fs.exists(path) ? await $.fs.read(path) : ''
152
- const lines = old.split('\n').filter(line => line.trim()).slice(-299)
167
+ const lines = old.split('\n').filter(line => line.trim()).slice(-(PICK_LOG_LINES - 1))
153
168
  await $.fs.write(path, [...lines, JSON.stringify(entry)].join('\n') + '\n')
154
169
  } catch (error) { await report($, 'worker pick log', error) }
155
170
  })
@@ -346,7 +361,15 @@ async function toggleLog($: EngineInterface): Promise<boolean> {
346
361
  const language = await read($, lang)
347
362
  const open = await isLogUp($)
348
363
  if (open) await $.ui.close({ id: LOG_PANE })
349
- else await $.ui.open({ id: LOG_PANE, title: t(language, 'commands.logTitle') })
364
+ else {
365
+ // The surface may decline to draw the pane; say why rather than doing nothing on a press.
366
+ const opened = await $.ui.open({ id: LOG_PANE, title: t(language, 'commands.logTitle') })
367
+ if (!opened.isPlaced) {
368
+ const why = 'reason' in opened && opened.reason ? String(opened.reason) : 'not placed'
369
+ $.ui.toast(t(language, 'toasts.logNotPlaced', { reason: why }))
370
+ await report($, 'log pane', why)
371
+ }
372
+ }
350
373
  await syncPanes($)
351
374
  if (!open) await refreshRouter($)
352
375
  return !open
@@ -412,6 +435,18 @@ async function workerFiles($: EngineInterface): Promise<Map<string, string>> {
412
435
  }
413
436
  return files
414
437
  }
438
+ /**
439
+ * The provider a started agent runs on, for its card's dot: its worker file's (`via <provider>.`),
440
+ * Anthropic for a Claude id, else the router's catalogue entry for the id.
441
+ */
442
+ async function spawnProvider($: EngineInterface, subagentType: string, model: string): Promise<string | undefined> {
443
+ const file = (await workerFiles($)).get(subagentType)
444
+ const fromFile = file ? agentProvider(file) : undefined
445
+ if (fromFile) return fromFile
446
+ const id = model.replace(/@[^@]*$/, '')
447
+ if (/^(?:sonnet|opus|haiku|fable|mythos|claude-)/.test(id)) return 'anthropic'
448
+ return (await read($, routerModels)).find(m => m.value === id)?.provider
449
+ }
415
450
  async function workerFix($: EngineInterface, spawn: Spawn): Promise<WorkerFix | null> {
416
451
  const text = await optionalFile($, `${await rippleHome($)}/generated-agents.json`)
417
452
  if (!text || !strings(object(JSON.parse(text)).agents).includes(spawn.subagentType)) return null
@@ -660,7 +695,9 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
660
695
  const pinnedCandidates = [...[...files].flatMap(([agent, file]) => { const model = agentModel(file)?.replace(/@[^@]*$/, ''); const provider = agentProvider(file); return model && provider ? [{ target: { agent }, model, provider }] : [] }), ...claudeCandidates()]
661
696
  const usable = await availability($)
662
697
  const marker = readMarker(e.prompt)
663
- const explicit = marker && !['auto', 'ripple'].includes(marker.name) ? marker : null
698
+ const tier = tierOf(marker)
699
+ const explicit = marker && !tier && !['auto', 'ripple'].includes(marker.name) ? marker : null
700
+ const id = (await $.clock.now()).toString(36) + Math.random().toString(36).slice(2, 8)
664
701
  let verdict: WorkerVerdict = { domain: 'general', level: 'standard', effort: 'high', why: t(language, 'toasts.judgeUnavailable') }
665
702
  let by: WorkerPick['by'] = 'default'
666
703
  let ms = 0
@@ -673,6 +710,13 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
673
710
  target = name ? { agent: name } : { agent: 'general-purpose', model: explicit.name }
674
711
  verdict = { domain: 'general', level: 'standard', effort: effortOf(explicit.level) ?? 'high', why: t(language, 'toasts.explicitMarker') }
675
712
  by = 'marker'
713
+ } else if (tier) {
714
+ // The main chat named the tier. The judge still rates the same brief, unawaited, so the log
715
+ // can later say which of the two was right (see shadowJudge).
716
+ const named = TIER_MARKERS[tier]!
717
+ verdict = { domain: 'general', ...named, effort: RUNG_EFFORT[rungOf(named)], why: t(language, 'toasts.sessionTier', { tier }) }
718
+ by = 'session'
719
+ void shadowJudge($, s, id, { description: e.description, prompt: e.prompt })
676
720
  } else {
677
721
  const judged = await judge($, s, { description: e.description, prompt: e.prompt }, true)
678
722
  if (judged.verdict && 'domain' in judged.verdict) { rawVerdict = judged.verdict; verdict = judged.verdict; by = judged.by }
@@ -700,18 +744,34 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
700
744
  const model = spawnModel(selected, verdict.effort, files.has(selected.agent) ? agentModel(files.get(selected.agent)!) : undefined)
701
745
  let prompt = e.prompt
702
746
  if (marker) prompt = prompt.replace(MARKER, '').replace(/^\s*\n/, '')
703
- const value: WorkerPick = { ...verdict, ...resolved, target: selected, by, ms, ...(fallbackReason ? { fallbackReason } : {}) }
747
+ const value: WorkerPick = { ...verdict, ...resolved, target: selected, by, ms, id, ...(fallbackReason ? { fallbackReason } : {}) }
704
748
  // The verdict behind a pick is otherwise only in the agents panel; one debug line per pick keeps
705
749
  // it checkable: what the judge said, the rung it landed on and what ran.
706
750
  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 } : {}) }
707
751
  await $.ui.log('ClaudeRipple worker pick: ' + JSON.stringify(entry), { to: 'debug' })
708
- 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 } : {}) })
752
+ // The brief itself is never logged (it can hold anything); its length is. To re-judge a pick,
753
+ // its brief is in the session's own transcript under the same description.
754
+ const named = by === 'marker' || by === 'default' || by === 'session'
755
+ await persistWorkerPick($, { type: 'pick', id, ...entry, at: new Date(now).toISOString(), verdict: rawVerdict, by: named ? by : 'judge', judge: named ? null : by, ...(tier ? { tier } : {}), ms, ...(judgeError ? { error: judgeError } : {}), promptChars: e.prompt.length })
709
756
  if (fallbackReason) await $.ui.toast(t(language, 'toasts.workerFallback', { reason: fallbackReason, name: targetLabel(selected, language) }))
710
757
  await update($, lastSpawn, () => value)
711
758
  await update($, stats, current => addWorker(current, verdict.level))
712
759
  return { input: { ...e, subagentType: selected.agent, model, prompt }, pick: value }
713
760
  }
714
761
 
762
+ /**
763
+ * What the judge would have said of a brief the main chat already tiered: logged beside the pick
764
+ * (same `id`), never awaited by the spawn, never acted on. Together with each pick's outcome it is
765
+ * what decides later whether the judge stays.
766
+ */
767
+ async function shadowJudge($: EngineInterface, s: Settings, id: string, input: { description: string; prompt: string }): Promise<void> {
768
+ try {
769
+ const judged = await judge($, s, input, true)
770
+ const verdict = judged.verdict && 'domain' in judged.verdict ? judged.verdict : null
771
+ await persistWorkerPick($, { type: 'shadow', pick: id, at: new Date(await $.clock.now()).toISOString(), verdict, rung: verdict ? rungOf(verdict) : null, judge: judged.by, ms: judged.ms, ...(judged.error ? { error: judged.error } : {}) })
772
+ } catch (error) { await report($, 'shadow judge', error) }
773
+ }
774
+
715
775
  async function toggleAuto($: EngineInterface): Promise<void> {
716
776
  const s = await currentSettings($)
717
777
  await saveSettings($, { ...s, effortAuto: !s.effortAuto })
@@ -941,6 +1001,81 @@ async function routerClaudeLimits($: EngineInterface): Promise<SessionRateLimit[
941
1001
  return status.ok ? claudeLimitsFrom(JSON.parse(status.text), await $.clock.now()) : []
942
1002
  } catch (error) { await report($, 'claude limits', error); return [] }
943
1003
  }
1004
+ /** The router's figures for the Claude login (5-hour, weekly, per-model weeklies), at most one
1005
+ * status lookup a minute and only while the limits row can show them; a failure keeps the last
1006
+ * good numbers rather than blanking them. */
1007
+ let routerClaudeAt = 0
1008
+ let routerClaudeCache: { limits: SessionRateLimit[]; scoped: ScopedLimit[] } = { limits: [], scoped: [] }
1009
+ async function routerClaude($: EngineInterface): Promise<{ limits: SessionRateLimit[]; scoped: ScopedLimit[] }> {
1010
+ const now = await $.clock.now()
1011
+ if (!(await currentSettings($)).extras.includes('limits') || now - routerClaudeAt < 60_000) return routerClaudeCache
1012
+ routerClaudeAt = now
1013
+ try {
1014
+ const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
1015
+ if (status.ok) {
1016
+ const body: unknown = JSON.parse(status.text)
1017
+ routerClaudeCache = { limits: claudeLimitsFrom(body, now), scoped: claudeScopedFrom(body, now) }
1018
+ }
1019
+ } catch (error) { await report($, 'claude limits row', error) }
1020
+ return routerClaudeCache
1021
+ }
1022
+ /**
1023
+ * The limits row as a router that predates `GET /api/limits` still answers it: Claude's plan in the
1024
+ * unified shape, from the older `/api/status` and `/api/chatgpt-accounts` routes. Kept so the row
1025
+ * shows exactly as before until the router is restarted onto the new endpoint.
1026
+ */
1027
+ async function fallbackLimits($: EngineInterface): Promise<LimitItem[]> {
1028
+ const now = await $.clock.now()
1029
+ const fromRouter = await routerClaude($)
1030
+ const claudeChat = /^claude-/.test(await $.session.model())
1031
+ // The 5-hour and the weekly (one each, the hotter reading winning when both sources name it), then
1032
+ // the per-model weeklies the router reports. Claude's plan shows whatever this chat runs on.
1033
+ const usage = claudeChat ? (await $.session.usage()).rateLimits : []
1034
+ const mains = new Map<'five_hour' | 'seven_day', SessionRateLimit>()
1035
+ for (const w of [...usage, ...fromRouter.limits]) {
1036
+ if (w.kind !== 'five_hour' && w.kind !== 'seven_day') continue
1037
+ const prev = mains.get(w.kind)
1038
+ if (!prev || w.percentUsed > prev.percentUsed) mains.set(w.kind, w)
1039
+ }
1040
+ const items: LimitItem[] = (['five_hour', 'seven_day'] as const)
1041
+ .flatMap(kind => mains.get(kind) ? [mains.get(kind)!] : [])
1042
+ .map(w => ({ provider: 'claude', type: 'anthropic', title: 'Claude', account: 0, kind: w.kind as 'five_hour' | 'seven_day', label: '', percent: w.percentUsed, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) }))
1043
+ for (const s of fromRouter.scoped) items.push({ provider: 'claude', type: 'anthropic', title: 'Claude', account: 0, kind: 'scoped', label: s.name, percent: s.percent, ...(s.resetsAt ? { resetsAt: s.resetsAt } : {}) })
1044
+ const admin = await adminUrl($)
1045
+ const status = await $.http.fetch(`${admin}/api/status`)
1046
+ if (status.ok) for (const provider of chatgptProviders(JSON.parse(status.text))) {
1047
+ const response = await $.http.fetch(`${admin}/api/chatgpt-accounts?provider=${encodeURIComponent(provider)}`)
1048
+ if (!response.ok) continue
1049
+ const body: unknown = JSON.parse(response.text)
1050
+ // The router answers for its first ChatGPT provider when the name is not one: not this provider.
1051
+ if (object(body).provider !== provider) continue
1052
+ items.push(...gptLimitsFrom(body, provider, now))
1053
+ }
1054
+ return items
1055
+ }
1056
+ /**
1057
+ * The whole limits row from the router's `GET /api/limits` — every provider whose quota it can read,
1058
+ * Claude first — at most one round a minute and only while the row can show it. On a router that
1059
+ * predates the endpoint (404) the older per-provider routes answer instead, so the row shows before
1060
+ * the router is restarted. A failure keeps the last good row rather than blanking it, and the state
1061
+ * is written only when it moved: a redraw that shows nothing new is wasted (each answer is drawn
1062
+ * afresh).
1063
+ */
1064
+ let limitsAt = 0
1065
+ async function refreshLimits($: EngineInterface): Promise<void> {
1066
+ if (!(await currentSettings($)).extras.includes('limits')) return
1067
+ const now = await $.clock.now()
1068
+ if (now - limitsAt < 60_000) return
1069
+ limitsAt = now
1070
+ try {
1071
+ const admin = await adminUrl($)
1072
+ const response = await $.http.fetch(`${admin}/api/limits`)
1073
+ let items: LimitItem[] | null = null
1074
+ if (response.ok) items = routerLimitItems(JSON.parse(response.text), now)
1075
+ else if (response.status === 404) items = await fallbackLimits($)
1076
+ if (items && !same(items, await read($, limits))) await update($, limits, () => items)
1077
+ } catch (error) { await report($, 'limits', error) }
1078
+ }
944
1079
  async function refreshUsage($: EngineInterface): Promise<void> {
945
1080
  const usage = await $.session.usage()
946
1081
  const c = usage.context
@@ -956,10 +1091,18 @@ async function refreshUsage($: EngineInterface): Promise<void> {
956
1091
  const rateLimits = own || !/^claude-/.test(await $.session.model()) ? usage.rateLimits : [...usage.rateLimits, ...await routerClaudeLimits($)]
957
1092
  const h = hottest(rateLimits)
958
1093
  const previous = await read($, hot)
959
- if (previous && (!h || previous.kind !== h.kind || previous.resetsAt !== h.resetsAt)) await update($, hotHidden, () => null)
1094
+ // A put-off lasts until the window it was put off for moves on (session.ts hotWindowMoved). A
1095
+ // put-off from an older mod carries no window: it adopts the current one.
1096
+ if (await read($, hotHidden) !== null) {
1097
+ const putOffFor = await read($, hotHiddenFor)
1098
+ if (!putOffFor) { if (h) await update($, hotHiddenFor, () => h) }
1099
+ else if (hotWindowMoved(putOffFor, h)) { await update($, hotHidden, () => null); await update($, hotHiddenFor, () => null) }
1100
+ }
960
1101
  if (JSON.stringify(h) !== JSON.stringify(previous)) await update($, hot, () => h)
961
1102
  const top = rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
962
1103
  const l = top ? { kind: top.kind, percent: top.percentUsed, ...(top.resetsAt ? { resetsAt: top.resetsAt } : {}) } : null
1104
+ // The limits row itself is read from the router's `/api/limits` (refreshLimits); the host's own
1105
+ // windows still bind the first row's limit and the hot alert here, as they always have.
963
1106
  // Check the previous binding deadline before a rolled-forward snapshot replaces it.
964
1107
  await endModeIfOver($)
965
1108
  if (JSON.stringify(l) !== JSON.stringify(await read($, limit))) await update($, limit, () => l)
@@ -997,6 +1140,7 @@ async function tick($: EngineInterface): Promise<void> {
997
1140
  ticking = true
998
1141
  try {
999
1142
  await refreshUsage($)
1143
+ await refreshLimits($)
1000
1144
  const now = await $.clock.now()
1001
1145
  const list = await read($, agents)
1002
1146
  if (list.some(a => a.state === 'running' && a.toolSince !== undefined && now - a.toolSince >= 8000)) {
@@ -1044,6 +1188,18 @@ function startTimers($: EngineInterface): void {
1044
1188
  $.clock.after(1, async () => { await refreshRouter($); await tick($) })
1045
1189
  $.clock.every(3000, async () => { await refreshRouter($) })
1046
1190
  $.clock.every(15000, async () => { await tick($) })
1191
+ $.clock.every(1000, async () => { await agentsClock($) })
1192
+ }
1193
+ /**
1194
+ * The Agents pane's clocks: each second an agent runs while the pane is open, one write that only
1195
+ * the pane reads, so its elapsed times tick and nothing else redraws.
1196
+ */
1197
+ async function agentsClock($: EngineInterface): Promise<void> {
1198
+ try {
1199
+ if (!(await read($, agents)).some(a => a.state === 'running' || a.state === 'waiting')) return
1200
+ if (!(await $.ui.panes()).some(p => p.id === AGENTS_PANE)) return
1201
+ await update($, agentsTick, n => n + 1)
1202
+ } catch (error) { await report($, 'agents clock', error) }
1047
1203
  }
1048
1204
 
1049
1205
  async function dashboard($: EngineInterface, working: boolean): Promise<DashboardView> {
@@ -1061,6 +1217,7 @@ async function dashboard($: EngineInterface, working: boolean): Promise<Dashboar
1061
1217
  model: await read($, modelIs) ? modelName((await read($, modelIs))!) : null,
1062
1218
  context: await read($, context), cache: await read($, cache), warm: { ...w, on: await keepWarmOn($, s), ...(await read($, warmHere) !== null ? { here: true } : {}) },
1063
1219
  limit: l ? { ...l, save: saving } : null, route: record ? routeOf(record) : null, notice: await read($, line),
1220
+ limits: await read($, limits) ?? [],
1064
1221
  auto: s.effortAuto, handoff: await read($, advice) ? 'advised' : s.handoffButton === 'always' ? 'always' : 'off',
1065
1222
  agents: { live: list.filter(a => ['running', 'waiting'].includes(a.state)).length, total: list.length },
1066
1223
  logOpen: await read($, logOpen), agentsOpen: await read($, agentsOpen), effortMenu: await read($, effortMenuOpen),
@@ -1075,7 +1232,7 @@ async function frameOf($: EngineInterface, e: RenderInput): Promise<Frame> {
1075
1232
  const s = await currentSettings($)
1076
1233
  const columns = 'bodyColumns' in e.props ? e.props.bodyColumns : e.viewport?.columns ?? 100
1077
1234
  const maxRows = 'maxRows' in e.props ? e.props.maxRows : 'scroll' in e.props ? e.props.scroll.bodyRows : 1
1078
- return { surface: e.surface, columns, maxRows, now: await $.clock.now(), appearance: s.appearance, lang: await read($, lang) }
1235
+ return { surface: e.surface, columns, maxRows, now: await $.clock.now(), appearance: s.appearance, skin: s.skin, lang: await read($, lang) }
1079
1236
  }
1080
1237
  async function alertView($: EngineInterface, now: number): Promise<AlertView | null> {
1081
1238
  const language = await read($, lang)
@@ -1122,8 +1279,11 @@ async function markSeen($: EngineInterface): Promise<void> {
1122
1279
  }
1123
1280
  async function dismiss($: EngineInterface, view: AlertView): Promise<void> {
1124
1281
  await update($, demo, () => null)
1125
- if (view.kind === 'hot') await update($, hotHidden, () => view.percent)
1126
- else if (view.kind === 'cold') await update($, coldHidden, () => true)
1282
+ if (view.kind === 'hot') {
1283
+ const current = await read($, hot)
1284
+ await update($, hotHidden, () => view.percent)
1285
+ await update($, hotHiddenFor, () => current)
1286
+ } else if (view.kind === 'cold') await update($, coldHidden, () => true)
1127
1287
  else if (view.kind === 'kept') await update($, kept, () => null)
1128
1288
  else if (view.kind === 'swamp') await update($, swampHidden, () => view.percent)
1129
1289
  else if (view.kind === 'judgeDown') await update($, judgeDownHidden, () => view.reason)
@@ -1154,10 +1314,33 @@ function alertActions($: EngineInterface, view: AlertView): AlertActions {
1154
1314
  return { compact: note => runCompact($, note), clear: async () => { await update($, panel, () => null); await $.command.run({ command: 'clear', args: '' }) }, handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
1155
1315
  noteInput: text => update($, compactNote, () => text), setHandoffKind: kind => update($, handoffChoiceKind, () => kind), setHandoffAfter: after => update($, handoffChoiceAfter, () => after), go: () => queueHandoff($), copy: () => copyHandoff($), openNew: () => moveToNewSession($) }
1156
1316
  }
1157
- function toolLine(e: ToolCallInput): string {
1158
- const fields = object(e)
1159
- const detail = fields.command ?? fields.file_path ?? fields.pattern ?? fields.query ?? fields.url ?? fields.description ?? ''
1160
- return `${e.tool} ${String(detail).split('\n')[0]}`.trim().slice(0, 70)
1317
+ function agentsActions($: EngineInterface): AgentsActions {
1318
+ return {
1319
+ toggleDone: () => update($, agentsShowDone, value => !value),
1320
+ open: async id => { await update($, agentEarlier, () => 0); await update($, agentRunsOpen, () => []); await update($, agentOpen, () => id) },
1321
+ // Back on the list, the runs unfolded in that conversation fold again: it opens folded next time.
1322
+ back: async () => { await update($, agentRunsOpen, () => []); await update($, agentOpen, () => null) },
1323
+ earlier: () => update($, agentEarlier, n => n + 1),
1324
+ toggleRun: id => update($, agentRunsOpen, ids => ids.includes(id) ? ids.filter(i => i !== id) : [...ids, id]),
1325
+ }
1326
+ }
1327
+ /**
1328
+ * An agent's conversation for the Agents pane, read again at each draw: its steps and tool calls
1329
+ * redraw the pane while it runs, so what it says shows as it says it. Paths in its tool calls read
1330
+ * relative to the session's folder, or under `~`.
1331
+ */
1332
+ async function agentTalk($: EngineInterface, id: string): Promise<AgentTalk> {
1333
+ try {
1334
+ const found = await $.session.messages({ agentId: id })
1335
+ if (!Array.isArray(found)) return { deny: found.deny }
1336
+ const cwd = await $.session.cwd().catch(() => undefined)
1337
+ return { entries: talkOf(found, { ...(cwd ? { cwd } : {}), home: await userHome($) }) }
1338
+ } catch (error) { return { deny: reason(error) } }
1339
+ }
1340
+ /** What an agent is doing, in the words its conversation's tool rows use (tools.ts toolDetail): no leading `cd`, paths short. */
1341
+ async function toolLine($: EngineInterface, e: ToolCallInput): Promise<string> {
1342
+ const cwd = await $.session.cwd().catch(() => undefined)
1343
+ return `${e.tool} ${toolDetail(e.tool, object(e), { ...(cwd ? { cwd } : {}), home: await userHome($) })}`.trim().slice(0, 70)
1161
1344
  }
1162
1345
  async function prepareStep($: EngineInterface, e: TurnStepInput): Promise<TurnStepInput> {
1163
1346
  const language = await read($, lang)
@@ -1210,9 +1393,10 @@ export const register: Register = on => {
1210
1393
  const result = await next(e)
1211
1394
  jevBlips = 0
1212
1395
  try {
1213
- await loadSettings($)
1396
+ // A failure here must not cost the commands: without them there is no /ripple to look with.
1397
+ try { await loadSettings($); await $.ui.status(undefined) }
1398
+ catch (error) { await report($, 'session.start settings', error) }
1214
1399
  const language = await read($, lang)
1215
- await $.ui.status(undefined)
1216
1400
  await $.command.register({ name: 'ripple', description: t(language, 'commands.description'), argumentHint: t(language, 'commands.argumentHint'), immediate: true })
1217
1401
  await $.command.register({ name: 'ripple-log', description: t(language, 'commands.logDescription'), immediate: true })
1218
1402
  await $.command.register({ name: 'ripple-bar', description: t(language, 'commands.barDescription'), immediate: true })
@@ -1327,7 +1511,8 @@ export const register: Register = on => {
1327
1511
  if (epoch !== generation) return answer
1328
1512
  if (ownFork) { if (answer.usage) forkUsage = answer.usage; return answer }
1329
1513
  if (e.agentId && answer.usage) {
1330
- await update($, agents, list => list.map(a => a.id === e.agentId ? { ...a, cost: a.cost + weighted(answer.usage!) } : a))
1514
+ const effort = sentEffort(request.model, request.effort)
1515
+ await update($, agents, list => list.map(a => a.id === e.agentId ? addAgentStep(a, answer.usage!, effort) : a))
1331
1516
  } else if (!e.agentId && answer.usage) {
1332
1517
  const now = await $.clock.now()
1333
1518
  if (answer.usage.model === await $.session.model()) {
@@ -1349,7 +1534,13 @@ export const register: Register = on => {
1349
1534
  const language = await read($, lang)
1350
1535
  if (e.agentId) {
1351
1536
  const now = await $.clock.now()
1537
+ const before = (await read($, agents)).find(a => a.id === e.agentId)
1352
1538
  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))
1539
+ // A picked worker's outcome, once, joined to its pick by id: whether the tier it was given
1540
+ // (by the main chat or the judge) was enough is read from these lines later.
1541
+ if (before?.pick?.id && (before.state === 'running' || before.state === 'waiting')) {
1542
+ await persistWorkerPick($, { type: 'outcome', pick: before.pick.id, agent: e.agentId, at: new Date(now).toISOString(), reason: e.reason, aborted: e.isAborted, ms: e.durationMs, answerChars: e.answer.trim().length, model: before.model ?? null, cost: before.cost })
1543
+ }
1353
1544
  } else if (!forking) {
1354
1545
  if (await read($, busy)) await update($, busy, () => false)
1355
1546
  const held = await read($, heldPick)
@@ -1388,18 +1579,22 @@ export const register: Register = on => {
1388
1579
  const files = await workerFiles($)
1389
1580
  try { await refreshModels($) } catch (error) { await report($, 'explicit worker catalogue', error) }
1390
1581
  const file = files.get(e.subagentType)
1391
- 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'
1582
+ const provider = file ? agentProvider(file) : e.model && !/^(?:sonnet|opus|haiku|fable|mythos|claude-)/.test(e.model) ? (await read($, routerModels)).find(m => m.value === e.model?.replace(/@[^@]*$/, ''))?.provider : 'anthropic'
1392
1583
  if (provider && s.providers[provider] === false) return { deny: t(await read($, lang), 'workers.disabled', { provider }) }
1393
1584
  const marker = readMarker(e.prompt)
1394
- if (marker) {
1585
+ // A tier marker means something only to ripple:auto; on any other worker it is dropped
1586
+ // rather than read as a model named "hard" (which the router would refuse).
1587
+ if (tierOf(marker)) {
1588
+ input = { ...e, prompt: e.prompt.replace(MARKER, '').replace(/^\s*\n/, '') }
1589
+ } else if (marker) {
1395
1590
  const markerFile = files.get(marker.name) ?? [...files.values()].find(f => agentModel(f)?.replace(/@[^@]*$/, '') === marker.name)
1396
1591
  const markerProvider = markerFile ? agentProvider(markerFile) : (await read($, routerModels)).find(m => m.value === marker.name)?.provider
1397
1592
  if (markerProvider && s.providers[markerProvider] === false) return { deny: t(await read($, lang), 'workers.disabled', { provider: markerProvider }) }
1398
1593
  }
1399
- const fix = await workerFix($, e)
1594
+ const fix = await workerFix($, input)
1400
1595
  if (fix) {
1401
1596
  if (fix.dropped) await $.ui.toast(t(await read($, lang), 'toasts.ownModel', { name: e.subagentType, model: fix.dropped }))
1402
- input = { ...e, prompt: fix.prompt, model: fix.model }
1597
+ input = { ...input, prompt: fix.prompt, model: fix.model }
1403
1598
  }
1404
1599
  }
1405
1600
  } catch (error) { input = e; await report($, 'agent.spawn prepare', error) }
@@ -1415,7 +1610,9 @@ export const register: Register = on => {
1415
1610
  const result = await next(input)
1416
1611
  try {
1417
1612
  if (result.agentId) {
1418
- 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 }
1613
+ const provider = await spawnProvider($, input.subagentType, result.model)
1614
+ const effort = chosen?.effort ?? /@([a-z]+)$/.exec(result.model)?.[1]
1615
+ const rec: AgentRec = { id: result.agentId, description: input.description, type: input.subagentType, model: result.model, ...(chosen ? { pick: chosen } : {}), ...(provider ? { provider } : {}), ...(effort ? { effort } : {}), state: 'running', startedAt: await $.clock.now(), cost: 0 }
1419
1616
  await update($, agents, list => [...list.filter(a => a.id !== rec.id), rec].slice(-40))
1420
1617
  }
1421
1618
  } catch (error) { await report($, 'agent.spawn record', error) }
@@ -1425,7 +1622,8 @@ export const register: Register = on => {
1425
1622
  let started = 0
1426
1623
  try {
1427
1624
  started = await $.clock.now()
1428
- if (e.agentId) await update($, agents, list => list.map(a => a.id === e.agentId ? { ...a, now: toolLine(e), toolSince: started, state: 'running' } : a))
1625
+ const line = e.agentId ? await toolLine($, e) : ''
1626
+ if (e.agentId) await update($, agents, list => list.map(a => a.id === e.agentId ? { ...a, now: line, toolSince: started, state: 'running', tools: (a.tools ?? 0) + 1 } : a))
1429
1627
  } catch (error) { await report($, 'tool.call start', error) }
1430
1628
  try { return await next(e) }
1431
1629
  finally {
@@ -1494,7 +1692,11 @@ export const register: Register = on => {
1494
1692
  return next(e)
1495
1693
  })
1496
1694
  on('ui.close', { id: AGENTS_PANE }, async ($, e, next) => {
1497
- try { await update($, agentsOpen, () => false) } catch (error) { await report($, 'agents close', error) }
1695
+ try {
1696
+ await update($, agentsOpen, () => false)
1697
+ // Opened again, the pane starts on the list.
1698
+ if (await read($, agentOpen) !== null) await update($, agentOpen, () => null)
1699
+ } catch (error) { await report($, 'agents close', error) }
1498
1700
  return next(e)
1499
1701
  })
1500
1702
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
@@ -1554,7 +1756,11 @@ export const register: Register = on => {
1554
1756
  try {
1555
1757
  startTimers($)
1556
1758
  const frame = await frameOf($, e)
1557
- return renderAgents($.ui.resolve(e), frame, { agents: await read($, agents), now: frame.now, showDone: await read($, agentsShowDone) }, { toggleDone: () => update($, agentsShowDone, value => !value) }) as RenderElement
1759
+ // Read to be drawn again each second an agent runs (agentsClock): its elapsed time ticks.
1760
+ await read($, agentsTick)
1761
+ const id = await read($, agentOpen)
1762
+ const open = id === null ? undefined : { id, talk: await agentTalk($, id), earlier: await read($, agentEarlier), runsOpen: await read($, agentRunsOpen) }
1763
+ return renderAgents($.ui.resolve(e), frame, { agents: await read($, agents), now: frame.now, showDone: await read($, agentsShowDone), ...(open ? { open } : {}) }, agentsActions($)) as RenderElement
1558
1764
  } catch (error) { await report($, 'agents render', error); return next(e) }
1559
1765
  })
1560
1766
  }