pi-better-harness 0.41.1 → 0.41.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.
@@ -19,11 +19,12 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
19
19
  - A compact goal widget that does not replace Pi's footer.
20
20
  - Background activity tracking for subagents and other registered providers.
21
21
  - A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a sticky hold recoverable with `/goal resume` or `alt+g`. Conversation, changed callback results, and background drains do not reopen an exhausted hold. Delays saturate at Node's timer limit, and observed background drains reset non-held progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
22
+ - Permission failure reports remain available to the agent as diagnostic evidence. They do not pause Goal or require a one-turn `/goal resume` release. The agent can retry, wait, or continue other authorized work; tool and sandbox permissions remain enforced independently. Repeated unchanged work still reaches the ordinary no-progress hold. Legacy permission-paused goals resume normally with `/goal resume` or `alt+g`.
22
23
  - An observable-progress stall state for active goals.
23
24
 
24
25
  ## Optional Harness-Owned Outcome & Todo
25
26
 
26
- When `pi-better-harness` is installed, its optional **Outcome & Todo** runtime owns the session's structured outcome and checklist. Goal keeps its own objective, clocks, pause and permission holds, workflow gates, background wake behavior, and continuation policy; it also sends lifecycle requests to the Harness runtime so a bound outcome starts, pauses, resumes, and completes with the Goal. When the runtime is present, Goal stops adding its own active-objective text to avoid duplicating the Harness-owned outcome, whether Outcome & Todo is enabled or off.
27
+ When `pi-better-harness` is installed, its optional **Outcome & Todo** runtime owns the session's structured outcome and checklist. Goal keeps its own objective, clocks, pauses, workflow gates, background wake behavior, and continuation policy; it also sends lifecycle requests to the Harness runtime so a bound outcome starts, pauses, resumes, and completes with the Goal. When the runtime is present, Goal stops adding its own active-objective text to avoid duplicating the Harness-owned outcome, whether Outcome & Todo is enabled or off.
27
28
 
28
29
  `/goal` does not install, enable, or configure Outcome & Todo. Install Harness and enable **Outcome & Todo** in `/harness-settings` if you want that shared outcome and checklist. Without Harness, this package keeps its standalone prompts and behavior unchanged. The stored Goal objective remains the raw original trigger (for example, a bound command invocation) so `/goal resume` can re-enter the work; it is not the authoritative business outcome when Harness owns one.
29
30
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,7 +66,16 @@ function isGoalStatus(value: unknown): value is GoalStatus {
66
66
  }
67
67
 
68
68
  function isPauseReason(value: unknown): value is GoalPauseReason {
69
- return value === "interrupt" || value === "permission-blocker" || value === "handback";
69
+ return value === "interrupt" || value === "handback";
70
+ }
71
+
72
+ /**
73
+ * Goals persisted by older releases can carry `permission-blocker`. Reports no
74
+ * longer pause a goal, so such a goal resumes like `/goal pause` (human-only
75
+ * `/goal resume` or alt+g); loading grants no permission and rewrites nothing.
76
+ */
77
+ function normalizedPauseReason(value: unknown): GoalPauseReason | undefined {
78
+ return isPauseReason(value) ? value : undefined;
70
79
  }
71
80
 
