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
@@ -0,0 +1,320 @@
1
+ // Reverse SSH tunnels to remote hosts that run Claude Code for Claude Desktop (issue #67).
2
+ //
3
+ // Each configured host gets one long-lived `ssh -N -R 127.0.0.1:<remote port>:127.0.0.1:<proxy
4
+ // port> <host>`, owned by the router because the router is what launchd / the Windows supervisor /
5
+ // systemd already keeps alive. A tunnel that exits is started again with capped backoff; each time
6
+ // one comes up the CA and agent files on that host are brought up to date (remote-host.ts), and
7
+ // again when the router regenerates the agent files.
8
+ //
9
+ // Children are killed when the router exits, and their pids are recorded: a router that died
10
+ // without killing them (SIGKILL) would otherwise leave an orphan holding the remote port, and every
11
+ // new tunnel would then fail `ExitOnForwardFailure` for as long as the orphan lives.
12
+ import { execFileSync, spawn } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { BATCH_OPTIONS, SSH_MISSING, extraSshOptions, sshExecutable } from "./remote-host.js";
16
+ /**
17
+ * The tunnel's argv. `-v` is there for one line: `remote forward success for: listen …` is how
18
+ * ssh says the remote port is actually bound (measured, OpenSSH 10.3). Every other debug line is
19
+ * dropped before the log; `ExitOnForwardFailure` makes a forward that cannot bind an exit.
20
+ */
21
+ export function tunnelArgs(host, port, target) {
22
+ return [
23
+ "-N",
24
+ "-v",
25
+ ...BATCH_OPTIONS,
26
+ "-o", "ExitOnForwardFailure=yes",
27
+ "-o", "ServerAliveInterval=15",
28
+ "-o", "ServerAliveCountMax=3",
29
+ "-R", `127.0.0.1:${port}:${target.host}:${target.port}`,
30
+ ...extraSshOptions(),
31
+ host,
32
+ ];
33
+ }
34
+ /** The connection target a router listening on `host` is reached at: a wildcard bind means loopback. */
35
+ export function proxyConnectHost(listenHost) {
36
+ return listenHost === "0.0.0.0" || listenHost === "::" || listenHost === "" ? "127.0.0.1" : listenHost;
37
+ }
38
+ function defaultProcessCommand(pid) {
39
+ try {
40
+ if (process.platform === "win32") {
41
+ const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: "utf8", windowsHide: true, timeout: 15_000 });
42
+ return out.trim() || null;
43
+ }
44
+ const out = execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 });
45
+ return out.trim() || null;
46
+ }
47
+ catch {
48
+ return null; // ps exits 1 when no such process
49
+ }
50
+ }
51
+ export class RemoteTunnels {
52
+ entries = new Map();
53
+ deps;
54
+ backoff;
55
+ closed = false;
56
+ constructor(deps) {
57
+ this.deps = deps;
58
+ this.backoff = deps.backoff ?? { baseMs: 1_000, maxMs: 60_000, stableMs: 60_000 };
59
+ }
60
+ now() {
61
+ return this.deps.now?.() ?? Date.now();
62
+ }
63
+ /**
64
+ * Kill ssh children a previous router left running. Only a pid whose command line is still our
65
+ * tunnel for that host and port is killed: a pid the OS has since given to something else is not.
66
+ */
67
+ cleanupStale() {
68
+ let records = [];
69
+ try {
70
+ const parsed = JSON.parse(fs.readFileSync(this.deps.pidFile, "utf8"));
71
+ if (Array.isArray(parsed.tunnels))
72
+ records = parsed.tunnels.filter((r) => !!r && typeof r.pid === "number" && typeof r.host === "string" && typeof r.port === "number");
73
+ }
74
+ catch {
75
+ return 0;
76
+ }
77
+ const lookup = this.deps.processCommand ?? defaultProcessCommand;
78
+ const kill = this.deps.kill ?? ((pid) => process.kill(pid, "SIGTERM"));
79
+ let killed = 0;
80
+ for (const r of records) {
81
+ if (r.pid === process.pid)
82
+ continue;
83
+ const cmd = lookup(r.pid);
84
+ if (!cmd || !/\bssh(\.exe)?\b/i.test(cmd) || !cmd.includes(`127.0.0.1:${r.port}:`) || !cmd.includes(r.host))
85
+ continue;
86
+ try {
87
+ kill(r.pid);
88
+ killed++;
89
+ this.deps.log.info(`remote ${r.host}: killed tunnel pid ${r.pid} left by a previous router`);
90
+ }
91
+ catch (e) {
92
+ this.deps.log.warn(`remote ${r.host}: cannot kill stale tunnel pid ${r.pid}: ${e.message}`);
93
+ }
94
+ }
95
+ this.writePids();
96
+ return killed;
97
+ }
98
+ /** Start tunnels for new hosts, stop removed ones, restart one whose port or target changed. */
99
+ reconcile(hosts) {
100
+ if (this.closed)
101
+ return;
102
+ const target = this.deps.proxyTarget();
103
+ const wanted = new Map(hosts.map((h) => [h.host, h]));
104
+ for (const [host, entry] of this.entries) {
105
+ const want = wanted.get(host);
106
+ if (!want || want.port !== entry.port || entry.target.host !== target.host || entry.target.port !== target.port) {
107
+ this.stop(entry);
108
+ this.entries.delete(host);
109
+ this.deps.log.info(`remote ${host}: tunnel stopped${want ? " (port changed)" : " (host removed)"}`);
110
+ }
111
+ }
112
+ for (const h of hosts) {
113
+ if (this.entries.has(h.host))
114
+ continue;
115
+ const entry = {
116
+ host: h.host, port: h.port, target, child: null, state: "starting", since: this.now(), upAt: null, lastError: null,
117
+ attempt: 0, restarts: 0, timer: null, settle: null, stopped: false, synced: null, syncedPrint: null, syncing: false,
118
+ };
119
+ this.entries.set(h.host, entry);
120
+ this.start(entry);
121
+ }
122
+ this.writePids();
123
+ }
124
+ status() {
125
+ return [...this.entries.values()].map((e) => ({
126
+ host: e.host,
127
+ port: e.port,
128
+ state: e.state,
129
+ since: new Date(e.since).toISOString(),
130
+ lastError: e.lastError,
131
+ pid: e.child?.pid ?? null,
132
+ restarts: e.restarts,
133
+ synced: e.synced === null ? null : new Date(e.synced).toISOString(),
134
+ }));
135
+ }
136
+ /** The agent files or the CA changed here: copy them to every host whose tunnel is up. */
137
+ assetsChanged() {
138
+ if (!this.deps.fingerprint)
139
+ return;
140
+ const print = this.deps.fingerprint();
141
+ for (const e of this.entries.values())
142
+ if (e.state === "up" && e.syncedPrint !== print)
143
+ void this.sync(e);
144
+ }
145
+ /** Kill every tunnel. Synchronous, so it can run from an `exit` handler. */
146
+ stopAll() {
147
+ this.closed = true;
148
+ for (const e of this.entries.values())
149
+ this.stop(e);
150
+ this.entries.clear();
151
+ this.writePids();
152
+ }
153
+ setState(e, state) {
154
+ if (e.state !== state)
155
+ e.since = this.now();
156
+ e.state = state;
157
+ }
158
+ start(e) {
159
+ if (e.stopped || this.closed)
160
+ return;
161
+ e.timer = null;
162
+ this.setState(e, "starting");
163
+ let child;
164
+ try {
165
+ child = (this.deps.spawn ?? ((cmd, args) => spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true })))(sshExecutable(), tunnelArgs(e.host, e.port, e.target));
166
+ }
167
+ catch (err) {
168
+ this.gone(e, null, err.message);
169
+ return;
170
+ }
171
+ e.child = child;
172
+ this.writePids();
173
+ this.deps.log.info(`remote ${e.host}: starting tunnel 127.0.0.1:${e.port} on the host → ${e.target.host}:${e.target.port}${child.pid ? ` (pid ${child.pid})` : ""}`);
174
+ let buffered = "";
175
+ let said = null; // this child's own last word, not an earlier one's
176
+ child.stderr?.on("data", (d) => {
177
+ buffered += d.toString();
178
+ let i;
179
+ while ((i = buffered.indexOf("\n")) >= 0) {
180
+ const line = buffered.slice(0, i).replace(/\r$/, "");
181
+ buffered = buffered.slice(i + 1);
182
+ if (e.child === child)
183
+ said = this.stderrLine(e, line) ?? said;
184
+ }
185
+ });
186
+ child.on("error", (err) => {
187
+ if (e.child !== child)
188
+ return;
189
+ this.gone(e, child, err.code === "ENOENT" ? SSH_MISSING : err.message, err.code === "ENOENT");
190
+ });
191
+ // `close`, not `exit`: it comes after stderr has been read to the end, so the reason ssh gave
192
+ // for leaving is in `said` by then.
193
+ child.on("close", (code, signal) => {
194
+ if (e.child !== child)
195
+ return;
196
+ this.gone(e, child, said ?? `ssh exited (${signal ?? `code ${code}`})`);
197
+ });
198
+ const settleMs = this.deps.settleMs ?? 15_000;
199
+ e.settle = setTimeout(() => {
200
+ if (e.child === child && e.state === "starting")
201
+ this.up(e);
202
+ }, settleMs);
203
+ e.settle.unref?.();
204
+ }
205
+ /** Returns the line when it is something ssh said to a person (an error, a warning). */
206
+ stderrLine(e, line) {
207
+ if (!line.trim())
208
+ return null;
209
+ if (/^debug\d?: remote forward success for: listen/.test(line)) {
210
+ this.up(e);
211
+ return null;
212
+ }
213
+ if (/^debug\d?:/.test(line))
214
+ return null; // `-v` noise
215
+ // `-v` also lets through ssh's verbose-level notices, which carry no prefix (measured: the
216
+ // "Authenticated to … using "publickey"" line on every connect). They are not errors.
217
+ if (/^(Authenticated to |Transferred: |Bytes per second|Warning: Permanently added )/.test(line)) {
218
+ this.deps.log.info(`remote ${e.host}: ${line.trim()}`);
219
+ return null;
220
+ }
221
+ e.lastError = line.trim();
222
+ this.deps.log.warn(`remote ${e.host}: ${line.trim()}`);
223
+ return line.trim();
224
+ }
225
+ up(e) {
226
+ if (e.state === "up")
227
+ return;
228
+ if (e.settle)
229
+ clearTimeout(e.settle);
230
+ e.settle = null;
231
+ this.setState(e, "up");
232
+ e.upAt = this.now();
233
+ e.lastError = null;
234
+ this.deps.log.info(`remote ${e.host}: tunnel up (127.0.0.1:${e.port} on the host)`);
235
+ // Each (re)start copies again: the host may have lost or changed the files while we were away.
236
+ e.syncedPrint = null;
237
+ void this.sync(e);
238
+ }
239
+ async sync(e) {
240
+ if (!this.deps.sync || e.syncing)
241
+ return;
242
+ e.syncing = true;
243
+ const print = this.deps.fingerprint?.() ?? null;
244
+ try {
245
+ await this.deps.sync(e.host);
246
+ e.synced = this.now();
247
+ e.syncedPrint = print;
248
+ this.deps.log.info(`remote ${e.host}: CA and agent files synced`);
249
+ }
250
+ catch (err) {
251
+ this.deps.log.warn(`remote ${e.host}: sync failed: ${err.message}`);
252
+ }
253
+ finally {
254
+ e.syncing = false;
255
+ }
256
+ // Something changed while this one ran: catch up once.
257
+ if (this.deps.fingerprint && e.state === "up" && e.syncedPrint !== null && this.deps.fingerprint() !== e.syncedPrint)
258
+ void this.sync(e);
259
+ }
260
+ gone(e, child, reason, sshMissing = false) {
261
+ if (child && e.child !== child)
262
+ return;
263
+ e.child = null;
264
+ if (e.settle)
265
+ clearTimeout(e.settle);
266
+ e.settle = null;
267
+ this.writePids();
268
+ if (e.stopped || this.closed) {
269
+ this.setState(e, "down");
270
+ return;
271
+ }
272
+ if (e.upAt !== null && this.now() - e.upAt >= this.backoff.stableMs)
273
+ e.attempt = 0;
274
+ e.upAt = null;
275
+ e.lastError = reason;
276
+ const delay = sshMissing ? this.backoff.maxMs : Math.min(this.backoff.maxMs, this.backoff.baseMs * 2 ** e.attempt);
277
+ e.attempt++;
278
+ this.setState(e, sshMissing ? "down" : "backoff");
279
+ this.deps.log.warn(`remote ${e.host}: tunnel ${sshMissing ? "cannot start" : "down"}: ${reason}; retrying in ${Math.round(delay / 1000)}s`);
280
+ e.timer = setTimeout(() => {
281
+ e.restarts++;
282
+ this.start(e);
283
+ }, delay);
284
+ e.timer.unref?.();
285
+ }
286
+ stop(e) {
287
+ e.stopped = true;
288
+ if (e.timer)
289
+ clearTimeout(e.timer);
290
+ if (e.settle)
291
+ clearTimeout(e.settle);
292
+ e.timer = e.settle = null;
293
+ const child = e.child;
294
+ e.child = null;
295
+ this.setState(e, "down");
296
+ if (child) {
297
+ try {
298
+ child.kill("SIGTERM");
299
+ }
300
+ catch {
301
+ /* already gone */
302
+ }
303
+ }
304
+ }
305
+ writePids() {
306
+ const tunnels = [];
307
+ for (const e of this.entries.values())
308
+ if (e.child?.pid)
309
+ tunnels.push({ pid: e.child.pid, host: e.host, port: e.port });
310
+ if (tunnels.length === 0 && !fs.existsSync(this.deps.pidFile))
311
+ return; // a router with no remote hosts leaves no file
312
+ try {
313
+ fs.mkdirSync(path.dirname(this.deps.pidFile), { recursive: true });
314
+ fs.writeFileSync(this.deps.pidFile, JSON.stringify({ tunnels }, null, 2) + "\n");
315
+ }
316
+ catch (err) {
317
+ this.deps.log.warn(`remote: cannot record tunnel pids in ${this.deps.pidFile}: ${err.message}`);
318
+ }
319
+ }
320
+ }
@@ -2,11 +2,9 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import zlib from "node:zlib";
4
4
  import path from "node:path";
