398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
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<void>;
|
|
/** 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<typeof setTimeout> | null = null;
|
|
private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private pollTimer: ReturnType<typeof setInterval> | 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<string>();
|
|
private triggerLastEmitAt = new Map<string, number>();
|
|
|
|
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<void> {
|
|
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<unknown>).then === "function") {
|
|
void (maybePromise as Promise<unknown>).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)`;
|
|
}
|