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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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 = 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() + 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
@@ -403,16 +405,47 @@ export class RpcSession extends EventEmitter {
403
405
  this.#settle(id, answer.cancelled === true ? "cancelled" : "answered");
404
406
  }
405
407
 
406
- close() {
407
- if (this.closed) return;
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?.kill("SIGTERM");
412
- setTimeout(() => {
413
- if (this.child !== undefined && this.child.exitCode === null) this.child.kill("SIGKILL");
414
- }, 2_000).unref();
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 {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, waiting?: number}} SessionSummary */
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
- if (typeof state?.sessionName === "string" && state.sessionName.trim() !== "") {
341
- return state.sessionName.trim();
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, { error: error instanceof Error ? error.message : String(error) });
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 = [...dormant, ...others].map((session) =>
676
- owners?.has(session.id) === true ? { ...session, openElsewhere: true } : session,
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, error: error instanceof Error ? error.message : String(error) }));
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
- for (const entry of this.sessions.values()) entry.close();
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
  }
@@ -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
+ }