@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.2

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.
Files changed (129) hide show
  1. package/AGENTS.md +104 -35
  2. package/README.md +390 -494
  3. package/docs/architecture.md +423 -20
  4. package/package.json +11 -8
  5. package/packages/extension/package.json +11 -4
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
  8. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  14. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  15. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  16. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  17. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  18. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  19. package/packages/extension/src/ask-user-tool.ts +170 -61
  20. package/packages/extension/src/bridge.ts +199 -19
  21. package/packages/extension/src/multiselect-decode.ts +40 -0
  22. package/packages/extension/src/multiselect-list.ts +146 -0
  23. package/packages/extension/src/multiselect-polyfill.ts +73 -0
  24. package/packages/extension/src/server-launcher.ts +15 -3
  25. package/packages/extension/src/ui-modules.ts +272 -0
  26. package/packages/server/package.json +11 -5
  27. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  28. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  29. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  30. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  31. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  32. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  33. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  34. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  35. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  36. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  37. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  38. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  39. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  40. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  41. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  43. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  44. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  45. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  46. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  47. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  49. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  50. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  51. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  52. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  53. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  54. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  55. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  56. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  57. package/packages/server/src/browse.ts +118 -13
  58. package/packages/server/src/browser-gateway.ts +19 -0
  59. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  60. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  61. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  63. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  64. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  65. package/packages/server/src/cli.ts +61 -15
  66. package/packages/server/src/directory-service.ts +156 -15
  67. package/packages/server/src/event-wiring.ts +111 -10
  68. package/packages/server/src/installed-package-enricher.ts +143 -0
  69. package/packages/server/src/package-manager-wrapper.ts +305 -8
  70. package/packages/server/src/package-source-helpers.ts +104 -0
  71. package/packages/server/src/pending-attach-registry.ts +112 -0
  72. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  73. package/packages/server/src/pi-core-checker.ts +9 -14
  74. package/packages/server/src/pi-gateway.ts +14 -0
  75. package/packages/server/src/pi-version-skew.ts +12 -1
  76. package/packages/server/src/proposal-attach-naming.ts +47 -0
  77. package/packages/server/src/restart-helper.ts +13 -2
  78. package/packages/server/src/routes/file-routes.ts +29 -3
  79. package/packages/server/src/routes/package-routes.ts +72 -3
  80. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  81. package/packages/server/src/routes/system-routes.ts +2 -0
  82. package/packages/server/src/server.ts +339 -10
  83. package/packages/server/src/session-api.ts +30 -5
  84. package/packages/server/src/session-order-manager.ts +22 -0
  85. package/packages/server/src/session-scanner.ts +10 -1
  86. package/packages/shared/package.json +9 -2
  87. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  88. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  89. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  90. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  91. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  93. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  94. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  95. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  96. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  97. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  98. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  99. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  100. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  101. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  102. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  103. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  104. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  105. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  106. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  107. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  108. package/packages/shared/src/browser-protocol.ts +110 -4
  109. package/packages/shared/src/config.ts +45 -0
  110. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  111. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  112. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  113. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  114. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  115. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  116. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  117. package/packages/shared/src/openspec-poller.ts +117 -3
  118. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  119. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  120. package/packages/shared/src/platform/index.ts +1 -0
  121. package/packages/shared/src/platform/node-spawn.ts +154 -0
  122. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  123. package/packages/shared/src/protocol.ts +79 -2
  124. package/packages/shared/src/recommended-extensions.ts +7 -1
  125. package/packages/shared/src/rest-api.ts +68 -3
  126. package/packages/shared/src/state-replay.ts +20 -1
  127. package/packages/shared/src/tool-registry/definitions.ts +92 -0
  128. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  129. package/packages/shared/src/types.ts +160 -0
@@ -226,6 +226,75 @@ describe("DirectoryService", () => {
226
226
 
227
227
  fs.rmSync(tmp, { recursive: true, force: true });
228
228
  });
