@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +114 -9
- package/README.md +218 -97
- package/docs/architecture.md +107 -7
- package/package.json +9 -4
- package/packages/extension/package.json +1 -1
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +38 -4
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/server/package.json +2 -1
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
- package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tunnel.test.ts +91 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/browse.ts +100 -6
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/editor-manager.ts +20 -1
- package/packages/server/src/editor-pid-registry.ts +198 -0
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/headless-pid-registry.ts +9 -0
- package/packages/server/src/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +31 -0
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +166 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/process-manager.ts +1 -1
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +83 -1
- package/packages/server/src/routes/pi-core-routes.ts +117 -0
- package/packages/server/src/routes/provider-auth-routes.ts +4 -2
- package/packages/server/src/routes/provider-routes.ts +12 -2
- package/packages/server/src/routes/recommended-routes.ts +227 -0
- package/packages/server/src/routes/system-routes.ts +10 -1
- package/packages/server/src/server.ts +151 -15
- package/packages/server/src/terminal-manager.ts +4 -0
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +132 -8
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +3 -3
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/browser-protocol.ts +23 -1
- package/packages/shared/src/openspec-poller.ts +8 -3
- package/packages/shared/src/recommended-extensions.ts +180 -0
- package/packages/shared/src/rest-api.ts +71 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -0
- package/packages/shared/src/types.ts +7 -0
|
@@ -27,6 +27,12 @@ const SPAWN_TIMEOUT_MS = 30_000;
|
|
|
27
27
|
let activeProcess: ChildProcess | null = null;
|
|
28
28
|
let activeTunnelUrl: string | null = null;
|
|
29
29
|
let zrokAvailable: boolean | null = null;
|
|
30
|
+
// Serialization: any concurrent createTunnel() call while one is already in
|
|
31
|
+
// flight returns the same promise instead of spawning a second zrok process.
|
|
32
|
+
// Without this, a UI double-click or a race between startup auto-connect and
|
|
33
|
+
// an explicit `/api/tunnel-connect` created two parallel reservations and
|
|
34
|
+
// two running `zrok share` processes for the same port.
|
|
35
|
+
let pendingCreate: Promise<string | null> | null = null;
|
|
30
36
|
|
|
31
37
|
// ── Binary Detection ────────────────────────────────────────────────
|
|
32
38
|
|
|
@@ -160,6 +166,72 @@ function saveReservedToken(token: string): void {
|
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Release a reserved share via `zrok release <token>`. Best-effort, non-throwing.
|
|
171
|
+
* Returns true if the release command exited cleanly, false otherwise. Callers
|
|
172
|
+
* should invoke this whenever abandoning a reserved token so the zrok edge
|
|
173
|
+
* doesn't keep an orphaned reservation record (which is what causes stale
|
|
174
|
+
* URLs like `tgbdzzvlar6b.share.zrok.io` to persist after the agent dies).
|
|
175
|
+
*/
|
|
176
|
+
export function releaseShare(token: string): boolean {
|
|
177
|
+
if (!token) return false;
|
|
178
|
+
try {
|
|
179
|
+
execSync(`zrok release ${token}`, {
|
|
180
|
+
timeout: 10_000,
|
|
181
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Scan `ps` for orphan `zrok share` processes that point at the given port
|
|
191
|
+
* via `--override-endpoint http://localhost:<port>` and SIGTERM them.
|
|
192
|
+
*
|
|
193
|
+
* This complements `cleanupStaleZrok` (which only knows about the single PID
|
|
194
|
+
* in our pid-file): when the retry logic in `createTunnel` leaks processes
|
|
195
|
+
* across failures, or when a previous server instance crashed, the pid-file
|
|
196
|
+
* loses track of them. On startup we scavenge them directly from the process
|
|
197
|
+
* table so a fresh tunnel doesn't compete with orphans.
|
|
198
|
+
*
|
|
199
|
+
* Returns the list of PIDs we killed.
|
|
200
|
+
*/
|
|
201
|
+
export function scavengeOrphanZrokProcesses(port: number): number[] {
|
|
202
|
+
const killed: number[] = [];
|
|
203
|
+
let output = "";
|
|
204
|
+
try {
|
|
205
|
+
output = execSync("ps -ax -o pid=,args=", {
|
|
206
|
+
encoding: "utf-8",
|
|
207
|
+
timeout: 5_000,
|
|
208
|
+
}).toString();
|
|
209
|
+
} catch {
|
|
210
|
+
return killed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const endpointMarker = `--override-endpoint http://localhost:${port}`;
|
|
214
|
+
for (const line of output.split(/\r?\n/)) {
|
|
215
|
+
const trimmed = line.trim();
|
|
216
|
+
if (!trimmed) continue;
|
|
217
|
+
if (!trimmed.includes("zrok share")) continue;
|
|
218
|
+
if (!trimmed.includes(endpointMarker)) continue;
|
|
219
|
+
const m = trimmed.match(/^(\d+)\s+/);
|
|
220
|
+
if (!m) continue;
|
|
221
|
+
const pid = parseInt(m[1], 10);
|
|
222
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
223
|
+
if (pid === process.pid) continue; // never kill ourselves
|
|
224
|
+
try {
|
|
225
|
+
process.kill(pid, "SIGTERM");
|
|
226
|
+
killed.push(pid);
|
|
227
|
+
console.log(`Scavenged orphan zrok process (PID ${pid})`);
|
|
228
|
+
} catch {
|
|
229
|
+
// Process may have exited between ps and kill — ignore
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return killed;
|
|
233
|
+
}
|
|
234
|
+
|
|
163
235
|
/**
|
|
164
236
|
* Create a reserved share via `zrok reserve public`.
|
|
165
237
|
* Returns the share token or null on failure.
|
|
@@ -195,7 +267,29 @@ function reserveShare(port: number): Promise<string | null> {
|
|
|
195
267
|
* On subsequent runs, reuses the reserved token.
|
|
196
268
|
* Returns URL or null on failure.
|
|
197
269
|
*/
|
|
198
|
-
export function createTunnel(
|
|
270
|
+
export function createTunnel(
|
|
271
|
+
port: number,
|
|
272
|
+
reservedToken?: string,
|
|
273
|
+
retriesLeft: number = 1,
|
|
274
|
+
): Promise<string | null> {
|
|
275
|
+
// Fast path: another caller is already creating a tunnel — join that promise.
|
|
276
|
+
if (pendingCreate) return pendingCreate;
|
|
277
|
+
// Fast path: tunnel already up — return its URL without spawning.
|
|
278
|
+
if (activeTunnelUrl) return Promise.resolve(activeTunnelUrl);
|
|
279
|
+
|
|
280
|
+
const promise = _createTunnelInner(port, reservedToken, retriesLeft);
|
|
281
|
+
pendingCreate = promise;
|
|
282
|
+
promise.finally(() => {
|
|
283
|
+
if (pendingCreate === promise) pendingCreate = null;
|
|
284
|
+
});
|
|
285
|
+
return promise;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function _createTunnelInner(
|
|
289
|
+
port: number,
|
|
290
|
+
reservedToken?: string,
|
|
291
|
+
retriesLeft: number = 1,
|
|
292
|
+
): Promise<string | null> {
|
|
199
293
|
return new Promise(async (resolve) => {
|
|
200
294
|
if (!detectZrokBinary()) {
|
|
201
295
|
resolve(null);
|
|
@@ -209,7 +303,11 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
|
|
|
209
303
|
return;
|
|
210
304
|
}
|
|
211
305
|
|
|
212
|
-
//
|
|
306
|
+
// Track whether this call reserved the token itself (so we know to
|
|
307
|
+
// release it if we subsequently time out or fail — the caller-provided
|
|
308
|
+
// `reservedToken` is owned by the caller / config and must not be released
|
|
309
|
+
// on transient timeouts).
|
|
310
|
+
const callerProvidedToken = !!reservedToken;
|
|
213
311
|
let token = reservedToken;
|
|
214
312
|
if (!token) {
|
|
215
313
|
token = await reserveShare(port) ?? undefined;
|
|
@@ -228,12 +326,19 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
|
|
|
228
326
|
detached: false,
|
|
229
327
|
});
|
|
230
328
|
|
|
231
|
-
// Timeout: kill process if URL not parsed in time
|
|
329
|
+
// Timeout: kill process if URL not parsed in time. Escalate SIGTERM
|
|
330
|
+
// → SIGKILL after a grace period so a wedged zrok doesn't keep a stale
|
|
331
|
+
// reservation attached after we've moved on. If we reserved the token
|
|
332
|
+
// just-in-time within this call, release it on the zrok edge too so we
|
|
333
|
+
// don't leak a dead reservation (root cause of stale URLs like
|
|
334
|
+
// `tgbdzzvlar6b.share.zrok.io`).
|
|
232
335
|
const timeout = setTimeout(() => {
|
|
233
336
|
if (!resolved) {
|
|
234
337
|
resolved = true;
|
|
235
338
|
console.warn("zrok tunnel creation timed out (30s)");
|
|
236
339
|
try { child.kill("SIGTERM"); } catch {}
|
|
340
|
+
setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 2_000);
|
|
341
|
+
if (token && !callerProvidedToken) releaseShare(token);
|
|
237
342
|
removeZrokPid();
|
|
238
343
|
resolve(null);
|
|
239
344
|
}
|
|
@@ -270,10 +375,20 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
|
|
|
270
375
|
if (!resolved) {
|
|
271
376
|
resolved = true;
|
|
272
377
|
clearTimeout(timeout);
|
|
273
|
-
// If reserved share failed, token may be expired
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
378
|
+
// If reserved share failed, token may be expired or already attached
|
|
379
|
+
// to an orphan process. Release it on the zrok edge before retrying so
|
|
380
|
+
// we don't leak dead reservations (which is what produced stale URLs
|
|
381
|
+
// like `tgbdzzvlar6b.share.zrok.io` pointing at nothing).
|
|
382
|
+
if (token && retriesLeft > 0) {
|
|
383
|
+
console.warn(`Reserved share failed (code ${code}), releasing token ${token} and creating new reservation...`);
|
|
384
|
+
releaseShare(token);
|
|
385
|
+
// Bypass the mutex wrapper so we don't self-deadlock: call the inner
|
|
386
|
+
// implementation directly for the retry attempt.
|
|
387
|
+
resolve(_createTunnelInner(port, undefined, retriesLeft - 1));
|
|
388
|
+
} else if (token) {
|
|
389
|
+
console.warn(`Reserved share failed (code ${code}) and retry budget exhausted; releasing token ${token}`);
|
|
390
|
+
releaseShare(token);
|
|
391
|
+
resolve(null);
|
|
277
392
|
} else {
|
|
278
393
|
console.warn(`zrok process exited before producing URL (code ${code})`);
|
|
279
394
|
resolve(null);
|
|
@@ -291,8 +406,11 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
|
|
|
291
406
|
|
|
292
407
|
/**
|
|
293
408
|
* Stop the active tunnel. Kills the subprocess and removes PID file.
|
|
409
|
+
* Also sweeps any orphan zrok processes bound to the given port so restart
|
|
410
|
+
* paths (which call `deleteTunnel` then spawn a new server) don't leave
|
|
411
|
+
* dead reservations attached to the zrok edge.
|
|
294
412
|
*/
|
|
295
|
-
export async function deleteTunnel(): Promise<void> {
|
|
413
|
+
export async function deleteTunnel(port?: number): Promise<void> {
|
|
296
414
|
const child = activeProcess;
|
|
297
415
|
activeProcess = null;
|
|
298
416
|
activeTunnelUrl = null;
|
|
@@ -305,6 +423,12 @@ export async function deleteTunnel(): Promise<void> {
|
|
|
305
423
|
}
|
|
306
424
|
}
|
|
307
425
|
removeZrokPid();
|
|
426
|
+
|
|
427
|
+
// Belt-and-braces: sweep any orphan zrok processes that escaped pid-file
|
|
428
|
+
// tracking (e.g. from a previous crash or a failed retry chain).
|
|
429
|
+
if (typeof port === "number") {
|
|
430
|
+
try { scavengeOrphanZrokProcesses(port); } catch { /* best-effort */ }
|
|
431
|
+
}
|
|
308
432
|
}
|
|
309
433
|
|
|
310
434
|
/**
|
|
@@ -27,7 +27,7 @@ describe("loadConfig", () => {
|
|
|
27
27
|
expect(config.port).toBe(8000);
|
|
28
28
|
expect(config.piPort).toBe(9999);
|
|
29
29
|
expect(config.autoStart).toBe(true);
|
|
30
|
-
expect(config.autoShutdown).toBe(
|
|
30
|
+
expect(config.autoShutdown).toBe(false);
|
|
31
31
|
expect(config.lastServer).toBeUndefined();
|
|
32
32
|
expect(config.shutdownIdleSeconds).toBe(300);
|
|
33
33
|
});
|
|
@@ -52,7 +52,7 @@ describe("loadConfig", () => {
|
|
|
52
52
|
expect(config.port).toBe(3000);
|
|
53
53
|
expect(config.piPort).toBe(9999);
|
|
54
54
|
expect(config.autoStart).toBe(true);
|
|
55
|
-
expect(config.autoShutdown).toBe(
|
|
55
|
+
expect(config.autoShutdown).toBe(false);
|
|
56
56
|
expect(config.shutdownIdleSeconds).toBe(300);
|
|
57
57
|
});
|
|
58
58
|
|
|
@@ -333,7 +333,7 @@ describe("ensureConfig", () => {
|
|
|
333
333
|
expect(content.port).toBe(8000);
|
|
334
334
|
expect(content.piPort).toBe(9999);
|
|
335
335
|
expect(content.autoStart).toBe(true);
|
|
336
|
-
expect(content.autoShutdown).toBe(
|
|
336
|
+
expect(content.autoShutdown).toBe(false);
|
|
337
337
|
expect(content.shutdownIdleSeconds).toBe(300);
|
|
338
338
|
expect(content.devBuildOnReload).toBe(false);
|
|
339
339
|
expect(content.electronMode).toBeUndefined();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildOpenSpecData } from "../openspec-poller.js";
|
|
3
|
+
|
|
4
|
+
describe("buildOpenSpecData - isComplete pass-through", () => {
|
|
5
|
+
const listResult = {
|
|
6
|
+
changes: [
|
|
7
|
+
{ name: "a", status: "in-progress", completedTasks: 1, totalTasks: 3 },
|
|
8
|
+
{ name: "b", status: "in-progress", completedTasks: 0, totalTasks: 5 },
|
|
9
|
+
{ name: "c", status: "complete", completedTasks: 2, totalTasks: 2 },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it("passes isComplete=true through", () => {
|
|
14
|
+
const statusResults = new Map<string, any>([
|
|
15
|
+
["a", { artifacts: [{ id: "proposal", status: "done" }], isComplete: true }],
|
|
16
|
+
["b", null],
|
|
17
|
+
["c", null],
|
|
18
|
+
]);
|
|
19
|
+
const data = buildOpenSpecData(listResult, statusResults);
|
|
20
|
+
const a = data.changes.find((c) => c.name === "a")!;
|
|
21
|
+
expect(a.isComplete).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("passes isComplete=false through", () => {
|
|
25
|
+
const statusResults = new Map<string, any>([
|
|
26
|
+
["a", { artifacts: [], isComplete: false }],
|
|
27
|
+
["b", null],
|
|
28
|
+
["c", null],
|
|
29
|
+
]);
|
|
30
|
+
const data = buildOpenSpecData(listResult, statusResults);
|
|
31
|
+
expect(data.changes.find((c) => c.name === "a")!.isComplete).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("leaves isComplete undefined when absent from status result", () => {
|
|
35
|
+
const statusResults = new Map<string, any>([
|
|
36
|
+
["a", { artifacts: [] }],
|
|
37
|
+
["b", null],
|
|
38
|
+
["c", null],
|
|
39
|
+
]);
|
|
40
|
+
const data = buildOpenSpecData(listResult, statusResults);
|
|
41
|
+
expect("isComplete" in data.changes.find((c) => c.name === "a")!).toBe(false);
|
|
42
|
+
expect(data.changes.find((c) => c.name === "b")!.isComplete).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
RECOMMENDED_EXTENSIONS,
|
|
4
|
+
getRecommendedExtension,
|
|
5
|
+
getRecommendedByStatus,
|
|
6
|
+
type RecommendedExtension,
|
|
7
|
+
} from "../recommended-extensions.js";
|
|
8
|
+
|
|
9
|
+
describe("RECOMMENDED_EXTENSIONS manifest", () => {
|
|
10
|
+
it("contains exactly the five expected entries", () => {
|
|
11
|
+
const ids = RECOMMENDED_EXTENSIONS.map((e) => e.id).sort();
|
|
12
|
+
expect(ids).toEqual(
|
|
13
|
+
[
|
|
14
|
+
"pi-anthropic-messages",
|
|
15
|
+
"pi-agent-browser",
|
|
16
|
+
"pi-flows",
|
|
17
|
+
"pi-web-access",
|
|
18
|
+
"tintinweb-pi-subagents",
|
|
19
|
+
].sort(),
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("every entry has the required shape", () => {
|
|
24
|
+
for (const entry of RECOMMENDED_EXTENSIONS) {
|
|
25
|
+
expect(typeof entry.id).toBe("string");
|
|
26
|
+
expect(entry.id.length).toBeGreaterThan(0);
|
|
27
|
+
expect(typeof entry.source).toBe("string");
|
|
28
|
+
expect(entry.source.length).toBeGreaterThan(0);
|
|
29
|
+
expect(typeof entry.displayName).toBe("string");
|
|
30
|
+
expect(typeof entry.fallbackDescription).toBe("string");
|
|
31
|
+
expect(entry.fallbackDescription.length).toBeGreaterThan(10);
|
|
32
|
+
expect(["required", "strongly-suggested", "optional"]).toContain(entry.status);
|
|
33
|
+
expect(Array.isArray(entry.unlocks)).toBe(true);
|
|
34
|
+
expect(entry.unlocks.length).toBeGreaterThan(0);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("pi-anthropic-messages is marked required and uses SSH git URL", () => {
|
|
39
|
+
const entry = getRecommendedExtension("pi-anthropic-messages");
|
|
40
|
+
expect(entry).toBeDefined();
|
|
41
|
+
expect(entry?.status).toBe("required");
|
|
42
|
+
expect(entry?.source).toContain("git@github.com:BlackBeltTechnology/pi-anthropic-messages.git");
|
|
43
|
+
expect(entry?.autowired).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("pi-flows uses SSH git URL and registers flow-engine tools", () => {
|
|
47
|
+
const entry = getRecommendedExtension("pi-flows");
|
|
48
|
+
expect(entry).toBeDefined();
|
|
49
|
+
expect(entry?.source).toBe("git@github.com:BlackBeltTechnology/pi-flows.git");
|
|
50
|
+
expect(entry?.toolsRegistered).toContain("subagent");
|
|
51
|
+
expect(entry?.toolsRegistered).toContain("flow_write");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("tintinweb-pi-subagents registers Agent under its canonical capitalization", () => {
|
|
55
|
+
const entry = getRecommendedExtension("tintinweb-pi-subagents");
|
|
56
|
+
expect(entry).toBeDefined();
|
|
57
|
+
expect(entry?.source).toBe("npm:@tintinweb/pi-subagents");
|
|
58
|
+
expect(entry?.toolsRegistered).toContain("Agent");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("npm-sourced entries use the npm: prefix", () => {
|
|
62
|
+
const npmEntries = RECOMMENDED_EXTENSIONS.filter((e) => e.source.startsWith("npm:"));
|
|
63
|
+
expect(npmEntries.map((e) => e.id).sort()).toEqual(
|
|
64
|
+
["pi-agent-browser", "pi-web-access", "tintinweb-pi-subagents"].sort(),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("git-sourced entries use the git@github.com:/.git SSH form", () => {
|
|
69
|
+
const gitEntries = RECOMMENDED_EXTENSIONS.filter((e) =>
|
|
70
|
+
e.source.startsWith("git@github.com:"),
|
|
71
|
+
);
|
|
72
|
+
for (const entry of gitEntries) {
|
|
73
|
+
expect(entry.source).toMatch(/^git@github\.com:[^/]+\/[^/]+\.git$/);
|
|
74
|
+
}
|
|
75
|
+
expect(gitEntries.map((e) => e.id).sort()).toEqual(
|
|
76
|
+
["pi-anthropic-messages", "pi-flows"].sort(),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("getRecommendedExtension", () => {
|
|
82
|
+
it("returns the entry when id matches", () => {
|
|
83
|
+
const e = getRecommendedExtension("pi-web-access");
|
|
84
|
+
expect(e?.displayName).toBe("pi-web-access");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns undefined for unknown ids", () => {
|
|
88
|
+
expect(getRecommendedExtension("does-not-exist")).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("getRecommendedByStatus", () => {
|
|
93
|
+
it("filters by required", () => {
|
|
94
|
+
const required = getRecommendedByStatus("required");
|
|
95
|
+
expect(required.map((e) => e.id)).toEqual(["pi-anthropic-messages"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("filters by strongly-suggested", () => {
|
|
99
|
+
const suggested = getRecommendedByStatus("strongly-suggested");
|
|
100
|
+
expect(suggested.map((e) => e.id).sort()).toEqual(
|
|
101
|
+
["pi-flows", "pi-web-access", "tintinweb-pi-subagents"].sort(),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("filters by optional", () => {
|
|
106
|
+
const optional = getRecommendedByStatus("optional");
|
|
107
|
+
expect(optional.map((e) => e.id)).toEqual(["pi-agent-browser"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("RecommendedExtension type", () => {
|
|
112
|
+
it("accepts a minimal entry", () => {
|
|
113
|
+
const entry: RecommendedExtension = {
|
|
114
|
+
id: "x",
|
|
115
|
+
source: "npm:x",
|
|
116
|
+
displayName: "X",
|
|
117
|
+
fallbackDescription: "A test extension description.",
|
|
118
|
+
status: "optional",
|
|
119
|
+
unlocks: ["something"],
|
|
120
|
+
};
|
|
121
|
+
expect(entry.id).toBe("x");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseSourceKey, sourcesMatch } from "../source-matching.js";
|
|
3
|
+
|
|
4
|
+
describe("parseSourceKey", () => {
|
|
5
|
+
it("parses npm:<name>", () => {
|
|
6
|
+
expect(parseSourceKey("npm:pi-web-access")).toEqual({ kind: "npm", name: "pi-web-access" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("parses npm:<name>@<version>", () => {
|
|
10
|
+
expect(parseSourceKey("npm:pi-web-access@0.10.6")).toEqual({
|
|
11
|
+
kind: "npm",
|
|
12
|
+
name: "pi-web-access",
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("parses scoped npm name without version", () => {
|
|
17
|
+
expect(parseSourceKey("npm:@tintinweb/pi-subagents")).toEqual({
|
|
18
|
+
kind: "npm",
|
|
19
|
+
name: "@tintinweb/pi-subagents",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("parses scoped npm name with version", () => {
|
|
24
|
+
expect(parseSourceKey("npm:@tintinweb/pi-subagents@0.5.2")).toEqual({
|
|
25
|
+
kind: "npm",
|
|
26
|
+
name: "@tintinweb/pi-subagents",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses git SSH sources", () => {
|
|
31
|
+
expect(parseSourceKey("git@github.com:BlackBeltTechnology/pi-flows.git")).toEqual({
|
|
32
|
+
kind: "git",
|
|
33
|
+
host: "github.com",
|
|
34
|
+
owner: "BlackBeltTechnology",
|
|
35
|
+
repo: "pi-flows",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parses git HTTPS sources", () => {
|
|
40
|
+
expect(parseSourceKey("https://github.com/BlackBeltTechnology/pi-flows.git")).toEqual({
|
|
41
|
+
kind: "git",
|
|
42
|
+
host: "github.com",
|
|
43
|
+
owner: "BlackBeltTechnology",
|
|
44
|
+
repo: "pi-flows",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("parses git:<host>/... sources", () => {
|
|
49
|
+
expect(parseSourceKey("git:github.com/BlackBeltTechnology/pi-flows#main")).toEqual({
|
|
50
|
+
kind: "git",
|
|
51
|
+
host: "github.com",
|
|
52
|
+
owner: "BlackBeltTechnology",
|
|
53
|
+
repo: "pi-flows",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns raw for local paths", () => {
|
|
58
|
+
expect(parseSourceKey("../pi-flows")).toEqual({ kind: "raw", source: "../pi-flows" });
|
|
59
|
+
expect(parseSourceKey("/abs/path")).toEqual({ kind: "raw", source: "/abs/path" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("sourcesMatch", () => {
|
|
64
|
+
it("matches npm by name regardless of version", () => {
|
|
65
|
+
expect(sourcesMatch("npm:pi-web-access@0.10.6", "npm:pi-web-access")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("matches scoped npm names", () => {
|
|
69
|
+
expect(
|
|
70
|
+
sourcesMatch("npm:@tintinweb/pi-subagents@0.5.2", "npm:@tintinweb/pi-subagents"),
|
|
71
|
+
).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("matches git SSH vs HTTPS forms", () => {
|
|
75
|
+
expect(
|
|
76
|
+
sourcesMatch(
|
|
77
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
78
|
+
"https://github.com/BlackBeltTechnology/pi-flows.git",
|
|
79
|
+
),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("is case-insensitive for git host/owner/repo", () => {
|
|
84
|
+
expect(
|
|
85
|
+
sourcesMatch(
|
|
86
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
87
|
+
"git@github.com:blackbelttechnology/pi-flows.git",
|
|
88
|
+
),
|
|
89
|
+
).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("distinguishes different repos", () => {
|
|
93
|
+
expect(
|
|
94
|
+
sourcesMatch(
|
|
95
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
96
|
+
"git@github.com:BlackBeltTechnology/pi-anthropic-messages.git",
|
|
97
|
+
),
|
|
98
|
+
).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("distinguishes different npm names", () => {
|
|
102
|
+
expect(sourcesMatch("npm:pi-web-access", "npm:pi-agent-browser")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("cross-matches git URL against local path whose basename is the repo", () => {
|
|
106
|
+
expect(
|
|
107
|
+
sourcesMatch(
|
|
108
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
109
|
+
"../pi-flows",
|
|
110
|
+
),
|
|
111
|
+
).toBe(true);
|
|
112
|
+
expect(
|
|
113
|
+
sourcesMatch(
|
|
114
|
+
"../pi-anthropic-messages/",
|
|
115
|
+
"git@github.com:BlackBeltTechnology/pi-anthropic-messages.git",
|
|
116
|
+
),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
expect(
|
|
119
|
+
sourcesMatch(
|
|
120
|
+
"git@github.com:Org/pi-flows.git",
|
|
121
|
+
"/abs/path/to/pi-flows.git",
|
|
122
|
+
),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not cross-match a git URL against an unrelated local path", () => {
|
|
127
|
+
expect(
|
|
128
|
+
sourcesMatch(
|
|
129
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
130
|
+
"../pi-web-access",
|
|
131
|
+
),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not cross-match a git URL against a deep local path", () => {
|
|
136
|
+
expect(
|
|
137
|
+
sourcesMatch(
|
|
138
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
139
|
+
"../pi-flows/packages/core",
|
|
140
|
+
),
|
|
141
|
+
).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -92,6 +92,10 @@ export interface BrowserModelsListMessage {
|
|
|
92
92
|
models: ModelInfo[];
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export interface ModelsRefreshedMessage {
|
|
96
|
+
type: "models_refreshed";
|
|
97
|
+
}
|
|
98
|
+
|
|
95
99
|
export interface BrowserRolesListMessage {
|
|
96
100
|
type: "roles_list";
|
|
97
101
|
sessionId: string;
|
|
@@ -207,6 +211,21 @@ export interface PackageProgressMessage {
|
|
|
207
211
|
};
|
|
208
212
|
}
|
|
209
213
|
|
|
214
|
+
/** Progress event streamed during a pi core package update. */
|
|
215
|
+
export interface PiCoreUpdateProgressMessage {
|
|
216
|
+
type: "pi_core_update_progress";
|
|
217
|
+
name: string;
|
|
218
|
+
phase: "start" | "output" | "complete" | "error";
|
|
219
|
+
message?: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Sent when a full pi core update batch finishes. */
|
|
223
|
+
export interface PiCoreUpdateCompleteMessage {
|
|
224
|
+
type: "pi_core_update_complete";
|
|
225
|
+
results: Array<{ name: string; success: boolean; error?: string }>;
|
|
226
|
+
sessionsReloaded: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
/** Sent when a package operation finishes (success or failure). */
|
|
211
230
|
export interface PackageOperationCompleteMessage {
|
|
212
231
|
type: "package_operation_complete";
|
|
@@ -244,6 +263,8 @@ export type ServerToBrowserMessage =
|
|
|
244
263
|
| SessionStateResetMessage
|
|
245
264
|
| PackageProgressMessage
|
|
246
265
|
| PackageOperationCompleteMessage
|
|
266
|
+
| PiCoreUpdateProgressMessage
|
|
267
|
+
| PiCoreUpdateCompleteMessage
|
|
247
268
|
| EditorStatusMessage
|
|
248
269
|
| ForceKillResultMessage
|
|
249
270
|
| BrowserRolesListMessage
|
|
@@ -252,7 +273,8 @@ export type ServerToBrowserMessage =
|
|
|
252
273
|
| ServersUpdatedMessage
|
|
253
274
|
| BrowserPromptRequestMessage
|
|
254
275
|
| BrowserPromptDismissMessage
|
|
255
|
-
| BrowserPromptCancelMessage
|
|
276
|
+
| BrowserPromptCancelMessage
|
|
277
|
+
| ModelsRefreshedMessage;
|
|
256
278
|
|
|
257
279
|
// ── Browser → Server ────────────────────────────────────────────────
|
|
258
280
|
|
|
@@ -40,9 +40,9 @@ async function runOpenSpecAsync(args: string[], cwd: string): Promise<unknown |
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function buildOpenSpecData(
|
|
43
|
+
export function buildOpenSpecData(
|
|
44
44
|
listResult: { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> } | null,
|
|
45
|
-
statusResults: Map<string, { artifacts?: Array<{ id: string; status: string }
|
|
45
|
+
statusResults: Map<string, { artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null>,
|
|
46
46
|
): OpenSpecData {
|
|
47
47
|
if (!listResult || !Array.isArray(listResult.changes)) {
|
|
48
48
|
return EMPTY_DATA;
|
|
@@ -55,13 +55,18 @@ function buildOpenSpecData(
|
|
|
55
55
|
status: (a.status === "done" ? "done" : a.status === "ready" ? "ready" : "blocked") as OpenSpecArtifact["status"],
|
|
56
56
|
}));
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
const isComplete =
|
|
59
|
+
typeof statusResult?.isComplete === "boolean" ? statusResult.isComplete : undefined;
|
|
60
|
+
|
|
61
|
+
const change: OpenSpecChange = {
|
|
59
62
|
name: c.name,
|
|
60
63
|
status: (c.status === "complete" ? "complete" : c.status === "in-progress" ? "in-progress" : "no-tasks") as OpenSpecChange["status"],
|
|
61
64
|
completedTasks: c.completedTasks ?? 0,
|
|
62
65
|
totalTasks: c.totalTasks ?? 0,
|
|
63
66
|
artifacts,
|
|
64
67
|
};
|
|
68
|
+
if (isComplete !== undefined) change.isComplete = isComplete;
|
|
69
|
+
return change;
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
return { initialized: true, changes };
|