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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@
emit the previous mode's stale screen or queued output. Reconnects establish
the same fresh `GEOMETRY` → `SCREEN` → `DATA`/`EXIT` baseline.

### Atomic exact-id metadata patching

- `pty metadata patch --id <stable-id>` reads one merge-style JSON object from
stdin and atomically updates `displayName` and tags under one metadata lock.
It never falls back to display-name lookup, preserves unrelated tags, returns
`{ changed, metadata }`, and suppresses no-op writes and events.
- `patchMetadataById(id, patch)` exposes the same exact-id operation from
`@compoundingtech/pty/client`. Existing rename/tag APIs share the merge engine
while retaining their documented specialized events for compatibility.
- Metadata publication acquires the event lock before the metadata lock, so a
busy event log fails before either file changes. Event appends and retention
rewrites are serialized without a per-record byte-size assumption.
- `pty exec` now carries an opaque generation owner token in session children
and refuses stale same-id replacements. Sessions started by an older build
must be restarted once before they can use `pty exec`.
- The current display-name contract supersedes the permissive limits described
in earlier release notes: values must be nonempty, already trimmed,
single-line, free of Unicode control characters, and at most 160 Unicode
scalar values. Slash and backslash remain valid metadata characters.

### Storage format

Effective atomic patches append one `metadata_change` event whose `previous`
and `value` objects contain only changed `displayName` and tag keys. No-op
patches append no event. Lock contention cannot publish only one side of an
effective patch. Metadata remains the authoritative state: a process crash or
underlying I/O failure between its write and event append can omit the
notification because pty does not journal a cross-file transaction.

### Non-unique display names with unambiguous session resolution

- Display names are presentation metadata and no longer need to be unique.
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pty rename my-label # inside a session: add/change its dis
pty rename <ref> my-label # outside: set displayName on <ref>
pty rename --show <ref> # show current displayName
pty rename --clear [ref] # remove displayName
pty metadata patch --id myserver < patch.json # atomically patch displayName/tags by exact id

pty list # show active sessions (tags shown by default)
pty list --tags # include internal bookkeeping tags (ptyfile*, strategy, etc.)
Expand Down Expand Up @@ -127,6 +128,20 @@ exact stable id first, then a display name only when that label has one match.
Ambiguous display names fail without acting and print the candidate stable ids.
Use stable ids in scripts and automation.

For automation that must update presentation metadata without alias fallback,
`pty metadata patch --id <stable-id>` reads one merge-style JSON object from
stdin and returns `{ changed, metadata }` as JSON:

```sh
printf '%s' '{"displayName":"Worker","tags":{"role":"worker","old":null}}' \
| pty metadata patch --id a1b2c3d4
```

`displayName` and individual tag values use strings to set and `null` to clear;
omitted fields and tag keys remain unchanged. The operation holds the session's
metadata lock across one read/merge/atomic-write cycle. It fails if the exact id
is absent, even when a display name has the same text.

### Remote over fabric

