omp-mobile 0.1.0 → 0.2.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/README.md +10 -6
- package/package.json +8 -2
- package/src/omp-extension.mjs +270 -0
- package/src/rpc-session.mjs +75 -1
- package/src/server.mjs +159 -3
package/README.md
CHANGED
|
@@ -14,34 +14,38 @@
|
|
|
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
16
|
- **Native app:** The app draws the transcript, tool cards, thinking, Markdown and composer itself. No WebView, no web page.
|
|
17
|
+
- **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
|
+
- **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.
|
|
17
19
|
- **Approvals on the phone:** OMP's own tool approvals and questions arrive as a sheet, and the turn waits for your answer.
|
|
18
20
|
- **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
21
|
- **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
|
|
20
22
|
- **All your machines:** Pair each computer by QR code, switch between them from the session list. A reopened chat shows at once, also offline.
|
|
21
23
|
- **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
|
+
- **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.
|
|
22
25
|
|
|
23
26
|
## Getting started
|
|
24
27
|
|
|
25
28
|
### Prerequisites
|
|
26
29
|
|
|
27
|
-
- On every computer:
|
|
30
|
+
- On every computer: OMP 18.x
|
|
28
31
|
- [Tailscale](https://tailscale.com) signed in to the same account on the computer and the phone
|
|
29
32
|
- An Android phone
|
|
30
33
|
|
|
31
34
|
### Computer
|
|
32
35
|
|
|
33
|
-
Install the
|
|
36
|
+
Install the plugin into OMP on every computer:
|
|
34
37
|
|
|
35
38
|
```bash
|
|
36
|
-
|
|
37
|
-
omp-mobile --host 100.x.y.z --cwd ~/project
|
|
39
|
+
omp plugin install omp-mobile
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
It
|
|
42
|
+
Then in OMP, run `/mobile`. It starts the daemon in the background, bound to the computer's Tailscale address, and shows a pairing code above the composer until the phone connects. The daemon runs on the Bun inside OMP and outlives the terminal and OMP; `/mobile status` shows it and `/mobile stop` ends it. Phone sessions start in the directory where you ran `/mobile` the first time.
|
|
43
|
+
|
|
44
|
+
Without the plugin, the same daemon runs from npm (Node.js 22.19 or newer) in the foreground: `npm install -g omp-mobile`, then `omp-mobile --host tailscale --cwd ~/project`. It prints the same code.
|
|
41
45
|
|
|
42
46
|
### Phone
|
|
43
47
|
|
|
44
|
-
Install the APK from the [releases page](https://github.com/Rinai-R/omp-mobile/releases). In the app, tap **Scan to pair** and scan the code
|
|
48
|
+
Install the APK from the [releases page](https://github.com/Rinai-R/omp-mobile/releases). In the app, tap **Scan to pair** and scan the code. **Enter address** takes the address and token by hand, or a pasted pairing link.
|
|
45
49
|
|
|
46
50
|
For notifications, open **Settings → Notifications** in the app.
|
|
47
51
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-mobile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
|
|
5
5
|
"keywords": [
|
|
6
|
+
"mobile",
|
|
6
7
|
"oh-my-pi",
|
|
7
8
|
"omp",
|
|
8
|
-
"
|
|
9
|
+
"omp-plugin",
|
|
9
10
|
"remote",
|
|
10
11
|
"tailscale"
|
|
11
12
|
],
|
|
@@ -27,6 +28,11 @@
|
|
|
27
28
|
"bin": {
|
|
28
29
|
"omp-mobile": "src/server.mjs"
|
|
29
30
|
},
|
|
31
|
+
"omp": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/omp-extension.mjs"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
30
36
|
"files": [
|
|
31
37
|
"src",
|
|
32
38
|
"NOTICE"
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OMP extension of omp-mobile: `/mobile` in the interactive OMP.
|
|
3
|
+
*
|
|
4
|
+
* `/mobile` starts the daemon in the background if it is not running, then
|
|
5
|
+
* shows its pairing QR code above the composer until a phone connects. The
|
|
6
|
+
* daemon is its own process (detached, in its own session, logging to
|
|
7
|
+
* `~/.omp-mobile/daemon.log`), so it keeps serving the phone after this
|
|
8
|
+
* terminal and this OMP exit; `/mobile stop` ends it.
|
|
9
|
+
*
|
|
10
|
+
* It runs on the Bun that ships inside omp (`BUN_BE_BUN=1`), so no separate
|
|
11
|
+
* Node.js is needed, and binds this computer's Tailscale IPv4 (`--host tailscale`).
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { delimiter, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { renderQr } from "./qr.mjs";
|
|
19
|
+
|
|
20
|
+
const HOME = join(homedir(), ".omp-mobile");
|
|
21
|
+
const STATE_FILE = join(HOME, "daemon.json");
|
|
22
|
+
const LOG_FILE = join(HOME, "daemon.log");
|
|
23
|
+
const SERVER = fileURLToPath(new URL("./server.mjs", import.meta.url));
|
|
24
|
+
const WIDGET = "omp-mobile";
|
|
25
|
+
const START_TIMEOUT_MS = 20_000;
|
|
26
|
+
const POLL_MS = 1_500;
|
|
27
|
+
/** How long the code stays up without a phone connecting. */
|
|
28
|
+
const SHOW_MS = 5 * 60_000;
|
|
29
|
+
const SUBCOMMANDS = [
|
|
30
|
+
{ value: "status", label: "status", description: "Is the daemon running, and where" },
|
|
31
|
+
{ value: "stop", label: "stop", description: "Stop the background daemon" },
|
|
32
|
+
{ value: "hide", label: "hide", description: "Hide the pairing code" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The running daemon's record, if its process is alive.
|
|
37
|
+
* @returns {{pid: number, version: string, url: string, pair: string, startedAt: number} | undefined}
|
|
38
|
+
*/
|
|
39
|
+
export function readState(path = STATE_FILE) {
|
|
40
|
+
let state;
|
|
41
|
+
try {
|
|
42
|
+
state = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (typeof state?.pid !== "number" || typeof state.url !== "string" || typeof state.pair !== "string") return undefined;
|
|
47
|
+
return alive(state.pid) ? state : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function alive(pid) {
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// EPERM: it exists but belongs to someone else.
|
|
56
|
+
return error?.code === "EPERM";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The token inside a pairing link. */
|
|
61
|
+
export function tokenOf(pair) {
|
|
62
|
+
return new URL(pair.replace(/^omp-mobile:\/\//, "http://x/")).searchParams.get("token") ?? "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The daemon's `/status`, or undefined when it does not answer. */
|
|
66
|
+
async function status(state) {
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(`${state.url}/status`, {
|
|
69
|
+
headers: { authorization: `Bearer ${tokenOf(state.pair)}`, "x-omp-mobile-probe": "1" },
|
|
70
|
+
signal: AbortSignal.timeout(3_000),
|
|
71
|
+
});
|
|
72
|
+
return response.ok ? await response.json() : undefined;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function logTail(lines = 6) {
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync(LOG_FILE, "utf8").trimEnd().split("\n").slice(-lines).join("\n");
|
|
81
|
+
} catch {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The omp the daemon starts sessions with: `omp` on PATH when there is one, since
|
|
88
|
+
* a package manager's link survives an upgrade that removes the versioned binary
|
|
89
|
+
* this process runs from (Homebrew's Cellar path, for one); else this binary.
|
|
90
|
+
*/
|
|
91
|
+
function ompBinary() {
|
|
92
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
93
|
+
if (dir === "") continue;
|
|
94
|
+
const candidate = join(dir, "omp");
|
|
95
|
+
try {
|
|
96
|
+
accessSync(candidate, constants.X_OK);
|
|
97
|
+
return candidate;
|
|
98
|
+
} catch {
|
|
99
|
+
// Not in this directory.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return process.execPath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Start the daemon detached from this terminal and wait until it serves.
|
|
107
|
+
* @param {string} cwd Default directory for sessions the phone starts.
|
|
108
|
+
*/
|
|
109
|
+
async function startDaemon(cwd) {
|
|
110
|
+
mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
111
|
+
const log = openSync(LOG_FILE, "a", 0o600);
|
|
112
|
+
let child;
|
|
113
|
+
try {
|
|
114
|
+
child = spawn(process.execPath, [SERVER, "--host", "tailscale", "--cwd", cwd, "--omp", ompBinary(), "--no-qr"], {
|
|
115
|
+
cwd,
|
|
116
|
+
detached: true,
|
|
117
|
+
stdio: ["ignore", log, log],
|
|
118
|
+
env: { ...process.env, BUN_BE_BUN: "1" },
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
closeSync(log);
|
|
122
|
+
}
|
|
123
|
+
let exited = false;
|
|
124
|
+
child.on("exit", () => {
|
|
125
|
+
exited = true;
|
|
126
|
+
});
|
|
127
|
+
child.on("error", () => {
|
|
128
|
+
exited = true;
|
|
129
|
+
});
|
|
130
|
+
child.unref();
|
|
131
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
132
|
+
while (Date.now() < deadline && !exited) {
|
|
133
|
+
const state = readState();
|
|
134
|
+
if (state?.pid === child.pid && (await status(state)) !== undefined) return state;
|
|
135
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
136
|
+
}
|
|
137
|
+
const reason = exited ? "it exited" : "it did not answer in time";
|
|
138
|
+
throw new Error(`The omp-mobile daemon did not start (${reason}).\n${logTail()}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function stopDaemon(state) {
|
|
142
|
+
process.kill(state.pid, "SIGTERM");
|
|
143
|
+
const deadline = Date.now() + 5_000;
|
|
144
|
+
while (Date.now() < deadline && alive(state.pid)) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
145
|
+
if (alive(state.pid)) process.kill(state.pid, "SIGKILL");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What the pairing widget shows: a title, the code, and where it points. */
|
|
149
|
+
export function pairingContent(state) {
|
|
150
|
+
const host = new URL(state.url).host;
|
|
151
|
+
return {
|
|
152
|
+
title: "OMP Mobile · in the app: Machines → Scan to pair",
|
|
153
|
+
code: renderQr(state.pair).split("\n"),
|
|
154
|
+
notes: [
|
|
155
|
+
`${host} · pid ${state.pid} · keeps running after you exit`,
|
|
156
|
+
"/mobile hide closes this · /mobile stop stops the daemon",
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `text` cut to `width` columns (the widget's lines are plain, one column per character). */
|
|
162
|
+
function fit(text, width) {
|
|
163
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The pairing widget as a TUI component: a plain line widget is cut at ten
|
|
168
|
+
* lines, which a QR code needs more than. A code cut at the terminal's edge
|
|
169
|
+
* would not scan, so a terminal narrower than the code is asked to widen;
|
|
170
|
+
* the text lines are shortened to fit instead.
|
|
171
|
+
*/
|
|
172
|
+
export function pairingWidget({ title, code, notes }) {
|
|
173
|
+
const codeWidth = Math.max(...code.map((line) => line.length)) + 1;
|
|
174
|
+
return () => ({
|
|
175
|
+
render(width) {
|
|
176
|
+
const text = (line) => fit(` ${line}`, width);
|
|
177
|
+
if (width < codeWidth) return [text(`OMP Mobile: widen the terminal to ${codeWidth} columns to show the code.`)];
|
|
178
|
+
return [text(title), "", ...code.map((line) => ` ${line}`), "", ...notes.map(text)];
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function ompMobile(pi) {
|
|
184
|
+
/** The pairing code on screen: its watcher and when it appeared. */
|
|
185
|
+
let showing;
|
|
186
|
+
|
|
187
|
+
const hide = (ctx) => {
|
|
188
|
+
if (showing === undefined) return;
|
|
189
|
+
ctx.clearTimer(showing.timer);
|
|
190
|
+
showing = undefined;
|
|
191
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const show = (ctx, state) => {
|
|
195
|
+
hide(ctx);
|
|
196
|
+
if (ctx.mode !== "tui") {
|
|
197
|
+
ctx.ui.notify(`OMP Mobile: open this link on the phone: ${state.pair}`, "info");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const shownAt = Date.now();
|
|
201
|
+
ctx.ui.setWidget(WIDGET, pairingWidget(pairingContent(state)), { placement: "aboveEditor" });
|
|
202
|
+
const timer = ctx.setInterval(async () => {
|
|
203
|
+
if (showing?.timer !== timer) return;
|
|
204
|
+
if (Date.now() - shownAt > SHOW_MS) {
|
|
205
|
+
hide(ctx);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const now = await status(state);
|
|
209
|
+
if (showing?.timer !== timer) return;
|
|
210
|
+
if (now === undefined) {
|
|
211
|
+
hide(ctx);
|
|
212
|
+
ctx.ui.notify("OMP Mobile: the daemon stopped answering.", "warning");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (now.lastPhone !== null && now.lastPhone.at >= shownAt) {
|
|
216
|
+
hide(ctx);
|
|
217
|
+
ctx.ui.notify(`OMP Mobile: phone connected from ${now.lastPhone.address}.`, "info");
|
|
218
|
+
}
|
|
219
|
+
}, POLL_MS);
|
|
220
|
+
showing = { timer };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
pi.registerCommand("mobile", {
|
|
224
|
+
description: "Pair your phone with this computer (OMP Mobile)",
|
|
225
|
+
getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((item) => item.value.startsWith(prefix.trim())),
|
|
226
|
+
handler: async (args, ctx) => {
|
|
227
|
+
const sub = args.trim();
|
|
228
|
+
const state = readState();
|
|
229
|
+
if (sub === "hide") {
|
|
230
|
+
hide(ctx);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (sub === "stop") {
|
|
234
|
+
hide(ctx);
|
|
235
|
+
if (state === undefined) {
|
|
236
|
+
ctx.ui.notify("OMP Mobile: the daemon is not running.", "info");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await stopDaemon(state);
|
|
240
|
+
ctx.ui.notify(`OMP Mobile: stopped the daemon (pid ${state.pid}).`, "info");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (sub === "status") {
|
|
244
|
+
const now = state === undefined ? undefined : await status(state);
|
|
245
|
+
if (state === undefined || now === undefined) {
|
|
246
|
+
ctx.ui.notify("OMP Mobile: the daemon is not running. /mobile starts it.", "info");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const phone = now.lastPhone === null ? "no phone yet" : `last phone ${now.lastPhone.address}`;
|
|
250
|
+
ctx.ui.notify(`OMP Mobile: ${now.url} · pid ${now.pid} · ${now.liveSessions} live sessions · ${phone}`, "info");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (sub !== "") {
|
|
254
|
+
ctx.ui.notify(`OMP Mobile: unknown "${sub}". Use /mobile, /mobile status, /mobile stop or /mobile hide.`, "warning");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let running = state !== undefined && (await status(state)) !== undefined ? state : undefined;
|
|
258
|
+
if (running === undefined) {
|
|
259
|
+
ctx.ui.notify("OMP Mobile: starting the daemon…", "info");
|
|
260
|
+
try {
|
|
261
|
+
running = await startDaemon(ctx.cwd);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
show(ctx, running);
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
package/src/rpc-session.mjs
CHANGED
|
@@ -11,6 +11,15 @@ import { randomUUID } from "node:crypto";
|
|
|
11
11
|
import { createInterface } from "node:readline";
|
|
12
12
|
|
|
13
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
|
+
}
|
|
14
23
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
15
24
|
|
|
16
25
|
/** Events that carry no useful state to the phone. */
|
|
@@ -26,6 +35,36 @@ const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_te
|
|
|
26
35
|
* @property {boolean} [autoApprove]
|
|
27
36
|
*/
|
|
28
37
|
|
|
38
|
+
/**
|
|
39
|
+
* One entry of OMP's model catalog, cut to what a phone lists: provider, name,
|
|
40
|
+
* context window and input kinds. The full object carries endpoints, cost and
|
|
41
|
+
* compatibility fields the app never shows.
|
|
42
|
+
* @param {Record<string, unknown>} model
|
|
43
|
+
*/
|
|
44
|
+
export function compactModel(model) {
|
|
45
|
+
const thinking = model.thinking;
|
|
46
|
+
const cost = model.cost;
|
|
47
|
+
return {
|
|
48
|
+
provider: typeof model.provider === "string" ? model.provider : "",
|
|
49
|
+
id: typeof model.id === "string" ? model.id : "",
|
|
50
|
+
name: typeof model.name === "string" ? model.name : typeof model.id === "string" ? model.id : "",
|
|
51
|
+
reasoning: model.reasoning === true,
|
|
52
|
+
input: Array.isArray(model.input) ? model.input.filter((kind) => typeof kind === "string") : [],
|
|
53
|
+
contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : undefined,
|
|
54
|
+
cost:
|
|
55
|
+
cost !== null && typeof cost === "object"
|
|
56
|
+
? {
|
|
57
|
+
input: typeof cost.input === "number" ? cost.input : undefined,
|
|
58
|
+
output: typeof cost.output === "number" ? cost.output : undefined,
|
|
59
|
+
}
|
|
60
|
+
: undefined,
|
|
61
|
+
efforts:
|
|
62
|
+
thinking !== null && typeof thinking === "object" && Array.isArray(thinking.efforts)
|
|
63
|
+
? thinking.efforts.filter((level) => typeof level === "string")
|
|
64
|
+
: undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
export class RpcSession extends EventEmitter {
|
|
30
69
|
/** @param {RpcSessionOptions} options */
|
|
31
70
|
constructor(options) {
|
|
@@ -61,7 +100,7 @@ export class RpcSession extends EventEmitter {
|
|
|
61
100
|
if (this.sessionDir) args.push("--session-dir", this.sessionDir);
|
|
62
101
|
const child = spawn(this.ompBin, args, {
|
|
63
102
|
cwd: this.cwd,
|
|
64
|
-
env: { ...process.env, NO_COLOR: "1" },
|
|
103
|
+
env: { ...withoutBunFlag(process.env), NO_COLOR: "1" },
|
|
65
104
|
stdio: ["pipe", "pipe", "pipe"],
|
|
66
105
|
});
|
|
67
106
|
this.child = child;
|
|
@@ -219,6 +258,41 @@ export class RpcSession extends EventEmitter {
|
|
|
219
258
|
return response.data ?? { messages: [] };
|
|
220
259
|
}
|
|
221
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The models this session can switch to, compacted for a phone list.
|
|
263
|
+
* OMP waits out background discovery first, so this can take a few seconds.
|
|
264
|
+
* @returns {Promise<ReturnType<typeof compactModel>[]>}
|
|
265
|
+
*/
|
|
266
|
+
async availableModels() {
|
|
267
|
+
const response = await this.request("get_available_models");
|
|
268
|
+
const models = response.data?.models;
|
|
269
|
+
return Array.isArray(models) ? models.map(compactModel) : [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Switch this session to another model. Returns the state afterwards: the
|
|
274
|
+
* model's thinking efforts decide which levels the phone may offer.
|
|
275
|
+
* @param {string} provider
|
|
276
|
+
* @param {string} modelId
|
|
277
|
+
*/
|
|
278
|
+
async setModel(provider, modelId) {
|
|
279
|
+
await this.request("set_model", { provider, modelId });
|
|
280
|
+
return this.getState();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** @returns {Promise<{levels: string[]}>} */
|
|
284
|
+
async availableThinkingLevels() {
|
|
285
|
+
const response = await this.request("get_available_thinking_levels");
|
|
286
|
+
const levels = response.data?.levels;
|
|
287
|
+
return { levels: Array.isArray(levels) ? levels.filter((level) => typeof level === "string") : [] };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @param {string} level */
|
|
291
|
+
async setThinkingLevel(level) {
|
|
292
|
+
await this.request("set_thinking_level", { level });
|
|
293
|
+
return this.getState();
|
|
294
|
+
}
|
|
295
|
+
|
|
222
296
|
/** @param {string} text */
|
|
223
297
|
async prompt(text) {
|
|
224
298
|
return this.request("prompt", { message: text });
|
package/src/server.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* to be exposed to the public internet.
|
|
9
9
|
*/
|
|
10
10
|
import { STATUS_CODES, createServer } from "node:http";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { homedir, hostname, platform } from "node:os";
|
|
14
14
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -25,10 +25,25 @@ const PROTOCOL = 1;
|
|
|
25
25
|
const DEFAULT_PORT = 7390;
|
|
26
26
|
const DEFAULT_TOKEN_FILE = join(homedir(), ".omp-mobile", "token");
|
|
27
27
|
const DEFAULT_REGISTRY_FILE = join(homedir(), ".omp-mobile", "sessions.json");
|
|
28
|
+
const DEFAULT_STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
|
|
28
29
|
const DEFAULT_OMP_HOME = join(homedir(), ".omp");
|
|
29
30
|
const MAX_BODY = 256 * 1024;
|
|
30
31
|
/** Terminal sessions listed next to the daemon's own, newest first. */
|
|
31
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
|
+
/** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
|
|
46
|
+
const BLOB_NAME = /^[0-9a-f]{64}$/;
|
|
32
47
|
|
|
33
48
|
/** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
|
|
34
49
|
|
|
@@ -45,6 +60,7 @@ function parseArgs(argv) {
|
|
|
45
60
|
omp: "omp",
|
|
46
61
|
tokenFile: DEFAULT_TOKEN_FILE,
|
|
47
62
|
registryFile: DEFAULT_REGISTRY_FILE,
|
|
63
|
+
stateFile: DEFAULT_STATE_FILE,
|
|
48
64
|
token: "",
|
|
49
65
|
sessionDir: "",
|
|
50
66
|
ompHome: DEFAULT_OMP_HOME,
|
|
@@ -86,6 +102,10 @@ function parseArgs(argv) {
|
|
|
86
102
|
options.registryFile = resolve(value);
|
|
87
103
|
i += 1;
|
|
88
104
|
break;
|
|
105
|
+
case "--state-file":
|
|
106
|
+
options.stateFile = resolve(value);
|
|
107
|
+
i += 1;
|
|
108
|
+
break;
|
|
89
109
|
case "--session-dir":
|
|
90
110
|
options.sessionDir = resolve(value);
|
|
91
111
|
i += 1;
|
|
@@ -130,8 +150,7 @@ function printHelp() {
|
|
|
130
150
|
|
|
131
151
|
Usage: omp-mobile [options]
|
|
132
152
|
|
|
133
|
-
--host <address> Bind address (default 127.0.0.1
|
|
134
|
-
--port <port> Port (default ${DEFAULT_PORT})
|
|
153
|
+
--host <address> Bind address (default 127.0.0.1). \`tailscale\`: this computer's Tailscale IPv4
|
|
135
154
|
--cwd <dir> Default working directory for new sessions
|
|
136
155
|
--omp <path> omp executable (default "omp")
|
|
137
156
|
--session-dir <dir> OMP session directory for sessions this daemon creates
|
|
@@ -139,6 +158,7 @@ Usage: omp-mobile [options]
|
|
|
139
158
|
--token <token> Fixed bearer token (otherwise generated and stored)
|
|
140
159
|
--token-file <path> Token file (default ~/.omp-mobile/token)
|
|
141
160
|
--registry-file <path> Session registry (default ~/.omp-mobile/sessions.json)
|
|
161
|
+
--state-file <path> Where the running daemon records its pid and pairing link (default ~/.omp-mobile/daemon.json)
|
|
142
162
|
--auto-approve Pass --auto-approve to omp (no tool approvals reach the phone)
|
|
143
163
|
--approval-mode <m> Pass --approval-mode to omp (always-ask, always-ask-cached, write, yolo)
|
|
144
164
|
--tailscale <path> tailscale CLI used for admission (default "tailscale")
|
|
@@ -219,6 +239,39 @@ function sendJson(res, status, body) {
|
|
|
219
239
|
res.end(data);
|
|
220
240
|
}
|
|
221
241
|
|
|
242
|
+
/** What a stored blob is, from its first bytes: OMP keeps the extension off the hash name. */
|
|
243
|
+
export function blobType(bytes) {
|
|
244
|
+
if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
|
|
245
|
+
return "image/webp";
|
|
246
|
+
}
|
|
247
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
248
|
+
return "image/png";
|
|
249
|
+
}
|
|
250
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
251
|
+
if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "GIF8") return "image/gif";
|
|
252
|
+
return "application/octet-stream";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
|
|
256
|
+
function sendBlob(res, ompHome, name) {
|
|
257
|
+
if (!BLOB_NAME.test(name)) {
|
|
258
|
+
sendJson(res, 400, { error: "bad blob name" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const path = join(ompHome, "agent", "blobs", name);
|
|
262
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
263
|
+
sendJson(res, 404, { error: "no such blob" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const bytes = readFileSync(path);
|
|
267
|
+
res.writeHead(200, {
|
|
268
|
+
"content-type": blobType(bytes),
|
|
269
|
+
"content-length": bytes.length,
|
|
270
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
271
|
+
});
|
|
272
|
+
res.end(bytes);
|
|
273
|
+
}
|
|
274
|
+
|
|
222
275
|
function readBody(req) {
|
|
223
276
|
return new Promise((resolveBody, reject) => {
|
|
224
277
|
let size = 0;
|
|
@@ -353,6 +406,9 @@ class SessionEntry {
|
|
|
353
406
|
this.onChange?.(this);
|
|
354
407
|
}
|
|
355
408
|
}
|
|
409
|
+
if (STATE_EVENTS.has(event.type)) void this.refreshState();
|
|
410
|
+
// A `todo` tool call rewrites the list before any reminder fires.
|
|
411
|
+
if (event.type === "tool_execution_end" && event.toolName === "todo") void this.refreshState();
|
|
356
412
|
if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
|
|
357
413
|
this.broadcast({ type: "event", event });
|
|
358
414
|
});
|
|
@@ -397,6 +453,10 @@ class Daemon {
|
|
|
397
453
|
this.token = token;
|
|
398
454
|
this.admission = identity.admission;
|
|
399
455
|
this.user = identity.user ?? "";
|
|
456
|
+
this.startedAt = Date.now();
|
|
457
|
+
/** The last authenticated request from a client (not `/mobile`'s own probe), for `/mobile` to see a phone connect. */
|
|
458
|
+
/** @type {{address: string, at: number} | null} */
|
|
459
|
+
this.lastPhone = null;
|
|
400
460
|
/** @type {Map<string, SessionEntry>} */
|
|
401
461
|
this.sessions = new Map();
|
|
402
462
|
/** @type {Map<string, object>} */
|
|
@@ -477,6 +537,24 @@ class Daemon {
|
|
|
477
537
|
sendJson(res, 401, { error: "unauthorized" });
|
|
478
538
|
return;
|
|
479
539
|
}
|
|
540
|
+
this.noteClient(req);
|
|
541
|
+
const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
|
|
542
|
+
if (blob !== null && req.method === "GET") {
|
|
543
|
+
sendBlob(res, this.options.ompHome, blob[1]);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (url.pathname === "/status" && req.method === "GET") {
|
|
547
|
+
sendJson(res, 200, {
|
|
548
|
+
pid: process.pid,
|
|
549
|
+
version: VERSION,
|
|
550
|
+
startedAt: this.startedAt,
|
|
551
|
+
url: this.baseUrl(),
|
|
552
|
+
liveSessions: this.sessions.size,
|
|
553
|
+
eventStreams: this.streams.size,
|
|
554
|
+
lastPhone: this.lastPhone,
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
480
558
|
if (url.pathname === "/events" && req.method === "GET") {
|
|
481
559
|
const end = serveEvents(this.attention, req, res);
|
|
482
560
|
this.streams.add(end);
|
|
@@ -653,6 +731,7 @@ class Daemon {
|
|
|
653
731
|
socket.destroy();
|
|
654
732
|
return;
|
|
655
733
|
}
|
|
734
|
+
this.noteClient(req);
|
|
656
735
|
void this.ensureLive(decodeURIComponent(match[1]))
|
|
657
736
|
.then((entry) => {
|
|
658
737
|
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -706,6 +785,39 @@ class Daemon {
|
|
|
706
785
|
case "refresh":
|
|
707
786
|
await entry.refreshState();
|
|
708
787
|
return;
|
|
788
|
+
case "models": {
|
|
789
|
+
const models = await entry.rpc.availableModels();
|
|
790
|
+
ws.send(JSON.stringify({ type: "models", models }));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
case "set_model": {
|
|
794
|
+
if (typeof message.provider !== "string" || typeof message.modelId !== "string") {
|
|
795
|
+
throw new Error("set_model needs provider and modelId");
|
|
796
|
+
}
|
|
797
|
+
const state = await entry.rpc.setModel(message.provider, message.modelId);
|
|
798
|
+
entry.state = state;
|
|
799
|
+
entry.updatedAt = Date.now();
|
|
800
|
+
entry.onChange?.(entry);
|
|
801
|
+
entry.broadcast({ type: "state", state });
|
|
802
|
+
ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
case "thinking_levels": {
|
|
806
|
+
const { levels } = await entry.rpc.availableThinkingLevels();
|
|
807
|
+
ws.send(
|
|
808
|
+
JSON.stringify({ type: "thinking_levels", levels, current: entry.state?.thinkingLevel ?? null }),
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
case "set_thinking_level": {
|
|
813
|
+
if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
|
|
814
|
+
const state = await entry.rpc.setThinkingLevel(message.level);
|
|
815
|
+
entry.state = state;
|
|
816
|
+
entry.updatedAt = Date.now();
|
|
817
|
+
entry.broadcast({ type: "state", state });
|
|
818
|
+
ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
709
821
|
default:
|
|
710
822
|
throw new Error(`unknown command ${String(message.type)}`);
|
|
711
823
|
}
|
|
@@ -714,6 +826,17 @@ class Daemon {
|
|
|
714
826
|
}
|
|
715
827
|
}
|
|
716
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Remember an authenticated client request. `/mobile` marks its own status
|
|
831
|
+
* polls with `x-omp-mobile-probe`; anything else is the app. An address cannot
|
|
832
|
+
* tell them apart: an emulator's requests leave from this computer's own address.
|
|
833
|
+
*/
|
|
834
|
+
noteClient(req) {
|
|
835
|
+
if (req.headers["x-omp-mobile-probe"] !== undefined) return;
|
|
836
|
+
const address = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
|
|
837
|
+
this.lastPhone = { address, at: Date.now() };
|
|
838
|
+
}
|
|
839
|
+
|
|
717
840
|
listen() {
|
|
718
841
|
return new Promise((resolveListen) => {
|
|
719
842
|
this.server.listen(this.options.port, this.options.host, () => resolveListen());
|
|
@@ -774,14 +897,44 @@ function isTailnetAddress(host) {
|
|
|
774
897
|
return /\.ts\.net$/i.test(host);
|
|
775
898
|
}
|
|
776
899
|
|
|
900
|
+
/** This computer's Tailscale IPv4 address, for `--host tailscale`. */
|
|
901
|
+
async function tailscaleHost(options) {
|
|
902
|
+
let self;
|
|
903
|
+
try {
|
|
904
|
+
self = await new Tailscale({ binary: options.tailscale }).status();
|
|
905
|
+
} catch (error) {
|
|
906
|
+
throw startupError(`--host tailscale: cannot read tailscale status (${error instanceof Error ? error.message : String(error)})`);
|
|
907
|
+
}
|
|
908
|
+
const ipv4 = self.ips.find((ip) => isTailnetAddress(ip));
|
|
909
|
+
if (ipv4 === undefined) throw startupError("--host tailscale: this computer has no Tailscale IPv4 address");
|
|
910
|
+
return ipv4;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Record the running daemon for `/mobile` (owner-only: the pairing link carries the token). */
|
|
914
|
+
function writeState(path, state) {
|
|
915
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
916
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Remove the state file if it still describes this process. */
|
|
920
|
+
function clearState(path) {
|
|
921
|
+
try {
|
|
922
|
+
if (JSON.parse(readFileSync(path, "utf8")).pid === process.pid) unlinkSync(path);
|
|
923
|
+
} catch {
|
|
924
|
+
// Already gone or replaced by another daemon.
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
777
928
|
async function main() {
|
|
778
929
|
const options = parseArgs(process.argv.slice(2));
|
|
930
|
+
if (options.host === "tailscale") options.host = await tailscaleHost(options);
|
|
779
931
|
const token = loadToken(options);
|
|
780
932
|
const identity = await resolveIdentity(options);
|
|
781
933
|
const daemon = new Daemon(options, token, identity);
|
|
782
934
|
await daemon.listen();
|
|
783
935
|
const url = daemon.baseUrl();
|
|
784
936
|
const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
|
|
937
|
+
writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, startedAt: daemon.startedAt });
|
|
785
938
|
console.log(`omp-mobile daemon ${VERSION}`);
|
|
786
939
|
console.log(` listening ${url}`);
|
|
787
940
|
console.log(` cwd ${options.cwd}`);
|
|
@@ -799,11 +952,14 @@ async function main() {
|
|
|
799
952
|
}
|
|
800
953
|
}
|
|
801
954
|
const shutdown = async () => {
|
|
955
|
+
clearState(options.stateFile);
|
|
802
956
|
await daemon.close();
|
|
803
957
|
process.exit(0);
|
|
804
958
|
};
|
|
805
959
|
process.on("SIGINT", () => void shutdown());
|
|
806
960
|
process.on("SIGTERM", () => void shutdown());
|
|
961
|
+
// A daemon started by /mobile has no terminal; a closed one must not stop it.
|
|
962
|
+
process.on("SIGHUP", () => undefined);
|
|
807
963
|
}
|
|
808
964
|
|
|
809
965
|
// npm runs the `omp-mobile` bin through a symlink; compare real paths.
|