pi-better-harness 0.17.1 → 0.19.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.
Files changed (36) hide show
  1. package/README.md +2 -0
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +67 -4
  4. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +2 -0
  5. package/node_modules/pi-better-background-tasks/src/tools.ts +4 -1
  6. package/node_modules/pi-better-goal/README.md +2 -1
  7. package/node_modules/pi-better-goal/package.json +1 -1
  8. package/node_modules/pi-better-goal/src/index.ts +168 -27
  9. package/node_modules/pi-better-goal/src/preferences.ts +73 -0
  10. package/node_modules/pi-better-goal/src/settings-page.ts +68 -0
  11. package/node_modules/pi-better-plan/README.md +2 -0
  12. package/node_modules/pi-better-plan/package.json +3 -2
  13. package/node_modules/pi-better-sandbox/README.md +76 -9
  14. package/node_modules/pi-better-sandbox/index.ts +1 -0
  15. package/node_modules/pi-better-sandbox/package.json +3 -2
  16. package/node_modules/pi-better-sandbox/permissions-page.ts +4 -4
  17. package/node_modules/pi-better-sandbox/permissions.ts +10 -4
  18. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +2 -0
  19. package/node_modules/pi-better-sandbox/shared-task-process-list.ts +203 -0
  20. package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +8 -1
  21. package/node_modules/pi-better-sandbox/shared-task-tools.ts +1 -1
  22. package/node_modules/pi-better-ssh/README.md +2 -0
  23. package/node_modules/pi-better-ssh/package.json +3 -2
  24. package/node_modules/pi-better-subagents/docs/usage.md +26 -10
  25. package/node_modules/pi-better-subagents/index.ts +10 -2
  26. package/node_modules/pi-better-subagents/package.json +1 -1
  27. package/node_modules/pi-better-subagents/permission-policy.ts +4 -1
  28. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +67 -4
  29. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +2 -0
  30. package/node_modules/pi-better-subagents/shared-task-process-list.ts +203 -0
  31. package/node_modules/pi-better-subagents/shared-task-sandbox.ts +8 -1
  32. package/node_modules/pi-better-subagents/shared-task-tools.ts +1 -1
  33. package/node_modules/pi-better-subagents/subagent-tools.ts +5 -0
  34. package/node_modules/pi-better-subagents/task-guard.ts +3 -1
  35. package/node_modules/pi-better-subagents/task-policy.ts +4 -1
  36. package/package.json +7 -7
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-better-harness
2
2
 
