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,138 @@
|
|
|
1
|
+
// Host-local HTTP boundary. Relay peers enter HostServer.connected(), never this handler.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import qrcode from "qrcode-generator";
|
|
5
|
+
import { DEFAULT_RELAY, loadConfig, newToken, normalizeRelay, pairingUrl, relayPairingUrl, resolveBind, saveConfig, tokenMatches } from "./config.js";
|
|
6
|
+
export function isLoopback(address) {
|
|
7
|
+
return address === "::1" || /^127(?:\.\d{1,3}){3}$/.test(address ?? "") || /^::ffff:127(?:\.\d{1,3}){3}$/i.test(address ?? "");
|
|
8
|
+
}
|
|
9
|
+
/** Exclusive creation makes concurrent starts agree on one secret; never generated by the router. */
|
|
10
|
+
export function localSecret(dir) {
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
12
|
+
const file = path.join(dir, "local.secret");
|
|
13
|
+
try {
|
|
14
|
+
fs.writeFileSync(file, newToken() + "\n", { flag: "wx", mode: 0o600 });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code !== "EEXIST")
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
fs.chmodSync(file, 0o600);
|
|
21
|
+
const secret = fs.readFileSync(file, "utf8").trim();
|
|
22
|
+
if (secret.length < 32)
|
|
23
|
+
throw new Error("invalid local control secret");
|
|
24
|
+
return secret;
|
|
25
|
+
}
|
|
26
|
+
function json(res, status, body) {
|
|
27
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
28
|
+
res.end(JSON.stringify(body));
|
|
29
|
+
}
|
|
30
|
+
async function settingsBody(req) {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let size = 0;
|
|
33
|
+
for await (const chunk of req) {
|
|
34
|
+
size += Buffer.byteLength(chunk);
|
|
35
|
+
if (size > 4096)
|
|
36
|
+
throw new Error("settings body exceeds 4096 bytes");
|
|
37
|
+
chunks.push(Buffer.from(chunk));
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
40
|
+
}
|
|
41
|
+
export class LocalControl {
|
|
42
|
+
secret;
|
|
43
|
+
running;
|
|
44
|
+
where;
|
|
45
|
+
opts;
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
this.opts = opts;
|
|
48
|
+
this.secret = localSecret(opts.dir);
|
|
49
|
+
this.running = { ...opts.config };
|
|
50
|
+
this.where = resolveBind(this.running.bind);
|
|
51
|
+
}
|
|
52
|
+
/** No caller-supplied error text is logged or reflected: pairing credentials stay out of logs. */
|
|
53
|
+
async handle(req, res) {
|
|
54
|
+
if (!isLoopback(req.socket.remoteAddress) || !tokenMatches(this.secret, req.headers["x-ripple-local"])) {
|
|
55
|
+
json(res, 403, { error: "local control authentication required" });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const route = new URL(req.url ?? "/", "http://local").pathname;
|
|
59
|
+
try {
|
|
60
|
+
if (route === "/local/status" && req.method === "GET") {
|
|
61
|
+
const cfg = this.running;
|
|
62
|
+
const saved = loadConfig(this.opts.dir, false);
|
|
63
|
+
json(res, 200, {
|
|
64
|
+
version: this.opts.version, mode: cfg.relay ? "relay" : this.where.reachableFromPhone ? "direct" : "local-only",
|
|
65
|
+
...(cfg.relay ? { relay: cfg.relay } : {}), bind: cfg.bind, port: cfg.port,
|
|
66
|
+
reachableFromPhone: !!cfg.relay || this.where.reachableFromPhone, pushReady: !!cfg.relay,
|
|
67
|
+
phones: this.opts.phones(), settings: { bind: saved.bind, ...(saved.relay ? { relay: saved.relay } : {}) }, defaultRelay: DEFAULT_RELAY,
|
|
68
|
+
restartRequired: saved.bind !== cfg.bind || saved.relay !== cfg.relay,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (route === "/local/shutdown" && req.method === "POST" && this.opts.shutdown) {
|
|
73
|
+
res.once("finish", () => this.opts.shutdown?.());
|
|
74
|
+
json(res, 200, { ok: true });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (route === "/local/pairing" && req.method === "GET") {
|
|
78
|
+
const cfg = this.running;
|
|
79
|
+
const url = cfg.relay ? relayPairingUrl(cfg.relay, cfg.serverId, this.opts.hostPublicKey, this.opts.config.token)
|
|
80
|
+
: pairingUrl(this.where.advertise, cfg.port, this.opts.config.token);
|
|
81
|
+
const qr = qrcode(0, "M");
|
|
82
|
+
qr.addData(url);
|
|
83
|
+
qr.make();
|
|
84
|
+
json(res, 200, { url, svg: qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true }) });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (route === "/local/settings" && req.method === "POST") {
|
|
88
|
+
let next;
|
|
89
|
+
try {
|
|
90
|
+
next = this.settings(await settingsBody(req));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
json(res, 400, { error: "invalid settings: use an HTTPS Relay URL (or off) and bind local, lan or tailscale; the selected network must be available" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
saveConfig(next, this.opts.dir);
|
|
97
|
+
json(res, 200, { restartRequired: next.bind !== this.running.bind || next.relay !== this.running.relay });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (route === "/local/rotate" && req.method === "POST") {
|
|
101
|
+
const next = loadConfig(this.opts.dir, false);
|
|
102
|
+
next.token = newToken();
|
|
103
|
+
saveConfig(next, this.opts.dir);
|
|
104
|
+
this.opts.config.token = next.token;
|
|
105
|
+
this.opts.rotate(next.token);
|
|
106
|
+
json(res, 200, { ok: true });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
json(res, 404, { error: "unknown local control endpoint or method" });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
json(res, 500, { error: "host local control failed" });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
settings(raw) {
|
|
116
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
117
|
+
throw new Error("expected object");
|
|
118
|
+
const body = raw;
|
|
119
|
+
if (Object.keys(body).some(key => key !== "relay" && key !== "bind"))
|
|
120
|
+
throw new Error("unknown setting");
|
|
121
|
+
const cfg = loadConfig(this.opts.dir, false);
|
|
122
|
+
if (body.relay !== undefined) {
|
|
123
|
+
if (typeof body.relay !== "string")
|
|
124
|
+
throw new Error("invalid relay");
|
|
125
|
+
if (body.relay === "off")
|
|
126
|
+
delete cfg.relay;
|
|
127
|
+
else
|
|
128
|
+
cfg.relay = normalizeRelay(body.relay);
|
|
129
|
+
}
|
|
130
|
+
if (body.bind !== undefined) {
|
|
131
|
+
if (body.bind !== "local" && body.bind !== "lan" && body.bind !== "tailscale")
|
|
132
|
+
throw new Error("invalid bind");
|
|
133
|
+
cfg.bind = body.bind === "local" ? "127.0.0.1" : body.bind;
|
|
134
|
+
resolveBind(cfg.bind);
|
|
135
|
+
}
|
|
136
|
+
return cfg;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ripple-host: runs Claude Code sessions on this machine for the phone.
|
|
3
|
+
// ripple-host [--bind 127.0.0.1|tailscale|lan|0.0.0.0|<ip>] [--port 8797] [--relay <https url>|off] [--new-token]
|
|
4
|
+
// ripple-host install [same options] [--print] launchd agent com.clauderipple.host (--print: only show the plist)
|
|
5
|
+
// ripple-host uninstall stop and remove the agent
|
|
6
|
+
// ripple-host status agent state and health (prints no secret)
|
|
7
|
+
// ripple-host pair print the pairing QR and link (they carry the pairing key)
|
|
8
|
+
// Options are remembered in host.json. The host never stops on its own; day to day it runs as the
|
|
9
|
+
// launchd agent (KeepAlive), which restarts it after a crash; on start it recovers persisted
|
|
10
|
+
// sessions and permission requests (Sessions.recover). With a relay it also keeps one outbound
|
|
11
|
+
// socket to it, so the phone reaches the Mac from anywhere over an https origin (relay.ts).
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { listSessions } from "@anthropic-ai/claude-agent-sdk";
|
|
17
|
+
import { sdkQuery as query } from "./sdk-launcher.js";
|
|
18
|
+
import qrcode from "qrcode-generator";
|
|
19
|
+
import nacl from "tweetnacl";
|
|
20
|
+
import { WebSocket } from "ws";
|
|
21
|
+
import { loadConfig, newToken, normalizeRelay, pairingUrl, relayPairingUrl, remoteHome, resolveBind, saveConfig } from "./config.js";
|
|
22
|
+
import { MacAway } from "./away.js";
|
|
23
|
+
import { toB64u } from "./e2ee.js";
|
|
24
|
+
import { PermissionQueue } from "./permissions.js";
|
|
25
|
+
import { PowerHold } from "./power.js";
|
|
26
|
+
import { Notifier, loadVapid, webPushSender } from "./push.js";
|
|
27
|
+
import { ExpoNotifier, expoSender } from "./expo-push.js";
|
|
28
|
+
import { RelayHost, loadHostKey } from "./relay.js";
|
|
29
|
+
import { RouterClient } from "./router.js";
|
|
30
|
+
import { HostServer } from "./server.js";
|
|
31
|
+
import { LocalControl } from "./local-control.js";
|
|
32
|
+
import { findClaude, specFor } from "./service.js";
|
|
33
|
+
import { installService, uninstallService, serviceStatus, servicePaths, renderService, supervisorName, controlShutdown } from "./host-service.js";
|
|
34
|
+
import { Sessions } from "./sessions.js";
|
|
35
|
+
import { desktopHandoff } from "./handoff.js";
|
|
36
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version;
|
|
38
|
+
const out = (s) => { process.stdout.write(s); };
|
|
39
|
+
function arg(name) {
|
|
40
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
41
|
+
return i > 0 ? process.argv[i + 1] : undefined;
|
|
42
|
+
}
|
|
43
|
+
function qr(text) {
|
|
44
|
+
const q = qrcode(0, "M");
|
|
45
|
+
q.addData(text);
|
|
46
|
+
q.make();
|
|
47
|
+
const n = q.getModuleCount();
|
|
48
|
+
const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
|
|
49
|
+
const lines = [];
|
|
50
|
+
// Two rows per character with half blocks and a quiet zone of 2. Black on an explicit white
|
|
51
|
+
// background, so it scans the same on a light or a dark terminal.
|
|
52
|
+
for (let r = -2; r < n + 2; r += 2) {
|
|
53
|
+
let line = "\x1b[30;47m";
|
|
54
|
+
for (let c = -2; c < n + 2; c++) {
|
|
55
|
+
const top = dark(r, c), bottom = dark(r + 1, c);
|
|
56
|
+
line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
|
|
57
|
+
}
|
|
58
|
+
lines.push(line + "\x1b[0m");
|
|
59
|
+
}
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
/** --bind / --port / --relay / --new-token given on the command line are remembered. */
|
|
63
|
+
function configFromArgs(dir) {
|
|
64
|
+
const cfg = loadConfig(dir);
|
|
65
|
+
const bind = arg("bind");
|
|
66
|
+
const port = arg("port");
|
|
67
|
+
const relay = arg("relay");
|
|
68
|
+
if (bind)
|
|
69
|
+
cfg.bind = bind;
|
|
70
|
+
if (port)
|
|
71
|
+
cfg.port = Number(port);
|
|
72
|
+
if (relay === "off")
|
|
73
|
+
delete cfg.relay;
|
|
74
|
+
else if (relay)
|
|
75
|
+
cfg.relay = normalizeRelay(relay);
|
|
76
|
+
if (process.argv.includes("--new-token"))
|
|
77
|
+
cfg.token = newToken();
|
|
78
|
+
if (bind || port || relay || process.argv.includes("--new-token"))
|
|
79
|
+
saveConfig(cfg, dir);
|
|
80
|
+
return cfg;
|
|
81
|
+
}
|
|
82
|
+
function printPairing(cfg, dir) {
|
|
83
|
+
const where = resolveBind(cfg.bind);
|
|
84
|
+
let url;
|
|
85
|
+
if (cfg.relay) {
|
|
86
|
+
// The fragment (token, server id, host key) never leaves the phone; the relay only sees the page request.
|
|
87
|
+
url = relayPairingUrl(cfg.relay, cfg.serverId, toB64u(loadHostKey(dir, nacl).publicKey), cfg.token);
|
|
88
|
+
out(`\nThrough the relay ${cfg.relay} (works from anywhere; notifications need this https address).\n`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
url = pairingUrl(where.advertise, cfg.port, cfg.token);
|
|
92
|
+
out(where.reachableFromPhone
|
|
93
|
+
? "\nDirect http: works on this network only, and without notifications (they need https: use --relay).\n"
|
|
94
|
+
: "\nOnly this computer can reach it. For the phone, use --relay <url> (anywhere) or --bind lan / tailscale (same network).\n");
|
|
95
|
+
}
|
|
96
|
+
out(`\nScan with the phone's camera (the link carries the pairing key — do not share it):\n\n${qr(url)}\n\n${url}\n\n`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Under launchd both streams are the log file: start every line with an ISO time, so a run of
|
|
100
|
+
* reconnects or errors can be placed next to sleep, network and relay events afterwards. Wraps the
|
|
101
|
+
* stream itself, so lines written by libraries and child processes' relayed output get one too.
|
|
102
|
+
*/
|
|
103
|
+
function stampLines(stream) {
|
|
104
|
+
const write = stream.write.bind(stream);
|
|
105
|
+
let lineStart = true;
|
|
106
|
+
stream.write = ((chunk, ...rest) => {
|
|
107
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
108
|
+
if (!text)
|
|
109
|
+
return write(chunk, ...rest);
|
|
110
|
+
let stamped = "";
|
|
111
|
+
for (const piece of text.split(/(?<=\n)/)) {
|
|
112
|
+
stamped += (lineStart && piece !== "\n" ? `${new Date().toISOString()} ` : "") + piece;
|
|
113
|
+
lineStart = piece.endsWith("\n");
|
|
114
|
+
}
|
|
115
|
+
return write(stamped, ...rest);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async function runHost() {
|
|
119
|
+
if (!process.stdout.isTTY)
|
|
120
|
+
stampLines(process.stdout);
|
|
121
|
+
if (!process.stderr.isTTY)
|
|
122
|
+
stampLines(process.stderr);
|
|
123
|
+
const dir = remoteHome();
|
|
124
|
+
const cfg = configFromArgs(dir);
|
|
125
|
+
const where = resolveBind(cfg.bind);
|
|
126
|
+
const router = new RouterClient(cfg.adminUrl);
|
|
127
|
+
const permissions = PermissionQueue.open(path.join(dir, "permissions.json"));
|
|
128
|
+
let server = null;
|
|
129
|
+
const claudePath = findClaude(cfg.claudePath);
|
|
130
|
+
const power = new PowerHold();
|
|
131
|
+
const log = (line) => { process.stderr.write(`${line}\n`); };
|
|
132
|
+
// A VAPID subject must be an https: or mailto: URL; the relay is this host's one real https origin.
|
|
133
|
+
const vapid = loadVapid(dir, cfg.relay?.startsWith("https:") ? cfg.relay : "mailto:ripple-host@clauderipple.invalid");
|
|
134
|
+
let sessions = null;
|
|
135
|
+
const notifier = new Notifier({
|
|
136
|
+
dir, send: webPushSender(vapid), log,
|
|
137
|
+
seenRecently: (id) => server?.sessionSeenRecently(id) ?? false,
|
|
138
|
+
summary: (id) => sessions?.summaryOf(id),
|
|
139
|
+
});
|
|
140
|
+
const expoNotifier = new ExpoNotifier({ dir, send: expoSender(), log,
|
|
141
|
+
seenRecently: (id) => server?.sessionSeenRecently(id) ?? false,
|
|
142
|
+
context: (id, event) => sessions?.pushContext(id, event) ?? {},
|
|
143
|
+
});
|
|
144
|
+
// Mac sessions the host is not tailing push only while the Mac is away (screen locked or idle).
|
|
145
|
+
const away = new MacAway({ log });
|
|
146
|
+
sessions = new Sessions({
|
|
147
|
+
dir, router, permissions,
|
|
148
|
+
desktopProjects: path.join(os.homedir(), ".claude", "projects"),
|
|
149
|
+
...(process.platform === "darwin" ? { desktopSidebar: {} } : {}),
|
|
150
|
+
queryFn: query,
|
|
151
|
+
...(claudePath ? { claudePath, ...(process.platform === "darwin" ? { handoff: desktopHandoff(claudePath) } : {}) } : {}),
|
|
152
|
+
log,
|
|
153
|
+
onEvent: (id, event) => { server?.onEvent(id); void notifier.onEvent(id, event); void expoNotifier.onEvent(id, event); },
|
|
154
|
+
away: () => away.away(),
|
|
155
|
+
onAlert: (id, body) => { void notifier.onEvent(id, body); void expoNotifier.onEvent(id, body); },
|
|
156
|
+
onSessions: () => { server?.onSessions(); if (sessions)
|
|
157
|
+
power.update(sessions.busy()); },
|
|
158
|
+
onDesktopProblem: (problem) => server?.setDesktopProblem(problem),
|
|
159
|
+
});
|
|
160
|
+
sessions.recover();
|
|
161
|
+
const live = sessions;
|
|
162
|
+
const host = new HostServer({
|
|
163
|
+
listen: where.listen, port: cfg.port, token: cfg.token, webDir: path.resolve(here, "..", "web"), sessions: live, router,
|
|
164
|
+
hostName: os.hostname().replace(/\.local$/, ""), version: VERSION,
|
|
165
|
+
recentCli: async () => (await listSessions({ limit: 300, includeProgrammatic: false })).flatMap((s) => (s.cwd ? [{ path: s.cwd, lastUsed: s.lastModified }] : [])),
|
|
166
|
+
log,
|
|
167
|
+
push: { publicKey: vapid.publicKey, subscribe: (id, sub, lang) => notifier.subscribe(id, sub, lang), unsubscribe: (id) => notifier.unsubscribe(id) },
|
|
168
|
+
expoPush: expoNotifier,
|
|
169
|
+
pushDevices: () => [...new Set([...notifier.devices(), ...expoNotifier.devices()])],
|
|
170
|
+
clearPush: () => {
|
|
171
|
+
for (const id of notifier.devices())
|
|
172
|
+
notifier.unsubscribe(id);
|
|
173
|
+
for (const id of expoNotifier.devices())
|
|
174
|
+
expoNotifier.unsubscribe(id);
|
|
175
|
+
},
|
|
176
|
+
local: new LocalControl({
|
|
177
|
+
dir, config: cfg, version: VERSION, hostPublicKey: toB64u(loadHostKey(dir, nacl).publicKey),
|
|
178
|
+
phones: () => server?.phones() ?? [],
|
|
179
|
+
rotate: token => { server?.rotateToken(token); },
|
|
180
|
+
shutdown: () => stop(),
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
server = host;
|
|
184
|
+
await host.listen();
|
|
185
|
+
power.update(live.busy());
|
|
186
|
+
const relay = cfg.relay
|
|
187
|
+
? new RelayHost({
|
|
188
|
+
relay: cfg.relay, serverId: cfg.serverId, secret: cfg.relaySecret, hostKey: loadHostKey(dir, nacl), nacl, log,
|
|
189
|
+
makeSocket: (url, headers) => new WebSocket(url, { headers, maxPayload: 2 * 1024 * 1024 }),
|
|
190
|
+
onPeer: (peer) => host.connected(peer),
|
|
191
|
+
})
|
|
192
|
+
: null;
|
|
193
|
+
relay?.start();
|
|
194
|
+
out(`\nRipple Remote ${VERSION} — listening on ${where.listen}:${cfg.port}${cfg.relay ? `, relay ${cfg.relay}` : ""}\n`);
|
|
195
|
+
if (claudePath)
|
|
196
|
+
out(`Claude Code: ${claudePath}\n`);
|
|
197
|
+
// Under launchd stdout is a log file; the pairing link carries the token and must not land there.
|
|
198
|
+
if (process.stdout.isTTY)
|
|
199
|
+
printPairing(cfg, dir);
|
|
200
|
+
else
|
|
201
|
+
out("Pairing QR: run `ripple-host pair` (npm run remote -- pair) in a terminal.\n");
|
|
202
|
+
const stop = () => {
|
|
203
|
+
power.release();
|
|
204
|
+
relay?.stop();
|
|
205
|
+
live.shutdown();
|
|
206
|
+
void host.close().then(() => process.exit(0));
|
|
207
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
208
|
+
};
|
|
209
|
+
process.on("SIGTERM", stop);
|
|
210
|
+
process.on("SIGINT", stop);
|
|
211
|
+
}
|
|
212
|
+
async function status() {
|
|
213
|
+
const cfg = loadConfig(remoteHome());
|
|
214
|
+
const st = serviceStatus();
|
|
215
|
+
const p = servicePaths();
|
|
216
|
+
out(`${supervisorName()}: ${st.installed ? p.definition : "not installed"}${st.loaded ? `, loaded (state ${st.state ?? "?"}${st.pid ? `, pid ${st.pid}` : ""}${st.lastExit ? `, last exit ${st.lastExit}` : ""})` : ", not loaded"}\n`);
|
|
217
|
+
const listen = resolveBind(cfg.bind).listen;
|
|
218
|
+
const probe = listen === "0.0.0.0" || listen === "::" ? "127.0.0.1" : listen;
|
|
219
|
+
let health = "no answer";
|
|
220
|
+
try {
|
|
221
|
+
const r = await fetch(`http://${probe.includes(":") ? `[${probe}]` : probe}:${cfg.port}/healthz`, { signal: AbortSignal.timeout(2000) });
|
|
222
|
+
health = r.ok ? "ok" : `HTTP ${r.status}`;
|
|
223
|
+
}
|
|
224
|
+
catch { /* no answer */ }
|
|
225
|
+
out(`host: ${probe}:${cfg.port} — ${health} (bind ${cfg.bind})\n`);
|
|
226
|
+
out(`relay: ${cfg.relay ?? "off"}\nlogs: ${p.log}\n`);
|
|
227
|
+
}
|
|
228
|
+
async function installCmd() {
|
|
229
|
+
const dir = remoteHome();
|
|
230
|
+
// Read the running host's old port before command-line options persist a replacement.
|
|
231
|
+
if (process.platform === "win32" && !process.argv.includes("--print"))
|
|
232
|
+
await controlShutdown(dir);
|
|
233
|
+
const cfg = process.argv.includes("--print") ? loadConfig(dir, false) : configFromArgs(dir);
|
|
234
|
+
const spec = specFor({ script: fileURLToPath(import.meta.url), claudePath: findClaude(cfg.claudePath), ...(process.env.RIPPLE_REMOTE_HOME ? { remoteHome: process.env.RIPPLE_REMOTE_HOME } : {}) });
|
|
235
|
+
if (process.argv.includes("--print")) {
|
|
236
|
+
out(renderService(spec));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
for (const line of await installService(spec))
|
|
240
|
+
out(`${line}\n`);
|
|
241
|
+
out(`Pair the phone: clauderipple remote pair\n`);
|
|
242
|
+
}
|
|
243
|
+
async function main() {
|
|
244
|
+
const sub = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : null;
|
|
245
|
+
switch (sub) {
|
|
246
|
+
case null: return runHost();
|
|
247
|
+
case "install": return installCmd();
|
|
248
|
+
case "uninstall":
|
|
249
|
+
for (const line of await uninstallService())
|
|
250
|
+
out(`${line}\n`);
|
|
251
|
+
return;
|
|
252
|
+
case "status": return status();
|
|
253
|
+
case "pair": {
|
|
254
|
+
const dir = remoteHome();
|
|
255
|
+
printPairing(configFromArgs(dir), dir);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
default: throw new Error(`unknown command "${sub}" (install, uninstall, status, pair)`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
main().catch((e) => {
|
|
262
|
+
process.stderr.write(`ripple-host: ${e.message}\n`);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
3
|
+
const WEEK = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
export class PermissionQueue {
|
|
5
|
+
expiredOnOpen;
|
|
6
|
+
file;
|
|
7
|
+
now;
|
|
8
|
+
records = new Map();
|
|
9
|
+
waiters = new Map();
|
|
10
|
+
constructor(file, now, expired) {
|
|
11
|
+
this.file = file;
|
|
12
|
+
this.now = now;
|
|
13
|
+
this.expiredOnOpen = structuredClone(expired);
|
|
14
|
+
}
|
|
15
|
+
static open(file, now = Date.now) {
|
|
16
|
+
// A JSON array is the complete snapshot; malformed state is never an empty queue.
|
|
17
|
+
const saved = readJson(file) ?? [];
|
|
18
|
+
if (!Array.isArray(saved))
|
|
19
|
+
throw new Error("Invalid permission queue");
|
|
20
|
+
const at = now();
|
|
21
|
+
const expired = [];
|
|
22
|
+
const records = new Map();
|
|
23
|
+
for (const record of saved) {
|
|
24
|
+
if (!validRecord(record) || records.has(record.id))
|
|
25
|
+
throw new Error("Invalid permission record");
|
|
26
|
+
if (record.state === "pending") {
|
|
27
|
+
record.state = "expired";
|
|
28
|
+
record.reason = "host_restarted";
|
|
29
|
+
record.decidedAt = at;
|
|
30
|
+
expired.push(record);
|
|
31
|
+
}
|
|
32
|
+
records.set(record.id, record);
|
|
33
|
+
}
|
|
34
|
+
const queue = new PermissionQueue(file, now, expired);
|
|
35
|
+
queue.persist(records, at);
|
|
36
|
+
return queue;
|
|
37
|
+
}
|
|
38
|
+
persist(records, at) {
|
|
39
|
+
const kept = new Map([...records].filter(([, record]) => record.state === "pending" || record.decidedAt >= at - WEEK));
|
|
40
|
+
writeFileAtomic(this.file, JSON.stringify([...kept.values()]));
|
|
41
|
+
// Memory and promises must not get ahead of durable state when a write fails.
|
|
42
|
+
this.records = kept;
|
|
43
|
+
}
|
|
44
|
+
request(r, signal) {
|
|
45
|
+
const at = this.now();
|
|
46
|
+
const record = { ...structuredClone(r), id: randomUUID(), createdAt: at, state: "pending" };
|
|
47
|
+
const records = new Map(this.records);
|
|
48
|
+
records.set(record.id, record);
|
|
49
|
+
this.persist(records, at);
|
|
50
|
+
const decision = new Promise(resolve => {
|
|
51
|
+
const abort = () => { this.expire(record.id, "aborted"); };
|
|
52
|
+
this.waiters.set(record.id, { resolve, cleanup: () => signal?.removeEventListener("abort", abort) });
|
|
53
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
54
|
+
if (signal?.aborted)
|
|
55
|
+
abort();
|
|
56
|
+
});
|
|
57
|
+
return { record: structuredClone(this.records.get(record.id)), decision };
|
|
58
|
+
}
|
|
59
|
+
finish(ids, decision, reason, answers) {
|
|
60
|
+
const at = this.now();
|
|
61
|
+
const records = new Map(this.records);
|
|
62
|
+
const changed = [];
|
|
63
|
+
for (const id of ids) {
|
|
64
|
+
const record = records.get(id);
|
|
65
|
+
if (record?.state !== "pending")
|
|
66
|
+
continue;
|
|
67
|
+
const next = { ...record, state: decision, decidedAt: at };
|
|
68
|
+
if (reason !== undefined)
|
|
69
|
+
next.reason = reason;
|
|
70
|
+
records.set(id, next);
|
|
71
|
+
changed.push(next);
|
|
72
|
+
}
|
|
73
|
+
if (!changed.length)
|
|
74
|
+
return [];
|
|
75
|
+
this.persist(records, at);
|
|
76
|
+
for (const record of changed) {
|
|
77
|
+
const waiter = this.waiters.get(record.id);
|
|
78
|
+
if (!waiter)
|
|
79
|
+
continue;
|
|
80
|
+
this.waiters.delete(record.id);
|
|
81
|
+
waiter.cleanup();
|
|
82
|
+
const answer = { decision };
|
|
83
|
+
if (reason !== undefined)
|
|
84
|
+
answer.reason = reason;
|
|
85
|
+
if (answers !== undefined)
|
|
86
|
+
answer.answers = answers;
|
|
87
|
+
waiter.resolve(answer);
|
|
88
|
+
}
|
|
89
|
+
return structuredClone(changed);
|
|
90
|
+
}
|
|
91
|
+
decide(id, decision, reason, answers) {
|
|
92
|
+
// The type says the union, but a phone frame is runtime data: anything else would corrupt the
|
|
93
|
+
// record (a forged "expired" would close a card without answering it).
|
|
94
|
+
if (decision !== "allow" && decision !== "allow_always" && decision !== "deny")
|
|
95
|
+
throw new Error("invalid decision");
|
|
96
|
+
return this.finish([id], decision, reason, answers)[0] ?? null;
|
|
97
|
+
}
|
|
98
|
+
denySession(session, reason) {
|
|
99
|
+
return this.finish(this.pending(session).map(record => record.id), "deny", reason);
|
|
100
|
+
}
|
|
101
|
+
expire(id, reason) {
|
|
102
|
+
return this.finish([id], "expired", reason)[0] ?? null;
|
|
103
|
+
}
|
|
104
|
+
expireSession(session, reason) {
|
|
105
|
+
return this.finish(this.pending(session).map(record => record.id), "expired", reason);
|
|
106
|
+
}
|
|
107
|
+
pending(session) {
|
|
108
|
+
return structuredClone([...this.records.values()]
|
|
109
|
+
.filter(record => record.state === "pending" && (session === undefined || record.session === session))
|
|
110
|
+
.sort((a, b) => a.createdAt - b.createdAt));
|
|
111
|
+
}
|
|
112
|
+
get(id) {
|
|
113
|
+
const record = this.records.get(id);
|
|
114
|
+
return record === undefined ? undefined : structuredClone(record);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function validRecord(record) {
|
|
118
|
+
return record !== null && typeof record === "object"
|
|
119
|
+
&& [record.id, record.session, record.tool, record.title, record.summary].every(value => typeof value === "string")
|
|
120
|
+
&& Number.isFinite(record.createdAt)
|
|
121
|
+
&& ["pending", "allow", "allow_always", "deny", "expired"].includes(record.state)
|
|
122
|
+
&& (record.state === "pending" || Number.isFinite(record.decidedAt))
|
|
123
|
+
&& (record.reason === undefined || typeof record.reason === "string");
|
|
124
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Keeps the Mac awake while a turn runs, and only then (spec: "a power assertion only while a
|
|
2
|
+
// turn runs"). One `caffeinate -i -w <host pid>` child is held while any session is starting,
|
|
3
|
+
// running or waiting on a permission — a turn that waits for the phone must not let the Mac
|
|
4
|
+
// sleep under it, or the answer has nowhere to go. `-i` blocks idle sleep only (lid close and
|
|
5
|
+
// the user's own sleep stay theirs); `-w` ends the assertion by itself if the host dies.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
export const nodeSpawner = (cmd, args) => {
|
|
8
|
+
const child = spawn(cmd, args, { stdio: "ignore", windowsHide: true });
|
|
9
|
+
// A missing binary must not crash the host; the hold just does not happen.
|
|
10
|
+
child.on("error", () => undefined);
|
|
11
|
+
return {
|
|
12
|
+
kill: () => { if (child.exitCode === null && child.signalCode === null)
|
|
13
|
+
child.kill("SIGTERM"); },
|
|
14
|
+
onExit: (fn) => { child.once("exit", fn); child.once("error", fn); },
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export function keepAwakeCommand(platform, pid) {
|
|
18
|
+
if (platform === "darwin")
|
|
19
|
+
return { cmd: "/usr/bin/caffeinate", args: ["-i", "-w", String(pid)] };
|
|
20
|
+
if (platform === "win32")
|
|
21
|
+
return { cmd: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command",
|
|
22
|
+
`Add-Type -Namespace Ripple -Name Power -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint flags);'; $hostProcess = Get-Process -Id ${pid} -ErrorAction Stop; try { [Ripple.Power]::SetThreadExecutionState([uint32]2147483649) | Out-Null; while (-not $hostProcess.HasExited) { Start-Sleep -Seconds 2 } } finally { [Ripple.Power]::SetThreadExecutionState([uint32]2147483648) | Out-Null }`] };
|
|
23
|
+
return { cmd: "systemd-inhibit", args: ["--what=idle", "--mode=block", "--who=Ripple Remote", "--why=Remote session active", process.execPath, "-e", `const t=setInterval(()=>{try{process.kill(${pid},0)}catch{clearInterval(t)}},2000)`] };
|
|
24
|
+
}
|
|
25
|
+
export class PowerHold {
|
|
26
|
+
spawner;
|
|
27
|
+
pid;
|
|
28
|
+
child = null;
|
|
29
|
+
wanted = false;
|
|
30
|
+
respawn = null;
|
|
31
|
+
retryMs;
|
|
32
|
+
platform;
|
|
33
|
+
constructor(opts = {}) {
|
|
34
|
+
this.platform = opts.platform ?? process.platform;
|
|
35
|
+
this.spawner = opts.spawner ?? nodeSpawner;
|
|
36
|
+
this.pid = opts.pid ?? process.pid;
|
|
37
|
+
this.retryMs = opts.retryMs ?? 5_000;
|
|
38
|
+
}
|
|
39
|
+
get holding() { return this.child !== null; }
|
|
40
|
+
/** Call whenever session states change; spawns once on the first busy session, kills when none is. */
|
|
41
|
+
update(busy) {
|
|
42
|
+
this.wanted = busy;
|
|
43
|
+
if (busy && !this.child)
|
|
44
|
+
this.hold();
|
|
45
|
+
if (!busy)
|
|
46
|
+
this.release();
|
|
47
|
+
}
|
|
48
|
+
release() {
|
|
49
|
+
this.wanted = false;
|
|
50
|
+
if (this.respawn) {
|
|
51
|
+
clearTimeout(this.respawn);
|
|
52
|
+
this.respawn = null;
|
|
53
|
+
}
|
|
54
|
+
const c = this.child;
|
|
55
|
+
this.child = null;
|
|
56
|
+
c?.kill();
|
|
57
|
+
}
|
|
58
|
+
hold() {
|
|
59
|
+
if (this.respawn) {
|
|
60
|
+
clearTimeout(this.respawn);
|
|
61
|
+
this.respawn = null;
|
|
62
|
+
}
|
|
63
|
+
const command = keepAwakeCommand(this.platform, this.pid);
|
|
64
|
+
const c = this.spawner(command.cmd, command.args);
|
|
65
|
+
this.child = c;
|
|
66
|
+
c.onExit(() => {
|
|
67
|
+
if (this.child !== c)
|
|
68
|
+
return;
|
|
69
|
+
// Killed by someone else while a turn still runs: take it again, but not in a tight loop.
|
|
70
|
+
this.child = null;
|
|
71
|
+
if (this.wanted && !this.respawn)
|
|
72
|
+
this.respawn = setTimeout(() => { this.respawn = null; if (this.wanted && !this.child)
|
|
73
|
+
this.hold(); }, this.retryMs);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// One OS process snapshot for transcript liveness. Desktop registry identity remains macOS-only.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { windowsPowerShellEnv } from "../../cli/src/windows-powershell.js";
|
|
4
|
+
export function listProcesses(platform = process.platform, exec = (file, args) => execFileSync(file, args, {
|
|
5
|
+
encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 5000, windowsHide: true, env: windowsPowerShellEnv(), stdio: ["ignore", "pipe", "pipe"],
|
|
6
|
+
})) {
|
|
7
|
+
if (platform === "win32") {
|
|
8
|
+
const raw = JSON.parse(exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = [Text.UTF8Encoding]::new(); ConvertTo-Json -Compress -InputObject @(Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine,@{Name='CreationDate';Expression={if ($_.CreationDate) {$_.CreationDate.ToUniversalTime().ToString('o')}}})"]));
|
|
9
|
+
if (!Array.isArray(raw))
|
|
10
|
+
throw new Error("invalid Windows process snapshot");
|
|
11
|
+
return raw.flatMap((value) => {
|
|
12
|
+
if (!value || typeof value !== "object")
|
|
13
|
+
return [];
|
|
14
|
+
const p = value;
|
|
15
|
+
return typeof p.ProcessId === "number" && typeof p.CommandLine === "string"
|
|
16
|
+
? [{ pid: p.ProcessId, command: p.CommandLine, ...(typeof p.CreationDate === "string" ? { started: p.CreationDate } : {}) }] : [];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return exec("ps", ["-axo", "pid,command"]).split(/\r?\n/).flatMap(line => {
|
|
20
|
+
const m = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
21
|
+
return m ? [{ pid: Number(m[1]), command: m[2] }] : [];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function processCommands() { return listProcesses().map(p => `${p.pid} ${p.command}`).join("\n"); }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Wire protocol between ripple-host and the phone client. Types and constants only, no
|
|
2
|
+
// imports: the host also serves this file to the browser (as /src/protocol.ts), so both ends
|
|
3
|
+
// read the same numbers.
|
|
4
|
+
//
|
|
5
|
+
// Every session has an append-only event log. An event's `seq` counts from 1 inside one
|
|
6
|
+
// `epoch`; a new epoch means the log was replaced (never happens in normal operation) and a
|
|
7
|
+
// client must drop what it holds for that session. A client that reconnects asks for
|
|
8
|
+
// everything after the last `seq` it has, so a re-attach is cheap and idempotent.
|
|
9
|
+
/** Largest WebSocket frame either side sends, in bytes of UTF-8 JSON (Paseo #2610: 8 MB killed the socket). */
|
|
10
|
+
export const FRAME_CAP = 256 * 1024;
|
|
11
|
+
/** A string field longer than this is moved out of the event into a blob fetched on demand. */
|
|
12
|
+
export const INLINE_CAP = 32 * 1024;
|
|
13
|
+
/** Client pings this often; either side calls the link stale after STALE_MS of silence. */
|
|
14
|
+
export const PING_MS = 10_000;
|
|
15
|
+
export const STALE_MS = 30_000;
|
|
16
|
+
/** Reconnect backoff is min(BACKOFF_CAP_MS, attempt × 1 s) and never stops. */
|
|
17
|
+
export const BACKOFF_CAP_MS = 30_000;
|
|
18
|
+
/** A socket that opens but is not ready (relay hello, host ready) within this is abandoned. */
|
|
19
|
+
export const READY_MS = 8_000;
|
|
20
|
+
/** Largest frame the relay forwards: an encrypted FRAME_CAP frame (16-byte tag) with room to spare. */
|
|
21
|
+
export const RELAY_FRAME_CAP = FRAME_CAP + 1024;
|
|
22
|
+
/** Close codes the relay uses. `stale`: the host's control socket sent no ping for HOST_SILENT_MS. */
|
|
23
|
+
export const RELAY_CLOSE = { badRequest: 4400, badSecret: 4401, hostOffline: 4404, stale: 4408, replaced: 4409, peerGone: 4410, tooBig: 1009 };
|
|
24
|
+
/**
|
|
25
|
+
* The relay routes phones only to the host's newest control socket, and only while that socket
|
|
26
|
+
* pinged within this long (PING_MS cadence; a missed ping or two is not a verdict).
|
|
27
|
+
*/
|
|
28
|
+
export const HOST_SILENT_MS = STALE_MS + PING_MS;
|
|
29
|
+
export const PERMISSION_MODES = ["plan", "default", "acceptEdits", "auto", "bypassPermissions"];
|
|
30
|
+
export const ATTACHMENT_CAP = 8 * 1024 * 1024;
|
|
31
|
+
export const ATTACHMENT_COUNT = 8;
|
|
32
|
+
export function defaultEffort(efforts) { return efforts.includes("high") ? "high" : efforts[0] ?? null; }
|