@juicesharp/rpiv-pi 0.12.2 → 0.12.4

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.
@@ -0,0 +1,110 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { AGENT_CATALOG, buildAgentEnumDescription } from "./agent-catalog.js";
7
+ import { RPIV_SPECIALISTS } from "./hide-builtin-subagents.js";
8
+
9
+ const AGENTS_DIR = fileURLToPath(new URL("../../agents/", import.meta.url));
10
+
11
+ function readFrontmatterDescription(name: string): string {
12
+ const content = readFileSync(join(AGENTS_DIR, `${name}.md`), "utf8");
13
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
14
+ if (!match) throw new Error(`${name}.md has no frontmatter`);
15
+ const fm = parseYaml(match[1]) as Record<string, unknown>;
16
+ return fm.description as string;
17
+ }
18
+
19
+ describe("AGENT_CATALOG — frontmatter-sourced per-agent descriptions", () => {
20
+ it("contains exactly one entry per RPIV_SPECIALISTS name, in declaration order", () => {
21
+ expect(AGENT_CATALOG.map((e) => e.name)).toEqual([...RPIV_SPECIALISTS]);
22
+ });
23
+
24
+ it("every entry has a non-empty description string", () => {
25
+ for (const entry of AGENT_CATALOG) {
26
+ expect(typeof entry.description).toBe("string");
27
+ expect(entry.description.length).toBeGreaterThan(0);
28
+ }
29
+ });
30
+
31
+ it("each entry's description is a byte-for-byte mirror of the agents/<name>.md frontmatter", () => {
32
+ // Hard invariant: no trimming, no truncation, no transformation — the
33
+ // value the LLM sees is exactly the value authored in the .md file.
34
+ for (const entry of AGENT_CATALOG) {
35
+ expect(entry.description).toBe(readFrontmatterDescription(entry.name));
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("AGENTS_DIR resolution — cross-platform URL walk via fileURLToPath(new URL(...))", () => {
41
+ // WHATWG URL resolution is OS-agnostic: `new URL("../../agents/", base)` uses
42
+ // forward-slash URL semantics regardless of what OS the test runs on. We can
43
+ // therefore simulate POSIX, Windows, and Windows-UNC base URLs from any host
44
+ // platform and assert the resolved URL is correct — no actual file I/O, no
45
+ // need for a Windows CI runner to catch URL-walk bugs.
46
+ const MODULE_REL = "/packages/rpiv-pi/extensions/subagent-widget/agent-catalog.ts";
47
+
48
+ it("POSIX base: resolves to the rpiv-pi/agents/ directory URL", () => {
49
+ const base = `file:///Users/alice/repo${MODULE_REL}`;
50
+ const resolved = new URL("../../agents/", base);
51
+ expect(resolved.href).toBe("file:///Users/alice/repo/packages/rpiv-pi/agents/");
52
+ });
53
+
54
+ it("Windows drive-letter base: resolves preserving the C: drive and backslash-free URL form", () => {
55
+ const base = `file:///C:/Users/alice/repo${MODULE_REL}`;
56
+ const resolved = new URL("../../agents/", base);
57
+ expect(resolved.href).toBe("file:///C:/Users/alice/repo/packages/rpiv-pi/agents/");
58
+ });
59
+
60
+ it("Windows UNC share base: preserves the //server/share/ authority across the relative walk", () => {
61
+ const base = `file:////server/share/repo${MODULE_REL}`;
62
+ const resolved = new URL("../../agents/", base);
63
+ // WHATWG keeps the empty-host + path-with-leading-// form of UNC file URLs;
64
+ // Node's fileURLToPath on Windows later converts this to \\server\share\... .
65
+ expect(resolved.href).toBe("file:////server/share/repo/packages/rpiv-pi/agents/");
66
+ });
67
+
68
+ it("URL-encoded characters in the path (space, %20): round-trip through fileURLToPath correctly", () => {
69
+ // Typical hazard: installing under a path with a space (e.g. "Program Files").
70
+ // import.meta.url encodes the space as %20; fileURLToPath decodes it back.
71
+ const base = `file:///Users/alice/Program%20Files/repo${MODULE_REL}`;
72
+ const resolved = new URL("../../agents/", base);
73
+ expect(resolved.href).toBe("file:///Users/alice/Program%20Files/repo/packages/rpiv-pi/agents/");
74
+ // fileURLToPath decodes %20 back to a literal space in the filesystem path.
75
+ const fsPath = fileURLToPath(resolved);
76
+ expect(fsPath).toContain("Program Files");
77
+ expect(fsPath).not.toContain("%20");
78
+ });
79
+
80
+ it("current-platform sanity: AGENTS_DIR points at a real directory containing the specialists", () => {
81
+ // Closes the loop — the URL walk is correct on this runner AND the
82
+ // resulting path is a real dir that readFileSync can subsequently read.
83
+ const stat = statSync(AGENTS_DIR);
84
+ expect(stat.isDirectory()).toBe(true);
85
+ for (const name of RPIV_SPECIALISTS) {
86
+ const entryStat = statSync(join(AGENTS_DIR, `${name}.md`));
87
+ expect(entryStat.isFile()).toBe(true);
88
+ }
89
+ });
90
+ });
91
+
92
+ describe("buildAgentEnumDescription — flattens catalog into an LLM-facing bullet list", () => {
93
+ const text = buildAgentEnumDescription();
94
+
95
+ it("starts with a contextual header line before the bullet list", () => {
96
+ expect(text.split("\n")[0]).toBe("Agent name (SINGLE mode) or target for management get/update/delete. Options:");
97
+ });
98
+
99
+ it("emits one '- <name>: <description>' bullet per specialist", () => {
100
+ for (const entry of AGENT_CATALOG) {
101
+ expect(text).toContain(`- ${entry.name}: ${entry.description}`);
102
+ }
103
+ });
104
+
105
+ it("orders bullets by RPIV_SPECIALISTS declaration order", () => {
106
+ const bullets = text.split("\n").slice(1);
107
+ const names = bullets.map((line) => line.replace(/^- /, "").split(":")[0]);
108
+ expect(names).toEqual([...RPIV_SPECIALISTS]);
109
+ });
110
+ });
@@ -0,0 +1,58 @@
1
+ // Loads per-agent descriptions from packages/rpiv-pi/agents/<name>.md YAML
2
+ // frontmatter so the `agent` tool parameter can carry a rich enum-with-table
3
+ // description instead of duplicating copy across the monorepo. Runs once at
4
+ // module init; crashes loudly if an expected agent file is missing.
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { RPIV_SPECIALISTS } from "./hide-builtin-subagents.js";
11
+
12
+ // extensions/subagent-widget/agent-catalog.ts → packages/rpiv-pi/agents/
13
+ // URL form matches the monorepo idiom (see root guidance: prompts loaded via
14
+ // `fileURLToPath(new URL("./prompts/…", import.meta.url))`) and cross-platform
15
+ // safely handles both POSIX and Windows file URLs.
16
+ const AGENTS_DIR = fileURLToPath(new URL("../../agents/", import.meta.url));
17
+
18
+ // Splits a markdown file on its leading `---` frontmatter fences and parses
19
+ // the YAML block. Returns undefined when no frontmatter is present.
20
+ function extractFrontmatter(content: string): Record<string, unknown> | undefined {
21
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
22
+ if (!match) return undefined;
23
+ const parsed = parseYaml(match[1]) as unknown;
24
+ if (typeof parsed !== "object" || parsed === null) return undefined;
25
+ return parsed as Record<string, unknown>;
26
+ }
27
+
28
+ export interface AgentCatalogEntry {
29
+ readonly name: string;
30
+ readonly description: string;
31
+ }
32
+
33
+ function loadAgentEntry(name: string): AgentCatalogEntry {
34
+ const path = join(AGENTS_DIR, `${name}.md`);
35
+ const content = readFileSync(path, "utf8");
36
+ const fm = extractFrontmatter(content);
37
+ if (!fm) throw new Error(`agent-catalog: ${name}.md has no YAML frontmatter`);
38
+ const description = fm.description;
39
+ if (typeof description !== "string" || description.length === 0) {
40
+ throw new Error(`agent-catalog: ${name}.md frontmatter is missing a string 'description'`);
41
+ }
42
+ return { name, description };
43
+ }
44
+
45
+ // Eager load at module init: one readFileSync per specialist. Failure modes
46
+ // (missing file, missing description) fail Pi's boot rather than surfacing as
47
+ // a runtime tool-registration error — consistent with rpiv-todo / rpiv-advisor
48
+ // loading their prompts at module init.
49
+ export const AGENT_CATALOG: ReadonlyArray<AgentCatalogEntry> = RPIV_SPECIALISTS.map(loadAgentEntry);
50
+
51
+ // Flattens the catalog into the LLM-facing string used as the `agent` param's
52
+ // `description`. Format: header line + "- <name>: <desc>" bullets. Keeps the
53
+ // list parameter-adjacent so the LLM sees agent capabilities inline with the
54
+ // enum choice instead of having to cross-reference a separate doc.
55
+ export function buildAgentEnumDescription(): string {
56
+ const bullets = AGENT_CATALOG.map((entry) => `- ${entry.name}: ${entry.description}`).join("\n");
57
+ return `Agent name (SINGLE mode) or target for management get/update/delete. Options:\n${bullets}`;
58
+ }
@@ -0,0 +1,21 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { RPIV_SPECIALISTS } from "./hide-builtin-subagents.js";
5
+
6
+ const AGENTS_DIR = fileURLToPath(new URL("../../agents/", import.meta.url));
7
+
8
+ function discoverAgentStems(): string[] {
9
+ return readdirSync(AGENTS_DIR)
10
+ .filter((name) => name.endsWith(".md"))
11
+ .map((name) => name.slice(0, -".md".length))
12
+ .sort();
13
+ }
14
+
15
+ describe("RPIV_SPECIALISTS catalog drift guard", () => {
16
+ it("matches the bundled packages/rpiv-pi/agents/*.md filenames", () => {
17
+ const onDisk = discoverAgentStems();
18
+ const declared = [...RPIV_SPECIALISTS].sort();
19
+ expect(declared).toEqual(onDisk);
20
+ });
21
+ });
@@ -16,11 +16,19 @@ export const ERROR_STATUSES: ReadonlySet<ErrorStatus> = new Set<ErrorStatus>([
16
16
  "stopped",
17
17
  ]);
18
18
 
19
- /** How many turns a completed run lingers before it drops from the tree. */
20
- export const COMPLETED_LINGER_TURNS = 1;
19
+ /** How many turns a completed run lingers before it drops from the tree.
20
+ * Advanced by both user-input boundaries (`pi.on("input")`) and orchestrator
21
+ * agent-loop iterations (`pi.on("turn_start")`), so completed runs stay visible
22
+ * for ~3 orchestrator turns after the last agent finishes, then auto-evict. */
23
+ export const COMPLETED_LINGER_TURNS = 3;
21
24
 
22
25
  /** How many turns an error/aborted/steered/stopped run lingers. */
23
- export const ERROR_LINGER_TURNS = 2;
26
+ export const ERROR_LINGER_TURNS = 5;
24
27
 
25
28
  /** Spinner animation tick in ms. TUI's 16 ms render coalescing absorbs this. */
26
29
  export const TICK_MS = 80;
30
+
31
+ /** Max visible characters of the descriptor column (task text). Applied
32
+ * identically to running + finished rows so the stats tail is never
33
+ * truncation-clipped off the right edge regardless of terminal width. */
34
+ export const MAX_DESCRIPTOR_CHARS = 40;
@@ -0,0 +1,218 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ filterDisabledFromListResult,
5
+ getCuratedSubagentDescription,
6
+ LIST_FILTER_SNAPSHOT_FRAGMENT,
7
+ PI_SUBAGENTS_BUILTINS,
8
+ RPIV_SPECIALISTS,
9
+ rewriteSubagentParameters,
10
+ } from "./hide-builtin-subagents.js";
11
+
12
+ // Mirrors pi-subagents@0.17.5/index.ts:311-336 verbatim. This fixture is no
13
+ // longer used to exercise a rewriter (we replace upstream wholesale), but the
14
+ // upstream-snapshot drift guard below fails when these literals diverge — a
15
+ // signal to re-review prompts/subagent-description.txt against upstream.
16
+ const PI_SUBAGENTS_UPSTREAM_DESCRIPTION = `Delegate to subagents or manage agent definitions.
17
+
18
+ EXECUTION (use exactly ONE mode):
19
+ • SINGLE: { agent, task } - one task
20
+ • CHAIN: { chain: [{agent:"scout"}, {parallel:[{agent:"worker",count:3}]}] } - sequential pipeline with optional parallel fan-out
21
+ • PARALLEL: { tasks: [{agent,task,count?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
22
+ • Optional context: { context: "fresh" | "fork" } (default: "fresh")
23
+
24
+ CHAIN TEMPLATE VARIABLES (use in task strings):
25
+ • {task} - The original task/request from the user
26
+ • {previous} - Text response from the previous step (empty for first step)
27
+ • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-subagents-<scope>/chain-runs/abc123/)
28
+
29
+ Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }
30
+
31
+ MANAGEMENT (use action field, omit agent/task/chain/tasks):
32
+ • { action: "list" } - discover agents/chains
33
+ • { action: "get", agent: "name" } - full detail
34
+ • { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, ... } }
35
+ • { action: "update", agent: "name", config: { ... } } - merge
36
+ • { action: "delete", agent: "name" }
37
+ • Use chainName for chain operations
38
+
39
+ CONTROL:
40
+ • { action: "status", id: "..." } - inspect an async/background run by id or prefix
41
+ • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused`;
42
+
43
+ describe("getCuratedSubagentDescription — file-loaded replacement for upstream's literal", () => {
44
+ const curated = getCuratedSubagentDescription();
45
+
46
+ it("contains no builtin agent names — leak invariant over prompts/subagent-description.txt", () => {
47
+ for (const name of PI_SUBAGENTS_BUILTINS) {
48
+ expect(curated).not.toContain(`"${name}"`);
49
+ expect(curated).not.toContain(`:"${name}"`);
50
+ }
51
+ });
52
+
53
+ it("preserves the mode and action section headers the LLM anchors on", () => {
54
+ expect(curated).toContain("EXECUTION");
55
+ expect(curated).toContain("CHAIN TEMPLATE VARIABLES");
56
+ expect(curated).toContain("MANAGEMENT");
57
+ expect(curated).toContain("CONTROL");
58
+ expect(curated).toContain("• SINGLE: { agent, task }");
59
+ expect(curated).toContain("• PARALLEL:");
60
+ expect(curated).toContain(`{ action: "list" }`);
61
+ expect(curated).toContain(`{ action: "status", id: "..." }`);
62
+ });
63
+
64
+ it("is non-empty and trimmed (no trailing whitespace from readFileSync)", () => {
65
+ expect(curated.length).toBeGreaterThan(0);
66
+ expect(curated).toBe(curated.trimEnd());
67
+ });
68
+
69
+ it("upstream drift guard: fails when pi-subagents' literal diverges from the pinned snapshot", () => {
70
+ // The snapshot is pinned to pi-subagents@0.17.5/index.ts:311-336. If
71
+ // upstream adds/edits sections, this fails FIRST — prompting a human
72
+ // review of prompts/subagent-description.txt. We deliberately do NOT
73
+ // compare curated to upstream (they're allowed to diverge); we only
74
+ // verify the snapshot remains a faithful record of what we forked from.
75
+ expect(PI_SUBAGENTS_UPSTREAM_DESCRIPTION).toContain("Delegate to subagents or manage agent definitions.");
76
+ expect(PI_SUBAGENTS_UPSTREAM_DESCRIPTION).toContain(
77
+ `{ chain: [{agent:"scout"}, {parallel:[{agent:"worker",count:3}]}] }`,
78
+ );
79
+ expect(PI_SUBAGENTS_UPSTREAM_DESCRIPTION).toContain(
80
+ `Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }`,
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("rewriteSubagentParameters — pin top-level agent to RPIV_SPECIALISTS enum + injected description", () => {
86
+ const original = Type.Object({
87
+ agent: Type.Optional(Type.String({ description: "orig agent description" })),
88
+ task: Type.Optional(Type.String({ description: "task" })),
89
+ tasks: Type.Optional(Type.Array(Type.Object({ agent: Type.String(), task: Type.String() }))),
90
+ });
91
+
92
+ const stubAgentDescription = "stub agent description (injected)";
93
+
94
+ it("re-types the agent field to an optional string enum of RPIV_SPECIALISTS", () => {
95
+ const rewritten = rewriteSubagentParameters(original, stubAgentDescription) as unknown as {
96
+ properties: { agent: { type: string; enum: string[]; description: string } };
97
+ };
98
+ const agent = rewritten.properties.agent;
99
+ expect(agent.type).toBe("string");
100
+ expect(agent.enum).toEqual([...RPIV_SPECIALISTS]);
101
+ });
102
+
103
+ it("carries the injected description verbatim onto the agent field", () => {
104
+ const rewritten = rewriteSubagentParameters(original, stubAgentDescription) as unknown as {
105
+ properties: { agent: { description: string } };
106
+ };
107
+ expect(rewritten.properties.agent.description).toBe(stubAgentDescription);
108
+ });
109
+
110
+ it("preserves all other top-level properties unchanged by reference", () => {
111
+ const rewritten = rewriteSubagentParameters(original, stubAgentDescription) as unknown as {
112
+ properties: { task: unknown; tasks: unknown };
113
+ };
114
+ expect(rewritten.properties.task).toBe(original.properties.task);
115
+ expect(rewritten.properties.tasks).toBe(original.properties.tasks);
116
+ });
117
+
118
+ it("preserves Type.Optional modifier on sibling fields (TypeBox symbol carried via reference spread)", () => {
119
+ const schema = Type.Object({
120
+ agent: Type.Optional(Type.String()),
121
+ task: Type.Optional(Type.String()),
122
+ });
123
+ const rewritten = rewriteSubagentParameters(schema, stubAgentDescription);
124
+ const optionalKey = Object.getOwnPropertySymbols(schema.properties.task).find(
125
+ (s) => s.description === "TypeBox.Optional",
126
+ );
127
+ expect(optionalKey).toBeDefined();
128
+ const preservedFlag = (rewritten as typeof schema).properties.task[
129
+ optionalKey as keyof typeof schema.properties.task
130
+ ];
131
+ expect(preservedFlag).toBe("Optional");
132
+ });
133
+
134
+ it("does not mutate the input schema", () => {
135
+ const snapshot = JSON.stringify(original);
136
+ rewriteSubagentParameters(original, stubAgentDescription);
137
+ expect(JSON.stringify(original)).toBe(snapshot);
138
+ });
139
+
140
+ it("returns input unchanged when it is not a TypeBox object schema (defensive fallback)", () => {
141
+ const notASchema = { foo: "bar" } as unknown;
142
+ expect(rewriteSubagentParameters(notASchema, stubAgentDescription)).toBe(notASchema);
143
+ expect(rewriteSubagentParameters(undefined, stubAgentDescription)).toBe(undefined);
144
+ });
145
+ });
146
+
147
+ // Mirrors the `- <name> (<source>, disabled): <desc>` row format emitted by
148
+ // pi-subagents@0.17.5/agent-management.ts:375 (`handleList`). If upstream
149
+ // shifts the literal, the drift guard below fails first — pointing at
150
+ // LIST_FILTER_SNAPSHOT_FRAGMENT in hide-builtin-subagents.ts.
151
+ const PI_SUBAGENTS_LIST_OUTPUT = `Agents:
152
+ - claim-verifier (project): Adversarial finding verifier. …
153
+ - codebase-analyzer (project): Analyzes codebase implementation details. …
154
+ - context-builder (builtin, disabled): Analyzes requirements and codebase, generates context and meta-prompt
155
+ - delegate (builtin, disabled): Lightweight subagent that inherits the parent model with no default reads
156
+ - general-purpose (project): General-purpose agent for researching complex questions …
157
+ - oracle (builtin, disabled): High-context decision-consistency oracle that protects inherited state and prevents drift
158
+ - oracle-executor (builtin, disabled): High-context implementation agent that executes only after main-agent approval
159
+ - planner (builtin, disabled): Creates implementation plans from context and requirements
160
+ - researcher (builtin, disabled): Autonomous web researcher …
161
+ - reviewer (builtin, disabled): Code review specialist that validates implementation and fixes issues
162
+ - scout (builtin, disabled): Fast codebase recon that returns compressed context for handoff
163
+ - thoughts-locator (project): Discovers relevant documents in thoughts/ directory …
164
+ - worker (builtin, disabled): General-purpose subagent with full capabilities
165
+
166
+ Chains:
167
+ - (none)`;
168
+
169
+ describe("filterDisabledFromListResult — strip disabled builtin rows from handleList output", () => {
170
+ it("drift guard: handleList still emits the '(builtin, disabled)' suffix somewhere in the snapshot", () => {
171
+ expect(PI_SUBAGENTS_LIST_OUTPUT).toContain(LIST_FILTER_SNAPSHOT_FRAGMENT);
172
+ });
173
+
174
+ it("removes every row tagged '(builtin, disabled)' — leak invariant over all 9 builtins", () => {
175
+ const filtered = filterDisabledFromListResult(PI_SUBAGENTS_LIST_OUTPUT) as string;
176
+ for (const name of PI_SUBAGENTS_BUILTINS) {
177
+ expect(filtered).not.toContain(`- ${name} (builtin, disabled)`);
178
+ }
179
+ expect(filtered).not.toMatch(/, disabled\)/);
180
+ });
181
+
182
+ it("preserves non-disabled project rows and the Chains section verbatim", () => {
183
+ const filtered = filterDisabledFromListResult(PI_SUBAGENTS_LIST_OUTPUT) as string;
184
+ expect(filtered).toContain("- claim-verifier (project):");
185
+ expect(filtered).toContain("- codebase-analyzer (project):");
186
+ expect(filtered).toContain("- general-purpose (project):");
187
+ expect(filtered).toContain("- thoughts-locator (project):");
188
+ expect(filtered).toContain("Chains:");
189
+ expect(filtered).toContain("- (none)");
190
+ });
191
+
192
+ it("keeps the Agents: header and blank-line-before-Chains even when every builtin is removed", () => {
193
+ const filtered = filterDisabledFromListResult(PI_SUBAGENTS_LIST_OUTPUT) as string;
194
+ expect(filtered.startsWith("Agents:\n")).toBe(true);
195
+ expect(filtered).toMatch(/\n\nChains:/);
196
+ });
197
+
198
+ it("consumes trailing newline on each removed row (no accumulated blank lines)", () => {
199
+ const minimal = "Agents:\n- scout (builtin, disabled): a\n- foo (project): b\n\nChains:\n- (none)";
200
+ const filtered = filterDisabledFromListResult(minimal) as string;
201
+ expect(filtered).toBe("Agents:\n- foo (project): b\n\nChains:\n- (none)");
202
+ });
203
+
204
+ it("handles a disabled row at EOF (no trailing newline) without leaving it behind", () => {
205
+ const endEdge = "Agents:\n- foo (project): b\n- scout (builtin, disabled): a";
206
+ const filtered = filterDisabledFromListResult(endEdge) as string;
207
+ expect(filtered).toBe("Agents:\n- foo (project): b\n");
208
+ });
209
+
210
+ it("is a no-op on inputs that contain no disabled-tagged rows", () => {
211
+ const userOnly = "Agents:\n- claim-verifier (project): x\n\nChains:\n- (none)";
212
+ expect(filterDisabledFromListResult(userOnly)).toBe(userOnly);
213
+ });
214
+
215
+ it("passes undefined through unchanged", () => {
216
+ expect(filterDisabledFromListResult(undefined)).toBe(undefined);
217
+ });
218
+ });
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { type TSchema, Type } from "@sinclair/typebox";
4
+
5
+ export const RPIV_SPECIALISTS = [
6
+ "claim-verifier",
7
+ "codebase-analyzer",
8
+ "codebase-locator",
9
+ "codebase-pattern-finder",
10
+ "diff-auditor",
11
+ "general-purpose",
12
+ "integration-scanner",
13
+ "peer-comparator",
14
+ "precedent-locator",
15
+ "test-case-locator",
16
+ "thoughts-analyzer",
17
+ "thoughts-locator",
18
+ "web-search-researcher",
19
+ ] as const;
20
+
21
+ export const PI_SUBAGENTS_BUILTINS = [
22
+ "scout",
23
+ "planner",
24
+ "worker",
25
+ "reviewer",
26
+ "context-builder",
27
+ "researcher",
28
+ "delegate",
29
+ "oracle",
30
+ "oracle-executor",
31
+ ] as const;
32
+
33
+ const AGENT_ENUM: string[] = [...RPIV_SPECIALISTS];
34
+
35
+ // Curated tool description loaded once at module init — replaces upstream's
36
+ // literal wholesale instead of regex-editing it. Avoids the drift-fragility of
37
+ // matching template-literal fragments; we own every byte the LLM sees.
38
+ const CURATED_TOOL_DESCRIPTION = readFileSync(
39
+ fileURLToPath(new URL("./prompts/subagent-description.txt", import.meta.url)),
40
+ "utf8",
41
+ ).trimEnd();
42
+
43
+ export function getCuratedSubagentDescription(): string {
44
+ return CURATED_TOOL_DESCRIPTION;
45
+ }
46
+
47
+ interface TypeBoxObject {
48
+ properties: Record<string, TSchema>;
49
+ }
50
+
51
+ function isTypeBoxObject(schema: unknown): schema is TypeBoxObject {
52
+ return typeof schema === "object" && schema !== null && "properties" in schema;
53
+ }
54
+
55
+ // Rebuild via Type.Object (not shallow spread) so TypeBox regenerates the
56
+ // Symbol-keyed kind markers + compiler hooks that a plain clone would drop.
57
+ // agentEnumDescription is injected (not imported directly) to keep this module
58
+ // free of the agent-catalog's fs side-effect at load time — test-only callers
59
+ // can substitute a stub.
60
+ export function rewriteSubagentParameters<T>(original: T, agentEnumDescription: string): T {
61
+ if (!isTypeBoxObject(original)) return original;
62
+ const rebuilt = Type.Object({
63
+ ...original.properties,
64
+ agent: Type.Optional(Type.String({ enum: AGENT_ENUM, description: agentEnumDescription })),
65
+ });
66
+ return rebuilt as unknown as T;
67
+ }
68
+
69
+ // Matches a single "- <name> (<source>, disabled): <desc>" row as produced by
70
+ // handleList in pi-subagents@0.17.5/agent-management.ts:375. The `(builtin,
71
+ // disabled)` suffix is the load-bearing literal — source="user" / "project"
72
+ // rows never carry the ", disabled" tag, so this regex is safe to apply to the
73
+ // full text block without parsing sections. The `.*(?:\r?\n|$)` trailing group
74
+ // consumes the row's newline so removed rows don't leave blank lines behind,
75
+ // with an alternation for the EOF case (no trailing newline).
76
+ const DISABLED_ROW_REGEX = /^- .+ \([^)]+, disabled\): .*(?:\r?\n|$)/gm;
77
+
78
+ // Drift guard anchor — the exact literal suffix handleList interpolates when
79
+ // an agent is disabled. If upstream renames the tag (e.g. "(builtin, off)"),
80
+ // the test fixture fails BEFORE the filter's behavioural test, pinpointing
81
+ // the stale literal instead of reporting an empty match.
82
+ export const LIST_FILTER_SNAPSHOT_FRAGMENT = ", disabled): ";
83
+
84
+ export function filterDisabledFromListResult(text: string | undefined): string | undefined {
85
+ if (text === undefined) return undefined;
86
+ return text.replace(DISABLED_ROW_REGEX, "");
87
+ }
@@ -213,12 +213,13 @@ describe("subagent-widget extension factory", () => {
213
213
  expect(listRuns()).toHaveLength(0);
214
214
  });
215
215
 
216
- it("input evicts lingering finished runs on the next user turn", async () => {
216
+ it("input + turn_start both advance linger ages; evicts after COMPLETED_LINGER_TURNS boundaries", async () => {
217
217
  const pi = makePi();
218
218
  await initExtension(pi);
219
219
  const startHandler = pi.handlers.get("tool_execution_start")!;
220
220
  const endHandler = pi.handlers.get("tool_execution_end")!;
221
221
  const inputHandler = pi.handlers.get("input")!;
222
+ const turnStartHandler = pi.handlers.get("turn_start")!;
222
223
  await startHandler(
223
224
  {
224
225
  type: "tool_execution_start",
@@ -239,7 +240,52 @@ describe("subagent-widget extension factory", () => {
239
240
  makeCtx(true),
240
241
  );
241
242
  expect(listRuns()).toHaveLength(1);
242
- await inputHandler({ type: "input", text: "next", source: "interactive" } as any, makeCtx(true));
243
+ // COMPLETED_LINGER_TURNS = 3 takes 3 turn boundaries to evict.
244
+ await turnStartHandler({ type: "turn_start" } as any, makeCtx(true)); // age 1
245
+ await turnStartHandler({ type: "turn_start" } as any, makeCtx(true)); // age 2
246
+ expect(listRuns()).toHaveLength(1);
247
+ await inputHandler({ type: "input", text: "next", source: "interactive" } as any, makeCtx(true)); // age 3 → evicted
243
248
  expect(listRuns()).toHaveLength(0);
244
249
  });
250
+
251
+ it("purges finished runs when a new wave starts (tool_execution_start with no active runs)", async () => {
252
+ const pi = makePi();
253
+ await initExtension(pi);
254
+ const startHandler = pi.handlers.get("tool_execution_start")!;
255
+ const endHandler = pi.handlers.get("tool_execution_end")!;
256
+ // Wave 1: dispatch + complete.
257
+ await startHandler(
258
+ {
259
+ type: "tool_execution_start",
260
+ toolCallId: "t1",
261
+ toolName: "subagent",
262
+ args: { agent: "scout", task: "x" },
263
+ },
264
+ makeCtx(true),
265
+ );
266
+ await endHandler(
267
+ {
268
+ type: "tool_execution_end",
269
+ toolCallId: "t1",
270
+ toolName: "subagent",
271
+ result: { details: { mode: "single", agentScope: "user", projectAgentsDir: null, results: [] } },
272
+ isError: false,
273
+ },
274
+ makeCtx(true),
275
+ );
276
+ expect(listRuns()).toHaveLength(1);
277
+ // Wave 2: new dispatch while wave 1 is still lingering → wave 1 purged first.
278
+ await startHandler(
279
+ {
280
+ type: "tool_execution_start",
281
+ toolCallId: "t2",
282
+ toolName: "subagent",
283
+ args: { agent: "worker", task: "y" },
284
+ },
285
+ makeCtx(true),
286
+ );
287
+ const runs = listRuns();
288
+ expect(runs).toHaveLength(1);
289
+ expect(runs[0].toolCallId).toBe("t2");
290
+ });
245
291
  });
@@ -49,15 +49,20 @@ export default async function (pi: ExtensionAPI) {
49
49
  tracker.__resetState();
50
50
  });
51
51
 
52
- // User-turn boundary: advance linger ages on user input, NOT on "turn_start".
53
- // Pi fires turn_start per agent-loop iteration (after each tool result,
54
- // before the next assistant call), which would evict completed runs
55
- // before the user sees them. "input" fires only on user-originated
56
- // messages the correct semantics for "persists until next user turn".
57
- pi.on("input", async () => {
52
+ // Turn-boundary eviction: advance linger ages on BOTH user input and
53
+ // orchestrator agent-loop iterations. `input` fires on user-originated
54
+ // messages; `turn_start` fires per agent-loop iteration (after each tool
55
+ // result, before the next assistant call). Together with the bumped
56
+ // `COMPLETED_LINGER_TURNS=3` budget in constants.ts, completed rows stay
57
+ // visible long enough for the user to see, then auto-evict across
58
+ // ~3 orchestrator turns — no more "overlay sticks around forever" when
59
+ // the user doesn't immediately type back.
60
+ const advanceTurn = () => {
58
61
  const evicted = tracker.onTurnStart();
59
62
  if (evicted) widget?.update();
60
- });
63
+ };
64
+ pi.on("input", async () => advanceTurn());
65
+ pi.on("turn_start", async () => advanceTurn());
61
66
 
62
67
  // Background dispatches (args.async === true per pi-subagents@0.17.5 schema)
63
68
  // return a job handle in ~100ms. Tracking them produces a misleading
@@ -70,6 +75,13 @@ export default async function (pi: ExtensionAPI) {
70
75
  pi.on("tool_execution_start", async (event, ctx) => {
71
76
  if (event.toolName !== SUBAGENT_TOOL) return;
72
77
  if (isAsyncDispatch(event.args)) return;
78
+ // Wave-boundary purge: if no runs are currently active and we still
79
+ // have finished rows lingering from a prior wave, drop them before
80
+ // the new run appears. Prevents "new wave appends under yesterday's
81
+ // ✓ lines" when waves dispatch back-to-back without a user turn.
82
+ if (tracker.runningCount() === 0 && tracker.hasAnyVisible()) {
83
+ tracker.purgeFinished();
84
+ }
73
85
  tracker.onStart(event.toolCallId, event.args);
74
86
  if (ctx.hasUI) {
75
87
  widget ??= new SubagentWidget();