omp-mobile 0.4.4 → 0.6.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 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. */
@@ -116,7 +119,8 @@ const OPEN_FAILED_CLOSE_CODE = 4000;
116
119
  const BLOB_NAME = /^[0-9a-f]{64}$/;
117
120
 
118
121
  /** @typedef {{pid: number, busy: boolean, terminal: boolean}} SessionOwner */
119
- /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, owner?: SessionOwner, waiting?: number}} SessionSummary */
122
+ /** @typedef {{id: string, method: string, title: string, message?: string, options?: string[]}} PendingRequest */
123
+ /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, owner?: SessionOwner, waiting?: number, request?: PendingRequest}} SessionSummary */
120
124
 
121
125
  /** An error with the HTTP status it answers, and any detail its JSON answer carries. */
122
126
  function httpError(status, message, detail = {}) {
@@ -129,6 +133,29 @@ function errorBody(error) {
129
133
  return { error: error instanceof Error ? error.message : String(error), ...detail };
130
134
  }
131
135
 
136
+ /**
137
+ * The part of an open extension request a session list shows: enough to answer
138
+ * a tool approval or a short choice from the home, nothing for a text prompt.
139
+ * @param {any} request
140
+ * @returns {PendingRequest | undefined}
141
+ */
142
+ function pendingOf(request) {
143
+ if (request === null || typeof request !== "object" || typeof request.id !== "string") return undefined;
144
+ const pending = { id: request.id, method: String(request.method), title: typeof request.title === "string" ? request.title : "" };
145
+ if (typeof request.message === "string") pending.message = request.message;
146
+ if (Array.isArray(request.options)) pending.options = request.options.filter((option) => typeof option === "string");
147
+ return pending;
148
+ }
149
+
150
+ /** The answer fields a phone sends, over the session socket or `POST /sessions/:id/answer`. */
151
+ function answerOf(message) {
152
+ const answer = {};
153
+ if (typeof message.value === "string") answer.value = message.value;
154
+ if (typeof message.confirmed === "boolean") answer.confirmed = message.confirmed;
155
+ if (message.cancelled === true) answer.cancelled = true;
156
+ return answer;
157
+ }
158
+
132
159
  function parseArgs(argv) {
133
160
  const options = {
134
161
  host: "127.0.0.1",
@@ -146,6 +173,8 @@ function parseArgs(argv) {
146
173
  tailscale: "tailscale",
147
174
  whois: true,
148
175
  qr: true,
176
+ relay: "",
177
+ relayDir: DEFAULT_RELAY_DIR,
149
178
  };
150
179
  for (let i = 0; i < argv.length; i += 1) {
151
180
  const flag = argv[i];
@@ -208,6 +237,19 @@ function parseArgs(argv) {
208
237
  case "--no-qr":
209
238
  options.qr = false;
210
239
  break;
240
+ case "--relay":
241
+ // The server is optional: `--relay` alone means the public ds-harness-remote relay.
242
+ if (value !== undefined && !value.startsWith("--")) {
243
+ options.relay = value;
244
+ i += 1;
245
+ } else {
246
+ options.relay = DEFAULT_RELAY;
247
+ }
248
+ break;
249
+ case "--relay-dir":
250
+ options.relayDir = resolve(value);
251
+ i += 1;
252
+ break;
211
253
  case "--help":
212
254
  case "-h":
213
255
  printHelp();
@@ -245,6 +287,14 @@ Admission: on a non-loopback --host every request and WebSocket upgrade must nam
245
287
  this node in Host (421 otherwise) and come from a tailnet device of this node's
246
288
  user (403 otherwise), before the token is checked. Loopback hosts skip admission.
247
289
  --no-qr Do not print the pairing QR code at startup
290
+ --relay [url] Also serve paired phones through a ds-harness-remote relay (default ${DEFAULT_RELAY}),
291
+ end-to-end encrypted; pair with \`omp-mobile relay pair\` or /mobile relay
292
+ --relay-dir <dir> Relay identity and device tokens (default ~/.omp-mobile/relay)
293
+
294
+ Relay commands (talk to the running daemon; same --state-file):
295
+ omp-mobile relay pair Show a pairing QR code, then confirm the phone's 8-digit code
296
+ omp-mobile relay status Relay server, connection and paired phones
297
+ omp-mobile relay revoke <phone device id>
248
298
  `);
249
299
  }
250
300
 
@@ -605,6 +655,8 @@ class SessionEntry {
605
655
  origin: "daemon",
606
656
  // OMP questions and tool approvals still waiting for an answer: the session needs the user.
607
657
  waiting: this.openRequests().length,
658
+ // The oldest of them, so a list can answer it without opening the session.
659
+ request: pendingOf(this.openRequests()[0]),
608
660
  };
609
661
  }
610
662
 
@@ -657,6 +709,8 @@ class SessionEntry {
657
709
  if (STATE_EVENTS.has(event.type)) void this.refreshState();
658
710
  // A `todo` tool call rewrites the list before any reminder fires.
659
711
  if (event.type === "tool_execution_end" && event.toolName === "todo") void this.refreshState();
712
+ // A run starting is what makes a session "running" in the list; OMP's state read would lag behind.
713
+ if (event.type === "agent_start" && this.state !== null && typeof this.state === "object") this.state = { ...this.state, isStreaming: true };
660
714
  if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
661
715
  this.broadcast({ type: "event", event });
662
716
  });
@@ -746,6 +800,29 @@ class Daemon {
746
800
  // the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
747
801
  this.wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: { threshold: 1_024 } });
