pi-better-goal 0.8.1 → 0.8.3
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 +16 -2
- package/package.json +2 -1
- package/src/index.ts +80 -33
- package/src/preferences.ts +32 -23
- package/src/shared-harness-settings.ts +114 -0
package/README.md
CHANGED
|
@@ -18,9 +18,23 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
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.
|
|
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.
|
|
21
|
+
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a sticky hold recoverable with `/goal resume` or `alt+g`. Conversation, changed callback results, and background drains do not reopen an exhausted hold. Delays saturate at Node's timer limit, and observed background drains reset non-held progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
22
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:
|
|
@@ -30,7 +44,7 @@ metadata:
|
|
|
30
44
|
workflow-role: coordinator
|
|
31
45
|
```
|
|
32
46
|
|
|
33
|
-
Invoke the skill with Pi's `/skill:name` command, or supervise it with `/goal /skill:name task`. The goal extension resolves the command against Pi's registry, persists its source, and expands the skill on kickoff and continuation (including after session resume). A bound command that disappears or changes source pauses the goal. `/goal /template task` also re-expands prompt templates on continuation; `/goal /extension-command task` dispatches the extension command once, then continues with ordinary goal supervision to avoid repeating side effects. Plain-language goals work as before. This command binding requires Pi 0.84.4 or later. Legacy slash-shaped goals without a binding pause on resume rather than running without the skill. `/workflow` shows a coordinator skill owner; call `release_workflow` after the workflow's completion audit
|
|
47
|
+
Invoke the skill with Pi's `/skill:name` command, or supervise it with `/goal /skill:name task`. The goal extension resolves the command against Pi's registry, persists its source, and expands the skill on kickoff and continuation (including after session resume). A bound command that disappears or changes source pauses the goal. `/goal /template task` also re-expands prompt templates on continuation; `/goal /extension-command task` dispatches the extension command once, then continues with ordinary goal supervision to avoid repeating side effects. Plain-language goals work as before. This command binding requires Pi 0.84.4 or later. Legacy slash-shaped goals without a binding pause on resume rather than running without the skill. `/workflow` shows a coordinator skill owner; call `release_workflow` after the workflow's completion audit, or use `/workflow clear` to release ownership manually. `release_workflow` also pauses an active Goal bound to that coordinator, preserving its objective and incomplete status. Reinvoke the skill explicitly before `/goal resume`; automatic continuation cannot reacquire ownership. Unrelated ordinary Goals are unchanged. Completing an active goal also releases ownership.
|
|
34
48
|
|
|
35
49
|
A skill that is only an alias of a coordinator declares the target instead of a role:
|
|
36
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-goal",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
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/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";
|
|
@@ -257,6 +257,7 @@ function formatGoal(
|
|
|
257
257
|
`Observable progress: ${stall?.state ?? "unknown"}`,
|
|
258
258
|
continuationStatus,
|
|
259
259
|
settings,
|
|
260
|
+
...(continuation?.blocked && goal.status === "active" ? ["Resume: /goal resume or alt+g"] : []),
|
|
260
261
|
...(goal.status === "paused"
|
|
261
262
|
? [agentResumable(goal, preferences.conversationalResume)
|
|
262
263
|
? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
|
|
@@ -432,10 +433,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
432
433
|
if (!isPokeable(goal)) {
|
|
433
434
|
return;
|
|
434
435
|
}
|
|
435
|
-
|
|
436
|
+
const held = currentContinuationState(ctx, goal.goalId)?.blocked === true;
|
|
437
|
+
setGoal(goalWithStatus(goal, "paused", undefined, held ? undefined : "interrupt"), ctx, "runtime");
|
|
436
438
|
notifyGoal(ctx, currentPermissionHold(ctx, goal.goalId).blockers.length > 0
|
|
437
439
|
? "Goal remains permission-held. Use /goal resume or alt+g for one same-scope retry."
|
|
438
|
-
: preferences.conversationalResume
|
|
440
|
+
: preferences.conversationalResume && !held
|
|
439
441
|
? 'Goal paused. Say "go" to resume it, or use /goal resume.'
|
|
440
442
|
: "Goal paused. Use /goal resume or alt+g to resume it.");
|
|
441
443
|
};
|
|
@@ -446,13 +448,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
446
448
|
escapeAbortSuppression.execution === executionGeneration;
|
|
447
449
|
|
|
448
450
|
/** Why a paused goal cannot become active again, or null when it can. */
|
|
449
|
-
const resumeBlocker = (goal: GoalSnapshot): string | null => {
|
|
451
|
+
const resumeBlocker = (goal: GoalSnapshot, ctx: ExtensionContext): string | null => {
|
|
450
452
|
if (!goal.command && skillCommandName(goal.objective)) {
|
|
451
453
|
return "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.";
|
|
452
454
|
}
|
|
453
455
|
if (goal.command && !commandAvailable(pi, goal.command)) {
|
|
454
456
|
return `Cannot resume: /${goal.command.name} is no longer registered at its original source.`;
|
|
455
457
|
}
|
|
458
|
+
if (goal.command?.source === "skill") {
|
|
459
|
+
const owner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
|
|
460
|
+
const activeOwner = getWorkflow(ctx);
|
|
461
|
+
if (owner && (activeOwner?.name !== owner.name || activeOwner.path !== owner.path)) {
|
|
462
|
+
return `Reinvoke /${goal.command.name} before resuming its workflow goal.`;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
456
465
|
return null;
|
|
457
466
|
};
|
|
458
467
|
|
|
@@ -469,7 +478,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
469
478
|
if (!current || (current.status !== "paused" && !held)) {
|
|
470
479
|
return { ok: false, message: "Only paused or no-progress-held goals can be resumed." };
|
|
471
480
|
}
|
|
472
|
-
|
|
481
|
+
if (source === "tool" && currentContinuationState(ctx, current.goalId)?.blocked) {
|
|
482
|
+
return { ok: false, message: "Only /goal resume or alt+g can reopen a no-progress hold." };
|
|
483
|
+
}
|
|
484
|
+
const blocker = resumeBlocker(current, ctx);
|
|
473
485
|
if (blocker) {
|
|
474
486
|
return { ok: false, message: blocker };
|
|
475
487
|
}
|
|
@@ -544,7 +556,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
544
556
|
return;
|
|
545
557
|
}
|
|
546
558
|
if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
|
|
547
|
-
if (
|
|
559
|
+
if (currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
548
560
|
return;
|
|
549
561
|
}
|
|
550
562
|
const signature = `${goal.goalId}:${kind}`;
|
|
@@ -575,6 +587,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
575
587
|
const current = currentGoalSnapshot(ctx);
|
|
576
588
|
return isPokeable(current) && current.goalId === goalId &&
|
|
577
589
|
currentPermissionHold(ctx, goalId).blockers.length === 0 &&
|
|
590
|
+
!currentContinuationState(ctx, goalId)?.blocked &&
|
|
578
591
|
continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
|
|
579
592
|
};
|
|
580
593
|
const goal = currentGoalSnapshot(ctx);
|
|
@@ -637,7 +650,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
637
650
|
const goal = currentGoalSnapshot(ctx);
|
|
638
651
|
const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
|
|
639
652
|
backgroundDrainTracker = wakePlan.nextTracker;
|
|
640
|
-
if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0
|
|
653
|
+
if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
|
|
654
|
+
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
641
655
|
// Persist external progress now: a foreground/callback turn may cancel its delayed wake.
|
|
642
656
|
clearIdleContinuation();
|
|
643
657
|
resetContinuationState(goal);
|
|
@@ -842,6 +856,36 @@ export default function (pi: ExtensionAPI): void {
|
|
|
842
856
|
if (latestSnapshot) collectIfPossible();
|
|
843
857
|
});
|
|
844
858
|
|
|
859
|
+
const changePreference = async (ctx: ExtensionCommandContext, key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
|
|
860
|
+
const previous = preferences;
|
|
861
|
+
preferences = writeGoalPreference(key, enabled);
|
|
862
|
+
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
863
|
+
syncResumeTool(getGoal(ctx));
|
|
864
|
+
applyStatus(ctx);
|
|
865
|
+
if (!previous.autoContinue && preferences.autoContinue) {
|
|
866
|
+
const snapshot = await publishSnapshot(ctx);
|
|
867
|
+
const goal = getGoal(ctx);
|
|
868
|
+
if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
|
|
869
|
+
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
|
|
874
|
+
currentCtx = ctx;
|
|
875
|
+
if (ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
|
|
876
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
|
|
877
|
+
get: () => ({ ...preferences }),
|
|
878
|
+
change: (key, enabled) => changePreference(ctx, key, enabled),
|
|
879
|
+
}, () => tui.requestRender(), () => done()));
|
|
880
|
+
} else {
|
|
881
|
+
ctx.ui.notify(`${formatPreferences(preferences)}\nPreferences: ${goalPreferencesPath()}`, "info");
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
const contribution = { id: "goal", label: "Goal", command: "/goal settings", open: openSettings };
|
|
885
|
+
const registerSettings = () => pi.events.emit("harness-settings:register", contribution);
|
|
886
|
+
const unsubscribeSettingsRequest = pi.events.on("harness-settings:request", registerSettings);
|
|
887
|
+
registerSettings();
|
|
888
|
+
|
|
845
889
|
pi.registerCommand("goal", {
|
|
846
890
|
description: "Create, inspect, pause, resume, clear, or complete the active goal; configure persistent goal settings",
|
|
847
891
|
getArgumentCompletions: goalArgumentCompletions,
|
|
@@ -851,25 +895,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
851
895
|
const current = getGoal(ctx);
|
|
852
896
|
|
|
853
897
|
if (trimmed === "settings" || trimmed.startsWith("settings ")) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
preferences = writeGoalPreference(key, enabled);
|
|
857
|
-
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
858
|
-
syncResumeTool(getGoal(ctx));
|
|
859
|
-
applyStatus(ctx);
|
|
860
|
-
if (!previous.autoContinue && preferences.autoContinue) {
|
|
861
|
-
const snapshot = await publishSnapshot(ctx);
|
|
862
|
-
const goal = getGoal(ctx);
|
|
863
|
-
if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
|
|
864
|
-
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
};
|
|
868
|
-
if (trimmed === "settings" && ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
|
|
869
|
-
await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
|
|
870
|
-
get: () => ({ ...preferences }),
|
|
871
|
-
change: changePreference,
|
|
872
|
-
}, () => tui.requestRender(), () => done()));
|
|
898
|
+
if (trimmed === "settings") {
|
|
899
|
+
await openSettings(ctx);
|
|
873
900
|
return;
|
|
874
901
|
}
|
|
875
902
|
const [, setting, mode, ...extra] = trimmed.split(/\s+/);
|
|
@@ -882,7 +909,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
882
909
|
try {
|
|
883
910
|
const key = setting === "auto-continue" ? "autoContinue"
|
|
884
911
|
: setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
|
|
885
|
-
await changePreference(key, mode === "on");
|
|
912
|
+
await changePreference(ctx, key, mode === "on");
|
|
886
913
|
} catch (error) {
|
|
887
914
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
888
915
|
return;
|
|
@@ -1088,13 +1115,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1088
1115
|
pi.registerTool({
|
|
1089
1116
|
name: "release_workflow",
|
|
1090
1117
|
label: "Release Workflow",
|
|
1091
|
-
description: "Release the active skill-owned workflow after its final handoff and completion audit.",
|
|
1118
|
+
description: "Release the active skill-owned workflow after its final handoff and completion audit. Its bound active Goal pauses; explicitly reinvoke the skill before resuming that Goal.",
|
|
1092
1119
|
parameters: Type.Object({}),
|
|
1093
1120
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1094
1121
|
const owner = getWorkflow(ctx);
|
|
1095
1122
|
if (!owner) return { content: [{ type: "text", text: "No workflow owns this session." }], details: { released: false } };
|
|
1123
|
+
const goal = getGoal(ctx);
|
|
1124
|
+
let resumeCommand: string | undefined;
|
|
1125
|
+
if (goal?.status === "active" && goal.command?.source === "skill") {
|
|
1126
|
+
const boundOwner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
|
|
1127
|
+
if (boundOwner?.name === owner.name && boundOwner.path === owner.path) {
|
|
1128
|
+
setGoal(goalWithStatus(goal, "paused"), ctx, "tool");
|
|
1129
|
+
resumeCommand = goal.command.name;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1096
1132
|
recordWorkflow(null);
|
|
1097
|
-
return {
|
|
1133
|
+
return {
|
|
1134
|
+
content: [{ type: "text", text: `Released ${owner.name} workflow ownership.${resumeCommand ? ` Its Goal is paused, not complete. Reinvoke /${resumeCommand} before /goal resume.` : ""}` }],
|
|
1135
|
+
details: { released: true, owner: owner.name, goalPaused: resumeCommand !== undefined },
|
|
1136
|
+
};
|
|
1098
1137
|
},
|
|
1099
1138
|
});
|
|
1100
1139
|
|
|
@@ -1198,7 +1237,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1198
1237
|
// A paused goal stays paused while the user talks: the message is ordinary
|
|
1199
1238
|
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
1200
1239
|
const goal = getGoal(ctx);
|
|
1201
|
-
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0
|
|
1240
|
+
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
|
|
1241
|
+
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
1202
1242
|
executionGeneration += 1;
|
|
1203
1243
|
clearIdleContinuation();
|
|
1204
1244
|
lastAgentEvidence = null;
|
|
@@ -1230,6 +1270,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1230
1270
|
const questionInstruction = snapshot.backgroundRunning
|
|
1231
1271
|
? " A blocking user question (ask_user_question) holds this whole turn until the user answers, and background completions wait behind it. Harvest finished background results before asking, and ask only when the answer is needed to proceed."
|
|
1232
1272
|
: "";
|
|
1273
|
+
const heldInstruction = goal?.status === "active" && currentContinuationState(ctx, goal.goalId)?.blocked
|
|
1274
|
+
? "Automatic Goal continuation is held for no progress. Answer the current user message or inspect delivered background results without restarting autonomous Goal work. Only /goal resume or alt+g reopens this hold."
|
|
1275
|
+
: "";
|
|
1233
1276
|
|
|
1234
1277
|
if (owner) {
|
|
1235
1278
|
if (!workflowAvailable(owner)) {
|
|
@@ -1253,7 +1296,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1253
1296
|
: "") +
|
|
1254
1297
|
`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}` +
|
|
1255
1298
|
(isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
|
|
1256
|
-
(questionInstruction ? `\n\n${questionInstruction.trim()}` : "")
|
|
1299
|
+
(questionInstruction ? `\n\n${questionInstruction.trim()}` : "") +
|
|
1300
|
+
(heldInstruction ? `\n\n${heldInstruction}` : ""),
|
|
1257
1301
|
};
|
|
1258
1302
|
}
|
|
1259
1303
|
|
|
@@ -1264,7 +1308,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1264
1308
|
return {
|
|
1265
1309
|
systemPrompt:
|
|
1266
1310
|
`${event.systemPrompt}\n\n` +
|
|
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
|
|
1311
|
+
`Pi Better Goal active objective: ${goal.objective}. ${heldInstruction || "Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit."}${backgroundInstruction}${questionInstruction}` +
|
|
1268
1312
|
(permissionPrompt ? `\n\n${permissionPrompt}` : ""),
|
|
1269
1313
|
};
|
|
1270
1314
|
});
|
|
@@ -1395,6 +1439,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1395
1439
|
return;
|
|
1396
1440
|
}
|
|
1397
1441
|
const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
|
|
1442
|
+
if (previous.blocked) return;
|
|
1398
1443
|
const repeated = previous.lastEvidenceSignature === evidence.signature;
|
|
1399
1444
|
const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
|
|
1400
1445
|
const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
|
|
@@ -1415,7 +1460,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1415
1460
|
if (ctx.hasUI) {
|
|
1416
1461
|
ctx.ui.setStatus(EXTENSION_NAME, "waiting: no progress");
|
|
1417
1462
|
ctx.ui.notify(
|
|
1418
|
-
`Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}).
|
|
1463
|
+
`Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}). Use /goal resume or alt+g to resume it.`,
|
|
1419
1464
|
"warning",
|
|
1420
1465
|
);
|
|
1421
1466
|
}
|
|
@@ -1425,6 +1470,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1425
1470
|
});
|
|
1426
1471
|
|
|
1427
1472
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1473
|
+
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
1474
|
+
|
|
1428
1475
|
executionGeneration += 1;
|
|
1429
1476
|
sessionGeneration += 1;
|
|
1430
1477
|
escapeAbortSuppression = undefined;
|
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
|
+
}
|