omp-mobile 0.4.3 → 0.5.1

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/src/relay.mjs ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * The daemon as a DSH Remote Host: it registers with a relay server (by
3
+ * default the public https://dsh.r2049.cn of ds-harness-remote) through
4
+ * account-less device pairing, and serves its own HTTP + WebSocket API to
5
+ * paired phones through that relay, end-to-end encrypted (Noise IK, pinned
6
+ * keys). The relay only forwards ciphertext; nothing of DSH runs here.
7
+ *
8
+ * Over the encrypted channel the phone speaks a small tunnel (`createTunnelRouter`):
9
+ * rpc `omp.http.open` {id, method, path, headers?, body? (text)} -> {status, headers}
10
+ * rpc `omp.ws.open` {id, path} -> {}
11
+ * rpc `omp.ws.send` {id, text} -> {}
12
+ * rpc `omp.close` {id} -> {}
13
+ * event `omp.data` {id, text? (a text body or WebSocket message part), data? (base64 of a binary body),
14
+ * more? (the WebSocket message continues), done?, error?}
15
+ * Requests go to the daemon's loopback tunnel listener with its token added,
16
+ * so a paired phone gets exactly what a phone on the tailnet gets.
17
+ */
18
+ import { request as httpRequest } from "node:http";
19
+ import { StringDecoder } from "node:string_decoder";
20
+ import { WebSocket } from "ws";
21
+ import {
22
+ ConnectionController,
23
+ HostPairing,
24
+ HostServerApi,
25
+ HostServerConnection,
26
+ IdentityStore,
27
+ SafeLogger,
28
+ ServerCredentialStore,
29
+ createEvent,
30
+ createRpcError,
31
+ createRpcResponse,
32
+ } from "../vendor/dsh-remote-host.mjs";
33
+
34
+ /** Marks a request that came through the relay tunnel (set by the tunnel, never by the phone). */
35
+ export const RELAY_HEADER = "x-omp-mobile-relay";
36
+ /** Characters per text part and bytes per base64 part (a multiple of 3): well under the 4 MiB secure message cap. */
37
+ const TEXT_CHUNK_CHARS = 1024 * 1024;
38
+ const BINARY_PART_BYTES = 3 * 256 * 1024;
39
+ /** A binary answer (an image) is sent whole: at most this large. */
40
+ const MAX_BINARY_BODY = 32 * 1024 * 1024;
41
+ const MAX_HANDLES = 64;
42
+ const MAX_REQUEST_BODY = 16 * 1024 * 1024;
43
+ const ID = /^[A-Za-z0-9_-]{1,64}$/;
44
+ const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
45
+ /** Headers the phone may pass on; the token and the relay mark are the tunnel's own. */
46
+ const PASSED_HEADERS = new Set(["accept", "content-type", "x-omp-mobile-probe"]);
47
+
48
+ class TunnelError extends Error {
49
+ constructor(code, message) {
50
+ super(message);
51
+ this.code = code;
52
+ }
53
+ }
54
+
55
+ /** A daemon path the phone may ask for: absolute, no scheme or host, and never the local `/relay` control. */
56
+ function tunnelPath(value) {
57
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.length > 8192 || /[\s\\]/.test(value)) {
58
+ throw new TunnelError("INVALID_MESSAGE", "Invalid path.");
59
+ }
60
+ const url = new URL(value, "http://tunnel");
61
+ if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) throw new TunnelError("METHOD_NOT_ALLOWED", "Relay control is local only.");
62
+ return url;
63
+ }
64
+
65
+ /**
66
+ * One phone connection's tunnel to the daemon at 127.0.0.1:`port`.
67
+ * @param {{port: number, token: string}} target
68
+ * @param {(message: object) => Promise<void>} send Encrypted delivery to that phone.
69
+ */
70
+ export function createTunnelRouter(target, send) {
71
+ /** @type {Map<string, {close(): void, socket?: WebSocket}>} */
72
+ const handles = new Map();
73
+ let out = Promise.resolve();
74
+ /** Events go out in order; a failed send (the phone left) ends the handle. */
75
+ const emit = (data) => {
76
+ out = out.then(() => send(createEvent("omp.data", data))).catch(() => handles.get(data.id)?.close());
77
+ return out;
78
+ };
79
+ const claim = (id) => {
80
+ if (typeof id !== "string" || !ID.test(id)) throw new TunnelError("INVALID_MESSAGE", "Invalid handle id.");
81
+ if (handles.has(id)) throw new TunnelError("REQUEST_CONFLICT", "Handle already open.");
82
+ if (handles.size >= MAX_HANDLES) throw new TunnelError("RATE_LIMITED", "Too many open requests.");
83
+ };
84
+
85
+ const openHttp = (params) => {
86
+ claim(params.id);
87
+ const { id } = params;
88
+ const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
89
+ if (!METHODS.has(method)) throw new TunnelError("INVALID_MESSAGE", "Invalid method.");
90
+ const url = tunnelPath(params.path);
91
+ const headers = { authorization: `Bearer ${target.token}`, [RELAY_HEADER]: "1" };
92
+ for (const [key, value] of Object.entries(params.headers ?? {})) {
93
+ if (PASSED_HEADERS.has(key.toLowerCase()) && typeof value === "string") headers[key.toLowerCase()] = value;
94
+ }
95
+ const body = typeof params.body === "string" ? Buffer.from(params.body, "utf8") : undefined;
96
+ if (body !== undefined && body.length > MAX_REQUEST_BODY) throw new TunnelError("FRAME_TOO_LARGE", "Request body too large.");
97
+ if (body !== undefined) headers["content-length"] = String(body.length);
98
+ return new Promise((resolveOpen, rejectOpen) => {
99
+ let settled = false;
100
+ const request = httpRequest({ host: "127.0.0.1", port: target.port, method, path: `${url.pathname}${url.search}`, headers, agent: false }, (response) => {
101
+ settled = true;
102
+ const type = String(response.headers["content-type"] ?? "");
103
+ resolveOpen({ status: response.statusCode ?? 502, headers: { "content-type": type } });
104
+ let ended = false;
105
+ // Text (JSON, the `/events` stream) goes as text, whole characters only; anything
106
+ // else (an image from `/blobs`) is collected and sent as base64 in 3-byte-aligned parts,
107
+ // so the phone can join the parts without decoding them.
108
+ const text = /^(text\/|application\/json)/.test(type) ? new StringDecoder("utf8") : undefined;
109
+ const bytes = [];
110
+ let total = 0;
111
+ response.on("data", (chunk) => {
112
+ if (text === undefined) {
113
+ total += chunk.length;
114
+ if (total > MAX_BINARY_BODY) request.destroy();
115
+ else bytes.push(chunk);
116
+ return;
117
+ }
118
+ const part = text.write(chunk);
119
+ if (part === "") return;
120
+ response.pause();
121
+ let sent = Promise.resolve();
122
+ for (let at = 0; at < part.length; at += TEXT_CHUNK_CHARS) sent = emit({ id, text: part.slice(at, at + TEXT_CHUNK_CHARS) });
123
+ void sent.then(() => response.resume());
124
+ });
125
+ response.on("end", () => {
126
+ ended = true;
127
+ handles.delete(id);
128
+ const rest = text?.end() ?? "";
129
+ if (rest !== "") void emit({ id, text: rest });
130
+ const all = Buffer.concat(bytes);
131
+ for (let at = 0; at < all.length; at += BINARY_PART_BYTES) void emit({ id, data: all.subarray(at, at + BINARY_PART_BYTES).toString("base64") });
132
+ void emit({ id, done: true });
133
+ });
134
+ response.on("close", () => {
135
+ if (ended || !handles.has(id)) return;
136
+ handles.delete(id);
137
+ void emit({ id, done: true, error: "closed" });
138
+ });
139
+ });
140
+ handles.set(id, { close: () => { handles.delete(id); request.destroy(); } });
141
+ request.on("error", () => {
142
+ handles.delete(id);
143
+ if (!settled) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", "The daemon did not answer."));
144
+ });
145
+ request.end(body);
146
+ });
147
+ };
148
+
149
+ const openWs = (params) => {
150
+ claim(params.id);
151
+ const { id } = params;
152
+ const url = tunnelPath(params.path);
153
+ url.searchParams.set("token", target.token);
154
+ return new Promise((resolveOpen, rejectOpen) => {
155
+ const socket = new WebSocket(`ws://127.0.0.1:${target.port}${url.pathname}${url.search}`, {
156
+ headers: { [RELAY_HEADER]: "1" },
157
+ perMessageDeflate: false,
158
+ maxPayload: 256 * 1024 * 1024,
159
+ });
160
+ let opened = false;
161
+ handles.set(id, { socket, close: () => { handles.delete(id); socket.terminate(); } });
162
+ socket.on("open", () => {
163
+ opened = true;
164
+ resolveOpen({});
165
+ });
166
+ socket.on("message", (data, binary) => {
167
+ if (binary) return;
168
+ const text = data.toString("utf8");
169
+ socket.pause();
170
+ let sent = Promise.resolve();
171
+ const parts = Math.max(1, Math.ceil(text.length / TEXT_CHUNK_CHARS));
172
+ for (let index = 0; index < parts; index += 1) {
173
+ sent = emit({ id, text: text.slice(index * TEXT_CHUNK_CHARS, (index + 1) * TEXT_CHUNK_CHARS), more: index < parts - 1 });
174
+ }
175
+ void sent.then(() => socket.resume());
176
+ });
177
+ socket.on("close", (code) => {
178
+ const known = handles.delete(id);
179
+ if (!opened) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", `The daemon refused the socket (${code}).`));
180
+ else if (known) void emit({ id, done: true, code });
181
+ });
182
+ socket.on("error", () => undefined);
183
+ });
184
+ };
185
+
186
+ const call = async (method, params) => {
187
+ if (params === null || typeof params !== "object") throw new TunnelError("INVALID_MESSAGE", "Missing parameters.");
188
+ switch (method) {
189
+ case "omp.http.open":
190
+ return openHttp(params);
191
+ case "omp.ws.open":
192
+ return openWs(params);
193
+ case "omp.ws.send": {
194
+ const socket = handles.get(params.id)?.socket;
195
+ if (socket === undefined || typeof params.text !== "string") throw new TunnelError("CONNECTION_NOT_FOUND", "No such socket.");
196
+ await new Promise((resolveSend, rejectSend) => socket.send(params.text, (error) => (error ? rejectSend(new TunnelError("CONNECTION_FAILED", "Socket closed.")) : resolveSend())));
197
+ return {};
198
+ }
199
+ case "omp.close":
200
+ handles.get(params.id)?.close();
201
+ return {};
202
+ default:
203
+ throw new TunnelError("METHOD_NOT_FOUND", `Unknown method ${String(method)}.`);
204
+ }
205
+ };
206
+
207
+ return {
208
+ async handle(message) {
209
+ if (message.type !== "rpc.request") return createRpcError(message.id, "INVALID_MESSAGE", "Only RPC requests are accepted.");
210
+ const { method, params } = message.payload ?? {};
211
+ try {
212
+ return createRpcResponse(message.id, await call(method, params));
213
+ } catch (error) {
214
+ const code = error instanceof TunnelError ? error.code : "HARNESS_UNAVAILABLE";
215
+ return createRpcError(message.id, code, error instanceof Error ? error.message : String(error));
216
+ }
217
+ },
218
+ closePeerStreams() {
219
+ for (const handle of [...handles.values()]) handle.close();
220
+ },
221
+ };
222
+ }
223
+
224
+ /**
225
+ * This daemon's Host identity, pairing and relay connection on one server.
226
+ * State (identity key, device tokens, pinned phones, a pairing in progress)
227
+ * lives in `dir`, owner-only.
228
+ */
229
+ export class RelayHost {
230
+ /**
231
+ * @param {{serverUrl: string, dir: string, name: string, version: string, port: () => number, token: string, log: (line: string) => void}} options
232
+ */
233
+ constructor(options) {
234
+ this.options = options;
235
+ this.serverUrl = options.serverUrl.replace(/\/+$/, "");
236
+ this.pairing = undefined;
237
+ this.connection = undefined;
238
+ }
239
+
240
+ async start() {
241
+ const { dir, name, log } = this.options;
242
+ this.identities = new IdentityStore({ directory: dir });
243
+ this.identity = await this.identities.loadOrCreate(name);
244
+ this.api = new HostServerApi(this.serverUrl, new ServerCredentialStore(this.identities.directory));
245
+ this.api.bindIdentity(this.identity);
246
+ const write = (level) => (line) => log(`relay ${level}: ${line}`);
247
+ this.logger = new SafeLogger({ debug() {}, info: write("info"), warn: write("warn"), error: write("error") });
248
+ this.connections = new ConnectionController(
249
+ this.identities,
250
+ (_context, send) => createTunnelRouter({ port: this.options.port(), token: this.options.token }, send),
251
+ this.logger,
252
+ );
253
+ if (await this.api.hasStoredAuthorization()) this.connect();
254
+ }
255
+
256
+ /** Go (back) online with the device tokens a pairing left. */
257
+ connect() {
258
+ if (this.connection !== undefined) {
259
+ this.connection.reconnect();
260
+ return;
261
+ }
262
+ const config = {
263
+ serverUrl: this.serverUrl,
264
+ // No WebRTC on the computer: phone traffic always goes through the relay, still end-to-end encrypted.
265
+ forceRelay: true,
266
+ rotatePinnedPeerKey: false,
267
+ reconnect: { enabled: true, initialDelayMs: 2_000, maxDelayMs: 30_000, jitter: 0.2 },
268
+ };
269
+ this.connection = new HostServerConnection(config, this.identity, this.identities, this.api, this.connections, this.logger);
270
+ this.connection.start();
271
+ }
272
+
273
+ /** The pairing workflow; its state survives restarts in `dir`. */
274
+ hostPairing() {
275
+ this.pairing ??= new HostPairing(
276
+ this.serverUrl,
277
+ this.identities.directory,
278
+ this.identity,
279
+ {
280
+ deviceId: this.identity.deviceId,
281
+ name: this.identity.name,
282
+ role: "host",
283
+ platform: process.platform,
284
+ identityKey: this.identity.publicKey,
285
+ clientVersion: `omp-mobile/${this.options.version}`,
286
+ },
287
+ async (credentials) => {
288
+ await this.api.adoptPairing(credentials);
289
+ this.connect();
290
+ },
291
+ );
292
+ return this.pairing;
293
+ }
294
+
295
+ /** Start a pairing: the view carries `qrPayload`, the link the phone scans. */
296
+ async pairStart() {
297
+ // A finished pairing is left on disk, and asking the server about it again can fail
298
+ // (MEMBERSHIP_REQUIRED once its phone was revoked): drop it; an open one `start` cancels itself.
299
+ const previous = await this.hostPairing()
300
+ .status()
301
+ .catch(() => undefined);
302
+ if (previous === undefined || !["created", "claimed", "approved"].includes(previous.state)) {
303
+ await this.pairing.discard();
304
+ this.pairing = undefined;
305
+ }
306
+ return this.hostPairing().start();
307
+ }
308
+
309
+ /** Where the pairing stands (a phone claimed it: `mobile` names it); undefined when none. */
310
+ pairStatus() {
311
+ return this.hostPairing().status();
312
+ }
313
+
314
+ /** Approve the phone with the 8-digit code it shows. One attempt: a wrong code ends the pairing. */
315
+ pairConfirm(sessionId, code) {
316
+ return this.hostPairing().confirm(sessionId, code);
317
+ }
318
+
319
+ pairCancel(sessionId) {
320
+ return this.hostPairing().cancel(sessionId);
321
+ }
322
+
323
+ /** Paired phones, as the server lists them, and whether each is connected now. */
324
+ async phones() {
325
+ if (!(await this.api.hasStoredAuthorization())) return [];
326
+ const connected = new Set(this.connections.peerDeviceIds());
327
+ // `/devices` is for client devices; a host lists its phones through its memberships.
328
+ const { accessToken } = await this.api.authenticate();
329
+ const response = await fetch(`${this.serverUrl}/api/v1/device-memberships?limit=100`, { headers: { authorization: `Bearer ${accessToken}` } });
330
+ if (!response.ok) throw new Error(`The relay refused the phone list (HTTP ${response.status}).`);
331
+ const { items } = await response.json();
332
+ return (Array.isArray(items) ? items : []).map(({ peer }) => ({
333
+ deviceId: peer.deviceId,
334
+ name: peer.name,
335
+ platform: peer.platform,
336
+ connected: connected.has(peer.deviceId),
337
+ }));
338
+ }
339
+
340
+ /** Revoke one phone: the server ends its membership; its connection closes now. */
341
+ async revoke(deviceId) {
342
+ const { accessToken } = await this.api.authenticate();
343
+ const response = await fetch(`${this.serverUrl}/api/v1/device-memberships/${encodeURIComponent(deviceId)}`, {
344
+ method: "DELETE",
345
+ headers: { authorization: `Bearer ${accessToken}` },
346
+ });
347
+ if (!response.ok && response.status !== 403) throw new Error(`The relay refused the revoke (HTTP ${response.status}).`);
348
+ await this.connections.revoke(deviceId);
349
+ }
350
+
351
+ async status() {
352
+ // Before `start` finished there is no identity yet: not paired, not online.
353
+ return {
354
+ server: this.serverUrl,
355
+ deviceId: this.identity?.deviceId ?? null,
356
+ paired: (await this.api?.hasStoredAuthorization()) ?? false,
357
+ online: this.connection?.isOnline() ?? false,
358
+ connected: this.connections?.connectionCount() ?? 0,
359
+ };
360
+ }
361
+
362
+ async close() {
363
+ await this.connection?.stop().catch(() => undefined);
364
+ await this.connections?.close().catch(() => undefined);
365
+ this.pairing?.close();
366
+ this.api?.close();
367
+ }
368
+ }
package/src/server.mjs CHANGED
@@ -21,6 +21,8 @@ import { AttentionTracker, serveEvents } from "./attention.mjs";
21
21
  import { listOmpSessions, oneLine, promptTitle, sessionOwners } from "./omp-sessions.mjs";
