@aprimediet/minion 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tasks.ts ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Persistent delegation/task board — a lightweight kanban (à la Hermes' agent board) stored as
3
+ * one markdown card per task under ~/.pi/projects/<id>/tasks/. Each card carries a status
4
+ * (column), a designated agent (assignee), a structured instruction the subagent can execute
5
+ * directly, acceptance criteria, dependencies, and an activity log. Delegation records are written
6
+ * under .../delegations/ so every delegation's full detail is captured.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { type ExtensionAPI, type ExtensionContext, parseFrontmatter, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
12
+ import { StringEnum } from "@earendil-works/pi-ai";
13
+ import { Text } from "@earendil-works/pi-tui";
14
+ import { Type } from "typebox";
15
+ import { resolveProject } from "./project.ts";
16
+
17
+ export type TaskStatus = "backlog" | "todo" | "in_progress" | "blocked" | "review" | "done" | "cancelled";
18
+ export const STATUS_ORDER: TaskStatus[] = ["backlog", "todo", "in_progress", "blocked", "review", "done", "cancelled"];
19
+ /** Statuses that should be re-delegated when work resumes in a new session. */
20
+ const RESUMABLE = new Set<TaskStatus>(["todo", "in_progress", "blocked"]);
21
+ const OPEN = new Set<TaskStatus>(["backlog", "todo", "in_progress", "blocked", "review"]);
22
+
23
+ export interface Task {
24
+ id: string;
25
+ title: string;
26
+ status: TaskStatus;
27
+ agent: string;
28
+ priority: "low" | "normal" | "high";
29
+ labels: string[];
30
+ dependsOn: string[];
31
+ created: string;
32
+ updated: string;
33
+ attempts: number;
34
+ session: string;
35
+ instruction: string;
36
+ acceptance: string[];
37
+ notes: string;
38
+ activity: string[];
39
+ }
40
+
41
+ function nowISO(): string {
42
+ return new Date().toISOString();
43
+ }
44
+ export function generateTaskId(): string {
45
+ return `t-${Math.random().toString(36).slice(2, 8)}`;
46
+ }
47
+
48
+ // --------------------------------------------------------------- (de)serialize
49
+
50
+ function csv(v: string[]): string {
51
+ return v.filter(Boolean).join(", ");
52
+ }
53
+ function splitCsv(v: string | undefined): string[] {
54
+ return (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
55
+ }
56
+
57
+ function section(title: string, content: string): string {
58
+ return `## ${title}\n${content.trim()}\n`;
59
+ }
60
+
61
+ function serialize(t: Task): string {
62
+ const fm = [
63
+ "---",
64
+ `id: ${t.id}`,
65
+ `title: ${t.title}`,
66
+ `status: ${t.status}`,
67
+ `agent: ${t.agent}`,
68
+ `priority: ${t.priority}`,
69
+ `labels: ${csv(t.labels)}`,
70
+ `depends_on: ${csv(t.dependsOn)}`,
71
+ `created: ${t.created}`,
72
+ `updated: ${t.updated}`,
73
+ `attempts: ${t.attempts}`,
74
+ `session: ${t.session}`,
75
+ "---",
76
+ "",
77
+ ].join("\n");
78
+ const body = [
79
+ section("Instruction", t.instruction || "(none)"),
80
+ section("Acceptance criteria", t.acceptance.length ? t.acceptance.map((a) => `- ${a}`).join("\n") : "(none)"),
81
+ section("Notes", t.notes || "(none)"),
82
+ section("Activity", t.activity.length ? t.activity.map((a) => `- ${a}`).join("\n") : "(none)"),
83
+ ].join("\n");
84
+ return `${fm}${body}`;
85
+ }
86
+
87
+ function parseSections(body: string): Record<string, string> {
88
+ const out: Record<string, string> = {};
89
+ const parts = body.split(/^## /m);
90
+ for (const part of parts) {
91
+ const nl = part.indexOf("\n");
92
+ if (nl === -1) continue;
93
+ const heading = part.slice(0, nl).trim().toLowerCase();
94
+ if (!heading) continue;
95
+ out[heading] = part.slice(nl + 1).trim();
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function parse(content: string, fallbackId: string): Task | null {
101
+ try {
102
+ const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
103
+ if (!frontmatter?.id && !frontmatter?.title) return null;
104
+ const s = parseSections(body);
105
+ const listFrom = (txt: string | undefined): string[] =>
106
+ (txt && txt !== "(none)" ? txt.split("\n") : [])
107
+ .map((l) => l.replace(/^[-*]\s*/, "").trim())
108
+ .filter(Boolean);
109
+ const text = (txt: string | undefined): string => (txt && txt !== "(none)" ? txt : "");
110
+ return {
111
+ id: frontmatter.id || fallbackId,
112
+ title: frontmatter.title || "(untitled)",
113
+ status: (frontmatter.status as TaskStatus) || "todo",
114
+ agent: frontmatter.agent || "",
115
+ priority: (frontmatter.priority as Task["priority"]) || "normal",
116
+ labels: splitCsv(frontmatter.labels),
117
+ dependsOn: splitCsv(frontmatter.depends_on),
118
+ created: frontmatter.created || nowISO(),
119
+ updated: frontmatter.updated || frontmatter.created || nowISO(),
120
+ attempts: Number.parseInt(frontmatter.attempts ?? "0", 10) || 0,
121
+ session: frontmatter.session || "",
122
+ instruction: text(s.instruction),
123
+ acceptance: listFrom(s["acceptance criteria"]),
124
+ notes: text(s.notes),
125
+ activity: listFrom(s.activity),
126
+ };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // --------------------------------------------------------------- store ops
133
+
134
+ function taskPath(tasksDir: string, id: string): string {
135
+ return path.join(tasksDir, `${id}.md`);
136
+ }
137
+
138
+ export function listTasks(tasksDir: string): Task[] {
139
+ if (!fs.existsSync(tasksDir)) return [];
140
+ const out: Task[] = [];
141
+ for (const name of fs.readdirSync(tasksDir)) {
142
+ if (!name.endsWith(".md")) continue;
143
+ try {
144
+ const t = parse(fs.readFileSync(path.join(tasksDir, name), "utf-8"), name.replace(/\.md$/, ""));
145
+ if (t) out.push(t);
146
+ } catch {
147
+ /* skip */
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+
153
+ export function getTask(tasksDir: string, id: string): Task | null {
154
+ const file = taskPath(tasksDir, id);
155
+ if (!fs.existsSync(file)) return null;
156
+ try {
157
+ return parse(fs.readFileSync(file, "utf-8"), id);
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ export async function writeTask(tasksDir: string, t: Task): Promise<void> {
164
+ const file = taskPath(tasksDir, t.id);
165
+ fs.mkdirSync(tasksDir, { recursive: true });
166
+ await withFileMutationQueue(file, async () => {
167
+ const tmp = `${file}.tmp`;
168
+ await fs.promises.writeFile(tmp, serialize(t), { encoding: "utf-8", mode: 0o600 });
169
+ await fs.promises.rename(tmp, file);
170
+ });
171
+ }
172
+
173
+ export function listResumable(tasksDir: string): Task[] {
174
+ return listTasks(tasksDir).filter((t) => RESUMABLE.has(t.status));
175
+ }
176
+ export function listOpen(tasksDir: string): Task[] {
177
+ return listTasks(tasksDir).filter((t) => OPEN.has(t.status));
178
+ }
179
+
180
+ // --------------------------------------------------------------- delegation linkage
181
+
182
+ export function loadTaskInstruction(tasksDir: string, id: string): { instruction: string; agent: string } | null {
183
+ const t = getTask(tasksDir, id);
184
+ if (!t) return null;
185
+ const parts = [t.instruction];
186
+ if (t.acceptance.length) parts.push(`\nAcceptance criteria:\n${t.acceptance.map((a) => `- ${a}`).join("\n")}`);
187
+ if (t.notes) parts.push(`\nNotes:\n${t.notes}`);
188
+ return { instruction: parts.filter(Boolean).join("\n").trim() || t.title, agent: t.agent };
189
+ }
190
+
191
+ export async function markTaskDelegating(tasksDir: string, id: string, agent: string, session: string): Promise<void> {
192
+ const t = getTask(tasksDir, id);
193
+ if (!t) return;
194
+ t.status = "in_progress";
195
+ if (agent) t.agent = agent;
196
+ t.attempts += 1;
197
+ t.updated = nowISO();
198
+ t.activity.push(`${nowISO()} — delegated to ${agent || t.agent || "?"} (attempt ${t.attempts}, session ${session.slice(0, 8)})`);
199
+ await writeTask(tasksDir, t);
200
+ }
201
+
202
+ export async function updateTaskAfterDelegation(tasksDir: string, id: string, summary: string, ok: boolean): Promise<void> {
203
+ const t = getTask(tasksDir, id);
204
+ if (!t) return;
205
+ t.status = ok ? "review" : "blocked";
206
+ t.updated = nowISO();
207
+ const clipped = summary.length > 200 ? `${summary.slice(0, 200)}…` : summary;
208
+ t.activity.push(`${nowISO()} — ${ok ? "completed → review" : "failed → blocked"}: ${clipped.replace(/\n/g, " ")}`);
209
+ await writeTask(tasksDir, t);
210
+ }
211
+
212
+ // --------------------------------------------------------------- delegation records
213
+
214
+ export interface DelegationResult {
215
+ agent: string;
216
+ task: string;
217
+ output: string;
218
+ failed: boolean;
219
+ stopReason?: string;
220
+ model?: string;
221
+ }
222
+
223
+ export async function recordDelegation(
224
+ delegationsDir: string,
225
+ mode: string,
226
+ results: DelegationResult[],
227
+ ): Promise<void> {
228
+ try {
229
+ fs.mkdirSync(delegationsDir, { recursive: true });
230
+ const ts = nowISO();
231
+ const stamp = ts.replace(/[:.]/g, "-");
232
+ const first = results[0]?.agent ?? "agent";
233
+ const file = path.join(delegationsDir, `${stamp}-${mode}-${first}.md`);
234
+ const blocks = results.map((r, i) => {
235
+ const head = `### [${i + 1}] ${r.agent} — ${r.failed ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}` : "completed"}${r.model ? ` · ${r.model}` : ""}`;
236
+ return [head, "", "**Task**", r.task.trim(), "", "**Result**", (r.output || "(no output)").trim(), ""].join("\n");
237
+ });
238
+ const body = [`# Delegation — ${ts}`, `mode: ${mode}`, "", ...blocks].join("\n");
239
+ await withFileMutationQueue(file, async () => {
240
+ await fs.promises.writeFile(file, body, { encoding: "utf-8", mode: 0o600 });
241
+ });
242
+ } catch {
243
+ /* non-fatal */
244
+ }
245
+ }
246
+
247
+ // --------------------------------------------------------------- rendering
248
+
249
+ export function renderBoard(tasks: Task[]): string {
250
+ if (tasks.length === 0) return "(board empty)";
251
+ const lines: string[] = [];
252
+ for (const status of STATUS_ORDER) {
253
+ const col = tasks.filter((t) => t.status === status);
254
+ if (col.length === 0) continue;
255
+ lines.push(`${status} (${col.length}):`);
256
+ for (const t of col) lines.push(` - ${t.id} ${t.agent ? `@${t.agent} ` : ""}${t.title}`);
257
+ }
258
+ return lines.join("\n");
259
+ }
260
+
261
+ /** The session-start "resume" block: lists resumable tasks and instructs delegation. */
262
+ export function buildResumePrompt(cwd: string): string | null {
263
+ const tasks = listResumable(resolveProject(cwd).tasksDir);
264
+ if (tasks.length === 0) return null;
265
+ const lines = [
266
+ "# Open tasks — resume these",
267
+ "This project has unfinished tasks on its board (persisted from earlier sessions). Resume each",
268
+ "by delegating it to its designated agent with the `subagent` tool in single mode, passing",
269
+ "`taskId` so the agent loads the full structured instruction. Keep each task's status current",
270
+ "(it moves to `review` on success or `blocked` on failure automatically).",
271
+ "",
272
+ ...tasks.map((t) => `- ${t.id} [${t.status}] ${t.agent ? `→ ${t.agent}` : "(no agent assigned)"}: ${t.title}`),
273
+ ];
274
+ return lines.join("\n");
275
+ }
276
+
277
+ // --------------------------------------------------------------- the `task` tool
278
+
279
+ const TaskParams = Type.Object({
280
+ action: StringEnum(["create", "update", "list", "get"] as const, {
281
+ description: "create a task; update fields/status; list the board; get one task's full detail",
282
+ }),
283
+ id: Type.Optional(Type.String({ description: "Task id (required for update/get)" })),
284
+ title: Type.Optional(Type.String({ description: "Short task title (create)" })),
285
+ instruction: Type.Optional(Type.String({ description: "Full, self-contained instruction the subagent will execute" })),
286
+ agent: Type.Optional(Type.String({ description: "Designated agent to execute this task (assignee)" })),
287
+ status: Type.Optional(
288
+ StringEnum(["backlog", "todo", "in_progress", "blocked", "review", "done", "cancelled"] as const, {
289
+ description: "Kanban column / status",
290
+ }),
291
+ ),
292
+ priority: Type.Optional(StringEnum(["low", "normal", "high"] as const)),
293
+ acceptance: Type.Optional(Type.Array(Type.String(), { description: "Acceptance criteria (create/update)" })),
294
+ labels: Type.Optional(Type.Array(Type.String())),
295
+ dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Task ids this depends on" })),
296
+ note: Type.Optional(Type.String({ description: "Append a note to the task (update)" })),
297
+ filter: Type.Optional(StringEnum(["open", "all"] as const, { description: "list filter (default open)" })),
298
+ });
299
+
300
+ export function registerTaskTool(pi: ExtensionAPI): void {
301
+ pi.registerTool({
302
+ name: "task",
303
+ label: "Task board",
304
+ description:
305
+ "Manage the persistent project task board (a kanban for delegation). Create tasks with a designated agent and a structured instruction, update status/fields, and list or read tasks. Tasks survive across sessions; unfinished ones are resumed by delegating to their agent (use subagent with taskId).",
306
+ promptSnippet: "Manage the persistent kanban task board for delegation",
307
+ promptGuidelines: [
308
+ "Use the task tool to record durable, multi-step or to-be-delegated work as kanban cards with a designated agent and a clear instruction + acceptance criteria.",
309
+ "Execute a task by delegating it: call subagent in single mode with that task's taskId; its status updates to review (success) or blocked (failure) automatically.",
310
+ ],
311
+ parameters: TaskParams,
312
+ async execute(_id, params, _signal, _onUpdate, ctx: ExtensionContext) {
313
+ const { tasksDir } = resolveProject(ctx.cwd);
314
+ const action = params.action as string;
315
+
316
+ if (action === "list") {
317
+ const all = listTasks(tasksDir);
318
+ const shown = (params.filter ?? "open") === "all" ? all : all.filter((t) => OPEN.has(t.status));
319
+ return { content: [{ type: "text", text: renderBoard(shown) }], details: { count: shown.length } };
320
+ }
321
+
322
+ if (action === "get") {
323
+ if (!params.id) return { content: [{ type: "text", text: "get requires id" }], details: { error: true } };
324
+ const t = getTask(tasksDir, params.id as string);
325
+ if (!t) return { content: [{ type: "text", text: `No task ${params.id}` }], details: { error: true } };
326
+ return { content: [{ type: "text", text: serialize(t) }], details: { id: t.id } };
327
+ }
328
+
329
+ if (action === "create") {
330
+ if (!params.title) return { content: [{ type: "text", text: "create requires title" }], details: { error: true } };
331
+ const now = nowISO();
332
+ const t: Task = {
333
+ id: generateTaskId(),
334
+ title: params.title as string,
335
+ status: (params.status as TaskStatus) ?? "todo",
336
+ agent: (params.agent as string) ?? "",
337
+ priority: (params.priority as Task["priority"]) ?? "normal",
338
+ labels: (params.labels as string[]) ?? [],
339
+ dependsOn: (params.dependsOn as string[]) ?? [],
340
+ created: now,
341
+ updated: now,
342
+ attempts: 0,
343
+ session: "",
344
+ instruction: (params.instruction as string) ?? "",
345
+ acceptance: (params.acceptance as string[]) ?? [],
346
+ notes: "",
347
+ activity: [`${now} — created`],
348
+ };
349
+ await writeTask(tasksDir, t);
350
+ return { content: [{ type: "text", text: `Created task ${t.id} (${t.status}${t.agent ? `, @${t.agent}` : ""}).` }], details: { id: t.id } };
351
+ }
352
+
353
+ if (action === "update") {
354
+ if (!params.id) return { content: [{ type: "text", text: "update requires id" }], details: { error: true } };
355
+ const t = getTask(tasksDir, params.id as string);
356
+ if (!t) return { content: [{ type: "text", text: `No task ${params.id}` }], details: { error: true } };
357
+ const changes: string[] = [];
358
+ if (params.status) { changes.push(`status→${params.status}`); t.status = params.status as TaskStatus; }
359
+ if (params.agent !== undefined) { changes.push(`agent→${params.agent}`); t.agent = params.agent as string; }
360
+ if (params.priority) { changes.push(`priority→${params.priority}`); t.priority = params.priority as Task["priority"]; }
361
+ if (params.title) t.title = params.title as string;
362
+ if (params.instruction !== undefined) t.instruction = params.instruction as string;
363
+ if (params.acceptance) t.acceptance = params.acceptance as string[];
364
+ if (params.labels) t.labels = params.labels as string[];
365
+ if (params.dependsOn) t.dependsOn = params.dependsOn as string[];
366
+ if (params.note) t.notes = t.notes ? `${t.notes}\n${params.note}` : (params.note as string);
367
+ t.updated = nowISO();
368
+ t.activity.push(`${nowISO()} — updated${changes.length ? ` (${changes.join(", ")})` : ""}${params.note ? `: ${params.note}` : ""}`);
369
+ await writeTask(tasksDir, t);
370
+ return { content: [{ type: "text", text: `Updated task ${t.id}.` }], details: { id: t.id } };
371
+ }
372
+
373
+ return { content: [{ type: "text", text: "Unknown action" }], details: { error: true } };
374
+ },
375
+ renderResult(result, _opts, theme) {
376
+ const text = result.content[0];
377
+ return new Text(theme.fg("muted", "▣ ") + (text?.type === "text" ? text.text : ""), 0, 0);
378
+ },
379
+ });
380
+ }
package/todo.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `todo_write` — Claude Code's TodoWrite for pi.
3
+ *
4
+ * The model calls it with the full, updated task list (it replaces prior state).
5
+ * State lives in the tool result's `details` (branch-correct on session fork) and
6
+ * is reconstructed by scanning the session branch on session_start / session_tree.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { StringEnum } from "@earendil-works/pi-ai";
13
+ import { Text } from "@earendil-works/pi-tui";
14
+ import { Type } from "typebox";
15
+ import { resolveProject } from "./project.ts";
16
+
17
+ type Status = "pending" | "in_progress" | "completed";
18
+ interface Todo {
19
+ content: string;
20
+ activeForm: string;
21
+ status: Status;
22
+ }
23
+ interface TodoDetails {
24
+ todos: Todo[];
25
+ warning?: string;
26
+ }
27
+
28
+ const TodoItem = Type.Object({
29
+ content: Type.String({ description: "Imperative form, e.g. 'Run tests'" }),
30
+ activeForm: Type.String({ description: "Present-continuous form shown while active, e.g. 'Running tests'" }),
31
+ status: StringEnum(["pending", "in_progress", "completed"] as const),
32
+ });
33
+ const TodoWriteParams = Type.Object({
34
+ todos: Type.Array(TodoItem, { description: "The full, updated todo list (replaces prior state)" }),
35
+ });
36
+
37
+ const ICON: Record<Status, string> = { pending: "☐", in_progress: "◐", completed: "☑" };
38
+
39
+ function renderChecklist(todos: Todo[], fg?: (role: string, s: string) => string, strike?: (s: string) => string): string {
40
+ if (todos.length === 0) return "(no todos)";
41
+ const done = todos.filter((t) => t.status === "completed").length;
42
+ const header = `${done}/${todos.length} completed`;
43
+ const lines = todos.map((t) => {
44
+ const label = t.status === "in_progress" ? t.activeForm : t.content;
45
+ if (!fg) return `${ICON[t.status]} ${label}`;
46
+ if (t.status === "completed") return fg("success", `${ICON.completed} `) + fg("muted", strike ? strike(label) : label);
47
+ if (t.status === "in_progress") return fg("accent", `${ICON.in_progress} `) + fg("text", label);
48
+ return fg("muted", `${ICON.pending} `) + label;
49
+ });
50
+ return `${header}\n${lines.join("\n")}`;
51
+ }
52
+
53
+ export function registerTodoTool(pi: ExtensionAPI): void {
54
+ let todos: Todo[] = [];
55
+ // one snapshot file per session, overwritten as the list changes
56
+ const snapshotName = `${new Date().toISOString().slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}.md`;
57
+
58
+ const persistSnapshot = (ctx: ExtensionContext) => {
59
+ try {
60
+ const { todosDir } = resolveProject(ctx.cwd);
61
+ fs.mkdirSync(todosDir, { recursive: true });
62
+ const text = `# Todo snapshot — ${new Date().toISOString()}\n\n${renderChecklist(todos)}\n`;
63
+ fs.writeFileSync(path.join(todosDir, snapshotName), text, { encoding: "utf-8", mode: 0o600 });
64
+ } catch {
65
+ /* non-fatal */
66
+ }
67
+ };
68
+
69
+ const reconstruct = (ctx: ExtensionContext) => {
70
+ todos = [];
71
+ try {
72
+ for (const entry of (ctx.sessionManager as any).getBranch() ?? []) {
73
+ if (entry?.type !== "message") continue;
74
+ const msg = entry.message;
75
+ if (msg?.role !== "toolResult" || msg?.toolName !== "todo_write") continue;
76
+ const details = msg.details as TodoDetails | undefined;
77
+ if (details?.todos) todos = details.todos;
78
+ }
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ };
83
+
84
+ const updateStatus = (ctx: ExtensionContext) => {
85
+ if (!ctx.hasUI) return;
86
+ if (todos.length === 0) {
87
+ ctx.ui.setStatus("todos", undefined);
88
+ return;
89
+ }
90
+ const done = todos.filter((t) => t.status === "completed").length;
91
+ ctx.ui.setStatus("todos", ctx.ui.theme.fg("muted", `▣ ${done}/${todos.length}`));
92
+ };
93
+
94
+ pi.on("session_start", async (_e, ctx) => {
95
+ reconstruct(ctx);
96
+ updateStatus(ctx);
97
+ });
98
+ pi.on("session_tree", async (_e, ctx) => {
99
+ reconstruct(ctx);
100
+ updateStatus(ctx);
101
+ });
102
+
103
+ pi.registerTool({
104
+ name: "todo_write",
105
+ label: "Todos",
106
+ description:
107
+ "Manage the task list for the current work. Call with the FULL updated list whenever a task starts or finishes — it replaces the previous list. Keep exactly one task 'in_progress' at a time and mark a task 'completed' immediately when done (do not batch).",
108
+ promptSnippet: "Track multi-step work with a todo list",
109
+ promptGuidelines: [
110
+ "Use todo_write for any non-trivial multi-step task; update it as you start/finish each step.",
111
+ "Exactly one todo should be in_progress at a time.",
112
+ ],
113
+ parameters: TodoWriteParams,
114
+
115
+ async execute(_id, params, _signal, _onUpdate, ctx) {
116
+ todos = params.todos as Todo[];
117
+ const inProgress = todos.filter((t) => t.status === "in_progress").length;
118
+ let warning: string | undefined;
119
+ if (todos.length > 0 && inProgress !== 1) {
120
+ warning = `Expected exactly one in_progress task, found ${inProgress}.`;
121
+ }
122
+ updateStatus(ctx);
123
+ persistSnapshot(ctx);
124
+ const text = renderChecklist(todos);
125
+ return {
126
+ content: [{ type: "text" as const, text: warning ? `${text}\n\n⚠ ${warning}` : text }],
127
+ details: { todos, warning } satisfies TodoDetails,
128
+ };
129
+ },
130
+
131
+ renderResult(result, _opts, theme) {
132
+ const d = result.details as TodoDetails | undefined;
133
+ const list = d?.todos ?? [];
134
+ let text = renderChecklist(list, (role, s) => theme.fg(role, s), (s) => theme.strikethrough(s));
135
+ if (d?.warning) text += `\n${theme.fg("warning", `⚠ ${d.warning}`)}`;
136
+ return new Text(text, 0, 0);
137
+ },
138
+ });
139
+
140
+ pi.registerCommand("todos", {
141
+ description: "Show the current task list",
142
+ handler: async (_args, ctx) => {
143
+ reconstruct(ctx);
144
+ if (!ctx.hasUI) return;
145
+ const text = renderChecklist(todos, (role, s) => ctx.ui.theme.fg(role, s), (s) => ctx.ui.theme.strikethrough(s));
146
+ ctx.ui.notify(`Todos:\n${text}`, "info");
147
+ },
148
+ });
149
+ }