import { createHash } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { FlowToolResult } from "./tool-result.ts"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { isNewContextCompactionEnabled, NEW_CONTEXT_DISABLED_MESSAGE, } from "../compaction/compaction-settings.ts"; import { getActiveGoal, getGoalById, switchCurrentGoal } from "./goal.ts"; import { TodoSkillLoader } from "../skills/skill-loader.ts"; import type { TodoSkillBinding } from "../skills/skill-composer.ts"; import { SkillRuntime, type AnySkillActivation, type SkillActivation, type SkillActivationMetadata, } from "../skills/skill-runtime.ts"; import { todoOriginKey, type TodoMirrorTaskSpec, type TodoTaskOrigin, } from "../session/types.ts"; import { appendTodoResourceUris, cloneTodoHandoff, normalizeTodoHandoff, normalizeTodoResourceUris, TODO_GET_FIELDS, TODO_GET_DEFAULT_LIMIT, TODO_GET_MAX_LIMIT, TODO_LIST_DEFAULT_LIMIT, TODO_LIST_MAX_LIMIT, type TodoGetField, type TodoAdvanceTransition, type TodoHandoff, type TodoHandoffInput, type TodoHandoffFileInput, type TodoUpdateField, } from "./todo-contract.ts"; import { TODO_STATE_VERSION, assertTodoGeneration, configureTodoSerialization, isTodoMutation, loadTasksFromSession, persist, } from "./todo-serialization.ts"; import { activateTask, activationMetadata, clearCommittedSkillSnapshots, clearSkillSnapshot, cloneActivationBinding, configureTodoSkillEngine, createActiveSkillMessage, createContextInjectionAnchor, degradedActivation, formatSkillBinding, normalizeSkillBindings, normalizeTodoParams, requireSkillRuntime, resolveContextInjectionAnchor, type RunSkillInjection, type TodoParamsInput, } from "./todo-skill-engine.ts"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked" | "deleted"; export interface TodoActorRef { kind: "root" | "teammate"; id: string; label: string; agentType?: string; } export const ROOT_TODO_ACTOR: TodoActorRef = { kind: "root", id: "root", label: "root", }; export type { TodoSkillBinding, TodoSkillRole } from "../skills/skill-composer.ts"; export interface TodoTask { id: string; subject: string; description?: string; status: TaskStatus; blockedBy: string[]; context?: string; skills: TodoSkillBinding[]; resourceUris: string[]; handoff?: TodoHandoff; skillActivation?: SkillActivationMetadata; summary?: string; origin?: TodoTaskOrigin; planHandoffKey?: string; goalId?: string; createdBy: TodoActorRef; assignee: TodoActorRef; createdAt: number; updatedAt: number; activeStartedAt?: number; activeDurationMs?: number; completedAt?: number; } export interface TodoBatchSpec { subject: string; description?: string; context?: string; skills?: TodoSkillBinding[]; resourceUris?: string[]; handoff?: TodoHandoffInput; assignee?: string; blockedBy?: number[]; goalId?: string; } export interface TodoUpdateSpec { id: string; subject?: string; description?: string; status?: TaskStatus; blockedBy?: string[]; context?: string; skills?: TodoSkillBinding[] | null; summary?: string; resourceUris?: string[]; handoff?: TodoHandoffInput; updateFields?: TodoUpdateField[]; assignee?: string; goalId?: string; } export interface TodoParams { action: "create" | "update" | "list" | "get" | "delete" | "clear" | "next" | "advance"; subject?: string; description?: string; status?: TaskStatus; blockedBy?: string[]; context?: string; skills?: TodoSkillBinding[] | null; summary?: string; resourceUris?: string[]; handoff?: TodoHandoffInput; transition?: TodoAdvanceTransition; updateFields?: TodoUpdateField[]; id?: string; field?: TodoGetField; offset?: number; limit?: number; ids?: string[]; updates?: TodoUpdateSpec[]; assignee?: string; tasks?: TodoBatchSpec[]; filter?: { status?: TaskStatus; memberId?: string }; planHandoffKey?: string; goalId?: string; } /** * Slim per-task projection carried in every Todo tool result. Deliberately * excludes heavy fields (description, context, skills, activation metadata): * results are rendered as status counts and may be serialized into session * history or forwarded over the teammate broker, so each entry must stay * cheap. Full task state is available via `getVisibleTasks()`/`get`. */ export interface TodoTaskSnapshot { id: string; subject: string; status: TaskStatus; assignee: TodoActorRef; blockedBy?: string[]; summary?: string; durationMs?: number; completedAt?: number; } export interface TodoResultDetails { action: string; tasks: TodoTaskSnapshot[]; displayTaskIds?: string[]; /** Request-only transition receipt emitted after a successful completion-form advance or active update. */ transition?: TodoAdvanceTransition; /** Extension scheduler outcome; never persisted in Todo state. */ contextTransition?: "scheduled" | "coalesced" | "failed"; contextTransitionRequestId?: number; contextTransitionError?: string; error?: string; } export interface TodoCompactionSnapshot { stateVersion: number; revision: number; activeTaskId?: string; tasks: TodoTask[]; } export interface TodoMirrorReconcileResult { created: string[]; updated: string[]; tombstoned: string[]; unchanged: string[]; } export interface TodoContext { cwd: string; ui: { setStatus: (key: string, value: string | undefined) => void; }; sessionManager?: unknown; skillLoader?: TodoSkillLoader; } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let tasks: Map = new Map(); // Task ids are user-facing sequence numbers, so they start at 1 ("#1" is the first task). let nextTaskId = 1; let knownActors: Map = new Map([[ROOT_TODO_ACTOR.id, ROOT_TODO_ACTOR]]); let unavailableTodoActorIds = new Set(); let extensionApi: ExtensionAPI | undefined; let onTodoStateChanged: (() => void) | undefined; const todoStateChangeSubscribers = new Set<() => void>(); let skillLoader: TodoSkillLoader | undefined; let skillRuntime: SkillRuntime | undefined; let activeSkillSnapshots: Map = new Map(); let runSkillInjection: RunSkillInjection | undefined; let todoRevision = 0; let todoGeneration = 0; let todoSessionLoaded = false; let todoMutationQueue: Promise = Promise.resolve(); let todoHostContext: { cwd: string; sessionManager: unknown; sessionId?: string } | undefined; configureTodoSerialization({ getExtensionApi: () => extensionApi, getTasks: () => tasks, getTodoGeneration: () => todoGeneration, rootActor: ROOT_TODO_ACTOR, }); configureTodoSkillEngine({ getSkillRuntime: () => skillRuntime, getActiveSkillSnapshots: () => activeSkillSnapshots, getRunSkillInjection: () => runSkillInjection, setRunSkillInjection: (injection) => { runSkillInjection = injection; }, }); // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function initTodo(pi: ExtensionAPI): void { extensionApi = pi; } export function onSessionStart(ctx: TodoContext): void { const manager = ctx.sessionManager as { getSessionId?: () => string } | undefined; // Extension contexts have live getters. Capture immutable host scope, not ctx. todoHostContext = { cwd: ctx.cwd, sessionManager: manager, sessionId: manager?.getSessionId?.() }; todoGeneration++; todoSessionLoaded = false; todoMutationQueue = Promise.resolve(); skillLoader = ctx.skillLoader ?? new TodoSkillLoader({ cwd: ctx.cwd }); skillRuntime = new SkillRuntime(skillLoader); activeSkillSnapshots = new Map(); runSkillInjection = undefined; tasks = loadTasksFromSession(ctx); syncTodoRevision(); syncTaskIdCounter(); knownActors = new Map([[ROOT_TODO_ACTOR.id, cloneActor(ROOT_TODO_ACTOR)]]); unavailableTodoActorIds = new Set(); for (const task of tasks.values()) { rememberActor(task.createdBy); rememberActor(task.assignee); } todoSessionLoaded = true; markTodoChanged(); ctx.ui.setStatus("todo", undefined); } export function onSessionShutdown(ctx: TodoContext): void { todoHostContext = undefined; todoGeneration++; todoSessionLoaded = false; todoMutationQueue = Promise.resolve(); tasks.clear(); nextTaskId = 1; knownActors = new Map([[ROOT_TODO_ACTOR.id, cloneActor(ROOT_TODO_ACTOR)]]); unavailableTodoActorIds = new Set(); skillLoader = undefined; skillRuntime = undefined; activeSkillSnapshots.clear(); runSkillInjection = undefined; markTodoChanged(); ctx.ui.setStatus("todo", undefined); } export function isolateTodoForTeammateAttach(): void { let changed = false; const next = new Map(tasks); for (const [id, task] of next) { if (task.status !== "in_progress" || task.assignee.id !== ROOT_TODO_ACTOR.id || task.skills.length === 0) continue; next.set(id, { ...task, status: "pending", skillActivation: undefined, updatedAt: Date.now(), }); changed = true; } if (!changed) return; tasks = next; activeSkillSnapshots.clear(); runSkillInjection = undefined; persist(tasks); markTodoChanged(); } export function getVisibleTasks(): TodoTask[] { const visible = [...tasks.values()].filter((t) => t.status !== "deleted"); visible.sort((a, b) => a.createdAt - b.createdAt); return visible; } export function getTodoActors(): TodoActorRef[] { return [...knownActors.values()].map(cloneActor).sort((left, right) => left.kind === right.kind ? left.label.localeCompare(right.label) : left.kind === "root" ? -1 : 1 ); } export function registerTodoActor(actor: TodoActorRef): void { rememberActor(actor); unavailableTodoActorIds.delete(actor.id); } export function recordTodoActorCompletion(actor: TodoActorRef, wakeable: boolean): void { rememberActor(actor); if (wakeable) unavailableTodoActorIds.delete(actor.id); else unavailableTodoActorIds.add(actor.id); } export function formatTodoActorSelector( actor: { id: string; label: string }, actors: readonly { id: string; label: string }[], ): string { const collidingIds = new Set(actors .filter((candidate) => candidate.label === actor.label) .map((candidate) => candidate.id)); if (collidingIds.size < 2) return actor.label; for (let length = Math.min(4, actor.id.length); length < actor.id.length; length++) { const prefix = actor.id.slice(0, length); if ([...collidingIds].every((candidate) => candidate === actor.id || !candidate.startsWith(prefix))) { return `${actor.label}#${prefix}`; } } return `${actor.label}#${actor.id}`; } /** * Internal projection boundary. Canonical Session/Run state is authoritative; * this function never writes canonical files and is intentionally not exposed * as a public Todo tool action. */ export function reconcileMirrorTasks( specs: readonly TodoMirrorTaskSpec[], ctx: ExtensionContext, sessionGeneration?: string, ): TodoMirrorReconcileResult { const activeSpecs = specs.filter((spec) => spec.status === "in_progress"); if (activeSpecs.length > 1) { throw new Error(`Canonical projection has ${activeSpecs.length} active Todo tasks; expected at most one`); } const result: TodoMirrorReconcileResult = { created: [], updated: [], tombstoned: [], unchanged: [] }; const nextTasks = cloneTaskMap(); const skillSnapshotsToClear = new Set(); const existingByOrigin = new Map(); for (const task of nextTasks.values()) { if (task.origin) existingByOrigin.set(todoOriginKey(task.origin), task); } const projectedSpecs = specs.map((spec) => { const origin = sessionGeneration === undefined ? { ...spec.origin } : { ...spec.origin, sessionGeneration }; return { spec, origin, sourceOriginKey: todoOriginKey(spec.origin) }; }); const idsByOrigin = new Map(); const idsByOriginReference = new Map(); for (const projected of projectedSpecs) { const key = todoOriginKey(projected.origin); const id = existingByOrigin.get(key)?.id ?? mirrorTaskId(key); idsByOrigin.set(key, id); idsByOriginReference.set(projected.sourceOriginKey, id); idsByOriginReference.set(key, id); } const desiredKeys = new Set(idsByOrigin.keys()); const incomingSessions = new Set(projectedSpecs.map(({ origin }) => origin.sessionId)); const authoritativeProjection = sessionGeneration !== undefined; for (const { spec, origin, sourceOriginKey } of projectedSpecs) { const key = todoOriginKey(origin); const existing = existingByOrigin.get(key); if (existing?.status === "deleted") { result.unchanged.push(existing.id); continue; } const id = uniqueMirrorId(idsByOrigin.get(key)!, key, nextTasks); idsByOrigin.set(key, id); idsByOriginReference.set(sourceOriginKey, id); idsByOriginReference.set(key, id); const blockedBy = spec.blockedByOriginKeys .map((originKey) => idsByOriginReference.get(originKey)) .filter((value): value is string => Boolean(value)); const now = Date.now(); const next: TodoTask = { id, subject: spec.subject, ...(spec.description ? { description: spec.description } : {}), status: blockedBy.length > 0 && spec.status === "pending" ? "blocked" : spec.status, blockedBy, ...(spec.context ? { context: spec.context } : {}), // Workflow mirrors do not own completion resources or handoff annotations; // preserve local values already attached to an existing mirror task. resourceUris: existing?.resourceUris ? [...existing.resourceUris] : [], ...(existing?.handoff ? { handoff: cloneTodoHandoff(existing.handoff) } : {}), skills: spec.skills.map((skill) => ({ ...skill })), ...(spec.summary ? { summary: spec.summary } : {}), origin, createdBy: cloneActor(ROOT_TODO_ACTOR), assignee: cloneActor(ROOT_TODO_ACTOR), createdAt: existing?.createdAt ?? now, updatedAt: existing?.updatedAt ?? now, ...(existing?.activeStartedAt !== undefined && spec.status === "in_progress" ? { activeStartedAt: existing.activeStartedAt } : {}), ...(existing?.activeDurationMs !== undefined ? { activeDurationMs: existing.activeDurationMs } : {}), ...(existing?.completedAt !== undefined && spec.status === "completed" ? { completedAt: existing.completedAt } : {}), }; if (existing?.skillActivation && mirrorActivationStillValid(existing, next)) { next.skillActivation = cloneSkillActivation(existing.skillActivation); } if (!existing) { nextTasks.set(id, next); result.created.push(id); continue; } if (taskChanged(existing, next)) { next.updatedAt = now; nextTasks.set(existing.id, { ...next, id: existing.id }); skillSnapshotsToClear.add(existing.id); result.updated.push(existing.id); } else { result.unchanged.push(existing.id); } } for (const task of nextTasks.values()) { if (!task.origin) continue; const key = todoOriginKey(task.origin); const belongsToUpdatedProjection = authoritativeProjection ? true : incomingSessions.has(task.origin.sessionId); if (!belongsToUpdatedProjection || desiredKeys.has(key) || task.status === "deleted") continue; task.status = "deleted"; task.updatedAt = Date.now(); skillSnapshotsToClear.add(task.id); result.tombstoned.push(task.id); } if (result.created.length || result.updated.length || result.tombstoned.length) { commitTodoState(nextTasks); clearCommittedSkillSnapshots(skillSnapshotsToClear); } return result; } /** Return a detached Todo snapshot suitable for compaction metadata and prompts. */ export function getTodoCompactionSnapshot(): TodoCompactionSnapshot { const visible = getVisibleTasks().map(cloneTodoTask); const activeTask = visible .filter((task) => task.status === "in_progress") .sort((left, right) => right.updatedAt - left.updatedAt)[0]; return { stateVersion: TODO_STATE_VERSION, revision: todoRevision, ...(activeTask ? { activeTaskId: activeTask.id } : {}), tasks: visible, }; } /** Turn-start housekeeping: drop the skill injection record when no task needs it. */ export function onBeforeAgentStartTodo(): void { const active = findActiveTask(ROOT_TODO_ACTOR.id); if (!active || active.skills.length === 0) { runSkillInjection = undefined; } } export async function onContextTodo( messages: AgentMessage[], ): Promise<{ messages: AgentMessage[] } | undefined> { const active = findActiveTask(ROOT_TODO_ACTOR.id); if (!active || active.skills.length === 0) return undefined; const activation = await ensureSkillActivation(active); if (runSkillInjection?.taskId !== active.id || runSkillInjection.stackRevision !== activation.stackRevision) { runSkillInjection = { taskId: active.id, stackRevision: activation.stackRevision, anchor: createContextInjectionAnchor(messages), }; } const anchor = resolveContextInjectionAnchor(messages, runSkillInjection.anchor); runSkillInjection.anchor = anchor; const injected = createActiveSkillMessage(active, activation); return { messages: [ ...messages.slice(0, anchor.index), injected, ...messages.slice(anchor.index), ], }; } export function onAgentEndTodo(): void { runSkillInjection = undefined; } export async function executeTodo( input: TodoParamsInput, ctx: ExtensionContext, actor: TodoActorRef = ROOT_TODO_ACTOR, ): Promise { // Gate the explicit transition before entering the serialized mutation // queue. A disabled request must not mutate Todo state or schedule any // future context reset. if ((input.action === "advance" || input.action === "update") && input.transition === "new_context" && !isNewContextCompactionEnabled(ctx.cwd)) { return err(NEW_CONTEXT_DISABLED_MESSAGE, input.action); } const generation = todoGeneration; const execute = () => executeTodoAction(input, ctx, actor, generation); if (!isTodoMutation(input.action)) return execute(); const result = todoMutationQueue.then(execute, execute); todoMutationQueue = result.then(() => undefined, () => undefined); return result; } /** Opaque, process-local host snapshot; not a model tool parameter. */ export interface TodoHandoffAdviceSnapshot { readonly task: TodoTask; readonly revision: number; } interface HandoffAdviceFence { generation: number; revision: number; cwd: string; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; fingerprint: string; taskId: string; } const handoffAdviceFences = new WeakMap(); function assertHandoffAdviceHost(ctx: ExtensionContext): string { const sessionId = ctx.sessionManager?.getSessionId(); if (!todoSessionLoaded || !todoHostContext || !sessionId || ctx.cwd !== todoHostContext.cwd || sessionId !== todoHostContext.sessionId || ctx.sessionManager !== todoHostContext.sessionManager || process.env.PI_TEAMMATE_CHILD === "1" || process.env.PI_TEAMMATE_MANAGED_WINDOW === "1") { throw new Error("Handoff advice requires the current canonical root Todo host/session/workspace."); } return sessionId; } /** List only locally canonical root-owned tasks, detached from live state. */ export function listTodoHandoffAdviceTasks(ctx: ExtensionContext): TodoTask[] { assertHandoffAdviceHost(ctx); return getVisibleTasks().filter((task) => task.assignee.kind === "root" && task.assignee.id === ROOT_TODO_ACTOR.id && !task.origin).map(cloneTodoTask); } export function captureTodoHandoffAdvice(ctx: ExtensionContext, taskId: string): TodoHandoffAdviceSnapshot { const sessionId = assertHandoffAdviceHost(ctx); const task = listTodoHandoffAdviceTasks(ctx).find((candidate) => candidate.id === taskId); if (!task) throw new Error(`Task #${taskId} is not a canonical root-owned Todo.`); const snapshot = { task, revision: todoRevision }; handoffAdviceFences.set(snapshot, { generation: todoGeneration, revision: todoRevision, cwd: ctx.cwd, sessionId, sessionManager: ctx.sessionManager, fingerprint: JSON.stringify(task), taskId, }); return snapshot; } export function assertTodoHandoffAdviceCurrent(ctx: ExtensionContext, snapshot: TodoHandoffAdviceSnapshot): void { const fence = handoffAdviceFences.get(snapshot); const sessionId = assertHandoffAdviceHost(ctx); const task = fence && tasks.get(fence.taskId); if (!fence || fence.generation !== todoGeneration || fence.revision !== todoRevision || fence.cwd !== ctx.cwd || fence.sessionId !== sessionId || fence.sessionManager !== ctx.sessionManager || !task || task.origin || task.assignee.kind !== "root" || task.assignee.id !== ROOT_TODO_ACTOR.id || JSON.stringify(task) !== fence.fingerprint) { throw new Error("Todo changed; preview and confirm handoff advice again."); } } /** Trusted host-only CAS: the check and owner update share the real mutation queue. */ export function applyTodoHandoffAdviceCAS( ctx: ExtensionContext, snapshot: TodoHandoffAdviceSnapshot, files: readonly TodoHandoffFileInput[], assertAuthorizedCurrent: () => void, ): Promise { // Detach at enqueue, so a caller cannot change a previously confirmed patch. const patch = files.map((file) => ({ ...file })); const execute = async (): Promise => { try { assertAuthorizedCurrent(); assertTodoHandoffAdviceCurrent(ctx, snapshot); const fence = handoffAdviceFences.get(snapshot)!; const task = tasks.get(fence.taskId)!; if (!patch.length || new Set(patch.map((file) => file.path)).size !== patch.length || patch.some((file) => !task.handoff?.files.some((old) => old.path === file.path && old.value === "unknown") || !["required", "conditional", "skip"].includes(file.value) || (file.value === "conditional" && !file.when?.trim()))) { throw new Error("Advice may update only selected existing unknown annotations with concrete conditions."); } // No nested executeTodo enqueue: no interleaving after the CAS check. // Metadata-only owner update must not load skills or mutate activation. return await executeTodoAction({ action: "update", id: fence.taskId, updateFields: ["handoff"], handoff: { files: patch } }, ctx, ROOT_TODO_ACTOR, fence.generation, true); } catch (error) { return err(error instanceof Error ? error.message : String(error), "update"); } finally { handoffAdviceFences.delete(snapshot); } }; const result = todoMutationQueue.then(execute, execute); todoMutationQueue = result.then(() => undefined, () => undefined); return result; } /** * Auto-delegation hook for `teammate:started` events that carry `todo` * bindings (tasks[].todo at dispatch time, single id or ordered array). Each * bound task's assignee moves from root to the agent, and the first runnable * one (priority order, pending and not blocked) is auto-activated so the * agent can drive it immediately without calling `todo next`. */ export interface TodoDelegationResult { /** Task ids whose assignee now is the agent. */ delegated: string[]; /** Task ids whose assignee was actually changed by this delegation. */ reassigned: string[]; /** Task ids auto-activated (at most one unless one was already active). */ activated: string[]; /** Human-readable errors for bindings that could not be delegated. */ errors: string[]; } /** * Delegate an ordered list of Todo tasks to an agent and auto-activate the * first runnable one. Blocked tasks keep their state (they auto-unblock when * the dependency completes); already-active tasks are preserved; if the agent * already holds an in_progress task, nothing new is activated (one * in_progress per actor). */ export async function delegateTodoTasksToAgent( todos: readonly string[], actor: TodoActorRef, ctx: ExtensionContext, ): Promise { const delegated: string[] = []; const reassigned: string[] = []; const activated: string[] = []; const errors: string[] = []; if (todos.length === 0) return { delegated, reassigned, activated, errors }; // Mirror the started-event listener: the agent's identity must be known to // the assignee selector before the update resolves `actor.id`. rememberActor(actor); let alreadyActive = findActiveTask(actor.id) !== undefined; for (const raw of todos) { const trimmed = String(raw).trim(); const id = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; if (!id) { errors.push("Todo delegation: empty task id."); continue; } const task = tasks.get(id); if (!task || task.status === "deleted") { errors.push(`Todo delegation: unknown task "${raw}"; no assignment performed.`); continue; } if (task.assignee.id !== actor.id) { const result = await executeTodo( { action: "update", id, assignee: actor.id }, ctx, ROOT_TODO_ACTOR, ); if (result.isError) { errors.push(`Todo delegation: task #${id} failed to reassign: ${result.content[0] && "text" in result.content[0] ? result.content[0].text : "unknown"}`); continue; } reassigned.push(id); } delegated.push(id); } // Auto-activate the highest-priority runnable task (first pending, // non-blocked binding). A task already in_progress counts as activated. for (const id of delegated) { const task = tasks.get(id); if (!task || task.status === "completed" || task.status === "deleted") continue; if (task.status === "in_progress") { activated.push(id); break; } if (task.status !== "pending" || task.blockedBy.length > 0) continue; if (alreadyActive) break; const result = await executeTodo( { action: "update", id, status: "in_progress" }, ctx, ROOT_TODO_ACTOR, ); if (!result.isError) { activated.push(id); alreadyActive = true; } break; } return { delegated, reassigned, activated, errors }; } /** * Single-binding convenience wrapper over {@link delegateTodoTasksToAgent}; * returns a FlowToolResult for callers that need one (e.g. tests). */ export async function delegateTodoTaskToAgent( taskId: string, actor: TodoActorRef, ctx: ExtensionContext, ): Promise { const result = await delegateTodoTasksToAgent([taskId], actor, ctx); if (result.errors.length > 0) return err(result.errors[0], "update"); const id = result.delegated[0] ?? ""; const task = tasks.get(id); if (task && result.reassigned.length === 0) { return ok(`Todo task #${id} already assigned to @${actor.label}${result.activated.length > 0 ? ` (active #${result.activated[0]})` : ""}.`, "update"); } const extra = result.activated.length > 0 ? `; activated #${result.activated[0]}` : task && task.status === "blocked" && task.blockedBy.length > 0 ? ` (blocked by ${task.blockedBy.map((depId) => `#${depId}`).join(", ")}; auto-unblocks when the dependency completes)` : ""; return ok( id ? `Todo task #${id} delegated to @${actor.label}${extra}.` : "Todo delegation: no task delegated.", "update", ); } export interface TodoSealResult { /** Task ids auto-sealed to `completed` on a successful agent completion. */ sealed: string[]; /** * Tasks that should have been sealed but were rejected by the update path * (e.g. an unverified Goal quality gate). Surfaced so callers can notify * instead of leaving the task silently dangling in_progress. */ failed: Array<{ id: string; reason: string }>; } /** * Auto-seal hook for `teammate:complete`. On a clean agent exit (exitCode 0 * and not cancelled) every task the agent left `in_progress` is sealed to * `completed` with a marker summary, so a delegated task never dangles after * its agent finishes — even if the agent forgot to update it. Pending/blocked * tasks are left untouched (the agent never activated them), and failed or * cancelled runs leave everything as-is. */ export async function sealTodoTasksOnAgentComplete( actor: TodoActorRef, exitCode: number, cancelled: boolean, ctx: ExtensionContext, ): Promise { const sealed: string[] = []; const failed: TodoSealResult["failed"] = []; if (exitCode !== 0 || cancelled) return { sealed, failed }; const candidates = getVisibleTasks().filter( (task) => task.assignee.id === actor.id && task.status === "in_progress", ); for (const task of candidates) { const result = await executeTodo( { action: "update", id: task.id, status: "completed", summary: `Agent @${actor.label} finished (auto-sealed by teammate:complete)`, }, ctx, ROOT_TODO_ACTOR, ); if (!result.isError) { sealed.push(task.id); } else { const reason = result.content[0] && "text" in result.content[0] ? result.content[0].text : "unknown error"; failed.push({ id: task.id, reason }); } } return { sealed, failed }; } async function executeTodoAction( input: TodoParamsInput, ctx: ExtensionContext, actor: TodoActorRef, generation: number, handoffOnly = false, ): Promise { const { action } = input; try { assertTodoGeneration(generation); rememberActor(actor); const params = normalizeTodoParams(input); if (action !== "get" && params.field !== undefined) { return err("field is only valid for get", action); } if (action !== "get" && action !== "list" && (params.offset !== undefined || params.limit !== undefined)) { return err("offset and limit are only valid for get or list", action); } if (params.transition !== undefined && action !== "advance" && action !== "update") { return err("transition is only valid for active completion-form advance or an active single-task update", action); } if (params.transition !== undefined && params.transition !== "keep_context" && params.transition !== "new_context") { return err(`Invalid Todo transition: ${String(params.transition)}`, action); } switch (action) { case "create": return handleCreate(params, ctx, actor); case "update": return await handleUpdate(params, ctx, actor, generation, handoffOnly); case "list": return handleList(params, actor); case "get": return handleGet(params); case "delete": return handleDelete(params, ctx, actor); case "clear": return handleClear(ctx, actor); case "next": return await handleNext(ctx, actor, generation); case "advance": return await handleAdvance(params, ctx, actor, generation); default: return err(`Unknown action "${action}". Valid: create, update, list, get, delete, clear, next, advance`); } } catch (e) { return err(`Error in todo ${action}: ${e instanceof Error ? e.message : String(e)}`); } } // --------------------------------------------------------------------------- // Action Handlers // --------------------------------------------------------------------------- function handleCreate(params: TodoParams, ctx: ExtensionContext, actor: TodoActorRef): FlowToolResult { // `status`/`summary` are update-only concepts. Silently dropping them here // would make a create that passes JSON Schema validation behave differently // than the caller intended. const rejected: string[] = []; if (params.status !== undefined) rejected.push("status"); if (params.summary !== undefined) rejected.push("summary"); if (rejected.length > 0) { return err(`create does not support ${rejected.join(" and ")}; they are set via update`, "create"); } if (params.tasks && params.tasks.length > 0) { const conflicting: string[] = []; if (params.subject !== undefined) conflicting.push("subject"); if (params.description !== undefined) conflicting.push("description"); if (params.blockedBy !== undefined) conflicting.push("blockedBy"); if (params.assignee !== undefined) conflicting.push("assignee"); if (params.context !== undefined) conflicting.push("context"); if (params.skills !== undefined) conflicting.push("skills"); if (params.resourceUris !== undefined) conflicting.push("resourceUris"); if (params.handoff !== undefined) conflicting.push("handoff"); if (params.goalId !== undefined) conflicting.push("goalId"); if (conflicting.length > 0) { return err(`create accepts either a single task (subject) or a batch (tasks), not both; ${conflicting.join(", ")} cannot accompany tasks.`, "create"); } return handleBatchCreate(params.tasks, actor, params.planHandoffKey); } const subject = params.subject?.trim(); if (!subject) return err("subject is required for create", "create"); const id = peekTaskId(); const now = Date.now(); const blockerResolution = resolveBlockedBy(id, params.blockedBy ?? []); if (blockerResolution.error) return err(blockerResolution.error, "create"); const blockedBy = blockerResolution.blockedBy; const assignee = resolveAssignee(params.assignee, actor); if ("error" in assignee) return err(assignee.error, "create"); const handoff = params.handoff === undefined ? undefined : normalizeTodoHandoff(params.handoff, undefined, todoRevision + 1); const task: TodoTask = { id, subject, description: params.description, status: blockedBy.length > 0 ? "blocked" : "pending", blockedBy, skills: params.skills ?? [], resourceUris: normalizeTodoResourceUris(params.resourceUris), ...(handoff ? { handoff } : {}), ...(params.context ? { context: params.context } : {}), ...(params.planHandoffKey ? { planHandoffKey: params.planHandoffKey } : {}), ...(params.goalId ? { goalId: params.goalId } : {}), createdBy: cloneActor(actor), assignee: assignee.actor, createdAt: now, updatedAt: now, }; if (hasCycle(id, blockedBy)) return err("blockedBy would create a dependency cycle", "create"); commitTaskIds(1); const nextTasks = new Map(tasks); nextTasks.set(id, task); commitTodoState(nextTasks); return ok(`Created ${todoIndexEntry(task)}`, "create", [id]); } /** * Create an entire multi-step plan in one call. Array order is the execution * order (monotonic createdAt); each blockedBy integer is a zero-based index * into this same batch. The whole batch commits atomically — any invalid * spec aborts the create without touching existing state. */ function handleBatchCreate(specs: TodoBatchSpec[], actor: TodoActorRef, planHandoffKey?: string): FlowToolResult { const nextTasks = cloneTaskMap(); const ids: string[] = []; for (let i = 0; i < specs.length; i++) { ids.push(peekTaskId(i)); } const resolvedDeps: string[][] = []; for (let i = 0; i < specs.length; i++) { const deps: string[] = []; for (const index of specs[i].blockedBy ?? []) { if (!Number.isInteger(index) || index < 0) { return err(`tasks[${i}].blockedBy contains invalid batch index ${index}; indexes must be non-negative integers.`, "create"); } if (index >= i) { const validRange = i === 0 ? "tasks[0] cannot have dependencies because it has no earlier batch items" : `valid indexes for tasks[${i}] are 0 through ${i - 1}`; return err( `tasks[${i}].blockedBy index ${index} must reference an earlier batch item; ${validRange}.`, "create", ); } deps.push(ids[index]); } resolvedDeps.push(deps); } const created: TodoTask[] = []; let clock = Date.now(); for (let i = 0; i < specs.length; i++) { const spec = specs[i]; const subject = spec.subject?.trim(); if (!subject) return err(`tasks[${i}].subject is required`, "create"); const blockerResolution = resolveBlockedBy(ids[i], resolvedDeps[i], nextTasks); if (blockerResolution.error) return err(`tasks[${i}]: ${blockerResolution.error}`, "create"); const blockedBy = blockerResolution.blockedBy; const assignee = resolveAssignee(spec.assignee, actor); if ("error" in assignee) return err(`tasks[${i}]: ${assignee.error}`, "create"); let skills: TodoSkillBinding[]; let handoff: TodoHandoff | undefined; try { skills = spec.skills ? normalizeSkillBindings(spec.skills) : []; handoff = spec.handoff === undefined ? undefined : normalizeTodoHandoff(spec.handoff, undefined, todoRevision + 1); } catch (e) { return err(`tasks[${i}]: ${e instanceof Error ? e.message : String(e)}`, "create"); } clock += 1; const task: TodoTask = { id: ids[i], subject, description: spec.description, status: blockedBy.length > 0 ? "blocked" : "pending", blockedBy, skills, resourceUris: normalizeTodoResourceUris(spec.resourceUris), ...(handoff ? { handoff } : {}), ...(spec.context ? { context: spec.context } : {}), ...(planHandoffKey ? { planHandoffKey } : {}), ...(spec.goalId ? { goalId: spec.goalId } : {}), createdBy: cloneActor(actor), assignee: assignee.actor, createdAt: clock, updatedAt: clock, }; nextTasks.set(ids[i], task); created.push(task); } commitTaskIds(specs.length); commitTodoState(nextTasks); const shown = created.slice(0, TODO_LIST_DEFAULT_LIMIT); const lines = shown.map((task) => todoIndexEntry(task)); if (shown.length < created.length) { lines.push(`More created tasks: IDs #${created[shown.length]!.id} through #${created.at(-1)!.id}. Browse with todo {"action":"list"}, or get a task by id.`); } return ok(`Created ${created.length} tasks:\n${lines.join("\n")}`, "create", created.map((task) => task.id)); } interface PreparedTodoUpdate { before: TodoTask; draft: TodoTask; activationInputsChanged: boolean; shouldActivate: boolean; } function prepareTodoUpdate( params: TodoParams, actor: TodoActorRef, state: Map, ): PreparedTodoUpdate | { error: string } { if (!params.id) return { error: "id is required for update" }; const task = state.get(params.id); if (!task) return { error: `Task not found: ${params.id}` }; if (task.status === "deleted") return { error: `Cannot update deleted task: ${params.id}` }; if (!canEditTask(actor, task)) return { error: `@${actor.label} cannot update task #${params.id}` }; const before = cloneTodoTask(task); const draft = cloneTodoTask(task); const updateFields = params.updateFields ? new Set(params.updateFields) : undefined; const updates = (field: TodoUpdateField): boolean => updateFields ? updateFields.has(field) : params[field] !== undefined; if (updateFields) { for (const field of updateFields) { if (params[field] === undefined) return { error: `${field} is required when listed in updateFields` }; } } if (updates("subject")) { const subject = params.subject!; if (!subject.trim()) return { error: "subject cannot be empty" }; draft.subject = subject; } if (updates("description")) { if (params.description === "") delete draft.description; else draft.description = params.description; } if (updates("summary")) { if (params.summary === "") delete draft.summary; else draft.summary = params.summary; } if (updates("resourceUris")) { draft.resourceUris = normalizeTodoResourceUris(params.resourceUris); } if (updates("handoff")) { const handoff = normalizeTodoHandoff(params.handoff!, draft.handoff, todoRevision + 1); if (handoff) draft.handoff = handoff; else delete draft.handoff; } if (updates("goalId")) { if (params.goalId === "") delete draft.goalId; else draft.goalId = params.goalId; } if (updates("assignee")) { const assignee = resolveAssignee(params.assignee!, actor); if ("error" in assignee) return { error: assignee.error }; draft.assignee = assignee.actor; } if (updates("context")) { if (params.context === "") delete draft.context; else draft.context = params.context; draft.skillActivation = undefined; } if (updates("skills")) { draft.skills = params.skills ?? []; draft.skillActivation = undefined; } if (updates("blockedBy")) { const blockerResolution = resolveBlockedBy(draft.id, params.blockedBy!, state); if (blockerResolution.error) return { error: blockerResolution.error }; draft.blockedBy = blockerResolution.blockedBy; } if (updates("status")) draft.status = params.status!; if ( (updates("status") && (params.status === "pending" || params.status === "blocked")) || (!updates("status") && updates("blockedBy")) ) { draft.status = deriveDependencyStatus(draft.blockedBy); } applyTaskStatusTiming(before, draft); if (draft.status === "in_progress" && draft.blockedBy.length > 0) { return { error: `Task #${draft.id} is blocked by: ${draft.blockedBy.join(", ")}` }; } if ( draft.status === "in_progress" && draft.assignee.kind === "teammate" && unavailableTodoActorIds.has(draft.assignee.id) ) { return { error: `Teammate @${draft.assignee.label} is no longer running or restorable. Dispatch a new teammate bound to task #${draft.id} and pass the previous agent:// publication via tasks[].briefing.`, }; } if ( draft.status === "in_progress" && (before.status !== "in_progress" || before.assignee.id !== draft.assignee.id) ) { const active = findActiveTask(draft.assignee.id, draft.id, state); if (active) { return { error: `Task #${active.id} is already in progress for @${draft.assignee.label} (one in_progress per actor). For parallel work, dispatch teammates without changing todo status; update each task when its agent completes.` }; } } if (draft.status !== before.status && !isValidTransition(before.status, draft.status)) { return { error: `Invalid status transition: ${before.status} → ${draft.status}` }; } if (draft.status === "completed" && before.status !== "completed" && draft.goalId) { const gate = getGoalById(draft.goalId); if (!gate) { return { error: `Quality gate Goal ${draft.goalId} for task #${draft.id} was not found; cannot verify completion.` }; } if (gate.status !== "done") { const how = gate.status === "paused" ? "Resume the Goal (/goal resume) and let it verify before completing this task." : "Complete the Goal (goal complete) to verify it before completing this task."; return { error: `Quality gate Goal not verified for task #${draft.id}: "${gate.text}" (${gate.status}). ${how}` }; } } const activationInputsChanged = before.context !== draft.context || JSON.stringify(before.skills) !== JSON.stringify(draft.skills); const assigneeChanged = before.assignee.id !== draft.assignee.id; const shouldActivate = draft.status === "in_progress" && draft.skills.length > 0 && (before.status !== "in_progress" || activationInputsChanged || assigneeChanged || !draft.skillActivation); return { before, draft, activationInputsChanged, shouldActivate }; } async function handleBatchUpdate( updates: TodoUpdateSpec[], actor: TodoActorRef, generation: number, ): Promise { if (updates.length === 0) return err("updates must be a non-empty array", "update"); const ids = updates.map((update) => update.id); if (new Set(ids).size !== ids.length) return err("update ids must be unique", "update"); const baseline = cloneTaskMap(); const candidate = cloneTaskMap(); const preparedUpdates: PreparedTodoUpdate[] = []; for (const [index, update] of updates.entries()) { const prepared = prepareTodoUpdate({ action: "update", ...update }, actor, candidate); if ("error" in prepared) return err(`updates[${index}]: ${prepared.error}`, "update", [update.id]); candidate.set(prepared.draft.id, prepared.draft); if (prepared.draft.status === "completed" && prepared.before.status !== "completed") { autoUnblock(candidate, prepared.draft.id); } preparedUpdates.push(prepared); } const activations = new Map(); for (const prepared of preparedUpdates) { prepared.draft = candidate.get(prepared.draft.id)!; const activation = prepared.shouldActivate ? await activateTask(prepared.draft) : undefined; if (activation) { prepared.draft.skillActivation = activationMetadata(activation); candidate.set(prepared.draft.id, prepared.draft); activations.set(prepared.draft.id, activation); } if (prepared.draft.status === "pending" || prepared.draft.skills.length === 0) { prepared.draft.skillActivation = undefined; candidate.set(prepared.draft.id, prepared.draft); } } if (activations.size > 0) { assertTodoGeneration(generation); if (JSON.stringify([...tasks]) !== JSON.stringify([...baseline])) { throw new Error("Todo state changed while batch skills were activating; retry the mutation."); } } const changed = preparedUpdates.filter((prepared) => taskChanged(prepared.before, prepared.draft) || JSON.stringify(prepared.before.skillActivation) !== JSON.stringify(prepared.draft.skillActivation)); if (changed.length === 0) return ok(`No changes: ${updates.length} tasks`, "update", ids); const updatedAt = Date.now(); for (const prepared of changed) { prepared.draft.updatedAt = updatedAt; candidate.set(prepared.draft.id, prepared.draft); } commitTodoState(candidate); for (const prepared of preparedUpdates) { const activation = activations.get(prepared.draft.id); if (activation) { activeSkillSnapshots.set(prepared.draft.id, activation); runSkillInjection = undefined; } else if (prepared.activationInputsChanged || prepared.draft.status !== "in_progress") { clearSkillSnapshot(prepared.draft.id); } } const lines = preparedUpdates.map(({ before, draft }) => { const statusNote = before.status !== draft.status ? ` (${before.status} → ${draft.status})` : ""; return `#${draft.id} ${draft.subject}${statusNote}`; }); return ok(`Updated ${preparedUpdates.length} tasks:\n${lines.join("\n")}`, "update", ids); } async function handleUpdate( params: TodoParams, ctx: ExtensionContext, actor: TodoActorRef, generation: number, handoffOnly = false, ): Promise { if (params.updates !== undefined) { const conflicting = ["id", "subject", "description", "status", "blockedBy", "context", "skills", "summary", "resourceUris", "handoff", "transition", "updateFields", "assignee", "goalId"] .filter((field) => params[field as keyof TodoParams] !== undefined); if (conflicting.length > 0) { return err(`update accepts either id with top-level fields or updates, not both; ${conflicting.join(", ")} cannot accompany updates`, "update"); } return handleBatchUpdate(params.updates, actor, generation); } if (params.transition !== undefined) { if (params.transition !== "new_context") { return err("transition=keep_context is only valid for completion-form advance; active update supports only new_context", "update", params.id ? [params.id] : undefined); } const active = findActiveTask(actor.id); if (!active) { return err(`@${actor.label} has no in_progress task to update before new_context`, "update", params.id ? [params.id] : undefined); } if (!params.id) { return err(`id is required to update active task #${active.id} before new_context`, "update"); } if (params.id !== active.id) { return err(`Update id mismatch for @${actor.label}: active task is #${active.id}, not #${params.id}`, "update", [params.id]); } } const prepared = prepareTodoUpdate(params, actor, tasks); if ("error" in prepared) return err(prepared.error, "update", params.id ? [params.id] : undefined); const { before, draft, activationInputsChanged, shouldActivate } = prepared; if (params.transition === "new_context" && (draft.status !== "in_progress" || draft.assignee.id !== actor.id)) { return err("update with transition=new_context must leave the caller's active task in_progress", "update", [draft.id]); } const activation = shouldActivate && !handoffOnly ? await activateTask(draft) : undefined; if (shouldActivate && !handoffOnly) { revalidateAsyncTodoMutation({ generation, before, draft, actor }); } if (activation) draft.skillActivation = activationMetadata(activation); if (!handoffOnly && (draft.status === "pending" || draft.skills.length === 0)) draft.skillActivation = undefined; const changed = taskChanged(before, draft) || JSON.stringify(before.skillActivation) !== JSON.stringify(draft.skillActivation); if (!changed) return ok(`No change: #${draft.id}`, "update", [draft.id], params.transition); draft.updatedAt = Date.now(); const nextTasks = new Map(tasks); nextTasks.set(draft.id, draft); if (draft.status === "completed" && before.status !== "completed") { autoUnblock(nextTasks, draft.id); } commitTodoState(nextTasks); if (activation) { activeSkillSnapshots.set(draft.id, activation); runSkillInjection = undefined; } else if (activationInputsChanged || draft.status !== "in_progress") { clearSkillSnapshot(draft.id); } const statusNote = before.status !== draft.status ? ` (${before.status} → ${draft.status})` : ""; return ok(`Updated #${draft.id}: ${draft.subject}${statusNote}`, "update", [draft.id], params.transition); } function handleList(params: TodoParams, actor: TodoActorRef): FlowToolResult { const { offset, limit } = todoPageOptions(params, "list"); const visible = getVisibleTasks(); let filtered = visible; if (params.filter?.status) { filtered = filtered.filter((t) => t.status === params.filter!.status); } const memberSelector = params.filter?.memberId; if (memberSelector) { const member = resolveTodoActorSelector(memberSelector, actor); if ("error" in member) { // Ambiguous and unknown selectors are both caller errors: report them // instead of silently filtering down to an empty list. return err(member.error, "list"); } filtered = filtered.filter((task) => task.createdBy.id === member.actor.id || task.assignee.id === member.actor.id); } if (filtered.length === 0) { return ok("No tasks found.", "list", []); } // Pre-compute reverse dependency map: taskId -> list of task IDs it blocks const blocksMap = new Map(); for (const t of visible) { for (const dep of t.blockedBy) { const existing = blocksMap.get(dep); if (existing) existing.push(`#${t.id}`); else blocksMap.set(dep, [`#${t.id}`]); } } const page = filtered.slice(offset, offset + limit); const end = offset + page.length; const lines = page.map((task) => `${statusIcon(task.status)} ${todoIndexEntry(task, blocksMap.get(task.id))}`); const footer = end < filtered.length ? `Continue: todo ${JSON.stringify({ action: "list", ...(params.filter ? { filter: params.filter } : {}), offset: end, limit })}` : "End of list."; return ok( `Tasks: offset=${offset}, returned=${page.length}, total=${filtered.length} (task entries, creation order).\n${lines.join("\n")}\n${footer}`, "list", page.map((task) => task.id), ); } /** Minimal shared create/list projection; full fields remain available via get. */ function todoIndexEntry(task: TodoTask, blocks?: readonly string[]): string { const tags: string[] = []; if (task.blockedBy.length > 0) tags.push(`blocked by: ${task.blockedBy.join(", ")}`); if (blocks?.length) tags.push(`blocks: ${blocks.join(", ")}`); const metadata = `(${task.status}) ${actorTag(task)}${tags.length ? ` [${tags.join("] [")}]` : ""}`; // Reserve space for both title and routing/dependencies, so a long label or // dependency list cannot consume the title's entire preview. return `#${task.id}: ${todoPreview(task, "all", metadata, 128, true)} ${todoPreview(task, "subject", task.subject, TODO_SUBJECT_BYTES)}`; } function todoPageOptions(params: TodoParams, action: "get" | "list"): { offset: number; limit: number } { const max = action === "get" ? TODO_GET_MAX_LIMIT : TODO_LIST_MAX_LIMIT; const offset = params.offset === undefined ? 0 : params.offset; const limit = params.limit === undefined ? action === "get" ? TODO_GET_DEFAULT_LIMIT : TODO_LIST_DEFAULT_LIMIT : params.limit; const unit = action === "get" ? "Unicode code points" : "task entries"; if (!Number.isSafeInteger(offset) || offset < 0) throw new Error(`offset must be a non-negative safe integer (${unit})`); if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) { throw new Error(`limit must be an integer from 1 to ${max} (${unit})`); } return { offset, limit }; } // Content budgets exclude the small framing and actionable read notices. const TODO_SUBJECT_BYTES = 256; const TODO_DESCRIPTION_BYTES = 2 * 1024; const TODO_CONTEXT_BYTES = 4 * 1024; const TODO_SUMMARY_BYTES = 1024; const TODO_REFERENCE_BYTES = 1024; function todoReadHint(id: string, field: TodoGetField, offset = 0, limit = TODO_GET_DEFAULT_LIMIT): string { return `todo ${JSON.stringify({ action: "get", id, field, offset, limit })}`; } /** Byte-bounded previews never split a code point; read offsets use code points. */ function boundedTodoText(text: string, maxBytes: number, readHint: (offset: number) => string): string { if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; let bytes = 0; let offset = 0; let prefix = ""; for (const point of text) { const size = Buffer.byteLength(point, "utf8"); if (bytes + size > maxBytes) break; prefix += point; bytes += size; offset++; } return `${prefix}\n[Truncated; continue with ${readHint(offset)}]`; } function todoPreview(task: TodoTask, field: TodoGetField, text: string, bytes: number, formatted = false): string { return boundedTodoText(text, bytes, (offset) => todoReadHint(task.id, field, formatted ? 0 : offset)); } function todoFieldText(task: TodoTask, field: TodoGetField): string { if (field === "all") { return JSON.stringify({ ...task, blocks: [...tasks.values()].filter((t) => t.status !== "deleted" && t.blockedBy.includes(task.id)).map((t) => t.id), }, null, 2); } const value = task[field]; return typeof value === "string" ? value : JSON.stringify(value ?? null, null, 2); } function formatTodoHandoff(task: TodoTask): string { if (!task.handoff) return ""; const lines: string[] = []; if (task.handoff.nextSteps.length > 0) { lines.push("Handoff next steps:", ...task.handoff.nextSteps.map((step) => `- ${step}`)); } if (task.handoff.files.length > 0) { lines.push("Handoff files:", ...task.handoff.files.map((file) => `- ${file.path} [${file.value}]: ${file.reason}${file.when ? `; when=${file.when}` : ""}`)); } return lines.join("\n"); } function handleGet(params: TodoParams): FlowToolResult { if (!params.id) return err("id is required for get", "get"); const task = tasks.get(params.id); if (!task) return err(`Task not found: ${params.id}`, "get"); if (params.field !== undefined && !(TODO_GET_FIELDS as readonly unknown[]).includes(params.field)) { return err(`field must be one of: ${TODO_GET_FIELDS.join(", ")}`, "get"); } const { offset, limit } = todoPageOptions(params, "get"); if (params.field !== undefined || params.offset !== undefined || params.limit !== undefined) { const field = params.field ?? "all"; const points = Array.from(todoFieldText(task, field)); const end = Math.min(offset + limit, points.length); const header = `#${task.id} ${field}: offset=${offset}, returned=${Math.max(0, end - offset)}, total=${points.length} Unicode code points (0-based).`; const footer = end < points.length ? `Continue: ${todoReadHint(task.id, field, end, limit)}` : "End of field."; return ok(`${header}\n${points.slice(offset, end).join("")}\n${footer}`, "get", [task.id]); } const lines: string[] = [ `# #${task.id}: ${todoPreview(task, "subject", task.subject, TODO_SUBJECT_BYTES)}`, `Status: ${task.status}`, todoPreview(task, "all", `Created by: @${task.createdBy.label}\nAssignee: @${task.assignee.label}`, 512, true), ]; for (const [field, label] of [["description", "Description"], ["summary", "Summary"], ["context", "Context"]] as const) { if (task[field]) lines.push(`${label}: ${todoPreview(task, field, task[field]!, 512)}`); } if (task.blockedBy.length > 0) lines.push(todoPreview(task, "all", `Blocked by: ${task.blockedBy.join(", ")}`, 512, true)); const blockers = [...tasks.values()].filter((t) => t.blockedBy.includes(task.id) && t.status !== "deleted"); if (blockers.length > 0) lines.push(todoPreview(task, "all", `Blocks: ${blockers.map((b) => `#${b.id}`).join(", ")}`, 512, true)); lines.push(`Resource URIs: ${todoPreview(task, "resourceUris", todoFieldText(task, "resourceUris"), TODO_REFERENCE_BYTES, true)}`); if (task.handoff) lines.push(todoPreview(task, "handoff", formatTodoHandoff(task), TODO_REFERENCE_BYTES, true)); if (task.skills.length > 0) lines.push(`Skills: ${todoPreview(task, "skills", task.skills.map(formatSkillBinding).join(", "), 512, true)}`); if (task.skillActivation) { lines.push(todoPreview(task, "all", `Skill activation: ${task.skillActivation.activationId}\nStack revision: ${task.skillActivation.stackRevision}`, 512, true)); } if (task.goalId) lines.push(todoPreview(task, "all", `Goal: ${task.goalId}; read the current Goal with goal {"action":"get"}`, 512, true)); lines.push(`Full task/metadata: ${todoReadHint(task.id, "all")}. Text fields are raw text; structured fields are JSON. Follow Continue until End of field.`); return ok(lines.join("\n"), "get", [task.id]); } function handleDelete(params: TodoParams, ctx: ExtensionContext, actor: TodoActorRef): FlowToolResult { if (params.id && params.ids !== undefined) return err("delete accepts either id or ids, not both", "delete"); const requestedIds = params.ids ?? (params.id ? [params.id] : []); if (requestedIds.length === 0) return err("id or ids is required for delete", "delete"); if (new Set(requestedIds).size !== requestedIds.length) return err("delete ids must be unique", "delete"); const selected: TodoTask[] = []; for (const id of requestedIds) { const task = tasks.get(id); if (!task) return err(`Task not found: ${id}`, "delete"); if (!canDeleteTask(actor, task)) return err(`@${actor.label} cannot delete task #${id}`, "delete"); selected.push(task); } const deletedIds = new Set(requestedIds); const nextTasks = cloneTaskMap(); const updatedAt = Date.now(); for (const id of requestedIds) { const deleted = nextTasks.get(id)!; const before = tasks.get(id)!; deleted.status = "deleted"; deleted.updatedAt = updatedAt; applyTaskStatusTiming(before, deleted, updatedAt); } for (const task of nextTasks.values()) { if (task.status === "deleted" || !task.blockedBy.some((id) => deletedIds.has(id))) continue; task.blockedBy = task.blockedBy.filter((id) => !deletedIds.has(id)); if (task.status === "blocked" || task.status === "pending") { task.status = deriveDependencyStatus(task.blockedBy); } task.updatedAt = updatedAt; } commitTodoState(nextTasks); clearCommittedSkillSnapshots(deletedIds); if (selected.length === 1) { return ok(`Deleted #${selected[0].id}: ${selected[0].subject}`, "delete", requestedIds); } return ok( `Deleted ${selected.length} tasks:\n${selected.map((task) => `#${task.id} ${task.subject}`).join("\n")}`, "delete", requestedIds, ); } function handleClear(ctx: ExtensionContext, actor: TodoActorRef): FlowToolResult { if (actor.kind !== "root") return err("Only root can clear the shared Todo list.", "clear"); const count = [...tasks.values()].filter((t) => t.status !== "deleted").length; const nextTasks = new Map(); commitTodoState(nextTasks); clearSkillSnapshot(); return ok(`Cleared ${count} task(s).`, "clear"); } async function handleAdvance( params: TodoParams, ctx: ExtensionContext, actor: TodoActorRef, generation: number, ): Promise { const active = findActiveTask(actor.id); const suppliedResources = params.resourceUris === undefined ? undefined : normalizeTodoResourceUris(params.resourceUris); if (!active) { if (params.id !== undefined || params.summary !== undefined || suppliedResources !== undefined || params.handoff !== undefined || params.transition !== undefined) { return err(`@${actor.label} has no in_progress task. Omit id, summary, resourceUris, handoff, and transition to activate the next runnable task.`, "advance"); } const next = runnableTasksForActor(actor.id)[0]; if (next?.origin) { return err(`Task #${next.id} is a canonical Workflow mirror. Advance it through the Workflow Run lifecycle; use todo next only when the mirror instructions explicitly require activation.`, "advance"); } return relabelTodoResult(await handleNext(ctx, actor, generation), "advance"); } if (active.origin) { return err(`Task #${active.id} is a canonical Workflow mirror. Advance it through the Workflow Run lifecycle, not todo advance.`, "advance"); } if (active.assignee.id !== actor.id) { return err(`@${actor.label} cannot advance task #${active.id}; it is assigned to @${active.assignee.label}.`, "advance"); } if (!params.id) { return err(`id is required to complete active task #${active.id} with advance.`, "advance"); } if (params.id !== active.id) { return err(`Advance id mismatch for @${actor.label}: active task is #${active.id}, not #${params.id}.`, "advance"); } const summary = params.summary?.trim(); if (!summary) { return err(`summary is required to complete active task #${active.id} with advance.`, "advance"); } const completionResourceUris = suppliedResources === undefined ? undefined : appendTodoResourceUris(active.resourceUris, suppliedResources); const completionFields: TodoUpdateField[] = ["status", "summary"]; if (completionResourceUris !== undefined) completionFields.push("resourceUris"); if (params.handoff !== undefined) completionFields.push("handoff"); const completion = await handleUpdate({ action: "update", id: active.id, status: "completed", summary, ...(completionResourceUris !== undefined ? { resourceUris: completionResourceUris } : {}), ...(params.handoff !== undefined ? { handoff: params.handoff } : {}), updateFields: completionFields, }, ctx, actor, generation); if (completion.isError) return relabelTodoResult(completion, "advance"); const completedNote = `Completed #${active.id}: ${todoPreview(active, "subject", active.subject, TODO_SUBJECT_BYTES)}`; const nextCandidate = runnableTasksForActor(actor.id)[0]; if (nextCandidate?.origin) { return ok( `${completedNote}. Next task #${nextCandidate.id} is a canonical Workflow mirror and remains ${nextCandidate.status}; advance it through the Workflow Run lifecycle.`, "advance", undefined, params.transition, ); } let next: FlowToolResult; try { next = await handleNext(ctx, actor, generation); } catch (error) { return err(`${completedNote}, but the next task was not activated: ${error instanceof Error ? error.message : String(error)}`, "advance"); } const nextText = todoResultText(next); if (next.isError) { if (nextText.startsWith("Error: Dependency deadlock:")) { return ok( `${completedNote}. ${nextText.replace("Error: Dependency deadlock:", "Waiting:")}`, "advance", undefined, params.transition, ); } if (nextText.includes("the task remains in_progress")) { return err(`${completedNote}. ${nextText.replace(/^Error: /, "")}`, "advance"); } return err(`${completedNote}, but the next task was not activated: ${nextText}`, "advance"); } return ok(`${completedNote}\n\n${nextText}`, "advance", undefined, params.transition); } function todoResultText(result: FlowToolResult): string { const block = result.content.find((item) => item.type === "text"); return block && "text" in block ? block.text : "Todo request completed."; } function relabelTodoResult(result: FlowToolResult, action: string): FlowToolResult { const details = result.details as TodoResultDetails | undefined; return details ? { ...result, details: { ...details, action } } : result; } function runnableTasksForActor(actorId: string): TodoTask[] { return [...tasks.values()] .filter((task) => task.assignee.id === actorId && task.status === "pending" && task.blockedBy.length === 0) .sort((left, right) => left.createdAt - right.createdAt); } async function handleNext( ctx: ExtensionContext, actor: TodoActorRef, generation: number, ): Promise { const active = findActiveTask(actor.id); if (active) { return err(`Task #${active.id} is already in progress for @${actor.label} (one in_progress per actor). Complete or pause it first, or dispatch parallel work via teammates without changing todo status.`, "next"); } const pending = runnableTasksForActor(actor.id); if (pending.length === 0) { const inProgress = [...tasks.values()].filter((t) => t.assignee.id === actor.id && t.status === "in_progress"); if (inProgress.length > 0) { return ok(`No pending tasks. ${inProgress.length} task(s) in progress.`, "next"); } const blocked = getVisibleTasks().filter( (task) => task.assignee.id === actor.id && (task.status === "blocked" || (task.status === "pending" && task.blockedBy.length > 0)), ); if (blocked.length > 0) { const blockerDetails = blocked.slice(0, PREV_CONTEXT_WINDOW).map((task) => { const dependencies = task.blockedBy.map((depId) => { const dependency = tasks.get(depId); return dependency ? `#${depId} (${dependency.status})` : `#${depId} (missing)`; }); return `#${task.id} ${todoPreview(task, "subject", task.subject, TODO_SUBJECT_BYTES)} blocked by ${todoPreview(task, "all", dependencies.join(", ") || "an unresolved dependency", 512, true)}`; }); if (blocked.length > PREV_CONTEXT_WINDOW) { blockerDetails.push(`${blocked.length - PREV_CONTEXT_WINDOW} more blocked tasks; use todo {"action":"list","filter":{"memberId":"self"}} for IDs, then get each task`); } return err( `Dependency deadlock: no runnable pending task. ${blockerDetails.join("; ")}`, "next", ); } return ok("All tasks completed or no tasks exist.", "next"); } const task = pending[0]; // Set when the task's quality gate is a Goal the user stopped by hand, so the notice // below can be appended after the header is built. let userStoppedGate: string | undefined; // The actual Goal switch is deferred until the task commit succeeds: skill // activation below can fail, and a failed `next` must not leave the active // Goal switched as a stray side effect. let pendingGoalSwitch = false; if (task.goalId) { const current = getActiveGoal(); if (current?.id !== task.goalId || current?.status === "paused") { const gate = getGoalById(task.goalId); // `/goal stop` is the user speaking. Auto-resuming here would silently undo it, // and advancing a task is not consent to restart a Goal that was deliberately // halted. Every other pause reason is system-internal, so resuming is right there. if (gate?.status === "paused" && gate.pauseReason === "user") userStoppedGate = gate.text; pendingGoalSwitch = true; } } const draft = cloneTodoTask(task); const allTasks = getVisibleTasks(); const taskIndex = allTasks.findIndex((t) => t.id === task.id); const parts: string[] = [ `## Task #${task.id} [${taskIndex + 1}/${allTasks.length}]: ${todoPreview(task, "subject", task.subject, TODO_SUBJECT_BYTES)}`, ]; if (task.description) parts.push(todoPreview(task, "description", task.description, TODO_DESCRIPTION_BYTES)); const prevContext = buildPrevContext(task.id); if (prevContext) { parts.push(`\n\n${prevContext}\n`); } // Goal context reflects the gate that becomes active with this task (the // switch itself happens after the commit below). const contextGoal = (task.goalId ? getGoalById(task.goalId) : undefined) ?? getActiveGoal(); const goalPreview = (text: string) => boundedTodoText(text, TODO_REFERENCE_BYTES, () => 'goal {"action":"get"}'); if (contextGoal?.text) { parts.push(`\n\n${goalPreview(contextGoal.text)}\n`); } if (userStoppedGate) { parts.push( `\n\nThe quality-gate Goal "${goalPreview(userStoppedGate)}" was stopped by the user and has been left stopped.` + `\nYou can work on this task, but completing it is blocked until the user runs /goal resume. Do not resume the Goal yourself.` + `\n`, ); } if (task.context) { parts.push(`\n\n${todoPreview(task, "context", task.context, TODO_CONTEXT_BYTES)}\n`); } if (task.resourceUris.length > 0) { parts.push(`\nResource URIs: ${todoPreview(task, "resourceUris", todoFieldText(task, "resourceUris"), TODO_REFERENCE_BYTES, true)}`); } if (task.handoff) parts.push(`\n${todoPreview(task, "handoff", formatTodoHandoff(task), TODO_REFERENCE_BYTES, true)}`); // Skill-less tasks skip activation entirely: there is nothing to load, and // without an await there is no concurrency window to revalidate against. let activation: SkillActivation | undefined; if (draft.skills.length > 0) { const before = cloneTodoTask(task); activation = await activateTask(draft); draft.status = "in_progress"; revalidateAsyncTodoMutation({ generation, before, draft, actor, }); } else { draft.status = "in_progress"; } // Skill prompts are NOT inlined here: the full text is injected once per turn // as context while the task is active (onContextTodo). // Repeating them in this persistent tool result would double the token cost. if (activation && activation.skills.length > 0) { const bound = activation.skills .map((binding) => `- ${binding.skill.name} (${binding.role})`) .join("\n"); parts.push(`\n\n${todoPreview(task, "skills", bound, 512, true)}\nFull skill instructions arrive as context while this task is active.\n`); } draft.skillActivation = activation ? activationMetadata(activation) : undefined; draft.updatedAt = Date.now(); applyTaskStatusTiming(task, draft, draft.updatedAt); const nextTasks = new Map(tasks); nextTasks.set(draft.id, draft); commitTodoState(nextTasks); if (activation) activeSkillSnapshots.set(draft.id, activation); runSkillInjection = undefined; if (pendingGoalSwitch && task.goalId) { try { const switched = switchCurrentGoal(task.goalId, ctx, { resume: !userStoppedGate }); if (!switched) throw new Error(`Goal ${task.goalId} was not found.`); } catch (goalError) { const rollback = cloneTodoTask(task); rollback.updatedAt = Date.now(); const rollbackTasks = new Map(tasks); rollbackTasks.set(rollback.id, rollback); try { commitTodoState(rollbackTasks); clearSkillSnapshot(task.id); } catch (rollbackError) { return err( `Task #${task.id} was activated, but Goal ${task.goalId} selection failed and Todo rollback also failed; the task remains in_progress. Goal error: ${goalError instanceof Error ? goalError.message : String(goalError)}. Rollback error: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, "next", ); } return err( `Goal ${task.goalId} selection failed for task #${task.id}; activation was rolled back to ${task.status}. ${goalError instanceof Error ? goalError.message : String(goalError)}`, "next", ); } } return ok(parts.join("\n"), "next"); } const PREV_CONTEXT_WINDOW = 5; function buildPrevContext(currentId: string): string | null { const current = tasks.get(currentId); if (!current) return null; // Completed dependencies are removed by autoUnblock. Use existing ownership // and plan linkage rather than inventing a persisted dependency history. const completed = [...tasks.values()] .filter((t) => t.status === "completed" && t.id !== currentId && t.summary?.trim()); const samePlan = current.planHandoffKey ? completed.filter((t) => t.planHandoffKey === current.planHandoffKey) : []; const related = (current.planHandoffKey ? samePlan : completed.filter((t) => t.assignee.id === current.assignee.id)) .sort((a, b) => a.updatedAt - b.updatedAt); if (related.length === 0) return null; return related .slice(-PREV_CONTEXT_WINDOW) .map((t) => `[#${t.id}] ${todoPreview(t, "subject", t.subject, TODO_SUBJECT_BYTES)}: ${todoPreview(t, "summary", t.summary!, TODO_SUMMARY_BYTES)}`) .join("\n"); } function markTodoChanged(): void { todoRevision++; for (const listener of todoStateChangeSubscribers) { try { listener(); } catch { // Projection listeners are best-effort and must not break Todo mutations. } } } function cloneTodoTask(task: TodoTask): TodoTask { return { ...task, blockedBy: [...task.blockedBy], skills: task.skills.map((skill) => ({ ...skill })), resourceUris: task.resourceUris ? [...task.resourceUris] : [], ...(task.handoff ? { handoff: cloneTodoHandoff(task.handoff) } : {}), createdBy: cloneActor(task.createdBy), assignee: cloneActor(task.assignee), ...(task.origin ? { origin: { ...task.origin } } : {}), ...(task.skillActivation ? { skillActivation: cloneSkillActivation(task.skillActivation) } : {}), }; } function cloneTaskMap(state: Map = tasks): Map { return new Map([...state].map(([id, task]) => [id, cloneTodoTask(task)])); } /** * Ids are peeked during validation and only committed (counter advanced) once * the create is known to succeed, so failed creates do not burn id numbers. */ function peekTaskId(offset = 0): string { return String(nextTaskId + offset); } function commitTaskIds(count: number): void { nextTaskId += count; } function syncTaskIdCounter(state: Map = tasks): void { let max = 0; for (const id of state.keys()) { const n = Number(id); if (Number.isInteger(n) && n > max) max = n; } nextTaskId = max + 1; } /** Keep new handoff annotations newer than every revision restored from disk. */ function syncTodoRevision(state: Map = tasks): void { for (const task of state.values()) { if (!task.handoff) continue; todoRevision = Math.max(todoRevision, task.handoff.nextStepsRevision ?? 0, task.handoff.filesClearedRevision ?? 0); for (const file of task.handoff.files) { todoRevision = Math.max(todoRevision, file.annotationRevision); } } } function cloneSkillActivation(activation: SkillActivationMetadata): SkillActivationMetadata { return { ...activation, bindings: activation.bindings.map(cloneActivationBinding), }; } // --------------------------------------------------------------------------- // Status transitions // --------------------------------------------------------------------------- const VALID_TRANSITIONS: Record = { pending: ["in_progress", "blocked", "completed", "deleted"], in_progress: ["completed", "blocked", "pending", "deleted"], completed: ["deleted"], blocked: ["pending", "in_progress", "deleted"], deleted: [], }; function isValidTransition(from: TaskStatus, to: TaskStatus): boolean { return VALID_TRANSITIONS[from]?.includes(to) ?? false; } function applyTaskStatusTiming(before: TodoTask, draft: TodoTask, changedAt = Date.now()): void { if (before.status === draft.status) return; if (before.status === "in_progress") { if (before.activeStartedAt !== undefined) { draft.activeDurationMs = (before.activeDurationMs ?? 0) + Math.max(0, changedAt - before.activeStartedAt); } delete draft.activeStartedAt; } if (draft.status === "in_progress") { draft.activeStartedAt = changedAt; delete draft.completedAt; } else if (draft.status === "completed") { draft.completedAt = changedAt; } } // --------------------------------------------------------------------------- // Dependency management // --------------------------------------------------------------------------- function resolveBlockedBy( taskId: string, proposedDeps: readonly string[], state: Map = tasks, ): { blockedBy: string[]; error?: string } { const blockedBy: string[] = []; const seen = new Set(); for (const rawDepId of proposedDeps) { const depId = rawDepId.replace(/^#+/, ""); if (seen.has(depId)) continue; seen.add(depId); if (depId === taskId) return { blockedBy: [], error: "Task cannot block itself" }; const dependency = state.get(depId); if (!dependency) { return { blockedBy: [], error: `blockedBy references unknown task: ${depId}` }; } if (dependency.status === "deleted") { return { blockedBy: [], error: `blockedBy references deleted task: ${depId}` }; } if (dependency.status === "completed") continue; blockedBy.push(depId); } if (hasCycle(taskId, blockedBy, state)) { return { blockedBy: [], error: "blockedBy would create a dependency cycle" }; } return { blockedBy }; } function deriveDependencyStatus(blockedBy: readonly string[]): "blocked" | "pending" { return blockedBy.length > 0 ? "blocked" : "pending"; } function hasCycle( taskId: string, proposedDeps: string[], state: Map = tasks, ): boolean { const visited = new Set(); const stack = [...proposedDeps]; while (stack.length > 0) { const current = stack.pop()!; if (current === taskId) return true; if (visited.has(current)) continue; visited.add(current); const dep = state.get(current); if (dep) stack.push(...dep.blockedBy); } return false; } function autoUnblock(state: Map, completedId: string): void { for (const [id, t] of state) { if (t.status === "deleted") continue; if (!t.blockedBy.includes(completedId)) continue; const next = cloneTodoTask(t); next.blockedBy = next.blockedBy.filter((d) => d !== completedId); if (next.status === "blocked" || next.status === "pending") { next.status = deriveDependencyStatus(next.blockedBy); } next.updatedAt = Date.now(); state.set(id, next); } } /** * Drop the quality-gate binding from every task pointing at `goalId`. * * Mirrors {@link autoUnblock}: when an entity is destroyed, the references to it must go with * it. Without this, clearing a Goal leaves its bound tasks stuck at the completion gate * (`getGoalById` returns undefined) with no path to completion short of a manual * `update goalId: ""`. * * Returns the number of tasks detached so callers can surface it instead of failing silently. */ export function detachTasksFromGoal(goalId: string): number { if (!goalId) return 0; const next = new Map(tasks); let detached = 0; for (const [id, task] of next) { if (task.status === "deleted" || task.goalId !== goalId) continue; const draft = cloneTodoTask(task); delete draft.goalId; draft.updatedAt = Date.now(); next.set(id, draft); detached += 1; } if (detached === 0) return 0; commitTodoState(next); return detached; } /** Persist and publish the state restored by Todo startup before a Goal recovery marker clears. */ export function persistLoadedTodoStateForGoalDetachRecovery(): void { if (!todoSessionLoaded) throw new Error("Todo state must be loaded before Goal cleanup recovery."); commitTodoState(new Map(tasks)); } // --------------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------------- function commitTodoState( nextTasks: Map, ): void { persist(nextTasks); tasks = nextTasks; markTodoChanged(); try { onTodoStateChanged?.(); } catch (error) { console.warn(`[pi-maestro-flow] Todo state projection failed after commit: ${error instanceof Error ? error.message : String(error)}`); } } /** Subscribe to every Todo revision change without replacing the root UI listener. */ export function subscribeTodoStateChanges(listener: () => void): () => void { todoStateChangeSubscribers.add(listener); return () => todoStateChangeSubscribers.delete(listener); } /** Bind the root UI/UCL to durable Todo state changes. */ export function setTodoStateChangeListener(listener: (() => void) | undefined): void { onTodoStateChanged = listener; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- interface AsyncTodoMutationCheck { generation: number; before: TodoTask; draft: TodoTask; actor: TodoActorRef; } /** * Async skill loading must not publish a draft derived from stale Todo state. * Re-check every task-level authority and graph invariant at the await * boundary even though ordinary tool mutations are serialized: canonical * Workflow mirrors and session lifecycle hooks can still change module state * independently. The checks are deliberately scoped to the mutated task (deep * compare, permissions, dependency graph, in_progress uniqueness, transition * validity) rather than a global revision counter, so unrelated concurrent * changes — e.g. a teammate updating a different task — do not force a * spurious retry. */ function revalidateAsyncTodoMutation(check: AsyncTodoMutationCheck): TodoTask { assertTodoGeneration(check.generation); const current = tasks.get(check.before.id); if (!current || current.status === "deleted") { throw new Error(`Task #${check.before.id} is no longer available after skill activation.`); } if (JSON.stringify(current) !== JSON.stringify(check.before)) { throw new Error(`Task #${check.before.id} changed while its skills were activating; retry the mutation.`); } if (!canEditTask(check.actor, current)) { throw new Error(`@${check.actor.label} can no longer update task #${check.before.id}.`); } const dependencyCheck = resolveBlockedBy(check.draft.id, check.draft.blockedBy); if (dependencyCheck.error) throw new Error(dependencyCheck.error); if (JSON.stringify(dependencyCheck.blockedBy) !== JSON.stringify(check.draft.blockedBy)) { throw new Error(`Dependencies changed while activating task #${check.draft.id}; retry the mutation.`); } if (check.draft.status === "in_progress") { if (dependencyCheck.blockedBy.length > 0) { throw new Error(`Task #${check.draft.id} is blocked by: ${dependencyCheck.blockedBy.join(", ")}`); } const active = findActiveTask(check.draft.assignee.id, check.draft.id); if (active) { throw new Error( `Task #${active.id} is already in progress for @${check.draft.assignee.label} (one in_progress per actor). For parallel work, dispatch teammates without changing todo status; update each task when its agent completes.`, ); } } if ( check.draft.status !== current.status && !isValidTransition(current.status, check.draft.status) ) { throw new Error(`Invalid status transition: ${current.status} → ${check.draft.status}`); } return current; } function statusIcon(status: TaskStatus): string { switch (status) { case "completed": return "[x]"; case "in_progress": return "[>]"; case "blocked": return "[!]"; case "pending": return "[ ]"; case "deleted": return "[-]"; } } function taskChanged(before: TodoTask, after: TodoTask): boolean { return ( before.subject !== after.subject || before.description !== after.description || before.status !== after.status || before.summary !== after.summary || JSON.stringify(before.blockedBy) !== JSON.stringify(after.blockedBy) || before.context !== after.context || before.goalId !== after.goalId || JSON.stringify(before.skills) !== JSON.stringify(after.skills) || JSON.stringify(before.resourceUris) !== JSON.stringify(after.resourceUris) || JSON.stringify(before.handoff) !== JSON.stringify(after.handoff) || JSON.stringify(before.origin) !== JSON.stringify(after.origin) || JSON.stringify(before.createdBy) !== JSON.stringify(after.createdBy) || JSON.stringify(before.assignee) !== JSON.stringify(after.assignee) || before.activeStartedAt !== after.activeStartedAt || before.activeDurationMs !== after.activeDurationMs || before.completedAt !== after.completedAt ); } function mirrorTaskId(originKey: string): string { return `wf-${createHash("sha256").update(originKey).digest("hex").slice(0, 8)}`; } function uniqueMirrorId( candidate: string, originKey: string, state: Map = tasks, ): string { const occupied = state.get(candidate); if (!occupied || (occupied.origin && todoOriginKey(occupied.origin) === originKey)) return candidate; return `wf-${createHash("sha256").update(`${originKey}\u0000collision`).digest("hex").slice(0, 12)}`; } function mirrorActivationStillValid(before: TodoTask, after: TodoTask): boolean { return before.status === "in_progress" && after.status === "in_progress" && before.context === after.context && JSON.stringify(before.skills) === JSON.stringify(after.skills); } async function ensureSkillActivation(task: TodoTask): Promise { const cached = activeSkillSnapshots.get(task.id); // Without the `cached &&` guard, a task with no cached snapshot and no // skillActivation compares undefined === undefined and returns undefined // from a function declared to yield a SkillActivation. if (cached && cached.stackRevision === task.skillActivation?.stackRevision) { return cached; } const generation = todoGeneration; const before = cloneTodoTask(task); let activation: SkillActivation; try { activation = await requireSkillRuntime().activate( task.skills, task.context ?? "", task.skillActivation, ); } catch (error) { // onContextTodo is wired to pi.on("context"), so a throw here escapes into the // host on *every* turn and wedges the session. Re-activation is guaranteed to be // attempted after a restart (snapshots live in memory, persist only writes tasks), // so a skill file that has gone missing or invalid since activation would do // exactly that. // // Degrade instead: prefer the last snapshot that did load, otherwise synthesize an // empty activation whose prompt is a warning block. Neither is cached and neither is // persisted, so the next turn retries the real load and recovers on its own. const stale = activeSkillSnapshots.get(task.id); if (stale) return stale; return degradedActivation(task, error); } // The revalidation shares the fail-open constraint above: a concurrent Todo // mutation during the activate await must not escape this context-hook path // as an exception. On conflict the fresh activation still serves this turn, // but nothing is cached or persisted — the next turn re-activates against // the settled state. This also keeps the persist below from racing queued // mutations: it only runs when the task provably did not change. let current: TodoTask; try { current = revalidateAsyncTodoMutation({ generation, before, draft: before, actor: ROOT_TODO_ACTOR, }); } catch { return activation; } const nextMetadata: SkillActivationMetadata = { activationId: activation.activationId, stackRevision: activation.stackRevision, activatedAt: activation.activatedAt, validatedAt: activation.validatedAt, state: activation.state, bindings: activation.bindings.map(cloneActivationBinding), }; if (JSON.stringify(current.skillActivation) !== JSON.stringify(nextMetadata)) { const draft = cloneTodoTask(current); draft.skillActivation = nextMetadata; const nextTasks = new Map(tasks); nextTasks.set(draft.id, draft); persist(nextTasks); tasks = nextTasks; markTodoChanged(); } activeSkillSnapshots.set(current.id, activation); return activation; } function cloneActor(actor: TodoActorRef): TodoActorRef { return { ...actor }; } function rememberActor(actor: TodoActorRef): void { knownActors.set(actor.id, cloneActor(actor)); } type TodoActorResolution = | { actor: TodoActorRef } | { error: string; reason: "unknown" | "ambiguous" }; function resolveTodoActorSelector(requested: string, actor: TodoActorRef): TodoActorResolution { const selector = requested.trim().replace(/^@/, ""); if (selector === "self" || selector === actor.id || selector === actor.label) { return { actor: cloneActor(actor) }; } if (selector === ROOT_TODO_ACTOR.id) return { actor: cloneActor(ROOT_TODO_ACTOR) }; const exactId = knownActors.get(selector); if (exactId) return { actor: cloneActor(exactId) }; const labelMatches = [...knownActors.values()].filter((candidate) => candidate.label === selector); if (labelMatches.length === 1) return { actor: cloneActor(labelMatches[0]) }; if (labelMatches.length > 1) { return { error: `Ambiguous Todo member selector: ${requested}; use label#unique-id-prefix or the full member id`, reason: "ambiguous", }; } const marker = selector.lastIndexOf("#"); if (marker > 0 && marker < selector.length - 1) { const label = selector.slice(0, marker); const idPrefix = selector.slice(marker + 1); const decoratedMatches = [...knownActors.values()].filter((candidate) => candidate.label === label && candidate.id.startsWith(idPrefix) ); if (decoratedMatches.length === 1) return { actor: cloneActor(decoratedMatches[0]) }; if (decoratedMatches.length > 1) { return { error: `Ambiguous Todo member selector: ${requested}; use a longer id prefix`, reason: "ambiguous", }; } } if (!selector.includes("#")) { const idPrefixMatches = [...knownActors.values()].filter((candidate) => candidate.id.startsWith(selector)); if (idPrefixMatches.length === 1) return { actor: cloneActor(idPrefixMatches[0]) }; if (idPrefixMatches.length > 1) { return { error: `Ambiguous Todo member selector: ${requested}; use a longer id prefix or the full member id`, reason: "ambiguous", }; } } return { error: `Unknown Todo member selector: ${requested}`, reason: "unknown" }; } function resolveAssignee( requested: string | undefined, actor: TodoActorRef, ): { actor: TodoActorRef } | { error: string } { if (!requested) return { actor: cloneActor(actor) }; const resolved = resolveTodoActorSelector(requested, actor); if ("error" in resolved) { if (actor.kind !== "root") { return { error: `@${actor.label} can only assign Todo tasks to self or root` }; } return { error: resolved.reason === "ambiguous" ? resolved.error.replace("member selector", "assignee") : `Unknown Todo assignee: ${requested}` }; } if (actor.kind !== "root" && resolved.actor.id !== actor.id && resolved.actor.id !== ROOT_TODO_ACTOR.id) { return { error: `@${actor.label} can only assign Todo tasks to self or root` }; } return resolved; } function canEditTask(actor: TodoActorRef, task: TodoTask): boolean { return actor.kind === "root" || task.createdBy.id === actor.id || task.assignee.id === actor.id; } function canDeleteTask(actor: TodoActorRef, task: TodoTask): boolean { if (actor.kind === "root") return true; if (task.createdBy.id !== actor.id) return false; return task.assignee.id === actor.id || task.status !== "in_progress"; } function actorTag(task: TodoTask): string { const actors = [...knownActors.values()]; const createdBy = formatTodoActorSelector(task.createdBy, actors); const assignee = formatTodoActorSelector(task.assignee, actors); return task.createdBy.id === task.assignee.id ? `@${assignee}` : `@${createdBy}→@${assignee}`; } function findActiveTask( assigneeId: string, excludeId?: string, state: Map = tasks, ): TodoTask | undefined { return [...state.values()].find( (task) => task.status === "in_progress" && task.assignee.id === assigneeId && task.id !== excludeId, ); } function snapshotDetails( action: string, error?: string, displayTaskIds?: string[], transition?: TodoAdvanceTransition, ): TodoResultDetails { const capturedAt = Date.now(); const visibleTasks = getVisibleTasks(); const scopedTasks = error === undefined ? visibleTasks : displayTaskIds === undefined ? [] : visibleTasks.filter((task) => displayTaskIds.includes(task.id)); const tasks = scopedTasks.map((task): TodoTaskSnapshot => { const durationMs = (task.activeDurationMs ?? 0) + (task.status === "in_progress" && task.activeStartedAt !== undefined ? Math.max(0, capturedAt - task.activeStartedAt) : 0); return { id: task.id, subject: task.subject, status: task.status, assignee: cloneActor(task.assignee), ...(task.blockedBy.length > 0 ? { blockedBy: [...task.blockedBy] } : {}), ...(error === undefined && task.summary ? { summary: task.summary } : {}), ...(durationMs > 0 || task.status === "completed" ? { durationMs } : {}), ...(task.status === "completed" ? { completedAt: task.completedAt ?? task.updatedAt } : {}), }; }); return { action, tasks, ...(displayTaskIds !== undefined ? { displayTaskIds } : {}), ...(transition !== undefined ? { transition } : {}), ...(error ? { error } : {}), }; } function ok( text: string, action = "unknown", displayTaskIds?: string[], transition?: TodoAdvanceTransition, ): FlowToolResult { return { content: [{ type: "text", text }], details: snapshotDetails(action, undefined, displayTaskIds, transition), }; } function err(text: string, action = "unknown", affectedTaskIds?: string[]): FlowToolResult { return { content: [{ type: "text", text: `Error: ${text}` }], isError: true, details: snapshotDetails(action, text, affectedTaskIds), }; }