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 +12 -2
- package/package.json +4 -4
- package/src/command-binding.ts +37 -0
- package/src/goal-state.ts +24 -1
- package/src/index.ts +180 -37
- 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; 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
|
|
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
|
+
"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.
|
|
62
|
-
"@earendil-works/pi-tui": "^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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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.`);
|