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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
5
5
  "keywords": [
6
6
  "mobile",
@@ -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() + 5_000;
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
 
@@ -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()
@@ -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
- child.on("error", (error) => this.#fail(error));
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(() => reject(new Error("omp RPC did not become ready")), START_TIMEOUT_MS);
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
- close() {
407
- if (this.closed) return;
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?.kill("SIGTERM");
412
- setTimeout(() => {
413
- if (this.child !== undefined && this.child.exitCode === null) this.child.kill("SIGKILL");
414
- }, 2_000).unref();
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 {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, waiting?: number}} SessionSummary */
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
- if (typeof state?.sessionName === "string" && state.sessionName.trim() !== "") {
341
- return state.sessionName.trim();
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, { error: error instanceof Error ? error.message : String(error) });
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 = [...dormant, ...others].map((session) =>
676
- owners?.has(session.id) === true ? { ...session, openElsewhere: true } : session,
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
- /** Which sessions some omp process holds open (`undefined`: cannot tell). */
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
- return sessionOwners(join(this.options.ompHome, "run", "session-owners"));
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: stored.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
- void this.ensureLive(decodeURIComponent(match[1]))
793
- .then((entry) => {
794
- this.wsServer.handleUpgrade(req, socket, head, (ws) => {
795
- entry.clients.add(ws);
796
- ws.send(JSON.stringify({ type: "snapshot", ...entry.snapshot() }));
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 entry.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
799
- ws.on("message", (data) => void this.handleClientMessage(entry, ws, data.toString()));
800
- ws.on("close", () => entry.clients.delete(ws));
801
- ws.on("error", () => entry.clients.delete(ws));
802
- });
803
- })
804
- .catch((error) => {
805
- const status = typeof error?.status === "number" ? error.status : 500;
806
- if (status >= 500) this.log(error);
807
- // A browser-style client only sees that the upgrade failed, never why. Finish
808
- // the handshake instead, say why the session cannot open, and close: the app
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, () => resolveListen());
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
- for (const entry of this.sessions.values()) entry.close();
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
  }
@@ -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
+ }