@juicesharp/rpiv-pi 0.12.3 → 0.12.5
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/README.md +2 -0
- package/extensions/subagent-widget/agent-catalog.test.ts +110 -0
- package/extensions/subagent-widget/agent-catalog.ts +58 -0
- package/extensions/subagent-widget/catalog-drift.test.ts +21 -0
- package/extensions/subagent-widget/hide-builtin-manager-rows.test.ts +149 -0
- package/extensions/subagent-widget/hide-builtin-manager-rows.ts +129 -0
- package/extensions/subagent-widget/hide-builtin-subagents.test.ts +218 -0
- package/extensions/subagent-widget/hide-builtin-subagents.ts +87 -0
- package/extensions/subagent-widget/overlay.test.ts +149 -0
- package/extensions/subagent-widget/overlay.ts +96 -0
- package/extensions/subagent-widget/pi-subagents-stubs/agent-manager.d.ts +14 -0
- package/extensions/subagent-widget/prompts/subagent-description.txt +24 -0
- package/extensions/subagent-widget/renderer-override.test.ts +183 -121
- package/extensions/subagent-widget/renderer-override.ts +97 -134
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -194,6 +194,8 @@ Pi Agent discovers extensions via `"extensions": ["./extensions"]` and skills vi
|
|
|
194
194
|
|
|
195
195
|
rpiv-pi owns nicobailon's pi-subagents registration (runs it through an in-process proxy so the inline tool card stays quiet and the Subagents overlay is the live view). `/rpiv-setup` strips `"npm:pi-subagents"` from your `~/.pi/agent/settings.json#packages[]` to prevent Pi from loading it twice. If you remove rpiv-pi, subagents will stop loading until you re-add that entry.
|
|
196
196
|
|
|
197
|
+
The bundled built-in agents from `pi-subagents` (`scout`, `planner`, `oracle`, …) are hidden from both the `subagent` tool that the assistant dispatches to and the `/agents` manager overlay (and `ctrl+shift+a`). The overlay filter is best-effort — if a future `pi-subagents` release changes its manager UI, rpiv-pi will print one boot-time warning to stderr and the built-in rows will reappear in `/agents` until rpiv-pi ships an update. The assistant-side filter is unaffected by upstream changes. To re-enable a built-in agent yourself, edit `subagents.disableBuiltins` in `~/.pi/agent/settings.json` (set to `false` or delete the key) and restart Pi.
|
|
198
|
+
|
|
197
199
|
To fully uninstall:
|
|
198
200
|
|
|
199
201
|
1. Remove rpiv-pi from Pi: `pi uninstall npm:@juicesharp/rpiv-pi`
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
__resetManagerRowFilterForTests,
|
|
4
|
+
INSTALLED_SENTINEL,
|
|
5
|
+
installManagerRowFilter,
|
|
6
|
+
LOAD_ENTRIES_SOURCE_FRAGMENT,
|
|
7
|
+
type SkipReason,
|
|
8
|
+
} from "./hide-builtin-manager-rows.js";
|
|
9
|
+
|
|
10
|
+
interface AgentEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
interface AgentDataFixture {
|
|
14
|
+
builtin: AgentEntry[];
|
|
15
|
+
user: AgentEntry[];
|
|
16
|
+
project: AgentEntry[];
|
|
17
|
+
}
|
|
18
|
+
interface ManagerLike {
|
|
19
|
+
agentData: AgentDataFixture;
|
|
20
|
+
_walkedBuiltin: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Synthetic constructor whose `loadEntries` mirrors the load-bearing shape
|
|
24
|
+
// of upstream agent-manager.ts:120-124 — references `this.agentData.builtin`
|
|
25
|
+
// (drift anchor) and walks it, recording names into a side-channel so tests
|
|
26
|
+
// can assert what would have been rendered.
|
|
27
|
+
function makeFixtureCtor(): new () => ManagerLike {
|
|
28
|
+
function FixtureCtor(this: ManagerLike) {
|
|
29
|
+
this.agentData = { builtin: [], user: [], project: [] };
|
|
30
|
+
this._walkedBuiltin = [];
|
|
31
|
+
}
|
|
32
|
+
FixtureCtor.prototype.loadEntries = function (this: ManagerLike) {
|
|
33
|
+
this._walkedBuiltin = [];
|
|
34
|
+
// Keep the literal substring so the drift guard accepts this fixture.
|
|
35
|
+
for (const config of this.agentData.builtin) {
|
|
36
|
+
this._walkedBuiltin.push(config.name);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return FixtureCtor as unknown as new () => ManagerLike;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("installManagerRowFilter", () => {
|
|
43
|
+
it("filters PI_SUBAGENTS_BUILTINS rows from the walked list", () => {
|
|
44
|
+
const Ctor = makeFixtureCtor();
|
|
45
|
+
const result = installManagerRowFilter(Ctor);
|
|
46
|
+
expect(result).toBe("installed");
|
|
47
|
+
|
|
48
|
+
const instance = new Ctor();
|
|
49
|
+
instance.agentData = {
|
|
50
|
+
builtin: [{ name: "scout" }, { name: "planner" }, { name: "general-purpose" }, { name: "codebase-locator" }],
|
|
51
|
+
user: [],
|
|
52
|
+
project: [],
|
|
53
|
+
};
|
|
54
|
+
instance._walkedBuiltin = [];
|
|
55
|
+
instance.constructor.prototype.loadEntries.call(instance);
|
|
56
|
+
|
|
57
|
+
expect(instance._walkedBuiltin).toEqual(["general-purpose", "codebase-locator"]);
|
|
58
|
+
__resetManagerRowFilterForTests();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("filters on every invocation (covers refreshAgentData re-call)", () => {
|
|
62
|
+
const Ctor = makeFixtureCtor();
|
|
63
|
+
installManagerRowFilter(Ctor);
|
|
64
|
+
const instance = new Ctor();
|
|
65
|
+
|
|
66
|
+
instance.agentData = { builtin: [{ name: "scout" }, { name: "thoughts-locator" }], user: [], project: [] };
|
|
67
|
+
instance.constructor.prototype.loadEntries.call(instance);
|
|
68
|
+
expect(instance._walkedBuiltin).toEqual(["thoughts-locator"]);
|
|
69
|
+
|
|
70
|
+
instance.agentData = { builtin: [{ name: "oracle" }, { name: "diff-auditor" }], user: [], project: [] };
|
|
71
|
+
instance.constructor.prototype.loadEntries.call(instance);
|
|
72
|
+
expect(instance._walkedBuiltin).toEqual(["diff-auditor"]);
|
|
73
|
+
|
|
74
|
+
__resetManagerRowFilterForTests();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("restores the unfiltered agentData reference after loadEntries returns", () => {
|
|
78
|
+
const Ctor = makeFixtureCtor();
|
|
79
|
+
installManagerRowFilter(Ctor);
|
|
80
|
+
const instance = new Ctor();
|
|
81
|
+
const unfiltered = {
|
|
82
|
+
builtin: [{ name: "scout" }, { name: "general-purpose" }],
|
|
83
|
+
user: [],
|
|
84
|
+
project: [],
|
|
85
|
+
};
|
|
86
|
+
instance.agentData = unfiltered;
|
|
87
|
+
instance.constructor.prototype.loadEntries.call(instance);
|
|
88
|
+
|
|
89
|
+
expect(instance.agentData).toBe(unfiltered);
|
|
90
|
+
expect(instance.agentData.builtin.map((c) => c.name)).toEqual(["scout", "general-purpose"]);
|
|
91
|
+
__resetManagerRowFilterForTests();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("is idempotent — second install returns 'skipped' with reason 'already-installed'", () => {
|
|
95
|
+
const Ctor = makeFixtureCtor();
|
|
96
|
+
const first = installManagerRowFilter(Ctor);
|
|
97
|
+
const patched = (Ctor.prototype as { loadEntries: () => void }).loadEntries;
|
|
98
|
+
|
|
99
|
+
const onSkip = vi.fn<(reason: SkipReason) => void>();
|
|
100
|
+
const second = installManagerRowFilter(Ctor, { onSkip });
|
|
101
|
+
expect(first).toBe("installed");
|
|
102
|
+
expect(second).toBe("skipped");
|
|
103
|
+
expect(onSkip).toHaveBeenCalledWith("already-installed");
|
|
104
|
+
expect((Ctor.prototype as { loadEntries: () => void }).loadEntries).toBe(patched);
|
|
105
|
+
expect((Ctor as { [INSTALLED_SENTINEL]?: boolean })[INSTALLED_SENTINEL]).toBe(true);
|
|
106
|
+
|
|
107
|
+
__resetManagerRowFilterForTests();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it.each<[string, unknown, SkipReason]>([
|
|
111
|
+
["missing constructor (undefined)", undefined, "missing-constructor"],
|
|
112
|
+
["missing constructor (null)", null, "missing-constructor"],
|
|
113
|
+
["missing prototype", { prototype: undefined }, "missing-prototype"],
|
|
114
|
+
["missing loadEntries", { prototype: {} }, "missing-loadentries"],
|
|
115
|
+
["loadEntries lacks drift anchor", { prototype: { loadEntries: () => 1 } }, "drift-detected"],
|
|
116
|
+
])("fails soft on %s — onSkip(%s) and never throws", (_label, ctor, expectedReason) => {
|
|
117
|
+
const onSkip = vi.fn<(reason: SkipReason) => void>();
|
|
118
|
+
expect(() => installManagerRowFilter(ctor, { onSkip })).not.toThrow();
|
|
119
|
+
expect(onSkip).toHaveBeenCalledExactlyOnceWith(expectedReason);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("survives invocation when agentData.builtin is missing", () => {
|
|
123
|
+
const Ctor = makeFixtureCtor();
|
|
124
|
+
installManagerRowFilter(Ctor);
|
|
125
|
+
const instance = new Ctor();
|
|
126
|
+
instance.agentData = { builtin: undefined as unknown as AgentEntry[], user: [], project: [] };
|
|
127
|
+
expect(() => instance.constructor.prototype.loadEntries.call(instance)).not.toThrow();
|
|
128
|
+
__resetManagerRowFilterForTests();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("real pi-subagents AgentManagerComponent contract", () => {
|
|
133
|
+
// Canary: this is the only test that touches the live upstream module.
|
|
134
|
+
// Failing here means upstream pi-subagents drifted between our pin and
|
|
135
|
+
// our release — bump the dep, update LOAD_ENTRIES_SOURCE_FRAGMENT (or
|
|
136
|
+
// retire the patch), and re-snapshot. At user runtime the patch fails
|
|
137
|
+
// soft via onSkip; this test exists so we catch drift before shipping.
|
|
138
|
+
it("loadEntries body still contains the load-bearing drift anchor", async () => {
|
|
139
|
+
// Dynamic import: matches the runtime guarded import in renderer-override.ts
|
|
140
|
+
// and avoids tsc resolving into the upstream .ts source via the path stub.
|
|
141
|
+
const mod = (await import("pi-subagents/agent-manager")) as {
|
|
142
|
+
AgentManagerComponent?: { prototype?: { loadEntries?: () => void } };
|
|
143
|
+
};
|
|
144
|
+
const proto = mod.AgentManagerComponent?.prototype;
|
|
145
|
+
const fn = proto?.loadEntries;
|
|
146
|
+
expect(typeof fn).toBe("function");
|
|
147
|
+
expect(fn?.toString()).toContain(LOAD_ENTRIES_SOURCE_FRAGMENT);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Hides the upstream pi-subagents built-in agent rows from the `/agents`
|
|
2
|
+
// manager overlay (and ctrl+shift+a). Companion to hide-builtin-subagents.ts,
|
|
3
|
+
// which hides the same names from the LLM-facing `subagent` tool surface.
|
|
4
|
+
//
|
|
5
|
+
// Strategy: monkey-patch `AgentManagerComponent.prototype.loadEntries` — the
|
|
6
|
+
// single chokepoint that converts agentData → rendered AgentEntry rows. The
|
|
7
|
+
// constructor calls it, refreshAgentData() calls it after every in-overlay
|
|
8
|
+
// mutation, so one patch covers both code paths.
|
|
9
|
+
//
|
|
10
|
+
// Fail-soft contract: never throw at user runtime. Drift, missing class, or
|
|
11
|
+
// missing method routes through the caller-supplied `onSkip` so Pi can
|
|
12
|
+
// surface a single warning notify and continue booting. The /agents overlay
|
|
13
|
+
// silently regains the upstream rows; the LLM-facing filter (Proxy in
|
|
14
|
+
// renderer-override.ts) is unaffected because it lives on a different layer.
|
|
15
|
+
|
|
16
|
+
import { PI_SUBAGENTS_BUILTINS } from "./hide-builtin-subagents.js";
|
|
17
|
+
|
|
18
|
+
const BUILTIN_NAMES_SET = new Set<string>(PI_SUBAGENTS_BUILTINS);
|
|
19
|
+
|
|
20
|
+
// Drift anchor: the load-bearing substring inside upstream
|
|
21
|
+
// agent-manager.ts:120-124 loadEntries. If upstream rewrites the method to
|
|
22
|
+
// stop reading agentData.builtin (rename, inline into ctor, route through a
|
|
23
|
+
// helper), this fragment disappears and we skip with reason "drift-detected"
|
|
24
|
+
// before mutating the prototype.
|
|
25
|
+
export const LOAD_ENTRIES_SOURCE_FRAGMENT = "this.agentData.builtin";
|
|
26
|
+
|
|
27
|
+
export const INSTALLED_SENTINEL = Symbol.for("rpiv-pi.manager-row-filter-installed");
|
|
28
|
+
|
|
29
|
+
export type InstallResult = "installed" | "skipped";
|
|
30
|
+
|
|
31
|
+
export type SkipReason =
|
|
32
|
+
| "missing-constructor"
|
|
33
|
+
| "missing-prototype"
|
|
34
|
+
| "missing-loadentries"
|
|
35
|
+
| "drift-detected"
|
|
36
|
+
| "already-installed";
|
|
37
|
+
|
|
38
|
+
export interface InstallOptions {
|
|
39
|
+
onSkip?: (reason: SkipReason) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AgentDataLike {
|
|
43
|
+
builtin?: Array<{ name?: unknown }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ManagerInstanceLike {
|
|
47
|
+
agentData?: AgentDataLike;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ManagerCtorLike {
|
|
51
|
+
prototype?: { loadEntries?: (this: ManagerInstanceLike) => void };
|
|
52
|
+
[INSTALLED_SENTINEL]?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PatchRecord {
|
|
56
|
+
ctor: ManagerCtorLike;
|
|
57
|
+
original: (this: ManagerInstanceLike) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let installedPatch: PatchRecord | undefined;
|
|
61
|
+
|
|
62
|
+
function isFilteredBuiltin(entry: { name?: unknown }): boolean {
|
|
63
|
+
return typeof entry.name === "string" && BUILTIN_NAMES_SET.has(entry.name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function installManagerRowFilter(managerCtor: unknown, options: InstallOptions = {}): InstallResult {
|
|
67
|
+
const onSkip = options.onSkip ?? (() => {});
|
|
68
|
+
const ctor = managerCtor as ManagerCtorLike | undefined | null;
|
|
69
|
+
|
|
70
|
+
if (!ctor) {
|
|
71
|
+
onSkip("missing-constructor");
|
|
72
|
+
return "skipped";
|
|
73
|
+
}
|
|
74
|
+
if (ctor[INSTALLED_SENTINEL] === true) {
|
|
75
|
+
onSkip("already-installed");
|
|
76
|
+
return "skipped";
|
|
77
|
+
}
|
|
78
|
+
const proto = ctor.prototype;
|
|
79
|
+
if (!proto) {
|
|
80
|
+
onSkip("missing-prototype");
|
|
81
|
+
return "skipped";
|
|
82
|
+
}
|
|
83
|
+
const original = proto.loadEntries;
|
|
84
|
+
if (typeof original !== "function") {
|
|
85
|
+
onSkip("missing-loadentries");
|
|
86
|
+
return "skipped";
|
|
87
|
+
}
|
|
88
|
+
let source: string;
|
|
89
|
+
try {
|
|
90
|
+
source = original.toString();
|
|
91
|
+
} catch {
|
|
92
|
+
onSkip("drift-detected");
|
|
93
|
+
return "skipped";
|
|
94
|
+
}
|
|
95
|
+
if (!source.includes(LOAD_ENTRIES_SOURCE_FRAGMENT)) {
|
|
96
|
+
onSkip("drift-detected");
|
|
97
|
+
return "skipped";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
proto.loadEntries = function patchedLoadEntries(this: ManagerInstanceLike): void {
|
|
101
|
+
// Defensive clone: never mutate the agentData reference we received.
|
|
102
|
+
// Other manager screens (override-scope, edit) read agentData directly
|
|
103
|
+
// and may rely on the unfiltered shape.
|
|
104
|
+
const original = this.agentData;
|
|
105
|
+
const builtin = Array.isArray(original?.builtin) ? original.builtin : [];
|
|
106
|
+
const filteredBuiltin = builtin.filter((c) => !isFilteredBuiltin(c ?? {}));
|
|
107
|
+
const filteredAgentData = { ...(original ?? {}), builtin: filteredBuiltin };
|
|
108
|
+
this.agentData = filteredAgentData as AgentDataLike;
|
|
109
|
+
try {
|
|
110
|
+
(installedPatch?.original ?? (() => {})).call(this);
|
|
111
|
+
} finally {
|
|
112
|
+
// Restore the unfiltered view for downstream reads outside loadEntries.
|
|
113
|
+
this.agentData = original;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
ctor[INSTALLED_SENTINEL] = true;
|
|
117
|
+
installedPatch = { ctor, original };
|
|
118
|
+
return "installed";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Test-only: undo the prototype mutation so each test sees a fresh slate.
|
|
122
|
+
// Wired into test/setup.ts beforeEach. No-op when nothing is installed.
|
|
123
|
+
export function __resetManagerRowFilterForTests(): void {
|
|
124
|
+
if (!installedPatch) return;
|
|
125
|
+
const { ctor, original } = installedPatch;
|
|
126
|
+
if (ctor.prototype) ctor.prototype.loadEntries = original;
|
|
127
|
+
delete ctor[INSTALLED_SENTINEL];
|
|
128
|
+
installedPatch = undefined;
|
|
129
|
+
}
|