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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. package/packages/shared/src/types.ts +160 -0
@@ -10,6 +10,7 @@
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import * as crypto from "node:crypto";
13
+ import { computeIdentity, parseSourceKind } from "./package-source-helpers.js";
13
14
  import {
14
15
  getDefaultRegistry,
15
16
  ModuleResolutionError,
@@ -151,27 +152,54 @@ export function diagnosePiPackageManager(registry: ToolRegistry = getDefaultRegi
151
152
  export { ModuleResolutionError };
152
153
 
153
154
  export type PackageScope = "global" | "local";
154
- export type PackageAction = "install" | "remove" | "update";
155
+ export type PackageAction = "install" | "remove" | "update" | "move";
155
156
 
156
157
  export interface OperationRequest {
157
- action: PackageAction;
158
+ action: "install" | "remove" | "update";
158
159
  source: string;
159
160
  scope: PackageScope;
160
161
  cwd?: string;
161
162
  }
162
163
 
164
+ /**
165
+ * Pi `packages[]` entry. Either a bare source string or an object with
166
+ * filter keys (`extensions`/`skills`/`prompts`/`themes`). See pi's
167
+ * `docs/packages.md` “Package Filtering” section.
168
+ */
169
+ export type PackageEntry = string | { source: string; [k: string]: unknown };
170
+
171
+ /** Move operation request. See change: unify-package-management-ui. */
172
+ export interface MoveRequest {
173
+ /** Full origin entry (string or filter object) — passed verbatim from the route. */
174
+ entry: PackageEntry;
175
+ fromScope: PackageScope;
176
+ fromCwd?: string;
177
+ toScope: PackageScope;
178
+ toCwd?: string;
179
+ }
180
+
163
181
  export interface OperationResult {
164
182
  operationId: string;
183
+ /** `move` for composite move ops; `install`/`remove`/`update` otherwise. */
165
184
  action: PackageAction;
166
- source: string;
185
+ /** When `action === "move"`, this is the destination scope. */
167
186
  scope: PackageScope;
187
+ source: string;
168
188
  success: boolean;
169
189
  error?: string;
190
+ /** Set on `action === "move"` only; ties together emitted events. */
191
+ moveId?: string;
192
+ /** Set on `action === "move"` when install succeeded but remove failed. */
193
+ partialSuccess?: {
194
+ installed: boolean;
195
+ removed: boolean;
196
+ removeError?: string;
197
+ };
170
198
  /** On failure: full resolution trail if pi couldn't be loaded. */
171
199
  diagnostics?: Resolution;
172
200
  }
173
201
 
174
- export type ProgressListener = (operationId: string, event: ProgressEvent) => void;
202
+ export type ProgressListener = (operationId: string, event: ProgressEvent, moveId?: string) => void;
175
203
  export type CompleteListener = (result: OperationResult) => void;
176
204
 
177
205
  const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
@@ -243,6 +271,51 @@ export class PackageManagerWrapper {
243
271
  return operationId;
244
272
  }
245
273
 
274
+ /**
275
+ * Move a package between scopes (global ↔ local). Hybrid execution:
276
+ *
277
+ * - npm:/git:/https: → install at destination, then remove from origin.
278
+ * - abs-path/rel-path → settings-only edit (pi never copies path sources).
279
+ *
280
+ * Returns moveId synchronously. Single-flight via `this.busy`.
281
+ * Throws synchronously: `PackageOperationBusyError`,
282
+ * `InvalidMoveRequestError`, `UnsupportedSourceForDestinationError`.
283
+ * Throws async (caught by executeMove): `AlreadyAtDestinationError`
284
+ * is delivered via the complete listener with success=false.
285
+ *
286
+ * See change: unify-package-management-ui.
287
+ */
288
+ async move(req: MoveRequest): Promise<string> {
289
+ if (this.busy) {
290
+ throw new PackageOperationBusyError();
291
+ }
292
+
293
+ if (req.fromScope === req.toScope) {
294
+ throw new InvalidMoveRequestError("fromScope and toScope must differ");
295
+ }
296
+ if (req.fromScope === "local" && !req.fromCwd) {
297
+ throw new InvalidMoveRequestError("fromCwd required when fromScope is local");
298
+ }
299
+ if (req.toScope === "local" && !req.toCwd) {
300
+ throw new InvalidMoveRequestError("toCwd required when toScope is local");
301
+ }
302
+
303
+ const sourceStr = typeof req.entry === "string" ? req.entry : req.entry.source;
304
+ if (!sourceStr || typeof sourceStr !== "string") {
305
+ throw new InvalidMoveRequestError("entry.source must be a non-empty string");
306
+ }
307
+ if (parseSourceKind(sourceStr) === "rel-path" && req.fromScope === "local" && !req.fromCwd) {
308
+ throw new UnsupportedSourceForDestinationError("relative-path source requires fromCwd");
309
+ }
310
+
311
+ const moveId = crypto.randomUUID();
312
+ this.busy = true;
313
+ this.executeMove(moveId, req).catch(() => {
314
+ // errors handled inside executeMove
315
+ });
316
+ return moveId;
317
+ }
318
+
246
319
  /**
247
320
  * List configured packages for a scope.
248
321
  */
@@ -322,13 +395,14 @@ export class PackageManagerWrapper {
322
395
  this.pmPending.delete(effectiveCwd);
323
396
  }
324
397
 
325
- private async executeOperation(operationId: string, req: OperationRequest): Promise<void> {
398
+ private async executeOperation(operationId: string, req: OperationRequest, moveId?: string): Promise<void> {
326
399
  const result: OperationResult = {
327
400
  operationId,
328
401
  action: req.action,
329
402
  source: req.source,
330
403
  scope: req.scope,
331
404
  success: false,
405
+ moveId,
332
406
  };
333
407
 
334
408
  try {
@@ -336,7 +410,7 @@ export class PackageManagerWrapper {
336
410
  const local = req.scope === "local";
337
411
 
338
412
  pm.setProgressCallback((event: ProgressEvent) => {
339
- this.onProgress?.(operationId, event);
413
+ this.onProgress?.(operationId, event, moveId);
340
414
  });
341
415
 
342
416
  switch (req.action) {
@@ -357,8 +431,9 @@ export class PackageManagerWrapper {
357
431
  // listInstalled() calls see the mutated settings.json.
358
432
  this.invalidatePackageManager(req.cwd);
359
433
 
360
- // Reload all sessions after successful operation
361
- if (this.reloadSessions) {
434
+ // Reload all sessions. When called inside a move (moveId set),
435
+ // skip — executeMove issues exactly one reload at the very end.
436
+ if (this.reloadSessions && !moveId) {
362
437
  try {
363
438
  const count = await this.reloadSessions();
364
439
  (result as any).sessionsReloaded = count;
@@ -376,6 +451,156 @@ export class PackageManagerWrapper {
376
451
  } else {
377
452
  result.error = err?.message ?? String(err);
378
453
  }
454
+ // Re-throw so executeMove can detect failure and short-circuit.
455
+ if (moveId) throw err;
456
+ } finally {
457
+ // When inside a move the busy lock is held by executeMove —
458
+ // do NOT release it here. Don't fire the completion listener
459
+ // either — executeMove emits a single composite "move" event.
460
+ if (!moveId) {
461
+ this.busy = false;
462
+ this.onComplete?.(result);
463
+ }
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Execute a move. Holds the busy lock across both phases. Emits exactly
469
+ * one `package_operation_complete` listener call with `action: "move"`.
470
+ */
471
+ private async executeMove(moveId: string, req: MoveRequest): Promise<void> {
472
+ const sourceStr = typeof req.entry === "string" ? req.entry : req.entry.source;
473
+ const result: OperationResult = {
474
+ operationId: moveId,
475
+ action: "move",
476
+ source: sourceStr,
477
+ scope: req.toScope,
478
+ success: false,
479
+ moveId,
480
+ };
481
+
482
+ const pmCwd = req.toCwd ?? req.fromCwd ?? process.cwd();
483
+
484
+ try {
485
+ const pm = await this.createPackageManager(pmCwd);
486
+ const settingsManager = (pm as any).settingsManager;
487
+ if (!settingsManager) {
488
+ throw new Error("pi DefaultPackageManager does not expose settingsManager (unexpected pi version)");
489
+ }
490
+
491
+ // Identity preflight against destination's packages[].
492
+ const destPackages = readPackages(settingsManager, req.toScope);
493
+ const toSettingsDir = req.toScope === "global"
494
+ ? path.join(os.homedir(), ".pi", "agent")
495
+ : path.join(req.toCwd ?? pmCwd, ".pi");
496
+ const fromSettingsDir = req.fromScope === "global"
497
+ ? path.join(os.homedir(), ".pi", "agent")
498
+ : path.join(req.fromCwd ?? pmCwd, ".pi");
499
+ const incomingIdentity = computeIdentity(sourceStr, fromSettingsDir);
500
+ const dup = destPackages.find((e) => {
501
+ const s = typeof e === "string" ? e : e?.source;
502
+ return s ? computeIdentity(s, toSettingsDir) === incomingIdentity : false;
503
+ });
504
+ if (dup) throw new AlreadyAtDestinationError(sourceStr, req.toScope);
505
+
506
+ const kind = parseSourceKind(sourceStr);
507
+ const isPathArm = kind === "abs-path" || kind === "rel-path";
508
+
509
+ pm.setProgressCallback((event: ProgressEvent) => {
510
+ this.onProgress?.(moveId, { ...event, action: "move" as any }, moveId);
511
+ });
512
+
513
+ if (isPathArm) {
514
+ // Path arm: settings-only edit, no file copy.
515
+ const fromPackages = readPackages(settingsManager, req.fromScope);
516
+ const fromIdx = fromPackages.findIndex((e) => {
517
+ const s = typeof e === "string" ? e : e?.source;
518
+ return s && computeIdentity(s, fromSettingsDir) === incomingIdentity;
519
+ });
520
+ if (fromIdx < 0) {
521
+ throw new Error(`source not found in ${req.fromScope} packages[]`);
522
+ }
523
+ const originEntry = fromPackages[fromIdx];
524
+ const newSource = translatePathSource({
525
+ originalSource: sourceStr,
526
+ fromSettingsDir,
527
+ toSettingsDir,
528
+ toScope: req.toScope,
529
+ });
530
+ const newEntry: PackageEntry = typeof originEntry === "string"
531
+ ? newSource
532
+ : { ...originEntry, source: newSource };
533
+
534
+ writePackages(settingsManager, req.toScope, [...destPackages, newEntry]);
535
+ writePackages(settingsManager, req.fromScope, fromPackages.filter((_, i) => i !== fromIdx));
536
+ } else {
537
+ // npm/git/https arm: install at dest, then remove from origin.
538
+ const installReq: OperationRequest = {
539
+ action: "install",
540
+ source: sourceStr,
541
+ scope: req.toScope,
542
+ cwd: req.toScope === "local" ? req.toCwd : undefined,
543
+ };
544
+ await this.executeOperation(crypto.randomUUID(), installReq, moveId);
545
+
546
+ // Filter-preservation: if origin had filters (object form), patch
547
+ // the destination entry pi just wrote so they survive the move.
548
+ if (typeof req.entry === "object" && req.entry !== null) {
549
+ const finalDest = readPackages(settingsManager, req.toScope);
550
+ const idx = finalDest.findIndex((e) => {
551
+ const s = typeof e === "string" ? e : e?.source;
552
+ return s && computeIdentity(s, toSettingsDir) === incomingIdentity;
553
+ });
554
+ if (idx >= 0) {
555
+ const installedEntry = finalDest[idx];
556
+ const installedSource = typeof installedEntry === "string"
557
+ ? installedEntry
558
+ : installedEntry.source;
559
+ finalDest[idx] = { ...req.entry, source: installedSource };
560
+ writePackages(settingsManager, req.toScope, finalDest);
561
+ }
562
+ }
563
+
564
+ // Remove from origin. Failure → partial-success, not full failure.
565
+ try {
566
+ const removeReq: OperationRequest = {
567
+ action: "remove",
568
+ source: sourceStr,
569
+ scope: req.fromScope,
570
+ cwd: req.fromScope === "local" ? req.fromCwd : undefined,
571
+ };
572
+ await this.executeOperation(crypto.randomUUID(), removeReq, moveId);
573
+ } catch (removeErr: any) {
574
+ result.partialSuccess = {
575
+ installed: true,
576
+ removed: false,
577
+ removeError: removeErr?.message ?? String(removeErr),
578
+ };
579
+ }
580
+ }
581
+
582
+ result.success = true;
583
+ this.invalidatePackageManager(req.fromCwd);
584
+ this.invalidatePackageManager(req.toCwd);
585
+
586
+ if (this.reloadSessions) {
587
+ try {
588
+ const count = await this.reloadSessions();
589
+ (result as any).sessionsReloaded = count;
590
+ } catch (err) {
591
+ console.error("[package-manager] session reload failed:", err);
592
+ }
593
+ }
594
+ } catch (err: any) {
595
+ if (err instanceof ModuleResolutionError) {
596
+ result.error = err.message;
597
+ result.diagnostics = err.resolution;
598
+ } else if (err instanceof AlreadyAtDestinationError) {
599
+ result.error = err.message;
600
+ (result as any).code = "already_at_destination";
601
+ } else {
602
+ result.error = err?.message ?? String(err);
603
+ }
379
604
  } finally {
380
605
  this.busy = false;
381
606
  this.onComplete?.(result);
@@ -383,6 +608,78 @@ export class PackageManagerWrapper {
383
608
  }
384
609
  }
385
610
 
611
+ // ──────────────────────────────────────────────────────────────────
612
+ // SettingsManager helpers — thin shim around pi's API.
613
+ // ──────────────────────────────────────────────────────────────────
614
+
615
+ function readPackages(settingsManager: any, scope: PackageScope): PackageEntry[] {
616
+ const settings = scope === "global"
617
+ ? settingsManager.getGlobalSettings?.()
618
+ : settingsManager.getProjectSettings?.();
619
+ return Array.isArray(settings?.packages) ? [...settings.packages] : [];
620
+ }
621
+
622
+ function writePackages(settingsManager: any, scope: PackageScope, packages: PackageEntry[]): void {
623
+ if (scope === "global") {
624
+ if (typeof settingsManager.setPackages !== "function") {
625
+ throw new Error("settingsManager.setPackages not available (unexpected pi version)");
626
+ }
627
+ settingsManager.setPackages(packages);
628
+ } else {
629
+ if (typeof settingsManager.setProjectPackages !== "function") {
630
+ throw new Error("settingsManager.setProjectPackages not available (unexpected pi version)");
631
+ }
632
+ settingsManager.setProjectPackages(packages);
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Translate a path source between scopes per design.md decision 1.
638
+ * To global → resolve to absolute against fromSettingsDir.
639
+ * To local → try path.relative(toSettingsDir, abs); keep absolute if
640
+ * the relative form escapes the cwd tree by more than 2 `..` segments.
641
+ */
642
+ export function translatePathSource(args: {
643
+ originalSource: string;
644
+ fromSettingsDir: string;
645
+ toSettingsDir: string;
646
+ toScope: PackageScope;
647
+ }): string {
648
+ const { originalSource, fromSettingsDir, toSettingsDir, toScope } = args;
649
+ const abs = path.isAbsolute(originalSource)
650
+ ? path.normalize(originalSource)
651
+ : path.resolve(fromSettingsDir, originalSource);
652
+
653
+ if (toScope === "global") return abs;
654
+
655
+ const rel = path.relative(toSettingsDir, abs);
656
+ if (rel === "") return ".";
657
+ const upSegments = rel.split(path.sep).filter((s) => s === "..").length;
658
+ if (upSegments > 2) return abs;
659
+ return rel;
660
+ }
661
+
662
+ export class AlreadyAtDestinationError extends Error {
663
+ constructor(public source: string, public destScope: PackageScope) {
664
+ super(`Package already installed at ${destScope} scope: ${source}`);
665
+ this.name = "AlreadyAtDestinationError";
666
+ }
667
+ }
668
+
669
+ export class InvalidMoveRequestError extends Error {
670
+ constructor(reason: string) {
671
+ super(`Invalid move request: ${reason}`);
672
+ this.name = "InvalidMoveRequestError";
673
+ }
674
+ }
675
+
676
+ export class UnsupportedSourceForDestinationError extends Error {
677
+ constructor(reason: string) {
678
+ super(`Unsupported source for destination: ${reason}`);
679
+ this.name = "UnsupportedSourceForDestinationError";
680
+ }
681
+ }
682
+
386
683
  export class PackageOperationBusyError extends Error {
387
684
  constructor() {
388
685
  super("A package operation is already in progress");
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure helpers for classifying pi package sources and computing
3
+ * dedup identities. Mirrors the rules documented in pi's
4
+ * `docs/packages.md`:
5
+ *
6
+ * - npm:<spec> → identity = bare package name (without `@version`)
7
+ * - git:<url> → identity = url with trailing `@<ref>` stripped
8
+ * - https://... → identity = url with trailing `@<ref>` stripped
9
+ * - /abs/path → identity = the absolute path verbatim
10
+ * - rel-path → identity = path resolved against settingsDir
11
+ *
12
+ * Used by `PackageManagerWrapper.move()` for identity preflight (so we
13
+ * can return 409 already_at_destination before any side-effects) and
14
+ * for source classification when picking the move execution arm.
15
+ *
16
+ * See change: unify-package-management-ui.
17
+ */
18
+ import path from "node:path";
19
+
20
+ export type SourceKind = "npm" | "git" | "https" | "abs-path" | "rel-path";
21
+
22
+ const PROTOCOL_PREFIXES = ["https://", "http://", "ssh://", "git://"];
23
+
24
+ /**
25
+ * Classify a pi package source string by kind.
26
+ *
27
+ * Falls through to abs-path / rel-path for anything that isn't an
28
+ * `npm:` / `git:` / protocol-url source. Empty strings and pure
29
+ * whitespace are treated as `rel-path` (defensive — pi would reject
30
+ * these upstream, but we don't need to crash on them here).
31
+ */
32
+ export function parseSourceKind(source: string): SourceKind {
33
+ if (source.startsWith("npm:")) return "npm";
34
+ // Protocol URLs MUST be checked before the `git:` shorthand check
35
+ // because `git://` legitimately starts with `git:` but is a protocol
36
+ // URL per pi's docs (handled the same as https for our purposes).
37
+ for (const proto of PROTOCOL_PREFIXES) {
38
+ if (source.startsWith(proto)) return "https";
39
+ }
40
+ if (source.startsWith("git:")) return "git";
41
+ if (path.isAbsolute(source)) return "abs-path";
42
+ // Windows drive-letter paths (e.g. C:\foo or C:/foo). `path.isAbsolute`
43
+ // only returns true for these on win32; on POSIX hosts we still want
44
+ // to classify them as abs-path so cross-host tests are stable.
45
+ if (/^[a-zA-Z]:[\\/]/.test(source)) return "abs-path";
46
+ return "rel-path";
47
+ }
48
+
49
+ /**
50
+ * Compute the dedup identity for a source string per pi's package
51
+ * scope-and-deduplication rules.
52
+ *
53
+ * For relative paths, `settingsDir` is the directory of the
54
+ * `settings.json` file the entry lives in (pi resolves rel paths
55
+ * against that location). When called without `settingsDir` the
56
+ * relative path is returned verbatim — callers that need real
57
+ * dedup MUST pass `settingsDir`.
58
+ */
59
+ export function computeIdentity(source: string, settingsDir?: string): string {
60
+ const kind = parseSourceKind(source);
61
+
62
+ switch (kind) {
63
+ case "npm": {
64
+ // "npm:@scope/pkg@1.2.3" → "npm:@scope/pkg"
65
+ // "npm:foo@1.2.3" → "npm:foo"
66
+ const rest = source.slice(4); // strip "npm:"
67
+ // Find the LAST `@` that isn't part of the leading scope `@`.
68
+ const scoped = rest.startsWith("@");
69
+ const atIdx = scoped ? rest.indexOf("@", 1) : rest.indexOf("@");
70
+ const name = atIdx >= 0 ? rest.slice(0, atIdx) : rest;
71
+ return `npm:${name}`;
72
+ }
73
+
74
+ case "git":
75
+ case "https": {
76
+ // Strip trailing `@<ref>` if present. Refs come AFTER the
77
+ // repo URL; we look for the LAST `@` that isn't the
78
+ // `git@host` form's leading `@`.
79
+ //
80
+ // Heuristic: split on `@` and rejoin all but the last
81
+ // segment IF the last segment looks like a version/ref
82
+ // (no `/`, no `.`-host-style structure).
83
+ const lastAt = source.lastIndexOf("@");
84
+ if (lastAt > 0) {
85
+ const tail = source.slice(lastAt + 1);
86
+ // `git@github.com:...` — the `@` here is part of the host.
87
+ const hostLikeTail = tail.includes(":") || tail.includes("/");
88
+ if (!hostLikeTail) {
89
+ return source.slice(0, lastAt);
90
+ }
91
+ }
92
+ return source;
93
+ }
94
+
95
+ case "abs-path":
96
+ return path.normalize(source);
97
+
98
+ case "rel-path":
99
+ if (settingsDir) {
100
+ return path.resolve(settingsDir, source);
101
+ }
102
+ return source;
103
+ }
104
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * In-memory FIFO queue of pending `attachProposal` intents per cwd.
3
+ *
4
+ * Lifecycle:
5
+ * 1. Browser sends `spawn_session { cwd, attachProposal }` → server enqueues.
6
+ * 2. Bridge later issues `session_register { sessionId, cwd }` → server
7
+ * consumes the head intent for that cwd and applies the same idempotent
8
+ * attach + auto-rename logic as `handleAttachProposal`.
9
+ *
10
+ * Constraints (see openspec/changes/add-folder-task-checker-and-spawn-attach):
11
+ * - Per-cwd queue is FIFO, capped at 8 entries (silent drop + warn on overflow).
12
+ * - Entries older than 60 s are discarded on every read or write touching that
13
+ * cwd, so a failed spawn cannot strand an intent that would later attach to
14
+ * an unrelated session.
15
+ * - Cwd is normalized via `safeRealpathSync` before keying the queue, so
16
+ * trailing-slash / symlink variants collapse onto the same key.
17
+ *
18
+ * In-memory only. NOT persisted across server restarts.
19
+ */
20
+ import { safeRealpathSync } from "./resolve-path.js";
21
+
22
+ interface PendingAttach {
23
+ changeName: string;
24
+ enqueuedAt: number;
25
+ }
26
+
27
+ export const PENDING_ATTACH_TTL_MS = 60_000;
28
+ export const PENDING_ATTACH_QUEUE_CAP = 8;
29
+
30
+ export interface PendingAttachRegistry {
31
+ enqueue(cwd: string, changeName: string): boolean;
32
+ consume(cwd: string): string | null;
33
+ size(cwd: string): number;
34
+ }
35
+
36
+ export interface PendingAttachRegistryOptions {
37
+ /** Override `Date.now` for tests. */
38
+ now?: () => number;
39
+ /** Override the cwd normalizer (defaults to `safeRealpathSync`). */
40
+ normalize?: (cwd: string) => string;
41
+ /** Override warning sink (defaults to `console.warn`). */
42
+ warn?: (msg: string) => void;
43
+ }
44
+
45
+ export function createPendingAttachRegistry(
46
+ opts: PendingAttachRegistryOptions = {},
47
+ ): PendingAttachRegistry {
48
+ const now = opts.now ?? (() => Date.now());
49
+ const normalize = opts.normalize ?? ((cwd: string) => safeRealpathSync(stripTrailingSep(cwd)));
50
+ const warn = opts.warn ?? ((m: string) => console.warn(m));
51
+
52
+ const store = new Map<string, PendingAttach[]>();
53
+
54
+ function pruneStale(key: string): PendingAttach[] {
55
+ const queue = store.get(key);
56
+ if (!queue) return [];
57
+ const cutoff = now() - PENDING_ATTACH_TTL_MS;
58
+ let dropped = 0;
59
+ while (queue.length > 0 && queue[0]!.enqueuedAt < cutoff) {
60
+ const stale = queue.shift()!;
61
+ dropped += 1;
62
+ warn(
63
+ `[pending-attach-registry] dropping stale intent: cwd=${key} change=${stale.changeName} ageMs=${now() - stale.enqueuedAt}`,
64
+ );
65
+ }
66
+ if (queue.length === 0) {
67
+ store.delete(key);
68
+ return [];
69
+ }
70
+ void dropped;
71
+ return queue;
72
+ }
73
+
74
+ return {
75
+ enqueue(cwd: string, changeName: string): boolean {
76
+ if (!changeName) return false;
77
+ const key = normalize(cwd);
78
+ const queue = pruneStale(key);
79
+ if (queue.length >= PENDING_ATTACH_QUEUE_CAP) {
80
+ warn(
81
+ `[pending-attach-registry] queue cap reached (${PENDING_ATTACH_QUEUE_CAP}) for cwd=${key}; dropping change=${changeName}`,
82
+ );
83
+ return false;
84
+ }
85
+ queue.push({ changeName, enqueuedAt: now() });
86
+ store.set(key, queue);
87
+ return true;
88
+ },
89
+
90
+ consume(cwd: string): string | null {
91
+ const key = normalize(cwd);
92
+ const queue = pruneStale(key);
93
+ if (queue.length === 0) return null;
94
+ const head = queue.shift()!;
95
+ if (queue.length === 0) store.delete(key);
96
+ return head.changeName;
97
+ },
98
+
99
+ size(cwd: string): number {
100
+ const key = normalize(cwd);
101
+ const queue = pruneStale(key);
102
+ return queue.length;
103
+ },
104
+ };
105
+ }
106
+
107
+ function stripTrailingSep(p: string): string {
108
+ if (p.length > 1 && (p.endsWith("/") || p.endsWith("\\"))) {
109
+ return p.replace(/[/\\]+$/, "");
110
+ }
111
+ return p;
112
+ }