@nguyenquangthai/pi-todo 0.2.9 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-07-15)
4
+
5
+ ### Fixed
6
+
7
+ - **Durable `todo_update` replay**: Replays successful `todo_update` tool results
8
+ as a fallback when compaction retains tool messages but prunes older custom
9
+ state entries. Verified in a live Pi `write → update → /compact → /reload →
10
+ todo_diagnose` lifecycle.
11
+ - **Completion reminders**: Completion and cadence reminders now direct agents
12
+ to patch known IDs with `todo_update`, rather than unnecessarily replacing
13
+ the full checklist. A successful update also clears pending intent nudges.
14
+ - **Timeline overlay boundaries**: The overlay preserves checklist order,
15
+ pins an out-of-view active item, and never renders more than `maxLines`.
16
+
17
+ ## 0.3.0 (2026-07-15)
18
+
19
+ - **ID invariant hardening**: `validateTodoWrite` rejects items with explicit `id`
20
+ that doesn't exist in current list. New items must omit `id` (auto-assign).
21
+ - **`ensureTodoIds` tuple matching**: Matches by `(content+status+priority)` first,
22
+ then by unique content-only. Duplicate-content items get fresh UUIDs instead
23
+ of risking mis-assignment.
24
+ - **`todo_update` atomicity**: Stale-ctx returns error, real errors propagate,
25
+ setTodos after appendEntry — matching todo_write guarantees.
26
+ - **Status-first shared sort**: `statusPrioritySort()` extracted and shared by
27
+ `formatTodoListText` (tool output) and `selectOverlayLayout` (overlay) —
28
+ in_progress → pending → terminal, regardless of overflow.
29
+ - **Fixed double-count bug**: Terminal items no longer counted in both `+N done`
30
+ AND `+N more` on overlay overflow.
31
+ - **Lifecycle E2E test**: Write → update → tree → compact → shutdown → restart,
32
+ verifying stable IDs and status throughout.
33
+ - **125 tests**, typecheck clean, CI green (Node 20 + 22).
34
+
3
35
  ## 0.2.9 (2026-07-15)
4
36
 
