clauderipple 0.6.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 +66 -0
- package/README.ko.md +43 -2
- package/README.md +60 -4
- 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 +89 -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 +497 -34
- package/dist/router/src/agents.js +50 -9
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +33 -0
- package/dist/router/src/index.js +13 -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 +154 -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 +99 -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 +436 -44
- package/dist/ui/i18n.js +88 -4
- package/dist/ui/index.html +10 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +300 -3
- package/package.json +1 -1
package/dist/cli/src/index.js
CHANGED
|
@@ -120,6 +120,44 @@ function starterConfig(port) {
|
|
|
120
120
|
log: DEFAULTS.log,
|
|
121
121
|
}, null, 2) + "\n";
|
|
122
122
|
}
|
|
123
|
+
const IMAGE_USAGE = 'usage: clauderipple image "<prompt>" [-o FILE] [--aspect square|landscape|portrait] [--format png|jpeg|webp] [--transparent] [--ref FILE ...]';
|
|
124
|
+
/**
|
|
125
|
+
* One image through the running router's ChatGPT subscription (`POST /api/image`), written to a
|
|
126
|
+
* file whose path is printed — so a worker with nothing but Bash can make and then look at one.
|
|
127
|
+
*/
|
|
128
|
+
async function image() {
|
|
129
|
+
const prompt = args[1];
|
|
130
|
+
if (!prompt || prompt.startsWith("-"))
|
|
131
|
+
throw new Error(IMAGE_USAGE);
|
|
132
|
+
const images = args.flatMap((a, i) => (a === "--ref" && args[i + 1] ? [args[i + 1]] : [])).map((file) => {
|
|
133
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
134
|
+
return `data:image/${ext === "jpg" ? "jpeg" : ext};base64,${fs.readFileSync(file).toString("base64")}`;
|
|
135
|
+
});
|
|
136
|
+
const format = opt("format") ?? "png";
|
|
137
|
+
const out = path.resolve(opt("o") ?? `image-${new Date().toISOString().replace(/[:.]/g, "-")}.${format === "jpeg" ? "jpg" : format}`);
|
|
138
|
+
const body = {
|
|
139
|
+
prompt,
|
|
140
|
+
format,
|
|
141
|
+
...(opt("aspect") ? { aspect: opt("aspect") } : {}),
|
|
142
|
+
...(flag("transparent") ? { background: "transparent" } : {}),
|
|
143
|
+
...(images.length ? { images } : {}),
|
|
144
|
+
};
|
|
145
|
+
const port = adminPort(new ConfigStore(configPath()).get());
|
|
146
|
+
let res;
|
|
147
|
+
try {
|
|
148
|
+
res = await fetch(`http://127.0.0.1:${port}/api/image`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new Error(`the router is not answering on 127.0.0.1:${port}; start it with: clauderipple start`);
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const answer = (await res.json().catch(() => ({})));
|
|
155
|
+
throw new Error(answer.error ?? `HTTP ${res.status}`);
|
|
156
|
+
}
|
|
157
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
158
|
+
fs.writeFileSync(out, Buffer.from(await res.arrayBuffer()));
|
|
159
|
+
console.log(out);
|
|
160
|
+
}
|
|
123
161
|
/** The version the router on this admin port reports, or null when it does not answer. */
|
|
124
162
|
async function runningVersion(port) {
|
|
125
163
|
try {
|
|
@@ -277,7 +315,7 @@ async function status() {
|
|
|
277
315
|
const p = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
|
|
278
316
|
rows.push(["probe", `${p.ok ? "ok" : "FAIL"}: ${p.detail} (${p.ms}ms)`]);
|
|
279
317
|
for (const [name, p2] of Object.entries(cfg.providers)) {
|
|
280
|
-
const providerUrl = p2.type === "chatgpt" ? (p2.url ?? "https://chatgpt.com") : p2.type === "anthropic" ? "https://api.anthropic.com" : p2.url;
|
|
318
|
+
const providerUrl = p2.type === "chatgpt" ? (p2.url ?? "https://chatgpt.com") : p2.type === "anthropic" ? "https://api.anthropic.com" : p2.type === "google" ? (p2.url ?? "https://generativelanguage.googleapis.com") : p2.url;
|
|
281
319
|
const u = new URL(providerUrl);
|
|
282
320
|
rows.push([`provider ${name}`, await tcpCheck(u.hostname, Number(u.port) || (u.protocol === "https:" ? 443 : 80))]);
|
|
283
321
|
}
|
|
@@ -420,10 +458,15 @@ function help() {
|
|
|
420
458
|
logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
|
|
421
459
|
claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
|
|
422
460
|
claude-logout remove every Claude subscription added to ClaudeRipple
|
|
461
|
+
google-login add a Google account for the Antigravity (Cloud Code Assist) backend (prints Google's terms warning first)
|
|
462
|
+
google-logout remove every Google account added to ClaudeRipple
|
|
463
|
+
image "<prompt>" [-o FILE] [--aspect square|landscape|portrait] [--transparent] [--ref FILE ...]
|
|
464
|
+
generate an image with your ChatGPT subscription and print the file's path
|
|
423
465
|
picker on|off show your mapped models by name in the Claude Desktop picker (trusts the CA in your login keychain, routes the app through ClaudeRipple)
|
|
424
466
|
codex on|off add/remove ClaudeRipple's local OpenAI provider and selection profile for Codex CLI
|
|
425
467
|
agent-title on|off|status
|
|
426
468
|
prefix subagent titles with the real model and thinking depth ("Terra·high · …") via a Claude Code hook
|
|
469
|
+
mod on|off|status show where each request went above the Claude Code prompt, and the request log in a pane (/ripple-log), via a Claude Code mod
|
|
427
470
|
|
|
428
471
|
Home directory: ${homeDir()} (override with CLAUDERIPPLE_HOME)`);
|
|
429
472
|
}
|
|
@@ -500,6 +543,23 @@ try {
|
|
|
500
543
|
console.log(agentTitleHookEnabled() ? "on" : "off");
|
|
501
544
|
break;
|
|
502
545
|
}
|
|
546
|
+
case "mod": {
|
|
547
|
+
const sub = args[1];
|
|
548
|
+
const { setStatusMod, statusModEnabled } = await import("./settings.js");
|
|
549
|
+
if (sub === "on" || sub === "off") {
|
|
550
|
+
const r = setStatusMod(sub === "on", installedRuntime.modDir);
|
|
551
|
+
for (const n of r.notes)
|
|
552
|
+
console.log(`✓ ${n}`);
|
|
553
|
+
if (!r.changed)
|
|
554
|
+
console.log(`✓ already ${sub}`);
|
|
555
|
+
if (r.backup)
|
|
556
|
+
console.log(` backup: ${r.backup}`);
|
|
557
|
+
console.log("Applies to Claude Code sessions started from now on.");
|
|
558
|
+
}
|
|
559
|
+
else
|
|
560
|
+
console.log(statusModEnabled() ? "on" : "off");
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
503
563
|
case "login": {
|
|
504
564
|
const { login } = await import("../../router/src/providers/chatgpt/auth.js");
|
|
505
565
|
const { saveChatGptAccount, readChatGptAccounts, chatgptAccountsPath } = await import("../../router/src/providers/chatgpt/accounts.js");
|
|
@@ -552,6 +612,34 @@ try {
|
|
|
552
612
|
case "claude-logout":
|
|
553
613
|
console.log(claudeLogout(homeDir()) ? "✓ ClaudeRipple Claude accounts removed" : "no ClaudeRipple Claude accounts stored");
|
|
554
614
|
break;
|
|
615
|
+
case "google-login": {
|
|
616
|
+
const { GoogleOAuthSession } = await import("../../router/src/providers/google/login.js");
|
|
617
|
+
const { ANTIGRAVITY_WARNING } = await import("../../router/src/providers/google/antigravity.js");
|
|
618
|
+
const { readGoogleAccounts, googleAccountsPath } = await import("../../router/src/providers/google/accounts.js");
|
|
619
|
+
// The terms warning is printed once, before anything else, so a sign-in is never accidental.
|
|
620
|
+
console.log(`⚠ ${ANTIGRAVITY_WARNING}\n`);
|
|
621
|
+
const before = readGoogleAccounts(homeDir()).length;
|
|
622
|
+
const session = new GoogleOAuthSession({ home: homeDir() }, ANTIGRAVITY_WARNING);
|
|
623
|
+
const { url } = await session.start();
|
|
624
|
+
console.log(before > 0
|
|
625
|
+
? `Opening your browser to add a Google account (${before} signed in already). Sign in with the account to add; this window waits up to 5 minutes.`
|
|
626
|
+
: "Opening your browser to sign in with Google. Sign in there; this window waits up to 5 minutes.");
|
|
627
|
+
if (!openBrowser(url))
|
|
628
|
+
console.log(`Open this URL manually:\n${url}`);
|
|
629
|
+
await session.result;
|
|
630
|
+
const total = readGoogleAccounts(homeDir()).length;
|
|
631
|
+
console.log(`✓ Google account added. ${total} account${total === 1 ? "" : "s"} in ${googleAccountsPath(homeDir())}; refreshed automatically.`);
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
case "google-logout": {
|
|
635
|
+
const { removeAllGoogleAccounts } = await import("../../router/src/providers/google/accounts.js");
|
|
636
|
+
const removed = removeAllGoogleAccounts(homeDir());
|
|
637
|
+
console.log(removed > 0 ? `✓ removed ${removed} Google account${removed === 1 ? "" : "s"}` : "no Google account stored");
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
case "image":
|
|
641
|
+
await image();
|
|
642
|
+
break;
|
|
555
643
|
case "ui":
|
|
556
644
|
ui();
|
|
557
645
|
break;
|
package/dist/cli/src/runtime.js
CHANGED
|
@@ -21,6 +21,7 @@ export function runtime() {
|
|
|
21
21
|
cli: path.join(resources, "packages", "cli", "src", "index.ts"),
|
|
22
22
|
router: path.join(resources, "packages", "router", "src", "index.ts"),
|
|
23
23
|
hookScript: path.join(resources, "packages", "cli", "src", "hooks", "agent-title.ts"),
|
|
24
|
+
modDir: path.join(resources, "packages", "mod"),
|
|
24
25
|
repo: resources,
|
|
25
26
|
trayMain: "",
|
|
26
27
|
packaged: true,
|
|
@@ -37,6 +38,7 @@ export function runtime() {
|
|
|
37
38
|
cli: path.join(resources, "packages", "cli", "src", "index.ts"),
|
|
38
39
|
router: path.join(resources, "packages", "router", "src", "index.ts"),
|
|
39
40
|
hookScript: path.join(resources, "packages", "cli", "src", "hooks", "agent-title.ts"),
|
|
41
|
+
modDir: path.join(resources, "packages", "mod"),
|
|
40
42
|
repo: resources,
|
|
41
43
|
trayMain: "",
|
|
42
44
|
packaged: true,
|
|
@@ -55,6 +57,7 @@ export function runtime() {
|
|
|
55
57
|
cli,
|
|
56
58
|
router: path.resolve(here, `../../router/src/index${ext}`),
|
|
57
59
|
hookScript: path.resolve(here, `hooks/agent-title${ext}`),
|
|
60
|
+
modDir: path.resolve(here, "../../..", ext === ".ts" ? "packages/mod" : "dist/mod"),
|
|
58
61
|
repo: path.resolve(here, "../../.."),
|
|
59
62
|
trayMain: path.resolve(here, "../../..", ext === ".ts" ? "packages/app/dist/main.js" : "dist/app/dist/main.js"),
|
|
60
63
|
packaged: false,
|
package/dist/cli/src/settings.js
CHANGED
|
@@ -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,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
|
+
}
|