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/NOTICE CHANGED
@@ -8,3 +8,6 @@ OMP Mobile is not affiliated with oh-my-pi or Tailscale.
8
8
  The app bundles the Geist typeface (Copyright 2023 Vercel, in collaboration with basement.studio) and the
9
9
  JetBrains Mono typeface (Copyright 2020 The JetBrains Mono Project Authors), both under the SIL Open Font
10
10
  License 1.1; see app/assets/fonts.
11
+
12
+ app/vendor/dsh-remote and daemon/vendor bundle code of DS Harness Remote (Copyright (c) 2026 DSH Remote contributors)
13
+ under the MIT License; see app/vendor/dsh-remote/LICENSE and daemon/vendor/LICENSE.dsh-remote.
package/README.md CHANGED
@@ -22,7 +22,7 @@ OMP Mobile is a native phone app for [oh-my-pi](https://github.com/can1357/oh-my
22
22
  - **Terminal sessions:** Sessions you started in a terminal on the computer are listed too and continue on the phone. One still open in a terminal waits until that terminal exits.
23
23
  - **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
24
24
  - **All your machines:** Pair each computer by QR code and give it a name on the phone. Tap a chat's title for every session on every computer — searchable, with what needs you first. The app opens on the computer you used last; a reopened chat shows at once, also offline.
25
- - **Tailscale only:** The daemon listens on the computer's Tailscale address and admits only your own devices. No relay or cloud service of its own.
25
+ - **Tailscale, or a relay:** The daemon listens on the computer's Tailscale address and admits only your own devices. Without Tailscale, it can be reached through the open-source ds-harness-remote relay, end-to-end encrypted.
26
26
  - **An OMP plugin:** `/mobile` in OMP starts the daemon in the background and shows the pairing code. The daemon keeps running after the terminal closes.
27
27
 
28
28
  ## Getting started
@@ -30,7 +30,7 @@ OMP Mobile is a native phone app for [oh-my-pi](https://github.com/can1357/oh-my
30
30
  ### Prerequisites
31
31
 
32
32
  - On every computer: OMP 18.x
33
- - [Tailscale](https://tailscale.com) signed in to the same account on the computer and the phone
33
+ - [Tailscale](https://tailscale.com) signed in to the same account on the computer and the phone, or the relay (below)
34
34
  - An Android phone
35
35
 
36
36
  ### Computer
@@ -49,7 +49,9 @@ Without the plugin, the same daemon runs from npm (Node.js 22.19 or newer) in th
49
49
 
50
50
  Install the APK from the [releases page](https://github.com/Rinai-R/omp-mobile/releases). In the app, tap **Scan to pair** and scan the code. **Enter address** takes the address and token by hand, or a pasted pairing link.
51
51
 
52
- For notifications, open **Settings → Notifications** in the app.
52
+ #### Through the relay (no Tailscale)
53
+
54
+ The daemon can also be reached through the public relay of [ds-harness-remote](https://github.com/liguobao/ds-harness-remote) (`https://dsh.r2049.cn`), with no Tailscale and no account. In OMP run `/mobile relay` (or start the daemon with `omp-mobile --relay` and run `omp-mobile relay pair`). Scan the code it shows, or paste its link into **Enter address**; the phone shows the computer's key fingerprint and an 8-digit code, which you type where the computer asks. Traffic is end-to-end encrypted between the phone and the daemon (Noise IK, both keys pinned); the relay only forwards ciphertext. Everything works as over Tailscale. `omp-mobile relay status` lists paired phones, `omp-mobile relay revoke <id>` removes one, and removing the computer on the phone ends the pairing too.
53
55
 
54
56
  A device on your Tailscale account that has the token can run anything OMP can on that computer.
55
57
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.4.4",
4
- "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
3
+ "version": "0.6.0",
4
+ "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale or an end-to-end encrypted relay.",
5
5
  "keywords": [
6
6
  "mobile",
7
7
  "oh-my-pi",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "files": [
37
37
  "src",
38
+ "vendor",
38
39
  "NOTICE"
39
40
  ],
40
41
  "publishConfig": {
package/src/attention.mjs CHANGED
@@ -31,6 +31,8 @@ import { hostname } from "node:os";
31
31
  export const HEARTBEAT_MS = 25_000;
32
32
  /** OMP's tool approval is a `select` whose title starts with this. */
33
33
  export const TOOL_APPROVAL_PREFIX = "Allow tool:";
34
+ /** The labels OMP starts approval details with (`Command: `, `Path: `), from its `tools/approval.ts` and each tool. */
35
+ const DETAIL_LABEL = /^(?:Command|Path|Paths|Pattern|Language|Code|Content|URL|Reason|Origin):\s*/;
34
36
  const ASKING = new Set(["select", "confirm", "input", "editor"]);
35
37
  const TEXT_MAX = 280;
36
38
 
@@ -127,7 +129,10 @@ export class AttentionTracker {
127
129
  const approval = isToolApproval(request);
128
130
  const title = typeof request.title === "string" ? request.title : "";
129
131
  // OMP's tool approval title is `Allow tool: bash\nCommand: echo hi`: the tool, then its details.
130
- const lines = (approval ? title.slice(TOOL_APPROVAL_PREFIX.length) : title).split("\n").map((line) => line.trim());
132
+ // A notification reads in the phone's language, so OMP's English labels (`Command:`) are left out.
133
+ const lines = (approval ? title.slice(TOOL_APPROVAL_PREFIX.length) : title)
134
+ .split("\n")
135
+ .map((line, index) => (approval && index > 0 ? line.replace(DETAIL_LABEL, "") : line).trim());
131
136
  const text = [...lines, request.message].filter(Boolean).join(" · ");
132
137
  state.open.set(request.id, emit(approval ? "approval" : "question", request.id, text));
133
138
  break;
@@ -21,10 +21,12 @@ import { homedir } from "node:os";
21
21
  import { delimiter, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { renderQr } from "./qr.mjs";
24
+ import { Tailscale } from "./admission.mjs";
24
25
  import { requestPath, shellWord } from "./takeover.mjs";
26
+ import { alive, readState, tokenOf } from "./state.mjs";
27
+ import { DEFAULT_RELAY, grouped, pairingOutcome, relayCall, waitForPhone } from "./relay-control.mjs";
25
28
 
26
29
  const HOME = join(homedir(), ".omp-mobile");
27
- const STATE_FILE = join(HOME, "daemon.json");
28
30
  const LOG_FILE = join(HOME, "daemon.log");
29
31
  const SERVER = fileURLToPath(new URL("./server.mjs", import.meta.url));
30
32
  const WIDGET = "omp-mobile";
@@ -38,41 +40,12 @@ const TAKEOVER_MAX_AGE_MS = 60_000;
38
40
  /** How long a running turn gets to stop before omp closes anyway. */
39
41
  const TAKEOVER_IDLE_WAIT_MS = 5_000;
40
42
  const SUBCOMMANDS = [
43
+ { value: "relay", label: "relay", description: "Pair a phone through the ds-harness-remote relay (no Tailscale)" },
41
44
  { value: "status", label: "status", description: "Is the daemon running, and where" },
42
45
  { value: "stop", label: "stop", description: "Stop the background daemon" },
43
46
  { value: "hide", label: "hide", description: "Hide the pairing code" },
44
47
  ];
45
48
 
46
- /**
47
- * The running daemon's record, if its process is alive.
48
- * @returns {{pid: number, version: string, url: string, pair: string, startedAt: number} | undefined}
49
- */
50
- export function readState(path = STATE_FILE) {
51
- let state;
52
- try {
53
- state = JSON.parse(readFileSync(path, "utf8"));
54
- } catch {
55
- return undefined;
56
- }
57
- if (typeof state?.pid !== "number" || typeof state.url !== "string" || typeof state.pair !== "string") return undefined;
58
- return alive(state.pid) ? state : undefined;
59
- }
60
-
61
- function alive(pid) {
62
- try {
63
- process.kill(pid, 0);
64
- return true;
65
- } catch (error) {
66
- // EPERM: it exists but belongs to someone else.
67
- return error?.code === "EPERM";
68
- }
69
- }
70
-
71
- /** The token inside a pairing link. */
72
- export function tokenOf(pair) {
73
- return new URL(pair.replace(/^omp-mobile:\/\//, "http://x/")).searchParams.get("token") ?? "";
74
- }
75
-
76
49
  /** The daemon's `/status`, or undefined when it does not answer. */
77
50
  async function status(state) {
78
51
  try {
@@ -116,13 +89,23 @@ function ompBinary() {
116
89
  /**
117
90
  * Start the daemon detached from this terminal and wait until it serves.
118
91
  * @param {string} cwd Default directory for sessions the phone starts.
92
+ * @param {string} [relay] Relay server to serve phones through as well; then
93
+ * the daemon binds loopback when this computer has no Tailscale.
119
94
  */
120
- async function startDaemon(cwd) {
95
+ async function startDaemon(cwd, relay) {
121
96
  mkdirSync(HOME, { recursive: true, mode: 0o700 });
97
+ let host = "tailscale";
98
+ if (relay !== undefined) {
99
+ host = await new Tailscale({ binary: "tailscale" })
100
+ .status()
101
+ .then(() => "tailscale")
102
+ .catch(() => "127.0.0.1");
103
+ }
122
104
  const log = openSync(LOG_FILE, "a", 0o600);
123
105
  let child;
124
106
  try {
125
- child = spawn(process.execPath, [SERVER, "--host", "tailscale", "--cwd", cwd, "--omp", ompBinary(), "--no-qr"], {
107
+ const args = [SERVER, "--host", host, "--cwd", cwd, "--omp", ompBinary(), "--no-qr", ...(relay === undefined ? [] : ["--relay", relay])];
108
+ child = spawn(process.execPath, args, {
126
109
  cwd,
127
110
  detached: true,
128
111
  stdio: ["ignore", log, log],
@@ -278,6 +261,73 @@ export default function ompMobile(pi) {
278
261
  showing = { timer };
279
262
  };
280
263
 
264
+ /**
265
+ * `/mobile relay`: make sure the daemon serves through the relay, show the
266
+ * relay pairing code, and once a phone claims it ask for the code it shows.
267
+ */
268
+ const pairThroughRelay = async (ctx, state, server) => {
269
+ let running = state !== undefined && (await status(state)) !== undefined ? state : undefined;
270
+ if (running !== undefined && !running.relay) {
271
+ ctx.ui.notify("OMP Mobile: the daemon runs without the relay. /mobile stop, then /mobile relay.", "warning");
272
+ return;
273
+ }
274
+ if (running === undefined) {
275
+ ctx.ui.notify("OMP Mobile: starting the daemon with the relay…", "info");
276
+ try {
277
+ running = await startDaemon(ctx.cwd, server);
278
+ } catch (error) {
279
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
280
+ return;
281
+ }
282
+ }
283
+ let view;
284
+ try {
285
+ view = await relayCall(running, "POST", "/relay/pair");
286
+ } catch (error) {
287
+ ctx.ui.notify(`OMP Mobile: the relay did not start a pairing: ${error instanceof Error ? error.message : String(error)}`, "error");
288
+ return;
289
+ }
290
+ hide(ctx);
291
+ if (ctx.mode === "tui") {
292
+ ctx.ui.setWidget(
293
+ WIDGET,
294
+ pairingWidget({
295
+ title: "OMP Mobile · relay · in the app: Machines → Scan to pair",
296
+ code: renderQr(view.qrPayload).split("\n"),
297
+ notes: [`computer key ${grouped(view.hostFingerprint).slice(0, 39)}…`, "then enter the phone's 8-digit code here · /mobile hide closes this"],
298
+ }),
299
+ { placement: "aboveEditor" },
300
+ );
301
+ } else {
302
+ ctx.ui.notify(`OMP Mobile: open this link on the phone: ${view.qrPayload}`, "info");
303
+ }
304
+ const timer = ctx.setInterval(() => undefined, 60_000);
305
+ showing = { timer };
306
+ const claimed = await waitForPhone(running, view.sessionId, () => showing?.timer !== timer).catch(() => undefined);
307
+ if (showing?.timer !== timer) {
308
+ // Hidden (or replaced) before a phone came: the pairing is not wanted any more.
309
+ void relayCall(running, "POST", "/relay/pair/cancel", { sessionId: view.sessionId }).catch(() => undefined);
310
+ return;
311
+ }
312
+ hide(ctx);
313
+ if (claimed?.state !== "claimed") {
314
+ ctx.ui.notify(`OMP Mobile: ${claimed === undefined ? "the pairing ended." : pairingOutcome(claimed)}`, "warning");
315
+ return;
316
+ }
317
+ const code = (await ctx.ui.input(`OMP Mobile: code shown on ${claimed.mobile?.name ?? "the phone"} (phone key ${grouped(claimed.mobile?.fingerprint ?? "").slice(0, 19)}…)`, "8 digits, one try"))?.replace(/\D/g, "");
318
+ if (code === undefined || code === "") {
319
+ await relayCall(running, "POST", "/relay/pair/cancel", { sessionId: view.sessionId }).catch(() => undefined);
320
+ ctx.ui.notify("OMP Mobile: pairing cancelled.", "info");
321
+ return;
322
+ }
323
+ try {
324
+ const done = await relayCall(running, "POST", "/relay/pair/confirm", { sessionId: view.sessionId, code });
325
+ ctx.ui.notify(`OMP Mobile: ${pairingOutcome(done)}`, done.state === "completed" ? "info" : "warning");
326
+ } catch (error) {
327
+ ctx.ui.notify(`OMP Mobile: ${error instanceof Error ? error.message : String(error)}`, "error");
328
+ }
329
+ };
330
+
281
331
  pi.registerCommand("mobile", {
282
332
  description: "Pair your phone with this computer (OMP Mobile)",
283
333
  getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((item) => item.value.startsWith(prefix.trim())),
@@ -308,8 +358,12 @@ export default function ompMobile(pi) {
308
358
  ctx.ui.notify(`OMP Mobile: ${now.url} · pid ${now.pid} · ${now.liveSessions} live sessions · ${phone}`, "info");
309
359
  return;
310
360
  }
361
+ if (sub === "relay" || sub.startsWith("relay ")) {
362
+ await pairThroughRelay(ctx, state, sub.slice("relay".length).trim() || DEFAULT_RELAY);
363
+ return;
364
+ }
311
365
  if (sub !== "") {
312
- ctx.ui.notify(`OMP Mobile: unknown "${sub}". Use /mobile, /mobile status, /mobile stop or /mobile hide.`, "warning");
366
+ ctx.ui.notify(`OMP Mobile: unknown "${sub}". Use /mobile, /mobile relay, /mobile status, /mobile stop or /mobile hide.`, "warning");
313
367
  return;
314
368
  }
315
369
  let running = state !== undefined && (await status(state)) !== undefined ? state : undefined;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Relay pairing from this computer, against the running daemon's local
3
+ * `/relay` API: shared by `omp-mobile relay ...` and `/mobile relay`.
4
+ */
5
+ import { createInterface } from "node:readline/promises";
6
+ import { renderQr } from "./qr.mjs";
7
+ import { readState, tokenOf } from "./state.mjs";
8
+
9
+ /** The public relay of ds-harness-remote: it forwards only ciphertext. */
10
+ export const DEFAULT_RELAY = "https://dsh.r2049.cn";
11
+ const POLL_MS = 1_500;
12
+
13
+ /** One `/relay` call to the daemon `state` describes; throws its error text. */
14
+ export async function relayCall(state, method, path, body) {
15
+ const response = await fetch(`${state.url}${path}`, {
16
+ method,
17
+ headers: {
18
+ authorization: `Bearer ${tokenOf(state.pair)}`,
19
+ "x-omp-mobile-probe": "1",
20
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
21
+ },
22
+ body: body === undefined ? undefined : JSON.stringify(body),
23
+ signal: AbortSignal.timeout(30_000),
24
+ });
25
+ const answer = await response.json().catch(() => ({}));
26
+ if (!response.ok) throw new Error(typeof answer.error === "string" ? answer.error : `HTTP ${response.status}`);
27
+ return answer;
28
+ }
29
+
30
+ /** The pairing once a phone claimed it, or it ended; polls until then. */
31
+ export async function waitForPhone(state, sessionId, isCancelled = () => false) {
32
+ for (;;) {
33
+ if (isCancelled()) return undefined;
34
+ const { pairing } = await relayCall(state, "GET", "/relay/pair");
35
+ if (pairing === null || pairing.sessionId !== sessionId || pairing.state !== "created") return pairing ?? undefined;
36
+ await new Promise((resolveWait) => setTimeout(resolveWait, POLL_MS));
37
+ }
38
+ }
39
+
40
+ /** What a finished confirm means, in a line. */
41
+ export function pairingOutcome(view) {
42
+ if (view.state === "completed") return "Paired. The phone lists this computer and connects through the relay.";
43
+ if (view.failureReason === "verification_failed") return "The code did not match; this pairing is over. Start a new one.";
44
+ return `Pairing ended (${view.state}${view.failureReason ? `: ${view.failureReason}` : ""}).`;
45
+ }
46
+
47
+ /** The phone's fingerprint grouped like the app shows the computer's. */
48
+ export function grouped(fingerprint) {
49
+ return fingerprint.match(/.{1,4}/g)?.join(" ") ?? fingerprint;
50
+ }
51
+
52
+ function stateFileOf(args) {
53
+ const at = args.indexOf("--state-file");
54
+ return at === -1 ? undefined : args[at + 1];
55
+ }
56
+
57
+ /** `omp-mobile relay <pair|status|revoke>`: exits the process. */
58
+ export async function runRelayCli(args) {
59
+ const command = args[0] ?? "status";
60
+ const state = readState(stateFileOf(args));
61
+ if (state === undefined) {
62
+ console.error("omp-mobile: the daemon is not running. Start it with --relay (or /mobile relay in OMP).");
63
+ process.exit(1);
64
+ }
65
+ try {
66
+ if (command === "status") {
67
+ const relay = await relayCall(state, "GET", "/relay");
68
+ console.log(`relay ${relay.server} · ${relay.paired ? (relay.online ? "online" : "offline") : "not paired yet"}`);
69
+ for (const phone of relay.phones) console.log(`phone ${phone.name} (${phone.platform}) ${phone.deviceId}${phone.connected ? " · connected" : ""}`);
70
+ if (relay.phones.length === 0) console.log("phones none; pair one with: omp-mobile relay pair");
71
+ process.exit(0);
72
+ }
73
+ if (command === "revoke") {
74
+ if (args[1] === undefined) throw new Error("usage: omp-mobile relay revoke <phone device id>");
75
+ await relayCall(state, "DELETE", `/relay/phones/${encodeURIComponent(args[1])}`);
76
+ console.log("Revoked: that phone can no longer reach this computer.");
77
+ process.exit(0);
78
+ }
79
+ if (command !== "pair") throw new Error(`unknown relay command "${command}" (pair, status, revoke)`);
80
+ const view = await relayCall(state, "POST", "/relay/pair");
81
+ let cancelled = false;
82
+ process.on("SIGINT", () => {
83
+ cancelled = true;
84
+ void relayCall(state, "POST", "/relay/pair/cancel", { sessionId: view.sessionId }).finally(() => process.exit(130));
85
+ });
86
+ console.log("Scan with OMP on your phone (Machines > Scan to pair), or paste the link into Enter address:\n");
87
+ console.log(renderQr(view.qrPayload));
88
+ console.log(` ${view.qrPayload}`);
89
+ console.log(` computer key ${grouped(view.hostFingerprint)}`);
90
+ console.log(` expires ${new Date(view.expiresAt).toLocaleTimeString()}\n`);
91
+ const claimed = await waitForPhone(state, view.sessionId, () => cancelled);
92
+ if (claimed === undefined || claimed.state !== "claimed") throw new Error(claimed === undefined ? "Pairing ended." : pairingOutcome(claimed));
93
+ console.log(`Phone: ${claimed.mobile?.name ?? "unknown"} · key ${grouped(claimed.mobile?.fingerprint ?? "")}`);
94
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
95
+ const code = (await prompt.question("Enter the 8-digit code the phone shows (one try): ")).replace(/\D/g, "");
96
+ prompt.close();
97
+ const done = await relayCall(state, "POST", "/relay/pair/confirm", { sessionId: view.sessionId, code });
98
+ console.log(pairingOutcome(done));
99
+ process.exit(done.state === "completed" ? 0 : 1);
100
+ } catch (error) {
101
+ console.error(`omp-mobile: ${error instanceof Error ? error.message : String(error)}`);
102
+ process.exit(1);
103
+ }
104
+ }