moshcode 0.91.0 → 0.93.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,58 @@ 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 agents claude # throttle this one engine
1021
+ /nice pnpm -r build # …or this one shell line
1022
+ /nice merge # …or any other pit command
1023
+
1024
+ /nice on # or throttle everything from here on
1025
+ /nice mem 2G # a ceiling, so a runaway dies alone
1026
+ /nice cpu 15 # yield more (nice takes -20..19)
1027
+ /nice # what it is set to
1028
+ /nice off # back to normal priority (the default)
1029
+ ```
1030
+
1031
+ `/nice <line>` is the form to reach for. It runs **anything the pit can already
1032
+ run** — a pit command, an engine, a tool, one of your aliases, or a bare shell
1033
+ line — at low priority, without changing any setting. It works on all of those
1034
+ because it hands the rest of the line back to the top of the dispatcher exactly
1035
+ as an alias expansion does, rather than keeping a list of its own that would
1036
+ drift. The leading slash is optional: `/nice agents claude` and
1037
+ `/nice /agents claude` are the same line.
1038
+
1039
+ The throttle lasts exactly as long as the line that asked for it, including
1040
+ through an alias that expands into something else. The next thing you type is
1041
+ back to normal.
1042
+
1043
+ `/nice on` is the other half, for when the box is shared all day and you would
1044
+ rather decide once. It is off by default — a throttle nobody asked for is a slow
1045
+ engine nobody can explain — and it applies to engines started *after* you turn
1046
+ it on. The settings words (`on`, `off`, `status`, `cpu`, `io`, `mem`) win the
1047
+ first position, so a program named `on` is not reachable through `/nice`.
1048
+
1049
+ **`nice` alone is half a fix, and it is worth knowing which half.** It reorders
1050
+ CPU, so it buys back the part of a freeze you could have waited out. It does
1051
+ nothing about memory, and memory is the stall that actually costs you a session:
1052
+ once free RAM runs out the kernel reclaims, reclaim goes to disk, and no
1053
+ scheduling priority makes that faster. That is why `/nice mem` exists — it puts
1054
+ the engine in a systemd scope with a hard ceiling, so the one runaway process
1055
+ gets killed instead of the whole box going unresponsive.
1056
+
1057
+ The ceiling is the one setting that can silently not apply: `systemd-run --user`
1058
+ needs a systemd user session, and an ssh login without lingering has none.
1059
+ `/nice` says so in its status line rather than pretending, and the CPU and I/O
1060
+ halves still work. On a box with no `nice` or `ionice` at all, and on Windows,
1061
+ the whole thing is a no-op — engines spawn exactly as they did before.
1062
+
1063
+ Settings live in `~/.moshcode/nice.json`, owner-only like the history file.
1064
+
1013
1065
  ### Aliases (`/alias`)
1014
1066
 
1015
1067
  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.93.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,23 @@ 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: "<command> | on | off | cpu <n> | io <n> | mem <size>", pitOnly: true,
1600
+ description: "run something at low priority so the box stays usable",
1601
+ synopsis: [
1602
+ ["/nice <command> [args]", "run one line throttled — a pit command, engine, tool, alias or shell line"],
1603
+ ["/nice [status]", "what the throttle is set to"],
1604
+ ["/nice on | off", "throttle everything from here on (off by default)"],
1605
+ ["/nice cpu <-20..19>", "nice level — higher yields more CPU"],
1606
+ ["/nice io <0..7>", "ionice best-effort level"],
1607
+ ["/nice mem <size> | off", "memory ceiling per engine (needs systemd)"],
1608
+ ],
1609
+ examples: [
1610
+ ["/nice agents claude", "throttle this engine, leave the setting alone"],
1611
+ ["/nice pnpm -r build", "a shell line works too"],
1612
+ ["/nice on", "nice -n10 + ionice -c2 -n7 for every engine started after"],
1613
+ ["/nice mem 2G", "the ceiling nice(1) can't give you — a runaway dies alone"],
1614
+ ],
1615
+ seeAlso: ["agents", "start", "alias"] },
1599
1616
  { name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm | install <tool>', pitOnly: true,
1600
1617
  description: "name a line you keep retyping; /<name> runs it",
1601
1618
  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,235 @@
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
+ * `/nice <line>` throttles one line without changing the saved setting.
57
+ *
58
+ * Module-level rather than threaded through every call because the thing being
59
+ * throttled is not a function argument -- it is whatever that line eventually
60
+ * spawns, which may be an alias that expands to a pit command that starts an
61
+ * engine, three dispatch rounds later. The pit reads one line at a time and
62
+ * fully awaits it, so "armed until the next line the user types" is both the
63
+ * simplest implementation and exactly the intent.
64
+ */
65
+ let oneShot = false;
66
+
67
+ /** Throttle whatever the current line ends up spawning. */
68
+ export function armOneShot() { oneShot = true; }
69
+
70
+ /**
71
+ * Stop throttling. The pit calls this when it reads a fresh line, NOT when a
72
+ * command finishes: one typed line can dispatch several times through alias
73
+ * expansion, and all of it is the line the user asked to be nice.
74
+ */
75
+ export function disarmOneShot() { oneShot = false; }
76
+
77
+ /** Is a one-shot throttle in force? */
78
+ export function oneShotArmed() { return oneShot; }
79
+
80
+ /**
81
+ * The settings a spawn should actually use: what is saved, plus a one-shot.
82
+ *
83
+ * `/nice on` and `/nice <cmd>` end in the same place by design -- a spawn does
84
+ * not need to know which of the two asked for it.
85
+ */
86
+ export function effectiveNice() {
87
+ const saved = loadNice();
88
+ return oneShot ? { ...saved, on: true } : saved;
89
+ }
90
+
91
+ /**
92
+ * The current settings, always a complete object.
93
+ *
94
+ * Read on the spawn path for every CLI the pit starts, so a missing,
95
+ * unreadable, or hand-mangled file has to read as "throttle off" rather than
96
+ * throw. A file that says something we don't recognise loses only the field it
97
+ * got wrong: the point of this object is to decide how to launch a program, and
98
+ * one bad key must not stop the program launching.
99
+ */
100
+ export function loadNice() {
101
+ let parsed;
102
+ try { parsed = JSON.parse(fs.readFileSync(niceFile(), "utf8")); }
103
+ catch { return { on: false, ...DEFAULTS }; }
104
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { on: false, ...DEFAULTS };
105
+ const num = (v, fallback) => (Number.isInteger(v) ? v : fallback);
106
+ const mem = (v) => (typeof v === "string" && MEM_RE.test(v.trim()) ? v.trim().toUpperCase() : "");
107
+ return {
108
+ on: parsed.on === true,
109
+ cpu: clampCpu(num(parsed.cpu, DEFAULTS.cpu)),
110
+ io: clampIo(num(parsed.io, DEFAULTS.io)),
111
+ memoryMax: mem(parsed.memoryMax),
112
+ memoryHigh: mem(parsed.memoryHigh),
113
+ };
114
+ }
115
+
116
+ /** nice(1) accepts -20..19; anything else is a typo, not an intention. */
117
+ export function clampCpu(n) { return Math.min(19, Math.max(-20, n)); }
118
+ /** ionice best-effort levels are 0..7. */
119
+ export function clampIo(n) { return Math.min(7, Math.max(0, n)); }
120
+
121
+ /** Persist settings, creating ~/.moshcode on first use. */
122
+ export function saveNice(settings) {
123
+ const file = niceFile();
124
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
125
+ const body = {
126
+ on: settings.on === true,
127
+ cpu: clampCpu(Number.isInteger(settings.cpu) ? settings.cpu : DEFAULTS.cpu),
128
+ io: clampIo(Number.isInteger(settings.io) ? settings.io : DEFAULTS.io),
129
+ memoryMax: settings.memoryMax || "",
130
+ memoryHigh: settings.memoryHigh || "",
131
+ };
132
+ fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { mode: FILE_MODE });
133
+ // `mode` only applies at creation; tighten every write the way aliases does.
134
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
135
+ return body;
136
+ }
137
+
138
+ /** Is `bin` runnable here? Cached, because this is asked on every spawn. */
139
+ const lookupCache = new Map();
140
+ export function haveBin(bin, { spawn = spawnSync } = {}) {
141
+ if (lookupCache.has(bin)) return lookupCache.get(bin);
142
+ let found = false;
143
+ try { found = spawn("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0; }
144
+ catch { found = false; }
145
+ lookupCache.set(bin, found);
146
+ return found;
147
+ }
148
+
149
+ /** Only used by tests, which need to ask about a box they are pretending about. */
150
+ export function resetBinCache() { lookupCache.clear(); }
151
+
152
+ /** Is a memory ceiling actually enforceable here? Needs a systemd user cgroup. */
153
+ export function canCapMemory({ has = haveBin, env = process.env } = {}) {
154
+ if (!has("systemd-run")) return false;
155
+ // --user needs a session bus to place the scope in. Over ssh without
156
+ // lingering there is none, and systemd-run fails rather than degrading —
157
+ // which would take the engine down with it. Check before, not after.
158
+ return Boolean(env.XDG_RUNTIME_DIR || env.DBUS_SESSION_BUS_ADDRESS);
159
+ }
160
+
161
+ /**
162
+ * Wrap a spawn spec so the child runs throttled. Returns a new spec plus a
163
+ * short `how` describing what was actually applied.
164
+ *
165
+ * Everything here is best-effort by design: each wrapper is added only if its
166
+ * binary exists, and a box with none of them gets the original spec back. The
167
+ * alternative — refusing to launch, or launching a command line referencing a
168
+ * binary that isn't there — turns a comfort feature into an outage.
169
+ *
170
+ * Windows has no nice/ionice/cgroups in this form, so it is a no-op there
171
+ * rather than a wrong guess.
172
+ */
173
+ export function throttleSpec(spec, {
174
+ settings = effectiveNice(),
175
+ has = haveBin,
176
+ env = process.env,
177
+ platform = process.platform,
178
+ } = {}) {
179
+ const plain = { cmd: spec.cmd, args: spec.args ?? [], throttled: false, how: "" };
180
+ if (!settings.on || platform === "win32") return plain;
181
+
182
+ let cmd = spec.cmd;
183
+ let args = [...(spec.args ?? [])];
184
+ const how = [];
185
+
186
+ // Innermost first: each wrapper below prepends, so build outward.
187
+ if (has("ionice")) {
188
+ args = ["-c", "2", "-n", String(settings.io), cmd, ...args];
189
+ cmd = "ionice";
190
+ how.push(`ionice -c2 -n${settings.io}`);
191
+ }
192
+ if (has("nice")) {
193
+ args = ["-n", String(settings.cpu), cmd, ...args];
194
+ cmd = "nice";
195
+ how.push(`nice -n${settings.cpu}`);
196
+ }
197
+ // Outermost, so the scope contains the whole niced pipeline rather than
198
+ // sitting inside it — a cgroup only accounts for what it encloses.
199
+ const caps = [];
200
+ if (settings.memoryHigh) caps.push(`MemoryHigh=${settings.memoryHigh}`);
201
+ if (settings.memoryMax) caps.push(`MemoryMax=${settings.memoryMax}`);
202
+ if (caps.length && canCapMemory({ has, env })) {
203
+ const props = caps.flatMap((p) => ["-p", p]);
204
+ args = ["--user", "--scope", "--quiet", ...props, "--", cmd, ...args];
205
+ cmd = "systemd-run";
206
+ how.push(caps.join(" "));
207
+ }
208
+
209
+ if (cmd === spec.cmd) return plain;
210
+ return { cmd, args, throttled: true, how: how.join(" ") };
211
+ }
212
+
213
+ /** One line for `/nice` with no arguments. */
214
+ export function describeNice(settings = loadNice(), opts = {}) {
215
+ if (!settings.on) return "throttle is off — CLIs run at normal priority";
216
+ const parts = [`nice -n${settings.cpu}`, `ionice -c2 -n${settings.io}`];
217
+ if (settings.memoryMax || settings.memoryHigh) {
218
+ const caps = [
219
+ settings.memoryHigh ? `MemoryHigh=${settings.memoryHigh}` : "",
220
+ settings.memoryMax ? `MemoryMax=${settings.memoryMax}` : "",
221
+ ].filter(Boolean).join(" ");
222
+ parts.push(canCapMemory(opts) ? caps : `${caps} (no systemd user session here — not applied)`);
223
+ }
224
+ return `throttle is on — ${parts.join(", ")}`;
225
+ }
226
+
227
+ /** Validate a memory size the way the command wants to report it. */
228
+ export function parseMemory(value) {
229
+ const clean = String(value ?? "").trim().toUpperCase();
230
+ if (!clean || clean === "OFF" || clean === "NONE") return { ok: true, value: "" };
231
+ if (!MEM_RE.test(clean)) {
232
+ return { ok: false, error: `"${value}" isn't a memory size — try 2G, 1500M, or off` };
233
+ }
234
+ return { ok: true, value: clean };
235
+ }
package/src/tui.mjs CHANGED
@@ -22,6 +22,10 @@ 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 {
26
+ loadNice, saveNice, describeNice, canCapMemory, parseMemory,
27
+ armOneShot, disarmOneShot,
28
+ } from "./nice.mjs";
25
29
  import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs";
