@hheei/pi-todo 0.0.0-stage → 0.1.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/dist/todo.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ export declare const TODO_TOOL_NAME = "todo";
4
+ export declare const TODO_COMMAND_NAME = "todo";
5
+ /**
6
+ * `@hheei/pi-todo` is the stable owner of the `todo` declaration, so a new Pi
7
+ * runner may replace it on /reload while no other extension can claim the name.
8
+ * `defaultActive: false` keeps activation with the Pi host's active tool set;
9
+ * Loadout does not manage tool activation.
10
+ */
11
+ export declare const TODO_REMINDER_IDLE_TURNS = 3;
12
+ export declare const TODO_REMINDER_IDLE_MS: number;
13
+ export declare const TODO_PARAMETERS: Type.TObject<{
14
+ operations: Type.TArray<Type.TUnion<[Type.TObject<{
15
+ action: Type.TLiteral<"create">;
16
+ subject: Type.TString;
17
+ status: Type.TOptional<Type.TString>;
18
+ }>, Type.TObject<{
19
+ action: Type.TLiteral<"update">;
20
+ id: Type.TInteger;
21
+ subject: Type.TOptional<Type.TString>;
22
+ status: Type.TOptional<Type.TString>;
23
+ }>, Type.TObject<{
24
+ action: Type.TLiteral<"list">;
25
+ status: Type.TOptional<Type.TString>;
26
+ }>, Type.TObject<{
27
+ action: Type.TLiteral<"delete">;
28
+ id: Type.TInteger;
29
+ }>]>>;
30
+ }>;
31
+ export declare const TODO_TOOL_DESCRIPTION = "Maintain an atomic task list. Scheduling is automatic. Create known tasks in one batch; update only when state changes; use `blocked` when work cannot continue, `in_progress` to switch active work or resume blocked work, and `completed` after verification. Submit one `list` operation or an atomic batch of create, update, and delete operations. Follow the final guidance line returned by the tool.";
32
+ export declare const TODO_PROMPT_SNIPPET = "Manage a task list to track multi-step progress";
33
+ export declare const TODO_PROMPT_GUIDELINES: readonly ["Use `todo` for work with 3+ concrete steps or multiple user-requested tasks; skip trivial work.", "Create the full known task list in one atomic batch. Keep subjects short, imperative, and outcome-oriented; do not add bookkeeping tasks for routine commands.", "Scheduling is automatic; active task is already in_progress. Update only when state changes: use `completed` after verification, `blocked` only when work cannot continue, and `in_progress` to switch active work or resume blocked work. Do not repeatedly list or restate current state."];
34
+ export interface TodoFeature {
35
+ /** Starts fresh session-local task state and installs feature-owned hooks. */
36
+ start(context: ExtensionContext, signal?: AbortSignal): void | Promise<void>;
37
+ /** Idempotently clears widget, reminders, timers, and stale event state. */
38
+ dispose(sessionId: string): void | Promise<void>;
39
+ }
40
+ export interface TodoFeatureOptions {
41
+ readonly now?: () => number;
42
+ readonly clock?: () => number;
43
+ }
44
+ export declare function prepareTodoArguments(value: unknown): Static<typeof TODO_PARAMETERS>;
45
+ export declare function createTodoFeature(pi: ExtensionAPI, options?: TodoFeatureOptions): TodoFeature;
package/dist/todo.js ADDED
@@ -0,0 +1,626 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import { createOptionalToolRenderers, escapeXml, isRecord, subcommandCompletions, textToolResult, } from "@hheei/pi-ext-core";
3
+ import { Type } from "typebox";
4
+ import { COMPLETED_DISPLAY_DURATION_MS, createTodoFooterStatusController, } from "./footer-status.js";
5
+ import { applyTodo, cancelTodosByUser, freshTaskState, TASK_GLYPH, TASK_TONE, } from "./model.js";
6
+ import { snapshotFromState, stateFromSnapshot } from "./state.js";
7
+ export const TODO_TOOL_NAME = "todo";
8
+ export const TODO_COMMAND_NAME = "todo";
9
+ /**
10
+ * `@hheei/pi-todo` is the stable owner of the `todo` declaration, so a new Pi
11
+ * runner may replace it on /reload while no other extension can claim the name.
12
+ * `defaultActive: false` keeps activation with the Pi host's active tool set;
13
+ * Loadout does not manage tool activation.
14
+ */
15
+ export const TODO_REMINDER_IDLE_TURNS = 3;
16
+ export const TODO_REMINDER_IDLE_MS = 3 * 60_000;
17
+ const TODO_REMINDER_CUSTOM_TYPE = "pi-ext-tools:todo:reminder";
18
+ const TODO_STATE_CUSTOM_TYPE = "pi-ext-tools:todo:state";
19
+ const TODO_MAX_BODY_ROWS = 8;
20
+ /** Body order: active work first, suppressed tasks never reach a row. */
21
+ const TASK_RANK = {
22
+ in_progress: 0,
23
+ pending: 1,
24
+ blocked: 2,
25
+ completed: 3,
26
+ suppressed: 4,
27
+ };
28
+ const todoTaskStatus = Type.String({
29
+ enum: ["pending", "in_progress", "blocked", "completed", "suppressed"],
30
+ });
31
+ const agentTaskStatus = Type.String({ enum: ["pending", "in_progress", "blocked", "completed"] });
32
+ const taskId = Type.Integer({ minimum: 1 });
33
+ const todoOperation = Type.Union([
34
+ Type.Object({
35
+ action: Type.Literal("create"),
36
+ subject: Type.String({ minLength: 1 }),
37
+ status: Type.Optional(agentTaskStatus),
38
+ }, { additionalProperties: false }),
39
+ Type.Object({
40
+ action: Type.Literal("update"),
41
+ id: taskId,
42
+ subject: Type.Optional(Type.String({ minLength: 1 })),
43
+ status: Type.Optional(agentTaskStatus),
44
+ }, { additionalProperties: false, minProperties: 3 }),
45
+ Type.Object({
46
+ action: Type.Literal("list"),
47
+ status: Type.Optional(todoTaskStatus),
48
+ }, { additionalProperties: false }),
49
+ Type.Object({
50
+ action: Type.Literal("delete"),
51
+ id: taskId,
52
+ }, { additionalProperties: false }),
53
+ ]);
54
+ export const TODO_PARAMETERS = Type.Object({
55
+ operations: Type.Array(todoOperation, {
56
+ minItems: 1,
57
+ description: "Ordered task operations. The complete batch validates before one atomic commit; list must be the only operation when used.",
58
+ }),
59
+ }, { additionalProperties: false });
60
+ export const TODO_TOOL_DESCRIPTION = "Maintain an atomic task list. Scheduling is automatic. Create known tasks in one batch; update only when state changes; use `blocked` when work cannot continue, `in_progress` to switch active work or resume blocked work, and `completed` after verification. Submit one `list` operation or an atomic batch of create, update, and delete operations. Follow the final guidance line returned by the tool.";
61
+ export const TODO_PROMPT_SNIPPET = "Manage a task list to track multi-step progress";
62
+ export const TODO_PROMPT_GUIDELINES = [
63
+ "Use `todo` for work with 3+ concrete steps or multiple user-requested tasks; skip trivial work.",
64
+ "Create the full known task list in one atomic batch. Keep subjects short, imperative, and outcome-oriented; do not add bookkeeping tasks for routine commands.",
65
+ "Scheduling is automatic; active task is already in_progress. Update only when state changes: use `completed` after verification, `blocked` only when work cannot continue, and `in_progress` to switch active work or resume blocked work. Do not repeatedly list or restate current state.",
66
+ ];
67
+ /** Restores the latest durable snapshot on the active session branch. */
68
+ function restoreTodoState(context, now) {
69
+ const entries = context.sessionManager.getBranch();
70
+ for (let index = entries.length - 1; index >= 0; index--) {
71
+ const entry = entries[index];
72
+ if (entry === undefined)
73
+ continue;
74
+ if (entry.type === "custom" && entry.customType === TODO_STATE_CUSTOM_TYPE) {
75
+ const state = stateFromSnapshot(entry.data, now);
76
+ if (state)
77
+ return state;
78
+ continue;
79
+ }
80
+ if (entry.type === "message" &&
81
+ isRecord(entry.message) &&
82
+ entry.message.role === "toolResult" &&
83
+ entry.message.toolName === TODO_TOOL_NAME) {
84
+ const state = stateFromSnapshot(todoSnapshotFromDetails(entry.message.details), now);
85
+ if (state)
86
+ return state;
87
+ }
88
+ }
89
+ return freshTaskState();
90
+ }
91
+ function todoSnapshotFromDetails(details) {
92
+ if (details === null || typeof details !== "object" || Array.isArray(details))
93
+ return undefined;
94
+ if (!("snapshot" in details))
95
+ return undefined;
96
+ return details.snapshot;
97
+ }
98
+ /**
99
+ * Pi may pass numeric IDs as strings at the tool boundary. Normalize only that
100
+ * compatibility form here; TypeBox and the model still validate the complete
101
+ * operation so this helper cannot make an invalid batch partially acceptable.
102
+ */
103
+ function canonicalPositiveInteger(value) {
104
+ if (typeof value === "number")
105
+ return Number.isInteger(value) && value > 0 ? value : undefined;
106
+ if (typeof value !== "string" || !/^[1-9]\d*$/.test(value))
107
+ return undefined;
108
+ const parsed = Number(value);
109
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
110
+ }
111
+ export function prepareTodoArguments(value) {
112
+ if (!value || typeof value !== "object")
113
+ return value;
114
+ const params = value;
115
+ if (!Array.isArray(params.operations))
116
+ return value;
117
+ return {
118
+ ...params,
119
+ operations: params.operations.map((entry) => {
120
+ if (!entry || typeof entry !== "object")
121
+ return entry;
122
+ const operation = { ...entry };
123
+ const id = canonicalPositiveInteger(operation.id);
124
+ if (id !== undefined)
125
+ operation.id = id;
126
+ return operation;
127
+ }),
128
+ };
129
+ }
130
+ function activeTodoTask(state) {
131
+ return state.tasks.find((task) => task.status === "in_progress");
132
+ }
133
+ function todoReminder(current) {
134
+ const activeTask = activeTodoTask(current.state);
135
+ if (!activeTask)
136
+ return undefined;
137
+ const pending = current.state.tasks
138
+ .filter((task) => task.status === "pending")
139
+ .sort((left, right) => left.id - right.id);
140
+ const lines = [
141
+ "<system-reminder>",
142
+ `Active TODO: #${activeTask.id} ${escapeXml(activeTask.subject)}`,
143
+ ];
144
+ if (pending.length > 0) {
145
+ lines.push("Pending TODOs:");
146
+ for (const task of pending)
147
+ lines.push(`#${task.id} ${escapeXml(task.subject)}`);
148
+ }
149
+ lines.push("</system-reminder>");
150
+ return lines.join("\n");
151
+ }
152
+ function formatTodoGuidance(state) {
153
+ const active = activeTodoTask(state);
154
+ if (active)
155
+ return `in_progress: #${active.id} ${active.subject}.`;
156
+ return "Finished all todos.";
157
+ }
158
+ function formatTaskLine(task) {
159
+ const suppressed = task.status === "suppressed" ? " user suppressed" : "";
160
+ return `${TASK_GLYPH[task.status]} #${task.id} ${task.subject}${suppressed}`;
161
+ }
162
+ function formatTodoList(state, status) {
163
+ const tasks = state.tasks
164
+ .filter((task) => status === undefined || task.status === status)
165
+ .sort((left, right) => left.id - right.id);
166
+ if (tasks.length === 0)
167
+ return "No todos.";
168
+ return tasks.map((task) => formatTaskLine(task)).join("\n");
169
+ }
170
+ function formatActiveAndRecentTodos(state, now = Date.now()) {
171
+ const activeOrPending = state.tasks.filter((task) => task.status === "in_progress" || task.status === "pending");
172
+ const recentDoneOrBlocked = state.tasks.filter((task) => (task.status === "completed" || task.status === "blocked") &&
173
+ task.updatedAt !== undefined &&
174
+ now - task.updatedAt < COMPLETED_DISPLAY_DURATION_MS);
175
+ if (activeOrPending.length === 0 && recentDoneOrBlocked.length === 0) {
176
+ return "No active or recent todos. Use /todo list to view all.";
177
+ }
178
+ const lines = [];
179
+ if (activeOrPending.length > 0) {
180
+ lines.push("── Active ──");
181
+ for (const task of activeOrPending)
182
+ lines.push(formatTaskLine(task));
183
+ }
184
+ if (recentDoneOrBlocked.length > 0) {
185
+ lines.push("── Recent (last 3m) ──");
186
+ for (const task of recentDoneOrBlocked)
187
+ lines.push(formatTaskLine(task));
188
+ }
189
+ return lines.join("\n");
190
+ }
191
+ function formatTodosCommand(state) {
192
+ if (state.tasks.length === 0)
193
+ return "No todos.";
194
+ const visible = state.tasks.filter((task) => task.status !== "suppressed");
195
+ const completed = visible.filter((task) => task.status === "completed").length;
196
+ const lines = [`${completed}/${visible.length} completed`];
197
+ for (const status of ["in_progress", "pending", "blocked", "completed", "suppressed"]) {
198
+ const tasks = state.tasks.filter((task) => task.status === status).sort((a, b) => a.id - b.id);
199
+ if (tasks.length === 0)
200
+ continue;
201
+ lines.push(`── ${status === "in_progress" ? "In Progress" : status === "pending" ? "Pending" : status === "blocked" ? "Blocked" : status === "completed" ? "Completed" : "Suppressed"} ──`);
202
+ for (const task of tasks)
203
+ lines.push(formatTaskLine(task));
204
+ }
205
+ return lines.join("\n");
206
+ }
207
+ function formatTodoOperationResult(operation, result, state) {
208
+ switch (operation.action) {
209
+ case "create":
210
+ return result.id === undefined ? "Created task" : `Created #${result.id}`;
211
+ case "update": {
212
+ if (result.changed)
213
+ return `Updated #${operation.id}`;
214
+ const value = operation.status ?? operation.subject?.trim();
215
+ return `#${operation.id} is already \`${value ?? "unchanged"}\``;
216
+ }
217
+ case "delete":
218
+ return `Deleted #${operation.id}`;
219
+ case "list":
220
+ return formatTodoList(state, operation.status);
221
+ }
222
+ }
223
+ function formatTodoResult(params, result) {
224
+ const lines = [];
225
+ const createdIds = result.operations.flatMap((operation) => operation.action === "create" && operation.id !== undefined ? [operation.id] : []);
226
+ let createdReported = false;
227
+ for (const operationResult of result.operations) {
228
+ const operation = params.operations[operationResult.index];
229
+ if (!operation)
230
+ continue;
231
+ if (operation.action === "create") {
232
+ if (!createdReported) {
233
+ lines.push(createdIds.length === 0 ? "Created task" : `Created ${formatTodoIds(createdIds)}`);
234
+ createdReported = true;
235
+ }
236
+ continue;
237
+ }
238
+ lines.push(formatTodoOperationResult(operation, operationResult, result.state));
239
+ }
240
+ if (!result.changed && params.operations[0]?.action !== "list") {
241
+ lines.push("No change made.");
242
+ return lines.join("\n");
243
+ }
244
+ lines.push(formatTodoGuidance(result.state));
245
+ return lines.join("\n");
246
+ }
247
+ function taskStatus(value) {
248
+ return value === "pending" ||
249
+ value === "in_progress" ||
250
+ value === "blocked" ||
251
+ value === "completed" ||
252
+ value === "suppressed"
253
+ ? value
254
+ : undefined;
255
+ }
256
+ function readTodoToolDetails(value) {
257
+ if (!value || typeof value !== "object" || Array.isArray(value))
258
+ return undefined;
259
+ const details = value;
260
+ const state = stateFromSnapshot(details.snapshot);
261
+ if (!state || !Array.isArray(details.operations))
262
+ return undefined;
263
+ const operations = [];
264
+ for (const value of details.operations) {
265
+ if (!value || typeof value !== "object" || Array.isArray(value))
266
+ return undefined;
267
+ const operation = value;
268
+ const action = operation.action;
269
+ if (typeof operation.index !== "number" ||
270
+ !Number.isSafeInteger(operation.index) ||
271
+ operation.index < 0 ||
272
+ typeof operation.changed !== "boolean" ||
273
+ (action !== "create" && action !== "update" && action !== "list" && action !== "delete"))
274
+ return undefined;
275
+ const id = canonicalPositiveInteger(operation.id);
276
+ if (operation.id !== undefined && id === undefined)
277
+ return undefined;
278
+ operations.push({
279
+ index: operation.index,
280
+ action,
281
+ changed: operation.changed,
282
+ ...(id === undefined ? {} : { id }),
283
+ });
284
+ }
285
+ const listStatus = taskStatus(details.listStatus);
286
+ if (details.listStatus !== undefined && listStatus === undefined)
287
+ return undefined;
288
+ return { state, operations, ...(listStatus === undefined ? {} : { listStatus }) };
289
+ }
290
+ function formatTodoIds(ids) {
291
+ return ids.map((id) => `#${id}`).join(" ");
292
+ }
293
+ function isListDetails(details) {
294
+ return (details.listStatus !== undefined ||
295
+ details.operations.some((operation) => operation.action === "list"));
296
+ }
297
+ function taskRow(task, theme) {
298
+ const glyph = theme.fg(TASK_TONE[task.status], TASK_GLYPH[task.status]);
299
+ const subject = task.status === "completed" || task.status === "blocked"
300
+ ? theme.fg("dim", theme.strikethrough(task.subject))
301
+ : theme.fg("text", task.subject);
302
+ return `${glyph} ${theme.fg("accent", `#${task.id}`)} ${subject}`;
303
+ }
304
+ function taskRows(state, status, theme, expanded) {
305
+ const selected = state.tasks
306
+ .filter((task) => task.status !== "suppressed" && (status === undefined || task.status === status))
307
+ .slice()
308
+ .sort((left, right) => TASK_RANK[left.status] - TASK_RANK[right.status] || left.id - right.id);
309
+ const visible = expanded ? selected : selected.slice(0, TODO_MAX_BODY_ROWS);
310
+ const rows = [];
311
+ for (const currentStatus of ["in_progress", "pending", "blocked", "completed"]) {
312
+ const group = visible.filter((task) => task.status === currentStatus);
313
+ if (group.length === 0)
314
+ continue;
315
+ rows.push(theme.fg("dim", currentStatus.replaceAll("_", " ")));
316
+ for (const task of group)
317
+ rows.push(taskRow(task, theme));
318
+ }
319
+ if (selected.length > visible.length)
320
+ rows.push(theme.fg("dim", `… +${selected.length - visible.length} more`));
321
+ return rows.length === 0 ? [theme.fg("dim", "No tasks.")] : rows;
322
+ }
323
+ function mutationRows(details, theme) {
324
+ const rows = details.operations
325
+ .filter((operation) => operation.changed)
326
+ .map((operation) => {
327
+ const task = details.state.tasks.find((candidate) => candidate.id === operation.id);
328
+ if (operation.action === "delete")
329
+ return theme.fg("dim", `− #${operation.id ?? "?"}`);
330
+ if (operation.action === "create")
331
+ return task === undefined ? `#${operation.id ?? "?"}` : taskRow(task, theme);
332
+ return task === undefined ? `#${operation.id ?? "?"}` : taskRow(task, theme);
333
+ });
334
+ return rows.length === 0 ? [theme.fg("dim", "No changes.")] : rows;
335
+ }
336
+ function todoResultText(result) {
337
+ return (result.content ?? [])
338
+ .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
339
+ .join("\n");
340
+ }
341
+ /** One short row per requested operation, so a call reads as task ids without any UI package. */
342
+ function todoOperationRows(args) {
343
+ if (!isRecord(args) || !Array.isArray(args.operations))
344
+ return [];
345
+ const rows = [];
346
+ for (const operation of args.operations) {
347
+ if (!isRecord(operation))
348
+ continue;
349
+ const id = canonicalPositiveInteger(operation.id);
350
+ const label = id === undefined ? "?" : `#${id}`;
351
+ if (operation.action === "create") {
352
+ const subject = typeof operation.subject === "string" ? operation.subject.trim() : "";
353
+ rows.push(`+ ${subject === "" ? "task" : subject}`);
354
+ }
355
+ else if (operation.action === "update") {
356
+ rows.push(`~ ${label}`);
357
+ }
358
+ else if (operation.action === "delete") {
359
+ rows.push(`- ${label}`);
360
+ }
361
+ else if (operation.action === "list") {
362
+ rows.push(`list ${typeof operation.status === "string" ? operation.status : "all"}`);
363
+ }
364
+ }
365
+ return rows;
366
+ }
367
+ /** Native call frame: the tool title plus the operations this call asks for. */
368
+ function renderTodoToolTuiCall(args, theme) {
369
+ const rows = todoOperationRows(args);
370
+ const shown = rows.slice(0, TODO_MAX_BODY_ROWS);
371
+ const hidden = rows.length - shown.length;
372
+ if (hidden > 0)
373
+ shown.push(`… +${hidden} more`);
374
+ return new Text([theme.fg("toolTitle", theme.bold(TODO_TOOL_NAME)), ...shown].join("\n"), 0, 0);
375
+ }
376
+ function renderTodoToolTuiResult(result, theme, isError, expanded) {
377
+ if (isError)
378
+ return new Text(theme.fg("error", todoResultText(result) || "Error"), 0, 0);
379
+ const details = readTodoToolDetails(result.details);
380
+ if (details === undefined)
381
+ return new Text(theme.fg("dim", "No Todo state."), 0, 0);
382
+ const rows = isListDetails(details)
383
+ ? taskRows(details.state, details.listStatus, theme, expanded)
384
+ : mutationRows(details, theme);
385
+ return new Text(rows.join("\n"), 0, 0);
386
+ }
387
+ export function createTodoFeature(pi, options = {}) {
388
+ let active;
389
+ const now = options.now ?? (() => performance.now());
390
+ const clock = options.clock ?? (() => Date.now());
391
+ const tool = {
392
+ name: TODO_TOOL_NAME,
393
+ label: "todo",
394
+ description: TODO_TOOL_DESCRIPTION,
395
+ promptSnippet: TODO_PROMPT_SNIPPET,
396
+ promptGuidelines: [...TODO_PROMPT_GUIDELINES],
397
+ parameters: TODO_PARAMETERS,
398
+ prepareArguments: prepareTodoArguments,
399
+ executionMode: "sequential",
400
+ // pi-ext-ui replaces these when installed. They are re-read on every render, so package
401
+ // load order and UI session cleanup cannot strand a stale frame, and a direct call
402
+ // without any UI package still renders Todo's own header, ids, and task rows.
403
+ ...createOptionalToolRenderers(pi, TODO_TOOL_NAME, {
404
+ renderCall(args, theme) {
405
+ return renderTodoToolTuiCall(args, theme);
406
+ },
407
+ renderResult(result, options, theme, context) {
408
+ return renderTodoToolTuiResult(result, theme, context.isError, options.expanded ?? false);
409
+ },
410
+ }),
411
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
412
+ signal?.throwIfAborted();
413
+ const current = active;
414
+ if (!current || current.sessionId !== ctx?.sessionManager?.getSessionId())
415
+ throw new Error("Todo runtime is not active");
416
+ // applyTodo validates the entire candidate batch before returning a new
417
+ // state. Keep the live state untouched on every error to preserve atomicity.
418
+ const todoParams = params;
419
+ const result = applyTodo(current.state, todoParams, clock());
420
+ if (!result.ok) {
421
+ const message = result.error.endsWith(".") ? result.error : `${result.error}.`;
422
+ throw new Error(`${message}\nNo change made.`);
423
+ }
424
+ if (result.changed) {
425
+ current.state = result.state;
426
+ current.idleTurns = 0;
427
+ current.reminderWindowStartedAtMs = now();
428
+ current.todoChangedThisTurn = true;
429
+ current.footerController.update(current.state);
430
+ }
431
+ const listOperation = todoParams.operations.length === 1 && todoParams.operations[0]?.action === "list"
432
+ ? todoParams.operations[0]
433
+ : undefined;
434
+ return textToolResult(formatTodoResult(todoParams, result), {
435
+ snapshot: snapshotFromState(current.state),
436
+ operations: result.operations,
437
+ ...(listOperation?.status === undefined ? {} : { listStatus: listOperation.status }),
438
+ });
439
+ },
440
+ };
441
+ pi.registerTool({ ...tool, defaultActive: false });
442
+ pi.registerCommand(TODO_COMMAND_NAME, {
443
+ description: "Manage todos: /todo [list | clear | cancel #ID...]",
444
+ getArgumentCompletions: subcommandCompletions(["list", "clear", "cancel"]),
445
+ handler: async (args, ctx) => {
446
+ if (ctx.mode !== "tui") {
447
+ ctx.ui.notify("/todo requires interactive mode", "error");
448
+ return;
449
+ }
450
+ const current = active;
451
+ if (!current || current.sessionId !== ctx.sessionManager.getSessionId()) {
452
+ ctx.ui.notify("Todo tool is not active. Add +todo to defaultTools in settings.json to enable.", "warning");
453
+ return;
454
+ }
455
+ const input = args.trim();
456
+ if (input === "") {
457
+ ctx.ui.notify(formatActiveAndRecentTodos(current.state, clock()), "info");
458
+ return;
459
+ }
460
+ const [subcommand, ...rest] = input.split(/\s+/);
461
+ const sub = subcommand?.toLowerCase();
462
+ if (sub === "list" && rest.length === 0) {
463
+ ctx.ui.notify(formatTodosCommand(current.state), "info");
464
+ return;
465
+ }
466
+ if (sub === "clear" && rest.length === 0) {
467
+ current.state = freshTaskState();
468
+ current.idleTurns = 0;
469
+ current.reminderWindowStartedAtMs = now();
470
+ current.todoChangedThisTurn = true;
471
+ current.footerController.update(current.state);
472
+ pi.appendEntry(TODO_STATE_CUSTOM_TYPE, snapshotFromState(current.state));
473
+ ctx.ui.notify("Cleared all todos.", "info");
474
+ return;
475
+ }
476
+ if (sub === "cancel") {
477
+ if (rest.length === 0) {
478
+ ctx.ui.notify("Usage: /todo cancel #ID...", "error");
479
+ return;
480
+ }
481
+ const rawIdsPart = rest.join(" ").trim();
482
+ const tokens = rawIdsPart.split(/[\s,]+/).filter(Boolean);
483
+ const ids = [];
484
+ for (const token of tokens) {
485
+ const idMatch = /^#?([1-9]\d*)$/.exec(token);
486
+ const id = canonicalPositiveInteger(idMatch?.[1]);
487
+ if (id === undefined) {
488
+ ctx.ui.notify(`Invalid task id: ${token}`, "error");
489
+ return;
490
+ }
491
+ ids.push(id);
492
+ }
493
+ const result = cancelTodosByUser(current.state, ids, clock());
494
+ if (!result.ok) {
495
+ ctx.ui.notify(result.error, "error");
496
+ return;
497
+ }
498
+ if (!result.changed) {
499
+ ctx.ui.notify("Requested todos are already cancelled", "info");
500
+ return;
501
+ }
502
+ current.state = result.state;
503
+ current.idleTurns = 0;
504
+ current.reminderWindowStartedAtMs = now();
505
+ current.todoChangedThisTurn = true;
506
+ current.footerController.update(current.state);
507
+ pi.appendEntry(TODO_STATE_CUSTOM_TYPE, snapshotFromState(current.state));
508
+ const lines = [
509
+ `Cancelled ${result.cancelledIds.map((id) => `#${id}`).join(" ")}`,
510
+ formatTodoGuidance(result.state),
511
+ ];
512
+ ctx.ui.notify(lines.join("\n"), "info");
513
+ return;
514
+ }
515
+ ctx.ui.notify("Usage: /todo [list | clear | cancel #ID...]", "error");
516
+ },
517
+ });
518
+ // These subscriptions intentionally remain feature-owned: reminder timing,
519
+ // and session-tree semantics are Todo policy, not core coordination.
520
+ // The ActiveTodoRuntime guard above makes retained Pi handlers harmless
521
+ // after lifecycle cleanup or /reload.
522
+ pi.on("context", async (event, ctx) => {
523
+ const current = active;
524
+ if (!current || current.sessionId !== ctx.sessionManager.getSessionId())
525
+ return;
526
+ if (!pi.getActiveTools().includes(TODO_TOOL_NAME))
527
+ return;
528
+ if (current.idleTurns < TODO_REMINDER_IDLE_TURNS ||
529
+ now() - current.reminderWindowStartedAtMs < TODO_REMINDER_IDLE_MS)
530
+ return;
531
+ const content = todoReminder(current);
532
+ if (!content)
533
+ return;
534
+ current.idleTurns = 0;
535
+ current.reminderWindowStartedAtMs = now();
536
+ return {
537
+ messages: [
538
+ ...event.messages,
539
+ {
540
+ role: "custom",
541
+ customType: TODO_REMINDER_CUSTOM_TYPE,
542
+ content,
543
+ display: false,
544
+ timestamp: Date.now(),
545
+ },
546
+ ],
547
+ };
548
+ });
549
+ pi.on("turn_start", async (_event, ctx) => {
550
+ const current = active;
551
+ if (!current || current.sessionId !== ctx.sessionManager.getSessionId())
552
+ return;
553
+ current.todoChangedThisTurn = false;
554
+ });
555
+ pi.on("turn_end", async (event, ctx) => {
556
+ const current = active;
557
+ if (!current || current.sessionId !== ctx.sessionManager.getSessionId())
558
+ return;
559
+ if (event.message.role !== "assistant" ||
560
+ event.message.stopReason === "error" ||
561
+ event.message.stopReason === "aborted")
562
+ return;
563
+ if (current.todoChangedThisTurn) {
564
+ current.todoChangedThisTurn = false;
565
+ return;
566
+ }
567
+ if (!activeTodoTask(current.state)) {
568
+ current.idleTurns = 0;
569
+ current.reminderWindowStartedAtMs = now();
570
+ return;
571
+ }
572
+ current.idleTurns++;
573
+ });
574
+ pi.on("session_tree", async (_event, ctx) => {
575
+ const current = active;
576
+ if (!current || current.sessionId !== ctx.sessionManager.getSessionId())
577
+ return;
578
+ current.state = restoreTodoState(ctx, clock());
579
+ current.idleTurns = 0;
580
+ current.reminderWindowStartedAtMs = now();
581
+ current.todoChangedThisTurn = false;
582
+ current.footerController.update(current.state);
583
+ });
584
+ pi.on("tool_execution_end", async (event, ctx) => {
585
+ if (event.toolName !== TODO_TOOL_NAME || event.isError)
586
+ return;
587
+ try {
588
+ const current = active;
589
+ if (!current || current.sessionId !== ctx.sessionManager.getSessionId())
590
+ return;
591
+ current.footerController.update(current.state);
592
+ }
593
+ catch {
594
+ // Footer update is best-effort
595
+ }
596
+ });
597
+ return {
598
+ start(context, signal) {
599
+ const state = restoreTodoState(context, clock());
600
+ const current = {
601
+ sessionId: context.sessionManager.getSessionId(),
602
+ state,
603
+ idleTurns: 0,
604
+ reminderWindowStartedAtMs: now(),
605
+ todoChangedThisTurn: false,
606
+ footerController: createTodoFooterStatusController(context.ui, signal, clock),
607
+ };
608
+ current.footerController.update(state);
609
+ active = current;
610
+ },
611
+ async dispose(sessionId) {
612
+ const current = active;
613
+ if (!current || current.sessionId !== sessionId)
614
+ return;
615
+ try {
616
+ current.footerController.dispose();
617
+ }
618
+ finally {
619
+ // Clear the shared pointer last so in-flight handlers either finish against
620
+ // their matching session or observe no active runtime on their next event.
621
+ if (active === current)
622
+ active = undefined;
623
+ }
624
+ },
625
+ };
626
+ }