pi-better-goal 0.4.0 → 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 +11 -1
- package/package.json +1 -1
- package/src/goal-state.ts +11 -1
- package/src/index.ts +135 -15
- package/src/types.ts +10 -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.
|
|
@@ -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
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,14 @@ 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
|
+
const BLOCKING_QUESTION_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
|
|
74
|
+
|
|
66
75
|
const GOAL_ACTIONS: readonly AutocompleteItem[] = [
|
|
67
76
|
{ value: "pause", label: "pause", description: "Pause the active goal" },
|
|
68
77
|
{ value: "resume", label: "resume", description: "Resume the paused goal" },
|
|
@@ -112,6 +121,46 @@ function wasTurnAborted(messages: readonly unknown[]): boolean {
|
|
|
112
121
|
return false;
|
|
113
122
|
}
|
|
114
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
|
+
|
|
115
164
|
function continuationPrompt(goal: GoalSnapshot, owner: ReturnType<typeof currentWorkflowOwner> = null): string {
|
|
116
165
|
return [
|
|
117
166
|
"Continue working toward the active thread goal.",
|
|
@@ -188,12 +237,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
188
237
|
let idleContinuationSignature = "";
|
|
189
238
|
let refreshGoalWidget: ((force?: boolean) => void) | undefined;
|
|
190
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>>();
|
|
191
242
|
|
|
192
243
|
const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
|
|
193
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;
|
|
194
247
|
const workflowAvailable = (owner: NonNullable<ReturnType<typeof currentWorkflowOwner>>): boolean => {
|
|
195
|
-
|
|
196
|
-
if (command?.sourceInfo.path !== owner.path) return false;
|
|
248
|
+
if (registeredSkillPath(owner.name) !== owner.path) return false;
|
|
197
249
|
try {
|
|
198
250
|
return workflowOwnerFromSkill(owner.name, owner.path)?.planOwner === owner.planOwner;
|
|
199
251
|
} catch {
|
|
@@ -268,13 +320,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
268
320
|
refreshGoalWidget?.(wasVisible);
|
|
269
321
|
};
|
|
270
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
|
+
*/
|
|
271
329
|
const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
|
|
272
330
|
const goal = getGoal(ctx);
|
|
273
331
|
if (!isPokeable(goal)) {
|
|
274
332
|
return;
|
|
275
333
|
}
|
|
276
|
-
setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
|
|
277
|
-
notifyGoal(ctx, "Goal paused (interrupted).");
|
|
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;
|
|
278
347
|
};
|
|
279
348
|
|
|
280
349
|
const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
|
|
@@ -392,7 +461,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
392
461
|
}
|
|
393
462
|
const goal = createGoalSnapshot(objective.trim(), tokenBudget, undefined, command ?? undefined);
|
|
394
463
|
const owner = command?.source === "skill"
|
|
395
|
-
? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path)
|
|
464
|
+
? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path, registeredSkillPath)
|
|
396
465
|
: null;
|
|
397
466
|
if (getWorkflow(ctx)) recordWorkflow(null);
|
|
398
467
|
setGoal(goal, ctx, source);
|
|
@@ -441,7 +510,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
441
510
|
foregroundRunning,
|
|
442
511
|
backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
|
|
443
512
|
});
|
|
444
|
-
|
|
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
|
|
445
520
|
? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
|
|
446
521
|
: continuation?.blocked
|
|
447
522
|
? "waiting: no progress"
|
|
@@ -604,12 +679,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
604
679
|
notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
|
|
605
680
|
return;
|
|
606
681
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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");
|
|
682
|
+
const blocker = resumeBlocker(current);
|
|
683
|
+
if (blocker) {
|
|
684
|
+
notifyGoal(ctx, blocker, "error");
|
|
613
685
|
return;
|
|
614
686
|
}
|
|
615
687
|
const goal = goalWithStatus(current, "active");
|
|
@@ -794,7 +866,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
794
866
|
const command = pi.getCommands?.().find((item) => item.name === `skill:${skillName}` && item.source === "skill");
|
|
795
867
|
if (command) {
|
|
796
868
|
try {
|
|
797
|
-
const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path);
|
|
869
|
+
const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path, registeredSkillPath);
|
|
798
870
|
if (owner) recordWorkflow(owner);
|
|
799
871
|
} catch (error) {
|
|
800
872
|
notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
@@ -803,6 +875,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
803
875
|
}
|
|
804
876
|
}
|
|
805
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
|
+
}
|
|
806
894
|
if (goal?.status === "active") {
|
|
807
895
|
resetContinuationState(goal);
|
|
808
896
|
}
|
|
@@ -817,6 +905,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
817
905
|
return;
|
|
818
906
|
}
|
|
819
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
|
+
|
|
820
912
|
if (owner) {
|
|
821
913
|
if (!workflowAvailable(owner)) {
|
|
822
914
|
if (isPokeable(goal)) setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
|
|
@@ -832,7 +924,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
832
924
|
}
|
|
833
925
|
return {
|
|
834
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}` +
|
|
835
|
-
(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()}` : ""),
|
|
836
929
|
};
|
|
837
930
|
}
|
|
838
931
|
|
|
@@ -843,7 +936,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
843
936
|
return {
|
|
844
937
|
systemPrompt:
|
|
845
938
|
`${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}`,
|
|
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}`,
|
|
847
940
|
};
|
|
848
941
|
});
|
|
849
942
|
|
|
@@ -875,6 +968,32 @@ export default function (pi: ExtensionAPI): void {
|
|
|
875
968
|
}
|
|
876
969
|
});
|
|
877
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
|
+
|
|
878
997
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
879
998
|
currentCtx = ctx;
|
|
880
999
|
foregroundRunning = false;
|
|
@@ -917,6 +1036,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
917
1036
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
918
1037
|
stopPolling();
|
|
919
1038
|
foregroundRunning = false;
|
|
1039
|
+
pendingQuestions.clear();
|
|
920
1040
|
clearIdleContinuation();
|
|
921
1041
|
currentCtx = undefined;
|
|
922
1042
|
latestSnapshot = null;
|
package/src/types.ts
CHANGED
|
@@ -65,6 +65,14 @@ 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 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
|
+
|
|
68
76
|
export interface GoalUsage {
|
|
69
77
|
tokensUsed: number;
|
|
70
78
|
activeSeconds: number;
|
|
@@ -75,6 +83,8 @@ export interface GoalSnapshot {
|
|
|
75
83
|
objective: string;
|
|
76
84
|
command?: GoalCommandBinding;
|
|
77
85
|
status: GoalStatus;
|
|
86
|
+
/** Present only while `status` is "paused". */
|
|
87
|
+
pauseReason?: GoalPauseReason;
|
|
78
88
|
tokenBudget: number | null;
|
|
79
89
|
usage: GoalUsage;
|
|
80
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.`);
|