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
@@ -247,6 +247,10 @@ import {
247
247
  type MultiModelPickerResult,
248
248
  } from "../multi-model-picker.js";
249
249
  import { consumeRecoveryResume } from "../goal-recovery.js"; // decomposition step 3 (v0.34.111)
250
+ import {
251
+ discoverAuditorExtensions,
252
+ normalizeAuditorAllowedExtensions,
253
+ } from "../auditor-extensions.js";
250
254
  import {
251
255
  createGoalHeartbeat,
252
256
  endSubagentHangProbe,
@@ -672,11 +676,25 @@ async function promptModelRef(
672
676
  ctx: ExtensionContext,
673
677
  title: string,
674
678
  emptyLabel: string,
679
+ // v0.35.24 (note.md Next #1): auditor-slot parity with the main agent
680
+ // selector — forbidden-models policy filters BOTH the picker list and the
681
+ // typed escape hatch. Without it the user could pin a model the resolver
682
+ // would silently skip at audit time (auditor_model_fallback reason:
683
+ // "forbidden"), a dead-end selection the main selector never offers.
684
+ opts: { excludeRefs?: readonly string[] } = {},
675
685
  ): Promise<{ kind: "session" } | { kind: "ref"; ref: string } | undefined> {
686
+ const exclude = opts.excludeRefs ?? [];
687
+ const validateTypedRef = (ref: string): { kind: "session" } | { kind: "ref"; ref: string } | undefined => {
688
+ if (exclude.length > 0 && isForbiddenModel(ref, exclude)) {
689
+ ctx.ui.notify(`"${ref}" matches your forbidden-models policy — the picker refuses it. Adjust /glla → Forbidden models first.`, "warning");
690
+ return undefined;
691
+ }
692
+ return { kind: "ref", ref };
693
+ };
676
694
  const inputFallback = async (): Promise<{ kind: "session" } | { kind: "ref"; ref: string } | undefined> => {
677
695
  const v = await ctx.ui.input(title, "provider/model-id — empty keeps the default");
678
696
  if (v === undefined) return undefined;
679
- return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
697
+ return v.trim() ? validateTypedRef(v.trim()) : { kind: "session" };
680
698
  };
681
699
  if (typeof (ctx.ui as { custom?: unknown }).custom !== "function" || !ctx.modelRegistry) {
682
700
  return inputFallback();
@@ -686,7 +704,7 @@ async function promptModelRef(
686
704
  const models = ctx.modelRegistry
687
705
  .getAvailable()
688
706
  .filter((m: any) => ctx.modelRegistry.hasConfiguredAuth(m));
689
- const items = buildModelPickItems(models, sessionLabel);
707
+ const items = buildModelPickItems(models, sessionLabel, { excludeRefs: exclude });
690
708
  let factoryInvoked = false;
691
709
  const pick = await ctx.ui.custom<ModelPickItem | undefined>((tui, theme, keybindings, done) => {
692
710
  factoryInvoked = true;
@@ -701,7 +719,7 @@ async function promptModelRef(
701
719
  // manual escape hatch — typed provider/model, validated like before
702
720
  const v = await ctx.ui.input(title, emptyLabel);
703
721
  if (v === undefined) return undefined;
704
- return v.trim() ? { kind: "ref", ref: v.trim() } : { kind: "session" };
722
+ return v.trim() ? validateTypedRef(v.trim()) : { kind: "session" };
705
723
  }
706
724
 
707
725
  /** Multi-select counterpart to promptModelRef: returns an ordered string[] of
@@ -1089,7 +1107,11 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
1089
1107
  return;
1090
1108
  }
1091
1109
  case "auditorModel": {
1092
- const pick = await promptModelRef(ctx, "Auditor model override", "provider/model-id — empty keeps the pi session model");
1110
+ // v0.35.24 (note.md Next #1): parity with the main agent selector —
1111
+ // the auditor picker filters forbidden models out of the list AND
1112
+ // refuses typed policy matches, so a pin saved here is one the
1113
+ // resolver will actually honor.
1114
+ const pick = await promptModelRef(ctx, "Auditor model override", "provider/model-id — empty keeps the pi session model", { excludeRefs: loadSettings(ctx.cwd).forbiddenModels });
1093
1115
  if (pick === undefined) return;
1094
1116
  saveSettings("global", ctx.cwd, { auditorModel: pick.kind === "session" ? undefined : pick.ref });
1095
1117
  // v0.31.4: thinking is chosen WITH the model (user: "we are setting
@@ -1146,12 +1168,81 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
1146
1168
  return;
1147
1169
  }
1148
1170
  case "auditorModelFallback": {
1149
- const pick = await promptModelRef(ctx, "Auditor fallback agent (runtime failure or same-session swap)", "provider/model-id — empty clears the fallback");
1171
+ const pick = await promptModelRef(ctx, "Auditor fallback agent (runtime failure or same-session swap)", "provider/model-id — empty clears the fallback", { excludeRefs: loadSettings(ctx.cwd).forbiddenModels });
1150
1172
  if (pick === undefined) return;
1151
1173
  saveSettings("global", ctx.cwd, { auditorModelFallback: pick.kind === "session" ? undefined : pick.ref });
1152
1174
  if (pick.kind === "session") ctx.ui.notify("Auditor fallback cleared — a session on the pinned auditor model keeps that model.", "info");
1153
1175
  return;
1154
1176
  }
1177
+ case "auditorAllowedExtensions": {
1178
+ // v0.36.0 (GitHub issue: extension-based model providers in the
1179
+ // detached auditor). The picker lists every extension a normal pi
1180
+ // session would load, RESOLVED to concrete install paths (raw npm:/git:
1181
+ // specs are not directly loadable by the detached worker — see
1182
+ // extensions/auditor-extensions.ts); selected paths ride the auditor
1183
+ // request and the worker passes them as `pi --extension <path>` under
1184
+ // the still intact --no-extensions discovery off switch.
1185
+ const currentExts = normalizeAuditorAllowedExtensions(loadGlobalSettings().auditorAllowedExtensions);
1186
+ const discovered = discoverAuditorExtensions(os.homedir(), ctx.cwd);
1187
+ const discoveredSpecs = new Set(discovered.map((entry) => entry.spec));
1188
+ const extItems: ModelPickItem[] = [
1189
+ ...discovered.map((entry) => ({
1190
+ kind: "model" as const,
1191
+ ref: entry.spec,
1192
+ label: `${entry.label} · ${entry.source}`,
1193
+ searchText: `${entry.spec} ${entry.label} ${entry.source}`,
1194
+ })),
1195
+ // Saved specs that discovery can no longer see (removed package,
1196
+ // another machine's home) still render so the user can deselect
1197
+ // them instead of a silently immortal settings entry.
1198
+ ...currentExts.filter((spec) => !discoveredSpecs.has(spec)).map((spec) => ({
1199
+ kind: "model" as const,
1200
+ ref: spec,
1201
+ label: `${spec} · saved, not discovered here`,
1202
+ searchText: spec,
1203
+ })),
1204
+ ];
1205
+ const extInputFallback = async (): Promise<string[] | undefined> => {
1206
+ const v = await ctx.ui.input(
1207
+ "Auditor allowed extensions — comma-separated pi extension specs",
1208
+ currentExts.length ? currentExts.join(",") : "npm:package-name or /path/to/extension",
1209
+ );
1210
+ if (v === undefined) return undefined;
1211
+ return normalizeAuditorAllowedExtensions(v.split(","));
1212
+ };
1213
+ let extFactoryInvoked = false;
1214
+ const extPick = extItems.length
1215
+ ? await ctx.ui.custom<MultiModelPickerResult>((tui, theme, keybindings, done) => {
1216
+ extFactoryInvoked = true;
1217
+ return new MultiModelPickerComponent(
1218
+ {
1219
+ title: "Auditor allowed extensions — space toggles, enter confirms; empty = fully isolated auditor (default)",
1220
+ items: extItems,
1221
+ initialSelected: currentExts,
1222
+ unorderedSet: true,
1223
+ },
1224
+ () => tui.requestRender(),
1225
+ theme,
1226
+ keybindings,
1227
+ done,
1228
+ );
1229
+ })
1230
+ : undefined;
1231
+ let pickedExts: string[] | undefined;
1232
+ if (extPick === undefined && !extFactoryInvoked) pickedExts = await extInputFallback();
1233
+ else if (extPick === undefined) pickedExts = undefined;
1234
+ else pickedExts = Array.isArray(extPick) ? extPick : extPick.refs;
1235
+ if (pickedExts === undefined) return;
1236
+ const normalizedExts = normalizeAuditorAllowedExtensions(pickedExts);
1237
+ saveSettings("global", ctx.cwd, { auditorAllowedExtensions: normalizedExts.length ? normalizedExts : undefined });
1238
+ ctx.ui.notify(
1239
+ normalizedExts.length
1240
+ ? `Auditor allowed extensions saved: ${normalizedExts.join(", ")}.`
1241
+ : "Auditor allowed extensions cleared — the detached auditor runs extension-less again (default).",
1242
+ "info",
1243
+ );
1244
+ return;
1245
+ }
1155
1246
  case "auditorSameSessionSwap": {
1156
1247
  const v = await ctx.ui.select("Same-model swap — when the pinned auditor IS the session model, walk the fallback pin (a same-family model shares the executor's blind spots)", [
1157
1248
  "on — the verifier differs from the executor (default)",
@@ -410,6 +410,12 @@ async function resolveDraftActivationConflict(ctx: ExtensionContext, incoming: O
410
410
  return "proceed";
411
411
  }
412
412
 
413
+ function verifyTaskMilestone(ctx: ExtensionContext, verificationContract?: string): { failedCommand?: string; output?: string; exitCode?: number } | null {
414
+ if (!verificationContract?.trim()) return null;
415
+ const result = runMechanicalPreAuditChecks(ctx.cwd, extractMechanicalCheckCommands(verificationContract));
416
+ return result.passed ? null : result;
417
+ }
418
+
413
419
  function registerAgentTools(pi: any): void {
414
420
  pi.registerTool(defineTool({
415
421
  name: "complete_goal",
@@ -691,6 +697,7 @@ function registerAgentTools(pi: any): void {
691
697
  verificationSummary: p.verificationSummary,
692
698
  model: candidate.model,
693
699
  thinkingLevel: (settings.auditorThinkingLevel ?? "high") as any, // may be "max" — pi ≥0.83 understands it; the dev-types predate it
700
+ allowedExtensions: settings.auditorAllowedExtensions,
694
701
  runtime: { attemptId: () => newDetachedAuditJobAttemptId(completionClaim.attemptId!), logicalAttemptId: completionClaim.attemptId!, wallTimeoutMs: AUDITOR_WALL_TIMEOUT_MS },
695
702
  onProgress: (progress) => {
696
703
  publishDetachedAuditProgress(auditGeneration, auditGoalId, auditAttemptId, progress);
@@ -715,9 +722,10 @@ function registerAgentTools(pi: any): void {
715
722
  let fallbackUsed = false;
716
723
  // v0.35.7: Deterministic Fast-Fail Pre-Audit — if mechanical contract checks fail,
717
724
  // fail in 200ms with raw output rather than burning 45s on an LLM audit pass.
718
- const mechanicalCmds = extractMechanicalCheckCommands(auditGoal.verificationContract ?? "");
719
- const mechanicalResult = runMechanicalPreAuditChecks(ctx.cwd, mechanicalCmds);
720
- if (!mechanicalResult.passed) {
725
+ try {
726
+ const mechanicalCmds = extractMechanicalCheckCommands(auditGoal.verificationContract ?? "");
727
+ const mechanicalResult = runMechanicalPreAuditChecks(ctx.cwd, mechanicalCmds);
728
+ if (!mechanicalResult.passed) {
721
729
  result = {
722
730
  approved: false,
723
731
  disapproved: true,
@@ -727,8 +735,7 @@ function registerAgentTools(pi: any): void {
727
735
  regressionShieldPassed: true,
728
736
  goalRevision: captureGoalRevision(auditGoal) ?? undefined,
729
737
  };
730
- } else {
731
- try {
738
+ } else {
732
739
  ({ result, retriedOnce, fallbackUsed } = await runDetachedCompletionWithFallback(auditorCandidates, runAudit, {
733
740
  shouldRetry: () => detachedAuditContext(auditGeneration, auditGoalId, auditAttemptId) !== null,
734
741
  forbiddenRefs: settings.forbiddenModels,
@@ -754,12 +761,15 @@ function registerAgentTools(pi: any): void {
754
761
  current.ui.notify(`Detached auditor failed on ${auditorCandidateLabel(from)} — retrying with ${auditorCandidateLabel(to)}. This is infrastructure, not a verdict.`, "warning");
755
762
  },
756
763
  }));
757
- } finally {
758
- if (ownsDetachedAudit(auditGeneration, auditGoalId, auditAttemptId)) {
759
- clearDetachedAuditProgress(auditGeneration, auditGoalId, auditAttemptId);
760
- completionAuditInFlight = false;
761
- completionAuditGeneration = null;
762
- }
764
+ }
765
+ } finally {
766
+ // Deterministic pre-audit failures take the same cleanup path as a
767
+ // detached worker. Otherwise the in-flight latch survives forever
768
+ // and recovery/status surfaces claim an auditor is still running.
769
+ if (ownsDetachedAudit(auditGeneration, auditGoalId, auditAttemptId)) {
770
+ clearDetachedAuditProgress(auditGeneration, auditGoalId, auditAttemptId);
771
+ completionAuditInFlight = false;
772
+ completionAuditGeneration = null;
763
773
  }
764
774
  }
765
775
  const auditContextAfterRun = freshCtxForGeneration(auditGeneration);
@@ -883,7 +893,21 @@ function registerAgentTools(pi: any): void {
883
893
  ? state.goal.completionSummary.replace(/\s+/g, " ")
884
894
  : state.goal.objective;
885
895
  const recap = displaySlice(recapSrc, 110);
886
- archiveCurrentGoal(ctx, "complete", `auditor ${result.model} approved`);
896
+ const archived = archiveCurrentGoal(ctx, "complete", `auditor ${result.model} approved`);
897
+ if (!archived) {
898
+ // The archive helper preserves the live objective and emits the
899
+ // persistence warning. Stop here: an approved verdict is not a
900
+ // terminal success until the archive and state transition land.
901
+ updateGoal({
902
+ status: "paused",
903
+ pendingCompletion: undefined,
904
+ pauseKind: "blocked",
905
+ pauseReason: "completion approved but terminal archive persistence failed",
906
+ pauseSuggestedAction: `Fix .pi-glla disk access or resolve the archive fence, then ${activeGoalSurfaceCommand("resume")} and call complete_goal again.`,
907
+ }, ctx);
908
+ appendLedger(ctx.cwd, "goal_archive_failed_after_approval", { goalId: state.goal?.id, origin: "manual-verify", model: result.model });
909
+ return { content: [{ type: "text", text: "The auditor approved, but the terminal archive could not be persisted. The goal is paused; fix persistence, resume, and retry complete_goal." }], details: {} };
910
+ }
887
911
  ctx.ui.notify(`✓ done: ${recap} — auditor ${result.model} approved.`, "info");
888
912
  notifyExternal(ctx, `Goal complete (auditor approved): ${displaySlice(recapSrc, 120)}`);
889
913
  return { content: [{ type: "text", text: `Goal approved by auditor ${result.model}.` }], details: {} };
@@ -1082,7 +1106,7 @@ function registerAgentTools(pi: any): void {
1082
1106
  void retryStoredCompletionAudit();
1083
1107
  return;
1084
1108
  }
1085
- updateGoal({ status: "active", pauseKind: undefined, pauseResumeAt: undefined, pauseReason: undefined, pauseSuggestedAction: undefined }, fresh);
1109
+ updateGoal({ status: "active", pauseKind: undefined, pauseResumeAt: undefined, pauseReason: undefined, pauseSuggestedAction: undefined, autoResumedAt: new Date().toISOString(), autoResumedEvent: "auditor provider retry elapsed" }, fresh);
1086
1110
  appendLedger(fresh.cwd, "goal_resumed", { via: "provider-retry" });
1087
1111
  if (resolveEffectiveAggressiveSettings(loadSettings(fresh.cwd)).aggressiveMode) {
1088
1112
  fresh.ui.notify("Auto-resume fired (event: auditor provider retry elapsed). Continue working.", "info");
@@ -1281,7 +1305,7 @@ function registerAgentTools(pi: any): void {
1281
1305
  pi.registerTool(defineTool({
1282
1306
  name: "pause_goal",
1283
1307
  label: "Pause goal",
1284
- 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\". VOCABULARY (v0.28.24): decision options and reasons must reference REAL commands only — /goal resume, /goal cancel, /goal tweak \"<new text>\", /list remove N, /list next, /list resume, /loop stop, /loop resume. These all act on the ACTIVE goal/item: there is NO /goal drop and NO command takes a goal id. Never show goal ids to the user — name the thing ('the active goal', 'list item \"<short name>\"'); ids are internal plumbing the user cannot act on.",
1308
+ description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress. Pausing ABORTS the current turn immediately — after this call, stop; never keep working. 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 and pop a picker for the user. Time-gated waits (retry at a specific time) use kind=\"wait\" with resumeAt (ISO). Operational failures use kind=\"error\". VOCABULARY (v0.28.24): decision options and reasons must reference REAL commands only — /goal resume, /goal cancel, /goal tweak \"<new text>\", /list remove N, /list next, /list resume, /loop stop, /loop resume. These all act on the ACTIVE goal/item: there is NO /goal drop and NO command takes a goal id. Never show goal ids to the user — name the thing ('the active goal', 'list item \"<short name>\"'); ids are internal plumbing the user cannot act on.",
1285
1309
  parameters: Type.Object({
1286
1310
  reason: Type.String({ description: "Why the work is paused" }),
1287
1311
  suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
@@ -1296,6 +1320,10 @@ function registerAgentTools(pi: any): void {
1296
1320
  const ctx = currentToolContext(execCtx);
1297
1321
  if (!ctx) return staleToolResult();
1298
1322
  const p = params as { reason: string; suggestedAction?: string; kind?: "decision" | "error" | "wait" | "blocked"; options?: string[]; recommended?: number; resumeAt?: string };
1323
+ // v0.35.15: a model that passes options but forgets kind="decision"
1324
+ // still gets the decision card — a non-empty options array IS the
1325
+ // decision intent; silently dropping it left the user with no picker.
1326
+ if (!p.kind && p.options && p.options.length > 0) p.kind = "decision";
1299
1327
  if (!state.goal) return { content: [{ type: "text", text: "No active goal." }], details: {} };
1300
1328
  // A late model/tool call from the previous turn must not overwrite a
1301
1329
  // paused or auditing lifecycle. That race made a genuine stop look
@@ -1387,7 +1415,7 @@ function registerAgentTools(pi: any): void {
1387
1415
  try {
1388
1416
  const hours = Math.round((resumeAtMs - Date.now()) / (60 * 60 * 1000));
1389
1417
  ctx.ui.notify(
1390
- `Pause scheduled for ~${hours}h. If the objective no longer matches your intent, run ${activeGoalSurfaceCommand("tweak")} to replace it now; otherwise ${activeGoalSurfaceCommand("resume")} continues automatically when the wait ends.`,
1418
+ `Pause scheduled for ~${hours}h. If the objective no longer matches your intent, run ${activeGoalSurfaceCommand("tweak")} to replace it now; otherwise the wait auto-continues shortly after its resume time (heartbeat backstop), and ${activeGoalSurfaceCommand("resume")} resumes immediately.`,
1391
1419
  "info",
1392
1420
  );
1393
1421
  appendLedger(ctx.cwd, "pause_long_wait_offer_tweak", {
@@ -1400,12 +1428,29 @@ function registerAgentTools(pi: any): void {
1400
1428
  /* best-effort */
1401
1429
  }
1402
1430
  }
1431
+ // v0.35.15: a pause ENDS the turn. Before this fix the tool result was
1432
+ // just text — pi kept the same turn running, the model kept working
1433
+ // past its own pause ("the agent moves on"), and the deferred decision
1434
+ // picker lost the race: the user was never shown the options. Abort
1435
+ // (the same mechanism /goal cancel and the zero-stream watchdog use)
1436
+ // so the session actually stops on an idle surface where the decision
1437
+ // picker can own the screen. The impossible-drop advance is the one
1438
+ // exception: the queue already moved to the next item and its
1439
+ // continuation owns this turn — aborting would kill the hand-off.
1440
+ if (!droppedImpossible) {
1441
+ try {
1442
+ ctx.abort();
1443
+ appendLedger(ctx.cwd, "pause_goal_aborted_turn", { goalId: state.goal?.id, kind: p.kind ?? "blocked" });
1444
+ } catch (abortError) {
1445
+ appendLedger(ctx.cwd, "pause_goal_abort_failed", { goalId: state.goal?.id, error: abortError instanceof Error ? abortError.message : String(abortError) });
1446
+ }
1447
+ }
1403
1448
  return {
1404
1449
  content: [{
1405
1450
  type: "text",
1406
1451
  text: droppedImpossible
1407
1452
  ? "The list item was auto-dropped as impossible (blocked with no resume path) — the list moved on instead of stopping."
1408
- : `Goal paused. ${activeGoalSurfaceCommand("resume")} to continue.`,
1453
+ : `Goal paused. The turn ends here — do NOT continue working. ${activeGoalSurfaceCommand("resume")} to continue.`,
1409
1454
  }],
1410
1455
  details: {},
1411
1456
  };
@@ -1432,18 +1477,15 @@ function registerAgentTools(pi: any): void {
1432
1477
  while (queue.length > 0) {
1433
1478
  const t = queue.shift();
1434
1479
  if (t.id === p.id && t.status !== "complete") {
1435
- if (t.verificationContract) {
1436
- const checkCmds = extractMechanicalCheckCommands(t.verificationContract);
1437
- const checkRes = runMechanicalPreAuditChecks(ctx.cwd, checkCmds);
1438
- if (!checkRes.passed) {
1439
- return {
1440
- content: [{
1441
- type: "text",
1442
- text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains in_progress. Fix the failure before marking complete.`,
1443
- }],
1444
- details: {},
1445
- };
1446
- }
1480
+ const checkRes = verifyTaskMilestone(ctx, t.verificationContract);
1481
+ if (checkRes) {
1482
+ return {
1483
+ content: [{
1484
+ type: "text",
1485
+ text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains in_progress. Fix the failure before marking complete.`,
1486
+ }],
1487
+ details: {},
1488
+ };
1447
1489
  }
1448
1490
  t.status = "complete";
1449
1491
  updateGoal({ taskList: tl }, ctx);
@@ -1477,6 +1519,18 @@ function registerAgentTools(pi: any): void {
1477
1519
  while (queue.length > 0) {
1478
1520
  const t = queue.shift();
1479
1521
  if (t.id === p.id) {
1522
+ if (p.status === "complete") {
1523
+ const checkRes = verifyTaskMilestone(ctx, t.verificationContract);
1524
+ if (checkRes) {
1525
+ return {
1526
+ content: [{
1527
+ type: "text",
1528
+ text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains ${t.status}. Fix the failure before marking complete.`,
1529
+ }],
1530
+ details: {},
1531
+ };
1532
+ }
1533
+ }
1480
1534
  t.status = p.status;
1481
1535
  updateGoal({ taskList: tl }, ctx);
1482
1536
  return { content: [{ type: "text", text: `Task ${p.id} → ${p.status}` }], details: {} };
@@ -114,6 +114,7 @@ import {
114
114
  isGoalRevisionCurrent,
115
115
  nextHourlyPromptMs,
116
116
  nextHourlyProbeMs,
117
+ supervisorPaused,
117
118
  type ModelSwitchRecord,
118
119
  type ListItem,
119
120
  } from "../goal-loop-core.js";
@@ -129,6 +130,11 @@ import {
129
130
  transitionDispatch,
130
131
  type ContinuationDispatch,
131
132
  } from "../goal-loop-dispatch.js";
133
+ import {
134
+ AUDITOR_QUIET_MS,
135
+ auditorDisplayPhase,
136
+ fmtElapsed,
137
+ } from "../goal-loop-display.js";
132
138
  import {
133
139
  createGoalContinuation,
134
140
  scheduleContinuation,
@@ -257,6 +263,8 @@ import {
257
263
  type HeartbeatDeps,
258
264
  type HeartbeatFlags,
259
265
  } from "../goal-heartbeat.js"; // decomposition step 4 (v0.34.112)
266
+ import { getSubagentAgentsSnapshot } from "../goal-heartbeat.js";
267
+ import { renderAgentsWidgetLine, type AgentsPanelRow } from "../goal-agents-panel.js";
260
268
  import {
261
269
  clearMainModelRecoveryTimer,
262
270
  createGoalRecovery,
@@ -664,12 +672,23 @@ function refreshUI(ctx: ExtensionContext): void {
664
672
  const extras = {
665
673
  stalls: consecutiveStalls,
666
674
  recent: recentActions,
675
+ // v0.35.29 (issue #15): ambient tracked-subagent segment — hidden at
676
+ // zero tracked children (renderAgentsWidgetLine returns undefined).
677
+ ...(() => {
678
+ try {
679
+ const { agents } = getSubagentAgentsSnapshot();
680
+ const line = renderAgentsWidgetLine(agents as AgentsPanelRow[]);
681
+ return line ? { agents: { line } } : {};
682
+ } catch { return {}; }
683
+ })(),
684
+ ...activity,
667
685
  ...activity,
668
686
  turnPending: pendingContinuationDispatchRef() !== null,
669
687
  auditorSilent: loadSettings(ctx.cwd).auditorSilent !== false,
670
688
  auditorProgressSignals: settings.auditorProgressSignals !== false,
671
689
  mainModelFallbacks: fallbackRefs,
672
690
  modelProvenance,
691
+ ...(lastAuditorQuietStretch ? { auditorQuietStretch: lastAuditorQuietStretch } : {}),
673
692
  };
674
693
  // Settings are loaded once for the remaining projections; the explicit
675
694
  // auditorSilent expression preserves the source-level contract.
@@ -700,6 +719,57 @@ function scheduleUIRefresh(): void {
700
719
  deferredUIRefresh.unref?.();
701
720
  }
702
721
 
722
+ // v0.35.15: proactive auditor quiet reporting (runtime-only state).
723
+ // The seed complaint (2026-08-21): an auditor sat silent for 8 minutes and
724
+ // the user only learned about it afterwards from the status line — nothing
725
+ // ever NOTIFIED. The phase chip recolors at ~3 min, but a chip is not a
726
+ // report. The watcher below fires exactly ONE notify when a detached audit
727
+ // crosses into the quiet phase, then records the ended stretch so the
728
+ // footer can show "silent Xm then resumed" afterwards. `/glla pause`
729
+ // suppresses the notify (it is automatic machinery), never the recording.
730
+ let auditorQuietSince: number | null = null;
731
+ let auditorQuietNotified = false;
732
+ let lastAuditorQuietStretch: { ms: number; endedAt: number } | null = null;
733
+
734
+ /** Drive the quiet watcher once per UI tick. Returns a warning message to
735
+ * deliver, or null. Split out for testability. */
736
+ export function __auditorQuietWatchTick(now = Date.now()): string | null {
737
+ const g = state.goal;
738
+ if (!g || g.status !== "auditing") {
739
+ // No live audit — reset any open stretch so a stale silence cannot leak
740
+ // into a later goal's display.
741
+ if (auditorQuietSince !== null) {
742
+ const ms = now - auditorQuietSince;
743
+ if (ms >= AUDITOR_QUIET_MS) lastAuditorQuietStretch = { ms, endedAt: now };
744
+ auditorQuietSince = null;
745
+ auditorQuietNotified = false;
746
+ }
747
+ return null;
748
+ }
749
+ const phase = auditorDisplayPhase(g, latestAuditProgress, now);
750
+ if (phase === "quiet") {
751
+ // Entering quiet: anchor the stretch at the worker's last activity.
752
+ if (auditorQuietSince === null) {
753
+ auditorQuietSince = typeof latestAuditProgress?.lastActivityAt === "number"
754
+ ? latestAuditProgress.lastActivityAt
755
+ : now;
756
+ }
757
+ if (!auditorQuietNotified && !supervisorPaused(state)) {
758
+ auditorQuietNotified = true;
759
+ return `glla: the detached auditor shows NO worker activity for ${fmtElapsed(now - auditorQuietSince)} — it may be stuck (or doing one long read). The status footer keeps counting. /glla pause freezes all supervisor machinery; /goal cancel discards the claim.`;
760
+ }
761
+ return null;
762
+ }
763
+ // Left the quiet phase: record the ended stretch for the footer summary.
764
+ if (auditorQuietSince !== null) {
765
+ const ms = now - auditorQuietSince;
766
+ if (ms >= AUDITOR_QUIET_MS) lastAuditorQuietStretch = { ms, endedAt: now };
767
+ auditorQuietSince = null;
768
+ }
769
+ auditorQuietNotified = false;
770
+ return null;
771
+ }
772
+
703
773
  function startUITicker(): void {
704
774
  if (uiTicker) return;
705
775
  uiTicker = setInterval(() => {
@@ -707,7 +777,17 @@ function startUITicker(): void {
707
777
  // v0.34.12: keep ticking during a timed wait-pause too — the status
708
778
  // line counts down to resumeAt live (pully field request 2026-08-01).
709
779
  const auditVisible = state.goal?.status === "auditing";
710
- if (ctx && (isSupervising() || auditVisible || (state.goal?.status === "paused" && !!state.goal.pauseResumeAt))) refreshUI(ctx);
780
+ if (!ctx) return;
781
+ if (!(isSupervising() || auditVisible || (state.goal?.status === "paused" && !!state.goal.pauseResumeAt))) return;
782
+ // v0.35.15: proactive quiet-phase notification (once per stretch).
783
+ if (auditVisible) {
784
+ const warning = __auditorQuietWatchTick();
785
+ if (warning && !supervisorPaused(state)) {
786
+ try { ctx.ui.notify(warning, "warning"); } catch { /* stale handle — next tick retries the notify gate honestly */ }
787
+ scheduleUIRefresh();
788
+ }
789
+ }
790
+ refreshUI(ctx);
711
791
  }, 1_000);
712
792
  uiTicker.unref?.();
713
793
  }
@@ -803,6 +883,30 @@ export function __testOnlyRegisterAgentTools(pi: any): void {
803
883
  export function __testOnlyRememberCtx(ctx: ExtensionContext): void {
804
884
  rememberCtx(ctx);
805
885
  }
886
+
887
+ /** v0.35.15 test-only: drive the auditor quiet watcher with a controlled
888
+ * progress snapshot + clock. Pass null progress to simulate no audit.
889
+ * Returns the tick's notify message (null = silent) exactly like the
890
+ * production ticker consumes it. */
891
+ export function __testOnlyAuditorQuietWatchTick(
892
+ progress: import("../goal-loop-display.js").AuditDisplayProgress | null,
893
+ now = Date.now(),
894
+ ): string | null {
895
+ latestAuditProgress = progress;
896
+ return __auditorQuietWatchTick(now);
897
+ }
898
+
899
+ /** v0.35.15 test-only: reset the quiet-watcher runtime state between tests. */
900
+ export function __testOnlyResetAuditorQuietWatch(): void {
901
+ auditorQuietSince = null;
902
+ auditorQuietNotified = false;
903
+ lastAuditorQuietStretch = null;
904
+ }
905
+
906
+ /** v0.35.15 test-only: the last ended quiet stretch the footer extras carry. */
907
+ export function __testOnlyLastAuditorQuietStretch(): { ms: number; endedAt: number } | null {
908
+ return lastAuditorQuietStretch;
909
+ }
806
910
  // v0.32.1 (pi-goal-x's lesson — "recover from compacts smarter"): a compact
807
911
  // leaves a RESUME DEBT, not just two fixed-offset settle probes that can both
808
912
  // lose (field: hellhunter 4-min dangle 2026-07-31; polis stall same day).
@@ -47,7 +47,6 @@ import {
47
47
  } from "../goal-continuation.js";
48
48
  export { __testOnlySetContinuationStartTimeout, __testOnlySetContinuationRetryBackoff } from "../goal-continuation.js";
49
49
 
50
- import { resetLengthContinue } from "../length-continue.js";
51
50
  import {
52
51
  clearMainModelRecoveryTimer,
53
52
  createGoalRecovery,
@@ -60,7 +59,7 @@ import {
60
59
  type RecoveryDeps,
61
60
  type RecoveryFlags,
62
61
  } from "../goal-recovery.js";
63
- import { createGoalHeartbeat, startHeartbeat, type HeartbeatDeps, type HeartbeatFlags } from "../goal-heartbeat.js";
62
+ import { createGoalHeartbeat, getSubagentAgentsSnapshot, type HeartbeatDeps, type HeartbeatFlags } from "../goal-heartbeat.js";
64
63
  import { createGoalCommands, type CommandDeps, type CommandFlags } from "../goal-commands.js";
65
64
  import { clearLoopTimer, createGoalLoop, type LoopDeps, type LoopFlags } from "../goal-loop.js";
66
65
 
@@ -154,6 +153,9 @@ const commandDeps: CommandDeps = {
154
153
  persistState,
155
154
  updateGoal,
156
155
  setGoal,
156
+ // v0.35.29 (issue #15): /glla agents reads the heartbeat's tracked-subagent
157
+ // snapshot via injection (goal-commands must not import goal-heartbeat).
158
+ agentsSnapshot: () => getSubagentAgentsSnapshot(),
157
159
  archiveCurrentGoal,
158
160
  healGoalPolicy,
159
161
  startDrafting,
@@ -404,10 +406,8 @@ createGoalHeartbeat(heartbeatFlags, heartbeatDeps);
404
406
 
405
407
 
406
408
  export default function (pi: ExtensionAPI): void {
407
- extensionApi = pi;
408
- extensionApiStale = false; // a fresh factory run means a fresh runtime (reload path)
409
- resetLengthContinue(); // v0.27.2: fresh runtime, fresh truncation streak
410
- startHeartbeat();
411
- startUITicker();
409
+ // Factory evaluation can also happen inside pi-subagents child sessions.
410
+ // Registration must not claim the shared host API or start session timers;
411
+ // the admitted host session_start below owns both lifecycle resources.
412
412
  registerGoalRuntime(pi);
413
413
  }