This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): excellent per-subcommand --help, thin SKILL.md, 0.3s default --seq delay (#68)

Three bundled improvements toward "pty is independently useful: a great --help
(the reference) + a thin SKILL.md (the judgment)".

--help (the reference):
- Every subcommand now ships focused `--help` (usage + every flag + >=1 example)
via a central COMMAND_HELP registry + a single interceptor. Previously most
subcommands errored or silently ran on `--help`. renameUsage/printEmitHelp now
read from the same source, so error-usage and --help can't drift.
- Top-level `usage()` already listed every subcommand; added the missing
`run --force` line.
- tests/help.test.ts asserts every subcommand has working `--help`, and that no
dispatch `case` is undocumented (drift guard).

--seq default delay (decision from Nathan):
- `pty send --seq` now inserts a 0.3s gap between items by default so a trailing
key:return doesn't race ahead of the program parsing the typed text.
`--with-delay 0` = straight stream (opt-out); `--with-delay N` honored.
- resolveSeqDelayMs() (pure, in client.ts) is the single decision point; the
library send() still treats delayMs literally, so programmatic callers are
unchanged.
- tests/seq-delay.test.ts: deterministic (a) default=300ms, (b) 0=0, (c) N=N,
plus a delta-timed end-to-end check that 0 = no spacing.
- `pty send --help` + top-level usage state the 0.3 default and the 0 opt-out.

SKILL.md (the judgment): thinned to the routing-hook + what/when/idiom/footguns/
delegate shape. Footguns captured: broken global `pty` on PATH silently breaks
the whole message bus (st shells to `pty send`); PTY_ROOT is the isolation
mechanism (PTY_SESSION_DIR is masked under an ambient PTY_ROOT); and the --seq
timing footgun with the WHY (byte-burst vs spaced input; Enter racing the
program's parse/render), now mostly handled by the 0.3s default.

authored by

Nathan and committed by
GitHub
(Jul 10, 2026, 11:09 AM +0200) d20da9c7 7d17be8a

+548 -181
+67 -145
docs/SKILL.md
··· 1 - # PTY — Run and manage background processes 2 - 3 - Run `pty --help` to see the full command reference. 4 - 5 - Use `pty` to run processes in managed terminal sessions. Prefer pty over raw 6 - background commands (`&`, `nohup`, piping) for better lifecycle control, 7 - readable output, and the ability to wait for results. 8 - 9 - ## When to use 10 - 11 - - Running any long-lived or background process (dev servers, test suites, builds) 12 - - Interactive CLI tools that need a real terminal (auth via keychain, TUI, REPL) 13 - - Any task where you want to start work, do something else, then check results 14 - - Processes you may need to re-read output from later 15 - 16 - ## Session lifecycle 17 - 18 - `strategy=permanent` sessions are restarted by `pty gc` (typically a launchd `StartInterval=30` task — `pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist`). Restarts are stateless — no backoff tracking, no retry budget. Expect up to one interval of latency before a session comes back. 19 - 20 - ### 1. Create a detached session 21 - 22 - ```bash 23 - pty run -d --name <descriptive-name> --tag owner=<agent> -- <command> [args...] 24 - ``` 25 - 26 - Use `--cwd` to run in a specific directory: 27 - 28 - ```bash 29 - pty run -d --name <name> --cwd /path/to/project --tag owner=<agent> -- <command> 30 - ``` 31 - 32 - Tag sessions so they are identifiable. Never touch sessions you did not create. 33 - 34 - ### 2. Wait for the process to be ready 35 - 36 - ```bash 37 - pty peek --wait "expected text" --plain <name> -t 10 38 - ``` 39 - 40 - Check the output to confirm the process has started (e.g. "Authenticated", 41 - "Listening", a prompt character). 42 - 43 - ### 3. Send input (if interactive) 44 - 45 - ```bash 46 - pty send <name> "your message here" 47 - pty send <name> --seq key:return 48 - ``` 49 - 50 - For multi-step input use `--seq` chains: 51 - 52 - ```bash 53 - pty send <name> --seq "first line" --seq key:return 54 - ``` 55 - 56 - Use `--with-delay` if the tool needs time between inputs: 57 - 58 - ```bash 59 - pty send <name> --with-delay 0.5 --seq "text" --seq key:return 60 - ``` 61 - 62 - ### 4. Wait for output and read results 63 - 64 - Wait for specific text to appear: 65 - 66 - ```bash 67 - pty peek --wait "expected output" --plain <name> -t 120 68 - ``` 69 - 70 - Read the full scrollback (not just the visible screen): 71 - 72 - ```bash 73 - pty peek --full --plain <name> 74 - ``` 75 - 76 - Read just the current visible screen: 77 - 78 - ```bash 79 - pty peek --plain <name> 80 - ``` 81 - 82 - Check recent events without following: 83 - 84 - ```bash 85 - pty events --recent <name> 86 - ``` 87 - 88 - ### 5. Clean up 89 - 90 - Always kill sessions you created when done: 91 - 92 - ```bash 93 - pty kill <name> 94 - ``` 95 - 96 - ## Rules 97 - 98 - - **Always use `--plain` with peek** so output is readable (no ANSI escapes) 99 - - **Always use `-t` (timeout)** on `--wait` so you don't block forever 100 - - **Always tag sessions** so they are identifiable and attributable 101 - - **Always clean up** — kill sessions when you are done 102 - - **Never touch sessions you did not create** — check `pty list --tags` if unsure 103 - - **Use `--full` when output may exceed the screen** — peek without it only 104 - shows the visible terminal buffer 105 - 106 - ## Naming conventions 1 + --- 2 + name: pty 3 + description: >- 4 + Run and manage long-lived or background processes — dev servers, test suites, 5 + builds, interactive CLIs, agents — in persistent, detachable terminal 6 + sessions. Reach for pty INSTEAD of `&` / nohup / raw background shell whenever 7 + you need to start work, go do something else, then come back to read its 8 + output, send it input, or restart it; and for any interactive tool that needs 9 + a real TTY (auth/keychain prompts, TUIs, REPLs). 10 + when_to_use: >- 11 + An agent needs to start a process and check on it later; run a dev server / 12 + test suite / build and wait for a readiness or result line; drive an 13 + interactive CLI that needs a real terminal; or keep a process alive across 14 + disconnects. NOT for one-shot commands whose output you read immediately — 15 + run those directly. 16 + --- 107 17 108 - Name sessions by purpose: `gemini-review`, `test-runner`, `dev-server`, etc. 109 - Keep names lowercase with hyphens. 18 + # pty — persistent terminal sessions 110 19 111 - ## Quick reference 20 + ## What it is 21 + `pty` runs a command in a managed terminal session you can detach from and 22 + reconnect to later, from anywhere (including over SSH). It's the terminal / 23 + session layer: `run`, `list`, `peek`, `send`, `restart`, `kill`, `up`. 112 24 113 - | Action | Command | 114 - |---|---| 115 - | Create detached | `pty run -d --name <n> --tag owner=<a> -- <cmd>` | 116 - | Peek (plain) | `pty peek --plain <n>` | 117 - | Peek (full scrollback) | `pty peek --full --plain <n>` | 118 - | Wait for text | `pty peek --wait "text" --plain <n> -t 30` | 119 - | Send text | `pty send <n> "text"` | 120 - | Send enter | `pty send <n> --seq key:return` | 121 - | Recent events | `pty events --recent <n>` | 122 - | Follow events | `pty events <n>` | 123 - | List sessions | `pty list --tags` | 124 - | Kill session | `pty kill <n>` | 125 - 126 - ## Common patterns 25 + ## When to reach for it 26 + - A long-lived / background process: dev server, test suite, build, watcher, an agent. 27 + - An interactive CLI that needs a real TTY: keychain/auth prompts, a TUI, a REPL. 28 + - Any "start it, go do something else, come back to read / send / restart" task. 127 29 128 - ### Run a dev server and wait for it 129 - 130 - ```bash 131 - pty run -d --name dev-server --tag owner=agent -- npm run dev 132 - pty peek --wait "Listening" --plain dev-server -t 30 133 - # Server is ready — do your work 134 - pty kill dev-server 135 - ``` 136 - 137 - ### Run tests and read results 30 + Prefer `pty` over `&` / `nohup` / pipes for these — you get lifecycle control, 31 + readable replayed output, and the ability to wait for specific text. For a 32 + one-shot command whose output you read right now, just run it directly. 138 33 139 - ```bash 140 - pty run -d --name tests --tag owner=agent -- npm test 141 - pty peek --wait "passed" --wait "failed" --plain tests -t 120 142 - pty peek --full --plain tests 143 - pty kill tests 34 + ## The idiom (happy path) 35 + ```sh 36 + pty run -d --name <name> --tag owner=<you> -- <command> # start detached, tagged 37 + pty peek --wait "<ready text>" --plain <name> -t 30 # block until ready 38 + pty peek --full --plain <name> # read full output 39 + pty send <name> --seq "<text>" --seq key:return # send input + Enter 40 + pty kill <name> # clean up when done 144 41 ``` 42 + Tag the sessions you create; only touch sessions you created. 145 43 146 - ### Run a build and check for errors 44 + ## Footguns (the ones that actually bite) 45 + - **A broken global `pty` on `$PATH` silently breaks the whole message bus.** 46 + `st` / smalltalk delivery shells out to `pty send` found on `$PATH`. If a 47 + global-install symlink points at a stale or broken `pty`, *every* agent's 48 + message delivery fails network-wide — silently. Run `pty` from the intended 49 + install; if you do global-install, confirm `pty --version` works before 50 + trusting delivery. 51 + - **Isolation is `PTY_ROOT`, not `PTY_SESSION_DIR`.** To keep scratch/test 52 + sessions out of the production registry, set `PTY_ROOT=<dir>`. 53 + `PTY_SESSION_DIR` is a deprecated alias and is *ignored* when `PTY_ROOT` is 54 + already set (as it is inside a supervised session tree) — so setting only 55 + `PTY_SESSION_DIR` there leaks your sessions into the ambient registry. pty 56 + now warns when both are set. 57 + - **Sending text + Enter: mind the timing (top cause of "I sent it but nothing 58 + happened").** `pty send <ref> "text"` sends NO newline — to submit, use 59 + `pty send <ref> --seq "text" --seq key:return`. The *why* it can silently fail: 60 + a terminal program processes a burst of bytes differently from spaced-out 61 + input. With zero spacing, the trailing `key:return` routinely arrives before 62 + the program's readline / PTY event loop has parsed and rendered the typed 63 + text (and before bracketed-paste framing closes), so the Enter submits an 64 + empty or partial line. `pty send` now inserts a **0.3s gap between `--seq` 65 + items by default** so each chunk is consumed before the next — you usually 66 + don't need to think about it. Overrides: `--with-delay <sec>` to tune (some 67 + slow TUIs want 0.5s+), and **`--with-delay 0` for a raw back-to-back stream** 68 + (fast/bulk sends where you know the receiver can take it). 69 + - **Don't nest.** Inside a session, a bare `pty run` runs the command directly 70 + (nesting guard); use `pty run -d` to explicitly background a new session from 71 + inside one. 147 72 148 - ```bash 149 - pty run -d --name build --tag owner=agent -- npm run build 150 - pty peek --wait "error" --wait "successfully" --plain build -t 60 151 - pty peek --full --plain build 152 - pty kill build 153 - ``` 73 + ## The exact surface 74 + Run `pty --help` for the full subcommand list, and `pty <subcommand> --help` for 75 + that command's flags and examples. `pty --version` prints `<semver>+<short-sha>`.
+287 -36
src/cli.ts
··· 4 4 import * as readline from "node:readline/promises"; 5 5 import { spawnSync, execFileSync } from "node:child_process"; 6 6 import { randomBytes } from "node:crypto"; 7 - import { attach, peek, send, queryStats, type StatsResult } from "./client.ts"; 7 + import { attach, peek, send, queryStats, resolveSeqDelayMs, type StatsResult } from "./client.ts"; 8 8 import { printVersion } from "./version.ts"; 9 9 import { parseSeqValue } from "./keys.ts"; 10 10 import { ··· 71 71 } 72 72 } 73 73 74 + // Per-subcommand help. `pty <cmd> --help` (or `-h`) prints the matching entry: 75 + // usage synopsis, every flag, and at least one concrete example. Kept here as 76 + // the single source so the deprecation/error paths and --help never drift. 77 + // A test (tests/help.test.ts) asserts every subcommand has an entry. 78 + const COMMAND_HELP: Record<string, string> = { 79 + run: `Usage: pty run [flags] -- <command> [args...] 80 + 81 + Create a session and attach to it (use -d to leave it running in the background). 82 + 83 + Flags: 84 + --id <id> Pin the on-disk id (sock/json filename; charset-validated, ≤ 104-byte sock path) 85 + --name <label> Explicit display label (any printable text, ≤ 500 chars) 86 + --no-display-name Skip the auto cwd+command label — just the id 87 + -d, --detach Create in the background; don't attach 88 + -a, --attach Create, OR attach if a session with the same id already exists 89 + -e, --ephemeral Auto-remove metadata on clean exit 90 + --tag key=value Tag the session (repeatable) 91 + --cwd <path> Working directory for the command 92 + --isolate-env Scrub the child env to a safe allow-list (for remote-reachable sessions) 93 + --force Create even from inside another pty session (bypass the nesting guard) 94 + 95 + Examples: 96 + pty run -- node server.js 97 + pty run -d --name "API" --tag role=web -- node server.js`, 98 + 99 + attach: `Usage: pty attach [-r] [--force] <ref> 100 + 101 + Reconnect to a session (alias: pty a). Detach again with Ctrl+\\. 102 + 103 + Flags: 104 + -r, --auto-restart Auto-restart the session if it has exited 105 + --force Attach even from inside another pty session (nested) 106 + 107 + Examples: 108 + pty attach myserver 109 + pty attach -r myserver`, 110 + 111 + exec: `Usage: pty exec -- <command> [args...] 112 + 113 + Replace the current session's leaf process with a new command. Run INSIDE a 114 + session (uses $PTY_SESSION); the session keeps its id and metadata. 115 + 116 + Examples: 117 + pty exec -- codex 118 + pty exec -- bash -l`, 119 + 120 + peek: `Usage: pty peek [-f] [--plain] [--full] [--wait <text> [-t <sec>]] <ref> 121 + 122 + Print a session's screen (or follow it, or wait for text) without attaching. 123 + 124 + Flags: 125 + --plain Plain text, no ANSI escapes (best for scripts / agents) 126 + --full Full scrollback, not just the visible viewport 127 + -f, --follow Follow output read-only (Ctrl+\\ to stop) 128 + --wait <text> Block until <text> appears on screen 129 + -t, --timeout <sec> Timeout (seconds) for --wait 130 + 131 + Examples: 132 + pty peek --plain myserver 133 + pty peek --wait "Listening" -t 10 --plain myserver`, 134 + 135 + send: `Usage: pty send <ref> "text" 136 + pty send <ref> --seq <chunk> [--seq key:<name>] ... 137 + 138 + Send text or key events to a session. Raw text is sent with NO implicit newline — 139 + to send text followed by Enter, use --seq (see the second example). 140 + 141 + Flags: 142 + --seq <value> Ordered chunk or key event (repeatable). key:<name> sends a 143 + key, e.g. key:return, key:ctrl+c, key:tab 144 + --with-delay <sec> Delay (seconds) between --seq items. DEFAULT 0.3s so a 145 + trailing key:return doesn't race ahead of the program 146 + parsing the text. --with-delay 0 = straight stream (no gap). 147 + --paste "<text>" Wrap the payload in bracketed-paste markers 148 + 149 + Examples: 150 + pty send myserver "hello" 151 + pty send myserver --seq "git status" --seq key:return # 0.3s gap by default 152 + pty send myserver --with-delay 0 --seq a --seq b --seq c # back-to-back, no gap`, 153 + 154 + events: `Usage: pty events [--all | <ref>] [--recent] [--json] [--wait <type> [-t <sec>]] 155 + 156 + Follow a session's event log (bell, title, notifications, tag/rename changes, user.* events). 157 + 158 + Flags: 159 + --all Follow every session, interleaved (omit <ref>) 160 + --recent Print recent events and exit (don't follow) 161 + --json Emit raw JSONL 162 + --wait <type> Block until an event of <type> appears 163 + -t, --timeout <sec> Timeout (seconds) for --wait 164 + 165 + Examples: 166 + pty events myserver 167 + pty events --recent --json myserver`, 168 + 169 + list: `Usage: pty list [--json] [--tags] [--filter-tag k=v] [--remote] [--status <s>] [--summary] 170 + 171 + List sessions (alias: pty ls). User tags show by default. 172 + 173 + Flags: 174 + --json Emit JSON 175 + --tags Include internal bookkeeping tags (ptyfile*, strategy.*) 176 + --filter-tag k=v Only sessions with the tag (repeatable, ALL must match) 177 + --remote Include remote sessions via pty-relay (when installed) 178 + --status <state> Filter by status: running | exited | vanished 179 + --older-than <dur> Only sessions older than a duration (e.g. 30m, 2h, 3d) 180 + --newer-than <dur> Only sessions newer than a duration 181 + --summary Print a one-line count summary instead of the list 182 + 183 + Examples: 184 + pty list 185 + pty list --filter-tag role=web --json`, 186 + 187 + stats: `Usage: pty stats [--json] [--all] [<ref>] 188 + 189 + Live CPU / memory / PIDs. Omit <ref> for every session. 190 + 191 + Flags: 192 + --json Emit stats as JSON (one snapshot) 193 + --all Include every session (with an explicit <ref> given) 194 + 195 + Examples: 196 + pty stats 197 + pty stats --json myserver`, 198 + 199 + restart: `Usage: pty restart [-y] [--force] <ref> 200 + 201 + SIGTERM the session's daemon and respawn it from stored metadata (command, cwd, 202 + tags, displayName). Prompts first if it's still running. 203 + 204 + Flags: 205 + -y, --yes Skip the "kill and restart?" prompt 206 + --force Attach after restart even from inside another pty session 207 + 208 + Examples: 209 + pty restart myserver 210 + pty restart -y myserver`, 211 + 212 + kill: `Usage: pty kill <ref> 213 + 214 + SIGTERM a running session's daemon. Metadata is kept — restart or \`pty rm\` it later. 215 + 216 + Examples: 217 + pty kill myserver`, 218 + 219 + rm: `Usage: pty rm <ref> 220 + 221 + Remove an exited session's files (socket/pid/json/events) (alias: pty remove). 222 + Won't remove a running session — kill it first. 223 + 224 + Examples: 225 + pty rm myserver`, 226 + 227 + gc: `Usage: pty gc [-n] [--idle-days N] [--fast-fail-window=N] [--fast-fail-limit=N] 228 + pty gc --print-launchd-plist [--interval=N] 229 + 230 + One reconciliation pass: sweep exited/vanished, orphan-kill \`parent=<name>\` children, 231 + reap abandoned permanents, respawn \`strategy=permanent\` sessions. 232 + 233 + Flags: 234 + -n, --dry-run Preview without changing anything 235 + --idle-days N Also reap permanents with no attach in N days 236 + --fast-fail-window=N Fast-fail window seconds (default 60; per-session tag wins) 237 + --fast-fail-limit=N Consecutive fast fails before flapping (default 3; per-session tag wins) 238 + --print-launchd-plist Print a macOS launchd plist that runs 'pty gc' on an interval 239 + --interval=N Plist StartInterval seconds (default 30) 240 + 241 + Examples: 242 + pty gc --dry-run 243 + pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist`, 244 + 245 + tag: `Usage: pty tag <ref> Show tags 246 + pty tag <ref> key=value [key=value...] Set tags 247 + pty tag <ref> --rm key [--rm key...] Remove tags 248 + 249 + Read or write tags on one session. Updates apply before removals. 250 + 251 + Flags: 252 + --rm <key> Remove a tag key (repeatable) 253 + 254 + Examples: 255 + pty tag myserver role=web env=prod 256 + pty tag myserver --rm env`, 257 + 258 + "tag-multi": `Usage: pty tag-multi <selector> [ops...] 259 + 260 + Bulk read / write tags across many sessions. 261 + Selector (one of): --all | --filter-tag k=v (repeatable) | <ref>... 262 + Ops (any of): key=value | --rm key 263 + 264 + Flags: 265 + --all Select every session 266 + --filter-tag k=v Select sessions with the tag (repeatable) 267 + --rm <key> Remove a tag key (repeatable) 268 + --json Read mode: emit tags as JSON 269 + -y, --yes Required to write when the selector is --all 270 + 271 + Examples: 272 + pty tag-multi --filter-tag role=web env=prod 273 + pty tag-multi --all --json`, 274 + 275 + emit: `Usage: pty emit <type> [--json <payload>] [--text <string>] 276 + pty emit <ref> <type> [--json <payload>] [--text <string>] 277 + 278 + Publish a user.* event to a session's event log. Inside a session the ref 279 + defaults to $PTY_SESSION. Types must start with "user." — "session_*", "state.*", 280 + "bell", etc. are reserved. 281 + 282 + Flags: 283 + --json <payload> Attach a JSON payload 284 + --text <string> Attach a text payload 285 + 286 + Examples: 287 + pty emit user.build-done 288 + pty emit user.progress --json '{"pct": 40}' 289 + pty emit myserver user.tests-passed --json '{"n": 42}'`, 290 + 291 + rename: `Usage: pty rename <new-display-name> Inside a session: set displayName 292 + pty rename <ref> <new-display-name> Outside: set displayName on <ref> 293 + pty rename --show <ref> Show the current displayName 294 + pty rename --clear [ref] Clear the displayName 295 + 296 + displayName is a mutable alias; the session's stable id (name) never changes. 297 + 298 + Examples: 299 + pty rename my-friendly-name 300 + pty rename webapp "Web Frontend" 301 + pty rename --show webapp`, 302 + 303 + up: `Usage: pty up [<dir>] [<name>...] 304 + 305 + Start sessions declared in a pty.toml. With no args, reads ./pty.toml and starts all. 306 + 307 + Examples: 308 + pty up 309 + pty up ./backend 310 + pty up web worker`, 311 + 312 + down: `Usage: pty down [<dir>] [<name>...] 313 + 314 + Stop sessions declared in a pty.toml. 315 + 316 + Examples: 317 + pty down 318 + pty down web`, 319 + 320 + test: `Usage: pty test [watch | -t "<pattern>"] 321 + 322 + Run the pty test suite (a thin vitest passthrough). 323 + 324 + Examples: 325 + pty test 326 + pty test -t "peek"`, 327 + }; 328 + 329 + /** Print a subcommand's focused help. Resolves aliases; returns false for an 330 + * unknown command so the caller can fall through. */ 331 + function printCommandHelp(cmd: string): boolean { 332 + const canonical = ({ a: "attach", ls: "list", remove: "rm" } as Record<string, string>)[cmd] ?? cmd; 333 + const help = COMMAND_HELP[canonical]; 334 + if (!help) return false; 335 + console.log(help); 336 + return true; 337 + } 338 + 74 339 function usage(): void { 75 340 console.log(`Usage: 76 341 pty Interactive session manager (fullscreen TUI) ··· 90 355 pty run --cwd /path -- <command> Run in a specific directory 91 356 pty run --isolate-env -- <command> Scrub the child env to a safe allow-list 92 357 (intended for remote-reachable sessions) 358 + pty run --force -- <command> Create even from inside another pty session (nested) 93 359 94 360 Attach & interact: 95 361 pty attach <ref> Attach to an existing session (alias: pty a) ··· 98 364 pty exec -- <command> [args...] Replace the current session's process (inside a session) 99 365 pty send <ref> "text" Send raw text (no implicit newline) 100 366 pty send <ref> --seq "text" --seq key:return Send an ordered sequence of chunks / key events 101 - pty send <ref> --with-delay 0.5 --seq ... Insert a delay (seconds) between --seq items 367 + (0.3s gap between items by default) 368 + pty send <ref> --with-delay <sec> --seq ... Override the gap; --with-delay 0 = straight stream 102 369 pty send <ref> --paste "<big text>" Wrap the payload in bracketed-paste markers 103 370 104 371 Observe: ··· 325 592 } 326 593 327 594 const command = dispatchArgs[0]; 595 + 596 + // A subcommand's own `--help` / `-h` (in the first position after the command) 597 + // prints that command's focused help and exits 0. `--root <path>` is already 598 + // spliced out of `args` above, so `args[1]` is the token after the subcommand. 599 + // First-position only, so `pty send <ref> --help` still sends "--help" as text. 600 + if ((args[1] === "-h" || args[1] === "--help") && printCommandHelp(command)) { 601 + return; 602 + } 328 603 329 604 switch (command) { 330 605 case "interactive": ··· 683 958 send({ 684 959 name: resolvedSendName, 685 960 data, 686 - delayMs: delaySecs != null ? delaySecs * 1000 : undefined, 961 + // Default to a 0.3s inter-item gap so a trailing key:return doesn't race 962 + // ahead of the program parsing the typed text. `--with-delay 0` opts out. 963 + delayMs: resolveSeqDelayMs(delaySecs), 687 964 ...(paste ? { paste: true } : {}), 688 965 }); 689 966 break; ··· 964 1241 } 965 1242 966 1243 case "up": { 967 - if (args[1] === "-h" || args[1] === "--help") { 968 - console.log("Usage: pty up [dir] [name...]\n\nStart sessions defined in pty.toml."); 969 - break; 970 - } 971 - // pty up [dir] [name...] 1244 + // pty up [dir] [name...] (--help handled by the central interceptor) 972 1245 const upArgs = args.slice(1); 973 1246 let dir: string | undefined; 974 1247 const names: string[] = []; ··· 987 1260 } 988 1261 989 1262 case "down": { 990 - if (args[1] === "-h" || args[1] === "--help") { 991 - console.log("Usage: pty down [dir] [name...]\n\nStop sessions defined in pty.toml."); 992 - break; 993 - } 994 - // pty down [dir] [name...] 1263 + // pty down [dir] [name...] (--help handled by the central interceptor) 995 1264 const downArgs = args.slice(1); 996 1265 let dir: string | undefined; 997 1266 const names: string[] = []; ··· 1831 2100 } 1832 2101 1833 2102 function renameUsage(): void { 1834 - console.error( 1835 - "Usage:\n" + 1836 - " pty rename <new-display-name> Inside a session: set displayName on the current session\n" + 1837 - " pty rename <ref> <new-display-name> Outside: set displayName on <ref>\n" + 1838 - " pty rename --show <ref> Show the current displayName for <ref>\n" + 1839 - " pty rename --clear Inside a session: clear displayName\n" + 1840 - " pty rename --clear <ref> Outside: clear displayName on <ref>\n" + 1841 - "\n" + 1842 - "displayName is a mutable alias. The session's stable id (name) never changes." 1843 - ); 2103 + // Single source of truth: the same text `pty rename --help` prints, to stderr 2104 + // for the error paths. 2105 + console.error(COMMAND_HELP.rename); 1844 2106 } 1845 2107 1846 2108 async function cmdRename(rawArgs: string[]): Promise<void> { ··· 2455 2717 } 2456 2718 2457 2719 function printEmitHelp(): void { 2458 - console.log(`Usage: 2459 - pty emit <type> [--json <payload>] [--text <string>] 2460 - pty emit <session-ref> <type> [--json <payload>] [--text <string>] 2461 - 2462 - Publishes a user.* event to a session's events log. Inside a pty 2463 - session, the ref defaults to $PTY_SESSION. Event types must start 2464 - with "user." — "session_*", "state.*", "bell", etc. are reserved. 2465 - 2466 - Examples: 2467 - pty emit user.build-done 2468 - pty emit user.progress --json '{"pct": 40}' 2469 - pty emit user.note --text "starting deploy" 2470 - pty emit myserver user.tests-passed --json '{"n": 42}'`); 2720 + // Single source of truth: same text as `pty emit --help`. 2721 + console.log(COMMAND_HELP.emit); 2471 2722 } 2472 2723 2473 2724 // (state / wrap / unwrap removed in the lean-core pass — smalltalk's
+16
src/client.ts
··· 171 171 paste?: boolean; 172 172 } 173 173 174 + /** Default spacing (ms) the `pty send` CLI inserts between `--seq` items when 175 + * the caller doesn't pass `--with-delay`. A burst of bytes and spaced-out 176 + * input are processed differently by terminal programs — a trailing `key:return` 177 + * fired with zero delay routinely lands before the program has parsed/rendered 178 + * the typed text, submitting an empty or partial line. 0.3s lets each chunk be 179 + * consumed. See docs/SKILL.md. This default lives in the CLI layer only; the 180 + * library `send()` still treats `delayMs` literally (undefined/0 = no spacing). */ 181 + export const DEFAULT_SEQ_DELAY_MS = 300; 182 + 183 + /** Resolve the `pty send` inter-item delay in ms from the `--with-delay <sec>` 184 + * argument: absent → the 0.3s default; an explicit value (including 0, the 185 + * straight-stream escape hatch) → that value. Pure; exported for testing. */ 186 + export function resolveSeqDelayMs(withDelaySecs: number | undefined): number { 187 + return withDelaySecs != null ? Math.round(withDelaySecs * 1000) : DEFAULT_SEQ_DELAY_MS; 188 + } 189 + 174 190 /** Send data to a session without attaching. Silent on success. */ 175 191 export function send(options: SendOptions): void { 176 192 const socketPath = getSocketPath(options.name);
+73
tests/help.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as path from "node:path"; 4 + import { fileURLToPath } from "node:url"; 5 + import { spawnSync } from "node:child_process"; 6 + 7 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 + const nodeBin = process.execPath; 9 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 10 + const cliSource = fs.readFileSync(path.join(__dirname, "..", "src", "cli.ts"), "utf8"); 11 + 12 + // Canonical subcommands that must each ship focused `--help`. 13 + const COMMANDS = [ 14 + "run", "attach", "exec", "peek", "send", "events", "list", "stats", 15 + "restart", "kill", "rm", "gc", "tag", "tag-multi", "emit", "rename", 16 + "up", "down", "test", 17 + ]; 18 + // Aliases that must resolve to the same help. 19 + const ALIASES = ["a", "ls", "remove"]; 20 + // Dispatch `case` labels that are NOT per-subcommand commands (no focused help 21 + // expected): the interactive TUI, and the global help/version verbs+flags. 22 + const NON_COMMAND_CASES = new Set([ 23 + "interactive", "i", "help", "--help", "-h", "version", "--version", "-v", "-V", 24 + ]); 25 + 26 + function help(cmd: string) { 27 + return spawnSync(nodeBin, [cliPath, cmd, "--help"], { 28 + encoding: "utf8", 29 + timeout: 15000, 30 + env: { ...process.env, PTY_ROOT_LEGACY_SILENT: "1" }, 31 + }); 32 + } 33 + 34 + describe("pty --help — per-subcommand help", () => { 35 + for (const cmd of [...COMMANDS, ...ALIASES]) { 36 + it(`\`pty ${cmd} --help\` prints usage + an example and exits 0`, () => { 37 + const r = help(cmd); 38 + expect(r.status).toBe(0); 39 + // Usage synopsis. 40 + expect(r.stdout).toMatch(/^Usage: pty /); 41 + // At least one concrete example (an `Examples:` header or a ` pty …` line 42 + // beyond the synopsis). 43 + const exampleLines = r.stdout.split("\n").filter((l) => /^ {2}pty /.test(l)); 44 + expect(exampleLines.length).toBeGreaterThan(0); 45 + // Help must not have executed the command (no session-list / JSON output). 46 + expect(r.stdout).not.toMatch(/^\[/); 47 + }); 48 + } 49 + }); 50 + 51 + describe("pty --help — no drift", () => { 52 + it("every dispatch `case` is either a documented command or a known non-command", () => { 53 + // Extract every `case "X":` label from the dispatcher. 54 + const cases = [...cliSource.matchAll(/case\s+"([^"]+)":/g)].map((m) => m[1]); 55 + const documented = new Set([...COMMANDS, ...ALIASES]); 56 + const uncovered = cases.filter((c) => !documented.has(c) && !NON_COMMAND_CASES.has(c)); 57 + // A new subcommand added without focused help (or without being listed as a 58 + // non-command) will show up here — add it to COMMAND_HELP + this test. 59 + expect(uncovered).toEqual([]); 60 + }); 61 + 62 + it("top-level `pty --help` lists every subcommand", () => { 63 + const r = spawnSync(nodeBin, [cliPath, "--help"], { 64 + encoding: "utf8", 65 + timeout: 15000, 66 + env: { ...process.env, PTY_ROOT_LEGACY_SILENT: "1" }, 67 + }); 68 + expect(r.status).toBe(0); 69 + for (const cmd of COMMANDS) { 70 + expect(r.stdout).toContain(`pty ${cmd} `); 71 + } 72 + }); 73 + });
+105
tests/seq-delay.test.ts
··· 1 + import { describe, it, expect, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + import { resolveSeqDelayMs, DEFAULT_SEQ_DELAY_MS } from "../src/client.ts"; 8 + 9 + // (a) default (no --with-delay) inserts 0.3s between --seq items; 10 + // (b) --with-delay 0 = straight stream, NO spacing (the escape hatch); 11 + // (c) --with-delay N = N. 12 + 13 + describe("resolveSeqDelayMs — the `pty send --seq` delay decision", () => { 14 + it("(a) defaults to 0.3s when --with-delay is absent", () => { 15 + expect(DEFAULT_SEQ_DELAY_MS).toBe(300); 16 + expect(resolveSeqDelayMs(undefined)).toBe(300); 17 + }); 18 + 19 + it("(b) --with-delay 0 resolves to 0 (straight stream, no spacing)", () => { 20 + expect(resolveSeqDelayMs(0)).toBe(0); 21 + }); 22 + 23 + it("(c) --with-delay N resolves to N * 1000 ms", () => { 24 + expect(resolveSeqDelayMs(0.1)).toBe(100); 25 + expect(resolveSeqDelayMs(0.5)).toBe(500); 26 + expect(resolveSeqDelayMs(2)).toBe(2000); 27 + }); 28 + }); 29 + 30 + // End-to-end: prove the resolved delay is actually applied by `pty send`. 31 + // Timing is compared as a DELTA against the straight-stream baseline so node 32 + // startup cancels out and the assertions stay robust. 33 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 34 + const nodeBin = process.execPath; 35 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 36 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 37 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-seq-")); 38 + const bgPids: number[] = []; 39 + afterAll(() => { 40 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 41 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 42 + }); 43 + 44 + async function startDaemon(dir: string, name: string): Promise<void> { 45 + const config = JSON.stringify({ 46 + name, command: "cat", args: [], displayCommand: "cat", 47 + cwd: os.tmpdir(), rows: 24, cols: 80, 48 + }); 49 + const child = spawn(nodeBin, [serverModule], { 50 + detached: true, stdio: ["ignore", "ignore", "ignore"], 51 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: dir }, 52 + }); 53 + child.unref(); 54 + if (child.pid) bgPids.push(child.pid); 55 + const sock = path.join(dir, `${name}.sock`); 56 + const start = Date.now(); 57 + while (Date.now() - start < 5000) { 58 + try { fs.statSync(sock); return; } catch {} 59 + await new Promise((r) => setTimeout(r, 50)); 60 + } 61 + throw new Error(`daemon "${name}" never started`); 62 + } 63 + 64 + /** Wall-clock ms of one `pty send` invocation. */ 65 + function timeSend(dir: string, args: string[]): number { 66 + const start = Date.now(); 67 + spawnSync(nodeBin, [cliPath, "send", ...args], { 68 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_ROOT_LEGACY_SILENT: "1" }, 69 + encoding: "utf8", timeout: 20000, 70 + }); 71 + return Date.now() - start; 72 + } 73 + 74 + describe("pty send --seq delay is applied end-to-end", () => { 75 + // 4 items = 3 inter-item gaps → default ≈ 900ms of spacing, plenty above the 76 + // node-startup noise floor once we subtract the straight-stream baseline. 77 + const ITEMS = ["--seq", "a", "--seq", "b", "--seq", "c", "--seq", "d"]; 78 + 79 + it("default spaces items but --with-delay 0 does not (0 = straight stream)", async () => { 80 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 81 + const name = `sq${Math.random().toString(36).slice(2, 6)}`; 82 + await startDaemon(dir, name); 83 + 84 + const straight = timeSend(dir, [name, "--with-delay", "0", ...ITEMS]); 85 + const dflt = timeSend(dir, [name, ...ITEMS]); 86 + 87 + // The default adds ~900ms (3 × 0.3s) over the straight stream. Generous 88 + // margin (≥ 600ms) keeps it robust under load. 89 + expect(dflt - straight).toBeGreaterThan(600); 90 + // And the straight stream itself is quick (no 0.9s of inserted delay). 91 + expect(straight).toBeLessThan(dflt - 500); 92 + }, 30000); 93 + 94 + it("--with-delay N scales the spacing", async () => { 95 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 96 + const name = `sq${Math.random().toString(36).slice(2, 6)}`; 97 + await startDaemon(dir, name); 98 + 99 + const straight = timeSend(dir, [name, "--with-delay", "0", ...ITEMS]); 100 + const slow = timeSend(dir, [name, "--with-delay", "0.2", ...ITEMS]); 101 + 102 + // 3 gaps × 0.2s ≈ 600ms over the straight baseline. 103 + expect(slow - straight).toBeGreaterThan(400); 104 + }, 30000); 105 + });