@blackbelt-technology/pi-agent-dashboard 0.4.1 → 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 (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  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 +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. package/packages/shared/src/types.ts +160 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Unit tests for the in-memory pending-attach registry.
3
+ * See change: add-folder-task-checker-and-spawn-attach.
4
+ */
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import {
7
+ createPendingAttachRegistry,
8
+ PENDING_ATTACH_QUEUE_CAP,
9
+ PENDING_ATTACH_TTL_MS,
10
+ } from "../pending-attach-registry.js";
11
+
12
+ function fakeNow() {
13
+ let t = 1_000_000;
14
+ return {
15
+ now: () => t,
16
+ advance(ms: number) {
17
+ t += ms;
18
+ },
19
+ };
20
+ }
21
+
22
+ describe("pending-attach-registry", () => {
23
+ it("FIFO enqueue + consume returns names in order", () => {
24
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn: () => {} });
25
+ expect(reg.enqueue("/p", "a")).toBe(true);
26
+ expect(reg.enqueue("/p", "b")).toBe(true);
27
+ expect(reg.enqueue("/p", "c")).toBe(true);
28
+ expect(reg.size("/p")).toBe(3);
29
+ expect(reg.consume("/p")).toBe("a");
30
+ expect(reg.consume("/p")).toBe("b");
31
+ expect(reg.consume("/p")).toBe("c");
32
+ expect(reg.consume("/p")).toBeNull();
33
+ expect(reg.size("/p")).toBe(0);
34
+ });
35
+
36
+ it("empty queue consume returns null", () => {
37
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn: () => {} });
38
+ expect(reg.consume("/never-enqueued")).toBeNull();
39
+ });
40
+
41
+ it("isolated per cwd", () => {
42
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn: () => {} });
43
+ reg.enqueue("/a", "x");
44
+ reg.enqueue("/b", "y");
45
+ expect(reg.consume("/a")).toBe("x");
46
+ expect(reg.consume("/b")).toBe("y");
47
+ });
48
+
49
+ it("normalizes cwd: trailing slash collapses", () => {
50
+ // We exercise the default stripTrailingSep behaviour by using identity
51
+ // realpath to keep the test platform-agnostic.
52
+ const reg = createPendingAttachRegistry({ normalize: (s) => s.replace(/[/\\]+$/, ""), warn: () => {} });
53
+ reg.enqueue("/p/", "a");
54
+ expect(reg.size("/p")).toBe(1);
55
+ expect(reg.consume("/p")).toBe("a");
56
+ });
57
+
58
+ it("normalizes cwd: realpath equivalence", () => {
59
+ const reg = createPendingAttachRegistry({
60
+ normalize: (s) => (s === "/symlink" ? "/real" : s),
61
+ warn: () => {},
62
+ });
63
+ reg.enqueue("/symlink", "a");
64
+ expect(reg.size("/real")).toBe(1);
65
+ expect(reg.consume("/real")).toBe("a");
66
+ });
67
+
68
+ it(`drops at queue cap (${PENDING_ATTACH_QUEUE_CAP}) and warns`, () => {
69
+ const warn = vi.fn();
70
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn });
71
+ for (let i = 0; i < PENDING_ATTACH_QUEUE_CAP; i++) {
72
+ expect(reg.enqueue("/p", `c${i}`)).toBe(true);
73
+ }
74
+ expect(reg.size("/p")).toBe(PENDING_ATTACH_QUEUE_CAP);
75
+ expect(warn).not.toHaveBeenCalled();
76
+ expect(reg.enqueue("/p", "overflow")).toBe(false);
77
+ expect(reg.size("/p")).toBe(PENDING_ATTACH_QUEUE_CAP);
78
+ expect(warn).toHaveBeenCalledTimes(1);
79
+ expect(warn.mock.calls[0]![0]).toMatch(/cap reached/);
80
+ expect(warn.mock.calls[0]![0]).toMatch(/overflow/);
81
+ });
82
+
83
+ it("expires entries older than 60 s on read", () => {
84
+ const clock = fakeNow();
85
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, now: clock.now, warn: () => {} });
86
+ reg.enqueue("/p", "a");
87
+ clock.advance(PENDING_ATTACH_TTL_MS + 1);
88
+ expect(reg.size("/p")).toBe(0);
89
+ expect(reg.consume("/p")).toBeNull();
90
+ });
91
+
92
+ it("expires entries older than 60 s on write", () => {
93
+ const clock = fakeNow();
94
+ const warn = vi.fn();
95
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, now: clock.now, warn });
96
+ reg.enqueue("/p", "a");
97
+ clock.advance(PENDING_ATTACH_TTL_MS + 1);
98
+ // A new enqueue should drop the stale entry first, then push the new one.
99
+ reg.enqueue("/p", "b");
100
+ expect(reg.size("/p")).toBe(1);
101
+ expect(reg.consume("/p")).toBe("b");
102
+ expect(warn).toHaveBeenCalled();
103
+ expect(warn.mock.calls.some((c) => /stale intent/.test(String(c[0])))).toBe(true);
104
+ });
105
+
106
+ it("rejects empty changeName", () => {
107
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn: () => {} });
108
+ expect(reg.enqueue("/p", "")).toBe(false);
109
+ expect(reg.size("/p")).toBe(0);
110
+ });
111
+
112
+ it("partial expiry preserves fresh entries", () => {
113
+ const clock = fakeNow();
114
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, now: clock.now, warn: () => {} });
115
+ reg.enqueue("/p", "old");
116
+ clock.advance(PENDING_ATTACH_TTL_MS / 2);
117
+ reg.enqueue("/p", "new");
118
+ clock.advance(PENDING_ATTACH_TTL_MS / 2 + 1);
119
+ // "old" is now stale; "new" is fresh.
120
+ expect(reg.consume("/p")).toBe("new");
121
+ expect(reg.consume("/p")).toBeNull();
122
+ });
123
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Unit tests for pending-resume-intent-registry.
3
+ *
4
+ * Uses an injectable `now()` to simulate the passage of time without
5
+ * fake-timer infrastructure — keeps the tests synchronous and free of
6
+ * implicit microtask ordering.
7
+ *
8
+ * See changes: preserve-session-order-on-reboot,
9
+ * differentiate-resume-intent-by-trigger.
10
+ */
11
+ import { describe, it, expect } from "vitest";
12
+ import {
13
+ createPendingResumeIntentRegistry,
14
+ PENDING_RESUME_INTENT_TTL_MS,
15
+ } from "../pending-resume-intent-registry.js";
16
+
17
+ function makeClock(start = 1_000_000) {
18
+ let t = start;
19
+ return {
20
+ now: () => t,
21
+ advance: (ms: number) => { t += ms; },
22
+ };
23
+ }
24
+
25
+ describe("pending-resume-intent-registry", () => {
26
+ it("record(\"front\") then consume returns \"front\"", () => {
27
+ const clock = makeClock();
28
+ const r = createPendingResumeIntentRegistry({ now: clock.now });
29
+ r.record("a", "front");
30
+ expect(r.consume("a")).toBe("front");
31
+ });
32
+
33
+ it("record(\"keep\") then consume returns \"keep\"", () => {
34
+ const clock = makeClock();
35
+ const r = createPendingResumeIntentRegistry({ now: clock.now });
36
+ r.record("a", "keep");
37
+ expect(r.consume("a")).toBe("keep");
38
+ });
39
+
40
+ it("consume clears the entry (second consume returns null)", () => {
41
+ const clock = makeClock();
42
+ const r = createPendingResumeIntentRegistry({ now: clock.now });
43
+ r.record("a", "front");
44
+ r.consume("a");
45
+ expect(r.consume("a")).toBeNull();
46
+ });
47
+
48
+ it("consume on an unknown id returns null (no error)", () => {
49
+ const r = createPendingResumeIntentRegistry();
50
+ expect(r.consume("unknown")).toBeNull();
51
+ });
52
+
53
+ it("record is idempotent — same id stored once", () => {
54
+ const clock = makeClock();
55
+ const r = createPendingResumeIntentRegistry({ now: clock.now });
56
+ r.record("a", "front");
57
+ r.record("a", "front");
58
+ r.record("a", "front");
59
+ expect(r.size()).toBe(1);
60
+ expect(r.consume("a")).toBe("front");
61
+ expect(r.consume("a")).toBeNull();
62
+ });
63
+
64
+ it("re-record overwrites the prior intent (last-write-wins: front → keep)", () => {
65
+ const r = createPendingResumeIntentRegistry();
66
+ r.record("a", "front");
67
+ r.record("a", "keep");
68
+ expect(r.consume("a")).toBe("keep");
69
+ });
70
+
71
+ it("re-record overwrites the prior intent (last-write-wins: keep → front)", () => {
72
+ const r = createPendingResumeIntentRegistry();
73
+ r.record("a", "keep");
74
+ r.record("a", "front");
75
+ expect(r.consume("a")).toBe("front");
76
+ });
77
+
78
+ it("re-record refreshes the timestamp (resists expiry)", () => {
79
+ const clock = makeClock();
80
+ const r = createPendingResumeIntentRegistry({ now: clock.now, ttlMs: 100 });
81
+ r.record("a", "front");
82
+ clock.advance(80);
83
+ r.record("a", "front"); // refresh — would expire at t=180 from this point
84
+ clock.advance(80); // total 160ms from first record, but only 80 since refresh
85
+ expect(r.consume("a")).toBe("front");
86
+ });
87
+
88
+ it("consume returns null after TTL even without explicit consume", () => {
89
+ const clock = makeClock();
90
+ const r = createPendingResumeIntentRegistry({ now: clock.now, ttlMs: 100 });
91
+ r.record("a", "front");
92
+ clock.advance(101);
93
+ expect(r.consume("a")).toBeNull();
94
+ });
95
+
96
+ it("expired entry is dropped from storage", () => {
97
+ const clock = makeClock();
98
+ const r = createPendingResumeIntentRegistry({ now: clock.now, ttlMs: 100 });
99
+ r.record("a", "front");
100
+ clock.advance(101);
101
+ r.consume("a"); // returns null, drops the stale entry
102
+ expect(r.size()).toBe(0);
103
+ });
104
+
105
+ it("size() prunes stale entries lazily", () => {
106
+ const clock = makeClock();
107
+ const r = createPendingResumeIntentRegistry({ now: clock.now, ttlMs: 100 });
108
+ r.record("a", "front");
109
+ r.record("b", "keep");
110
+ clock.advance(50);
111
+ r.record("c", "front");
112
+ expect(r.size()).toBe(3);
113
+ clock.advance(60); // a + b stale (110ms total), c still fresh (60ms)
114
+ expect(r.size()).toBe(1);
115
+ });
116
+
117
+ it("multiple ids carry independent intents", () => {
118
+ const r = createPendingResumeIntentRegistry();
119
+ r.record("a", "front");
120
+ r.record("b", "keep");
121
+ r.record("c", "front");
122
+ expect(r.consume("b")).toBe("keep");
123
+ expect(r.consume("a")).toBe("front");
124
+ expect(r.consume("c")).toBe("front");
125
+ expect(r.size()).toBe(0);
126
+ });
127
+
128
+ it("empty/falsy sessionId is rejected on record and on consume", () => {
129
+ const r = createPendingResumeIntentRegistry();
130
+ r.record("", "front");
131
+ expect(r.size()).toBe(0);
132
+ expect(r.consume("")).toBeNull();
133
+ });
134
+
135
+ it("default TTL is 60s (sanity check exported constant)", () => {
136
+ expect(PENDING_RESUME_INTENT_TTL_MS).toBe(60_000);
137
+ });
138
+ });
@@ -5,20 +5,23 @@ import fs from "node:fs";
5
5
  import { PiCoreChecker, CORE_PACKAGE_NAMES, _internal } from "../pi-core-checker.js";
