This repository has no description
0

Configure Feed

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

Fix two EventFollower/event-writer flakes

- EventFollower.watchFile now starts at offset 0 when the directory
watcher detects a brand-new events.jsonl. session_start is almost
always already in the file by the time the dir event fires, so
starting at EOF was skipping it. Pre-existing files on startup still
start at EOF (no history replay).

- Server.close() now awaits ptyProcess.onExit (bounded at 2s) before
flushing the EventWriter. When the daemon was killed via SIGTERM,
session_exit was queued on the async write chain but the daemon
exited before the append landed. pty kill no longer loses exit
events.

Nathan Herald (Apr 16, 2026, 11:04 AM +0200) ba8e6dca 89651723

+210 -11
+2
CHANGELOG.md
··· 11 11 12 12 ### Fixes 13 13 - Fix mouse tracking modes (1000/1002/1003) not being replayed when a client reattaches to a session with mouse tracking already enabled — the server previously only replayed the SGR encoding (1006), cursor visibility, and kitty keyboard flags. Clients (e.g., pty-layout) checking tracking mode to decide whether to forward mouse events will now see the correct state. 14 + - Fix `EventFollower` starting at EOF when its directory watcher detected a brand-new `.events.jsonl` — `session_start` was already on disk by the time the dir event fired, so followers were skipping it. New-file detections now start at offset 0 while existing-file watches still start at EOF. 15 + - Fix `session_exit` sometimes missing from the events log when the daemon was killed via SIGTERM (`pty kill` and similar). The event was queued on the `EventWriter` chain but the daemon exited before the append flushed. `close()` now waits for the child process's `onExit` (bounded at 2s) and then drains the writer before resolving. 14 16 15 17 ### Interactive TUI 16 18 - Add `--preselect-new` flag: `pty --preselect-new` opens the interactive TUI with "Create new session..." pre-selected (useful for pty-layout panes that should land on the create prompt)
+22 -10
src/events.ts
··· 187 187 start(): void { 188 188 if (this.options.names) { 189 189 for (const name of this.options.names) { 190 - this.watchFile(name); 190 + this.watchFile(name, { fromStart: false }); 191 191 } 192 192 } else { 193 193 this.scanAndWatchAll(); ··· 203 203 this.dirWatcher = null; 204 204 } 205 205 206 - private watchFile(name: string): void { 206 + private watchFile(name: string, opts: { fromStart: boolean }): void { 207 207 const filePath = getEventsPath(name); 208 208 209 - // Start at the end of the current file 209 + // Pre-existing files: start at current EOF (don't replay history). 210 + // Freshly-created files detected by the dirWatcher: start at offset 0 so 211 + // the session_start line, which is almost always already in the file by 212 + // the time the directory event fires, isn't skipped. 210 213 let offset = 0; 211 - try { 212 - offset = fs.statSync(filePath).size; 213 - } catch {} 214 + if (!opts.fromStart) { 215 + try { 216 + offset = fs.statSync(filePath).size; 217 + } catch {} 218 + } 214 219 215 220 try { 216 221 const watcher = fs.watch(filePath, () => { 217 222 this.readNewLines(name, filePath); 218 223 }); 219 224 this.watchers.set(name, { watcher, offset }); 225 + // Seed: if the file already has content we want to replay, do it now. 226 + if (opts.fromStart) { 227 + this.readNewLines(name, filePath); 228 + } 220 229 } catch {} 221 230 } 222 231 ··· 252 261 private scanAndWatchAll(): void { 253 262 const dir = getSessionDir(); 254 263 255 - // Watch existing .events.jsonl files 264 + // Watch existing .events.jsonl files — they've been running, so start at 265 + // EOF rather than replaying their history. 256 266 try { 257 267 for (const entry of fs.readdirSync(dir)) { 258 268 if (entry.endsWith(".events.jsonl")) { 259 269 const name = entry.replace(/\.events\.jsonl$/, ""); 260 - this.watchFile(name); 270 + this.watchFile(name, { fromStart: false }); 261 271 } 262 272 } 263 273 } catch {} 264 274 265 - // Watch directory for new .events.jsonl files 275 + // Watch directory for new .events.jsonl files. For a brand-new file the 276 + // session_start line is almost always already present by the time the 277 + // directory-change event fires, so start at offset 0 to include it. 266 278 try { 267 279 this.dirWatcher = fs.watch(dir, (_eventType, filename) => { 268 280 if ( ··· 271 283 !this.watchers.has(filename.replace(/\.events\.jsonl$/, "")) 272 284 ) { 273 285 const name = filename.replace(/\.events\.jsonl$/, ""); 274 - this.watchFile(name); 286 + this.watchFile(name, { fromStart: true }); 275 287 } 276 288 }); 277 289 } catch {}
+20 -1
src/server.ts
··· 186 186 private eventWriter: EventWriter; 187 187 private lastTitle = ""; 188 188 readonly ready: Promise<void>; 189 + // Resolves when the child process's onExit has fired — used by close() to 190 + // make sure session_exit has been queued to the event chain before we 191 + // flush and exit the daemon. See flake #2. 192 + private childExited: Promise<void>; 193 + private resolveChildExited!: () => void; 189 194 190 195 constructor(options: ServerOptions) { 191 196 this.name = options.name; 192 197 this.options = options; 193 198 this.eventWriter = new EventWriter(options.name); 199 + this.childExited = new Promise<void>((resolve) => { 200 + this.resolveChildExited = resolve; 201 + }); 194 202 195 203 // Set up xterm-headless for screen buffer tracking 196 204 this.terminal = new xterm.Terminal({ ··· 433 441 // here since PTY data could still be in-flight — close() will 434 442 // update with the final output. 435 443 this.saveExitMetadata(exitCode); 444 + this.resolveChildExited(); 436 445 options.onExit?.(exitCode); 437 446 }); 438 447 ··· 809 818 for (const client of this.clients.values()) { 810 819 client.socket.destroy(); 811 820 } 812 - this.socketServer.close(() => { 821 + this.socketServer.close(async () => { 813 822 cleanup(this.name); 814 823 try { 815 824 this.ptyProcess.kill(); 816 825 } catch {} 826 + // Wait for the child's onExit to fire (which enqueues session_exit) 827 + // before draining the writer. Without this, SIGTERM-initiated 828 + // shutdowns race: kill() returns synchronously but onExit fires 829 + // later, after we've already flushed. Bound with a short timeout in 830 + // case the child never exits (shouldn't happen — we just killed it). 831 + await Promise.race([ 832 + this.childExited, 833 + new Promise<void>((r) => setTimeout(r, 2000)), 834 + ]); 835 + try { await this.eventWriter.flush(); } catch {} 817 836 resolve(); 818 837 }); 819 838 });
+33
tests/events.test.ts
··· 303 303 expect(received[0].type).toBe("bell"); 304 304 }); 305 305 306 + it("dirWatcher-detected new files start at offset 0 so session_start is not skipped", async () => { 307 + const name = uniqueName(); 308 + // Make sure the file does not exist yet 309 + try { fs.unlinkSync(getEventsPath(name)); } catch {} 310 + 311 + // Start a follower with no specific names — it uses scanAndWatchAll and 312 + // relies on dirWatcher to pick up new files. 313 + const received: EventRecord[] = []; 314 + const follower = new EventFollower({ 315 + onEvent: (event) => { if (event.session === name) received.push(event); }, 316 + }); 317 + follower.start(); 318 + await sleep(100); 319 + 320 + // Create the file with a session_start already present. In the real 321 + // daemon, session_start is appended immediately on socket listen, and 322 + // the dirWatcher usually fires *after* that line is on disk. 323 + const writer = new EventWriter(name); 324 + writer.append({ 325 + session: name, 326 + type: "session_start", 327 + ts: "2026-04-05T00:00:00Z", 328 + }); 329 + await writer.flush(); 330 + 331 + // Wait for dirWatcher + readNewLines 332 + await sleep(500); 333 + follower.stop(); 334 + 335 + const starts = received.filter((e) => e.type === "session_start"); 336 + expect(starts.length).toBe(1); 337 + }); 338 + 306 339 it("handles file truncation gracefully", async () => { 307 340 const name = uniqueName(); 308 341 clearEvents(name);
+133
tests/exit-event-race.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 11 + 12 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-exit-")); 13 + afterAll(() => { 14 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 15 + }); 16 + 17 + let bgPids: number[] = []; 18 + let sessionDirs: string[] = []; 19 + 20 + function makeSessionDir(): string { 21 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 22 + sessionDirs.push(dir); 23 + return dir; 24 + } 25 + 26 + afterEach(() => { 27 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 28 + bgPids = []; 29 + for (const dir of sessionDirs) { 30 + try { 31 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 32 + } catch {} 33 + } 34 + sessionDirs = []; 35 + }); 36 + 37 + /** Spawn the daemon as a real subprocess and return the child + an exit promise. */ 38 + function startDaemonSubprocess(sessionDir: string, name: string, command: string, args: string[] = []) { 39 + const config = JSON.stringify({ 40 + name, command, args, displayCommand: command, 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 + bgPids.push(child.pid!); 49 + let stderr = ""; 50 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 51 + child.unref(); 52 + (child.stderr as any)?.unref?.(); 53 + 54 + const exitPromise = new Promise<number | null>((resolve) => { 55 + child.on("exit", (code) => resolve(code)); 56 + }); 57 + return { child, exitPromise, getStderr: () => stderr }; 58 + } 59 + 60 + async function waitForSocket(sessionDir: string, name: string, timeoutMs = 5000): Promise<void> { 61 + const socketPath = path.join(sessionDir, `${name}.sock`); 62 + const start = Date.now(); 63 + while (Date.now() - start < timeoutMs) { 64 + try { fs.statSync(socketPath); return; } catch {} 65 + await new Promise((r) => setTimeout(r, 50)); 66 + } 67 + throw new Error(`Timeout waiting for socket ${socketPath}`); 68 + } 69 + 70 + function readEvents(sessionDir: string, name: string): any[] { 71 + const eventsPath = path.join(sessionDir, `${name}.events.jsonl`); 72 + try { 73 + const content = fs.readFileSync(eventsPath, "utf-8"); 74 + return content.trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); 75 + } catch { 76 + return []; 77 + } 78 + } 79 + 80 + describe("session_exit event is flushed to disk before daemon exits", () => { 81 + it("captures session_exit when child exits naturally (short-lived process)", async () => { 82 + const dir = makeSessionDir(); 83 + const name = `x${Math.random().toString(36).slice(2, 6)}`; 84 + 85 + // `true` exits immediately, triggering the daemon's onExit path. 86 + const { exitPromise } = startDaemonSubprocess(dir, name, "true"); 87 + 88 + await waitForSocket(dir, name); 89 + // Wait for the daemon to fully exit (this is what pty kill races against). 90 + await exitPromise; 91 + 92 + const events = readEvents(dir, name); 93 + const exits = events.filter((e) => e.type === "session_exit"); 94 + expect(exits.length).toBe(1); 95 + expect(typeof exits[0].exitCode).toBe("number"); 96 + }, 15000); 97 + 98 + it("captures session_exit when daemon is killed via SIGTERM", async () => { 99 + const dir = makeSessionDir(); 100 + const name = `k${Math.random().toString(36).slice(2, 6)}`; 101 + 102 + // `sh -c 'sleep 30'` so the daemon stays alive until we SIGTERM it — mimics `pty kill`. 103 + const { child, exitPromise, getStderr } = startDaemonSubprocess(dir, name, "/bin/sh", ["-c", "sleep 30"]); 104 + 105 + try { await waitForSocket(dir, name); } 106 + catch (e) { 107 + throw new Error(`${(e as Error).message}\nDaemon stderr:\n${getStderr()}`); 108 + } 109 + // Give the daemon a moment to be fully ready 110 + await new Promise((r) => setTimeout(r, 200)); 111 + 112 + // Kill the daemon (like `pty kill` does) 113 + try { process.kill(child.pid!, "SIGTERM"); } catch {} 114 + await exitPromise; 115 + 116 + const events = readEvents(dir, name); 117 + const exits = events.filter((e) => e.type === "session_exit"); 118 + expect(exits.length).toBe(1); 119 + }, 15000); 120 + 121 + it("session_start is always present (watchFile offset 0 for new files)", async () => { 122 + const dir = makeSessionDir(); 123 + const name = `s${Math.random().toString(36).slice(2, 6)}`; 124 + 125 + const { exitPromise } = startDaemonSubprocess(dir, name, "true"); 126 + await waitForSocket(dir, name); 127 + await exitPromise; 128 + 129 + const events = readEvents(dir, name); 130 + const starts = events.filter((e) => e.type === "session_start"); 131 + expect(starts.length).toBe(1); 132 + }, 15000); 133 + });