omp-mobile 0.0.0-stage → 0.1.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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * One OMP process in RPC mode (`omp --mode rpc`).
3
+ *
4
+ * The daemon owns the process: commands go to stdin as JSON lines, responses
5
+ * and session events come back on stdout. Nothing here knows the native UI;
6
+ * `server.mjs` is the only place that decides what a phone may see.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { EventEmitter } from "node:events";
10
+ import { randomUUID } from "node:crypto";
11
+ import { createInterface } from "node:readline";
12
+
13
+ const START_TIMEOUT_MS = 45_000;
14
+ const REQUEST_TIMEOUT_MS = 60_000;
15
+
16
+ /** Events that carry no useful state to the phone. */
17
+ const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_text"]);
18
+
19
+ /**
20
+ * @typedef {object} RpcSessionOptions
21
+ * @property {string} cwd
22
+ * @property {string} [ompBin]
23
+ * @property {string[]} [extraArgs]
24
+ * @property {string} [sessionDir]
25
+ * @property {string} [resume]
26
+ * @property {boolean} [autoApprove]
27
+ */
28
+
29
+ export class RpcSession extends EventEmitter {
30
+ /** @param {RpcSessionOptions} options */
31
+ constructor(options) {
32
+ super();
33
+ this.cwd = options.cwd;
34
+ this.ompBin = options.ompBin ?? "omp";
35
+ this.extraArgs = options.extraArgs ?? [];
36
+ this.sessionDir = options.sessionDir;
37
+ this.resume = options.resume;
38
+ this.autoApprove = options.autoApprove === true;
39
+ /** @type {import("node:child_process").ChildProcessWithoutNullStreams | undefined} */
40
+ this.child = undefined;
41
+ /** @type {Map<string, {resolve: (value: any) => void, reject: (error: Error) => void}>} */
42
+ this.pending = new Map();
43
+ /** @type {Map<string, object>} */
44
+ this.extensionRequests = new Map();
45
+ this.started = false;
46
+ this.closed = false;
47
+ this.startedAt = Date.now();
48
+ this.updatedAt = Date.now();
49
+ this.lastState = null;
50
+ this.lastError = null;
51
+ this.stderr = "";
52
+ }
53
+
54
+ /** Start the process and resolve once it printed `ready`. */
55
+ async start() {
56
+ // rpc-ui gives tools an interactive UI: OMP's approval prompt reaches the
57
+ // phone as an extension `select` request instead of failing closed.
58
+ const args = ["--mode", "rpc-ui", "--cwd", this.cwd, ...this.extraArgs];
59
+ if (this.resume) args.push("--resume", this.resume);
60
+ if (this.autoApprove) args.push("--auto-approve");
61
+ if (this.sessionDir) args.push("--session-dir", this.sessionDir);
62
+ const child = spawn(this.ompBin, args, {
63
+ cwd: this.cwd,
64
+ env: { ...process.env, NO_COLOR: "1" },
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ });
67
+ this.child = child;
68
+ child.on("error", (error) => this.#fail(error));
69
+ child.on("exit", (code, signal) => {
70
+ this.closed = true;
71
+ // Nothing can answer the dialogs of a process that is gone.
72
+ for (const id of [...this.extensionRequests.keys()]) this.#settle(id, "closed");
73
+ const reason = signal ? `signal ${signal}` : `exit ${code}`;
74
+ this.emit("closed", { reason, stderr: this.stderr });
75
+ this.#fail(new Error(`omp exited: ${reason}`));
76
+ });
77
+ child.stderr.setEncoding("utf8");
78
+ child.stderr.on("data", (chunk) => {
79
+ this.stderr = `${this.stderr}${chunk}`.slice(-16_384);
80
+ });
81
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
82
+ const ready = new Promise((resolve, reject) => {
83
+ const timer = setTimeout(() => reject(new Error("omp RPC did not become ready")), START_TIMEOUT_MS);
84
+ const onReady = (message) => {
85
+ clearTimeout(timer);
86
+ this.off("ready", onReady);
87
+ resolve(message);
88
+ };
89
+ this.once("ready", onReady);
90
+ this.once("closed", () => {
91
+ clearTimeout(timer);
92
+ reject(new Error(`omp exited before ready: ${this.stderr.slice(-500)}`));
93
+ });
94
+ });
95
+ lines.on("line", (line) => this.#onLine(line));
96
+ await ready;
97
+ this.started = true;
98
+ return this;
99
+ }
100
+
101
+ /** @param {string} line */
102
+ #onLine(line) {
103
+ let message;
104
+ try {
105
+ message = JSON.parse(line);
106
+ } catch {
107
+ this.emit("protocol-error", { line: line.slice(0, 500) });
108
+ return;
109
+ }
110
+ this.updatedAt = Date.now();
111
+ if (message.type === "ready") {
112
+ this.emit("ready", message);
113
+ return;
114
+ }
115
+ if (message.type === "response") {
116
+ const pending = message.id ? this.pending.get(message.id) : undefined;
117
+ if (pending !== undefined && message.id) {
118
+ this.pending.delete(message.id);
119
+ if (message.success === false) pending.reject(new Error(message.error || `RPC ${message.command} failed`));
120
+ else pending.resolve(message);
121
+ return;
122
+ }
123
+ this.emit("response", message);
124
+ return;
125
+ }
126
+ if (message.type === "extension_ui_request") {
127
+ if (IGNORED_UI.has(message.method)) return;
128
+ if (message.method === "cancel") {
129
+ // OMP closed a dialog itself (timeout, abort); a later answer to it is ignored.
130
+ if (typeof message.targetId === "string") this.#settle(message.targetId, "cancelled");
131
+ return;
132
+ }
133
+ if (message.method === "notify") {
134
+ this.emit("notice", { level: message.notifyType ?? "info", message: message.message });
135
+ return;
136
+ }
137
+ if (message.method === "open_url") {
138
+ this.emit("notice", {
139
+ level: "info",
140
+ message: message.instructions || message.url,
141
+ url: message.url,
142
+ launchUrl: message.launchUrl,
143
+ });
144
+ return;
145
+ }
146
+ this.extensionRequests.set(message.id, message);
147
+ this.emit("approval", message);
148
+ return;
149
+ }
150
+ if (message.type === "available_commands_update") {
151
+ this.emit("commands", message.commands ?? []);
152
+ return;
153
+ }
154
+ this.emit("event", message);
155
+ }
156
+
157
+ /**
158
+ * Forget an open extension request and say how it ended.
159
+ * @param {string} id
160
+ * @param {"answered" | "cancelled" | "closed"} reason
161
+ */
162
+ #settle(id, reason) {
163
+ if (!this.extensionRequests.delete(id)) return;
164
+ this.emit("approval-resolved", { id, reason });
165
+ }
166
+
167
+ /** @param {Error} error */
168
+ #fail(error) {
169
+ this.lastError = error;
170
+ for (const pending of this.pending.values()) pending.reject(error);
171
+ this.pending.clear();
172
+ }
173
+
174
+ /**
175
+ * Send one command and wait for its `response` line.
176
+ * @param {string} type
177
+ * @param {Record<string, unknown>} [payload]
178
+ * @param {number} [timeoutMs]
179
+ */
180
+ request(type, payload = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
181
+ if (this.closed || this.child === undefined) {
182
+ return Promise.reject(new Error("omp session is closed"));
183
+ }
184
+ const id = randomUUID();
185
+ const line = JSON.stringify({ id, type, ...payload });
186
+ return new Promise((resolve, reject) => {
187
+ const timer = setTimeout(() => {
188
+ this.pending.delete(id);
189
+ reject(new Error(`omp RPC ${type} timed out`));
190
+ }, timeoutMs);
191
+ this.pending.set(id, {
192
+ resolve: (value) => {
193
+ clearTimeout(timer);
194
+ resolve(value);
195
+ },
196
+ reject: (error) => {
197
+ clearTimeout(timer);
198
+ reject(error);
199
+ },
200
+ });
201
+ this.child.stdin.write(`${line}\n`);
202
+ });
203
+ }
204
+
205
+ /** @returns {Promise<object>} */
206
+ async getState() {
207
+ const response = await this.request("get_state");
208
+ const data = response.data ?? {};
209
+ // The full system prompt and tool dump are large and never shown on a phone.
210
+ delete data.systemPrompt;
211
+ delete data.dumpTools;
212
+ this.lastState = data;
213
+ return data;
214
+ }
215
+
216
+ /** @returns {Promise<{messages: object[]}>} */
217
+ async getMessages() {
218
+ const response = await this.request("get_messages");
219
+ return response.data ?? { messages: [] };
220
+ }
221
+
222
+ /** @param {string} text */
223
+ async prompt(text) {
224
+ return this.request("prompt", { message: text });
225
+ }
226
+
227
+ async abort() {
228
+ return this.request("abort");
229
+ }
230
+
231
+ /**
232
+ * Answer an extension UI request (select/confirm/input/editor).
233
+ * @param {string} id
234
+ * @param {{value?: string, confirmed?: boolean, cancelled?: boolean}} answer
235
+ */
236
+ respondExtension(id, answer) {
237
+ if (this.closed || this.child === undefined) throw new Error("omp session is closed");
238
+ if (!this.extensionRequests.has(id)) throw new Error("unknown extension request");
239
+ const response = { type: "extension_ui_response", id, ...answer };
240
+ this.child.stdin.write(`${JSON.stringify(response)}\n`);
241
+ this.#settle(id, answer.cancelled === true ? "cancelled" : "answered");
242
+ }
243
+
244
+ close() {
245
+ if (this.closed) return;
246
+ this.closed = true;
247
+ for (const pending of this.pending.values()) pending.reject(new Error("omp session closed"));
248
+ this.pending.clear();
249
+ this.child?.kill("SIGTERM");
250
+ setTimeout(() => {
251
+ if (this.child !== undefined && this.child.exitCode === null) this.child.kill("SIGKILL");
252
+ }, 2_000).unref();
253
+ }
254
+ }
255
+
256
+ export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS };