import { stripVTControlCharacters } from "node:util"; import type { PtyTerminalSession } from "./pty-session.js"; import type { InteractiveShellConfig } from "./config.js"; export interface MonitorMatchInfo { strategy: "stream" | "poll-diff" | "file-watch"; triggerId: string; eventType: string; matchedText: string; lineOrDiff: string; stream: "pty"; } export interface MonitorTriggerMatcher { id: string; cooldownMs?: number; match: (input: string) => string | undefined; } export interface MonitorRuntimeConfig { strategy: "stream" | "poll-diff" | "file-watch"; triggers: MonitorTriggerMatcher[]; pollIntervalMs: number; dedupeExactLine: boolean; cooldownMs?: number; } /** Runtime options for monitoring a headless dispatch session. */ export interface HeadlessMonitorOptions { autoExitOnQuiet: boolean; quietThreshold: number; gracePeriod?: number; timeout?: number; monitor?: MonitorRuntimeConfig; onMonitorEvent?: (event: MonitorMatchInfo) => void | Promise; /** Original session start time in ms since epoch, preserved when a foreground session moves headless. */ startedAt?: number; } /** Completion payload emitted when a headless dispatch session finishes. */ export interface HeadlessCompletionInfo { exitCode: number | null; signal?: number; timedOut?: boolean; cancelled?: boolean; completionOutput?: { lines: string[]; totalLines: number; truncated: boolean; }; } export class HeadlessDispatchMonitor { readonly startTime: number; private _disposed = false; private quietTimer: ReturnType | null = null; private timeoutTimer: ReturnType | null = null; private pollTimer: ReturnType | null = null; private pollInFlight = false; private pollInitialized = false; private lastPollSnapshot = ""; private pollReadOffset = 0; private result: HeadlessCompletionInfo | undefined; private completeCallbacks: Array<() => void> = []; private unsubData: (() => void) | null = null; private unsubExit: (() => void) | null = null; private monitorLineBuffer = ""; private emittedMonitorKeys = new Set(); private triggerLastEmitAt = new Map(); get disposed(): boolean { return this._disposed; } constructor( private session: PtyTerminalSession, private config: InteractiveShellConfig, private options: HeadlessMonitorOptions, private onComplete: (info: HeadlessCompletionInfo) => void, ) { this.startTime = options.startedAt ?? Date.now(); this.subscribe(); if (options.autoExitOnQuiet) { this.resetQuietTimer(); } if (options.timeout && options.timeout > 0) { this.timeoutTimer = setTimeout(() => { this.handleCompletion(null, undefined, true); }, options.timeout); } if (options.monitor?.strategy === "poll-diff") { this.startPollTimer(); } if (session.exited) { queueMicrotask(() => { if (!this._disposed) { this.handleCompletion(session.exitCode, session.signal); } }); } } private subscribe(): void { this.unsubscribe(); this.unsubData = this.session.addDataListener((data) => { const visible = stripVTControlCharacters(data); if (this.options.autoExitOnQuiet && visible.trim().length > 0) { this.resetQuietTimer(); } if (this.options.monitor?.strategy !== "poll-diff" && this.options.onMonitorEvent) { this.processMonitorData(visible, false); } }); this.unsubExit = this.session.addExitListener((exitCode, signal) => { if (!this._disposed) { this.handleCompletion(exitCode, signal); } }); } private unsubscribe(): void { this.unsubData?.(); this.unsubData = null; this.unsubExit?.(); this.unsubExit = null; } private processMonitorData(visible: string, flushTrailing: boolean): void { if (!visible && !flushTrailing) return; const combined = this.monitorLineBuffer + visible; const parts = combined.split(/\r\n|\n|\r/g); if (flushTrailing) { this.monitorLineBuffer = ""; } else { this.monitorLineBuffer = parts.pop() ?? ""; } for (const line of parts) { if (!line) continue; this.emitStreamMatches(line); } } private emitStreamMatches(line: string): void { const monitor = this.options.monitor; if (!monitor || monitor.strategy === "poll-diff") return; for (const trigger of monitor.triggers) { const matchedText = trigger.match(line); if (!matchedText) continue; if (!this.canEmitTrigger(trigger.id, trigger.cooldownMs)) continue; if (!this.shouldEmitUnique(trigger.id, line)) continue; this.emitMonitorEvent({ strategy: monitor.strategy, triggerId: trigger.id, eventType: trigger.id, matchedText, lineOrDiff: line, stream: "pty", }); } } private startPollTimer(): void { const monitor = this.options.monitor; if (!monitor || monitor.strategy !== "poll-diff") return; const intervalMs = Math.max(250, Math.trunc(monitor.pollIntervalMs || 5000)); this.pollTimer = setInterval(() => { void this.processPollTick(); }, intervalMs); } private stopPollTimer(): void { if (!this.pollTimer) return; clearInterval(this.pollTimer); this.pollTimer = null; } private async processPollTick(): Promise { if (this._disposed || this.pollInFlight) return; const monitor = this.options.monitor; if (!monitor || monitor.strategy !== "poll-diff") return; this.pollInFlight = true; try { const raw = this.session.getRawStream({ sinceLast: false, stripAnsi: true }); if (this.pollReadOffset > raw.length) { this.pollReadOffset = raw.length; } const sample = normalizeMonitorSnapshot(raw.slice(this.pollReadOffset)); this.pollReadOffset = raw.length; if (!this.pollInitialized) { this.lastPollSnapshot = sample; this.pollInitialized = true; return; } if (sample === this.lastPollSnapshot) return; const previous = this.lastPollSnapshot; this.lastPollSnapshot = sample; const diffSummary = summarizeDiff(previous, sample); for (const trigger of monitor.triggers) { const matchedText = trigger.match(sample); if (!matchedText) continue; if (!this.canEmitTrigger(trigger.id, trigger.cooldownMs)) continue; if (!this.shouldEmitUnique(trigger.id, diffSummary)) continue; this.emitMonitorEvent({ strategy: "poll-diff", triggerId: trigger.id, eventType: trigger.id, matchedText, lineOrDiff: diffSummary, stream: "pty", }); } } catch (error) { console.error("interactive-shell: poll-diff tick error:", error); } finally { this.pollInFlight = false; } } private shouldEmitUnique(triggerId: string, lineOrDiff: string): boolean { const monitor = this.options.monitor; if (!monitor || monitor.dedupeExactLine === false) return true; const key = `${triggerId}\u0000${lineOrDiff}`; if (this.emittedMonitorKeys.has(key)) return false; this.emittedMonitorKeys.add(key); return true; } private canEmitTrigger(triggerId: string, triggerCooldownMs?: number): boolean { const monitor = this.options.monitor; if (!monitor) return true; const cooldown = triggerCooldownMs ?? monitor.cooldownMs; if (!cooldown || cooldown <= 0) return true; const now = Date.now(); const last = this.triggerLastEmitAt.get(triggerId) ?? 0; if (now - last < cooldown) return false; this.triggerLastEmitAt.set(triggerId, now); return true; } private emitMonitorEvent(event: MonitorMatchInfo): void { try { const maybePromise = this.options.onMonitorEvent?.(event); if (maybePromise && typeof (maybePromise as Promise).then === "function") { void (maybePromise as Promise).catch((error) => { console.error("interactive-shell: monitor event callback error:", error); }); } } catch (error) { console.error("interactive-shell: monitor event callback error:", error); } } private resetQuietTimer(): void { this.stopQuietTimer(); this.quietTimer = setTimeout(() => { this.quietTimer = null; if (!this._disposed && this.options.autoExitOnQuiet) { const gracePeriod = this.options.gracePeriod ?? this.config.autoExitGracePeriod; if (Date.now() - this.startTime < gracePeriod) { this.resetQuietTimer(); return; } this.session.kill(); this.handleCompletion(null, undefined, false, true); } }, this.options.quietThreshold); } private stopQuietTimer(): void { if (this.quietTimer) { clearTimeout(this.quietTimer); this.quietTimer = null; } } private captureOutput(): HeadlessCompletionInfo["completionOutput"] { try { const result = this.session.getTailLines({ lines: this.config.completionNotifyLines, ansi: false, maxChars: this.config.completionNotifyMaxChars, }); return { lines: result.lines, totalLines: result.totalLinesInBuffer, truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars, }; } catch { // Session terminal may already be disposed during completion — safe to return empty return { lines: [], totalLines: 0, truncated: false }; } } private handleCompletion(exitCode: number | null, signal?: number, timedOut?: boolean, cancelled?: boolean): void { if (this._disposed) return; if (this.options.monitor?.strategy !== "poll-diff" && this.options.onMonitorEvent) { this.processMonitorData("", true); } this._disposed = true; this.stopQuietTimer(); this.stopPollTimer(); if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; } this.unsubscribe(); if (timedOut) { this.session.kill(); } const completionOutput = this.captureOutput(); const info: HeadlessCompletionInfo = { exitCode, signal, timedOut, cancelled, completionOutput }; this.result = info; this.triggerCompleteCallbacks(); this.onComplete(info); } handleExternalCompletion(exitCode: number | null, signal?: number, completionOutput?: HeadlessCompletionInfo["completionOutput"]): void { if (this._disposed) return; if (this.options.monitor?.strategy !== "poll-diff" && this.options.onMonitorEvent) { this.processMonitorData("", true); } this._disposed = true; this.stopQuietTimer(); this.stopPollTimer(); if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; } this.unsubscribe(); const output = completionOutput ?? this.captureOutput(); const info: HeadlessCompletionInfo = { exitCode, signal, completionOutput: output }; this.result = info; this.triggerCompleteCallbacks(); this.onComplete(info); } getResult(): HeadlessCompletionInfo | undefined { return this.result; } registerCompleteCallback(callback: () => void): void { if (this.result) { callback(); return; } this.completeCallbacks.push(callback); } private triggerCompleteCallbacks(): void { for (const cb of this.completeCallbacks) { try { cb(); } catch (error) { console.error("interactive-shell: headless completion callback error:", error); } } this.completeCallbacks = []; } dispose(): void { if (this._disposed) return; this._disposed = true; this.stopQuietTimer(); this.stopPollTimer(); if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); this.timeoutTimer = null; } this.unsubscribe(); } } function normalizeMonitorSnapshot(raw: string): string { if (!raw) return ""; const normalizedLineEndings = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); return normalizedLineEndings .replace(/[\t ]+$/gm, "") .trimEnd(); } function summarizeDiff(previous: string, current: string): string { if (previous === current) return "No change"; if (!previous && current) return `Output changed: now has content (${current.length} chars)`; if (previous && !current) return "Output changed: now empty"; const prevLines = previous.split("\n"); const nextLines = current.split("\n"); const max = Math.max(prevLines.length, nextLines.length); for (let i = 0; i < max; i++) { const before = prevLines[i] ?? ""; const after = nextLines[i] ?? ""; if (before === after) continue; const left = before.length > 120 ? `${before.slice(0, 117)}...` : before; const right = after.length > 120 ? `${after.slice(0, 117)}...` : after; return `Output changed at line ${i + 1}: "${left}" -> "${right}"`; } return `Output changed (${previous.length} chars -> ${current.length} chars)`; }