omp-mobile 0.2.0 → 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,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.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"]);
@@ -88,6 +121,9 @@ export class RpcSession extends EventEmitter {
88
121
  this.lastState = null;
89
122
  this.lastError = null;
90
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();
91
127
  }
92
128
 
93
129
  /** Start the process and resolve once it printed `ready`. */
@@ -132,7 +168,14 @@ export class RpcSession extends EventEmitter {
132
168
  });
133
169
  });
134
170
  lines.on("line", (line) => this.#onLine(line));
135
- 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
+ }
136
179
  this.started = true;
137
180
  return this;
138
181
  }
@@ -141,11 +184,13 @@ export class RpcSession extends EventEmitter {
141
184
  #onLine(line) {
142
185
  let message;
143
186
  try {
144
- message = JSON.parse(line);
187
+ message = this.frames.push(JSON.parse(line));
145
188
  } catch {
146
189
  this.emit("protocol-error", { line: line.slice(0, 500) });
147
190
  return;
148
191
  }
192
+ // A chunk of a larger frame: the frame is handled once its last chunk arrives.
193
+ if (message === undefined) return;
149
194
  this.updatedAt = Date.now();
150
195
  if (message.type === "ready") {
151
196
  this.emit("ready", message);
@@ -155,7 +200,9 @@ export class RpcSession extends EventEmitter {
155
200
  const pending = message.id ? this.pending.get(message.id) : undefined;
156
201
  if (pending !== undefined && message.id) {
157
202
  this.pending.delete(message.id);
158
- 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
+ }
159
206
  else pending.resolve(message);
160
207
  return;
161
208
  }
@@ -252,12 +299,50 @@ export class RpcSession extends EventEmitter {
252
299
  return data;
253
300
  }
254
301
 
255
- /** @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
+ */
256
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
+ }
257
319
  const response = await this.request("get_messages");
258
320
  return response.data ?? { messages: [] };
259
321
  }
260
322
 
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
+
261
346
  /**
262
347
  * The models this session can switch to, compacted for a phone list.
263
348
  * OMP waits out background discovery first, so this can take a few seconds.
@@ -293,9 +378,12 @@ export class RpcSession extends EventEmitter {
293
378
  return this.getState();
294
379
  }
295
380
 
296
- /** @param {string} text */
297
- async prompt(text) {
298
- return this.request("prompt", { message: text });
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 });
299
387
  }
300
388
 
301
389
  async abort() {
@@ -327,4 +415,4 @@ export class RpcSession extends EventEmitter {
327
415
  }
328
416
  }
329
417
 
330
- export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS };
418
+ export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS, MESSAGE_PAGE_LIMIT };
package/src/server.mjs CHANGED
@@ -42,10 +42,46 @@ const STATE_EVENTS = new Set([
42
42
  "todo_auto_clear",
43
43
  "goal_updated",
44
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;
50
+
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;
45
81
  /** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
46
82
  const BLOB_NAME = /^[0-9a-f]{64}$/;
47
83
 
48
- /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
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 */
49
85
 
50
86
  /** An error with the HTTP status it answers. */
51
87
  function httpError(status, message) {
@@ -364,6 +400,8 @@ class SessionEntry {
364
400
  updatedAt: this.updatedAt,
365
401
  sessionFile: this.state?.sessionFile,
366
402
  origin: "daemon",
403
+ // OMP questions and tool approvals still waiting for an answer: the session needs the user.
404
+ waiting: this.openRequests().length,
367
405
  };
368
406
  }
369
407
 
@@ -451,6 +489,11 @@ class Daemon {
451
489
  constructor(options, token, identity = {}) {
452
490
  this.options = options;
453
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)}`));
454
497
  this.admission = identity.admission;
455
498
  this.user = identity.user ?? "";
456
499
  this.startedAt = Date.now();
@@ -530,6 +573,7 @@ class Daemon {
530
573
  sessionCount: this.sessions.size,
531
574
  cwd: this.options.cwd,
532
575
  user: this.user || null,
576
+ features: FEATURES,
533
577
  });
534
578
  return;
535
579
  }
@@ -601,9 +645,10 @@ class Daemon {
601
645
  }
602
646
  sendJson(res, 404, { error: "not found" });
603
647
  } catch (error) {
604
- sendJson(res, typeof error?.status === "number" ? error.status : 500, {
605
- error: error instanceof Error ? error.message : String(error),
606
- });
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) });
607
652
  }
608
653
  }
609
654
 
@@ -657,7 +702,9 @@ class Daemon {
657
702
  */
658
703
  ensureLive(id) {
659
704
  const live = this.sessions.get(id);
660
- 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);
661
708
  let starting = this.starting.get(id);
662
709
  if (starting === undefined) {
663
710
  starting = this.resume(id).finally(() => this.starting.delete(id));
@@ -683,16 +730,24 @@ class Daemon {
683
730
  autoApprove: this.options.autoApprove,
684
731
  extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
685
732
  });
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;
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
+ }
696
751
  }
697
752
 
698
753
  async createSession(cwd) {
@@ -703,19 +758,21 @@ class Daemon {
703
758
  autoApprove: this.options.autoApprove,
704
759
  extraArgs: this.options.approvalMode ? ["--approval-mode", this.options.approvalMode] : [],
705
760
  });
706
- await rpc.start();
707
- const entry = new SessionEntry(cwd, rpc);
708
- await entry.hydrate();
709
- 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) {
710
773
  rpc.close();
711
- throw new Error("omp returned no session id");
774
+ throw error;
712
775
  }
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
776
  }
720
777
 
721
778
  handleUpgrade(req, socket, head) {
@@ -745,10 +802,15 @@ class Daemon {
745
802
  });
746
803
  })
747
804
  .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);
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
+ });
752
814
  });
753
815
  }
754
816
 
@@ -763,8 +825,10 @@ class Daemon {
763
825
  try {
764
826
  switch (message.type) {
765
827
  case "prompt": {
766
- if (typeof message.text !== "string" || message.text.trim() === "") throw new Error("empty prompt");
767
- 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);
768
832
  ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
769
833
  return;
770
834
  }
@@ -822,7 +886,14 @@ class Daemon {
822
886
  throw new Error(`unknown command ${String(message.type)}`);
823
887
  }
824
888
  } catch (error) {
825
- 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
+ );
826
897
  }
827
898
  }
828
899