pi-better-goal 0.1.16 → 0.1.18

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 CHANGED
@@ -16,6 +16,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
16
16
  - A compact goal widget that does not replace Pi's footer.
17
17
  - Background activity tracking for subagents and other registered providers.
18
18
  - A progress-aware follow-up loop that holds after repeated identical outcomes.
19
+ - An observable-progress stall state for active goals.
19
20
 
20
21
  ## Install
21
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/goal-clock.ts CHANGED
@@ -81,20 +81,19 @@ export function renderGoalClockLine(
81
81
  const timing = goalTiming(goal, now);
82
82
  const heading = `goal ${goal.status}`;
83
83
  const active = formatClockDuration(timing.activeSeconds);
84
- const fixedWidth = visibleWidth(`▸ ${heading} ${active} `);
84
+ const fixedWidth = visibleWidth(`${heading} ${active} `);
85
85
  if (fixedWidth >= width) {
86
- return truncateToWidth(`▸ ${heading} ${active}`, width);
86
+ return truncateToWidth(`${heading} ${active}`, width);
87
87
  }
88
88
  const objective = truncateToWidth(goal.objective, width - fixedWidth);
89
- return styleGoalRailLine("▸", heading, `${active} ${objective}`, fg);
89
+ return styleGoalRailLine(heading, `${active} ${objective}`, fg);
90
90
  }
91
91
 
92
92
  function styleGoalRailLine(
93
- marker: string,
94
93
  heading: string,
95
94
  tail: string,
96
95
  fg?: (color: string, value: string) => string,
97
96
  ): string {
98
- if (!fg) return `${marker} ${heading} ${tail}`;
99
- return `${fg("dim", marker)} ${fg("warning", heading)} ${fg("dim", tail)}`;
97
+ if (!fg) return `${heading} ${tail}`;
98
+ return `${fg("warning", heading)} ${fg("dim", tail)}`;
100
99
  }
