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 +11 -1
- package/package.json +2 -2
- package/src/goal-state.ts +11 -1
- package/src/index.ts +271 -43
- package/src/types.ts +12 -0
- package/src/workflow.ts +32 -2
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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\
|
|
835
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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.`);
|