22
22
  import { askOwnerToLeave, forksOf, isOmpCommand, processInfo, sessionBusy, takeoverFarewell, tellTerminal } from "./takeover.mjs";
23
23
  import { TranscriptStore, deltaBody, deltaQuery } from "./transcripts.mjs";
24
+ import { RELAY_HEADER, RelayHost } from "./relay.mjs";
25
+ import { DEFAULT_RELAY, runRelayCli } from "./relay-control.mjs";
24
26
 
25
27
  const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
26
28
  const PROTOCOL = 1;
@@ -28,6 +30,7 @@ const DEFAULT_PORT = 7390;
28
30
  const DEFAULT_TOKEN_FILE = join(homedir(), ".omp-mobile", "token");
29
31
  const DEFAULT_REGISTRY_FILE = join(homedir(), ".omp-mobile", "sessions.json");
30
32
  const DEFAULT_STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
33
+ const DEFAULT_RELAY_DIR = join(homedir(), ".omp-mobile", "relay");
31
34
  const DEFAULT_OMP_HOME = join(homedir(), ".omp");
32
35
  const MAX_BODY = 256 * 1024;
33
36
  /** Terminal sessions listed next to the daemon's own, newest first. */
@@ -58,8 +61,6 @@ const IDLE_SESSION_MS = 30 * 60_000;
58
61
  const REAP_INTERVAL_MS = 60_000;