3
+ <img src="https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/brand/logo.png" alt="Pi Better Harness logo" width="128" height="128" />
4
+
3
5
  `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks, synchronous SSH commands, goal tracking, and structured plans.
4
6
 
5
7
  ## Quick Answer
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,6 +3,8 @@ export type CallbackSource = "subagent" | "background-task";
3
3
  export type CallbackDetailTool = "subagent_result" | "bg_task_status";
4
4
 
5
5
  export interface CallbackBatchHost {
6
+ /** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
7
+ events?: object;
6
8
  sendMessage(
7
9
  message: { customType: string; content: string; display: boolean },
8
10
  options: Record<string, unknown>,
@@ -66,6 +68,8 @@ export interface UrgentCallbackEvent {
66
68
  export interface CallbackBatcherOptions {
67
69
  windowMs?: number;
68
70
  retryMs?: number;
71
+ /** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
72
+ isAvailable?: () => boolean;
69
73
  /** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
70
74
  maxBytes?: number;
71
75
  }
@@ -73,6 +77,10 @@ export interface CallbackBatcherOptions {
73
77
  export interface CallbackBatcher {
74
78
  enqueue(event: CallbackBatchEvent): boolean;
75
79
  flush(): Promise<boolean>;
80
+ /** Refresh the API wrapper after reload without losing handoff receipts. */
81
+ setHost(host: CallbackBatchHost): void;
82
+ /** Update the session predicate and schedule an asynchronous drain when available. */
83
+ setAvailability(isAvailable: () => boolean): void;
76
84
  deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
77
85
  cancel(): void;
78
86
  pendingCount(): number;
@@ -95,6 +103,7 @@ interface PendingEvent {
95
103
 
96
104
  interface SharedCallbackBatcherState {
97
105
  byHost: WeakMap<object, CallbackBatcher>;
106
+ bySession?: WeakMap<object, CallbackBatcher>;
98
107
  }
99
108
 
100
109
  const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
@@ -435,6 +444,19 @@ export function createCallbackBatcher(
435
444
  let sequence = 0;
436
445
  let timer: ReturnType<typeof setTimeout> | undefined;
437
446
  let flushPromise: Promise<boolean> | undefined;
447
+ let isAvailable = options.isAvailable ?? (() => true);
448
+ let gated = options.isAvailable !== undefined;
449
+ let awaitingRun = false;
450
+
451
+ const available = (): boolean => {
452
+ try {
453
+ if (!isAvailable()) {
454
+ awaitingRun = false;
455
+ return false;
456
+ }
457
+ return !awaitingRun;
458
+ } catch { return false; }
459
+ };
438
460
 
439
461
  const cancelTimer = (): void => {
440
462
  if (timer) clearTimeout(timer);
@@ -443,10 +465,12 @@ export function createCallbackBatcher(
443
465
 
444
466
  const schedule = (delayMs: number): void => {
445
467
  if (timer || pending.size === 0) return;
468
+ // Some idle transitions (manual compaction) have no agent_settled event.
469
+ const delay = available() ? delayMs : retryMs;
446
470
  timer = setTimeout(() => {
447
471
  timer = undefined;
448
472
  void api.flush();
449
- }, Math.max(0, delayMs));
473
+ }, Math.max(1, delay));
450
474
  timer.unref?.();
451
475
  };
452
476
 
@@ -461,6 +485,10 @@ export function createCallbackBatcher(
461
485
 
462
486
  const performFlush = async (): Promise<boolean> => {
463
487
  cancelTimer();
488
+ if (!available()) {
489
+ schedule(retryMs);
490
+ return false;
491
+ }
464
492
  const snapshot = [...pending.entries()]
465
493
  .sort((a, b) => a[1].sequence - b[1].sequence);
466
494
  pending.clear();
@@ -509,6 +537,9 @@ export function createCallbackBatcher(
509
537
  const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
510
538
 
511
539
  try {
540
+ // Pi may defer the requested run while settled handlers finish, leaving
541
+ // isIdle() true. Permit no second handoff until a run starts.
542
+ awaitingRun = gated;
512
543
  await host.sendMessage(
513
544
  {
514
545
  customType: "background-completion-batch",
@@ -518,6 +549,7 @@ export function createCallbackBatcher(
518
549
  { deliverAs: "followUp", triggerTurn: true },
519
550
  );
520
551
  } catch {
552
+ awaitingRun = false;
521
553
  for (const [key] of deliverable) inFlight.delete(key);
522
554
  const retryItems = [...deliverable, ...pending.entries()]
523
555
  .sort((a, b) => a[1].sequence - b[1].sequence);
@@ -596,10 +628,19 @@ export function createCallbackBatcher(
596
628
  const api: CallbackBatcher = {
597
629
  enqueue,
598
630
  flush,
631
+ setHost(next) { host = next; },
632
+ setAvailability(check) {
633
+ gated = true;
634
+ isAvailable = check;
635
+ available();
636
+ cancelTimer();
637
+ schedule(windowMs);
638
+ },
599
639
  deliverUrgent,
600
640
  cancel() {
601
641
  cancelTimer();
602
642
  pending.clear();
643
+ awaitingRun = false;
603
644
  },
604
645
  pendingCount() {
605
646
  return pending.size;
@@ -613,16 +654,38 @@ export function getCallbackBatcher(
613
654
  options: CallbackBatcherOptions = {},
614
655
  ): CallbackBatcher {
615
656
  const state = globalState();
616
- const key = host as object;
657
+ const key = host.events ?? host;
617
658
  const existing = state.byHost.get(key);
618
- if (existing) return existing;
659
+ if (existing) {
660
+ existing.setHost(host);
661
+ return existing;
662
+ }
619
663
  const created = createCallbackBatcher(host, options);
620
664
  state.byHost.set(key, created);
621
665
  return created;
622
666
  }
623
667
 
624
668
  export function cancelCallbackBatch(host: CallbackBatchHost): void {
625
- globalState().byHost.get(host as object)?.cancel();
669
+ const batcher = globalState().byHost.get(host.events ?? host);
670
+ batcher?.setAvailability(() => false);
671
+ batcher?.cancel();
672
+ }
673
+
674
+ /** Called on session_start, agent_start and agent_settled by both extensions. */
675
+ export function setCallbackBatchContext(
676
+ host: CallbackBatchHost,
677
+ ctx: { isIdle(): boolean; sessionManager?: object },
678
+ ): void {
679
+ const state = globalState();
680
+ const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
681
+ const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
682
+ const batcher = shared ?? getCallbackBatcher(host);
683
+ if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
684
+ state.byHost.set(host.events ?? host, batcher);
685
+ batcher.setHost(host);
686
+ // Schedule rather than await delivery inside agent_settled: its handlers are
687
+ // still settling the previous run, and sendMessage may start a new one.
688
+ batcher.setAvailability(() => ctx.isIdle());
626
689
  }
627
690
 
628
691
  function globalState(): SharedCallbackBatcherState {
@@ -60,6 +60,8 @@ export type SandboxPermissions = {
60
60
  storedCredentials: "off" | "read" | "read-write";
61
61
  commands: boolean;
62
62
  network: boolean;
63
+ /** Fixed process_list adapter only; omitted legacy profiles keep it Off. */
64
+ processAccess?: "off" | "read";
63
65
  };
64
66
 
65
67
  /** Whether a file access level allows creating and overwriting files. */
@@ -13,7 +13,7 @@ import {
13
13
  type OutputOptions,
14
14
  } from "./output.js";
15
15
  import { readOutputControls } from "./shared-log-utils.js";
16
- import { cancelCallbackBatch } from "./shared-callback-batcher.js";
16
+ import { cancelCallbackBatch, setCallbackBatchContext } from "./shared-callback-batcher.js";
17
17
  import { inspectMeta, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
18
18
  import { awaitFirstWatchCheck, FIRST_WATCH_CHECK_WAIT_MS, resumeRunningTask, spawnTask, startWatchTask, stopTask, type WatchTaskParams } from "./runtime.js";
19
19
  import { runTaskMaintenance } from "./maintenance.js";
@@ -194,6 +194,7 @@ export function registerTools(pi: ExtensionAPI): void {
194
194
  const getActiveSession = () => activeSession;
195
195
 
196
196
  pi.on("session_start", async (_event, ctx) => {
197
+ setCallbackBatchContext(pi, ctx);
197
198
  activeSession = getCallbackOrigin(ctx);
198
199
  for (const meta of listMetasForOrigin(activeSession)) {
199
200
  if (meta.status === "running" || (meta.callback !== false && !meta.callbackSentAt && !meta.callbackSuppressedAt)) {
@@ -202,6 +203,8 @@ export function registerTools(pi: ExtensionAPI): void {
202
203
  }
203
204
  runTaskMaintenance({ activeOrigin: activeSession });
204
205
  });
206
+ pi.on("agent_start", (_event, ctx) => { setCallbackBatchContext(pi, ctx); });
207
+ pi.on("agent_settled", (_event, ctx) => { setCallbackBatchContext(pi, ctx); });
205
208
  pi.on("session_before_switch", () => {
206
209
  activeSession = undefined;
207
210
  cancelCallbackBatch(pi);
@@ -13,7 +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
- - `escape` pauses the active goal, and it stays paused while you talk to the agent. 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 `goal paused · say "go" or /goal resume`, 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.
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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.6.1",
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",
@@ -1,8 +1,11 @@
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";
7
+ import { goalPreferencesPath, readGoalPreferences, writeGoalPreference, type GoalPreferences } from "./preferences.js";
8
+ import { createGoalSettingsPage } from "./settings-page.js";
6
9
 
7
10
  import {
8
11
  collectActivitySnapshot,
@@ -82,20 +85,20 @@ const GOAL_RESUME_RULE =
82
85
  "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
86
 
84
87
  /** 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";
88
+ export function agentResumable(goal: GoalSnapshot | null, conversationalResume = true): boolean {
89
+ return conversationalResume && goal?.status === "paused" && goal.pauseReason === "interrupt";
87
90
  }
88
91
 
89
92
  /** 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";
93
+ export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
94
+ return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
92
95
  }
93
96
 
94
- function pausedGoalPrompt(goal: GoalSnapshot): string {
97
+ function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean): string {
95
98
  return [
96
99
  `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
