@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 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 `<cwd>/.pi/agents/`
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` | Sync rpiv agent profiles: add new, update changed, remove stale |
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` | Set Brave Search API key |
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 set the Brave Search API key, or set the `BRAVE_SEARCH_API_KEY` environment variable
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** - editable at `<cwd>/.pi/agents/`; sync from bundled defaults with `/rpiv-update-agents` (overwrites rpiv-managed files, preserves your custom agents)
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 | Brave API key not configured | Run `/web-search-config` or set `BRAVE_SEARCH_API_KEY` |
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 { BUNDLED_AGENTS_DIR, isSafeDestructiveOp, SYNC_OP, syncBundledAgents } from "./agents.js";
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(cwd, ".pi", "agents");
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, true);
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(cwd, false);
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(cwd, true);
341
- const r = syncBundledAgents(cwd, false);
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(cwd, true);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, true);
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(cwd, true);
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(cwd, true);
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(cwd, true);
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(cwd, true);
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(cwd, true);
465
- const r = syncBundledAgents(cwd, true);
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(cwd, false);
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(cwd, false);
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(cwd, false); // first sync — writes marker
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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
- writeFileSync(manifestPath, JSON.stringify({ "evil\u0000.md": "" }), "utf-8");
642
+ const nulKey = `evil${String.fromCharCode(0)}.md`;
643
+ writeFileSync(manifestPath, JSON.stringify({ [nulKey]: "" }), "utf-8");
632
644
 
633
- const r = syncBundledAgents(cwd, false);
645
+ const r = syncBundledAgents(false);
634
646
 
635
647
  expect(r.errors).toEqual([]);
636
- expect(r.removed).not.toContain("evil\u0000.md");
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(cwd, false);
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(cwd, false);
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
- // Plan called for chmod(targetDir, 0o500), but POSIX dir-without-write still
672
- // permits writes to existing files inside, so writeManifest would not fail.
673
- // Read-only the manifest file itself to deterministically trigger EACCES on
674
- // the writeManifest open(O_WRONLY|O_TRUNC|O_CREAT).
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(manifestPath, 0o400);
690
+ chmodSync(targetDir, 0o500);
680
691
 
681
692
  try {
682
- const r = syncBundledAgents(cwd, true);
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(manifestPath, 0o600);
696
+ chmodSync(targetDir, 0o700);
686
697
  }
687
698
  });
688
699
 
689
700
  it("Q18: mkdir failure tagged op:'mkdir' (not op:'manifest-write')", () => {
690
- // Plan called for vi.spyOn(fs, "mkdirSync"), but ESM module namespaces are
691
- // not configurable under this Vitest config. Inject the failure by placing
692
- // a regular file at `<cwd>/.pi` so mkdirSync(<cwd>/.pi/agents, {recursive:true})
693
- // fails with ENOTDIR same observable: an op:"mkdir" SyncError.
694
- writeFileSync(join(cwd, ".pi"), "not a dir", "utf-8");
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
- const r = syncBundledAgents(cwd, false);
708
+ try {
709
+ const r = syncBundledAgents(false);
697
710
 
698
- expect(r.errors.some((e) => e.op === SYNC_OP.MKDIR)).toBe(true);
699
- expect(r.errors.some((e) => e.op === SYNC_OP.MANIFEST_WRITE)).toBe(false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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(cwd, false);
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 <cwd>/.pi/agents/.
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 cwd. Two
7
- * sessions racing here may produce a partial manifest where the loser's
8
- * mutations are untracked. Per-cwd advisory locking is a deferred follow-up
9
- * (see CHANGELOG known-limitations and review findings Q12/Q13). The path
10
- * allowlist in readManifest neutralises the worst-case (arbitrary-path
11
- * unlink) regardless of concurrency.
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 { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
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
- writeFileSync(manifestPath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8");
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 <cwd>/.pi/agents/.
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 project, surviving JSON corruption / partial writes / empty-
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(cwd: string, apply: boolean): SyncResult {
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(cwd, ".pi", "agents");
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((cwd, apply) => real.syncBundledAgents(cwd, apply));
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(projectDir, ".pi", "agents");
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((cwd, apply) => real.syncBundledAgents(cwd, apply));
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 { type SyncResult, syncBundledAgents } from "./agents.js";
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 .pi/agents/`;
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 agents = syncBundledAgents(ctx.cwd, false);
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 <cwd>/.pi/agents/.
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: "Sync rpiv-pi bundled agents into .pi/agents/: add new, update changed, remove stale",
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 result = syncBundledAgents(ctx.cwd, true);
22
+ const cleanup = cleanupPerCwdAgents(ctx.cwd);
23
+ const result = syncBundledAgents(true);
21
24
  if (!ctx.hasUI) return;
22
- ctx.ui.notify(formatSyncReport(result), result.errors.length > 0 ? "warning" : "info");
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.6.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",