omp-mobile 0.1.0 → 0.2.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/README.md CHANGED
@@ -14,34 +14,38 @@
14
14
  OMP Mobile is a native phone app for [oh-my-pi](https://github.com/can1357/oh-my-pi) (OMP), with a small daemon on each computer. Start, follow and steer OMP sessions from the phone: transcript, tool calls, approvals, and the sessions you started in a terminal.
15
15
 
16
16
  - **Native app:** The app draws the transcript, tool cards, thinking, Markdown and composer itself. No WebView, no web page.
17
+ - **The whole transcript:** images the user sent and images tools produce (screenshots, opened files), edit diffs with their intent, tool results, Markdown tables, OMP's todo list, context usage and queued messages.
18
+ - **Model switching:** tap the model in the composer to pick from OMP's catalog — grouped by provider and searchable — and set its thinking level. The model chip follows the session when it changes.
17
19
  - **Approvals on the phone:** OMP's own tool approvals and questions arrive as a sheet, and the turn waits for your answer.
18
20
  - **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.
19
21
  - **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
20
22
  - **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
23
  - **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.
24
+ - **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
25
 
23
26
  ## Getting started
24
27
 
25
28
  ### Prerequisites
26
29
 
27
- - On every computer: Node.js 22.19 or newer, and OMP 18.x on `PATH`
30
+ - On every computer: OMP 18.x
28
31
  - [Tailscale](https://tailscale.com) signed in to the same account on the computer and the phone
29
32
  - An Android phone
30
33
 
31
34
  ### Computer
32
35
 
33
- Install the daemon on every computer and run it bound to the computer's Tailscale IPv4 address (`tailscale ip -4`):
36
+ Install the plugin into OMP on every computer:
34
37
 
35
38
  ```bash
36
- npm install -g omp-mobile
37
- omp-mobile --host 100.x.y.z --cwd ~/project
39
+ omp plugin install omp-mobile
38
40
  ```
39
41
 
40
- It prints its address, a token, and a pairing link with the same link as a QR code. New sessions start in `--cwd`.
42
+ 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.
43
+
44
+ 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
45
 
42
46
  ### Phone
43
47
 
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.
48
+ 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
49
 
46
50
  For notifications, open **Settings → Notifications** in the app.
47
51
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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. */
@@ -26,6 +35,36 @@ const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_te
26
35
  * @property {boolean} [autoApprove]
27
36
  */
28
37
 
38
+ /**
39
+ * One entry of OMP's model catalog, cut to what a phone lists: provider, name,
40
+ * context window and input kinds. The full object carries endpoints, cost and
41
+ * compatibility fields the app never shows.
42
+ * @param {Record<string, unknown>} model
43
+ */
44
+ export function compactModel(model) {
45
+ const thinking = model.thinking;
46
+ const cost = model.cost;
47
+ return {
48
+ provider: typeof model.provider === "string" ? model.provider : "",
49
+ id: typeof model.id === "string" ? model.id : "",
50
+ name: typeof model.name === "string" ? model.name : typeof model.id === "string" ? model.id : "",
51
+ reasoning: model.reasoning === true,
52
+ input: Array.isArray(model.input) ? model.input.filter((kind) => typeof kind === "string") : [],
53
+ contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : undefined,
54
+ cost:
55
+ cost !== null && typeof cost === "object"
56
+ ? {
57
+ input: typeof cost.input === "number" ? cost.input : undefined,
58
+ output: typeof cost.output === "number" ? cost.output : undefined,
59
+ }
60
+ : undefined,
61
+ efforts:
62
+ thinking !== null && typeof thinking === "object" && Array.isArray(thinking.efforts)
63
+ ? thinking.efforts.filter((level) => typeof level === "string")
64
+ : undefined,
65
+ };
66
+ }
67
+
29
68
  export class RpcSession extends EventEmitter {
30
69
  /** @param {RpcSessionOptions} options */
31
70
  constructor(options) {
@@ -61,7 +100,7 @@ export class RpcSession extends EventEmitter {
61
100
  if (this.sessionDir) args.push("--session-dir", this.sessionDir);
62
101
  const child = spawn(this.ompBin, args, {
63
102
  cwd: this.cwd,
64
- env: { ...process.env, NO_COLOR: "1" },
103
+ env: { ...withoutBunFlag(process.env), NO_COLOR: "1" },
65
104
  stdio: ["pipe", "pipe", "pipe"],
66
105
  });
67
106
  this.child = child;
@@ -219,6 +258,41 @@ export class RpcSession extends EventEmitter {
219
258
  return response.data ?? { messages: [] };
220
259
  }
221
260
 
261
+ /**
262
+ * The models this session can switch to, compacted for a phone list.
263
+ * OMP waits out background discovery first, so this can take a few seconds.
264
+ * @returns {Promise<ReturnType<typeof compactModel>[]>}
265
+ */
266
+ async availableModels() {
267
+ const response = await this.request("get_available_models");
268
+ const models = response.data?.models;
269
+ return Array.isArray(models) ? models.map(compactModel) : [];
270
+ }
271
+
272
+ /**
273
+ * Switch this session to another model. Returns the state afterwards: the
274
+ * model's thinking efforts decide which levels the phone may offer.
275
+ * @param {string} provider
276
+ * @param {string} modelId
277
+ */
278
+ async setModel(provider, modelId) {
279
+ await this.request("set_model", { provider, modelId });
280
+ return this.getState();
281
+ }
282
+
283
+ /** @returns {Promise<{levels: string[]}>} */
284
+ async availableThinkingLevels() {
285
+ const response = await this.request("get_available_thinking_levels");
286
+ const levels = response.data?.levels;
287
+ return { levels: Array.isArray(levels) ? levels.filter((level) => typeof level === "string") : [] };
288
+ }
289
+
290
+ /** @param {string} level */
291
+ async setThinkingLevel(level) {
292
+ await this.request("set_thinking_level", { level });
293
+ return this.getState();
294
+ }
295
+
222
296
  /** @param {string} text */
223
297
  async prompt(text) {
224
298
  return this.request("prompt", { message: text });
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,10 +25,25 @@ 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. */
31
32
  const OMP_SESSION_LIMIT = 50;
33
+ /**
34
+ * Session events after which `get_state` differs from what the phone holds:
35
+ * the model or thinking level changed, or the todo/queue/goal panels moved.
36
+ */
37
+ const STATE_EVENTS = new Set([
38
+ "model_changed",
39
+ "thinking_level_changed",
40
+ "queue_update",
41
+ "todo_reminder",
42
+ "todo_auto_clear",
43
+ "goal_updated",
44
+ ]);
45
+ /** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
46
+ const BLOB_NAME = /^[0-9a-f]{64}$/;
32
47
 
33
48
  /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
34
49
 
@@ -45,6 +60,7 @@ function parseArgs(argv) {
45
60
  omp: "omp",
46
61
  tokenFile: DEFAULT_TOKEN_FILE,
47
62
  registryFile: DEFAULT_REGISTRY_FILE,
63
+ stateFile: DEFAULT_STATE_FILE,
48
64
  token: "",
49
65
  sessionDir: "",
50
66
  ompHome: DEFAULT_OMP_HOME,
@@ -86,6 +102,10 @@ function parseArgs(argv) {
86
102
  options.registryFile = resolve(value);
87
103
  i += 1;
88
104
  break;
105
+ case "--state-file":
106
+ options.stateFile = resolve(value);
107
+ i += 1;
108
+ break;
89
109
  case "--session-dir":
90
110
  options.sessionDir = resolve(value);
91
111
  i += 1;
@@ -130,8 +150,7 @@ function printHelp() {
130
150
 
131
151
  Usage: omp-mobile [options]
132
152
 
133
- --host <address> Bind address (default 127.0.0.1; use the Tailscale IP for a phone)
134
- --port <port> Port (default ${DEFAULT_PORT})
153
+ --host <address> Bind address (default 127.0.0.1). \`tailscale\`: this computer's Tailscale IPv4
135
154
  --cwd <dir> Default working directory for new sessions
136
155
  --omp <path> omp executable (default "omp")
137
156
  --session-dir <dir> OMP session directory for sessions this daemon creates
@@ -139,6 +158,7 @@ Usage: omp-mobile [options]
139
158
  --token <token> Fixed bearer token (otherwise generated and stored)
140
159
  --token-file <path> Token file (default ~/.omp-mobile/token)
141
160
  --registry-file <path> Session registry (default ~/.omp-mobile/sessions.json)
161
+ --state-file <path> Where the running daemon records its pid and pairing link (default ~/.omp-mobile/daemon.json)
142
162
  --auto-approve Pass --auto-approve to omp (no tool approvals reach the phone)
143
163
  --approval-mode <m> Pass --approval-mode to omp (always-ask, always-ask-cached, write, yolo)
144
164
  --tailscale <path> tailscale CLI used for admission (default "tailscale")
@@ -219,6 +239,39 @@ function sendJson(res, status, body) {
219
239
  res.end(data);
220
240
  }
221
241
 
242
+ /** What a stored blob is, from its first bytes: OMP keeps the extension off the hash name. */
243
+ export function blobType(bytes) {
244
+ if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
245
+ return "image/webp";
246
+ }
247
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
248
+ return "image/png";
249
+ }
250
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
251
+ if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "GIF8") return "image/gif";
252
+ return "application/octet-stream";
253
+ }
254
+
255
+ /** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
256
+ function sendBlob(res, ompHome, name) {
257
+ if (!BLOB_NAME.test(name)) {
258
+ sendJson(res, 400, { error: "bad blob name" });
259
+ return;
260
+ }
261
+ const path = join(ompHome, "agent", "blobs", name);
262
+ if (!existsSync(path) || !statSync(path).isFile()) {
263
+ sendJson(res, 404, { error: "no such blob" });
264
+ return;
265
+ }
266
+ const bytes = readFileSync(path);
267
+ res.writeHead(200, {
268
+ "content-type": blobType(bytes),
269
+ "content-length": bytes.length,
270
+ "cache-control": "private, max-age=31536000, immutable",
271
+ });
272
+ res.end(bytes);
273
+ }
274
+
222
275
  function readBody(req) {
223
276
  return new Promise((resolveBody, reject) => {
224
277
  let size = 0;
@@ -353,6 +406,9 @@ class SessionEntry {
353
406
  this.onChange?.(this);
354
407
  }
355
408
  }
409
+ if (STATE_EVENTS.has(event.type)) void this.refreshState();
410
+ // A `todo` tool call rewrites the list before any reminder fires.
411
+ if (event.type === "tool_execution_end" && event.toolName === "todo") void this.refreshState();
356
412
  if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
357
413
  this.broadcast({ type: "event", event });
358
414
  });
@@ -397,6 +453,10 @@ class Daemon {
397
453
  this.token = token;
398
454
  this.admission = identity.admission;
399
455
  this.user = identity.user ?? "";
456
+ this.startedAt = Date.now();
457
+ /** The last authenticated request from a client (not `/mobile`'s own probe), for `/mobile` to see a phone connect. */
458
+ /** @type {{address: string, at: number} | null} */
459
+ this.lastPhone = null;
400
460
  /** @type {Map<string, SessionEntry>} */
401
461
  this.sessions = new Map();
402
462
  /** @type {Map<string, object>} */
@@ -477,6 +537,24 @@ class Daemon {
477
537
  sendJson(res, 401, { error: "unauthorized" });
478
538
  return;
479
539
  }
540
+ this.noteClient(req);
541
+ const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
542
+ if (blob !== null && req.method === "GET") {
543
+ sendBlob(res, this.options.ompHome, blob[1]);
544
+ return;
545
+ }
546
+ if (url.pathname === "/status" && req.method === "GET") {
547
+ sendJson(res, 200, {
548
+ pid: process.pid,
549
+ version: VERSION,
550
+ startedAt: this.startedAt,
551
+ url: this.baseUrl(),
552
+ liveSessions: this.sessions.size,
553
+ eventStreams: this.streams.size,
554
+ lastPhone: this.lastPhone,
555
+ });
556
+ return;
557
+ }
480
558
  if (url.pathname === "/events" && req.method === "GET") {
481
559
  const end = serveEvents(this.attention, req, res);
482
560
  this.streams.add(end);
@@ -653,6 +731,7 @@ class Daemon {
653
731
  socket.destroy();
654
732
  return;
655
733
  }
734
+ this.noteClient(req);
656
735
  void this.ensureLive(decodeURIComponent(match[1]))
657
736
  .then((entry) => {
658
737
  this.wsServer.handleUpgrade(req, socket, head, (ws) => {
@@ -706,6 +785,39 @@ class Daemon {
706
785
  case "refresh":
707
786
  await entry.refreshState();
708
787
  return;
788
+ case "models": {
789
+ const models = await entry.rpc.availableModels();
790
+ ws.send(JSON.stringify({ type: "models", models }));
791
+ return;
792
+ }
793
+ case "set_model": {
794
+ if (typeof message.provider !== "string" || typeof message.modelId !== "string") {
795
+ throw new Error("set_model needs provider and modelId");
796
+ }
797
+ const state = await entry.rpc.setModel(message.provider, message.modelId);
798
+ entry.state = state;
799
+ entry.updatedAt = Date.now();
800
+ entry.onChange?.(entry);
801
+ entry.broadcast({ type: "state", state });
802
+ ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
803
+ return;
804
+ }
805
+ case "thinking_levels": {
806
+ const { levels } = await entry.rpc.availableThinkingLevels();
807
+ ws.send(
808
+ JSON.stringify({ type: "thinking_levels", levels, current: entry.state?.thinkingLevel ?? null }),
809
+ );
810
+ return;
811
+ }
812
+ case "set_thinking_level": {
813
+ if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
814
+ const state = await entry.rpc.setThinkingLevel(message.level);
815
+ entry.state = state;
816
+ entry.updatedAt = Date.now();
817
+ entry.broadcast({ type: "state", state });
818
+ ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
819
+ return;
820
+ }
709
821
  default:
710
822
  throw new Error(`unknown command ${String(message.type)}`);
711
823
  }
@@ -714,6 +826,17 @@ class Daemon {
714
826
  }
715
827
  }
716
828
 
829
+ /**
830
+ * Remember an authenticated client request. `/mobile` marks its own status
831
+ * polls with `x-omp-mobile-probe`; anything else is the app. An address cannot
832
+ * tell them apart: an emulator's requests leave from this computer's own address.
833
+ */
834
+ noteClient(req) {
835
+ if (req.headers["x-omp-mobile-probe"] !== undefined) return;
836
+ const address = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
837
+ this.lastPhone = { address, at: Date.now() };
838
+ }
839
+
717
840
  listen() {
718
841
  return new Promise((resolveListen) => {
719
842
  this.server.listen(this.options.port, this.options.host, () => resolveListen());
@@ -774,14 +897,44 @@ function isTailnetAddress(host) {
774
897
  return /\.ts\.net$/i.test(host);
775
898
  }
776
899
 
900
+ /** This computer's Tailscale IPv4 address, for `--host tailscale`. */
901
+ async function tailscaleHost(options) {
902
+ let self;
903
+ try {
904
+ self = await new Tailscale({ binary: options.tailscale }).status();
905
+ } catch (error) {
906
+ throw startupError(`--host tailscale: cannot read tailscale status (${error instanceof Error ? error.message : String(error)})`);
907
+ }
908
+ const ipv4 = self.ips.find((ip) => isTailnetAddress(ip));
909
+ if (ipv4 === undefined) throw startupError("--host tailscale: this computer has no Tailscale IPv4 address");
910
+ return ipv4;
911
+ }
912
+
913
+ /** Record the running daemon for `/mobile` (owner-only: the pairing link carries the token). */
914
+ function writeState(path, state) {
915
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
916
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
917
+ }
918
+
919
+ /** Remove the state file if it still describes this process. */
920
+ function clearState(path) {
921
+ try {
922
+ if (JSON.parse(readFileSync(path, "utf8")).pid === process.pid) unlinkSync(path);
923
+ } catch {
924
+ // Already gone or replaced by another daemon.
925
+ }
926
+ }
927
+
777
928
  async function main() {
778
929
  const options = parseArgs(process.argv.slice(2));
930
+ if (options.host === "tailscale") options.host = await tailscaleHost(options);
779
931
  const token = loadToken(options);
780
932
  const identity = await resolveIdentity(options);
781
933
  const daemon = new Daemon(options, token, identity);
782
934
  await daemon.listen();
783
935
  const url = daemon.baseUrl();
784
936
  const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
937
+ writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, startedAt: daemon.startedAt });
785
938
  console.log(`omp-mobile daemon ${VERSION}`);
786
939
  console.log(` listening ${url}`);
787
940
  console.log(` cwd ${options.cwd}`);
@@ -799,11 +952,14 @@ async function main() {
799
952
  }
800
953
  }
801
954
  const shutdown = async () => {
955
+ clearState(options.stateFile);
802
956
  await daemon.close();
803
957
  process.exit(0);
804
958
  };
805
959
  process.on("SIGINT", () => void shutdown());
806
960
  process.on("SIGTERM", () => void shutdown());
961
+ // A daemon started by /mobile has no terminal; a closed one must not stop it.
962
+ process.on("SIGHUP", () => undefined);
807
963
  }
808
964
 
809
965
  // npm runs the `omp-mobile` bin through a symlink; compare real paths.