pi-plans 0.3.1 → 0.3.3

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/src/exec.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { randomUUID } from "node:crypto";
11
13
  import type {
12
14
  CompactionResult,
13
15
  ExtensionAPI,
@@ -24,6 +26,7 @@ import {
24
26
  entryCurrentIMarkers,
25
27
  formatVccCompactionStats,
26
28
  loadVccSettings,
29
+ PLANNING_PREPLAN_COMPACT_HINT,
27
30
  scaffoldVccSettings,
28
31
  shouldScheduleAutoContinue,
29
32
  type CompactionEntryLike,
@@ -33,13 +36,35 @@ import {
33
36
  type VccCompactionBuildResult,
34
37
  type VccCompactionStats,
35
38
  } from "./compaction.ts";
36
- import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
39
+ import { getRun, readActive, resolveStateRootOrNull, setRunStatus, StateError, utcNow } from "./state.ts";
40
+ import { bindRun, resolveActiveRun } from "./run-context.ts";
41
+ import { OwnershipError } from "./run-ownership.ts";
42
+ import {
43
+ applyExecutionApproved,
44
+ applyExecutionCompleted,
45
+ applyExecutionHeadChanged,
46
+ applyExecutionProgress,
47
+ applyExecutionStopped,
48
+ applyQuestionAsked,
49
+ applyReviewRoundStarted,
50
+ createCheckpoint,
51
+ loadCheckpoint,
52
+ mutateCheckpoint,
53
+ StaleCheckpointError,
54
+ planIdentityOf,
55
+ resolveHeadAt,
56
+ resolveWorktreeRoot,
57
+ sha256File,
58
+ type ExecutionApproval,
59
+ } from "./workflow-state.ts";
37
60
  import { graphBlockForExecutor } from "./code-graph/prompts.ts";
38
61
  import { resolveGraphMode } from "./code-graph/mode.ts";
39
- import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "./termination-prompt.ts";
62
+ import { TERMINATION_QUESTION, TERMINATION_OPTIONS, TERMINATION_RECORDING_INSTRUCTIONS, renderTerminationOptions } from "./termination-prompt.ts";
40
63
  import {
41
64
  extractCoverage,
42
65
  latestPlanVersion,
66
+ parseChecklist,
67
+ parseImplItems,
43
68
  resolveImplStatuses,
44
69
  scanDoneMarkers,
45
70
  scanImplMarkers,
@@ -76,6 +101,32 @@ const GOAL_WAIT_MAX_WAITING = 6;
76
101
 
77
102
  let execution: ExecState | null = null;
78
103
 
104
+ export const GOAL_WAIT_CUSTOM_TYPE = "pi-plans-goal-wait";
105
+
106
+ interface GoalWaitRuntime {
107
+ owner: ExecState;
108
+ session: ExtensionContext["sessionManager"];
109
+ handled: boolean;
110
+ stopReason?: string;
111
+ text: string;
112
+ wakeId?: string;
113
+ }
114
+
115
+ // Dispatch identity belongs to a live session, never to a persisted checklist.
116
+ let goalWaitRuntime: GoalWaitRuntime | null = null;
117
+
118
+ function resetGoalWaitRuntime(ctx: ExtensionContext): void {
119
+ goalWaitRuntime = execution
120
+ ? { owner: execution, session: ctx.sessionManager, handled: false, text: "" }
121
+ : null;
122
+ }
123
+
124
+ function currentGoalWaitRuntime(ctx: ExtensionContext): GoalWaitRuntime | null {
125
+ return goalWaitRuntime?.owner === execution && goalWaitRuntime.session === ctx.sessionManager
126
+ ? goalWaitRuntime
127
+ : null;
128
+ }
129
+
79
130
  // Execution-loop persistence is deferred until the agent settles so turn_end
80
131
  // never causes session writes during a streaming run.
81
132
  let pendingExecutionFlush = false;
@@ -104,6 +155,105 @@ export function getExecution(): ExecState | null {
104
155
  return execution;
105
156
  }
106
157
 
158
+ export interface CheckpointExecutionLoad {
159
+ status: "loaded" | "no-execution" | "plan-missing" | "plan-mismatch" | "no-checkpoint" | "corrupt";
160
+ planPath?: string;
161
+ doneVcIds?: string[];
162
+ reverifyAll?: boolean;
163
+ pausedReason?: string;
164
+ error?: string;
165
+ }
166
+
167
+ /**
168
+ * Shared restore primitive (I-005/I-006): load the executing state from a run
169
+ * checkpoint into THIS session. Authorization is kept only when the recorded
170
+ * approval matches the current plan digest; a HEAD change keeps the
171
+ * authorization but re-verifies previously verified VCs (D-011/F-001).
172
+ * F-002: the loaded state is persisted to the current session IMMEDIATELY so
173
+ * session_start/session_tree restore paths cannot silently clear it.
174
+ */
175
+ export function loadExecutionFromCheckpoint(
176
+ pi: ExtensionAPI,
177
+ ctx: ExtensionContext,
178
+ runId: string,
179
+ ): CheckpointExecutionLoad {
180
+ const load = loadCheckpoint(ctx.cwd, runId);
181
+ if (load.status === "missing") return { status: "no-checkpoint" };
182
+ if (load.status === "corrupt") return { status: "corrupt", error: load.error };
183
+ const cp = load.checkpoint;
184
+ if (!cp.execution || cp.phase !== "executing") return { status: "no-execution" };
185
+ const planPath = cp.plan?.path;
186
+ if (!planPath || !fs.existsSync(planPath)) {
187
+ return { status: "plan-missing", error: planPath ? `plan file vanished: ${planPath}` : "checkpoint has no plan identity" };
188
+ }
189
+ const planText = fs.readFileSync(planPath, "utf8");
190
+ // F-002 (implementation review): the recorded plan identity is over BYTES —
191
+ // an in-place edit at the same path must not inherit the authorization or
192
+ // the verified VCs. Refuse the load and require a fresh handoff.
193
+ if (sha256File(planPath) !== cp.plan.sha256) {
194
+ return {
195
+ status: "plan-mismatch" as const,
196
+ error: `plan file changed since the approval record (${planPath}); re-approve via /plans-execute before executing`,
197
+ };
198
+ }
199
+ const items = parseChecklist(planText);
200
+ if (items.length === 0) {
201
+ return { status: "plan-missing", error: `${planPath} has no parsable verifier checklist` };
202
+ }
203
+ const implItems = parseImplItems(planText);
204
+ const doneIds = new Set(cp.execution.doneVcIds);
205
+ // D-011/F-001: an unchanged plan digest keeps the recorded authorization;
206
+ // a changed HEAD under it forces re-verification of previously verified VCs.
207
+ // F-006 (implementation review): an approval without a resolvable HEAD
208
+ // recorded an unverifiable code state — re-verify instead of trusting.
209
+ const headNow = resolveHeadAt(ctx.cwd);
210
+ const headUnverifiable = cp.execution.approval === null || cp.execution.approval.headAtApproval === null;
211
+ const headChanged =
212
+ cp.execution.approval !== null &&
213
+ cp.execution.approval.headAtApproval !== null &&
214
+ cp.execution.approval.headAtApproval !== headNow;
215
+ const reverifyAll = cp.execution.reverifyAll === true || headChanged || headUnverifiable;
216
+ if (!reverifyAll) {
217
+ for (const item of items) {
218
+ if (doneIds.has(item.id)) item.done = true;
219
+ }
220
+ }
221
+ execution = {
222
+ planPath,
223
+ items,
224
+ startedAt: utcNow(),
225
+ usage: { inToks: cp.execution.usage.inToks, outToks: cp.execution.usage.outToks },
226
+ implItems,
227
+ implStatus: { ...cp.execution.implStatus },
228
+ currentI: cp.execution.currentI,
229
+ goalWait: {
230
+ noProgressRounds: 0,
231
+ waitRounds: 0,
232
+ lastMarkers: null,
233
+ paused: cp.execution.pausedReason !== undefined,
234
+ pausedReason: cp.execution.pausedReason,
235
+ },
236
+ };
237
+ executionRunId = runId;
238
+ bindRun(ctx.sessionManager, ctx.cwd, runId);
239
+ resetGoalWaitRuntime(ctx);
240
+ pendingExecutionFlush = false; // restored state: no inherited flush debt
241
+ resetExecutionCompactionState(ctx);
242
+ if (headChanged) {
243
+ withExecutionCheckpoint(ctx, (current) => applyExecutionHeadChanged(current));
244
+ }
245
+ if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
246
+ persist(pi); // F-002: immediate session snapshot
247
+ updateStatusWidget(ctx);
248
+ return {
249
+ status: "loaded",
250
+ planPath,
251
+ doneVcIds: [...doneIds],
252
+ reverifyAll,
253
+ pausedReason: cp.execution.pausedReason,
254
+ };
255
+ }
256
+
107
257
  const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
108
258
 
109
259
  interface ExecutionCompactionState {
@@ -228,7 +378,7 @@ export function updateStatusWidget(ctx: ExtensionContext): void {
228
378
  ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
229
379
  return;
230
380
  }
231
- const active = readActive(ctx.cwd);
381
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
232
382
  if (active) {
233
383
  // Idle indicator depends on the run's lifecycle, not just its existence:
234
384
  // done reads as finished, abandoned as closed, stopped/accepted as paused.
@@ -275,6 +425,24 @@ function persist(pi: ExtensionAPI): void {
275
425
  });
276
426
  }
277
427
 
428
+ /** Checkpoint bookkeeping for the executing run; best-effort for legacy runs
429
+ * without checkpoints (their cross-session resume degrades to R-008 rules). */
430
+ function withExecutionCheckpoint(ctx: ExtensionContext, mutator: (cp: import("./workflow-state.ts").WorkflowCheckpoint) => import("./workflow-state.ts").WorkflowCheckpoint): void {
431
+ if (!execution) return;
432
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
433
+ if (!active || active.run_id !== executionRunId) return;
434
+ try {
435
+ mutateCheckpoint(ctx.cwd, active.run_id, mutator);
436
+ } catch (error) {
437
+ // F-005 (implementation review): ownership loss and revision staleness
438
+ // must stop the advance, not vanish into the catch block.
439
+ if (error instanceof OwnershipError || error instanceof StaleCheckpointError) throw error;
440
+ /* legacy run or corrupt checkpoint: session snapshot still carries the loop */
441
+ }
442
+ }
443
+
444
+ let executionRunId: string | null = null;
445
+
278
446
  export async function startExecution(
279
447
  pi: ExtensionAPI,
280
448
  ctx: ExtensionContext,
@@ -286,11 +454,44 @@ export async function startExecution(
286
454
  // Seed the marker baseline so the first quiet round is counted against a
287
455
  // real snapshot instead of counting unconditionally (F-006).
288
456
  if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
457
+ resetGoalWaitRuntime(ctx);
289
458
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
290
459
  resetExecutionCompactionState(ctx);
291
460
  persist(pi);
292
- const active = readActive(ctx.cwd);
461
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
462
+ executionRunId = active?.run_id ?? null;
293
463
  if (active) {
464
+ bindRun(ctx.sessionManager, ctx.cwd, active.run_id);
465
+ // I-005: durable approval evidence — run + plan digest + HEAD at
466
+ // approval (D-003/D-011). Sets phase executing via the state machine.
467
+ try {
468
+ const load = loadCheckpoint(ctx.cwd, active.run_id);
469
+ if (load.status === "missing") {
470
+ createCheckpoint(ctx.cwd, { runId: active.run_id, originWorkdir: ctx.cwd, workdir: ctx.cwd });
471
+ }
472
+ const approval: ExecutionApproval = {
473
+ plan: planIdentityOf(path.resolve(planPath), 1),
474
+ worktree: resolveWorktreeRoot(ctx.cwd) ?? path.resolve(ctx.cwd),
475
+ headAtApproval: resolveHeadAt(ctx.cwd),
476
+ approvedAt: utcNow(),
477
+ };
478
+ mutateCheckpoint(ctx.cwd, active.run_id, (cp) => {
479
+ // Plan refinement may not have recorded the plan identity yet.
480
+ const withPlan = cp.plan === null ? { ...cp, plan: approval.plan } : cp;
481
+ // The checkpoint may not carry accept-execute (legacy flow);
482
+ // approval here came from the explicit handoff confirmation.
483
+ const aligned = withPlan.nextAction === "accept-execute"
484
+ ? withPlan
485
+ : { ...withPlan, nextAction: "accept-execute" as const };
486
+ return applyExecutionApproved(aligned, approval);
487
+ });
488
+ } catch (error) {
489
+ // F-002 (implementation review): a plan-digest mismatch between the
490
+ // recorded checkpoint plan and the approval must fail closed and
491
+ // visibly — never silently execute without durable approval.
492
+ if (error instanceof StateError && /does not match/.test(error.message)) throw error;
493
+ /* legacy/corrupt checkpoint: run status still transitions below */
494
+ }
294
495
  try {
295
496
  setRunStatus(ctx.cwd, active.run_id, "executing");
296
497
  } catch {
@@ -320,10 +521,30 @@ export function recordExecutionTurn(
320
521
  execution.usage.inToks += usage.input;
321
522
  execution.usage.outToks += usage.output;
322
523
  }
524
+ // I-005: mirror progress into the run checkpoint so a different session
525
+ // can resume with the verified VC/I set (R-004).
526
+ withExecutionCheckpoint(_ctx, (cp) =>
527
+ applyExecutionProgress(cp, {
528
+ doneVcIds: execution!.items.filter((item) => item.done).map((item) => item.id),
529
+ implStatus: implStatusSnapshot(),
530
+ currentI: execution!.currentI,
531
+ usage: usage ? { inToks: usage.input, outToks: usage.output } : undefined,
532
+ }),
533
+ );
323
534
  requestExecutionFlush(pi, _ctx);
324
535
  updateStatusWidget(_ctx);
325
536
  }
326
537
 
538
+ function implStatusSnapshot(): Record<string, string> {
539
+ const snapshot: Record<string, string> = {};
540
+ if (!execution?.implItems) return snapshot;
541
+ for (const item of execution.implItems) {
542
+ const state = execution.implStatus?.[item.id];
543
+ if (state) snapshot[item.id] = state;
544
+ }
545
+ return snapshot;
546
+ }
547
+
327
548
  export function registerExecutionTurnHandlers(
328
549
  pi: ExtensionAPI,
329
550
  onTurnEnd?: (ctx: ExtensionContext) => Promise<void> | void,
@@ -331,6 +552,31 @@ export function registerExecutionTurnHandlers(
331
552
  // The turn_end projection does not carry usage; message_end delivers the
332
553
  // full assistant message, so cache it here and consume it per turn.
333
554
  let lastAssistantUsage: { input: number; output: number } | null = null;
555
+ pi.on("agent_start", async (_event, ctx) => {
556
+ const runtime = currentGoalWaitRuntime(ctx);
557
+ if (!runtime) return;
558
+ runtime.handled = false;
559
+ runtime.stopReason = undefined;
560
+ runtime.text = "";
561
+ });
562
+ pi.on("before_agent_start", async (_event, ctx) => {
563
+ const runtime = currentGoalWaitRuntime(ctx);
564
+ if (runtime) runtime.wakeId = undefined;
565
+ });
566
+ pi.on("input", async (event, ctx) => {
567
+ if (event.source === "interactive" || event.source === "rpc") resumeGoalWaitIfPaused(pi, ctx);
568
+ });
569
+ pi.on("agent_settled", async (_event, ctx) => {
570
+ drainExecutionFlush(pi, ctx);
571
+ maybeGoalWaitFollowUp(pi, ctx);
572
+ });
573
+ pi.on("session_shutdown", async (_event, ctx) => {
574
+ drainExecutionFlush(pi, ctx);
575
+ execution = null;
576
+ executionRunId = null;
577
+ goalWaitRuntime = null;
578
+ lastAssistantUsage = null;
579
+ });
334
580
  pi.on("message_end", async (event) => {
335
581
  const message = event.message as { role?: string; usage?: { input?: number; output?: number } };
336
582
  if (message?.role === "assistant" && message.usage) {
@@ -339,7 +585,7 @@ export function registerExecutionTurnHandlers(
339
585
  });
340
586
 
341
587
  pi.on("turn_end", async (event, ctx) => {
342
- const message = event.message as { role?: string; content?: Array<{ type: string; text?: string }> };
588
+ const message = event.message as { role?: string; stopReason?: string; content?: Array<{ type: string; text?: string }> };
343
589
  if (!message || message.role !== "assistant") {
344
590
  updateStatusWidget(ctx);
345
591
  return;
@@ -348,6 +594,11 @@ export function registerExecutionTurnHandlers(
348
594
  .filter((part) => part.type === "text")
349
595
  .map((part) => part.text ?? "")
350
596
  .join("\n");
597
+ const runtime = currentGoalWaitRuntime(ctx);
598
+ if (runtime) {
599
+ runtime.stopReason = message.stopReason;
600
+ runtime.text = text;
601
+ }
351
602
  const changedIds = applyDoneMarkers(text);
352
603
  const changedImpls = applyImplMarkers(text);
353
604
  const changedCurrentI = applyCurrentIMarker(text);
@@ -361,8 +612,6 @@ export function registerExecutionTurnHandlers(
361
612
  }
362
613
  if (getExecution() && isExecutionComplete()) {
363
614
  await completeExecution(pi, ctx);
364
- } else if (getExecution()) {
365
- maybeGoalWaitFollowUp(pi, ctx, text);
366
615
  }
367
616
  await onTurnEnd?.(ctx);
368
617
  });
@@ -373,7 +622,7 @@ const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
373
622
  function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null {
374
623
  const stateRoot = resolveStateRootOrNull(ctx.cwd);
375
624
  if (!stateRoot) return null;
376
- const active = readActive(ctx.cwd);
625
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
377
626
  if (!active) return null;
378
627
  const run = getRun(ctx.cwd, active.run_id);
379
628
  if (!run) return null;
@@ -492,7 +741,6 @@ export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionCon
492
741
  if (!event.willRetry && stats) {
493
742
  ctx.ui.notify(formatVccCompactionStats(stats), "info");
494
743
  if (followUpPrompt) {
495
- compactionFollowUpSentThisTurn = true;
496
744
  await pi.sendUserMessage?.(followUpPrompt);
497
745
  } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
498
746
  state.resumeGuard = true;
@@ -569,6 +817,49 @@ export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
569
817
  export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
570
818
  const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
571
819
 
820
+ // ---------------------------------------------------------------------------
821
+ // Pre-plan compaction: right after `plans start-run` creates a new planning
822
+ // run, the extension triggers one VCC compaction so the new plan starts on a
823
+ // lean context (LLM reasoning degrades with longer context; see PLAN
824
+ // preplan-compact). The pending flag is session-scoped and opportunistic: it
825
+ // is set by the start-run tool case and consumed by the plans tool_result
826
+ // hook in index.ts, which requests the extension-context compact action and
827
+ // resumes planning exactly once regardless of success or failure.
828
+ // ---------------------------------------------------------------------------
829
+
830
+ export { PLANNING_PREPLAN_COMPACT_HINT };
831
+ export const PLANNING_PREPLAN_RESUME_CUSTOM_TYPE = "pi-plans-preplan-resume";
832
+
833
+ interface PrePlanCompactPending {
834
+ runId: string;
835
+ }
836
+
837
+ export function markPrePlanCompactPending(ctx: ExtensionContext, runId: string): void {
838
+ const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null };
839
+ session.__piPlansPrePlanCompact = { runId };
840
+ }
841
+
842
+ export function consumePrePlanCompactPending(ctx: ExtensionContext): PrePlanCompactPending | null {
843
+ const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null };
844
+ const pending = session.__piPlansPrePlanCompact ?? null;
845
+ session.__piPlansPrePlanCompact = null;
846
+ return pending;
847
+ }
848
+
849
+ /** Hidden resume message after the pre-plan compaction settles (success or
850
+ * failure): Pi's manual compaction never continues the aborted turn, so the
851
+ * planning workflow is continued exactly once from here. */
852
+ export function sendPrePlanCompactResume(pi: ExtensionAPI): void {
853
+ pi.sendMessage?.(
854
+ {
855
+ customType: PLANNING_PREPLAN_RESUME_CUSTOM_TYPE,
856
+ content: "Continue planning.",
857
+ display: false,
858
+ },
859
+ { triggerTurn: true },
860
+ );
861
+ }
862
+
572
863
  interface PlanningCompactionState {
573
864
  inFlight: boolean;
574
865
  resumeGuard: boolean;
@@ -698,8 +989,11 @@ export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void
698
989
  }
699
990
 
700
991
  export function requestPlanningCompaction(_ctx: ExtensionContext): void {
701
- // Proactive pi-plans compaction is intentionally disabled. Manual,
992
+ // Generic proactive pi-plans compaction is intentionally disabled. Manual,
702
993
  // threshold, and overflow compactions are handled by session_before_compact.
994
+ // The single exception is the pre-plan compaction: index.ts requests the
995
+ // extension-context compact action from the plans tool_result hook right
996
+ // after start-run (see PLANNING_PREPLAN_COMPACT_HINT).
703
997
  }
704
998
 
705
999
  function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
@@ -833,7 +1127,7 @@ export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionCont
833
1127
  }
834
1128
 
835
1129
  export function filterPlanningResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
836
- return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
1130
+ return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE && message.customType !== PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
837
1131
  }
838
1132
 
839
1133
  export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
@@ -842,7 +1136,11 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
842
1136
  // Final synchronous write: drain any deferred flush and land the last snapshot.
843
1137
  pendingExecutionFlush = false;
844
1138
  persist(pi);
1139
+ // Checkpoint first: withExecutionCheckpoint guards on the live execution.
1140
+ withExecutionCheckpoint(ctx, (cp) => applyExecutionStopped(cp, reason));
845
1141
  execution = null;
1142
+ executionRunId = null;
1143
+ goalWaitRuntime = null;
846
1144
  pi.appendEntry("pi-plans-exec-cleared", { reason });
847
1145
  pi.sendMessage(
848
1146
  {
@@ -852,7 +1150,7 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
852
1150
  },
853
1151
  { triggerTurn: false },
854
1152
  );
855
- const active = readActive(ctx.cwd);
1153
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
856
1154
  if (active) {
857
1155
  try {
858
1156
  setRunStatus(ctx.cwd, active.run_id, "stopped");
@@ -909,13 +1207,6 @@ export function isExecutionComplete(): boolean {
909
1207
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
910
1208
  }
911
1209
 
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
1210
  function goalWaitSnapshot(): string {
920
1211
  if (!execution) return "";
921
1212
  return JSON.stringify({
@@ -942,46 +1233,67 @@ function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string):
942
1233
  updateStatusWidget(ctx);
943
1234
  }
944
1235
 
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
- }
1236
+ function canWakeExecution(ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
963
1237
  const compaction = executionCompactionState(ctx);
964
- if (compaction && (compaction.inFlight || compaction.pendingFollowUpPrompt != null || compaction.resumeGuard)) {
965
- updateStatusWidget(ctx);
966
- return;
1238
+ return currentGoalWaitRuntime(ctx) === runtime
1239
+ && (ctx.mode === "tui" || ctx.mode === "rpc")
1240
+ && !isExecutionComplete()
1241
+ && !runtime.owner.goalWait?.paused
1242
+ && ctx.isIdle()
1243
+ && !ctx.hasPendingMessages()
1244
+ && !ctx.signal?.aborted
1245
+ && !compactionInFlight(ctx, "execution")
1246
+ && !compaction?.inFlight
1247
+ && !compaction?.resumeGuard
1248
+ && compaction?.pendingFollowUpPrompt == null;
1249
+ }
1250
+
1251
+ function sendGoalWaitWake(pi: ExtensionAPI, ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
1252
+ if (!canWakeExecution(ctx, runtime)) return false;
1253
+ try {
1254
+ // Custom messages bypass before_agent_start, so carry fresh execution rules.
1255
+ const content = executionContextMessage(ctx);
1256
+ if (!content) return false;
1257
+ runtime.wakeId = randomUUID();
1258
+ pi.sendMessage({
1259
+ customType: GOAL_WAIT_CUSTOM_TYPE,
1260
+ content,
1261
+ display: false,
1262
+ details: { wakeId: runtime.wakeId },
1263
+ }, { triggerTurn: true });
1264
+ return true;
1265
+ } catch (error) {
1266
+ runtime.wakeId = undefined;
1267
+ pauseGoalWait(pi, ctx, `continuation failed: ${String(error)}`);
1268
+ return false;
967
1269
  }
968
- if (compactionFollowUpSentThisTurn) {
969
- compactionFollowUpSentThisTurn = false; // the compaction path already queued a continuation
970
- updateStatusWidget(ctx);
1270
+ }
1271
+
1272
+ /** Only a fully settled agent run can need an extra wake, never a tool turn. */
1273
+ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext): void {
1274
+ const runtime = currentGoalWaitRuntime(ctx);
1275
+ if (!runtime || runtime.handled || !ctx.isIdle()) return;
1276
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
1277
+ if (runtime.stopReason === "error" || runtime.stopReason === "aborted" || ctx.signal?.aborted) {
1278
+ runtime.handled = true;
1279
+ pauseGoalWait(pi, ctx, runtime.stopReason === "error" ? "agent failed" : "agent interrupted");
971
1280
  return;
972
1281
  }
1282
+ if (runtime.stopReason !== "stop" || !canWakeExecution(ctx, runtime)) return;
1283
+ runtime.handled = true;
1284
+ const ex = runtime.owner;
1285
+ ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
1286
+ const goalWait = ex.goalWait;
973
1287
  const snapshot = goalWaitSnapshot();
974
1288
  const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers;
975
1289
  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 {
1290
+ if (changed) {
983
1291
  goalWait.noProgressRounds = 0;
984
1292
  goalWait.waitRounds = 0;
1293
+ } else if (/waiting for/i.test(runtime.text)) {
1294
+ goalWait.waitRounds += 1;
1295
+ } else {
1296
+ goalWait.noProgressRounds += 1;
985
1297
  }
986
1298
  if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) {
987
1299
  pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`);
@@ -991,25 +1303,40 @@ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistan
991
1303
  pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`);
992
1304
  return;
993
1305
  }
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
- );
1306
+ persist(pi);
1000
1307
  updateStatusWidget(ctx);
1308
+ // No await between the live gate and dispatch: another input cannot interleave.
1309
+ sendGoalWaitWake(pi, ctx, runtime);
1001
1310
  }
1002
1311
 
1003
- /** Any external input re-kicks a paused goal-wait (clears pause and counters). */
1004
- export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): void {
1312
+ export function filterGoalWaitMessages<T extends { customType?: string; details?: unknown }>(messages: T[]): T[] {
1313
+ return messages.filter((message) => message.customType !== GOAL_WAIT_CUSTOM_TYPE
1314
+ || (goalWaitRuntime?.owner === execution && goalWaitRuntime?.wakeId !== undefined
1315
+ && (message.details as { wakeId?: unknown } | undefined)?.wakeId === goalWaitRuntime.wakeId));
1316
+ }
1317
+
1318
+ /** Called only for genuine user input or an explicit same-execution resume. */
1319
+ export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
1005
1320
  const ex = getExecution();
1006
- if (!ex?.goalWait?.paused) return;
1321
+ if (!ex?.goalWait?.paused || !currentGoalWaitRuntime(ctx)) return false;
1007
1322
  ex.goalWait.paused = false;
1008
1323
  ex.goalWait.pausedReason = undefined;
1009
1324
  ex.goalWait.noProgressRounds = 0;
1010
1325
  ex.goalWait.waitRounds = 0;
1326
+ ex.goalWait.lastMarkers = goalWaitSnapshot();
1011
1327
  persist(pi);
1012
1328
  updateStatusWidget(ctx);
1329
+ return true;
1330
+ }
1331
+
1332
+ export function resumeActiveExecution(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
1333
+ if (!resumeGoalWaitIfPaused(pi, ctx)) return false;
1334
+ const runtime = currentGoalWaitRuntime(ctx)!;
1335
+ if (canWakeExecution(ctx, runtime)) {
1336
+ runtime.handled = true;
1337
+ sendGoalWaitWake(pi, ctx, runtime);
1338
+ }
1339
+ return true;
1013
1340
  }
1014
1341
 
1015
1342
  export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
@@ -1021,7 +1348,11 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1021
1348
 
1022
1349
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
1023
1350
  const planPath = execution.planPath;
1351
+ // Checkpoint first (live-execution guard), then clear the session state.
1352
+ withExecutionCheckpoint(ctx, (cp) => applyExecutionCompleted(cp));
1024
1353
  execution = null;
1354
+ executionRunId = null;
1355
+ goalWaitRuntime = null;
1025
1356
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
1026
1357
  // Post-execution goal-running continuation: in interactive sessions, attach
1027
1358
  // the continuation block and trigger a new turn so the agent immediately
@@ -1043,7 +1374,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1043
1374
  if (interactive) {
1044
1375
  pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 });
1045
1376
  }
1046
- const active = readActive(ctx.cwd);
1377
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
1047
1378
  if (active) {
1048
1379
  try {
1049
1380
  setRunStatus(ctx.cwd, active.run_id, "done");
@@ -1059,7 +1390,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1059
1390
  * implementation-review loop. Termination options are single-sourced from
1060
1391
  * src/termination-prompt.ts (shared with the ask_choice trailing branch). */
1061
1392
  export const AMELIORATION_PROMPT_TEXT = `---
1062
- Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
1393
+ Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. ${TERMINATION_RECORDING_INSTRUCTIONS} Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
1063
1394
 
1064
1395
  /** Injection text for before_agent_start while executing. */
1065
1396
  export function executionContextMessage(ctx: ExtensionContext): string | null {
@@ -1111,6 +1442,7 @@ interface SessionEntry {
1111
1442
  */
1112
1443
  export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
1113
1444
  pendingExecutionFlush = false; // no flush debt survives a restart
1445
+ goalWaitRuntime = null;
1114
1446
  resetExecutionCompactionState(ctx);
1115
1447
  let snapshotIndex = -1;
1116
1448
  let snapshot: ExecState | null = null;
@@ -1169,6 +1501,7 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1169
1501
  }
1170
1502
  }
1171
1503
  if (execution) {
1504
+ resetGoalWaitRuntime(ctx);
1172
1505
  // D-010: replay may have advanced progress past the persisted baseline.
1173
1506
  // Recompute the goal-wait markers; new progress resets the guard counters.
1174
1507
  if (execution.goalWait) {