pi-better-goal 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,11 +13,11 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
13
13
  ## Core Features
14
14
 
15
15
  - `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
16
- - `escape` pauses the active goal and your next message resumes it; `/goal pause` stays paused until `/goal resume`. Paused goals are never poked.
16
+ - `escape` pauses the active goal, and it stays paused while you talk to the agent. Say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows `goal paused · say "go" or /goal resume`, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
17
17
  - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
18
18
  - A compact goal widget that does not replace Pi's footer.
19
19
  - Background activity tracking for subagents and other registered providers.
20
- - A progress-aware follow-up loop that holds after repeated identical outcomes.
20
+ - A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (30s through 300s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
21
21
  - An observable-progress stall state for active goals.
22
22
 
23
23
  ## Skill-Owned Workflows
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "scripts": {
38
38
  "pretest": "node ../../scripts/sync-shared-log-utils.mjs",
39
39
  "prepack": "node ../../scripts/sync-shared-log-utils.mjs",
40
- "test": "node --import tsx --test test/*.test.ts",
40
+ "test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.ts",
41
41
  "typecheck": "tsc --noEmit",
42
42
  "verify": "npm run typecheck && npm test"
43
43
  },
package/src/index.ts CHANGED
@@ -51,7 +51,8 @@ import {
51
51
 
52
52
  const POLL_INTERVAL_MS = 2_000;
53
53
  const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 30_000;
54
- const DEFAULT_MAX_NO_PROGRESS_RETRIES = 3;
54
+ const DEFAULT_MAX_NO_PROGRESS_RETRIES = 10;
55
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
55
56
  const WAKE_DISABLED =
56
57
  process.env.PI_BETTER_GOAL_DISABLE_WAKE === "1" ||
57
58
  process.env.PI_BETTER_EXTENSION_DISABLE_WAKE === "1";
@@ -70,6 +71,34 @@ const MAX_NO_PROGRESS_RETRIES = parseRetryLimit(
70
71
  * steering after the whole tool batch, and callback batches are follow-ups
71
72
  * that wait for the entire run), so the goal harvests them explicitly.
72
73
  */
74
+ /** Model-callable resume, active only while an escape-paused goal waits. */
75
+ export const GOAL_RESUME_TOOL = "goal_resume";
76
+ /** Hotkey that resumes any paused goal, like `/goal resume`. */
77
+ export const GOAL_RESUME_SHORTCUT = "alt+g";
78
+
79
+ /** When the agent may call `goal_resume`; shared by the tool description and the paused prompt. */
80
+ const GOAL_RESUME_RULE =
81
+ "Call goal_resume only when the user's latest message clearly says to proceed (for example \"go\", \"continue\", \"ok do it\", \"approved, proceed\"), " +
82
+ "or answers a decision you explicitly asked for in your previous message with a choice that means proceed. Never call it for questions, \"why...\", \"what about...\", \"let me think\", or discussion.";
83
+
84
+ /** True when the agent may resume this goal with `goal_resume`. */
85
+ export function agentResumable(goal: GoalSnapshot | null): boolean {
86
+ return goal?.status === "paused" && goal.pauseReason === "interrupt";
87
+ }
88
+
89
+ /** Footer status for a paused goal, telling the user how to resume it. */
90
+ export function pausedGoalStatus(goal: GoalSnapshot): string {
91
+ return agentResumable(goal) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
92
+ }
93
+
94
+ function pausedGoalPrompt(goal: GoalSnapshot): string {
95
+ return [
96
+ `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
97
+ "Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
98
+ GOAL_RESUME_RULE,
99
+ ].join("\n");
100
+ }
101
+
73
102
  const BLOCKING_QUESTION_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
74
103
 
