pi-better-goal 0.7.0 → 0.8.1

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,8 +13,8 @@ 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
- - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. Both controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool. Goal kickoff, `/goal resume`, and `alt+g` remain available with either control off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off` and `/goal settings conversational-resume on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
- - `escape` pauses the active goal, and it stays paused while you talk to the agent. With conversational resume enabled, 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 the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
16
+ - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. All three controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool; **Pause on Esc** controls whether editor Escape pauses the active goal. Goal kickoff, `/goal resume`, and `alt+g` remain available with these controls off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off`, `/goal settings conversational-resume on|off`, and `/goal settings pause-on-escape on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
+ - `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, 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 the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
18
18
  - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
19
19
  - A compact goal widget that does not replace Pi's footer.
20
20
  - Background activity tracking for subagents and other registered providers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,6 +53,10 @@ function resultContent(content: unknown): unknown[] {
53
53
  export function continuationEvidence(messages: readonly unknown[]): ContinuationEvidence {
54
54
  const records: unknown[] = [];
55
55
  const toolNames: string[] = [];
56
+ const hasActions = messages.some((candidate) => isRecord(candidate) && (
57
+ candidate.role === "toolResult" || (candidate.role === "assistant" && Array.isArray(candidate.content) &&
58
+ candidate.content.some((block) => isRecord(block) && block.type === "toolCall"))
59
+ ));
56
60
 
57
61
  for (const candidate of messages) {
58
62
  if (!isRecord(candidate)) {
@@ -67,7 +71,7 @@ export function continuationEvidence(messages: readonly unknown[]): Continuation
67
71
  toolNames.push(block.name);
68
72
  return [{ name: block.name, arguments: block.arguments }];
69
73
  });
70
- const text = message.content.flatMap((block) =>
74
+ const text = hasActions ? [] : message.content.flatMap((block) =>
71
75
  isRecord(block) && block.type === "text" ? [{ text: block.text }] : [],
72
76
  );
73
77
  if (actions.length > 0 || text.length > 0) {
package/src/goal-state.ts CHANGED
@@ -66,7 +66,7 @@ function isGoalStatus(value: unknown): value is GoalStatus {
66
66
  }
67
67
 
68
68
  function isPauseReason(value: unknown): value is GoalPauseReason {
69
- return value === "interrupt";
69
+ return value === "interrupt" || value === "permission-blocker";
70
70
  }
71
71
 
72
72
  function isGoalLike(value: unknown): value is GoalLike {
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { CustomEditor, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { Type } from "typebox";
5
6
  import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
@@ -27,6 +28,7 @@ import {
27
28
  type GoalEntrySource,
28
29
  } from "./goal-state.js";
29
30
  import { continuationEvidence, type ContinuationEvidence } from "./continuation.js";
31
+ import { currentPermissionHold, logicalPermissionBlockerKey, MAX_PERMISSION_BLOCKERS, MAX_PERMISSION_RECORDS, permissionHoldRecord, permissionInstruction, permissionRecord, reportedPermissionBlockers, reportedPermissionGap, type PermissionHold } from "./permission-hold.js";
30
32
  import { observeGoalStall } from "./stall.js";
31
33
  import {
32
34
  flattenObjectiveForRail,
@@ -90,6 +92,7 @@ export function agentResumable(goal: GoalSnapshot | null, conversationalResume =
90
92
 
91
93
  /** Footer status for a paused goal, telling the user how to resume it. */
92
94
  export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
95
+ if (goal.pauseReason === "permission-blocker") return "goal held: permission blocker · /goal resume (one retry)";
93
96
  return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
94
97
  }
95
98
 
@@ -108,10 +111,10 @@ const GOAL_ACTIONS: readonly AutocompleteItem[] = [
108
111
  { value: "resume", label: "resume", description: "Resume the paused goal" },
109
112
  { value: "clear", label: "clear", description: "Remove the current goal" },
110
113
  { value: "complete", label: "complete", description: "Mark the current goal complete" },
111
- { value: "settings", label: "settings", description: "Inspect or persist goal continuation and conversational resume controls" },
114
+ { value: "settings", label: "settings", description: "Inspect or persist goal continuation, conversational resume, and Escape pause controls" },
112
115
  ];
113
116
 
114
- const GOAL_SETTINGS = ["auto-continue", "conversational-resume"] as const;
117
+ const GOAL_SETTINGS = ["auto-continue", "conversational-resume", "pause-on-escape"] as const;
115
118
 
116
119
  export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
117
120
  const prefix = argumentPrefix.trimStart().toLowerCase();
@@ -227,7 +230,8 @@ function formatGoal(
227
230
  goal: GoalSnapshot | null,
228
231
  continuation: ReturnType<typeof currentContinuationState> = null,
229
232
  stall = observeGoalStall(goal, continuation),
230
- preferences: GoalPreferences = { autoContinue: true, conversationalResume: true },
233
+ preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true },
234
+ permissions?: PermissionHold | null,
231
235
  ): string {
232
236
  const settings = formatPreferences(preferences);
233
237
  if (!goal) {
@@ -241,6 +245,11 @@ function formatGoal(
241
245
  return [
242
246
  `Goal: ${goal.objective}`,
243
247
  `Status: ${goal.status}`,
248
+ ...(goal.pauseReason ? [`Pause reason: ${goal.pauseReason}`] : []),
249
+ ...(permissions?.blockers.length ? [
250
+ `Permission hold: ${permissions.blockers.length} scope(s); ${permissions.recordCount} history records; retry ${permissions.retryPending ? "released once" : "held"}`,
251
+ ...(permissions.saturated ? ["Permission observation incomplete: history/scope limit reached; release refused."] : []),
252
+ ] : []),
244
253
  `Token budget: ${budget}`,
245
254
  `Tokens used: ${goal.usage.tokensUsed}`,
246
255
  `Active time: ${timing.activeSeconds}s`,
@@ -260,6 +269,7 @@ function formatPreferences(preferences: GoalPreferences): string {
260
269
  return [
261
270
  `Automatic continuation setting: ${preferences.autoContinue ? "on" : "off"}${WAKE_DISABLED ? " (disabled by environment)" : ""}`,
262
271
  `Conversational resume setting: ${preferences.conversationalResume ? "on" : "off"}`,
272
+ `Pause on Esc setting: ${preferences.pauseOnEscape ? "on" : "off"}`,
263
273
  ].join("\n");
264
274
  }
265
275
 
@@ -291,6 +301,8 @@ export default function (pi: ExtensionAPI): void {
291
301
  let currentCtx: ExtensionContext | undefined;
292
302
  let executionGeneration = 0;
293
303
  let agentGeneration = -1;
304
+ let turnGeneration = 0;
305
+ let escapeAbortSuppression: { session: number; turn: number; execution: number } | undefined;
294
306
  let sessionGeneration = 0;
295
307
  let wakeGeneration = 0;
296
308
  let snapshotSequence = 0;
@@ -306,8 +318,11 @@ export default function (pi: ExtensionAPI): void {
306
318
  let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
307
319
  let idleContinuationSignature = "";
308
320
  let refreshGoalWidget: ((force?: boolean) => void) | undefined;
321
+ let terminalInputUnsubscribe: (() => void) | undefined;
322
+ let terminalInputIsEditor: (() => boolean) | undefined;
323
+ let uiPromptDepth = 0;
309
324
  let lastAgentEvidence: ContinuationEvidence | null = null;
310
- let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true };
325
+ let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
311
326
  /** Background items active when each pending blocking question started, keyed by tool call id. */
312
327
  const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
313
328
 
@@ -368,6 +383,13 @@ export default function (pi: ExtensionAPI): void {
368
383
  };
369
384
 
370
385
  const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
386
+ const permissionHold = currentPermissionHold(ctx, goal.goalId);
387
+ if (goal.status === "paused" && permissionHold.blockers.length > 0) {
388
+ if (permissionHold.retryPending && permissionHold.recordCount < MAX_PERMISSION_RECORDS) {
389
+ pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
390
+ }
391
+ goal = { ...goal, pauseReason: "permission-blocker" };
392
+ }
371
393
  executionGeneration += 1;
372
394
  lastAgentEvidence = null;
373
395
  const previous = getGoal(ctx);
@@ -411,11 +433,18 @@ export default function (pi: ExtensionAPI): void {
411
433
  return;
412
434
  }
413
435
  setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
414
- notifyGoal(ctx, preferences.conversationalResume
436
+ notifyGoal(ctx, currentPermissionHold(ctx, goal.goalId).blockers.length > 0
437
+ ? "Goal remains permission-held. Use /goal resume or alt+g for one same-scope retry."
438
+ : preferences.conversationalResume
415
439
  ? 'Goal paused. Say "go" to resume it, or use /goal resume.'
416
440
  : "Goal paused. Use /goal resume or alt+g to resume it.");
417
441
  };
418
442
 
443
+ const escapeAbortIsSuppressed = (): boolean =>
444
+ escapeAbortSuppression?.session === sessionGeneration &&
445
+ escapeAbortSuppression.turn === turnGeneration &&
446
+ escapeAbortSuppression.execution === executionGeneration;
447
+
419
448
  /** Why a paused goal cannot become active again, or null when it can. */
420
449
  const resumeBlocker = (goal: GoalSnapshot): string | null => {
421
450
  if (!goal.command && skillCommandName(goal.objective)) {
@@ -445,6 +474,18 @@ export default function (pi: ExtensionAPI): void {
445
474
  return { ok: false, message: blocker };
446
475
  }
447
476
  const goal = held ? current : goalWithStatus(current, "active");
477
+ const permissions = currentPermissionHold(ctx, current.goalId);
478
+ if (current.pauseReason === "permission-blocker" && permissions.blockers.length === 0) {
479
+ return { ok: false, message: "Permission hold evidence is unavailable. The goal stays held; inspect the report before replacing this goal explicitly." };
480
+ }
481
+ if (permissions.blockers.length > 0) {
482
+ if (source === "tool" || permissions.saturated) {
483
+ return { ok: false, message: permissions.saturated
484
+ ? "Permission hold history is full. The goal stays held; inspect it and create a new goal explicitly if needed."
485
+ : "Only /goal resume or alt+g can release one permission retry." };
486
+ }
487
+ pi.appendEntry(EXTENSION_NAME, permissionRecord(current.goalId, "permission-release"));
488
+ }
448
489
  setGoal(goal, ctx, source);
449
490
  queueGoalContinuation(goal, ctx);
450
491
  return { ok: true, goal };
@@ -477,8 +518,11 @@ export default function (pi: ExtensionAPI): void {
477
518
  if (!boundCommandReady(goal, ctx)) return;
478
519
  clearIdleContinuation();
479
520
  continuationQueuedFor = goal.goalId;
480
- resetContinuationState(goal);
481
- sendGoalContinuation(goal, continuationPrompt(goal, getWorkflow(ctx)), "continuation");
521
+ const permissions = currentPermissionHold(ctx, goal.goalId);
522
+ if (permissions.blockers.length === 0) resetContinuationState(goal);
523
+ sendGoalContinuation(goal, permissions.blockers.length > 0
524
+ ? `${continuationPrompt(goal, getWorkflow(ctx))}\n\n${permissionInstruction(permissions)}`
525
+ : continuationPrompt(goal, getWorkflow(ctx)), "continuation");
482
526
  };
483
527
 
484
528
  function clearIdleContinuation(): void {
@@ -499,6 +543,7 @@ export default function (pi: ExtensionAPI): void {
499
543
  if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
500
544
  return;
501
545
  }
546
+ if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
502
547
  if (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
503
548
  return;
504
549
  }
@@ -529,6 +574,7 @@ export default function (pi: ExtensionAPI): void {
529
574
  if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
530
575
  const current = currentGoalSnapshot(ctx);
531
576
  return isPokeable(current) && current.goalId === goalId &&
577
+ currentPermissionHold(ctx, goalId).blockers.length === 0 &&
532
578
  continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
533
579
  };
534
580
  const goal = currentGoalSnapshot(ctx);
@@ -591,7 +637,7 @@ export default function (pi: ExtensionAPI): void {
591
637
  const goal = currentGoalSnapshot(ctx);
592
638
  const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
593
639
  backgroundDrainTracker = wakePlan.nextTracker;
594
- if (isPokeable(goal) && wakePlan.wakeSignature) {
640
+ if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0) {
595
641
  // Persist external progress now: a foreground/callback turn may cancel its delayed wake.
596
642
  clearIdleContinuation();
597
643
  resetContinuationState(goal);
@@ -723,6 +769,16 @@ export default function (pi: ExtensionAPI): void {
723
769
  ctx.ui.setWidget(
724
770
  EXTENSION_NAME,
725
771
  (tui, theme) => {
772
+ const inputIsEditor = (): boolean => {
773
+ const focus = tui as typeof tui & { getFocusedComponent?(): Partial<CustomEditor> | null };
774
+ if (typeof focus.getFocusedComponent !== "function" || tui.hasOverlay()) return false;
775
+ // Duck typing survives Pi's bundled/jiti module boundaries. Unknown
776
+ // custom editors and dialog components keep ownership of Escape.
777
+ const editor = focus.getFocusedComponent();
778
+ return typeof editor?.onEscape === "function" &&
779
+ typeof editor.isShowingAutocomplete === "function" && !editor.isShowingAutocomplete();
780
+ };
781
+ terminalInputIsEditor = inputIsEditor;
726
782
  const requestRender = (force = false): void => {
727
783
  try {
728
784
  tui.requestRender(force);
@@ -746,6 +802,7 @@ export default function (pi: ExtensionAPI): void {
746
802
  return {
747
803
  dispose() {
748
804
  scheduler.dispose();
805
+ if (terminalInputIsEditor === inputIsEditor) terminalInputIsEditor = undefined;
749
806
  if (refreshGoalWidget === refresh) {
750
807
  refreshGoalWidget = undefined;
751
808
  }
@@ -818,12 +875,14 @@ export default function (pi: ExtensionAPI): void {
818
875
  const [, setting, mode, ...extra] = trimmed.split(/\s+/);
819
876
  if (setting !== undefined && (!GOAL_SETTINGS.includes(setting as typeof GOAL_SETTINGS[number]) ||
820
877
  (mode !== "on" && mode !== "off") || extra.length > 0)) {
821
- notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume on|off]", "warning");
878
+ notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume|pause-on-escape on|off]", "warning");
822
879
  return;
823
880
  }
824
881
  if (setting !== undefined) {
825
882
  try {
826
- await changePreference(setting === "auto-continue" ? "autoContinue" : "conversationalResume", mode === "on");
883
+ const key = setting === "auto-continue" ? "autoContinue"
884
+ : setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
885
+ await changePreference(key, mode === "on");
827
886
  } catch (error) {
828
887
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
829
888
  return;
@@ -836,7 +895,8 @@ export default function (pi: ExtensionAPI): void {
836
895
  if (!trimmed) {
837
896
  const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
838
897
  // Inspection is always explicit user intent; show the full summary even while busy.
839
- ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences), "info");
898
+ ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences,
899
+ current ? currentPermissionHold(ctx, current.goalId) : null), "info");
840
900
  return;
841
901
  }
842
902
 
@@ -913,8 +973,8 @@ export default function (pi: ExtensionAPI): void {
913
973
  backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
914
974
  });
915
975
  return {
916
- content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences) }],
917
- details: { goal, continuation, stall, preferences: { ...preferences }, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
976
+ content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences, goal ? currentPermissionHold(ctx, goal.goalId) : null) }],
977
+ details: { goal, continuation, permissionHold: goal ? currentPermissionHold(ctx, goal.goalId) : null, stall, preferences: { ...preferences }, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
918
978
  };
919
979
  },
920
980
  });
@@ -1058,8 +1118,13 @@ export default function (pi: ExtensionAPI): void {
1058
1118
  });
1059
1119
 
1060
1120
  pi.on("session_start", async (_event, ctx) => {
1121
+ terminalInputUnsubscribe?.();
1122
+ terminalInputUnsubscribe = undefined;
1123
+ terminalInputIsEditor = undefined;
1124
+ uiPromptDepth = 0;
1061
1125
  executionGeneration += 1;
1062
1126
  sessionGeneration += 1;
1127
+ escapeAbortSuppression = undefined;
1063
1128
  clearIdleContinuation();
1064
1129
  continuationQueuedFor = null;
1065
1130
  backgroundDrainTracker = null;
@@ -1074,6 +1139,10 @@ export default function (pi: ExtensionAPI): void {
1074
1139
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
1075
1140
  }
1076
1141
  const restoredGoal = getGoal(ctx);
1142
+ if (restoredGoal?.status === "active" && currentPermissionHold(ctx, restoredGoal.goalId).blockers.length > 0) {
1143
+ // A persisted release is not a replayable dispatch ticket after reload.
1144
+ setGoal(goalWithStatus(restoredGoal, "paused", undefined, "permission-blocker"), ctx, "runtime");
1145
+ }
1077
1146
  if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
1078
1147
  setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
1079
1148
  notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
@@ -1081,6 +1150,23 @@ export default function (pi: ExtensionAPI): void {
1081
1150
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
1082
1151
  syncResumeTool(getGoal(ctx));
1083
1152
  installGoalWidget(ctx);
1153
+ if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.onTerminalInput === "function") {
1154
+ const session = sessionGeneration;
1155
+ terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
1156
+ if (session === sessionGeneration && uiPromptDepth === 0 &&
1157
+ !isKeyRelease(data) && matchesKey(data, "escape") && terminalInputIsEditor?.()) {
1158
+ if (preferences.pauseOnEscape) {
1159
+ pauseGoalOnInterrupt(ctx);
1160
+ } else if (foregroundRunning) {
1161
+ // The original key reaches Pi next and may abort this run. Preserve
1162
+ // this keypress's choice through both abort notifications only.
1163
+ escapeAbortSuppression = { session, turn: turnGeneration, execution: executionGeneration };
1164
+ }
1165
+ }
1166
+ // Observe only: Pi must still interrupt streams or handle editor Escape.
1167
+ return undefined;
1168
+ });
1169
+ }
1084
1170
  const snapshot = await publishSnapshot(ctx);
1085
1171
  if (!snapshot) return;
1086
1172
  syncPollingState();
@@ -1089,6 +1175,9 @@ export default function (pi: ExtensionAPI): void {
1089
1175
  }
1090
1176
  });
1091
1177
 
1178
+ pi.on("ui_prompt_start", () => { uiPromptDepth += 1; });
1179
+ pi.on("ui_prompt_end", () => { uiPromptDepth = Math.max(0, uiPromptDepth - 1); });
1180
+
1092
1181
  pi.on("input", async (event, ctx) => {
1093
1182
  if (event.source === "extension") {
1094
1183
  return;
@@ -1109,7 +1198,7 @@ export default function (pi: ExtensionAPI): void {
1109
1198
  // A paused goal stays paused while the user talks: the message is ordinary
1110
1199
  // conversation. Only /goal resume, the hotkey, or goal_resume resume it.
1111
1200
  const goal = getGoal(ctx);
1112
- if (goal?.status === "active") {
1201
+ if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0) {
1113
1202
  executionGeneration += 1;
1114
1203
  clearIdleContinuation();
1115
1204
  lastAgentEvidence = null;
@@ -1123,8 +1212,17 @@ export default function (pi: ExtensionAPI): void {
1123
1212
  const owner = getWorkflow(ctx);
1124
1213
  const snapshot = await publishSnapshot(ctx);
1125
1214
  if (!snapshot) return;
1126
- const pausedInstruction = goal?.status === "paused" && goal.pauseReason === "interrupt"
1127
- ? pausedGoalPrompt(goal, preferences.conversationalResume) : "";
1215
+ const permissions = goal ? currentPermissionHold(ctx, goal.goalId) : null;
1216
+ const permissionPrompt = permissions && goal?.status !== "complete" &&
1217
+ (permissions.blockers.length > 0 || goal?.pauseReason === "permission-blocker")
1218
+ ? permissionInstruction({ ...permissions, retryPending: permissions.retryPending && goal?.status === "active" }) +
1219
+ (permissions.blockers.length === 0 ? "\nBlocker evidence is unavailable. Stay held; do not invent a retry scope." : "") : "";
1220
+ // A held goal overrides workflow ownership without invoking or invalidating that workflow.
1221
+ if (permissionPrompt && goal?.status === "paused") {
1222
+ return { systemPrompt: `${event.systemPrompt}\n\n${permissionPrompt}` };
1223
+ }
1224
+ const pausedInstruction = permissionPrompt || (goal?.status === "paused" && goal.pauseReason === "interrupt"
1225
+ ? pausedGoalPrompt(goal, preferences.conversationalResume) : "");
1128
1226
  if (!isPokeable(goal) && !owner) {
1129
1227
  return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
1130
1228
  }
@@ -1149,7 +1247,9 @@ export default function (pi: ExtensionAPI): void {
1149
1247
  return {
1150
1248
  systemPrompt: `${event.systemPrompt}\n\n` +
1151
1249
  (pausedInstruction
1152
- ? `${pausedInstruction}\nWhile the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n`
1250
+ ? `${pausedInstruction}\n` + (permissionPrompt
1251
+ ? "These permission constraints override the workflow instructions below.\n\n"
1252
+ : "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n")
1153
1253
  : "") +
1154
1254
  `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}` +
1155
1255
  (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
@@ -1164,12 +1264,15 @@ export default function (pi: ExtensionAPI): void {
1164
1264
  return {
1165
1265
  systemPrompt:
1166
1266
  `${event.systemPrompt}\n\n` +
1167
- `Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit.${backgroundInstruction}${questionInstruction}`,
1267
+ `Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit.${backgroundInstruction}${questionInstruction}` +
1268
+ (permissionPrompt ? `\n\n${permissionPrompt}` : ""),
1168
1269
  };
1169
1270
  });
1170
1271
 
1171
1272
  pi.on("agent_start", async (_event, ctx) => {
1172
1273
  executionGeneration += 1;
1274
+ turnGeneration += 1;
1275
+ escapeAbortSuppression = undefined;
1173
1276
  agentGeneration = executionGeneration;
1174
1277
  currentCtx = ctx;
1175
1278
  foregroundRunning = true;
@@ -1178,10 +1281,16 @@ export default function (pi: ExtensionAPI): void {
1178
1281
  lastAgentEvidence = null;
1179
1282
  const turnSignal = ctx.signal;
1180
1283
  if (turnSignal) {
1284
+ const session = sessionGeneration;
1285
+ const turn = turnGeneration;
1286
+ const onAbort = (): void => {
1287
+ if (session !== sessionGeneration || turn !== turnGeneration) return;
1288
+ if (!escapeAbortIsSuppressed()) pauseGoalOnInterrupt(ctx);
1289
+ };
1181
1290
  if (turnSignal.aborted) {
1182
- pauseGoalOnInterrupt(ctx);
1291
+ onAbort();
1183
1292
  } else {
1184
- turnSignal.addEventListener("abort", () => pauseGoalOnInterrupt(ctx), { once: true });
1293
+ turnSignal.addEventListener("abort", onAbort, { once: true });
1185
1294
  }
1186
1295
  }
1187
1296
  await publishSnapshot(ctx);
@@ -1189,13 +1298,46 @@ export default function (pi: ExtensionAPI): void {
1189
1298
 
1190
1299
  pi.on("agent_end", async (event, ctx) => {
1191
1300
  lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
1192
- // `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
1193
- // cannot register it. Observe the interrupt instead: when a running turn is
1194
- // aborted (escape / ctrl+c while streaming), pause the active goal so it does
1195
- // not auto-continue after the user stopped the agent.
1196
- if (wasTurnAborted(event.messages)) {
1301
+ // Only an observed editor Escape with pause disabled exempts this abort.
1302
+ // Unknown interrupt sources retain the safety fallback on every host.
1303
+ if (wasTurnAborted(event.messages) && !escapeAbortIsSuppressed()) {
1197
1304
  pauseGoalOnInterrupt(ctx);
1198
1305
  }
1306
+ escapeAbortSuppression = undefined;
1307
+ });
1308
+
1309
+ pi.on("tool_result", (event, ctx) => {
1310
+ const goal = getGoal(ctx);
1311
+ if (!goal || (goal.status !== "active" && goal.status !== "paused")) return;
1312
+ const reports = reportedPermissionBlockers(pi, event.toolName, event.details);
1313
+ const incomplete = reportedPermissionGap(pi, event.toolName, event.details);
1314
+ if (reports.length === 0 && !incomplete) return;
1315
+ let hold = currentPermissionHold(ctx, goal.goalId);
1316
+ let changed = false;
1317
+ for (const blocker of reports) {
1318
+ const previous = hold.blockers.find((item) => logicalPermissionBlockerKey(item) === logicalPermissionBlockerKey(blocker));
1319
+ if (!hold.retryPending && previous && JSON.stringify(previous) === JSON.stringify(blocker)) continue;
1320
+ if (hold.saturated || (!previous && hold.blockers.length >= MAX_PERMISSION_BLOCKERS)) {
1321
+ if (!hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
1322
+ pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
1323
+ }
1324
+ break;
1325
+ }
1326
+ pi.appendEntry(EXTENSION_NAME, permissionHoldRecord(goal.goalId, blocker));
1327
+ changed = true;
1328
+ hold = currentPermissionHold(ctx, goal.goalId);
1329
+ }
1330
+ if (incomplete && !hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
1331
+ pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
1332
+ changed = true;
1333
+ hold = currentPermissionHold(ctx, goal.goalId);
1334
+ }
1335
+ if (hold.retryPending && hold.recordCount < MAX_PERMISSION_RECORDS) {
1336
+ pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
1337
+ }
1338
+ if (!changed && goal.status === "paused" && goal.pauseReason === "permission-blocker") return;
1339
+ setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
1340
+ notifyGoal(ctx, "Goal held for a permission blocker. /goal resume or alt+g releases one same-scope retry.", "warning");
1199
1341
  });
