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

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,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
@@ -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
  }
@@ -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);
@@ -1259,7 +1261,7 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
1259
1261
  const usage = state.goal.usage
1260
1262
  ? { tokensUsed: state.goal.usage.tokensUsed, tokensLimit: freshLimit }
1261
1263
  : undefined;
1262
- updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(staleEntry ? { interruptedAt: nowIso(), interruptedReason: "resumed in a stale session" } : {}), ...(usage ? { usage } : {}) }, ctx);
1264
+ 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
1265
  if (staleEntry) return;
1264
1266
  // v0.22.5: say what was resumed — with a non-empty list this also resumes
1265
1267
  // the queue (the active goal IS the list's head item).
@@ -2050,10 +2052,10 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2050
2052
  const sub = (parts[0] ?? "").toLowerCase();
2051
2053
  const rest = args.trim().slice(sub.length).trim();
2052
2054
 
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).
2055
+ if (!sub || sub === "resume") {
2056
+ // /loop with no args (or /loop resume, v0.28.22) → resume a held loop
2057
+ // if one is waiting; otherwise draft the loop config (metric design is
2058
+ // the whole game for a long-running loop; never start one blind).
2057
2059
  if (isLoopActive()) {
2058
2060
  ctx.ui.notify("A loop is already active — /loop status to inspect, /loop stop to end it.", "info");
2059
2061
  return;
@@ -2063,7 +2065,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2063
2065
  // v0.28.14: one-active-thing — a held loop must not resume over an
2064
2066
  // active goal/list-item (this was the last unguarded stacking path).
2065
2067
  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");
2068
+ ctx.ui.notify("A goal is active — the held loop stays held. /goal pause or /goal cancel it first, then /loop resume.", "warning");
2067
2069
  return;
2068
2070
  }
2069
2071
  state.loop = { ...stored, active: true, stopReason: undefined };
@@ -2075,6 +2077,10 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2075
2077
  );
2076
2078
  return;
2077
2079
  }
2080
+ if (sub === "resume") {
2081
+ ctx.ui.notify("No held loop to resume. /loop to draft one, or /loop start \"<target>\" for an infinite metricless loop.", "info");
2082
+ return;
2083
+ }
2078
2084
  await startDrafting(ctx, "loop");
2079
2085
  return;
2080
2086
  }
@@ -2440,6 +2446,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2440
2446
  updateGoal({
2441
2447
  status: "paused",
2442
2448
  auditHistory: history,
2449
+ pauseKind: "decision",
2443
2450
  pauseReason: `auditor verdict: IMPOSSIBLE — ${reason}`,
2444
2451
  pauseSuggestedAction: "The auditor says this goal can never be satisfied as stated. /goal tweak the objective (or /goal cancel), then /goal resume.",
2445
2452
  }, ctx);
@@ -2472,6 +2479,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2472
2479
  status: "paused",
2473
2480
  auditHistory: history,
2474
2481
  auditInfraStreak: undefined, // quota reached the auditor — infra streak broken
2482
+ pauseKind: "wait",
2483
+ pauseResumeAt: new Date(Date.now() + quota.retryAfterSec * 1000).toISOString(),
2475
2484
  pauseReason: `auditor quota: ${result.error}`,
2476
2485
  pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
2477
2486
  }, ctx);
@@ -2506,6 +2515,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2506
2515
  status: "paused",
2507
2516
  auditHistory: history,
2508
2517
  auditInfraStreak: infraStreak,
2518
+ pauseKind: "error",
2509
2519
  pauseReason: `auditor infrastructure failed ${infraStreak}× in a row — the auditor model is likely broken (last: ${result.error.slice(0, 120)})`,
2510
2520
  pauseSuggestedAction: "Fix the auditor model (/glla model=provider/id) or restart pi, then /goal resume. Your work was NOT judged.",
2511
2521
  }, ctx);
@@ -2612,6 +2622,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2612
2622
  updateGoal({
2613
2623
  status: "paused",
2614
2624
  auditHistory: history,
2625
+ pauseKind: "decision",
2615
2626
  pauseReason: `auditor disapproved ${trailingDisapprovals}× consecutively (cap ${auditCap})`,
2616
2627
  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
2628
  }, ctx);
