moshcode 0.31.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/README.md +104 -0
- package/bin/moshcode.mjs +31 -0
- package/package.json +1 -1
- package/prd/0009-persistent-agent-runtime.md +362 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +163 -3
- package/src/commands.mjs +102 -0
- package/src/dns-system.mjs +5 -1
- package/src/dns.mjs +167 -1
- package/src/engines.mjs +46 -0
- package/src/herd-cli.mjs +665 -0
- package/src/herd-state.mjs +227 -0
- package/src/herd.mjs +746 -0
- package/src/pty.mjs +6 -2
- package/src/tools.mjs +11 -2
- package/src/trust.mjs +60 -7
- package/src/tui.mjs +67 -4
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
|
+
}
|