229
+
230
+ it("applies design override (R3): tasks.md with checkboxes promotes design→done", async () => {
231
+ // See change: fix-openspec-design-detection.
232
+ const fs = await import("node:fs");
233
+ const os = await import("node:os");
234
+ const path = await import("node:path");
235
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ds-design-override-"));
236
+ const changeDir = path.join(tmp, "openspec", "changes", "change-x");
237
+ fs.mkdirSync(changeDir, { recursive: true });
238
+ fs.writeFileSync(path.join(changeDir, "tasks.md"), "## 1. Setup\n\n- [ ] 1.1 Do thing\n");
239
+
240
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
241
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
242
+ { name: "change-x", status: "in-progress", completedTasks: 0, totalTasks: 1 },
243
+ ] });
244
+ (runOpenSpecStatus as any).mockResolvedValue({
245
+ artifacts: [
246
+ { id: "proposal", status: "done" },
247
+ { id: "specs", status: "done" },
248
+ { id: "design", status: "ready" },
249
+ { id: "tasks", status: "ready" },
250
+ ],
251
+ isComplete: false,
252
+ });
253
+
254
+ const stateStore = createMockPreferencesStore();
255
+ const sessionManager = createMockSessionManager();
256
+ service = createDirectoryService(stateStore, sessionManager);
257
+
258
+ const data = await service.refreshOpenSpec(tmp);
259
+ const change = data.changes.find((c) => c.name === "change-x")!;
260
+ const design = change.artifacts.find((a) => a.id === "design")!;
261
+ expect(design.status).toBe("done");
262
+ // tasks artifact should pass through unchanged (still ready)
263
+ expect(change.artifacts.find((a) => a.id === "tasks")!.status).toBe("ready");
264
+
265
+ fs.rmSync(tmp, { recursive: true, force: true });
266
+ });
267
+
268
+ it("design override leaves design=ready when no evidence", async () => {
269
+ const fs = await import("node:fs");
270
+ const os = await import("node:os");
271
+ const path = await import("node:path");
272
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ds-design-no-override-"));
273
+ const changeDir = path.join(tmp, "openspec", "changes", "change-y");
274
+ fs.mkdirSync(changeDir, { recursive: true });
275
+ fs.writeFileSync(path.join(changeDir, "proposal.md"), "# proposal\n");
276
+
277
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
278
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
279
+ { name: "change-y", status: "in-progress", completedTasks: 0, totalTasks: 0 },
280
+ ] });
281
+ (runOpenSpecStatus as any).mockResolvedValue({
282
+ artifacts: [
283
+ { id: "proposal", status: "done" },
284
+ { id: "design", status: "ready" },
285
+ ],
286
+ });
287
+
288
+ const stateStore = createMockPreferencesStore();
289
+ const sessionManager = createMockSessionManager();
290
+ service = createDirectoryService(stateStore, sessionManager);
291
+
292
+ const data = await service.refreshOpenSpec(tmp);
293
+ const change = data.changes.find((c) => c.name === "change-y")!;
294
+ expect(change.artifacts.find((a) => a.id === "design")!.status).toBe("ready");
295
+
296
+ fs.rmSync(tmp, { recursive: true, force: true });
297
+ });
229
298
  });
230
299
 
