This repository has no description
0

Configure Feed

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

pty emit + pty state: user events and per-session JSON bag

pty emit publishes user.* events to a session's .events.jsonl; the
user.* namespace is reserved for arbitrary app events. Inside a
session the ref is omitted — $PTY_SESSION resolves it.

pty state adds a per-session JSON key/value bag with get/set/delete/
keys subcommands. Mutations auto-emit state.set / state.delete events
so downstream watchers see a full history. Writes go through the
atomic writeMetadata rename; in-process Promise.all over setState is
safe, cross-process writers can still race on the RMW window.

Both features are re-exported from @myobie/pty/client for programmatic
use (emitUserEvent, getState, setState, deleteState, listStateKeys,
plus the new event type unions).

Hardening surfaced while writing tests and during review:
- deleteState returns bool; CLI gates state.delete emission on it so a
delete of a missing key is a quiet no-op (no ghost event).
- getStateKey / deleteState use Object.prototype.hasOwnProperty so
prototype names (toString, constructor, __proto__) never match.
- appendEvent honors the same MAX_LINES retention as EventWriter with
a stat-based fast path — scripts in a loop don't grow events.jsonl
unbounded.
- pty state set rejects 3+ positionals instead of silently joining
them with spaces into JSON.parse.
- pty state keys rejects extra positionals, matching the other
subcommands.

Nathan Herald (Apr 22, 2026, 11:30 AM +0200) 51a41973 ed5ab991