97
100
  "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,
101
+ 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
102
  ].join("\n");
100
103
  }
101
104
 
@@ -106,10 +109,21 @@ const GOAL_ACTIONS: readonly AutocompleteItem[] = [
106
109
  { value: "resume", label: "resume", description: "Resume the paused goal" },
107
110
  { value: "clear", label: "clear", description: "Remove the current goal" },
108
111
  { value: "complete", label: "complete", description: "Mark the current goal complete" },
112
+ { value: "settings", label: "settings", description: "Inspect or persist goal continuation, conversational resume, and Escape pause controls" },
109
113
  ];
110
114
 
115
+ const GOAL_SETTINGS = ["auto-continue", "conversational-resume", "pause-on-escape"] as const;
116
+
111
117
  export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
112
118
  const prefix = argumentPrefix.trimStart().toLowerCase();
119
+ if (prefix.startsWith("settings ")) {
120
+ const settingPrefix = prefix.slice("settings ".length);
121
+ const choices = GOAL_SETTINGS.flatMap((setting) => settingPrefix.startsWith(`${setting} `)
122
+ ? ["on", "off"].map((mode) => `settings ${setting} ${mode}`)
123
+ : [`settings ${setting}`]);
124
+ const matches = choices.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
125
+ return matches.length > 0 ? matches : null;
126
+ }
113
127
  const matches = GOAL_ACTIONS.filter((action) => action.value.startsWith(prefix));
