import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { buildAttentionEvent, nodeHookRunner, readHookPath, runAttentionHook } from "./attention.js"; import { parseLoopCommand } from "./command.js"; import { formatDuration, parseDuration } from "./duration.js"; import { applyDecision, beginIteration, deadlineOf, isTerminal, materializeDeadline, queueIteration, requestPause, requestStop, resumeLoop, settleWithoutDecision, startLoop, updateLoop, type AttentionStatus, type LoopDecision, type LoopStateV1 } from "./domain.js"; import { LoopScheduler, systemClock } from "./scheduler.js"; import { LOOP_STATE_ENTRY, restoreLoop, snapshot } from "./state.js"; const ATTENTION_ENTRY = "loop-attention"; const attentionStatuses = new Set(["completed", "failed", "blocked", "duration_limit", "iteration_limit", "protocol_error"]); function iterationEnvelope(state: LoopStateV1, now: number): string { const remaining = Math.max(0, deadlineOf(state) - now); const policy = state.policy.kind === "fixed" ? `fixed ${formatDuration(state.policy.delayMs)}` : `auto (faixa ${formatDuration(state.minDelayMs)}..${formatDuration(state.maxDelayMs)})`; return ["[loop iteration]", `Objetivo (verbatim): ${state.objective}`, `Iteração: ${state.iterationCount} de ${state.maxIterations}`, `Tempo de calendário decorrido: ${formatDuration(now - state.startedAt)}`, `Tempo até o prazo: ${formatDuration(remaining)}`, `Ritmo: ${policy}`, `Última decisão/resumo/evidência: ${state.lastDecision ? `${state.lastDecision.decision} — ${state.lastDecision.summary} — ${state.lastDecision.evidence}` : "nenhuma"}`, "", "Reavalie o objetivo com o estado atual. Não use polling do loop para um processo background que o harness já observa. Termine chamando loop_control como sua última ação."].join("\n"); } function statusText(state: LoopStateV1, now: number): string { const wake = state.nextWakeAt === undefined ? "—" : `${formatDuration(Math.max(0, state.nextWakeAt - now))} (${new Date(state.nextWakeAt).toISOString()})`; const policy = state.policy.kind === "fixed" ? `fixed ${formatDuration(state.policy.delayMs)}` : `auto ${formatDuration(state.minDelayMs)}..${formatDuration(state.maxDelayMs)}`; return [`estado: ${state.status}${state.pauseReason ? ` (${state.pauseReason})` : ""}`, `objetivo: ${state.objective}`, `ritmo: ${policy}`, `iterações: ${state.iterationCount}/${state.maxIterations}`, `retries de protocolo: ${state.protocolRetryCount}/5`, `prazo: ${new Date(deadlineOf(state)).toISOString()}`, `próximo despertar: ${wake}`, state.lastDecision ? `última decisão: ${state.lastDecision.decision} — ${state.lastDecision.summary}\nevidência: ${state.lastDecision.evidence}` : "última decisão: —"].join("\n"); } export default function loopExtension(pi: ExtensionAPI) { let state: LoopStateV1 | undefined; let unknownVersion: number | undefined; let sessionCtx: ExtensionContext | undefined; let shuttingDown = false; const scheduler = new LoopScheduler(systemClock); const persist = () => { if (state) pi.appendEntry(LOOP_STATE_ENTRY, snapshot(state)); }; const updateStatus = () => { if (!sessionCtx) return; if (!state) return sessionCtx.ui.setStatus("loop", unknownVersion === undefined ? undefined : `loop: versão ${unknownVersion} incompatível`); const suffix = state.status === "waiting" && state.nextWakeAt !== undefined ? ` · ${formatDuration(Math.max(0, state.nextWakeAt - Date.now()))}` : ""; sessionCtx.ui.setStatus("loop", `loop: ${state.status} ${state.iterationCount}/${state.maxIterations}${suffix}`); }; const emitAttention = async (status: AttentionStatus) => { if (!state || !sessionCtx) return; const event = buildAttentionEvent(state, status, { id: sessionCtx.sessionManager.getSessionId(), file: sessionCtx.sessionManager.getSessionFile(), cwd: sessionCtx.cwd }, Date.now()); pi.appendEntry(ATTENTION_ENTRY, event); sessionCtx.ui.notify(`Loop requer atenção: ${status} — ${event.summary}`, status === "completed" ? "info" : "warning"); const hook = await readHookPath(); if (hook) void runAttentionHook(nodeHookRunner, hook, event).catch((error) => sessionCtx?.ui.notify(`Hook do loop falhou: ${error instanceof Error ? error.message : String(error)}`, "warning")); }; const attentionFromState = (before: LoopStateV1 | undefined, after: LoopStateV1) => { const status = after.status === "paused" ? after.pauseReason : after.status; const prior = before?.status === "paused" ? before.pauseReason : before?.status; if (status !== prior && attentionStatuses.has(status as AttentionStatus)) void emitAttention(status as AttentionStatus); }; const commit = (next: LoopStateV1) => { const before = state; state = next; persist(); updateStatus(); attentionFromState(before, next); arm(); }; const onDeadline = () => { if (state) commit(materializeDeadline(state, Date.now())); }; const deliverIteration = () => { if (!state || !sessionCtx || shuttingDown) return; const queued = queueIteration(state, Date.now()); if (queued === state) return arm(); commit(queued); if (!state || state.status !== "queued") return; const projected = { ...state, iterationCount: state.iterationCount + 1 }; pi.sendUserMessage(iterationEnvelope(projected, Date.now()), sessionCtx.isIdle() ? undefined : { deliverAs: "followUp" }); }; function arm() { scheduler.cancel(); if (!state || shuttingDown || isTerminal(state.status)) return; const wakeAt = state.status === "waiting" ? state.nextWakeAt : undefined; scheduler.arm(wakeAt, deadlineOf(state), deliverIteration, onDeadline); } const requireState = () => { if (unknownVersion !== undefined) throw new Error(`Estado do loop usa versão incompatível: ${unknownVersion}.`); if (!state) throw new Error("Não há loop nesta sessão."); return state; }; pi.registerEntryRenderer(ATTENTION_ENTRY, (entry, _options, theme) => { const event = entry.data as { status?: string; summary?: string; evidence?: { source?: string; text?: string } }; return new Text(theme.fg(event.status === "completed" ? "success" : "warning", `⟳ loop ${event.status}: ${event.summary}\n evidência (${event.evidence?.source}): ${event.evidence?.text}`), 1, 0); }); pi.registerCommand("loop", { description: "Supervisiona continuamente um objetivo: start/status/pause/resume/stop/update", getArgumentCompletions: (prefix) => { const values = ["start --every ", "status", "pause", "resume", "stop", "update --for "]; const matches = values.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })); return matches.length ? matches : null; }, handler: async (args, ctx) => { try { const command = parseLoopCommand(args); if (command.command === "status") { ctx.ui.notify(state ? statusText(state, Date.now()) : unknownVersion !== undefined ? `Estado incompatível v${unknownVersion}.` : "Não há loop nesta sessão.", "info"); return; } if (command.command === "start") { if (state && !isTerminal(state.status)) throw new Error("Já existe um loop ativo ou pausado nesta sessão."); unknownVersion = undefined; commit(startLoop(command.options, Date.now())); if (command.options.afterMs > command.options.forMs * 0.75) ctx.ui.notify("A espera inicial consome mais de 75% do horizonte.", "warning"); ctx.ui.notify("Loop iniciado.", "info"); return; } const current = requireState(); if (command.command === "pause") { commit(requestPause(current)); ctx.ui.notify("Loop pausado.", "info"); } if (command.command === "resume") { commit(resumeLoop(current, Date.now())); ctx.ui.notify("Loop retomado.", "info"); } if (command.command === "stop") { commit(requestStop(current, Date.now())); ctx.ui.notify("Loop encerrado.", "info"); } if (command.command === "update") { commit(updateLoop(current, command.patch, Date.now())); ctx.ui.notify("Política do loop atualizada.", "info"); } } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerTool({ name: "loop_control", label: "Loop Control", description: "Decide como termina a iteração corrente do /loop. Deve ser a última ação da iteração.", promptSnippet: "Finalizar uma iteração ativa do /loop com decisão estruturada", promptGuidelines: ["Use loop_control como última ação de toda iteração iniciada por [loop iteration]. Não use loop_control fora dessas iterações."], parameters: Type.Object({ decision: StringEnum(["continue", "complete", "fail", "block"] as const), summary: Type.String({ minLength: 1 }), evidence: Type.String({ minLength: 1 }), nextDelay: Type.Optional(Type.String()) }), async execute(_id, params) { const current = requireState(); const decision: LoopDecision = params.decision === "continue" ? { decision: "continue", summary: params.summary, evidence: params.evidence, nextDelayMs: params.nextDelay === undefined ? undefined : parseDuration(params.nextDelay) } : { decision: params.decision, summary: params.summary, evidence: params.evidence }; commit(applyDecision(current, decision, Date.now())); return { content: [{ type: "text", text: `Decisão registrada: ${params.decision}.` }], details: { state: snapshot(requireState()) }, terminate: true }; }, }); const restore = (ctx: ExtensionContext, reload: boolean) => { sessionCtx = ctx; shuttingDown = false; const restored = restoreLoop(ctx.sessionManager.getBranch()); state = restored.state; unknownVersion = restored.unknownVersion; if (state) { const before = state; state = materializeDeadline(state, Date.now()); if (!reload && (state.status === "waiting" || state.status === "queued" || state.status === "running")) state = { ...state, status: "paused", pauseReason: "session_closed", nextWakeAt: undefined, pendingIntent: undefined }; if (state !== before) { persist(); attentionFromState(before, state); } } updateStatus(); arm(); }; pi.on("session_start", (event, ctx) => restore(ctx, event.reason === "reload")); pi.on("input", (event) => { if (event.source === "extension" && event.text.startsWith("[loop iteration]") && state?.status === "queued") commit(beginIteration(state, Date.now())); }); pi.on("agent_settled", () => { if (!state || state.status !== "running") return; const result = settleWithoutDecision(state, Date.now()); commit(result.state); if (result.retry) pi.sendUserMessage("[loop protocol retry]\nEsta iteração terminou sem loop_control válido. Termine agora chamando loop_control como sua última ação; não repita o trabalho já realizado.", { deliverAs: "followUp" }); }); const pauseForBranch = () => { if (state && !isTerminal(state.status) && state.status !== "paused") { commit({ ...state, status: "paused", pauseReason: "branch_changed", nextWakeAt: undefined, pendingIntent: undefined }); sessionCtx?.ui.notify("Loop pausado antes da mudança de branch.", "warning"); } }; pi.on("session_before_fork", pauseForBranch); pi.on("session_before_tree", pauseForBranch); pi.on("session_tree", (_event, ctx) => restore(ctx, false)); pi.on("session_shutdown", (event) => { shuttingDown = true; scheduler.cancel(); if (state && event.reason !== "reload" && !isTerminal(state.status)) { state = { ...state, status: "paused", pauseReason: event.reason === "fork" ? "branch_changed" : "session_closed", nextWakeAt: undefined, pendingIntent: undefined }; persist(); } sessionCtx?.ui.setStatus("loop", undefined); sessionCtx = undefined; }); }