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.
@@ -0,0 +1,1181 @@
1
+ ---
2
+ openprd: "0.2"
3
+ id: "0013"
4
+ title: "Add persistent SSH workspaces for humans and agents"
5
+ status: "Draft"
6
+ authors:
7
+ - "anthony@profullstack.com"
8
+ created: "2026-09-03"
9
+ updated: "2026-09-03"
10
+ repo: "https://github.com/moshcoder/moshcode"
11
+ discussion: ""
12
+ implementation: "src/ssh.mjs, src/cli-schema.mjs, src/commands.mjs, src/tui.mjs, bin/moshcode.mjs, test/ssh.test.mjs, test/ssh-sshd.test.mjs"
13
+ tags:
14
+ - ssh
15
+ - remote
16
+ - runtime
17
+ - agents
18
+ - chovy
19
+ - workspaces
20
+ supersedes: ""
21
+ superseded-by: ""
22
+ ---
23
+
24
+ # Add persistent SSH workspaces for humans and agents
25
+
26
+ ## Problem
27
+
28
+ MoshCode already has a strong persistent-local-runtime model:
29
+
30
+ - `herd` keeps local shells and agents alive in tmux or the PTY fallback.
31
+ - `herd prompt`, `read`, `wait`, and `--json` make those sessions controllable by another agent.
32
+ - `shell()` and the pit's shell execution path deliberately use the user's real shell.
33
+ - the project remains zero-dependency ESM and delegates terminal/process behavior to native tools instead of embedding a terminal implementation.
34
+
35
+ What it does **not** have is a first-class remote-shell/workspace abstraction.
36
+
37
+ Today a caller such as Chovy can invoke the system `ssh` command repeatedly, but if every file read, file edit, `git status`, test run, or inspection starts a brand-new SSH process with a brand-new transport, it repeatedly pays for:
38
+
39
+ - TCP setup;
40
+ - SSH negotiation;
41
+ - host-key negotiation;
42
+ - authentication;
43
+ - key-agent interaction;
44
+ - session setup;
45
+ - remote-shell startup.
46
+
47
+ That is unnecessary. SSH is explicitly capable of carrying many independent channels over one authenticated transport.
48
+
49
+ The immediate Chovy use case is concrete: an AI coding run may inspect and modify dozens or hundreds of files on one remote workspace. Chovy should not create a completely new authenticated SSH transport for every operation.
50
+
51
+ A naive `/ssh` command that merely does this:
52
+
53
+ ```sh
54
+ ssh user@host
55
+ ```
56
+
57
+ would not solve the problem. The user can already type that as a normal shell command.
58
+
59
+ The useful feature is instead a **persistent SSH workspace manager**:
60
+
61
+ ```text
62
+ one authenticated OpenSSH master connection
63
+
64
+ ├── exec channel → git status
65
+ ├── exec channel → cat package.json
66
+ ├── exec channel → apply a multi-file patch
67
+ ├── exec channel → pnpm test
68
+ ├── scp/sftp-style file transfer
69
+ └── optional remote tmux shell
70
+ ```
71
+
72
+ Each operation remains independently observable and machine-readable while reusing the same authenticated connection.
73
+
74
+ This is especially useful for AI systems. Models generally work better with discrete tool calls returning bounded stdout/stderr and exit status than by pretending to be a human typing blindly into one long terminal stream. A persistent transport should therefore **not** imply that all AI activity must share one stateful shell.
75
+
76
+ MoshCode needs both:
77
+
78
+ 1. multiplexed stateless command channels over one SSH connection; and
79
+ 2. an optional persistent interactive remote shell for workflows that actually require shell state, a REPL, a TUI, a dev server, or a long-running process.
80
+
81
+ ## Source Review / Why This Fits MoshCode
82
+
83
+ The current codebase already contains most of the concepts needed for this feature.
84
+
85
+ ### Existing shell abstraction
86
+
87
+ `src/shell.mjs` centralizes how the pit runs shell commands, including interactive rc-file behavior and terminal/job-control details. The proposed SSH implementation should follow the same principle: one authoritative module should own SSH invocation construction and lifecycle behavior.
88
+
89
+ ### Existing persistent runtime
90
+
91
+ `src/herd.mjs` already:
92
+
93
+ - detects tmux and PTY capabilities;
94
+ - creates named persistent sessions;
95
+ - captures output;
96
+ - sends literal input;
97
+ - attaches a terminal;
98
+ - keeps session metadata;
99
+ - exposes machine-readable controls.
100
+
101
+ The SSH feature should reuse the **design philosophy**, not tunnel every SSH command through herd.
102
+
103
+ ### Existing machine interface
104
+
105
+ The herd intentionally has no separate hidden API: CLI verbs use `--json`, and scripts/agents consume the same concepts. `/ssh` should follow that rule.
106
+
107
+ ### Existing zero-dependency posture
108
+
109
+ MoshCode is deliberately zero-dependency ESM. Do **not** add `ssh2`, `node-pty`, libssh bindings, or a custom SSH protocol implementation.
110
+
111
+ Use the installed OpenSSH client and its native connection-multiplexing support.
112
+
113
+ ## Goals
114
+
115
+ - Allow many remote commands to reuse one authenticated SSH transport.
116
+ - Make remote execution dramatically cheaper than reconnecting for every command.
117
+ - Give humans a natural `/ssh` pit command and `moshcode ssh` CLI.
118
+ - Give AI systems a structured, machine-readable `ssh exec` interface.
119
+ - Preserve discrete stdout, stderr, exit status, timeout, and cancellation behavior for each operation.
120
+ - Support stdin so an agent can apply a multi-file patch in one remote operation.
121
+ - Support an optional truly persistent remote shell when shell state matters.
122
+ - Reuse the user's existing OpenSSH configuration, ssh-agent, known_hosts, ProxyJump, identities, and hardware-backed keys.
123
+ - Keep credentials and private keys out of MoshCode storage.
124
+ - Keep the project zero-dependency ESM.
125
+ - Fail soft when OpenSSH or an optional remote capability such as tmux is unavailable.
126
+ - Make the feature directly useful to Chovy without making Chovy depend on MoshCode internals.
127
+
128
+ ## Non-Goals
129
+
130
+ - Implement the SSH protocol in JavaScript.
131
+ - Replace OpenSSH.
132
+ - Store SSH passwords.
133
+ - Store private keys.
134
+ - Disable host-key checking.
135
+ - Invent a second `~/.ssh/config`.
136
+ - Force every remote command through one interactive PTY.
137
+ - Require MoshCode to be installed on the remote host.
138
+ - Require tmux for normal `ssh exec`.
139
+ - Build a remote filesystem/FUSE mount.
140
+ - Build an IDE file browser.
141
+ - Replace rsync, scp, or sftp.
142
+ - Automatically deploy MoshCode to remote machines.
143
+ - Make SSH itself an A2A protocol.
144
+ - Treat a remote shell as an AI agent when it is not one.
145
+
146
+ ## Users
147
+
148
+ ### Chovy / agentic application backend
149
+
150
+ Needs to perform many file and shell operations against one remote app workspace during a coding run without opening a new authenticated SSH transport every time.
151
+
152
+ ### MoshCode operator
153
+
154
+ Wants to type:
155
+
156
+ ```text
157
+ /ssh dev
158
+ ```
159
+
160
+ and land on a configured remote box, or:
161
+
162
+ ```text
163
+ /ssh exec dev -- git status
164
+ ```
165
+
166
+ without thinking about connection multiplexing.
167
+
168
+ ### Coding agent
169
+
170
+ Needs deterministic tools such as:
171
+
172
+ ```sh
173
+ moshcode ssh exec dev --json -- git diff --stat
174
+ ```
175
+
176
+ rather than scraping an interactive terminal.
177
+
178
+ ### Automation / moshscript
179
+
180
+ Needs to open a connection, execute several operations, branch on exit codes, and close or leave the connection available for later reuse.
181
+
182
+ ## Product Principle
183
+
184
+ **Persistent connection, discrete operations.**
185
+
186
+ The transport stays alive. Commands do not have to share shell state.
187
+
188
+ This is the default:
189
+
190
+ ```text
191
+ AI
192
+
193
+ ├─ exec("pwd") ───────────────┐
194
+ ├─ exec("git status") ────────┤
195
+ ├─ exec("git apply -", stdin) ┤
196
+ └─ exec("pnpm test") ─────────┤
197
+
198
+ one OpenSSH master
199
+
200
+
201
+ remote server
202
+ ```
203
+
204
+ Use a stateful remote shell only when the task truly needs one:
205
+
206
+ ```text
207
+ AI / human
208
+
209
+
210
+ remote tmux shell
211
+
212
+ ├── cd persists
213
+ ├── exports persist
214
+ ├── dev server persists
215
+ ├── REPL persists
216
+ └── TUI persists
217
+ ```
218
+
219
+ ## Requirements
220
+
221
+ ### Phase 1 — Named SSH targets
222
+
223
+ - **R1 [P0]** Add a core `moshcode ssh` command and `/ssh` pit command.
224
+
225
+ - **R2 [P0]** Support named targets:
226
+
227
+ ```sh
228
+ moshcode ssh add dev deploy@example.com
229
+ moshcode ssh add dev deploy@example.com --port 2222
230
+ moshcode ssh add dev deploy@example.com --cwd /srv/app
231
+ moshcode ssh add dev my-ssh-config-host
232
+ ```
233
+
234
+ - **R3 [P0]** Store only non-secret metadata under:
235
+
236
+ ```text
237
+ ~/.moshcode/ssh/targets.json
238
+ ```
239
+
240
+ Suggested shape:
241
+
242
+ ```json
243
+ {
244
+ "dev": {
245
+ "target": "deploy@example.com",
246
+ "port": 22,
247
+ "cwd": "/srv/app"
248
+ }
249
+ }
250
+ ```
251
+
252
+ - **R4 [P0]** `targets.json` MUST NOT contain:
253
+ - passwords;
254
+ - private-key contents;
255
+ - passphrases;
256
+ - ssh-agent material;
257
+ - temporary auth tokens.
258
+
259
+ - **R5 [P0]** Allow normal OpenSSH host aliases as targets so existing `~/.ssh/config` remains authoritative:
260
+
261
+ ```sshconfig
262
+ Host devbox
263
+ HostName 203.0.113.10
264
+ User deploy
265
+ IdentityFile ~/.ssh/id_ed25519
266
+ ProxyJump bastion
267
+ ```
268
+
269
+ then:
270
+
271
+ ```sh
272
+ moshcode ssh add dev devbox
273
+ ```
274
+
275
+ - **R6 [P0]** Commands:
276
+
277
+ ```sh
278
+ moshcode ssh
279
+ moshcode ssh list
280
+ moshcode ssh add <name> <target>
281
+ moshcode ssh remove <name>
282
+ moshcode ssh show <name>
283
+ ```
284
+
285
+ Bare `moshcode ssh` lists configured targets and connection state.
286
+
287
+ - **R7 [P0]** Every non-interactive verb supports `--json`.
288
+
289
+ ### Phase 2 — Persistent OpenSSH transport
290
+
291
+ - **R8 [P0]** Use native OpenSSH connection multiplexing.
292
+
293
+ MoshCode MUST establish a control master rather than keeping a Node child process with a hand-rolled protocol.
294
+
295
+ Conceptually:
296
+
297
+ ```sh
298
+ ssh \
299
+ -o ControlMaster=yes \
300
+ -o ControlPersist=10m \
301
+ -o ControlPath=<moshcode-control-socket> \
302
+ -N -f \
303
+ devbox
304
+ ```
305
+
306
+ - **R9 [P0]** Add:
307
+
308
+ ```sh
309
+ moshcode ssh open <name>
310
+ moshcode ssh check <name>
311
+ moshcode ssh close <name>
312
+ ```
313
+
314
+ Pit equivalents:
315
+
316
+ ```text
317
+ /ssh open dev
318
+ /ssh check dev
319
+ /ssh close dev
320
+ ```
321
+
322
+ - **R10 [P0]** Opening an already-live master is idempotent and returns success with `alreadyOpen: true`.
323
+
324
+ - **R11 [P0]** Connection state MUST be checked using OpenSSH's control operations where supported, e.g. `ssh -O check`.
325
+
326
+ - **R12 [P0]** Closing MUST use OpenSSH's control operation, e.g. `ssh -O exit`, rather than killing arbitrary PIDs.
327
+
328
+ - **R13 [P0]** Default to a finite `ControlPersist` window after the last client disconnects. Initial default: 10 minutes.
329
+
330
+ Configurable by:
331
+
332
+ ```sh
333
+ --persist 30m
334
+ ```
335
+
336
+ and:
337
+
338
+ ```text
339
+ MOSHCODE_SSH_PERSIST=30m
340
+ ```
341
+
342
+ - **R14 [P0]** Use connection keepalives appropriate for unattended agents:
343
+
344
+ ```text
345
+ ServerAliveInterval=30
346
+ ServerAliveCountMax=3
347
+ ```
348
+
349
+ unless the user has explicitly configured alternatives.
350
+
351
+ - **R15 [P0]** If the master dies or a control socket becomes stale, the next operation MUST:
352
+ 1. detect the failure;
353
+ 2. clean up only MoshCode-owned stale state;
354
+ 3. establish a new master;
355
+ 4. retry the requested operation once.
356
+
357
+ - **R16 [P0]** Control socket paths MUST avoid Unix-domain-socket path-length failures.
358
+
359
+ Do not derive a long socket filename directly from `user@host:/workspace/path`.
360
+
361
+ Use a stable short hash:
362
+
363
+ ```text
364
+ ~/.moshcode/ssh/control/7f31a8c2
365
+ ```
366
+
367
+ If platform socket limits make even that unsafe, use a private runtime directory such as:
368
+
369
+ ```text
370
+ /tmp/moshcode-ssh-<uid>/
371
+ ```
372
+
373
+ - **R17 [P0]** Any runtime/control directory containing sockets MUST be mode `0700`.
374
+
375
+ ### Phase 3 — Discrete command execution
376
+
377
+ - **R18 [P0]** Add:
378
+
379
+ ```sh
380
+ moshcode ssh exec <name> -- <command> [args...]
381
+ ```
382
+
383
+ Example:
384
+
385
+ ```sh
386
+ moshcode ssh exec dev -- git status --short
387
+ ```
388
+
389
+ - **R19 [P0]** `ssh exec` MUST automatically reuse the named target's master connection.
390
+
391
+ - **R20 [P0]** `ssh exec` MUST default to **no PTY**.
392
+
393
+ This keeps:
394
+ - stdout deterministic;
395
+ - stderr deterministic;
396
+ - binary-safe stdin possible;
397
+ - automation predictable.
398
+
399
+ - **R21 [P0]** Add `--tty` for commands that require a terminal:
400
+
401
+ ```sh
402
+ moshcode ssh exec dev --tty -- sudo systemctl status nginx
403
+ ```
404
+
405
+ - **R22 [P0]** Return the command's actual remote exit status.
406
+
407
+ - **R23 [P0]** `--json` output shape:
408
+
409
+ ```json
410
+ {
411
+ "ok": true,
412
+ "target": "dev",
413
+ "connected": true,
414
+ "code": 0,
415
+ "signal": null,
416
+ "stdout": " M src/app.ts\n",
417
+ "stderr": "",
418
+ "durationMs": 84
419
+ }
420
+ ```
421
+
422
+ - **R24 [P0]** Failed remote commands are not transport failures.
423
+
424
+ Example: remote `grep` exits `1`.
425
+
426
+ JSON:
427
+
428
+ ```json
429
+ {
430
+ "ok": false,
431
+ "transportOk": true,
432
+ "code": 1
433
+ }
434
+ ```
435
+
436
+ This distinction matters to agents.
437
+
438
+ - **R25 [P0]** SSH/network/auth failures MUST be distinguished from remote command failures.
439
+
440
+ Example:
441
+
442
+ ```json
443
+ {
444
+ "ok": false,
445
+ "transportOk": false,
446
+ "code": 255,
447
+ "error": "ssh authentication failed"
448
+ }
449
+ ```
450
+
451
+ - **R26 [P0]** Support timeout:
452
+
453
+ ```sh
454
+ moshcode ssh exec dev --timeout 2m -- pnpm test
455
+ ```
456
+
457
+ - **R27 [P0]** Support per-operation cwd:
458
+
459
+ ```sh
460
+ moshcode ssh exec dev --cwd /srv/app -- git status
461
+ ```
462
+
463
+ If omitted, use the target's configured default cwd.
464
+
465
+ - **R28 [P1]** Support per-operation environment values:
466
+
467
+ ```sh
468
+ moshcode ssh exec dev --env NODE_ENV=test -- pnpm test
469
+ ```
470
+
471
+ These values apply to that operation only.
472
+
473
+ - **R29 [P0]** Do not emulate shell persistence for `exec`.
474
+
475
+ This should **not** work by accident:
476
+
477
+ ```sh
478
+ moshcode ssh exec dev -- cd /tmp
479
+ moshcode ssh exec dev -- pwd
480
+ ```
481
+
482
+ The second command should still use the configured/default cwd.
483
+
484
+ Persistent shell state belongs to the shell-session feature.
485
+
486
+ ### Phase 4 — stdin and agent-friendly file editing
487
+
488
+ - **R30 [P0]** `ssh exec` MUST be able to forward stdin.
489
+
490
+ Example:
491
+
492
+ ```sh
493
+ printf '%s\n' "$PATCH" |
494
+ moshcode ssh exec dev --stdin --cwd /srv/app -- git apply -
495
+ ```
496
+
497
+ - **R31 [P0]** stdin MUST remain raw and must not be shell-escaped, JSON-encoded, line-split, or interpreted by MoshCode.
498
+
499
+ - **R32 [P0]** This is the recommended Chovy multi-file-edit path:
500
+
501
+ ```text
502
+ model produces unified diff
503
+
504
+
505
+ one `ssh exec --stdin`
506
+
507
+
508
+ `git apply -`
509
+
510
+
511
+ many files changed atomically-ish in one remote command
512
+ ```
513
+
514
+ This is preferable to one SSH operation per changed file when the model already has a patch.
515
+
516
+ - **R33 [P1]** Add convenience transfer verbs backed by OpenSSH-native tools:
517
+
518
+ ```sh
519
+ moshcode ssh put dev ./local-file /srv/app/file
520
+ moshcode ssh get dev /srv/app/file ./local-file
521
+ ```
522
+
523
+ Implementation may use `scp` with the same ControlPath.
524
+
525
+ - **R34 [P1]** `put` SHOULD support atomic replacement for individual files:
526
+ 1. copy to a temporary sibling path;
527
+ 2. rename on the remote filesystem.
528
+
529
+ - **R35 [P1]** No custom SFTP implementation.
530
+
531
+ ### Phase 5 — Interactive connection
532
+
533
+ - **R36 [P0]** Bare named target attaches a normal interactive SSH session:
534
+
535
+ ```sh
536
+ moshcode ssh dev
537
+ ```
538
+
539
+ pit:
540
+
541
+ ```text
542
+ /ssh dev
543
+ ```
544
+
545
+ - **R37 [P0]** Interactive attach MUST reuse the same ControlMaster when available.
546
+
547
+ - **R38 [P0]** Interactive mode hands the terminal directly to OpenSSH. MoshCode does not parse or redraw the remote terminal.
548
+
549
+ - **R39 [P0]** Ctrl-C, terminal resize, colors, mouse input, TUIs, vim, top, btop, and nested agent CLIs should behave as they do under ordinary OpenSSH.
550
+
551
+ - **R40 [P0]** Exiting the interactive shell does **not** necessarily close the master connection. `ControlPersist` governs transport lifetime.
552
+
553
+ ### Phase 6 — Persistent stateful remote shell
554
+
555
+ A multiplexed SSH connection avoids repeated authentication, but separate `exec` channels intentionally do not preserve shell state.
556
+
557
+ Some workflows need actual state:
558
+
559
+ ```sh
560
+ cd /srv/app
561
+ export DEBUG=1
562
+ pnpm dev
563
+ ```
564
+
565
+ or an interactive CLI that stays alive.
566
+
567
+ - **R41 [P1]** Add:
568
+
569
+ ```sh
570
+ moshcode ssh shell <target> --name <session>
571
+ ```
572
+
573
+ Example:
574
+
575
+ ```sh
576
+ moshcode ssh shell dev --name app
577
+ ```
578
+
579
+ - **R42 [P1]** When remote tmux exists, create-or-attach a namespaced remote tmux session:
580
+
581
+ ```text
582
+ moshcode-ssh-<local-target>-<session>
583
+ ```
584
+
585
+ - **R43 [P1]** The remote shell persists independently of the local terminal and independently of the local SSH transport. If the laptop sleeps, the remote tmux shell remains.
586
+
587
+ - **R44 [P1]** Add non-attaching machine controls:
588
+
589
+ ```sh
590
+ moshcode ssh shell send dev/app "pnpm test"
591
+ moshcode ssh shell read dev/app --lines 80
592
+ moshcode ssh shell kill dev/app
593
+ ```
594
+
595
+ - **R45 [P1]** `send` MUST write literal text followed by Enter, matching herd's existing literal-input safety model.
596
+
597
+ - **R46 [P1]** `read` uses remote `tmux capture-pane`, returning terminal text rather than requiring the caller to attach.
598
+
599
+ - **R47 [P1]** The remote shell feature MUST gracefully report when tmux is unavailable on the remote machine.
600
+
601
+ Normal `ssh exec` remains fully functional.
602
+
603
+ - **R48 [P1]** Do not silently install tmux remotely.
604
+
605
+ ### Phase 7 — Moshscript / agent API
606
+
607
+ - **R49 [P0]** Expose value-returning moshscript helpers instead of forcing scripts to parse human text.
608
+
609
+ Proposed:
610
+
611
+ ```js
612
+ sshOpen("dev");
613
+ const r = sshExec("dev", ["git", "status", "--short"], {
614
+ cwd: "/srv/app"
615
+ });
616
+
617
+ if (!r.ok) {
618
+ say(r.stderr);
619
+ }
620
+
621
+ sshClose("dev");
622
+ ```
623
+
624
+ - **R50 [P0]** `sshExec()` returns the same conceptual object as CLI `--json`:
625
+
626
+ ```js
627
+ {
628
+ ok,
629
+ transportOk,
630
+ code,
631
+ signal,
632
+ stdout,
633
+ stderr,
634
+ durationMs
635
+ }
636
+ ```
637
+
638
+ - **R51 [P1]** Support stdin in moshscript:
639
+
640
+ ```js
641
+ sshExec("dev", ["git", "apply", "-"], {
642
+ cwd: "/srv/app",
643
+ stdin: patch
644
+ });
645
+ ```
646
+
647
+ - **R52 [P1]** Add shell-session helpers only if Phase 6 is implemented:
648
+
649
+ ```js
650
+ sshShellSend("dev/app", "pnpm test");
651
+ const screen = sshShellRead("dev/app", { lines: 50 });
652
+ ```
653
+
654
+ (Starting a shell hands the terminal to ssh, so it is a CLI verb rather
655
+ than a script helper; a script drives an existing shell with `send`,
656
+ `read` and `kill`.)
657
+
658
+ ### Phase 8 — Chovy integration contract
659
+
660
+ The feature should be usable by Chovy strictly through the public CLI. Chovy must not import MoshCode private modules.
661
+
662
+ Recommended lifecycle:
663
+
664
+ ```sh
665
+ # once when a workspace is provisioned
666
+ moshcode ssh add chovy-app app@server --cwd /srv/chovy/workspace
667
+
668
+ # once at the beginning of an active coding run
669
+ moshcode ssh open chovy-app --json
670
+ ```
671
+
672
+ Then every AI tool operation:
673
+
674
+ ```sh
675
+ moshcode ssh exec chovy-app --json -- git status --short
676
+ ```
677
+
678
+ Read a file:
679
+
680
+ ```sh
681
+ moshcode ssh exec chovy-app --json -- sed -n '1,240p' src/app.ts
682
+ ```
683
+
684
+ Apply a model-generated multi-file patch:
685
+
686
+ ```sh
687
+ moshcode ssh exec chovy-app \
688
+ --json \
689
+ --stdin \
690
+ --cwd /srv/chovy/workspace \
691
+ -- git apply -
692
+ ```
693
+
694
+ Run tests:
695
+
696
+ ```sh
697
+ moshcode ssh exec chovy-app \
698
+ --json \
699
+ --timeout 10m \
700
+ --cwd /srv/chovy/workspace \
701
+ -- pnpm test
702
+ ```
703
+
704
+ Optional interactive debug:
705
+
706
+ ```sh
707
+ moshcode ssh chovy-app
708
+ ```
709
+
710
+ Optional persistent remote dev shell:
711
+
712
+ ```sh
713
+ moshcode ssh shell chovy-app --name dev
714
+ ```
715
+
716
+ At run completion:
717
+
718
+ ```sh
719
+ moshcode ssh close chovy-app
720
+ ```
721
+
722
+ or simply let `ControlPersist` expire.
723
+
724
+ - **R53 [P0]** Chovy SHOULD keep using discrete model tool calls.
725
+
726
+ - **R54 [P0]** Chovy SHOULD NOT force all model actions through a single interactive shell merely to avoid reconnect cost.
727
+
728
+ - **R55 [P0]** Chovy SHOULD batch model file changes into unified diffs where practical and apply one patch through stdin.
729
+
730
+ - **R56 [P0]** Chovy MAY run independent commands concurrently over the same master connection. SSH multiplexing should allow several logical channels over one authenticated transport.
731
+
732
+ - **R57 [P0]** A single Chovy coding run should normally perform one SSH authentication/transport setup, not one per file.
733
+
734
+ ## UX Notes
735
+
736
+ ### Human pit flow
737
+
738
+ ```text
739
+ mosh ▸ /ssh add dev deploy@dev.example.com --cwd ~/src/app
740
+ ✓ dev → deploy@dev.example.com
741
+
742
+ mosh ▸ /ssh open dev
743
+ ✓ dev connected
744
+
745
+ mosh ▸ /ssh exec dev -- git status --short
746
+ M src/app.ts
747
+
748
+ mosh ▸ /ssh dev
749
+ deploy@dev:~/src/app$
750
+ ```
751
+
752
+ Leaving the remote shell returns to the pit while the master connection remains reusable.
753
+
754
+ ### Connection list
755
+
756
+ ```text
757
+ mosh ▸ /ssh
758
+
759
+ name target state cwd
760
+ dev deploy@dev.example.com connected ~/src/app
761
+ prod deploy@prod.example.com closed /srv/app
762
+ ```
763
+
764
+ ### JSON list
765
+
766
+ ```json
767
+ {
768
+ "targets": [
769
+ {
770
+ "name": "dev",
771
+ "target": "deploy@dev.example.com",
772
+ "connected": true,
773
+ "cwd": "~/src/app"
774
+ }
775
+ ]
776
+ }
777
+ ```
778
+
779
+ ### Shell session
780
+
781
+ ```text
782
+ mosh ▸ /ssh shell dev --name app
783
+ dev/app ▸ ~/src/app
784
+
785
+ deploy@dev:~/src/app$ pnpm dev
786
+ ```
787
+
788
+ Detach behavior should be documented clearly. If remote tmux is used, detaching must leave the remote process alive.
789
+
790
+ ## CLI Surface
791
+
792
+ ```text
793
+ moshcode ssh
794
+ moshcode ssh list
795
+ moshcode ssh add <name> <target> [--port N] [--cwd PATH]
796
+ moshcode ssh remove <name>
797
+ moshcode ssh show <name>
798
+
799
+ moshcode ssh open <name> [--persist 10m]
800
+ moshcode ssh check <name>
801
+ moshcode ssh close <name>
802
+
803
+ moshcode ssh <name>
804
+ moshcode ssh exec <name> [--cwd PATH] [--env K=V] [--stdin] [--tty] [--timeout DURATION] -- <command...>
805
+
806
+ moshcode ssh put <name> <local> <remote>
807
+ moshcode ssh get <name> <remote> <local>
808
+
809
+ moshcode ssh shell <name> --name <session>
810
+ moshcode ssh shell send <name>/<session> <text>
811
+ moshcode ssh shell read <name>/<session> [--lines N]
812
+ moshcode ssh shell kill <name>/<session>
813
+
814
+ moshcode ssh bench <name> [--n 20]
815
+ ```
816
+
817
+ Every appropriate verb:
818
+
819
+ ```text
820
+ --json
821
+ ```
822
+
823
+ Pit facade:
824
+
825
+ ```text
826
+ /ssh ...
827
+ ```
828
+
829
+ ## Architecture
830
+
831
+ ### New module: `src/ssh.mjs`
832
+
833
+ Own:
834
+
835
+ - target registry;
836
+ - control socket naming;
837
+ - OpenSSH capability detection;
838
+ - master open/check/close;
839
+ - invocation construction;
840
+ - exec;
841
+ - stdin forwarding;
842
+ - output capture;
843
+ - timeout;
844
+ - interactive attach;
845
+ - scp convenience;
846
+ - optional remote tmux shell helpers.
847
+
848
+ No SSH command construction should be duplicated in `cli.mjs`, `commands.mjs`, or Chovy.
849
+
850
+ ### `src/cli-schema.mjs`
851
+
852
+ Add the canonical help schema for `ssh` and its verbs.
853
+
854
+ The README command table is generated from the command schema, so `/ssh` must enter through the same canonical command/help path as existing commands.
855
+
856
+ ### `bin/moshcode.mjs`
857
+
858
+ Dispatch `moshcode ssh ...` into `src/ssh.mjs`.
859
+
860
+ ### `src/commands.mjs`
861
+
862
+ Expose:
863
+
864
+ ```js
865
+ cliVerb("ssh", "connect to and operate persistent remote SSH workspaces")
866
+ ```
867
+
868
+ plus value-returning moshscript helpers where required.
869
+
870
+ ### `src/runtime.mjs`
871
+
872
+ The runtime injects every registered command as a global, so the helpers in
873
+ `src/commands.mjs` are the injection; no runtime change is needed.
874
+
875
+ ### Relationship to `src/herd.mjs`
876
+
877
+ Phase 1 does not change herd.
878
+
879
+ This is intentional:
880
+
881
+ - herd owns local persistent processes and agent state;
882
+ - ssh owns remote transport and remote command execution.
883
+
884
+ Future integration may allow a remote SSH shell or an SSH-launched remote agent to appear in `moshcode ps`, but `/ssh` should first work cleanly as an independent transport primitive.
885
+
886
+ ## OpenSSH Invocation Strategy
887
+
888
+ Implementation builds argv arrays, never a shell string.
889
+
890
+ ### Master
891
+
892
+ ```sh
893
+ ssh \
894
+ -o ControlMaster=yes \
895
+ -o ControlPersist=600 \
896
+ -o ControlPath=/private/path/abc123 \
897
+ -o ServerAliveInterval=30 \
898
+ -o ServerAliveCountMax=3 \
899
+ -o ConnectTimeout=20 \
900
+ -N -f \
901
+ devbox
902
+ ```
903
+
904
+ Note the absence of `-M`. Found in testing: `-M` together with
905
+ `-o ControlMaster=yes` is read by ssh as a *second* request for master mode,
906
+ which means **ask** mode — every later client then needs an askpass
907
+ confirmation, and headless the answer is "Master refused session request:
908
+ Permission denied". One spelling or the other, never both.
909
+
910
+ ### Check
911
+
912
+ ```sh
913
+ ssh \
914
+ -o ControlPath=/private/path/abc123 \
915
+ -O check \
916
+ devbox
917
+ ```
918
+
919
+ ### Close
920
+
921
+ ```sh
922
+ ssh \
923
+ -o ControlPath=/private/path/abc123 \
924
+ -O exit \
925
+ devbox
926
+ ```
927
+
928
+ ### Exec
929
+
930
+ ```sh
931
+ ssh \
932
+ -o ControlPath=/private/path/abc123 \
933
+ -o ControlMaster=auto \
934
+ -o ControlPersist=600 \
935
+ -T \
936
+ devbox \
937
+ -- <remote-command>
938
+ ```
939
+
940
+ `ControlMaster=auto` on the client is the native stale-socket recovery: a
941
+ socket nobody is listening on is unlinked and the client becomes the new
942
+ master, so a master that died between two commands costs one reconnect.
943
+
944
+ The remote command is built from argv with POSIX single-quoting:
945
+
946
+ ```text
947
+ cd -- '/srv/app' && NODE_ENV='test' exec 'pnpm' 'test'
948
+ ```
949
+
950
+ A `--sh` flag passes a single argument as a shell snippet on purpose; nothing
951
+ is ever guessed to be one.
952
+
953
+ ## Security
954
+
955
+ - **R58 [P0]** Never pass `StrictHostKeyChecking=no`.
956
+ - **R59 [P0]** Respect normal OpenSSH `known_hosts` behavior.
957
+ - **R60 [P0]** Never persist passwords.
958
+ - **R61 [P0]** Never copy private keys into `~/.moshcode`.
959
+ - **R62 [P0]** Prefer ssh-agent, OpenSSH config, hardware-backed keys, and standard identity files.
960
+ - **R63 [P0]** Redact obvious secret-bearing CLI arguments from debug logs where MoshCode controls logging.
961
+ - **R64 [P0]** Do not print full stdin payloads in debug output.
962
+ - **R65 [P0]** The socket/control directory must be private to the current OS user.
963
+ - **R66 [P0]** Refuse target names containing path separators or traversal components.
964
+ - **R67 [P0]** Registry file writes must be atomic and owner-only.
965
+ - **R68 [P0]** Remote commands must be built from argv with explicit quoting rules.
966
+ - **R69 [P0]** `--env` values must not be echoed in ordinary human output.
967
+ - **R70 [P0]** The feature must not weaken the user's existing SSH policy.
968
+
969
+ ## Failure Modes
970
+
971
+ ### OpenSSH missing
972
+
973
+ ```text
974
+ ✗ ssh not found — install an OpenSSH client
975
+ ```
976
+
977
+ No package is auto-installed.
978
+
979
+ ### Authentication requires interaction
980
+
981
+ Interactive `/ssh dev` may naturally allow OpenSSH to ask.
982
+
983
+ Headless `ssh exec --json` should fail clearly rather than hang indefinitely.
984
+ `BatchMode=yes` is passed whenever stdin is not a terminal or `--batch` is
985
+ given; with a terminal attached, OpenSSH may prompt as it normally would.
986
+
987
+ ### Unknown host key
988
+
989
+ Use native OpenSSH behavior. Do not auto-accept.
990
+
991
+ ### Stale master socket
992
+
993
+ Detect → clean MoshCode-owned stale socket → reconnect → retry once.
994
+
995
+ ### Remote command exits nonzero
996
+
997
+ Return command exit status without calling it an SSH failure.
998
+
999
+ ### Remote tmux missing
1000
+
1001
+ Only `ssh shell` persistent mode is unavailable. `ssh exec` still works.
1002
+
1003
+ ### Local process dies
1004
+
1005
+ A detached ControlMaster may survive according to OpenSSH behavior and ControlPersist. Remote tmux shells survive regardless of the local master.
1006
+
1007
+ ## Performance Expectations
1008
+
1009
+ The feature exists to remove repeated SSH handshakes from high-churn agent workloads.
1010
+
1011
+ ### Required measurement
1012
+
1013
+ `moshcode ssh bench <name> [--n N]` compares:
1014
+
1015
+ ```text
1016
+ N × fresh ssh "true"
1017
+ ```
1018
+
1019
+ against:
1020
+
1021
+ ```text
1022
+ 1 × master connection
1023
+ N × multiplexed ssh "true"
1024
+ ```
1025
+
1026
+ and reports total wall time, median and p95 latency, failures, and the number
1027
+ of authentications each side performed.
1028
+
1029
+ Measured on a loopback sshd on the development box (20 runs each): fresh
1030
+ ~96ms median, multiplexed ~12ms median. Real hosts will differ; the number to
1031
+ quote is the one `bench` prints for your own host.
1032
+
1033
+ ## Success Metrics
1034
+
1035
+ - A Chovy run that performs 100 remote operations normally authenticates once rather than 100 times.
1036
+ - Median subsequent `ssh exec` startup latency is materially lower than a fresh SSH connection on the same host.
1037
+ - File edits can be applied as one multi-file patch over stdin.
1038
+ - `ssh exec --json` exposes stdout, stderr, exit status, transport status, and duration without terminal scraping.
1039
+ - Interactive `/ssh <name>` behaves like normal OpenSSH.
1040
+ - Existing `~/.ssh/config` features continue to work.
1041
+ - No SSH private key or password is stored by MoshCode.
1042
+ - No runtime npm dependency is added.
1043
+ - MoshCode remains usable when tmux is absent.
1044
+ - Tests cover stale sockets, failed authentication, remote exit codes, stdin, quoting, cwd, and JSON output.
1045
+
1046
+ ## Test Plan
1047
+
1048
+ ### Unit (`test/ssh.test.mjs`)
1049
+
1050
+ - target-name validation;
1051
+ - registry read/write;
1052
+ - control-path hashing;
1053
+ - OpenSSH argv construction;
1054
+ - port handling;
1055
+ - cwd encoding;
1056
+ - environment encoding;
1057
+ - remote argv quoting;
1058
+ - JSON shapes;
1059
+ - exit-code mapping;
1060
+ - transport-vs-command failure classification;
1061
+ - timeout parsing;
1062
+ - stale-socket recovery decision logic.
1063
+
1064
+ ### Integration (`test/ssh-sshd.test.mjs`)
1065
+
1066
+ An ephemeral, non-root `sshd` on a loopback port with generated keys and a
1067
+ private `ssh_config` (pointed at through `MOSHCODE_SSH_CONFIG`). Skipped, not
1068
+ failed, where `sshd` or `ssh-keygen` is unavailable.
1069
+
1070
+ 1. add target;
1071
+ 2. open master;
1072
+ 3. check master;
1073
+ 4. exec `printf`;
1074
+ 5. exec failing command;
1075
+ 6. stdin round trip;
1076
+ 7. cwd;
1077
+ 8. parallel exec channels;
1078
+ 9. close master;
1079
+ 10. automatic reopen;
1080
+ 11. host-key failure;
1081
+ 12. authentication failure;
1082
+ 13. `scp` reuse via `put`/`get`.
1083
+
1084
+ ### Remote tmux
1085
+
1086
+ When tmux is present in the test image:
1087
+
1088
+ 1. send `cd` and `pwd`, verify state persists;
1089
+ 2. read the screen;
1090
+ 3. kill.
1091
+
1092
+ ## Documentation
1093
+
1094
+ README section `## SSH workspaces`, leading with:
1095
+
1096
+ > `/ssh` keeps the SSH connection alive; `ssh exec` still gives each tool call a clean command channel.
1097
+
1098
+ with a Chovy/agent example showing one connection and a multi-file `git apply -`, and `moshcode help ssh`.
1099
+
1100
+ ## Rollout
1101
+
1102
+ ### Milestone 1
1103
+
1104
+ - target registry;
1105
+ - `/ssh`;
1106
+ - open/check/close;
1107
+ - exec;
1108
+ - JSON;
1109
+ - stdin;
1110
+ - cwd;
1111
+ - tests.
1112
+
1113
+ This alone solves the Chovy reconnect problem.
1114
+
1115
+ ### Milestone 2
1116
+
1117
+ - put/get;
1118
+ - timeout polish;
1119
+ - parallel execution tests;
1120
+ - moshscript value helpers.
1121
+
1122
+ ### Milestone 3
1123
+
1124
+ - persistent remote tmux shell;
1125
+ - send/read/kill;
1126
+ - optional herd bridge exploration.
1127
+
1128
+ ## Future: Herd Bridge
1129
+
1130
+ Do not block this PRD on herd integration.
1131
+
1132
+ A later PRD may define:
1133
+
1134
+ ```sh
1135
+ moshcode herd remote add devbox --kind ssh --target dev
1136
+ ```
1137
+
1138
+ or allow:
1139
+
1140
+ ```sh
1141
+ moshcode ssh agent dev --engine claude --name api
1142
+ ```
1143
+
1144
+ to launch a MoshCode herd/agent on a remote machine.
1145
+
1146
+ The clean layering should be:
1147
+
1148
+ ```text
1149
+ herd / agent orchestration
1150
+
1151
+
1152
+ ssh workspace
1153
+
1154
+
1155
+ OpenSSH
1156
+ ```
1157
+
1158
+ not:
1159
+
1160
+ ```text
1161
+ SSH implementation hidden inside herd
1162
+ ```
1163
+
1164
+ ## Risks & Open Questions
1165
+
1166
+ - OpenSSH multiplexing behavior differs slightly across platforms. POSIX/OpenSSH-first is acceptable, but capability checks must be explicit.
1167
+ - ControlPath socket limits can be surprisingly small; hashed short paths are mandatory.
1168
+ - Remote argv quoting is security-sensitive and deserves dedicated tests.
1169
+ - `BatchMode=yes` is on whenever stdin is not a terminal, and `--batch` forces it; a person at a terminal can still be prompted.
1170
+ - `ControlPersist=10m` is a reasonable default but Chovy may want a master open for the entire coding-run lifetime. Explicit `open` + `close` already handles that.
1171
+ - `scp` behavior and flags have changed across OpenSSH versions; `put/get` are P1, not required for the core reconnect fix.
1172
+ - Long-running noninteractive commands are still individual channels. If a command must outlive its caller, use remote tmux/systemd/herd rather than pretending `ssh exec` is a job supervisor.
1173
+ - A future remote-herd abstraction should decide whether MoshCode is installed remotely or whether local MoshCode drives raw remote tmux. That decision is intentionally outside this PRD.
1174
+
1175
+ ## Decision
1176
+
1177
+ Build `/ssh`, but build it as a **persistent SSH workspace primitive**, not as a convenience alias for `ssh`.
1178
+
1179
+ For Chovy, the key win is not "one forever-interactive shell." The key win is:
1180
+
1181
+ > **one authenticated SSH transport, many clean AI tool calls, plus an optional persistent remote shell when state is actually needed.**