748
802
  this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
803
+ /**
804
+ * Phones paired through a relay reach the daemon through this loopback-only
805
+ * listener (`relay.mjs` adds the token): the same API, without the tailnet admission.
806
+ */
807
+ this.tunnelServer = undefined;
808
+ /** @type {RelayHost | undefined} */
809
+ this.relay = undefined;
810
+ if (options.relay) {
811
+ this.tunnelServer = createServer((req, res) => void this.handleHttp(req, res));
812
+ this.tunnelServer.on("upgrade", (req, socket, head) => {
813
+ socket.on("error", () => socket.destroy());
814
+ this.handleUpgrade(req, socket, head);
815
+ });
816
+ this.relay = new RelayHost({
817
+ serverUrl: options.relay,
818
+ dir: options.relayDir,
819
+ name: `omp-mobile on ${hostname().replace(/\.local$/, "")}`,
820
+ version: VERSION,
821
+ port: () => this.tunnelServer.address().port,
822
+ token: this.token,
823
+ log: (line) => this.log(line),
824
+ });
825
+ }
749
826
  }
750
827
 
751
828
  baseUrl() {
@@ -828,9 +905,14 @@ class Daemon {
828
905
  liveSessions: this.sessions.size,
829
906
  eventStreams: this.streams.size,
830
907
  lastPhone: this.lastPhone,
908
+ relay: this.relay === undefined ? null : await this.relay.status(),
831
909
  });
832
910
  return;
833
911
  }
