pi-better-harness 0.28.0 → 0.28.2
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-goal/README.md +2 -2
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/goal-state.ts +1 -1
- package/node_modules/pi-better-goal/src/index.ts +63 -17
- package/node_modules/pi-better-goal/src/types.ts +2 -1
- package/package.json +2 -2
|
@@ -18,7 +18,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
18
18
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
19
19
|
- A compact goal widget that does not replace Pi's footer.
|
|
20
20
|
- Background activity tracking for subagents and other registered providers.
|
|
21
|
-
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
21
|
+
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a sticky hold recoverable with `/goal resume` or `alt+g`. Conversation, changed callback results, and background drains do not reopen an exhausted hold. Delays saturate at Node's timer limit, and observed background drains reset non-held progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
22
22
|
- An observable-progress stall state for active goals.
|
|
23
23
|
|
|
24
24
|
## Settings Storage
|
|
@@ -44,7 +44,7 @@ metadata:
|
|
|
44
44
|
workflow-role: coordinator
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Invoke the skill with Pi's `/skill:name` command, or supervise it with `/goal /skill:name task`. The goal extension resolves the command against Pi's registry, persists its source, and expands the skill on kickoff and continuation (including after session resume). A bound command that disappears or changes source pauses the goal. `/goal /template task` also re-expands prompt templates on continuation; `/goal /extension-command task` dispatches the extension command once, then continues with ordinary goal supervision to avoid repeating side effects. Plain-language goals work as before. This command binding requires Pi 0.84.4 or later. Legacy slash-shaped goals without a binding pause on resume rather than running without the skill. `/workflow` shows a coordinator skill owner
|
|
47
|
+
Invoke the skill with Pi's `/skill:name` command, or supervise it with `/goal /skill:name task`. The goal extension resolves the command against Pi's registry, persists its source, and expands the skill on kickoff and continuation (including after session resume). A bound command that disappears or changes source pauses the goal. `/goal /template task` also re-expands prompt templates on continuation; `/goal /extension-command task` dispatches the extension command once, then continues with ordinary goal supervision to avoid repeating side effects. Plain-language goals work as before. This command binding requires Pi 0.84.4 or later. Legacy slash-shaped goals without a binding pause on resume rather than running without the skill. `/workflow` shows a coordinator skill owner. When the workflow is done, complete the goal (`update_goal`), which also releases ownership. When it hands back unfinished work, call `release_workflow`, or use `/workflow clear` to release ownership manually. `release_workflow` also pauses an active Goal bound to that coordinator as a handback, preserving its objective and incomplete status. Resuming that Goal (`/goal resume`, `alt+g`, or, with conversational resume on, the user's go-ahead such as "continue") rebinds the coordinator it was created with, provided the skill is still registered at its original source; automatic continuation and synthetic input never reacquire ownership. Unrelated ordinary Goals are unchanged.
|
|
48
48
|
|
|
49
49
|
A skill that is only an alias of a coordinator declares the target instead of a role:
|
|
50
50
|
|
|
@@ -66,7 +66,7 @@ function isGoalStatus(value: unknown): value is GoalStatus {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function isPauseReason(value: unknown): value is GoalPauseReason {
|
|
69
|
-
return value === "interrupt" || value === "permission-blocker";
|
|
69
|
+
return value === "interrupt" || value === "permission-blocker" || value === "handback";
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function isGoalLike(value: unknown): value is GoalLike {
|
|
@@ -87,7 +87,8 @@ const GOAL_RESUME_RULE =
|
|
|
87
87
|
|
|
88
88
|
/** True when the agent may resume this goal with `goal_resume`. */
|
|
89
89
|
export function agentResumable(goal: GoalSnapshot | null, conversationalResume = true): boolean {
|
|
90
|
-
return conversationalResume && goal?.status === "paused" &&
|
|
90
|
+
return conversationalResume && goal?.status === "paused" &&
|
|
91
|
+
(goal.pauseReason === "interrupt" || goal.pauseReason === "handback");
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** Footer status for a paused goal, telling the user how to resume it. */
|
|
@@ -98,7 +99,9 @@ export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true
|
|
|
98
99
|
|
|
99
100
|
function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean): string {
|
|
100
101
|
return [
|
|
101
|
-
|
|
102
|
+
goal.pauseReason === "handback"
|
|
103
|
+
? `Pi Better Goal is paused because its workflow handed back unfinished work for the user's answer. Goal: ${goal.objective}`
|
|
104
|
+
: `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
|
|
102
105
|
"Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
|
|
103
106
|
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.",
|
|
104
107
|
].join("\n");
|
|
@@ -257,6 +260,7 @@ function formatGoal(
|
|
|
257
260
|
`Observable progress: ${stall?.state ?? "unknown"}`,
|
|
258
261
|
continuationStatus,
|
|
259
262
|
settings,
|
|
263
|
+
...(continuation?.blocked && goal.status === "active" ? ["Resume: /goal resume or alt+g"] : []),
|
|
260
264
|
...(goal.status === "paused"
|
|
261
265
|
? [agentResumable(goal, preferences.conversationalResume)
|
|
262
266
|
? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
|
|
@@ -432,10 +436,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
432
436
|
if (!isPokeable(goal)) {
|
|
433
437
|
return;
|
|
434
438
|
}
|
|
435
|
-
|
|
439
|
+
const held = currentContinuationState(ctx, goal.goalId)?.blocked === true;
|
|
440
|
+
setGoal(goalWithStatus(goal, "paused", undefined, held ? undefined : "interrupt"), ctx, "runtime");
|
|
436
441
|
notifyGoal(ctx, currentPermissionHold(ctx, goal.goalId).blockers.length > 0
|
|
437
442
|
? "Goal remains permission-held. Use /goal resume or alt+g for one same-scope retry."
|
|
438
|
-
: preferences.conversationalResume
|
|
443
|
+
: preferences.conversationalResume && !held
|
|
439
444
|
? 'Goal paused. Say "go" to resume it, or use /goal resume.'
|
|
440
445
|
: "Goal paused. Use /goal resume or alt+g to resume it.");
|
|
441
446
|
};
|
|
@@ -446,16 +451,29 @@ export default function (pi: ExtensionAPI): void {
|
|
|
446
451
|
escapeAbortSuppression.execution === executionGeneration;
|
|
447
452
|
|
|
448
453
|
/** Why a paused goal cannot become active again, or null when it can. */
|
|
449
|
-
const resumeBlocker = (goal: GoalSnapshot): string | null => {
|
|
454
|
+
const resumeBlocker = (goal: GoalSnapshot, ctx: ExtensionContext): string | null => {
|
|
450
455
|
if (!goal.command && skillCommandName(goal.objective)) {
|
|
451
456
|
return "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.";
|
|
452
457
|
}
|
|
453
458
|
if (goal.command && !commandAvailable(pi, goal.command)) {
|
|
454
459
|
return `Cannot resume: /${goal.command.name} is no longer registered at its original source.`;
|
|
455
460
|
}
|
|
461
|
+
if (goal.command?.source === "skill") {
|
|
462
|
+
try {
|
|
463
|
+
goalWorkflowOwner(goal);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
return `Cannot resume: ${error instanceof Error ? error.message : String(error)} Reinvoke /${goal.command.name}.`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
456
468
|
return null;
|
|
457
469
|
};
|
|
458
470
|
|
|
471
|
+
/** The workflow a skill-bound goal owns, re-resolved from its registered skill; null for other goals. */
|
|
472
|
+
const goalWorkflowOwner = (goal: GoalSnapshot): ReturnType<typeof currentWorkflowOwner> =>
|
|
473
|
+
goal.command?.source === "skill"
|
|
474
|
+
? workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath)
|
|
475
|
+
: null;
|
|
476
|
+
|
|
459
477
|
/**
|
|
460
478
|
* The one resume path shared by `/goal resume`, the hotkey, and `goal_resume`:
|
|
461
479
|
* reactivate a paused goal or reopen an active no-progress hold.
|
|
@@ -469,7 +487,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
469
487
|
if (!current || (current.status !== "paused" && !held)) {
|
|
470
488
|
return { ok: false, message: "Only paused or no-progress-held goals can be resumed." };
|
|
471
489
|
}
|
|
472
|
-
|
|
490
|
+
if (source === "tool" && currentContinuationState(ctx, current.goalId)?.blocked) {
|
|
491
|
+
return { ok: false, message: "Only /goal resume or alt+g can reopen a no-progress hold." };
|
|
492
|
+
}
|
|
493
|
+
const blocker = resumeBlocker(current, ctx);
|
|
473
494
|
if (blocker) {
|
|
474
495
|
return { ok: false, message: blocker };
|
|
475
496
|
}
|
|
@@ -486,6 +507,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
486
507
|
}
|
|
487
508
|
pi.appendEntry(EXTENSION_NAME, permissionRecord(current.goalId, "permission-release"));
|
|
488
509
|
}
|
|
510
|
+
// Resuming a skill-bound goal is explicit re-entry into its workflow: rebind the owner the
|
|
511
|
+
// goal was created with (released by a handback) before the continuation runs.
|
|
512
|
+
const owner = goalWorkflowOwner(current);
|
|
513
|
+
const activeOwner = getWorkflow(ctx);
|
|
514
|
+
if (owner && (activeOwner?.name !== owner.name || activeOwner.path !== owner.path)) recordWorkflow(owner);
|
|
489
515
|
setGoal(goal, ctx, source);
|
|
490
516
|
queueGoalContinuation(goal, ctx);
|
|
491
517
|
return { ok: true, goal };
|
|
@@ -544,7 +570,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
544
570
|
return;
|
|
545
571
|
}
|
|
546
572
|
if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
|
|
547
|
-
if (
|
|
573
|
+
if (currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
548
574
|
return;
|
|
549
575
|
}
|
|
550
576
|
const signature = `${goal.goalId}:${kind}`;
|
|
@@ -575,6 +601,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
575
601
|
const current = currentGoalSnapshot(ctx);
|
|
576
602
|
return isPokeable(current) && current.goalId === goalId &&
|
|
577
603
|
currentPermissionHold(ctx, goalId).blockers.length === 0 &&
|
|
604
|
+
!currentContinuationState(ctx, goalId)?.blocked &&
|
|
578
605
|
continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
|
|
579
606
|
};
|
|
580
607
|
const goal = currentGoalSnapshot(ctx);
|
|
@@ -637,7 +664,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
637
664
|
const goal = currentGoalSnapshot(ctx);
|
|
638
665
|
const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
|
|
639
666
|
backgroundDrainTracker = wakePlan.nextTracker;
|
|
640
|
-
if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0
|
|
667
|
+
if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
|
|
668
|
+
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
641
669
|
// Persist external progress now: a foreground/callback turn may cancel its delayed wake.
|
|
642
670
|
clearIdleContinuation();
|
|
643
671
|
resetContinuationState(goal);
|
|
@@ -1035,8 +1063,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1035
1063
|
name: GOAL_RESUME_TOOL,
|
|
1036
1064
|
label: "Resume Goal",
|
|
1037
1065
|
description:
|
|
1038
|
-
"Resume the goal the user paused with escape, exactly like /goal resume. " + GOAL_RESUME_RULE,
|
|
1039
|
-
promptSnippet: "Resume the escape-paused goal, only on the user's clear go-ahead.",
|
|
1066
|
+
"Resume the goal the user paused with escape, or one its workflow handed back, exactly like /goal resume. " + GOAL_RESUME_RULE,
|
|
1067
|
+
promptSnippet: "Resume the escape-paused or handed-back goal, only on the user's clear go-ahead.",
|
|
1040
1068
|
parameters: Type.Object({
|
|
1041
1069
|
reason: Type.Optional(Type.String({ description: "Short quote or summary of the user's go-ahead." })),
|
|
1042
1070
|
}),
|
|
@@ -1101,13 +1129,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1101
1129
|
pi.registerTool({
|
|
1102
1130
|
name: "release_workflow",
|
|
1103
1131
|
label: "Release Workflow",
|
|
1104
|
-
description: "Release the active skill-owned workflow
|
|
1132
|
+
description: "Release the active skill-owned workflow when it hands back unfinished work (a blocked unit awaiting the human). Its bound active Goal pauses; /goal resume rebinds the workflow. When the workflow is done, call update_goal complete instead, which also releases it.",
|
|
1105
1133
|
parameters: Type.Object({}),
|
|
1106
1134
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1107
1135
|
const owner = getWorkflow(ctx);
|
|
1108
1136
|
if (!owner) return { content: [{ type: "text", text: "No workflow owns this session." }], details: { released: false } };
|
|
1137
|
+
const goal = getGoal(ctx);
|
|
1138
|
+
let resumeCommand: string | undefined;
|
|
1139
|
+
if (goal?.status === "active" && goal.command?.source === "skill") {
|
|
1140
|
+
const boundOwner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
|
|
1141
|
+
if (boundOwner?.name === owner.name && boundOwner.path === owner.path) {
|
|
1142
|
+
setGoal(goalWithStatus(goal, "paused", undefined, "handback"), ctx, "tool");
|
|
1143
|
+
resumeCommand = goal.command.name;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1109
1146
|
recordWorkflow(null);
|
|
1110
|
-
return {
|
|
1147
|
+
return {
|
|
1148
|
+
content: [{ type: "text", text: `Released ${owner.name} workflow ownership.${resumeCommand ? ` Its Goal is paused, not complete; the user's go-ahead or /goal resume rebinds /${resumeCommand}.` : ""}` }],
|
|
1149
|
+
details: { released: true, owner: owner.name, goalPaused: resumeCommand !== undefined },
|
|
1150
|
+
};
|
|
1111
1151
|
},
|
|
1112
1152
|
});
|
|
1113
1153
|
|
|
@@ -1211,7 +1251,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1211
1251
|
// A paused goal stays paused while the user talks: the message is ordinary
|
|
1212
1252
|
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
1213
1253
|
const goal = getGoal(ctx);
|
|
1214
|
-
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0
|
|
1254
|
+
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
|
|
1255
|
+
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
1215
1256
|
executionGeneration += 1;
|
|
1216
1257
|
clearIdleContinuation();
|
|
1217
1258
|
lastAgentEvidence = null;
|
|
@@ -1234,7 +1275,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1234
1275
|
if (permissionPrompt && goal?.status === "paused") {
|
|
1235
1276
|
return { systemPrompt: `${event.systemPrompt}\n\n${permissionPrompt}` };
|
|
1236
1277
|
}
|
|
1237
|
-
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && goal.pauseReason === "interrupt"
|
|
1278
|
+
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && (goal.pauseReason === "interrupt" || goal.pauseReason === "handback")
|
|
1238
1279
|
? pausedGoalPrompt(goal, preferences.conversationalResume) : "");
|
|
1239
1280
|
if (!isPokeable(goal) && !owner) {
|
|
1240
1281
|
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
@@ -1243,6 +1284,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1243
1284
|
const questionInstruction = snapshot.backgroundRunning
|
|
1244
1285
|
? " A blocking user question (ask_user_question) holds this whole turn until the user answers, and background completions wait behind it. Harvest finished background results before asking, and ask only when the answer is needed to proceed."
|
|
1245
1286
|
: "";
|
|
1287
|
+
const heldInstruction = goal?.status === "active" && currentContinuationState(ctx, goal.goalId)?.blocked
|
|
1288
|
+
? "Automatic Goal continuation is held for no progress. Answer the current user message or inspect delivered background results without restarting autonomous Goal work. Only /goal resume or alt+g reopens this hold."
|
|
1289
|
+
: "";
|
|
1246
1290
|
|
|
1247
1291
|
if (owner) {
|
|
1248
1292
|
if (!workflowAvailable(owner)) {
|
|
@@ -1266,7 +1310,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1266
1310
|
: "") +
|
|
1267
1311
|
`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}` +
|
|
1268
1312
|
(isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
|
|
1269
|
-
(questionInstruction ? `\n\n${questionInstruction.trim()}` : "")
|
|
1313
|
+
(questionInstruction ? `\n\n${questionInstruction.trim()}` : "") +
|
|
1314
|
+
(heldInstruction ? `\n\n${heldInstruction}` : ""),
|
|
1270
1315
|
};
|
|
1271
1316
|
}
|
|
1272
1317
|
|
|
@@ -1277,7 +1322,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1277
1322
|
return {
|
|
1278
1323
|
systemPrompt:
|
|
1279
1324
|
`${event.systemPrompt}\n\n` +
|
|
1280
|
-
`Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit
|
|
1325
|
+
`Pi Better Goal active objective: ${goal.objective}. ${heldInstruction || "Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit."}${backgroundInstruction}${questionInstruction}` +
|
|
1281
1326
|
(permissionPrompt ? `\n\n${permissionPrompt}` : ""),
|
|
1282
1327
|
};
|
|
1283
1328
|
});
|
|
@@ -1408,6 +1453,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1408
1453
|
return;
|
|
1409
1454
|
}
|
|
1410
1455
|
const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
|
|
1456
|
+
if (previous.blocked) return;
|
|
1411
1457
|
const repeated = previous.lastEvidenceSignature === evidence.signature;
|
|
1412
1458
|
const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
|
|
1413
1459
|
const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
|
|
@@ -1428,7 +1474,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1428
1474
|
if (ctx.hasUI) {
|
|
1429
1475
|
ctx.ui.setStatus(EXTENSION_NAME, "waiting: no progress");
|
|
1430
1476
|
ctx.ui.notify(
|
|
1431
|
-
`Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}).
|
|
1477
|
+
`Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}). Use /goal resume or alt+g to resume it.`,
|
|
1432
1478
|
"warning",
|
|
1433
1479
|
);
|
|
1434
1480
|
}
|
|
@@ -75,7 +75,8 @@ export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
|
|
|
75
75
|
* requires those human controls, and releases just one same-scope retry while
|
|
76
76
|
* keeping its separate blocker/release history.
|
|
77
77
|
*/
|
|
78
|
-
|
|
78
|
+
/** Why a goal paused: escape, a permission hold, or a workflow handing back unfinished work for the user's answer. */
|
|
79
|
+
export type GoalPauseReason = "interrupt" | "permission-blocker" | "handback";
|
|
79
80
|
|
|
80
81
|
export interface GoalUsage {
|
|
81
82
|
tokensUsed: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.2",
|
|
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",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"pi-better-background-tasks": "0.8.0",
|
|
57
|
-
"pi-better-goal": "0.8.
|
|
57
|
+
"pi-better-goal": "0.8.4",
|
|
58
58
|
"pi-better-plan": "0.8.0",
|
|
59
59
|
"pi-better-sandbox": "0.12.0",
|
|
60
60
|
"pi-better-ssh": "0.1.2",
|