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.
- package/README.md +2 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +67 -4
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +2 -0
- package/node_modules/pi-better-background-tasks/src/tools.ts +4 -1
- package/node_modules/pi-better-goal/README.md +2 -1
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +168 -27
- package/node_modules/pi-better-goal/src/preferences.ts +73 -0
- package/node_modules/pi-better-goal/src/settings-page.ts +68 -0
- package/node_modules/pi-better-plan/README.md +2 -0
- package/node_modules/pi-better-plan/package.json +3 -2
- package/node_modules/pi-better-sandbox/README.md +76 -9
- package/node_modules/pi-better-sandbox/index.ts +1 -0
- package/node_modules/pi-better-sandbox/package.json +3 -2
- package/node_modules/pi-better-sandbox/permissions-page.ts +4 -4
- package/node_modules/pi-better-sandbox/permissions.ts +10 -4
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +2 -0
- package/node_modules/pi-better-sandbox/shared-task-process-list.ts +203 -0
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +8 -1
- package/node_modules/pi-better-sandbox/shared-task-tools.ts +1 -1
- package/node_modules/pi-better-ssh/README.md +2 -0
- package/node_modules/pi-better-ssh/package.json +3 -2
- package/node_modules/pi-better-subagents/docs/usage.md +26 -10
- package/node_modules/pi-better-subagents/index.ts +10 -2
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/permission-policy.ts +4 -1
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +67 -4
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +2 -0
- package/node_modules/pi-better-subagents/shared-task-process-list.ts +203 -0
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +8 -1
- package/node_modules/pi-better-subagents/shared-task-tools.ts +1 -1
- package/node_modules/pi-better-subagents/subagent-tools.ts +5 -0
- package/node_modules/pi-better-subagents/task-guard.ts +3 -1
- package/node_modules/pi-better-subagents/task-policy.ts +4 -1
- 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
|
|
@@ -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(
|
|
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
|
|
657
|
+
const key = host.events ?? host;
|
|
617
658
|
const existing = state.byHost.get(key);
|
|
618
|
-
if (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
|
|
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
|
-
- `
|
|
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,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
|
|
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,
|
|
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 (!
|
|
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 =
|
|
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
|
-
|
|
1240
|
+
onAbort();
|
|
1105
1241
|
} else {
|
|
1106
|
-
turnSignal.addEventListener("abort",
|
|
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
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
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();
|