pi-goal-list-loop-audit 0.38.21 → 0.38.24

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 (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +10 -0
  3. package/docs/DESIGN-subagent-visibility.md +38 -5
  4. package/docs/INDEX.md +5 -3
  5. package/docs/SETTINGS.md +85 -0
  6. package/extensions/auditor-extensions.ts +19 -6
  7. package/extensions/completion-summary.ts +8 -3
  8. package/extensions/context-checkpoint.ts +26 -42
  9. package/extensions/drafter-model.ts +8 -1
  10. package/extensions/goal-agents-panel.ts +121 -35
  11. package/extensions/goal-commands.ts +42 -5
  12. package/extensions/goal-compactor.ts +42 -2
  13. package/extensions/goal-continuation.ts +80 -8
  14. package/extensions/goal-heartbeat.ts +12 -0
  15. package/extensions/goal-loop-core.ts +90 -0
  16. package/extensions/goal-loop-display.ts +154 -52
  17. package/extensions/goal-loop-shield.ts +31 -0
  18. package/extensions/goal-recovery.ts +24 -5
  19. package/extensions/goal-settings.ts +84 -2
  20. package/extensions/loops/goal-activation.ts +31 -5
  21. package/extensions/loops/goal-auditor-hooks.ts +1 -0
  22. package/extensions/loops/goal-list-queue.ts +9 -0
  23. package/extensions/loops/goal-orchestrator.ts +5 -1
  24. package/extensions/loops/goal-runtime-globals.ts +24 -0
  25. package/extensions/loops/goal-session.ts +40 -5
  26. package/extensions/loops/goal-settings-ui.ts +48 -7
  27. package/extensions/loops/goal-tools.ts +6 -4
  28. package/extensions/loops/goal-ui.ts +21 -12
  29. package/extensions/model-selector.ts +13 -5
  30. package/extensions/multi-model-picker.ts +12 -0
  31. package/extensions/settings-menu.ts +13 -2
  32. package/extensions/state-root-owner.ts +79 -4
  33. package/package.json +1 -1
  34. package/scripts/release-pack-smoke.mjs +4 -1
@@ -11,7 +11,8 @@
11
11
  // in tailChildTranscript through an injected reader so tests stay hermetic.
12
12
 
13
13
  import * as path from "node:path";
14
- import { sanitizeDisplayText } from "./goal-loop-core.js";
14
+ import { paint, truncateCells, type DisplayTheme } from "./goal-loop-display.js";
15
+ import { bucketSilentMs, fmtDuration, lifesignBandFor, sanitizeDisplayText } from "./goal-loop-core.js";
15
16
 
16
17
  /** v0.35.45 (audit finding): the candidate scan reads a bounded TAIL of each
17
18
  * transcript, not the whole file — up to 25 files were previously read in
@@ -43,6 +44,10 @@ export interface AgentsPanelRow {
43
44
  outputTokens: number;
44
45
  silentMs: number;
45
46
  evidence: "record-frozen" | "event-only" | "live";
47
+ /** In-flight parent tool wait this live child holds (audit 2026-09-06:
48
+ * the doc-promised `└ blocks:` row). Set only from observed
49
+ * in-flight subagent-wait tool calls — never inferred. */
50
+ blockedBy?: string;
46
51
  action?: "abort-requested" | "unavailable" | "failed";
47
52
  endedOk?: boolean;
48
53
  endedAt?: number;
@@ -90,21 +95,24 @@ function rowStateWord(row: AgentsPanelRow, now: number): string {
90
95
  }
91
96
 
92
97
  export function truncate(text: string, max: number): string {
93
- return text.length <= max ? text : text.slice(0, Math.max(1, max - 1)) + "…";
94
- }
95
-
96
- function fmtDuration(ms: number): string {
97
- const totalSec = Math.max(0, Math.round(ms / 1000));
98
- const min = Math.floor(totalSec / 60);
99
- const sec = totalSec % 60;
100
- if (min >= 60) return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}m`;
101
- if (min > 0) return `${min}m${String(sec).padStart(2, "0")}s`;
102
- return `${sec}s`;
98
+ // Audit 2026-09-06: cell-aware shared helper — the char slice overran on
99
+ // wide glyphs and could split surrogate pairs. ASCII contract unchanged.
100
+ return truncateCells(text, max);
103
101
  }
104
102
 
105
103
  const PANEL_ROW_CAP = 20;
106
104
  export const WIDGET_AGENT_ROW_CAP = 8;
107
105
 
106
+ /** Audit 2026-09-07 (field screenshots): the age label never says "silent" — that
107
+ * noun reads as a state claim ("this child is silent") while the child is
108
+ * visibly working. Fresh bands read `active {age}`, everything else
109
+ * `quiet {age}`: both are time-since-last-evidence, matching the band
110
+ * colors. Exported for the count line + panel detail sharing. */
111
+ export function activeAgeLabel(row: AgentsPanelRow): string {
112
+ const d = fmtDuration(bucketSilentMs(row.silentMs));
113
+ return lifesignBandFor(row) === "fresh" ? `active ${d}` : `quiet ${d}`;
114
+ }
115
+
108
116
  function rowRank(row: AgentsPanelRow): number {
109
117
  if (row.status === "hung") return 0;
110
118
  if (row.status === "running") return 1;
@@ -116,9 +124,17 @@ function orderedRows(rows: AgentsPanelRow[]): AgentsPanelRow[] {
116
124
  return [...rows].sort((a, b) => rowRank(a) - rowRank(b) || b.silentMs - a.silentMs);
117
125
  }
118
126
 
127
+ /** Compose the `└ blocks:` label from observed in-flight parent waits.
128
+ * Pure helper so the snapshot assembly and tests share the wording. */
129
+ export function blockedByLabel(waitNames: string[]): string | undefined {
130
+ const names = [...new Set(waitNames.filter(Boolean))];
131
+ if (names.length === 0) return undefined;
132
+ return `parent subagent wait (${names.join("/")})`;
133
+ }
134
+
119
135
  /** Render the /glla agents table. Hung/running/queued first, then ended;
120
136
  * capped at PANEL_ROW_CAP rows with an explicit truncation notice. */
121
- export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAvailable: boolean): string[] {
137
+ export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAvailable: boolean, recentHangs: string[] = []): string[] {
122
138
  if (rows.length === 0) {
123
139
  return ["No subagents tracked yet — spawn one via the `subagent` tool and it appears here.", "(evidence: glla's event probes" + (managerAvailable ? " + pi-subagents manager records" : "") + ")"];
124
140
  }
@@ -128,7 +144,7 @@ export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAv
128
144
  for (const row of shown) {
129
145
  const glyph = row.status === "ended" ? "✓" : row.status === "hung" ? "⚠" : "●";
130
146
  lines.push(`${glyph} ${rowLabel(row)} ${rowStateWord(row, now)}`);
131
- lines.push(` id ${cleanField(row.recordId, 18)} · silent ${fmtDuration(row.silentMs)} · tools ${row.toolUses} · out ${row.outputTokens >= 1000 ? `${(row.outputTokens / 1000).toFixed(1)}k` : row.outputTokens}${row.evidence !== "live" ? ` · ${row.evidence}` : ""}`);
147
+ lines.push(` id ${cleanField(row.recordId, 18)} · ${activeAgeLabel(row)} · tools ${row.toolUses} · out ${row.outputTokens >= 1000 ? `${(row.outputTokens / 1000).toFixed(1)}k` : row.outputTokens}${row.evidence !== "live" ? ` · ${row.evidence}` : ""}`);
132
148
  if (row.action === "abort-requested") {
133
149
  lines.push(" └ child-specific abort requested; partial output remains available while it settles");
134
150
  } else if (row.action === "unavailable") {
@@ -138,33 +154,106 @@ export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAv
138
154
  } else if (row.status === "hung") {
139
155
  lines.push(" └ check the Agents panel: a child whose counters stopped moving is hung, not thinking");
140
156
  }
157
+ // Audit 2026-09-06: the doc-promised `└ blocks:` row — only rendered
158
+ // from an observed in-flight wait (blockedBy set at snapshot time).
159
+ if (row.blockedBy && row.status !== "ended") {
160
+ lines.push(` └ blocks: ${truncate(row.blockedBy, 80)} (zombie stand-down active)`);
161
+ }
141
162
  }
142
163
  if (ordered.length > shown.length) {
143
164
  lines.push(`… ${ordered.length - shown.length} more (oldest ended trimmed — cap ${PANEL_ROW_CAP})`);
144
165
  }
166
+ // Audit 2026-09-06: the doc-promised "Recent hangs" footer, fed from
167
+ // the durable subagent_hang_detected ledger by the caller. Absent when
168
+ // there is nothing to show — never a placeholder row.
169
+ const hangs = recentHangs.filter(Boolean).slice(-3);
170
+ if (hangs.length > 0) {
171
+ lines.push(`Recent hangs: ${hangs.map((h) => truncate(h, 60)).join(" · ")}`);
172
+ }
145
173
  return lines;
146
174
  }
147
175
 
148
- /** Detailed worker rows for the above-editor widget. The widget receives every
149
- * active row up to a bounded display cap; the remainder has an explicit
150
- * /glla agents escape hatch rather than disappearing silently. */
151
- export function renderAgentsWidgetLines(rows: AgentsPanelRow[], now = Date.now(), maxRows = WIDGET_AGENT_ROW_CAP): string[] {
176
+ /** Single-line glyph-first worker rows for the below-chat widget (v0.38.23
177
+ * Option-2 shape). One row per tracked child: state reads from the leading
178
+ * glyph (▶ running, ◉ abort in flight, ⚠ hung/unavailable/failed) so no
179
+ * state word is needed for the healthy case; the silence age sits before
180
+ * any trailing suffix so narrow-terminal truncation cuts the least
181
+ * important field first. Ids live in the /glla agents table, not here.
182
+ * Overflow names its count without a command hint (v0.38.23: extension
183
+ * meta is noise). Silence ages stay bucketed — raw per-second values
184
+ * churn the widget key and re-layout the editor every tick (v0.37.1).
185
+ * v0.38.23 lifesign: the glyph also takes the semantic band color
186
+ * (success <5m, warning to 30m, error past it) — color never rides alone,
187
+ * the shape plus the silence number carry the same meaning unpainted. */
188
+ export function renderAgentsWidgetLines(rows: AgentsPanelRow[], now = Date.now(), maxRows = WIDGET_AGENT_ROW_CAP, theme?: DisplayTheme): string[] {
152
189
  const active = orderedRows(rows.filter((r) => r.status !== "ended"));
153
190
  const shown = active.slice(0, Math.max(1, maxRows));
154
191
  const lines: string[] = [];
155
192
  for (const row of shown) {
156
- // Keep identity/purpose and liveness fields on separate short lines so a
157
- // narrow terminal does not truncate the silence age—the field that tells
158
- // the user whether a worker is actually making progress.
159
- lines.push(`${rowLabel(row)} · id ${cleanField(row.recordId, 10)}`);
193
+ const glyph = row.status === "hung" ? "⚠"
194
+ : row.action === "abort-requested" ? "◉"
195
+ : (row.action === "unavailable" || row.action === "failed") ? "⚠"
196
+ : "▶";
197
+ const band = lifesignBandFor(row);
198
+ const color = band === "fresh" ? "success" : band === "aging" ? "warning" : "error";
199
+ const action = row.action === "abort-requested" ? " · aborting"
200
+ : row.action === "unavailable" ? " · abort unavailable"
201
+ : row.action === "failed" ? " · abort failed" : "";
160
202
  const evidence = row.evidence !== "live" ? ` · ${row.evidence}` : "";
161
- const action = row.action === "abort-requested" ? " · aborting" : row.action === "unavailable" ? " · abort unavailable" : row.action === "failed" ? " · abort failed" : "";
162
- lines.push(` ${rowStateWord(row, now)} · silent ${fmtDuration(row.silentMs)}${evidence}${action}`);
203
+ lines.push(`${paint(theme, color, glyph)} ${rowLabel(row)} · ${activeAgeLabel(row)}${action}${evidence}`);
163
204
  }
164
- if (active.length > shown.length) lines.push(`… ${active.length - shown.length} more agents · /glla agents`);
205
+ if (active.length > shown.length) lines.push(`… ${active.length - shown.length} more agents`);
165
206
  return lines;
166
207
  }
167
208
 
209
+ /** Safety invariant for the richness ladder — HUNG/aborting/failed
210
+ * workers are never silent, so `quiet` still surfaces them. Audit
211
+ * 2026-09-07: also covers action failed/unavailable, matching the
212
+ * lifesignBandFor hung classification and the red rows — the count line
213
+ * and the rows can never disagree about trouble. */
214
+ export function hasHungWorker(rows: AgentsPanelRow[]): boolean {
215
+ return rows.some((r) => r.status !== "ended"
216
+ && (r.status === "hung"
217
+ || r.action === "abort-requested"
218
+ || r.action === "failed"
219
+ || r.action === "unavailable"));
220
+ }
221
+
222
+ export type AgentsExtras = { line: string; lines: string[] };
223
+
224
+ /** v0.38.22 (display unification): the pure richness switch behind the
225
+ * widget/status worker presence. Rich restores detailed single-line rows
226
+ * (capped, bucketed — the widget key only moves on genuine state
227
+ * transitions); compact keeps the count line; quiet surfaces
228
+ * hung/aborting workers only. Pure and hermetic for tests; the caller
229
+ * supplies rows + richness. v0.38.23: the task-linkage header is gone —
230
+ * the card head already names the objective, so `→ <objective>` only
231
+ * repeated it verbatim on the next row. */
232
+ export function assembleAgentsExtras(
233
+ rows: AgentsPanelRow[],
234
+ richness: "rich" | "compact" | "quiet",
235
+ now = Date.now(),
236
+ theme?: DisplayTheme,
237
+ ): AgentsExtras | undefined {
238
+ const line = renderAgentsWidgetLine(rows);
239
+ if (!line) return undefined;
240
+ // Audit 2026-09-06 (DECIDED: show count line): quiet hides only when
241
+ // zero are tracked (line undefined above). Tracked-but-healthy keeps
242
+ // the compact count line — hiding healthy fan-out entirely cost ambient
243
+ // awareness. HUNG still surfaces via the ⚠ in the line itself.
244
+ // Audit 2026-09-07 (DECIDED: exceptions-only by default): quiet renders
245
+ // troubled (non-fresh-band) rows only — healthy fan-out lives on the
246
+ // native fleet panel, which shows it in more detail. The count line
247
+ // stays at every level (2026-09-06 ambient-awareness decision); HUNG is
248
+ // never silent at any level.
249
+ if (richness === "compact") return { line, lines: [] };
250
+ if (richness === "quiet") {
251
+ const troubled = rows.filter((r) => lifesignBandFor(r) !== "fresh");
252
+ return { line, lines: renderAgentsWidgetLines(troubled, now, WIDGET_AGENT_ROW_CAP, theme) };
253
+ }
254
+ return { line, lines: renderAgentsWidgetLines(rows, now, WIDGET_AGENT_ROW_CAP, theme) };
255
+ }
256
+
168
257
  /** The compact footer summary: count + the least-live child.
169
258
  * v0.37.1 (ui-jitter fix): bucket the silent age so the status/widget
170
259
  * text stays stable between ticks — a per-second change made the footer
@@ -174,22 +263,19 @@ export function renderAgentsWidgetLine(rows: AgentsPanelRow[]): string | undefin
174
263
  const active = rows.filter((r) => r.status !== "ended");
175
264
  if (active.length === 0) return undefined;
176
265
  const busiest = [...active].sort((a, b) => b.silentMs - a.silentMs)[0]!;
177
- const hung = busiest.action === "abort-requested"
266
+ // Audit 2026-09-07 (HIGH): the trouble flag scans ALL live children.
267
+ // Checking only the stalest hid a hung/aborting child behind a healthy
268
+ // stalest sibling — HUNG-never-silent means any troubled child flags
269
+ // the line, while the name/age still describe the stalest child.
270
+ const hung = active.some((r) => r.action === "abort-requested")
178
271
  ? " ⚠ aborting"
179
- : busiest.status === "hung"
272
+ : hasHungWorker(active)
180
273
  ? " ⚠"
181
274
  : "";
182
- return `● ${active.length} agent${active.length === 1 ? "" : "s"} · ${cleanField(busiest.agentType ?? "subagent", 18)} silent ${fmtDuration(bucketSilentMs(busiest.silentMs))}${hung}`;
275
+ return `● ${active.length} agent${active.length === 1 ? "" : "s"} · ${cleanField(busiest.agentType ?? "subagent", 18)} ${activeAgeLabel(busiest)}${hung}`;
183
276
  }
184
277
 
185
- /** Bucket silentMs to coarser granularity for display stability:
186
- * <1m → 5s buckets, <5m → 15s, otherwise 30s. The underlying
187
- * hung classification still uses the exact value. */
188
- function bucketSilentMs(ms: number): number {
189
- if (ms < 60_000) return Math.floor(ms / 5000) * 5000;
190
- if (ms < 300_000) return Math.floor(ms / 15_000) * 15_000;
191
- return Math.floor(ms / 30_000) * 30_000;
192
- }
278
+
193
279
 
194
280
  export interface TranscriptTailResult {
195
281
  ok: boolean;
@@ -1815,7 +1815,9 @@ function parseToolOverrideValue(s: string): unknown {
1815
1815
  /** v0.27.5: /glla reviewer | postaudit — the post-completion audit config menu
1816
1816
  * (project-scoped). Reads the dual-write settings (postaudit wins over the
1817
1817
  * legacy reviewer key), and writes back to whichever key was read first —
1818
- * so we don't drift two parallel config blocks. */
1818
+ * so we don't drift two parallel config blocks. Writes to the legacy key
1819
+ * don't stick: load-migration consolidates them into postaudit on the
1820
+ * next read (audit 2026-09-07, finding 395). */
1819
1821
  async function cmdReviewerSettings(ctx: ExtensionContext): Promise<void> {
1820
1822
  const settings = loadSettings(ctx.cwd);
1821
1823
  const block = (settings.postaudit ?? settings.reviewer) as Partial<ReviewerConfig> | undefined;
@@ -2459,11 +2461,19 @@ function cmdAgents(args: string, ctx: ExtensionContext): void {
2459
2461
  const tailMatch = args.match(/--tail\s+(\S+)/);
2460
2462
  if (tailMatch) {
2461
2463
  const id = tailMatch[1]!;
2462
- const row = agents.find((a) => a.recordId === id || a.recordId.startsWith(id));
2463
- if (!row) {
2464
+ // Audit 2026-09-06: an ambiguous prefix used to silently pick the
2465
+ // first match — name every candidate and let the user disambiguate.
2466
+ const exact = agents.find((a) => a.recordId === id);
2467
+ const cands = exact ? [exact] : agents.filter((a) => a.recordId.startsWith(id));
2468
+ if (cands.length === 0) {
2464
2469
  ctx.ui.notify(`No tracked subagent matches "${id}". /glla agents lists the current ids.`, "warning");
2465
2470
  return;
2466
2471
  }
2472
+ if (cands.length > 1) {
2473
+ ctx.ui.notify(`"${id}" matches ${cands.length} tracked subagents — be more specific:\n${cands.map((a) => ` ${a.recordId} (${a.agentType ?? "subagent"}, ${a.status})`).join("\n")}`, "warning");
2474
+ return;
2475
+ }
2476
+ const row = cands[0]!;
2467
2477
  const linesMatch = args.match(/--lines\s+(\d+)/);
2468
2478
  // v0.35.45 (audit finding): the candidate scan reads a bounded TAIL of
2469
2479
  // each transcript instead of up to 25 FULL files synchronously on the
@@ -2499,7 +2509,24 @@ function cmdAgents(args: string, ctx: ExtensionContext): void {
2499
2509
  ctx.ui.notify([header, ...result.lines, ...(result.ok ? [] : [result.detail])].join("\n"), result.ok ? "info" : "warning");
2500
2510
  return;
2501
2511
  }
2502
- ctx.ui.notify(`glla agents${managerAvailable ? "" : " (pi-subagents manager registry not present — event evidence only)"}\n${renderAgentsPanel(agents, Date.now(), managerAvailable).join("\n")}`, "info");
2512
+ // Audit 2026-09-06: the doc-promised "Recent hangs" footer — last 3
2513
+ // durable subagent_hang_detected events. Best-effort: a missing ledger
2514
+ // yields no footer, never an error row.
2515
+ let recentHangs: string[] = [];
2516
+ try {
2517
+ const now = Date.now();
2518
+ recentHangs = readLedgerTail(ctx.cwd, 25, (e) => e.type === "subagent_hang_detected")
2519
+ .slice(-3)
2520
+ .map((e) => {
2521
+ const v = (e.value ?? {}) as { agentType?: string; recordId?: string; at?: string };
2522
+ const ms = e.at ? now - Date.parse(e.at) : Number.NaN;
2523
+ const age = Number.isFinite(ms) && ms >= 0
2524
+ ? ms < 3_600_000 ? `${Math.max(0, Math.round(ms / 60_000))}m` : `${Math.floor(ms / 3_600_000)}h${String(Math.floor(ms / 60_000) % 60).padStart(2, "0")}m`
2525
+ : "?";
2526
+ return `${v.agentType ?? "subagent"} ${age} ago`;
2527
+ });
2528
+ } catch { recentHangs = []; }
2529
+ ctx.ui.notify(`glla agents${managerAvailable ? "" : " (pi-subagents manager registry not present — event evidence only)"}\n${renderAgentsPanel(agents, Date.now(), managerAvailable, recentHangs).join("\n")}`, "info");
2503
2530
  }
2504
2531
 
2505
2532
  // v0.29.8: /glla status — the unified "what's running" surface (user: "we
@@ -2555,7 +2582,12 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2555
2582
  // rather than through noisy inline assignments.
2556
2583
  const trimmed = args.trim();
2557
2584
  const verb = trimmed ? trimmed.split(/\s+/)[0]!.toLowerCase() : "ui";
2558
- if (staleEntry && (verb === "ui" || SETTINGS_MUTATING_ACTIONS.has(verb))) {
2585
+ // Audit 2026-09-07 (LOW, finding 399): bare `/glla fallbacks` only
2586
+ // displays the chain — only clear/off/unset/none mutates. Refusing the
2587
+ // read-only display on a stale handle blocks inspection for no safety.
2588
+ const fallbacksRest = verb === "fallbacks" ? trimmed.slice("fallbacks".length).trim().toLowerCase() : "";
2589
+ const fallbacksReadOnly = verb === "fallbacks" && !/^(clear|off|unset|none)(\s|$)/.test(fallbacksRest);
2590
+ if (staleEntry && (verb === "ui" || (!fallbacksReadOnly && SETTINGS_MUTATING_ACTIONS.has(verb)))) {
2559
2591
  appendLedger(ctx.cwd, "settings_mutation_refused_stale", { sub: verb });
2560
2592
  return;
2561
2593
  }
@@ -2706,6 +2738,11 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2706
2738
  fmt("visionAssist", "visionAssist"),
2707
2739
  fmt("auditorModel", "auditorModel"),
2708
2740
  fmt("auditorThinkingLevel", "thinking"),
2741
+ // Audit 2026-09-06: the headless fallback omitted these — headless
2742
+ // operators could not see the compactor chain or display richness.
2743
+ fmt("compactorModel", "compactorModel"),
2744
+ `compactorModelFallbacks: ${formatMainModelFallbacks(effectiveSettings.compactorModelFallbacks)} [${prov.compactorModelFallbacks?.source ?? "default"}]`,
2745
+ fmt("subagentDisplayRichness", "subagentDisplayRichness"),
2709
2746
  fmt("notifyCmd", "notify"),
2710
2747
  fmt("tokenLimit", "tokenLimit"),
2711
2748
  fmt("autoResume", "autoResume"),
@@ -33,6 +33,9 @@ export const COMPACTOR_BRIEF_MAX_CHARS = 2000;
33
33
  export const COMPACTOR_PACKET_MAX_CHARS = 6000;
34
34
  /** Worker wall clock: a brief is one completion, not an agentic loop. */
35
35
  export const COMPACTOR_TIMEOUT_MS = 180_000;
36
+ /** Audit 2026-09-06: grace between SIGTERM and SIGKILL when the worker
37
+ * overruns its timeout — SIGTERM alone can leave a stuck child alive. */
38
+ export const COMPACTOR_KILL_GRACE_MS = 5_000;
36
39
  /** The compactor reasons as little as possible: compression, not judgment. */
37
40
  export const COMPACTOR_THINKING = "minimal";
38
41
 
@@ -149,7 +152,19 @@ function defaultSpawnWorker(script: string, jobDir: string, request: Record<stri
149
152
  }
150
153
  const child = nodeSpawn(process.execPath, [script, "--job-dir", jobDir], { stdio: "ignore" });
151
154
  const timer = setTimeout(() => {
152
- try { child.kill("SIGTERM"); } catch {}
155
+ // Audit 2026-09-06: SIGTERM first, SIGKILL fallback — a stuck worker
156
+ // must not survive the timeout as a zombie holding the job dir.
157
+ try {
158
+ if (child.exitCode === null && child.signalCode === null) {
159
+ child.kill("SIGTERM");
160
+ const killTimer = setTimeout(() => {
161
+ try {
162
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
163
+ } catch { /* already gone */ }
164
+ }, COMPACTOR_KILL_GRACE_MS);
165
+ killTimer.unref?.();
166
+ }
167
+ } catch { /* already gone */ }
153
168
  done({ ok: false, error: "compactor worker timed out" });
154
169
  }, (typeof request.timeoutMs === "number" && request.timeoutMs > 0 ? request.timeoutMs : COMPACTOR_TIMEOUT_MS) + 15_000);
155
170
  timer.unref?.();
@@ -192,12 +207,37 @@ function pruneOldJobDirs(cwd: string, now = Date.now()): void {
192
207
  * model (chain → plan B → skip), persists the brief, notifies + pages.
193
208
  * Fire-and-forget safe: never throws.
194
209
  */
210
+ /** Durable one-shot marker — audit 2026-09-06: the in-memory refuse
211
+ * transition is process-local, so a restart mid-episode would re-fire the
212
+ * brief + page. The marker survives restarts; recovery (shouldRefuse=false)
213
+ * removes it to re-arm the next episode. */
214
+ export function compactorFiredMarkerPath(cwd: string): string {
215
+ return path.join(piGlaDir(cwd), "compactor-fired.json");
216
+ }
217
+
195
218
  export async function runEmergencyCompactorIfDue(
196
219
  ctx: Pick<ExtensionContext, "cwd" | "model" | "modelRegistry" | "getContextUsage">,
197
220
  shouldRefuseNow: boolean,
198
221
  deps: CompactorDeps = {},
199
222
  ): Promise<{ fired: boolean; briefChars?: number; via?: string }> {
200
- if (!claimCompactorRefuseTransition(shouldRefuseNow)) return { fired: false };
223
+ if (!shouldRefuseNow) {
224
+ claimCompactorRefuseTransition(false);
225
+ try { fs.rmSync(compactorFiredMarkerPath(ctx.cwd), { force: true }); } catch { /* re-arm best effort */ }
226
+ return { fired: false };
227
+ }
228
+ // Restart mid-episode: the marker says this episode already fired.
229
+ // Converge the in-memory transition (claim it) and stay silent.
230
+ let alreadyFired = false;
231
+ try { alreadyFired = fs.existsSync(compactorFiredMarkerPath(ctx.cwd)); } catch { alreadyFired = false; }
232
+ if (alreadyFired) {
233
+ claimCompactorRefuseTransition(true);
234
+ return { fired: false };
235
+ }
236
+ if (!claimCompactorRefuseTransition(true)) return { fired: false };
237
+ try {
238
+ fs.mkdirSync(piGlaDir(ctx.cwd), { recursive: true });
239
+ fs.writeFileSync(compactorFiredMarkerPath(ctx.cwd), JSON.stringify({ at: new Date().toISOString() }) + "\n");
240
+ } catch { /* marker write is best effort; in-memory one-shot still holds this process */ }
201
241
  try {
202
242
  return await runEmergencyCompactor(ctx, deps);
203
243
  } catch (error) {
@@ -34,6 +34,7 @@ import {
34
34
  readLedgerTail,
35
35
  nowIso,
36
36
  newGoalId,
37
+ archiveDir,
37
38
  archivedGoalPath,
38
39
  goalMdPath,
39
40
  writeGoalMd,
@@ -257,6 +258,20 @@ let continuationScheduledFor: string | null = null;
257
258
 
258
259
  let lastContinuationSentAt = 0;
259
260
 
261
+ // Audit 2026-09-07 (MEDIUM): wall-clock of the last genuine user-role
262
+ // message observed via message_start. A user message arriving after a
263
+ // dispatch was sent means the next agent_start/turn_start belongs to the
264
+ // user's manual turn, not the continuation — the fallback ack must refuse
265
+ // it so a manual turn can't hide a genuine no-start. Our own continuations
266
+ // send as customType (never role user) and our injected draft seed is
267
+ // skipped by the caller, so only real user turns stamp this.
268
+ let lastUserMessageAt = 0;
269
+
270
+ /** Record a genuine user message (message_start, role user, not our seed). */
271
+ export function noteUserMessageForDispatch(): void {
272
+ lastUserMessageAt = Date.now();
273
+ }
274
+
260
275
  let queueStuckProbe: ReturnType<typeof setTimeout> | null = null;
261
276
 
262
277
  // v0.28.5 (E3): send-retry re-arm accounting. The 50ms BACKOFF_IDLE_RETRY
@@ -653,7 +668,19 @@ export function dispatchStartAcknowledged(ctx: ExtensionContext, source: string,
653
668
  if (source === "before_agent_start") {
654
669
  if (!dispatchPromptMatches(record, prompt)) return false;
655
670
  } else if (source === "agent_start" || source === "turn_start") {
656
- // fallback — no prompt to match
671
+ // fallback — no prompt to match. But a user message after the send
672
+ // means this turn is the user's manual turn, not the continuation:
673
+ // refuse so the watchdog keeps watching the genuine no-start.
674
+ // before_agent_start with the marker still acks (strongest proof).
675
+ if (lastUserMessageAt > record.sentAt) {
676
+ appendLedger(ctx.cwd, "continuation_start_ack_refused_user_turn", {
677
+ dispatchId: record.id,
678
+ source,
679
+ sentAt: record.sentAt,
680
+ lastUserMessageAt,
681
+ });
682
+ return false;
683
+ }
657
684
  } else {
658
685
  // message_update / agent_end / other liveness signals cannot settle
659
686
  // without an existing proof, and pending.phase !== "accepted" already
@@ -934,9 +961,21 @@ export function guardGoalBeforeContinuation(
934
961
  if (goal.status === "auditing" && !allowAuditing) return false;
935
962
  if (goal.status !== "active" && goal.status !== "paused" && goal.status !== "auditing") return false;
936
963
 
937
- const storedArchive = goal.archivedPath
938
- ? (path.isAbsolute(goal.archivedPath) ? goal.archivedPath : path.resolve(ctx.cwd, goal.archivedPath))
939
- : archivedGoalPath(ctx.cwd, goal.id);
964
+ // Audit 2026-09-06: archivedPath is durable state and may be corrupted —
965
+ // the old code existsSync-probed ANY absolute path it named (arbitrary
966
+ // filesystem existence oracle) and then nulled the live goal on a hit.
967
+ // Only honor it inside the archive dir; anything else is an anomaly that
968
+ // falls back to the canonical location.
969
+ let storedArchive = archivedGoalPath(ctx.cwd, goal.id);
970
+ if (goal.archivedPath) {
971
+ const candidate = path.isAbsolute(goal.archivedPath) ? goal.archivedPath : path.resolve(ctx.cwd, goal.archivedPath);
972
+ const rel = path.relative(archiveDir(ctx.cwd), candidate);
973
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
974
+ storedArchive = candidate;
975
+ } else {
976
+ appendLedger(ctx.cwd, "faulty_objective_archive_path_anomaly", { goalId: goal.id, where, archivedPath: String(goal.archivedPath).slice(0, 200) });
977
+ }
978
+ }
940
979
  if (fs.existsSync(storedArchive)) {
941
980
  appendLedger(ctx.cwd, "faulty_objective_archive_fence", { goalId: goal.id, where, archive: storedArchive });
942
981
  try { fs.rmSync(goalMdPath(ctx.cwd, goal.id), { force: true }); } catch { /* best effort */ }
@@ -1061,10 +1100,13 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
1061
1100
  if (pendingContinuationDispatch) return;
1062
1101
  if (continuationDispatchStoodDown && !force) return;
1063
1102
  if (force) releaseContinuationDispatchStandDown();
1064
- flags.abortedStandDown = false; // v0.29.5: any explicit schedule ends the stand-down
1065
1103
  if (!isActionableGoal()) return;
1066
1104
  if (!guardGoalBeforeContinuation(ctx, "schedule")) return;
1067
1105
  if (!isActionableGoal()) return;
1106
+ // v0.29.5: an explicit schedule ends the stand-down — audit 2026-09-06:
1107
+ // only a schedule that actually arms clears it. Clearing before the
1108
+ // gates let no-op schedules discharge the user's abort latch.
1109
+ flags.abortedStandDown = false;
1068
1110
  rememberCtx(ctx);
1069
1111
  const goalId = state.goal!.id;
1070
1112
  if (!force && continuationScheduledFor === goalId) return;
@@ -1102,6 +1144,10 @@ export interface TerminalCompletionNotice {
1102
1144
  goalId: string;
1103
1145
  outcome: string;
1104
1146
  details: string[];
1147
+ /** Audit 2026-09-06: the session generation that produced the verdict.
1148
+ * A stale generation (verdict applied after handoff/reload) must not
1149
+ * fire a `✓ done` turn into the successor session's unrelated work. */
1150
+ generation?: number;
1105
1151
  }
1106
1152
 
1107
1153
  /** v0.38.18 (track 3: junk-runner stale waiting-verdict): the detached
@@ -1118,8 +1164,26 @@ export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: Term
1118
1164
  if (supervisorPaused(state)) return false;
1119
1165
  if (mainModelRecoveryActive()) return false;
1120
1166
  if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return false;
1167
+ // Audit 2026-09-07 (HIGH, scoped): refuse while the user-abort latch
1168
+ // stands — a `✓ done` turn firing under the user's hands after Esc/zombie
1169
+ // abort violates the stand-down promise. Deliberately NOT gated on
1170
+ // stoodDown/pending: closure wins there (a pending dispatch fences itself
1171
+ // on the archived goal), and dropping the notice would lose transcript
1172
+ // closure with no retry path.
1173
+ if (flags.abortedStandDown) {
1174
+ appendLedger(ctx.cwd, "terminal_completion_notice_refused_stood_down", { goalId: notice.goalId });
1175
+ return false;
1176
+ }
1121
1177
  if (!flags.extensionApi) return false;
1122
1178
  if (isForeignCtx(ctx)) return false;
1179
+ if (typeof notice.generation === "number" && notice.generation !== flags.sessionGeneration) {
1180
+ appendLedger(ctx.cwd, "terminal_completion_notice_stale_generation", {
1181
+ goalId: notice.goalId,
1182
+ noticeGeneration: notice.generation,
1183
+ currentGeneration: flags.sessionGeneration,
1184
+ });
1185
+ return false;
1186
+ }
1123
1187
  try {
1124
1188
  const already = readLedgerTail(ctx.cwd, 400, (entry) =>
1125
1189
  entry.type === "terminal_completion_notice_sent" &&
@@ -1201,7 +1265,11 @@ export function sendContinuation(goalId: string): void {
1201
1265
  // refuses new schedules; this closes the armed-timer race.
1202
1266
  if (supervisorPaused(state)) return;
1203
1267
  if (mainModelRecoveryActive()) return;
1204
- if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown || continuationDispatchStoodDown || pendingContinuationDispatch) return;
1268
+ // Audit 2026-09-07 (HIGH): an armed timer must not fire after a user-Esc
1269
+ // or zombie abort. continuationDispatchStoodDown alone is insufficient —
1270
+ // explicit resume paths release it while flags.abortedStandDown stays set
1271
+ // until a schedule actually arms, so check the abort latch here too.
1272
+ if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown || continuationDispatchStoodDown || pendingContinuationDispatch || flags.abortedStandDown) return;
1205
1273
  continuationTimer = null;
1206
1274
  continuationScheduledFor = null;
1207
1275
  if (!state.goal || state.goal.id !== goalId) {
@@ -1341,7 +1409,9 @@ export function sendContinuation(goalId: string): void {
1341
1409
  // call otherwise. display: true — the user should see the warning too.
1342
1410
  export function sendStallEscalation(ctx: ExtensionContext, nudges: number): void {
1343
1411
  if (supervisorPaused(state)) return;
1344
- if (flags.sessionHandoffPending || flags.initialSessionLoadPending || !flags.extensionApi || flags.extensionApiStale || continuationDispatchStoodDown || pendingContinuationDispatch) return;
1412
+ // Audit 2026-09-07 (HIGH): a stall nudge must not resurrect a stood-down
1413
+ // chain — same abort-latch reasoning as sendContinuation.
1414
+ if (flags.sessionHandoffPending || flags.initialSessionLoadPending || !flags.extensionApi || flags.extensionApiStale || continuationDispatchStoodDown || pendingContinuationDispatch || flags.abortedStandDown) return;
1345
1415
  if (!state.goal || !guardGoalBeforeContinuation(ctx, "stall-escalation")) return;
1346
1416
  const remaining = HEARTBEAT_MAX_NUDGES - nudges;
1347
1417
  const text = [
@@ -1383,7 +1453,9 @@ export function sendStallEscalation(ctx: ExtensionContext, nudges: number): void
1383
1453
  // plain sessions truncate too.
1384
1454
  export function sendLengthContinue(ctx: ExtensionContext, consecutive: number): void {
1385
1455
  if (supervisorPaused(state)) return;
1386
- if (flags.sessionHandoffPending || flags.initialSessionLoadPending || !flags.extensionApi || flags.extensionApiStale || continuationDispatchStoodDown || pendingContinuationDispatch) return;
1456
+ // Audit 2026-09-07 (HIGH): a length nudge must not resurrect a stood-down
1457
+ // chain — same abort-latch reasoning as sendContinuation.
1458
+ if (flags.sessionHandoffPending || flags.initialSessionLoadPending || !flags.extensionApi || flags.extensionApiStale || continuationDispatchStoodDown || pendingContinuationDispatch || flags.abortedStandDown) return;
1387
1459
  if (state.goal && !guardGoalBeforeContinuation(ctx, "length-continuation")) return;
1388
1460
  const attempt = dispatchPrepare(ctx, {
1389
1461
  generation: flags.sessionGeneration,
@@ -45,6 +45,7 @@ import { isLoopActive, loopTimerPending, scheduleLoopTick } from "./goal-loop.js
45
45
  import { mainModelRecoveryActive, markCompletionAuditRecoveryPending, probeMainModelRecovery } from "./goal-recovery.js";
46
46
  import type { ContinuationDispatch } from "./goal-loop-dispatch.js";
47
47
  import type { AgentPhase, AgentStatus } from "./goal-agents-panel.js";
48
+ import { blockedByLabel } from "./goal-agents-panel.js";
48
49
  import { ContinuousSupervisor, SUPERVISION_MAX_POLL_MS, type SupervisionSignal } from "./continuous-supervision.js";
49
50
 
50
51
  /** goal.ts-owned module lets the heartbeat reads/writes through this accessor.
@@ -1661,6 +1662,8 @@ export interface SubagentAgentView {
1661
1662
  /** ms since the last NEW progress (tool use / output tokens). */
1662
1663
  silentMs: number;
1663
1664
  evidence: "record-frozen" | "event-only" | "live";
1665
+ /** Observed in-flight parent subagent wait (audit 2026-09-06 `└ blocks:`). */
1666
+ blockedBy?: string;
1664
1667
  hangAlertedAt?: number;
1665
1668
  action?: "abort-requested" | "unavailable" | "failed";
1666
1669
  endedAt?: number;
@@ -1671,6 +1674,14 @@ function finiteNonNegative(value: unknown): number | undefined {
1671
1674
  }
1672
1675
 
1673
1676
  export function getSubagentAgentsSnapshot(now = Date.now()): { agents: SubagentAgentView[]; managerAvailable: boolean } {
1677
+ // Audit 2026-09-06: the doc-promised `└ blocks:` row — label live rows
1678
+ // with the OBSERVED in-flight parent wait (tool args carry no run id, so
1679
+ // per-row correlation is impossible; the name is named, never inferred).
1680
+ let blockedBy: string | undefined;
1681
+ try {
1682
+ const waits = [...flags.inFlightToolCalls.values()].filter(isSubagentWaitCall).map((t) => t.name ?? "");
1683
+ blockedBy = blockedByLabel(waits);
1684
+ } catch { blockedBy = undefined; }
1674
1685
  const poll = subagentManagerPoller();
1675
1686
  const managerAvailable = typeof poll.getRecord === "function";
1676
1687
  const agents: SubagentAgentView[] = [];
@@ -1726,6 +1737,7 @@ export function getSubagentAgentsSnapshot(now = Date.now()): { agents: SubagentA
1726
1737
  outputTokens,
1727
1738
  silentMs,
1728
1739
  evidence,
1740
+ ...(blockedBy && status !== "ended" ? { blockedBy } : {}),
1729
1741
  ...(probe.hangAlertedAt !== undefined ? { hangAlertedAt: probe.hangAlertedAt } : {}),
1730
1742
  ...(probe.hangAction !== undefined ? { action: probe.hangAction } : {}),
1731
1743
  ...(probe.endedAt !== undefined ? { endedAt: probe.endedAt } : {}),