pi-goal-list-loop-audit 0.35.13 → 0.35.32

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +366 -0
  2. package/INSTALL.md +3 -3
  3. package/LIST-PHILOSOPHY.md +90 -0
  4. package/PLAN.md +316 -0
  5. package/README.md +108 -13
  6. package/docs/DESIGN-subagent-visibility.md +76 -0
  7. package/docs/DESIGN.md +59 -8
  8. package/docs/GLLA-POSITIONING-AND-DECOMPOSITION-2026-08-08.md +6 -1
  9. package/docs/INDEX.md +4 -0
  10. package/docs/VISION-ASSIST.md +2 -2
  11. package/extensions/auditor-extensions.ts +289 -0
  12. package/extensions/goal-agents-panel.ts +185 -0
  13. package/extensions/goal-commands.ts +120 -6
  14. package/extensions/goal-continuation.ts +45 -2
  15. package/extensions/goal-heartbeat.ts +176 -5
  16. package/extensions/goal-loop-auditor-process.ts +26 -0
  17. package/extensions/goal-loop-auditor.ts +3 -5
  18. package/extensions/goal-loop-backoff.ts +79 -0
  19. package/extensions/goal-loop-core.ts +97 -4
  20. package/extensions/goal-loop-display.ts +107 -8
  21. package/extensions/goal-loop-forever.ts +39 -1
  22. package/extensions/goal-loop-shield.ts +114 -25
  23. package/extensions/goal-loop.ts +66 -1
  24. package/extensions/goal-recovery.ts +8 -2
  25. package/extensions/goal-settings.ts +23 -0
  26. package/extensions/goal-state.ts +11 -0
  27. package/extensions/loops/goal-activation.ts +226 -25
  28. package/extensions/loops/goal-auditor-hooks.ts +39 -3
  29. package/extensions/loops/goal-list-queue.ts +17 -1
  30. package/extensions/loops/goal-orchestrator.ts +28 -1
  31. package/extensions/loops/goal-settings-ui.ts +96 -5
  32. package/extensions/loops/goal-tools.ts +82 -28
  33. package/extensions/loops/goal-ui.ts +105 -1
  34. package/extensions/loops/goal.ts +7 -7
  35. package/extensions/multi-model-picker.ts +45 -7
  36. package/extensions/settings-menu.ts +10 -0
  37. package/package.json +8 -3
  38. package/schemas/goal.schema.json +2 -0
  39. package/scripts/goal-auditor-launch.mjs +19 -1
  40. package/scripts/goal-auditor-worker.mjs +15 -0
  41. package/scripts/verify-auditor-extensions-offline.mjs +59 -0