72
81
  function isGoalLike(value: unknown): value is GoalLike {
@@ -119,12 +128,13 @@ function normalizeGoal(goal: GoalLike): GoalSnapshot {
119
128
  const now = unixSeconds();
120
129
  const createdAt = typeof goal.createdAt === "number" ? goal.createdAt : now;
121
130
  const updatedAt = typeof goal.updatedAt === "number" ? goal.updatedAt : createdAt;
131
+ const pauseReason = goal.status === "paused" ? normalizedPauseReason(goal.pauseReason) : undefined;
122
132
  return {
123
133
  goalId: goal.goalId,
124
134
  objective: goal.objective,
125
135
  ...(isCommandBinding(goal.command) ? { command: goal.command } : {}),
126
136
  status: goal.status,
127
- ...(goal.status === "paused" && isPauseReason(goal.pauseReason) ? { pauseReason: goal.pauseReason } : {}),
137
+ ...(pauseReason ? { pauseReason } : {}),
128
138
  tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
129
139
  usage: {
130
140
  tokensUsed: typeof goal.usage?.tokensUsed === "number" ? goal.usage.tokensUsed : 0,
@@ -29,7 +29,7 @@ import {
29
29
  type GoalEntrySource,
30
30
  } from "./goal-state.js";
31
31
  import { continuationEvidence, type ContinuationEvidence } from "./continuation.js";
32
- import { currentPermissionHold, logicalPermissionBlockerKey, MAX_PERMISSION_BLOCKERS, MAX_PERMISSION_RECORDS, permissionHoldRecord, permissionInstruction, permissionRecord, reportedPermissionBlockers, reportedPermissionGap, type PermissionHold } from "./permission-hold.js";
32
+ import { currentPermissionHold, logicalPermissionBlockerKey, MAX_PERMISSION_BLOCKERS, MAX_PERMISSION_RECORDS, permissionGapRecord, permissionHoldRecord, reportedPermissionBlockers, reportedPermissionGap, type PermissionHold } from "./permission-hold.js";
33
33
  import { publishGoalGuidance } from "./prompt.js";
34
34
  import { observeGoalStall } from "./stall.js";
35
35
  import {
@@ -103,7 +103,6 @@ export function agentResumable(goal: GoalSnapshot | null, conversationalResume =
103
103
 
104
104
  /** Footer status for a paused goal, telling the user how to resume it. */
105
105
  export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
106
- if (goal.pauseReason === "permission-blocker") return "goal held: permission blocker · /goal resume (one retry)";
107
106
  return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
108
107
  }
109
108
 
@@ -111,9 +110,11 @@ function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean, inc
111
110
  return [
112
111
  goal.pauseReason === "handback"
113
112
  ? `Pi Better Goal is paused because its workflow handed back unfinished work for the user's answer.${includeObjective ? ` Goal: ${goal.objective}` : ""}`
114
- : `Pi Better Goal is paused because the user pressed escape.${includeObjective ? ` Goal: ${goal.objective}` : ""}`,
113
+ : goal.pauseReason === "interrupt"
114
+ ? `Pi Better Goal is paused because the user pressed escape.${includeObjective ? ` Goal: ${goal.objective}` : ""}`
115
+ : `Pi Better Goal is paused.${includeObjective ? ` Goal: ${goal.objective}` : ""}`,
115
116
  "Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
116
- conversationalResume ? GOAL_RESUME_RULE : "Only the user can resume this goal, with /goal resume or alt+g. A conversational go-ahead does not resume it.",
117
+ agentResumable(goal, conversationalResume) ? GOAL_RESUME_RULE : "Only the user can resume this goal, with /goal resume or alt+g. A conversational go-ahead does not resume it.",
117
118
  ].join("\n");
118
119
  }
119
120
 
@@ -260,8 +261,8 @@ function formatGoal(
260
261
  `Status: ${goal.status}`,
261
262
  ...(goal.pauseReason ? [`Pause reason: ${goal.pauseReason}`] : []),
262
263
  ...(permissions?.blockers.length ? [
263
- `Permission hold: ${permissions.blockers.length} scope(s); ${permissions.recordCount} history records; retry ${permissions.retryPending ? "released once" : "held"}`,
264
- ...(permissions.saturated ? ["Permission observation incomplete: history/scope limit reached; release refused."] : []),
264
+ `Permission diagnostics: ${permissions.blockers.length} scope(s); ${permissions.recordCount} history records`,
265
+ ...(permissions.saturated ? ["Permission observation incomplete: history/scope limit reached. Diagnostics only; continuation is unaffected."] : []),
265
266
  ] : []),
266
267
  `Token budget: ${budget}`,
267
268
  `Tokens used: ${goal.usage.tokensUsed}`,
@@ -396,13 +397,6 @@ export default function (pi: ExtensionAPI): void {
396
397
  };
397
398
 
398
399
  const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
399
- const permissionHold = currentPermissionHold(ctx, goal.goalId);
400
- if (goal.status === "paused" && permissionHold.blockers.length > 0) {
401
- if (permissionHold.retryPending && permissionHold.recordCount < MAX_PERMISSION_RECORDS) {
402
- pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
403
- }
404
- goal = { ...goal, pauseReason: "permission-blocker" };
405
- }
406
400
  executionGeneration += 1;
407
401
  lastAgentEvidence = null;
408
402
  const previous = getGoal(ctx);
@@ -458,9 +452,7 @@ export default function (pi: ExtensionAPI): void {
458
452
  }
459
453
  const held = currentContinuationState(ctx, goal.goalId)?.blocked === true;
460
454
  setGoal(goalWithStatus(goal, "paused", undefined, held ? undefined : "interrupt"), ctx, "runtime");
461
- notifyGoal(ctx, currentPermissionHold(ctx, goal.goalId).blockers.length > 0
462
- ? "Goal remains permission-held. Use /goal resume or alt+g for one same-scope retry."
463
- : preferences.conversationalResume && !held
455
+ notifyGoal(ctx, preferences.conversationalResume && !held
464
456
  ? 'Goal paused. Say "go" to resume it, or use /goal resume.'
465
457
  : "Goal paused. Use /goal resume or alt+g to resume it.");
466
458
  };
@@ -530,18 +522,6 @@ export default function (pi: ExtensionAPI): void {
530
522
  return { ok: false, message: blocker };
531
523
  }
532
524
  const goal = held ? current : goalWithStatus(current, "active");
533
- const permissions = currentPermissionHold(ctx, current.goalId);
534
- if (current.pauseReason === "permission-blocker" && permissions.blockers.length === 0) {
535
- return { ok: false, message: "Permission hold evidence is unavailable. The goal stays held; inspect the report before replacing this goal explicitly." };
536
- }
537
- if (permissions.blockers.length > 0) {
538
- if (source === "tool" || permissions.saturated) {
539
- return { ok: false, message: permissions.saturated
540
- ? "Permission hold history is full. The goal stays held; inspect it and create a new goal explicitly if needed."
541
- : "Only /goal resume or alt+g can release one permission retry." };
542
- }
543
- pi.appendEntry(EXTENSION_NAME, permissionRecord(current.goalId, "permission-release"));
544
- }
545
525
  // Resuming a skill-bound goal is explicit re-entry into its workflow: rebind the owner the
546
526
  // goal was created with (released by a handback) before the continuation runs.
547
527
  const owner = goalWorkflowOwner(current);
@@ -579,11 +559,8 @@ export default function (pi: ExtensionAPI): void {
579
559
  if (!boundCommandReady(goal, ctx)) return;
580
560
  clearIdleContinuation();
581
561
  continuationQueuedFor = goal.goalId;
582
- const permissions = currentPermissionHold(ctx, goal.goalId);
583
- if (permissions.blockers.length === 0) resetContinuationState(goal);
584
- sendGoalContinuation(goal, permissions.blockers.length > 0
585
- ? `${continuationPrompt(goal, getWorkflow(ctx))}\n\n${permissionInstruction(permissions)}`
586
- : continuationPrompt(goal, getWorkflow(ctx)), "continuation");
562
+ resetContinuationState(goal);
563
+ sendGoalContinuation(goal, continuationPrompt(goal, getWorkflow(ctx)), "continuation");
587
564
  };
588
565
 
589
566
  function clearIdleContinuation(): void {
@@ -606,7 +583,6 @@ export default function (pi: ExtensionAPI): void {
606
583
  if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
607
584
  return;
608
585
  }
609
- if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
610
586
  if (currentContinuationState(ctx, goal.goalId)?.blocked) {
611
587
  return;
612
588
  }
@@ -637,7 +613,6 @@ export default function (pi: ExtensionAPI): void {
637
613
  if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
638
614
  const current = currentGoalSnapshot(ctx);
639
615
  return isPokeable(current) && current.goalId === goalId &&
640
- currentPermissionHold(ctx, goalId).blockers.length === 0 &&
641
616
  !currentContinuationState(ctx, goalId)?.blocked &&
642
617
  continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
643
618
  };
@@ -703,8 +678,7 @@ export default function (pi: ExtensionAPI): void {
703
678
  const goal = currentGoalSnapshot(ctx);
704
679
  const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
705
680
  backgroundDrainTracker = wakePlan.nextTracker;
706
- if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
707
- !currentContinuationState(ctx, goal.goalId)?.blocked) {
681
+ if (isPokeable(goal) && wakePlan.wakeSignature && !currentContinuationState(ctx, goal.goalId)?.blocked) {
708
682
  // Persist external progress now: a foreground/callback turn may cancel its delayed wake.
709
683
  clearIdleContinuation();
710
684
  resetContinuationState(goal);
@@ -878,7 +852,6 @@ export default function (pi: ExtensionAPI): void {
878
852
  if ((request.owner.goalId ?? null) !== (goal?.goalId ?? null)) { held("Owning Goal is no longer current"); return; }
879
853
  if (goal && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
880
854
  if (goal && !isPokeable(goal)) { held("Goal is not active"); return; }
881
- if (goal && currentPermissionHold(ctx, goal.goalId).blockers.length) { held("Goal has an active permission hold"); return; }
882
855
  if (goal && currentContinuationState(ctx, goal.goalId)?.blocked) { held("Goal continuation requires human resume"); return; }
883
856
  if (goal && !boundCommandReady(goal, ctx)) { held("Bound workflow is not ready"); return; }
884
857
  const workflow = getWorkflow(ctx);
@@ -889,7 +862,7 @@ export default function (pi: ExtensionAPI): void {
889
862
  const latest = getGoal(ctx);
890
863
  if ((request.owner!.goalId ?? null) !== (latest?.goalId ?? null)) { held("Owning Goal changed during resume admission"); return; }
891
864
  if (latest && !preferences.autoContinue) { held("Goal automatic continuation is disabled"); return; }
892
- if (latest && (!isPokeable(latest) || currentPermissionHold(ctx, latest.goalId).blockers.length || currentContinuationState(ctx, latest.goalId)?.blocked)) {
865
+ if (latest && (!isPokeable(latest) || currentContinuationState(ctx, latest.goalId)?.blocked)) {
893
866
  held("Goal became held during resume admission"); return;
894
867
  }
895
868
  if (!gate.ok) { held(gate.reason); return; }
@@ -903,7 +876,6 @@ export default function (pi: ExtensionAPI): void {
903
876
  if ((request.owner!.goalId ?? null) !== (active?.goalId ?? null)) return false;
904
877
  if (JSON.stringify(getWorkflow(ctx)) !== JSON.stringify(workflow)) return false;
905
878
  return !active || (preferences.autoContinue && isPokeable(active)
906
- && !currentPermissionHold(ctx, active.goalId).blockers.length
907
879
  && !currentContinuationState(ctx, active.goalId)?.blocked && boundCommandReady(active, ctx));
908
880
  };
909
881
  reply({ allowed: true, revision, isCurrent });
@@ -1263,10 +1235,6 @@ export default function (pi: ExtensionAPI): void {
1263
1235
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
1264
1236
  }
1265
1237
  const restoredGoal = getGoal(ctx);
1266
- if (restoredGoal?.status === "active" && currentPermissionHold(ctx, restoredGoal.goalId).blockers.length > 0) {
1267
- // A persisted release is not a replayable dispatch ticket after reload.
1268
- setGoal(goalWithStatus(restoredGoal, "paused", undefined, "permission-blocker"), ctx, "runtime");
1269
- }
1270
1238
  if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
1271
1239
  setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
1272
1240
  notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
@@ -1336,8 +1304,7 @@ export default function (pi: ExtensionAPI): void {
1336
1304
  // A paused goal stays paused while the user talks: the message is ordinary
1337
1305
  // conversation. Only /goal resume, the hotkey, or goal_resume resume it.
1338
1306
  const goal = getGoal(ctx);
1339
- if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
1340
- !currentContinuationState(ctx, goal.goalId)?.blocked) {
1307
+ if (goal?.status === "active" && !currentContinuationState(ctx, goal.goalId)?.blocked) {
1341
1308
  executionGeneration += 1;
1342
1309
  clearIdleContinuation();
1343
1310
  lastAgentEvidence = null;
@@ -1354,17 +1321,8 @@ export default function (pi: ExtensionAPI): void {
1354
1321
  const harnessPresent = requestTaskContext(pi, "inspect", goal).enabled !== undefined;
1355
1322
  const snapshot = await publishSnapshot(ctx);
1356
1323
  if (!snapshot) return;
1357
- const permissions = goal ? currentPermissionHold(ctx, goal.goalId) : null;
1358
- const permissionPrompt = permissions && goal?.status !== "complete" &&
1359
- (permissions.blockers.length > 0 || goal?.pauseReason === "permission-blocker")
1360
- ? permissionInstruction({ ...permissions, retryPending: permissions.retryPending && goal?.status === "active" }) +
1361
- (permissions.blockers.length === 0 ? "\nBlocker evidence is unavailable. Stay held; do not invent a retry scope." : "") : "";
1362
- // A held goal overrides workflow ownership without invoking or invalidating that workflow.
1363
- if (permissionPrompt && goal?.status === "paused") {
1364
- return publishGoalGuidance(event, permissionPrompt);
1365
- }
1366
- const pausedInstruction = permissionPrompt || (goal?.status === "paused" && (goal.pauseReason === "interrupt" || goal.pauseReason === "handback")
1367
- ? pausedGoalPrompt(goal, preferences.conversationalResume, !harnessPresent) : "");
1324
+ const pausedInstruction = goal?.status === "paused"
1325
+ ? pausedGoalPrompt(goal, preferences.conversationalResume, !harnessPresent) : "";
1368
1326
  if (!isPokeable(goal) && !owner) {
1369
1327
  return publishGoalGuidance(event, pausedInstruction);
1370
1328
  }
@@ -1392,9 +1350,7 @@ export default function (pi: ExtensionAPI): void {
1392
1350
  return publishGoalGuidance(
1393
1351
  event,
1394
1352
  pausedInstruction &&
1395
- `${pausedInstruction}\n` + (permissionPrompt
1396
- ? "These permission constraints override the workflow instructions below.\n\n"
1397
- : "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n"),
1353
+ `${pausedInstruction}\n` + "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n",
1398
1354
  `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}`,
1399
1355
  isPokeable(goal) && (harnessPresent
1400
1356
  ? "Complete the goal only after the workflow completion audit."
@@ -1416,13 +1372,11 @@ export default function (pi: ExtensionAPI): void {
1416
1372
  heldInstruction,
1417
1373
  backgroundInstruction.trim(),
1418
1374
  questionInstruction.trim(),
1419
- permissionPrompt,
1420
1375
  );
1421
1376
  }
1422
1377
  return publishGoalGuidance(
1423
1378
  event,
1424
1379
  `Pi Better Goal active objective: ${goal.objective}. ${heldInstruction || "Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit."}${backgroundInstruction}${questionInstruction}`,
1425
- permissionPrompt,
1426
1380
  );
1427
1381
  });
1428
1382
 
@@ -1472,31 +1426,22 @@ export default function (pi: ExtensionAPI): void {
1472
1426
  const incomplete = reportedPermissionGap(pi, event.toolName, event.details);
1473
1427
  if (reports.length === 0 && !incomplete) return;
1474
1428
  let hold = currentPermissionHold(ctx, goal.goalId);
1475
- let changed = false;
1476
1429
  for (const blocker of reports) {
1477
1430
  const previous = hold.blockers.find((item) => logicalPermissionBlockerKey(item) === logicalPermissionBlockerKey(blocker));
1478
- if (!hold.retryPending && previous && JSON.stringify(previous) === JSON.stringify(blocker)) continue;
1431
+ if (previous && JSON.stringify(previous) === JSON.stringify(blocker)) continue;
1479
1432
  if (hold.saturated || (!previous && hold.blockers.length >= MAX_PERMISSION_BLOCKERS)) {
1480
1433
  if (!hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
1481
- pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
1434
+ pi.appendEntry(EXTENSION_NAME, permissionGapRecord(goal.goalId));
1482
1435
  }
1483
1436
  break;
1484
1437
  }
1485
1438
  pi.appendEntry(EXTENSION_NAME, permissionHoldRecord(goal.goalId, blocker));
1486
- changed = true;
1487
1439
  hold = currentPermissionHold(ctx, goal.goalId);
1488
1440
  }
1489
1441
  if (incomplete && !hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
1490
- pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
1491
- changed = true;
1492
- hold = currentPermissionHold(ctx, goal.goalId);
1493
- }
1494
- if (hold.retryPending && hold.recordCount < MAX_PERMISSION_RECORDS) {
1495
- pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
1442
+ pi.appendEntry(EXTENSION_NAME, permissionGapRecord(goal.goalId));
1496
1443
  }
1497
- if (!changed && goal.status === "paused" && goal.pauseReason === "permission-blocker") return;
1498
- setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
1499
- notifyGoal(ctx, "Goal held for a permission blocker. /goal resume or alt+g releases one same-scope retry.", "warning");
1444
+ // Diagnostics only: the goal keeps running and continuation is unchanged.
1500
1445
  });
1501
1446
 
1502
1447
  pi.on("tool_execution_start", async (event, ctx) => {
@@ -1535,16 +1480,6 @@ export default function (pi: ExtensionAPI): void {
1535
1480
  const snapshot = await publishSnapshot(ctx);
1536
1481
  if (!snapshot) return;
1537
1482
  const goal = getGoal(ctx);
1538
- if (isPokeable(goal)) {
1539
- const permissions = currentPermissionHold(ctx, goal.goalId);
1540
- if (permissions.blockers.length > 0) {
1541
- if (permissions.retryPending && permissions.recordCount < MAX_PERMISSION_RECORDS) {
1542
- pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
1543
- }
1544
- setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
1545
- return;
1546
- }
1547
- }
1548
1483
  if (!isPokeable(goal) || snapshot.backgroundRunning) {
1549
1484
  return;
1550
1485
  }
@@ -4,6 +4,12 @@ import { dirname, join } from "node:path";
4
4
  import { isPermissionBlocker, permissionBlockerKey, type PermissionBlocker } from "./shared-permission-blocker.js";
5
5
  import { EXTENSION_NAME } from "./types.js";
6
6
 
7
+ /**
8
+ * Permission failure reports are diagnostics. This ledger records observed
9
+ * blockers so `/goal` and `get_goal` can show them; it never pauses a goal,
10
+ * limits resume, suppresses continuation/wakes/audits, or grants permission.
11
+ * Tool and sandbox enforcement remain the only authority.
12
+ */
7
13
  export const MAX_PERMISSION_BLOCKERS = 32;
8
14
  export const MAX_PERMISSION_RECORDS = 128;
9
15
 
@@ -13,7 +19,6 @@ export type PermissionRecord =
13
19
 
14
20
  export interface PermissionHold {
15
21
  blockers: PermissionBlocker[];
16
- retryPending: boolean;
17
22
  recordCount: number;
18
23
  saturated: boolean;
19
24
  }
@@ -26,10 +31,9 @@ export function logicalPermissionBlockerKey(blocker: PermissionBlocker): string
26
31
  });
27
32
  }
28
33
 
29
- /** Replay only compact goal-owned authority records, independently of progress resets. */
34
+ /** Replay the goal's compact diagnostic records, independently of progress resets. */
30
35
  export function currentPermissionHold(ctx: ExtensionContext, goalId: string): PermissionHold {
31
36
  const blockers = new Map<string, PermissionBlocker>();
32
- let retryPending = false;
33
37
  let recordCount = 0;
34
38
  let saturated = false;
35
39
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -41,7 +45,6 @@ export function currentPermissionHold(ctx: ExtensionContext, goalId: string): Pe
41
45
  if (data.version !== 1 || typeof data.at !== "number" || !Number.isFinite(data.at) ||
42
46
  (data.kind === "permission-hold" && !isPermissionBlocker(data.blocker))) {
43
47
  saturated = true;
44
- retryPending = false;
45
48
  recordCount += 1;
46
49
  continue;
47
50
  }
@@ -49,20 +52,15 @@ export function currentPermissionHold(ctx: ExtensionContext, goalId: string): Pe
49
52
  const key = logicalPermissionBlockerKey(data.blocker);
50
53
  if (blockers.has(key) || blockers.size < MAX_PERMISSION_BLOCKERS) blockers.set(key, compactBlocker(data.blocker));
51
54
  else saturated = true;
52
- retryPending = false;
53
- } else if (data.kind === "permission-release" && blockers.size > 0) {
54
- retryPending = !saturated;
55
- } else if (data.kind === "permission-retry-finished") {
56
- retryPending = false;
57
55
  } else if (data.kind === "permission-gap") {
58
56
  saturated = true;
59
- retryPending = false;
60
- } else continue;
57
+ }
58
+ // Legacy release records remain countable history but carry no authority.
61
59
  recordCount += 1;
62
- // Reserve room to record an incomplete scope and consume an outstanding release.
60
+ // Reserve room to record an incomplete scope.
63
61
  if (recordCount >= MAX_PERMISSION_RECORDS - 2) saturated = true;
64
62
  }
65
- return { blockers: [...blockers.values()], retryPending, recordCount, saturated };
63
+ return { blockers: [...blockers.values()], recordCount, saturated };
66
64
  }
67
65
 
68
66
  function compactBlocker(blocker: PermissionBlocker): PermissionBlocker {
@@ -130,21 +128,10 @@ export function reportedPermissionGap(pi: ExtensionAPI, toolName: string, detail
130
128
  return typeof omitted === "number" && Number.isSafeInteger(omitted) && omitted > 0;
131
129
  }
132
130
 
133
- export function permissionRecord(goalId: string, kind: "permission-release" | "permission-retry-finished" | "permission-gap"): PermissionRecord {
134
- return { version: 1, kind, goalId, at: Math.floor(Date.now() / 1000) };
131
+ export function permissionGapRecord(goalId: string): PermissionRecord {
132
+ return { version: 1, kind: "permission-gap", goalId, at: Math.floor(Date.now() / 1000) };
135
133
  }
136
134
 
137
135
  export function permissionHoldRecord(goalId: string, blocker: PermissionBlocker): PermissionRecord {
138
136
  return { version: 1, kind: "permission-hold", goalId, blocker: compactBlocker(blocker), at: Math.floor(Date.now() / 1000) };
139
137
  }
140
-
141
- export function permissionInstruction(hold: PermissionHold): string {
142
- return [
143
- hold.retryPending
144
- ? "The human explicitly released ONE bounded retry of the held operation and scope below. This is not a permission grant or evidence of success."
145
- : "The goal is paused for a permission blocker. Answer ordinary questions, but do not advance goal or workflow work. Only /goal resume or alt+g releases one bounded retry; goal_resume cannot release it.",
146
- ...hold.blockers.map((blocker) => `- ${blocker.context}: ${blocker.resource}; operation=${blocker.operation}; basis=${blocker.basis}; remoteOutcome=${blocker.remoteOutcome}` +
147
- (blocker.runId ? `; runId=${blocker.runId}` : "") + (blocker.policySnapshotId ? `; policySnapshotId=${blocker.policySnapshotId}` : "")),
148
- "Keep the same operation and scope. Do not switch to foreground execution, copy credentials, change authentication, or broaden permissions. Changed worker settings require a fresh worker; retain the logical operation identity. Runtime-observed refusals and agent-reported blockers are distinct. Unknown remote outcome is never evidence of success.",
149
- ].join("\n");
150
- }
@@ -71,12 +71,11 @@ export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
71
71
  * resume a goal on their own. An `interrupt` pause may be resumed by the agent
72
72
  * with `goal_resume` when the user clearly says to proceed. A paused goal
73
73
  * without a reason (`/goal pause`, an unavailable command or workflow) resumes
74
- * only through `/goal resume` or the resume hotkey. `permission-blocker` also
75
- * requires those human controls, and releases just one same-scope retry while
76
- * keeping its separate blocker/release history.
74
+ * only through `/goal resume` or the resume hotkey. Permission failure reports
75
+ * are diagnostics and never create a pause reason.
77
76
  */
78
- /** Why a goal paused: escape, a permission hold, or a workflow handing back unfinished work for the user's answer. */
79
- export type GoalPauseReason = "interrupt" | "permission-blocker" | "handback";
77
+ /** Why a goal paused: escape, or a workflow handing back unfinished work for the user's answer. */
78
+ export type GoalPauseReason = "interrupt" | "handback";
80
79
 
81
80
  export interface GoalUsage {
82
81
  tokensUsed: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.41.1",
3
+ "version": "0.41.2",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, model routing, and Outcome & Todo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "pi-better-background-tasks": "0.14.2",
61
- "pi-better-goal": "0.12.2",
61
+ "pi-better-goal": "0.12.3",
62
62
  "pi-better-model-router": "0.1.4",
63
63
  "pi-ollama-cloud": "0.13.1",
64
64
  "pi-better-sandbox": "0.14.1",