pi-goal-list-loop-audit 0.27.9 → 0.28.1

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.
@@ -156,6 +156,12 @@ export interface Goal {
156
156
  stopReason?: string;
157
157
  pauseReason?: string;
158
158
  pauseSuggestedAction?: string;
159
+ /** v0.28.1 (S1/S2): stale-handle interrupt marker. Set INSTEAD of pausing
160
+ * when pi invalidates the extension handle mid-goal — the goal stays
161
+ * active so a fresh session auto-resumes it via the restore gate. Cleared
162
+ * on that auto-resume. */
163
+ interruptedAt?: string;
164
+ interruptedReason?: string;
159
165
  /** v0.25.0 (contract item 22): auditor objections extracted as TODOs when
160
166
  * aggressiveMode keeps the goal active past the disapproval cap. Rendered
161
167
  * into every continuation prompt until the next audit clears them. */
@@ -137,6 +137,11 @@ export function buildStatusText(state: State, audit?: AuditDisplayProgress | nul
137
137
  return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}`;
138
138
  }
139
139
  if (g.status === "active") {
140
+ // v0.28.1 (S1/S2): a stale-handle interrupt keeps the goal ACTIVE (the
141
+ // next fresh session auto-resumes it) — say so instead of looking healthy.
142
+ if (g.interruptedAt) {
143
+ return `glla: ${g.policy} ${paint(theme, "error", "⚠ interrupted — stale handle · auto-resumes on pi restart")}`;
144
+ }
140
145
  // v0.24.7: list policy gets its own wording — a queue item is not a goal.
141
146
  // Before: "glla: list ● 3m 19s · list 29" (policy label AND queue counter
142
147
  // both said "list"). After: "glla: list ● 3m 19s · 29 queued". Goal
@@ -141,6 +141,11 @@ import {
141
141
  syncSubagentModelOverrides,
142
142
  type SubagentModelStrategy,
143
143
  } from "../goal-loop-subagents.js";
144
+ import {
145
+ buildSettingsRows,
146
+ SettingsMenuComponent,
147
+ type SettingsRow,
148
+ } from "../settings-menu.js";
144
149
  import {
145
150
  applyMeasurement,
146
151
  applyMetriclessTick,
@@ -193,24 +198,57 @@ let extensionApi: ExtensionAPI | null = null;
193
198
  // failure shape. Detect the stale signature once and go terminally loud.
194
199
  let extensionApiStale = false;
195
200
 
196
- /** v0.26.7: a stale api is terminal for this process — pause/stop loudly
197
- * with restart guidance instead of retrying sends that can never land. */
201
+ /** v0.26.7: a stale api is terminal for this process — go loudly with
202
+ * restart guidance instead of retrying sends that can never land.
203
+ * v0.28.1 (S1/S2): goals STAY ACTIVE with an interrupt marker instead of
204
+ * pausing — the restore gate only auto-resumes ACTIVE goals, so pausing
205
+ * here stranded goals until manual /goal resume (hegemon/sraaal shape).
206
+ * sendContinuation's extensionApiStale guard already stops further sends
207
+ * in this doomed process; the next fresh session auto-resumes. */
198
208
  function goStaleTerminal(ctx: ExtensionContext, where: string): void {
199
209
  if (extensionApiStale) return; // already terminal — don't re-spam
200
210
  extensionApiStale = true;
201
211
  appendLedger(ctx.cwd, "extension_api_stale", { where, kind: isLoopActive() ? "loop" : "goal" });
202
- const guidance = "pi invalidated this session's extension handle (session replacement — compaction triggers it in pi 0.82.x). Sends can never land in this process. Restart pi (or reload extensions), then /goal resume / /loop start.";
212
+ const guidance = "pi invalidated this session's extension handle (session replacement — compaction triggers it in pi 0.82.x). Sends can never land in this process. Restart pi (or reload extensions) an active goal auto-resumes on the fresh session; loops need /loop start.";
203
213
  if (isLoopActive()) {
204
214
  clearLoopTimer();
205
215
  state.loop = { ...state.loop!, active: false, stopReason: `extension api stale: ${guidance}` };
206
216
  persistState(ctx);
207
217
  } else if (state.goal && state.goal.status === "active") {
208
- updateGoal({ status: "paused", pauseReason: "extension api stale (pi session replacement)", pauseSuggestedAction: guidance }, ctx);
218
+ updateGoal({ interruptedAt: nowIso(), interruptedReason: `extension api stale (${where})` }, ctx);
209
219
  }
210
220
  ctx.ui.notify(`glla: ${guidance}`, "warning");
211
221
  notifyExternal(ctx, `glla: extension api stale — restart pi. (${where})`);
212
222
  }
213
223
 
224
+ /** v0.28.1 (S3): side-effect-free staleness probe — getSessionName()
225
+ * routes through pi's assertActive() and throws the stale signature iff
226
+ * pi invalidated this factory handle (session replacement). A positive
227
+ * result is cached in extensionApiStale. */
228
+ function probeExtensionApiStale(): boolean {
229
+ if (extensionApiStale) return true;
230
+ if (!extensionApi) return false;
231
+ try {
232
+ extensionApi.getSessionName();
233
+ } catch (err) {
234
+ if (isStaleApiError(err)) extensionApiStale = true;
235
+ }
236
+ return extensionApiStale;
237
+ }
238
+
239
+ /** v0.28.1 (S3): command-entry staleness probe + honest warning. Returns
240
+ * true when the handle is stale — callers must skip send-dependent paths
241
+ * and must NOT claim work started (S3's "created — starting now" lie). */
242
+ function warnIfStaleAtEntry(ctx: ExtensionContext, what: string): boolean {
243
+ if (!probeExtensionApiStale()) return false;
244
+ appendLedger(ctx.cwd, "extension_api_stale", { where: `entry probe (${what})` });
245
+ ctx.ui.notify(
246
+ `glla: this session's extension handle is stale (pi session replacement) — ${what} can't send continuations in this process. State is safe in .pi-glla/ — restart pi and the active goal auto-resumes.`,
247
+ "warning",
248
+ );
249
+ return true;
250
+ }
251
+
214
252
  // The most recent ExtensionContext seen from any event or command handler.
215
253
  // pi replaces sessions (newSession/fork/reload) and stale ctx throws on use,
216
254
  // so timers must never capture a ctx — they read lastCtx at fire time.
@@ -837,8 +875,18 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
837
875
  draftingUserReplies = 0;
838
876
  draftingBlockedProposals = 0;
839
877
  draftingSeedInFlight = true; // our injected prompt also arrives as a user message — don't count it
840
- } catch {
878
+ } catch (err) {
841
879
  draftingTarget = null;
880
+ // v0.28.1 (E6): the seed send used to fail SILENTLY — the user pressed
881
+ // Enter on /goal and nothing happened. Now: loud, and stale handles get
882
+ // the honest restart guidance.
883
+ if (isStaleApiError(err)) {
884
+ extensionApiStale = true;
885
+ appendLedger(ctx.cwd, "extension_api_stale", { where: "startDrafting seed" });
886
+ ctx.ui.notify("glla: can't start the drafting interview — this session's extension handle is stale (pi session replacement). Restart pi and re-run the command.", "warning");
887
+ } else {
888
+ ctx.ui.notify(`glla: couldn't start the drafting interview (${err instanceof Error ? err.message : String(err)}) — try again.`, "warning");
889
+ }
842
890
  }
843
891
  }
844
892
 
@@ -875,6 +923,10 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
875
923
  // =================================================================
876
924
 
