/** * Incremental scanner for terminal-title escapes (OSC 0/2) in the child CLI's * PTY stream — the TS-runtime mirror of rs/src/title_scanner.rs; keep the two * in sync. * * Coding-agent CLIs (claude, opencode, codex) continuously set the terminal * title to a summary of what they are doing (`ESC ] 0 ; BEL`, also * terminated by `ESC \`). The wrapper observes the stream anyway, so scanning * it yields a live "what is this agent doing" label for free — surfaced as * `title` in the pid registry and shown by `ay whoami` / `ay ls --json`. * * Fed arbitrary chunk boundaries: a sequence split across reads still parses. * The scanner only observes — it never modifies the stream. */ /** A title longer than this is a runaway OSC without terminator — discard. */ const MAX_TITLE_LEN = 512; /** What we store/display. */ const STORED_TITLE_LEN = 256; const enum State { Ground, Esc, OscParam, OscTitle, OscOther, OscTitleEsc, OscOtherEsc, } export class TitleScanner { private state = State.Ground; private param = ""; private title = ""; /** Feed one output chunk; returns the LAST complete title in it, if any. */ feed(chunk: string): string | null { let found: string | null = null; for (const c of chunk) { switch (this.state) { case State.Ground: if (c === "\x1b") this.state = State.Esc; break; case State.Esc: if (c === "]") { this.param = ""; this.state = State.OscParam; } else if (c !== "\x1b") { this.state = State.Ground; } break; case State.OscParam: if (c >= "0" && c <= "9" && this.param.length < 4) { this.param += c; } else if (c === ";") { // OSC 0 (icon+title) and 2 (title) carry the window title; OSC 1 // is icon-only and everything else (8, 52, 133, …) is unrelated. if (this.param === "0" || this.param === "2") { this.title = ""; this.state = State.OscTitle; } else { this.state = State.OscOther; } } else if (c === "\x07") { this.state = State.Ground; } else if (c === "\x1b") { this.state = State.OscOtherEsc; } else { this.state = State.OscOther; } break; case State.OscTitle: if (c === "\x07") { found = cleanTitle(this.title); this.state = State.Ground; } else if (c === "\x1b") { this.state = State.OscTitleEsc; } else if (this.title.length >= MAX_TITLE_LEN) { this.state = State.OscOther; // runaway — drop and resync } else { this.title += c; } break; case State.OscTitleEsc: if (c === "\\") found = cleanTitle(this.title); this.state = State.Ground; // a bare ESC aborts the OSC either way break; case State.OscOther: if (c === "\x07") this.state = State.Ground; else if (c === "\x1b") this.state = State.OscOtherEsc; break; case State.OscOtherEsc: this.state = State.Ground; break; } } return found || null; } } /** Strip control chars (they can't be typed into a title bar) and clamp. */ function cleanTitle(raw: string): string { // eslint-disable-next-line no-control-regex const trimmed = raw.replace(/[\x00-\x1f\x7f]/g, "").trim(); return trimmed.slice(0, STORED_TITLE_LEN); } /** * Change-gated + rate-limited publisher: registry writes take the * cross-runtime lock, while claude retitles every few seconds — the steady * state must cost zero writes, and a retitle burst collapses to one write per * window. Call `poll()` from any recurring point in the run loop so the final * title still lands within a window after the CLI goes quiet. */ export class TitlePublisher { private latest: string | null = null; private written: string | null = null; private writtenAt = 0; constructor( private readonly write: (title: string) => void, private readonly minIntervalMs = 2_000, ) {} observe(title: string): void { this.latest = title; this.poll(); } poll(now: number = Date.now()): void { if (this.latest === null || this.latest === this.written) return; if (now - this.writtenAt < this.minIntervalMs) return; this.write(this.latest); this.written = this.latest; this.writtenAt = now; } }