omp-mobile 0.3.0 → 0.4.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/package.json +1 -1
- package/src/omp-extension.mjs +60 -2
- package/src/omp-sessions.mjs +1 -1
- package/src/rpc-session.mjs +40 -7
- package/src/server.mjs +108 -18
- package/src/takeover.mjs +244 -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 = 1_000;
|
|
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
|
|
@@ -403,16 +405,47 @@ export class RpcSession extends EventEmitter {
|
|
|
403
405
|
this.#settle(id, answer.cancelled === true ? "cancelled" : "answered");
|
|
404
406
|
}
|
|
405
407
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
+
/**
|
|
409
|
+
* Stop the omp child the way Ctrl+C does: `SIGINT`, then `SIGTERM`, each
|
|
410
|
+
* followed by a wait. OMP routes both through its teardown (the turn is
|
|
411
|
+
* aborted, the session file flushed, its lock released). Never `SIGKILL`: a
|
|
412
|
+
* process cut off mid-write can leave a session half saved. Resolves to how
|
|
413
|
+
* it left, or `"running"` when it ignored both signals.
|
|
414
|
+
* @param {{sigint?: number, sigterm?: number}} [waits] milliseconds after each signal
|
|
415
|
+
* @returns {Promise<"exited" | "SIGINT" | "SIGTERM" | "running">}
|
|
416
|
+
*/
|
|
417
|
+
close(waits = {}) {
|
|
418
|
+
if (this.closing !== undefined) return this.closing;
|
|
408
419
|
this.closed = true;
|
|
409
420
|
for (const pending of this.pending.values()) pending.reject(new Error("omp session closed"));
|
|
410
421
|
this.pending.clear();
|
|
411
|
-
this.child
|
|
412
|
-
|
|
413
|
-
if (
|
|
414
|
-
|
|
422
|
+
const child = this.child;
|
|
423
|
+
this.closing = (async () => {
|
|
424
|
+
if (child === undefined || child.exitCode !== null || child.signalCode !== null) return "exited";
|
|
425
|
+
const exited = new Promise((resolveExit) => child.once("exit", () => resolveExit(true)));
|
|
426
|
+
for (const [signal, wait] of /** @type {const} */ ([
|
|
427
|
+
["SIGINT", waits.sigint ?? CLOSE_WAIT_MS],
|
|
428
|
+
["SIGTERM", waits.sigterm ?? CLOSE_WAIT_MS],
|
|
429
|
+
])) {
|
|
430
|
+
try {
|
|
431
|
+
child.kill(signal);
|
|
432
|
+
} catch {
|
|
433
|
+
return "exited";
|
|
434
|
+
}
|
|
435
|
+
let timer;
|
|
436
|
+
const left = await Promise.race([
|
|
437
|
+
exited,
|
|
438
|
+
new Promise((resolveWait) => {
|
|
439
|
+
timer = setTimeout(() => resolveWait(false), wait);
|
|
440
|
+
}),
|
|
441
|
+
]);
|
|
442
|
+
clearTimeout(timer);
|
|
443
|
+
if (left) return signal;
|
|
444
|
+
}
|
|
445
|
+
return "running";
|
|
446
|
+
})();
|
|
447
|
+
return this.closing;
|
|
415
448
|
}
|
|
416
449
|
}
|
|
417
450
|
|
|
418
|
-
export { REQUEST_TIMEOUT_MS, START_TIMEOUT_MS, MESSAGE_PAGE_LIMIT };
|
|
451
|
+
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;
|
|
@@ -73,19 +74,29 @@ export function promptImages(value) {
|
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
76
|
* 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
|
|
77
|
+
* the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
|
|
78
|
+
* `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds.
|
|
77
79
|
*/
|
|
78
|
-
const FEATURES = ["prompt-images"];
|
|
80
|
+
const FEATURES = ["prompt-images", "takeover"];
|
|
81
|
+
/** Who a phone continues a session on, as the terminal it leaves is told. */
|
|
82
|
+
const DEFAULT_DEVICE = "OMP Mobile";
|
|
79
83
|
/** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
|
|
80
84
|
const OPEN_FAILED_CLOSE_CODE = 4000;
|
|
81
85
|
/** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
|
|
82
86
|
const BLOB_NAME = /^[0-9a-f]{64}$/;
|
|
83
87
|
|
|
84
|
-
/** @typedef {{
|
|
88
|
+
/** @typedef {{pid: number, busy: boolean, terminal: boolean}} SessionOwner */
|
|
89
|
+
/** @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
90
|
|
|
86
|
-
/** An error with the HTTP status it answers. */
|
|
87
|
-
function httpError(status, message) {
|
|
88
|
-
return Object.assign(new Error(message), { status });
|
|
91
|
+
/** An error with the HTTP status it answers, and any detail its JSON answer carries. */
|
|
92
|
+
function httpError(status, message, detail = {}) {
|
|
93
|
+
return Object.assign(new Error(message), { status, detail });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The JSON answer for a failed request: the reason and the error's detail (a 409's `owner`). */
|
|
97
|
+
function errorBody(error) {
|
|
98
|
+
const detail = error !== null && typeof error === "object" && typeof error.detail === "object" ? error.detail : {};
|
|
99
|
+
return { error: error instanceof Error ? error.message : String(error), ...detail };
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
function parseArgs(argv) {
|
|
@@ -336,10 +347,10 @@ function readBody(req) {
|
|
|
336
347
|
});
|
|
337
348
|
}
|
|
338
349
|
|
|
339
|
-
function titleOf(messages, state, cwd) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
350
|
+
export function titleOf(messages, state, cwd) {
|
|
351
|
+
// OMP's session names can start with a Nerd Font icon, as its stored titles do (see `oneLine`).
|
|
352
|
+
const named = typeof state?.sessionName === "string" ? oneLine(state.sessionName) : "";
|
|
353
|
+
if (named !== "") return named;
|
|
343
354
|
if (Array.isArray(messages)) {
|
|
344
355
|
const title = promptTitle(messages.find((message) => message?.role === "user")?.content);
|
|
345
356
|
if (title !== "") return title;
|
|
@@ -472,10 +483,11 @@ class SessionEntry {
|
|
|
472
483
|
return this.rpc.extensionRequests instanceof Map ? [...this.rpc.extensionRequests.values()] : [];
|
|
473
484
|
}
|
|
474
485
|
|
|
486
|
+
/** Disconnect the phones and stop omp gracefully (see `RpcSession.close`). */
|
|
475
487
|
close() {
|
|
476
488
|
this.closed = true;
|
|
477
|
-
this.rpc.close();
|
|
478
489
|
for (const client of this.clients) client.close(1001, "session closed");
|
|
490
|
+
return this.rpc.close();
|
|
479
491
|
}
|
|
480
492
|
}
|
|
481
493
|
|
|
@@ -511,6 +523,8 @@ class Daemon {
|
|
|
511
523
|
this.ompSessions = new Map();
|
|
512
524
|
/** Resumes in flight, so two opens of one session start one process. @type {Map<string, Promise<SessionEntry>>} */
|
|
513
525
|
this.starting = new Map();
|
|
526
|
+
/** Takeovers in flight, so two phones asking at once ask the owner once. @type {Map<string, Promise<object>>} */
|
|
527
|
+
this.takingOver = new Map();
|
|
514
528
|
this.server = createServer((req, res) => void this.admitHttp(req, res));
|
|
515
529
|
this.wsServer = new WebSocketServer({ noServer: true });
|
|
516
530
|
this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
|
|
@@ -624,6 +638,13 @@ class Daemon {
|
|
|
624
638
|
sendJson(res, 201, { session: entry.summary() });
|
|
625
639
|
return;
|
|
626
640
|
}
|
|
641
|
+
const takeover = /^\/sessions\/([^/]+)\/takeover$/.exec(url.pathname);
|
|
642
|
+
if (takeover !== null && req.method === "POST") {
|
|
643
|
+
const body = await readBody(req);
|
|
644
|
+
const device = typeof body.device === "string" && body.device.trim() !== "" ? body.device.trim().slice(0, 60) : DEFAULT_DEVICE;
|
|
645
|
+
sendJson(res, 200, await this.takeOver(decodeURIComponent(takeover[1]), device));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
627
648
|
const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
|
|
628
649
|
if (match !== null) {
|
|
629
650
|
const id = decodeURIComponent(match[1]);
|
|
@@ -648,7 +669,7 @@ class Daemon {
|
|
|
648
669
|
const status = typeof error?.status === "number" ? error.status : 500;
|
|
649
670
|
// A refusal the API defines (404, 409, 410) is an answer; anything else is a failure worth keeping.
|
|
650
671
|
if (status >= 500) this.log(error);
|
|
651
|
-
sendJson(res, status,
|
|
672
|
+
sendJson(res, status, errorBody(error));
|
|
652
673
|
}
|
|
653
674
|
}
|
|
654
675
|
|
|
@@ -672,12 +693,26 @@ class Daemon {
|
|
|
672
693
|
const others = found
|
|
673
694
|
.filter((session) => !known.has(session.id))
|
|
674
695
|
.map((session) => ({ ...session, status: "idle", origin: "omp" }));
|
|
675
|
-
const marked =
|
|
676
|
-
|
|
696
|
+
const marked = await Promise.all(
|
|
697
|
+
[...dormant, ...others].map(async (session) => {
|
|
698
|
+
const pid = owners?.get(session.id);
|
|
699
|
+
if (pid === undefined) return session;
|
|
700
|
+
return { ...session, openElsewhere: true, owner: await this.ownerOf(pid, session.sessionFile) };
|
|
701
|
+
}),
|
|
677
702
|
);
|
|
678
703
|
return [...live, ...marked];
|
|
679
704
|
}
|
|
680
705
|
|
|
706
|
+
/**
|
|
707
|
+
* What the phone is told about the process holding a session: whether it is
|
|
708
|
+
* mid-turn (by the session file's last message) and runs in a terminal.
|
|
709
|
+
* @returns {Promise<SessionSummary["owner"]>}
|
|
710
|
+
*/
|
|
711
|
+
async ownerOf(pid, sessionFile) {
|
|
712
|
+
const info = await processInfo(pid);
|
|
713
|
+
return { pid, busy: typeof sessionFile === "string" && sessionBusy(sessionFile), terminal: info?.tty !== undefined };
|
|
714
|
+
}
|
|
715
|
+
|
|
681
716
|
/** Which sessions some omp process holds open (`undefined`: cannot tell). */
|
|
682
717
|
owners() {
|
|
683
718
|
return sessionOwners(join(this.options.ompHome, "run", "session-owners"));
|
|
@@ -721,7 +756,9 @@ class Daemon {
|
|
|
721
756
|
// stays with the process that has it (a terminal) until that one exits.
|
|
722
757
|
const owner = (await this.owners())?.get(id);
|
|
723
758
|
if (owner !== undefined) {
|
|
724
|
-
throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here
|
|
759
|
+
throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here.`, {
|
|
760
|
+
owner: await this.ownerOf(owner, stored.sessionFile),
|
|
761
|
+
});
|
|
725
762
|
}
|
|
726
763
|
const rpc = new RpcSession({
|
|
727
764
|
cwd: stored.cwd,
|
|
@@ -750,6 +787,55 @@ class Daemon {
|
|
|
750
787
|
}
|
|
751
788
|
}
|
|
752
789
|
|
|
790
|
+
/**
|
|
791
|
+
* Continue a session another omp process holds: ask that process to leave
|
|
792
|
+
* (see `takeover.mjs`), then open the session here. Concurrent takeovers of
|
|
793
|
+
* one session share one attempt.
|
|
794
|
+
* @param {string} id
|
|
795
|
+
* @param {string} device Who continues it, as the terminal is told.
|
|
796
|
+
*/
|
|
797
|
+
takeOver(id, device) {
|
|
798
|
+
let running = this.takingOver.get(id);
|
|
799
|
+
if (running === undefined) {
|
|
800
|
+
running = this.runTakeover(id, device).finally(() => this.takingOver.delete(id));
|
|
801
|
+
this.takingOver.set(id, running);
|
|
802
|
+
}
|
|
803
|
+
return running;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async runTakeover(id, device) {
|
|
807
|
+
const live = this.sessions.get(id);
|
|
808
|
+
if (live !== undefined && !live.closed) return { ...live.snapshot(), takeover: { pid: null, via: "live", waitedMs: 0, busy: false, forks: [] } };
|
|
809
|
+
// The phone may take over a session listed before this daemon started.
|
|
810
|
+
if (!this.registry.has(id) && !this.ompSessions.has(id)) await this.listSessions();
|
|
811
|
+
const stored = this.registry.get(id) ?? this.ompSessions.get(id);
|
|
812
|
+
if (stored === undefined || typeof stored.sessionFile !== "string") throw httpError(404, "unknown session");
|
|
813
|
+
if (!existsSync(stored.sessionFile)) throw httpError(410, "session file is gone");
|
|
814
|
+
const owners = await this.owners();
|
|
815
|
+
if (owners === undefined) throw httpError(503, "Cannot tell which process holds this session (lsof is not available).");
|
|
816
|
+
const pid = owners.get(id);
|
|
817
|
+
let outcome = { pid: null, via: "free", waitedMs: 0, busy: false, forks: [] };
|
|
818
|
+
if (pid !== undefined) {
|
|
819
|
+
const info = await processInfo(pid);
|
|
820
|
+
if (info !== undefined && !isOmpCommand(info.command)) {
|
|
821
|
+
throw httpError(409, `The process holding this session (pid ${pid}) is not omp; it is left alone.`);
|
|
822
|
+
}
|
|
823
|
+
const busy = sessionBusy(stored.sessionFile);
|
|
824
|
+
const since = Date.now();
|
|
825
|
+
const left = await askOwnerToLeave({ pid, sessionId: id, sessionFile: stored.sessionFile, device, timings: this.options.takeoverTimings });
|
|
826
|
+
// The kernel drops the lock with the process; give a slow exit a moment.
|
|
827
|
+
const deadline = Date.now() + 3_000;
|
|
828
|
+
while ((await this.owners())?.get(id) === pid && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 100));
|
|
829
|
+
// Whoever looks at the terminal sees why omp closed.
|
|
830
|
+
if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell(device));
|
|
831
|
+
const forks = forksOf(dirname(stored.sessionFile), id, since);
|
|
832
|
+
if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
|
|
833
|
+
outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
|
|
834
|
+
}
|
|
835
|
+
const entry = await this.ensureLive(id);
|
|
836
|
+
return { ...entry.snapshot(), takeover: outcome };
|
|
837
|
+
}
|
|
838
|
+
|
|
753
839
|
async createSession(cwd) {
|
|
754
840
|
const rpc = new RpcSession({
|
|
755
841
|
cwd,
|
|
@@ -808,7 +894,7 @@ class Daemon {
|
|
|
808
894
|
// the handshake instead, say why the session cannot open, and close: the app
|
|
809
895
|
// shows the reason and stops retrying what a retry cannot fix.
|
|
810
896
|
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
811
|
-
ws.send(JSON.stringify({ type: "open_failed", status,
|
|
897
|
+
ws.send(JSON.stringify({ type: "open_failed", status, ...errorBody(error) }));
|
|
812
898
|
ws.close(OPEN_FAILED_CLOSE_CODE, "session did not open");
|
|
813
899
|
});
|
|
814
900
|
});
|
|
@@ -917,8 +1003,12 @@ class Daemon {
|
|
|
917
1003
|
async close() {
|
|
918
1004
|
for (const end of this.streams) end();
|
|
919
1005
|
this.streams.clear();
|
|
920
|
-
|
|
1006
|
+
// Each omp saves and releases its session before the daemon exits.
|
|
1007
|
+
const closing = [...this.sessions.values()].map((entry) => entry.close());
|
|
921
1008
|
this.sessions.clear();
|
|
1009
|
+
const outcomes = await Promise.all(closing);
|
|
1010
|
+
const stuck = outcomes.filter((outcome) => outcome === "running").length;
|
|
1011
|
+
if (stuck > 0) console.error(`omp-mobile: ${stuck} omp process(es) ignored SIGINT and SIGTERM and are still running`);
|
|
922
1012
|
this.wsServer.close();
|
|
923
1013
|
await new Promise((resolveClose) => this.server.close(() => resolveClose()));
|
|
924
1014
|
}
|
package/src/takeover.mjs
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
/** How long each step waits before the next, in milliseconds. */
|
|
28
|
+
export const TAKEOVER_TIMINGS = {
|
|
29
|
+
/** For the extension to pick up the request (it polls every second). */
|
|
30
|
+
ack: 2_500,
|
|
31
|
+
/** For an owner whose extension took the request to exit (OMP closes once its session is idle). */
|
|
32
|
+
extensionExit: 10_000,
|
|
33
|
+
/** After `SIGINT`, then after `SIGTERM`. */
|
|
34
|
+
sigint: 10_000,
|
|
35
|
+
sigterm: 10_000,
|
|
36
|
+
poll: 100,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** How far from the end of a session file the last message is looked for. */
|
|
40
|
+
const TAIL_BYTES = 256 * 1024;
|
|
41
|
+
|
|
42
|
+
/** The request file the owner's extension watches. */
|
|
43
|
+
export function requestPath(sessionFile) {
|
|
44
|
+
return join(dirname(sessionFile), `.${basename(sessionFile)}.omp-mobile-takeover`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether `pid` is a live process this user may signal. */
|
|
48
|
+
export function alive(pid) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return error?.code === "EPERM";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a process is: its command and controlling terminal (`ttys007`), or
|
|
59
|
+
* `undefined` when there is no such process.
|
|
60
|
+
* @returns {Promise<{pid: number, command: string, tty: string | undefined} | undefined>}
|
|
61
|
+
*/
|
|
62
|
+
export function processInfo(pid) {
|
|
63
|
+
return new Promise((resolveInfo) => {
|
|
64
|
+
execFile("ps", ["-o", "tty=", "-o", "args=", "-p", String(pid)], { timeout: 5_000 }, (error, stdout) => {
|
|
65
|
+
const line = stdout?.split("\n").find((text) => text.trim() !== "");
|
|
66
|
+
if (error || line === undefined) {
|
|
67
|
+
resolveInfo(undefined);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const [, tty = "", command = ""] = /^\s*(\S+)\s+(.*)$/.exec(line) ?? [];
|
|
71
|
+
resolveInfo({ pid, command: command.trim(), tty: /^(\?+|-)$/.test(tty) || tty === "" ? undefined : tty });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether a command line runs omp: the binary, or an interpreter running a script named omp. */
|
|
77
|
+
export function isOmpCommand(command) {
|
|
78
|
+
const [program = "", script = ""] = command.trim().split(/\s+/);
|
|
79
|
+
return /(^|\/)omp$/.test(program) || /(^|\/)(node|bun)$/.test(program) && /(^|\/)omp$/.test(script);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the session's last message leaves a turn running: a prompt or tool
|
|
84
|
+
* result not answered yet, or an assistant message that called tools. OMP
|
|
85
|
+
* writes the assistant's reply when it ends, so a streaming turn ends in the
|
|
86
|
+
* user's prompt.
|
|
87
|
+
* @param {string} sessionFile
|
|
88
|
+
*/
|
|
89
|
+
export function sessionBusy(sessionFile) {
|
|
90
|
+
let fd;
|
|
91
|
+
try {
|
|
92
|
+
fd = openSync(sessionFile, "r");
|
|
93
|
+
const size = statSync(sessionFile).size;
|
|
94
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
95
|
+
const buffer = Buffer.alloc(length);
|
|
96
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
97
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
98
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
99
|
+
let entry;
|
|
100
|
+
try {
|
|
101
|
+
entry = JSON.parse(lines[i]);
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (entry?.type !== "message" || entry.message === null || typeof entry.message !== "object") continue;
|
|
106
|
+
const { role, stopReason } = entry.message;
|
|
107
|
+
if (role === "assistant") return stopReason === "toolUse";
|
|
108
|
+
return role === "user" || role === "toolResult";
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
} finally {
|
|
114
|
+
if (fd !== undefined) closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Session files in `dir` forked from `sessionId` (their header's
|
|
120
|
+
* `parentSession`) and written since `since`: what a second writer leaves.
|
|
121
|
+
*/
|
|
122
|
+
export function forksOf(dir, sessionId, since) {
|
|
123
|
+
let names;
|
|
124
|
+
try {
|
|
125
|
+
names = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const forks = [];
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
const file = join(dir, name);
|
|
132
|
+
let fd;
|
|
133
|
+
try {
|
|
134
|
+
if (statSync(file).mtimeMs < since) continue;
|
|
135
|
+
fd = openSync(file, "r");
|
|
136
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
137
|
+
const head = buffer.toString("utf8", 0, readSync(fd, buffer, 0, buffer.length, 0));
|
|
138
|
+
for (const line of head.split("\n").slice(0, 3)) {
|
|
139
|
+
try {
|
|
140
|
+
const entry = JSON.parse(line);
|
|
141
|
+
if (entry?.type === "session" && entry.parentSession === sessionId) forks.push(file);
|
|
142
|
+
} catch {
|
|
143
|
+
// Not a header line.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Removed while looking.
|
|
148
|
+
} finally {
|
|
149
|
+
if (fd !== undefined) closeSync(fd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return forks;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Write a line to a terminal (`ttys007`) the owner ran in, after it left, so
|
|
157
|
+
* whoever looks at it sees why omp closed. Best effort.
|
|
158
|
+
*/
|
|
159
|
+
export function tellTerminal(tty, text) {
|
|
160
|
+
if (!/^tty[A-Za-z0-9]+$/.test(tty)) return false;
|
|
161
|
+
let fd;
|
|
162
|
+
try {
|
|
163
|
+
fd = openSync(`/dev/${tty}`, constants.O_WRONLY | constants.O_NOCTTY | constants.O_NONBLOCK);
|
|
164
|
+
writeSync(fd, `\r\n${text}\r\n`);
|
|
165
|
+
return true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
} finally {
|
|
169
|
+
if (fd !== undefined) closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What the terminal is told once omp left it. */
|
|
174
|
+
export function takeoverFarewell(device) {
|
|
175
|
+
return `omp-mobile: this session continues on ${device}. omp closed here so the phone could take it over.`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
179
|
+
|
|
180
|
+
/** Wait until `done()` or `ms` passed; whether it became true. */
|
|
181
|
+
async function waitFor(done, ms, poll) {
|
|
182
|
+
const deadline = Date.now() + ms;
|
|
183
|
+
while (!done()) {
|
|
184
|
+
if (Date.now() >= deadline) return false;
|
|
185
|
+
await sleep(poll);
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function exists(path) {
|
|
191
|
+
try {
|
|
192
|
+
statSync(path);
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Ask the owner of a session to leave, escalating until it exited.
|
|
201
|
+
* Resolves how it left; rejects (status 504) when it is still running after
|
|
202
|
+
* `SIGTERM`, in which case it still has the session.
|
|
203
|
+
* @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
|
|
204
|
+
* @returns {Promise<{via: "extension" | "SIGINT" | "SIGTERM" | "exited", waitedMs: number}>}
|
|
205
|
+
*/
|
|
206
|
+
export async function askOwnerToLeave({ pid, sessionId, sessionFile, device, timings = {}, kill = process.kill.bind(process), isAlive = alive }) {
|
|
207
|
+
const t = { ...TAKEOVER_TIMINGS, ...timings };
|
|
208
|
+
const started = Date.now();
|
|
209
|
+
const gone = () => !isAlive(pid);
|
|
210
|
+
const result = (via) => ({ via, waitedMs: Date.now() - started });
|
|
211
|
+
if (gone()) return result("exited");
|
|
212
|
+
|
|
213
|
+
const request = requestPath(sessionFile);
|
|
214
|
+
const staging = `${request}.${process.pid}.tmp`;
|
|
215
|
+
let written = false;
|
|
216
|
+
try {
|
|
217
|
+
writeFileSync(staging, `${JSON.stringify({ pid, sessionId, device, at: Date.now() })}\n`, { mode: 0o600 });
|
|
218
|
+
renameSync(staging, request);
|
|
219
|
+
written = true;
|
|
220
|
+
} catch {
|
|
221
|
+
rmSync(staging, { force: true });
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
// The extension removes the request when it takes it, then shuts omp down.
|
|
225
|
+
if (written && (await waitFor(() => gone() || !exists(request), t.ack, t.poll))) {
|
|
226
|
+
const taken = !exists(request);
|
|
227
|
+
if (await waitFor(gone, taken ? t.extensionExit : 0, t.poll)) return result(taken ? "extension" : "exited");
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
rmSync(request, { force: true });
|
|
231
|
+
}
|
|
232
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
233
|
+
try {
|
|
234
|
+
kill(pid, signal);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error?.code === "ESRCH") return result("exited");
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
if (await waitFor(gone, signal === "SIGINT" ? t.sigint : t.sigterm, t.poll)) return result(signal);
|
|
240
|
+
}
|
|
241
|
+
throw Object.assign(new Error(`The omp process (pid ${pid}) did not exit after SIGINT and SIGTERM; it still has this session.`), {
|
|
242
|
+
status: 504,
|
|
243
|
+
});
|
|
244
|
+
}
|