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.
@@ -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; call `release_workflow` after the workflow's completion audit (or use `/workflow clear` to release it manually). 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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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" && goal.pauseReason === "interrupt";
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
- `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
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
- setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
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
- const blocker = resumeBlocker(current);
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 (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
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 after its final handoff and completion audit.",
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 { content: [{ type: "text", text: `Released ${owner.name} workflow ownership.` }], details: { released: true, owner: owner.name } };
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.${backgroundInstruction}${questionInstruction}` +
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}). New input or background activity will resume it.`,
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
- 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.28.0",
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.2",
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",