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.
- package/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// The host's side of the relay. One outbound control socket that never gives up — the same
|
|
2
|
+
// liveness numbers as the phone's link and Paseo's daemon (spec): ping every 10 s, stale after
|
|
3
|
+
// 30 s of silence, abandoned if not ready 8 s after it was created, backoff min(30 s, n × 1 s)
|
|
4
|
+
// forever. When the relay says a phone arrived, the host opens one data socket for it, answers the
|
|
5
|
+
// phone's handshake (e2ee.ts) and hands the decrypted channel to HostServer like any other phone.
|
|
6
|
+
// The relay sees ciphertext only; the pairing token is checked inside the channel, as on the
|
|
7
|
+
// direct path.
|
|
8
|
+
//
|
|
9
|
+
// A pong proves only that the relay's edge answers this socket, not that phones reach it: on
|
|
10
|
+
// 2026-10-15 the relay kept sending every phone to a replaced socket that never answered its close,
|
|
11
|
+
// while this socket stayed "connected" and phones sat at "connecting" (see Room.host). So after
|
|
12
|
+
// each hello and every ROUTE_CHECK_MS the host also walks the phone's path itself — a client socket
|
|
13
|
+
// to its own room must come back "open" — and reconnects when it does not.
|
|
14
|
+
import { BACKOFF_CAP_MS, FRAME_CAP, PING_MS, READY_MS, RELAY_CLOSE, RELAY_FRAME_CAP, STALE_MS } from "./protocol.js";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fromB64u, hostAccept, toB64u } from "./e2ee.js";
|
|
17
|
+
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
18
|
+
/** The host's long-term X25519 key pair (its public half goes in the pairing link), made once, kept 0600. */
|
|
19
|
+
export function loadHostKey(dir, nacl) {
|
|
20
|
+
const file = path.join(dir, "host-key.json");
|
|
21
|
+
const stored = readJson(file);
|
|
22
|
+
if (stored?.publicKey && stored.secretKey) {
|
|
23
|
+
const kp = { publicKey: fromB64u(stored.publicKey), secretKey: fromB64u(stored.secretKey) };
|
|
24
|
+
if (kp.publicKey.length === 32 && kp.secretKey.length === 32)
|
|
25
|
+
return kp;
|
|
26
|
+
throw new Error(`${file} is damaged; remove it to make a new key (every phone then pairs again)`);
|
|
27
|
+
}
|
|
28
|
+
const kp = nacl.box.keyPair();
|
|
29
|
+
writeFileAtomic(file, JSON.stringify({ publicKey: toB64u(kp.publicKey), secretKey: toB64u(kp.secretKey) }, null, 2) + "\n");
|
|
30
|
+
return kp;
|
|
31
|
+
}
|
|
32
|
+
const OPEN = 1;
|
|
33
|
+
export function relayBackoff(attempt) {
|
|
34
|
+
return Math.min(BACKOFF_CAP_MS, Math.max(1, attempt) * 1000);
|
|
35
|
+
}
|
|
36
|
+
/** First walk of the phone's path after a hello, then every ROUTE_CHECK_MS while connected. */
|
|
37
|
+
export const ROUTE_FIRST_MS = 2_000;
|
|
38
|
+
export const ROUTE_CHECK_MS = 5 * 60_000;
|
|
39
|
+
/** A probe phone that is not let through ("open") within this has no route to this host. */
|
|
40
|
+
export const ROUTE_TIMEOUT_MS = 10_000;
|
|
41
|
+
/** A ping timer this late means the process did not run (sleep, or a blocked event loop). */
|
|
42
|
+
export const STALL_MS = 5_000;
|
|
43
|
+
/** After such a stall, how long a fresh ping has to be answered before the socket is called dead. */
|
|
44
|
+
export const STALL_GRACE_MS = 5_000;
|
|
45
|
+
function wsOrigin(relay) {
|
|
46
|
+
const u = new URL(relay);
|
|
47
|
+
return `${u.protocol === "https:" ? "wss:" : "ws:"}//${u.host}`;
|
|
48
|
+
}
|
|
49
|
+
const toBytes = (data) => Array.isArray(data) ? new Uint8Array(Buffer.concat(data)) : data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
50
|
+
const toText = (data) => Buffer.from(toBytes(data)).toString("utf8");
|
|
51
|
+
/**
|
|
52
|
+
* One phone through the relay, after the handshake: a Peer whose frames are sealed and opened
|
|
53
|
+
* here. Anything that does not open — altered, replayed, out of order, or plaintext — ends it.
|
|
54
|
+
*/
|
|
55
|
+
export class SecurePeer {
|
|
56
|
+
OPEN = OPEN;
|
|
57
|
+
ws;
|
|
58
|
+
channel;
|
|
59
|
+
handlers = { message: [], close: [], error: [] };
|
|
60
|
+
closed = false;
|
|
61
|
+
constructor(ws, channel) {
|
|
62
|
+
this.ws = ws;
|
|
63
|
+
this.channel = channel;
|
|
64
|
+
ws.on("message", (data, isBinary) => {
|
|
65
|
+
if (this.closed)
|
|
66
|
+
return;
|
|
67
|
+
const bytes = toBytes(data);
|
|
68
|
+
if (!isBinary || bytes.length > RELAY_FRAME_CAP) {
|
|
69
|
+
this.fail(4400, "bad frame");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = channel.open(bytes);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
this.fail(4400, "bad frame");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (text.length > FRAME_CAP) {
|
|
81
|
+
this.fail(1009, "frame over cap");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const fn of this.handlers.message)
|
|
85
|
+
fn(text);
|
|
86
|
+
});
|
|
87
|
+
ws.on("close", () => this.end());
|
|
88
|
+
ws.on("error", (e) => { for (const fn of this.handlers.error)
|
|
89
|
+
fn(e); });
|
|
90
|
+
}
|
|
91
|
+
get readyState() { return this.closed ? 3 : this.ws.readyState; }
|
|
92
|
+
get bufferedAmount() { return this.ws.bufferedAmount; }
|
|
93
|
+
send(text) {
|
|
94
|
+
if (this.closed)
|
|
95
|
+
return;
|
|
96
|
+
this.ws.send(this.channel.seal(text));
|
|
97
|
+
}
|
|
98
|
+
close(code = 1000, reason = "") { try {
|
|
99
|
+
this.ws.close(code, reason);
|
|
100
|
+
}
|
|
101
|
+
catch { /* gone */ } this.end(); }
|
|
102
|
+
terminate() { try {
|
|
103
|
+
this.ws.terminate();
|
|
104
|
+
}
|
|
105
|
+
catch { /* gone */ } this.end(); }
|
|
106
|
+
on(event, fn) {
|
|
107
|
+
this.handlers[event].push(fn);
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
fail(code, reason) { this.close(code, reason); }
|
|
111
|
+
end() {
|
|
112
|
+
if (this.closed)
|
|
113
|
+
return;
|
|
114
|
+
this.closed = true;
|
|
115
|
+
for (const fn of this.handlers.close)
|
|
116
|
+
fn();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export class RelayHost {
|
|
120
|
+
state = "connecting";
|
|
121
|
+
attempt = 0;
|
|
122
|
+
/** Consecutive route checks that found no way from a phone to this host. */
|
|
123
|
+
routeFailures = 0;
|
|
124
|
+
deps;
|
|
125
|
+
now;
|
|
126
|
+
setTimer;
|
|
127
|
+
clearTimer;
|
|
128
|
+
sock = null;
|
|
129
|
+
lastRx = 0;
|
|
130
|
+
downSince = null;
|
|
131
|
+
timers = { retry: null, ping: null, ready: null, route: null };
|
|
132
|
+
stopped = false;
|
|
133
|
+
data = new Set();
|
|
134
|
+
probe = null;
|
|
135
|
+
/** A route check passed on the current control socket (said once per connection). */
|
|
136
|
+
routeConfirmed = false;
|
|
137
|
+
constructor(deps) {
|
|
138
|
+
this.deps = deps;
|
|
139
|
+
this.now = deps.now ?? Date.now;
|
|
140
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
141
|
+
this.clearTimer = deps.clearTimer ?? ((id) => clearTimeout(id));
|
|
142
|
+
}
|
|
143
|
+
start() { this.stopped = false; this.connect(); }
|
|
144
|
+
stop() {
|
|
145
|
+
this.stopped = true;
|
|
146
|
+
this.clearAll();
|
|
147
|
+
try {
|
|
148
|
+
this.sock?.close(1000, "stop");
|
|
149
|
+
}
|
|
150
|
+
catch { /* gone */ }
|
|
151
|
+
this.sock = null;
|
|
152
|
+
for (const d of this.data)
|
|
153
|
+
try {
|
|
154
|
+
d.close(1001, "host stopping");
|
|
155
|
+
}
|
|
156
|
+
catch { /* gone */ }
|
|
157
|
+
this.data.clear();
|
|
158
|
+
}
|
|
159
|
+
log(line) { this.deps.log?.(`relay: ${line}`); }
|
|
160
|
+
headers() { return { "x-ripple-host-secret": this.deps.secret }; }
|
|
161
|
+
clearAll() {
|
|
162
|
+
for (const k of ["retry", "ping", "ready", "route"])
|
|
163
|
+
if (this.timers[k] !== null) {
|
|
164
|
+
this.clearTimer(this.timers[k]);
|
|
165
|
+
this.timers[k] = null;
|
|
166
|
+
}
|
|
167
|
+
const probe = this.probe;
|
|
168
|
+
this.probe = null;
|
|
169
|
+
if (probe)
|
|
170
|
+
try {
|
|
171
|
+
probe.terminate();
|
|
172
|
+
}
|
|
173
|
+
catch { /* gone */ }
|
|
174
|
+
}
|
|
175
|
+
connect() {
|
|
176
|
+
if (this.stopped)
|
|
177
|
+
return;
|
|
178
|
+
this.state = "connecting";
|
|
179
|
+
let sock;
|
|
180
|
+
try {
|
|
181
|
+
sock = this.deps.makeSocket(`${wsOrigin(this.deps.relay)}/ws/host?server=${encodeURIComponent(this.deps.serverId)}`, this.headers());
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
this.log(e.message);
|
|
185
|
+
this.fail(null);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.sock = sock;
|
|
189
|
+
let ready = false;
|
|
190
|
+
// From creation: a connect that hangs after a network change fires neither open nor close for minutes.
|
|
191
|
+
this.timers.ready = this.setTimer(() => {
|
|
192
|
+
this.timers.ready = null;
|
|
193
|
+
if (!ready && this.sock === sock) {
|
|
194
|
+
this.log(`no hello within ${READY_MS / 1000} s; trying again`);
|
|
195
|
+
try {
|
|
196
|
+
sock.terminate();
|
|
197
|
+
}
|
|
198
|
+
catch { /* gone */ }
|
|
199
|
+
this.fail(sock);
|
|
200
|
+
}
|
|
201
|
+
}, READY_MS);
|
|
202
|
+
sock.on("open", () => { if (this.sock === sock)
|
|
203
|
+
this.lastRx = this.now(); });
|
|
204
|
+
sock.on("message", (data) => {
|
|
205
|
+
if (this.sock !== sock)
|
|
206
|
+
return;
|
|
207
|
+
this.lastRx = this.now();
|
|
208
|
+
const text = Buffer.isBuffer(data) ? data.toString("utf8") : Array.isArray(data) ? Buffer.concat(data).toString("utf8") : Buffer.from(data).toString("utf8");
|
|
209
|
+
if (text === "pong")
|
|
210
|
+
return;
|
|
211
|
+
let msg;
|
|
212
|
+
try {
|
|
213
|
+
msg = JSON.parse(text);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (msg.relay === "hello" && !ready) {
|
|
219
|
+
ready = true;
|
|
220
|
+
if (this.timers.ready !== null) {
|
|
221
|
+
this.clearTimer(this.timers.ready);
|
|
222
|
+
this.timers.ready = null;
|
|
223
|
+
}
|
|
224
|
+
if (this.attempt > 0)
|
|
225
|
+
this.log(`connected again after ${this.attempt} attempt(s), ${Math.round((this.now() - (this.downSince ?? this.now())) / 1000)} s without the relay`);
|
|
226
|
+
else
|
|
227
|
+
this.log(`connected to ${this.deps.relay}`);
|
|
228
|
+
this.state = "open";
|
|
229
|
+
this.attempt = 0;
|
|
230
|
+
this.downSince = null;
|
|
231
|
+
this.armPing(sock);
|
|
232
|
+
this.routeConfirmed = false;
|
|
233
|
+
this.armRouteCheck(sock, ROUTE_FIRST_MS);
|
|
234
|
+
}
|
|
235
|
+
else if (msg.relay === "connect" && typeof msg.conn === "string")
|
|
236
|
+
this.openData(msg.conn);
|
|
237
|
+
});
|
|
238
|
+
sock.on("unexpected-response", (_req, res) => {
|
|
239
|
+
// 403: another host already holds this server id with a different secret. Retrying is still right (it may be a stale room), but say so.
|
|
240
|
+
this.log(`refused with HTTP ${res.statusCode ?? "?"}`);
|
|
241
|
+
// With a listener here, `ws` leaves the handshake to us; end it so the close (and the retry) follow now.
|
|
242
|
+
try {
|
|
243
|
+
sock.terminate();
|
|
244
|
+
}
|
|
245
|
+
catch { /* gone */ }
|
|
246
|
+
});
|
|
247
|
+
sock.on("close", (code, reason) => {
|
|
248
|
+
if (this.sock !== sock)
|
|
249
|
+
return;
|
|
250
|
+
const why = reason ? String(reason) : "";
|
|
251
|
+
if (code === RELAY_CLOSE.replaced) {
|
|
252
|
+
// This process keeps one control socket, so a replacement came from somewhere else: another
|
|
253
|
+
// ripple-host with the same server id and secret. Back off fully rather than trade the room
|
|
254
|
+
// back and forth with it every second.
|
|
255
|
+
this.log(`replaced by another connection with this server id (a second ripple-host sharing this host.json?); retrying in ${BACKOFF_CAP_MS / 1000} s`);
|
|
256
|
+
this.fail(sock, BACKOFF_CAP_MS);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (ready)
|
|
260
|
+
this.log(`connection closed (${code}${why ? ` ${why}` : ""})`);
|
|
261
|
+
this.fail(sock);
|
|
262
|
+
});
|
|
263
|
+
sock.on("error", () => { });
|
|
264
|
+
}
|
|
265
|
+
/** The current control socket ended: retry after the backoff, or after `wait` when given. */
|
|
266
|
+
fail(sock, wait) {
|
|
267
|
+
if (sock && this.sock !== sock)
|
|
268
|
+
return;
|
|
269
|
+
this.sock = null;
|
|
270
|
+
this.clearAll();
|
|
271
|
+
if (this.stopped)
|
|
272
|
+
return;
|
|
273
|
+
this.attempt++;
|
|
274
|
+
this.downSince ??= this.now();
|
|
275
|
+
this.state = "waiting";
|
|
276
|
+
this.timers.retry = this.setTimer(() => { this.timers.retry = null; this.connect(); }, wait ?? relayBackoff(this.attempt));
|
|
277
|
+
}
|
|
278
|
+
armPing(sock) {
|
|
279
|
+
let due = this.now() + PING_MS;
|
|
280
|
+
let graceUntil = 0;
|
|
281
|
+
const loop = () => {
|
|
282
|
+
this.timers.ping = null;
|
|
283
|
+
if (this.sock !== sock)
|
|
284
|
+
return;
|
|
285
|
+
const now = this.now();
|
|
286
|
+
const late = now - due;
|
|
287
|
+
if (late > STALL_MS) {
|
|
288
|
+
// This process did not run for a while: the Mac slept, or something blocked the event
|
|
289
|
+
// loop. A pong that arrived meanwhile may still be queued behind this timer, so silence
|
|
290
|
+
// proves nothing yet; ping, and judge a moment later.
|
|
291
|
+
this.log(`timers ran ${Math.round(late / 1000)} s late (sleep or a blocked event loop); checking the connection`);
|
|
292
|
+
graceUntil = now + STALL_GRACE_MS;
|
|
293
|
+
}
|
|
294
|
+
else if (now - this.lastRx > STALE_MS && now >= graceUntil) {
|
|
295
|
+
// Half-open: no frame, not even a pong, for 30 s. Drop it ourselves rather than wait for TCP.
|
|
296
|
+
this.log(`nothing from the relay for ${Math.round((now - this.lastRx) / 1000)} s; dropping the connection`);
|
|
297
|
+
try {
|
|
298
|
+
sock.terminate();
|
|
299
|
+
}
|
|
300
|
+
catch { /* gone */ }
|
|
301
|
+
this.fail(sock);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (sock.readyState === OPEN)
|
|
305
|
+
try {
|
|
306
|
+
sock.send("ping");
|
|
307
|
+
}
|
|
308
|
+
catch { /* close follows */ }
|
|
309
|
+
const next = now < graceUntil ? STALL_GRACE_MS : PING_MS;
|
|
310
|
+
due = now + next;
|
|
311
|
+
this.timers.ping = this.setTimer(loop, next);
|
|
312
|
+
};
|
|
313
|
+
this.timers.ping = this.setTimer(loop, PING_MS);
|
|
314
|
+
}
|
|
315
|
+
armRouteCheck(sock, ms) {
|
|
316
|
+
if (this.timers.route !== null)
|
|
317
|
+
this.clearTimer(this.timers.route);
|
|
318
|
+
this.timers.route = this.setTimer(() => { this.timers.route = null; this.checkRoute(sock); }, ms);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Walk the phone's path: a client socket to this host's own room must be let through ("open"),
|
|
322
|
+
* which takes the relay sending "connect" to the socket this host holds and this host's data
|
|
323
|
+
* socket arriving. The probe then leaves; it never says hello, so HostServer never sees it.
|
|
324
|
+
*/
|
|
325
|
+
checkRoute(sock) {
|
|
326
|
+
if (this.sock !== sock || this.stopped)
|
|
327
|
+
return;
|
|
328
|
+
let probe;
|
|
329
|
+
try {
|
|
330
|
+
probe = this.deps.makeSocket(`${wsOrigin(this.deps.relay)}/ws/client?server=${encodeURIComponent(this.deps.serverId)}`, {});
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
this.log(`route check could not start: ${e.message}`);
|
|
334
|
+
this.armRouteCheck(sock, ROUTE_CHECK_MS);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.probe = probe;
|
|
338
|
+
let settled = false;
|
|
339
|
+
const finish = (ok, why) => {
|
|
340
|
+
if (settled)
|
|
341
|
+
return;
|
|
342
|
+
settled = true;
|
|
343
|
+
this.clearTimer(timeout);
|
|
344
|
+
if (this.probe === probe)
|
|
345
|
+
this.probe = null;
|
|
346
|
+
try {
|
|
347
|
+
probe.close(1000, "route check");
|
|
348
|
+
}
|
|
349
|
+
catch { /* gone */ }
|
|
350
|
+
if (this.sock !== sock || this.stopped)
|
|
351
|
+
return;
|
|
352
|
+
if (ok) {
|
|
353
|
+
if (this.routeFailures > 0)
|
|
354
|
+
this.log(`phones reach this host again (after ${this.routeFailures} failed route check(s))`);
|
|
355
|
+
else if (!this.routeConfirmed)
|
|
356
|
+
this.log("route check: phones reach this host");
|
|
357
|
+
this.routeConfirmed = true;
|
|
358
|
+
this.routeFailures = 0;
|
|
359
|
+
this.armRouteCheck(sock, ROUTE_CHECK_MS);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this.routeFailures++;
|
|
363
|
+
// Said once per streak (then every tenth), not every retry.
|
|
364
|
+
if (this.routeFailures === 1 || this.routeFailures % 10 === 0)
|
|
365
|
+
this.log(`route check failed (${why}): a phone cannot reach this host through the relay; reconnecting (failure ${this.routeFailures})`);
|
|
366
|
+
try {
|
|
367
|
+
sock.terminate();
|
|
368
|
+
}
|
|
369
|
+
catch { /* gone */ }
|
|
370
|
+
// Growing waits: a route the relay keeps broken is retried, not hammered.
|
|
371
|
+
this.fail(sock, relayBackoff(5 * this.routeFailures));
|
|
372
|
+
};
|
|
373
|
+
const timeout = this.setTimer(() => finish(false, `not let through within ${ROUTE_TIMEOUT_MS / 1000} s`), ROUTE_TIMEOUT_MS);
|
|
374
|
+
probe.on("message", (data, isBinary) => {
|
|
375
|
+
if (isBinary)
|
|
376
|
+
return;
|
|
377
|
+
try {
|
|
378
|
+
if (JSON.parse(toText(data)).relay === "open")
|
|
379
|
+
finish(true, "");
|
|
380
|
+
}
|
|
381
|
+
catch { /* not ours */ }
|
|
382
|
+
});
|
|
383
|
+
probe.on("close", (code) => finish(false, code === RELAY_CLOSE.hostOffline ? "the relay says this host is offline" : `closed ${code}`));
|
|
384
|
+
probe.on("unexpected-response", (_req, res) => {
|
|
385
|
+
finish(false, `HTTP ${res.statusCode ?? "?"}`);
|
|
386
|
+
try {
|
|
387
|
+
probe.terminate();
|
|
388
|
+
}
|
|
389
|
+
catch { /* gone */ }
|
|
390
|
+
});
|
|
391
|
+
probe.on("error", () => { });
|
|
392
|
+
}
|
|
393
|
+
/** A phone is waiting in the room: open its data socket and answer its handshake. */
|
|
394
|
+
openData(conn) {
|
|
395
|
+
if (!/^[\w-]{8,64}$/.test(conn))
|
|
396
|
+
return;
|
|
397
|
+
let ws;
|
|
398
|
+
try {
|
|
399
|
+
ws = this.deps.makeSocket(`${wsOrigin(this.deps.relay)}/ws/host-data?server=${encodeURIComponent(this.deps.serverId)}&conn=${encodeURIComponent(conn)}`, this.headers());
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.data.add(ws);
|
|
405
|
+
let done = false;
|
|
406
|
+
const timer = this.setTimer(() => { if (!done)
|
|
407
|
+
try {
|
|
408
|
+
ws.terminate();
|
|
409
|
+
}
|
|
410
|
+
catch { /* gone */ } }, READY_MS * 2);
|
|
411
|
+
const onHello = (data, isBinary) => {
|
|
412
|
+
if (done)
|
|
413
|
+
return;
|
|
414
|
+
done = true;
|
|
415
|
+
this.clearTimer(timer);
|
|
416
|
+
try {
|
|
417
|
+
if (isBinary)
|
|
418
|
+
throw new Error("handshake must be text");
|
|
419
|
+
const bytes = toBytes(data);
|
|
420
|
+
if (bytes.length > 4096)
|
|
421
|
+
throw new Error("handshake too large");
|
|
422
|
+
const { text, channel } = hostAccept(this.deps.nacl, this.deps.hostKey, new TextDecoder().decode(bytes));
|
|
423
|
+
ws.send(text);
|
|
424
|
+
this.deps.onPeer(new SecurePeer(ws, channel));
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
this.deps.log?.(`relay: phone handshake failed: ${e.message}`);
|
|
428
|
+
try {
|
|
429
|
+
ws.close(4400, "handshake");
|
|
430
|
+
}
|
|
431
|
+
catch { /* gone */ }
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
ws.on("message", onHello);
|
|
435
|
+
ws.on("close", () => { this.data.delete(ws); this.clearTimer(timer); });
|
|
436
|
+
ws.on("error", () => { });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
|
|
2
|
+
const LABELS = {
|
|
3
|
+
chatgpt: "ChatGPT", anthropic: "Claude", openrouter: "OpenRouter", deepseek: "DeepSeek",
|
|
4
|
+
"opencode-go": "OpenCode Go", "opencode-zen": "OpenCode Zen", "google-gemini": "Google Gemini",
|
|
5
|
+
};
|
|
6
|
+
const toEffort = (s) => (s && EFFORTS.includes(s) ? s : null);
|
|
7
|
+
const base = (id) => id.split("@")[0].replace(/-\d{8}$/, "");
|
|
8
|
+
export class RouterClient {
|
|
9
|
+
adminUrl;
|
|
10
|
+
fetch;
|
|
11
|
+
constructor(adminUrl, fetchImpl = globalThis.fetch) {
|
|
12
|
+
this.adminUrl = adminUrl.replace(/\/$/, "");
|
|
13
|
+
this.fetch = fetchImpl;
|
|
14
|
+
}
|
|
15
|
+
async get(path) {
|
|
16
|
+
const res = await this.fetch(this.adminUrl + path, { signal: AbortSignal.timeout(4000) });
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
throw new Error(`router ${path}: HTTP ${res.status}`);
|
|
19
|
+
return (await res.json());
|
|
20
|
+
}
|
|
21
|
+
/** Every chat model the router routes, grouped by provider, judge-only models left out (ARCHITECTURE §3b). */
|
|
22
|
+
async models() {
|
|
23
|
+
const [opts, levels] = await Promise.all([this.get("/api/mod-options"), this.get("/api/effort-levels")]);
|
|
24
|
+
return groupModels(opts, levels);
|
|
25
|
+
}
|
|
26
|
+
/** The router's own `GET /api/limits` body, unvalidated: `rippleStatus` reads it at the boundary. */
|
|
27
|
+
async limits() { return this.get("/api/limits"); }
|
|
28
|
+
/** The router's own `GET /api/mod-settings` body, unvalidated: `rippleStatus` reads it at the boundary. */
|
|
29
|
+
async modSettings() { return this.get("/api/mod-settings"); }
|
|
30
|
+
/** What a spawned CLI needs to go through the router: the same two keys `~/.claude/settings.json` carries (ARCHITECTURE §1). */
|
|
31
|
+
async status() {
|
|
32
|
+
try {
|
|
33
|
+
const s = await this.get("/api/status");
|
|
34
|
+
const proxyEnv = {};
|
|
35
|
+
const proxy = s.settings?.HTTPS_PROXY ?? (s.listen ? `http://${s.listen.host}:${s.listen.port}` : undefined);
|
|
36
|
+
if (proxy)
|
|
37
|
+
proxyEnv.HTTPS_PROXY = proxy;
|
|
38
|
+
if (s.settings?.NODE_EXTRA_CA_CERTS)
|
|
39
|
+
proxyEnv.NODE_EXTRA_CA_CERTS = s.settings.NODE_EXTRA_CA_CERTS;
|
|
40
|
+
// Readiness problems name one provider needing attention (measured: "provider:opencode-go"
|
|
41
|
+
// while that provider still answered); they are not "the router is down", so they do not turn the banner on.
|
|
42
|
+
return { ok: true, proxyEnv };
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return { ok: false, problem: e.message, proxyEnv: {} };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The mod's figures for one Claude Code session, from the router's own request records. */
|
|
49
|
+
async figures(sessionId, mainModel, contextWindow) {
|
|
50
|
+
const [reqs, status, window] = await Promise.all([
|
|
51
|
+
this.get(`/api/requests?kind=messages&n=400&session=${encodeURIComponent(sessionId)}`).catch(() => ({ requests: [] })),
|
|
52
|
+
this.get("/api/status").catch(() => ({})),
|
|
53
|
+
contextWindow ?? this.catalogWindow(mainModel),
|
|
54
|
+
]);
|
|
55
|
+
return computeFigures(reqs.requests, mainModel, window, status, Date.now());
|
|
56
|
+
}
|
|
57
|
+
windows = null;
|
|
58
|
+
/**
|
|
59
|
+
* The router's catalogue window for a model (`GET /api/context-windows`), for a session whose CLI
|
|
60
|
+
* never told us one: a Claude app session. Read at most every 10 minutes; unknown or an older
|
|
61
|
+
* router without the endpoint is null.
|
|
62
|
+
*/
|
|
63
|
+
async catalogWindow(model) {
|
|
64
|
+
// Claude Code's own suffix: "[1m]" is the million-token variant of the same model.
|
|
65
|
+
if (/\[1m\]$/i.test(model))
|
|
66
|
+
return 1_000_000;
|
|
67
|
+
if (!this.windows || Date.now() - this.windows.at > 10 * 60_000) {
|
|
68
|
+
const body = await this.get("/api/context-windows").catch(() => null);
|
|
69
|
+
const raw = body?.windows && typeof body.windows === "object" ? body.windows : null;
|
|
70
|
+
// A failed read is not cached: the next refresh asks again.
|
|
71
|
+
if (raw) {
|
|
72
|
+
const map = {};
|
|
73
|
+
for (const [id, value] of Object.entries(raw))
|
|
74
|
+
if (typeof value === "number" && value > 0)
|
|
75
|
+
map[id] = value;
|
|
76
|
+
this.windows = { at: Date.now(), map };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const map = this.windows?.map ?? {};
|
|
80
|
+
const id = model.replace(/\[[^\]]*\]$/, "");
|
|
81
|
+
return map[model] ?? map[id] ?? map[base(id)] ?? null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function groupModels(opts, levels) {
|
|
85
|
+
const usable = new Map(opts.providers.map((p) => [p.name, p.usable]));
|
|
86
|
+
const groups = new Map();
|
|
87
|
+
for (const m of opts.models) {
|
|
88
|
+
if (m.judgeOnly)
|
|
89
|
+
continue;
|
|
90
|
+
const ladder = levels.providers[m.provider];
|
|
91
|
+
const efforts = (ladder?.models?.[m.model] ?? ladder?.default ?? []).map(toEffort).filter((e) => e !== null && e !== "none" && e !== "minimal");
|
|
92
|
+
const ok = usable.get(m.provider) ?? false;
|
|
93
|
+
let g = groups.get(m.provider);
|
|
94
|
+
if (!g) {
|
|
95
|
+
g = { provider: m.provider, label: LABELS[m.provider] ?? m.provider, usable: ok, models: [] };
|
|
96
|
+
groups.set(m.provider, g);
|
|
97
|
+
}
|
|
98
|
+
g.models.push({ model: m.model, name: m.name, provider: m.provider, efforts, usable: ok });
|
|
99
|
+
}
|
|
100
|
+
return Array.from(groups.values());
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Cache % is read over all input of the session's main-model requests; context % is the latest
|
|
104
|
+
* main request's input over the window the CLI reported for the model. Unknown stays null.
|
|
105
|
+
* `route` is where that same latest request went (the router's target, not the model asked for).
|
|
106
|
+
*/
|
|
107
|
+
export function computeFigures(records, mainModel, contextWindow, status, now) {
|
|
108
|
+
const main = base(mainModel);
|
|
109
|
+
let read = 0, all = 0;
|
|
110
|
+
let effort = null;
|
|
111
|
+
let latest = -Infinity;
|
|
112
|
+
let lastInput = null;
|
|
113
|
+
let route = null;
|
|
114
|
+
const workers = new Map();
|
|
115
|
+
for (const r of records) {
|
|
116
|
+
const target = r.target ?? r.source ?? "";
|
|
117
|
+
if (base(target) === main || base(r.source ?? "") === main) {
|
|
118
|
+
const u = r.usage ?? {};
|
|
119
|
+
read += u.cached ?? 0;
|
|
120
|
+
all += (u.input ?? 0) + (u.cached ?? 0) + (u.cacheWrite ?? 0);
|
|
121
|
+
const at = r.at ? Date.parse(r.at) : 0;
|
|
122
|
+
const input = (u.input ?? 0) + (u.cached ?? 0) + (u.cacheWrite ?? 0);
|
|
123
|
+
// A request still streaming has no usage yet; it would read as an empty context (measured: 0% mid-turn).
|
|
124
|
+
if (at > latest && input > 0) {
|
|
125
|
+
latest = at;
|
|
126
|
+
effort = toEffort(r.effort) ?? effort;
|
|
127
|
+
lastInput = input;
|
|
128
|
+
route = { model: target.replace(/@[^@]*$/, ""), provider: r.provider ?? "", ok: r.ok === true, status: typeof r.status === "number" ? r.status : null };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (target) {
|
|
132
|
+
const key = base(target);
|
|
133
|
+
const w = workers.get(key) ?? { model: key, provider: r.provider ?? "", requests: 0 };
|
|
134
|
+
w.requests++;
|
|
135
|
+
workers.set(key, w);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const windows = status.claude?.accounts?.[0]?.quota?.rate_limits;
|
|
139
|
+
const five = [windows?.primary, windows?.secondary].find((w) => w?.window_minutes === 300);
|
|
140
|
+
return {
|
|
141
|
+
effort,
|
|
142
|
+
cachePct: all > 0 ? Math.round((read / all) * 1000) / 10 : null,
|
|
143
|
+
contextPct: contextWindow && lastInput !== null ? Math.min(100, Math.round((lastInput / contextWindow) * 1000) / 10) : null,
|
|
144
|
+
contextTokens: lastInput,
|
|
145
|
+
contextWindow: contextWindow && contextWindow > 0 ? contextWindow : null,
|
|
146
|
+
route,
|
|
147
|
+
fiveHourPct: typeof five?.used_percent === "number" ? five.used_percent : null,
|
|
148
|
+
fiveHourResetAt: typeof five?.reset_at === "number" ? five.reset_at * 1000 : null,
|
|
149
|
+
workers: Array.from(workers.values()).sort((a, b) => b.requests - a.requests),
|
|
150
|
+
at: now,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// ---- the mod's host-wide status -----------------------------------------------------------------
|
|
154
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
155
|
+
const WINDOW_KINDS = ["five_hour", "seven_day", "scoped", "window"];
|
|
156
|
+
/**
|
|
157
|
+
* What the ClaudeRipple mod is set to, from the router's `GET /api/mod-settings` and `GET /api/limits`.
|
|
158
|
+
* Both bodies are read at the boundary: a malformed piece is dropped, never thrown on. The
|
|
159
|
+
* flattening follows the mod's own `routerLimitItems` (packages/mod/hooks/session.ts): a window
|
|
160
|
+
* whose reset has passed is gone, a reset under 1e12 is epoch seconds, Claude's windows lead and the
|
|
161
|
+
* other providers keep the router's order.
|
|
162
|
+
*/
|
|
163
|
+
export function rippleStatus(limitsJson, modSettingsJson, now) {
|
|
164
|
+
const settings = obj(obj(modSettingsJson).settings);
|
|
165
|
+
const modes = Array.isArray(settings.modes) ? settings.modes.map(obj) : [];
|
|
166
|
+
const active = typeof settings.activeMode === "string" ? modes.find((m) => m.id === settings.activeMode) : undefined;
|
|
167
|
+
const mode = active && typeof active.id === "string" && typeof active.name === "string"
|
|
168
|
+
? { id: active.id, name: active.name, since: typeof settings.activeSince === "number" && Number.isFinite(settings.activeSince) ? settings.activeSince : null }
|
|
169
|
+
: null;
|
|
170
|
+
const providers = Array.isArray(obj(limitsJson).providers) ? obj(limitsJson).providers.map(obj) : [];
|
|
171
|
+
const limits = [];
|
|
172
|
+
const claude = [];
|
|
173
|
+
for (const p of providers) {
|
|
174
|
+
const provider = typeof p.provider === "string" ? p.provider : "";
|
|
175
|
+
if (!provider || !Array.isArray(p.accounts))
|
|
176
|
+
continue;
|
|
177
|
+
const title = typeof p.title === "string" && p.title ? p.title : LABELS[provider] ?? provider;
|
|
178
|
+
const accounts = p.accounts.map(obj).filter((a) => Array.isArray(a.windows));
|
|
179
|
+
const into = p.type === "anthropic" ? claude : limits;
|
|
180
|
+
accounts.forEach((a, index) => {
|
|
181
|
+
for (const w of a.windows.map(obj)) {
|
|
182
|
+
const kind = WINDOW_KINDS.find((k) => k === w.kind);
|
|
183
|
+
if (!kind || typeof w.used_percent !== "number" || !Number.isFinite(w.used_percent))
|
|
184
|
+
continue;
|
|
185
|
+
const resetAt = typeof w.reset_at === "number" && Number.isFinite(w.reset_at) ? w.reset_at * (w.reset_at < 1e12 ? 1000 : 1) : null;
|
|
186
|
+
if (resetAt !== null && resetAt <= now)
|
|
187
|
+
continue;
|
|
188
|
+
into.push({
|
|
189
|
+
provider, title,
|
|
190
|
+
...(accounts.length > 1 ? { account: `#${(typeof a.account === "number" ? a.account : index) + 1}` } : {}),
|
|
191
|
+
kind,
|
|
192
|
+
label: (kind === "scoped" || kind === "window") && typeof w.label === "string" ? w.label : null,
|
|
193
|
+
usedPct: Math.round(w.used_percent * 10) / 10,
|
|
194
|
+
windowMinutes: typeof w.window_minutes === "number" && w.window_minutes > 0 ? w.window_minutes : null,
|
|
195
|
+
resetAt,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
mode,
|
|
202
|
+
effortAuto: settings.effortAuto === true,
|
|
203
|
+
effortFloor: toEffort(typeof settings.floor === "string" ? settings.floor : undefined),
|
|
204
|
+
effortCeiling: toEffort(typeof settings.ceiling === "string" ? settings.ceiling : undefined),
|
|
205
|
+
limits: [...claude, ...limits],
|
|
206
|
+
at: now,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// npm's Windows batch launcher needs cmd.exe; native .exe and SDK's bundled CLI stay direct.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
export function batchCommand(command, args) {
|
|
5
|
+
// cmd expands these even inside quotes. Refuse rather than execute a different command.
|
|
6
|
+
const words = [command, ...args];
|
|
7
|
+
if (words.some(word => /[\r\n"%!^&|<>]/.test(word)))
|
|
8
|
+
throw new Error("unsafe character in Windows Claude batch launcher arguments; use claude.exe instead");
|
|
9
|
+
return words.map(word => `"${word}"`).join(" ");
|
|
10
|
+
}
|
|
11
|
+
export function launcherOptions(binary, platform = process.platform) {
|
|
12
|
+
if (platform !== "win32" || !binary || !/\.(cmd|bat)$/i.test(binary))
|
|
13
|
+
return {};
|
|
14
|
+
return { spawnClaudeCodeProcess: options => spawn(batchCommand(options.command, options.args), {
|
|
15
|
+
cwd: options.cwd, env: options.env, signal: options.signal, shell: true, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
|
|
16
|
+
}) };
|
|
17
|
+
}
|
|
18
|
+
export const sdkQuery = input => query({ ...input, options: { ...input.options, ...launcherOptions(input.options?.pathToClaudeCodeExecutable) } });
|