`pty list --remote <peer>` lists another machine's sessions over [fabric](https://github.com/compoundingtech/fabric), which hands consumers a plain local Unix socket — pty never touches iroh. The remote machine serves a small control protocol that fabric exposes under the `pty-remote` ALPN. The recommended form is **on-demand**: fabric spawns the handler per dial, pipes the connection to its stdin/stdout, and owns persistence + roaming (no persistent pty daemon):
Expand Down Expand Up @@ -271,7 +286,7 @@ display_name = "My Web Server" # override the default `<prefix>-<sessionKey>`
cwd = "packages/web" # working directory (default: the manifest's dir)
```

`id` is validated like a `pty run --id` value (charset, sock-path length, uniqueness); omitted → pty generates a short random id at spawn time. `display_name` is permissive (≤ 500 chars, any printable text); omitted → defaults to `<prefix>-<sessionKey>` (or just `<sessionKey>` if no prefix). The two fields decouple the human label from the kernel-constrained filename — long prefixes that would have blown past `sockaddr_un.sun_path` (~104 bytes) now work because the actual sock filename is just the short id.
`id` is validated like a `pty run --id` value (charset, sock-path length, uniqueness); omitted → pty generates a short random id at spawn time. `display_name` must be nonempty, already trimmed, single-line, free of Unicode control characters, and at most 160 Unicode scalar values; `/` and `\` are allowed because the value is metadata, not a path. Omitted → defaults to `<prefix>-<sessionKey>` (or just `<sessionKey>` if no prefix). The two fields decouple the human label from the kernel-constrained filename — long prefixes that would have blown past `sockaddr_un.sun_path` (~104 bytes) now work because the actual sock filename is just the short id.

`cwd` sets the session's working directory. An absolute path is used as-is; a relative path resolves against the manifest's directory. Omitted → the session runs in the manifest's directory (the default). This decouples where a session runs from where its `pty.toml` lives — so a manifest kept in a subdirectory (e.g. `.convoy/pty.toml`, to keep a repo root pristine) can still run its sessions in the repo root with `cwd = ".."`. The declared `cwd` is honored on the initial `pty up` and preserved across manual and `strategy=permanent` respawns.

Expand Down
5 changes: 4 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 rm remove gc tag tag-multi emit rename up down test remote-serve"
commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename metadata up down test remote-serve"

if [[ ${COMP_CWORD} -eq 1 ]]; then
if [[ "${cur}" == -* ]]; then
Expand Down Expand Up @@ -122,6 +122,9 @@ _pty() {
COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
fi
;;
metadata)
COMPREPLY=($(compgen -W "--id" -- "${cur}"))
;;
up)
COMPREPLY=($(compgen -o dirnames -- "${cur}"))
;;
Expand Down
5 changes: 4 additions & 1 deletion completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ complete -c pty -n __pty_needs_command -a tag -d 'Read / write tags on one sessi
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 metadata -d 'Atomically patch presentation metadata by stable id'
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 All @@ -68,7 +69,7 @@ complete -c pty -n '__pty_using_command run' -l detach -s d -d 'Create in the ba
complete -c pty -n '__pty_using_command run' -l attach -s a -d 'Create OR attach if id already exists'
complete -c pty -n '__pty_using_command run' -l ephemeral -s e -d 'Ephemeral: auto-remove metadata on clean exit'
complete -c pty -n '__pty_using_command run' -l id -d 'Pin on-disk id (charset-validated)'
complete -c pty -n '__pty_using_command run' -l name -d 'Display label (any printable, ≤ 500 chars)'
complete -c pty -n '__pty_using_command run' -l name -d 'Display label (trimmed, single-line, ≤ 160 Unicode scalars)'
complete -c pty -n '__pty_using_command run' -l no-display-name -d 'Skip the auto-generated label'
complete -c pty -n '__pty_using_command run' -l tag -d 'Tag session (k=v, repeatable)'
complete -c pty -n '__pty_using_command run' -l env -d 'Overlay child environment (KEY=VALUE, repeatable)'
Expand Down Expand Up @@ -137,6 +138,8 @@ complete -c pty -n '__pty_using_command emit' -a '(__pty_sessions)' -d 'Session'
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'
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_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
8 changes: 7 additions & 1 deletion completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _pty() {
'tag-multi:Bulk tag ops across sessions'
'emit:Publish a user.* event'
'rename:Set / show / clear displayName'
'metadata:Atomically patch presentation metadata by stable id'
'up:Start sessions from pty.toml'
'down:Stop sessions from pty.toml'
'test:Run the pty test suite (vitest)'
Expand All @@ -58,7 +59,7 @@ _pty() {
'(a --attach){a,--attach}[Create OR attach if id already exists]' \
'(e --ephemeral){e,--ephemeral}[Ephemeral: auto-remove metadata on clean exit]' \
'--id[Pin on-disk id (charset-validated)]' \
'--name[Display label (any printable, ≤ 500 chars)]' \
'--name[Display label (trimmed, single-line, ≤ 160 Unicode scalars)]' \
'--no-display-name[Skip the auto-generated label]' \
'--tag[Tag session (k=v, repeatable)]' \
'--env[Overlay child environment (KEY=VALUE, repeatable)]' \
Expand Down Expand Up @@ -173,6 +174,11 @@ _pty() {
'--clear[Remove displayName]' \
'1:session:_pty_sessions'
;;
metadata)
_arguments \
'--id[Exact stable session id]' \
'1:mode:(patch)'
;;
up)
_arguments \
'1:directory:_directories'
Expand Down
40 changes: 39 additions & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,36 @@ matches. Resolve once, then pass `session.name` to socket-oriented APIs.

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

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

Atomically merge presentation metadata for one exact stable id. This API never
falls back to a matching display name. It holds the session metadata lock across
one read, merge, validation, and atomic write; unrelated tags are preserved and
a no-op returns `changed: false` without writing or emitting an event.

```typescript
const result = await patchMetadataById("a1b2c3d4", {
displayName: "Worker",
tags: { role: "worker", temporary: null },
});

