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 +2 -2
- package/package.json +1 -1
- package/src/continuation.ts +5 -1
- package/src/goal-state.ts +1 -1
- package/src/index.ts +184 -26
- package/src/permission-hold.ts +150 -0
- package/src/preferences.ts +7 -4
- package/src/settings-page.ts +1 -0
- package/src/shared-permission-blocker.ts +48 -0
- package/src/types.ts +4 -2
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.
|
|
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
package/src/continuation.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1127
|
-
|
|
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}\
|
|
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
|
-
|
|
1291
|
+
onAbort();
|
|
1183
1292
|
} else {
|
|
1184
|
-
turnSignal.addEventListener("abort",
|
|
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
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
|
|
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
|
+
}
|
package/src/preferences.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
package/src/settings-page.ts
CHANGED
|
@@ -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;
|