pi-better-goal 0.7.0 → 0.8.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 +2 -2
- package/package.json +1 -1
- package/src/index.ts +77 -14
- package/src/preferences.ts +7 -4
- package/src/settings-page.ts +1 -0
package/README.md
CHANGED
|
@@ -13,8 +13,8 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
13
13
|
## Core Features
|
|
14
14
|
|
|
15
15
|
- `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
|
|
16
|
-
- `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page.
|
|
17
|
-
- `escape` pauses the active goal, and it stays paused while you talk to the agent. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
|
|
16
|
+
- `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. All three controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool; **Pause on Esc** controls whether editor Escape pauses the active goal. Goal kickoff, `/goal resume`, and `alt+g` remain available with these controls off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off`, `/goal settings conversational-resume on|off`, and `/goal settings pause-on-escape on|off`; bare `/goal settings` prints the saved values outside the TUI.
|
|
17
|
+
- `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
|
|
18
18
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
19
19
|
- A compact goal widget that does not replace Pi's footer.
|
|
20
20
|
- Background activity tracking for subagents and other registered providers.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { CustomEditor, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui";
|
|
3
4
|
import { readFileSync } from "node:fs";
|
|
4
5
|
import { Type } from "typebox";
|
|
5
6
|
import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
|
|
@@ -108,10 +109,10 @@ const GOAL_ACTIONS: readonly AutocompleteItem[] = [
|
|
|
108
109
|
{ value: "resume", label: "resume", description: "Resume the paused goal" },
|
|
109
110
|
{ value: "clear", label: "clear", description: "Remove the current goal" },
|
|
110
111
|
{ value: "complete", label: "complete", description: "Mark the current goal complete" },
|
|
111
|
-
{ value: "settings", label: "settings", description: "Inspect or persist goal continuation
|
|
112
|
+
{ value: "settings", label: "settings", description: "Inspect or persist goal continuation, conversational resume, and Escape pause controls" },
|
|
112
113
|
];
|
|
113
114
|
|
|
114
|
-
const GOAL_SETTINGS = ["auto-continue", "conversational-resume"] as const;
|
|
115
|
+
const GOAL_SETTINGS = ["auto-continue", "conversational-resume", "pause-on-escape"] as const;
|
|
115
116
|
|
|
116
117
|
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
117
118
|
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
@@ -227,7 +228,7 @@ function formatGoal(
|
|
|
227
228
|
goal: GoalSnapshot | null,
|
|
228
229
|
continuation: ReturnType<typeof currentContinuationState> = null,
|
|
229
230
|
stall = observeGoalStall(goal, continuation),
|
|
230
|
-
preferences: GoalPreferences = { autoContinue: true, conversationalResume: true },
|
|
231
|
+
preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true },
|
|
231
232
|
): string {
|
|
232
233
|
const settings = formatPreferences(preferences);
|
|
233
234
|
if (!goal) {
|
|
@@ -260,6 +261,7 @@ function formatPreferences(preferences: GoalPreferences): string {
|
|
|
260
261
|
return [
|
|
261
262
|
`Automatic continuation setting: ${preferences.autoContinue ? "on" : "off"}${WAKE_DISABLED ? " (disabled by environment)" : ""}`,
|
|
262
263
|
`Conversational resume setting: ${preferences.conversationalResume ? "on" : "off"}`,
|
|
264
|
+
`Pause on Esc setting: ${preferences.pauseOnEscape ? "on" : "off"}`,
|
|
263
265
|
].join("\n");
|
|
264
266
|
}
|
|
265
267
|
|
|
@@ -291,6 +293,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
291
293
|
let currentCtx: ExtensionContext | undefined;
|
|
292
294
|
let executionGeneration = 0;
|
|
293
295
|
let agentGeneration = -1;
|
|
296
|
+
let turnGeneration = 0;
|
|
297
|
+
let escapeAbortSuppression: { session: number; turn: number; execution: number } | undefined;
|
|
294
298
|
let sessionGeneration = 0;
|
|
295
299
|
let wakeGeneration = 0;
|
|
296
300
|
let snapshotSequence = 0;
|
|
@@ -306,8 +310,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
306
310
|
let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
307
311
|
let idleContinuationSignature = "";
|
|
308
312
|
let refreshGoalWidget: ((force?: boolean) => void) | undefined;
|
|
313
|
+
let terminalInputUnsubscribe: (() => void) | undefined;
|
|
314
|
+
let terminalInputIsEditor: (() => boolean) | undefined;
|
|
315
|
+
let uiPromptDepth = 0;
|
|
309
316
|
let lastAgentEvidence: ContinuationEvidence | null = null;
|
|
310
|
-
let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true };
|
|
317
|
+
let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
|
|
311
318
|
/** Background items active when each pending blocking question started, keyed by tool call id. */
|
|
312
319
|
const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
|
|
313
320
|
|
|
@@ -416,6 +423,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
416
423
|
: "Goal paused. Use /goal resume or alt+g to resume it.");
|
|
417
424
|
};
|
|
418
425
|
|
|
426
|
+
const escapeAbortIsSuppressed = (): boolean =>
|
|
427
|
+
escapeAbortSuppression?.session === sessionGeneration &&
|
|
428
|
+
escapeAbortSuppression.turn === turnGeneration &&
|
|
429
|
+
escapeAbortSuppression.execution === executionGeneration;
|
|
430
|
+
|
|
419
431
|
/** Why a paused goal cannot become active again, or null when it can. */
|
|
420
432
|
const resumeBlocker = (goal: GoalSnapshot): string | null => {
|
|
421
433
|
if (!goal.command && skillCommandName(goal.objective)) {
|
|
@@ -723,6 +735,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
723
735
|
ctx.ui.setWidget(
|
|
724
736
|
EXTENSION_NAME,
|
|
725
737
|
(tui, theme) => {
|
|
738
|
+
const inputIsEditor = (): boolean => {
|
|
739
|
+
const focus = tui as typeof tui & { getFocusedComponent?(): Partial<CustomEditor> | null };
|
|
740
|
+
if (typeof focus.getFocusedComponent !== "function" || tui.hasOverlay()) return false;
|
|
741
|
+
// Duck typing survives Pi's bundled/jiti module boundaries. Unknown
|
|
742
|
+
// custom editors and dialog components keep ownership of Escape.
|
|
743
|
+
const editor = focus.getFocusedComponent();
|
|
744
|
+
return typeof editor?.onEscape === "function" &&
|
|
745
|
+
typeof editor.isShowingAutocomplete === "function" && !editor.isShowingAutocomplete();
|
|
746
|
+
};
|
|
747
|
+
terminalInputIsEditor = inputIsEditor;
|
|
726
748
|
const requestRender = (force = false): void => {
|
|
727
749
|
try {
|
|
728
750
|
tui.requestRender(force);
|
|
@@ -746,6 +768,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
746
768
|
return {
|
|
747
769
|
dispose() {
|
|
748
770
|
scheduler.dispose();
|
|
771
|
+
if (terminalInputIsEditor === inputIsEditor) terminalInputIsEditor = undefined;
|
|
749
772
|
if (refreshGoalWidget === refresh) {
|
|
750
773
|
refreshGoalWidget = undefined;
|
|
751
774
|
}
|
|
@@ -818,12 +841,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
818
841
|
const [, setting, mode, ...extra] = trimmed.split(/\s+/);
|
|
819
842
|
if (setting !== undefined && (!GOAL_SETTINGS.includes(setting as typeof GOAL_SETTINGS[number]) ||
|
|
820
843
|
(mode !== "on" && mode !== "off") || extra.length > 0)) {
|
|
821
|
-
notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume on|off]", "warning");
|
|
844
|
+
notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume|pause-on-escape on|off]", "warning");
|
|
822
845
|
return;
|
|
823
846
|
}
|
|
824
847
|
if (setting !== undefined) {
|
|
825
848
|
try {
|
|
826
|
-
|
|
849
|
+
const key = setting === "auto-continue" ? "autoContinue"
|
|
850
|
+
: setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
|
|
851
|
+
await changePreference(key, mode === "on");
|
|
827
852
|
} catch (error) {
|
|
828
853
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
829
854
|
return;
|
|
@@ -1058,8 +1083,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1058
1083
|
});
|
|
1059
1084
|
|
|
1060
1085
|
pi.on("session_start", async (_event, ctx) => {
|
|
1086
|
+
terminalInputUnsubscribe?.();
|
|
1087
|
+
terminalInputUnsubscribe = undefined;
|
|
1088
|
+
terminalInputIsEditor = undefined;
|
|
1089
|
+
uiPromptDepth = 0;
|
|
1061
1090
|
executionGeneration += 1;
|
|
1062
1091
|
sessionGeneration += 1;
|
|
1092
|
+
escapeAbortSuppression = undefined;
|
|
1063
1093
|
clearIdleContinuation();
|
|
1064
1094
|
continuationQueuedFor = null;
|
|
1065
1095
|
backgroundDrainTracker = null;
|
|
@@ -1081,6 +1111,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1081
1111
|
pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
|
|
1082
1112
|
syncResumeTool(getGoal(ctx));
|
|
1083
1113
|
installGoalWidget(ctx);
|
|
1114
|
+
if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.onTerminalInput === "function") {
|
|
1115
|
+
const session = sessionGeneration;
|
|
1116
|
+
terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
1117
|
+
if (session === sessionGeneration && uiPromptDepth === 0 &&
|
|
1118
|
+
!isKeyRelease(data) && matchesKey(data, "escape") && terminalInputIsEditor?.()) {
|
|
1119
|
+
if (preferences.pauseOnEscape) {
|
|
1120
|
+
pauseGoalOnInterrupt(ctx);
|
|
1121
|
+
} else if (foregroundRunning) {
|
|
1122
|
+
// The original key reaches Pi next and may abort this run. Preserve
|
|
1123
|
+
// this keypress's choice through both abort notifications only.
|
|
1124
|
+
escapeAbortSuppression = { session, turn: turnGeneration, execution: executionGeneration };
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// Observe only: Pi must still interrupt streams or handle editor Escape.
|
|
1128
|
+
return undefined;
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1084
1131
|
const snapshot = await publishSnapshot(ctx);
|
|
1085
1132
|
if (!snapshot) return;
|
|
1086
1133
|
syncPollingState();
|
|
@@ -1089,6 +1136,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1089
1136
|
}
|
|
1090
1137
|
});
|
|
1091
1138
|
|
|
1139
|
+
pi.on("ui_prompt_start", () => { uiPromptDepth += 1; });
|
|
1140
|
+
pi.on("ui_prompt_end", () => { uiPromptDepth = Math.max(0, uiPromptDepth - 1); });
|
|
1141
|
+
|
|
1092
1142
|
pi.on("input", async (event, ctx) => {
|
|
1093
1143
|
if (event.source === "extension") {
|
|
1094
1144
|
return;
|
|
@@ -1170,6 +1220,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1170
1220
|
|
|
1171
1221
|
pi.on("agent_start", async (_event, ctx) => {
|
|
1172
1222
|
executionGeneration += 1;
|
|
1223
|
+
turnGeneration += 1;
|
|
1224
|
+
escapeAbortSuppression = undefined;
|
|
1173
1225
|
agentGeneration = executionGeneration;
|
|
1174
1226
|
currentCtx = ctx;
|
|
1175
1227
|
foregroundRunning = true;
|
|
@@ -1178,10 +1230,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1178
1230
|
lastAgentEvidence = null;
|
|
1179
1231
|
const turnSignal = ctx.signal;
|
|
1180
1232
|
if (turnSignal) {
|
|
1233
|
+
const session = sessionGeneration;
|
|
1234
|
+
const turn = turnGeneration;
|
|
1235
|
+
const onAbort = (): void => {
|
|
1236
|
+
if (session !== sessionGeneration || turn !== turnGeneration) return;
|
|
1237
|
+
if (!escapeAbortIsSuppressed()) pauseGoalOnInterrupt(ctx);
|
|
1238
|
+
};
|
|
1181
1239
|
if (turnSignal.aborted) {
|
|
1182
|
-
|
|
1240
|
+
onAbort();
|
|
1183
1241
|
} else {
|
|
1184
|
-
turnSignal.addEventListener("abort",
|
|
1242
|
+
turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
1185
1243
|
}
|
|
1186
1244
|
}
|
|
1187
1245
|
await publishSnapshot(ctx);
|
|
@@ -1189,13 +1247,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1189
1247
|
|
|
1190
1248
|
pi.on("agent_end", async (event, ctx) => {
|
|
1191
1249
|
lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
|
|
1195
|
-
// not auto-continue after the user stopped the agent.
|
|
1196
|
-
if (wasTurnAborted(event.messages)) {
|
|
1250
|
+
// Only an observed editor Escape with pause disabled exempts this abort.
|
|
1251
|
+
// Unknown interrupt sources retain the safety fallback on every host.
|
|
1252
|
+
if (wasTurnAborted(event.messages) && !escapeAbortIsSuppressed()) {
|
|
1197
1253
|
pauseGoalOnInterrupt(ctx);
|
|
1198
1254
|
}
|
|
1255
|
+
escapeAbortSuppression = undefined;
|
|
1199
1256
|
});
|
|
1200
1257
|
|
|
1201
1258
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
@@ -1228,6 +1285,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1228
1285
|
|
|
1229
1286
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
1230
1287
|
executionGeneration += 1;
|
|
1288
|
+
escapeAbortSuppression = undefined;
|
|
1231
1289
|
currentCtx = ctx;
|
|
1232
1290
|
foregroundRunning = false;
|
|
1233
1291
|
const snapshot = await publishSnapshot(ctx);
|
|
@@ -1274,6 +1332,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1274
1332
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1275
1333
|
executionGeneration += 1;
|
|
1276
1334
|
sessionGeneration += 1;
|
|
1335
|
+
escapeAbortSuppression = undefined;
|
|
1336
|
+
terminalInputUnsubscribe?.();
|
|
1337
|
+
terminalInputUnsubscribe = undefined;
|
|
1338
|
+
terminalInputIsEditor = undefined;
|
|
1339
|
+
uiPromptDepth = 0;
|
|
1277
1340
|
stopPolling();
|
|
1278
1341
|
foregroundRunning = false;
|
|
1279
1342
|
pendingQuestions.clear();
|
package/src/preferences.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
export interface GoalPreferences {
|
|
6
6
|
autoContinue: boolean;
|
|
7
7
|
conversationalResume: boolean;
|
|
8
|
+
pauseOnEscape: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface GoalPreferenceSeams {
|
|
@@ -22,7 +23,7 @@ export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPrefer
|
|
|
22
23
|
raw = readFileSync(path, "utf8");
|
|
23
24
|
} catch (error) {
|
|
24
25
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
25
|
-
return { autoContinue: true, conversationalResume: true };
|
|
26
|
+
return { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
|
|
26
27
|
}
|
|
27
28
|
throw new Error(`Goal preferences at ${path} could not be read: ${messageOf(error)}`);
|
|
28
29
|
}
|
|
@@ -35,16 +36,18 @@ export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPrefer
|
|
|
35
36
|
const value = parsed as Partial<GoalPreferences> & { version?: unknown } | null;
|
|
36
37
|
if (value?.version !== 1 ||
|
|
37
38
|
(value.autoContinue !== undefined && typeof value.autoContinue !== "boolean") ||
|
|
38
|
-
(value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean")
|
|
39
|
-
|
|
39
|
+
(value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean") ||
|
|
40
|
+
(value.pauseOnEscape !== undefined && typeof value.pauseOnEscape !== "boolean")) {
|
|
41
|
+
throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume/pauseOnEscape settings.`);
|
|
40
42
|
}
|
|
41
43
|
return {
|
|
42
44
|
autoContinue: value.autoContinue ?? true,
|
|
43
45
|
conversationalResume: value.conversationalResume ?? true,
|
|
46
|
+
pauseOnEscape: value.pauseOnEscape ?? true,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
/** Persist one control without replacing
|
|
50
|
+
/** Persist one control without replacing another session's saved choices. */
|
|
48
51
|
export function writeGoalPreference(
|
|
49
52
|
key: keyof GoalPreferences,
|
|
50
53
|
enabled: boolean,
|
package/src/settings-page.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { GoalPreferences } from "./preferences.js";
|
|
|
5
5
|
const rows: { key: keyof GoalPreferences; label: string }[] = [
|
|
6
6
|
{ key: "autoContinue", label: "Automatic continuation" },
|
|
7
7
|
{ key: "conversationalResume", label: "Conversational resume" },
|
|
8
|
+
{ key: "pauseOnEscape", label: "Pause on Esc" },
|
|
8
9
|
];
|
|
9
10
|
|
|
10
11
|
export function createGoalSettingsPage(
|