Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Changelog

## 0.12.0 — reboot cutover

Reboot-moment release: pty relocates permanent-session respawn and the fast-fail crash-loop cap out to convoy. `pty gc` becomes clean-only; `pty-daemon` is spawned by the host rather than pty; `pty up` / `pty down` move to `convoy up` / `convoy down`. See `notes/lean-pty-core-supervision-spec.md` for the full contract.

### BREAKING — CLI surface

- **Removed `pty up` and `pty down`.** Manifest processing is now `convoy up` / `convoy down`. `pty.toml` stays as the manifest file name; convoy reads it verbatim. `readPtyFile` + `commandWithEnvExports` remain exported on `@myobie/pty/client` so convoy shares the parser without vendoring.
- **Removed `pty gc --fast-fail-window` and `--fast-fail-limit`.** The fast-fail respawn cap moved to convoy's reconcile loop. Per-session `strategy.fast-fail-window` / `strategy.fast-fail-limit` tags still work but are now read by convoy, not pty.
- **`pty gc` is clean-only.** The reconciliation pass keeps STEP 1 (orphan-kill on `parent=` tag), STEP 1.5 (abandoned-reap: cwd-gone; opt-in idle via `--idle-days` or `strategy.idle-days` tag), and STEP 3 (sweep exited non-permanent metadata). STEP 2 (permanent respawn) is gone. Permanent sessions that are gone stay in-place on disk for convoy's next reconcile tick to notice. Never spawns anything.

### BREAKING — API surface

- **`GcResult` trimmed.** `respawned`, `respawnFailed`, `flapped`, and `flappingSkipped` fields removed. The surviving shape is `{ removed, killedOrphanChildren, abandoned }`.
- **`sessions.ts:respawnPermanent` deleted.** The helper's role (respawn a `strategy=permanent` session, re-read pty.toml, thread bookkeeping tags) is now convoy's. Convoy either `execFile`'s `pty run -d` (recommended, per Nathan's Q2) or imports `spawnDaemon` from `@myobie/pty/client` directly.
- **`sessions.ts:classifyFlapping` deleted.** The classifier's role (fast-fail counter + command-hash divergence reset + flapping-status flip) is now convoy's. `commandFingerprint` stays exported so convoy computes byte-identical hashes; see spec §8.1 wire-format freeze.
- **`[flapping]` badge removed from `strategyMarker` / `pty list`.** Convoy renders its own list view if desired; pty stays neutral on the status.
- **`SessionFlappingEvent` interface + `EventType.SESSION_FLAPPING` stay exported.** Convoy imports both from `@myobie/pty/client` and emits the event via `appendEventSync`. The event payload shape is frozen (spec §8.1): `{ session, type: "session_flapping", ts, counter, limit, window }`. Any change requires a joint pty ⇄ convoy version bump.

### Non-breaking additions (from the pre-cutover branch)

- **Exported from `@myobie/pty/client`**: `commandFingerprint`, `DEFAULT_FAST_FAIL_WINDOW_SEC`, `DEFAULT_FAST_FAIL_LIMIT` (wire-format primitives convoy needs); `readPtyFile`, `commandWithEnvExports` (parser convoy shares).

### Tests

- Deleted: `tests/gc-flapping.test.ts`, `tests/gc-permanent.test.ts`, `tests/up-down.test.ts`, `tests/up-name-decouple.test.ts` — all exercised behavior that moved out of pty.
- Extracted `tests/pty-root-length-backstop.test.ts` from the previous `tests/gc-flap-clear-badge-root-len.test.ts`, keeping only the PTY_ROOT length backstop cases.
- `tests/gc-abandoned.test.ts` and `tests/gc-parent-child.test.ts` updated to assert the post-reboot behavior (exited permanents are left in-place; orphan-kill still removes children including permanent ones).

### Migration

- Reboot-only. This release is not incremental — it changes ownership of respawn. Bringing 0.11.0 pty online alongside a convoy that expects 0.12.0 pty (or vice versa) will leave permanent sessions with no respawn owner. Sequencing on Nathan's machine (or Johannes's): quiesce network → install pty 0.12.0 + convoy → run `convoy up`.

## 0.11.0

