Skip to content

Latest commit

 

History

History
155 lines (133 loc) · 10.1 KB

File metadata and controls

155 lines (133 loc) · 10.1 KB

pty on-disk layout

Pre-1.0. Format may change in any release; pin to a pty version if you depend on these files. Breaking changes appear under ### Storage format in the CHANGELOG.

For non-Node tools that want to read pty's state without paying Node startup. The CLI is the canonical writer; the files below are the canonical readable surface.

Directory

$PTY_ROOT (default ~/.local/state/pty, mode 0700, single-user). Every CLI command honors the env var. The pre-Phase-2 name $PTY_SESSION_DIR still works with a one-time deprecation notice; set PTY_ROOT_LEGACY_SILENT=1 to suppress it.

file purpose tier
<name>.json session metadata 1
<name>.events.jsonl append-only event log 1
<name>.sock daemon IPC socket (Unix) 2
<name>.pid daemon pid (decimal) 2
<name>.lock creation-race lock 2
.recovery/ authenticated request/result exchange for supported live daemons 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
<name>.events.jsonl.tmp.<pid>.<rand> same n/a

Tier 1: we'll try not to break these; changes called out in CHANGELOG. Tier 2: pty-internal; may move freely.

Atomic write contract

pty writes to <target>.tmp.<pid>.<rand> then rename()s into place. POSIX same-filesystem rename is atomic — readers see the old version or the new one, never partial. When scanning the directory, filter *.tmp.*.

<name>.json (tier 1)

Pretty-printed JSON. Source of truth: SessionMetadata in src/sessions.ts.

{
  generation?: string;       // opaque daemon generation; guards cleanup ownership
  daemonPid?: number;        // daemon owning this generation, retained after child exit
  recovery?: {               // signal-free live-registry recovery capability
    protocol: 1;
    secret: string;          // opaque request-authentication key
    processStartToken: string;
    launchIdentity: string;
    rootDevice: number;
    rootInode: number;
    recoveryDirDevice: number;
    recoveryDirInode: number;
    metadataRevision: string; // exact revision bound to retained recovery state
  };
  command: string;            // resolved binary path
  args: string[];
  displayCommand: string;     // command as the user typed it
  cwd: string;
  rows?: number;              // initial terminal rows, persisted for restart
  cols?: number;              // initial terminal columns
  ephemeral?: boolean;
  isolateEnv?: boolean;
  extraEnv?: { [k: string]: string }; // explicit inherited-env overlay (`--env`)
  unsetEnv?: string[];         // inherited env keys removed before `extraEnv`
  env?: { [k: string]: string };      // exact child env for programmatic callers
  createdAt: string;          // ISO 8601
  exitCode?: number;          // present after clean exit
  exitedAt?: string;
  lastLines?: string[];       // snapshotted at exit
  tags?: { [k: string]: string };
  displayName?: string;
  lastAttachAt?: string;      // ISO 8601 — set by the daemon on every non-readonly ATTACH
  lastOutputAtMs?: number;    // unix ms — newest PTY output observed by the daemon
}

lastOutputAtMs is absent until the daemon observes output. While output continues, a trailing-edge debounce persists the newest stamp at most once per second; exit finalization carries the final in-memory value even when a debounce is pending.

unsetEnv and extraEnv form the persisted inherited-environment policy. Removals are applied first and explicit assignments second, so an assignment wins when both mention the same key. Older metadata without unsetEnv keeps the historical ambient-inheritance behavior.

  • Status (running / exited / vanished) is derived, not stored. A reachable socket or live pidfile process proves the daemon is running. If both paths are absent, daemonPid is accepted only when the retained recovery process-start token still matches that OS process.
  • generation and daemonPid are internal lifecycle guards. A daemon only removes files still owned by its generation, and pty rm waits for that daemon to finish deferred shutdown before it reports success. Readers should treat the generation token as opaque.
  • recovery is present only when the daemon can prove its OS process-start identity and the selected root is owned by the daemon user with no group/other permissions. A snapshot containing this capability can authenticate pty recover after the socket, pid, and metadata paths are externally unlinked. The root is mode 0700; treat the embedded secret as opaque and do not publish snapshots. The root and .recovery directory identities and permissions are revalidated before recovery state is exchanged. A signed retained revision rejects older snapshots after tags, display name, attach state, or other metadata changes. The signed revision advances before changed metadata is renamed into place: a partial publication may disable recovery, but never re-authorizes the previous snapshot. Successful recovery rotates the secret.
  • displayName is mutable presentation metadata and is not unique. Stable identity remains the <name> filename stem; consumers must not use displayName as a durable key.
  • Reserved tag keys (ptyfile*, strategy, anything starting with :) are pty/tool-internal; hidden from pty list unless --tags.
  • User-facing tags that drive pty behavior but are visible by default:
    • strategy=permanentpty gc respawns the session when its daemon exits (the historic supervisor's role; now stateless and run on a cron).
    • strategy.abandon-if-cwd-gone=false — opts a permanent session OUT of the on-by-default cwd-gone reap in pty gc step 1.5. Only meaningful with strategy=permanent.
    • strategy.idle-days=<N> — opts a permanent session INTO idle-reap: pty gc reaps it when lastAttachAt is older than N days. Takes precedence over the global --idle-days flag.
    • parent=<name>pty gc orphan-kills this session (SIGTERM + cleanup) when the referenced session's daemon is no longer alive. Combinator with strategy=permanent is well-defined: orphan-kill wins.
    • keep=true — exempts the session from reaping: unconditionally from the daemon's exit-time self-cleanup, and from pty gc's sweep until the session has been dead longer than pty gc --keep-max-age <dur> (default 7d; 0 sweeps every dead keep session on that pass). Age is anchored on exitedAt, or createdAt for a vanished session that never wrote one. Its metadata, lastLines, and events file therefore survive its death until that window elapses or an explicit pty rm, whichever comes first; a running session is never swept regardless of age. Any value other than false/0/no/off counts as set, so a mis-spelled value errs toward retaining. Without this tag, a non-permanent session's files are gone the moment its command finishes.
  • Lifetime: a non-permanent session's files are removed by its own daemon during shutdown once the child process terminates. Files therefore outlive the process only for keep (bounded by --keep-max-age), strategy=permanent, external pty kill, and vanished sessions (SIGKILLed daemon — no cleanup code ran). Readers that poll these files after a session finishes must set keep=true or accept the race.
  • Concurrent writers: last-write-wins; readers never see torn files. Cross-process writers can lose updates to the read-modify-write window.

<name>.events.jsonl (tier 1)

Append-only JSONL, one event per line. Auto-truncates from 1000 → 500 lines via atomic rewrite (the inode changes; tailing readers should re-open).

Envelope: { session: string; type: string; ts: string; ...payload }. Event types (source: src/events.ts):

type payload
bell
title_change value: string
notification title?, body?, source?: "osc9" | "osc99" | "osc777"
focus_request
cursor_visible
session_start tags?
session_exit exitCode, signal? — signal death (e.g. OOM SIGKILL) surfaces as exitCode = 128 + signal (SIGKILL 9 → 137), matching shell $?; signal carries the raw number
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_descendants_survived data: { pids } — a daemon signalled its child's process tree with TERM and then KILL and these processes were still alive. A record of what it could not kill, not a list of everything that outlived the session: a process that left the tree before the snapshot is not in 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

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

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

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.