better-dsh 0.3.1-f → 0.3.2-d

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.
@@ -4,25 +4,47 @@ Better Dash and Super Dash each provide the native `remote` tool and REPL route.
4
4
 
5
5
  ## Choose a call
6
6
 
7
+ `remote` declares its action; `remote_transfer` declares its mode. Nothing dispatches on which fields happen to be present.
8
+
7
9
  | Goal | Input |
8
10
  | --- | --- |
9
- | List locally known targets without dialing | `{}` |
10
- | Probe one target | `{target:"ssh:alias"}` |
11
- | Register an Agent-local SSH identity | `{target:"byossh:build",ssh:{host:"host",user:"user",key:"/origin/key"}}` |
12
- | Stateless command and stdin | `{target:"byossh:build",cmd:"cat",stdin:"hello"}` |
13
- | Stream a local script to fixed `bash -s` | `{target:"ssh:alias",script:"/absolute/script.sh"}` |
14
- | Persistent shell | `{target:"ssh:alias",mode:"pty",cmd:"cd /tmp; export CHECK=ready"}` |
15
- | Official background job | `{target:"ssh:alias",cmd:"long-command",run_in_background:true}` |
16
- | Native CLI copy | `{action:"copy",src:"/absolute/source",dst:"ssh:alias:/absolute/destination",argv:["-p"]}` |
17
- | Native rsync | `{action:"rsync",src:"/absolute/source/",dst:"ssh:alias:/existing/destination/",argv:["-a","--partial","--dry-run"]}` |
18
-
19
- `script` cannot combine with cmd/stdin/spawn/PTY. Direct local command execution is not provided; use native local shell tools. Background jobs are oneshot-only, require the official jobs service/controller, and are read/killed with `job_output`/`job_kill`; they have no default foreground timeout. An explicit timeout still applies. SSH keys/config stay on the origin; BYO identities are not persisted across new/cold Agents. PTY backlog recovery and live probes are not part of this release.
11
+ | List locally known targets without dialing | `remote {action:"status"}` |
12
+ | Probe one target / show a session card | `remote {action:"status",target:"ssh:alias"}`, `remote {action:"status",target:"pty:<id\|label>"}` |
13
+ | Register an Agent-local SSH identity (no dialing) | `remote {action:"add",target:"ssh:build",ssh:{host:"host",user:"user",key:"/origin/key"}}`; omit `target` for an auto id (`ssh-xxxx`) |
14
+ | Register a BYO PTY from a spawn command | `remote {action:"add",spawn:"docker exec -it img bash",target:"pty:build"}`; then address it like any session |
15
+ | Stateless command and stdin over an identity | `remote {action:"exec",target:"ssh:build",cmd:"cat",stdin:"hello"}` |
16
+ | Stream a local script to fixed `bash -s` | `remote {action:"exec",target:"ssh:alias",scriptPath:"/absolute/script.sh"}` |
17
+ | Create a persistent shell (tab semantics) | `remote {action:"exec",target:"ssh:alias:pty",cmd:"cd /tmp; export CHECK=ready"}`; append `:label` (`ssh:alias:pty:build`) to name it |
18
+ | Reuse a persistent shell | `remote {action:"exec",target:"pty:<id\|label>",cmd:"…"}` |
19
+ | Answer a prompt / feed a running command (password, OTP, token, choice, REPL line, `\u0003`/`\u0004`) | `remote {action:"input",target:"pty:<id\|label>",stdin:"the-answer\n"}` — optional `commandId` guards against stale prompts |
20
+ | Close a PTY session | `remote {action:"close",target:"pty:<id\|label>",force:true}` |
21
+ | Native CLI copy | `remote_transfer {mode:"copy",src:"/absolute/source",dst:"ssh:alias:/absolute/destination",argv:["-p"]}` |
22
+ | Native rsync | `remote_transfer {mode:"rsync",src:"/absolute/source/",dst:"ssh:alias:/existing/destination/",argv:["-a","--partial","--dry-run"]}` |
23
+
24
+ ## PTY is a persistent, interactive terminal session
25
+
26
+ Prefer the PTY lane. A PTY dispatch returns synchronously with the same receipt a oneshot returns — output text, exit code, duration — and only adds state: `session`, `cwd`, `envDelta`, `commandId`, `ttlSec`. Nothing is withheld at read time; the terminal merges stderr into the output stream, which is the only receipt difference. What the session buys is durability: cwd, exported environment, shell variables, activated virtualenvs, aliases and an open REPL survive between calls, so a multi-step task becomes N cheap calls instead of N stateless logins, and a command that outlives its timeout hands off to a pollable job instead of being killed. It is also the only interactive lane: a running command can be observed and then fed more stdin (`input`), which a oneshot never offers.
27
+
28
+ Interactive flow — see a prompt, answer it, read the response:
29
+
30
+ 1. Start the program: `exec` with `cmd` (foreground, `run_in_background`, or after a timeout handoff — identity is preserved either way). The running receipt carries the output so far plus `session` / `commandId` / `jobId`.
31
+ 2. Watch for the prompt in that output; use `job_output <jobId>` when you need more.
32
+ 3. Send the answer: `{action:"input", target:"pty:<id|label>", stdin:"the-answer\n"}`. `stdin` passes through verbatim — include the trailing `\n` to submit a line; `\u0003` is Ctrl-C and `\u0004` is Ctrl-D as raw characters; a literal backslash-x-0-4 stays four characters; `""` writes zero bytes (not EOF). The optional `commandId` must equal the running command's id; a stale id refuses the write instead of feeding the next command.
33
+ 4. Read the response with `job_output` on the SAME job. The input receipt only confirms N bytes were written — it never means the password was accepted or the login succeeded; judge from the program's output and the job's exit.
34
+
35
+ Honest limits of the input lane: Ctrl-D is not a universal EOF (on a line without a trailing newline it first delivers that text — send it again on the emptied line to end a reader like `cat`); Ctrl-C only interrupts where the terminal mode allows; `job_kill` remains the explicit cancel; `input` never creates, dials, reconnects or replays — address a live session as `pty:<id|label>`, any transport. A command waiting for input IS the busy state — a new `cmd` gets `[E_SESSION_BUSY]`; feed the running command instead of queueing a second one. On a oneshot, `stdin` is startup-only: known data may ride the initial `exec`, but a oneshot cannot be fed after it starts and its timeout still cancels the whole group — a prompt there means cancel and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives; there is no global stdin ban. Cancelling is reliable (0.3.2-d): `job_kill` writes its status marker after the terminal's ^C input flush, so even a cancel that follows a bare Ctrl-C settles `killed · exit 130` with the session alive — and hub-lane control bytes cross the local raw half as bytes, interpreted by the peer pty, so ^C/^D behave like every other transport.
36
+
37
+ Use the stateless oneshot when a step is genuinely one-shot (a probe, a pipeline stage, a supervised build you will not revisit) or when the `runner` supervision guarantee matters. On dumb-pipe transports the supervisor is the pinned `superd-run` helper (or its POSIX wrapper) — receipts say which ran. On the Hub lane the peer IS our own server: supervision is native there (`bash -lc` in a detached group, TERM→2s→KILL, output rings, lease lifecycle) and receipts say `hub-lease`; the helper is a dumb-pipe artifact and is never uploaded over the Hub. Note that `exec` always executes something: a warm session is created by running a command (`true` is enough), never by omitting `cmd`.
38
+
39
+ There is no `mode` field: append `:pty` to a transport target to create, address a live shell as `pty:<id|label>`. `add` only registers (BYO identity or BYO PTY) and never runs a command. `scriptPath` cannot combine with cmd/stdin/PTY and is `exec`-only. Direct local command execution is not provided; use native local shell tools. Background jobs require the official jobs service/controller and are read/killed with `job_output`/`job_kill`; on PTY lanes a background job's jobId is the commandId (a timeout handoff keeps them as separate ledger fields). SSH keys/config stay on the origin; BYO identities are not persisted across new/cold Agents. PTY backlog recovery and live probes are not part of this release.
40
+
41
+ PTY state is durable and readable: every dispatch returns exit, cwd and `envDelta` (variable names and change markers only — values never leave the endpoint; fetch them yourself if needed). `close` is idempotent (`closed:false, reason:"not-found"|"already-closed-or-reaped"`); a session with a running job refuses close unless `force:true`, which first interrupts the running command — its job settles in the killed family (`interrupted: exit 130`, or `session closed` when the frame cannot arrive) — and then kills the group. A busy session rejects new commands instead of queueing (`[E_SESSION_BUSY]` names the running id and the ways out: `input` more stdin, `job_output`, `job_kill`, or another session/oneshot); open another PTY or use a oneshot for parallel work. Idle PTYs are reaped after 15 minutes; an Agent holds at most 10 live sessions across connections, and dead sessions do not count. `hub:<m4>:pty[:label]` opens the same session shape over the Hub WebSocket lane — identity, labels, input, parallelism and background work exactly like ssh/docker/incus/BYO.
20
42
 
