pi-plans 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,7 +119,7 @@ Planning artifacts live under `./docs/pi-plans/YYYY-MM-DD-<topic>/` by default (
119
119
  | VCC compact | Active planning/execution compaction uses deterministic, no-LLM VCC-style summaries when Pi core emits manual `/compact`, threshold, or overflow events. Summaries use five bracket sections plus a brief transcript, keep a smart recent tail, support `keep:N`, and write VCC details/stats without adding `/pi-vcc` commands. |
120
120
  | Visible Refiner overlay | Delegated reviewer/criticizer subagents surface as a named public overlay in the TUI — one `Reviewer`/`Criticizer` panel with per-lane tool progress, full streaming transcript with follow-bottom scroll, Tab-pane focus, retention until the user presses `Esc` after completion, and clean cancelled/timed-out vs completed states. `reviewers: 3` renders three equal-height panes inside the same overlay |
121
121
  | Tracked execution | Checklist injected each turn; `[DONE:VC-xxx]` markers drive completion; implementation items report progress with `[I-xxx:implemented]` / `[I-xxx:validating]` markers; the bottom status bar shows lifecycle, `x/y` progress, elapsed time, and input/output token usage in real time |
122
- | Goal-wait continuation | While execution is active, a turn that ends with unpassed VCs gets an automatic light follow-up (`🔁 goal-wait` in the status bar) the worker keeps going, and rounds blocked on an external event poll via the taught `waiting for` backoff instead of stopping. Guard rails: 3 consecutive no-progress rounds, or 6 waiting rounds, pause the continuation (`⏸ goal-wait paused (reason)` in the status bar); any new input or `/plans-execute` re-kicks it, and `/plans-stop` always terminates |
122
+ | Goal-wait continuation | In TUI/RPC, only a fully settled agent with unpassed VCs and no pending input or compaction gets one hidden wake carrying the latest checklist. Tool turns never queue reminders or consume guard rounds. The status bar shows progress; 3 no-progress cycles or 6 waiting cycles pause continuation. User interruption and final model errors also pause it. Only genuine user input or `/plans-execute` resumes; extension messages cannot. Print/JSON single-shot sessions track progress without automatic wakes. `/plans-stop` terminates execution |
123
123
  | Execution handoff | The accepted plan resumes in the current session model; no separate model selection is performed. |
124
124
  | Execution-phase compaction | Pi core owns scheduling; pi-plans maps the active plan path, current `I-###`, implementation IDs, and remaining `VC-###` checklist into the VCC sections. The old current-I proactive trigger and model-generated summary path are removed. |
125
125
  | Planning-phase compaction | During `run.status=planning` with no active execution, pi-plans maps active run, artifact directory, latest plan path from session entries, and observed current-I markers into the VCC sections. Without an active planning run, compaction returns to Pi core. |
@@ -137,7 +137,7 @@ Planning artifacts live under `./docs/pi-plans/YYYY-MM-DD-<topic>/` by default (
137
137
  | `execute_plan` | Execution handoff: re-confirms with the user and enters extension-managed execution mode |
138
138
  | `/plans` | Show config, active run, and execution progress |
139
139
  | `/config-pi-plans` | Re-ask workspace defaults for language, artifact root, refs root, code graph, reviewer mode/model, and criticizer mode/model |
140
- | `/plans-execute [plan.md]` | Manual execution handoff (defaults to highest `PLAN_vN.md`) |
140
+ | `/plans-execute [plan.md]` | Resume a paused active execution without losing verified progress; otherwise enter the explicit execution handoff (defaults to highest `PLAN_vN.md`) |
141
141
  | `/update-plan [plan.md] [reason…]` | Interrupt-and-refine: stops execution (if any), returns the run to planning, and directs the agent to revise the plan into `PLAN_vN+1.md` while preserving verified work |
142
142
  | `/plans-autocomplete-stop` | Stop the current run's Auto-complete mode and return later planning questions to normal interaction |
143
143
  | `/plans-stop` | Stop execution mode |
package/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  drainExecutionFlush,
24
24
  executionContextMessage,
25
25
  filterExecutionResumeMessages,
26
+ filterGoalWaitMessages,
26
27
  filterPlanningResumeMessages,
27
28
  getExecution,
28
29
  handleExecutionBeforeCompact,
@@ -39,8 +40,6 @@ import {
39
40
  refreshPlanningCompactionCooldown,
40
41
  requestPlanningCompaction,
41
42
  restoreFromSession,
42
- resetGoalWaitTurnFlags,
43
- resumeGoalWaitIfPaused,
44
43
  stopExecution,
45
44
  updateStatusWidget,
46
45
  shouldTriggerPlanningCompaction,
@@ -69,7 +68,7 @@ import { latestPlanVersion, nextPlanVersionPath } from "./src/plan.ts";
69
68
  import { configPiPlansCommand } from "./src/config-command.ts";
70
69
  import { getRun, loadConfig, readActive, recordDecision, resolveStateRootOrNull, setRunStatus } from "./src/state.ts";
71
70
  import { registerAskChoiceTool } from "./tools/ask-choice.ts";
72
- import { executeHandoff, registerExecutePlanTool } from "./tools/execute-plan.ts";
71
+ import { executeCommand, registerExecutePlanTool } from "./tools/execute-plan.ts";
73
72
  import { registerPlansTool } from "./tools/plans.ts";
74
73
  import { registerRefineTool } from "./tools/refine.ts";
75
74
  import { registerAnalyzeRefsTool } from "./tools/analyze-refs.ts";
@@ -215,7 +214,7 @@ export default function piPlansExtension(pi: ExtensionAPI): void {
215
214
 
216
215
  pi.on("context", (event) => {
217
216
  const filteredExecution = filterExecutionResumeMessages(event.messages as Array<{ customType?: string }>);
218
- const messages = filterPlanningResumeMessages(filteredExecution);
217
+ const messages = filterGoalWaitMessages(filterPlanningResumeMessages(filteredExecution));
219
218
  if (messages.length !== event.messages.length) {
220
219
  return { messages };
221
220
  }
@@ -238,21 +237,11 @@ export default function piPlansExtension(pi: ExtensionAPI): void {
238
237
  noteCompactionEnded(ctx, event.customInstructions);
239
238
  });
240
239
 
241
- // Flush points for deferred execution-loop writes: primary drain when the
242
- // agent run fully settles, backstop drain at the next run's start (covers
243
- // continuation paths that might not emit agent_settled), plus the forced
244
- // synchronous flush inside stop/complete.
245
- pi.on("agent_settled", async (_event, ctx) => {
246
- drainExecutionFlush(pi, ctx);
247
- });
248
-
249
240
  // -----------------------------------------------------------------------
250
241
  // Execution loop: inject remaining checklist each turn, track markers.
251
242
  // -----------------------------------------------------------------------
252
243
  pi.on("before_agent_start", async (_event, ctx) => {
253
244
  drainExecutionFlush(pi, ctx);
254
- resetGoalWaitTurnFlags();
255
- resumeGoalWaitIfPaused(pi, ctx);
256
245
  const content = executionContextMessage(ctx);
257
246
  if (!content) {
258
247
  if (!getExecution() && shouldTriggerPlanningCompaction(ctx)) {
@@ -388,7 +377,7 @@ export default function piPlansExtension(pi: ExtensionAPI): void {
388
377
  description: "Execute handoff: enter tracked execution mode for an accepted plan",
389
378
  handler: async (args, ctx) => {
390
379
  const planPath = args.trim() || undefined;
391
- const outcome = await executeHandoff(ctx, planPath);
380
+ const outcome = await executeCommand(ctx, planPath);
392
381
  ctx.ui.notify(outcome.message, outcome.status === "error" ? "error" : "info");
393
382
  },
394
383
  });
@@ -543,8 +532,11 @@ export default function piPlansExtension(pi: ExtensionAPI): void {
543
532
  // -----------------------------------------------------------------------
544
533
  // Session lifecycle
545
534
  // -----------------------------------------------------------------------
535
+ pi.on("session_tree", async (_event, ctx) => {
536
+ await restoreFromSession(pi, ctx, ctx.sessionManager.getBranch() as unknown as Parameters<typeof restoreFromSession>[2]);
537
+ });
546
538
  pi.on("session_start", async (_event, ctx) => {
547
- await restoreFromSession(pi, ctx, ctx.sessionManager.getEntries() as unknown as Parameters<typeof restoreFromSession>[2]);
539
+ await restoreFromSession(pi, ctx, ctx.sessionManager.getBranch() as unknown as Parameters<typeof restoreFromSession>[2]);
548
540
  restoreAutoCompleteFromSession(ctx, ctx.sessionManager.getEntries() as unknown as Parameters<typeof restoreAutoCompleteFromSession>[1]);
549
541
  });
550
542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-plans",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Human-in-the-loop planning extension for the Pi coding agent: researched, refined Markdown plans before any code changes.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -137,6 +137,27 @@ When the user picks `✓ Accept PLAN_vN and execute it now` in the merged questi
137
137
 
138
138
  If the user declines, stay in planning (or stop, per their choice). Never start implementation without the approved handoff.
139
139
 
140
+ ### Execution Goal-Wait
141
+
142
+ In TUI/RPC, automatic goal-wait is evaluated only at `agent_settled`, after
143
+ Pi has finished natural tool continuation, retries, and compaction. The
144
+ extension rechecks that the same execution is active and incomplete, the
145
+ session is idle, and neither pending input nor compaction owns continuation.
146
+ Each eligible settled cycle can send at most one hidden custom message with
147
+ the current execution rules and remaining VC checklist. Tool `turn_end`
148
+ events only update progress; they never prequeue goal-wait reminders.
149
+
150
+ No-progress and literal `waiting for` counters advance only on eligible
151
+ settled cycles. Real marker progress resets both; thresholds remain 3 and 6.
152
+ User interruption and final model errors also pause continuation. Genuine
153
+ interactive/RPC user input or `/plans-execute` can resume a paused active
154
+ execution without losing verified VCs; extension input cannot unpause it.
155
+ New-plan handoffs and the `execute_plan` tool still require explicit approval.
156
+ Completion, stop, and session replacement invalidate the extension's wake
157
+ identity without clearing user or other-extension queues. Print/JSON
158
+ single-shot sessions keep VC tracking and completion but never auto-wake;
159
+ use RPC for persistent headless execution.
160
+
140
161
  ### Post-Execution Continuation
141
162
 
142
163
  When execution completes in an interactive session, the completion message attaches a goal-running continuation block and triggers a new agent turn so the model can enter the implementation-review loop immediately. The interactive-only trigger keeps headless sessions silent (no unconsented subagent cost). The same behavior applies on both completion call sites (the normal `turn_end` completion and the `restoreFromSession` recovery path).
package/src/exec.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
+ import { randomUUID } from "node:crypto";
11
12
  import type {
12
13
  CompactionResult,
13
14
  ExtensionAPI,
@@ -76,6 +77,32 @@ const GOAL_WAIT_MAX_WAITING = 6;
76
77
 
77
78
  let execution: ExecState | null = null;
78
79
 
80
+ export const GOAL_WAIT_CUSTOM_TYPE = "pi-plans-goal-wait";
81
+
82
+ interface GoalWaitRuntime {
83
+ owner: ExecState;
84
+ session: ExtensionContext["sessionManager"];
85
+ handled: boolean;
86
+ stopReason?: string;
87
+ text: string;
88
+ wakeId?: string;
89
+ }
90
+
91
+ // Dispatch identity belongs to a live session, never to a persisted checklist.
92
+ let goalWaitRuntime: GoalWaitRuntime | null = null;
93
+
94
+ function resetGoalWaitRuntime(ctx: ExtensionContext): void {
95
+ goalWaitRuntime = execution
96
+ ? { owner: execution, session: ctx.sessionManager, handled: false, text: "" }
97
+ : null;
98
+ }
99
+
100
+ function currentGoalWaitRuntime(ctx: ExtensionContext): GoalWaitRuntime | null {
101
+ return goalWaitRuntime?.owner === execution && goalWaitRuntime.session === ctx.sessionManager
102
+ ? goalWaitRuntime
103
+ : null;
104
+ }
105
+
79
106
  // Execution-loop persistence is deferred until the agent settles so turn_end
80
107
  // never causes session writes during a streaming run.
81
108
  let pendingExecutionFlush = false;
@@ -286,6 +313,7 @@ export async function startExecution(
286
313
  // Seed the marker baseline so the first quiet round is counted against a
287
314
  // real snapshot instead of counting unconditionally (F-006).
288
315
  if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
316
+ resetGoalWaitRuntime(ctx);
289
317
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
290
318
  resetExecutionCompactionState(ctx);
291
319
  persist(pi);
@@ -331,6 +359,30 @@ export function registerExecutionTurnHandlers(
331
359
  // The turn_end projection does not carry usage; message_end delivers the
332
360
  // full assistant message, so cache it here and consume it per turn.
333
361
  let lastAssistantUsage: { input: number; output: number } | null = null;
362
+ pi.on("agent_start", async (_event, ctx) => {
363
+ const runtime = currentGoalWaitRuntime(ctx);
364
+ if (!runtime) return;
365
+ runtime.handled = false;
366
+ runtime.stopReason = undefined;
367
+ runtime.text = "";
368
+ });
369
+ pi.on("before_agent_start", async (_event, ctx) => {
370
+ const runtime = currentGoalWaitRuntime(ctx);
371
+ if (runtime) runtime.wakeId = undefined;
372
+ });
373
+ pi.on("input", async (event, ctx) => {
374
+ if (event.source === "interactive" || event.source === "rpc") resumeGoalWaitIfPaused(pi, ctx);
375
+ });
376
+ pi.on("agent_settled", async (_event, ctx) => {
377
+ drainExecutionFlush(pi, ctx);
378
+ maybeGoalWaitFollowUp(pi, ctx);
379
+ });
380
+ pi.on("session_shutdown", async (_event, ctx) => {
381
+ drainExecutionFlush(pi, ctx);
382
+ execution = null;
383
+ goalWaitRuntime = null;
384
+ lastAssistantUsage = null;
385
+ });
334
386
  pi.on("message_end", async (event) => {
335
387
  const message = event.message as { role?: string; usage?: { input?: number; output?: number } };
336
388
  if (message?.role === "assistant" && message.usage) {
@@ -339,7 +391,7 @@ export function registerExecutionTurnHandlers(
339
391
  });
340
392
 
341
393
  pi.on("turn_end", async (event, ctx) => {
342
- const message = event.message as { role?: string; content?: Array<{ type: string; text?: string }> };
394
+ const message = event.message as { role?: string; stopReason?: string; content?: Array<{ type: string; text?: string }> };
343
395
  if (!message || message.role !== "assistant") {
344
396
  updateStatusWidget(ctx);
345
397
  return;
@@ -348,6 +400,11 @@ export function registerExecutionTurnHandlers(
348
400
  .filter((part) => part.type === "text")
349
401
  .map((part) => part.text ?? "")
350
402
  .join("\n");
403
+ const runtime = currentGoalWaitRuntime(ctx);
404
+ if (runtime) {
405
+ runtime.stopReason = message.stopReason;
406
+ runtime.text = text;
407
+ }
351
408
  const changedIds = applyDoneMarkers(text);
352
409
  const changedImpls = applyImplMarkers(text);
353
410
  const changedCurrentI = applyCurrentIMarker(text);
@@ -361,8 +418,6 @@ export function registerExecutionTurnHandlers(
361
418
  }
362
419
  if (getExecution() && isExecutionComplete()) {
363
420
  await completeExecution(pi, ctx);
364
- } else if (getExecution()) {
365
- maybeGoalWaitFollowUp(pi, ctx, text);
366
421
  }
367
422
  await onTurnEnd?.(ctx);
368
423
  });
@@ -492,7 +547,6 @@ export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionCon
492
547
  if (!event.willRetry && stats) {
493
548
  ctx.ui.notify(formatVccCompactionStats(stats), "info");
494
549
  if (followUpPrompt) {
495
- compactionFollowUpSentThisTurn = true;
496
550
  await pi.sendUserMessage?.(followUpPrompt);
497
551
  } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
498
552
  state.resumeGuard = true;
@@ -843,6 +897,7 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
843
897
  pendingExecutionFlush = false;
844
898
  persist(pi);
845
899
  execution = null;
900
+ goalWaitRuntime = null;
846
901
  pi.appendEntry("pi-plans-exec-cleared", { reason });
847
902
  pi.sendMessage(
848
903
  {
@@ -909,13 +964,6 @@ export function isExecutionComplete(): boolean {
909
964
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
910
965
  }
911
966
 
912
- let compactionFollowUpSentThisTurn = false;
913
-
914
- /** Reset per-turn continuation flags at the start of a new agent turn. */
915
- export function resetGoalWaitTurnFlags(): void {
916
- compactionFollowUpSentThisTurn = false;
917
- }
918
-
919
967
  function goalWaitSnapshot(): string {
920
968
  if (!execution) return "";
921
969
  return JSON.stringify({
@@ -942,46 +990,67 @@ function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string):
942
990
  updateStatusWidget(ctx);
943
991
  }
944
992
 
945
- /**
946
- * Goal-wait continuation: a turn that ends with unpassed VCs gets one light
947
- * followUp so the worker keeps going (working, or polling an external event
948
- * per the taught backoff rules). Skipped when the compaction machinery owns
949
- * continuation for this turn, and paused entirely by the no-progress guard.
950
- * Note: the tri-flag check here deliberately runs BEFORE index.ts's
951
- * handleExecutionTurnCompaction consumes resumeGuard — checking after the
952
- * one-shot consumption would never observe it.
953
- */
954
- function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistantText: string): void {
955
- const ex = getExecution();
956
- if (!ex) return;
957
- ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
958
- const goalWait = ex.goalWait;
959
- if (goalWait.paused) {
960
- updateStatusWidget(ctx);
961
- return;
962
- }
993
+ function canWakeExecution(ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
963
994
  const compaction = executionCompactionState(ctx);
964
- if (compaction && (compaction.inFlight || compaction.pendingFollowUpPrompt != null || compaction.resumeGuard)) {
965
- updateStatusWidget(ctx);
966
- return;
995
+ return currentGoalWaitRuntime(ctx) === runtime
996
+ && (ctx.mode === "tui" || ctx.mode === "rpc")
997
+ && !isExecutionComplete()
998
+ && !runtime.owner.goalWait?.paused
999
+ && ctx.isIdle()
1000
+ && !ctx.hasPendingMessages()
1001
+ && !ctx.signal?.aborted
1002
+ && !compactionInFlight(ctx, "execution")
1003
+ && !compaction?.inFlight
1004
+ && !compaction?.resumeGuard
1005
+ && compaction?.pendingFollowUpPrompt == null;
1006
+ }
1007
+
1008
+ function sendGoalWaitWake(pi: ExtensionAPI, ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
1009
+ if (!canWakeExecution(ctx, runtime)) return false;
1010
+ try {
1011
+ // Custom messages bypass before_agent_start, so carry fresh execution rules.
1012
+ const content = executionContextMessage(ctx);
1013
+ if (!content) return false;
1014
+ runtime.wakeId = randomUUID();
1015
+ pi.sendMessage({
1016
+ customType: GOAL_WAIT_CUSTOM_TYPE,
1017
+ content,
1018
+ display: false,
1019
+ details: { wakeId: runtime.wakeId },
1020
+ }, { triggerTurn: true });
1021
+ return true;
1022
+ } catch (error) {
1023
+ runtime.wakeId = undefined;
1024
+ pauseGoalWait(pi, ctx, `continuation failed: ${String(error)}`);
1025
+ return false;
967
1026
  }
968
- if (compactionFollowUpSentThisTurn) {
969
- compactionFollowUpSentThisTurn = false; // the compaction path already queued a continuation
970
- updateStatusWidget(ctx);
1027
+ }
1028
+
1029
+ /** Only a fully settled agent run can need an extra wake, never a tool turn. */
1030
+ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext): void {
1031
+ const runtime = currentGoalWaitRuntime(ctx);
1032
+ if (!runtime || runtime.handled || !ctx.isIdle()) return;
1033
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
1034
+ if (runtime.stopReason === "error" || runtime.stopReason === "aborted" || ctx.signal?.aborted) {
1035
+ runtime.handled = true;
1036
+ pauseGoalWait(pi, ctx, runtime.stopReason === "error" ? "agent failed" : "agent interrupted");
971
1037
  return;
972
1038
  }
1039
+ if (runtime.stopReason !== "stop" || !canWakeExecution(ctx, runtime)) return;
1040
+ runtime.handled = true;
1041
+ const ex = runtime.owner;
1042
+ ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
1043
+ const goalWait = ex.goalWait;
973
1044
  const snapshot = goalWaitSnapshot();
974
1045
  const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers;
975
1046
  goalWait.lastMarkers = snapshot;
976
- if (!changed) {
977
- if (/waiting for/i.test(assistantText)) {
978
- goalWait.waitRounds += 1;
979
- } else {
980
- goalWait.noProgressRounds += 1;
981
- }
982
- } else {
1047
+ if (changed) {
983
1048
  goalWait.noProgressRounds = 0;
984
1049
  goalWait.waitRounds = 0;
1050
+ } else if (/waiting for/i.test(runtime.text)) {
1051
+ goalWait.waitRounds += 1;
1052
+ } else {
1053
+ goalWait.noProgressRounds += 1;
985
1054
  }
986
1055
  if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) {
987
1056
  pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`);
@@ -991,25 +1060,40 @@ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistan
991
1060
  pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`);
992
1061
  return;
993
1062
  }
994
- const remaining = ex.items.filter((item) => !item.done);
995
- const remainingIds = remaining.map((item) => `\`${item.id}\``).join(", ");
996
- pi.sendUserMessage?.(
997
- `Goal wait: ${remaining.length}/${ex.items.length} verifier items still open (${remainingIds}). Continue the plan — if blocked on an external event, keep waiting per the backoff rules; otherwise resolve the remaining items.`,
998
- { deliverAs: "followUp" },
999
- );
1063
+ persist(pi);
1000
1064
  updateStatusWidget(ctx);
1065
+ // No await between the live gate and dispatch: another input cannot interleave.
1066
+ sendGoalWaitWake(pi, ctx, runtime);
1067
+ }
1068
+
1069
+ export function filterGoalWaitMessages<T extends { customType?: string; details?: unknown }>(messages: T[]): T[] {
1070
+ return messages.filter((message) => message.customType !== GOAL_WAIT_CUSTOM_TYPE
1071
+ || (goalWaitRuntime?.owner === execution && goalWaitRuntime?.wakeId !== undefined
1072
+ && (message.details as { wakeId?: unknown } | undefined)?.wakeId === goalWaitRuntime.wakeId));
1001
1073
  }
1002
1074
 
1003
- /** Any external input re-kicks a paused goal-wait (clears pause and counters). */
1004
- export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): void {
1075
+ /** Called only for genuine user input or an explicit same-execution resume. */
1076
+ export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
1005
1077
  const ex = getExecution();
1006
- if (!ex?.goalWait?.paused) return;
1078
+ if (!ex?.goalWait?.paused || !currentGoalWaitRuntime(ctx)) return false;
1007
1079
  ex.goalWait.paused = false;
1008
1080
  ex.goalWait.pausedReason = undefined;
1009
1081
  ex.goalWait.noProgressRounds = 0;
1010
1082
  ex.goalWait.waitRounds = 0;
1083
+ ex.goalWait.lastMarkers = goalWaitSnapshot();
1011
1084
  persist(pi);
1012
1085
  updateStatusWidget(ctx);
1086
+ return true;
1087
+ }
1088
+
1089
+ export function resumeActiveExecution(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
1090
+ if (!resumeGoalWaitIfPaused(pi, ctx)) return false;
1091
+ const runtime = currentGoalWaitRuntime(ctx)!;
1092
+ if (canWakeExecution(ctx, runtime)) {
1093
+ runtime.handled = true;
1094
+ sendGoalWaitWake(pi, ctx, runtime);
1095
+ }
1096
+ return true;
1013
1097
  }
1014
1098
 
1015
1099
  export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
@@ -1022,6 +1106,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1022
1106
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
1023
1107
  const planPath = execution.planPath;
1024
1108
  execution = null;
1109
+ goalWaitRuntime = null;
1025
1110
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
1026
1111
  // Post-execution goal-running continuation: in interactive sessions, attach
1027
1112
  // the continuation block and trigger a new turn so the agent immediately
@@ -1111,6 +1196,7 @@ interface SessionEntry {
1111
1196
  */
1112
1197
  export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
1113
1198
  pendingExecutionFlush = false; // no flush debt survives a restart
1199
+ goalWaitRuntime = null;
1114
1200
  resetExecutionCompactionState(ctx);
1115
1201
  let snapshotIndex = -1;
1116
1202
  let snapshot: ExecState | null = null;
@@ -1169,6 +1255,7 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1169
1255
  }
1170
1256
  }
1171
1257
  if (execution) {
1258
+ resetGoalWaitRuntime(ctx);
1172
1259
  // D-010: replay may have advanced progress past the persisted baseline.
1173
1260
  // Recompute the goal-wait markers; new progress resets the guard counters.
1174
1261
  if (execution.goalWait) {
@@ -0,0 +1,137 @@
1
+ import * as assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { after, describe, it } from "node:test";
6
+ import { InMemoryCredentialStore, createAssistantMessageEventStream } from "@earendil-works/pi-ai";
7
+ import { createAgentSession, DefaultResourceLoader, initTheme, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import piPlansExtension from "../index.ts";
10
+ import { GOAL_WAIT_CUSTOM_TYPE, getExecution, startExecution } from "../src/exec.ts";
11
+
12
+ initTheme("dark", false);
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-lifecycle-"));
14
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
15
+ let serial = 0;
16
+
17
+ async function exercise(mode: "tui" | "rpc" | "print" | "json", needsWake: boolean, commandResume = false) {
18
+ const cwd = path.join(root, String(++serial));
19
+ fs.mkdirSync(cwd);
20
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
21
+ const modelRuntime = await ModelRuntime.create({
22
+ credentials: new InMemoryCredentialStore(), modelsPath: null,
23
+ modelsStorePath: path.join(cwd, "models-store.json"), allowModelNetwork: false, refreshOnCreate: false,
24
+ });
25
+ const inputs: any[] = [];
26
+ let toolCalls = 0;
27
+ modelRuntime.registerProvider("local-lifecycle-test", {
28
+ baseUrl: "http://unused.invalid", api: "openai-completions", apiKey: "not-a-real-key",
29
+ models: [{ id: "fixture", name: "fixture", reasoning: false, input: ["text"],
30
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 1024 }],
31
+ streamSimple: (model: any, context: any) => {
32
+ inputs.push(structuredClone({ messages: context.messages, systemPrompt: context.systemPrompt }));
33
+ const call = inputs.length;
34
+ assert.ok(call <= 5, "unexpected extra model invocation");
35
+ const tool = call <= 2;
36
+ const text = call === 3
37
+ ? needsWake ? "[DONE:VC-001] More verification remains." : "[DONE:VC-001] [DONE:VC-002]"
38
+ : call === 4 && needsWake ? "[DONE:VC-002]" : "Review awaits explicit user approval.";
39
+ const message: any = {
40
+ role: "assistant", api: model.api, provider: model.provider, model: model.id,
41
+ content: tool ? [
42
+ ...(commandResume && call === 2 ? [{ type: "text", text: "[DONE:VC-001]" }] : []),
43
+ { type: "toolCall", id: `call-${call}`, name: "probe", arguments: {} },
44
+ ] : [{ type: "text", text: commandResume && call === 3 ? "Interrupted." : text }],
45
+ stopReason: tool ? "toolUse" : commandResume && call === 3 ? "aborted" : "stop", timestamp: Date.now(),
46
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15,
47
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
48
+ };
49
+ const stream = createAssistantMessageEventStream();
50
+ stream.push({ type: "start", partial: message });
51
+ if (message.stopReason === "aborted") stream.push({ type: "error", reason: "aborted", error: message });
52
+ else stream.push({ type: "done", reason: message.stopReason, message });
53
+ stream.end(message);
54
+ return stream;
55
+ },
56
+ });
57
+ let beforeAgentStarts = 0;
58
+ const events: string[] = [];
59
+ const errors: string[] = [];
60
+ const loader = new DefaultResourceLoader({
61
+ cwd, agentDir: path.join(cwd, "agent"), settingsManager,
62
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true,
63
+ agentsFilesOverride: () => ({ agentsFiles: [] }), systemPromptOverride: () => "Deterministic test.",
64
+ extensionFactories: [piPlansExtension, pi => {
65
+ pi.on("before_agent_start", () => { beforeAgentStarts++; });
66
+ pi.on("session_start", async (_event, ctx) => {
67
+ await startExecution(pi, ctx, path.join(cwd, "PLAN_v1.md"), [
68
+ { id: "VC-001", text: "first", done: false }, { id: "VC-002", text: "second", done: false },
69
+ ]);
70
+ });
71
+ }],
72
+ });
73
+ await loader.reload();
74
+ assert.deepEqual(loader.getExtensions().errors, []);
75
+ const { session } = await createAgentSession({
76
+ cwd, agentDir: path.join(cwd, "agent"), modelRuntime,
77
+ model: modelRuntime.getModel("local-lifecycle-test", "fixture")!, thinkingLevel: "off",
78
+ resourceLoader: loader, settingsManager, sessionManager: SessionManager.inMemory(cwd), tools: ["probe"],
79
+ customTools: [{ name: "probe", label: "Probe", description: "Local test probe", parameters: Type.Object({}),
80
+ execute: async () => { toolCalls++; return { content: [{ type: "text", text: "ok" }], details: {} }; } }],
81
+ });
82
+ const unsubscribe = session.subscribe(event => events.push(event.type));
83
+ try {
84
+ await session.bindExtensions({
85
+ mode,
86
+ ...(mode === "tui" || mode === "rpc" ? { uiContext: {
87
+ setStatus: () => {}, notify: () => {}, theme: { fg: (_c: string, s: string) => s },
88
+ } as any } : {}),
89
+ onError: error => errors.push(error.error),
90
+ });
91
+ await session.prompt("Implement the test plan.");
92
+ await session.waitForIdle();
93
+ if (commandResume) {
94
+ assert.equal(getExecution()?.goalWait?.paused, true);
95
+ assert.deepEqual(getExecution()?.items.map(item => item.done), [true, false]);
96
+ assert.equal(inputs.length, 3);
97
+ await session.prompt("/plans-execute");
98
+ }
99
+ // SDK callers, unlike print mode, own the runtime until all nested wakes settle.
100
+ await session.waitForIdle();
101
+ assert.deepEqual(errors, []);
102
+ assert.deepEqual(session.messages.filter((m: any) => m.role === "assistant" && m.stopReason === "error"), [], "fixture model must run successfully");
103
+ const wakes = session.messages.filter((m: any) => m.customType === GOAL_WAIT_CUSTOM_TYPE) as any[];
104
+ assert.equal(toolCalls, 2);
105
+ assert.equal(beforeAgentStarts, 1, "custom wake must work without before_agent_start");
106
+ const interactive = mode === "tui" || mode === "rpc";
107
+ assert.equal(wakes.length, interactive && needsWake ? 1 : 0);
108
+ assert.equal(inputs.length, interactive ? needsWake ? 5 : 4 : 3);
109
+ if (interactive && needsWake) {
110
+ assert.equal(wakes[0].display, false);
111
+ assert.match(JSON.stringify(inputs[3].messages), /1\/2 verifier items done/);
112
+ assert.match(wakes[0].content, /- `VC-002` second/);
113
+ assert.doesNotMatch(wakes[0].content, /- `VC-001` first/);
114
+ }
115
+ if (interactive || !needsWake) assert.equal(getExecution(), null);
116
+ else assert.deepEqual(getExecution()?.items.map(item => item.done), [true, false]);
117
+ assert.equal(session.pendingMessageCount, 0);
118
+ assert.equal(events.at(-1), "agent_settled");
119
+ return { events, calls: inputs.length, wakes: wakes.length };
120
+ } finally {
121
+ unsubscribe();
122
+ session.dispose();
123
+ }
124
+ }
125
+
126
+ describe("goal-wait on the real Pi host", () => {
127
+ it("dispatches the registered /plans-execute command and preserves completed VCs", { timeout: 15000 }, async () => {
128
+ await exercise("rpc", true, true);
129
+ });
130
+ for (const mode of ["tui", "rpc", "print", "json"] as const) {
131
+ for (const needsWake of [false, true]) {
132
+ it(`${mode}: tools then ${needsWake ? "incomplete stop" : "completion"}`, { timeout: 15000 }, async () => {
133
+ await exercise(mode, needsWake);
134
+ });
135
+ }
136
+ }
137
+ });
@@ -20,7 +20,6 @@ import {
20
20
  filterExecutionResumeMessages,
21
21
  filterPlanningResumeMessages,
22
22
  getExecution,
23
- resumeGoalWaitIfPaused,
24
23
  handleExecutionBeforeCompact,
25
24
  handleExecutionCompact,
26
25
  handleExecutionTurnCompaction,
@@ -37,7 +36,6 @@ import {
37
36
  refreshPlanningCompactionCooldown,
38
37
  requestPlanningCompaction,
39
38
  restoreFromSession,
40
- resetGoalWaitTurnFlags,
41
39
  shouldTriggerPlanningCompaction,
42
40
  startExecution,
43
41
  recordExecutionTurn,
@@ -1459,134 +1457,6 @@ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSu
1459
1457
  };
1460
1458
  }
1461
1459
 
1462
- describe("execution goal-wait continuation", () => {
1463
- // Fresh per-turn continuation flags, mirroring the before_agent_start reset.
1464
- const setup = (workdir: string) => {
1465
- resetGoalWaitTurnFlags();
1466
- const harness = makeHarness(workdir);
1467
- registerExecutionTurnHandlers(harness.pi);
1468
- return harness;
1469
- };
1470
-
1471
- it("sends a goal-wait followUp when a turn ends with unpassed VCs", async () => {
1472
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1473
- const { pi, ctx, recorded, emit } = setup(workdir);
1474
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1475
- recorded.userMessages.length = 0;
1476
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1477
- assert.equal(recorded.userMessages.length, 1);
1478
- assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
1479
- assert.match(recorded.userMessages[0], /\`VC-001\`/);
1480
- assert.equal(recorded.userMessageOptions.at(-1)?.deliverAs, "followUp");
1481
- assert.match(recorded.status ?? "", /goal-wait · 无进展 1\/3 · 等待 0\/6/);
1482
- });
1483
-
1484
- it("sends the goal-wait followUp in headless sessions too", async () => {
1485
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1486
- const { pi, ctx, recorded, emit } = setup(workdir);
1487
- (ctx as any).hasUI = false;
1488
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1489
- recorded.userMessages.length = 0;
1490
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1491
- assert.equal(recorded.userMessages.length, 1);
1492
- assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
1493
- });
1494
-
1495
- it("does not goal-wait when every VC is done (completion path)", async () => {
1496
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1497
- const { pi, ctx, recorded, emit } = setup(workdir);
1498
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1499
- recorded.userMessages.length = 0;
1500
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "done [DONE:VC-001]" }] } });
1501
- assert.equal(recorded.userMessages.length, 0);
1502
- assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
1503
- });
1504
-
1505
- it("skips goal-wait while any compaction continuation flag is active", async () => {
1506
- const variants = [
1507
- { inFlight: true, resumeGuard: false, pendingFollowUpPrompt: null },
1508
- { inFlight: false, resumeGuard: true, pendingFollowUpPrompt: null },
1509
- { inFlight: false, resumeGuard: false, pendingFollowUpPrompt: "compaction follow-up" },
1510
- ];
1511
- for (const flags of variants) {
1512
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1513
- const { pi, ctx, recorded, emit } = setup(workdir);
1514
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1515
- (ctx.sessionManager as any).__executionCompaction = { ...flags, cooldownActive: false };
1516
- recorded.userMessages.length = 0;
1517
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1518
- assert.equal(recorded.userMessages.length, 0, `flags ${JSON.stringify(flags)} must skip goal-wait`);
1519
- }
1520
- });
1521
-
1522
- it("pauses after 3 no-progress rounds and resumes on kick", async () => {
1523
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1524
- const { pi, ctx, recorded, emit } = setup(workdir);
1525
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1526
- recorded.userMessages.length = 0;
1527
- for (let round = 0; round < 3; round++) {
1528
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1529
- }
1530
- assert.equal(getExecution()?.goalWait?.paused, true);
1531
- assert.equal(recorded.userMessages.length, 2, "third quiet round must not queue another followUp");
1532
- assert.ok(recorded.notifies.some((entry) => /goal-wait paused/.test(entry.message)));
1533
- assert.match(recorded.status ?? "", /⏸ goal-wait paused/);
1534
-
1535
- resumeGoalWaitIfPaused(pi, ctx);
1536
- assert.equal(getExecution()?.goalWait?.paused, false);
1537
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
1538
- assert.match(recorded.userMessages.at(-1) ?? "", /Goal wait/);
1539
- });
1540
-
1541
- it("waiting rounds are exempt until the sixth quiet waiting round", async () => {
1542
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1543
- const { pi, ctx, recorded, emit } = setup(workdir);
1544
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1545
- for (let round = 1; round <= 5; round++) {
1546
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: `waiting for CI (${round})` }] } });
1547
- assert.equal(getExecution()?.goalWait?.paused, false, `round ${round} must not pause`);
1548
- }
1549
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "waiting for CI (6)" }] } });
1550
- assert.equal(getExecution()?.goalWait?.paused, true);
1551
- assert.ok(recorded.notifies.some((entry) => /waiting without progress for 6 rounds/.test(entry.message)));
1552
- });
1553
-
1554
- it("progress resets both guard counters", async () => {
1555
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1556
- const { pi, ctx, emit } = makeHarness(workdir);
1557
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
1558
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1559
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1560
- await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
1561
- assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
1562
- assert.equal(getExecution()?.goalWait?.waitRounds, 0);
1563
- });
1564
-
1565
- it("keeps goal-wait counters across restore", async () => {
1566
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1567
- const { pi, ctx } = setup(workdir);
1568
- const planPath = path.join(workdir, "PLAN_v1.md");
1569
- fs.writeFileSync(planPath, "# plan");
1570
- const snapshot = {
1571
- planPath,
1572
- items: items("VC-001"),
1573
- startedAt: "2026-08-25T00:00:00Z",
1574
- usage: { inToks: 0, outToks: 0 },
1575
- implItems: [],
1576
- implStatus: {},
1577
- goalWait: { noProgressRounds: 2, waitRounds: 1, lastMarkers: null, paused: false },
1578
- };
1579
- const entries = [
1580
- { type: "custom", customType: "pi-plans-exec", data: snapshot },
1581
- { type: "message", message: { role: "assistant", content: [{ type: "text", text: "no new progress this turn" }] } },
1582
- ];
1583
- await restoreFromSession(pi, ctx, entries as any);
1584
- // Replay advanced the marker snapshot past the persisted baseline → counters reset (D-010).
1585
- assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
1586
- assert.equal(getExecution()?.goalWait?.waitRounds, 0);
1587
- });
1588
- });
1589
-
1590
1460
  describe("amelioration termination prompt", () => {
1591
1461
  it("recommends goal-wait first and keeps the round options", () => {
1592
1462
  assert.match(AMELIORATION_PROMPT_TEXT, /goal wait: continue until no unpassed VCs remain/);
@@ -0,0 +1,269 @@
1
+ import * as assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { after, describe, it } from "node:test";
6
+ import {
7
+ GOAL_WAIT_CUSTOM_TYPE, filterGoalWaitMessages, getExecution, noteCompactionStarted,
8
+ registerExecutionTurnHandlers, restoreFromSession, startExecution, stopExecution,
9
+ } from "../src/exec.ts";
10
+ import { executeCommand, executeHandoff, setCurrentApi } from "../tools/execute-plan.ts";
11
+
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
13
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
14
+ let serial = 0;
15
+
16
+ async function setup(mode = "tui") {
17
+ const cwd = path.join(root, String(++serial));
18
+ fs.mkdirSync(cwd);
19
+ const planPath = path.join(cwd, "PLAN_v1.md");
20
+ fs.writeFileSync(planPath, "## Verifier Checklist\n- [ ] `VC-001` first\n- [ ] `VC-002` second\n");
21
+ const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
22
+ const messages: any[] = [];
23
+ const entries: any[] = [];
24
+ const notices: string[] = [];
25
+ const pending: unknown[] = [];
26
+ let idle = true;
27
+ let status = "";
28
+ const ctx: any = {
29
+ cwd, mode, hasUI: mode === "tui" || mode === "rpc", sessionManager: {},
30
+ isIdle: () => idle, hasPendingMessages: () => pending.length > 0,
31
+ ui: { setStatus: (_key: string, s: string) => { status = s; },
32
+ notify: (s: string) => notices.push(s), theme: { fg: (_c: string, s: string) => s },
33
+ confirm: async () => { throw new Error("same-plan resume must not re-enter handoff"); } },
34
+ };
35
+ const pi: any = {
36
+ on: (name: string, handler: any) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
37
+ appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data: structuredClone(data) }),
38
+ sendUserMessage: () => { throw new Error("goal-wait must not impersonate a user"); },
39
+ sendMessage: (message: any, options: any) => {
40
+ messages.push({ ...message, options });
41
+ if (message.customType === GOAL_WAIT_CUSTOM_TYPE && options?.triggerTurn) idle = false;
42
+ },
43
+ };
44
+ registerExecutionTurnHandlers(pi);
45
+ setCurrentApi(pi);
46
+ await startExecution(pi, ctx, planPath, [
47
+ { id: "VC-001", text: "first", done: false }, { id: "VC-002", text: "second", done: false },
48
+ ]);
49
+ const emit = async (name: string, event = {}) => {
50
+ for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
51
+ };
52
+ const begin = async () => { idle = false; await emit("agent_start"); };
53
+ const turn = async (text = "working", stopReason = "stop") => emit("turn_end", {
54
+ message: { role: "assistant", stopReason, content: [{ type: "text", text },
55
+ ...(stopReason === "toolUse" ? [{ type: "toolCall", id: "call", name: "read", arguments: {} }] : [])],
56
+ usage: { input: 10, output: 5 } }, toolResults: [],
57
+ });
58
+ const settle = async () => { idle = true; await emit("agent_settled"); };
59
+ const run = async (text = "working", stopReason = "stop") => { await begin(); await turn(text, stopReason); await settle(); };
60
+ return { pi, ctx, planPath, messages, entries, notices, pending, emit, begin, turn, settle, run,
61
+ wakes: () => messages.filter(m => m.customType === GOAL_WAIT_CUSTOM_TYPE),
62
+ status: () => status, setIdle: (value: boolean) => { idle = value; } };
63
+ }
64
+
65
+ describe("goal-wait settled lifecycle", () => {
66
+ it("never queues on ten tool turns and never wakes after completion", async () => {
67
+ const h = await setup();
68
+ await h.begin();
69
+ for (let i = 0; i < 10; i++) await h.turn("working", "toolUse");
70
+ assert.equal(h.wakes().length, 0);
71
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
72
+ await h.turn("[DONE:VC-001] [DONE:VC-002]");
73
+ await h.settle();
74
+ await h.settle();
75
+ assert.equal(getExecution(), null);
76
+ assert.equal(h.wakes().length, 0);
77
+ assert.equal(h.messages.filter(m => m.customType === "pi-plans-complete").length, 1);
78
+ });
79
+
80
+ for (const mode of ["tui", "rpc"]) {
81
+ it(`${mode}: sends one hidden fresh wake and deduplicates settled`, async () => {
82
+ const h = await setup(mode);
83
+ await h.run("[DONE:VC-001]");
84
+ await h.settle();
85
+ const [wake] = h.wakes();
86
+ assert.equal(h.wakes().length, 1);
87
+ assert.equal(wake.display, false);
88
+ assert.equal(wake.options.triggerTurn, true);
89
+ assert.match(wake.content, /1\/2 verifier items done/);
90
+ assert.match(wake.content, /- `VC-002` second/);
91
+ assert.doesNotMatch(wake.content, /- `VC-001` first/);
92
+ assert.deepEqual(filterGoalWaitMessages([wake]), [wake]);
93
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
94
+ });
95
+ }
96
+
97
+ for (const mode of ["print", "json"]) {
98
+ it(`${mode}: tracks markers without any automatic wake`, async () => {
99
+ const h = await setup(mode);
100
+ await h.run("[DONE:VC-001]");
101
+ assert.equal(getExecution()?.items[0].done, true);
102
+ await h.run("failed", "error");
103
+ await h.run("[DONE:VC-002]");
104
+ assert.equal(h.wakes().length, 0);
105
+ assert.equal(getExecution(), null);
106
+ assert.equal(h.messages.find(m => m.customType === "pi-plans-complete").options.triggerTurn, false);
107
+ });
108
+ }
109
+
110
+ for (const gate of ["busy", "pending", "inFlight", "resumeGuard", "pendingFollowUpPrompt", "lifecycle"]) {
111
+ it(`does not send or count when ${gate} owns continuation`, async () => {
112
+ const h = await setup();
113
+ await h.begin();
114
+ await h.turn();
115
+ h.setIdle(gate !== "busy");
116
+ if (gate === "pending") h.pending.push("user message", { customType: "another-extension" });
117
+ else if (gate === "lifecycle") noteCompactionStarted(h.ctx, undefined);
118
+ else if (gate !== "busy") h.ctx.sessionManager.__executionCompaction = { [gate]: gate === "pendingFollowUpPrompt" ? "follow-up" : true };
119
+ const original = structuredClone(h.pending);
120
+ await h.emit("agent_settled");
121
+ assert.equal(h.wakes().length, 0);
122
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
123
+ assert.deepEqual(h.pending, original);
124
+ if (gate === "busy") {
125
+ await h.settle();
126
+ assert.equal(h.wakes().length, 1, "a busy notification must not consume the real settled cycle");
127
+ }
128
+ });
129
+ }
130
+
131
+ for (const stopReason of ["error", "aborted"]) {
132
+ it(`${stopReason}: pauses once and only genuine input resumes`, async () => {
133
+ const h = await setup();
134
+ await h.run("failed", stopReason);
135
+ assert.equal(getExecution()?.goalWait?.paused, true);
136
+ await h.settle();
137
+ assert.equal(h.notices.length, 1);
138
+ await h.emit("input", { source: "extension" });
139
+ await h.emit("before_agent_start");
140
+ assert.equal(getExecution()?.goalWait?.paused, true);
141
+ assert.equal(h.wakes().length, 0);
142
+ await h.emit("input", { source: "rpc" });
143
+ assert.equal(getExecution()?.goalWait?.paused, false);
144
+ assert.equal(h.wakes().length, 0, "input is already owned by Pi");
145
+ await h.run();
146
+ assert.equal(h.wakes().length, 1);
147
+ });
148
+ }
149
+
150
+ it("leaves retries to Pi and refuses unknown or intentional tool termination", async () => {
151
+ const h = await setup();
152
+ await h.begin();
153
+ await h.turn("retryable failure", "error");
154
+ await h.emit("agent_end");
155
+ assert.equal(h.wakes().length, 0);
156
+ assert.equal(getExecution()?.goalWait?.paused, false);
157
+ await h.turn("recovered");
158
+ await h.settle();
159
+ assert.equal(h.wakes().length, 1);
160
+ for (const reason of ["toolUse", "length", "unknown"]) await h.run("intentional stop", reason);
161
+ assert.equal(h.wakes().length, 1);
162
+ });
163
+
164
+ for (const [text, count, field] of [["working", 3, "noProgressRounds"], ["waiting for CI", 6, "waitRounds"]] as const) {
165
+ it(`pauses at ${count} ${field} settled cycles, not intermediate turns`, async () => {
166
+ const h = await setup();
167
+ for (let i = 1; i <= count; i++) {
168
+ await h.begin();
169
+ for (let j = 0; j < 4; j++) await h.turn("tool work", "toolUse");
170
+ assert.equal(getExecution()?.goalWait?.[field], i - 1);
171
+ await h.turn(text);
172
+ await h.settle();
173
+ await h.settle();
174
+ assert.equal(getExecution()?.goalWait?.[field], i);
175
+ }
176
+ assert.equal(h.wakes().length, count - 1);
177
+ assert.equal(getExecution()?.goalWait?.paused, true);
178
+ assert.match(h.status(), /goal-wait paused/);
179
+ });
180
+ }
181
+
182
+ it("real progress resets both nonzero counters through registered handlers", async () => {
183
+ const h = await setup();
184
+ await h.run();
185
+ await h.run("waiting for tests");
186
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 1);
187
+ assert.equal(getExecution()?.goalWait?.waitRounds, 1);
188
+ await h.run("[DONE:VC-001]");
189
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
190
+ assert.equal(getExecution()?.goalWait?.waitRounds, 0);
191
+ const persisted = h.entries.filter(e => e.customType === "pi-plans-exec").at(-1).data;
192
+ assert.equal(persisted.items[0].done, true);
193
+ assert.equal(persisted.goalWait.noProgressRounds, 0);
194
+ });
195
+
196
+ it("same-plan explicit handoff resumes without losing verified progress", async () => {
197
+ const h = await setup();
198
+ await h.run("[DONE:VC-001]");
199
+ await h.run("cancelled", "aborted");
200
+ const ex = getExecution()!;
201
+ const before = structuredClone(ex);
202
+ const outcome = await executeCommand(h.ctx, h.planPath);
203
+ assert.equal(outcome.status, "executing");
204
+ assert.equal(getExecution(), ex);
205
+ assert.deepEqual(ex.items, before.items);
206
+ assert.deepEqual(ex.usage, before.usage);
207
+ assert.equal(ex.startedAt, before.startedAt);
208
+ assert.equal(ex.goalWait?.paused, false);
209
+ assert.equal(h.wakes().length, 2);
210
+ await executeCommand(h.ctx, h.planPath);
211
+ assert.equal(h.wakes().length, 2);
212
+ await assert.rejects(executeHandoff(h.ctx, h.planPath), /must not re-enter handoff/);
213
+ const other = path.join(h.ctx.cwd, "PLAN_v2.md");
214
+ fs.copyFileSync(h.planPath, other);
215
+ await assert.rejects(executeCommand(h.ctx, other), /must not re-enter handoff/);
216
+ });
217
+
218
+ it("a paused command during another run preserves its next settled opportunity", async () => {
219
+ const h = await setup();
220
+ await h.run("interrupted", "aborted");
221
+ await h.begin();
222
+ const outcome = await executeCommand(h.ctx, h.planPath);
223
+ assert.equal(outcome.status, "executing");
224
+ assert.equal(getExecution()?.goalWait?.paused, false);
225
+ assert.equal(h.wakes().length, 0, "busy command must not enqueue a kick");
226
+ await h.turn("still incomplete");
227
+ await h.settle();
228
+ assert.equal(h.wakes().length, 1);
229
+ });
230
+
231
+ it("restore preserves pause and counters but cannot replay a wake", async () => {
232
+ const h = await setup();
233
+ for (let i = 0; i < 3; i++) await h.run();
234
+ const oldWake = h.wakes().at(-1);
235
+ const before = structuredClone(getExecution()?.goalWait);
236
+ await restoreFromSession(h.pi, h.ctx, h.entries);
237
+ await h.settle();
238
+ assert.deepEqual(getExecution()?.goalWait, before);
239
+ assert.equal(h.wakes().length, 2);
240
+ assert.deepEqual(filterGoalWaitMessages([oldWake]), []);
241
+ });
242
+
243
+ for (const exit of ["stop", "complete", "replacement", "shutdown"]) {
244
+ it(`${exit} invalidates wake identity without filtering user messages`, async () => {
245
+ const h = await setup();
246
+ await h.run();
247
+ const wake = h.wakes()[0];
248
+ if (exit === "stop") await stopExecution(h.pi, h.ctx, "test");
249
+ if (exit === "complete") await h.turn("[DONE:VC-001] [DONE:VC-002]");
250
+ if (exit === "replacement") await startExecution(h.pi, h.ctx, h.planPath, [{ id: "VC-003", text: "new", done: false }]);
251
+ if (exit === "shutdown") await h.emit("session_shutdown");
252
+ await h.settle();
253
+ const user = { role: "user", content: "Goal wait: this is my text" };
254
+ const other = { customType: "another-extension", content: "continue" };
255
+ assert.deepEqual(filterGoalWaitMessages([wake, user, other]), [user, other]);
256
+ assert.equal(h.wakes().length, 1);
257
+ });
258
+ }
259
+
260
+ it("a synchronous dispatch failure pauses once instead of leaving a retry lock", async () => {
261
+ const h = await setup();
262
+ h.pi.sendMessage = () => { throw new Error("dispatch failed"); };
263
+ await h.run();
264
+ await h.settle();
265
+ assert.equal(getExecution()?.goalWait?.paused, true);
266
+ assert.match(h.notices[0], /dispatch failed/);
267
+ assert.equal(h.notices.length, 1);
268
+ });
269
+ });
@@ -9,6 +9,8 @@ import { Type } from "typebox";
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import {
12
+ getExecution,
13
+ resumeActiveExecution,
12
14
  startExecution,
13
15
  } from "../src/exec.ts";
14
16
  import { disableAutoComplete } from "../src/autocomplete.ts";
@@ -97,6 +99,22 @@ export async function executeHandoff(
97
99
  };
98
100
  }
99
101
 
102
+ /** The user command may resume an approved execution; the tool always asks. */
103
+ export async function executeCommand(ctx: ExtensionContext, planPathArg?: string): Promise<HandoffOutcome> {
104
+ const activeExecution = getExecution();
105
+ const planPath = planPathArg ? path.resolve(ctx.cwd, planPathArg.replace(/^@/, "")) : activeExecution?.planPath;
106
+ if (activeExecution && planPath && path.resolve(activeExecution.planPath) === path.resolve(planPath)) {
107
+ const resumed = resumeActiveExecution(getCurrentApi(), ctx);
108
+ return {
109
+ status: "executing",
110
+ planPath,
111
+ itemCount: activeExecution.items.length,
112
+ message: resumed ? "Execution resumed; verified progress preserved." : "This plan is already executing.",
113
+ };
114
+ }
115
+ return executeHandoff(ctx, planPathArg);
116
+ }
117
+
100
118
  // The tool registers with the ExtensionAPI in scope; keep a module-level
101
119
  // reference so the shared handoff helper can reach appendEntry/sendMessage.
102
120
  let currentApi: ExtensionAPI | null = null;