1200
1342
 
1201
1343
  pi.on("tool_execution_start", async (event, ctx) => {
@@ -1228,11 +1370,22 @@ export default function (pi: ExtensionAPI): void {
1228
1370
 
1229
1371
  pi.on("agent_settled", async (_event, ctx) => {
1230
1372
  executionGeneration += 1;
1373
+ escapeAbortSuppression = undefined;
1231
1374
  currentCtx = ctx;
1232
1375
  foregroundRunning = false;
1233
1376
  const snapshot = await publishSnapshot(ctx);
1234
1377
  if (!snapshot) return;
1235
1378
  const goal = getGoal(ctx);
1379
+ if (isPokeable(goal)) {
1380
+ const permissions = currentPermissionHold(ctx, goal.goalId);
1381
+ if (permissions.blockers.length > 0) {
1382
+ if (permissions.retryPending && permissions.recordCount < MAX_PERMISSION_RECORDS) {
1383
+ pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
1384
+ }
1385
+ setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
1386
+ return;
1387
+ }
1388
+ }
1236
1389
  if (!isPokeable(goal) || snapshot.backgroundRunning) {
1237
1390
  return;
1238
1391
  }
@@ -1274,6 +1427,11 @@ export default function (pi: ExtensionAPI): void {
1274
1427
  pi.on("session_shutdown", async (_event, ctx) => {
1275
1428
  executionGeneration += 1;
1276
1429
  sessionGeneration += 1;
1430
+ escapeAbortSuppression = undefined;
1431
+ terminalInputUnsubscribe?.();
1432
+ terminalInputUnsubscribe = undefined;
1433
+ terminalInputIsEditor = undefined;
1434
+ uiPromptDepth = 0;
1277
1435
  stopPolling();
1278
1436
  foregroundRunning = false;
1279
1437
  pendingQuestions.clear();
@@ -0,0 +1,150 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { isPermissionBlocker, permissionBlockerKey, type PermissionBlocker } from "./shared-permission-blocker.js";
5
+ import { EXTENSION_NAME } from "./types.js";
6
+
7
+ export const MAX_PERMISSION_BLOCKERS = 32;
8
+ export const MAX_PERMISSION_RECORDS = 128;
9
+
10
+ export type PermissionRecord =
11
+ | { version: 1; kind: "permission-hold"; goalId: string; blocker: PermissionBlocker; at: number }
12
+ | { version: 1; kind: "permission-release" | "permission-retry-finished" | "permission-gap"; goalId: string; at: number };
13
+
14
+ export interface PermissionHold {
15
+ blockers: PermissionBlocker[];
16
+ retryPending: boolean;
17
+ recordCount: number;
18
+ saturated: boolean;
19
+ }
20
+
21
+ /** Worker/incident references describe evidence, not authority for a different retry scope. */
22
+ export function logicalPermissionBlockerKey(blocker: PermissionBlocker): string {
23
+ return permissionBlockerKey({
24
+ version: 1, kind: "permission-blocker", context: blocker.context, resource: blocker.resource,
25
+ basis: blocker.basis, operation: blocker.operation, remoteOutcome: "unknown",
26
+ });
27
+ }
28
+
29
+ /** Replay only compact goal-owned authority records, independently of progress resets. */
30
+ export function currentPermissionHold(ctx: ExtensionContext, goalId: string): PermissionHold {
31
+ const blockers = new Map<string, PermissionBlocker>();
32
+ let retryPending = false;
33
+ let recordCount = 0;
34
+ let saturated = false;
35
+ for (const entry of ctx.sessionManager.getBranch()) {
36
+ if (entry.type !== "custom" || entry.customType !== EXTENSION_NAME || !entry.data || typeof entry.data !== "object") continue;
37
+ const data = entry.data as Partial<PermissionRecord>;
38
+ // Only attributable authority records can make this goal's retry scope incomplete.
39
+ if (data.goalId !== goalId || (data.kind !== "permission-hold" && data.kind !== "permission-release" &&
40
+ data.kind !== "permission-retry-finished" && data.kind !== "permission-gap")) continue;
41
+ if (data.version !== 1 || typeof data.at !== "number" || !Number.isFinite(data.at) ||
42
+ (data.kind === "permission-hold" && !isPermissionBlocker(data.blocker))) {
43
+ saturated = true;
44
+ retryPending = false;
45
+ recordCount += 1;
46
+ continue;
47
+ }
48
+ if (data.kind === "permission-hold" && isPermissionBlocker(data.blocker)) {
49
+ const key = logicalPermissionBlockerKey(data.blocker);
50
+ if (blockers.has(key) || blockers.size < MAX_PERMISSION_BLOCKERS) blockers.set(key, compactBlocker(data.blocker));
51
+ 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
+ } else if (data.kind === "permission-gap") {
58
+ saturated = true;
59
+ retryPending = false;
60
+ } else continue;
61
+ recordCount += 1;
62
+ // Reserve room to record an incomplete scope and consume an outstanding release.
63
+ if (recordCount >= MAX_PERMISSION_RECORDS - 2) saturated = true;
64
+ }
65
+ return { blockers: [...blockers.values()], retryPending, recordCount, saturated };
66
+ }
67
+
68
+ function compactBlocker(blocker: PermissionBlocker): PermissionBlocker {
69
+ return {
70
+ version: 1, kind: "permission-blocker", context: blocker.context,
71
+ resource: blocker.resource, basis: blocker.basis, operation: blocker.operation, remoteOutcome: blocker.remoteOutcome,
72
+ ...(blocker.incidentId ? { incidentId: blocker.incidentId } : {}),
73
+ ...(blocker.runId ? { runId: blocker.runId } : {}),
74
+ ...(blocker.policySnapshotId ? { policySnapshotId: blocker.policySnapshotId } : {}),
75
+ };
76
+ }
77
+
78
+ function packageName(root: string): unknown {
79
+ try {
80
+ return (JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: unknown }).name;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ function isSubagentEntry(path: string): boolean {
87
+ const root = dirname(path);
88
+ const name = packageName(root);
89
+ return (name === "pi-better-subagents" || name === "@vanillagreen/pi-better-subagents") &&
90
+ path === realpathSync(join(root, "index.ts"));
91
+ }
92
+
93
+ /** Trust the registered parent producer's canonical entry, never a tool name alone. */
94
+ export function isParentBlockerProducer(pi: ExtensionAPI, toolName: string): boolean {
95
+ if (toolName !== "subagent_result" && toolName !== "subagent_output") return false;
96
+ try {
97
+ const source = pi.getAllTools().find((tool) => tool.name === toolName)?.sourceInfo.path;
98
+ if (!source) return false;
99
+ const path = realpathSync(source);
100
+ if (isSubagentEntry(path)) return true;
101
+ const root = dirname(dirname(dirname(path)));
102
+ if (packageName(root) !== "pi-better-harness" || path !== join(root, "extensions/subagents/index.ts")) return false;
103
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
104
+ pi?: { extensions?: unknown }; dependencies?: Record<string, unknown>; bundledDependencies?: unknown;
105
+ };
106
+ if (!Array.isArray(manifest.pi?.extensions) || !manifest.pi.extensions.includes("extensions/subagents/index.ts") ||
107
+ typeof manifest.dependencies?.["pi-better-subagents"] !== "string" ||
108
+ !Array.isArray(manifest.bundledDependencies) || !manifest.bundledDependencies.includes("pi-better-subagents")) return false;
109
+ // The SDK attributes tools to the loaded wrapper. Admit only the shipped literal shim, not arbitrary imports.
110
+ if (readFileSync(path, "utf8").trim() !== 'export { default } from "../../node_modules/pi-better-subagents/index.ts";') return false;
111
+ return isSubagentEntry(realpathSync(join(root, "node_modules/pi-better-subagents/index.ts")));
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /** The parent emits actionable open reports; child errors/prose are not this contract. */
118
+ export function reportedPermissionBlockers(pi: ExtensionAPI, toolName: string, details: unknown): PermissionBlocker[] {
119
+ if (!isParentBlockerProducer(pi, toolName) || !details || typeof details !== "object") return [];
120
+ const reports = (details as { permissionBlockers?: unknown }).permissionBlockers;
121
+ if (!Array.isArray(reports) || reports.length > MAX_PERMISSION_BLOCKERS) return [];
122
+ // Reject a malformed batch whole rather than accidentally widening a partial retry scope.
123
+ if (!reports.every((report) => isPermissionBlocker(report) && report.context === "worker")) return [];
124
+ return reports.map(compactBlocker);
125
+ }
126
+
127
+ export function reportedPermissionGap(pi: ExtensionAPI, toolName: string, details: unknown): boolean {
128
+ if (!isParentBlockerProducer(pi, toolName) || !details || typeof details !== "object") return false;
129
+ const omitted = (details as { permissionBlockersOmitted?: unknown }).permissionBlockersOmitted;
130
+ return typeof omitted === "number" && Number.isSafeInteger(omitted) && omitted > 0;
131
+ }
132
+
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) };
135
+ }
136
+
137
+ export function permissionHoldRecord(goalId: string, blocker: PermissionBlocker): PermissionRecord {
138
+ return { version: 1, kind: "permission-hold", goalId, blocker: compactBlocker(blocker), at: Math.floor(Date.now() / 1000) };
139
+ }
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
+ }
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  export interface GoalPreferences {
6
6
  autoContinue: boolean;
7
7
  conversationalResume: boolean;
8
+ pauseOnEscape: boolean;
8
9
  }
9
10
 
10
11
  export interface GoalPreferenceSeams {
@@ -22,7 +23,7 @@ export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPrefer
22
23
  raw = readFileSync(path, "utf8");
23
24
  } catch (error) {
24
25
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
25
- return { autoContinue: true, conversationalResume: true };
26
+ return { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
26
27
  }
27
28
  throw new Error(`Goal preferences at ${path} could not be read: ${messageOf(error)}`);
28
29
  }
@@ -35,16 +36,18 @@ export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPrefer
35
36
  const value = parsed as Partial<GoalPreferences> & { version?: unknown } | null;
36
37
  if (value?.version !== 1 ||
37
38
  (value.autoContinue !== undefined && typeof value.autoContinue !== "boolean") ||
38
- (value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean")) {
39
- throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume settings.`);
39
+ (value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean") ||
40
+ (value.pauseOnEscape !== undefined && typeof value.pauseOnEscape !== "boolean")) {
41
+ throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume/pauseOnEscape settings.`);
40
42
  }
41
43
  return {
42
44
  autoContinue: value.autoContinue ?? true,
43
45
  conversationalResume: value.conversationalResume ?? true,
46
+ pauseOnEscape: value.pauseOnEscape ?? true,
44
47
  };
45
48
  }
46
49
 
47
- /** Persist one control without replacing the other session's saved choice. */
50
+ /** Persist one control without replacing another session's saved choices. */
48
51
  export function writeGoalPreference(
49
52
  key: keyof GoalPreferences,
50
53
  enabled: boolean,
@@ -5,6 +5,7 @@ import type { GoalPreferences } from "./preferences.js";
5
5
  const rows: { key: keyof GoalPreferences; label: string }[] = [
6
6
  { key: "autoContinue", label: "Automatic continuation" },
7
7
  { key: "conversationalResume", label: "Conversational resume" },
8
+ { key: "pauseOnEscape", label: "Pause on Esc" },
8
9
  ];
9
10
 
10
11
  export function createGoalSettingsPage(
@@ -0,0 +1,48 @@
1
+ // Generated from packages/failure-observations/permission-blocker.ts. Do not edit directly.
2
+ /** Privacy-safe wire contract. Context and identity must be assigned by a trusted adapter. */
3
+ export const PERMISSION_RESOURCES = Object.freeze([
4
+ "credential-files", "process-inspection", "project-files", "outside-project-files",
5
+ "runtime-control-files", "command-execution", "network-access", "unknown",
6
+ ] as const);
7
+ export type PermissionResource = typeof PERMISSION_RESOURCES[number];
8
+ export interface PermissionBlocker {
9
+ version: 1;
10
+ kind: "permission-blocker";
11
+ context: "worker" | "foreground";
12
+ resource: PermissionResource;
13
+ basis: "policy-refusal" | "os-permission-error" | "agent-reported";
14
+ /** A stable hash or bounded logical identifier, never a command or path. */
15
+ operation: string;
16
+ remoteOutcome: "unknown" | "not-started";
17
+ incidentId?: string;
18
+ runId?: string;
19
+ policySnapshotId?: string;
20
+ }
21
+ const FIELDS = new Set(["version", "kind", "context", "resource", "basis", "operation", "remoteOutcome", "incidentId", "runId", "policySnapshotId"]);
22
+ const LOGICAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
23
+ // Responses SDK references join call_id and item_id with "|"; these are opaque evidence ids,
24
+ // not caller-chosen logical operations. Keep the same bound and exclude paths and prose.
25
+ const INCIDENT_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:|\-]{0,199}$/;
26
+ export function isPermissionResource(value: unknown): value is PermissionResource {
27
+ return typeof value === "string" && (PERMISSION_RESOURCES as readonly string[]).includes(value);
28
+ }
29
+ /** Pure and strict: no coercion, unknown fields, paths, argv, or free-form prose. */
30
+ export function isPermissionBlocker(value: unknown): value is PermissionBlocker {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
32
+ const prototype = Object.getPrototypeOf(value);
33
+ if (prototype !== Object.prototype && prototype !== null) return false;
34
+ if (Reflect.ownKeys(value).some(key => typeof key !== "string" || !FIELDS.has(key))) return false;
35
+ const v = value as Record<string, unknown>;
36
+ if (v.version !== 1 || v.kind !== "permission-blocker" || !["worker", "foreground"].includes(v.context as string) ||
37
+ !isPermissionResource(v.resource) || !["policy-refusal", "os-permission-error", "agent-reported"].includes(v.basis as string) ||
38
+ !["unknown", "not-started"].includes(v.remoteOutcome as string) ||
39
+ typeof v.operation !== "string" || v.operation.length > 64 || !LOGICAL_ID.test(v.operation)) return false;
40
+ if (Object.hasOwn(v, "incidentId") && (typeof v.incidentId !== "string" || !INCIDENT_REFERENCE.test(v.incidentId))) return false;
41
+ return ["runId", "policySnapshotId"].every(key => !Object.hasOwn(v, key) ||
42
+ (typeof v[key] === "string" && LOGICAL_ID.test(v[key] as string)));
43
+ }
44
+ /** Order-independent identity without I/O or a platform-specific crypto dependency. */
45
+ export function permissionBlockerKey(blocker: PermissionBlocker): string {
46
+ return JSON.stringify([blocker.version, blocker.kind, blocker.context, blocker.resource, blocker.basis,
47
+ blocker.operation, blocker.remoteOutcome, blocker.incidentId ?? null, blocker.runId ?? null, blocker.policySnapshotId ?? null]);
48
+ }
package/src/types.ts CHANGED
@@ -71,9 +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.
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.
75
77
  */
76
- export type GoalPauseReason = "interrupt";
78
+ export type GoalPauseReason = "interrupt" | "permission-blocker";
77
79
 
78
80
  export interface GoalUsage {
79
81
  tokensUsed: number;