+1281 -3
+10
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Events & state 6 + - **`pty emit` — publish user events.** `pty emit <type> [--json <payload>] [--text <string>]` appends an event of type `user.<name>` to a session's `.events.jsonl` file. `<type>` must start with `user.` (the `user.*` namespace is reserved for arbitrary app events and will never collide with built-in types). Inside a pty session the ref is omitted — `$PTY_SESSION` resolves it — so scripts inside a session can just run `pty emit user.deploy.started --json '{"commit":"abc123"}'`. Consumers tail via the existing `EventFollower` API. Programmatic access: `emitUserEvent(sessionName, type, { data?, text? })` on `@myobie/pty/client`. 7 + - **`pty state` — per-session JSON key/value bag.** Each session's metadata file now carries an optional `state: Record<string, unknown>` field with four CLI subcommands: 8 + - `pty state set [ref] <key> [value]` — value parsed as JSON; if omitted, read from stdin (for piping big payloads). 9 + - `pty state get [ref] [key]` — prints a single key's value (JSON), or the whole bag pretty-printed when `key` is omitted. Missing key exits non-zero with no stdout. 10 + - `pty state delete [ref] <key>` — removes a key (drops the whole `state` field if the last key is removed). 11 + - `pty state keys [ref]` — one key per line. 12 + All four support the inside-session shorthand where `ref` is omitted and `$PTY_SESSION` resolves it. `pty state set` takes the JSON value as exactly one positional or as stdin — three or more positionals are rejected with a clear hint to quote the value (previously trailing args were silently joined with spaces and fed to `JSON.parse`, which produced confusing errors). Writes go through `writeMetadata`'s atomic rename, so readers never see a torn file, and in-process `Promise.all` over many `setState` calls lands every update (Node is single-threaded and `setState` is synchronous). Cross-process concurrent writers can still race on the read-modify-write window; keep multi-writer fan-outs serialized if it matters. Mutations automatically emit `state.set` / `state.delete` events (carrying `key` and `value`), so downstream watchers get a full history without extra wiring. `pty state delete` on a missing key is a quiet no-op — no ghost event fires. Programmatic access: `getState`, `getStateKey`, `setState`, `deleteState`, `listStateKeys` on `@myobie/pty/client`. `deleteState` now returns a `boolean` indicating whether a key was actually removed (was `void`). 13 + - **New event types exported from `@myobie/pty/client`:** `UserEvent`, `StateSetEvent`, `StateDeleteEvent`, plus helpers `emitUserEvent`, `appendEvent`, `isUserEvent`, `validateUserEventType`. `EventBase.type` widened from the `EventType` enum to `string` so subtypes carry their own literals (`user.${string}` / `"state.set"` / `"state.delete"`); existing consumers that switch on the enum values are unaffected. 14 + 5 15 ### Supervisor 6 16 - Add `pty supervisor systemd install|uninstall` for Linux user services. The installer writes a unit to `~/.config/systemd/user/`, sets `PATH`, `TERM`, `COLORTERM`, and `PTY_SESSION_DIR`, runs `systemctl --user daemon-reload`, and enables/starts the service immediately. If linger is disabled, the command now prints a hint explaining that the service will only stay up while the user session exists and how to enable boot-time startup with `loginctl enable-linger`. 7 17 - Add `pty supervisor runit install|uninstall`. The installer writes a `run` script plus symlink-ready service directory (defaulting to `~/.config/runit/{sv,service}`), exporting the same environment variables before execing the supervisor entry point. This is aimed at runit-based systems like Void Linux, while still being easy to test from a private `runsvdir`.
+11
README.md
··· 95 95 pty events --recent myserver # show recent events and exit 96 96 pty events --json myserver # output raw JSONL 97 97 98 + pty emit user.deploy.started # emit a user event (inside a session) 99 + pty emit myserver user.build.finished --json '{"ok":true}' # with JSON payload 100 + pty emit myserver user.note --text "checkpoint reached" # with a text payload 101 + 102 + pty state set myserver port 3000 # set a JSON-parsed state key 103 + pty state set myserver config < cfg.json # pipe a bigger value from stdin 104 + pty state get myserver port # print a single key's JSON value 105 + pty state get myserver # print the whole bag (pretty JSON) 106 + pty state keys myserver # list keys (one per line) 107 + pty state delete myserver port # remove a key 108 + 98 109 pty restart myserver # restart an exited session 99 110 pty kill myserver # terminate a running session 100 111 pty rm myserver # remove an exited session's metadata
+253 -1
src/cli.ts
··· 23 23 readMetadata, 24 24 writeMetadata, 25 25 getSessionDir, 26 + getState, getStateKey, setState, deleteState, listStateKeys, 26 27 type SessionInfo, 27 28 } from "./sessions.ts"; 28 29 import { spawnDaemon, resolveCommand } from "./spawn.ts"; 29 - import { EventFollower, EventWriter, EventType, readRecentEvents, formatEvent } from "./events.ts"; 30 + import { 31 + EventFollower, EventWriter, EventType, 32 + readRecentEvents, formatEvent, 33 + emitUserEvent, appendEvent, 34 + } from "./events.ts"; 30 35 import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 31 36 import { getSupervisorDir } from "./supervisor.ts"; 32 37 import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; ··· 707 712 console.error(e.message); 708 713 process.exit(1); 709 714 } 715 + break; 716 + } 717 + 718 + case "emit": { 719 + await cmdEmit(args.slice(1)); 720 + break; 721 + } 722 + 723 + case "state": { 724 + await cmdState(args.slice(1)); 710 725 break; 711 726 } 712 727 ··· 1877 1892 ? `Would clean up ${parts.join(" and ")}. (Dry run — no changes made.)` 1878 1893 : `Cleaned up ${parts.join(" and ")}.`, 1879 1894 ); 1895 + } 1896 + 1897 + // --- emit: publish a user.* event to a session's events log --- 1898 + 1899 + async function cmdEmit(argv: string[]): Promise<void> { 1900 + // pty emit <type> [--json <payload>] [--text <string>] 1901 + // pty emit <ref> <type> [--json <payload>] [--text <string>] 1902 + // Inside a session, $PTY_SESSION provides the default ref. 1903 + let ref: string | null = null; 1904 + let type: string | null = null; 1905 + let jsonStr: string | null = null; 1906 + let textStr: string | null = null; 1907 + const positional: string[] = []; 1908 + 1909 + for (let i = 0; i < argv.length; i++) { 1910 + const a = argv[i]; 1911 + if (a === "--json" && i + 1 < argv.length) { jsonStr = argv[++i]; continue; } 1912 + if (a === "--text" && i + 1 < argv.length) { textStr = argv[++i]; continue; } 1913 + if (a === "-h" || a === "--help") { 1914 + printEmitHelp(); 1915 + return; 1916 + } 1917 + positional.push(a); 1918 + } 1919 + 1920 + if (positional.length === 2) { ref = positional[0]; type = positional[1]; } 1921 + else if (positional.length === 1) { ref = null; type = positional[0]; } 1922 + else { 1923 + printEmitHelp(); 1924 + process.exit(1); 1925 + } 1926 + 1927 + // Default to $PTY_SESSION when no explicit ref given. 1928 + if (ref == null) ref = process.env.PTY_SESSION ?? null; 1929 + if (!ref) { 1930 + console.error("pty emit: no session ref given and not running inside a pty session"); 1931 + console.error(" tip: run inside a pty session, or: pty emit <session-ref> <type>"); 1932 + process.exit(1); 1933 + } 1934 + 1935 + try { 1936 + validateName(ref); 1937 + } catch (e: any) { 1938 + console.error(e.message); 1939 + process.exit(1); 1940 + } 1941 + const resolvedName = await resolveRef(ref); 1942 + 1943 + let data: unknown; 1944 + if (jsonStr != null) { 1945 + try { data = JSON.parse(jsonStr); } 1946 + catch (e: any) { 1947 + console.error(`pty emit: --json payload is not valid JSON: ${e.message}`); 1948 + process.exit(1); 1949 + } 1950 + } 1951 + 1952 + try { 1953 + const event = await emitUserEvent(resolvedName, type!, { 1954 + ...(data !== undefined ? { data } : {}), 1955 + ...(textStr != null ? { text: textStr } : {}), 1956 + }); 1957 + // Silent by default; -v isn't implemented. Return the type so scripts 1958 + // can chain `pty emit ... | ...` if they want. 1959 + void event; 1960 + } catch (e: any) { 1961 + console.error(e.message); 1962 + process.exit(1); 1963 + } 1964 + } 1965 + 1966 + function printEmitHelp(): void { 1967 + console.log(`Usage: 1968 + pty emit <type> [--json <payload>] [--text <string>] 1969 + pty emit <session-ref> <type> [--json <payload>] [--text <string>] 1970 + 1971 + Publishes a user.* event to a session's events log. Inside a pty 1972 + session, the ref defaults to $PTY_SESSION. Event types must start 1973 + with "user." — "session_*", "state.*", "bell", etc. are reserved. 1974 + 1975 + Examples: 1976 + pty emit user.build-done 1977 + pty emit user.progress --json '{"pct": 40}' 1978 + pty emit user.note --text "starting deploy" 1979 + pty emit myserver user.tests-passed --json '{"n": 42}'`); 1980 + } 1981 + 1982 + // --- state: per-session JSON data bag --- 1983 + 1984 + async function cmdState(argv: string[]): Promise<void> { 1985 + const sub = argv[0]; 1986 + if (!sub || sub === "-h" || sub === "--help") { 1987 + printStateHelp(); 1988 + return; 1989 + } 1990 + 1991 + // pty state <sub> [ref] [key] [value] 1992 + // ref defaults to $PTY_SESSION inside a session. 1993 + const rest = argv.slice(1); 1994 + 1995 + // Figure out whether the first positional is a session-ref or a key. 1996 + // Heuristic: if it names an existing session, treat it as the ref; 1997 + // otherwise fall back to $PTY_SESSION. This matches the `pty exec`, 1998 + // `pty rename` etc. pattern. 1999 + async function resolveStateTarget(): Promise<{ name: string; rest: string[] }> { 2000 + const inside = process.env.PTY_SESSION; 2001 + if (rest.length > 0) { 2002 + const candidate = rest[0]; 2003 + try { 2004 + validateName(candidate); 2005 + const existing = await getSession(candidate); 2006 + if (existing) return { name: candidate, rest: rest.slice(1) }; 2007 + } catch { 2008 + // Not a valid session name — treat as the key instead. 2009 + } 2010 + } 2011 + if (!inside) { 2012 + console.error(`pty state ${sub}: no session ref given and not running inside a pty session`); 2013 + console.error(` tip: run inside a pty session, or pass the session-ref: pty state ${sub} <session-ref> ...`); 2014 + process.exit(1); 2015 + } 2016 + return { name: await resolveRef(inside), rest }; 2017 + } 2018 + 2019 + try { 2020 + switch (sub) { 2021 + case "get": { 2022 + const { name, rest: r } = await resolveStateTarget(); 2023 + if (r.length === 0) { 2024 + const bag = getState(name); 2025 + console.log(JSON.stringify(bag, null, 2)); 2026 + return; 2027 + } 2028 + if (r.length > 1) { 2029 + console.error("pty state get: unexpected extra args"); 2030 + process.exit(1); 2031 + } 2032 + const value = getStateKey(name, r[0]); 2033 + if (value === undefined) { 2034 + process.exit(1); // "missing key" — silent, non-zero exit 2035 + } 2036 + console.log(JSON.stringify(value, null, 2)); 2037 + return; 2038 + } 2039 + case "set": { 2040 + const { name, rest: r } = await resolveStateTarget(); 2041 + if (r.length < 1) { 2042 + console.error("pty state set: expected <key> [value]. If value is omitted, JSON is read from stdin."); 2043 + process.exit(1); 2044 + } 2045 + if (r.length > 2) { 2046 + console.error("pty state set: too many positional arguments. Quote the JSON value so the shell keeps it as one argument: pty state set <ref> <key> '<json>'."); 2047 + process.exit(1); 2048 + } 2049 + const key = r[0]; 2050 + let raw: string; 2051 + if (r.length === 2) { 2052 + raw = r[1]; 2053 + } else { 2054 + raw = await readAllStdin(); 2055 + } 2056 + let value: unknown; 2057 + try { value = JSON.parse(raw); } 2058 + catch (e: any) { 2059 + console.error(`pty state set: value is not valid JSON: ${e.message}`); 2060 + process.exit(1); 2061 + } 2062 + setState(name, key, value); 2063 + await appendEvent(name, { 2064 + session: name, type: "state.set", ts: new Date().toISOString(), 2065 + key, value, 2066 + }); 2067 + return; 2068 + } 2069 + case "delete": 2070 + case "rm": { 2071 + const { name, rest: r } = await resolveStateTarget(); 2072 + if (r.length !== 1) { 2073 + console.error(`pty state ${sub}: expected <key>`); 2074 + process.exit(1); 2075 + } 2076 + const key = r[0]; 2077 + const removed = deleteState(name, key); 2078 + if (removed) { 2079 + await appendEvent(name, { 2080 + session: name, type: "state.delete", ts: new Date().toISOString(), 2081 + key, 2082 + }); 2083 + } 2084 + return; 2085 + } 2086 + case "keys": { 2087 + const { name, rest: r } = await resolveStateTarget(); 2088 + if (r.length > 0) { 2089 + console.error("pty state keys: unexpected extra args"); 2090 + process.exit(1); 2091 + } 2092 + const keys = listStateKeys(name); 2093 + for (const k of keys) console.log(k); 2094 + return; 2095 + } 2096 + default: 2097 + printStateHelp(); 2098 + process.exit(1); 2099 + } 2100 + } catch (e: any) { 2101 + console.error(e.message); 2102 + process.exit(1); 2103 + } 2104 + } 2105 + 2106 + function printStateHelp(): void { 2107 + console.log(`Usage: 2108 + pty state get [<ref>] [<key>] # print full bag or one key (JSON) 2109 + pty state set [<ref>] <key> [v] # set <key> to JSON value v (or stdin) 2110 + pty state delete [<ref>] <key> # remove a key 2111 + pty state keys [<ref>] # list keys 2112 + 2113 + Inside a pty session, <ref> defaults to $PTY_SESSION. Every set/delete 2114 + emits a matching state.set / state.delete event so followers of 2115 + 'pty events <ref>' see state transitions live. 2116 + 2117 + Values are JSON — "42" is the number 42, '"42"' is the string "42". 2118 + Use 'pty state set foo "$(cat payload.json)"' for larger payloads, 2119 + or pipe via stdin: cat payload.json | pty state set foo`); 2120 + } 2121 + 2122 + async function readAllStdin(): Promise<string> { 2123 + // Collect all of stdin. Used by 'pty state set <key>' when no value arg 2124 + // is given — allows shell piping for large JSON payloads. 2125 + return await new Promise((resolve, reject) => { 2126 + let data = ""; 2127 + process.stdin.setEncoding("utf8"); 2128 + process.stdin.on("data", (chunk) => { data += chunk; }); 2129 + process.stdin.on("end", () => resolve(data)); 2130 + process.stdin.on("error", reject); 2131 + }); 1880 2132 } 1881 2133 1882 2134 async function cmdSupervisorStart(): Promise<void> {
+3
src/client-api.ts
··· 7 7 validateName, updateTags, setDisplayName, 8 8 getSessionDir, getSocketPath, 9 9 cleanupSocket, cleanupAll, 10 + getState, getStateKey, setState, deleteState, listStateKeys, 10 11 type SessionInfo, type SessionMetadata, type PrunedTagResult, 11 12 } from "./sessions.ts"; 12 13 ··· 31 32 export { 32 33 EventType, 33 34 EventFollower, readRecentEvents, formatEvent, 35 + emitUserEvent, appendEvent, isUserEvent, validateUserEventType, 34 36 type EventRecord, type EventBase, 35 37 type BellEvent, type TitleChangeEvent, type NotificationEvent, 36 38 type FocusRequestEvent, type CursorVisibleEvent, 37 39 type SessionStartEvent, type SessionExitEvent, type SessionExecEvent, 38 40 type SessionRestartEvent, type SessionFailedEvent, 39 41 type SupervisorStartEvent, type SupervisorStopEvent, 42 + type UserEvent, type StateSetEvent, type StateDeleteEvent, 40 43 type FollowerOptions, 41 44 } from "./events.ts"; 42 45
+123 -2
src/events.ts
··· 21 21 22 22 export interface EventBase { 23 23 session: string; 24 - type: EventType; 24 + /** Type string. Known system types live in the `EventType` enum; user 25 + * events are `user.*`; state bag changes are `state.set` / `state.delete`. 26 + * Kept as a plain `string` here so subtype interfaces can carry their 27 + * own literal types without fighting the compiler. */ 28 + type: string; 25 29 ts: string; 26 30 } 27 31 ··· 85 89 type: "supervisor_stop"; 86 90 } 87 91 92 + /** User-published event. `type` must begin with `user.` — the CLI 93 + * (`pty emit`) rejects anything else, and the client-API `emitEvent` 94 + * helper throws on bad types. Payload is free-form JSON. */ 95 + export interface UserEvent extends EventBase { 96 + type: `user.${string}`; 97 + data?: unknown; 98 + text?: string; 99 + } 100 + 101 + /** Emitted automatically whenever `setState` writes a key. Mirrors 102 + * what `pty state set` records. Consumers of the event stream can 103 + * react to state changes without polling the metadata file. */ 104 + export interface StateSetEvent extends EventBase { 105 + type: "state.set"; 106 + key: string; 107 + value: unknown; 108 + } 109 + 110 + export interface StateDeleteEvent extends EventBase { 111 + type: "state.delete"; 112 + key: string; 113 + } 114 + 88 115 export type EventRecord = 89 116 | BellEvent 90 117 | TitleChangeEvent ··· 97 124 | SessionRestartEvent 98 125 | SessionFailedEvent 99 126 | SupervisorStartEvent 100 - | SupervisorStopEvent; 127 + | SupervisorStopEvent 128 + | UserEvent 129 + | StateSetEvent 130 + | StateDeleteEvent; 131 + 132 + /** Type guard: narrows an EventRecord to a UserEvent. */ 133 + export function isUserEvent(e: EventRecord): e is UserEvent { 134 + return typeof e.type === "string" && e.type.startsWith("user."); 135 + } 136 + 137 + /** Validate a user-emitted event type. Returns null if valid, an error 138 + * message otherwise. Shared between the CLI and the client-API helper 139 + * so both surface the same message. */ 140 + export function validateUserEventType(type: string): string | null { 141 + if (typeof type !== "string" || type.length === 0) { 142 + return "event type must be a non-empty string"; 143 + } 144 + if (!type.startsWith("user.")) { 145 + return `custom events must start with "user." (got ${JSON.stringify(type)})`; 146 + } 147 + if (type === "user.") { 148 + return `event type "user." needs a suffix (e.g. "user.build-done")`; 149 + } 150 + // Reserve ASCII whitespace / control chars — they'd break JSONL round-trip 151 + // and any shell that tries to grep the events file. 152 + if (/[\s\x00-\x1f]/.test(type)) { 153 + return `event type may not contain whitespace or control characters`; 154 + } 155 + return null; 156 + } 101 157 102 158 const MAX_LINES = 1000; 103 159 const KEEP_LINES = 500; 104 160 const TRUNCATE_CHECK_INTERVAL = 100; 161 + 162 + /** One-shot helper to append a single event to a session's events log 163 + * without keeping an EventWriter around. Used by CLI subcommands like 164 + * `pty emit` and `pty state set` that run outside the daemon process. 165 + * Applies the same MAX_LINES/KEEP_LINES retention as `EventWriter` so 166 + * scripts that write in a loop don't grow the log unbounded — the 167 + * truncate path is skipped via a cheap stat when the file is small. */ 168 + export async function appendEvent(name: string, event: EventRecord): Promise<void> { 169 + ensureSessionDir(); 170 + const filePath = getEventsPath(name); 171 + const line = JSON.stringify(event) + "\n"; 172 + await fsp.appendFile(filePath, line); 173 + await maybeTruncate(filePath); 174 + } 175 + 176 + /** Cheap retention check. Only reads + rewrites when the file's byte size 177 + * suggests it might exceed MAX_LINES — avoids paying a readFile per 178 + * append in the common case. */ 179 + async function maybeTruncate(filePath: string): Promise<void> { 180 + try { 181 + const stat = await fsp.stat(filePath); 182 + // Very conservative lower bound: shortest plausible JSONL event line is 183 + // ~40 bytes. If the file is smaller than MAX_LINES * 40, skip the line 184 + // count entirely. In practice most events are 100-400 bytes so this 185 + // fast path covers the overwhelming majority of calls. 186 + if (stat.size < MAX_LINES * 40) return; 187 + await truncate(filePath); 188 + } catch { 189 + // File might have been concurrently removed — ignore. 190 + } 191 + } 192 + 193 + /** Validate + append a user.* event. Throws on an invalid type so the 194 + * caller surfaces the error cleanly. Timestamped here so callers don't 195 + * need to remember to set `ts`. */ 196 + export async function emitUserEvent( 197 + sessionName: string, 198 + type: string, 199 + opts: { data?: unknown; text?: string } = {}, 200 + ): Promise<UserEvent> { 201 + const err = validateUserEventType(type); 202 + if (err) throw new Error(err); 203 + const event: UserEvent = { 204 + session: sessionName, 205 + type: type as `user.${string}`, 206 + ts: new Date().toISOString(), 207 + ...(opts.data !== undefined ? { data: opts.data } : {}), 208 + ...(opts.text !== undefined ? { text: opts.text } : {}), 209 + }; 210 + await appendEvent(sessionName, event); 211 + return event; 212 + } 105 213 106 214 /** Manages async, serialized writes to a session's events JSONL file. */ 107 215 export class EventWriter { ··· 327 435 return `${prefix} supervisor started`; 328 436 case "supervisor_stop": 329 437 return `${prefix} supervisor stopped`; 438 + case "state.set": 439 + return `${prefix} state.set ${event.key} = ${JSON.stringify(event.value)}`; 440 + case "state.delete": 441 + return `${prefix} state.delete ${event.key}`; 442 + default: { 443 + // user.* events + anything else unknown-at-compile-time. 444 + const e = event as EventBase & { data?: unknown; text?: string }; 445 + const suffix = 446 + e.text != null ? ` "${e.text}"` 447 + : e.data !== undefined ? ` ${JSON.stringify(e.data)}` 448 + : ""; 449 + return `${prefix} ${e.type}${suffix}`; 450 + } 330 451 } 331 452 }
+70
src/sessions.ts
··· 75 75 exitedAt?: string; 76 76 lastLines?: string[]; 77 77 tags?: Record<string, string>; 78 + /** Free-form per-session data bag. Separate from `tags` (which are 79 + * string-valued, filterable, and rendered in `pty list`) — `state` 80 + * holds complex JSON values a session or a consumer wants to track 81 + * (web server port, agent turn count, cached result, etc.). Mutated 82 + * via `setState` / `deleteState`, which emit `state.set` / 83 + * `state.delete` events automatically. Keep to small-to-medium JSON 84 + * — the metadata file is rewritten on every update. */ 85 + state?: Record<string, unknown>; 78 86 /** Optional human-friendly alias for the session. Mutable via `pty rename`. 79 87 * The immutable stable id is always `SessionInfo.name`. Most code should 80 88 * keep using `name`; `displayName` is purely for presentation and as an ··· 154 162 delete metadata.tags; 155 163 } 156 164 writeMetadata(name, metadata); 165 + } 166 + 167 + /** Read a session's state bag. Returns an empty object when the session 168 + * has no state. Throws if the session doesn't exist. */ 169 + export function getState(name: string): Record<string, unknown> { 170 + const metadata = readMetadata(name); 171 + if (!metadata) throw new Error(`Session "${name}" not found.`); 172 + return { ...(metadata.state ?? {}) }; 173 + } 174 + 175 + /** Read a single state key. Returns `undefined` if the key isn't set. 176 + * Uses own-property lookup so prototype names like `toString` / 177 + * `hasOwnProperty` return `undefined` instead of leaking inherited 178 + * methods. */ 179 + export function getStateKey(name: string, key: string): unknown { 180 + const metadata = readMetadata(name); 181 + if (!metadata) throw new Error(`Session "${name}" not found.`); 182 + const state = metadata.state; 183 + if (!state || !Object.prototype.hasOwnProperty.call(state, key)) return undefined; 184 + return state[key]; 185 + } 186 + 187 + /** Set a key on the state bag. Atomic read-modify-write of the metadata 188 + * file. Caller is responsible for calling `appendEvent` with the 189 + * matching `state.set` record if event emission is desired — the CLI 190 + * wrapper (`pty state set`) does this. Keeping the event-emit decoupled 191 + * from the write keeps this function usable from contexts that don't 192 + * want to emit (e.g. tests, bulk imports). */ 193 + export function setState(name: string, key: string, value: unknown): void { 194 + const metadata = readMetadata(name); 195 + if (!metadata) throw new Error(`Session "${name}" not found.`); 196 + const state = { ...(metadata.state ?? {}) }; 197 + state[key] = value; 198 + metadata.state = state; 199 + writeMetadata(name, metadata); 200 + } 201 + 202 + /** Delete a key from the state bag. Returns `true` when the key existed and 203 + * was removed, `false` when the key wasn't set (no write performed). 204 + * Callers that emit a `state.delete` event should gate on the return value 205 + * so a delete on a missing key doesn't produce a ghost event. Uses 206 + * own-property lookup — inherited names like `toString` never match. */ 207 + export function deleteState(name: string, key: string): boolean { 208 + const metadata = readMetadata(name); 209 + if (!metadata) throw new Error(`Session "${name}" not found.`); 210 + if (!metadata.state || !Object.prototype.hasOwnProperty.call(metadata.state, key)) return false; 211 + const state = { ...metadata.state }; 212 + delete state[key]; 213 + if (Object.keys(state).length > 0) { 214 + metadata.state = state; 215 + } else { 216 + delete metadata.state; 217 + } 218 + writeMetadata(name, metadata); 219 + return true; 220 + } 221 + 222 + /** List every key currently set on the state bag. */ 223 + export function listStateKeys(name: string): string[] { 224 + const metadata = readMetadata(name); 225 + if (!metadata) throw new Error(`Session "${name}" not found.`); 226 + return Object.keys(metadata.state ?? {}); 157 227 } 158 228 159 229 export function readMetadata(name: string): SessionMetadata | null {
+310
tests/events-emit.test.ts
··· 1 + // Tests for the `pty emit` CLI subcommand and the underlying 2 + // emitUserEvent helper. Round-trips events through a real events.jsonl 3 + // file so the parse + format paths also get covered. 4 + 5 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 6 + import * as fs from "node:fs"; 7 + import * as os from "node:os"; 8 + import * as path from "node:path"; 9 + import { fileURLToPath } from "node:url"; 10 + import { spawn, spawnSync } from "node:child_process"; 11 + import { 12 + emitUserEvent, validateUserEventType, readRecentEvents, isUserEvent, 13 + EventFollower, type EventRecord, 14 + } from "../src/events.ts"; 15 + 16 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 17 + const nodeBin = process.execPath; 18 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 19 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 20 + 21 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-emit-")); 22 + afterAll(() => { 23 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 24 + }); 25 + 26 + let bgPids: number[] = []; 27 + let sessionDirs: string[] = []; 28 + 29 + function makeSessionDir(): string { 30 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 31 + sessionDirs.push(dir); 32 + return dir; 33 + } 34 + 35 + let nameCounter = 0; 36 + function uniqueName(): string { 37 + return `em${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 38 + } 39 + 40 + async function startDaemon(sessionDir: string, name: string): Promise<number> { 41 + const config = JSON.stringify({ 42 + name, command: "cat", args: [], displayCommand: "cat", 43 + cwd: os.tmpdir(), rows: 24, cols: 80, 44 + }); 45 + const child = spawn(nodeBin, [serverModule], { 46 + detached: true, 47 + stdio: ["ignore", "ignore", "pipe"], 48 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 49 + }); 50 + let stderr = ""; 51 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 52 + let exitCode: number | null = null; 53 + child.on("exit", (code) => { exitCode = code; }); 54 + (child.stderr as any)?.unref?.(); 55 + child.unref(); 56 + 57 + const socketPath = path.join(sessionDir, `${name}.sock`); 58 + const start = Date.now(); 59 + while (Date.now() - start < 5000) { 60 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 61 + try { 62 + fs.statSync(socketPath); 63 + await new Promise((r) => setTimeout(r, 100)); 64 + bgPids.push(child.pid!); 65 + return child.pid!; 66 + } catch {} 67 + await new Promise((r) => setTimeout(r, 50)); 68 + } 69 + throw new Error("Timeout waiting for daemon"); 70 + } 71 + 72 + function runCli(sessionDir: string, env: Record<string, string>, ...args: string[]) { 73 + return spawnSync(nodeBin, [cliPath, ...args], { 74 + env: { ...process.env, PTY_SESSION_DIR: sessionDir, ...env }, 75 + encoding: "utf-8", 76 + timeout: 10_000, 77 + }); 78 + } 79 + 80 + afterEach(() => { 81 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 82 + bgPids = []; 83 + for (const dir of sessionDirs) { 84 + try { 85 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 86 + } catch {} 87 + } 88 + sessionDirs = []; 89 + }); 90 + 91 + describe("validateUserEventType", () => { 92 + it("accepts user.something", () => { 93 + expect(validateUserEventType("user.build-done")).toBeNull(); 94 + expect(validateUserEventType("user.a")).toBeNull(); 95 + }); 96 + it("rejects types that don't start with user.", () => { 97 + expect(validateUserEventType("build-done")).toMatch(/must start with/); 98 + expect(validateUserEventType("session_start")).toMatch(/must start with/); 99 + expect(validateUserEventType("state.set")).toMatch(/must start with/); 100 + }); 101 + it("rejects bare 'user.'", () => { 102 + expect(validateUserEventType("user.")).toMatch(/suffix/); 103 + }); 104 + it("rejects empty / whitespace / control chars", () => { 105 + expect(validateUserEventType("")).toMatch(/non-empty/); 106 + expect(validateUserEventType("user.has space")).toMatch(/whitespace/); 107 + expect(validateUserEventType("user.tab\tfoo")).toMatch(/whitespace/); 108 + }); 109 + }); 110 + 111 + describe("emitUserEvent direct API", () => { 112 + it("writes a user event and readRecentEvents round-trips it", async () => { 113 + process.env.PTY_SESSION_DIR = makeSessionDir(); 114 + const name = uniqueName(); 115 + // Don't need a daemon — appendEvent just writes the .events.jsonl file. 116 + await emitUserEvent(name, "user.build-done", { data: { pct: 100 } }); 117 + await emitUserEvent(name, "user.note", { text: "hello" }); 118 + 119 + const events = readRecentEvents(name); 120 + expect(events).toHaveLength(2); 121 + expect(events[0].type).toBe("user.build-done"); 122 + expect((events[0] as any).data).toEqual({ pct: 100 }); 123 + expect(events[1].type).toBe("user.note"); 124 + expect((events[1] as any).text).toBe("hello"); 125 + expect(isUserEvent(events[0])).toBe(true); 126 + }); 127 + 128 + it("throws on invalid types instead of silently writing them", async () => { 129 + process.env.PTY_SESSION_DIR = makeSessionDir(); 130 + const name = uniqueName(); 131 + await expect(emitUserEvent(name, "system_thing")).rejects.toThrow(/must start with/); 132 + await expect(emitUserEvent(name, "user.")).rejects.toThrow(/suffix/); 133 + }); 134 + }); 135 + 136 + describe("pty emit CLI", () => { 137 + it("publishes a user event on a running session", async () => { 138 + const dir = makeSessionDir(); 139 + const name = uniqueName(); 140 + await startDaemon(dir, name); 141 + 142 + const r = runCli(dir, {}, "emit", name, "user.tests-passed", 143 + "--json", '{"count": 42}'); 144 + expect(r.status).toBe(0); 145 + 146 + const events = readRecentEventsInDir(dir, name); 147 + const latest = events[events.length - 1]; 148 + expect(latest.type).toBe("user.tests-passed"); 149 + expect((latest as any).data).toEqual({ count: 42 }); 150 + }, 15000); 151 + 152 + it("resolves to $PTY_SESSION when no ref is given", async () => { 153 + const dir = makeSessionDir(); 154 + const name = uniqueName(); 155 + await startDaemon(dir, name); 156 + 157 + const r = runCli(dir, { PTY_SESSION: name }, "emit", "user.from-inside"); 158 + expect(r.status).toBe(0); 159 + 160 + const events = readRecentEventsInDir(dir, name); 161 + expect(events.some(e => e.type === "user.from-inside")).toBe(true); 162 + }, 15000); 163 + 164 + it("rejects non-user.* types with a clear error", async () => { 165 + const dir = makeSessionDir(); 166 + const name = uniqueName(); 167 + await startDaemon(dir, name); 168 + 169 + const r = runCli(dir, {}, "emit", name, "bogus-type"); 170 + expect(r.status).not.toBe(0); 171 + expect(r.stderr).toMatch(/must start with/); 172 + }, 15000); 173 + 174 + it("errors when no ref and no $PTY_SESSION", async () => { 175 + const dir = makeSessionDir(); 176 + // Run without PTY_SESSION in the env (parent process may have it set). 177 + const env: Record<string, string | undefined> = { ...process.env, PTY_SESSION_DIR: dir }; 178 + delete env.PTY_SESSION; 179 + const r = spawnSync(nodeBin, [cliPath, "emit", "user.whatever"], { 180 + env: env as Record<string, string>, 181 + encoding: "utf-8", 182 + timeout: 10_000, 183 + }); 184 + expect(r.status).not.toBe(0); 185 + expect(r.stderr).toMatch(/not running inside a pty session|no session ref/); 186 + }); 187 + 188 + it("--text puts the payload on the event's text field", async () => { 189 + const dir = makeSessionDir(); 190 + const name = uniqueName(); 191 + await startDaemon(dir, name); 192 + 193 + const r = runCli(dir, {}, "emit", name, "user.note", "--text", "checkpoint reached"); 194 + expect(r.status).toBe(0); 195 + 196 + const events = readRecentEventsInDir(dir, name); 197 + const latest = events[events.length - 1]; 198 + expect(latest.type).toBe("user.note"); 199 + expect((latest as any).text).toBe("checkpoint reached"); 200 + expect((latest as any).data).toBeUndefined(); 201 + }, 15000); 202 + 203 + it("--json and --text together land both fields", async () => { 204 + const dir = makeSessionDir(); 205 + const name = uniqueName(); 206 + await startDaemon(dir, name); 207 + 208 + const r = runCli(dir, {}, "emit", name, "user.mixed", 209 + "--json", '{"ok":true}', "--text", "done"); 210 + expect(r.status).toBe(0); 211 + 212 + const events = readRecentEventsInDir(dir, name); 213 + const latest = events[events.length - 1]; 214 + expect((latest as any).data).toEqual({ ok: true }); 215 + expect((latest as any).text).toBe("done"); 216 + }, 15000); 217 + 218 + it("rejects invalid --json with a clear error and no event written", async () => { 219 + const dir = makeSessionDir(); 220 + const name = uniqueName(); 221 + await startDaemon(dir, name); 222 + 223 + const before = readRecentEventsInDir(dir, name).length; 224 + 225 + const r = runCli(dir, {}, "emit", name, "user.bad", 226 + "--json", "{not-valid-json"); 227 + expect(r.status).not.toBe(0); 228 + expect(r.stderr).toMatch(/not valid JSON|--json/); 229 + 230 + const after = readRecentEventsInDir(dir, name).length; 231 + expect(after).toBe(before); 232 + }, 15000); 233 + }); 234 + 235 + describe("appendEvent retention", () => { 236 + it("caps the events log when scripts write in a loop", async () => { 237 + // Regression: previously `appendEvent` (used by `pty emit` and the 238 + // `pty state set/delete` event emissions) did a raw fsp.appendFile 239 + // with no truncation, while the daemon's EventWriter enforces a 240 + // 1000-line cap. A script that ran `pty state set` in a tight loop 241 + // would grow events.jsonl without bound. Now appendEvent calls the 242 + // same retention path. 243 + process.env.PTY_SESSION_DIR = makeSessionDir(); 244 + const name = uniqueName(); 245 + 246 + // Write well past MAX_LINES (1000) to force at least one truncation. 247 + // Emit sequentially to keep the test deterministic. 248 + for (let i = 0; i < 1200; i++) { 249 + await emitUserEvent(name, "user.loop", { data: { i } }); 250 + } 251 + 252 + const content = fs.readFileSync( 253 + path.join(process.env.PTY_SESSION_DIR!, `${name}.events.jsonl`), 254 + "utf-8" 255 + ); 256 + const lineCount = content.trimEnd().split("\n").length; 257 + // After truncation kicks in the file should be at or below MAX_LINES. 258 + expect(lineCount).toBeLessThanOrEqual(1000); 259 + // And the truncation is tail-preserving: the most recent event (i=1199) 260 + // should still be in the file. 261 + expect(content).toContain('"i":1199'); 262 + }, 30000); 263 + }); 264 + 265 + describe("EventFollower — user.* streaming", () => { 266 + it("delivers user.* events to a live follower", async () => { 267 + const dir = makeSessionDir(); 268 + const name = uniqueName(); 269 + await startDaemon(dir, name); 270 + 271 + // The follower reads PTY_SESSION_DIR from process.env when resolving 272 + // event file paths — point it at the test dir before construction. 273 + process.env.PTY_SESSION_DIR = dir; 274 + 275 + const received: EventRecord[] = []; 276 + const follower = new EventFollower({ 277 + names: [name], 278 + onEvent: (e) => { if (e.type.startsWith("user.")) received.push(e); }, 279 + }); 280 + follower.start(); 281 + 282 + try { 283 + // Give the watcher a moment to attach. 284 + await new Promise((r) => setTimeout(r, 100)); 285 + 286 + await emitUserEvent(name, "user.first", { text: "one" }); 287 + await emitUserEvent(name, "user.second", { data: { n: 2 } }); 288 + 289 + // Poll for both — fs.watch can coalesce notifications. 290 + const deadline = Date.now() + 2000; 291 + while (received.length < 2 && Date.now() < deadline) { 292 + await new Promise((r) => setTimeout(r, 50)); 293 + } 294 + 295 + expect(received.map(e => e.type)).toEqual(["user.first", "user.second"]); 296 + expect(isUserEvent(received[0])).toBe(true); 297 + expect((received[0] as any).text).toBe("one"); 298 + expect((received[1] as any).data).toEqual({ n: 2 }); 299 + } finally { 300 + follower.stop(); 301 + } 302 + }, 15000); 303 + }); 304 + 305 + // Helper: re-read events.jsonl from a specific session dir (can't rely on 306 + // process.env.PTY_SESSION_DIR since readRecentEvents uses it directly). 307 + function readRecentEventsInDir(dir: string, name: string): any[] { 308 + const content = fs.readFileSync(path.join(dir, `${name}.events.jsonl`), "utf-8"); 309 + return content.trimEnd().split("\n").filter(Boolean).map(l => JSON.parse(l)); 310 + }
+62
tests/events.test.ts
··· 263 263 }); 264 264 expect(result).toContain("cursor restored"); 265 265 }); 266 + 267 + it("formats state.set with JSON-encoded value", () => { 268 + const result = formatEvent({ 269 + session: "test", 270 + type: "state.set", 271 + ts: "2026-04-05T10:15:03.000Z", 272 + key: "port", 273 + value: 3000, 274 + }); 275 + expect(result).toContain("state.set port = 3000"); 276 + }); 277 + 278 + it("formats state.set with complex value (JSON, no pretty-printing)", () => { 279 + const result = formatEvent({ 280 + session: "test", 281 + type: "state.set", 282 + ts: "2026-04-05T10:15:03.000Z", 283 + key: "config", 284 + value: { host: "localhost", tls: { cert: "x" } }, 285 + }); 286 + expect(result).toContain('state.set config = {"host":"localhost","tls":{"cert":"x"}}'); 287 + }); 288 + 289 + it("formats state.delete naming the removed key", () => { 290 + const result = formatEvent({ 291 + session: "test", 292 + type: "state.delete", 293 + ts: "2026-04-05T10:15:03.000Z", 294 + key: "port", 295 + }); 296 + expect(result).toContain("state.delete port"); 297 + }); 298 + 299 + it("formats user.* with a text payload (quoted)", () => { 300 + const result = formatEvent({ 301 + session: "test", 302 + type: "user.note", 303 + ts: "2026-04-05T10:15:03.000Z", 304 + text: "checkpoint", 305 + } as any); 306 + expect(result).toContain('user.note "checkpoint"'); 307 + }); 308 + 309 + it("formats user.* with a data payload (JSON)", () => { 310 + const result = formatEvent({ 311 + session: "test", 312 + type: "user.progress", 313 + ts: "2026-04-05T10:15:03.000Z", 314 + data: { pct: 40 }, 315 + } as any); 316 + expect(result).toContain('user.progress {"pct":40}'); 317 + }); 318 + 319 + it("formats user.* with neither data nor text as just the type", () => { 320 + const result = formatEvent({ 321 + session: "test", 322 + type: "user.ping", 323 + ts: "2026-04-05T10:15:03.000Z", 324 + } as any); 325 + // Trailing content after the type should be empty (no stray space / quotes). 326 + expect(result).toMatch(/user\.ping\s*$/); 327 + }); 266 328 }); 267 329 268 330 describe("EventFollower", () => {
+439
tests/state.test.ts
··· 1 + // Tests for `pty state` and the underlying sessions.ts state helpers. 2 + 3 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 4 + import * as fs from "node:fs"; 5 + import * as os from "node:os"; 6 + import * as path from "node:path"; 7 + import { fileURLToPath } from "node:url"; 8 + import { spawn, spawnSync } from "node:child_process"; 9 + import { 10 + getState, getStateKey, setState, deleteState, listStateKeys, 11 + } from "../src/sessions.ts"; 12 + import { EventFollower, type EventRecord } from "../src/events.ts"; 13 + 14 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 + const nodeBin = process.execPath; 16 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 17 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 18 + 19 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-state-")); 20 + afterAll(() => { 21 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 22 + }); 23 + 24 + let bgPids: number[] = []; 25 + let sessionDirs: string[] = []; 26 + 27 + function makeSessionDir(): string { 28 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 29 + sessionDirs.push(dir); 30 + return dir; 31 + } 32 + 33 + let nameCounter = 0; 34 + function uniqueName(): string { 35 + return `st${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 36 + } 37 + 38 + async function startDaemon(sessionDir: string, name: string): Promise<number> { 39 + const config = JSON.stringify({ 40 + name, command: "cat", args: [], displayCommand: "cat", 41 + cwd: os.tmpdir(), rows: 24, cols: 80, 42 + }); 43 + const child = spawn(nodeBin, [serverModule], { 44 + detached: true, 45 + stdio: ["ignore", "ignore", "pipe"], 46 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 47 + }); 48 + let stderr = ""; 49 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 50 + let exitCode: number | null = null; 51 + child.on("exit", (code) => { exitCode = code; }); 52 + (child.stderr as any)?.unref?.(); 53 + child.unref(); 54 + const socketPath = path.join(sessionDir, `${name}.sock`); 55 + const start = Date.now(); 56 + while (Date.now() - start < 5000) { 57 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 58 + try { 59 + fs.statSync(socketPath); 60 + await new Promise((r) => setTimeout(r, 100)); 61 + bgPids.push(child.pid!); 62 + return child.pid!; 63 + } catch {} 64 + await new Promise((r) => setTimeout(r, 50)); 65 + } 66 + throw new Error("Timeout waiting for daemon"); 67 + } 68 + 69 + function runCli(sessionDir: string, env: Record<string, string>, ...args: string[]) { 70 + return spawnSync(nodeBin, [cliPath, ...args], { 71 + env: { ...process.env, PTY_SESSION_DIR: sessionDir, ...env }, 72 + encoding: "utf-8", 73 + input: env.STDIN ?? undefined, 74 + timeout: 10_000, 75 + }); 76 + } 77 + 78 + afterEach(() => { 79 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 80 + bgPids = []; 81 + for (const dir of sessionDirs) { 82 + try { 83 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 84 + } catch {} 85 + } 86 + sessionDirs = []; 87 + }); 88 + 89 + function readEvents(dir: string, name: string): any[] { 90 + try { 91 + const content = fs.readFileSync(path.join(dir, `${name}.events.jsonl`), "utf-8"); 92 + return content.trimEnd().split("\n").filter(Boolean).map(l => JSON.parse(l)); 93 + } catch { return []; } 94 + } 95 + 96 + describe("state helpers (direct API)", () => { 97 + it("setState / getStateKey round-trip complex values", async () => { 98 + const dir = makeSessionDir(); 99 + const name = uniqueName(); 100 + await startDaemon(dir, name); 101 + process.env.PTY_SESSION_DIR = dir; 102 + 103 + setState(name, "port", 3000); 104 + setState(name, "config", { host: "localhost", tls: { cert: "x.pem" } }); 105 + setState(name, "peers", ["a", "b", "c"]); 106 + 107 + expect(getStateKey(name, "port")).toBe(3000); 108 + expect(getStateKey(name, "config")).toEqual({ host: "localhost", tls: { cert: "x.pem" } }); 109 + expect(getStateKey(name, "peers")).toEqual(["a", "b", "c"]); 110 + }, 15000); 111 + 112 + it("getState returns the whole bag", async () => { 113 + const dir = makeSessionDir(); 114 + const name = uniqueName(); 115 + await startDaemon(dir, name); 116 + process.env.PTY_SESSION_DIR = dir; 117 + 118 + setState(name, "a", 1); 119 + setState(name, "b", "two"); 120 + const bag = getState(name); 121 + expect(bag).toEqual({ a: 1, b: "two" }); 122 + }, 15000); 123 + 124 + it("getState returns a fresh copy — mutating doesn't affect stored state", async () => { 125 + const dir = makeSessionDir(); 126 + const name = uniqueName(); 127 + await startDaemon(dir, name); 128 + process.env.PTY_SESSION_DIR = dir; 129 + 130 + setState(name, "a", 1); 131 + const bag = getState(name); 132 + bag.a = 999; 133 + expect(getStateKey(name, "a")).toBe(1); 134 + }, 15000); 135 + 136 + it("deleteState removes the key; deleting the last key drops the field", async () => { 137 + const dir = makeSessionDir(); 138 + const name = uniqueName(); 139 + await startDaemon(dir, name); 140 + process.env.PTY_SESSION_DIR = dir; 141 + 142 + setState(name, "only", 1); 143 + deleteState(name, "only"); 144 + const meta = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")); 145 + expect(meta.state).toBeUndefined(); 146 + }, 15000); 147 + 148 + it("listStateKeys returns the keys", async () => { 149 + const dir = makeSessionDir(); 150 + const name = uniqueName(); 151 + await startDaemon(dir, name); 152 + process.env.PTY_SESSION_DIR = dir; 153 + 154 + setState(name, "a", 1); 155 + setState(name, "b", 2); 156 + expect(listStateKeys(name).sort()).toEqual(["a", "b"]); 157 + }, 15000); 158 + 159 + it("throws on unknown session", () => { 160 + process.env.PTY_SESSION_DIR = makeSessionDir(); 161 + expect(() => getState("no-such-session")).toThrow(/not found/); 162 + expect(() => setState("no-such-session", "k", 1)).toThrow(/not found/); 163 + }); 164 + }); 165 + 166 + describe("pty state CLI", () => { 167 + it("set then get round-trips JSON", async () => { 168 + const dir = makeSessionDir(); 169 + const name = uniqueName(); 170 + await startDaemon(dir, name); 171 + 172 + const setR = runCli(dir, {}, "state", "set", name, "port", "3000"); 173 + expect(setR.status).toBe(0); 174 + 175 + const getR = runCli(dir, {}, "state", "get", name, "port"); 176 + expect(getR.status).toBe(0); 177 + expect(getR.stdout.trim()).toBe("3000"); 178 + }, 15000); 179 + 180 + it("set emits a state.set event; delete emits state.delete", async () => { 181 + const dir = makeSessionDir(); 182 + const name = uniqueName(); 183 + await startDaemon(dir, name); 184 + 185 + runCli(dir, {}, "state", "set", name, "foo", '"bar"'); 186 + runCli(dir, {}, "state", "delete", name, "foo"); 187 + 188 + const events = readEvents(dir, name); 189 + const set = events.find(e => e.type === "state.set" && e.key === "foo"); 190 + const del = events.find(e => e.type === "state.delete" && e.key === "foo"); 191 + expect(set).toBeTruthy(); 192 + expect(set.value).toBe("bar"); 193 + expect(del).toBeTruthy(); 194 + }, 15000); 195 + 196 + it("get with no key prints the whole bag as pretty JSON", async () => { 197 + const dir = makeSessionDir(); 198 + const name = uniqueName(); 199 + await startDaemon(dir, name); 200 + 201 + runCli(dir, {}, "state", "set", name, "a", "1"); 202 + runCli(dir, {}, "state", "set", name, "b", "2"); 203 + 204 + const r = runCli(dir, {}, "state", "get", name); 205 + expect(r.status).toBe(0); 206 + expect(JSON.parse(r.stdout)).toEqual({ a: 1, b: 2 }); 207 + }, 15000); 208 + 209 + it("get on a missing key exits non-zero without printing undefined", async () => { 210 + const dir = makeSessionDir(); 211 + const name = uniqueName(); 212 + await startDaemon(dir, name); 213 + 214 + const r = runCli(dir, {}, "state", "get", name, "missing"); 215 + expect(r.status).not.toBe(0); 216 + expect(r.stdout).toBe(""); 217 + }, 15000); 218 + 219 + it("keys lists keys one per line", async () => { 220 + const dir = makeSessionDir(); 221 + const name = uniqueName(); 222 + await startDaemon(dir, name); 223 + 224 + runCli(dir, {}, "state", "set", name, "alpha", "1"); 225 + runCli(dir, {}, "state", "set", name, "beta", "2"); 226 + 227 + const r = runCli(dir, {}, "state", "keys", name); 228 + expect(r.status).toBe(0); 229 + expect(r.stdout.trim().split("\n").sort()).toEqual(["alpha", "beta"]); 230 + }, 15000); 231 + 232 + it("resolves to $PTY_SESSION when ref is omitted", async () => { 233 + const dir = makeSessionDir(); 234 + const name = uniqueName(); 235 + await startDaemon(dir, name); 236 + 237 + const setR = runCli(dir, { PTY_SESSION: name }, "state", "set", "inside-port", "4242"); 238 + expect(setR.status).toBe(0); 239 + 240 + const getR = runCli(dir, { PTY_SESSION: name }, "state", "get", "inside-port"); 241 + expect(getR.status).toBe(0); 242 + expect(getR.stdout.trim()).toBe("4242"); 243 + }, 15000); 244 + 245 + it("set value from stdin when not given as an arg", async () => { 246 + const dir = makeSessionDir(); 247 + const name = uniqueName(); 248 + await startDaemon(dir, name); 249 + 250 + const r = runCli(dir, { STDIN: '{"big":{"payload":[1,2,3]}}' }, "state", "set", name, "config"); 251 + expect(r.status).toBe(0); 252 + 253 + const getR = runCli(dir, {}, "state", "get", name, "config"); 254 + expect(JSON.parse(getR.stdout)).toEqual({ big: { payload: [1, 2, 3] } }); 255 + }, 15000); 256 + 257 + it("invalid JSON value rejected with a helpful message", async () => { 258 + const dir = makeSessionDir(); 259 + const name = uniqueName(); 260 + await startDaemon(dir, name); 261 + 262 + const r = runCli(dir, {}, "state", "set", name, "k", "not-json"); 263 + expect(r.status).not.toBe(0); 264 + expect(r.stderr).toMatch(/not valid JSON/); 265 + }, 15000); 266 + 267 + it("prototype-chain names don't leak through get/delete/keys", async () => { 268 + // Regression: the original code used `metadata.state?.[key]` (inherits 269 + // through the prototype chain) and `key in metadata.state` (same). 270 + // Meant `getStateKey(name, "toString")` would return the Function 271 + // prototype method, and `pty state delete name toString` would emit a 272 + // ghost `state.delete` event because `in` said the key "existed." 273 + const dir = makeSessionDir(); 274 + const name = uniqueName(); 275 + await startDaemon(dir, name); 276 + process.env.PTY_SESSION_DIR = dir; 277 + 278 + // Prime the bag with one real key so metadata.state exists. 279 + setState(name, "real", 1); 280 + 281 + for (const name2 of ["toString", "hasOwnProperty", "constructor", "__proto__"]) { 282 + expect(getStateKey(name, name2)).toBeUndefined(); 283 + } 284 + 285 + const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 286 + const r = runCli(dir, {}, "state", "delete", name, "toString"); 287 + expect(r.status).toBe(0); 288 + const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 289 + expect(after).toBe(before); // no ghost event for inherited name 290 + 291 + // And `keys` still reports only real own-properties. 292 + expect(listStateKeys(name)).toEqual(["real"]); 293 + }, 15000); 294 + 295 + it("keys rejects extra positional args (consistent with get/set/delete)", async () => { 296 + const dir = makeSessionDir(); 297 + const name = uniqueName(); 298 + await startDaemon(dir, name); 299 + 300 + const r = runCli(dir, {}, "state", "keys", name, "typo"); 301 + expect(r.status).not.toBe(0); 302 + expect(r.stderr).toMatch(/unexpected/i); 303 + }, 15000); 304 + 305 + it("rejects too many positional args (forces the user to quote their JSON)", async () => { 306 + const dir = makeSessionDir(); 307 + const name = uniqueName(); 308 + await startDaemon(dir, name); 309 + 310 + // Unquoted JSON gets split by the shell and joined by the CLI into garbage. 311 + // Previous behavior silently joined `extra1 extra2` with a space and tried 312 + // JSON.parse on it. Now we reject the whole shape with a helpful message. 313 + const r = runCli(dir, {}, "state", "set", name, "key", "extra1", "extra2"); 314 + expect(r.status).not.toBe(0); 315 + expect(r.stderr).toMatch(/too many positional|quote/i); 316 + }, 15000); 317 + 318 + it("delete on a missing key is a quiet no-op — no ghost state.delete event", async () => { 319 + const dir = makeSessionDir(); 320 + const name = uniqueName(); 321 + await startDaemon(dir, name); 322 + 323 + const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 324 + 325 + const r = runCli(dir, {}, "state", "delete", name, "never-existed"); 326 + expect(r.status).toBe(0); 327 + 328 + const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 329 + expect(after).toBe(before); // no new state.delete event fired 330 + }, 15000); 331 + 332 + it("heuristic: first positional that names an existing session wins as ref, not key", async () => { 333 + // Documents current resolveStateTarget() behavior. Two sessions in the 334 + // same dir: `inside` (the session we're pretending to be running under) 335 + // and `other`. Running `pty state set other 42` with PTY_SESSION=inside 336 + // targets session `other` with key=... wait, there's no key. That'd error. 337 + // Instead use: `pty state set other mykey 1`. The heuristic sees `other` 338 + // is a known session → treats it as ref, key=mykey, value=1. 339 + // 340 + // The footgun: if `mykey` collides with a session name, a future 341 + // refactor might re-shuffle positional resolution. This test locks the 342 + // current rule in place so the ambiguity can't silently drift. 343 + const dir = makeSessionDir(); 344 + const inside = uniqueName(); 345 + const other = uniqueName(); 346 + await startDaemon(dir, inside); 347 + await startDaemon(dir, other); 348 + 349 + // With PTY_SESSION=inside, `state set <other's real name> mykey 1` 350 + // should target `other` (heuristic: first positional is a known session). 351 + const r = runCli(dir, { PTY_SESSION: inside }, "state", "set", other, "mykey", "1"); 352 + expect(r.status).toBe(0); 353 + 354 + // Wrong interpretation (key="other" on `inside`) would put { other: "mykey" } 355 + // somewhere. Verify that didn't happen. 356 + process.env.PTY_SESSION_DIR = dir; 357 + expect(getStateKey(other, "mykey")).toBe(1); 358 + expect(getState(inside)).toEqual({}); 359 + }, 20000); 360 + 361 + it("heuristic: an unknown-session-named first arg falls back to $PTY_SESSION and is treated as a key", async () => { 362 + // Mirror of the above: when the first positional is NOT a known session, 363 + // it's interpreted as the key instead. Same resolveStateTarget() path — 364 + // different branch. 365 + const dir = makeSessionDir(); 366 + const inside = uniqueName(); 367 + await startDaemon(dir, inside); 368 + 369 + // `notasession` isn't the name of any session in `dir`, so this becomes 370 + // `state set <inside> notasession "42"`. 371 + const r = runCli(dir, { PTY_SESSION: inside }, "state", "set", "notasession", "42"); 372 + expect(r.status).toBe(0); 373 + 374 + process.env.PTY_SESSION_DIR = dir; 375 + expect(getStateKey(inside, "notasession")).toBe(42); 376 + }, 15000); 377 + 378 + it("setState under in-process Promise.all: every update lands", async () => { 379 + // Node's main loop is single-threaded and setState is synchronous — 380 + // Promise.all serializes these on the event loop with no interleaving 381 + // between a read and its matching write. Any lost update here means a 382 + // subtle bug (e.g., async RMW leaking in) we want to catch immediately. 383 + const dir = makeSessionDir(); 384 + const name = uniqueName(); 385 + await startDaemon(dir, name); 386 + process.env.PTY_SESSION_DIR = dir; 387 + 388 + const KEYS = 20; 389 + await Promise.all( 390 + Array.from({ length: KEYS }, (_, i) => Promise.resolve().then(() => setState(name, `k${i}`, i))) 391 + ); 392 + 393 + const bag = getState(name); 394 + expect(Object.keys(bag).length).toBe(KEYS); 395 + for (let i = 0; i < KEYS; i++) { 396 + expect(bag[`k${i}`]).toBe(i); 397 + } 398 + }, 15000); 399 + }); 400 + 401 + describe("EventFollower — state.* streaming", () => { 402 + it("delivers state.set and state.delete events live", async () => { 403 + const dir = makeSessionDir(); 404 + const name = uniqueName(); 405 + await startDaemon(dir, name); 406 + 407 + process.env.PTY_SESSION_DIR = dir; 408 + 409 + const received: EventRecord[] = []; 410 + const follower = new EventFollower({ 411 + names: [name], 412 + onEvent: (e) => { if (e.type === "state.set" || e.type === "state.delete") received.push(e); }, 413 + }); 414 + follower.start(); 415 + 416 + try { 417 + await new Promise((r) => setTimeout(r, 100)); 418 + 419 + // Drive mutations through the CLI so we test the actual shipping path, 420 + // not an internal shortcut that bypasses event emission. 421 + const s = runCli(dir, {}, "state", "set", name, "port", "3000"); 422 + expect(s.status).toBe(0); 423 + const d = runCli(dir, {}, "state", "delete", name, "port"); 424 + expect(d.status).toBe(0); 425 + 426 + const deadline = Date.now() + 2000; 427 + while (received.length < 2 && Date.now() < deadline) { 428 + await new Promise((r) => setTimeout(r, 50)); 429 + } 430 + 431 + expect(received.map(e => e.type)).toEqual(["state.set", "state.delete"]); 432 + expect((received[0] as any).key).toBe("port"); 433 + expect((received[0] as any).value).toBe(3000); 434 + expect((received[1] as any).key).toBe("port"); 435 + } finally { 436 + follower.stop(); 437 + } 438 + }, 20000); 439 + });