/** * Claude-style `/loop` for Pi (P0 dynamic + P1). * * Runs a task iteratively. Pi has no native ScheduleWakeup/cron, so the * primitive is inverted: the model decides the next cadence by calling the * `loop_schedule` tool we register, and THIS extension materializes the wake * with setTimeout, re-injecting the iteration prompt via pi.sendUserMessage. * The loop lives in the extension's Node process. * * P0 scope: * - commands: /loop , /loop stop [id], /loop status [id] * - tools: loop_schedule(delaySeconds, reason), loop_stop(reason) * - engine: fireWake / scheduleWake / startLoop / stopLoop * - state: activeLoops Map + persistence via pi.appendEntry("loop-state", ...) * - rehydrate on session_start (no double-fire; single catch-up tick) * - cleanup on session_shutdown (clearTimeout + abort + persist "stale") * - safety net on agent_end * - status line * * P1 scope (all additive over P0; P0 behavior unchanged): * - fixed-interval mode: `/loop ` where interval matches * ^\d+(s|m|h)$ (the LAST token). No interval = dynamic (P0). In fixed mode the * extension owns the period: re-arm after each iteration with an ABSOLUTE * timestamp (nextFireAt += periodMs) via a re-armed setTimeout (never setInterval, * so iterations never overlap). loop_schedule is an informative NO-OP in fixed * mode (the model only decides continue/stop; the period is fixed). * - full state machine: running|paused|stopped|done|failed|stale, with * `/loop pause [id]` and `/loop resume [id]`. * - irreversible-action gate via pi.on("tool_call"): a per-loop `autopilot` flag * is set when fireWake injects (the turn was triggered by a wake, not the user) * and cleared on agent_end. While autopilot is active, destructive tools matching * a conservative allowlist are confirmed (if UI) or blocked (if no UI). * - time/budget caps: maxWallClockMs (absolute deadline) in addition to * maxIterations, plus a best-effort ctx.getContextUsage() percent threshold. * Checked BEFORE re-arming (in fireWake and agent_end). Hitting a cap -> stop * with status "done" + notify. * - robust persistence: an ATOMIC sidecar JSON (temp+rename) with `updatedAt` in * addition to appendEntry; on rehydrate the newer of {last JSONL entry, sidecar} * wins by updatedAt. Dual-root: .pi/loops//state.json if trusted, else * getAgentDir()/loops///state.json. Single catch-up tick kept. * - safety net on agent_end also covers fixed mode (period re-arm) and the caps. * * P2 scope (all additive over P0/P1; P0/P1 behavior unchanged): * - multi-loop FIFO wake queue: with N live loops their setTimeout callbacks could * fire (near-)simultaneously and each call sendUserMessage, racing for the turn. A * module-level FIFO of pending wakes serializes them: a wake is DELIVERED only when * ctx.isIdle() AND no autopilot wake is already in flight this turn; otherwise it is * enqueued and drained on the next agent_end. Guarantees exactly ONE autopilot turn * at a time. loop_schedule/loop_stop resolve the loop that OWNS the current turn (the * one whose autopilot flag is set), robustened for N coexisting loops. * - autonomous mode: `/loop auto [interval]` — a loop with NO user task in the * conventional sense; the re-injected text is a sentinel generated by the extension * (a recurring objective). Start REQUIRES ctx.isProjectTrusted() AND an explicit * ctx.ui.confirm; missing either → reject. (Documented at handleAutoStart.) The trust * gate also holds on RE-ENTRY: rehydrate retires an autonomous loop (terminal "stopped") * if the project is no longer trusted, so a once-confirmed autonomous loop can never keep * firing unattended across reloads in a project that has since lost trust. * - GC of old terminal state: sweep sidecar state.json dirs for loops in a TERMINAL * status (done/stopped/failed) whose updatedAt is older than GC_MAX_AGE_MS, mirroring * dynamic-workflows getRunDirs. Runs on session_start (and an explicit sweep). NEVER * touches live loops (running/paused/stale). * - anti-zombie watchdog: a backstop ABOVE maxWallClockMs. A periodic module-level * sweep force-stops (done + cleanup) any loop that blew past a HARD deadline * (startedAt + WATCHDOG_HARD_DEADLINE_MS) — catching loops that hung without the * normal caps/agent_end firing. Healthy loops are never killed. * * Hard rules: * - print gate: ctx.mode === "print" → notify + reject. * - clamp delaySeconds to [60, 3600] INSIDE execute() (do not trust the model). * - the cadence heuristic lives in loop_schedule promptGuidelines, not in code. * - no new deps (typebox is already present). * - defaults: maxIterations = 25; on "fork" do NOT migrate the loop. * - never re-inject outside tui/rpc. */ import * as crypto from "node:crypto"; import { existsSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, getAgentDir, type ToolCallEvent, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { capExceeded } from "./caps.js"; import { destructiveReason } from "./gate.js"; import { formatInterval, parseInterval } from "./interval.js"; import { notify } from "./notify.js"; import { makeLoopIterationPrompt } from "./prompt.js"; import { collectLatestByKey } from "./session-state.js"; import { formatStatus } from "./status.js"; import { formatEta } from "./time.js"; const LOOP_STATE_TYPE = "loop-state"; const LOOP_STATUS_KEY = "loop"; const LOOP_DIR = "loops"; const STATE_FILE = "state.json"; const DEFAULT_MAX_ITERATIONS = 25; // Hard ceiling on simultaneously-active loops (running/paused). Bounds unbounded timer/ // state accumulation from repeated /loop starts — each loop owns a setTimeout, so without // this a user could grow activeLoops without limit. New starts past the cap are refused; // rehydrate of already-created loops is deliberately exempt (it recovers existing state). const MAX_CONCURRENT_LOOPS = 20; // Treat a persisted cap as valid only if it is a finite number > 0; otherwise fall back to // the default. Defends rehydrate against a corrupt/tampered sidecar where `0`/NaN/undefined // would slip past `??` (which only replaces null/undefined) and silently disable a cap // (maxWallClockMs<=0 voids the deadline; a missing maxIterations makes `iter >= undefined` // always-false, voiding the iteration gate). Call sites add per-cap shaping: Math.trunc for // the integer iteration count, and a Math.min(.,100) clamp for the percentage cap. const positiveOr = (value: unknown, dflt: number): number => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : dflt; const MIN_DELAY_SECONDS = 60; const MAX_DELAY_SECONDS = 3600; // Safety-net cadence when a turn closed without the model calling loop_schedule. const SAFETY_NET_DELAY_SECONDS = 1500; // P1 caps. maxWallClockMs is an absolute deadline measured from startedAt; the // budget threshold is a best-effort fraction of the context window (getContextUsage). const DEFAULT_MAX_WALL_CLOCK_MS = 6 * 60 * 60 * 1000; // 6h absolute deadline by default. const DEFAULT_CONTEXT_PERCENT_CAP = 90; // stop if getContextUsage().percent exceeds this. // P2 GC: terminal (done/stopped/failed) sidecar state dirs older than this are swept. // Live states (running/paused/stale) are NEVER swept regardless of age. const GC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days. // P2 watchdog: an ABSOLUTE backstop above maxWallClockMs. A loop that somehow blew past // startedAt + this (e.g. hung, caps never fired) is force-stopped (done + cleanup). It is // deliberately generous (well beyond the 6h default deadline) so it only catches zombies. const WATCHDOG_HARD_DEADLINE_MS = 25 * 60 * 60 * 1000; // 25h. type LoopMode = "dynamic" | "fixed"; type LoopStatus = "running" | "paused" | "stopped" | "done" | "failed" | "stale"; interface LoopState { loopId: string; task: string; mode: LoopMode; /** Fixed-mode period in ms (0/undefined for dynamic). The extension owns this. */ intervalMs?: number; iteration: number; maxIterations: number; /** Absolute wall-clock deadline (epoch ms): stop once Date.now() exceeds it. */ maxWallClockMs: number; /** Best-effort context-usage percent cap (stop if getContextUsage().percent exceeds). */ contextPercentCap: number; startedAt: number; nextFireAt: number | null; lastReason?: string; status: LoopStatus; /** * Autonomous mode (P2): true when this loop has no conventional user task; the * re-injected text is a sentinel generated by the extension (a recurring objective). * Start requires trust + an explicit confirm. Persisted so it survives a reload. */ autonomous?: boolean; /** Ultracode posture: lean on dynamic workflows to drive the work (prompt-injection only). */ ultracode?: boolean; /** ISO timestamp of the last write; used to resolve JSONL-vs-sidecar conflicts. */ updatedAt: string; } interface ActiveLoop extends LoopState { timer: ReturnType | null; controller: AbortController; /** True once a wake was (re)armed in the current turn; reset on each fire. */ rearmedThisTurn: boolean; /** True while the CURRENT turn was triggered by a wake (fireWake), not the user. */ autopilot: boolean; /** * Transient: ms left on the dynamic timer when paused, so resume re-arms with the * remainder. null = "fire immediately" (was at iteration boundary). Not persisted. */ pausedRemainingMs?: number | null; /** * Transient (fixed mode): absolute timestamp the in-flight iteration was scheduled * for. The next re-arm is fixedAnchor + period, so the cadence never drifts even if * an iteration runs long. Not persisted. */ fixedAnchor?: number; } // Calca activeRuns de dynamic-workflows: fuente de verdad de "qué timers viven AHORA". const activeLoops = new Map(); // --------------------------------------------------------------------------- // FIFO wake queue (P2): serialize autopilot turns across N loops // --------------------------------------------------------------------------- /** * A pending autopilot wake. When several loops' timers fire at (about) the same time, * each would otherwise call sendUserMessage and race for the turn. We serialize them: * only ONE wake is delivered at a time, and only when the agent is idle and no autopilot * turn is already in flight. The rest queue here, FIFO, and drain on agent_end. */ interface PendingWake { loopId: string; } // Module-level FIFO of wakes waiting to be delivered. Order = arrival order. const wakeQueue: PendingWake[] = []; // True from the moment a wake is delivered until the turn it triggered ends (agent_end). // While true, no further wake is delivered (one autopilot turn at a time). let autopilotTurnInFlight = false; /** Is the loop that owns the in-flight autopilot turn still present and running? */ function inFlightOwnerAlive(): boolean { for (const loop of activeLoops.values()) { if (loop.autopilot && loop.status === "running") return true; } return false; } // --------------------------------------------------------------------------- // Pure leaves extracted to depth-one siblings: // ./prompt.ts — makeLoopIterationPrompt // ./interval.ts — parseInterval / formatInterval // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Status line // --------------------------------------------------------------------------- function setLoopStatus(ctx: ExtensionContext, loop: LoopState): void { if (!ctx.hasUI) return; const theme = ctx.ui.theme; const paused = loop.status === "paused" ? " paused" : ""; const fixed = loop.mode === "fixed" && loop.intervalMs ? ` @${formatInterval(Math.round(loop.intervalMs / 1000))}` : ""; const eta = loop.status === "running" && loop.nextFireAt ? ` next ${formatEta(loop.nextFireAt)}` : ""; const reason = loop.lastReason ? ` · ${loop.lastReason}` : ""; ctx.ui.setStatus( LOOP_STATUS_KEY, `${theme.fg("accent", "↻ loop")} ${theme.fg("dim", `it ${loop.iteration}/${loop.maxIterations}${fixed}${paused}${eta}${reason}`)}`, ); } function clearLoopStatus(ctx: ExtensionContext): void { if (ctx.hasUI) ctx.ui.setStatus(LOOP_STATUS_KEY, undefined); } /** Refresh status from whatever loop is currently running or paused (if any). */ function refreshLoopStatus(ctx: ExtensionContext): void { if (!ctx.hasUI) return; // Prefer a running loop; fall back to a paused one so the user keeps seeing it. for (const loop of activeLoops.values()) { if (loop.status === "running") { setLoopStatus(ctx, loop); return; } } for (const loop of activeLoops.values()) { if (loop.status === "paused") { setLoopStatus(ctx, loop); return; } } clearLoopStatus(ctx); } // --------------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------------- function snapshot(loop: ActiveLoop): LoopState { return { loopId: loop.loopId, task: loop.task, mode: loop.mode, intervalMs: loop.intervalMs, iteration: loop.iteration, maxIterations: loop.maxIterations, maxWallClockMs: loop.maxWallClockMs, contextPercentCap: loop.contextPercentCap, startedAt: loop.startedAt, nextFireAt: loop.nextFireAt, lastReason: loop.lastReason, status: loop.status, autonomous: loop.autonomous, ultracode: loop.ultracode, updatedAt: loop.updatedAt, }; } /** * Persist a loop transition. Stamps `updatedAt` (so JSONL vs sidecar conflicts * resolve by recency), appends to the session JSONL (does NOT go to the LLM), and * fire-and-forgets an ATOMIC sidecar write that covers a hard crash where the * JSONL might miss the last append. */ function persist(pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop): void { loop.updatedAt = new Date().toISOString(); const snap = snapshot(loop); pi.appendEntry(LOOP_STATE_TYPE, snap); // Best-effort atomic sidecar (never throws into the engine). void writeSidecar(ctx, snap).catch(() => {}); } // --- Atomic sidecar (P1) ------------------------------------------------------- /** * Dual-root state dir, mirroring dynamic-workflows getRunRoot: * - trusted project → /.pi/loops/ * - otherwise → /loops// */ function loopStateDir(ctx: ExtensionContext, loopId: string): string { if (ctx.isProjectTrusted()) return path.join(ctx.cwd, CONFIG_DIR_NAME, LOOP_DIR, loopId); const projectHash = crypto.createHash("sha1").update(ctx.cwd).digest("hex").slice(0, 12); return path.join(getAgentDir(), LOOP_DIR, projectHash, loopId); } /** Atomic write: temp file then rename, so a crash mid-write never truncates state.json. */ async function writeSidecar(ctx: ExtensionContext, state: LoopState): Promise { const dir = loopStateDir(ctx, state.loopId); await fs.mkdir(dir, { recursive: true }); const file = path.join(dir, STATE_FILE); const temp = `${file}.${crypto.randomBytes(6).toString("hex")}.tmp`; await fs.writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, "utf8"); try { await fs.rename(temp, file); } catch (err) { await fs.rm(temp, { force: true }).catch(() => {}); throw err; } } /** Read a sidecar state.json for a loopId, or undefined if missing/corrupt. */ async function readSidecar(ctx: ExtensionContext, loopId: string): Promise { try { const file = path.join(loopStateDir(ctx, loopId), STATE_FILE); const body = await fs.readFile(file, "utf8"); const data = JSON.parse(body) as LoopState; if (!data || typeof data.loopId !== "string") return undefined; return data; } catch { return undefined; } } /** Best-effort discovery of loopIds that exist only in sidecar state. */ async function discoverSidecarLoopIds(ctx: ExtensionContext): Promise { try { const dirents = await fs.readdir(loopStateRoot(ctx), { withFileTypes: true }); return dirents.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name); } catch { return []; } } // --------------------------------------------------------------------------- // Wake / scheduling // --------------------------------------------------------------------------- /** * A loop can only run where the agent loop is interactive enough to re-inject a * prompt and resume on its own: TUI and RPC. "print" is a one-shot, and "json" * is non-interactive (hasUI is true only in tui/rpc) — neither can sustain a * looping session. Mirrors wakeAgentForWorkflowResult in dynamic-workflows. */ function canLoopInMode(ctx: ExtensionContext): boolean { return ctx.mode === "tui" || ctx.mode === "rpc"; } /** * Low-level delivery primitive: actually re-inject a prompt into the session. Mirrors * wakeAgentForWorkflowResult (idle → steer; busy → followUp). Mode-gated. The FIFO queue * (deliverWake/drainWakeQueue) is the only caller; never call this directly to re-inject * an autopilot iteration (that would bypass serialization). * * NOTE (P2): drainWakeQueue now gates delivery on ctx.isIdle(), so the only reachable path * here is the idle (steer) branch; the followUp branch is retained defensively (a future * caller / different mode could still hit it) but the queue never delivers while busy. */ function wake(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void { // Mode gate: never re-inject outside tui/rpc (defends rehydrate paths too). if (!canLoopInMode(ctx)) return; if (ctx.isIdle()) pi.sendUserMessage(prompt); else pi.sendUserMessage(prompt, { deliverAs: "followUp" }); } /** * Try to deliver the next queued wake (P2). Delivers AT MOST ONE, and only when the * agent is idle (never mid-user-turn) AND no autopilot turn is already in flight — * guaranteeing a single autopilot turn at a time across N loops and never opening an * autopilot turn while the human still owns the turn. Anything else stays queued (FIFO) * and is retried on the next agent_end. Skips/drops queue entries whose loop is no longer * running (stopped/paused/gone) so a stale entry never re-injects. * * The iteration counter is advanced and the autopilot flag is armed HERE (at delivery), * not at enqueue time, so a queued-but-undelivered loop neither advances nor blocks the * destructive-action gate. */ function drainWakeQueue(pi: ExtensionAPI, ctx: ExtensionContext): void { if (!canLoopInMode(ctx)) return; // Deliver ONLY when the agent is idle: a wake injected during the user's own turn would // open an autopilot turn mid-human-turn (anyAutopilotActive() would then gate the human's // own destructive commands — violating "a human-driven turn is never gated"). If the agent // is busy, leave everything queued; agent_end re-drains once the turn ends and the agent is // idle again. if (!ctx.isIdle()) return; // One autopilot turn at a time: never deliver a second wake while a turn is already in // flight (its owning loop still running). This is the load-bearing serialization that // keeps N loops from each injecting in the same turn. if (autopilotTurnInFlight && inFlightOwnerAlive()) return; while (wakeQueue.length > 0) { const next = wakeQueue.shift()!; const loop = activeLoops.get(next.loopId); // Drop stale entries: the loop was stopped/paused/removed before its turn came up. if (loop?.status !== "running") continue; // Guards re-checked at delivery (state may have changed while queued). if (loop.iteration >= loop.maxIterations) { stopLoop(pi, ctx, loop.loopId, `reached maxIterations (${loop.maxIterations})`, "done"); notify(ctx, `Loop ${loop.loopId} stopped: reached maxIterations (${loop.maxIterations}).`, "warning"); continue; } const cap = capExceeded(ctx, loop); if (cap) { stopForCap(pi, ctx, loop, cap); continue; } deliverWake(pi, ctx, loop); return; // exactly one autopilot turn at a time. } } /** Deliver one loop's iteration: advance counter, arm autopilot, persist, re-inject. */ function deliverWake(pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop): void { loop.iteration += 1; // In fixed mode, remember the ABSOLUTE timestamp this iteration was scheduled for // so the next re-arm is previousTarget + period (drift-free). In dynamic mode the // model picks the next cadence, so clear the anchor. loop.fixedAnchor = loop.mode === "fixed" ? (loop.nextFireAt ?? Date.now()) : undefined; loop.nextFireAt = null; loop.rearmedThisTurn = false; // This turn is triggered by a wake (not the user): arm the autopilot gate and mark a // turn in flight so no other queued wake is delivered until this turn ends. loop.autopilot = true; autopilotTurnInFlight = true; persist(pi, ctx, loop); setLoopStatus(ctx, loop); wake(pi, ctx, makeLoopIterationPrompt(loop)); } /** Stop a loop because a cap was hit. Status "done" (a clean, expected end). */ function stopForCap(pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop, reason: string): void { stopLoop(pi, ctx, loop.loopId, reason, "done"); notify(ctx, `Loop ${loop.loopId} stopped: ${reason}.`, "warning"); } /** * Fire one iteration. Guards status, enforces maxIterations + caps, then ENQUEUES the * wake onto the module FIFO and tries to drain it. With N loops whose timers fire near * the same instant, this serializes them: drainWakeQueue delivers at most one autopilot * turn at a time; the rest stay queued and drain on agent_end. (P2 changed this from a * direct re-inject to enqueue+drain; the per-iteration bookkeeping moved to deliverWake.) */ function fireWake(pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop): void { loop.timer = null; if (loop.status !== "running") return; // This loop's timer fired, which means its PREVIOUS turn finished (the timer is only // armed at scheduleWake/rearmFixed/agent_end, i.e. after the turn). If this loop was // the in-flight autopilot owner, release the gate now so a fresh wake can be delivered // (covers paths where agent_end did not run between turns, e.g. tests / RPC edge). if (loop.autopilot) { loop.autopilot = false; autopilotTurnInFlight = false; } // Backstop: if THIS loop is itself a zombie (past the hard deadline), stop instead of // firing. (The caps gate below covers maxWallClockMs; this also catches loops whose // maxWallClockMs was somehow set absurdly high.) if (Date.now() - loop.startedAt >= WATCHDOG_HARD_DEADLINE_MS) { const reason = `watchdog: exceeded hard backstop deadline (${Math.round(WATCHDOG_HARD_DEADLINE_MS / 3600000)}h)`; stopLoop(pi, ctx, loop.loopId, reason, "done"); notify(ctx, `Loop ${loop.loopId} force-stopped by watchdog (zombie backstop).`, "warning"); return; } if (loop.iteration >= loop.maxIterations) { stopLoop(pi, ctx, loop.loopId, `reached maxIterations (${loop.maxIterations})`, "done"); notify(ctx, `Loop ${loop.loopId} stopped: reached maxIterations (${loop.maxIterations}).`, "warning"); return; } // Caps gate before doing any work: never fire an iteration past a deadline/budget. const cap = capExceeded(ctx, loop); if (cap) { stopForCap(pi, ctx, loop, cap); return; } // Enqueue this loop's wake (dedup: never queue the same loop twice concurrently) and // attempt delivery. deliverWake does iteration++/autopilot/persist/re-inject. // Only the loopId is queued: deliverWake rebuilds the prompt fresh via // makeLoopIterationPrompt(loop) at delivery (reflecting the just-incremented iteration), // so carrying a prompt string on the queue entry would be dead, stale data. if (!wakeQueue.some((w) => w.loopId === loop.loopId)) { wakeQueue.push({ loopId: loop.loopId }); } drainWakeQueue(pi, ctx); } /** Arm the next wake after delaySec. Caller is responsible for clamping. */ function scheduleWake( pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop, delaySec: number, reason: string, ): void { if (loop.timer) { clearTimeout(loop.timer); loop.timer = null; } loop.nextFireAt = Date.now() + delaySec * 1000; loop.lastReason = reason; loop.rearmedThisTurn = true; persist(pi, ctx, loop); setLoopStatus(ctx, loop); loop.timer = setTimeout(() => fireWake(pi, ctx, loop), delaySec * 1000); } /** * Fixed-mode re-arm (P1). The extension owns the cadence: schedule the next wake at * an ABSOLUTE timestamp (nextFireAt += periodMs) so periods never drift, and use a * re-armed setTimeout (never setInterval) so a slow iteration can never overlap the * next. If the absolute target is already in the past (a long iteration), fire on * the next tick (delay 0) — a single catch-up, never a burst. */ function rearmFixed(pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop): void { if (loop.timer) { clearTimeout(loop.timer); loop.timer = null; } const period = loop.intervalMs ?? 0; // Anchor to the previous scheduled fire time (set by fireWake) so periods never // drift; fall back to current nextFireAt (first arm / resume) or now. const base = loop.fixedAnchor ?? loop.nextFireAt ?? Date.now(); loop.fixedAnchor = undefined; const target = base + period; const delay = Math.max(0, target - Date.now()); loop.nextFireAt = target; loop.lastReason = `auto: fixed interval ${formatInterval(Math.round(period / 1000))}`; loop.rearmedThisTurn = true; persist(pi, ctx, loop); setLoopStatus(ctx, loop); loop.timer = setTimeout(() => fireWake(pi, ctx, loop), delay); } // --------------------------------------------------------------------------- // Start / stop // --------------------------------------------------------------------------- /** * Strip a `--ultracode` / `--uc` posture flag off the args (anywhere in the string). * Returns the cleaned text plus whether the flag was present. Parsed BEFORE the trailing * interval token so the flag is never mistaken for an interval. */ function extractUltracodeFlag(args: string): { rest: string; ultracode: boolean } { let ultracode = false; const kept: string[] = []; for (const token of args.split(/\s+/)) { const lower = token.toLowerCase(); if (lower === "--ultracode" || lower === "--uc") ultracode = true; else if (token.length) kept.push(token); } return { rest: kept.join(" "), ultracode }; } function startLoop(pi: ExtensionAPI, ctx: ExtensionContext, task: string): ActiveLoop | undefined { // Mode gate: only TUI/RPC can sustain a persistent looping session. if (!canLoopInMode(ctx)) { notify(ctx, "/loop requires a TUI or RPC session (this mode cannot loop).", "error"); return undefined; } const { rest: withoutFlag, ultracode } = extractUltracodeFlag(task); const trimmed = withoutFlag.trim(); if (!trimmed) { notify(ctx, "Usage: /loop [--ultracode] [interval]", "warning"); return undefined; } // Fixed-interval mode: the LAST whitespace-separated token may be an interval // (^\d+(s|m|h)$). If so, strip it and own the cadence; otherwise stay dynamic (P0). let taskText = trimmed; let intervalMs: number | undefined; const lastSpace = trimmed.lastIndexOf(" "); if (lastSpace !== -1) { const candidate = trimmed.slice(lastSpace + 1); const parsed = parseInterval(candidate); if (parsed !== null) { intervalMs = parsed; taskText = trimmed.slice(0, lastSpace).trim(); } } if (!taskText) { notify(ctx, "Usage: /loop [interval]", "warning"); return undefined; } if (activeLoops.size >= MAX_CONCURRENT_LOOPS) { notify( ctx, `Too many active loops (${activeLoops.size}/${MAX_CONCURRENT_LOOPS}). Stop one with /loop stop before starting another.`, "error", ); return undefined; } const loopId = crypto.randomBytes(4).toString("hex"); const loop: ActiveLoop = { loopId, task: taskText, mode: intervalMs ? "fixed" : "dynamic", intervalMs, iteration: 0, maxIterations: DEFAULT_MAX_ITERATIONS, maxWallClockMs: DEFAULT_MAX_WALL_CLOCK_MS, contextPercentCap: DEFAULT_CONTEXT_PERCENT_CAP, startedAt: Date.now(), nextFireAt: null, lastReason: undefined, status: "running", ultracode, updatedAt: new Date().toISOString(), timer: null, controller: new AbortController(), rearmedThisTurn: false, autopilot: false, }; activeLoops.set(loopId, loop); persist(pi, ctx, loop); // Send the first iteration prompt immediately. fireWake handles iteration++/persist/status. // deliverWake builds the prompt fresh via makeLoopIterationPrompt(loop), so it is never // stored on the loop — it would only ever be stale by the time it was read. fireWake(pi, ctx, loop); const modeLabel = loop.mode === "fixed" ? ` (every ${formatInterval(Math.round((intervalMs ?? 0) / 1000))})` : ""; const uc = ultracode ? " [ultracode]" : ""; notify(ctx, `Started loop ${loopId}${modeLabel}${uc}: ${taskText}`, "info"); return loop; } /** * Start an AUTONOMOUS loop (P2): a loop whose re-injected text is a sentinel/objective * generated by the extension rather than a one-off user task. Because such a loop will * keep acting without a human in the turn, the bar is higher: * - the project MUST be trusted (ctx.isProjectTrusted()), and * - the user MUST explicitly confirm via ctx.ui.confirm. * Missing EITHER → reject (no loop created). Without UI there is no way to confirm, so * autonomous mode is refused there too. Everything else (modes, caps, persistence, the * irreversible-action gate, the FIFO queue, the watchdog) is shared with startLoop. */ async function startAutonomousLoop( pi: ExtensionAPI, ctx: ExtensionContext, rawArgs: string, ): Promise { if (!canLoopInMode(ctx)) { notify(ctx, "/loop auto requires a TUI or RPC session (this mode cannot loop).", "error"); return undefined; } // Trust gate FIRST: an autonomous loop must never run in an untrusted project. if (!ctx.isProjectTrusted()) { notify(ctx, "/loop auto requires a trusted project. Run /trust first, then retry.", "error"); return undefined; } const { rest: withoutFlag, ultracode } = extractUltracodeFlag(rawArgs); const trimmed = withoutFlag.trim(); if (!trimmed) { notify(ctx, "Usage: /loop auto [--ultracode] [interval]", "warning"); return undefined; } // Strip an optional trailing interval token, same parser as startLoop. let objective = trimmed; let intervalMs: number | undefined; const lastSpace = trimmed.lastIndexOf(" "); if (lastSpace !== -1) { const parsed = parseInterval(trimmed.slice(lastSpace + 1)); if (parsed !== null) { intervalMs = parsed; objective = trimmed.slice(0, lastSpace).trim(); } } if (!objective) { notify(ctx, "Usage: /loop auto [interval]", "warning"); return undefined; } // Mandatory confirmation: no UI to confirm on → refuse (cannot get consent). if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") { notify( ctx, "/loop auto requires an interactive confirmation; run it from a TUI or RPC session instead.", "error", ); return undefined; } const approved = await ctx.ui.confirm( "Start an autonomous loop?", `This loop will act on its own (no user message each turn) to pursue:\n\n${objective}\n\nDestructive actions stay gated, but it will run unattended. Start it?`, ); if (!approved) { notify(ctx, "Autonomous loop not started (not confirmed).", "info"); return undefined; } if (activeLoops.size >= MAX_CONCURRENT_LOOPS) { notify( ctx, `Too many active loops (${activeLoops.size}/${MAX_CONCURRENT_LOOPS}). Stop one with /loop stop before starting another.`, "error", ); return undefined; } const loopId = crypto.randomBytes(4).toString("hex"); const loop: ActiveLoop = { loopId, task: objective, mode: intervalMs ? "fixed" : "dynamic", intervalMs, iteration: 0, maxIterations: DEFAULT_MAX_ITERATIONS, maxWallClockMs: DEFAULT_MAX_WALL_CLOCK_MS, contextPercentCap: DEFAULT_CONTEXT_PERCENT_CAP, startedAt: Date.now(), nextFireAt: null, lastReason: undefined, status: "running", autonomous: true, ultracode, updatedAt: new Date().toISOString(), timer: null, controller: new AbortController(), rearmedThisTurn: false, autopilot: false, }; activeLoops.set(loopId, loop); persist(pi, ctx, loop); fireWake(pi, ctx, loop); const modeLabel = loop.mode === "fixed" ? ` (every ${formatInterval(Math.round((intervalMs ?? 0) / 1000))})` : ""; notify(ctx, `Started autonomous loop ${loopId}${modeLabel}: ${objective}`, "info"); return loop; } /** * Resolve a loop by id, the unique candidate, or via ui.select. `statuses` filters * which loops are eligible (e.g. ["running"] for stop, ["running","paused"] for * status display). Defaults to running-only (preserves P0 stop/schedule behavior). */ async function resolveLoop( ctx: ExtensionContext, idOrUndef: string | undefined, statuses: LoopStatus[] = ["running"], ): Promise { if (idOrUndef) { const loop = activeLoops.get(idOrUndef); return loop && statuses.includes(loop.status) ? loop : undefined; } const candidates = [...activeLoops.values()].filter((l) => statuses.includes(l.status)); if (candidates.length === 0) return undefined; if (candidates.length === 1) return candidates[0]; if (ctx.hasUI) { const choice = await ctx.ui.select( "Which loop?", candidates.map((l) => `${l.loopId} — ${l.task}`), ); if (!choice) return undefined; const id = choice.split(" ")[0]; return activeLoops.get(id); } return undefined; } function stopLoop( pi: ExtensionAPI, ctx: ExtensionContext, loopId: string, reason: string, finalStatus: "stopped" | "done" | "failed" = "stopped", ): boolean { const loop = activeLoops.get(loopId); if (!loop) return false; if (loop.timer) { clearTimeout(loop.timer); loop.timer = null; } loop.controller.abort(reason); loop.status = finalStatus; loop.nextFireAt = null; loop.lastReason = reason; loop.autopilot = false; // Drop any pending wake for this loop so it can never re-inject after stopping. dropQueuedWakes(loopId); persist(pi, ctx, loop); // Terminal loops are no longer active: keep the persisted final snapshot for // audit/rehydrate decisions, but remove the in-memory loop immediately so // /loop status, completions, GC, and status-line refresh only see live loops. activeLoops.delete(loopId); refreshLoopStatus(ctx); return true; } /** Remove any queued wakes for a loop (used on stop/pause so they never deliver). */ function dropQueuedWakes(loopId: string): void { for (let i = wakeQueue.length - 1; i >= 0; i--) { if (wakeQueue[i].loopId === loopId) wakeQueue.splice(i, 1); } } /** * Pause a loop (P1): clear the timer, keep all state, set status "paused". Records * the remaining delay so resume (dynamic) can re-arm with what was left. Does NOT * re-inject. A no-op if the loop is not running. */ function pauseLoop(pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop): boolean { if (loop.status !== "running") return false; if (loop.timer) { clearTimeout(loop.timer); loop.timer = null; } // Preserve the remaining delay as a relative offset so resume can restore it even // across a persist/rehydrate (we re-derive nextFireAt on resume from this). loop.pausedRemainingMs = loop.nextFireAt === null ? null : Math.max(0, loop.nextFireAt - Date.now()); loop.status = "paused"; loop.autopilot = false; // Drop any pending wake so a paused loop never re-injects from the queue. dropQueuedWakes(loop.loopId); persist(pi, ctx, loop); refreshLoopStatus(ctx); return true; } /** * Resume a paused loop (P1): status back to "running" and re-arm. Dynamic loops use * the remaining delay captured at pause (fallback: a safety-net cadence if unknown); * fixed loops re-arm by their owned period. A no-op if the loop is not paused. */ function resumeLoop(pi: ExtensionAPI, ctx: ExtensionContext, loop: ActiveLoop): boolean { if (loop.status !== "paused") return false; loop.status = "running"; if (loop.mode === "fixed") { // Fixed: anchor the next absolute fire at now + period (drift-free from resume). loop.nextFireAt = Date.now(); rearmFixed(pi, ctx, loop); return true; } // Prefer the remainder captured at pause (same-process pause/resume). If that is // gone (e.g. the loop was paused, persisted, then rehydrated across a reload — // pausedRemainingMs is transient and NOT persisted), fall back to the persisted // absolute nextFireAt, which DOES survive a reload (mirrors what rehydrate does for // running loops). Only when neither is available do we use the safety-net cadence. const remaining = loop.pausedRemainingMs != null ? loop.pausedRemainingMs : loop.nextFireAt != null ? Math.max(0, loop.nextFireAt - Date.now()) : SAFETY_NET_DELAY_SECONDS * 1000; loop.pausedRemainingMs = undefined; scheduleWake(pi, ctx, loop, Math.round(remaining / 1000), "resumed by user"); return true; } // --------------------------------------------------------------------------- // Rehydration (session_start) // --------------------------------------------------------------------------- /** * Pick the newer of two snapshots by updatedAt (ISO strings compare lexically since * they share format; missing updatedAt is treated as oldest). Used to resolve a * JSONL-vs-sidecar conflict — whichever was written last wins. */ function newerState(a: LoopState | undefined, b: LoopState | undefined): LoopState | undefined { if (!a) return b; if (!b) return a; const ta = a.updatedAt ?? ""; const tb = b.updatedAt ?? ""; return tb > ta ? b : a; } /** * Rebuild loop state and re-arm. Source of truth per loopId is the NEWER of the last * JSONL entry and the atomic sidecar (by updatedAt), covering a hard crash where the * JSONL might miss the last append. Avoids double-fire: if activeLoops already has the * loop (timer alive in this process), skip. Only a SINGLE catch-up tick — no burst. * Recovers "paused" loops as paused (no re-arm). Respects caps (never re-arm past one). */ async function rehydrate(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const entries = ctx.sessionManager.getEntries(); const latestJsonl = collectLatestByKey(entries, LOOP_STATE_TYPE, (d) => d.loopId); // Resolve each loopId against its sidecar (newer-by-updatedAt wins). Also include // sidecar-only loopIds: the sidecar is specifically the crash-recovery fallback for // a transition that reached state.json but did not make it into the session JSONL. const resolved = new Map(); const sidecarLoopIds = await discoverSidecarLoopIds(ctx); for (const loopId of new Set([...latestJsonl.keys(), ...sidecarLoopIds])) { const jsonlState = latestJsonl.get(loopId); const sidecar = await readSidecar(ctx, loopId); const winner = newerState(jsonlState, sidecar); if (winner) resolved.set(loopId, winner); } for (const state of resolved.values()) { // "running" = was live in a prior process; "stale" = persisted by a clean // session_shutdown (reload/quit); "paused" = recover and keep paused. // Anything else (stopped/done/failed) is terminal → skip. if (state.status !== "running" && state.status !== "stale" && state.status !== "paused") continue; // Timer still alive in this process → do not re-arm (no double-fire). if (activeLoops.has(state.loopId)) continue; // AUTONOMOUS re-entry gate (P2 security): an autonomous loop acts with no human in // the turn, so its start required trust + an explicit confirm. That guarantee must // hold on EVERY re-entry, not just the interactive start — otherwise a once-confirmed // autonomous loop would keep firing unattended across reloads even after the project // stops being trusted. If the project is no longer trusted, retire it (terminal // "stopped") instead of re-arming it. (A trusted project still rehydrates it; we do // not re-prompt confirm on rehydrate because there is no interactive user at // session_start, and trust is the load-bearing gate for unattended action.) if (state.autonomous && !ctx.isProjectTrusted()) { const retired: ActiveLoop = { ...state, mode: state.mode ?? "dynamic", maxIterations: positiveOr(Math.trunc(state.maxIterations), DEFAULT_MAX_ITERATIONS), maxWallClockMs: positiveOr(state.maxWallClockMs, DEFAULT_MAX_WALL_CLOCK_MS), contextPercentCap: Math.min(positiveOr(state.contextPercentCap, DEFAULT_CONTEXT_PERCENT_CAP), 100), updatedAt: state.updatedAt ?? new Date().toISOString(), status: "stopped", timer: null, controller: new AbortController(), rearmedThisTurn: false, autopilot: false, }; activeLoops.set(retired.loopId, retired); stopLoop(pi, ctx, retired.loopId, "autonomous loop retired: project no longer trusted", "stopped"); continue; } const recoverPaused = state.status === "paused"; const loop: ActiveLoop = { ...state, // Back-compat for pre-P1 snapshots missing the new fields. mode: state.mode ?? "dynamic", maxIterations: positiveOr(Math.trunc(state.maxIterations), DEFAULT_MAX_ITERATIONS), maxWallClockMs: positiveOr(state.maxWallClockMs, DEFAULT_MAX_WALL_CLOCK_MS), contextPercentCap: Math.min(positiveOr(state.contextPercentCap, DEFAULT_CONTEXT_PERCENT_CAP), 100), updatedAt: state.updatedAt ?? new Date().toISOString(), // Normalize a recovered "stale" snapshot back to "running"; keep "paused" as-is. status: recoverPaused ? "paused" : "running", timer: null, controller: new AbortController(), rearmedThisTurn: false, autopilot: false, }; activeLoops.set(loop.loopId, loop); // Paused loops are recovered idle (no timer) until /loop resume. if (recoverPaused) continue; // A cap already exceeded across the downtime → stop cleanly instead of re-arming. const cap = capExceeded(ctx, loop); if (cap) { stopForCap(pi, ctx, loop, cap); continue; } const remaining = loop.nextFireAt === null ? 0 : Math.max(0, loop.nextFireAt - Date.now()); // Single catch-up tick (clamped to >= 0); never a burst of missed wakes. loop.timer = setTimeout(() => fireWake(pi, ctx, loop), remaining); } refreshLoopStatus(ctx); // Backstop sweep: a loop that was hung across the downtime (past the hard deadline) // is force-stopped here rather than being re-armed into another zombie iteration. watchdogSweep(pi, ctx); } // --------------------------------------------------------------------------- // GC of old terminal state (P2) // --------------------------------------------------------------------------- /** * Root that holds per-loop sidecar dirs, mirroring loopStateDir's parent: * - trusted project → /.pi/loops * - otherwise → /loops/ * (Same split as dynamic-workflows getRunRoot, so GC walks the right tree.) */ function loopStateRoot(ctx: ExtensionContext): string { if (ctx.isProjectTrusted()) return path.join(ctx.cwd, CONFIG_DIR_NAME, LOOP_DIR); const projectHash = crypto.createHash("sha1").update(ctx.cwd).digest("hex").slice(0, 12); return path.join(getAgentDir(), LOOP_DIR, projectHash); } const TERMINAL_STATUSES: ReadonlySet = new Set(["done", "stopped", "failed"]); /** * Sweep old terminal sidecar dirs (P2). For every //state.json, parse it and * remove the dir ONLY when the loop is in a terminal status (done/stopped/failed) AND its * updatedAt is older than GC_MAX_AGE_MS. Live loops (running/paused/stale) and loops still * present in activeLoops are NEVER removed regardless of age. Best-effort: any read/parse/ * rm error is swallowed so GC can never crash the session. Returns how many dirs it removed. * * Mirrors dynamic-workflows getRunDirs (readdir withFileTypes + stat), but the recency * decision uses the persisted updatedAt (not dir mtime) so a clock-skewed FS can't make us * delete fresh state — and we still require a TERMINAL status, so a live loop is safe even * if its state.json is ancient. */ async function gcOldTerminalLoops(ctx: ExtensionContext, now: number = Date.now()): Promise { const root = loopStateRoot(ctx); if (!existsSync(root)) return 0; let removed = 0; let dirents: import("node:fs").Dirent[]; try { dirents = await fs.readdir(root, { withFileTypes: true }); } catch { return 0; } for (const dirent of dirents) { if (!dirent.isDirectory()) continue; const loopId = dirent.name; // Never GC a loop that is live in this process (timer may be armed). if (activeLoops.has(loopId)) continue; const dir = path.join(root, loopId); const file = path.join(dir, STATE_FILE); try { const body = await fs.readFile(file, "utf8"); const state = JSON.parse(body) as LoopState; if (!state || typeof state.status !== "string") continue; // Only terminal states are eligible; live states are preserved indefinitely. if (!TERMINAL_STATUSES.has(state.status)) continue; const updated = state.updatedAt ? Date.parse(state.updatedAt) : NaN; // Require a parseable, sufficiently-old updatedAt before deleting. if (!Number.isFinite(updated) || now - updated < GC_MAX_AGE_MS) continue; await fs.rm(dir, { recursive: true, force: true }); removed += 1; } catch { // Missing/corrupt state.json or rm failure → skip (never throw into the engine). } } return removed; } // --------------------------------------------------------------------------- // Anti-zombie watchdog (P2) // --------------------------------------------------------------------------- /** * Force-stop any loop that blew past its ABSOLUTE backstop deadline (P2). This is a * last-resort net ABOVE maxWallClockMs / the context cap: those are checked at re-arm * time, so a loop that hung WITHOUT reaching an agent_end (or whose caps somehow never * fired) could otherwise live forever. Here we hard-stop (done + full cleanup) any * RUNNING loop whose startedAt + WATCHDOG_HARD_DEADLINE_MS is in the past. * Healthy loops (well within the deadline) are untouched. Returns count force-stopped. * * PAUSED loops are NOT zombies and are deliberately excluded: a paused loop has no armed * timer and consumes nothing — it is intentionally idle waiting for /loop resume, a fully * legitimate state. Its wall-clock since startedAt keeps growing while paused, so measuring * it against startedAt would kill a healthy paused loop behind the user's back (e.g. paused * over a weekend). A paused loop only becomes watchdog-eligible after it resumes (status * back to "running"). The soft wall-clock cap (capExceeded) already never fires on a paused * loop, so excluding paused here keeps the hard backstop consistent with it. * * No dedicated periodic timer: the sweep is driven from the natural pulse points * (session_start after rehydrate, every agent_end, and each fireWake). That avoids * adding an orthogonal module timer (and is just as effective — a dead process would * not run a periodic timer anyway; recovery happens on the next session_start). */ function watchdogSweep(pi: ExtensionAPI, ctx: ExtensionContext, now: number = Date.now()): number { let killed = 0; for (const loop of [...activeLoops.values()]) { // Only RUNNING loops can be zombies; a paused loop is intentionally idle (see docstring). if (loop.status !== "running") continue; if (now - loop.startedAt < WATCHDOG_HARD_DEADLINE_MS) continue; const reason = `watchdog: exceeded hard backstop deadline (${Math.round(WATCHDOG_HARD_DEADLINE_MS / 3600000)}h)`; stopLoop(pi, ctx, loop.loopId, reason, "done"); notify(ctx, `Loop ${loop.loopId} force-stopped by watchdog (zombie backstop).`, "warning"); killed += 1; } return killed; } // --------------------------------------------------------------------------- // Command handling // --------------------------------------------------------------------------- async function handleLoopCommand(pi: ExtensionAPI, args: string, ctx: ExtensionContext): Promise { const trimmed = args.trim(); const firstSpace = trimmed.indexOf(" "); const firstToken = (firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase(); const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim(); if (firstToken === "stop") { const loop = await resolveLoop(ctx, rest || undefined, ["running", "paused"]); if (!loop) { notify(ctx, "No matching loop to stop. Use /loop status to see active loops.", "warning"); return; } stopLoop(pi, ctx, loop.loopId, "stopped by user (/loop stop)", "stopped"); notify(ctx, `Stopped loop ${loop.loopId}.`, "info"); return; } if (firstToken === "pause") { const loop = await resolveLoop(ctx, rest || undefined, ["running"]); if (!loop) { notify(ctx, "No running loop to pause. Use /loop status to see active loops.", "warning"); return; } if (pauseLoop(pi, ctx, loop)) notify(ctx, `Paused loop ${loop.loopId}.`, "info"); else notify(ctx, `Loop ${loop.loopId} is not running.`, "warning"); return; } if (firstToken === "resume") { const loop = await resolveLoop(ctx, rest || undefined, ["paused"]); if (!loop) { notify(ctx, "No paused loop to resume. Use /loop status to see active loops.", "warning"); return; } if (resumeLoop(pi, ctx, loop)) notify(ctx, `Resumed loop ${loop.loopId}.`, "info"); else notify(ctx, `Loop ${loop.loopId} is not paused.`, "warning"); return; } if (firstToken === "auto") { // Autonomous mode (P2): requires trust + an explicit confirm (enforced inside). await startAutonomousLoop(pi, ctx, rest); return; } if (firstToken === "status") { if (rest) { const loop = activeLoops.get(rest); notify( ctx, loop ? formatStatus(loop) : `No loop with id ${rest}. Use /loop status to list active loops.`, loop ? "info" : "warning", ); return; } const all = [...activeLoops.values()]; if (all.length === 0) { notify(ctx, "No loops.", "info"); return; } notify(ctx, all.map(formatStatus).join("\n"), "info"); return; } // Otherwise: the whole args is the task (possibly with a trailing interval token). startLoop(pi, ctx, trimmed); } // --------------------------------------------------------------------------- // Irreversible-action gate (P1) — pure policy in ./gate.ts (destructiveReason); wiring below. // --------------------------------------------------------------------------- /** True if ANY loop currently considers this turn an autopilot (wake-triggered) turn. */ function anyAutopilotActive(): boolean { for (const loop of activeLoops.values()) { if (loop.autopilot && loop.status === "running") return true; } return false; } /** * tool_call handler (P1). Only gates when the current turn is autopilot (wake-triggered) * AND the tool/args match the destructive allowlist. With UI: ask ctx.ui.confirm and * block if rejected. Without UI (still tui/rpc edge, or confirm unavailable): hard block. * A human-driven turn (no autopilot flag) is never gated. */ async function handleToolCall( ctx: ExtensionContext, event: ToolCallEvent, ): Promise<{ block?: boolean; reason?: string } | undefined> { if (!anyAutopilotActive()) return undefined; const reason = destructiveReason(ctx, event); if (!reason) return undefined; if (ctx.hasUI && typeof ctx.ui.confirm === "function") { const approved = await ctx.ui.confirm( "Autopilot wants to run a destructive action", `${reason}\n\nThis loop iteration was triggered automatically (not by you). Allow it?`, ); if (approved) return undefined; return { block: true, reason }; } // No interactive UI to confirm → block to stay safe. return { block: true, reason }; } // --------------------------------------------------------------------------- // Extension entrypoint // --------------------------------------------------------------------------- export default function loopExtension(pi: ExtensionAPI): void { pi.registerTool({ name: "loop_schedule", label: "Loop Schedule", description: "Schedule the next iteration of the active /loop. Call this when more work or waiting is needed before the next pass.", promptSnippet: "Schedule the next /loop iteration with a delay and reason.", promptGuidelines: [ "Think about WHAT you are waiting for, not how long you want to sleep — then pick a cadence that matches it. The delay is clamped to [60, 3600] seconds.", "Use a short delay (<300s) to poll a fast-moving external state (e.g. a CI run, a deploy) while keeping a warm working cache, but never exactly 300s.", "Use a long delay (300-3600s) when you expect a slow change or are waiting on something that takes many minutes.", "If you are idle with no concrete signal to wait on, schedule a long fallback (1200-1800s) instead of busy-polling.", "Do NOT poll work that the harness already tracks for you (background jobs, subagents, workflows) — schedule a long fallback and let it report back.", "Always pass a one-sentence reason explaining what you chose and why; it is shown in the status line and re-injected into the next iteration for continuity.", ], parameters: Type.Object({ // No schema bounds on purpose: the SDK validates (and rejects) args // via validateToolArguments BEFORE execute() runs, so min/max here // would throw on an out-of-range value instead of letting us clamp. // The clamp inside execute() is the single defense — never trust the model. delaySeconds: Type.Number({ description: `Seconds to wait before the next iteration; clamped to [${MIN_DELAY_SECONDS}, ${MAX_DELAY_SECONDS}].`, }), reason: Type.String({ minLength: 3 }), }), executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const running = [...activeLoops.values()].filter((l) => l.status === "running"); if (running.length === 0) { return { content: [ { type: "text" as const, text: "No active loop to schedule. There is nothing to reschedule.", }, ], details: { isError: true }, }; } // Target the loop whose autopilot turn is actually calling this tool: the one // with autopilot set. This matters when a fixed and a dynamic loop coexist — // without it, a fixed loop's autopilot turn could reprogram the dynamic loop's // timer. Fall back to a running dynamic loop, then any running loop. const loop = running.find((l) => l.autopilot) ?? running.find((l) => l.mode === "dynamic") ?? running[0]; // Fixed mode: the extension owns the cadence, so loop_schedule is an // informative NO-OP — do not touch the timer or nextFireAt. The model only // decides continue (do nothing) vs stop (loop_stop) on a fixed interval. if (loop.mode === "fixed") { const periodSec = Math.round((loop.intervalMs ?? 0) / 1000); return { content: [ { type: "text" as const, text: `Loop ${loop.loopId} runs on a fixed interval (every ${formatInterval(periodSec)}); cadence is fixed and loop_schedule is a no-op. Reason noted: ${params.reason}.`, }, ], details: { loopId: loop.loopId, mode: "fixed", noop: true, intervalSeconds: periodSec }, }; } // Clamp INSIDE execute() — never trust the model's value. A non-finite // value (NaN/Infinity) falls back to the safety-net cadence instead of // arming setTimeout(NaN) (which would fire immediately). const raw = params.delaySeconds; const delaySec = Number.isFinite(raw) ? Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, Math.round(raw))) : SAFETY_NET_DELAY_SECONDS; scheduleWake(pi, ctx, loop, delaySec, params.reason); return { content: [ { type: "text" as const, text: `Scheduled next iteration of loop ${loop.loopId} in ${delaySec}s (reason: ${params.reason}).`, }, ], details: { loopId: loop.loopId, delaySeconds: delaySec, clampedFrom: raw !== delaySec ? raw : undefined, }, }; }, }); pi.registerTool({ name: "loop_stop", label: "Loop Stop", description: "End the active /loop. Call this when the task is complete or no further iterations help.", promptSnippet: "End the active /loop with a reason.", parameters: Type.Object({ reason: Type.String(), }), executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const running = [...activeLoops.values()].filter((l) => l.status === "running"); if (running.length === 0) { return { content: [{ type: "text" as const, text: "No active loop to stop." }], details: { isError: true }, }; } // Resolve the loop that OWNS the current turn (its autopilot flag is set), so // with N coexisting loops a wake-triggered loop_stop ends the RIGHT loop rather // than an arbitrary running[0]. Fall back to the sole running loop otherwise. const loop = running.find((l) => l.autopilot) ?? running[0]; stopLoop(pi, ctx, loop.loopId, params.reason || "stopped by loop_stop", "stopped"); return { content: [ { type: "text" as const, text: `Stopped loop ${loop.loopId} (reason: ${params.reason}).`, }, ], details: { loopId: loop.loopId }, }; }, }); pi.registerCommand("loop", { description: "Run a task iteratively: /loop [--ultracode] [interval] | /loop auto [--ultracode] [interval] | /loop stop [id] | /loop pause [id] | /loop resume [id] | /loop status [id]. Interval (e.g. 5m, 30s, 2h) runs on a fixed cadence; omit it for model-paced. 'auto' starts an autonomous loop (requires a trusted project + confirmation). --ultracode drives iterations via dynamic workflows.", getArgumentCompletions: (argumentPrefix: string) => { const items = [ { value: "auto", label: "auto", description: "Start an autonomous loop (trust + confirm)" }, { value: "stop", label: "stop", description: "Stop a loop" }, { value: "pause", label: "pause", description: "Pause a running loop" }, { value: "resume", label: "resume", description: "Resume a paused loop" }, { value: "status", label: "status", description: "Show loop status" }, { value: "--ultracode", label: "--ultracode", description: "Run loop iterations via dynamic workflows" }, ]; for (const loop of activeLoops.values()) { if (loop.status === "running" || loop.status === "paused") { items.push({ value: loop.loopId, label: loop.loopId, description: loop.task }); } } const prefix = argumentPrefix.trim().toLowerCase(); if (!prefix) return items; return items.filter((i) => i.value.toLowerCase().startsWith(prefix)); }, handler: async (args, ctx) => await handleLoopCommand(pi, args, ctx), }); // Irreversible-action gate (P1): block/confirm destructive tools on autopilot turns. pi.on("tool_call", async (event, ctx) => await handleToolCall(ctx, event)); pi.on("session_start", async (event, ctx) => { // Do NOT migrate a loop into a forked session: a fork inherits the parent's // "loop-state" entries, but the loop must keep running only in the parent. // startup / reload / resume DO rehydrate; "new" carries no parent entries. if (event.reason === "fork") return; await rehydrate(pi, ctx); // GC old terminal sidecar state (P2). Runs AFTER rehydrate so live loops are in // activeLoops and thus never collected. Best-effort; never throws into the engine. await gcOldTerminalLoops(ctx).catch(() => {}); }); pi.on("session_shutdown", async (_event, ctx) => { for (const loop of activeLoops.values()) { if (loop.timer) { clearTimeout(loop.timer); loop.timer = null; } loop.controller.abort("session shutdown"); if (loop.status === "running") { // Persist as "stale" (recoverable on next session_start), keeping nextFireAt intact. loop.status = "stale"; persist(pi, ctx, loop); } // "paused" is left as-is so it rehydrates as paused. Terminal states untouched. } // Clear the live in-memory set, queue, and in-flight gate: the persisted snapshots // above are the source of truth for the next session_start. Keeping stale/paused // ActiveLoop objects here would make a same-process reload skip rehydrate via // activeLoops.has(...) and leave no timer armed. activeLoops.clear(); wakeQueue.length = 0; autopilotTurnInFlight = false; clearLoopStatus(ctx); }); pi.on("agent_end", async (_event, ctx) => { // End of an autopilot turn: clear the per-loop autopilot flag (the next user turn // must not be gated). Then run the safety net (covers dynamic + fixed + caps). for (const loop of activeLoops.values()) { loop.autopilot = false; if (loop.status !== "running") continue; // Caps gate before any re-arm: if a deadline/budget is already exhausted, // stop cleanly instead of scheduling another iteration. const cap = capExceeded(ctx, loop); if (cap) { stopForCap(pi, ctx, loop, cap); continue; } if (loop.rearmedThisTurn) continue; if (loop.timer) continue; if (loop.mode === "fixed") { // Fixed mode: re-arm by the owned period (absolute timestamp, no overlap). rearmFixed(pi, ctx, loop); } else { // Dynamic mode: model did not call loop_schedule this turn → defensive re-arm. scheduleWake(pi, ctx, loop, SAFETY_NET_DELAY_SECONDS, "auto: turn closed without loop_schedule"); } } // The autopilot turn is over: release the in-flight gate and deliver the NEXT queued // wake (if any) — this is where loops that lost the race for this turn get theirs. autopilotTurnInFlight = false; drainWakeQueue(pi, ctx); // Opportunistic anti-zombie sweep at every turn boundary (cheap; the periodic // timer covers idle gaps). Force-stops any loop past its hard backstop deadline. watchdogSweep(pi, ctx); }); }