@bacnh85/pi-advisor 0.2.2 → 0.2.4
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 +41 -0
- package/README.md +6 -5
- package/extensions/commands/advisor.ts +1 -3
- package/extensions/index.ts +34 -11
- package/extensions/lib/config.ts +13 -3
- package/extensions/lib/watcher.ts +10 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.4 (2026-09-12)
|
|
4
|
+
|
|
5
|
+
### Removed
|
|
6
|
+
|
|
7
|
+
- Dead `AdvisorState.getThinking` knob (sole impl returned `undefined`; the
|
|
8
|
+
value flowed nowhere meaningful).
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- `saveModels` / `migrateLegacyAdvisorModel` no longer leave a `*.tmp-*` file
|
|
13
|
+
behind when the final rename throws (best-effort unlink, error re-thrown).
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
- README: added `/advisor watch-off` to the command list.
|
|
18
|
+
|
|
19
|
+
## 0.2.3 (2026-09-11)
|
|
20
|
+
|
|
21
|
+
- **Fix 20–30s+ TUI freeze after every settled turn**: pi core awaits
|
|
22
|
+
`agent_settled` handlers before the prompt regains input, and the advisor
|
|
23
|
+
review (a full model call — measured median ~11s, p90 ~96s) ran inside that
|
|
24
|
+
awaited barrier, freezing the TUI — most visibly after `write_plan`, where
|
|
25
|
+
the prefilled `/plan-approve` was dead until the review finished. The review
|
|
26
|
+
now runs fire-and-forget: notes deliver via `sendUserMessage`, which the SDK
|
|
27
|
+
queues as a steer when a run is already active or fires as a follow-up turn
|
|
28
|
+
when idle. A still-running review makes the next settle skip (bounded loss).
|
|
29
|
+
- **Watch is skipped in headless modes** (print/rpc/json): a floating review
|
|
30
|
+
would die at process exit, and a note there fired an unrequested follow-up
|
|
31
|
+
agent run. The on-demand `/advisor` consult tool is unaffected.
|
|
32
|
+
- **Hardened against stale/edge contexts** (review follow-up): a review still
|
|
33
|
+
in flight when a new session starts is silently discarded instead of
|
|
34
|
+
steering the new session; the in-flight guard moved to per-runtime state so
|
|
35
|
+
a draining old review can't suppress the new session's first review; a
|
|
36
|
+
mode-less context is treated as headless (fail-safe skip).
|
|
37
|
+
- **Delivery-time liveness + teardown self-disarm** (final review round): a
|
|
38
|
+
review in flight across `/new`, `/resume`, `/fork`, `/reload`, or quit is
|
|
39
|
+
discarded silently — `session_shutdown` disarms the watch before the runner
|
|
40
|
+
invalidates, so no stale-ctx error toast lands in the replaced session; the
|
|
41
|
+
pause toast routes through the same liveness gate; the watch-disabled state
|
|
42
|
+
mid-review (`/advisor off`, `watch-off`) also suppresses delivery.
|
|
43
|
+
|
|
3
44
|
## 0.2.2 (2026-09-07)
|
|
4
45
|
|
|
5
46
|
- **Fix TUI hang on stalled reviewer provider**: every chain candidate now
|
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ consult tool. Inspired by the advisor subsystem in
|
|
|
14
14
|
turn during the post-steer calm-down window. The severity sets the note's
|
|
15
15
|
authority wording (“nit — consider” vs “concern — address this” vs
|
|
16
16
|
“blocker — fix before continuing”).
|
|
17
|
-
- Post-steer cooldown: after a note steers,
|
|
18
|
-
|
|
19
|
-
instead of waking the agent again — bounds ping-pong.
|
|
20
|
-
steer immediately.
|
|
17
|
+
- Post-steer cooldown: after a note steers, nit notes within the next
|
|
18
|
+
`immuneTurns` settled turns are deferred (LLM-visible next turn)
|
|
19
|
+
instead of waking the agent again — bounds ping-pong. Concerns and
|
|
20
|
+
blockers always steer immediately.
|
|
21
21
|
- **Emission guard** (noise control): content-free phrases ("lgtm", "done", …)
|
|
22
22
|
are dropped, identical notes are deduped (severity escalation still passes),
|
|
23
23
|
and at most one note is delivered per review cycle.
|
|
@@ -47,6 +47,7 @@ npm install -g @bacnh85/pi-advisor
|
|
|
47
47
|
/advisor models # edit the full model chain (TUI panel; non-TUI prints it)
|
|
48
48
|
/advisor status # model chain, watch state, counters
|
|
49
49
|
/advisor on # enable watch for this session (also clears a pause)
|
|
50
|
+
/advisor watch-off # disable background watch for this session
|
|
50
51
|
/advisor off # clear the chain (disables tool + watch)
|
|
51
52
|
```
|
|
52
53
|
|
|
@@ -68,7 +69,7 @@ Settings live in `~/.pi/agent/settings.json` (global) and `.pi/settings.json`
|
|
|
68
69
|
time, the next candidate serves automatically; a whole-chain failure counts
|
|
69
70
|
as one review failure (the 3-strike pause still applies). The advisor never
|
|
70
71
|
falls back to the primary model — it must never review its own turns.
|
|
71
|
-
- `watch.enabled` (default `true`) — turn-end reviewing on session start
|
|
72
|
+
- `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
73
|
- `watch.minToolCalls` (default `3`, `0` = every turn) — skip trivial turns
|
|
73
74
|
- `watch.immuneTurns` (default `3`) — review window during which the same
|
|
74
75
|
normalized note is not re-delivered (loop protection); distinct concerns and
|
|
@@ -19,7 +19,6 @@ export function parseChainArgument(raw: string): string[] {
|
|
|
19
19
|
export interface AdvisorState {
|
|
20
20
|
getModels(): string[];
|
|
21
21
|
setModels(models: string[]): Promise<void> | void;
|
|
22
|
-
getThinking(): string | undefined;
|
|
23
22
|
getRuntime(): WatcherRuntime | undefined;
|
|
24
23
|
isWatchEnabled(): boolean;
|
|
25
24
|
setWatchEnabled(value: boolean): void;
|
|
@@ -100,7 +99,6 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
100
99
|
const transcriptEvidence = buildEvidence(ctx, models, transcript.messages, SYSTEM);
|
|
101
100
|
const chain = models.join(" → ");
|
|
102
101
|
onUpdate?.({ content: [{ type: "text", text: `Consulting ${chain}…` }], details: { models } });
|
|
103
|
-
const reasoning = state.getThinking();
|
|
104
102
|
// Progressive display resets per attempt: a candidate that dies mid-stream
|
|
105
103
|
// must not leave its partial output above the next candidate's response.
|
|
106
104
|
let output = "";
|
|
@@ -116,7 +114,7 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
116
114
|
if (forAttempt !== attempt) { attempt = forAttempt; output = ""; }
|
|
117
115
|
output += delta;
|
|
118
116
|
onUpdate?.({ content: [{ type: "text", text: output }], details: { models } });
|
|
119
|
-
}, signal
|
|
117
|
+
}, signal);
|
|
120
118
|
return {
|
|
121
119
|
content: [{ type: "text", text: `Advice from ${result.model}:\n${result.text}` }],
|
|
122
120
|
details: { models, served: result.model },
|
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.";
|
|
@@ -92,10 +94,6 @@ export default function piAdvisor(pi: ExtensionAPI): void {
|
|
|
92
94
|
if (legacy) {
|
|
93
95
|
models = [legacy];
|
|
94
96
|
ctx.ui.notify(`Advisor model migrated from pi-plan: ${legacy}`, "info");
|
|
95
|
-
// If we migrated on top of a pi-plan that had legacyMigrated:true already,
|
|
96
|
-
// the user config may still lack migrationVersion. Backfill it idempotently
|
|
97
|
-
// so no future manual patch is needed (code writes, agent never touches
|
|
98
|
-
// the real global config directly).
|
|
99
97
|
}
|
|
100
98
|
}
|
|
101
99
|
runtime = createRuntime(config, models);
|
|
@@ -108,13 +106,39 @@ export default function piAdvisor(pi: ExtensionAPI): void {
|
|
|
108
106
|
runtime.cursor = entries.length ? entries[entries.length - 1].id : undefined;
|
|
109
107
|
});
|
|
110
108
|
|
|
109
|
+
// Self-disarm on session teardown: session_shutdown is emitted and awaited
|
|
110
|
+
// BEFORE the runner invalidates (agent-session-runtime teardownCurrent), so
|
|
111
|
+
// flipping the flag here makes live() false in this (about-to-be-orphaned)
|
|
112
|
+
// closure — an in-flight fire-and-forget review is discarded silently
|
|
113
|
+
// instead of throwing the stale-ctx error into the .catch and toasting the
|
|
114
|
+
// NEW session. The factory re-runs per session, so this never touches a
|
|
115
|
+
// live session's flag.
|
|
116
|
+
pi.on("session_shutdown", () => { watchEnabled = false; });
|
|
117
|
+
|
|
111
118
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
112
119
|
if (!runtime || !watchEnabled || runtime.stats.paused) return;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
// Only TUI: a floating review would die at process exit in headless
|
|
121
|
+
// modes, and a note there fired an unrequested follow-up run. Fail-safe:
|
|
122
|
+
// unknown/mode-less contexts skip too (missed review < surprise run).
|
|
123
|
+
if (ctx.mode !== "tui") return;
|
|
124
|
+
// ponytail: fire-and-forget — pi core awaits agent_settled handlers before
|
|
125
|
+
// the TUI regains input, so awaiting the 10-90s+ review here froze the UI
|
|
126
|
+
// after every turn. Notes deliver via sendUserMessage, which the SDK
|
|
127
|
+
// queues as a steer when a run is active or fires as a follow-up turn
|
|
128
|
+
// when idle. A still-running review makes the next settle skip (rt
|
|
129
|
+
// reviewing flag) — bounded loss, not queued.
|
|
130
|
+
const rt = runtime;
|
|
131
|
+
// Liveness guard evaluated at delivery time (not just settle time): a
|
|
132
|
+
// fresh session (/new) replaces runtime, /advisor off clears the chain,
|
|
133
|
+
// watch-off flips the flag, repeated failures pause — a review in flight
|
|
134
|
+
// across any of these must deliver nothing.
|
|
135
|
+
const live = () => runtime === rt && watchEnabled && rt.models.length > 0 && !rt.stats.paused;
|
|
136
|
+
void reviewTurn(rt, ctx, {
|
|
137
|
+
sendMessage: (message, options) => { if (live()) pi.sendMessage(message, options as never); },
|
|
138
|
+
sendUserMessage: (content, options) => { if (live()) pi.sendUserMessage(content, options); },
|
|
139
|
+
appendEntry: (customType, data) => { if (live()) pi.appendEntry(customType, data); },
|
|
140
|
+
notify: (message) => { if (live()) ctx.ui.notify(message, "error"); },
|
|
141
|
+
}, testIsolated).catch((err) => { if (live()) ctx.ui.notify(`Advisor review failed: ${String(err)}`, "error"); });
|
|
118
142
|
});
|
|
119
143
|
|
|
120
144
|
registerAdvisor(pi, {
|
|
@@ -123,7 +147,6 @@ export default function piAdvisor(pi: ExtensionAPI): void {
|
|
|
123
147
|
await saveModels(models);
|
|
124
148
|
if (runtime) runtime.models = models;
|
|
125
149
|
},
|
|
126
|
-
getThinking: () => undefined,
|
|
127
150
|
getRuntime: () => runtime,
|
|
128
151
|
isWatchEnabled: () => watchEnabled,
|
|
129
152
|
setWatchEnabled: (value) => {
|
package/extensions/lib/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CONFIG_DIR_NAME, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -94,7 +94,12 @@ export async function saveModels(models: string[]): Promise<void> {
|
|
|
94
94
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
95
95
|
await mkdir(path.dirname(file), { recursive: true });
|
|
96
96
|
await writeFile(tmp, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
97
|
-
|
|
97
|
+
try {
|
|
98
|
+
await rename(tmp, file);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
try { await unlink(tmp); } catch { /* best-effort cleanup */ }
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
export function parseModel(value: string): { provider: string; id: string } | undefined {
|
|
@@ -130,7 +135,12 @@ export async function migrateLegacyAdvisorModel(): Promise<string | undefined> {
|
|
|
130
135
|
const tmp = `${settingsPath}.tmp-${process.pid}`;
|
|
131
136
|
await mkdir(path.dirname(settingsPath), { recursive: true });
|
|
132
137
|
await writeFile(tmp, JSON.stringify({ ...settings, [KEY]: { ...block, model: legacy, migrationVersion: MIGRATION_VERSION } }, null, 2) + "\n", "utf8");
|
|
133
|
-
|
|
138
|
+
try {
|
|
139
|
+
await rename(tmp, settingsPath);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
try { await unlink(tmp); } catch { /* best-effort cleanup */ }
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
134
144
|
return legacy;
|
|
135
145
|
} catch { return undefined; }
|
|
136
146
|
}
|
|
@@ -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.4",
|
|
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",
|