pi-better-goal 0.3.1 → 0.4.1

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 your next message resumes it; `/goal pause` stays paused until `/goal resume`. Paused goals are never poked.
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.
@@ -28,7 +29,16 @@ metadata:
28
29
  workflow-role: coordinator
29
30
  ```
30
31
 
31
- Invoke the skill with Pi's `/skill:name` command. The goal extension checks Pi's skill-command provenance, persists the owner on the session branch, and restores its instructions on resumed turns. It does not infer ownership from ordinary text or skill names. A goal such as `/goal /skill:name task` is rejected; invoke `/skill:name task` directly and use `/goal` only for a plain-language objective. Legacy slash-shaped goals pause on resume rather than running without the skill. `/workflow` shows the 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
+ 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.
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.
32
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "verify": "npm run typecheck && npm test"
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
46
46
  "@earendil-works/pi-tui": "*",
47
47
  "typebox": "*"
48
48
  },
@@ -58,8 +58,8 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-coding-agent": "^0.82.0",
62
- "@earendil-works/pi-tui": "^0.82.0",
61
+ "@earendil-works/pi-coding-agent": "^0.84.4",
62
+ "@earendil-works/pi-tui": "^0.84.4",
63
63
  "@types/node": "^25.0.0",
64
64
  "tsx": "^4.22.3",
65
65
  "typebox": "^1.1.39",
@@ -0,0 +1,37 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+
4
+ export interface GoalCommandBinding {
5
+ name: string;
6
+ source: "skill" | "prompt" | "extension";
7
+ path: string;
8
+ args: string;
9
+ }
10
+
11
+ export function leadingCommand(text: string): { name: string; args: string } | null {
12
+ const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
13
+ return match ? { name: match[1]!, args: match[2]?.trim() ?? "" } : null;
14
+ }
15
+
16
+ export function resolveGoalCommand(pi: ExtensionAPI, text: string): GoalCommandBinding | null {
17
+ const parsed = leadingCommand(text);
18
+ if (!parsed) return null;
19
+ if (parsed.name === "goal") throw new Error("A goal cannot invoke /goal recursively.");
20
+ const command = pi.getCommands().find((item) => item.name === parsed.name);
21
+ if (!command) throw new Error(`Unknown command /${parsed.name}.`);
22
+ if (!command.sourceInfo.path) throw new Error(`Command /${parsed.name} has no stable source path.`);
23
+ return { name: command.name, source: command.source, path: command.sourceInfo.path, args: parsed.args };
24
+ }
25
+
26
+ export function commandAvailable(pi: ExtensionAPI, binding: GoalCommandBinding): boolean {
27
+ return (binding.source === "extension" || existsSync(binding.path)) &&
28
+ pi.getCommands().some((command) => command.name === binding.name &&
29
+ command.source === binding.source && command.sourceInfo.path === binding.path);
30
+ }
31
+
32
+ export function commandInvocation(binding: GoalCommandBinding, continuation = false): string {
33
+ const args = continuation
34
+ ? `${binding.args ? `${binding.args}\n\n` : ""}Continue the existing goal using the current session state. Do not restart completed work.`
35
+ : binding.args;
36
+ return `/${binding.name}${args ? ` ${args}` : ""}`;
37
+ }
package/src/goal-state.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { GoalCommandBinding } from "./command-binding.js";
2
3
 
3
- import { EXTENSION_NAME, type GoalSnapshot, type GoalStatus } from "./types.js";
4
+ import { EXTENSION_NAME, type GoalPauseReason, type GoalSnapshot, type GoalStatus } from "./types.js";
4
5
 
5
6
  const LEGACY_GOAL_ENTRY_TYPE = "pi-codex-goal";
6
7
  const MAX_OBJECTIVE_CHARS = 8000;
@@ -14,7 +15,9 @@ interface SessionEntryLike {
14
15
  interface GoalLike {
15
16
  goalId: string;
16
17
  objective: string;
18
+ command?: unknown;
17
19
  status: GoalStatus;
20
+ pauseReason?: unknown;
18
21
  tokenBudget?: number | null;
19
22
  usage?: { tokensUsed?: number; activeSeconds?: number };
20
23
  createdAt?: number;
@@ -62,6 +65,10 @@ function isGoalStatus(value: unknown): value is GoalStatus {
62
65
  return value === "active" || value === "paused" || value === "budgetLimited" || value === "complete";
63
66
  }
64
67
 
68
+ function isPauseReason(value: unknown): value is GoalPauseReason {
69
+ return value === "interrupt";
70
+ }
71
+
65
72
  function isGoalLike(value: unknown): value is GoalLike {
66
73
  if (!value || typeof value !== "object") {
67
74
  return false;
@@ -96,6 +103,14 @@ function unixSeconds(): number {
96
103
  return Math.floor(Date.now() / 1000);
97
104
  }
98
105
 
106
+ function isCommandBinding(value: unknown): value is GoalCommandBinding {
107
+ if (!value || typeof value !== "object") return false;
108
+ const binding = value as Partial<GoalCommandBinding>;
109
+ return typeof binding.name === "string" && binding.name.length > 0 &&
110
+ (binding.source === "skill" || binding.source === "prompt" || binding.source === "extension") &&
111
+ typeof binding.path === "string" && binding.path.length > 0 && typeof binding.args === "string";
112
+ }
113
+
99
114
  function nextGoalId(): string {
100
115
  return `goal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
