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 +1 -1
- package/package.json +1 -1
- package/src/restart.mjs +156 -0
- package/src/server.mjs +14 -1
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
package/src/restart.mjs
ADDED
|
@@ -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
|
-
|
|
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}`);
|