Files
pi-config/extensions/pi-interactive-shell/notification-utils.ts

179 lines
7.6 KiB
TypeScript

import type { InteractiveShellResult, HandsFreeUpdate, MonitorEventPayload, MonitorSessionState } from "./types.js";
import type { HeadlessCompletionInfo } from "./headless-monitor.js";
import { formatDurationMs } from "./types.js";
const BRIEF_TAIL_LINES = 5;
export function buildDispatchNotification(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
const parts = [buildDispatchStatusLine(sessionId, info, duration)];
if (info.completionOutput && info.completionOutput.totalLines > 0) {
parts.push(` ${info.completionOutput.totalLines} lines of output.`);
}
appendTailBlock(parts, info.completionOutput?.lines, BRIEF_TAIL_LINES);
parts.push(`\n\nAttach to review full output: interactive_shell({ attach: "${sessionId}" })`);
return parts.join("");
}
export function buildResultNotification(sessionId: string, result: InteractiveShellResult): string {
const parts = [buildResultStatusLine(sessionId, result)];
if (result.completionOutput && result.completionOutput.lines.length > 0) {
const truncNote = result.completionOutput.truncated
? ` (truncated from ${result.completionOutput.totalLines} total lines)`
: "";
parts.push(`\nOutput (${result.completionOutput.lines.length} lines${truncNote}):\n\n${result.completionOutput.lines.join("\n")}`);
}
return parts.join("");
}
export function buildMonitorEventNotification(event: MonitorEventPayload): string {
return [
`Monitor Event (${event.sessionId}) #${event.eventId}`,
`Time: ${event.timestamp}`,
`Strategy: ${event.strategy}`,
`Trigger: ${event.triggerId}`,
`Matched: ${event.matchedText}`,
`${event.strategy === "poll-diff" ? "Diff" : "Line"}: ${event.lineOrDiff}`,
].join("\n");
}
export function buildMonitorLifecycleNotification(state: MonitorSessionState): string {
const reason = state.terminalReason ?? "stopped";
let headline: string;
if (reason === "stream-ended") {
headline = `Monitor ${state.sessionId} stream ended.`;
} else if (reason === "timed-out") {
headline = `Monitor ${state.sessionId} timed out.`;
} else if (reason === "script-failed") {
headline = `Monitor ${state.sessionId} script failed.`;
} else {
headline = `Monitor ${state.sessionId} stopped.`;
}
const details: string[] = [
headline,
`Strategy: ${state.strategy}`,
`Events: ${state.eventCount}`,
state.lastEventAt ? `Last event: #${state.lastEventId} at ${state.lastEventAt}` : "Last event: none",
];
if (state.exitCode !== undefined && state.exitCode !== null) {
details.push(`Exit code: ${state.exitCode}`);
}
if (state.signal !== undefined) {
details.push(`Signal: ${state.signal}`);
}
return details.join("\n");
}
export function buildHandsFreeUpdateMessage(update: HandsFreeUpdate): { content: string; details: HandsFreeUpdate } | null {
if (update.status === "running") return null;
const tail = update.tail.length > 0 ? `\n\n${update.tail.join("\n")}` : "";
let statusLine: string;
switch (update.status) {
case "exited":
statusLine = `Session ${update.sessionId} exited (${formatDurationMs(update.runtime)})`;
break;
case "killed":
statusLine = `Session ${update.sessionId} killed (${formatDurationMs(update.runtime)})`;
break;
case "user-takeover":
statusLine = `Session ${update.sessionId}: user took over (${formatDurationMs(update.runtime)})`;
break;
case "agent-resumed":
statusLine = `Session ${update.sessionId}: agent resumed monitoring (${formatDurationMs(update.runtime)})`;
break;
default:
statusLine = `Session ${update.sessionId} update (${formatDurationMs(update.runtime)})`;
}
return { content: statusLine + tail, details: update };
}
export function summarizeInteractiveResult(command: string, result: InteractiveShellResult, timeout?: number, reason?: string): string {
let summary = buildInteractiveSummary(result, timeout);
if (result.userTookOver) {
summary += "\n\nNote: User took over control during hands-free mode.";
}
if (!result.transferred && result.handoffPreview?.type === "tail" && result.handoffPreview.lines.length > 0) {
summary += `\n\nOverlay tail (${result.handoffPreview.when}, last ${result.handoffPreview.lines.length} lines):\n${result.handoffPreview.lines.join("\n")}`;
}
const warning = buildIdlePromptWarning(command, reason);
if (warning) {
summary += `\n\n${warning}`;
}
return summary;
}
export function buildIdlePromptWarning(command: string, reason: string | undefined): string | null {
if (!reason) return null;
const tasky = /\b(scan|check|review|summariz|analyz|inspect|audit|find|fix|refactor|debug|investigat|explore|enumerat|list)\b/i;
if (!tasky.test(reason)) return null;
const trimmed = command.trim();
const binaries = ["pi", "claude", "codex", "gemini", "agent"] as const;
const bin = binaries.find((candidate) => trimmed === candidate || trimmed.startsWith(`${candidate} `));
if (!bin) return null;
const rest = trimmed === bin ? "" : trimmed.slice(bin.length).trim();
const hasQuotedPrompt = /["']/.test(rest);
const hasKnownPromptFlag =
/\b(-p|--print|--prompt|--prompt-interactive|-i|exec)\b/.test(rest) ||
(bin === "pi" && /\b-p\b/.test(rest)) ||
(bin === "codex" && /\bexec\b/.test(rest));
if (hasQuotedPrompt || hasKnownPromptFlag) return null;
if (!looksLikeIdleCommand(rest)) return null;
const examplePrompt = reason.replace(/\s+/g, " ").trim();
const clipped = examplePrompt.length > 120 ? `${examplePrompt.slice(0, 117)}...` : examplePrompt;
return `Note: \`reason\` is UI-only. This command likely started the agent idle. If you intended an initial prompt, embed it in \`command\`, e.g. \`${bin} "${clipped}"\`.`;
}
function buildDispatchStatusLine(sessionId: string, info: HeadlessCompletionInfo, duration: string): string {
if (info.timedOut) return `Session ${sessionId} timed out (${duration}).`;
if (info.cancelled) return `Session ${sessionId} was killed (${duration}).`;
if (info.exitCode === 0) return `Session ${sessionId} completed successfully (${duration}).`;
return `Session ${sessionId} exited with code ${info.exitCode} (${duration}).`;
}
function buildResultStatusLine(sessionId: string, result: InteractiveShellResult): string {
if (result.timedOut) return `Session ${sessionId} timed out.`;
if (result.cancelled) return `Session ${sessionId} was killed.`;
if (result.exitCode === 0) return `Session ${sessionId} completed successfully.`;
return `Session ${sessionId} exited with code ${result.exitCode}.`;
}
function buildInteractiveSummary(result: InteractiveShellResult, timeout?: number): string {
if (result.transferred) {
const truncatedNote = result.transferred.truncated ? ` (truncated from ${result.transferred.totalLines} total lines)` : "";
return `Session output transferred (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
}
if (result.backgrounded) {
return `Session running in background (id: ${result.backgroundId}). User can reattach with /attach ${result.backgroundId}`;
}
if (result.cancelled) return "User killed the interactive session";
if (result.timedOut) return `Session killed after timeout (${timeout ?? "?"}ms)`;
const status = result.exitCode === 0 ? "successfully" : `with code ${result.exitCode}`;
return `Session ended ${status}`;
}
function appendTailBlock(parts: string[], lines: string[] | undefined, tailLines: number): void {
if (!lines || lines.length === 0) return;
let end = lines.length;
while (end > 0 && lines[end - 1].trim() === "") end--;
const tail = lines.slice(Math.max(0, end - tailLines), end);
if (tail.length > 0) {
parts.push(`\n\n${tail.join("\n")}`);
}
}
function looksLikeIdleCommand(rest: string): boolean {
return rest.length === 0 || /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:=[^\s]+|\s+[^\s-][^\s]*)?\s*)+$/.test(rest);
}