@ryan_nookpi/pi-extension-todo-write 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.
Files changed (3) hide show
  1. package/README.md +15 -0
  2. package/index.ts +586 -0
  3. package/package.json +27 -0
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @ryan_nookpi/pi-extension-todo-write
2
+
3
+ Independent pi package for the `todo_write` extension.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install /Users/creatrip/Documents/pi-extension/packages/todo-write
9
+ pi install npm:@ryan_nookpi/pi-extension-todo-write
10
+ ```
11
+
12
+ ## What it provides
13
+
14
+ - `todo_write` tool
15
+ - `./index.ts` entry
package/index.ts ADDED
@@ -0,0 +1,586 @@
1
+ import { StringEnum } from "@mariozechner/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import { Text, truncateToWidth } from "@mariozechner/pi-tui";
4
+ import { type Static, Type } from "@sinclair/typebox";
5
+
6
+ type TodoStatus = "pending" | "in_progress" | "completed";
7
+
8
+ type TodoTask = {
9
+ id: string;
10
+ content: string;
11
+ status: TodoStatus;
12
+ activeForm?: string;
13
+ notes?: string;
14
+ };
15
+
16
+ type TodoState = {
17
+ tasks: TodoTask[];
18
+ };
19
+
20
+ const StatusEnum = StringEnum(["pending", "in_progress", "completed"] as const, {
21
+ description: "Task status",
22
+ });
23
+
24
+ const InputTask = Type.Object({
25
+ content: Type.String({ description: "Task description" }),
26
+ status: StatusEnum,
27
+ activeForm: Type.Optional(
28
+ Type.String({
29
+ description: "Present continuous form for display during execution (e.g., 'Running tests', '테스트 실행 중')",
30
+ }),
31
+ ),
32
+ notes: Type.Optional(Type.String({ description: "Additional context or notes" })),
33
+ });
34
+
35
+ const TodoWriteParams = Type.Object(
36
+ {
37
+ todos: Type.Array(InputTask, { description: "The updated todo list" }),
38
+ },
39
+ { additionalProperties: true },
40
+ );
41
+
42
+ type TodoWriteParamsType = Static<typeof TodoWriteParams>;
43
+
44
+ const todoStateStore = new Map<string, TodoState>();
45
+ const TODO_WIDGET_KEY = "todo-write";
46
+ const TODO_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
47
+ const TODO_SPINNER_INTERVAL_MS = 120;
48
+ let todoWidgetTimer: ReturnType<typeof setInterval> | undefined;
49
+ const todoWidgetHideTimerByKey = new Map<string, ReturnType<typeof setTimeout>>();
50
+ const todoWidgetMetaStore = new Map<string, { completedAt?: number; completedTurn?: number }>();
51
+ const todoWidgetAgentRunningStore = new Map<string, boolean>();
52
+ const todoTurnStore = new Map<string, number>();
53
+ const TODO_HIDE_COMPLETED_AFTER_TURNS = 2;
54
+ const TODO_HIDE_COMPLETED_AFTER_MS = 90_000;
55
+ const TODO_MAX_VISIBLE_COMPLETED_WIDGET_ITEMS = 2;
56
+ const TODO_STATE_ENTRY_TYPE = "todo-write-state";
57
+ const TODO_COMPACTION_REMINDER_TYPE = "todo-write-compaction-reminder";
58
+
59
+ function createEmptyState(): TodoState {
60
+ return { tasks: [] };
61
+ }
62
+
63
+ function getTodoStateKey(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): string {
64
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
65
+ return sessionFile ? `session:${sessionFile}` : `cwd:${ctx.cwd}`;
66
+ }
67
+
68
+ function cloneTasks(tasks: TodoTask[]): TodoTask[] {
69
+ return tasks.map((task) => ({ ...task }));
70
+ }
71
+
72
+ function normalizeInProgressTask(tasks: TodoTask[]): void {
73
+ if (tasks.length === 0) return;
74
+
75
+ const inProgressTasks = tasks.filter((task) => task.status === "in_progress");
76
+ if (inProgressTasks.length > 1) {
77
+ for (const task of inProgressTasks.slice(1)) {
78
+ task.status = "pending";
79
+ }
80
+ }
81
+
82
+ if (inProgressTasks.length > 0) return;
83
+
84
+ const firstPendingTask = tasks.find((task) => task.status === "pending");
85
+ if (firstPendingTask) firstPendingTask.status = "in_progress";
86
+ }
87
+
88
+ function hasRemainingTasks(state: TodoState): boolean {
89
+ return state.tasks.some((task) => task.status === "pending" || task.status === "in_progress");
90
+ }
91
+
92
+ function getTodoTaskCount(state: TodoState): number {
93
+ return state.tasks.length;
94
+ }
95
+
96
+ type TodoWidgetVisibility = {
97
+ hidden: boolean;
98
+ completionGraceActive: boolean;
99
+ meta?: { completedAt: number; completedTurn: number };
100
+ };
101
+
102
+ export function getTodoWidgetVisibility(
103
+ state: TodoState,
104
+ meta: { completedAt?: number; completedTurn?: number } | undefined,
105
+ currentTurn: number,
106
+ now: number,
107
+ ): TodoWidgetVisibility {
108
+ if (getTodoTaskCount(state) === 0) return { hidden: true, completionGraceActive: false };
109
+ // Reset completion tracking when tasks are still remaining — stale completedTurn causes immediate hide on next full completion
110
+ if (hasRemainingTasks(state)) return { hidden: false, completionGraceActive: false };
111
+
112
+ const completedTurn = meta?.completedTurn ?? currentTurn;
113
+ const completedAt = meta?.completedAt ?? now;
114
+ const elapsedTurns = Math.max(0, currentTurn - completedTurn);
115
+ const elapsedMs = Math.max(0, now - completedAt);
116
+ const hidden = elapsedTurns >= TODO_HIDE_COMPLETED_AFTER_TURNS || elapsedMs >= TODO_HIDE_COMPLETED_AFTER_MS;
117
+
118
+ return {
119
+ hidden,
120
+ completionGraceActive: !hidden,
121
+ meta: { completedAt, completedTurn },
122
+ };
123
+ }
124
+
125
+ export function applyTodoWrite(todos: TodoWriteParamsType["todos"]): {
126
+ state: TodoState;
127
+ } {
128
+ const tasks: TodoTask[] = todos.map((todo, index) => ({
129
+ id: `task-${index + 1}`,
130
+ content: todo.content,
131
+ status: todo.status,
132
+ activeForm: todo.activeForm,
133
+ notes: todo.notes,
134
+ }));
135
+ normalizeInProgressTask(tasks);
136
+ return { state: { tasks } };
137
+ }
138
+
139
+ function renderTodoWidgetTaskLine(task: TodoTask): string {
140
+ const isDone = task.status === "completed";
141
+ const marker = task.status === "in_progress" ? "→" : isDone ? "●" : "○";
142
+ const displayText = task.status === "in_progress" && task.activeForm ? task.activeForm : task.content;
143
+ return isDone ? `~~${marker} ${displayText}` : `${marker} ${displayText}`;
144
+ }
145
+
146
+ export function renderTodoWidgetLines(state: TodoState): string[] {
147
+ if (getTodoTaskCount(state) === 0) return [];
148
+
149
+ const completedTasks = state.tasks.filter((task) => task.status === "completed");
150
+ const hiddenCompletedCount = Math.max(0, completedTasks.length - TODO_MAX_VISIBLE_COMPLETED_WIDGET_ITEMS);
151
+ const lines: string[] = [];
152
+ let seenCompletedCount = 0;
153
+ let insertedCompletedSummary = false;
154
+
155
+ for (const task of state.tasks) {
156
+ if (task.status !== "completed") {
157
+ lines.push(renderTodoWidgetTaskLine(task));
158
+ continue;
159
+ }
160
+
161
+ seenCompletedCount += 1;
162
+ if (seenCompletedCount <= hiddenCompletedCount) {
163
+ if (!insertedCompletedSummary) {
164
+ lines.push(`완료 +${hiddenCompletedCount}`);
165
+ insertedCompletedSummary = true;
166
+ }
167
+ continue;
168
+ }
169
+
170
+ lines.push(renderTodoWidgetTaskLine(task));
171
+ }
172
+
173
+ return lines;
174
+ }
175
+
176
+ export function renderTodoWriteSummary(state: TodoState): string {
177
+ if (state.tasks.length === 0) return "Todo list cleared.";
178
+
179
+ const remainingTasks = state.tasks.filter((task) => task.status === "pending" || task.status === "in_progress");
180
+ const doneCount = state.tasks.filter((task) => task.status === "completed").length;
181
+
182
+ const lines: string[] = [];
183
+ if (remainingTasks.length === 0) {
184
+ lines.push("Remaining items: none.");
185
+ } else {
186
+ lines.push(`Remaining items (${remainingTasks.length}):`);
187
+ for (const task of remainingTasks) {
188
+ lines.push(` - ${task.id} ${task.content} [${task.status}]`);
189
+ }
190
+ }
191
+
192
+ lines.push(`Progress: ${doneCount}/${state.tasks.length} tasks complete`);
193
+
194
+ for (const task of state.tasks) {
195
+ const marker = task.status === "completed" ? "✓" : task.status === "in_progress" ? "→" : "○";
196
+ lines.push(` ${marker} ${task.id} ${task.content}`);
197
+ }
198
+
199
+ return lines.join("\n");
200
+ }
201
+
202
+ function buildTodoTurnContext(state: TodoState): string | null {
203
+ if (state.tasks.length === 0) return null;
204
+ const summary = renderTodoWriteSummary(state);
205
+ const activeTask = state.tasks.find((task) => task.status === "in_progress");
206
+ const directive = activeTask
207
+ ? [
208
+ `Active task: ${activeTask.id} ${activeTask.content}`,
209
+ "When this task becomes done, your next action must be todo_write before any other tool call or response.",
210
+ ].join("\n")
211
+ : hasRemainingTasks(state)
212
+ ? "There are remaining tasks but no active in_progress task. Before doing more work, call todo_write to select the next active task."
213
+ : "All todo items are complete.";
214
+ return [
215
+ "[todo-reminder] internal todo_write state snapshot",
216
+ "Source: in-memory session state maintained by the todo_write tool.",
217
+ "Treat this as the latest authoritative todo status for the current turn.",
218
+ "Do not contradict this snapshot. If progress/status differs, update todo_write first.",
219
+ "",
220
+ summary,
221
+ "",
222
+ directive,
223
+ ].join("\n");
224
+ }
225
+
226
+ type TodoStateEntryData = {
227
+ tasks: TodoTask[];
228
+ updatedAt: number;
229
+ };
230
+
231
+ function persistTodoWriteStateEntry(pi: Pick<ExtensionAPI, "appendEntry">, state: TodoState): void {
232
+ pi.appendEntry<TodoStateEntryData>(TODO_STATE_ENTRY_TYPE, {
233
+ tasks: cloneTasks(state.tasks),
234
+ updatedAt: Date.now(),
235
+ });
236
+ }
237
+
238
+ function clearTodoWriteState(
239
+ ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
240
+ pi: Pick<ExtensionAPI, "appendEntry">,
241
+ ): void {
242
+ const empty = createEmptyState();
243
+ writeTodoWriteState(ctx, empty);
244
+ persistTodoWriteStateEntry(pi, empty);
245
+ }
246
+
247
+ // ── Legacy persistence migration ────────────────────────────────────────────
248
+
249
+ type PersistedTodoStatus = TodoStatus | "abandoned";
250
+
251
+ type PersistedTodoTask = {
252
+ id: string;
253
+ content: string;
254
+ status: PersistedTodoStatus;
255
+ activeForm?: string;
256
+ notes?: string;
257
+ };
258
+
259
+ type PersistedTodoStateEntryData = {
260
+ tasks: PersistedTodoTask[];
261
+ updatedAt: number;
262
+ };
263
+
264
+ function _isPersistedStatus(value: unknown): value is PersistedTodoStatus {
265
+ return value === "pending" || value === "in_progress" || value === "completed" || value === "abandoned";
266
+ }
267
+
268
+ function isPersistedTodoTask(value: unknown): value is PersistedTodoTask {
269
+ if (!value || typeof value !== "object") return false;
270
+ const candidate = value as Record<string, unknown>;
271
+ return (
272
+ typeof candidate.id === "string" &&
273
+ typeof candidate.content === "string" &&
274
+ _isPersistedStatus(candidate.status) &&
275
+ (candidate.activeForm === undefined || typeof candidate.activeForm === "string") &&
276
+ (candidate.notes === undefined || typeof candidate.notes === "string")
277
+ );
278
+ }
279
+
280
+ /** Migrate legacy persisted tasks: map `abandoned` → `completed`. */
281
+ function migrateLegacyTasks(tasks: PersistedTodoTask[]): TodoTask[] {
282
+ return tasks.map((task) => ({
283
+ ...task,
284
+ status: task.status === "abandoned" ? "completed" : task.status,
285
+ }));
286
+ }
287
+
288
+ function isPersistedTodoStateEntryData(value: unknown): value is PersistedTodoStateEntryData {
289
+ if (!value || typeof value !== "object") return false;
290
+ const candidate = value as Partial<PersistedTodoStateEntryData>;
291
+ return (
292
+ typeof candidate.updatedAt === "number" &&
293
+ Array.isArray(candidate.tasks) &&
294
+ candidate.tasks.every((task) => isPersistedTodoTask(task))
295
+ );
296
+ }
297
+
298
+ export function restoreTodoWriteState(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): TodoState {
299
+ const branch = ctx.sessionManager.getBranch();
300
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
301
+ const entry = branch[index];
302
+ if (entry.type !== "custom" || entry.customType !== TODO_STATE_ENTRY_TYPE) continue;
303
+ if (isPersistedTodoStateEntryData(entry.data)) {
304
+ const tasks = migrateLegacyTasks(entry.data.tasks);
305
+ normalizeInProgressTask(tasks);
306
+ const restored = { tasks };
307
+ writeTodoWriteState(ctx, restored);
308
+ return restored;
309
+ }
310
+ }
311
+
312
+ const empty = createEmptyState();
313
+ writeTodoWriteState(ctx, empty);
314
+ return empty;
315
+ }
316
+
317
+ export function buildPostCompactionTodoReminder(state: TodoState): string | null {
318
+ if (!hasRemainingTasks(state)) return null;
319
+ return [
320
+ "[todo-reminder] todo_write still has remaining items after compaction.",
321
+ "Please continue from the authoritative snapshot below.",
322
+ "",
323
+ renderTodoWriteSummary(state),
324
+ ].join("\n");
325
+ }
326
+
327
+ function readTodoWriteState(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): TodoState {
328
+ const key = getTodoStateKey(ctx);
329
+ const state = todoStateStore.get(key);
330
+ return state ? { tasks: cloneTasks(state.tasks) } : createEmptyState();
331
+ }
332
+
333
+ function writeTodoWriteState(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">, state: TodoState): void {
334
+ const key = getTodoStateKey(ctx);
335
+ if (state.tasks.length === 0) {
336
+ todoStateStore.delete(key);
337
+ return;
338
+ }
339
+ todoStateStore.set(key, { tasks: cloneTasks(state.tasks) });
340
+ }
341
+
342
+ function hasInProgressTask(state: TodoState): boolean {
343
+ return state.tasks.some((task) => task.status === "in_progress");
344
+ }
345
+
346
+ function clearTodoWidgetTimer(): void {
347
+ if (!todoWidgetTimer) return;
348
+ clearInterval(todoWidgetTimer);
349
+ todoWidgetTimer = undefined;
350
+ }
351
+
352
+ function clearTodoWidgetHideTimer(key: string): void {
353
+ const timer = todoWidgetHideTimerByKey.get(key);
354
+ if (!timer) return;
355
+ clearTimeout(timer);
356
+ todoWidgetHideTimerByKey.delete(key);
357
+ }
358
+
359
+ function getTodoTurn(key: string): number {
360
+ return todoTurnStore.get(key) ?? 0;
361
+ }
362
+
363
+ function incrementTodoTurn(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): void {
364
+ const key = getTodoStateKey(ctx);
365
+ todoTurnStore.set(key, getTodoTurn(key) + 1);
366
+ }
367
+
368
+ function setTodoWidgetAgentRunning(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">, running: boolean): void {
369
+ const key = getTodoStateKey(ctx);
370
+ todoWidgetAgentRunningStore.set(key, running);
371
+ }
372
+
373
+ async function syncTodoWidget(ctx: ExtensionContext, pi: Pick<ExtensionAPI, "appendEntry">): Promise<void> {
374
+ if (!ctx.hasUI) return;
375
+
376
+ const key = getTodoStateKey(ctx);
377
+ const state = readTodoWriteState(ctx);
378
+ const visibility = getTodoWidgetVisibility(state, todoWidgetMetaStore.get(key), getTodoTurn(key), Date.now());
379
+
380
+ if (visibility.meta) {
381
+ todoWidgetMetaStore.set(key, visibility.meta);
382
+ } else {
383
+ todoWidgetMetaStore.delete(key);
384
+ }
385
+
386
+ const lines = visibility.hidden ? [] : renderTodoWidgetLines(state);
387
+ if (lines.length === 0) {
388
+ // When hide conditions are met, clear state entirely
389
+ // so that todo-reminder context is no longer injected into LLM turns.
390
+ if (visibility.hidden && state.tasks.length > 0) {
391
+ clearTodoWriteState(ctx, pi);
392
+ todoWidgetMetaStore.delete(key);
393
+ }
394
+ clearTodoWidgetTimer();
395
+ clearTodoWidgetHideTimer(key);
396
+ ctx.ui.setWidget(TODO_WIDGET_KEY, undefined);
397
+ return;
398
+ }
399
+
400
+ clearTodoWidgetHideTimer(key);
401
+ if (visibility.completionGraceActive && !hasRemainingTasks(state) && visibility.meta?.completedAt !== undefined) {
402
+ const elapsedMs = Math.max(0, Date.now() - visibility.meta.completedAt);
403
+ const remainingMs = Math.max(0, TODO_HIDE_COMPLETED_AFTER_MS - elapsedMs);
404
+ const hideTimer = setTimeout(() => {
405
+ todoWidgetHideTimerByKey.delete(key);
406
+ void syncTodoWidget(ctx, pi);
407
+ }, remainingMs);
408
+ todoWidgetHideTimerByKey.set(key, hideTimer);
409
+ }
410
+
411
+ ctx.ui.setWidget(TODO_WIDGET_KEY, (tui, theme) => {
412
+ const renderedLines = [...lines];
413
+ const hasRunning = hasInProgressTask(state) && (todoWidgetAgentRunningStore.get(key) ?? false);
414
+ const content = new Text("", 0, 0);
415
+
416
+ clearTodoWidgetTimer();
417
+ if (hasRunning) {
418
+ todoWidgetTimer = setInterval(() => tui.requestRender(), TODO_SPINNER_INTERVAL_MS);
419
+ }
420
+
421
+ return {
422
+ render(width: number): string[] {
423
+ const lineWidth = Math.max(8, width);
424
+ const spinner =
425
+ TODO_SPINNER_FRAMES[Math.floor(Date.now() / TODO_SPINNER_INTERVAL_MS) % TODO_SPINNER_FRAMES.length] ?? "•";
426
+ const styledLines = renderedLines.map((line) => {
427
+ if (line.startsWith("→ ")) {
428
+ if (hasRunning) {
429
+ const runningLine = `${spinner} ${line.slice(2)}`;
430
+ return theme.bold(theme.fg("accent", truncateToWidth(runningLine, lineWidth)));
431
+ }
432
+ return theme.fg("accent", truncateToWidth(`○ ${line.slice(2)}`, lineWidth));
433
+ }
434
+ if (line.startsWith("~~")) {
435
+ return theme.fg("dim", theme.strikethrough(truncateToWidth(line.slice(2), lineWidth)));
436
+ }
437
+ if (line.startsWith("...")) {
438
+ return theme.fg("dim", truncateToWidth(line, lineWidth));
439
+ }
440
+ return theme.fg("toolOutput", truncateToWidth(line, lineWidth));
441
+ });
442
+ content.setText(styledLines.join("\n"));
443
+ return content.render(width);
444
+ },
445
+ invalidate() {
446
+ content.invalidate();
447
+ },
448
+ };
449
+ });
450
+ }
451
+
452
+ export default function todoWriteExtension(pi: ExtensionAPI): void {
453
+ pi.registerTool({
454
+ name: "todo_write",
455
+ label: "Todo Write",
456
+ description: `Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and show the user your overall progress.
457
+
458
+ ## When to Use
459
+ - Complex multi-step tasks requiring 3+ distinct steps
460
+ - User provides multiple tasks to be done
461
+ - Non-trivial tasks requiring careful planning
462
+
463
+ ## When NOT to Use
464
+ - Single, straightforward task — just do it directly
465
+ - Trivial tasks completable in less than 3 steps
466
+ - Purely conversational or informational requests
467
+
468
+ ## Rules
469
+ - Write todo content in Korean when practical
470
+ - Update task status in real-time as you work
471
+ - Mark tasks complete IMMEDIATELY after finishing — don't batch completions
472
+ - Exactly ONE task should be in_progress at any time
473
+ - Complete current tasks before starting new ones
474
+ - Remove tasks that are no longer relevant
475
+ - ONLY mark completed when FULLY accomplished — if blocked, keep as in_progress
476
+ - If requirements change mid-task, update the todo list before continuing
477
+
478
+ ## Task Fields
479
+ - content: Imperative form (e.g., "테스트 실행", "Run tests")
480
+ - status: pending | in_progress | completed
481
+ - activeForm: (optional) Present continuous form for display (e.g., "테스트 실행 중", "Running tests")
482
+ - notes: (optional) Additional context`,
483
+ parameters: TodoWriteParams,
484
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
485
+ const applied = applyTodoWrite(params.todos);
486
+ const summary = renderTodoWriteSummary(applied.state);
487
+ writeTodoWriteState(ctx, applied.state);
488
+ persistTodoWriteStateEntry(pi, applied.state);
489
+ await syncTodoWidget(ctx, pi);
490
+ return {
491
+ content: [{ type: "text" as const, text: summary }],
492
+ details: { tasks: applied.state.tasks, summary },
493
+ };
494
+ },
495
+ renderResult(result, { expanded }, theme) {
496
+ if (!expanded) return new Text("", 0, 0);
497
+ const details = result.details as { summary?: unknown } | undefined;
498
+ const summary = typeof details?.summary === "string" ? details.summary : "";
499
+ return new Text(summary ? theme.fg("toolOutput", summary) : "", 0, 0);
500
+ },
501
+ });
502
+
503
+ pi.on("before_agent_start", async (_event, ctx) => {
504
+ const state = readTodoWriteState(ctx);
505
+ if (state.tasks.length === 0) return;
506
+
507
+ // If hide conditions are met, clear state so no reminder is injected
508
+ const key = getTodoStateKey(ctx);
509
+ const visibility = getTodoWidgetVisibility(state, todoWidgetMetaStore.get(key), getTodoTurn(key), Date.now());
510
+ if (visibility.hidden) {
511
+ clearTodoWriteState(ctx, pi);
512
+ todoWidgetMetaStore.delete(key);
513
+ return;
514
+ }
515
+
516
+ const content = buildTodoTurnContext(state);
517
+ if (!content) return;
518
+ return {
519
+ message: {
520
+ customType: "todo-write-context",
521
+ content,
522
+ display: false,
523
+ details: { summary: renderTodoWriteSummary(state) },
524
+ },
525
+ };
526
+ });
527
+
528
+ pi.on("agent_start", async (_event, ctx) => {
529
+ setTodoWidgetAgentRunning(ctx, true);
530
+ await syncTodoWidget(ctx, pi);
531
+ });
532
+
533
+ pi.on("agent_end", async (_event, ctx) => {
534
+ setTodoWidgetAgentRunning(ctx, false);
535
+ await syncTodoWidget(ctx, pi);
536
+ });
537
+
538
+ pi.on("session_start", async (_event, ctx) => {
539
+ setTodoWidgetAgentRunning(ctx, false);
540
+ restoreTodoWriteState(ctx);
541
+ await syncTodoWidget(ctx, pi);
542
+ });
543
+
544
+ pi.on("session_tree", async (_event, ctx) => {
545
+ setTodoWidgetAgentRunning(ctx, false);
546
+ restoreTodoWriteState(ctx);
547
+ await syncTodoWidget(ctx, pi);
548
+ });
549
+
550
+ pi.on("session_compact", async (_event, ctx) => {
551
+ const state = restoreTodoWriteState(ctx);
552
+ await syncTodoWidget(ctx, pi);
553
+ const reminder = buildPostCompactionTodoReminder(state);
554
+ if (!reminder) return;
555
+
556
+ if (ctx.hasUI) {
557
+ ctx.ui.notify("Todo reminder: remaining items still exist after compaction.", "info");
558
+ }
559
+
560
+ pi.sendMessage(
561
+ {
562
+ customType: TODO_COMPACTION_REMINDER_TYPE,
563
+ content: reminder,
564
+ display: true,
565
+ details: { summary: renderTodoWriteSummary(state) },
566
+ },
567
+ { deliverAs: "followUp", triggerTurn: true },
568
+ );
569
+ });
570
+
571
+ pi.on("message_end", async (_event, ctx) => {
572
+ incrementTodoTurn(ctx);
573
+ await syncTodoWidget(ctx, pi);
574
+ });
575
+
576
+ pi.on("session_shutdown", async (_event, ctx) => {
577
+ const key = getTodoStateKey(ctx);
578
+ clearTodoWidgetTimer();
579
+ clearTodoWidgetHideTimer(key);
580
+ todoWidgetMetaStore.delete(key);
581
+ todoWidgetAgentRunningStore.delete(key);
582
+ todoTurnStore.delete(key);
583
+ if (!ctx.hasUI) return;
584
+ ctx.ui.setWidget(TODO_WIDGET_KEY, undefined);
585
+ });
586
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@ryan_nookpi/pi-extension-todo-write",
3
+ "version": "0.1.0",
4
+ "description": "Todo write tool extension for pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "index.ts",
11
+ "README.md"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ },
18
+ "peerDependencies": {
19
+ "@mariozechner/pi-ai": "*",
20
+ "@mariozechner/pi-coding-agent": "*",
21
+ "@mariozechner/pi-tui": "*",
22
+ "@sinclair/typebox": "*"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }