@juicesharp/rpiv-pi 1.6.1 → 1.8.0
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 +6 -6
- package/agents/web-search-researcher.md +0 -1
- package/extensions/rpiv-core/agents.test.ts +198 -74
- package/extensions/rpiv-core/agents.ts +192 -13
- package/extensions/rpiv-core/session-hooks.test.ts +68 -5
- package/extensions/rpiv-core/session-hooks.ts +26 -3
- package/extensions/rpiv-core/update-agents-command.test.ts +6 -1
- package/extensions/rpiv-core/update-agents-command.ts +15 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ pi install npm:@juicesharp/rpiv-pi
|
|
|
91
91
|
### First Session
|
|
92
92
|
|
|
93
93
|
On first Pi Agent session start, rpiv-pi automatically:
|
|
94
|
-
- Copies agent profiles to
|
|
94
|
+
- Copies agent profiles to `~/.pi/agent/agents/` (user-global, shared across all projects)
|
|
95
95
|
- Detects outdated or removed agents on subsequent starts
|
|
96
96
|
- Scaffolds `thoughts/shared/` directories for pipeline artifacts
|
|
97
97
|
- Shows a warning if any sibling plugins are missing
|
|
@@ -180,12 +180,12 @@ Invoke via `/skill:<name>` from inside a Pi Agent session.
|
|
|
180
180
|
| Command | Description |
|
|
181
181
|
|---|---|
|
|
182
182
|
| `/rpiv-setup` | Install all sibling plugins in one go |
|
|
183
|
-
| `/rpiv-update-agents` |
|
|
183
|
+
| `/rpiv-update-agents` | Refresh `~/.pi/agent/agents/` from bundled agent definitions and clean up legacy per-project agent directories |
|
|
184
184
|
| `/advisor` | Configure advisor model and reasoning effort |
|
|
185
185
|
| `/btw` | Ask a side question without polluting the main conversation _(requires `@juicesharp/rpiv-btw`, opt-in)_ |
|
|
186
186
|
| `/languages` | Pick the UI language for rpiv-* TUI strings (Deutsch / English / Español / Français / Português / Português (Brasil) / Русский / Українська) |
|
|
187
187
|
| `/todos` | Show current todo list |
|
|
188
|
-
| `/web-search-config` |
|
|
188
|
+
| `/web-search-config` | Pick the active search provider and set its API key |
|
|
189
189
|
|
|
190
190
|
### Agents
|
|
191
191
|
|
|
@@ -220,12 +220,12 @@ Pi Agent discovers extensions via `"extensions": ["./extensions"]` and skills vi
|
|
|
220
220
|
|
|
221
221
|
## Configuration
|
|
222
222
|
|
|
223
|
-
- **Web search** - run `/web-search-config` to
|
|
223
|
+
- **Web search** - run `/web-search-config` to pick a provider (Brave, Tavily, Serper, Exa, Jina, or Firecrawl) and set its API key; the per-provider env var (e.g. `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`) also works and takes precedence
|
|
224
224
|
- **Advisor** - run `/advisor` to select a reviewer model and reasoning effort
|
|
225
225
|
- **Side questions** _(opt-in: `pi install npm:@juicesharp/rpiv-btw`)_ - type `/btw <question>` anytime (even mid-stream) to ask the primary model a one-off question; answer appears in a borderless bottom overlay and never enters the main conversation
|
|
226
226
|
- **UI language** - run `/languages` to pick the locale for rpiv-* TUI strings, or pass `pi --locale <code>` at startup. Detection priority: flag → `~/.config/rpiv-i18n/locale.json` → `LANG` / `LC_ALL` → English. LLM-facing copy stays English by design
|
|
227
227
|
- **Agent concurrency** - open the `/agents` overlay and tune `Settings → Max concurrency` to match your provider's rate limits. `@tintinweb/pi-subagents` owns this setting; rpiv-pi does not seed it.
|
|
228
|
-
- **Agent profiles** -
|
|
228
|
+
- **Agent profiles** - synced to `~/.pi/agent/agents/` from bundled defaults; refresh with `/rpiv-update-agents` (overwrites rpiv-managed files, preserves your custom agents).
|
|
229
229
|
|
|
230
230
|
## Uninstall
|
|
231
231
|
|
|
@@ -240,7 +240,7 @@ Pi Agent discovers extensions via `"extensions": ["./extensions"]` and skills vi
|
|
|
240
240
|
| Warning about missing siblings on session start | Sibling plugins not installed | Run `/rpiv-setup` |
|
|
241
241
|
| `/rpiv-setup` fails on a package | Network or registry issue | Check connection, retry with `pi install npm:<pkg>`, re-run `/rpiv-setup` |
|
|
242
242
|
| `/rpiv-setup` says "requires interactive mode" | Running in headless mode | Install manually: `pi install npm:<pkg>` for each sibling |
|
|
243
|
-
| `web_search` or `web_fetch` errors |
|
|
243
|
+
| `web_search` or `web_fetch` errors | Active provider's API key not configured | Run `/web-search-config` or set the matching env var (e.g. `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`) |
|
|
244
244
|
| `advisor` tool not available after upgrade | Advisor model selection lost | Run `/advisor` to re-select a model |
|
|
245
245
|
| Skills hang or serialize agent calls | Agent concurrency too low | Open `/agents`, raise `Settings → Max concurrency` |
|
|
246
246
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
name: web-search-researcher
|
|
3
3
|
description: Do you find yourself desiring information that you don't quite feel well-trained (confident) on? Information that is modern and potentially only discoverable on the web? Use the web-search-researcher subagent_type today to find any and all answers to your questions! It will research deeply to figure out and attempt to answer your questions! If you aren't immediately satisfied you can get your money back! (Not really - but you can re-run web-search-researcher with an altered prompt in the event you're not satisfied the first time)
|
|
4
4
|
tools: web_search, web_fetch, read, grep, find, ls
|
|
5
|
-
isolated: true
|
|
6
5
|
---
|
|
7
6
|
|
|
8
7
|
You are an expert web research specialist focused on finding accurate, relevant information from web sources. Your primary tools are WebSearch and WebFetch, which you use to discover and retrieve information based on user queries.
|
|
@@ -10,10 +10,18 @@ import {
|
|
|
10
10
|
statSync,
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
BUNDLED_AGENTS_DIR,
|
|
18
|
+
CLEANUP_SKIP_REASON,
|
|
19
|
+
cleanupPerCwdAgents,
|
|
20
|
+
isSafeDestructiveOp,
|
|
21
|
+
SYNC_OP,
|
|
22
|
+
summarizeCleanupSkips,
|
|
23
|
+
syncBundledAgents,
|
|
24
|
+
} from "./agents.js";
|
|
17
25
|
|
|
18
26
|
const sha256 = (s: string | Buffer) => createHash("sha256").update(s).digest("hex");
|
|
19
27
|
|
|
@@ -27,12 +35,15 @@ let markerPath: string;
|
|
|
27
35
|
|
|
28
36
|
beforeEach(() => {
|
|
29
37
|
cwd = mkdtempSync(join(tmpdir(), "rpiv-agents-"));
|
|
30
|
-
targetDir = join(
|
|
38
|
+
targetDir = join(homedir(), ".pi", "agent", "agents");
|
|
31
39
|
manifestPath = join(targetDir, ".rpiv-managed.json");
|
|
32
40
|
markerPath = join(targetDir, ".rpiv-managed.v2");
|
|
33
41
|
});
|
|
34
42
|
afterEach(() => {
|
|
35
43
|
rmSync(cwd, { recursive: true, force: true });
|
|
44
|
+
// Remove the `agent/` parent — not just `agent/agents/` — so Q18's writeFileSync
|
|
45
|
+
// (which needs the `agent` slot empty) and cross-test isolation both hold.
|
|
46
|
+
rmSync(join(homedir(), ".pi", "agent"), { recursive: true, force: true });
|
|
36
47
|
vi.restoreAllMocks();
|
|
37
48
|
});
|
|
38
49
|
|
|
@@ -42,7 +53,7 @@ afterEach(() => {
|
|
|
42
53
|
|
|
43
54
|
describe("syncBundledAgents — first-run (no manifest, empty target)", () => {
|
|
44
55
|
it("copies every source .md and writes a v2 manifest with sha256 hashes", () => {
|
|
45
|
-
const r = syncBundledAgents(
|
|
56
|
+
const r = syncBundledAgents(false);
|
|
46
57
|
const bundled = bundledNames();
|
|
47
58
|
expect(r.added.sort()).toEqual(bundled.sort());
|
|
48
59
|
expect(r.updated).toEqual([]);
|
|
@@ -60,7 +71,7 @@ describe("syncBundledAgents — first-run (no manifest, empty target)", () => {
|
|
|
60
71
|
});
|
|
61
72
|
|
|
62
73
|
it("writes the .rpiv-managed.v2 sentinel marker after first successful sync", () => {
|
|
63
|
-
syncBundledAgents(
|
|
74
|
+
syncBundledAgents(false);
|
|
64
75
|
expect(existsSync(markerPath)).toBe(true);
|
|
65
76
|
expect(readFileSync(markerPath, "utf-8")).toBe("");
|
|
66
77
|
});
|
|
@@ -79,7 +90,7 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
79
90
|
writeFileSync(join(targetDir, target), bundledContent(target), "utf-8");
|
|
80
91
|
writeFileSync(manifestPath, JSON.stringify([target]), "utf-8");
|
|
81
92
|
|
|
82
|
-
const r = syncBundledAgents(
|
|
93
|
+
const r = syncBundledAgents(false);
|
|
83
94
|
|
|
84
95
|
expect(r.unchanged).toContain(target);
|
|
85
96
|
expect(r.updated).not.toContain(target);
|
|
@@ -96,7 +107,7 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
96
107
|
writeFileSync(join(targetDir, target), "user-edited content", "utf-8");
|
|
97
108
|
writeFileSync(manifestPath, JSON.stringify([target]), "utf-8");
|
|
98
109
|
|
|
99
|
-
const r = syncBundledAgents(
|
|
110
|
+
const r = syncBundledAgents(false);
|
|
100
111
|
|
|
101
112
|
expect(r.updated).toContain(target);
|
|
102
113
|
expect(r.pendingUpdate).not.toContain(target);
|
|
@@ -115,7 +126,7 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
115
126
|
for (const name of legacyEntries) writeFileSync(join(targetDir, name), bundledContent(name), "utf-8");
|
|
116
127
|
writeFileSync(manifestPath, JSON.stringify(legacyEntries), "utf-8");
|
|
117
128
|
|
|
118
|
-
const r = syncBundledAgents(
|
|
129
|
+
const r = syncBundledAgents(false);
|
|
119
130
|
|
|
120
131
|
expect(r.added).toContain(target);
|
|
121
132
|
expect(existsSync(join(targetDir, target))).toBe(true);
|
|
@@ -126,7 +137,7 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
126
137
|
writeFileSync(join(targetDir, "stale.md"), "old shipped content", "utf-8");
|
|
127
138
|
writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
|
|
128
139
|
|
|
129
|
-
const r = syncBundledAgents(
|
|
140
|
+
const r = syncBundledAgents(false);
|
|
130
141
|
|
|
131
142
|
expect(r.removed).toContain("stale.md");
|
|
132
143
|
expect(r.pendingRemove).not.toContain("stale.md");
|
|
@@ -138,7 +149,7 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
138
149
|
writeFileSync(join(targetDir, "stale.md"), "user-edited stale content", "utf-8");
|
|
139
150
|
writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
|
|
140
151
|
|
|
141
|
-
const r = syncBundledAgents(
|
|
152
|
+
const r = syncBundledAgents(false);
|
|
142
153
|
|
|
143
154
|
expect(r.removed).toContain("stale.md");
|
|
144
155
|
expect(existsSync(join(targetDir, "stale.md"))).toBe(false);
|
|
@@ -149,7 +160,7 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
149
160
|
writeFileSync(join(targetDir, "unrelated.md"), "stale", "utf-8");
|
|
150
161
|
writeFileSync(manifestPath, JSON.stringify([42, null, "unrelated.md"]), "utf-8");
|
|
151
162
|
|
|
152
|
-
const r = syncBundledAgents(
|
|
163
|
+
const r = syncBundledAgents(false);
|
|
153
164
|
|
|
154
165
|
expect(r.errors).toEqual([]);
|
|
155
166
|
expect(r.removed).toContain("unrelated.md");
|
|
@@ -162,7 +173,7 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
162
173
|
for (const name of bundled) writeFileSync(join(targetDir, name), "stale legacy content", "utf-8");
|
|
163
174
|
writeFileSync(manifestPath, JSON.stringify(bundled), "utf-8");
|
|
164
175
|
|
|
165
|
-
syncBundledAgents(
|
|
176
|
+
syncBundledAgents(false);
|
|
166
177
|
|
|
167
178
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
168
179
|
expect(Array.isArray(manifest)).toBe(false);
|
|
@@ -180,14 +191,14 @@ describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
|
180
191
|
mkdirSync(targetDir, { recursive: true });
|
|
181
192
|
writeFileSync(join(targetDir, target), "pre-migration drift", "utf-8");
|
|
182
193
|
writeFileSync(manifestPath, JSON.stringify([target]), "utf-8");
|
|
183
|
-
syncBundledAgents(
|
|
194
|
+
syncBundledAgents(false);
|
|
184
195
|
// First successful sync commits the v2 marker (one-shot per project).
|
|
185
196
|
expect(existsSync(markerPath)).toBe(true);
|
|
186
197
|
|
|
187
198
|
// User edits AFTER migration
|
|
188
199
|
writeFileSync(join(targetDir, target), "user customization", "utf-8");
|
|
189
200
|
|
|
190
|
-
const r2 = syncBundledAgents(
|
|
201
|
+
const r2 = syncBundledAgents(false);
|
|
191
202
|
|
|
192
203
|
expect(r2.pendingUpdate).toContain(target);
|
|
193
204
|
expect(r2.updated).not.toContain(target);
|
|
@@ -207,7 +218,7 @@ describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
|
207
218
|
const target = bundled[0];
|
|
208
219
|
writeFileSync(join(targetDir, target), bundledContent(target), "utf-8");
|
|
209
220
|
|
|
210
|
-
const r = syncBundledAgents(
|
|
221
|
+
const r = syncBundledAgents(false);
|
|
211
222
|
|
|
212
223
|
expect(r.unchanged).toContain(target);
|
|
213
224
|
expect(r.added).not.toContain(target);
|
|
@@ -222,7 +233,7 @@ describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
|
222
233
|
const target = bundled[0];
|
|
223
234
|
writeFileSync(join(targetDir, target), "drift content", "utf-8");
|
|
224
235
|
|
|
225
|
-
const r = syncBundledAgents(
|
|
236
|
+
const r = syncBundledAgents(false);
|
|
226
237
|
|
|
227
238
|
expect(r.updated).toContain(target);
|
|
228
239
|
expect(readFileSync(join(targetDir, target), "utf-8")).toBe(bundledContent(target));
|
|
@@ -236,7 +247,7 @@ describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
|
236
247
|
writeFileSync(join(targetDir, bundled[0]), "drift", "utf-8");
|
|
237
248
|
expect(existsSync(markerPath)).toBe(false);
|
|
238
249
|
|
|
239
|
-
const r = syncBundledAgents(
|
|
250
|
+
const r = syncBundledAgents(false);
|
|
240
251
|
|
|
241
252
|
expect(r.errors).toEqual([]);
|
|
242
253
|
expect(r.updated).toContain(bundled[0]);
|
|
@@ -254,7 +265,7 @@ describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
|
254
265
|
writeFileSync(manifestPath, JSON.stringify(42), "utf-8");
|
|
255
266
|
writeFileSync(join(targetDir, bundled[0]), "drift", "utf-8");
|
|
256
267
|
|
|
257
|
-
const r = syncBundledAgents(
|
|
268
|
+
const r = syncBundledAgents(false);
|
|
258
269
|
|
|
259
270
|
expect(r.errors).toEqual([]);
|
|
260
271
|
expect(r.updated).toContain(bundled[0]);
|
|
@@ -277,7 +288,7 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
277
288
|
// v2 manifest: hash matches what we just wrote, so "user hasn't edited"
|
|
278
289
|
writeFileSync(manifestPath, JSON.stringify({ [target]: sha256(oldContent) }), "utf-8");
|
|
279
290
|
|
|
280
|
-
const r = syncBundledAgents(
|
|
291
|
+
const r = syncBundledAgents(false);
|
|
281
292
|
|
|
282
293
|
expect(r.updated).toContain(target);
|
|
283
294
|
expect(r.pendingUpdate).not.toContain(target);
|
|
@@ -295,7 +306,7 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
295
306
|
writeFileSync(join(targetDir, target), "user edits", "utf-8");
|
|
296
307
|
writeFileSync(manifestPath, JSON.stringify({ [target]: sha256("shipped version") }), "utf-8");
|
|
297
308
|
|
|
298
|
-
const r = syncBundledAgents(
|
|
309
|
+
const r = syncBundledAgents(false);
|
|
299
310
|
|
|
300
311
|
expect(r.pendingUpdate).toContain(target);
|
|
301
312
|
expect(r.updated).not.toContain(target);
|
|
@@ -307,7 +318,7 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
307
318
|
writeFileSync(join(targetDir, "removed.md"), "old removed agent", "utf-8");
|
|
308
319
|
writeFileSync(manifestPath, JSON.stringify({ "removed.md": sha256("old removed agent") }), "utf-8");
|
|
309
320
|
|
|
310
|
-
const r = syncBundledAgents(
|
|
321
|
+
const r = syncBundledAgents(false);
|
|
311
322
|
|
|
312
323
|
expect(r.removed).toContain("removed.md");
|
|
313
324
|
expect(existsSync(join(targetDir, "removed.md"))).toBe(false);
|
|
@@ -318,7 +329,7 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
318
329
|
writeFileSync(join(targetDir, "removed.md"), "user added notes", "utf-8");
|
|
319
330
|
writeFileSync(manifestPath, JSON.stringify({ "removed.md": sha256("shipped") }), "utf-8");
|
|
320
331
|
|
|
321
|
-
const r = syncBundledAgents(
|
|
332
|
+
const r = syncBundledAgents(false);
|
|
322
333
|
|
|
323
334
|
expect(r.pendingRemove).toContain("removed.md");
|
|
324
335
|
expect(r.removed).not.toContain("removed.md");
|
|
@@ -326,19 +337,19 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
326
337
|
});
|
|
327
338
|
|
|
328
339
|
it("treats a manually-removed dest as a new add on next sync", () => {
|
|
329
|
-
syncBundledAgents(
|
|
340
|
+
syncBundledAgents(true);
|
|
330
341
|
const bundled = bundledNames();
|
|
331
342
|
if (bundled.length === 0) return;
|
|
332
343
|
rmSync(join(targetDir, bundled[0]));
|
|
333
344
|
|
|
334
|
-
const r = syncBundledAgents(
|
|
345
|
+
const r = syncBundledAgents(false);
|
|
335
346
|
|
|
336
347
|
expect(r.added).toContain(bundled[0]);
|
|
337
348
|
});
|
|
338
349
|
|
|
339
350
|
it("reports unchanged on a quiescent second sync", () => {
|
|
340
|
-
syncBundledAgents(
|
|
341
|
-
const r = syncBundledAgents(
|
|
351
|
+
syncBundledAgents(true);
|
|
352
|
+
const r = syncBundledAgents(false);
|
|
342
353
|
expect(r.added).toEqual([]);
|
|
343
354
|
expect(r.updated).toEqual([]);
|
|
344
355
|
expect(r.pendingUpdate).toEqual([]);
|
|
@@ -352,11 +363,11 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
352
363
|
|
|
353
364
|
describe("syncBundledAgents — custom user agents (v2 manifest active)", () => {
|
|
354
365
|
it("ignores a custom user .md whose name does NOT match any bundled agent", () => {
|
|
355
|
-
syncBundledAgents(
|
|
366
|
+
syncBundledAgents(true);
|
|
356
367
|
const customPath = join(targetDir, "my-custom-agent.md");
|
|
357
368
|
writeFileSync(customPath, "user content", "utf-8");
|
|
358
369
|
|
|
359
|
-
const r = syncBundledAgents(
|
|
370
|
+
const r = syncBundledAgents(false);
|
|
360
371
|
|
|
361
372
|
expect(r.removed).not.toContain("my-custom-agent.md");
|
|
362
373
|
expect(r.pendingRemove).not.toContain("my-custom-agent.md");
|
|
@@ -382,7 +393,7 @@ describe("syncBundledAgents — custom user agents (v2 manifest active)", () =>
|
|
|
382
393
|
// User's hand-placed file happens to match canonical content
|
|
383
394
|
writeFileSync(join(targetDir, target), bundledContent(target), "utf-8");
|
|
384
395
|
|
|
385
|
-
const r = syncBundledAgents(
|
|
396
|
+
const r = syncBundledAgents(false);
|
|
386
397
|
|
|
387
398
|
expect(r.unchanged).toContain(target);
|
|
388
399
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
@@ -406,7 +417,7 @@ describe("syncBundledAgents — custom user agents (v2 manifest active)", () =>
|
|
|
406
417
|
// User's hand-placed file diverges from canonical
|
|
407
418
|
writeFileSync(join(targetDir, target), "user wrote this", "utf-8");
|
|
408
419
|
|
|
409
|
-
const r = syncBundledAgents(
|
|
420
|
+
const r = syncBundledAgents(false);
|
|
410
421
|
|
|
411
422
|
// hasV2Data=true (marker present) → unknown entry is gated
|
|
412
423
|
expect(r.pendingUpdate).toContain(target);
|
|
@@ -423,11 +434,11 @@ describe("syncBundledAgents — apply=true (forced sync)", () => {
|
|
|
423
434
|
it("overwrites a user-edited file even with v2 gate in place", () => {
|
|
424
435
|
const bundled = bundledNames();
|
|
425
436
|
if (bundled.length === 0) return;
|
|
426
|
-
syncBundledAgents(
|
|
437
|
+
syncBundledAgents(true);
|
|
427
438
|
const target = bundled[0];
|
|
428
439
|
writeFileSync(join(targetDir, target), "user-modified", "utf-8");
|
|
429
440
|
|
|
430
|
-
const r = syncBundledAgents(
|
|
441
|
+
const r = syncBundledAgents(true);
|
|
431
442
|
|
|
432
443
|
expect(r.updated).toContain(target);
|
|
433
444
|
expect(readFileSync(join(targetDir, target), "utf-8")).toBe(bundledContent(target));
|
|
@@ -438,20 +449,20 @@ describe("syncBundledAgents — apply=true (forced sync)", () => {
|
|
|
438
449
|
writeFileSync(join(targetDir, "stale.md"), "user-edited stale", "utf-8");
|
|
439
450
|
writeFileSync(manifestPath, JSON.stringify({ "stale.md": sha256("originally shipped content") }), "utf-8");
|
|
440
451
|
|
|
441
|
-
const r = syncBundledAgents(
|
|
452
|
+
const r = syncBundledAgents(true);
|
|
442
453
|
|
|
443
454
|
expect(r.removed).toContain("stale.md");
|
|
444
455
|
expect(existsSync(join(targetDir, "stale.md"))).toBe(false);
|
|
445
456
|
});
|
|
446
457
|
|
|
447
458
|
it("baselines real hashes from a v1 manifest (after-upgrade sync)", () => {
|
|
448
|
-
syncBundledAgents(
|
|
459
|
+
syncBundledAgents(true);
|
|
449
460
|
const bundled = bundledNames();
|
|
450
461
|
if (bundled.length === 0) return;
|
|
451
462
|
// Roll back manifest to v1 form to simulate upgrade-then-/rpiv-update-agents
|
|
452
463
|
writeFileSync(manifestPath, JSON.stringify(bundled), "utf-8");
|
|
453
464
|
|
|
454
|
-
syncBundledAgents(
|
|
465
|
+
syncBundledAgents(true);
|
|
455
466
|
|
|
456
467
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
457
468
|
expect(Array.isArray(manifest)).toBe(false);
|
|
@@ -461,8 +472,8 @@ describe("syncBundledAgents — apply=true (forced sync)", () => {
|
|
|
461
472
|
});
|
|
462
473
|
|
|
463
474
|
it("leaves unchanged files alone", () => {
|
|
464
|
-
syncBundledAgents(
|
|
465
|
-
const r = syncBundledAgents(
|
|
475
|
+
syncBundledAgents(true);
|
|
476
|
+
const r = syncBundledAgents(true);
|
|
466
477
|
expect(r.updated).toEqual([]);
|
|
467
478
|
expect(r.unchanged.length).toBeGreaterThan(0);
|
|
468
479
|
});
|
|
@@ -486,7 +497,7 @@ describe("syncBundledAgents — manifest robustness", () => {
|
|
|
486
497
|
);
|
|
487
498
|
writeFileSync(join(targetDir, target), bundledContent(target), "utf-8");
|
|
488
499
|
|
|
489
|
-
const r = syncBundledAgents(
|
|
500
|
+
const r = syncBundledAgents(false);
|
|
490
501
|
|
|
491
502
|
expect(r.errors).toEqual([]);
|
|
492
503
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
@@ -506,7 +517,7 @@ describe("syncBundledAgents — manifest robustness", () => {
|
|
|
506
517
|
writeFileSync(join(targetDir, a), bundledContent(a), "utf-8");
|
|
507
518
|
writeFileSync(join(targetDir, b), "user-edited content", "utf-8");
|
|
508
519
|
|
|
509
|
-
const r = syncBundledAgents(
|
|
520
|
+
const r = syncBundledAgents(false);
|
|
510
521
|
|
|
511
522
|
expect(r.pendingUpdate).toContain(b);
|
|
512
523
|
expect(r.updated).not.toContain(b);
|
|
@@ -522,14 +533,14 @@ describe("syncBundledAgents — I1: v2 sentinel marker", () => {
|
|
|
522
533
|
it("with marker present and corrupt JSON manifest, gates user edits as pendingUpdate", () => {
|
|
523
534
|
const bundled = bundledNames();
|
|
524
535
|
if (bundled.length === 0) return;
|
|
525
|
-
syncBundledAgents(
|
|
536
|
+
syncBundledAgents(false); // first sync — writes marker
|
|
526
537
|
expect(existsSync(markerPath)).toBe(true);
|
|
527
538
|
|
|
528
539
|
const target = bundled[0];
|
|
529
540
|
writeFileSync(join(targetDir, target), "user customization", "utf-8");
|
|
530
541
|
writeFileSync(manifestPath, "{ corrupt :: not json", "utf-8");
|
|
531
542
|
|
|
532
|
-
const r = syncBundledAgents(
|
|
543
|
+
const r = syncBundledAgents(false);
|
|
533
544
|
|
|
534
545
|
expect(r.updated).not.toContain(target);
|
|
535
546
|
expect(r.pendingUpdate).toContain(target);
|
|
@@ -539,7 +550,7 @@ describe("syncBundledAgents — I1: v2 sentinel marker", () => {
|
|
|
539
550
|
it("with marker present and all-empty-hash manifest, gates user edits as pendingUpdate", () => {
|
|
540
551
|
const bundled = bundledNames();
|
|
541
552
|
if (bundled.length === 0) return;
|
|
542
|
-
syncBundledAgents(
|
|
553
|
+
syncBundledAgents(false);
|
|
543
554
|
|
|
544
555
|
const target = bundled[0];
|
|
545
556
|
writeFileSync(join(targetDir, target), "user customization", "utf-8");
|
|
@@ -547,7 +558,7 @@ describe("syncBundledAgents — I1: v2 sentinel marker", () => {
|
|
|
547
558
|
for (const name of bundled) empty[name] = "";
|
|
548
559
|
writeFileSync(manifestPath, JSON.stringify(empty), "utf-8");
|
|
549
560
|
|
|
550
|
-
const r = syncBundledAgents(
|
|
561
|
+
const r = syncBundledAgents(false);
|
|
551
562
|
|
|
552
563
|
expect(r.updated).not.toContain(target);
|
|
553
564
|
expect(r.pendingUpdate).toContain(target);
|
|
@@ -562,12 +573,12 @@ describe("syncBundledAgents — I1: v2 sentinel marker", () => {
|
|
|
562
573
|
writeFileSync(join(targetDir, target), "drift", "utf-8");
|
|
563
574
|
expect(existsSync(markerPath)).toBe(false);
|
|
564
575
|
|
|
565
|
-
const r1 = syncBundledAgents(
|
|
576
|
+
const r1 = syncBundledAgents(false);
|
|
566
577
|
expect(r1.updated).toContain(target);
|
|
567
578
|
expect(existsSync(markerPath)).toBe(true);
|
|
568
579
|
|
|
569
580
|
writeFileSync(join(targetDir, target), "post-migration user edit", "utf-8");
|
|
570
|
-
const r2 = syncBundledAgents(
|
|
581
|
+
const r2 = syncBundledAgents(false);
|
|
571
582
|
expect(r2.updated).not.toContain(target);
|
|
572
583
|
expect(r2.pendingUpdate).toContain(target);
|
|
573
584
|
});
|
|
@@ -584,7 +595,7 @@ describe("syncBundledAgents — I2: path-traversal hardening", () => {
|
|
|
584
595
|
writeFileSync(sentinel, "DO NOT DELETE", "utf-8");
|
|
585
596
|
writeFileSync(manifestPath, JSON.stringify({ "../../sentinel.md": "" }), "utf-8");
|
|
586
597
|
|
|
587
|
-
const r = syncBundledAgents(
|
|
598
|
+
const r = syncBundledAgents(false);
|
|
588
599
|
|
|
589
600
|
expect(existsSync(sentinel)).toBe(true);
|
|
590
601
|
expect(r.removed).not.toContain("../../sentinel.md");
|
|
@@ -597,7 +608,7 @@ describe("syncBundledAgents — I2: path-traversal hardening", () => {
|
|
|
597
608
|
writeFileSync(sentinel, "absolute target", "utf-8");
|
|
598
609
|
writeFileSync(manifestPath, JSON.stringify({ [sentinel]: "" }), "utf-8");
|
|
599
610
|
|
|
600
|
-
const r = syncBundledAgents(
|
|
611
|
+
const r = syncBundledAgents(false);
|
|
601
612
|
|
|
602
613
|
expect(existsSync(sentinel)).toBe(true);
|
|
603
614
|
expect(r.removed.length).toBe(0);
|
|
@@ -608,7 +619,7 @@ describe("syncBundledAgents — I2: path-traversal hardening", () => {
|
|
|
608
619
|
writeFileSync(join(targetDir, "weird.txt"), "not an agent", "utf-8");
|
|
609
620
|
writeFileSync(manifestPath, JSON.stringify({ "weird.txt": "" }), "utf-8");
|
|
610
621
|
|
|
611
|
-
const r = syncBundledAgents(
|
|
622
|
+
const r = syncBundledAgents(false);
|
|
612
623
|
|
|
613
624
|
expect(r.removed).not.toContain("weird.txt");
|
|
614
625
|
expect(existsSync(join(targetDir, "weird.txt"))).toBe(true);
|
|
@@ -620,7 +631,7 @@ describe("syncBundledAgents — I2: path-traversal hardening", () => {
|
|
|
620
631
|
writeFileSync(sentinel, "v1 target", "utf-8");
|
|
621
632
|
writeFileSync(manifestPath, JSON.stringify(["../../v1-sentinel.md"]), "utf-8");
|
|
622
633
|
|
|
623
|
-
const r = syncBundledAgents(
|
|
634
|
+
const r = syncBundledAgents(false);
|
|
624
635
|
|
|
625
636
|
expect(existsSync(sentinel)).toBe(true);
|
|
626
637
|
expect(r.removed).not.toContain("../../v1-sentinel.md");
|
|
@@ -628,12 +639,13 @@ describe("syncBundledAgents — I2: path-traversal hardening", () => {
|
|
|
628
639
|
|
|
629
640
|
it("ignores manifest keys containing a NUL byte", () => {
|
|
630
641
|
mkdirSync(targetDir, { recursive: true });
|
|
631
|
-
|
|
642
|
+
const nulKey = `evil${String.fromCharCode(0)}.md`;
|
|
643
|
+
writeFileSync(manifestPath, JSON.stringify({ [nulKey]: "" }), "utf-8");
|
|
632
644
|
|
|
633
|
-
const r = syncBundledAgents(
|
|
645
|
+
const r = syncBundledAgents(false);
|
|
634
646
|
|
|
635
647
|
expect(r.errors).toEqual([]);
|
|
636
|
-
expect(r.removed).not.toContain(
|
|
648
|
+
expect(r.removed).not.toContain(nulKey);
|
|
637
649
|
});
|
|
638
650
|
});
|
|
639
651
|
|
|
@@ -644,7 +656,7 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
644
656
|
mkdirSync(targetDir, { recursive: true });
|
|
645
657
|
chmodSync(targetDir, 0o500);
|
|
646
658
|
try {
|
|
647
|
-
const r = syncBundledAgents(
|
|
659
|
+
const r = syncBundledAgents(false);
|
|
648
660
|
const errorTripped = r.errors.some((e) => e.op === SYNC_OP.COPY) || r.added.length < bundled.length;
|
|
649
661
|
expect(errorTripped).toBe(true);
|
|
650
662
|
} finally {
|
|
@@ -658,7 +670,7 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
658
670
|
mkdirSync(targetDir, { recursive: true });
|
|
659
671
|
writeFileSync(manifestPath, JSON.stringify({ "stale.md": sha256("x") }), "utf-8");
|
|
660
672
|
|
|
661
|
-
const r = syncBundledAgents(
|
|
673
|
+
const r = syncBundledAgents(false);
|
|
662
674
|
|
|
663
675
|
expect(r.errors).toEqual([]);
|
|
664
676
|
expect(r.removed).toContain("stale.md");
|
|
@@ -668,35 +680,39 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
668
680
|
});
|
|
669
681
|
|
|
670
682
|
it.skipIf(process.platform === "win32")("Q9: writeManifest failure surfaces op:'manifest-write' SyncError", () => {
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
syncBundledAgents(cwd, true);
|
|
683
|
+
// Q9: make the targetDir read-only so writeFileSync(tmpFile) fails (not just renameSync).
|
|
684
|
+
// Atomic write writes a NEW .tmp file then renames — chmod-ing the manifest file
|
|
685
|
+
// is insufficient because renameSync only needs directory write perms.
|
|
686
|
+
syncBundledAgents(true);
|
|
676
687
|
const bundled = bundledNames();
|
|
677
688
|
if (bundled.length === 0) return;
|
|
678
689
|
writeFileSync(join(targetDir, bundled[0]), "drift", "utf-8");
|
|
679
|
-
chmodSync(
|
|
690
|
+
chmodSync(targetDir, 0o500);
|
|
680
691
|
|
|
681
692
|
try {
|
|
682
|
-
const r = syncBundledAgents(
|
|
693
|
+
const r = syncBundledAgents(true);
|
|
683
694
|
expect(r.errors.some((e) => e.op === SYNC_OP.MANIFEST_WRITE)).toBe(true);
|
|
684
695
|
} finally {
|
|
685
|
-
chmodSync(
|
|
696
|
+
chmodSync(targetDir, 0o700);
|
|
686
697
|
}
|
|
687
698
|
});
|
|
688
699
|
|
|
689
700
|
it("Q18: mkdir failure tagged op:'mkdir' (not op:'manifest-write')", () => {
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
701
|
+
// Reset pre-state: parent must exist as a dir; the agent slot must NOT exist
|
|
702
|
+
// (other tests may have left it as an empty dir via mkdirSync(recursive)).
|
|
703
|
+
mkdirSync(join(homedir(), ".pi"), { recursive: true });
|
|
704
|
+
rmSync(join(homedir(), ".pi", "agent"), { recursive: true, force: true });
|
|
705
|
+
// Block the global agents dir path by placing a file where the dir should go
|
|
706
|
+
writeFileSync(join(homedir(), ".pi", "agent"), "not a dir", "utf-8");
|
|
695
707
|
|
|
696
|
-
|
|
708
|
+
try {
|
|
709
|
+
const r = syncBundledAgents(false);
|
|
697
710
|
|
|
698
|
-
|
|
699
|
-
|
|
711
|
+
expect(r.errors.some((e) => e.op === SYNC_OP.MKDIR)).toBe(true);
|
|
712
|
+
expect(r.errors.some((e) => e.op === SYNC_OP.MANIFEST_WRITE)).toBe(false);
|
|
713
|
+
} finally {
|
|
714
|
+
rmSync(join(homedir(), ".pi", "agent"), { force: true });
|
|
715
|
+
}
|
|
700
716
|
});
|
|
701
717
|
|
|
702
718
|
it("Q5: pushes to result.removed when a tracked file has already vanished from disk (v2 active)", () => {
|
|
@@ -705,7 +721,7 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
705
721
|
writeFileSync(markerPath, "", "utf-8");
|
|
706
722
|
// Note: 'stale.md' is NOT created on disk — it has vanished while still tracked.
|
|
707
723
|
|
|
708
|
-
const r = syncBundledAgents(
|
|
724
|
+
const r = syncBundledAgents(false);
|
|
709
725
|
|
|
710
726
|
expect(r.removed).toContain("stale.md");
|
|
711
727
|
expect(r.errors).toEqual([]);
|
|
@@ -722,7 +738,7 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
722
738
|
// readFileSync(src) throws EACCES; restore in finally.
|
|
723
739
|
const bundled = bundledNames();
|
|
724
740
|
if (bundled.length === 0) return;
|
|
725
|
-
syncBundledAgents(
|
|
741
|
+
syncBundledAgents(false);
|
|
726
742
|
const target = bundled[0];
|
|
727
743
|
const baselined = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
728
744
|
const priorHash = baselined[target];
|
|
@@ -732,7 +748,7 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
732
748
|
const originalMode = statSync(srcPath).mode & 0o777;
|
|
733
749
|
chmodSync(srcPath, 0o000);
|
|
734
750
|
try {
|
|
735
|
-
const r = syncBundledAgents(
|
|
751
|
+
const r = syncBundledAgents(false);
|
|
736
752
|
expect(r.errors.some((e) => e.op === SYNC_OP.READ_SRC && e.file === target)).toBe(true);
|
|
737
753
|
expect(r.pendingUpdate).not.toContain(target);
|
|
738
754
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
@@ -752,7 +768,7 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
752
768
|
chmodSync(stalePath, 0o000);
|
|
753
769
|
|
|
754
770
|
try {
|
|
755
|
-
const r = syncBundledAgents(
|
|
771
|
+
const r = syncBundledAgents(false);
|
|
756
772
|
expect(r.errors.some((e) => e.op === SYNC_OP.READ_DEST && e.file === "stale.md")).toBe(true);
|
|
757
773
|
} finally {
|
|
758
774
|
chmodSync(stalePath, 0o600);
|
|
@@ -760,6 +776,114 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
760
776
|
});
|
|
761
777
|
});
|
|
762
778
|
|
|
779
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
780
|
+
// cleanupPerCwdAgents — conservative all-or-nothing migration helper
|
|
781
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
782
|
+
|
|
783
|
+
describe("cleanupPerCwdAgents — conservative all-or-nothing cleanup", () => {
|
|
784
|
+
let perCwdAgentsDir: string;
|
|
785
|
+
let perCwdManifest: string;
|
|
786
|
+
|
|
787
|
+
beforeEach(() => {
|
|
788
|
+
perCwdAgentsDir = join(cwd, ".pi", "agents");
|
|
789
|
+
perCwdManifest = join(perCwdAgentsDir, ".rpiv-managed.json");
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("returns empty when no .pi/agents/ directory exists", () => {
|
|
793
|
+
const r = cleanupPerCwdAgents(cwd);
|
|
794
|
+
expect(r.cleanedUp).toEqual([]);
|
|
795
|
+
expect(r.skipped).toEqual([]);
|
|
796
|
+
expect(r.errors).toEqual([]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("skips with reason=unmanaged when manifest is missing (hand-managed directory)", () => {
|
|
800
|
+
mkdirSync(perCwdAgentsDir, { recursive: true });
|
|
801
|
+
writeFileSync(join(perCwdAgentsDir, "custom.md"), "user content", "utf-8");
|
|
802
|
+
|
|
803
|
+
const r = cleanupPerCwdAgents(cwd);
|
|
804
|
+
|
|
805
|
+
expect(r.skipped.length).toBe(1);
|
|
806
|
+
expect(r.skipped[0].reason).toBe(CLEANUP_SKIP_REASON.UNMANAGED);
|
|
807
|
+
expect(existsSync(perCwdAgentsDir)).toBe(true);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("skips with reason=diverged when a managed file is user-edited", () => {
|
|
811
|
+
const bundled = bundledNames();
|
|
812
|
+
if (bundled.length === 0) return;
|
|
813
|
+
mkdirSync(perCwdAgentsDir, { recursive: true });
|
|
814
|
+
const manifest: Record<string, string> = {};
|
|
815
|
+
for (const name of bundled) {
|
|
816
|
+
writeFileSync(join(perCwdAgentsDir, name), "user edited", "utf-8");
|
|
817
|
+
manifest[name] = sha256("user edited");
|
|
818
|
+
}
|
|
819
|
+
writeFileSync(perCwdManifest, JSON.stringify(manifest), "utf-8");
|
|
820
|
+
|
|
821
|
+
const r = cleanupPerCwdAgents(cwd);
|
|
822
|
+
|
|
823
|
+
expect(r.skipped.length).toBe(1);
|
|
824
|
+
expect(r.skipped[0].reason).toBe(CLEANUP_SKIP_REASON.DIVERGED);
|
|
825
|
+
expect(existsSync(perCwdAgentsDir)).toBe(true);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("skips with reason=custom-files when non-managed files exist alongside matching managed files", () => {
|
|
829
|
+
const bundled = bundledNames();
|
|
830
|
+
if (bundled.length === 0) return;
|
|
831
|
+
mkdirSync(perCwdAgentsDir, { recursive: true });
|
|
832
|
+
const manifest: Record<string, string> = {};
|
|
833
|
+
for (const name of bundled) {
|
|
834
|
+
writeFileSync(join(perCwdAgentsDir, name), bundledContent(name), "utf-8");
|
|
835
|
+
manifest[name] = sha256(bundledContent(name));
|
|
836
|
+
}
|
|
837
|
+
writeFileSync(join(perCwdAgentsDir, "my-custom.md"), "user content", "utf-8");
|
|
838
|
+
writeFileSync(perCwdManifest, JSON.stringify(manifest), "utf-8");
|
|
839
|
+
|
|
840
|
+
const r = cleanupPerCwdAgents(cwd);
|
|
841
|
+
|
|
842
|
+
expect(r.skipped.length).toBe(1);
|
|
843
|
+
expect(r.skipped[0].reason).toBe(CLEANUP_SKIP_REASON.CUSTOM_FILES);
|
|
844
|
+
expect(existsSync(perCwdAgentsDir)).toBe(true);
|
|
845
|
+
expect(existsSync(join(perCwdAgentsDir, "my-custom.md"))).toBe(true);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
describe("summarizeCleanupSkips", () => {
|
|
849
|
+
it("returns empty string for no skips", () => {
|
|
850
|
+
expect(summarizeCleanupSkips([])).toBe("");
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("formats a single reason", () => {
|
|
854
|
+
expect(summarizeCleanupSkips([{ dir: "/a", reason: CLEANUP_SKIP_REASON.DIVERGED }])).toBe("1 with user edits");
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("aggregates and orders reasons (unmanaged, diverged, custom-files)", () => {
|
|
858
|
+
const skips = [
|
|
859
|
+
{ dir: "/a", reason: CLEANUP_SKIP_REASON.DIVERGED },
|
|
860
|
+
{ dir: "/b", reason: CLEANUP_SKIP_REASON.UNMANAGED },
|
|
861
|
+
{ dir: "/c", reason: CLEANUP_SKIP_REASON.CUSTOM_FILES },
|
|
862
|
+
{ dir: "/d", reason: CLEANUP_SKIP_REASON.DIVERGED },
|
|
863
|
+
];
|
|
864
|
+
expect(summarizeCleanupSkips(skips)).toBe("1 unmanaged, 2 with user edits, 1 with custom files");
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("removes directory when all managed files match source and no extras", () => {
|
|
869
|
+
const bundled = bundledNames();
|
|
870
|
+
if (bundled.length === 0) return;
|
|
871
|
+
mkdirSync(perCwdAgentsDir, { recursive: true });
|
|
872
|
+
const manifest: Record<string, string> = {};
|
|
873
|
+
for (const name of bundled) {
|
|
874
|
+
writeFileSync(join(perCwdAgentsDir, name), bundledContent(name), "utf-8");
|
|
875
|
+
manifest[name] = sha256(bundledContent(name));
|
|
876
|
+
}
|
|
877
|
+
writeFileSync(perCwdManifest, JSON.stringify(manifest), "utf-8");
|
|
878
|
+
|
|
879
|
+
const r = cleanupPerCwdAgents(cwd);
|
|
880
|
+
|
|
881
|
+
expect(r.cleanedUp.length).toBe(1);
|
|
882
|
+
expect(r.skipped).toEqual([]);
|
|
883
|
+
expect(existsSync(perCwdAgentsDir)).toBe(false);
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
763
887
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
764
888
|
// Unified safety predicate — exercised indirectly through syncBundledAgents,
|
|
765
889
|
// pinned here directly so the three branches stay regression-checked.
|
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent auto-copy — copies bundled agents into
|
|
2
|
+
* Agent auto-copy — copies bundled agents into ~/.pi/agent/agents/.
|
|
3
3
|
*
|
|
4
4
|
* Pure utility. No ExtensionAPI interactions.
|
|
5
5
|
*
|
|
6
|
-
* Concurrency: NOT safe across multiple Pi sessions sharing one
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* Concurrency: NOT safe across multiple Pi sessions sharing one target dir.
|
|
7
|
+
* The temp+rename atomic write in writeManifest guarantees the on-disk manifest
|
|
8
|
+
* file is always a complete valid JSON (no truncated/half-written content), but
|
|
9
|
+
* the read-modify-write lost-update problem remains: two sessions both reading
|
|
10
|
+
* the manifest before either writes will race, and the second writer overwrites
|
|
11
|
+
* the first's entries with its own stale snapshot. Advisory locking is a
|
|
12
|
+
* deferred follow-up (see CHANGELOG known-limitations). The path allowlist in
|
|
13
|
+
* readManifest neutralises the worst-case (arbitrary-path unlink) regardless of
|
|
14
|
+
* concurrency.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { createHash } from "node:crypto";
|
|
15
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
copyFileSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from "node:fs";
|
|
16
29
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
17
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
18
32
|
import { isPlainObject, toErrorMessage } from "./utils.js";
|
|
19
33
|
|
|
20
34
|
// ---------------------------------------------------------------------------
|
|
@@ -86,6 +100,61 @@ function emptySyncResult(): SyncResult {
|
|
|
86
100
|
};
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
/** Discriminant for why a per-cwd directory was left intact. */
|
|
104
|
+
export const CLEANUP_SKIP_REASON = {
|
|
105
|
+
/** No manifest present — directory was never installed by rpiv (hand-managed). */
|
|
106
|
+
UNMANAGED: "unmanaged",
|
|
107
|
+
/** Managed file content diverges from current bundle source (user edit, deletion, or source change). */
|
|
108
|
+
DIVERGED: "diverged",
|
|
109
|
+
/** Directory contains non-managed files (user added custom agents). */
|
|
110
|
+
CUSTOM_FILES: "custom-files",
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
export type CleanupSkipReason = (typeof CLEANUP_SKIP_REASON)[keyof typeof CLEANUP_SKIP_REASON];
|
|
114
|
+
|
|
115
|
+
export interface CleanupSkip {
|
|
116
|
+
dir: string;
|
|
117
|
+
reason: CleanupSkipReason;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface CleanupResult {
|
|
121
|
+
/** Per-cwd agent directories successfully removed (all managed files matched source). */
|
|
122
|
+
cleanedUp: string[];
|
|
123
|
+
/** Directories preserved with discriminated reason — see CLEANUP_SKIP_REASON. */
|
|
124
|
+
skipped: CleanupSkip[];
|
|
125
|
+
/** Per-file errors collected during cleanup. */
|
|
126
|
+
errors: SyncError[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Create an empty CleanupResult with all arrays initialized. */
|
|
130
|
+
function emptyCleanupResult(): CleanupResult {
|
|
131
|
+
return {
|
|
132
|
+
cleanedUp: [],
|
|
133
|
+
skipped: [],
|
|
134
|
+
errors: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format skip counts as a comma-joined parts list ("N edited, M with custom files").
|
|
140
|
+
* Shared by session_start notifyCleanup and /rpiv-update-agents handler so the
|
|
141
|
+
* two consumer surfaces describe preserved directories identically.
|
|
142
|
+
*/
|
|
143
|
+
export function summarizeCleanupSkips(skipped: CleanupSkip[]): string {
|
|
144
|
+
if (skipped.length === 0) return "";
|
|
145
|
+
const counts: Record<CleanupSkipReason, number> = {
|
|
146
|
+
unmanaged: 0,
|
|
147
|
+
diverged: 0,
|
|
148
|
+
"custom-files": 0,
|
|
149
|
+
};
|
|
150
|
+
for (const s of skipped) counts[s.reason]++;
|
|
151
|
+
const parts: string[] = [];
|
|
152
|
+
if (counts.unmanaged > 0) parts.push(`${counts.unmanaged} unmanaged`);
|
|
153
|
+
if (counts.diverged > 0) parts.push(`${counts.diverged} with user edits`);
|
|
154
|
+
if (counts["custom-files"] > 0) parts.push(`${counts["custom-files"]} with custom files`);
|
|
155
|
+
return parts.join(", ");
|
|
156
|
+
}
|
|
157
|
+
|
|
89
158
|
// ---------------------------------------------------------------------------
|
|
90
159
|
// Path-traversal allowlist (I2 — hardens the manifest reader boundary)
|
|
91
160
|
// ---------------------------------------------------------------------------
|
|
@@ -215,7 +284,23 @@ function writeManifest(targetDir: string, manifest: Manifest, result: SyncResult
|
|
|
215
284
|
try {
|
|
216
285
|
const ordered: Manifest = {};
|
|
217
286
|
for (const k of Object.keys(manifest).sort()) ordered[k] = manifest[k];
|
|
218
|
-
|
|
287
|
+
const content = `${JSON.stringify(ordered, null, 2)}\n`;
|
|
288
|
+
// Atomic write: tmp file in same dir + renameSync (POSIX same-filesystem guarantee).
|
|
289
|
+
// Prevents write-write corruption under widened concurrency (global target).
|
|
290
|
+
// Pid-suffixed so concurrent sessions don't unlink each other's in-flight tmp files
|
|
291
|
+
// on the failure path (see catch's unlinkSync below).
|
|
292
|
+
const tmpFile = join(targetDir, `${MANIFEST_FILE}.${process.pid}.tmp`);
|
|
293
|
+
try {
|
|
294
|
+
writeFileSync(tmpFile, content, "utf-8");
|
|
295
|
+
renameSync(tmpFile, manifestPath);
|
|
296
|
+
} catch (inner) {
|
|
297
|
+
try {
|
|
298
|
+
unlinkSync(tmpFile);
|
|
299
|
+
} catch {
|
|
300
|
+
/* ignore */
|
|
301
|
+
}
|
|
302
|
+
throw inner;
|
|
303
|
+
}
|
|
219
304
|
} catch (e) {
|
|
220
305
|
result.errors.push({
|
|
221
306
|
op: SYNC_OP.MANIFEST_WRITE,
|
|
@@ -420,7 +505,7 @@ function commitStaleUnlinks(
|
|
|
420
505
|
// ---------------------------------------------------------------------------
|
|
421
506
|
|
|
422
507
|
/**
|
|
423
|
-
* Synchronize bundled agents from <PACKAGE_ROOT>/agents/ into
|
|
508
|
+
* Synchronize bundled agents from <PACKAGE_ROOT>/agents/ into ~/.pi/agent/agents/.
|
|
424
509
|
*
|
|
425
510
|
* Resolution policy (apply=false, session_start):
|
|
426
511
|
* - New source files → always copied.
|
|
@@ -430,7 +515,7 @@ function commitStaleUnlinks(
|
|
|
430
515
|
* - V2 marker absent (legacy v1, missing, or never-installed) → auto-update;
|
|
431
516
|
* package wins. Triggers exactly while transitioning to v2; the marker
|
|
432
517
|
* file (.rpiv-managed.v2) is written once committed and never re-fires
|
|
433
|
-
* for this
|
|
518
|
+
* for this installation, surviving JSON corruption / partial writes / empty-
|
|
434
519
|
* hash collapse.
|
|
435
520
|
* - otherwise (V2 marker present, dest differs from recorded hash) →
|
|
436
521
|
* pendingUpdate (gated; respects user edits).
|
|
@@ -445,14 +530,14 @@ function commitStaleUnlinks(
|
|
|
445
530
|
*
|
|
446
531
|
* Never throws — errors are collected in `result.errors`.
|
|
447
532
|
*/
|
|
448
|
-
export function syncBundledAgents(
|
|
533
|
+
export function syncBundledAgents(apply: boolean): SyncResult {
|
|
449
534
|
const result = emptySyncResult();
|
|
450
535
|
|
|
451
536
|
if (!existsSync(BUNDLED_AGENTS_DIR)) {
|
|
452
537
|
return result;
|
|
453
538
|
}
|
|
454
539
|
|
|
455
|
-
const targetDir = join(
|
|
540
|
+
const targetDir = join(getAgentDir(), "agents");
|
|
456
541
|
try {
|
|
457
542
|
mkdirSync(targetDir, { recursive: true });
|
|
458
543
|
} catch (e) {
|
|
@@ -488,3 +573,97 @@ export function syncBundledAgents(cwd: string, apply: boolean): SyncResult {
|
|
|
488
573
|
|
|
489
574
|
return result;
|
|
490
575
|
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Clean up per-cwd agent directories from pre-global-sync installs.
|
|
579
|
+
*
|
|
580
|
+
* Conservative all-or-nothing gate: removes `<cwd>/.pi/agents/` only when:
|
|
581
|
+
* 1. A manifest exists (we installed these files)
|
|
582
|
+
* 2. Every managed file matches current source content
|
|
583
|
+
* 3. No non-managed files exist in the directory
|
|
584
|
+
*
|
|
585
|
+
* If any check fails, the directory is left intact. Never throws — errors
|
|
586
|
+
* are collected in the CleanupResult.
|
|
587
|
+
*/
|
|
588
|
+
export function cleanupPerCwdAgents(cwd: string): CleanupResult {
|
|
589
|
+
const result = emptyCleanupResult();
|
|
590
|
+
const perCwdDir = join(cwd, ".pi", "agents");
|
|
591
|
+
|
|
592
|
+
if (!existsSync(perCwdDir)) return result;
|
|
593
|
+
const manifest = readManifest(perCwdDir);
|
|
594
|
+
if (Object.keys(manifest).length === 0) {
|
|
595
|
+
// Edge state 1: no manifest (never synced by us, or hand-managed)
|
|
596
|
+
result.skipped.push({ dir: perCwdDir, reason: CLEANUP_SKIP_REASON.UNMANAGED });
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Edge state 2: verify all managed files match current source content
|
|
601
|
+
for (const [name] of Object.entries(manifest)) {
|
|
602
|
+
const srcPath = safeJoin(BUNDLED_AGENTS_DIR, name);
|
|
603
|
+
const destPath = safeJoin(perCwdDir, name);
|
|
604
|
+
if (srcPath === null || destPath === null) {
|
|
605
|
+
// Crafted manifest key would escape allowlist — treat as unmanaged.
|
|
606
|
+
result.skipped.push({ dir: perCwdDir, reason: CLEANUP_SKIP_REASON.UNMANAGED });
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let srcContent: Buffer;
|
|
611
|
+
try {
|
|
612
|
+
srcContent = readFileSync(srcPath);
|
|
613
|
+
} catch {
|
|
614
|
+
// Source file no longer exists — can't verify against bundle, treat as diverged.
|
|
615
|
+
result.skipped.push({ dir: perCwdDir, reason: CLEANUP_SKIP_REASON.DIVERGED });
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (!existsSync(destPath)) {
|
|
620
|
+
// Managed file missing from disk — user deleted it, treat as diverged.
|
|
621
|
+
result.skipped.push({ dir: perCwdDir, reason: CLEANUP_SKIP_REASON.DIVERGED });
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let destContent: Buffer;
|
|
626
|
+
try {
|
|
627
|
+
destContent = readFileSync(destPath);
|
|
628
|
+
} catch (e) {
|
|
629
|
+
// Hard failure — surface as error only (do not double-count in skipped).
|
|
630
|
+
result.errors.push({ op: SYNC_OP.READ_DEST, message: toErrorMessage(e) });
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (sha256(destContent) !== sha256(srcContent)) {
|
|
635
|
+
// User edited this file — conservative gate.
|
|
636
|
+
result.skipped.push({ dir: perCwdDir, reason: CLEANUP_SKIP_REASON.DIVERGED });
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Edge state 3: check for non-managed files
|
|
642
|
+
try {
|
|
643
|
+
const allFiles = readdirSync(perCwdDir);
|
|
644
|
+
const managedNames = new Set(Object.keys(manifest));
|
|
645
|
+
const managedMetadata = new Set([MANIFEST_FILE, V2_MARKER_FILE]);
|
|
646
|
+
for (const f of allFiles) {
|
|
647
|
+
if (!managedNames.has(f) && !managedMetadata.has(f)) {
|
|
648
|
+
// Non-managed file present (user custom agent or other content)
|
|
649
|
+
result.skipped.push({ dir: perCwdDir, reason: CLEANUP_SKIP_REASON.CUSTOM_FILES });
|
|
650
|
+
return result;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
} catch (e) {
|
|
654
|
+
// Hard failure — surface as error only (do not double-count in skipped).
|
|
655
|
+
result.errors.push({ op: SYNC_OP.READ_DEST, message: toErrorMessage(e) });
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Happy path: all checks passed, safe to remove
|
|
660
|
+
try {
|
|
661
|
+
rmSync(perCwdDir, { recursive: true, force: true });
|
|
662
|
+
result.cleanedUp.push(perCwdDir);
|
|
663
|
+
} catch (e) {
|
|
664
|
+
// Hard failure — surface as error only (do not double-count in skipped).
|
|
665
|
+
result.errors.push({ op: SYNC_OP.REMOVE, message: toErrorMessage(e) });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createMockCtx, createMockPi, stubGitExec } from "@juicesharp/rpiv-test-utils";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -18,11 +18,16 @@ vi.mock("./agents.js", async (importOriginal) => {
|
|
|
18
18
|
pendingRemove: [],
|
|
19
19
|
errors: [],
|
|
20
20
|
})),
|
|
21
|
+
cleanupPerCwdAgents: vi.fn(() => ({
|
|
22
|
+
cleanedUp: [],
|
|
23
|
+
skipped: [],
|
|
24
|
+
errors: [],
|
|
25
|
+
})),
|
|
21
26
|
};
|
|
22
27
|
});
|
|
23
28
|
|
|
24
29
|
import type { SyncResult } from "./agents.js";
|
|
25
|
-
import { SYNC_OP, syncBundledAgents } from "./agents.js";
|
|
30
|
+
import { cleanupPerCwdAgents, SYNC_OP, syncBundledAgents } from "./agents.js";
|
|
26
31
|
import { clearGitContextCache, getGitContext, resetInjectedMarker, takeGitContextIfChanged } from "./git-context.js";
|
|
27
32
|
import { clearInjectionState } from "./guidance.js";
|
|
28
33
|
import { findMissingSiblings } from "./package-checks.js";
|
|
@@ -161,6 +166,64 @@ describe("session_start hook — notifications", () => {
|
|
|
161
166
|
expect(healCall?.[1]).toBe("info");
|
|
162
167
|
});
|
|
163
168
|
|
|
169
|
+
it("notifyCleanup: emits 'Cleaned up' info when cleanedUp > 0", async () => {
|
|
170
|
+
vi.mocked(syncBundledAgents).mockReturnValueOnce(emptySync);
|
|
171
|
+
vi.mocked(cleanupPerCwdAgents).mockReturnValueOnce({
|
|
172
|
+
cleanedUp: ["/tmp/old-project/.pi/agents"],
|
|
173
|
+
skipped: [],
|
|
174
|
+
errors: [],
|
|
175
|
+
});
|
|
176
|
+
vi.mocked(findMissingSiblings).mockReturnValueOnce([]);
|
|
177
|
+
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
|
|
178
|
+
registerSessionHooks(pi);
|
|
179
|
+
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
|
|
180
|
+
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
|
|
181
|
+
const cleanCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.find(
|
|
182
|
+
(c) => typeof c[0] === "string" && /Cleaned up \d+ per-project agent/.test(c[0]),
|
|
183
|
+
);
|
|
184
|
+
expect(cleanCall).toBeDefined();
|
|
185
|
+
expect(cleanCall?.[1]).toBe("info");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("notifyCleanup: emits 'Preserved ...' info with reason summary when skipped > 0", async () => {
|
|
189
|
+
vi.mocked(syncBundledAgents).mockReturnValueOnce(emptySync);
|
|
190
|
+
vi.mocked(cleanupPerCwdAgents).mockReturnValueOnce({
|
|
191
|
+
cleanedUp: [],
|
|
192
|
+
skipped: [{ dir: "/tmp/old-project/.pi/agents", reason: "diverged" }],
|
|
193
|
+
errors: [],
|
|
194
|
+
});
|
|
195
|
+
vi.mocked(findMissingSiblings).mockReturnValueOnce([]);
|
|
196
|
+
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
|
|
197
|
+
registerSessionHooks(pi);
|
|
198
|
+
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
|
|
199
|
+
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
|
|
200
|
+
const skipCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.find(
|
|
201
|
+
(c) => typeof c[0] === "string" && /Preserved \d+ per-project agent/.test(c[0]),
|
|
202
|
+
);
|
|
203
|
+
expect(skipCall).toBeDefined();
|
|
204
|
+
expect(skipCall?.[0]).toContain("1 with user edits");
|
|
205
|
+
expect(skipCall?.[1]).toBe("info");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("notifyCleanup: emits warning when cleanup errors > 0", async () => {
|
|
209
|
+
vi.mocked(syncBundledAgents).mockReturnValueOnce(emptySync);
|
|
210
|
+
vi.mocked(cleanupPerCwdAgents).mockReturnValueOnce({
|
|
211
|
+
cleanedUp: [],
|
|
212
|
+
skipped: [],
|
|
213
|
+
errors: [{ op: SYNC_OP.REMOVE, message: "EACCES" }],
|
|
214
|
+
});
|
|
215
|
+
vi.mocked(findMissingSiblings).mockReturnValueOnce([]);
|
|
216
|
+
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
|
|
217
|
+
registerSessionHooks(pi);
|
|
218
|
+
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
|
|
219
|
+
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
|
|
220
|
+
const warnCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.find(
|
|
221
|
+
(c) => c[1] === "warning" && typeof c[0] === "string" && /Agent cleanup reported/.test(c[0]),
|
|
222
|
+
);
|
|
223
|
+
expect(warnCall).toBeDefined();
|
|
224
|
+
expect(warnCall?.[0]).toContain("1 error");
|
|
225
|
+
});
|
|
226
|
+
|
|
164
227
|
it("I3: emits a 'sync errors' warning when result.errors > 0", async () => {
|
|
165
228
|
vi.mocked(syncBundledAgents).mockReturnValueOnce({
|
|
166
229
|
...emptySync,
|
|
@@ -191,14 +254,14 @@ describe("G0: session_start → real syncBundledAgents → notifyAgentSyncDrift"
|
|
|
191
254
|
|
|
192
255
|
it("on a fresh tmp cwd, copies bundled agents and emits a single 'Copied N agents' info", async () => {
|
|
193
256
|
const real = await vi.importActual<typeof import("./agents.js")>("./agents.js");
|
|
194
|
-
vi.mocked(syncBundledAgents).mockImplementationOnce((
|
|
257
|
+
vi.mocked(syncBundledAgents).mockImplementationOnce((apply) => real.syncBundledAgents(apply));
|
|
195
258
|
|
|
196
259
|
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
|
|
197
260
|
registerSessionHooks(pi);
|
|
198
261
|
const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
|
|
199
262
|
await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
|
|
200
263
|
|
|
201
|
-
const agentsDir = join(
|
|
264
|
+
const agentsDir = join(homedir(), ".pi", "agent", "agents");
|
|
202
265
|
expect(existsSync(agentsDir)).toBe(true);
|
|
203
266
|
expect(existsSync(join(agentsDir, ".rpiv-managed.json"))).toBe(true);
|
|
204
267
|
expect(existsSync(join(agentsDir, ".rpiv-managed.v2"))).toBe(true);
|
|
@@ -216,7 +279,7 @@ describe("G0: session_start → real syncBundledAgents → notifyAgentSyncDrift"
|
|
|
216
279
|
|
|
217
280
|
it("on a second cold-start, reports unchanged (no Copied / no Synced / no drift)", async () => {
|
|
218
281
|
const real = await vi.importActual<typeof import("./agents.js")>("./agents.js");
|
|
219
|
-
vi.mocked(syncBundledAgents).mockImplementation((
|
|
282
|
+
vi.mocked(syncBundledAgents).mockImplementation((apply) => real.syncBundledAgents(apply));
|
|
220
283
|
|
|
221
284
|
const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
|
|
222
285
|
registerSessionHooks(pi);
|
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
isToolCallEventType,
|
|
14
14
|
type ToolCallEvent,
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type CleanupResult,
|
|
18
|
+
cleanupPerCwdAgents,
|
|
19
|
+
type SyncResult,
|
|
20
|
+
summarizeCleanupSkips,
|
|
21
|
+
syncBundledAgents,
|
|
22
|
+
} from "./agents.js";
|
|
17
23
|
import { FLAG_DEBUG, MSG_TYPE_GIT_CONTEXT } from "./constants.js";
|
|
18
24
|
import {
|
|
19
25
|
clearGitContextCache,
|
|
@@ -33,7 +39,7 @@ const THOUGHTS_DIRS = [
|
|
|
33
39
|
"thoughts/shared/reviews",
|
|
34
40
|
] as const;
|
|
35
41
|
|
|
36
|
-
const msgAgentsAdded = (n: number) => `Copied ${n} rpiv-pi agent(s) to
|
|
42
|
+
const msgAgentsAdded = (n: number) => `Copied ${n} rpiv-pi agent(s) to ~/.pi/agent/agents/`;
|
|
37
43
|
const msgAgentsHealed = (parts: string[]) => `Synced bundled agent(s): ${parts.join(", ")}.`;
|
|
38
44
|
const msgAgentsDrift = (parts: string[]) => `${parts.join(", ")} agent(s). Run /rpiv-update-agents to sync.`;
|
|
39
45
|
const msgAgentsErrors = (n: number) => `Agent sync reported ${n} error(s). Run /rpiv-update-agents for details.`;
|
|
@@ -79,8 +85,10 @@ async function onSessionStart(
|
|
|
79
85
|
injectRootGuidance(ctx.cwd, pi);
|
|
80
86
|
scaffoldThoughtsDirs(ctx.cwd);
|
|
81
87
|
await injectGitContext(pi, (msg) => sendGitContextMessage(pi, msg));
|
|
82
|
-
const
|
|
88
|
+
const cleanup = cleanupPerCwdAgents(ctx.cwd);
|
|
89
|
+
const agents = syncBundledAgents(false);
|
|
83
90
|
if (ctx.hasUI) {
|
|
91
|
+
notifyCleanup(ctx.ui, cleanup);
|
|
84
92
|
notifyAgentSyncDrift(ctx.ui, agents);
|
|
85
93
|
warnMissingSiblings(ctx.ui);
|
|
86
94
|
}
|
|
@@ -157,6 +165,21 @@ function notifyAgentSyncDrift(ui: UI, result: SyncResult): void {
|
|
|
157
165
|
}
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
function notifyCleanup(ui: UI, result: CleanupResult): void {
|
|
169
|
+
if (result.cleanedUp.length > 0) {
|
|
170
|
+
ui.notify(`Cleaned up ${result.cleanedUp.length} per-project agent directory (migrated to global)`, "info");
|
|
171
|
+
}
|
|
172
|
+
if (result.skipped.length > 0) {
|
|
173
|
+
ui.notify(
|
|
174
|
+
`Preserved ${result.skipped.length} per-project agent directory (${summarizeCleanupSkips(result.skipped)})`,
|
|
175
|
+
"info",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (result.errors.length > 0) {
|
|
179
|
+
ui.notify(`Agent cleanup reported ${result.errors.length} error(s)`, "warning");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
160
183
|
function warnMissingSiblings(ui: UI): void {
|
|
161
184
|
const missing = findMissingSiblings();
|
|
162
185
|
if (missing.length === 0) return;
|
|
@@ -11,13 +11,18 @@ vi.mock("./agents.js", () => ({
|
|
|
11
11
|
MKDIR: "mkdir",
|
|
12
12
|
},
|
|
13
13
|
syncBundledAgents: vi.fn(),
|
|
14
|
+
cleanupPerCwdAgents: vi.fn(),
|
|
14
15
|
}));
|
|
15
16
|
|
|
16
|
-
import { SYNC_OP, syncBundledAgents } from "./agents.js";
|
|
17
|
+
import { cleanupPerCwdAgents, SYNC_OP, syncBundledAgents } from "./agents.js";
|
|
17
18
|
import { registerUpdateAgentsCommand } from "./update-agents-command.js";
|
|
18
19
|
|
|
20
|
+
const emptyCleanup = () => ({ cleanedUp: [], skipped: [], errors: [] });
|
|
21
|
+
|
|
19
22
|
beforeEach(() => {
|
|
20
23
|
vi.mocked(syncBundledAgents).mockReset();
|
|
24
|
+
vi.mocked(cleanupPerCwdAgents).mockReset();
|
|
25
|
+
vi.mocked(cleanupPerCwdAgents).mockReturnValue(emptyCleanup());
|
|
21
26
|
});
|
|
22
27
|
|
|
23
28
|
const empty = (overrides: Partial<ReturnType<typeof syncBundledAgents>> = {}) => ({
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /rpiv-update-agents — apply-mode sync of bundled agents into
|
|
2
|
+
* /rpiv-update-agents — apply-mode sync of bundled agents into ~/.pi/agent/agents/.
|
|
3
|
+
* Also cleans up legacy per-cwd agent directories.
|
|
3
4
|
* Adds new, overwrites changed managed files, removes stale managed files.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { type SyncResult, syncBundledAgents } from "./agents.js";
|
|
8
|
+
import { cleanupPerCwdAgents, type SyncResult, summarizeCleanupSkips, syncBundledAgents } from "./agents.js";
|
|
8
9
|
|
|
9
10
|
const MSG_UP_TO_DATE = "All agents already up-to-date.";
|
|
10
11
|
const MSG_NO_CHANGES = "No changes needed.";
|
|
@@ -15,11 +16,20 @@ const msgSyncedWithErrors = (summary: string, errors: string[]) =>
|
|
|
15
16
|
|
|
16
17
|
export function registerUpdateAgentsCommand(pi: ExtensionAPI): void {
|
|
17
18
|
pi.registerCommand("rpiv-update-agents", {
|
|
18
|
-
description:
|
|
19
|
+
description:
|
|
20
|
+
"Sync rpiv-pi bundled agents into ~/.pi/agent/agents/: add new, update changed, remove stale. Also cleans up legacy per-project agent directories.",
|
|
19
21
|
handler: async (_args, ctx) => {
|
|
20
|
-
const
|
|
22
|
+
const cleanup = cleanupPerCwdAgents(ctx.cwd);
|
|
23
|
+
const result = syncBundledAgents(true);
|
|
21
24
|
if (!ctx.hasUI) return;
|
|
22
|
-
|
|
25
|
+
const parts: string[] = [];
|
|
26
|
+
if (cleanup.cleanedUp.length > 0) parts.push(`${cleanup.cleanedUp.length} old dir(s) cleaned up`);
|
|
27
|
+
if (cleanup.skipped.length > 0)
|
|
28
|
+
parts.push(`${cleanup.skipped.length} old dir(s) preserved (${summarizeCleanupSkips(cleanup.skipped)})`);
|
|
29
|
+
if (cleanup.errors.length > 0) parts.push(`${cleanup.errors.length} cleanup error(s)`);
|
|
30
|
+
const syncReport = formatSyncReport(result);
|
|
31
|
+
const fullReport = parts.length > 0 ? `${parts.join(", ")}. ${syncReport}` : syncReport;
|
|
32
|
+
ctx.ui.notify(fullReport, result.errors.length + cleanup.errors.length > 0 ? "warning" : "info");
|
|
23
33
|
},
|
|
24
34
|
});
|
|
25
35
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juicesharp/rpiv-pi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "A skill-based development workflow for Pi Agent. Five skills (research, design, plan, implement, validate) and the shared subagents that compose its ship-loop.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|