pi-goal-list-loop-audit 0.28.21 → 0.28.23

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/README.md CHANGED
@@ -38,6 +38,7 @@ Five top-level commands — `/goal`, `/list`, `/loop`, `/glla`, `/review`:
38
38
  /goal pause # pause
39
39
  /goal resume # resume
40
40
  /goal cancel # abort
41
+ /goal decide # re-open the decision picker (v0.28.23)
41
42
  /goal tweak "<new objective>" # edit in place (Confirm dialog)
42
43
  /goal archive # archived goals, newest first
43
44
  /glla # settings UI table · /glla key=value · /glla stats · /glla audits [N|full] · /glla postaudit · /glla autoaccept=on
@@ -156,6 +156,16 @@ export interface Goal {
156
156
  stopReason?: string;
157
157
  pauseReason?: string;
158
158
  pauseSuggestedAction?: string;
159
+ /** v0.28.22: pause classification — drives the widget/status rendering
160
+ * (a decision pause, an operational failure, a time-gated wait, and a
161
+ * generic block must not look alike). Undefined = legacy flat card. */
162
+ pauseKind?: "decision" | "error" | "wait" | "blocked";
163
+ /** v0.28.22: decision pauses — the options the user picks between. */
164
+ pauseOptions?: string[];
165
+ /** v0.28.22: 1-based index into pauseOptions the agent recommends. */
166
+ pauseRecommended?: number;
167
+ /** v0.28.22: ISO time a wait-pause becomes resumable (countdown shown). */
168
+ pauseResumeAt?: string;
159
169
  /** v0.28.1 (S1/S2): stale-handle interrupt marker. Set INSTEAD of pausing
160
170
  * when pi invalidates the extension handle mid-goal — the goal stays
161
171
  * active so a fresh session auto-resumes it via the restore gate. Cleared
@@ -193,9 +203,9 @@ export interface Goal {
193
203
  export type GoalRoute =
194
204
  | { kind: "draft" }
195
205
  | { kind: "set"; text: string }
196
- | { kind: "sub"; name: "status" | "pause" | "resume" | "cancel" | "tweak" | "archive" | "start"; rest: string };
206
+ | { kind: "sub"; name: "status" | "pause" | "resume" | "cancel" | "decide" | "tweak" | "archive" | "start"; rest: string };
197
207
 
198
- const GOAL_EXACT_SUBS = new Set(["status", "pause", "resume", "cancel"]);
208
+ const GOAL_EXACT_SUBS = new Set(["status", "pause", "resume", "cancel", "decide"]);
199
209
  const GOAL_ARG_SUBS = new Set(["tweak", "archive", "start"]);
200
210
 
201
211
  export function routeGoalArgs(raw: string): GoalRoute {
@@ -205,7 +215,7 @@ export function routeGoalArgs(raw: string): GoalRoute {
205
215
  const first = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
206
216
  const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
207
217
  if (GOAL_EXACT_SUBS.has(first) && rest === "") {
208
- return { kind: "sub", name: first as "status" | "pause" | "resume" | "cancel", rest: "" };
218
+ return { kind: "sub", name: first as "status" | "pause" | "resume" | "cancel" | "decide", rest: "" };
209
219
  }
210
220
  if (GOAL_ARG_SUBS.has(first)) {
211
221
  return { kind: "sub", name: first as "tweak" | "archive" | "start", rest };
@@ -107,6 +107,18 @@ const paint = (theme: DisplayTheme | undefined, color: DisplayColor, text: strin
107
107
  const ERROR_PAUSE = /token limit|stalled|infra|auditor.*fail/i;
108
108
  const pauseIsError = (g: Goal): boolean => ERROR_PAUSE.test(g.pauseReason ?? "");
109
109
 
110
+ /** v0.28.22: the rendering class of a pause — declared kind wins; legacy
111
+ * pauses (no kind) fall back to the error-regex so old states still
112
+ * classify sensibly. */
113
+ type PauseKind = "decision" | "error" | "wait" | "blocked";
114
+ const pauseKind = (g: Goal): PauseKind | undefined => g.pauseKind ?? (pauseIsError(g) ? "error" : undefined);
115
+
116
+ /** v0.28.22: "06:40 UTC" from an ISO string (wait-pause countdown). */
117
+ const shortClock = (iso: string): string => {
118
+ const d = new Date(iso);
119
+ return Number.isNaN(d.getTime()) ? iso.slice(0, 16) : d.toISOString().slice(11, 16) + " UTC";
120
+ };
121
+
110
122
  // ---- status line (one-liner, always-on) ----
111
123
 
112
124
  export interface AuditDisplayProgress {
@@ -150,6 +162,13 @@ export function buildStatusText(state: State, audit?: AuditDisplayProgress | nul
150
162
  return `glla: ${paint(theme, "accent", "auditing…")}${tool}${heldSuffix}`;
151
163
  }
152
164
  if (g.status === "paused") {
165
+ // v0.28.22: the status line names the ACTIONABILITY, not the reason —
166
+ // "decision needed" / "action needed" / "waiting" tell you at a glance
167
+ // whether the session needs you. Legacy pauses keep the reason dump.
168
+ const kind = pauseKind(g);
169
+ if (kind === "decision") return `glla: ${g.policy} ${paint(theme, "accent", "⏸ decision needed")}${heldSuffix}`;
170
+ if (kind === "error") return `glla: ${g.policy} ${paint(theme, "error", `⏸ action needed — ${truncate(g.pauseReason ?? "", 30)}`)}${heldSuffix}`;
171
+ if (kind === "wait") return `glla: ${g.policy} ${paint(theme, "dim", `⏳ waiting${g.pauseResumeAt ? ` · resumes ${shortClock(g.pauseResumeAt)}` : ""}`)}${heldSuffix}`;
153
172
  const label = `${g.policy} paused ⏸ ${truncate(g.pauseReason ?? "", 40)}`;
154
173
  return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}${heldSuffix}`;
155
174
  }
@@ -274,14 +293,38 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
274
293
  return lines;
275
294
  }
276
295
  if (g.status === "paused" && g.pauseReason) {
277
- const isErr = pauseIsError(g);
296
+ const kind = pauseKind(g);
297
+ const isErr = kind === "error";
278
298
  const budget = budgetFor(width, 3, 60);
279
- // v0.27.1: wrap reason + suggested action over up to 3 lines each
280
- // (see wrap()); before, both were truncated at ~60 chars and the actual
281
- // question in a decision-pause never reached the user.
282
- wrap(g.pauseReason, budget, 3).forEach((w, i) => {
283
- lines.push(`${i === 0 ? "├─" : "│ "} ${paint(theme, isErr ? "error" : "warning", w)}`);
299
+ // v0.28.22: actionability banner a decision pause, an operational
300
+ // failure, and a time-gated wait must not look alike (user report:
301
+ // "if something actionable is going on it can be hard to tell").
302
+ if (kind === "decision") lines.push(`├─ ${paint(theme, "accent", "decision needed — your call unblocks this")}`);
303
+ else if (kind === "error") lines.push(`├─ ${paint(theme, "error", "action needed — this won't fix itself")}`);
304
+ else if (kind === "wait") lines.push(`├─ ${paint(theme, "dim", "waiting — nothing for you to do")}`);
305
+ // v0.27.1: wrap reason + suggested action (see wrap()). v0.28.22:
306
+ // decision/wait reasons cap at 2 lines — the options/countdown below
307
+ // carry the actionable content; error reasons keep 3.
308
+ const reasonPaint = isErr ? "error" : kind === "wait" ? "dim" : "warning";
309
+ wrap(g.pauseReason, budget, kind === "decision" || kind === "wait" ? 2 : 3).forEach((w, i) => {
310
+ lines.push(`${i === 0 ? "├─" : "│ "} ${paint(theme, reasonPaint, w)}`);
284
311
  });
312
+ // v0.28.22: decision options — one numbered line each (Claude Code /
313
+ // muselinn-Ask convention), the recommended one accented and flagged.
314
+ if (kind === "decision" && g.pauseOptions && g.pauseOptions.length > 0) {
315
+ g.pauseOptions.slice(0, 6).forEach((opt, i) => {
316
+ const rec = g.pauseRecommended === i + 1;
317
+ const text = `${i + 1}. ${truncate(opt, budget - 4)}${rec ? " ◂ recommended" : ""}`;
318
+ lines.push(`│ ${paint(theme, rec ? "accent" : "dim", text)}`);
319
+ });
320
+ if (g.pauseOptions.length > 6) lines.push(`│ ${paint(theme, "dim", `… and ${g.pauseOptions.length - 6} more`)}`);
321
+ }
322
+ // v0.28.22: wait countdown — when the pause lifts on its own.
323
+ if (kind === "wait" && g.pauseResumeAt) {
324
+ const ms = Date.parse(g.pauseResumeAt) - now;
325
+ const when = Number.isNaN(ms) ? g.pauseResumeAt : ms <= 0 ? "now" : `${shortClock(g.pauseResumeAt)} (in ${fmtElapsed(ms)})`;
326
+ lines.push(`├─ ${paint(theme, "dim", `resumes ${when} — or /goal resume now`)}`);
327
+ }
285
328
  // v0.27.1: what survives the pause — the first question at a pause is
286
329
  // "did I lose the work?". Answer it on the card.
287
330
  // v0.27.9: when the goal has no telemetry yet (restored-in-fresh-session
@@ -300,7 +343,9 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
300
343
  if (g.pauseSuggestedAction) {
301
344
  lines.push(`├─ ${paint(theme, "dim", truncate(savedLine, budget))}`);
302
345
  const wrapped = wrap(g.pauseSuggestedAction, budget, 3);
303
- wrapped.forEach((w, i) => lines.push(`${i === wrapped.length - 1 ? "└─" : "│ "} ${paint(theme, "dim", w)}`));
346
+ // v0.28.22: for ACTION NEEDED pauses the action is the point pop it.
347
+ const actionPaint = kind === "error" ? "warning" : "dim";
348
+ wrapped.forEach((w, i) => lines.push(`${i === wrapped.length - 1 ? "└─" : "│ "} ${paint(theme, actionPaint, w)}`));
304
349
  } else {
305
350
  lines.push(`└─ ${paint(theme, "dim", truncate(savedLine, budget))}`);
306
351
  }
@@ -33,6 +33,10 @@ export interface Settings {
33
33
  /** on → restored goals/loops/lists auto-resume even in fresh sessions
34
34
  * (unattended rigs). Default off: restore holds until /goal resume. */
35
35
  autoResume?: boolean;
36
+ /** v0.28.23: off → decision pauses don't pop the select() picker (the
37
+ * widget card still shows the options; /goal decide opens it on demand).
38
+ * Default on; unattended rigs have no UI so this never fires there. */
39
+ decisionPopup?: boolean;
36
40
  /** v0.28.14: what happens to stale carryover (paused goal, waiting list,
37
41
  * held loop from before this session) when NEW work activates.
38
42
  * pause (default) = leave it + ONE summary; clear = drop it all honestly;
@@ -156,6 +160,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
156
160
  "tokenLimit",
157
161
  "wedgeAlertMinutes",
158
162
  "autoResume",
163
+ "decisionPopup",
159
164
  "carryover",
160
165
  "autoAcceptDrafts",
161
166
  "auditCap",
@@ -498,6 +498,7 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
498
498
  if (state.goal && state.goal.status === "active") {
499
499
  updateGoal({
500
500
  status: "paused",
501
+ pauseKind: "error",
501
502
  pauseReason: `send-retry storm: ${mins}m of 50ms re-arms — the session never went idle for the continuation`,
502
503
  pauseSuggestedAction: "The session never went idle for the send (wedged queue or permanently busy). Restart pi, then /goal resume.",
503
504
  }, ctx);
@@ -521,6 +522,7 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
521
522
  if (state.goal && state.goal.status === "active") {
522
523
  updateGoal({
523
524
  status: "paused",
525
+ pauseKind: "error",
524
526
  pauseReason: `stalled: ${threshold} continuation refires landed no turn`,
525
527
  pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
526
528
  }, ctx);
@@ -1136,6 +1138,13 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
1136
1138
  if (route.name === "pause") return cmdPause(ctx);
1137
1139
  if (route.name === "resume") return cmdResume(ctx);
1138
1140
  if (route.name === "cancel") return cmdCancel(ctx);
1141
+ // v0.28.23: re-open the decision picker for a decision pause (the
1142
+ // popup auto-opens when the pause lands; this is the on-demand path).
1143
+ if (route.name === "decide") {
1144
+ const shown = await showDecisionPrompt(ctx);
1145
+ if (!shown) ctx.ui.notify("No pending decision — the goal isn't paused on a choice (or no UI).", "info");
1146
+ return;
1147
+ }
1139
1148
  if (route.name === "tweak") return cmdTweak(route.rest, ctx);
1140
1149
  if (route.name === "archive") return cmdGoals(ctx);
1141
1150
  // v0.16.0: /goal start <objective> — explicit skip-draft. Activates
@@ -1259,7 +1268,7 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
1259
1268
  const usage = state.goal.usage
1260
1269
  ? { tokensUsed: state.goal.usage.tokensUsed, tokensLimit: freshLimit }
1261
1270
  : undefined;
1262
- updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(staleEntry ? { interruptedAt: nowIso(), interruptedReason: "resumed in a stale session" } : {}), ...(usage ? { usage } : {}) }, ctx);
1271
+ updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, pauseKind: undefined, pauseOptions: undefined, pauseRecommended: undefined, pauseResumeAt: undefined, ...(staleEntry ? { interruptedAt: nowIso(), interruptedReason: "resumed in a stale session" } : {}), ...(usage ? { usage } : {}) }, ctx);
1263
1272
  if (staleEntry) return;
1264
1273
  // v0.22.5: say what was resumed — with a non-empty list this also resumes
1265
1274
  // the queue (the active goal IS the list's head item).
@@ -1289,6 +1298,68 @@ async function cmdCancel(ctx: ExtensionContext): Promise<void> {
1289
1298
  ctx.ui.notify(`Goal aborted.${isLoopActive() ? " A loop is still active — /loop stop ends it." : ""}`, "info");
1290
1299
  }
1291
1300
 
1301
+ // ---- v0.28.23: decision picker popup ----
1302
+ // A decision pause is ACTIONABLE — the widget card summarizes (and
1303
+ // truncates) it, but picking from a truncated wall was the user's
1304
+ // complaint. Borrow Claude Code / muselinn-Ask: a real select() modal
1305
+ // with the FULL option text, pick → act. Escape leaves the card as the
1306
+ // fallback; /goal decide re-opens the picker at any time.
1307
+
1308
+ let decisionPromptOpen = false;
1309
+
1310
+ /** True when the goal is paused on a user decision with options. */
1311
+ function pendingDecision(): Goal | null {
1312
+ const g = state.goal;
1313
+ return g && g.status === "paused" && g.pauseKind === "decision" && g.pauseOptions && g.pauseOptions.length > 0 ? g : null;
1314
+ }
1315
+
1316
+ /** Open the decision picker for the current decision pause. Returns true
1317
+ * when a picker was shown (false → caller notifies "no pending decision"). */
1318
+ async function showDecisionPrompt(ctx: ExtensionContext): Promise<boolean> {
1319
+ const g = pendingDecision();
1320
+ if (!g || !ctx.hasUI || decisionPromptOpen) return false;
1321
+ decisionPromptOpen = true;
1322
+ try {
1323
+ const title = `Decision needed — ${g.objective.replace(/\s+/g, " ").slice(0, 72)}${g.pauseReason ? ` · ${g.pauseReason.slice(0, 80)}` : ""}`;
1324
+ const options = g.pauseOptions!.map((o, i) => (g.pauseRecommended === i + 1 ? `${o} (recommended)` : o));
1325
+ const pick = await ctx.ui.select(title, options);
1326
+ if (!pick) return true; // Escape — the widget card remains the fallback
1327
+ const idx = options.indexOf(pick);
1328
+ const label = g.pauseOptions![idx] ?? pick.replace(/ {2}\(recommended\)$/, "");
1329
+ // Executable options — "Label (/goal cancel)" — RUN the command.
1330
+ // Placeholder commands (…/<arg>) fall through to the message path.
1331
+ const cmdMatch = label.match(/\(\/(goal|list|loop) ([a-z]+)\)\s*$/);
1332
+ if (cmdMatch && !label.includes("…") && !label.includes("<")) {
1333
+ const [, group, verb] = cmdMatch;
1334
+ if (group === "goal" && verb === "resume") await cmdResume(ctx);
1335
+ else if (group === "goal" && verb === "cancel") await cmdCancel(ctx);
1336
+ else if (group === "loop" && verb === "stop") await cmdLoop("stop", ctx);
1337
+ else if (group === "loop" && verb === "resume") await cmdLoop("resume", ctx);
1338
+ else {
1339
+ extensionApi?.sendUserMessage(`Decision for the paused goal "${g.objective}": ${label} — continue on this path.`);
1340
+ await cmdResume(ctx);
1341
+ }
1342
+ return true;
1343
+ }
1344
+ // Content choice — deliver to the agent, then resume.
1345
+ extensionApi?.sendUserMessage(`Decision for the paused goal "${g.objective}": ${label} — continue on this path.`);
1346
+ await cmdResume(ctx);
1347
+ return true;
1348
+ } finally {
1349
+ decisionPromptOpen = false;
1350
+ }
1351
+ }
1352
+
1353
+ /** Pop the picker after a decision pause lands — deferred so the current
1354
+ * turn finishes first (pi serializes dialogs). No-ops without a UI, when
1355
+ * disabled (/glla decisionpopup=off), or when one is already open. */
1356
+ function maybeDecisionPopup(ctx: ExtensionContext): void {
1357
+ if (!ctx.hasUI || loadSettings(ctx.cwd).decisionPopup === false) return;
1358
+ setTimeout(() => {
1359
+ void showDecisionPrompt(ctx).catch(() => {});
1360
+ }, 600);
1361
+ }
1362
+
1292
1363
  async function cmdGoals(ctx: ExtensionContext): Promise<void> {
1293
1364
  const dir = archiveDir(ctx.cwd);
1294
1365
  if (!fs.existsSync(dir)) {
@@ -2050,10 +2121,10 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2050
2121
  const sub = (parts[0] ?? "").toLowerCase();
2051
2122
  const rest = args.trim().slice(sub.length).trim();
2052
2123
 
2053
- if (!sub) {
2054
- // /loop with no args → resume a held loop if one is waiting; otherwise
2055
- // draft the loop config (metric design is the whole game for a
2056
- // long-running loop; never start one blind).
2124
+ if (!sub || sub === "resume") {
2125
+ // /loop with no args (or /loop resume, v0.28.22) → resume a held loop
2126
+ // if one is waiting; otherwise draft the loop config (metric design is
2127
+ // the whole game for a long-running loop; never start one blind).
2057
2128
  if (isLoopActive()) {
2058
2129
  ctx.ui.notify("A loop is already active — /loop status to inspect, /loop stop to end it.", "info");
2059
2130
  return;
@@ -2063,7 +2134,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2063
2134
  // v0.28.14: one-active-thing — a held loop must not resume over an
2064
2135
  // active goal/list-item (this was the last unguarded stacking path).
2065
2136
  if (state.goal && state.goal.status === "active") {
2066
- ctx.ui.notify("A goal is active — the held loop stays held. /goal pause or /goal cancel it first, then /loop to resume.", "warning");
2137
+ ctx.ui.notify("A goal is active — the held loop stays held. /goal pause or /goal cancel it first, then /loop resume.", "warning");
2067
2138
  return;
2068
2139
  }
2069
2140
  state.loop = { ...stored, active: true, stopReason: undefined };
@@ -2075,6 +2146,10 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2075
2146
  );
2076
2147
  return;
2077
2148
  }
2149
+ if (sub === "resume") {
2150
+ ctx.ui.notify("No held loop to resume. /loop to draft one, or /loop start \"<target>\" for an infinite metricless loop.", "info");
2151
+ return;
2152
+ }
2078
2153
  await startDrafting(ctx, "loop");
2079
2154
  return;
2080
2155
  }
@@ -2440,10 +2515,14 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2440
2515
  updateGoal({
2441
2516
  status: "paused",
2442
2517
  auditHistory: history,
2518
+ pauseKind: "decision",
2519
+ pauseOptions: ["Tweak the objective — /goal tweak <new text>", "Cancel the goal (/goal cancel)"],
2520
+ pauseRecommended: 1,
2443
2521
  pauseReason: `auditor verdict: IMPOSSIBLE — ${reason}`,
2444
2522
  pauseSuggestedAction: "The auditor says this goal can never be satisfied as stated. /goal tweak the objective (or /goal cancel), then /goal resume.",
2445
2523
  }, ctx);
2446
2524
  ctx.ui.notify(`Auditor: goal IMPOSSIBLE — ${reason}. Goal paused; /goal tweak or /goal cancel, then /goal resume.`, "warning");
2525
+ maybeDecisionPopup(ctx);
2447
2526
  appendLedger(ctx.cwd, "goal_paused", { reason: `auditor impossible: ${reason}` });
2448
2527
  notifyExternal(ctx, `Goal paused (auditor: impossible): ${reason.slice(0, 120)}`);
2449
2528
  return {
@@ -2472,6 +2551,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2472
2551
  status: "paused",
2473
2552
  auditHistory: history,
2474
2553
  auditInfraStreak: undefined, // quota reached the auditor — infra streak broken
2554
+ pauseKind: "wait",
2555
+ pauseResumeAt: new Date(Date.now() + quota.retryAfterSec * 1000).toISOString(),
2475
2556
  pauseReason: `auditor quota: ${result.error}`,
2476
2557
  pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
2477
2558
  }, ctx);
@@ -2506,6 +2587,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2506
2587
  status: "paused",
2507
2588
  auditHistory: history,
2508
2589
  auditInfraStreak: infraStreak,
2590
+ pauseKind: "error",
2509
2591
  pauseReason: `auditor infrastructure failed ${infraStreak}× in a row — the auditor model is likely broken (last: ${result.error.slice(0, 120)})`,
2510
2592
  pauseSuggestedAction: "Fix the auditor model (/glla model=provider/id) or restart pi, then /goal resume. Your work was NOT judged.",
2511
2593
  }, ctx);
@@ -2612,10 +2694,14 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2612
2694
  updateGoal({
2613
2695
  status: "paused",
2614
2696
  auditHistory: history,
2697
+ pauseKind: "decision",
2698
+ pauseOptions: ["Fix the disapproval gap, then continue (/goal resume)", "Tweak the objective — /goal tweak <new text>", "Cancel the goal (/goal cancel)"],
2699
+ pauseRecommended: 1,
2615
2700
  pauseReason: `auditor disapproved ${trailingDisapprovals}× consecutively (cap ${auditCap})`,
2616
2701
  pauseSuggestedAction: "Read the audit history (/goal status), fix the actual gap or /goal tweak the objective, then /goal resume. Raise the cap with /glla auditcap=N.",
2617
2702
  }, ctx);
2618
2703
  ctx.ui.notify(`Goal paused: auditor disapproved ${trailingDisapprovals}× consecutively (cap ${auditCap}). /goal status for the reports; /goal resume to continue.`, "warning");
2704
+ maybeDecisionPopup(ctx);
2619
2705
  appendLedger(ctx.cwd, "goal_paused", { reason: `disapproval cap: ${trailingDisapprovals} consecutive (cap ${auditCap})` });
2620
2706
  notifyExternal(ctx, `Goal paused: ${trailingDisapprovals} consecutive auditor disapprovals`);
2621
2707
  return {
@@ -2646,21 +2732,30 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2646
2732
  pi.registerTool(defineTool({
2647
2733
  name: "pause_goal",
2648
2734
  label: "Pause goal",
2649
- description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress.",
2735
+ description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress. When the user must CHOOSE between options, pass kind=\"decision\" with the options list (recommended = 1-based index of the best one) — decision pauses render as a prominent DECISION NEEDED card. Time-gated waits (retry at a specific time) use kind=\"wait\" with resumeAt (ISO). Operational failures use kind=\"error\".",
2650
2736
  parameters: Type.Object({
2651
2737
  reason: Type.String({ description: "Why the work is paused" }),
2652
2738
  suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
2739
+ kind: Type.Optional(Type.Union([Type.Literal("decision"), Type.Literal("error"), Type.Literal("wait"), Type.Literal("blocked")], { description: "Pause class: decision (user picks an option), error (operational failure), wait (time-gated), blocked (generic)" })),
2740
+ options: Type.Optional(Type.Array(Type.String(), { description: "For kind=decision: the options the user picks between (one line each)" })),
2741
+ recommended: Type.Optional(Type.Number({ description: "For kind=decision: 1-based index of the recommended option" })),
2742
+ resumeAt: Type.Optional(Type.String({ description: "For kind=wait: ISO time the pause lifts (countdown is shown)" })),
2653
2743
  }),
2654
2744
  async execute(_id, params, _signal, _onUpdate, execCtx) {
2655
2745
  const foreign1 = foreignToolGuard(execCtx);
2656
2746
  if (foreign1) return { content: [{ type: "text", text: foreign1 }], details: {} };
2657
- const p = params as { reason: string; suggestedAction?: string };
2747
+ const p = params as { reason: string; suggestedAction?: string; kind?: "decision" | "error" | "wait" | "blocked"; options?: string[]; recommended?: number; resumeAt?: string };
2658
2748
  if (!state.goal) return { content: [{ type: "text", text: "No active goal." }], details: {} };
2659
2749
  updateGoal({
2660
2750
  status: "paused",
2661
2751
  pauseReason: p.reason,
2662
2752
  pauseSuggestedAction: p.suggestedAction,
2753
+ pauseKind: p.kind,
2754
+ pauseOptions: p.kind === "decision" && p.options && p.options.length > 0 ? p.options : undefined,
2755
+ pauseRecommended: p.kind === "decision" && p.recommended && p.recommended >= 1 ? Math.floor(p.recommended) : undefined,
2756
+ pauseResumeAt: p.kind === "wait" && p.resumeAt ? p.resumeAt : undefined,
2663
2757
  }, ctx);
2758
+ if (p.kind === "decision" && p.options && p.options.length > 0) maybeDecisionPopup(ctx);
2664
2759
  // v0.27.1: surface the FULL pause contract — reason AND suggested
2665
2760
  // action. Before, the action only appeared in /goal status and the
2666
2761
  // widget truncated both at ~60 chars, so decision-pauses ("choose a
@@ -3386,6 +3481,14 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
3386
3481
  if (v) saveSettings("global", ctx.cwd, { autoAcceptDrafts: v.startsWith("on") ? true : undefined });
3387
3482
  return;
3388
3483
  }
3484
+ case "decisionPopup": {
3485
+ const v = await ctx.ui.select("Decision popup (v0.28.23 — decision pauses pop the select() picker)", [
3486
+ "on — a decision pause opens the picker; the widget card is the Escape fallback",
3487
+ "off — widget card only; /goal decide opens the picker on demand",
3488
+ ]);
3489
+ if (v) saveSettings("global", ctx.cwd, { decisionPopup: v.startsWith("off") ? false : undefined });
3490
+ return;
3491
+ }
3389
3492
  case "aggressiveMode": {
3390
3493
  const v = await ctx.ui.select("Aggressive mode (flips DEFAULTS toward keep-going — explicit per-key settings still win)", [
3391
3494
  "off — current behavior: pause at the audit cap, wedge alerts on, manual resume",
@@ -3941,6 +4044,16 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
3941
4044
  } else {
3942
4045
  ctx.ui.notify(`autoresume must be on or off, got: ${value}`, "warning");
3943
4046
  }
4047
+ } else if (key === "decisionpopup") {
4048
+ if (["on", "true", "1", "yes"].includes(value)) {
4049
+ patch.decisionPopup = true;
4050
+ changed = true;
4051
+ } else if (["off", "false", "0", "no"].includes(value)) {
4052
+ patch.decisionPopup = false;
4053
+ changed = true;
4054
+ } else {
4055
+ ctx.ui.notify(`decisionpopup must be on or off, got: ${value}`, "warning");
4056
+ }
3944
4057
  } else if (key === "carryover") {
3945
4058
  if (["resume", "pause", "clear"].includes(value)) {
3946
4059
  patch.carryover = value as "resume" | "pause" | "clear";
@@ -4072,7 +4185,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
4072
4185
  }
4073
4186
  }
4074
4187
  if (!changed) {
4075
- ctx.ui.notify("Nothing changed. Use key=value (model, thinking, notify, tokenlimit, autoresume, carryover, auditcap, auditfeedbackchars, aggressivemode, quotaretryminutes, stuckmax), optionally prefixed with 'project'.", "info");
4188
+ ctx.ui.notify("Nothing changed. Use key=value (model, thinking, notify, tokenlimit, autoresume, decisionpopup, carryover, auditcap, auditfeedbackchars, aggressivemode, quotaretryminutes, stuckmax), optionally prefixed with 'project'.", "info");
4076
4189
  return;
4077
4190
  }
4078
4191
  saveSettings(scope, ctx.cwd, patch);
@@ -4206,6 +4319,7 @@ export default function (pi: ExtensionAPI): void {
4206
4319
  ["notify=", "desktop push command: /glla notify='notify-send pi \"$1\"'"],
4207
4320
  ["tokenlimit=", "per-goal token budget (0 = off): /glla tokenlimit=2000000"],
4208
4321
  ["autoresume=", "default: hold when a session is loaded, auto-resume on reload/fork; on: always auto-resume; off: never"],
4322
+ ["decisionpopup=", "on|off: decision pauses pop the select() picker (default on; the widget card always lists the options, /goal decide reopens the picker)"],
4209
4323
  ["auditcap=", "N: pause goal after N consecutive auditor disapprovals (default 5, 0 = unlimited)"],
4210
4324
  ["auditfeedbackchars=", "cap on executor-visible disapproval report chars (0 = full report, the default)"],
4211
4325
  ["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
@@ -4462,7 +4576,7 @@ export default function (pi: ExtensionAPI): void {
4462
4576
  state.loop = { ...l, active: false, stopReason: HELD_ON_RESTORE };
4463
4577
  persistState(ctx);
4464
4578
  ctx.ui.notify(
4465
- `Loop held on restore: ${l.target.slice(0, 60)} — /loop to resume, /glla autoresume=on to auto-resume on session load in this project.`,
4579
+ `Loop held on restore: ${l.target.slice(0, 60)} — /loop resume to continue, /glla autoresume=on to auto-resume on session load in this project.`,
4466
4580
  "info",
4467
4581
  );
4468
4582
  }
@@ -4492,6 +4606,7 @@ export default function (pi: ExtensionAPI): void {
4492
4606
  const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} waiting in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
4493
4607
  updateGoal({
4494
4608
  status: "paused",
4609
+ pauseKind: "blocked",
4495
4610
  pauseReason: "restored on session load — held for explicit resume",
4496
4611
  pauseSuggestedAction: resumeHint,
4497
4612
  }, ctx);
@@ -4522,6 +4637,9 @@ export default function (pi: ExtensionAPI): void {
4522
4637
  if (state.loop && state.goal && state.goal.status === "active") {
4523
4638
  updateGoal({
4524
4639
  status: "paused",
4640
+ pauseKind: "decision",
4641
+ pauseOptions: ["Stop the loop, then resume the goal (/loop stop)", "Cancel the goal (/goal cancel) — the loop keeps running"],
4642
+ pauseRecommended: 1,
4525
4643
  pauseReason: "held on session load — the loop owns the active slot (one active thing at a time)",
4526
4644
  pauseSuggestedAction: "/loop to work the loop, or /loop stop then /goal resume to work the goal",
4527
4645
  }, ctx);
@@ -4529,6 +4647,7 @@ export default function (pi: ExtensionAPI): void {
4529
4647
  `Goal [${state.goal.id}] held — a loop also exists; one active thing at a time. /loop to resume the loop, or /loop stop then /goal resume.`,
4530
4648
  "info",
4531
4649
  );
4650
+ maybeDecisionPopup(ctx);
4532
4651
  }
4533
4652
  // Always paint on session load (v0.22.1): the branches above only reach
4534
4653
  // refreshUI via persistState, so a goal that was ALREADY paused (or any
@@ -4617,10 +4736,14 @@ export default function (pi: ExtensionAPI): void {
4617
4736
  if (state.goal) {
4618
4737
  updateGoal({
4619
4738
  status: "paused",
4739
+ pauseKind: "decision",
4740
+ pauseOptions: ["Retry — /goal resume", "Tweak the objective — /goal tweak <new text>", "Cancel the goal (/goal cancel)"],
4741
+ pauseRecommended: 1,
4620
4742
  pauseReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive unproductive turns (no tools, short or repetitive)`,
4621
4743
  pauseSuggestedAction: "Inspect the goal — /goal resume to retry, /goal tweak to narrow it, /goal cancel to abort.",
4622
4744
  }, ctx);
