pi-goal-list-loop-audit 0.29.15 → 0.29.17

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.
@@ -152,6 +152,11 @@ import {
152
152
  SettingsMenuComponent,
153
153
  type SettingsRow,
154
154
  } from "../settings-menu.js";
155
+ import {
156
+ buildModelPickItems,
157
+ ModelPickerComponent,
158
+ type ModelPickItem,
159
+ } from "../model-picker.js";
155
160
  import {
156
161
  applyMeasurement,
157
162
  applyMetriclessTick,
@@ -424,6 +429,15 @@ const countedLoopTokenMessages = new Set<string>();
424
429
 
425
430
  // Heartbeat self-watchdog state: liveness is the loop's own job.
426
431
  let lastActivityAt = Date.now();
432
+ // v0.29.16: zombie-run watchdog clock — updated ONLY by genuine pi stream
433
+ // events (message_update / tool_call / agent_start / turn_start /
434
+ // agent_end), never by heartbeat-internal bookkeeping. A session pi
435
+ // reports as BUSY with zero stream events for N min is a hung provider
436
+ // stream (pi has no read timeout): continuations queue into the void and
437
+ // the busy flag conceals the wedge from every other watchdog (field:
438
+ // hellhunter + hegemon 2026-07-30, MiniMax streams died silently).
439
+ let lastStreamActivityAt = Date.now();
440
+ let lastZombieAlertAt = 0;
427
441
  let lastWedgeAlertAt = 0;
428
442
  let heartbeatNudges = 0;
429
443
  // v0.28.4 (P3): skip nudge accounting for the first agent_end turns after a
@@ -445,6 +459,9 @@ let carryoverResolved = true;
445
459
  let completionAuditInFlight = false;
446
460
  let heartbeatTimer: NodeJS.Timeout | null = null;
447
461
 
462
+ const ZOMBIE_RUN_SILENT_MS = 20 * 60_000;
463
+ const ZOMBIE_RUN_ALERT_THROTTLE_MS = 10 * 60_000;
464
+
448
465
  function noteActivity(real = false): void {
449
466
  lastActivityAt = Date.now();
450
467
  if (real) consecutiveStalls = 0;
@@ -653,6 +670,20 @@ function heartbeatTick(): void {
653
670
  // escalation would PAUSE the goal — silently cancelling the
654
671
  // interruptedAt → hold-on-restart promise the footer shows.
655
672
  if (probeExtensionApiStale()) { goStaleTerminal(ctx, "heartbeat probe"); return; }
673
+ // v0.29.16: zombie-run watchdog. pi reports BUSY (a run is "active") but
674
+ // zero stream events for 20 min = the provider stream hung silently —
675
+ // queued continuations can't land, and every other watchdog stays quiet
676
+ // because busy≠wedged. Detection + loud guidance only: aborting a turn
677
+ // is the user's call (consent line); Esc frees the queue and the
678
+ // heartbeat refires the goal/loop by itself.
679
+ const streamSilentMs = Date.now() - lastStreamActivityAt;
680
+ if (isSupervising() && !idle && streamSilentMs >= ZOMBIE_RUN_SILENT_MS && Date.now() - lastZombieAlertAt >= ZOMBIE_RUN_ALERT_THROTTLE_MS) {
681
+ lastZombieAlertAt = Date.now();
682
+ appendLedger(ctx.cwd, "zombie_run_suspected", { streamSilentMs, pending });
683
+ ctx.ui.notify(`glla: the session has been BUSY with zero stream activity for ${Math.round(streamSilentMs / 60000)} min — the provider stream is hung (pi never times it out; queued continuations can't land). Press Esc to abort the zombie turn — the goal/loop refires itself.`, "warning");
684
+ notifyExternal(ctx, `glla: zombie run suspected (${Math.round(streamSilentMs / 60000)} min busy-silent) — press Esc to abort.`);
685
+ return;
686
+ }
656
687
  // v0.29.1: stranded-audit recovery. A goal left in "auditing" with NO
657
688
  // in-flight audit means the auditor's result never landed (wedged queue
658
689
  // ate the tool result; compaction/restart mid-audit). Field-observed in
@@ -3917,19 +3948,37 @@ function getSessionThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high
3917
3948
  function resolveAuditorModel(ctx: ExtensionContext, ref?: string): { model: any; error?: string; via?: string } {
3918
3949
  if (ref && ref.trim()) {
3919
3950
  const trimmed = ref.trim();
3951
+ // v0.29.17: an unavailable configured model (unknown id, or a provider
3952
+ // with no configured auth) falls back LOUDLY to the session model —
3953
+ // user request: "fall back to the session if unavailable". The v0.9.12
3954
+ // no-SILENT-substitution law stands: the fallback notifies + ledgers.
3955
+ // (Quota-exhausted keys stay on the quota-retry path — the model IS
3956
+ // available there; the key's window is the failure, not the model.)
3957
+ const fail = (reason: string) => {
3958
+ const sessionModel = ctx.model as any;
3959
+ if (sessionModel) {
3960
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: trimmed, reason });
3961
+ ctx.ui.notify(`Auditor model "${trimmed}" is unavailable (${reason}) — falling back to the session model. Fix via /glla → Auditor model.`, "warning");
3962
+ return { model: sessionModel, via: "session-fallback" };
3963
+ }
3964
+ return { model: undefined, error: `${reason}: ${trimmed}` };
3965
+ };
3920
3966
  const slash = trimmed.indexOf("/");
3921
3967
  if (slash > 0) {
3922
3968
  const provider = trimmed.slice(0, slash);
3923
3969
  const id = trimmed.slice(slash + 1);
3924
3970
  const model = ctx.modelRegistry.find(provider, id);
3925
- return model ? { model, via: "setting" } : { model: undefined, error: `model not found: ${trimmed}` };
3971
+ if (!model) return fail("model not found");
3972
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) return fail(`no configured auth for ${provider}`);
3973
+ return { model, via: "setting" };
3926
3974
  }
3927
3975
  const matches = ctx.modelRegistry.getAvailable().filter((m: any) => m.id === trimmed || m.name === trimmed);
3928
- return matches[0] ? { model: matches[0], via: "setting" } : { model: undefined, error: `no available model matching: ${trimmed}` };
3976
+ if (matches[0]) return { model: matches[0], via: "setting" };
3977
+ return fail("no available model matching");
3929
3978
  }
3930
3979
  const sessionModel = ctx.model as any;
3931
3980
  if (sessionModel) return { model: sessionModel, via: "session" };
3932
- return { model: undefined, error: "no session model and no auditorModel configured — set one with /glla model=provider/id" };
3981
+ return { model: undefined, error: "no session model and no auditorModel configured — set one with /glla → Auditor model" };
3933
3982
  }
