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,706 @@
1
+ // One HTTP server: the PWA's files, and one WebSocket that carries everything else. Keeping all
2
+ // traffic on the socket is the relay seam: a relay later forwards this one stream (encrypted)
3
+ // and nothing about the app changes.
4
+ import fs from "node:fs";
5
+ import http from "node:http";
6
+ import module from "node:module";
7
+ import path from "node:path";
8
+ import { WebSocketServer } from "ws";
9
+ import { tokenMatches } from "./config.js";
10
+ import { browse, makeFolder, recentFolders } from "./folders.js";
11
+ import { gitInfo } from "./worktree.js";
12
+ import { ATTACHMENT_CAP, FRAME_CAP, PING_MS, STALE_MS } from "./protocol.js";
13
+ import { SEEN_MS, VISIBLE_MS } from "./push.js";
14
+ import { OutcomeUnknownError } from "./receipts.js";
15
+ import { rippleStatus } from "./router.js";
16
+ /** Stop writing to a client whose socket has this much queued; its pump resumes when it drains. */
17
+ const HIGH_WATER = 1024 * 1024;
18
+ /** A reconnecting client further behind than this many frames gets the tail instead of a replay. */
19
+ const CATCHUP_FRAMES = 4;
20
+ /** `/api/limits` makes the router read each provider's quota; once a minute is plenty for percentages. */
21
+ const LIMITS_MS = 60_000;
22
+ const BLOB_CHUNK_CHARS = 60_000;
23
+ const TYPES = {
24
+ ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".ts": "text/javascript; charset=utf-8",
25
+ ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".webmanifest": "application/manifest+json",
26
+ };
27
+ export const CSP = "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'";
28
+ const require = module.createRequire(import.meta.url);
29
+ /** Published path → file, for what the PWA loads from outside web/. The relay's build serves the same paths. */
30
+ export const SHARED = {
31
+ "src/protocol.ts": (pkg) => path.join(pkg, "src", "protocol.ts"),
32
+ "src/tool-details.ts": (pkg) => path.join(pkg, "src", "tool-details.ts"),
33
+ "src/e2ee.ts": (pkg) => path.join(pkg, "src", "e2ee.ts"),
34
+ "vendor/nacl-fast.min.js": () => require.resolve("tweetnacl/nacl-fast.min.js"),
35
+ };
36
+ export class HostServer {
37
+ opts;
38
+ http;
39
+ wss;
40
+ clients = new Set();
41
+ localHttp = null;
42
+ lastSeen = new Map();
43
+ phones() {
44
+ const push = new Set(this.opts.pushDevices?.() ?? []);
45
+ const phones = new Map([...push].map(clientId => [clientId, { clientId, connected: false, lastSeen: this.lastSeen.get(clientId) ?? null, push: true }]));
46
+ for (const c of this.clients) {
47
+ if (!c.authed || !c.clientId || c.ws.readyState !== c.ws.OPEN)
48
+ continue;
49
+ const prior = phones.get(c.clientId);
50
+ phones.set(c.clientId, { clientId: c.clientId, connected: true, lastSeen: Math.max(prior?.lastSeen ?? 0, c.lastRx), push: push.has(c.clientId) });
51
+ }
52
+ return [...phones.values()];
53
+ }
54
+ rotateToken(token) {
55
+ this.opts.token = token;
56
+ for (const c of this.clients) {
57
+ if (!c.authed)
58
+ continue;
59
+ c.authed = false;
60
+ c.attached.clear();
61
+ c.ws.terminate();
62
+ this.clients.delete(c);
63
+ }
64
+ this.uploads.clear();
65
+ this.seenAt.clear();
66
+ this.lastSeen.clear();
67
+ this.opts.clearPush?.();
68
+ }
69
+ stripped = new Map();
70
+ models = null;
71
+ uploads = new Map();
72
+ hostInfo;
73
+ limitsBody = null;
74
+ limitsAt = -Infinity;
75
+ timers = [];
76
+ sessionsTimer = null;
77
+ /** session → last time a connected phone looked at it (attach, or visible presence while
78
+ * attached). Push goes out only when nobody looked in the last SEEN_MS (Paseo's
79
+ * agent-attention-policy PRESENCE_THRESHOLD_MS, 180 s). */
80
+ seenAt = new Map();
81
+ constructor(opts) {
82
+ this.opts = opts;
83
+ const desktopProblem = opts.sessions.desktopProblem?.();
84
+ this.hostInfo = { name: opts.hostName, version: opts.version, routerOk: true, ...(desktopProblem ? { desktopProblem } : {}) };
85
+ this.http = http.createServer((req, res) => this.serve(req, res));
86
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: FRAME_CAP });
87
+ this.http.on("upgrade", (req, socket, head) => {
88
+ if (new URL(req.url ?? "/", "http://x").pathname !== "/ws" || !this.sameOrigin(req)) {
89
+ socket.destroy();
90
+ return;
91
+ }
92
+ this.wss.handleUpgrade(req, socket, head, (ws) => this.connected(ws));
93
+ });
94
+ }
95
+ /** A page on another origin in this machine's browser must not drive the socket, token or not. */
96
+ sameOrigin(req) {
97
+ const origin = req.headers.origin;
98
+ if (!origin)
99
+ return true;
100
+ try {
101
+ return new URL(origin).host === req.headers.host;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ /** True while a connected phone reported the app visible within VISIBLE_MS: then nobody needs a push. */
108
+ someoneLooking() {
109
+ const now = this.now();
110
+ for (const c of this.clients)
111
+ if (c.authed && c.visibleAt > 0 && now - c.visibleAt < VISIBLE_MS && c.ws.readyState === c.ws.OPEN)
112
+ return true;
113
+ return false;
114
+ }
115
+ /** True while a connected phone looked at this session in the last SEEN_MS: then it needs no push. */
116
+ sessionSeenRecently(session) {
117
+ return this.now() - (this.seenAt.get(session) ?? 0) < SEEN_MS;
118
+ }
119
+ markSeen(c, session) {
120
+ if (!c.authed)
121
+ return;
122
+ this.seenAt.set(session, this.now());
123
+ if (this.seenAt.size > 500) {
124
+ const cutoff = this.now() - SEEN_MS;
125
+ for (const [id, at] of this.seenAt)
126
+ if (at < cutoff)
127
+ this.seenAt.delete(id);
128
+ }
129
+ }
130
+ now() { return (this.opts.now ?? Date.now)(); }
131
+ desktopTicks = 0;
132
+ pollDesktop() {
133
+ // The desktop poll runs on its own timer even with no phone connected, so a Mac session that
134
+ // finishes while nobody watches still appends its result — and the push for it goes out. Each
135
+ // tick also asks Remote Control for every live Mac session's state over the network, so with no
136
+ // phone looking it runs every fifth tick (15 s) rather than every 3 s, around the clock.
137
+ if (!this.someoneLooking() && this.desktopTicks++ % 5 !== 0)
138
+ return;
139
+ void this.opts.sessions.pollDesktop().catch(error => {
140
+ this.opts.log?.(`desktop poll failed: ${error instanceof Error ? error.message : String(error)}`);
141
+ });
142
+ }
143
+ async listen() {
144
+ await new Promise((resolve, reject) => {
145
+ this.http.once("error", reject);
146
+ this.http.listen(this.opts.port, this.opts.listen, () => {
147
+ this.http.off("error", reject);
148
+ this.timers.push(setInterval(() => this.reapStale(), PING_MS));
149
+ this.timers.push(setInterval(() => void this.checkRouter(), 15_000));
150
+ this.timers.push(setInterval(() => this.pollDesktop(), 3000));
151
+ void this.checkRouter();
152
+ resolve();
153
+ });
154
+ });
155
+ // A specific LAN/Tailscale/IPv6 bind does not receive 127.0.0.1 traffic. Keep its public
156
+ // interface unchanged and add only a loopback control listener on the same port.
157
+ if (this.opts.local && this.opts.listen !== "127.0.0.1" && this.opts.listen !== "localhost" && this.opts.listen !== "0.0.0.0" && this.opts.listen !== "::") {
158
+ const local = http.createServer((req, res) => {
159
+ try {
160
+ const pathname = new URL(req.url ?? "/", "http://local").pathname;
161
+ if (!pathname.startsWith("/local/")) {
162
+ res.writeHead(404).end();
163
+ return;
164
+ }
165
+ }
166
+ catch {
167
+ res.writeHead(400).end();
168
+ return;
169
+ }
170
+ this.serve(req, res);
171
+ });
172
+ this.localHttp = local;
173
+ try {
174
+ await new Promise((resolve, reject) => {
175
+ local.once("error", reject);
176
+ local.listen(this.address().port, "127.0.0.1", () => { local.off("error", reject); resolve(); });
177
+ });
178
+ }
179
+ catch (error) {
180
+ await this.close();
181
+ throw error;
182
+ }
183
+ }
184
+ }
185
+ close() {
186
+ for (const t of this.timers)
187
+ clearInterval(t);
188
+ if (this.sessionsTimer)
189
+ clearTimeout(this.sessionsTimer);
190
+ this.sessionsTimer = null;
191
+ for (const c of this.clients)
192
+ c.ws.terminate();
193
+ return Promise.all([this.http, this.localHttp].filter((server) => server !== null)
194
+ .map(server => new Promise(resolve => server.close(() => resolve())))).then(() => { });
195
+ }
196
+ address() { return this.http.address(); }
197
+ // ---- session events in, frames out ---------------------------------------------------------
198
+ /** Called for every appended event: push it to every client attached to that session. */
199
+ onEvent(session) {
200
+ for (const c of this.clients)
201
+ if (c.authed && c.attached.has(session))
202
+ this.pump(c, session);
203
+ }
204
+ /** Session list changed (state, pending count, a new one). Coalesced; lists are small but frequent. */
205
+ onSessions() {
206
+ if (this.sessionsTimer)
207
+ return;
208
+ this.sessionsTimer = setTimeout(() => {
209
+ this.sessionsTimer = null;
210
+ try {
211
+ const msg = { t: "sessions", sessions: this.opts.sessions.list(), groups: this.opts.sessions.groups() };
212
+ for (const c of this.clients)
213
+ if (c.authed)
214
+ this.send(c, msg);
215
+ }
216
+ catch (error) {
217
+ this.opts.log?.(`session list failed: ${error instanceof Error ? error.message : String(error)}`);
218
+ }
219
+ }, 150);
220
+ }
221
+ pump(c, session) {
222
+ if (c.pumping.has(session))
223
+ return;
224
+ c.pumping.add(session);
225
+ try {
226
+ const log = this.opts.sessions.log(session);
227
+ for (;;) {
228
+ const after = c.attached.get(session);
229
+ if (after === undefined || after >= log.lastSeq)
230
+ break;
231
+ if (c.ws.bufferedAmount > HIGH_WATER) {
232
+ setTimeout(() => this.pump(c, session), 200);
233
+ break;
234
+ }
235
+ const page = log.readAfter(after);
236
+ // Skipped (superseded) deltas can leave a page empty; the cursor still moves to the end.
237
+ const last = page.events.length ? page.events[page.events.length - 1].seq : log.lastSeq;
238
+ c.attached.set(session, page.more ? last : Math.max(last, log.lastSeq));
239
+ if (page.events.length)
240
+ this.send(c, { t: "events", session, epoch: log.epoch, events: page.events, more: page.more });
241
+ if (!page.more)
242
+ break;
243
+ }
244
+ }
245
+ finally {
246
+ c.pumping.delete(session);
247
+ }
248
+ }
249
+ attach(c, session, epoch, afterSeq) {
250
+ if (!this.opts.sessions.has(session)) {
251
+ this.send(c, { t: "error", code: "no_session", message: "no such session", session });
252
+ return;
253
+ }
254
+ this.markSeen(c, session);
255
+ // A Mac session may already have approvals waiting from before this attach (or before the
256
+ // host's Remote Control stream connected): fetch them so no approval card is missed.
257
+ void this.opts.sessions.syncBridge(session).catch(error => {
258
+ this.opts.log?.(`bridge sync failed: ${error instanceof Error ? error.message : String(error)}`);
259
+ });
260
+ const log = this.opts.sessions.log(session);
261
+ if (epoch === log.epoch && afterSeq !== undefined && afterSeq > 0 && afterSeq <= log.lastSeq) {
262
+ // Resume by seq, unless the gap is so large that replaying it would be slower than starting from the tail.
263
+ let cursor = afterSeq;
264
+ let frames = 0;
265
+ for (; frames < CATCHUP_FRAMES; frames++) {
266
+ const page = log.readAfter(cursor);
267
+ if (!page.more)
268
+ break;
269
+ cursor = page.events.length ? page.events[page.events.length - 1].seq : log.lastSeq;
270
+ }
271
+ if (frames < CATCHUP_FRAMES) {
272
+ c.attached.set(session, afterSeq);
273
+ this.pump(c, session);
274
+ return;
275
+ }
276
+ }
277
+ const tail = log.tail();
278
+ c.attached.set(session, log.lastSeq);
279
+ this.send(c, { t: "events", session, epoch: log.epoch, events: tail.events, more: false, tail: { hasOlder: tail.hasOlder } });
280
+ }
281
+ // ---- sockets --------------------------------------------------------------------------------
282
+ /** A phone connected, directly or through the relay; it must say hello with the token within 10 s. */
283
+ connected(ws) {
284
+ const c = { ws, authed: false, clientId: "", visibleAt: 0, lastRx: Date.now(), attached: new Map(), pumping: new Set() };
285
+ this.clients.add(c);
286
+ const helloTimer = setTimeout(() => { if (!c.authed)
287
+ ws.close(4001, "hello"); }, 10_000);
288
+ ws.on("message", (data) => {
289
+ c.lastRx = Date.now();
290
+ let msg;
291
+ try {
292
+ msg = JSON.parse(String(data));
293
+ }
294
+ catch {
295
+ return;
296
+ }
297
+ void this.onClient(c, msg).catch((e) => this.send(c, { t: "error", code: "internal", message: e.message }));
298
+ });
299
+ ws.on("close", () => {
300
+ clearTimeout(helloTimer);
301
+ if (c.authed && c.clientId)
302
+ this.lastSeen.set(c.clientId, c.lastRx);
303
+ this.clients.delete(c);
304
+ });
305
+ ws.on("error", () => { });
306
+ }
307
+ reapStale() {
308
+ const now = Date.now();
309
+ for (const c of this.clients)
310
+ if (now - c.lastRx > STALE_MS)
311
+ c.ws.terminate();
312
+ }
313
+ send(c, msg) {
314
+ if (c.ws.readyState !== c.ws.OPEN)
315
+ return;
316
+ const text = JSON.stringify(msg);
317
+ if (Buffer.byteLength(text) > FRAME_CAP) {
318
+ // The log caps events and pages, so this means a bug; say so rather than kill the socket (Paseo #2610).
319
+ this.opts.log?.(`frame over cap dropped: ${msg.t} ${Buffer.byteLength(text)} bytes`);
320
+ if (msg.t !== "error")
321
+ this.send(c, { t: "error", code: "too_large", message: `${msg.t} frame over ${FRAME_CAP} bytes` });
322
+ return;
323
+ }
324
+ c.ws.send(text);
325
+ }
326
+ async onClient(c, msg) {
327
+ if (msg.t === "ping") {
328
+ this.send(c, { t: "pong", n: msg.n });
329
+ return;
330
+ }
331
+ if (!c.authed) {
332
+ if (msg.t === "hello" && tokenMatches(this.opts.token, msg.token)) {
333
+ const clientId = typeof msg.clientId === "string" ? msg.clientId : "";
334
+ if (clientId && !/^[A-Za-z0-9_-]{1,100}$/.test(clientId)) {
335
+ this.send(c, { t: "denied", reason: "token" });
336
+ c.ws.close(4003, "denied");
337
+ return;
338
+ }
339
+ c.authed = true;
340
+ c.clientId = clientId;
341
+ this.send(c, { t: "ready", host: this.hostInfo, sessions: this.opts.sessions.list(), groups: this.opts.sessions.groups() });
342
+ }
343
+ else {
344
+ this.send(c, { t: "denied", reason: "token" });
345
+ c.ws.close(4003, "denied");
346
+ }
347
+ return;
348
+ }
349
+ const s = this.opts.sessions;
350
+ switch (msg.t) {
351
+ case "hello": return;
352
+ case "attach":
353
+ this.attach(c, msg.session, msg.epoch, msg.afterSeq);
354
+ return;
355
+ case "detach":
356
+ c.attached.delete(msg.session);
357
+ return;
358
+ case "seen":
359
+ if (s.has(msg.session))
360
+ s.markSeen(msg.session);
361
+ return;
362
+ case "archive":
363
+ try {
364
+ s.setArchived(msg.session, msg.archived === true);
365
+ }
366
+ catch (e) {
367
+ this.send(c, { t: "error", code: "archive", message: e.message, session: msg.session });
368
+ }
369
+ return;
370
+ case "unread":
371
+ try {
372
+ s.markUnread(msg.session);
373
+ }
374
+ catch (e) {
375
+ this.send(c, { t: "error", code: "unread", message: e.message, session: msg.session });
376
+ }
377
+ return;
378
+ case "history": {
379
+ if (!s.has(msg.session))
380
+ return;
381
+ const page = s.log(msg.session).before(msg.beforeSeq);
382
+ this.send(c, { t: "history", session: msg.session, reqId: msg.reqId, events: page.events, hasOlder: page.hasOlder });
383
+ return;
384
+ }
385
+ case "create":
386
+ try {
387
+ const id = await s.create(msg);
388
+ this.send(c, { t: "ack", clientMsgId: msg.clientMsgId, session: id });
389
+ }
390
+ catch (e) {
391
+ if (e instanceof OutcomeUnknownError) {
392
+ this.send(c, { t: "error", code: "outcome_unknown", message: e.message, clientMsgId: msg.clientMsgId });
393
+ }
394
+ else {
395
+ this.send(c, { t: "error", code: "create", message: e.message, clientMsgId: msg.clientMsgId });
396
+ }
397
+ }
398
+ return;
399
+ case "send":
400
+ try {
401
+ const desktop = s.list().some(session => session.id === msg.session && session.origin === "desktop");
402
+ const id = await s.send(msg.session, msg.clientMsgId, msg.text, msg.attachments, msg.behavior);
403
+ this.send(c, { t: "ack", clientMsgId: msg.clientMsgId, session: id });
404
+ if (desktop && id === msg.session)
405
+ this.attach(c, id, undefined, undefined);
406
+ }
407
+ catch (e) {
408
+ if (e instanceof OutcomeUnknownError) {
409
+ this.send(c, { t: "error", code: "outcome_unknown", message: e.message, clientMsgId: msg.clientMsgId, session: msg.session });
410
+ }
411
+ else {
412
+ this.send(c, { t: "error", code: "send", message: e.message, clientMsgId: msg.clientMsgId, session: msg.session });
413
+ }
414
+ }
415
+ return;
416
+ case "decide":
417
+ try {
418
+ if (msg.decision !== "allow" && msg.decision !== "allow_always" && msg.decision !== "deny")
419
+ throw new Error("invalid decision");
420
+ if (!await s.decide(msg.session, msg.id, msg.decision, msg.message, msg.answers))
421
+ throw new Error("이미 처리됨");
422
+ if (msg.reqId)
423
+ this.send(c, { t: "res", reqId: msg.reqId, ok: true, data: null });
424
+ }
425
+ catch (e) {
426
+ if (msg.reqId)
427
+ this.send(c, { t: "res", reqId: msg.reqId, ok: false, error: e.message });
428
+ else
429
+ this.send(c, { t: "error", code: "permission", message: e.message, session: msg.session });
430
+ }
431
+ return;
432
+ case "set_mode":
433
+ try {
434
+ await s.setMode(msg.sessionId, msg.mode);
435
+ this.send(c, { t: "res", reqId: msg.reqId, ok: true, data: null });
436
+ }
437
+ catch (e) {
438
+ this.send(c, { t: "res", reqId: msg.reqId, ok: false, error: e.message });
439
+ }
440
+ return;
441
+ case "interrupt":
442
+ case "set_model":
443
+ try {
444
+ if (msg.t === "interrupt")
445
+ await s.interrupt(msg.session);
446
+ else
447
+ await s.setModel(msg.session, msg.model, msg.effort);
448
+ if (msg.reqId)
449
+ this.send(c, { t: "res", reqId: msg.reqId, ok: true, data: null });
450
+ }
451
+ catch (e) {
452
+ if (msg.reqId)
453
+ this.send(c, { t: "res", reqId: msg.reqId, ok: false, error: e.message });
454
+ else
455
+ this.send(c, { t: "error", code: msg.t, session: msg.session, message: e.message });
456
+ }
457
+ return;
458
+ case "presence":
459
+ c.visibleAt = msg.visible === true ? this.now() : 0;
460
+ if (msg.visible === true)
461
+ for (const session of c.attached.keys())
462
+ this.markSeen(c, session);
463
+ return;
464
+ case "req":
465
+ try {
466
+ this.send(c, { t: "res", reqId: msg.reqId, ok: true, data: await this.request(msg.op, c) });
467
+ }
468
+ catch (e) {
469
+ this.send(c, { t: "res", reqId: msg.reqId, ok: false, error: e.message });
470
+ }
471
+ return;
472
+ }
473
+ }
474
+ async request(op, c) {
475
+ const s = this.opts.sessions;
476
+ switch (op.op) {
477
+ case "upload": {
478
+ if (!s.has(op.session) || !/^[\w-]{1,100}$/.test(op.uploadId) || typeof op.data !== "string" || op.data.length > BLOB_CHUNK_CHARS || !/^[A-Za-z0-9+/=]*$/.test(op.data))
479
+ throw new Error("Invalid attachment chunk.");
480
+ for (const [key, value] of this.uploads)
481
+ if (this.now() - value.at > 300_000)
482
+ this.uploads.delete(key);
483
+ const key = `${c.clientId}/${op.session}/${op.uploadId}`;
484
+ if (!this.uploads.has(key) && this.uploads.size >= 16)
485
+ throw new Error("Too many unfinished uploads. Try again later.");
486
+ const previous = op.offset === 0 ? "" : this.uploads.get(key)?.data;
487
+ if (previous === undefined || previous.length !== op.offset)
488
+ throw new Error("Upload offset mismatch. Retry the attachment.");
489
+ const data = previous + op.data;
490
+ if (data.length > Math.ceil(ATTACHMENT_CAP / 3) * 4) {
491
+ this.uploads.delete(key);
492
+ throw new Error("Attachment exceeds 8 MB.");
493
+ }
494
+ if (!op.done) {
495
+ this.uploads.set(key, { data, at: this.now() });
496
+ return null;
497
+ }
498
+ this.uploads.delete(key);
499
+ if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data) || Buffer.from(data, "base64").length > ATTACHMENT_CAP)
500
+ throw new Error("Invalid attachment bytes.");
501
+ return { blob: s.log(op.session).image(data) };
502
+ }
503
+ case "models": {
504
+ if (!this.models || Date.now() - this.models.at > 30_000)
505
+ this.models = { at: Date.now(), data: await this.opts.router.models() };
506
+ return this.models.data;
507
+ }
508
+ case "folders": {
509
+ const cli = await this.opts.recentCli().catch(() => []);
510
+ return recentFolders([...s.recentSources(), ...cli]);
511
+ }
512
+ case "browse": return browse(op.path, op.hidden ?? false);
513
+ case "mkdir": return { path: await makeFolder(op.parent, op.name) };
514
+ case "git": return gitInfo(op.path);
515
+ case "commands": return s.commands(op.session);
516
+ case "blob": {
517
+ if (!s.has(op.session))
518
+ throw new Error("no such session");
519
+ const text = s.log(op.session).blob(op.id);
520
+ if (text === null)
521
+ throw new Error("no such blob");
522
+ const offset = Math.max(0, op.offset ?? 0);
523
+ const end = Math.min(text.length, offset + BLOB_CHUNK_CHARS);
524
+ const chunk = { text: text.slice(offset, end), offset, next: end < text.length ? end : null, bytes: Buffer.byteLength(text) };
525
+ return chunk;
526
+ }
527
+ case "sub": {
528
+ const log = s.subLog(op.session, op.sub);
529
+ if (!log)
530
+ throw new Error("no such subagent");
531
+ return log.readAfter(op.afterSeq ?? 0);
532
+ }
533
+ case "expo_push_subscribe":
534
+ if (!this.opts.expoPush)
535
+ throw new Error("native push is off on this host");
536
+ if (!c.clientId)
537
+ throw new Error("no device id");
538
+ this.opts.expoPush.subscribe(c.clientId, op.token, op.lang, op.platform, op.key);
539
+ return { ok: true };
540
+ case "expo_push_unsubscribe":
541
+ if (c.clientId)
542
+ this.opts.expoPush?.unsubscribe(c.clientId);
543
+ return { ok: true };
544
+ case "push_key":
545
+ if (!this.opts.push)
546
+ throw new Error("push is off on this host");
547
+ return { publicKey: this.opts.push.publicKey };
548
+ case "push_subscribe":
549
+ if (!this.opts.push)
550
+ throw new Error("push is off on this host");
551
+ if (!c.clientId)
552
+ throw new Error("no device id");
553
+ this.opts.push.subscribe(c.clientId, op.subscription, op.lang);
554
+ return { ok: true };
555
+ case "push_unsubscribe":
556
+ if (c.clientId)
557
+ this.opts.push?.unsubscribe(c.clientId);
558
+ return { ok: true };
559
+ }
560
+ }
561
+ async checkRouter() {
562
+ const st = await this.opts.router.status();
563
+ const next = { ...this.hostInfo, routerOk: st.ok, ...(st.problem ? { routerProblem: st.problem } : {}) };
564
+ if (!st.problem)
565
+ delete next.routerProblem;
566
+ const ripple = st.ok ? await this.readRipple() : undefined;
567
+ if (ripple)
568
+ next.ripple = ripple;
569
+ else
570
+ delete next.ripple;
571
+ // `at` moves on every read; only a change a phone could see is worth a message.
572
+ const shown = (r) => JSON.stringify(r && { ...r, at: 0 });
573
+ if (next.routerOk === this.hostInfo.routerOk && next.routerProblem === this.hostInfo.routerProblem && shown(next.ripple) === shown(this.hostInfo.ripple))
574
+ return;
575
+ this.setHostInfo(next);
576
+ }
577
+ /**
578
+ * The mod's mode and effort range every check, the provider limits at most once a minute (the
579
+ * router asks each provider's own API for them). A failed limits read keeps the last good body;
580
+ * without mod settings there is nothing to say and `ripple` is left out.
581
+ */
582
+ async readRipple() {
583
+ const { limits, modSettings } = this.opts.router;
584
+ if (!limits || !modSettings)
585
+ return undefined;
586
+ const now = (this.opts.now ?? Date.now)();
587
+ if (now - this.limitsAt >= LIMITS_MS) {
588
+ this.limitsAt = now;
589
+ try {
590
+ this.limitsBody = await limits.call(this.opts.router);
591
+ }
592
+ catch { /* keep the last good body */ }
593
+ }
594
+ try {
595
+ return rippleStatus(this.limitsBody, await modSettings.call(this.opts.router), now);
596
+ }
597
+ catch {
598
+ return undefined;
599
+ }
600
+ }
601
+ /** Sessions says Claude app sessions became unreachable (the Mac's login) or reachable again. */
602
+ setDesktopProblem(problem) {
603
+ if ((problem ?? undefined) === this.hostInfo.desktopProblem)
604
+ return;
605
+ const next = { ...this.hostInfo, ...(problem ? { desktopProblem: problem } : {}) };
606
+ if (!problem)
607
+ delete next.desktopProblem;
608
+ this.setHostInfo(next);
609
+ }
610
+ setHostInfo(next) {
611
+ this.hostInfo = next;
612
+ for (const c of this.clients)
613
+ if (c.authed)
614
+ this.send(c, { t: "host", host: next });
615
+ }
616
+ // ---- the PWA --------------------------------------------------------------------------------
617
+ /** Never throws: a malformed path is a 400, anything else unexpected a logged 500. */
618
+ serve(req, res) {
619
+ try {
620
+ this.serveInner(req, res);
621
+ }
622
+ catch (error) {
623
+ this.opts.log?.(`http serve failed: ${error instanceof Error ? error.message : String(error)}`);
624
+ try {
625
+ res.writeHead(500).end();
626
+ }
627
+ catch { /* the socket is already gone */ }
628
+ }
629
+ }
630
+ serveInner(req, res) {
631
+ const url = new URL(req.url ?? "/", "http://x");
632
+ if (url.pathname.startsWith("/local/")) {
633
+ if (!this.opts.local) {
634
+ res.writeHead(404).end();
635
+ return;
636
+ }
637
+ void this.opts.local.handle(req, res).catch(() => {
638
+ // Never log a local request, body or exception: pairing responses contain credentials.
639
+ if (!res.headersSent)
640
+ res.writeHead(500).end();
641
+ else
642
+ res.destroy();
643
+ });
644
+ return;
645
+ }
646
+ if (url.pathname === "/healthz") {
647
+ res.writeHead(200, { "content-type": "application/json" }).end(`{"ok":true}`);
648
+ return;
649
+ }
650
+ if (req.method !== "GET" && req.method !== "HEAD") {
651
+ res.writeHead(405).end();
652
+ return;
653
+ }
654
+ let rel;
655
+ try {
656
+ rel = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname).replace(/^\/+/, "");
657
+ }
658
+ catch {
659
+ res.writeHead(400).end();
660
+ return;
661
+ }
662
+ if (rel.includes("\0")) {
663
+ res.writeHead(400).end();
664
+ return;
665
+ }
666
+ // Files from outside web/ the client loads: the shared protocol and encryption, and tweetnacl.
667
+ const shared = SHARED[rel];
668
+ const file = shared ? shared(path.dirname(this.opts.webDir)) : path.resolve(this.opts.webDir, rel);
669
+ const ext = path.extname(file);
670
+ if ((!shared && !file.startsWith(this.opts.webDir + path.sep)) || !TYPES[ext]) {
671
+ res.writeHead(404).end();
672
+ return;
673
+ }
674
+ let stat;
675
+ try {
676
+ stat = fs.statSync(file);
677
+ }
678
+ catch (error) {
679
+ if (error.code !== "ENOENT")
680
+ throw error;
681
+ res.writeHead(404).end();
682
+ return;
683
+ }
684
+ let body;
685
+ if (ext === ".ts") {
686
+ const hit = this.stripped.get(file);
687
+ if (hit && hit.mtime === stat.mtimeMs)
688
+ body = hit.body;
689
+ else {
690
+ // The client is TypeScript like the rest of the product; Node strips the types, no build step.
691
+ body = module.stripTypeScriptTypes(fs.readFileSync(file, "utf8"), { mode: "strip" });
692
+ this.stripped.set(file, { mtime: stat.mtimeMs, body });
693
+ }
694
+ }
695
+ else
696
+ body = fs.readFileSync(file);
697
+ res.writeHead(200, {
698
+ "content-type": TYPES[ext],
699
+ "cache-control": "no-cache",
700
+ "x-content-type-options": "nosniff",
701
+ "referrer-policy": "no-referrer",
702
+ ...(ext === ".html" ? { "content-security-policy": CSP } : {}),
703
+ });
704
+ res.end(req.method === "HEAD" ? undefined : body);
705
+ }
706
+ }