75
104
  const GOAL_ACTIONS: readonly AutocompleteItem[] = [
@@ -93,6 +122,14 @@ function parseDurationEnv(raw: string | undefined, fallback: number): number {
93
122
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
94
123
  }
95
124
 
125
+ /**
126
+ * Linear backoff for no-progress retries: each identical outcome waits one more
127
+ * grace period than the last (30s, 60s, 90s, ... by default).
128
+ */
129
+ export function idleContinuationDelay(noProgressRetries: number, baseMs = IDLE_CONTINUATION_DELAY_MS): number {
130
+ return Math.min(MAX_TIMER_DELAY_MS, baseMs * (1 + Math.max(0, noProgressRetries)));
131
+ }
132
+
96
133
  function parseRetryLimit(raw: string | undefined, fallback: number): number {
97
134
  if (raw === undefined || raw.trim() === "") {
98
135
  return fallback;
@@ -195,6 +232,11 @@ function formatGoal(
195
232
  `Elapsed time: ${timing.elapsedSeconds}s`,
196
233
  `Observable progress: ${stall?.state ?? "unknown"}`,
197
234
  continuationStatus,
235
+ ...(goal.status === "paused"
236
+ ? [agentResumable(goal)
237
+ ? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
238
+ : "Resume: /goal resume or alt+g"]
239
+ : []),
198
240
  ].join("\n");
199
241
  }
200
242
 
@@ -224,13 +266,18 @@ export default function (pi: ExtensionAPI): void {
224
266
  });
225
267
 
226
268
  let currentCtx: ExtensionContext | undefined;
269
+ let executionGeneration = 0;
270
+ let agentGeneration = -1;
271
+ let sessionGeneration = 0;
272
+ let wakeGeneration = 0;
273
+ let snapshotSequence = 0;
274
+ let lastAppliedSnapshotSequence = 0;
227
275
  let foregroundRunning = false;
228
276
  let pollTimer: ReturnType<typeof setInterval> | undefined;
229
277
  let collecting = false;
230
278
  let collectionPending = false;
231
279
  let latestSnapshot: ActivitySnapshot | null = null;
232
280
  let backgroundDrainTracker: BackgroundDrainTracker | null = null;
233
- let lastWakeSignature = "";
234
281
  let lastAttentionSignature = "";
235
282
  let continuationQueuedFor: string | null = null;
236
283
  let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
@@ -297,6 +344,8 @@ export default function (pi: ExtensionAPI): void {
297
344
  };
298
345
 
299
346
  const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
347
+ executionGeneration += 1;
348
+ lastAgentEvidence = null;
300
349
  const previous = getGoal(ctx);
301
350
  const wasVisible = previous !== null && isGoalClockVisible(previous);
302
351
  pi.appendEntry(EXTENSION_NAME, goalSetEntry(goal, source));
@@ -304,11 +353,15 @@ export default function (pi: ExtensionAPI): void {
304
353
  backgroundDrainTracker = null;
305
354
  clearIdleContinuation();
306
355
  syncPollingState();
356
+ syncResumeTool(goal);
357
+ applyStatus(ctx);
307
358
  // Force a full redraw when the dock height changes (absent ↔ visible clock).
308
359
  refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
309
360
  };
310
361
 
311
362
  const clearGoal = (ctx: ExtensionContext, source: GoalEntrySource): void => {
363
+ executionGeneration += 1;
364
+ lastAgentEvidence = null;
312
365
  const current = getGoal(ctx);
313
366
  const wasVisible = current !== null && isGoalClockVisible(current);
314
367
  pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
@@ -317,14 +370,16 @@ export default function (pi: ExtensionAPI): void {
317
370
  backgroundDrainTracker = null;
318
371
  clearIdleContinuation();
319
372
  syncPollingState();
373
+ syncResumeTool(null);
374
+ applyStatus(ctx);
320
375
  refreshGoalWidget?.(wasVisible);
321
376
  };
322
377
 
323
378
  /**
324
- * An interrupt (escape, or anything else that aborts the running turn) is a
325
- * soft pause: it stops autonomous continuation now, and the user's next
326
- * conversational message resumes the goal once that exchange settles. Only
327
- * `/goal pause` (or an unavailable command/workflow) is a sticky pause.
379
+ * An interrupt (escape, or anything else that aborts the running turn) pauses
380
+ * the goal. It stays paused while the user talks: messages are ordinary
381
+ * conversation. It resumes through `/goal resume`, the hotkey, or the agent's
382
+ * `goal_resume` once the user clearly says to proceed.
328
383
  */
329
384
  const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
330
385
  const goal = getGoal(ctx);
@@ -332,7 +387,7 @@ export default function (pi: ExtensionAPI): void {
332
387
  return;
333
388
  }
334
389
  setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
335
- notifyGoal(ctx, "Goal paused (interrupted). Send a message to resume it after that exchange, or use /goal pause to keep it paused.");
390
+ notifyGoal(ctx, 'Goal paused. Say "go" to resume it, or use /goal resume.');
336
391
  };
337
392
 
338
393
  /** Why a paused goal cannot become active again, or null when it can. */
@@ -346,6 +401,29 @@ export default function (pi: ExtensionAPI): void {
346
401
  return null;
347
402
  };
348
403
 
404
+ /**
405
+ * The one resume path shared by `/goal resume`, the hotkey, and `goal_resume`:
406
+ * reactivate a paused goal or reopen an active no-progress hold.
407
+ */
408
+ const resumeGoal = (
409
+ ctx: ExtensionContext,
410
+ source: GoalEntrySource,
411
+ ): { ok: true; goal: GoalSnapshot } | { ok: false; message: string } => {
412
+ const current = getGoal(ctx);
413
+ const held = current?.status === "active" && currentContinuationState(ctx, current.goalId)?.blocked === true;
414
+ if (!current || (current.status !== "paused" && !held)) {
415
+ return { ok: false, message: "Only paused or no-progress-held goals can be resumed." };
416
+ }
417
+ const blocker = resumeBlocker(current);
418
+ if (blocker) {
419
+ return { ok: false, message: blocker };
420
+ }
421
+ const goal = held ? current : goalWithStatus(current, "active");
422
+ setGoal(goal, ctx, source);
423
+ queueGoalContinuation(goal, ctx);
424
+ return { ok: true, goal };
425
+ };
426
+
349
427
  const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
350
428
  if (!goal.command || commandAvailable(pi, goal.command)) return true;
351
429
  setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
@@ -378,6 +456,7 @@ export default function (pi: ExtensionAPI): void {
378
456
  };
379
457
 
380
458
  function clearIdleContinuation(): void {
459
+ wakeGeneration += 1;
381
460
  if (idleContinuationTimer) {
382
461
  clearTimeout(idleContinuationTimer);
383
462
  idleContinuationTimer = undefined;
@@ -398,7 +477,8 @@ export default function (pi: ExtensionAPI): void {
398
477
  return;
399
478
  }
400
479
  const signature = `${goal.goalId}:${kind}`;
401
- if (idleContinuationTimer && idleContinuationSignature === signature) {
480
+ if (idleContinuationTimer && (idleContinuationSignature === signature ||
481
+ (kind === "continuation" && idleContinuationSignature === `${goal.goalId}:background-drained`))) {
402
482
  return;
403
483
  }
404
484
  clearIdleContinuation();
@@ -407,7 +487,7 @@ export default function (pi: ExtensionAPI): void {
407
487
  idleContinuationTimer = undefined;
408
488
  idleContinuationSignature = "";
409
489
  void sendIdleContinuationAfterAudit(goal.goalId, ctx, kind, snapshot);
410
- }, IDLE_CONTINUATION_DELAY_MS);
490
+ }, idleContinuationDelay(kind === "continuation" ? currentContinuationState(ctx, goal.goalId)?.noProgressRetries ?? 0 : 0));
411
491
  idleContinuationTimer.unref?.();
412
492
  };
