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 your next message resumes it; `/goal pause` stays paused until `/goal resume`. Paused goals are never poked.
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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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) is a
325
- * soft pause: it stops autonomous continuation now, and the user's next
326
- * conversational message resumes the goal once that exchange settles. Only
327
- * `/goal pause` (or an unavailable command/workflow) is a sticky pause.
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, "Goal paused (interrupted). Send a message to resume it after that exchange, or use /goal pause to keep it paused.");
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
- if (ctx.hasUI) {
507
- const goal = getGoal(ctx);
508
- const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
509
- const goalStall = observeGoalStall(goal, continuation, {
510
- foregroundRunning,
511
- backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
512
- });
513
- let waitingOnAnswer = 0;
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
- return snapshot;
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
- if (!current || current.status !== "paused") {
679
- notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
680
- return;
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\nActive 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}` +
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 soft pause from escape (or
70
- * any other abort of the running turn): the user's next conversational message
71
- * resumes it. A paused goal without a reason (`/goal pause`, an unavailable
72
- * command or workflow) stays paused until `/goal resume`.
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.9.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.4.1",
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",