231
300
  describe("onDirectoryAdded", () => {
@@ -375,6 +444,111 @@ describe("DirectoryService", () => {
375
444
  expect(runOpenSpecStatus).toHaveBeenCalledTimes(1);
376
445
  });
377
446
 
447
+ it("re-spawns list+status when tasks.md is edited in place (POSIX dir-mtime blind spot)", async () => {
448
+ // This test covers the bug fix in change `fix-openspec-mtime-gate-blind-spots`:
449
+ // POSIX directory mtime advances only on entry create/delete/rename, not on
450
+ // in-place file content edits. The previous gate used dir mtime alone and
451
+ // missed these edits, leaving `completedTasks` stuck at the cached value.
452
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
453
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
454
+ { name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 3 },
455
+ { name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 1 },
456
+ ] });
457
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
458
+
459
+ // Seed tasks.md inside change-a so the file is part of the gate signal.
460
+ const tasksMd = path.join(changesDir, "change-a", "tasks.md");
461
+ fs.writeFileSync(tasksMd, "- [ ] 1.1 a\n- [ ] 1.2 b\n- [ ] 1.3 c\n", "utf-8");
462
+
463
+ const stateStore = createMockPreferencesStore();
464
+ const sessionManager = createMockSessionManager();
465
+ service = createDirectoryService(stateStore, sessionManager);
466
+ await service.refreshOpenSpec(cwd);
467
+ (runOpenSpecList as any).mockClear();
468
+ (runOpenSpecStatus as any).mockClear();
469
+
470
+ // Simulate an in-place edit: rewrite tasks.md AND bump its file mtime.
471
+ // Crucially, do NOT touch the parent directory's mtime — that's the
472
+ // blind spot the fix is supposed to cover.
473
+ fs.writeFileSync(tasksMd, "- [x] 1.1 a\n- [x] 1.2 b\n- [ ] 1.3 c\n", "utf-8");
474
+ const future = new Date(Date.now() + 60_000);
475
+ fs.utimesSync(tasksMd, future, future);
476
+
477
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
478
+ { name: "change-a", status: "in-progress", completedTasks: 2, totalTasks: 3 },
479
+ { name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 1 },
480
+ ] });
481
+
482
+ await service.pollDirectoryGated(cwd);
483
+
484
+ // List re-runs because the tasks.md file mtime is part of the list-step signal.
485
+ expect(runOpenSpecList).toHaveBeenCalledTimes(1);
486
+ // Status re-runs only for change-a (its effective mtime advanced via tasks.md).
487
+ expect(runOpenSpecStatus).toHaveBeenCalledTimes(1);
488
+ expect((runOpenSpecStatus as any).mock.calls[0][1]).toBe("change-a");
489
+ // Cache reflects the new counter.
490
+ const data = service.getOpenSpecData(cwd);
491
+ const ca = data?.changes.find((c) => c.name === "change-a");
492
+ expect(ca?.completedTasks).toBe(2);
493
+ });
494
+
495
+ it("post-archive path uses pollDirectoryGated (zero status spawns for unchanged changes)", async () => {
496
+ // Covers the post-archive contract: `fix-openspec-mtime-gate-blind-spots`
497
+ // made the gate file-aware so the bulk-archive path could safely skip
498
+ // status spawns for unchanged changes. `fix-openspec-mtime-gate-toctou`
499
+ // re-introduced `force=true` on the user-facing `refreshOpenSpec` (so a
500
+ // user clicking the refresh icon always sees authoritative data), and
501
+ // routed the bulk-archive handler through `pollDirectoryGated` instead
502
+ // — preserving the O(1) status-spawn property after archives.
503
+ // Pre-fix (pre-blind-spots) this test would see 4 status spawns because
504
+ // `force=true` disabled the gate.
505
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
506
+
507
+ // Set up 5 changes in the directory (replacing the beforeEach default).
508
+ fs.rmSync(path.join(changesDir, "change-a"), { recursive: true });
509
+ fs.rmSync(path.join(changesDir, "change-b"), { recursive: true });
510
+ for (const n of ["c1", "c2", "c3", "c4", "c5"]) {
511
+ fs.mkdirSync(path.join(changesDir, n));
512
+ }
513
+
514
+ (runOpenSpecList as any).mockResolvedValueOnce({ changes: [
515
+ { name: "c1", status: "in-progress", completedTasks: 0, totalTasks: 1 },
516
+ { name: "c2", status: "in-progress", completedTasks: 0, totalTasks: 1 },
517
+ { name: "c3", status: "in-progress", completedTasks: 0, totalTasks: 1 },
518
+ { name: "c4", status: "in-progress", completedTasks: 0, totalTasks: 1 },
519
+ { name: "c5", status: "in-progress", completedTasks: 0, totalTasks: 1 },
520
+ ] });
521
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
522
+
523
+ const stateStore = createMockPreferencesStore();
524
+ const sessionManager = createMockSessionManager();
525
+ service = createDirectoryService(stateStore, sessionManager);
526
+
527
+ // First poll seeds the cache (5 list+status spawns; not what's under test).
528
+ await service.pollDirectoryGated(cwd);
529
+ (runOpenSpecList as any).mockClear();
530
+ (runOpenSpecStatus as any).mockClear();
531
+
532
+ // Simulate archive: remove one change directory and bump <changes>/ mtime.
533
+ fs.rmSync(path.join(changesDir, "c5"), { recursive: true });
534
+ const future = new Date(Date.now() + 60_000);
535
+ fs.utimesSync(changesDir, future, future);
536
+ (runOpenSpecList as any).mockResolvedValueOnce({ changes: [
537
+ { name: "c1", status: "in-progress", completedTasks: 0, totalTasks: 1 },
538
+ { name: "c2", status: "in-progress", completedTasks: 0, totalTasks: 1 },
539
+ { name: "c3", status: "in-progress", completedTasks: 0, totalTasks: 1 },
540
+ { name: "c4", status: "in-progress", completedTasks: 0, totalTasks: 1 },
541
+ ] });
542
+
543
+ await service.pollDirectoryGated(cwd);
544
+
545
+ expect(runOpenSpecList).toHaveBeenCalledTimes(1);
546
+ // The gate skips every status because none of the surviving changes'
547
+ // artifact files moved. This is the path `handleOpenSpecBulkArchive`
548
+ // now takes (was `refreshOpenSpec` before fix-openspec-mtime-gate-toctou).
549
+ expect(runOpenSpecStatus).toHaveBeenCalledTimes(0);
550
+ });
551
+
378
552
  it("removed change is pruned from cache", async () => {
379
553
  const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
380
554
  (runOpenSpecList as any).mockResolvedValueOnce({ changes: [
@@ -0,0 +1,8 @@
1
+ {"type":"session","version":"1","id":"019dcdd5-0000-0000-0000-000000000000","timestamp":"2026-04-27T07:26:23.927Z","cwd":"/tmp/fork-test"}
2
+ {"type":"model_change","id":"m1","timestamp":"2026-04-27T07:26:24.000Z","provider":"anthropic","modelId":"claude-sonnet-4"}
3
+ {"type":"message","id":"u1","parentId":"m1","timestamp":"2026-04-27T07:26:25.000Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}]}}
4
+ {"type":"message","id":"a1","parentId":"u1","timestamp":"2026-04-27T07:26:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi there!"}]}}
5
+ {"type":"message","id":"u2","parentId":"a1","timestamp":"2026-04-27T07:26:40.000Z","message":{"role":"user","content":[{"type":"text","text":"How are you?"}]}}
6
+ {"type":"message","id":"a2","parentId":"u2","timestamp":"2026-04-27T07:26:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Doing well."}]}}
7
+ {"type":"message","id":"u3","parentId":"a2","timestamp":"2026-04-27T07:26:55.000Z","message":{"role":"user","content":[{"type":"text","text":"Goodbye"}]}}
8
+ {"type":"message","id":"a3","parentId":"u3","timestamp":"2026-04-27T07:27:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Bye!"}]}}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Round-trip test: createBranchedSessionFile MUST end the new JSONL at the
3
+ * given entry id. Catches the fork-bubble off-by-one bug from the upstream
4
+ * angle: if the bridge ever stamps a correct entry id on a bubble, this
5
+ * function must produce a file whose tail entry equals that id.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+ import { readFileSync, mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { createBranchedSessionFile } from "../session-file-reader.js";
12
+
13
+ const FIXTURE = join(__dirname, "fixtures", "fork-jsonl-roundtrip.jsonl");
14
+
15
+ function readEntries(path: string): any[] {
16
+ return readFileSync(path, "utf-8").trim().split("\n").map(l => JSON.parse(l));
17
+ }
18
+
19
+ describe("createBranchedSessionFile round-trip", () => {
20
+ it("for every non-header entry id, the forked JSONL ends at that id", () => {
21
+ // Copy fixture to a tmp dir so the function can write its sibling output there.
22
+ const tmp = mkdtempSync(join(tmpdir(), "fork-roundtrip-"));
23
+ const tmpFixture = join(tmp, "src.jsonl");
24
+ require("node:fs").copyFileSync(FIXTURE, tmpFixture);
25
+
26
+ try {
27
+ const allEntries = readEntries(tmpFixture);
28
+ const candidates = allEntries.filter(e => e.type === "message" || e.type === "model_change").map(e => e.id);
29
+ expect(candidates.length).toBeGreaterThan(0);
30
+
31
+ for (const targetId of candidates) {
32
+ const newPath = createBranchedSessionFile(tmpFixture, targetId);
33
+ const newEntries = readEntries(newPath);
34
+
35
+ const header = newEntries[0];
36
+ expect(header.type).toBe("session");
37
+
38
+ const lastEntry = newEntries[newEntries.length - 1];
39
+ expect(lastEntry.id).toBe(targetId);
40
+ }
41
+ } finally {
42
+ rmSync(tmp, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ it("throws on unknown entry id", () => {
47
+ expect(() => createBranchedSessionFile(FIXTURE, "does-not-exist")).toThrow(/not found/i);
48
+ });
49
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Unit tests for the installed-package enricher (used by /api/packages/installed).
3
+ *
4
+ * The enricher is pure-with-injected-IO: every external dep
5
+ * (readMeta, existsFn, manifest, bundledIds) is replaceable. We
6
+ * exercise each branch without touching the real filesystem.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import {
10
+ enrichInstalledRow,
11
+ extractBasenameFromSource,
12
+ matchRecommendedEntry,
13
+ computeIsBundled,
14
+ type RawInstalledRow,
15
+ } from "../installed-package-enricher.js";
16
+ import type { RecommendedExtension } from "@blackbelt-technology/pi-dashboard-shared/recommended-extensions.js";
17
+
18
+ const FAKE_MANIFEST: readonly RecommendedExtension[] = [
19
+ {
20
+ id: "pi-flows",
21
+ source: "https://github.com/BlackBeltTechnology/pi-flows.git",
22
+ displayName: "pi-flows",
23
+ fallbackDescription: "Flow engine for pi.",
24
+ status: "strongly-suggested",
25
+ unlocks: [],
26
+ toolsRegistered: [],
27
+ },
28
+ {
29
+ id: "pi-anthropic-messages",
30
+ source: "https://github.com/BlackBeltTechnology/pi-anthropic-messages.git",
31
+ displayName: "pi-anthropic-messages",
32
+ fallbackDescription: "Anthropic Messages provider.",
33
+ status: "required",
34
+ unlocks: [],
35
+ toolsRegistered: [],
36
+ },
37
+ {
38
+ id: "@tintinweb/pi-subagents",
39
+ source: "npm:@tintinweb/pi-subagents",
40
+ displayName: "@tintinweb/pi-subagents",
41
+ fallbackDescription: "Sub-agents for pi.",
42
+ status: "strongly-suggested",
43
+ unlocks: [],
44
+ toolsRegistered: [],
45
+ },
46
+ ];
47
+
48
+ describe("extractBasenameFromSource", () => {
49
+ it("strips npm: prefix and version pin", () => {
50
+ expect(extractBasenameFromSource("npm:pi-agent-browser")).toBe("pi-agent-browser");
51
+ expect(extractBasenameFromSource("npm:pi-agent-browser@1.2.3")).toBe("pi-agent-browser");
52
+ expect(extractBasenameFromSource("npm:@tintinweb/pi-subagents")).toBe("@tintinweb/pi-subagents");
53
+ });
54
+
55
+ it("strips .git suffix from git URLs", () => {
56
+ expect(extractBasenameFromSource("https://github.com/BlackBeltTechnology/pi-flows.git")).toBe("pi-flows");
57
+ expect(extractBasenameFromSource("git@github.com:BlackBeltTechnology/pi-flows.git")).toBe("pi-flows");
58
+ });
59
+
60
+ it("returns last path segment for local paths", () => {
61
+ expect(extractBasenameFromSource("/home/user/pi-packages/pi-flows")).toBe("pi-flows");
62
+ expect(extractBasenameFromSource("../../BB/pi-packages/pi-flows")).toBe("pi-flows");
63
+ });
64
+
65
+ it("falls back to the raw source when nothing else matches", () => {
66
+ expect(extractBasenameFromSource("weird-thing")).toBe("weird-thing");
67
+ });
68
+ });
69
+
70
+ describe("matchRecommendedEntry", () => {
71
+ it("matches by exact source", () => {
72
+ expect(
73
+ matchRecommendedEntry("npm:@tintinweb/pi-subagents", FAKE_MANIFEST)?.id,
74
+ ).toBe("@tintinweb/pi-subagents");
75
+ });
76
+
77
+ it("matches git source regardless of trailing slash / case", () => {
78
+ expect(
79
+ matchRecommendedEntry(
80
+ "https://github.com/BlackBeltTechnology/pi-flows.git",
81
+ FAKE_MANIFEST,
82
+ )?.id,
83
+ ).toBe("pi-flows");
84
+ });
85
+
86
+ it("returns undefined when no entry matches", () => {
87
+ expect(matchRecommendedEntry("npm:weird-other-pkg", FAKE_MANIFEST)).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ describe("computeIsBundled", () => {
92
+ it("returns false when no resourcesPath", () => {
93
+ expect(computeIsBundled("pi-flows", undefined, () => true, ["pi-flows"])).toBe(false);
94
+ });
95
+
96
+ it("returns false when id not in bundledIds", () => {
97
+ expect(computeIsBundled("pi-flows", "/res", () => true, ["pi-anthropic-messages"])).toBe(false);
98
+ });
99
+
100
+ it("returns false when bundle dir missing on disk", () => {
101
+ expect(computeIsBundled("pi-flows", "/res", () => false, ["pi-flows"])).toBe(false);
102
+ });
103
+
104
+ it("returns true when all conditions met", () => {
105
+ const exists = (p: string) => p === "/res/bundled-extensions/pi-flows";
106
+ expect(computeIsBundled("pi-flows", "/res", exists, ["pi-flows"])).toBe(true);
107
+ });
108
+ });
109
+
110
+ describe("enrichInstalledRow", () => {
111
+ const baseDeps = {
112
+ manifest: FAKE_MANIFEST,
113
+ bundledIds: ["pi-flows", "pi-anthropic-messages"],
114
+ };
115
+
116
+ it("enriches a recommended npm row with displayName and description from manifest", () => {
117
+ const row: RawInstalledRow = {
118
+ source: "npm:@tintinweb/pi-subagents",
119
+ scope: "user",
120
+ filtered: false,
121
+ installedPath: "/fake/path",
122
+ };
123
+ const out = enrichInstalledRow(row, {
124
+ ...baseDeps,
125
+ readMeta: () => ({ version: "0.6.1", description: "Live npm desc" }),
126
+ existsFn: () => false,
127
+ resourcesPath: "/res",
128
+ });
129
+ expect(out.displayName).toBe("@tintinweb/pi-subagents");
130
+ // Recommended manifest description wins over package.json description.
131
+ expect(out.description).toBe("Sub-agents for pi.");
132
+ expect(out.version).toBe("0.6.1");
133
+ expect(out.isRecommended).toBe(true);
134
+ expect(out.isBundled).toBe(false);
135
+ });
136
+
137
+ it("enriches a recommended git row that is bundled", () => {
138
+ const row: RawInstalledRow = {
139
+ source: "https://github.com/BlackBeltTechnology/pi-flows.git",
140
+ scope: "user",
141
+ filtered: false,
142
+ installedPath: "/cache/pi-flows",
143
+ };
144
+ const out = enrichInstalledRow(row, {
145
+ ...baseDeps,
146
+ readMeta: () => ({ version: "0.1.0", description: "from package.json" }),
147
+ existsFn: (p) => p === "/res/bundled-extensions/pi-flows",
148
+ resourcesPath: "/res",
149
+ });
150
+ expect(out.isRecommended).toBe(true);
151
+ expect(out.isBundled).toBe(true);
152
+ expect(out.displayName).toBe("pi-flows");
153
+ });
154
+
155
+ it("enriches a non-recommended row using basename + package.json", () => {
156
+ const row: RawInstalledRow = {
157
+ source: "/home/dev/pi-mystery",
158
+ scope: "user",
159
+ filtered: false,
160
+ installedPath: "/home/dev/pi-mystery",
161
+ };
162
+ const out = enrichInstalledRow(row, {
163
+ ...baseDeps,
164
+ readMeta: () => ({ version: "9.9.9", description: "Mystery extension" }),
165
+ existsFn: () => false,
166
+ });
167
+ expect(out.isRecommended).toBe(false);
168
+ expect(out.isBundled).toBe(false);
169
+ expect(out.displayName).toBe("pi-mystery");
170
+ expect(out.description).toBe("Mystery extension");
171
+ expect(out.version).toBe("9.9.9");
172
+ });
173
+
174
+ it("handles missing installedPath silently", () => {
175
+ const row: RawInstalledRow = {
176
+ source: "npm:@tintinweb/pi-subagents",
177
+ scope: "user",
178
+ filtered: false,
179
+ };
180
+ const out = enrichInstalledRow(row, {
181
+ ...baseDeps,
182
+ readMeta: () => ({}),
183
+ existsFn: () => false,
184
+ });
185
+ expect(out.version).toBeUndefined();
186
+ // Manifest description still applies.
187
+ expect(out.description).toBe("Sub-agents for pi.");
188
+ expect(out.isRecommended).toBe(true);
189
+ });
190
+
191
+ it("handles unreadable package.json silently", () => {
192
+ const row: RawInstalledRow = {
193
+ source: "/local/broken",
194
+ scope: "user",
195
+ filtered: false,
196
+ installedPath: "/local/broken",
197
+ };
198
+ const out = enrichInstalledRow(row, {
199
+ ...baseDeps,
200
+ readMeta: () => ({}),
201
+ existsFn: () => false,
202
+ });
203
+ expect(out.version).toBeUndefined();
204
+ expect(out.description).toBeUndefined();
205
+ expect(out.isRecommended).toBe(false);
206
+ expect(out.displayName).toBe("broken");
207
+ });
208
+
209
+ it("isBundled is always false outside Electron (no resourcesPath)", () => {
210
+ const row: RawInstalledRow = {
211
+ source: "https://github.com/BlackBeltTechnology/pi-flows.git",
212
+ scope: "user",
213
+ filtered: false,
214
+ installedPath: "/cache/pi-flows",
215
+ };
216
+ const out = enrichInstalledRow(row, {
217
+ ...baseDeps,
218
+ readMeta: () => ({ version: "0.1.0" }),
219
+ existsFn: () => true,
220
+ resourcesPath: undefined,
221
+ });
222
+ expect(out.isRecommended).toBe(true);
223
+ expect(out.isBundled).toBe(false);
224
+ });
225
+ });