59
62
  /** Messages a live session keeps in memory; older ones leave its front (its `base` counts them). */
60
63
  const MAX_LIVE_MESSAGES = 5_000;
61
- /** Events that are a session being used: a turn and what it streams. State and setting echoes are not. */
62
- const ACTIVITY_EVENT = /^(agent_|turn_|message_|tool_execution_|auto_compaction_|auto_retry_)/;
63
64
  /**
64
65
  * The fields of a tool result's `details` the app reads: an edit's diff, a
65
66
  * read's line count, a command's run time, a screenshot's file. The rest
@@ -148,6 +149,8 @@ function parseArgs(argv) {
148
149
  tailscale: "tailscale",
149
150
  whois: true,
150
151
  qr: true,
152
+ relay: "",
153
+ relayDir: DEFAULT_RELAY_DIR,
151
154
  };
152
155
  for (let i = 0; i < argv.length; i += 1) {
153
156
  const flag = argv[i];
@@ -210,6 +213,19 @@ function parseArgs(argv) {
210
213
  case "--no-qr":
211
214
  options.qr = false;
212
215
  break;
216
+ case "--relay":
217
+ // The server is optional: `--relay` alone means the public ds-harness-remote relay.
218
+ if (value !== undefined && !value.startsWith("--")) {
219
+ options.relay = value;
220
+ i += 1;
221
+ } else {
222
+ options.relay = DEFAULT_RELAY;
223
+ }
224
+ break;
225
+ case "--relay-dir":
226
+ options.relayDir = resolve(value);
227
+ i += 1;
228
+ break;
213
229
  case "--help":
214
230
  case "-h":
215
231
  printHelp();
@@ -247,6 +263,14 @@ Admission: on a non-loopback --host every request and WebSocket upgrade must nam
247
263
  this node in Host (421 otherwise) and come from a tailnet device of this node's
248
264
  user (403 otherwise), before the token is checked. Loopback hosts skip admission.
249
265
  --no-qr Do not print the pairing QR code at startup
266
+ --relay [url] Also serve paired phones through a ds-harness-remote relay (default ${DEFAULT_RELAY}),
267
+ end-to-end encrypted; pair with \`omp-mobile relay pair\` or /mobile relay
268
+ --relay-dir <dir> Relay identity and device tokens (default ~/.omp-mobile/relay)
269
+
270
+ Relay commands (talk to the running daemon; same --state-file):
271
+ omp-mobile relay pair Show a pairing QR code, then confirm the phone's 8-digit code
272
+ omp-mobile relay status Relay server, connection and paired phones
273
+ omp-mobile relay revoke <phone device id>
250
274
  `);
251
275
  }
252
276
 
@@ -638,8 +662,10 @@ class SessionEntry {
638
662
 
639
663
  bind() {
640
664
  this.rpc.on("event", (event) => {
641
- if (typeof event?.type === "string" && ACTIVITY_EVENT.test(event.type)) this.updatedAt = Date.now();
642
665
  if (event.type === "message_end" && event.message) {
666
+ // A new message is what makes a session the most recent one; opening it,
667
+ // a lifecycle event from a resumed omp, or a state echo are not.
668
+ this.updatedAt = Date.now();
643
669
  this.messages.push(phoneMessage(event.message, this.blobDir));
644
670
  if (this.messages.length > MAX_LIVE_MESSAGES) {
645
671
  // Indices stay the transcript's: a phone's `since` still lines up after the front is dropped.
@@ -746,6 +772,29 @@ class Daemon {
746
772
  // the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
747
773
  this.wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: { threshold: 1_024 } });
748
774
  this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
775
+ /**
776
+ * Phones paired through a relay reach the daemon through this loopback-only
777
+ * listener (`relay.mjs` adds the token): the same API, without the tailnet admission.
778
+ */
779
+ this.tunnelServer = undefined;
780
+ /** @type {RelayHost | undefined} */
781
+ this.relay = undefined;
782
+ if (options.relay) {
783
+ this.tunnelServer = createServer((req, res) => void this.handleHttp(req, res));
784
+ this.tunnelServer.on("upgrade", (req, socket, head) => {
785
+ socket.on("error", () => socket.destroy());
786
+ this.handleUpgrade(req, socket, head);
787
+ });
788
+ this.relay = new RelayHost({
789
+ serverUrl: options.relay,
790
+ dir: options.relayDir,
791
+ name: `omp-mobile on ${hostname().replace(/\.local$/, "")}`,
792
+ version: VERSION,
793
+ port: () => this.tunnelServer.address().port,
794
+ token: this.token,
795
+ log: (line) => this.log(line),
796
+ });
797
+ }
749
798
  }