21
43
  Native/PTY output retains at most 4,000,000 UTF-16 units per stream; Hub oneshot retains 4 MiB bytes. Normal returned tails are smaller. Read `outputStats` units and dropped/returned counters rather than treating character counts as bytes. Discarded output is not recoverable after restart.
22
44
 
23
45
  ## File operations: native CLI semantics
24
46
 
25
- `remote` addresses targets once (`ssh:<name>`, `byossh:<label>`, `docker:<name>`, `incus:<name>`, `hub:<machine>`, local paths) and hands the actual transfer to the native CLI. `argv` is an ordered array of native option tokens — one element = one argv token, appended verbatim before the parsed `src`/`dst`. There is no option whitelist, no shell parsing, and no forced defaults: no hidden `--dry-run`, no forced archive/timeout, no automatic verification. A dry run means passing `--dry-run` yourself; unknown or incompatible options fail in the native CLI and its exit code and stderr come back as-is. Success of a native command is never reported as independently verified — `verified:false` on native copy receipts means exactly that.
47
+ `remote_transfer` addresses targets once (`ssh:<name>`, `ssh:<label>` for a BYO identity, `docker:<name>`, `incus:<name>`, `hub:<machine>`, local paths) and hands the actual transfer to the native CLI. `mode:"copy"|"rsync"` is required and spans only transfer fields. `argv` is an ordered array of native option tokens — one element = one argv token, appended verbatim before the parsed `src`/`dst`. There is no option whitelist, no shell parsing, and no forced defaults: no hidden `--dry-run`, no forced archive/timeout, no automatic verification. A dry run means passing `--dry-run` yourself; unknown or incompatible options fail in the native CLI and its exit code and stderr come back as-is. Success of a native command is never reported as independently verified — `verified:false` on native copy receipts means exactly that.
26
48
 
