omp-mobile 0.4.0 → 0.4.2
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 +1 -1
- package/src/rpc-session.mjs +21 -2
- package/src/server.mjs +302 -39
- package/src/takeover.mjs +10 -5
package/package.json
CHANGED
package/src/omp-extension.mjs
CHANGED
|
@@ -33,7 +33,7 @@ const POLL_MS = 1_500;
|
|
|
33
33
|
/** How long the code stays up without a phone connecting. */
|
|
34
34
|
const SHOW_MS = 5 * 60_000;
|
|
35
35
|
/** How often the session's takeover request is looked for, and how stale one may be. */
|
|
36
|
-
const TAKEOVER_POLL_MS =
|
|
36
|
+
const TAKEOVER_POLL_MS = 500;
|
|
37
37
|
const TAKEOVER_MAX_AGE_MS = 60_000;
|
|
38
38
|
/** How long a running turn gets to stop before omp closes anyway. */
|
|
39
39
|
const TAKEOVER_IDLE_WAIT_MS = 5_000;
|
package/src/rpc-session.mjs
CHANGED
|
@@ -142,7 +142,15 @@ export class RpcSession extends EventEmitter {
|
|
|
142
142
|
stdio: ["pipe", "pipe", "pipe"],
|
|
143
143
|
});
|
|
144
144
|
this.child = child;
|
|
145
|
-
|
|
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
|
+
});
|
|
146
154
|
child.on("exit", (code, signal) => {
|
|
147
155
|
this.closed = true;
|
|
148
156
|
// Nothing can answer the dialogs of a process that is gone.
|
|
@@ -157,7 +165,10 @@ export class RpcSession extends EventEmitter {
|
|
|
157
165
|
});
|
|
158
166
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
159
167
|
const ready = new Promise((resolve, reject) => {
|
|
160
|
-
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
|
+
);
|
|
161
172
|
const onReady = (message) => {
|
|
162
173
|
clearTimeout(timer);
|
|
163
174
|
this.off("ready", onReady);
|
|
@@ -168,6 +179,14 @@ export class RpcSession extends EventEmitter {
|
|
|
168
179
|
clearTimeout(timer);
|
|
169
180
|
reject(new Error(`omp exited before ready: ${this.stderr.slice(-500)}`));
|
|
170
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
|
+
});
|
|
171
190
|
});
|
|
172
191
|
lines.on("line", (line) => this.#onLine(line));
|
|
173
192
|
const hello = await ready;
|
package/src/server.mjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* to be exposed to the public internet.
|
|
9
9
|
*/
|
|
10
10
|
import { STATUS_CODES, createServer } from "node:http";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
13
|
import { homedir, hostname, platform } from "node:os";
|
|
14
14
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -43,6 +43,29 @@ const STATE_EVENTS = new Set([
|
|
|
43
43
|
"todo_auto_clear",
|
|
44
44
|
"goal_updated",
|
|
45
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;
|
|
58
|
+
/**
|
|
59
|
+
* The fields of a tool result's `details` the app reads: an edit's diff, a
|
|
60
|
+
* read's line count, a command's run time, a screenshot's file. The rest
|
|
61
|
+
* (an edit's old and new text, a read's display copy) repeats what the
|
|
62
|
+
* result already says, and was a fifth of a long session's snapshot.
|
|
63
|
+
*/
|
|
64
|
+
const PHONE_DETAILS = ["diff", "totalLines", "wallTimeMs", "mediaPath"];
|
|
65
|
+
/** Inline images shorter than this (base64 characters) stay inline: a reference would save nothing. */
|
|
66
|
+
const STASH_MIN_CHARS = 4_096;
|
|
67
|
+
/** Stashed images untouched this long are dropped at start; the session file still has them. */
|
|
68
|
+
const BLOB_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
|
|
46
69
|
/** Image types a phone may attach to a prompt: what the camera and the photo picker produce. */
|
|
47
70
|
const PROMPT_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
48
71
|
/** At most this many images per prompt, each at most this many decoded bytes. */
|
|
@@ -75,9 +98,11 @@ export function promptImages(value) {
|
|
|
75
98
|
/**
|
|
76
99
|
* What this daemon supports beyond protocol 1, for an app to offer only what
|
|
77
100
|
* 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
|
|
101
|
+
* `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds;
|
|
102
|
+
* `heartbeat`: session sockets get a `ping` frame every 20 s, so the app can
|
|
103
|
+
* treat a silent link as gone.
|
|
79
104
|
*/
|
|
80
|
-
const FEATURES = ["prompt-images", "takeover"];
|
|
105
|
+
const FEATURES = ["prompt-images", "takeover", "heartbeat"];
|
|
81
106
|
/** Who a phone continues a session on, as the terminal it leaves is told. */
|
|
82
107
|
const DEFAULT_DEVICE = "OMP Mobile";
|
|
83
108
|
/** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
|
|
@@ -249,6 +274,108 @@ function saveRegistry(path, registry) {
|
|
|
249
274
|
writeFileSync(path, `${JSON.stringify(Object.fromEntries(registry), null, 2)}\n`, { mode: 0o600 });
|
|
250
275
|
}
|
|
251
276
|
|
|
277
|
+
/**
|
|
278
|
+
* The directory an omp starts in, recreated when it has been removed (a
|
|
279
|
+
* session in /tmp outlives its directory). Answers a 410 the phone can show
|
|
280
|
+
* when it cannot be recreated (a parent that is a file, no permission).
|
|
281
|
+
* @param {string} cwd
|
|
282
|
+
* @returns {string}
|
|
283
|
+
*/
|
|
284
|
+
export function ensureWorkingDirectory(cwd) {
|
|
285
|
+
try {
|
|
286
|
+
if (statSync(cwd).isDirectory()) return cwd;
|
|
287
|
+
} catch {
|
|
288
|
+
// Missing: recreate it below.
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
mkdirSync(cwd, { recursive: true });
|
|
292
|
+
return cwd;
|
|
293
|
+
} catch {
|
|
294
|
+
throw httpError(410, `the session's working directory is gone and cannot be recreated: ${cwd}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Write an inline image to the daemon's blob cache under its SHA-256 and
|
|
300
|
+
* answer the `blob:sha256:` reference the app loads from `/blobs`; undefined
|
|
301
|
+
* leaves the image inline (too small, already a reference, not base64, no disk).
|
|
302
|
+
* @param {{data?: unknown}} part
|
|
303
|
+
* @param {string} blobDir
|
|
304
|
+
*/
|
|
305
|
+
function stashImage(part, blobDir) {
|
|
306
|
+
const data = part.data;
|
|
307
|
+
if (typeof data !== "string" || data.length < STASH_MIN_CHARS || data.startsWith("blob:") || data.startsWith("data:")) return undefined;
|
|
308
|
+
const bytes = Buffer.from(data, "base64");
|
|
309
|
+
if (bytes.length === 0) return undefined;
|
|
310
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
311
|
+
const path = join(blobDir, hash);
|
|
312
|
+
try {
|
|
313
|
+
if (!existsSync(path)) {
|
|
314
|
+
mkdirSync(blobDir, { recursive: true, mode: 0o700 });
|
|
315
|
+
writeFileSync(path, bytes, { mode: 0o600 });
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
return `blob:sha256:${hash}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A message as a snapshot carries it to the phone: inline images moved to
|
|
325
|
+
* the blob cache and sent as references (the app loads only the ones it
|
|
326
|
+
* shows), and tool details cut to {@link PHONE_DETAILS}. A long session's
|
|
327
|
+
* snapshot was mostly base64 screenshots and copies of edited text --
|
|
328
|
+
* megabytes the phone downloaded, parsed and cached before it showed anything.
|
|
329
|
+
* Live events still carry messages whole.
|
|
330
|
+
* @param {any} message
|
|
331
|
+
* @param {string | undefined} blobDir no stashing when undefined
|
|
332
|
+
*/
|
|
333
|
+
export function phoneMessage(message, blobDir) {
|
|
334
|
+
if (message === null || typeof message !== "object") return message;
|
|
335
|
+
let next = message;
|
|
336
|
+
if (blobDir !== undefined && Array.isArray(message.content)) {
|
|
337
|
+
let changed = false;
|
|
338
|
+
const content = message.content.map((part) => {
|
|
339
|
+
const ref = part !== null && typeof part === "object" && part.type === "image" ? stashImage(part, blobDir) : undefined;
|
|
340
|
+
if (ref === undefined) return part;
|
|
341
|
+
changed = true;
|
|
342
|
+
return { ...part, data: ref };
|
|
343
|
+
});
|
|
344
|
+
if (changed) next = { ...next, content };
|
|
345
|
+
}
|
|
346
|
+
const tool = message.role === "toolResult" || message.role === "tool";
|
|
347
|
+
if (tool && message.details !== null && typeof message.details === "object" && !Array.isArray(message.details)) {
|
|
348
|
+
const details = {};
|
|
349
|
+
for (const key of PHONE_DETAILS) if (key in message.details) details[key] = message.details[key];
|
|
350
|
+
next = { ...next, details };
|
|
351
|
+
}
|
|
352
|
+
return next;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Drop stashed images older than {@link BLOB_CACHE_MAX_AGE_MS}; the next open of their session stashes them again. */
|
|
356
|
+
export function pruneBlobCache(blobDir, now = Date.now()) {
|
|
357
|
+
let names;
|
|
358
|
+
try {
|
|
359
|
+
names = readdirSync(blobDir);
|
|
360
|
+
} catch {
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
let dropped = 0;
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
if (!BLOB_NAME.test(name)) continue;
|
|
366
|
+
const path = join(blobDir, name);
|
|
367
|
+
try {
|
|
368
|
+
if (now - statSync(path).mtimeMs > BLOB_CACHE_MAX_AGE_MS) {
|
|
369
|
+
unlinkSync(path);
|
|
370
|
+
dropped += 1;
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// Gone already.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return dropped;
|
|
377
|
+
}
|
|
378
|
+
|
|
252
379
|
function storedSummary(stored) {
|
|
253
380
|
return {
|
|
254
381
|
id: stored.id,
|
|
@@ -300,13 +427,14 @@ export function blobType(bytes) {
|
|
|
300
427
|
}
|
|
301
428
|
|
|
302
429
|
/** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
|
|
303
|
-
function sendBlob(res,
|
|
430
|
+
function sendBlob(res, dirs, name) {
|
|
304
431
|
if (!BLOB_NAME.test(name)) {
|
|
305
432
|
sendJson(res, 400, { error: "bad blob name" });
|
|
306
433
|
return;
|
|
307
434
|
}
|
|
308
|
-
|
|
309
|
-
|
|
435
|
+
// OMP's own store first, then the images this daemon stashed from snapshots.
|
|
436
|
+
const path = dirs.map((dir) => join(dir, name)).find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
|
|
437
|
+
if (path === undefined) {
|
|
310
438
|
sendJson(res, 404, { error: "no such blob" });
|
|
311
439
|
return;
|
|
312
440
|
}
|
|
@@ -388,12 +516,16 @@ class SessionEntry {
|
|
|
388
516
|
*/
|
|
389
517
|
this.onBroadcast = undefined;
|
|
390
518
|
this.closed = false;
|
|
519
|
+
/** When the last phone left; undefined while one is connected. The reaper reads it. */
|
|
520
|
+
this.idleSince = undefined;
|
|
521
|
+
/** Where inline images go (see `phoneMessage`); undefined keeps them inline. */
|
|
522
|
+
this.blobDir = undefined;
|
|
391
523
|
}
|
|
392
524
|
|
|
393
525
|
async hydrate() {
|
|
394
526
|
const [state, messages] = await Promise.all([this.rpc.getState(), this.rpc.getMessages()]);
|
|
395
527
|
this.state = state;
|
|
396
|
-
this.messages = Array.isArray(messages.messages) ? messages.messages : [];
|
|
528
|
+
this.messages = Array.isArray(messages.messages) ? messages.messages.map((message) => phoneMessage(message, this.blobDir)) : [];
|
|
397
529
|
this.id = state.sessionId ?? "";
|
|
398
530
|
this.title = titleOf(this.messages, state, this.cwd);
|
|
399
531
|
this.updatedAt = Date.now();
|
|
@@ -446,7 +578,7 @@ class SessionEntry {
|
|
|
446
578
|
this.rpc.on("event", (event) => {
|
|
447
579
|
this.updatedAt = Date.now();
|
|
448
580
|
if (event.type === "message_end" && event.message) {
|
|
449
|
-
this.messages.push(event.message);
|
|
581
|
+
this.messages.push(phoneMessage(event.message, this.blobDir));
|
|
450
582
|
if (this.messages.length > 5_000) this.messages.splice(0, this.messages.length - 5_000);
|
|
451
583
|
// The first prompt names a session that was created untitled.
|
|
452
584
|
const title = titleOf(this.messages, this.state, this.cwd);
|
|
@@ -521,12 +653,24 @@ class Daemon {
|
|
|
521
653
|
this.streams = new Set();
|
|
522
654
|
/** OMP's own sessions as last listed, for resuming one the app picked. @type {Map<string, import("./omp-sessions.mjs").OmpSession>} */
|
|
523
655
|
this.ompSessions = new Map();
|
|
656
|
+
/** The last full scan of OMP's session directory, reused within `SESSION_SCAN_TTL_MS`. */
|
|
657
|
+
this.sessionScan = { at: 0, list: /** @type {import("./omp-sessions.mjs").OmpSession[]} */ ([]), pending: undefined };
|
|
658
|
+
/** The last `lsof` of the owner locks, pending or settled, reused within `OWNERS_TTL_MS`. */
|
|
659
|
+
this.ownerLookup = { at: 0, owners: /** @type {Promise<Map<string, number> | undefined> | undefined} */ (undefined) };
|
|
660
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
661
|
+
this.heartbeat = undefined;
|
|
662
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
663
|
+
this.reaper = undefined;
|
|
524
664
|
/** Resumes in flight, so two opens of one session start one process. @type {Map<string, Promise<SessionEntry>>} */
|
|
525
665
|
this.starting = new Map();
|
|
526
666
|
/** Takeovers in flight, so two phones asking at once ask the owner once. @type {Map<string, Promise<object>>} */
|
|
527
667
|
this.takingOver = new Map();
|
|
668
|
+
/** Images stashed out of snapshots, served by `/blobs` next to OMP's own store. */
|
|
669
|
+
this.blobDir = options.blobDir ?? join(dirname(options.registryFile ?? DEFAULT_REGISTRY_FILE), "blobs");
|
|
528
670
|
this.server = createServer((req, res) => void this.admitHttp(req, res));
|
|
529
|
-
|
|
671
|
+
// Compressed frames: a snapshot is JSON text, several times smaller deflated, and
|
|
672
|
+
// the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
|
|
673
|
+
this.wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: { threshold: 1_024 } });
|
|
530
674
|
this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
|
|
531
675
|
}
|
|
532
676
|
|
|
@@ -598,7 +742,7 @@ class Daemon {
|
|
|
598
742
|
this.noteClient(req);
|
|
599
743
|
const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
|
|
600
744
|
if (blob !== null && req.method === "GET") {
|
|
601
|
-
sendBlob(res, this.options.ompHome, blob[1]);
|
|
745
|
+
sendBlob(res, [join(this.options.ompHome, "agent", "blobs"), this.blobDir], blob[1]);
|
|
602
746
|
return;
|
|
603
747
|
}
|
|
604
748
|
if (url.pathname === "/status" && req.method === "GET") {
|
|
@@ -657,6 +801,7 @@ class Daemon {
|
|
|
657
801
|
const live = this.sessions.get(id);
|
|
658
802
|
if (live !== undefined) this.attention.forget(live);
|
|
659
803
|
live?.close();
|
|
804
|
+
this.forgetOwners();
|
|
660
805
|
this.sessions.delete(id);
|
|
661
806
|
this.registry.delete(id);
|
|
662
807
|
saveRegistry(this.options.registryFile, this.registry);
|
|
@@ -685,10 +830,7 @@ class Daemon {
|
|
|
685
830
|
.filter((stored) => !known.has(stored.id))
|
|
686
831
|
.map((stored) => storedSummary(stored));
|
|
687
832
|
for (const session of dormant) known.add(session.id);
|
|
688
|
-
const [found, owners] = await Promise.all([
|
|
689
|
-
listOmpSessions(join(this.options.ompHome, "agent", "sessions"), { limit: OMP_SESSION_LIMIT }),
|
|
690
|
-
this.owners(),
|
|
691
|
-
]);
|
|
833
|
+
const [found, owners] = await Promise.all([this.scanOmpSessions(), this.owners()]);
|
|
692
834
|
this.ompSessions = new Map(found.map((session) => [session.id, session]));
|
|
693
835
|
const others = found
|
|
694
836
|
.filter((session) => !known.has(session.id))
|
|
@@ -713,9 +855,40 @@ class Daemon {
|
|
|
713
855
|
return { pid, busy: typeof sessionFile === "string" && sessionBusy(sessionFile), terminal: info?.tty !== undefined };
|
|
714
856
|
}
|
|
715
857
|
|
|
716
|
-
/**
|
|
858
|
+
/**
|
|
859
|
+
* OMP's sessions, rescanned at most every `SESSION_SCAN_TTL_MS`: a phone's
|
|
860
|
+
* probes ask for the same list every few seconds, and reading the first
|
|
861
|
+
* 512 KB of up to 50 of hundreds of files is the daemon's most expensive read.
|
|
862
|
+
*/
|
|
863
|
+
scanOmpSessions() {
|
|
864
|
+
const scan = this.sessionScan;
|
|
865
|
+
if (Date.now() - scan.at < SESSION_SCAN_TTL_MS) return Promise.resolve(scan.list);
|
|
866
|
+
if (scan.pending !== undefined) return scan.pending;
|
|
867
|
+
scan.pending = listOmpSessions(join(this.options.ompHome, "agent", "sessions"), { limit: OMP_SESSION_LIMIT }).then(
|
|
868
|
+
(list) => {
|
|
869
|
+
this.sessionScan = { at: Date.now(), list, pending: undefined };
|
|
870
|
+
return list;
|
|
871
|
+
},
|
|
872
|
+
(error) => {
|
|
873
|
+
scan.pending = undefined;
|
|
874
|
+
throw error;
|
|
875
|
+
},
|
|
876
|
+
);
|
|
877
|
+
return scan.pending;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Which sessions some omp process holds open (`undefined`: cannot tell). Cached for `OWNERS_TTL_MS`. */
|
|
717
881
|
owners() {
|
|
718
|
-
|
|
882
|
+
const lookup = this.ownerLookup;
|
|
883
|
+
if (lookup.owners !== undefined && Date.now() - lookup.at < OWNERS_TTL_MS) return lookup.owners;
|
|
884
|
+
const owners = sessionOwners(join(this.options.ompHome, "run", "session-owners"));
|
|
885
|
+
this.ownerLookup = { at: Date.now(), owners };
|
|
886
|
+
return owners;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The owner locks changed (a takeover, an omp closing): the next look must not reuse the cache. */
|
|
890
|
+
forgetOwners() {
|
|
891
|
+
this.ownerLookup = { at: 0, owners: undefined };
|
|
719
892
|
}
|
|
720
893
|
|
|
721
894
|
remember(entry) {
|
|
@@ -754,14 +927,20 @@ class Daemon {
|
|
|
754
927
|
if (!existsSync(stored.sessionFile)) throw httpError(410, "session file is gone");
|
|
755
928
|
// A second omp on the same file would fork it into a new one; the session
|
|
756
929
|
// stays with the process that has it (a terminal) until that one exits.
|
|
930
|
+
// This comes before the directory check: a held session is a takeover, not
|
|
931
|
+
// a broken one, and the phone's Take over button needs the 409.
|
|
757
932
|
const owner = (await this.owners())?.get(id);
|
|
758
933
|
if (owner !== undefined) {
|
|
759
934
|
throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here.`, {
|
|
760
935
|
owner: await this.ownerOf(owner, stored.sessionFile),
|
|
761
936
|
});
|
|
762
937
|
}
|
|
938
|
+
// OMP refuses to start in a directory that no longer exists (a session in
|
|
939
|
+
// /tmp whose directory was cleaned up). Recreate it here instead of letting
|
|
940
|
+
// the start wait out its timeout and answer with a bare 500.
|
|
941
|
+
const cwd = ensureWorkingDirectory(stored.cwd);
|
|
763
942
|
const rpc = new RpcSession({
|
|
764
|
-
cwd
|
|
943
|
+
cwd,
|
|
765
944
|
ompBin: this.options.omp,
|
|
766
945
|
resume: stored.sessionFile,
|
|
767
946
|
autoApprove: this.options.autoApprove,
|
|
@@ -773,6 +952,7 @@ class Daemon {
|
|
|
773
952
|
try {
|
|
774
953
|
await rpc.start();
|
|
775
954
|
const entry = new SessionEntry(stored.cwd, rpc);
|
|
955
|
+
entry.blobDir = this.blobDir;
|
|
776
956
|
await entry.hydrate();
|
|
777
957
|
entry.id = id;
|
|
778
958
|
entry.onChange = (changed) => this.remember(changed);
|
|
@@ -832,6 +1012,8 @@ class Daemon {
|
|
|
832
1012
|
if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
|
|
833
1013
|
outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
|
|
834
1014
|
}
|
|
1015
|
+
// The owner left: the cached lock map is stale and would refuse this open.
|
|
1016
|
+
this.forgetOwners();
|
|
835
1017
|
const entry = await this.ensureLive(id);
|
|
836
1018
|
return { ...entry.snapshot(), takeover: outcome };
|
|
837
1019
|
}
|
|
@@ -847,6 +1029,7 @@ class Daemon {
|
|
|
847
1029
|
try {
|
|
848
1030
|
await rpc.start();
|
|
849
1031
|
const entry = new SessionEntry(cwd, rpc);
|
|
1032
|
+
entry.blobDir = this.blobDir;
|
|
850
1033
|
await entry.hydrate();
|
|
851
1034
|
if (entry.id === "") throw new Error("omp returned no session id");
|
|
852
1035
|
entry.onChange = (changed) => this.remember(changed);
|
|
@@ -875,29 +1058,62 @@ class Daemon {
|
|
|
875
1058
|
return;
|
|
876
1059
|
}
|
|
877
1060
|
this.noteClient(req);
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1061
|
+
const id = decodeURIComponent(match[1]);
|
|
1062
|
+
// Finish the handshake before the session is open: a slow omp can take
|
|
1063
|
+
// tens of seconds, and a phone's WebSocket client gives up after ~10 s --
|
|
1064
|
+
// then retries, starting another omp every time. The snapshot (or
|
|
1065
|
+
// `open_failed`) follows on the open socket instead.
|
|
1066
|
+
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
1067
|
+
ws.isAlive = true;
|
|
1068
|
+
ws.on("pong", () => {
|
|
1069
|
+
ws.isAlive = true;
|
|
1070
|
+
});
|
|
1071
|
+
/** @type {SessionEntry | undefined} */
|
|
1072
|
+
let entry;
|
|
1073
|
+
/** Frames the phone sent before the session was ready. */
|
|
1074
|
+
const early = [];
|
|
1075
|
+
ws.on("message", (data) => {
|
|
1076
|
+
if (entry === undefined) {
|
|
1077
|
+
early.push(data.toString());
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
void this.handleClientMessage(entry, ws, data.toString());
|
|
1081
|
+
});
|
|
1082
|
+
ws.on("close", () => {
|
|
1083
|
+
if (entry !== undefined) this.detach(entry, ws);
|
|
1084
|
+
});
|
|
1085
|
+
ws.on("error", () => {
|
|
1086
|
+
if (entry !== undefined) this.detach(entry, ws);
|
|
1087
|
+
});
|
|
1088
|
+
void this.ensureLive(id).then(
|
|
1089
|
+
(live) => {
|
|
1090
|
+
entry = live;
|
|
1091
|
+
if (ws.readyState !== 1) return;
|
|
1092
|
+
live.clients.add(ws);
|
|
1093
|
+
live.idleSince = undefined;
|
|
1094
|
+
ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot() }));
|
|
883
1095
|
// A dialog opened before this client connected (it may have come from a notification tap).
|
|
884
|
-
for (const request of
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
// the handshake instead, say why the session cannot open, and close: the app
|
|
895
|
-
// shows the reason and stops retrying what a retry cannot fix.
|
|
896
|
-
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
1096
|
+
for (const request of live.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
|
|
1097
|
+
for (const data of early) void this.handleClientMessage(live, ws, data);
|
|
1098
|
+
early.length = 0;
|
|
1099
|
+
},
|
|
1100
|
+
(error) => {
|
|
1101
|
+
const status = typeof error?.status === "number" ? error.status : 500;
|
|
1102
|
+
if (status >= 500) this.log(error);
|
|
1103
|
+
if (ws.readyState !== 1) return;
|
|
1104
|
+
// Say why the session cannot open and close: the app shows the reason
|
|
1105
|
+
// and stops retrying what a retry cannot fix.
|
|
897
1106
|
ws.send(JSON.stringify({ type: "open_failed", status, ...errorBody(error) }));
|
|
898
1107
|
ws.close(OPEN_FAILED_CLOSE_CODE, "session did not open");
|
|
899
|
-
}
|
|
900
|
-
|
|
1108
|
+
},
|
|
1109
|
+
);
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** A phone left a session: it counts as idle from now, for the reaper. */
|
|
1114
|
+
detach(entry, ws) {
|
|
1115
|
+
if (!entry.clients.delete(ws)) return;
|
|
1116
|
+
if (entry.clients.size === 0) entry.idleSince = Date.now();
|
|
901
1117
|
}
|
|
902
1118
|
|
|
903
1119
|
async handleClientMessage(entry, ws, text) {
|
|
@@ -996,11 +1212,58 @@ class Daemon {
|
|
|
996
1212
|
|
|
997
1213
|
listen() {
|
|
998
1214
|
return new Promise((resolveListen) => {
|
|
999
|
-
this.server.listen(this.options.port, this.options.host, () =>
|
|
1215
|
+
this.server.listen(this.options.port, this.options.host, () => {
|
|
1216
|
+
this.startTimers();
|
|
1217
|
+
resolveListen();
|
|
1218
|
+
});
|
|
1000
1219
|
});
|
|
1001
1220
|
}
|
|
1002
1221
|
|
|
1222
|
+
/** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
|
|
1223
|
+
startTimers() {
|
|
1224
|
+
pruneBlobCache(this.blobDir);
|
|
1225
|
+
this.heartbeat = setInterval(() => this.pingClients(), this.options.heartbeatMs ?? HEARTBEAT_MS);
|
|
1226
|
+
this.heartbeat.unref?.();
|
|
1227
|
+
this.reaper = setInterval(() => this.reapIdleSessions(), this.options.reapIntervalMs ?? REAP_INTERVAL_MS);
|
|
1228
|
+
this.reaper.unref?.();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Ping every WebSocket and drop the ones that missed the previous beat. A
|
|
1233
|
+
* protocol ping plus a small frame keeps NAT and phone radios from dropping
|
|
1234
|
+
* an idle session silently; the app ignores the frame, older ones too.
|
|
1235
|
+
*/
|
|
1236
|
+
pingClients() {
|
|
1237
|
+
const beat = JSON.stringify({ type: "ping", at: Date.now() });
|
|
1238
|
+
for (const ws of this.wsServer.clients) {
|
|
1239
|
+
if (ws.isAlive === false) {
|
|
1240
|
+
ws.terminate();
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
ws.isAlive = false;
|
|
1244
|
+
try {
|
|
1245
|
+
ws.ping();
|
|
1246
|
+
ws.send(beat);
|
|
1247
|
+
} catch {
|
|
1248
|
+
// Closing; the close handler cleans up.
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Close live sessions no phone has looked at for `IDLE_SESSION_MS` (never mid-turn). */
|
|
1254
|
+
reapIdleSessions() {
|
|
1255
|
+
const now = Date.now();
|
|
1256
|
+
for (const [id, entry] of this.sessions) {
|
|
1257
|
+
if (entry.clients.size > 0 || entry.state?.isStreaming === true) continue;
|
|
1258
|
+
if (now - (entry.idleSince ?? entry.updatedAt) < (this.options.idleSessionMs ?? IDLE_SESSION_MS)) continue;
|
|
1259
|
+
entry.close();
|
|
1260
|
+
this.sessions.delete(id);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1003
1264
|
async close() {
|
|
1265
|
+
clearInterval(this.heartbeat);
|
|
1266
|
+
clearInterval(this.reaper);
|
|
1004
1267
|
for (const end of this.streams) end();
|
|
1005
1268
|
this.streams.clear();
|
|
1006
1269
|
// Each omp saves and releases its session before the daemon exits.
|
package/src/takeover.mjs
CHANGED
|
@@ -24,15 +24,20 @@ import { execFile } from "node:child_process";
|
|
|
24
24
|
import { closeSync, constants, openSync, readdirSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
25
25
|
import { basename, dirname, join } from "node:path";
|
|
26
26
|
|
|
27
|
-
/**
|
|
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
|
+
*/
|
|
28
33
|
export const TAKEOVER_TIMINGS = {
|
|
29
|
-
/** For the extension to pick up the request (it polls every
|
|
30
|
-
ack:
|
|
34
|
+
/** For the extension to pick up the request (it polls every 500 ms). */
|
|
35
|
+
ack: 1_500,
|
|
31
36
|
/** For an owner whose extension took the request to exit (OMP closes once its session is idle). */
|
|
32
37
|
extensionExit: 10_000,
|
|
33
38
|
/** After `SIGINT`, then after `SIGTERM`. */
|
|
34
|
-
sigint:
|
|
35
|
-
sigterm:
|
|
39
|
+
sigint: 5_000,
|
|
40
|
+
sigterm: 5_000,
|
|
36
41
|
poll: 100,
|
|
37
42
|
};
|
|
38
43
|
|