omp-mobile 0.0.0-stage → 0.1.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/src/server.mjs ADDED
@@ -0,0 +1,818 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * omp-mobile daemon.
4
+ *
5
+ * One process on the computer owns OMP RPC sessions and serves them to the
6
+ * native app over HTTP + WebSocket. It binds a Tailscale address (or loopback
7
+ * for development) and requires a per-computer bearer token, so nothing needs
8
+ * to be exposed to the public internet.
9
+ */
10
+ import { STATUS_CODES, createServer } from "node:http";
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
12
+ import { randomBytes } from "node:crypto";
13
+ import { homedir, hostname, platform } from "node:os";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { WebSocketServer } from "ws";
17
+ import { renderQr } from "./qr.mjs";
18
+ import { RpcSession } from "./rpc-session.mjs";
19
+ import { Admission, Tailscale, isLoopbackHost } from "./admission.mjs";
20
+ import { AttentionTracker, serveEvents } from "./attention.mjs";
21
+ import { listOmpSessions, promptTitle, sessionOwners } from "./omp-sessions.mjs";
22
+
23
+ const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
24
+ const PROTOCOL = 1;
25
+ const DEFAULT_PORT = 7390;
26
+ const DEFAULT_TOKEN_FILE = join(homedir(), ".omp-mobile", "token");
27
+ const DEFAULT_REGISTRY_FILE = join(homedir(), ".omp-mobile", "sessions.json");
28
+ const DEFAULT_OMP_HOME = join(homedir(), ".omp");
29
+ const MAX_BODY = 256 * 1024;
30
+ /** Terminal sessions listed next to the daemon's own, newest first. */
31
+ const OMP_SESSION_LIMIT = 50;
32
+
33
+ /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
34
+
35
+ /** An error with the HTTP status it answers. */
36
+ function httpError(status, message) {
37
+ return Object.assign(new Error(message), { status });
38
+ }
39
+
40
+ function parseArgs(argv) {
41
+ const options = {
42
+ host: "127.0.0.1",
43
+ port: DEFAULT_PORT,
44
+ cwd: process.cwd(),
45
+ omp: "omp",
46
+ tokenFile: DEFAULT_TOKEN_FILE,
47
+ registryFile: DEFAULT_REGISTRY_FILE,
48
+ token: "",
49
+ sessionDir: "",
50
+ ompHome: DEFAULT_OMP_HOME,
51
+ autoApprove: false,
52
+ approvalMode: "",
53
+ tailscale: "tailscale",
54
+ whois: true,
55
+ qr: true,
56
+ };
57
+ for (let i = 0; i < argv.length; i += 1) {
58
+ const flag = argv[i];
59
+ const value = argv[i + 1];
60
+ switch (flag) {
61
+ case "--host":
62
+ options.host = value;
63
+ i += 1;
64
+ break;
65
+ case "--port":
66
+ options.port = Number(value);
67
+ i += 1;
68
+ break;
69
+ case "--cwd":
70
+ options.cwd = resolve(value);
71
+ i += 1;
72
+ break;
73
+ case "--omp":
74
+ options.omp = value;
75
+ i += 1;
76
+ break;
77
+ case "--token":
78
+ options.token = value;
79
+ i += 1;
80
+ break;
81
+ case "--token-file":
82
+ options.tokenFile = resolve(value);
83
+ i += 1;
84
+ break;
85
+ case "--registry-file":
86
+ options.registryFile = resolve(value);
87
+ i += 1;
88
+ break;
89
+ case "--session-dir":
90
+ options.sessionDir = resolve(value);
91
+ i += 1;
92
+ break;
93
+ case "--omp-home":
94
+ options.ompHome = resolve(value);
95
+ i += 1;
96
+ break;
97
+ case "--tailscale":
98
+ options.tailscale = value;
99
+ i += 1;
100
+ break;
101
+ case "--no-whois":
102
+ options.whois = false;
103
+ break;
104
+ case "--auto-approve":
105
+ options.autoApprove = true;
106
+ break;
107
+ case "--approval-mode":
108
+ options.approvalMode = value;
109
+ i += 1;
110
+ break;
111
+ case "--no-qr":
112
+ options.qr = false;
113
+ break;
114
+ case "--help":
115
+ case "-h":
116
+ printHelp();
117
+ process.exit(0);
118
+ default:
119
+ if (flag.startsWith("--")) {
120
+ console.error(`unknown flag: ${flag}`);
121
+ process.exit(2);
122
+ }
123
+ }
124
+ }
125
+ return options;
126
+ }
127
+
128
+ function printHelp() {
129
+ console.log(`omp-mobile daemon ${VERSION}
130
+
131
+ Usage: omp-mobile [options]
132
+
133
+ --host <address> Bind address (default 127.0.0.1; use the Tailscale IP for a phone)
134
+ --port <port> Port (default ${DEFAULT_PORT})
135
+ --cwd <dir> Default working directory for new sessions
136
+ --omp <path> omp executable (default "omp")
137
+ --session-dir <dir> OMP session directory for sessions this daemon creates
138
+ --omp-home <dir> OMP home whose terminal sessions are listed (default ~/.omp)
139
+ --token <token> Fixed bearer token (otherwise generated and stored)
140
+ --token-file <path> Token file (default ~/.omp-mobile/token)
141
+ --registry-file <path> Session registry (default ~/.omp-mobile/sessions.json)
142
+ --auto-approve Pass --auto-approve to omp (no tool approvals reach the phone)
143
+ --approval-mode <m> Pass --approval-mode to omp (always-ask, always-ask-cached, write, yolo)
144
+ --tailscale <path> tailscale CLI used for admission (default "tailscale")
145
+ --no-whois Skip the whois user check (the Host check and token still apply)
146
+
147
+ Admission: on a non-loopback --host every request and WebSocket upgrade must name
148
+ this node in Host (421 otherwise) and come from a tailnet device of this node's
149
+ user (403 otherwise), before the token is checked. Loopback hosts skip admission.
150
+ --no-qr Do not print the pairing QR code at startup
151
+ `);
152
+ }
153
+
154
+ function loadToken(options) {
155
+ if (options.token) return options.token;
156
+ const envToken = process.env.OMP_MOBILE_TOKEN;
157
+ if (envToken) return envToken;
158
+ try {
159
+ const token = readFileSync(options.tokenFile, "utf8").trim();
160
+ if (token.length >= 16) return token;
161
+ } catch {
162
+ // Generate below.
163
+ }
164
+ const token = randomBytes(24).toString("hex");
165
+ mkdirSync(dirname(options.tokenFile), { recursive: true, mode: 0o700 });
166
+ writeFileSync(options.tokenFile, `${token}\n`, { mode: 0o600 });
167
+ return token;
168
+ }
169
+
170
+ function loadRegistry(path) {
171
+ try {
172
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
173
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return new Map();
174
+ return new Map(Object.entries(parsed));
175
+ } catch {
176
+ return new Map();
177
+ }
178
+ }
179
+
180
+ function saveRegistry(path, registry) {
181
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
182
+ writeFileSync(path, `${JSON.stringify(Object.fromEntries(registry), null, 2)}\n`, { mode: 0o600 });
183
+ }
184
+
185
+ function storedSummary(stored) {
186
+ return {
187
+ id: stored.id,
188
+ title: stored.title,
189
+ cwd: stored.cwd,
190
+ status: "idle",
191
+ model: stored.model,
192
+ createdAt: stored.createdAt,
193
+ updatedAt: stored.updatedAt,
194
+ sessionFile: stored.sessionFile,
195
+ origin: "daemon",
196
+ };
197
+ }
198
+
199
+ function tokenMatches(actual, expected) {
200
+ if (typeof actual !== "string" || actual.length !== expected.length) return false;
201
+ let diff = 0;
202
+ for (let i = 0; i < expected.length; i += 1) diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
203
+ return diff === 0;
204
+ }
205
+
206
+ function authOf(req, url) {
207
+ const header = req.headers.authorization;
208
+ if (typeof header === "string" && header.startsWith("Bearer ")) return header.slice(7);
209
+ return url.searchParams.get("token") ?? "";
210
+ }
211
+
212
+ function sendJson(res, status, body) {
213
+ const data = JSON.stringify(body);
214
+ res.writeHead(status, {
215
+ "content-type": "application/json; charset=utf-8",
216
+ "content-length": Buffer.byteLength(data),
217
+ "cache-control": "no-store",
218
+ });
219
+ res.end(data);
220
+ }
221
+
222
+ function readBody(req) {
223
+ return new Promise((resolveBody, reject) => {
224
+ let size = 0;
225
+ const chunks = [];
226
+ req.on("data", (chunk) => {
227
+ size += chunk.length;
228
+ if (size > MAX_BODY) {
229
+ reject(new Error("request body too large"));
230
+ req.destroy();
231
+ return;
232
+ }
233
+ chunks.push(chunk);
234
+ });
235
+ req.on("end", () => {
236
+ if (chunks.length === 0) {
237
+ resolveBody({});
238
+ return;
239
+ }
240
+ try {
241
+ resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8")));
242
+ } catch {
243
+ reject(new Error("invalid JSON body"));
244
+ }
245
+ });
246
+ req.on("error", reject);
247
+ });
248
+ }
249
+
250
+ function titleOf(messages, state, cwd) {
251
+ if (typeof state?.sessionName === "string" && state.sessionName.trim() !== "") {
252
+ return state.sessionName.trim();
253
+ }
254
+ if (Array.isArray(messages)) {
255
+ const title = promptTitle(messages.find((message) => message?.role === "user")?.content);
256
+ if (title !== "") return title;
257
+ }
258
+ return cwd.split("/").filter(Boolean).at(-1) ?? "session";
259
+ }
260
+
261
+ function modelOf(state) {
262
+ const model = state?.model;
263
+ if (model === undefined || model === null) return undefined;
264
+ return typeof model.name === "string" ? model.name : typeof model.id === "string" ? model.id : undefined;
265
+ }
266
+
267
+ class SessionEntry {
268
+ /**
269
+ * @param {string} cwd
270
+ * @param {RpcSession} rpc
271
+ */
272
+ constructor(cwd, rpc) {
273
+ this.cwd = cwd;
274
+ this.rpc = rpc;
275
+ this.id = "";
276
+ this.state = null;
277
+ this.messages = [];
278
+ this.createdAt = Date.now();
279
+ this.updatedAt = this.createdAt;
280
+ this.title = cwd.split("/").filter(Boolean).at(-1) ?? "session";
281
+ /** @type {Set<import("ws").WebSocket>} */
282
+ this.clients = new Set();
283
+ /** @type {((entry: SessionEntry) => void) | undefined} */
284
+ this.onChange = undefined;
285
+ /**
286
+ * Hears every payload broadcast, whether or not a client is connected (the attention stream).
287
+ * @type {((entry: SessionEntry, payload: object) => void) | undefined}
288
+ */
289
+ this.onBroadcast = undefined;
290
+ this.closed = false;
291
+ }
292
+
293
+ async hydrate() {
294
+ const [state, messages] = await Promise.all([this.rpc.getState(), this.rpc.getMessages()]);
295
+ this.state = state;
296
+ this.messages = Array.isArray(messages.messages) ? messages.messages : [];
297
+ this.id = state.sessionId ?? "";
298
+ this.title = titleOf(this.messages, state, this.cwd);
299
+ this.updatedAt = Date.now();
300
+ return this;
301
+ }
302
+
303
+ summary() {
304
+ return {
305
+ id: this.id,
306
+ title: this.title,
307
+ cwd: this.cwd,
308
+ status: this.state?.isStreaming === true ? "streaming" : "idle",
309
+ model: modelOf(this.state),
310
+ createdAt: this.createdAt,
311
+ updatedAt: this.updatedAt,
312
+ sessionFile: this.state?.sessionFile,
313
+ origin: "daemon",
314
+ };
315
+ }
316
+
317
+ snapshot() {
318
+ return {
319
+ session: this.summary(),
320
+ state: this.state,
321
+ messages: this.messages,
322
+ };
323
+ }
324
+
325
+ broadcast(payload) {
326
+ this.onBroadcast?.(this, payload);
327
+ const text = JSON.stringify(payload);
328
+ for (const client of this.clients) {
329
+ if (client.readyState === 1) client.send(text);
330
+ }
331
+ }
332
+
333
+ async refreshState() {
334
+ try {
335
+ this.state = await this.rpc.getState();
336
+ this.updatedAt = Date.now();
337
+ this.broadcast({ type: "state", state: this.state });
338
+ } catch {
339
+ // The process may be exiting; the close event tells clients.
340
+ }
341
+ }
342
+
343
+ bind() {
344
+ this.rpc.on("event", (event) => {
345
+ this.updatedAt = Date.now();
346
+ if (event.type === "message_end" && event.message) {
347
+ this.messages.push(event.message);
348
+ if (this.messages.length > 5_000) this.messages.splice(0, this.messages.length - 5_000);
349
+ // The first prompt names a session that was created untitled.
350
+ const title = titleOf(this.messages, this.state, this.cwd);
351
+ if (title !== this.title) {
352
+ this.title = title;
353
+ this.onChange?.(this);
354
+ }
355
+ }
356
+ if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
357
+ this.broadcast({ type: "event", event });
358
+ });
359
+ this.rpc.on("approval", (request) => {
360
+ this.broadcast({ type: "approval", request });
361
+ });
362
+ // Answered by any client, or closed by OMP itself: every client drops its dialog.
363
+ this.rpc.on("approval-resolved", ({ id, reason }) => {
364
+ this.broadcast({ type: "approval_resolved", id, reason });
365
+ });
366
+ this.rpc.on("notice", (notice) => {
367
+ this.broadcast({ type: "notice", ...notice });
368
+ });
369
+ this.rpc.on("closed", ({ reason, stderr }) => {
370
+ this.closed = true;
371
+ this.broadcast({ type: "closed", reason, stderr: stderr.slice(-2_000) });
372
+ });
373
+ this.rpc.on("protocol-error", (payload) => this.broadcast({ type: "protocol-error", ...payload }));
374
+ }
375
+
376
+ /** Extension requests still waiting for an answer, oldest first. */
377
+ openRequests() {
378
+ return this.rpc.extensionRequests instanceof Map ? [...this.rpc.extensionRequests.values()] : [];
379
+ }
380
+
381
+ close() {
382
+ this.closed = true;
383
+ this.rpc.close();
384
+ for (const client of this.clients) client.close(1001, "session closed");
385
+ }
386
+ }
387
+
388
+ class Daemon {
389
+ /**
390
+ * @param {object} options
391
+ * @param {string} token
392
+ * @param {{admission?: {check(req: import("node:http").IncomingMessage): Promise<{ok: boolean, status?: number, error?: string}>}, user?: string}} [identity]
393
+ * Listener admission (absent: none) and this node's login name for /hello.
394
+ */
395
+ constructor(options, token, identity = {}) {
396
+ this.options = options;
397
+ this.token = token;
398
+ this.admission = identity.admission;
399
+ this.user = identity.user ?? "";
400
+ /** @type {Map<string, SessionEntry>} */
401
+ this.sessions = new Map();
402
+ /** @type {Map<string, object>} */
403
+ this.registry = loadRegistry(options.registryFile);
404
+ this.attention = new AttentionTracker({ machine: hostname() });
405
+ /** @type {Set<() => void>} open `/events` streams, ended on close */
406
+ this.streams = new Set();
407
+ /** OMP's own sessions as last listed, for resuming one the app picked. @type {Map<string, import("./omp-sessions.mjs").OmpSession>} */
408
+ this.ompSessions = new Map();
409
+ /** Resumes in flight, so two opens of one session start one process. @type {Map<string, Promise<SessionEntry>>} */
410
+ this.starting = new Map();
411
+ this.server = createServer((req, res) => void this.admitHttp(req, res));
412
+ this.wsServer = new WebSocketServer({ noServer: true });
413
+ this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
414
+ }
415
+
416
+ baseUrl() {
417
+ return `http://${this.options.host}:${this.options.port}`;
418
+ }
419
+
420
+ async admitHttp(req, res) {
421
+ if (this.admission !== undefined) {
422
+ let verdict;
423
+ try {
424
+ verdict = await this.admission.check(req);
425
+ } catch (error) {
426
+ verdict = { ok: false, status: 403, error: `admission failed: ${error instanceof Error ? error.message : String(error)}` };
427
+ }
428
+ if (!verdict.ok) {
429
+ sendJson(res, verdict.status, { error: verdict.error });
430
+ return;
431
+ }
432
+ }
433
+ await this.handleHttp(req, res);
434
+ }
435
+
436
+ async admitUpgrade(req, socket, head) {
437
+ socket.on("error", () => socket.destroy());
438
+ if (this.admission !== undefined) {
439
+ let verdict;
440
+ try {
441
+ verdict = await this.admission.check(req);
442
+ } catch (error) {
443
+ verdict = { ok: false, status: 403, error: String(error) };
444
+ }
445
+ if (!verdict.ok) {
446
+ socket.write(`HTTP/1.1 ${verdict.status} ${STATUS_CODES[verdict.status] ?? "Rejected"}\r\n\r\n`);
447
+ socket.destroy();
448
+ return;
449
+ }
450
+ }
451
+ this.handleUpgrade(req, socket, head);
452
+ }
453
+
454
+ async handleHttp(req, res) {
455
+ const url = new URL(req.url ?? "/", this.baseUrl());
456
+ res.setHeader("access-control-allow-origin", "*");
457
+ res.setHeader("access-control-allow-headers", "authorization, content-type");
458
+ res.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
459
+ if (req.method === "OPTIONS") {
460
+ res.writeHead(204);
461
+ res.end();
462
+ return;
463
+ }
464
+ if (url.pathname === "/hello") {
465
+ sendJson(res, 200, {
466
+ name: hostname(),
467
+ os: platform(),
468
+ version: VERSION,
469
+ protocol: PROTOCOL,
470
+ sessionCount: this.sessions.size,
471
+ cwd: this.options.cwd,
472
+ user: this.user || null,
473
+ });
474
+ return;
475
+ }
476
+ if (!tokenMatches(authOf(req, url), this.token)) {
477
+ sendJson(res, 401, { error: "unauthorized" });
478
+ return;
479
+ }
480
+ if (url.pathname === "/events" && req.method === "GET") {
481
+ const end = serveEvents(this.attention, req, res);
482
+ this.streams.add(end);
483
+ res.on("close", () => this.streams.delete(end));
484
+ return;
485
+ }
486
+ try {
487
+ if (url.pathname === "/sessions" && req.method === "GET") {
488
+ sendJson(res, 200, { sessions: await this.listSessions() });
489
+ return;
490
+ }
491
+ if (url.pathname === "/sessions" && req.method === "POST") {
492
+ const body = await readBody(req);
493
+ const cwd = typeof body.cwd === "string" && body.cwd.trim() !== "" ? resolve(body.cwd) : this.options.cwd;
494
+ if (!isAbsolute(cwd) || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
495
+ sendJson(res, 400, { error: "cwd is not a directory" });
496
+ return;
497
+ }
498
+ const entry = await this.createSession(cwd);
499
+ if (typeof body.prompt === "string" && body.prompt.trim() !== "") {
500
+ await entry.rpc.prompt(body.prompt);
501
+ }
502
+ sendJson(res, 201, { session: entry.summary() });
503
+ return;
504
+ }
505
+ const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
506
+ if (match !== null) {
507
+ const id = decodeURIComponent(match[1]);
508
+ if (req.method === "GET") {
509
+ const live = await this.ensureLive(id);
510
+ sendJson(res, 200, live.snapshot());
511
+ return;
512
+ }
513
+ if (req.method === "DELETE") {
514
+ const live = this.sessions.get(id);
515
+ if (live !== undefined) this.attention.forget(live);
516
+ live?.close();
517
+ this.sessions.delete(id);
518
+ this.registry.delete(id);
519
+ saveRegistry(this.options.registryFile, this.registry);
520
+ sendJson(res, 200, { ok: true });
521
+ return;
522
+ }
523
+ }
524
+ sendJson(res, 404, { error: "not found" });
525
+ } catch (error) {
526
+ sendJson(res, typeof error?.status === "number" ? error.status : 500, {
527
+ error: error instanceof Error ? error.message : String(error),
528
+ });
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Live sessions, then the ones this daemon created earlier, then OMP's other
534
+ * sessions (terminal ones), newest first. A dormant session another omp
535
+ * process holds open is marked `openElsewhere`; opening it is refused.
536
+ */
537
+ async listSessions() {
538
+ const live = [...this.sessions.values()].map((entry) => entry.summary());
539
+ const known = new Set(live.map((session) => session.id));
540
+ const dormant = [...this.registry.values()]
541
+ .filter((stored) => !known.has(stored.id))
542
+ .map((stored) => storedSummary(stored));
543
+ for (const session of dormant) known.add(session.id);
544
+ const [found, owners] = await Promise.all([
545
+ listOmpSessions(join(this.options.ompHome, "agent", "sessions"), { limit: OMP_SESSION_LIMIT }),
546
+ this.owners(),
547
+ ]);
548
+ this.ompSessions = new Map(found.map((session) => [session.id, session]));
549
+ const others = found
550
+ .filter((session) => !known.has(session.id))
551
+ .map((session) => ({ ...session, status: "idle", origin: "omp" }));
552
+ const marked = [...dormant, ...others].map((session) =>
553
+ owners?.has(session.id) === true ? { ...session, openElsewhere: true } : session,
554
+ );
555
+ return [...live, ...marked];
556
+ }
557
+
558
+ /** Which sessions some omp process holds open (`undefined`: cannot tell). */
559
+ owners() {
560
+ return sessionOwners(join(this.options.ompHome, "run", "session-owners"));
561
+ }
562
+
563
+ remember(entry) {
564
+ this.registry.set(entry.id, {
565
+ id: entry.id,
566
+ title: entry.title,
567
+ cwd: entry.cwd,
568
+ model: entry.summary().model,
569
+ createdAt: entry.createdAt,
570
+ updatedAt: entry.updatedAt,
571
+ sessionFile: entry.state?.sessionFile,
572
+ });
573
+ saveRegistry(this.options.registryFile, this.registry);
574
+ }
575
+
576
+ /**
577
+ * The live session for `id`, resuming it on demand: one this daemon created
578
+ * earlier, or any other OMP session it listed. Concurrent opens share one start.
579
+ */
580
+ ensureLive(id) {
581
+ const live = this.sessions.get(id);
582
+ if (live !== undefined) return Promise.resolve(live);
583
+ let starting = this.starting.get(id);
584
+ if (starting === undefined) {
585
+ starting = this.resume(id).finally(() => this.starting.delete(id));
586
+ this.starting.set(id, starting);
587
+ }
588
+ return starting;
589
+ }
590
+
591
+ async resume(id) {
592
+ const stored = this.registry.get(id) ?? this.ompSessions.get(id);
593
+ if (stored === undefined || typeof stored.sessionFile !== "string") throw httpError(404, "unknown session");
594
+ if (!existsSync(stored.sessionFile)) throw httpError(410, "session file is gone");
595
+ // A second omp on the same file would fork it into a new one; the session
596
+ // stays with the process that has it (a terminal) until that one exits.
597
+ const owner = (await this.owners())?.get(id);
598
+ if (owner !== undefined) {
599
+ throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here.`);
600
+ }
601
+ const rpc = new RpcSession({
602
+ cwd: stored.cwd,
603
+ ompBin: this.options.omp,
604
+ resume: stored.sessionFile,
605
+ autoApprove: this.options.autoApprove,
606
+ extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
607
+ });
608
+ await rpc.start();
609
+ const entry = new SessionEntry(stored.cwd, rpc);
610
+ await entry.hydrate();
611
+ entry.id = id;
612
+ entry.onChange = (changed) => this.remember(changed);
613
+ entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
614
+ entry.bind();
615
+ this.sessions.set(id, entry);
616
+ this.remember(entry);
617
+ return entry;
618
+ }
619
+
620
+ async createSession(cwd) {
621
+ const rpc = new RpcSession({
622
+ cwd,
623
+ ompBin: this.options.omp,
624
+ sessionDir: this.options.sessionDir || undefined,
625
+ autoApprove: this.options.autoApprove,
626
+ extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
627
+ });
628
+ await rpc.start();
629
+ const entry = new SessionEntry(cwd, rpc);
630
+ await entry.hydrate();
631
+ if (entry.id === "") {
632
+ rpc.close();
633
+ throw new Error("omp returned no session id");
634
+ }
635
+ entry.onChange = (changed) => this.remember(changed);
636
+ entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
637
+ entry.bind();
638
+ this.sessions.set(entry.id, entry);
639
+ this.remember(entry);
640
+ return entry;
641
+ }
642
+
643
+ handleUpgrade(req, socket, head) {
644
+ const url = new URL(req.url ?? "/", this.baseUrl());
645
+ const match = /^\/sessions\/([^/]+)\/ws$/.exec(url.pathname);
646
+ if (match === null) {
647
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
648
+ socket.destroy();
649
+ return;
650
+ }
651
+ if (!tokenMatches(authOf(req, url), this.token)) {
652
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
653
+ socket.destroy();
654
+ return;
655
+ }
656
+ void this.ensureLive(decodeURIComponent(match[1]))
657
+ .then((entry) => {
658
+ this.wsServer.handleUpgrade(req, socket, head, (ws) => {
659
+ entry.clients.add(ws);
660
+ ws.send(JSON.stringify({ type: "snapshot", ...entry.snapshot() }));
661
+ // A dialog opened before this client connected (it may have come from a notification tap).
662
+ for (const request of entry.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
663
+ ws.on("message", (data) => void this.handleClientMessage(entry, ws, data.toString()));
664
+ ws.on("close", () => entry.clients.delete(ws));
665
+ ws.on("error", () => entry.clients.delete(ws));
666
+ });
667
+ })
668
+ .catch((error) => {
669
+ const status = typeof error?.status === "number" ? error.status : 404;
670
+ socket.write(`HTTP/1.1 ${status} ${STATUS_CODES[status] ?? "Error"}\r\n\r\n`);
671
+ socket.destroy();
672
+ this.log?.(error);
673
+ });
674
+ }
675
+
676
+ async handleClientMessage(entry, ws, text) {
677
+ let message;
678
+ try {
679
+ message = JSON.parse(text);
680
+ } catch {
681
+ ws.send(JSON.stringify({ type: "error", error: "invalid JSON" }));
682
+ return;
683
+ }
684
+ try {
685
+ switch (message.type) {
686
+ case "prompt": {
687
+ if (typeof message.text !== "string" || message.text.trim() === "") throw new Error("empty prompt");
688
+ await entry.rpc.prompt(message.text);
689
+ ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
690
+ return;
691
+ }
692
+ case "abort":
693
+ await entry.rpc.abort();
694
+ ws.send(JSON.stringify({ type: "accepted", command: "abort" }));
695
+ return;
696
+ case "approval": {
697
+ if (typeof message.id !== "string") throw new Error("missing approval id");
698
+ const answer = {};
699
+ if (typeof message.value === "string") answer.value = message.value;
700
+ if (typeof message.confirmed === "boolean") answer.confirmed = message.confirmed;
701
+ if (message.cancelled === true) answer.cancelled = true;
702
+ entry.rpc.respondExtension(message.id, answer);
703
+ ws.send(JSON.stringify({ type: "accepted", command: "approval" }));
704
+ return;
705
+ }
706
+ case "refresh":
707
+ await entry.refreshState();
708
+ return;
709
+ default:
710
+ throw new Error(`unknown command ${String(message.type)}`);
711
+ }
712
+ } catch (error) {
713
+ ws.send(JSON.stringify({ type: "error", error: error instanceof Error ? error.message : String(error) }));
714
+ }
715
+ }
716
+
717
+ listen() {
718
+ return new Promise((resolveListen) => {
719
+ this.server.listen(this.options.port, this.options.host, () => resolveListen());
720
+ });
721
+ }
722
+
723
+ async close() {
724
+ for (const end of this.streams) end();
725
+ this.streams.clear();
726
+ for (const entry of this.sessions.values()) entry.close();
727
+ this.sessions.clear();
728
+ this.wsServer.close();
729
+ await new Promise((resolveClose) => this.server.close(() => resolveClose()));
730
+ }
731
+ }
732
+
733
+ function startupError(message) {
734
+ return Object.assign(new Error(message), { startup: true });
735
+ }
736
+
737
+ /**
738
+ * Build the listener admission from `tailscale status`.
739
+ * Loopback skips admission; a non-loopback host without tailscale refuses to
740
+ * start unless --no-whois.
741
+ */
742
+ async function resolveIdentity(options) {
743
+ const tailscale = new Tailscale({ binary: options.tailscale });
744
+ const loopback = isLoopbackHost(options.host);
745
+ let self;
746
+ let failure;
747
+ try {
748
+ self = await tailscale.status();
749
+ } catch (error) {
750
+ failure = error instanceof Error ? error.message : String(error);
751
+ }
752
+ const user = self?.loginName ?? "";
753
+ if (loopback) {
754
+ return { admission: undefined, user, notes: [` admission off (loopback host ${options.host}; token only)`] };
755
+ }
756
+ if (self === undefined && options.whois) {
757
+ throw startupError(
758
+ `cannot read tailscale status (${failure}); admission needs the tailscale CLI on a non-loopback host. ` +
759
+ `Pass --tailscale <path>, bind --host 127.0.0.1, or use --no-whois to skip the user check.`,
760
+ );
761
+ }
762
+ const effective = self ?? { ips: [], dnsName: "", userId: 0, loginName: "" };
763
+ const admission = new Admission({ self: effective, bindHost: options.host, tailscale, whois: options.whois });
764
+ const notes = [` admission host ∈ {${[...admission.hosts].join(", ")}}`];
765
+ if (options.whois) notes.push(` whois same user only (${user || `user ${effective.userId}`})`);
766
+ else notes.push(" WARNING --no-whois: any device that can reach this address and holds the token is admitted");
767
+ return { admission, user, notes };
768
+ }
769
+
770
+ /** 100.64.0.0/10 or a MagicDNS *.ts.net name: what the app accepts in a pairing code. */
771
+ function isTailnetAddress(host) {
772
+ const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
773
+ if (octets !== null) return octets[1] === "100" && Number(octets[2]) >= 64 && Number(octets[2]) <= 127;
774
+ return /\.ts\.net$/i.test(host);
775
+ }
776
+
777
+ async function main() {
778
+ const options = parseArgs(process.argv.slice(2));
779
+ const token = loadToken(options);
780
+ const identity = await resolveIdentity(options);
781
+ const daemon = new Daemon(options, token, identity);
782
+ await daemon.listen();
783
+ const url = daemon.baseUrl();
784
+ const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
785
+ console.log(`omp-mobile daemon ${VERSION}`);
786
+ console.log(` listening ${url}`);
787
+ console.log(` cwd ${options.cwd}`);
788
+ for (const line of identity.notes) (line.includes("WARNING") ? console.warn : console.log)(line);
789
+ console.log(` token ${token}`);
790
+ console.log(` pair ${pair}`);
791
+ if (options.qr) {
792
+ console.log("");
793
+ console.log(" Scan with OMP on your phone (Machines > Scan to pair):");
794
+ console.log(renderQr(pair));
795
+ // The app accepts only tailnet addresses from a code; say so before the user tries.
796
+ if (!isTailnetAddress(options.host)) {
797
+ console.log(` note: ${options.host} is not a Tailscale address, so the app will refuse this code.`);
798
+ console.log(" Start with --host <this computer's Tailscale IP> to pair a phone.");
799
+ }
800
+ }
801
+ const shutdown = async () => {
802
+ await daemon.close();
803
+ process.exit(0);
804
+ };
805
+ process.on("SIGINT", () => void shutdown());
806
+ process.on("SIGTERM", () => void shutdown());
807
+ }
808
+
809
+ // npm runs the `omp-mobile` bin through a symlink; compare real paths.
810
+ const isMain = process.argv[1] !== undefined && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url);
811
+ if (isMain) {
812
+ main().catch((error) => {
813
+ console.error(error?.startup === true ? `omp-mobile daemon: ${error.message}` : error);
814
+ process.exit(1);
815
+ });
816
+ }
817
+
818
+ export { Daemon, SessionEntry, VERSION, PROTOCOL, parseArgs, resolveIdentity };