This repository has no description
0

Configure Feed

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

Document the on-disk layout under docs/disk-layout.md

For non-Node consumers that want to read pty's session state directly
without paying Node CLI startup. Covers PTY_SESSION_DIR, the file
table with stability tiers (tier 1 = <name>.json, .events.jsonl;
tier 2 = .sock, .pid, .lock, theme, supervisor/), the SessionMetadata
TS shape, the events JSONL envelope and every concrete event type,
and the <target>.tmp.<pid>.<rand> atomic-write convention with an
explicit "third-party readers MUST ignore these" note.

Pre-1.0 banner: schema may change in any release; pin to a pty
version. CHANGELOG carries a "### Storage format" heading whenever
a tier-1 file shape changes.

Drift guard: tests/disk-layout-docs.test.ts asserts every
SessionMetadata field name, every concrete event-type literal, and
every file extension we write under PTY_SESSION_DIR appears in the
doc. SessionMetadata and EventBase carry "PUBLIC FORMAT" comment
markers pointing at the doc + the smoke test.

README's Events section gains a one-paragraph pointer to the doc and
mentions git-style command forwarding (pty <subcommand> ->
pty-<subcommand> on $PATH) as the recommended path for native
fast-path readers.

Nathan Herald (Apr 25, 2026, 11:23 AM +0200) 51a3bbb3 e9b208a9

