pi-better-harness 0.3.11 → 0.3.12

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.
@@ -28,7 +28,7 @@ metadata:
28
28
  workflow-role: coordinator
29
29
  ```
30
30
 
31
- Invoke the skill with Pi's `/skill:name` command. The goal extension checks Pi's skill-command provenance, persists the owner on the session branch, and restores its instructions on resumed turns. It does not infer ownership from ordinary text or skill names. A goal such as `/goal /skill:name task` is rejected; invoke `/skill:name task` directly and use `/goal` only for a plain-language objective. Legacy slash-shaped goals pause on resume rather than running without the skill. `/workflow` shows the owner; call `release_workflow` after the workflow's completion audit (or use `/workflow clear` to release it manually). Completing an active goal also releases ownership.
31
+ 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
32
 
33
33
  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
34
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "verify": "npm run typecheck && npm test"
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
46
46
  "@earendil-works/pi-tui": "*",
47
47
  "typebox": "*"
48
48
  },
@@ -58,8 +58,8 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-coding-agent": "^0.82.0",
62
- "@earendil-works/pi-tui": "^0.82.0",
61
+ "@earendil-works/pi-coding-agent": "^0.84.4",
62
+ "@earendil-works/pi-tui": "^0.84.4",
63
63
  "@types/node": "^25.0.0",
64
64
  "tsx": "^4.22.3",
65
65
  "typebox": "^1.1.39",
@@ -0,0 +1,37 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+
4
+ export interface GoalCommandBinding {
5
+ name: string;
6
+ source: "skill" | "prompt" | "extension";
7
+ path: string;
8
+ args: string;
9
+ }
10
+
11
+ export function leadingCommand(text: string): { name: string; args: string } | null {
12
+ const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
13
+ return match ? { name: match[1]!, args: match[2]?.trim() ?? "" } : null;
14
+ }
15
+
16
+ export function resolveGoalCommand(pi: ExtensionAPI, text: string): GoalCommandBinding | null {
17
+ const parsed = leadingCommand(text);
18
+ if (!parsed) return null;
19
+ if (parsed.name === "goal") throw new Error("A goal cannot invoke /goal recursively.");
20
+ const command = pi.getCommands().find((item) => item.name === parsed.name);
21
+ if (!command) throw new Error(`Unknown command /${parsed.name}.`);
22
+ if (!command.sourceInfo.path) throw new Error(`Command /${parsed.name} has no stable source path.`);
23
+ return { name: command.name, source: command.source, path: command.sourceInfo.path, args: parsed.args };
24
+ }
25
+
26
+ export function commandAvailable(pi: ExtensionAPI, binding: GoalCommandBinding): boolean {
27
+ return (binding.source === "extension" || existsSync(binding.path)) &&
28
+ pi.getCommands().some((command) => command.name === binding.name &&
29
+ command.source === binding.source && command.sourceInfo.path === binding.path);
30
+ }
31
+
32
+ export function commandInvocation(binding: GoalCommandBinding, continuation = false): string {
33
+ const args = continuation
34
+ ? `${binding.args ? `${binding.args}\n\n` : ""}Continue the existing goal using the current session state. Do not restart completed work.`
35
+ : binding.args;
36
+ return `/${binding.name}${args ? ` ${args}` : ""}`;
37
+ }
@@ -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
  import { EXTENSION_NAME, type GoalSnapshot, type GoalStatus } from "./types.js";
4
5
 
@@ -14,6 +15,7 @@ interface SessionEntryLike {
14
15
  interface GoalLike {
15
16
  goalId: string;
16
17
  objective: string;
18
+ command?: unknown;
17
19
  status: GoalStatus;
18
20
  tokenBudget?: number | null;
19
21
  usage?: { tokensUsed?: number; activeSeconds?: number };
@@ -96,6 +98,14 @@ function unixSeconds(): number {
96
98
  return Math.floor(Date.now() / 1000);
97
99
  }
98
100
 
101
+ function isCommandBinding(value: unknown): value is GoalCommandBinding {
102
+ if (!value || typeof value !== "object") return false;
103
+ const binding = value as Partial<GoalCommandBinding>;
104
+ return typeof binding.name === "string" && binding.name.length > 0 &&
105
+ (binding.source === "skill" || binding.source === "prompt" || binding.source === "extension") &&
106
+ typeof binding.path === "string" && binding.path.length > 0 && typeof binding.args === "string";
107
+ }
108
+
99
109
  function nextGoalId(): string {
100
110
  return `goal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
