@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.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/packages/herdr-subagents/CHANGELOG.md +15 -0
- package/packages/herdr-subagents/README.md +9 -6
- package/packages/herdr-subagents/TESTING.md +6 -4
- package/packages/herdr-subagents/docs/demo.webm +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +40 -6
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +7 -7
- package/packages/herdr-subagents/src/blocking.ts +4 -4
- package/packages/herdr-subagents/src/dispatcher.ts +8 -0
- package/packages/herdr-subagents/src/fleet-controller.ts +167 -16
- package/packages/herdr-subagents/src/fleet.ts +119 -8
- package/packages/herdr-subagents/src/history.ts +11 -5
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/reconcile.ts +2 -1
- package/packages/herdr-subagents/src/records.ts +53 -1
- package/packages/herdr-subagents/src/tab-cleanup.ts +18 -2
- package/packages/herdr-subagents/src/tool.ts +30 -3
- package/packages/herdr-subagents/src/validation.ts +2 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +16 -2
- package/packages/herdr-subagents/tests/run-tests.mjs +169 -8
- package/packages/todo/CHANGELOG.md +4 -0
- package/packages/todo/README.md +19 -9
- package/packages/todo/TESTING.md +9 -7
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/packages/todo/extensions/todo.ts +120 -90
- package/packages/todo/package.json +2 -2
- package/packages/todo/src/todo-state.ts +48 -49
- package/packages/todo/tests/todo-extension.mjs +134 -0
- package/packages/todo/tests/todo-state.mjs +80 -70
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Branch-local execution-plan extension with tool-result snapshot persistence.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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(["
|
|
33
|
-
description: "
|
|
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
|
-
|
|
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: "
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
description: "
|
|
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
|
|
68
|
+
function historySummary(todos: Todo[]): string {
|
|
67
69
|
const counts = countTodoStatuses(todos);
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
143
|
+
lines.push(truncateToWidth(` ${this.theme.fg("dim", "No todo history on this branch")}`, width));
|
|
110
144
|
} else {
|
|
111
|
-
lines.push(truncateToWidth(
|
|
112
|
-
|
|
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
|
|
143
|
-
if (
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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: "
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
...(result.error ? {
|
|
204
|
-
}
|
|
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}
|
|
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.
|
|
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
|
|
233
|
-
|
|
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
|
|
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(
|
|
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": "
|
|
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 = "
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
99
|
-
|
|
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:
|
|
129
|
+
return { ...next, message: "Listed current plan" };
|
|
108
130
|
|
|
109
|
-
case "
|
|
110
|
-
if (!input.items
|
|
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, "
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
next.
|
|
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 "
|
|
145
|
+
case "add_step": {
|
|
122
146
|
const text = cleanText(input.text);
|
|
123
|
-
if (!text) return fail(state, "text required for
|
|
124
|
-
|
|
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
|
-
|
|
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 "
|
|
147
|
-
|
|
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 "
|
|
156
|
-
|
|
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");
|