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
package/docs/DESIGN.md CHANGED
@@ -255,9 +255,9 @@ replacement without delivering a successor `session_start`:
255
255
  flattens extensions in load order and suffixes EVERY registration of a duplicated
256
256
  command name (`name:1`, `name:2`, …) — the bare name becomes owned by nobody and
257
257
  dispatch stops routing it while a collision exists. The model is read from the
258
- installed pi core (hermetic, never modified) and auto-records the routing table to
259
- `audit/command-registration-routing.md`, making collisions reproducible and
260
- diagnosable without touching pi.
258
+ installed pi core (hermetic, never modified) and records a process-scoped
259
+ routing diagnostic, making collisions reproducible without rewriting tracked
260
+ repository files or touching pi.
261
261
  - **Unmatched telemetry stays unmatched** (v0.34.56): tool starts/ends without a
262
262
  counterpart are represented as explicitly unmatched facts, never falsely paired —
263
263
  the report surface stays truthful (the AuditProgress/AuditorProgress dual-interface
@@ -429,15 +429,20 @@ v0.1.0 ships the same auditor behaviour as pi-goal-x. The author of pi-goal-x do
429
429
 
430
430
  We accept this caveat for v0.1.0. v0.2.0 will add **regression_shield**: an explicit requirement that the auditor's report must include raw output (a `cat`, a `grep -A 5 <file>`, a `bash <user-script>`) for every item in `verificationContract`. Without that evidence, the auditor's `<approved/>` is rejected by the orchestrator.
431
431
 
432
- ### Decision 3: Hard 5-minute backoff cap
432
+ ### Decision 3: Hard 5-minute backoff cap (SUPERSEDED v0.35.4)
433
433
 
434
434
  The #1 complaint about pi-goal-x in our audit (user-stated) was "1-hour waits". The cause is exponential backoff with no ceiling.
435
435
 
436
- v0.1.0 ships a hard 5-minute cap. After 5 minutes of consecutive backoff:
436
+ v0.1.0 shipped a hard 5-minute cap. After 5 minutes of consecutive backoff:
437
437
  1. TUI badge turns red with "Last activity: 5m+".
438
438
  2. User can press `r` to force-continue or `s` to skip to next pending task.
439
439
  3. Optional: configure Telegram/web push notification.
440
440
 
441
+ > **Superseded (v0.35.4):** the cap had zero production call sites and was
442
+ > removed (`extensions/goal-loop-backoff.ts` header). The live envelope is
443
+ > the eager 5s first retry + hourly-aligned ladder with stall-ladder/heartbeat
444
+ > coverage — see README's retry section.
445
+
441
446
  ### Decision 4: No drafting phase in v0.1.0 (deferred to v0.2.0)
442
447
 
443
448
  The user identified vague-correction as a key strength of pi-goal-x. But shipping it in v0.1.0 doubles the scope and we won't get the auditor right if we split focus.
@@ -494,8 +499,11 @@ This is a **clean break** by decision of the user. We do not interop with `pi-go
494
499
  ### Decision 8: Status machine
495
500
 
496
501
  ```ts
502
+ // Historical sketch from v0.2.0 planning. The SHIPPED Status union is
503
+ // "active" | "auditing" | "complete" | "paused" | "aborted" (drafting is a
504
+ // UI phase, not a persisted status).
497
505
  type Status =
498
- | "drafting" // v0.2.0
506
+ | "drafting" // v0.2.0 (planned; not a persisted Status value)
499
507
  | "active"
500
508
  | "auditing"
501
509
  | "complete"
@@ -565,10 +573,53 @@ This protects against model-generated summaries losing fidelity.
565
573
  - *Current*: Parallel subagent exploration fan-out (spawning multiple `Explore` agents in one turn) and parallel disjoint-worktree implementation (`general-purpose` workers with `isolation: "worktree"`). Detached auditor runs concurrently in background OS process.
566
574
  - *Opportunities*: Concurrent `/list` dispatch across non-conflicting tasks using isolated worktrees to eliminate head-of-line blocking for large queues, plus background contract rehearsals.
567
575
 
576
+ ## Addendum v0.35.7–v0.35.31 (consolidated behavior addenda)
577
+
578
+ Per the doc convention, these releases appended the following architectural
579
+ shapes (details in CHANGELOG.md; each is pinned by tests):
580
+
581
+ - **Supervisor freeze — `/glla pause` (v0.35.15/17)**: freezes ALL automatic
582
+ machinery (re-arms, recovery probes, auto-resume, continuation dispatch,
583
+ proactive notifies) without touching active work or a running detached
584
+ auditor. Distinct from goal pause; only `/glla resume` clears it.
585
+ - **Load hold (v0.35.23)**: a cold session load that restores pending state
586
+ WITHOUT explicit consent holds automation (`loadHoldAt`) until an explicit
587
+ work command releases it. Refines the restore-gate consent story above.
588
+ - **Auditor picker parity (v0.35.24)**: `promptModelRef` accepts
589
+ `excludeRefs`; forbiddenModels policy refs are filtered from lists AND
590
+ refused on typed input (`forbidden_model_switch`).
591
+ - **Zero-stream zombie park + RESUMABLE_STOP (v0.35.25/26)**: a run with no
592
+ provider activity parks as `stopped: automatic zero-stream abort…` and
593
+ `/loop resume` retries it; the zombie watchdog stands down while tracked
594
+ subagent waits are in flight via ONE shared `SUBAGENT_WAIT_TOOL_NAMES`
595
+ predicate (no per-site name lists).
596
+ - **Windows auditor launch, gate-before-quote (v0.35.27)**: unsafe-arg
597
+ rejection runs on EVERY argument before the quoting decision; bare tokens
598
+ reach cmd.exe untouched so pnpm .CMD shims resolve.
599
+ - **Heartbeat due-wait backstop + recovery notice (v0.35.28)**: wall-clock
600
+ comparison against persisted `pauseResumeAt` in the tick resumes lapsed
601
+ wait/error-brake parks after a 90s grace (consent-gated); successful
602
+ recoveries stamp `autoResumedAt`/`autoResumedEvent` and the continuation
603
+ prompt renders a RECOVERY NOTICE instead of waiting on a fired signal.
604
+ - **Tracked-subagent visibility (v0.35.29)**: `getSubagentAgentsSnapshot()`
605
+ (read-only probe views with hung classification) feeds `/glla agents`,
606
+ `/glla agents --tail <id>` (read-only child transcript tail), and a widget
607
+ segment — pure rendering in `extensions/goal-agents-panel.ts`.
608
+ - **Durable last-outcome retention (v0.35.30)**: archiving writes
609
+ `state.lastOutcome`; the widget keeps one dim ✓/▪ line for 24h after the
610
+ slot empties so a finished audit stays visible after the turn ends.
611
+ - **Loop plateau vs never-moved baselines (v0.35.31)**: flat metric readings
612
+ count toward plateau only once the metric has demonstrably moved; a metric
613
+ that NEVER moves gets its own loud bounded stop. Audit loops keep their
614
+ purpose-built deferred-baseline + reprieve semantics verbatim.
615
+
568
616
  ## Files
569
617
 
570
618
  - `docs/DESIGN.md` — **this file**
571
619
  - `README.md` — quickstart
572
- - `audit/pi-name-v3-registry-based.md` — naming rationale
573
- - `audit/pi-goal-loop-design.md` — earlier design (now superseded)
620
+ - `docs/INDEX.md` — shipped documentation and architecture entry points
621
+ - `README.md` — user-facing installation and operating guidance
622
+
623
+ Historical audit notes are repository-only and are intentionally not part of
624
+ the published package.
574
625
 
@@ -78,7 +78,12 @@ detached-auditor loop, but it is also a deliberate trade:
78
78
 
79
79
  ## 4. The refactor: decompose goal.ts (the plan skeleton)
80
80
 
81
- Current state: `extensions/loops/goal.ts` ≈ 10.5K lines, 189 top-level
81
+ > **STATUS (2026-08-22): IMPLEMENTED.** This plan shipped in decomposition
82
+ > steps 1–6 (v0.34.109–v0.34.114). `extensions/loops/goal.ts` is now a thin
83
+ > activation/wiring installer (~413 lines); every module below exists under
84
+ > `extensions/` and `extensions/loops/`. Kept for rationale/history.
85
+
86
+ Current state at planning time: `extensions/loops/goal.ts` ≈ 10.5K lines, 189 top-level
82
87
  functions, covering at least these domains (grep counts):
83
88
 
84
89
  ```
package/docs/INDEX.md CHANGED
@@ -18,6 +18,10 @@ For shipped docs, the relevant entry points are:
18
18
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
19
19
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
20
20
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
21
+ v0.35.14–v0.35.31 continue through the supervisor freeze (`/glla pause`),
22
+ load hold, auditor picker parity, Windows launch fix, zombie-watchdog
23
+ subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
24
+ and durable last-outcome retention — see CHANGELOG.md for the full trail.
21
25
  - `../README.md` — what the plugin is, install, quickstart, and the
22
26
  architectural guarantee (drafting + confirm + detached auditor).
23
27
  - `../INSTALL.md` — manual install / symlink setup; the recommended
@@ -66,8 +66,8 @@ Edit: `/glla` → **Keep-going** → **Vision assist**, then choose **off**.
66
66
  | Command builder | `visionDescribeCommand(imagePath, question?)` |
67
67
  | Routing rule (pure) | `routeVisionCheck(request)` — mmx by default; forbidden target → mmx + `blockedSwitch`; preapproved target → `model-switch` allowed |
68
68
  | Ledger payload builder | `visionAssistLedger(route, request)` |
69
- | Continuation injection | `extensions/loops/goal.ts` `continuationPrompt()` (gated on `visionAssist !== false`) |
70
- | Forbidden-switch hook | `observeModelChange()` forbidden branch → `vision_assist` entry |
69
+ | Continuation injection | `extensions/goal-continuation.ts` pushes `VISION_ASSIST_GUIDANCE` into the continuation directives (gated on `visionAssist !== false`) |
70
+ | Forbidden-switch hook | forbidden-model gate in the settings editors/model pickers (`extensions/loops/goal-settings-ui.ts`, `extensions/loops/goal-activation.ts`) → `forbidden_model_switch` + `vision_assist` ledger entries |
71
71
  | Setting | `extensions/goal-settings.ts` (default true), menu row in `extensions/settings-menu.ts`, editor + `/glla` row in `extensions/loops/goal.ts` |
72
72
  | Tests | `tests/vision-assist.test.ts` |
73
73
 
@@ -0,0 +1,289 @@
1
+ // pi-goal-list-loop-audit — v0.36.0
2
+ // extensions/auditor-extensions.ts
3
+ //
4
+ // Discovery + normalization for the auditor extension allowlist
5
+ // (`auditorAllowedExtensions`, GitHub issue: "Allow extension-based model
6
+ // providers in detached auditor sessions").
7
+ //
8
+ // The detached auditor worker spawns `pi --mode rpc --no-extensions …`. Pi's
9
+ // `--extension <spec>` flag still loads explicitly given specs even under
10
+ // `--no-extensions`, BUT a raw `npm:<pkg>` spec makes pi perform a fresh
11
+ // *temporary* npm install into `~/.pi/agent/tmp/extensions/npm/<hash>` on
12
+ // every spawn — network-bound, hundreds of MB, and completely dead offline
13
+ // (field-verified by the 2026-08-22 audit: `-e npm:pi-cursor-sdk` with
14
+ // PI_OFFLINE=1 registered 0 models; the resolved install path registered
15
+ // 209). Relative `extensions[]` entries are likewise resolved by pi against
16
+ // the *auditor's cwd*, not the settings base dir, so a verbatim
17
+ // `../../ghq/...` spec silently loads nothing.
18
+ //
19
+ // We therefore resolve every allowlist entry to a concrete, existing
20
+ // absolute path BEFORE it reaches the worker:
21
+ // • `npm:<name>[@version]` → `~/.pi/agent/npm/node_modules/<name>` (or the
22
+ // project `.pi/npm/node_modules/<name>` equivalent) — existing install
23
+ // only, never triggering a new one.
24
+ // • `git:<url>` → `~/.pi/agent/git/<host>/<user>/<project>` (or project
25
+ // `.pi/git/...` equivalent).
26
+ // • paths → `~` expanded; relative paths resolved against the settings
27
+ // base dir (user scope: `~/.pi/agent`, project scope: `<cwd>/.pi`),
28
+ // matching pi's own `getBaseDirForScope` resolution.
29
+ // Entries that do not resolve to an existing path are dropped (fail-closed)
30
+ // rather than emitted as unloadable specs.
31
+ //
32
+ // Discovery is read-only and best-effort: unreadable files simply contribute
33
+ // no entries. The picker menu (Auditor tab) is the only consumer besides the
34
+ // dispatch path, which re-reads the normalized settings at request time.
35
+
36
+ import * as fs from "node:fs";
37
+ import * as path from "node:path";
38
+
39
+ /** One extension the user could allow-list for the auditor. */
40
+ export interface DiscoveredPiExtension {
41
+ /** The spec to pass to `pi --extension` / store in settings.
42
+ * Settings-sourced entries are RESOLVED to concrete install paths (see
43
+ * module header); directory-discovered entries are already absolute. */
44
+ spec: string;
45
+ /** Short human label (package name or file/dir basename). */
46
+ label: string;
47
+ /** Where the entry came from — for the picker's descriptive text. */
48
+ source: "package" | "extensions-setting" | "user-dir" | "project-dir";
49
+ /** The raw settings entry this was resolved from (specs resolved from
50
+ * packages[]/extensions[] keep the original string for display). */
51
+ raw?: string;
52
+ }
53
+
54
+ /** Parse an npm package spec into its install directory name.
55
+ * Mirrors pi's PackageManager.parseNpmSpec: `@scope/pkg@1.2.3` →
56
+ * `@scope/pkg`; `pkg@^2` → `pkg`; `pkg` → `pkg`. */
57
+ function npmSpecName(spec: string): string {
58
+ const match = spec.match(/^(@?[^@]+(?:\/?[^@]+)?)(?:@(.+))?$/);
59
+ return (match?.[1] ?? spec).trim() || spec;
60
+ }
61
+
62
+ /** Parse a git source into `{ host, repoPath }` where repoPath is
63
+ * `user/project` (`.git` and ref stripped). Mirrors the subset of pi's
64
+ * parseGitUrl/splitRef that settings `packages[]` entries can contain.
65
+ * Returns undefined when the string is not a git source. */
66
+ function parseGitSpec(source: string): { host: string; repoPath: string } | undefined {
67
+ let url = source.trim();
68
+ if (url.startsWith("git:")) url = url.slice(4).trim();
69
+ else if (!/^(https?|ssh|git):\/\//i.test(url) && !/^git@[^:]+:/.test(url) && !/^[^.\s]+\.[^.\s]+\//.test(url)) {
70
+ return undefined; // not a git-shaped source
71
+ }
72
+ url = url.split("#")[0] ?? url; // strip #ref
73
+ let host = "";
74
+ let rest = "";
75
+ const scp = url.match(/^git@([^:]+):(.+)$/);
76
+ if (scp) {
77
+ host = scp[1] ?? "";
78
+ rest = scp[2] ?? "";
79
+ } else if (url.includes("://")) {
80
+ try {
81
+ const parsed = new URL(url);
82
+ host = parsed.hostname;
83
+ rest = parsed.pathname.replace(/^\/+/, "");
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ } else {
88
+ const slash = url.indexOf("/");
89
+ if (slash < 0) return undefined;
90
+ host = url.slice(0, slash);
91
+ rest = url.slice(slash + 1);
92
+ }
93
+ const at = rest.indexOf("@"); // strip @ref (pi's splitRef semantics)
94
+ if (at >= 0) rest = rest.slice(0, at);
95
+ rest = rest.replace(/\.git$/, "").replace(/^\/+/, "");
96
+ const parts = rest.split("/").filter(Boolean);
97
+ if (!host || parts.length < 2 || parts.some((p) => p === "..")) return undefined;
98
+ return { host, repoPath: parts.join("/") };
99
+ }
100
+
101
+ function firstExisting(candidates: string[]): string | undefined {
102
+ for (const candidate of candidates) {
103
+ try {
104
+ if (fs.existsSync(candidate)) return candidate;
105
+ } catch {
106
+ /* unreadable — treat as missing */
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /** Expand `~` and resolve a path against a base dir (pi's
113
+ * resolvePathFromBase semantics for settings entries). */
114
+ function resolveSettingsPath(input: string, baseDir: string, home: string): string {
115
+ if (input.startsWith("~")) return path.join(home, input.slice(1).replace(/^\/+/, ""));
116
+ return path.resolve(baseDir, input);
117
+ }
118
+
119
+ /** Resolve one allowlist spec to a concrete existing absolute path the
120
+ * worker can pass as `pi --extension <path>` without triggering installs.
121
+ * `base` is the settings base dir for relative paths (user scope:
122
+ * `~/.pi/agent`; project scope: `<cwd>/.pi`). Returns undefined when the
123
+ * entry does not resolve to something loadable — callers drop it
124
+ * (fail-closed) rather than emit an unloadable spec. */
125
+ export function resolveAuditorExtensionSpec(
126
+ spec: string,
127
+ opts: { home: string; cwd?: string; base?: string },
128
+ ): string | undefined {
129
+ const agentDir = path.join(opts.home, ".pi", "agent");
130
+ const projectDir = opts.cwd ? path.join(opts.cwd, ".pi") : undefined;
131
+ const trimmed = spec.trim();
132
+ if (trimmed.startsWith("npm:")) {
133
+ const name = npmSpecName(trimmed.slice(4));
134
+ if (!name) return undefined;
135
+ return firstExisting([
136
+ path.join(agentDir, "npm", "node_modules", name),
137
+ ...(projectDir ? [path.join(projectDir, "npm", "node_modules", name)] : []),
138
+ ]);
139
+ }
140
+ const git = parseGitSpec(trimmed);
141
+ if (git) {
142
+ return firstExisting([
143
+ path.join(agentDir, "git", git.host, ...git.repoPath.split("/")),
144
+ ...(projectDir ? [path.join(projectDir, "git", git.host, ...git.repoPath.split("/"))] : []),
145
+ ]);
146
+ }
147
+ // Local path: `~`/absolute as-is, relative against the settings base dir
148
+ // (NOT the auditor's future cwd — pi resolves -e against cwd, so emitting
149
+ // a relative spec would silently load nothing).
150
+ if (/^[a-z]+:/.test(trimmed)) return undefined; // unknown scheme — no safe resolution
151
+ const resolved = resolveSettingsPath(trimmed, opts.base ?? agentDir, opts.home);
152
+ return firstExisting([resolved]);
153
+ }
154
+
155
+ /** Resolve an allowlist to worker-ready install paths: every entry maps to
156
+ * a concrete existing path; unresolvable entries are dropped fail-closed. */
157
+ export function resolveAuditorAllowedExtensions(
158
+ specs: string[] | undefined,
159
+ home: string,
160
+ cwd?: string,
161
+ ): string[] {
162
+ const out: string[] = [];
163
+ const seen = new Set<string>();
164
+ for (const spec of normalizeAuditorAllowedExtensions(specs)) {
165
+ const resolved = resolveAuditorExtensionSpec(spec, { home, cwd, base: path.join(home, ".pi", "agent") });
166
+ if (!resolved || seen.has(resolved)) continue; // not installed here — skip, don't emit a dead spec
167
+ seen.add(resolved);
168
+ out.push(resolved);
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /** Bounded, deterministic normalization of the allowlist value. */
174
+ export function normalizeAuditorAllowedExtensions(value: unknown): string[] {
175
+ if (!Array.isArray(value)) return [];
176
+ const seen = new Set<string>();
177
+ const out: string[] = [];
178
+ for (const entry of value) {
179
+ if (typeof entry !== "string") continue;
180
+ const spec = entry.trim();
181
+ if (!spec || seen.has(spec)) continue;
182
+ if (out.length >= 32) break; // bounded: the request hash stays sane
183
+ seen.add(spec);
184
+ out.push(spec);
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function readJsonArray(file: string): string[] {
190
+ try {
191
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
192
+ if (typeof parsed !== "object" || parsed === null) return [];
193
+ const out: string[] = [];
194
+ for (const key of ["packages", "extensions"]) {
195
+ const value = (parsed as Record<string, unknown>)[key];
196
+ if (!Array.isArray(value)) continue;
197
+ for (const entry of value) {
198
+ if (typeof entry === "string" && entry.trim()) out.push(entry.trim());
199
+ }
200
+ }
201
+ return out;
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ /** Auto-discovered extension entries in a directory: *.ts / *.js files and
208
+ * subdirectories (loaded by pi as <dir>/index.ts). */
209
+ function discoverDir(dir: string, source: DiscoveredPiExtension["source"]): DiscoveredPiExtension[] {
210
+ let entries: fs.Dirent[];
211
+ try {
212
+ entries = fs.readdirSync(dir, { withFileTypes: true });
213
+ } catch {
214
+ return [];
215
+ }
216
+ const out: DiscoveredPiExtension[] = [];
217
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
218
+ const full = path.join(dir, entry.name);
219
+ if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
220
+ out.push({ spec: full, label: entry.name, source });
221
+ } else if (entry.isDirectory()) {
222
+ out.push({ spec: full, label: `${entry.name}/`, source });
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function labelForSpec(spec: string): string {
229
+ // "npm:@scope/pkg@version" → "@scope/pkg"; "git:url" → url tail; paths → basename.
230
+ const colon = spec.indexOf(":");
231
+ if (colon > 0) {
232
+ const scheme = spec.slice(0, colon);
233
+ const rest = spec.slice(colon + 1);
234
+ if (scheme === "npm" || scheme === "git") {
235
+ const withoutVersion = rest.replace(/@[^/@]*$/, "");
236
+ return withoutVersion || rest;
237
+ }
238
+ }
239
+ return path.basename(spec) || spec;
240
+ }
241
+
242
+ /**
243
+ * Discover every extension a normal pi session would load in this
244
+ * environment: user settings packages/extensions, the user extensions dir,
245
+ * project settings packages/extensions, and the project extensions dir.
246
+ * Deduplicated by spec, stable order: settings packages first, then
247
+ * settings extension paths, then directory discovery (user before project).
248
+ */
249
+ export function discoverAuditorExtensions(home: string, cwd?: string): DiscoveredPiExtension[] {
250
+ const agentDir = path.join(home, ".pi", "agent");
251
+ const out: DiscoveredPiExtension[] = [];
252
+ const seen = new Set<string>();
253
+ const push = (spec: string, source: DiscoveredPiExtension["source"]) => {
254
+ if (!spec || seen.has(spec)) return;
255
+ seen.add(spec);
256
+ out.push({ spec, label: labelForSpec(spec), source });
257
+ };
258
+ // Settings entries: packages[] specs and extensions[] paths are resolved
259
+ // to concrete install paths (npm: → managed node_modules, git: → managed
260
+ // git root, relative paths → settings base dir) so the stored allowlist
261
+ // entry is directly loadable by `pi --extension <path>` without a fresh
262
+ // temporary install. Unresolvable entries are skipped, not emitted.
263
+ const pushResolved = (raw: string, source: DiscoveredPiExtension["source"], base: string) => {
264
+ const spec = resolveAuditorExtensionSpec(raw, { home, cwd, base });
265
+ if (!spec || seen.has(spec)) return;
266
+ seen.add(spec);
267
+ out.push({ spec, label: labelForSpec(raw), source, raw });
268
+ };
269
+ const userSettings = readJsonArray(path.join(agentDir, "settings.json"));
270
+ for (const raw of userSettings) pushResolved(raw, /^[a-z]+:/.test(raw) ? "package" : "extensions-setting", agentDir);
271
+ for (const entry of discoverDir(path.join(agentDir, "extensions"), "user-dir")) push(entry.spec, entry.source);
272
+ if (cwd) {
273
+ const projectSettings = readJsonArray(path.join(cwd, ".pi", "settings.json"));
274
+ const projectBase = path.join(cwd, ".pi");
275
+ for (const raw of projectSettings) pushResolved(raw, /^[a-z]+:/.test(raw) ? "package" : "extensions-setting", projectBase);
276
+ for (const entry of discoverDir(path.join(cwd, ".pi", "extensions"), "project-dir")) push(entry.spec, entry.source);
277
+ }
278
+ return out;
279
+ }
280
+
281
+ /** Expand the allowlist into `pi` CLI args: ["--extension", spec, …].
282
+ * Empty/absent list → [] (the worker keeps plain --no-extensions). */
283
+ export function auditorExtensionArgs(allowed: string[] | undefined): string[] {
284
+ const args: string[] = [];
285
+ for (const spec of normalizeAuditorAllowedExtensions(allowed)) {
286
+ args.push("--extension", spec);
287
+ }
288
+ return args;
289
+ }
@@ -0,0 +1,185 @@
1
+ // pi-goal-list-loop-audit — v0.35.29
2
+ // goal-agents-panel.ts — issue #15 subagent visibility (design:
3
+ // docs/DESIGN-subagent-visibility.md, scope agreed 2026-08-22).
4
+ //
5
+ // Pure rendering + data assembly for:
6
+ // 1. /glla agents — tracked-subagent snapshot table
7
+ // 2. /glla agents --tail <id> — read-only child transcript tail
8
+ // 3. the widget agents segment (line built here, appended by the widget)
9
+ //
10
+ // Everything here is deterministic given its inputs; fs access happens only
11
+ // in tailChildTranscript through an injected reader so tests stay hermetic.
12
+
13
+ import * as path from "node:path";
14
+
15
+ export interface AgentsPanelRow {
16
+ recordId: string;
17
+ agentType?: string;
18
+ summary?: string;
19
+ status: "running" | "hung" | "ended";
20
+ spawnedAt: number;
21
+ lastProgressAt: number;
22
+ toolUses: number;
23
+ outputTokens: number;
24
+ silentMs: number;
25
+ evidence: "record-frozen" | "event-only" | "live";
26
+ endedOk?: boolean;
27
+ endedAt?: number;
28
+ }
29
+
30
+ /** Human label: agentType + short summary. */
31
+ function rowLabel(row: AgentsPanelRow): string {
32
+ const summary = (row.summary ?? "").replace(/\s+/g, " ").trim();
33
+ return [row.agentType ?? "subagent", summary ? truncate(summary, 28) : ""].filter(Boolean).join(" · ");
34
+ }
35
+
36
+ export function truncate(text: string, max: number): string {
37
+ return text.length <= max ? text : text.slice(0, Math.max(1, max - 1)) + "…";
38
+ }
39
+
40
+ function fmtDuration(ms: number): string {
41
+ const totalSec = Math.max(0, Math.round(ms / 1000));
42
+ const min = Math.floor(totalSec / 60);
43
+ const sec = totalSec % 60;
44
+ if (min >= 60) return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}m`;
45
+ if (min > 0) return `${min}m${String(sec).padStart(2, "0")}s`;
46
+ return `${sec}s`;
47
+ }
48
+
49
+ const PANEL_ROW_CAP = 20;
50
+
51
+ /** Render the /glla agents table. Running/hung first (hung topmost), then
52
+ * ended; capped at PANEL_ROW_CAP rows with an explicit truncation notice. */
53
+ export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAvailable: boolean): string[] {
54
+ if (rows.length === 0) {
55
+ return ["No subagents tracked yet — spawn one via the Agent tool and it appears here.", "(evidence: glla's event probes" + (managerAvailable ? " + pi-subagents manager records" : "") + ")"];
56
+ }
57
+ const rank = (r: AgentsPanelRow): number => (r.status === "hung" ? 0 : r.status === "running" ? 1 : 2);
58
+ const ordered = [...rows].sort((a, b) => rank(a) - rank(b) || a.silentMs - b.silentMs);
59
+ const shown = ordered.slice(0, PANEL_ROW_CAP);
60
+ const lines: string[] = [];
61
+ for (const row of shown) {
62
+ const glyph = row.status === "ended" ? "✓" : row.status === "hung" ? "⚠" : "●";
63
+ const stateWord = row.status === "ended"
64
+ ? `ENDED ${row.endedOk === false ? "✗" : "ok"} ${fmtDuration((row.endedAt ?? now) - row.spawnedAt)}`
65
+ : `${row.status === "hung" ? "HUNG?" : "RUNNING"} ${fmtDuration(now - row.spawnedAt)}`;
66
+ lines.push(`${glyph} ${rowLabel(row)} ${stateWord}`);
67
+ lines.push(` tools ${row.toolUses} · out ${row.outputTokens >= 1000 ? `${(row.outputTokens / 1000).toFixed(1)}k` : row.outputTokens} · silent ${fmtDuration(row.silentMs)}${row.evidence !== "live" ? ` (${row.evidence})` : ""}`);
68
+ if (row.status === "hung") {
69
+ lines.push(" └ check the Agents panel: a child whose counters stopped moving is hung, not thinking");
70
+ }
71
+ }
72
+ if (ordered.length > shown.length) {
73
+ lines.push(`… ${ordered.length - shown.length} more (oldest ended trimmed — cap ${PANEL_ROW_CAP})`);
74
+ }
75
+ return lines;
76
+ }
77
+
78
+ /** The ambient widget segment: hidden at zero tracked children by the caller. */
79
+ export function renderAgentsWidgetLine(rows: AgentsPanelRow[]): string | undefined {
80
+ const active = rows.filter((r) => r.status !== "ended");
81
+ if (active.length === 0) return undefined;
82
+ const busiest = [...active].sort((a, b) => b.silentMs - a.silentMs)[0]!;
83
+ const hung = busiest.status === "hung" ? " ⚠" : "";
84
+ return `● ${active.length} agent${active.length === 1 ? "" : "s"} · ${(busiest.agentType ?? "subagent")} silent ${fmtDuration(busiest.silentMs)}${hung}`;
85
+ }
86
+
87
+ export interface TranscriptTailResult {
88
+ ok: boolean;
89
+ lines: string[];
90
+ /** What was searched / read — surfaced to the user on failure. */
91
+ detail: string;
92
+ }
93
+
94
+ /** Read-only tail of a child's session transcript. Candidate selection: all
95
+ * .jsonl files in sessionsDir whose content mentions the child's summary or
96
+ * agent type; most recently modified wins. NEVER resumes or attaches.
97
+ * readFile is injected for tests; production passes fs.readFileSync-wrapped. */
98
+ export function tailChildTranscript(
99
+ sessionsDir: string,
100
+ row: { recordId: string; agentType?: string; summary?: string },
101
+ opts: {
102
+ lines?: number;
103
+ readFile?: (file: string) => Buffer;
104
+ listDir?: (dir: string) => string[];
105
+ statMtime?: (file: string) => number;
106
+ now?: number;
107
+ } = {},
108
+ ): TranscriptTailResult {
109
+ const wantLines = Math.max(1, Math.min(200, opts.lines ?? 20));
110
+ const readFile = opts.readFile ?? (() => { throw new Error("no reader"); });
111
+ const listDir = opts.listDir ?? (() => []);
112
+ const statMtime = opts.statMtime ?? (() => 0);
113
+ let entries: string[];
114
+ try {
115
+ entries = listDir(sessionsDir);
116
+ } catch (error) {
117
+ return { ok: false, lines: [], detail: `cannot list ${sessionsDir}: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}` };
118
+ }
119
+ const needles = [row.summary?.slice(0, 48), row.agentType, row.recordId]
120
+ .filter((n): n is string => typeof n === "string" && n.trim().length >= 6)
121
+ .map((n) => n.toLowerCase());
122
+ const candidates = entries
123
+ .filter((f) => f.endsWith(".jsonl"))
124
+ .map((f) => path.join(sessionsDir, f))
125
+ .map((f) => ({ f, mtime: statMtime(f) }))
126
+ .sort((a, b) => b.mtime - a.mtime);
127
+ let matched: string | undefined;
128
+ for (const candidate of candidates.slice(0, 25)) {
129
+ try {
130
+ const content = readFile(candidate.f).toString("utf8").toLowerCase();
131
+ if (needles.some((needle) => content.includes(needle))) { matched = candidate.f; break; }
132
+ } catch { /* unreadable file — skip */ }
133
+ }
134
+ if (!matched) {
135
+ return {
136
+ ok: false,
137
+ lines: [],
138
+ detail: `no session file in ${sessionsDir} matches this child (searched ${candidates.length} transcripts for: ${needles.map((n) => `"${truncate(n, 24)}"`).join(", ") || "any needle"}) — the child may not persist a session, or it lives under another working directory`,
139
+ };
140
+ }
141
+ try {
142
+ const raw = readFile(matched).toString("utf8").split("\n").filter(Boolean);
143
+ const formatted = raw.map(formatTranscriptEntry).filter(Boolean) as string[];
144
+ return { ok: true, lines: formatted.slice(-wantLines), detail: `${matched} (last ${Math.min(wantLines, formatted.length)} of ${formatted.length})` };
145
+ } catch (error) {
146
+ return { ok: false, lines: [], detail: `cannot read ${matched}: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}` };
147
+ }
148
+ }
149
+
150
+ /** Tolerant pi-session JSONL → `[role] text` line. Unparseable lines are
151
+ * truncated raw so forensic value survives shape drift. */
152
+ export function formatTranscriptEntry(line: string): string | undefined {
153
+ const trimmed = line.trim();
154
+ if (!trimmed) return undefined;
155
+ try {
156
+ const entry = JSON.parse(trimmed) as {
157
+ type?: string;
158
+ role?: string;
159
+ message?: { role?: string; content?: unknown };
160
+ content?: unknown;
161
+ };
162
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return `[raw] ${truncate(trimmed, 120)}`;
163
+ const role = entry.message?.role ?? entry.role ?? entry.type ?? "?";
164
+ const content = entry.message?.content ?? entry.content;
165
+ const text = extractText(content);
166
+ if (!text) return undefined;
167
+ return `[${role}] ${truncate(text.replace(/\s+/g, " ").trim(), 160)}`;
168
+ } catch {
169
+ return `[raw] ${truncate(trimmed, 120)}`;
170
+ }
171
+ }
172
+
173
+ function extractText(content: unknown): string {
174
+ if (typeof content === "string") return content;
175
+ if (Array.isArray(content)) {
176
+ return content
177
+ .map((block) => typeof block === "string"
178
+ ? block
179
+ : typeof block === "object" && block !== null && typeof (block as { text?: unknown }).text === "string"
180
+ ? (block as { text: string }).text
181
+ : "")
182
+ .join(" ");
183
+ }
184
+ return "";
185
+ }