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 +4 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/rpc-frame.mjs +135 -0
- package/src/rpc-session.mjs +96 -8
- package/src/server.mjs +104 -33
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
|
|
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
|
@@ -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
|
+
}
|
package/src/rpc-session.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
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 :
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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
|
|