@zhushanwen/pi-goal 0.1.6 → 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 +106 -36
- package/src/state.ts +51 -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,11 +25,15 @@ 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 {
|
|
33
|
+
createInitialState,
|
|
31
34
|
deserializeState,
|
|
32
35
|
getCompletedCount,
|
|
36
|
+
type GoalExternalInit,
|
|
33
37
|
isActiveStatus,
|
|
34
38
|
isTerminalStatus,
|
|
35
39
|
} from "./state";
|
|
@@ -41,6 +45,7 @@ import {
|
|
|
41
45
|
HISTORY_ENTRY_TYPE,
|
|
42
46
|
isGoalEntry,
|
|
43
47
|
isStaleContextError,
|
|
48
|
+
persistGoalState,
|
|
44
49
|
updateWidget,
|
|
45
50
|
} from "./tool-handler";
|
|
46
51
|
import { toSingleLine } from "./widget";
|
|
@@ -178,45 +183,60 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
178
183
|
tasksCompletedAtAgentStart: 0,
|
|
179
184
|
hasPendingInjection: false,
|
|
180
185
|
isProcessing: false, // P1-3: 防重入
|
|
186
|
+
pendingPause: false, // ESC 中断标记
|
|
181
187
|
};
|
|
182
188
|
|
|
183
|
-
//
|
|
189
|
+
// Capture latest ctx for external API (initializeGoalFromExternal needs it for persistGoalState)
|
|
190
|
+
let lastCtx: ExtensionContext | undefined;
|
|
184
191
|
|
|
185
192
|
pi.registerTool({
|
|
186
193
|
name: "goal_manager",
|
|
187
194
|
label: "Goal Manager",
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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[])",
|
|
201
208
|
promptSnippet: "Manage task list, completion status, and exit for /goal mode",
|
|
202
209
|
promptGuidelines: [
|
|
203
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)",
|
|
204
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 ...'",
|
|
205
213
|
"[Append] When discovering omissions during execution, use add_tasks to append — do not re-call create_tasks",
|
|
206
214
|
"[Completion] After completing a task, call update_tasks with status=completed and provide evidence (e.g. 'test X passed', 'file F created')",
|
|
207
|
-
"[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",
|
|
208
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",
|
|
209
|
-
"[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.",
|
|
210
218
|
"[Progress] Use list_tasks anytime to check remaining tasks and budget",
|
|
211
219
|
"[Cancel] To cancel a task, use update_tasks with status=cancelled. Cancelled tasks do not block goal completion",
|
|
212
|
-
"[
|
|
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.",
|
|
213
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",
|
|
214
225
|
"[Forbidden] Do not re-call create_tasks to overwrite existing incomplete tasks — use add_tasks to append",
|
|
215
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>",
|
|
216
235
|
],
|
|
217
236
|
parameters: GoalManagerParams,
|
|
218
237
|
|
|
219
238
|
async execute(_toolCallId: string, params: Static<typeof GoalManagerParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
239
|
+
lastCtx = ctx;
|
|
220
240
|
try {
|
|
221
241
|
// P1-4: 透传 signal
|
|
222
242
|
return await executeGoalAction(pi, session, params, ctx, signal);
|
|
@@ -255,35 +275,40 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
255
275
|
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
|
256
276
|
}
|
|
257
277
|
const tasks = details.tasks;
|
|
258
|
-
const completed = tasks
|
|
278
|
+
const completed = getCompletedCount(tasks);
|
|
259
279
|
const summary = theme.fg("success", `✓ ${completed}/${tasks.length} completed`);
|
|
260
280
|
if (!expanded || tasks.length === 0) {
|
|
261
281
|
return new Text(summary, 0, 0);
|
|
262
282
|
}
|
|
263
283
|
const lines = [summary];
|
|
264
284
|
for (const t of tasks) {
|
|
265
|
-
const icon = t.status === "
|
|
266
|
-
? theme.fg("success", "
|
|
267
|
-
: t.status === "
|
|
268
|
-
? theme.fg("
|
|
269
|
-
: t.status === "
|
|
270
|
-
? theme.fg("
|
|
271
|
-
:
|
|
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", "☐");
|
|
272
294
|
const descText = toSingleLine(t.description);
|
|
273
|
-
const desc = (t.status === "completed" || t.status === "cancelled")
|
|
295
|
+
const desc = (t.status === "verified" || t.status === "completed" || t.status === "cancelled")
|
|
274
296
|
? theme.fg("dim", descText)
|
|
275
297
|
: theme.fg("text", descText);
|
|
276
298
|
lines.push(` ${icon} ${theme.fg("accent", `#${t.id}`)} ${desc}`);
|
|
277
|
-
// Subtask items in expanded view
|
|
299
|
+
// Subtask items in expanded view — collapse when all completed
|
|
278
300
|
if (t.subtasks && t.subtasks.length > 0) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
? theme.fg("
|
|
284
|
-
:
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|
|
287
312
|
}
|
|
288
313
|
}
|
|
289
314
|
}
|
|
@@ -295,7 +320,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
295
320
|
|
|
296
321
|
pi.registerCommand("goal", {
|
|
297
322
|
description:
|
|
298
|
-
"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",
|
|
299
324
|
handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
300
325
|
await handleGoalCommand(pi, session, args, ctx);
|
|
301
326
|
},
|
|
@@ -303,6 +328,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
303
328
|
|
|
304
329
|
// ── Event: before_agent_start ──────────────────────
|
|
305
330
|
pi.on("before_agent_start", async (_event: BeforeAgentStartLikeEvent, ctx: ExtensionContext) => {
|
|
331
|
+
lastCtx = ctx;
|
|
306
332
|
return handleBeforeAgentStart(pi, session, ctx);
|
|
307
333
|
});
|
|
308
334
|
|
|
@@ -345,6 +371,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
345
371
|
|
|
346
372
|
// ── Event: session_start (state reconstruction) ───
|
|
347
373
|
pi.on("session_start", async (_event: SessionStartLikeEvent, ctx: ExtensionContext) => {
|
|
374
|
+
lastCtx = ctx;
|
|
348
375
|
reconstructGoalState(pi, session, ctx);
|
|
349
376
|
if (session.state) {
|
|
350
377
|
session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
|
|
@@ -372,4 +399,47 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
372
399
|
return new Text(prefix + theme.fg("dim", content), 0, 0);
|
|
373
400
|
});
|
|
374
401
|
}
|
|
402
|
+
|
|
403
|
+
// ── External API: initializeGoalFromExternal ──────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Allow other extensions (e.g. coding-workflow) to programmatically initialize a goal.
|
|
407
|
+
* Skips the /goal command flow — directly creates state + tasks.
|
|
408
|
+
* Returns true if initialized, false if goal already active.
|
|
409
|
+
*/
|
|
410
|
+
function initializeGoalFromExternal(
|
|
411
|
+
objective: string,
|
|
412
|
+
tasks: string[],
|
|
413
|
+
budget?: { tokenBudget?: number; timeBudgetMinutes?: number; maxTurns?: number },
|
|
414
|
+
ctx?: ExtensionContext,
|
|
415
|
+
): boolean {
|
|
416
|
+
if (session.state && isActiveStatus(session.state.status)) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
session.state = createInitialState(objective, budget);
|
|
421
|
+
session.tasksCompletedAtAgentStart = 0;
|
|
422
|
+
|
|
423
|
+
// Create tasks (same logic as handleCreateTasks)
|
|
424
|
+
session.state.tasks = tasks.map((desc, i) => ({
|
|
425
|
+
id: i + 1,
|
|
426
|
+
description: desc.length > EXT_INIT_TASK_DESC_MAX ? desc.slice(0, EXT_INIT_TASK_DESC_MAX - ELLIPSIS_LENGTH) + "..." : desc,
|
|
427
|
+
status: "pending" as const,
|
|
428
|
+
lastUpdatedTurn: session.state!.currentTurnIndex,
|
|
429
|
+
}));
|
|
430
|
+
|
|
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);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Expose on pi for cross-extension access
|
|
443
|
+
const api = pi as unknown as Record<string, unknown>;
|
|
444
|
+
api.__goalInit = initializeGoalFromExternal satisfies GoalExternalInit;
|
|
375
445
|
}
|