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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Attention stream: the moments a phone should hear about while the app is in
3
+ * the background — a tool approval or another question waiting for an answer,
4
+ * the answer (or cancellation) that settles it, and an agent run that finished
5
+ * or failed — folded from what every live session broadcasts, across all
6
+ * sessions, and served as one Server-Sent Events stream (`GET /events`).
7
+ *
8
+ * The tracker reads the same payloads `SessionEntry.broadcast` sends its
9
+ * WebSocket clients (`{type: "event" | "approval" | "approval_resolved" |
10
+ * "notice" | "closed", ...}`), so a frame never depends on a client being
11
+ * connected to that session.
12
+ */
13
+ import { hostname } from "node:os";
14
+
15
+ /** @typedef {"approval" | "question" | "resolved" | "done" | "failed"} AttentionKind */
16
+
17
+ /**
18
+ * @typedef {object} AttentionFrame
19
+ * @property {AttentionKind} kind
20
+ * @property {string} key Waiting-item identity: the extension request id; `run:<n>` for done/failed.
21
+ * @property {string} sessionId
22
+ * @property {string} sessionTitle
23
+ * @property {string} machine This computer's hostname.
24
+ * @property {string} text Human text: the question, the final answer, or the failure.
25
+ * @property {number} time Epoch ms.
26
+ */
27
+
28
+ /** @typedef {{id: string, title?: string}} SessionLike */
29
+
30
+ /** The listener sends a comment this often so phones can tell a dead link from a quiet one. */
31
+ export const HEARTBEAT_MS = 25_000;
32
+ /** OMP's tool approval is a `select` whose title starts with this. */
33
+ export const TOOL_APPROVAL_PREFIX = "Allow tool:";
34
+ const ASKING = new Set(["select", "confirm", "input", "editor"]);
35
+ const TEXT_MAX = 280;
36
+
37
+ function clip(value) {
38
+ const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
39
+ return text.length > TEXT_MAX ? `${text.slice(0, TEXT_MAX - 1)}…` : text;
40
+ }
41
+
42
+ function textOf(content) {
43
+ if (typeof content === "string") return content;
44
+ if (!Array.isArray(content)) return "";
45
+ return content
46
+ .map((part) => (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" ? part.text : ""))
47
+ .join("");
48
+ }
49
+
50
+ /**
51
+ * Whether an extension UI request is OMP's tool approval rather than another question.
52
+ * @param {{method?: string, title?: unknown}} request
53
+ */
54
+ export function isToolApproval(request) {
55
+ return request.method === "select" && typeof request.title === "string" && request.title.startsWith(TOOL_APPROVAL_PREFIX);
56
+ }
57
+
58
+ function lastAssistant(messages) {
59
+ if (!Array.isArray(messages)) return undefined;
60
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
61
+ if (messages[i]?.role === "assistant") return messages[i];
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ /** Folds session broadcasts into attention frames and fans them out. */
67
+ export class AttentionTracker {
68
+ /** @param {{machine?: string, now?: () => number}} [options] */
69
+ constructor(options = {}) {
70
+ this.machine = options.machine ?? hostname();
71
+ this.now = options.now ?? (() => Date.now());
72
+ /** @type {Map<string, {open: Map<string, AttentionFrame>, runs: number, error: string, last: object | undefined}>} */
73
+ this.sessions = new Map();
74
+ /** @type {Set<(frame: AttentionFrame) => void>} */
75
+ this.subscribers = new Set();
76
+ }
77
+
78
+ /** Approvals and questions still waiting, across all sessions, oldest first. */
79
+ pending() {
80
+ const open = [];
81
+ for (const state of this.sessions.values()) open.push(...state.open.values());
82
+ return open.sort((a, b) => a.time - b.time);
83
+ }
84
+
85
+ /**
86
+ * @param {(frame: AttentionFrame) => void} listener
87
+ * @returns {() => void} unsubscribe
88
+ */
89
+ subscribe(listener) {
90
+ this.subscribers.add(listener);
91
+ return () => this.subscribers.delete(listener);
92
+ }
93
+
94
+ /** Drop a session that is gone, resolving whatever it still had open. */
95
+ forget(session) {
96
+ this.observe(session, { type: "closed" });
97
+ this.sessions.delete(session.id);
98
+ }
99
+
100
+ /**
101
+ * Fold one payload a session broadcast.
102
+ * @param {SessionLike} session
103
+ * @param {{type: string, [key: string]: any}} payload
104
+ * @returns {AttentionFrame[]} the frames it produced
105
+ */
106
+ observe(session, payload) {
107
+ const state = this.#state(session.id);
108
+ const frames = [];
109
+ const emit = (kind, key, text) => {
110
+ const frame = {
111
+ kind,
112
+ key,
113
+ sessionId: session.id,
114
+ sessionTitle: session.title ?? "",
115
+ machine: this.machine,
116
+ text: clip(text),
117
+ time: this.now(),
118
+ };
119
+ frames.push(frame);
120
+ return frame;
121
+ };
122
+ switch (payload?.type) {
123
+ case "approval": {
124
+ const request = payload.request ?? {};
125
+ if (typeof request.id !== "string" || !ASKING.has(request.method)) break;
126
+ if (state.open.has(request.id)) break;
127
+ const approval = isToolApproval(request);
128
+ const title = typeof request.title === "string" ? request.title : "";
129
+ // OMP's tool approval title is `Allow tool: bash\nCommand: echo hi`: the tool, then its details.
130
+ const lines = (approval ? title.slice(TOOL_APPROVAL_PREFIX.length) : title).split("\n").map((line) => line.trim());
131
+ const text = [...lines, request.message].filter(Boolean).join(" · ");
132
+ state.open.set(request.id, emit(approval ? "approval" : "question", request.id, text));
133
+ break;
134
+ }
135
+ case "approval_resolved": {
136
+ if (typeof payload.id !== "string" || !state.open.delete(payload.id)) break;
137
+ emit("resolved", payload.id, typeof payload.reason === "string" ? payload.reason : "answered");
138
+ break;
139
+ }
140
+ case "closed": {
141
+ for (const key of [...state.open.keys()]) {
142
+ state.open.delete(key);
143
+ emit("resolved", key, "closed");
144
+ }
145
+ break;
146
+ }
147
+ case "notice": {
148
+ if (payload.level === "error") state.error = typeof payload.message === "string" ? payload.message : "error";
149
+ break;
150
+ }
151
+ case "event":
152
+ this.#event(state, payload.event ?? {}, emit);
153
+ break;
154
+ default:
155
+ }
156
+ for (const frame of frames) for (const listener of this.subscribers) listener(frame);
157
+ return frames;
158
+ }
159
+
160
+ #event(state, event, emit) {
161
+ switch (event.type) {
162
+ case "agent_start":
163
+ state.error = "";
164
+ state.last = undefined;
165
+ return;
166
+ case "notice":
167
+ if (event.level === "error") state.error = typeof event.message === "string" ? event.message : "error";
168
+ return;
169
+ case "message_end":
170
+ if (event.message?.role === "assistant") state.last = event.message;
171
+ return;
172
+ case "agent_end": {
173
+ state.runs += 1;
174
+ const key = `run:${state.runs}`;
175
+ const last = lastAssistant(event.messages) ?? state.last;
176
+ const stop = last?.stopReason;
177
+ if (stop === "error" || stop === "aborted") {
178
+ const reason = typeof last.errorMessage === "string" && last.errorMessage !== "" ? last.errorMessage : stop === "aborted" ? "Aborted" : "Failed";
179
+ emit("failed", key, reason);
180
+ } else if (state.error !== "") {
181
+ emit("failed", key, state.error);
182
+ } else {
183
+ emit("done", key, textOf(last?.content));
184
+ }
185
+ state.error = "";
186
+ state.last = undefined;
187
+ return;
188
+ }
189
+ default:
190
+ }
191
+ }
192
+
193
+ #state(sessionId) {
194
+ let state = this.sessions.get(sessionId);
195
+ if (state === undefined) {
196
+ state = { open: new Map(), runs: 0, error: "", last: undefined };
197
+ this.sessions.set(sessionId, state);
198
+ }
199
+ return state;
200
+ }
201
+ }
202
+
203
+ /** One SSE frame of data. */
204
+ export function sseData(frame) {
205
+ return `data: ${JSON.stringify(frame)}\n\n`;
206
+ }
207
+
208
+ /**
209
+ * Serve the stream on an authorized request: open approvals/questions first,
210
+ * then `event: live`, then frames as they happen, with a heartbeat comment.
211
+ * @param {AttentionTracker} tracker
212
+ * @param {import("node:http").IncomingMessage} req
213
+ * @param {import("node:http").ServerResponse} res
214
+ * @param {{heartbeatMs?: number}} [options]
215
+ * @returns {() => void} ends the stream
216
+ */
217
+ export function serveEvents(tracker, req, res, options = {}) {
218
+ res.writeHead(200, {
219
+ "content-type": "text/event-stream; charset=utf-8",
220
+ "cache-control": "no-store",
221
+ connection: "keep-alive",
222
+ "x-accel-buffering": "no",
223
+ });
224
+ req.socket.setNoDelay(true);
225
+ req.socket.setKeepAlive(true, 30_000);
226
+ res.write(": omp-mobile attention\n\n");
227
+ for (const frame of tracker.pending()) res.write(sseData(frame));
228
+ res.write("event: live\ndata: {}\n\n");
229
+ const off = tracker.subscribe((frame) => res.write(sseData(frame)));
230
+ const heartbeat = setInterval(() => res.write(": ping\n\n"), options.heartbeatMs ?? HEARTBEAT_MS);
231
+ let ended = false;
232
+ const end = () => {
233
+ if (ended) return;
234
+ ended = true;
235
+ off();
236
+ clearInterval(heartbeat);
237
+ if (!res.writableEnded) res.end();
238
+ };
239
+ req.on("close", end);
240
+ res.on("close", end);
241
+ res.on("error", end);
242
+ return end;
243
+ }
@@ -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
+ }