clauderipple 0.7.0 → 0.8.1

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 (46) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.ko.md +18 -2
  3. package/README.md +27 -5
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +46 -1
  9. package/dist/cli/src/picker.js +19 -11
  10. package/dist/cli/src/runtime.js +3 -0
  11. package/dist/cli/src/settings.js +49 -0
  12. package/dist/mod/.claude-plugin/plugin.json +1 -0
  13. package/dist/mod/hooks/hooks.json +1 -0
  14. package/dist/mod/hooks/register.tsx +309 -0
  15. package/dist/mod/hooks/worker.ts +49 -0
  16. package/dist/mod/tsconfig.json +3 -0
  17. package/dist/mod/types/index.d.ts +30 -0
  18. package/dist/router/src/admin.js +411 -32
  19. package/dist/router/src/agents.js +24 -8
  20. package/dist/router/src/catalog.js +80 -0
  21. package/dist/router/src/compat.js +27 -1
  22. package/dist/router/src/config.js +31 -0
  23. package/dist/router/src/index.js +12 -1
  24. package/dist/router/src/ingress/server.js +3 -2
  25. package/dist/router/src/picker.js +33 -2
  26. package/dist/router/src/presets.js +2 -1
  27. package/dist/router/src/providers/chatgpt/index.js +77 -18
  28. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  29. package/dist/router/src/providers/google/accounts.js +345 -0
  30. package/dist/router/src/providers/google/antigravity.js +392 -0
  31. package/dist/router/src/providers/google/index.js +617 -0
  32. package/dist/router/src/providers/google/login.js +249 -0
  33. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  34. package/dist/router/src/providers/google/translate.js +539 -0
  35. package/dist/router/src/providers/google/transport.js +118 -0
  36. package/dist/router/src/providers/openai/translate.js +3 -3
  37. package/dist/router/src/proxy.js +116 -9
  38. package/dist/router/src/requestlog.js +2 -0
  39. package/dist/router/src/routing.js +22 -0
  40. package/dist/router/src/version.js +1 -1
  41. package/dist/ui/app.js +420 -45
  42. package/dist/ui/i18n.js +76 -4
  43. package/dist/ui/index.html +5 -0
  44. package/dist/ui/style.css +4 -0
  45. package/docs/ARCHITECTURE.md +307 -4
  46. package/package.json +1 -1
@@ -214,3 +214,52 @@ export function agentTitleHookEnabled() {
214
214
  return false;
215
215
  }
216
216
  }
