@maxiaochao/pi-toolkit 0.10.2 → 0.10.4

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 (34) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -2
  4. package/packages/herdr-subagents/CHANGELOG.md +15 -0
  5. package/packages/herdr-subagents/README.md +9 -6
  6. package/packages/herdr-subagents/TESTING.md +6 -4
  7. package/packages/herdr-subagents/docs/demo.webm +0 -0
  8. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  9. package/packages/herdr-subagents/extensions/herdr-subagents.ts +40 -6
  10. package/packages/herdr-subagents/package.json +1 -1
  11. package/packages/herdr-subagents/src/batch-runner.ts +7 -7
  12. package/packages/herdr-subagents/src/blocking.ts +4 -4
  13. package/packages/herdr-subagents/src/dispatcher.ts +8 -0
  14. package/packages/herdr-subagents/src/fleet-controller.ts +167 -16
  15. package/packages/herdr-subagents/src/fleet.ts +119 -8
  16. package/packages/herdr-subagents/src/history.ts +11 -5
  17. package/packages/herdr-subagents/src/parameters.ts +2 -2
  18. package/packages/herdr-subagents/src/reconcile.ts +2 -1
  19. package/packages/herdr-subagents/src/records.ts +53 -1
  20. package/packages/herdr-subagents/src/tab-cleanup.ts +18 -2
  21. package/packages/herdr-subagents/src/tool.ts +30 -3
  22. package/packages/herdr-subagents/src/validation.ts +2 -1
  23. package/packages/herdr-subagents/tests/extension-contract.ts +16 -2
  24. package/packages/herdr-subagents/tests/run-tests.mjs +169 -8
  25. package/packages/todo/CHANGELOG.md +4 -0
  26. package/packages/todo/README.md +19 -9
  27. package/packages/todo/TESTING.md +9 -7
  28. package/packages/todo/docs/demo.gif +0 -0
  29. package/packages/todo/docs/screenshot.png +0 -0
  30. package/packages/todo/extensions/todo.ts +120 -90
  31. package/packages/todo/package.json +2 -2
  32. package/packages/todo/src/todo-state.ts +48 -49
  33. package/packages/todo/tests/todo-extension.mjs +134 -0
  34. package/packages/todo/tests/todo-state.mjs +80 -70
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Session-scoped todo extension with branch-aware persistence.
2
+ * Branch-local execution-plan extension with tool-result snapshot persistence.
3
3
  *
4
- * Provides the `todo` tool, a compact active-work widget, and `/todos` current-plan view.
4
+ * The model sees only the current plan. The widget mirrors that plan, while
5
+ * `/todos` shows the complete history for the active conversation branch.
5
6
  */
6
7
 
7
8
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -12,11 +13,13 @@ import {
12
13
  activeTodoRows,
13
14
  applyTodoAction,
14
15
  countTodoStatuses,
16
+ formatCurrentPlan,
15
17
  normalizeTodo,
16
18
  restoreTodoState,
17
19
  type StoredTodo,
18
20
  type Todo,
19
21
  type TodoAction,
22
+ type TodoActionInput,
20
23
  type TodoState,
21
24
  type TodoStatus,
22
25
  } from "../src/todo-state.ts";
@@ -29,17 +32,16 @@ interface TodoDetails {
29
32
  }
30
33
 