912
+ if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) {
913
+ await this.handleRelay(req, res, url);
914
+ return;
915
+ }
834
916
  if (url.pathname === "/events" && req.method === "GET") {
835
917
  const end = serveEvents(this.attention, req, res);
836
918
  this.streams.add(end);
@@ -868,6 +950,18 @@ class Daemon {
868
950
  sendJson(res, 200, this.transcript(decodeURIComponent(transcript[1]), deltaQuery(url.searchParams)));
869
951
  return;
870
952
  }
953
+ const answer = /^\/sessions\/([^/]+)\/answer$/.exec(url.pathname);
954
+ if (answer !== null && req.method === "POST") {
955
+ const body = await readBody(req);
956
+ const live = this.sessions.get(decodeURIComponent(answer[1]));
957
+ if (live === undefined) throw httpError(404, "session is not running");
958
+ if (typeof body.id !== "string" || !live.openRequests().some((request) => request.id === body.id)) {
959
+ throw httpError(409, "that question was already answered");
960
+ }
961
+ live.rpc.respondExtension(body.id, answerOf(body));
962
+ sendJson(res, 200, { ok: true });
963
+ return;
964
+ }
871
965
  const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
872
966
  if (match !== null) {
873
967
  const id = decodeURIComponent(match[1]);
@@ -1264,11 +1358,7 @@ class Daemon {
1264
1358
  return;
1265
1359
  case "approval": {
1266
1360
  if (typeof message.id !== "string") throw new Error("missing approval id");
1267
- const answer = {};
1268
- if (typeof message.value === "string") answer.value = message.value;
1269
- if (typeof message.confirmed === "boolean") answer.confirmed = message.confirmed;
1270
- if (message.cancelled === true) answer.cancelled = true;
1271
- entry.rpc.respondExtension(message.id, answer);
1361
+ entry.rpc.respondExtension(message.id, answerOf(message));
1272
1362
  ws.send(JSON.stringify({ type: "accepted", command: "approval" }));
1273
1363
  return;
1274
1364
  }
@@ -1328,17 +1418,72 @@ class Daemon {
1328
1418
  */
1329
1419
  noteClient(req) {
1330
1420
  if (req.headers["x-omp-mobile-probe"] !== undefined) return;
1331
- const address = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
1421
+ // Through the relay every request comes from loopback: name the path instead.
1422
+ const address = req.headers[RELAY_HEADER] !== undefined ? "relay" : String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
1332
1423
  this.lastPhone = { address, at: Date.now() };
1333
1424
  }
1334
1425
 
1335
- listen() {
1336
- return new Promise((resolveListen) => {
1337
- this.server.listen(this.options.port, this.options.host, () => {
1338
- this.startTimers();
1339
- resolveListen();
1340
- });
1341
- });
1426
+ /**
1427
+ * Relay pairing and phones, for `omp-mobile relay` and `/mobile relay` on this
1428
+ * computer; never through the relay itself (a paired phone cannot pair or revoke).
1429
+ */
1430
+ async handleRelay(req, res, url) {
1431
+ if (req.headers[RELAY_HEADER] !== undefined) {
1432
+ sendJson(res, 403, { error: "relay control is local only" });
1433
+ return;
1434
+ }
1435
+ if (this.relay === undefined) {
1436
+ sendJson(res, 409, { error: "the daemon runs without --relay" });
1437
+ return;
1438
+ }
1439
+ try {
1440
+ if (url.pathname === "/relay" && req.method === "GET") {
1441
+ sendJson(res, 200, { ...(await this.relay.status()), phones: await this.relay.phones() });
1442
+ return;
1443
+ }
1444
+ if (url.pathname === "/relay/pair" && req.method === "POST") {
1445
+ sendJson(res, 200, await this.relay.pairStart());
1446
+ return;
1447
+ }
1448
+ if (url.pathname === "/relay/pair" && req.method === "GET") {
1449
+ sendJson(res, 200, { pairing: (await this.relay.pairStatus()) ?? null });
1450
+ return;
1451
+ }
1452
+ if ((url.pathname === "/relay/pair/confirm" || url.pathname === "/relay/pair/cancel") && req.method === "POST") {
1453
+ const body = await readBody(req);
1454
+ if (typeof body.sessionId !== "string") {
1455
+ sendJson(res, 400, { error: "sessionId is required" });
1456
+ return;
1457
+ }
1458
+ const confirm = url.pathname.endsWith("/confirm");
1459
+ if (confirm && (typeof body.code !== "string" || !/^\d{8}$/.test(body.code))) {
1460
+ sendJson(res, 400, { error: "the code is 8 digits" });
1461
+ return;
1462
+ }
1463
+ sendJson(res, 200, confirm ? await this.relay.pairConfirm(body.sessionId, body.code) : await this.relay.pairCancel(body.sessionId));
1464
+ return;
1465
+ }
1466
+ const phone = /^\/relay\/phones\/([^/]+)$/.exec(url.pathname);
1467
+ if (phone !== null && req.method === "DELETE") {
1468
+ await this.relay.revoke(decodeURIComponent(phone[1]));
1469
+ sendJson(res, 200, { ok: true });
1470
+ return;
1471
+ }
1472
+ sendJson(res, 404, { error: "not found" });
1473
+ } catch (error) {
1474
+ // The relay's own refusals carry a code (PAIRING_EXPIRED, PAIRING_ATTEMPT_USED, ...): pass it on.
1475
+ const code = typeof error?.code === "string" ? error.code : undefined;
1476
+ sendJson(res, 502, { error: error instanceof Error ? error.message : String(error), ...(code === undefined ? {} : { code }) });
1477
+ }
1478
+ }
1479
+
1480
+ async listen() {
1481
+ await new Promise((resolveListen) => this.server.listen(this.options.port, this.options.host, () => resolveListen()));
1482
+ if (this.relay !== undefined) {
1483
+ await new Promise((resolveListen) => this.tunnelServer.listen(0, "127.0.0.1", () => resolveListen()));
1484
+ await this.relay.start();
1485
+ }
1486
+ this.startTimers();
1342
1487
  }
1343
1488
 
1344
1489
  /** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
@@ -1395,7 +1540,9 @@ class Daemon {
1395
1540
  const outcomes = await Promise.all(closing);
1396
1541
  const stuck = outcomes.filter((outcome) => outcome === "running").length;
1397
1542
  if (stuck > 0) console.error(`omp-mobile: ${stuck} omp process(es) ignored SIGINT and SIGTERM and are still running`);
1543
+ await this.relay?.close();
1398
1544
  this.wsServer.close();
1545
+ this.tunnelServer?.close();
1399
1546
  await new Promise((resolveClose) => this.server.close(() => resolveClose()));
1400
1547
  }
1401
1548
  }
@@ -1473,6 +1620,10 @@ function clearState(path) {
1473
1620
  }
1474
1621
 
1475
1622
  async function main() {
1623
+ if (process.argv[2] === "relay") {
1624
+ await runRelayCli(process.argv.slice(3));
1625
+ return;
1626
+ }
1476
1627
  const options = parseArgs(process.argv.slice(2));
1477
1628
  if (options.host === "tailscale") options.host = await tailscaleHost(options);
1478
1629
  const token = loadToken(options);
@@ -1481,21 +1632,26 @@ async function main() {
1481
1632
  await daemon.listen();
1482
1633
  const url = daemon.baseUrl();
1483
1634
  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 });
1635
+ writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt });
1485
1636
  console.log(`omp-mobile daemon ${VERSION}`);
1486
1637
  console.log(` listening ${url}`);
1487
1638
  console.log(` cwd ${options.cwd}`);
1488
1639
  for (const line of identity.notes) (line.includes("WARNING") ? console.warn : console.log)(line);
1489
1640
  console.log(` token ${token}`);
1490
1641
  console.log(` pair ${pair}`);
1491
- if (options.qr) {
1642
+ if (daemon.relay !== undefined) {
1643
+ const relay = await daemon.relay.status();
1644
+ console.log(` relay ${relay.server} · ${relay.paired ? "paired" : "no phone paired yet"} · pair a phone: omp-mobile relay pair`);
1645
+ }
1646
+ const tailnet = isTailnetAddress(options.host);
1647
+ if (options.qr && (tailnet || daemon.relay === undefined)) {
1492
1648
  console.log("");
1493
1649
  console.log(" Scan with OMP on your phone (Machines > Scan to pair):");
1494
1650
  console.log(renderQr(pair));
1495
1651
  // The app accepts only tailnet addresses from a code; say so before the user tries.
1496
- if (!isTailnetAddress(options.host)) {
1652
+ if (!tailnet) {
1497
1653
  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.");
1654
+ console.log(" Start with --host <this computer's Tailscale IP>, or --relay, to pair a phone.");
1499
1655
  }
1500
1656
  }
1501
1657
  const shutdown = async () => {
package/src/state.mjs ADDED
@@ -0,0 +1,36 @@
1
+ /** The running daemon's state file, as `/mobile` and `omp-mobile relay` read it. */
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
7
+
8
+ /**
9
+ * The running daemon's record, if its process is alive.
10
+ * @returns {{pid: number, version: string, url: string, pair: string, relay?: string | null, startedAt: number} | undefined}
11
+ */
12
+ export function readState(path = STATE_FILE) {
13
+ let state;
14
+ try {
15
+ state = JSON.parse(readFileSync(path, "utf8"));
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ if (typeof state?.pid !== "number" || typeof state.url !== "string" || typeof state.pair !== "string") return undefined;
20
+ return alive(state.pid) ? state : undefined;
21
+ }
22
+
23
+ export function alive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ } catch (error) {
28
+ // EPERM: it exists but belongs to someone else.
29
+ return error?.code === "EPERM";
30
+ }
31
+ }
32
+
33
+ /** The token inside a pairing link. */
34
+ export function tokenOf(pair) {
35
+ return new URL(pair.replace(/^omp-mobile:\/\//, "http://x/")).searchParams.get("token") ?? "";
36
+ }
@@ -136,7 +136,7 @@ export class TranscriptStore {
136
136
  base: Number.isSafeInteger(base) && base > 0 ? base : 0,
137
137
  messages,
138
138
  state: state !== null && typeof state === "object" ? { ...state, isStreaming: false } : state,
139
- session: { ...session, status: "idle", waiting: 0 },
139
+ session: { ...session, status: "idle", waiting: 0, request: undefined },
140
140
  };
141
141
  try {
142
142
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSH Remote contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ `dsh-remote-host.mjs` is the Host side of ds-harness-remote's relay (device pairing, the
2
+ relay control connection and its Noise IK channel), bundled from
3
+ [liguobao/ds-harness-remote](https://github.com/liguobao/ds-harness-remote) at commit
4
+ ea1c0925f8be825a5f5afac32ad2d0b2c699f7f7 with `bun build --target=node --format=esm` over an
5
+ entry that re-exports `HostPairing`, `HostServerConnection`, `ConnectionController`,
6
+ `IdentityStore`, `HostServerApi`, `ServerCredentialStore` and `SafeLogger` from
7
+ `packages/plugin/src`, and `createEvent`, `createRpcError`, `createRpcResponse` and
8
+ `MAX_SECURE_MESSAGE_BYTES` from `@dsh-remote/protocol`. Not edited here; see
9
+ `LICENSE.dsh-remote` (MIT).