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

|
|
6
|
+
|
|
5
7
|
## What It Does
|
|
6
8
|
|
|
7
9
|
- Gives models `update_plan` and `get_plan` tools for atomic, explicit progress updates.
|
|
@@ -36,7 +38,7 @@ For a DAG, assign stable ids to prerequisite steps and list those ids in depende
|
|
|
36
38
|
|
|
37
39
|
When an explicitly invoked skill declares `workflow-role: coordinator` in its metadata, that skill's task plan takes precedence. The generic checklist stays persisted but is hidden, and `update_plan` refuses generic checklist updates until workflow ownership is released. `/plan` never opens a stale generic checklist during workflow ownership.
|
|
38
40
|
|
|
39
|
-
For `rush-issues
|
|
41
|
+
For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), call `sync_workflow_plan` once with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`. This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`; the binding survives Pi session resume, and ownership release restores the prior generic checklist. After that, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
|
|
40
42
|
|
|
41
43
|
```json
|
|
42
44
|
{ "workflow": {
|
|
@@ -50,7 +52,9 @@ For `rush-issues`, call `sync_workflow_plan` once with the absolute `.resolve-is
|
|
|
50
52
|
} }
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
`changes` addresses units, components, and fleet stages by id (a change without an id sets run-level fields); all changes in one call are one transition. For a scope change, a change with `target` `unit` or `component` and an `add` row appends a new row; existing rows cannot be removed or renamed. The tool checks ids, dependencies (including cycles), worker slots, and status/stage values against the
|
|
55
|
+
`changes` addresses units, components, and fleet stages by id (a change without an id sets run-level fields); all changes in one call are one transition. For a scope change, a change with `target` `unit` or `component` and an `add` row appends a new row; existing rows cannot be removed or renamed. The tool checks ids, dependencies (including cycles), worker slots, and status/stage values against the shared issue-resolution contract (a fleet status of `n/a` is saved as `not-applicable`), refuses a stale `revision`, appends an optional `decision`, then saves the plan atomically with `planRevision + 1` and a new `updatedAt`, and appends one profiling event with the same revision to the log the run already uses (`profiling/run.jsonl` or `profiling.jsonl`). A rejected update changes nothing. The event is appended before the plan is renamed into place, so a crash between the two can leave the log one revision ahead; the next update notices, reuses that revision, and marks its event with `logAheadRevision`.
|
|
56
|
+
|
|
57
|
+
The binding records the active workflow's name, which is shown in the plan view. A different workflow cannot read or update that binding; a new invocation or ownership release invalidates it. The skill name is not an allowlist: explicit binding opts a workflow into the shared schema and transition rules, while path confinement to `.resolve-issues/rush/<run-id>/task-plan.json`, run identity, size limits, and revision checks still apply. Workflows using other schemas or storage locations retain their own planning and must not bind incompatible state.
|
|
54
58
|
|
|
55
59
|
## Plan Display
|
|
56
60
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-plan",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Structured execution plans with persistent progress for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"pi": {
|
|
32
32
|
"extensions": [
|
|
33
33
|
"./src/index.ts"
|
|
34
|
-
]
|
|
34
|
+
],
|
|
35
|
+
"image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-plan.png"
|
|
35
36
|
},
|
|
36
37
|
"scripts": {
|
|
37
38
|
"test": "node --import tsx --test test/*.test.ts",
|
|
@@ -134,7 +134,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
134
134
|
const refresh = (force = false): void => refreshWidget?.(force);
|
|
135
135
|
|
|
136
136
|
const loadRushPlan = (ctx: ExtensionContext): RushPlan | null => {
|
|
137
|
-
if (!rushBinding || workflowPlanOwner(ctx) !==
|
|
137
|
+
if (!rushBinding || workflowPlanOwner(ctx) !== rushBinding.owner) return null;
|
|
138
138
|
try {
|
|
139
139
|
const plan = readRushPlan(rushBinding.path, ctx.cwd);
|
|
140
140
|
if (plan.runId !== rushBinding.runId) throw new Error("Rush run identity changed.");
|
|
@@ -181,7 +181,8 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
181
181
|
const state = reconstructPlanState(ctx.sessionManager.getBranch());
|
|
182
182
|
currentPlan = state.plan;
|
|
183
183
|
displayedWorkflowOwner = workflowPlanOwner(ctx);
|
|
184
|
-
|
|
184
|
+
const binding = workflowBinding(ctx.sessionManager.getBranch());
|
|
185
|
+
rushBinding = binding?.owner === displayedWorkflowOwner ? binding : null;
|
|
185
186
|
rushPlan = null;
|
|
186
187
|
rushError = null;
|
|
187
188
|
if (rushBinding) loadRushPlan(ctx);
|
|
@@ -213,13 +214,13 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
213
214
|
refreshWidget = localRefresh;
|
|
214
215
|
return {
|
|
215
216
|
render(width: number): string[] {
|
|
216
|
-
if (displayMode === "hidden") return [];
|
|
217
|
+
if (displayMode === "hidden" || displayedWorkflowOwner !== workflowPlanOwner(ctx)) return [];
|
|
217
218
|
if (displayedWorkflowOwner) {
|
|
218
|
-
if (
|
|
219
|
+
if (rushBinding?.owner !== displayedWorkflowOwner) return [];
|
|
219
220
|
const fg = typeof theme?.fg === "function"
|
|
220
221
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
221
222
|
: undefined;
|
|
222
|
-
return rushPlan ? ["", ...renderRushPlan(rushPlan, width, false, fg)] : [];
|
|
223
|
+
return rushPlan ? ["", ...renderRushPlan(rushPlan, width, false, fg, displayedWorkflowOwner)] : [];
|
|
223
224
|
}
|
|
224
225
|
if (!currentPlan) return [];
|
|
225
226
|
return ["", ...renderCompactPlan(currentPlan, width, theme as never)];
|
|
@@ -237,20 +238,20 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
237
238
|
const showFullPlan = async (ctx: ExtensionContext): Promise<void> => {
|
|
238
239
|
const owner = workflowPlanOwner(ctx);
|
|
239
240
|
if (owner) {
|
|
240
|
-
const plan =
|
|
241
|
+
const plan = loadRushPlan(ctx);
|
|
241
242
|
if (!plan) {
|
|
242
|
-
ctx.ui.notify(owner
|
|
243
|
+
ctx.ui.notify(`${owner} plan unavailable: ${rushError ?? "not bound"}`, "warning");
|
|
243
244
|
return;
|
|
244
245
|
}
|
|
245
246
|
if (ctx.mode !== "tui") {
|
|
246
|
-
ctx.ui.notify(renderRushPlan(plan, 160, true).join("\n"), "info");
|
|
247
|
+
ctx.ui.notify(renderRushPlan(plan, 160, true, undefined, owner).join("\n"), "info");
|
|
247
248
|
return;
|
|
248
249
|
}
|
|
249
250
|
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
250
251
|
const fg = typeof theme?.fg === "function"
|
|
251
252
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
252
253
|
: undefined;
|
|
253
|
-
const component = createRushPlanComponent(plan, () => done(), fg);
|
|
254
|
+
const component = createRushPlanComponent(plan, () => done(), fg, owner);
|
|
254
255
|
return { render: (width) => component.render(width), handleInput(data) {
|
|
255
256
|
component.handleInput?.(data);
|
|
256
257
|
tui.requestRender();
|
|
@@ -284,13 +285,14 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
284
285
|
pi.registerTool({
|
|
285
286
|
name: "sync_workflow_plan",
|
|
286
287
|
label: "Sync Workflow Plan",
|
|
287
|
-
description: "Bind and display
|
|
288
|
+
description: "Bind and display an active workflow's compatible persisted task plan at its exact checkpoint revision. Record later transitions with update_plan's workflow field.",
|
|
288
289
|
parameters: Type.Object({
|
|
289
290
|
path: Type.String({ description: "Absolute path to .resolve-issues/rush/<run-id>/task-plan.json" }),
|
|
290
291
|
revision: Type.Integer({ minimum: 0, description: "Persisted planRevision to display" }),
|
|
291
292
|
}),
|
|
292
293
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
293
|
-
|
|
294
|
+
const owner = workflowPlanOwner(ctx);
|
|
295
|
+
if (!owner) throw new Error("Only an active workflow can sync its plan.");
|
|
294
296
|
const plan = readRushPlan(params.path, ctx.cwd);
|
|
295
297
|
if (plan.planRevision !== params.revision) {
|
|
296
298
|
rushPlan = null;
|
|
@@ -298,22 +300,23 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
298
300
|
refresh(true);
|
|
299
301
|
throw new Error(`Rush plan ${rushError}.`);
|
|
300
302
|
}
|
|
301
|
-
const binding: WorkflowPlanBinding = { owner
|
|
302
|
-
if (!rushBinding || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
|
|
303
|
+
const binding: WorkflowPlanBinding = { owner, path: params.path, runId: plan.runId };
|
|
304
|
+
if (!rushBinding || rushBinding.owner !== owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
|
|
303
305
|
pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
|
|
304
306
|
}
|
|
307
|
+
displayedWorkflowOwner = owner;
|
|
305
308
|
rushBinding = binding;
|
|
306
309
|
rushPlan = plan;
|
|
307
310
|
rushError = null;
|
|
308
311
|
refresh(true);
|
|
309
|
-
return { content: [{ type: "text", text: `Showing
|
|
312
|
+
return { content: [{ type: "text", text: `Showing ${owner} rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { runId: plan.runId, revision: plan.planRevision } };
|
|
310
313
|
},
|
|
311
314
|
});
|
|
312
315
|
|
|
313
316
|
pi.registerTool({
|
|
314
317
|
name: "update_plan",
|
|
315
318
|
label: "Update Plan",
|
|
316
|
-
description: "Create or atomically replace the current structured execution plan and its step statuses. While
|
|
319
|
+
description: "Create or atomically replace the current structured execution plan and its step statuses. While a workflow owns a bound plan, send workflow instead of plan to save one task-plan transition.",
|
|
317
320
|
promptSnippet: "Create and update a persistent structured execution plan",
|
|
318
321
|
promptGuidelines: [
|
|
319
322
|
"Use update_plan for work with three or more meaningful steps unless a skill owns planning; update it immediately when a step completes, becomes blocked, or scope changes.",
|
|
@@ -322,7 +325,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
322
325
|
"For generic plans, follow the active delegation mode. Manual forbids proactive delegation even in plan mode; adaptive favors substantial independent work; coordinator delegates nontrivial role-owned tasks after agents_catalog discovery.",
|
|
323
326
|
"Use a generic plan as a milestone ledger only when no workflow owns planning. Otherwise follow the workflow's task plan and foreground role.",
|
|
324
327
|
"For generic plans, use separate steps for distinct deliverables, not one step per worker process. Before completing verification or the plan, inspect and integrate every relevant delegated result or failure.",
|
|
325
|
-
"While
|
|
328
|
+
"While a workflow owns the plan and it is bound with sync_workflow_plan, record every transition with update_plan's workflow field instead of editing task-plan.json or its profiling log; it saves the rows, the next planRevision, and the matching profiling event together.",
|
|
326
329
|
],
|
|
327
330
|
parameters: UpdatePlanSchema,
|
|
328
331
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -330,9 +333,8 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
330
333
|
const input = params as { explanation?: string; plan?: PlanStepInput[]; workflow?: RushPlanUpdate };
|
|
331
334
|
if (input.workflow !== undefined) {
|
|
332
335
|
if (!owner) throw new Error("No workflow owns the task plan; send plan instead of workflow.");
|
|
333
|
-
if (owner !== "rush-issues") throw new Error(`${owner} owns the task plan and does not accept update_plan workflow changes.`);
|
|
334
336
|
if (input.plan !== undefined) throw new Error("Send either plan or workflow, not both.");
|
|
335
|
-
if (!rushBinding) throw new Error(
|
|
337
|
+
if (!rushBinding || rushBinding.owner !== owner) throw new Error(`No ${owner} plan is bound; call sync_workflow_plan with the task-plan.json path first.`);
|
|
336
338
|
const result = applyRushPlanUpdate(rushBinding.path, ctx.cwd, rushBinding.runId, input.workflow);
|
|
337
339
|
rushPlan = result.plan;
|
|
338
340
|
rushError = null;
|
|
@@ -342,7 +344,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
342
344
|
const logNote = result.logAheadRevision === undefined ? ""
|
|
343
345
|
: ` The profiling log already held rev ${result.logAheadRevision} (an earlier write stopped after logging); this event supersedes it.`;
|
|
344
346
|
return {
|
|
345
|
-
content: [{ type: "text", text: `Saved
|
|
347
|
+
content: [{ type: "text", text: `Saved ${owner} rev ${result.revision} (${input.workflow.event.trim()})${rows.length ? `: ${rows.join(", ")}` : ""}${input.workflow.decision ? `${rows.length ? ";" : ":"} decision ${input.workflow.decision.id}` : ""}. Profiling: ${result.profilingPath}.${logNote}` }],
|
|
346
348
|
details: {
|
|
347
349
|
ok: true, runId: result.plan.runId, revision: result.revision, changed: result.changed, profilingPath: result.profilingPath,
|
|
348
350
|
...(result.logAheadRevision === undefined ? {} : { logAheadRevision: result.logAheadRevision }),
|
|
@@ -350,9 +352,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
350
352
|
};
|
|
351
353
|
}
|
|
352
354
|
if (owner) {
|
|
353
|
-
throw new Error(owner
|
|
354
|
-
? "rush-issues owns the task plan. Send workflow (bound with sync_workflow_plan) instead of plan."
|
|
355
|
-
: `${owner} owns the task plan. Update its workflow plan instead of update_plan.`);
|
|
355
|
+
throw new Error(`${owner} owns the task plan. Send workflow (bound with sync_workflow_plan) instead of plan.`);
|
|
356
356
|
}
|
|
357
357
|
if (input.plan === undefined) throw new Error("plan is required: send the full list of steps.");
|
|
358
358
|
const plan = replacePlan(currentPlan, input.plan, input.explanation);
|
|
@@ -387,10 +387,10 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
387
387
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
388
388
|
const owner = workflowPlanOwner(ctx);
|
|
389
389
|
if (owner) {
|
|
390
|
-
const workflowPlan =
|
|
390
|
+
const workflowPlan = loadRushPlan(ctx);
|
|
391
391
|
return {
|
|
392
392
|
content: [{ type: "text", text: workflowPlan
|
|
393
|
-
? renderRushPlan(workflowPlan, 160, true).join("\n")
|
|
393
|
+
? renderRushPlan(workflowPlan, 160, true, undefined, owner).join("\n")
|
|
394
394
|
: `${owner} owns the task plan; ${rushError ?? "consult its persisted workflow state"}.` }],
|
|
395
395
|
details: { hasPlan: !!workflowPlan, plan: workflowPlan, progress: null, workflowOwner: owner },
|
|
396
396
|
};
|
|
@@ -28,7 +28,7 @@ export interface RushPlan {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface WorkflowPlanBinding {
|
|
31
|
-
owner:
|
|
31
|
+
owner: string;
|
|
32
32
|
path: string;
|
|
33
33
|
runId: string;
|
|
34
34
|
}
|
|
@@ -113,9 +113,9 @@ export function workflowBinding(entries: Iterable<{ type: string; customType?: s
|
|
|
113
113
|
const data = entry.data;
|
|
114
114
|
if (!isRecord(data) || data.version !== 1) continue;
|
|
115
115
|
if (data.kind === "clear") binding = null;
|
|
116
|
-
else if (data.kind === "set" && data.owner === "
|
|
116
|
+
else if (data.kind === "set" && typeof data.owner === "string" && data.owner.length > 0 &&
|
|
117
117
|
typeof data.path === "string" && typeof data.runId === "string") {
|
|
118
|
-
binding = { owner:
|
|
118
|
+
binding = { owner: data.owner, path: data.path, runId: data.runId };
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
return binding;
|
|
@@ -133,7 +133,7 @@ function workflowStatus(status: string): DisplayStatus {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function presentRushPlan(plan: RushPlan): PlanPresentation {
|
|
136
|
+
function presentRushPlan(plan: RushPlan, owner: string): PlanPresentation {
|
|
137
137
|
// The warehouse canary only applies to data work; hide it otherwise.
|
|
138
138
|
const stages = ["explore", "implement", "review", "ci", ...(plan.warehouseCanaryRequired ? ["canary"] : [])];
|
|
139
139
|
const fleet = stages.map((stage) => {
|
|
@@ -142,7 +142,7 @@ function presentRushPlan(plan: RushPlan): PlanPresentation {
|
|
|
142
142
|
const style = statusStyle(display);
|
|
143
143
|
return { color: style.color, text: ` ${stage} ${style.glyph}${display === "unknown" ? ` ${planText(status)}` : ""}` };
|
|
144
144
|
});
|
|
145
|
-
const metadata = [[{ color: "dim", text:
|
|
145
|
+
const metadata = [[{ color: "dim", text: `${planText(owner)} · rev ${plan.planRevision} ·` }, ...fleet]];
|
|
146
146
|
if (plan.spec?.title) metadata.push([{ color: "dim", text: plan.spec.title }]);
|
|
147
147
|
return {
|
|
148
148
|
metadata,
|
|
@@ -164,16 +164,18 @@ export function renderRushPlan(
|
|
|
164
164
|
width: number,
|
|
165
165
|
full = false,
|
|
166
166
|
fg?: (color: string, value: string) => string,
|
|
167
|
+
owner = "rush-issues",
|
|
167
168
|
): string[] {
|
|
168
169
|
const theme = fg ? { fg } : plainPlanTheme;
|
|
169
|
-
return renderPlanPresentation(presentRushPlan(plan), width, theme, full);
|
|
170
|
+
return renderPlanPresentation(presentRushPlan(plan, owner), width, theme, full);
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
export function createRushPlanComponent(
|
|
173
174
|
plan: RushPlan,
|
|
174
175
|
onClose: () => void,
|
|
175
176
|
fg?: (color: string, value: string) => string,
|
|
177
|
+
owner = "rush-issues",
|
|
176
178
|
): Component {
|
|
177
179
|
const theme = fg ? { fg } : plainPlanTheme;
|
|
178
|
-
return createPlanPresentationComponent(presentRushPlan(plan), theme, onClose);
|
|
180
|
+
return createPlanPresentationComponent(presentRushPlan(plan, owner), theme, onClose);
|
|
179
181
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Sandbox permissions for Pi's foreground tools and detached subagents.
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
It is installed by default with [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme), and can be installed on its own:
|
|
6
8
|
|
|
7
9
|
```sh
|
|
@@ -386,6 +388,41 @@ credential stores under a read-only outside root, a writable outside root
|
|
|
386
388
|
The macOS permission combinations are covered by real-kernel tests; Linux
|
|
387
389
|
mount behavior requires a Linux runner with Bubblewrap and user namespaces.
|
|
388
390
|
|
|
391
|
+
### macOS setuid executable limitation
|
|
392
|
+
|
|
393
|
+
Seatbelt-sandboxed processes cannot execute setuid/setgid binaries, even with
|
|
394
|
+
an `(allow default)` profile. On the verified host, `/bin/ps` and `/usr/bin/top`
|
|
395
|
+
are setuid-root binaries. A shell attempting to run `/bin/ps` reports
|
|
396
|
+
`Operation not permitted` and exits 126. This is an OS-level execution
|
|
397
|
+
restriction, not a harness command deny rule, a filesystem permission failure,
|
|
398
|
+
or evidence that Run commands & applications is Off.
|
|
399
|
+
|
|
400
|
+
Read-only reproduction (verified on 2026-10-04):
|
|
401
|
+
|
|
402
|
+
```sh
|
|
403
|
+
ls -l /bin/ps /usr/bin/top
|
|
404
|
+
/usr/bin/sandbox-exec -p '(version 1) (allow default)' \
|
|
405
|
+
/bin/bash -c '/bin/ps -p $$ -o pid,ppid,comm'
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
The second command fails with `/bin/bash: /bin/ps: Operation not permitted`
|
|
409
|
+
and exit 126; the same `ps` query outside Seatbelt succeeds. Launching `ps`
|
|
410
|
+
directly through `sandbox-exec` instead reports `execvp()` failure and exit 71.
|
|
411
|
+
Changing the requested output fields or adding a profile allowance for `ps`
|
|
412
|
+
does not remove this limitation.
|
|
413
|
+
|
|
414
|
+
When process inspection is a prerequisite for safe work, do not interpret the
|
|
415
|
+
denial as an empty process list. Hold the dependent operation until an
|
|
416
|
+
authorized coordinator or environment custodian supplies a fresh snapshot
|
|
417
|
+
and explicit ownership handoff from outside the task sandbox. Prefer
|
|
418
|
+
`pid,ppid,pgid,lstart,comm` over full command arguments, which may expose
|
|
419
|
+
secrets. Do not disable the sandbox or modify/copy privileged executables to
|
|
420
|
+
work around the denial. A trusted process-inspection adapter would require
|
|
421
|
+
separate implementation; this package does not currently provide one.
|
|
422
|
+
|
|
423
|
+
See the [session evidence](../../docs/sandbox-session-validation.md#macos-process-inspection-follow-up)
|
|
424
|
+
and [documented setuid/setgid limitation](https://github.com/navikt/cplt/blob/main/docs/known-impacts.md).
|
|
425
|
+
|
|
389
426
|
## For other extensions
|
|
390
427
|
|
|
391
428
|
The effective policy is published as a frozen snapshot on Pi's extension event
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-sandbox",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"pi": {
|
|
17
17
|
"extensions": [
|
|
18
18
|
"./index.ts"
|
|
19
|
-
]
|
|
19
|
+
],
|
|
20
|
+
"image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-sandbox.png"
|
|
20
21
|
},
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
`pi-better-ssh` runs short synchronous remote commands over safe, reusable SSH connections. It adds explicit remote tools; Pi's built-in `bash` remains local and is never overridden.
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-ssh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Pi extension for safe synchronous remote bash commands over reusable SSH connections.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"pi": {
|
|
15
15
|
"extensions": [
|
|
16
16
|
"./src/index.ts"
|
|
17
|
-
]
|
|
17
|
+
],
|
|
18
|
+
"image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-ssh.png"
|
|
18
19
|
},
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
@@ -246,13 +246,17 @@ given.
|
|
|
246
246
|
Its context can't clog the parent, its crash can't corrupt parent state, and
|
|
247
247
|
its output is durable in a log file.
|
|
248
248
|
- **Completions trigger one batched turn that fetches durable results.** Ordinary
|
|
249
|
-
terminal callbacks
|
|
249
|
+
terminal callbacks stay in the harness queue while the foreground agent is
|
|
250
|
+
busy. Once Pi is idle, they accumulate for 100 ms and share one
|
|
250
251
|
`pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
|
|
251
252
|
with background-task completions from the same Pi host. Each row contains only
|
|
252
253
|
source, id, label, terminal status, and the `subagent_result` lookup. Full
|
|
253
254
|
results and logs stay in durable tools and are never embedded in the callback.
|
|
254
255
|
Set `PI_BETTER_CALLBACK_BATCH_MS` to `0` through `5000` milliseconds to tune
|
|
255
|
-
the accumulation window; invalid values use 100 ms.
|
|
256
|
+
the accumulation window; invalid values use 100 ms. `agent_settled` schedules
|
|
257
|
+
the aggregate without starting a model run inside the lifecycle handler.
|
|
258
|
+
Completions arriving during that model run, including bounded overflow,
|
|
259
|
+
stay queued for the next available boundary. A failed handoff keeps the
|
|
256
260
|
pending records retryable, and `/reload` recovers records explicitly marked
|
|
257
261
|
pending. `callback:false` sends no model message; read the durable result later
|
|
258
262
|
with `subagent_result`.
|
|
@@ -649,10 +653,11 @@ only after a successful handoff and dedupe across reloads and repeated health ti
|
|
|
649
653
|
Persisted unmarked orphaned/lost states are recovered on the health ticker after
|
|
650
654
|
`/reload` even when process evidence does not produce a fresh transition.
|
|
651
655
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
follow-ups
|
|
655
|
-
|
|
656
|
+
Ordinary completion batching does not depend on Pi's `followUpMode`: events
|
|
657
|
+
stay in the harness until availability rather than becoming separate queued
|
|
658
|
+
follow-ups during the foreground run. Urgent health/failure alerts retain their
|
|
659
|
+
existing immediate follow-up behavior. This extension does not change Pi core
|
|
660
|
+
or Pi's default follow-up mode.
|
|
656
661
|
|
|
657
662
|
### Surfacing health (tools + passive widget)
|
|
658
663
|
|
|
@@ -116,7 +116,7 @@ import {
|
|
|
116
116
|
getSharedCapacityGate,
|
|
117
117
|
} from "./capacity.mjs";
|
|
118
118
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
119
|
-
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
119
|
+
import { cancelCallbackBatch, getCallbackBatcher, setCallbackBatchContext } from "./shared-callback-batcher.ts";
|
|
120
120
|
import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
|
|
121
121
|
import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
|
|
122
122
|
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
@@ -2340,6 +2340,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2340
2340
|
|
|
2341
2341
|
// ---- live-status lifecycle -----------------------------------------
|
|
2342
2342
|
pi.on("agent_start", async (_event, ctx) => {
|
|
2343
|
+
setCallbackBatchContext(pi, ctx);
|
|
2343
2344
|
uiCtx = ctx;
|
|
2344
2345
|
mainAgentStartedAt = Date.now();
|
|
2345
2346
|
mainAgentTools.clear();
|
|
@@ -2372,6 +2373,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2372
2373
|
});
|
|
2373
2374
|
|
|
2374
2375
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
2376
|
+
setCallbackBatchContext(pi, ctx);
|
|
2375
2377
|
mainAgentStartedAt = undefined;
|
|
2376
2378
|
mainAgentTools.clear();
|
|
2377
2379
|
refreshBackgroundWorkNavigator(ctx);
|
|
@@ -2387,6 +2389,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2387
2389
|
// alive, resume the ticking widget. Deferred out of the factory per pi's
|
|
2388
2390
|
// "no background resources at load" rule.
|
|
2389
2391
|
pi.on("session_start", async (_event, ctx) => {
|
|
2392
|
+
setCallbackBatchContext(pi, ctx);
|
|
2390
2393
|
agentSessionSettings.restore(ctx);
|
|
2391
2394
|
restoreDelegationMode(ctx);
|
|
2392
2395
|
uiCtx = ctx;
|
|
@@ -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 {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -52,12 +52,12 @@
|
|
|
52
52
|
"test": "node --test test/*.test.mjs"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"pi-better-background-tasks": "0.6.
|
|
56
|
-
"pi-better-goal": "0.
|
|
57
|
-
"pi-better-plan": "0.5.
|
|
58
|
-
"pi-better-sandbox": "0.9.
|
|
59
|
-
"pi-better-ssh": "0.1.
|
|
60
|
-
"pi-better-subagents": "0.13.
|
|
55
|
+
"pi-better-background-tasks": "0.6.4",
|
|
56
|
+
"pi-better-goal": "0.7.0",
|
|
57
|
+
"pi-better-plan": "0.5.4",
|
|
58
|
+
"pi-better-sandbox": "0.9.1",
|
|
59
|
+
"pi-better-ssh": "0.1.2",
|
|
60
|
+
"pi-better-subagents": "0.13.1",
|
|
61
61
|
"smol-toml": "1.9.0",
|
|
62
62
|
"yaml": "^2.9.1"
|
|
63
63
|
},
|