### `@myobie/pty/tui` — `text()` accepts an object form `{ fg, bold, ... }`
Expand Down
48 changes: 13 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,22 +104,19 @@ pty emit myserver user.note --text "checkpoint reached" # with a text payloa
pty restart myserver # restart an exited session
pty kill myserver # terminate a running session
pty rm myserver # remove an exited session's metadata
pty gc # reconcile sessions: kill orphan children, respawn permanents, sweep exited
pty gc # clean-only reconcile: orphan-kill, abandoned-reap, sweep exited
pty gc --dry-run # preview what gc would do without changing anything
pty gc --idle-days N # also reap permanent sessions with no attach in N days
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist # install macOS auto-gc
pty tag myserver role=web env=prod # set one or more tags on a session
pty tag myserver --rm role --rm env # remove one or more tags
pty tag-multi --filter-tag role=web env=prod # bulk write across matching sessions
pty tag-multi --all --json # bulk read tags across every session
pty tag-multi --all --yes audit=today # write to every session (--yes required)

pty up # start all sessions from ./pty.toml
pty up ./backend # start sessions from ./backend/pty.toml
pty up claude dev # start specific sessions from ./pty.toml
pty down # stop all sessions from ./pty.toml
pty down claude # stop specific sessions
```

Manifest-processing (`pty up` / `pty down`) has moved to convoy. `pty.toml` is now read by `convoy up`; pty's public API keeps `readPtyFile` + `commandWithEnvExports` exported on `@myobie/pty/client` so convoy shares the parser without vendoring. See `notes/lean-pty-core-supervision-spec.md`.

### Nesting Prevention

If you run `pty run` inside an existing pty session, pty detects the nesting via the `PTY_SESSION` environment variable and runs the command directly instead of creating a session-inside-a-session.
Expand Down Expand Up @@ -186,7 +183,7 @@ command = "bin/serve"
tags = { role = "server" }
```

Run `pty up` in the project directory (or `pty up /path/to/project`) to start all sessions. Run `pty down` to stop them. You can also start specific sessions: `pty up dev serve`.
`pty.toml` manifest processing (start / stop / reconcile) is owned by convoy — run `convoy up` in the project directory to start the declared sessions. Convoy reads the same file verbatim; pty's public API exposes `readPtyFile` and `commandWithEnvExports` on `@myobie/pty/client` so convoy shares the parser. For ad-hoc single sessions, `pty run -d -- <cmd>` still creates a background session directly.

Each session also supports two optional fields:

Expand All @@ -210,20 +207,18 @@ PORT = "8080"
LOG_LEVEL = "debug"
```

The values are exported into the session's shell before the command runs — `pty up` wraps every toml-managed session in `/bin/sh -c` so the `export K='V'; …` prefix is honored. They take effect on the next `pty up` after the session has stopped — restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill <name>` followed by `pty up` is the way to pick up a changed env block on an already-running session.
The values are exported into the session's shell before the command runs — convoy's `up` wraps every toml-managed session in `/bin/sh -c` so the `export K='V'; …` prefix is honored. They take effect on the next `convoy up` after the session has stopped. Restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill <name>` (convoy respawns on its next reconcile tick) is the way to pick up a changed env block on an already-running session.

### Permanent sessions

Tag a session with `strategy=permanent` and `pty gc` will respawn it whenever its daemon exits or vanishes:
Tag a session with `strategy=permanent` and convoy's reconcile loop will respawn it whenever its daemon exits or vanishes. `pty gc` is clean-only: it kills orphan children (`parent=` tag), reaps abandoned permanents (cwd-gone; opt-in idle), and sweeps exited non-permanent metadata — never respawns.

```sh
pty tag myserver strategy=permanent

# After myserver exits — manually or by crash — the next `pty gc` run
# brings it back. No backoff, no retry budget; the cron interval below
# is the rate limit. Sessions managed by pty.toml re-read the toml on
# respawn so command/env edits take effect immediately.
pty gc
# After myserver exits — manually or by crash — convoy's next reconcile
# tick respawns it (default 30 s). Sessions managed by pty.toml re-read
# the toml on respawn so command/env edits take effect immediately.
```

From `pty.toml`:
Expand All @@ -234,26 +229,9 @@ command = "bin/serve"
tags = { strategy = "permanent" }
```

Restart is stateless — every `pty gc` invocation re-derives intent from on-disk metadata. There's no in-memory restart counter, no `[failed]` state, no persisted bookkeeping. If a session's binary isn't reachable (volume not mounted, broken symlink), `pty gc` reports `Respawn failed:` and the next tick tries again.

