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/README.md +124 -0
- package/bin/moshcode.mjs +7 -0
- package/package.json +1 -1
- package/prd/0013-persistent-ssh-workspaces.md +1181 -0
- package/prd/README.md +2 -0
- package/src/cli-schema.mjs +123 -0
- package/src/commands.mjs +164 -0
- package/src/engines.mjs +16 -0
- package/src/nice.mjs +199 -0
- package/src/ssh.mjs +1228 -0
- package/src/tui.mjs +73 -0
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 -->
|
package/src/cli-schema.mjs
CHANGED
|
@@ -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
|
+
}
|