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 +4 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/rpc-frame.mjs +135 -0
- package/src/rpc-session.mjs +161 -8
- package/src/server.mjs +192 -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,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
|
|
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
|
@@ -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"]);
|
|
@@ -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)
|
|
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
|
-
/**
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
|
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 :
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
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
|
|