pi-better-harness 0.13.1 → 0.14.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.
@@ -17,7 +17,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.5.0",
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",
@@ -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";
@@ -121,6 +122,14 @@ function parseDurationEnv(raw: string | undefined, fallback: number): number {
121
122
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
122
123
  }
123
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
+
124
133
  function parseRetryLimit(raw: string | undefined, fallback: number): number {
125
134
  if (raw === undefined || raw.trim() === "") {
126
135
  return fallback;
@@ -257,13 +266,18 @@ export default function (pi: ExtensionAPI): void {
257
266
  });
258
267
 
259
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;
260
275
  let foregroundRunning = false;
261
276
  let pollTimer: ReturnType<typeof setInterval> | undefined;
262
277
  let collecting = false;
263
278
  let collectionPending = false;
264
279
  let latestSnapshot: ActivitySnapshot | null = null;
265
280
  let backgroundDrainTracker: BackgroundDrainTracker | null = null;
266
- let lastWakeSignature = "";
267
281
  let lastAttentionSignature = "";
268
282
  let continuationQueuedFor: string | null = null;
269
283
  let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
@@ -330,6 +344,8 @@ export default function (pi: ExtensionAPI): void {
330
344
  };
331
345
 
332
346
  const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
347
+ executionGeneration += 1;
348
+ lastAgentEvidence = null;
333
349
  const previous = getGoal(ctx);
334
350
  const wasVisible = previous !== null && isGoalClockVisible(previous);
335
351
  pi.appendEntry(EXTENSION_NAME, goalSetEntry(goal, source));
@@ -344,6 +360,8 @@ export default function (pi: ExtensionAPI): void {
344
360
  };
345
361
 
346
362
  const clearGoal = (ctx: ExtensionContext, source: GoalEntrySource): void => {
363
+ executionGeneration += 1;
364
+ lastAgentEvidence = null;
347
365
  const current = getGoal(ctx);
348
366
  const wasVisible = current !== null && isGoalClockVisible(current);
349
367
  pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
@@ -385,21 +403,22 @@ export default function (pi: ExtensionAPI): void {
385
403
 
386
404
  /**
387
405
  * The one resume path shared by `/goal resume`, the hotkey, and `goal_resume`:
388
- * reactivate the paused goal and queue its continuation.
406
+ * reactivate a paused goal or reopen an active no-progress hold.
389
407
  */
390
408
  const resumeGoal = (
391
409
  ctx: ExtensionContext,
392
410
  source: GoalEntrySource,
393
411
  ): { ok: true; goal: GoalSnapshot } | { ok: false; message: string } => {
394
412
  const current = getGoal(ctx);
395
- if (!current || current.status !== "paused") {
396
- return { ok: false, message: "Only paused goals can be resumed." };
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." };
397
416
  }
398
417
  const blocker = resumeBlocker(current);
399
418
  if (blocker) {
400
419
  return { ok: false, message: blocker };
401
420
  }
402
- const goal = goalWithStatus(current, "active");
421
+ const goal = held ? current : goalWithStatus(current, "active");
403
422
  setGoal(goal, ctx, source);
404
423
  queueGoalContinuation(goal, ctx);
405
424
  return { ok: true, goal };
@@ -437,6 +456,7 @@ export default function (pi: ExtensionAPI): void {
437
456
  };
438
457
 
439
458
  function clearIdleContinuation(): void {
459
+ wakeGeneration += 1;
440
460
  if (idleContinuationTimer) {
441
461
  clearTimeout(idleContinuationTimer);
442
462
  idleContinuationTimer = undefined;
@@ -457,7 +477,8 @@ export default function (pi: ExtensionAPI): void {
457
477
  return;
458
478
  }
459
479
  const signature = `${goal.goalId}:${kind}`;
460
- if (idleContinuationTimer && idleContinuationSignature === signature) {
480
+ if (idleContinuationTimer && (idleContinuationSignature === signature ||
481
+ (kind === "continuation" && idleContinuationSignature === `${goal.goalId}:background-drained`))) {
461
482
  return;
462
483
  }
463
484
  clearIdleContinuation();
@@ -466,7 +487,7 @@ export default function (pi: ExtensionAPI): void {
466
487
  idleContinuationTimer = undefined;
467
488
  idleContinuationSignature = "";
468
489
  void sendIdleContinuationAfterAudit(goal.goalId, ctx, kind, snapshot);
469
- }, IDLE_CONTINUATION_DELAY_MS);
490
+ }, idleContinuationDelay(kind === "continuation" ? currentContinuationState(ctx, goal.goalId)?.noProgressRetries ?? 0 : 0));
470
491
  idleContinuationTimer.unref?.();
471
492
  };
472
493
 
@@ -476,22 +497,20 @@ export default function (pi: ExtensionAPI): void {
476
497
  kind: "continuation" | "background-drained",
477
498
  priorSnapshot?: ActivitySnapshot,
478
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
+ };
479
508
  const goal = currentGoalSnapshot(ctx);
480
- if (!isPokeable(goal) || goal.goalId !== goalId || continuationQueuedFor === goal.goalId || foregroundRunning) {
481
- return;
482
- }
509
+ if (!auditIsCurrent() || !isPokeable(goal)) return;
483
510
  if (!boundCommandReady(goal, ctx)) return;
484
- const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
485
- latestSnapshot = snapshot;
486
- pi.events.emit(EVENT_ACTIVITY, snapshot);
487
- if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
488
- return;
489
- }
511
+ const snapshot = await publishSnapshot(ctx);
512
+ if (!snapshot || !auditIsCurrent() || snapshot.foregroundRunning || snapshot.backgroundRunning) return;
490
513
 
491
- // A background drain is a meaningful external state transition and reopens a held goal.
492
- if (kind === "background-drained") {
493
- resetContinuationState(goal);
494
- }
495
514
  continuationQueuedFor = goal.goalId;
496
515
  const content = kind === "background-drained"
497
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" +
@@ -534,34 +553,38 @@ export default function (pi: ExtensionAPI): void {
534
553
  return goal;
535
554
  };
536
555
 
537
- const publishSnapshot = async (ctx: ExtensionContext): Promise<ActivitySnapshot> => {
556
+ const publishSnapshot = async (ctx: ExtensionContext): Promise<ActivitySnapshot | null> => {
557
+ const execution = executionGeneration;
558
+ const sequence = ++snapshotSequence;
538
559
  const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
560
+ if (execution !== executionGeneration) return null;
561
+ if (sequence < lastAppliedSnapshotSequence) return latestSnapshot;
562
+ lastAppliedSnapshotSequence = sequence;
539
563
  latestSnapshot = snapshot;
540
- pi.events.emit(EVENT_ACTIVITY, snapshot);
541
-
542
- const attention = terminalAttentionSignature(snapshot);
543
- if (attention && attention !== lastAttentionSignature) {
544
- lastAttentionSignature = attention;
545
- pi.events.emit(EVENT_TERMINAL_ATTENTION, snapshot);
546
- }
547
564
 
548
- if (!WAKE_DISABLED) {
549
- const goal = currentGoalSnapshot(ctx);
550
- const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
551
- backgroundDrainTracker = wakePlan.nextTracker;
552
- if (isPokeable(goal) && wakePlan.wakeSignature) {
553
- const wakeSignature = wakePlan.wakeSignature;
554
- if (wakeSignature !== lastWakeSignature) {
555
- lastWakeSignature = wakeSignature;
556
- scheduleIdleContinuation(goal, ctx, "background-drained", snapshot);
557
- }
558
- }
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);
559
574
  }
560
575
 
561
576
  if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
562
577
  clearIdleContinuation();
563
578
  }
564
579
 
580
+ pi.events.emit(EVENT_ACTIVITY, snapshot);
581
+ if (execution !== executionGeneration) return null;
582
+ const attention = terminalAttentionSignature(snapshot);
583
+ if (attention && attention !== lastAttentionSignature) {
584
+ lastAttentionSignature = attention;
585
+ pi.events.emit(EVENT_TERMINAL_ATTENTION, snapshot);
586
+ if (execution !== executionGeneration) return null;
587
+ }
565
588
  applyStatus(ctx);
566
589
 
567
590
  return snapshot;
@@ -628,7 +651,9 @@ export default function (pi: ExtensionAPI): void {
628
651
  return;
629
652
  }
630
653
  collecting = true;
654
+ const session = sessionGeneration;
631
655
  void publishSnapshot(ctx).finally(() => {
656
+ if (session !== sessionGeneration) return;
632
657
  collecting = false;
633
658
  if (collectionPending) {
634
659
  collectionPending = false;
@@ -910,7 +935,8 @@ export default function (pi: ExtensionAPI): void {
910
935
  handler: async (_args, ctx) => {
911
936
  currentCtx = ctx;
912
937
  const snapshot = await publishSnapshot(ctx);
913
- ctx.ui.notify(formatSnapshot(snapshot), snapshot.backgroundRunning ? "info" : "info");
938
+ if (!snapshot) return;
939
+ ctx.ui.notify(formatSnapshot(snapshot), "info");
914
940
  },
915
941
  });
916
942
 
@@ -949,6 +975,9 @@ export default function (pi: ExtensionAPI): void {
949
975
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
950
976
  currentCtx = ctx;
951
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
+ }
952
981
  return {
953
982
  content: [{ type: "text", text: formatSnapshot(snapshot) }],
954
983
  details: snapshot,
@@ -957,6 +986,14 @@ export default function (pi: ExtensionAPI): void {
957
986
  });
958
987
 
959
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;
960
997
  currentCtx = ctx;
961
998
  foregroundRunning = !ctx.isIdle();
962
999
  const restoredGoal = getGoal(ctx);
@@ -967,9 +1004,10 @@ export default function (pi: ExtensionAPI): void {
967
1004
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
968
1005
  syncResumeTool(getGoal(ctx));
969
1006
  installGoalWidget(ctx);
970
- latestSnapshot = await publishSnapshot(ctx);
1007
+ const snapshot = await publishSnapshot(ctx);
1008
+ if (!snapshot) return;
971
1009
  syncPollingState();
972
- 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) {
973
1011
  scheduleIdleContinuation(restoredGoal, ctx, "continuation");
974
1012
  }
975
1013
  });
@@ -995,6 +1033,9 @@ export default function (pi: ExtensionAPI): void {
995
1033
  // conversation. Only /goal resume, the hotkey, or goal_resume resume it.
996
1034
  const goal = getGoal(ctx);
997
1035
  if (goal?.status === "active") {
1036
+ executionGeneration += 1;
1037
+ clearIdleContinuation();
1038
+ lastAgentEvidence = null;
998
1039
  resetContinuationState(goal);
999
1040
  }
1000
1041
  });
@@ -1004,6 +1045,7 @@ export default function (pi: ExtensionAPI): void {
1004
1045
  const goal = currentGoalSnapshot(ctx);
1005
1046
  const owner = getWorkflow(ctx);
1006
1047
  const snapshot = await publishSnapshot(ctx);
1048
+ if (!snapshot) return;
1007
1049
  const pausedInstruction = agentResumable(goal) ? pausedGoalPrompt(goal!) : "";
1008
1050
  if (!isPokeable(goal) && !owner) {
1009
1051
  return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
@@ -1049,6 +1091,8 @@ export default function (pi: ExtensionAPI): void {
1049
1091
  });
1050
1092
 
1051
1093
  pi.on("agent_start", async (_event, ctx) => {
1094
+ executionGeneration += 1;
1095
+ agentGeneration = executionGeneration;
1052
1096
  currentCtx = ctx;
1053
1097
  foregroundRunning = true;
1054
1098
  clearIdleContinuation();
@@ -1066,7 +1110,7 @@ export default function (pi: ExtensionAPI): void {
1066
1110
  });
1067
1111
 
1068
1112
  pi.on("agent_end", async (event, ctx) => {
1069
- lastAgentEvidence = continuationEvidence(event.messages);
1113
+ lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
1070
1114
  // `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
1071
1115
  // cannot register it. Observe the interrupt instead: when a running turn is
1072
1116
  // aborted (escape / ctrl+c while streaming), pause the active goal so it does
@@ -1080,6 +1124,7 @@ export default function (pi: ExtensionAPI): void {
1080
1124
  if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
1081
1125
  currentCtx = ctx;
1082
1126
  const snapshot = await publishSnapshot(ctx);
1127
+ if (!snapshot) return;
1083
1128
  const active = activeItemsByKey(snapshot);
1084
1129
  if (active.size > 0) pendingQuestions.set(event.toolCallId, active);
1085
1130
  });
@@ -1090,6 +1135,7 @@ export default function (pi: ExtensionAPI): void {
1090
1135
  pendingQuestions.delete(event.toolCallId);
1091
1136
  currentCtx = ctx;
1092
1137
  const snapshot = await publishSnapshot(ctx);
1138
+ if (!snapshot) return;
1093
1139
  const finished = finishedSinceQuestion(activeAtStart, snapshot);
1094
1140
  if (finished.length === 0) return;
1095
1141
  // Steering is drained right after this tool batch, so the model sees the
@@ -1103,15 +1149,21 @@ export default function (pi: ExtensionAPI): void {
1103
1149
  });
1104
1150
 
1105
1151
  pi.on("agent_settled", async (_event, ctx) => {
1152
+ executionGeneration += 1;
1106
1153
  currentCtx = ctx;
1107
1154
  foregroundRunning = false;
1108
1155
  const snapshot = await publishSnapshot(ctx);
1156
+ if (!snapshot) return;
1109
1157
  const goal = getGoal(ctx);
1110
1158
  if (!isPokeable(goal) || snapshot.backgroundRunning) {
1111
1159
  return;
1112
1160
  }
1161
+ const evidence = lastAgentEvidence;
1162
+ if (!evidence) {
1163
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
1164
+ return;
1165
+ }
1113
1166
  const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
1114
- const evidence = lastAgentEvidence ?? continuationEvidence([]);
1115
1167
  const repeated = previous.lastEvidenceSignature === evidence.signature;
1116
1168
  const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
1117
1169
  const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
@@ -1142,6 +1194,8 @@ export default function (pi: ExtensionAPI): void {
1142
1194
  });
1143
1195
 
1144
1196
  pi.on("session_shutdown", async (_event, ctx) => {
1197
+ executionGeneration += 1;
1198
+ sessionGeneration += 1;
1145
1199
  stopPolling();
1146
1200
  foregroundRunning = false;
1147
1201
  pendingQuestions.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "pi-better-background-tasks": "0.6.2",
56
- "pi-better-goal": "0.5.0",
56
+ "pi-better-goal": "0.6.0",
57
57
  "pi-better-plan": "0.5.1",
58
58
  "pi-better-sandbox": "0.8.0",
59
59
  "pi-better-ssh": "0.1.1",