pi-better-harness 0.9.0 → 0.10.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.
|
@@ -13,7 +13,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
13
13
|
## Core Features
|
|
14
14
|
|
|
15
15
|
- `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
|
|
16
|
-
- `escape` pauses the active goal and
|
|
16
|
+
- `escape` pauses the active goal, and it stays paused while you talk to the agent. Say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows `goal paused · say "go" or /goal resume`, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
|
|
17
17
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
18
18
|
- A compact goal widget that does not replace Pi's footer.
|
|
19
19
|
- Background activity tracking for subagents and other registered providers.
|
|
@@ -70,6 +70,34 @@ const MAX_NO_PROGRESS_RETRIES = parseRetryLimit(
|
|
|
70
70
|
* steering after the whole tool batch, and callback batches are follow-ups
|
|
71
71
|
* that wait for the entire run), so the goal harvests them explicitly.
|
|
72
72
|
*/
|
|
73
|
+
/** Model-callable resume, active only while an escape-paused goal waits. */
|
|
74
|
+
export const GOAL_RESUME_TOOL = "goal_resume";
|
|
75
|
+
/** Hotkey that resumes any paused goal, like `/goal resume`. */
|
|
76
|
+
export const GOAL_RESUME_SHORTCUT = "alt+g";
|
|
77
|
+
|
|
78
|
+
/** When the agent may call `goal_resume`; shared by the tool description and the paused prompt. */
|
|
79
|
+
const GOAL_RESUME_RULE =
|
|
80
|
+
"Call goal_resume only when the user's latest message clearly says to proceed (for example \"go\", \"continue\", \"ok do it\", \"approved, proceed\"), " +
|
|
81
|
+
"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.";
|
|
82
|
+
|
|
83
|
+
/** True when the agent may resume this goal with `goal_resume`. */
|
|
84
|
+
export function agentResumable(goal: GoalSnapshot | null): boolean {
|
|
85
|
+
return goal?.status === "paused" && goal.pauseReason === "interrupt";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Footer status for a paused goal, telling the user how to resume it. */
|
|
89
|
+
export function pausedGoalStatus(goal: GoalSnapshot): string {
|
|
90
|
+
return agentResumable(goal) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pausedGoalPrompt(goal: GoalSnapshot): string {
|
|
94
|
+
return [
|
|
95
|
+
`Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
|
|
96
|
+
"Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
|
|
97
|
+
GOAL_RESUME_RULE,
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
const BLOCKING_QUESTION_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
|
|
74
102
|
|
|
75
103
|
const GOAL_ACTIONS: readonly AutocompleteItem[] = [
|
|
@@ -195,6 +223,11 @@ function formatGoal(
|
|
|
195
223
|
`Elapsed time: ${timing.elapsedSeconds}s`,
|
|
196
224
|
`Observable progress: ${stall?.state ?? "unknown"}`,
|
|
197
225
|
continuationStatus,
|
|
226
|
+
...(goal.status === "paused"
|
|
227
|
+
? [agentResumable(goal)
|
|
228
|
+
? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
|
|
229
|
+
: "Resume: /goal resume or alt+g"]
|
|
230
|
+
: []),
|
|
198
231
|
].join("\n");
|
|
199
232
|
}
|
|
200
233
|
|
|
@@ -304,6 +337,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
304
337
|
backgroundDrainTracker = null;
|
|
305
338
|
clearIdleContinuation();
|
|
306
339
|
syncPollingState();
|
|
340
|
+
syncResumeTool(goal);
|
|
341
|
+
applyStatus(ctx);
|
|
307
342
|
// Force a full redraw when the dock height changes (absent ↔ visible clock).
|
|
308
343
|
refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
|
|
309
344
|
};
|
|
@@ -317,14 +352,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
317
352
|
backgroundDrainTracker = null;
|
|
318
353
|
clearIdleContinuation();
|
|
319
354
|
syncPollingState();
|
|
355
|
+
syncResumeTool(null);
|
|
356
|
+
applyStatus(ctx);
|
|
320
357
|
refreshGoalWidget?.(wasVisible);
|
|
321
358
|
};
|
|
322
359
|
|
|
323
360
|
/**
|
|
324
|
-
* An interrupt (escape, or anything else that aborts the running turn)
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
361
|
+
* An interrupt (escape, or anything else that aborts the running turn) pauses
|
|
362
|
+
* the goal. It stays paused while the user talks: messages are ordinary
|
|
363
|
+
* conversation. It resumes through `/goal resume`, the hotkey, or the agent's
|
|
364
|
+
* `goal_resume` once the user clearly says to proceed.
|
|
328
365
|
*/
|
|
329
366
|
const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
|
|
330
367
|
const goal = getGoal(ctx);
|
|
@@ -332,7 +369,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
332
369
|
return;
|
|
333
370
|
}
|
|
334
371
|
setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
|
|
335
|
-
notifyGoal(ctx,
|
|
372
|
+
notifyGoal(ctx, 'Goal paused. Say "go" to resume it, or use /goal resume.');
|
|
336
373
|
};
|
|
337
374
|
|
|
338
375
|
/** Why a paused goal cannot become active again, or null when it can. */
|
|
@@ -346,6 +383,28 @@ export default function (pi: ExtensionAPI): void {
|
|
|
346
383
|
return null;
|
|
347
384
|
};
|
|
348
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The one resume path shared by `/goal resume`, the hotkey, and `goal_resume`:
|
|
388
|
+
* reactivate the paused goal and queue its continuation.
|
|
389
|
+
*/
|
|
390
|
+
const resumeGoal = (
|
|
391
|
+
ctx: ExtensionContext,
|
|
392
|
+
source: GoalEntrySource,
|
|
393
|
+
): { ok: true; goal: GoalSnapshot } | { ok: false; message: string } => {
|
|
394
|
+
const current = getGoal(ctx);
|
|
395
|
+
if (!current || current.status !== "paused") {
|
|
396
|
+
return { ok: false, message: "Only paused goals can be resumed." };
|
|
397
|
+
}
|
|
398
|
+
const blocker = resumeBlocker(current);
|
|
399
|
+
if (blocker) {
|
|
400
|
+
return { ok: false, message: blocker };
|
|
401
|
+
}
|
|
402
|
+
const goal = goalWithStatus(current, "active");
|
|
403
|
+
setGoal(goal, ctx, source);
|
|
404
|
+
queueGoalContinuation(goal, ctx);
|
|
405
|
+
return { ok: true, goal };
|
|
406
|
+
};
|
|
407
|
+
|
|
349
408
|
const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
|
|
350
409
|
if (!goal.command || commandAvailable(pi, goal.command)) return true;
|
|
351
410
|
setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
|
|
@@ -503,36 +562,62 @@ export default function (pi: ExtensionAPI): void {
|
|
|
503
562
|
clearIdleContinuation();
|
|
504
563
|
}
|
|
505
564
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
565
|
+
applyStatus(ctx);
|
|
566
|
+
|
|
567
|
+
return snapshot;
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/** Footer status from the goal state and the latest activity snapshot. */
|
|
571
|
+
const statusText = (ctx: ExtensionContext, snapshot: ActivitySnapshot | null): string | undefined => {
|
|
572
|
+
const goal = getGoal(ctx);
|
|
573
|
+
let waitingOnAnswer = 0;
|
|
574
|
+
if (snapshot) {
|
|
514
575
|
for (const activeAtStart of pendingQuestions.values()) {
|
|
515
576
|
waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
|
|
516
577
|
}
|
|
517
|
-
const status = waitingOnAnswer > 0
|
|
518
|
-
? `${waitingOnAnswer} background done; waiting on your answer`
|
|
519
|
-
: snapshot.backgroundRunning
|
|
520
|
-
? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
|
|
521
|
-
: continuation?.blocked
|
|
522
|
-
? "waiting: no progress"
|
|
523
|
-
: goalStall?.state === "stalled"
|
|
524
|
-
? "goal stalled"
|
|
525
|
-
: undefined;
|
|
526
|
-
try {
|
|
527
|
-
ctx.ui.setStatus(EXTENSION_NAME, status);
|
|
528
|
-
} catch {
|
|
529
|
-
// UI status is best-effort only.
|
|
530
|
-
}
|
|
531
578
|
}
|
|
532
|
-
|
|
533
|
-
|
|
579
|
+
if (waitingOnAnswer > 0) return `${waitingOnAnswer} background done; waiting on your answer`;
|
|
580
|
+
const background = snapshot?.backgroundRunning
|
|
581
|
+
? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
|
|
582
|
+
: undefined;
|
|
583
|
+
if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal)} · ${background}` : pausedGoalStatus(goal);
|
|
584
|
+
if (background) return background;
|
|
585
|
+
const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
|
|
586
|
+
if (continuation?.blocked) return "waiting: no progress";
|
|
587
|
+
const goalStall = observeGoalStall(goal, continuation, {
|
|
588
|
+
foregroundRunning,
|
|
589
|
+
backgroundRunning: snapshot?.backgroundRunning ?? false,
|
|
590
|
+
});
|
|
591
|
+
return goalStall?.state === "stalled" ? "goal stalled" : undefined;
|
|
534
592
|
};
|
|
535
593
|
|
|
594
|
+
function applyStatus(ctx: ExtensionContext): void {
|
|
595
|
+
if (!ctx.hasUI) return;
|
|
596
|
+
try {
|
|
597
|
+
ctx.ui.setStatus(EXTENSION_NAME, statusText(ctx, latestSnapshot));
|
|
598
|
+
} catch {
|
|
599
|
+
// UI status is best-effort only.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* `goal_resume` is in the model's tool list only while an escape-paused goal
|
|
605
|
+
* waits. Pi activates newly registered tools by default, so this also removes
|
|
606
|
+
* it at session start. Only this one tool is toggled; other extensions' tool
|
|
607
|
+
* choices are left as they are.
|
|
608
|
+
*/
|
|
609
|
+
function syncResumeTool(goal: GoalSnapshot | null): void {
|
|
610
|
+
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
611
|
+
try {
|
|
612
|
+
const active = pi.getActiveTools();
|
|
613
|
+
const wanted = agentResumable(goal);
|
|
614
|
+
if (active.includes(GOAL_RESUME_TOOL) === wanted) return;
|
|
615
|
+
pi.setActiveTools(wanted ? [...active, GOAL_RESUME_TOOL] : active.filter((name) => name !== GOAL_RESUME_TOOL));
|
|
616
|
+
} catch {
|
|
617
|
+
// Tool activation is unavailable before Pi binds the session; the tool also refuses on its own.
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
536
621
|
const collectIfPossible = (): void => {
|
|
537
622
|
const ctx = currentCtx;
|
|
538
623
|
if (!ctx) {
|
|
@@ -675,18 +760,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
675
760
|
}
|
|
676
761
|
|
|
677
762
|
if (trimmed === "resume") {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
const blocker = resumeBlocker(current);
|
|
683
|
-
if (blocker) {
|
|
684
|
-
notifyGoal(ctx, blocker, "error");
|
|
763
|
+
const result = resumeGoal(ctx, "command");
|
|
764
|
+
if (!result.ok) {
|
|
765
|
+
notifyGoal(ctx, result.message, current?.status === "paused" ? "error" : "warning");
|
|
685
766
|
return;
|
|
686
767
|
}
|
|
687
|
-
const goal = goalWithStatus(current, "active");
|
|
688
|
-
setGoal(goal, ctx, "command");
|
|
689
|
-
queueGoalContinuation(goal, ctx);
|
|
690
768
|
notifyGoal(ctx, "Goal resumed.");
|
|
691
769
|
return;
|
|
692
770
|
}
|
|
@@ -789,6 +867,44 @@ export default function (pi: ExtensionAPI): void {
|
|
|
789
867
|
},
|
|
790
868
|
});
|
|
791
869
|
|
|
870
|
+
pi.registerTool({
|
|
871
|
+
name: GOAL_RESUME_TOOL,
|
|
872
|
+
label: "Resume Goal",
|
|
873
|
+
description:
|
|
874
|
+
"Resume the goal the user paused with escape, exactly like /goal resume. " + GOAL_RESUME_RULE,
|
|
875
|
+
promptSnippet: "Resume the escape-paused goal, only on the user's clear go-ahead.",
|
|
876
|
+
parameters: Type.Object({
|
|
877
|
+
reason: Type.Optional(Type.String({ description: "Short quote or summary of the user's go-ahead." })),
|
|
878
|
+
}),
|
|
879
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
880
|
+
const reason = (params as { reason?: string }).reason;
|
|
881
|
+
const current = getGoal(ctx);
|
|
882
|
+
if (!agentResumable(current)) {
|
|
883
|
+
const text = current?.status === "paused"
|
|
884
|
+
? "This goal was paused with /goal pause or by the runtime. Only the user can resume it, with /goal resume."
|
|
885
|
+
: "No goal is paused, so there is nothing to resume.";
|
|
886
|
+
return { content: [{ type: "text", text }], details: { ok: false, goal: current } };
|
|
887
|
+
}
|
|
888
|
+
const result = resumeGoal(ctx, "tool");
|
|
889
|
+
if (!result.ok) {
|
|
890
|
+
return { content: [{ type: "text", text: `Goal stays paused. ${result.message}` }], details: { ok: false, goal: getGoal(ctx) } };
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
content: [{ type: "text", text: "Goal resumed. Its continuation runs after this turn." }],
|
|
894
|
+
details: { ok: true, goal: result.goal, ...(reason ? { reason } : {}) },
|
|
895
|
+
};
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
pi.registerShortcut?.(GOAL_RESUME_SHORTCUT, {
|
|
900
|
+
description: "Resume the paused goal",
|
|
901
|
+
handler: (ctx) => {
|
|
902
|
+
currentCtx = ctx;
|
|
903
|
+
const result = resumeGoal(ctx, "command");
|
|
904
|
+
notifyGoal(ctx, result.ok ? "Goal resumed." : result.message, result.ok ? "info" : "warning");
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
792
908
|
pi.registerCommand("better-activity", {
|
|
793
909
|
description: "Show foreground/background activity known to pi-better-goal",
|
|
794
910
|
handler: async (_args, ctx) => {
|
|
@@ -849,6 +965,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
849
965
|
notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
|
|
850
966
|
}
|
|
851
967
|
pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
|
|
968
|
+
syncResumeTool(getGoal(ctx));
|
|
852
969
|
installGoalWidget(ctx);
|
|
853
970
|
latestSnapshot = await publishSnapshot(ctx);
|
|
854
971
|
syncPollingState();
|
|
@@ -874,23 +991,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
874
991
|
}
|
|
875
992
|
}
|
|
876
993
|
}
|
|
994
|
+
// A paused goal stays paused while the user talks: the message is ordinary
|
|
995
|
+
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
877
996
|
const goal = getGoal(ctx);
|
|
878
|
-
if (goal?.status === "paused" && goal.pauseReason === "interrupt") {
|
|
879
|
-
// Conversational input after an interrupt means the user has taken the
|
|
880
|
-
// wheel, not stopped the goal: reactivate it so continuation resumes once
|
|
881
|
-
// this exchange settles. Pi's built-in commands (/settings, /model, ...)
|
|
882
|
-
// and extension commands never reach this handler.
|
|
883
|
-
const blocker = resumeBlocker(goal);
|
|
884
|
-
if (blocker) {
|
|
885
|
-
notifyGoal(ctx, `Goal stays paused. ${blocker}`, "warning");
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
const resumed = goalWithStatus(goal, "active");
|
|
889
|
-
setGoal(resumed, ctx, "runtime");
|
|
890
|
-
resetContinuationState(resumed);
|
|
891
|
-
notifyGoal(ctx, "Goal resumed; it continues after this exchange.");
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
997
|
if (goal?.status === "active") {
|
|
895
998
|
resetContinuationState(goal);
|
|
896
999
|
}
|
|
@@ -901,8 +1004,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
901
1004
|
const goal = currentGoalSnapshot(ctx);
|
|
902
1005
|
const owner = getWorkflow(ctx);
|
|
903
1006
|
const snapshot = await publishSnapshot(ctx);
|
|
1007
|
+
const pausedInstruction = agentResumable(goal) ? pausedGoalPrompt(goal!) : "";
|
|
904
1008
|
if (!isPokeable(goal) && !owner) {
|
|
905
|
-
return;
|
|
1009
|
+
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
906
1010
|
}
|
|
907
1011
|
|
|
908
1012
|
const questionInstruction = snapshot.backgroundRunning
|
|
@@ -923,7 +1027,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
923
1027
|
return { systemPrompt: `${event.systemPrompt}\n\nWorkflow ${owner.name} is unavailable. Stop work and ask the user to restore or reinvoke the skill.` };
|
|
924
1028
|
}
|
|
925
1029
|
return {
|
|
926
|
-
systemPrompt: `${event.systemPrompt}\n\
|
|
1030
|
+
systemPrompt: `${event.systemPrompt}\n\n` +
|
|
1031
|
+
(pausedInstruction
|
|
1032
|
+
? `${pausedInstruction}\nWhile the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n`
|
|
1033
|
+
: "") +
|
|
1034
|
+
`Active workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}` +
|
|
927
1035
|
(isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
|
|
928
1036
|
(questionInstruction ? `\n\n${questionInstruction.trim()}` : ""),
|
|
929
1037
|
};
|
|
@@ -66,10 +66,12 @@ export interface ActivitySnapshot {
|
|
|
66
66
|
export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Why a paused goal is paused. `interrupt` marks a
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* Why a paused goal is paused. `interrupt` marks a pause from escape (or any
|
|
70
|
+
* other abort of the running turn). Every pause is sticky: user messages never
|
|
71
|
+
* resume a goal on their own. An `interrupt` pause may be resumed by the agent
|
|
72
|
+
* with `goal_resume` when the user clearly says to proceed. A paused goal
|
|
73
|
+
* without a reason (`/goal pause`, an unavailable command or workflow) resumes
|
|
74
|
+
* only through `/goal resume` or the resume hotkey.
|
|
73
75
|
*/
|
|
74
76
|
export type GoalPauseReason = "interrupt";
|
|
75
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"pi-better-background-tasks": "0.6.0",
|
|
54
|
-
"pi-better-goal": "0.
|
|
54
|
+
"pi-better-goal": "0.5.0",
|
|
55
55
|
"pi-better-plan": "0.5.0",
|
|
56
56
|
"pi-better-sandbox": "0.7.1",
|
|
57
57
|
"pi-better-ssh": "0.1.1",
|