26
30
  import { stocksCommand } from "./advisor.mjs";
27
31
  import { cryptoCommand } from "./crypto.mjs";
@@ -388,6 +392,73 @@ function isInstalledTool(key) {
388
392
  * line, not an argument list: re-joining tokens would drop the quoting that the
389
393
  * shell still has to read.
390
394
  */
395
+ /**
396
+ * The words that mean "configure the throttle" rather than "run this".
397
+ *
398
+ * Kept next to the command that implements them so the two cannot drift: every
399
+ * other first word after `/nice` is a line to run, so adding a verb here
400
+ * without teaching niceCommand about it would make that word silently
401
+ * unrunnable rather than produce an error.
402
+ */
403
+ const NICE_SETTING_VERBS = new Set(["on", "off", "status", "cpu", "io", "mem", "memory"]);
404
+
405
+ /**
406
+ * `/nice` — run the CLIs the pit starts at a lower priority than your terminal.
407
+ *
408
+ * A toggle rather than a per-launch flag because the thing being tuned is the
409
+ * box, not the command: you decide once that this machine is shared, and every
410
+ * engine started afterwards honours it. Off by default — a throttle nobody
411
+ * asked for is a slow engine nobody can explain.
412
+ */
413
+ function niceCommand(rest) {
414
+ const [verb, ...args] = rest.filter((a) => a !== "--json");
415
+ const sub = String(verb ?? "").toLowerCase();
416
+ const json = rest.includes("--json");
417
+ const settings = loadNice();
418
+
419
+ if (json) { console.log(JSON.stringify(settings, null, 2)); return; }
420
+
421
+ if (!verb || sub === "status") {
422
+ console.log(info(describeNice(settings)));
423
+ if (!settings.on) console.log(ash(` ${acid("/nice on")} throttles CPU and I/O for every engine the pit starts`));
424
+ else if (!settings.memoryMax) console.log(ash(` ${acid("/nice mem 2G")} adds a memory ceiling — the part nice(1) can't do`));
425
+ return;
426
+ }
427
+
428
+ if (sub === "on" || sub === "off") {
429
+ const saved = saveNice({ ...settings, on: sub === "on" });
430
+ console.log(ok(describeNice(saved)));
431
+ if (saved.on) console.log(ash(" applies to engines started from here on; anything already running keeps its priority"));
432
+ return;
433
+ }
434
+
435
+ if (sub === "cpu" || sub === "io") {
436
+ const n = Number(args[0]);
437
+ if (!Number.isInteger(n)) {
438
+ console.log(err(`usage: /nice ${sub} <number>${sub === "cpu" ? " (-20..19, higher = yields more)" : " (0..7, higher = yields more)"}`));
439
+ return;
440
+ }
441
+ const saved = saveNice({ ...settings, [sub]: n });
442
+ console.log(ok(describeNice(saved)));
443
+ return;
444
+ }
445
+
446
+ if (sub === "mem" || sub === "memory") {
447
+ const parsed = parseMemory(args[0]);
448
+ if (!parsed.ok) { console.log(err(parsed.error)); return; }
449
+ const saved = saveNice({ ...settings, memoryMax: parsed.value });
450
+ console.log(ok(describeNice(saved)));
451
+ // Worth saying plainly: this is the one setting that can silently not apply.
452
+ if (parsed.value && !canCapMemory()) {
453
+ console.log(ash(" no systemd user session on this box, so the ceiling is recorded but not enforced"));
454
+ console.log(ash(` ${acid("loginctl enable-linger $USER")} gives this login one`));
455
+ }
456
+ return;
457
+ }
458
+
459
+ console.log(err(`/nice ${sub} isn't a thing — try on, off, cpu <n>, io <n>, or mem <size>`));
460
+ }
461
+
391
462
  function aliasCommand(rest, line) {
392
463
  const json = rest.includes("--json");
393
464
  // `--json` is the listing's flag wherever it appears, so `/alias --json` is a
@@ -871,6 +942,11 @@ export async function tui() {
871
942
  finally { atPrompt(null); }
872
943
  if (line == null) break; // Ctrl-D
873
944
  expansions = 0;
945
+ // A one-shot `/nice <line>` lasts exactly as long as the line that asked
946
+ // for it. Cleared here, where a REAL line is read, and not when a command
947
+ // returns: one typed line can dispatch several times through alias
948
+ // expansion, and all of that is still the line the user said to be nice.
949
+ disarmOneShot();
874
950
  line = line.trim();
875
951
  if (!line) continue;
876
952
  saveHistory(); // readline just recorded this line into the shared history
@@ -936,6 +1012,31 @@ export async function tui() {
936
1012
  continue;
937
1013
  }
938
1014
  if (cmd === "alias" || cmd === "aliases") { aliasCommand(rest, line); continue; }
1015
+ if (cmd === "nice" || cmd === "throttle") {
1016
+ // `/nice <line>` runs one line throttled without touching the setting --
1017
+ // the form anyone who has used nice(1) reaches for first. The settings
1018
+ // verbs win the name, so a command called `on` is unreachable this way;
1019
+ // that is the right trade for `/nice on` meaning what it obviously means.
1020
+ const sub = String(rest[0] ?? "").toLowerCase().replace(/^\//, "");
1021
+ // A leading flag (`/nice --json`) is asking about the throttle, not
1022
+ // naming a program: no line the pit runs starts with a dash.
1023
+ if (!rest.length || sub.startsWith("-") || NICE_SETTING_VERBS.has(sub)) {
1024
+ niceCommand(rest);
1025
+ continue;
1026
+ }
1027
+
1028
+ // Hand the remainder back to the top of this loop rather than dispatching
1029
+ // it here, exactly as alias expansion does. That is what makes `/nice`
1030
+ // work on anything the pit can already run -- a pit command, an engine, a
1031
+ // tool, an alias, or a bare shell line -- with no roster of its own to
1032
+ // drift out of date. The leading slash is optional because the dispatcher
1033
+ // strips one anyway, so `/nice agents claude` and `/nice /agents claude`
1034
+ // are the same line.
1035
+ armOneShot();
1036
+ pending = commandRemainder(line);
1037
+ console.log(ash(` ▸ throttled: ${pending}`));
1038
+ continue;
1039
+ }
939
1040
  if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
940
1041
  if (cmd === "login") {
941
1042
  const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");