pi-better-goal 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/package.json +2 -1
- package/src/continuation.ts +5 -1
- package/src/goal-state.ts +1 -1
- package/src/index.ts +143 -33
- package/src/permission-hold.ts +150 -0
- package/src/preferences.ts +32 -23
- package/src/shared-harness-settings.ts +114 -0
- package/src/shared-permission-blocker.ts +48 -0
- package/src/types.ts +4 -2
package/README.md
CHANGED
|
@@ -21,6 +21,20 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
21
21
|
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
22
22
|
- An observable-progress stall state for active goals.
|
|
23
23
|
|
|
24
|
+
## Settings Storage
|
|
25
|
+
|
|
26
|
+
Goal preferences are saved only in Pi's global `~/.pi/agent/settings.json` under
|
|
27
|
+
`piBetterHarness.goal` (or `$PI_CODING_AGENT_DIR/settings.json` when overridden).
|
|
28
|
+
The payload retains `version: 1` and the boolean `autoContinue`,
|
|
29
|
+
`conversationalResume`, and `pauseOnEscape` fields; omitted controls default to
|
|
30
|
+
`true`. Updates preserve other Pi settings and other `piBetterHarness` keys.
|
|
31
|
+
|
|
32
|
+
On first read, an existing `extensions/pi-better-goal-preferences.json` in the
|
|
33
|
+
agent directory is validated and migrated when the global Goal key is absent.
|
|
34
|
+
An existing global key is authoritative, and subsequent changes write only
|
|
35
|
+
`settings.json`. Invalid preferences report an error instead of being replaced
|
|
36
|
+
with defaults.
|
|
37
|
+
|
|
24
38
|
## Skill-Owned Workflows
|
|
25
39
|
|
|
26
40
|
Skills that own execution and their own task plan can opt in through `SKILL.md` frontmatter:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-goal",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Pi extension for goal tracking with background-aware continuation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"node": ">=22.0.0"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
+
"proper-lockfile": "^4.1.2",
|
|
72
73
|
"yaml": "^2.9.1"
|
|
73
74
|
}
|
|
74
75
|
}
|
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,4 +1,4 @@
|
|
|
1
|
-
import type { CustomEditor, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { CustomEditor, ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
3
|
import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
@@ -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,
|
|
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
|
-
|
|
493
|
-
|
|
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);
|
|
@@ -808,6 +842,36 @@ export default function (pi: ExtensionAPI): void {
|
|
|
808
842
|
if (latestSnapshot) collectIfPossible();
|
|
809
843
|
});
|
|
810
844
|
|
|
845
|
+
const changePreference = async (ctx: ExtensionCommandContext, key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
|
|
846
|
+
const previous = preferences;
|
|
847
|
+
preferences = writeGoalPreference(key, enabled);
|
|
848
|
+
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
849
|
+
syncResumeTool(getGoal(ctx));
|
|
850
|
+
applyStatus(ctx);
|
|
851
|
+
if (!previous.autoContinue && preferences.autoContinue) {
|
|
852
|
+
const snapshot = await publishSnapshot(ctx);
|
|
853
|
+
const goal = getGoal(ctx);
|
|
854
|
+
if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
|
|
855
|
+
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
|
|
860
|
+
currentCtx = ctx;
|
|
861
|
+
if (ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
|
|
862
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
|
|
863
|
+
get: () => ({ ...preferences }),
|
|
864
|
+
change: (key, enabled) => changePreference(ctx, key, enabled),
|
|
865
|
+
}, () => tui.requestRender(), () => done()));
|
|
866
|
+
} else {
|
|
867
|
+
ctx.ui.notify(`${formatPreferences(preferences)}\nPreferences: ${goalPreferencesPath()}`, "info");
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
const contribution = { id: "goal", label: "Goal", command: "/goal settings", open: openSettings };
|
|
871
|
+
const registerSettings = () => pi.events.emit("harness-settings:register", contribution);
|
|
872
|
+
const unsubscribeSettingsRequest = pi.events.on("harness-settings:request", registerSettings);
|
|
873
|
+
registerSettings();
|
|
874
|
+
|
|
811
875
|
pi.registerCommand("goal", {
|
|
812
876
|
description: "Create, inspect, pause, resume, clear, or complete the active goal; configure persistent goal settings",
|
|
813
877
|
getArgumentCompletions: goalArgumentCompletions,
|
|
@@ -817,25 +881,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
817
881
|
const current = getGoal(ctx);
|
|
818
882
|
|
|
819
883
|
if (trimmed === "settings" || trimmed.startsWith("settings ")) {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
preferences = writeGoalPreference(key, enabled);
|
|
823
|
-
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
824
|
-
syncResumeTool(getGoal(ctx));
|
|
825
|
-
applyStatus(ctx);
|
|
826
|
-
if (!previous.autoContinue && preferences.autoContinue) {
|
|
827
|
-
const snapshot = await publishSnapshot(ctx);
|
|
828
|
-
const goal = getGoal(ctx);
|
|
829
|
-
if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
|
|
830
|
-
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
};
|
|
834
|
-
if (trimmed === "settings" && ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
|
|
835
|
-
await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
|
|
836
|
-
get: () => ({ ...preferences }),
|
|
837
|
-
change: changePreference,
|
|
838
|
-
}, () => tui.requestRender(), () => done()));
|
|
884
|
+
if (trimmed === "settings") {
|
|
885
|
+
await openSettings(ctx);
|
|
839
886
|
return;
|
|
840
887
|
}
|
|
841
888
|
const [, setting, mode, ...extra] = trimmed.split(/\s+/);
|
|
@@ -848,7 +895,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
848
895
|
try {
|
|
849
896
|
const key = setting === "auto-continue" ? "autoContinue"
|
|
850
897
|
: setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
|
|
851
|
-
await changePreference(key, mode === "on");
|
|
898
|
+
await changePreference(ctx, key, mode === "on");
|
|
852
899
|
} catch (error) {
|
|
853
900
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
854
901
|
return;
|
|
@@ -861,7 +908,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
861
908
|
if (!trimmed) {
|
|
862
909
|
const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
|
|
863
910
|
// 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
|
|
911
|
+
ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences,
|
|
912
|
+
current ? currentPermissionHold(ctx, current.goalId) : null), "info");
|
|
865
913
|
return;
|
|
866
914
|
}
|
|
867
915
|
|
|
@@ -938,8 +986,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
938
986
|
backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
|
|
939
987
|
});
|
|
940
988
|
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 },
|
|
989
|
+
content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences, goal ? currentPermissionHold(ctx, goal.goalId) : null) }],
|
|
990
|
+
details: { goal, continuation, permissionHold: goal ? currentPermissionHold(ctx, goal.goalId) : null, stall, preferences: { ...preferences }, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
|
|
943
991
|
};
|
|
944
992
|
},
|
|
945
993
|
});
|
|
@@ -1104,6 +1152,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1104
1152
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1105
1153
|
}
|
|
1106
1154
|
const restoredGoal = getGoal(ctx);
|
|
1155
|
+
if (restoredGoal?.status === "active" && currentPermissionHold(ctx, restoredGoal.goalId).blockers.length > 0) {
|
|
1156
|
+
// A persisted release is not a replayable dispatch ticket after reload.
|
|
1157
|
+
setGoal(goalWithStatus(restoredGoal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1158
|
+
}
|
|
1107
1159
|
if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
|
|
1108
1160
|
setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
|
|
1109
1161
|
notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
|
|
@@ -1159,7 +1211,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1159
1211
|
// A paused goal stays paused while the user talks: the message is ordinary
|
|
1160
1212
|
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
1161
1213
|
const goal = getGoal(ctx);
|
|
1162
|
-
if (goal?.status === "active") {
|
|
1214
|
+
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0) {
|
|
1163
1215
|
executionGeneration += 1;
|
|
1164
1216
|
clearIdleContinuation();
|
|
1165
1217
|
lastAgentEvidence = null;
|
|
@@ -1173,8 +1225,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1173
1225
|
const owner = getWorkflow(ctx);
|
|
1174
1226
|
const snapshot = await publishSnapshot(ctx);
|
|
1175
1227
|
if (!snapshot) return;
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1228
|
+
const permissions = goal ? currentPermissionHold(ctx, goal.goalId) : null;
|
|
1229
|
+
const permissionPrompt = permissions && goal?.status !== "complete" &&
|
|
1230
|
+
(permissions.blockers.length > 0 || goal?.pauseReason === "permission-blocker")
|
|
1231
|
+
? permissionInstruction({ ...permissions, retryPending: permissions.retryPending && goal?.status === "active" }) +
|
|
1232
|
+
(permissions.blockers.length === 0 ? "\nBlocker evidence is unavailable. Stay held; do not invent a retry scope." : "") : "";
|
|
1233
|
+
// A held goal overrides workflow ownership without invoking or invalidating that workflow.
|
|
1234
|
+
if (permissionPrompt && goal?.status === "paused") {
|
|
1235
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${permissionPrompt}` };
|
|
1236
|
+
}
|
|
1237
|
+
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && goal.pauseReason === "interrupt"
|
|
1238
|
+
? pausedGoalPrompt(goal, preferences.conversationalResume) : "");
|
|
1178
1239
|
if (!isPokeable(goal) && !owner) {
|
|
1179
1240
|
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
1180
1241
|
}
|
|
@@ -1199,7 +1260,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1199
1260
|
return {
|
|
1200
1261
|
systemPrompt: `${event.systemPrompt}\n\n` +
|
|
1201
1262
|
(pausedInstruction
|
|
1202
|
-
? `${pausedInstruction}\
|
|
1263
|
+
? `${pausedInstruction}\n` + (permissionPrompt
|
|
1264
|
+
? "These permission constraints override the workflow instructions below.\n\n"
|
|
1265
|
+
: "While the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n")
|
|
1203
1266
|
: "") +
|
|
1204
1267
|
`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
1268
|
(isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
|
|
@@ -1214,7 +1277,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1214
1277
|
return {
|
|
1215
1278
|
systemPrompt:
|
|
1216
1279
|
`${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}
|
|
1280
|
+
`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}` +
|
|
1281
|
+
(permissionPrompt ? `\n\n${permissionPrompt}` : ""),
|
|
1218
1282
|
};
|
|
1219
1283
|
});
|
|
1220
1284
|
|
|
@@ -1255,6 +1319,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1255
1319
|
escapeAbortSuppression = undefined;
|
|
1256
1320
|
});
|
|
1257
1321
|
|
|
1322
|
+
pi.on("tool_result", (event, ctx) => {
|
|
1323
|
+
const goal = getGoal(ctx);
|
|
1324
|
+
if (!goal || (goal.status !== "active" && goal.status !== "paused")) return;
|
|
1325
|
+
const reports = reportedPermissionBlockers(pi, event.toolName, event.details);
|
|
1326
|
+
const incomplete = reportedPermissionGap(pi, event.toolName, event.details);
|
|
1327
|
+
if (reports.length === 0 && !incomplete) return;
|
|
1328
|
+
let hold = currentPermissionHold(ctx, goal.goalId);
|
|
1329
|
+
let changed = false;
|
|
1330
|
+
for (const blocker of reports) {
|
|
1331
|
+
const previous = hold.blockers.find((item) => logicalPermissionBlockerKey(item) === logicalPermissionBlockerKey(blocker));
|
|
1332
|
+
if (!hold.retryPending && previous && JSON.stringify(previous) === JSON.stringify(blocker)) continue;
|
|
1333
|
+
if (hold.saturated || (!previous && hold.blockers.length >= MAX_PERMISSION_BLOCKERS)) {
|
|
1334
|
+
if (!hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1335
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
|
|
1336
|
+
}
|
|
1337
|
+
break;
|
|
1338
|
+
}
|
|
1339
|
+
pi.appendEntry(EXTENSION_NAME, permissionHoldRecord(goal.goalId, blocker));
|
|
1340
|
+
changed = true;
|
|
1341
|
+
hold = currentPermissionHold(ctx, goal.goalId);
|
|
1342
|
+
}
|
|
1343
|
+
if (incomplete && !hold.saturated && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1344
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-gap"));
|
|
1345
|
+
changed = true;
|
|
1346
|
+
hold = currentPermissionHold(ctx, goal.goalId);
|
|
1347
|
+
}
|
|
1348
|
+
if (hold.retryPending && hold.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1349
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
1350
|
+
}
|
|
1351
|
+
if (!changed && goal.status === "paused" && goal.pauseReason === "permission-blocker") return;
|
|
1352
|
+
setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1353
|
+
notifyGoal(ctx, "Goal held for a permission blocker. /goal resume or alt+g releases one same-scope retry.", "warning");
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1258
1356
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
1259
1357
|
if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
|
|
1260
1358
|
currentCtx = ctx;
|
|
@@ -1291,6 +1389,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1291
1389
|
const snapshot = await publishSnapshot(ctx);
|
|
1292
1390
|
if (!snapshot) return;
|
|
1293
1391
|
const goal = getGoal(ctx);
|
|
1392
|
+
if (isPokeable(goal)) {
|
|
1393
|
+
const permissions = currentPermissionHold(ctx, goal.goalId);
|
|
1394
|
+
if (permissions.blockers.length > 0) {
|
|
1395
|
+
if (permissions.retryPending && permissions.recordCount < MAX_PERMISSION_RECORDS) {
|
|
1396
|
+
pi.appendEntry(EXTENSION_NAME, permissionRecord(goal.goalId, "permission-retry-finished"));
|
|
1397
|
+
}
|
|
1398
|
+
setGoal(goalWithStatus(goal, "paused", undefined, "permission-blocker"), ctx, "runtime");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1294
1402
|
if (!isPokeable(goal) || snapshot.backgroundRunning) {
|
|
1295
1403
|
return;
|
|
1296
1404
|
}
|
|
@@ -1330,6 +1438,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1330
1438
|
});
|
|
1331
1439
|
|
|
1332
1440
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1441
|
+
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
1442
|
+
|
|
1333
1443
|
executionGeneration += 1;
|
|
1334
1444
|
sessionGeneration += 1;
|
|
1335
1445
|
escapeAbortSuppression = undefined;
|
|
@@ -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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
harnessSettingsPath,
|
|
5
|
+
readHarnessSetting,
|
|
6
|
+
updateHarnessSetting,
|
|
7
|
+
type HarnessSettingsSeams,
|
|
8
|
+
} from "./shared-harness-settings.js";
|
|
4
9
|
|
|
5
10
|
export interface GoalPreferences {
|
|
6
11
|
autoContinue: boolean;
|
|
@@ -8,30 +13,33 @@ export interface GoalPreferences {
|
|
|
8
13
|
pauseOnEscape: boolean;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
|
-
export interface GoalPreferenceSeams {
|
|
12
|
-
agentDir?: () => string;
|
|
13
|
-
}
|
|
16
|
+
export interface GoalPreferenceSeams extends HarnessSettingsSeams {}
|
|
14
17
|
|
|
15
18
|
export function goalPreferencesPath(seams: GoalPreferenceSeams = {}): string {
|
|
16
|
-
return
|
|
19
|
+
return harnessSettingsPath(seams);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPreferences {
|
|
20
23
|
const path = goalPreferencesPath(seams);
|
|
21
|
-
|
|
24
|
+
const legacyPath = join((seams.agentDir ?? getAgentDir)(), "extensions", "pi-better-goal-preferences.json");
|
|
22
25
|
try {
|
|
23
|
-
|
|
26
|
+
const value = readHarnessSetting<unknown>("goal", seams, {
|
|
27
|
+
path: legacyPath,
|
|
28
|
+
parse: (value) => ({ version: 1, ...parseGoalPreferences(value, legacyPath) }),
|
|
29
|
+
});
|
|
30
|
+
return parseGoalPreferences(value, path);
|
|
24
31
|
} catch (error) {
|
|
25
|
-
if ((error
|
|
26
|
-
|
|
32
|
+
if (messageOf(error).startsWith("Goal preferences at ")) throw error;
|
|
33
|
+
if (error instanceof SyntaxError || /not valid JSON/.test(messageOf(error))) {
|
|
34
|
+
throw new Error(`Goal preferences at ${path} are not valid JSON: ${messageOf(error)}`);
|
|
27
35
|
}
|
|
28
36
|
throw new Error(`Goal preferences at ${path} could not be read: ${messageOf(error)}`);
|
|
29
37
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseGoalPreferences(parsed: unknown, path: string): GoalPreferences {
|
|
41
|
+
if (parsed === undefined) {
|
|
42
|
+
return { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
|
|
35
43
|
}
|
|
36
44
|
const value = parsed as Partial<GoalPreferences> & { version?: unknown } | null;
|
|
37
45
|
if (value?.version !== 1 ||
|
|
@@ -53,19 +61,20 @@ export function writeGoalPreference(
|
|
|
53
61
|
enabled: boolean,
|
|
54
62
|
seams: GoalPreferenceSeams = {},
|
|
55
63
|
): GoalPreferences {
|
|
56
|
-
|
|
64
|
+
// Migrate legacy preferences before updating the authoritative value under lock.
|
|
65
|
+
readGoalPreferences(seams);
|
|
57
66
|
const path = goalPreferencesPath(seams);
|
|
58
|
-
const pending = `${path}.${process.pid}.tmp`;
|
|
59
67
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
const { version: _version, ...preferences } = updateHarnessSetting("goal", (current) => ({
|
|
69
|
+
version: 1,
|
|
70
|
+
...parseGoalPreferences(current, path),
|
|
71
|
+
[key]: enabled,
|
|
72
|
+
}), seams);
|
|
73
|
+
return preferences;
|
|
63
74
|
} catch (error) {
|
|
75
|
+
if (messageOf(error).startsWith("Goal preferences at ")) throw error;
|
|
64
76
|
throw new Error(`Goal preferences at ${path} could not be written: ${messageOf(error)}`);
|
|
65
|
-
} finally {
|
|
66
|
-
rmSync(pending, { force: true });
|
|
67
77
|
}
|
|
68
|
-
return preferences;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
function messageOf(error: unknown): string {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Generated from packages/harness-settings/index.ts. Do not edit directly.
|
|
2
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
// Match Pi's settings lock without depending on a private SDK import.
|
|
9
|
+
const lockfile = createRequire(import.meta.url)("proper-lockfile") as {
|
|
10
|
+
lockSync(path: string, options: { realpath: boolean }): () => void;
|
|
11
|
+
};
|
|
12
|
+
const NAMESPACE = "piBetterHarness";
|
|
13
|
+
export interface HarnessSettingsSeams { agentDir?: () => string }
|
|
14
|
+
|
|
15
|
+
export function harnessSettingsPath(seams: HarnessSettingsSeams = {}): string {
|
|
16
|
+
return join((seams.agentDir ?? getAgentDir)(), "settings.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object.`);
|
|
21
|
+
return value as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readSettings(path: string): Record<string, unknown> {
|
|
25
|
+
try { return object(JSON.parse(readFileSync(path, "utf8")), `Settings at ${path}`); }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function section(settings: Record<string, unknown>): Record<string, unknown> {
|
|
33
|
+
return Object.hasOwn(settings, NAMESPACE) ? object(settings[NAMESPACE], NAMESPACE) : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function acquire(path: string): () => void {
|
|
37
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
38
|
+
try { return lockfile.lockSync(path, { realpath: false }); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code !== "ELOCKED" || attempt === 9) throw error;
|
|
41
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Cannot lock settings at ${path}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function lockedRead(path: string): Record<string, unknown> {
|
|
48
|
+
if (!existsSync(path)) return {};
|
|
49
|
+
const release = acquire(path);
|
|
50
|
+
try { return readSettings(path); } finally { release(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeDestination(path: string): string {
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = lstatSync(path); }
|
|
56
|
+
catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return path;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
// Keep managed dotfiles links intact. A dangling link fails rather than being replaced.
|
|
61
|
+
return stat.isSymbolicLink() ? realpathSync(path) : path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mutate<T>(seams: HarnessSettingsSeams, change: (values: Record<string, unknown>) => T): T {
|
|
65
|
+
const path = harnessSettingsPath(seams);
|
|
66
|
+
mkdirSync((seams.agentDir ?? getAgentDir)(), { recursive: true });
|
|
67
|
+
const release = acquire(path);
|
|
68
|
+
let pending: string | undefined;
|
|
69
|
+
try {
|
|
70
|
+
// Read inside the lock so another session's settings are never replaced by a stale snapshot.
|
|
71
|
+
const settings = readSettings(path);
|
|
72
|
+
const values = section(settings);
|
|
73
|
+
const result = change(values);
|
|
74
|
+
settings[NAMESPACE] = values;
|
|
75
|
+
const destination = writeDestination(path);
|
|
76
|
+
pending = `${destination}.${randomUUID()}.tmp`;
|
|
77
|
+
writeFileSync(pending, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
78
|
+
renameSync(pending, destination);
|
|
79
|
+
return result;
|
|
80
|
+
} finally {
|
|
81
|
+
try { if (pending) rmSync(pending, { force: true }); } finally { release(); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readHarnessSetting<T = unknown>(
|
|
86
|
+
key: string,
|
|
87
|
+
seams: HarnessSettingsSeams = {},
|
|
88
|
+
legacy?: { path: string; parse: (value: unknown) => T },
|
|
89
|
+
): T | undefined {
|
|
90
|
+
const values = section(lockedRead(harnessSettingsPath(seams)));
|
|
91
|
+
if (Object.hasOwn(values, key)) return values[key] as T;
|
|
92
|
+
if (!legacy) return undefined;
|
|
93
|
+
let raw: string;
|
|
94
|
+
try { raw = readFileSync(legacy.path, "utf8"); }
|
|
95
|
+
catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const migrated = legacy.parse(JSON.parse(raw));
|
|
100
|
+
return mutate(seams, current => {
|
|
101
|
+
if (!Object.hasOwn(current, key)) current[key] = migrated;
|
|
102
|
+
return current[key] as T;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function updateHarnessSetting<T>(
|
|
107
|
+
key: string, update: (current: unknown) => T, seams: HarnessSettingsSeams = {},
|
|
108
|
+
): T {
|
|
109
|
+
return mutate(seams, values => {
|
|
110
|
+
const next = update(values[key]);
|
|
111
|
+
values[key] = next;
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -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;
|