pi-better-goal 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.8.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
@@ -28,6 +28,7 @@ import {
28
28
  type GoalEntrySource,
29
29
  } from "./goal-state.js";
30
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";
31
32
  import { observeGoalStall } from "./stall.js";
32
33
  import {
33
34
  flattenObjectiveForRail,
@@ -91,6 +92,7 @@ export function agentResumable(goal: GoalSnapshot | null, conversationalResume =
91
92
 
92
93
  /** Footer status for a paused goal, telling the user how to resume it. */
93
94
  export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
95
+ if (goal.pauseReason === "permission-blocker") return "goal held: permission blocker · /goal resume (one retry)";
94
96
  return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
95
97
  }
96
98
 
@@ -229,6 +231,7 @@ function formatGoal(
229
231
  continuation: ReturnType<typeof currentContinuationState> = null,
230
232
  stall = observeGoalStall(goal, continuation),
231
233
  preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true },
234
+ permissions?: PermissionHold | null,
232
235
  ): string {
233
236
  const settings = formatPreferences(preferences);
234
237
  if (!goal) {
@@ -242,6 +245,11 @@ function formatGoal(
242
245
  return [
243
246
  `Goal: ${goal.objective}`,
244
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
+ ] : []),
245
253
  `Token budget: ${budget}`,
246
254
  `Tokens used: ${goal.usage.tokensUsed}`,
247
255
  `Active time: ${timing.activeSeconds}s`,
@@ -375,6 +383,13 @@ export default function (pi: ExtensionAPI): void {
375
383
  };
376
384
 
377
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
+ }
378
393
  executionGeneration += 1;
379
394
  lastAgentEvidence = null;
380
395
  const previous = getGoal(ctx);
@@ -418,7 +433,9 @@ export default function (pi: ExtensionAPI): void {
418
433
  return;
419
434
  }
420
435
  setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
421
- 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
422
439
  ? 'Goal paused. Say "go" to resume it, or use /goal resume.'
423
440
  : "Goal paused. Use /goal resume or alt+g to resume it.");
424
441
  };
@@ -457,6 +474,18 @@ export default function (pi: ExtensionAPI): void {
457
474
  return { ok: false, message: blocker };
458
475
  }
459
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
+ }
460
489
  setGoal(goal, ctx, source);
461
490
  queueGoalContinuation(goal, ctx);
462
491
  return { ok: true, goal };
@@ -489,8 +518,11 @@ export default function (pi: ExtensionAPI): void {
489
518
  if (!boundCommandReady(goal, ctx)) return;
490
519
  clearIdleContinuation();
491
520
  continuationQueuedFor = goal.goalId;
492
- resetContinuationState(goal);
493
- 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");
494
526
  };
495
527
 
496
528
  function clearIdleContinuation(): void {
@@ -511,6 +543,7 @@ export default function (pi: ExtensionAPI): void {
511
543
  if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
512
544
  return;
513
545
  }
546
+ if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
514
547
  if (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
515
548
  return;
516
549
  }
@@ -541,6 +574,7 @@ export default function (pi: ExtensionAPI): void {
541
574
  if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
542
575
  const current = currentGoalSnapshot(ctx);
543
576
  return isPokeable(current) && current.goalId === goalId &&
577
+ currentPermissionHold(ctx, goalId).blockers.length === 0 &&
544
578
  continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
545
579
  };
546
580
  const goal = currentGoalSnapshot(ctx);
@@ -603,7 +637,7 @@ export default function (pi: ExtensionAPI): void {
603
637
  const goal = currentGoalSnapshot(ctx);
604
638
  const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
605
639
  backgroundDrainTracker = wakePlan.nextTracker;
606
- if (isPokeable(goal) && wakePlan.wakeSignature) {
640
+ if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0) {
607
641
  // Persist external progress now: a foreground/callback turn may cancel its delayed wake.
608
642
  clearIdleContinuation();
609
643
  resetContinuationState(goal);
@@ -861,7 +895,8 @@ export default function (pi: ExtensionAPI): void {
861
895
  if (!trimmed) {
862
896
  const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
863
897
  // Inspection is always explicit user intent; show the full summary even while busy.
864
- 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");
865
900
  return;
866
901
  }
867
902
 
@@ -938,8 +973,8 @@ export default function (pi: ExtensionAPI): void {
938
973
  backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
939
974
  });
940
975
  return {
941
- content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences) }],
942
- 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 },
943
978
  };
944
979
  },
945
980
  });
@@ -1104,6 +1139,10 @@ export default function (pi: ExtensionAPI): void {
1104
1139
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
1105
1140
  }
1106
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
+ }
1107
1146
  if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
1108
1147
  setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
1109
1148
  notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
@@ -1159,7 +1198,7 @@ export default function (pi: ExtensionAPI): void {
1159
1198
  // A paused goal stays paused while the user talks: the message is ordinary
1160
1199
  // conversation. Only /goal resume, the hotkey, or goal_resume resume it.
1161
1200
  const goal = getGoal(ctx);
1162
- if (goal?.status === "active") {
1201
+ if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0) {
1163
1202
  executionGeneration += 1;
1164
1203
  clearIdleContinuation();
1165
1204
  lastAgentEvidence = null;
@@ -1173,8 +1212,17 @@ export default function (pi: ExtensionAPI): void {
1173
1212
  const owner = getWorkflow(ctx);
1174
1213
  const snapshot = await publishSnapshot(ctx);
1175
1214
  if (!snapshot) return;
1176
- const pausedInstruction = goal?.status === "paused" && goal.pauseReason === "interrupt"
1177
- ? 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) : "");
1178
1226
  if (!isPokeable(goal) && !owner) {
1179
1227
  return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
1180
1228
  }
@@ -1199,7 +1247,9 @@ export default function (pi: ExtensionAPI): void {
1199
1247
  return {
1200
1248
  systemPrompt: `${event.systemPrompt}\n\n` +
1201
1249
  (pausedInstruction
1202
- ? `${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")
1203
1253
  : "") +
1204
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}` +
1205
1255
  (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
@@ -1214,7 +1264,8 @@ export default function (pi: ExtensionAPI): void {
1214
1264
  return {
1215
1265
  systemPrompt:
1216
1266
  `${event.systemPrompt}\n\n` +
1217
- `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}` : ""),
1218
1269
  };
1219
1270
  });
1220
1271
 
@@ -1255,6 +1306,40 @@ export default function (pi: ExtensionAPI): void {
1255
1306
  escapeAbortSuppression = undefined;
1256
1307
  });
1257
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");
1341
+ });
1342
+
1258
1343
  pi.on("tool_execution_start", async (event, ctx) => {
1259
1344
  if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
1260
1345
  currentCtx = ctx;
@@ -1291,6 +1376,16 @@ export default function (pi: ExtensionAPI): void {
1291
1376
  const snapshot = await publishSnapshot(ctx);
1292
1377
  if (!snapshot) return;
1293
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
+ }
1294
1389
  if (!isPokeable(goal) || snapshot.backgroundRunning) {
1295
1390
  return;
1296
1391
  }
@@ -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
+ }
@@ -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;