moshcode 0.90.0 → 0.91.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",
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.