pi-better-goal 0.4.0 → 0.5.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
@@ -13,7 +13,8 @@ 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; 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
+ - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
17
18
  - A compact goal widget that does not replace Pi's footer.
18
19
  - Background activity tracking for subagents and other registered providers.
19
20
  - A progress-aware follow-up loop that holds after repeated identical outcomes.
@@ -30,6 +31,15 @@ metadata:
30
31
 
31
32
  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.
32
33
 
34
+ A skill that is only an alias of a coordinator declares the target instead of a role:
35
+
36
+ ```yaml
37
+ metadata:
38
+ workflow-alias-of: rush-issues
39
+ ```
40
+
41
+ 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.
42
+
33
43
  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.
34
44
 
35
45
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.4.0",
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",
@@ -37,7 +37,7 @@
37
37
  "scripts": {
38
38
  "pretest": "node ../../scripts/sync-shared-log-utils.mjs",
39
39
  "prepack": "node ../../scripts/sync-shared-log-utils.mjs",
40
- "test": "node --import tsx --test test/*.test.ts",
40
+ "test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.ts",
41
41
  "typecheck": "tsc --noEmit",
42
42
  "verify": "npm run typecheck && npm test"
43
43
  },
package/src/goal-state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { GoalCommandBinding } from "./command-binding.js";
3
3
 
4
- import { EXTENSION_NAME, type GoalSnapshot, type GoalStatus } from "./types.js";
4
+ import { EXTENSION_NAME, type GoalPauseReason, type GoalSnapshot, type GoalStatus } from "./types.js";
5
5
 
6
6
  const LEGACY_GOAL_ENTRY_TYPE = "pi-codex-goal";
7
7
  const MAX_OBJECTIVE_CHARS = 8000;
