@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi /goal Extension — Codex-style persistent goal-driven autonomous loop
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 持久化目标设定,支持 pause/resume/clear/update
|
|
6
|
+
* - Evidence-based completion(完成任务必须提供具体证据)
|
|
7
|
+
* - Token 预算 + 时间预算(含 70%/90% 预警)
|
|
8
|
+
* - Blocked 状态检测(连续 stall 自动阻塞)
|
|
9
|
+
* - Steering 模板化(continuation / budget-limit / objective-updated)
|
|
10
|
+
* - 任务清单追踪
|
|
11
|
+
*
|
|
12
|
+
* 健壮性保障:
|
|
13
|
+
* - goalId snapshot 防止旧回调操作新 goal
|
|
14
|
+
* - 时间累计统一由 persistState 管理,无双写
|
|
15
|
+
* - 防重入保护(hasPendingInjection)
|
|
16
|
+
* - deserializeState 向后兼容旧格式
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext, CustomEntry } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type GoalTask,
|
|
24
|
+
type BudgetConfig,
|
|
25
|
+
DEFAULT_BUDGET,
|
|
26
|
+
createInitialState,
|
|
27
|
+
transitionStatus,
|
|
28
|
+
isTerminalStatus,
|
|
29
|
+
isTerminalTaskStatus,
|
|
30
|
+
isActiveStatus,
|
|
31
|
+
deserializeState,
|
|
32
|
+
getCompletedCount,
|
|
33
|
+
getIncompleteTasks,
|
|
34
|
+
getElapsedTimeSeconds,
|
|
35
|
+
} from "./state";
|
|
36
|
+
|
|
37
|
+
import { parseGoalArgs } from "./commands";
|
|
38
|
+
import {
|
|
39
|
+
continuationPrompt,
|
|
40
|
+
budgetLimitPrompt,
|
|
41
|
+
objectiveUpdatedPrompt,
|
|
42
|
+
contextInjectionPrompt,
|
|
43
|
+
stalenessReminderPrompt,
|
|
44
|
+
} from "./templates";
|
|
45
|
+
|
|
46
|
+
import { renderTerminalStatusLine } from "./widget";
|
|
47
|
+
import { toSingleLine } from "./widget";
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
SECONDS_PER_MINUTE,
|
|
51
|
+
CONTEXT_USAGE_RATIO_LIMIT,
|
|
52
|
+
PERCENT_FACTOR,
|
|
53
|
+
TASK_STALL_TURN_THRESHOLD,
|
|
54
|
+
AUTO_CLEAR_TURNS,
|
|
55
|
+
MAX_HISTORY_ENTRIES,
|
|
56
|
+
OBJECTIVE_DISPLAY_LIMIT,
|
|
57
|
+
OBJECTIVE_TRUNCATE_KEEP,
|
|
58
|
+
} from "./constants";
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
checkBudgetOnTurnEnd,
|
|
62
|
+
checkBudgetOnResume,
|
|
63
|
+
checkProgress,
|
|
64
|
+
} from "./budget.js";
|
|
65
|
+
|
|
66
|
+
import {
|
|
67
|
+
type GoalSession,
|
|
68
|
+
type GoalManagerDetails,
|
|
69
|
+
GoalManagerParams,
|
|
70
|
+
executeGoalAction,
|
|
71
|
+
persistGoalState,
|
|
72
|
+
clearGoalSession,
|
|
73
|
+
updateWidget,
|
|
74
|
+
writeGoalHistoryEntry,
|
|
75
|
+
isGoalEntry,
|
|
76
|
+
HISTORY_ENTRY_TYPE,
|
|
77
|
+
} from "./tool-handler";
|
|
78
|
+
|
|
79
|
+
// ── State Reconstruction ─────────────────────────────
|
|
80
|
+
|
|
81
|
+
function reconstructGoalState(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
|
|
82
|
+
session.state = null;
|
|
83
|
+
const entries = ctx.sessionManager.getEntries();
|
|
84
|
+
|
|
85
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
86
|
+
const entry = entries[i]!;
|
|
87
|
+
if (isGoalEntry(entry)) {
|
|
88
|
+
const data = entry.data as Record<string, unknown> | undefined;
|
|
89
|
+
if (data) {
|
|
90
|
+
try {
|
|
91
|
+
session.state = deserializeState(data);
|
|
92
|
+
} catch {
|
|
93
|
+
// 旧格式 goal-state entry,视为无活跃 goal
|
|
94
|
+
session.state = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!session.state) return;
|
|
102
|
+
|
|
103
|
+
// 非终态 → 恢复为 active(session 重启后 resume)
|
|
104
|
+
if (!isTerminalStatus(session.state.status) && session.state.status !== "paused") {
|
|
105
|
+
session.state.status = "active";
|
|
106
|
+
session.state.timeStartedAt = Date.now();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Entry GC — 标记旧的 goal-state entries 以便清理
|
|
110
|
+
const goalEntryIndices: number[] = [];
|
|
111
|
+
let latestFound = false;
|
|
112
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
113
|
+
const entry = entries[i];
|
|
114
|
+
if (isGoalEntry(entry)) {
|
|
115
|
+
if (!latestFound) {
|
|
116
|
+
latestFound = true;
|
|
117
|
+
} else {
|
|
118
|
+
goalEntryIndices.push(i);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const idx of goalEntryIndices) {
|
|
123
|
+
entries.splice(idx, 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Goal-history entry GC — 保留最近 MAX_HISTORY_ENTRIES 条
|
|
127
|
+
const historyIndices: number[] = [];
|
|
128
|
+
for (let i = 0; i < entries.length; i++) {
|
|
129
|
+
const entry = entries[i]!;
|
|
130
|
+
if (entry.type === "custom" && (entry as CustomEntry).customType === HISTORY_ENTRY_TYPE) {
|
|
131
|
+
historyIndices.push(i);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (historyIndices.length > MAX_HISTORY_ENTRIES) {
|
|
135
|
+
const toDelete = historyIndices.slice(0, historyIndices.length - MAX_HISTORY_ENTRIES);
|
|
136
|
+
for (let i = toDelete.length - 1; i >= 0; i--) {
|
|
137
|
+
entries.splice(toDelete[i]!, 1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Command Handler ───────────────────────────────────
|
|
143
|
+
|
|
144
|
+
async function handleGoalCommand(pi: ExtensionAPI, session: GoalSession, args: string, ctx: ExtensionContext): Promise<void> {
|
|
145
|
+
const parsed = parseGoalArgs(args);
|
|
146
|
+
|
|
147
|
+
switch (parsed.action) {
|
|
148
|
+
case "status": {
|
|
149
|
+
if (!session.state) {
|
|
150
|
+
ctx.ui.notify("Goal 模式未激活。使用 /goal <objective> 启动。", "info");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const completed = getCompletedCount(session.state.tasks);
|
|
154
|
+
const total = session.state.tasks.length;
|
|
155
|
+
const elapsed = getElapsedTimeSeconds(session.state);
|
|
156
|
+
const lines = [
|
|
157
|
+
`目标: ${session.state.objective}`,
|
|
158
|
+
`状态: ${session.state.status}`,
|
|
159
|
+
`轮次: ${session.state.turnCount}/${session.state.budget.maxTurns}`,
|
|
160
|
+
`任务: ${completed}/${total} 完成`,
|
|
161
|
+
`无进展轮数: ${session.state.stallCount}`,
|
|
162
|
+
`已用时间: ${Math.floor(elapsed / SECONDS_PER_MINUTE)}分${Math.floor(elapsed % SECONDS_PER_MINUTE)}秒`,
|
|
163
|
+
session.state.budget.tokenBudget ? `Token: ${session.state.tokensUsed}/${session.state.budget.tokenBudget}` : null,
|
|
164
|
+
`Goal ID: ${session.state.goalId}`,
|
|
165
|
+
].filter(Boolean);
|
|
166
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case "pause": {
|
|
171
|
+
if (!session.state) {
|
|
172
|
+
ctx.ui.notify("Goal 模式未激活。", "warning");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (isTerminalStatus(session.state.status)) {
|
|
176
|
+
ctx.ui.notify(`Goal 已处于终态 (${session.state.status}),无法暂停。`, "warning");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
session.state.status = transitionStatus(session.state.status, "paused");
|
|
180
|
+
persistGoalState(pi, session, ctx);
|
|
181
|
+
updateWidget(session, ctx);
|
|
182
|
+
ctx.ui.notify("Goal 已暂停。使用 /goal resume 恢复。", "info");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "resume": {
|
|
187
|
+
if (!session.state) {
|
|
188
|
+
ctx.ui.notify("Goal 模式未激活。", "warning");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (isTerminalStatus(session.state.status)) {
|
|
192
|
+
ctx.ui.notify(`Goal 已处于终态 (${session.state.status}),无法恢复。`, "warning");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (session.state.status !== "paused" && session.state.status !== "blocked") {
|
|
196
|
+
ctx.ui.notify("Goal 未暂停或阻塞,无需恢复。", "info");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
session.state.status = "active";
|
|
200
|
+
session.state.stallCount = 0;
|
|
201
|
+
session.state.timeStartedAt = Date.now();
|
|
202
|
+
|
|
203
|
+
// Resume 时重检预算(复用 budget.ts 的决策函数)
|
|
204
|
+
const resumeBudgetCheck = checkBudgetOnResume(session.state);
|
|
205
|
+
if (resumeBudgetCheck) {
|
|
206
|
+
const dim = resumeBudgetCheck.dimension;
|
|
207
|
+
session.state.status = transitionStatus(session.state.status, dim === "token" ? "budget_limited" : "time_limited");
|
|
208
|
+
persistGoalState(pi, session, ctx);
|
|
209
|
+
updateWidget(session, ctx);
|
|
210
|
+
ctx.ui.notify(`${dim === "token" ? "Token" : "时间"} 预算已耗尽,无法恢复。使用 /goal clear 清除。`, "warning");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
persistGoalState(pi, session, ctx);
|
|
215
|
+
updateWidget(session, ctx);
|
|
216
|
+
|
|
217
|
+
const incomplete = getIncompleteTasks(session.state.tasks);
|
|
218
|
+
if (incomplete.length > 0) {
|
|
219
|
+
pi.sendUserMessage(
|
|
220
|
+
`Goal 已恢复。继续执行剩余 ${incomplete.length} 个任务。` +
|
|
221
|
+
(session.state.lastBlockerReason ? `
|
|
222
|
+
|
|
223
|
+
上次阻塞原因: ${session.state.lastBlockerReason}。请尝试不同的方法。` : "") +
|
|
224
|
+
`
|
|
225
|
+
|
|
226
|
+
目标: ${session.state.objective}`,
|
|
227
|
+
{ deliverAs: "followUp" },
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
ctx.ui.notify("所有任务已完成。", "info");
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
case "history": {
|
|
236
|
+
const entries = ctx.sessionManager.getEntries();
|
|
237
|
+
const historyEntries = entries.filter(
|
|
238
|
+
(e) => e.type === "custom" && (e as CustomEntry).customType === HISTORY_ENTRY_TYPE,
|
|
239
|
+
) as Array<CustomEntry<{
|
|
240
|
+
goalId: string;
|
|
241
|
+
objective: string;
|
|
242
|
+
status: string;
|
|
243
|
+
completedTasks: number;
|
|
244
|
+
totalTasks: number;
|
|
245
|
+
elapsedSeconds: number;
|
|
246
|
+
timestamp: number;
|
|
247
|
+
}>>;
|
|
248
|
+
|
|
249
|
+
if (historyEntries.length === 0) {
|
|
250
|
+
ctx.ui.notify("暂无历史 Goal", "info");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 按时间倒序
|
|
255
|
+
const sorted = [...historyEntries].reverse();
|
|
256
|
+
const lines: string[] = ["历史 Goal:\n"];
|
|
257
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
258
|
+
const h = sorted[i]!.data;
|
|
259
|
+
if (!h) continue;
|
|
260
|
+
const statusIcon =
|
|
261
|
+
h.status === "complete" ? "✓" :
|
|
262
|
+
h.status === "cancelled" ? "✗" :
|
|
263
|
+
h.status === "budget_limited" ? "⊗" :
|
|
264
|
+
h.status === "time_limited" ? "⏱" : "?";
|
|
265
|
+
const objDisplay = h.objective.length > OBJECTIVE_DISPLAY_LIMIT
|
|
266
|
+
? h.objective.slice(0, OBJECTIVE_TRUNCATE_KEEP) + "..."
|
|
267
|
+
: h.objective;
|
|
268
|
+
const mins = Math.floor(h.elapsedSeconds / SECONDS_PER_MINUTE);
|
|
269
|
+
const secs = Math.floor(h.elapsedSeconds % SECONDS_PER_MINUTE);
|
|
270
|
+
lines.push(`${i + 1}. ${statusIcon} ${objDisplay}`);
|
|
271
|
+
lines.push(` ${h.completedTasks}/${h.totalTasks} 任务 | ${mins}分${secs}秒 | ${h.status}`);
|
|
272
|
+
}
|
|
273
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
case "clear": {
|
|
278
|
+
if (!session.state) {
|
|
279
|
+
ctx.ui.notify("Goal 模式未激活。", "info");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
session.state.status = "cancelled";
|
|
283
|
+
session.state.completedAtTurnIndex = session.state.currentTurnIndex;
|
|
284
|
+
writeGoalHistoryEntry(pi, session);
|
|
285
|
+
persistGoalState(pi, session, ctx);
|
|
286
|
+
clearGoalSession(session, ctx);
|
|
287
|
+
ctx.ui.notify("Goal 已清除。", "info");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case "update": {
|
|
292
|
+
if (!session.state) {
|
|
293
|
+
ctx.ui.notify("Goal 模式未激活。", "warning");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!parsed.objective) {
|
|
297
|
+
ctx.ui.notify("用法: /goal update <new-objective>", "warning");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const oldObjective = session.state.objective;
|
|
301
|
+
session.state.objective = parsed.objective;
|
|
302
|
+
session.state.objectiveUpdatedAt = Date.now();
|
|
303
|
+
session.state.tasks = [];
|
|
304
|
+
session.state.stallCount = 0;
|
|
305
|
+
session.state.turnCount = 0;
|
|
306
|
+
session.state.lastProgressTurn = 0;
|
|
307
|
+
session.state.budgetLimitSteeringSent = false;
|
|
308
|
+
session.state.budgetWarning70Sent = false;
|
|
309
|
+
session.state.budgetWarning90Sent = false;
|
|
310
|
+
session.tasksCompletedAtAgentStart = 0;
|
|
311
|
+
persistGoalState(pi, session, ctx);
|
|
312
|
+
updateWidget(session, ctx);
|
|
313
|
+
ctx.ui.notify(`目标已更新:\n旧: ${oldObjective}\n新: ${parsed.objective}`, "info");
|
|
314
|
+
|
|
315
|
+
if (isActiveStatus(session.state.status)) {
|
|
316
|
+
pi.sendUserMessage(objectiveUpdatedPrompt(session.state, oldObjective), { deliverAs: "steer" });
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case "set": {
|
|
322
|
+
if (!parsed.objective) {
|
|
323
|
+
ctx.ui.notify("用法: /goal <objective> [--tokens N] [--timeout N]", "warning");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!parsed.objective.trim()) {
|
|
327
|
+
ctx.ui.notify("目标描述不能为空。", "warning");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (session.state && !isTerminalStatus(session.state.status)) {
|
|
331
|
+
ctx.ui.notify(
|
|
332
|
+
`已取消旧 Goal: ${session.state.objective}\n(新目标已启动)`,
|
|
333
|
+
"info",
|
|
334
|
+
);
|
|
335
|
+
session.state.status = "cancelled";
|
|
336
|
+
session.state.completedAtTurnIndex = session.state.currentTurnIndex;
|
|
337
|
+
writeGoalHistoryEntry(pi, session);
|
|
338
|
+
persistGoalState(pi, session, ctx);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (parsed.budget?.tokenBudget !== undefined && parsed.budget.tokenBudget <= 0) {
|
|
342
|
+
ctx.ui.notify("Token 预算必须大于 0。", "warning");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const budget: Partial<BudgetConfig> = {};
|
|
346
|
+
if (parsed.budget?.tokenBudget) budget.tokenBudget = parsed.budget.tokenBudget;
|
|
347
|
+
if (parsed.budget?.timeBudgetMinutes) budget.timeBudgetMinutes = parsed.budget.timeBudgetMinutes;
|
|
348
|
+
budget.maxTurns = parsed.budget?.maxTurns ?? DEFAULT_BUDGET.maxTurns;
|
|
349
|
+
budget.maxStallTurns = parsed.budget?.maxStallTurns ?? DEFAULT_BUDGET.maxStallTurns;
|
|
350
|
+
|
|
351
|
+
session.state = createInitialState(parsed.objective, budget);
|
|
352
|
+
session.tasksCompletedAtAgentStart = 0;
|
|
353
|
+
session.hasPendingInjection = false;
|
|
354
|
+
|
|
355
|
+
persistGoalState(pi, session, ctx);
|
|
356
|
+
updateWidget(session, ctx);
|
|
357
|
+
|
|
358
|
+
const budgetNotice: string[] = [];
|
|
359
|
+
if (budget.tokenBudget) budgetNotice.push(`Token 预算: ${budget.tokenBudget}`);
|
|
360
|
+
if (budget.timeBudgetMinutes) budgetNotice.push(`时间预算: ${budget.timeBudgetMinutes} 分钟`);
|
|
361
|
+
const notice = [
|
|
362
|
+
`Goal 已启动: ${parsed.objective}`,
|
|
363
|
+
`最大轮次: ${budget.maxTurns}`,
|
|
364
|
+
...budgetNotice,
|
|
365
|
+
].join("\n");
|
|
366
|
+
ctx.ui.notify(notice, "info");
|
|
367
|
+
|
|
368
|
+
pi.sendUserMessage(parsed.objective, { deliverAs: "followUp" });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Event: before_agent_start Handler ─────────────────
|
|
375
|
+
|
|
376
|
+
async function handleBeforeAgentStart(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext) {
|
|
377
|
+
if (!session.state) return;
|
|
378
|
+
|
|
379
|
+
// 终态处理:自动清理或折叠 status bar
|
|
380
|
+
if (isTerminalStatus(session.state.status)) {
|
|
381
|
+
const state = session.state;
|
|
382
|
+
const turnsInTerminal = state.currentTurnIndex - (state.completedAtTurnIndex ?? 0);
|
|
383
|
+
|
|
384
|
+
if (turnsInTerminal >= AUTO_CLEAR_TURNS) {
|
|
385
|
+
clearGoalSession(session, ctx);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 折叠 status bar(不渲染 task 列表)
|
|
390
|
+
const statusText = renderTerminalStatusLine(state, ctx.ui.theme);
|
|
391
|
+
if (statusText) {
|
|
392
|
+
ctx.ui.setStatus("goal", statusText);
|
|
393
|
+
}
|
|
394
|
+
ctx.ui.setWidget("goal", undefined);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!isActiveStatus(session.state.status)) return;
|
|
399
|
+
|
|
400
|
+
session.hasPendingInjection = true;
|
|
401
|
+
|
|
402
|
+
// 停滞检查
|
|
403
|
+
const state = session.state;
|
|
404
|
+
const staleTasks: Array<{
|
|
405
|
+
task: GoalTask;
|
|
406
|
+
staleTurns: number;
|
|
407
|
+
staleSubtasks: Array<{ text: string; staleTurns: number }>;
|
|
408
|
+
}> = [];
|
|
409
|
+
let allTerminal = true;
|
|
410
|
+
|
|
411
|
+
for (const task of state.tasks) {
|
|
412
|
+
if (!isTerminalTaskStatus(task.status)) {
|
|
413
|
+
allTerminal = false;
|
|
414
|
+
const staleTurns = state.currentTurnIndex - task.lastUpdatedTurn;
|
|
415
|
+
if (staleTurns >= TASK_STALL_TURN_THRESHOLD) {
|
|
416
|
+
const staleSubtasks: Array<{ text: string; staleTurns: number }> = [];
|
|
417
|
+
if (task.subtasks) {
|
|
418
|
+
for (const s of task.subtasks) {
|
|
419
|
+
if (s.status !== "completed") {
|
|
420
|
+
const subStale = state.currentTurnIndex - s.lastUpdatedTurn;
|
|
421
|
+
if (subStale >= TASK_STALL_TURN_THRESHOLD) {
|
|
422
|
+
staleSubtasks.push({ text: s.text, staleTurns: subStale });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
staleTasks.push({ task, staleTurns, staleSubtasks });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 边界:所有 task 已终态但 goal 仍 active
|
|
433
|
+
if (allTerminal && state.tasks.length > 0) {
|
|
434
|
+
return {
|
|
435
|
+
message: {
|
|
436
|
+
customType: "goal-staleness-reminder",
|
|
437
|
+
content: stalenessReminderPrompt(state, [], true),
|
|
438
|
+
display: false,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 有停滞项 → 注入提醒
|
|
444
|
+
if (staleTasks.length > 0) {
|
|
445
|
+
// 重置被提醒项的 lastUpdatedTurn
|
|
446
|
+
for (const item of staleTasks) {
|
|
447
|
+
item.task.lastUpdatedTurn = state.currentTurnIndex;
|
|
448
|
+
if (item.task.subtasks) {
|
|
449
|
+
for (const s of item.task.subtasks) {
|
|
450
|
+
if (s.status !== "completed") {
|
|
451
|
+
s.lastUpdatedTurn = state.currentTurnIndex;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
message: {
|
|
459
|
+
customType: "goal-staleness-reminder",
|
|
460
|
+
content: stalenessReminderPrompt(state, staleTasks, false),
|
|
461
|
+
display: false,
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 无停滞 → 继续原有 context injection
|
|
467
|
+
const usage = ctx.getContextUsage();
|
|
468
|
+
if (usage && usage.contextWindow > 0 && (usage.tokens ?? 0) / usage.contextWindow > CONTEXT_USAGE_RATIO_LIMIT) {
|
|
469
|
+
session.state.status = transitionStatus(session.state.status, "paused");
|
|
470
|
+
persistGoalState(pi, session, ctx);
|
|
471
|
+
updateWidget(session, ctx);
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
message: {
|
|
475
|
+
customType: "goal-context-exceeded",
|
|
476
|
+
content:
|
|
477
|
+
"[GOAL — 上下文空间不足,必须立即收尾]\n" +
|
|
478
|
+
"1. 用 goal_manager 的 list_tasks 查看剩余任务\n" +
|
|
479
|
+
"2. 只标记你真正完成且有证据的任务\n" +
|
|
480
|
+
"3. 总结当前进度和剩余工作\n" +
|
|
481
|
+
"不要再开始新任务。",
|
|
482
|
+
display: false,
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
message: {
|
|
489
|
+
customType: "goal-context",
|
|
490
|
+
content: contextInjectionPrompt(session.state),
|
|
491
|
+
display: false,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Event: agent_end Handler ──────────────────────────
|
|
497
|
+
|
|
498
|
+
async function handleAgentEnd(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): Promise<void> {
|
|
499
|
+
if (!session.state) return;
|
|
500
|
+
|
|
501
|
+
const snapshotGoalId = session.state.goalId;
|
|
502
|
+
const checkStale = () => !session.state || session.state.goalId !== snapshotGoalId;
|
|
503
|
+
|
|
504
|
+
// 终态处理:complete / blocked 只需 persist + notify
|
|
505
|
+
if (session.state.status === "complete") {
|
|
506
|
+
persistGoalState(pi, session, ctx);
|
|
507
|
+
if (checkStale()) return;
|
|
508
|
+
updateWidget(session, ctx);
|
|
509
|
+
ctx.ui.notify(
|
|
510
|
+
`目标已完成 ✓ (${getCompletedCount(session.state.tasks)}/${session.state.tasks.length} 任务, ${session.state.turnCount} 轮)`,
|
|
511
|
+
"info",
|
|
512
|
+
);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (session.state.status === "blocked") {
|
|
517
|
+
persistGoalState(pi, session, ctx);
|
|
518
|
+
if (checkStale()) return;
|
|
519
|
+
updateWidget(session, ctx);
|
|
520
|
+
ctx.ui.notify("Goal 被阻塞。使用 /goal resume 恢复或 /goal clear 清除。", "warning");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!isActiveStatus(session.state.status)) return;
|
|
525
|
+
|
|
526
|
+
// 防重入
|
|
527
|
+
if (session.hasPendingInjection) {
|
|
528
|
+
session.hasPendingInjection = false;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (checkStale()) return;
|
|
533
|
+
|
|
534
|
+
// ── 预算策略(集中检查)──
|
|
535
|
+
|
|
536
|
+
const budgetResult = checkBudgetOnTurnEnd(session.state);
|
|
537
|
+
|
|
538
|
+
// 发送预警
|
|
539
|
+
for (const w of budgetResult.warnings) {
|
|
540
|
+
if (w.type === "warning90") {
|
|
541
|
+
session.state.budgetWarning90Sent = true;
|
|
542
|
+
ctx.ui.notify(`${w.dimension === "token" ? "Token" : "时间"} 预算已用 90%,请开始收尾。`, "warning");
|
|
543
|
+
} else if (w.type === "warning70") {
|
|
544
|
+
session.state.budgetWarning70Sent = true;
|
|
545
|
+
ctx.ui.notify(`${w.dimension === "token" ? "Token" : "时间"} 预算已用 70%,注意控制范围。`, "info");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 预算耗尽 → 终止
|
|
550
|
+
if (budgetResult.terminal) {
|
|
551
|
+
const dim = budgetResult.terminal.dimension;
|
|
552
|
+
session.state.status = transitionStatus(session.state.status, dim === "token" ? "budget_limited" : "time_limited");
|
|
553
|
+
session.state.completedAtTurnIndex = session.state.currentTurnIndex;
|
|
554
|
+
writeGoalHistoryEntry(pi, session);
|
|
555
|
+
persistGoalState(pi, session, ctx);
|
|
556
|
+
if (checkStale()) return;
|
|
557
|
+
updateWidget(session, ctx);
|
|
558
|
+
ctx.ui.notify(
|
|
559
|
+
dim === "token"
|
|
560
|
+
? "Token 预算已耗尽,Goal 已终止。"
|
|
561
|
+
: `时间预算耗尽 (${session.state.budget.timeBudgetMinutes} 分钟),Goal 已终止。`,
|
|
562
|
+
"warning",
|
|
563
|
+
);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 90% steering → 发送收尾指令
|
|
568
|
+
if (budgetResult.shouldSendSteering) {
|
|
569
|
+
session.state.budgetLimitSteeringSent = true;
|
|
570
|
+
persistGoalState(pi, session, ctx);
|
|
571
|
+
if (checkStale()) return;
|
|
572
|
+
updateWidget(session, ctx);
|
|
573
|
+
pi.sendUserMessage(budgetLimitPrompt(session.state, "token"), { deliverAs: "steer" });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (checkStale()) return;
|
|
578
|
+
|
|
579
|
+
// ── Turn 递增 + 进展评估 ──
|
|
580
|
+
|
|
581
|
+
session.state.turnCount++;
|
|
582
|
+
|
|
583
|
+
const progress = checkProgress(session.state, session.tasksCompletedAtAgentStart);
|
|
584
|
+
|
|
585
|
+
// 所有任务完成 → 提示 complete_goal
|
|
586
|
+
if (progress.allTasksDone) {
|
|
587
|
+
if (progress.maxTurnsReached) {
|
|
588
|
+
session.state.status = transitionStatus(session.state.status, "complete");
|
|
589
|
+
session.state.completedAtTurnIndex = session.state.currentTurnIndex;
|
|
590
|
+
writeGoalHistoryEntry(pi, session);
|
|
591
|
+
persistGoalState(pi, session, ctx);
|
|
592
|
+
if (checkStale()) return;
|
|
593
|
+
updateWidget(session, ctx);
|
|
594
|
+
ctx.ui.notify(
|
|
595
|
+
`所有任务已完成,Goal 自动结束。(${progress.completedCount}/${progress.totalCount} 任务, ${session.state.turnCount} 轮)`,
|
|
596
|
+
"info",
|
|
597
|
+
);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (progress.budgetTight) {
|
|
602
|
+
pi.sendUserMessage(
|
|
603
|
+
`所有任务已完成,且 token 预算已用 ${Math.round(session.state.tokensUsed / session.state.budget.tokenBudget! * PERCENT_FACTOR)}%。` +
|
|
604
|
+
`请立即调用 goal_manager 的 complete_goal 完成目标,提供整体 evidence。` +
|
|
605
|
+
`\n\n目标: ${session.state.objective}`,
|
|
606
|
+
{ deliverAs: "steer" },
|
|
607
|
+
);
|
|
608
|
+
} else {
|
|
609
|
+
pi.sendUserMessage(
|
|
610
|
+
`所有 ${progress.totalCount} 个任务已完成。请调用 goal_manager 的 complete_goal 完成目标,提供整体 evidence。` +
|
|
611
|
+
`\n\n目标: ${session.state.objective}`,
|
|
612
|
+
{ deliverAs: "followUp" },
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
persistGoalState(pi, session, ctx);
|
|
616
|
+
updateWidget(session, ctx);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 没有任务创建 → 提醒 create_tasks
|
|
621
|
+
if (progress.noTasksCreated) {
|
|
622
|
+
if (progress.maxTurnsReached) {
|
|
623
|
+
session.state.status = transitionStatus(session.state.status, "cancelled");
|
|
624
|
+
session.state.completedAtTurnIndex = session.state.currentTurnIndex;
|
|
625
|
+
writeGoalHistoryEntry(pi, session);
|
|
626
|
+
persistGoalState(pi, session, ctx);
|
|
627
|
+
if (checkStale()) return;
|
|
628
|
+
updateWidget(session, ctx);
|
|
629
|
+
ctx.ui.notify(
|
|
630
|
+
`已达最大轮次 (${session.state.budget.maxTurns}),LLM 未创建任务清单。`,
|
|
631
|
+
"warning",
|
|
632
|
+
);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
pi.sendUserMessage(
|
|
636
|
+
`你尚未创建任务清单。请立即调用 goal_manager 的 create_tasks 将工作拆分为具体可验证的任务步骤。` +
|
|
637
|
+
`\n\n目标: ${session.state.objective}`,
|
|
638
|
+
{ deliverAs: "followUp" },
|
|
639
|
+
);
|
|
640
|
+
persistGoalState(pi, session, ctx);
|
|
641
|
+
updateWidget(session, ctx);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 最大轮次 → 取消
|
|
646
|
+
if (progress.maxTurnsReached) {
|
|
647
|
+
const incomplete = getIncompleteTasks(session.state.tasks);
|
|
648
|
+
session.state.status = transitionStatus(session.state.status, "cancelled");
|
|
649
|
+
session.state.completedAtTurnIndex = session.state.currentTurnIndex;
|
|
650
|
+
writeGoalHistoryEntry(pi, session);
|
|
651
|
+
persistGoalState(pi, session, ctx);
|
|
652
|
+
if (checkStale()) return;
|
|
653
|
+
updateWidget(session, ctx);
|
|
654
|
+
ctx.ui.notify(
|
|
655
|
+
`已达最大轮次 (${session.state.budget.maxTurns}),还有 ${incomplete.length} 个任务未完成。`,
|
|
656
|
+
"warning",
|
|
657
|
+
);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Stall 检测
|
|
662
|
+
if (progress.isStalled) {
|
|
663
|
+
session.state.stallCount++;
|
|
664
|
+
} else {
|
|
665
|
+
session.state.stallCount = 0;
|
|
666
|
+
session.state.lastProgressTurn = session.state.turnCount;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (session.state.stallCount >= session.state.budget.maxStallTurns) {
|
|
670
|
+
session.state.status = transitionStatus(session.state.status, "blocked");
|
|
671
|
+
persistGoalState(pi, session, ctx);
|
|
672
|
+
if (checkStale()) return;
|
|
673
|
+
updateWidget(session, ctx);
|
|
674
|
+
ctx.ui.notify(
|
|
675
|
+
`已连续 ${session.state.stallCount} 轮无进展,Goal 自动阻塞。使用 /goal resume 恢复或 /goal clear 清除。`,
|
|
676
|
+
"warning",
|
|
677
|
+
);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (checkStale()) return;
|
|
682
|
+
|
|
683
|
+
// 去抖:本 turn 无 token 消耗则不发 continuation
|
|
684
|
+
const tokenDelta = session.state.tokensUsed - session.state.lastTurnTokensUsed;
|
|
685
|
+
session.state.lastTurnTokensUsed = session.state.tokensUsed;
|
|
686
|
+
|
|
687
|
+
if (tokenDelta === 0) {
|
|
688
|
+
persistGoalState(pi, session, ctx);
|
|
689
|
+
updateWidget(session, ctx);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Normal continuation
|
|
694
|
+
persistGoalState(pi, session, ctx);
|
|
695
|
+
updateWidget(session, ctx);
|
|
696
|
+
|
|
697
|
+
pi.sendUserMessage(continuationPrompt(session.state), { deliverAs: "followUp" });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ── Extension Factory ─────────────────────────────────
|
|
701
|
+
|
|
702
|
+
export default function goalExtension(pi: ExtensionAPI) {
|
|
703
|
+
const session: GoalSession = {
|
|
704
|
+
state: null,
|
|
705
|
+
tasksCompletedAtAgentStart: 0,
|
|
706
|
+
hasPendingInjection: false,
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// ── Tool: goal_manager ─────────────────────────────
|
|
710
|
+
|
|
711
|
+
pi.registerTool({
|
|
712
|
+
name: "goal_manager",
|
|
713
|
+
label: "Goal Manager",
|
|
714
|
+
description:
|
|
715
|
+
"Goal 模式任务管理器。此工具仅在用户通过 /goal 命令启动目标后才可用,AI 不能主动触发此功能。如果 Goal 模式未激活,调用此工具会报错。" +
|
|
716
|
+
"\n\n可用 action:" +
|
|
717
|
+
"\n- create_tasks: 首次拆分目标为任务清单(每个 goal 开始时调用一次)。每条 task description 必须是一行简短摘要(不超过 60 字),不要包含换行、markdown、详细参数" +
|
|
718
|
+
"\n- add_tasks: 向已有任务清单追加新任务(执行中发现遗漏时使用)。每条 task description 必须是一行简短摘要(不超过 60 字),不要包含换行、markdown、详细参数" +
|
|
719
|
+
"\n- update_tasks: 批量更新任务状态(completed 必须带 evidence,cancelled 不阻碍 goal 完成)" +
|
|
720
|
+
"\n- list_tasks: 查看进度和剩余预算" +
|
|
721
|
+
"\n- complete_goal: 标记目标达成(必须所有任务完成 + evidence)" +
|
|
722
|
+
"\n- cancel_goal: 取消当前目标(用户要求退出/停止时使用)" +
|
|
723
|
+
"\n- report_blocked: 报告阻塞(遇到无法解决的问题时使用)" +
|
|
724
|
+
"\n- add_subtasks: 给指定 task 添加 subtask(参数: taskId, texts[])。Goal 模式下用此替代 todo 工具" +
|
|
725
|
+
"\n- update_subtasks: 批量更新 subtask 状态(参数: taskId, subUpdates[])" +
|
|
726
|
+
"\n- delete_subtasks: 删除指定 task 的 subtask(参数: taskId, subIds[])",
|
|
727
|
+
promptSnippet: "管理 /goal 模式的任务清单、完成状态和退出",
|
|
728
|
+
promptGuidelines: [
|
|
729
|
+
"[工作流] 收到目标后,第一步必须调用 create_tasks 拆分任务。已有任务清单时不要重复调用",
|
|
730
|
+
"[格式] 每个 task description 必须是一行简短摘要,不超过 60 个字符。不要包含换行符、markdown 格式、详细参数列表——这些放在执行阶段处理。示例: '修复 hook-registry 去重逻辑' 而不是 '修复 hook-registry 去重逻辑 + transport-execute enhancementConfig 防护 + failover-loop ...'",
|
|
731
|
+
"[追加] 执行中发现遗漏的子任务时,使用 add_tasks 追加,不要尝试重新 create_tasks",
|
|
732
|
+
"[完成] 每完成一个任务调用 update_tasks 将状态设为 completed,必须提供 evidence(具体证据,如'测试 X 通过'、'文件 F 已创建')",
|
|
733
|
+
"[目标完成] 只有所有任务完成且有整体证据时,才能调用 complete_goal",
|
|
734
|
+
"[退出] 当用户说'停止'、'退出'、'取消'、'stop'、'exit'、'cancel'、'不用了'、'结束'等表示不想继续时,立即调用 cancel_goal 取消目标,不要引导用户走 complete_goal 流程",
|
|
735
|
+
"[阻塞] 遇到无法解决的技术问题时调用 report_blocked 说明原因",
|
|
736
|
+
"[进度] 随时可用 list_tasks 查看剩余任务和预算,",
|
|
737
|
+
"[取消] 取消任务时使用 update_tasks 将状态设为 cancelled,取消的任务不阻碍 goal 完成",
|
|
738
|
+
"[禁止] 不要在没有 evidence 的情况下将任务标记为 completed,也不要在没有 evidence 时调用 complete_goal",
|
|
739
|
+
"[禁止] 不要在用户明确想退出时强制要求完成任务——直接 cancel_goal",
|
|
740
|
+
"[禁止] 不要重复调用 create_tasks 覆盖已有未完成任务,如需追加请用 add_tasks",
|
|
741
|
+
"[subtask] Goal 模式下需要细粒度步骤追踪时,使用 add_subtasks 给 task 添加 subtask,不要使用 todo 工具",
|
|
742
|
+
],
|
|
743
|
+
parameters: GoalManagerParams,
|
|
744
|
+
|
|
745
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
746
|
+
try {
|
|
747
|
+
return await executeGoalAction(pi, session, params, ctx);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
750
|
+
const inputSummary = JSON.stringify(params, null, 2);
|
|
751
|
+
throw new Error(`${msg}\n\nInput: ${inputSummary}`);
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
renderCall(args, theme) {
|
|
756
|
+
let text = theme.fg("toolTitle", theme.bold("goal_manager ")) + theme.fg("muted", args.action);
|
|
757
|
+
if (args.tasks) text += ` ${theme.fg("dim", `(${args.tasks.length} tasks)`)}`;
|
|
758
|
+
if (args.updates) text += ` ${theme.fg("dim", `(${args.updates.length} updates)`)}`;
|
|
759
|
+
if (args.taskId !== undefined) text += ` ${theme.fg("accent", `#${args.taskId}`)}`;
|
|
760
|
+
if (args.texts) text += ` ${theme.fg("dim", `(${args.texts.length} subtasks)`)}`;
|
|
761
|
+
if (args.subUpdates) text += ` ${theme.fg("dim", `(${args.subUpdates.length} subtask updates)`)}`;
|
|
762
|
+
if (args.subIds) text += ` ${theme.fg("dim", `del #${args.subIds.join(",")}`)}`;
|
|
763
|
+
return new Text(text, 0, 0);
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
renderResult(result, { expanded }, theme) {
|
|
767
|
+
const details = result.details as GoalManagerDetails | undefined;
|
|
768
|
+
if (!details || !Array.isArray(details.tasks)) {
|
|
769
|
+
const text = result.content[0];
|
|
770
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
771
|
+
}
|
|
772
|
+
const tasks = details.tasks;
|
|
773
|
+
const completed = tasks.filter((t) => t.status === "completed").length;
|
|
774
|
+
const summary = theme.fg("success", `✓ ${completed}/${tasks.length} 完成`);
|
|
775
|
+
if (!expanded || tasks.length === 0) {
|
|
776
|
+
return new Text(summary, 0, 0);
|
|
777
|
+
}
|
|
778
|
+
const lines = [summary];
|
|
779
|
+
for (const t of tasks) {
|
|
780
|
+
const icon = t.status === "completed"
|
|
781
|
+
? theme.fg("success", "✓")
|
|
782
|
+
: t.status === "in_progress"
|
|
783
|
+
? theme.fg("warning", "●")
|
|
784
|
+
: t.status === "cancelled"
|
|
785
|
+
? theme.fg("dim", "✗")
|
|
786
|
+
: theme.fg("dim", "☐");
|
|
787
|
+
const descText = toSingleLine(t.description);
|
|
788
|
+
const desc = (t.status === "completed" || t.status === "cancelled")
|
|
789
|
+
? theme.fg("dim", descText)
|
|
790
|
+
: theme.fg("text", descText);
|
|
791
|
+
lines.push(` ${icon} ${theme.fg("accent", `#${t.id}`)} ${desc}`);
|
|
792
|
+
// Subtask items in expanded view
|
|
793
|
+
if (t.subtasks && t.subtasks.length > 0) {
|
|
794
|
+
for (const s of t.subtasks) {
|
|
795
|
+
const subIcon = s.status === "completed"
|
|
796
|
+
? theme.fg("success", "\u2713")
|
|
797
|
+
: s.status === "in_progress"
|
|
798
|
+
? theme.fg("warning", "\u25cf")
|
|
799
|
+
: theme.fg("dim", "\u25cb");
|
|
800
|
+
const subText = s.status === "completed" ? theme.fg("dim", s.text) : theme.fg("muted", s.text);
|
|
801
|
+
lines.push(` ${subIcon} ${theme.fg("dim", `${t.id}.${s.id}`)} ${subText}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// ── Command: /goal ─────────────────────────────────
|
|
810
|
+
|
|
811
|
+
pi.registerCommand("goal", {
|
|
812
|
+
description:
|
|
813
|
+
"目标驱动模式: /goal <objective> [--tokens N] [--timeout N] [--max-turns N] | /goal pause | /goal resume | /goal clear | /goal update <new-objective> | /goal status | /goal history",
|
|
814
|
+
handler: async (args, ctx) => {
|
|
815
|
+
await handleGoalCommand(pi, session, args, ctx);
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// ── Event: before_agent_start ──────────────────────
|
|
820
|
+
|
|
821
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
822
|
+
return handleBeforeAgentStart(pi, session, ctx);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// ── Event: agent_start ─────────────────────────────
|
|
826
|
+
|
|
827
|
+
pi.on("agent_start", async () => {
|
|
828
|
+
if (!session.state || !isActiveStatus(session.state.status)) return;
|
|
829
|
+
session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// ── Event: turn_end ────────────────────────────────
|
|
833
|
+
|
|
834
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
835
|
+
if (!session.state) return;
|
|
836
|
+
session.state.currentTurnIndex++;
|
|
837
|
+
updateWidget(session, ctx);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// ── Event: message_end (token accounting) ──────────
|
|
841
|
+
|
|
842
|
+
pi.on("message_end", async (event, _ctx) => {
|
|
843
|
+
if (!session.state || !isActiveStatus(session.state.status)) return;
|
|
844
|
+
if (event.message.role !== "assistant") return;
|
|
845
|
+
|
|
846
|
+
const usage = event.message.usage;
|
|
847
|
+
if (usage) {
|
|
848
|
+
const input = usage.input ?? 0;
|
|
849
|
+
const output = usage.output ?? 0;
|
|
850
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
851
|
+
if (input > 0 || output > 0) {
|
|
852
|
+
session.state.tokensUsed += Math.max(input - cacheRead, 0) + output;
|
|
853
|
+
} else if (usage.totalTokens) {
|
|
854
|
+
session.state.tokensUsed += usage.totalTokens;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// ── Event: agent_end ───────────────────────────────
|
|
860
|
+
|
|
861
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
862
|
+
await handleAgentEnd(pi, session, ctx);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// ── Event: session_start (state reconstruction) ───
|
|
866
|
+
|
|
867
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
868
|
+
reconstructGoalState(pi, session, ctx);
|
|
869
|
+
if (session.state) {
|
|
870
|
+
session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
|
|
871
|
+
updateWidget(session, ctx);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// ── Message Renderers ──────────────────────────────
|
|
876
|
+
|
|
877
|
+
const goalMessageTypes = [
|
|
878
|
+
"goal-context",
|
|
879
|
+
"goal-context-exceeded",
|
|
880
|
+
"goal-staleness-reminder",
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
for (const customType of goalMessageTypes) {
|
|
884
|
+
pi.registerMessageRenderer(customType, (message, _options, theme) => {
|
|
885
|
+
const prefix =
|
|
886
|
+
message.customType === "goal-context-exceeded"
|
|
887
|
+
? theme.fg("error", "[GOAL 预算] ")
|
|
888
|
+
: message.customType === "goal-staleness-reminder"
|
|
889
|
+
? theme.fg("warning", "[GOAL 提醒] ")
|
|
890
|
+
: theme.fg("accent", "[GOAL] ");
|
|
891
|
+
const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
892
|
+
return new Text(prefix + theme.fg("dim", content), 0, 0);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|