27
49
  ### copy routes
28
50
 
@@ -39,7 +61,7 @@ The native CLIs do not create destination parents or verify results on their own
39
61
 
40
62
  ### rsync routes
41
63
 
42
- rsync supports SSH/byossh endpoints with at most one local endpoint. Local↔SSH runs `rsync` on the origin with the endpoint's SSH identity wired through `RSYNC_RSH`; an explicit `-e`/`--rsh` in `argv` replaces that wiring per rsync's own rules (the command must then carry its own port/key/config). Nothing else is injected — no archive, no timeout, no dry-run, no remote owner, no `--rsync-path`.
64
+ rsync supports SSH endpoints (native aliases and BYO identities) with at most one local endpoint. Local↔SSH runs `rsync` on the origin with the endpoint's SSH identity wired through `RSYNC_RSH`; an explicit `-e`/`--rsh` in `argv` replaces that wiring per rsync's own rules (the command must then carry its own port/key/config). Nothing else is injected — no archive, no timeout, no dry-run, no remote owner, no `--rsync-path`.
43
65
 
44
66
  SSH↔SSH prefers the native forwarding route: the destination runs a temporary loopback-only rsync daemon, `ssh -L` brings it to an origin port, `ssh -R` carries it onto the source's loopback, and the source's own native rsync client performs one protocol conversation through the chain. The origin only forwards rsync protocol bytes; the destination's existing file data feeds real delta matching (`--stats` shows Matched data dominating for a small change on a large file). If an endpoint refuses TCP forwarding (`AllowTcpForwarding no`), a standard-stream route would be attempted; on the current OpenSSH/rsync combination (10.2 / 3.4.1) that route reports itself unavailable — the error says so and nothing is copied. rsync never falls back to a full copy or to Python.