package/src/goal-state.ts CHANGED
@@ -29,6 +29,8 @@ export interface ContinuationState {
29
29
  goalId: string;
30
30
  lastEvidenceSignature: string | null;
31
31
  lastEvidenceSummary: string;
32
+ /** Millisecond timestamp of the last changed foreground or external evidence. */
33
+ lastProgressAt?: number;
32
34
  noProgressRetries: number;
33
35
  blocked: boolean;
34
36
  updatedAt: number;
@@ -81,6 +83,7 @@ function isContinuationState(value: unknown): value is ContinuationState {
81
83
  typeof candidate.goalId === "string" &&
82
84
  (candidate.lastEvidenceSignature === null || typeof candidate.lastEvidenceSignature === "string") &&
83
85
  typeof candidate.lastEvidenceSummary === "string" &&
86
+ (candidate.lastProgressAt === undefined || (typeof candidate.lastProgressAt === "number" && Number.isFinite(candidate.lastProgressAt))) &&
84
87
  typeof candidate.noProgressRetries === "number" &&
85
88
  Number.isInteger(candidate.noProgressRetries) &&
86
89
  candidate.noProgressRetries >= 0 &&
@@ -178,6 +181,7 @@ export function createContinuationState(goalId: string, now = unixSeconds()): Co
178
181
  goalId,
179
182
  lastEvidenceSignature: null,
180
183
  lastEvidenceSummary: "",
184
+ lastProgressAt: now * 1000,
181
185
  noProgressRetries: 0,
182
186
  blocked: false,
183
187
  updatedAt: now,
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  validateTokenBudget,
22
22
  } from "./goal-state.js";
23
23
  import { continuationEvidence, type ContinuationEvidence } from "./continuation.js";
24
+ import { observeGoalStall } from "./stall.js";
24
25
  import {
25
26
  goalClockRefreshDelayMs,
26
27
  goalTiming,
@@ -82,7 +83,11 @@ function continuationPrompt(goal: GoalSnapshot): string {
82
83
  ].join("\n");
83
84
  }
84
85
 
85
- function formatGoal(goal: GoalSnapshot | null, continuation: ReturnType<typeof currentContinuationState> = null): string {
86
+ function formatGoal(
87
+ goal: GoalSnapshot | null,
88
+ continuation: ReturnType<typeof currentContinuationState> = null,
89
+ stall = observeGoalStall(goal, continuation),
90
+ ): string {
86
91
  if (!goal) {
87
92
  return "No goal is set.";
88
93
  }
@@ -98,6 +103,7 @@ function formatGoal(goal: GoalSnapshot | null, continuation: ReturnType<typeof c
98
103
  `Tokens used: ${goal.usage.tokensUsed}`,
99
104
  `Active time: ${timing.activeSeconds}s`,
100
105
  `Elapsed time: ${timing.elapsedSeconds}s`,
106
+ `Observable progress: ${stall?.state ?? "unknown"}`,
101
107
  continuationStatus,
102
108
  ].join("\n");
103
109
  }
@@ -309,10 +315,16 @@ export default function (pi: ExtensionAPI): void {
309
315
  if (ctx.hasUI) {
310
316
  const goal = getGoal(ctx);
311
317
  const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
318
+ const goalStall = observeGoalStall(goal, continuation, {
319
+ foregroundRunning,
320
+ backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
321
+ });
312
322
  const status = snapshot.backgroundRunning
313
323
  ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
314
324
  : continuation?.blocked
315
325
  ? "waiting: no progress"
326
+ : goalStall?.state === "stalled"
327
+ ? "goal stalled"
316
328
  : undefined;
317
329
  try {
318
330
  ctx.ui.setStatus(EXTENSION_NAME, status);
@@ -407,7 +419,8 @@ export default function (pi: ExtensionAPI): void {
407
419
  const current = getGoal(ctx);
408
420
 
409
421
  if (!trimmed) {
410
- ctx.ui.notify(formatGoal(current, current ? currentContinuationState(ctx, current.goalId) : null), "info");
422
+ const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
423
+ ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning })), "info");
411
424
  return;
412
425
  }
413
426
 
@@ -477,9 +490,13 @@ export default function (pi: ExtensionAPI): void {
477
490
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
478
491
  const goal = getGoal(ctx);
479
492
  const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
493
+ const stall = observeGoalStall(goal, continuation, {
494
+ foregroundRunning,
495
+ backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
496
+ });
480
497
  return {
481
- content: [{ type: "text", text: formatGoal(goal, continuation) }],
482
- details: { goal, continuation, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
498
+ content: [{ type: "text", text: formatGoal(goal, continuation, stall) }],
499
+ details: { goal, continuation, stall, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
483
500
  };
484
501
  },
485
502
  });
@@ -607,13 +624,17 @@ export default function (pi: ExtensionAPI): void {
607
624
  const repeated = previous.lastEvidenceSignature === evidence.signature;
608
625
  const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
609
626
  const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
627
+ const now = Date.now();
610
628
  appendContinuationState({
611
629
  goalId: goal.goalId,
612
630
  lastEvidenceSignature: evidence.signature,
613
631
  lastEvidenceSummary: evidence.summary,
632
+ ...(repeated
633
+ ? (previous.lastProgressAt !== undefined ? { lastProgressAt: previous.lastProgressAt } : {})
634
+ : { lastProgressAt: now }),
614
635
  noProgressRetries,
615
636
  blocked,
616
- updatedAt: Math.floor(Date.now() / 1000),
637
+ updatedAt: Math.floor(now / 1000),
617
638
  });
618
639
  if (blocked) {
619
640
  refreshGoalWidget?.();
@@ -0,0 +1,69 @@
1
+ // Generated from packages/stall-detector/index.ts. Do not edit directly.
2
+ export interface StallThresholds {
3
+ /** Age of observable progress before work becomes quiet. */
4
+ quietMs: number;
5
+ /** Age of observable progress before work becomes stalled. */
6
+ stallMs: number;
7
+ }
8
+
9
+ export type StallState = "healthy" | "quiet" | "stalled" | "unknown";
10
+
11
+ export interface StallObservation {
12
+ state: StallState;
13
+ /** Most recent meaningful evidence, falling back to the start time. */
14
+ observedAt?: number;
15
+ ageMs?: number;
16
+ thresholds: StallThresholds;
17
+ }
18
+
19
+ export interface ObserveStallInput {
20
+ now: number;
21
+ /** Explicit meaningful progress, never a timer tick or metadata write. */
22
+ lastProgressAt?: number;
23
+ /** Fallback anchor for work that has not produced progress yet. */
24
+ startedAt?: number;
25
+ /** Known active phases that explain a lack of observable progress. */
26
+ exempt?: boolean;
27
+ thresholds?: Partial<StallThresholds>;
28
+ }
29
+
30
+ export const DEFAULT_STALL_THRESHOLDS: Readonly<StallThresholds> = Object.freeze({
31
+ quietMs: 60_000,
32
+ stallMs: 5 * 60_000,
33
+ });
34
+
35
+ function positiveMs(value: unknown, fallback: number): number {
36
+ const parsed = Number(value);
37
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
38
+ }
39
+
40
+ /** Normalize caller config and ensure quiet never exceeds the stall threshold. */
41
+ export function resolveStallThresholds(partial?: Partial<StallThresholds> | null): StallThresholds {
42
+ const quietMs = positiveMs(partial?.quietMs, DEFAULT_STALL_THRESHOLDS.quietMs);
43
+ const stallMs = positiveMs(partial?.stallMs, DEFAULT_STALL_THRESHOLDS.stallMs);
44
+ return { quietMs: Math.min(quietMs, stallMs), stallMs: Math.max(quietMs, stallMs) };
45
+ }
46
+
47
+ /**
48
+ * Classify the age of observable progress. This is deliberately observational:
49
+ * it does not claim a live process is broken and never performs recovery.
50
+ */
51
+ export function observeStall(input: ObserveStallInput): StallObservation {
52
+ const thresholds = resolveStallThresholds(input.thresholds);
53
+ const observedAt = input.lastProgressAt ?? input.startedAt;
54
+ if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) {
55
+ return { state: "unknown", thresholds };
56
+ }
57
+
58
+ const ageMs = Math.max(0, input.now - observedAt);
59
+ if (input.exempt) {
60
+ return { state: ageMs >= thresholds.quietMs ? "quiet" : "healthy", observedAt, ageMs, thresholds };
61
+ }
62
+ if (ageMs >= thresholds.stallMs) {
63
+ return { state: "stalled", observedAt, ageMs, thresholds };
64
+ }
65
+ if (ageMs >= thresholds.quietMs) {
66
+ return { state: "quiet", observedAt, ageMs, thresholds };
67
+ }
68
+ return { state: "healthy", observedAt, ageMs, thresholds };
69
+ }
package/src/stall.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { observeStall, type StallObservation, type StallThresholds } from "./shared-stall-detector.js";
2
+ import type { ContinuationState } from "./goal-state.js";
3
+ import type { GoalSnapshot } from "./types.js";
4
+
5
+ function durationEnv(name: string): number | undefined {
6
+ const raw = process.env[name];
7
+ if (!raw || raw.trim() === "") return undefined;
8
+ const value = Number(raw);
9
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
10
+ }
11
+
12
+ export function goalStallThresholds(): Partial<StallThresholds> {
13
+ const thresholds: Partial<StallThresholds> = {};
14
+ const quietMs = durationEnv("PI_BETTER_STALL_QUIET_MS");
15
+ const stallMs = durationEnv("PI_BETTER_STALL_MS");
16
+ if (quietMs !== undefined) thresholds.quietMs = quietMs;
17
+ if (stallMs !== undefined) thresholds.stallMs = stallMs;
18
+ return thresholds;
19
+ }
20
+
21
+ export function observeGoalStall(
22
+ goal: GoalSnapshot | null,
23
+ continuation: ContinuationState | null,
24
+ options: { now?: number; foregroundRunning?: boolean; backgroundRunning?: boolean } = {},
25
+ ): StallObservation | null {
26
+ if (!goal) return null;
27
+ return observeStall({
28
+ now: options.now ?? Date.now(),
29
+ ...(continuation?.lastProgressAt !== undefined ? { lastProgressAt: continuation.lastProgressAt } : {}),
30
+ startedAt: goal.createdAt * 1000,
31
+ exempt: goal.status !== "active" || options.foregroundRunning === true || options.backgroundRunning === true,
32
+ thresholds: goalStallThresholds(),
33
+ });
34
+ }