pi-goal-list-loop-audit 0.28.20 → 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.
package/README.md CHANGED
@@ -203,7 +203,7 @@ No external watchdog plugin needed.
203
203
  /glla tokenlimit=10000000 # per-goal token budget (default: off) → GLOBAL
204
204
  /glla tokenlimit=0 # explicitly no cap (the default)
205
205
  /glla wedgealert=30 # hung-command alert minutes (default: 30, 0 = off)
206
- /glla autoresume=on # auto-resume goals/loops on session LOAD too (default: hold on load, auto-resume on reload/fork; off: never)
206
+ /glla autoresume=on # auto-resume goals/loops on ANY session start (default: load HELD, never auto-start — explicit /goal resume, /list resume, or /loop; off: never)
207
207
  /glla auditcap=5 # pause the goal after N consecutive auditor disapprovals (default 5, 0 = unlimited)
208
208
  /glla aggressivemode=on # keep-going defaults: autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs
209
209
  /glla quotaretryminutes=60 # minutes before auto-retrying a quota-exhausted auditor
@@ -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
@@ -728,21 +738,21 @@ export function cloneGoal(goal: Goal): Goal {
728
738
  * setting for unattended restarts). One mechanical predicate; no heuristics.
729
739
  */
730
740
  export function shouldAutoResumeOnSessionStart(reason: string | undefined, autoResume: boolean | undefined): boolean {
731
- // v0.26.9 tri-state:
732
- // true → auto-resume on EVERY session start (unattended rigs).
741
+ // v0.28.21: the DEFAULT flipped to hold-everything (user directive:
742
+ // "load it on session load but not auto start it"). Tri-state:
743
+ // true → auto-resume on EVERY session start (unattended rigs;
744
+ // /glla autoresume=on — this is the ONLY auto-resume path).
733
745
  // false → never auto-resume; always hold for an explicit resume.
734
- // undefined → DEFAULT: a human LOADING a session ("startup"/"new"/
735
- // "resume", or old pi reporting no reason) must not trigger
736
- // work show the held popup, they resume explicitly.
737
- // In-session MACHINERY ("reload"/"fork") auto-resumes so an
738
- // extension reload or session fork never strands work.
746
+ // undefined → DEFAULT: never auto-resume either whatever the reason
747
+ // ("startup"/"new"/"resume"/"reload"/"fork"/none), the
748
+ // item is LOADED (visible, state intact) but HELD until an
749
+ // explicit /goal resume, /list resume, or /loop.
739
750
  // Mid-session continuation (agent_end chains, heartbeat refires,
740
751
  // post-compaction, list/loop transitions) is not gated here at all — it
741
752
  // auto-continues forever unless a super-stuck brake (stall escalation,
742
753
  // stale-api terminal, pending-latch watchdog) stops it loudly.
743
- if (autoResume === true) return true;
744
- if (autoResume === false) return false;
745
- return reason === "reload" || reason === "fork";
754
+ void reason; // retained for the signature; no reason auto-resumes by default anymore
755
+ return autoResume === true;
746
756
  }
747
757
 