4623
4745
  ctx.ui.notify(`Goal paused: stalled (${HEARTBEAT_MAX_NUDGES} unproductive turns).`, "warning");
4746
+ maybeDecisionPopup(ctx);
4624
4747
  notifyExternal(ctx, "Goal paused: stalled (no tool calls).");
4625
4748
  return;
4626
4749
  }
@@ -4664,6 +4787,7 @@ export default function (pi: ExtensionAPI): void {
4664
4787
  updateGoal({
4665
4788
  usage: { tokensUsed: used, tokensLimit: limit },
4666
4789
  status: "paused",
4790
+ pauseKind: "error",
4667
4791
  pauseReason: `token limit exceeded (${used.toLocaleString()} > ${limit.toLocaleString()})`,
4668
4792
  pauseSuggestedAction: "/glla tokenlimit=<n> to raise the cap (or 0 to disable), then /goal resume",
4669
4793
  }, ctx);
@@ -4687,6 +4811,8 @@ export default function (pi: ExtensionAPI): void {
4687
4811
  const reason = `5 consecutive errors${detail}`;
4688
4812
  updateGoal({
4689
4813
  status: "paused",
4814
+ pauseKind: "wait",
4815
+ pauseResumeAt: new Date(Date.now() + 60_000).toISOString(),
4690
4816
  pauseReason: reason,
4691
4817
  pauseSuggestedAction: "Transient provider flake? The goal auto-resumes once in 60s if still paused for this reason — or /goal resume now.",
4692
4818
  }, ctx);
@@ -4714,6 +4840,7 @@ export default function (pi: ExtensionAPI): void {
4714
4840
  if (consecutiveAbortIterations >= 5) {
4715
4841
  updateGoal({
4716
4842
  status: "paused",
4843
+ pauseKind: "blocked",
4717
4844
  pauseReason: "5 consecutive aborts (user interrupted)",
4718
4845
  pauseSuggestedAction: "You interrupted 5 turns in a row — the goal stays paused until you /goal resume (or /goal cancel).",
4719
4846
  }, ctx);
@@ -129,7 +129,16 @@ export function buildSettingsRows(
129
129
  valueText: show("autoResume", "default"),
130
130
  sourceText: src("autoResume"),
131
131
  description:
132
- "on: resume on session load too · off: never · default: hold on load, resume on reload/fork",
132
+ "on: resume on session load too · off: never · default: hold on EVERY load — explicit resume (v0.28.21)",
133
+ },
134
+ {
135
+ id: "decisionPopup",
136
+ section: "keep-going",
137
+ label: "Decision popup",
138
+ valueText: show("decisionPopup", "on"),
139
+ sourceText: src("decisionPopup"),
140
+ description:
141
+ "on: decision pauses pop the select() picker · off: widget card only — /goal decide reopens the picker (v0.28.23)",
133
142
  },
134
143
  {
135
144
  id: "autoAcceptDrafts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.21",
3
+ "version": "0.28.23",
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,10 @@
46
46
  "stopReason": { "type": "string" },
47
47
  "pauseReason": { "type": "string" },
48
48
  "pauseSuggestedAction": { "type": "string" },
49
+ "pauseKind": { "type": "string", "enum": ["decision", "error", "wait", "blocked"] },
50
+ "pauseOptions": { "type": "array", "items": { "type": "string" } },
51
+ "pauseRecommended": { "type": "number" },
52
+ "pauseResumeAt": { "type": "string" },
49
53
  "interruptedAt": { "type": "string" },
50
54
  "interruptedReason": { "type": "string" },
51
55
  "auditInfraStreak": { "type": "number" },