5
+ import { UsageHistory, addTotals, emptyTotals, finishTotals, recordTotals, sortByVolume } from "./usage-history.js";
5
6
  const DEFAULT_MAX_BYTES = 20 * 1024 * 1024;
6
7
  const MAX_CARRY = 64 * 1024;
7
- function emptyBucket() {
8
- return { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, cacheHitPercent: 0, avgMs: 0 };
9
- }
10
8
  function isRecord(value) {
11
9
  if (!value || typeof value !== "object")
12
10
  return false;
@@ -21,13 +19,29 @@ export class RequestLog {
21
19
  max;
22
20
  maxBytes;
23
21
  records;
22
+ history;
24
23
  constructor(file, max = 2000, maxBytes = DEFAULT_MAX_BYTES) {
25
24
  this.file = file;
26
25
  this.max = max;
27
26
  this.maxBytes = maxBytes;
28
- this.records = this.load();
27
+ const persisted = this.readRecords(this.file);
28
+ this.records = persisted.slice(-this.max);
29
+ this.history = new UsageHistory(path.join(path.dirname(file), "usage-hourly.jsonl"));
30
+ // Replay what the history has not seen yet: the `.1` file first when it may hold a record above
31
+ // the watermark, so the first run backfills both files. Its mtime is its last append, and a
32
+ // record starts before it is written, so an mtime at or below the watermark hour rules it out;
33
+ // the first record of requests.jsonl does not, since a long request that straddled the rotation
34
+ // starts earlier than records left in `.1`. The history skips everything at or below its
35
+ // watermark, so a restart counts no hour twice.
36
+ const watermark = this.history.watermark;
37
+ const rotatedHour = usageHourOfFile(`${file}.1`);
38
+ const replay = rotatedHour !== null && (watermark === null || rotatedHour > watermark)
39
+ ? [...this.readRecords(`${file}.1`), ...persisted]
40
+ : persisted;
41
+ this.history.replay(replay);
29
42
  }
30
43
  add(record) {
44
+ this.history.add(record);
31
45
  this.records.push(record);
32
46
  if (this.records.length > this.max)
33
47
  this.records.splice(0, this.records.length - this.max);
@@ -64,47 +78,34 @@ export class RequestLog {
64
78
  }
65
79
  /** Summarise records at or after an epoch timestamp in milliseconds. */
66
80
  summary(sinceMs) {
67
- const total = emptyBucket();
68
- const providers = {};
69
- const cacheBases = new Map();
70
- const add = (bucket, record) => {
71
- bucket.count++;
72
- if (record.ok)
73
- bucket.ok++;
74
- else if (!record.resent)
75
- bucket.failed++;
76
- bucket.avgMs += record.ms;
77
- if (record.usage) {
78
- bucket.input += record.usage.input;
79
- bucket.cached += record.usage.cached;
80
- bucket.cacheWrite += record.usage.cacheWrite ?? 0;
81
- bucket.output += record.usage.output;
82
- // A cache write is input the model read fresh, so it counts against the hit rate. Leaving it
83
- // out read a turn that wrote 2,218 tokens and uncached 2 as a 100% hit (2026-09-23).
84
- cacheBases.set(bucket, (cacheBases.get(bucket) ?? 0) + record.usage.input + record.usage.cached + (record.usage.cacheWrite ?? 0));
85
- }
86
- };
81
+ const total = emptyTotals();
82
+ const providers = new Map();
83
+ const models = new Map();
87
84
  for (const record of this.records) {
88
85
  const at = Date.parse(record.at);
89
86
  if (!Number.isFinite(at) || at < sinceMs)
90
87
  continue;
91
- const provider = providers[record.provider] ?? (providers[record.provider] = emptyBucket());
92
- add(total, record);
93
- add(provider, record);
88
+ const provider = providers.get(record.provider) ?? providers.set(record.provider, emptyTotals()).get(record.provider);
89
+ const key = `${record.provider}\u0000${record.target}`;
90
+ const model = models.get(key) ?? models.set(key, { totals: emptyTotals(), provider: record.provider, model: record.target }).get(key);
91
+ for (const bucket of [total, provider, model.totals])
92
+ addTotals(bucket, recordTotals(record));
94
93
  }
95
- for (const bucket of [total, ...Object.values(providers)]) {
96
- if (bucket.count > 0)
97
- bucket.avgMs = Math.round(bucket.avgMs / bucket.count);
98
- const base = cacheBases.get(bucket) ?? 0;
99
- bucket.cacheHitPercent = base > 0 ? Math.round((bucket.cached / base) * 1000) / 10 : 0;
100
- }
101
- return { total, providers };
94
+ const modelsList = [...models.values()].map(({ totals, provider, model }) => ({ ...finishTotals(totals), provider, model }));
95
+ sortByVolume(modelsList);
96
+ return {
97
+ total: finishTotals(total),
98
+ providers: Object.fromEntries([...providers].map(([name, totals]) => [name, finishTotals(totals)])),
99
+ models: modelsList,
100
+ };
101
+ }
102
+ /** Durable hourly usage over [fromMs, toMs), beyond the in-memory ring. */
103
+ usage(fromMs, toMs) {
104
+ return this.history.summary(fromMs, toMs);
102
105
  }
103
- load() {
106
+ readRecords(file) {
104
107
  try {
105
- if (!fs.existsSync(this.file))
106
- return [];
107
- const records = fs.readFileSync(this.file, "utf8")
108
+ return fs.readFileSync(file, "utf8")
108
109
  .split("\n")
109
110
  .flatMap((line) => {
110
111
  try {
@@ -115,13 +116,21 @@ export class RequestLog {
115
116
  return [];
116
117
  }
117
118
  });
118
- return records.slice(-this.max);
119
119
  }
120
120
  catch {
121
121
  return [];
122
122
  }
123
123
  }
124
124
  }
125
+ /** The hour of a file's last write, or null when it does not exist. */
126
+ function usageHourOfFile(file) {
127
+ try {
128
+ return Math.floor(fs.statSync(file).mtimeMs / 3_600_000);
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
125
134
  /**
126
135
  * Observes an Anthropic-compatible response without retaining or modifying its bytes.
127
136
  * Call feed() after forwarding each response chunk, then finish() on response end.
@@ -1,16 +1,27 @@
1
1
  // Thinking blocks another model left in a conversation that is now going to Anthropic.
2
2
  //
3
- // The translated providers hand the CLI their reasoning as `thinking` blocks with no signature —
4
- // there is no Anthropic signature to give — and an empty `thinking` when the upstream sent a
5
- // reasoning item without a summary. The CLI keeps them in the transcript, so a session switched
6
- // from such a model to a Claude model replays them, and Anthropic refuses the whole request:
7
- // "messages.2.content.0.thinking: each thinking block must contain thinking" (measured 2026-09-28,
8
- // gpt-6-astra → claude-opus-5-5, every retry of the turn). A block Anthropic wrote always carries a
9
- // signature, so an unsigned one is foreign and goes; the translated providers drop thinking from
10
- // replayed history for the same reason (ARCHITECTURE §4, "Cache-safety decisions").
3
+ // The translated providers hand the CLI their reasoning as `thinking` blocks, and an empty `thinking`
4
+ // when the upstream sent a reasoning item without a summary. The CLI keeps them in the transcript, so
5
+ // a session switched from such a model to a Claude model replays them, and Anthropic refuses the
6
+ // whole request: "messages.2.content.0.thinking: each thinking block must contain thinking" (measured
7
+ // 2026-09-28, gpt-6-astra → claude-opus-5-5, every retry of the turn).
8
+ //
9
+ // A block Anthropic wrote always carries a signature of Anthropic's own, a base64 string this router
10
+ // cannot produce. A translation provider cannot produce one either, so it signs with an envelope of
11
+ // its own naming where the reasoning came from (reasoning.ts) — which Anthropic would refuse just as
12
+ // fast, and which is why those go too. What is left that is signed is Anthropic's.
13
+ //
14
+ // The translated adapters themselves keep their own replayed reasoning (that is the point of the
15
+ // envelope), so this is the one place a translated provider's thinking must still be dropped: a
16
+ // CLI-side switch to a Claude model. Gemini and the other translators drop `thinking` on their own.
17
+ import { isOurSignature } from "./reasoning.js";
11
18
  function foreign(block) {
12
19
  const b = block;
13
- return !!b && typeof b === "object" && b.type === "thinking" && !(typeof b.signature === "string" && b.signature.length > 0);
20
+ if (!b || typeof b !== "object" || b.type !== "thinking")
21
+ return false;
22
+ // Unsigned: a translated provider's reasoning with nothing to carry, or an empty reasoning item.
23
+ // Ours: a translated provider's envelope, which only its own adapter can read back.
24
+ return !(typeof b.signature === "string" && b.signature.length > 0) || isOurSignature(b.signature);
14
25
  }
15
26
  /**
16
27
  * Removes unsigned thinking blocks from assistant turns, in place, and returns how many went.