31
34
  const TodoParams = Type.Object({
32
- action: StringEnum(["list", "replace", "add", "update", "remove", "clear"] as const, {
33
- description: "list current tasks; replace the plan; add one; update status; remove one; or clear all",
35
+ action: StringEnum(["plan", "add_step", "complete_current", "cancel_current", "list"] as const, {
36
+ description: "plan creates or replaces the current plan; add_step appends; complete_current or cancel_current advances one expected current step; list reads the current plan",
34
37
  }),
35
38
  items: Type.Optional(Type.Array(Type.String(), {
36
- minItems: 1,
37
- description: "Complete ordered task texts for replace",
39
+ description: "Ordered executable steps for plan; an empty array cancels all unfinished work",
38
40
  })),
39
- text: Type.Optional(Type.String({ description: "Task text for add" })),
40
- id: Type.Optional(Type.Integer({ minimum: 1, description: "Task ID for update or remove" })),
41
- status: Type.Optional(StringEnum(["todo", "doing", "done", "cancelled"] as const, {
42
- description: "New status for update; optional initial status for add",
41
+ text: Type.Optional(Type.String({ description: "Step text for add_step" })),
42
+ expected_current_id: Type.Optional(Type.Integer({
43
+ minimum: 1,
44
+ description: "Stable ID of the sole current doing step; required by complete_current and cancel_current",
43
45
  })),
44
46
  });
45
47
 
@@ -63,16 +65,17 @@ function themedStatus(theme: Theme, status: TodoStatus, text: string): string {
63
65
  return theme.fg("dim", text);
64
66
  }
65
67
 
66
- function taskSummary(todos: Todo[]): string {
68
+ function historySummary(todos: Todo[]): string {
67
69
  const counts = countTodoStatuses(todos);
68
- const active = counts.todo + counts.doing;
69
- const parts = [`${active} active (${counts.doing} doing, ${counts.todo} queued)`];
70
- if (counts.done > 0) parts.push(`${counts.done} done`);
71
- if (counts.cancelled > 0) parts.push(`${counts.cancelled} cancelled`);
72
- return parts.join(" · ");
70
+ return `${todos.length} steps · ${counts.done} done · ${counts.cancelled} cancelled · ${counts.doing + counts.todo} current`;
73
71
  }
74
72
 
75
- function renderTodoLine(theme: Theme, todo: Todo): string {
73
+ function currentSummary(todos: Todo[]): string {
74
+ const rows = activeTodoRows(todos);
75
+ return `${rows.length} current step${rows.length === 1 ? "" : "s"}`;
76
+ }
77
+
78
+ function renderHistoryLine(theme: Theme, todo: Todo): string {
76
79
  const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
77
80
  if (todo.status === "done" || todo.status === "cancelled") {
78
81
  return `${icon} \x1b[9m${theme.fg("dim", `#${todo.id} ${todo.text}`)}\x1b[29m`;
@@ -80,20 +83,52 @@ function renderTodoLine(theme: Theme, todo: Todo): string {
80
83
  return `${icon} ${theme.fg("accent", `#${todo.id}`)} ${theme.fg("text", todo.text)}`;
81
84
  }
82
85
 
83
- function renderActiveTodoLine(theme: Theme, todo: Todo, position: number): string {
86
+ function renderCurrentLine(theme: Theme, todo: Todo, position: number): string {
84
87
  const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
85
88
  return `${icon} ${theme.fg("accent", `${position}.`)} ${theme.fg("text", todo.text)}`;
86
89
  }
87
90
 
88
- class TodoListComponent {
91
+ export class CurrentPlanWidget {
89
92
  private cachedWidth?: number;
90
93
  private cachedLines?: string[];
94
+ private readonly getTodos: () => Todo[];
95
+ private readonly theme: Theme;
96
+
97
+ constructor(getTodos: () => Todo[], theme: Theme) {
98
+ this.getTodos = getTodos;
99
+ this.theme = theme;
100
+ }
101
+
102
+ render(width: number): string[] {
103
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
104
+ const todos = this.getTodos();
105
+ const rows = activeTodoRows(todos);
106
+ this.cachedLines = [
107
+ truncateToWidth(`${this.theme.fg("accent", "●")} ${this.theme.fg("muted", currentSummary(todos))}`, width),
108
+ ...rows.map(({ position, todo }) => truncateToWidth(` ${renderCurrentLine(this.theme, todo, position)}`, width)),
109
+ ];
110
+ this.cachedWidth = width;
111
+ return this.cachedLines;
112
+ }
91
113
 
92
- constructor(
93
- private readonly todos: Todo[],
94
- private readonly theme: Theme,
95
- private readonly onClose: () => void,
96
- ) {}
114
+ invalidate(): void {
115
+ this.cachedWidth = undefined;
116
+ this.cachedLines = undefined;
117
+ }
118
+ }
119
+
120
+ class TodoListComponent {
121
+ private cachedWidth?: number;
122
+ private cachedLines?: string[];
123
+ private readonly todos: Todo[];
124
+ private readonly theme: Theme;
125
+ private readonly onClose: () => void;
126
+
127
+ constructor(todos: Todo[], theme: Theme, onClose: () => void) {
128
+ this.todos = todos;
129
+ this.theme = theme;
130
+ this.onClose = onClose;
131
+ }
97
132
 
98
133
  handleInput(data: string): void {
99
134
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose();
@@ -104,20 +139,12 @@ class TodoListComponent {
104
139
 
105
140
  const border = this.theme.fg("borderMuted", "-".repeat(Math.max(0, width - 10)));
106
141
  const lines = ["", truncateToWidth(`${this.theme.fg("borderMuted", "---")}${this.theme.fg("accent", " Todos ")}${border}`, width), ""];
107
-
108
142
  if (this.todos.length === 0) {
109
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No todos")}`, width));
143
+ lines.push(truncateToWidth(` ${this.theme.fg("dim", "No todo history on this branch")}`, width));
110
144
  } else {
111
- lines.push(truncateToWidth(
112
- `${this.theme.fg("accent", "●")} ${this.theme.fg("muted", taskSummary(this.todos))}`,
113
- width,
114
- ));
115
- lines.push("");
116
- for (const todo of sortedTodos(this.todos)) {
117
- lines.push(truncateToWidth(` ${renderTodoLine(this.theme, todo)}`, width));
118
- }
145
+ lines.push(truncateToWidth(`${this.theme.fg("accent", "●")} ${this.theme.fg("muted", historySummary(this.todos))}`, width), "");
146
+ for (const todo of sortedTodos(this.todos)) lines.push(truncateToWidth(` ${renderHistoryLine(this.theme, todo)}`, width));
119
147
  }
120
-
121
148
  lines.push("", truncateToWidth(` ${this.theme.fg("dim", "Press Escape to close")}`, width), "");
122
149
  this.cachedWidth = width;
123
150
  this.cachedLines = lines;
@@ -130,33 +157,45 @@ class TodoListComponent {
130
157
  }
131
158
  }
132
159
 
160
+ interface MountedWidget {
161
+ component: CurrentPlanWidget;
162
+ tui: { requestRender(): void };
163
+ }
164
+
133
165
  export default function (pi: ExtensionAPI) {
134
166
  if (process.env.PI_HERDR_SUBAGENTS_CHILD === "1") return;
135
167
 
136
168
  let state: TodoState = { todos: [], nextId: 1 };
137
-
169
+ let mountedWidget: MountedWidget | undefined;
170
+ let actionQueue = Promise.resolve();
138
171
  const snapshot = (): StoredTodo[] => state.todos.map((todo) => ({ ...todo }));
139
172
 
140
173
  const refreshUI = (ctx: ExtensionContext) => {
141
174
  if (!ctx.hasUI) return;
142
- const visible = activeTodoRows(state.todos);
143
- if (visible.length === 0) {
144
- ctx.ui.setWidget(WIDGET_KEY, undefined);
175
+ const hasCurrentPlan = activeTodoRows(state.todos).length > 0;
176
+ if (!hasCurrentPlan) {
177
+ if (mountedWidget) {
178
+ mountedWidget.component.invalidate();
179
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
180
+ mountedWidget.tui.requestRender();
181
+ mountedWidget = undefined;
182
+ }
145
183
  return;
146
184
  }
147
185
 
148
- ctx.ui.setWidget(
149
- WIDGET_KEY,
150
- (_tui, theme) => ({
151
- render(width: number): string[] {
152
- return [
153
- truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(state.todos))}`, width),
154
- ...visible.map(({ position, todo }) => truncateToWidth(` ${renderActiveTodoLine(theme, todo, position)}`, width)),
155
- ];
156
- },
157
- invalidate() {},
158
- }),
159
- );
186
+ if (mountedWidget) {
187
+ mountedWidget.component.invalidate();
188
+ mountedWidget.tui.requestRender();
189
+ return;
190
+ }
191
+
192
+ ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
193
+ const component = new CurrentPlanWidget(() => state.todos, theme);
194
+ mountedWidget = { component, tui };
195
+ component.invalidate();
196
+ tui.requestRender();
197
+ return component;
198
+ });
160
199
  };
161
200
 
162
201
  const reconstructState = (ctx: ExtensionContext) => {
@@ -176,73 +215,64 @@ export default function (pi: ExtensionAPI) {
176
215
  pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
177
216
  pi.on("session_shutdown", async (_event, ctx) => {
178
217
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
218
+ mountedWidget = undefined;
179
219
  });
180
220
 
181
221
  pi.registerTool({
182
222
  name: "todo",
183
223
  label: "Todo",
184
- description: "Track explicit checklists and non-trivial multi-step work. Replace the plan when instructions change. The tool keeps one task doing and advances automatically; mark obsolete work cancelled and verified work done.",
224
+ description: "Maintain a branch-local, user-visible CURRENT execution plan. Use plan only for explicit multi-step work, genuinely multi-stage work, or cross-model handoff—not simple one-step tasks. Preserve user intent and order. Complete only work actually done; blocked-but-needed work stays current. cancel_current means the current step is no longer needed while later steps remain valid; changed direction uses plan. Each completion/cancellation must name expected_current_id and advances exactly one step.",
185
225
  parameters: TodoParams,
226
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
186
227
 
187
228
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
188
- const result = applyTodoAction(state, params);
189
- state = { todos: result.todos, nextId: result.nextId };
190
- const text = params.action === "list"
191
- ? state.todos.length
192
- ? sortedTodos(state.todos).map((todo) => `${statusMarker(todo.status)} #${todo.id} [${todo.status}] ${todo.text}`).join("\n")
193
- : "No todos"
194
- : result.message;
195
-
196
- refreshUI(ctx);
197
- return {
198
- content: [{ type: "text" as const, text }],
199
- details: {
200
- action: params.action,
201
- todos: snapshot(),
202
- nextId: state.nextId,
203
- ...(result.error ? { error: result.error } : {}),
204
- } as TodoDetails,
205
- };
229
+ const run = actionQueue.then(() => {
230
+ const result = applyTodoAction(state, params as TodoActionInput);
231
+ state = { todos: result.todos, nextId: result.nextId };
232
+ refreshUI(ctx);
233
+ return {
234
+ content: [{
235
+ type: "text" as const,
236
+ text: result.error ? `Error: ${result.error}\n\n${formatCurrentPlan(state.todos)}` : formatCurrentPlan(state.todos),
237
+ }],
238
+ details: {
239
+ action: params.action,
240
+ todos: snapshot(),
241
+ nextId: state.nextId,
242
+ ...(result.error ? { error: result.error } : {}),
243
+ } as TodoDetails,
244
+ ...(result.error ? { isError: true } : {}),
245
+ };
246
+ });
247
+ actionQueue = run.then(() => undefined, () => undefined);
248
+ return run;
206
249
  },
207
250
 
208
251
  renderCall(args, theme) {
209
252
  let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
210
- if (args.items) text += ` ${theme.fg("dim", `${args.items.length} tasks`)}`;
253
+ if (args.items) text += ` ${theme.fg("dim", `${args.items.length} steps`)}`;
211
254
  if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
212
- if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
213
- if (args.status) text += ` ${themedStatus(theme, args.status, args.status)}`;
255
+ if (args.expected_current_id !== undefined) text += ` ${theme.fg("accent", `#${args.expected_current_id}`)}`;
214
256
  return new Text(text, 0, 0);
215
257
  },
216
258
 
217
259
  renderResult(result, _options, theme) {
218
- const details = result.details as TodoDetails | undefined;
219
- if (!details) {
220
- const item = result.content[0];
221
- return new Text(item?.type === "text" ? item.text : "", 0, 0);
222
- }
223
- if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
224
-
225
- if (details.action === "list") {
226
- const list = details.todos.map(normalizeTodo);
227
- if (list.length === 0) return new Text(theme.fg("dim", "No todos"), 0, 0);
228
- return new Text(sortedTodos(list).map((todo) => renderTodoLine(theme, todo)).join("\n"), 0, 0);
229
- }
230
-
231
260
  const item = result.content[0];
232
- const message = item?.type === "text" ? item.text : "";
233
- return new Text(theme.fg("success", "> ") + theme.fg("muted", message), 0, 0);
261
+ const text = item?.type === "text" ? item.text : "";
262
+ const details = result.details as TodoDetails | undefined;
263
+ return new Text(details?.error ? theme.fg("error", text) : theme.fg("muted", text), 0, 0);
234
264
  },
235
265
  });
236
266
 
237
267
  pi.registerCommand("todos", {
238
- description: "Show active and closed tasks in the current plan",
268
+ description: "Show full Todo history on the current branch",
239
269
  handler: async (_args, ctx) => {
240
270
  if (ctx.mode !== "tui") {
241
271
  ctx.ui.notify("/todos requires interactive mode", "error");
242
272
  return;
243
273
  }
244
274
  await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
245
- new TodoListComponent(state.todos, theme, () => done()),
275
+ new TodoListComponent(snapshot().map(normalizeTodo), theme, () => done()),
246
276
  );
247
277
  },
248
278
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-todo",
3
3
  "version": "0.1.0",
4
- "description": "Session checklist and todo lifecycle extension for Pi.",
4
+ "description": "Branch-local execution-plan lifecycle and history extension for Pi.",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "pi-extension", "todo"],
7
7
  "files": ["extensions", "src", "docs", "README.md", "TESTING.md", "CHANGELOG.md"],
@@ -18,7 +18,7 @@
18
18
  "typebox": "*"
19
19
  },
20
20
  "scripts": {
21
- "test": "node --experimental-strip-types tests/todo-state.mjs",
21
+ "test": "node --experimental-strip-types tests/todo-state.mjs && node --experimental-strip-types tests/todo-extension.mjs",
22
22
  "load-test": "pi -ne -e ./extensions/todo.ts"
23
23
  },
24
24
  "publishConfig": { "access": "public" }
@@ -1,5 +1,5 @@
1
1
  export type TodoStatus = "todo" | "doing" | "done" | "cancelled";
2
- export type TodoAction = "list" | "replace" | "add" | "update" | "remove" | "clear";
2
+ export type TodoAction = "plan" | "add_step" | "complete_current" | "cancel_current" | "list";
3
3
 
4
4
  export interface Todo {
5
5
  id: number;
@@ -26,10 +26,9 @@ export interface ActiveTodoRow {
26
26
 
27
27
  export interface TodoActionInput {
28
28
  action: TodoAction;
29
- text?: string;
30
29
  items?: string[];
31
- id?: number;
32
- status?: TodoStatus;
30
+ text?: string;
31
+ expected_current_id?: number;
33
32
  }
34
33
 
35
34
  export interface TodoActionResult extends TodoState {
@@ -75,13 +74,26 @@ export function countTodoStatuses(todos: Todo[]): TodoCounts {
75
74
  }
76
75
 
77
76
  export function activeTodos(todos: Todo[]): Todo[] {
78
- return todos.filter((todo) => todo.status === "doing" || todo.status === "todo");
77
+ return [
78
+ ...todos.filter((todo) => todo.status === "doing"),
79
+ ...todos.filter((todo) => todo.status === "todo"),
80
+ ];
79
81
  }
80
82
 
81
83
  export function activeTodoRows(todos: Todo[]): ActiveTodoRow[] {
82
84
  return activeTodos(todos).map((todo, index) => ({ position: index + 1, todo }));
83
85
  }
84
86
 
87
+ export function currentTodo(todos: Todo[]): Todo | undefined {
88
+ return todos.find((todo) => todo.status === "doing");
89
+ }
90
+
91
+ export function formatCurrentPlan(todos: Todo[]): string {
92
+ const current = activeTodos(todos);
93
+ if (current.length === 0) return "CURRENT plan: none";
94
+ return ["CURRENT plan", ...current.map((todo) => `#${todo.id} [${todo.status}] ${todo.text}`)].join("\n");
95
+ }
96
+
85
97
  function cloneState(state: TodoState): TodoState {
86
98
  return { todos: state.todos.map((todo) => ({ ...todo })), nextId: state.nextId };
87
99
  }
@@ -95,8 +107,18 @@ function cleanText(text: string | undefined): string | undefined {
95
107
  return cleaned ? cleaned : undefined;
96
108
  }
97
109
 
98
- function hasActiveDuplicate(todos: Todo[], text: string): boolean {
99
- return todos.some((todo) => (todo.status === "todo" || todo.status === "doing") && todo.text === text);
110
+ function closeCurrentAndAdvance(next: TodoState, expectedId: number | undefined, status: "done" | "cancelled"): TodoActionResult {
111
+ const current = next.todos.filter((todo) => todo.status === "doing");
112
+ if (expectedId === undefined) return fail(next, "expected_current_id is required");
113
+ if (current.length !== 1 || current[0].id !== expectedId) {
114
+ const actual = current.length === 1 ? `#${current[0].id}` : "none";
115
+ return fail(next, `expected current #${expectedId}, but current is ${actual}`);
116
+ }
117
+
118
+ current[0].status = status;
119
+ const following = next.todos.find((todo) => todo.status === "todo");
120
+ if (following) following.status = "doing";
121
+ return { ...next, message: `${status === "done" ? "Completed" : "Cancelled"} current step #${expectedId}` };
100
122
  }
101
123
 
102
124
  export function applyTodoAction(state: TodoState, input: TodoActionInput): TodoActionResult {
@@ -104,57 +126,34 @@ export function applyTodoAction(state: TodoState, input: TodoActionInput): TodoA
104
126
 
105
127
  switch (input.action) {
106
128
  case "list":
107
- return { ...next, message: next.todos.length ? "Listed todos" : "No todos" };
129
+ return { ...next, message: "Listed current plan" };
108
130
 
109
- case "replace": {
110
- if (!input.items?.length) return fail(state, "items required for replace");
131
+ case "plan": {
132
+ if (!input.items) return fail(state, "items is required for plan");
111
133
  const items = input.items.map(cleanText);
112
- if (items.some((item) => item === undefined)) return fail(state, "replace items must not be empty");
134
+ if (items.some((item) => item === undefined)) return fail(state, "plan items must not be empty");
135
+ for (const todo of next.todos) {
136
+ if (todo.status === "doing" || todo.status === "todo") todo.status = "cancelled";
137
+ }
113
138
  const texts = items as string[];
114
- if (new Set(texts).size !== texts.length) return fail(state, "replace items must be unique");
115
- const firstId = next.nextId;
116
- next.todos = texts.map((text, index) => ({ id: firstId + index, text, status: index === 0 ? "doing" : "todo" }));
117
- next.nextId += next.todos.length;
118
- return { ...next, message: `Replaced plan with ${next.todos.length} tasks` };
139
+ for (const [index, text] of texts.entries()) {
140
+ next.todos.push({ id: next.nextId++, text, status: index === 0 ? "doing" : "todo" });
141
+ }
142
+ return { ...next, message: texts.length === 0 ? "Cancelled all unfinished work" : `Planned ${texts.length} steps` };
119
143
  }
120
144
 
121
- case "add": {
145
+ case "add_step": {
122
146
  const text = cleanText(input.text);
123
- if (!text) return fail(state, "text required for add");
124
- if (hasActiveDuplicate(next.todos, text)) return fail(state, `active task already exists: ${text}`);
125
- if (input.status === "doing") {
126
- for (const todo of next.todos) if (todo.status === "doing") todo.status = "todo";
127
- }
128
- const todo: Todo = { id: next.nextId++, text, status: input.status ?? "todo" };
147
+ if (!text) return fail(state, "text is required for add_step");
148
+ const todo: Todo = { id: next.nextId++, text, status: currentTodo(next.todos) ? "todo" : "doing" };
129
149
  next.todos.push(todo);
130
- normalizeTodoFlow(next.todos);
131
- return { ...next, message: `Added todo #${todo.id} [${todo.status}]: ${todo.text}` };
132
- }
133
-
134
- case "update": {
135
- if (input.id === undefined || input.status === undefined) return fail(state, "id and status required for update");
136
- const todo = next.todos.find((item) => item.id === input.id);
137
- if (!todo) return fail(state, `#${input.id} not found`);
138
- if (input.status === "doing") {
139
- for (const item of next.todos) if (item.status === "doing") item.status = "todo";
140
- }
141
- todo.status = input.status;
142
- normalizeTodoFlow(next.todos);
143
- return { ...next, message: `Todo #${todo.id} is now ${todo.status}` };
150
+ return { ...next, message: `Added step #${todo.id}` };
144
151
  }
145
152
 
146
- case "remove": {
147
- if (input.id === undefined) return fail(state, "id required for remove");
148
- const index = next.todos.findIndex((item) => item.id === input.id);
149
- if (index < 0) return fail(state, `#${input.id} not found`);
150
- const [removed] = next.todos.splice(index, 1);
151
- normalizeTodoFlow(next.todos);
152
- return { ...next, message: `Removed todo #${removed.id}: ${removed.text}` };
153
- }
153
+ case "complete_current":
154
+ return closeCurrentAndAdvance(next, input.expected_current_id, "done");
154
155
 
155
- case "clear": {
156
- const count = next.todos.length;
157
- return { todos: [], nextId: 1, message: `Cleared ${count} todos` };
158
- }
156
+ case "cancel_current":
157
+ return closeCurrentAndAdvance(next, input.expected_current_id, "cancelled");
159
158
  }
160
159
  }
@@ -0,0 +1,134 @@
1
+ import assert from "node:assert/strict";
2
+ import registerTodo from "../extensions/todo.ts";
3
+
4
+ const theme = {
5
+ fg(_color, text) { return text; },
6
+ bold(text) { return text; },
7
+ };
8
+
9
+ function createHarness(branch = [], hasUI = true) {
10
+ const handlers = new Map();
11
+ const commands = new Map();
12
+ let tool;
13
+ let currentBranch = branch;
14
+ let widget;
15
+ let historyComponent;
16
+ let renderRequests = 0;
17
+ const tui = { requestRender() { renderRequests += 1; } };
18
+ const ui = {
19
+ setWidget(_key, value) {
20
+ if (value === undefined) {
21
+ widget = undefined;
22
+ return;
23
+ }
24
+ widget = value(tui, theme);
25
+ },
26
+ notify() {},
27
+ async custom(factory) {
28
+ historyComponent = factory(tui, theme, {}, () => {});
29
+ },
30
+ };
31
+ const pi = {
32
+ on(name, handler) { handlers.set(name, handler); },
33
+ registerTool(definition) { tool = definition; },
34
+ registerCommand(name, definition) { commands.set(name, definition); },
35
+ };
36
+ const childGuard = process.env.PI_HERDR_SUBAGENTS_CHILD;
37
+ delete process.env.PI_HERDR_SUBAGENTS_CHILD;
38
+ try {
39
+ registerTodo(pi);
40
+ } finally {
41
+ if (childGuard === undefined) delete process.env.PI_HERDR_SUBAGENTS_CHILD;
42
+ else process.env.PI_HERDR_SUBAGENTS_CHILD = childGuard;
43
+ }
44
+ const context = {
45
+ hasUI,
46
+ mode: hasUI ? "tui" : "print",
47
+ ui,
48
+ sessionManager: { getBranch: () => currentBranch },
49
+ };
50
+ return {
51
+ handlers,
52
+ commands,
53
+ context,
54
+ get tool() { return tool; },
55
+ get widget() { return widget; },
56
+ get historyComponent() { return historyComponent; },
57
+ get renderRequests() { return renderRequests; },
58
+ setBranch(value) { currentBranch = value; },
59
+ };
60
+ }
61
+
62
+ function toolResult(todos, nextId) {
63
+ return {
64
+ type: "message",
65
+ message: {
66
+ role: "toolResult",
67
+ toolName: "todo",
68
+ details: { action: "list", todos, nextId },
69
+ },
70
+ };
71
+ }
72
+
73
+ const branch = [toolResult([
74
+ { id: 1, text: "Closed history", status: "done" },
75
+ { id: 2, text: "Current work", status: "doing" },
76
+ { id: 3, text: "Later work", status: "todo" },
77
+ ], 4)];
78
+ const harness = createHarness(branch);
79
+ await harness.handlers.get("session_start")({}, harness.context);
80
+ assert.ok(harness.tool, "Todo tool is registered");
81
+ assert.deepEqual(harness.tool.parameters.properties.action.enum, ["plan", "add_step", "complete_current", "cancel_current", "list"]);
82
+ assert.equal(harness.renderRequests, 1, "mounting the widget explicitly requests a TUI render");
83
+ const mountedWidget = harness.widget;
84
+ const initialWidget = mountedWidget.render(100).join("\n");
85
+ assert.match(initialWidget, /1\. Current work/);
86
+ assert.match(initialWidget, /2\. Later work/);
87
+ assert.doesNotMatch(initialWidget, /#2|#3|Closed history|done|cancelled/, "widget hides IDs and closed history");
88
+
89
+ const completed = await harness.tool.execute("complete", {
90
+ action: "complete_current",
91
+ expected_current_id: 2,
92
+ }, undefined, undefined, harness.context);
93
+ assert.equal(harness.widget, mountedWidget, "live updates keep the mounted widget component");
94
+ assert.ok(harness.renderRequests >= 2, "state change invalidates the widget and requests another render");
95
+ const updatedWidget = mountedWidget.render(100).join("\n");
96
+ assert.match(updatedWidget, /1\. Later work/);
97
+ assert.doesNotMatch(updatedWidget, /Current work/, "mounted widget reads current state instead of a captured row snapshot");
98
+ assert.equal(completed.content[0].text, "CURRENT plan\n#3 [doing] Later work");
99
+
100
+ await harness.commands.get("todos").handler("", harness.context);
101
+ const history = harness.historyComponent.render(100).join("\n");
102
+ assert.match(history, /#1 Closed history/);
103
+ assert.match(history, /#2 Current work/);
104
+ assert.match(history, /#3 Later work/, "/todos alone exposes complete branch history");
105
+
106
+ await harness.tool.execute("empty", { action: "plan", items: [] }, undefined, undefined, harness.context);
107
+ assert.equal(harness.widget, undefined, "widget disappears when the current plan is done or cancelled");
108
+ assert.ok(harness.renderRequests >= 3, "unmounting the widget requests a render");
109
+
110
+ harness.setBranch([toolResult([
111
+ { id: 10, text: "Selected branch only", status: "doing" },
112
+ { id: 11, text: "Selected branch next", status: "todo" },
113
+ ], 12)]);
114
+ await harness.handlers.get("session_tree")({}, harness.context);
115
+ const branchList = await harness.tool.execute("branch-list", { action: "list" }, undefined, undefined, harness.context);
116
+ assert.equal(branchList.content[0].text, "CURRENT plan\n#10 [doing] Selected branch only\n#11 [todo] Selected branch next");
117
+ assert.doesNotMatch(branchList.content[0].text, /Closed history|Later work/, "tree navigation reconstructs only getBranch snapshots");
118
+
119
+ const serialized = createHarness([], false);
120
+ await serialized.handlers.get("session_start")({}, serialized.context);
121
+ const [planned, firstCompletion, repeatedCompletion] = await Promise.all([
122
+ serialized.tool.execute("plan", { action: "plan", items: ["First", "Second", "Third"] }, undefined, undefined, serialized.context),
123
+ serialized.tool.execute("complete-one", { action: "complete_current", expected_current_id: 1 }, undefined, undefined, serialized.context),
124
+ serialized.tool.execute("complete-repeat", { action: "complete_current", expected_current_id: 1 }, undefined, undefined, serialized.context),
125
+ ]);
126
+ assert.equal(planned.content[0].text, "CURRENT plan\n#1 [doing] First\n#2 [todo] Second\n#3 [todo] Third");
127
+ assert.equal(firstCompletion.content[0].text, "CURRENT plan\n#2 [doing] Second\n#3 [todo] Third");
128
+ assert.equal(repeatedCompletion.isError, true, "same-turn repeated completion fails after the first queued call advances");
129
+ assert.match(repeatedCompletion.content[0].text, /expected current #1, but current is #2/);
130
+ assert.match(repeatedCompletion.content[0].text, /#2 \[doing\] Second/);
131
+ const afterConcurrentCalls = await serialized.tool.execute("list", { action: "list" }, undefined, undefined, serialized.context);
132
+ assert.equal(afterConcurrentCalls.content[0].text, "CURRENT plan\n#2 [doing] Second\n#3 [todo] Third");
133
+
134
+ console.log("Todo extension integration tests passed");