omp-mobile 0.1.0 → 0.1.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/README.md CHANGED
@@ -19,29 +19,31 @@ OMP Mobile is a native phone app for [oh-my-pi](https://github.com/can1357/oh-my
19
19
  - **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
20
20
  - **All your machines:** Pair each computer by QR code, switch between them from the session list. A reopened chat shows at once, also offline.
21
21
  - **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.
22
+ - **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.
22
23
 
23
24
  ## Getting started
24
25
 
25
26
  ### Prerequisites
26
27
 
27
- - On every computer: Node.js 22.19 or newer, and OMP 18.x on `PATH`
28
+ - On every computer: OMP 18.x
28
29
  - [Tailscale](https://tailscale.com) signed in to the same account on the computer and the phone
29
30
  - An Android phone
30
31
 
31
32
  ### Computer
32
33
 
33
- Install the daemon on every computer and run it bound to the computer's Tailscale IPv4 address (`tailscale ip -4`):
34
+ Install the plugin into OMP on every computer:
34
35
 
35
36
  ```bash
36
- npm install -g omp-mobile
37
- omp-mobile --host 100.x.y.z --cwd ~/project
37
+ omp plugin install omp-mobile
38
38
  ```
39
39
 
40
- It prints its address, a token, and a pairing link with the same link as a QR code. New sessions start in `--cwd`.
40
+ Then in OMP, run `/mobile`. It starts the daemon in the background, bound to the computer's Tailscale address, and shows a pairing code above the composer until the phone connects. The daemon runs on the Bun inside OMP and outlives the terminal and OMP; `/mobile status` shows it and `/mobile stop` ends it. Phone sessions start in the directory where you ran `/mobile` the first time.
41
+
42
+ Without the plugin, the same daemon runs from npm (Node.js 22.19 or newer) in the foreground: `npm install -g omp-mobile`, then `omp-mobile --host tailscale --cwd ~/project`. It prints the same code.
41
43
 
42
44
  ### Phone
43
45
 
44
- 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 the daemon printed. **Enter address** takes the address and token by hand, or a pasted pairing link.
46
+ 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.
45
47
 
46
48
  For notifications, open **Settings → Notifications** in the app.
47
49
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
5
5
  "keywords": [
6
+ "mobile",
6
7
  "oh-my-pi",
7
8
  "omp",
8
- "mobile",
9
+ "omp-plugin",
9
10
  "remote",
10
11
  "tailscale"
11
12
  ],
@@ -27,6 +28,11 @@
27
28
  "bin": {
28
29
  "omp-mobile": "src/server.mjs"
29
30
  },
31
+ "omp": {
32
+ "extensions": [
33
+ "./src/omp-extension.mjs"
34
+ ]
35
+ },
30
36
  "files": [
31
37
  "src",
32
38
  "NOTICE"
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The OMP extension of omp-mobile: `/mobile` in the interactive OMP.
3
+ *
4
+ * `/mobile` starts the daemon in the background if it is not running, then
5
+ * shows its pairing QR code above the composer until a phone connects. The
6
+ * daemon is its own process (detached, in its own session, logging to
7
+ * `~/.omp-mobile/daemon.log`), so it keeps serving the phone after this
8
+ * terminal and this OMP exit; `/mobile stop` ends it.
9
+ *
10
+ * It runs on the Bun that ships inside omp (`BUN_BE_BUN=1`), so no separate
11
+ * Node.js is needed, and binds this computer's Tailscale IPv4 (`--host tailscale`).
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { delimiter, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { renderQr } from "./qr.mjs";
19
+
20
+ const HOME = join(homedir(), ".omp-mobile");
21
+ const STATE_FILE = join(HOME, "daemon.json");
22
+ const LOG_FILE = join(HOME, "daemon.log");
23
+ const SERVER = fileURLToPath(new URL("./server.mjs", import.meta.url));
24
+ const WIDGET = "omp-mobile";
25
+ const START_TIMEOUT_MS = 20_000;
26
+ const POLL_MS = 1_500;
27
+ /** How long the code stays up without a phone connecting. */
28
+ const SHOW_MS = 5 * 60_000;
29
+ const SUBCOMMANDS = [
30
+ { value: "status", label: "status", description: "Is the daemon running, and where" },
31
+ { value: "stop", label: "stop", description: "Stop the background daemon" },
32
+ { value: "hide", label: "hide", description: "Hide the pairing code" },
33
+ ];
34
+
35
+ /**
36
+ * The running daemon's record, if its process is alive.
37
+ * @returns {{pid: number, version: string, url: string, pair: string, startedAt: number} | undefined}
38
+ */
39
+ export function readState(path = STATE_FILE) {
40
+ let state;
41
+ try {
42
+ state = JSON.parse(readFileSync(path, "utf8"));
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ if (typeof state?.pid !== "number" || typeof state.url !== "string" || typeof state.pair !== "string") return undefined;
47
+ return alive(state.pid) ? state : undefined;
48
+ }
49
+
50
+ function alive(pid) {
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ } catch (error) {
55
+ // EPERM: it exists but belongs to someone else.
56
+ return error?.code === "EPERM";
57
+ }
58
+ }
59
+
60
+ /** The token inside a pairing link. */
61
+ export function tokenOf(pair) {
62
+ return new URL(pair.replace(/^omp-mobile:\/\//, "http://x/")).searchParams.get("token") ?? "";
63
+ }
64
+
65
+ /** The daemon's `/status`, or undefined when it does not answer. */
66
+ async function status(state) {
67
+ try {
68
+ const response = await fetch(`${state.url}/status`, {
69
+ headers: { authorization: `Bearer ${tokenOf(state.pair)}`, "x-omp-mobile-probe": "1" },
70
+ signal: AbortSignal.timeout(3_000),
71
+ });
72
+ return response.ok ? await response.json() : undefined;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ function logTail(lines = 6) {
79
+ try {
80
+ return readFileSync(LOG_FILE, "utf8").trimEnd().split("\n").slice(-lines).join("\n");
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The omp the daemon starts sessions with: `omp` on PATH when there is one, since
88
+ * a package manager's link survives an upgrade that removes the versioned binary
89
+ * this process runs from (Homebrew's Cellar path, for one); else this binary.
90
+ */
91
+ function ompBinary() {
92
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
93
+ if (dir === "") continue;
94
+ const candidate = join(dir, "omp");
95
+ try {
96
+ accessSync(candidate, constants.X_OK);
97
+ return candidate;
98
+ } catch {
99
+ // Not in this directory.
100
+ }
101
+ }
102
+ return process.execPath;
103
+ }
104
+
105
+ /**
106
+ * Start the daemon detached from this terminal and wait until it serves.
107
+ * @param {string} cwd Default directory for sessions the phone starts.
108
+ */
109
+ async function startDaemon(cwd) {
110
+ mkdirSync(HOME, { recursive: true, mode: 0o700 });
111
+ const log = openSync(LOG_FILE, "a", 0o600);
112
+ let child;
113
+ try {
114
+ child = spawn(process.execPath, [SERVER, "--host", "tailscale", "--cwd", cwd, "--omp", ompBinary(), "--no-qr"], {
115
+ cwd,
116
+ detached: true,
117
+ stdio: ["ignore", log, log],
118
+ env: { ...process.env, BUN_BE_BUN: "1" },
119
+ });
120
+ } finally {
121
+ closeSync(log);
122
+ }
123
+ let exited = false;
124
+ child.on("exit", () => {
125
+ exited = true;
126
+ });
127
+ child.on("error", () => {
128
+ exited = true;
129
+ });
130
+ child.unref();
131
+ const deadline = Date.now() + START_TIMEOUT_MS;
132
+ while (Date.now() < deadline && !exited) {
133
+ const state = readState();
134
+ if (state?.pid === child.pid && (await status(state)) !== undefined) return state;
135
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
136
+ }
137
+ const reason = exited ? "it exited" : "it did not answer in time";
138
+ throw new Error(`The omp-mobile daemon did not start (${reason}).\n${logTail()}`);
139
+ }
140
+
141
+ async function stopDaemon(state) {
142
+ process.kill(state.pid, "SIGTERM");
143
+ const deadline = Date.now() + 5_000;
144
+ while (Date.now() < deadline && alive(state.pid)) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
145
+ if (alive(state.pid)) process.kill(state.pid, "SIGKILL");
146
+ }
147
+
148
+ /** What the pairing widget shows: a title, the code, and where it points. */
149
+ export function pairingContent(state) {
150
+ const host = new URL(state.url).host;
151
+ return {
152
+ title: "OMP Mobile · in the app: Machines → Scan to pair",
153
+ code: renderQr(state.pair).split("\n"),
154
+ notes: [
155
+ `${host} · pid ${state.pid} · keeps running after you exit`,
156
+ "/mobile hide closes this · /mobile stop stops the daemon",
157
+ ],
158
+ };
159
+ }
160
+
161
+ /** `text` cut to `width` columns (the widget's lines are plain, one column per character). */
162
+ function fit(text, width) {
163
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
164
+ }
165
+
166
+ /**
167
+ * The pairing widget as a TUI component: a plain line widget is cut at ten
168
+ * lines, which a QR code needs more than. A code cut at the terminal's edge
169
+ * would not scan, so a terminal narrower than the code is asked to widen;
170
+ * the text lines are shortened to fit instead.
171
+ */
172
+ export function pairingWidget({ title, code, notes }) {
173
+ const codeWidth = Math.max(...code.map((line) => line.length)) + 1;
174
+ return () => ({
175
+ render(width) {
176
+ const text = (line) => fit(` ${line}`, width);
177
+ if (width < codeWidth) return [text(`OMP Mobile: widen the terminal to ${codeWidth} columns to show the code.`)];
178
+ return [text(title), "", ...code.map((line) => ` ${line}`), "", ...notes.map(text)];
179
+ },
180
+ });
181
+ }
182
+
183
+ export default function ompMobile(pi) {
184
+ /** The pairing code on screen: its watcher and when it appeared. */
185
+ let showing;
186
+
187
+ const hide = (ctx) => {
188
+ if (showing === undefined) return;
189
+ ctx.clearTimer(showing.timer);
190
+ showing = undefined;
191
+ ctx.ui.setWidget(WIDGET, undefined);
192
+ };
193
+
194
+ const show = (ctx, state) => {
195
+ hide(ctx);
196
+ if (ctx.mode !== "tui") {
197
+ ctx.ui.notify(`OMP Mobile: open this link on the phone: ${state.pair}`, "info");
198
+ return;
199
+ }
200
+ const shownAt = Date.now();
201
+ ctx.ui.setWidget(WIDGET, pairingWidget(pairingContent(state)), { placement: "aboveEditor" });
202
+ const timer = ctx.setInterval(async () => {
203
+ if (showing?.timer !== timer) return;
204
+ if (Date.now() - shownAt > SHOW_MS) {
205
+ hide(ctx);
206
+ return;
207
+ }
208
+ const now = await status(state);
209
+ if (showing?.timer !== timer) return;
210
+ if (now === undefined) {
211
+ hide(ctx);
212
+ ctx.ui.notify("OMP Mobile: the daemon stopped answering.", "warning");
213
+ return;
214
+ }
215
+ if (now.lastPhone !== null && now.lastPhone.at >= shownAt) {
216
+ hide(ctx);
217
+ ctx.ui.notify(`OMP Mobile: phone connected from ${now.lastPhone.address}.`, "info");
218
+ }
219
+ }, POLL_MS);
220
+ showing = { timer };
221
+ };
222
+
223
+ pi.registerCommand("mobile", {
224
+ description: "Pair your phone with this computer (OMP Mobile)",
225
+ getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((item) => item.value.startsWith(prefix.trim())),
226
+ handler: async (args, ctx) => {
227
+ const sub = args.trim();
228
+ const state = readState();
229
+ if (sub === "hide") {
230
+ hide(ctx);
231
+ return;
232
+ }
233
+ if (sub === "stop") {
234
+ hide(ctx);
235
+ if (state === undefined) {
236
+ ctx.ui.notify("OMP Mobile: the daemon is not running.", "info");
237
+ return;
238
+ }
239
+ await stopDaemon(state);
240
+ ctx.ui.notify(`OMP Mobile: stopped the daemon (pid ${state.pid}).`, "info");
241
+ return;
242
+ }
243
+ if (sub === "status") {
244
+ const now = state === undefined ? undefined : await status(state);
245
+ if (state === undefined || now === undefined) {
246
+ ctx.ui.notify("OMP Mobile: the daemon is not running. /mobile starts it.", "info");
247
+ return;
248
+ }
249
+ const phone = now.lastPhone === null ? "no phone yet" : `last phone ${now.lastPhone.address}`;
250
+ ctx.ui.notify(`OMP Mobile: ${now.url} · pid ${now.pid} · ${now.liveSessions} live sessions · ${phone}`, "info");
251
+ return;
252
+ }
253
+ if (sub !== "") {
254
+ ctx.ui.notify(`OMP Mobile: unknown "${sub}". Use /mobile, /mobile status, /mobile stop or /mobile hide.`, "warning");
255
+ return;
256
+ }
257
+ let running = state !== undefined && (await status(state)) !== undefined ? state : undefined;
258
+ if (running === undefined) {
259
+ ctx.ui.notify("OMP Mobile: starting the daemon…", "info");
260
+ try {
261
+ running = await startDaemon(ctx.cwd);
262
+ } catch (error) {
263
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
264
+ return;
265
+ }
266
+ }
267
+ show(ctx, running);
268
+ },
269
+ });
270
+ }
@@ -11,6 +11,15 @@ import { randomUUID } from "node:crypto";
11
11
  import { createInterface } from "node:readline";
12
12
 
13
13
  const START_TIMEOUT_MS = 45_000;
14
+
15
+ /**
16
+ * The environment without `BUN_BE_BUN`: /mobile starts the daemon under omp's
17
+ * embedded Bun with it set, and a child omp that inherits it would run as Bun.
18
+ */
19
+ function withoutBunFlag(env) {
20
+ const { BUN_BE_BUN: _ignored, ...rest } = env;
21
+ return rest;
22
+ }
14
23
  const REQUEST_TIMEOUT_MS = 60_000;
15
24
 
16
25
  /** Events that carry no useful state to the phone. */
@@ -61,7 +70,7 @@ export class RpcSession extends EventEmitter {
61
70
  if (this.sessionDir) args.push("--session-dir", this.sessionDir);
62
71
  const child = spawn(this.ompBin, args, {
63
72
  cwd: this.cwd,
64
- env: { ...process.env, NO_COLOR: "1" },
73
+ env: { ...withoutBunFlag(process.env), NO_COLOR: "1" },
65
74
  stdio: ["pipe", "pipe", "pipe"],
66
75
  });
67
76
  this.child = child;
package/src/server.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * to be exposed to the public internet.
9
9
  */
10
10
  import { STATUS_CODES, createServer } from "node:http";
11
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
12
12
  import { randomBytes } from "node:crypto";
13
13
  import { homedir, hostname, platform } from "node:os";
14
14
  import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -25,6 +25,7 @@ const PROTOCOL = 1;
25
25
  const DEFAULT_PORT = 7390;
26
26
  const DEFAULT_TOKEN_FILE = join(homedir(), ".omp-mobile", "token");
27
27
  const DEFAULT_REGISTRY_FILE = join(homedir(), ".omp-mobile", "sessions.json");
28
+ const DEFAULT_STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
28
29
  const DEFAULT_OMP_HOME = join(homedir(), ".omp");
29
30
  const MAX_BODY = 256 * 1024;
30
31
  /** Terminal sessions listed next to the daemon's own, newest first. */
@@ -45,6 +46,7 @@ function parseArgs(argv) {
45
46
  omp: "omp",
46
47
  tokenFile: DEFAULT_TOKEN_FILE,
47
48
  registryFile: DEFAULT_REGISTRY_FILE,
49
+ stateFile: DEFAULT_STATE_FILE,
48
50
  token: "",
49
51
  sessionDir: "",
50
52
  ompHome: DEFAULT_OMP_HOME,
@@ -86,6 +88,10 @@ function parseArgs(argv) {
86
88
  options.registryFile = resolve(value);
87
89
  i += 1;
88
90
  break;
91
+ case "--state-file":
92
+ options.stateFile = resolve(value);
93
+ i += 1;
94
+ break;
89
95
  case "--session-dir":
90
96
  options.sessionDir = resolve(value);
91
97
  i += 1;
@@ -130,8 +136,7 @@ function printHelp() {
130
136
 
131
137
  Usage: omp-mobile [options]
132
138
 
133
- --host <address> Bind address (default 127.0.0.1; use the Tailscale IP for a phone)
134
- --port <port> Port (default ${DEFAULT_PORT})
139
+ --host <address> Bind address (default 127.0.0.1). \`tailscale\`: this computer's Tailscale IPv4
135
140
  --cwd <dir> Default working directory for new sessions
136
141
  --omp <path> omp executable (default "omp")
137
142
  --session-dir <dir> OMP session directory for sessions this daemon creates
@@ -139,6 +144,7 @@ Usage: omp-mobile [options]
139
144
  --token <token> Fixed bearer token (otherwise generated and stored)
140
145
  --token-file <path> Token file (default ~/.omp-mobile/token)
141
146
  --registry-file <path> Session registry (default ~/.omp-mobile/sessions.json)
147
+ --state-file <path> Where the running daemon records its pid and pairing link (default ~/.omp-mobile/daemon.json)
142
148
  --auto-approve Pass --auto-approve to omp (no tool approvals reach the phone)
143
149
  --approval-mode <m> Pass --approval-mode to omp (always-ask, always-ask-cached, write, yolo)
144
150
  --tailscale <path> tailscale CLI used for admission (default "tailscale")
@@ -397,6 +403,10 @@ class Daemon {
397
403
  this.token = token;
398
404
  this.admission = identity.admission;
399
405
  this.user = identity.user ?? "";
406
+ this.startedAt = Date.now();
407
+ /** The last authenticated request from a client (not `/mobile`'s own probe), for `/mobile` to see a phone connect. */
408
+ /** @type {{address: string, at: number} | null} */
409
+ this.lastPhone = null;
400
410
  /** @type {Map<string, SessionEntry>} */
401
411
  this.sessions = new Map();
402
412
  /** @type {Map<string, object>} */
@@ -477,6 +487,19 @@ class Daemon {
477
487
  sendJson(res, 401, { error: "unauthorized" });
478
488
  return;
479
489
  }
490
+ this.noteClient(req);
491
+ if (url.pathname === "/status" && req.method === "GET") {
492
+ sendJson(res, 200, {
493
+ pid: process.pid,
494
+ version: VERSION,
495
+ startedAt: this.startedAt,
496
+ url: this.baseUrl(),
497
+ liveSessions: this.sessions.size,
498
+ eventStreams: this.streams.size,
499
+ lastPhone: this.lastPhone,
500
+ });
501
+ return;
502
+ }
480
503
  if (url.pathname === "/events" && req.method === "GET") {
481
504
  const end = serveEvents(this.attention, req, res);
482
505
  this.streams.add(end);
@@ -653,6 +676,7 @@ class Daemon {
653
676
  socket.destroy();
654
677
  return;
655
678
  }
679
+ this.noteClient(req);
656
680
  void this.ensureLive(decodeURIComponent(match[1]))
657
681
  .then((entry) => {
658
682
  this.wsServer.handleUpgrade(req, socket, head, (ws) => {
@@ -714,6 +738,17 @@ class Daemon {
714
738
  }
715
739
  }
716
740
 
741
+ /**
742
+ * Remember an authenticated client request. `/mobile` marks its own status
743
+ * polls with `x-omp-mobile-probe`; anything else is the app. An address cannot
744
+ * tell them apart: an emulator's requests leave from this computer's own address.
745
+ */
746
+ noteClient(req) {
747
+ if (req.headers["x-omp-mobile-probe"] !== undefined) return;
748
+ const address = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
749
+ this.lastPhone = { address, at: Date.now() };
750
+ }
751
+
717
752
  listen() {
718
753
  return new Promise((resolveListen) => {
719
754
  this.server.listen(this.options.port, this.options.host, () => resolveListen());
@@ -774,14 +809,44 @@ function isTailnetAddress(host) {
774
809
  return /\.ts\.net$/i.test(host);
775
810
  }
776
811
 
812
+ /** This computer's Tailscale IPv4 address, for `--host tailscale`. */
813
+ async function tailscaleHost(options) {
814
+ let self;
815
+ try {
816
+ self = await new Tailscale({ binary: options.tailscale }).status();
817
+ } catch (error) {
818
+ throw startupError(`--host tailscale: cannot read tailscale status (${error instanceof Error ? error.message : String(error)})`);
819
+ }
820
+ const ipv4 = self.ips.find((ip) => isTailnetAddress(ip));
821
+ if (ipv4 === undefined) throw startupError("--host tailscale: this computer has no Tailscale IPv4 address");
822
+ return ipv4;
823
+ }
824
+
825
+ /** Record the running daemon for `/mobile` (owner-only: the pairing link carries the token). */
826
+ function writeState(path, state) {
827
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
828
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
829
+ }
830
+
831
+ /** Remove the state file if it still describes this process. */
832
+ function clearState(path) {
833
+ try {
834
+ if (JSON.parse(readFileSync(path, "utf8")).pid === process.pid) unlinkSync(path);
835
+ } catch {
836
+ // Already gone or replaced by another daemon.
837
+ }
838
+ }
839
+
777
840
  async function main() {
778
841
  const options = parseArgs(process.argv.slice(2));
842
+ if (options.host === "tailscale") options.host = await tailscaleHost(options);
779
843
  const token = loadToken(options);
780
844
  const identity = await resolveIdentity(options);
781
845
  const daemon = new Daemon(options, token, identity);
782
846
  await daemon.listen();
783
847
  const url = daemon.baseUrl();
784
848
  const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
849
+ writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, startedAt: daemon.startedAt });
785
850
  console.log(`omp-mobile daemon ${VERSION}`);
786
851
  console.log(` listening ${url}`);
787
852
  console.log(` cwd ${options.cwd}`);
@@ -799,11 +864,14 @@ async function main() {
799
864
  }
800
865
  }
801
866
  const shutdown = async () => {
867
+ clearState(options.stateFile);
802
868
  await daemon.close();
803
869
  process.exit(0);
804
870
  };
805
871
  process.on("SIGINT", () => void shutdown());
806
872
  process.on("SIGTERM", () => void shutdown());
873
+ // A daemon started by /mobile has no terminal; a closed one must not stop it.
874
+ process.on("SIGHUP", () => undefined);
807
875
  }
808
876
 
809
877
  // npm runs the `omp-mobile` bin through a symlink; compare real paths.