omp-mobile 0.1.1 → 0.3.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,11 +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.
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.
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.
17
21
  - **Approvals on the phone:** OMP's own tool approvals and questions arrive as a sheet, and the turn waits for your answer.
18
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.
19
23
  - **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
20
- - **All your machines:** Pair each computer by QR code, switch between them from the session list. A reopened chat shows at once, also offline.
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.
21
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.
22
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.
23
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",
@@ -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,6 +9,7 @@ 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;
14
15
 
@@ -21,6 +22,38 @@ function withoutBunFlag(env) {
21
22
  return rest;
22
23
  }
23
24
  const REQUEST_TIMEOUT_MS = 60_000;
25
+ /** Messages per `get_messages_page` request: OMP's maximum; a page also stays under 768 KiB. */
26
+ const MESSAGE_PAGE_LIMIT = 256;
27
+ /** OMP's `get_messages_page` codes that mean "page later or read it whole", as OMP's own client treats them. */
28
+ const PAGE_FALLBACK_CODES = new Set(["session_busy", "stale_cursor"]);
29
+ const PAGE_FALLBACK_MESSAGES = new Set([
30
+ "Cannot page messages while the session is changing",
31
+ "RPC message cursor is stale",
32
+ ]);
33
+
34
+ /**
35
+ * A failed RPC command: OMP's error text, and its machine-readable `code` when it sent one.
36
+ */
37
+ export class RpcCommandError extends Error {
38
+ /**
39
+ * @param {string} message
40
+ * @param {string} command
41
+ * @param {string | undefined} code
42
+ */
43
+ constructor(message, command, code) {
44
+ super(message);
45
+ this.name = "RpcCommandError";
46
+ this.command = command;
47
+ this.code = code;
48
+ }
49
+ }
50
+
51
+ /** Whether a paging failure should fall back to one whole `get_messages`. */
52
+ function pageFallback(error) {
53
+ if (!(error instanceof Error)) return false;
54
+ if (error instanceof RpcCommandError && error.code !== undefined && PAGE_FALLBACK_CODES.has(error.code)) return true;
55
+ return PAGE_FALLBACK_MESSAGES.has(error.message);
56
+ }
24
57
 
25
58
  /** Events that carry no useful state to the phone. */
26
59
  const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_text"]);
@@ -35,6 +68,36 @@ const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_te
35
68
  * @property {boolean} [autoApprove]
36
69
  */
37
70
 
71
+ /**
72
+ * One entry of OMP's model catalog, cut to what a phone lists: provider, name,
73
+ * context window and input kinds. The full object carries endpoints, cost and
74
+ * compatibility fields the app never shows.
75
+ * @param {Record<string, unknown>} model
76
+ */
77
+ export function compactModel(model) {
78
+ const thinking = model.thinking;
79
+ const cost = model.cost;
80
+ return {
81
+ provider: typeof model.provider === "string" ? model.provider : "",
82
+ id: typeof model.id === "string" ? model.id : "",
83
+ name: typeof model.name === "string" ? model.name : typeof model.id === "string" ? model.id : "",
84
+ reasoning: model.reasoning === true,
85
+ input: Array.isArray(model.input) ? model.input.filter((kind) => typeof kind === "string") : [],
86
+ contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : undefined,
87
+ cost:
88
+ cost !== null && typeof cost === "object"
89
+ ? {
90
+ input: typeof cost.input === "number" ? cost.input : undefined,
91
+ output: typeof cost.output === "number" ? cost.output : undefined,
92
+ }
93
+ : undefined,
94
+ efforts:
95
+ thinking !== null && typeof thinking === "object" && Array.isArray(thinking.efforts)
96
+ ? thinking.efforts.filter((level) => typeof level === "string")
97
+ : undefined,
98
+ };
99
+ }
100
+
38
101
  export class RpcSession extends EventEmitter {
39
102
  /** @param {RpcSessionOptions} options */
40
103
  constructor(options) {
@@ -58,6 +121,9 @@ export class RpcSession extends EventEmitter {
58
121
  this.lastState = null;
59
122
  this.lastError = null;
60
123
  this.stderr = "";
124
+ /** 1 until OMP accepts `negotiate_protocol` v2; then frames above 1 MiB arrive as chunks. */
125
+ this.protocolVersion = 1;
126
+ this.frames = new RpcFrameDecoder();
61
127
  }
62
128
 
63
129
  /** Start the process and resolve once it printed `ready`. */
@@ -102,7 +168,14 @@ export class RpcSession extends EventEmitter {
102
168
  });
103
169
  });