3934
3983
 
3935
3984
  // (v0.9.12) The auto-fallback apparatus was REMOVED: no tier ranking, no
@@ -4006,6 +4055,43 @@ async function promptSettingsMenu(
4006
4055
  */
4007
4056
  // v0.28.7 (T4): exported for the behavioral settings-editor tests
4008
4057
  // (tests/settings-editors.test.ts drives each editor class end-to-end).
4058
+ /**
4059
+ * v0.29.17: /model-style fuzzy picker for model-valued settings. Builds the
4060
+ * item list from the registry (configured-auth providers only — a pick
4061
+ * from this list can never be a dead provider) and hosts the picker via
4062
+ * ctx.ui.custom. Falls back to the typed input when the runtime has no
4063
+ * custom shard (headless) — typing stays the emergency hatch there.
4064
+ * Returns { kind: "session" } to clear the override, { kind: "ref" } with
4065
+ * provider/id, or undefined for cancel.
4066
+ */
4067
+ async function promptModelRef(
4068
+ ctx: ExtensionContext,
4069
+ title: string,
4070
+ emptyLabel: string,
4071
+ ): Promise<{ kind: "session" } | { kind: "ref"; ref: string } | undefined> {
4072
+ if (typeof (ctx.ui as { custom?: unknown }).custom !== "function" || !ctx.modelRegistry) {
4073
+ const v = await ctx.ui.input(title, "provider/model-id — empty keeps the default");
4074
+ if (v === undefined) return undefined;
4075
+ return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
4076
+ }
4077
+ const sessionModel = ctx.model as any;
4078
+ const sessionLabel = sessionModel ? `${sessionModel.provider}/${sessionModel.id}` : "pi session model";
4079
+ const models = ctx.modelRegistry
4080
+ .getAvailable()
4081
+ .filter((m: any) => ctx.modelRegistry.hasConfiguredAuth(m));
4082
+ const items = buildModelPickItems(models, sessionLabel);
4083
+ const pick = await ctx.ui.custom<ModelPickItem | undefined>((tui, theme, keybindings, done) => {
4084
+ return new ModelPickerComponent({ title, items }, () => tui.requestRender(), theme, keybindings, done);
4085
+ });
4086
+ if (!pick) return undefined;
4087
+ if (pick.kind === "session") return { kind: "session" };
4088
+ if (pick.kind === "model" && pick.ref) return { kind: "ref", ref: pick.ref };
4089
+ // manual escape hatch — typed provider/model, validated like before
4090
+ const v = await ctx.ui.input(title, emptyLabel);
4091
+ if (v === undefined) return undefined;
4092
+ return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
4093
+ }
4094
+
4009
4095
  export async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
4010
4096
  switch (id) {
4011
4097
  case "autoResume": {
@@ -4045,8 +4131,10 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
4045
4131
  return;
4046
4132
  }
4047
4133
  case "auditorModel": {
4048
- const v = await ctx.ui.input("Auditor model override", "provider/model-id — empty keeps the pi session model");
4049
- if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
4134
+ const pick = await promptModelRef(ctx, "Auditor model override", "provider/model-id — empty keeps the pi session model");
4135
+ if (pick === undefined) return;
4136
+ saveSettings("global", ctx.cwd, { auditorModel: pick.kind === "session" ? undefined : pick.ref });
4137
+ if (pick.kind === "session") ctx.ui.notify("Auditor model override cleared — the auditor follows the pi session model.", "info");
4050
4138
  return;
4051
4139
  }
4052
4140
  case "auditorThinkingLevel": {
@@ -4151,15 +4239,14 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
4151
4239
  case "subagentModelOverrides.Plan":
4152
4240
  case "subagentModelOverrides.general-purpose": {
4153
4241
  const agentType = id.slice("subagentModelOverrides.".length);
4154
- const v = await ctx.ui.input(`Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
4155
- if (v !== undefined) {
4156
- const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
4157
- const next = { ...current };
4158
- if (v.trim()) next[agentType] = v.trim();
4159
- else delete next[agentType];
4160
- saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
4161
- ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
4162
- }
4242
+ const pick = await promptModelRef(ctx, `Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
4243
+ if (pick === undefined) return;
4244
+ const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
4245
+ const next = { ...current };
4246
+ if (pick.kind === "ref") next[agentType] = pick.ref;
4247
+ else delete next[agentType];
4248
+ saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
4249
+ ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
4163
4250
  return;
4164
4251
  }
4165
4252
  case "subagentResolved":
@@ -5467,6 +5554,7 @@ export default function (pi: ExtensionAPI): void {
5467
5554
  // continuation loop.
5468
5555
  if (isForeignCtx(ctx)) return;
5469
5556
  noteActivity(true);
5557
+ lastStreamActivityAt = Date.now();
5470
5558
  // v0.27.2: folded-in length-continue (standalone pi-length-continue is
5471
5559
  // deprecated). A response cut by the per-response output cap is NOT a
5472
5560
  // completed turn (no telemetry), NOT a stall (no no-tool nudge), and
@@ -5734,9 +5822,22 @@ export default function (pi: ExtensionAPI): void {
5734
5822
  pi.on("tool_call", () => {
5735
5823
  toolCallsThisTurn++;
5736
5824
  noteActivity(true);
5825
+ lastStreamActivityAt = Date.now();
5737
5826
  // v0.24.0: count loop-iteration tool calls (narration-only detection).
5738
5827
  if (isLoopActive()) {
5739
5828
  state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
5740
5829
  }
5741
5830
  });
5831
+
5832
+ // v0.29.16: stream liveness for the zombie-run watchdog — deltas, run
5833
+ // starts, and turn starts all prove the provider stream is alive.
5834
+ pi.on("message_update", () => {
5835
+ lastStreamActivityAt = Date.now();
5836
+ });
5837
+ pi.on("agent_start", () => {
5838
+ lastStreamActivityAt = Date.now();
5839
+ });
5840
+ pi.on("turn_start", () => {
5841
+ lastStreamActivityAt = Date.now();
5842
+ });
5742
5843
  }
@@ -0,0 +1,208 @@
1
+ // pi-goal-list-loop-audit — v0.29.17
2
+ // extensions/model-picker.ts
3
+ //
4
+ // A /model-style fuzzy picker for model-valued settings (Auditor model,
5
+ // subagent model pins). Why this exists:
6
+ // • ctx.ui.select renders EVERY option unsorted with no search — a full
7
+ // model registry is hundreds of rows; unusable (field: the auditor
8
+ // model was left on a quota-dead openrouter key partly because fixing
9
+ // it meant hand-typing provider/model into a bare input).
10
+ // • pi's own /model dialog (ModelSelectorComponent) needs ModelRuntime
11
+ // and SettingsManager internals that extensions never receive.
12
+ // So we rebuild the same interaction shape — a search line with a
13
+ // fuzzy-filtered list — from pi-tui primitives (fuzzyFilter) over
14
+ // ctx.modelRegistry, hosted via ctx.ui.custom, exactly like the v0.28.0
15
+ // settings table. Unit-testable via synthetic handleInput calls.
16
+ //
17
+ // Item order: "session model" (clear override) first, then every
18
+ // configured-auth model sorted by provider/id, then "type manually…" last.
19
+
20
+ import { fuzzyFilter, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
+ import type { SettingsMenuTheme, KeybindingsManagerLike } from "./settings-menu.ts";
22
+
23
+ export type ModelPickKind = "session" | "model" | "manual";
24
+
25
+ export interface ModelPickItem {
26
+ kind: ModelPickKind;
27
+ /** provider/model-id for kind === "model". */
28
+ ref?: string;
29
+ /** Row label shown in the list. */
30
+ label: string;
31
+ /** Text the fuzzy filter matches against. */
32
+ searchText: string;
33
+ }
34
+
35
+ export interface RegistryModelLike {
36
+ provider: string;
37
+ id: string;
38
+ name?: string;
39
+ }
40
+
41
+ /** Build the picker's static item list from registry models (already
42
+ * filtered to configured-auth providers by the caller). Session row first,
43
+ * manual-entry row last; models sorted by provider then id. */
44
+ export function buildModelPickItems(models: RegistryModelLike[], sessionLabel: string): ModelPickItem[] {
45
+ const sorted = [...models].sort((a, b) =>
46
+ a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider),
47
+ );
48
+ return [
49
+ {
50
+ kind: "session",
51
+ label: `session model (${sessionLabel}) — clear the override`,
52
+ searchText: "session model default clear override follow",
53
+ },
54
+ ...sorted.map((m) => {
55
+ const ref = `${m.provider}/${m.id}`;
56
+ return {
57
+ kind: "model" as const,
58
+ ref,
59
+ label: m.name && m.name !== m.id ? `${ref} — ${m.name}` : ref,
60
+ searchText: `${ref} ${m.name ?? ""}`,
61
+ };
62
+ }),
63
+ {
64
+ kind: "manual",
65
+ label: "type provider/model manually…",
66
+ searchText: "manual type custom provider model id",
67
+ },
68
+ ];
69
+ }
70
+
71
+ export interface ModelPickerFactoryDeps {
72
+ title: string;
73
+ items: ModelPickItem[];
74
+ /** Cap on visible list rows (window scrolls with the selection). */
75
+ maxVisibleRows?: number;
76
+ }
77
+
78
+ export class ModelPickerComponent {
79
+ private readonly title: string;
80
+ private readonly items: ModelPickItem[];
81
+ private readonly maxRows: number;
82
+ private readonly requestRender: () => void;
83
+ private readonly theme: SettingsMenuTheme;
84
+ private readonly keybindings: KeybindingsManagerLike;
85
+ private readonly done: (item: ModelPickItem | undefined) => void;
86
+
87
+ private query = "";
88
+ private selectedIdx = 0;
89
+
90
+ constructor(
91
+ deps: ModelPickerFactoryDeps,
92
+ requestRender: () => void,
93
+ theme: SettingsMenuTheme,
94
+ keybindings: KeybindingsManagerLike,
95
+ done: (item: ModelPickItem | undefined) => void,
96
+ ) {
97
+ this.title = deps.title;
98
+ this.items = deps.items;
99
+ this.maxRows = deps.maxVisibleRows ?? 12;
100
+ this.requestRender = requestRender;
101
+ this.theme = theme;
102
+ this.keybindings = keybindings;
103
+ this.done = done;
104
+ }
105
+
106
+ /** Current search query. Exposed for tests. */
107
+ getQuery(): string {
108
+ return this.query;
109
+ }
110
+
111
+ /** Index into the filtered list. Exposed for tests. */
112
+ getSelectedIdx(): number {
113
+ return this.selectedIdx;
114
+ }
115
+
116
+ /** Filtered items for the current query. Exposed for tests. */
117
+ filteredItems(): ModelPickItem[] {
118
+ if (!this.query.trim()) return this.items;
119
+ return fuzzyFilter(this.items, this.query.trim(), (it) => it.searchText);
120
+ }
121
+
122
+ private refresh(): void {
123
+ this.requestRender();
124
+ }
125
+
126
+ private move(delta: number): void {
127
+ const n = this.filteredItems().length;
128
+ if (n === 0) return;
129
+ this.selectedIdx = ((this.selectedIdx + delta) % n + n) % n;
130
+ this.refresh();
131
+ }
132
+
133
+ render(width: number): string[] {
134
+ const w = Math.max(20, width - 2);
135
+ const lines: string[] = [];
136
+ lines.push(this.theme.fg("accent", this.theme.bold(this.title)));
137
+ lines.push("");
138
+ const searchLine = `search: ${this.query}`;
139
+ lines.push(this.theme.fg("muted", truncateToWidth(searchLine, w, "…") + "▏"));
140
+ lines.push("");
141
+ const filtered = this.filteredItems();
142
+ if (filtered.length === 0) {
143
+ lines.push(this.theme.fg("warning", " no matches — keep typing, or Esc to cancel"));
144
+ } else {
145
+ const sel = Math.min(this.selectedIdx, filtered.length - 1);
146
+ const half = Math.floor(this.maxRows / 2);
147
+ const start = Math.max(0, Math.min(sel - half, filtered.length - this.maxRows));
148
+ const window = filtered.slice(start, start + this.maxRows);
149
+ if (start > 0) lines.push(this.theme.fg("dim", ` ↑ ${start} more`));
150
+ for (let i = 0; i < window.length; i++) {
151
+ const idx = start + i;
152
+ const it = window[i]!;
153
+ const row = truncateToWidth(it.label, w - 2, "…");
154
+ lines.push(idx === sel ? this.theme.fg("accent", `→ ${row}`) : ` ${row}`);
155
+ }
156
+ const remaining = filtered.length - (start + window.length);
157
+ if (remaining > 0) lines.push(this.theme.fg("dim", ` ↓ ${remaining} more`));
158
+ }
159
+ lines.push("");
160
+ lines.push(this.theme.fg("dim", "type to filter · ↑/↓ move · enter select · esc cancel"));
161
+ return lines;
162
+ }
163
+
164
+ handleInput(data: string): void {
165
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
166
+ const it = this.filteredItems()[this.selectedIdx];
167
+ this.done(it);
168
+ return;
169
+ }
170
+ if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
171
+ this.done(undefined);
172
+ return;
173
+ }
174
+ if (this.keybindings.matches(data, "tui.select.up")) {
175
+ this.move(-1);
176
+ return;
177
+ }
178
+ if (this.keybindings.matches(data, "tui.select.down")) {
179
+ this.move(+1);
180
+ return;
181
+ }
182
+ if (data === "\x7f" || data === "\b") {
183
+ if (this.query.length > 0) {
184
+ this.query = this.query.slice(0, -1);
185
+ this.selectedIdx = 0;
186
+ this.refresh();
187
+ }
188
+ return;
189
+ }
190
+ // Printable input (single keystrokes and pasted runs alike). Ignore
191
+ // escape/CSI sequences — they start with \x1b and were handled above.
192
+ if (!data.startsWith("\x1b")) {
193
+ const printable = [...data].filter((ch) => ch >= " ").join("");
194
+ if (printable.length > 0) {
195
+ this.query += printable;
196
+ this.selectedIdx = 0;
197
+ this.refresh();
198
+ }
199
+ }
200
+ }
201
+
202
+ invalidate(): void {
203
+ // Stateless beyond the query/selection — nothing to clear.
204
+ }
205
+ }
206
+
207
+ // Re-export for callers that only need the width helper's type signature.
208
+ export { visibleWidth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.29.15",
3
+ "version": "0.29.17",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",