@narumitw/pi-goal 0.1.21 → 0.1.23

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.
Files changed (3) hide show
  1. package/README.md +16 -7
  2. package/package.json +1 -1
  3. package/src/goal.ts +203 -24
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  `@narumitw/pi-goal` is a native [Pi coding agent](https://pi.dev) extension that adds session-scoped `/goal` commands and a `goal_complete` tool for autonomous, verifiable task completion.
6
6
 
7
- Goal mode uses Codex-like persistence instructions and keeps sending guarded continuation messages until the agent calls `goal_complete`, the user pauses or clears the goal, or an optional token budget is reached.
7
+ Goal mode uses Codex-like persistence instructions and keeps sending guarded continuation messages until the agent calls `goal_complete`, the user pauses or clears the goal, an interrupt/error pauses the goal, or an optional token budget is reached.
8
8
 
9
9
  ## ✨ Features
10
10
 
@@ -16,9 +16,10 @@ Goal mode uses Codex-like persistence instructions and keeps sending guarded con
16
16
  - Tracks `active`, `paused`, `budget_limited`, and `complete` states.
17
17
  - Stores goal state in the current Pi session, following Codex's thread-owned goal model instead of using a global per-directory goal.
18
18
  - Registers a `goal_complete` tool for explicit completion.
19
- - Automatically prompts the agent to continue if an active turn ends early, directly triggering the next turn when Pi is idle.
20
- - Guards auto-follow-ups so replaced, paused, cleared, completed, or budget-limited goals are not continued.
21
- - Encourages verification before the goal is marked complete.
19
+ - Automatically prompts the agent to continue if an active turn ends early, directly triggering the next turn when Pi is idle and no pending messages are queued.
20
+ - Pauses instead of auto-continuing when Pi reports an aborted or errored assistant turn.
21
+ - Guards auto-follow-ups so duplicate, replaced, paused, cleared, completed, or budget-limited goals are not continued.
22
+ - Encourages requirement-by-requirement verification before the goal is marked complete.
22
23
 
23
24
  ## 📦 Install
24
25
 
@@ -55,7 +56,7 @@ pi -e ./extensions/pi-goal
55
56
  - `/goal --tokens 100k <goal_to_complete>` starts or replaces goal mode with a token budget. `k` and `m` suffixes are accepted, for example `100k` or `1.5m`.
56
57
  - `/goal edit <goal_to_complete>` updates the existing goal objective without resetting usage counters. Active goals stay active, paused goals stay paused, and budget-limited goals remain budget-limited if their budget is still exhausted.
57
58
  - `/goal pause` stops prompt injection and auto-continuation without forgetting the goal.
58
- - `/goal resume` resumes a paused or budget-limited goal when the token budget allows it.
59
+ - `/goal resume` resumes a paused or budget-limited goal when the token budget allows it, then queues a resume prompt so work continues.
59
60
  - `/goal clear` cancels the current goal and also removes any legacy persisted state for the current working directory.
60
61
 
61
62
  Goal objectives are limited to 4,000 characters. Put longer instructions in a file and reference the file path from `/goal`.
@@ -78,9 +79,17 @@ Older versions wrote unfinished goals to `~/.pi/agent/pi-goal-state.json` keyed
78
79
 
79
80
  ## ✅ How completion works
80
81
 
81
- The extension registers a `goal_complete` tool. While a goal is active, the system prompt uses Codex-like persistence rules: keep going until the goal is resolved end-to-end, do not stop at analysis, a plan, partial fixes, or suggested next steps, use available tools for implementation and verification, and call `goal_complete` only when the goal is fully done.
82
+ The extension registers a `goal_complete` tool. While a goal is active, the system prompt uses Codex-like persistence rules: keep going until the goal is resolved end-to-end, treat current files, command output, tests, and external state as authoritative, avoid redefining the goal into a smaller task, and call `goal_complete` only after requirement-by-requirement verification.
82
83
 
83
- If an agent turn ends before `goal_complete` is called, the extension records elapsed time and token usage, checks the budget, verifies that the same goal id is still active, then sends a continuation prompt for the same goal. When Pi is already idle, this directly triggers the next turn; otherwise it is queued as a follow-up until the agent finishes current work.
84
+ If an agent turn ends before `goal_complete` is called, the extension records elapsed time and token usage, checks the budget, verifies that the same goal id is still active, and sends a continuation prompt only when no user or extension messages are already pending. When Pi is already idle, this directly triggers the next turn; otherwise it is queued as a follow-up until the agent finishes current work. A continuation-pending guard prevents repeated end events from enqueueing duplicate continuations.
85
+
86
+ ## 🛑 Interruption and queued-input behavior
87
+
88
+ When Pi reports the final assistant message with `stopReason: "aborted"` or `stopReason: "error"`, `pi-goal` records usage, changes the goal to `paused`, and does not auto-continue. Run `/goal resume` to continue after reviewing the interruption or error.
89
+
90
+ If user or extension messages are already queued at the end of a goal turn, those messages take priority and `pi-goal` skips that automatic continuation. Active goal instructions are still injected into the next agent turn, and normal continuation can resume after pending work finishes.
91
+
92
+ Queued automatic continuation prompts are tracked by goal id and iteration. If a queued continuation is invalidated by `/goal pause`, `/goal clear`, `/goal edit`, replacement, or completion before delivery, the extension handles that stale prompt instead of letting it restart old goal work.
84
93
 
85
94
  ## 🧠 Use cases
86
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-goal",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Pi extension that keeps working on a /goal until the agent marks it complete.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/goal.ts CHANGED
@@ -6,6 +6,7 @@ import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
7
 
8
8
  type GoalStatus = "active" | "paused" | "budget_limited" | "complete";
9
+ type AgentStopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
9
10
 
10
11
  interface ActiveGoal {
11
12
  id: string;
@@ -25,6 +26,19 @@ interface GoalCompleteDetails {
25
26
  summary: string;
26
27
  }
27
28
 
29
+ interface ContinuationPending {
30
+ goalId: string;
31
+ iteration: number;
32
+ marker: string;
33
+ prompt: string;
34
+ }
35
+
36
+ interface AssistantMessageLike {
37
+ role: "assistant";
38
+ stopReason?: AgentStopReason;
39
+ errorMessage?: string;
40
+ }
41
+
28
42
  interface GoalStateEntryData {
29
43
  goal?: ActiveGoal | null;
30
44
  }
@@ -43,12 +57,15 @@ interface StatusContext {
43
57
  setStatus: (key: string, value: string | undefined) => void;
44
58
  };
45
59
  isIdle?: () => boolean;
60
+ hasPendingMessages?: () => boolean;
46
61
  sessionManager?: unknown;
47
62
  }
48
63
 
49
64
  const STATUS_KEY = "goal";
50
65
  const GOAL_STATE_ENTRY_TYPE = "goal-state";
51
66
  const MAX_OBJECTIVE_LENGTH = 4_000;
67
+ const MAX_CANCELLED_CONTINUATION_PROMPTS = 20;
68
+ const CONTINUATION_MARKER_PREFIX = "pi-goal-continuation:";
52
69
  const STATE_FILE = join(
53
70
  process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? ".", ".pi", "agent"),
54
71
  "pi-goal-state.json",
@@ -57,6 +74,8 @@ const STATE_FILE = join(
57
74
  let activeGoal: ActiveGoal | undefined;
58
75
  let completionStatusTimer: NodeJS.Timeout | undefined;
59
76
  let extensionApi: ExtensionAPI | undefined;
77
+ let continuationPending: ContinuationPending | undefined;
78
+ const cancelledContinuationMarkers = new Set<string>();
60
79
 
61
80
  const goalCompleteTool = defineTool({
62
81
  name: "goal_complete",
@@ -66,6 +85,7 @@ const goalCompleteTool = defineTool({
66
85
  promptSnippet: "Mark the active /goal as complete after fully finishing and verifying it",
67
86
  promptGuidelines: [
68
87
  "When a /goal is active, keep working until the goal is complete; do not stop with only a plan or partial progress.",
88
+ "Before calling goal_complete, audit the active goal requirement by requirement against the current files, command output, tests, or external state.",
69
89
  "Call goal_complete only after the requested goal is fully implemented, verified, and no known required work remains.",
70
90
  ],
71
91
  parameters: Type.Object({
@@ -118,13 +138,13 @@ export default function goal(pi: ExtensionAPI) {
118
138
  pauseGoal(ctx);
119
139
  return;
120
140
  case "resume":
121
- resumeGoal(ctx);
141
+ await resumeGoal(pi, ctx);
122
142
  return;
123
143
  case "clear":
124
144
  clearGoal(ctx);
125
145
  return;
126
146
  case "edit":
127
- editGoal(result.objective ?? "", result.tokenBudget, pi, ctx);
147
+ await editGoal(result.objective ?? "", result.tokenBudget, pi, ctx);
128
148
  return;
129
149
  case "start":
130
150
  await startGoal(result.objective ?? "", result.tokenBudget, pi, ctx);
@@ -134,6 +154,7 @@ export default function goal(pi: ExtensionAPI) {
134
154
  });
135
155
 
136
156
  pi.on("session_start", (_event, ctx) => {
157
+ clearContinuationTracking();
137
158
  activeGoal = loadGoalFromSession(ctx);
138
159
  if (activeGoal) updateStatus(ctx, activeGoal);
139
160
  else ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -141,11 +162,18 @@ export default function goal(pi: ExtensionAPI) {
141
162
 
142
163
  pi.on("session_shutdown", (_event, ctx) => {
143
164
  if (activeGoal) persistGoal(activeGoal);
165
+ clearContinuationTracking();
144
166
  ctx.ui.setStatus(STATUS_KEY, undefined);
145
167
  if (completionStatusTimer) clearTimeout(completionStatusTimer);
146
168
  });
147
169
 
170
+ pi.on("input", (event) => {
171
+ if (event.source !== "extension") return;
172
+ if (consumeCancelledContinuationPrompt(event.text)) return { action: "handled" as const };
173
+ });
174
+
148
175
  pi.on("before_agent_start", (event) => {
176
+ markContinuationDelivered(event.prompt);
149
177
  if (!activeGoal || activeGoal.status !== "active") return;
150
178
 
151
179
  return {
@@ -153,14 +181,23 @@ export default function goal(pi: ExtensionAPI) {
153
181
  };
154
182
  });
155
183
 
156
- pi.on("agent_end", (_event, ctx) => {
184
+ pi.on("agent_end", async (event, ctx) => {
157
185
  if (!activeGoal || activeGoal.status !== "active") return;
158
186
 
159
187
  const goalId = activeGoal.id;
160
- activeGoal = incrementGoal(activeGoal);
188
+ const hadPendingContinuation = continuationPending?.goalId === goalId;
189
+ const finalAssistant = findFinalAssistantMessage(event.messages);
190
+
191
+ if (!hadPendingContinuation) activeGoal = incrementGoal(activeGoal);
161
192
  updateGoalUsage(activeGoal, ctx);
162
193
 
194
+ if (finalAssistant?.stopReason === "aborted" || finalAssistant?.stopReason === "error") {
195
+ pauseGoalAfterAgentEnd(ctx, activeGoal, finalAssistant);
196
+ return;
197
+ }
198
+
163
199
  if (activeGoal.tokenBudget !== undefined && activeGoal.tokensUsed >= activeGoal.tokenBudget) {
200
+ cancelContinuationPending();
164
201
  activeGoal = transitionGoal(activeGoal, "budget_limited");
165
202
  persistGoal(activeGoal);
166
203
  updateStatus(ctx, activeGoal);
@@ -171,9 +208,15 @@ export default function goal(pi: ExtensionAPI) {
171
208
  persistGoal(activeGoal);
172
209
  updateStatus(ctx, activeGoal);
173
210
 
211
+ if (hadPendingContinuation) {
212
+ if (hasPendingMessages(ctx)) return;
213
+ if (continuationPending?.goalId === goalId) continuationPending = undefined;
214
+ }
215
+
174
216
  const currentGoal = activeGoal;
175
217
  if (!currentGoal || currentGoal.id !== goalId || currentGoal.status !== "active") return;
176
- sendContinuationPrompt(pi, ctx, currentGoal);
218
+ if (hasPendingMessages(ctx)) return;
219
+ await sendContinuationPrompt(pi, ctx, currentGoal);
177
220
  });
178
221
  }
179
222
 
@@ -201,11 +244,12 @@ async function startGoal(
201
244
  }
202
245
  }
203
246
 
247
+ cancelContinuationPending();
204
248
  activeGoal = createGoal(objective, tokenBudget, currentTokenTotal(ctx));
205
249
  persistGoal(activeGoal);
206
250
  updateStatus(ctx, activeGoal);
207
251
  ctx.ui.notify(existingGoal ? `Goal replaced: ${objective}` : `Goal started: ${objective}`, "info");
208
- sendGoalPrompt(pi, ctx, activeGoal);
252
+ await sendGoalPrompt(pi, ctx, activeGoal);
209
253
  }
210
254
 
211
255
  function pauseGoal(ctx: StatusContext) {
@@ -217,13 +261,14 @@ function pauseGoal(ctx: StatusContext) {
217
261
  ctx.ui.notify(`Goal is ${activeGoal.status}; only active goals can be paused.`, "warning");
218
262
  return;
219
263
  }
264
+ cancelContinuationPending();
220
265
  activeGoal = transitionGoal(activeGoal, "paused");
221
266
  persistGoal(activeGoal);
222
267
  updateStatus(ctx, activeGoal);
223
268
  ctx.ui.notify(`Goal paused: ${activeGoal.text}`, "info");
224
269
  }
225
270
 
226
- function resumeGoal(ctx: StatusContext) {
271
+ async function resumeGoal(pi: ExtensionAPI, ctx: StatusContext) {
227
272
  if (!activeGoal) {
228
273
  ctx.ui.notify("No active goal.", "info");
229
274
  return;
@@ -235,12 +280,18 @@ function resumeGoal(ctx: StatusContext) {
235
280
  activeGoal = transitionGoal(activeGoal, "active");
236
281
  persistGoal(activeGoal);
237
282
  updateStatus(ctx, activeGoal);
283
+ if (activeGoal.status !== "active") {
284
+ ctx.ui.notify(`Goal token budget is still reached: ${formatBudget(activeGoal)}`, "warning");
285
+ return;
286
+ }
238
287
  ctx.ui.notify(`Goal resumed: ${activeGoal.text}`, "info");
288
+ await sendResumePrompt(pi, ctx, activeGoal);
239
289
  }
240
290
 
241
291
  function clearGoal(ctx: StatusContext) {
242
292
  if (!activeGoal) {
243
293
  ctx.ui.notify("No active goal.", "info");
294
+ cancelContinuationPending();
244
295
  clearPersistedGoal(ctx.cwd);
245
296
  ctx.ui.setStatus(STATUS_KEY, undefined);
246
297
  return;
@@ -251,7 +302,7 @@ function clearGoal(ctx: StatusContext) {
251
302
  ctx.ui.notify(`Goal cleared: ${stoppedGoal}`, "warning");
252
303
  }
253
304
 
254
- function editGoal(
305
+ async function editGoal(
255
306
  objective: string,
256
307
  tokenBudget: number | undefined,
257
308
  pi: ExtensionAPI,
@@ -268,6 +319,7 @@ function editGoal(
268
319
  }
269
320
 
270
321
  updateGoalUsage(activeGoal, ctx);
322
+ cancelContinuationPending();
271
323
  activeGoal = normalizeGoalForBudget({
272
324
  ...activeGoal,
273
325
  text: objective,
@@ -278,7 +330,7 @@ function editGoal(
278
330
  persistGoal(activeGoal);
279
331
  updateStatus(ctx, activeGoal);
280
332
  ctx.ui.notify(`Goal updated: ${objective}`, "info");
281
- if (activeGoal.status === "active") sendObjectiveUpdatedPrompt(pi, ctx, activeGoal);
333
+ if (activeGoal.status === "active") await sendObjectiveUpdatedPrompt(pi, ctx, activeGoal);
282
334
  }
283
335
 
284
336
  function showGoal(ctx: StatusContext) {
@@ -332,6 +384,21 @@ function incrementGoal(goal: ActiveGoal): ActiveGoal {
332
384
  return { ...goal, iteration: goal.iteration + 1, updatedAt: Date.now() };
333
385
  }
334
386
 
387
+ function pauseGoalAfterAgentEnd(
388
+ ctx: StatusContext,
389
+ goal: ActiveGoal,
390
+ assistant: AssistantMessageLike,
391
+ ) {
392
+ cancelContinuationPending();
393
+ activeGoal = transitionGoal(goal, "paused");
394
+ persistGoal(activeGoal);
395
+ updateStatus(ctx, activeGoal);
396
+
397
+ const reason = assistant.stopReason === "aborted" ? "interruption" : "agent error";
398
+ const details = assistant.errorMessage ? ` (${truncateNotification(assistant.errorMessage)})` : "";
399
+ ctx.ui.notify(`Goal paused after ${reason}${details}. Run /goal resume to continue.`, "warning");
400
+ }
401
+
335
402
  function updateGoalUsage(goal: ActiveGoal, ctx: StatusContext) {
336
403
  goal.tokensUsed = Math.max(0, currentTokenTotal(ctx) - goal.baselineTokens);
337
404
  goal.timeUsedSeconds = Math.max(0, Math.floor((Date.now() - goal.startedAt) / 1000));
@@ -415,21 +482,41 @@ function validateObjective(objective: string): string | undefined {
415
482
  return undefined;
416
483
  }
417
484
 
418
- function sendGoalPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
419
- sendPrompt(pi, ctx, buildGoalPrompt(goal));
485
+ async function sendGoalPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
486
+ return sendPrompt(pi, ctx, buildGoalPrompt(goal));
420
487
  }
421
488
 
422
- function sendObjectiveUpdatedPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
423
- sendPrompt(pi, ctx, buildObjectiveUpdatedPrompt(goal));
489
+ async function sendObjectiveUpdatedPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
490
+ return sendPrompt(pi, ctx, buildObjectiveUpdatedPrompt(goal));
424
491
  }
425
492
 
426
- function sendContinuationPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
427
- sendPrompt(pi, ctx, buildContinuePrompt(goal));
493
+ async function sendResumePrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
494
+ return sendPrompt(pi, ctx, buildResumePrompt(goal));
428
495
  }
429
496
 
430
- function sendPrompt(pi: ExtensionAPI, ctx: StatusContext, prompt: string) {
431
- if (ctx.isIdle?.()) pi.sendUserMessage(prompt);
432
- else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
497
+ async function sendContinuationPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
498
+ if (continuationPending?.goalId === goal.id) return false;
499
+ if (hasPendingMessages(ctx)) return false;
500
+
501
+ const marker = continuationMarker(goal);
502
+ const prompt = buildContinuePrompt(goal, marker);
503
+ continuationPending = { goalId: goal.id, iteration: goal.iteration, marker, prompt };
504
+ const sent = await sendPrompt(pi, ctx, prompt);
505
+ if (!sent && continuationPending?.marker === marker) continuationPending = undefined;
506
+ return sent;
507
+ }
508
+
509
+ async function sendPrompt(pi: ExtensionAPI, ctx: StatusContext, prompt: string) {
510
+ try {
511
+ const sent = ctx.isIdle?.()
512
+ ? (pi.sendUserMessage(prompt) as void | Promise<void>)
513
+ : (pi.sendUserMessage(prompt, { deliverAs: "followUp" }) as void | Promise<void>);
514
+ await sent;
515
+ return true;
516
+ } catch (error) {
517
+ ctx.ui.notify(`Goal prompt failed: ${formatError(error)}`, "error");
518
+ return false;
519
+ }
433
520
  }
434
521
 
435
522
  function updateStatus(ctx: StatusContext, goal: ActiveGoal) {
@@ -482,25 +569,116 @@ function formatTokenCount(value: number) {
482
569
 
483
570
  function buildGoalPrompt(goal: ActiveGoal) {
484
571
  const budgetLine = goal.tokenBudget === undefined ? "" : `\nToken budget: ${formatTokenCount(goal.tokenBudget)}.`;
485
- return `Goal mode is active. Complete this goal fully:\n\n${goal.text}${budgetLine}\n\n${goalPersistenceRules("this goal")}`;
572
+ return `Goal mode is active. Complete this goal fully:\n\n${goalObjectiveBlock(goal)}${budgetLine}\n\n${goalPersistenceRules("this goal")}`;
486
573
  }
487
574
 
488
575
  function buildObjectiveUpdatedPrompt(goal: ActiveGoal) {
489
576
  const budgetLine = goal.tokenBudget === undefined ? "" : `\nToken budget: ${formatBudget(goal)} used.`;
490
- return `The active /goal objective was updated. Continue working toward this goal:\n\n${goal.text}${budgetLine}\n\n${goalPersistenceRules("the updated goal")}`;
577
+ return `The active /goal objective was updated. Continue working toward this goal:\n\n${goalObjectiveBlock(goal)}${budgetLine}\n\n${goalPersistenceRules("the updated goal")}`;
578
+ }
579
+
580
+ function buildResumePrompt(goal: ActiveGoal) {
581
+ const budgetLine = goal.tokenBudget === undefined ? "" : `\nToken budget: ${formatBudget(goal)} used.`;
582
+ return `The user explicitly resumed the paused /goal. Continue working toward this goal:\n\n${goalObjectiveBlock(goal)}${budgetLine}\n\n${goalPersistenceRules("this goal")}`;
491
583
  }
492
584
 
493
585
  function buildGoalSystemPrompt(goal: ActiveGoal) {
494
586
  const budgetLine = goal.tokenBudget === undefined ? "" : `\n- Respect the goal token budget (${formatBudget(goal)} used).`;
495
- return `Active /goal: ${goal.text}\n\nGoal-mode rules:\n- Keep going until the active goal is completely resolved end-to-end.\n- Do not stop at analysis, a plan, TODO list, partial fixes, or suggested next steps.\n- Autonomously perform implementation and verification with the available tools when they are needed to complete the goal.\n- Persevere through recoverable tool failures by trying reasonable alternatives instead of yielding early.\n- If the goal is not complete at the end of a turn, expect an automatic continuation and keep working from where you left off.\n- Only call the goal_complete tool after the goal is fully complete and verified.${budgetLine}`;
587
+ return `Active /goal:\n${goalObjectiveBlock(goal)}\n\nGoal-mode rules:\n- Keep going until the active goal is completely resolved end-to-end.\n- Treat the current worktree, command output, tests, and external state as authoritative.\n- Do not redefine the goal into a smaller task; audit every requirement before completion.\n- Do not stop at analysis, a plan, TODO list, partial fixes, or suggested next steps.\n- Autonomously perform implementation and verification with the available tools when they are needed to complete the goal.\n- Persevere through recoverable tool failures by trying reasonable alternatives instead of yielding early.\n- If the goal is not complete at the end of a turn, expect an automatic continuation and keep working from where you left off.\n- Only call the goal_complete tool after the goal is fully complete and verified.${budgetLine}`;
496
588
  }
497
589
 
498
- function buildContinuePrompt(goal: ActiveGoal) {
499
- return `Continue the active /goal until it is complete:\n\n${goal.text}\n\nThis is automatic continuation #${goal.iteration}. ${goalPersistenceRules("this goal")}`;
590
+ function buildContinuePrompt(goal: ActiveGoal, marker: string) {
591
+ return `Continue the active /goal until it is complete:\n\n${goalObjectiveBlock(goal)}\n\nThis is automatic continuation #${goal.iteration}. Current files, command output, tests, and external state are authoritative; re-check them as needed. ${goalPersistenceRules("this goal")}\n\n${continuationMarkerComment(marker)}`;
592
+ }
593
+
594
+ function goalObjectiveBlock(goal: ActiveGoal) {
595
+ return `<goal_objective>\n${escapeXmlText(goal.text)}\n</goal_objective>`;
500
596
  }
501
597
 
502
598
  function goalPersistenceRules(goalLabel: string) {
503
- return `Keep going until ${goalLabel} is completely resolved end-to-end. Do not stop at analysis, a plan, TODO list, partial fixes, or suggested next steps. Autonomously perform implementation and verification with the available tools when they are needed. If a tool call fails, try reasonable alternatives instead of yielding early. Only call the goal_complete tool after ${goalLabel} is fully complete and verified.`;
599
+ return `Keep going until ${goalLabel} is completely resolved end-to-end. Do not redefine ${goalLabel} into a smaller task. Do not stop at analysis, a plan, TODO list, partial fixes, or suggested next steps. Autonomously perform implementation and verification with the available tools when they are needed. Treat the current worktree, command output, tests, and external state as authoritative. If a tool call fails, try reasonable alternatives instead of yielding early. Before calling goal_complete, audit ${goalLabel} requirement by requirement against the verified current state. Only call the goal_complete tool after ${goalLabel} is fully complete and verified.`;
600
+ }
601
+
602
+ function hasPendingMessages(ctx: StatusContext) {
603
+ return ctx.hasPendingMessages?.() ?? false;
604
+ }
605
+
606
+ function clearContinuationTracking() {
607
+ continuationPending = undefined;
608
+ cancelledContinuationMarkers.clear();
609
+ }
610
+
611
+ function cancelContinuationPending() {
612
+ if (continuationPending) rememberCancelledContinuationMarker(continuationPending.marker);
613
+ continuationPending = undefined;
614
+ }
615
+
616
+ function rememberCancelledContinuationMarker(marker: string) {
617
+ cancelledContinuationMarkers.add(marker);
618
+ if (cancelledContinuationMarkers.size <= MAX_CANCELLED_CONTINUATION_PROMPTS) return;
619
+ const oldest = cancelledContinuationMarkers.values().next().value;
620
+ if (oldest) cancelledContinuationMarkers.delete(oldest);
621
+ }
622
+
623
+ function consumeCancelledContinuationPrompt(prompt: string) {
624
+ const marker = extractContinuationMarker(prompt);
625
+ return marker ? cancelledContinuationMarkers.delete(marker) : false;
626
+ }
627
+
628
+ function markContinuationDelivered(prompt: string) {
629
+ const marker = extractContinuationMarker(prompt);
630
+ if (marker && continuationPending?.marker === marker) continuationPending = undefined;
631
+ }
632
+
633
+ function continuationMarker(goal: ActiveGoal) {
634
+ return `${goal.id}:${goal.iteration}`;
635
+ }
636
+
637
+ function continuationMarkerComment(marker: string) {
638
+ return `<!-- ${CONTINUATION_MARKER_PREFIX}${marker} -->`;
639
+ }
640
+
641
+ function escapeRegExpText(value: string) {
642
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
643
+ }
644
+
645
+ const CONTINUATION_MARKER_PATTERN = new RegExp(
646
+ `<!--\\s*${escapeRegExpText(CONTINUATION_MARKER_PREFIX)}([^\\s>]+)\\s*-->`,
647
+ );
648
+
649
+ function extractContinuationMarker(prompt: string) {
650
+ return CONTINUATION_MARKER_PATTERN.exec(prompt)?.[1];
651
+ }
652
+
653
+ function findFinalAssistantMessage(messages: unknown[]): AssistantMessageLike | undefined {
654
+ for (let i = messages.length - 1; i >= 0; i--) {
655
+ const message = messages[i];
656
+ if (!message || typeof message !== "object") continue;
657
+ const candidate = message as Record<string, unknown>;
658
+ if (candidate.role !== "assistant") continue;
659
+ return {
660
+ role: "assistant",
661
+ stopReason: isAgentStopReason(candidate.stopReason) ? candidate.stopReason : undefined,
662
+ errorMessage: typeof candidate.errorMessage === "string" ? candidate.errorMessage : undefined,
663
+ };
664
+ }
665
+ return undefined;
666
+ }
667
+
668
+ function isAgentStopReason(value: unknown): value is AgentStopReason {
669
+ return ["stop", "length", "toolUse", "error", "aborted"].includes(String(value));
670
+ }
671
+
672
+ function escapeXmlText(value: string) {
673
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
674
+ }
675
+
676
+ function formatError(error: unknown) {
677
+ return truncateNotification(error instanceof Error ? error.message : String(error));
678
+ }
679
+
680
+ function truncateNotification(value: string) {
681
+ return value.length > 160 ? `${value.slice(0, 157)}...` : value;
504
682
  }
505
683
 
506
684
  function currentTokenTotal(ctx: StatusContext): number {
@@ -543,6 +721,7 @@ function loadGoalFromSession(ctx: StatusContext): ActiveGoal | undefined {
543
721
  }
544
722
 
545
723
  function clearActiveGoal(ctx: StatusContext) {
724
+ cancelContinuationPending();
546
725
  activeGoal = undefined;
547
726
  clearPersistedGoal(ctx.cwd);
548
727
  ctx.ui.setStatus(STATUS_KEY, undefined);