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 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. 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 and conversational resume controls" },
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
- await changePreference(setting === "auto-continue" ? "autoContinue" : "conversationalResume", mode === "on");
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
- pauseGoalOnInterrupt(ctx);
1240
+ onAbort();
1183
1241
  } else {
1184
- turnSignal.addEventListener("abort", () => pauseGoalOnInterrupt(ctx), { once: true });
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
- // `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
1193
- // cannot register it. Observe the interrupt instead: when a running turn is
1194
- // aborted (escape / ctrl+c while streaming), pause the active goal so it does
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();
@@ -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
- throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume settings.`);
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 the other session's saved choice. */
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,
@@ -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(