This repository has no description
0

Configure Feed

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

pty events

Nathan Herald (Apr 5, 2026, 11:21 AM +0200) a6c346ac e3153025

+925 -6
+12
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Unreleased 4 + 5 + - Add terminal event logging — sessions capture bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions to a per-session JSONL file 6 + - Add `pty events <name>` command to follow events in real-time (like `tail -f`) 7 + - Add `pty events --all` to follow events from all sessions, interleaved 8 + - Add `pty events --recent <name>` to show recent events and exit 9 + - Add `pty events --json` for machine-readable JSONL output 10 + - Deduplicate consecutive identical title change events 11 + - Event files auto-truncate at 1,000 lines (keeping most recent 500) 12 + - Event file I/O is fully async (non-blocking write queue) 13 + - Event files are cleaned up with the existing 24-hour dead session TTL 14 + 3 15 ## 0.4.1 4 16 5 17 - Add `pty stats` command for live session metrics (terminal size, scrollback, clients, modes, uptime)
+3 -2
completions/pty.bash
··· 6 6 COMPREPLY=() 7 7 cur="${COMP_WORDS[COMP_CWORD]}" 8 8 prev="${COMP_WORDS[COMP_CWORD-1]}" 9 - commands="run attach peek send kill list restart help" 9 + commands="run attach peek send kill list restart events help" 10 10 11 11 # Complete subcommand 12 12 if [[ ${COMP_CWORD} -eq 1 ]]; then ··· 16 16 17 17 # Complete session names for commands that take them 18 18 case "${COMP_WORDS[1]}" in 19 - attach|a|peek|send|kill|restart) 19 + attach|a|peek|send|kill|restart|events) 20 20 local session_dir="${PTY_SESSION_DIR:-${HOME}/.local/state/pty}" 21 21 if [[ -d "${session_dir}" ]]; then 22 22 local names ··· 30 30 peek) COMPREPLY=($(compgen -W "--follow -f --plain" -- "${cur}")) ;; 31 31 send) COMPREPLY=($(compgen -W "--seq --with-delay" -- "${cur}")) ;; 32 32 restart) COMPREPLY=($(compgen -W "--yes -y" -- "${cur}")) ;; 33 + events) COMPREPLY=($(compgen -W "--all --recent --json" -- "${cur}")) ;; 33 34 esac 34 35 fi 35 36 ;;
+7
completions/pty.fish
··· 37 37 complete -c pty -n __pty_needs_command -a kill -d 'Kill or remove a session' 38 38 complete -c pty -n __pty_needs_command -a list -d 'List active sessions' 39 39 complete -c pty -n __pty_needs_command -a restart -d 'Restart a session' 40 + complete -c pty -n __pty_needs_command -a events -d 'Follow terminal events from sessions' 40 41 complete -c pty -n __pty_needs_command -a help -d 'Show usage information' 41 42 42 43 # run: flags and file completion for the command argument ··· 65 66 # restart: session names and flags 66 67 complete -c pty -n '__pty_using_command restart' -a '(__pty_sessions)' -d 'Session' 67 68 complete -c pty -n '__pty_using_command restart' -s y -l yes -d 'Skip confirmation' 69 + 70 + # events: session names and flags 71 + complete -c pty -n '__pty_using_command events' -a '(__pty_sessions)' -d 'Session' 72 + complete -c pty -n '__pty_using_command events' -l all -d 'Follow events from all sessions' 73 + complete -c pty -n '__pty_using_command events' -l recent -d 'Show recent events and exit' 74 + complete -c pty -n '__pty_using_command events' -l json -d 'Output raw JSONL' 68 75 69 76 # list: flags 70 77 complete -c pty -n '__pty_using_command list' -l json -d 'Output as JSON'
+8
completions/pty.zsh
··· 23 23 'list:List active sessions' 24 24 'ls:List active sessions' 25 25 'restart:Restart an exited session' 26 + 'events:Follow terminal events from sessions' 26 27 'help:Show usage information' 27 28 ) 28 29 ··· 59 60 restart) 60 61 _arguments \ 61 62 '(-y --yes)'{-y,--yes}'[Skip confirmation for running sessions]' \ 63 + '1:session:_pty_sessions' 64 + ;; 65 + events) 66 + _arguments \ 67 + '--all[Follow events from all sessions]' \ 68 + '--recent[Show recent events and exit]' \ 69 + '--json[Output raw JSONL]' \ 62 70 '1:session:_pty_sessions' 63 71 ;; 64 72 list|ls)
+1 -1
flake.nix
··· 29 29 30 30 # Generated from package-lock.json. 31 31 # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json 32 - npmDepsHash = "sha256-jMQIzAnSvoUfZ6nUOt0a821HVuGApE/1vsGlOBzFZOc="; 32 + npmDepsHash = "sha256-RpVlZ+xn3KY2xEgyDNqEe9/B6IOV3wX+MgGjXaNttDA="; 33 33 34 34 # node-pty has native code that needs these at build time 35 35 nativeBuildInputs = with pkgs; [ python3 pkg-config ];
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.2.3", 3 + "version": "0.3.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "@myobie/pty", 9 - "version": "0.2.3", 9 + "version": "0.3.0", 10 10 "hasInstallScript": true, 11 11 "license": "MIT", 12 12 "dependencies": {
+83
src/cli.ts
··· 17 17 } from "./sessions.ts"; 18 18 import { spawnDaemon, resolveCommand } from "./spawn.ts"; 19 19 import { runInteractive } from "./tui/interactive.ts"; 20 + import { EventFollower, readRecentEvents, formatEvent } from "./events.ts"; 20 21 21 22 function usage(): void { 22 23 console.log(`Usage: ··· 35 36 pty send <name> --with-delay 0.5 --seq ... Delay between each --seq item 36 37 pty restart <name> Restart a session (prompts if running) 37 38 pty restart -y <name> Restart without confirmation 39 + pty events <name> Follow events from a session 40 + pty events --all Follow events from all sessions 41 + pty events --recent <name> Show recent events and exit 42 + pty events --json <name> Output raw JSONL 38 43 pty list List active sessions 39 44 pty list --json List sessions as JSON 40 45 pty kill <name> Kill or remove a session ··· 274 279 } 275 280 276 281 send({ name: sendName, data, delayMs: delaySecs != null ? delaySecs * 1000 : undefined }); 282 + break; 283 + } 284 + 285 + case "events": { 286 + let all = false; 287 + let recent = false; 288 + let json = false; 289 + let ei = 1; 290 + while (ei < args.length && args[ei].startsWith("-")) { 291 + if (args[ei] === "--all") { all = true; ei++; } 292 + else if (args[ei] === "--recent") { recent = true; ei++; } 293 + else if (args[ei] === "--json") { json = true; ei++; } 294 + else break; 295 + } 296 + const eventsName = args[ei]; 297 + 298 + if (!all && !eventsName) { 299 + console.error("Usage: pty events [--all] [--recent] [--json] [<name>]"); 300 + process.exit(1); 301 + } 302 + 303 + if (eventsName) { 304 + try { 305 + validateName(eventsName); 306 + } catch (e: any) { 307 + console.error(e.message); 308 + process.exit(1); 309 + } 310 + } 311 + 312 + await cmdEvents(eventsName ?? null, { all, recent, json }); 277 313 break; 278 314 } 279 315 ··· 799 835 await spawnDaemon(name, meta.command, meta.args, meta.displayCommand, meta.cwd); 800 836 console.log(`Session "${name}" restarted.`); 801 837 doAttach(name); 838 + } 839 + 840 + async function cmdEvents( 841 + name: string | null, 842 + opts: { all: boolean; recent: boolean; json: boolean } 843 + ): Promise<void> { 844 + if (opts.recent) { 845 + if (!name) { 846 + console.error("--recent requires a session name."); 847 + process.exit(1); 848 + } 849 + const events = readRecentEvents(name); 850 + if (events.length === 0) { 851 + console.log(`No recent events for "${name}".`); 852 + return; 853 + } 854 + for (const event of events) { 855 + console.log(opts.json ? JSON.stringify(event) : formatEvent(event)); 856 + } 857 + return; 858 + } 859 + 860 + // Follow mode: verify session exists if a specific name was given 861 + if (name) { 862 + const session = await getSession(name); 863 + if (!session) { 864 + console.error(`Session "${name}" not found.`); 865 + process.exit(1); 866 + } 867 + } 868 + 869 + const follower = new EventFollower({ 870 + names: opts.all ? undefined : name ? [name] : undefined, 871 + onEvent: (event) => { 872 + console.log(opts.json ? JSON.stringify(event) : formatEvent(event)); 873 + }, 874 + }); 875 + 876 + follower.start(); 877 + 878 + process.on("SIGINT", () => { 879 + follower.stop(); 880 + process.exit(0); 881 + }); 882 + 883 + // Keep the process alive while watchers are active 884 + setInterval(() => {}, 60_000); 802 885 } 803 886 804 887 async function cmdTest(args: string[]): Promise<void> {
+253
src/events.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as fsp from "node:fs/promises"; 3 + import { getEventsPath, getSessionDir, ensureSessionDir } from "./sessions.ts"; 4 + 5 + export const EventType = { 6 + BELL: "bell", 7 + TITLE_CHANGE: "title_change", 8 + NOTIFICATION: "notification", 9 + FOCUS_REQUEST: "focus_request", 10 + CURSOR_VISIBLE: "cursor_visible", 11 + } as const; 12 + 13 + export type EventType = (typeof EventType)[keyof typeof EventType]; 14 + 15 + interface EventBase { 16 + session: string; 17 + type: EventType; 18 + ts: string; 19 + } 20 + 21 + interface BellEvent extends EventBase { 22 + type: "bell"; 23 + } 24 + 25 + interface TitleChangeEvent extends EventBase { 26 + type: "title_change"; 27 + value: string; 28 + } 29 + 30 + interface NotificationEvent extends EventBase { 31 + type: "notification"; 32 + title?: string; 33 + body?: string; 34 + source?: "osc9" | "osc99" | "osc777"; 35 + } 36 + 37 + interface FocusRequestEvent extends EventBase { 38 + type: "focus_request"; 39 + } 40 + 41 + interface CursorVisibleEvent extends EventBase { 42 + type: "cursor_visible"; 43 + } 44 + 45 + export type EventRecord = 46 + | BellEvent 47 + | TitleChangeEvent 48 + | NotificationEvent 49 + | FocusRequestEvent 50 + | CursorVisibleEvent; 51 + 52 + const MAX_LINES = 1000; 53 + const KEEP_LINES = 500; 54 + const TRUNCATE_CHECK_INTERVAL = 100; 55 + 56 + /** Manages async, serialized writes to a session's events JSONL file. */ 57 + export class EventWriter { 58 + private chain: Promise<void> = Promise.resolve(); 59 + private appendCount = 0; 60 + private name: string; 61 + 62 + constructor(name: string) { 63 + this.name = name; 64 + } 65 + 66 + /** Queue an event for writing. Returns immediately; I/O happens async. */ 67 + append(event: EventRecord): void { 68 + this.chain = this.chain 69 + .then(() => { 70 + const line = JSON.stringify(event) + "\n"; 71 + return fsp.appendFile(getEventsPath(this.name), line); 72 + }) 73 + .then(() => { 74 + this.appendCount++; 75 + if (this.appendCount >= TRUNCATE_CHECK_INTERVAL) { 76 + this.appendCount = 0; 77 + return truncate(getEventsPath(this.name)); 78 + } 79 + }) 80 + .catch(() => {}); 81 + } 82 + 83 + /** Wait for all pending writes to complete. */ 84 + flush(): Promise<void> { 85 + return this.chain; 86 + } 87 + } 88 + 89 + async function truncate(filePath: string): Promise<void> { 90 + const content = await fsp.readFile(filePath, "utf-8"); 91 + const lines = content.trimEnd().split("\n"); 92 + if (lines.length >= MAX_LINES) { 93 + await fsp.writeFile(filePath, lines.slice(-KEEP_LINES).join("\n") + "\n"); 94 + } 95 + } 96 + 97 + export function clearEvents(name: string): void { 98 + ensureSessionDir(); 99 + try { 100 + fs.writeFileSync(getEventsPath(name), ""); 101 + } catch {} 102 + } 103 + 104 + export function removeEvents(name: string): void { 105 + try { 106 + fs.unlinkSync(getEventsPath(name)); 107 + } catch {} 108 + } 109 + 110 + export function readRecentEvents(name: string, count = 50): EventRecord[] { 111 + try { 112 + const content = fs.readFileSync(getEventsPath(name), "utf-8"); 113 + const lines = content.trimEnd().split("\n").filter((l) => l.length > 0); 114 + return lines.slice(-count).map((l) => JSON.parse(l)); 115 + } catch { 116 + return []; 117 + } 118 + } 119 + 120 + export interface FollowerOptions { 121 + names?: string[]; 122 + onEvent: (event: EventRecord) => void; 123 + } 124 + 125 + export class EventFollower { 126 + private watchers = new Map< 127 + string, 128 + { watcher: fs.FSWatcher; offset: number } 129 + >(); 130 + private dirWatcher: fs.FSWatcher | null = null; 131 + private options: FollowerOptions; 132 + 133 + constructor(options: FollowerOptions) { 134 + this.options = options; 135 + } 136 + 137 + start(): void { 138 + if (this.options.names) { 139 + for (const name of this.options.names) { 140 + this.watchFile(name); 141 + } 142 + } else { 143 + this.scanAndWatchAll(); 144 + } 145 + } 146 + 147 + stop(): void { 148 + for (const { watcher } of this.watchers.values()) { 149 + watcher.close(); 150 + } 151 + this.watchers.clear(); 152 + this.dirWatcher?.close(); 153 + this.dirWatcher = null; 154 + } 155 + 156 + private watchFile(name: string): void { 157 + const filePath = getEventsPath(name); 158 + 159 + // Start at the end of the current file 160 + let offset = 0; 161 + try { 162 + offset = fs.statSync(filePath).size; 163 + } catch {} 164 + 165 + try { 166 + const watcher = fs.watch(filePath, () => { 167 + this.readNewLines(name, filePath); 168 + }); 169 + this.watchers.set(name, { watcher, offset }); 170 + } catch {} 171 + } 172 + 173 + private readNewLines(name: string, filePath: string): void { 174 + const entry = this.watchers.get(name); 175 + if (!entry) return; 176 + 177 + try { 178 + const stat = fs.statSync(filePath); 179 + if (stat.size < entry.offset) { 180 + // File was truncated — reset to beginning 181 + entry.offset = 0; 182 + } 183 + if (stat.size === entry.offset) return; 184 + 185 + const fd = fs.openSync(filePath, "r"); 186 + const buf = Buffer.alloc(stat.size - entry.offset); 187 + fs.readSync(fd, buf, 0, buf.length, entry.offset); 188 + fs.closeSync(fd); 189 + entry.offset = stat.size; 190 + 191 + const chunk = buf.toString("utf-8"); 192 + const lines = chunk.split("\n").filter((l) => l.length > 0); 193 + for (const line of lines) { 194 + try { 195 + const event = JSON.parse(line) as EventRecord; 196 + this.options.onEvent(event); 197 + } catch {} 198 + } 199 + } catch {} 200 + } 201 + 202 + private scanAndWatchAll(): void { 203 + const dir = getSessionDir(); 204 + 205 + // Watch existing .events.jsonl files 206 + try { 207 + for (const entry of fs.readdirSync(dir)) { 208 + if (entry.endsWith(".events.jsonl")) { 209 + const name = entry.replace(/\.events\.jsonl$/, ""); 210 + this.watchFile(name); 211 + } 212 + } 213 + } catch {} 214 + 215 + // Watch directory for new .events.jsonl files 216 + try { 217 + this.dirWatcher = fs.watch(dir, (_eventType, filename) => { 218 + if ( 219 + filename && 220 + filename.endsWith(".events.jsonl") && 221 + !this.watchers.has(filename.replace(/\.events\.jsonl$/, "")) 222 + ) { 223 + const name = filename.replace(/\.events\.jsonl$/, ""); 224 + this.watchFile(name); 225 + } 226 + }); 227 + } catch {} 228 + } 229 + } 230 + 231 + export function formatEvent(event: EventRecord): string { 232 + const time = new Date(event.ts).toLocaleTimeString("en-US", { 233 + hour12: false, 234 + }); 235 + const prefix = `[${time}] ${event.session}:`; 236 + 237 + switch (event.type) { 238 + case "bell": 239 + return `${prefix} bell`; 240 + case "title_change": 241 + return `${prefix} title -> "${event.value}"`; 242 + case "notification": { 243 + const parts = [prefix, "notification"]; 244 + if (event.title) parts.push(`-- "${event.title}"`); 245 + if (event.body) parts.push(event.body); 246 + return parts.join(" "); 247 + } 248 + case "focus_request": 249 + return `${prefix} focus requested`; 250 + case "cursor_visible": 251 + return `${prefix} cursor restored`; 252 + } 253 + }
+68 -1
src/server.ts
··· 26 26 readMetadata, 27 27 type SessionMetadata, 28 28 } from "./sessions.ts"; 29 + import { EventWriter, clearEvents, EventType, type EventRecord } from "./events.ts"; 29 30 30 31 interface Client { 31 32 socket: net.Socket; ··· 64 65 private cursorHidden = false; 65 66 private kittyKeyboardStack: number[] = []; 66 67 private lastResizeTime = 0; 68 + private eventWriter: EventWriter; 69 + private lastTitle = ""; 67 70 readonly ready: Promise<void>; 68 71 69 72 constructor(options: ServerOptions) { 70 73 this.name = options.name; 71 74 this.options = options; 75 + this.eventWriter = new EventWriter(options.name); 72 76 73 77 // Set up xterm-headless for screen buffer tracking 74 78 this.terminal = new xterm.Terminal({ ··· 87 91 for (const p of params) { 88 92 const v = typeof p === "number" ? p : p[0]; 89 93 if (v === 1006) this.sgrMouseMode = true; 90 - if (v === 25) this.cursorHidden = false; 94 + if (v === 25) { 95 + if (this.cursorHidden) this.emitEvent(EventType.CURSOR_VISIBLE); 96 + this.cursorHidden = false; 97 + } 98 + if (v === 1004) this.emitEvent(EventType.FOCUS_REQUEST); 91 99 } 92 100 return false; 93 101 } ··· 119 127 } 120 128 ); 121 129 130 + // ── Event detection ── 131 + 132 + this.terminal.onBell(() => { 133 + this.emitEvent(EventType.BELL); 134 + }); 135 + 136 + this.terminal.onTitleChange((title: string) => { 137 + if (title !== this.lastTitle) { 138 + this.lastTitle = title; 139 + this.emitEvent(EventType.TITLE_CHANGE, { value: title }); 140 + } 141 + }); 142 + 143 + // iTerm2 desktop notification (OSC 9) 144 + this.terminal.parser.registerOscHandler(9, (data: string) => { 145 + this.emitEvent(EventType.NOTIFICATION, { body: data, source: "osc9" }); 146 + return false; 147 + }); 148 + 149 + // Kitty notification (OSC 99) — key=value;key=value payload 150 + this.terminal.parser.registerOscHandler(99, (data: string) => { 151 + const fields: Record<string, string> = {}; 152 + for (const part of data.split(";")) { 153 + const eq = part.indexOf("="); 154 + if (eq !== -1) { 155 + fields[part.slice(0, eq)] = part.slice(eq + 1); 156 + } 157 + } 158 + this.emitEvent(EventType.NOTIFICATION, { 159 + title: fields["title"] ?? fields["t"], 160 + body: fields["body"] ?? fields["b"], 161 + source: "osc99", 162 + }); 163 + return false; 164 + }); 165 + 166 + // rxvt notification (OSC 777) — notify;title;body 167 + this.terminal.parser.registerOscHandler(777, (data: string) => { 168 + const parts = data.split(";"); 169 + if (parts[0] === "notify" && parts.length >= 2) { 170 + this.emitEvent(EventType.NOTIFICATION, { 171 + title: parts[1], 172 + body: parts.slice(2).join(";"), 173 + source: "osc777", 174 + }); 175 + } 176 + return false; 177 + }); 178 + 122 179 // Spawn the child process in a PTY via a shell, so that shell scripts, 123 180 // symlinks, and shebangs all work reliably (like tmux/screen do). 124 181 // `exec "$@"` replaces the shell with the actual process. ··· 162 219 163 220 // Create Unix socket server 164 221 ensureSessionDir(); 222 + clearEvents(this.name); 165 223 const socketPath = getSocketPath(this.name); 166 224 167 225 // Remove stale socket if it exists ··· 401 459 this.terminal.resize(cols - 1, rows); 402 460 this.ptyProcess.resize(cols, rows); 403 461 this.terminal.resize(cols, rows); 462 + } 463 + 464 + private emitEvent(type: EventType, fields?: Record<string, unknown>): void { 465 + this.eventWriter.append({ 466 + session: this.name, 467 + type, 468 + ts: new Date().toISOString(), 469 + ...fields, 470 + } as EventRecord); 404 471 } 405 472 406 473 private broadcast(data: Buffer): void {
+7
src/sessions.ts
··· 43 43 return path.join(getSessionDir(), `${name}.json`); 44 44 } 45 45 46 + export function getEventsPath(name: string): string { 47 + return path.join(getSessionDir(), `${name}.events.jsonl`); 48 + } 49 + 46 50 export interface SessionMetadata { 47 51 command: string; 48 52 args: string[]; ··· 204 208 cleanupSocket(name); 205 209 try { 206 210 fs.unlinkSync(getMetadataPath(name)); 211 + } catch {} 212 + try { 213 + fs.unlinkSync(getEventsPath(name)); 207 214 } catch {} 208 215 releaseLock(name); 209 216 }
+481
tests/events.test.ts
··· 1 + import { describe, it, expect, afterAll, beforeEach } from "vitest"; 2 + import * as net from "node:net"; 3 + import * as fs from "node:fs"; 4 + import * as os from "node:os"; 5 + import * as path from "node:path"; 6 + import { PtyServer, type ServerOptions } from "../src/server.ts"; 7 + import { 8 + MessageType, 9 + PacketReader, 10 + encodeAttach, 11 + encodeData, 12 + } from "../src/protocol.ts"; 13 + import { getSocketPath, getEventsPath, cleanupAll } from "../src/sessions.ts"; 14 + import { 15 + EventWriter, 16 + clearEvents, 17 + readRecentEvents, 18 + EventFollower, 19 + formatEvent, 20 + type EventRecord, 21 + } from "../src/events.ts"; 22 + 23 + const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ev-")); 24 + const testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ev-sd-")); 25 + process.env.PTY_SESSION_DIR = testSessionDir; 26 + 27 + afterAll(() => { 28 + fs.rmSync(testCwd, { recursive: true, force: true }); 29 + fs.rmSync(testSessionDir, { 30 + recursive: true, 31 + force: true, 32 + maxRetries: 3, 33 + retryDelay: 100, 34 + }); 35 + }); 36 + 37 + let servers: PtyServer[] = []; 38 + let sessionNames: string[] = []; 39 + 40 + function uniqueName(): string { 41 + const name = `ev-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 42 + sessionNames.push(name); 43 + return name; 44 + } 45 + 46 + async function startServer( 47 + name: string, 48 + command: string, 49 + args: string[] = [], 50 + opts: Partial<ServerOptions> = {} 51 + ): Promise<PtyServer> { 52 + const server = new PtyServer({ 53 + name, 54 + command, 55 + args, 56 + displayCommand: command, 57 + cwd: testCwd, 58 + rows: 24, 59 + cols: 80, 60 + ...opts, 61 + }); 62 + servers.push(server); 63 + await server.ready; 64 + return server; 65 + } 66 + 67 + function connect(name: string): Promise<net.Socket> { 68 + return new Promise((resolve, reject) => { 69 + const socket = net.createConnection(getSocketPath(name)); 70 + socket.on("connect", () => resolve(socket)); 71 + socket.on("error", reject); 72 + }); 73 + } 74 + 75 + function sleep(ms: number): Promise<void> { 76 + return new Promise((resolve) => setTimeout(resolve, ms)); 77 + } 78 + 79 + /** Wait for the events file to contain at least `count` events. */ 80 + async function waitForEvents( 81 + name: string, 82 + count: number, 83 + timeoutMs = 5000 84 + ): Promise<EventRecord[]> { 85 + const deadline = Date.now() + timeoutMs; 86 + while (Date.now() < deadline) { 87 + const events = readRecentEvents(name, 1000); 88 + if (events.length >= count) return events; 89 + await sleep(50); 90 + } 91 + return readRecentEvents(name, 1000); 92 + } 93 + 94 + afterAll(async () => { 95 + for (const server of servers) { 96 + try { 97 + await server.close(); 98 + } catch {} 99 + } 100 + for (const name of sessionNames) { 101 + try { 102 + cleanupAll(name); 103 + } catch {} 104 + } 105 + }); 106 + 107 + describe("EventWriter", () => { 108 + it("appends events as JSONL lines", async () => { 109 + const name = uniqueName(); 110 + clearEvents(name); 111 + const writer = new EventWriter(name); 112 + 113 + writer.append({ 114 + session: name, 115 + type: "bell", 116 + ts: "2026-04-05T00:00:00.000Z", 117 + }); 118 + writer.append({ 119 + session: name, 120 + type: "bell", 121 + ts: "2026-04-05T00:00:01.000Z", 122 + }); 123 + await writer.flush(); 124 + 125 + const events = readRecentEvents(name); 126 + expect(events).toHaveLength(2); 127 + expect(events[0].type).toBe("bell"); 128 + expect(events[1].ts).toBe("2026-04-05T00:00:01.000Z"); 129 + }); 130 + 131 + it("truncates when exceeding MAX_LINES", async () => { 132 + const name = uniqueName(); 133 + clearEvents(name); 134 + const writer = new EventWriter(name); 135 + 136 + // Write 1050 events — should trigger truncation at the 100th append 137 + // (TRUNCATE_CHECK_INTERVAL), keeping 500 lines 138 + for (let i = 0; i < 1050; i++) { 139 + writer.append({ 140 + session: name, 141 + type: "bell", 142 + ts: `2026-04-05T00:00:${String(i).padStart(4, "0")}Z`, 143 + }); 144 + } 145 + await writer.flush(); 146 + 147 + const content = fs 148 + .readFileSync(getEventsPath(name), "utf-8") 149 + .trimEnd() 150 + .split("\n") 151 + .filter((l) => l.length > 0); 152 + // After truncation at 1000+ lines, should keep 500, 153 + // then the remaining appends add more, so total should be <= ~600 154 + expect(content.length).toBeLessThanOrEqual(650); 155 + expect(content.length).toBeGreaterThan(0); 156 + 157 + // The last event should be preserved 158 + const last = JSON.parse(content[content.length - 1]); 159 + expect(last.ts).toBe("2026-04-05T00:00:1049Z"); 160 + }); 161 + }); 162 + 163 + describe("readRecentEvents", () => { 164 + it("returns last N events", () => { 165 + const name = uniqueName(); 166 + clearEvents(name); 167 + const lines: string[] = []; 168 + for (let i = 0; i < 10; i++) { 169 + lines.push( 170 + JSON.stringify({ 171 + session: name, 172 + type: "bell", 173 + ts: `2026-04-05T00:00:0${i}Z`, 174 + }) 175 + ); 176 + } 177 + fs.writeFileSync(getEventsPath(name), lines.join("\n") + "\n"); 178 + 179 + const events = readRecentEvents(name, 3); 180 + expect(events).toHaveLength(3); 181 + expect(events[0].ts).toBe("2026-04-05T00:00:07Z"); 182 + expect(events[2].ts).toBe("2026-04-05T00:00:09Z"); 183 + }); 184 + 185 + it("returns empty array for missing file", () => { 186 + const events = readRecentEvents("nonexistent-session-xyz"); 187 + expect(events).toEqual([]); 188 + }); 189 + }); 190 + 191 + describe("clearEvents", () => { 192 + it("creates an empty file", () => { 193 + const name = uniqueName(); 194 + clearEvents(name); 195 + const content = fs.readFileSync(getEventsPath(name), "utf-8"); 196 + expect(content).toBe(""); 197 + }); 198 + }); 199 + 200 + describe("cleanupAll removes events file", () => { 201 + it("removes .events.jsonl", () => { 202 + const name = uniqueName(); 203 + clearEvents(name); 204 + fs.writeFileSync( 205 + getEventsPath(name), 206 + JSON.stringify({ session: name, type: "bell", ts: "t" }) + "\n" 207 + ); 208 + expect(fs.existsSync(getEventsPath(name))).toBe(true); 209 + 210 + cleanupAll(name); 211 + expect(fs.existsSync(getEventsPath(name))).toBe(false); 212 + }); 213 + }); 214 + 215 + describe("formatEvent", () => { 216 + it("formats bell", () => { 217 + const result = formatEvent({ 218 + session: "test", 219 + type: "bell", 220 + ts: "2026-04-05T10:15:03.000Z", 221 + }); 222 + expect(result).toContain("test:"); 223 + expect(result).toContain("bell"); 224 + }); 225 + 226 + it("formats title change", () => { 227 + const result = formatEvent({ 228 + session: "test", 229 + type: "title_change", 230 + ts: "2026-04-05T10:15:03.000Z", 231 + value: "Building...", 232 + }); 233 + expect(result).toContain('title -> "Building..."'); 234 + }); 235 + 236 + it("formats notification with title and body", () => { 237 + const result = formatEvent({ 238 + session: "test", 239 + type: "notification", 240 + ts: "2026-04-05T10:15:03.000Z", 241 + title: "Done", 242 + body: "Build succeeded", 243 + source: "osc9", 244 + }); 245 + expect(result).toContain('-- "Done"'); 246 + expect(result).toContain("Build succeeded"); 247 + }); 248 + 249 + it("formats focus request", () => { 250 + const result = formatEvent({ 251 + session: "test", 252 + type: "focus_request", 253 + ts: "2026-04-05T10:15:03.000Z", 254 + }); 255 + expect(result).toContain("focus requested"); 256 + }); 257 + 258 + it("formats cursor visible", () => { 259 + const result = formatEvent({ 260 + session: "test", 261 + type: "cursor_visible", 262 + ts: "2026-04-05T10:15:03.000Z", 263 + }); 264 + expect(result).toContain("cursor restored"); 265 + }); 266 + }); 267 + 268 + describe("EventFollower", () => { 269 + it("follows new events appended to a file", async () => { 270 + const name = uniqueName(); 271 + clearEvents(name); 272 + 273 + const received: EventRecord[] = []; 274 + const follower = new EventFollower({ 275 + names: [name], 276 + onEvent: (event) => received.push(event), 277 + }); 278 + follower.start(); 279 + 280 + // Give fs.watch a moment to initialize 281 + await sleep(100); 282 + 283 + // Append events 284 + const writer = new EventWriter(name); 285 + writer.append({ 286 + session: name, 287 + type: "bell", 288 + ts: "2026-04-05T00:00:00Z", 289 + }); 290 + writer.append({ 291 + session: name, 292 + type: "bell", 293 + ts: "2026-04-05T00:00:01Z", 294 + }); 295 + await writer.flush(); 296 + 297 + // Wait for fs.watch to pick up the changes 298 + await sleep(300); 299 + 300 + follower.stop(); 301 + 302 + expect(received.length).toBeGreaterThanOrEqual(2); 303 + expect(received[0].type).toBe("bell"); 304 + }); 305 + 306 + it("handles file truncation gracefully", async () => { 307 + const name = uniqueName(); 308 + clearEvents(name); 309 + 310 + // Pre-fill the file so the follower starts at a non-zero offset 311 + const line = 312 + JSON.stringify({ 313 + session: name, 314 + type: "bell", 315 + ts: "2026-04-05T00:00:00Z", 316 + }) + "\n"; 317 + fs.writeFileSync(getEventsPath(name), line.repeat(5)); 318 + 319 + const received: EventRecord[] = []; 320 + const follower = new EventFollower({ 321 + names: [name], 322 + onEvent: (event) => received.push(event), 323 + }); 324 + follower.start(); 325 + await sleep(100); 326 + 327 + // Truncate the file (simulating the writer's truncation) 328 + fs.writeFileSync(getEventsPath(name), ""); 329 + await sleep(100); 330 + 331 + // Write new events after truncation 332 + const writer = new EventWriter(name); 333 + writer.append({ 334 + session: name, 335 + type: "notification", 336 + ts: "2026-04-05T00:01:00Z", 337 + title: "After truncation", 338 + source: "osc9", 339 + }); 340 + await writer.flush(); 341 + await sleep(300); 342 + 343 + follower.stop(); 344 + 345 + const notifications = received.filter((e) => e.type === "notification"); 346 + expect(notifications.length).toBeGreaterThanOrEqual(1); 347 + }); 348 + }); 349 + 350 + describe("PtyServer event detection", () => { 351 + it("logs bell events", async () => { 352 + const name = uniqueName(); 353 + await startServer(name, "sh", []); 354 + 355 + const socket = await connect(name); 356 + const reader = new PacketReader(); 357 + 358 + // Attach 359 + socket.write(encodeAttach(24, 80)); 360 + await sleep(200); 361 + 362 + // Send a bell character 363 + socket.write(encodeData("printf '\\a'\n")); 364 + const events = await waitForEvents(name, 1); 365 + socket.destroy(); 366 + 367 + const bells = events.filter((e) => e.type === "bell"); 368 + expect(bells.length).toBeGreaterThanOrEqual(1); 369 + expect(bells[0].session).toBe(name); 370 + }); 371 + 372 + it("logs title change events", async () => { 373 + const name = uniqueName(); 374 + await startServer(name, "sh", []); 375 + 376 + const socket = await connect(name); 377 + socket.write(encodeAttach(24, 80)); 378 + await sleep(200); 379 + 380 + // Set the terminal title via OSC 0 381 + socket.write(encodeData("printf '\\033]0;My Custom Title\\a'\n")); 382 + const events = await waitForEvents(name, 1); 383 + socket.destroy(); 384 + 385 + const titles = events.filter((e) => e.type === "title_change"); 386 + expect(titles.length).toBeGreaterThanOrEqual(1); 387 + expect((titles[0] as any).value).toBe("My Custom Title"); 388 + }); 389 + 390 + it("deduplicates identical title changes", async () => { 391 + const name = uniqueName(); 392 + await startServer(name, "sh", []); 393 + 394 + const socket = await connect(name); 395 + socket.write(encodeAttach(24, 80)); 396 + await sleep(200); 397 + 398 + // Set the same title twice 399 + socket.write(encodeData("printf '\\033]0;Same Title\\a'\n")); 400 + await sleep(200); 401 + socket.write(encodeData("printf '\\033]0;Same Title\\a'\n")); 402 + await sleep(200); 403 + 404 + // Set a different title 405 + socket.write(encodeData("printf '\\033]0;Different Title\\a'\n")); 406 + await sleep(300); 407 + 408 + const events = readRecentEvents(name, 1000); 409 + socket.destroy(); 410 + 411 + const titles = events.filter((e) => e.type === "title_change"); 412 + const titleValues = titles.map((e) => (e as any).value); 413 + 414 + // "Same Title" should appear only once, "Different Title" should also appear 415 + expect( 416 + titleValues.filter((v: string) => v === "Same Title").length 417 + ).toBe(1); 418 + expect(titleValues).toContain("Different Title"); 419 + }); 420 + 421 + it("logs OSC 9 notification events", async () => { 422 + const name = uniqueName(); 423 + await startServer(name, "sh", []); 424 + 425 + const socket = await connect(name); 426 + socket.write(encodeAttach(24, 80)); 427 + await sleep(200); 428 + 429 + // Send an iTerm2-style notification 430 + socket.write(encodeData("printf '\\033]9;Build complete\\a'\n")); 431 + const events = await waitForEvents(name, 1); 432 + socket.destroy(); 433 + 434 + const notifs = events.filter((e) => e.type === "notification"); 435 + expect(notifs.length).toBeGreaterThanOrEqual(1); 436 + expect((notifs[0] as any).body).toBe("Build complete"); 437 + expect((notifs[0] as any).source).toBe("osc9"); 438 + }); 439 + 440 + it("logs OSC 777 notification events", async () => { 441 + const name = uniqueName(); 442 + await startServer(name, "sh", []); 443 + 444 + const socket = await connect(name); 445 + socket.write(encodeAttach(24, 80)); 446 + await sleep(200); 447 + 448 + // Send an rxvt-style notification 449 + socket.write( 450 + encodeData("printf '\\033]777;notify;Build;All tests passed\\a'\n") 451 + ); 452 + const events = await waitForEvents(name, 1); 453 + socket.destroy(); 454 + 455 + const notifs = events.filter((e) => e.type === "notification"); 456 + expect(notifs.length).toBeGreaterThanOrEqual(1); 457 + expect((notifs[0] as any).title).toBe("Build"); 458 + expect((notifs[0] as any).body).toBe("All tests passed"); 459 + expect((notifs[0] as any).source).toBe("osc777"); 460 + }); 461 + 462 + it("clears events file on session start", async () => { 463 + const name = uniqueName(); 464 + 465 + // Write some stale events 466 + clearEvents(name); 467 + fs.appendFileSync( 468 + getEventsPath(name), 469 + JSON.stringify({ session: name, type: "bell", ts: "old" }) + "\n" 470 + ); 471 + expect(readRecentEvents(name)).toHaveLength(1); 472 + 473 + // Starting a new server should clear the events 474 + await startServer(name, "sh", []); 475 + await sleep(100); 476 + const events = readRecentEvents(name); 477 + // Should have no stale events (may have new ones from shell startup) 478 + const stale = events.filter((e) => e.ts === "old"); 479 + expect(stale).toHaveLength(0); 480 + }); 481 + });