@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.
Files changed (76) hide show
  1. package/AGENTS.md +114 -9
  2. package/README.md +218 -97
  3. package/docs/architecture.md +107 -7
  4. package/package.json +9 -4
  5. package/packages/extension/package.json +1 -1
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  8. package/packages/extension/src/ask-user-tool.ts +289 -20
  9. package/packages/extension/src/bridge.ts +38 -4
  10. package/packages/extension/src/command-handler.ts +34 -39
  11. package/packages/extension/src/prompt-expander.ts +25 -4
  12. package/packages/server/package.json +2 -1
  13. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  14. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  15. package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
  16. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  17. package/packages/server/src/__tests__/cors.test.ts +34 -2
  18. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  19. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  20. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  21. package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
  22. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  23. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  24. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  25. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  26. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  27. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
  28. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  29. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  30. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  31. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  32. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  33. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  34. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  35. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  36. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  37. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  38. package/packages/server/src/__tests__/tunnel.test.ts +91 -0
  39. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  40. package/packages/server/src/browse.ts +100 -6
  41. package/packages/server/src/browser-gateway.ts +16 -3
  42. package/packages/server/src/editor-manager.ts +20 -1
  43. package/packages/server/src/editor-pid-registry.ts +198 -0
  44. package/packages/server/src/fix-pty-permissions.ts +44 -0
  45. package/packages/server/src/headless-pid-registry.ts +9 -0
  46. package/packages/server/src/npm-search-proxy.ts +71 -0
  47. package/packages/server/src/openspec-tasks.ts +158 -0
  48. package/packages/server/src/package-manager-wrapper.ts +31 -0
  49. package/packages/server/src/pi-core-checker.ts +290 -0
  50. package/packages/server/src/pi-core-updater.ts +166 -0
  51. package/packages/server/src/pi-gateway.ts +7 -0
  52. package/packages/server/src/process-manager.ts +1 -1
  53. package/packages/server/src/routes/file-routes.ts +30 -3
  54. package/packages/server/src/routes/openspec-routes.ts +83 -1
  55. package/packages/server/src/routes/pi-core-routes.ts +117 -0
  56. package/packages/server/src/routes/provider-auth-routes.ts +4 -2
  57. package/packages/server/src/routes/provider-routes.ts +12 -2
  58. package/packages/server/src/routes/recommended-routes.ts +227 -0
  59. package/packages/server/src/routes/system-routes.ts +10 -1
  60. package/packages/server/src/server.ts +151 -15
  61. package/packages/server/src/terminal-manager.ts +4 -0
  62. package/packages/server/src/test-env-guard.ts +26 -0
  63. package/packages/server/src/test-support/test-server.ts +63 -0
  64. package/packages/server/src/tunnel.ts +132 -8
  65. package/packages/shared/package.json +1 -1
  66. package/packages/shared/src/__tests__/config.test.ts +3 -3
  67. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  68. package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
  69. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  70. package/packages/shared/src/browser-protocol.ts +23 -1
  71. package/packages/shared/src/openspec-poller.ts +8 -3
  72. package/packages/shared/src/recommended-extensions.ts +180 -0
  73. package/packages/shared/src/rest-api.ts +71 -0
  74. package/packages/shared/src/source-matching.ts +126 -0
  75. package/packages/shared/src/test-support/setup-home.ts +74 -0
  76. 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(port: number, reservedToken?: string): Promise<string | null> {
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
- // Try to get or create a reserved token
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 retry with fresh reservation
274
- if (token) {
275
- console.warn(`Reserved share failed (code ${code}), creating new reservation...`);
276
- resolve(createTunnel(port)); // retry without token
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
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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(true);
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(true);
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(true);
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 }> } | null>,
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
- return {
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 };