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 +1 -1
- package/src/omp-sessions.mjs +3 -1
- package/src/server.mjs +252 -27
- package/src/transcripts.mjs +227 -0
package/package.json
CHANGED
package/src/omp-sessions.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 >
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|