@@ -17,6 +17,7 @@ interface GoalLike {
17
17
  objective: string;
18
18
  command?: unknown;
19
19
  status: GoalStatus;
20
+ pauseReason?: unknown;
20
21
  tokenBudget?: number | null;
21
22
  usage?: { tokensUsed?: number; activeSeconds?: number };
22
23
  createdAt?: number;
@@ -64,6 +65,10 @@ function isGoalStatus(value: unknown): value is GoalStatus {
64
65
  return value === "active" || value === "paused" || value === "budgetLimited" || value === "complete";
65
66
  }
66
67
 
68
+ function isPauseReason(value: unknown): value is GoalPauseReason {
69
+ return value === "interrupt";
70
+ }
71
+
67
72
  function isGoalLike(value: unknown): value is GoalLike {
68
73
  if (!value || typeof value !== "object") {
69
74
  return false;
@@ -119,6 +124,7 @@ function normalizeGoal(goal: GoalLike): GoalSnapshot {
119
124
  objective: goal.objective,
120
125
  ...(isCommandBinding(goal.command) ? { command: goal.command } : {}),
121
126
  status: goal.status,
127
+ ...(goal.status === "paused" && isPauseReason(goal.pauseReason) ? { pauseReason: goal.pauseReason } : {}),
122
128
  tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
123
129
  usage: {
124
130
  tokensUsed: typeof goal.usage?.tokensUsed === "number" ? goal.usage.tokensUsed : 0,
@@ -209,7 +215,10 @@ export function goalWithStatus(
209
215
  goal: GoalSnapshot,
210
216
  status: GoalStatus,
211
217
  now = unixSeconds(),
218
+ pauseReason?: GoalPauseReason,
212
219
  ): GoalSnapshot {
220
+ const { pauseReason: _previousReason, ...rest } = goal;
221
+ goal = rest;
213
222
  const accruedActiveSeconds =
214
223
  goal.status === "active" && goal.activeStartedAt !== null
215
224
  ? Math.max(0, now - goal.activeStartedAt)
@@ -233,6 +242,7 @@ export function goalWithStatus(
233
242
  return {
234
243
  ...goal,
235
244
  status,
245
+ ...(status === "paused" && pauseReason ? { pauseReason } : {}),
236
246
  usage,
237
247
  updatedAt: now,
238
248
  activeStartedAt: null,
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  EXTENSION_VERSION,
46
46
  type ActivitySnapshot,
47
47
  type BackgroundActivityProvider,
48
+ type BackgroundWorkItem,
48
49
  type GoalSnapshot,
49
50
  } from "./types.js";
50
51
 
@@ -63,6 +64,42 @@ const MAX_NO_PROGRESS_RETRIES = parseRetryLimit(
63
64
  DEFAULT_MAX_NO_PROGRESS_RETRIES,
64
65
  );
65
66
 
67
+ /**
68
+ * Tools that block the foreground turn until the user answers. Background
69
+ * completions that arrive meanwhile queue behind the answer (Pi only drains
70
+ * steering after the whole tool batch, and callback batches are follow-ups
71
+ * that wait for the entire run), so the goal harvests them explicitly.
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
+
101
+ const BLOCKING_QUESTION_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
102
+
66
103
  const GOAL_ACTIONS: readonly AutocompleteItem[] = [
67
104
  { value: "pause", label: "pause", description: "Pause the active goal" },
68
105
  { value: "resume", label: "resume", description: "Resume the paused goal" },
@@ -112,6 +149,46 @@ function wasTurnAborted(messages: readonly unknown[]): boolean {
112
149
  return false;
113
150
  }
114
151
 
152
+ function activeItemsByKey(snapshot: ActivitySnapshot): Map<string, BackgroundWorkItem> {
153
+ const items = new Map<string, BackgroundWorkItem>();
154
+ for (const provider of snapshot.providers) {
155
+ for (const item of provider.items) {
156
+ if (item.active) items.set(`${provider.providerId}:${item.id}`, item);
157
+ }
158
+ }
159
+ return items;
160
+ }
161
+
162
+ /** Items that were active when a question started and are no longer active now. */
163
+ export function finishedSinceQuestion(
164
+ activeAtStart: ReadonlyMap<string, BackgroundWorkItem>,
165
+ snapshot: ActivitySnapshot,
166
+ ): BackgroundWorkItem[] {
167
+ const stillActive = activeItemsByKey(snapshot);
168
+ const current = new Map<string, BackgroundWorkItem>();
169
+ for (const provider of snapshot.providers) {
170
+ for (const item of provider.items) current.set(`${provider.providerId}:${item.id}`, item);
171
+ }
172
+ const finished: BackgroundWorkItem[] = [];
173
+ for (const [key, started] of activeAtStart) {
174
+ if (stillActive.has(key)) continue;
175
+ finished.push(current.get(key) ?? { ...started, active: false, status: "unknown" });
176
+ }
177
+ return finished;
178
+ }
179
+
180
+ function questionHarvestPrompt(finished: readonly BackgroundWorkItem[]): string {
181
+ const rows = finished.map((item) => {
182
+ const label = item.label ? `${item.label} (${item.id})` : item.id;
183
+ return `- ${label}: ${item.status}`;
184
+ });
185
+ return [
186
+ `${finished.length} background item${finished.length === 1 ? "" : "s"} finished while you were waiting on the user's answer:`,
187
+ ...rows,
188
+ "Inspect and integrate these results now (for example with subagent_result or bg_task_status) before acting on the answer; their completion notices may still arrive later as a batch.",
189
+ ].join("\n");
190
+ }
191
+
115
192
  function continuationPrompt(goal: GoalSnapshot, owner: ReturnType<typeof currentWorkflowOwner> = null): string {
116
193
  return [
117
194
  "Continue working toward the active thread goal.",
@@ -146,6 +223,11 @@ function formatGoal(
146
223
  `Elapsed time: ${timing.elapsedSeconds}s`,
147
224
  `Observable progress: ${stall?.state ?? "unknown"}`,
148
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
+ : []),
149
231
  ].join("\n");
150
232
  }
151
233
 
@@ -188,12 +270,15 @@ export default function (pi: ExtensionAPI): void {
188
270
  let idleContinuationSignature = "";
189
271
  let refreshGoalWidget: ((force?: boolean) => void) | undefined;
190
272
  let lastAgentEvidence: ContinuationEvidence | null = null;
273
+ /** Background items active when each pending blocking question started, keyed by tool call id. */
274
+ const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
191
275
 
192
276
  const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
193
277
  const getWorkflow = (ctx: ExtensionContext) => currentWorkflowOwner(ctx.sessionManager.getBranch());
278
+ const registeredSkillPath = (name: string): string | undefined =>
279
+ pi.getCommands?.().find((item) => item.name === `skill:${name}` && item.source === "skill")?.sourceInfo.path;
194
280
  const workflowAvailable = (owner: NonNullable<ReturnType<typeof currentWorkflowOwner>>): boolean => {
195
- const command = pi.getCommands?.().find((item) => item.name === `skill:${owner.name}` && item.source === "skill");
196
- if (command?.sourceInfo.path !== owner.path) return false;
281
+ if (registeredSkillPath(owner.name) !== owner.path) return false;
197
282
  try {
198
283
  return workflowOwnerFromSkill(owner.name, owner.path)?.planOwner === owner.planOwner;
199
284
  } catch {
@@ -252,6 +337,8 @@ export default function (pi: ExtensionAPI): void {
252
337
  backgroundDrainTracker = null;
253
338
  clearIdleContinuation();
254
339
  syncPollingState();
340
+ syncResumeTool(goal);
341
+ applyStatus(ctx);
255
342
  // Force a full redraw when the dock height changes (absent ↔ visible clock).
256
343
  refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
257
344
  };
@@ -265,16 +352,57 @@ export default function (pi: ExtensionAPI): void {
265
352
  backgroundDrainTracker = null;
266
353
  clearIdleContinuation();
267
354
  syncPollingState();
355
+ syncResumeTool(null);
356
+ applyStatus(ctx);
268
357
  refreshGoalWidget?.(wasVisible);
269
358
  };
270
359
 
360
+ /**
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.
365
+ */
271
366
  const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
272
367
  const goal = getGoal(ctx);
273
368
  if (!isPokeable(goal)) {
274
369
  return;
275
370
  }
276
- setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
277
- notifyGoal(ctx, "Goal paused (interrupted).");
371
+ setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
372
+ notifyGoal(ctx, 'Goal paused. Say "go" to resume it, or use /goal resume.');
373
+ };
374
+
375
+ /** Why a paused goal cannot become active again, or null when it can. */
376
+ const resumeBlocker = (goal: GoalSnapshot): string | null => {
377
+ if (!goal.command && skillCommandName(goal.objective)) {
378
+ return "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.";
379
+ }
380
+ if (goal.command && !commandAvailable(pi, goal.command)) {
381
+ return `Cannot resume: /${goal.command.name} is no longer registered at its original source.`;
382
+ }
383
+ return null;
384
+ };
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 };
278
406
  };
279
407
 
280
408
  const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
@@ -392,7 +520,7 @@ export default function (pi: ExtensionAPI): void {
392
520
  }
393
521
  const goal = createGoalSnapshot(objective.trim(), tokenBudget, undefined, command ?? undefined);
394
522
  const owner = command?.source === "skill"
395
- ? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path)
523
+ ? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path, registeredSkillPath)
396
524
  : null;
397
525
  if (getWorkflow(ctx)) recordWorkflow(null);
398
526
  setGoal(goal, ctx, source);
@@ -434,30 +562,62 @@ export default function (pi: ExtensionAPI): void {
434
562
  clearIdleContinuation();
435
563
  }
436
564
 
437
- if (ctx.hasUI) {
438
- const goal = getGoal(ctx);
439
- const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
440
- const goalStall = observeGoalStall(goal, continuation, {
441
- foregroundRunning,
442
- backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
443
- });
444
- const status = snapshot.backgroundRunning
445
- ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
446
- : continuation?.blocked
447
- ? "waiting: no progress"
448
- : goalStall?.state === "stalled"
449
- ? "goal stalled"
450
- : undefined;
451
- try {
452
- ctx.ui.setStatus(EXTENSION_NAME, status);
453
- } catch {
454
- // UI status is best-effort only.
455
- }
456
- }
565
+ applyStatus(ctx);
457
566
 
458
567
  return snapshot;
459
568
  };
460
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) {
575
+ for (const activeAtStart of pendingQuestions.values()) {
576
+ waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
577
+ }
578
+ }
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;
592
+ };
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
+
461
621
  const collectIfPossible = (): void => {
462
622
  const ctx = currentCtx;
463
623
  if (!ctx) {
@@ -600,21 +760,11 @@ export default function (pi: ExtensionAPI): void {
600
760
  }
601
761
 
602
762
  if (trimmed === "resume") {
603
- if (!current || current.status !== "paused") {
604
- notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
605
- return;
606
- }
607
- if (!current.command && skillCommandName(current.objective)) {
608
- notifyGoal(ctx, "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.", "error");
609
- return;
610
- }
611
- if (current.command && !commandAvailable(pi, current.command)) {
612
- notifyGoal(ctx, `Cannot resume: /${current.command.name} is no longer registered at its original source.`, "error");
763
+ const result = resumeGoal(ctx, "command");
764
+ if (!result.ok) {
765
+ notifyGoal(ctx, result.message, current?.status === "paused" ? "error" : "warning");
613
766
  return;
614
767
  }
615
- const goal = goalWithStatus(current, "active");
616
- setGoal(goal, ctx, "command");
617
- queueGoalContinuation(goal, ctx);
618
768
  notifyGoal(ctx, "Goal resumed.");
619
769
  return;
620
770
  }
@@ -717,6 +867,44 @@ export default function (pi: ExtensionAPI): void {
717
867
  },
718
868
  });
719
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
+
720
908
  pi.registerCommand("better-activity", {
721
909
  description: "Show foreground/background activity known to pi-better-goal",
722
910
  handler: async (_args, ctx) => {
@@ -777,6 +965,7 @@ export default function (pi: ExtensionAPI): void {
777
965
  notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
778
966
  }
779
967
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
968
+ syncResumeTool(getGoal(ctx));
780
969
  installGoalWidget(ctx);
781
970
  latestSnapshot = await publishSnapshot(ctx);
782
971
  syncPollingState();
@@ -794,7 +983,7 @@ export default function (pi: ExtensionAPI): void {
794
983
  const command = pi.getCommands?.().find((item) => item.name === `skill:${skillName}` && item.source === "skill");
795
984
  if (command) {
796
985
  try {
797
- const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path);
986
+ const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path, registeredSkillPath);
798
987
  if (owner) recordWorkflow(owner);
799
988
  } catch (error) {
800
989
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
@@ -802,6 +991,8 @@ export default function (pi: ExtensionAPI): void {
802
991
  }
803
992
  }
804
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.
805
996
  const goal = getGoal(ctx);
806
997
  if (goal?.status === "active") {
807
998
  resetContinuationState(goal);
@@ -813,10 +1004,15 @@ export default function (pi: ExtensionAPI): void {
813
1004
  const goal = currentGoalSnapshot(ctx);
814
1005
  const owner = getWorkflow(ctx);
815
1006
  const snapshot = await publishSnapshot(ctx);
1007
+ const pausedInstruction = agentResumable(goal) ? pausedGoalPrompt(goal!) : "";
816
1008
  if (!isPokeable(goal) && !owner) {
817
- return;
1009
+ return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
818
1010
  }
819
1011
 
1012
+ const questionInstruction = snapshot.backgroundRunning
1013
+ ? " 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."
1014
+ : "";
1015
+
820
1016
  if (owner) {
821
1017
  if (!workflowAvailable(owner)) {
822
1018
  if (isPokeable(goal)) setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
@@ -831,8 +1027,13 @@ export default function (pi: ExtensionAPI): void {
831
1027
  return { systemPrompt: `${event.systemPrompt}\n\nWorkflow ${owner.name} is unavailable. Stop work and ask the user to restore or reinvoke the skill.` };
832
1028
  }
833
1029
  return {
834
- 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}` +
835
- (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : ""),
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}` +
1035
+ (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
1036
+ (questionInstruction ? `\n\n${questionInstruction.trim()}` : ""),
836
1037
  };
837
1038
  }
838
1039
 
@@ -843,7 +1044,7 @@ export default function (pi: ExtensionAPI): void {
843
1044
  return {
844
1045
  systemPrompt:
845
1046
  `${event.systemPrompt}\n\n` +
846
- `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}`,
1047
+ `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}`,
847
1048
  };
848
1049
  });
849
1050
 
@@ -875,6 +1076,32 @@ export default function (pi: ExtensionAPI): void {
875
1076
  }
876
1077
  });
877
1078
 
1079
+ pi.on("tool_execution_start", async (event, ctx) => {
1080
+ if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
1081
+ currentCtx = ctx;
1082
+ const snapshot = await publishSnapshot(ctx);
1083
+ const active = activeItemsByKey(snapshot);
1084
+ if (active.size > 0) pendingQuestions.set(event.toolCallId, active);
1085
+ });
1086
+
1087
+ pi.on("tool_execution_end", async (event, ctx) => {
1088
+ const activeAtStart = pendingQuestions.get(event.toolCallId);
1089
+ if (!activeAtStart) return;
1090
+ pendingQuestions.delete(event.toolCallId);
1091
+ currentCtx = ctx;
1092
+ const snapshot = await publishSnapshot(ctx);
1093
+ const finished = finishedSinceQuestion(activeAtStart, snapshot);
1094
+ if (finished.length === 0) return;
1095
+ // Steering is drained right after this tool batch, so the model sees the
1096
+ // finished work together with the user's answer instead of after the run.
1097
+ pi.sendMessage({
1098
+ customType: EXTENSION_NAME,
1099
+ content: questionHarvestPrompt(finished),
1100
+ display: false,
1101
+ details: { kind: "question-harvest", toolCallId: event.toolCallId, finished: finished.map((item) => ({ id: item.id, status: item.status })) },
1102
+ }, { deliverAs: "steer", triggerTurn: true });
1103
+ });
1104
+
878
1105
  pi.on("agent_settled", async (_event, ctx) => {
879
1106
  currentCtx = ctx;
880
1107
  foregroundRunning = false;
@@ -917,6 +1144,7 @@ export default function (pi: ExtensionAPI): void {
917
1144
  pi.on("session_shutdown", async (_event, ctx) => {
918
1145
  stopPolling();
919
1146
  foregroundRunning = false;
1147
+ pendingQuestions.clear();
920
1148
  clearIdleContinuation();
921
1149
  currentCtx = undefined;
922
1150
  latestSnapshot = null;
package/src/types.ts CHANGED
@@ -65,6 +65,16 @@ export interface ActivitySnapshot {
65
65
 
66
66
  export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
67
67
 
68
+ /**
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.
75
+ */
76
+ export type GoalPauseReason = "interrupt";
77
+
68
78
  export interface GoalUsage {
69
79
  tokensUsed: number;
70
80
  activeSeconds: number;
@@ -75,6 +85,8 @@ export interface GoalSnapshot {
75
85
  objective: string;
76
86
  command?: GoalCommandBinding;
77
87
  status: GoalStatus;
88
+ /** Present only while `status` is "paused". */
89
+ pauseReason?: GoalPauseReason;
78
90
  tokenBudget: number | null;
79
91
  usage: GoalUsage;
80
92
  createdAt: number;
package/src/workflow.ts CHANGED
@@ -16,7 +16,12 @@ interface SessionEntryLike {
16
16
  data?: unknown;
17
17
  }
18
18
 
19
- export function workflowOwnerFromSkill(name: string, path: string): WorkflowOwner | null {
19
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
20
+
21
+ /** Resolves a registered skill's name to its SKILL.md path, or undefined when it is not registered. */
22
+ export type SkillPathResolver = (name: string) => string | undefined;
23
+
24
+ function skillMetadata(path: string): Record<string, unknown> | null {
20
25
  const source = readFileSync(path, "utf8");
21
26
  const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
22
27
  if (!match) return null;
@@ -24,9 +29,34 @@ export function workflowOwnerFromSkill(name: string, path: string): WorkflowOwne
24
29
  if (!frontmatter || typeof frontmatter !== "object") return null;
25
30
  const metadata = (frontmatter as { metadata?: unknown }).metadata;
26
31
  if (!metadata || typeof metadata !== "object") return null;
27
- const fields = metadata as Record<string, unknown>;
32
+ return metadata as Record<string, unknown>;
33
+ }
34
+
35
+ /**
36
+ * Reads a skill's workflow declaration. A coordinator declares `workflow-role: coordinator`.
37
+ * An alias declares `workflow-alias-of: <skill>` and resolves, through the registered skill
38
+ * commands, to that coordinator: the recorded owner is the target (its name and path), so
39
+ * invoking the alias is indistinguishable from invoking the coordinator. Aliases do not chain.
40
+ */
41
+ export function workflowOwnerFromSkill(name: string, path: string, resolveSkillPath?: SkillPathResolver): WorkflowOwner | null {
42
+ const fields = skillMetadata(path);
43
+ if (!fields) return null;
28
44
  const role = fields["workflow-role"];
29
45
  const legacyRole = fields["pi-better-plan-workflow"];
46
+ const aliasOf = fields["workflow-alias-of"];
47
+ if (aliasOf !== undefined) {
48
+ if (typeof aliasOf !== "string" || !SKILL_NAME.test(aliasOf) || aliasOf === name ||
49
+ role !== undefined || legacyRole !== undefined) {
50
+ throw new Error(`Invalid workflow metadata in ${path}. Expected workflow-alias-of: <coordinator skill name> without workflow-role.`);
51
+ }
52
+ const targetPath = resolveSkillPath?.(aliasOf);
53
+ if (!targetPath) throw new Error(`Workflow alias ${name} targets ${aliasOf}, which is not a registered skill.`);
54
+ const target = workflowOwnerFromSkill(aliasOf, targetPath, () => {
55
+ throw new Error(`Workflow alias ${name} targets ${aliasOf}, which is itself an alias; aliases do not chain.`);
56
+ });
57
+ if (!target) throw new Error(`Workflow alias ${name} targets ${aliasOf}, which is not a workflow coordinator.`);
58
+ return target;
59
+ }
30
60
  if (role === undefined && legacyRole === undefined) {
31
61
  if (fields["pi-better-workflow-role"] !== undefined || fields["pi-better-plan-owner"] !== undefined) {
32
62
  throw new Error(`Outdated workflow metadata in ${path}. Use workflow-role: coordinator.`);