@bacnh85/pi-advisor 0.2.1 → 0.2.3
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/CHANGELOG.md +37 -0
- package/README.md +1 -1
- package/extensions/index.ts +34 -6
- package/extensions/lib/isolated-model.ts +41 -4
- package/extensions/lib/watcher.ts +10 -8
- package/package.json +1 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3 (2026-09-11)
|
|
4
|
+
|
|
5
|
+
- **Fix 20–30s+ TUI freeze after every settled turn**: pi core awaits
|
|
6
|
+
`agent_settled` handlers before the prompt regains input, and the advisor
|
|
7
|
+
review (a full model call — measured median ~11s, p90 ~96s) ran inside that
|
|
8
|
+
awaited barrier, freezing the TUI — most visibly after `write_plan`, where
|
|
9
|
+
the prefilled `/plan-approve` was dead until the review finished. The review
|
|
10
|
+
now runs fire-and-forget: notes deliver via `sendUserMessage`, which the SDK
|
|
11
|
+
queues as a steer when a run is already active or fires as a follow-up turn
|
|
12
|
+
when idle. A still-running review makes the next settle skip (bounded loss).
|
|
13
|
+
- **Watch is skipped in headless modes** (print/rpc/json): a floating review
|
|
14
|
+
would die at process exit, and a note there fired an unrequested follow-up
|
|
15
|
+
agent run. The on-demand `/advisor` consult tool is unaffected.
|
|
16
|
+
- **Hardened against stale/edge contexts** (review follow-up): a review still
|
|
17
|
+
in flight when a new session starts is silently discarded instead of
|
|
18
|
+
steering the new session; the in-flight guard moved to per-runtime state so
|
|
19
|
+
a draining old review can't suppress the new session's first review; a
|
|
20
|
+
mode-less context is treated as headless (fail-safe skip).
|
|
21
|
+
- **Delivery-time liveness + teardown self-disarm** (final review round): a
|
|
22
|
+
review in flight across `/new`, `/resume`, `/fork`, `/reload`, or quit is
|
|
23
|
+
discarded silently — `session_shutdown` disarms the watch before the runner
|
|
24
|
+
invalidates, so no stale-ctx error toast lands in the replaced session; the
|
|
25
|
+
pause toast routes through the same liveness gate; the watch-disabled state
|
|
26
|
+
mid-review (`/advisor off`, `watch-off`) also suppresses delivery.
|
|
27
|
+
|
|
28
|
+
## 0.2.2 (2026-09-07)
|
|
29
|
+
|
|
30
|
+
- **Fix TUI hang on stalled reviewer provider**: every chain candidate now
|
|
31
|
+
runs under a 90s **idle** deadline — a candidate is treated as dead only
|
|
32
|
+
after 90s with no stream events, so a hung provider (accepts the
|
|
33
|
+
connection, never streams) advances the chain to the next model, while
|
|
34
|
+
healthy slow streams (reasoning models, large transcripts) are never
|
|
35
|
+
killed: any event resets the deadline. Previously a hung provider stalled
|
|
36
|
+
the awaited `agent_settled` review forever, blocking the prompt so
|
|
37
|
+
slash commands (e.g. `/plan-approve` after write_plan) could not run.
|
|
38
|
+
Caller abort (`/advisor` consult tool) still aborts without fall-through.
|
|
39
|
+
|
|
3
40
|
## 0.2.1 (2026-09-05)
|
|
4
41
|
|
|
5
42
|
- Widen Pi SDK peer range to `>=0.85.0 <0.86.0` and bump devDep to `^0.85.0` for Pi 0.85.0 compatibility (no breaking changes; peer cap widening only).
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Settings live in `~/.pi/agent/settings.json` (global) and `.pi/settings.json`
|
|
|
68
68
|
time, the next candidate serves automatically; a whole-chain failure counts
|
|
69
69
|
as one review failure (the 3-strike pause still applies). The advisor never
|
|
70
70
|
falls back to the primary model — it must never review its own turns.
|
|
71
|
-
- `watch.enabled` (default `true`) — turn-end reviewing on session start
|
|
71
|
+
- `watch.enabled` (default `true`) — turn-end reviewing on session start (TUI only — print/rpc/json runs skip the watch; the on-demand advisor tool still works)
|
|
72
72
|
- `watch.minToolCalls` (default `3`, `0` = every turn) — skip trivial turns
|
|
73
73
|
- `watch.immuneTurns` (default `3`) — review window during which the same
|
|
74
74
|
normalized note is not re-delivered (loop protection); distinct concerns and
|
package/extensions/index.ts
CHANGED
|
@@ -70,7 +70,9 @@ export default function piAdvisor(pi: ExtensionAPI): void {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
pi.on("before_agent_start", (event, ctx: ExtensionContext): any => {
|
|
73
|
-
|
|
73
|
+
// Headless runs can't receive watch notes anymore — don't make them pay
|
|
74
|
+
// dead prompt text claiming otherwise.
|
|
75
|
+
if (ctx.mode !== "tui" || !watchEnabled || !runtime || runtime.models.length === 0 || runtime.stats.paused) return;
|
|
74
76
|
// Every turn the agent sees the authority line (static per session,
|
|
75
77
|
// cache-safe): messages starting 'Advisor review' are reviewer findings.
|
|
76
78
|
const line = "Advisor notes: messages starting 'Advisor review' are authoritative reviewer findings. Fix or explicitly justify ignoring each finding.";
|
|
@@ -108,13 +110,39 @@ export default function piAdvisor(pi: ExtensionAPI): void {
|
|
|
108
110
|
runtime.cursor = entries.length ? entries[entries.length - 1].id : undefined;
|
|
109
111
|
});
|
|
110
112
|
|
|
113
|
+
// Self-disarm on session teardown: session_shutdown is emitted and awaited
|
|
114
|
+
// BEFORE the runner invalidates (agent-session-runtime teardownCurrent), so
|
|
115
|
+
// flipping the flag here makes live() false in this (about-to-be-orphaned)
|
|
116
|
+
// closure — an in-flight fire-and-forget review is discarded silently
|
|
117
|
+
// instead of throwing the stale-ctx error into the .catch and toasting the
|
|
118
|
+
// NEW session. The factory re-runs per session, so this never touches a
|
|
119
|
+
// live session's flag.
|
|
120
|
+
pi.on("session_shutdown", () => { watchEnabled = false; });
|
|
121
|
+
|
|
111
122
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
112
123
|
if (!runtime || !watchEnabled || runtime.stats.paused) return;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
// Only TUI: a floating review would die at process exit in headless
|
|
125
|
+
// modes, and a note there fired an unrequested follow-up run. Fail-safe:
|
|
126
|
+
// unknown/mode-less contexts skip too (missed review < surprise run).
|
|
127
|
+
if (ctx.mode !== "tui") return;
|
|
128
|
+
// ponytail: fire-and-forget — pi core awaits agent_settled handlers before
|
|
129
|
+
// the TUI regains input, so awaiting the 10-90s+ review here froze the UI
|
|
130
|
+
// after every turn. Notes deliver via sendUserMessage, which the SDK
|
|
131
|
+
// queues as a steer when a run is active or fires as a follow-up turn
|
|
132
|
+
// when idle. A still-running review makes the next settle skip (rt
|
|
133
|
+
// reviewing flag) — bounded loss, not queued.
|
|
134
|
+
const rt = runtime;
|
|
135
|
+
// Liveness guard evaluated at delivery time (not just settle time): a
|
|
136
|
+
// fresh session (/new) replaces runtime, /advisor off clears the chain,
|
|
137
|
+
// watch-off flips the flag, repeated failures pause — a review in flight
|
|
138
|
+
// across any of these must deliver nothing.
|
|
139
|
+
const live = () => runtime === rt && watchEnabled && rt.models.length > 0 && !rt.stats.paused;
|
|
140
|
+
void reviewTurn(rt, ctx, {
|
|
141
|
+
sendMessage: (message, options) => { if (live()) pi.sendMessage(message, options as never); },
|
|
142
|
+
sendUserMessage: (content, options) => { if (live()) pi.sendUserMessage(content, options); },
|
|
143
|
+
appendEntry: (customType, data) => { if (live()) pi.appendEntry(customType, data); },
|
|
144
|
+
notify: (message) => { if (live()) ctx.ui.notify(message, "error"); },
|
|
145
|
+
}, testIsolated).catch((err) => { if (live()) ctx.ui.notify(`Advisor review failed: ${String(err)}`, "error"); });
|
|
118
146
|
});
|
|
119
147
|
|
|
120
148
|
registerAdvisor(pi, {
|
|
@@ -18,6 +18,8 @@ export async function runIsolated(
|
|
|
18
18
|
onDelta?: (delta: string) => void,
|
|
19
19
|
signal?: AbortSignal,
|
|
20
20
|
reasoning?: string,
|
|
21
|
+
/** Progress hook — every stream event (incl. non-text deltas) resets the caller's idle deadline. */
|
|
22
|
+
onEvent?: () => void,
|
|
21
23
|
): Promise<string> {
|
|
22
24
|
const parsed = modelId ? parseModel(modelId) : undefined;
|
|
23
25
|
if (modelId && !parsed) throw new Error(`Invalid model: ${modelId}`);
|
|
@@ -32,16 +34,46 @@ export async function runIsolated(
|
|
|
32
34
|
const response = provider?.streamSimple
|
|
33
35
|
? provider.streamSimple(model, context, streamOptions)
|
|
34
36
|
: streamSimple(model, context, streamOptions);
|
|
35
|
-
for await (const event of response)
|
|
37
|
+
for await (const event of response) {
|
|
38
|
+
onEvent?.();
|
|
39
|
+
if (event.type === "text_delta") onDelta?.(event.delta);
|
|
40
|
+
}
|
|
36
41
|
const result = await response.result();
|
|
37
42
|
if (result.stopReason !== "stop") throw new Error(result.errorMessage ?? `Model stopped: ${result.stopReason}`);
|
|
38
43
|
return text(result);
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
/** Idle deadline per candidate: a candidate is aborted only after timeoutMs
|
|
47
|
+
* with NO stream events — healthy slow streams (reasoning models, large
|
|
48
|
+
* transcripts) keep resetting it, so progress is never killed. Caller abort
|
|
49
|
+
* propagates immediately. streamSimple honors the signal (same mechanism as
|
|
50
|
+
* tool-call abort). */
|
|
51
|
+
const CANDIDATE_TIMEOUT_MS = 90_000;
|
|
52
|
+
|
|
53
|
+
function idleSignal(signal: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; touch(): void; dispose(): void } {
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
const arm = () => {
|
|
57
|
+
if (timer) clearTimeout(timer);
|
|
58
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
59
|
+
};
|
|
60
|
+
const onAbort = () => controller.abort();
|
|
61
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
62
|
+
arm();
|
|
63
|
+
return {
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
touch: arm,
|
|
66
|
+
dispose() {
|
|
67
|
+
if (timer) clearTimeout(timer);
|
|
68
|
+
signal?.removeEventListener("abort", onAbort);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
/**
|
|
42
74
|
* Try each model in priority order: unresolvable candidates are skipped;
|
|
43
|
-
* any call error (rate limit, quota, unavailable, network) advances
|
|
44
|
-
* next candidate — for a best-effort reviewer any dead candidate should
|
|
75
|
+
* any call error (rate limit, quota, unavailable, network, timeout) advances
|
|
76
|
+
* to the next candidate — for a best-effort reviewer any dead candidate should
|
|
45
77
|
* yield to the next. All exhausted → the last error is rethrown.
|
|
46
78
|
* No parent-model fallback: the advisor must never use the primary model.
|
|
47
79
|
*/
|
|
@@ -56,16 +88,21 @@ export async function runIsolatedChain(
|
|
|
56
88
|
onDelta?: ChainOnDelta,
|
|
57
89
|
signal?: AbortSignal,
|
|
58
90
|
reasoning?: string,
|
|
91
|
+
// ponytail: test seam — production callers use the 90s default
|
|
92
|
+
timeoutMs: number = CANDIDATE_TIMEOUT_MS,
|
|
59
93
|
): Promise<{ text: string; model: string }> {
|
|
60
94
|
let lastError: unknown;
|
|
61
95
|
for (const [attempt, modelId] of models.entries()) {
|
|
62
96
|
if (signal?.aborted) throw new Error("Advisor call aborted");
|
|
97
|
+
const idle = idleSignal(signal, timeoutMs);
|
|
63
98
|
try {
|
|
64
|
-
return { text: await runIsolated(ctx, modelId, context, (delta) => onDelta?.(delta, attempt), signal, reasoning), model: modelId };
|
|
99
|
+
return { text: await runIsolated(ctx, modelId, context, (delta) => onDelta?.(delta, attempt), idle.signal, reasoning, idle.touch), model: modelId };
|
|
65
100
|
} catch (error) {
|
|
66
101
|
// An abort is a caller decision, not a dead model — do not fall through.
|
|
67
102
|
if (signal?.aborted) throw error;
|
|
68
103
|
lastError = error;
|
|
104
|
+
} finally {
|
|
105
|
+
idle.dispose();
|
|
69
106
|
}
|
|
70
107
|
}
|
|
71
108
|
throw lastError instanceof Error ? lastError : new Error(`All advisor models failed: ${models.join(", ") || "none configured"}`);
|
|
@@ -49,13 +49,16 @@ export interface WatcherRuntime {
|
|
|
49
49
|
guard: GuardState;
|
|
50
50
|
stats: WatcherStats;
|
|
51
51
|
failures: number;
|
|
52
|
+
/** Set while a review is in flight — per-runtime so a stale session's
|
|
53
|
+
* draining review never blocks the new session's first review. */
|
|
54
|
+
reviewing: boolean;
|
|
52
55
|
/** OMP-parity post-steer cooldown: remaining settled turns during which
|
|
53
56
|
* non-blocker notes are deferred to next-turn asides instead of steering. */
|
|
54
57
|
steerCooldownTurns: number;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export function createRuntime(config: AdvisorConfig, models: string[]): WatcherRuntime {
|
|
58
|
-
return { config, models, cursor: undefined, guard: createGuard(), stats: createStats(), failures: 0, steerCooldownTurns: 0 };
|
|
61
|
+
return { config, models, cursor: undefined, guard: createGuard(), stats: createStats(), failures: 0, steerCooldownTurns: 0, reviewing: false };
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/**
|
|
@@ -133,17 +136,16 @@ export interface WatcherHost {
|
|
|
133
136
|
sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
|
|
134
137
|
/** Display-only immediate card (session entry; never enters LLM context). */
|
|
135
138
|
appendEntry<T = unknown>(customType: string, data?: T): void;
|
|
139
|
+
/** Error/status toast, liveness-gated by the caller (never leaks into a replaced session). */
|
|
140
|
+
notify(message: string): void;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/** Injectable isolated-model call — defaults to the chain runner; tests pass a fake. */
|
|
139
144
|
export type IsolatedCall = typeof runIsolatedChain;
|
|
140
145
|
|
|
141
146
|
/** One review step, called from the agent_settled handler while watching is active. */
|
|
142
|
-
// ponytail: module-level guard — one review at a time across the single session
|
|
143
|
-
let reviewing = false;
|
|
144
|
-
|
|
145
147
|
export async function reviewTurn(rt: WatcherRuntime, ctx: ExtensionContext, host: WatcherHost, isolated: IsolatedCall = runIsolatedChain): Promise<void> {
|
|
146
|
-
if (rt.stats.paused || reviewing) return;
|
|
148
|
+
if (rt.stats.paused || rt.reviewing) return;
|
|
147
149
|
// No advisor models → no watching: never let the primary model review its own turns.
|
|
148
150
|
if (rt.models.length === 0) return;
|
|
149
151
|
const config = rt.config.watch;
|
|
@@ -158,7 +160,7 @@ export async function reviewTurn(rt: WatcherRuntime, ctx: ExtensionContext, host
|
|
|
158
160
|
if (calls < config.minToolCalls) { rt.stats.skippedTrivial++; return; }
|
|
159
161
|
|
|
160
162
|
rt.guard.reviewIndex++;
|
|
161
|
-
reviewing = true;
|
|
163
|
+
rt.reviewing = true;
|
|
162
164
|
try {
|
|
163
165
|
const transcript = buildSessionContext(entries, ctx.sessionManager.getLeafId());
|
|
164
166
|
const evidence = buildEvidence(ctx, rt.models, transcript.messages, SYSTEM);
|
|
@@ -181,7 +183,7 @@ export async function reviewTurn(rt: WatcherRuntime, ctx: ExtensionContext, host
|
|
|
181
183
|
rt.failures++;
|
|
182
184
|
if (rt.failures >= MAX_CONSECUTIVE_FAILURES && !rt.stats.paused) {
|
|
183
185
|
rt.stats.paused = true;
|
|
184
|
-
|
|
186
|
+
host.notify(`Advisor watch paused after ${MAX_CONSECUTIVE_FAILURES} consecutive review failures (${String(error)}). Run /advisor on to retry.`);
|
|
185
187
|
}
|
|
186
188
|
return;
|
|
187
189
|
}
|
|
@@ -236,6 +238,6 @@ export async function reviewTurn(rt: WatcherRuntime, ctx: ExtensionContext, host
|
|
|
236
238
|
host.sendUserMessage(templates[verdict.severity], { deliverAs: "followUp" });
|
|
237
239
|
rt.steerCooldownTurns = config.immuneTurns;
|
|
238
240
|
} finally {
|
|
239
|
-
reviewing = false;
|
|
241
|
+
rt.reviewing = false;
|
|
240
242
|
}
|
|
241
243
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-advisor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Pi extension for an automatic advisor: a second model that reviews each settled turn and injects severity-routed notes, plus an on-demand consult tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@earendil-works/pi-ai": "^0.85.0",
|
|
51
51
|
"@earendil-works/pi-coding-agent": "^0.85.0",
|
|
52
|
-
"@earendil-works/pi-server": "^0.85.0",
|
|
53
52
|
"@earendil-works/pi-tui": "^0.85.0",
|
|
54
53
|
"@types/mocha": "^10.0.10",
|
|
55
54
|
"@types/node": "^20.19.43",
|