omp-mobile 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/omp-extension.mjs +2 -2
- package/src/omp-sessions.mjs +3 -1
- package/src/server.mjs +143 -20
- package/src/takeover.mjs +15 -3
- package/src/transcripts.mjs +227 -0
package/package.json
CHANGED
package/src/omp-extension.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { delimiter, join } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
import { renderQr } from "./qr.mjs";
|
|
24
|
-
import { requestPath } from "./takeover.mjs";
|
|
24
|
+
import { requestPath, shellWord } from "./takeover.mjs";
|
|
25
25
|
|
|
26
26
|
const HOME = join(homedir(), ".omp-mobile");
|
|
27
27
|
const STATE_FILE = join(HOME, "daemon.json");
|
|
@@ -229,7 +229,7 @@ export default function ompMobile(pi) {
|
|
|
229
229
|
rmSync(requestPath(file), { force: true });
|
|
230
230
|
// OMP closes once the session is idle; the daemon signals it if that does not come soon,
|
|
231
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
|
|
232
|
+
ctx.ui.notify(`OMP Mobile: this session continues on ${request.device}. Closing omp here; resume it later with: omp --resume ${shellWord(file)}`, "warning");
|
|
233
233
|
if (!ctx.isIdle()) {
|
|
234
234
|
ctx.abort();
|
|
235
235
|
const deadline = Date.now() + TAKEOVER_IDLE_WAIT_MS;
|
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
|
@@ -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,8 @@ 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;
|
|
58
61
|
/**
|
|
59
62
|
* The fields of a tool result's `details` the app reads: an edit's diff, a
|
|
60
63
|
* read's line count, a command's run time, a screenshot's file. The rest
|
|
@@ -100,9 +103,11 @@ export function promptImages(value) {
|
|
|
100
103
|
* the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
|
|
101
104
|
* `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds;
|
|
102
105
|
* `heartbeat`: session sockets get a `ping` frame every 20 s, so the app can
|
|
103
|
-
* treat a silent link as gone
|
|
106
|
+
* treat a silent link as gone; `transcript-sync`: a session socket's `since`/`last`
|
|
107
|
+
* ask for a delta snapshot (`from`, `total`), and `GET /sessions/:id/transcript`
|
|
108
|
+
* answers the same without starting an omp.
|
|
104
109
|
*/
|
|
105
|
-
const FEATURES = ["prompt-images", "takeover", "heartbeat"];
|
|
110
|
+
const FEATURES = ["prompt-images", "takeover", "heartbeat", "transcript-sync"];
|
|
106
111
|
/** Who a phone continues a session on, as the terminal it leaves is told. */
|
|
107
112
|
const DEFAULT_DEVICE = "OMP Mobile";
|
|
108
113
|
/** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
|
|
@@ -376,6 +381,46 @@ export function pruneBlobCache(blobDir, now = Date.now()) {
|
|
|
376
381
|
return dropped;
|
|
377
382
|
}
|
|
378
383
|
|
|
384
|
+
/**
|
|
385
|
+
* When the messages were last written: the newest timestamp OMP stamped on
|
|
386
|
+
* them, or undefined when none carries one.
|
|
387
|
+
* @param {readonly any[]} messages
|
|
388
|
+
*/
|
|
389
|
+
export function lastActivity(messages) {
|
|
390
|
+
let latest;
|
|
391
|
+
for (let i = messages.length - 1; i >= 0 && i >= messages.length - 50; i -= 1) {
|
|
392
|
+
const at = Number(messages[i]?.timestamp);
|
|
393
|
+
if (Number.isFinite(at) && at > 0 && (latest === undefined || at > latest)) latest = at;
|
|
394
|
+
}
|
|
395
|
+
return latest;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* When a session the daemon knows but has no omp for was last used: what the
|
|
400
|
+
* registry says, or its file's time when something wrote the file since --
|
|
401
|
+
* unless the file is exactly as this daemon's own omp left it on exit (omp
|
|
402
|
+
* rewrites the whole file as it closes; that is not anyone using it).
|
|
403
|
+
* @param {{updatedAt?: number, closedFile?: {mtimeMs: number, size: number}} | undefined} stored
|
|
404
|
+
* @param {{updatedAt: number, size?: number} | undefined} scanned
|
|
405
|
+
*/
|
|
406
|
+
export function dormantActivity(stored, scanned) {
|
|
407
|
+
const known = Number(stored?.updatedAt) || 0;
|
|
408
|
+
if (scanned === undefined || !(scanned.updatedAt > known)) return known;
|
|
409
|
+
const closed = stored?.closedFile;
|
|
410
|
+
if (closed !== undefined && closed !== null && closed.mtimeMs === scanned.updatedAt && closed.size === scanned.size) return known;
|
|
411
|
+
return scanned.updatedAt;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A file's time and size, as `TranscriptStore` checks them; undefined when it cannot be read. */
|
|
415
|
+
function fileState(path) {
|
|
416
|
+
try {
|
|
417
|
+
const info = statSync(path);
|
|
418
|
+
return info.isFile() ? { mtimeMs: info.mtimeMs, size: info.size } : undefined;
|
|
419
|
+
} catch {
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
379
424
|
function storedSummary(stored) {
|
|
380
425
|
return {
|
|
381
426
|
id: stored.id,
|
|
@@ -503,6 +548,10 @@ class SessionEntry {
|
|
|
503
548
|
this.id = "";
|
|
504
549
|
this.state = null;
|
|
505
550
|
this.messages = [];
|
|
551
|
+
/** How many messages left the front of `messages` (the cap): `messages[0]` is the transcript's message `base`. */
|
|
552
|
+
this.base = 0;
|
|
553
|
+
/** The session file as this omp left it on exit, so its rewrite is not taken for activity. */
|
|
554
|
+
this.closedFile = undefined;
|
|
506
555
|
this.createdAt = Date.now();
|
|
507
556
|
this.updatedAt = this.createdAt;
|
|
508
557
|
this.title = cwd.split("/").filter(Boolean).at(-1) ?? "session";
|
|
@@ -520,15 +569,26 @@ class SessionEntry {
|
|
|
520
569
|
this.idleSince = undefined;
|
|
521
570
|
/** Where inline images go (see `phoneMessage`); undefined keeps them inline. */
|
|
522
571
|
this.blobDir = undefined;
|
|
572
|
+
/**
|
|
573
|
+
* Hears the omp process exit, once its session file is final (the daemon saves the transcript).
|
|
574
|
+
* @type {((entry: SessionEntry) => void) | undefined}
|
|
575
|
+
*/
|
|
576
|
+
this.onClosed = undefined;
|
|
523
577
|
}
|
|
524
578
|
|
|
525
|
-
|
|
579
|
+
/**
|
|
580
|
+
* @param {number} [knownActivity] when the session was last used, as the daemon knew it before this omp
|
|
581
|
+
*/
|
|
582
|
+
async hydrate(knownActivity) {
|
|
526
583
|
const [state, messages] = await Promise.all([this.rpc.getState(), this.rpc.getMessages()]);
|
|
527
584
|
this.state = state;
|
|
528
585
|
this.messages = Array.isArray(messages.messages) ? messages.messages.map((message) => phoneMessage(message, this.blobDir)) : [];
|
|
586
|
+
this.base = 0;
|
|
529
587
|
this.id = state.sessionId ?? "";
|
|
530
588
|
this.title = titleOf(this.messages, state, this.cwd);
|
|
531
|
-
|
|
589
|
+
// Opening a session is not using it: it is as recent as its last message (a new one: now).
|
|
590
|
+
const known = Math.max(lastActivity(this.messages) ?? 0, Number(knownActivity) || 0);
|
|
591
|
+
this.updatedAt = known > 0 ? known : Date.now();
|
|
532
592
|
return this;
|
|
533
593
|
}
|
|
534
594
|
|
|
@@ -548,12 +608,13 @@ class SessionEntry {
|
|
|
548
608
|
};
|
|
549
609
|
}
|
|
550
610
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
611
|
+
/**
|
|
612
|
+
* What a phone is sent on open: everything, or only the messages after the
|
|
613
|
+
* ones it holds when its copy lines up (see `deltaBody`).
|
|
614
|
+
* @param {{since: number, last: string}} [held]
|
|
615
|
+
*/
|
|
616
|
+
snapshot(held) {
|
|
617
|
+
return deltaBody({ session: this.summary(), state: this.state, messages: this.messages, base: this.base }, held);
|
|
557
618
|
}
|
|
558
619
|
|
|
559
620
|
broadcast(payload) {
|
|
@@ -567,7 +628,6 @@ class SessionEntry {
|
|
|
567
628
|
async refreshState() {
|
|
568
629
|
try {
|
|
569
630
|
this.state = await this.rpc.getState();
|
|
570
|
-
this.updatedAt = Date.now();
|
|
571
631
|
this.broadcast({ type: "state", state: this.state });
|
|
572
632
|
} catch {
|
|
573
633
|
// The process may be exiting; the close event tells clients.
|
|
@@ -576,10 +636,17 @@ class SessionEntry {
|
|
|
576
636
|
|
|
577
637
|
bind() {
|
|
578
638
|
this.rpc.on("event", (event) => {
|
|
579
|
-
this.updatedAt = Date.now();
|
|
580
639
|
if (event.type === "message_end" && event.message) {
|
|
640
|
+
// A new message is what makes a session the most recent one; opening it,
|
|
641
|
+
// a lifecycle event from a resumed omp, or a state echo are not.
|
|
642
|
+
this.updatedAt = Date.now();
|
|
581
643
|
this.messages.push(phoneMessage(event.message, this.blobDir));
|
|
582
|
-
if (this.messages.length >
|
|
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
|
+
}
|
|
583
650
|
// The first prompt names a session that was created untitled.
|
|
584
651
|
const title = titleOf(this.messages, this.state, this.cwd);
|
|
585
652
|
if (title !== this.title) {
|
|
@@ -606,6 +673,11 @@ class SessionEntry {
|
|
|
606
673
|
this.rpc.on("closed", ({ reason, stderr }) => {
|
|
607
674
|
this.closed = true;
|
|
608
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
|
+
}
|
|
609
681
|
});
|
|
610
682
|
this.rpc.on("protocol-error", (payload) => this.broadcast({ type: "protocol-error", ...payload }));
|
|
611
683
|
}
|
|
@@ -667,6 +739,8 @@ class Daemon {
|
|
|
667
739
|
this.takingOver = new Map();
|
|
668
740
|
/** Images stashed out of snapshots, served by `/blobs` next to OMP's own store. */
|
|
669
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"));
|
|
670
744
|
this.server = createServer((req, res) => void this.admitHttp(req, res));
|
|
671
745
|
// Compressed frames: a snapshot is JSON text, several times smaller deflated, and
|
|
672
746
|
// the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
|
|
@@ -789,6 +863,11 @@ class Daemon {
|
|
|
789
863
|
sendJson(res, 200, await this.takeOver(decodeURIComponent(takeover[1]), device));
|
|
790
864
|
return;
|
|
791
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
|
+
}
|
|
792
871
|
const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
|
|
793
872
|
if (match !== null) {
|
|
794
873
|
const id = decodeURIComponent(match[1]);
|
|
@@ -799,8 +878,13 @@ class Daemon {
|
|
|
799
878
|
}
|
|
800
879
|
if (req.method === "DELETE") {
|
|
801
880
|
const live = this.sessions.get(id);
|
|
802
|
-
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
|
+
}
|
|
803
886
|
live?.close();
|
|
887
|
+
this.transcripts.remove(id);
|
|
804
888
|
this.forgetOwners();
|
|
805
889
|
this.sessions.delete(id);
|
|
806
890
|
this.registry.delete(id);
|
|
@@ -835,6 +919,10 @@ class Daemon {
|
|
|
835
919
|
const others = found
|
|
836
920
|
.filter((session) => !known.has(session.id))
|
|
837
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
|
+
}
|
|
838
926
|
const marked = await Promise.all(
|
|
839
927
|
[...dormant, ...others].map(async (session) => {
|
|
840
928
|
const pid = owners?.get(session.id);
|
|
@@ -891,6 +979,31 @@ class Daemon {
|
|
|
891
979
|
this.ownerLookup = { at: 0, owners: undefined };
|
|
892
980
|
}
|
|
893
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
|
+
|
|
894
1007
|
remember(entry) {
|
|
895
1008
|
this.registry.set(entry.id, {
|
|
896
1009
|
id: entry.id,
|
|
@@ -900,6 +1013,8 @@ class Daemon {
|
|
|
900
1013
|
createdAt: entry.createdAt,
|
|
901
1014
|
updatedAt: entry.updatedAt,
|
|
902
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 } : {}),
|
|
903
1018
|
});
|
|
904
1019
|
saveRegistry(this.options.registryFile, this.registry);
|
|
905
1020
|
}
|
|
@@ -953,10 +1068,13 @@ class Daemon {
|
|
|
953
1068
|
await rpc.start();
|
|
954
1069
|
const entry = new SessionEntry(stored.cwd, rpc);
|
|
955
1070
|
entry.blobDir = this.blobDir;
|
|
956
|
-
await entry.hydrate();
|
|
1071
|
+
await entry.hydrate(this.registry.has(id) ? dormantActivity(this.registry.get(id), this.ompSessions.get(id)) : stored.updatedAt);
|
|
957
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();
|
|
958
1075
|
entry.onChange = (changed) => this.remember(changed);
|
|
959
1076
|
entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
|
|
1077
|
+
entry.onClosed = (closed) => this.sessionClosed(closed);
|
|
960
1078
|
entry.bind();
|
|
961
1079
|
this.sessions.set(id, entry);
|
|
962
1080
|
this.remember(entry);
|
|
@@ -1007,7 +1125,7 @@ class Daemon {
|
|
|
1007
1125
|
const deadline = Date.now() + 3_000;
|
|
1008
1126
|
while ((await this.owners())?.get(id) === pid && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 100));
|
|
1009
1127
|
// Whoever looks at the terminal sees why omp closed.
|
|
1010
|
-
if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell(device));
|
|
1128
|
+
if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell({ device, sessionFile: stored.sessionFile }));
|
|
1011
1129
|
const forks = forksOf(dirname(stored.sessionFile), id, since);
|
|
1012
1130
|
if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
|
|
1013
1131
|
outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
|
|
@@ -1032,8 +1150,10 @@ class Daemon {
|
|
|
1032
1150
|
entry.blobDir = this.blobDir;
|
|
1033
1151
|
await entry.hydrate();
|
|
1034
1152
|
if (entry.id === "") throw new Error("omp returned no session id");
|
|
1153
|
+
entry.idleSince = Date.now();
|
|
1035
1154
|
entry.onChange = (changed) => this.remember(changed);
|
|
1036
1155
|
entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
|
|
1156
|
+
entry.onClosed = (closed) => this.sessionClosed(closed);
|
|
1037
1157
|
entry.bind();
|
|
1038
1158
|
this.sessions.set(entry.id, entry);
|
|
1039
1159
|
this.remember(entry);
|
|
@@ -1059,6 +1179,8 @@ class Daemon {
|
|
|
1059
1179
|
}
|
|
1060
1180
|
this.noteClient(req);
|
|
1061
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);
|
|
1062
1184
|
// Finish the handshake before the session is open: a slow omp can take
|
|
1063
1185
|
// tens of seconds, and a phone's WebSocket client gives up after ~10 s --
|
|
1064
1186
|
// then retries, starting another omp every time. The snapshot (or
|
|
@@ -1091,7 +1213,7 @@ class Daemon {
|
|
|
1091
1213
|
if (ws.readyState !== 1) return;
|
|
1092
1214
|
live.clients.add(ws);
|
|
1093
1215
|
live.idleSince = undefined;
|
|
1094
|
-
ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot() }));
|
|
1216
|
+
ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot(held) }));
|
|
1095
1217
|
// A dialog opened before this client connected (it may have come from a notification tap).
|
|
1096
1218
|
for (const request of live.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
|
|
1097
1219
|
for (const data of early) void this.handleClientMessage(live, ws, data);
|
|
@@ -1131,6 +1253,8 @@ class Daemon {
|
|
|
1131
1253
|
const text = typeof message.text === "string" ? message.text : "";
|
|
1132
1254
|
if (text.trim() === "" && images.length === 0) throw new Error("empty prompt");
|
|
1133
1255
|
await entry.rpc.prompt(text, images);
|
|
1256
|
+
entry.updatedAt = Date.now();
|
|
1257
|
+
entry.onChange?.(entry);
|
|
1134
1258
|
ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
|
|
1135
1259
|
return;
|
|
1136
1260
|
}
|
|
@@ -1162,7 +1286,6 @@ class Daemon {
|
|
|
1162
1286
|
}
|
|
1163
1287
|
const state = await entry.rpc.setModel(message.provider, message.modelId);
|
|
1164
1288
|
entry.state = state;
|
|
1165
|
-
entry.updatedAt = Date.now();
|
|
1166
1289
|
entry.onChange?.(entry);
|
|
1167
1290
|
entry.broadcast({ type: "state", state });
|
|
1168
1291
|
ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
|
|
@@ -1179,7 +1302,6 @@ class Daemon {
|
|
|
1179
1302
|
if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
|
|
1180
1303
|
const state = await entry.rpc.setThinkingLevel(message.level);
|
|
1181
1304
|
entry.state = state;
|
|
1182
|
-
entry.updatedAt = Date.now();
|
|
1183
1305
|
entry.broadcast({ type: "state", state });
|
|
1184
1306
|
ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
|
|
1185
1307
|
return;
|
|
@@ -1222,6 +1344,7 @@ class Daemon {
|
|
|
1222
1344
|
/** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
|
|
1223
1345
|
startTimers() {
|
|
1224
1346
|
pruneBlobCache(this.blobDir);
|
|
1347
|
+
this.transcripts.prune();
|
|
1225
1348
|
this.heartbeat = setInterval(() => this.pingClients(), this.options.heartbeatMs ?? HEARTBEAT_MS);
|
|
1226
1349
|
this.heartbeat.unref?.();
|
|
1227
1350
|
this.reaper = setInterval(() => this.reapIdleSessions(), this.options.reapIntervalMs ?? REAP_INTERVAL_MS);
|
|
@@ -1395,4 +1518,4 @@ if (isMain) {
|
|
|
1395
1518
|
});
|
|
1396
1519
|
}
|
|
1397
1520
|
|
|
1398
|
-
export { Daemon, SessionEntry, VERSION, PROTOCOL, parseArgs, resolveIdentity };
|
|
1521
|
+
export { Daemon, SessionEntry, VERSION, PROTOCOL, FEATURES, parseArgs, resolveIdentity };
|
package/src/takeover.mjs
CHANGED
|
@@ -175,9 +175,21 @@ export function tellTerminal(tty, text) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
/**
|
|
179
|
-
export function
|
|
180
|
-
return
|
|
178
|
+
/** A path as a shell word: quoted only when it has to be. */
|
|
179
|
+
export function shellWord(value) {
|
|
180
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What the terminal is told once omp left it: where the session went, and the
|
|
185
|
+
* command that brings it back -- the hint leaving omp by hand also leaves.
|
|
186
|
+
* @param {{device: string, sessionFile: string}} leaving
|
|
187
|
+
*/
|
|
188
|
+
export function takeoverFarewell({ device, sessionFile }) {
|
|
189
|
+
const lines = [`This session continues on the phone (${device}).`, "omp closed here so the phone could take it over."];
|
|
190
|
+
const file = typeof sessionFile === "string" && sessionFile !== "" ? shellWord(sessionFile) : "";
|
|
191
|
+
if (file !== "") lines.push("", "Resume it in this terminal with:", ` omp --resume ${file}`);
|
|
192
|
+
return lines.join("\n");
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
@@ -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
|
+
}
|