748
758
  /**
@@ -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);
@@ -1194,9 +1196,9 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
1194
1196
  consecutiveNoToolIterations = 0;
1195
1197
  if (staleEntry) {
1196
1198
  // v0.28.1 (S3): the goal is persisted — mark the interrupt so the next
1197
- // fresh session auto-resumes, and tell the truth instead of "starting now".
1199
+ // fresh session LOADS it (held by default since v0.28.21), and tell the truth instead of "starting now".
1198
1200
  updateGoal({ interruptedAt: nowIso(), interruptedReason: "created in a stale session" }, ctx);
1199
- ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations. Restart pi and it auto-resumes. (id: ${goal.id})`, "warning");
1201
+ ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations. Restart pi, then /goal resume (v0.28.21: session loads no longer auto-start by default). (id: ${goal.id})`, "warning");
1200
1202
  return;
1201
1203
  }
1202
1204
  ctx.ui.notify(`Goal started: ${shortObj(goal.objective)} — the auditor will verify on completion. (id: ${goal.id})`, "info");
@@ -1239,6 +1241,13 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
1239
1241
 
1240
1242
  async function cmdResume(ctx: ExtensionContext): Promise<void> {
1241
1243
  if (!state.goal || state.goal.status !== "paused") return;
1244
+ // v0.28.21: one-active-thing — the LAST unguarded activation path. A
1245
+ // paused goal/list-item must not resume over a live loop (covers
1246
+ // /goal resume AND /list resume, which routes here).
1247
+ if (isLoopActive()) {
1248
+ ctx.ui.notify("A loop is active — one active thing at a time. /loop stop it first, then resume the goal.", "warning");
1249
+ return;
1250
+ }
1242
1251
  // v0.28.1 (S1/S3): resuming in a stale session used to flip status to
1243
1252
  // active, claim "Resumed goal", then re-pause on the stale send failure
1244
1253
  // (or zombie — S1). Now: persist the resume (the next fresh session
@@ -1252,7 +1261,7 @@ async function cmdResume(ctx: ExtensionContext): Promise<void> {
1252
1261
  const usage = state.goal.usage
1253
1262
  ? { tokensUsed: state.goal.usage.tokensUsed, tokensLimit: freshLimit }
1254
1263
  : undefined;
1255
- 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);
1256
1265
  if (staleEntry) return;
1257
1266
  // v0.22.5: say what was resumed — with a non-empty list this also resumes
1258
1267
  // the queue (the active goal IS the list's head item).
@@ -2043,10 +2052,10 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2043
2052
  const sub = (parts[0] ?? "").toLowerCase();
2044
2053
  const rest = args.trim().slice(sub.length).trim();
2045
2054
 
2046
- if (!sub) {
2047
- // /loop with no args → resume a held loop if one is waiting; otherwise
2048
- // draft the loop config (metric design is the whole game for a
2049
- // 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).
2050
2059
  if (isLoopActive()) {
2051
2060
  ctx.ui.notify("A loop is already active — /loop status to inspect, /loop stop to end it.", "info");
2052
2061
  return;
@@ -2056,7 +2065,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2056
2065
  // v0.28.14: one-active-thing — a held loop must not resume over an
2057
2066
  // active goal/list-item (this was the last unguarded stacking path).
2058
2067
  if (state.goal && state.goal.status === "active") {
2059
- 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");
2060
2069
  return;
2061
2070
  }
2062
2071
  state.loop = { ...stored, active: true, stopReason: undefined };
@@ -2068,6 +2077,10 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
2068
2077
  );
2069
2078
  return;
2070
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
+ }
2071
2084
  await startDrafting(ctx, "loop");
2072
2085
  return;
2073
2086
  }
@@ -2433,6 +2446,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2433
2446
  updateGoal({
2434
2447
  status: "paused",
2435
2448
  auditHistory: history,
2449
+ pauseKind: "decision",
2436
2450
  pauseReason: `auditor verdict: IMPOSSIBLE — ${reason}`,
2437
2451
  pauseSuggestedAction: "The auditor says this goal can never be satisfied as stated. /goal tweak the objective (or /goal cancel), then /goal resume.",
2438
2452
  }, ctx);
@@ -2465,6 +2479,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2465
2479
  status: "paused",
2466
2480
  auditHistory: history,
2467
2481
  auditInfraStreak: undefined, // quota reached the auditor — infra streak broken
2482
+ pauseKind: "wait",
2483
+ pauseResumeAt: new Date(Date.now() + quota.retryAfterSec * 1000).toISOString(),
2468
2484
  pauseReason: `auditor quota: ${result.error}`,
2469
2485
  pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
2470
2486
  }, ctx);
@@ -2499,6 +2515,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2499
2515
  status: "paused",
2500
2516
  auditHistory: history,
2501
2517
  auditInfraStreak: infraStreak,
2518
+ pauseKind: "error",
2502
2519
  pauseReason: `auditor infrastructure failed ${infraStreak}× in a row — the auditor model is likely broken (last: ${result.error.slice(0, 120)})`,
2503
2520
  pauseSuggestedAction: "Fix the auditor model (/glla model=provider/id) or restart pi, then /goal resume. Your work was NOT judged.",
2504
2521
  }, ctx);
@@ -2605,6 +2622,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2605
2622
  updateGoal({
2606
2623
  status: "paused",
2607
2624
  auditHistory: history,
2625
+ pauseKind: "decision",
2608
2626
  pauseReason: `auditor disapproved ${trailingDisapprovals}× consecutively (cap ${auditCap})`,
2609
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.",
2610
2628
  }, ctx);
@@ -2639,20 +2657,28 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2639
2657
  pi.registerTool(defineTool({
2640
2658
  name: "pause_goal",
2641
2659
  label: "Pause goal",
2642
- 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\".",
2643
2661
  parameters: Type.Object({
2644
2662
  reason: Type.String({ description: "Why the work is paused" }),
2645
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)" })),
2646
2668
  }),
2647
2669
  async execute(_id, params, _signal, _onUpdate, execCtx) {
2648
2670
  const foreign1 = foreignToolGuard(execCtx);
2649
2671
  if (foreign1) return { content: [{ type: "text", text: foreign1 }], details: {} };
2650
- 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 };
2651
2673
  if (!state.goal) return { content: [{ type: "text", text: "No active goal." }], details: {} };
2652
2674
  updateGoal({
2653
2675
  status: "paused",
2654
2676
  pauseReason: p.reason,
2655
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,
2656
2682
  }, ctx);
2657
2683
  // v0.27.1: surface the FULL pause contract — reason AND suggested
2658
2684
  // action. Before, the action only appeared in /goal status and the
@@ -4455,17 +4481,18 @@ export default function (pi: ExtensionAPI): void {
4455
4481
  state.loop = { ...l, active: false, stopReason: HELD_ON_RESTORE };
4456
4482
  persistState(ctx);
4457
4483
  ctx.ui.notify(
4458
- `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.`,
4459
4485
  "info",
4460
4486
  );
4461
4487
  }
4462
4488
  } else if (state.goal && state.goal.status === "active" && state.goal.autoContinue) {
4463
- // v0.28.3 (S2 completed): an infra interrupt outranks the DEFAULT
4464
- // hold — the goal never chose to stop; pi killed its handle. With
4465
- // autoresume unset, an interrupted goal auto-resumes even on a human
4466
- // session load; explicit /glla autoresume=off still holds.
4467
4489
  const wasInterrupted = !!state.goal.interruptedAt;
4468
- if (autoResume || (wasInterrupted && autoResumeSetting !== false)) {
4490
+ // v0.28.21: the 0.28.3 interrupted-goal exemption is SUPERSEDED
4491
+ // the default is now hold-everything on session load (user directive:
4492
+ // "load it but not auto start it"). Interrupted goals hold like
4493
+ // everything else; autoresume=on (unattended rigs) still auto-resumes
4494
+ // them, and the marker is cleared only on that promised auto-resume.
4495
+ if (autoResume) {
4469
4496
  // v0.28.1 (S2): clear the stale-handle interrupt marker — this IS
4470
4497
  // the auto-resume the marker promised.
4471
4498
  if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
@@ -4484,6 +4511,7 @@ export default function (pi: ExtensionAPI): void {
4484
4511
  const resumeHint = `${resumeCmd} to continue${queued > 0 ? ` (+${queued} waiting in the list)` : ""} · /glla autoresume=on to auto-resume in this project`;
4485
4512
  updateGoal({
4486
4513
  status: "paused",
4514
+ pauseKind: "blocked",
4487
4515
  pauseReason: "restored on session load — held for explicit resume",
4488
4516
  pauseSuggestedAction: resumeHint,
4489
4517
  }, ctx);
@@ -4506,6 +4534,23 @@ export default function (pi: ExtensionAPI): void {
4506
4534
  ctx.ui.notify(`List has ${listQueue().length} item(s) waiting — /list next to activate the head.`, "info");
4507
4535
  }
4508
4536
  }
4537
+ // v0.28.21: enforce one-active-thing at the restore boundary for DIRTY
4538
+ // legacy states — pre-guard versions could persist an active goal AND
4539
+ // an active/held loop; the chain above handles the loop first, and the
4540
+ // goal would otherwise stay active and fire on agent_end. Pause it:
4541
+ // at most one thing owns the active slot, and nothing auto-starts.
4542
+ if (state.loop && state.goal && state.goal.status === "active") {
4543
+ updateGoal({
4544
+ status: "paused",
4545
+ pauseKind: "decision",
4546
+ pauseReason: "held on session load — the loop owns the active slot (one active thing at a time)",
4547
+ pauseSuggestedAction: "/loop to work the loop, or /loop stop then /goal resume to work the goal",
4548
+ }, ctx);
4549
+ ctx.ui.notify(
4550
+ `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.`,
4551
+ "info",
4552
+ );
4553
+ }
4509
4554
  // Always paint on session load (v0.22.1): the branches above only reach
4510
4555
  // refreshUI via persistState, so a goal that was ALREADY paused (or any
4511
4556
  // state that doesn't mutate on load) rendered nothing — "can't tell if
@@ -4593,6 +4638,7 @@ export default function (pi: ExtensionAPI): void {
4593
4638
  if (state.goal) {
4594
4639
  updateGoal({
4595
4640
  status: "paused",
4641
+ pauseKind: "decision",
4596
4642
  pauseReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive unproductive turns (no tools, short or repetitive)`,
4597
4643
  pauseSuggestedAction: "Inspect the goal — /goal resume to retry, /goal tweak to narrow it, /goal cancel to abort.",
4598
4644
  }, ctx);
@@ -4640,6 +4686,7 @@ export default function (pi: ExtensionAPI): void {
4640
4686
  updateGoal({
4641
4687
  usage: { tokensUsed: used, tokensLimit: limit },
4642
4688
  status: "paused",
4689
+ pauseKind: "error",
4643
4690
  pauseReason: `token limit exceeded (${used.toLocaleString()} > ${limit.toLocaleString()})`,
4644
4691
  pauseSuggestedAction: "/glla tokenlimit=<n> to raise the cap (or 0 to disable), then /goal resume",
4645
4692
  }, ctx);
@@ -4663,6 +4710,8 @@ export default function (pi: ExtensionAPI): void {
4663
4710
  const reason = `5 consecutive errors${detail}`;
4664
4711
  updateGoal({
4665
4712
  status: "paused",
4713
+ pauseKind: "wait",
4714
+ pauseResumeAt: new Date(Date.now() + 60_000).toISOString(),
4666
4715
  pauseReason: reason,
4667
4716
  pauseSuggestedAction: "Transient provider flake? The goal auto-resumes once in 60s if still paused for this reason — or /goal resume now.",
4668
4717
  }, ctx);
@@ -4690,6 +4739,7 @@ export default function (pi: ExtensionAPI): void {
4690
4739
  if (consecutiveAbortIterations >= 5) {
4691
4740
  updateGoal({
4692
4741
  status: "paused",
4742
+ pauseKind: "blocked",
4693
4743
  pauseReason: "5 consecutive aborts (user interrupted)",
4694
4744
  pauseSuggestedAction: "You interrupted 5 turns in a row — the goal stays paused until you /goal resume (or /goal cancel).",
4695
4745
  }, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.20",
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" },