moshcode 0.32.0 → 0.33.0

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/src/herd.mjs ADDED
@@ -0,0 +1,746 @@
1
+ // The herd — a persistent runtime for agent sessions (PRD 0009).
2
+ //
3
+ // Everything else in moshcode opens an engine with `stdio: "inherit"`: the
4
+ // child takes the terminal, you wait, and when it exits you get the prompt
5
+ // back. That is why an engine feels native, and it is also why the pit can only
6
+ // ever be doing one thing, and why closing the terminal kills the work.
7
+ //
8
+ // The herd inverts it. A session runs inside a runtime that outlives the pit,
9
+ // so starting one hands the prompt straight back and you carry on. `ps` shows
10
+ // the roster, `attach` puts you inside one, and detaching leaves it running.
11
+ //
12
+ // TWO SUBSTRATES, ONE INTERFACE. Persisting an interactive program means
13
+ // something other than your terminal has to own its pty:
14
+ //
15
+ // "tmux" — a single named tmux server (socket `moshcode`, not the per-pid one
16
+ // src/tabs.mjs opens). Full fidelity: real resizing, scrollback, native
17
+ // attach. This is the recommended path.
18
+ //
19
+ // "pty" — no tmux on the box. `script(1)` allocates the pty (the same
20
+ // capability detection src/pty.mjs already does), the child is detached
21
+ // with its stdin on a FIFO, and `attach` replays the transcript and relays
22
+ // keystrokes. Works everywhere script(1) does. Its one real limit: nothing
23
+ // outside the pty can ioctl the master, so the size is fixed at launch (to
24
+ // the starting terminal, via stty from inside) and a later resize does not
25
+ // reach it. Honest and useful, not equal.
26
+ //
27
+ // null — neither. Callers fall back to today's foreground passthrough and say
28
+ // so once. moshcode does not harden a soft dependency into a hard one.
29
+ //
30
+ // Metadata (engine, cwd, argv) lives in a 0600 manifest rather than in the
31
+ // substrate, because the manifest is needed anyway to rebuild the herd after a
32
+ // reboot, and because tmux user-options are a 3.0+ feature we would rather not
33
+ // require.
34
+ import { spawn, spawnSync } from "node:child_process";
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+
39
+ import { followFile, scriptFlavor, shQuote, stripScriptBanner } from "./pty.mjs";
40
+
41
+ /** tmux server socket. Deliberately stable — the whole point is outliving pits. */
42
+ export const HERD_SOCKET = process.env.MOSHCODE_HERD_SOCKET || "moshcode";
43
+
44
+ /** Where the manifest, transcripts, FIFOs and hook reports live. */
45
+ export function herdDir() {
46
+ return process.env.MOSHCODE_HERD_DIR || path.join(os.homedir(), ".moshcode", "herd");
47
+ }
48
+ const manifestPath = () => path.join(herdDir(), "sessions.json");
49
+
50
+ /**
51
+ * Session names are a handle typed at a prompt, embedded in a tmux target, and
52
+ * used as a filename. herdr's shape, and for the same reasons: anything looser
53
+ * would let a name mean one thing to tmux (which reads `:` and `.` as target
54
+ * separators) and another to the filesystem.
55
+ */
56
+ export const NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
57
+ export const validName = (name) => NAME_RE.test(String(name || ""));
58
+
59
+ /** Turn any string into something NAME_RE accepts, for auto-generated names. */
60
+ export function slugifyName(input) {
61
+ const slug = String(input || "")
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, "-")
64
+ .replace(/^-+|-+$/g, "")
65
+ .replace(/^[^a-z]+/, "")
66
+ .slice(0, 32);
67
+ return slug || "agent";
68
+ }
69
+
70
+ /**
71
+ * The default name for a session: `<engine>-<dir>`, suffixed on collision.
72
+ * `taken` is whatever is already in the herd, so two claudes in two repos get
73
+ * distinguishable names without anyone typing `--name`.
74
+ */
75
+ export function defaultName(engine, cwd, taken = []) {
76
+ const base = slugifyName(`${engine}-${path.basename(cwd || "") || "pit"}`);
77
+ const used = new Set(taken);
78
+ if (!used.has(base)) return base;
79
+ for (let n = 2; n < 1000; n++) {
80
+ const candidate = slugifyName(`${base}-${n}`);
81
+ if (!used.has(candidate)) return candidate;
82
+ }
83
+ return slugifyName(`${base}-${process.pid}`);
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Manifest — the metadata that has to survive the runtime, not just the pit.
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function ensureDir() {
91
+ const dir = herdDir();
92
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
93
+ return dir;
94
+ }
95
+
96
+ /**
97
+ * Read the manifest. Never throws: a corrupt or absent manifest means "no
98
+ * remembered sessions", which is recoverable, and the live substrate is still
99
+ * the authority on what is actually running.
100
+ */
101
+ export function readManifest() {
102
+ try {
103
+ const raw = JSON.parse(fs.readFileSync(manifestPath(), "utf8"));
104
+ if (!raw || typeof raw !== "object" || typeof raw.sessions !== "object") return { version: 1, sessions: {} };
105
+ return { version: 1, sessions: raw.sessions || {} };
106
+ } catch {
107
+ return { version: 1, sessions: {} };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Write the manifest at 0600.
113
+ *
114
+ * The same reasoning as .moshcode_history in src/tui.mjs, one step harder: this
115
+ * records the argv an engine was launched with, and an engine is regularly
116
+ * launched with a flag carrying a token. `mode` only applies on create, so
117
+ * chmod every write to fix installs that predate this.
118
+ */
119
+ export function writeManifest(manifest) {
120
+ try {
121
+ ensureDir();
122
+ const file = manifestPath();
123
+ fs.writeFileSync(file, JSON.stringify({ version: 1, sessions: manifest.sessions || {} }, null, 2), { mode: 0o600 });
124
+ fs.chmodSync(file, 0o600);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ export function rememberSession(name, entry) {
132
+ const manifest = readManifest();
133
+ manifest.sessions[name] = { ...(manifest.sessions[name] || {}), ...entry };
134
+ writeManifest(manifest);
135
+ }
136
+
137
+ export function forgetSession(name) {
138
+ const manifest = readManifest();
139
+ if (!(name in manifest.sessions)) return false;
140
+ delete manifest.sessions[name];
141
+ writeManifest(manifest);
142
+ return true;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Substrate detection
147
+ // ---------------------------------------------------------------------------
148
+
149
+ let substrateCache;
150
+
151
+ /**
152
+ * Which substrate this machine can run the herd on: "tmux", "pty", or null.
153
+ *
154
+ * Probed once and cached, like scriptFlavor() — every roster render would
155
+ * otherwise fork a `tmux -V`. MOSHCODE_HERD=off forces the honest degradation
156
+ * path, which is how the fallback gets tested on a box that has tmux.
157
+ */
158
+ export function detectSubstrate({ runner = spawnSync, env = process.env, force = false } = {}) {
159
+ if (!force && substrateCache !== undefined) return substrateCache;
160
+ const chosen = (() => {
161
+ if (env.MOSHCODE_HERD === "off") return null;
162
+ if (env.MOSHCODE_HERD !== "pty") {
163
+ try {
164
+ const r = runner("tmux", ["-V"], { encoding: "utf8" });
165
+ if (!r?.error && r?.status === 0) return "tmux";
166
+ } catch { /* fall through */ }
167
+ }
168
+ if (env.MOSHCODE_HERD === "tmux") return null;
169
+ // The pty substrate needs a script(1) we understand AND a mkfifo, because
170
+ // the FIFO is how a detached child keeps a stdin that never sees EOF.
171
+ if (!scriptFlavor({ runner })) return null;
172
+ try {
173
+ const r = runner("mkfifo", ["--version"], { encoding: "utf8" });
174
+ // BSD mkfifo has no --version and exits non-zero on it; a usage message
175
+ // still proves the binary is there, which is all this needs to know.
176
+ if (r?.error?.code === "ENOENT") return null;
177
+ } catch { return null; }
178
+ return "pty";
179
+ })();
180
+ if (!force) substrateCache = chosen;
181
+ return chosen;
182
+ }
183
+
184
+ /** Test seam: drop the memoised substrate. */
185
+ export function resetSubstrate() { substrateCache = undefined; }
186
+
187
+ /** One line explaining what the user loses, printed once when it matters. */
188
+ export function substrateNote(substrate = detectSubstrate()) {
189
+ if (substrate === "tmux") return null;
190
+ if (substrate === "pty") {
191
+ return "no tmux — sessions run under script(1). they work, but their size is fixed when they start. install tmux to make them resizable.";
192
+ }
193
+ const how = process.platform === "darwin" ? "brew install tmux" : "sudo apt install tmux (or your package manager)";
194
+ return `no tmux and no usable script(1) — sessions will run in the foreground and end with this terminal. ${how}`;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // tmux substrate
199
+ // ---------------------------------------------------------------------------
200
+
201
+ const tmuxArgs = (args) => ["-L", HERD_SOCKET, ...args];
202
+
203
+ export function tmux(args, { runner = spawnSync, env = process.env, encoding = "utf8" } = {}) {
204
+ try {
205
+ const r = runner("tmux", tmuxArgs(args), { encoding, env });
206
+ if (r?.error) return { ok: false, error: r.error, stdout: "", stderr: "" };
207
+ return {
208
+ ok: r.status === 0,
209
+ code: r.status,
210
+ stdout: String(r.stdout || ""),
211
+ stderr: String(r.stderr || ""),
212
+ };
213
+ } catch (error) {
214
+ return { ok: false, error, stdout: "", stderr: "" };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The shell-command tmux runs for a session.
220
+ *
221
+ * A single quoted string rather than an argv, matching src/tabs.mjs: tmux's
222
+ * `shell-command` is one argument in every version we care about, and quoting
223
+ * it ourselves is the only way an argument containing a space survives.
224
+ *
225
+ * `env -u` rather than tmux's `-e`: engines like claude need variables *removed*
226
+ * (an inherited ANTHROPIC_API_KEY hijacks its stored login — see ENGINES), and
227
+ * `-e KEY=` sets an empty value, which is not the same as unset.
228
+ */
229
+ export function sessionCommand({ bin, args = [], stripEnv = [], exec = true }) {
230
+ const unset = stripEnv.flatMap((key) => ["-u", key]);
231
+ const command = [bin, ...args].map(shQuote).join(" ");
232
+ const withEnv = unset.length ? `env ${unset.map(shQuote).join(" ")} ${command}` : command;
233
+ // `exec` so the engine replaces the shell rather than sitting under it — one
234
+ // less process between a signal and the thing meant to receive it. The pty
235
+ // substrate passes exec:false because it needs the shell to outlive the
236
+ // engine by exactly one command, to record that it finished.
237
+ return exec ? `exec ${withEnv}` : withEnv;
238
+ }
239
+
240
+ /**
241
+ * Argv that creates a detached session. Split out from the spawn so the
242
+ * safety-sensitive part is testable without starting a real server.
243
+ *
244
+ * `-f /dev/null` for the same reason src/tabs.mjs does it: this server is
245
+ * moshcode's, and the detach key we print has to be the one that works even
246
+ * when the user's own tmux.conf rebinds prefix.
247
+ */
248
+ export function tmuxStartPlan({ name, cwd, command }) {
249
+ // ONE tmux invocation, not two. A finished agent must stay readable — "which
250
+ // one is done?" is half the reason the roster exists, and a session that
251
+ // evaporates on exit can only ever answer "gone". But a short-lived command
252
+ // can finish before a second `tmux set-option` process has even started, and
253
+ // then the option lands on a session that is already gone. tmux takes `;` as
254
+ // its own argument to mean "and then", which closes the race by never letting
255
+ // the session exist without the option.
256
+ return [
257
+ "-f", "/dev/null",
258
+ "new-session", "-d", "-s", name, "-c", cwd, command,
259
+ ";", "set-option", "-t", name, "remain-on-exit", "on",
260
+ ];
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // pty substrate — detached script(1) + a FIFO for stdin
265
+ // ---------------------------------------------------------------------------
266
+
267
+ const ptyPaths = (name) => ({
268
+ transcript: path.join(herdDir(), `${name}.transcript`),
269
+ fifo: path.join(herdDir(), `${name}.stdin`),
270
+ meta: path.join(herdDir(), `${name}.pid`),
271
+ // Written by the session itself on the way out. A dead pid alone cannot tell
272
+ // "the agent finished" from "the box rebooted while it was working", and
273
+ // those are different answers: one is `done`, the other is something
274
+ // `restore` should bring back.
275
+ exit: path.join(herdDir(), `${name}.exit`),
276
+ });
277
+
278
+ /** Is this pid still ours and alive? signal 0 asks without sending anything. */
279
+ export function pidAlive(pid) {
280
+ if (!pid) return false;
281
+ try { process.kill(pid, 0); return true; }
282
+ catch (e) { return e.code === "EPERM"; }
283
+ }
284
+
285
+ /**
286
+ * Start a session with no tmux in sight.
287
+ *
288
+ * The FIFO is opened O_RDWR *before* the spawn and handed to the child as fd 0.
289
+ * That detail is load-bearing: a FIFO opened read-only returns EOF the moment
290
+ * the last writer closes, so a child whose stdin is a plain reader would die as
291
+ * soon as the pit that started it exited — the exact failure this whole module
292
+ * exists to prevent. Holding it O_RDWR makes the child its own writer, so it
293
+ * never sees EOF and waits for input forever, which is what an idle agent
294
+ * should do.
295
+ */
296
+ function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner = spawnSync, size = {} }) {
297
+ ensureDir();
298
+ const cols = Number(size.cols) || Number(env.COLUMNS) || process.stdout.columns || 80;
299
+ const rows = Number(size.rows) || Number(env.LINES) || process.stdout.rows || 24;
300
+ const { transcript, fifo, meta, exit } = ptyPaths(name);
301
+ for (const file of [transcript, fifo, meta, exit]) {
302
+ try { fs.rmSync(file, { force: true }); } catch { /* first run */ }
303
+ }
304
+
305
+ const made = runner("mkfifo", ["-m", "600", fifo], { encoding: "utf8" });
306
+ if (made?.error || made?.status !== 0) {
307
+ return { ok: false, error: new Error(`could not create the input pipe: ${made?.stderr?.trim() || made?.error?.message || "mkfifo failed"}`) };
308
+ }
309
+ fs.writeFileSync(transcript, "", { mode: 0o600 });
310
+
311
+ const flavor = scriptFlavor({ runner });
312
+ // script(1) sizes the pty from its own stdout, and ours is /dev/null, so the
313
+ // child would otherwise start on a 0x0 terminal — which full-screen engines
314
+ // do not survive. Nothing outside the pty can ioctl its master, but `stty`
315
+ // running *inside* it can, so the session sizes itself on the way in. The
316
+ // size is whatever the terminal that started it had; a later resize cannot
317
+ // reach it, which is the pty substrate's one honest limitation.
318
+ // Not `exec`: the shell has to outlive the engine by exactly one command, so
319
+ // that a session which finishes on its own leaves proof it finished.
320
+ const command = [
321
+ `stty rows ${rows} cols ${cols} 2>/dev/null`,
322
+ sessionCommand({ bin, args, stripEnv, exec: false }),
323
+ `printf '%s' "$?" > ${shQuote(exit)}`,
324
+ ].join("; ");
325
+ // Reuse ptySpec's flag knowledge rather than re-deriving it: util-linux and
326
+ // BSD disagree on both the flags and the argument order.
327
+ const spec = flavor === "util-linux"
328
+ ? { cmd: "script", args: ["-q", "-e", "-f", "-c", command, transcript] }
329
+ : { cmd: "script", args: ["-q", "-F", transcript, "sh", "-c", command] };
330
+
331
+ let stdin;
332
+ try { stdin = fs.openSync(fifo, fs.constants.O_RDWR); }
333
+ catch (error) { return { ok: false, error }; }
334
+
335
+ let child;
336
+ try {
337
+ child = spawner(spec.cmd, spec.args, {
338
+ cwd,
339
+ // Belt and braces with the stty above: some toolkits read COLUMNS/LINES
340
+ // before they ever ask the terminal.
341
+ env: { ...env, COLUMNS: String(cols), LINES: String(rows), MOSHCODE_HERD_SESSION: name },
342
+ stdio: [stdin, "ignore", "ignore"],
343
+ detached: true,
344
+ });
345
+ } catch (error) {
346
+ try { fs.closeSync(stdin); } catch { /* already gone */ }
347
+ return { ok: false, error };
348
+ }
349
+ // Cut every tie to the pit: its own process group so a Ctrl-C in the pit does
350
+ // not reach it, and unref'd so node will exit without waiting for it.
351
+ child.unref();
352
+ try { fs.closeSync(stdin); } catch { /* the child holds its own */ }
353
+
354
+ try { fs.writeFileSync(meta, JSON.stringify({ pid: child.pid }), { mode: 0o600 }); }
355
+ catch { /* liveness falls back to the manifest pid */ }
356
+ return { ok: true, pid: child.pid };
357
+ }
358
+
359
+ function ptyPid(name) {
360
+ try { return JSON.parse(fs.readFileSync(ptyPaths(name).meta, "utf8")).pid || null; }
361
+ catch { return null; }
362
+ }
363
+
364
+ /** Did this session's own shell record an exit? */
365
+ function ptyFinished(name) {
366
+ try { return fs.existsSync(ptyPaths(name).exit); }
367
+ catch { return false; }
368
+ }
369
+
370
+ function ptyCleanup(name) {
371
+ const { transcript, fifo, meta, exit } = ptyPaths(name);
372
+ for (const file of [transcript, fifo, meta, exit]) {
373
+ try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Everything the pty substrate has of a session's screen: its transcript.
379
+ *
380
+ * script(1)'s own header goes first. `-q` silences it on the terminal but still
381
+ * writes it to the file, and it is not harmless bookkeeping here — it contains
382
+ * the fully quoted command line, so leaving it in would put an engine's argv
383
+ * (flags, tokens and all) at the top of every `read` and every notification.
384
+ */
385
+ function ptyCapture(name, lines) {
386
+ try {
387
+ const text = stripScriptBanner(fs.readFileSync(ptyPaths(name).transcript, "utf8"), true);
388
+ const all = text.split(/\r?\n/);
389
+ return all.slice(Math.max(0, all.length - lines)).join("\n");
390
+ } catch {
391
+ return "";
392
+ }
393
+ }
394
+
395
+ function ptyWrite(name, data) {
396
+ let fd;
397
+ try {
398
+ // O_WRONLY on a FIFO blocks until a reader shows up; the child is that
399
+ // reader and it is already there, so this returns immediately. It also
400
+ // means writing to a session whose child has died fails fast rather than
401
+ // hanging, which is the behaviour we want.
402
+ fd = fs.openSync(ptyPaths(name).fifo, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
403
+ fs.writeSync(fd, data);
404
+ return { ok: true };
405
+ } catch (error) {
406
+ return { ok: false, error };
407
+ } finally {
408
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* closed */ } }
409
+ }
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // The interface the rest of moshcode uses
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /** Names the substrate says are live right now. */
417
+ export function liveNames({ substrate = detectSubstrate(), runner = spawnSync } = {}) {
418
+ if (substrate === "tmux") {
419
+ const r = tmux(["list-sessions", "-F", "#{session_name}"], { runner });
420
+ if (!r.ok) return []; // no server yet is not an error, it is an empty herd
421
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
422
+ }
423
+ if (substrate === "pty") {
424
+ // A finished session is still one the runtime has: it stays on the roster
425
+ // reading `done` until someone kills or prunes it, exactly as a dead tmux
426
+ // pane does. What drops off is a session whose process is gone *without*
427
+ // having recorded an exit — which is what a reboot looks like.
428
+ return Object.keys(readManifest().sessions).filter((name) => pidAlive(ptyPid(name)) || ptyFinished(name));
429
+ }
430
+ return [];
431
+ }
432
+
433
+ /**
434
+ * Is the session's process finished? A finished agent is `done`, and that is a
435
+ * fact about the process, not about what is on the screen — so it is answered
436
+ * here and not by the classifier.
437
+ */
438
+ export function sessionExited(name, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
439
+ if (substrate === "tmux") {
440
+ const r = tmux(["list-panes", "-t", name, "-F", "#{pane_dead}"], { runner });
441
+ if (!r.ok) return null; // gone entirely, not exited-but-present
442
+ return r.stdout.split("\n").some((line) => line.trim() === "1");
443
+ }
444
+ if (substrate === "pty") {
445
+ if (ptyFinished(name)) return true;
446
+ const pid = ptyPid(name);
447
+ if (!pid) return null;
448
+ return !pidAlive(pid);
449
+ }
450
+ return null;
451
+ }
452
+
453
+ /**
454
+ * Everything the roster needs from tmux, in two calls instead of two per
455
+ * session.
456
+ *
457
+ * The roster renders on every pit start and on every poll of `wait`. Asking
458
+ * tmux for one session's attached-count and one session's dead-pane status
459
+ * meant a fork per field per row, so a herd of six cost thirteen processes to
460
+ * draw one screen. tmux will format the whole server in one pass.
461
+ */
462
+ function tmuxSnapshot({ runner = spawnSync } = {}) {
463
+ const sessions = tmux(["list-sessions", "-F", "#{session_name}\t#{session_attached}"], { runner });
464
+ const attached = new Map();
465
+ if (sessions.ok) {
466
+ for (const line of sessions.stdout.split("\n")) {
467
+ if (!line.trim()) continue;
468
+ const [name, count] = line.split("\t");
469
+ attached.set(name, Number(count) || 0);
470
+ }
471
+ }
472
+ // `-a` is every pane on the server. A session is finished when it has no pane
473
+ // that is still alive.
474
+ const panes = tmux(["list-panes", "-a", "-F", "#{session_name}\t#{pane_dead}"], { runner });
475
+ const anyLive = new Map();
476
+ if (panes.ok) {
477
+ for (const line of panes.stdout.split("\n")) {
478
+ if (!line.trim()) continue;
479
+ const [name, dead] = line.split("\t");
480
+ anyLive.set(name, (anyLive.get(name) || false) || dead.trim() !== "1");
481
+ }
482
+ }
483
+ return { attached, anyLive };
484
+ }
485
+
486
+ /**
487
+ * Start a session in the herd and return immediately.
488
+ *
489
+ * This is the whole point of the module: the caller gets its prompt back while
490
+ * the engine keeps running. Returns { ok, name } or { ok:false, error }.
491
+ */
492
+ export function startSession({
493
+ name,
494
+ engine,
495
+ bin,
496
+ args = [],
497
+ stripEnv = [],
498
+ cwd = process.cwd(),
499
+ substrate = detectSubstrate(),
500
+ env = process.env,
501
+ runner = spawnSync,
502
+ spawner = spawn,
503
+ } = {}) {
504
+ if (!substrate) return { ok: false, error: new Error("no herd substrate — install tmux") };
505
+ if (!validName(name)) return { ok: false, error: new Error(`invalid session name ${JSON.stringify(name)} — ${NAME_RE}`) };
506
+ if (liveNames({ substrate, runner }).includes(name)) {
507
+ return { ok: false, error: new Error(`a session named "${name}" is already running — moshcode attach ${name}`) };
508
+ }
509
+
510
+ const entry = {
511
+ engine,
512
+ bin,
513
+ args,
514
+ cwd,
515
+ substrate,
516
+ created: Date.now(),
517
+ stripEnv,
518
+ };
519
+
520
+ if (substrate === "tmux") {
521
+ const command = sessionCommand({ bin, args, stripEnv });
522
+ const started = tmux(tmuxStartPlan({ name, cwd, command }), { runner, env });
523
+ if (!started.ok) {
524
+ return { ok: false, error: new Error(started.stderr.trim() || started.error?.message || "tmux could not start the session") };
525
+ }
526
+ rememberSession(name, entry);
527
+ return { ok: true, name, substrate };
528
+ }
529
+
530
+ const started = ptyStart({ name, cwd, bin, args, stripEnv, env, spawner, runner });
531
+ if (!started.ok) return started;
532
+ rememberSession(name, { ...entry, pid: started.pid });
533
+ return { ok: true, name, substrate, pid: started.pid };
534
+ }
535
+
536
+ /** The last `lines` rows of a session's screen — what the classifier reads. */
537
+ export function capture(name, { lines = 60, substrate = detectSubstrate(), runner = spawnSync } = {}) {
538
+ if (substrate === "tmux") {
539
+ const r = tmux(["capture-pane", "-p", "-t", name, "-S", `-${Math.max(0, lines)}`], { runner });
540
+ return r.ok ? r.stdout.replace(/\n+$/, "") : "";
541
+ }
542
+ if (substrate === "pty") return ptyCapture(name, lines);
543
+ return "";
544
+ }
545
+
546
+ /** Raw key relay. `keys` is passed through to tmux's own key vocabulary. */
547
+ export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
548
+ if (substrate === "tmux") {
549
+ const r = tmux(["send-keys", "-t", name, ...(Array.isArray(keys) ? keys : [keys])], { runner });
550
+ return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "send-keys failed") };
551
+ }
552
+ if (substrate === "pty") {
553
+ const literal = (Array.isArray(keys) ? keys : [keys])
554
+ .map((k) => (k === "Enter" ? "\r" : k === "Escape" ? "\x1b" : k))
555
+ .join("");
556
+ return ptyWrite(name, literal);
557
+ }
558
+ return { ok: false, error: new Error("no herd substrate") };
559
+ }
560
+
561
+ /**
562
+ * Type a prompt into a session and press Enter.
563
+ *
564
+ * Deliberately two calls with the text sent literally (`-l`): a prompt is user
565
+ * text and regularly contains `;`, `$` or a bare `Enter`, all of which tmux
566
+ * would otherwise read as key names rather than characters.
567
+ */
568
+ export function sendPrompt(name, text, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
569
+ if (substrate === "tmux") {
570
+ const typed = tmux(["send-keys", "-t", name, "-l", String(text)], { runner });
571
+ if (!typed.ok) return { ok: false, error: new Error(typed.stderr.trim() || "send-keys failed") };
572
+ const entered = tmux(["send-keys", "-t", name, "Enter"], { runner });
573
+ return entered.ok ? { ok: true } : { ok: false, error: new Error(entered.stderr.trim() || "send-keys failed") };
574
+ }
575
+ if (substrate === "pty") return ptyWrite(name, `${String(text)}\r`);
576
+ return { ok: false, error: new Error("no herd substrate") };
577
+ }
578
+
579
+ /** End a session and forget it. */
580
+ export function killSession(name, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
581
+ if (substrate === "tmux") {
582
+ const r = tmux(["kill-session", "-t", name], { runner });
583
+ forgetSession(name);
584
+ return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
585
+ }
586
+ if (substrate === "pty") {
587
+ const pid = ptyPid(name);
588
+ let killed = false;
589
+ if (pid && pidAlive(pid)) {
590
+ // Negative pid: script(1) is a process group leader (detached), and the
591
+ // engine is its child. Signalling the leader alone regularly leaves the
592
+ // engine running with no way left to reach it.
593
+ try { process.kill(-pid, "SIGTERM"); killed = true; }
594
+ catch { try { process.kill(pid, "SIGTERM"); killed = true; } catch { /* already gone */ } }
595
+ }
596
+ ptyCleanup(name);
597
+ forgetSession(name);
598
+ return killed ? { ok: true } : { ok: false, error: new Error("no such session") };
599
+ }
600
+ return { ok: false, error: new Error("no herd substrate") };
601
+ }
602
+
603
+ /** Stop the whole runtime. Every session in it goes too — hence the name. */
604
+ export function stopRuntime({ substrate = detectSubstrate(), runner = spawnSync } = {}) {
605
+ if (substrate === "tmux") {
606
+ const r = tmux(["kill-server"], { runner });
607
+ writeManifest({ sessions: {} });
608
+ return { ok: r.ok };
609
+ }
610
+ if (substrate === "pty") {
611
+ for (const name of Object.keys(readManifest().sessions)) killSession(name, { substrate, runner });
612
+ return { ok: true };
613
+ }
614
+ return { ok: false };
615
+ }
616
+
617
+ /**
618
+ * Attach the current terminal to a session, resolving when the user detaches
619
+ * or the session ends. This is the one call that takes the terminal.
620
+ */
621
+ export async function attachSession(name, {
622
+ substrate = detectSubstrate(),
623
+ env = process.env,
624
+ spawner = spawn,
625
+ stdin = process.stdin,
626
+ stdout = process.stdout,
627
+ } = {}) {
628
+ if (substrate === "tmux") {
629
+ return new Promise((resolve) => {
630
+ let child;
631
+ try { child = spawner("tmux", tmuxArgs(["attach-session", "-t", name]), { stdio: "inherit", env }); }
632
+ catch (error) { resolve({ ok: false, error }); return; }
633
+ child.on("error", (error) => resolve({ ok: false, error }));
634
+ child.on("exit", (code, signal) => resolve({ ok: code === 0, code, signal }));
635
+ });
636
+ }
637
+ if (substrate === "pty") return ptyAttachSession(name, { stdin, stdout });
638
+ return { ok: false, error: new Error("no herd substrate") };
639
+ }
640
+
641
+ /** The byte that detaches a pty-substrate session: Ctrl-]. */
642
+ export const PTY_DETACH_KEY = "\x1d";
643
+
644
+ /**
645
+ * Attach without tmux: replay what is on screen, then relay.
646
+ *
647
+ * Everything typed goes to the FIFO and everything appended to the transcript
648
+ * comes back out, which is a terminal in the only sense that matters here. The
649
+ * replay is what makes it usable at all — an engine in its alternate screen
650
+ * will not redraw for us, so without pushing the tail back you attach to a
651
+ * blank rectangle.
652
+ */
653
+ export function ptyAttachSession(name, { stdin = process.stdin, stdout = process.stdout } = {}) {
654
+ return new Promise((resolve) => {
655
+ if (!pidAlive(ptyPid(name))) { resolve({ ok: false, error: new Error(`no session named "${name}"`) }); return; }
656
+
657
+ // Note the size *before* printing the context, and start the follow there.
658
+ // Following from zero would replay everything the session has ever printed
659
+ // on top of the tail we just showed — for an agent that has been running
660
+ // for hours that is megabytes of scrollback. Anything written between the
661
+ // stat and the follow starting is inside [size, …) and still arrives; at
662
+ // worst a line or two is shown twice, which beats both a gap and a replay.
663
+ let size = 0;
664
+ try { size = fs.statSync(ptyPaths(name).transcript).size; } catch { /* first read */ }
665
+ stdout.write(ptyCapture(name, 200));
666
+ stdout.write(`\n\x1b[2m— attached to ${name} · Ctrl-] to detach —\x1b[22m\n`);
667
+
668
+ const wasRaw = Boolean(stdin.isRaw);
669
+ try { stdin.setRawMode?.(true); } catch { /* not a tty; relay still works */ }
670
+ stdin.resume();
671
+
672
+ let done = false;
673
+ const cleanupTimers = [];
674
+ const stopFollow = followFile(ptyPaths(name).transcript, (chunk) => stdout.write(chunk), {
675
+ intervalMs: 40, startOffset: size,
676
+ });
677
+
678
+ const finish = (result) => {
679
+ if (done) return;
680
+ done = true;
681
+ for (const timer of cleanupTimers) clearInterval(timer);
682
+ stdin.off("data", onData);
683
+ try { stdin.setRawMode?.(wasRaw); } catch { /* not a tty */ }
684
+ stdin.pause();
685
+ stopFollow();
686
+ stdout.write("\n");
687
+ resolve(result);
688
+ };
689
+
690
+ const onData = (buf) => {
691
+ if (buf.includes(PTY_DETACH_KEY)) {
692
+ const before = buf.subarray(0, buf.indexOf(PTY_DETACH_KEY));
693
+ if (before.length) ptyWrite(name, before);
694
+ finish({ ok: true, detached: true });
695
+ return;
696
+ }
697
+ const written = ptyWrite(name, buf);
698
+ if (!written.ok) finish({ ok: true, ended: true });
699
+ };
700
+ stdin.on("data", onData);
701
+
702
+ // The child can exit while you are watching it; nothing else would notice.
703
+ //
704
+ // Not unref'd, for the same reason the poll timer in herd-cli is not: an
705
+ // attach is a foreground act whose entire purpose is to stay. The follow
706
+ // timer is unref'd (pty.mjs), so if this one were too, an attach whose
707
+ // stdin did not hold the loop open would exit the instant it started.
708
+ // finish() clears it, so it never outlives the attach either.
709
+ const liveness = setInterval(() => {
710
+ if (!pidAlive(ptyPid(name))) { finish({ ok: true, ended: true }); }
711
+ }, 500);
712
+ cleanupTimers.push(liveness);
713
+ });
714
+ }
715
+
716
+ /**
717
+ * The roster: every session the herd knows about, live or remembered.
718
+ *
719
+ * Remembered-but-not-live entries are kept rather than swept, because "the box
720
+ * rebooted and these are what you were running" is exactly the question
721
+ * `moshcode restore` answers.
722
+ */
723
+ export function listSessions({ substrate = detectSubstrate(), runner = spawnSync, now = Date.now() } = {}) {
724
+ const manifest = readManifest();
725
+ const snapshot = substrate === "tmux" ? tmuxSnapshot({ runner }) : null;
726
+ const live = new Set(snapshot ? snapshot.attached.keys() : liveNames({ substrate, runner }));
727
+ const names = [...new Set([...live, ...Object.keys(manifest.sessions)])].sort();
728
+ return names.map((name) => {
729
+ const meta = manifest.sessions[name] || {};
730
+ const alive = live.has(name);
731
+ const exited = !alive ? null
732
+ : snapshot ? !snapshot.anyLive.get(name)
733
+ : sessionExited(name, { substrate, runner });
734
+ return {
735
+ name,
736
+ engine: meta.engine || "?",
737
+ cwd: meta.cwd || "",
738
+ created: meta.created || null,
739
+ age: meta.created ? now - meta.created : null,
740
+ alive,
741
+ exited,
742
+ attached: alive && snapshot ? snapshot.attached.get(name) || 0 : 0,
743
+ substrate: meta.substrate || substrate,
744
+ };
745
+ });
746
+ }