413
493
 
@@ -417,22 +497,20 @@ export default function (pi: ExtensionAPI): void {
417
497
  kind: "continuation" | "background-drained",
418
498
  priorSnapshot?: ActivitySnapshot,
419
499
  ): Promise<void> => {
500
+ const execution = executionGeneration;
501
+ const wake = wakeGeneration;
502
+ const auditIsCurrent = (): boolean => {
503
+ if (execution !== executionGeneration || wake !== wakeGeneration) return false;
504
+ const current = currentGoalSnapshot(ctx);
505
+ return isPokeable(current) && current.goalId === goalId &&
506
+ continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
507
+ };
420
508
  const goal = currentGoalSnapshot(ctx);
421
- if (!isPokeable(goal) || goal.goalId !== goalId || continuationQueuedFor === goal.goalId || foregroundRunning) {
422
- return;
423
- }
509
+ if (!auditIsCurrent() || !isPokeable(goal)) return;
424
510
  if (!boundCommandReady(goal, ctx)) return;
425
- const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
426
- latestSnapshot = snapshot;
427
- pi.events.emit(EVENT_ACTIVITY, snapshot);
428
- if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
429
- return;
430
- }
511
+ const snapshot = await publishSnapshot(ctx);
512
+ if (!snapshot || !auditIsCurrent() || snapshot.foregroundRunning || snapshot.backgroundRunning) return;
431
513
 
432
- // A background drain is a meaningful external state transition and reopens a held goal.
433
- if (kind === "background-drained") {
434
- resetContinuationState(goal);
435
- }
436
514
  continuationQueuedFor = goal.goalId;
437
515
  const content = kind === "background-drained"
438
516
  ? "Background activity for the active goal is no longer running. Inspect any subagent callbacks or final results, then continue the completion audit before marking the goal complete.\n\n" +
@@ -475,64 +553,94 @@ export default function (pi: ExtensionAPI): void {
475
553
  return goal;
476
554
  };
477
555
 
478
- const publishSnapshot = async (ctx: ExtensionContext): Promise<ActivitySnapshot> => {
556
+ const publishSnapshot = async (ctx: ExtensionContext): Promise<ActivitySnapshot | null> => {
557
+ const execution = executionGeneration;
558
+ const sequence = ++snapshotSequence;
479
559
  const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
560
+ if (execution !== executionGeneration) return null;
561
+ if (sequence < lastAppliedSnapshotSequence) return latestSnapshot;
562
+ lastAppliedSnapshotSequence = sequence;
480
563
  latestSnapshot = snapshot;
481
- pi.events.emit(EVENT_ACTIVITY, snapshot);
482
564
 
565
+ const goal = currentGoalSnapshot(ctx);
566
+ const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
567
+ backgroundDrainTracker = wakePlan.nextTracker;
568
+ if (isPokeable(goal) && wakePlan.wakeSignature) {
569
+ // Persist external progress now: a foreground/callback turn may cancel its delayed wake.
570
+ clearIdleContinuation();
571
+ resetContinuationState(goal);
572
+ lastAgentEvidence = null;
573
+ scheduleIdleContinuation(goal, ctx, "background-drained", snapshot);
574
+ }
575
+
576
+ if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
577
+ clearIdleContinuation();
578
+ }
579
+
580
+ pi.events.emit(EVENT_ACTIVITY, snapshot);
581
+ if (execution !== executionGeneration) return null;
483
582
  const attention = terminalAttentionSignature(snapshot);
484
583
  if (attention && attention !== lastAttentionSignature) {
485
584
  lastAttentionSignature = attention;
486
585
  pi.events.emit(EVENT_TERMINAL_ATTENTION, snapshot);
586
+ if (execution !== executionGeneration) return null;
487
587
  }
588
+ applyStatus(ctx);
488
589
 
489
- if (!WAKE_DISABLED) {
490
- const goal = currentGoalSnapshot(ctx);
491
- const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
492
- backgroundDrainTracker = wakePlan.nextTracker;
493
- if (isPokeable(goal) && wakePlan.wakeSignature) {
494
- const wakeSignature = wakePlan.wakeSignature;
495
- if (wakeSignature !== lastWakeSignature) {
496
- lastWakeSignature = wakeSignature;
497
- scheduleIdleContinuation(goal, ctx, "background-drained", snapshot);
498
- }
499
- }
500
- }
501
-
502
- if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
503
- clearIdleContinuation();
504
- }
590
+ return snapshot;
591
+ };
505
592
 
