@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.
- package/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- 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
|
|
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("
|
|
15
|
-
|
|
16
|
-
expect(_internal.looksLikePiEcosystem("pi-
|
|
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("
|
|
20
|
-
expect(_internal.looksLikePiEcosystem("@tintinweb/pi-subagents")).toBe(
|
|
21
|
-
expect(_internal.looksLikePiEcosystem("@benvargas/pi-claude-code-use")).toBe(
|
|
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-
|
|
52
|
-
|
|
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-
|
|
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
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
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(
|
|
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: {
|
|
157
|
+
dependencies: {
|
|
158
|
+
"@mariozechner/pi-coding-agent": { version: "0.67.1" },
|
|
159
|
+
},
|
|
120
160
|
});
|
|
121
161
|
throw err;
|
|
122
162
|
},
|
|
123
|
-
fetchLatest: async () => "0.
|
|
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-
|
|
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({
|
|
176
|
+
return JSON.stringify({
|
|
177
|
+
dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
|
|
178
|
+
});
|
|
137
179
|
},
|
|
138
|
-
fetchLatest: async () => "0.
|
|
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({
|
|
193
|
+
return JSON.stringify({
|
|
194
|
+
dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
|
|
195
|
+
});
|
|
152
196
|
},
|
|
153
|
-
fetchLatest: async () => "0.
|
|
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({
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
+
});
|