@@ -2646,20 +2657,28 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2646
2657
  pi.registerTool(defineTool({
2647
2658
  name: "pause_goal",
2648
2659
  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.",
2660
+ 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
2661
  parameters: Type.Object({
2651
2662
  reason: Type.String({ description: "Why the work is paused" }),
2652
2663
  suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
2664
+ 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)" })),
2665
+ options: Type.Optional(Type.Array(Type.String(), { description: "For kind=decision: the options the user picks between (one line each)" })),
2666
+ recommended: Type.Optional(Type.Number({ description: "For kind=decision: 1-based index of the recommended option" })),
2667
+ resumeAt: Type.Optional(Type.String({ description: "For kind=wait: ISO time the pause lifts (countdown is shown)" })),
2653
2668
  }),
2654
2669
  async execute(_id, params, _signal, _onUpdate, execCtx) {
2655
2670
  const foreign1 = foreignToolGuard(execCtx);
2656
2671
  if (foreign1) return { content: [{ type: "text", text: foreign1 }], details: {} };
2657
- const p = params as { reason: string; suggestedAction?: string };
2672
+ const p = params as { reason: string; suggestedAction?: string; kind?: "decision" | "error" | "wait" | "blocked"; options?: string[]; recommended?: number; resumeAt?: string };
2658
2673
  if (!state.goal) return { content: [{ type: "text", text: "No active goal." }], details: {} };
2659
2674
  updateGoal({
2660
2675
  status: "paused",
2661
2676
  pauseReason: p.reason,
2662
2677
  pauseSuggestedAction: p.suggestedAction,
2678
+ pauseKind: p.kind,
2679
+ pauseOptions: p.kind === "decision" && p.options && p.options.length > 0 ? p.options : undefined,
2680
+ pauseRecommended: p.kind === "decision" && p.recommended && p.recommended >= 1 ? Math.floor(p.recommended) : undefined,
2681
+ pauseResumeAt: p.kind === "wait" && p.resumeAt ? p.resumeAt : undefined,
2663
2682
  }, ctx);
2664
2683
  // v0.27.1: surface the FULL pause contract — reason AND suggested
2665
2684
  // action. Before, the action only appeared in /goal status and the
@@ -4462,7 +4481,7 @@ export default function (pi: ExtensionAPI): void {
4462
4481
  state.loop = { ...l, active: false, stopReason: HELD_ON_RESTORE };
4463
4482
  persistState(ctx);
4464
4483
  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.`,
4484
+ `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
4485
  "info",
4467
4486
  );
4468
4487
  }
@@ -4492,6 +4511,7 @@ export default function (pi: ExtensionAPI): void {
4492
4511
  const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} waiting in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
4493
4512
  updateGoal({
4494
4513
  status: "paused",
4514
+ pauseKind: "blocked",
4495
4515
  pauseReason: "restored on session load — held for explicit resume",
4496
4516
  pauseSuggestedAction: resumeHint,
4497
4517
  }, ctx);
@@ -4522,6 +4542,7 @@ export default function (pi: ExtensionAPI): void {
4522
4542
  if (state.loop && state.goal && state.goal.status === "active") {
4523
4543
  updateGoal({
4524
4544
  status: "paused",
4545
+ pauseKind: "decision",
4525
4546
  pauseReason: "held on session load — the loop owns the active slot (one active thing at a time)",
4526
4547
  pauseSuggestedAction: "/loop to work the loop, or /loop stop then /goal resume to work the goal",
4527
4548
  }, ctx);
@@ -4617,6 +4638,7 @@ export default function (pi: ExtensionAPI): void {
4617
4638
  if (state.goal) {
4618
4639
  updateGoal({
4619
4640
  status: "paused",
4641
+ pauseKind: "decision",
4620
4642
  pauseReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive unproductive turns (no tools, short or repetitive)`,
4621
4643
  pauseSuggestedAction: "Inspect the goal — /goal resume to retry, /goal tweak to narrow it, /goal cancel to abort.",
4622
4644
  }, ctx);
@@ -4664,6 +4686,7 @@ export default function (pi: ExtensionAPI): void {
4664
4686
  updateGoal({
4665
4687
  usage: { tokensUsed: used, tokensLimit: limit },
4666
4688
  status: "paused",
4689
+ pauseKind: "error",
4667
4690
  pauseReason: `token limit exceeded (${used.toLocaleString()} > ${limit.toLocaleString()})`,
4668
4691
  pauseSuggestedAction: "/glla tokenlimit=<n> to raise the cap (or 0 to disable), then /goal resume",
4669
4692
  }, ctx);
@@ -4687,6 +4710,8 @@ export default function (pi: ExtensionAPI): void {
4687
4710
  const reason = `5 consecutive errors${detail}`;
4688
4711
  updateGoal({
4689
4712
  status: "paused",
4713
+ pauseKind: "wait",
4714
+ pauseResumeAt: new Date(Date.now() + 60_000).toISOString(),
4690
4715
  pauseReason: reason,
4691
4716
  pauseSuggestedAction: "Transient provider flake? The goal auto-resumes once in 60s if still paused for this reason — or /goal resume now.",
4692
4717
  }, ctx);
@@ -4714,6 +4739,7 @@ export default function (pi: ExtensionAPI): void {
4714
4739
  if (consecutiveAbortIterations >= 5) {
4715
4740
  updateGoal({
4716
4741
  status: "paused",
4742
+ pauseKind: "blocked",
4717
4743
  pauseReason: "5 consecutive aborts (user interrupted)",
4718
4744
  pauseSuggestedAction: "You interrupted 5 turns in a row — the goal stays paused until you /goal resume (or /goal cancel).",
4719
4745
  }, ctx);
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.22",
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" },