pi-better-goal 0.6.0 → 0.7.0
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 +3 -2
- package/package.json +1 -1
- package/src/index.ts +99 -21
- package/src/preferences.ts +70 -0
- package/src/settings-page.ts +67 -0
package/README.md
CHANGED
|
@@ -13,11 +13,12 @@ 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
|
-
- `
|
|
16
|
+
- `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. Both controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool. Goal kickoff, `/goal resume`, and `alt+g` remain available with either control off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off` and `/goal settings conversational-resume on|off`; bare `/goal settings` prints the saved values outside the TUI.
|
|
17
|
+
- `escape` pauses the active goal, and it stays paused while you talk to the agent. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
|
|
17
18
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
18
19
|
- A compact goal widget that does not replace Pi's footer.
|
|
19
20
|
- Background activity tracking for subagents and other registered providers.
|
|
20
|
-
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (
|
|
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
22
|
- An observable-progress stall state for active goals.
|
|
22
23
|
|
|
23
24
|
## Skill-Owned Workflows
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
|
|
6
|
+
import { goalPreferencesPath, readGoalPreferences, writeGoalPreference, type GoalPreferences } from "./preferences.js";
|
|
7
|
+
import { createGoalSettingsPage } from "./settings-page.js";
|
|
6
8
|
|
|
7
9
|
import {
|
|
8
10
|
collectActivitySnapshot,
|
|
@@ -50,7 +52,7 @@ import {
|
|
|
50
52
|
} from "./types.js";
|
|
51
53
|
|
|
52
54
|
const POLL_INTERVAL_MS = 2_000;
|
|
53
|
-
const DEFAULT_IDLE_CONTINUATION_DELAY_MS =
|
|
55
|
+
const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 60_000;
|
|
54
56
|
const DEFAULT_MAX_NO_PROGRESS_RETRIES = 10;
|
|
55
57
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
56
58
|
const WAKE_DISABLED =
|
|
@@ -82,20 +84,20 @@ const GOAL_RESUME_RULE =
|
|
|
82
84
|
"or answers a decision you explicitly asked for in your previous message with a choice that means proceed. Never call it for questions, \"why...\", \"what about...\", \"let me think\", or discussion.";
|
|
83
85
|
|
|
84
86
|
/** True when the agent may resume this goal with `goal_resume`. */
|
|
85
|
-
export function agentResumable(goal: GoalSnapshot | null): boolean {
|
|
86
|
-
return goal?.status === "paused" && goal.pauseReason === "interrupt";
|
|
87
|
+
export function agentResumable(goal: GoalSnapshot | null, conversationalResume = true): boolean {
|
|
88
|
+
return conversationalResume && goal?.status === "paused" && goal.pauseReason === "interrupt";
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/** Footer status for a paused goal, telling the user how to resume it. */
|
|
90
|
-
export function pausedGoalStatus(goal: GoalSnapshot): string {
|
|
91
|
-
return agentResumable(goal) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
|
|
92
|
+
export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
|
|
93
|
+
return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
function pausedGoalPrompt(goal: GoalSnapshot): string {
|
|
96
|
+
function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean): string {
|
|
95
97
|
return [
|
|
96
98
|
`Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
|
|
97
99
|
"Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
|
|
98
|
-
GOAL_RESUME_RULE,
|
|
100
|
+
conversationalResume ? GOAL_RESUME_RULE : "Only the user can resume this goal, with /goal resume or alt+g. A conversational go-ahead does not resume it.",
|
|
99
101
|
].join("\n");
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -106,10 +108,21 @@ const GOAL_ACTIONS: readonly AutocompleteItem[] = [
|
|
|
106
108
|
{ value: "resume", label: "resume", description: "Resume the paused goal" },
|
|
107
109
|
{ value: "clear", label: "clear", description: "Remove the current goal" },
|
|
108
110
|
{ value: "complete", label: "complete", description: "Mark the current goal complete" },
|
|
111
|
+
{ value: "settings", label: "settings", description: "Inspect or persist goal continuation and conversational resume controls" },
|
|
109
112
|
];
|
|
110
113
|
|
|
114
|
+
const GOAL_SETTINGS = ["auto-continue", "conversational-resume"] as const;
|
|
115
|
+
|
|
111
116
|
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
112
117
|
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
118
|
+
if (prefix.startsWith("settings ")) {
|
|
119
|
+
const settingPrefix = prefix.slice("settings ".length);
|
|
120
|
+
const choices = GOAL_SETTINGS.flatMap((setting) => settingPrefix.startsWith(`${setting} `)
|
|
121
|
+
? ["on", "off"].map((mode) => `settings ${setting} ${mode}`)
|
|
122
|
+
: [`settings ${setting}`]);
|
|
123
|
+
const matches = choices.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
|
|
124
|
+
return matches.length > 0 ? matches : null;
|
|
125
|
+
}
|
|
113
126
|
const matches = GOAL_ACTIONS.filter((action) => action.value.startsWith(prefix));
|
|
114
127
|
return matches.length > 0 ? [...matches] : null;
|
|
115
128
|
}
|
|
@@ -124,7 +137,7 @@ function parseDurationEnv(raw: string | undefined, fallback: number): number {
|
|
|
124
137
|
|
|
125
138
|
/**
|
|
126
139
|
* Linear backoff for no-progress retries: each identical outcome waits one more
|
|
127
|
-
* grace period than the last (
|
|
140
|
+
* grace period than the last (60s, 120s, 180s, ... by default).
|
|
128
141
|
*/
|
|
129
142
|
export function idleContinuationDelay(noProgressRetries: number, baseMs = IDLE_CONTINUATION_DELAY_MS): number {
|
|
130
143
|
return Math.min(MAX_TIMER_DELAY_MS, baseMs * (1 + Math.max(0, noProgressRetries)));
|
|
@@ -214,9 +227,11 @@ function formatGoal(
|
|
|
214
227
|
goal: GoalSnapshot | null,
|
|
215
228
|
continuation: ReturnType<typeof currentContinuationState> = null,
|
|
216
229
|
stall = observeGoalStall(goal, continuation),
|
|
230
|
+
preferences: GoalPreferences = { autoContinue: true, conversationalResume: true },
|
|
217
231
|
): string {
|
|
232
|
+
const settings = formatPreferences(preferences);
|
|
218
233
|
if (!goal) {
|
|
219
|
-
return
|
|
234
|
+
return `No goal is set.\n${settings}`;
|
|
220
235
|
}
|
|
221
236
|
const budget = goal.tokenBudget === null ? "none" : String(goal.tokenBudget);
|
|
222
237
|
const timing = goalTiming(goal);
|
|
@@ -232,14 +247,22 @@ function formatGoal(
|
|
|
232
247
|
`Elapsed time: ${timing.elapsedSeconds}s`,
|
|
233
248
|
`Observable progress: ${stall?.state ?? "unknown"}`,
|
|
234
249
|
continuationStatus,
|
|
250
|
+
settings,
|
|
235
251
|
...(goal.status === "paused"
|
|
236
|
-
? [agentResumable(goal)
|
|
252
|
+
? [agentResumable(goal, preferences.conversationalResume)
|
|
237
253
|
? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
|
|
238
254
|
: "Resume: /goal resume or alt+g"]
|
|
239
255
|
: []),
|
|
240
256
|
].join("\n");
|
|
241
257
|
}
|
|
242
258
|
|
|
259
|
+
function formatPreferences(preferences: GoalPreferences): string {
|
|
260
|
+
return [
|
|
261
|
+
`Automatic continuation setting: ${preferences.autoContinue ? "on" : "off"}${WAKE_DISABLED ? " (disabled by environment)" : ""}`,
|
|
262
|
+
`Conversational resume setting: ${preferences.conversationalResume ? "on" : "off"}`,
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
243
266
|
function formatSnapshot(snapshot: ActivitySnapshot): string {
|
|
244
267
|
const providers = snapshot.providers.map((provider) => {
|
|
245
268
|
const active = provider.items.filter((item) => item.active).length;
|
|
@@ -284,6 +307,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
284
307
|
let idleContinuationSignature = "";
|
|
285
308
|
let refreshGoalWidget: ((force?: boolean) => void) | undefined;
|
|
286
309
|
let lastAgentEvidence: ContinuationEvidence | null = null;
|
|
310
|
+
let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true };
|
|
287
311
|
/** Background items active when each pending blocking question started, keyed by tool call id. */
|
|
288
312
|
const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
|
|
289
313
|
|
|
@@ -387,7 +411,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
387
411
|
return;
|
|
388
412
|
}
|
|
389
413
|
setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
|
|
390
|
-
notifyGoal(ctx,
|
|
414
|
+
notifyGoal(ctx, preferences.conversationalResume
|
|
415
|
+
? 'Goal paused. Say "go" to resume it, or use /goal resume.'
|
|
416
|
+
: "Goal paused. Use /goal resume or alt+g to resume it.");
|
|
391
417
|
};
|
|
392
418
|
|
|
393
419
|
/** Why a paused goal cannot become active again, or null when it can. */
|
|
@@ -470,7 +496,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
470
496
|
kind: "continuation" | "background-drained",
|
|
471
497
|
snapshot?: ActivitySnapshot,
|
|
472
498
|
): void => {
|
|
473
|
-
if (WAKE_DISABLED || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
|
|
499
|
+
if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
|
|
474
500
|
return;
|
|
475
501
|
}
|
|
476
502
|
if (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
@@ -500,7 +526,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
500
526
|
const execution = executionGeneration;
|
|
501
527
|
const wake = wakeGeneration;
|
|
502
528
|
const auditIsCurrent = (): boolean => {
|
|
503
|
-
if (execution !== executionGeneration || wake !== wakeGeneration) return false;
|
|
529
|
+
if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
|
|
504
530
|
const current = currentGoalSnapshot(ctx);
|
|
505
531
|
return isPokeable(current) && current.goalId === goalId &&
|
|
506
532
|
continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
|
|
@@ -603,7 +629,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
603
629
|
const background = snapshot?.backgroundRunning
|
|
604
630
|
? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
|
|
605
631
|
: undefined;
|
|
606
|
-
if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal)} · ${background}` : pausedGoalStatus(goal);
|
|
632
|
+
if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal, preferences.conversationalResume)} · ${background}` : pausedGoalStatus(goal, preferences.conversationalResume);
|
|
607
633
|
if (background) return background;
|
|
608
634
|
const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
|
|
609
635
|
if (continuation?.blocked) return "waiting: no progress";
|
|
@@ -633,7 +659,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
633
659
|
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
634
660
|
try {
|
|
635
661
|
const active = pi.getActiveTools();
|
|
636
|
-
const wanted = agentResumable(goal);
|
|
662
|
+
const wanted = agentResumable(goal, preferences.conversationalResume);
|
|
637
663
|
if (active.includes(GOAL_RESUME_TOOL) === wanted) return;
|
|
638
664
|
pi.setActiveTools(wanted ? [...active, GOAL_RESUME_TOOL] : active.filter((name) => name !== GOAL_RESUME_TOOL));
|
|
639
665
|
} catch {
|
|
@@ -760,17 +786,57 @@ export default function (pi: ExtensionAPI): void {
|
|
|
760
786
|
});
|
|
761
787
|
|
|
762
788
|
pi.registerCommand("goal", {
|
|
763
|
-
description: "Create, inspect, pause, resume, clear, or complete the active goal",
|
|
789
|
+
description: "Create, inspect, pause, resume, clear, or complete the active goal; configure persistent goal settings",
|
|
764
790
|
getArgumentCompletions: goalArgumentCompletions,
|
|
765
791
|
handler: async (args, ctx) => {
|
|
766
792
|
currentCtx = ctx;
|
|
767
793
|
const trimmed = args.trim();
|
|
768
794
|
const current = getGoal(ctx);
|
|
769
795
|
|
|
796
|
+
if (trimmed === "settings" || trimmed.startsWith("settings ")) {
|
|
797
|
+
const changePreference = async (key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
|
|
798
|
+
const previous = preferences;
|
|
799
|
+
preferences = writeGoalPreference(key, enabled);
|
|
800
|
+
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
801
|
+
syncResumeTool(getGoal(ctx));
|
|
802
|
+
applyStatus(ctx);
|
|
803
|
+
if (!previous.autoContinue && preferences.autoContinue) {
|
|
804
|
+
const snapshot = await publishSnapshot(ctx);
|
|
805
|
+
const goal = getGoal(ctx);
|
|
806
|
+
if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
|
|
807
|
+
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
if (trimmed === "settings" && ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
|
|
812
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
|
|
813
|
+
get: () => ({ ...preferences }),
|
|
814
|
+
change: changePreference,
|
|
815
|
+
}, () => tui.requestRender(), () => done()));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const [, setting, mode, ...extra] = trimmed.split(/\s+/);
|
|
819
|
+
if (setting !== undefined && (!GOAL_SETTINGS.includes(setting as typeof GOAL_SETTINGS[number]) ||
|
|
820
|
+
(mode !== "on" && mode !== "off") || extra.length > 0)) {
|
|
821
|
+
notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume on|off]", "warning");
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (setting !== undefined) {
|
|
825
|
+
try {
|
|
826
|
+
await changePreference(setting === "auto-continue" ? "autoContinue" : "conversationalResume", mode === "on");
|
|
827
|
+
} catch (error) {
|
|
828
|
+
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
ctx.ui.notify(`${formatPreferences(preferences)}\nPreferences: ${goalPreferencesPath()}`, "info");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
770
836
|
if (!trimmed) {
|
|
771
837
|
const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
|
|
772
838
|
// Inspection is always explicit user intent; show the full summary even while busy.
|
|
773
|
-
ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning })), "info");
|
|
839
|
+
ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences), "info");
|
|
774
840
|
return;
|
|
775
841
|
}
|
|
776
842
|
|
|
@@ -847,8 +913,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
847
913
|
backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
|
|
848
914
|
});
|
|
849
915
|
return {
|
|
850
|
-
content: [{ type: "text", text: formatGoal(goal, continuation, stall) }],
|
|
851
|
-
details: { goal, continuation, stall, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
|
|
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 },
|
|
852
918
|
};
|
|
853
919
|
},
|
|
854
920
|
});
|
|
@@ -904,7 +970,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
904
970
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
905
971
|
const reason = (params as { reason?: string }).reason;
|
|
906
972
|
const current = getGoal(ctx);
|
|
907
|
-
if (!
|
|
973
|
+
if (!preferences.conversationalResume) {
|
|
974
|
+
return {
|
|
975
|
+
content: [{ type: "text", text: "Conversational goal resume is disabled. Only the user can resume with /goal resume or alt+g." }],
|
|
976
|
+
details: { ok: false, goal: current },
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (!agentResumable(current, preferences.conversationalResume)) {
|
|
908
980
|
const text = current?.status === "paused"
|
|
909
981
|
? "This goal was paused with /goal pause or by the runtime. Only the user can resume it, with /goal resume."
|
|
910
982
|
: "No goal is paused, so there is nothing to resume.";
|
|
@@ -996,6 +1068,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
996
1068
|
collectionPending = false;
|
|
997
1069
|
currentCtx = ctx;
|
|
998
1070
|
foregroundRunning = !ctx.isIdle();
|
|
1071
|
+
try {
|
|
1072
|
+
preferences = readGoalPreferences();
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1075
|
+
}
|
|
999
1076
|
const restoredGoal = getGoal(ctx);
|
|
1000
1077
|
if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
|
|
1001
1078
|
setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
|
|
@@ -1046,7 +1123,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1046
1123
|
const owner = getWorkflow(ctx);
|
|
1047
1124
|
const snapshot = await publishSnapshot(ctx);
|
|
1048
1125
|
if (!snapshot) return;
|
|
1049
|
-
const pausedInstruction =
|
|
1126
|
+
const pausedInstruction = goal?.status === "paused" && goal.pauseReason === "interrupt"
|
|
1127
|
+
? pausedGoalPrompt(goal, preferences.conversationalResume) : "";
|
|
1050
1128
|
if (!isPokeable(goal) && !owner) {
|
|
1051
1129
|
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
1052
1130
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface GoalPreferences {
|
|
6
|
+
autoContinue: boolean;
|
|
7
|
+
conversationalResume: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GoalPreferenceSeams {
|
|
11
|
+
agentDir?: () => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function goalPreferencesPath(seams: GoalPreferenceSeams = {}): string {
|
|
15
|
+
return join((seams.agentDir ?? getAgentDir)(), "extensions", "pi-better-goal-preferences.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPreferences {
|
|
19
|
+
const path = goalPreferencesPath(seams);
|
|
20
|
+
let raw: string;
|
|
21
|
+
try {
|
|
22
|
+
raw = readFileSync(path, "utf8");
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
25
|
+
return { autoContinue: true, conversationalResume: true };
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Goal preferences at ${path} could not be read: ${messageOf(error)}`);
|
|
28
|
+
}
|
|
29
|
+
let parsed: unknown;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(raw);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`Goal preferences at ${path} are not valid JSON: ${messageOf(error)}`);
|
|
34
|
+
}
|
|
35
|
+
const value = parsed as Partial<GoalPreferences> & { version?: unknown } | null;
|
|
36
|
+
if (value?.version !== 1 ||
|
|
37
|
+
(value.autoContinue !== undefined && typeof value.autoContinue !== "boolean") ||
|
|
38
|
+
(value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean")) {
|
|
39
|
+
throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume settings.`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
autoContinue: value.autoContinue ?? true,
|
|
43
|
+
conversationalResume: value.conversationalResume ?? true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Persist one control without replacing the other session's saved choice. */
|
|
48
|
+
export function writeGoalPreference(
|
|
49
|
+
key: keyof GoalPreferences,
|
|
50
|
+
enabled: boolean,
|
|
51
|
+
seams: GoalPreferenceSeams = {},
|
|
52
|
+
): GoalPreferences {
|
|
53
|
+
const preferences = { ...readGoalPreferences(seams), [key]: enabled };
|
|
54
|
+
const path = goalPreferencesPath(seams);
|
|
55
|
+
const pending = `${path}.${process.pid}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
58
|
+
writeFileSync(pending, `${JSON.stringify({ version: 1, ...preferences }, null, 2)}\n`, "utf8");
|
|
59
|
+
renameSync(pending, path);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new Error(`Goal preferences at ${path} could not be written: ${messageOf(error)}`);
|
|
62
|
+
} finally {
|
|
63
|
+
rmSync(pending, { force: true });
|
|
64
|
+
}
|
|
65
|
+
return preferences;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function messageOf(error: unknown): string {
|
|
69
|
+
return error instanceof Error ? error.message : String(error);
|
|
70
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { GoalPreferences } from "./preferences.js";
|
|
4
|
+
|
|
5
|
+
const rows: { key: keyof GoalPreferences; label: string }[] = [
|
|
6
|
+
{ key: "autoContinue", label: "Automatic continuation" },
|
|
7
|
+
{ key: "conversationalResume", label: "Conversational resume" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function createGoalSettingsPage(
|
|
11
|
+
theme: Theme,
|
|
12
|
+
handlers: { get(): GoalPreferences; change(key: keyof GoalPreferences, enabled: boolean): Promise<void> },
|
|
13
|
+
requestRender: () => void,
|
|
14
|
+
close: () => void,
|
|
15
|
+
): Component & Focusable {
|
|
16
|
+
let row = 0;
|
|
17
|
+
let busy = false;
|
|
18
|
+
let message = "";
|
|
19
|
+
let error = false;
|
|
20
|
+
function change(): void {
|
|
21
|
+
const selected = rows[row]!;
|
|
22
|
+
busy = true;
|
|
23
|
+
message = "Saving...";
|
|
24
|
+
error = false;
|
|
25
|
+
void Promise.resolve().then(() => handlers.change(selected.key, !handlers.get()[selected.key])).then(() => {
|
|
26
|
+
message = "Saved.";
|
|
27
|
+
}, (cause: unknown) => {
|
|
28
|
+
message = cause instanceof Error ? cause.message : String(cause);
|
|
29
|
+
error = true;
|
|
30
|
+
}).finally(() => {
|
|
31
|
+
busy = false;
|
|
32
|
+
requestRender();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
focused: false,
|
|
37
|
+
invalidate() {},
|
|
38
|
+
handleInput(data: string) {
|
|
39
|
+
if (matchesKey(data, Key.escape)) { close(); return; }
|
|
40
|
+
if (busy) return;
|
|
41
|
+
if (matchesKey(data, Key.up)) row = Math.max(0, row - 1);
|
|
42
|
+
else if (matchesKey(data, Key.down)) row = Math.min(rows.length - 1, row + 1);
|
|
43
|
+
else if (matchesKey(data, Key.enter) || data === " ") change();
|
|
44
|
+
requestRender();
|
|
45
|
+
},
|
|
46
|
+
render(width: number) {
|
|
47
|
+
const w = Math.max(1, Math.floor(width));
|
|
48
|
+
const preferences = handlers.get();
|
|
49
|
+
const lines = [theme.fg("accent", theme.bold(" Goal settings")), ""];
|
|
50
|
+
rows.forEach(({ key, label }, index) => {
|
|
51
|
+
const selected = row === index;
|
|
52
|
+
const title = theme.fg(selected ? "accent" : "text", (selected ? "> " : " ") + label);
|
|
53
|
+
const value = preferences[key] ? "On" : "Off";
|
|
54
|
+
const active = selected ? theme.inverse(value) : theme.fg("text", value);
|
|
55
|
+
if (w < 40) {
|
|
56
|
+
lines.push(title, " " + active);
|
|
57
|
+
} else {
|
|
58
|
+
const line = title + " ".repeat(Math.max(1, 30 - visibleWidth(title))) + active;
|
|
59
|
+
lines.push(selected ? theme.bg("selectedBg", theme.bold(line)) : line);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
if (message) lines.push("", theme.fg(error ? "error" : "muted", message));
|
|
63
|
+
lines.push("", theme.fg("dim", "Up/Down Select · Space/Enter Toggle · Esc Back"));
|
|
64
|
+
return lines.map((line) => truncateToWidth(line, w, ""));
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|