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 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; call `release_workflow` after the workflow's completion audit, or use `/workflow clear` to release ownership manually. `release_workflow` also pauses an active Goal bound to that coordinator, preserving its objective and incomplete status. Reinvoke the skill explicitly before `/goal resume`; automatic continuation cannot reacquire ownership. Unrelated ordinary Goals are unchanged. Completing an active goal also releases ownership.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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" && goal.pauseReason === "interrupt";
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
- `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
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
- const owner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
460
- const activeOwner = getWorkflow(ctx);
461
- if (owner && (activeOwner?.name !== owner.name || activeOwner.path !== owner.path)) {
462
- return `Reinvoke /${goal.command.name} before resuming its workflow goal.`;
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 after its final handoff and completion audit. Its bound active Goal pauses; explicitly reinvoke the skill before resuming that Goal.",
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. Reinvoke /${resumeCommand} before /goal resume.` : ""}` }],
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
 
@@ -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
- export type GoalPauseReason = "interrupt" | "permission-blocker";
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;