Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ Like `git`, `pty` supports extensions: if you run `pty foo` and there's a `pty-f
```typescript
import {
spawnDaemon, listSessions, getSession,
getSessionExitEvidence, removeSessionGeneration,
SessionConnection, sendData, peekScreen, queryStats,
EventFollower, readRecentEvents,
extractFilterTags, matchesAllTags,
Expand Down
29 changes: 28 additions & 1 deletion completions/pty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,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 recover rm remove gc tag tag-multi emit rename metadata up down test remote-serve"
commands="run attach a exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata evidence up down test remote-serve"

if [[ ${COMP_CWORD} -eq 1 ]]; then
if [[ "${cur}" == -* ]]; then
Expand Down Expand Up @@ -128,6 +128,33 @@ _pty() {
metadata)
COMPREPLY=($(compgen -W "--id" -- "${cur}"))
;;
evidence)
if [[ ${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=($(compgen -W "snapshot remove" -- "${cur}"))
return
fi
case "${COMP_WORDS[2]}" in
snapshot)
if [[ "${prev}" == "--id" ]]; then
return
fi
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "--id" -- "${cur}"))
fi
;;
remove)
if [[ "${prev}" == "--id" ]]; then
return
fi
if [[ "${prev}" == "--expected-generation" ]]; then
return
fi
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "--id --expected-generation" -- "${cur}"))
fi
;;
esac
;;
up)
COMPREPLY=($(compgen -o dirnames -- "${cur}"))
;;
Expand Down
16 changes: 16 additions & 0 deletions completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ function __pty_using_command
test (count $cmd) -ge 2; and test "$cmd[2]" = "$argv[1]"
end

function __pty_needs_subcommand
set -l cmd (commandline -opc)
test (count $cmd) -eq 2; and test "$cmd[2]" = "$argv[1]"
end

function __pty_using_subcommand
set -l cmd (commandline -opc)
test (count $cmd) -ge 3; and test "$cmd[2]" = "$argv[1]"; and test "$cmd[3]" = "$argv[2]"
end

complete -c pty -f

# ── Global flags ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -62,6 +72,7 @@ complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sess
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 metadata -d 'Atomically patch presentation metadata by stable id'
complete -c pty -n __pty_needs_command -a evidence -d 'Read or remove exact-generation retained exit evidence'
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)'
Expand Down Expand Up @@ -142,6 +153,11 @@ complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName'
complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command metadata' -l id -d 'Exact stable session id'
complete -c pty -n '__pty_using_command metadata' -x -a 'patch' -d 'Value'
complete -c pty -n '__pty_needs_subcommand evidence' -a snapshot -d 'Read retained evidence for one exact generation'
complete -c pty -n '__pty_using_subcommand evidence snapshot' -l id -x -d 'Exact stable session id'
complete -c pty -n '__pty_needs_subcommand evidence' -a remove -d 'Remove only the matching terminal generation'
complete -c pty -n '__pty_using_subcommand evidence remove' -l id -x -d 'Exact stable session id'
complete -c pty -n '__pty_using_subcommand evidence remove' -l expected-generation -x -d 'Opaque generation returned by evidence snapshot'
complete -c pty -n '__pty_using_command up' -F
complete -c pty -n '__pty_using_command down' -F
complete -c pty -n '__pty_using_command test' -l t -d 'Run matching tests'
Expand Down
18 changes: 18 additions & 0 deletions completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ _pty() {
'emit:Publish a user.* event'
'rename:Set / show / clear displayName'
'metadata:Atomically patch presentation metadata by stable id'
'evidence:Read or remove exact-generation retained exit evidence'
'up:Start sessions from pty.toml'
'down:Stop sessions from pty.toml'
'test:Run the pty test suite (vitest)'
Expand Down Expand Up @@ -184,6 +185,23 @@ _pty() {
'--id[Exact stable session id]' \
'1:mode:(patch)'
;;
evidence)
if (( CURRENT == 3 )); then
_values 'operation' snapshot remove
else
case ${words[2]} in
snapshot)
_arguments \
'--id[Exact stable session id]:id:'
;;
remove)
_arguments \
'--id[Exact stable session id]:id:' \
'--expected-generation[Opaque generation returned by evidence snapshot]:generation:'
;;
esac
fi
;;
up)
_arguments \
'1:directory:_directories'
Expand Down
38 changes: 37 additions & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,45 @@ display name resolves only when it has exactly one match; multiple matches throw
an error that lists the candidate stable ids. Returns `null` when no session
matches. Resolve once, then pass `session.name` to socket-oriented APIs.

### `getSessionExitEvidence(name: string): Promise<SessionExitEvidenceResult>`

Read the retained terminal evidence for one dead daemon generation. A snapshot
contains the stable session id, opaque generation, `exited` or `vanished`
status, nullable exit code, `stream: "combined"`, and the exact persisted
bounded `lastLines`. An absent persisted tail is tagged `unavailable`; it is
not reported as an empty tail.

The operation fails closed with a tagged `unavailable` result when the session
is missing, running, locked, lacks a generation, or has invalid metadata. The
evidence reader rejects malformed, oversized, symlink, non-regular, type-invalid,
and over-200-line metadata as `invalid-metadata`.

### `removeSessionGeneration(name: string, expectedGeneration: string): Promise<RemoveSessionGenerationResult>`

Remove all PTY artifacts only when the retained metadata still belongs to the
given opaque generation and its daemon is gone. Results distinguish `removed`,
`missing`, `generation-mismatch`, `not-terminal`, `invalid-metadata`, and
`busy`. A replacement generation is never removed. Cleanup errors other than
absence are thrown, and metadata is removed last so failed cleanup retains the
evidence for retry.

Rust and other non-TypeScript consumers can use the equivalent machine-only
CLI boundary. Both operations address only an immutable stable id, emit exactly
one tagged JSON document on stdout, and exit 0 for semantic outcomes:

```sh
pty evidence snapshot --id a1b2c3d4
pty evidence remove --id a1b2c3d4 --expected-generation 7f44b35e
```

Invalid arguments and operational failures exit nonzero with a diagnostic on
stderr. A reconciler should durably consume the snapshot before passing its
opaque generation to `remove`; a mismatch must leave the replacement intact.

### `validateName(name: string): void`

Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]` and be at most 255 characters.
Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]`, cannot be
`.` or `..`, and are at most 255 characters.

### `patchMetadataById(id: string, patch: MetadataPatch): Promise<MetadataPatchResult>`

Expand Down
8 changes: 8 additions & 0 deletions docs/vrs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,11 @@ implementation contract and validation map live in [spec.md](./spec.md).
remote routing preserve the applicable runtime, stream, geometry, registry,
and lifecycle contracts. A surface rejects unsupported capabilities instead
of silently weakening them; tests use real PTYs and processes.
- **R12 Exact-generation retained exit evidence:** Supported client and CLI
surfaces expose one bounded, tagged snapshot of retained terminal evidence
for an exact stable id and opaque generation, distinguishing exited,
vanished, live, missing, busy, unavailable, and invalid state. Conditional
cleanup removes artifacts only while that same terminal generation remains;
it never removes a live or replacement generation. Semantic outcomes and
operational failures are machine-distinguishable, and validation covers the
snapshot-to-cleanup race with real processes.
114 changes: 114 additions & 0 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,119 @@ Explicit lifecycle commands and `gc` own mutation. Cleanup is authorized by the
observed generation; removal wins over late daemon finalization, and permanent
respawn cannot overwrite a replacement (R03, R10).

### Retained exit evidence

An evidence consumer separates observation from cleanup (R12):

```text
exact stable id
|
v
bounded snapshot -- opaque generation --> durable consumer action
|
v
conditional same-generation remove
|
+-------------------+--------------------+
| | |
removed semantic refusal cleanup I/O failure
| | |
artifacts absent artifacts untouched metadata retained;
cleanup may be partial
```

The exported client API and machine CLI share these tagged result schemas:

```typescript
type ExitEvidenceTail =
| { _tag: "present"; lastLines: string[] }
| { _tag: "unavailable" }

