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.
- package/CHANGELOG.md +366 -0
- package/INSTALL.md +3 -3
- package/LIST-PHILOSOPHY.md +90 -0
- package/PLAN.md +316 -0
- package/README.md +108 -13
- package/docs/DESIGN-subagent-visibility.md +76 -0
- package/docs/DESIGN.md +59 -8
- package/docs/GLLA-POSITIONING-AND-DECOMPOSITION-2026-08-08.md +6 -1
- package/docs/INDEX.md +4 -0
- package/docs/VISION-ASSIST.md +2 -2
- package/extensions/auditor-extensions.ts +289 -0
- package/extensions/goal-agents-panel.ts +185 -0
- package/extensions/goal-commands.ts +120 -6
- package/extensions/goal-continuation.ts +45 -2
- package/extensions/goal-heartbeat.ts +176 -5
- package/extensions/goal-loop-auditor-process.ts +26 -0
- package/extensions/goal-loop-auditor.ts +3 -5
- package/extensions/goal-loop-backoff.ts +79 -0
- package/extensions/goal-loop-core.ts +97 -4
- package/extensions/goal-loop-display.ts +107 -8
- package/extensions/goal-loop-forever.ts +39 -1
- package/extensions/goal-loop-shield.ts +114 -25
- package/extensions/goal-loop.ts +66 -1
- package/extensions/goal-recovery.ts +8 -2
- package/extensions/goal-settings.ts +23 -0
- package/extensions/goal-state.ts +11 -0
- package/extensions/loops/goal-activation.ts +226 -25
- package/extensions/loops/goal-auditor-hooks.ts +39 -3
- package/extensions/loops/goal-list-queue.ts +17 -1
- package/extensions/loops/goal-orchestrator.ts +28 -1
- package/extensions/loops/goal-settings-ui.ts +96 -5
- package/extensions/loops/goal-tools.ts +82 -28
- package/extensions/loops/goal-ui.ts +105 -1
- package/extensions/loops/goal.ts +7 -7
- package/extensions/multi-model-picker.ts +45 -7
- package/extensions/settings-menu.ts +10 -0
- package/package.json +8 -3
- package/schemas/goal.schema.json +2 -0
- package/scripts/goal-auditor-launch.mjs +19 -1
- package/scripts/goal-auditor-worker.mjs +15 -0
- 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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
- `
|
|
573
|
-
- `
|
|
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
|
-
|
|
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
|
package/docs/VISION-ASSIST.md
CHANGED
|
@@ -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/
|
|
70
|
-
| Forbidden-switch hook |
|
|
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
|
+
}
|