interface MetadataPatch {
displayName?: string | null;
tags?: Record<string, string | null>;
}

interface MetadataPatchResult {
changed: boolean;
metadata: SessionMetadata;
}
```

Strings set values, `null` clears them, and omitted fields or tag keys remain
unchanged. A successful change emits one `metadata_change` event containing
only effective changes as `previous` and `value` snapshots. The existing
`setDisplayName` and `updateTags` APIs retain their specialized event types for
compatibility.

### `getSessionDir(): string`

Returns the session directory path — `$PTY_ROOT` if set (the legacy `$PTY_SESSION_DIR` name is still honored), otherwise `~/.local/state/pty`.
Expand Down Expand Up @@ -97,7 +127,11 @@ Remove a session's `.sock` and `.pid` files.

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

Remove all files for a session (socket, pid, metadata, events, lock).
Remove all files for a session (socket, pid, metadata, and events). Cleanup is
serialized by acquiring the event lock before the metadata/creation lock. It
throws when either lock has a live holder, changes no session files in that
case, and removes only locks acquired by the cleanup call. Dead holders' stale
locks are reclaimed.

### Types

Expand Down Expand Up @@ -407,6 +441,10 @@ Each extends `EventBase { session: string; type: EventType; ts: string }`.
`NotificationEvent` adds `title?`, `body?`, `source?: "osc9" | "osc99" | "osc777"`.
`TitleChangeEvent` adds `value: string`.

`MetadataChangeEvent` has type `"metadata_change"` and carries `previous` and
`value` objects. Only the changed `displayName` field and changed tag keys are
present; `null` represents an absent or cleared value.

## Keys (also available via `@compoundingtech/pty/keys`)

These functions are also available as a standalone browser-safe import via `@compoundingtech/pty/keys` (zero dependencies).
Expand Down
11 changes: 9 additions & 2 deletions docs/disk-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ For non-Node tools that want to read pty's state without paying Node startup. Th
| `<name>.sock` | daemon IPC socket (Unix) | 2 |
| `<name>.pid` | daemon pid (decimal) | 2 |
| `<name>.lock` | creation-race lock | 2 |
| `<name>.events.lock` | event append/retention lock | 2 |
| `theme` | last-selected TUI theme | 2 |
| `gc.log` | stdout/stderr of `pty gc` when run by launchd/cron (only present after auto-running gc is installed) | 2 |
| `<name>.json.tmp.<pid>.<rand>` | atomic-write tmp — readers MUST ignore | n/a |
Expand Down Expand Up @@ -100,14 +101,20 @@ Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event typ
| `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) |
| `display_name_change` | `previous: string\|null, value: string\|null` |
| `tags_change` | `previous, value` (full snapshots) |
| `metadata_change` | `previous, value` containing only changed `displayName` and tag keys; absent tag values are `null` |
| `user.<name>` | `data?, text?` — free-form, via `pty emit` |

A single line ≤ `PIPE_BUF` (~4 KB) is atomic per POSIX `O_APPEND`. Built-ins are well under. Keep large `user.*` payloads out of the event stream.
All event writers and retention rewrites are serialized by the per-session
event lock. A complete JSONL record is therefore published without relying on
an operating-system write-size limit, and retention cannot discard an append
that races its atomic rewrite. Async writers wait up to five seconds for a live
holder; synchronous writers fail immediately. Lock files are removed on release,
and a dead holder's stale lock is reclaimed by the next writer or cleanup.

## Reading from outside pty

```sh
jq -r '.tags["role"] // empty' "$PTY_ROOT/myserver.json"
```

For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `tags_change` / `display_name_change` / `session_*` fire on every mutation.
For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `metadata_change` / `tags_change` / `display_name_change` / `session_*` fire on every mutation.
Loading
Loading