pi-better-goal 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/goal-state.ts +1 -1
- package/src/index.ts +55 -12
- package/src/rehydrate.ts +57 -0
- package/src/types.ts +2 -1
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -55,6 +55,8 @@ metadata:
|
|
|
55
55
|
|
|
56
56
|
Invoking the alias (`/skill:resolve-issues`, or `/goal /skill:resolve-issues task`) records the target coordinator, with the target's registered path, as the workflow owner, so its task plan, continuation instructions, and plan tools behave exactly as if the target had been invoked. The target must be a registered `workflow-role: coordinator` skill; an unregistered target, a non-coordinator target, or a chain of aliases is refused with a notice.
|
|
57
57
|
|
|
58
|
+
After every successful context compaction (Pi's `session_compact` event) while a coordinator owns the session, the goal extension re-injects the skill so the model sees it before its next step; the compaction summary keeps run state but not the skill's rules, and the summary itself is left unchanged. If the skill directory ships `scripts/rehydrate.mjs` (checked first) or `rehydrate.mjs`, it runs as `node <script> --cwd <session cwd>` (no shell, 10-second timeout, stderr ignored) and its trimmed stdout becomes the message; empty output injects nothing, so the script decides whether a run is in progress. Without a script, or when it fails or times out, the message is: "Your context was just compacted while you coordinate the `<name>` workflow. Before your next step, read `<SKILL.md path>` in full, with no line limit." The message is a `workflow-rehydrate` custom message: inside a running agent loop (or before an overflow retry) it steers the next model call; otherwise it is appended without starting a turn.
|
|
59
|
+
|
|
58
60
|
When `pi-better-plan` is installed, it defers its prompt, checklist, and `update_plan` tool to a skill-owned task plan. Skills without this metadata retain normal goal and plan behavior. The skill itself owns its planning format and worker policy; the harness does not enumerate skills or impose a shared workflow schema. The former `pi-better-plan-workflow: coordinator` metadata remains supported for installed skills.
|
|
59
61
|
|
|
60
62
|
## Install
|
package/package.json
CHANGED
package/src/goal-state.ts
CHANGED
|
@@ -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 {
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { createRenderScheduler } from "./shared-render-scheduler.js";
|
|
41
41
|
import { collectSubagentActivity } from "./subagents.js";
|
|
42
42
|
import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
|
|
43
|
+
import { workflowRehydrateMessage, WORKFLOW_REHYDRATE_MESSAGE_TYPE } from "./rehydrate.js";
|
|
43
44
|
import {
|
|
44
45
|
EVENT_ACTIVITY,
|
|
45
46
|
EVENT_READY,
|
|
@@ -87,7 +88,8 @@ const GOAL_RESUME_RULE =
|
|
|
87
88
|
|
|
88
89
|
/** True when the agent may resume this goal with `goal_resume`. */
|
|
89
90
|
export function agentResumable(goal: GoalSnapshot | null, conversationalResume = true): boolean {
|
|
90
|
-
return conversationalResume && goal?.status === "paused" &&
|
|
91
|
+
return conversationalResume && goal?.status === "paused" &&
|
|
92
|
+
(goal.pauseReason === "interrupt" || goal.pauseReason === "handback");
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/** Footer status for a paused goal, telling the user how to resume it. */
|
|
@@ -98,7 +100,9 @@ export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true
|
|
|
98
100
|
|
|
99
101
|
function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean): string {
|
|
100
102
|
return [
|
|
101
|
-
|
|
103
|
+
goal.pauseReason === "handback"
|
|
104
|
+
? `Pi Better Goal is paused because its workflow handed back unfinished work for the user's answer. Goal: ${goal.objective}`
|
|
105
|
+
: `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
|
|
102
106
|
"Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
|
|
103
107
|
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
108
|
].join("\n");
|
|
@@ -309,6 +313,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
309
313
|
let snapshotSequence = 0;
|
|
310
314
|
let lastAppliedSnapshotSequence = 0;
|
|
311
315
|
let foregroundRunning = false;
|
|
316
|
+
/** True between agent_start and agent_end: the agent loop will call the model again without a new prompt. */
|
|
317
|
+
let agentLoopActive = false;
|
|
312
318
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
313
319
|
let collecting = false;
|
|
314
320
|
let collectionPending = false;
|
|
@@ -456,15 +462,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
456
462
|
return `Cannot resume: /${goal.command.name} is no longer registered at its original source.`;
|
|
457
463
|
}
|
|
458
464
|
if (goal.command?.source === "skill") {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
return `Reinvoke /${goal.command.name}
|
|
465
|
+
try {
|
|
466
|
+
goalWorkflowOwner(goal);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return `Cannot resume: ${error instanceof Error ? error.message : String(error)} Reinvoke /${goal.command.name}.`;
|
|
463
469
|
}
|
|
464
470
|
}
|
|
465
471
|
return null;
|
|
466
472
|
};
|
|
467
473
|
|
|
474
|
+
/** The workflow a skill-bound goal owns, re-resolved from its registered skill; null for other goals. */
|
|
475
|
+
const goalWorkflowOwner = (goal: GoalSnapshot): ReturnType<typeof currentWorkflowOwner> =>
|
|
476
|
+
goal.command?.source === "skill"
|
|
477
|
+
? workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath)
|
|
478
|
+
: null;
|
|
479
|
+
|
|
468
480
|
/**
|
|
469
481
|
* The one resume path shared by `/goal resume`, the hotkey, and `goal_resume`:
|
|
470
482
|
* reactivate a paused goal or reopen an active no-progress hold.
|
|
@@ -498,6 +510,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
498
510
|
}
|
|
499
511
|
pi.appendEntry(EXTENSION_NAME, permissionRecord(current.goalId, "permission-release"));
|
|
500
512
|
}
|
|
513
|
+
// Resuming a skill-bound goal is explicit re-entry into its workflow: rebind the owner the
|
|
514
|
+
// goal was created with (released by a handback) before the continuation runs.
|
|
515
|
+
const owner = goalWorkflowOwner(current);
|
|
516
|
+
const activeOwner = getWorkflow(ctx);
|
|
517
|
+
if (owner && (activeOwner?.name !== owner.name || activeOwner.path !== owner.path)) recordWorkflow(owner);
|
|
501
518
|
setGoal(goal, ctx, source);
|
|
502
519
|
queueGoalContinuation(goal, ctx);
|
|
503
520
|
return { ok: true, goal };
|
|
@@ -1049,8 +1066,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1049
1066
|
name: GOAL_RESUME_TOOL,
|
|
1050
1067
|
label: "Resume Goal",
|
|
1051
1068
|
description:
|
|
1052
|
-
"Resume the goal the user paused with escape, exactly like /goal resume. " + GOAL_RESUME_RULE,
|
|
1053
|
-
promptSnippet: "Resume the escape-paused goal, only on the user's clear go-ahead.",
|
|
1069
|
+
"Resume the goal the user paused with escape, or one its workflow handed back, exactly like /goal resume. " + GOAL_RESUME_RULE,
|
|
1070
|
+
promptSnippet: "Resume the escape-paused or handed-back goal, only on the user's clear go-ahead.",
|
|
1054
1071
|
parameters: Type.Object({
|
|
1055
1072
|
reason: Type.Optional(Type.String({ description: "Short quote or summary of the user's go-ahead." })),
|
|
1056
1073
|
}),
|
|
@@ -1115,7 +1132,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1115
1132
|
pi.registerTool({
|
|
1116
1133
|
name: "release_workflow",
|
|
1117
1134
|
label: "Release Workflow",
|
|
1118
|
-
description: "Release the active skill-owned workflow
|
|
1135
|
+
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.",
|
|
1119
1136
|
parameters: Type.Object({}),
|
|
1120
1137
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1121
1138
|
const owner = getWorkflow(ctx);
|
|
@@ -1125,13 +1142,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1125
1142
|
if (goal?.status === "active" && goal.command?.source === "skill") {
|
|
1126
1143
|
const boundOwner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
|
|
1127
1144
|
if (boundOwner?.name === owner.name && boundOwner.path === owner.path) {
|
|
1128
|
-
setGoal(goalWithStatus(goal, "paused"), ctx, "tool");
|
|
1145
|
+
setGoal(goalWithStatus(goal, "paused", undefined, "handback"), ctx, "tool");
|
|
1129
1146
|
resumeCommand = goal.command.name;
|
|
1130
1147
|
}
|
|
1131
1148
|
}
|
|
1132
1149
|
recordWorkflow(null);
|
|
1133
1150
|
return {
|
|
1134
|
-
content: [{ type: "text", text: `Released ${owner.name} workflow ownership.${resumeCommand ? ` Its Goal is paused, not complete
|
|
1151
|
+
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}.` : ""}` }],
|
|
1135
1152
|
details: { released: true, owner: owner.name, goalPaused: resumeCommand !== undefined },
|
|
1136
1153
|
};
|
|
1137
1154
|
},
|
|
@@ -1172,6 +1189,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1172
1189
|
collectionPending = false;
|
|
1173
1190
|
currentCtx = ctx;
|
|
1174
1191
|
foregroundRunning = !ctx.isIdle();
|
|
1192
|
+
agentLoopActive = false;
|
|
1175
1193
|
try {
|
|
1176
1194
|
preferences = readGoalPreferences();
|
|
1177
1195
|
} catch (error) {
|
|
@@ -1261,7 +1279,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1261
1279
|
if (permissionPrompt && goal?.status === "paused") {
|
|
1262
1280
|
return { systemPrompt: `${event.systemPrompt}\n\n${permissionPrompt}` };
|
|
1263
1281
|
}
|
|
1264
|
-
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && goal.pauseReason === "interrupt"
|
|
1282
|
+
const pausedInstruction = permissionPrompt || (goal?.status === "paused" && (goal.pauseReason === "interrupt" || goal.pauseReason === "handback")
|
|
1265
1283
|
? pausedGoalPrompt(goal, preferences.conversationalResume) : "");
|
|
1266
1284
|
if (!isPokeable(goal) && !owner) {
|
|
1267
1285
|
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
@@ -1320,6 +1338,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1320
1338
|
agentGeneration = executionGeneration;
|
|
1321
1339
|
currentCtx = ctx;
|
|
1322
1340
|
foregroundRunning = true;
|
|
1341
|
+
agentLoopActive = true;
|
|
1323
1342
|
clearIdleContinuation();
|
|
1324
1343
|
continuationQueuedFor = null;
|
|
1325
1344
|
lastAgentEvidence = null;
|
|
@@ -1341,6 +1360,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1341
1360
|
});
|
|
1342
1361
|
|
|
1343
1362
|
pi.on("agent_end", async (event, ctx) => {
|
|
1363
|
+
agentLoopActive = false;
|
|
1344
1364
|
lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
|
|
1345
1365
|
// Only an observed editor Escape with pause disabled exempts this abort.
|
|
1346
1366
|
// Unknown interrupt sources retain the safety fallback on every host.
|
|
@@ -1469,6 +1489,29 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1469
1489
|
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
1470
1490
|
});
|
|
1471
1491
|
|
|
1492
|
+
// A compaction summary keeps run state but drops the coordinator skill's rules. Re-inject them
|
|
1493
|
+
// (or the skill's own rehydrate script output) so the model sees them before its next step.
|
|
1494
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
1495
|
+
const owner = getWorkflow(ctx);
|
|
1496
|
+
if (!owner) return;
|
|
1497
|
+
const content = await workflowRehydrateMessage(owner, ctx.cwd);
|
|
1498
|
+
if (!content) return;
|
|
1499
|
+
// Inside a running agent loop (mid-run compaction or an overflow retry) steer it into the next
|
|
1500
|
+
// model call. Otherwise append it without triggering a turn: the next turn, whatever starts it,
|
|
1501
|
+
// sees it. Steering after the loop ended would start an unprompted continuation.
|
|
1502
|
+
const options = agentLoopActive || event.willRetry ? { deliverAs: "steer" as const } : { triggerTurn: false };
|
|
1503
|
+
try {
|
|
1504
|
+
pi.sendMessage({
|
|
1505
|
+
customType: WORKFLOW_REHYDRATE_MESSAGE_TYPE,
|
|
1506
|
+
content,
|
|
1507
|
+
display: true,
|
|
1508
|
+
details: { workflow: owner.name, path: owner.path, reason: event.reason },
|
|
1509
|
+
}, options);
|
|
1510
|
+
} catch {
|
|
1511
|
+
// Best effort: never fail or block the session over a rehydrate notice.
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1472
1515
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1473
1516
|
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
1474
1517
|
|
package/src/rehydrate.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { WorkflowOwner } from "./workflow.js";
|
|
6
|
+
|
|
7
|
+
export const WORKFLOW_REHYDRATE_MESSAGE_TYPE = "workflow-rehydrate";
|
|
8
|
+
export const REHYDRATE_TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
/** Script locations a coordinator skill may ship, in precedence order, relative to its SKILL.md directory. */
|
|
11
|
+
const REHYDRATE_SCRIPTS = [join("scripts", "rehydrate.mjs"), "rehydrate.mjs"];
|
|
12
|
+
|
|
13
|
+
export function rehydrateFallbackMessage(owner: WorkflowOwner): string {
|
|
14
|
+
return `Your context was just compacted while you coordinate the ${owner.name} workflow. ` +
|
|
15
|
+
`Before your next step, read ${owner.path} in full, with no line limit.`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The first rehydrate script the owner's skill ships, or undefined when it ships none. */
|
|
19
|
+
export function findRehydrateScript(owner: WorkflowOwner): string | undefined {
|
|
20
|
+
const skillDir = dirname(owner.path);
|
|
21
|
+
for (const relative of REHYDRATE_SCRIPTS) {
|
|
22
|
+
const candidate = join(skillDir, relative);
|
|
23
|
+
try {
|
|
24
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
25
|
+
} catch {
|
|
26
|
+
// Missing or unreadable: try the next location.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runScript(script: string, cwd: string, timeoutMs: number): Promise<string> {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
execFile("node", [script, "--cwd", cwd], { cwd, timeout: timeoutMs, encoding: "utf8", windowsHide: true },
|
|
35
|
+
(error, stdout) => (error ? reject(error) : resolve(stdout)));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The message to inject after a compaction while `owner` coordinates the session, or null for none.
|
|
41
|
+
* A skill's rehydrate script decides the text (empty output means no run is in progress); without
|
|
42
|
+
* a script, or when it fails or times out, the model is told to re-read SKILL.md. Never throws.
|
|
43
|
+
*/
|
|
44
|
+
export async function workflowRehydrateMessage(
|
|
45
|
+
owner: WorkflowOwner,
|
|
46
|
+
cwd: string,
|
|
47
|
+
options: { timeoutMs?: number } = {},
|
|
48
|
+
): Promise<string | null> {
|
|
49
|
+
try {
|
|
50
|
+
const script = findRehydrateScript(owner);
|
|
51
|
+
if (!script) return rehydrateFallbackMessage(owner);
|
|
52
|
+
const stdout = (await runScript(script, cwd, options.timeoutMs ?? REHYDRATE_TIMEOUT_MS)).trim();
|
|
53
|
+
return stdout.length > 0 ? stdout : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return rehydrateFallbackMessage(owner);
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -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;
|