@maxiaochao/pi-toolkit 0.10.4 → 0.10.6
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 +10 -0
- package/README.md +26 -4
- package/package.json +3 -5
- package/packages/herdr-subagents/CHANGELOG.md +11 -0
- package/packages/herdr-subagents/README.md +13 -2
- package/packages/herdr-subagents/TESTING.md +5 -4
- package/packages/herdr-subagents/agents/analyst.md +8 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +1 -1
- package/packages/herdr-subagents/extensions/subagent-report.ts +26 -0
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/history.ts +2 -2
- package/packages/herdr-subagents/src/live-agent.ts +11 -0
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/run-pane-allocator.ts +2 -1
- package/packages/herdr-subagents/src/runner.ts +2 -8
- package/packages/herdr-subagents/src/status.ts +95 -0
- package/packages/herdr-subagents/src/tool.ts +13 -2
- package/packages/herdr-subagents/src/validation.ts +2 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +4 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +61 -4
- package/skills/agent-team/SKILL.md +33 -57
- package/skills/agent-team/models.json +19 -12
- package/packages/todo/CHANGELOG.md +0 -9
- package/packages/todo/README.md +0 -73
- package/packages/todo/TESTING.md +0 -22
- package/packages/todo/docs/demo.gif +0 -0
- package/packages/todo/docs/screenshot.png +0 -0
- package/packages/todo/extensions/todo.ts +0 -279
- package/packages/todo/package.json +0 -25
- package/packages/todo/src/todo-state.ts +0 -159
- package/packages/todo/tests/todo-extension.mjs +0 -134
- package/packages/todo/tests/todo-state.mjs +0 -108
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Branch-local execution-plan extension with tool-result snapshot persistence.
|
|
3
|
-
*
|
|
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.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
9
|
-
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
-
import { Type } from "typebox";
|
|
12
|
-
import {
|
|
13
|
-
activeTodoRows,
|
|
14
|
-
applyTodoAction,
|
|
15
|
-
countTodoStatuses,
|
|
16
|
-
formatCurrentPlan,
|
|
17
|
-
normalizeTodo,
|
|
18
|
-
restoreTodoState,
|
|
19
|
-
type StoredTodo,
|
|
20
|
-
type Todo,
|
|
21
|
-
type TodoAction,
|
|
22
|
-
type TodoActionInput,
|
|
23
|
-
type TodoState,
|
|
24
|
-
type TodoStatus,
|
|
25
|
-
} from "../src/todo-state.ts";
|
|
26
|
-
|
|
27
|
-
interface TodoDetails {
|
|
28
|
-
action: TodoAction;
|
|
29
|
-
todos: StoredTodo[];
|
|
30
|
-
nextId: number;
|
|
31
|
-
error?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const TodoParams = Type.Object({
|
|
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",
|
|
37
|
-
}),
|
|
38
|
-
items: Type.Optional(Type.Array(Type.String(), {
|
|
39
|
-
description: "Ordered executable steps for plan; an empty array cancels all unfinished work",
|
|
40
|
-
})),
|
|
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",
|
|
45
|
-
})),
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const WIDGET_KEY = "todo-list";
|
|
49
|
-
|
|
50
|
-
function sortedTodos(todos: Todo[]): Todo[] {
|
|
51
|
-
return [...todos].sort((a, b) => a.id - b.id);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function statusMarker(status: TodoStatus): string {
|
|
55
|
-
if (status === "doing") return "◼";
|
|
56
|
-
if (status === "done") return "✔";
|
|
57
|
-
if (status === "cancelled") return "✖";
|
|
58
|
-
return "◻";
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function themedStatus(theme: Theme, status: TodoStatus, text: string): string {
|
|
62
|
-
if (status === "doing") return theme.fg("warning", text);
|
|
63
|
-
if (status === "done") return theme.fg("success", text);
|
|
64
|
-
if (status === "cancelled") return theme.fg("error", text);
|
|
65
|
-
return theme.fg("dim", text);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function historySummary(todos: Todo[]): string {
|
|
69
|
-
const counts = countTodoStatuses(todos);
|
|
70
|
-
return `${todos.length} steps · ${counts.done} done · ${counts.cancelled} cancelled · ${counts.doing + counts.todo} current`;
|
|
71
|
-
}
|
|
72
|
-
|
|
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 {
|
|
79
|
-
const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
|
|
80
|
-
if (todo.status === "done" || todo.status === "cancelled") {
|
|
81
|
-
return `${icon} \x1b[9m${theme.fg("dim", `#${todo.id} ${todo.text}`)}\x1b[29m`;
|
|
82
|
-
}
|
|
83
|
-
return `${icon} ${theme.fg("accent", `#${todo.id}`)} ${theme.fg("text", todo.text)}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function renderCurrentLine(theme: Theme, todo: Todo, position: number): string {
|
|
87
|
-
const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
|
|
88
|
-
return `${icon} ${theme.fg("accent", `${position}.`)} ${theme.fg("text", todo.text)}`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export class CurrentPlanWidget {
|
|
92
|
-
private cachedWidth?: number;
|
|
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
|
-
}
|
|
113
|
-
|
|
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
|
-
}
|
|
132
|
-
|
|
133
|
-
handleInput(data: string): void {
|
|
134
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
render(width: number): string[] {
|
|
138
|
-
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
139
|
-
|
|
140
|
-
const border = this.theme.fg("borderMuted", "-".repeat(Math.max(0, width - 10)));
|
|
141
|
-
const lines = ["", truncateToWidth(`${this.theme.fg("borderMuted", "---")}${this.theme.fg("accent", " Todos ")}${border}`, width), ""];
|
|
142
|
-
if (this.todos.length === 0) {
|
|
143
|
-
lines.push(truncateToWidth(` ${this.theme.fg("dim", "No todo history on this branch")}`, width));
|
|
144
|
-
} else {
|
|
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));
|
|
147
|
-
}
|
|
148
|
-
lines.push("", truncateToWidth(` ${this.theme.fg("dim", "Press Escape to close")}`, width), "");
|
|
149
|
-
this.cachedWidth = width;
|
|
150
|
-
this.cachedLines = lines;
|
|
151
|
-
return lines;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
invalidate(): void {
|
|
155
|
-
this.cachedWidth = undefined;
|
|
156
|
-
this.cachedLines = undefined;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
interface MountedWidget {
|
|
161
|
-
component: CurrentPlanWidget;
|
|
162
|
-
tui: { requestRender(): void };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export default function (pi: ExtensionAPI) {
|
|
166
|
-
if (process.env.PI_HERDR_SUBAGENTS_CHILD === "1") return;
|
|
167
|
-
|
|
168
|
-
let state: TodoState = { todos: [], nextId: 1 };
|
|
169
|
-
let mountedWidget: MountedWidget | undefined;
|
|
170
|
-
let actionQueue = Promise.resolve();
|
|
171
|
-
const snapshot = (): StoredTodo[] => state.todos.map((todo) => ({ ...todo }));
|
|
172
|
-
|
|
173
|
-
const refreshUI = (ctx: ExtensionContext) => {
|
|
174
|
-
if (!ctx.hasUI) return;
|
|
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
|
-
}
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
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
|
-
});
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const reconstructState = (ctx: ExtensionContext) => {
|
|
202
|
-
state = { todos: [], nextId: 1 };
|
|
203
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
204
|
-
if (entry.type !== "message") continue;
|
|
205
|
-
const message = entry.message;
|
|
206
|
-
if (message.role !== "toolResult" || message.toolName !== "todo") continue;
|
|
207
|
-
const details = message.details as TodoDetails | undefined;
|
|
208
|
-
if (!details || !Array.isArray(details.todos) || typeof details.nextId !== "number") continue;
|
|
209
|
-
state = restoreTodoState(details.todos, details.nextId);
|
|
210
|
-
}
|
|
211
|
-
refreshUI(ctx);
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
215
|
-
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
216
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
217
|
-
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
218
|
-
mountedWidget = undefined;
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
pi.registerTool({
|
|
222
|
-
name: "todo",
|
|
223
|
-
label: "Todo",
|
|
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.",
|
|
225
|
-
parameters: TodoParams,
|
|
226
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
227
|
-
|
|
228
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
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;
|
|
249
|
-
},
|
|
250
|
-
|
|
251
|
-
renderCall(args, theme) {
|
|
252
|
-
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
253
|
-
if (args.items) text += ` ${theme.fg("dim", `${args.items.length} steps`)}`;
|
|
254
|
-
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
255
|
-
if (args.expected_current_id !== undefined) text += ` ${theme.fg("accent", `#${args.expected_current_id}`)}`;
|
|
256
|
-
return new Text(text, 0, 0);
|
|
257
|
-
},
|
|
258
|
-
|
|
259
|
-
renderResult(result, _options, theme) {
|
|
260
|
-
const item = result.content[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);
|
|
264
|
-
},
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
pi.registerCommand("todos", {
|
|
268
|
-
description: "Show full Todo history on the current branch",
|
|
269
|
-
handler: async (_args, ctx) => {
|
|
270
|
-
if (ctx.mode !== "tui") {
|
|
271
|
-
ctx.ui.notify("/todos requires interactive mode", "error");
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
|
|
275
|
-
new TodoListComponent(snapshot().map(normalizeTodo), theme, () => done()),
|
|
276
|
-
);
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@maxiaochao/pi-todo",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"description": "Branch-local execution-plan lifecycle and history extension for Pi.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"keywords": ["pi-package", "pi-extension", "todo"],
|
|
7
|
-
"files": ["extensions", "src", "docs", "README.md", "TESTING.md", "CHANGELOG.md"],
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
11
|
-
"directory": "packages/todo"
|
|
12
|
-
},
|
|
13
|
-
"pi": { "extensions": ["./extensions/todo.ts"] },
|
|
14
|
-
"peerDependencies": {
|
|
15
|
-
"@earendil-works/pi-ai": "*",
|
|
16
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
17
|
-
"@earendil-works/pi-tui": "*",
|
|
18
|
-
"typebox": "*"
|
|
19
|
-
},
|
|
20
|
-
"scripts": {
|
|
21
|
-
"test": "node --experimental-strip-types tests/todo-state.mjs && node --experimental-strip-types tests/todo-extension.mjs",
|
|
22
|
-
"load-test": "pi -ne -e ./extensions/todo.ts"
|
|
23
|
-
},
|
|
24
|
-
"publishConfig": { "access": "public" }
|
|
25
|
-
}
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
export type TodoStatus = "todo" | "doing" | "done" | "cancelled";
|
|
2
|
-
export type TodoAction = "plan" | "add_step" | "complete_current" | "cancel_current" | "list";
|
|
3
|
-
|
|
4
|
-
export interface Todo {
|
|
5
|
-
id: number;
|
|
6
|
-
text: string;
|
|
7
|
-
status: TodoStatus;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface StoredTodo {
|
|
11
|
-
id: number;
|
|
12
|
-
text: string;
|
|
13
|
-
status?: TodoStatus;
|
|
14
|
-
done?: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface TodoState {
|
|
18
|
-
todos: Todo[];
|
|
19
|
-
nextId: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface ActiveTodoRow {
|
|
23
|
-
position: number;
|
|
24
|
-
todo: Todo;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface TodoActionInput {
|
|
28
|
-
action: TodoAction;
|
|
29
|
-
items?: string[];
|
|
30
|
-
text?: string;
|
|
31
|
-
expected_current_id?: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface TodoActionResult extends TodoState {
|
|
35
|
-
message: string;
|
|
36
|
-
error?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type TodoCounts = Record<TodoStatus, number>;
|
|
40
|
-
|
|
41
|
-
export function normalizeTodo(todo: StoredTodo): Todo {
|
|
42
|
-
const status = todo.status === "todo" || todo.status === "doing" || todo.status === "done" || todo.status === "cancelled"
|
|
43
|
-
? todo.status
|
|
44
|
-
: todo.done
|
|
45
|
-
? "done"
|
|
46
|
-
: "todo";
|
|
47
|
-
return { id: todo.id, text: todo.text, status };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function normalizeTodoFlow(todos: Todo[]): void {
|
|
51
|
-
const active = todos.filter((todo) => todo.status === "doing");
|
|
52
|
-
for (const todo of active.slice(1)) todo.status = "todo";
|
|
53
|
-
if (active.length > 0) return;
|
|
54
|
-
|
|
55
|
-
const next = todos.find((todo) => todo.status === "todo");
|
|
56
|
-
if (next) next.status = "doing";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function restoreTodoState(stored: StoredTodo[], nextId?: number): TodoState {
|
|
60
|
-
const todos = stored.map(normalizeTodo).sort((a, b) => a.id - b.id);
|
|
61
|
-
normalizeTodoFlow(todos);
|
|
62
|
-
const minimumNextId = todos.reduce((max, todo) => Math.max(max, todo.id + 1), 1);
|
|
63
|
-
return { todos, nextId: Math.max(nextId ?? 1, minimumNextId) };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function countTodoStatuses(todos: Todo[]): TodoCounts {
|
|
67
|
-
return todos.reduce<TodoCounts>(
|
|
68
|
-
(counts, todo) => {
|
|
69
|
-
counts[todo.status] += 1;
|
|
70
|
-
return counts;
|
|
71
|
-
},
|
|
72
|
-
{ todo: 0, doing: 0, done: 0, cancelled: 0 },
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function activeTodos(todos: Todo[]): Todo[] {
|
|
77
|
-
return [
|
|
78
|
-
...todos.filter((todo) => todo.status === "doing"),
|
|
79
|
-
...todos.filter((todo) => todo.status === "todo"),
|
|
80
|
-
];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function activeTodoRows(todos: Todo[]): ActiveTodoRow[] {
|
|
84
|
-
return activeTodos(todos).map((todo, index) => ({ position: index + 1, todo }));
|
|
85
|
-
}
|
|
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
|
-
|
|
97
|
-
function cloneState(state: TodoState): TodoState {
|
|
98
|
-
return { todos: state.todos.map((todo) => ({ ...todo })), nextId: state.nextId };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function fail(state: TodoState, error: string): TodoActionResult {
|
|
102
|
-
return { ...cloneState(state), message: `Error: ${error}`, error };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function cleanText(text: string | undefined): string | undefined {
|
|
106
|
-
const cleaned = text?.trim();
|
|
107
|
-
return cleaned ? cleaned : undefined;
|
|
108
|
-
}
|
|
109
|
-
|
|
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}` };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function applyTodoAction(state: TodoState, input: TodoActionInput): TodoActionResult {
|
|
125
|
-
const next = cloneState(state);
|
|
126
|
-
|
|
127
|
-
switch (input.action) {
|
|
128
|
-
case "list":
|
|
129
|
-
return { ...next, message: "Listed current plan" };
|
|
130
|
-
|
|
131
|
-
case "plan": {
|
|
132
|
-
if (!input.items) return fail(state, "items is required for plan");
|
|
133
|
-
const items = input.items.map(cleanText);
|
|
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
|
-
}
|
|
138
|
-
const texts = items as string[];
|
|
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` };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
case "add_step": {
|
|
146
|
-
const text = cleanText(input.text);
|
|
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" };
|
|
149
|
-
next.todos.push(todo);
|
|
150
|
-
return { ...next, message: `Added step #${todo.id}` };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
case "complete_current":
|
|
154
|
-
return closeCurrentAndAdvance(next, input.expected_current_id, "done");
|
|
155
|
-
|
|
156
|
-
case "cancel_current":
|
|
157
|
-
return closeCurrentAndAdvance(next, input.expected_current_id, "cancelled");
|
|
158
|
-
}
|
|
159
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
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");
|