omp-mobile 0.7.0 → 0.7.2
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/README.md +1 -1
- package/package.json +1 -1
- package/src/restart.mjs +156 -0
- package/src/rpc-session.mjs +16 -0
- package/src/server.mjs +84 -4
- package/src/subagents.mjs +218 -0
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Install the plugin into OMP on every computer:
|
|
|
42
42
|
omp plugin install omp-mobile
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Then in OMP, run `/mobile`. It starts the daemon in the background, bound to the computer's Tailscale address, and shows a pairing code above the composer until the phone connects. The daemon runs on the Bun inside OMP and outlives the terminal and OMP; `/mobile status` shows it and `/mobile stop` ends it. Phone sessions start in the directory where you ran `/mobile` the first time.
|
|
45
|
+
Then in OMP, run `/mobile`. It starts the daemon in the background, bound to the computer's Tailscale address, and shows a pairing code above the composer until the phone connects. The daemon runs on the Bun inside OMP and outlives the terminal and OMP; `/mobile status` shows it and `/mobile stop` ends it. After `omp plugin upgrade omp-mobile`, `omp-mobile restart` puts the running daemon on the new version with the same settings, once no session is mid-turn (safe to run from a phone session). Phone sessions start in the directory where you ran `/mobile` the first time.
|
|
46
46
|
|
|
47
47
|
Without the plugin, the same daemon runs from npm (Node.js 22.19 or newer) in the foreground: `npm install -g omp-mobile`, then `omp-mobile --host tailscale --cwd ~/project`. It prints the same code.
|
|
48
48
|
|
package/package.json
CHANGED
package/src/restart.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `omp-mobile restart`: replace the running daemon with the code installed
|
|
3
|
+
* now (after `omp plugin upgrade omp-mobile`), with the same arguments.
|
|
4
|
+
*
|
|
5
|
+
* The daemon runs every phone session's omp as its child, so whoever asks
|
|
6
|
+
* for a restart from such a session (an agent, a phone) would be cut off by
|
|
7
|
+
* the stop and could never start the new daemon. The work is therefore done
|
|
8
|
+
* by a detached worker in its own session: it waits until no session is
|
|
9
|
+
* running a turn (unless `--now`), stops the daemon, starts the new one and
|
|
10
|
+
* logs beside the state file (`~/.omp-mobile/daemon.log`). Phones reconnect; sessions resume on open.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
13
|
+
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { STATE_FILE, alive, readState, tokenOf } from "./state.mjs";
|
|
18
|
+
|
|
19
|
+
const SERVER = join(dirname(fileURLToPath(import.meta.url)), "server.mjs");
|
|
20
|
+
/** How long a restart waits for running turns to end before it goes ahead anyway. */
|
|
21
|
+
const IDLE_WAIT_MS = 10 * 60_000;
|
|
22
|
+
/** How long the old daemon gets to close its omp processes, and the new one to come up. */
|
|
23
|
+
const STOP_TIMEOUT_MS = 25_000;
|
|
24
|
+
const START_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
27
|
+
|
|
28
|
+
function log(line) {
|
|
29
|
+
console.log(`omp-mobile restart: ${line}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The arguments and directory the daemon was started with: recorded in its
|
|
34
|
+
* state since 0.7.2; for an older daemon, read from its process (an argument
|
|
35
|
+
* with a space in it cannot be told apart there).
|
|
36
|
+
* @returns {{argv: string[], cwd: string} | undefined}
|
|
37
|
+
*/
|
|
38
|
+
export function launchOf(state) {
|
|
39
|
+
if (Array.isArray(state.argv) && typeof state.cwd === "string") return { argv: state.argv, cwd: state.cwd };
|
|
40
|
+
try {
|
|
41
|
+
const command = execFileSync("ps", ["-o", "command=", "-p", String(state.pid)], { encoding: "utf8" }).trim();
|
|
42
|
+
const words = command.split(/\s+/);
|
|
43
|
+
const at = words.findIndex((word) => word.endsWith("server.mjs"));
|
|
44
|
+
if (at === -1) return undefined;
|
|
45
|
+
const cwd = execFileSync("lsof", ["-a", "-p", String(state.pid), "-d", "cwd", "-Fn"], { encoding: "utf8" })
|
|
46
|
+
.split("\n")
|
|
47
|
+
.find((line) => line.startsWith("n"))
|
|
48
|
+
?.slice(1);
|
|
49
|
+
return { argv: words.slice(at + 1), cwd: cwd ?? homedir() };
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A daemon that cannot say whether a turn runs (before 0.7.2) gets this long, so the turn asking can end. */
|
|
56
|
+
const GRACE_MS = 60_000;
|
|
57
|
+
|
|
58
|
+
/** Sessions running a turn; undefined when the daemon does not report it. */
|
|
59
|
+
async function busySessions(state) {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(`${state.url}/status`, { headers: { authorization: `Bearer ${tokenOf(state.pair)}` }, signal: AbortSignal.timeout(5_000) });
|
|
62
|
+
const status = await response.json();
|
|
63
|
+
return typeof status.busySessions === "number" ? status.busySessions : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The worker: wait, stop, start. Runs detached; its output goes to the daemon log. */
|
|
70
|
+
/** The log beside the state file: `~/.omp-mobile/daemon.log` for the default one, where /mobile logs too. */
|
|
71
|
+
function logFileOf(stateFile) {
|
|
72
|
+
return join(dirname(stateFile), "daemon.log");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function work(stateFile, now) {
|
|
76
|
+
const state = readState(stateFile);
|
|
77
|
+
if (state === undefined) {
|
|
78
|
+
log("no daemon is running; nothing to restart");
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
const launch = launchOf(state);
|
|
82
|
+
if (launch === undefined) {
|
|
83
|
+
log(`cannot tell how daemon ${state.pid} was started; restart it by hand`);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
if (!now) {
|
|
87
|
+
const deadline = Date.now() + IDLE_WAIT_MS;
|
|
88
|
+
let busy = await busySessions(state);
|
|
89
|
+
if (busy === undefined) await sleep(GRACE_MS);
|
|
90
|
+
while (busy !== undefined && busy > 0 && Date.now() < deadline) {
|
|
91
|
+
await sleep(2_000);
|
|
92
|
+
busy = await busySessions(state);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
log(`stopping daemon ${state.pid} (${state.version})`);
|
|
96
|
+
process.kill(state.pid, "SIGTERM");
|
|
97
|
+
const stopBy = Date.now() + STOP_TIMEOUT_MS;
|
|
98
|
+
while (Date.now() < stopBy && alive(state.pid)) await sleep(100);
|
|
99
|
+
if (alive(state.pid)) process.kill(state.pid, "SIGKILL");
|
|
100
|
+
const out = openSync(logFileOf(stateFile), "a", 0o600);
|
|
101
|
+
let child;
|
|
102
|
+
try {
|
|
103
|
+
child = spawn(process.execPath, [SERVER, ...launch.argv], { cwd: launch.cwd, detached: true, stdio: ["ignore", out, out], env: process.env });
|
|
104
|
+
} finally {
|
|
105
|
+
closeSync(out);
|
|
106
|
+
}
|
|
107
|
+
child.unref();
|
|
108
|
+
const startBy = Date.now() + START_TIMEOUT_MS;
|
|
109
|
+
while (Date.now() < startBy) {
|
|
110
|
+
const next = readState(stateFile);
|
|
111
|
+
if (next?.pid === child.pid) {
|
|
112
|
+
log(`daemon ${next.pid} (${next.version}) is running`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (!alive(child.pid)) break;
|
|
116
|
+
await sleep(250);
|
|
117
|
+
}
|
|
118
|
+
log("the new daemon did not start; see the lines above");
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `omp-mobile restart [--now] [--state-file <path>]`: hand the restart to a
|
|
124
|
+
* detached worker and return at once, so this process may be one of the
|
|
125
|
+
* daemon's own sessions.
|
|
126
|
+
* @param {string[]} args
|
|
127
|
+
*/
|
|
128
|
+
export async function runRestartCli(args) {
|
|
129
|
+
const at = args.indexOf("--state-file");
|
|
130
|
+
const stateFile = at === -1 ? STATE_FILE : args[at + 1];
|
|
131
|
+
const now = args.includes("--now");
|
|
132
|
+
if (args.includes("--worker")) process.exit(await work(stateFile, now));
|
|
133
|
+
const state = readState(stateFile);
|
|
134
|
+
if (state === undefined) {
|
|
135
|
+
console.error("omp-mobile: the daemon is not running. Start it with /mobile in OMP, or omp-mobile --relay.");
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
if (launchOf(state) === undefined) {
|
|
139
|
+
console.error(`omp-mobile: cannot tell how daemon ${state.pid} was started; stop it and start it again by hand.`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
const logFile = logFileOf(stateFile);
|
|
143
|
+
mkdirSync(dirname(logFile), { recursive: true, mode: 0o700 });
|
|
144
|
+
const out = openSync(logFile, "a", 0o600);
|
|
145
|
+
const worker = spawn(process.execPath, [SERVER, "restart", "--worker", "--state-file", stateFile, ...(now ? ["--now"] : [])], {
|
|
146
|
+
detached: true,
|
|
147
|
+
stdio: ["ignore", out, out],
|
|
148
|
+
});
|
|
149
|
+
closeSync(out);
|
|
150
|
+
worker.unref();
|
|
151
|
+
console.log(
|
|
152
|
+
now
|
|
153
|
+
? `Restarting daemon ${state.pid} now (log: ${logFile}).`
|
|
154
|
+
: `Daemon ${state.pid} restarts once no session is running a turn (log: ${logFile}). Phones reconnect by themselves.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
package/src/rpc-session.mjs
CHANGED
|
@@ -24,6 +24,8 @@ function withoutBunFlag(env) {
|
|
|
24
24
|
return rest;
|
|
25
25
|
}
|
|
26
26
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
27
|
+
/** `get_subagents` reads OMP's in-memory roster: a slow answer means a busy omp, and the files answer without it. */
|
|
28
|
+
const SUBAGENTS_TIMEOUT_MS = 5_000;
|
|
27
29
|
/** Messages per `get_messages_page` request: OMP's maximum; a page also stays under 768 KiB. */
|
|
28
30
|
const MESSAGE_PAGE_LIMIT = 256;
|
|
29
31
|
/** OMP's `get_messages_page` codes that mean "page later or read it whole", as OMP's own client treats them. */
|
|
@@ -200,6 +202,9 @@ export class RpcSession extends EventEmitter {
|
|
|
200
202
|
if (response.data?.protocolVersion !== 2) throw new Error("omp RPC protocol v2 negotiation failed");
|
|
201
203
|
this.protocolVersion = 2;
|
|
202
204
|
}
|
|
205
|
+
// Lifecycle frames say when a subagent starts and ends (the daemon forwards
|
|
206
|
+
// them); an omp without the command answers with an error, which is fine.
|
|
207
|
+
this.request("set_subagent_subscription", { level: "progress" }).catch(() => undefined);
|
|
203
208
|
this.started = true;
|
|
204
209
|
return this;
|
|
205
210
|
}
|
|
@@ -312,6 +317,17 @@ export class RpcSession extends EventEmitter {
|
|
|
312
317
|
});
|
|
313
318
|
}
|
|
314
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The subagents this omp runs now, as OMP's `get_subagents` lists them
|
|
322
|
+
* (pending and running only; one leaves the list as it ends).
|
|
323
|
+
* @returns {Promise<object[]>}
|
|
324
|
+
*/
|
|
325
|
+
async subagents() {
|
|
326
|
+
const response = await this.request("get_subagents", {}, SUBAGENTS_TIMEOUT_MS);
|
|
327
|
+
const list = response.data?.subagents;
|
|
328
|
+
return Array.isArray(list) ? list : [];
|
|
329
|
+
}
|
|
330
|
+
|
|
315
331
|
/** @returns {Promise<object>} */
|
|
316
332
|
async getState() {
|
|
317
333
|
const response = await this.request("get_state");
|
package/src/server.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { STATUS_CODES, createServer } from "node:http";
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { createHash, randomBytes } from "node:crypto";
|
|
13
13
|
import { homedir, hostname, platform } from "node:os";
|
|
14
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
14
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { WebSocketServer } from "ws";
|
|
17
17
|
import { renderQr } from "./qr.mjs";
|
|
@@ -25,6 +25,8 @@ import { SessionSearchIndex, matchOf, rankRecords, searchTokens, sessionFilesUnd
|
|
|
25
25
|
import { copyArtifacts, isForkPoint, writeCut } from "./fork.mjs";
|
|
26
26
|
import { RELAY_HEADER, RelayHost } from "./relay.mjs";
|
|
27
27
|
import { DEFAULT_RELAY, runRelayCli } from "./relay-control.mjs";
|
|
28
|
+
import { listSubagents, subagentDir, subagentTranscript } from "./subagents.mjs";
|
|
29
|
+
import { runRestartCli } from "./restart.mjs";
|
|
28
30
|
|
|
29
31
|
const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
30
32
|
const PROTOCOL = 1;
|
|
@@ -112,9 +114,13 @@ export function promptImages(value) {
|
|
|
112
114
|
* ask for a delta snapshot (`from`, `total`), and `GET /sessions/:id/transcript`
|
|
113
115
|
* answers the same without starting an omp; `session-search`: `GET /sessions/search?q=`
|
|
114
116
|
* finds sessions by title and message text; `fork`: `POST /sessions/:id/fork`
|
|
115
|
-
* starts a new session from a whole session or from one of its messages
|
|
117
|
+
* starts a new session from a whole session or from one of its messages;
|
|
118
|
+
* `subagents`: `GET /sessions/:id/subagents` lists the subagents a session
|
|
119
|
+
* delegated to and `GET /sessions/:id/subagents/:agent` reads one's transcript
|
|
120
|
+
* (`since`/`last` as `/transcript`); a live session's socket sends a `subagent`
|
|
121
|
+
* frame as one starts or ends.
|
|
116
122
|
*/
|
|
117
|
-
const FEATURES = ["prompt-images", "takeover", "heartbeat", "transcript-sync", "session-search", "fork"];
|
|
123
|
+
const FEATURES = ["prompt-images", "takeover", "heartbeat", "transcript-sync", "session-search", "fork", "subagents"];
|
|
118
124
|
/** Search results a phone is sent at most. */
|
|
119
125
|
const SEARCH_LIMIT = 50;
|
|
120
126
|
/** Who a phone continues a session on, as the terminal it leaves is told. */
|
|
@@ -297,6 +303,11 @@ user (403 otherwise), before the token is checked. Loopback hosts skip admission
|
|
|
297
303
|
end-to-end encrypted; pair with \`omp-mobile relay pair\` or /mobile relay
|
|
298
304
|
--relay-dir <dir> Relay identity and device tokens (default ~/.omp-mobile/relay)
|
|
299
305
|
|
|
306
|
+
Restart (after \`omp plugin upgrade omp-mobile\`; same --state-file):
|
|
307
|
+
omp-mobile restart Restart the running daemon on the installed code, with its arguments, once no
|
|
308
|
+
session is running a turn; it is done by a detached process, so this is safe to
|
|
309
|
+
run from one of the daemon's own sessions. --now: do not wait.
|
|
310
|
+
|
|
300
311
|
Relay commands (talk to the running daemon; same --state-file):
|
|
301
312
|
omp-mobile relay pair Show a pairing QR code, then confirm the phone's 8-digit code
|
|
302
313
|
omp-mobile relay status Relay server, connection and paired phones
|
|
@@ -692,8 +703,32 @@ class SessionEntry {
|
|
|
692
703
|
}
|
|
693
704
|
}
|
|
694
705
|
|
|
706
|
+
/**
|
|
707
|
+
* A subagent started or ended, as a `subagent` frame: its id as the
|
|
708
|
+
* `/subagents` routes name it (the path of its transcript under the
|
|
709
|
+
* session's directory) and how it stands.
|
|
710
|
+
* @param {any} payload OMP's `subagent_lifecycle` payload
|
|
711
|
+
*/
|
|
712
|
+
subagentFrame(payload) {
|
|
713
|
+
const dir = subagentDir(this.state?.sessionFile);
|
|
714
|
+
const file = typeof payload?.sessionFile === "string" ? payload.sessionFile : undefined;
|
|
715
|
+
const path = dir !== undefined && file?.endsWith(".jsonl") ? relative(dir, file.slice(0, -6)) : "";
|
|
716
|
+
const id = path !== "" && !path.startsWith("..") && !isAbsolute(path) ? path.split(sep).join("/") : String(payload?.id ?? "");
|
|
717
|
+
const status = payload?.status === "started" ? "running" : payload?.status;
|
|
718
|
+
return {
|
|
719
|
+
type: "subagent",
|
|
720
|
+
subagent: { id, agentId: payload?.id, agent: payload?.agent, description: payload?.description, status, parentToolCallId: payload?.parentToolCallId },
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
695
724
|
bind() {
|
|
696
725
|
this.rpc.on("event", (event) => {
|
|
726
|
+
if (event.type === "subagent_lifecycle") {
|
|
727
|
+
this.broadcast(this.subagentFrame(event.payload));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
// Progress ticks (and raw events, at OMP's "events" level) would flood a phone: the transcript route has them.
|
|
731
|
+
if (event.type === "subagent_progress" || event.type === "subagent_event") return;
|
|
697
732
|
if (event.type === "message_end" && event.message) {
|
|
698
733
|
// A new message is what makes a session the most recent one; opening it,
|
|
699
734
|
// a lifecycle event from a resumed omp, or a state echo are not.
|
|
@@ -916,6 +951,8 @@ class Daemon {
|
|
|
916
951
|
startedAt: this.startedAt,
|
|
917
952
|
url: this.baseUrl(),
|
|
918
953
|
liveSessions: this.sessions.size,
|
|
954
|
+
// Sessions running a turn: `omp-mobile restart` waits for none.
|
|
955
|
+
busySessions: [...this.sessions.values()].filter((entry) => entry.state?.isStreaming === true).length,
|
|
919
956
|
eventStreams: this.streams.size,
|
|
920
957
|
lastPhone: this.lastPhone,
|
|
921
958
|
relay: this.relay === undefined ? null : await this.relay.status(),
|
|
@@ -980,6 +1017,22 @@ class Daemon {
|
|
|
980
1017
|
sendJson(res, 200, this.transcript(decodeURIComponent(transcript[1]), deltaQuery(url.searchParams)));
|
|
981
1018
|
return;
|
|
982
1019
|
}
|
|
1020
|
+
const subagents = /^\/sessions\/([^/]+)\/subagents$/.exec(url.pathname);
|
|
1021
|
+
if (subagents !== null && req.method === "GET") {
|
|
1022
|
+
const { sessionFile, running } = await this.subagentSource(decodeURIComponent(subagents[1]));
|
|
1023
|
+
sendJson(res, 200, { subagents: listSubagents(sessionFile, running) });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const subagent = /^\/sessions\/([^/]+)\/subagents\/([^/]+)$/.exec(url.pathname);
|
|
1027
|
+
if (subagent !== null && req.method === "GET") {
|
|
1028
|
+
const { sessionFile, running } = await this.subagentSource(decodeURIComponent(subagent[1]));
|
|
1029
|
+
const body = subagentTranscript(sessionFile, decodeURIComponent(subagent[2]), running, deltaQuery(url.searchParams), (message) =>
|
|
1030
|
+
phoneMessage(message, this.blobDir),
|
|
1031
|
+
);
|
|
1032
|
+
if (body === undefined) throw httpError(404, "unknown subagent");
|
|
1033
|
+
sendJson(res, 200, body);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
983
1036
|
const answer = /^\/sessions\/([^/]+)\/answer$/.exec(url.pathname);
|
|
984
1037
|
if (answer !== null && req.method === "POST") {
|
|
985
1038
|
const body = await readBody(req);
|
|
@@ -1119,6 +1172,28 @@ class Daemon {
|
|
|
1119
1172
|
return deltaBody(saved, held);
|
|
1120
1173
|
}
|
|
1121
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* Where a session's subagent transcripts are, without starting an omp, and
|
|
1177
|
+
* which subagents its live omp runs now: null when that omp did not say (an
|
|
1178
|
+
* older OMP), undefined when none runs.
|
|
1179
|
+
* @param {string} id
|
|
1180
|
+
*/
|
|
1181
|
+
async subagentSource(id) {
|
|
1182
|
+
const live = this.sessions.get(id);
|
|
1183
|
+
if (live !== undefined && !live.closed && typeof live.state?.sessionFile === "string") {
|
|
1184
|
+
let running = null;
|
|
1185
|
+
try {
|
|
1186
|
+
running = await live.rpc.subagents();
|
|
1187
|
+
} catch {
|
|
1188
|
+
// The files still tell the story; only "still running" is a guess (see `statusOf`).
|
|
1189
|
+
}
|
|
1190
|
+
return { sessionFile: live.state.sessionFile, running };
|
|
1191
|
+
}
|
|
1192
|
+
const sessionFile = this.known(id)?.sessionFile;
|
|
1193
|
+
if (typeof sessionFile !== "string") throw httpError(404, "unknown session");
|
|
1194
|
+
return { sessionFile, running: undefined };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1122
1197
|
/** The omp of a session exited: save its transcript and when it was last active. */
|
|
1123
1198
|
sessionClosed(entry) {
|
|
1124
1199
|
if (entry.id === "") return;
|
|
@@ -1737,6 +1812,10 @@ async function main() {
|
|
|
1737
1812
|
await runRelayCli(process.argv.slice(3));
|
|
1738
1813
|
return;
|
|
1739
1814
|
}
|
|
1815
|
+
if (process.argv[2] === "restart") {
|
|
1816
|
+
await runRestartCli(process.argv.slice(3));
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1740
1819
|
const options = parseArgs(process.argv.slice(2));
|
|
1741
1820
|
if (options.host === "tailscale") options.host = await tailscaleHost(options);
|
|
1742
1821
|
const token = loadToken(options);
|
|
@@ -1745,7 +1824,8 @@ async function main() {
|
|
|
1745
1824
|
await daemon.listen();
|
|
1746
1825
|
const url = daemon.baseUrl();
|
|
1747
1826
|
const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
|
|
1748
|
-
|
|
1827
|
+
// `argv` and `cwd`: how to start it again (`omp-mobile restart`).
|
|
1828
|
+
writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt, argv: process.argv.slice(2), cwd: process.cwd() });
|
|
1749
1829
|
console.log(`omp-mobile daemon ${VERSION}`);
|
|
1750
1830
|
console.log(` listening ${url}`);
|
|
1751
1831
|
console.log(` cwd ${options.cwd}`);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A session's subagents, read where OMP keeps them (`session/sub-sessions.ts`):
|
|
3
|
+
* a session at `<dir>/<name>.jsonl` stores each subagent's own session at
|
|
4
|
+
* `<dir>/<name>/<AgentId>.jsonl`, and a subagent's children nest the same way
|
|
5
|
+
* under `<dir>/<name>/<AgentId>/`. Reading the files serves sessions whose omp
|
|
6
|
+
* has exited too; a live omp only adds which subagents still run (its RPC
|
|
7
|
+
* `get_subagents` lists running ones only and forgets them once they end).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { deltaFrom } from "./transcripts.mjs";
|
|
12
|
+
|
|
13
|
+
/** One agent id as OMP allocates it (a name, `-2` on a repeat, `.` before a nested child's own). */
|
|
14
|
+
const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,159}$/;
|
|
15
|
+
/** The stem of OMP's advisor transcripts (`advisor/transcript-recorder.ts`). */
|
|
16
|
+
const ADVISOR = "__advisor";
|
|
17
|
+
/** Deep enough for any real delegation chain; a symlink loop cannot recurse past it. */
|
|
18
|
+
const MAX_DEPTH = 6;
|
|
19
|
+
/** The assignment shown in a list: its start, the rest is in the transcript. */
|
|
20
|
+
const TASK_EXCERPT = 300;
|
|
21
|
+
/** Parsed files kept while their size and mtime hold: the app polls a running subagent's transcript. */
|
|
22
|
+
const CACHE_LIMIT = 32;
|
|
23
|
+
/** @type {Map<string, {mtimeMs: number, size: number, parsed: Parsed}>} */
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{agent?: string, task?: string, messages: any[]}} Parsed
|
|
28
|
+
* @typedef {{id: string, agentId: string, parent: string | null, file: string, aborted: boolean}} Found
|
|
29
|
+
* @typedef {{id?: string, status?: string, description?: string, parentToolCallId?: string, sessionFile?: string}} LiveSnapshot
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Where a session's subagent transcripts live; undefined for a file that is not a session. */
|
|
33
|
+
export function subagentDir(sessionFile) {
|
|
34
|
+
return typeof sessionFile === "string" && sessionFile.endsWith(".jsonl") ? sessionFile.slice(0, -6) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every subagent transcript under a session, parents before their children.
|
|
39
|
+
* `id` is the slash-joined path of agent ids from the session ("Scout", "Scout/Helper").
|
|
40
|
+
* @param {string} sessionFile
|
|
41
|
+
* @returns {Found[]}
|
|
42
|
+
*/
|
|
43
|
+
export function findSubagents(sessionFile) {
|
|
44
|
+
const dir = subagentDir(sessionFile);
|
|
45
|
+
/** @type {Found[]} */
|
|
46
|
+
const found = [];
|
|
47
|
+
if (dir !== undefined) walk(dir, null, 0, found);
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} dir @param {string | null} parent @param {number} depth @param {Found[]} out */
|
|
52
|
+
function walk(dir, parent, depth, out) {
|
|
53
|
+
let dirents;
|
|
54
|
+
try {
|
|
55
|
+
dirents = readdirSync(dir, { withFileTypes: true });
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const files = new Set();
|
|
60
|
+
const dirs = new Set();
|
|
61
|
+
for (const dirent of dirents) {
|
|
62
|
+
if (dirent.isFile()) files.add(dirent.name);
|
|
63
|
+
else if (dirent.isDirectory()) dirs.add(dirent.name);
|
|
64
|
+
}
|
|
65
|
+
for (const name of [...files].sort()) {
|
|
66
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
67
|
+
const agentId = name.slice(0, -6);
|
|
68
|
+
// `__advisor.jsonl` and `__advisor.<slug>.jsonl` share the directory and are not subagents; backups are not either.
|
|
69
|
+
if (!SEGMENT.test(agentId) || agentId === ADVISOR || agentId.startsWith(`${ADVISOR}.`) || name.includes(".bak")) continue;
|
|
70
|
+
const id = parent === null ? agentId : `${parent}/${agentId}`;
|
|
71
|
+
out.push({ id, agentId, parent, file: join(dir, name), aborted: files.has(`${name}.tombstone`) });
|
|
72
|
+
// Only real directories: a symlinked one could loop back up the tree.
|
|
73
|
+
if (depth + 1 < MAX_DEPTH && dirs.has(agentId)) walk(join(dir, agentId), id, depth + 1, out);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The transcript at `id` under a session; undefined for an id that is malformed
|
|
79
|
+
* or names no transcript. Segments are checked, so no id leaves the session's directory.
|
|
80
|
+
* @param {string} sessionFile
|
|
81
|
+
* @param {string} id
|
|
82
|
+
*/
|
|
83
|
+
export function subagentFile(sessionFile, id) {
|
|
84
|
+
const dir = subagentDir(sessionFile);
|
|
85
|
+
const segments = id.split("/");
|
|
86
|
+
if (dir === undefined || segments.length > MAX_DEPTH || !segments.every((segment) => SEGMENT.test(segment))) return undefined;
|
|
87
|
+
const file = join(dir, ...segments.slice(0, -1), `${segments.at(-1)}.jsonl`);
|
|
88
|
+
return existsSync(file) ? file : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A subagent session file's messages in order, its agent type and assignment
|
|
93
|
+
* (OMP's `session_init` entry). Lines that do not parse (a write in progress) are skipped.
|
|
94
|
+
* @param {string} file
|
|
95
|
+
* @returns {Parsed | undefined}
|
|
96
|
+
*/
|
|
97
|
+
export function readSubagent(file) {
|
|
98
|
+
let info;
|
|
99
|
+
try {
|
|
100
|
+
info = statSync(file);
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const hit = cache.get(file);
|
|
105
|
+
if (hit !== undefined && hit.mtimeMs === info.mtimeMs && hit.size === info.size) return hit.parsed;
|
|
106
|
+
/** @type {Parsed} */
|
|
107
|
+
const parsed = { messages: [] };
|
|
108
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
109
|
+
if (line === "") continue;
|
|
110
|
+
let entry;
|
|
111
|
+
try {
|
|
112
|
+
entry = JSON.parse(line);
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (entry?.type === "message" && entry.message !== null && typeof entry.message === "object") parsed.messages.push(entry.message);
|
|
117
|
+
else if (entry?.type === "session_init") {
|
|
118
|
+
if (typeof entry.agent === "string") parsed.agent = entry.agent;
|
|
119
|
+
if (typeof entry.task === "string") parsed.task = entry.task;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
cache.delete(file);
|
|
123
|
+
cache.set(file, { mtimeMs: info.mtimeMs, size: info.size, parsed });
|
|
124
|
+
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How a subagent stands: running while the live omp lists it; else from its
|
|
130
|
+
* transcript: killed (a tombstone, or a last reply that was aborted), failed
|
|
131
|
+
* (a last reply that errored), or completed. One that stopped mid-step
|
|
132
|
+
* without a final reply is running when its omp is live but could not say
|
|
133
|
+
* (`unsure`: an omp without `get_subagents`), aborted otherwise (its omp exited).
|
|
134
|
+
* @param {{aborted: boolean, live?: LiveSnapshot, unsure?: boolean, messages: any[]}} subagent
|
|
135
|
+
* @returns {"running" | "completed" | "failed" | "aborted"}
|
|
136
|
+
*/
|
|
137
|
+
export function statusOf({ aborted, live, unsure = false, messages }) {
|
|
138
|
+
if (live !== undefined) return "running";
|
|
139
|
+
if (aborted) return "aborted";
|
|
140
|
+
const last = messages.at(-1);
|
|
141
|
+
if (last?.role === "assistant" && last.stopReason === "error") return "failed";
|
|
142
|
+
if (last?.role === "assistant" && last.stopReason === "aborted") return "aborted";
|
|
143
|
+
if (last?.role === "assistant" && last.stopReason !== "toolUse") return "completed";
|
|
144
|
+
return unsure ? "running" : "aborted";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The phone's list entry for one subagent. `running`: the live omp's
|
|
149
|
+
* `get_subagents`; null when it is live but could not answer; undefined when no omp runs.
|
|
150
|
+
* @param {Found} found
|
|
151
|
+
* @param {Parsed} parsed
|
|
152
|
+
* @param {readonly LiveSnapshot[] | null | undefined} running
|
|
153
|
+
*/
|
|
154
|
+
function summaryOf(found, parsed, running) {
|
|
155
|
+
let updatedAt = 0;
|
|
156
|
+
try {
|
|
157
|
+
updatedAt = statSync(found.file).mtimeMs;
|
|
158
|
+
} catch {
|
|
159
|
+
// Removed between the walk and now: it is listed as it was read.
|
|
160
|
+
}
|
|
161
|
+
// OMP lists pending and running subagents only, and drops one as it ends.
|
|
162
|
+
const live = running?.find((snapshot) => snapshot?.sessionFile === found.file);
|
|
163
|
+
// OMP opens every assignment with the same line; the list shows what the task actually is.
|
|
164
|
+
const task = parsed.task?.replace(/^\s*Complete assignment thoroughly:\s*/i, "").trim();
|
|
165
|
+
return {
|
|
166
|
+
id: found.id,
|
|
167
|
+
agentId: found.agentId,
|
|
168
|
+
parent: found.parent,
|
|
169
|
+
agent: parsed.agent,
|
|
170
|
+
description: live?.description,
|
|
171
|
+
task: task === undefined || task === "" ? undefined : task.length > TASK_EXCERPT ? `${task.slice(0, TASK_EXCERPT)}…` : task,
|
|
172
|
+
status: statusOf({ aborted: found.aborted, live, unsure: running === null, messages: parsed.messages }),
|
|
173
|
+
messageCount: parsed.messages.length,
|
|
174
|
+
updatedAt: Math.round(updatedAt),
|
|
175
|
+
parentToolCallId: live?.parentToolCallId,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every subagent of a session, parents before their children.
|
|
181
|
+
* @param {string} sessionFile
|
|
182
|
+
* @param {readonly LiveSnapshot[] | null} [running] see `summaryOf`
|
|
183
|
+
*/
|
|
184
|
+
export function listSubagents(sessionFile, running) {
|
|
185
|
+
const list = [];
|
|
186
|
+
for (const found of findSubagents(sessionFile)) {
|
|
187
|
+
const parsed = readSubagent(found.file);
|
|
188
|
+
if (parsed !== undefined) list.push(summaryOf(found, parsed, running));
|
|
189
|
+
}
|
|
190
|
+
return list;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One subagent and its messages: those after what the phone holds when its
|
|
195
|
+
* copy lines up (`since`/`last`, as `/transcript`), else all of them.
|
|
196
|
+
* @param {string} sessionFile
|
|
197
|
+
* @param {string} id
|
|
198
|
+
* @param {readonly LiveSnapshot[] | null | undefined} running see `summaryOf`
|
|
199
|
+
* @param {{since: number, last: string}} held
|
|
200
|
+
* @param {(message: any) => any} toPhone
|
|
201
|
+
*/
|
|
202
|
+
export function subagentTranscript(sessionFile, id, running, held, toPhone) {
|
|
203
|
+
const file = subagentFile(sessionFile, id);
|
|
204
|
+
const parsed = file === undefined ? undefined : readSubagent(file);
|
|
205
|
+
if (file === undefined || parsed === undefined) return undefined;
|
|
206
|
+
const segments = id.split("/");
|
|
207
|
+
const found = {
|
|
208
|
+
id,
|
|
209
|
+
agentId: segments.at(-1) ?? id,
|
|
210
|
+
parent: segments.length > 1 ? segments.slice(0, -1).join("/") : null,
|
|
211
|
+
file,
|
|
212
|
+
aborted: existsSync(`${file}.tombstone`),
|
|
213
|
+
};
|
|
214
|
+
const subagent = summaryOf(found, parsed, running);
|
|
215
|
+
const { messages } = parsed;
|
|
216
|
+
const from = deltaFrom(messages, held.since, held.last);
|
|
217
|
+
return { subagent, from, total: messages.length, messages: messages.slice(from).map(toPhone) };
|
|
218
|
+
}
|