omp-mobile 0.4.1 → 0.4.3

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.4.1",
3
+ "version": "0.4.3",
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",
@@ -16,7 +16,7 @@ const TITLE_SCAN_BYTES = 512 * 1024;
16
16
  const CHUNK_BYTES = 64 * 1024;
17
17
  const LSOF_TIMEOUT_MS = 5_000;
18
18
 
19
- /** @typedef {{id: string, title: string, cwd: string, createdAt: number, updatedAt: number, sessionFile: string}} OmpSession */
19
+ /** @typedef {{id: string, title: string, cwd: string, createdAt: number, updatedAt: number, size: number, sessionFile: string}} OmpSession */
20
20
 
21
21
  /** Text of an OMP message content field (string or content-part array). */
22
22
  function textOfContent(content) {
@@ -103,6 +103,8 @@ export async function readSessionFile(file) {
103
103
  cwd: header.cwd,
104
104
  createdAt: Number.isFinite(created) ? created : info.birthtimeMs,
105
105
  updatedAt: info.mtimeMs,
106
+ // With the time, what tells this daemon's own omp leaving the file from someone writing it.
107
+ size: info.size,
106
108
  sessionFile: file,
107
109
  };
108
110
  } finally {
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";
@@ -20,6 +20,7 @@ import { Admission, Tailscale, isLoopbackHost } from "./admission.mjs";
20
20
  import { AttentionTracker, serveEvents } from "./attention.mjs";
21
21
  import { listOmpSessions, oneLine, promptTitle, sessionOwners } from "./omp-sessions.mjs";
22
22
  import { askOwnerToLeave, forksOf, isOmpCommand, processInfo, sessionBusy, takeoverFarewell, tellTerminal } from "./takeover.mjs";
23
+ import { TranscriptStore, deltaBody, deltaQuery } from "./transcripts.mjs";
23
24
 
24
25
  const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
25
26
  const PROTOCOL = 1;
@@ -55,6 +56,21 @@ const HEARTBEAT_MS = 20_000;
55
56
  /** A live omp nobody has looked at for this long is closed; the next open resumes it. */
56
57
  const IDLE_SESSION_MS = 30 * 60_000;
57
58
  const REAP_INTERVAL_MS = 60_000;
59
+ /** Messages a live session keeps in memory; older ones leave its front (its `base` counts them). */
60
+ const MAX_LIVE_MESSAGES = 5_000;
61
+ /** Events that are a session being used: a turn and what it streams. State and setting echoes are not. */
62
+ const ACTIVITY_EVENT = /^(agent_|turn_|message_|tool_execution_|auto_compaction_|auto_retry_)/;
63
+ /**
64
+ * The fields of a tool result's `details` the app reads: an edit's diff, a
65
+ * read's line count, a command's run time, a screenshot's file. The rest
66
+ * (an edit's old and new text, a read's display copy) repeats what the
67
+ * result already says, and was a fifth of a long session's snapshot.
68
+ */
69
+ const PHONE_DETAILS = ["diff", "totalLines", "wallTimeMs", "mediaPath"];
70
+ /** Inline images shorter than this (base64 characters) stay inline: a reference would save nothing. */
71
+ const STASH_MIN_CHARS = 4_096;
72
+ /** Stashed images untouched this long are dropped at start; the session file still has them. */
73
+ const BLOB_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
58
74
  /** Image types a phone may attach to a prompt: what the camera and the photo picker produce. */
59
75
  const PROMPT_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
60
76
  /** At most this many images per prompt, each at most this many decoded bytes. */
@@ -89,9 +105,11 @@ export function promptImages(value) {
89
105
  * the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
90
106
  * `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds;
91
107
  * `heartbeat`: session sockets get a `ping` frame every 20 s, so the app can
92
- * treat a silent link as gone.
108
+ * treat a silent link as gone; `transcript-sync`: a session socket's `since`/`last`
109
+ * ask for a delta snapshot (`from`, `total`), and `GET /sessions/:id/transcript`
110
+ * answers the same without starting an omp.
93
111
  */
94
- const FEATURES = ["prompt-images", "takeover", "heartbeat"];
112
+ const FEATURES = ["prompt-images", "takeover", "heartbeat", "transcript-sync"];
95
113
  /** Who a phone continues a session on, as the terminal it leaves is told. */
96
114
  const DEFAULT_DEVICE = "OMP Mobile";
97
115
  /** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
@@ -284,6 +302,127 @@ export function ensureWorkingDirectory(cwd) {
284
302
  }
285
303
  }
286
304
 
305
+ /**
306
+ * Write an inline image to the daemon's blob cache under its SHA-256 and
307
+ * answer the `blob:sha256:` reference the app loads from `/blobs`; undefined
308
+ * leaves the image inline (too small, already a reference, not base64, no disk).
309
+ * @param {{data?: unknown}} part
310
+ * @param {string} blobDir
311
+ */
312
+ function stashImage(part, blobDir) {
313
+ const data = part.data;
314
+ if (typeof data !== "string" || data.length < STASH_MIN_CHARS || data.startsWith("blob:") || data.startsWith("data:")) return undefined;
315
+ const bytes = Buffer.from(data, "base64");
316
+ if (bytes.length === 0) return undefined;
317
+ const hash = createHash("sha256").update(bytes).digest("hex");
318
+ const path = join(blobDir, hash);
319
+ try {
320
+ if (!existsSync(path)) {
321
+ mkdirSync(blobDir, { recursive: true, mode: 0o700 });
322
+ writeFileSync(path, bytes, { mode: 0o600 });
323
+ }
324
+ } catch {
325
+ return undefined;
326
+ }
327
+ return `blob:sha256:${hash}`;
328
+ }
329
+
330
+ /**
331
+ * A message as a snapshot carries it to the phone: inline images moved to
332
+ * the blob cache and sent as references (the app loads only the ones it
333
+ * shows), and tool details cut to {@link PHONE_DETAILS}. A long session's
334
+ * snapshot was mostly base64 screenshots and copies of edited text --
335
+ * megabytes the phone downloaded, parsed and cached before it showed anything.
336
+ * Live events still carry messages whole.
337
+ * @param {any} message
338
+ * @param {string | undefined} blobDir no stashing when undefined
339
+ */
340
+ export function phoneMessage(message, blobDir) {
341
+ if (message === null || typeof message !== "object") return message;
342
+ let next = message;
343
+ if (blobDir !== undefined && Array.isArray(message.content)) {
344
+ let changed = false;
345
+ const content = message.content.map((part) => {
346
+ const ref = part !== null && typeof part === "object" && part.type === "image" ? stashImage(part, blobDir) : undefined;
347
+ if (ref === undefined) return part;
348
+ changed = true;
349
+ return { ...part, data: ref };
350
+ });
351
+ if (changed) next = { ...next, content };
352
+ }
353
+ const tool = message.role === "toolResult" || message.role === "tool";
354
+ if (tool && message.details !== null && typeof message.details === "object" && !Array.isArray(message.details)) {
355
+ const details = {};
356
+ for (const key of PHONE_DETAILS) if (key in message.details) details[key] = message.details[key];
357
+ next = { ...next, details };
358
+ }
359
+ return next;
360
+ }
361
+
362
+ /** Drop stashed images older than {@link BLOB_CACHE_MAX_AGE_MS}; the next open of their session stashes them again. */
363
+ export function pruneBlobCache(blobDir, now = Date.now()) {
364
+ let names;
365
+ try {
366
+ names = readdirSync(blobDir);
367
+ } catch {
368
+ return 0;
369
+ }
370
+ let dropped = 0;
371
+ for (const name of names) {
372
+ if (!BLOB_NAME.test(name)) continue;
373
+ const path = join(blobDir, name);
374
+ try {
375
+ if (now - statSync(path).mtimeMs > BLOB_CACHE_MAX_AGE_MS) {
376
+ unlinkSync(path);
377
+ dropped += 1;
378
+ }
379
+ } catch {
380
+ // Gone already.
381
+ }
382
+ }
383
+ return dropped;
384
+ }
385
+
386
+ /**
387
+ * When the messages were last written: the newest timestamp OMP stamped on
388
+ * them, or undefined when none carries one.
389
+ * @param {readonly any[]} messages
390
+ */
391
+ export function lastActivity(messages) {
392
+ let latest;
393
+ for (let i = messages.length - 1; i >= 0 && i >= messages.length - 50; i -= 1) {
394
+ const at = Number(messages[i]?.timestamp);
395
+ if (Number.isFinite(at) && at > 0 && (latest === undefined || at > latest)) latest = at;
396
+ }
397
+ return latest;
398
+ }
399
+
400
+ /**
401
+ * When a session the daemon knows but has no omp for was last used: what the
402
+ * registry says, or its file's time when something wrote the file since --
403
+ * unless the file is exactly as this daemon's own omp left it on exit (omp
404
+ * rewrites the whole file as it closes; that is not anyone using it).
405
+ * @param {{updatedAt?: number, closedFile?: {mtimeMs: number, size: number}} | undefined} stored
406
+ * @param {{updatedAt: number, size?: number} | undefined} scanned
407
+ */
408
+ export function dormantActivity(stored, scanned) {
409
+ const known = Number(stored?.updatedAt) || 0;
410
+ if (scanned === undefined || !(scanned.updatedAt > known)) return known;
411
+ const closed = stored?.closedFile;
412
+ if (closed !== undefined && closed !== null && closed.mtimeMs === scanned.updatedAt && closed.size === scanned.size) return known;
413
+ return scanned.updatedAt;
414
+ }
415
+
416
+ /** A file's time and size, as `TranscriptStore` checks them; undefined when it cannot be read. */
417
+ function fileState(path) {
418
+ try {
419
+ const info = statSync(path);
420
+ return info.isFile() ? { mtimeMs: info.mtimeMs, size: info.size } : undefined;
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ }
425
+
287
426
  function storedSummary(stored) {
288
427
  return {
289
428
  id: stored.id,
@@ -335,13 +474,14 @@ export function blobType(bytes) {
335
474
  }
336
475
 
337
476
  /** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
338
- function sendBlob(res, ompHome, name) {
477
+ function sendBlob(res, dirs, name) {
339
478
  if (!BLOB_NAME.test(name)) {
340
479
  sendJson(res, 400, { error: "bad blob name" });
341
480
  return;
342
481
  }
343
- const path = join(ompHome, "agent", "blobs", name);
344
- if (!existsSync(path) || !statSync(path).isFile()) {
482
+ // OMP's own store first, then the images this daemon stashed from snapshots.
483
+ const path = dirs.map((dir) => join(dir, name)).find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
484
+ if (path === undefined) {
345
485
  sendJson(res, 404, { error: "no such blob" });
346
486
  return;
347
487
  }
@@ -410,6 +550,10 @@ class SessionEntry {
410
550
  this.id = "";
411
551
  this.state = null;
412
552
  this.messages = [];
553
+ /** How many messages left the front of `messages` (the cap): `messages[0]` is the transcript's message `base`. */
554
+ this.base = 0;
555
+ /** The session file as this omp left it on exit, so its rewrite is not taken for activity. */
556
+ this.closedFile = undefined;
413
557
  this.createdAt = Date.now();
414
558
  this.updatedAt = this.createdAt;
415
559
  this.title = cwd.split("/").filter(Boolean).at(-1) ?? "session";
@@ -425,15 +569,28 @@ class SessionEntry {
425
569
  this.closed = false;
426
570
  /** When the last phone left; undefined while one is connected. The reaper reads it. */
427
571
  this.idleSince = undefined;
572
+ /** Where inline images go (see `phoneMessage`); undefined keeps them inline. */
573
+ this.blobDir = undefined;
574
+ /**
575
+ * Hears the omp process exit, once its session file is final (the daemon saves the transcript).
576
+ * @type {((entry: SessionEntry) => void) | undefined}
577
+ */
578
+ this.onClosed = undefined;
428
579
  }
429
580
 
430
- async hydrate() {
581
+ /**
582
+ * @param {number} [knownActivity] when the session was last used, as the daemon knew it before this omp
583
+ */
584
+ async hydrate(knownActivity) {
431
585
  const [state, messages] = await Promise.all([this.rpc.getState(), this.rpc.getMessages()]);
432
586
  this.state = state;
433
- this.messages = Array.isArray(messages.messages) ? messages.messages : [];
587
+ this.messages = Array.isArray(messages.messages) ? messages.messages.map((message) => phoneMessage(message, this.blobDir)) : [];
588
+ this.base = 0;
434
589
  this.id = state.sessionId ?? "";
435
590
  this.title = titleOf(this.messages, state, this.cwd);
436
- this.updatedAt = Date.now();
591
+ // Opening a session is not using it: it is as recent as its last message (a new one: now).
592
+ const known = Math.max(lastActivity(this.messages) ?? 0, Number(knownActivity) || 0);
593
+ this.updatedAt = known > 0 ? known : Date.now();
437
594
  return this;
438
595
  }
439
596
 
@@ -453,12 +610,13 @@ class SessionEntry {
453
610
  };
454
611
  }
455
612
 
456
- snapshot() {
457
- return {
458
- session: this.summary(),
459
- state: this.state,
460
- messages: this.messages,
461
- };
613
+ /**
614
+ * What a phone is sent on open: everything, or only the messages after the
615
+ * ones it holds when its copy lines up (see `deltaBody`).
616
+ * @param {{since: number, last: string}} [held]
617
+ */
618
+ snapshot(held) {
619
+ return deltaBody({ session: this.summary(), state: this.state, messages: this.messages, base: this.base }, held);
462
620
  }
463
621
 
464
622
  broadcast(payload) {
@@ -472,7 +630,6 @@ class SessionEntry {
472
630
  async refreshState() {
473
631
  try {
474
632
  this.state = await this.rpc.getState();
475
- this.updatedAt = Date.now();
476
633
  this.broadcast({ type: "state", state: this.state });
477
634
  } catch {
478
635
  // The process may be exiting; the close event tells clients.
@@ -481,10 +638,15 @@ class SessionEntry {
481
638
 
482
639
  bind() {
483
640
  this.rpc.on("event", (event) => {
484
- this.updatedAt = Date.now();
641
+ if (typeof event?.type === "string" && ACTIVITY_EVENT.test(event.type)) this.updatedAt = Date.now();
485
642
  if (event.type === "message_end" && event.message) {
486
- this.messages.push(event.message);
487
- if (this.messages.length > 5_000) this.messages.splice(0, this.messages.length - 5_000);
643
+ this.messages.push(phoneMessage(event.message, this.blobDir));
644
+ if (this.messages.length > MAX_LIVE_MESSAGES) {
645
+ // Indices stay the transcript's: a phone's `since` still lines up after the front is dropped.
646
+ const dropped = this.messages.length - MAX_LIVE_MESSAGES;
647
+ this.messages.splice(0, dropped);
648
+ this.base += dropped;
649
+ }
488
650
  // The first prompt names a session that was created untitled.
489
651
  const title = titleOf(this.messages, this.state, this.cwd);
490
652
  if (title !== this.title) {
@@ -511,6 +673,11 @@ class SessionEntry {
511
673
  this.rpc.on("closed", ({ reason, stderr }) => {
512
674
  this.closed = true;
513
675
  this.broadcast({ type: "closed", reason, stderr: stderr.slice(-2_000) });
676
+ try {
677
+ this.onClosed?.(this);
678
+ } catch {
679
+ // Saving the transcript is a shortcut; the session file has everything.
680
+ }
514
681
  });
515
682
  this.rpc.on("protocol-error", (payload) => this.broadcast({ type: "protocol-error", ...payload }));
516
683
  }
@@ -570,8 +737,14 @@ class Daemon {
570
737
  this.starting = new Map();
571
738
  /** Takeovers in flight, so two phones asking at once ask the owner once. @type {Map<string, Promise<object>>} */
572
739
  this.takingOver = new Map();
740
+ /** Images stashed out of snapshots, served by `/blobs` next to OMP's own store. */
741
+ this.blobDir = options.blobDir ?? join(dirname(options.registryFile ?? DEFAULT_REGISTRY_FILE), "blobs");
742
+ /** Transcripts of sessions whose omp exited, served by `/transcript` without starting one. */
743
+ this.transcripts = new TranscriptStore(options.transcriptDir ?? join(dirname(options.registryFile ?? DEFAULT_REGISTRY_FILE), "transcripts"));
573
744
  this.server = createServer((req, res) => void this.admitHttp(req, res));
574
- this.wsServer = new WebSocketServer({ noServer: true });
745
+ // Compressed frames: a snapshot is JSON text, several times smaller deflated, and
746
+ // the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
747
+ this.wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: { threshold: 1_024 } });
575
748
  this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
576
749
  }
577
750
 
@@ -643,7 +816,7 @@ class Daemon {
643
816
  this.noteClient(req);
644
817
  const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
645
818
  if (blob !== null && req.method === "GET") {
646
- sendBlob(res, this.options.ompHome, blob[1]);
819
+ sendBlob(res, [join(this.options.ompHome, "agent", "blobs"), this.blobDir], blob[1]);
647
820
  return;
648
821
  }
649
822
  if (url.pathname === "/status" && req.method === "GET") {
@@ -690,6 +863,11 @@ class Daemon {
690
863
  sendJson(res, 200, await this.takeOver(decodeURIComponent(takeover[1]), device));
691
864
  return;
692
865
  }
866
+ const transcript = /^\/sessions\/([^/]+)\/transcript$/.exec(url.pathname);
867
+ if (transcript !== null && req.method === "GET") {
868
+ sendJson(res, 200, this.transcript(decodeURIComponent(transcript[1]), deltaQuery(url.searchParams)));
869
+ return;
870
+ }
693
871
  const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
694
872
  if (match !== null) {
695
873
  const id = decodeURIComponent(match[1]);
@@ -700,8 +878,13 @@ class Daemon {
700
878
  }
701
879
  if (req.method === "DELETE") {
702
880
  const live = this.sessions.get(id);
703
- if (live !== undefined) this.attention.forget(live);
881
+ if (live !== undefined) {
882
+ this.attention.forget(live);
883
+ // Closed for good: its exit must not save it, or bring it back into the registry.
884
+ live.onClosed = undefined;
885
+ }
704
886
  live?.close();
887
+ this.transcripts.remove(id);
705
888
  this.forgetOwners();
706
889
  this.sessions.delete(id);
707
890
  this.registry.delete(id);
@@ -736,6 +919,10 @@ class Daemon {
736
919
  const others = found
737
920
  .filter((session) => !known.has(session.id))
738
921
  .map((session) => ({ ...session, status: "idle", origin: "omp" }));
922
+ // A dormant session is as recent as its file when a terminal omp wrote it since, not when our own omp's exit did.
923
+ for (const session of dormant) {
924
+ session.updatedAt = dormantActivity(this.registry.get(session.id), this.ompSessions.get(session.id));
925
+ }
739
926
  const marked = await Promise.all(
740
927
  [...dormant, ...others].map(async (session) => {
741
928
  const pid = owners?.get(session.id);
@@ -792,6 +979,31 @@ class Daemon {
792
979
  this.ownerLookup = { at: 0, owners: undefined };
793
980
  }
794
981
 
982
+ /**
983
+ * A session's transcript without starting an omp: a live session's from
984
+ * memory, a closed one's from its saved copy while its session file is
985
+ * unchanged; otherwise a 404 the phone skips.
986
+ * @param {string} id
987
+ * @param {{since: number, last: string}} held
988
+ */
989
+ transcript(id, held) {
990
+ const live = this.sessions.get(id);
991
+ if (live !== undefined && !live.closed) return live.snapshot(held);
992
+ const known = this.registry.get(id)?.sessionFile ?? this.ompSessions.get(id)?.sessionFile;
993
+ const saved = this.transcripts.read(id, typeof known === "string" ? known : undefined);
994
+ if (saved === undefined) throw httpError(404, "not cached");
995
+ return deltaBody(saved, held);
996
+ }
997
+
998
+ /** The omp of a session exited: save its transcript and when it was last active. */
999
+ sessionClosed(entry) {
1000
+ if (entry.id === "") return;
1001
+ const sessionFile = entry.state?.sessionFile;
1002
+ entry.closedFile = typeof sessionFile === "string" ? fileState(sessionFile) : undefined;
1003
+ this.transcripts.write(entry.id, { sessionFile, messages: entry.messages, base: entry.base, state: entry.state, session: entry.summary() });
1004
+ if (this.registry.has(entry.id)) this.remember(entry);
1005
+ }
1006
+
795
1007
  remember(entry) {
796
1008
  this.registry.set(entry.id, {
797
1009
  id: entry.id,
@@ -801,6 +1013,8 @@ class Daemon {
801
1013
  createdAt: entry.createdAt,
802
1014
  updatedAt: entry.updatedAt,
803
1015
  sessionFile: entry.state?.sessionFile,
1016
+ // Set once its omp exited: the file as that omp left it (see `dormantActivity`).
1017
+ ...(entry.closedFile !== undefined ? { closedFile: entry.closedFile } : {}),
804
1018
  });
805
1019
  saveRegistry(this.options.registryFile, this.registry);
806
1020
  }
@@ -853,10 +1067,14 @@ class Daemon {
853
1067
  try {
854
1068
  await rpc.start();
855
1069
  const entry = new SessionEntry(stored.cwd, rpc);
856
- await entry.hydrate();
1070
+ entry.blobDir = this.blobDir;
1071
+ await entry.hydrate(this.registry.has(id) ? dormantActivity(this.registry.get(id), this.ompSessions.get(id)) : stored.updatedAt);
857
1072
  entry.id = id;
1073
+ // Idle from now for the reaper, until a phone attaches: its last activity may be long ago.
1074
+ entry.idleSince = Date.now();
858
1075
  entry.onChange = (changed) => this.remember(changed);
859
1076
  entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
1077
+ entry.onClosed = (closed) => this.sessionClosed(closed);
860
1078
  entry.bind();
861
1079
  this.sessions.set(id, entry);
862
1080
  this.remember(entry);
@@ -929,10 +1147,13 @@ class Daemon {
929
1147
  try {
930
1148
  await rpc.start();
931
1149
  const entry = new SessionEntry(cwd, rpc);
1150
+ entry.blobDir = this.blobDir;
932
1151
  await entry.hydrate();
933
1152
  if (entry.id === "") throw new Error("omp returned no session id");
1153
+ entry.idleSince = Date.now();
934
1154
  entry.onChange = (changed) => this.remember(changed);
935
1155
  entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
1156
+ entry.onClosed = (closed) => this.sessionClosed(closed);
936
1157
  entry.bind();
937
1158
  this.sessions.set(entry.id, entry);
938
1159
  this.remember(entry);
@@ -958,6 +1179,8 @@ class Daemon {
958
1179
  }
959
1180
  this.noteClient(req);
960
1181
  const id = decodeURIComponent(match[1]);
1182
+ // What the phone already holds, read now: the snapshot is cut against it once the session is open.
1183
+ const held = deltaQuery(url.searchParams);
961
1184
  // Finish the handshake before the session is open: a slow omp can take
962
1185
  // tens of seconds, and a phone's WebSocket client gives up after ~10 s --
963
1186
  // then retries, starting another omp every time. The snapshot (or
@@ -990,7 +1213,7 @@ class Daemon {
990
1213
  if (ws.readyState !== 1) return;
991
1214
  live.clients.add(ws);
992
1215
  live.idleSince = undefined;
993
- ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot() }));
1216
+ ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot(held) }));
994
1217
  // A dialog opened before this client connected (it may have come from a notification tap).
995
1218
  for (const request of live.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
996
1219
  for (const data of early) void this.handleClientMessage(live, ws, data);
@@ -1118,8 +1341,10 @@ class Daemon {
1118
1341
  });
1119
1342
  }
1120
1343
 
1121
- /** Keepalive pings and the idle-session reaper, once the daemon listens. */
1344
+ /** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
1122
1345
  startTimers() {
1346
+ pruneBlobCache(this.blobDir);
1347
+ this.transcripts.prune();
1123
1348
  this.heartbeat = setInterval(() => this.pingClients(), this.options.heartbeatMs ?? HEARTBEAT_MS);
1124
1349
  this.heartbeat.unref?.();
1125
1350
  this.reaper = setInterval(() => this.reapIdleSessions(), this.options.reapIntervalMs ?? REAP_INTERVAL_MS);
@@ -1293,4 +1518,4 @@ if (isMain) {
1293
1518
  });
1294
1519
  }
1295
1520
 
1296
- export { Daemon, SessionEntry, VERSION, PROTOCOL, parseArgs, resolveIdentity };
1521
+ export { Daemon, SessionEntry, VERSION, PROTOCOL, FEATURES, parseArgs, resolveIdentity };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The daemon's saved transcripts: the slim messages of each session it had
3
+ * open, written when the session's omp exits (the reaper, the daemon
4
+ * stopping, omp leaving on its own), so a phone can read a recent session
5
+ * without the daemon starting an omp for it.
6
+ *
7
+ * A record is valid while the session file it was read from has the same
8
+ * modification time and size: anything that wrote the session since (a
9
+ * terminal omp, a resume elsewhere) makes it stale, and it is not served.
10
+ * Records are kept while their session was active in the last
11
+ * {@link RECENT_MS}; nothing is evicted by count.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ /** How long after its last activity a session's transcript is kept, on the computer and on the phone. */
18
+ export const RECENT_MS = 24 * 60 * 60_000;
19
+ const VERSION = 1;
20
+ const NAME = /^[A-Za-z0-9_-]{1,140}\.json$/;
21
+ /** A temporary file left by a write that never finished (a crash) is dropped after this long. */
22
+ const TEMP_MAX_AGE_MS = 60_000;
23
+ let writes = 0;
24
+
25
+ /**
26
+ * What identifies one message well enough to line a phone's copy up with the
27
+ * daemon's: OMP stamps every message, and tool results and replies carry ids.
28
+ * The app computes the same string (`app/src/cache.ts`).
29
+ * @param {any} message
30
+ */
31
+ export function fingerprint(message) {
32
+ if (message === null || typeof message !== "object") return "";
33
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : typeof message.responseId === "string" ? message.responseId : "";
34
+ return `${String(message.role ?? "")}:${String(message.timestamp ?? "")}:${id}`;
35
+ }
36
+
37
+ /**
38
+ * Where a phone that holds the first `since` messages (the last of them
39
+ * fingerprinted `last`) can continue: `since` when its copy lines up with
40
+ * `messages` there, else 0 (send everything). Indices are the transcript's:
41
+ * `messages` start at `base`, the count a long session dropped from its front.
42
+ * @param {readonly any[]} messages
43
+ * @param {number} since
44
+ * @param {string} last
45
+ * @param {number} [base]
46
+ */
47
+ export function deltaFrom(messages, since, last, base = 0) {
48
+ if (!Array.isArray(messages) || !Number.isSafeInteger(since) || since <= base || since > base + messages.length) return 0;
49
+ if (typeof last !== "string" || last === "") return 0;
50
+ return fingerprint(messages[since - 1 - base]) === last ? since : 0;
51
+ }
52
+
53
+ /**
54
+ * The `since` and `last` query parameters, as a phone sends them; anything
55
+ * malformed reads as "nothing held" (a whole transcript).
56
+ * @param {URLSearchParams} searchParams
57
+ */
58
+ export function deltaQuery(searchParams) {
59
+ const raw = searchParams.get("since") ?? "";
60
+ const since = /^\d{1,9}$/.test(raw) ? Number(raw) : 0;
61
+ return { since, last: searchParams.get("last") ?? "" };
62
+ }
63
+
64
+ /**
65
+ * The body of a snapshot frame or a `/transcript` answer: the messages after
66
+ * what the phone holds when its copy lines up, else all of them. `from` is the
67
+ * index of the first message sent (0: the whole transcript), `total` how many
68
+ * the transcript has. A session past the daemon's cap holds only its last
69
+ * messages, from index `base` on: a delta is still cut at the phone's `since`,
70
+ * and everything is sent as a whole transcript of what is held, with `base`
71
+ * saying where in the session it starts.
72
+ * @param {{session: any, state: any, messages: any[], base?: number}} transcript
73
+ * @param {{since: number, last: string}} [held]
74
+ */
75
+ export function deltaBody({ session, state, messages, base = 0 }, held = { since: 0, last: "" }) {
76
+ const start = Number.isSafeInteger(base) && base > 0 ? base : 0;
77
+ const from = deltaFrom(messages, held.since, held.last, start);
78
+ if (from > 0) return { session, state, from, total: start + messages.length, messages: messages.slice(from - start) };
79
+ return { session, state, from: 0, total: messages.length, messages, ...(start > 0 ? { base: start } : {}) };
80
+ }
81
+
82
+ function fileState(path) {
83
+ try {
84
+ const info = statSync(path);
85
+ return info.isFile() ? { mtimeMs: info.mtimeMs, size: info.size } : undefined;
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /** A record that still describes its session file as it is now. */
92
+ function current(record) {
93
+ if (typeof record?.sessionFile !== "string") return false;
94
+ const now = fileState(record.sessionFile);
95
+ return now !== undefined && now.mtimeMs === record.file?.mtimeMs && now.size === record.file?.size;
96
+ }
97
+
98
+ export class TranscriptStore {
99
+ /** @param {string} dir */
100
+ constructor(dir) {
101
+ this.dir = dir;
102
+ }
103
+
104
+ /** @param {string} id */
105
+ path(id) {
106
+ const safe = id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 120);
107
+ // Two ids that differ only in characters a file name cannot carry must not share a file.
108
+ const name = safe === id ? safe : `${safe}-${createHash("sha256").update(id).digest("hex").slice(0, 16)}`;
109
+ return join(this.dir, `${name}.json`);
110
+ }
111
+
112
+ /**
113
+ * Save a session's slim messages against the current state of its file:
114
+ * call it once omp has exited, when the file is final. Best effort: a full
115
+ * disk only loses the shortcut.
116
+ * @param {string} id
117
+ * @param {{sessionFile?: string, messages: any[], state: any, session: any, base?: number}} record
118
+ * @returns {boolean} whether it was saved
119
+ */
120
+ write(id, { sessionFile, messages, state, session, base = 0 }) {
121
+ if (typeof id !== "string" || id === "" || typeof sessionFile !== "string" || !Array.isArray(messages)) return false;
122
+ const file = fileState(sessionFile);
123
+ if (file === undefined) return false;
124
+ const path = this.path(id);
125
+ writes += 1;
126
+ const temporary = `${path}.${process.pid}-${writes}.tmp`;
127
+ // No omp runs for it any more: nothing streams and nothing waits for an answer.
128
+ const record = {
129
+ v: VERSION,
130
+ id,
131
+ sessionFile,
132
+ file,
133
+ savedAt: Date.now(),
134
+ // When the session was last used: omp rewrites its file as it exits, so the file's time is not that.
135
+ activeAt: Number(session?.updatedAt) || file.mtimeMs,
136
+ base: Number.isSafeInteger(base) && base > 0 ? base : 0,
137
+ messages,
138
+ state: state !== null && typeof state === "object" ? { ...state, isStreaming: false } : state,
139
+ session: { ...session, status: "idle", waiting: 0 },
140
+ };
141
+ try {
142
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
143
+ writeFileSync(temporary, JSON.stringify(record), { mode: 0o600 });
144
+ renameSync(temporary, path);
145
+ return true;
146
+ } catch {
147
+ try {
148
+ unlinkSync(temporary);
149
+ } catch {
150
+ // Never written.
151
+ }
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * A saved transcript that still matches its session file, else undefined.
158
+ * Its session is dated by its last activity, not by the file: the file was
159
+ * last written by the omp exiting.
160
+ * @param {string} id
161
+ * @param {string | undefined} sessionFile the file the session is known by now, when known
162
+ */
163
+ read(id, sessionFile) {
164
+ let record;
165
+ try {
166
+ record = JSON.parse(readFileSync(this.path(id), "utf8"));
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ if (record?.v !== VERSION || record.id !== id || !Array.isArray(record.messages)) return undefined;
171
+ if (sessionFile !== undefined && record.sessionFile !== sessionFile) return undefined;
172
+ if (!current(record)) return undefined;
173
+ const session = record.session !== null && typeof record.session === "object" ? record.session : { id };
174
+ return { ...record, session: { ...session, updatedAt: Number(session.updatedAt) || record.file.mtimeMs } };
175
+ }
176
+
177
+ /** @param {string} id */
178
+ remove(id) {
179
+ try {
180
+ unlinkSync(this.path(id));
181
+ } catch {
182
+ // Never saved.
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Drop transcripts whose session has been quiet longer than
188
+ * {@link RECENT_MS}, and those that can never be served again (their
189
+ * session file changed or is gone, or the record is unreadable).
190
+ */
191
+ prune(now = Date.now()) {
192
+ let names;
193
+ try {
194
+ names = readdirSync(this.dir);
195
+ } catch {
196
+ return 0;
197
+ }
198
+ let dropped = 0;
199
+ for (const name of names) {
200
+ const path = join(this.dir, name);
201
+ if (name.endsWith(".tmp")) {
202
+ try {
203
+ if (now - statSync(path).mtimeMs > TEMP_MAX_AGE_MS) unlinkSync(path);
204
+ } catch {
205
+ // Gone already.
206
+ }
207
+ continue;
208
+ }
209
+ if (!NAME.test(name)) continue;
210
+ let record;
211
+ try {
212
+ record = JSON.parse(readFileSync(path, "utf8"));
213
+ } catch {
214
+ record = undefined;
215
+ }
216
+ const activeAt = Number(record?.activeAt) || 0;
217
+ if (record?.v === VERSION && now - activeAt <= RECENT_MS && current(record)) continue;
218
+ try {
219
+ unlinkSync(path);
220
+ dropped += 1;
221
+ } catch {
222
+ // Gone already.
223
+ }
224
+ }
225
+ return dropped;
226
+ }
227
+ }