@bacnh85/pi-advisor 0.2.2 → 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 CHANGED
@@ -1,5 +1,30 @@
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
+
3
28
  ## 0.2.2 (2026-09-07)
4
29
 
5
30
  - **Fix TUI hang on stalled reviewer provider**: every chain candidate now
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
@@ -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.";
@@ -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
- 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);
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, {
@@ -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.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",