217
+ // ---- Status mod (packages/mod) ------------------------------------------------------------
218
+ /** The `name` in packages/mod/.claude-plugin/plugin.json: how our entry is told from anyone else's. */
219
+ export const STATUS_MOD_NAME = "clauderipple-status";
220
+ const PLUGIN_DIRS = "CLAUDE_CODE_PLUGIN_DIRS";
221
+ function isStatusMod(dir) {
222
+ try {
223
+ return JSON.parse(fs.readFileSync(path.join(dir, ".claude-plugin", "plugin.json"), "utf8")).name === STATUS_MOD_NAME;
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ }
229
+ function pluginDirs(env) {
230
+ return (env[PLUGIN_DIRS] ?? "").split(path.delimiter).filter(Boolean);
231
+ }
232
+ /**
233
+ * Registers or removes the status mod in settings.json's `env.CLAUDE_CODE_PLUGIN_DIRS`, the one
234
+ * place a session the desktop app starts reads plugin folders from (Claude Code docs, plugin
235
+ * authoring reference, CLI 2.1.286). The variable is a path list other plugins may share: only
236
+ * entries that are this mod are touched — by its manifest's name, so an install that moved
237
+ * (a checkout, then the app) leaves no stale entry behind — and the variable goes only when we
238
+ * were its last entry.
239
+ */
240
+ export function setStatusMod(enabled, dir) {
241
+ const file = settingsPath();
242
+ const s = readSettings(file);
243
+ const env = { ...(s.env ?? {}) };
244
+ const before = pluginDirs(env);
245
+ const kept = before.filter((entry) => path.resolve(entry) !== path.resolve(dir) && !isStatusMod(entry));
246
+ const after = enabled ? [...kept, dir] : kept;
247
+ if (after.join(path.delimiter) === before.join(path.delimiter))
248
+ return { changed: false, backup: null, notes: [], wroteSlots: [] };
249
+ if (after.length > 0)
250
+ env[PLUGIN_DIRS] = after.join(path.delimiter);
251
+ else
252
+ delete env[PLUGIN_DIRS];
253
+ const b = backup(file);
254
+ s.env = env;
255
+ write(file, s);
256
+ return { changed: true, backup: b, notes: [`env.${PLUGIN_DIRS}: ClaudeRipple status mod ${enabled ? "added" : "removed"}`], wroteSlots: [] };
257
+ }
258
+ export function statusModEnabled() {
259
+ try {
260
+ return pluginDirs(readSettings(settingsPath()).env ?? {}).some(isStatusMod);
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
@@ -0,0 +1 @@
1
+ { "name": "clauderipple-status", "version": "0.1.0", "description": "Shows where ClaudeRipple sent each request and its request log, and keeps ClaudeRipple workers on their own model and effort.", "types": "./types/index.d.ts" }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,309 @@
1
+ // What did ClaudeRipple do with this session's model requests? The router records each request
2
+ // under the Claude Code session that sent it (`X-Claude-Code-Session-Id`). A one-line band above
3
+ // the prompt shows where the last one went, with a button to the request log and an × that puts
4
+ // the band away (`/ripple-bar` brings it back); `/ripple-log` opens the log too. The log is the
5
+ // router's request records, not its text log: one entry per model request with what matters
6
+ // (model, provider, effort, tokens, cache, time, result). All of that only reads the router's
7
+ // admin API. The one thing the mod changes is a spawn of a ClaudeRipple worker (./worker.ts).
8
+
9
+ import { atom, read, update } from 'claude-code'
10
+ import type { EngineInterface, Register } from 'claude-code'
11
+
12
+ import type { StatusRequest } from '../types'
13
+ import { agentModel, fixSpawn } from './worker'
14
+ import type { Spawn, WorkerFix } from './worker'
15
+
16
+ // The router's admin port, as the router itself works it out (packages/router/src/admin.ts
17
+ // `adminPort`): `admin.port`, else one above the proxy port; 8792 when the config cannot be read.
18
+ async function userHome($: EngineInterface): Promise<string> {
19
+ return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~'
20
+ }
21
+
22
+ /** Where ClaudeRipple keeps its config (packages/router/src/config.ts `homeDir`). */
23
+ async function rippleHome($: EngineInterface): Promise<string> {
24
+ return (await $.env.get('CLAUDERIPPLE_HOME')) ?? `${await userHome($)}/.clauderipple`
25
+ }
26
+
27
+ /** Claude Code's own folder, where the generated agent files live. */
28
+ async function claudeHome($: EngineInterface): Promise<string> {
29
+ return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await userHome($)}/.claude`
30
+ }
31
+
32
+ /**
33
+ * The fix for a spawn of one of ClaudeRipple's workers (./worker.ts), or null. A worker is a name
34
+ * the router lists in generated-agents.json; anything else, Claude's own agents included, is left
35
+ * alone, as is every spawn when either file cannot be read.
36
+ */
37
+ async function workerFix($: EngineInterface, spawn: Spawn): Promise<WorkerFix | null> {
38
+ try {
39
+ const listed = JSON.parse(await $.fs.read(`${await rippleHome($)}/generated-agents.json`)) as { agents?: string[] }
40
+ if (!listed.agents?.includes(spawn.subagentType)) return null
41
+ const fileModel = agentModel(await $.fs.read(`${await claudeHome($)}/agents/${spawn.subagentType}.md`))
42
+ return fileModel ? fixSpawn(spawn, fileModel) : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ async function adminUrl($: EngineInterface): Promise<string> {
49
+ try {
50
+ const config = JSON.parse(await $.fs.read(`${await rippleHome($)}/config.json`)) as { admin?: { port?: number }; listen?: { port?: number } }
51
+ const port = config.admin?.port ?? (config.listen?.port ?? 8791) + 1
52
+ return `http://127.0.0.1:${port}`
53
+ } catch {
54
+ return 'http://127.0.0.1:8792'
55
+ }
56
+ }
57
+ const EVERY_MS = 3000
58
+ const LOG_ROWS = 60
59
+ const PANE = 'ripple-log'
60
+ const BAND_HIDDEN_KEY = 'bandHidden'
61
+
62
+ const OK = '#16a34a'
63
+ const FAIL = '#dc2626'
64
+ const WARN = '#d97706'
65
+ const ACCENT = '#2563eb'
66
+
67
+ // A provider is named by whoever added it, so most names are not known here. The presets' default
68
+ // names keep their vendor's hue; any other name gets one from PALETTE picked by a hash of the name,
69
+ // so it keeps that color from one session to the next. The palette leaves out the status
70
+ // red/green/amber, so a provider is never read as a result.
71
+ const KNOWN: Record<string, string> = {
72
+ anthropic: '#d97757',
73
+ chatgpt: '#10a37f',
74
+ 'google-gemini': '#4285f4',
75
+ deepseek: '#4d6bfe',
76
+ 'opencode-go': '#9333ea',
77
+ 'opencode-zen': '#9333ea',
78
+ }
79
+ const PALETTE = ['#0891b2', '#db2777', '#7c3aed', '#0d9488', '#c026d3', '#4f46e5', '#0284c7', '#be185d']
80
+
81
+ export function providerColor(provider: string): string {
82
+ if (provider === 'refused') return FAIL
83
+ const known = KNOWN[provider]
84
+ if (known) return known
85
+ let hash = 0
86
+ for (const char of provider) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
87
+ return PALETTE[hash % PALETTE.length]!
88
+ }
89
+
90
+ // The acceptance bar for translated providers is a 90% cache hit.
91
+ function cacheColor(percent: number): string {
92
+ return percent >= 90 ? OK : percent >= 50 ? WARN : FAIL
93
+ }
94
+
95
+ // Set by session.start; lets a press redraw the log at once instead of on the next tick.
96
+ let refreshNow: (() => Promise<void>) | undefined
97
+
98
+ const line = atom({ plugin: 'clauderipple-status', key: 'line' } as const, null)
99
+ const last = atom({ plugin: 'clauderipple-status', key: 'last' } as const, null)
100
+ const requests = atom({ plugin: 'clauderipple-status', key: 'requests' } as const, [])
101
+ const logOpen = atom({ plugin: 'clauderipple-status', key: 'logOpen' } as const, false)
102
+ const onlySession = atom({ plugin: 'clauderipple-status', key: 'onlySession' } as const, false)
103
+ const bandHidden = atom({ plugin: 'clauderipple-status', key: 'bandHidden' } as const, false)
104
+
105
+ type Usage = NonNullable<StatusRequest['usage']>
106
+
107
+ function promptTokens(usage: Usage): number {
108
+ return usage.input + usage.cached + (usage.cacheWrite ?? 0)
109
+ }
110
+
111
+ function cachePercent(usage: Usage | undefined): number | undefined {
112
+ if (!usage) return undefined
113
+ const prompt = promptTokens(usage)
114
+ return prompt > 0 ? Math.round((usage.cached / prompt) * 100) : undefined
115
+ }
116
+
117
+ function compact(n: number): string {
118
+ return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
119
+ }
120
+
121
+ // The requested name's `@effort` is the agent file's default, not what was sent; the sent effort is
122
+ // its own field.
123
+ function modelOf(record: StatusRequest): string {
124
+ const source = record.source.replace(/@[^@]*$/, '')
125
+ return source === record.target ? record.target : `${record.target} (${source})`
126
+ }
127
+
128
+ function timeOf(at: string): string {
129
+ const date = new Date(at)
130
+ return [date.getHours(), date.getMinutes(), date.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
131
+ }
132
+
133
+ // Whether the pane is up is the engine's record, not ours: a reload drops the pane without a
134
+ // `ui.close` this module hears, while `$.state` keeps `logOpen` (seen 2026-10-05: the button stuck
135
+ // on "Close log" and the pane could not be reopened).
136
+ async function isLogUp($: EngineInterface): Promise<boolean> {
137
+ return (await $.ui.panes()).some(pane => pane.id === PANE)
138
+ }
139
+
140
+ async function openLog($: EngineInterface) {
141
+ await update($, logOpen, () => true)
142
+ await $.ui.open({ id: PANE, title: 'ClaudeRipple' })
143
+ }
144
+
145
+ async function closeLog($: EngineInterface) {
146
+ await update($, logOpen, () => false)
147
+ await $.ui.close({ id: PANE })
148
+ }
149
+
150
+ async function toggleLog($: EngineInterface): Promise<boolean> {
151
+ if (await isLogUp($)) {
152
+ await closeLog($)
153
+ return false
154
+ }
155
+ await openLog($)
156
+ return true
157
+ }
158
+
159
+ async function hideBand($: EngineInterface) {
160
+ await update($, bandHidden, () => true)
161
+ await $.store.set(BAND_HIDDEN_KEY, true)
162
+ }
163
+
164
+ export const register: Register = on => {
165
+ on('session.start', async ($, e, next) => {
166
+ const result = await next(e)
167
+ const session = await $.session.id()
168
+ // An earlier version of this mod pinned a status line; this one draws nothing under the prompt.
169
+ $.ui.status(undefined)
170
+ const hidden = (await $.store.get(BAND_HIDDEN_KEY)) === true
171
+ await update($, bandHidden, () => hidden)
172
+ await $.command.register({ name: 'ripple-log', description: 'Open or close the ClaudeRipple request log' })
173
+ await $.command.register({ name: 'ripple-bar', description: 'Show or hide the ClaudeRipple line above the prompt' })
174
+ const ADMIN = await adminUrl($)
175
+
176
+ const refresh = async () => {
177
+ const isOpen = await isLogUp($)
178
+ if (isOpen !== (await read($, logOpen))) await update($, logOpen, () => isOpen)
179
+ if (!isOpen && (await read($, bandHidden))) return
180
+ try {
181
+ const own = await $.http.fetch(`${ADMIN}/api/requests?kind=messages&n=${isOpen ? LOG_ROWS : 1}&session=${encodeURIComponent(session)}`)
182
+ const mine = (JSON.parse(own.text) as { requests?: StatusRequest[] }).requests ?? []
183
+ const newest = mine[0] ?? null
184
+ // A router older than the session field answers every session's requests, which would show
185
+ // another conversation's route here: say so rather than show it.
186
+ const notice = newest && newest.session === undefined ? 'ClaudeRipple: restart the router to show routes here' : null
187
+ if (notice !== (await read($, line))) await update($, line, () => notice)
188
+ if (JSON.stringify(newest) !== JSON.stringify(await read($, last))) await update($, last, () => newest)
189
+ if (!isOpen) return
190
+ const shown = (await read($, onlySession))
191
+ ? mine
192
+ : (JSON.parse((await $.http.fetch(`${ADMIN}/api/requests?kind=messages&n=${LOG_ROWS}`)).text) as { requests?: StatusRequest[] }).requests ?? []
193
+ await update($, requests, () => shown.map(record => ({ ...record, mine: record.session === session })))
194
+ } catch {
195
+ await update($, line, () => 'ClaudeRipple: router not answering')
196
+ }
197
+ }
198
+ refreshNow = refresh
199
+
200
+ $.clock.after(1, () => void refresh())
201
+ $.clock.every(EVERY_MS, () => void refresh())
202
+ return result
203
+ })
204
+
205
+ // A worker runs on its own agent file's model, at the level its marker asks for wherever the
206
+ // marker sits (./worker.ts). A fork inherits the parent's model and is not a worker spawn.
207
+ on('agent.spawn', async ($, e, next) => {
208
+ const fix = e.fork ? null : await workerFix($, e)
209
+ if (!fix) return next(e)
210
+ if (fix.dropped) $.ui.toast(`ClaudeRipple: ${e.subagentType} runs on its own model, not "${fix.dropped}"`)
211
+ return next({ ...e, prompt: fix.prompt, model: fix.model })
212
+ })
213
+
214
+ on('command.run', { command: 'ripple-log' }, async $ => ({ text: (await toggleLog($)) ? 'ClaudeRipple log opened.' : 'ClaudeRipple log closed.' }))
215
+
216
+ on('command.run', { command: 'ripple-bar' }, async $ => {
217
+ if (await read($, bandHidden)) {
218
+ await update($, bandHidden, () => false)
219
+ await $.store.set(BAND_HIDDEN_KEY, false)
220
+ return { text: 'ClaudeRipple line shown above the prompt.' }
221
+ }
222
+ await hideBand($)
223
+ return { text: 'ClaudeRipple line hidden. /ripple-bar shows it again.' }
224
+ })
225
+
226
+ on('ui.close', { id: PANE }, async ($, e, next) => {
227
+ await update($, logOpen, () => false)
228
+ return next(e)
229
+ })
230
+
231
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
232
+ if (await read($, bandHidden)) return next(e)
233
+ const notice = await read($, line)
234
+ const record = await read($, last)
235
+ if (!notice && !record) return next(e)
236
+ const isOpen = await read($, logOpen)
237
+ const { Box, Button, Text } = $.ui.resolve(e)
238
+ const cache = record ? cachePercent(record.usage) : undefined
239
+ return (
240
+ <Box flexDirection="row" alignItems="center" gap={1}>
241
+ {notice || !record ? (
242
+ <Text dimColor>{notice}</Text>
243
+ ) : (
244
+ <Box flexDirection="row" gap={1}>
245
+ <Text color={record.ok ? OK : FAIL}>{record.ok ? '↳' : '✕'}</Text>
246
+ <Text bold>{modelOf(record)}</Text>
247
+ <Text color={providerColor(record.provider)}>{record.provider}</Text>
248
+ {record.effort ? <Text dimColor>{record.effort}</Text> : null}
249
+ {record.ok ? null : <Text color={FAIL}>{String(record.status)}</Text>}
250
+ {record.ok && cache !== undefined ? <Text color={cacheColor(cache)}>cache {cache}%</Text> : null}
251
+ <Text dimColor>{(record.ms / 1000).toFixed(1)}s</Text>
252
+ </Box>
253
+ )}
254
+ <Button key="log" plain label={isOpen ? 'Close log' : 'Log'} onPress={() => toggleLog($)} />
255
+ <Button key="hide" plain role="dismiss" label="×" onPress={() => hideBand($)} />
256
+ </Box>
257
+ )
258
+ })
259
+
260
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
261
+ const { Box, Button, Text } = $.ui.resolve(e)
262
+ const mineOnly = await read($, onlySession)
263
+ const rows = await read($, requests)
264
+ return (
265
+ <Box flexDirection="column" gap={1}>
266
+ <Box flexDirection="row" gap={1}>
267
+ <Text bold>{mineOnly ? 'This session' : 'All sessions'}</Text>
268
+ <Button
269
+ key="scope"
270
+ plain
271
+ label={mineOnly ? 'Show all sessions' : 'This session only'}
272
+ onPress={async () => {
273
+ await update($, onlySession, value => !value)
274
+ await refreshNow?.()
275
+ }}
276
+ />
277
+ </Box>
278
+ {!mineOnly && <Text dimColor>● this session ○ other sessions</Text>}
279
+ {rows.length === 0 && <Text dimColor>No model requests yet.</Text>}
280
+ {rows.map(record => {
281
+ const cache = cachePercent(record.usage)
282
+ return (
283
+ <Box key={`${record.at}-${record.target}`} flexDirection="column">
284
+ <Box flexDirection="row" gap={1}>
285
+ {mineOnly ? null : <Text color={record.mine ? ACCENT : undefined} dimColor={!record.mine}>{record.mine ? '●' : '○'}</Text>}
286
+ <Text dimColor>{timeOf(record.at)}</Text>
287
+ <Text bold wrap="truncate-end">{modelOf(record)}</Text>
288
+ <Text color={providerColor(record.provider)}>{record.provider}</Text>
289
+ <Text color={record.ok ? OK : FAIL} bold={!record.ok}>{record.ok ? '✓' : `✕ ${record.status}`}</Text>
290
+ </Box>
291
+ <Box flexDirection="row" gap={1}>
292
+ <Text dimColor>{record.effort ?? '-'}</Text>
293
+ {record.usage ? <Text dimColor>in {compact(promptTokens(record.usage))}</Text> : null}
294
+ {cache !== undefined ? <Text color={cacheColor(cache)}>cache {cache}%</Text> : null}
295
+ {record.usage ? <Text dimColor>out {compact(record.usage.output)}</Text> : null}
296
+ <Text dimColor>{(record.ms / 1000).toFixed(1)}s</Text>
297
+ </Box>
298
+ {!record.ok && record.note ? (
299
+ <Text color={FAIL} wrap="wrap">
300
+ {record.note}
301
+ </Text>
302
+ ) : null}
303
+ </Box>
304
+ )
305
+ })}
306
+ </Box>
307
+ )
308
+ })
309
+ }
@@ -0,0 +1,49 @@
1
+ // How a spawn of one of ClaudeRipple's workers is set right before it starts. Plain functions, no
2
+ // engine: the hook in register.tsx feeds them and applies the answer, and the tests import them.
3
+ //
4
+ // A worker is an agent file the router generated, whose `model:` is a routed id with an optional
5
+ // `@effort` (packages/router/src/agents.ts). Two things went wrong at spawn time, both silently:
6
+ // - A `model` argument on the Agent call replaced that id with a Claude alias, so the worker ran on
7
+ // Claude and its output was cut to 64 tokens (2026-09).
8
+ // - The `[[ripple: name@level]]` marker that sets a worker's effort counts only as the prompt's
9
+ // first line (packages/router/src/routing.ts); anywhere else it is prose and the level is lost.
10
+ // Measured on CLI 2.1.286 (2026-10-05): an `agent.spawn` hook that sets `model` to
11
+ // `deepseek-v4.1-flash@low` runs the worker on that id, and the router records effort low.
12
+
13
+ /** The router's marker, found anywhere in the prompt rather than only at its top. */
14
+ const MARKER = /\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/
15
+
16
+ export type Spawn = { subagentType: string; prompt: string; model?: string }
17
+ export type WorkerFix = { prompt: string; model: string; dropped?: string }
18
+
19
+ /** The `model:` line of an agent file's frontmatter. */
20
+ export function agentModel(file: string): string | undefined {
21
+ const head = /^---\n([\s\S]*?)\n---/.exec(file)?.[1]
22
+ return head ? /^model:\s*(\S+)\s*$/m.exec(head)?.[1] : undefined
23
+ }
24
+
25
+ /**
26
+ * What the spawn should run with, or null to leave it alone. `fileModel` is the worker's agent
27
+ * file `model:` (an id, maybe with `@effort`).
28
+ */
29
+ export function fixSpawn(spawn: Spawn, fileModel: string): WorkerFix | null {
30
+ const base = fileModel.replace(/@[^@]*$/, '')
31
+ const marker = MARKER.exec(spawn.prompt)
32
+ const dropped = spawn.model !== undefined && spawn.model !== fileModel && spawn.model.replace(/@[^@]*$/, '') !== base ? spawn.model : undefined
33
+ if (marker) {
34
+ const name = marker[1]!.toLowerCase()
35
+ const level = marker[2]?.toLowerCase()
36
+ const rest = (spawn.prompt.slice(0, marker.index) + spawn.prompt.slice(marker.index + marker[0].length)).replace(/^\s*\n/, '')
37
+ // The marker names this worker: its level goes on the model id, where the router reads it as
38
+ // the agent file's own suffix, and the worker no longer reads a routing note.
39
+ if (name === spawn.subagentType.toLowerCase() || name === base.toLowerCase()) {
40
+ return { prompt: rest, model: level ? `${base}@${level}` : fileModel, ...(dropped ? { dropped } : {}) }
41
+ }
42
+ // It names another model or alias, which only the router can resolve: put it where the router
43
+ // looks. Already there, there is nothing to move.
44
+ const atTop = MARKER.exec(spawn.prompt.replace(/^\s+/, ''))?.index === 0
45
+ if (atTop && !dropped) return null
46
+ return { prompt: atTop ? spawn.prompt : `${marker[0]}\n${rest}`, model: fileModel, ...(dropped ? { dropped } : {}) }
47
+ }
48
+ return dropped ? { prompt: spawn.prompt, model: fileModel, dropped } : null
49
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -0,0 +1,30 @@
1
+ export type StatusLine = string | null
2
+
3
+ export type StatusRequest = {
4
+ at: string
5
+ source: string
6
+ target: string
7
+ provider: string
8
+ status: number | string
9
+ ok: boolean
10
+ ms: number
11
+ effort?: string
12
+ usage?: { input: number; cached: number; cacheWrite?: number; output: number }
13
+ note?: string
14
+ session?: string
15
+ /** Sent by the session this mod runs in. */
16
+ mine?: boolean
17
+ }
18
+
19
+ declare module 'claude-code' {
20
+ interface PluginState {
21
+ 'clauderipple-status': {
22
+ line: StatusLine
23
+ last: StatusRequest | null
24
+ requests: StatusRequest[]
25
+ logOpen: boolean
26
+ onlySession: boolean
27
+ bandHidden: boolean
28
+ }
29
+ }
30
+ }