101
116
  }
@@ -107,7 +122,9 @@ function normalizeGoal(goal: GoalLike): GoalSnapshot {
107
122
  return {
108
123
  goalId: goal.goalId,
109
124
  objective: goal.objective,
125
+ ...(isCommandBinding(goal.command) ? { command: goal.command } : {}),
110
126
  status: goal.status,
127
+ ...(goal.status === "paused" && isPauseReason(goal.pauseReason) ? { pauseReason: goal.pauseReason } : {}),
111
128
  tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
112
129
  usage: {
113
130
  tokensUsed: typeof goal.usage?.tokensUsed === "number" ? goal.usage.tokensUsed : 0,
@@ -134,10 +151,12 @@ export function createGoalSnapshot(
134
151
  objective: string,
135
152
  tokenBudget: number | null = null,
136
153
  now = unixSeconds(),
154
+ command?: GoalCommandBinding,
137
155
  ): GoalSnapshot {
138
156
  return {
139
157
  goalId: nextGoalId(),
140
158
  objective,
159
+ ...(command ? { command } : {}),
141
160
  status: "active",
142
161
  tokenBudget,
143
162
  usage: { tokensUsed: 0, activeSeconds: 0 },
@@ -196,7 +215,10 @@ export function goalWithStatus(
196
215
  goal: GoalSnapshot,
197
216
  status: GoalStatus,
198
217
  now = unixSeconds(),
218
+ pauseReason?: GoalPauseReason,
199
219
  ): GoalSnapshot {
220
+ const { pauseReason: _previousReason, ...rest } = goal;
221
+ goal = rest;
200
222
  const accruedActiveSeconds =
201
223
  goal.status === "active" && goal.activeStartedAt !== null
202
224
  ? Math.max(0, now - goal.activeStartedAt)
@@ -220,6 +242,7 @@ export function goalWithStatus(
220
242
  return {
221
243
  ...goal,
222
244
  status,
245
+ ...(status === "paused" && pauseReason ? { pauseReason } : {}),
223
246
  usage,
224
247
  updatedAt: now,
225
248
  activeStartedAt: null,
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { Type } from "typebox";
5
+ import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
5
6
 
6
7
  import {
7
8
  collectActivitySnapshot,
@@ -44,6 +45,7 @@ import {
44
45
  EXTENSION_VERSION,
45
46
  type ActivitySnapshot,
46
47
  type BackgroundActivityProvider,
48
+ type BackgroundWorkItem,
47
49
  type GoalSnapshot,
48
50
  } from "./types.js";
49
51
 
@@ -62,6 +64,14 @@ const MAX_NO_PROGRESS_RETRIES = parseRetryLimit(
62
64
  DEFAULT_MAX_NO_PROGRESS_RETRIES,
63
65
  );
64
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
+ const BLOCKING_QUESTION_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
74
+
65
75
  const GOAL_ACTIONS: readonly AutocompleteItem[] = [
66
76
  { value: "pause", label: "pause", description: "Pause the active goal" },
67
77
  { value: "resume", label: "resume", description: "Resume the paused goal" },
@@ -111,6 +121,46 @@ function wasTurnAborted(messages: readonly unknown[]): boolean {
111
121
  return false;
112
122
  }
113
123
 
124
+ function activeItemsByKey(snapshot: ActivitySnapshot): Map<string, BackgroundWorkItem> {
125
+ const items = new Map<string, BackgroundWorkItem>();
126
+ for (const provider of snapshot.providers) {
127
+ for (const item of provider.items) {
128
+ if (item.active) items.set(`${provider.providerId}:${item.id}`, item);
129
+ }
130
+ }
131
+ return items;
132
+ }
133
+
134
+ /** Items that were active when a question started and are no longer active now. */
135
+ export function finishedSinceQuestion(
136
+ activeAtStart: ReadonlyMap<string, BackgroundWorkItem>,
137
+ snapshot: ActivitySnapshot,
138
+ ): BackgroundWorkItem[] {
139
+ const stillActive = activeItemsByKey(snapshot);
140
+ const current = new Map<string, BackgroundWorkItem>();
141
+ for (const provider of snapshot.providers) {
142
+ for (const item of provider.items) current.set(`${provider.providerId}:${item.id}`, item);
143
+ }
144
+ const finished: BackgroundWorkItem[] = [];
145
+ for (const [key, started] of activeAtStart) {
146
+ if (stillActive.has(key)) continue;
147
+ finished.push(current.get(key) ?? { ...started, active: false, status: "unknown" });
148
+ }
149
+ return finished;
150
+ }
151
+
152
+ function questionHarvestPrompt(finished: readonly BackgroundWorkItem[]): string {
153
+ const rows = finished.map((item) => {
154
+ const label = item.label ? `${item.label} (${item.id})` : item.id;
155
+ return `- ${label}: ${item.status}`;
156
+ });
157
+ return [
158
+ `${finished.length} background item${finished.length === 1 ? "" : "s"} finished while you were waiting on the user's answer:`,
159
+ ...rows,
160
+ "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.",
161
+ ].join("\n");
162
+ }
163
+
114
164
  function continuationPrompt(goal: GoalSnapshot, owner: ReturnType<typeof currentWorkflowOwner> = null): string {
115
165
  return [
116
166
  "Continue working toward the active thread goal.",
@@ -187,12 +237,15 @@ export default function (pi: ExtensionAPI): void {
187
237
  let idleContinuationSignature = "";
188
238
  let refreshGoalWidget: ((force?: boolean) => void) | undefined;
189
239
  let lastAgentEvidence: ContinuationEvidence | null = null;
240
+ /** Background items active when each pending blocking question started, keyed by tool call id. */
241
+ const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
190
242
 
191
243
  const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
192
244
  const getWorkflow = (ctx: ExtensionContext) => currentWorkflowOwner(ctx.sessionManager.getBranch());
245
+ const registeredSkillPath = (name: string): string | undefined =>
246
+ pi.getCommands?.().find((item) => item.name === `skill:${name}` && item.source === "skill")?.sourceInfo.path;
193
247
  const workflowAvailable = (owner: NonNullable<ReturnType<typeof currentWorkflowOwner>>): boolean => {
194
- const command = pi.getCommands?.().find((item) => item.name === `skill:${owner.name}` && item.source === "skill");
195
- if (command?.sourceInfo.path !== owner.path) return false;
248
+ if (registeredSkillPath(owner.name) !== owner.path) return false;
196
249
  try {
197
250
  return workflowOwnerFromSkill(owner.name, owner.path)?.planOwner === owner.planOwner;
198
251
  } catch {
@@ -267,31 +320,61 @@ export default function (pi: ExtensionAPI): void {
267
320
  refreshGoalWidget?.(wasVisible);
268
321
  };
269
322
 
323
+ /**
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.
328
+ */
270
329
  const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
271
330
  const goal = getGoal(ctx);
272
331
  if (!isPokeable(goal)) {
273
332
  return;
274
333
  }
334
+ 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.");
336
+ };
337
+
338
+ /** Why a paused goal cannot become active again, or null when it can. */
339
+ const resumeBlocker = (goal: GoalSnapshot): string | null => {
340
+ if (!goal.command && skillCommandName(goal.objective)) {
341
+ return "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.";
342
+ }
343
+ if (goal.command && !commandAvailable(pi, goal.command)) {
344
+ return `Cannot resume: /${goal.command.name} is no longer registered at its original source.`;
345
+ }
346
+ return null;
347
+ };
348
+
349
+ const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
350
+ if (!goal.command || commandAvailable(pi, goal.command)) return true;
275
351
  setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
276
- notifyGoal(ctx, "Goal paused (interrupted).");
352
+ notifyGoal(ctx, `Goal paused: /${goal.command.name} is no longer registered at its original source.`, "error");
353
+ return false;
354
+ };
355
+
356
+ const sendGoalContinuation = (goal: GoalSnapshot, content: string, kind: string, snapshot?: ActivitySnapshot): void => {
357
+ if (goal.command?.source === "skill" || goal.command?.source === "prompt") {
358
+ pi.sendUserMessage(`${commandInvocation(goal.command, true)}\n\n${content}`, {
359
+ deliverAs: "followUp", expandPromptTemplates: true,
360
+ });
361
+ } else {
362
+ pi.sendMessage({
363
+ customType: EXTENSION_NAME, content, display: false,
364
+ details: snapshot ? { kind, snapshot } : { kind, goalId: goal.goalId },
365
+ }, { triggerTurn: true, deliverAs: "followUp" });
366
+ }
277
367
  };
278
368
 
279
- const queueGoalContinuation = (goal: GoalSnapshot): void => {
369
+ const queueGoalContinuation = (goal: GoalSnapshot, ctx: ExtensionContext): void => {
280
370
  if (!isPokeable(goal) || continuationQueuedFor === goal.goalId) {
281
371
  return;
282
372
  }
373
+ if (!boundCommandReady(goal, ctx)) return;
283
374
  clearIdleContinuation();
284
375
  continuationQueuedFor = goal.goalId;
285
376
  resetContinuationState(goal);
286
- pi.sendMessage(
287
- {
288
- customType: EXTENSION_NAME,
289
- content: continuationPrompt(goal, currentCtx ? getWorkflow(currentCtx) : null),
290
- display: false,
291
- details: { kind: "continuation", goalId: goal.goalId },
292
- },
293
- { triggerTurn: true, deliverAs: "followUp" },
294
- );
377
+ sendGoalContinuation(goal, continuationPrompt(goal, getWorkflow(ctx)), "continuation");
295
378
  };
296
379
 
297
380
  function clearIdleContinuation(): void {
@@ -338,6 +421,7 @@ export default function (pi: ExtensionAPI): void {
338
421
  if (!isPokeable(goal) || goal.goalId !== goalId || continuationQueuedFor === goal.goalId || foregroundRunning) {
339
422
  return;
340
423
  }
424
+ if (!boundCommandReady(goal, ctx)) return;
341
425
  const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
342
426
  latestSnapshot = snapshot;
343
427
  pi.events.emit(EVENT_ACTIVITY, snapshot);
@@ -354,17 +438,7 @@ export default function (pi: ExtensionAPI): void {
354
438
  ? "Background activity for the active goal is no longer running. Inspect any subagent callbacks or final results, then continue the completion audit before marking the goal complete.\n\n" +
355
439
  continuationPrompt(goal, getWorkflow(ctx))
356
440
  : continuationPrompt(goal, getWorkflow(ctx));
357
- pi.sendMessage(
358
- {
359
- customType: EXTENSION_NAME,
360
- content,
361
- display: false,
362
- details: kind === "background-drained"
363
- ? { kind, snapshot: priorSnapshot ?? snapshot }
364
- : { kind, goalId: goal.goalId },
365
- },
366
- { triggerTurn: true, deliverAs: "followUp" },
367
- );
441
+ sendGoalContinuation(goal, content, kind, kind === "background-drained" ? priorSnapshot ?? snapshot : undefined);
368
442
  };
369
443
 
370
444
  const startOrReplaceGoal = (
@@ -373,9 +447,6 @@ export default function (pi: ExtensionAPI): void {
373
447
  ctx: ExtensionContext,
374
448
  source: "command",
375
449
  ): GoalSnapshot => {
376
- if (skillCommandName(objective)) {
377
- throw new Error("A skill command cannot be a goal objective. Invoke /skill:name directly so Pi loads the skill, then set a plain-language goal if needed.");
378
- }
379
450
  const objectiveError = validateObjective(objective);
380
451
  if (objectiveError) {
381
452
  throw new Error(objectiveError);
@@ -384,9 +455,23 @@ export default function (pi: ExtensionAPI): void {
384
455
  if (budgetError) {
385
456
  throw new Error(budgetError);
386
457
  }
387
- const goal = createGoalSnapshot(objective.trim(), tokenBudget);
458
+ const command = resolveGoalCommand(pi, objective);
459
+ if (command && !commandAvailable(pi, command)) {
460
+ throw new Error(`Command /${command.name} is unavailable at its registered source.`);
461
+ }
462
+ const goal = createGoalSnapshot(objective.trim(), tokenBudget, undefined, command ?? undefined);
463
+ const owner = command?.source === "skill"
464
+ ? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path, registeredSkillPath)
465
+ : null;
466
+ if (getWorkflow(ctx)) recordWorkflow(null);
388
467
  setGoal(goal, ctx, source);
389
- queueGoalContinuation(goal);
468
+ if (command) {
469
+ if (owner) recordWorkflow(owner);
470
+ pi.sendUserMessage(commandInvocation(command), { deliverAs: "followUp", expandPromptTemplates: true });
471
+ if (command.source === "extension") scheduleIdleContinuation(goal, ctx, "continuation");
472
+ } else {
473
+ queueGoalContinuation(goal, ctx);
474
+ }
390
475
  return goal;
391
476
  };
392
477
 
@@ -425,7 +510,13 @@ export default function (pi: ExtensionAPI): void {
425
510
  foregroundRunning,
426
511
  backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
427
512
  });
428
- const status = snapshot.backgroundRunning
513
+ let waitingOnAnswer = 0;
514
+ for (const activeAtStart of pendingQuestions.values()) {
515
+ waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
516
+ }
517
+ const status = waitingOnAnswer > 0
518
+ ? `${waitingOnAnswer} background done; waiting on your answer`
519
+ : snapshot.backgroundRunning
429
520
  ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
430
521
  : continuation?.blocked
431
522
  ? "waiting: no progress"
@@ -588,13 +679,14 @@ export default function (pi: ExtensionAPI): void {
588
679
  notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
589
680
  return;
590
681
  }
591
- if (skillCommandName(current.objective)) {
592
- notifyGoal(ctx, "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.", "error");
682
+ const blocker = resumeBlocker(current);
683
+ if (blocker) {
684
+ notifyGoal(ctx, blocker, "error");
593
685
  return;
594
686
  }
595
687
  const goal = goalWithStatus(current, "active");
596
688
  setGoal(goal, ctx, "command");
597
- queueGoalContinuation(goal);
689
+ queueGoalContinuation(goal, ctx);
598
690
  notifyGoal(ctx, "Goal resumed.");
599
691
  return;
600
692
  }
@@ -752,7 +844,7 @@ export default function (pi: ExtensionAPI): void {
752
844
  currentCtx = ctx;
753
845
  foregroundRunning = !ctx.isIdle();
754
846
  const restoredGoal = getGoal(ctx);
755
- if (restoredGoal?.status === "active" && skillCommandName(restoredGoal.objective)) {
847
+ if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
756
848
  setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
757
849
  notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
758
850
  }
@@ -760,6 +852,9 @@ export default function (pi: ExtensionAPI): void {
760
852
  installGoalWidget(ctx);
761
853
  latestSnapshot = await publishSnapshot(ctx);
762
854
  syncPollingState();
855
+ if (restoredGoal?.status === "active" && restoredGoal.command && boundCommandReady(restoredGoal, ctx) && !foregroundRunning && !latestSnapshot.backgroundRunning) {
856
+ scheduleIdleContinuation(restoredGoal, ctx, "continuation");
857
+ }
763
858
  });
764
859
 
765
860
  pi.on("input", async (event, ctx) => {
@@ -771,7 +866,7 @@ export default function (pi: ExtensionAPI): void {
771
866
  const command = pi.getCommands?.().find((item) => item.name === `skill:${skillName}` && item.source === "skill");
772
867
  if (command) {
773
868
  try {
774
- const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path);
869
+ const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path, registeredSkillPath);
775
870
  if (owner) recordWorkflow(owner);
776
871
  } catch (error) {
777
872
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
@@ -780,6 +875,22 @@ export default function (pi: ExtensionAPI): void {
780
875
  }
781
876
  }
782
877
  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
+ }
783
894
  if (goal?.status === "active") {
784
895
  resetContinuationState(goal);
785
896
  }
@@ -794,6 +905,10 @@ export default function (pi: ExtensionAPI): void {
794
905
  return;
795
906
  }
796
907
 
908
+ const questionInstruction = snapshot.backgroundRunning
909
+ ? " 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."
910
+ : "";
911
+
797
912
  if (owner) {
798
913
  if (!workflowAvailable(owner)) {
799
914
  if (isPokeable(goal)) setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
@@ -809,7 +924,8 @@ export default function (pi: ExtensionAPI): void {
809
924
  }
810
925
  return {
811
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}` +
812
- (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : ""),
927
+ (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
928
+ (questionInstruction ? `\n\n${questionInstruction.trim()}` : ""),
813
929
  };
814
930
  }
815
931
 
@@ -820,7 +936,7 @@ export default function (pi: ExtensionAPI): void {
820
936
  return {
821
937
  systemPrompt:
822
938
  `${event.systemPrompt}\n\n` +
823
- `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}`,
939
+ `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}`,
824
940
  };
825
941
  });
826
942
 
@@ -852,6 +968,32 @@ export default function (pi: ExtensionAPI): void {
852
968
  }
853
969
  });
854
970
 
971
+ pi.on("tool_execution_start", async (event, ctx) => {
972
+ if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
973
+ currentCtx = ctx;
974
+ const snapshot = await publishSnapshot(ctx);
975
+ const active = activeItemsByKey(snapshot);
976
+ if (active.size > 0) pendingQuestions.set(event.toolCallId, active);
977
+ });
978
+
979
+ pi.on("tool_execution_end", async (event, ctx) => {
980
+ const activeAtStart = pendingQuestions.get(event.toolCallId);
981
+ if (!activeAtStart) return;
982
+ pendingQuestions.delete(event.toolCallId);
983
+ currentCtx = ctx;
984
+ const snapshot = await publishSnapshot(ctx);
985
+ const finished = finishedSinceQuestion(activeAtStart, snapshot);
986
+ if (finished.length === 0) return;
987
+ // Steering is drained right after this tool batch, so the model sees the
988
+ // finished work together with the user's answer instead of after the run.
989
+ pi.sendMessage({
990
+ customType: EXTENSION_NAME,
991
+ content: questionHarvestPrompt(finished),
992
+ display: false,
993
+ details: { kind: "question-harvest", toolCallId: event.toolCallId, finished: finished.map((item) => ({ id: item.id, status: item.status })) },
994
+ }, { deliverAs: "steer", triggerTurn: true });
995
+ });
996
+
855
997
  pi.on("agent_settled", async (_event, ctx) => {
856
998
  currentCtx = ctx;
857
999
  foregroundRunning = false;
@@ -894,6 +1036,7 @@ export default function (pi: ExtensionAPI): void {
894
1036
  pi.on("session_shutdown", async (_event, ctx) => {
895
1037
  stopPolling();
896
1038
  foregroundRunning = false;
1039
+ pendingQuestions.clear();
897
1040
  clearIdleContinuation();
898
1041
  currentCtx = undefined;
899
1042
  latestSnapshot = null;
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { GoalCommandBinding } from "./command-binding.js";
2
3
 
3
4
  export const EXTENSION_NAME = "pi-better-goal";
4
5
  export const EXTENSION_VERSION = "0.1.0";
@@ -64,6 +65,14 @@ export interface ActivitySnapshot {
64
65
 
65
66
  export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
66
67
 
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`.
73
+ */
74
+ export type GoalPauseReason = "interrupt";
75
+
67
76
  export interface GoalUsage {
68
77
  tokensUsed: number;
69
78
  activeSeconds: number;
@@ -72,7 +81,10 @@ export interface GoalUsage {
72
81
  export interface GoalSnapshot {
73
82
  goalId: string;
74
83
  objective: string;
84
+ command?: GoalCommandBinding;
75
85
  status: GoalStatus;
86
+ /** Present only while `status` is "paused". */
87
+ pauseReason?: GoalPauseReason;
76
88
  tokenBudget: number | null;
77
89
  usage: GoalUsage;
78
90
  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.`);