import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { piSessionIdFromContext, type AgentTickSessionContext } from "./session"; import { redactForAgentTick } from "./redaction"; import { StatusReporter } from "./status"; import type { AgentTickExtensionConfig, CreateToolActivityInput, StatusHookConfig, StatusState, StatusToolActivityConfig } from "./types"; type PiWithOn = ExtensionAPI & { on?: (event: string, handler: (...args: any[]) => any) => void }; type StatusReporterLike = Pick; const FALLBACK_MESSAGES: Record = { working: "working", waiting: "waiting", blocked: "blocked", done: "done", failed: "failed", }; function collapseWhitespace(text: string): string { return text.replace(/\s+/g, " ").trim(); } export function summarizeTaskPrompt(prompt: unknown, maxChars: number): string | undefined { const text = typeof prompt === "string" ? collapseWhitespace(prompt) : undefined; if (!text) return undefined; if (text.length <= maxChars) return text; return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; } function truncateText(text: string, maxChars: number): string { if (!Number.isFinite(maxChars) || maxChars <= 0 || text.length <= maxChars) return text; return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; } function blockText(block: unknown, includeThinking: boolean): string { if (!block || typeof block !== "object") return ""; const value = block as { type?: unknown; text?: unknown; content?: unknown }; const type = typeof value.type === "string" ? value.type : undefined; if (type && ["tool_use", "tool_result", "function_call", "function_result"].includes(type)) return ""; if (!includeThinking && type && ["thinking", "reasoning", "redacted_thinking"].includes(type)) return ""; if (!type || type === "text" || type === "output_text" || (includeThinking && (type === "thinking" || type === "reasoning"))) { if (typeof value.text === "string") return value.text; if (typeof value.content === "string") return value.content; } return ""; } function messageContentText(message: unknown, role: "assistant" | "user", includeThinking: boolean): string { const candidate = (message ?? {}) as { content?: unknown; text?: unknown; message?: unknown }; const content = candidate.content ?? candidate.message; if (typeof content === "string") return content; if (typeof candidate.text === "string") return candidate.text; if (Array.isArray(content)) return content.map((block) => blockText(block, includeThinking)).filter(Boolean).join(""); if (role === "user" && content && typeof content === "object" && "text" in content && typeof (content as { text?: unknown }).text === "string") return (content as { text: string }).text; return ""; } function messageRole(event: unknown): "assistant" | "user" | "system" | undefined { const object = (event ?? {}) as { role?: unknown; message?: { role?: unknown } }; const role = object.role ?? object.message?.role; return role === "assistant" || role === "user" || role === "system" ? role : undefined; } function stopReason(event: unknown): string | undefined { const object = (event ?? {}) as { stopReason?: unknown; stop_reason?: unknown; message?: { stopReason?: unknown; stop_reason?: unknown } }; const value = object.stopReason ?? object.stop_reason ?? object.message?.stopReason ?? object.message?.stop_reason; return typeof value === "string" ? value : undefined; } function hasToolUseStop(event: unknown): boolean { const reason = stopReason(event); return reason === "toolUse" || reason === "tool_use" || reason === "tool-call" || reason === "tool_call"; } const IGNORED_TOOL_ACTIVITY_NAMES = new Set(["agenttickaskuser", "askuser"]); function toolNameFromEvent(event: unknown): string { const object = (event ?? {}) as { toolName?: unknown; tool_name?: unknown; name?: unknown }; const name = object.toolName ?? object.tool_name ?? object.name; return typeof name === "string" && name.trim() ? name.trim() : "tool"; } function normalizedToolActivityName(toolName: string): string { return toolName.toLowerCase().replace(/[^a-z0-9]/g, ""); } function shouldIgnoreToolActivity(toolName: string): boolean { const leafName = toolName.split(".").at(-1) ?? toolName; return IGNORED_TOOL_ACTIVITY_NAMES.has(normalizedToolActivityName(toolName)) || IGNORED_TOOL_ACTIVITY_NAMES.has(normalizedToolActivityName(leafName)); } function toolCallIdFromEvent(event: unknown): string | undefined { const object = (event ?? {}) as { toolCallId?: unknown; tool_call_id?: unknown; id?: unknown }; const id = object.toolCallId ?? object.tool_call_id ?? object.id; return typeof id === "string" && id.trim() ? id.trim() : undefined; } function toolArgsFromEvent(event: unknown): unknown { const object = (event ?? {}) as { args?: unknown; input?: unknown; toolInput?: unknown; tool_input?: unknown }; return object.args ?? object.input ?? object.toolInput ?? object.tool_input; } function toolResultFromEvent(event: unknown): unknown { const object = (event ?? {}) as { result?: unknown; output?: unknown; content?: unknown }; return object.result ?? object.output ?? object.content; } function toolIsError(event: unknown): boolean { const object = (event ?? {}) as { isError?: unknown; is_error?: unknown; error?: unknown }; return object.isError === true || object.is_error === true || object.error === true || object.error instanceof Error || typeof object.error === "string"; } function toolIsCancelled(event: unknown): boolean { const object = (event ?? {}) as { cancelled?: unknown; canceled?: unknown; outcome?: unknown; status?: unknown }; return object.cancelled === true || object.canceled === true || object.outcome === "cancelled" || object.status === "cancelled" || object.status === "canceled"; } function toolOutcomeFromEvent(event: unknown): "success" | "failed" | "cancelled" { if (toolIsCancelled(event)) return "cancelled"; return toolIsError(event) ? "failed" : "success"; } function toolActivitySummary(toolName: string, phase: "start" | "end", outcome: "success" | "failed" | "cancelled" = "success"): string { if (phase === "start") { if (toolName === "read") return "Reading file"; if (toolName === "write") return "Writing file"; if (toolName === "edit") return "Editing file"; return `${toolName} started`; } if (outcome === "cancelled") return `${toolName} cancelled`; if (outcome === "failed") return `${toolName} failed`; if (toolName === "read") return "Read file"; if (toolName === "write") return "Wrote file"; if (toolName === "edit") return "Edited file"; return `${toolName} finished`; } function safeToolInput(toolName: string, args: unknown): unknown | undefined { if (!args || typeof args !== "object" || Array.isArray(args)) return undefined; const object = args as Record; if (toolName === "bash" && typeof object.command === "string") return { command: redactForAgentTick(object.command, 2000) }; if (toolName === "read") return pickDefined(object, ["path", "offset", "limit"]); if (toolName === "edit" || toolName === "write") return pickDefined(object, ["path"]); return undefined; } function pickDefined(object: Record, keys: string[]): Record | undefined { const picked: Record = {}; for (const key of keys) if (object[key] !== undefined) picked[key] = object[key]; return Object.keys(picked).length ? picked : undefined; } function redactToolDetail(value: unknown, maxChars: number): string { const text = typeof value === "string" ? value : JSON.stringify(value ?? null, null, 2); return truncateText(redactForAgentTick(text, maxChars), maxChars); } function toolActivityDetail(toolName: string, event: unknown, config: StatusToolActivityConfig, phase: "start" | "end", outcome: "success" | "failed" | "cancelled" = "success", rememberedInput?: unknown): Record | undefined { if (config.visibility !== "details") return undefined; const detail: Record = { toolName, phase }; const safeInput = safeToolInput(toolName, toolArgsFromEvent(event)) ?? rememberedInput; if (safeInput !== undefined) detail.input = safeInput; if (phase === "end") { detail.outcome = outcome; detail.isError = outcome === "failed"; if (outcome === "failed") detail.result = redactToolDetail(toolResultFromEvent(event), config.maxDetailChars); } return Object.keys(detail).length > 2 || phase === "end" ? detail : undefined; } function toolActivityBaseInput(toolName: string, event: unknown, config: StatusToolActivityConfig, phase: "start" | "end", outcome: "success" | "failed" | "cancelled" = "success"): CreateToolActivityInput { const state = phase === "start" ? "started" : "finished"; return { toolName, state, ...(state === "finished" ? { outcome } : {}), ...(toolCallIdFromEvent(event) ? { toolCallId: toolCallIdFromEvent(event) } : {}), ...(config.visibility === "summaries" || config.visibility === "details" ? { summary: toolActivitySummary(toolName, phase, outcome) } : {}), metadata: { event: phase === "start" ? "tool_execution_start" : "tool_execution_end", }, ...(phase === "start" ? { startedAt: new Date().toISOString() } : { finishedAt: new Date().toISOString() }), }; } function contextUsageFromPi(ctx: unknown) { const usage = typeof ctx === "object" && ctx && "getContextUsage" in ctx ? (ctx as { getContextUsage?: () => unknown }).getContextUsage?.() : undefined; if (!usage || typeof usage !== "object") return undefined; const candidate = usage as { tokens?: unknown; contextWindow?: unknown; percent?: unknown }; const tokens = candidate.tokens; const contextWindow = candidate.contextWindow; const percent = candidate.percent; if (!(tokens === null || (typeof tokens === "number" && Number.isFinite(tokens) && tokens >= 0))) return undefined; if (!(typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0)) return undefined; if (!(percent === null || (typeof percent === "number" && Number.isFinite(percent) && percent >= 0))) return undefined; return { tokens: tokens === null ? null : Math.floor(tokens), contextWindow: Math.floor(contextWindow), percent }; } function shouldKeepTaskSummaryPrivate(config: AgentTickExtensionConfig): boolean { return config.status.messageMirroring.enabled && config.status.messageMirroring.contentMode === "private"; } function shouldSuppressRoutineLifecycleStatus(config: AgentTickExtensionConfig): boolean { return config.status.messageMirroring.enabled; } function messageForHook( hook: StatusHookConfig, config: AgentTickExtensionConfig, lastTaskSummary: string | undefined, ): string { if (hook.message === "task-summary") { return config.status.includeTaskSummary && !shouldKeepTaskSummaryPrivate(config) && lastTaskSummary ? `Working on: ${lastTaskSummary}` : FALLBACK_MESSAGES[hook.state]; } if (hook.message === "none") return FALLBACK_MESSAGES[hook.state]; return hook.message || FALLBACK_MESSAGES[hook.state]; } function configuredHook(config: AgentTickExtensionConfig, name: string): StatusHookConfig | undefined { const hook = config.status.hooks[name]; return hook?.send ? hook : undefined; } export function registerStatusHooks( pi: ExtensionAPI, reporter: StatusReporterLike | null, config: AgentTickExtensionConfig, sessionContext?: AgentTickSessionContext, ): void { if (!reporter || !config.status.enabled) return; const maybeOn = (pi as PiWithOn).on; if (typeof maybeOn !== "function") return; let lastTaskSummary: string | undefined; let heartbeatStartedForRun = false; let sessionStartedForRun = false; const publicTaskSummary = () => shouldKeepTaskSummaryPrivate(config) ? undefined : lastTaskSummary; const startSession = (ctx?: unknown, title = publicTaskSummary()) => { if (sessionStartedForRun) return; sessionStartedForRun = true; sessionContext?.startRun(title, piSessionIdFromContext(ctx)); }; const on = (event: string, handler: (...args: any[]) => any) => maybeOn.call(pi, event, handler); const startHeartbeat = () => { if (!config.status.heartbeat.enabled || heartbeatStartedForRun) return; heartbeatStartedForRun = true; const message = config.status.heartbeat.message === "task-summary" ? messageForHook({ send: true, state: "working", message: "task-summary" }, config, lastTaskSummary) : config.status.heartbeat.message === "none" ? FALLBACK_MESSAGES.working : config.status.heartbeat.message; reporter.startHeartbeat(message, config.status.heartbeat.intervalMs); }; const stopHeartbeat = () => { heartbeatStartedForRun = false; reporter.stopHeartbeat(); }; const beforeAgentStart = configuredHook(config, "before_agent_start"); if (beforeAgentStart || config.status.heartbeat.enabled) { on("before_agent_start", (event: any, ctx?: unknown) => { lastTaskSummary = summarizeTaskPrompt(event?.prompt, config.status.taskSummaryMaxChars); sessionStartedForRun = false; startSession(ctx); if (beforeAgentStart && !shouldSuppressRoutineLifecycleStatus(config)) { reporter.send(beforeAgentStart.state, messageForHook(beforeAgentStart, config, lastTaskSummary), { metadata: { event: "before_agent_start" }, }); } startHeartbeat(); }); } const agentStart = configuredHook(config, "agent_start"); if (agentStart || config.status.heartbeat.enabled) { on("agent_start", (_event: unknown, ctx?: unknown) => { startSession(ctx); if (agentStart && !shouldSuppressRoutineLifecycleStatus(config)) { reporter.send(agentStart.state, messageForHook(agentStart, config, lastTaskSummary), { metadata: { event: "agent_start" }, }); } startHeartbeat(); }); } const mirrorConfig = config.status.messageMirroring; if (mirrorConfig.enabled) { on("message_end", (event: any, ctx?: unknown) => { const role = messageRole(event); if (role !== "assistant" && role !== "user") return; if (role === "assistant" && mirrorConfig.sendAssistant === "off") return; if (role === "user" && !mirrorConfig.sendUser) return; if (!mirrorConfig.includeToolUseTurns && hasToolUseStop(event)) return; const reason = stopReason(event); if (role === "assistant" && mirrorConfig.sendAssistant === "final-only" && reason && !["stop", "length", "error", "aborted"].includes(reason)) return; const rawBody = messageContentText(event?.message ?? event, role, mirrorConfig.includeThinking); if (!rawBody.trim()) return; void reporter.sendMessageStatus({ role, body: truncateText(rawBody, mirrorConfig.maxBodyChars), preview: truncateText(collapseWhitespace(rawBody), mirrorConfig.previewMaxChars), config: mirrorConfig, ...(mirrorConfig.includeContextUsage ? { contextUsage: contextUsageFromPi(ctx) } : {}), }); }); } const toolActivityConfig = config.status.toolActivity; if (toolActivityConfig.enabled && toolActivityConfig.visibility !== "off") { const toolInputsByCallId = new Map(); on("tool_execution_start", (event: any) => { const toolName = toolNameFromEvent(event); if (shouldIgnoreToolActivity(toolName)) return; const toolCallId = toolCallIdFromEvent(event); const safeInput = safeToolInput(toolName, toolArgsFromEvent(event)); if (toolCallId && safeInput !== undefined) toolInputsByCallId.set(toolCallId, safeInput); const detail = toolActivityDetail(toolName, event, toolActivityConfig, "start", "success", safeInput); void reporter.sendToolActivity({ ...toolActivityBaseInput(toolName, event, toolActivityConfig, "start"), ...(detail ? { detail } : {}), }); }); on("tool_execution_end", (event: any) => { const toolName = toolNameFromEvent(event); if (shouldIgnoreToolActivity(toolName)) return; const outcome = toolOutcomeFromEvent(event); const toolCallId = toolCallIdFromEvent(event); const rememberedInput = toolCallId ? toolInputsByCallId.get(toolCallId) : undefined; if (toolCallId) toolInputsByCallId.delete(toolCallId); const detail = toolActivityDetail(toolName, event, toolActivityConfig, "end", outcome, rememberedInput); void reporter.sendToolActivity({ ...toolActivityBaseInput(toolName, event, toolActivityConfig, "end", outcome), ...(detail ? { detail } : {}), }); }); } const turnEnd = configuredHook(config, "turn_end"); if (turnEnd) { on("turn_end", () => { if (!shouldSuppressRoutineLifecycleStatus(config)) { reporter.sendTurnCompleted(messageForHook(turnEnd, config, lastTaskSummary)); } }); } const agentEnd = configuredHook(config, "agent_end"); if (agentEnd || config.status.heartbeat.enabled) { on("agent_end", () => { stopHeartbeat(); sessionStartedForRun = false; if (agentEnd && !shouldSuppressRoutineLifecycleStatus(config)) { reporter.send(agentEnd.state, messageForHook(agentEnd, config, lastTaskSummary), { metadata: { event: "agent_end" }, }); } }); } const sessionShutdown = configuredHook(config, "session_shutdown"); if (sessionShutdown || config.status.heartbeat.enabled) { on("session_shutdown", async () => { stopHeartbeat(); sessionStartedForRun = false; if (sessionShutdown && !shouldSuppressRoutineLifecycleStatus(config)) { await reporter.sendAsync(sessionShutdown.state, messageForHook(sessionShutdown, config, lastTaskSummary), { metadata: { event: "session_shutdown" }, }); } }); } }