877
925
  async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): Promise<void> {
926
+ // v0.28.1 (S3): probe at the creation entry — no "created — starting now"
927
+ // lie in a doomed process. (The draft path's seed send has its own loud
928
+ // stale handling — E6.)
929
+ const staleEntry = warnIfStaleAtEntry(ctx, "/goal");
878
930
  let raw = args.trim();
879
931
  // Users naturally quote the objective ("/goal \"do X\""); strip one layer of
880
932
  // surrounding matching quotes so they don't leak into the goal text.
@@ -904,6 +956,13 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
904
956
  iterationCounter = 0;
905
957
  consecutiveErrorIterations = 0;
906
958
  consecutiveNoToolIterations = 0;
959
+ if (staleEntry) {
960
+ // v0.28.1 (S3): the goal is persisted — mark the interrupt so the next
961
+ // fresh session auto-resumes, and tell the truth instead of "starting now".
962
+ updateGoal({ interruptedAt: nowIso(), interruptedReason: "created in a stale session" }, ctx);
963
+ ctx.ui.notify(`Goal ${goal.id} created and safe in .pi-glla/ — this stale process can't send continuations. Restart pi and it auto-resumes.`, "warning");
964
+ return;
965
+ }
907
966
  ctx.ui.notify(`Goal ${goal.id} created — starting now. Auditor will verify on completion.`, "info");
908
967
  scheduleContinuation(ctx, true);
909
968
  }
@@ -944,6 +1003,12 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
944
1003
 
945
1004
  async function cmdResume(ctx: ExtensionContext): Promise<void> {
946
1005
  if (!state.goal || state.goal.status !== "paused") return;
1006
+ // v0.28.1 (S1/S3): resuming in a stale session used to flip status to
1007
+ // active, claim "Resumed goal", then re-pause on the stale send failure
1008
+ // (or zombie — S1). Now: persist the resume (the next fresh session
1009
+ // auto-resumes ACTIVE goals), mark the interrupt, tell the truth, and
1010
+ // skip the send that can never land.
1011
+ const staleEntry = warnIfStaleAtEntry(ctx, "/goal resume");
947
1012
  // v0.12.0: refresh the token cap from CURRENT settings on resume — goals
948
1013
  // snapshot the cap at creation, so a goal paused under an old default
949
1014
  // (e.g. 10M) would re-pause instantly even after the default changed.
@@ -951,7 +1016,8 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
951
1016
  const usage = state.goal.usage
952
1017
  ? { tokensUsed: state.goal.usage.tokensUsed, tokensLimit: freshLimit }
953
1018
  : undefined;
954
- updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(usage ? { usage } : {}) }, ctx);
1019
+ updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(staleEntry ? { interruptedAt: nowIso(), interruptedReason: "resumed in a stale session" } : {}), ...(usage ? { usage } : {}) }, ctx);
1020
+ if (staleEntry) return;
955
1021
  // v0.22.5: say what was resumed — with a non-empty list this also resumes
956
1022
  // the queue (the active goal IS the list's head item).
957
1023
  // v0.22.7: name WHAT was resumed — list items resume through /list.
@@ -1106,6 +1172,8 @@ async function bulkAddFromFile(ctx: ExtensionContext, abs: string): Promise<void
1106
1172
  }
1107
1173
 
1108
1174
  async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
1175
+ // v0.28.1 (S3): honest staleness warning; read-only subcommands still work.
1176
+ warnIfStaleAtEntry(ctx, "/list");
1109
1177
  const parts = args.trim().split(/\s+/);
1110
1178
  const sub = (parts[0] ?? "").toLowerCase();
1111
1179
  const rest = args.trim().slice(sub.length).trim();
@@ -2402,6 +2470,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2402
2470
  };
2403
2471
  }
2404
2472
  const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
2473
+ // v0.28.1 (S3): honest staleness warning before any Confirm attempt.
2474
+ warnIfStaleAtEntry(liveCtx, "goal drafting");
2405
2475
  // Multi-item list draft: one Confirm for the whole batch.
2406
2476
  if (p.items && p.items.length > 0) {
2407
2477
  // v0.23.7: show ALL items in full — the user approves the whole
@@ -2419,7 +2489,14 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2419
2489
  "Confirm list batch",
2420
2490
  `${p.items.length} items:\n${preview}${batchActivates ? "\n\n(List is empty — confirming ACTIVATES item 1 immediately as the active goal.)" : ""}`,
2421
2491
  );
2422
- } catch {
2492
+ } catch (err) {
2493
+ // v0.28.1 (T1): a stale confirm is NOT a rejection — nothing was
2494
+ // refused; the dialog simply can't render in a doomed process.
2495
+ if (isStaleApiError(err)) {
2496
+ extensionApiStale = true;
2497
+ appendLedger(liveCtx.cwd, "extension_api_stale", { where: "batch confirm" });
2498
+ return { content: [{ type: "text", text: "The Confirm dialog could not render: pi invalidated this session's extension handle (session replacement). This is NOT a rejection — do NOT refine or re-propose. Tell the user to restart pi, then re-run the drafting flow." }], details: {} };
2499
+ }
2423
2500
  batchConfirmed = false;
2424
2501
  }
2425
2502
  }
@@ -2460,7 +2537,13 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2460
2537
  } else {
2461
2538
  try {
2462
2539
  confirmed = await liveCtx.ui.confirm(isListDraft ? "Confirm list item" : "Confirm goal", `${p.objective.trim()}${contractBlock}${activationNote}`);
2463
- } catch {
2540
+ } catch (err) {
2541
+ // v0.28.1 (T1): a stale confirm is NOT "Draft rejected by the user".
2542
+ if (isStaleApiError(err)) {
2543
+ extensionApiStale = true;
2544
+ appendLedger(liveCtx.cwd, "extension_api_stale", { where: "draft confirm" });
2545
+ return { content: [{ type: "text", text: "The Confirm dialog could not render: pi invalidated this session's extension handle (session replacement). This is NOT a rejection — do NOT refine or re-propose. Tell the user to restart pi, then re-run the drafting flow." }], details: {} };
2546
+ }
2464
2547
  confirmed = false;
2465
2548
  }
2466
2549
  }
