moshcode 0.32.0 → 0.33.1
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 +121 -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 +166 -3
- package/src/commands.mjs +101 -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/trust.mjs +60 -7
- package/src/tui.mjs +67 -4
package/src/herd-cli.mjs
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// `moshcode herd` — the command surface over src/herd.mjs (PRD 0009 R5, R10–R12).
|
|
2
|
+
//
|
|
3
|
+
// There is no second API. herdr's framing is that "the cli and socket api are
|
|
4
|
+
// the same surface agents drive"; moshcode's version of that is simpler,
|
|
5
|
+
// because there is only ever one surface — every verb here takes `--json`, and
|
|
6
|
+
// that is what a machine reads. A moshscript verb, a Claude Code session
|
|
7
|
+
// spawning a helper, and a person typing at the pit all go through this file.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET,
|
|
13
|
+
herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt,
|
|
14
|
+
startSession, stopRuntime, substrateNote, validName, NAME_RE,
|
|
15
|
+
} from "./herd.mjs";
|
|
16
|
+
import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
|
|
17
|
+
import { ENGINES, resolveEngine, resolveExecutable, agentLaunchArgs } from "./engines.mjs";
|
|
18
|
+
import { ingestApproval, pollApproval } from "./notify.mjs";
|
|
19
|
+
import { acid, amber, ash, bone, danger, dim, err, info, ok, warn } from "./ui.mjs";
|
|
20
|
+
|
|
21
|
+
/** Distinct exit codes, because `wait` exists to be branched on (R10). */
|
|
22
|
+
export const EXIT = { matched: 0, usage: 1, timeout: 2, gone: 3 };
|
|
23
|
+
|
|
24
|
+
const configFile = () => path.join(herdDir(), "config.json");
|
|
25
|
+
|
|
26
|
+
export function readConfig() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(fs.readFileSync(configFile(), "utf8"));
|
|
29
|
+
return { notify: { enabled: false, states: ["blocked"], ask: false, ...(raw?.notify || {}) } };
|
|
30
|
+
} catch {
|
|
31
|
+
return { notify: { enabled: false, states: ["blocked"], ask: false } };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeConfig(config) {
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(herdDir(), { recursive: true, mode: 0o700 });
|
|
38
|
+
fs.writeFileSync(configFile(), JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
39
|
+
return true;
|
|
40
|
+
} catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "4m", "1h12m", "3d" — a column, not a sentence. */
|
|
44
|
+
export function humanAge(ms) {
|
|
45
|
+
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
46
|
+
const s = Math.floor(ms / 1000);
|
|
47
|
+
if (s < 60) return `${s}s`;
|
|
48
|
+
const m = Math.floor(s / 60);
|
|
49
|
+
if (m < 60) return `${m}m`;
|
|
50
|
+
const h = Math.floor(m / 60);
|
|
51
|
+
if (h < 24) return `${h}h${m % 60 ? `${m % 60}m` : ""}`;
|
|
52
|
+
return `${Math.floor(h / 24)}d`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tilde = (p) => {
|
|
56
|
+
const home = process.env.HOME || "";
|
|
57
|
+
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The state column, coloured.
|
|
62
|
+
*
|
|
63
|
+
* `blocked` is the only one that gets a warning colour, because it is the only
|
|
64
|
+
* one that is asking for something. A roster where four things are shouting is
|
|
65
|
+
* a roster nobody reads.
|
|
66
|
+
*/
|
|
67
|
+
export function paintState(state) {
|
|
68
|
+
if (state === "blocked") return amber("blocked");
|
|
69
|
+
if (state === "working") return acid("working");
|
|
70
|
+
if (state === "done") return bone("done");
|
|
71
|
+
if (state === "gone") return danger("gone");
|
|
72
|
+
return ash(state);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The roster. Shared by `moshcode ps`, `/ps`, and the pit's own front door, so
|
|
77
|
+
* they cannot drift into three different answers to the same question.
|
|
78
|
+
*/
|
|
79
|
+
export function renderRoster(rows, { indent = " " } = {}) {
|
|
80
|
+
if (!rows.length) return "";
|
|
81
|
+
const w = (key, min) => Math.max(min, ...rows.map((r) => String(r[key] ?? "").length));
|
|
82
|
+
const nameW = w("name", 4);
|
|
83
|
+
const engineW = w("engine", 6);
|
|
84
|
+
return rows.map((r) => [
|
|
85
|
+
indent,
|
|
86
|
+
bone(r.name.padEnd(nameW)),
|
|
87
|
+
" ",
|
|
88
|
+
ash(String(r.engine).padEnd(engineW)),
|
|
89
|
+
" ",
|
|
90
|
+
paintState(r.state).padEnd(9 + (paintState(r.state).length - r.state.length)),
|
|
91
|
+
" ",
|
|
92
|
+
ash(tilde(r.cwd || "").padEnd(24)),
|
|
93
|
+
" ",
|
|
94
|
+
dim(humanAge(r.age)),
|
|
95
|
+
].join("")).join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every session, with state attached. The one place that assembles both. */
|
|
99
|
+
export function roster(options = {}) {
|
|
100
|
+
return withState(listSessions(options), options);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Verbs
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function requireSubstrate(write) {
|
|
108
|
+
const substrate = detectSubstrate();
|
|
109
|
+
if (substrate) return substrate;
|
|
110
|
+
write(err("the herd needs somewhere to run."));
|
|
111
|
+
write(info(substrateNote(null)));
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findSession(name, options) {
|
|
116
|
+
return roster(options).find((s) => s.name === name) || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Start a session and hand the prompt straight back.
|
|
121
|
+
*
|
|
122
|
+
* The absolute path matters: the runtime's environment is whatever created the
|
|
123
|
+
* server, which may predate an engine installer appending its bin directory to
|
|
124
|
+
* a shell profile. resolveExecutable already knows every engine's extra
|
|
125
|
+
* directories, so resolving here means `herd start opencode` works in the same
|
|
126
|
+
* session that installed opencode — the exact case that bit the foreground
|
|
127
|
+
* path first.
|
|
128
|
+
*/
|
|
129
|
+
export function herdStart(argv, { write = console.log } = {}) {
|
|
130
|
+
const substrate = requireSubstrate(write);
|
|
131
|
+
if (!substrate) return EXIT.usage;
|
|
132
|
+
|
|
133
|
+
const flags = { name: null, cwd: process.cwd(), agent: false, json: false };
|
|
134
|
+
const rest = [];
|
|
135
|
+
for (let i = 0; i < argv.length; i++) {
|
|
136
|
+
const a = argv[i];
|
|
137
|
+
if (a === "--name") flags.name = argv[++i];
|
|
138
|
+
else if (a.startsWith("--name=")) flags.name = a.slice(7);
|
|
139
|
+
else if (a === "--cwd") flags.cwd = path.resolve(argv[++i] || ".");
|
|
140
|
+
else if (a === "--agent") flags.agent = true;
|
|
141
|
+
else if (a === "--json") flags.json = true;
|
|
142
|
+
else rest.push(a);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const target = rest.shift();
|
|
146
|
+
const resolved = target && resolveEngine(target);
|
|
147
|
+
if (!resolved) {
|
|
148
|
+
write(err(`usage: moshcode herd start <engine> [--name <slug>] [--agent] [args…]`));
|
|
149
|
+
write(info(`engines: ${Object.keys(ENGINES).join(", ")}`));
|
|
150
|
+
return EXIT.usage;
|
|
151
|
+
}
|
|
152
|
+
const [key, engine] = resolved;
|
|
153
|
+
|
|
154
|
+
const taken = listSessions().map((s) => s.name);
|
|
155
|
+
const name = flags.name || defaultName(key, flags.cwd, taken);
|
|
156
|
+
if (!validName(name)) {
|
|
157
|
+
write(err(`invalid name ${JSON.stringify(name)} — must match ${NAME_RE}`));
|
|
158
|
+
return EXIT.usage;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin;
|
|
162
|
+
const args = flags.agent ? agentLaunchArgs(engine, rest) : rest;
|
|
163
|
+
const started = startSession({
|
|
164
|
+
name, engine: key, bin, args, stripEnv: engine.stripEnv || [], cwd: flags.cwd, substrate,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!started.ok) {
|
|
168
|
+
write(err(String(started.error?.message || started.error)));
|
|
169
|
+
return EXIT.usage;
|
|
170
|
+
}
|
|
171
|
+
rememberSession(name, { agent: flags.agent });
|
|
172
|
+
|
|
173
|
+
if (flags.json) {
|
|
174
|
+
write(JSON.stringify({ name, engine: key, cwd: flags.cwd, substrate, agent: flags.agent }, null, 2));
|
|
175
|
+
return EXIT.matched;
|
|
176
|
+
}
|
|
177
|
+
write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`));
|
|
178
|
+
if (flags.agent) write(warn("agent mode: native approvals are bypassed or auto-approved."));
|
|
179
|
+
write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
180
|
+
const note = substrateNote(substrate);
|
|
181
|
+
if (note) write(info(note));
|
|
182
|
+
return EXIT.matched;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Pull the herd flags out of an engine launch (PRD 0009 R3).
|
|
187
|
+
*
|
|
188
|
+
* Opt-in, never the default: `moshcode start claude` and `/start claude` have
|
|
189
|
+
* to keep feeling exactly as they do today, or this is a regression wearing a
|
|
190
|
+
* roster. `--name` implies `--detach`, because naming a session you were about
|
|
191
|
+
* to sit inside is a request for one you can come back to.
|
|
192
|
+
*
|
|
193
|
+
* Shared by the CLI and the pit so the two cannot drift on what `-d` means.
|
|
194
|
+
*/
|
|
195
|
+
export function splitDetachArgs(args = []) {
|
|
196
|
+
const rest = [];
|
|
197
|
+
let detach = false, name = null;
|
|
198
|
+
for (let i = 0; i < args.length; i++) {
|
|
199
|
+
const a = args[i];
|
|
200
|
+
if (a === "--detach" || a === "-d") detach = true;
|
|
201
|
+
else if (a === "--name") { name = args[++i]; detach = true; }
|
|
202
|
+
else if (a.startsWith("--name=")) { name = a.slice(7); detach = true; }
|
|
203
|
+
else rest.push(a);
|
|
204
|
+
}
|
|
205
|
+
return { detach, name, rest };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function herdPs(argv, { write = console.log } = {}) {
|
|
209
|
+
const rows = roster();
|
|
210
|
+
if (argv.includes("--json")) {
|
|
211
|
+
write(JSON.stringify(rows.map(({ name, engine, state, authority, cwd, age, alive, attached, substrate }) => ({
|
|
212
|
+
name, engine, state, authority, cwd, ageMs: age, alive, attached, substrate,
|
|
213
|
+
})), null, 2));
|
|
214
|
+
return EXIT.matched;
|
|
215
|
+
}
|
|
216
|
+
if (!rows.length) {
|
|
217
|
+
write(info("the herd is empty — `moshcode herd start claude` puts something in it."));
|
|
218
|
+
const note = substrateNote();
|
|
219
|
+
if (note) write(info(note));
|
|
220
|
+
return EXIT.matched;
|
|
221
|
+
}
|
|
222
|
+
write(renderRoster(rows));
|
|
223
|
+
const blocked = rows.filter((r) => r.state === "blocked");
|
|
224
|
+
if (blocked.length) {
|
|
225
|
+
write("");
|
|
226
|
+
write(warn(`${blocked.length} waiting on you — ${acid(`moshcode attach ${blocked[0].name}`)}`));
|
|
227
|
+
}
|
|
228
|
+
return EXIT.matched;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function herdAttach(argv, { write = console.log } = {}) {
|
|
232
|
+
const name = argv.find((a) => !a.startsWith("-"));
|
|
233
|
+
if (!name) { write(err("usage: moshcode attach <name>")); return EXIT.usage; }
|
|
234
|
+
const session = findSession(name);
|
|
235
|
+
if (!session) { write(err(`no session named ${JSON.stringify(name)} — ${acid("moshcode ps")}`)); return EXIT.gone; }
|
|
236
|
+
if (!session.alive) {
|
|
237
|
+
write(err(`${name} is not running — ${acid("moshcode restore")} rebuilds it.`));
|
|
238
|
+
return EXIT.gone;
|
|
239
|
+
}
|
|
240
|
+
// A finished session has nothing to type into. Show what it ended on rather
|
|
241
|
+
// than dropping someone into a terminal that will not answer.
|
|
242
|
+
if (session.exited) {
|
|
243
|
+
write(info(`${bone(name)} has finished — this is where it stopped:`));
|
|
244
|
+
write(capture(name, { lines: 40 }));
|
|
245
|
+
write(info(`${acid(`moshcode restore`)} to start it again · ${acid(`moshcode kill ${name}`)} to drop it`));
|
|
246
|
+
return EXIT.matched;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Say how to get out before taking the terminal. The single worst outcome of
|
|
250
|
+
// this whole feature is someone quitting a session they meant to leave
|
|
251
|
+
// running, and the only defence is telling them the key first.
|
|
252
|
+
const substrate = detectSubstrate();
|
|
253
|
+
write(info(substrate === "tmux" ? "detach with Ctrl-b d — the session keeps running." : "detach with Ctrl-] — the session keeps running."));
|
|
254
|
+
|
|
255
|
+
const result = await attachSession(name, { substrate });
|
|
256
|
+
if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; }
|
|
257
|
+
|
|
258
|
+
const after = findSession(name);
|
|
259
|
+
if (after?.alive) write(info(`detached — ${bone(name)} still ${after.state}. ${acid(`moshcode attach ${name}`)} to come back.`));
|
|
260
|
+
else write(info(`${bone(name)} ended.`));
|
|
261
|
+
return EXIT.matched;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function herdKill(argv, { write = console.log } = {}) {
|
|
265
|
+
const all = argv.includes("--all");
|
|
266
|
+
const names = all ? roster().map((s) => s.name) : argv.filter((a) => !a.startsWith("-"));
|
|
267
|
+
if (!names.length) { write(err("usage: moshcode kill <name> | --all")); return EXIT.usage; }
|
|
268
|
+
let failed = 0;
|
|
269
|
+
for (const name of names) {
|
|
270
|
+
const result = killSession(name);
|
|
271
|
+
clearReport(name);
|
|
272
|
+
if (result.ok) write(ok(`${name} ended.`));
|
|
273
|
+
else { write(err(`${name}: ${result.error?.message || "no such session"}`)); failed++; }
|
|
274
|
+
}
|
|
275
|
+
return failed && failed === names.length ? EXIT.gone : EXIT.matched;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Drop sessions the runtime no longer has. Only ever removes bookkeeping — a
|
|
280
|
+
* `prune` that could end running work would be a `kill` with a friendlier name.
|
|
281
|
+
*/
|
|
282
|
+
export function herdPrune(argv, { write = console.log } = {}) {
|
|
283
|
+
const gone = roster().filter((s) => !s.alive);
|
|
284
|
+
for (const s of gone) { forgetSession(s.name); clearReport(s.name); }
|
|
285
|
+
write(gone.length ? ok(`forgot ${gone.length} session(s) the runtime no longer has.`) : info("nothing to prune."));
|
|
286
|
+
return EXIT.matched;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function herdRead(argv, { write = console.log } = {}) {
|
|
290
|
+
const positional = [];
|
|
291
|
+
let lines = 60, json = false;
|
|
292
|
+
for (let i = 0; i < argv.length; i++) {
|
|
293
|
+
const a = argv[i];
|
|
294
|
+
if (a === "--lines") lines = Number(argv[++i]) || 60;
|
|
295
|
+
else if (a.startsWith("--lines=")) lines = Number(a.slice(8)) || 60;
|
|
296
|
+
else if (a === "--json") json = true;
|
|
297
|
+
else if (!a.startsWith("-")) positional.push(a);
|
|
298
|
+
}
|
|
299
|
+
const name = positional[0];
|
|
300
|
+
if (!name) { write(err("usage: moshcode herd read <name> [--lines N]")); return EXIT.usage; }
|
|
301
|
+
const session = findSession(name);
|
|
302
|
+
if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; }
|
|
303
|
+
const screen = capture(name, { lines });
|
|
304
|
+
write(json ? JSON.stringify({ name, state: session.state, screen }, null, 2) : screen);
|
|
305
|
+
return EXIT.matched;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Deliberately NOT unref'd.
|
|
310
|
+
*
|
|
311
|
+
* Everywhere else in moshcode a timer is unref'd so a background nicety — the
|
|
312
|
+
* mirror, a follow — can never hold the process open. Here that instinct is
|
|
313
|
+
* exactly backwards: `wait` and `watch` exist to keep the process alive, and an
|
|
314
|
+
* unref'd timer means node finds nothing pending between polls and exits. It
|
|
315
|
+
* does not hang; it is worse than that. `moshcode wait api --timeout 1h`
|
|
316
|
+
* returns in a millisecond, exit 0, having waited for nothing.
|
|
317
|
+
*/
|
|
318
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Block until a session reaches one of `states`, or the timeout runs out.
|
|
322
|
+
*
|
|
323
|
+
* Polling, not an event stream, and deliberately so: neither substrate can push
|
|
324
|
+
* a state change, and a one-second poll against a `capture-pane` is cheaper
|
|
325
|
+
* than the machinery that would be needed to pretend otherwise. What matters is
|
|
326
|
+
* that the *caller* stops polling and gets to just wait.
|
|
327
|
+
*/
|
|
328
|
+
export async function waitFor(name, states, {
|
|
329
|
+
timeoutMs = 30 * 60 * 1000,
|
|
330
|
+
intervalMs = 1000,
|
|
331
|
+
now = () => Date.now(),
|
|
332
|
+
look = (n) => findSession(n),
|
|
333
|
+
} = {}) {
|
|
334
|
+
const wanted = new Set(states);
|
|
335
|
+
const deadline = now() + timeoutMs;
|
|
336
|
+
for (;;) {
|
|
337
|
+
const session = look(name);
|
|
338
|
+
if (!session) return { outcome: "gone", state: "gone" };
|
|
339
|
+
if (wanted.has(session.state)) return { outcome: "matched", state: session.state };
|
|
340
|
+
// A session that ended can never reach `blocked`; waiting the full timeout
|
|
341
|
+
// for something impossible is a hang, not a wait.
|
|
342
|
+
if (!session.alive || session.state === "done") {
|
|
343
|
+
return wanted.has("done") && session.state === "done"
|
|
344
|
+
? { outcome: "matched", state: session.state }
|
|
345
|
+
: { outcome: "ended", state: session.state };
|
|
346
|
+
}
|
|
347
|
+
if (now() >= deadline) return { outcome: "timeout", state: session.state };
|
|
348
|
+
await sleep(intervalMs);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseDuration(raw, fallback) {
|
|
353
|
+
const m = /^(\d+)(ms|s|m|h)?$/.exec(String(raw || "").trim());
|
|
354
|
+
if (!m) return fallback;
|
|
355
|
+
const n = Number(m[1]);
|
|
356
|
+
return { ms: n, s: n * 1000, m: n * 60000, h: n * 3600000 }[m[2] || "s"];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function herdWait(argv, { write = console.log } = {}) {
|
|
360
|
+
const positional = [];
|
|
361
|
+
let states = ["blocked", "done"], timeoutMs = 30 * 60 * 1000, json = false;
|
|
362
|
+
for (let i = 0; i < argv.length; i++) {
|
|
363
|
+
const a = argv[i];
|
|
364
|
+
if (a === "--state") states = String(argv[++i] || "").split(",").filter(Boolean);
|
|
365
|
+
else if (a.startsWith("--state=")) states = a.slice(8).split(",").filter(Boolean);
|
|
366
|
+
else if (a === "--timeout") timeoutMs = parseDuration(argv[++i], timeoutMs);
|
|
367
|
+
else if (a.startsWith("--timeout=")) timeoutMs = parseDuration(a.slice(10), timeoutMs);
|
|
368
|
+
else if (a === "--json") json = true;
|
|
369
|
+
else if (!a.startsWith("-")) positional.push(a);
|
|
370
|
+
}
|
|
371
|
+
const name = positional[0];
|
|
372
|
+
if (!name) { write(err("usage: moshcode wait <name> [--state blocked,done] [--timeout 30m]")); return EXIT.usage; }
|
|
373
|
+
const unknown = states.filter((s) => !STATES.includes(s));
|
|
374
|
+
if (unknown.length) { write(err(`unknown state ${unknown[0]} — one of ${STATES.join(", ")}`)); return EXIT.usage; }
|
|
375
|
+
|
|
376
|
+
const result = await waitFor(name, states, { timeoutMs });
|
|
377
|
+
if (json) write(JSON.stringify({ name, ...result }, null, 2));
|
|
378
|
+
else if (result.outcome === "matched") write(ok(`${name} is ${result.state}.`));
|
|
379
|
+
else if (result.outcome === "timeout") write(warn(`${name} is still ${result.state} after the timeout.`));
|
|
380
|
+
else if (result.outcome === "gone") write(err(`no session named ${JSON.stringify(name)}`));
|
|
381
|
+
else write(info(`${name} ended (${result.state}) without reaching ${states.join("/")}.`));
|
|
382
|
+
|
|
383
|
+
if (result.outcome === "matched") return EXIT.matched;
|
|
384
|
+
if (result.outcome === "timeout") return EXIT.timeout;
|
|
385
|
+
return EXIT.gone;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Type a prompt into a running session, optionally waiting for it to land.
|
|
390
|
+
*
|
|
391
|
+
* `--wait` is the composite that makes agent-to-agent work practical: submit,
|
|
392
|
+
* then block until the session stops working. The grace period before that is
|
|
393
|
+
* not decoration — an engine takes a moment to notice input, and without it the
|
|
394
|
+
* wait would see the still-idle screen and return instantly, reporting success
|
|
395
|
+
* before the agent had read a word.
|
|
396
|
+
*/
|
|
397
|
+
export async function herdPrompt(argv, { write = console.log } = {}) {
|
|
398
|
+
const positional = [];
|
|
399
|
+
let wait = false, timeoutMs = 30 * 60 * 1000, json = false;
|
|
400
|
+
for (let i = 0; i < argv.length; i++) {
|
|
401
|
+
const a = argv[i];
|
|
402
|
+
if (a === "--wait") wait = true;
|
|
403
|
+
else if (a === "--timeout") timeoutMs = parseDuration(argv[++i], timeoutMs);
|
|
404
|
+
else if (a.startsWith("--timeout=")) timeoutMs = parseDuration(a.slice(10), timeoutMs);
|
|
405
|
+
else if (a === "--json") json = true;
|
|
406
|
+
else positional.push(a);
|
|
407
|
+
}
|
|
408
|
+
const [name, ...words] = positional;
|
|
409
|
+
const text = words.join(" ");
|
|
410
|
+
if (!name || !text) { write(err('usage: moshcode herd prompt <name> "<text>" [--wait]')); return EXIT.usage; }
|
|
411
|
+
const session = findSession(name);
|
|
412
|
+
if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; }
|
|
413
|
+
|
|
414
|
+
const sent = sendPrompt(name, text);
|
|
415
|
+
if (!sent.ok) { write(err(String(sent.error?.message || sent.error))); return EXIT.usage; }
|
|
416
|
+
if (!wait) {
|
|
417
|
+
if (json) write(JSON.stringify({ name, sent: true }, null, 2));
|
|
418
|
+
else write(ok(`sent to ${bone(name)}.`));
|
|
419
|
+
return EXIT.matched;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
await waitFor(name, ["working"], { timeoutMs: 8000, intervalMs: 500 });
|
|
423
|
+
const result = await waitFor(name, ["blocked", "done", "idle"], { timeoutMs });
|
|
424
|
+
if (json) write(JSON.stringify({ name, sent: true, ...result }, null, 2));
|
|
425
|
+
else if (result.outcome === "matched") write(ok(`${name} is ${result.state}.`));
|
|
426
|
+
else write(warn(`${name}: ${result.outcome} (${result.state})`));
|
|
427
|
+
return result.outcome === "matched" ? EXIT.matched : result.outcome === "timeout" ? EXIT.timeout : EXIT.gone;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function herdSendKeys(argv, { write = console.log } = {}) {
|
|
431
|
+
const positional = argv.filter((a) => a !== "--json");
|
|
432
|
+
const [name, ...keys] = positional;
|
|
433
|
+
if (!name || !keys.length) { write(err("usage: moshcode herd send-keys <name> <keys…>")); return EXIT.usage; }
|
|
434
|
+
const session = findSession(name);
|
|
435
|
+
if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; }
|
|
436
|
+
const sent = sendKeys(name, keys);
|
|
437
|
+
if (!sent.ok) { write(err(String(sent.error?.message || sent.error))); return EXIT.usage; }
|
|
438
|
+
write(ok(`sent ${keys.join(" ")} to ${name}.`));
|
|
439
|
+
return EXIT.matched;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function herdReport(argv, { write = console.log } = {}) {
|
|
443
|
+
const positional = [];
|
|
444
|
+
let ttl;
|
|
445
|
+
for (let i = 0; i < argv.length; i++) {
|
|
446
|
+
const a = argv[i];
|
|
447
|
+
if (a === "--ttl") ttl = parseDuration(argv[++i]);
|
|
448
|
+
else if (a.startsWith("--ttl=")) ttl = parseDuration(a.slice(6));
|
|
449
|
+
else if (!a.startsWith("-")) positional.push(a);
|
|
450
|
+
}
|
|
451
|
+
const [name, state] = positional;
|
|
452
|
+
if (!name || !state) {
|
|
453
|
+
write(err(`usage: moshcode herd report <name> <${STATES.join("|")}> [--ttl 15m]`));
|
|
454
|
+
return EXIT.usage;
|
|
455
|
+
}
|
|
456
|
+
const result = reportState(name, state, ttl ? { ttl } : {});
|
|
457
|
+
if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; }
|
|
458
|
+
write(ok(`${name} → ${state} (authoritative)`));
|
|
459
|
+
return EXIT.matched;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function herdStatus(argv, { write = console.log } = {}) {
|
|
463
|
+
const substrate = detectSubstrate();
|
|
464
|
+
const rows = roster();
|
|
465
|
+
const model = {
|
|
466
|
+
substrate,
|
|
467
|
+
socket: substrate === "tmux" ? HERD_SOCKET : null,
|
|
468
|
+
dir: herdDir(),
|
|
469
|
+
sessions: rows.length,
|
|
470
|
+
live: rows.filter((r) => r.alive).length,
|
|
471
|
+
blocked: rows.filter((r) => r.state === "blocked").length,
|
|
472
|
+
notify: readConfig().notify,
|
|
473
|
+
};
|
|
474
|
+
if (argv.includes("--json")) { write(JSON.stringify(model, null, 2)); return EXIT.matched; }
|
|
475
|
+
write(`${bone("substrate")} ${substrate || danger("none")}${substrate === "tmux" ? ash(` (socket ${HERD_SOCKET})`) : ""}`);
|
|
476
|
+
write(`${bone("sessions")} ${model.live} live${model.sessions - model.live ? ash(`, ${model.sessions - model.live} remembered`) : ""}`);
|
|
477
|
+
write(`${bone("notify")} ${model.notify.enabled ? acid(`on → ${model.notify.states.join(",")}`) : ash("off")}`);
|
|
478
|
+
const note = substrateNote(substrate);
|
|
479
|
+
if (note) write(info(note));
|
|
480
|
+
return EXIT.matched;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function herdNotify(argv, { write = console.log } = {}) {
|
|
484
|
+
const verb = argv.find((a) => !a.startsWith("-"));
|
|
485
|
+
const config = readConfig();
|
|
486
|
+
if (!verb || verb === "status") {
|
|
487
|
+
write(config.notify.enabled
|
|
488
|
+
? ok(`notifications on for ${config.notify.states.join(", ")}${config.notify.ask ? " (replies typed back into the session)" : ""}`)
|
|
489
|
+
: info("notifications off — `moshcode herd notify on`"));
|
|
490
|
+
return EXIT.matched;
|
|
491
|
+
}
|
|
492
|
+
if (verb === "on" || verb === "off") {
|
|
493
|
+
config.notify.enabled = verb === "on";
|
|
494
|
+
if (argv.includes("--ask")) config.notify.ask = true;
|
|
495
|
+
if (argv.includes("--no-ask")) config.notify.ask = false;
|
|
496
|
+
const at = argv.indexOf("--state");
|
|
497
|
+
if (at >= 0 && argv[at + 1]) config.notify.states = argv[at + 1].split(",").filter((s) => STATES.includes(s));
|
|
498
|
+
writeConfig(config);
|
|
499
|
+
write(verb === "on"
|
|
500
|
+
? ok(`notifications on for ${config.notify.states.join(", ")} — run ${acid("moshcode herd watch")} in the herd to deliver them.`)
|
|
501
|
+
: ok("notifications off."));
|
|
502
|
+
return EXIT.matched;
|
|
503
|
+
}
|
|
504
|
+
write(err("usage: moshcode herd notify <on|off|status> [--state blocked,done] [--ask]"));
|
|
505
|
+
return EXIT.usage;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The watcher: the piece that turns a state change into a phone buzzing.
|
|
510
|
+
*
|
|
511
|
+
* It runs *inside* the herd (`moshcode herd watch` started as its own session),
|
|
512
|
+
* which is the only placement that makes sense — a watcher in the pit would
|
|
513
|
+
* stop watching the moment you closed the pit, which is precisely when you
|
|
514
|
+
* needed it. This is also the part herdr structurally cannot do: it can colour
|
|
515
|
+
* a pane, and moshcode can reach the human who is not looking at one.
|
|
516
|
+
*/
|
|
517
|
+
/**
|
|
518
|
+
* Is this state change worth a human's attention?
|
|
519
|
+
*
|
|
520
|
+
* Only a *transition into* a watched state. Three things this rules out, each
|
|
521
|
+
* of which would kill the feature on its own:
|
|
522
|
+
* - a session that sits blocked for an hour paging every five seconds;
|
|
523
|
+
* - the first sighting of an already-blocked session, which is history, not
|
|
524
|
+
* news — the watcher has just started and everything looks new;
|
|
525
|
+
* - any transition *out of* a watched state, which is the good news nobody
|
|
526
|
+
* needs a text about.
|
|
527
|
+
*/
|
|
528
|
+
export function shouldNotify(previous, current, interesting) {
|
|
529
|
+
if (previous === undefined) return false;
|
|
530
|
+
if (previous === current) return false;
|
|
531
|
+
return interesting.has(current);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function herdWatch(argv, { write = console.log, once = false } = {}) {
|
|
535
|
+
const intervalMs = (() => {
|
|
536
|
+
const at = argv.indexOf("--interval");
|
|
537
|
+
return at >= 0 ? parseDuration(argv[at + 1], 5000) : 5000;
|
|
538
|
+
})();
|
|
539
|
+
const config = readConfig();
|
|
540
|
+
if (!config.notify.enabled && !argv.includes("--force")) {
|
|
541
|
+
write(info("notifications are off — `moshcode herd notify on` first (or --force to watch anyway)."));
|
|
542
|
+
return EXIT.usage;
|
|
543
|
+
}
|
|
544
|
+
const interesting = new Set(config.notify.states);
|
|
545
|
+
write(ok(`watching the herd every ${Math.round(intervalMs / 1000)}s for ${[...interesting].join(", ")} 🤘`));
|
|
546
|
+
|
|
547
|
+
const seen = new Map();
|
|
548
|
+
for (;;) {
|
|
549
|
+
// One roster per tick, not one per session: this loop runs forever, and
|
|
550
|
+
// re-reading the herd inside the cleanup pass made a watcher on six
|
|
551
|
+
// sessions shell out dozens of times every five seconds, all night.
|
|
552
|
+
const current = roster();
|
|
553
|
+
for (const session of current) {
|
|
554
|
+
const previous = seen.get(session.name);
|
|
555
|
+
seen.set(session.name, session.state);
|
|
556
|
+
if (!shouldNotify(previous, session.state, interesting)) continue;
|
|
557
|
+
await deliver(session, config, write);
|
|
558
|
+
}
|
|
559
|
+
const present = new Set(current.map((s) => s.name));
|
|
560
|
+
for (const name of [...seen.keys()]) if (!present.has(name)) seen.delete(name);
|
|
561
|
+
if (once) return EXIT.matched;
|
|
562
|
+
await sleep(intervalMs);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function deliver(session, config, write) {
|
|
567
|
+
const tail = capture(session.name, { lines: 30 }).split("\n").slice(-12).join("\n");
|
|
568
|
+
const message = `${session.name} (${session.engine}) is ${session.state} in ${tilde(session.cwd)}\n\n${tail}`;
|
|
569
|
+
if (!config.notify.ask) {
|
|
570
|
+
const r = await ingestApproval({ message, kind: "notify", script: "herd", session: session.name });
|
|
571
|
+
write(r.ok ? info(`notified: ${session.name} → ${session.state}`) : warn(`notify failed (${r.error || r.status}) — run \`moshcode login\``));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const r = await ingestApproval({ message, kind: "ask", script: "herd", session: session.name });
|
|
575
|
+
if (!r.ok) { write(warn(`ask failed (${r.error || r.status}) — run \`moshcode login\``)); return; }
|
|
576
|
+
write(info(`asked: ${r.url}`));
|
|
577
|
+
const reply = await pollApproval(r.id);
|
|
578
|
+
if (reply == null) { write(info(`no reply for ${session.name} — leaving it be`)); return; }
|
|
579
|
+
const sent = sendPrompt(session.name, reply);
|
|
580
|
+
write(sent.ok ? ok(`answered ${session.name}: ${reply}`) : warn(`could not type the reply into ${session.name}`));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Rebuild the herd from the manifest.
|
|
585
|
+
*
|
|
586
|
+
* What comes back is the *shape* — the sessions, in their directories, on their
|
|
587
|
+
* engines. The processes are gone and no amount of bookkeeping brings them
|
|
588
|
+
* back, so the wording here never says "restored your work". `--resume` is the
|
|
589
|
+
* separate, explicit act of asking each engine to reopen its own conversation,
|
|
590
|
+
* and only the engines that actually have a resume flag get one.
|
|
591
|
+
*/
|
|
592
|
+
export function herdRestore(argv, { write = console.log } = {}) {
|
|
593
|
+
const substrate = requireSubstrate(write);
|
|
594
|
+
if (!substrate) return EXIT.usage;
|
|
595
|
+
const resume = argv.includes("--resume");
|
|
596
|
+
const dryRun = argv.includes("--dry-run");
|
|
597
|
+
|
|
598
|
+
const manifest = readManifest();
|
|
599
|
+
// Only a session that is actually *running* is one there is nothing to do
|
|
600
|
+
// about. A finished one is a fair thing to bring back — it is on the roster
|
|
601
|
+
// reading `done`, and restoring it is how you pick the work back up.
|
|
602
|
+
const live = new Set(listSessions().filter((s) => s.alive && !s.exited).map((s) => s.name));
|
|
603
|
+
const candidates = Object.entries(manifest.sessions).filter(([name]) => !live.has(name));
|
|
604
|
+
if (!candidates.length) { write(info("nothing to restore — everything remembered is already running.")); return EXIT.matched; }
|
|
605
|
+
|
|
606
|
+
let restored = 0;
|
|
607
|
+
for (const [name, meta] of candidates) {
|
|
608
|
+
const engine = ENGINES[meta.engine];
|
|
609
|
+
if (!engine) { write(warn(`${name}: unknown engine ${meta.engine} — skipped`)); continue; }
|
|
610
|
+
if (!fs.existsSync(meta.cwd || "")) { write(warn(`${name}: ${tilde(meta.cwd || "")} is gone — skipped`)); continue; }
|
|
611
|
+
|
|
612
|
+
const resumeArgs = resume ? engine.resume || null : null;
|
|
613
|
+
if (resume && !resumeArgs) write(info(`${name}: ${meta.engine} has no resume flag — starting fresh`));
|
|
614
|
+
const args = resumeArgs || meta.args || [];
|
|
615
|
+
if (dryRun) { write(info(`would restore ${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? " (resumed)" : ""}`)); restored++; continue; }
|
|
616
|
+
|
|
617
|
+
const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin;
|
|
618
|
+
const started = startSession({ name, engine: meta.engine, bin, args, stripEnv: engine.stripEnv || [], cwd: meta.cwd, substrate });
|
|
619
|
+
if (!started.ok) { write(err(`${name}: ${started.error?.message || started.error}`)); continue; }
|
|
620
|
+
clearReport(name);
|
|
621
|
+
write(ok(`${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? ash(" (asked to resume)") : ""}`));
|
|
622
|
+
restored++;
|
|
623
|
+
}
|
|
624
|
+
if (restored && !dryRun) {
|
|
625
|
+
write("");
|
|
626
|
+
write(info("the shape is back; the processes are new. anything that was mid-task is not still running it."));
|
|
627
|
+
}
|
|
628
|
+
return EXIT.matched;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function herdStop(argv, { write = console.log } = {}) {
|
|
632
|
+
const rows = roster().filter((s) => s.alive);
|
|
633
|
+
if (rows.length && !argv.includes("--yes") && !argv.includes("-y")) {
|
|
634
|
+
write(err(`this ends ${rows.length} running session(s). re-run with --yes.`));
|
|
635
|
+
write(renderRoster(rows));
|
|
636
|
+
return EXIT.usage;
|
|
637
|
+
}
|
|
638
|
+
stopRuntime();
|
|
639
|
+
write(ok("the herd is stopped."));
|
|
640
|
+
return EXIT.matched;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Dispatch
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
const VERBS = {
|
|
648
|
+
ps: herdPs, list: herdPs, status: herdStatus,
|
|
649
|
+
start: herdStart, attach: herdAttach, kill: herdKill, prune: herdPrune,
|
|
650
|
+
read: herdRead, prompt: herdPrompt, "send-keys": herdSendKeys,
|
|
651
|
+
wait: herdWait, restore: herdRestore, report: herdReport,
|
|
652
|
+
notify: herdNotify, watch: herdWatch, stop: herdStop,
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
export async function herdCommand(argv = [], { write = console.log } = {}) {
|
|
656
|
+
const [verb, ...rest] = argv;
|
|
657
|
+
if (!verb || verb === "--json") return herdPs(argv, { write });
|
|
658
|
+
const run = VERBS[verb];
|
|
659
|
+
if (!run) {
|
|
660
|
+
write(err(`unknown herd verb ${JSON.stringify(verb)}`));
|
|
661
|
+
write(info(`verbs: ${Object.keys(VERBS).join(", ")}`));
|
|
662
|
+
return EXIT.usage;
|
|
663
|
+
}
|
|
664
|
+
return run(rest, { write });
|
|
665
|
+
}
|