@zhushanwen/pi-goal 0.2.0 → 0.3.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/package.json +8 -4
- package/src/__tests__/deserialize-state.test.ts +109 -0
- package/src/__tests__/is-task-done.test.ts +43 -0
- package/src/__tests__/stubs/pi-sdk.ts +11 -0
- package/src/__tests__/stubs/typebox.ts +14 -0
- package/src/__tests__/validate-update-tasks.test.ts +172 -0
- package/src/action-handlers.ts +92 -21
- package/src/agent-end-handler.ts +43 -41
- package/src/before-agent-start-handler.ts +4 -6
- package/src/budget.ts +3 -3
- package/src/command-handler.ts +32 -13
- package/src/commands.ts +4 -1
- package/src/constants.ts +5 -0
- package/src/index.ts +64 -42
- package/src/state.ts +32 -8
- package/src/templates.ts +59 -17
- package/src/tool-handler.ts +40 -1
- package/src/widget.ts +69 -30
package/src/agent-end-handler.ts
CHANGED
|
@@ -31,8 +31,8 @@ import {
|
|
|
31
31
|
} from "./templates";
|
|
32
32
|
import {
|
|
33
33
|
type GoalSession,
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
persistAndUpdate,
|
|
35
|
+
sendGoalContextMessage,
|
|
36
36
|
writeGoalHistoryEntry,
|
|
37
37
|
} from "./tool-handler";
|
|
38
38
|
|
|
@@ -56,9 +56,17 @@ export async function handleAgentEnd(pi: ExtensionAPI, session: GoalSession, ctx
|
|
|
56
56
|
await handleTerminalStateAgentEnd(pi, session, ctx, checkStale); return;
|
|
57
57
|
}
|
|
58
58
|
if (!isActiveStatus(session.state.status)) return;
|
|
59
|
+
|
|
60
|
+
// ESC / user abort during text generation also pauses the goal
|
|
61
|
+
if (ctx.signal?.aborted) {
|
|
62
|
+
session.pendingPause = true;
|
|
63
|
+
const progress = checkProgress(session.state, session.tasksCompletedAtAgentStart);
|
|
64
|
+
await handleStallAndContinuation(pi, session, ctx, progress, checkStale);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
const budgetAction = await handleBudgetChecks(pi, session, ctx, checkBudgetOnTurnEnd(session.state), checkStale);
|
|
60
69
|
if (budgetAction !== "continue") return;
|
|
61
|
-
session.state.turnCount++;
|
|
62
70
|
const progress = checkProgress(session.state, session.tasksCompletedAtAgentStart);
|
|
63
71
|
const progressAction = handleProgressAndTasks(pi, session, ctx, progress, checkStale);
|
|
64
72
|
if (progressAction !== "continue") return;
|
|
@@ -81,9 +89,7 @@ async function handleTerminalStateAgentEnd(
|
|
|
81
89
|
checkStale: () => boolean,
|
|
82
90
|
): Promise<void> {
|
|
83
91
|
const state = session.state!;
|
|
84
|
-
|
|
85
|
-
if (checkStale()) return;
|
|
86
|
-
updateWidget(session, ctx);
|
|
92
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return;
|
|
87
93
|
if (state.status === "complete") {
|
|
88
94
|
ctx.ui.notify(
|
|
89
95
|
`Objective completed ✓ (${getCompletedCount(state.tasks)}/${state.tasks.length} tasks, ${state.currentTurnIndex} turns)`,
|
|
@@ -118,9 +124,7 @@ async function handleBudgetChecks(
|
|
|
118
124
|
session.state!.status = transitionStatus(session.state!.status, dim === "token" ? "budget_limited" : "time_limited");
|
|
119
125
|
session.state!.completedAtTurnIndex = session.state!.currentTurnIndex;
|
|
120
126
|
writeGoalHistoryEntry(pi, session);
|
|
121
|
-
|
|
122
|
-
if (checkStale()) return "stop";
|
|
123
|
-
updateWidget(session, ctx);
|
|
127
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
|
|
124
128
|
ctx.ui.notify(
|
|
125
129
|
dim === "token"
|
|
126
130
|
? "Token budget exhausted, Goal terminated."
|
|
@@ -132,10 +136,8 @@ async function handleBudgetChecks(
|
|
|
132
136
|
// 90% steering → 收尾
|
|
133
137
|
if (budgetResult.shouldSendSteering) {
|
|
134
138
|
session.state!.budgetLimitSteeringSent = true;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
updateWidget(session, ctx);
|
|
138
|
-
pi.sendUserMessage(budgetLimitPrompt(session.state!, "token"), { deliverAs: "steer" });
|
|
139
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
|
|
140
|
+
sendGoalContextMessage(pi, budgetLimitPrompt(session.state!, "token"), "steer");
|
|
139
141
|
return "stop";
|
|
140
142
|
}
|
|
141
143
|
if (checkStale()) return "stop";
|
|
@@ -174,9 +176,7 @@ function handleAllTasksDone(
|
|
|
174
176
|
state.status = transitionStatus(state.status, "complete");
|
|
175
177
|
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
176
178
|
writeGoalHistoryEntry(pi, session);
|
|
177
|
-
|
|
178
|
-
if (checkStale()) return "stop";
|
|
179
|
-
updateWidget(session, ctx);
|
|
179
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
|
|
180
180
|
ctx.ui.notify(
|
|
181
181
|
`All tasks completed, Goal auto-closed. (${progress.completedCount}/${progress.totalCount} tasks, ${state.currentTurnIndex} turns)`,
|
|
182
182
|
"info",
|
|
@@ -184,21 +184,22 @@ function handleAllTasksDone(
|
|
|
184
184
|
return "stop";
|
|
185
185
|
}
|
|
186
186
|
if (progress.budgetTight) {
|
|
187
|
-
|
|
187
|
+
sendGoalContextMessage(
|
|
188
|
+
pi,
|
|
188
189
|
`All tasks completed, token budget ${Math.round(state.tokensUsed / state.budget.tokenBudget! * PERCENT_FACTOR)}% used.` +
|
|
189
190
|
`Call goal_manager's complete_goal now with overall evidence.` +
|
|
190
191
|
`\n\nObjective: ${state.objective}`,
|
|
191
|
-
|
|
192
|
+
"steer",
|
|
192
193
|
);
|
|
193
194
|
} else {
|
|
194
|
-
|
|
195
|
+
sendGoalContextMessage(
|
|
196
|
+
pi,
|
|
195
197
|
`All ${progress.totalCount} tasks completed. Call goal_manager's complete_goal with overall evidence.` +
|
|
196
198
|
`\n\nObjective: ${state.objective}`,
|
|
197
|
-
|
|
199
|
+
"followUp",
|
|
198
200
|
);
|
|
199
201
|
}
|
|
200
|
-
|
|
201
|
-
updateWidget(session, ctx);
|
|
202
|
+
persistAndUpdate(pi, session, ctx);
|
|
202
203
|
return "stop";
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -211,22 +212,20 @@ function handleNoTasksOrMaxTurns(
|
|
|
211
212
|
state.status = transitionStatus(state.status, "cancelled");
|
|
212
213
|
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
213
214
|
writeGoalHistoryEntry(pi, session);
|
|
214
|
-
|
|
215
|
-
if (checkStale()) return "stop";
|
|
216
|
-
updateWidget(session, ctx);
|
|
215
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
|
|
217
216
|
ctx.ui.notify(
|
|
218
217
|
`Max turns reached (${state.budget.maxTurns}), LLM did not create task list.`,
|
|
219
218
|
"warning",
|
|
220
219
|
);
|
|
221
220
|
return "stop";
|
|
222
221
|
}
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
sendGoalContextMessage(
|
|
223
|
+
pi,
|
|
224
|
+
`No task list created yet. First check if the objective is already satisfied — if yes, call goal_manager's cancel_goal with cancelReason. Otherwise call create_tasks immediately to decompose the work into verifiable task steps.` +
|
|
225
225
|
`\n\nObjective: ${state.objective}`,
|
|
226
|
-
|
|
226
|
+
"followUp",
|
|
227
227
|
);
|
|
228
|
-
|
|
229
|
-
updateWidget(session, ctx);
|
|
228
|
+
persistAndUpdate(pi, session, ctx);
|
|
230
229
|
return "stop";
|
|
231
230
|
}
|
|
232
231
|
|
|
@@ -239,9 +238,7 @@ function handleMaxTurnsReached(
|
|
|
239
238
|
state.status = transitionStatus(state.status, "cancelled");
|
|
240
239
|
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
241
240
|
writeGoalHistoryEntry(pi, session);
|
|
242
|
-
|
|
243
|
-
if (checkStale()) return "stop";
|
|
244
|
-
updateWidget(session, ctx);
|
|
241
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
|
|
245
242
|
ctx.ui.notify(
|
|
246
243
|
`Max turns reached (${state.budget.maxTurns}), ${incomplete.length} tasks still incomplete.`,
|
|
247
244
|
"warning",
|
|
@@ -258,6 +255,15 @@ async function handleStallAndContinuation(
|
|
|
258
255
|
const state = session.state!;
|
|
259
256
|
if (checkStale()) return;
|
|
260
257
|
|
|
258
|
+
// ESC pause: if tool call was aborted, pause instead of continuing
|
|
259
|
+
if (session.pendingPause) {
|
|
260
|
+
session.pendingPause = false;
|
|
261
|
+
state.status = transitionStatus(state.status, "paused");
|
|
262
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return;
|
|
263
|
+
ctx.ui.notify("Goal paused (user interrupt). Use /goal resume to continue.", "info");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
261
267
|
// Stall 检测
|
|
262
268
|
updateStallCounter(state, progress.isStalled);
|
|
263
269
|
if (state.stallCount >= state.budget.maxStallTurns) {
|
|
@@ -268,13 +274,11 @@ async function handleStallAndContinuation(
|
|
|
268
274
|
|
|
269
275
|
// 去抖 + Continuation
|
|
270
276
|
if (!consumeTokensForDebounce(state)) {
|
|
271
|
-
|
|
272
|
-
updateWidget(session, ctx);
|
|
277
|
+
persistAndUpdate(pi, session, ctx);
|
|
273
278
|
return;
|
|
274
279
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
pi.sendUserMessage(continuationPrompt(state), { deliverAs: "followUp" });
|
|
280
|
+
persistAndUpdate(pi, session, ctx);
|
|
281
|
+
sendGoalContextMessage(pi, continuationPrompt(state), "followUp");
|
|
278
282
|
}
|
|
279
283
|
|
|
280
284
|
/** 更新 stall 计数。stall 时递增,否则重置。 */
|
|
@@ -287,9 +291,7 @@ function updateStallCounter(state: { stallCount: number; lastProgressTurn: numbe
|
|
|
287
291
|
function markGoalBlocked(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext, checkStale: () => boolean): void {
|
|
288
292
|
const state = session.state!;
|
|
289
293
|
state.status = transitionStatus(state.status, "blocked");
|
|
290
|
-
|
|
291
|
-
if (checkStale()) return;
|
|
292
|
-
updateWidget(session, ctx);
|
|
294
|
+
if (persistAndUpdate(pi, session, ctx, checkStale)) return;
|
|
293
295
|
ctx.ui.notify(
|
|
294
296
|
`${state.stallCount} consecutive turns without progress, Goal auto-blocked. Use /goal resume to continue or /goal clear to reset.`,
|
|
295
297
|
"warning",
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type GoalTask,
|
|
19
19
|
isActiveStatus,
|
|
20
20
|
isTerminalStatus,
|
|
21
|
-
|
|
21
|
+
isTaskDone,
|
|
22
22
|
transitionStatus,
|
|
23
23
|
} from "./state";
|
|
24
24
|
import {
|
|
@@ -28,8 +28,7 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
clearGoalSession,
|
|
30
30
|
type GoalSession,
|
|
31
|
-
|
|
32
|
-
updateWidget,
|
|
31
|
+
persistAndUpdate,
|
|
33
32
|
} from "./tool-handler";
|
|
34
33
|
import { renderTerminalStatusLine } from "./widget";
|
|
35
34
|
|
|
@@ -100,7 +99,7 @@ function checkStaleness(session: GoalSession) {
|
|
|
100
99
|
let allTerminal = true;
|
|
101
100
|
|
|
102
101
|
for (const task of state.tasks) {
|
|
103
|
-
if (!
|
|
102
|
+
if (!isTaskDone(task)) {
|
|
104
103
|
allTerminal = false;
|
|
105
104
|
const staleTurns = state.currentTurnIndex - task.lastUpdatedTurn;
|
|
106
105
|
if (staleTurns >= TASK_STALL_TURN_THRESHOLD) {
|
|
@@ -163,8 +162,7 @@ function checkContextUsage(
|
|
|
163
162
|
if (usage && usage.contextWindow > 0 && (usage.tokens ?? 0) / usage.contextWindow > CONTEXT_USAGE_RATIO_LIMIT) {
|
|
164
163
|
const state = session.state!;
|
|
165
164
|
state.status = transitionStatus(state.status, "paused");
|
|
166
|
-
|
|
167
|
-
updateWidget(session, ctx);
|
|
165
|
+
persistAndUpdate(pi, session, ctx);
|
|
168
166
|
return {
|
|
169
167
|
message: {
|
|
170
168
|
customType: "goal-context-exceeded",
|
package/src/budget.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
SECONDS_PER_MINUTE,
|
|
17
17
|
} from "./constants";
|
|
18
18
|
import type { GoalRuntimeState } from "./state";
|
|
19
|
-
import { getElapsedTimeSeconds } from "./state";
|
|
19
|
+
import { getCompletedCount, getElapsedTimeSeconds, getIncompleteTasks } from "./state";
|
|
20
20
|
|
|
21
21
|
// ── 决策类型 ────────────────────────────────────────
|
|
22
22
|
|
|
@@ -139,8 +139,8 @@ export interface ProgressCheck {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
export function checkProgress(state: GoalRuntimeState, tasksCompletedAtStart: number): ProgressCheck {
|
|
142
|
-
const incomplete = state.tasks
|
|
143
|
-
const completedCount = state.tasks
|
|
142
|
+
const incomplete = getIncompleteTasks(state.tasks);
|
|
143
|
+
const completedCount = getCompletedCount(state.tasks);
|
|
144
144
|
const totalCount = state.tasks.length;
|
|
145
145
|
const progressThisRound = completedCount - tasksCompletedAtStart;
|
|
146
146
|
|
package/src/command-handler.ts
CHANGED
|
@@ -31,8 +31,9 @@ import { objectiveUpdatedPrompt } from "./templates";
|
|
|
31
31
|
import {
|
|
32
32
|
clearGoalSession,
|
|
33
33
|
type GoalSession,
|
|
34
|
+
persistAndUpdate,
|
|
34
35
|
persistGoalState,
|
|
35
|
-
|
|
36
|
+
sendGoalContextMessage,
|
|
36
37
|
writeGoalHistoryEntry,
|
|
37
38
|
} from "./tool-handler";
|
|
38
39
|
|
|
@@ -51,6 +52,7 @@ export async function handleGoalCommand(
|
|
|
51
52
|
case "resume": return handleResume(pi, session, ctx);
|
|
52
53
|
case "history": return handleHistory(ctx);
|
|
53
54
|
case "clear": return handleClear(pi, session, ctx);
|
|
55
|
+
case "abort": return handleAbort(pi, session, ctx);
|
|
54
56
|
case "update": return handleUpdate(pi, session, parsed.objective, ctx);
|
|
55
57
|
case "set": return handleSet(pi, session, parsed.objective ?? "", parsed.budget, ctx);
|
|
56
58
|
}
|
|
@@ -89,8 +91,7 @@ function handlePause(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
session.state.status = transitionStatus(session.state.status, "paused");
|
|
92
|
-
|
|
93
|
-
updateWidget(session, ctx);
|
|
94
|
+
persistAndUpdate(pi, session, ctx);
|
|
94
95
|
ctx.ui.notify("Goal paused. Use /goal resume to continue.", "info");
|
|
95
96
|
}
|
|
96
97
|
|
|
@@ -116,13 +117,11 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
|
|
|
116
117
|
if (resumeBudgetCheck) {
|
|
117
118
|
const dim = resumeBudgetCheck.dimension;
|
|
118
119
|
state.status = transitionStatus(state.status, dim === "token" ? "budget_limited" : "time_limited");
|
|
119
|
-
|
|
120
|
-
updateWidget(session, ctx);
|
|
120
|
+
persistAndUpdate(pi, session, ctx);
|
|
121
121
|
ctx.ui.notify(`${dim === "token" ? "Token" : "Time"} budget exhausted, cannot resume. Use /goal clear to reset.`, "warning");
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
|
-
|
|
125
|
-
updateWidget(session, ctx);
|
|
124
|
+
persistAndUpdate(pi, session, ctx);
|
|
126
125
|
|
|
127
126
|
const incomplete = getIncompleteTasks(state.tasks);
|
|
128
127
|
if (incomplete.length > 0) {
|
|
@@ -191,6 +190,29 @@ function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
|
|
|
191
190
|
ctx.ui.notify("Goal cleared.", "info");
|
|
192
191
|
}
|
|
193
192
|
|
|
193
|
+
// ── /goal abort ──────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function handleAbort(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
|
|
196
|
+
if (!session.state) { ctx.ui.notify("Goal mode not active.", "info"); return; }
|
|
197
|
+
if (isTerminalStatus(session.state.status)) {
|
|
198
|
+
ctx.ui.notify(`Goal is already in terminal state (${session.state.status}).`, "warning");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (session.state.tasks.length > 0) {
|
|
202
|
+
const nonCancelled = session.state.tasks.filter(t => t.status !== "cancelled");
|
|
203
|
+
if (nonCancelled.length > 0) {
|
|
204
|
+
ctx.ui.notify(`Cannot abort: ${nonCancelled.length} non-cancelled tasks exist. Use /goal clear to force cancel.`, "warning");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
session.state.status = "cancelled";
|
|
209
|
+
session.state.completedAtTurnIndex = session.state.currentTurnIndex;
|
|
210
|
+
writeGoalHistoryEntry(pi, session);
|
|
211
|
+
persistGoalState(pi, session, ctx);
|
|
212
|
+
clearGoalSession(session, ctx);
|
|
213
|
+
ctx.ui.notify("Goal aborted: no work needed.", "info");
|
|
214
|
+
}
|
|
215
|
+
|
|
194
216
|
// ── /goal update ──────────────────────────────────────
|
|
195
217
|
|
|
196
218
|
function handleUpdate(
|
|
@@ -208,19 +230,17 @@ function handleUpdate(
|
|
|
208
230
|
state.objectiveUpdatedAt = Date.now();
|
|
209
231
|
state.tasks = [];
|
|
210
232
|
state.stallCount = 0;
|
|
211
|
-
state.turnCount = 0;
|
|
212
233
|
state.currentTurnIndex = 0;
|
|
213
234
|
state.lastProgressTurn = 0;
|
|
214
235
|
state.budgetLimitSteeringSent = false;
|
|
215
236
|
state.budgetWarning70Sent = false;
|
|
216
237
|
state.budgetWarning90Sent = false;
|
|
217
238
|
session.tasksCompletedAtAgentStart = 0;
|
|
218
|
-
|
|
219
|
-
updateWidget(session, ctx);
|
|
239
|
+
persistAndUpdate(pi, session, ctx);
|
|
220
240
|
ctx.ui.notify(`Objective updated:\nPrevious: ${oldObjective}\nNew: ${newObjective}`, "info");
|
|
221
241
|
|
|
222
242
|
if (isActiveStatus(state.status)) {
|
|
223
|
-
pi
|
|
243
|
+
sendGoalContextMessage(pi, objectiveUpdatedPrompt(state, oldObjective), "steer");
|
|
224
244
|
}
|
|
225
245
|
}
|
|
226
246
|
|
|
@@ -263,8 +283,7 @@ function handleSet(
|
|
|
263
283
|
session.tasksCompletedAtAgentStart = 0;
|
|
264
284
|
session.hasPendingInjection = false;
|
|
265
285
|
|
|
266
|
-
|
|
267
|
-
updateWidget(session, ctx);
|
|
286
|
+
persistAndUpdate(pi, session, ctx);
|
|
268
287
|
|
|
269
288
|
const budgetNotice: string[] = [];
|
|
270
289
|
if (budget.tokenBudget) budgetNotice.push(`Token budget: ${budget.tokenBudget}`);
|
package/src/commands.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { MAX_STALL_CAP, MAX_TURNS_CAP, UPDATE_PREFIX_LENGTH } from "./constants"
|
|
|
6
6
|
import type { BudgetConfig } from "./state";
|
|
7
7
|
|
|
8
8
|
export interface GoalCommandArgs {
|
|
9
|
-
action: "set" | "status" | "pause" | "resume" | "clear" | "update" | "history";
|
|
9
|
+
action: "set" | "status" | "pause" | "resume" | "clear" | "abort" | "update" | "history";
|
|
10
10
|
objective?: string;
|
|
11
11
|
budget?: Partial<BudgetConfig>;
|
|
12
12
|
}
|
|
@@ -28,6 +28,9 @@ export function parseGoalArgs(raw: string): GoalCommandArgs {
|
|
|
28
28
|
if (trimmed === "clear") {
|
|
29
29
|
return { action: "clear" };
|
|
30
30
|
}
|
|
31
|
+
if (trimmed === "abort") {
|
|
32
|
+
return { action: "abort" };
|
|
33
|
+
}
|
|
31
34
|
if (trimmed === "history") {
|
|
32
35
|
return { action: "history" };
|
|
33
36
|
}
|
package/src/constants.ts
CHANGED
|
@@ -42,3 +42,8 @@ export const OBJECTIVE_TRUNCATE_KEEP = 77; // DISPLAY_LIMIT - 3 for "..."
|
|
|
42
42
|
export const TASK_STALL_TURN_THRESHOLD = 10; // task/subtask 停滞提醒阈值(turn 数)
|
|
43
43
|
export const AUTO_CLEAR_TURNS = 2; // 终态后自动清理轮数
|
|
44
44
|
export const MAX_HISTORY_ENTRIES = 20; // goal-history entry GC 上限
|
|
45
|
+
|
|
46
|
+
export const VERIFY_METHOD_WIDGET_LEN = 30; // widget 中验证方法显示的截断长度
|
|
47
|
+
export const EXT_INIT_TASK_DESC_MAX = 60; // external init 中 task description 截断上限
|
|
48
|
+
export const TASK_DESC_MAX_LENGTH = 80; // task description 截断上限(normalizeDescription)
|
|
49
|
+
export const ELLIPSIS_LENGTH = 3;
|
package/src/index.ts
CHANGED
|
@@ -25,15 +25,17 @@ import { handleAgentEnd } from "./agent-end-handler";
|
|
|
25
25
|
import { handleBeforeAgentStart } from "./before-agent-start-handler";
|
|
26
26
|
import { handleGoalCommand } from "./command-handler";
|
|
27
27
|
import {
|
|
28
|
+
ELLIPSIS_LENGTH,
|
|
29
|
+
EXT_INIT_TASK_DESC_MAX,
|
|
28
30
|
MAX_HISTORY_ENTRIES,
|
|
29
31
|
} from "./constants";
|
|
30
32
|
import {
|
|
31
33
|
createInitialState,
|
|
32
34
|
deserializeState,
|
|
33
35
|
getCompletedCount,
|
|
36
|
+
type GoalExternalInit,
|
|
34
37
|
isActiveStatus,
|
|
35
38
|
isTerminalStatus,
|
|
36
|
-
type GoalExternalInit,
|
|
37
39
|
} from "./state";
|
|
38
40
|
import {
|
|
39
41
|
executeGoalAction,
|
|
@@ -181,6 +183,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
181
183
|
tasksCompletedAtAgentStart: 0,
|
|
182
184
|
hasPendingInjection: false,
|
|
183
185
|
isProcessing: false, // P1-3: 防重入
|
|
186
|
+
pendingPause: false, // ESC 中断标记
|
|
184
187
|
};
|
|
185
188
|
|
|
186
189
|
// Capture latest ctx for external API (initializeGoalFromExternal needs it for persistGoalState)
|
|
@@ -189,34 +192,46 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
189
192
|
pi.registerTool({
|
|
190
193
|
name: "goal_manager",
|
|
191
194
|
label: "Goal Manager",
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
195
|
+
description:
|
|
196
|
+
"Goal mode task manager. This tool is only available after starting a goal via the /goal command. AI cannot trigger it proactively. If Goal mode is not active, calling this tool will error." +
|
|
197
|
+
"\n\nAvailable actions:" +
|
|
198
|
+
"\n- create_tasks: Decompose the objective into a task list (call once at goal start). Each task description must be a one-line summary (max 60 chars), no newlines or markdown. Accept verifications array for task verification." +
|
|
199
|
+
"\n- add_tasks: Append new tasks to the existing list (when omissions are discovered). Fails if no tasks exist yet — use create_tasks first. Each task description must be a one-line summary (max 60 chars), no newlines or markdown. Accept verifications array." +
|
|
200
|
+
"\n- update_tasks: Batch update task statuses. Must follow state machine: pending→in_progress→completed→verified. Cannot skip states (pending→completed is forbidden). Exactly one task should be in_progress at a time. Completed requires evidence. Cancelled does not block goal completion. Completing a task with verification triggers a verification prompt — then call update_tasks with status=verified and actual=<result>." +
|
|
201
|
+
"\n- list_tasks: View progress and remaining budget" +
|
|
202
|
+
"\n- complete_goal: Mark the objective as achieved. Only call when ALL tasks are completed or verified with concrete evidence. Do not mark complete merely because the budget is nearly exhausted or because you are stopping work. Before calling, use list_tasks to verify all tasks are done." +
|
|
203
|
+
"\n- cancel_goal: Cancel the current goal (use when user wants to exit/stop)" +
|
|
204
|
+
"\n- report_blocked: Report being blocked. Only use after trying at least 3 alternative approaches to the same blocking condition. Do not use merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Requires a specific reason describing what is blocking and what you have already tried." +
|
|
205
|
+
"\n- add_subtasks: Add subtasks to a specified task (params: taskId, texts[]). Use this instead of todo tool in Goal mode" +
|
|
206
|
+
"\n- update_subtasks: Batch update subtask statuses (params: taskId, subUpdates[])" +
|
|
207
|
+
"\n- delete_subtasks: Delete subtasks from a specified task (params: taskId, subIds[])",
|
|
205
208
|
promptSnippet: "Manage task list, completion status, and exit for /goal mode",
|
|
206
209
|
promptGuidelines: [
|
|
207
210
|
"[Workflow] After receiving the objective, the first step must be create_tasks to decompose. Do not re-call if task list already exists",
|
|
211
|
+
"[Status flow] Tasks must follow this order: pending → in_progress → completed → verified. You CANNOT skip in_progress. Before marking completed, always set status=in_progress first. Transitions: pending→in_progress | pending→cancelled, in_progress→completed | in_progress→cancelled, completed→verified (only if task has verification)",
|
|
208
212
|
"[Format] Each task description must be a one-line summary, max 60 chars. No newlines, markdown, or detailed parameter lists — those go in execution phase. Example: 'Fix hook-registry dedup logic' not 'Fix hook-registry dedup + transport-execute enhancementConfig guard + failover-loop ...'",
|
|
209
213
|
"[Append] When discovering omissions during execution, use add_tasks to append — do not re-call create_tasks",
|
|
210
214
|
"[Completion] After completing a task, call update_tasks with status=completed and provide evidence (e.g. 'test X passed', 'file F created')",
|
|
211
|
-
"[Goal completion] Only call complete_goal when all tasks are completed with overall evidence",
|
|
215
|
+
"[Goal completion] Only call complete_goal when all tasks are completed or verified with overall evidence",
|
|
212
216
|
"[Exit] When user says 'stop', 'exit', 'cancel', '不用了', '结束', etc. indicating they don't want to continue, immediately call cancel_goal — do not guide them through complete_goal",
|
|
213
|
-
"[Blocked]
|
|
217
|
+
"[Blocked] Do not call report_blocked the first time a blocker appears. Try at least 3 alternative approaches first. Only report blocked when genuinely at an impasse without user input — not because work is hard, slow, uncertain, or incomplete. Once you call report_blocked, include what you have already tried in the reason.",
|
|
214
218
|
"[Progress] Use list_tasks anytime to check remaining tasks and budget",
|
|
215
219
|
"[Cancel] To cancel a task, use update_tasks with status=cancelled. Cancelled tasks do not block goal completion",
|
|
216
|
-
"[
|
|
220
|
+
"[Audit] Before marking a task completed, verify against actual current state (files, command output, test results). Intent, partial progress, or 'it should work' are NOT evidence. Do not redefine success around work already done — preserve original scope. Uncertain or indirect evidence means not completed, keep working.",
|
|
221
|
+
"[Fidelity] Optimize for movement toward the requested end state, not the easiest passing change. Do not substitute a narrower or safer solution because it is easier to verify. An edit is aligned only if it makes the requested final state more true.",
|
|
222
|
+
"[Forbidden] Do not mark tasks as completed without evidence, and do not call complete_goal without evidence. Do not mark completed due to budget exhaustion or because you are stopping work.",
|
|
217
223
|
"[Forbidden] Do not force task completion when the user explicitly wants to exit — call cancel_goal directly",
|
|
224
|
+
"[Quick exit] When no tasks have been created and you determine the objective is already met, call cancel_goal with cancelReason instead of creating tasks",
|
|
218
225
|
"[Forbidden] Do not re-call create_tasks to overwrite existing incomplete tasks — use add_tasks to append",
|
|
219
226
|
"[Subtask] For fine-grained step tracking in Goal mode, use add_subtasks — do not use the todo tool",
|
|
227
|
+
"[Verification] Each task should have a concrete verification method. Use verifications param in create_tasks/add_tasks. Templates:",
|
|
228
|
+
"[Verification] - Command: method='pnpm --filter <pkg> typecheck', expected='zero type errors'",
|
|
229
|
+
"[Verification] - Test: method='pnpm --filter <pkg> test', expected='all tests pass'",
|
|
230
|
+
"[Verification] - File: method='check <path> exists and contains <content>', expected='file exists with matching content'",
|
|
231
|
+
"[Verification] - Manual: method='manual check <specific items>', expected='<expected result'",
|
|
232
|
+
"[Verification] Multiple related tasks can share one verification — set verification on the LAST related task only",
|
|
233
|
+
"[Verification] Do NOT create a separate 'run tests' task for verification — use verification field instead",
|
|
234
|
+
"[Verification] When a task with verification is completed, run the verification command with bash. Then call update_tasks with status=verified and actual=<result>",
|
|
220
235
|
],
|
|
221
236
|
parameters: GoalManagerParams,
|
|
222
237
|
|
|
@@ -260,35 +275,40 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
260
275
|
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
|
261
276
|
}
|
|
262
277
|
const tasks = details.tasks;
|
|
263
|
-
const completed = tasks
|
|
278
|
+
const completed = getCompletedCount(tasks);
|
|
264
279
|
const summary = theme.fg("success", `✓ ${completed}/${tasks.length} completed`);
|
|
265
280
|
if (!expanded || tasks.length === 0) {
|
|
266
281
|
return new Text(summary, 0, 0);
|
|
267
282
|
}
|
|
268
283
|
const lines = [summary];
|
|
269
284
|
for (const t of tasks) {
|
|
270
|
-
const icon = t.status === "
|
|
271
|
-
? theme.fg("success", "
|
|
272
|
-
: t.status === "
|
|
273
|
-
? theme.fg("
|
|
274
|
-
: t.status === "
|
|
275
|
-
? theme.fg("
|
|
276
|
-
:
|
|
285
|
+
const icon = t.status === "verified"
|
|
286
|
+
? theme.fg("success", "◉")
|
|
287
|
+
: t.status === "completed"
|
|
288
|
+
? theme.fg("success", "✓")
|
|
289
|
+
: t.status === "in_progress"
|
|
290
|
+
? theme.fg("warning", "●")
|
|
291
|
+
: t.status === "cancelled"
|
|
292
|
+
? theme.fg("dim", "✗")
|
|
293
|
+
: theme.fg("dim", "☐");
|
|
277
294
|
const descText = toSingleLine(t.description);
|
|
278
|
-
const desc = (t.status === "completed" || t.status === "cancelled")
|
|
295
|
+
const desc = (t.status === "verified" || t.status === "completed" || t.status === "cancelled")
|
|
279
296
|
? theme.fg("dim", descText)
|
|
280
297
|
: theme.fg("text", descText);
|
|
281
298
|
lines.push(` ${icon} ${theme.fg("accent", `#${t.id}`)} ${desc}`);
|
|
282
|
-
// Subtask items in expanded view
|
|
299
|
+
// Subtask items in expanded view — collapse when all completed
|
|
283
300
|
if (t.subtasks && t.subtasks.length > 0) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
? theme.fg("
|
|
289
|
-
:
|
|
290
|
-
|
|
291
|
-
|
|
301
|
+
const allSubCompleted = t.subtasks.every((s: { status: string }) => s.status === "completed");
|
|
302
|
+
if (!allSubCompleted) {
|
|
303
|
+
for (const s of t.subtasks) {
|
|
304
|
+
const subIcon = s.status === "completed"
|
|
305
|
+
? theme.fg("success", "\u2713")
|
|
306
|
+
: s.status === "in_progress"
|
|
307
|
+
? theme.fg("warning", "\u25cf")
|
|
308
|
+
: theme.fg("dim", "\u25cb");
|
|
309
|
+
const subText = s.status === "completed" ? theme.fg("dim", s.text) : theme.fg("muted", s.text);
|
|
310
|
+
lines.push(` ${subIcon} ${theme.fg("dim", `${t.id}.${s.id}`)} ${subText}`);
|
|
311
|
+
}
|
|
292
312
|
}
|
|
293
313
|
}
|
|
294
314
|
}
|
|
@@ -300,7 +320,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
300
320
|
|
|
301
321
|
pi.registerCommand("goal", {
|
|
302
322
|
description:
|
|
303
|
-
"Goal-driven mode: /goal <objective> [--tokens N] [--timeout N] [--max-turns N] | /goal pause | /goal resume | /goal clear | /goal update <new-objective> | /goal status | /goal history",
|
|
323
|
+
"Goal-driven mode: /goal <objective> [--tokens N] [--timeout N] [--max-turns N] | /goal pause | /goal resume | /goal abort | /goal clear | /goal update <new-objective> | /goal status | /goal history",
|
|
304
324
|
handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
305
325
|
await handleGoalCommand(pi, session, args, ctx);
|
|
306
326
|
},
|
|
@@ -391,6 +411,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
391
411
|
objective: string,
|
|
392
412
|
tasks: string[],
|
|
393
413
|
budget?: { tokenBudget?: number; timeBudgetMinutes?: number; maxTurns?: number },
|
|
414
|
+
ctx?: ExtensionContext,
|
|
394
415
|
): boolean {
|
|
395
416
|
if (session.state && isActiveStatus(session.state.status)) {
|
|
396
417
|
return false;
|
|
@@ -402,16 +423,17 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
402
423
|
// Create tasks (same logic as handleCreateTasks)
|
|
403
424
|
session.state.tasks = tasks.map((desc, i) => ({
|
|
404
425
|
id: i + 1,
|
|
405
|
-
description: desc.length >
|
|
426
|
+
description: desc.length > EXT_INIT_TASK_DESC_MAX ? desc.slice(0, EXT_INIT_TASK_DESC_MAX - ELLIPSIS_LENGTH) + "..." : desc,
|
|
406
427
|
status: "pending" as const,
|
|
407
428
|
lastUpdatedTurn: session.state!.currentTurnIndex,
|
|
408
429
|
}));
|
|
409
430
|
|
|
410
|
-
// Persist state so it survives session reconstruction
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
431
|
+
// Persist state so it survives session reconstruction.
|
|
432
|
+
// Prefer explicitly passed ctx (from the calling extension's tool execute),
|
|
433
|
+
// fallback to lastCtx captured from this extension's own event handlers.
|
|
434
|
+
const persistCtx = ctx ?? lastCtx;
|
|
435
|
+
if (persistCtx) {
|
|
436
|
+
persistGoalState(pi, session, persistCtx);
|
|
415
437
|
}
|
|
416
438
|
|
|
417
439
|
return true;
|