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 +1 -0
- package/package.json +1 -1
- package/src/goal-clock.ts +5 -6
- package/src/goal-state.ts +4 -0
- package/src/index.ts +26 -5
- package/src/shared-stall-detector.ts +69 -0
- package/src/stall.ts +34 -0
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
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(
|
|
84
|
+
const fixedWidth = visibleWidth(`${heading} ${active} `);
|
|
85
85
|
if (fixedWidth >= width) {
|
|
86
|
-
return truncateToWidth(
|
|
86
|
+
return truncateToWidth(`${heading} ${active}`, width);
|
|
87
87
|
}
|
|
88
88
|
const objective = truncateToWidth(goal.objective, width - fixedWidth);
|
|
89
|
-
return styleGoalRailLine(
|
|
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 `${
|
|
99
|
-
return `${fg("
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|