**Fast-fail cap** — a permanent session whose leaf exits within `strategy.fast-fail-window` seconds of its previous `pty gc` respawn counts as a fast fail. After `strategy.fast-fail-limit` consecutive fast fails, `pty gc` writes `strategy.status=flapping` on the session, emits a `session_flapping` event, and stops respawning it. Subsequent gc ticks print `Skipped (flapping): <name>` and take no action. Defaults: 60 s window, 3 consecutive fast fails.

A flagged session shows `[flapping]` (red) in `pty list` in place of `[permanent]` — the operator's expectation has changed, so the badge reflects that.

Reset a flagged session with one of:

- `pty restart <name>` or `pty up` — the manual respawn drops all fast-fail bookkeeping (`strategy.status`, `strategy.consecutive-fast-fails`, `strategy.last-respawn-at`, `strategy.command-hash`), treating restart as an operator "please try again" signal.
- `pty tag <name> --rm strategy.status` — surgical reset that clears only the mark, leaving the counter intact for observability.
- Edit the session's `pty.toml` command — the classifier notices the SHA-256 fingerprint change and auto-resets the counter and mark on the next gc tick.

Per-session overrides tune the cap without editing gc's globals:

```sh
pty tag myserver strategy.fast-fail-window=120 # allow 2min of runtime before "fast"
pty tag myserver strategy.fast-fail-limit=5 # tolerate 5 fast fails before flapping
```
The reboot moment relocated respawn + the fast-fail crash-loop cap from `pty gc` to convoy. Convoy owns the classifier (fast-fail window + counter + flapping mark + command-hash reset); pty owns the wire-format for the tags convoy writes (`strategy.consecutive-fast-fails`, `strategy.last-respawn-at`, `strategy.command-hash`, `strategy.status`). See `notes/lean-pty-core-supervision-spec.md` §5 + §8.1 for the full contract.

CLI globals mirror the per-session tags (`--fast-fail-window=N`, `--fast-fail-limit=N`); the per-session tag wins when both are set.
The **operator-restart path** post-reboot: `pty kill <ref>` → convoy's reconcile tick sees the session gone → respawns via `pty run -d`. A manual kill on a long-lived agent doesn't count as a fast fail (it lived past the window). `pty restart` still works for a one-verb "kill and respawn now" that also strips convoy's bookkeeping tags to give the restart a clean slate.

### Parent-child sessions

