import { debugLog } from "#src/debug"; import { getLifetimeTotal } from "#src/lifecycle/usage"; import type { Subagent } from "#src/types"; /** Details attached to custom notification messages for visual rendering. */ export interface NotificationDetails { id: string; description: string; modelLabel: string; thinkingLevel: Subagent["effectiveThinkingLevel"]; status: string; toolUses: number; turnCount: number; maxTurns?: number; totalTokens: number; durationMs: number; outputFile?: string; error?: string; resultPreview: string; } // ---- Pure helpers (exported for unit testing) ---- /** Escape XML text and attribute delimiters in structured notifications. */ export function escapeXml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** Human-readable status label for agent completion. */ export function getStatusLabel(status: string, error?: string): string { switch (status) { case "error": return `Error: ${error ?? "unknown"}`; case "aborted": return "Aborted (max turns exceeded)"; case "steered": return "Wrapped up (turn limit)"; case "stopped": return "Stopped"; default: return "Done"; } } /** Format a structured XML block for the parent agent to parse. */ export function formatTaskNotification(record: Subagent, resultMaxLen: number): string { const status = getStatusLabel(record.status, record.error); const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0; const totalTokens = getLifetimeTotal(record.lifetimeUsage); const contextPercent = record.getContextPercent(); const ctxXml = contextPercent !== null ? `${Math.round(contextPercent)}` : ""; const compactXml = record.compactionCount ? `${record.compactionCount}` : ""; const resultPreview = record.stoppedWhileQueued ? "Agent was stopped while queued and never started. No work was performed." : record.result ? record.result.length > resultMaxLen ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)" : record.result : "No output."; const toolCallId = record.toolCallId; const outputFile = record.outputFile; return [ "", `${record.id}`, toolCallId ? `${escapeXml(toolCallId)}` : null, outputFile ? `${escapeXml(outputFile)}` : null, `${escapeXml(status)}`, `${escapeXml(record.modelLabel)}`, `${escapeXml(record.effectiveThinkingLevel)}`, `Subagent "${escapeXml(record.description)}" ${record.status}`, `${escapeXml(resultPreview)}`, `${totalTokens}${record.toolUses}${ctxXml}${compactXml}${durationMs}`, "", ] .filter(Boolean) .join("\n"); } /** Build notification details for the custom message renderer. */ export function buildNotificationDetails( record: Subagent, resultMaxLen: number, ): NotificationDetails { const totalTokens = getLifetimeTotal(record.lifetimeUsage); return { id: record.id, description: record.description, modelLabel: record.modelLabel, thinkingLevel: record.effectiveThinkingLevel, status: record.status, toolUses: record.toolUses, turnCount: record.turnCount, maxTurns: record.maxTurns, totalTokens, durationMs: record.completedAt ? record.completedAt - record.startedAt : 0, outputFile: record.outputFile, error: record.error, resultPreview: record.stoppedWhileQueued ? "Agent was stopped while queued and never started. No work was performed." : record.result ? record.result.length > resultMaxLen ? record.result.slice(0, resultMaxLen) + "…" : record.result : "No output.", }; } /** Build event data for lifecycle events from a Subagent. */ export function buildEventData(record: Subagent) { const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt; const u = record.lifetimeUsage; const total = getLifetimeTotal(u); const tokens = total > 0 ? { input: u.input, output: u.output, total } : undefined; return { id: record.id, type: record.type, description: record.description, result: record.result, error: record.error, status: record.status, toolUses: record.toolUses, durationMs, tokens, }; } // ---- Notification system factory ---- export interface NotificationSystem { sendCompletion: (record: Subagent) => void; dispose: () => void; } export class NotificationManager implements NotificationSystem { // Pi cannot recall a queued followUp. Hold records while the parent run is // active so consumption can be rechecked at the actual delivery boundary. private pendingNudges = new Map(); private parentRunActive = false; private disposed = false; constructor( private sendMessage: ( msg: { customType: string; content: string; display: boolean; details?: unknown }, opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ) => void, ) {} sendCompletion(record: Subagent): void { if (this.disposed || record.consumed) return; if (this.parentRunActive) { this.pendingNudges.set(record.id, record); return; } this.emitIndividualNudge(record); } onParentAgentStart(): void { if (!this.disposed) this.parentRunActive = true; } onParentAgentSettled(): void { if (this.disposed) return; this.parentRunActive = false; const withheld = [...this.pendingNudges.values()]; this.pendingNudges.clear(); for (const record of withheld) { try { this.emitIndividualNudge(record); } catch (err) { debugLog("notification render", err); } } } dispose(): void { this.disposed = true; this.pendingNudges.clear(); } private emitIndividualNudge(record: Subagent): void { if (this.disposed || record.consumed) return; const notification = formatTaskNotification(record, 500); const outputFile = record.outputFile; const footer = !record.stoppedWhileQueued && outputFile ? `\nFull transcript available at: ${outputFile}` : ""; this.sendMessage( { customType: "subagent-notification", content: notification + footer, display: true, details: buildNotificationDetails(record, 500), }, { deliverAs: "followUp", triggerTurn: true }, ); // Push delivery collected the terminal outcome just as surely as a pull. record.markConsumed(); } }