506
- if (ctx.hasUI) {
507
- const goal = getGoal(ctx);
508
- const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
509
- const goalStall = observeGoalStall(goal, continuation, {
510
- foregroundRunning,
511
- backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
512
- });
513
- let waitingOnAnswer = 0;
593
+ /** Footer status from the goal state and the latest activity snapshot. */
594
+ const statusText = (ctx: ExtensionContext, snapshot: ActivitySnapshot | null): string | undefined => {
595
+ const goal = getGoal(ctx);
596
+ let waitingOnAnswer = 0;
597
+ if (snapshot) {
514
598
  for (const activeAtStart of pendingQuestions.values()) {
515
599
  waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
516
600
  }
517
- const status = waitingOnAnswer > 0
518
- ? `${waitingOnAnswer} background done; waiting on your answer`
519
- : snapshot.backgroundRunning
520
- ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
521
- : continuation?.blocked
522
- ? "waiting: no progress"
523
- : goalStall?.state === "stalled"
524
- ? "goal stalled"
525
- : undefined;
526
- try {
527
- ctx.ui.setStatus(EXTENSION_NAME, status);
528
- } catch {
529
- // UI status is best-effort only.
530
- }
531
601
  }
532
-
533
- return snapshot;
602
+ if (waitingOnAnswer > 0) return `${waitingOnAnswer} background done; waiting on your answer`;
603
+ const background = snapshot?.backgroundRunning
604
+ ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
605
+ : undefined;
606
+ if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal)} · ${background}` : pausedGoalStatus(goal);
607
+ if (background) return background;
608
+ const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
609
+ if (continuation?.blocked) return "waiting: no progress";
610
+ const goalStall = observeGoalStall(goal, continuation, {
611
+ foregroundRunning,
612
+ backgroundRunning: snapshot?.backgroundRunning ?? false,
613
+ });
614
+ return goalStall?.state === "stalled" ? "goal stalled" : undefined;
534
615
  };
535
616
 
617
+ function applyStatus(ctx: ExtensionContext): void {
618
+ if (!ctx.hasUI) return;
619
+ try {
620
+ ctx.ui.setStatus(EXTENSION_NAME, statusText(ctx, latestSnapshot));
621
+ } catch {
622
+ // UI status is best-effort only.
623
+ }
624
+ }
625
+
626
+ /**
627
+ * `goal_resume` is in the model's tool list only while an escape-paused goal
628
+ * waits. Pi activates newly registered tools by default, so this also removes
629
+ * it at session start. Only this one tool is toggled; other extensions' tool
630
+ * choices are left as they are.
631
+ */
632
+ function syncResumeTool(goal: GoalSnapshot | null): void {
633
+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
634
+ try {
635
+ const active = pi.getActiveTools();
636
+ const wanted = agentResumable(goal);
637
+ if (active.includes(GOAL_RESUME_TOOL) === wanted) return;
638
+ pi.setActiveTools(wanted ? [...active, GOAL_RESUME_TOOL] : active.filter((name) => name !== GOAL_RESUME_TOOL));
639
+ } catch {
640
+ // Tool activation is unavailable before Pi binds the session; the tool also refuses on its own.
641
+ }
642
+ }
643
+
536
644
  const collectIfPossible = (): void => {
537
645
  const ctx = currentCtx;
538
646
  if (!ctx) {
@@ -543,7 +651,9 @@ export default function (pi: ExtensionAPI): void {
543
651
  return;
544
652
  }
545
653
  collecting = true;
654
+ const session = sessionGeneration;
546
655
  void publishSnapshot(ctx).finally(() => {
656
+ if (session !== sessionGeneration) return;
547
657
  collecting = false;
548
658
  if (collectionPending) {
549
659
  collectionPending = false;
@@ -675,18 +785,11 @@ export default function (pi: ExtensionAPI): void {
675
785
  }
676
786
 
677
787
  if (trimmed === "resume") {
678
- if (!current || current.status !== "paused") {
679
- notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
680
- return;
681
- }
682
- const blocker = resumeBlocker(current);
683
- if (blocker) {
684
- notifyGoal(ctx, blocker, "error");
788
+ const result = resumeGoal(ctx, "command");
789
+ if (!result.ok) {
790
+ notifyGoal(ctx, result.message, current?.status === "paused" ? "error" : "warning");
685
791
  return;
686
792
  }
687
- const goal = goalWithStatus(current, "active");
688
- setGoal(goal, ctx, "command");
689
- queueGoalContinuation(goal, ctx);
690
793
  notifyGoal(ctx, "Goal resumed.");
691
794
  return;
692
795
  }
@@ -789,12 +892,51 @@ export default function (pi: ExtensionAPI): void {
789
892
  },
790
893
  });
791
894
 
895
+ pi.registerTool({
896
+ name: GOAL_RESUME_TOOL,
897
+ label: "Resume Goal",
898
+ description:
899
+ "Resume the goal the user paused with escape, exactly like /goal resume. " + GOAL_RESUME_RULE,
900
+ promptSnippet: "Resume the escape-paused goal, only on the user's clear go-ahead.",
901
+ parameters: Type.Object({
902
+ reason: Type.Optional(Type.String({ description: "Short quote or summary of the user's go-ahead." })),
903
+ }),
904
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
905
+ const reason = (params as { reason?: string }).reason;
906
+ const current = getGoal(ctx);
907
+ if (!agentResumable(current)) {
908
+ const text = current?.status === "paused"
909
+ ? "This goal was paused with /goal pause or by the runtime. Only the user can resume it, with /goal resume."
910
+ : "No goal is paused, so there is nothing to resume.";
911
+ return { content: [{ type: "text", text }], details: { ok: false, goal: current } };
912
+ }
913
+ const result = resumeGoal(ctx, "tool");
914
+ if (!result.ok) {
915
+ return { content: [{ type: "text", text: `Goal stays paused. ${result.message}` }], details: { ok: false, goal: getGoal(ctx) } };
916
+ }
917
+ return {
918
+ content: [{ type: "text", text: "Goal resumed. Its continuation runs after this turn." }],
919
+ details: { ok: true, goal: result.goal, ...(reason ? { reason } : {}) },
920
+ };
921
+ },
922
+ });
923
+
924
+ pi.registerShortcut?.(GOAL_RESUME_SHORTCUT, {
925
+ description: "Resume the paused goal",
926
+ handler: (ctx) => {
927
+ currentCtx = ctx;
928
+ const result = resumeGoal(ctx, "command");
929
+ notifyGoal(ctx, result.ok ? "Goal resumed." : result.message, result.ok ? "info" : "warning");
930
+ },
931
+ });
932
+
792
933
  pi.registerCommand("better-activity", {
793
934
  description: "Show foreground/background activity known to pi-better-goal",
794
935
  handler: async (_args, ctx) => {
795
936
  currentCtx = ctx;
796
937
  const snapshot = await publishSnapshot(ctx);
797
- ctx.ui.notify(formatSnapshot(snapshot), snapshot.backgroundRunning ? "info" : "info");
938
+ if (!snapshot) return;
939
+ ctx.ui.notify(formatSnapshot(snapshot), "info");
798
940
  },
799
941
  });
800
942
 
@@ -833,6 +975,9 @@ export default function (pi: ExtensionAPI): void {
833
975
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
834
976
  currentCtx = ctx;
835
977
  const snapshot = await publishSnapshot(ctx);
978
+ if (!snapshot) {
979
+ return { content: [{ type: "text", text: "Activity changed during collection; inspect again." }], details: { stale: true } };
980
+ }
836
981
  return {
837
982
  content: [{ type: "text", text: formatSnapshot(snapshot) }],
838
983
  details: snapshot,
@@ -841,6 +986,14 @@ export default function (pi: ExtensionAPI): void {
841
986
  });
842
987
 
843
988
  pi.on("session_start", async (_event, ctx) => {
989
+ executionGeneration += 1;
990
+ sessionGeneration += 1;
991
+ clearIdleContinuation();
992
+ continuationQueuedFor = null;
993
+ backgroundDrainTracker = null;
994
+ lastAgentEvidence = null;
995
+ collecting = false;
996
+ collectionPending = false;
844
997
  currentCtx = ctx;
845
998
  foregroundRunning = !ctx.isIdle();
846
999
  const restoredGoal = getGoal(ctx);
@@ -849,10 +1002,12 @@ export default function (pi: ExtensionAPI): void {
849
1002
  notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
850
1003
  }
851
1004
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
1005
+ syncResumeTool(getGoal(ctx));
852
1006
  installGoalWidget(ctx);
853
- latestSnapshot = await publishSnapshot(ctx);
1007
+ const snapshot = await publishSnapshot(ctx);
1008
+ if (!snapshot) return;
854
1009
  syncPollingState();
855
- if (restoredGoal?.status === "active" && restoredGoal.command && boundCommandReady(restoredGoal, ctx) && !foregroundRunning && !latestSnapshot.backgroundRunning) {
1010
+ if (restoredGoal?.status === "active" && restoredGoal.command && boundCommandReady(restoredGoal, ctx) && !foregroundRunning && !snapshot.backgroundRunning) {
856
1011
  scheduleIdleContinuation(restoredGoal, ctx, "continuation");
857
1012
  }
858
1013
  });
@@ -874,24 +1029,13 @@ export default function (pi: ExtensionAPI): void {
874
1029
  }
875
1030
  }
876
1031
  }
1032
+ // A paused goal stays paused while the user talks: the message is ordinary
1033
+ // conversation. Only /goal resume, the hotkey, or goal_resume resume it.
877
1034
  const goal = getGoal(ctx);
878
- if (goal?.status === "paused" && goal.pauseReason === "interrupt") {
879
- // Conversational input after an interrupt means the user has taken the
880
- // wheel, not stopped the goal: reactivate it so continuation resumes once
881
- // this exchange settles. Pi's built-in commands (/settings, /model, ...)
882
- // and extension commands never reach this handler.
883
- const blocker = resumeBlocker(goal);
884
- if (blocker) {
885
- notifyGoal(ctx, `Goal stays paused. ${blocker}`, "warning");
886
- return;
887
- }
888
- const resumed = goalWithStatus(goal, "active");
889
- setGoal(resumed, ctx, "runtime");
890
- resetContinuationState(resumed);
891
- notifyGoal(ctx, "Goal resumed; it continues after this exchange.");
892
- return;
893
- }
894
1035
  if (goal?.status === "active") {
1036
+ executionGeneration += 1;
1037
+ clearIdleContinuation();
1038
+ lastAgentEvidence = null;
895
1039
  resetContinuationState(goal);
896
1040
  }
897
1041
  });
@@ -901,8 +1045,10 @@ export default function (pi: ExtensionAPI): void {
901
1045
  const goal = currentGoalSnapshot(ctx);
902
1046
  const owner = getWorkflow(ctx);
903
1047
  const snapshot = await publishSnapshot(ctx);
1048
+ if (!snapshot) return;
1049
+ const pausedInstruction = agentResumable(goal) ? pausedGoalPrompt(goal!) : "";
904
1050
  if (!isPokeable(goal) && !owner) {
905
- return;
1051
+ return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
906
1052
  }
907
1053
 
908
1054
  const questionInstruction = snapshot.backgroundRunning
@@ -923,7 +1069,11 @@ export default function (pi: ExtensionAPI): void {
923
1069
  return { systemPrompt: `${event.systemPrompt}\n\nWorkflow ${owner.name} is unavailable. Stop work and ask the user to restore or reinvoke the skill.` };
924
1070
  }
925
1071
  return {
926
- systemPrompt: `${event.systemPrompt}\n\nActive workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}` +
1072
+ systemPrompt: `${event.systemPrompt}\n\n` +
1073
+ (pausedInstruction
1074
+ ? `${pausedInstruction}\nWhile the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n`
1075
+ : "") +
1076
+ `Active workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}` +
927
1077
  (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
928
1078
  (questionInstruction ? `\n\n${questionInstruction.trim()}` : ""),
929
1079
  };
@@ -941,6 +1091,8 @@ export default function (pi: ExtensionAPI): void {
941
1091
  });
942
1092
 
943
1093
  pi.on("agent_start", async (_event, ctx) => {
1094
+ executionGeneration += 1;
1095
+ agentGeneration = executionGeneration;
944
1096
  currentCtx = ctx;
945
1097
  foregroundRunning = true;
946
1098
  clearIdleContinuation();
@@ -958,7 +1110,7 @@ export default function (pi: ExtensionAPI): void {
958
1110
  });
959
1111
 
960
1112
  pi.on("agent_end", async (event, ctx) => {
961
- lastAgentEvidence = continuationEvidence(event.messages);
1113
+ lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
962
1114
  // `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