Expand All @@ -265,7 +243,7 @@ pty run -d --name webserver-tail --tag parent=webserver -- tail -f log/web.log
# If `webserver` dies, the next `pty gc` SIGTERMs `webserver-tail`.
```

What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's `exitedAt` timestamp. Combinator with `strategy=permanent` is well-defined — orphan-kill wins (the child is removed, not respawned).
What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's `exitedAt` timestamp. Combinator with `strategy=permanent` is well-defined — orphan-kill wins (child removed by `pty gc`; convoy has nothing to respawn on the next tick).

Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whichever name sorts first dies first on the tick where both parents are gone; the loser dies the same tick because its parent (the just-killed winner) is also dead. No cycle detection needed.

Expand Down
9 changes: 2 additions & 7 deletions completions/pty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ _pty() {
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename up down test help"
commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename test help"

# Complete subcommand (first positional).
if [[ ${COMP_CWORD} -eq 1 ]]; then
Expand Down Expand Up @@ -86,7 +86,7 @@ _pty() {
;;
gc)
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}"))
COMPREPLY=($(compgen -W "-n --dry-run --idle-days --print-launchd-plist --interval" -- "${cur}"))
fi
;;
tag)
Expand All @@ -110,11 +110,6 @@ _pty() {
COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
fi
;;
up|down)
# Complete directories (containing pty.toml) or session names from an
# already-loaded toml. Directories are more common; keep it simple.
COMPREPLY=($(compgen -o dirnames -- "${cur}"))
;;
exec)
# After --, fall through to default (command + args) completion.
COMPREPLY=($(compgen -o default -- "${cur}"))
Expand Down
11 changes: 1 addition & 10 deletions completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,6 @@ complete -c pty -n __pty_needs_command -a tag -d 'Read / write tags on one
complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sessions'
complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event'
complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName'
complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml'
complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml'
complete -c pty -n __pty_needs_command -a test -d 'Run the pty test suite (vitest)'
complete -c pty -n __pty_needs_command -a help -d 'Show usage'

Expand Down Expand Up @@ -132,11 +130,9 @@ complete -c pty -n '__pty_using_command kill' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command rm' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command remove' -a '(__pty_sessions)' -d 'Session'

# ── gc ─────────────────────────────────────────────────────────────────
# ── gc (clean-only post-reboot; convoy owns respawn) ───────────────────
complete -c pty -n '__pty_using_command gc' -s n -l dry-run -d 'Preview without changing anything'
complete -c pty -n '__pty_using_command gc' -l idle-days -x -d 'Reap permanents with no attach in N days'
complete -c pty -n '__pty_using_command gc' -l fast-fail-window -x -d 'Fast-fail window (seconds; default 60)'
complete -c pty -n '__pty_using_command gc' -l fast-fail-limit -x -d 'Consecutive fast fails before flapping (default 3)'
complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Emit a launchd plist that runs pty gc'
complete -c pty -n '__pty_using_command gc' -l interval -x -d 'Plist StartInterval seconds (default 30)'

Expand All @@ -161,11 +157,6 @@ complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Sessio
complete -c pty -n '__pty_using_command rename' -l show -d 'Print current displayName'
complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName'

# ── up / down ──────────────────────────────────────────────────────────
for verb in up down
complete -c pty -n "__pty_using_command $verb" -a '(__fish_complete_directories)' -d 'Directory containing pty.toml'
end

# ── test ───────────────────────────────────────────────────────────────
complete -c pty -n '__pty_using_command test' -a 'watch' -d 'Watch mode'
complete -c pty -n '__pty_using_command test' -s t -x -d 'Run matching tests'
7 changes: 0 additions & 7 deletions completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ _pty() {
'tag-multi:Bulk tag ops across sessions'
'emit:Publish a user.* event'
'rename:Set / show / clear displayName'
'up:Start sessions from pty.toml'
'down:Stop sessions from pty.toml'
'test:Run the pty test suite (vitest)'
'help:Show usage'
)
Expand Down Expand Up @@ -113,8 +111,6 @@ _pty() {
_arguments \
'(-n --dry-run)'{-n,--dry-run}'[Preview without changing anything]' \
'--idle-days[Reap permanents with no attach in N days]:days:' \
'--fast-fail-window[Fast-fail window in seconds (default 60)]:seconds:' \
'--fast-fail-limit[Consecutive fast fails before flapping (default 3)]:count:' \
'--print-launchd-plist[Emit a launchd plist that runs pty gc]' \
'--interval[Plist StartInterval seconds (default 30)]:seconds:'
;;
Expand Down Expand Up @@ -143,9 +139,6 @@ _pty() {
'--clear[Remove displayName]' \
'1:session:_pty_sessions'
;;
up|down)
_arguments '1:directory:_directories'
;;
run)
# After --, fall back to normal (command + file) completion
local -i i
Expand Down
2 changes: 1 addition & 1 deletion docs/disk-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event typ
| `session_exec` | `previousCommand, command` |
| `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) |
| `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) |
| `session_flapping` | `counter, limit, window` — (`pty gc` flipped a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it) |
| `session_flapping` | `counter, limit, window` — emitted by convoy's reconcile loop when it flips a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it. Interface + `EventType.SESSION_FLAPPING` still exported from `@myobie/pty/client` so convoy shares the wire-format. |
| `display_name_change` | `previous: string\|null, value: string\|null` |
| `tags_change` | `previous, value` (full snapshots) |
| `user.<name>` | `data?, text?` — free-form, via `pty emit` |
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

# Generated from package-lock.json.
# Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json
npmDepsHash = "sha256-mer8rhDyD/j+htWDU8F1EH7MrnuS8pS57WdQgGH8cnQ=";
npmDepsHash = "sha256-yzWiYJ7sbxG1ulvmqA4X4ybs0Qtp7xacwy8PA0GCYBA=";

# node-pty has native code that needs these at build time
nativeBuildInputs = with pkgs; [ python3 pkg-config ];
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@myobie/pty",
"version": "0.11.0",
"version": "0.12.0",
"description": "Persistent terminal sessions with detach/attach, plus a Playwright-style testing library for TUI apps",
"type": "module",
"license": "MIT",
Expand Down
Loading
Loading