This repository has no description
0

Configure Feed

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

Emit display_name_change + tags_change; move metadata-mutation events into the helpers so the programmatic API emits too.

Requested by pty-layout-claude: `pty rename` wrote metadata atomically
but emitted no event, so EventFollower consumers (pty-layout's pane
titles) had no signal to refresh and stayed stale until re-attach.

Same bundling for tag mutations since the smell was identical —
setDisplayName and updateTags now both emit on effective change:

- display_name_change: { session, ts, previous, value } with value
string | null. Skipped on no-op writes.
- tags_change: { session, ts, previous, value } carrying full
Record<string,string> snapshots so consumers can diff without
reasoning about updates vs. removals. Skipped on no-op.

While here, moved the state.set / state.delete emission from the CLI
down into setState / deleteState themselves. The CLI was the only
emission point before, which meant @myobie/pty/client consumers
calling setState directly got silent writes. Now every metadata-
mutation helper emits uniformly regardless of caller.

Added appendEventSync so the sync helpers can emit inline without
going async. Same MAX_LINES retention as the async path, with a
stat-based fast path.

Tests: 19 new across tests/metadata-events.test.ts (new) and
tests/state.test.ts — covering both events (add / change / clear /
remove), both formatEvent cases, live EventFollower delivery, CLI
end-to-end (pty rename, pty tag), no-op suppression, and the
programmatic-emission fix for setState/deleteState.

Nathan Herald (Apr 23, 2026, 11:05 AM +0200) 04076b88 51a41973

