pi-better-goal 0.1.18 → 0.1.20

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +75 -37
package/README.md CHANGED
@@ -13,6 +13,7 @@ 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
17
  - A compact goal widget that does not replace Pi's footer.
17
18
  - Background activity tracking for subagents and other registered providers.
18
19
  - A progress-aware follow-up loop that holds after repeated identical outcomes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  goalWithStatus,
20
20
  validateObjective,
21
21
  validateTokenBudget,
22
+ type GoalEntrySource,
22
23
  } from "./goal-state.js";
23
24
  import { continuationEvidence, type ContinuationEvidence } from "./continuation.js";
24
25
  import { observeGoalStall } from "./stall.js";
@@ -73,6 +74,26 @@ function parseRetryLimit(raw: string | undefined, fallback: number): number {
73
74
  return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
74
75
  }
75
76
 
77
+ /**
78
+ * True when the agent loop ended because the running turn was interrupted
79
+ * (escape / ctrl+c). An aborted run leaves a final assistant message whose
80
+ * stopReason is "aborted".
81
+ */
82
+ function wasTurnAborted(messages: readonly unknown[]): boolean {
83
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
84
+ const message = messages[index];
85
+ if (!message || typeof message !== "object") {
86
+ continue;
87
+ }
88
+ const candidate = message as { role?: unknown; stopReason?: unknown };
89
+ if (candidate.role !== "assistant") {
90
+ continue;
91
+ }
92
+ return candidate.stopReason === "aborted";
93
+ }
94
+ return false;
95
+ }
96
+
76
97
  function continuationPrompt(goal: GoalSnapshot): string {
77
98
  return [
78
99
  "Continue working toward the active thread goal.",
@@ -148,6 +169,9 @@ export default function (pi: ExtensionAPI): void {
148
169
 
149
170
  const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
150
171
 
172
+ /** Only active goals receive autonomous pokes; paused, complete, and budget-limited goals never do. */
173
+ const isPokeable = (goal: GoalSnapshot | null): goal is GoalSnapshot => goal?.status === "active";
174
+
151
175
  const appendContinuationState = (state: ReturnType<typeof createContinuationState>): void => {
152
176
  pi.appendEntry(EXTENSION_NAME, continuationStateEntry(state));
153
177
  };
@@ -156,7 +180,7 @@ export default function (pi: ExtensionAPI): void {
156
180
  appendContinuationState(createContinuationState(goal.goalId));
157
181
  };
158
182
 
159
- const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: "command" | "tool" | "runtime"): void => {
183
+ const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
160
184
  pi.appendEntry(EXTENSION_NAME, goalSetEntry(goal, source));
161
185
  continuationQueuedFor = null;
162
186
  backgroundDrainTracker = null;
@@ -164,7 +188,7 @@ export default function (pi: ExtensionAPI): void {
164
188
  refreshGoalWidget?.();
165
189
  };
166
190
 
167
- const clearGoal = (ctx: ExtensionContext, source: "command" | "tool" | "runtime"): void => {
191
+ const clearGoal = (ctx: ExtensionContext, source: GoalEntrySource): void => {
168
192
  const current = getGoal(ctx);
169
193
  pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
170
194
  continuationQueuedFor = null;
@@ -174,7 +198,7 @@ export default function (pi: ExtensionAPI): void {
174
198
  };
175
199
 
176
200
  const queueGoalContinuation = (goal: GoalSnapshot): void => {
177
- if (goal.status !== "active" || continuationQueuedFor === goal.goalId) {
201
+ if (!isPokeable(goal) || continuationQueuedFor === goal.goalId) {
178
202
  return;
179
203
  }
180
204
  clearIdleContinuation();
@@ -205,7 +229,7 @@ export default function (pi: ExtensionAPI): void {
205
229
  kind: "continuation" | "background-drained",
206
230
  snapshot?: ActivitySnapshot,
207
231
  ): void => {
208
- if (WAKE_DISABLED || goal.status !== "active" || continuationQueuedFor === goal.goalId) {
232
+ if (WAKE_DISABLED || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
209
233
  return;
210
234
  }
211
235
  if (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
@@ -232,7 +256,7 @@ export default function (pi: ExtensionAPI): void {
232
256
  priorSnapshot?: ActivitySnapshot,
233
257
  ): Promise<void> => {
234
258
  const goal = currentGoalSnapshot(ctx);
235
- if (goal?.status !== "active" || goal.goalId !== goalId || continuationQueuedFor === goal.goalId || foregroundRunning) {
259
+ if (!isPokeable(goal) || goal.goalId !== goalId || continuationQueuedFor === goal.goalId || foregroundRunning) {
236
260
  return;
237
261
  }
238
262
  const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
@@ -299,7 +323,7 @@ export default function (pi: ExtensionAPI): void {
299
323
  const goal = currentGoalSnapshot(ctx);
300
324
  const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
301
325
  backgroundDrainTracker = wakePlan.nextTracker;
302
- if (goal?.status === "active" && wakePlan.wakeSignature) {
326
+ if (isPokeable(goal) && wakePlan.wakeSignature) {
303
327
  const wakeSignature = wakePlan.wakeSignature;
304
328
  if (wakeSignature !== lastWakeSignature) {
305
329
  lastWakeSignature = wakeSignature;
@@ -586,7 +610,7 @@ export default function (pi: ExtensionAPI): void {
586
610
  currentCtx = ctx;
587
611
  const goal = currentGoalSnapshot(ctx);
588
612
  const snapshot = await publishSnapshot(ctx);
589
- if (goal?.status !== "active") {
613
+ if (!isPokeable(goal)) {
590
614
  return;
591
615
  }
592
616
 
@@ -609,8 +633,21 @@ export default function (pi: ExtensionAPI): void {
609
633
  await publishSnapshot(ctx);
610
634
  });
611
635
 
612
- pi.on("agent_end", async (event, _ctx) => {
636
+ pi.on("agent_end", async (event, ctx) => {
613
637
  lastAgentEvidence = continuationEvidence(event.messages);
638
+ // `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
639
+ // cannot register it. Observe the interrupt instead: when a running turn is
640
+ // aborted (escape / ctrl+c while streaming), pause the active goal so it does
641
+ // not auto-continue after the user stopped the agent.
642
+ if (wasTurnAborted(event.messages)) {
643
+ const goal = getGoal(ctx);
644
+ if (isPokeable(goal)) {
645
+ setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
646
+ if (ctx.hasUI) {
647
+ ctx.ui.notify("Goal paused (interrupted).", "info");
648
+ }
649
+ }
650
+ }
614
651
  });
615
652
 
616
653
  pi.on("agent_settled", async (_event, ctx) => {
@@ -618,37 +655,38 @@ export default function (pi: ExtensionAPI): void {
618
655
  foregroundRunning = false;
619
656
  const snapshot = await publishSnapshot(ctx);
620
657
  const goal = getGoal(ctx);
621
- if (goal?.status === "active" && !snapshot.backgroundRunning) {
622
- const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
623
- const evidence = lastAgentEvidence ?? continuationEvidence([]);
624
- const repeated = previous.lastEvidenceSignature === evidence.signature;
625
- const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
626
- const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
627
- const now = Date.now();
628
- appendContinuationState({
629
- goalId: goal.goalId,
630
- lastEvidenceSignature: evidence.signature,
631
- lastEvidenceSummary: evidence.summary,
632
- ...(repeated
633
- ? (previous.lastProgressAt !== undefined ? { lastProgressAt: previous.lastProgressAt } : {})
634
- : { lastProgressAt: now }),
635
- noProgressRetries,
636
- blocked,
637
- updatedAt: Math.floor(now / 1000),
638
- });
639
- if (blocked) {
640
- refreshGoalWidget?.();
641
- if (ctx.hasUI) {
642
- ctx.ui.setStatus(EXTENSION_NAME, "waiting: no progress");
643
- ctx.ui.notify(
644
- `Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}). New input or background activity will resume it.`,
645
- "warning",
646
- );
647
- }
648
- return;
658
+ if (!isPokeable(goal) || snapshot.backgroundRunning) {
659
+ return;
660
+ }
661
+ const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
662
+ const evidence = lastAgentEvidence ?? continuationEvidence([]);
663
+ const repeated = previous.lastEvidenceSignature === evidence.signature;
664
+ const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
665
+ const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
666
+ const now = Date.now();
667
+ appendContinuationState({
668
+ goalId: goal.goalId,
669
+ lastEvidenceSignature: evidence.signature,
670
+ lastEvidenceSummary: evidence.summary,
671
+ ...(repeated
672
+ ? (previous.lastProgressAt !== undefined ? { lastProgressAt: previous.lastProgressAt } : {})
673
+ : { lastProgressAt: now }),
674
+ noProgressRetries,
675
+ blocked,
676
+ updatedAt: Math.floor(now / 1000),
677
+ });
678
+ if (blocked) {
679
+ refreshGoalWidget?.();
680
+ if (ctx.hasUI) {
681
+ ctx.ui.setStatus(EXTENSION_NAME, "waiting: no progress");
682
+ ctx.ui.notify(
683
+ `Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}). New input or background activity will resume it.`,
684
+ "warning",
685
+ );
649
686
  }
650
- scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
687
+ return;
651
688
  }
689
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
652
690
  });
653
691
 
654
692
  pi.on("session_shutdown", async (_event, ctx) => {