5
37
  - **Atomic write ordering**: `setTodos()` happens *after* `pi.appendEntry()`
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  OpenCode-style session todo checklist for the [pi coding agent](https://pi.dev).
8
8
 
9
- Adds `todo_write` / `todo_read` / `todo_diagnose`, a live `# Todos` overlay above the editor (`[ ]` / `[•]` / `[✓]` / `[×]`), and branch-replay persistence (survives `/reload`, tree nav, and custom-entry durability across compaction).
9
+ Adds `todo_write` / `todo_update` / `todo_read` / `todo_diagnose`, a live `# Todos` overlay above the editor (`[ ]` / `[•]` / `[✓]` / `[×]`), and branch-replay persistence (survives `/reload`, tree nav, and custom-entry durability across compaction).
10
10
 
11
11
  ## Install
12
12
 
@@ -45,12 +45,25 @@ Rules enforced by the tool:
45
45
  - Exactly **one** `in_progress` allowed (hard reject if more)
46
46
  - `content` required (non-empty after sanitize); max **500** chars (longer values truncated)
47
47
  - `priority` required: `high` | `medium` | `low`
48
- - Status: `pending` | `in_progress` | `completed` | `cancelled`
48
+ - Status: `pending` | `in_progress` | `completed` | `cancelled`
49
+ - Array order is the workflow timeline. Keep existing positions when statuses change; only add or reorder items intentionally.
49
50
  - Tool text echo caps at **40** lines (`+N more` in the text body; full list still in `details` / JSON)
50
51
 
51
- ### `todo_read`
52
+ ### `todo_update`
53
+
54
+ Patch existing todos by stable ID without replacing the list or changing its order. Use `todo_read` first to obtain IDs; this tool never deletes items.
55
+
56
+ ```json
57
+ {
58
+ "updates": [
59
+ { "id": "existing-todo-id", "status": "completed" }
60
+ ]
61
+ }
62
+ ```
63
+
64
+ ### `todo_read`
52
65
 
53
- Returns the current list as text + JSON. Prefer the overlay for at-a-glance status; avoid calling it in the same parallel batch as `todo_write`.
66
+ Returns the current list as text + JSON. Prefer the overlay for at-a-glance status; use it to obtain stable IDs before `todo_update`, and avoid calling it in the same parallel batch as a todo mutation.
54
67
 
55
68
  ### `todo_diagnose`
56
69
 
@@ -68,10 +81,10 @@ Heading shows open + running counts + background-color progress bar, e.g. `# Tod
68
81
  - **running** = `in_progress` only (0 or 1 after a valid write)
69
82
  - **progress bar** = ANSI background color via reverse video, using theme `accent` (filled) and `muted` (empty)
70
83
 
71
- Items within each status group are sorted by priority (high medium → low).
72
- When space is tight, completed/cancelled items collapse into `+N done`.
84
+ Items always stay in the array's workflow order; status changes only their marker/color.
85
+ When space is tight, the overlay shows the earliest checklist items and `+N more`. If the active item is outside that prefix, it is repeated as `Active: [•] …` rather than moved ahead of earlier work.
73
86
  A blank line separates the heading from the first todo row for visual breathing room.
74
- Successful `todo_write` results display `✓ Saved`, meaning the durable checkpoint was accepted before the in-memory snapshot was updated.
87
+ Successful `todo_write` and `todo_update` results display `✓ Saved`, meaning the durable checkpoint was accepted before the in-memory snapshot was updated.
75
88
 
76
89
  ## Development
77
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nguyenquangthai/pi-todo",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "OpenCode-like session todo checklist for the pi coding agent — todowrite/todoread with a live TUI overlay",
5
5
  "type": "module",
6
6
  "author": "QuangThai",
package/src/format.ts CHANGED
@@ -1,18 +1,9 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import type { TodoItem, TodoStatus } from "./types.js";
4
- import { MAX_OVERLAY_LINES, MAX_RESULT_LINES } from "./types.js";
5
- import { countOpenTodos, countRunningTodos, hasOpenTodos, isTerminalStatus } from "./validate.js";
6
-
7
- const PRIORITY_RANK: Record<string, number> = {
8
- high: 0,
9
- medium: 1,
10
- low: 2,
11
- };
12
-
13
- function prioritySort(a: TodoItem, b: TodoItem): number {
14
- return (PRIORITY_RANK[a.priority] ?? 2) - (PRIORITY_RANK[b.priority] ?? 2);
15
- }
3
+ import type { TodoItem, TodoStatus } from "./types.js";
4
+ import { MAX_OVERLAY_LINES, MAX_RESULT_LINES } from "./types.js";
5
+ import { countOpenTodos, countRunningTodos, hasOpenTodos } from "./validate.js";
6
+
16
7
 
17
8
  export function getTodoMarker(status: TodoStatus): string {
18
9
  switch (status) {
@@ -35,25 +26,15 @@ export function formatPlainTodoLine(todo: TodoItem): string {
35
26
  export function formatTodoListText(todos: readonly TodoItem[], summary: string): string {
36
27
  if (todos.length === 0) return summary;
37
28
 
38
- // in_progress first, then pending (sorted by priority), then terminal
39
- const sorted = [...todos].sort((a, b) => {
40
- const statusRank: Record<string, number> = {
41
- in_progress: 0,
42
- pending: 1,
43
- completed: 2,
44
- cancelled: 2,
45
- };
46
- const diff =
47
- (statusRank[a.status] ?? 2) - (statusRank[b.status] ?? 2);
48
- if (diff !== 0) return diff;
49
- return prioritySort(a, b);
50
- });
29
+ // The list is a workflow timeline. Never reshuffle completed work after the
30
+ // next task just because its status changed.
31
+ const ordered = [...todos];
51
32
 
52
33
  if (todos.length <= MAX_RESULT_LINES) {
53
- return [summary, ...sorted.map(formatPlainTodoLine)].join("\n");
34
+ return [summary, ...ordered.map(formatPlainTodoLine)].join("\n");
54
35
  }
55
- const shown = sorted.slice(0, MAX_RESULT_LINES);
56
- const hidden = sorted.length - MAX_RESULT_LINES;
36
+ const shown = ordered.slice(0, MAX_RESULT_LINES);
37
+ const hidden = ordered.length - MAX_RESULT_LINES;
57
38
  return [
58
39
  summary,
59
40
  ...shown.map(formatPlainTodoLine),
@@ -77,57 +58,49 @@ export function shouldShowOverlay(todos: readonly TodoItem[]): boolean {
77
58
  return hasOpenTodos(todos);
78
59
  }
79
60
 
80
- export interface OverlayLayout {
81
- visible: TodoItem[];
82
- hiddenCount: number;
83
- terminalCount: number;
84
- }
85
-
86
- /**
87
- * Fit into maxLines (includes heading). On overflow, collapse terminal items
88
- * into a "+N done" line. Within each status group, items are sorted by
89
- * priority (high → medium → low).
90
- */
61
+ export interface OverlayLayout {
62
+ visible: TodoItem[];
63
+ /** Active item repeated below the timeline when it is outside the visible prefix. */
64
+ pinnedActive?: TodoItem;
65
+ hiddenCount: number;
66
+ /** Retained for consumers of the layout API; terminal items are no longer regrouped. */
67
+ terminalCount: number;
68
+ }
69
+
70
+ /**
71
+ * Fit the checklist timeline into the overlay without sorting by status or
72
+ * priority. When the active task falls outside the visible prefix, repeat it
73
+ * as a pinned "Active:" row rather than moving it ahead of earlier work.
74
+ */
91
75
  export function selectOverlayLayout(
92
76
  todos: readonly TodoItem[],
93
- maxLines: number = MAX_OVERLAY_LINES,
94
- ): OverlayLayout {
95
- if (!shouldShowOverlay(todos)) {
96
- return { visible: [], hiddenCount: 0, terminalCount: 0 };
97
- }
98
-
99
- const bodyBudget = Math.max(1, maxLines - 1);
100
- if (todos.length <= bodyBudget) {
101
- // All fit — show everything
102
- return { visible: [...todos].sort(prioritySort), hiddenCount: 0, terminalCount: 0 };
103
- }
104
-
105
- const innerBudget = Math.max(1, bodyBudget - 1);
106
- const inProgress = todos.filter((t) => t.status === "in_progress").sort(prioritySort);
107
- const pending = todos.filter((t) => t.status === "pending").sort(prioritySort);
108
- const terminal = todos.filter((t) => isTerminalStatus(t.status)).sort(prioritySort);
109
-
110
- const picked: TodoItem[] = [];
111
- const addPicked = (items: TodoItem[]) => {
112
- for (const t of items) {
113
- if (picked.length >= innerBudget) break;
114
- picked.push(t);
115
- }
116
- };
117
-
118
- addPicked(inProgress);
119
- addPicked(pending);
120
-
121
- // Collapse terminal items: only show them individually if there's room
122
- if (terminal.length > 0 && picked.length < innerBudget) {
123
- addPicked(terminal);
77
+ maxLines: number = MAX_OVERLAY_LINES,
78
+ ): OverlayLayout {
79
+ if (!shouldShowOverlay(todos)) {
80
+ return { visible: [], hiddenCount: 0, terminalCount: 0 };
124
81
  }
125
82
 
126
- const terminalCollapsed = terminal.length > 0 && picked.length >= innerBudget;
127
- const remaining = todos.length - picked.length;
128
- const hiddenCount = terminalCollapsed ? remaining : Math.max(0, remaining - terminal.length);
129
-
130
- return { visible: picked, hiddenCount, terminalCount: terminalCollapsed ? terminal.length : 0 };
83
+ const bodyBudget = Math.max(1, maxLines - 1);
84
+ if (todos.length <= bodyBudget) {
85
+ // All fit show the canonical checklist sequence unchanged.
86
+ return { visible: [...todos], hiddenCount: 0, terminalCount: 0 };
87
+ }
88
+
89
+ const active = todos.find((todo) => todo.status === "in_progress");
90
+ // Reserve one row for the overflow summary. If the active task lies outside
91
+ // the timeline prefix, reserve another row to pin it without reordering.
92
+ let visibleCapacity = Math.max(0, bodyBudget - 1);
93
+ let visible = todos.slice(0, visibleCapacity);
94
+ let pinnedActive = active && !visible.includes(active) ? active : undefined;
95
+
96
+ if (pinnedActive) {
97
+ visibleCapacity = Math.max(0, bodyBudget - 2);
98
+ visible = todos.slice(0, visibleCapacity);
99
+ pinnedActive = active;
100
+ }
101
+
102
+ const hiddenCount = todos.length - visible.length - (pinnedActive ? 1 : 0);
103
+ return { visible, pinnedActive, hiddenCount, terminalCount: 0 };
131
104
  }
132
105
 
133
106
  export interface RenderOverlayOptions {
@@ -149,7 +122,7 @@ export function renderOverlayLines(
149
122
  ): string[] {
150
123
  if (!shouldShowOverlay(todos)) return [];
151
124
 
152
- const maxLines = options.maxLines ?? MAX_OVERLAY_LINES;
125
+ const maxLines = Math.max(1, options.maxLines ?? MAX_OVERLAY_LINES);
153
126
  const truncate = (line: string) => truncateToWidth(line, width, "…");
154
127
  const open = countOpenTodos(todos);
155
128
  const running = countRunningTodos(todos);
@@ -175,15 +148,19 @@ export function renderOverlayLines(
175
148
  const layout = selectOverlayLayout(todos, Math.max(3, maxLines - 1));
176
149
  const lines: string[] = [heading, ""];
177
150
 
178
- for (const todo of layout.visible) {
179
- lines.push(truncate(formatThemedTodoLine(todo, theme)));
180
- }
181
- if (layout.terminalCount > 0) {
182
- lines.push(truncate(theme.fg("dim", `+${layout.terminalCount} done`)));
183
- }
151
+ for (const todo of layout.visible) {
152
+ lines.push(truncate(formatThemedTodoLine(todo, theme)));
153
+ }
154
+ if (layout.pinnedActive) {
155
+ lines.push(
156
+ truncate(theme.fg("warning", `Active: ${formatPlainTodoLine(layout.pinnedActive)}`)),
157
+ );
158
+ }
184
159
  if (layout.hiddenCount > 0) {
185
160
  lines.push(truncate(theme.fg("dim", `+${layout.hiddenCount} more`)));
186
161
  }
187
- lines.push("");
188
- return lines;
189
- }
162
+ lines.push("");
163
+ // Layout reserves content rows, but the heading and trailing spacer are
164
+ // rendered here. Enforce the public maxLines contract at the final boundary.
165
+ return lines.slice(0, maxLines);
166
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * pi-todo — OpenCode-like session todo checklist for Pi.
3
3
  *
4
- * Tools: todo_write (full replace), todo_read, todo_diagnose (read-only)
4
+ * Tools: todo_write (full replace), todo_update (patch by ID), todo_read,
5
+ * todo_diagnose (read-only)
5
6
  * Overlay: # Todos with [ ]/[•]/[✓]/[×] above the editor
6
7
  * Persistence: toolResult details + custom entry, replayed from branch
7
8
  * Reminder: pi-tasks-style cadence → transient <system-reminder> via context
@@ -33,10 +34,11 @@ import { clearTodos, getTodos, setTodos } from "./store.js";
33
34
  import { registerTodoReadTool } from "./tools/todoread.js";
34
35
  import { registerTodoDiagnoseTool } from "./tools/tododiagnose.js";
35
36
  import { registerTodoWriteTool } from "./tools/todowrite.js";
36
- import { TOOL_DIAGNOSE, TOOL_READ, TOOL_WRITE } from "./types.js";
37
+ import { registerTodoUpdateTool } from "./tools/todoupdate.js";
38
+ import { TOOL_DIAGNOSE, TOOL_READ, TOOL_UPDATE, TOOL_WRITE } from "./types.js";
37
39
  import { hasOpenTodos, isOpenTodo } from "./validate.js";
38
40
 
39
- const TODO_TOOL_NAMES = new Set([TOOL_WRITE, TOOL_READ, TOOL_DIAGNOSE]);
41
+ const TODO_TOOL_NAMES = new Set([TOOL_WRITE, TOOL_READ, TOOL_UPDATE, TOOL_DIAGNOSE]);
40
42
 
41
43
  const cadenceConfig: CadenceConfig = {
42
44
  reminderInterval: REMINDER_INTERVAL,
@@ -87,6 +89,7 @@ export default function (pi: ExtensionAPI): void {
87
89
  };
88
90
 
89
91
  registerTodoWriteTool(pi, { onCommit: refreshOverlay });
92
+ registerTodoUpdateTool(pi, { onCommit: refreshOverlay });
90
93
  registerTodoReadTool(pi);
91
94
  registerTodoDiagnoseTool(pi);
92
95
 
@@ -178,10 +181,11 @@ export default function (pi: ExtensionAPI): void {
178
181
  });
179
182
 
180
183
  // Refresh from in-memory store — do NOT replayFromBranch here (branch is stale).
181
- // Clear intent nudge once todo_write succeeds (model complied; avoid double-inject).
182
- pi.on("tool_execution_end", async (event) => {
183
- if (event.toolName !== TOOL_WRITE || event.isError) return;
184
- pendingIntentNudge = null;
185
- overlay?.update();
184
+ // Clear intent nudge once either mutation succeeds (model complied; avoid
185
+ // injecting a stale completion reminder after todo_update).
186
+ pi.on("tool_execution_end", async (event) => {
187
+ if ((event.toolName !== TOOL_WRITE && event.toolName !== TOOL_UPDATE) || event.isError) return;
188
+ pendingIntentNudge = null;
189
+ overlay?.update();
186
190
  });
187
191
  }
@@ -140,7 +140,7 @@ export function buildCompletionUpdateReminder(openLines: string[]): string {
140
140
  ? `\nOpen todos:\n${openLines.map((l) => `- ${l}`).join("\n")}\n`
141
141
  : "\n";
142
142
  return `<system-reminder>
143
- The user signaled that work is done/approved. Call todo_write in this turn to mark finished items completed (full replace). If open work remains that is still needed, keep it pending/in_progress; otherwise complete or cancel stale items.
143
+ The user signaled that work is done/approved. Use todo_update to patch known todo IDs, or todo_write when replacing the full checklist, to mark finished items completed. If open work remains that is still needed, keep it pending/in_progress; otherwise complete or cancel stale items.
144
144
  ${body}
145
145
  NEVER mention this reminder to the user.
146
146
  </system-reminder>`;
package/src/prompt.ts CHANGED
@@ -35,7 +35,8 @@ Do **not** skip just because the request is "explain" or "review" — if the ans
35
35
  - Update status in real time; don't batch completions across multiple finished steps.
36
36
  - Mark \`completed\` only after the work is actually done (including verification) — never on intent alone.
37
37
  - Keep exactly one \`in_progress\` while actively working. Never leave a stale \`in_progress\` after that step is finished.
38
- - After marking an item \`completed\`, if open work remains, set the next actionable item to \`in_progress\` in the same todo_write call when you are continuing.
38
+ - When using \`todo_write\`, after marking an item \`completed\`, set the next actionable item to \`in_progress\` in the same full-replace call when you are continuing.
39
+ - Array order is the workflow timeline. Preserve existing item positions as statuses change; add or reorder items only deliberately.
39
40
  - If blocked, keep the active item \`in_progress\` and add a follow-up todo for the blocker.
40
41
  - Items should be specific and actionable.
41
42
  - Do not call todo_write and todo_read in the same parallel tool batch — write first, then read later if needed.
@@ -50,8 +51,9 @@ export const TODODIAGNOSE_DESCRIPTION =
50
51
  export const TODOWRITE_GUIDELINES = [
51
52
  "For multi-step work (3+ steps), codebase explain/explore/review, or when the user gives a list of tasks, call todo_write BEFORE starting — do not skip tracking.",
52
53
  "Pass the full list every todo_write call (full replace). Keep exactly one todo in_progress; mark completed immediately when a step finishes — never leave a stale in_progress.",
53
- "When finishing a step or when the user confirms done, call todo_write in that same turn to mark completed and advance the next pending item if work continues.",
54
- "Do not invent TaskCreate/TaskUpdate tools use todo_write/todo_read; todo_diagnose is read-only troubleshooting.",
54
+ "When finishing a step or when the user confirms done, use todo_update for an ID-based patch when possible; use todo_write only when replacing the full checklist. Advance the next pending item in the same mutation if work continues.",
55
+ "Treat todo array order as the workflow timeline: preserve positions when updating statuses, and only add or reorder items intentionally.",
56
+ "Use todo_write for full replacement and todo_update for ID-based patches; todo_read exposes IDs and todo_diagnose is read-only troubleshooting.",
55
57
  "Do not call todo_write and todo_read in the same parallel tool batch.",
56
58
  ];
57
59
 
@@ -62,11 +64,11 @@ export const TODOWRITE_GUIDELINES = [
62
64
  */
63
65
  export const TASK_MANAGEMENT_SECTION = `
64
66
  <Task_Management>
65
- todo_write/todo_read are the coordination layer for multi-step work. The TUI overlay only appears after todo_write — without it the user cannot see plan/progress.
67
+ todo_write/todo_update/todo_read are the coordination layer for multi-step work. Use todo_write for the initial/full checklist and todo_update for a targeted patch by stable ID. The TUI overlay only appears after a todo mutation — without it the user cannot see plan/progress.
66
68
 
67
69
  Required cold start: for explain/explore/review/implement/refactor/audit/debug/fix/polish/setup of a codebase, feature, UI, or multi-file ask — your FIRST tool call must be todo_write with a short checklist (WHAT/WHERE), exactly one in_progress, then continue. Do not start with read/bash/search alone on those asks.
68
70
 
69
- Ongoing: mark completed immediately when a step finishes; advance the next pending item in the same todo_write; when the user says done/approved, update via todo_write that turn.
71
+ Ongoing: mark completed immediately when a step finishes; use todo_update for a targeted ID-based patch or todo_write for a full replacement. Advance the next pending item in the same mutation; when the user says done/approved, update that turn.
70
72
 
71
73
  Skip only single trivial Q&A (one short fact, greeting). Multi-step Vietnamese or English asks (giải thích, chỉnh, bổ sung, help me, …) still need todos first.
72
74
  </Task_Management>
@@ -85,7 +85,7 @@ export function drainReminderForContext(state: CadenceState): boolean {
85
85
  return true;
86
86
  }
87
87
 
88
- /** Default: 4 turns without todo_write/todo_read while open work remains. */
88
+ /** Default: 4 turns without a todo tool while open work remains. */
89
89
  export const REMINDER_INTERVAL = 4;
90
90
 
91
91
  /**
@@ -101,7 +101,7 @@ export function buildSystemReminder(todos: readonly TodoItem[]): string | null {
101
101
 
102
102
  const focus =
103
103
  inProgress.length > 0
104
- ? `Active item still in_progress: "${inProgress[0].content}". If that work is finished, call todo_write immediately with it marked completed (full replace), then set the next pending item to in_progress (exactly one). Do not leave a stale [•] after finishing a step.`
104
+ ? `Active item still in_progress: "${inProgress[0].content}". If that work is finished, use todo_update to patch its known ID, or todo_write for a full replacement, and mark it completed; then set the next pending item to in_progress in the same mutation. Do not leave a stale [•] after finishing a step.`
105
105
  : `Open items are still pending with none in_progress. If you are about to work, call todo_write and mark exactly one item in_progress before continuing.`;
106
106
 
107
107
  return `<system-reminder>
package/src/replay.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { TodoItem, TodoWriteDetails } from "./types.js";
2
- import { TODO_STATE_ENTRY_TYPE, TOOL_WRITE } from "./types.js";
2
+ import { TODO_STATE_ENTRY_TYPE, TOOL_UPDATE, TOOL_WRITE } from "./types.js";
3
3
  import { TERMINAL_STATUSES, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
4
4
 
5
5
  type BranchEntry = {
@@ -36,8 +36,9 @@ function isWriteDetails(value: unknown): value is TodoWriteDetails {
36
36
  * Replay todo state from the session branch.
37
37
  *
38
38
  * Pi guarantees getBranch() returns entries in root→leaf (chronological) order.
39
- * Last valid entry wins — custom `pi-todo.state` entries and `todo_write`
40
- * toolResult details are both tracked.
39
+ * Last valid entry wins — custom `pi-todo.state` entries plus both mutation
40
+ * toolResult details are tracked. Tool-result replay is a fallback when a
41
+ * compaction retains messages but drops old custom entries.
41
42
  *
42
43
  * Error envelopes (e.g. validation failures) are skipped so they never
43
44
  * overwrite a good state.
@@ -59,7 +60,12 @@ export function replayFromBranch(ctx: {
59
60
 
60
61
  if (e.type !== "message" || !isRecord(e.message)) continue;
61
62
  const msg = e.message as Record<string, unknown>;
62
- if (msg.role !== "toolResult" || msg.toolName !== TOOL_WRITE) continue;
63
+ if (
64
+ msg.role !== "toolResult" ||
65
+ (msg.toolName !== TOOL_WRITE && msg.toolName !== TOOL_UPDATE)
66
+ ) {
67
+ continue;
68
+ }
63
69
  if (!isWriteDetails(msg.details)) continue;
64
70
  // Skip error envelopes — they did not commit
65
71
  if ((msg.details as TodoWriteDetails).error) continue;
package/src/schema.ts CHANGED
@@ -2,7 +2,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
4
4
 
5
- export const TodoItemSchema = Type.Object({
5
+ export const TodoItemSchema = Type.Object({
6
+ id: Type.Optional(Type.String({ minLength: 1, description: "Stable todo ID; preserve it when rewriting an existing item" })),
6
7
  content: Type.String({ description: "Brief description of the task" }),
7
8
  status: StringEnum([...TODO_STATUSES], {
8
9
  description: "pending | in_progress | completed | cancelled",
@@ -22,6 +23,18 @@ export const TodoReadParams = Type.Object({});
22
23
 
23
24
  /** Read-only persistence check; deliberately accepts no mutation input. */
24
25
  export const TodoDiagnoseParams = Type.Object({});
26
+
27
+ export const TodoUpdateParams = Type.Object({
28
+ updates: Type.Array(
29
+ Type.Object({
30
+ id: Type.String({ minLength: 1 }),
31
+ content: Type.Optional(Type.String()),
32
+ status: Type.Optional(StringEnum([...TODO_STATUSES])),
33
+ priority: Type.Optional(StringEnum([...TODO_PRIORITIES])),
34
+ }),
35
+ { minItems: 1, description: "Patch existing todos by stable ID" },
36
+ ),
37
+ });
25
38
 
26
39
  export type TodoWriteInput = Static<typeof TodoWriteParams>;
27
40
  export type TodoItemInput = Static<typeof TodoItemSchema>;
@@ -0,0 +1,43 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { TodoUpdateParams } from "../schema.js";
4
+ import { getTodos, setTodos, withStoreLock } from "../store.js";
5
+ import type { TodoWriteDetails } from "../types.js";
6
+ import { TODO_STATE_ENTRY_TYPE, TOOL_UPDATE } from "../types.js";
7
+ import { ensureTodoIds, validateTodoUpdate } from "../validate.js";
8
+
9
+ export function registerTodoUpdateTool(pi: ExtensionAPI, options: { onCommit?: () => void }): void {
10
+ pi.registerTool({
11
+ name: TOOL_UPDATE,
12
+ label: "Todo Update",
13
+ description: "Patch existing todos by stable ID. Use todo_read first to obtain IDs. This never deletes items.",
14
+ promptSnippet: "Patch one or more existing todos by ID without replacing the full list",
15
+ parameters: TodoUpdateParams,
16
+ async execute(_toolCallId, params) {
17
+ return withStoreLock(() => {
18
+ const current = getTodos();
19
+ const result = validateTodoUpdate(params.updates, current);
20
+ if (!result.ok) return { content: [{ type: "text", text: `Error: ${result.error}` }], details: { todos: current, error: result.error } as TodoWriteDetails };
21
+ const todos = ensureTodoIds(result.todos, current);
22
+ if (!result.unchanged) {
23
+ try { pi.appendEntry(TODO_STATE_ENTRY_TYPE, { todos }); }
24
+ catch (e) {
25
+ if (/stale after session replacement/i.test(String(e))) {
26
+ return { content: [{ type: "text", text: "Error: session was replaced — state not committed. Please retry todo_update." }], details: { todos: current, error: "stale session replacement" } as TodoWriteDetails };
27
+ }
28
+ throw e;
29
+ }
30
+ }
31
+ setTodos(todos);
32
+ options.onCommit?.();
33
+ return { content: [{ type: "text", text: result.unchanged ? "No change" : `Updated ${params.updates.length} todo(s)` }], details: { todos, ...(result.unchanged ? { unchanged: true } : {}) } as TodoWriteDetails };
34
+ });
35
+ },
36
+ renderCall(args, theme) { return new Text(theme.fg("toolTitle", theme.bold("todo_update ")) + theme.fg("accent", `${Array.isArray(args.updates) ? args.updates.length : 0} patch(es)`), 0, 0); },
37
+ renderResult(result, _opts, theme) {
38
+ const details = result.details as TodoWriteDetails | undefined;
39
+ if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
40
+ return new Text(theme.fg("success", details?.unchanged ? "No change" : "✓ Saved"), 0, 0);
41
+ },
42
+ });
43
+ }
@@ -6,7 +6,7 @@ import { TodoWriteParams } from "../schema.js";
6
6
  import { getTodos, setTodos, withStoreLock } from "../store.js";
7
7
  import type { TodoWriteDetails } from "../types.js";
8
8
  import { TODO_STATE_ENTRY_TYPE, TOOL_WRITE } from "../types.js";
9
- import { countOpenTodos, validateTodoWrite } from "../validate.js";
9
+ import { countOpenTodos, ensureTodoIds, todosEqual, validateTodoWrite } from "../validate.js";
10
10
 
11
11
  export function registerTodoWriteTool(
12
12
  pi: ExtensionAPI,
@@ -33,12 +33,14 @@ export function registerTodoWriteTool(
33
33
  };
34
34
  }
35
35
 
36
- // 1. Persist durable state BEFORE updating in-memory store.
36
+ const todos = result.unchanged ? result.todos : ensureTodoIds(result.todos, current);
37
+ const unchanged = result.unchanged || todosEqual(todos, current);
38
+ // 1. Persist durable state BEFORE updating in-memory store.
37
39
  // If appendEntry fails (stale ctx, persistence error), we abort
38
40
  // so in-memory store never diverges from durable state.
39
- if (!result.unchanged) {
41
+ if (!unchanged) {
40
42
  try {
41
- pi.appendEntry(TODO_STATE_ENTRY_TYPE, { todos: result.todos });
43
+ pi.appendEntry(TODO_STATE_ENTRY_TYPE, { todos });
42
44
  } catch (e) {
43
45
  // Stale session: discard this write entirely — returning error
44
46
  // so the LLM knows the state was not committed.
@@ -59,20 +61,20 @@ export function registerTodoWriteTool(
59
61
  }
60
62
 
61
63
  // 2. Durable write succeeded (or no-op) — now update in-memory store.
62
- setTodos(result.todos);
64
+ setTodos(todos);
63
65
 
64
66
  options.onCommit?.();
65
67
 
66
- const open = countOpenTodos(result.todos);
67
- const summary = result.unchanged
68
+ const open = countOpenTodos(todos);
69
+ const summary = unchanged
68
70
  ? "No change"
69
- : `${open} open / ${result.todos.length} total`;
71
+ : `${open} open / ${todos.length} total`;
70
72
  const body =
71
- result.todos.length === 0 ? "Cleared todos" : formatTodoListText(result.todos, summary);
73
+ todos.length === 0 ? "Cleared todos" : formatTodoListText(todos, summary);
72
74
 
73
75
  const details: TodoWriteDetails = {
74
- todos: result.todos,
75
- ...(result.unchanged ? { unchanged: true } : {}),
76
+ todos,
77
+ ...(unchanged ? { unchanged: true } : {}),
76
78
  };
77
79
 
78
80
  return {
package/src/types.ts CHANGED
@@ -7,6 +7,8 @@ export type TodoPriority = (typeof TODO_PRIORITIES)[number];
7
7
  export const TERMINAL_STATUSES: ReadonlySet<TodoStatus> = new Set(["completed", "cancelled"]);
8
8
 
9
9
  export interface TodoItem {
10
+ /** Stable identity; absent only in legacy session entries until their next mutation. */
11
+ id?: string;
10
12
  content: string;
11
13
  status: TodoStatus;
12
14
  priority: TodoPriority;
@@ -22,6 +24,7 @@ export const TODO_STATE_ENTRY_TYPE = "pi-todo.state";
22
24
  export const TOOL_WRITE = "todo_write";
23
25
  export const TOOL_READ = "todo_read";
24
26
  export const TOOL_DIAGNOSE = "todo_diagnose";
27
+ export const TOOL_UPDATE = "todo_update";
25
28
  export const WIDGET_KEY = "pi-todo";
26
29
  export const MAX_OVERLAY_LINES = 12;
27
30
  /** Max characters per todo content after sanitize (context/tool safety). */
package/src/validate.ts CHANGED
@@ -1,105 +1,201 @@
1
- import { sanitizeTodoText } from "./sanitize.js";
2
- import type { TodoItem, TodoPriority, TodoStatus } from "./types.js";
3
- import { MAX_CONTENT_LENGTH, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
4
-
5
- export type ValidateOk = { ok: true; todos: TodoItem[]; unchanged: boolean };
6
- export type ValidateErr = { ok: false; error: string };
7
- export type ValidateResult = ValidateOk | ValidateErr;
8
-
9
- function isStatus(value: unknown): value is TodoStatus {
10
- return typeof value === "string" && (TODO_STATUSES as readonly string[]).includes(value);
11
- }
12
-
13
- function isPriority(value: unknown): value is TodoPriority {
14
- return typeof value === "string" && (TODO_PRIORITIES as readonly string[]).includes(value);
15
- }
16
-
17
- export function todosEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
18
- if (a.length !== b.length) return false;
19
- return a.every(
20
- (item, i) =>
21
- item.content === b[i].content && item.status === b[i].status && item.priority === b[i].priority,
22
- );
23
- }
24
-
25
- /**
26
- * Validate and normalize a full-replace payload.
27
- * Hard-enforces at most one `in_progress`. Does not mutate `current`.
28
- */
29
- export function validateTodoWrite(
30
- rawTodos: unknown,
31
- current: readonly TodoItem[],
32
- ): ValidateResult {
33
- if (!Array.isArray(rawTodos)) {
34
- return { ok: false, error: "todos must be an array" };
35
- }
36
-
37
- const todos: TodoItem[] = [];
38
- let inProgressCount = 0;
39
-
40
- for (let i = 0; i < rawTodos.length; i++) {
41
- const item = rawTodos[i];
42
- if (!item || typeof item !== "object") {
43
- return { ok: false, error: `todos[${i}] must be an object` };
44
- }
45
- const rec = item as Record<string, unknown>;
46
-
47
- if (typeof rec.content !== "string") {
48
- return { ok: false, error: `todos[${i}].content must be a string` };
49
- }
50
- let content = sanitizeTodoText(rec.content);
51
- if (!content) {
52
- return { ok: false, error: `todos[${i}].content must be non-empty` };
53
- }
54
- if (content.length > MAX_CONTENT_LENGTH) {
55
- content = `${content.slice(0, MAX_CONTENT_LENGTH - 1)}…`;
56
- }
57
-
58
- if (!isStatus(rec.status)) {
59
- return {
60
- ok: false,
61
- error: `todos[${i}].status must be one of: ${TODO_STATUSES.join(", ")}`,
62
- };
63
- }
64
- if (!isPriority(rec.priority)) {
65
- return {
66
- ok: false,
67
- error: `todos[${i}].priority must be one of: ${TODO_PRIORITIES.join(", ")}`,
68
- };
69
- }
70
-
71
- if (rec.status === "in_progress") inProgressCount += 1;
72
-
73
- todos.push({ content, status: rec.status, priority: rec.priority });
74
- }
75
-
76
- if (inProgressCount > 1) {
77
- return {
78
- ok: false,
79
- error: `exactly one in_progress allowed (got ${inProgressCount}); keep only the active task in_progress`,
80
- };
81
- }
82
-
83
- return { ok: true, todos, unchanged: todosEqual(todos, current) };
84
- }
85
-
86
- export function isTerminalStatus(status: TodoStatus): boolean {
87
- return status === "completed" || status === "cancelled";
88
- }
89
-
90
- export function isOpenTodo(todo: TodoItem): boolean {
91
- return !isTerminalStatus(todo.status);
92
- }
93
-
94
- export function hasOpenTodos(todos: readonly TodoItem[]): boolean {
95
- return todos.some(isOpenTodo);
96
- }
97
-
98
- export function countOpenTodos(todos: readonly TodoItem[]): number {
99
- return todos.filter(isOpenTodo).length;
100
- }
101
-
102
- /** Count of `in_progress` items (shown as "running" in the overlay heading). */
103
- export function countRunningTodos(todos: readonly TodoItem[]): number {
104
- return todos.filter((t) => t.status === "in_progress").length;
105
- }
1
+ import { sanitizeTodoText } from "./sanitize.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { TodoItem, TodoPriority, TodoStatus } from "./types.js";
4
+ import { MAX_CONTENT_LENGTH, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
5
+
6
+ export type ValidateOk = { ok: true; todos: TodoItem[]; unchanged: boolean };
7
+ export type ValidateErr = { ok: false; error: string };
8
+ export type ValidateResult = ValidateOk | ValidateErr;
9
+
10
+ function isStatus(value: unknown): value is TodoStatus {
11
+ return typeof value === "string" && (TODO_STATUSES as readonly string[]).includes(value);
12
+ }
13
+
14
+ function isPriority(value: unknown): value is TodoPriority {
15
+ return typeof value === "string" && (TODO_PRIORITIES as readonly string[]).includes(value);
16
+ }
17
+
18
+ export function todosEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
19
+ if (a.length !== b.length) return false;
20
+ return a.every(
21
+ (item, i) =>
22
+ item.id === b[i].id && item.content === b[i].content && item.status === b[i].status && item.priority === b[i].priority,
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Validate and normalize a full-replace payload.
28
+ * Hard-enforces at most one `in_progress`. Does not mutate `current`.
29
+ */
30
+ export function validateTodoWrite(
31
+ rawTodos: unknown,
32
+ current: readonly TodoItem[],
33
+ ): ValidateResult {
34
+ if (!Array.isArray(rawTodos)) {
35
+ return { ok: false, error: "todos must be an array" };
36
+ }
37
+
38
+ const todos: TodoItem[] = [];
39
+ let inProgressCount = 0;
40
+ const seenIds = new Set<string>();
41
+
42
+ for (let i = 0; i < rawTodos.length; i++) {
43
+ const item = rawTodos[i];
44
+ if (!item || typeof item !== "object") {
45
+ return { ok: false, error: `todos[${i}] must be an object` };
46
+ }
47
+ const rec = item as Record<string, unknown>;
48
+
49
+ if (typeof rec.content !== "string") {
50
+ return { ok: false, error: `todos[${i}].content must be a string` };
51
+ }
52
+ let content = sanitizeTodoText(rec.content);
53
+ if (!content) {
54
+ return { ok: false, error: `todos[${i}].content must be non-empty` };
55
+ }
56
+ if (content.length > MAX_CONTENT_LENGTH) {
57
+ content = `${content.slice(0, MAX_CONTENT_LENGTH - 1)}…`;
58
+ }
59
+
60
+ if (!isStatus(rec.status)) {
61
+ return {
62
+ ok: false,
63
+ error: `todos[${i}].status must be one of: ${TODO_STATUSES.join(", ")}`,
64
+ };
65
+ }
66
+ if (!isPriority(rec.priority)) {
67
+ return {
68
+ ok: false,
69
+ error: `todos[${i}].priority must be one of: ${TODO_PRIORITIES.join(", ")}`,
70
+ };
71
+ }
72
+
73
+ if (rec.status === "in_progress") inProgressCount += 1;
74
+
75
+ if (rec.id !== undefined) {
76
+ if (typeof rec.id !== "string" || !rec.id.trim()) {
77
+ return { ok: false, error: `todos[${i}].id must be a non-empty string when provided` };
78
+ }
79
+ if (seenIds.has(rec.id)) {
80
+ return { ok: false, error: `todos[${i}].id "${rec.id}" is duplicated` };
81
+ }
82
+ seenIds.add(rec.id);
83
+ }
84
+ todos.push({ ...(typeof rec.id === "string" ? { id: rec.id } : {}), content, status: rec.status, priority: rec.priority });
85
+ }
86
+
87
+ if (inProgressCount > 1) {
88
+ return {
89
+ ok: false,
90
+ error: `exactly one in_progress allowed (got ${inProgressCount}); keep only the active task in_progress`,
91
+ };
92
+ }
93
+
94
+ // Reject explicit IDs that don't exist in current list
95
+ // (caller must omit `id` for new items so the system auto-assigns).
96
+ const currentIds = new Set(current.map((t) => t.id).filter(Boolean));
97
+ for (let i = 0; i < todos.length; i++) {
98
+ if (todos[i].id && !currentIds.has(todos[i].id)) {
99
+ return {
100
+ ok: false,
101
+ error: `todos[${i}].id "${todos[i].id}" does not match any existing todo; omit id for new items`,
102
+ };
103
+ }
104
+ }
105
+
106
+ return { ok: true, todos, unchanged: todosEqual(todos, current) };
107
+ }
108
+
109
+ /** Assign IDs once at mutation time.
110
+ *
111
+ * - Items with an explicit `id` keep it (must already exist in `current`, verified
112
+ * by `validateTodoWrite`).
113
+ * - Items without `id` try to match a prior item: first by full tuple
114
+ * (content+status+priority), then by unique content. If ambiguous (multiple
115
+ * same-content items) or no prior match, a new UUID is generated.
116
+ */
117
+ export function ensureTodoIds(todos: readonly TodoItem[], current: readonly TodoItem[]): TodoItem[] {
118
+ const claimed = new Set<string>();
119
+
120
+ // Pre-index content uniqueness: content that appears only once in `current`
121
+ // is safe for content-only fallback.
122
+ const contentCounts = new Map<string, number>();
123
+ for (const c of current) {
124
+ contentCounts.set(c.content, (contentCounts.get(c.content) ?? 0) + 1);
125
+ }
126
+
127
+ return todos.map((todo) => {
128
+ let id = todo.id;
129
+ if (!id) {
130
+ // 1. Exact tuple match (content + status + priority)
131
+ const byTuple = current.find(
132
+ (c) => !!c.id && !claimed.has(c.id) &&
133
+ c.content === todo.content &&
134
+ c.status === todo.status &&
135
+ c.priority === todo.priority,
136
+ );
137
+ if (byTuple) {
138
+ id = byTuple.id;
139
+ } else {
140
+ // 2. Content-only fallback — only when content is unique in current
141
+ const count = contentCounts.get(todo.content) ?? 0;
142
+ if (count === 1) {
143
+ const byContent = current.find(
144
+ (c) => !!c.id && !claimed.has(c.id) && c.content === todo.content,
145
+ );
146
+ if (byContent) id = byContent.id;
147
+ }
148
+ }
149
+ // 3. No match or ambiguous → fresh ID
150
+ if (!id) id = randomUUID();
151
+ }
152
+ claimed.add(id);
153
+ return { ...todo, id };
154
+ });
155
+ }
156
+
157
+ export function validateTodoUpdate(rawUpdates: unknown, current: readonly TodoItem[]): ValidateResult {
158
+ if (!Array.isArray(rawUpdates) || rawUpdates.length === 0) {
159
+ return { ok: false, error: "updates must be a non-empty array" };
160
+ }
161
+ const next = current.map((todo) => ({ ...todo }));
162
+ const seen = new Set<string>();
163
+ for (let i = 0; i < rawUpdates.length; i++) {
164
+ const update = rawUpdates[i];
165
+ if (!update || typeof update !== "object") return { ok: false, error: `updates[${i}] must be an object` };
166
+ const rec = update as Record<string, unknown>;
167
+ if (typeof rec.id !== "string" || !rec.id) return { ok: false, error: `updates[${i}].id must be a non-empty string` };
168
+ if (seen.has(rec.id)) return { ok: false, error: `updates[${i}].id is duplicated` };
169
+ seen.add(rec.id);
170
+ const target = next.find((todo) => todo.id === rec.id);
171
+ if (!target) return { ok: false, error: `updates[${i}].id does not match an existing todo` };
172
+ if (rec.content === undefined && rec.status === undefined && rec.priority === undefined) {
173
+ return { ok: false, error: `updates[${i}] must change content, status, or priority` };
174
+ }
175
+ if (rec.content !== undefined) target.content = rec.content as string;
176
+ if (rec.status !== undefined) target.status = rec.status as TodoStatus;
177
+ if (rec.priority !== undefined) target.priority = rec.priority as TodoPriority;
178
+ }
179
+ return validateTodoWrite(next, current);
180
+ }
181
+
182
+ export function isTerminalStatus(status: TodoStatus): boolean {
183
+ return status === "completed" || status === "cancelled";
184
+ }
185
+
186
+ export function isOpenTodo(todo: TodoItem): boolean {
187
+ return !isTerminalStatus(todo.status);
188
+ }
189
+
190
+ export function hasOpenTodos(todos: readonly TodoItem[]): boolean {
191
+ return todos.some(isOpenTodo);
192
+ }
193
+
194
+ export function countOpenTodos(todos: readonly TodoItem[]): number {
195
+ return todos.filter(isOpenTodo).length;
196
+ }
197
+
198
+ /** Count of `in_progress` items (shown as "running" in the overlay heading). */
199
+ export function countRunningTodos(todos: readonly TodoItem[]): number {
200
+ return todos.filter((t) => t.status === "in_progress").length;
201
+ }