104
170
  lines.on("line", (line) => this.#onLine(line));
105
- await ready;
171
+ const hello = await ready;
172
+ // v1 caps every frame at 1 MiB: a long session's messages or a large tool
173
+ // result would come back as a transport-limit error. v2 chunks them instead.
174
+ if (supportsProtocolV2(hello)) {
175
+ const response = await this.request("negotiate_protocol", { protocolVersion: 2 });
176
+ if (response.data?.protocolVersion !== 2) throw new Error("omp RPC protocol v2 negotiation failed");
177
+ this.protocolVersion = 2;
178
+ }
106
179
  this.started = true;
107
180
  return this;
108
181
  }
@@ -111,11 +184,13 @@ export class RpcSession extends EventEmitter {
111
184
  #onLine(line) {
112
185
  let message;
113
186
  try {
114
- message = JSON.parse(line);
187
+ message = this.frames.push(JSON.parse(line));
115
188
  } catch {
116
189
  this.emit("protocol-error", { line: line.slice(0, 500) });
117
190
  return;
118
191
  }
192
+ // A chunk of a larger frame: the frame is handled once its last chunk arrives.
193
+ if (message === undefined) return;
119
194
  this.updatedAt = Date.now();
120
195
  if (message.type === "ready") {
121
196
  this.emit("ready", message);
@@ -125,7 +200,9 @@ export class RpcSession extends EventEmitter {
125
200
  const pending = message.id ? this.pending.get(message.id) : undefined;
126
201
  if (pending !== undefined && message.id) {
127
202
  this.pending.delete(message.id);
128
- if (message.success === false) pending.reject(new Error(message.error || `RPC ${message.command} failed`));
203
+ if (message.success === false) {
204
+ pending.reject(new RpcCommandError(message.error || `RPC ${message.command} failed`, message.command, message.code));
205
+ }
129
206
  else pending.resolve(message);
130
207
  return;
131
208
  }
@@ -222,15 +299,91 @@ export class RpcSession extends EventEmitter {
222
299
  return data;
223
300
  }
224
301
 
225
- /** @returns {Promise<{messages: object[]}>} */
302
+ /**
303
+ * The session's messages. Under protocol v2 they are read in pages, as OMP's
304
+ * own RPC client does: one `get_messages` answer is a single frame, which a
305
+ * long uncompacted session outgrows. A page that cannot be read now (the
306
+ * session is changing, or the cursor went stale) falls back to the whole list
307
+ * in one response, which v2 may chunk.
308
+ * @returns {Promise<{messages: object[]}>}
309
+ */
226
310
  async getMessages() {
311
+ // As OMP's own client: pages only once v2 is negotiated (an OMP that offers v2 pages).
312
+ if (this.protocolVersion === 2) {
313
+ try {
314
+ return { messages: await this.#drainPages() };
315
+ } catch (error) {
316
+ if (!pageFallback(error)) throw error;
317
+ }
318
+ }
227
319
  const response = await this.request("get_messages");
228
320
  return response.data ?? { messages: [] };
229
321
  }
230
322
 
231
- /** @param {string} text */
232
- async prompt(text) {
233
- return this.request("prompt", { message: text });
323
+ async #drainPages() {
324
+ const messages = [];
325
+ const seen = new Set();
326
+ /** @type {number | undefined} */
327
+ let total;
328
+ /** @type {string | undefined} */
329
+ let cursor;
330
+ do {
331
+ const response = await this.request("get_messages_page", cursor === undefined ? { limit: MESSAGE_PAGE_LIMIT } : { cursor, limit: MESSAGE_PAGE_LIMIT });
332
+ const page = response.data ?? {};
333
+ if (!Number.isSafeInteger(page.totalMessages) || page.totalMessages < 0 || (total !== undefined && page.totalMessages !== total)) {
334
+ throw new Error("omp message pages returned an inconsistent total");
335
+ }
336
+ total = page.totalMessages;
337
+ if (Array.isArray(page.messages)) messages.push(...page.messages);
338
+ cursor = typeof page.nextCursor === "string" && page.nextCursor !== "" ? page.nextCursor : undefined;
339
+ if (cursor !== undefined && seen.has(cursor)) throw new Error("omp message pages repeated a cursor");
340
+ if (cursor !== undefined) seen.add(cursor);
341
+ } while (cursor !== undefined);
342
+ if (messages.length !== total) throw new Error("omp message pages ended before the advertised total");
343
+ return messages;
344
+ }
345
+
346
+ /**
347
+ * The models this session can switch to, compacted for a phone list.
348
+ * OMP waits out background discovery first, so this can take a few seconds.
349
+ * @returns {Promise<ReturnType<typeof compactModel>[]>}
350
+ */
351
+ async availableModels() {
352
+ const response = await this.request("get_available_models");
353
+ const models = response.data?.models;
354
+ return Array.isArray(models) ? models.map(compactModel) : [];
355
+ }
356
+
357
+ /**
358
+ * Switch this session to another model. Returns the state afterwards: the
359
+ * model's thinking efforts decide which levels the phone may offer.
360
+ * @param {string} provider
361
+ * @param {string} modelId
362
+ */
363
+ async setModel(provider, modelId) {
364
+ await this.request("set_model", { provider, modelId });
365
+ return this.getState();
366
+ }
367
+
368
+ /** @returns {Promise<{levels: string[]}>} */
369
+ async availableThinkingLevels() {
370
+ const response = await this.request("get_available_thinking_levels");
371
+ const levels = response.data?.levels;
372
+ return { levels: Array.isArray(levels) ? levels.filter((level) => typeof level === "string") : [] };
373
+ }
374
+
375
+ /** @param {string} level */
376
+ async setThinkingLevel(level) {
377
+ await this.request("set_thinking_level", { level });
378
+ return this.getState();
379
+ }
380
+
381
+ /**
382
+ * @param {string} text
383
+ * @param {{type: "image", data: string, mimeType: string}[]} [images] OMP `ImageContent` parts sent with the text.
384
+ */
385
+ async prompt(text, images = []) {
386
+ return this.request("prompt", images.length > 0 ? { message: text, images } : { message: text });
234
387
  }
235
388
 
236
389
  async abort() {
@@ -262,4 +415,4 @@ export class RpcSession extends EventEmitter {
262
415
  }
263
416
  }
264
417
 
265
- export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS };
418
+ export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS, MESSAGE_PAGE_LIMIT };
package/src/server.mjs CHANGED
@@ -30,8 +30,58 @@ const DEFAULT_OMP_HOME = join(homedir(), ".omp");
30
30
  const MAX_BODY = 256 * 1024;
31
31
  /** Terminal sessions listed next to the daemon's own, newest first. */
32
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
+ /** Image types a phone may attach to a prompt: what the camera and the photo picker produce. */
46
+ const PROMPT_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
47
+ /** At most this many images per prompt, each at most this many decoded bytes. */
48
+ const MAX_PROMPT_IMAGES = 8;
49
+ const MAX_PROMPT_IMAGE_BYTES = 10 * 1024 * 1024;
33
50
 
34
- /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
51
+ /**
52
+ * The images of a `prompt` frame as OMP's `ImageContent`, checked: base64 that
53
+ * round-trips, a known image type, bounded count and size. Anything else is
54
+ * refused whole rather than sent half.
55
+ * @param {unknown} value
56
+ * @returns {{type: "image", data: string, mimeType: string}[]}
57
+ */
58
+ export function promptImages(value) {
59
+ if (value === undefined || value === null) return [];
60
+ if (!Array.isArray(value)) throw new Error("images must be a list");
61
+ if (value.length > MAX_PROMPT_IMAGES) throw new Error(`at most ${MAX_PROMPT_IMAGES} images per message`);
62
+ return value.map((image, index) => {
63
+ const data = image?.data;
64
+ const mimeType = image?.mimeType;
65
+ if (typeof mimeType !== "string" || !PROMPT_IMAGE_TYPES.has(mimeType)) throw new Error(`image ${index + 1}: unsupported type`);
66
+ if (typeof data !== "string" || data === "") throw new Error(`image ${index + 1}: no data`);
67
+ const bytes = Buffer.from(data, "base64");
68
+ if (bytes.byteLength === 0 || bytes.toString("base64") !== data) throw new Error(`image ${index + 1}: data is not base64`);
69
+ if (bytes.byteLength > MAX_PROMPT_IMAGE_BYTES) throw new Error(`image ${index + 1}: larger than ${MAX_PROMPT_IMAGE_BYTES / 1024 / 1024} MB`);
70
+ return { type: "image", data, mimeType };
71
+ });
72
+ }
73
+
74
+ /**
75
+ * What this daemon supports beyond protocol 1, for an app to offer only what
76
+ * the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`.
77
+ */
78
+ const FEATURES = ["prompt-images"];
79
+ /** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
80
+ const OPEN_FAILED_CLOSE_CODE = 4000;
81
+ /** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
82
+ const BLOB_NAME = /^[0-9a-f]{64}$/;
83
+
84
+ /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, waiting?: number}} SessionSummary */
35
85
 
36
86
  /** An error with the HTTP status it answers. */
37
87
  function httpError(status, message) {
@@ -225,6 +275,39 @@ function sendJson(res, status, body) {
225
275
  res.end(data);
226
276
  }
227
277
 
278
+ /** What a stored blob is, from its first bytes: OMP keeps the extension off the hash name. */
279
+ export function blobType(bytes) {
280
+ if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
281
+ return "image/webp";
282
+ }
283
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
284
+ return "image/png";
285
+ }
286
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
287
+ if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "GIF8") return "image/gif";
288
+ return "application/octet-stream";
289
+ }
290
+
291
+ /** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
292
+ function sendBlob(res, ompHome, name) {
293
+ if (!BLOB_NAME.test(name)) {
294
+ sendJson(res, 400, { error: "bad blob name" });
295
+ return;
296
+ }
297
+ const path = join(ompHome, "agent", "blobs", name);
298
+ if (!existsSync(path) || !statSync(path).isFile()) {
299
+ sendJson(res, 404, { error: "no such blob" });
300
+ return;
301
+ }
302
+ const bytes = readFileSync(path);
303
+ res.writeHead(200, {
304
+ "content-type": blobType(bytes),
305
+ "content-length": bytes.length,
306
+ "cache-control": "private, max-age=31536000, immutable",
307
+ });
308
+ res.end(bytes);
309
+ }
310
+
228
311
  function readBody(req) {
229
312
  return new Promise((resolveBody, reject) => {
230
313
  let size = 0;
@@ -317,6 +400,8 @@ class SessionEntry {
317
400
  updatedAt: this.updatedAt,
318
401
  sessionFile: this.state?.sessionFile,
319
402
  origin: "daemon",
403
+ // OMP questions and tool approvals still waiting for an answer: the session needs the user.
404
+ waiting: this.openRequests().length,
320
405
  };
321
406
  }
322
407
 
@@ -359,6 +444,9 @@ class SessionEntry {
359
444
  this.onChange?.(this);
360
445
  }
361
446
  }
447
+ if (STATE_EVENTS.has(event.type)) void this.refreshState();
448
+ // A `todo` tool call rewrites the list before any reminder fires.
449
+ if (event.type === "tool_execution_end" && event.toolName === "todo") void this.refreshState();
362
450
  if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
363
451
  this.broadcast({ type: "event", event });
364
452
  });
@@ -401,6 +489,11 @@ class Daemon {
401
489
  constructor(options, token, identity = {}) {
402
490
  this.options = options;
403
491
  this.token = token;
492
+ /**
493
+ * Where failures nothing else reports go: the daemon's output is `~/.omp-mobile/daemon.log`.
494
+ * @type {(error: unknown) => void}
495
+ */
496
+ this.log = options.log ?? ((error) => console.error(`${new Date().toISOString()} ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`));
404
497
  this.admission = identity.admission;
405
498
  this.user = identity.user ?? "";
406
499
  this.startedAt = Date.now();
@@ -480,6 +573,7 @@ class Daemon {
480
573
  sessionCount: this.sessions.size,
481
574
  cwd: this.options.cwd,
482
575
  user: this.user || null,
576
+ features: FEATURES,
483
577
  });
484
578
  return;
485
579
  }
@@ -488,6 +582,11 @@ class Daemon {
488
582
  return;
489
583
  }
490
584
  this.noteClient(req);
585
+ const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
586
+ if (blob !== null && req.method === "GET") {
587
+ sendBlob(res, this.options.ompHome, blob[1]);
588
+ return;
589
+ }
491
590
  if (url.pathname === "/status" && req.method === "GET") {
492
591
  sendJson(res, 200, {
493
592
  pid: process.pid,
@@ -546,9 +645,10 @@ class Daemon {
546
645
  }
547
646
  sendJson(res, 404, { error: "not found" });
548
647
  } catch (error) {
549
- sendJson(res, typeof error?.status === "number" ? error.status : 500, {
550
- error: error instanceof Error ? error.message : String(error),
551
- });
648
+ const status = typeof error?.status === "number" ? error.status : 500;
649
+ // A refusal the API defines (404, 409, 410) is an answer; anything else is a failure worth keeping.
650
+ if (status >= 500) this.log(error);
651
+ sendJson(res, status, { error: error instanceof Error ? error.message : String(error) });
552
652
  }
553
653
  }
554
654
 
@@ -602,7 +702,9 @@ class Daemon {
602
702
  */
603
703
  ensureLive(id) {
604
704
  const live = this.sessions.get(id);
605
- if (live !== undefined) return Promise.resolve(live);
705
+ if (live !== undefined && !live.closed) return Promise.resolve(live);
706
+ // Its OMP process exited: resume it afresh instead of serving a dead session.
707
+ if (live !== undefined) this.sessions.delete(id);
606
708
  let starting = this.starting.get(id);
607
709
  if (starting === undefined) {
608
710
  starting = this.resume(id).finally(() => this.starting.delete(id));
@@ -628,16 +730,24 @@ class Daemon {
628
730
  autoApprove: this.options.autoApprove,
629
731
  extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
630
732
  });
631
- await rpc.start();
632
- const entry = new SessionEntry(stored.cwd, rpc);
633
- await entry.hydrate();
634
- entry.id = id;
635
- entry.onChange = (changed) => this.remember(changed);
636
- entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
637
- entry.bind();
638
- this.sessions.set(id, entry);
639
- this.remember(entry);
640
- return entry;
733
+ // Until the session is registered the process is ours alone to close: left
734
+ // running it would hold the session's owner lock, so every later open would
735
+ // be refused as "open in another omp process" naming this very child.
736
+ try {
737
+ await rpc.start();
738
+ const entry = new SessionEntry(stored.cwd, rpc);
739
+ await entry.hydrate();
740
+ entry.id = id;
741
+ entry.onChange = (changed) => this.remember(changed);
742
+ entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
743
+ entry.bind();
744
+ this.sessions.set(id, entry);
745
+ this.remember(entry);
746
+ return entry;
747
+ } catch (error) {
748
+ rpc.close();
749
+ throw error;
750
+ }
641
751
  }
642
752
 
643
753
  async createSession(cwd) {
@@ -648,19 +758,21 @@ class Daemon {
648
758
  autoApprove: this.options.autoApprove,
649
759
  extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
650
760
  });
651
- await rpc.start();
652
- const entry = new SessionEntry(cwd, rpc);
653
- await entry.hydrate();
654
- if (entry.id === "") {
761
+ try {
762
+ await rpc.start();
763
+ const entry = new SessionEntry(cwd, rpc);
764
+ await entry.hydrate();
765
+ if (entry.id === "") throw new Error("omp returned no session id");
766
+ entry.onChange = (changed) => this.remember(changed);
767
+ entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
768
+ entry.bind();
769
+ this.sessions.set(entry.id, entry);
770
+ this.remember(entry);
771
+ return entry;
772
+ } catch (error) {
655
773
  rpc.close();
656
- throw new Error("omp returned no session id");
774
+ throw error;
657
775
  }
658
- entry.onChange = (changed) => this.remember(changed);
659
- entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
660
- entry.bind();
661
- this.sessions.set(entry.id, entry);
662
- this.remember(entry);
663
- return entry;
664
776
  }
665
777
 
666
778
  handleUpgrade(req, socket, head) {
@@ -690,10 +802,15 @@ class Daemon {
690
802
  });
691
803
  })