750
799
 
751
800
  baseUrl() {
@@ -828,9 +877,14 @@ class Daemon {
828
877
  liveSessions: this.sessions.size,
829
878
  eventStreams: this.streams.size,
830
879
  lastPhone: this.lastPhone,
880
+ relay: this.relay === undefined ? null : await this.relay.status(),
831
881
  });
832
882
  return;
833
883
  }
884
+ if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) {
885
+ await this.handleRelay(req, res, url);
886
+ return;
887
+ }
834
888
  if (url.pathname === "/events" && req.method === "GET") {
835
889
  const end = serveEvents(this.attention, req, res);
836
890
  this.streams.add(end);
@@ -1125,7 +1179,7 @@ class Daemon {
1125
1179
  const deadline = Date.now() + 3_000;
1126
1180
  while ((await this.owners())?.get(id) === pid && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 100));
1127
1181
  // Whoever looks at the terminal sees why omp closed.
1128
- if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell(device));
1182
+ if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell({ device, sessionFile: stored.sessionFile }));
1129
1183
  const forks = forksOf(dirname(stored.sessionFile), id, since);
1130
1184
  if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
1131
1185
  outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
@@ -1253,6 +1307,8 @@ class Daemon {
1253
1307
  const text = typeof message.text === "string" ? message.text : "";
1254
1308
  if (text.trim() === "" && images.length === 0) throw new Error("empty prompt");
1255
1309
  await entry.rpc.prompt(text, images);
1310
+ entry.updatedAt = Date.now();
1311
+ entry.onChange?.(entry);
1256
1312
  ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
1257
1313
  return;
1258
1314
  }