45
67
 
@@ -51,6 +73,6 @@ File operations require the corresponding CLI on the origin (`scp`, `rsync`, `do
51
73
 
52
74
  ## One-shot command requirements
53
75
 
54
- One-shot SSH/byossh commands (foreground, background, script and literal stdin) run through ONE native SSH exec: the command executes under bash with a small POSIX wrapper whose TERM/HUP trap kills the command's whole process group, so backgrounded children do not outlive the session teardown. Remote prerequisites: bash (POSIX) — **no Python, no owner/lease protocol, no flock/monotonic clock, no extra sessions**. On normal completion the wrapper's EXIT trap reaps the command's whole process group (backgrounded children included — they neither outlive the call nor hold the channel open), and the exit code passes through verbatim. On timeout/cancellation the local transport dies at once and a dedicated cleanup session reads the pidfile the wrapper left and kills the group (TERM→KILL); sshd delivers no signal to a no-PTY session, so this explicit cleanup is the remote kill path.
76
+ One-shot SSH commands (native alias or BYO identity; foreground, background, scriptPath and literal stdin) run through ONE native SSH exec: the command executes under bash with a small POSIX wrapper whose TERM/HUP trap kills the command's whole process group, so backgrounded children do not outlive the session teardown. Remote prerequisites: bash (POSIX) — **no Python, no owner/lease protocol, no flock/monotonic clock, no extra sessions**. On normal completion the wrapper's EXIT trap reaps the command's whole process group (backgrounded children included — they neither outlive the call nor hold the channel open), and the exit code passes through verbatim. On timeout/cancellation the local transport dies at once and a dedicated cleanup session reads the pidfile the wrapper left and kills the group (TERM→KILL); sshd delivers no signal to a no-PTY session, so this explicit cleanup is the remote kill path.
55
77
 
56
78
  Docker/Incus one-shots use the same pure-CLI lane (`docker exec` / `incus exec` with the same wrapper); container prerequisites are the container's own bash. PTY paths are unchanged (`ssh -tt bash`, docker via script(1), incus interactive) and never required Python.
@@ -1,5 +1,7 @@
1
1
  # remote-pty-label Specification
2
2
 
3
+ > **Superseded(2026-10-19)**:本 spec 的 `byop:<label>` 入口、`mode` 锁定、`p:<label>` 池键与「同 label 复用」语义已被 [PTY Workbench v2 计划](../../superpowers/plans/2026-10-19-pty-workbench.md) 取代:BYO-PTY 并入 `pty:` selector(`spawn` 在场 = BYO)、`byossh:`/`byop:` 前缀移除、`mode` 字段移除且未知参数不强制拒绝、label 与机器名统一池、id 为 `pty-xxxx` / `ssh-xxxx`。下文保留为历史契约参照。
4
+
3
5
  ## Purpose
4
6
 
5
7
  `remote` 工具的持久化 BYO-PTY 通道从「`spawn` 命令原文即会话键」改为「`target` 选择器寻址的具名会话」。旧实现的池键就是整条 spawn 文本,于是「同一条命令」与「同一个会话」被绑死:改一个 token 就**静默**新开一个 shell(cwd/env 全丢、零提示),roster 又拿整整一条 shell 命令当会话名。本规格固定新契约——`byop:<label>` 是唯一入口、label 由调用方(模型)自选并因此成为复用入口、`spawn` 降格为该 label 首次创建时的伴生参数——并据此定义创建/复用/冲突三分支、失败面,以及裸名候选表的末位新增项。
@@ -2,6 +2,8 @@
2
2
 
3
3
  - 日期:2026-10-10(Asia/Hong_Kong)
4
4
  - 状态:2026-10-10 user 已审阅并明确授权执行;Task0–7 已独立审阅通过;Task8 完整双包消费者验收进行中,L 实装暴露的 worker/SSH cleanup 缺陷已进入修复验证。用户亲验与发布尚未放行。
5
+ - 2026-10-25 surface 修订(user 指令,本轮实现):`remote` 的判别字段改为**必填** `action: exec|add|close|status`;文件传输字段(`src`/`dst`/`argv`/`pullArgv`/`pushArgv`/`verify`/`mkdir`)迁到独立工具 `remote_transfer`,判别字段为**必填** `mode: copy|rsync`。`add` 只注册 BYO 身份/终端,不执行命令;`exec` 必填 `target` 且必须执行东西(warm 会话用 `cmd:"true"`)。工具简介把 PTY 重新定位为「stateful oneshot」并鼓励优先使用。下文 §5 的旧 `action:"copy"|"rsync"` 调用形与「无 action」形仅作历史。
6
+ - 0.3.2-b 对齐(2026-10-19):`script` 已定名 `scriptPath`;`mode` 字段已移除(用 `<transport>:pty[:label]` 创建、`pty:<id|label>` 复用);`byossh:`/`byop:` 前缀已移除(`ssh`/`spawn` 参数在场即 BYO)。下文旧调用形仅作历史。
5
7
  - 输入:[2026-10-09 方向稿](../../10_plans/2026-10-09-remote-tool-optimization-directions.md)及 2026-10-10 会话裁决。
6
8
  - 执行计划:[2026-10-10-remote-tool-enhancement.md](../../superpowers/plans/2026-10-10-remote-tool-enhancement.md)。本规格优先于方向稿的冲突条款。
7
9
 
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // Hub PTY pump — the DUMB child of the canonical PTY session over a Hub
3
+ // machine (0.3.2-c). The session (better-dsh/src/remote/pty-session.ts)
4
+ // spawns `script -qfec 'stty -echo; … exec node <this file>' /dev/null`, so
5
+ // this process's stdin/stdout ride a host-side PTY; we forward those bytes
6
+ // over one WebSocket to the peer hub's dumb PTY executor
7
+ // (/api/superd/exec/pty: one WS = one `script -qfec bash /dev/null`, text
8
+ // frames = stream bytes, {"op":…} JSON frames = transport control, WS close
9
+ // = peer group-kill). All session intelligence (nonce framing, init
10
+ // handshake, env E-frames, two-stage interrupt, idle TTL) is in-band bytes
11
+ // produced by the SESSION — this pump never parses the stream, only the
12
+ // peer's control frames.
13
+ //
14
+ // Connection facts arrive via environment (never argv — credentials must not
15
+ // show up in `ps`):
16
+ // SUPERD_HUB_PTY_URL wss://…/api/superd/exec/pty (peer executor)
17
+ // SUPERD_HUB_PTY_COOKIE pairing cookie for the upgrade request
18
+ // SUPERD_HUB_PTY_WS ABSOLUTE path to a usable `ws` module (resolved by
19
+ // the caller before spawning; no resolution gamble
20
+ // here — a missing module is a creation-time error)
21
+ //
22
+ // Verbatim-asset discipline (helper-bin precedent): this file is copied
23
+ // byte-for-byte into lib/remote/ and the Super closure; no build step
24
+ // transforms it. Crash-proof by construction (mesh-gateway lesson): the
25
+ // 'error' listener is registered before anything can reject, and post-close
26
+ // sends are swallowed — the session's death path owns the teardown.
27
+ const PUMP_ENV_PREFIX = 'SUPERD_HUB_PTY_'
28
+ const [url, cookie, wsPath] = ['URL', 'COOKIE', 'WS'].map((k) => process.env[`SUPERD_HUB_PTY_${k}`])
29
+
30
+ // cookie may be legitimately empty (an unauthenticated test lane sends no header);
31
+ // the endpoint URL and the ws module path are the hard requirements.
32
+ if (url === undefined || url === '' || wsPath === undefined || wsPath === '') {
33
+ process.stderr.write(`hub-pty-pump: missing environment (${PUMP_ENV_PREFIX}URL/WS)\n`)
34
+ process.exit(1)
35
+ }
36
+
37
+ let WebSocket
38
+ try {
39
+ const require = (await import('node:module')).createRequire(import.meta.url)
40
+ const mod = require(wsPath)
41
+ WebSocket = typeof mod === 'function' ? mod : mod.default
42
+ } catch (err) {
43
+ process.stderr.write(`hub-pty-pump: cannot load ws module at ${wsPath} — ${err instanceof Error ? err.message : String(err)}\n`)
44
+ process.exit(1)
45
+ }
46
+ if (typeof WebSocket !== 'function') {
47
+ process.stderr.write('hub-pty-pump: the ws module did not yield a WebSocket constructor\n')
48
+ process.exit(1)
49
+ }
50
+
51
+ const socket = new WebSocket(url, { headers: cookie ? { cookie } : {} })
52
+ let opened = false
53
+ let forward = true
54
+
55
+ socket.on('error', (err) => {
56
+ // Always-on listener: an ECONNREFUSED or a rejected 401 upgrade must be a
57
+ // reported death, never an uncaught exception.
58
+ process.stderr.write(`hub-pty-pump: ${opened ? 'socket error' : 'connect failed'} — ${err instanceof Error ? err.message : String(err)}\n`)
59
+ })
60
+ socket.on('open', () => { opened = true; pumpStdin() })
61
+ socket.on('message', (data) => {
62
+ if (!forward) return
63
+ const text = typeof data === 'string' ? data : Array.isArray(data) ? Buffer.concat(data).toString('utf8') : Buffer.from(data).toString('utf8')
64
+ // Transport control frames (peer: {"op":"exit"} / {"op":"spawn-error"}) are
65
+ // addressed to THIS pump, not to the shell stream — the exit frame is
66
+ // followed by a socket close, which settles us below.
67
+ if (text.startsWith('{"op":')) {
68
+ try {
69
+ const parsed = JSON.parse(text)
70
+ if (typeof parsed.op === 'string') {
71
+ if (parsed.op === 'spawn-error' && typeof parsed.error === 'string') process.stderr.write(`hub-pty-pump: peer could not spawn the terminal — ${parsed.error}\n`)
72
+ return
73
+ }
74
+ } catch { /* not JSON after all: fall through and forward */ }
75
+ }
76
+ process.stdout.write(text)
77
+ })
78
+ socket.on('close', () => {
79
+ forward = false
80
+ // Before open this is a failed connection (diag already on stderr); after
81
+ // open it is the peer session ending — either way the child exiting is the
82
+ // session's dead signal.
83
+ process.exit(opened ? 0 : 1)
84
+ })
85
+
86
+ function pumpStdin() {
87
+ process.stdin.setEncoding('utf8')
88
+ process.stdin.on('data', (chunk) => {
89
+ if (!opened) return
90
+ try { socket.send(chunk) } catch { /* closing: the close handler exits us */ }
91
+ })
92
+ // The session's kill path tears down the process group; a plain stdin end
93
+ // (no kill) still means nobody will type more — close politely so the peer
94
+ // reaps its side.
95
+ process.stdin.on('end', () => { try { socket.close() } catch { /* already closing */ } })
96
+ process.stdin.on('error', () => { /* EPIPE on teardown: the close path owns the exit */ })
97
+ }