omp-mobile 0.2.0 → 0.4.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
@@ -4,3 +4,7 @@ Copyright (c) 2026 Rinai
4
4
  Licensed under the Apache License, Version 2.0. See the LICENSE file.
5
5
 
6
6
  OMP Mobile is not affiliated with oh-my-pi or Tailscale.
7
+
8
+ The app bundles the Geist typeface (Copyright 2023 Vercel, in collaboration with basement.studio) and the
9
+ JetBrains Mono typeface (Copyright 2020 The JetBrains Mono Project Authors), both under the SIL Open Font
10
+ License 1.1; see app/assets/fonts.
package/README.md CHANGED
@@ -13,13 +13,15 @@
13
13
 
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
- - **Native app:** The app draws the transcript, tool cards, thinking, Markdown and composer itself. No WebView, no web page.
16
+ - **Native app:** The app draws the transcript, tool cards, thinking, Markdown and composer itself, in OMP's own look (Geist, JetBrains Mono, the π mark). No WebView, no web page.
17
+ - **A transcript you can follow:** each stretch of tool calls folds into one card that shows the step running now and sums itself up when done ("Edited 2 files, ran 3"); tap a step for its command, diff and output. The view stays on the newest message while you are at the bottom and stays put while you read back.
17
18
  - **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
19
  - **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.
20
+ - **Photos to the agent:** attach photos from the library or the camera; they are downscaled on the phone and sent with your message.
19
21
  - **Approvals on the phone:** OMP's own tool approvals and questions arrive as a sheet, and the turn waits for your answer.
20
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.
21
23
  - **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
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.
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.
23
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.
24
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.
25
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.2.0",
3
+ "version": "0.4.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
6
  "mobile",
@@ -9,13 +9,19 @@
9
9
  *
10
10
  * It runs on the Bun that ships inside omp (`BUN_BE_BUN=1`), so no separate
11
11
  * Node.js is needed, and binds this computer's Tailscale IPv4 (`--host tailscale`).
12
+ *
13
+ * In every omp it is loaded into, it also watches for the daemon's takeover
14
+ * request for the session that omp has open (a file beside the session file,
15
+ * see `takeover.mjs`): it then says where the session went, stops the turn and
16
+ * closes omp through OMP's own shutdown, so the phone can continue it.
12
17
  */
13
18
  import { spawn } from "node:child_process";
14
- import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync } from "node:fs";
19
+ import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
15
20
  import { homedir } from "node:os";
16
21
  import { delimiter, join } from "node:path";
17
22
  import { fileURLToPath } from "node:url";
18
23
  import { renderQr } from "./qr.mjs";
24
+ import { requestPath } from "./takeover.mjs";
19
25
 
20
26
  const HOME = join(homedir(), ".omp-mobile");
21
27
  const STATE_FILE = join(HOME, "daemon.json");
@@ -26,6 +32,11 @@ const START_TIMEOUT_MS = 20_000;
26
32
  const POLL_MS = 1_500;
27
33
  /** How long the code stays up without a phone connecting. */
28
34
  const SHOW_MS = 5 * 60_000;
35
+ /** How often the session's takeover request is looked for, and how stale one may be. */
36
+ const TAKEOVER_POLL_MS = 1_000;
37
+ const TAKEOVER_MAX_AGE_MS = 60_000;
38
+ /** How long a running turn gets to stop before omp closes anyway. */
39
+ const TAKEOVER_IDLE_WAIT_MS = 5_000;
29
40
  const SUBCOMMANDS = [
30
41
  { value: "status", label: "status", description: "Is the daemon running, and where" },
31
42
  { value: "stop", label: "stop", description: "Stop the background daemon" },
@@ -138,9 +149,12 @@ async function startDaemon(cwd) {
138
149
  throw new Error(`The omp-mobile daemon did not start (${reason}).\n${logTail()}`);
139
150
  }
140
151
 
152
+ /** Time the daemon gets to close its omp processes (each up to SIGINT + SIGTERM waits) before it is killed. */
153
+ const STOP_TIMEOUT_MS = 25_000;
154
+
141
155
  async function stopDaemon(state) {
142
156
  process.kill(state.pid, "SIGTERM");
143
- const deadline = Date.now() + 5_000;
157
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
144
158
  while (Date.now() < deadline && alive(state.pid)) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
145
159
  if (alive(state.pid)) process.kill(state.pid, "SIGKILL");
146
160
  }
@@ -180,7 +194,51 @@ export function pairingWidget({ title, code, notes }) {
180
194
  });
181
195
  }
182
196
 
