moshcode 0.91.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/README.md CHANGED
@@ -1010,6 +1010,39 @@ an equity's score, and each response ships the `caveats` that say so. Prices are
1010
1010
  Alpaca's US venue alone and can differ materially from other exchanges. Research
1011
1011
  aid, not advice — and like `stocks`, nothing under `crypto` can place an order.
1012
1012
 
1013
+ ### Throttle (`/nice`)
1014
+
1015
+ The pit's job is starting other people's programs, and some of them are not shy.
1016
+ Run a few engines at once on a box you also want to type on and you get the
1017
+ failure everyone knows: nothing crashed, but the machine stops answering.
1018
+
1019
+ ```text
1020
+ /nice on # nice -n10 + ionice -c2 -n7 for every engine started after
1021
+ /nice mem 2G # a ceiling, so a runaway dies alone
1022
+ /nice cpu 15 # yield more (nice takes -20..19)
1023
+ /nice # what it is set to
1024
+ /nice off # back to normal priority (the default)
1025
+ ```
1026
+
1027
+ It is off by default — a throttle nobody asked for is a slow engine nobody can
1028
+ explain — and it applies to engines started *after* you turn it on.
1029
+
1030
+ **`nice` alone is half a fix, and it is worth knowing which half.** It reorders
1031
+ CPU, so it buys back the part of a freeze you could have waited out. It does
1032
+ nothing about memory, and memory is the stall that actually costs you a session:
1033
+ once free RAM runs out the kernel reclaims, reclaim goes to disk, and no
1034
+ scheduling priority makes that faster. That is why `/nice mem` exists — it puts
1035
+ the engine in a systemd scope with a hard ceiling, so the one runaway process
1036
+ gets killed instead of the whole box going unresponsive.
1037
+
1038
+ The ceiling is the one setting that can silently not apply: `systemd-run --user`
1039
+ needs a systemd user session, and an ssh login without lingering has none.
1040
+ `/nice` says so in its status line rather than pretending, and the CPU and I/O
1041
+ halves still work. On a box with no `nice` or `ionice` at all, and on Windows,
1042
+ the whole thing is a no-op — engines spawn exactly as they did before.
1043
+
1044
+ Settings live in `~/.moshcode/nice.json`, owner-only like the history file.
1045
+
1013
1046
  ### Aliases (`/alias`)
1014
1047
 
1015
1048
  The pit is a prompt you sit at all day, so it lets you name the lines you keep
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.91.0",
3
+ "version": "0.92.0",
4
4
  "type": "module",
5
5
  "description": "moshcode \u2014 a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -1596,6 +1596,20 @@ export const PIT_COMMANDS = [
1596
1596
  description: "show the current dir + git repo/branch/origin" },
1597
1597
  { name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
1598
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"] },
1599
1613
  { name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm | install <tool>', pitOnly: true,
1600
1614
  description: "name a line you keep retyping; /<name> runs it",
1601
1615
  synopsis: [
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
+ }
package/src/tui.mjs CHANGED
@@ -22,6 +22,7 @@ import { activeChildInput, createMirror, pressKey, setActiveSink, teeOutput } fr
22
22
  import { fetchMotdAd } from "./ads.mjs";
23
23
  import { runScript } from "./runtime.mjs";
24
24
  import { moshVocabulary } from "./commands.mjs";
25
+ import { loadNice, saveNice, describeNice, canCapMemory, parseMemory } from "./nice.mjs";
25
26
  import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs";
26
27
  import { stocksCommand } from "./advisor.mjs";
27
28
  import { cryptoCommand } from "./crypto.mjs";
@@ -388,6 +389,63 @@ function isInstalledTool(key) {
388
389
  * line, not an argument list: re-joining tokens would drop the quoting that the
389
390
  * shell still has to read.
390
391
  */
392
+ /**
393
+ * `/nice` — run the CLIs the pit starts at a lower priority than your terminal.
394
+ *
395
+ * A toggle rather than a per-launch flag because the thing being tuned is the
396
+ * box, not the command: you decide once that this machine is shared, and every
397
+ * engine started afterwards honours it. Off by default — a throttle nobody
398
+ * asked for is a slow engine nobody can explain.
399
+ */
400
+ function niceCommand(rest) {
401
+ const [verb, ...args] = rest.filter((a) => a !== "--json");
402
+ const sub = String(verb ?? "").toLowerCase();
403
+ const json = rest.includes("--json");
404
+ const settings = loadNice();
405
+
406
+ if (json) { console.log(JSON.stringify(settings, null, 2)); return; }
407
+
408
+ if (!verb || sub === "status") {
409
+ console.log(info(describeNice(settings)));
410
+ if (!settings.on) console.log(ash(` ${acid("/nice on")} throttles CPU and I/O for every engine the pit starts`));
411
+ else if (!settings.memoryMax) console.log(ash(` ${acid("/nice mem 2G")} adds a memory ceiling — the part nice(1) can't do`));
412
+ return;
413
+ }
414
+
415
+ if (sub === "on" || sub === "off") {
416
+ const saved = saveNice({ ...settings, on: sub === "on" });
417
+ console.log(ok(describeNice(saved)));
418
+ if (saved.on) console.log(ash(" applies to engines started from here on; anything already running keeps its priority"));
419
+ return;
420
+ }
421
+
422
+ if (sub === "cpu" || sub === "io") {
423
+ const n = Number(args[0]);
424
+ if (!Number.isInteger(n)) {
425
+ console.log(err(`usage: /nice ${sub} <number>${sub === "cpu" ? " (-20..19, higher = yields more)" : " (0..7, higher = yields more)"}`));
426
+ return;
427
+ }
428
+ const saved = saveNice({ ...settings, [sub]: n });
429
+ console.log(ok(describeNice(saved)));
430
+ return;
431
+ }
432
+
433
+ if (sub === "mem" || sub === "memory") {
434
+ const parsed = parseMemory(args[0]);
435
+ if (!parsed.ok) { console.log(err(parsed.error)); return; }
436
+ const saved = saveNice({ ...settings, memoryMax: parsed.value });
437
+ console.log(ok(describeNice(saved)));
438
+ // Worth saying plainly: this is the one setting that can silently not apply.
439
+ if (parsed.value && !canCapMemory()) {
440
+ console.log(ash(" no systemd user session on this box, so the ceiling is recorded but not enforced"));
441
+ console.log(ash(` ${acid("loginctl enable-linger $USER")} gives this login one`));
442
+ }
443
+ return;
444
+ }
445
+
446
+ console.log(err(`/nice ${sub} isn't a thing — try on, off, cpu <n>, io <n>, or mem <size>`));
447
+ }
448
+
391
449
  function aliasCommand(rest, line) {
392
450
  const json = rest.includes("--json");
393
451
  // `--json` is the listing's flag wherever it appears, so `/alias --json` is a
@@ -936,6 +994,7 @@ export async function tui() {
936
994
  continue;
937
995
  }
938
996
  if (cmd === "alias" || cmd === "aliases") { aliasCommand(rest, line); continue; }
997
+ if (cmd === "nice" || cmd === "throttle") { niceCommand(rest); continue; }
939
998
  if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
940
999
  if (cmd === "login") {
941
1000
  const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");