@oh-my-pi/pi-coding-agent 12.19.3 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/package.json +7 -7
- package/src/commit/prompts/analysis-system.md +3 -3
- package/src/commit/prompts/analysis-user.md +14 -14
- package/src/commit/prompts/changelog-system.md +4 -4
- package/src/commit/prompts/changelog-user.md +4 -4
- package/src/commit/prompts/file-observer-system.md +2 -2
- package/src/commit/prompts/file-observer-user.md +2 -2
- package/src/commit/prompts/reduce-system.md +4 -4
- package/src/commit/prompts/reduce-user.md +6 -6
- package/src/commit/prompts/summary-system.md +4 -4
- package/src/commit/prompts/summary-user.md +6 -6
- package/src/discovery/helpers.ts +13 -1
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/internal-urls/index.ts +8 -3
- package/src/internal-urls/local-protocol.ts +223 -0
- package/src/internal-urls/{docs-protocol.ts → pi-protocol.ts} +12 -12
- package/src/internal-urls/router.ts +1 -1
- package/src/internal-urls/types.ts +1 -1
- package/src/ipy/executor.ts +4 -32
- package/src/memories/index.ts +1 -1
- package/src/modes/controllers/event-controller.ts +4 -4
- package/src/modes/interactive-mode.ts +84 -64
- package/src/modes/types.ts +11 -3
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/patch/hashline.ts +42 -42
- package/src/patch/index.ts +24 -21
- package/src/patch/shared.ts +21 -43
- package/src/plan-mode/approved-plan.ts +55 -0
- package/src/prompts/agents/designer.md +6 -6
- package/src/prompts/agents/explore.md +4 -4
- package/src/prompts/agents/frontmatter.md +1 -0
- package/src/prompts/agents/init.md +10 -10
- package/src/prompts/agents/plan.md +6 -6
- package/src/prompts/agents/reviewer.md +4 -3
- package/src/prompts/agents/task.md +10 -10
- package/src/prompts/compaction/branch-summary.md +3 -3
- package/src/prompts/compaction/compaction-short-summary.md +7 -7
- package/src/prompts/compaction/compaction-summary-context.md +1 -1
- package/src/prompts/compaction/compaction-summary.md +5 -5
- package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
- package/src/prompts/compaction/compaction-update-summary.md +11 -11
- package/src/prompts/memories/consolidation.md +5 -5
- package/src/prompts/memories/read-path.md +11 -0
- package/src/prompts/memories/stage_one_input.md +1 -1
- package/src/prompts/memories/stage_one_system.md +5 -5
- package/src/prompts/review-request.md +4 -4
- package/src/prompts/system/agent-creation-architect.md +17 -17
- package/src/prompts/system/agent-creation-user.md +2 -2
- package/src/prompts/system/custom-system-prompt.md +6 -6
- package/src/prompts/system/plan-mode-active.md +20 -20
- package/src/prompts/system/plan-mode-approved.md +9 -7
- package/src/prompts/system/plan-mode-reference.md +2 -2
- package/src/prompts/system/plan-mode-subagent.md +8 -8
- package/src/prompts/system/subagent-submit-reminder.md +5 -5
- package/src/prompts/system/subagent-system-prompt.md +9 -9
- package/src/prompts/system/subagent-user-prompt.md +3 -5
- package/src/prompts/system/summarization-system.md +1 -1
- package/src/prompts/system/system-prompt.md +109 -84
- package/src/prompts/system/title-system.md +2 -2
- package/src/prompts/system/ttsr-interrupt.md +2 -2
- package/src/prompts/system/web-search.md +16 -16
- package/src/prompts/tools/ask.md +6 -6
- package/src/prompts/tools/bash.md +9 -9
- package/src/prompts/tools/browser.md +5 -5
- package/src/prompts/tools/cancel-job.md +2 -2
- package/src/prompts/tools/exit-plan-mode.md +13 -10
- package/src/prompts/tools/find.md +2 -2
- package/src/prompts/tools/gemini-image.md +7 -7
- package/src/prompts/tools/grep.md +4 -3
- package/src/prompts/tools/hashline.md +37 -39
- package/src/prompts/tools/patch.md +5 -5
- package/src/prompts/tools/poll-jobs.md +1 -1
- package/src/prompts/tools/python.md +8 -10
- package/src/prompts/tools/read.md +2 -12
- package/src/prompts/tools/replace.md +6 -6
- package/src/prompts/tools/ssh.md +2 -7
- package/src/prompts/tools/task.md +34 -23
- package/src/prompts/tools/todo-write.md +65 -49
- package/src/prompts/tools/web-search.md +2 -2
- package/src/prompts/tools/write.md +4 -3
- package/src/sdk.ts +11 -9
- package/src/session/agent-session.ts +92 -51
- package/src/session/artifacts.ts +1 -1
- package/src/session/messages.ts +1 -0
- package/src/task/agents.ts +1 -0
- package/src/task/index.ts +2 -1
- package/src/task/render.ts +2 -2
- package/src/task/types.ts +1 -0
- package/src/tools/bash-interactive.ts +1 -1
- package/src/tools/bash-skill-urls.ts +3 -2
- package/src/tools/bash.ts +21 -12
- package/src/tools/exit-plan-mode.ts +30 -2
- package/src/tools/grep.ts +131 -75
- package/src/tools/index.ts +13 -3
- package/src/tools/path-utils.ts +2 -1
- package/src/tools/plan-mode-guard.ts +8 -8
- package/src/tools/python.ts +0 -2
- package/src/tools/read.ts +2 -2
- package/src/tools/todo-write.ts +276 -146
- package/src/internal-urls/plan-protocol.ts +0 -95
- package/src/modes/components/todo-display.ts +0 -114
- package/src/prompts/memories/read_path.md +0 -11
package/src/tools/todo-write.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
3
2
|
import { StringEnum } from "@oh-my-pi/pi-ai";
|
|
4
3
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
5
4
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
6
|
-
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
|
|
7
5
|
import { type Static, Type } from "@sinclair/typebox";
|
|
8
6
|
import chalk from "chalk";
|
|
9
7
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
@@ -11,138 +9,274 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
|
11
9
|
import type { Theme } from "../modes/theme/theme";
|
|
12
10
|
import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" };
|
|
13
11
|
import type { ToolSession } from "../sdk";
|
|
12
|
+
import type { SessionEntry } from "../session/session-manager";
|
|
14
13
|
import { renderStatusLine, renderTreeList } from "../tui";
|
|
15
14
|
import { PREVIEW_LIMITS } from "./render-utils";
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
id: Type.Optional(Type.String({ description: "Stable todo id" })),
|
|
21
|
-
content: Type.String({ description: "Task description (e.g., 'Run tests')" }),
|
|
22
|
-
status: StringEnum(["pending", "in_progress", "completed"]),
|
|
23
|
-
}),
|
|
24
|
-
{ description: "The updated todo list" },
|
|
25
|
-
),
|
|
26
|
-
});
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// =============================================================================
|
|
27
19
|
|
|
28
|
-
type TodoStatus = "pending" | "in_progress" | "completed";
|
|
20
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
|
|
29
21
|
|
|
30
22
|
export interface TodoItem {
|
|
31
23
|
id: string;
|
|
32
24
|
content: string;
|
|
33
25
|
status: TodoStatus;
|
|
26
|
+
notes?: string;
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
interface
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
export interface TodoPhase {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
tasks: TodoItem[];
|
|
39
33
|
}
|
|
40
34
|
|
|
41
35
|
export interface TodoWriteToolDetails {
|
|
42
|
-
|
|
43
|
-
updatedAt: number;
|
|
36
|
+
phases: TodoPhase[];
|
|
44
37
|
storage: "session" | "memory";
|
|
45
38
|
}
|
|
46
39
|
|
|
47
|
-
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// Schema
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
const StatusEnum = StringEnum(["pending", "in_progress", "completed", "abandoned"] as const);
|
|
45
|
+
|
|
46
|
+
const InputTask = Type.Object({
|
|
47
|
+
content: Type.String(),
|
|
48
|
+
status: Type.Optional(StatusEnum),
|
|
49
|
+
notes: Type.Optional(Type.String()),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const InputPhase = Type.Object({
|
|
53
|
+
name: Type.String(),
|
|
54
|
+
tasks: Type.Optional(Type.Array(InputTask)),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const todoWriteSchema = Type.Object({
|
|
58
|
+
ops: Type.Array(
|
|
59
|
+
Type.Union([
|
|
60
|
+
Type.Object({
|
|
61
|
+
op: Type.Literal("replace"),
|
|
62
|
+
phases: Type.Array(InputPhase),
|
|
63
|
+
}),
|
|
64
|
+
Type.Object({
|
|
65
|
+
op: Type.Literal("add_phase"),
|
|
66
|
+
name: Type.String(),
|
|
67
|
+
tasks: Type.Optional(Type.Array(InputTask)),
|
|
68
|
+
}),
|
|
69
|
+
Type.Object({
|
|
70
|
+
op: Type.Literal("add_task"),
|
|
71
|
+
phase: Type.String({ description: "Phase ID, e.g. phase-1" }),
|
|
72
|
+
content: Type.String(),
|
|
73
|
+
notes: Type.Optional(Type.String()),
|
|
74
|
+
}),
|
|
75
|
+
Type.Object({
|
|
76
|
+
op: Type.Literal("update"),
|
|
77
|
+
id: Type.String({ description: "Task ID, e.g. task-3" }),
|
|
78
|
+
status: Type.Optional(StatusEnum),
|
|
79
|
+
content: Type.Optional(Type.String()),
|
|
80
|
+
notes: Type.Optional(Type.String()),
|
|
81
|
+
}),
|
|
82
|
+
Type.Object({
|
|
83
|
+
op: Type.Literal("remove_task"),
|
|
84
|
+
id: Type.String({ description: "Task ID, e.g. task-3" }),
|
|
85
|
+
}),
|
|
86
|
+
]),
|
|
87
|
+
),
|
|
88
|
+
});
|
|
48
89
|
|
|
49
90
|
type TodoWriteParams = Static<typeof todoWriteSchema>;
|
|
50
91
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// File format
|
|
94
|
+
// =============================================================================
|
|
95
|
+
|
|
96
|
+
interface TodoFile {
|
|
97
|
+
phases: TodoPhase[];
|
|
98
|
+
nextTaskId: number;
|
|
99
|
+
nextPhaseId: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// State helpers
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
function makeEmptyFile(): TodoFile {
|
|
107
|
+
return { phases: [], nextTaskId: 1, nextPhaseId: 1 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findTask(phases: TodoPhase[], id: string): TodoItem | undefined {
|
|
111
|
+
for (const phase of phases) {
|
|
112
|
+
const task = phase.tasks.find(t => t.id === id);
|
|
113
|
+
if (task) return task;
|
|
61
114
|
}
|
|
115
|
+
return undefined;
|
|
62
116
|
}
|
|
63
117
|
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
content,
|
|
76
|
-
status: normalizeTodoStatus(item.status),
|
|
77
|
-
};
|
|
78
|
-
});
|
|
118
|
+
function buildPhaseFromInput(
|
|
119
|
+
input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; notes?: string }> },
|
|
120
|
+
phaseId: string,
|
|
121
|
+
nextTaskId: number,
|
|
122
|
+
): { phase: TodoPhase; nextTaskId: number } {
|
|
123
|
+
const tasks: TodoItem[] = [];
|
|
124
|
+
let tid = nextTaskId;
|
|
125
|
+
for (const t of input.tasks ?? []) {
|
|
126
|
+
tasks.push({ id: `task-${tid++}`, content: t.content, status: t.status ?? "pending", notes: t.notes });
|
|
127
|
+
}
|
|
128
|
+
return { phase: { id: phaseId, name: input.name, tasks }, nextTaskId: tid };
|
|
79
129
|
}
|
|
80
130
|
|
|
81
|
-
function
|
|
82
|
-
|
|
131
|
+
function getNextIds(phases: TodoPhase[]): { nextTaskId: number; nextPhaseId: number } {
|
|
132
|
+
let maxTaskId = 0;
|
|
133
|
+
let maxPhaseId = 0;
|
|
83
134
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
valid: false,
|
|
90
|
-
error: `Error: Cannot complete "${todos[i].content}" before completing "${todos[firstIncompleteIndex].content}". Todos must be completed sequentially.`,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
135
|
+
for (const phase of phases) {
|
|
136
|
+
const phaseMatch = /^phase-(\d+)$/.exec(phase.id);
|
|
137
|
+
if (phaseMatch) {
|
|
138
|
+
const value = Number.parseInt(phaseMatch[1], 10);
|
|
139
|
+
if (Number.isFinite(value) && value > maxPhaseId) maxPhaseId = value;
|
|
93
140
|
}
|
|
94
|
-
}
|
|
95
141
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
for (const idx of inProgressIndices) {
|
|
102
|
-
const hasPriorIncomplete = todos.slice(0, idx).some(t => t.status === "pending");
|
|
103
|
-
if (hasPriorIncomplete) {
|
|
104
|
-
return {
|
|
105
|
-
valid: false,
|
|
106
|
-
error: `Cannot start "${todos[idx].content}" while earlier tasks are still pending.`,
|
|
107
|
-
};
|
|
142
|
+
for (const task of phase.tasks) {
|
|
143
|
+
const taskMatch = /^task-(\d+)$/.exec(task.id);
|
|
144
|
+
if (!taskMatch) continue;
|
|
145
|
+
const value = Number.parseInt(taskMatch[1], 10);
|
|
146
|
+
if (Number.isFinite(value) && value > maxTaskId) maxTaskId = value;
|
|
108
147
|
}
|
|
109
148
|
}
|
|
110
149
|
|
|
111
|
-
return {
|
|
150
|
+
return { nextTaskId: maxTaskId + 1, nextPhaseId: maxPhaseId + 1 };
|
|
112
151
|
}
|
|
113
152
|
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
153
|
+
function fileFromPhases(phases: TodoPhase[]): TodoFile {
|
|
154
|
+
const { nextTaskId, nextPhaseId } = getNextIds(phases);
|
|
155
|
+
return { phases, nextTaskId, nextPhaseId };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function clonePhases(phases: TodoPhase[]): TodoPhase[] {
|
|
159
|
+
return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(task => ({ ...task })) }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[] {
|
|
163
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
164
|
+
const entry = entries[i];
|
|
165
|
+
if (entry.type !== "message") continue;
|
|
166
|
+
|
|
167
|
+
const message = entry.message as { role?: string; toolName?: string; details?: unknown; isError?: boolean };
|
|
168
|
+
if (message.role !== "toolResult" || message.toolName !== "todo_write" || message.isError) continue;
|
|
169
|
+
|
|
170
|
+
const details = message.details as { phases?: unknown } | undefined;
|
|
171
|
+
if (!details || !Array.isArray(details.phases)) continue;
|
|
172
|
+
|
|
173
|
+
return clonePhases(details.phases as TodoPhase[]);
|
|
125
174
|
}
|
|
175
|
+
|
|
176
|
+
return [];
|
|
126
177
|
}
|
|
127
178
|
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
179
|
+
function applyOps(file: TodoFile, ops: TodoWriteParams["ops"]): { file: TodoFile; errors: string[] } {
|
|
180
|
+
const errors: string[] = [];
|
|
181
|
+
|
|
182
|
+
for (const op of ops) {
|
|
183
|
+
switch (op.op) {
|
|
184
|
+
case "replace": {
|
|
185
|
+
const next = makeEmptyFile();
|
|
186
|
+
for (const inputPhase of op.phases) {
|
|
187
|
+
const phaseId = `phase-${next.nextPhaseId++}`;
|
|
188
|
+
const { phase, nextTaskId } = buildPhaseFromInput(inputPhase, phaseId, next.nextTaskId);
|
|
189
|
+
next.phases.push(phase);
|
|
190
|
+
next.nextTaskId = nextTaskId;
|
|
191
|
+
}
|
|
192
|
+
file = next;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "add_phase": {
|
|
197
|
+
const phaseId = `phase-${file.nextPhaseId++}`;
|
|
198
|
+
const { phase, nextTaskId } = buildPhaseFromInput(op, phaseId, file.nextTaskId);
|
|
199
|
+
file.phases.push(phase);
|
|
200
|
+
file.nextTaskId = nextTaskId;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case "add_task": {
|
|
205
|
+
const target = file.phases.find(p => p.id === op.phase);
|
|
206
|
+
if (!target) {
|
|
207
|
+
errors.push(`Phase "${op.phase}" not found`);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
target.tasks.push({
|
|
211
|
+
id: `task-${file.nextTaskId++}`,
|
|
212
|
+
content: op.content,
|
|
213
|
+
status: "pending",
|
|
214
|
+
notes: op.notes,
|
|
215
|
+
});
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case "update": {
|
|
220
|
+
const task = findTask(file.phases, op.id);
|
|
221
|
+
if (!task) {
|
|
222
|
+
errors.push(`Task "${op.id}" not found`);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (op.status !== undefined) task.status = op.status;
|
|
226
|
+
if (op.content !== undefined) task.content = op.content;
|
|
227
|
+
if (op.notes !== undefined) task.notes = op.notes;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case "remove_task": {
|
|
232
|
+
let removed = false;
|
|
233
|
+
for (const phase of file.phases) {
|
|
234
|
+
const idx = phase.tasks.findIndex(t => t.id === op.id);
|
|
235
|
+
if (idx !== -1) {
|
|
236
|
+
phase.tasks.splice(idx, 1);
|
|
237
|
+
removed = true;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!removed) errors.push(`Task "${op.id}" not found`);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { file, errors };
|
|
134
248
|
}
|
|
135
249
|
|
|
136
|
-
function
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
250
|
+
function formatSummary(phases: TodoPhase[], errors: string[]): string {
|
|
251
|
+
const tasks = phases.flatMap(p => p.tasks);
|
|
252
|
+
if (tasks.length === 0) return errors.length > 0 ? `Errors: ${errors.join("; ")}` : "Todo list cleared.";
|
|
253
|
+
|
|
254
|
+
// Find current phase
|
|
255
|
+
let currentIdx = phases.findIndex(p => p.tasks.some(t => t.status === "pending" || t.status === "in_progress"));
|
|
256
|
+
if (currentIdx === -1) currentIdx = phases.length - 1;
|
|
257
|
+
const current = phases[currentIdx];
|
|
258
|
+
const done = current.tasks.filter(t => t.status === "completed" || t.status === "abandoned").length;
|
|
259
|
+
|
|
260
|
+
const lines: string[] = [];
|
|
261
|
+
if (errors.length > 0) lines.push(`Errors: ${errors.join("; ")}`);
|
|
262
|
+
lines.push(
|
|
263
|
+
`Phase ${currentIdx + 1}/${phases.length} "${current.name}" — ${done}/${current.tasks.length} tasks complete`,
|
|
264
|
+
);
|
|
265
|
+
for (const phase of phases) {
|
|
266
|
+
lines.push(` ${phase.name}:`);
|
|
267
|
+
for (const task of phase.tasks) {
|
|
268
|
+
const sym =
|
|
269
|
+
task.status === "completed"
|
|
270
|
+
? "✓"
|
|
271
|
+
: task.status === "in_progress"
|
|
272
|
+
? "→"
|
|
273
|
+
: task.status === "abandoned"
|
|
274
|
+
? "✗"
|
|
275
|
+
: "○";
|
|
276
|
+
lines.push(` ${sym} ${task.id} ${task.content}`);
|
|
277
|
+
}
|
|
145
278
|
}
|
|
279
|
+
return lines.join("\n");
|
|
146
280
|
}
|
|
147
281
|
|
|
148
282
|
// =============================================================================
|
|
@@ -167,40 +301,15 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
|
|
|
167
301
|
_onUpdate?: AgentToolUpdateCallback<TodoWriteToolDetails>,
|
|
168
302
|
_context?: AgentToolContext,
|
|
169
303
|
): Promise<AgentToolResult<TodoWriteToolDetails>> {
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const updatedAt = Date.now();
|
|
176
|
-
|
|
177
|
-
const sessionFile = this.session.getSessionFile();
|
|
178
|
-
if (!sessionFile) {
|
|
179
|
-
return {
|
|
180
|
-
content: [{ type: "text", text: formatTodoSummary(todos) }],
|
|
181
|
-
details: { todos, updatedAt, storage: "memory" },
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const todoPath = path.join(sessionFile.slice(0, -6), TODO_FILE_NAME);
|
|
186
|
-
const existing = await loadTodoFile(todoPath);
|
|
187
|
-
const storedTodos = existing?.todos ?? [];
|
|
188
|
-
const merged = todos.length > 0 ? todos : [];
|
|
189
|
-
const fileData: TodoFile = { updatedAt, todos: merged };
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
await Bun.write(todoPath, JSON.stringify(fileData, null, 2));
|
|
193
|
-
} catch (error) {
|
|
194
|
-
logger.error("Failed to write todo file", { path: todoPath, error: String(error) });
|
|
195
|
-
return {
|
|
196
|
-
content: [{ type: "text", text: "Failed to save todos." }],
|
|
197
|
-
details: { todos: storedTodos, updatedAt, storage: "session" },
|
|
198
|
-
};
|
|
199
|
-
}
|
|
304
|
+
const previousPhases = this.session.getTodoPhases?.() ?? [];
|
|
305
|
+
const current = fileFromPhases(previousPhases);
|
|
306
|
+
const { file: updated, errors } = applyOps(current, params.ops);
|
|
307
|
+
this.session.setTodoPhases?.(updated.phases);
|
|
308
|
+
const storage = this.session.getSessionFile() ? "session" : "memory";
|
|
200
309
|
|
|
201
310
|
return {
|
|
202
|
-
content: [{ type: "text", text:
|
|
203
|
-
details: {
|
|
311
|
+
content: [{ type: "text", text: formatSummary(updated.phases, errors) }],
|
|
312
|
+
details: { phases: updated.phases, storage },
|
|
204
313
|
};
|
|
205
314
|
}
|
|
206
315
|
}
|
|
@@ -210,14 +319,28 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
|
|
|
210
319
|
// =============================================================================
|
|
211
320
|
|
|
212
321
|
interface TodoWriteRenderArgs {
|
|
213
|
-
|
|
322
|
+
ops?: Array<{ op: string }>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
|
|
326
|
+
const checkbox = uiTheme.checkbox;
|
|
327
|
+
switch (item.status) {
|
|
328
|
+
case "completed":
|
|
329
|
+
return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
|
|
330
|
+
case "in_progress":
|
|
331
|
+
return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
|
|
332
|
+
case "abandoned":
|
|
333
|
+
return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`);
|
|
334
|
+
default:
|
|
335
|
+
return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
|
|
336
|
+
}
|
|
214
337
|
}
|
|
215
338
|
|
|
216
339
|
export const todoWriteToolRenderer = {
|
|
217
340
|
renderCall(args: TodoWriteRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
218
|
-
const count = args.
|
|
219
|
-
const
|
|
220
|
-
const text = renderStatusLine({ icon: "pending", title: "Todo Write", meta }, uiTheme);
|
|
341
|
+
const count = args.ops?.length ?? 0;
|
|
342
|
+
const label = count === 1 ? (args.ops?.[0]?.op ?? "update") : `${count} ops`;
|
|
343
|
+
const text = renderStatusLine({ icon: "pending", title: "Todo Write", meta: [label] }, uiTheme);
|
|
221
344
|
return new Text(text, 0, 0);
|
|
222
345
|
},
|
|
223
346
|
|
|
@@ -227,29 +350,36 @@ export const todoWriteToolRenderer = {
|
|
|
227
350
|
uiTheme: Theme,
|
|
228
351
|
_args?: TodoWriteRenderArgs,
|
|
229
352
|
): Component {
|
|
230
|
-
const
|
|
353
|
+
const phases = (result.details?.phases ?? []).filter(p => p.tasks.length > 0);
|
|
354
|
+
const allTasks = phases.flatMap(p => p.tasks);
|
|
231
355
|
const header = renderStatusLine(
|
|
232
|
-
{ icon: "success", title: "Todo Write", meta: [`${
|
|
356
|
+
{ icon: "success", title: "Todo Write", meta: [`${allTasks.length} tasks`] },
|
|
233
357
|
uiTheme,
|
|
234
358
|
);
|
|
235
|
-
if (
|
|
359
|
+
if (allTasks.length === 0) {
|
|
236
360
|
const fallback = result.content?.find(c => c.type === "text")?.text ?? "No todos";
|
|
237
361
|
return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
|
|
238
362
|
}
|
|
239
363
|
|
|
240
364
|
const { expanded } = options;
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
365
|
+
const lines: string[] = [header];
|
|
366
|
+
for (const phase of phases) {
|
|
367
|
+
if (phases.length > 1) {
|
|
368
|
+
lines.push(uiTheme.fg("accent", ` ${uiTheme.tree.hook} ${phase.name}`));
|
|
369
|
+
}
|
|
370
|
+
const treeLines = renderTreeList(
|
|
371
|
+
{
|
|
372
|
+
items: phase.tasks,
|
|
373
|
+
expanded,
|
|
374
|
+
maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
|
|
375
|
+
itemType: "todo",
|
|
376
|
+
renderItem: todo => formatTodoLine(todo, uiTheme, ""),
|
|
377
|
+
},
|
|
378
|
+
uiTheme,
|
|
379
|
+
);
|
|
380
|
+
lines.push(...treeLines);
|
|
381
|
+
}
|
|
382
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
253
383
|
},
|
|
254
384
|
mergeCallAndResult: true,
|
|
255
385
|
};
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Protocol handler for plan:// URLs.
|
|
3
|
-
*
|
|
4
|
-
* Resolves plan references to plan files under the plans directory.
|
|
5
|
-
*
|
|
6
|
-
* URL forms:
|
|
7
|
-
* - plan://<sessionId> (defaults to plan.md)
|
|
8
|
-
* - plan://<sessionId>/plan.md
|
|
9
|
-
* - plan://<plan-id>.md (resolves directly under plans dir)
|
|
10
|
-
*/
|
|
11
|
-
import * as path from "node:path";
|
|
12
|
-
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
13
|
-
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
14
|
-
|
|
15
|
-
export interface PlanProtocolOptions {
|
|
16
|
-
getPlansDirectory: (cwd?: string) => string;
|
|
17
|
-
cwd: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function parsePlanUrl(input: string): InternalUrl {
|
|
21
|
-
let parsed: URL;
|
|
22
|
-
try {
|
|
23
|
-
parsed = new URL(input);
|
|
24
|
-
} catch {
|
|
25
|
-
throw new Error(`Invalid URL: ${input}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const hostMatch = input.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i);
|
|
29
|
-
let rawHost = hostMatch ? hostMatch[2] : parsed.hostname;
|
|
30
|
-
try {
|
|
31
|
-
rawHost = decodeURIComponent(rawHost);
|
|
32
|
-
} catch {
|
|
33
|
-
// Leave rawHost as-is if decoding fails.
|
|
34
|
-
}
|
|
35
|
-
(parsed as InternalUrl).rawHost = rawHost;
|
|
36
|
-
return parsed as InternalUrl;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function normalizeRelativePath(host: string, pathname: string): string {
|
|
40
|
-
const trimmedHost = host.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
41
|
-
const trimmedPath = pathname.replace(/^\/+/, "");
|
|
42
|
-
|
|
43
|
-
if (!trimmedHost) {
|
|
44
|
-
throw new Error("plan:// URL requires a session or plan identifier");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (trimmedPath) {
|
|
48
|
-
return path.join(trimmedHost, trimmedPath);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (trimmedHost.endsWith(".md")) {
|
|
52
|
-
return trimmedHost;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return path.join(trimmedHost, "plan.md");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function resolvePlanUrlToPath(input: string | InternalUrl, options: PlanProtocolOptions): string {
|
|
59
|
-
const url = typeof input === "string" ? parsePlanUrl(input) : input;
|
|
60
|
-
const host = url.rawHost || url.hostname;
|
|
61
|
-
const relativePath = normalizeRelativePath(host, url.pathname ?? "");
|
|
62
|
-
const plansDir = path.resolve(options.getPlansDirectory(options.cwd));
|
|
63
|
-
const resolved = path.resolve(plansDir, relativePath);
|
|
64
|
-
|
|
65
|
-
if (resolved !== plansDir && !resolved.startsWith(`${plansDir}${path.sep}`)) {
|
|
66
|
-
throw new Error("plan:// URL escapes the plans directory");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return resolved;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export class PlanProtocolHandler implements ProtocolHandler {
|
|
73
|
-
readonly scheme = "plan";
|
|
74
|
-
|
|
75
|
-
constructor(private readonly options: PlanProtocolOptions) {}
|
|
76
|
-
|
|
77
|
-
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
78
|
-
const planPath = resolvePlanUrlToPath(url, this.options);
|
|
79
|
-
try {
|
|
80
|
-
const content = await Bun.file(planPath).text();
|
|
81
|
-
return {
|
|
82
|
-
url: url.href,
|
|
83
|
-
content,
|
|
84
|
-
contentType: "text/markdown",
|
|
85
|
-
size: Buffer.byteLength(content, "utf-8"),
|
|
86
|
-
sourcePath: planPath,
|
|
87
|
-
};
|
|
88
|
-
} catch (error) {
|
|
89
|
-
if (isEnoent(error)) {
|
|
90
|
-
throw new Error(`Plan file not found: ${url.href}`);
|
|
91
|
-
}
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|