114
128
  return matches.length > 0 ? [...matches] : null;
115
129
  }
@@ -214,9 +228,11 @@ function formatGoal(
214
228
  goal: GoalSnapshot | null,
215
229
  continuation: ReturnType<typeof currentContinuationState> = null,
216
230
  stall = observeGoalStall(goal, continuation),
231
+ preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true },
217
232
  ): string {
233
+ const settings = formatPreferences(preferences);
218
234
  if (!goal) {
219
- return "No goal is set.";
235
+ return `No goal is set.\n${settings}`;
220
236
  }
221
237
  const budget = goal.tokenBudget === null ? "none" : String(goal.tokenBudget);
222
238
  const timing = goalTiming(goal);
@@ -232,14 +248,23 @@ function formatGoal(
232
248
  `Elapsed time: ${timing.elapsedSeconds}s`,
233
249
  `Observable progress: ${stall?.state ?? "unknown"}`,
234
250
  continuationStatus,
251
+ settings,
235
252
  ...(goal.status === "paused"
236
- ? [agentResumable(goal)
253
+ ? [agentResumable(goal, preferences.conversationalResume)
237
254
  ? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
238
255
  : "Resume: /goal resume or alt+g"]
239
256
  : []),
240
257
  ].join("\n");
241
258
  }
242
259
 
260
+ function formatPreferences(preferences: GoalPreferences): string {
261
+ return [
262
+ `Automatic continuation setting: ${preferences.autoContinue ? "on" : "off"}${WAKE_DISABLED ? " (disabled by environment)" : ""}`,
263
+ `Conversational resume setting: ${preferences.conversationalResume ? "on" : "off"}`,
264
+ `Pause on Esc setting: ${preferences.pauseOnEscape ? "on" : "off"}`,
265
+ ].join("\n");
266
+ }
267
+
243
268
  function formatSnapshot(snapshot: ActivitySnapshot): string {
244
269
  const providers = snapshot.providers.map((provider) => {
245
270
  const active = provider.items.filter((item) => item.active).length;
@@ -268,6 +293,8 @@ export default function (pi: ExtensionAPI): void {
268
293
  let currentCtx: ExtensionContext | undefined;
269
294
  let executionGeneration = 0;
270
295
  let agentGeneration = -1;
296
+ let turnGeneration = 0;
297
+ let escapeAbortSuppression: { session: number; turn: number; execution: number } | undefined;
271
298
  let sessionGeneration = 0;
272
299
  let wakeGeneration = 0;
273
300
  let snapshotSequence = 0;
@@ -283,7 +310,11 @@ export default function (pi: ExtensionAPI): void {
283
310
  let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
284
311
  let idleContinuationSignature = "";
285
312
  let refreshGoalWidget: ((force?: boolean) => void) | undefined;
313
+ let terminalInputUnsubscribe: (() => void) | undefined;
314
+ let terminalInputIsEditor: (() => boolean) | undefined;
315
+ let uiPromptDepth = 0;
286
316
  let lastAgentEvidence: ContinuationEvidence | null = null;
317
+ let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
287
318
  /** Background items active when each pending blocking question started, keyed by tool call id. */
288
319
  const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
289
320
 
@@ -387,9 +418,16 @@ export default function (pi: ExtensionAPI): void {
387
418
  return;
388
419
  }
389
420
  setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
390
- notifyGoal(ctx, 'Goal paused. Say "go" to resume it, or use /goal resume.');
421
+ notifyGoal(ctx, preferences.conversationalResume
422
+ ? 'Goal paused. Say "go" to resume it, or use /goal resume.'
423
+ : "Goal paused. Use /goal resume or alt+g to resume it.");
391
424
  };
392
425
 
426
+ const escapeAbortIsSuppressed = (): boolean =>
427
+ escapeAbortSuppression?.session === sessionGeneration &&
428
+ escapeAbortSuppression.turn === turnGeneration &&
429
+ escapeAbortSuppression.execution === executionGeneration;
430
+
393
431
  /** Why a paused goal cannot become active again, or null when it can. */
394
432
  const resumeBlocker = (goal: GoalSnapshot): string | null => {
395
433
  if (!goal.command && skillCommandName(goal.objective)) {
@@ -470,7 +508,7 @@ export default function (pi: ExtensionAPI): void {
470
508
  kind: "continuation" | "background-drained",
471
509
  snapshot?: ActivitySnapshot,
472
510
  ): void => {
473
- if (WAKE_DISABLED || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
511
+ if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
474
512
  return;
475
513
  }
476
514
  if (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
@@ -500,7 +538,7 @@ export default function (pi: ExtensionAPI): void {
500
538
  const execution = executionGeneration;
501
539
  const wake = wakeGeneration;
502
540
  const auditIsCurrent = (): boolean => {
503
- if (execution !== executionGeneration || wake !== wakeGeneration) return false;
541
+ if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
504
542
  const current = currentGoalSnapshot(ctx);
505
543
  return isPokeable(current) && current.goalId === goalId &&
506
544
  continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
@@ -603,7 +641,7 @@ export default function (pi: ExtensionAPI): void {
603
641
  const background = snapshot?.backgroundRunning
604
642
  ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
605
643
  : undefined;
606
- if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal)} · ${background}` : pausedGoalStatus(goal);
644
+ if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal, preferences.conversationalResume)} · ${background}` : pausedGoalStatus(goal, preferences.conversationalResume);
607
645
  if (background) return background;
608
646
  const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
609
647
  if (continuation?.blocked) return "waiting: no progress";
@@ -633,7 +671,7 @@ export default function (pi: ExtensionAPI): void {
633
671
  if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
634
672
  try {
635
673
  const active = pi.getActiveTools();
636
- const wanted = agentResumable(goal);
674
+ const wanted = agentResumable(goal, preferences.conversationalResume);
637
675
  if (active.includes(GOAL_RESUME_TOOL) === wanted) return;
638
676
  pi.setActiveTools(wanted ? [...active, GOAL_RESUME_TOOL] : active.filter((name) => name !== GOAL_RESUME_TOOL));
639
677
  } catch {
@@ -697,6 +735,16 @@ export default function (pi: ExtensionAPI): void {
697
735
  ctx.ui.setWidget(
698
736
  EXTENSION_NAME,
699
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;
700
748
  const requestRender = (force = false): void => {
701
749
  try {
702
750
  tui.requestRender(force);
@@ -720,6 +768,7 @@ export default function (pi: ExtensionAPI): void {
720
768
  return {
721
769
  dispose() {
722
770
  scheduler.dispose();
771
+ if (terminalInputIsEditor === inputIsEditor) terminalInputIsEditor = undefined;
723
772
  if (refreshGoalWidget === refresh) {
724
773
  refreshGoalWidget = undefined;
725
774
  }
@@ -760,17 +809,59 @@ export default function (pi: ExtensionAPI): void {
760
809
  });
761
810
 
762
811
  pi.registerCommand("goal", {
763
- description: "Create, inspect, pause, resume, clear, or complete the active goal",
812
+ description: "Create, inspect, pause, resume, clear, or complete the active goal; configure persistent goal settings",
764
813
  getArgumentCompletions: goalArgumentCompletions,
765
814
  handler: async (args, ctx) => {
766
815
  currentCtx = ctx;
767
816
  const trimmed = args.trim();
768
817
  const current = getGoal(ctx);
769
818
 
819
+ if (trimmed === "settings" || trimmed.startsWith("settings ")) {
820
+ const changePreference = async (key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
821
+ const previous = preferences;
822
+ preferences = writeGoalPreference(key, enabled);
823
+ if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
824
+ syncResumeTool(getGoal(ctx));
825
+ applyStatus(ctx);
826
+ if (!previous.autoContinue && preferences.autoContinue) {
827
+ const snapshot = await publishSnapshot(ctx);
828
+ const goal = getGoal(ctx);
829
+ if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
830
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
831
+ }
832
+ }
833
+ };
834
+ if (trimmed === "settings" && ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
835
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
836
+ get: () => ({ ...preferences }),
837
+ change: changePreference,
838
+ }, () => tui.requestRender(), () => done()));
839
+ return;
840
+ }
841
+ const [, setting, mode, ...extra] = trimmed.split(/\s+/);
842
+ if (setting !== undefined && (!GOAL_SETTINGS.includes(setting as typeof GOAL_SETTINGS[number]) ||
843
+ (mode !== "on" && mode !== "off") || extra.length > 0)) {
844
+ notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume|pause-on-escape on|off]", "warning");
845
+ return;
846
+ }
847
+ if (setting !== undefined) {
848
+ try {
849
+ const key = setting === "auto-continue" ? "autoContinue"
850
+ : setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
851
+ await changePreference(key, mode === "on");
852
+ } catch (error) {
853
+ notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
854
+ return;
855
+ }
856
+ }
857
+ ctx.ui.notify(`${formatPreferences(preferences)}\nPreferences: ${goalPreferencesPath()}`, "info");
858
+ return;
859
+ }
860
+
770
861
  if (!trimmed) {
771
862
  const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
772
863
  // 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");
864
+ ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences), "info");
774
865
  return;
775
866
  }
776
867
 
@@ -847,8 +938,8 @@ export default function (pi: ExtensionAPI): void {
847
938
  backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
848
939
  });
849
940
  return {
850
- content: [{ type: "text", text: formatGoal(goal, continuation, stall) }],
851
- details: { goal, continuation, stall, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
941
+ content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences) }],
942
+ details: { goal, continuation, stall, preferences: { ...preferences }, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
852
943
  };
853
944
  },
854
945
  });
@@ -904,7 +995,13 @@ export default function (pi: ExtensionAPI): void {
904
995
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
905
996
  const reason = (params as { reason?: string }).reason;
906
997
  const current = getGoal(ctx);
907
- if (!agentResumable(current)) {
998
+ if (!preferences.conversationalResume) {
999
+ return {
1000
+ content: [{ type: "text", text: "Conversational goal resume is disabled. Only the user can resume with /goal resume or alt+g." }],
1001
+ details: { ok: false, goal: current },
1002
+ };
1003
+ }
1004
+ if (!agentResumable(current, preferences.conversationalResume)) {
908
1005
  const text = current?.status === "paused"
909
1006
  ? "This goal was paused with /goal pause or by the runtime. Only the user can resume it, with /goal resume."
910
1007
  : "No goal is paused, so there is nothing to resume.";
@@ -986,8 +1083,13 @@ export default function (pi: ExtensionAPI): void {
986
1083
  });
987
1084
 
988
1085
  pi.on("session_start", async (_event, ctx) => {
1086
+ terminalInputUnsubscribe?.();
1087
+ terminalInputUnsubscribe = undefined;
1088
+ terminalInputIsEditor = undefined;
1089
+ uiPromptDepth = 0;
989
1090
  executionGeneration += 1;
990
1091
  sessionGeneration += 1;
1092
+ escapeAbortSuppression = undefined;
991
1093
  clearIdleContinuation();
992
1094
  continuationQueuedFor = null;
993
1095
  backgroundDrainTracker = null;
@@ -996,6 +1098,11 @@ export default function (pi: ExtensionAPI): void {
996
1098
  collectionPending = false;
997
1099
  currentCtx = ctx;
998
1100
  foregroundRunning = !ctx.isIdle();
1101
+ try {
1102
+ preferences = readGoalPreferences();
1103
+ } catch (error) {
1104
+ notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
1105
+ }
999
1106
  const restoredGoal = getGoal(ctx);
1000
1107
  if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
1001
1108
  setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
@@ -1004,6 +1111,23 @@ export default function (pi: ExtensionAPI): void {
1004
1111
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
1005
1112
  syncResumeTool(getGoal(ctx));
1006
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
+ }
1007
1131
  const snapshot = await publishSnapshot(ctx);
1008
1132
  if (!snapshot) return;
1009
1133
  syncPollingState();
@@ -1012,6 +1136,9 @@ export default function (pi: ExtensionAPI): void {
1012
1136
  }
1013
1137
  });
1014
1138
 
1139
+ pi.on("ui_prompt_start", () => { uiPromptDepth += 1; });
1140
+ pi.on("ui_prompt_end", () => { uiPromptDepth = Math.max(0, uiPromptDepth - 1); });
1141
+
1015
1142
  pi.on("input", async (event, ctx) => {
1016
1143
  if (event.source === "extension") {
1017
1144
  return;
@@ -1046,7 +1173,8 @@ export default function (pi: ExtensionAPI): void {
1046
1173
  const owner = getWorkflow(ctx);
1047
1174
  const snapshot = await publishSnapshot(ctx);
1048
1175
  if (!snapshot) return;
1049
- const pausedInstruction = agentResumable(goal) ? pausedGoalPrompt(goal!) : "";
1176
+ const pausedInstruction = goal?.status === "paused" && goal.pauseReason === "interrupt"
1177
+ ? pausedGoalPrompt(goal, preferences.conversationalResume) : "";
1050
1178
  if (!isPokeable(goal) && !owner) {
1051
1179
  return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
1052
1180
  }
@@ -1092,6 +1220,8 @@ export default function (pi: ExtensionAPI): void {
1092
1220
 
1093
1221
  pi.on("agent_start", async (_event, ctx) => {
1094
1222
  executionGeneration += 1;
1223
+ turnGeneration += 1;
1224
+ escapeAbortSuppression = undefined;
1095
1225
  agentGeneration = executionGeneration;
1096
1226
  currentCtx = ctx;
1097
1227
  foregroundRunning = true;
@@ -1100,10 +1230,16 @@ export default function (pi: ExtensionAPI): void {
1100
1230
  lastAgentEvidence = null;
1101
1231
  const turnSignal = ctx.signal;
1102
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
+ };
1103
1239
  if (turnSignal.aborted) {
1104
- pauseGoalOnInterrupt(ctx);
1240
+ onAbort();
1105
1241
  } else {
1106
- turnSignal.addEventListener("abort", () => pauseGoalOnInterrupt(ctx), { once: true });
1242
+ turnSignal.addEventListener("abort", onAbort, { once: true });
1107
1243
  }
1108
1244
  }
1109
1245
  await publishSnapshot(ctx);
@@ -1111,13 +1247,12 @@ export default function (pi: ExtensionAPI): void {
1111
1247
 
1112
1248
  pi.on("agent_end", async (event, ctx) => {
1113
1249
  lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
1114
- // `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
1115
- // cannot register it. Observe the interrupt instead: when a running turn is
1116
- // aborted (escape / ctrl+c while streaming), pause the active goal so it does
1117
- // not auto-continue after the user stopped the agent.
1118
- 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()) {
1119
1253
  pauseGoalOnInterrupt(ctx);
1120
1254
  }
1255
+ escapeAbortSuppression = undefined;
1121
1256
  });
1122
1257
 
1123
1258
  pi.on("tool_execution_start", async (event, ctx) => {
@@ -1150,6 +1285,7 @@ export default function (pi: ExtensionAPI): void {
1150
1285
 
1151
1286
  pi.on("agent_settled", async (_event, ctx) => {
1152
1287
  executionGeneration += 1;
1288
+ escapeAbortSuppression = undefined;
1153
1289
  currentCtx = ctx;
1154
1290
  foregroundRunning = false;
1155
1291
  const snapshot = await publishSnapshot(ctx);
@@ -1196,6 +1332,11 @@ export default function (pi: ExtensionAPI): void {
1196
1332
  pi.on("session_shutdown", async (_event, ctx) => {
1197
1333
  executionGeneration += 1;
1198
1334
  sessionGeneration += 1;
1335
+ escapeAbortSuppression = undefined;
1336
+ terminalInputUnsubscribe?.();
1337
+ terminalInputUnsubscribe = undefined;
1338
+ terminalInputIsEditor = undefined;
1339
+ uiPromptDepth = 0;
1199
1340
  stopPolling();
1200
1341
  foregroundRunning = false;
1201
1342
  pendingQuestions.clear();