692
804
  .catch((error) => {
693
- const status = typeof error?.status === "number" ? error.status : 404;
694
- socket.write(`HTTP/1.1 ${status} ${STATUS_CODES[status] ?? "Error"}\r\n\r\n`);
695
- socket.destroy();
696
- this.log?.(error);
805
+ const status = typeof error?.status === "number" ? error.status : 500;
806
+ if (status >= 500) this.log(error);
807
+ // A browser-style client only sees that the upgrade failed, never why. Finish
808
+ // the handshake instead, say why the session cannot open, and close: the app
809
+ // shows the reason and stops retrying what a retry cannot fix.
810
+ this.wsServer.handleUpgrade(req, socket, head, (ws) => {
811
+ ws.send(JSON.stringify({ type: "open_failed", status, error: error instanceof Error ? error.message : String(error) }));
812
+ ws.close(OPEN_FAILED_CLOSE_CODE, "session did not open");
813
+ });
697
814
  });
698
815
  }
699
816
 
@@ -708,8 +825,10 @@ class Daemon {
708
825
  try {
709
826
  switch (message.type) {
710
827
  case "prompt": {
711
- if (typeof message.text !== "string" || message.text.trim() === "") throw new Error("empty prompt");
712
- await entry.rpc.prompt(message.text);
828
+ const images = promptImages(message.images);
829
+ const text = typeof message.text === "string" ? message.text : "";
830
+ if (text.trim() === "" && images.length === 0) throw new Error("empty prompt");
831
+ await entry.rpc.prompt(text, images);
713
832
  ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
714
833
  return;
715
834
  }
@@ -730,11 +849,51 @@ class Daemon {
730
849
  case "refresh":
731
850
  await entry.refreshState();
732
851
  return;
852
+ case "models": {
853
+ const models = await entry.rpc.availableModels();
854
+ ws.send(JSON.stringify({ type: "models", models }));
855
+ return;
856
+ }
857
+ case "set_model": {
858
+ if (typeof message.provider !== "string" || typeof message.modelId !== "string") {
859
+ throw new Error("set_model needs provider and modelId");
860
+ }
861
+ const state = await entry.rpc.setModel(message.provider, message.modelId);
862
+ entry.state = state;
863
+ entry.updatedAt = Date.now();
864
+ entry.onChange?.(entry);
865
+ entry.broadcast({ type: "state", state });
866
+ ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
867
+ return;
868
+ }
869
+ case "thinking_levels": {
870
+ const { levels } = await entry.rpc.availableThinkingLevels();
871
+ ws.send(
872
+ JSON.stringify({ type: "thinking_levels", levels, current: entry.state?.thinkingLevel ?? null }),
873
+ );
874
+ return;
875
+ }
876
+ case "set_thinking_level": {
877
+ if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
878
+ const state = await entry.rpc.setThinkingLevel(message.level);
879
+ entry.state = state;
880
+ entry.updatedAt = Date.now();
881
+ entry.broadcast({ type: "state", state });
882
+ ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
883
+ return;
884
+ }
733
885
  default:
734
886
  throw new Error(`unknown command ${String(message.type)}`);
735
887
  }
736
888
  } catch (error) {
737
- ws.send(JSON.stringify({ type: "error", error: error instanceof Error ? error.message : String(error) }));
889
+ // `command` names the frame that failed, so the app can tell a refused prompt from any other error.
890
+ ws.send(
891
+ JSON.stringify({
892
+ type: "error",
893
+ command: typeof message?.type === "string" ? message.type : undefined,
894
+ error: error instanceof Error ? error.message : String(error),
895
+ }),
896
+ );
738
897
  }
739
898
  }
740
899