@zhushanwen/pi-goal 0.1.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/README.md +151 -0
- package/index.ts +1 -0
- package/package.json +24 -0
- package/src/budget.ts +159 -0
- package/src/commands.ts +79 -0
- package/src/constants.ts +44 -0
- package/src/index.ts +895 -0
- package/src/state.ts +231 -0
- package/src/templates.ts +232 -0
- package/src/tool-handler.ts +487 -0
- package/src/widget.ts +185 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal 扩展 — Tool 执行处理器和共享 helpers
|
|
3
|
+
*
|
|
4
|
+
* 从 index.ts 提取的 executeGoalAction 及其依赖的辅助函数、类型和 schema。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext, CustomEntry, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { Type, type Static } from "typebox";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type GoalRuntimeState,
|
|
13
|
+
type GoalTask,
|
|
14
|
+
type Subtask,
|
|
15
|
+
serializeState,
|
|
16
|
+
transitionStatus,
|
|
17
|
+
isTerminalStatus,
|
|
18
|
+
isTerminalTaskStatus,
|
|
19
|
+
getCompletedCount,
|
|
20
|
+
getIncompleteTasks,
|
|
21
|
+
getElapsedTimeSeconds,
|
|
22
|
+
GOAL_TASK_STATUSES,
|
|
23
|
+
SUBTASK_STATUSES,
|
|
24
|
+
} from "./state";
|
|
25
|
+
|
|
26
|
+
import { formatTaskList } from "./templates";
|
|
27
|
+
|
|
28
|
+
import { renderStatusLine, renderWidgetLines, renderTerminalStatusLine } from "./widget";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
SECONDS_PER_MINUTE,
|
|
32
|
+
MS_PER_SECOND,
|
|
33
|
+
} from "./constants";
|
|
34
|
+
|
|
35
|
+
// ── 常量 ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const ENTRY_TYPE = "goal-state";
|
|
38
|
+
|
|
39
|
+
export const HISTORY_ENTRY_TYPE = "goal-history";
|
|
40
|
+
|
|
41
|
+
// ── Session State Interface ──────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface GoalSession {
|
|
44
|
+
state: GoalRuntimeState | null;
|
|
45
|
+
tasksCompletedAtAgentStart: number;
|
|
46
|
+
hasPendingInjection: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Tool Parameter Schema ────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const GoalManagerParams = Type.Object({
|
|
52
|
+
action: StringEnum([
|
|
53
|
+
"create_tasks",
|
|
54
|
+
"add_tasks",
|
|
55
|
+
"update_tasks",
|
|
56
|
+
"list_tasks",
|
|
57
|
+
"complete_goal",
|
|
58
|
+
"cancel_goal",
|
|
59
|
+
"report_blocked",
|
|
60
|
+
"add_subtasks",
|
|
61
|
+
"update_subtasks",
|
|
62
|
+
"delete_subtasks",
|
|
63
|
+
] as const),
|
|
64
|
+
tasks: Type.Optional(Type.Array(Type.String(), { description: "Task descriptions. 每条必须是一行简短摘要(不超过 60 字),不含换行或 markdown" })),
|
|
65
|
+
updates: Type.Optional(Type.Array(Type.Object({
|
|
66
|
+
taskId: Type.Number(),
|
|
67
|
+
status: StringEnum(GOAL_TASK_STATUSES),
|
|
68
|
+
evidence: Type.Optional(Type.String()),
|
|
69
|
+
}))),
|
|
70
|
+
taskId: Type.Optional(Type.Number({ description: "Task ID(subtask 操作时必需)" })),
|
|
71
|
+
texts: Type.Optional(Type.Array(Type.String(), { description: "Subtask 文本列表(add_subtasks 时使用)" })),
|
|
72
|
+
subUpdates: Type.Optional(Type.Array(Type.Object({
|
|
73
|
+
subId: Type.Number(),
|
|
74
|
+
status: StringEnum(SUBTASK_STATUSES),
|
|
75
|
+
}))),
|
|
76
|
+
subIds: Type.Optional(Type.Array(Type.Number(), { description: "Subtask ID 列表(delete_subtasks 时使用)" })),
|
|
77
|
+
evidence: Type.Optional(Type.String({ description: "Evidence for completion (required for complete_goal)" })),
|
|
78
|
+
reason: Type.Optional(Type.String({ description: "Reason for being blocked (required for report_blocked)" })),
|
|
79
|
+
cancelReason: Type.Optional(Type.String({ description: "Why the user wants to cancel (required for cancel_goal)" })),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── Tool Details Types ───────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface GoalManagerDetails {
|
|
85
|
+
action: string;
|
|
86
|
+
tasks: GoalTask[];
|
|
87
|
+
goalId: string;
|
|
88
|
+
status: string;
|
|
89
|
+
_render?: {
|
|
90
|
+
type: "task-list" | "summary-table" | "progress" | "code-block";
|
|
91
|
+
summary?: string;
|
|
92
|
+
data: unknown;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Module-level Helpers ─────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function isGoalEntry(entry: SessionEntry): entry is CustomEntry<GoalRuntimeState> {
|
|
99
|
+
return entry.type === "custom" && (entry as CustomEntry).customType === ENTRY_TYPE;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function persistGoalState(pi: ExtensionAPI, session: GoalSession, _ctx: ExtensionContext): void {
|
|
103
|
+
if (!session.state) return;
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (session.state.timeStartedAt > 0) {
|
|
106
|
+
session.state.timeUsedSeconds += (now - session.state.timeStartedAt) / MS_PER_SECOND;
|
|
107
|
+
session.state.timeStartedAt = now;
|
|
108
|
+
}
|
|
109
|
+
pi.appendEntry(ENTRY_TYPE, serializeState(session.state));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function writeGoalHistoryEntry(pi: ExtensionAPI, session: GoalSession): void {
|
|
113
|
+
const state = session.state;
|
|
114
|
+
if (!state) return;
|
|
115
|
+
pi.appendEntry(HISTORY_ENTRY_TYPE, {
|
|
116
|
+
goalId: state.goalId,
|
|
117
|
+
objective: state.objective,
|
|
118
|
+
status: state.status,
|
|
119
|
+
completedTasks: getCompletedCount(state.tasks),
|
|
120
|
+
totalTasks: state.tasks.length,
|
|
121
|
+
elapsedSeconds: Math.floor(getElapsedTimeSeconds(state)),
|
|
122
|
+
timestamp: Date.now(),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function updateWidget(session: GoalSession, ctx: ExtensionContext): void {
|
|
127
|
+
if (!session.state || session.state.status === "cancelled") {
|
|
128
|
+
ctx.ui.setWidget("goal", undefined);
|
|
129
|
+
ctx.ui.setStatus("goal", undefined);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 终态折叠为单行 status bar
|
|
134
|
+
if (isTerminalStatus(session.state.status)) {
|
|
135
|
+
const statusText = renderTerminalStatusLine(session.state, ctx.ui.theme);
|
|
136
|
+
if (statusText) {
|
|
137
|
+
ctx.ui.setStatus("goal", statusText);
|
|
138
|
+
}
|
|
139
|
+
ctx.ui.setWidget("goal", undefined);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ctx.ui.setStatus("goal", renderStatusLine(session.state, ctx.ui.theme));
|
|
144
|
+
ctx.ui.setWidget("goal", renderWidgetLines(session.state, ctx.ui.theme));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function clearGoalSession(session: GoalSession, ctx: ExtensionContext): void {
|
|
148
|
+
session.state = null;
|
|
149
|
+
session.tasksCompletedAtAgentStart = 0;
|
|
150
|
+
session.hasPendingInjection = false;
|
|
151
|
+
ctx.ui.setWidget("goal", undefined);
|
|
152
|
+
ctx.ui.setStatus("goal", undefined);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Result Builder ───────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export function makeGoalResult(session: GoalSession, text: string) {
|
|
158
|
+
const state = session.state;
|
|
159
|
+
if (!state) throw new Error("No active goal");
|
|
160
|
+
const budgetInfo: string[] = [];
|
|
161
|
+
if (state.budget.tokenBudget) {
|
|
162
|
+
const remaining = Math.max(state.budget.tokenBudget - state.tokensUsed, 0);
|
|
163
|
+
budgetInfo.push(`Token: ${state.tokensUsed}/${state.budget.tokenBudget} (剩余 ${remaining})`);
|
|
164
|
+
}
|
|
165
|
+
if (state.budget.timeBudgetMinutes) {
|
|
166
|
+
const elapsed = getElapsedTimeSeconds(state);
|
|
167
|
+
const remaining = Math.max(state.budget.timeBudgetMinutes * SECONDS_PER_MINUTE - elapsed, 0);
|
|
168
|
+
budgetInfo.push(`时间: ${Math.floor(elapsed / SECONDS_PER_MINUTE)}分/${state.budget.timeBudgetMinutes}分 (剩余 ${Math.floor(remaining / SECONDS_PER_MINUTE)}分)`);
|
|
169
|
+
}
|
|
170
|
+
const suffix = budgetInfo.length > 0 ? `\n\n[Budget] ${budgetInfo.join(" | ")}` : "";
|
|
171
|
+
return {
|
|
172
|
+
content: [{ type: "text" as const, text: text + suffix }],
|
|
173
|
+
details: {
|
|
174
|
+
action: "update",
|
|
175
|
+
tasks: state.tasks.map((t) => ({ ...t })),
|
|
176
|
+
goalId: state.goalId,
|
|
177
|
+
status: state.status,
|
|
178
|
+
_render: {
|
|
179
|
+
type: "task-list" as const,
|
|
180
|
+
summary: `${getCompletedCount(state.tasks)}/${state.tasks.length} 完成`,
|
|
181
|
+
data: {
|
|
182
|
+
items: state.tasks.map((t) => ({
|
|
183
|
+
id: t.id,
|
|
184
|
+
text: t.description,
|
|
185
|
+
status: t.status,
|
|
186
|
+
evidence: t.evidence,
|
|
187
|
+
subtasks: t.subtasks?.map((s) => ({
|
|
188
|
+
id: s.id,
|
|
189
|
+
text: s.text,
|
|
190
|
+
status: s.status,
|
|
191
|
+
})),
|
|
192
|
+
})),
|
|
193
|
+
meta: {
|
|
194
|
+
...(state.budget.tokenBudget ? { "Token": `${state.tokensUsed}/${state.budget.tokenBudget}` } : {}),
|
|
195
|
+
...(state.budget.timeBudgetMinutes ? { "时间": `${Math.floor(getElapsedTimeSeconds(state) / SECONDS_PER_MINUTE)}分/${state.budget.timeBudgetMinutes}分` } : {}),
|
|
196
|
+
"轮次": `${state.turnCount}/${state.budget.maxTurns}`,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
} satisfies GoalManagerDetails,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Tool Execute Handler ──────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** 将 AI 传入的 task description 标准化:去换行、截断 */
|
|
207
|
+
function normalizeDescription(desc: string): string {
|
|
208
|
+
const singleLine = desc.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim();
|
|
209
|
+
const ELLIPSIS_LENGTH = 3;
|
|
210
|
+
const MAX_TASK_DESC_LENGTH = 80;
|
|
211
|
+
if (singleLine.length > MAX_TASK_DESC_LENGTH) {
|
|
212
|
+
return singleLine.slice(0, MAX_TASK_DESC_LENGTH - ELLIPSIS_LENGTH) + "...";
|
|
213
|
+
}
|
|
214
|
+
return singleLine;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function executeGoalAction(
|
|
218
|
+
pi: ExtensionAPI,
|
|
219
|
+
session: GoalSession,
|
|
220
|
+
params: Static<typeof GoalManagerParams>,
|
|
221
|
+
ctx: ExtensionContext,
|
|
222
|
+
) {
|
|
223
|
+
const state = session.state;
|
|
224
|
+
if (!state) {
|
|
225
|
+
throw new Error("Goal 模式未激活。使用 /goal <objective> 启动。");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
switch (params.action) {
|
|
229
|
+
case "create_tasks": {
|
|
230
|
+
if (!params.tasks || params.tasks.length === 0) {
|
|
231
|
+
throw new Error("create_tasks requires a non-empty tasks array");
|
|
232
|
+
}
|
|
233
|
+
const existingIncomplete = getIncompleteTasks(state.tasks);
|
|
234
|
+
if (state.tasks.length > 0 && existingIncomplete.length > 0) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`已有 ${state.tasks.length} 个任务(${existingIncomplete.length} 个未完成)。` +
|
|
237
|
+
`如需追加任务请用 add_tasks,如需全部重新规划请用 /goal update。`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
state.tasks = params.tasks.map((desc, i) => ({
|
|
241
|
+
id: i + 1,
|
|
242
|
+
description: normalizeDescription(desc),
|
|
243
|
+
status: "pending" as const,
|
|
244
|
+
lastUpdatedTurn: state.currentTurnIndex,
|
|
245
|
+
}));
|
|
246
|
+
persistGoalState(pi, session, ctx);
|
|
247
|
+
return makeGoalResult(session,
|
|
248
|
+
`已创建 ${state.tasks.length} 个任务:\n${state.tasks.map((t) => ` #${t.id}: ${t.description}`).join("\n")}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case "add_tasks": {
|
|
253
|
+
if (!params.tasks || params.tasks.length === 0) {
|
|
254
|
+
throw new Error("add_tasks requires a non-empty tasks array");
|
|
255
|
+
}
|
|
256
|
+
const startId = state.tasks.length > 0
|
|
257
|
+
? Math.max(...state.tasks.map((t) => t.id)) + 1
|
|
258
|
+
: 1;
|
|
259
|
+
const newTasks: GoalTask[] = params.tasks.map((desc, i) => ({
|
|
260
|
+
id: startId + i,
|
|
261
|
+
description: normalizeDescription(desc),
|
|
262
|
+
status: "pending" as const,
|
|
263
|
+
lastUpdatedTurn: state.currentTurnIndex,
|
|
264
|
+
}));
|
|
265
|
+
state.tasks.push(...newTasks);
|
|
266
|
+
persistGoalState(pi, session, ctx);
|
|
267
|
+
return makeGoalResult(session,
|
|
268
|
+
`已追加 ${newTasks.length} 个任务:\n${newTasks.map((t) => ` #${t.id}: ${t.description}`).join("\n")}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "update_tasks": {
|
|
273
|
+
if (!params.updates || params.updates.length === 0) {
|
|
274
|
+
throw new Error("update_tasks requires a non-empty updates array");
|
|
275
|
+
}
|
|
276
|
+
const taskIds = params.updates.map((u) => u.taskId);
|
|
277
|
+
const duplicateIds = taskIds.filter((id, i) => taskIds.indexOf(id) !== i);
|
|
278
|
+
if (duplicateIds.length > 0) {
|
|
279
|
+
throw new Error(`重复的 taskId: ${[...new Set(duplicateIds)].join(", ")}`);
|
|
280
|
+
}
|
|
281
|
+
for (const u of params.updates) {
|
|
282
|
+
const task = state.tasks.find((t) => t.id === u.taskId);
|
|
283
|
+
if (!task) {
|
|
284
|
+
throw new Error(`Task #${u.taskId} not found`);
|
|
285
|
+
}
|
|
286
|
+
if (isTerminalTaskStatus(task.status)) {
|
|
287
|
+
throw new Error(`Task #${task.id} 已处于终态 (${task.status}),不可变更`);
|
|
288
|
+
}
|
|
289
|
+
if (u.status === "completed" && (!u.evidence || u.evidence.trim() === "")) {
|
|
290
|
+
throw new Error(`Task #${task.id}: completed 必须提供 evidence`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const results: string[] = [];
|
|
294
|
+
for (const u of params.updates) {
|
|
295
|
+
const task = state.tasks.find((t) => t.id === u.taskId)!;
|
|
296
|
+
const prev = task.status;
|
|
297
|
+
task.lastUpdatedTurn = state.currentTurnIndex;
|
|
298
|
+
if (u.status === "completed") {
|
|
299
|
+
task.status = "completed";
|
|
300
|
+
task.evidence = u.evidence;
|
|
301
|
+
results.push(`#${task.id}: ${prev} → completed (${u.evidence})`);
|
|
302
|
+
} else {
|
|
303
|
+
task.status = u.status;
|
|
304
|
+
results.push(`#${task.id}: ${prev} → ${u.status}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
persistGoalState(pi, session, ctx);
|
|
308
|
+
return makeGoalResult(session, `已更新 ${results.length} 个任务:\n${results.join("\n")}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case "list_tasks": {
|
|
312
|
+
return makeGoalResult(session, formatTaskList(state.tasks));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case "complete_goal": {
|
|
316
|
+
if (!params.evidence || params.evidence.trim() === "") {
|
|
317
|
+
throw new Error("complete_goal requires evidence — 提供具体的证据证明目标已达成");
|
|
318
|
+
}
|
|
319
|
+
if (state.tasks.length === 0) {
|
|
320
|
+
throw new Error("请先使用 create_tasks 创建任务清单,再完成目标。");
|
|
321
|
+
}
|
|
322
|
+
const incomplete = getIncompleteTasks(state.tasks);
|
|
323
|
+
if (incomplete.length > 0) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`还有 ${incomplete.length} 个任务未完成:${incomplete.map((t) => `#${t.id}`).join(", ")}。` +
|
|
326
|
+
`请先完成这些任务或提供理由说明为什么它们不需要完成。`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const completedCount = getCompletedCount(state.tasks);
|
|
330
|
+
if (completedCount === 0) {
|
|
331
|
+
throw new Error("至少需要完成一个任务才能完成目标。全部取消不算达成。");
|
|
332
|
+
}
|
|
333
|
+
state.status = transitionStatus(state.status, "complete");
|
|
334
|
+
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
335
|
+
writeGoalHistoryEntry(pi, session);
|
|
336
|
+
persistGoalState(pi, session, ctx);
|
|
337
|
+
const budgetReport: string[] = [];
|
|
338
|
+
budgetReport.push(`总轮次: ${state.turnCount}`);
|
|
339
|
+
budgetReport.push(`任务完成: ${getCompletedCount(state.tasks)}/${state.tasks.length}`);
|
|
340
|
+
if (state.budget.tokenBudget) {
|
|
341
|
+
budgetReport.push(`Token 消耗: ${state.tokensUsed}/${state.budget.tokenBudget}`);
|
|
342
|
+
}
|
|
343
|
+
const elapsed = getElapsedTimeSeconds(state);
|
|
344
|
+
budgetReport.push(`用时: ${Math.floor(elapsed / SECONDS_PER_MINUTE)}分${Math.floor(elapsed % SECONDS_PER_MINUTE)}秒`);
|
|
345
|
+
return makeGoalResult(session,
|
|
346
|
+
`目标已完成!\n证据: ${params.evidence}\n\n--- Budget Report ---\n${budgetReport.join("\n")}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case "report_blocked": {
|
|
351
|
+
if (!params.reason || params.reason.trim() === "") {
|
|
352
|
+
throw new Error("report_blocked requires reason — 说明阻塞原因");
|
|
353
|
+
}
|
|
354
|
+
state.lastBlockerReason = params.reason;
|
|
355
|
+
state.status = transitionStatus(state.status, "blocked");
|
|
356
|
+
persistGoalState(pi, session, ctx);
|
|
357
|
+
return makeGoalResult(session, `已报告阻塞。原因: ${params.reason}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
case "cancel_goal": {
|
|
361
|
+
if (isTerminalStatus(state.status)) {
|
|
362
|
+
throw new Error(`Goal 已处于终态 (${state.status})。`);
|
|
363
|
+
}
|
|
364
|
+
const reason = params.cancelReason ?? "用户要求取消";
|
|
365
|
+
const goalId = state.goalId;
|
|
366
|
+
state.status = "cancelled";
|
|
367
|
+
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
368
|
+
writeGoalHistoryEntry(pi, session);
|
|
369
|
+
persistGoalState(pi, session, ctx);
|
|
370
|
+
clearGoalSession(session, ctx);
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: "text" as const, text: `Goal 已取消: ${reason}` }],
|
|
373
|
+
details: {
|
|
374
|
+
action: "cancel",
|
|
375
|
+
tasks: [],
|
|
376
|
+
goalId,
|
|
377
|
+
status: "cancelled",
|
|
378
|
+
_render: {
|
|
379
|
+
type: "task-list" as const,
|
|
380
|
+
summary: "已取消",
|
|
381
|
+
data: { items: [], meta: {} },
|
|
382
|
+
},
|
|
383
|
+
} satisfies GoalManagerDetails,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
case "add_subtasks": {
|
|
388
|
+
if (params.taskId === undefined) {
|
|
389
|
+
throw new Error("add_subtasks requires taskId");
|
|
390
|
+
}
|
|
391
|
+
if (!params.texts || params.texts.length === 0) {
|
|
392
|
+
throw new Error("add_subtasks requires a non-empty texts array");
|
|
393
|
+
}
|
|
394
|
+
const parentTask = state.tasks.find((t) => t.id === params.taskId);
|
|
395
|
+
if (!parentTask) {
|
|
396
|
+
throw new Error(`Task #${params.taskId} not found`);
|
|
397
|
+
}
|
|
398
|
+
if (isTerminalTaskStatus(parentTask.status)) {
|
|
399
|
+
throw new Error(`Task #${parentTask.id} 已处于终态 (${parentTask.status}),不能添加 subtask`);
|
|
400
|
+
}
|
|
401
|
+
const subtasks = parentTask.subtasks ?? [];
|
|
402
|
+
const startId = subtasks.length > 0 ? Math.max(...subtasks.map((s) => s.id)) + 1 : 1;
|
|
403
|
+
const trimmed = params.texts.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
404
|
+
if (trimmed.length === 0) {
|
|
405
|
+
throw new Error("texts 中至少需要一个非空字符串");
|
|
406
|
+
}
|
|
407
|
+
const newSubtasks: Subtask[] = trimmed.map((text, i) => ({
|
|
408
|
+
id: startId + i,
|
|
409
|
+
text,
|
|
410
|
+
status: "pending" as const,
|
|
411
|
+
lastUpdatedTurn: state.currentTurnIndex,
|
|
412
|
+
}));
|
|
413
|
+
parentTask.subtasks = [...subtasks, ...newSubtasks];
|
|
414
|
+
persistGoalState(pi, session, ctx);
|
|
415
|
+
return makeGoalResult(session,
|
|
416
|
+
`已给 Task #${parentTask.id} 添加 ${newSubtasks.length} 项 subtask:\n` +
|
|
417
|
+
newSubtasks.map((s) => ` - #${parentTask.id}.${s.id}: ${s.text}`).join("\n"),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
case "update_subtasks": {
|
|
422
|
+
if (params.taskId === undefined) {
|
|
423
|
+
throw new Error("update_subtasks requires taskId");
|
|
424
|
+
}
|
|
425
|
+
if (!params.subUpdates || params.subUpdates.length === 0) {
|
|
426
|
+
throw new Error("update_subtasks requires a non-empty subUpdates array");
|
|
427
|
+
}
|
|
428
|
+
const targetTask = state.tasks.find((t) => t.id === params.taskId);
|
|
429
|
+
if (!targetTask) {
|
|
430
|
+
throw new Error(`Task #${params.taskId} not found`);
|
|
431
|
+
}
|
|
432
|
+
if (!targetTask.subtasks || targetTask.subtasks.length === 0) {
|
|
433
|
+
throw new Error(`Task #${params.taskId} 没有 subtask`);
|
|
434
|
+
}
|
|
435
|
+
const results: string[] = [];
|
|
436
|
+
for (const u of params.subUpdates) {
|
|
437
|
+
const sub = targetTask.subtasks.find((s) => s.id === u.subId);
|
|
438
|
+
if (!sub) {
|
|
439
|
+
throw new Error(`Subtask #${params.taskId}.${u.subId} not found`);
|
|
440
|
+
}
|
|
441
|
+
if (sub.status === "completed") {
|
|
442
|
+
throw new Error(`Subtask #${params.taskId}.${sub.id} 已完成,不可变更`);
|
|
443
|
+
}
|
|
444
|
+
const prev = sub.status;
|
|
445
|
+
sub.status = u.status;
|
|
446
|
+
sub.lastUpdatedTurn = state.currentTurnIndex;
|
|
447
|
+
results.push(`#${params.taskId}.${sub.id}: ${prev} → ${u.status}`);
|
|
448
|
+
}
|
|
449
|
+
persistGoalState(pi, session, ctx);
|
|
450
|
+
return makeGoalResult(session,
|
|
451
|
+
`已更新 ${results.length} 项 subtask:\n${results.join("\n")}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
case "delete_subtasks": {
|
|
456
|
+
if (params.taskId === undefined) {
|
|
457
|
+
throw new Error("delete_subtasks requires taskId");
|
|
458
|
+
}
|
|
459
|
+
if (!params.subIds || params.subIds.length === 0) {
|
|
460
|
+
throw new Error("delete_subtasks requires a non-empty subIds array");
|
|
461
|
+
}
|
|
462
|
+
const delTask = state.tasks.find((t) => t.id === params.taskId);
|
|
463
|
+
if (!delTask) {
|
|
464
|
+
throw new Error(`Task #${params.taskId} not found`);
|
|
465
|
+
}
|
|
466
|
+
if (!delTask.subtasks || delTask.subtasks.length === 0) {
|
|
467
|
+
throw new Error(`Task #${params.taskId} 没有 subtask`);
|
|
468
|
+
}
|
|
469
|
+
const uniqueIds = [...new Set(params.subIds)];
|
|
470
|
+
const missing = uniqueIds.filter((id) => !delTask.subtasks!.some((s) => s.id === id));
|
|
471
|
+
if (missing.length > 0) {
|
|
472
|
+
throw new Error(`Subtask ${missing.map((id) => `#${params.taskId}.${id}`).join(", ")} not found`);
|
|
473
|
+
}
|
|
474
|
+
delTask.subtasks = delTask.subtasks.filter((s) => !uniqueIds.includes(s.id));
|
|
475
|
+
if (delTask.subtasks.length === 0) {
|
|
476
|
+
delTask.subtasks = undefined;
|
|
477
|
+
}
|
|
478
|
+
persistGoalState(pi, session, ctx);
|
|
479
|
+
return makeGoalResult(session,
|
|
480
|
+
`已删除 ${uniqueIds.length} 项 subtask,Task #${params.taskId} 剩余 ${delTask.subtasks?.length ?? 0} 项`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
default:
|
|
485
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget 渲染逻辑 — 状态栏和侧边栏任务面板
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { GoalRuntimeState } from "./state";
|
|
7
|
+
import { getCompletedCount, getElapsedTimeSeconds } from "./state";
|
|
8
|
+
import { getTokenUsagePercent, getTimeUsagePercent, getBudgetColor } from "./budget.js";
|
|
9
|
+
import {
|
|
10
|
+
SECONDS_PER_MINUTE,
|
|
11
|
+
PERCENT_FACTOR,
|
|
12
|
+
PROGRESS_BAR_DEFAULT_WIDTH,
|
|
13
|
+
OBJECTIVE_DISPLAY_LIMIT,
|
|
14
|
+
OBJECTIVE_TRUNCATE_KEEP,
|
|
15
|
+
} from "./constants";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 将多行文本压缩为单行,用于 widget 渲染。
|
|
19
|
+
* 多行 content 泄漏到 widget 会导致 markdown 表格/标题等破坏布局。
|
|
20
|
+
*/
|
|
21
|
+
export function toSingleLine(text: string): string {
|
|
22
|
+
return text.replace(/\r?\n/g, " ").trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ThemeLike {
|
|
26
|
+
fg: (color: ThemeColor, text: string) => string;
|
|
27
|
+
bold: (text: string) => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderProgressBar(pct: number, width: number = PROGRESS_BAR_DEFAULT_WIDTH): string {
|
|
31
|
+
const clamped = Math.min(Math.max(pct, 0), 1);
|
|
32
|
+
const filled = Math.round(clamped * width);
|
|
33
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderStatusLine(state: GoalRuntimeState, th: ThemeLike): string {
|
|
37
|
+
if (state.status === "cancelled") return "";
|
|
38
|
+
|
|
39
|
+
const completedCount = getCompletedCount(state.tasks);
|
|
40
|
+
const total = state.tasks.length;
|
|
41
|
+
|
|
42
|
+
let text = th.fg("accent", `◆ Goal`) + th.fg("muted", ` ${state.turnCount}/${state.budget.maxTurns}`);
|
|
43
|
+
|
|
44
|
+
if (total > 0) {
|
|
45
|
+
text += th.fg("muted", ` | ${completedCount}/${total} 任务`);
|
|
46
|
+
const cancelledCount = state.tasks.filter(t => t.status === "cancelled").length;
|
|
47
|
+
if (cancelledCount > 0) {
|
|
48
|
+
text += th.fg("dim", `, ${cancelledCount} 取消`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Budget indicators
|
|
53
|
+
if (state.budget.tokenBudget && state.budget.tokenBudget > 0) {
|
|
54
|
+
const pct = Math.round(getTokenUsagePercent(state));
|
|
55
|
+
text += th.fg(getBudgetColor(pct), ` | ${pct}% tokens`);
|
|
56
|
+
}
|
|
57
|
+
if (state.budget.timeBudgetMinutes && state.budget.timeBudgetMinutes > 0) {
|
|
58
|
+
const pct = Math.round(getTimeUsagePercent(state));
|
|
59
|
+
text += th.fg(getBudgetColor(pct), ` | ${pct}% time`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (state.stallCount > 0) {
|
|
63
|
+
text += th.fg("warning", ` | ⚠ ${state.stallCount}轮无进展`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Status suffix
|
|
67
|
+
switch (state.status) {
|
|
68
|
+
case "paused":
|
|
69
|
+
text += th.fg("warning", " | ⏸ 暂停");
|
|
70
|
+
break;
|
|
71
|
+
case "blocked":
|
|
72
|
+
text += th.fg("error", " | ⊘ 阻塞");
|
|
73
|
+
break;
|
|
74
|
+
case "complete":
|
|
75
|
+
text += th.fg("success", " | ✓ 完成");
|
|
76
|
+
break;
|
|
77
|
+
case "budget_limited":
|
|
78
|
+
text += th.fg("error", " | ⊗ Token 预算耗尽");
|
|
79
|
+
break;
|
|
80
|
+
case "time_limited":
|
|
81
|
+
text += th.fg("error", " | ⏱ 时间预算耗尽");
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderTerminalStatusLine(state: GoalRuntimeState, th: ThemeLike): string {
|
|
89
|
+
if (state.status === "cancelled") return "";
|
|
90
|
+
|
|
91
|
+
const completedCount = getCompletedCount(state.tasks);
|
|
92
|
+
const total = state.tasks.length;
|
|
93
|
+
|
|
94
|
+
let text = th.fg("accent", "◆ Goal");
|
|
95
|
+
|
|
96
|
+
// 状态后缀
|
|
97
|
+
switch (state.status) {
|
|
98
|
+
case "complete":
|
|
99
|
+
text += th.fg("success", " ✓ 完成");
|
|
100
|
+
break;
|
|
101
|
+
case "budget_limited":
|
|
102
|
+
text += th.fg("error", " ⊗ Token 预算耗尽");
|
|
103
|
+
break;
|
|
104
|
+
case "time_limited":
|
|
105
|
+
text += th.fg("error", " ⏱ 时间预算耗尽");
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
text += th.fg("muted", ` | ${completedCount}/${total} 任务`);
|
|
112
|
+
|
|
113
|
+
// 预算摘要
|
|
114
|
+
if (state.budget.tokenBudget && state.budget.tokenBudget > 0) {
|
|
115
|
+
const pct = Math.round(getTokenUsagePercent(state));
|
|
116
|
+
text += th.fg(getBudgetColor(pct), ` | ${pct}% tokens`);
|
|
117
|
+
}
|
|
118
|
+
if (state.budget.timeBudgetMinutes && state.budget.timeBudgetMinutes > 0) {
|
|
119
|
+
const pct = Math.round(getTimeUsagePercent(state));
|
|
120
|
+
text += th.fg(getBudgetColor(pct), ` | ${pct}% time`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function renderWidgetLines(state: GoalRuntimeState, th: ThemeLike): string[] {
|
|
127
|
+
if (state.status === "cancelled") return [];
|
|
128
|
+
|
|
129
|
+
const total = state.tasks.length;
|
|
130
|
+
|
|
131
|
+
// Header line
|
|
132
|
+
const header = renderStatusLine(state, th);
|
|
133
|
+
const lines: string[] = [header];
|
|
134
|
+
|
|
135
|
+
// Objective (single-line + truncated if too long)
|
|
136
|
+
const objSingleLine = toSingleLine(state.objective);
|
|
137
|
+
const objDisplay = objSingleLine.length > OBJECTIVE_DISPLAY_LIMIT
|
|
138
|
+
? objSingleLine.slice(0, OBJECTIVE_TRUNCATE_KEEP) + "..."
|
|
139
|
+
: objSingleLine;
|
|
140
|
+
lines.push(th.fg("dim", `目标: ${objDisplay}`));
|
|
141
|
+
|
|
142
|
+
// Task list
|
|
143
|
+
if (total === 0) {
|
|
144
|
+
lines.push(th.fg("dim", " 等待创建任务清单..."));
|
|
145
|
+
} else {
|
|
146
|
+
for (const t of state.tasks) {
|
|
147
|
+
const desc = toSingleLine(t.description);
|
|
148
|
+
if (t.status === "completed") {
|
|
149
|
+
lines.push(` ${th.fg("success", "✓")} ${th.fg("dim", `#${t.id}`)} ${th.fg("dim", desc)}`);
|
|
150
|
+
} else if (t.status === "cancelled") {
|
|
151
|
+
lines.push(` ${th.fg("dim", "✗")} ${th.fg("dim", `#${t.id}`)} ${th.fg("dim", desc)}`);
|
|
152
|
+
} else if (t.status === "in_progress") {
|
|
153
|
+
lines.push(` ${th.fg("warning", "●")} ${th.fg("accent", `#${t.id}`)} ${th.fg("text", desc)}`);
|
|
154
|
+
} else {
|
|
155
|
+
lines.push(` ${th.fg("dim", "☐")} ${th.fg("accent", `#${t.id}`)} ${th.fg("text", desc)}`);
|
|
156
|
+
}
|
|
157
|
+
// Sub-todo items
|
|
158
|
+
if (t.subtasks && t.subtasks.length > 0 && t.status !== "cancelled") {
|
|
159
|
+
for (const s of t.subtasks) {
|
|
160
|
+
const subIcon = s.status === "completed"
|
|
161
|
+
? th.fg("success", "✓")
|
|
162
|
+
: s.status === "in_progress"
|
|
163
|
+
? th.fg("warning", "●")
|
|
164
|
+
: th.fg("dim", "○");
|
|
165
|
+
const subText = s.status === "completed" ? th.fg("dim", s.text) : th.fg("muted", s.text);
|
|
166
|
+
lines.push(` ${subIcon} ${th.fg("dim", `${t.id}.${s.id}`)} ${subText}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// P2-8: Budget progress bars
|
|
173
|
+
if (state.budget.tokenBudget && state.budget.tokenBudget > 0) {
|
|
174
|
+
const pct = getTokenUsagePercent(state) / PERCENT_FACTOR;
|
|
175
|
+
lines.push(` Token: ${renderProgressBar(pct)} ${Math.round(pct * PERCENT_FACTOR)}%`);
|
|
176
|
+
}
|
|
177
|
+
if (state.budget.timeBudgetMinutes && state.budget.timeBudgetMinutes > 0) {
|
|
178
|
+
const pct = getTimeUsagePercent(state) / PERCENT_FACTOR;
|
|
179
|
+
const elapsed = getElapsedTimeSeconds(state);
|
|
180
|
+
const mins = Math.floor(elapsed / SECONDS_PER_MINUTE);
|
|
181
|
+
lines.push(` 时间: ${renderProgressBar(pct)} ${mins}/${state.budget.timeBudgetMinutes}分钟`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return lines;
|
|
185
|
+
}
|