+533 -28
+4 -1
CHANGELOG.md
··· 10 10 - `pty state delete [ref] <key>` — removes a key (drops the whole `state` field if the last key is removed). 11 11 - `pty state keys [ref]` — one key per line. 12 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. 13 + - **New event types exported from `@myobie/pty/client`:** `UserEvent`, `StateSetEvent`, `StateDeleteEvent`, `DisplayNameChangeEvent`, `TagsChangeEvent`, 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"` / `"display_name_change"` / `"tags_change"`); existing consumers that switch on the enum values are unaffected. 14 + - **`display_name_change` event on rename.** `setDisplayName` (and therefore `pty rename`) now emits `{ session, type: "display_name_change", ts, previous, value }` whenever the stored display name actually changes. `previous` and `value` are `string | null`. No-op writes (renaming to the same value, clearing when already null) emit nothing. Requested by pty-layout so live TUIs can refresh cached pane titles in response to a rename instead of polling metadata or waiting for re-attach. 15 + - **`tags_change` event on tag mutation.** `updateTags` (and therefore `pty tag`, `pty run --tag`, supervisor status stamping, etc.) now emits `{ session, type: "tags_change", ts, previous, value }` with full snapshots of the previous and new tag maps. Consumers diff the two maps; no need to reason about `updates` vs. `removals`. Skipped on no-op updates (setting the same value, removing a key that isn't present). 16 + - **Event emission moved down into the helpers.** Previously `state.set` / `state.delete` were emitted by the `pty state` CLI wrapper — calling `setState`/`deleteState` directly from `@myobie/pty/client` wrote metadata but no event, so programmatic consumers (pty-layout, dashboards) never saw the change. `setState` now emits on every successful write; `deleteState` emits when it actually removed a key. Same pattern for `setDisplayName` and `updateTags`. The CLI no longer needs to emit separately. Downstream callers pick this up automatically — if you were relying on the CLI being the emission point, you no longer need to guard against double-emission because the CLI path was removed. 14 17 15 18 ### Supervisor 16 19 - 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`.
+1 -1
README.md
··· 160 160 161 161 ### Events 162 162 163 - Sessions automatically log terminal events — bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions — to per-session JSONL files. 163 + Sessions automatically log terminal events — bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions — plus metadata mutations: `display_name_change` on rename, `tags_change` on tag updates, `state.set` / `state.delete` on state bag writes, and any `user.*` events published via `pty emit`. Everything goes into per-session JSONL files. 164 164 165 165 ```sh 166 166 pty events myserver # follow events live (like tail -f)
+5 -12
src/cli.ts
··· 30 30 import { 31 31 EventFollower, EventWriter, EventType, 32 32 readRecentEvents, formatEvent, 33 - emitUserEvent, appendEvent, 33 + emitUserEvent, 34 34 } from "./events.ts"; 35 35 import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 36 36 import { getSupervisorDir } from "./supervisor.ts"; ··· 2060 2060 process.exit(1); 2061 2061 } 2062 2062 setState(name, key, value); 2063 - await appendEvent(name, { 2064 - session: name, type: "state.set", ts: new Date().toISOString(), 2065 - key, value, 2066 - }); 2063 + // state.set event is emitted by setState itself. 2067 2064 return; 2068 2065 } 2069 2066 case "delete": ··· 2074 2071 process.exit(1); 2075 2072 } 2076 2073 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 - } 2074 + deleteState(name, key); 2075 + // state.delete event is emitted by deleteState when a key was 2076 + // actually removed; a delete on a missing key is a silent no-op. 2084 2077 return; 2085 2078 } 2086 2079 case "keys": {
+1
src/client-api.ts
··· 40 40 type SessionRestartEvent, type SessionFailedEvent, 41 41 type SupervisorStartEvent, type SupervisorStopEvent, 42 42 type UserEvent, type StateSetEvent, type StateDeleteEvent, 43 + type DisplayNameChangeEvent, type TagsChangeEvent, 43 44 type FollowerOptions, 44 45 } from "./events.ts"; 45 46
+54 -1
src/events.ts
··· 112 112 key: string; 113 113 } 114 114 115 + /** Emitted whenever `setDisplayName` actually changes the stored value. 116 + * `previous` / `value` are `null` when absent. Skipped on no-op writes 117 + * so consumers don't get spurious refresh pings. */ 118 + export interface DisplayNameChangeEvent extends EventBase { 119 + type: "display_name_change"; 120 + previous: string | null; 121 + value: string | null; 122 + } 123 + 124 + /** Emitted whenever `updateTags` effectively changes the tags map. 125 + * Snapshots both the previous and new full tag maps so consumers 126 + * can diff without having to reason about `updates` vs `removals`. */ 127 + export interface TagsChangeEvent extends EventBase { 128 + type: "tags_change"; 129 + previous: Record<string, string>; 130 + value: Record<string, string>; 131 + } 132 + 115 133 export type EventRecord = 116 134 | BellEvent 117 135 | TitleChangeEvent ··· 127 145 | SupervisorStopEvent 128 146 | UserEvent 129 147 | StateSetEvent 130 - | StateDeleteEvent; 148 + | StateDeleteEvent 149 + | DisplayNameChangeEvent 150 + | TagsChangeEvent; 131 151 132 152 /** Type guard: narrows an EventRecord to a UserEvent. */ 133 153 export function isUserEvent(e: EventRecord): e is UserEvent { ··· 171 191 const line = JSON.stringify(event) + "\n"; 172 192 await fsp.appendFile(filePath, line); 173 193 await maybeTruncate(filePath); 194 + } 195 + 196 + /** Synchronous twin of `appendEvent` — lets synchronous metadata-mutation 197 + * helpers (setDisplayName, updateTags, setState, deleteState) emit their 198 + * change events inline without forcing their signatures to go async. 199 + * Uses the same retention path with a sync stat fast-path. */ 200 + export function appendEventSync(name: string, event: EventRecord): void { 201 + ensureSessionDir(); 202 + const filePath = getEventsPath(name); 203 + const line = JSON.stringify(event) + "\n"; 204 + fs.appendFileSync(filePath, line); 205 + maybeTruncateSync(filePath); 206 + } 207 + 208 + function maybeTruncateSync(filePath: string): void { 209 + try { 210 + const stat = fs.statSync(filePath); 211 + if (stat.size < MAX_LINES * 40) return; 212 + const content = fs.readFileSync(filePath, "utf-8"); 213 + const lines = content.trimEnd().split("\n"); 214 + if (lines.length >= MAX_LINES) { 215 + fs.writeFileSync(filePath, lines.slice(-KEEP_LINES).join("\n") + "\n"); 216 + } 217 + } catch { 218 + // File might have been concurrently removed — ignore. 219 + } 174 220 } 175 221 176 222 /** Cheap retention check. Only reads + rewrites when the file's byte size ··· 439 485 return `${prefix} state.set ${event.key} = ${JSON.stringify(event.value)}`; 440 486 case "state.delete": 441 487 return `${prefix} state.delete ${event.key}`; 488 + case "display_name_change": 489 + return `${prefix} display_name -> ${JSON.stringify(event.value)} (was ${JSON.stringify(event.previous)})`; 490 + case "tags_change": { 491 + const fmt = (t: Record<string, string>) => 492 + Object.keys(t).length === 0 ? "{}" : Object.entries(t).map(([k, v]) => `${k}=${v}`).join(" "); 493 + return `${prefix} tags -> ${fmt(event.value)} (was ${fmt(event.previous)})`; 494 + } 442 495 default: { 443 496 // user.* events + anything else unknown-at-compile-time. 444 497 const e = event as EventBase & { data?: unknown; text?: string };
+64 -13
src/sessions.ts
··· 2 2 import * as path from "node:path"; 3 3 import * as os from "node:os"; 4 4 import * as net from "node:net"; 5 + // Circular import: events.ts imports getEventsPath/ensureSessionDir from 6 + // this file. Cycle is safe — `appendEventSync` is only called at runtime 7 + // from inside functions, never at module-init time. 8 + import { appendEventSync } from "./events.ts"; 5 9 6 10 const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".local", "state", "pty"); 7 11 ··· 125 129 } 126 130 127 131 /** Set or clear the displayName on an existing session. Atomic read-modify-write. 128 - * Pass `null` to remove the alias. Throws if `name` doesn't exist. */ 132 + * Pass `null` to remove the alias. Throws if `name` doesn't exist. Emits a 133 + * `display_name_change` event when (and only when) the value actually 134 + * changed — no-op renames don't ping downstream watchers. */ 129 135 export function setDisplayName(name: string, displayName: string | null): void { 130 136 const metadata = readMetadata(name); 131 137 if (!metadata) { 132 138 throw new Error(`Session "${name}" not found.`); 133 139 } 134 - if (displayName === null || displayName === "") { 140 + const previous = metadata.displayName ?? null; 141 + const next = displayName === null || displayName === "" ? null : displayName; 142 + if (previous === next) return; // no-op write + no-op event 143 + 144 + if (next === null) { 135 145 delete metadata.displayName; 136 146 } else { 137 - metadata.displayName = displayName; 147 + metadata.displayName = next; 138 148 } 139 149 writeMetadata(name, metadata); 150 + appendEventSync(name, { 151 + session: name, 152 + type: "display_name_change", 153 + ts: new Date().toISOString(), 154 + previous, 155 + value: next, 156 + }); 140 157 } 141 158 142 - /** Update tags on an existing session. Performs an atomic read-modify-write. */ 159 + /** Update tags on an existing session. Performs an atomic read-modify-write. 160 + * Emits a `tags_change` event carrying snapshots of the full previous and 161 + * new tag maps when the effective tags change. No-op updates (e.g. setting 162 + * a key to the same value, removing a key that isn't there) don't emit. */ 143 163 export function updateTags( 144 164 name: string, 145 165 updates: Record<string, string>, ··· 149 169 if (!metadata) { 150 170 throw new Error(`Session "${name}" not found.`); 151 171 } 152 - const tags = { ...(metadata.tags ?? {}) }; 172 + const previous = { ...(metadata.tags ?? {}) }; 173 + const tags = { ...previous }; 153 174 for (const [k, v] of Object.entries(updates)) { 154 175 tags[k] = v; 155 176 } 156 177 for (const k of removals) { 157 178 delete tags[k]; 158 179 } 180 + if (tagsEqual(previous, tags)) return; // no-op write + no-op event 181 + 159 182 if (Object.keys(tags).length > 0) { 160 183 metadata.tags = tags; 161 184 } else { 162 185 delete metadata.tags; 163 186 } 164 187 writeMetadata(name, metadata); 188 + appendEventSync(name, { 189 + session: name, 190 + type: "tags_change", 191 + ts: new Date().toISOString(), 192 + previous, 193 + value: tags, 194 + }); 195 + } 196 + 197 + function tagsEqual(a: Record<string, string>, b: Record<string, string>): boolean { 198 + const aKeys = Object.keys(a); 199 + const bKeys = Object.keys(b); 200 + if (aKeys.length !== bKeys.length) return false; 201 + for (const k of aKeys) { 202 + if (!Object.prototype.hasOwnProperty.call(b, k) || a[k] !== b[k]) return false; 203 + } 204 + return true; 165 205 } 166 206 167 207 /** Read a session's state bag. Returns an empty object when the session ··· 185 225 } 186 226 187 227 /** 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). */ 228 + * file. Emits a `state.set` event on every successful write — callers 229 + * that want the full reactive signal (pty-layout, activity viewers, 230 + * etc.) get it whether they use the CLI or the programmatic API. */ 193 231 export function setState(name: string, key: string, value: unknown): void { 194 232 const metadata = readMetadata(name); 195 233 if (!metadata) throw new Error(`Session "${name}" not found.`); ··· 197 235 state[key] = value; 198 236 metadata.state = state; 199 237 writeMetadata(name, metadata); 238 + appendEventSync(name, { 239 + session: name, 240 + type: "state.set", 241 + ts: new Date().toISOString(), 242 + key, 243 + value, 244 + }); 200 245 } 201 246 202 247 /** Delete a key from the state bag. Returns `true` when the key existed and 203 248 * 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. */ 249 + * Emits a `state.delete` event only when something was actually removed, 250 + * so a delete on a missing key is a true no-op (no ghost event). 251 + * Uses own-property lookup — inherited names like `toString` never match. */ 207 252 export function deleteState(name: string, key: string): boolean { 208 253 const metadata = readMetadata(name); 209 254 if (!metadata) throw new Error(`Session "${name}" not found.`); ··· 216 261 delete metadata.state; 217 262 } 218 263 writeMetadata(name, metadata); 264 + appendEventSync(name, { 265 + session: name, 266 + type: "state.delete", 267 + ts: new Date().toISOString(), 268 + key, 269 + }); 219 270 return true; 220 271 } 221 272
+358
tests/metadata-events.test.ts
··· 1 + // Tests for the new display_name_change / tags_change events that fire 2 + // from setDisplayName / updateTags. Requested by pty-layout-claude so 3 + // downstream consumers (pty-layout) can react to rename and tag 4 + // mutations without polling the metadata file. 5 + 6 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 7 + import * as fs from "node:fs"; 8 + import * as os from "node:os"; 9 + import * as path from "node:path"; 10 + import { fileURLToPath } from "node:url"; 11 + import { spawn, spawnSync } from "node:child_process"; 12 + import { 13 + setDisplayName, updateTags, readMetadata, 14 + } from "../src/sessions.ts"; 15 + import { EventFollower, formatEvent, type EventRecord } from "../src/events.ts"; 16 + 17 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 18 + const nodeBin = process.execPath; 19 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 20 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 21 + 22 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-metaev-")); 23 + afterAll(() => { 24 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 25 + }); 26 + 27 + let bgPids: number[] = []; 28 + let sessionDirs: string[] = []; 29 + 30 + function makeSessionDir(): string { 31 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 32 + sessionDirs.push(dir); 33 + return dir; 34 + } 35 + 36 + let nameCounter = 0; 37 + function uniqueName(): string { 38 + return `mev${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 39 + } 40 + 41 + async function startDaemon(sessionDir: string, name: string): Promise<number> { 42 + const config = JSON.stringify({ 43 + name, command: "cat", args: [], displayCommand: "cat", 44 + cwd: os.tmpdir(), rows: 24, cols: 80, 45 + }); 46 + const child = spawn(nodeBin, [serverModule], { 47 + detached: true, 48 + stdio: ["ignore", "ignore", "pipe"], 49 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 50 + }); 51 + let stderr = ""; 52 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 53 + let exitCode: number | null = null; 54 + child.on("exit", (code) => { exitCode = code; }); 55 + (child.stderr as any)?.unref?.(); 56 + child.unref(); 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 + function readEvents(dir: string, name: string): any[] { 92 + try { 93 + const content = fs.readFileSync(path.join(dir, `${name}.events.jsonl`), "utf-8"); 94 + return content.trimEnd().split("\n").filter(Boolean).map(l => JSON.parse(l)); 95 + } catch { return []; } 96 + } 97 + 98 + describe("setDisplayName — display_name_change event", () => { 99 + it("emits on a real change, with previous + value populated", async () => { 100 + const dir = makeSessionDir(); 101 + const name = uniqueName(); 102 + await startDaemon(dir, name); 103 + process.env.PTY_SESSION_DIR = dir; 104 + 105 + setDisplayName(name, "my-label"); 106 + 107 + const ev = readEvents(dir, name).find(e => e.type === "display_name_change"); 108 + expect(ev).toBeTruthy(); 109 + expect(ev.previous).toBeNull(); 110 + expect(ev.value).toBe("my-label"); 111 + }, 15000); 112 + 113 + it("emits on clear (value becomes null)", async () => { 114 + const dir = makeSessionDir(); 115 + const name = uniqueName(); 116 + await startDaemon(dir, name); 117 + process.env.PTY_SESSION_DIR = dir; 118 + 119 + setDisplayName(name, "initial"); 120 + setDisplayName(name, null); 121 + 122 + const changes = readEvents(dir, name).filter(e => e.type === "display_name_change"); 123 + expect(changes).toHaveLength(2); 124 + expect(changes[0].value).toBe("initial"); 125 + expect(changes[1].previous).toBe("initial"); 126 + expect(changes[1].value).toBeNull(); 127 + }, 15000); 128 + 129 + it("does NOT emit on a no-op write (same value twice)", async () => { 130 + const dir = makeSessionDir(); 131 + const name = uniqueName(); 132 + await startDaemon(dir, name); 133 + process.env.PTY_SESSION_DIR = dir; 134 + 135 + setDisplayName(name, "stable"); 136 + const afterFirst = readEvents(dir, name).filter(e => e.type === "display_name_change").length; 137 + setDisplayName(name, "stable"); // no-op 138 + const afterSecond = readEvents(dir, name).filter(e => e.type === "display_name_change").length; 139 + expect(afterSecond).toBe(afterFirst); 140 + }, 15000); 141 + 142 + it("does NOT emit on a no-op clear (was already null)", async () => { 143 + const dir = makeSessionDir(); 144 + const name = uniqueName(); 145 + await startDaemon(dir, name); 146 + process.env.PTY_SESSION_DIR = dir; 147 + 148 + const before = readEvents(dir, name).filter(e => e.type === "display_name_change").length; 149 + setDisplayName(name, null); // was never set 150 + const after = readEvents(dir, name).filter(e => e.type === "display_name_change").length; 151 + expect(after).toBe(before); 152 + }, 15000); 153 + 154 + it("fires from `pty rename` CLI too (end-to-end)", async () => { 155 + const dir = makeSessionDir(); 156 + const name = uniqueName(); 157 + await startDaemon(dir, name); 158 + 159 + const r = runCli(dir, {}, "rename", name, "friendly"); 160 + expect(r.status).toBe(0); 161 + 162 + const ev = readEvents(dir, name).find(e => e.type === "display_name_change"); 163 + expect(ev).toBeTruthy(); 164 + expect(ev.value).toBe("friendly"); 165 + }, 15000); 166 + 167 + it("delivers live via EventFollower", async () => { 168 + const dir = makeSessionDir(); 169 + const name = uniqueName(); 170 + await startDaemon(dir, name); 171 + process.env.PTY_SESSION_DIR = dir; 172 + 173 + const received: EventRecord[] = []; 174 + const follower = new EventFollower({ 175 + names: [name], 176 + onEvent: (e) => { if (e.type === "display_name_change") received.push(e); }, 177 + }); 178 + follower.start(); 179 + 180 + try { 181 + await new Promise((r) => setTimeout(r, 100)); 182 + setDisplayName(name, "live-label"); 183 + 184 + const deadline = Date.now() + 2000; 185 + while (received.length < 1 && Date.now() < deadline) { 186 + await new Promise((r) => setTimeout(r, 50)); 187 + } 188 + expect(received.length).toBe(1); 189 + expect((received[0] as any).value).toBe("live-label"); 190 + } finally { 191 + follower.stop(); 192 + } 193 + }, 15000); 194 + }); 195 + 196 + describe("updateTags — tags_change event", () => { 197 + it("emits with full previous + value snapshots when a tag is added", async () => { 198 + const dir = makeSessionDir(); 199 + const name = uniqueName(); 200 + await startDaemon(dir, name); 201 + process.env.PTY_SESSION_DIR = dir; 202 + 203 + updateTags(name, { role: "web" }); 204 + 205 + const ev = readEvents(dir, name).find(e => e.type === "tags_change"); 206 + expect(ev).toBeTruthy(); 207 + expect(ev.previous).toEqual({}); 208 + expect(ev.value).toEqual({ role: "web" }); 209 + }, 15000); 210 + 211 + it("emits with previous carrying existing tags + value reflecting the merge", async () => { 212 + const dir = makeSessionDir(); 213 + const name = uniqueName(); 214 + await startDaemon(dir, name); 215 + process.env.PTY_SESSION_DIR = dir; 216 + 217 + updateTags(name, { role: "web" }); 218 + updateTags(name, { owner: "forge" }); 219 + 220 + const changes = readEvents(dir, name).filter(e => e.type === "tags_change"); 221 + expect(changes).toHaveLength(2); 222 + expect(changes[1].previous).toEqual({ role: "web" }); 223 + expect(changes[1].value).toEqual({ role: "web", owner: "forge" }); 224 + }, 15000); 225 + 226 + it("emits when a tag is removed", async () => { 227 + const dir = makeSessionDir(); 228 + const name = uniqueName(); 229 + await startDaemon(dir, name); 230 + process.env.PTY_SESSION_DIR = dir; 231 + 232 + updateTags(name, { a: "1", b: "2" }); 233 + updateTags(name, {}, ["a"]); 234 + 235 + const changes = readEvents(dir, name).filter(e => e.type === "tags_change"); 236 + expect(changes).toHaveLength(2); 237 + expect(changes[1].previous).toEqual({ a: "1", b: "2" }); 238 + expect(changes[1].value).toEqual({ b: "2" }); 239 + }, 15000); 240 + 241 + it("does NOT emit on a no-op (same value for an existing key)", async () => { 242 + const dir = makeSessionDir(); 243 + const name = uniqueName(); 244 + await startDaemon(dir, name); 245 + process.env.PTY_SESSION_DIR = dir; 246 + 247 + updateTags(name, { role: "web" }); 248 + const before = readEvents(dir, name).filter(e => e.type === "tags_change").length; 249 + updateTags(name, { role: "web" }); // no-op — same key, same value 250 + const after = readEvents(dir, name).filter(e => e.type === "tags_change").length; 251 + expect(after).toBe(before); 252 + }, 15000); 253 + 254 + it("does NOT emit when the `removals` list doesn't intersect current keys", async () => { 255 + const dir = makeSessionDir(); 256 + const name = uniqueName(); 257 + await startDaemon(dir, name); 258 + process.env.PTY_SESSION_DIR = dir; 259 + 260 + updateTags(name, { role: "web" }); 261 + const before = readEvents(dir, name).filter(e => e.type === "tags_change").length; 262 + updateTags(name, {}, ["never-was-set"]); 263 + const after = readEvents(dir, name).filter(e => e.type === "tags_change").length; 264 + expect(after).toBe(before); 265 + }, 15000); 266 + 267 + it("fires from `pty tag` CLI too (end-to-end)", async () => { 268 + const dir = makeSessionDir(); 269 + const name = uniqueName(); 270 + await startDaemon(dir, name); 271 + 272 + const r = runCli(dir, {}, "tag", name, "role=web"); 273 + expect(r.status).toBe(0); 274 + 275 + const ev = readEvents(dir, name).find(e => e.type === "tags_change"); 276 + expect(ev).toBeTruthy(); 277 + expect(ev.value.role).toBe("web"); 278 + }, 15000); 279 + 280 + it("delivers live via EventFollower", async () => { 281 + const dir = makeSessionDir(); 282 + const name = uniqueName(); 283 + await startDaemon(dir, name); 284 + process.env.PTY_SESSION_DIR = dir; 285 + 286 + const received: EventRecord[] = []; 287 + const follower = new EventFollower({ 288 + names: [name], 289 + onEvent: (e) => { if (e.type === "tags_change") received.push(e); }, 290 + }); 291 + follower.start(); 292 + 293 + try { 294 + await new Promise((r) => setTimeout(r, 100)); 295 + updateTags(name, { live: "yes" }); 296 + 297 + const deadline = Date.now() + 2000; 298 + while (received.length < 1 && Date.now() < deadline) { 299 + await new Promise((r) => setTimeout(r, 50)); 300 + } 301 + expect(received.length).toBe(1); 302 + expect((received[0] as any).value).toEqual({ live: "yes" }); 303 + } finally { 304 + follower.stop(); 305 + } 306 + }, 15000); 307 + }); 308 + 309 + describe("formatEvent for new metadata events", () => { 310 + it("formats display_name_change showing both previous and new", () => { 311 + const line = formatEvent({ 312 + session: "test", 313 + type: "display_name_change", 314 + ts: "2026-04-23T10:15:03.000Z", 315 + previous: "old", 316 + value: "new", 317 + }); 318 + expect(line).toContain("display_name ->"); 319 + expect(line).toContain('"new"'); 320 + expect(line).toContain('"old"'); 321 + }); 322 + 323 + it("formats display_name_change cleanly when clearing (value=null)", () => { 324 + const line = formatEvent({ 325 + session: "test", 326 + type: "display_name_change", 327 + ts: "2026-04-23T10:15:03.000Z", 328 + previous: "old", 329 + value: null, 330 + }); 331 + expect(line).toContain("null"); 332 + expect(line).toContain('"old"'); 333 + }); 334 + 335 + it("formats tags_change as a space-separated k=v listing", () => { 336 + const line = formatEvent({ 337 + session: "test", 338 + type: "tags_change", 339 + ts: "2026-04-23T10:15:03.000Z", 340 + previous: { role: "web" }, 341 + value: { role: "web", owner: "forge" }, 342 + }); 343 + expect(line).toContain("tags ->"); 344 + expect(line).toContain("role=web"); 345 + expect(line).toContain("owner=forge"); 346 + }); 347 + 348 + it("formats tags_change with empty maps as {}", () => { 349 + const line = formatEvent({ 350 + session: "test", 351 + type: "tags_change", 352 + ts: "2026-04-23T10:15:03.000Z", 353 + previous: { role: "web" }, 354 + value: {}, 355 + }); 356 + expect(line).toContain("{}"); 357 + }); 358 + });
+46
tests/state.test.ts
··· 398 398 }, 15000); 399 399 }); 400 400 401 + describe("state helpers emit events from the programmatic API", () => { 402 + // Regression: pty-layout et al. use setState/deleteState via the client 403 + // API, not via the CLI. Prior to this fix, events only fired from the 404 + // CLI wrapper — programmatic callers got silent writes. 405 + it("setState emits state.set even when called directly (no CLI involved)", async () => { 406 + const dir = makeSessionDir(); 407 + const name = uniqueName(); 408 + await startDaemon(dir, name); 409 + process.env.PTY_SESSION_DIR = dir; 410 + 411 + setState(name, "port", 9000); 412 + 413 + const events = readEvents(dir, name); 414 + const set = events.find(e => e.type === "state.set" && e.key === "port"); 415 + expect(set).toBeTruthy(); 416 + expect(set.value).toBe(9000); 417 + }, 15000); 418 + 419 + it("deleteState emits state.delete when something was removed", async () => { 420 + const dir = makeSessionDir(); 421 + const name = uniqueName(); 422 + await startDaemon(dir, name); 423 + process.env.PTY_SESSION_DIR = dir; 424 + 425 + setState(name, "x", 1); 426 + const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 427 + const removed = deleteState(name, "x"); 428 + expect(removed).toBe(true); 429 + const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 430 + expect(after).toBe(before + 1); 431 + }, 15000); 432 + 433 + it("deleteState on a missing key writes no event and returns false", async () => { 434 + const dir = makeSessionDir(); 435 + const name = uniqueName(); 436 + await startDaemon(dir, name); 437 + process.env.PTY_SESSION_DIR = dir; 438 + 439 + const before = readEvents(dir, name).filter(e => e.type === "state.delete").length; 440 + const removed = deleteState(name, "never-existed"); 441 + expect(removed).toBe(false); 442 + const after = readEvents(dir, name).filter(e => e.type === "state.delete").length; 443 + expect(after).toBe(before); 444 + }, 15000); 445 + }); 446 + 401 447 describe("EventFollower — state.* streaming", () => { 402 448 it("delivers state.set and state.delete events live", async () => { 403 449 const dir = makeSessionDir();