@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 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, non-blocker notes within the
18
- next `immuneTurns` settled turns are deferred (LLM-visible next turn)
19
- instead of waking the agent again — bounds ping-pong. Blockers always
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, reasoning);
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 },
@@ -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
- if (!watchEnabled || !runtime || runtime.models.length === 0 || runtime.stats.paused) return;
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
- await reviewTurn(runtime, ctx, {
114
- sendMessage: (message, options) => pi.sendMessage(message, options as never),
115
- sendUserMessage: (content, options) => pi.sendUserMessage(content, options),
116
- appendEntry: (customType, data) => pi.appendEntry(customType, data),
117
- }, testIsolated);
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) => {
@@ -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
- await rename(tmp, file);
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
- await rename(tmp, settingsPath);
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
- ctx.ui.notify(`Advisor watch paused after ${MAX_CONSECUTIVE_FAILURES} consecutive review failures (${String(error)}). Run /advisor on to retry.`, "error");
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.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",