963
1115
  // cannot register it. Observe the interrupt instead: when a running turn is
964
1116
  // aborted (escape / ctrl+c while streaming), pause the active goal so it does
@@ -972,6 +1124,7 @@ export default function (pi: ExtensionAPI): void {
972
1124
  if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
973
1125
  currentCtx = ctx;
974
1126
  const snapshot = await publishSnapshot(ctx);
1127
+ if (!snapshot) return;
975
1128
  const active = activeItemsByKey(snapshot);
976
1129
  if (active.size > 0) pendingQuestions.set(event.toolCallId, active);
977
1130
  });
@@ -982,6 +1135,7 @@ export default function (pi: ExtensionAPI): void {
982
1135
  pendingQuestions.delete(event.toolCallId);
983
1136
  currentCtx = ctx;
984
1137
  const snapshot = await publishSnapshot(ctx);
1138
+ if (!snapshot) return;
985
1139
  const finished = finishedSinceQuestion(activeAtStart, snapshot);
986
1140
  if (finished.length === 0) return;
987
1141
  // Steering is drained right after this tool batch, so the model sees the
@@ -995,15 +1149,21 @@ export default function (pi: ExtensionAPI): void {
995
1149
  });
996
1150
 
997
1151
  pi.on("agent_settled", async (_event, ctx) => {
1152
+ executionGeneration += 1;
998
1153
  currentCtx = ctx;
999
1154
  foregroundRunning = false;
1000
1155
  const snapshot = await publishSnapshot(ctx);
1156
+ if (!snapshot) return;
1001
1157
  const goal = getGoal(ctx);
1002
1158
  if (!isPokeable(goal) || snapshot.backgroundRunning) {
1003
1159
  return;
1004
1160
  }
1161
+ const evidence = lastAgentEvidence;
1162
+ if (!evidence) {
1163
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
1164
+ return;
1165
+ }
1005
1166
  const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
1006
- const evidence = lastAgentEvidence ?? continuationEvidence([]);
1007
1167
  const repeated = previous.lastEvidenceSignature === evidence.signature;
1008
1168
  const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
1009
1169
  const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
@@ -1034,6 +1194,8 @@ export default function (pi: ExtensionAPI): void {
1034
1194
  });
1035
1195
 
1036
1196
  pi.on("session_shutdown", async (_event, ctx) => {
1197
+ executionGeneration += 1;
1198
+ sessionGeneration += 1;
1037
1199
  stopPolling();
1038
1200
  foregroundRunning = false;
1039
1201
  pendingQuestions.clear();
package/src/types.ts CHANGED
@@ -66,10 +66,12 @@ export interface ActivitySnapshot {
66
66
  export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
67
67
 
68
68
  /**
69
- * Why a paused goal is paused. `interrupt` marks a soft pause from escape (or
70
- * any other abort of the running turn): the user's next conversational message
71
- * resumes it. A paused goal without a reason (`/goal pause`, an unavailable
72
- * command or workflow) stays paused until `/goal resume`.
69
+ * Why a paused goal is paused. `interrupt` marks a pause from escape (or any
70
+ * other abort of the running turn). Every pause is sticky: user messages never
71
+ * resume a goal on their own. An `interrupt` pause may be resumed by the agent
72
+ * with `goal_resume` when the user clearly says to proceed. A paused goal
73
+ * without a reason (`/goal pause`, an unavailable command or workflow) resumes
74
+ * only through `/goal resume` or the resume hotkey.
73
75
  */
74
76
  export type GoalPauseReason = "interrupt";
75
77