197
+ /**
198
+ * The daemon's takeover request for this process and session, if there is a
199
+ * fresh one. A request for another process or an old one is left alone.
200
+ * @returns {{device: string} | undefined}
201
+ */
202
+ export function takeoverRequest(sessionFile, sessionId, pid = process.pid, now = Date.now()) {
203
+ if (typeof sessionFile !== "string" || sessionFile === "") return undefined;
204
+ let request;
205
+ try {
206
+ request = JSON.parse(readFileSync(requestPath(sessionFile), "utf8"));
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ if (request?.pid !== pid || request.sessionId !== sessionId) return undefined;
211
+ if (typeof request.at !== "number" || now - request.at > TAKEOVER_MAX_AGE_MS) return undefined;
212
+ return { device: typeof request.device === "string" && request.device !== "" ? request.device : "OMP Mobile" };
213
+ }
214
+
183
215
  export default function ompMobile(pi) {
216
+ /** The takeover watch for the session this omp has open. */
217
+ let watching;
218
+ let leaving = false;
219
+
220
+ pi.on("session_start", (_event, ctx) => {
221
+ if (watching !== undefined) ctx.clearTimer(watching);
222
+ watching = ctx.setInterval(async () => {
223
+ if (leaving) return;
224
+ const file = ctx.sessionManager.getSessionFile();
225
+ const request = takeoverRequest(file, ctx.sessionManager.getSessionId());
226
+ if (request === undefined) return;
227
+ leaving = true;
228
+ // Taking the request is the answer the daemon waits for before it would signal.
229
+ rmSync(requestPath(file), { force: true });
230
+ // OMP closes once the session is idle; the daemon signals it if that does not come soon,
231
+ // and tells the terminal where the session went once omp is gone.
232
+ ctx.ui.notify(`OMP Mobile: this session continues on ${request.device}. Closing omp here…`, "warning");
233
+ if (!ctx.isIdle()) {
234
+ ctx.abort();
235
+ const deadline = Date.now() + TAKEOVER_IDLE_WAIT_MS;
236
+ while (!ctx.isIdle() && Date.now() < deadline) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
237
+ }
238
+ ctx.shutdown();
239
+ }, TAKEOVER_POLL_MS);
240
+ });
241
+
184
242
  /** The pairing code on screen: its watcher and when it appeared. */
185
243
  let showing;
186
244
 
@@ -42,7 +42,7 @@ export function promptTitle(content) {
42
42
  * A title as one line of at most 80 characters. OMP's title cards start with
43
43
  * a Nerd Font icon (private-use code points) a phone font has no glyph for.
44
44
  */
45
- function oneLine(text) {
45
+ export function oneLine(text) {
46
46
  return text
47
47
  .replace(/[\uE000-\uF8FF\u{F0000}-\u{10FFFF}]/gu, "")
48
48
  .trim()
@@ -0,0 +1,135 @@
1
+ /**
2
+ * OMP's RPC framing, the parts the daemon needs to read protocol v2.
3
+ *
4
+ * Protocol v1 caps every JSON line at 1 MiB; a response above that arrives as
5
+ * `{success: false, error: "RPC response exceeded the transport limit"}`. After
6
+ * `negotiate_protocol` v2, OMP splits a larger frame into `rpc_chunk` lines
7
+ * (base64 slices of the frame's UTF-8 bytes) that reassemble up to 64 MiB.
8
+ * The limits and checks mirror OMP's `rpc-frame.ts` so a frame OMP accepts
9
+ * is one this decoder accepts.
10
+ */
11
+
12
+ /** Maximum UTF-8 size of one newline-delimited RPC frame, including the newline. */
13
+ export const MAX_RPC_FRAME_BYTES = 1024 * 1024;
14
+ /** Maximum UTF-8 size of one logical frame reassembled by protocol v2. */
15
+ export const MAX_RPC_REASSEMBLED_BYTES = 64 * 1024 * 1024;
16
+ /** Bytes of the original frame carried by one chunk. */
17
+ export const RPC_CHUNK_PAYLOAD_BYTES = 256 * 1024;
18
+
19
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
20
+
21
+ /**
22
+ * Whether OMP's `ready` frame offers protocol v2 with the limits this decoder implements.
23
+ * @param {unknown} ready
24
+ */
25
+ export function supportsProtocolV2(ready) {
26
+ if (ready === null || typeof ready !== "object") return false;
27
+ const value = /** @type {Record<string, unknown>} */ (ready);
28
+ return (
29
+ value.type === "ready" &&
30
+ Array.isArray(value.supportedProtocolVersions) &&
31
+ value.supportedProtocolVersions.includes(2) &&
32
+ value.maxFrameBytes === MAX_RPC_FRAME_BYTES &&
33
+ value.maxReassembledFrameBytes === MAX_RPC_REASSEMBLED_BYTES
34
+ );
35
+ }
36
+
37
+ /** @param {unknown} data */
38
+ function decodeBase64(data) {
39
+ if (typeof data !== "string" || data.length === 0 || !BASE64.test(data)) throw new Error("invalid rpc chunk data");
40
+ const bytes = Buffer.from(data, "base64");
41
+ if (bytes.toString("base64") !== data) throw new Error("invalid rpc chunk data");
42
+ return bytes;
43
+ }
44
+
45
+ /**
46
+ * Reassembles `rpc_chunk` sequences. Feed it every parsed line in order: a
47
+ * plain frame comes straight back, a chunk returns undefined until its
48
+ * sequence completes, then the reassembled frame. Any violation throws, and
49
+ * the decoder is left empty for the next frame.
50
+ */
51
+ export class RpcFrameDecoder {
52
+ /** @type {{chunkId: string, count: number, byteLength: number, nextIndex: number, chunks: Buffer[], receivedBytes: number} | undefined} */
53
+ #pending = undefined;
54
+
55
+ /**
56
+ * @param {unknown} value
57
+ * @returns {object | undefined}
58
+ */
59
+ push(value) {
60
+ try {
61
+ return this.#push(value);
62
+ } catch (error) {
63
+ this.#pending = undefined;
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ /** @param {unknown} value */
69
+ #push(value) {
70
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("rpc frame must be an object");
71
+ const frame = /** @type {Record<string, unknown>} */ (value);
72
+ if (frame.type !== "rpc_chunk") {
73
+ if (this.#pending !== undefined) throw new Error("rpc chunk sequence interrupted");
74
+ return frame;
75
+ }
76
+ const { chunkId, index, count, byteLength } = frame;
77
+ if (
78
+ typeof chunkId !== "string" ||
79
+ chunkId.length === 0 ||
80
+ chunkId.length > 128 ||
81
+ !Number.isSafeInteger(index) ||
82
+ !Number.isSafeInteger(count) ||
83
+ !Number.isSafeInteger(byteLength) ||
84
+ /** @type {number} */ (index) < 0 ||
85
+ /** @type {number} */ (count) < 2 ||
86
+ /** @type {number} */ (count) > Math.ceil(MAX_RPC_REASSEMBLED_BYTES / RPC_CHUNK_PAYLOAD_BYTES) ||
87
+ /** @type {number} */ (index) >= /** @type {number} */ (count) ||
88
+ /** @type {number} */ (byteLength) < MAX_RPC_FRAME_BYTES ||
89
+ /** @type {number} */ (byteLength) > MAX_RPC_REASSEMBLED_BYTES
90
+ ) {
91
+ throw new Error("invalid rpc chunk metadata");
92
+ }
93
+ const bytes = decodeBase64(frame.data);
94
+ if (bytes.byteLength > RPC_CHUNK_PAYLOAD_BYTES) throw new Error("rpc chunk payload exceeds the transport limit");
95
+ if (this.#pending === undefined) {
96
+ if (index !== 0) throw new Error("rpc chunk sequence must start at index 0");
97
+ this.#pending = { chunkId, count: /** @type {number} */ (count), byteLength: /** @type {number} */ (byteLength), nextIndex: 0, chunks: [], receivedBytes: 0 };
98
+ }
99
+ const pending = this.#pending;
100
+ if (pending.chunkId !== chunkId || pending.count !== count || pending.byteLength !== byteLength || pending.nextIndex !== index) {
101
+ throw new Error("rpc chunk sequence mismatch");
102
+ }
103
+ pending.chunks.push(bytes);
104
+ pending.receivedBytes += bytes.byteLength;
105
+ pending.nextIndex += 1;
106
+ if (pending.receivedBytes > pending.byteLength) throw new Error("rpc chunk sequence exceeds its byte length");
107
+ if (pending.nextIndex < pending.count) return undefined;
108
+ this.#pending = undefined;
109
+ if (pending.receivedBytes !== pending.byteLength) throw new Error("rpc chunk sequence ended short of its byte length");
110
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(pending.chunks, pending.receivedBytes));
111
+ const reassembled = JSON.parse(text);
112
+ if (reassembled === null || typeof reassembled !== "object" || Array.isArray(reassembled)) {
113
+ throw new Error("reassembled rpc frame must be an object");
114
+ }
115
+ if (reassembled.type === "rpc_chunk") throw new Error("reassembled rpc frame must not be a chunk");
116
+ return reassembled;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Split one frame into the `rpc_chunk` lines OMP v2 writes for it (used by tests and fakes).
122
+ * @param {object} frame
123
+ * @param {string} chunkId
124
+ * @returns {string[]}
125
+ */
126
+ export function chunkFrame(frame, chunkId) {
127
+ const bytes = Buffer.from(JSON.stringify(frame), "utf8");
128
+ const count = Math.ceil(bytes.byteLength / RPC_CHUNK_PAYLOAD_BYTES);
129
+ const lines = [];
130
+ for (let index = 0; index < count; index += 1) {
131
+ const data = bytes.subarray(index * RPC_CHUNK_PAYLOAD_BYTES, (index + 1) * RPC_CHUNK_PAYLOAD_BYTES).toString("base64");
132
+ lines.push(JSON.stringify({ type: "rpc_chunk", chunkId, index, count, byteLength: bytes.byteLength, data }));
133
+ }
134
+ return lines;
135
+ }
@@ -9,8 +9,11 @@ import { spawn } from "node:child_process";
9
9
  import { EventEmitter } from "node:events";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { createInterface } from "node:readline";
12
+ import { RpcFrameDecoder, supportsProtocolV2 } from "./rpc-frame.mjs";
12
13
 
13
14
  const START_TIMEOUT_MS = 45_000;
15
+ /** How long omp gets to leave after `SIGINT`, and again after `SIGTERM`, when the daemon closes it. */
16
+ const CLOSE_WAIT_MS = 10_000;
14
17
 
15
18
  /**
16
19
  * The environment without `BUN_BE_BUN`: /mobile starts the daemon under omp's
@@ -21,6 +24,38 @@ function withoutBunFlag(env) {
21
24
  return rest;
22
25
  }
23
26
  const REQUEST_TIMEOUT_MS = 60_000;
27
+ /** Messages per `get_messages_page` request: OMP's maximum; a page also stays under 768 KiB. */
28
+ const MESSAGE_PAGE_LIMIT = 256;
29
+ /** OMP's `get_messages_page` codes that mean "page later or read it whole", as OMP's own client treats them. */
30
+ const PAGE_FALLBACK_CODES = new Set(["session_busy", "stale_cursor"]);
31
+ const PAGE_FALLBACK_MESSAGES = new Set([
32
+ "Cannot page messages while the session is changing",
33
+ "RPC message cursor is stale",
34
+ ]);
35
+
36
+ /**
37
+ * A failed RPC command: OMP's error text, and its machine-readable `code` when it sent one.
38
+ */
39
+ export class RpcCommandError extends Error {
40
+ /**
41
+ * @param {string} message
42
+ * @param {string} command
43
+ * @param {string | undefined} code
44
+ */
45
+ constructor(message, command, code) {
46
+ super(message);
47
+ this.name = "RpcCommandError";
48
+ this.command = command;
49
+ this.code = code;
50
+ }
51
+ }
52
+
53
+ /** Whether a paging failure should fall back to one whole `get_messages`. */
54
+ function pageFallback(error) {
55
+ if (!(error instanceof Error)) return false;
56
+ if (error instanceof RpcCommandError && error.code !== undefined && PAGE_FALLBACK_CODES.has(error.code)) return true;
57
+ return PAGE_FALLBACK_MESSAGES.has(error.message);
58
+ }
24
59
 
25
60
  /** Events that carry no useful state to the phone. */
26
61
  const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_text"]);
@@ -88,6 +123,9 @@ export class RpcSession extends EventEmitter {
88
123
  this.lastState = null;
89
124
  this.lastError = null;
90
125
  this.stderr = "";
126
+ /** 1 until OMP accepts `negotiate_protocol` v2; then frames above 1 MiB arrive as chunks. */
127
+ this.protocolVersion = 1;
128
+ this.frames = new RpcFrameDecoder();
91
129
  }
92
130
 
93
131
  /** Start the process and resolve once it printed `ready`. */
@@ -132,7 +170,14 @@ export class RpcSession extends EventEmitter {
132
170
  });
133
171
  });
134
172
  lines.on("line", (line) => this.#onLine(line));
135
- await ready;
173
+ const hello = await ready;
174
+ // v1 caps every frame at 1 MiB: a long session's messages or a large tool
175
+ // result would come back as a transport-limit error. v2 chunks them instead.
176
+ if (supportsProtocolV2(hello)) {
177
+ const response = await this.request("negotiate_protocol", { protocolVersion: 2 });
178
+ if (response.data?.protocolVersion !== 2) throw new Error("omp RPC protocol v2 negotiation failed");
179
+ this.protocolVersion = 2;
180
+ }
136
181
  this.started = true;
137
182
  return this;
138
183
  }
@@ -141,11 +186,13 @@ export class RpcSession extends EventEmitter {
141
186
  #onLine(line) {
142
187
  let message;
143
188
  try {
144
- message = JSON.parse(line);
189
+ message = this.frames.push(JSON.parse(line));
145
190
  } catch {
146
191
  this.emit("protocol-error", { line: line.slice(0, 500) });
147
192
  return;
148
193
  }
194
+ // A chunk of a larger frame: the frame is handled once its last chunk arrives.
195
+ if (message === undefined) return;
149
196
  this.updatedAt = Date.now();
150
197
  if (message.type === "ready") {
151
198
  this.emit("ready", message);
@@ -155,7 +202,9 @@ export class RpcSession extends EventEmitter {
155
202
  const pending = message.id ? this.pending.get(message.id) : undefined;
156
203
  if (pending !== undefined && message.id) {
157
204
  this.pending.delete(message.id);
158
- if (message.success === false) pending.reject(new Error(message.error || `RPC ${message.command} failed`));
205
+ if (message.success === false) {
206
+ pending.reject(new RpcCommandError(message.error || `RPC ${message.command} failed`, message.command, message.code));
207
+ }
159
208
  else pending.resolve(message);
160
209
  return;
161
210
  }
@@ -252,12 +301,50 @@ export class RpcSession extends EventEmitter {
252
301
  return data;
253
302
  }
254
303
 
255
- /** @returns {Promise<{messages: object[]}>} */
304
+ /**
305
+ * The session's messages. Under protocol v2 they are read in pages, as OMP's
306
+ * own RPC client does: one `get_messages` answer is a single frame, which a
307
+ * long uncompacted session outgrows. A page that cannot be read now (the
308
+ * session is changing, or the cursor went stale) falls back to the whole list
309
+ * in one response, which v2 may chunk.
310
+ * @returns {Promise<{messages: object[]}>}
311
+ */
256
312
  async getMessages() {
313
+ // As OMP's own client: pages only once v2 is negotiated (an OMP that offers v2 pages).
314
+ if (this.protocolVersion === 2) {
315
+ try {
316
+ return { messages: await this.#drainPages() };
317
+ } catch (error) {
318
+ if (!pageFallback(error)) throw error;
319
+ }
320
+ }
257
321
  const response = await this.request("get_messages");
258
322
  return response.data ?? { messages: [] };
259
323
  }
260
324
 
325
+ async #drainPages() {
326
+ const messages = [];
327
+ const seen = new Set();
328
+ /** @type {number | undefined} */
329
+ let total;
330
+ /** @type {string | undefined} */
331
+ let cursor;
332
+ do {
333
+ const response = await this.request("get_messages_page", cursor === undefined ? { limit: MESSAGE_PAGE_LIMIT } : { cursor, limit: MESSAGE_PAGE_LIMIT });
334
+ const page = response.data ?? {};
335
+ if (!Number.isSafeInteger(page.totalMessages) || page.totalMessages < 0 || (total !== undefined && page.totalMessages !== total)) {
336
+ throw new Error("omp message pages returned an inconsistent total");
337
+ }
338
+ total = page.totalMessages;
339
+ if (Array.isArray(page.messages)) messages.push(...page.messages);
340
+ cursor = typeof page.nextCursor === "string" && page.nextCursor !== "" ? page.nextCursor : undefined;
341
+ if (cursor !== undefined && seen.has(cursor)) throw new Error("omp message pages repeated a cursor");
342
+ if (cursor !== undefined) seen.add(cursor);
343
+ } while (cursor !== undefined);
344
+ if (messages.length !== total) throw new Error("omp message pages ended before the advertised total");
345
+ return messages;
346
+ }
347
+
261
348
  /**
262
349
  * The models this session can switch to, compacted for a phone list.
263
350
  * OMP waits out background discovery first, so this can take a few seconds.
@@ -293,9 +380,12 @@ export class RpcSession extends EventEmitter {
293
380
  return this.getState();
294
381
  }
295
382
 
296
- /** @param {string} text */
297
- async prompt(text) {
298
- return this.request("prompt", { message: text });
383
+ /**
384
+ * @param {string} text
385
+ * @param {{type: "image", data: string, mimeType: string}[]} [images] OMP `ImageContent` parts sent with the text.
386
+ */
387
+ async prompt(text, images = []) {
388
+ return this.request("prompt", images.length > 0 ? { message: text, images } : { message: text });
299
389
  }
300
390
 
301
391
  async abort() {
@@ -315,16 +405,47 @@ export class RpcSession extends EventEmitter {
315
405
  this.#settle(id, answer.cancelled === true ? "cancelled" : "answered");
316
406
  }
317
407
 
318
- close() {
319
- if (this.closed) return;
408
+ /**
409
+ * Stop the omp child the way Ctrl+C does: `SIGINT`, then `SIGTERM`, each
410
+ * followed by a wait. OMP routes both through its teardown (the turn is
411
+ * aborted, the session file flushed, its lock released). Never `SIGKILL`: a
412
+ * process cut off mid-write can leave a session half saved. Resolves to how
413
+ * it left, or `"running"` when it ignored both signals.
414
+ * @param {{sigint?: number, sigterm?: number}} [waits] milliseconds after each signal
415
+ * @returns {Promise<"exited" | "SIGINT" | "SIGTERM" | "running">}
416
+ */
417
+ close(waits = {}) {
418
+ if (this.closing !== undefined) return this.closing;
320
419
  this.closed = true;
321
420
  for (const pending of this.pending.values()) pending.reject(new Error("omp session closed"));
322
421
  this.pending.clear();
323
- this.child?.kill("SIGTERM");
324
- setTimeout(() => {
325
- if (this.child !== undefined && this.child.exitCode === null) this.child.kill("SIGKILL");
326
- }, 2_000).unref();
422
+ const child = this.child;
423
+ this.closing = (async () => {
424
+ if (child === undefined || child.exitCode !== null || child.signalCode !== null) return "exited";
425
+ const exited = new Promise((resolveExit) => child.once("exit", () => resolveExit(true)));
426
+ for (const [signal, wait] of /** @type {const} */ ([
427
+ ["SIGINT", waits.sigint ?? CLOSE_WAIT_MS],
428
+ ["SIGTERM", waits.sigterm ?? CLOSE_WAIT_MS],
429
+ ])) {
430
+ try {
431
+ child.kill(signal);
432
+ } catch {
433
+ return "exited";
434
+ }
435
+ let timer;
436
+ const left = await Promise.race([
437
+ exited,
438
+ new Promise((resolveWait) => {
439
+ timer = setTimeout(() => resolveWait(false), wait);
440
+ }),
441
+ ]);
442
+ clearTimeout(timer);
443
+ if (left) return signal;
444
+ }
445
+ return "running";
446
+ })();
447
+ return this.closing;
327
448
  }
328
449
  }
329
450
 
330
- export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS };
451
+ export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS, MESSAGE_PAGE_LIMIT, CLOSE_WAIT_MS };
package/src/server.mjs CHANGED
@@ -18,7 +18,8 @@ import { renderQr } from "./qr.mjs";
18
18
  import { RpcSession } from "./rpc-session.mjs";
19
19
  import { Admission, Tailscale, isLoopbackHost } from "./admission.mjs";
20
20
  import { AttentionTracker, serveEvents } from "./attention.mjs";
21
- import { listOmpSessions, promptTitle, sessionOwners } from "./omp-sessions.mjs";
21
+ import { listOmpSessions, oneLine, promptTitle, sessionOwners } from "./omp-sessions.mjs";
22
+ import { askOwnerToLeave, forksOf, isOmpCommand, processInfo, sessionBusy, takeoverFarewell, tellTerminal } from "./takeover.mjs";
22
23
 
23
24
  const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
24
25
  const PROTOCOL = 1;
@@ -42,14 +43,60 @@ const STATE_EVENTS = new Set([
42
43
  "todo_auto_clear",
43
44
  "goal_updated",
44
45
  ]);
46
+ /** Image types a phone may attach to a prompt: what the camera and the photo picker produce. */
47
+ const PROMPT_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
48
+ /** At most this many images per prompt, each at most this many decoded bytes. */
49
+ const MAX_PROMPT_IMAGES = 8;
50
+ const MAX_PROMPT_IMAGE_BYTES = 10 * 1024 * 1024;
51
+
52
+ /**
53
+ * The images of a `prompt` frame as OMP's `ImageContent`, checked: base64 that
54
+ * round-trips, a known image type, bounded count and size. Anything else is
55
+ * refused whole rather than sent half.
56
+ * @param {unknown} value
57
+ * @returns {{type: "image", data: string, mimeType: string}[]}
58
+ */
59
+ export function promptImages(value) {
60
+ if (value === undefined || value === null) return [];
61
+ if (!Array.isArray(value)) throw new Error("images must be a list");
62
+ if (value.length > MAX_PROMPT_IMAGES) throw new Error(`at most ${MAX_PROMPT_IMAGES} images per message`);
63
+ return value.map((image, index) => {
64
+ const data = image?.data;
65
+ const mimeType = image?.mimeType;
66
+ if (typeof mimeType !== "string" || !PROMPT_IMAGE_TYPES.has(mimeType)) throw new Error(`image ${index + 1}: unsupported type`);
67
+ if (typeof data !== "string" || data === "") throw new Error(`image ${index + 1}: no data`);
68
+ const bytes = Buffer.from(data, "base64");
69
+ if (bytes.byteLength === 0 || bytes.toString("base64") !== data) throw new Error(`image ${index + 1}: data is not base64`);
70
+ if (bytes.byteLength > MAX_PROMPT_IMAGE_BYTES) throw new Error(`image ${index + 1}: larger than ${MAX_PROMPT_IMAGE_BYTES / 1024 / 1024} MB`);
71
+ return { type: "image", data, mimeType };
72
+ });
73
+ }
74
+
75
+ /**
76
+ * What this daemon supports beyond protocol 1, for an app to offer only what
77
+ * the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
78
+ * `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds.
79
+ */
80
+ const FEATURES = ["prompt-images", "takeover"];
81
+ /** Who a phone continues a session on, as the terminal it leaves is told. */
82
+ const DEFAULT_DEVICE = "OMP Mobile";
83
+ /** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
84
+ const OPEN_FAILED_CLOSE_CODE = 4000;
45
85
  /** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
46
86
  const BLOB_NAME = /^[0-9a-f]{64}$/;
47
87
 
48
- /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
88
+ /** @typedef {{pid: number, busy: boolean, terminal: boolean}} SessionOwner */
89
+ /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, owner?: SessionOwner, waiting?: number}} SessionSummary */
49
90
 
50
- /** An error with the HTTP status it answers. */
51
- function httpError(status, message) {
52
- return Object.assign(new Error(message), { status });
91
+ /** An error with the HTTP status it answers, and any detail its JSON answer carries. */
92
+ function httpError(status, message, detail = {}) {
93
+ return Object.assign(new Error(message), { status, detail });
94
+ }
95
+
96
+ /** The JSON answer for a failed request: the reason and the error's detail (a 409's `owner`). */
97
+ function errorBody(error) {
98
+ const detail = error !== null && typeof error === "object" && typeof error.detail === "object" ? error.detail : {};
99
+ return { error: error instanceof Error ? error.message : String(error), ...detail };
53
100
  }
54
101
 
55
102
  function parseArgs(argv) {
@@ -300,10 +347,10 @@ function readBody(req) {
300
347
  });
301
348
  }
302
349
 
303
- function titleOf(messages, state, cwd) {
304
- if (typeof state?.sessionName === "string" && state.sessionName.trim() !== "") {
305
- return state.sessionName.trim();
306
- }
350
+ export function titleOf(messages, state, cwd) {
351
+ // OMP's session names can start with a Nerd Font icon, as its stored titles do (see `oneLine`).
352
+ const named = typeof state?.sessionName === "string" ? oneLine(state.sessionName) : "";
353
+ if (named !== "") return named;
307
354
  if (Array.isArray(messages)) {
308
355
  const title = promptTitle(messages.find((message) => message?.role === "user")?.content);
309
356
  if (title !== "") return title;
@@ -364,6 +411,8 @@ class SessionEntry {
364
411
  updatedAt: this.updatedAt,
365
412
  sessionFile: this.state?.sessionFile,
366
413
  origin: "daemon",
414
+ // OMP questions and tool approvals still waiting for an answer: the session needs the user.
415
+ waiting: this.openRequests().length,
367
416
  };
368
417
  }
369
418
 
@@ -434,10 +483,11 @@ class SessionEntry {
434
483
  return this.rpc.extensionRequests instanceof Map ? [...this.rpc.extensionRequests.values()] : [];
435
484
  }
436
485
 
486
+ /** Disconnect the phones and stop omp gracefully (see `RpcSession.close`). */
437
487
  close() {
438
488
  this.closed = true;
439
- this.rpc.close();
440
489
  for (const client of this.clients) client.close(1001, "session closed");
490
+ return this.rpc.close();
441
491
  }
442
492
  }
443
493
 
@@ -451,6 +501,11 @@ class Daemon {
451
501
  constructor(options, token, identity = {}) {
452
502
  this.options = options;
453
503
  this.token = token;
504
+ /**
505
+ * Where failures nothing else reports go: the daemon's output is `~/.omp-mobile/daemon.log`.
506
+ * @type {(error: unknown) => void}
507
+ */
508
+ this.log = options.log ?? ((error) => console.error(`${new Date().toISOString()} ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`));
454
509
  this.admission = identity.admission;
455
510
  this.user = identity.user ?? "";
456
511
  this.startedAt = Date.now();
@@ -468,6 +523,8 @@ class Daemon {
468
523
  this.ompSessions = new Map();
469
524
  /** Resumes in flight, so two opens of one session start one process. @type {Map<string, Promise<SessionEntry>>} */
470
525
  this.starting = new Map();
526
+ /** Takeovers in flight, so two phones asking at once ask the owner once. @type {Map<string, Promise<object>>} */
527
+ this.takingOver = new Map();
471
528
  this.server = createServer((req, res) => void this.admitHttp(req, res));
472
529
  this.wsServer = new WebSocketServer({ noServer: true });
473
530
  this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
@@ -530,6 +587,7 @@ class Daemon {
530
587
  sessionCount: this.sessions.size,
531
588
  cwd: this.options.cwd,
532
589
  user: this.user || null,
590
+ features: FEATURES,
533
591
  });
534
592
  return;
535
593
  }
@@ -580,6 +638,13 @@ class Daemon {
580
638
  sendJson(res, 201, { session: entry.summary() });
581
639
  return;
582
640
  }
641
+ const takeover = /^\/sessions\/([^/]+)\/takeover$/.exec(url.pathname);
642
+ if (takeover !== null && req.method === "POST") {
643
+ const body = await readBody(req);
644
+ const device = typeof body.device === "string" && body.device.trim() !== "" ? body.device.trim().slice(0, 60) : DEFAULT_DEVICE;
645
+ sendJson(res, 200, await this.takeOver(decodeURIComponent(takeover[1]), device));
646
+ return;
647
+ }
583
648
  const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
584
649
  if (match !== null) {
585
650
  const id = decodeURIComponent(match[1]);
@@ -601,9 +666,10 @@ class Daemon {
601
666
  }
602
667
  sendJson(res, 404, { error: "not found" });
603
668
  } catch (error) {
604
- sendJson(res, typeof error?.status === "number" ? error.status : 500, {
605
- error: error instanceof Error ? error.message : String(error),
606
- });
669
+ const status = typeof error?.status === "number" ? error.status : 500;
670
+ // A refusal the API defines (404, 409, 410) is an answer; anything else is a failure worth keeping.
671
+ if (status >= 500) this.log(error);
672
+ sendJson(res, status, errorBody(error));
607
673
  }
608
674
  }
609
675
 
@@ -627,12 +693,26 @@ class Daemon {
627
693
  const others = found
628
694
  .filter((session) => !known.has(session.id))
629
695
  .map((session) => ({ ...session, status: "idle", origin: "omp" }));
630
- const marked = [...dormant, ...others].map((session) =>
631
- owners?.has(session.id) === true ? { ...session, openElsewhere: true } : session,
696
+ const marked = await Promise.all(
697
+ [...dormant, ...others].map(async (session) => {
698
+ const pid = owners?.get(session.id);
699
+ if (pid === undefined) return session;
700
+ return { ...session, openElsewhere: true, owner: await this.ownerOf(pid, session.sessionFile) };
701
+ }),
632
702
  );
633
703
  return [...live, ...marked];
634
704
  }
635
705
 
706
+ /**
707
+ * What the phone is told about the process holding a session: whether it is
708
+ * mid-turn (by the session file's last message) and runs in a terminal.
709
+ * @returns {Promise<SessionSummary["owner"]>}
710
+ */
711
+ async ownerOf(pid, sessionFile) {
712
+ const info = await processInfo(pid);
713
+ return { pid, busy: typeof sessionFile === "string" && sessionBusy(sessionFile), terminal: info?.tty !== undefined };
714
+ }
715
+
636
716
  /** Which sessions some omp process holds open (`undefined`: cannot tell). */
637
717
  owners() {
638
718
  return sessionOwners(join(this.options.ompHome, "run", "session-owners"));
@@ -657,7 +737,9 @@ class Daemon {
657
737
  */
658
738
  ensureLive(id) {
659
739
  const live = this.sessions.get(id);
660
- if (live !== undefined) return Promise.resolve(live);
740
+ if (live !== undefined && !live.closed) return Promise.resolve(live);
741
+ // Its OMP process exited: resume it afresh instead of serving a dead session.
742
+ if (live !== undefined) this.sessions.delete(id);
661
743
  let starting = this.starting.get(id);
662
744
  if (starting === undefined) {
663
745
  starting = this.resume(id).finally(() => this.starting.delete(id));
@@ -674,7 +756,9 @@ class Daemon {
674
756
  // stays with the process that has it (a terminal) until that one exits.
675
757
  const owner = (await this.owners())?.get(id);
676
758
  if (owner !== undefined) {
677
- throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here.`);
759
+ throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here.`, {
760
+ owner: await this.ownerOf(owner, stored.sessionFile),
761
+ });
678
762
  }
679
763
  const rpc = new RpcSession({
680
764
  cwd: stored.cwd,
@@ -683,16 +767,73 @@ class Daemon {
683
767
  autoApprove: this.options.autoApprove,
684
768
  extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
685
769
  });
686
- await rpc.start();
687
- const entry = new SessionEntry(stored.cwd, rpc);
688
- await entry.hydrate();
689
- entry.id = id;
690
- entry.onChange = (changed) => this.remember(changed);
691
- entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
692
- entry.bind();
693
- this.sessions.set(id, entry);
694
- this.remember(entry);
695
- return entry;
770
+ // Until the session is registered the process is ours alone to close: left
771
+ // running it would hold the session's owner lock, so every later open would
772
+ // be refused as "open in another omp process" naming this very child.
773
+ try {
774
+ await rpc.start();
775
+ const entry = new SessionEntry(stored.cwd, rpc);
776
+ await entry.hydrate();
777
+ entry.id = id;
778
+ entry.onChange = (changed) => this.remember(changed);
779
+ entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
780
+ entry.bind();
781
+ this.sessions.set(id, entry);
782
+ this.remember(entry);
783
+ return entry;
784
+ } catch (error) {
785
+ rpc.close();
786
+ throw error;
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Continue a session another omp process holds: ask that process to leave
792
+ * (see `takeover.mjs`), then open the session here. Concurrent takeovers of
793
+ * one session share one attempt.
794
+ * @param {string} id
795
+ * @param {string} device Who continues it, as the terminal is told.
796
+ */
797
+ takeOver(id, device) {
798
+ let running = this.takingOver.get(id);
799
+ if (running === undefined) {
800
+ running = this.runTakeover(id, device).finally(() => this.takingOver.delete(id));
801
+ this.takingOver.set(id, running);
802
+ }
803
+ return running;
804
+ }
805
+
806
+ async runTakeover(id, device) {
807
+ const live = this.sessions.get(id);
808
+ if (live !== undefined && !live.closed) return { ...live.snapshot(), takeover: { pid: null, via: "live", waitedMs: 0, busy: false, forks: [] } };
809
+ // The phone may take over a session listed before this daemon started.
810
+ if (!this.registry.has(id) && !this.ompSessions.has(id)) await this.listSessions();
811
+ const stored = this.registry.get(id) ?? this.ompSessions.get(id);
812
+ if (stored === undefined || typeof stored.sessionFile !== "string") throw httpError(404, "unknown session");
813
+ if (!existsSync(stored.sessionFile)) throw httpError(410, "session file is gone");
814
+ const owners = await this.owners();
815
+ if (owners === undefined) throw httpError(503, "Cannot tell which process holds this session (lsof is not available).");
816
+ const pid = owners.get(id);
817
+ let outcome = { pid: null, via: "free", waitedMs: 0, busy: false, forks: [] };
818
+ if (pid !== undefined) {
819
+ const info = await processInfo(pid);
820
+ if (info !== undefined && !isOmpCommand(info.command)) {
821
+ throw httpError(409, `The process holding this session (pid ${pid}) is not omp; it is left alone.`);
822
+ }
823
+ const busy = sessionBusy(stored.sessionFile);
824
+ const since = Date.now();
825
+ const left = await askOwnerToLeave({ pid, sessionId: id, sessionFile: stored.sessionFile, device, timings: this.options.takeoverTimings });
826
+ // The kernel drops the lock with the process; give a slow exit a moment.
827
+ const deadline = Date.now() + 3_000;
828
+ while ((await this.owners())?.get(id) === pid && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 100));
829
+ // Whoever looks at the terminal sees why omp closed.
830
+ if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell(device));
831
+ const forks = forksOf(dirname(stored.sessionFile), id, since);
832
+ if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
833
+ outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
834
+ }
835
+ const entry = await this.ensureLive(id);
836
+ return { ...entry.snapshot(), takeover: outcome };
696
837
  }
697
838
 
698
839
  async createSession(cwd) {
@@ -703,19 +844,21 @@ class Daemon {
703
844
  autoApprove: this.options.autoApprove,
704
845
  extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
705
846
  });
706
- await rpc.start();
707
- const entry = new SessionEntry(cwd, rpc);
708
- await entry.hydrate();
709
- if (entry.id === "") {
847
+ try {
848
+ await rpc.start();
849
+ const entry = new SessionEntry(cwd, rpc);
850
+ await entry.hydrate();
851
+ if (entry.id === "") throw new Error("omp returned no session id");
852
+ entry.onChange = (changed) => this.remember(changed);
853
+ entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
854
+ entry.bind();
855
+ this.sessions.set(entry.id, entry);
856
+ this.remember(entry);
857
+ return entry;
858
+ } catch (error) {
710
859
  rpc.close();
711
- throw new Error("omp returned no session id");
860
+ throw error;
712
861
  }
713
- entry.onChange = (changed) => this.remember(changed);
714
- entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
715
- entry.bind();
716
- this.sessions.set(entry.id, entry);
717
- this.remember(entry);
718
- return entry;
719
862
  }
720
863
 
721
864
  handleUpgrade(req, socket, head) {
@@ -745,10 +888,15 @@ class Daemon {
745
888
  });
746
889
  })
747
890
  .catch((error) => {
748
- const status = typeof error?.status === "number" ? error.status : 404;
749
- socket.write(`HTTP/1.1 ${status} ${STATUS_CODES[status] ?? "Error"}\r\n\r\n`);
750
- socket.destroy();
751
- this.log?.(error);
891
+ const status = typeof error?.status === "number" ? error.status : 500;
892
+ if (status >= 500) this.log(error);
893
+ // A browser-style client only sees that the upgrade failed, never why. Finish
894
+ // the handshake instead, say why the session cannot open, and close: the app
895
+ // shows the reason and stops retrying what a retry cannot fix.
896
+ this.wsServer.handleUpgrade(req, socket, head, (ws) => {
897
+ ws.send(JSON.stringify({ type: "open_failed", status, ...errorBody(error) }));
898
+ ws.close(OPEN_FAILED_CLOSE_CODE, "session did not open");
899
+ });
752
900
  });
753
901
  }
754
902
 
@@ -763,8 +911,10 @@ class Daemon {
763
911
  try {
764
912
  switch (message.type) {
765
913
  case "prompt": {
766
- if (typeof message.text !== "string" || message.text.trim() === "") throw new Error("empty prompt");
767
- await entry.rpc.prompt(message.text);
914
+ const images = promptImages(message.images);
915
+ const text = typeof message.text === "string" ? message.text : "";
916
+ if (text.trim() === "" && images.length === 0) throw new Error("empty prompt");
917
+ await entry.rpc.prompt(text, images);
768
918
  ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
769
919
  return;
770
920
  }
@@ -822,7 +972,14 @@ class Daemon {
822
972
  throw new Error(`unknown command ${String(message.type)}`);
823
973
  }
824
974
  } catch (error) {
825
- ws.send(JSON.stringify({ type: "error", error: error instanceof Error ? error.message : String(error) }));
975
+ // `command` names the frame that failed, so the app can tell a refused prompt from any other error.
976
+ ws.send(
977
+ JSON.stringify({
978
+ type: "error",
979
+ command: typeof message?.type === "string" ? message.type : undefined,
980
+ error: error instanceof Error ? error.message : String(error),
981
+ }),
982
+ );
826
983
  }
827
984
  }
828
985
 
@@ -846,8 +1003,12 @@ class Daemon {
846
1003
  async close() {
847
1004
  for (const end of this.streams) end();
848
1005
  this.streams.clear();
849
- for (const entry of this.sessions.values()) entry.close();
1006
+ // Each omp saves and releases its session before the daemon exits.
1007
+ const closing = [...this.sessions.values()].map((entry) => entry.close());
850
1008
  this.sessions.clear();
1009
+ const outcomes = await Promise.all(closing);
1010
+ const stuck = outcomes.filter((outcome) => outcome === "running").length;
1011
+ if (stuck > 0) console.error(`omp-mobile: ${stuck} omp process(es) ignored SIGINT and SIGTERM and are still running`);
851
1012
  this.wsServer.close();
852
1013
  await new Promise((resolveClose) => this.server.close(() => resolveClose()));
853
1014
  }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Taking a session over from the other omp process that holds it.
3
+ *
4
+ * OMP lets one process write a session: the first write takes the session's
5
+ * owner lock (`<omp home>/run/session-owners/<id>.lock`, an OS lock the
6
+ * kernel drops when the process exits), and a second process that writes the
7
+ * same session moves to a new file (a fork with `parentSession` set) instead
8
+ * of mixing entries into the owner's journal. OMP has no way to attach a
9
+ * second process to a running one (Collab needs the owner to host a room on
10
+ * a relay), so the phone continues a session by asking its owner to leave:
11
+ *
12
+ * 1. A request file beside the session file (`.<name>.omp-mobile-takeover`)
13
+ * that the omp-mobile extension in the owner watches: it says where the
14
+ * session went, stops the turn, and exits through OMP's own shutdown.
15
+ * 2. Without an answer from the extension, the owner gets `SIGINT`, then
16
+ * `SIGTERM`: OMP routes both through the teardown its Ctrl+C runs (the
17
+ * turn is aborted, the session flushed and closed with a `session_exit`
18
+ * entry, the lock released). Never `SIGKILL`.
19
+ *
20
+ * Only the owner is ever signalled: a process that holds the session without
21
+ * owning it would fork it into a new file as it saves on exit.
22
+ */
23
+ import { execFile } from "node:child_process";
24
+ import { closeSync, constants, openSync, readdirSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
25
+ import { basename, dirname, join } from "node:path";
26
+
27
+ /** How long each step waits before the next, in milliseconds. */
28
+ export const TAKEOVER_TIMINGS = {
29
+ /** For the extension to pick up the request (it polls every second). */
30
+ ack: 2_500,
31
+ /** For an owner whose extension took the request to exit (OMP closes once its session is idle). */
32
+ extensionExit: 10_000,
33
+ /** After `SIGINT`, then after `SIGTERM`. */
34
+ sigint: 10_000,
35
+ sigterm: 10_000,
36
+ poll: 100,
37
+ };
38
+
39
+ /** How far from the end of a session file the last message is looked for. */
40
+ const TAIL_BYTES = 256 * 1024;
41
+
42
+ /** The request file the owner's extension watches. */
43
+ export function requestPath(sessionFile) {
44
+ return join(dirname(sessionFile), `.${basename(sessionFile)}.omp-mobile-takeover`);
45
+ }
46
+
47
+ /** Whether `pid` is a live process this user may signal. */
48
+ export function alive(pid) {
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ } catch (error) {
53
+ return error?.code === "EPERM";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * What a process is: its command and controlling terminal (`ttys007`), or
59
+ * `undefined` when there is no such process.
60
+ * @returns {Promise<{pid: number, command: string, tty: string | undefined} | undefined>}
61
+ */
62
+ export function processInfo(pid) {
63
+ return new Promise((resolveInfo) => {
64
+ execFile("ps", ["-o", "tty=", "-o", "args=", "-p", String(pid)], { timeout: 5_000 }, (error, stdout) => {
65
+ const line = stdout?.split("\n").find((text) => text.trim() !== "");
66
+ if (error || line === undefined) {
67
+ resolveInfo(undefined);
68
+ return;
69
+ }
70
+ const [, tty = "", command = ""] = /^\s*(\S+)\s+(.*)$/.exec(line) ?? [];
71
+ resolveInfo({ pid, command: command.trim(), tty: /^(\?+|-)$/.test(tty) || tty === "" ? undefined : tty });
72
+ });
73
+ });
74
+ }
75
+
76
+ /** Whether a command line runs omp: the binary, or an interpreter running a script named omp. */
77
+ export function isOmpCommand(command) {
78
+ const [program = "", script = ""] = command.trim().split(/\s+/);
79
+ return /(^|\/)omp$/.test(program) || /(^|\/)(node|bun)$/.test(program) && /(^|\/)omp$/.test(script);
80
+ }
81
+
82
+ /**
83
+ * Whether the session's last message leaves a turn running: a prompt or tool
84
+ * result not answered yet, or an assistant message that called tools. OMP
85
+ * writes the assistant's reply when it ends, so a streaming turn ends in the
86
+ * user's prompt.
87
+ * @param {string} sessionFile
88
+ */
89
+ export function sessionBusy(sessionFile) {
90
+ let fd;
91
+ try {
92
+ fd = openSync(sessionFile, "r");
93
+ const size = statSync(sessionFile).size;
94
+ const length = Math.min(size, TAIL_BYTES);
95
+ const buffer = Buffer.alloc(length);
96
+ readSync(fd, buffer, 0, length, size - length);
97
+ const lines = buffer.toString("utf8").split("\n");
98
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
99
+ let entry;
100
+ try {
101
+ entry = JSON.parse(lines[i]);
102
+ } catch {
103
+ continue;
104
+ }
105
+ if (entry?.type !== "message" || entry.message === null || typeof entry.message !== "object") continue;
106
+ const { role, stopReason } = entry.message;
107
+ if (role === "assistant") return stopReason === "toolUse";
108
+ return role === "user" || role === "toolResult";
109
+ }
110
+ return false;
111
+ } catch {
112
+ return false;
113
+ } finally {
114
+ if (fd !== undefined) closeSync(fd);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Session files in `dir` forked from `sessionId` (their header's
120
+ * `parentSession`) and written since `since`: what a second writer leaves.
121
+ */
122
+ export function forksOf(dir, sessionId, since) {
123
+ let names;
124
+ try {
125
+ names = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
126
+ } catch {
127
+ return [];
128
+ }
129
+ const forks = [];
130
+ for (const name of names) {
131
+ const file = join(dir, name);
132
+ let fd;
133
+ try {
134
+ if (statSync(file).mtimeMs < since) continue;
135
+ fd = openSync(file, "r");
136
+ const buffer = Buffer.alloc(64 * 1024);
137
+ const head = buffer.toString("utf8", 0, readSync(fd, buffer, 0, buffer.length, 0));
138
+ for (const line of head.split("\n").slice(0, 3)) {
139
+ try {
140
+ const entry = JSON.parse(line);
141
+ if (entry?.type === "session" && entry.parentSession === sessionId) forks.push(file);
142
+ } catch {
143
+ // Not a header line.
144
+ }
145
+ }
146
+ } catch {
147
+ // Removed while looking.
148
+ } finally {
149
+ if (fd !== undefined) closeSync(fd);
150
+ }
151
+ }
152
+ return forks;
153
+ }
154
+
155
+ /**
156
+ * Write a line to a terminal (`ttys007`) the owner ran in, after it left, so
157
+ * whoever looks at it sees why omp closed. Best effort.
158
+ */
159
+ export function tellTerminal(tty, text) {
160
+ if (!/^tty[A-Za-z0-9]+$/.test(tty)) return false;
161
+ let fd;
162
+ try {
163
+ fd = openSync(`/dev/${tty}`, constants.O_WRONLY | constants.O_NOCTTY | constants.O_NONBLOCK);
164
+ writeSync(fd, `\r\n${text}\r\n`);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ } finally {
169
+ if (fd !== undefined) closeSync(fd);
170
+ }
171
+ }
172
+
173
+ /** What the terminal is told once omp left it. */
174
+ export function takeoverFarewell(device) {
175
+ return `omp-mobile: this session continues on ${device}. omp closed here so the phone could take it over.`;
176
+ }
177
+
178
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
179
+
180
+ /** Wait until `done()` or `ms` passed; whether it became true. */
181
+ async function waitFor(done, ms, poll) {
182
+ const deadline = Date.now() + ms;
183
+ while (!done()) {
184
+ if (Date.now() >= deadline) return false;
185
+ await sleep(poll);
186
+ }
187
+ return true;
188
+ }
189
+
190
+ function exists(path) {
191
+ try {
192
+ statSync(path);
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Ask the owner of a session to leave, escalating until it exited.
201
+ * Resolves how it left; rejects (status 504) when it is still running after
202
+ * `SIGTERM`, in which case it still has the session.
203
+ * @param {{pid: number, sessionId: string, sessionFile: string, device: string, timings?: Partial<typeof TAKEOVER_TIMINGS>, kill?: (pid: number, signal: string) => void, isAlive?: (pid: number) => boolean}} options
204
+ * @returns {Promise<{via: "extension" | "SIGINT" | "SIGTERM" | "exited", waitedMs: number}>}
205
+ */
206
+ export async function askOwnerToLeave({ pid, sessionId, sessionFile, device, timings = {}, kill = process.kill.bind(process), isAlive = alive }) {
207
+ const t = { ...TAKEOVER_TIMINGS, ...timings };
208
+ const started = Date.now();
209
+ const gone = () => !isAlive(pid);
210
+ const result = (via) => ({ via, waitedMs: Date.now() - started });
211
+ if (gone()) return result("exited");
212
+
213
+ const request = requestPath(sessionFile);
214
+ const staging = `${request}.${process.pid}.tmp`;
215
+ let written = false;
216
+ try {
217
+ writeFileSync(staging, `${JSON.stringify({ pid, sessionId, device, at: Date.now() })}\n`, { mode: 0o600 });
218
+ renameSync(staging, request);
219
+ written = true;
220
+ } catch {
221
+ rmSync(staging, { force: true });
222
+ }
223
+ try {
224
+ // The extension removes the request when it takes it, then shuts omp down.
225
+ if (written && (await waitFor(() => gone() || !exists(request), t.ack, t.poll))) {
226
+ const taken = !exists(request);
227
+ if (await waitFor(gone, taken ? t.extensionExit : 0, t.poll)) return result(taken ? "extension" : "exited");
228
+ }
229
+ } finally {
230
+ rmSync(request, { force: true });
231
+ }
232
+ for (const signal of ["SIGINT", "SIGTERM"]) {
233
+ try {
234
+ kill(pid, signal);
235
+ } catch (error) {
236
+ if (error?.code === "ESRCH") return result("exited");
237
+ throw error;
238
+ }
239
+ if (await waitFor(gone, signal === "SIGINT" ? t.sigint : t.sigterm, t.poll)) return result(signal);
240
+ }
241
+ throw Object.assign(new Error(`The omp process (pid ${pid}) did not exit after SIGINT and SIGTERM; it still has this session.`), {
242
+ status: 504,
243
+ });
244
+ }