moshcode 0.90.0 → 0.92.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/prd/README.md CHANGED
@@ -27,4 +27,6 @@ Start one with `moshcode prd "<idea>"` (TUI: `/prd`).
27
27
  | [0009](0009-persistent-agent-runtime.md) | Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents | Accepted |
28
28
  | [0010](0010-cloud-settings-sync.md) | Sync the pit's settings to your moshcode.sh account | Draft |
29
29
  | [0011](0011-herd-agent-protocol.md) | Teach the herd the agent protocol — hooks-first state, a task ledger, and an A2A surface for local and remote agents | Draft |
30
+ | [0012](0012-billing-baked-into-the-agent-cli.md) | Bake billing into the agent CLI — timer, clients, teams, rates, invoices, rails | Draft |
31
+ | [0013](0013-persistent-ssh-workspaces.md) | Add persistent SSH workspaces for humans and agents | Draft |
30
32
  <!-- PRD-INDEX:END -->
@@ -207,6 +207,36 @@ export const CORE_CLI_COMMANDS = [
207
207
  seeAlso: ["herd", "ps"],
208
208
  note: "this brings back the shape — sessions, directories, engines. the processes are new; work that was in flight is not still running.",
209
209
  },
210
+ {
211
+ name: "ssh",
212
+ group: "runtime",
213
+ description: "persistent SSH workspaces — one connection, many clean commands",
214
+ synopsis: [
215
+ ["moshcode ssh", "targets, and whether each one is connected"],
216
+ ["moshcode ssh <name>", "an interactive shell over the shared connection"],
217
+ ["moshcode ssh exec <name> [flags] -- <command…>", "one command over it: stdout, stderr and exit status kept apart"],
218
+ ["moshcode ssh <verb> [args…]", "add, open, check, close, put, get, shell, bench"],
219
+ ],
220
+ verbs: "SSH_VERBS",
221
+ flags: [
222
+ ["--json", "machine-readable, on every verb that does not take the terminal", ""],
223
+ ["--persist <dur>", "how long the connection outlives its last client", "10m"],
224
+ ],
225
+ examples: [
226
+ ["moshcode ssh add dev deploy@example.com --cwd /srv/app", "a name for a box (or an alias from ~/.ssh/config)"],
227
+ ["moshcode ssh open dev", "authenticate once"],
228
+ ["moshcode ssh exec dev -- git status --short", "…then every command reuses it"],
229
+ ["moshcode ssh exec dev --json -- pnpm test", "exit code, stdout and stderr as one object"],
230
+ ["git diff | moshcode ssh exec dev --stdin -- git apply -", "a multi-file patch in one round trip"],
231
+ ["moshcode ssh dev", "a real shell on the same connection"],
232
+ ["moshcode ssh shell dev --name app", "a remote tmux shell that survives your laptop sleeping"],
233
+ ["moshcode ssh close dev", "hang up (or let --persist expire)"],
234
+ ],
235
+ seeAlso: ["herd", "shell"],
236
+ note: "nothing secret is stored: targets.json holds a host, a port and a directory, and OpenSSH keeps using your ~/.ssh, "
237
+ + "agent, known_hosts and ProxyJump exactly as it does at the prompt. `exec` gives every call a fresh command channel — "
238
+ + "no shell state carries between calls; `shell` is for the workflows that need it.",
239
+ },
210
240
  {
211
241
  name: "install",
212
242
  group: "engines",
@@ -1325,6 +1355,82 @@ export const HERD_VERBS = [
1325
1355
  // The business layer's verbs. Flatter than the herd's on purpose: these are
1326
1356
  // commands somebody types between other work, and a verb that needs a paragraph
1327
1357
  // to explain itself is a verb in the wrong place.
1358
+ export const SSH_VERBS = [
1359
+ { name: "list", description: "every target, and whether its connection is up",
1360
+ synopsis: [["moshcode ssh [list] [--json]", ""]],
1361
+ flags: [["--json", "machine-readable", ""]] },
1362
+ { name: "add", description: "name a box: user@host, or an alias from ~/.ssh/config",
1363
+ synopsis: [["moshcode ssh add <name> <user@host|alias> [--port N] [--cwd PATH] [--persist 10m]", ""]],
1364
+ flags: [
1365
+ ["--port <n>", "ssh port, when ~/.ssh/config does not say", "22"],
1366
+ ["--cwd <path>", "where exec and the interactive shell start", "the remote login directory"],
1367
+ ["--persist <dur>", "how long the connection outlives its last client", "10m"],
1368
+ ["--json", "machine-readable", ""],
1369
+ ] },
1370
+ { name: "remove", description: "forget a target (and close its connection)",
1371
+ synopsis: [["moshcode ssh remove <name>", ""]] },
1372
+ { name: "show", description: "one target in full, with its socket and state",
1373
+ synopsis: [["moshcode ssh show <name> [--json]", ""]],
1374
+ flags: [["--json", "machine-readable", ""]] },
1375
+ { name: "open", description: "authenticate once and keep the connection",
1376
+ synopsis: [["moshcode ssh open <name> [--persist 10m] [--batch] [--json]", ""]],
1377
+ flags: [
1378
+ ["--persist <dur>", "how long it outlives its last client", "10m"],
1379
+ ["--batch", "never prompt — fail instead (the default when stdin is not a terminal)", ""],
1380
+ ["--json", "machine-readable; alreadyOpen says whether it was up", ""],
1381
+ ] },
1382
+ { name: "check", description: "is the connection up? (exit 0 yes, 1 no)",
1383
+ synopsis: [["moshcode ssh check <name> [--json]", ""]],
1384
+ flags: [["--json", "machine-readable", ""]] },
1385
+ { name: "close", description: "hang up, with ssh's own -O exit",
1386
+ synopsis: [["moshcode ssh close <name> [--json]", ""]],
1387
+ flags: [["--json", "machine-readable", ""]] },
1388
+ { name: "exec", description: "one command over the connection — no PTY, no shared shell state",
1389
+ synopsis: [["moshcode ssh exec <name> [--cwd PATH] [--env K=V…] [--stdin] [--tty] [--timeout 2m] [--sh] [--json] -- <command…>", ""]],
1390
+ flags: [
1391
+ ["--cwd <path>", "run it here, this once", "the target's cwd"],
1392
+ ["--env <K=V>", "an environment variable for this command only (repeatable)", ""],
1393
+ ["--stdin", "forward this process's stdin, raw", ""],
1394
+ ["--tty", "allocate a terminal (sudo, editors, anything that insists)", ""],
1395
+ ["--timeout <dur>", "kill it after this long — exit 124", ""],
1396
+ ["--sh", "the one argument is a shell snippet, pipes and all", ""],
1397
+ ["--batch", "never prompt for auth — fail instead", ""],
1398
+ ["--json", "{ ok, transportOk, code, signal, stdout, stderr, durationMs }", ""],
1399
+ ],
1400
+ examples: [
1401
+ ["moshcode ssh exec dev -- git status --short", ""],
1402
+ ["moshcode ssh exec dev --json -- grep -rn TODO src", "exit 1 is grep's answer, not a transport failure"],
1403
+ ["moshcode ssh exec dev --sh 'git log --oneline | head -5'", "a pipeline, on purpose"],
1404
+ ] },
1405
+ { name: "put", description: "copy a file up over the connection, atomically",
1406
+ synopsis: [["moshcode ssh put <name> <local> <remote> [--json]", ""]],
1407
+ flags: [["--json", "machine-readable", ""]] },
1408
+ { name: "get", description: "copy a file down over the connection",
1409
+ synopsis: [["moshcode ssh get <name> <remote> <local> [--json]", ""]],
1410
+ flags: [["--json", "machine-readable", ""]] },
1411
+ { name: "shell", description: "a persistent remote shell in tmux, and the verbs to drive it",
1412
+ synopsis: [
1413
+ ["moshcode ssh shell <name> --name <session>", "create or attach — Ctrl-b d leaves it running"],
1414
+ ["moshcode ssh shell send <name>/<session> <text>", "type a line into it"],
1415
+ ["moshcode ssh shell read <name>/<session> [--lines N]", "its screen, as text"],
1416
+ ["moshcode ssh shell kill <name>/<session>", "end it"],
1417
+ ["moshcode ssh shell list <name>", "every moshcode shell on the box"],
1418
+ ],
1419
+ flags: [
1420
+ ["--name <session>", "which shell", "main"],
1421
+ ["--lines <n>", "how much screen to read", "60"],
1422
+ ["--json", "machine-readable", ""],
1423
+ ],
1424
+ examples: [
1425
+ ["moshcode ssh shell dev --name app", ""],
1426
+ ["moshcode ssh shell send dev/app \"pnpm dev\"", ""],
1427
+ ["moshcode ssh shell read dev/app --lines 40", ""],
1428
+ ] },
1429
+ { name: "bench", description: "measure fresh connections against the shared one, on this host",
1430
+ synopsis: [["moshcode ssh bench <name> [--n 20] [--json]", ""]],
1431
+ flags: [["--n <runs>", "how many of each", "20"], ["--json", "machine-readable", ""]] },
1432
+ ];
1433
+
1328
1434
  export const TIMER_VERBS = [
1329
1435
  { name: "on", description: "start the clock", synopsis: [["moshcode timer on [client] [--task …] [--agents N|auto]", ""]] },
1330
1436
  { name: "off", description: "stop it and write the entry", synopsis: [["moshcode timer off [--note …]", ""]] },
@@ -1377,6 +1483,7 @@ export const PAYMENT_VERBS = [
1377
1483
 
1378
1484
  export const VERB_TABLES = {
1379
1485
  HERD_VERBS,
1486
+ SSH_VERBS,
1380
1487
  TIMER_VERBS,
1381
1488
  CLIENT_VERBS,
1382
1489
  TEAM_VERBS,
@@ -1428,6 +1535,8 @@ export const PIT_COMMANDS = [
1428
1535
  description: "block until a session is blocked or done" },
1429
1536
  { name: "restore", args: "[--resume]", cli: "restore",
1430
1537
  description: "rebuild the herd's sessions after a reboot" },
1538
+ { name: "ssh", args: "[name|verb] [args…]", cli: "ssh",
1539
+ description: "persistent SSH workspaces — one connection, many clean commands" },
1431
1540
  { name: "tools", args: "[name] [args…]", cli: "tools",
1432
1541
  description: "list workflow tools, or run one" },
1433
1542
  { name: "trade", args: "<verb> [args…]", cli: "trade",
@@ -1487,6 +1596,20 @@ export const PIT_COMMANDS = [
1487
1596
  description: "show the current dir + git repo/branch/origin" },
1488
1597
  { name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
1489
1598
  description: "drop into $SHELL (exit → back to the pit); also !cmd" },
1599
+ { name: "nice", aliases: ["throttle"], args: "on | off | cpu <n> | io <n> | mem <size>", pitOnly: true,
1600
+ description: "run engines at low priority so the box stays usable",
1601
+ synopsis: [
1602
+ ["/nice [status]", "what the throttle is set to"],
1603
+ ["/nice on | off", "toggle it (off by default)"],
1604
+ ["/nice cpu <-20..19>", "nice level — higher yields more CPU"],
1605
+ ["/nice io <0..7>", "ionice best-effort level"],
1606
+ ["/nice mem <size> | off", "memory ceiling per engine (needs systemd)"],
1607
+ ],
1608
+ examples: [
1609
+ ["/nice on", "nice -n10 + ionice -c2 -n7 for every engine started after"],
1610
+ ["/nice mem 2G", "the ceiling nice(1) can't give you — a runaway dies alone"],
1611
+ ],
1612
+ seeAlso: ["agents", "start"] },
1490
1613
  { name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm | install <tool>', pitOnly: true,
1491
1614
  description: "name a line you keep retyping; /<name> runs it",
1492
1615
  synopsis: [
package/src/commands.mjs CHANGED
@@ -22,6 +22,27 @@ import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs";
22
22
  import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs";
23
23
  import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs";
24
24
  import { shellInvocation } from "./shell.mjs";
25
+ import {
26
+ checkMaster as sshCheckMaster, closeMaster as sshCloseMaster, exec as sshRun, get as sshGetFile,
27
+ openMaster as sshOpenMaster, parseSessionRef as sshParseSessionRef, parseTimeout as parseSshTimeout,
28
+ put as sshPutFile, resolveTarget as sshResolve,
29
+ shellKill as sshShellKillRemote, shellRead as sshShellReadRemote, shellSend as sshShellSendRemote,
30
+ } from "./ssh.mjs";
31
+
32
+ /** The registry entry for a named ssh target, or a moshscript error naming the verb. */
33
+ function sshTarget(name, verb) {
34
+ if (!name) throw new Error(`moshscript: ${verb}(name) requires a target name`);
35
+ const found = sshResolve(String(name));
36
+ if (found.error) throw new Error(`moshscript: ${verb}: ${found.error.replace(/^ssh: /, "")}`);
37
+ return found.entry;
38
+ }
39
+
40
+ /** `dev/app` → the target entry and the session name, or a moshscript error. */
41
+ function sshShellRef(ref, verb) {
42
+ const parsed = sshParseSessionRef(ref);
43
+ if (parsed.error) throw new Error(`moshscript: ${verb}: ${parsed.error.replace(/^ssh: /, "")}`);
44
+ return { found: sshTarget(parsed.name, verb), session: parsed.session };
45
+ }
25
46
  import { captureSpec } from "./pty.mjs";
26
47
  import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
27
48
  import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
@@ -654,6 +675,148 @@ const COMMANDS = [
654
675
  },
655
676
  },
656
677
 
678
+ // SSH workspaces (PRD 0013 R49–R52). Value-returning for the herd's reason:
679
+ // a script that runs a command on a remote box wants its stdout back, and a
680
+ // cliVerb's { ok, code } cannot carry it. Each returns the same object the
681
+ // CLI prints under --json, so a script and a shell pipeline read one shape.
682
+ //
683
+ // sshOpen("dev");
684
+ // const r = sshExec("dev", ["git", "status", "--short"], { cwd: "/srv/app" });
685
+ // if (!r.ok) say(r.stderr);
686
+ // sshExec("dev", ["git", "apply", "-"], { stdin: patch });
687
+ // sshClose("dev");
688
+ {
689
+ name: "sshOpen",
690
+ summary: "authenticate to a named ssh target once and keep the connection",
691
+ usage: "sshOpen(name, { persist })",
692
+ detail: "returns { ok, connected, alreadyOpen }; a live connection is left alone",
693
+ run(ctx, name, opts = {}) {
694
+ const found = sshTarget(name, "sshOpen");
695
+ if (ctx.dryRun) { ctx.out(` 🔌 sshOpen(${name}) → would open a master to ${found.target}`); return { ok: true, target: found.name, connected: true, dryRun: true }; }
696
+ const r = sshOpenMaster(found, { persist: opts.persist, batch: opts.batch });
697
+ ctx.out(r.ok ? ` 🔌 sshOpen(${name}) → ${r.alreadyOpen ? "already connected" : "connected"}` : ` ✗ sshOpen(${name}) → ${r.error}`);
698
+ return r;
699
+ },
700
+ },
701
+ {
702
+ name: "sshCheck",
703
+ summary: "is the connection to a target up?",
704
+ usage: "sshCheck(name)",
705
+ detail: "returns { connected, stale, pid }; never connects",
706
+ run(ctx, name) {
707
+ const found = sshTarget(name, "sshCheck");
708
+ if (ctx.dryRun) return { target: found.name, connected: false, dryRun: true };
709
+ const s = sshCheckMaster(found);
710
+ return { target: found.name, connected: s.connected, stale: s.stale, pid: s.pid ?? null };
711
+ },
712
+ },
713
+ {
714
+ name: "sshExec",
715
+ summary: "run one command over the shared connection and RETURN its result",
716
+ usage: "sshExec(name, [cmd, ...args], { cwd, env, stdin, timeout, sh })",
717
+ detail: "returns { ok, transportOk, code, signal, stdout, stderr, durationMs }; opens the connection if it is down. ok is the command's verdict, transportOk is ssh's",
718
+ run(ctx, name, argv, opts = {}) {
719
+ const found = sshTarget(name, "sshExec");
720
+ const command = Array.isArray(argv) ? argv.map(String) : [String(argv ?? "")].filter(Boolean);
721
+ if (!command.length) throw new Error("moshscript: sshExec(name, [command, ...args]) needs a command");
722
+ if (ctx.dryRun) {
723
+ ctx.out(` ▶ sshExec(${name}) → would run on ${found.target}: ${command.join(" ")}`);
724
+ return { ok: true, transportOk: true, target: found.name, connected: true, code: 0, signal: null, stdout: "", stderr: "", durationMs: 0, dryRun: true };
725
+ }
726
+ ctx.out(` ▶ sshExec(${name}) → ${command.join(" ").slice(0, 60)}${command.join(" ").length > 60 ? "…" : ""}`);
727
+ const timeoutMs = opts.timeout === undefined ? undefined
728
+ : (typeof opts.timeout === "number" ? opts.timeout : parseSshTimeout(opts.timeout));
729
+ const r = sshRun(found, command, {
730
+ cwd: opts.cwd, remoteEnv: opts.env || {}, stdin: opts.stdin, sh: Boolean(opts.sh), timeoutMs, persist: opts.persist, batch: opts.batch ?? true,
731
+ });
732
+ if (!r.transportOk) ctx.out(` ✗ sshExec(${name}) → ${r.error}`);
733
+ else if (!r.ok) ctx.out(` ✗ sshExec(${name}) exited ${r.signal || r.code}`);
734
+ return r;
735
+ },
736
+ },
737
+ {
738
+ name: "sshClose",
739
+ summary: "hang up a target's connection",
740
+ usage: "sshClose(name)",
741
+ detail: "returns { ok, closed, wasOpen }",
742
+ run(ctx, name) {
743
+ const found = sshTarget(name, "sshClose");
744
+ if (ctx.dryRun) { ctx.out(` 🔌 sshClose(${name}) → would send -O exit`); return { ok: true, target: found.name, closed: true, dryRun: true }; }
745
+ const r = sshCloseMaster(found);
746
+ ctx.out(r.ok ? ` 🔌 sshClose(${name}) → ${r.wasOpen ? "closed" : "was not connected"}` : ` ✗ sshClose(${name}) → ${r.error}`);
747
+ return r;
748
+ },
749
+ },
750
+ {
751
+ name: "sshPut",
752
+ summary: "copy a local file to a target, atomically",
753
+ usage: "sshPut(name, local, remote)",
754
+ detail: "returns { ok, remote }; scp to a temp path over the shared connection, then rename",
755
+ run(ctx, name, local, remote) {
756
+ const found = sshTarget(name, "sshPut");
757
+ if (!local || !remote) throw new Error("moshscript: sshPut(name, local, remote) needs both paths");
758
+ if (ctx.dryRun) { ctx.out(` 📤 sshPut(${name}) → would copy ${local} to ${remote}`); return { ok: true, target: found.name, dryRun: true }; }
759
+ const r = sshPutFile(found, String(local), String(remote));
760
+ ctx.out(r.ok ? ` 📤 sshPut(${name}) → ${r.remote}` : ` ✗ sshPut(${name}) → ${r.error}`);
761
+ return r;
762
+ },
763
+ },
764
+ {
765
+ name: "sshGet",
766
+ summary: "copy a file down from a target",
767
+ usage: "sshGet(name, remote, local)",
768
+ detail: "returns { ok, local }",
769
+ run(ctx, name, remote, local) {
770
+ const found = sshTarget(name, "sshGet");
771
+ if (!local || !remote) throw new Error("moshscript: sshGet(name, remote, local) needs both paths");
772
+ if (ctx.dryRun) { ctx.out(` 📥 sshGet(${name}) → would copy ${remote} to ${local}`); return { ok: true, target: found.name, dryRun: true }; }
773
+ const r = sshGetFile(found, String(remote), String(local));
774
+ ctx.out(r.ok ? ` 📥 sshGet(${name}) → ${r.local}` : ` ✗ sshGet(${name}) → ${r.error}`);
775
+ return r;
776
+ },
777
+ },
778
+ {
779
+ name: "sshShellSend",
780
+ summary: "type a line into a persistent remote shell",
781
+ usage: 'sshShellSend("dev/app", text)',
782
+ detail: "returns { ok }; literal text then Enter, into the remote tmux session",
783
+ run(ctx, ref, ...words) {
784
+ const { found, session } = sshShellRef(ref, "sshShellSend");
785
+ const text = words.join(" ");
786
+ if (!text) throw new Error("moshscript: sshShellSend(ref, text) needs text");
787
+ if (ctx.dryRun) { ctx.out(` 💬 sshShellSend(${ref}) → would send: ${text}`); return { ok: true, dryRun: true }; }
788
+ const r = sshShellSendRemote(found, session, text);
789
+ if (!r.ok) ctx.out(` ✗ sshShellSend(${ref}) → ${r.error}`);
790
+ return r;
791
+ },
792
+ },
793
+ {
794
+ name: "sshShellRead",
795
+ summary: "the screen of a persistent remote shell, as a string",
796
+ usage: 'sshShellRead("dev/app", { lines })',
797
+ detail: "returns the text (empty on failure); the same capture-pane the CLI's read prints",
798
+ run(ctx, ref, opts = {}) {
799
+ const { found, session } = sshShellRef(ref, "sshShellRead");
800
+ if (ctx.dryRun) { ctx.out(` 📖 sshShellRead(${ref}) → would capture the pane`); return ""; }
801
+ const r = sshShellReadRemote(found, session, { lines: opts.lines });
802
+ if (!r.ok) { ctx.out(` ✗ sshShellRead(${ref}) → ${r.error}`); return ""; }
803
+ return r.screen;
804
+ },
805
+ },
806
+ {
807
+ name: "sshShellKill",
808
+ summary: "end a persistent remote shell",
809
+ usage: 'sshShellKill("dev/app")',
810
+ detail: "returns { ok }",
811
+ run(ctx, ref) {
812
+ const { found, session } = sshShellRef(ref, "sshShellKill");
813
+ if (ctx.dryRun) { ctx.out(` ☠ sshShellKill(${ref}) → would kill the session`); return { ok: true, dryRun: true }; }
814
+ const r = sshShellKillRemote(found, session);
815
+ if (!r.ok) ctx.out(` ✗ sshShellKill(${ref}) → ${r.error}`);
816
+ return r;
817
+ },
818
+ },
819
+
657
820
  // CLI verbs — each is `moshcode <name> ...args`. This is the whole point:
658
821
  // scripting the CLI. Add a capability by adding a line here.
659
822
  //
@@ -695,6 +858,7 @@ const COMMANDS = [
695
858
  cliVerb("elevenlabs", "drive the ElevenLabs CLI (Eleven Agents, voices, TTS, dubbing)"),
696
859
  cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
697
860
  cliVerb("pwd", "print the current repo/location"),
861
+ cliVerb("ssh", "persistent SSH workspaces (moshcode ssh <verb>) — see sshExec/sshOpen for values"),
698
862
 
699
863
  // Research and feeds. The *Read() verbs above return the data; these are the
700
864
  // rendered CLI, for when a script wants the table on the operator's screen.
package/src/engines.mjs CHANGED
@@ -37,6 +37,7 @@ import { homedir } from "node:os";
37
37
  import path from "node:path";
38
38
 
39
39
  import { setActiveChildInput } from "./mirror.mjs";
40
+ import { throttleSpec } from "./nice.mjs";
40
41
  import { captureSpec } from "./pty.mjs";
41
42
 
42
43
  export const ENGINES = {
@@ -338,7 +339,22 @@ function nodeShebang(file) {
338
339
  }
339
340
  }
340
341
 
342
+ /**
343
+ * Where every CLI the pit starts is turned into a spawnable command.
344
+ *
345
+ * Both launch paths go through here — `runCmd` for installers and updaters,
346
+ * `openPassthrough` for the engines themselves — which makes it the one place
347
+ * the resource throttle has to be applied to cover all of them. It wraps
348
+ * *outside* resolution on purpose: `nice` needs a real executable to hand off
349
+ * to, and an unresolved binary must still produce its own ENOENT rather than
350
+ * one from a wrapper that obscures which program was actually missing.
351
+ * `/nice off` (the default) returns the spec untouched.
352
+ */
341
353
  function spawnSpec(bin, args = [], extraDirs = []) {
354
+ return throttleSpec(resolveSpec(bin, args, extraDirs));
355
+ }
356
+
357
+ function resolveSpec(bin, args = [], extraDirs = []) {
342
358
  const resolved = resolveExecutable(bin, extraDirs);
343
359
  // Unresolved, so hand the spawn the preferred name and let it produce the
344
360
  // ENOENT — a list would be spawned as a single nonsense filename.
package/src/nice.mjs ADDED
@@ -0,0 +1,199 @@
1
+ // Run the CLIs the pit launches at a lower priority than everything else.
2
+ //
3
+ // The pit's whole job is starting other people's programs, and some of them are
4
+ // not shy: a coding engine holding a big context, a bundler, a browser under
5
+ // test. Start a few at once on a box you also want to type on and you get the
6
+ // failure everyone knows — the machine stops answering. Nothing crashed. Every
7
+ // core is busy, the last of the RAM went to swap, and the swap went to disk.
8
+ //
9
+ // `nice` is the classic answer to that and it is *half* of one. It reorders CPU
10
+ // and nothing else, so it fixes the part of the freeze you can wait out and not
11
+ // the part that kills a process. The stall that actually costs you an afternoon
12
+ // is memory: once free RAM runs out the kernel starts reclaiming, reclaim goes
13
+ // to disk, and no scheduling priority on earth makes that faster. So a throttle
14
+ // worth the name has to cover three resources, not one:
15
+ //
16
+ // CPU nice -n 10 the engine yields to whatever you are typing in
17
+ // I/O ionice -c 2 -n 7 its reads stop starving the rest of the box
18
+ // memory systemd-run scope a ceiling, so a runaway dies alone
19
+ //
20
+ // Only the first two are free. A memory ceiling needs a cgroup, which on a
21
+ // normal login means a systemd user session, which not every box has — an ssh
22
+ // login without lingering enabled is the common way to not have one. So the
23
+ // memory cap is opt-in (`/nice mem 2G`) rather than default: a throttle that
24
+ // refuses to launch anything on a box without systemd would be worse than no
25
+ // throttle at all. CPU and I/O work everywhere that has the binaries, and
26
+ // degrade to "no wrapper" where they don't.
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import { spawnSync } from "node:child_process";
31
+
32
+ /** Same 0600 as aliases and history: this file records how you run things. */
33
+ const FILE_MODE = 0o600;
34
+
35
+ /**
36
+ * `nice -n 10` and `ionice -c 2 -n 7` — deliberately not the extremes.
37
+ *
38
+ * nice 19 and ionice class 3 (idle) both mean "run only when nothing else
39
+ * wants the machine", which sounds right and is not: an engine that yields
40
+ * *completely* can take minutes to answer while a single background job holds
41
+ * the box, and a coding CLI that never finishes reads as broken rather than as
42
+ * polite. 10 and 7 are "last in line among normal work", which is the actual
43
+ * intent — you keep your terminal, the engine keeps making progress.
44
+ */
45
+ export const DEFAULTS = Object.freeze({ cpu: 10, io: 7, memoryMax: "", memoryHigh: "" });
46
+
47
+ /** A memory size systemd would accept: 512M, 2G, 1500K, or a byte count. */
48
+ const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i;
49
+
50
+ /** Where the throttle setting lives. Derived per call so tests can move $HOME. */
51
+ export function niceFile() {
52
+ return path.join(os.homedir(), ".moshcode", "nice.json");
53
+ }
54
+
55
+ /**
56
+ * The current settings, always a complete object.
57
+ *
58
+ * Read on the spawn path for every CLI the pit starts, so a missing,
59
+ * unreadable, or hand-mangled file has to read as "throttle off" rather than
60
+ * throw. A file that says something we don't recognise loses only the field it
61
+ * got wrong: the point of this object is to decide how to launch a program, and
62
+ * one bad key must not stop the program launching.
63
+ */
64
+ export function loadNice() {
65
+ let parsed;
66
+ try { parsed = JSON.parse(fs.readFileSync(niceFile(), "utf8")); }
67
+ catch { return { on: false, ...DEFAULTS }; }
68
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { on: false, ...DEFAULTS };
69
+ const num = (v, fallback) => (Number.isInteger(v) ? v : fallback);
70
+ const mem = (v) => (typeof v === "string" && MEM_RE.test(v.trim()) ? v.trim().toUpperCase() : "");
71
+ return {
72
+ on: parsed.on === true,
73
+ cpu: clampCpu(num(parsed.cpu, DEFAULTS.cpu)),
74
+ io: clampIo(num(parsed.io, DEFAULTS.io)),
75
+ memoryMax: mem(parsed.memoryMax),
76
+ memoryHigh: mem(parsed.memoryHigh),
77
+ };
78
+ }
79
+
80
+ /** nice(1) accepts -20..19; anything else is a typo, not an intention. */
81
+ export function clampCpu(n) { return Math.min(19, Math.max(-20, n)); }
82
+ /** ionice best-effort levels are 0..7. */
83
+ export function clampIo(n) { return Math.min(7, Math.max(0, n)); }
84
+
85
+ /** Persist settings, creating ~/.moshcode on first use. */
86
+ export function saveNice(settings) {
87
+ const file = niceFile();
88
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
89
+ const body = {
90
+ on: settings.on === true,
91
+ cpu: clampCpu(Number.isInteger(settings.cpu) ? settings.cpu : DEFAULTS.cpu),
92
+ io: clampIo(Number.isInteger(settings.io) ? settings.io : DEFAULTS.io),
93
+ memoryMax: settings.memoryMax || "",
94
+ memoryHigh: settings.memoryHigh || "",
95
+ };
96
+ fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { mode: FILE_MODE });
97
+ // `mode` only applies at creation; tighten every write the way aliases does.
98
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
99
+ return body;
100
+ }
101
+
102
+ /** Is `bin` runnable here? Cached, because this is asked on every spawn. */
103
+ const lookupCache = new Map();
104
+ export function haveBin(bin, { spawn = spawnSync } = {}) {
105
+ if (lookupCache.has(bin)) return lookupCache.get(bin);
106
+ let found = false;
107
+ try { found = spawn("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0; }
108
+ catch { found = false; }
109
+ lookupCache.set(bin, found);
110
+ return found;
111
+ }
112
+
113
+ /** Only used by tests, which need to ask about a box they are pretending about. */
114
+ export function resetBinCache() { lookupCache.clear(); }
115
+
116
+ /** Is a memory ceiling actually enforceable here? Needs a systemd user cgroup. */
117
+ export function canCapMemory({ has = haveBin, env = process.env } = {}) {
118
+ if (!has("systemd-run")) return false;
119
+ // --user needs a session bus to place the scope in. Over ssh without
120
+ // lingering there is none, and systemd-run fails rather than degrading —
121
+ // which would take the engine down with it. Check before, not after.
122
+ return Boolean(env.XDG_RUNTIME_DIR || env.DBUS_SESSION_BUS_ADDRESS);
123
+ }
124
+
125
+ /**
126
+ * Wrap a spawn spec so the child runs throttled. Returns a new spec plus a
127
+ * short `how` describing what was actually applied.
128
+ *
129
+ * Everything here is best-effort by design: each wrapper is added only if its
130
+ * binary exists, and a box with none of them gets the original spec back. The
131
+ * alternative — refusing to launch, or launching a command line referencing a
132
+ * binary that isn't there — turns a comfort feature into an outage.
133
+ *
134
+ * Windows has no nice/ionice/cgroups in this form, so it is a no-op there
135
+ * rather than a wrong guess.
136
+ */
137
+ export function throttleSpec(spec, {
138
+ settings = loadNice(),
139
+ has = haveBin,
140
+ env = process.env,
141
+ platform = process.platform,
142
+ } = {}) {
143
+ const plain = { cmd: spec.cmd, args: spec.args ?? [], throttled: false, how: "" };
144
+ if (!settings.on || platform === "win32") return plain;
145
+
146
+ let cmd = spec.cmd;
147
+ let args = [...(spec.args ?? [])];
148
+ const how = [];
149
+
150
+ // Innermost first: each wrapper below prepends, so build outward.
151
+ if (has("ionice")) {
152
+ args = ["-c", "2", "-n", String(settings.io), cmd, ...args];
153
+ cmd = "ionice";
154
+ how.push(`ionice -c2 -n${settings.io}`);
155
+ }
156
+ if (has("nice")) {
157
+ args = ["-n", String(settings.cpu), cmd, ...args];
158
+ cmd = "nice";
159
+ how.push(`nice -n${settings.cpu}`);
160
+ }
161
+ // Outermost, so the scope contains the whole niced pipeline rather than
162
+ // sitting inside it — a cgroup only accounts for what it encloses.
163
+ const caps = [];
164
+ if (settings.memoryHigh) caps.push(`MemoryHigh=${settings.memoryHigh}`);
165
+ if (settings.memoryMax) caps.push(`MemoryMax=${settings.memoryMax}`);
166
+ if (caps.length && canCapMemory({ has, env })) {
167
+ const props = caps.flatMap((p) => ["-p", p]);
168
+ args = ["--user", "--scope", "--quiet", ...props, "--", cmd, ...args];
169
+ cmd = "systemd-run";
170
+ how.push(caps.join(" "));
171
+ }
172
+
173
+ if (cmd === spec.cmd) return plain;
174
+ return { cmd, args, throttled: true, how: how.join(" ") };
175
+ }
176
+
177
+ /** One line for `/nice` with no arguments. */
178
+ export function describeNice(settings = loadNice(), opts = {}) {
179
+ if (!settings.on) return "throttle is off — CLIs run at normal priority";
180
+ const parts = [`nice -n${settings.cpu}`, `ionice -c2 -n${settings.io}`];
181
+ if (settings.memoryMax || settings.memoryHigh) {
182
+ const caps = [
183
+ settings.memoryHigh ? `MemoryHigh=${settings.memoryHigh}` : "",
184
+ settings.memoryMax ? `MemoryMax=${settings.memoryMax}` : "",
185
+ ].filter(Boolean).join(" ");
186
+ parts.push(canCapMemory(opts) ? caps : `${caps} (no systemd user session here — not applied)`);
187
+ }
188
+ return `throttle is on — ${parts.join(", ")}`;
189
+ }
190
+
191
+ /** Validate a memory size the way the command wants to report it. */
192
+ export function parseMemory(value) {
193
+ const clean = String(value ?? "").trim().toUpperCase();
194
+ if (!clean || clean === "OFF" || clean === "NONE") return { ok: true, value: "" };
195
+ if (!MEM_RE.test(clean)) {
196
+ return { ok: false, error: `"${value}" isn't a memory size — try 2G, 1500M, or off` };
197
+ }
198
+ return { ok: true, value: clean };
199
+ }