type ExitEvidenceResult =
| {
_tag: "snapshot"
snapshot: {
name: string
generation: string
status: "exited" | "vanished"
exitCode: number | null
stream: "combined"
tail: ExitEvidenceTail
}
}
| {
_tag: "unavailable"
reason:
| "missing"
| "running"
| "busy"
| "generation-unavailable"
| "invalid-metadata"
}

type RemoveGenerationResult =
| { _tag: "removed" }
| { _tag: "missing" }
| { _tag: "generation-mismatch" }
| { _tag: "not-terminal" }
| { _tag: "invalid-metadata" }
| { _tag: "busy" }
```

Snapshot and remove accept an exact filename-safe stable id, never a mutable
display-name reference. The generation is an opaque, nonempty token returned by
the snapshot; callers compare or return it without interpreting its format.
`exited` carries the recorded integer exit code, while `vanished` has a null
exit code because no terminal exit record exists. `tail.present` is the exact
persisted combined-stream line array, including an empty array;
`tail.unavailable` means no retained tail was persisted.

The evidence metadata reader opens the metadata path read-only, nonblocking,
and without following symlinks. It requires a regular file no larger than 1
MiB and a JSON object with these structural constraints:

| Field | Constraint |
| --- | --- |
| `generation` | required nonempty string |
| `daemonPid` | optional positive integer |
| `exitedAt`, `exitCode` | both absent, or nonempty string plus integer |
| `lastLines` | optional array of at most 200 strings |

Unknown compatible fields are ignored. Malformed JSON, oversized or nonregular
artifacts, symlinks, invalid field types, mixed terminal fields, and over-bound
tails produce `invalid-metadata`; they are neither truncated nor treated as
missing. A legacy record without `generation` is explicitly
`generation-unavailable` for snapshot and `generation-mismatch` for removal.

Snapshot holds the stable-id creation lock across metadata validation and the
daemon-generation liveness check. Conditional removal serializes event and
creation mutation, then applies this order:

1. read and structurally validate metadata;
2. compare the retained opaque generation with the expected generation;
3. refuse a live matching generation;
4. re-read, revalidate, and compare the generation;
5. remove socket, pid, event, and recovery-revision artifacts;
6. remove metadata last, preserving evidence if an earlier cleanup step fails;
7. release both locks.

Steps 5 and 6 are not transactional. If step 5 fails, auxiliary artifacts
removed earlier in the sequence remain absent, while metadata remains as
retryable exact-generation evidence. No rollback is attempted. Missing
artifacts are idempotent. Contention and semantic refusal happen before cleanup
mutation and return tagged results. Argument, metadata-cleanup I/O, and
output-transport failures remain operational errors rather than success-shaped
results. The CLI output law is:

| CLI outcome | stdout | stderr | exit status |
| --- | --- | --- | --- |
| semantic snapshot/remove result | exactly one tagged JSON document | empty | 0 |
| argument or operational failure | no success JSON | diagnostic | nonzero |

`evidence snapshot --id` and `evidence remove --id --expected-generation`
provide leaf-specific help and completion schemas; snapshot never advertises
the remove-only generation option (R11, R12).

### Live registry recovery

A supporting daemon may publish an opaque recovery capability only when it can
Expand Down Expand Up @@ -220,6 +333,7 @@ input, resize, and multi-client geometry without mocks.
| R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) |
| R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) |
| R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) |
| R12 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [client API](../../src/client-api.ts), [CLI](../../src/cli.ts), [completions](../../src/completions.ts) | [exit evidence](../../tests/exit-reap.test.ts), [generation guard](../../tests/gc-generation-guard.test.ts), [immediate reuse](../../tests/rm-immediate-reuse.test.ts), [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [security](../../tests/security-fixes.test.ts) |

`node scripts/verify-docs.ts --vrs-only` validates this two-document shape,
sequential requirement IDs, links, and complete requirement references.
4 changes: 3 additions & 1 deletion scripts/verify-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function verifyVrs(): void {
const requirementsPath = path.join(vrsRoot, "requirements.md");
const specPath = path.join(vrsRoot, "spec.md");
const errors: string[] = [];
let requirementCount = 0;

if (actual.join("\n") !== expected.join("\n")) {
errors.push(`docs/vrs must contain only ${expected.join(" and ")}`);
Expand All @@ -32,6 +33,7 @@ function verifyVrs(): void {
const ids = [...requirements.matchAll(/^- \*\*(R\d{2}) [^*]+:\*\*/gm)].map(
(match) => match[1],
);
requirementCount = ids.length;

if (ids.length === 0) errors.push("requirements.md defines no requirement IDs");
if (!ids.every((id, index) => id === `R${String(index + 1).padStart(2, "0")}`)) {
Expand Down Expand Up @@ -67,7 +69,7 @@ function verifyVrs(): void {
console.error(`VRS verification failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
process.exit(1);
}
console.log("Verified 2 VRS documents and 11 requirement IDs");
console.log(`Verified 2 VRS documents and ${requirementCount} requirement IDs`);
}

verifyVrs();
Expand Down
Loading
Loading