@@ -54,6 +54,7 @@ import {
54
54
  extractPendingTasks,
55
55
  isFullAuditObjective,
56
56
  resolveEffectiveAggressiveSettings,
57
+ supervisorPaused,
57
58
  appendAuditLog,
58
59
  computeListDepth,
59
60
  formatAuditLog,
@@ -258,6 +259,7 @@ import {
258
259
  import { consumeRecoveryResume } from "../goal-recovery.js"; // decomposition step 3 (v0.34.111)
259
260
  import {
260
261
  createGoalHeartbeat,
262
+ releaseZombieAbortKey,
261
263
  endSubagentHangProbe,
262
264
  markSubagentHangProgress,
263
265
  startHeartbeat,
@@ -339,6 +341,9 @@ import {
339
341
  PENDING_LATCH_STUCK_MS,
340
342
  shouldFirePendingLatchWatchdog,
341
343
  AUDITOR_WALL_TIMEOUT_MS,
344
+ ZOMBIE_RETRY_DELAY_MS,
345
+ zombieRetryDecision,
346
+ type ZombieRetryStreak,
342
347
  } from "../goal-loop-backoff.js";
343
348
 
344
349
  import {
@@ -351,6 +356,7 @@ import {
351
356
  cmdSettings,
352
357
  createGoalCommands,
353
358
  enqueueItems,
359
+ hydrateListQueueFromDisk,
354
360
  maybeDecisionPopup,
355
361
  probeAutoNotify,
356
362
  recentlyCompletedObjectives,
@@ -411,12 +417,137 @@ export function enqueueFaultRepairTask(ctx: ExtensionContext, objective: string,
411
417
  appendLedger(ctx.cwd, "faulty_objective_repair_promoted", { goalId: repair.id, targetId: target?.id, position: 1, source: target?.source ?? "unknown" });
412
418
  }
413
419
 
414
- /** v0.35.x: terminate one confirmed zero-stream host turn and park the
415
- * owning goal/list item before asking pi to abort. This is deliberately an
416
- * activation-owned operation: it can clear the durable continuation sidecar,
417
- * stand down re-arms, and validate the current generation before touching the
418
- * host. A later /goal or /list resume explicitly clears the stand-down and
419
- * starts a new attempt; no callback here schedules a blind retry. */
420
+ /** v0.35.17 — bounded automatic retry after a confirmed zero-stream abort.
421
+ *
422
+ * Field evidence (note.md Next §1, screenshot 20260821_152311): turns
423
+ * dispatched by ACCEPTING a Confirm dialog hang with zero provider stream
424
+ * activity often enough that users repeatedly return to "Goal paused after
425
+ * 30m ... action needed - this won't fix itself" sessions. The watchdog's
426
+ * abort is correct (it stops the token bleed); what was missing is the
427
+ * self-heal half. This adds ONE bounded automatic re-dispatch per silent
428
+ * streak, mirroring main-model recovery's timer pattern:
429
+ *
430
+ * - exactly ONE automatic retry per zero-stream STREAK: a retried turn
431
+ * that actually streams advances lastStreamActivityAt past the previous
432
+ * abort's observation point, so any LATER independent hang starts a
433
+ * fresh streak and earns its own single retry;
434
+ * - a SECOND consecutive silence (retry hung too, or the original turn
435
+ * never streamed again) parks permanently — no retry storm;
436
+ * - `/glla pause` freezes the retry like every other automatic
437
+ * side-effect: the parked claim stays durable for manual resume;
438
+ * - manual /goal resume · /loop resume remain available at all times.
439
+ *
440
+ * The streak counter is process-memory by design: if the session restarts
441
+ * inside the retry window the park stands and manual resume applies — an
442
+ * honest degradation, not a phantom promise.
443
+ */
444
+ const ZOMBIE_PAUSE_REASON = "automatic zero-stream abort — no provider activity was observed";
445
+
446
+ // Streak state is process-memory by design (see the block comment above
447
+ // scheduleZombieAutoRetry): a session restart inside the retry window leaves
448
+ // the park standing for manual resume — an honest degradation.
449
+ let zombieRetryStreak: ZombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
450
+ let zombieRetryTimer: NodeJS.Timeout | null = null;
451
+
452
+ /** Arm the one-shot automatic re-dispatch after a successful zombie abort.
453
+ * Returns true when a retry was scheduled (the caller adjusts its user-facing
454
+ * copy accordingly). */
455
+ function scheduleZombieAutoRetry(
456
+ ctx: ExtensionContext,
457
+ generation: number,
458
+ goalId: string | undefined,
459
+ observedStreamAt: number,
460
+ silentMinutes: number,
461
+ ): boolean {
462
+ const key = goalId ?? "loop";
463
+ const { retry, streak } = zombieRetryDecision(observedStreamAt, key, zombieRetryStreak);
464
+ zombieRetryStreak = streak;
465
+ appendLedger(ctx.cwd, retry ? "zombie_auto_retry_scheduled" : "zombie_auto_retry_refused_streak", {
466
+ goalId,
467
+ streakCount: streak.count,
468
+ delayMs: zombieRetryDelayOverride ?? ZOMBIE_RETRY_DELAY_MS,
469
+ });
470
+ if (!retry) return false;
471
+ if (zombieRetryTimer) {
472
+ clearTimeout(zombieRetryTimer);
473
+ zombieRetryTimer = null;
474
+ }
475
+ // scheduleSessionTimeout carries the generation/stale/zombie fences — an
476
+ // old session's callback can never re-arm work after stale/shutdown/reload.
477
+ zombieRetryTimer = scheduleSessionTimeout(() => {
478
+ zombieRetryTimer = null;
479
+ // v0.35.15 semantics: a frozen supervisor keeps everything automatic off.
480
+ // The parked claim stays durable; /glla resume then /goal resume apply.
481
+ if (supervisorPaused(state)) return;
482
+ const fresh = freshCtxForGeneration(generation);
483
+ if (!fresh) return;
484
+ const goal = state.goal;
485
+ if (goalId !== undefined || goal) {
486
+ if (!goal || goal.status !== "paused" || goal.id !== goalId) return;
487
+ if (goal.pauseReason !== ZOMBIE_PAUSE_REASON) return; // superseded pause — not ours to clear
488
+ const freshLimit = loadSettings(fresh.cwd).tokenLimit ?? DEFAULT_TOKEN_LIMIT;
489
+ const usage = goal.usage ? { tokensUsed: goal.usage.tokensUsed, tokensLimit: freshLimit } : undefined;
490
+ abortedStandDown = false;
491
+ setContinuationDispatchStoodDownRef(false);
492
+ updateGoal({
493
+ status: "active",
494
+ pauseKind: undefined,
495
+ pauseReason: undefined,
496
+ pauseSuggestedAction: undefined,
497
+ ...(usage ? { usage } : {}),
498
+ }, fresh);
499
+ appendLedger(fresh.cwd, "zombie_auto_retry_dispatched", { goalId: goal.id, policy: goal.policy });
500
+ releaseZombieAbortKey();
501
+ try {
502
+ fresh.ui.notify(`Automatic retry starting — the earlier ${goal.policy === "list" ? "list item" : "goal"} turn was aborted after ${silentMinutes}m of zero stream activity. One bounded retry is dispatched now; if it hangs again the ${goal.policy === "list" ? "item" : "goal"} stays paused for ${activeGoalSurfaceCommand("resume")}.`, "info");
503
+ } catch { /* best-effort */ }
504
+ scheduleContinuation(fresh, true);
505
+ return;
506
+ }
507
+ // Loop owner: revive the preserved iteration and tick once.
508
+ const loop = state.loop;
509
+ if (!loop || !loop.stopReason?.includes("zero-stream abort")) return;
510
+ abortedStandDown = false;
511
+ setContinuationDispatchStoodDownRef(false);
512
+ loop.active = true;
513
+ loop.stopReason = undefined;
514
+ persistState(fresh);
515
+ appendLedger(fresh.cwd, "zombie_auto_retry_dispatched", { kind: "loop", iteration: loop.iteration });
516
+ releaseZombieAbortKey();
517
+ try {
518
+ fresh.ui.notify(`Automatic retry starting — the loop was stopped after a zero-stream abort. One bounded retry ticks now; if it hangs again it stays stopped for /loop resume.`, "info");
519
+ } catch { /* best-effort */ }
520
+ scheduleLoopTick(fresh);
521
+ }, zombieRetryDelayOverride ?? ZOMBIE_RETRY_DELAY_MS);
522
+ return true;
523
+ }
524
+
525
+ /** Test-only: reset the module-global retry streak/timer state. */
526
+ export function __testOnlyResetZombieAutoRetry(): void {
527
+ if (zombieRetryTimer) {
528
+ clearTimeout(zombieRetryTimer);
529
+ zombieRetryTimer = null;
530
+ }
531
+ zombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
532
+ }
533
+
534
+ let zombieRetryDelayOverride: number | null = null;
535
+
536
+ /** Test-only: shrink the automatic-retry delay (null restores the 90s
537
+ * production default). Never called by production code. */
538
+ export function __testOnlySetZombieRetryDelay(ms: number | null): void {
539
+ zombieRetryDelayOverride = ms;
540
+ }
541
+
542
+ /** v0.35.x (amended v0.35.17): terminate one confirmed zero-stream host turn
543
+ * and park the owning goal/list item before asking pi to abort. This is
544
+ * deliberately an activation-owned operation: it can clear the durable
545
+ * continuation sidecar, stand down re-arms, and validate the current
546
+ * generation before touching the host. A later /goal or /list resume
547
+ * explicitly clears the stand-down and starts a new attempt — and since
548
+ * v0.35.17, the FIRST silence of a streak additionally arms ONE bounded
549
+ * automatic retry (scheduleZombieAutoRetry below); only a second consecutive
550
+ * zero-stream abort leaves the park purely manual. */
420
551
  export function abortZombieRun(
421
552
  ctx: ExtensionContext,
422
553
  generation: number,
@@ -441,7 +572,7 @@ export function abortZombieRun(
441
572
  setContinuationDispatchStoodDownRef(true);
442
573
  abortedStandDown = true;
443
574
  const silentMinutes = Math.max(1, Math.round((Date.now() - observedStreamAt) / 60_000));
444
- const reason = "automatic zero-stream abort — no provider activity was observed";
575
+ const reason = ZOMBIE_PAUSE_REASON;
445
576
  if (goal) {
446
577
  const noun = goal.policy === "list" ? "list item" : "goal";
447
578
  updateGoal({
@@ -473,7 +604,15 @@ export function abortZombieRun(
473
604
  });
474
605
  const resume = goal ? activeGoalSurfaceCommand("resume") : "/loop resume";
475
606
  const cancel = goal ? activeGoalSurfaceCommand("cancel") : "/loop stop";
476
- const message = `${goal ? goal.policy === "list" ? "List item" : "Goal" : "Loop"} paused after ${silentMinutes}m with zero stream activity; the zombie turn was aborted and queued retries were stopped. Work is saved. ${resume} retries it, or ${cancel} discards/stops it.`;
607
+ // v0.35.17: ONE bounded automatic retry per zero-stream streak — the park
608
+ // becomes a 90-second waystation instead of a dead end for the first
609
+ // silence. A refused streak (second consecutive hang) keeps the manual copy.
610
+ const retryScheduled = scheduleZombieAutoRetry(current, generation, goal?.id, observedStreamAt, silentMinutes);
611
+ const message = `${goal ? goal.policy === "list" ? "List item" : "Goal" : "Loop"} paused after ${silentMinutes}m with zero stream activity; the zombie turn was aborted and queued retries were stopped. Work is saved. ${
612
+ retryScheduled
613
+ ? `An automatic retry starts in ~90s; ${resume} retries immediately instead.`
614
+ : `${resume} retries it, or ${cancel} discards/stops it.`
615
+ }`;
477
616
  current.ui.notify(message, abortError ? "warning" : "info");
478
617
  notifyExternal(current, message);
479
618
  return true;
@@ -520,18 +659,20 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
520
659
  });
521
660
  const settingsHandler = (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdSettings(args, ctx); };
522
661
  pi.registerCommand("glla", {
523
- description: "Open the settings UI for goals, loops, lists, and the auditor. `/glla version` shows the installed package version; `/glla cancel` cancels the active objective; `/glla wipe` Confirm-gatedly clears all live state while preserving history.",
662
+ description: "Open the settings UI for goals, loops, lists, and the auditor. `/glla version` shows the installed package version; `/glla pause` freezes the supervisor's automatic machinery (re-arms/recovery/auto-resume/dispatch) without touching active work; `/glla cancel` cancels the active objective; `/glla wipe` Confirm-gatedly clears all live state while preserving history.",
524
663
  getArgumentCompletions: completions([
525
664
  // Operational verbs only. Settings and section navigation belong in
526
665
  // the bare `/glla` table, so they do not compete with action completion.
527
666
  ["version", "show the installed package version and registry check"],
528
667
  ["status", "show goal, list, loop, and pending decisions"],
529
668
  ["log", "show the recent event trail"],
530
- ["resume", "resume the paused or held live thing"],
669
+ ["resume", "resume the paused or held live thing; also unfreezes a /glla pause"],
670
+ ["pause", "freeze ALL supervisor automation (re-arms/recovery/dispatch) — active work keeps running"],
531
671
  ["cancel", "cancel the active objective (list item + queue)"],
532
672
  ["wipe", "Confirm-gated idempotent reset of all live goal/list/loop state"],
533
673
  ["stats", "show per-project ledger rollups"],
534
674
  ["audits", "browse the audit log"],
675
+ ["agents", "show tracked subagents; --tail <id> reads a child transcript"],
535
676
  ["switchlog", "show the model-switch trail (model_switch / forbidden_model_switch)"],
536
677
  ["tooloverride", "configure agent-tool visibility"],
537
678
  ]),
@@ -889,6 +1030,9 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
889
1030
  });
890
1031
  }
891
1032
  extensionApi = pi;
1033
+ // Session-scoped resources are reset only after this context has passed
1034
+ // the host-admission gate; child factories never touch host state.
1035
+ resetLengthContinue();
892
1036
  sessionHandoffPending = false;
893
1037
  // Reset terminal ownership before rememberCtx: this is the only event
894
1038
  // allowed to bind a context after a stale/shutdown handoff.
@@ -936,6 +1080,18 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
936
1080
  }
937
1081
  }
938
1082
  replaceState(readState(ctx.cwd));
1083
+ // v0.35.21 (list-invisible-until-restart): the durable queue is the
1084
+ // UNION of the persisted state and the per-item .queue.json sidecars
1085
+ // (v0.34.60 disk-first writes). readState replays only the state
1086
+ // ledger, so a plugin re-init / stale-handle window that reset RAM to
1087
+ // defaults used to leave the sidebar queue blank — active item only,
1088
+ // no "N waiting · up next" line — until a full restart re-ran the
1089
+ // disk merge on some other path. Converge memory to the union at EVERY
1090
+ // lifecycle restore so the rendered surface is truthful immediately.
1091
+ const hydratedFromDisk = hydrateListQueueFromDisk(ctx);
1092
+ if (hydratedFromDisk > 0) {
1093
+ ctx.ui.notify(`glla: restored ${hydratedFromDisk} queued list item(s) from durable sidecars.`, "info");
1094
+ }
939
1095
  // v0.35.x: a fresh session_start is the coalesced recovery boundary for
940
1096
  // a silent host death. Consume the one-shot arm after durable state is
941
1097
  // reloaded so the marker is cleared in the new context, and let the
@@ -1066,11 +1222,13 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1066
1222
  const rebindResume = ownerClaim.rebind;
1067
1223
  if (rebindResume) appendLedger(ctx.cwd, "rebind_resume", { pid: process.pid });
1068
1224
  const explicitRecovery = handoffResume || recoveryResume || rebindResume;
1069
- // A successor after a non-quit shutdown is lifecycle consent for a held
1070
- // loop even when the old inactive loop did not qualify for a handoff
1071
- // marker. Keep this loop-specific; standalone goals retain their normal
1072
- // cold-start hold policy.
1073
- const loopSuccessorResume = explicitRecovery || nonQuitShutdownResume;
1225
+ // v0.35.23 (note.md Next #2): a non-quit shutdown successor no longer
1226
+ // consents to loop auto-resume by itself — a crash restart IS a cold
1227
+ // load, and cold loads hold for an explicit decision. The SAME-PROCESS
1228
+ // successor case stays consented: pi replaced its session without dying
1229
+ // (the /reload family), so the held work is mid-flight continuity, not
1230
+ // a cold start. Different-pid successors hold like any cold load.
1231
+ const sameProcessSuccessorResume = nonQuitShutdownResume && ownerClaim.previousPid === process.pid;
1074
1232
  const heldLoopSuccessorResume = !!state.loop
1075
1233
  && !state.loop.active
1076
1234
  && isLifecycleHeldLoopReason(state.loop.stopReason)
@@ -1120,7 +1278,15 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1120
1278
  // autoResume keys are ignored. Once running, the chain auto-continues
1121
1279
  // forever unless a super-stuck brake (stall escalation / stale-api /
1122
1280
  // latch) stops it loudly.
1123
- const autoResumeSetting = resolveEffectiveAggressiveSettings(loadGlobalSettings()).autoResume;
1281
+ // v0.35.23 (note.md Next #2): auto-resume consent is now the RAW global
1282
+ // setting, not the aggressive-mode coercion. resolveEffectiveAggressive
1283
+ // Settings coerced unset→true (aggressiveMode defaults on), which made
1284
+ // stock installs AUTO-RESUME everything on every load — defeating the
1285
+ // documented v0.28.21 tri-state whose undefined default is HOLD, and
1286
+ // the pinned settings-menu copy ("default: hold on EVERY load"). The
1287
+ // aggressive dial still owns its caps; only explicit `autoResume: true`
1288
+ // consents to load-time automation now.
1289
+ const autoResumeSetting = loadGlobalSettings().autoResume;
1124
1290
  const autoResume = shouldAutoResumeOnSessionStart(event?.reason, autoResumeSetting);
1125
1291
  const mainRecovery = state.mainModelRecovery;
1126
1292
  if (mainRecovery?.manualResumeRequired) {
@@ -1218,13 +1384,13 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1218
1384
  // stuck, and zero-stream abort reasons are intentionally excluded by the
1219
1385
  // shared predicate and remain explicitly resumable only.
1220
1386
  if (state.loop && !state.loop.active && isLifecycleHeldLoopReason(state.loop.stopReason)
1221
- && (autoResume || loopSuccessorResume)) {
1387
+ && (autoResume || explicitRecovery || sameProcessSuccessorResume)) {
1222
1388
  const held = state.loop;
1223
1389
  state.loop = { ...held, active: true, stopReason: undefined };
1224
1390
  persistState(ctx);
1225
1391
  releaseContinuationDispatchStandDown();
1226
1392
  appendLedger(ctx.cwd, "loop_auto_resumed_on_restore", {
1227
- via: loopSuccessorResume ? "successor" : "auto-resume",
1393
+ via: recoveryResume ? "recovery" : rebindResume ? "rebind" : handoffResume ? "handoff" : sameProcessSuccessorResume ? "same-process-successor" : "auto-resume",
1228
1394
  iteration: held.iteration,
1229
1395
  best: held.bestValue,
1230
1396
  });
@@ -1261,7 +1427,12 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1261
1427
  // rebind marker were consumed before the startup barrier above.
1262
1428
  if (isLoopActive()) {
1263
1429
  const l = state.loop!;
1264
- if (autoResume || loopSuccessorResume) {
1430
+ // v0.35.23: a different-pid crash successor no longer consents by
1431
+ // itself — a crash restart IS a cold load, and cold loads hold for an
1432
+ // explicit decision (note.md Next #2). Validated handoffs/rebinds,
1433
+ // SAME-PROCESS successors, and the Auto-resume setting keep today's
1434
+ // continuity.
1435
+ if (autoResume || explicitRecovery || sameProcessSuccessorResume) {
1265
1436
  ctx.ui.notify(
1266
1437
  `Resuming loop (iteration ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"}, best ${l.bestValue ?? "n/a"}, stall ${l.stallCount}/${l.plateauWindow}): ${displaySlice(l.target, 60)}`,
1267
1438
  "info",
@@ -1359,12 +1530,12 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1359
1530
  markCompletionAuditRecoveryPending(ctx, `session_start:${startReason}`);
1360
1531
  }
1361
1532
  const recoveredClaim = state.goal?.pendingCompletion;
1362
- // A timeout/no-verdict claim may already own one durable recovery slot.
1363
- // A validated host lifecycle can consume that slot even when global
1364
- // Auto-resume is off; ordinary cold startup still holds legacy/manual
1365
- // parked claims for explicit consent.
1366
- const scheduledRecovery = !!recoveredClaim?.recoveryRetryAt;
1367
- const canRecoverNow = explicitRecovery || autoResume || (hostLifecycleStart && scheduledRecovery);
1533
+ // v0.35.23 (note.md Next #2): the old third disjunct — a validated
1534
+ // host lifecycle consuming the durable retry slot even without
1535
+ // Auto-resume — dispatched automation on a cold load. Removed: parked
1536
+ // claims hold for explicit consent like every other pending state;
1537
+ // the else branch below defers them with a truthful ledger event.
1538
+ const canRecoverNow = explicitRecovery || autoResume;
1368
1539
  if (canRecoverNow && recoveredClaim && (recoveredClaim.phase ?? "recovery-pending") === "recovery-pending") {
1369
1540
  completionAuditRecoveryArmed = true;
1370
1541
  const started = maybeAutoRetryParkedCompletionAudit(hostLifecycleStart || explicitRecovery ? "host-rebind" : "session-start");
@@ -1406,6 +1577,36 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1406
1577
  // state that doesn't mutate on load) rendered nothing — "can't tell if
1407
1578
  // it's on" is a bug. Painting unconditionally also clears/refreshes any
1408
1579
  // stale widget carried over from a previous in-process session.
1580
+ // v0.35.23 (note.md Next #2): a consent-less cold load with pending
1581
+ // durable state engages the LOAD HOLD — same freeze gates as /glla
1582
+ // pause (continuation dispatch, loop ticks, heartbeat probes, recovery
1583
+ // timers all check supervisorPaused()), but DISTINCT from it: released
1584
+ // by any explicit work command, never by timers. State stays fully
1585
+ // restored and DISPLAYED — only automation waits for the user.
1586
+ if (!autoResume && !explicitRecovery && !staleRearmedOnSessionStart) {
1587
+ const somethingLive = (!!state.goal && ["active", "auditing"].includes(state.goal.status)) || isLoopActive();
1588
+ const pendingDurableState = !!state.goal || (state.list?.length ?? 0) > 0 || !!state.loop;
1589
+ // v0.35.23: restore itself may have consented to live work (journal
1590
+ // replay activating a deferred item, stale rearm) — a hold must never
1591
+ // freeze what restore deliberately started.
1592
+ if (pendingDurableState && !somethingLive && typeof state.loadHoldAt !== "number") {
1593
+ replaceState({ ...state, loadHoldAt: Date.now() });
1594
+ persistState(ctx);
1595
+ appendLedger(ctx.cwd, "load_hold_engaged", { reason: startReason ?? "startup" });
1596
+ ctx.ui.notify(
1597
+ "Loaded without starting: your goal/list/loop state is restored and shown below, but automation is HELD for your decision. /goal resume, /list resume, or /list next starts work; enable Auto-resume in /glla settings to restore load-time automation.",
1598
+ "warning",
1599
+ );
1600
+ }
1601
+ } else if ((autoResume || explicitRecovery) && typeof state.loadHoldAt === "number") {
1602
+ // v0.35.28 (issue #16): a hold persisted by a PREVIOUS process must
1603
+ // not outlive the user's consent — with auto-resume on, a stale
1604
+ // hold would freeze supervisorPaused()-gated machinery (including
1605
+ // the due-wait backstop) until a manual resume that may never come.
1606
+ replaceState({ ...state, loadHoldAt: undefined });
1607
+ persistState(ctx);
1608
+ appendLedger(ctx.cwd, "load_hold_released", { via: "consenting-reload" });
1609
+ }
1409
1610
  refreshUI(ctx);
1410
1611
  });
1411
1612
 
@@ -114,6 +114,8 @@ import {
114
114
  isForbiddenModel,
115
115
  isGoalRevisionCurrent,
116
116
  nextHourlyProbeMs,
117
+ supervisorPaused,
118
+ loadHoldActive,
117
119
  type ModelSwitchRecord,
118
120
  type ListItem,
119
121
  } from "../goal-loop-core.js";
@@ -715,6 +717,12 @@ export type AutomaticCompletionRecoveryTrigger = "session-start" | "host-rebind"
715
717
  * is not eligible for another automatic event-triggered attempt.
716
718
  */
717
719
  export function maybeAutoRetryParkedCompletionAudit(trigger: AutomaticCompletionRecoveryTrigger): boolean {
720
+ // v0.35.23 (note.md Next #2): a plain LOAD HOLD stops every automatic
721
+ // recovery trigger — pending claims wait for an explicit decision. The
722
+ // single exempt is "main-model-recovery": the claim was parked BY the
723
+ // provider failure, and the recovery ladder healing IS the pinned
724
+ // one-shot consent. Manual /glla pause freezes all triggers regardless.
725
+ if (loadHoldActive(state) && trigger !== "main-model-recovery") return false;
718
726
  const goal = state.goal;
719
727
  const claim = goal?.pendingCompletion;
720
728
  if (!goal || goal.status !== "paused" || !claim) return false;
@@ -749,7 +757,7 @@ export function maybeAutoRetryParkedCompletionAudit(trigger: AutomaticCompletion
749
757
  // beginCompletionAudit runs before retryStoredCompletionAudit reaches its
750
758
  // first await, atomically replacing the parked claim with a running claim
751
759
  // and consuming the durable one-shot marker.
752
- void retryStoredCompletionAudit("session-recovery");
760
+ void retryStoredCompletionAudit("session-recovery", trigger === "main-model-recovery");
753
761
  return true;
754
762
  }
755
763
 
@@ -810,7 +818,17 @@ export async function runDetachedCompletionWithFallback(
810
818
  * impossible, semantic infra) → preserve the claim and pause for explicit `/goal resume`, while
811
819
  * semantic verdicts remain durable in auditHistory.
812
820
  */
813
- async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provider-retry"): Promise<void> {
821
+ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provider-retry", exemptLoadHold = false): Promise<void> {
822
+ // v0.35.15: `/glla pause` freezes automatic audit recovery — provider
823
+ // retries and session-recovery re-starts stay parked while the supervisor
824
+ // is paused. Explicit manual requests (`/goal resume`, `/goal verify`)
825
+ // still run: the user typed them, so they are not "automatic machinery".
826
+ // v0.35.23: exemptLoadHold carves out ONE narrow path from the automatic
827
+ // LOAD HOLD (never from a manual /glla pause): a main-model-recovery
828
+ // triggered retry. The claim was parked by provider infrastructure
829
+ // failure; the recovery ladder healing the provider IS the durable
830
+ // consent for its single retry (pinned v0.35.x).
831
+ if (origin !== "manual" && supervisorPaused(state) && !exemptLoadHold) return;
814
832
  const goal = state.goal;
815
833
  if (!goal?.pendingCompletion) return;
816
834
  const goalId = goal.id;
@@ -878,6 +896,10 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
878
896
  verificationSummary: claim.verificationSummary,
879
897
  model: candidate.model,
880
898
  thinkingLevel: (settings.auditorThinkingLevel ?? "high") as any, // may be "max" — pi ≥0.83 understands it; the dev-types predate it
899
+ // v0.36.0: raw settings allowlist; the process layer resolves
900
+ // entries to install paths before hashing (see
901
+ // goal-loop-auditor-process.ts).
902
+ allowedExtensions: settings.auditorAllowedExtensions,
881
903
  runtime: { attemptId: () => newDetachedAuditJobAttemptId(claim.attemptId!), logicalAttemptId: claim.attemptId!, wallTimeoutMs: AUDITOR_WALL_TIMEOUT_MS },
882
904
  onProgress: (progress) => {
883
905
  publishDetachedAuditProgress(generation, goalId, claim.attemptId!, progress);
@@ -1030,7 +1052,21 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
1030
1052
  : state.goal.objective;
1031
1053
  const recap = displaySlice(recapSrc, 110);
1032
1054
  const approvalVia = `${origin === "manual" ? " on /goal verify" : origin === "session-recovery" ? " after session recovery" : " on the provider retry"}${fallbackUsed ? " after an auditor-model fallback" : ""}`;
1033
- archiveCurrentGoal(liveCtx, "complete", `auditor ${result.model} approved (${origin})`);
1055
+ const archived = archiveCurrentGoal(liveCtx, "complete", `auditor ${result.model} approved (${origin})`);
1056
+ if (!archived) {
1057
+ // archiveCurrentGoal already preserved the live record and warned the
1058
+ // user. Keep the approved claim recoverable, but never emit a terminal
1059
+ // success after the durable archive failed.
1060
+ updateGoal({
1061
+ status: "paused",
1062
+ pendingCompletion: undefined,
1063
+ pauseKind: "blocked",
1064
+ pauseReason: "completion approved but terminal archive persistence failed",
1065
+ pauseSuggestedAction: `Fix .pi-glla disk access or resolve the archive fence, then ${activeGoalSurfaceCommand("resume")} and call complete_goal again.`,
1066
+ }, liveCtx);
1067
+ appendLedger(liveCtx.cwd, "goal_archive_failed_after_approval", { goalId, attemptId: claim.attemptId, origin });
1068
+ return;
1069
+ }
1034
1070
  liveCtx.ui.notify(`✓ done: ${recap} — auditor ${result.model} approved${approvalVia}.`, "info");
1035
1071
  notifyExternal(liveCtx, `Goal complete (auditor approved, ${origin}): ${displaySlice(recapSrc, 120)}`);
1036
1072
  return;
@@ -645,7 +645,23 @@ function activateNextListItem(ctx: ExtensionContext, n = 1, opts?: { explicit?:
645
645
  // completion cascade, /list next, list_activate, list-draft auto-activate)
646
646
  // may activate a list item over a live loop, present or future.
647
647
  if (isLoopActive()) {
648
- appendLedger(ctx.cwd, "list_activation_blocked_loop", {});
648
+ // v0.35.22 (note.md Next #3, field 2026-08-21): this refusal used to be
649
+ // LEDGER-ONLY — a queued repair card behind a paused suspicious goal
650
+ // was unstartable AND invisibly so (screenshots 134442/134645: the user
651
+ // is told to run /list next, it silently no-ops while a loop holds the
652
+ // surface). One-active-thing still refuses, but LOUDLY and with the way
653
+ // out; the loop-end cascade (announceQueuedListAfterLoopEnd) surfaces
654
+ // that the item is startable again once the loop ends.
655
+ const head = listQueue()[0];
656
+ appendLedger(ctx.cwd, "list_activation_blocked_loop", {
657
+ ...(head ? { queueItemId: head.id, objective: head.objective.slice(0, 200) } : {}),
658
+ });
659
+ ctx.ui.notify(
660
+ head
661
+ ? `The queued item cannot start while a loop owns the surface — "${head.objective.slice(0, 80)}" stays queued. /loop stop the loop (or let its bounds end it), then /list next starts the item.`
662
+ : "A loop is active — one active thing at a time. /loop stop it first, then /list next.",
663
+ "warning",
664
+ );
649
665
  return false;
650
666
  }
651
667
  // v0.28.14: carryover resolution runs BEFORE the item is taken — under
@@ -109,6 +109,7 @@ import {
109
109
  deleteQueueItemFile,
110
110
  missingGllaTools,
111
111
  runPersistStep,
112
+ isSafePersistedId,
112
113
  isPersistenceDegraded,
113
114
  lastPersistenceFailure,
114
115
  modelSwitch,
@@ -116,6 +117,7 @@ import {
116
117
  isGoalRevisionCurrent,
117
118
  nextHourlyPromptMs,
118
119
  nextHourlyProbeMs,
120
+ clearLoadHold,
119
121
  type ModelSwitchRecord,
120
122
  type ListItem,
121
123
  } from "../goal-loop-core.js";
@@ -586,6 +588,14 @@ async function handleMainModelAgentEnd(ctx: ExtensionContext, rawLastA: any, las
586
588
 
587
589
  function createGoal(objective: string, ctx: ExtensionContext, policy: "goal" | "list" = "goal"): Goal {
588
590
  ensureDirs(ctx.cwd);
591
+ // v0.35.23 (note.md Next #2): creating a goal — via /goal start, a list
592
+ // item activation, or an accepted draft — IS the explicit decision a
593
+ // load hold waits for. Release before the new work's first continuation
594
+ // would otherwise be frozen.
595
+ if (clearLoadHold(state)) {
596
+ persistState(ctx);
597
+ appendLedger(ctx.cwd, "load_hold_released", { via: `goal-created:${policy}` });
598
+ }
589
599
  // Extract verification contract if present in objective.
590
600
  const { objective: roleCleaned, agentRole } = extractAgentRole(objective);
591
601
  const { objective: cleanObj, verificationContract } = extractVerificationContract(roleCleaned);
@@ -893,6 +903,10 @@ function archiveCurrentGoal(
893
903
  ): boolean {
894
904
  if (!state.goal) return false;
895
905
  const goal = state.goal;
906
+ if (!isSafePersistedId(goal.id)) {
907
+ ctx.ui.notify("Could not archive the goal — its persisted id is invalid, so no filesystem path was used. Repair the state before retrying.", "warning");
908
+ return false;
909
+ }
896
910
  const pendingAttemptId = goal.pendingCompletion?.attemptId;
897
911
  const target = archivedGoalPath(ctx.cwd, goal.id);
898
912
  const terminalGoal: Goal = {
@@ -984,7 +998,20 @@ function archiveCurrentGoal(
984
998
  // had a chance to choose a successor.
985
999
  const closeArchivedSlot = () => {
986
1000
  if (state.goal?.id !== goal.id) return;
987
- replaceState({ ...state, goal: null });
1001
+ // v0.35.30: the archive nulled the slot, which blanked the widget — the
1002
+ // verdict (approval or abort) survived only as one transient toast. Keep
1003
+ // a durable last-outcome record so the widget can answer "did the final
1004
+ // audit actually run?" hours later, after the agent's turn has ended.
1005
+ replaceState({
1006
+ ...state,
1007
+ goal: null,
1008
+ lastOutcome: {
1009
+ at: nowIso(),
1010
+ ok: status === "complete",
1011
+ title: stopReason ?? status,
1012
+ recap: (goal.completionSummary?.trim() || goal.objective).replace(/\s+/g, " ").slice(0, 300),
1013
+ },
1014
+ });
988
1015
  persistState(ctx);
989
1016
  };
990
1017
  // Loop 2: a list-sourced goal COMPLETED → auto-activate the next item.