clauderipple 0.9.5 → 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 (112) hide show
  1. package/CHANGELOG.md +67 -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/providers/anthropic-usage.js +33 -1
  94. package/dist/router/src/providers/google/index.js +26 -0
  95. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  96. package/dist/router/src/providers/google/usage.js +257 -0
  97. package/dist/router/src/providers/grok/index.js +18 -0
  98. package/dist/router/src/providers/grok/usage.js +77 -0
  99. package/dist/router/src/providers/openai/translate.js +13 -2
  100. package/dist/router/src/providers/opencode-usage.js +116 -0
  101. package/dist/router/src/proxy.js +339 -258
  102. package/dist/router/src/remote-host.js +320 -0
  103. package/dist/router/src/remote-tunnel.js +320 -0
  104. package/dist/router/src/requestlog.js +48 -39
  105. package/dist/router/src/usage-history.js +227 -0
  106. package/dist/router/src/version.js +1 -1
  107. package/dist/ui/app.js +436 -59
  108. package/dist/ui/i18n.js +184 -0
  109. package/dist/ui/index.html +24 -5
  110. package/dist/ui/style.css +39 -0
  111. package/docs/ARCHITECTURE.md +108 -8
  112. package/package.json +7 -1
@@ -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.
@@ -0,0 +1,227 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const HOUR_MS = 3_600_000;
4
+ export function emptyTotals() {
5
+ return { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, ms: 0 };
6
+ }
7
+ /** The same counting rules as `RequestLog.summary`, for one record. */
8
+ export function recordTotals(record) {
9
+ const totals = emptyTotals();
10
+ totals.count = 1;
11
+ if (record.ok)
12
+ totals.ok = 1;
13
+ else if (!record.resent)
14
+ totals.failed = 1;
15
+ totals.ms = record.ms;
16
+ if (record.usage) {
17
+ totals.input = record.usage.input;
18
+ totals.cached = record.usage.cached;
19
+ totals.cacheWrite = record.usage.cacheWrite ?? 0;
20
+ totals.output = record.usage.output;
21
+ }
22
+ return totals;
23
+ }
24
+ export function addTotals(target, source) {
25
+ target.count += source.count;
26
+ target.ok += source.ok;
27
+ target.failed += source.failed;
28
+ target.input += source.input;
29
+ target.cached += source.cached;
30
+ target.cacheWrite += source.cacheWrite;
31
+ target.output += source.output;
32
+ target.ms += source.ms;
33
+ }
34
+ /** Derives the rate columns from summed totals. A cache write is input the model read fresh, so it
35
+ * counts against the hit rate (leaving it out read a turn that wrote 2,218 tokens and uncached 2 as
36
+ * a 100% hit, 2026-09-23). */
37
+ export function finishTotals(totals) {
38
+ const base = totals.input + totals.cached + totals.cacheWrite;
39
+ return {
40
+ count: totals.count,
41
+ ok: totals.ok,
42
+ failed: totals.failed,
43
+ input: totals.input,
44
+ cached: totals.cached,
45
+ cacheWrite: totals.cacheWrite,
46
+ output: totals.output,
47
+ cacheHitPercent: base > 0 ? Math.round((totals.cached / base) * 1000) / 10 : 0,
48
+ avgMs: totals.count > 0 ? Math.round(totals.ms / totals.count) : 0,
49
+ };
50
+ }
51
+ function bucketKey(h, provider, model) {
52
+ return `${h}\u0000${provider}\u0000${model}`;
53
+ }
54
+ function modelKey(provider, model) {
55
+ return `${provider}\u0000${model}`;
56
+ }
57
+ /** Models with the most tokens read first (input + cached + cacheWrite), then the busiest. */
58
+ export function sortByVolume(models) {
59
+ const volume = (bucket) => bucket.input + bucket.cached + bucket.cacheWrite;
60
+ models.sort((a, b) => volume(b) - volume(a) || b.count - a.count);
61
+ }
62
+ function isHourBucket(value) {
63
+ if (!value || typeof value !== "object")
64
+ return false;
65
+ const b = value;
66
+ return typeof b.h === "number" && Number.isFinite(b.h) && Number.isInteger(b.h) &&
67
+ typeof b.p === "string" && typeof b.m === "string" &&
68
+ ["count", "ok", "failed", "input", "cached", "cacheWrite", "output", "ms"].every((key) => {
69
+ const n = b[key];
70
+ return typeof n === "number" && Number.isFinite(n);
71
+ });
72
+ }
73
+ function toLine(bucket) {
74
+ return JSON.stringify({
75
+ h: bucket.h,
76
+ p: bucket.p,
77
+ m: bucket.m,
78
+ count: bucket.count,
79
+ ok: bucket.ok,
80
+ failed: bucket.failed,
81
+ input: bucket.input,
82
+ cached: bucket.cached,
83
+ cacheWrite: bucket.cacheWrite,
84
+ output: bucket.output,
85
+ ms: bucket.ms,
86
+ }) + "\n";
87
+ }
88
+ /**
89
+ * Durable hourly usage history, appended to `usage-hourly.jsonl` next to `requests.jsonl`.
90
+ *
91
+ * Invariant: every hour below the open hour is fully on disk (possibly as several additive lines
92
+ * for the same (h,p,m)); only the open hour lives in memory. A late record (its hour is below the
93
+ * open hour because a request started earlier and finished later) is appended immediately as its
94
+ * own line. On startup the file's highest hour is the watermark, and request records above it are
95
+ * replayed, so an hour never counted twice yet the open hour survives a restart.
96
+ */
97
+ export class UsageHistory {
98
+ file;
99
+ buckets = new Map();
100
+ open = new Map();
101
+ openHour = null;
102
+ highestHour = null;
103
+ earliestHour = null;
104
+ constructor(file) {
105
+ this.file = file;
106
+ this.load();
107
+ }
108
+ /** Highest hour already on disk, or null when nothing was persisted yet. */
109
+ get watermark() {
110
+ return this.highestHour;
111
+ }
112
+ /** Feeds startup replay: records at or below the watermark are already on disk. */
113
+ replay(records) {
114
+ const floor = this.highestHour;
115
+ for (const record of records) {
116
+ const hour = hourOf(record);
117
+ if (hour === null)
118
+ continue;
119
+ if (floor !== null && hour <= floor)
120
+ continue;
121
+ this.add(record);
122
+ }
123
+ }
124
+ add(record) {
125
+ const hour = hourOf(record);
126
+ if (hour === null)
127
+ return;
128
+ this.observe(hour);
129
+ const bucket = { h: hour, p: record.provider, m: record.target, ...recordTotals(record) };
130
+ if (this.openHour !== null && hour < this.openHour) {
131
+ this.append([bucket]);
132
+ this.merge(this.buckets, bucket);
133
+ return;
134
+ }
135
+ if (this.openHour === null || hour > this.openHour) {
136
+ this.flush();
137
+ this.openHour = hour;
138
+ }
139
+ this.merge(this.open, bucket);
140
+ }
141
+ /** Buckets whose hour start lies in [fromMs, toMs), summed across providers and models. */
142
+ summary(fromMs, toMs) {
143
+ const fromHour = Math.floor(fromMs / HOUR_MS);
144
+ const total = emptyTotals();
145
+ const providers = new Map();
146
+ const models = new Map();
147
+ for (const bucket of [...this.buckets.values(), ...this.open.values()]) {
148
+ if (bucket.h < fromHour || bucket.h * HOUR_MS >= toMs)
149
+ continue;
150
+ addTotals(total, bucket);
151
+ const provider = providers.get(bucket.p) ?? providers.set(bucket.p, emptyTotals()).get(bucket.p);
152
+ addTotals(provider, bucket);
153
+ const key = modelKey(bucket.p, bucket.m);
154
+ const model = models.get(key) ?? (models.set(key, { totals: emptyTotals(), provider: bucket.p, model: bucket.m }).get(key));
155
+ addTotals(model.totals, bucket);
156
+ }
157
+ const modelList = [...models.values()].map(({ totals, provider, model }) => ({ ...finishTotals(totals), provider, model }));
158
+ sortByVolume(modelList);
159
+ return {
160
+ total: finishTotals(total),
161
+ providers: Object.fromEntries([...providers].map(([name, totals]) => [name, finishTotals(totals)])),
162
+ models: modelList,
163
+ earliest: this.earliestHour === null ? null : new Date(this.earliestHour * HOUR_MS).toISOString(),
164
+ };
165
+ }
166
+ observe(hour) {
167
+ if (this.earliestHour === null || hour < this.earliestHour)
168
+ this.earliestHour = hour;
169
+ }
170
+ merge(target, bucket) {
171
+ const key = bucketKey(bucket.h, bucket.p, bucket.m);
172
+ const existing = target.get(key);
173
+ if (existing)
174
+ addTotals(existing, bucket);
175
+ else
176
+ target.set(key, { ...bucket });
177
+ if (target === this.buckets && (this.highestHour === null || bucket.h > this.highestHour))
178
+ this.highestHour = bucket.h;
179
+ }
180
+ flush() {
181
+ if (this.open.size === 0)
182
+ return;
183
+ const lines = [...this.open.values()];
184
+ this.open.clear();
185
+ this.append(lines);
186
+ for (const line of lines)
187
+ this.merge(this.buckets, line);
188
+ }
189
+ append(lines) {
190
+ if (lines.length === 0)
191
+ return;
192
+ try {
193
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
194
+ fs.appendFileSync(this.file, lines.map(toLine).join(""), "utf8");
195
+ }
196
+ catch {
197
+ // Usage history is best-effort: it must never alter the proxy response path.
198
+ }
199
+ }
200
+ load() {
201
+ let text;
202
+ try {
203
+ text = fs.readFileSync(this.file, "utf8");
204
+ }
205
+ catch {
206
+ return;
207
+ }
208
+ for (const line of text.split("\n")) {
209
+ if (line === "")
210
+ continue;
211
+ try {
212
+ const parsed = JSON.parse(line);
213
+ if (isHourBucket(parsed)) {
214
+ this.observe(parsed.h);
215
+ this.merge(this.buckets, parsed);
216
+ }
217
+ }
218
+ catch {
219
+ // A torn write stays behind; the neighbouring lines still count.
220
+ }
221
+ }
222
+ }
223
+ }
224
+ function hourOf(record) {
225
+ const at = Date.parse(record.at);
226
+ return Number.isFinite(at) ? Math.floor(at / HOUR_MS) : null;
227
+ }
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.9.5";
8
+ export const VERSION = "0.10.0";