101
111
  }
@@ -107,6 +117,7 @@ function normalizeGoal(goal: GoalLike): GoalSnapshot {
107
117
  return {
108
118
  goalId: goal.goalId,
109
119
  objective: goal.objective,
120
+ ...(isCommandBinding(goal.command) ? { command: goal.command } : {}),
110
121
  status: goal.status,
111
122
  tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
112
123
  usage: {
@@ -134,10 +145,12 @@ export function createGoalSnapshot(
134
145
  objective: string,
135
146
  tokenBudget: number | null = null,
136
147
  now = unixSeconds(),
148
+ command?: GoalCommandBinding,
137
149
  ): GoalSnapshot {
138
150
  return {
139
151
  goalId: nextGoalId(),
140
152
  objective,
153
+ ...(command ? { command } : {}),
141
154
  status: "active",
142
155
  tokenBudget,
143
156
  usage: { tokensUsed: 0, activeSeconds: 0 },
@@ -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,
@@ -276,22 +277,35 @@ export default function (pi: ExtensionAPI): void {
276
277
  notifyGoal(ctx, "Goal paused (interrupted).");
277
278
  };
278
279
 
279
- const queueGoalContinuation = (goal: GoalSnapshot): void => {
280
+ const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
281
+ if (!goal.command || commandAvailable(pi, goal.command)) return true;
282
+ setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
283
+ notifyGoal(ctx, `Goal paused: /${goal.command.name} is no longer registered at its original source.`, "error");
284
+ return false;
285
+ };
286
+
287
+ const sendGoalContinuation = (goal: GoalSnapshot, content: string, kind: string, snapshot?: ActivitySnapshot): void => {
288
+ if (goal.command?.source === "skill" || goal.command?.source === "prompt") {
289
+ pi.sendUserMessage(`${commandInvocation(goal.command, true)}\n\n${content}`, {
290
+ deliverAs: "followUp", expandPromptTemplates: true,
291
+ });
292
+ } else {
293
+ pi.sendMessage({
294
+ customType: EXTENSION_NAME, content, display: false,
295
+ details: snapshot ? { kind, snapshot } : { kind, goalId: goal.goalId },
296
+ }, { triggerTurn: true, deliverAs: "followUp" });
297
+ }
298
+ };
299
+
300
+ const queueGoalContinuation = (goal: GoalSnapshot, ctx: ExtensionContext): void => {
280
301
  if (!isPokeable(goal) || continuationQueuedFor === goal.goalId) {
281
302
  return;
282
303
  }
304
+ if (!boundCommandReady(goal, ctx)) return;
283
305
  clearIdleContinuation();
284
306
  continuationQueuedFor = goal.goalId;
285
307
  resetContinuationState(goal);
286
- pi.sendMessage(
287
- {
288
- customType: EXTENSION_NAME,
289
- content: continuationPrompt(goal, currentCtx ? getWorkflow(currentCtx) : null),
290
- display: false,
291
- details: { kind: "continuation", goalId: goal.goalId },
292
- },
293
- { triggerTurn: true, deliverAs: "followUp" },
294
- );
308
+ sendGoalContinuation(goal, continuationPrompt(goal, getWorkflow(ctx)), "continuation");
295
309
  };
296
310
 
297
311
  function clearIdleContinuation(): void {
@@ -338,6 +352,7 @@ export default function (pi: ExtensionAPI): void {
338
352
  if (!isPokeable(goal) || goal.goalId !== goalId || continuationQueuedFor === goal.goalId || foregroundRunning) {
339
353
  return;
340
354
  }
355
+ if (!boundCommandReady(goal, ctx)) return;
341
356
  const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
342
357
  latestSnapshot = snapshot;
343
358
  pi.events.emit(EVENT_ACTIVITY, snapshot);
@@ -354,17 +369,7 @@ export default function (pi: ExtensionAPI): void {
354
369
  ? "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
370
  continuationPrompt(goal, getWorkflow(ctx))
356
371
  : continuationPrompt(goal, getWorkflow(ctx));
357
- pi.sendMessage(
358
- {
359
- customType: EXTENSION_NAME,
360
- content,
361
- display: false,
362
- details: kind === "background-drained"
363
- ? { kind, snapshot: priorSnapshot ?? snapshot }
364
- : { kind, goalId: goal.goalId },
365
- },
366
- { triggerTurn: true, deliverAs: "followUp" },
367
- );
372
+ sendGoalContinuation(goal, content, kind, kind === "background-drained" ? priorSnapshot ?? snapshot : undefined);
368
373
  };
369
374
 
370
375
  const startOrReplaceGoal = (
@@ -373,9 +378,6 @@ export default function (pi: ExtensionAPI): void {
373
378
  ctx: ExtensionContext,
374
379
  source: "command",
375
380
  ): 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
381
  const objectiveError = validateObjective(objective);
380
382
  if (objectiveError) {
381
383
  throw new Error(objectiveError);
@@ -384,9 +386,23 @@ export default function (pi: ExtensionAPI): void {
384
386
  if (budgetError) {
385
387
  throw new Error(budgetError);
386
388
  }
387
- const goal = createGoalSnapshot(objective.trim(), tokenBudget);
389
+ const command = resolveGoalCommand(pi, objective);
390
+ if (command && !commandAvailable(pi, command)) {
391
+ throw new Error(`Command /${command.name} is unavailable at its registered source.`);
392
+ }
393
+ const goal = createGoalSnapshot(objective.trim(), tokenBudget, undefined, command ?? undefined);
394
+ const owner = command?.source === "skill"
395
+ ? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path)
396
+ : null;
397
+ if (getWorkflow(ctx)) recordWorkflow(null);
388
398
  setGoal(goal, ctx, source);
389
- queueGoalContinuation(goal);
399
+ if (command) {
400
+ if (owner) recordWorkflow(owner);
401
+ pi.sendUserMessage(commandInvocation(command), { deliverAs: "followUp", expandPromptTemplates: true });
402
+ if (command.source === "extension") scheduleIdleContinuation(goal, ctx, "continuation");
403
+ } else {
404
+ queueGoalContinuation(goal, ctx);
405
+ }
390
406
  return goal;
391
407
  };
392
408
 
@@ -588,13 +604,17 @@ export default function (pi: ExtensionAPI): void {
588
604
  notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
589
605
  return;
590
606
  }
591
- if (skillCommandName(current.objective)) {
607
+ if (!current.command && skillCommandName(current.objective)) {
592
608
  notifyGoal(ctx, "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.", "error");
593
609
  return;
594
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");
613
+ return;
614
+ }
595
615
  const goal = goalWithStatus(current, "active");
596
616
  setGoal(goal, ctx, "command");
597
- queueGoalContinuation(goal);
617
+ queueGoalContinuation(goal, ctx);
598
618
  notifyGoal(ctx, "Goal resumed.");
599
619
  return;
600
620
  }
@@ -752,7 +772,7 @@ export default function (pi: ExtensionAPI): void {
752
772
  currentCtx = ctx;
753
773
  foregroundRunning = !ctx.isIdle();
754
774
  const restoredGoal = getGoal(ctx);
755
- if (restoredGoal?.status === "active" && skillCommandName(restoredGoal.objective)) {
775
+ if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
756
776
  setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
757
777
  notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
758
778
  }
@@ -760,6 +780,9 @@ export default function (pi: ExtensionAPI): void {
760
780
  installGoalWidget(ctx);
761
781
  latestSnapshot = await publishSnapshot(ctx);
762
782
  syncPollingState();
783
+ if (restoredGoal?.status === "active" && restoredGoal.command && boundCommandReady(restoredGoal, ctx) && !foregroundRunning && !latestSnapshot.backgroundRunning) {
784
+ scheduleIdleContinuation(restoredGoal, ctx, "continuation");
785
+ }
763
786
  });
764
787
 
765
788
  pi.on("input", async (event, ctx) => {
@@ -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";
@@ -72,6 +73,7 @@ export interface GoalUsage {
72
73
  export interface GoalSnapshot {
73
74
  goalId: string;
74
75
  objective: string;
76
+ command?: GoalCommandBinding;
75
77
  status: GoalStatus;
76
78
  tokenBudget: number | null;
77
79
  usage: GoalUsage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "pi-better-background-tasks": "0.2.13",
54
- "pi-better-goal": "0.3.1",
54
+ "pi-better-goal": "0.4.0",
55
55
  "pi-better-plan": "0.2.1",
56
56
  "pi-better-sandbox": "0.3.0",
57
57
  "pi-better-ssh": "0.1.1",