@@ -1284,7 +1340,6 @@ class Daemon {
1284
1340
  }
1285
1341
  const state = await entry.rpc.setModel(message.provider, message.modelId);
1286
1342
  entry.state = state;
1287
- entry.updatedAt = Date.now();
1288
1343
  entry.onChange?.(entry);
1289
1344
  entry.broadcast({ type: "state", state });
1290
1345
  ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
@@ -1301,7 +1356,6 @@ class Daemon {
1301
1356
  if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
1302
1357
  const state = await entry.rpc.setThinkingLevel(message.level);
1303
1358
  entry.state = state;
1304
- entry.updatedAt = Date.now();
1305
1359
  entry.broadcast({ type: "state", state });
1306
1360
  ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
1307
1361
  return;
@@ -1328,17 +1382,72 @@ class Daemon {
1328
1382
  */
1329
1383
  noteClient(req) {
1330
1384
  if (req.headers["x-omp-mobile-probe"] !== undefined) return;
1331
- const address = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
1385
+ // Through the relay every request comes from loopback: name the path instead.
1386
+ const address = req.headers[RELAY_HEADER] !== undefined ? "relay" : String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
1332
1387
  this.lastPhone = { address, at: Date.now() };
1333
1388
  }
1334
1389
 
1335
- listen() {
1336
- return new Promise((resolveListen) => {
1337
- this.server.listen(this.options.port, this.options.host, () => {
1338
- this.startTimers();
1339
- resolveListen();
1340
- });
1341
- });
1390
+ /**
1391
+ * Relay pairing and phones, for `omp-mobile relay` and `/mobile relay` on this
1392
+ * computer; never through the relay itself (a paired phone cannot pair or revoke).
1393
+ */
1394
+ async handleRelay(req, res, url) {
1395
+ if (req.headers[RELAY_HEADER] !== undefined) {
1396
+ sendJson(res, 403, { error: "relay control is local only" });
1397
+ return;
1398
+ }
1399
+ if (this.relay === undefined) {
1400
+ sendJson(res, 409, { error: "the daemon runs without --relay" });
1401
+ return;
1402
+ }
1403
+ try {
1404
+ if (url.pathname === "/relay" && req.method === "GET") {
1405
+ sendJson(res, 200, { ...(await this.relay.status()), phones: await this.relay.phones() });
1406
+ return;
1407
+ }
1408
+ if (url.pathname === "/relay/pair" && req.method === "POST") {
1409
+ sendJson(res, 200, await this.relay.pairStart());
1410
+ return;
1411
+ }
1412
+ if (url.pathname === "/relay/pair" && req.method === "GET") {
1413
+ sendJson(res, 200, { pairing: (await this.relay.pairStatus()) ?? null });
1414
+ return;
1415
+ }
1416
+ if ((url.pathname === "/relay/pair/confirm" || url.pathname === "/relay/pair/cancel") && req.method === "POST") {
1417
+ const body = await readBody(req);
1418
+ if (typeof body.sessionId !== "string") {
1419
+ sendJson(res, 400, { error: "sessionId is required" });
1420
+ return;
1421
+ }
1422
+ const confirm = url.pathname.endsWith("/confirm");
1423
+ if (confirm && (typeof body.code !== "string" || !/^\d{8}$/.test(body.code))) {
1424
+ sendJson(res, 400, { error: "the code is 8 digits" });
1425
+ return;
1426
+ }
1427
+ sendJson(res, 200, confirm ? await this.relay.pairConfirm(body.sessionId, body.code) : await this.relay.pairCancel(body.sessionId));
1428
+ return;
1429
+ }
1430
+ const phone = /^\/relay\/phones\/([^/]+)$/.exec(url.pathname);
1431
+ if (phone !== null && req.method === "DELETE") {
1432
+ await this.relay.revoke(decodeURIComponent(phone[1]));
1433
+ sendJson(res, 200, { ok: true });
1434
+ return;
1435
+ }
1436
+ sendJson(res, 404, { error: "not found" });
1437
+ } catch (error) {
1438
+ // The relay's own refusals carry a code (PAIRING_EXPIRED, PAIRING_ATTEMPT_USED, ...): pass it on.
1439
+ const code = typeof error?.code === "string" ? error.code : undefined;
1440
+ sendJson(res, 502, { error: error instanceof Error ? error.message : String(error), ...(code === undefined ? {} : { code }) });
1441
+ }
1442
+ }
1443
+
1444
+ async listen() {
1445
+ await new Promise((resolveListen) => this.server.listen(this.options.port, this.options.host, () => resolveListen()));
1446
+ if (this.relay !== undefined) {
1447
+ await new Promise((resolveListen) => this.tunnelServer.listen(0, "127.0.0.1", () => resolveListen()));
1448
+ await this.relay.start();
1449
+ }
1450
+ this.startTimers();
1342
1451
  }
1343
1452
 
1344
1453
  /** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
@@ -1395,7 +1504,9 @@ class Daemon {
1395
1504
  const outcomes = await Promise.all(closing);
1396
1505
  const stuck = outcomes.filter((outcome) => outcome === "running").length;
1397
1506
  if (stuck > 0) console.error(`omp-mobile: ${stuck} omp process(es) ignored SIGINT and SIGTERM and are still running`);
1507
+ await this.relay?.close();
1398
1508
  this.wsServer.close();
1509
+ this.tunnelServer?.close();
1399
1510
  await new Promise((resolveClose) => this.server.close(() => resolveClose()));
1400
1511
  }
1401
1512
  }
@@ -1473,6 +1584,10 @@ function clearState(path) {
1473
1584
  }
1474
1585
 
1475
1586
  async function main() {
1587
+ if (process.argv[2] === "relay") {
1588
+ await runRelayCli(process.argv.slice(3));
1589
+ return;
1590
+ }
1476
1591
  const options = parseArgs(process.argv.slice(2));
1477
1592
  if (options.host === "tailscale") options.host = await tailscaleHost(options);
1478
1593
  const token = loadToken(options);
@@ -1481,21 +1596,26 @@ async function main() {
1481
1596
  await daemon.listen();
1482
1597
  const url = daemon.baseUrl();
1483
1598
  const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
1484
- writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, startedAt: daemon.startedAt });
1599
+ writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt });
1485
1600
  console.log(`omp-mobile daemon ${VERSION}`);
1486
1601
  console.log(` listening ${url}`);
1487
1602
  console.log(` cwd ${options.cwd}`);
1488
1603
  for (const line of identity.notes) (line.includes("WARNING") ? console.warn : console.log)(line);
1489
1604
  console.log(` token ${token}`);
1490
1605
  console.log(` pair ${pair}`);
1491
- if (options.qr) {
1606
+ if (daemon.relay !== undefined) {
1607
+ const relay = await daemon.relay.status();
1608
+ console.log(` relay ${relay.server} · ${relay.paired ? "paired" : "no phone paired yet"} · pair a phone: omp-mobile relay pair`);
1609
+ }
1610
+ const tailnet = isTailnetAddress(options.host);
1611
+ if (options.qr && (tailnet || daemon.relay === undefined)) {
1492
1612
  console.log("");
1493
1613
  console.log(" Scan with OMP on your phone (Machines > Scan to pair):");
1494
1614
  console.log(renderQr(pair));
1495
1615
  // The app accepts only tailnet addresses from a code; say so before the user tries.
1496
- if (!isTailnetAddress(options.host)) {
1616
+ if (!tailnet) {
1497
1617
  console.log(` note: ${options.host} is not a Tailscale address, so the app will refuse this code.`);
1498
- console.log(" Start with --host <this computer's Tailscale IP> to pair a phone.");
1618
+ console.log(" Start with --host <this computer's Tailscale IP>, or --relay, to pair a phone.");
1499
1619
  }
1500
1620
  }
1501
1621
  const shutdown = async () => {