/** * work-timer — WORK ALL DAY mode * * For long-running project builds / autonomous AI exploration: * set a work duration (in 0.5-hour units) and the agent will not stop until * the deadline — even after finishing every task in the prompt, the extension * injects a short "keep working" user message to chain the next turn. * Lower bound guaranteed, upper bound unlimited (the turn that crosses the * deadline runs to completion). * * Commands (/wad is a shorthand alias for /workallday): * /wad 4 start, 4 units = 2 hours * /wad status (remaining time, continuations, rules, guides) * /wad off stop * /wad resume resume after Esc-abort pause * /wad rules list persistent rules * /wad rule add add a persistent rule (also: /wad rules ) * /wad rule del remove rule #n * /wad rule clear remove all rules * /wad guides list guidance prompts * /wad guide add add guidance for subsequent work (also: /wad guide ) * /wad guide del remove guide #n * /wad guide clear remove all guides * * Persistent injection (fights instruction-following decay on long runs): * Ok, keep working. Remember: * - * Focus next: * - * * Behavior: * - Esc abort (stopReason=aborted) pauses the loop; /wad resume continues * - Model errors do NOT pause the loop: retries are unlimited until the deadline * - HTTP 413 (Request Entity Too Large) triggers automatic /compact so work can * continue with a shrunk context; after 3 failed compactions the loop pauses * - Live countdown: banner/footer/title refresh on a timer, not just at turn edges * - State persists in the session (survives /reload and /resume; expired state is dropped) * - While active: fire banner widget above the editor, custom working indicator, * footer countdown, and terminal title */ import { Type } from "@earendil-works/pi-ai"; import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem } from "@earendil-works/pi-tui"; const UNIT_MS = 30 * 60 * 1000; // 0.5 hour const TICK_MS = 15 * 1000; // countdown refresh interval const ENTRY_TYPE = "work-timer-state"; const COMPACT_MAX_FAILURES = 3; interface PersistedState { active: boolean; deadline?: number; nudgeCount?: number; rules?: string[]; guides?: string[]; } export default function (pi: ExtensionAPI) { let deadline: number | null = null; // non-null = mode enabled let paused = false; // Esc-abort protective pause let nudgeCount = 0; // auto-injected continuations so far let rules: string[] = []; // persistent norms injected with every nudge let guides: string[] = []; // persistent direction prompts injected with every nudge let compactNeeded = false; // a 413 was seen; compact before the next nudge let compactFailures = 0; // consecutive failed compaction attempts let lastCtx: ExtensionContext | null = null; // freshest ctx for the ticker let ticker: ReturnType | null = null; const active = () => deadline !== null; const remainingMs = () => (deadline === null ? 0 : Math.max(0, deadline - Date.now())); function fmt(ms: number): string { const min = Math.ceil(ms / 60000); const h = Math.floor(min / 60); return h > 0 ? `${h}h ${min % 60}m` : `${min}m`; } function persist() { const data: PersistedState = active() ? { active: true, deadline: deadline!, nudgeCount, rules, guides } : { active: false, rules, guides }; pi.appendEntry(ENTRY_TYPE, data); } function buildNudge(): string { let text = "Ok, keep working."; if (rules.length > 0) { text += " Remember:\n" + rules.map((r) => `- ${r}`).join("\n"); } if (guides.length > 0) { text += "\nFocus next:\n" + guides.map((g) => `- ${g}`).join("\n"); } return text; } // ─── Visuals: banner widget + footer status + title + indicator ── function refreshVisuals(ctx: ExtensionContext) { if (!ctx.hasUI) return; if (!active()) { ctx.ui.setWidget("workallday", undefined); ctx.ui.setStatus("workallday", undefined); ctx.ui.setTitle("pi"); ctx.ui.setWorkingIndicator(); return; } const t = ctx.ui.theme; const fire = t.fg("warning", "🔥"); const header = `${fire} ${t.bold(t.fg("accent", "WORK ALL DAY"))} ${fire} ` + t.fg( "muted", `${fmt(remainingMs())} left · ${nudgeCount} continuation${nudgeCount === 1 ? "" : "s"}` + (paused ? " · PAUSED" : ""), ); const lines = [header]; if (rules.length > 0) { lines.push(t.fg("dim", `rules: ${rules.map((r, i) => `${i + 1}.${r}`).join(" ")}`)); } if (guides.length > 0) { lines.push(t.fg("dim", `guides: ${guides.map((g, i) => `${i + 1}.${g}`).join(" ")}`)); } ctx.ui.setWidget("workallday", lines); ctx.ui.setStatus("workallday", `⏱ ${fmt(remainingMs())}${paused ? " (paused)" : ""}`); ctx.ui.setTitle(`🔥 WORK ALL DAY — ${fmt(remainingMs())} left`); ctx.ui.setWorkingIndicator({ frames: ["🔥", "🔥.", "🔥..", "🔥..."].map((f) => t.fg("warning", f)), intervalMs: 200, }); } // ─── Live countdown ticker (session-scoped, idempotent) ───────── function startTicker() { if (ticker) return; ticker = setInterval(() => { if (!active() || !lastCtx) { stopTicker(); return; } refreshVisuals(lastCtx); }, TICK_MS); } function stopTicker() { if (ticker) { clearInterval(ticker); ticker = null; } } // ─── Loop control ──────────────────────────────────────────────── function resumeLoop() { nudgeCount++; persist(); if (lastCtx) refreshVisuals(lastCtx); try { pi.sendUserMessage(buildNudge()); } catch { pi.sendUserMessage(buildNudge(), { deliverAs: "followUp" }); } } function stopMode(ctx: ExtensionContext, message: string) { deadline = null; paused = false; nudgeCount = 0; compactNeeded = false; compactFailures = 0; stopTicker(); persist(); refreshVisuals(ctx); ctx.ui.notify(message, "info"); } // ─── Tool: let the agent check remaining time to pace itself ───── pi.registerTool( defineTool({ name: "work_mode_status", label: "Work Mode Status", description: "Check WORK ALL DAY mode status and remaining time. While this mode is active, do not stop iterating until the deadline.", promptSnippet: "Check WORK ALL DAY remaining time", parameters: Type.Object({}), async execute() { if (!active()) { return { content: [{ type: "text", text: "WORK ALL DAY mode is not active." }], details: { active: false }, }; } const text = paused ? `WORK ALL DAY is paused (~${fmt(remainingMs())} left), waiting for the user to /wad resume.` : `WORK ALL DAY active: ~${fmt(remainingMs())} left (${nudgeCount} auto-continuations so far). Keep iterating; do not wrap up before the deadline.`; return { content: [{ type: "text", text }], details: { active: true, paused, remainingMs: remainingMs(), deadline, nudgeCount, rules, guides, }, }; }, }), ); // ─── Rule / guide list management (shared logic) ──────────────── function listText(label: string, items: string[], hint: string): string { return items.length > 0 ? [`${label}:`, ...items.map((r, i) => ` ${i + 1}. ${r}`)].join("\n") : `No ${label.toLowerCase()} yet. Add one: ${hint}`; } function handleListCommand( list: string[], label: string, body: string, ctx: ExtensionCommandContext, ) { const kw = body.match(/^(add|del|rm|clear)\b\s*([\s\S]*)$/i); if (!kw) { // forgiving default: the whole body is the item text list.push(body); persist(); refreshVisuals(ctx); ctx.ui.notify(`${label} #${list.length} added: "${body}"`, "info"); return; } const sub = kw[1].toLowerCase(); const rest = kw[2].trim(); if (sub === "add") { if (!rest) { ctx.ui.notify(`Usage: /wad ${label.toLowerCase()} add `, "error"); return; } list.push(rest); persist(); refreshVisuals(ctx); ctx.ui.notify(`${label} #${list.length} added: "${rest}"`, "info"); return; } if (sub === "del" || sub === "rm") { const i = Number(rest); if (!Number.isInteger(i) || i < 1 || i > list.length) { ctx.ui.notify(`Invalid ${label.toLowerCase()} number. You have ${list.length}.`, "error"); return; } const [removed] = list.splice(i - 1, 1); persist(); refreshVisuals(ctx); ctx.ui.notify(`Removed ${label.toLowerCase()} #${i}: "${removed}"`, "info"); return; } if (sub === "clear" && (!rest || rest.toLowerCase() === "all")) { list.length = 0; persist(); refreshVisuals(ctx); ctx.ui.notify(`All ${label.toLowerCase()} cleared.`, "info"); return; } // e.g. "clear cache before deploy" reads better as an item than as a typo list.push(body); persist(); refreshVisuals(ctx); ctx.ui.notify(`${label} #${list.length} added: "${body}"`, "info"); } // ─── Command (/workallday + /wad alias) ────────────────────────── const workCommand = { description: "WORK ALL DAY: <0.5h units> start | off | resume | rule(s) | guide(s) (alias: /wad)", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const candidates = [ "off", "resume", "rules", "rule add ", "rule del ", "rule clear", "guides", "guide add ", "guide del ", "guide clear", ]; const items = candidates .filter((c) => c.startsWith(prefix)) .map((c) => ({ value: c, label: c.trim() })); return items.length > 0 ? items : null; }, handler: async (args: string, ctx: ExtensionCommandContext) => { lastCtx = ctx; const raw = args.trim(); const lower = raw.toLowerCase(); // status if (!raw) { if (!active()) { ctx.ui.notify( "WORK ALL DAY is off. Usage: /wad <0.5h units>, e.g. /wad 4 = 2 hours", "info", ); return; } const lines = [ `🔥 WORK ALL DAY — ${fmt(remainingMs())} left, ${nudgeCount} auto-continuation${nudgeCount === 1 ? "" : "s"}` + (paused ? " (PAUSED, /wad resume)" : ""), listText("Rules", rules, "/wad rule add "), listText("Guides", guides, "/wad guide add "), ]; ctx.ui.notify(lines.join("\n"), "info"); return; } // stop if (lower === "off" || lower === "stop") { stopMode(ctx, "WORK ALL DAY stopped."); return; } // resume if (lower === "resume") { if (!active()) { ctx.ui.notify("Mode is not active. Start it first: /wad <0.5h units>", "warning"); return; } paused = false; startTicker(); refreshVisuals(ctx); ctx.ui.notify("Resumed — auto-continuation will resume after this turn.", "info"); return; } // rules / guides management (forgiving: "rules " also adds) const listMatch = raw.match(/^(rules?|guides?)\s*([\s\S]*)$/i); if (listMatch) { const isGuide = listMatch[1].toLowerCase().startsWith("guide"); const body = listMatch[2].trim(); if (!body) { ctx.ui.notify( isGuide ? listText("Guides", guides, "/wad guide add ") : listText("Rules", rules, "/wad rule add "), "info", ); return; } handleListCommand(isGuide ? guides : rules, isGuide ? "Guide" : "Rule", body, ctx); return; } // start const units = Number(raw); if (!Number.isFinite(units) || units <= 0) { ctx.ui.notify("Usage: /wad <0.5h units>, e.g. /wad 4 = 2 hours", "error"); return; } deadline = Date.now() + units * UNIT_MS; paused = false; nudgeCount = 0; compactNeeded = false; compactFailures = 0; persist(); startTicker(); refreshVisuals(ctx); ctx.ui.notify( `🔥 WORK ALL DAY started: ${units * 0.5} hour(s), until ${new Date(deadline).toLocaleTimeString()}. The agent will not stop before the deadline.`, "info", ); }, }; pi.registerCommand("workallday", workCommand); pi.registerCommand("wad", { ...workCommand, description: "Alias for /workallday" }); // ─── 413 detection: mark for compaction ───────────────────────── pi.on("after_provider_response", (event) => { if (event.status === 413) compactNeeded = true; }); pi.on("agent_end", async (event, ctx) => { lastCtx = ctx; const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant"); if (!lastAssistant) return; // fallback 413 detection via the surfaced error message if (lastAssistant.stopReason === "error") { const msg = (lastAssistant as { errorMessage?: string }).errorMessage ?? ""; if (/\b413\b|Request Entity Too Large/i.test(msg)) compactNeeded = true; return; } // only an explicit Esc abort pauses the loop if (lastAssistant.stopReason === "aborted" && active() && !paused) { paused = true; refreshVisuals(ctx); ctx.ui.notify( "Output aborted (Esc) — WORK ALL DAY paused. /wad resume to continue, /wad off to stop.", "warning", ); } }); // ─── Core loop: chain the next turn whenever the agent settles ─── pi.on("agent_settled", async (_event, ctx) => { lastCtx = ctx; if (!active() || paused) return; // deadline reached: stop automatically if (Date.now() >= deadline!) { const total = nudgeCount; stopMode( ctx, `⏱ Time's up — WORK ALL DAY stopped automatically (${total} auto-continuation${total === 1 ? "" : "s"}).`, ); return; } // another extension already started a new run if (!ctx.isIdle()) return; // 413 → compact the context first, then continue the loop if (compactNeeded) { compactNeeded = false; ctx.ui.notify( "⚠️ 413 Request Entity Too Large — compacting context to keep working…", "warning", ); ctx.compact({ customInstructions: "Preserve the current goal, work progress, important decisions, and concrete next steps so autonomous work can continue seamlessly.", onComplete: () => { compactFailures = 0; if (active() && !paused) resumeLoop(); }, onError: (error) => { compactFailures++; if (compactFailures >= COMPACT_MAX_FAILURES) { paused = true; refreshVisuals(ctx); ctx.ui.notify( `Compaction failed ${COMPACT_MAX_FAILURES} times (${error.message}). WORK ALL DAY paused — run /compact manually, then /wad resume.`, "error", ); } else if (active() && !paused) { resumeLoop(); // retry; a recurring 413 re-triggers compaction } }, }); return; } // unlimited retries: errors and idle spins still chain the next turn resumeLoop(); }); // ─── Session lifecycle: restore persisted state ────────────────── pi.on("session_start", async (_event, ctx) => { lastCtx = ctx; deadline = null; paused = false; nudgeCount = 0; rules = []; guides = []; compactNeeded = false; compactFailures = 0; // replay persisted records in order; the latest one wins for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { const d = entry.data as PersistedState | undefined; rules = d?.rules ?? []; guides = d?.guides ?? []; if (d?.active && typeof d.deadline === "number" && d.deadline > Date.now()) { deadline = d.deadline; nudgeCount = d.nudgeCount ?? 0; } else { deadline = null; } } } if (active()) { ctx.ui.notify(`🔥 WORK ALL DAY restored — ${fmt(remainingMs())} left.`, "info"); startTicker(); } refreshVisuals(ctx); }); pi.on("session_shutdown", async () => { stopTicker(); lastCtx = null; }); // refresh the countdown banner at each turn boundary pi.on("turn_start", async (_event, ctx) => { lastCtx = ctx; if (active()) refreshVisuals(ctx); }); }