omp-mobile 0.0.0-stage → 0.1.1
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/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +75 -2
- package/package.json +53 -4
- package/src/admission.mjs +207 -0
- package/src/attention.mjs +243 -0
- package/src/omp-extension.mjs +270 -0
- package/src/omp-sessions.mjs +203 -0
- package/src/qr.mjs +67 -0
- package/src/rpc-session.mjs +265 -0
- package/src/server.mjs +886 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions OMP wrote outside this daemon: the terminal sessions in OMP's own
|
|
3
|
+
* session directory, and which of them another omp process holds open.
|
|
4
|
+
*
|
|
5
|
+
* Reads only what OMP documents as its on-disk contract: one directory per
|
|
6
|
+
* cwd under `<omp home>/agent/sessions`, JSONL files that begin with an
|
|
7
|
+
* optional fixed-width `title` slot and the `session` header, and the owner
|
|
8
|
+
* lock each writing process holds in `<omp home>/run/session-owners/<id>.lock`.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** How far into a file the first prompt is looked for; headers and setup entries come first. */
|
|
15
|
+
const TITLE_SCAN_BYTES = 512 * 1024;
|
|
16
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
17
|
+
const LSOF_TIMEOUT_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
/** @typedef {{id: string, title: string, cwd: string, createdAt: number, updatedAt: number, sessionFile: string}} OmpSession */
|
|
20
|
+
|
|
21
|
+
/** Text of an OMP message content field (string or content-part array). */
|
|
22
|
+
function textOfContent(content) {
|
|
23
|
+
if (typeof content === "string") return content;
|
|
24
|
+
if (Array.isArray(content)) {
|
|
25
|
+
return content
|
|
26
|
+
.map((part) => {
|
|
27
|
+
if (typeof part === "string") return part;
|
|
28
|
+
if (part && typeof part === "object" && typeof part.text === "string") return part.text;
|
|
29
|
+
return "";
|
|
30
|
+
})
|
|
31
|
+
.join("");
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The first user prompt, as one line of at most 80 characters. */
|
|
37
|
+
export function promptTitle(content) {
|
|
38
|
+
return oneLine(textOfContent(content));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A title as one line of at most 80 characters. OMP's title cards start with
|
|
43
|
+
* a Nerd Font icon (private-use code points) a phone font has no glyph for.
|
|
44
|
+
*/
|
|
45
|
+
function oneLine(text) {
|
|
46
|
+
return text
|
|
47
|
+
.replace(/[\uE000-\uF8FF\u{F0000}-\u{10FFFF}]/gu, "")
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.slice(0, 80);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read a session file's identity and title.
|
|
55
|
+
* Undefined when the file has no `session` header (not an OMP session, or still empty).
|
|
56
|
+
* @param {string} file
|
|
57
|
+
* @returns {Promise<OmpSession | undefined>}
|
|
58
|
+
*/
|
|
59
|
+
export async function readSessionFile(file) {
|
|
60
|
+
const handle = await open(file, "r");
|
|
61
|
+
try {
|
|
62
|
+
const info = await handle.stat();
|
|
63
|
+
let header;
|
|
64
|
+
let slotTitle = "";
|
|
65
|
+
let firstPrompt = "";
|
|
66
|
+
let rest = "";
|
|
67
|
+
let offset = 0;
|
|
68
|
+
const buffer = Buffer.alloc(CHUNK_BYTES);
|
|
69
|
+
scan: while (offset < TITLE_SCAN_BYTES) {
|
|
70
|
+
const { bytesRead } = await handle.read(buffer, 0, CHUNK_BYTES, offset);
|
|
71
|
+
if (bytesRead === 0) break;
|
|
72
|
+
offset += bytesRead;
|
|
73
|
+
rest += buffer.toString("utf8", 0, bytesRead);
|
|
74
|
+
const lines = rest.split("\n");
|
|
75
|
+
rest = lines.pop() ?? "";
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
let entry;
|
|
78
|
+
try {
|
|
79
|
+
entry = JSON.parse(line);
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (entry?.type === "title" && header === undefined) {
|
|
84
|
+
if (typeof entry.title === "string") slotTitle = oneLine(entry.title);
|
|
85
|
+
} else if (entry?.type === "session") {
|
|
86
|
+
header = entry;
|
|
87
|
+
if (slotTitle === "" && typeof entry.title === "string") slotTitle = oneLine(entry.title);
|
|
88
|
+
if (slotTitle !== "") break scan;
|
|
89
|
+
} else if (header === undefined) {
|
|
90
|
+
// Legacy files begin with the header; anything else first is not a session.
|
|
91
|
+
return undefined;
|
|
92
|
+
} else if (entry?.type === "message" && entry.message?.role === "user") {
|
|
93
|
+
firstPrompt = promptTitle(entry.message.content);
|
|
94
|
+
if (firstPrompt !== "") break scan;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (header === undefined || typeof header.id !== "string" || typeof header.cwd !== "string") return undefined;
|
|
99
|
+
const created = Date.parse(header.timestamp);
|
|
100
|
+
return {
|
|
101
|
+
id: header.id,
|
|
102
|
+
title: slotTitle || firstPrompt || basename(header.cwd) || "session",
|
|
103
|
+
cwd: header.cwd,
|
|
104
|
+
createdAt: Number.isFinite(created) ? created : info.birthtimeMs,
|
|
105
|
+
updatedAt: info.mtimeMs,
|
|
106
|
+
sessionFile: file,
|
|
107
|
+
};
|
|
108
|
+
} finally {
|
|
109
|
+
await handle.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The most recently written sessions under OMP's session root, newest first.
|
|
115
|
+
* A missing root is no sessions; unreadable or foreign files are skipped.
|
|
116
|
+
* @param {string} root `<omp home>/agent/sessions`
|
|
117
|
+
* @param {{limit?: number}} [options]
|
|
118
|
+
* @returns {Promise<OmpSession[]>}
|
|
119
|
+
*/
|
|
120
|
+
export async function listOmpSessions(root, { limit = 50 } = {}) {
|
|
121
|
+
let dirs;
|
|
122
|
+
try {
|
|
123
|
+
dirs = await readdir(root, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
/** @type {{file: string, mtime: number}[]} */
|
|
128
|
+
const files = [];
|
|
129
|
+
await Promise.all(
|
|
130
|
+
dirs
|
|
131
|
+
.filter((dir) => dir.isDirectory())
|
|
132
|
+
.map(async (dir) => {
|
|
133
|
+
const path = join(root, dir.name);
|
|
134
|
+
let names;
|
|
135
|
+
try {
|
|
136
|
+
names = await readdir(path);
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await Promise.all(
|
|
141
|
+
names
|
|
142
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
143
|
+
.map(async (name) => {
|
|
144
|
+
const file = join(path, name);
|
|
145
|
+
try {
|
|
146
|
+
files.push({ file, mtime: (await stat(file)).mtimeMs });
|
|
147
|
+
} catch {
|
|
148
|
+
// Removed while listing.
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
155
|
+
const sessions = [];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
for (const { file } of files) {
|
|
158
|
+
if (sessions.length >= limit) break;
|
|
159
|
+
let session;
|
|
160
|
+
try {
|
|
161
|
+
session = await readSessionFile(file);
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// A moved session can leave an older copy; the newest file wins.
|
|
166
|
+
if (session === undefined || seen.has(session.id)) continue;
|
|
167
|
+
seen.add(session.id);
|
|
168
|
+
sessions.push(session);
|
|
169
|
+
}
|
|
170
|
+
return sessions;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Session ids whose owner lock some process holds open, with that process id.
|
|
175
|
+
* `undefined` when ownership cannot be read (no `lsof`), which callers must not treat as "nobody".
|
|
176
|
+
* @param {string} ownersDir `<omp home>/run/session-owners`
|
|
177
|
+
* @param {{lsof?: string}} [options]
|
|
178
|
+
* @returns {Promise<Map<string, number> | undefined>}
|
|
179
|
+
*/
|
|
180
|
+
export function sessionOwners(ownersDir, { lsof = "lsof" } = {}) {
|
|
181
|
+
return new Promise((resolveOwners) => {
|
|
182
|
+
// -F pn: one `p<pid>` line per process, then `n<path>` per open file.
|
|
183
|
+
execFile(lsof, ["-n", "-w", "-F", "pn", "+d", ownersDir], { timeout: LSOF_TIMEOUT_MS }, (error, stdout) => {
|
|
184
|
+
// lsof exits 1 when nothing in the directory is open; that is an answer.
|
|
185
|
+
if (error && (typeof error.code !== "number" || stdout === "")) {
|
|
186
|
+
resolveOwners(error.code === 1 ? new Map() : undefined);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
resolveOwners(parseLsof(stdout));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {string} output `lsof -F pn` output */
|
|
195
|
+
export function parseLsof(output) {
|
|
196
|
+
const owners = new Map();
|
|
197
|
+
let pid = 0;
|
|
198
|
+
for (const line of output.split("\n")) {
|
|
199
|
+
if (line.startsWith("p")) pid = Number(line.slice(1));
|
|
200
|
+
else if (line.startsWith("n") && line.endsWith(".lock") && pid > 0) owners.set(basename(line.slice(1), ".lock"), pid);
|
|
201
|
+
}
|
|
202
|
+
return owners;
|
|
203
|
+
}
|
package/src/qr.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal QR codes for the pairing URI, drawn with Unicode half blocks.
|
|
3
|
+
*
|
|
4
|
+
* Each text line carries two module rows: "▀" is a dark top half, "▄" a dark
|
|
5
|
+
* bottom half, "█" both and a space neither. Dark modules are the terminal's
|
|
6
|
+
* foreground and the quiet zone is plain spaces, so on a light terminal the
|
|
7
|
+
* code is dark on light, and on a dark terminal it is the same code inverted,
|
|
8
|
+
* which the phone scanners (ML Kit, Vision) read as well. No colors are used,
|
|
9
|
+
* so the output survives logs, tmux and terminals without ANSI support.
|
|
10
|
+
*/
|
|
11
|
+
import qrcode from "qrcode-generator";
|
|
12
|
+
|
|
13
|
+
/** Modules of blank margin on every side. */
|
|
14
|
+
export const QUIET_ZONE = 2;
|
|
15
|
+
|
|
16
|
+
/** Error correction level: M survives a slightly blurry photo of a screen. */
|
|
17
|
+
const LEVEL = "M";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The QR module matrix for `text`, `true` for a dark module.
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @returns {boolean[][]}
|
|
23
|
+
*/
|
|
24
|
+
export function qrMatrix(text) {
|
|
25
|
+
// Type 0 picks the smallest version that fits; byte mode keeps URIs intact.
|
|
26
|
+
const code = qrcode(0, LEVEL);
|
|
27
|
+
code.addData(text, "Byte");
|
|
28
|
+
code.make();
|
|
29
|
+
const size = code.getModuleCount();
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (let row = 0; row < size; row += 1) {
|
|
32
|
+
const cells = [];
|
|
33
|
+
for (let col = 0; col < size; col += 1) cells.push(code.isDark(row, col));
|
|
34
|
+
rows.push(cells);
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render `text` as a QR code of half blocks with a blank quiet zone.
|
|
41
|
+
* Every line has the same width; lines are joined with "\n" (no trailing newline).
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function renderQr(text) {
|
|
46
|
+
const matrix = qrMatrix(text);
|
|
47
|
+
const size = matrix.length;
|
|
48
|
+
const total = size + QUIET_ZONE * 2;
|
|
49
|
+
/** Whether the module at (row, col) of the padded grid is dark. */
|
|
50
|
+
const dark = (row, col) => {
|
|
51
|
+
const r = row - QUIET_ZONE;
|
|
52
|
+
const c = col - QUIET_ZONE;
|
|
53
|
+
return r >= 0 && r < size && c >= 0 && c < size && matrix[r][c] === true;
|
|
54
|
+
};
|
|
55
|
+
const lines = [];
|
|
56
|
+
// An odd row count leaves the last line's bottom half outside the grid: blank.
|
|
57
|
+
for (let row = 0; row < total; row += 2) {
|
|
58
|
+
let line = "";
|
|
59
|
+
for (let col = 0; col < total; col += 1) {
|
|
60
|
+
const top = dark(row, col);
|
|
61
|
+
const bottom = row + 1 < total && dark(row + 1, col);
|
|
62
|
+
line += top ? (bottom ? "█" : "▀") : bottom ? "▄" : " ";
|
|
63
|
+
}
|
|
64
|
+
lines.push(line);
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The environment without `BUN_BE_BUN`: /mobile starts the daemon under omp's
|
|
17
|
+
* embedded Bun with it set, and a child omp that inherits it would run as Bun.
|
|
18
|
+
*/
|
|
19
|
+
function withoutBunFlag(env) {
|
|
20
|
+
const { BUN_BE_BUN: _ignored, ...rest } = env;
|
|
21
|
+
return rest;
|
|
22
|
+
}
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
/** Events that carry no useful state to the phone. */
|
|
26
|
+
const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_text"]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} RpcSessionOptions
|
|
30
|
+
* @property {string} cwd
|
|
31
|
+
* @property {string} [ompBin]
|
|
32
|
+
* @property {string[]} [extraArgs]
|
|
33
|
+
* @property {string} [sessionDir]
|
|
34
|
+
* @property {string} [resume]
|
|
35
|
+
* @property {boolean} [autoApprove]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export class RpcSession extends EventEmitter {
|
|
39
|
+
/** @param {RpcSessionOptions} options */
|
|
40
|
+
constructor(options) {
|
|
41
|
+
super();
|
|
42
|
+
this.cwd = options.cwd;
|
|
43
|
+
this.ompBin = options.ompBin ?? "omp";
|
|
44
|
+
this.extraArgs = options.extraArgs ?? [];
|
|
45
|
+
this.sessionDir = options.sessionDir;
|
|
46
|
+
this.resume = options.resume;
|
|
47
|
+
this.autoApprove = options.autoApprove === true;
|
|
48
|
+
/** @type {import("node:child_process").ChildProcessWithoutNullStreams | undefined} */
|
|
49
|
+
this.child = undefined;
|
|
50
|
+
/** @type {Map<string, {resolve: (value: any) => void, reject: (error: Error) => void}>} */
|
|
51
|
+
this.pending = new Map();
|
|
52
|
+
/** @type {Map<string, object>} */
|
|
53
|
+
this.extensionRequests = new Map();
|
|
54
|
+
this.started = false;
|
|
55
|
+
this.closed = false;
|
|
56
|
+
this.startedAt = Date.now();
|
|
57
|
+
this.updatedAt = Date.now();
|
|
58
|
+
this.lastState = null;
|
|
59
|
+
this.lastError = null;
|
|
60
|
+
this.stderr = "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Start the process and resolve once it printed `ready`. */
|
|
64
|
+
async start() {
|
|
65
|
+
// rpc-ui gives tools an interactive UI: OMP's approval prompt reaches the
|
|
66
|
+
// phone as an extension `select` request instead of failing closed.
|
|
67
|
+
const args = ["--mode", "rpc-ui", "--cwd", this.cwd, ...this.extraArgs];
|
|
68
|
+
if (this.resume) args.push("--resume", this.resume);
|
|
69
|
+
if (this.autoApprove) args.push("--auto-approve");
|
|
70
|
+
if (this.sessionDir) args.push("--session-dir", this.sessionDir);
|
|
71
|
+
const child = spawn(this.ompBin, args, {
|
|
72
|
+
cwd: this.cwd,
|
|
73
|
+
env: { ...withoutBunFlag(process.env), NO_COLOR: "1" },
|
|
74
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
75
|
+
});
|
|
76
|
+
this.child = child;
|
|
77
|
+
child.on("error", (error) => this.#fail(error));
|
|
78
|
+
child.on("exit", (code, signal) => {
|
|
79
|
+
this.closed = true;
|
|
80
|
+
// Nothing can answer the dialogs of a process that is gone.
|
|
81
|
+
for (const id of [...this.extensionRequests.keys()]) this.#settle(id, "closed");
|
|
82
|
+
const reason = signal ? `signal ${signal}` : `exit ${code}`;
|
|
83
|
+
this.emit("closed", { reason, stderr: this.stderr });
|
|
84
|
+
this.#fail(new Error(`omp exited: ${reason}`));
|
|
85
|
+
});
|
|
86
|
+
child.stderr.setEncoding("utf8");
|
|
87
|
+
child.stderr.on("data", (chunk) => {
|
|
88
|
+
this.stderr = `${this.stderr}${chunk}`.slice(-16_384);
|
|
89
|
+
});
|
|
90
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
91
|
+
const ready = new Promise((resolve, reject) => {
|
|
92
|
+
const timer = setTimeout(() => reject(new Error("omp RPC did not become ready")), START_TIMEOUT_MS);
|
|
93
|
+
const onReady = (message) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
this.off("ready", onReady);
|
|
96
|
+
resolve(message);
|
|
97
|
+
};
|
|
98
|
+
this.once("ready", onReady);
|
|
99
|
+
this.once("closed", () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(new Error(`omp exited before ready: ${this.stderr.slice(-500)}`));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
lines.on("line", (line) => this.#onLine(line));
|
|
105
|
+
await ready;
|
|
106
|
+
this.started = true;
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {string} line */
|
|
111
|
+
#onLine(line) {
|
|
112
|
+
let message;
|
|
113
|
+
try {
|
|
114
|
+
message = JSON.parse(line);
|
|
115
|
+
} catch {
|
|
116
|
+
this.emit("protocol-error", { line: line.slice(0, 500) });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.updatedAt = Date.now();
|
|
120
|
+
if (message.type === "ready") {
|
|
121
|
+
this.emit("ready", message);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (message.type === "response") {
|
|
125
|
+
const pending = message.id ? this.pending.get(message.id) : undefined;
|
|
126
|
+
if (pending !== undefined && message.id) {
|
|
127
|
+
this.pending.delete(message.id);
|
|
128
|
+
if (message.success === false) pending.reject(new Error(message.error || `RPC ${message.command} failed`));
|
|
129
|
+
else pending.resolve(message);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.emit("response", message);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (message.type === "extension_ui_request") {
|
|
136
|
+
if (IGNORED_UI.has(message.method)) return;
|
|
137
|
+
if (message.method === "cancel") {
|
|
138
|
+
// OMP closed a dialog itself (timeout, abort); a later answer to it is ignored.
|
|
139
|
+
if (typeof message.targetId === "string") this.#settle(message.targetId, "cancelled");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (message.method === "notify") {
|
|
143
|
+
this.emit("notice", { level: message.notifyType ?? "info", message: message.message });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (message.method === "open_url") {
|
|
147
|
+
this.emit("notice", {
|
|
148
|
+
level: "info",
|
|
149
|
+
message: message.instructions || message.url,
|
|
150
|
+
url: message.url,
|
|
151
|
+
launchUrl: message.launchUrl,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.extensionRequests.set(message.id, message);
|
|
156
|
+
this.emit("approval", message);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (message.type === "available_commands_update") {
|
|
160
|
+
this.emit("commands", message.commands ?? []);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.emit("event", message);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Forget an open extension request and say how it ended.
|
|
168
|
+
* @param {string} id
|
|
169
|
+
* @param {"answered" | "cancelled" | "closed"} reason
|
|
170
|
+
*/
|
|
171
|
+
#settle(id, reason) {
|
|
172
|
+
if (!this.extensionRequests.delete(id)) return;
|
|
173
|
+
this.emit("approval-resolved", { id, reason });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @param {Error} error */
|
|
177
|
+
#fail(error) {
|
|
178
|
+
this.lastError = error;
|
|
179
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
180
|
+
this.pending.clear();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Send one command and wait for its `response` line.
|
|
185
|
+
* @param {string} type
|
|
186
|
+
* @param {Record<string, unknown>} [payload]
|
|
187
|
+
* @param {number} [timeoutMs]
|
|
188
|
+
*/
|
|
189
|
+
request(type, payload = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
190
|
+
if (this.closed || this.child === undefined) {
|
|
191
|
+
return Promise.reject(new Error("omp session is closed"));
|
|
192
|
+
}
|
|
193
|
+
const id = randomUUID();
|
|
194
|
+
const line = JSON.stringify({ id, type, ...payload });
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
this.pending.delete(id);
|
|
198
|
+
reject(new Error(`omp RPC ${type} timed out`));
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
this.pending.set(id, {
|
|
201
|
+
resolve: (value) => {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
resolve(value);
|
|
204
|
+
},
|
|
205
|
+
reject: (error) => {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
reject(error);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
this.child.stdin.write(`${line}\n`);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @returns {Promise<object>} */
|
|
215
|
+
async getState() {
|
|
216
|
+
const response = await this.request("get_state");
|
|
217
|
+
const data = response.data ?? {};
|
|
218
|
+
// The full system prompt and tool dump are large and never shown on a phone.
|
|
219
|
+
delete data.systemPrompt;
|
|
220
|
+
delete data.dumpTools;
|
|
221
|
+
this.lastState = data;
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @returns {Promise<{messages: object[]}>} */
|
|
226
|
+
async getMessages() {
|
|
227
|
+
const response = await this.request("get_messages");
|
|
228
|
+
return response.data ?? { messages: [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {string} text */
|
|
232
|
+
async prompt(text) {
|
|
233
|
+
return this.request("prompt", { message: text });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async abort() {
|
|
237
|
+
return this.request("abort");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Answer an extension UI request (select/confirm/input/editor).
|
|
242
|
+
* @param {string} id
|
|
243
|
+
* @param {{value?: string, confirmed?: boolean, cancelled?: boolean}} answer
|
|
244
|
+
*/
|
|
245
|
+
respondExtension(id, answer) {
|
|
246
|
+
if (this.closed || this.child === undefined) throw new Error("omp session is closed");
|
|
247
|
+
if (!this.extensionRequests.has(id)) throw new Error("unknown extension request");
|
|
248
|
+
const response = { type: "extension_ui_response", id, ...answer };
|
|
249
|
+
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
|
250
|
+
this.#settle(id, answer.cancelled === true ? "cancelled" : "answered");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
close() {
|
|
254
|
+
if (this.closed) return;
|
|
255
|
+
this.closed = true;
|
|
256
|
+
for (const pending of this.pending.values()) pending.reject(new Error("omp session closed"));
|
|
257
|
+
this.pending.clear();
|
|
258
|
+
this.child?.kill("SIGTERM");
|
|
259
|
+
setTimeout(() => {
|
|
260
|
+
if (this.child !== undefined && this.child.exitCode === null) this.child.kill("SIGKILL");
|
|
261
|
+
}, 2_000).unref();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS };
|