@@ -2893,173 +2976,241 @@ function resolveAuditorModel(ctx: ExtensionContext, ref?: string): { model: any;
2893
2976
  * Done/Esc exits. Rarely opened by design; scriptable /glla key=value remains
2894
2977
  * for tmux/headless.
2895
2978
  */
2979
+ /**
2980
+ * v0.28.0: open the /glla settings menu as a TUI table (top tabs row +
2981
+ * 4-column body: KEY | VALUE | SOURCE | DESCRIPTION). Loops until the user
2982
+ * exits (Esc / undefined from confirm or cancel) or until a handler returns.
2983
+ *
2984
+ * The dispatcher (handleSettingChoice, below) takes a stable id and calls the
2985
+ * per-key editor (input/select/confirm dialog) used by the pick. The prior
2986
+ * v0.27.0 dispatcher used `choice.startsWith(label)` strings; the new id-based
2987
+ * switch is contract-equal in behavior and unit-testable via
2988
+ * extensions/settings-menu.ts.
2989
+ */
2896
2990
  async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
2897
2991
  for (;;) {
2898
- const prov = settingsProvenance(ctx.cwd);
2899
- const show = (k: keyof Settings, fallback: string) => {
2900
- const p = prov[k];
2901
- const v = p.value === undefined ? fallback : String(p.value);
2902
- return `${v} [${p.source}]`;
2903
- };
2904
2992
  const settings = loadSettings(ctx.cwd);
2905
- // v0.27.0: every option on ONE screen, grouped into sections, each row
2906
- // carrying `label value [provenance] — what it does` so the menu is
2907
- // also the documentation. Header rows are no-ops when selected.
2908
- const rows = [
2909
- "── Keep-going ──",
2910
- `Auto-resume on load — ${show("autoResume", "default")} — on: resume on session load too · off: never · default: hold on load, resume on reload/fork`,
2911
- `Auto-accept drafts — ${show("autoAcceptDrafts", "(off)")} — on: goal/loop drafts activate without the Confirm dialog (unattended rigs)`,
2912
- `Aggressive mode — ${show("aggressiveMode", "(off)")} — flips DEFAULTS toward keep-going (autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs); explicit per-key settings still win`,
2913
- "── Auditor ──",
2914
- `Auditor model — ${show("auditorModel", "(pi session model)")} — provider/model override for the isolated auditor`,
2915
- `Auditor thinking — ${show("auditorThinkingLevel", "(session, floor high)")} — thinking level for the auditor session`,
2916
- `Audit cap — ${show("auditCap", "(5)")} — pause the goal after N consecutive disapprovals (0 = unlimited)`,
2917
- `Audit feedback chars — ${show("auditFeedbackChars", "(full report)")} — cap the executor-visible disapproval report (0 = full report)`,
2918
- `Quota retry minutes — ${show("quotaRetryMinutes", `(${DEFAULT_QUOTA_RETRY_MINUTES})`)} — auto-retry a quota-exhausted auditor after N minutes`,
2919
- "── Stall brakes ──",
2920
- `Wedge alert minutes — ${show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES})`)} — hung-command alert while the session is busy (0 = off)`,
2921
- `Stuck max interventions — ${show("stuckMaxInterventions", "(5)")} — consecutive stuck interventions before a loop stops`,
2922
- `Stall escalation refires — ${show("stallEscalationRefires", "(5)")} — heartbeat refires with no turn before the goal pauses / loop stops (0 = never)`,
2923
- `Stall short words — ${show("stallShortWords", `(${DEFAULT_STALL_SHORT_WORDS})`)} — turns with no tools AND fewer words than this count as a nudge`,
2924
- `Stall similarity threshold — ${show("stallSimilarityThreshold", `(${DEFAULT_STALL_SIM_THRESHOLD})`)} — no-tool turns whose text is > this similar to the prior turn count as a nudge (0–1)`,
2925
- "── Subagents ──",
2926
- `Subagent model strategy — ${show("subagentModelStrategy", "(inherit-parent)")} — inherit-parent shares your session model+quota; agent-default pins haiku for Explore`,
2927
- `Subagent Explore pin — ${settings.subagentModelOverrides?.Explore ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
2928
- `Subagent Plan pin — ${settings.subagentModelOverrides?.Plan ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
2929
- `Subagent general-purpose pin — ${settings.subagentModelOverrides?.["general-purpose"] ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
2930
- "── Other ──",
2931
- `Notify command — ${show("notifyCmd", "(off)")} — desktop push command; the event message is passed as $1`,
2932
- `Token limit per goal — ${show("tokenLimit", "(off)")} — per-goal token budget; pause when exceeded (0 = off)`,
2933
- `Reviewer config… — open the reviewer menu (post-completion follow-up enqueuer: mode, triggers, cascade, caps)`,
2934
- "Done",
2935
- ];
2936
- let choice: string | undefined;
2993
+ const prov = settingsProvenance(ctx.cwd);
2994
+ const rows = buildSettingsRows(settings, prov);
2995
+ const id = await promptSettingsMenu(ctx, rows);
2996
+ if (!id) return;
2937
2997
  try {
2938
- choice = await ctx.ui.select(
2939
- `pi-goal-list-loop-audit settings — global: ${globalSettingsPath()}`,
2940
- rows,
2941
- );
2998
+ await handleSettingChoice(id, ctx);
2942
2999
  } catch {
2943
3000
  return;
2944
3001
  }
2945
- if (!choice || choice === "Done") return;
2946
- if (choice.startsWith("──")) continue; // section header — no-op
2947
- try {
2948
- if (choice.startsWith("Auto-resume")) {
2949
- const v = await ctx.ui.select("Auto-resume goals/loops on session start", [
2950
- "default HOLD when a session is loaded (popup shows what waits); auto-resume on reload/fork so machinery never strands work",
2951
- "on auto-resume on EVERY session start (unattended rigs)",
2952
- "off never auto-resume; always wait for an explicit resume",
2953
- ]);
2954
- if (v) saveSettings("global", ctx.cwd, { autoResume: v.startsWith("on") ? true : v.startsWith("off") ? false : undefined });
2955
- } else if (choice.startsWith("Auto-accept drafts")) {
2956
- const v = await ctx.ui.select("Auto-accept goal/loop drafts", [
2957
- "off — the Confirm dialog gates every draft",
2958
- "on drafts activate immediately, no Confirm (unattended rigs)",
2959
- ]);
2960
- if (v) saveSettings("global", ctx.cwd, { autoAcceptDrafts: v.startsWith("on") ? true : undefined });
2961
- } else if (choice.startsWith("Aggressive mode")) {
2962
- const v = await ctx.ui.select("Aggressive mode (flips DEFAULTS toward keep-going explicit per-key settings still win)", [
2963
- "off current behavior: pause at the audit cap, wedge alerts on, manual resume",
2964
- "on autoResume, audit cap 10, stuck max 10, wedge alerts off, quota auto-retry, cap disapprovals become a TODO list and the goal KEEPS GOING",
2965
- ]);
2966
- if (v) {
2967
- saveSettings("global", ctx.cwd, { aggressiveMode: v.startsWith("on") });
2968
- ctx.ui.notify(`Aggressive mode ${v.startsWith("on") ? "ON — goals keep going past the audit cap; objections become TODOs" : "off"}.`, "info");
2969
- }
2970
- } else if (choice.startsWith("Auditor model")) {
2971
- const v = await ctx.ui.input("Auditor model override", "provider/model-id empty keeps the pi session model");
2972
- if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
2973
- } else if (choice.startsWith("Auditor thinking")) {
2974
- const v = await ctx.ui.select("Auditor thinking level", ["off", "minimal", "low", "medium", "high", "xhigh"]);
2975
- if (v) saveSettings("global", ctx.cwd, { auditorThinkingLevel: v as Settings["auditorThinkingLevel"] });
2976
- } else if (choice.startsWith("Audit cap")) {
2977
- const v = await ctx.ui.input("Consecutive auditor disapprovals before the goal pauses", "non-negative integer; 0 = unlimited, empty = default 5");
2978
- if (v !== undefined) {
2979
- const n = Number.parseInt(v.trim(), 10);
2980
- if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { auditCap: n });
2981
- else if (!v.trim()) saveSettings("global", ctx.cwd, { auditCap: undefined });
2982
- else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
2983
- }
2984
- } else if (choice.startsWith("Audit feedback")) {
2985
- const v = await ctx.ui.input("Auditor feedback returned to the executor (characters)", "non-negative integer cap; 0 or empty = full report (default)");
2986
- if (v !== undefined) {
2987
- const raw = v.trim();
2988
- const n = Number(raw);
2989
- if (/^\d+$/.test(raw) && Number.isSafeInteger(n)) saveSettings("global", ctx.cwd, { auditFeedbackChars: n });
2990
- else if (!v.trim()) saveSettings("global", ctx.cwd, { auditFeedbackChars: undefined });
2991
- else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
2992
- }
2993
- } else if (choice.startsWith("Quota retry minutes")) {
2994
- const v = await ctx.ui.input("Minutes before auto-retrying a quota-exhausted auditor", `positive integer; empty = default ${DEFAULT_QUOTA_RETRY_MINUTES}`);
2995
- if (v !== undefined) {
2996
- const n = Number.parseInt(v.trim(), 10);
2997
- if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { quotaRetryMinutes: n });
2998
- else if (!v.trim()) saveSettings("global", ctx.cwd, { quotaRetryMinutes: undefined });
2999
- else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
3000
- }
3001
- } else if (choice.startsWith("Wedge alert")) {
3002
- const v = await ctx.ui.input("Wedge alert threshold (minutes)", "non-negative integer; 0 = off, empty = default 30");
3003
- if (v !== undefined) {
3004
- const n = Number.parseInt(v.trim(), 10);
3005
- if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: n });
3006
- else if (!v.trim()) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: undefined });
3007
- else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3008
- }
3009
- } else if (choice.startsWith("Stuck max interventions")) {
3010
- const v = await ctx.ui.input("Consecutive stuck interventions before a loop stops", "positive integer; empty = default 5 (10 under aggressiveMode)");
3011
- if (v !== undefined) {
3012
- const n = Number.parseInt(v.trim(), 10);
3013
- if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { stuckMaxInterventions: n });
3014
- else if (!v.trim()) saveSettings("global", ctx.cwd, { stuckMaxInterventions: undefined });
3015
- else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
3016
- }
3017
- } else if (choice.startsWith("Stall escalation")) {
3018
- const v = await ctx.ui.input("Heartbeat refires without a turn before the goal pauses / loop stops", "non-negative integer; 0 = never escalate, empty = default 5");
3019
- if (v !== undefined) {
3020
- const n = Number.parseInt(v.trim(), 10);
3021
- if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallEscalationRefires: n });
3022
- else if (!v.trim()) saveSettings("global", ctx.cwd, { stallEscalationRefires: undefined });
3023
- else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3024
- }
3025
- } else if (choice.startsWith("Subagent model strategy")) {
3026
- const v = await ctx.ui.select("Subagent model (pi-subagents default agents)", [
3027
- "inherit-parent — subagents share your session model + its quota pool (fixes separate-provider 403s; search agents may run on a pricier model)",
3028
- "agent-default — upstream behavior: Explore pins claude-haiku-4-5 (cheap search, but a SEPARATE provider quota from your session)",
3029
- ]);
3030
- if (v) {
3031
- const strategy: SubagentModelStrategy = v.startsWith("agent-default") ? "agent-default" : "inherit-parent";
3032
- saveSettings("global", ctx.cwd, { subagentModelStrategy: strategy });
3033
- ctx.ui.notify("Subagent model strategy saved — applies to NEW pi sessions (pi-subagents registers agents at session start).", "info");
3034
- }
3035
- } else if (/^Subagent (Explore|Plan|general-purpose) pin/.test(choice)) {
3036
- const agentType = choice.match(/^Subagent (Explore|Plan|general-purpose) pin/)![1]!;
3037
- 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");
3038
- if (v !== undefined) {
3039
- const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
3040
- const next = { ...current };
3041
- if (v.trim()) next[agentType] = v.trim();
3042
- else delete next[agentType];
3043
- saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
3044
- ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
3045
- }
3046
- } else if (choice.startsWith("Notify command")) {
3047
- const v = await ctx.ui.input("Notify command — the event message is passed as $1", "e.g. a desktop-notification or push command; empty = off");
3048
- if (v !== undefined) saveSettings("global", ctx.cwd, { notifyCmd: v.trim() || undefined });
3049
- } else if (choice.startsWith("Token limit")) {
3050
- const v = await ctx.ui.input("Per-goal token budget", "non-negative integer; 0 or empty = off (no cap)");
3051
- if (v !== undefined) {
3052
- const n = Number.parseInt(v.trim(), 10);
3053
- if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { tokenLimit: n });
3054
- else if (!v.trim()) saveSettings("global", ctx.cwd, { tokenLimit: undefined });
3055
- else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3056
- }
3057
- } else if (choice.startsWith("Reviewer config")) {
3058
- await cmdReviewerSettings(ctx);
3002
+ }
3003
+ }
3004
+
3005
+ /**
3006
+ * Show the table-rendered settings menu and return the user's pick id (or
3007
+ * undefined for cancel). Wraps `ctx.ui.custom` so openSettingsUI stays a thin
3008
+ * loop. Falls back to a select-based legacy menu when the runtime has no
3009
+ * `ctx.ui.custom` (the `ctx.hasUI` guard already protects this path elsewhere;
3010
+ * this is a second-line defense for headless custom-only shards).
3011
+ */
3012
+ async function promptSettingsMenu(
3013
+ ctx: ExtensionContext,
3014
+ rows: SettingsRow[],
3015
+ ): Promise<string | undefined> {
3016
+ const title = `pi-goal-list-loop-audit settings — global: ${globalSettingsPath()}`;
3017
+ if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
3018
+ // Headless / no custom shard — fall back to the legacy flat-row select
3019
+ // for any environment that lacks the new primitive. This is rare and
3020
+ // effectively an emergency hatch; the new UI is the supported path.
3021
+ const flat = rows.map((r) => `${r.label} ${r.valueText} [${r.sourceText.replace(/^\[|\]$/g, "")}] ${r.description}`);
3022
+ flat.push("Done");
3023
+ const v = await ctx.ui.select(title, flat);
3024
+ if (!v || v === "Done") return undefined;
3025
+ return rows.find((r) => v.startsWith(r.label))?.id;
3026
+ }
3027
+ return await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
3028
+ return new SettingsMenuComponent({ rows, title }, () => tui.requestRender(), theme, keybindings, done);
3029
+ });
3030
+ }
3031
+
3032
+ /**
3033
+ * v0.28.0: per-key dispatch for the settings menu. The id comes from
3034
+ * `buildSettingsRows` (e.g. "autoResume", "auditorModel", "subagentModelOverrides.Explore").
3035
+ * Same handlers as v0.27.0's if/else chain only the trigger changed from
3036
+ * `startsWith(label)` strings to stable ids.
3037
+ */
3038
+ async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
3039
+ switch (id) {
3040
+ case "autoResume": {
3041
+ const v = await ctx.ui.select("Auto-resume goals/loops on session start", [
3042
+ "default HOLD when a session is loaded (popup shows what waits); auto-resume on reload/fork so machinery never strands work",
3043
+ "on — auto-resume on EVERY session start (unattended rigs)",
3044
+ "off never auto-resume; always wait for an explicit resume",
3045
+ ]);
3046
+ if (v) saveSettings("global", ctx.cwd, { autoResume: v.startsWith("on") ? true : v.startsWith("off") ? false : undefined });
3047
+ return;
3048
+ }
3049
+ case "autoAcceptDrafts": {
3050
+ const v = await ctx.ui.select("Auto-accept goal/loop drafts", [
3051
+ "off the Confirm dialog gates every draft",
3052
+ "on — drafts activate immediately, no Confirm (unattended rigs)",
3053
+ ]);
3054
+ if (v) saveSettings("global", ctx.cwd, { autoAcceptDrafts: v.startsWith("on") ? true : undefined });
3055
+ return;
3056
+ }
3057
+ case "aggressiveMode": {
3058
+ const v = await ctx.ui.select("Aggressive mode (flips DEFAULTS toward keep-going — explicit per-key settings still win)", [
3059
+ "off current behavior: pause at the audit cap, wedge alerts on, manual resume",
3060
+ "on autoResume, audit cap 10, stuck max 10, wedge alerts off, quota auto-retry, cap disapprovals become a TODO list and the goal KEEPS GOING",
3061
+ ]);
3062
+ if (v) {
3063
+ saveSettings("global", ctx.cwd, { aggressiveMode: v.startsWith("on") });
3064
+ ctx.ui.notify(`Aggressive mode ${v.startsWith("on") ? "ON — goals keep going past the audit cap; objections become TODOs" : "off"}.`, "info");
3059
3065
  }
3060
- } catch {
3061
3066
  return;
3062
3067
  }
3068
+ case "auditorModel": {
3069
+ const v = await ctx.ui.input("Auditor model override", "provider/model-id — empty keeps the pi session model");
3070
+ if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
3071
+ return;
3072
+ }
3073
+ case "auditorThinkingLevel": {
3074
+ const v = await ctx.ui.select("Auditor thinking level", ["off", "minimal", "low", "medium", "high", "xhigh"]);
3075
+ if (v) saveSettings("global", ctx.cwd, { auditorThinkingLevel: v as Settings["auditorThinkingLevel"] });
3076
+ return;
3077
+ }
3078
+ case "auditCap": {
3079
+ const v = await ctx.ui.input("Consecutive auditor disapprovals before the goal pauses", "non-negative integer; 0 = unlimited, empty = default 5");
3080
+ if (v !== undefined) {
3081
+ const n = Number.parseInt(v.trim(), 10);
3082
+ if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { auditCap: n });
3083
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { auditCap: undefined });
3084
+ else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3085
+ }
3086
+ return;
3087
+ }
3088
+ case "auditFeedbackChars": {
3089
+ const v = await ctx.ui.input("Auditor feedback returned to the executor (characters)", "non-negative integer cap; 0 or empty = full report (default)");
3090
+ if (v !== undefined) {
3091
+ const raw = v.trim();
3092
+ const n = Number(raw);
3093
+ if (/^\d+$/.test(raw) && Number.isSafeInteger(n)) saveSettings("global", ctx.cwd, { auditFeedbackChars: n });
3094
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { auditFeedbackChars: undefined });
3095
+ else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3096
+ }
3097
+ return;
3098
+ }
3099
+ case "quotaRetryMinutes": {
3100
+ const v = await ctx.ui.input("Minutes before auto-retrying a quota-exhausted auditor", `positive integer; empty = default ${DEFAULT_QUOTA_RETRY_MINUTES}`);
3101
+ if (v !== undefined) {
3102
+ const n = Number.parseInt(v.trim(), 10);
3103
+ if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { quotaRetryMinutes: n });
3104
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { quotaRetryMinutes: undefined });
3105
+ else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
3106
+ }
3107
+ return;
3108
+ }
3109
+ case "wedgeAlertMinutes": {
3110
+ const v = await ctx.ui.input("Wedge alert threshold (minutes)", "non-negative integer; 0 = off, empty = default 30");
3111
+ if (v !== undefined) {
3112
+ const n = Number.parseInt(v.trim(), 10);
3113
+ if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: n });
3114
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: undefined });
3115
+ else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3116
+ }
3117
+ return;
3118
+ }
3119
+ case "stuckMaxInterventions": {
3120
+ const v = await ctx.ui.input("Consecutive stuck interventions before a loop stops", "positive integer; empty = default 5 (10 under aggressiveMode)");
3121
+ if (v !== undefined) {
3122
+ const n = Number.parseInt(v.trim(), 10);
3123
+ if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { stuckMaxInterventions: n });
3124
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { stuckMaxInterventions: undefined });
3125
+ else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
3126
+ }
3127
+ return;
3128
+ }
3129
+ case "stallEscalationRefires": {
3130
+ const v = await ctx.ui.input("Heartbeat refires without a turn before the goal pauses / loop stops", "non-negative integer; 0 = never escalate, empty = default 5");
3131
+ if (v !== undefined) {
3132
+ const n = Number.parseInt(v.trim(), 10);
3133
+ if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallEscalationRefires: n });
3134
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { stallEscalationRefires: undefined });
3135
+ else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3136
+ }
3137
+ return;
3138
+ }
3139
+ case "stallShortWords": {
3140
+ const v = await ctx.ui.input("Stall short words threshold", "non-negative integer; 0 = off, empty = default 15");
3141
+ if (v !== undefined) {
3142
+ const n = Number.parseInt(v.trim(), 10);
3143
+ if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallShortWords: n });
3144
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { stallShortWords: undefined });
3145
+ else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3146
+ }
3147
+ return;
3148
+ }
3149
+ case "stallSimilarityThreshold": {
3150
+ const v = await ctx.ui.input("Stall similarity threshold (0..1)", "decimal between 0 and 1; empty = default 0.6");
3151
+ if (v !== undefined) {
3152
+ const n = Number(v.trim());
3153
+ if (Number.isFinite(n) && n >= 0 && n <= 1) saveSettings("global", ctx.cwd, { stallSimilarityThreshold: n });
3154
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { stallSimilarityThreshold: undefined });
3155
+ else ctx.ui.notify(`Not a decimal between 0 and 1: ${v}`, "warning");
3156
+ }
3157
+ return;
3158
+ }
3159
+ case "subagentModelStrategy": {
3160
+ const v = await ctx.ui.select("Subagent model (pi-subagents default agents)", [
3161
+ "inherit-parent — share your session model + quota pool (recommended)",
3162
+ "agent-default — use the upstream pi-subagents default agents",
3163
+ ]);
3164
+ if (v) {
3165
+ const strategy: SubagentModelStrategy = v.startsWith("agent-default") ? "agent-default" : "inherit-parent";
3166
+ saveSettings("global", ctx.cwd, { subagentModelStrategy: strategy });
3167
+ ctx.ui.notify("Subagent model strategy saved — applies to NEW pi sessions (pi-subagents registers agents at session start).", "info");
3168
+ }
3169
+ return;
3170
+ }
3171
+ case "subagentModelOverrides.Explore":
3172
+ case "subagentModelOverrides.Plan":
3173
+ case "subagentModelOverrides.general-purpose": {
3174
+ const agentType = id.slice("subagentModelOverrides.".length);
3175
+ 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");
3176
+ if (v !== undefined) {
3177
+ const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
3178
+ const next = { ...current };
3179
+ if (v.trim()) next[agentType] = v.trim();
3180
+ else delete next[agentType];
3181
+ saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
3182
+ ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
3183
+ }
3184
+ return;
3185
+ }
3186
+ case "subagentResolved":
3187
+ // Read-only (effective resolution row) — no editor; row just shows the
3188
+ // current effective subagent models. Treat as no-op.
3189
+ return;
3190
+ case "notifyCmd": {
3191
+ const v = await ctx.ui.input("Notify command — the event message is passed as $1", "e.g. a desktop-notification or push command; empty = off");
3192
+ if (v !== undefined) saveSettings("global", ctx.cwd, { notifyCmd: v.trim() || undefined });
3193
+ return;
3194
+ }
3195
+ case "tokenLimit": {
3196
+ const v = await ctx.ui.input("Per-goal token budget", "non-negative integer; 0 or empty = off (no cap)");
3197
+ if (v !== undefined) {
3198
+ const n = Number.parseInt(v.trim(), 10);
3199
+ if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { tokenLimit: n });
3200
+ else if (!v.trim()) saveSettings("global", ctx.cwd, { tokenLimit: undefined });
3201
+ else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
3202
+ }
3203
+ return;
3204
+ }
3205
+ case "postaudit":
3206
+ await cmdReviewerSettings(ctx);
3207
+ return;
3208
+ default:
3209
+ // Unknown id — keep the menu looping. Surface a soft warning so the
3210
+ // user knows a row existed but had no handler (better than silently
3211
+ // swallowing it).
3212
+ ctx.ui.notify(`/glla: unknown setting id "${id}" — please report this.`, "warning");
3213
+ return;
3063
3214
  }
3064
3215
  }
3065
3216
 
@@ -3959,8 +4110,12 @@ export default function (pi: ExtensionAPI): void {
3959
4110
  }
3960
4111
  } else if (state.goal && state.goal.status === "active" && state.goal.autoContinue) {
3961
4112
  if (autoResume) {
4113
+ // v0.28.1 (S2): clear the stale-handle interrupt marker — this IS
4114
+ // the auto-resume the marker promised.
4115
+ const wasInterrupted = !!state.goal.interruptedAt;
4116
+ if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
3962
4117
  ctx.ui.notify(
3963
- `Resuming ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
4118
+ `Resuming ${state.goal.policy === "list" ? "list item" : "goal"} [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}${wasInterrupted ? " — auto-resumed after the stale-handle interrupt" : ""}`,
3964
4119
  "info",
3965
4120
  );
3966
4121
  scheduleContinuation(ctx, true);
@@ -0,0 +1,571 @@
1
+ // pi-goal-list-loop-audit — v0.28.0
2
+ // extensions/settings-menu.ts
3
+ //
4
+ // The /glla settings menu as a real TUI table (v0.28.0).
5
+ //
6
+ // Pre-0.28.0 used `ctx.ui.select` with flat single-line rows; v0.28.0
7
+ // replaces it with a `ctx.ui.custom` Container/Text layout featuring:
8
+ // • a top TABS row listing all 5 sections (left/right to switch sections)
9
+ // • a 4-column table for the active section (KEY | VALUE | SOURCE | DESCRIPTION)
10
+ // • up/down navigation scoped to the active section's rows
11
+ // • Enter → emit the selected row's id (caller dispatches handler)
12
+ // • Esc / Ctrl+C → emit undefined (caller exits)
13
+ //
14
+ // Sections (5 total) map to the pre-0.28.0 menu groupings:
15
+ // keep-going | auditor | stall-brakes | subagents | other
16
+ //
17
+ // Extracted into its own module so tests can import `buildSettingsRows` directly
18
+ // (mirrors how `readState` lives in goal-loop-core.ts) and so the renderer is
19
+ // unit-testable via synthetic handleInput calls (no live TUI needed).
20
+ //
21
+ // The pre-v0.28.0 headless fallback (`/glla` with no args and no UI) keeps its
22
+ // existing text rendering — that's still the right shape for tmux/cron. Only
23
+ // the TUI menu becomes a table.
24
+
25
+ import {
26
+ type Component,
27
+ truncateToWidth,
28
+ visibleWidth,
29
+ } from "@earendil-works/pi-tui";
30
+
31
+ import {
32
+ DEFAULT_AUDIT_FEEDBACK_CHARS,
33
+ DEFAULT_QUOTA_RETRY_MINUTES,
34
+ DEFAULT_STALL_ESCALATION_REFIRES,
35
+ } from "./goal-loop-core.ts";
36
+ import {
37
+ DEFAULT_STALL_SIM_THRESHOLD,
38
+ DEFAULT_STALL_SHORT_WORDS,
39
+ WEDGE_ALERT_DEFAULT_MINUTES,
40
+ } from "./goal-loop-backoff.ts";
41
+ import type { Settings } from "./goal-settings.ts";
42
+ import { resolveEffectiveSubagentModel } from "./goal-loop-subagents.ts";
43
+
44
+ // =================================================================
45
+ // Pure row builder (testable + reusable from the headless fallback)
46
+ // =================================================================
47
+
48
+ export type SettingsSectionId =
49
+ | "keep-going"
50
+ | "auditor"
51
+ | "stall-brakes"
52
+ | "subagents"
53
+ | "other";
54
+
55
+ export const SETTINGS_SECTIONS: readonly { id: SettingsSectionId; label: string }[] = [
56
+ { id: "keep-going", label: "Keep-going" },
57
+ { id: "auditor", label: "Auditor" },
58
+ { id: "stall-brakes", label: "Stall brakes" },
59
+ { id: "subagents", label: "Subagents" },
60
+ { id: "other", label: "Other" },
61
+ ];
62
+
63
+ /** One menu row. `id` is the stable dispatch key (caller switch(id) → handler). */
64
+ export interface SettingsRow {
65
+ /** Stable dispatch key — used both as the table id and as the switch(id) case. */
66
+ id: string;
67
+ /** Which section this row belongs to. */
68
+ section: SettingsSectionId;
69
+ /** KEY column — the setting name (left-aligned, padded to keyW). */
70
+ label: string;
71
+ /** VALUE column — current effective value, e.g. `true` / `(off)` / `60`. */
72
+ valueText: string;
73
+ /** SOURCE column — provenance tag, one of `[project]` / `[global]` / `[default]`. */
74
+ sourceText: string;
75
+ /** DESCRIPTION column — one-line explanation; truncated with ellipsis when narrow. */
76
+ description: string;
77
+ }
78
+
79
+ export type ProvenanceSource = "project" | "global" | "default";
80
+
81
+ export interface MenuProvenance {
82
+ value: unknown;
83
+ source: ProvenanceSource;
84
+ }
85
+
86
+ /** Defaults surfaced in the menu when the user has not set a value. */
87
+ export interface MenuDefaults {
88
+ auditCap: number;
89
+ stuckMaxInterventions: number;
90
+ }
91
+
92
+ export const DEFAULT_MENU_DEFAULTS: MenuDefaults = {
93
+ auditCap: 5,
94
+ stuckMaxInterventions: 5,
95
+ };
96
+
97
+ /** Subagent model provenance context needed to render the subagent pins column. */
98
+ export interface MenuSubagentContext {
99
+ /** Active session model id (provider/model) — used by inherit-parent resolution. */
100
+ sessionModel?: string;
101
+ }
102
+
103
+ /**
104
+ * Build the full ordered list of menu rows for every section.
105
+ * Pure: no I/O, no extension context; the renderer composes sections onto rows.
106
+ */
107
+ export function buildSettingsRows(
108
+ settings: Settings,
109
+ prov: Partial<Record<keyof Settings, MenuProvenance>>,
110
+ subagent: MenuSubagentContext = {},
111
+ defaults: MenuDefaults = DEFAULT_MENU_DEFAULTS,
112
+ ): SettingsRow[] {
113
+ const provFor = (k: keyof Settings): MenuProvenance =>
114
+ prov[k] ?? { value: undefined, source: "default" };
115
+ const show = (k: keyof Settings, fallback: string): string => {
116
+ const p = provFor(k);
117
+ return p.value === undefined ? fallback : String(p.value);
118
+ };
119
+ const src = (k: keyof Settings): string => `[${provFor(k).source}]`;
120
+
121
+ const rows: SettingsRow[] = [];
122
+
123
+ // ── Keep-going ──
124
+ rows.push(
125
+ {
126
+ id: "autoResume",
127
+ section: "keep-going",
128
+ label: "Auto-resume on load",
129
+ valueText: show("autoResume", "default"),
130
+ sourceText: src("autoResume"),
131
+ description:
132
+ "on: resume on session load too · off: never · default: hold on load, resume on reload/fork",
133
+ },
134
+ {
135
+ id: "autoAcceptDrafts",
136
+ section: "keep-going",
137
+ label: "Auto-accept drafts",
138
+ valueText: show("autoAcceptDrafts", "(off)"),
139
+ sourceText: src("autoAcceptDrafts"),
140
+ description: "on: goal/loop drafts activate without the Confirm dialog (unattended rigs)",
141
+ },
142
+ {
143
+ id: "aggressiveMode",
144
+ section: "keep-going",
145
+ label: "Aggressive mode",
146
+ valueText: show("aggressiveMode", "(off)"),
147
+ sourceText: src("aggressiveMode"),
148
+ description:
149
+ "flips DEFAULTS toward keep-going (autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs); explicit per-key settings still win",
150
+ },
151
+ );
152
+
153
+ // ── Auditor ──
154
+ rows.push(
155
+ {
156
+ id: "auditorModel",
157
+ section: "auditor",
158
+ label: "Auditor model",
159
+ valueText: show("auditorModel", "(pi session model)"),
160
+ sourceText: src("auditorModel"),
161
+ description: "provider/model override for the isolated auditor",
162
+ },
163
+ {
164
+ id: "auditorThinkingLevel",
165
+ section: "auditor",
166
+ label: "Auditor thinking",
167
+ valueText: show("auditorThinkingLevel", "(session, floor high)"),
168
+ sourceText: src("auditorThinkingLevel"),
169
+ description: "thinking level for the auditor session",
170
+ },
171
+ {
172
+ id: "auditCap",
173
+ section: "auditor",
174
+ label: "Audit cap",
175
+ valueText: show("auditCap", `(${defaults.auditCap})`),
176
+ sourceText: src("auditCap"),
177
+ description: "pause the goal after N consecutive disapprovals (0 = unlimited)",
178
+ },
179
+ {
180
+ id: "auditFeedbackChars",
181
+ section: "auditor",
182
+ label: "Audit feedback chars",
183
+ valueText: show(
184
+ "auditFeedbackChars",
185
+ DEFAULT_AUDIT_FEEDBACK_CHARS === 0 ? "(full report)" : `(${DEFAULT_AUDIT_FEEDBACK_CHARS})`,
186
+ ),
187
+ sourceText: src("auditFeedbackChars"),
188
+ description: "cap the executor-visible disapproval report (0 = full report)",
189
+ },
190
+ {
191
+ id: "quotaRetryMinutes",
192
+ section: "auditor",
193
+ label: "Quota retry minutes",
194
+ valueText: show("quotaRetryMinutes", `(${DEFAULT_QUOTA_RETRY_MINUTES})`),
195
+ sourceText: src("quotaRetryMinutes"),
196
+ description: "auto-retry a quota-exhausted auditor after N minutes",
197
+ },
198
+ );
199
+
200
+ // ── Stall brakes ──
201
+ rows.push(
202
+ {
203
+ id: "wedgeAlertMinutes",
204
+ section: "stall-brakes",
205
+ label: "Wedge alert minutes",
206
+ valueText: show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES})`),
207
+ sourceText: src("wedgeAlertMinutes"),
208
+ description: "hung-command alert while the session is busy (0 = off)",
209
+ },
210
+ {
211
+ id: "stuckMaxInterventions",
212
+ section: "stall-brakes",
213
+ label: "Stuck max interventions",
214
+ valueText: show("stuckMaxInterventions", `(${defaults.stuckMaxInterventions})`),
215
+ sourceText: src("stuckMaxInterventions"),
216
+ description: "consecutive stuck interventions before a loop stops",
217
+ },
218
+ {
219
+ id: "stallEscalationRefires",
220
+ section: "stall-brakes",
221
+ label: "Stall escalation refires",
222
+ valueText: show("stallEscalationRefires", `(${DEFAULT_STALL_ESCALATION_REFIRES})`),
223
+ sourceText: src("stallEscalationRefires"),
224
+ description:
225
+ "heartbeat refires with no turn before the goal pauses / loop stops (0 = never)",
226
+ },
227
+ {
228
+ id: "stallShortWords",
229
+ section: "stall-brakes",
230
+ label: "Stall short words",
231
+ valueText: show("stallShortWords", `(${DEFAULT_STALL_SHORT_WORDS})`),
232
+ sourceText: src("stallShortWords"),
233
+ description: "turns with no tools AND fewer words than this count as a nudge",
234
+ },
235
+ {
236
+ id: "stallSimilarityThreshold",
237
+ section: "stall-brakes",
238
+ label: "Stall similarity threshold",
239
+ valueText: show("stallSimilarityThreshold", `(${DEFAULT_STALL_SIM_THRESHOLD})`),
240
+ sourceText: src("stallSimilarityThreshold"),
241
+ description:
242
+ "no-tool turns whose text is > this similar to the prior turn count as a nudge (0–1)",
243
+ },
244
+ );
245
+
246
+ // ── Subagents ──
247
+ rows.push(
248
+ {
249
+ id: "subagentModelStrategy",
250
+ section: "subagents",
251
+ label: "Subagent model strategy",
252
+ valueText: show("subagentModelStrategy", "(inherit-parent)"),
253
+ sourceText: src("subagentModelStrategy"),
254
+ description:
255
+ "inherit-parent shares your session model + quota pool; agent-default uses the upstream pi-subagents default agents",
256
+ },
257
+ {
258
+ id: "subagentModelOverrides.Explore",
259
+ section: "subagents",
260
+ label: "Subagent Explore pin",
261
+ valueText: settings.subagentModelOverrides?.Explore ?? "(follows strategy)",
262
+ sourceText:
263
+ settings.subagentModelOverrides?.Explore !== undefined
264
+ ? src("subagentModelOverrides")
265
+ : "[default]",
266
+ description: "provider/model pin; always wins over strategy",
267
+ },
268
+ {
269
+ id: "subagentModelOverrides.Plan",
270
+ section: "subagents",
271
+ label: "Subagent Plan pin",
272
+ valueText: settings.subagentModelOverrides?.Plan ?? "(follows strategy)",
273
+ sourceText:
274
+ settings.subagentModelOverrides?.Plan !== undefined
275
+ ? src("subagentModelOverrides")
276
+ : "[default]",
277
+ description: "provider/model pin; always wins over strategy",
278
+ },
279
+ {
280
+ id: "subagentModelOverrides.general-purpose",
281
+ section: "subagents",
282
+ label: "Subagent general-purpose pin",
283
+ valueText: settings.subagentModelOverrides?.["general-purpose"] ?? "(follows strategy)",
284
+ sourceText:
285
+ settings.subagentModelOverrides?.["general-purpose"] !== undefined
286
+ ? src("subagentModelOverrides")
287
+ : "[default]",
288
+ description: "provider/model pin; always wins over strategy",
289
+ },
290
+ {
291
+ id: "subagentResolved",
292
+ section: "subagents",
293
+ label: "Effective resolution",
294
+ valueText: [
295
+ resolveEffectiveSubagentModel("Explore", settings, subagent.sessionModel),
296
+ resolveEffectiveSubagentModel("Plan", settings, subagent.sessionModel),
297
+ resolveEffectiveSubagentModel("general-purpose", settings, subagent.sessionModel),
298
+ ].join(" · "),
299
+ sourceText: "[runtime]",
300
+ description: "effective Explore / Plan / general-purpose model given current settings",
301
+ },
302
+ );
303
+
304
+ // ── Other ──
305
+ rows.push(
306
+ {
307
+ id: "notifyCmd",
308
+ section: "other",
309
+ label: "Notify command",
310
+ valueText: show("notifyCmd", "(off)"),
311
+ sourceText: src("notifyCmd"),
312
+ description: "desktop push command; the event message is passed as $1",
313
+ },
314
+ {
315
+ id: "tokenLimit",
316
+ section: "other",
317
+ label: "Token limit per goal",
318
+ valueText: show("tokenLimit", "(off)"),
319
+ sourceText: src("tokenLimit"),
320
+ description: "per-goal token budget; pause when exceeded (0 = off)",
321
+ },
322
+ {
323
+ id: "postaudit",
324
+ section: "other",
325
+ label: "Postaudit config…",
326
+ valueText: "open sub-menu",
327
+ sourceText: "[—]",
328
+ description:
329
+ "post-completion follow-up enqueuer: mode, triggers, cascade, caps (postaudit / reviewer)",
330
+ },
331
+ );
332
+
333
+ return rows;
334
+ }
335
+
336
+ // =================================================================
337
+ // TUI table component
338
+ // =================================================================
339
+
340
+ /** Padding + gutter between columns. */
341
+ const COL_GUTTER = 2;
342
+
343
+ /** Maximum width for each fixed column before truncation kicks in. */
344
+ const MAX_KEY_W = 32;
345
+ const MAX_VALUE_W = 24;
346
+ const MAX_SOURCE_W = 10;
347
+ const MIN_DESC_W = 12;
348
+
349
+ /** A minimal subset of pi-tui's Theme interface used by the renderer. */
350
+ export interface SettingsMenuTheme {
351
+ fg(color: "accent" | "muted" | "dim" | "warning" | "success", text: string): string;
352
+ bold(text: string): string;
353
+ }
354
+
355
+ /** Structural type for the KeybindingsManager — avoids pulling in the
356
+ * full class so callers can supply any compatible implementation.
357
+ * (Top-level and nested pi-tui ship separate KeybindingsManager classes
358
+ * with private fields; structural typing sidesteps the cross-package type
359
+ * incompatibility entirely.) */
360
+ export interface KeybindingsManagerLike {
361
+ matches(data: string, key: string): boolean;
362
+ }
363
+
364
+ export interface SettingsMenuFactoryDeps {
365
+ rows: SettingsRow[];
366
+ title: string;
367
+ }
368
+
369
+ /**
370
+ * TUI Component for the /glla settings menu. Renders a top tabs row + a
371
+ * 4-column table for the active section. The host (extensions/loops/goal.ts)
372
+ * constructs it via `ctx.ui.custom(...)` and dispatches the returned id
373
+ * with `switch (id)` instead of the pre-v0.28.0 `startsWith` strings.
374
+ */
375
+ export class SettingsMenuComponent implements Component {
376
+ private readonly rows: SettingsRow[];
377
+ private readonly title: string;
378
+ private readonly requestRender: () => void;
379
+ private readonly theme: SettingsMenuTheme;
380
+ private readonly keybindings: KeybindingsManagerLike;
381
+ private readonly done: (id: string | undefined) => void;
382
+
383
+ private activeSectionIdx: number;
384
+ private selectedIdx: number;
385
+ private cachedWidth?: number;
386
+ private cachedLines?: string[];
387
+
388
+ constructor(
389
+ deps: SettingsMenuFactoryDeps,
390
+ requestRender: () => void,
391
+ theme: SettingsMenuTheme,
392
+ keybindings: KeybindingsManagerLike,
393
+ done: (id: string | undefined) => void,
394
+ ) {
395
+ this.rows = deps.rows;
396
+ this.title = deps.title;
397
+ this.requestRender = requestRender;
398
+ this.theme = theme;
399
+ this.keybindings = keybindings;
400
+ this.done = done;
401
+ this.activeSectionIdx = 0;
402
+ this.selectedIdx = 0;
403
+ }
404
+
405
+ /** Index into `SETTINGS_SECTIONS`. Exposed for tests. */
406
+ getActiveSectionIdx(): number {
407
+ return this.activeSectionIdx;
408
+ }
409
+
410
+ /** Index into the active-section's visible rows. Exposed for tests. */
411
+ getSelectedIdx(): number {
412
+ return this.selectedIdx;
413
+ }
414
+
415
+ /** Rows in the active section. Exposed for tests. */
416
+ visibleRows(): SettingsRow[] {
417
+ return this.rows.filter(
418
+ (r) => r.section === SETTINGS_SECTIONS[this.activeSectionIdx]!.id,
419
+ );
420
+ }
421
+
422
+ private refresh(): void {
423
+ this.cachedWidth = undefined;
424
+ this.cachedLines = undefined;
425
+ this.requestRender();
426
+ }
427
+
428
+ /** Move within the active section, wrapping at ends. Exposed for tests. */
429
+ move(delta: number): void {
430
+ const vs = this.visibleRows();
431
+ if (vs.length === 0) return;
432
+ const n = vs.length;
433
+ this.selectedIdx = ((this.selectedIdx + delta) % n + n) % n;
434
+ this.refresh();
435
+ }
436
+
437
+ /** Switch section. -1 = left, +1 = right; wraps at ends. Exposed for tests. */
438
+ switchSection(delta: number): void {
439
+ const n = SETTINGS_SECTIONS.length;
440
+ this.activeSectionIdx = ((this.activeSectionIdx + delta) % n + n) % n;
441
+ this.selectedIdx = 0;
442
+ this.refresh();
443
+ }
444
+
445
+ private resolveSelectedId(): string | undefined {
446
+ return this.visibleRows()[this.selectedIdx]?.id;
447
+ }
448
+
449
+ private widths(width: number) {
450
+ let keyW = visibleWidth(this.theme.bold("KEY"));
451
+ let valueW = visibleWidth(this.theme.bold("VALUE"));
452
+ let sourceW = visibleWidth(this.theme.bold("SOURCE"));
453
+ for (const r of this.visibleRows()) {
454
+ if (visibleWidth(r.label) > keyW) keyW = visibleWidth(r.label);
455
+ if (visibleWidth(r.valueText) > valueW) valueW = visibleWidth(r.valueText);
456
+ if (visibleWidth(r.sourceText) > sourceW) sourceW = visibleWidth(r.sourceText);
457
+ }
458
+ keyW = Math.min(keyW, MAX_KEY_W);
459
+ valueW = Math.min(valueW, MAX_VALUE_W);
460
+ sourceW = Math.min(sourceW, MAX_SOURCE_W);
461
+ const descW = Math.max(MIN_DESC_W, width - keyW - valueW - sourceW - 3 * COL_GUTTER);
462
+ return { keyW, valueW, sourceW, descW };
463
+ }
464
+
465
+ private padEnd(text: string, width: number): string {
466
+ const w = visibleWidth(text);
467
+ return w >= width ? text : text + " ".repeat(width - w);
468
+ }
469
+
470
+ private renderBody(width: number): string[] {
471
+ const { keyW, valueW, sourceW, descW } = this.widths(width);
472
+ const gutter = " ".repeat(COL_GUTTER);
473
+
474
+ const lines: string[] = [];
475
+
476
+ lines.push(this.theme.fg("accent", this.theme.bold(this.title)));
477
+
478
+ lines.push(
479
+ SETTINGS_SECTIONS.map((s, i) =>
480
+ i === this.activeSectionIdx
481
+ ? this.theme.fg("accent", `[${s.label}]`)
482
+ : this.theme.fg("dim", s.label),
483
+ ).join(" "),
484
+ );
485
+
486
+ lines.push(
487
+ [
488
+ this.padEnd(this.theme.bold("KEY"), keyW),
489
+ this.padEnd(this.theme.bold("VALUE"), valueW),
490
+ this.padEnd(this.theme.bold("SOURCE"), sourceW),
491
+ this.theme.bold("DESCRIPTION"),
492
+ ].join(gutter),
493
+ );
494
+
495
+ const vs = this.visibleRows();
496
+ if (vs.length === 0) {
497
+ lines.push(this.theme.fg("muted", "(no settings in this section)"));
498
+ } else {
499
+ vs.forEach((r, i) => {
500
+ const selected = i === this.selectedIdx;
501
+ const prefix = selected ? "▶ " : " ";
502
+ const row = [
503
+ this.padEnd(prefix + r.label, keyW),
504
+ this.padEnd(r.valueText, valueW),
505
+ this.padEnd(r.sourceText, sourceW),
506
+ truncateToWidth(r.description, descW, "…"),
507
+ ].join(gutter);
508
+ lines.push(selected ? this.theme.fg("accent", row) : row);
509
+ });
510
+ }
511
+
512
+ lines.push(
513
+ this.theme.fg(
514
+ "dim",
515
+ "←/→ tab · ↑/↓ move · enter drill-in · esc exit",
516
+ ),
517
+ );
518
+
519
+ return lines;
520
+ }
521
+
522
+ render(width: number): string[] {
523
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
524
+ this.cachedWidth = width;
525
+ this.cachedLines = this.renderBody(width);
526
+ return this.cachedLines;
527
+ }
528
+
529
+ handleInput(data: string): void {
530
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
531
+ this.done(this.resolveSelectedId());
532
+ return;
533
+ }
534
+ if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
535
+ this.done(undefined);
536
+ return;
537
+ }
538
+ if (this.keybindings.matches(data, "tui.select.up")) {
539
+ this.move(-1);
540
+ return;
541
+ }
542
+ if (this.keybindings.matches(data, "tui.select.down")) {
543
+ this.move(+1);
544
+ return;
545
+ }
546
+ // Left/right cycle sections. The Keybindings type only has up/down, so we
547
+ // match the raw CSI arrow-key sequences directly. Some terminals emit
548
+ // SS3 ("\x1bOD"/"\x1bOC") instead — fall back to those too.
549
+ if (data === "\x1b[D" || data === "\x1bOD") {
550
+ this.switchSection(-1);
551
+ return;
552
+ }
553
+ if (data === "\x1b[C" || data === "\x1bOC") {
554
+ this.switchSection(+1);
555
+ return;
556
+ }
557
+ if (data === "\t") {
558
+ this.switchSection(+1);
559
+ return;
560
+ }
561
+ if (data === "\x1b[Z") {
562
+ this.switchSection(-1);
563
+ return;
564
+ }
565
+ }
566
+
567
+ invalidate(): void {
568
+ this.cachedWidth = undefined;
569
+ this.cachedLines = undefined;
570
+ }
571
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.27.9",
3
+ "version": "0.28.1",
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",
@@ -46,6 +46,8 @@
46
46
  "stopReason": { "type": "string" },
47
47
  "pauseReason": { "type": "string" },
48
48
  "pauseSuggestedAction": { "type": "string" },
49
+ "interruptedAt": { "type": "string" },
50
+ "interruptedReason": { "type": "string" },
49
51
  "activePath": { "type": "string" },
50
52
  "archivedPath": { "type": "string" },
51
53
  "usage": {