+229
+4
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Storage format 6 + - **Documented the on-disk layout** in [docs/disk-layout.md](docs/disk-layout.md). Covers `PTY_SESSION_DIR`, the full directory contents (`<name>.json`, `.events.jsonl`, `.sock`, `.pid`, `.lock`, `theme`, `supervisor/`), the metadata JSON shape field-by-field, the events JSONL line format and every built-in event type, the atomic-write tmp-file convention (`<target>.tmp.<pid>.<rand>` — third-party readers must ignore these), and stability tiers (tier 1 = "we'll try not to break this," tier 2 = "may move freely"). Aimed at non-Node consumers who want to skip the CLI's startup cost — read the JSON directly or ship a fast-path reader as a `pty-<subcommand>` binary on `$PATH`. Pre-1.0 caveat: schema may change in any release; pin to a pty version. The README's Events section now points at the doc. 7 + - **Drift-guard test** (`tests/disk-layout-docs.test.ts`) asserts every `SessionMetadata` field name, every concrete event-type literal, and every file extension we write under `PTY_SESSION_DIR` is mentioned in the doc. Adding a field or event type without documenting it fails CI. The corresponding interfaces in `src/sessions.ts` and `src/events.ts` carry `// PUBLIC FORMAT` comments pointing at the doc. 8 + 5 9 ### Interactive TUI 6 10 - **Readline-style editing shortcuts in the interactive filter input** (closes #24). The `pty` / `pty i` / `pty interactive` picker's filter input was append-only (backspace + printable chars) — no cursor, no word motion, no kill-line. It now carries a real cursor and routes every keystroke through `applyTextKey`, which means: `ctrl+a` / `ctrl+e` / `home` / `end` to jump, `left` / `right` / `alt+←/→` / `alt+b` / `alt+f` to walk and word-motion, `ctrl+u` to clear, `ctrl+w` to delete the previous word (new), `ctrl+k` to kill to end of line (new), and printable characters insert at the cursor. The filter line renders with `renderFieldNodes` so the cursor paints on top of the character under it (inverse-video) without shoving neighbors sideways. 7 11 - **`applyTextKey` (in `@myobie/pty/tui`) gained `ctrl+w` and `ctrl+k`.** Every text-field consumer — the interactive filter, the form widget, the command palette, the prompt bar — inherits the shortcuts for free.
+4
README.md
··· 171 171 172 172 Event files auto-truncate at 1,000 lines and are cleaned up with the 24-hour dead session TTL. 173 173 174 + ### On-disk format 175 + 176 + Session metadata, events, and supporting files all live under `$PTY_SESSION_DIR` (default `~/.local/state/pty`). The full layout — file naming, JSON shape, atomic-write contract, event types, stability tiers — is documented in [docs/disk-layout.md](docs/disk-layout.md). Third parties can read these files directly to skip the Node CLI's startup cost; `git`-style command forwarding (`pty <subcommand>` resolves to a `pty-<subcommand>` binary on `$PATH`) lets you ship native fast-path readers as `pty` subcommands. 177 + 174 178 ### Project Files 175 179 176 180 A project can include a `pty.toml` to declare its sessions:
+88
docs/disk-layout.md
··· 1 + # pty on-disk layout 2 + 3 + > **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. 4 + 5 + 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. 6 + 7 + ## Directory 8 + 9 + `$PTY_SESSION_DIR` (default `~/.local/state/pty`, mode `0700`, single-user). Every CLI command honors the env var. 10 + 11 + | file | purpose | tier | 12 + |---|---|---| 13 + | `<name>.json` | session metadata | 1 | 14 + | `<name>.events.jsonl` | append-only event log | 1 | 15 + | `<name>.sock` | daemon IPC socket (Unix) | 2 | 16 + | `<name>.pid` | daemon pid (decimal) | 2 | 17 + | `<name>.lock` | creation-race lock | 2 | 18 + | `theme` | last-selected TUI theme | 2 | 19 + | `supervisor/state.json` | supervisor state | 2 (internal) | 20 + | `<name>.json.tmp.<pid>.<rand>` | atomic-write tmp — readers MUST ignore | n/a | 21 + | `<name>.events.jsonl.tmp.<pid>.<rand>` | same | n/a | 22 + 23 + **Tier 1**: we'll try not to break these; changes called out in CHANGELOG. 24 + **Tier 2**: pty-internal; may move freely. 25 + 26 + ## Atomic write contract 27 + 28 + 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.*`. 29 + 30 + ## `<name>.json` (tier 1) 31 + 32 + Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`. 33 + 34 + ```ts 35 + { 36 + command: string; // resolved binary path 37 + args: string[]; 38 + displayCommand: string; // command as the user typed it 39 + cwd: string; 40 + createdAt: string; // ISO 8601 41 + exitCode?: number; // present after clean exit 42 + exitedAt?: string; 43 + lastLines?: string[]; // snapshotted at exit 44 + tags?: { [k: string]: string }; 45 + state?: { [k: string]: unknown }; 46 + displayName?: string; 47 + } 48 + ``` 49 + 50 + - Status (`running` / `exited` / `vanished`) is *derived* from socket + pid, not stored. 51 + - Reserved tag keys (`ptyfile*`, `strategy`, `supervisor.status`, anything starting with `:`) are pty/tool-internal; hidden from `pty list` unless `--tags`. 52 + - Concurrent writers: last-write-wins; readers never see torn files. Cross-process writers can lose updates to the read-modify-write window. 53 + 54 + ## `<name>.events.jsonl` (tier 1) 55 + 56 + Append-only JSONL, one event per line. Auto-truncates from 1000 → 500 lines via atomic rewrite (the inode changes; tailing readers should re-open). 57 + 58 + Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event types (source: `src/events.ts`): 59 + 60 + | type | payload | 61 + |---|---| 62 + | `bell` | — | 63 + | `title_change` | `value: string` | 64 + | `notification` | `title?, body?, source?: "osc9" \| "osc99" \| "osc777"` | 65 + | `focus_request` | — | 66 + | `cursor_visible` | — | 67 + | `session_start` | `tags?` | 68 + | `session_exit` | `exitCode` | 69 + | `session_exec` | `previousCommand, command` | 70 + | `session_restart` | `restartCount, backoffMs` | 71 + | `session_failed` | `restartCount, reason` | 72 + | `supervisor_start` | — | 73 + | `supervisor_stop` | — | 74 + | `display_name_change` | `previous: string\|null, value: string\|null` | 75 + | `tags_change` | `previous, value` (full snapshots) | 76 + | `state.set` | `key, value` | 77 + | `state.delete` | `key` | 78 + | `user.<name>` | `data?, text?` — free-form, via `pty emit` | 79 + 80 + A single line ≤ `PIPE_BUF` (~4 KB) is atomic per POSIX `O_APPEND`. Built-ins are well under. Keep large `user.*` / `state.set` payloads in `state` (atomic-rename), not events. 81 + 82 + ## Reading from outside pty 83 + 84 + ```sh 85 + jq -r '.tags["role"] // empty' "$PTY_SESSION_DIR/myserver.json" 86 + ``` 87 + 88 + For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `state.set` / `tags_change` / `display_name_change` fire on every mutation.
+6
src/events.ts
··· 22 22 23 23 export type EventType = (typeof EventType)[keyof typeof EventType]; 24 24 25 + // PUBLIC FORMAT — `<name>.events.jsonl` is line-delimited JSON of these 26 + // records. Adding a new event type, renaming an existing one, or changing 27 + // a payload field MUST be reflected in `docs/disk-layout.md` and called 28 + // out under `### Storage format` in the next CHANGELOG entry. A smoke 29 + // test (`tests/disk-layout-docs.test.ts`) asserts every event-type literal 30 + // appears in the docs. 25 31 export interface EventBase { 26 32 session: string; 27 33 /** Type string. Known system types live in the `EventType` enum; user
+5
src/sessions.ts
··· 70 70 return path.join(getSessionDir(), `${name}.events.jsonl`); 71 71 } 72 72 73 + // PUBLIC FORMAT — this is the on-disk shape of `<name>.json`. Any change 74 + // to fields here (add / rename / remove / type change) MUST be reflected in 75 + // `docs/disk-layout.md` and called out under `### Storage format` in the 76 + // next CHANGELOG entry. A smoke test (`tests/disk-layout-docs.test.ts`) 77 + // asserts every field name on this interface appears in the docs. 73 78 export interface SessionMetadata { 74 79 command: string; 75 80 args: string[];
+122
tests/disk-layout-docs.test.ts
··· 1 + // Drift guard: docs/disk-layout.md is supposed to describe the on-disk 2 + // formats third parties read. If we add / rename a SessionMetadata field 3 + // or an event type and forget to update the doc, this test fails. 4 + // 5 + // The check is intentionally string-grep-cheap, not a parser — we just 6 + // confirm every field name and event-type literal appears somewhere in 7 + // the doc body. False positives (a string that happens to match) are 8 + // acceptable; false negatives (silent doc rot) are the failure mode we 9 + // want to prevent. 10 + 11 + import { describe, it, expect } from "vitest"; 12 + import * as fs from "node:fs"; 13 + import * as path from "node:path"; 14 + import { fileURLToPath } from "node:url"; 15 + 16 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 17 + const docsPath = path.join(__dirname, "..", "docs", "disk-layout.md"); 18 + const sessionsPath = path.join(__dirname, "..", "src", "sessions.ts"); 19 + const eventsPath = path.join(__dirname, "..", "src", "events.ts"); 20 + 21 + function readFile(p: string): string { 22 + return fs.readFileSync(p, "utf-8"); 23 + } 24 + 25 + /** Pull field names off a TypeScript interface body by grepping. Looks for 26 + * ` <ident>?:` or ` <ident>:` at line start (after indent). Keeps it 27 + * intentionally dumb — we just want a list of identifiers. */ 28 + function fieldsOfInterface(src: string, interfaceName: string): string[] { 29 + const re = new RegExp(`export\\s+interface\\s+${interfaceName}\\s*\\{([\\s\\S]*?)\\}`, "m"); 30 + const m = src.match(re); 31 + if (!m) throw new Error(`could not find interface ${interfaceName}`); 32 + const body = m[1]; 33 + const fields: string[] = []; 34 + // Match ` <ident>?: ` or ` <ident>: ` at line start (after whitespace). 35 + // Stop at the colon — type can be anything. 36 + const fieldRe = /^\s+([a-zA-Z_][a-zA-Z0-9_]*)\??:/gm; 37 + let fm; 38 + while ((fm = fieldRe.exec(body)) !== null) { 39 + fields.push(fm[1]); 40 + } 41 + return fields; 42 + } 43 + 44 + /** Pull event-type string literals from `type: "..."` lines inside any 45 + * interface that extends EventBase. We grep the whole file for `type: "..."` 46 + * occurrences — any string literal there is an event type. Includes the 47 + * `user.${string}` template; we exclude that since it's a pattern, not a 48 + * concrete type, and the doc covers it as `user.*`. */ 49 + function eventTypeLiterals(src: string): string[] { 50 + const re = /type:\s*"([^"]+)"/g; 51 + const out: string[] = []; 52 + let m; 53 + while ((m = re.exec(src)) !== null) out.push(m[1]); 54 + return out; 55 + } 56 + 57 + describe("docs/disk-layout.md drift guard", () => { 58 + const doc = readFile(docsPath); 59 + const sessions = readFile(sessionsPath); 60 + const events = readFile(eventsPath); 61 + 62 + it("every SessionMetadata field is mentioned in the docs", () => { 63 + const fields = fieldsOfInterface(sessions, "SessionMetadata"); 64 + expect(fields.length).toBeGreaterThan(0); 65 + const missing = fields.filter((f) => !doc.includes(f)); 66 + expect( 67 + missing, 68 + `SessionMetadata fields missing from docs/disk-layout.md: ${missing.join(", ")}\n` + 69 + `If you added or renamed a field, document it in docs/disk-layout.md and add a` + 70 + ` "### Storage format" line to the CHANGELOG.`, 71 + ).toEqual([]); 72 + }); 73 + 74 + it("every concrete event type literal is mentioned in the docs", () => { 75 + const types = eventTypeLiterals(events); 76 + // Sanity: we expect at least the known built-ins to be picked up. 77 + expect(types.length).toBeGreaterThan(10); 78 + // Strip the OSC notification source values which also match `type: "..."` 79 + // accidentally — they live on `source:` not `type:`. The grep is 80 + // line-loose; filter out anything that doesn't look like an event type 81 + // by checking it's not one of the known notification sources. 82 + const notificationSources = new Set(["osc9", "osc99", "osc777"]); 83 + const eventTypes = types.filter((t) => !notificationSources.has(t)); 84 + 85 + const missing = eventTypes.filter((t) => !doc.includes(t)); 86 + expect( 87 + missing, 88 + `Event type literals missing from docs/disk-layout.md: ${missing.join(", ")}\n` + 89 + `If you added or renamed an event type, document it in docs/disk-layout.md and add` + 90 + ` a "### Storage format" line to the CHANGELOG.`, 91 + ).toEqual([]); 92 + }); 93 + 94 + it("references every file extension we write under PTY_SESSION_DIR", () => { 95 + // Coarse but useful: if pty starts writing a new <name>.<ext> file under 96 + // PTY_SESSION_DIR, we want a forcing function to document it. Walk 97 + // sessions.ts for `${name}.<ext>` patterns; assert each <ext> shows up 98 + // in the doc. Hand-rolled patterns rather than parsing JS. 99 + const extRe = /\$\{name\}\.([a-z]+)/g; 100 + const found = new Set<string>(); 101 + let m; 102 + while ((m = extRe.exec(sessions)) !== null) found.add(m[1]); 103 + expect(found.size).toBeGreaterThan(0); 104 + 105 + const missing = Array.from(found).filter((ext) => !doc.includes(`<name>.${ext}`)); 106 + expect( 107 + missing, 108 + `File extensions written under PTY_SESSION_DIR but not documented: ${missing.join(", ")}\n` + 109 + `Add a row in the directory-contents table of docs/disk-layout.md.`, 110 + ).toEqual([]); 111 + }); 112 + 113 + it("references PTY_SESSION_DIR and the default path", () => { 114 + expect(doc).toContain("PTY_SESSION_DIR"); 115 + expect(doc).toContain(".local/state/pty"); 116 + }); 117 + 118 + it("documents the atomic-write tmp pattern as an ignore-target for readers", () => { 119 + expect(doc).toContain(".tmp."); 120 + expect(doc.toLowerCase()).toMatch(/ignore/); 121 + }); 122 + });