clauderipple 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.ko.md +15 -1
- package/README.md +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +46 -1
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +411 -32
- package/dist/router/src/agents.js +24 -8
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +31 -0
- package/dist/router/src/index.js +12 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +42 -15
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +85 -1
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +420 -45
- package/dist/ui/i18n.js +76 -4
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +287 -2
- package/package.json +1 -1
|
@@ -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,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
|
+
}
|