Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
178
extensions/pi-interactive-shell/notification-utils.ts
Normal file
178
extensions/pi-interactive-shell/notification-utils.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user