omp-mobile 0.3.0 → 0.4.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/package.json +1 -1
- package/src/omp-extension.mjs +60 -2
- package/src/omp-sessions.mjs +1 -1
- package/src/rpc-session.mjs +61 -9
- package/src/server.mjs +297 -46
- package/src/takeover.mjs +249 -0
package/package.json
CHANGED
package/src/omp-extension.mjs
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
*
|
|
10
10
|
* It runs on the Bun that ships inside omp (`BUN_BE_BUN=1`), so no separate
|
|
11
11
|
* Node.js is needed, and binds this computer's Tailscale IPv4 (`--host tailscale`).
|
|
12
|
+
*
|
|
13
|
+
* In every omp it is loaded into, it also watches for the daemon's takeover
|
|
14
|
+
* request for the session that omp has open (a file beside the session file,
|
|
15
|
+
* see `takeover.mjs`): it then says where the session went, stops the turn and
|
|
16
|
+
* closes omp through OMP's own shutdown, so the phone can continue it.
|
|
12
17
|
*/
|
|
13
18
|
import { spawn } from "node:child_process";
|
|
14
|
-
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
19
|
+
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
15
20
|
import { homedir } from "node:os";
|
|
16
21
|
import { delimiter, join } from "node:path";
|
|
17
22
|
import { fileURLToPath } from "node:url";
|
|
18
23
|
import { renderQr } from "./qr.mjs";
|
|
24
|
+
import { requestPath } from "./takeover.mjs";
|
|
19
25
|
|
|
20
26
|
const HOME = join(homedir(), ".omp-mobile");
|
|
21
27
|
const STATE_FILE = join(HOME, "daemon.json");
|
|
@@ -26,6 +32,11 @@ const START_TIMEOUT_MS = 20_000;
|
|
|
26
32
|
const POLL_MS = 1_500;
|
|
27
33
|
/** How long the code stays up without a phone connecting. */
|
|
28
34
|
const SHOW_MS = 5 * 60_000;
|
|
35
|
+
/** How often the session's takeover request is looked for, and how stale one may be. */
|
|
36
|
+
const TAKEOVER_POLL_MS = 500;
|
|
37
|
+
const TAKEOVER_MAX_AGE_MS = 60_000;
|
|
38
|
+
/** How long a running turn gets to stop before omp closes anyway. */
|
|
39
|
+
const TAKEOVER_IDLE_WAIT_MS = 5_000;
|
|
29
40
|
const SUBCOMMANDS = [
|
|
30
41
|
{ value: "status", label: "status", description: "Is the daemon running, and where" },
|
|
31
42
|
{ value: "stop", label: "stop", description: "Stop the background daemon" },
|
|
@@ -138,9 +149,12 @@ async function startDaemon(cwd) {
|
|
|
138
149
|
throw new Error(`The omp-mobile daemon did not start (${reason}).\n${logTail()}`);
|
|
139
150
|
}
|
|
140
151
|
|
|
152
|
+
/** Time the daemon gets to close its omp processes (each up to SIGINT + SIGTERM waits) before it is killed. */
|
|
153
|
+
const STOP_TIMEOUT_MS = 25_000;
|
|
154
|
+
|
|
141
155
|
async function stopDaemon(state) {
|
|
142
156
|
process.kill(state.pid, "SIGTERM");
|
|
143
|
-
const deadline = Date.now() +
|
|
157
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
144
158
|
while (Date.now() < deadline && alive(state.pid)) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
145
159
|
if (alive(state.pid)) process.kill(state.pid, "SIGKILL");
|
|
146
160
|
}
|
|
@@ -180,7 +194,51 @@ export function pairingWidget({ title, code, notes }) {
|
|
|
180
194
|
});
|
|
181
195
|
}
|
|
182
196
|
|
|
197
|
+
/**
|
|
198
|
+
* The daemon's takeover request for this process and session, if there is a
|
|
199
|
+
* fresh one. A request for another process or an old one is left alone.
|
|
200
|
+
* @returns {{device: string} | undefined}
|
|
201
|
+
*/
|
|
202
|
+
export function takeoverRequest(sessionFile, sessionId, pid = process.pid, now = Date.now()) {
|
|
203
|
+
if (typeof sessionFile !== "string" || sessionFile === "") return undefined;
|
|
204
|
+
let request;
|
|
205
|
+
try {
|
|
206
|
+
request = JSON.parse(readFileSync(requestPath(sessionFile), "utf8"));
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
if (request?.pid !== pid || request.sessionId !== sessionId) return undefined;
|
|
211
|
+
if (typeof request.at !== "number" || now - request.at > TAKEOVER_MAX_AGE_MS) return undefined;
|
|
212
|
+
return { device: typeof request.device === "string" && request.device !== "" ? request.device : "OMP Mobile" };
|
|
213
|
+
}
|
|
214
|
+
|
|
183
215
|
export default function ompMobile(pi) {
|
|
216
|
+
/** The takeover watch for the session this omp has open. */
|
|
217
|
+
let watching;
|
|
218
|
+
let leaving = false;
|
|
219
|
+
|
|
220
|
+
pi.on("session_start", (_event, ctx) => {
|
|
221
|
+
if (watching !== undefined) ctx.clearTimer(watching);
|
|
222
|
+
watching = ctx.setInterval(async () => {
|
|
223
|
+
if (leaving) return;
|
|
224
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
225
|
+
const request = takeoverRequest(file, ctx.sessionManager.getSessionId());
|
|
226
|
+
if (request === undefined) return;
|
|
227
|
+
leaving = true;
|
|
228
|
+
// Taking the request is the answer the daemon waits for before it would signal.
|
|
229
|
+
rmSync(requestPath(file), { force: true });
|
|
230
|
+
// OMP closes once the session is idle; the daemon signals it if that does not come soon,
|
|
231
|
+
// and tells the terminal where the session went once omp is gone.
|
|
232
|
+
ctx.ui.notify(`OMP Mobile: this session continues on ${request.device}. Closing omp here…`, "warning");
|
|
233
|
+
if (!ctx.isIdle()) {
|
|
234
|
+
ctx.abort();
|
|
235
|
+
const deadline = Date.now() + TAKEOVER_IDLE_WAIT_MS;
|
|
236
|
+
while (!ctx.isIdle() && Date.now() < deadline) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
237
|
+
}
|
|
238
|
+
ctx.shutdown();
|
|
239
|
+
}, TAKEOVER_POLL_MS);
|
|
240
|
+
});
|
|
241
|
+
|
|
184
242
|
/** The pairing code on screen: its watcher and when it appeared. */
|
|
185
243
|
let showing;
|
|
186
244
|
|
package/src/omp-sessions.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function promptTitle(content) {
|
|
|
42
42
|
* A title as one line of at most 80 characters. OMP's title cards start with
|
|
43
43
|
* a Nerd Font icon (private-use code points) a phone font has no glyph for.
|
|
44
44
|
*/
|
|
45
|
-
function oneLine(text) {
|
|
45
|
+
export function oneLine(text) {
|
|
46
46
|
return text
|
|
47
47
|
.replace(/[\uE000-\uF8FF\u{F0000}-\u{10FFFF}]/gu, "")
|
|
48
48
|
.trim()
|
package/src/rpc-session.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { createInterface } from "node:readline";
|
|
|
12
12
|
import { RpcFrameDecoder, supportsProtocolV2 } from "./rpc-frame.mjs";
|
|
13
13
|
|
|
14
14
|
const START_TIMEOUT_MS = 45_000;
|
|
15
|
+
/** How long omp gets to leave after `SIGINT`, and again after `SIGTERM`, when the daemon closes it. */
|
|
16
|
+
const CLOSE_WAIT_MS = 10_000;
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* The environment without `BUN_BE_BUN`: /mobile starts the daemon under omp's
|
|
@@ -140,7 +142,15 @@ export class RpcSession extends EventEmitter {
|
|
|
140
142
|
stdio: ["pipe", "pipe", "pipe"],
|
|
141
143
|
});
|
|
142
144
|
this.child = child;
|
|
143
|
-
|
|
145
|
+
// A process that never started (missing binary, a cwd that was removed)
|
|
146
|
+
// emits `error` and no `exit`, so `closed` never comes: without its own
|
|
147
|
+
// signal the `ready` wait would sit out the whole start timeout.
|
|
148
|
+
child.on("error", (error) => {
|
|
149
|
+
this.closed = true;
|
|
150
|
+
this.lastError = error;
|
|
151
|
+
this.emit("start-error", error);
|
|
152
|
+
this.#fail(error);
|
|
153
|
+
});
|
|
144
154
|
child.on("exit", (code, signal) => {
|
|
145
155
|
this.closed = true;
|
|
146
156
|
// Nothing can answer the dialogs of a process that is gone.
|
|
@@ -155,7 +165,10 @@ export class RpcSession extends EventEmitter {
|
|
|
155
165
|
});
|
|
156
166
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
157
167
|
const ready = new Promise((resolve, reject) => {
|
|
158
|
-
const timer = setTimeout(
|
|
168
|
+
const timer = setTimeout(
|
|
169
|
+
() => reject(new Error(`omp RPC did not become ready in ${Math.round(START_TIMEOUT_MS / 1000)}s: ${this.stderr.trim().slice(-500) || "no output"}`)),
|
|
170
|
+
START_TIMEOUT_MS,
|
|
171
|
+
);
|
|
159
172
|
const onReady = (message) => {
|
|
160
173
|
clearTimeout(timer);
|
|
161
174
|
this.off("ready", onReady);
|
|
@@ -166,6 +179,14 @@ export class RpcSession extends EventEmitter {
|
|
|
166
179
|
clearTimeout(timer);
|
|
167
180
|
reject(new Error(`omp exited before ready: ${this.stderr.slice(-500)}`));
|
|
168
181
|
});
|
|
182
|
+
// `start()` is called from the daemon's request path: the reason a process
|
|
183
|
+
// cannot start (no omp on PATH, the session's directory is gone) has to
|
|
184
|
+
// reach the phone instead of a generic timeout.
|
|
185
|
+
this.once("start-error", (error) => {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
this.off("ready", onReady);
|
|
188
|
+
reject(new Error(`omp could not start in ${this.cwd}: ${error?.message ?? String(error)}`));
|
|
189
|
+
});
|
|
169
190
|
});
|
|
170
191
|
lines.on("line", (line) => this.#onLine(line));
|
|
171
192
|
const hello = await ready;
|
|
@@ -403,16 +424,47 @@ export class RpcSession extends EventEmitter {
|
|
|
403
424
|
this.#settle(id, answer.cancelled === true ? "cancelled" : "answered");
|
|
404
425
|
}
|
|
405
426
|
|
|
406
|
-
|
|
407
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Stop the omp child the way Ctrl+C does: `SIGINT`, then `SIGTERM`, each
|
|
429
|
+
* followed by a wait. OMP routes both through its teardown (the turn is
|
|
430
|
+
* aborted, the session file flushed, its lock released). Never `SIGKILL`: a
|
|
431
|
+
* process cut off mid-write can leave a session half saved. Resolves to how
|
|
432
|
+
* it left, or `"running"` when it ignored both signals.
|
|
433
|
+
* @param {{sigint?: number, sigterm?: number}} [waits] milliseconds after each signal
|
|
434
|
+
* @returns {Promise<"exited" | "SIGINT" | "SIGTERM" | "running">}
|
|
435
|
+
*/
|
|
436
|
+
close(waits = {}) {
|
|
437
|
+
if (this.closing !== undefined) return this.closing;
|
|
408
438
|
this.closed = true;
|
|
409
439
|
for (const pending of this.pending.values()) pending.reject(new Error("omp session closed"));
|
|
410
440
|
this.pending.clear();
|
|
411
|
-
this.child
|
|
412
|
-
|
|
413
|
-
if (
|
|
414
|
-
|
|
441
|
+
const child = this.child;
|
|
442
|
+
this.closing = (async () => {
|
|
443
|
+
if (child === undefined || child.exitCode !== null || child.signalCode !== null) return "exited";
|
|
444
|
+
const exited = new Promise((resolveExit) => child.once("exit", () => resolveExit(true)));
|
|
445
|
+
for (const [signal, wait] of /** @type {const} */ ([
|
|
446
|
+
["SIGINT", waits.sigint ?? CLOSE_WAIT_MS],
|
|
447
|
+
["SIGTERM", waits.sigterm ?? CLOSE_WAIT_MS],
|
|
448
|
+
])) {
|
|
449
|
+
try {
|
|
450
|
+
child.kill(signal);
|
|
451
|
+
} catch {
|
|
452
|
+
return "exited";
|
|
453
|
+
}
|
|
454
|
+
let timer;
|
|
455
|
+
const left = await Promise.race([
|
|
456
|
+
exited,
|
|
457
|
+
new Promise((resolveWait) => {
|
|
458
|
+
timer = setTimeout(() => resolveWait(false), wait);
|
|
459
|
+
}),
|
|
460
|
+
]);
|
|
461
|
+
clearTimeout(timer);
|
|
462
|
+
if (left) return signal;
|
|
463
|
+
}
|
|
464
|
+
return "running";
|
|
465
|
+
})();
|
|
466
|
+
return this.closing;
|
|
415
467
|
}
|
|
416
468
|
}
|
|
417
469
|
|
|
418
|
-
export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS, MESSAGE_PAGE_LIMIT };
|
|
470
|
+
export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS, MESSAGE_PAGE_LIMIT, CLOSE_WAIT_MS };
|
package/src/server.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { renderQr } from "./qr.mjs";
|
|
|
18
18
|
import { RpcSession } from "./rpc-session.mjs";
|
|
19
19
|
import { Admission, Tailscale, isLoopbackHost } from "./admission.mjs";
|
|
20
20
|
import { AttentionTracker, serveEvents } from "./attention.mjs";
|
|
21
|
-
import { listOmpSessions, promptTitle, sessionOwners } from "./omp-sessions.mjs";
|
|
21
|
+
import { listOmpSessions, oneLine, promptTitle, sessionOwners } from "./omp-sessions.mjs";
|
|
22
|
+
import { askOwnerToLeave, forksOf, isOmpCommand, processInfo, sessionBusy, takeoverFarewell, tellTerminal } from "./takeover.mjs";
|
|
22
23
|
|
|
23
24
|
const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
24
25
|
const PROTOCOL = 1;
|
|
@@ -42,6 +43,18 @@ const STATE_EVENTS = new Set([
|
|
|
42
43
|
"todo_auto_clear",
|
|
43
44
|
"goal_updated",
|
|
44
45
|
]);
|
|
46
|
+
/**
|
|
47
|
+
* How long OMP's session directory and owner locks are reused for, in ms.
|
|
48
|
+
* The phone probes every 15 s; without this every probe would rescan every
|
|
49
|
+
* session file (hundreds, often a gigabyte) and spawn `lsof` again.
|
|
50
|
+
*/
|
|
51
|
+
const SESSION_SCAN_TTL_MS = 3_000;
|
|
52
|
+
const OWNERS_TTL_MS = 1_500;
|
|
53
|
+
/** WebSocket keepalive: a protocol ping plus a `ping` frame, and a dead peer after one missed beat. */
|
|
54
|
+
const HEARTBEAT_MS = 20_000;
|
|
55
|
+
/** A live omp nobody has looked at for this long is closed; the next open resumes it. */
|
|
56
|
+
const IDLE_SESSION_MS = 30 * 60_000;
|
|
57
|
+
const REAP_INTERVAL_MS = 60_000;
|
|
45
58
|
/** Image types a phone may attach to a prompt: what the camera and the photo picker produce. */
|
|
46
59
|
const PROMPT_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
47
60
|
/** At most this many images per prompt, each at most this many decoded bytes. */
|
|
@@ -73,19 +86,31 @@ export function promptImages(value) {
|
|
|
73
86
|
|
|
74
87
|
/**
|
|
75
88
|
* 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
|
|
89
|
+
* the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
|
|
90
|
+
* `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds;
|
|
91
|
+
* `heartbeat`: session sockets get a `ping` frame every 20 s, so the app can
|
|
92
|
+
* treat a silent link as gone.
|
|
77
93
|
*/
|
|
78
|
-
const FEATURES = ["prompt-images"];
|
|
94
|
+
const FEATURES = ["prompt-images", "takeover", "heartbeat"];
|
|
95
|
+
/** Who a phone continues a session on, as the terminal it leaves is told. */
|
|
96
|
+
const DEFAULT_DEVICE = "OMP Mobile";
|
|
79
97
|
/** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
|
|
80
98
|
const OPEN_FAILED_CLOSE_CODE = 4000;
|
|
81
99
|
/** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
|
|
82
100
|
const BLOB_NAME = /^[0-9a-f]{64}$/;
|
|
83
101
|
|
|
84
|
-
/** @typedef {{
|
|
102
|
+
/** @typedef {{pid: number, busy: boolean, terminal: boolean}} SessionOwner */
|
|
103
|
+
/** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, owner?: SessionOwner, waiting?: number}} SessionSummary */
|
|
85
104
|
|
|
86
|
-
/** An error with the HTTP status it answers. */
|
|
87
|
-
function httpError(status, message) {
|
|
88
|
-
return Object.assign(new Error(message), { status });
|
|
105
|
+
/** An error with the HTTP status it answers, and any detail its JSON answer carries. */
|
|
106
|
+
function httpError(status, message, detail = {}) {
|
|
107
|
+
return Object.assign(new Error(message), { status, detail });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The JSON answer for a failed request: the reason and the error's detail (a 409's `owner`). */
|
|
111
|
+
function errorBody(error) {
|
|
112
|
+
const detail = error !== null && typeof error === "object" && typeof error.detail === "object" ? error.detail : {};
|
|
113
|
+
return { error: error instanceof Error ? error.message : String(error), ...detail };
|
|
89
114
|
}
|
|
90
115
|
|
|
91
116
|
function parseArgs(argv) {
|
|
@@ -238,6 +263,27 @@ function saveRegistry(path, registry) {
|
|
|
238
263
|
writeFileSync(path, `${JSON.stringify(Object.fromEntries(registry), null, 2)}\n`, { mode: 0o600 });
|
|
239
264
|
}
|
|
240
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The directory an omp starts in, recreated when it has been removed (a
|
|
268
|
+
* session in /tmp outlives its directory). Answers a 410 the phone can show
|
|
269
|
+
* when it cannot be recreated (a parent that is a file, no permission).
|
|
270
|
+
* @param {string} cwd
|
|
271
|
+
* @returns {string}
|
|
272
|
+
*/
|
|
273
|
+
export function ensureWorkingDirectory(cwd) {
|
|
274
|
+
try {
|
|
275
|
+
if (statSync(cwd).isDirectory()) return cwd;
|
|
276
|
+
} catch {
|
|
277
|
+
// Missing: recreate it below.
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
mkdirSync(cwd, { recursive: true });
|
|
281
|
+
return cwd;
|
|
282
|
+
} catch {
|
|
283
|
+
throw httpError(410, `the session's working directory is gone and cannot be recreated: ${cwd}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
241
287
|
function storedSummary(stored) {
|
|
242
288
|
return {
|
|
243
289
|
id: stored.id,
|
|
@@ -336,10 +382,10 @@ function readBody(req) {
|
|
|
336
382
|
});
|
|
337
383
|
}
|
|
338
384
|
|
|
339
|
-
function titleOf(messages, state, cwd) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
385
|
+
export function titleOf(messages, state, cwd) {
|
|
386
|
+
// OMP's session names can start with a Nerd Font icon, as its stored titles do (see `oneLine`).
|
|
387
|
+
const named = typeof state?.sessionName === "string" ? oneLine(state.sessionName) : "";
|
|
388
|
+
if (named !== "") return named;
|
|
343
389
|
if (Array.isArray(messages)) {
|
|
344
390
|
const title = promptTitle(messages.find((message) => message?.role === "user")?.content);
|
|
345
391
|
if (title !== "") return title;
|
|
@@ -377,6 +423,8 @@ class SessionEntry {
|
|
|
377
423
|
*/
|
|
378
424
|
this.onBroadcast = undefined;
|
|
379
425
|
this.closed = false;
|
|
426
|
+
/** When the last phone left; undefined while one is connected. The reaper reads it. */
|
|
427
|
+
this.idleSince = undefined;
|
|
380
428
|
}
|
|
381
429
|
|
|
382
430
|
async hydrate() {
|
|
@@ -472,10 +520,11 @@ class SessionEntry {
|
|
|
472
520
|
return this.rpc.extensionRequests instanceof Map ? [...this.rpc.extensionRequests.values()] : [];
|
|
473
521
|
}
|
|
474
522
|
|
|
523
|
+
/** Disconnect the phones and stop omp gracefully (see `RpcSession.close`). */
|
|
475
524
|
close() {
|
|
476
525
|
this.closed = true;
|
|
477
|
-
this.rpc.close();
|
|
478
526
|
for (const client of this.clients) client.close(1001, "session closed");
|
|
527
|
+
return this.rpc.close();
|
|
479
528
|
}
|
|
480
529
|
}
|
|
481
530
|
|
|
@@ -509,8 +558,18 @@ class Daemon {
|
|
|
509
558
|
this.streams = new Set();
|
|
510
559
|
/** OMP's own sessions as last listed, for resuming one the app picked. @type {Map<string, import("./omp-sessions.mjs").OmpSession>} */
|
|
511
560
|
this.ompSessions = new Map();
|
|
561
|
+
/** The last full scan of OMP's session directory, reused within `SESSION_SCAN_TTL_MS`. */
|
|
562
|
+
this.sessionScan = { at: 0, list: /** @type {import("./omp-sessions.mjs").OmpSession[]} */ ([]), pending: undefined };
|
|
563
|
+
/** The last `lsof` of the owner locks, pending or settled, reused within `OWNERS_TTL_MS`. */
|
|
564
|
+
this.ownerLookup = { at: 0, owners: /** @type {Promise<Map<string, number> | undefined> | undefined} */ (undefined) };
|
|
565
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
566
|
+
this.heartbeat = undefined;
|
|
567
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
568
|
+
this.reaper = undefined;
|
|
512
569
|
/** Resumes in flight, so two opens of one session start one process. @type {Map<string, Promise<SessionEntry>>} */
|
|
513
570
|
this.starting = new Map();
|
|
571
|
+
/** Takeovers in flight, so two phones asking at once ask the owner once. @type {Map<string, Promise<object>>} */
|
|
572
|
+
this.takingOver = new Map();
|
|
514
573
|
this.server = createServer((req, res) => void this.admitHttp(req, res));
|
|
515
574
|
this.wsServer = new WebSocketServer({ noServer: true });
|
|
516
575
|
this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
|
|
@@ -624,6 +683,13 @@ class Daemon {
|
|
|
624
683
|
sendJson(res, 201, { session: entry.summary() });
|
|
625
684
|
return;
|
|
626
685
|
}
|
|
686
|
+
const takeover = /^\/sessions\/([^/]+)\/takeover$/.exec(url.pathname);
|
|
687
|
+
if (takeover !== null && req.method === "POST") {
|
|
688
|
+
const body = await readBody(req);
|
|
689
|
+
const device = typeof body.device === "string" && body.device.trim() !== "" ? body.device.trim().slice(0, 60) : DEFAULT_DEVICE;
|
|
690
|
+
sendJson(res, 200, await this.takeOver(decodeURIComponent(takeover[1]), device));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
627
693
|
const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
|
|
628
694
|
if (match !== null) {
|
|
629
695
|
const id = decodeURIComponent(match[1]);
|
|
@@ -636,6 +702,7 @@ class Daemon {
|
|
|
636
702
|
const live = this.sessions.get(id);
|
|
637
703
|
if (live !== undefined) this.attention.forget(live);
|
|
638
704
|
live?.close();
|
|
705
|
+
this.forgetOwners();
|
|
639
706
|
this.sessions.delete(id);
|
|
640
707
|
this.registry.delete(id);
|
|
641
708
|
saveRegistry(this.options.registryFile, this.registry);
|
|
@@ -648,7 +715,7 @@ class Daemon {
|
|
|
648
715
|
const status = typeof error?.status === "number" ? error.status : 500;
|
|
649
716
|
// A refusal the API defines (404, 409, 410) is an answer; anything else is a failure worth keeping.
|
|
650
717
|
if (status >= 500) this.log(error);
|
|
651
|
-
sendJson(res, status,
|
|
718
|
+
sendJson(res, status, errorBody(error));
|
|
652
719
|
}
|
|
653
720
|
}
|
|
654
721
|
|
|
@@ -664,23 +731,65 @@ class Daemon {
|
|
|
664
731
|
.filter((stored) => !known.has(stored.id))
|
|
665
732
|
.map((stored) => storedSummary(stored));
|
|
666
733
|
for (const session of dormant) known.add(session.id);
|
|
667
|
-
const [found, owners] = await Promise.all([
|
|
668
|
-
listOmpSessions(join(this.options.ompHome, "agent", "sessions"), { limit: OMP_SESSION_LIMIT }),
|
|
669
|
-
this.owners(),
|
|
670
|
-
]);
|
|
734
|
+
const [found, owners] = await Promise.all([this.scanOmpSessions(), this.owners()]);
|
|
671
735
|
this.ompSessions = new Map(found.map((session) => [session.id, session]));
|
|
672
736
|
const others = found
|
|
673
737
|
.filter((session) => !known.has(session.id))
|
|
674
738
|
.map((session) => ({ ...session, status: "idle", origin: "omp" }));
|
|
675
|
-
const marked =
|
|
676
|
-
|
|
739
|
+
const marked = await Promise.all(
|
|
740
|
+
[...dormant, ...others].map(async (session) => {
|
|
741
|
+
const pid = owners?.get(session.id);
|
|
742
|
+
if (pid === undefined) return session;
|
|
743
|
+
return { ...session, openElsewhere: true, owner: await this.ownerOf(pid, session.sessionFile) };
|
|
744
|
+
}),
|
|
677
745
|
);
|
|
678
746
|
return [...live, ...marked];
|
|
679
747
|
}
|
|
680
748
|
|
|
681
|
-
/**
|
|
749
|
+
/**
|
|
750
|
+
* What the phone is told about the process holding a session: whether it is
|
|
751
|
+
* mid-turn (by the session file's last message) and runs in a terminal.
|
|
752
|
+
* @returns {Promise<SessionSummary["owner"]>}
|
|
753
|
+
*/
|
|
754
|
+
async ownerOf(pid, sessionFile) {
|
|
755
|
+
const info = await processInfo(pid);
|
|
756
|
+
return { pid, busy: typeof sessionFile === "string" && sessionBusy(sessionFile), terminal: info?.tty !== undefined };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* OMP's sessions, rescanned at most every `SESSION_SCAN_TTL_MS`: a phone's
|
|
761
|
+
* probes ask for the same list every few seconds, and reading the first
|
|
762
|
+
* 512 KB of up to 50 of hundreds of files is the daemon's most expensive read.
|
|
763
|
+
*/
|
|
764
|
+
scanOmpSessions() {
|
|
765
|
+
const scan = this.sessionScan;
|
|
766
|
+
if (Date.now() - scan.at < SESSION_SCAN_TTL_MS) return Promise.resolve(scan.list);
|
|
767
|
+
if (scan.pending !== undefined) return scan.pending;
|
|
768
|
+
scan.pending = listOmpSessions(join(this.options.ompHome, "agent", "sessions"), { limit: OMP_SESSION_LIMIT }).then(
|
|
769
|
+
(list) => {
|
|
770
|
+
this.sessionScan = { at: Date.now(), list, pending: undefined };
|
|
771
|
+
return list;
|
|
772
|
+
},
|
|
773
|
+
(error) => {
|
|
774
|
+
scan.pending = undefined;
|
|
775
|
+
throw error;
|
|
776
|
+
},
|
|
777
|
+
);
|
|
778
|
+
return scan.pending;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Which sessions some omp process holds open (`undefined`: cannot tell). Cached for `OWNERS_TTL_MS`. */
|
|
682
782
|
owners() {
|
|
683
|
-
|
|
783
|
+
const lookup = this.ownerLookup;
|
|
784
|
+
if (lookup.owners !== undefined && Date.now() - lookup.at < OWNERS_TTL_MS) return lookup.owners;
|
|
785
|
+
const owners = sessionOwners(join(this.options.ompHome, "run", "session-owners"));
|
|
786
|
+
this.ownerLookup = { at: Date.now(), owners };
|
|
787
|
+
return owners;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The owner locks changed (a takeover, an omp closing): the next look must not reuse the cache. */
|
|
791
|
+
forgetOwners() {
|
|
792
|
+
this.ownerLookup = { at: 0, owners: undefined };
|
|
684
793
|
}
|
|
685
794
|
|
|
686
795
|
remember(entry) {
|
|
@@ -719,12 +828,20 @@ class Daemon {
|
|
|
719
828
|
if (!existsSync(stored.sessionFile)) throw httpError(410, "session file is gone");
|
|
720
829
|
// A second omp on the same file would fork it into a new one; the session
|
|
721
830
|
// stays with the process that has it (a terminal) until that one exits.
|
|
831
|
+
// This comes before the directory check: a held session is a takeover, not
|
|
832
|
+
// a broken one, and the phone's Take over button needs the 409.
|
|
722
833
|
const owner = (await this.owners())?.get(id);
|
|
723
834
|
if (owner !== undefined) {
|
|
724
|
-
throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here
|
|
835
|
+
throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here.`, {
|
|
836
|
+
owner: await this.ownerOf(owner, stored.sessionFile),
|
|
837
|
+
});
|
|
725
838
|
}
|
|
839
|
+
// OMP refuses to start in a directory that no longer exists (a session in
|
|
840
|
+
// /tmp whose directory was cleaned up). Recreate it here instead of letting
|
|
841
|
+
// the start wait out its timeout and answer with a bare 500.
|
|
842
|
+
const cwd = ensureWorkingDirectory(stored.cwd);
|
|
726
843
|
const rpc = new RpcSession({
|
|
727
|
-
cwd
|
|
844
|
+
cwd,
|
|
728
845
|
ompBin: this.options.omp,
|
|
729
846
|
resume: stored.sessionFile,
|
|
730
847
|
autoApprove: this.options.autoApprove,
|
|
@@ -750,6 +867,57 @@ class Daemon {
|
|
|
750
867
|
}
|
|
751
868
|
}
|
|
752
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Continue a session another omp process holds: ask that process to leave
|
|
872
|
+
* (see `takeover.mjs`), then open the session here. Concurrent takeovers of
|
|
873
|
+
* one session share one attempt.
|
|
874
|
+
* @param {string} id
|
|
875
|
+
* @param {string} device Who continues it, as the terminal is told.
|
|
876
|
+
*/
|
|
877
|
+
takeOver(id, device) {
|
|
878
|
+
let running = this.takingOver.get(id);
|
|
879
|
+
if (running === undefined) {
|
|
880
|
+
running = this.runTakeover(id, device).finally(() => this.takingOver.delete(id));
|
|
881
|
+
this.takingOver.set(id, running);
|
|
882
|
+
}
|
|
883
|
+
return running;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async runTakeover(id, device) {
|
|
887
|
+
const live = this.sessions.get(id);
|
|
888
|
+
if (live !== undefined && !live.closed) return { ...live.snapshot(), takeover: { pid: null, via: "live", waitedMs: 0, busy: false, forks: [] } };
|
|
889
|
+
// The phone may take over a session listed before this daemon started.
|
|
890
|
+
if (!this.registry.has(id) && !this.ompSessions.has(id)) await this.listSessions();
|
|
891
|
+
const stored = this.registry.get(id) ?? this.ompSessions.get(id);
|
|
892
|
+
if (stored === undefined || typeof stored.sessionFile !== "string") throw httpError(404, "unknown session");
|
|
893
|
+
if (!existsSync(stored.sessionFile)) throw httpError(410, "session file is gone");
|
|
894
|
+
const owners = await this.owners();
|
|
895
|
+
if (owners === undefined) throw httpError(503, "Cannot tell which process holds this session (lsof is not available).");
|
|
896
|
+
const pid = owners.get(id);
|
|
897
|
+
let outcome = { pid: null, via: "free", waitedMs: 0, busy: false, forks: [] };
|
|
898
|
+
if (pid !== undefined) {
|
|
899
|
+
const info = await processInfo(pid);
|
|
900
|
+
if (info !== undefined && !isOmpCommand(info.command)) {
|
|
901
|
+
throw httpError(409, `The process holding this session (pid ${pid}) is not omp; it is left alone.`);
|
|
902
|
+
}
|
|
903
|
+
const busy = sessionBusy(stored.sessionFile);
|
|
904
|
+
const since = Date.now();
|
|
905
|
+
const left = await askOwnerToLeave({ pid, sessionId: id, sessionFile: stored.sessionFile, device, timings: this.options.takeoverTimings });
|
|
906
|
+
// The kernel drops the lock with the process; give a slow exit a moment.
|
|
907
|
+
const deadline = Date.now() + 3_000;
|
|
908
|
+
while ((await this.owners())?.get(id) === pid && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 100));
|
|
909
|
+
// Whoever looks at the terminal sees why omp closed.
|
|
910
|
+
if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell(device));
|
|
911
|
+
const forks = forksOf(dirname(stored.sessionFile), id, since);
|
|
912
|
+
if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
|
|
913
|
+
outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
|
|
914
|
+
}
|
|
915
|
+
// The owner left: the cached lock map is stale and would refuse this open.
|
|
916
|
+
this.forgetOwners();
|
|
917
|
+
const entry = await this.ensureLive(id);
|
|
918
|
+
return { ...entry.snapshot(), takeover: outcome };
|
|
919
|
+
}
|
|
920
|
+
|
|
753
921
|
async createSession(cwd) {
|
|
754
922
|
const rpc = new RpcSession({
|
|
755
923
|
cwd,
|
|
@@ -789,29 +957,62 @@ class Daemon {
|
|
|
789
957
|
return;
|
|
790
958
|
}
|
|
791
959
|
this.noteClient(req);
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
960
|
+
const id = decodeURIComponent(match[1]);
|
|
961
|
+
// Finish the handshake before the session is open: a slow omp can take
|
|
962
|
+
// tens of seconds, and a phone's WebSocket client gives up after ~10 s --
|
|
963
|
+
// then retries, starting another omp every time. The snapshot (or
|
|
964
|
+
// `open_failed`) follows on the open socket instead.
|
|
965
|
+
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
966
|
+
ws.isAlive = true;
|
|
967
|
+
ws.on("pong", () => {
|
|
968
|
+
ws.isAlive = true;
|
|
969
|
+
});
|
|
970
|
+
/** @type {SessionEntry | undefined} */
|
|
971
|
+
let entry;
|
|
972
|
+
/** Frames the phone sent before the session was ready. */
|
|
973
|
+
const early = [];
|
|
974
|
+
ws.on("message", (data) => {
|
|
975
|
+
if (entry === undefined) {
|
|
976
|
+
early.push(data.toString());
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
void this.handleClientMessage(entry, ws, data.toString());
|
|
980
|
+
});
|
|
981
|
+
ws.on("close", () => {
|
|
982
|
+
if (entry !== undefined) this.detach(entry, ws);
|
|
983
|
+
});
|
|
984
|
+
ws.on("error", () => {
|
|
985
|
+
if (entry !== undefined) this.detach(entry, ws);
|
|
986
|
+
});
|
|
987
|
+
void this.ensureLive(id).then(
|
|
988
|
+
(live) => {
|
|
989
|
+
entry = live;
|
|
990
|
+
if (ws.readyState !== 1) return;
|
|
991
|
+
live.clients.add(ws);
|
|
992
|
+
live.idleSince = undefined;
|
|
993
|
+
ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot() }));
|
|
797
994
|
// A dialog opened before this client connected (it may have come from a notification tap).
|
|
798
|
-
for (const request of
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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) }));
|
|
995
|
+
for (const request of live.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
|
|
996
|
+
for (const data of early) void this.handleClientMessage(live, ws, data);
|
|
997
|
+
early.length = 0;
|
|
998
|
+
},
|
|
999
|
+
(error) => {
|
|
1000
|
+
const status = typeof error?.status === "number" ? error.status : 500;
|
|
1001
|
+
if (status >= 500) this.log(error);
|
|
1002
|
+
if (ws.readyState !== 1) return;
|
|
1003
|
+
// Say why the session cannot open and close: the app shows the reason
|
|
1004
|
+
// and stops retrying what a retry cannot fix.
|
|
1005
|
+
ws.send(JSON.stringify({ type: "open_failed", status, ...errorBody(error) }));
|
|
812
1006
|
ws.close(OPEN_FAILED_CLOSE_CODE, "session did not open");
|
|
813
|
-
}
|
|
814
|
-
|
|
1007
|
+
},
|
|
1008
|
+
);
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** A phone left a session: it counts as idle from now, for the reaper. */
|
|
1013
|
+
detach(entry, ws) {
|
|
1014
|
+
if (!entry.clients.delete(ws)) return;
|
|
1015
|
+
if (entry.clients.size === 0) entry.idleSince = Date.now();
|
|
815
1016
|
}
|
|
816
1017
|
|
|
817
1018
|
async handleClientMessage(entry, ws, text) {
|
|
@@ -910,15 +1111,65 @@ class Daemon {
|
|
|
910
1111
|
|
|
911
1112
|
listen() {
|
|
912
1113
|
return new Promise((resolveListen) => {
|
|
913
|
-
this.server.listen(this.options.port, this.options.host, () =>
|
|
1114
|
+
this.server.listen(this.options.port, this.options.host, () => {
|
|
1115
|
+
this.startTimers();
|
|
1116
|
+
resolveListen();
|
|
1117
|
+
});
|
|
914
1118
|
});
|
|
915
1119
|
}
|
|
916
1120
|
|
|
1121
|
+
/** Keepalive pings and the idle-session reaper, once the daemon listens. */
|
|
1122
|
+
startTimers() {
|
|
1123
|
+
this.heartbeat = setInterval(() => this.pingClients(), this.options.heartbeatMs ?? HEARTBEAT_MS);
|
|
1124
|
+
this.heartbeat.unref?.();
|
|
1125
|
+
this.reaper = setInterval(() => this.reapIdleSessions(), this.options.reapIntervalMs ?? REAP_INTERVAL_MS);
|
|
1126
|
+
this.reaper.unref?.();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Ping every WebSocket and drop the ones that missed the previous beat. A
|
|
1131
|
+
* protocol ping plus a small frame keeps NAT and phone radios from dropping
|
|
1132
|
+
* an idle session silently; the app ignores the frame, older ones too.
|
|
1133
|
+
*/
|
|
1134
|
+
pingClients() {
|
|
1135
|
+
const beat = JSON.stringify({ type: "ping", at: Date.now() });
|
|
1136
|
+
for (const ws of this.wsServer.clients) {
|
|
1137
|
+
if (ws.isAlive === false) {
|
|
1138
|
+
ws.terminate();
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
ws.isAlive = false;
|
|
1142
|
+
try {
|
|
1143
|
+
ws.ping();
|
|
1144
|
+
ws.send(beat);
|
|
1145
|
+
} catch {
|
|
1146
|
+
// Closing; the close handler cleans up.
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Close live sessions no phone has looked at for `IDLE_SESSION_MS` (never mid-turn). */
|
|
1152
|
+
reapIdleSessions() {
|
|
1153
|
+
const now = Date.now();
|
|
1154
|
+
for (const [id, entry] of this.sessions) {
|
|
1155
|
+
if (entry.clients.size > 0 || entry.state?.isStreaming === true) continue;
|
|
1156
|
+
if (now - (entry.idleSince ?? entry.updatedAt) < (this.options.idleSessionMs ?? IDLE_SESSION_MS)) continue;
|
|
1157
|
+
entry.close();
|
|
1158
|
+
this.sessions.delete(id);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
917
1162
|
async close() {
|
|
1163
|
+
clearInterval(this.heartbeat);
|
|
1164
|
+
clearInterval(this.reaper);
|
|
918
1165
|
for (const end of this.streams) end();
|
|
919
1166
|
this.streams.clear();
|
|
920
|
-
|
|
1167
|
+
// Each omp saves and releases its session before the daemon exits.
|
|
1168
|
+
const closing = [...this.sessions.values()].map((entry) => entry.close());
|
|
921
1169
|
this.sessions.clear();
|
|
1170
|
+
const outcomes = await Promise.all(closing);
|
|
1171
|
+
const stuck = outcomes.filter((outcome) => outcome === "running").length;
|
|
1172
|
+
if (stuck > 0) console.error(`omp-mobile: ${stuck} omp process(es) ignored SIGINT and SIGTERM and are still running`);
|
|
922
1173
|
this.wsServer.close();
|
|
923
1174
|
await new Promise((resolveClose) => this.server.close(() => resolveClose()));
|
|
924
1175
|
}
|
package/src/takeover.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taking a session over from the other omp process that holds it.
|
|
3
|
+
*
|
|
4
|
+
* OMP lets one process write a session: the first write takes the session's
|
|
5
|
+
* owner lock (`<omp home>/run/session-owners/<id>.lock`, an OS lock the
|
|
6
|
+
* kernel drops when the process exits), and a second process that writes the
|
|
7
|
+
* same session moves to a new file (a fork with `parentSession` set) instead
|
|
8
|
+
* of mixing entries into the owner's journal. OMP has no way to attach a
|
|
9
|
+
* second process to a running one (Collab needs the owner to host a room on
|
|
10
|
+
* a relay), so the phone continues a session by asking its owner to leave:
|
|
11
|
+
*
|
|
12
|
+
* 1. A request file beside the session file (`.<name>.omp-mobile-takeover`)
|
|
13
|
+
* that the omp-mobile extension in the owner watches: it says where the
|
|
14
|
+
* session went, stops the turn, and exits through OMP's own shutdown.
|
|
15
|
+
* 2. Without an answer from the extension, the owner gets `SIGINT`, then
|
|
16
|
+
* `SIGTERM`: OMP routes both through the teardown its Ctrl+C runs (the
|
|
17
|
+
* turn is aborted, the session flushed and closed with a `session_exit`
|
|
18
|
+
* entry, the lock released). Never `SIGKILL`.
|
|
19
|
+
*
|
|
20
|
+
* Only the owner is ever signalled: a process that holds the session without
|
|
21
|
+
* owning it would fork it into a new file as it saves on exit.
|
|
22
|
+
*/
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { closeSync, constants, openSync, readdirSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
25
|
+
import { basename, dirname, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long each step waits before the next, in milliseconds. The extension
|
|
29
|
+
* path (a terminal with the plugin loaded) answers within a second or two;
|
|
30
|
+
* the signal path is the fallback for an omp that predates the extension, and
|
|
31
|
+
* OMP leaves on SIGINT promptly, so the waits are short rather than generous.
|
|
32
|
+
*/
|
|
33
|
+
export const TAKEOVER_TIMINGS = {
|
|
34
|
+
/** For the extension to pick up the request (it polls every 500 ms). */
|
|
35
|
+
ack: 1_500,
|
|
36
|
+
/** For an owner whose extension took the request to exit (OMP closes once its session is idle). */
|
|
37
|
+
extensionExit: 10_000,
|
|
38
|
+
/** After `SIGINT`, then after `SIGTERM`. */
|
|
39
|
+
sigint: 5_000,
|
|
40
|
+
sigterm: 5_000,
|
|
41
|
+
poll: 100,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** How far from the end of a session file the last message is looked for. */
|
|
45
|
+
const TAIL_BYTES = 256 * 1024;
|
|
46
|
+
|
|
47
|
+
/** The request file the owner's extension watches. */
|
|
48
|
+
export function requestPath(sessionFile) {
|
|
49
|
+
return join(dirname(sessionFile), `.${basename(sessionFile)}.omp-mobile-takeover`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether `pid` is a live process this user may signal. */
|
|
53
|
+
export function alive(pid) {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, 0);
|
|
56
|
+
return true;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return error?.code === "EPERM";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a process is: its command and controlling terminal (`ttys007`), or
|
|
64
|
+
* `undefined` when there is no such process.
|
|
65
|
+
* @returns {Promise<{pid: number, command: string, tty: string | undefined} | undefined>}
|
|
66
|
+
*/
|
|
67
|
+
export function processInfo(pid) {
|
|
68
|
+
return new Promise((resolveInfo) => {
|
|
69
|
+
execFile("ps", ["-o", "tty=", "-o", "args=", "-p", String(pid)], { timeout: 5_000 }, (error, stdout) => {
|
|
70
|
+
const line = stdout?.split("\n").find((text) => text.trim() !== "");
|
|
71
|
+
if (error || line === undefined) {
|
|
72
|
+
resolveInfo(undefined);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const [, tty = "", command = ""] = /^\s*(\S+)\s+(.*)$/.exec(line) ?? [];
|
|
76
|
+
resolveInfo({ pid, command: command.trim(), tty: /^(\?+|-)$/.test(tty) || tty === "" ? undefined : tty });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a command line runs omp: the binary, or an interpreter running a script named omp. */
|
|
82
|
+
export function isOmpCommand(command) {
|
|
83
|
+
const [program = "", script = ""] = command.trim().split(/\s+/);
|
|
84
|
+
return /(^|\/)omp$/.test(program) || /(^|\/)(node|bun)$/.test(program) && /(^|\/)omp$/.test(script);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the session's last message leaves a turn running: a prompt or tool
|
|
89
|
+
* result not answered yet, or an assistant message that called tools. OMP
|
|
90
|
+
* writes the assistant's reply when it ends, so a streaming turn ends in the
|
|
91
|
+
* user's prompt.
|
|
92
|
+
* @param {string} sessionFile
|
|
93
|
+
*/
|
|
94
|
+
export function sessionBusy(sessionFile) {
|
|
95
|
+
let fd;
|
|
96
|
+
try {
|
|
97
|
+
fd = openSync(sessionFile, "r");
|
|
98
|
+
const size = statSync(sessionFile).size;
|
|
99
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
100
|
+
const buffer = Buffer.alloc(length);
|
|
101
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
102
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
103
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
104
|
+
let entry;
|
|
105
|
+
try {
|
|
106
|
+
entry = JSON.parse(lines[i]);
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (entry?.type !== "message" || entry.message === null || typeof entry.message !== "object") continue;
|
|
111
|
+
const { role, stopReason } = entry.message;
|
|
112
|
+
if (role === "assistant") return stopReason === "toolUse";
|
|
113
|
+
return role === "user" || role === "toolResult";
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
} finally {
|
|
119
|
+
if (fd !== undefined) closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Session files in `dir` forked from `sessionId` (their header's
|
|
125
|
+
* `parentSession`) and written since `since`: what a second writer leaves.
|
|
126
|
+
*/
|
|
127
|
+
export function forksOf(dir, sessionId, since) {
|
|
128
|
+
let names;
|
|
129
|
+
try {
|
|
130
|
+
names = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const forks = [];
|
|
135
|
+
for (const name of names) {
|
|
136
|
+
const file = join(dir, name);
|
|
137
|
+
let fd;
|
|
138
|
+
try {
|
|
139
|
+
if (statSync(file).mtimeMs < since) continue;
|
|
140
|
+
fd = openSync(file, "r");
|
|
141
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
142
|
+
const head = buffer.toString("utf8", 0, readSync(fd, buffer, 0, buffer.length, 0));
|
|
143
|
+
for (const line of head.split("\n").slice(0, 3)) {
|
|
144
|
+
try {
|
|
145
|
+
const entry = JSON.parse(line);
|
|
146
|
+
if (entry?.type === "session" && entry.parentSession === sessionId) forks.push(file);
|
|
147
|
+
} catch {
|
|
148
|
+
// Not a header line.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Removed while looking.
|
|
153
|
+
} finally {
|
|
154
|
+
if (fd !== undefined) closeSync(fd);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return forks;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Write a line to a terminal (`ttys007`) the owner ran in, after it left, so
|
|
162
|
+
* whoever looks at it sees why omp closed. Best effort.
|
|
163
|
+
*/
|
|
164
|
+
export function tellTerminal(tty, text) {
|
|
165
|
+
if (!/^tty[A-Za-z0-9]+$/.test(tty)) return false;
|
|
166
|
+
let fd;
|
|
167
|
+
try {
|
|
168
|
+
fd = openSync(`/dev/${tty}`, constants.O_WRONLY | constants.O_NOCTTY | constants.O_NONBLOCK);
|
|
169
|
+
writeSync(fd, `\r\n${text}\r\n`);
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
} finally {
|
|
174
|
+
if (fd !== undefined) closeSync(fd);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What the terminal is told once omp left it. */
|
|
179
|
+
export function takeoverFarewell(device) {
|
|
180
|
+
return `omp-mobile: this session continues on ${device}. omp closed here so the phone could take it over.`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
184
|
+
|
|
185
|
+
/** Wait until `done()` or `ms` passed; whether it became true. */
|
|
186
|
+
async function waitFor(done, ms, poll) {
|
|
187
|
+
const deadline = Date.now() + ms;
|
|
188
|
+
while (!done()) {
|
|
189
|
+
if (Date.now() >= deadline) return false;
|
|
190
|
+
await sleep(poll);
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function exists(path) {
|
|
196
|
+
try {
|
|
197
|
+
statSync(path);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Ask the owner of a session to leave, escalating until it exited.
|
|
206
|
+
* Resolves how it left; rejects (status 504) when it is still running after
|
|
207
|
+
* `SIGTERM`, in which case it still has the session.
|
|
208
|
+
* @param {{pid: number, sessionId: string, sessionFile: string, device: string, timings?: Partial<typeof TAKEOVER_TIMINGS>, kill?: (pid: number, signal: string) => void, isAlive?: (pid: number) => boolean}} options
|
|
209
|
+
* @returns {Promise<{via: "extension" | "SIGINT" | "SIGTERM" | "exited", waitedMs: number}>}
|
|
210
|
+
*/
|
|
211
|
+
export async function askOwnerToLeave({ pid, sessionId, sessionFile, device, timings = {}, kill = process.kill.bind(process), isAlive = alive }) {
|
|
212
|
+
const t = { ...TAKEOVER_TIMINGS, ...timings };
|
|
213
|
+
const started = Date.now();
|
|
214
|
+
const gone = () => !isAlive(pid);
|
|
215
|
+
const result = (via) => ({ via, waitedMs: Date.now() - started });
|
|
216
|
+
if (gone()) return result("exited");
|
|
217
|
+
|
|
218
|
+
const request = requestPath(sessionFile);
|
|
219
|
+
const staging = `${request}.${process.pid}.tmp`;
|
|
220
|
+
let written = false;
|
|
221
|
+
try {
|
|
222
|
+
writeFileSync(staging, `${JSON.stringify({ pid, sessionId, device, at: Date.now() })}\n`, { mode: 0o600 });
|
|
223
|
+
renameSync(staging, request);
|
|
224
|
+
written = true;
|
|
225
|
+
} catch {
|
|
226
|
+
rmSync(staging, { force: true });
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
// The extension removes the request when it takes it, then shuts omp down.
|
|
230
|
+
if (written && (await waitFor(() => gone() || !exists(request), t.ack, t.poll))) {
|
|
231
|
+
const taken = !exists(request);
|
|
232
|
+
if (await waitFor(gone, taken ? t.extensionExit : 0, t.poll)) return result(taken ? "extension" : "exited");
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
rmSync(request, { force: true });
|
|
236
|
+
}
|
|
237
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
238
|
+
try {
|
|
239
|
+
kill(pid, signal);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code === "ESRCH") return result("exited");
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
if (await waitFor(gone, signal === "SIGINT" ? t.sigint : t.sigterm, t.poll)) return result(signal);
|
|
245
|
+
}
|
|
246
|
+
throw Object.assign(new Error(`The omp process (pid ${pid}) did not exit after SIGINT and SIGTERM; it still has this session.`), {
|
|
247
|
+
status: 504,
|
|
248
|
+
});
|
|
249
|
+
}
|