import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, ToolInfo, } from "@earendil-works/pi-coding-agent"; import { Key, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { OPENPI_TOOL_SURFACE, patchOwnedTools, } from "../shared/tool-surface.ts"; import { TASKS_ENTRY_TYPE, TASKS_LIMITS, TASK_STATUSES, TaskRestoreError, applyTaskAdd, applyTaskUpdate, createSessionTasks, emptyTaskSnapshot, projectTasks, restoreTaskSnapshot, type TaskFilter, type TaskItem, type TaskSnapshot, } from "./tasks.ts"; import { openTasksScreen, TASK_WIDGET_LIMIT, renderTaskWidget, renderToolResult, taskCounts, type TaskToolDetails, } from "./ui.ts"; const TASK_WIDGET_KEY = "session-tasks-panel"; const CONFLICT_NAMES = new Set(["todo", "TodoWrite", "update_plan"]); const TOOL_PURPOSE = "Records session work intent. It does not execute, schedule, or delegate work."; export interface TaskConflict { name: string; source?: string; } export function findTaskConflict(tools: readonly ToolInfo[]) { const conflict = tools.find((tool) => CONFLICT_NAMES.has(tool.name)); if (!conflict) return undefined; const source = conflict.sourceInfo?.path || conflict.sourceInfo?.source; return { name: conflict.name, source } satisfies TaskConflict; } export function injectTaskProjection( messages: readonly unknown[], projection: string, ) { // Keep this pure for direct tests. Pi already supplies the context hook a // deep copy, so this is defensive rather than required by the runtime. const next = structuredClone(messages) as Array<{ role?: string; content?: unknown; }>; for (let index = next.length - 1; index >= 0; index--) { const message = next[index]; if (message.role !== "user") continue; const safeProjection = projection .replaceAll("", "[session-tasks]") .replaceAll("", "[/session-tasks]"); const block = { type: "text", text: `\n\n\n${safeProjection}\n`, }; if (typeof message.content === "string") { message.content = [{ type: "text", text: message.content }, block]; } else if (Array.isArray(message.content)) { message.content.push(block); } else { return undefined; } return next; } return undefined; } export function taskConflictMessage(conflict: TaskConflict) { return `Session tasks disabled because tool “${conflict.name}” is already registered${conflict.source ? ` by ${conflict.source}` : ""}. Disable the other Todo/plan extension and run /reload.`; } export default function sessionTasks(pi: ExtensionAPI) { let tasks = createSessionTasks(); let lockedReason: string | undefined; let conflict: TaskConflict | undefined; let toolsRegistered = false; let coldRun = true; let activeRun = false; let frozenProjection = ""; let notifiedProblem: string | undefined; let taskWidgetVisible = true; let taskWidgetExpanded = false; let taskWidgetMounted = false; let requestTaskWidgetRender: (() => void) | undefined; let ui: ExtensionContext["ui"] | undefined; let uiMode: ExtensionContext["mode"] | undefined; const hideLifecycleTools = () => patchOwnedTools(pi, "tasks", { disable: OPENPI_TOOL_SURFACE.tasks.deferred, }); const showLifecycleTools = () => patchOwnedTools(pi, "tasks", { enable: OPENPI_TOOL_SURFACE.tasks.deferred, }); const snapshot = () => tasks.snapshot(); const actionableTaskCount = () => snapshot().items.filter( (item) => item.status === "pending" || item.status === "in_progress" || item.status === "blocked", ).length; const hasActionableTasks = () => actionableTaskCount() > 0; const updateTaskWidget = (ctx?: ExtensionContext) => { if (ctx?.hasUI) { if (ui !== ctx.ui) { taskWidgetMounted = false; requestTaskWidgetRender = undefined; } ui = ctx.ui; uiMode = ctx.mode; } if (!ui || uiMode !== "tui") return false; const shown = taskWidgetVisible && !problemMessage() && hasActionableTasks(); if (!shown) { if (!taskWidgetMounted) return false; ui.setWidget(TASK_WIDGET_KEY, undefined); taskWidgetMounted = false; requestTaskWidgetRender = undefined; return false; } if (taskWidgetMounted) { requestTaskWidgetRender?.(); return true; } ui.setWidget(TASK_WIDGET_KEY, (tui, theme) => { requestTaskWidgetRender = () => tui.requestRender(); return { render: (width) => renderTaskWidget(snapshot(), theme, width, taskWidgetExpanded), invalidate() {}, }; }); taskWidgetMounted = true; return true; }; const taskWidgetFeedback = (shown: boolean) => shown ? "Task panel shown." : uiMode !== "tui" ? "Task panel is available only in interactive TUI mode." : taskWidgetVisible ? "Task panel enabled; it will appear when active tasks exist." : "Task panel hidden."; const restore = (ctx: ExtensionContext) => { try { tasks = createSessionTasks( restoreTaskSnapshot(ctx.sessionManager.getBranch()), ); lockedReason = undefined; } catch (error) { tasks = createSessionTasks(emptyTaskSnapshot()); lockedReason = error instanceof TaskRestoreError || error instanceof Error ? error.message : String(error); } }; const problemMessage = () => { if (conflict) return taskConflictMessage(conflict); if (lockedReason) { return `Session tasks are locked because their newest snapshot is invalid: ${lockedReason}. Navigate to a clean branch or start a new session.`; } return undefined; }; const notifyProblem = (ctx: ExtensionContext) => { const problem = problemMessage(); if (!problem) { notifiedProblem = undefined; return; } if (ctx.hasUI && notifiedProblem !== problem) { notifiedProblem = problem; ctx.ui.notify(problem, "warning"); } }; const assertAvailable = () => { const problem = problemMessage(); if (problem) throw new Error(problem); }; const persistThenCommit = (candidate: TaskSnapshot) => { if (candidate.revision === snapshot().revision) return false; // Keep this path synchronous. An await here would let sibling tool calls // reorder state and persistence, recreating the upstream Todo lost-update bug. pi.appendEntry(TASKS_ENTRY_TYPE, candidate); tasks.commit(candidate); updateTaskWidget(); return true; }; const toolDetails = ( action: TaskToolDetails["action"], items: TaskItem[], batchClosed = false, ): TaskToolDetails => ({ action, items, total: snapshot().items.length, revision: snapshot().revision, counts: taskCounts(snapshot().items), ...(batchClosed ? { batchClosed: true } : {}), }); const mutationResultText = (summary: string) => { const current = snapshot(); return `${summary}\nCurrent task snapshot (${current.items.length} ${current.items.length === 1 ? "item" : "items"}):\n${tasks.render()}`; }; const registerTools = () => { if (toolsRegistered || conflict) return; toolsRegistered = true; pi.registerTool({ name: "tasks_add", label: "Tasks Add", description: `${TOOL_PURPOSE} Add one or more stable-ID items to the current Pi session tasks. Use this only for work spanning multiple agent runs or user turns, or for an explicit user task list. When every item in a batch reaches done/dropped the batch closes and the list clears; the next tasks_add starts a fresh batch numbered from T1, so a T-id only identifies work within its own batch (past evidence remains in the session history, not this list).`, promptSnippet: "Add stable work-intent items to the current session tasks", promptGuidelines: [ "Use tasks_add only for work spanning multiple agent runs or user turns, or when the user explicitly provides a task list; do not use it as a per-step scratchpad within one run.", "Before starting each tracked item, call tasks_update to mark it in_progress; concurrent work may have multiple in_progress items.", "Task tools record advisory intent only; Subagents and Workflows execute work, while files, git, tests, tool results, artifacts, and user confirmation remain truth.", ], parameters: Type.Object({ items: Type.Array( Type.Object({ subject: Type.String({ minLength: 1, maxLength: TASKS_LIMITS.subjectChars, description: "Short imperative description.", }), detail: Type.Optional( Type.String({ maxLength: TASKS_LIMITS.detailChars }), ), }), { minItems: 1, maxItems: TASKS_LIMITS.addBatch }, ), }), execute(_id, params) { assertAvailable(); const mutation = applyTaskAdd(snapshot(), params.items); persistThenCommit(mutation.snapshot); showLifecycleTools(); return Promise.resolve({ content: [ { type: "text" as const, text: mutationResultText( `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`, ), }, ], details: toolDetails("add", snapshot().items), }); }, renderCall(args, theme) { return new Text( `${theme.fg("toolTitle", theme.bold("tasks_add"))} ${theme.fg("muted", `${args.items.length} item(s)`)}`, 0, 0, ); }, renderResult(result, options, theme) { const first = result.content[0]; return renderToolResult( result.details, options.expanded, theme, first?.type === "text" ? first.text : "Tasks unavailable.", ); }, }); pi.registerTool({ name: "tasks_update", label: "Tasks Update", description: `${TOOL_PURPOSE} Patch one task item by numeric ID. blocked, done, and dropped status changes require a fresh note explaining the blocker, observable evidence, or drop reason.`, promptSnippet: "Update one session task item by stable ID", promptGuidelines: [ "Immediately after each tracked item reaches a real outcome, call tasks_update to set done, blocked, or dropped before moving to the next tracked item.", "Before sending a final answer, reconcile every task touched in the current request; do not leave completed work pending or in_progress.", "A commit, passing test, or authorization is task-scoped evidence only; it does not by itself prove a task is done or identify which task to update.", "Before setting a task item to done, include a note citing an observable check, artifact, commit, tool result, or user confirmation; Tasks record this claim but do not verify it.", ], parameters: Type.Object({ id: Type.Integer({ minimum: 1 }), subject: Type.Optional( Type.String({ minLength: 1, maxLength: TASKS_LIMITS.subjectChars }), ), detail: Type.Optional( Type.Union([ Type.String({ maxLength: TASKS_LIMITS.detailChars }), Type.Null(), ]), ), status: Type.Optional(StringEnum(TASK_STATUSES)), note: Type.Optional( Type.Union([ Type.String({ maxLength: TASKS_LIMITS.noteChars }), Type.Null(), ]), ), }), execute(_id, params) { assertAvailable(); const before = snapshot(); const closesBatch = mutationWillCloseBatch( before, params.id, params.status, ); const mutation = applyTaskUpdate(before, params); const changed = persistThenCommit(mutation.snapshot); if (changed && closesBatch) hideLifecycleTools(); return Promise.resolve({ content: [ { type: "text" as const, text: mutationResultText( changed ? closesBatch ? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.` : `Updated T${params.id}.` : `T${params.id} already has that state; no update recorded.`, ), }, ], details: toolDetails("update", snapshot().items, closesBatch), }); }, renderCall(args, theme) { return new Text( `${theme.fg("toolTitle", theme.bold("tasks_update"))} ${theme.fg("accent", `T${args.id}`)}${args.status ? ` ${theme.fg("muted", args.status)}` : ""}`, 0, 0, ); }, renderResult(result, options, theme) { const first = result.content[0]; return renderToolResult( result.details, options.expanded, theme, first?.type === "text" ? first.text : "Tasks unavailable.", ); }, }); pi.registerTool({ name: "tasks_list", label: "Tasks List", description: "Reads the current session's work-intent tasks, optionally filtered by ID and status; does not execute, schedule, or delegate work.", promptSnippet: "List current session work-intent task items", parameters: Type.Object({ id: Type.Optional(Type.Integer({ minimum: 1 })), status: Type.Optional(StringEnum(TASK_STATUSES)), }), execute(_id, params) { assertAvailable(); const filter = params satisfies TaskFilter; const items = tasks.list(filter); const preview = items.slice(0, 5); const rendered = tasks.render(filter, 3_800); const text = rendered.endsWith("…") || items.length > preview.length ? `${rendered}\nShowing a bounded view of ${items.length} matched item(s); filter by status or id for a narrower result.` : rendered; return Promise.resolve({ content: [{ type: "text" as const, text }], details: toolDetails("list", preview), }); }, renderCall(_args, theme) { return new Text(theme.fg("toolTitle", theme.bold("tasks_list")), 0, 0); }, renderResult(result, options, theme) { const first = result.content[0]; return renderToolResult( result.details, options.expanded, theme, first?.type === "text" ? first.text : "Tasks unavailable.", ); }, }); }; pi.registerCommand("tasks", { description: "Inspect the current session work-intent tasks", handler: async (args, ctx) => { const problem = problemMessage(); if (problem) { if (ctx.hasUI) ctx.ui.notify(problem, "warning"); return; } const action = args.trim().toLowerCase(); if (action === "hide" || action === "show" || action === "toggle") { taskWidgetVisible = action === "show" ? true : action === "hide" ? false : !taskWidgetVisible; taskWidgetExpanded = false; const shown = updateTaskWidget(ctx); if (ctx.hasUI) ctx.ui.notify(taskWidgetFeedback(shown), "info"); return; } await openTasksScreen(ctx, snapshot()); }, }); pi.registerShortcut(Key.ctrlShift("t"), { description: "Show all active tasks above the editor, or collapse to four", handler: async (ctx) => { const problem = problemMessage(); if (problem) { if (ctx.hasUI) ctx.ui.notify(problem, "warning"); return; } const count = actionableTaskCount(); if (count === 0) { if (ctx.hasUI) { ctx.ui.notify( "No active tasks to show. Use /tasks to inspect task history.", "info", ); } return; } taskWidgetVisible = true; taskWidgetExpanded = count > TASK_WIDGET_LIMIT ? !taskWidgetExpanded : false; updateTaskWidget(ctx); if (ctx.hasUI) { ctx.ui.notify( count <= TASK_WIDGET_LIMIT ? `All ${count} active task${count === 1 ? " is" : "s are"} already visible.` : taskWidgetExpanded ? `Showing all ${count} active tasks above the editor.` : `Task panel collapsed to ${TASK_WIDGET_LIMIT} active tasks.`, "info", ); } }, }); pi.on("session_start", (_event, ctx) => { ui = ctx.hasUI ? ctx.ui : undefined; uiMode = ctx.hasUI ? ctx.mode : undefined; restore(ctx); conflict = findTaskConflict(pi.getAllTools()); coldRun = true; activeRun = false; frozenProjection = ""; taskWidgetVisible = true; taskWidgetExpanded = false; registerTools(); if (hasActionableTasks()) showLifecycleTools(); else hideLifecycleTools(); notifyProblem(ctx); updateTaskWidget(ctx); }); pi.on("session_tree", (_event, ctx) => { restore(ctx); if (hasActionableTasks()) showLifecycleTools(); else hideLifecycleTools(); taskWidgetExpanded = false; coldRun = true; activeRun = false; frozenProjection = ""; notifyProblem(ctx); updateTaskWidget(ctx); }); pi.on("session_compact", () => { coldRun = true; frozenProjection = ""; }); pi.on("agent_start", () => { activeRun = true; if (coldRun) { frozenProjection = !problemMessage() ? projectTasks(snapshot()) : ""; coldRun = false; } }); pi.on("agent_settled", () => { activeRun = false; frozenProjection = ""; }); pi.on("context", (event) => { if (!activeRun || !frozenProjection || problemMessage()) return; const messages = injectTaskProjection(event.messages, frozenProjection); if (messages) return { messages: messages as typeof event.messages }; }); pi.on("session_shutdown", () => { try { ui?.setWidget(TASK_WIDGET_KEY, undefined); } catch { // The interactive UI may already be disposed. } taskWidgetMounted = false; requestTaskWidgetRender = undefined; ui = undefined; uiMode = undefined; }); } function mutationWillCloseBatch( snapshot: TaskSnapshot, id: number, status: TaskItem["status"] | undefined, ) { if (status !== "done" && status !== "dropped") return false; return snapshot.items.every( (item) => item.id === id || item.status === "done" || item.status === "dropped", ); }