6
6
 
7
7
  describe("PiCoreChecker._internal.looksLikePiEcosystem", () => {
8
- it("matches known core packages", () => {
8
+ it("matches every known core package", () => {
9
9
  for (const name of CORE_PACKAGE_NAMES) {
10
10
  expect(_internal.looksLikePiEcosystem(name)).toBe(true);
11
11
  }
12
12
  });
13
13
 
14
- it("matches bare pi- packages", () => {
15
- expect(_internal.looksLikePiEcosystem("pi-web-access")).toBe(true);
16
- expect(_internal.looksLikePiEcosystem("pi-agent-browser")).toBe(true);
14
+ it("rejects pi-* prefixed packages that are NOT in the whitelist (no heuristic)", () => {
15
+ // These were previously matched by the dropped pi-* heuristic.
16
+ expect(_internal.looksLikePiEcosystem("pi-web-access")).toBe(false);
17
+ expect(_internal.looksLikePiEcosystem("pi-agent-browser")).toBe(false);
18
+ expect(_internal.looksLikePiEcosystem("pi-flows")).toBe(false);
19
+ expect(_internal.looksLikePiEcosystem("pi-anthropic-messages")).toBe(false);
17
20
  });
18
21
 
19
- it("matches scoped pi- packages", () => {
20
- expect(_internal.looksLikePiEcosystem("@tintinweb/pi-subagents")).toBe(true);
21
- expect(_internal.looksLikePiEcosystem("@benvargas/pi-claude-code-use")).toBe(true);
22
+ it("rejects scoped pi-* packages that are NOT in the whitelist", () => {
23
+ expect(_internal.looksLikePiEcosystem("@tintinweb/pi-subagents")).toBe(false);
24
+ expect(_internal.looksLikePiEcosystem("@benvargas/pi-claude-code-use")).toBe(false);
22
25
  });
23
26
 
24
27
  it("rejects non-pi packages", () => {
@@ -42,19 +45,20 @@ describe("PiCoreChecker.getStatus", () => {
42
45
  fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version }));
43
46
  }
44
47
 
45
- it("discovers global pi packages via npm list", async () => {
48
+ it("discovers global pi packages via npm list (whitelist only)", async () => {
46
49
  const checker = new PiCoreChecker({
47
50
  npmList: async () =>
48
51
  JSON.stringify({
49
52
  dependencies: {
50
53
  "@mariozechner/pi-coding-agent": { version: "0.67.1" },
51
- "pi-web-access": { version: "0.10.6" },
52
- react: { version: "19.0.0" }, // must be ignored
54
+ "@blackbelt-technology/pi-agent-dashboard": { version: "0.4.0" },
55
+ "pi-web-access": { version: "0.10.6" }, // NOT in whitelist → ignored
56
+ react: { version: "19.0.0" }, // ignored
53
57
  },
54
58
  }),
55
59
  fetchLatest: async (name) => {
56
60
  if (name === "@mariozechner/pi-coding-agent") return "0.67.6";
57
- if (name === "pi-web-access") return "0.10.6";
61
+ if (name === "@blackbelt-technology/pi-agent-dashboard") return "0.4.1";
58
62
  return null;
59
63
  },
60
64
  managedDir: tmpManagedDir,
@@ -63,6 +67,8 @@ describe("PiCoreChecker.getStatus", () => {
63
67
  const status = await checker.getStatus();
64
68
 
65
69
  expect(status.packages.length).toBe(2);
70
+ expect(status.packages.find((p) => p.name === "pi-web-access")).toBeUndefined();
71
+
66
72
  const pi = status.packages.find((p) => p.name === "@mariozechner/pi-coding-agent")!;
67
73
  expect(pi.displayName).toBe("pi (core agent)");
68
74
  expect(pi.currentVersion).toBe("0.67.1");
@@ -70,12 +76,30 @@ describe("PiCoreChecker.getStatus", () => {
70
76
  expect(pi.updateAvailable).toBe(true);
71
77
  expect(pi.installSource).toBe("global");
72
78
 
73
- const web = status.packages.find((p) => p.name === "pi-web-access")!;
74
- expect(web.displayName).toBe("pi-web-access");
75
- expect(web.updateAvailable).toBe(false);
76
- expect(web.installSource).toBe("global");
79
+ const dash = status.packages.find((p) => p.name === "@blackbelt-technology/pi-agent-dashboard")!;
80
+ expect(dash.displayName).toBe("pi-dashboard");
81
+ expect(dash.updateAvailable).toBe(true);
77
82
 
78
- expect(status.updatesAvailable).toBe(1);
83
+ expect(status.updatesAvailable).toBe(2);
84
+ });
85
+
86
+ it("recommended-extension packages installed globally are NOT in core discovery", async () => {
87
+ // Regression test for the dropped pi-* heuristic. These rows must
88
+ // surface only via /api/packages/installed.
89
+ const checker = new PiCoreChecker({
90
+ npmList: async () =>
91
+ JSON.stringify({
92
+ dependencies: {
93
+ "pi-agent-browser": { version: "0.1.0" },
94
+ "pi-web-access": { version: "0.10.6" },
95
+ "@tintinweb/pi-subagents": { version: "0.6.1" },
96
+ },
97
+ }),
98
+ fetchLatest: async () => null,
99
+ managedDir: path.join(tmpManagedDir, "nope"),
100
+ });
101
+ const status = await checker.getStatus();
102
+ expect(status.packages).toEqual([]);
79
103
  });
80
104
 
81
105
  it("discovers managed packages and prefers them over global duplicates", async () => {
@@ -98,6 +122,20 @@ describe("PiCoreChecker.getStatus", () => {
98
122
  expect(status.packages[0].installSource).toBe("managed");
99
123
  });
100
124
 
125
+ it("managed scan ignores non-whitelisted packages", async () => {
126
+ // Even if a pi-* prefixed package sits in ~/.pi-dashboard/node_modules,
127
+ // it must not appear in core discovery.
128
+ writeManagedPackage(tmpManagedDir, "pi-web-access", "0.10.6");
129
+
130
+ const checker = new PiCoreChecker({
131
+ npmList: async () => JSON.stringify({ dependencies: {} }),
132
+ fetchLatest: async () => null,
133
+ managedDir: tmpManagedDir,
134
+ });
135
+ const status = await checker.getStatus();
136
+ expect(status.packages).toEqual([]);
137
+ });
138
+
101
139
  it("returns empty list when managed dir missing and npm list fails", async () => {
102
140
  const checker = new PiCoreChecker({
103
141
  npmList: async () => {
@@ -116,16 +154,18 @@ describe("PiCoreChecker.getStatus", () => {
116
154
  npmList: async () => {
117
155
  const err = new Error("npm warn") as Error & { stdout: string };
118
156
  err.stdout = JSON.stringify({
119
- dependencies: { "pi-web-access": { version: "0.10.6" } },
157
+ dependencies: {
158
+ "@mariozechner/pi-coding-agent": { version: "0.67.1" },
159
+ },
120
160
  });
121
161
  throw err;
122
162
  },
123
- fetchLatest: async () => "0.10.6",
163
+ fetchLatest: async () => "0.67.6",
124
164
  managedDir: path.join(tmpManagedDir, "nope"),
125
165
  });
126
166
  const status = await checker.getStatus();
127
167
  expect(status.packages.length).toBe(1);
128
- expect(status.packages[0].name).toBe("pi-web-access");
168
+ expect(status.packages[0].name).toBe("@mariozechner/pi-coding-agent");
129
169
  });
130
170
 
131
171
  it("caches results within 5 minutes", async () => {
@@ -133,9 +173,11 @@ describe("PiCoreChecker.getStatus", () => {
133
173
  const checker = new PiCoreChecker({
134
174
  npmList: async () => {
135
175
  calls++;
136
- return JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } });
176
+ return JSON.stringify({
177
+ dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
178
+ });
137
179
  },
138
- fetchLatest: async () => "0.10.6",
180
+ fetchLatest: async () => "0.67.6",
139
181
  managedDir: path.join(tmpManagedDir, "nope"),
140
182
  });
141
183
  await checker.getStatus();
@@ -148,9 +190,11 @@ describe("PiCoreChecker.getStatus", () => {
148
190
  const checker = new PiCoreChecker({
149
191
  npmList: async () => {
150
192
  calls++;
151
- return JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } });
193
+ return JSON.stringify({
194
+ dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
195
+ });
152
196
  },
153
- fetchLatest: async () => "0.10.6",
197
+ fetchLatest: async () => "0.67.6",
154
198
  managedDir: path.join(tmpManagedDir, "nope"),
155
199
  });
156
200
  await checker.getStatus();
@@ -161,7 +205,9 @@ describe("PiCoreChecker.getStatus", () => {
161
205
  it("treats fetch failure as latestVersion=null, updateAvailable=false", async () => {
162
206
  const checker = new PiCoreChecker({
163
207
  npmList: async () =>
164
- JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } }),
208
+ JSON.stringify({
209
+ dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
210
+ }),
165
211
  fetchLatest: async () => {
166
212
  throw new Error("network down");
167
213
  },
@@ -173,14 +219,13 @@ describe("PiCoreChecker.getStatus", () => {
173
219
  expect(status.packages[0].updateAvailable).toBe(false);
174
220
  });
175
221
 
176
- it("sorts known core packages first", async () => {
222
+ it("sorts known core packages in CORE_PACKAGE_NAMES order", async () => {
177
223
  const checker = new PiCoreChecker({
178
224
  npmList: async () =>
179
225
  JSON.stringify({
180
226
  dependencies: {
181
- "pi-web-access": { version: "0.10.6" },
227
+ "@blackbelt-technology/pi-agent-dashboard": { version: "0.4.0" },
182
228
  "@mariozechner/pi-coding-agent": { version: "0.67.1" },
183
- "pi-agent-browser": { version: "0.1.0" },
184
229
  },
185
230
  }),
186
231
  fetchLatest: async () => null,
@@ -188,8 +233,6 @@ describe("PiCoreChecker.getStatus", () => {
188
233
  });
189
234
  const status = await checker.getStatus();
190
235
  expect(status.packages[0].name).toBe("@mariozechner/pi-coding-agent");
191
- // remaining are alphabetical
192
- expect(status.packages[1].name).toBe("pi-agent-browser");
193
- expect(status.packages[2].name).toBe("pi-web-access");
236
+ expect(status.packages[1].name).toBe("@blackbelt-technology/pi-agent-dashboard");
194
237
  });
195
238
  });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Verifies the consume-on-register flow: when `applyAttachProposal` runs with
3
+ * a pending intent's name, it (a) updates the session, (b) sends rename to
4
+ * the bridge if `attachRenameTarget` returned a name, and (c) broadcasts
5
+ * `session_updated`. Mirrors what `pi-gateway.onSessionRegistered` does in
6
+ * `event-wiring.ts`.
7
+ *
8
+ * See change: add-folder-task-checker-and-spawn-attach.
9
+ */
10
+ import { describe, it, expect, vi } from "vitest";
11
+ import { applyAttachProposal } from "../browser-handlers/session-meta-handler.js";
12
+ import { createPendingAttachRegistry } from "../pending-attach-registry.js";
13
+
14
+ function makeCtx(initial?: { name?: string }) {
15
+ const session = { id: "s99", cwd: "/p", name: initial?.name ?? "", attachedProposal: null };
16
+ const updates: any[] = [];
17
+ const broadcasts: any[] = [];
18
+ const piSent: any[] = [];
19
+ const ctx = {
20
+ sessionManager: {
21
+ get: () => session,
22
+ update: (id: string, u: any) => {
23
+ updates.push({ id, u });
24
+ Object.assign(session, u);
25
+ },
26
+ },
27
+ piGateway: {
28
+ sendToSession: (id: string, msg: any) => { piSent.push({ id, msg }); return true; },
29
+ },
30
+ broadcast: (msg: any) => { broadcasts.push(msg); },
31
+ } as any;
32
+ return { ctx, updates, broadcasts, piSent, session };
33
+ }
34
+
35
+ describe("consume-on-register flow", () => {
36
+ it("end-to-end: enqueue → consume → applyAttachProposal updates + broadcasts", () => {
37
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn: () => {} });
38
+ reg.enqueue("/p", "add-foo");
39
+
40
+ // Simulate session_register arriving for cwd /p with sessionId s99.
41
+ const consumed = reg.consume("/p");
42
+ expect(consumed).toBe("add-foo");
43
+
44
+ const { ctx, updates, broadcasts, piSent } = makeCtx({ name: "" });
45
+ applyAttachProposal("s99", consumed!, ctx);
46
+
47
+ expect(updates).toHaveLength(1);
48
+ expect(updates[0]!.id).toBe("s99");
49
+ expect(updates[0]!.u.attachedProposal).toBe("add-foo");
50
+ // Empty/witness name → auto-rename fires.
51
+ expect(updates[0]!.u.name).toBe("add-foo");
52
+ expect(piSent).toHaveLength(1);
53
+ expect(piSent[0]!.msg).toMatchObject({ type: "rename_session", sessionId: "s99", name: "add-foo" });
54
+ expect(broadcasts).toHaveLength(1);
55
+ expect(broadcasts[0]!).toMatchObject({
56
+ type: "session_updated",
57
+ sessionId: "s99",
58
+ updates: { attachedProposal: "add-foo", name: "add-foo" },
59
+ });
60
+ });
61
+
62
+ it("no intent in queue → no-op (regression: register without intent must not attach)", () => {
63
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn: () => {} });
64
+ expect(reg.consume("/p")).toBeNull();
65
+ // Caller short-circuits and never calls applyAttachProposal — verified by
66
+ // event-wiring.ts conditional. This test just pins the contract that
67
+ // consume returns null for an empty queue, which is what the wiring relies
68
+ // on to skip the call.
69
+ });
70
+
71
+ it("cwd normalization between enqueue and consume", () => {
72
+ const reg = createPendingAttachRegistry({
73
+ normalize: (s) => s.replace(/[/\\]+$/, ""),
74
+ warn: () => {},
75
+ });
76
+ reg.enqueue("/proj/", "add-bar");
77
+ // Bridge sends back cwd without trailing slash.
78
+ expect(reg.consume("/proj")).toBe("add-bar");
79
+ });
80
+
81
+ it("only one intent consumed per register call", () => {
82
+ const reg = createPendingAttachRegistry({ normalize: (s) => s, warn: () => {} });
83
+ reg.enqueue("/p", "a");
84
+ reg.enqueue("/p", "b");
85
+ expect(reg.consume("/p")).toBe("a");
86
+ expect(reg.size("/p")).toBe(1);
87
+ expect(reg.consume("/p")).toBe("b");
88
+ });
89
+
90
+ it("session with explicit user-set name keeps it (idempotent rename short-circuits)", () => {
91
+ const { ctx, updates, piSent } = makeCtx({ name: "my-custom-name" });
92
+ applyAttachProposal("s99", "add-foo", ctx);
93
+ expect(updates[0]!.u.attachedProposal).toBe("add-foo");
94
+ // attachRenameTarget returns undefined when name is non-empty/non-witness.
95
+ expect("name" in updates[0]!.u).toBe(false);
96
+ expect(piSent).toHaveLength(0);
97
+ });
98
+
99
+ it("calling applyAttachProposal twice with same name is idempotent", () => {
100
+ const { ctx, updates, piSent } = makeCtx({ name: "" });
101
+ applyAttachProposal("s99", "add-foo", ctx);
102
+ applyAttachProposal("s99", "add-foo", ctx);
103
+ // First call sets name="add-foo"; second call sees name===attachedProposal
104
+ // (witness equality holds) and the rename helper returns the same target —
105
+ // safe to re-emit, but the session state is unchanged.
106
+ expect(updates).toHaveLength(2);
107
+ // Both broadcasts include attachedProposal:"add-foo"; second is a no-op
108
+ // from a state perspective.
109
+ expect(updates.every((u) => u.u.attachedProposal === "add-foo")).toBe(true);
110
+ expect(piSent.length).toBeGreaterThanOrEqual(1);
111
+ });
112
+ });