omp-mobile 0.7.1 → 0.7.2

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
@@ -42,7 +42,7 @@ Install the plugin into OMP on every computer:
42
42
  omp plugin install omp-mobile
43
43
  ```
44
44
 
45
- 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.
45
+ 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. After `omp plugin upgrade omp-mobile`, `omp-mobile restart` puts the running daemon on the new version with the same settings, once no session is mid-turn (safe to run from a phone session). Phone sessions start in the directory where you ran `/mobile` the first time.
46
46
 
47
47
  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.
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
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",
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `omp-mobile restart`: replace the running daemon with the code installed
3
+ * now (after `omp plugin upgrade omp-mobile`), with the same arguments.
4
+ *
5
+ * The daemon runs every phone session's omp as its child, so whoever asks
6
+ * for a restart from such a session (an agent, a phone) would be cut off by
7
+ * the stop and could never start the new daemon. The work is therefore done
8
+ * by a detached worker in its own session: it waits until no session is
9
+ * running a turn (unless `--now`), stops the daemon, starts the new one and
10
+ * logs beside the state file (`~/.omp-mobile/daemon.log`). Phones reconnect; sessions resume on open.
11
+ */
12
+ import { spawn, execFileSync } from "node:child_process";
13
+ import { closeSync, mkdirSync, openSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { STATE_FILE, alive, readState, tokenOf } from "./state.mjs";
18
+
19
+ const SERVER = join(dirname(fileURLToPath(import.meta.url)), "server.mjs");
20
+ /** How long a restart waits for running turns to end before it goes ahead anyway. */
21
+ const IDLE_WAIT_MS = 10 * 60_000;
22
+ /** How long the old daemon gets to close its omp processes, and the new one to come up. */
23
+ const STOP_TIMEOUT_MS = 25_000;
24
+ const START_TIMEOUT_MS = 30_000;
25
+
26
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
27
+
28
+ function log(line) {
29
+ console.log(`omp-mobile restart: ${line}`);
30
+ }
31
+
32
+ /**
33
+ * The arguments and directory the daemon was started with: recorded in its
34
+ * state since 0.7.2; for an older daemon, read from its process (an argument
35
+ * with a space in it cannot be told apart there).
36
+ * @returns {{argv: string[], cwd: string} | undefined}
37
+ */
38
+ export function launchOf(state) {
39
+ if (Array.isArray(state.argv) && typeof state.cwd === "string") return { argv: state.argv, cwd: state.cwd };
40
+ try {
41
+ const command = execFileSync("ps", ["-o", "command=", "-p", String(state.pid)], { encoding: "utf8" }).trim();
42
+ const words = command.split(/\s+/);
43
+ const at = words.findIndex((word) => word.endsWith("server.mjs"));
44
+ if (at === -1) return undefined;
45
+ const cwd = execFileSync("lsof", ["-a", "-p", String(state.pid), "-d", "cwd", "-Fn"], { encoding: "utf8" })
46
+ .split("\n")
47
+ .find((line) => line.startsWith("n"))
48
+ ?.slice(1);
49
+ return { argv: words.slice(at + 1), cwd: cwd ?? homedir() };
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /** A daemon that cannot say whether a turn runs (before 0.7.2) gets this long, so the turn asking can end. */
56
+ const GRACE_MS = 60_000;
57
+
58
+ /** Sessions running a turn; undefined when the daemon does not report it. */
59
+ async function busySessions(state) {
60
+ try {
61
+ const response = await fetch(`${state.url}/status`, { headers: { authorization: `Bearer ${tokenOf(state.pair)}` }, signal: AbortSignal.timeout(5_000) });
62
+ const status = await response.json();
63
+ return typeof status.busySessions === "number" ? status.busySessions : undefined;
64
+ } catch {
65
+ return 0;
66
+ }
67
+ }
68
+
69
+ /** The worker: wait, stop, start. Runs detached; its output goes to the daemon log. */
70
+ /** The log beside the state file: `~/.omp-mobile/daemon.log` for the default one, where /mobile logs too. */
71
+ function logFileOf(stateFile) {
72
+ return join(dirname(stateFile), "daemon.log");
73
+ }
74
+
75
+ async function work(stateFile, now) {
76
+ const state = readState(stateFile);
77
+ if (state === undefined) {
78
+ log("no daemon is running; nothing to restart");
79
+ return 1;
80
+ }
81
+ const launch = launchOf(state);
82
+ if (launch === undefined) {
83
+ log(`cannot tell how daemon ${state.pid} was started; restart it by hand`);
84
+ return 1;
85
+ }
86
+ if (!now) {
87
+ const deadline = Date.now() + IDLE_WAIT_MS;
88
+ let busy = await busySessions(state);
89
+ if (busy === undefined) await sleep(GRACE_MS);
90
+ while (busy !== undefined && busy > 0 && Date.now() < deadline) {
91
+ await sleep(2_000);
92
+ busy = await busySessions(state);
93
+ }
94
+ }
95
+ log(`stopping daemon ${state.pid} (${state.version})`);
96
+ process.kill(state.pid, "SIGTERM");
97
+ const stopBy = Date.now() + STOP_TIMEOUT_MS;
98
+ while (Date.now() < stopBy && alive(state.pid)) await sleep(100);
99
+ if (alive(state.pid)) process.kill(state.pid, "SIGKILL");
100
+ const out = openSync(logFileOf(stateFile), "a", 0o600);
101
+ let child;
102
+ try {
103
+ child = spawn(process.execPath, [SERVER, ...launch.argv], { cwd: launch.cwd, detached: true, stdio: ["ignore", out, out], env: process.env });
104
+ } finally {
105
+ closeSync(out);
106
+ }
107
+ child.unref();
108
+ const startBy = Date.now() + START_TIMEOUT_MS;
109
+ while (Date.now() < startBy) {
110
+ const next = readState(stateFile);
111
+ if (next?.pid === child.pid) {
112
+ log(`daemon ${next.pid} (${next.version}) is running`);
113
+ return 0;
114
+ }
115
+ if (!alive(child.pid)) break;
116
+ await sleep(250);
117
+ }
118
+ log("the new daemon did not start; see the lines above");
119
+ return 1;
120
+ }
121
+
122
+ /**
123
+ * `omp-mobile restart [--now] [--state-file <path>]`: hand the restart to a
124
+ * detached worker and return at once, so this process may be one of the
125
+ * daemon's own sessions.
126
+ * @param {string[]} args
127
+ */
128
+ export async function runRestartCli(args) {
129
+ const at = args.indexOf("--state-file");
130
+ const stateFile = at === -1 ? STATE_FILE : args[at + 1];
131
+ const now = args.includes("--now");
132
+ if (args.includes("--worker")) process.exit(await work(stateFile, now));
133
+ const state = readState(stateFile);
134
+ if (state === undefined) {
135
+ console.error("omp-mobile: the daemon is not running. Start it with /mobile in OMP, or omp-mobile --relay.");
136
+ process.exit(1);
137
+ }
138
+ if (launchOf(state) === undefined) {
139
+ console.error(`omp-mobile: cannot tell how daemon ${state.pid} was started; stop it and start it again by hand.`);
140
+ process.exit(1);
141
+ }
142
+ const logFile = logFileOf(stateFile);
143
+ mkdirSync(dirname(logFile), { recursive: true, mode: 0o700 });
144
+ const out = openSync(logFile, "a", 0o600);
145
+ const worker = spawn(process.execPath, [SERVER, "restart", "--worker", "--state-file", stateFile, ...(now ? ["--now"] : [])], {
146
+ detached: true,
147
+ stdio: ["ignore", out, out],
148
+ });
149
+ closeSync(out);
150
+ worker.unref();
151
+ console.log(
152
+ now
153
+ ? `Restarting daemon ${state.pid} now (log: ${logFile}).`
154
+ : `Daemon ${state.pid} restarts once no session is running a turn (log: ${logFile}). Phones reconnect by themselves.`,
155
+ );
156
+ }
package/src/server.mjs CHANGED
@@ -26,6 +26,7 @@ import { copyArtifacts, isForkPoint, writeCut } from "./fork.mjs";
26
26
  import { RELAY_HEADER, RelayHost } from "./relay.mjs";
27
27
  import { DEFAULT_RELAY, runRelayCli } from "./relay-control.mjs";
28
28
  import { listSubagents, subagentDir, subagentTranscript } from "./subagents.mjs";
29
+ import { runRestartCli } from "./restart.mjs";
29
30
 
30
31
  const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
31
32
  const PROTOCOL = 1;
@@ -302,6 +303,11 @@ user (403 otherwise), before the token is checked. Loopback hosts skip admission
302
303
  end-to-end encrypted; pair with \`omp-mobile relay pair\` or /mobile relay
303
304
  --relay-dir <dir> Relay identity and device tokens (default ~/.omp-mobile/relay)
304
305
 
306
+ Restart (after \`omp plugin upgrade omp-mobile\`; same --state-file):
307
+ omp-mobile restart Restart the running daemon on the installed code, with its arguments, once no
308
+ session is running a turn; it is done by a detached process, so this is safe to
309
+ run from one of the daemon's own sessions. --now: do not wait.
310
+
305
311
  Relay commands (talk to the running daemon; same --state-file):
306
312
  omp-mobile relay pair Show a pairing QR code, then confirm the phone's 8-digit code
307
313
  omp-mobile relay status Relay server, connection and paired phones
@@ -945,6 +951,8 @@ class Daemon {
945
951
  startedAt: this.startedAt,
946
952
  url: this.baseUrl(),
947
953
  liveSessions: this.sessions.size,
954
+ // Sessions running a turn: `omp-mobile restart` waits for none.
955
+ busySessions: [...this.sessions.values()].filter((entry) => entry.state?.isStreaming === true).length,
948
956
  eventStreams: this.streams.size,
949
957
  lastPhone: this.lastPhone,
950
958
  relay: this.relay === undefined ? null : await this.relay.status(),
@@ -1804,6 +1812,10 @@ async function main() {
1804
1812
  await runRelayCli(process.argv.slice(3));
1805
1813
  return;
1806
1814
  }
1815
+ if (process.argv[2] === "restart") {
1816
+ await runRestartCli(process.argv.slice(3));
1817
+ return;
1818
+ }
1807
1819
  const options = parseArgs(process.argv.slice(2));
1808
1820
  if (options.host === "tailscale") options.host = await tailscaleHost(options);
1809
1821
  const token = loadToken(options);
@@ -1812,7 +1824,8 @@ async function main() {
1812
1824
  await daemon.listen();
1813
1825
  const url = daemon.baseUrl();
1814
1826
  const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
1815
- writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt });
1827
+ // `argv` and `cwd`: how to start it again (`omp-mobile restart`).
1828
+ writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt, argv: process.argv.slice(2), cwd: process.cwd() });
1816
1829
  console.log(`omp-mobile daemon ${VERSION}`);
1817
1830
  console.log(` listening ${url}`);
1818
1831
  console.log(` cwd ${options.cwd}`);