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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale or an end-to-end encrypted relay.",
5
5
  "keywords": [
6
6
  "mobile",
@@ -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
+ }
@@ -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
- writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt });
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
+ }