@blackbelt-technology/pi-agent-dashboard 0.5.3 → 0.5.4

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 (212) hide show
  1. package/AGENTS.md +19 -30
  2. package/README.md +69 -1
  3. package/docs/architecture.md +89 -165
  4. package/package.json +10 -7
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-default-model-gate.test.ts +47 -0
  7. package/packages/extension/src/__tests__/bridge-followup-chat-order.test.ts +215 -0
  8. package/packages/extension/src/__tests__/bridge-followup-multi-entry.test.ts +202 -0
  9. package/packages/extension/src/__tests__/bridge-queue-update-forward.test.ts +77 -0
  10. package/packages/extension/src/__tests__/bridge-retry-ordering.test.ts +148 -0
  11. package/packages/extension/src/__tests__/bridge-shadow-queue-drain.test.ts +221 -0
  12. package/packages/extension/src/__tests__/bridge-shadow-queue-gate.test.ts +299 -0
  13. package/packages/extension/src/__tests__/bridge-shutdown-reset.test.ts +238 -0
  14. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +127 -31
  15. package/packages/extension/src/__tests__/command-handler.test.ts +105 -3
  16. package/packages/extension/src/__tests__/fixtures/usage-limit-error-strings.ts +127 -0
  17. package/packages/extension/src/__tests__/source-detector.test.ts +15 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +12 -0
  19. package/packages/extension/src/bridge-default-model-gate.ts +32 -0
  20. package/packages/extension/src/bridge.ts +299 -20
  21. package/packages/extension/src/command-handler.ts +53 -7
  22. package/packages/extension/src/dashboard-default-adapter.ts +5 -0
  23. package/packages/extension/src/prompt-bus.ts +15 -0
  24. package/packages/extension/src/slash-dispatch.ts +30 -15
  25. package/packages/extension/src/source-detector.ts +13 -5
  26. package/packages/extension/src/usage-limit-orderer.ts +18 -1
  27. package/packages/server/bin/pi-dashboard.mjs +62 -14
  28. package/packages/server/package.json +9 -5
  29. package/packages/server/src/__tests__/browser-gateway-register-handler.test.ts +69 -0
  30. package/packages/server/src/__tests__/cli-env-no-clobber.test.ts +46 -0
  31. package/packages/server/src/__tests__/cli-no-bootstrap-references.test.ts +69 -0
  32. package/packages/server/src/__tests__/cli-parse.test.ts +9 -10
  33. package/packages/server/src/__tests__/cli-version.test.ts +151 -0
  34. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +9 -0
  35. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +9 -0
  36. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +9 -0
  37. package/packages/server/src/__tests__/directory-service-toctou.test.ts +9 -0
  38. package/packages/server/src/__tests__/directory-service.test.ts +9 -0
  39. package/packages/server/src/__tests__/doctor-route.test.ts +53 -0
  40. package/packages/server/src/__tests__/event-wiring-queue-state.test.ts +156 -0
  41. package/packages/server/src/__tests__/event-wiring-resume-clear.test.ts +105 -0
  42. package/packages/server/src/__tests__/health-shape.test.ts +35 -12
  43. package/packages/server/src/__tests__/installed-package-enricher.test.ts +12 -12
  44. package/packages/server/src/__tests__/is-activity-event.test.ts +4 -7
  45. package/packages/server/src/__tests__/package-routes.test.ts +6 -2
  46. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +10 -13
  47. package/packages/server/src/__tests__/pi-core-checker.test.ts +2 -2
  48. package/packages/server/src/__tests__/pi-version-skew.test.ts +3 -2
  49. package/packages/server/src/__tests__/plugin-activation-routes.test.ts +267 -0
  50. package/packages/server/src/__tests__/plugin-intent-cache.test.ts +75 -0
  51. package/packages/server/src/__tests__/preferences-store.test.ts +196 -0
  52. package/packages/server/src/__tests__/reattach-placement.test.ts +9 -0
  53. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  54. package/packages/server/src/__tests__/recovery-server.test.ts +203 -0
  55. package/packages/server/src/__tests__/session-action-handler-clear-queue.test.ts +153 -0
  56. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +43 -0
  57. package/packages/server/src/__tests__/session-order-manager.test.ts +9 -0
  58. package/packages/server/src/__tests__/session-order-reboot.test.ts +9 -0
  59. package/packages/server/src/__tests__/session-ordering-integration.test.ts +9 -0
  60. package/packages/server/src/browser-gateway.ts +83 -5
  61. package/packages/server/src/browser-handlers/directory-handler.ts +69 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +89 -0
  63. package/packages/server/src/browser-handlers/subscription-handler.ts +23 -0
  64. package/packages/server/src/changelog-parser.ts +1 -1
  65. package/packages/server/src/cli.ts +68 -250
  66. package/packages/server/src/event-status-extraction.ts +14 -62
  67. package/packages/server/src/event-wiring.ts +23 -10
  68. package/packages/server/src/memory-session-manager.ts +4 -0
  69. package/packages/server/src/pi-core-checker.ts +1 -1
  70. package/packages/server/src/pi-dev-version-check.ts +1 -1
  71. package/packages/server/src/pi-version-skew.ts +24 -46
  72. package/packages/server/src/plugin-intent-cache.ts +67 -0
  73. package/packages/server/src/preferences-store.ts +199 -13
  74. package/packages/server/src/recovery-server.ts +366 -0
  75. package/packages/server/src/routes/__tests__/manifest-route.test.ts +138 -0
  76. package/packages/server/src/routes/doctor-routes.ts +26 -21
  77. package/packages/server/src/routes/manifest-route.ts +162 -0
  78. package/packages/server/src/routes/openspec-routes.ts +4 -25
  79. package/packages/server/src/routes/pi-changelog-routes.ts +5 -24
  80. package/packages/server/src/routes/pi-core-routes.ts +3 -23
  81. package/packages/server/src/routes/plugin-activation-routes.ts +193 -0
  82. package/packages/server/src/routes/recommended-routes.ts +21 -0
  83. package/packages/server/src/routes/system-routes.ts +73 -11
  84. package/packages/server/src/server.ts +105 -307
  85. package/packages/server/src/session-api.ts +5 -63
  86. package/packages/shared/package.json +1 -1
  87. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +28 -0
  88. package/packages/shared/src/__tests__/binary-lookup-spawn-env.test.ts +61 -0
  89. package/packages/shared/src/__tests__/binary-lookup.test.ts +16 -0
  90. package/packages/shared/src/__tests__/bridge-register.test.ts +67 -0
  91. package/packages/shared/src/__tests__/ci-electron-no-side-effects.test.ts +129 -0
  92. package/packages/shared/src/__tests__/config.test.ts +40 -0
  93. package/packages/shared/src/__tests__/dashboard-paths.test.ts +81 -0
  94. package/packages/shared/src/__tests__/ensure-windows-path.test.ts +112 -0
  95. package/packages/shared/src/__tests__/intent-types.test.ts +120 -0
  96. package/packages/shared/src/__tests__/jiti-packages-parity.test.ts +85 -0
  97. package/packages/shared/src/__tests__/legacy-managed-dir.test.ts +59 -0
  98. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +12 -0
  99. package/packages/shared/src/__tests__/no-electron-execpath-spawn.test.ts +149 -0
  100. package/packages/shared/src/__tests__/no-flow-command-route-claims.test.ts +71 -0
  101. package/packages/shared/src/__tests__/no-flow-references-in-shell.test.ts +221 -0
  102. package/packages/shared/src/__tests__/no-managed-dir-reference.test.ts +134 -0
  103. package/packages/shared/src/__tests__/no-pi-dashboard-version-jiti-gate.test.ts +41 -0
  104. package/packages/shared/src/__tests__/no-primitive-direct-import.test.ts +235 -0
  105. package/packages/shared/src/__tests__/no-server-imports-in-resolver.test.ts +53 -0
  106. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +54 -101
  107. package/packages/shared/src/__tests__/node-spawn.test.ts +29 -13
  108. package/packages/shared/src/__tests__/pi-package-resolver.test.ts +300 -0
  109. package/packages/shared/src/__tests__/plugin-activation-contracts.test.ts +74 -0
  110. package/packages/shared/src/__tests__/plugin-bridge-classify-source.test.ts +73 -0
  111. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +17 -5
  112. package/packages/shared/src/__tests__/plugin-bridge-register-packages.test.ts +233 -0
  113. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +19 -9
  114. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +154 -15
  115. package/packages/shared/src/__tests__/recommended-extensions.test.ts +28 -10
  116. package/packages/shared/src/__tests__/resolver-parity-with-scanner.test.ts +76 -0
  117. package/packages/shared/src/__tests__/server-identity.test.ts +127 -0
  118. package/packages/shared/src/__tests__/server-launcher.test.ts +35 -0
  119. package/packages/shared/src/__tests__/source-matching.test.ts +5 -5
  120. package/packages/shared/src/__tests__/sync-versions-spec.test.ts +76 -0
  121. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +50 -2
  122. package/packages/shared/src/bridge-register.ts +35 -2
  123. package/packages/shared/src/browser-protocol.ts +176 -2
  124. package/packages/shared/src/config.ts +12 -0
  125. package/packages/shared/src/dashboard-paths.ts +69 -0
  126. package/packages/shared/src/dashboard-plugin/index.ts +2 -0
  127. package/packages/shared/src/dashboard-plugin/intent-types.ts +93 -0
  128. package/packages/shared/src/dashboard-plugin/manifest-types.ts +55 -1
  129. package/packages/shared/src/dashboard-plugin/plugin-status.ts +82 -0
  130. package/packages/shared/src/dashboard-plugin/slot-props.ts +11 -0
  131. package/packages/shared/src/dashboard-plugin/slot-types.ts +16 -2
  132. package/packages/shared/src/dashboard-plugin/ui-primitives.ts +287 -0
  133. package/packages/shared/src/dashboard-starter.ts +22 -0
  134. package/packages/shared/src/doctor-core.ts +49 -27
  135. package/packages/shared/src/launch-source-types.ts +9 -9
  136. package/packages/shared/src/legacy-managed-dir.ts +97 -0
  137. package/packages/shared/src/mdns-discovery.ts +4 -1
  138. package/packages/shared/src/pi-package-resolver.ts +388 -0
  139. package/packages/shared/src/platform/binary-lookup.ts +27 -3
  140. package/packages/shared/src/platform/ensure-windows-path.ts +95 -0
  141. package/packages/shared/src/platform/exec.ts +22 -0
  142. package/packages/shared/src/platform/node-spawn.ts +42 -41
  143. package/packages/shared/src/plugin-bridge-register.ts +275 -18
  144. package/packages/shared/src/protocol.ts +94 -2
  145. package/packages/shared/src/recommended-extensions.ts +34 -10
  146. package/packages/shared/src/server-identity.ts +74 -5
  147. package/packages/shared/src/server-launcher.ts +20 -0
  148. package/packages/shared/src/source-matching.ts +1 -1
  149. package/packages/shared/src/tool-registry/__tests__/node-script-toargv-fallback.test.ts +84 -0
  150. package/packages/shared/src/tool-registry/definitions.ts +91 -7
  151. package/packages/shared/src/types.ts +12 -8
  152. package/scripts/maybe-patch-package.cjs +44 -0
  153. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +0 -263
  154. package/packages/server/src/__tests__/bootstrap-queue.test.ts +0 -120
  155. package/packages/server/src/__tests__/bootstrap-routes.test.ts +0 -125
  156. package/packages/server/src/__tests__/bootstrap-state.test.ts +0 -119
  157. package/packages/server/src/__tests__/cli-bootstrap.test.ts +0 -36
  158. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +0 -55
  159. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +0 -149
  160. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +0 -180
  161. package/packages/server/src/__tests__/post-install-rescan.test.ts +0 -134
  162. package/packages/server/src/__tests__/system-routes-reextract.test.ts +0 -91
  163. package/packages/server/src/bootstrap-install-from-list.ts +0 -232
  164. package/packages/server/src/bootstrap-queue.ts +0 -130
  165. package/packages/server/src/bootstrap-state.ts +0 -159
  166. package/packages/server/src/legacy-pi-cleanup.ts +0 -151
  167. package/packages/server/src/routes/bootstrap-routes.ts +0 -125
  168. package/packages/shared/src/__tests__/bootstrap/README.md +0 -133
  169. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +0 -378
  170. package/packages/shared/src/__tests__/bootstrap/assertions.ts +0 -136
  171. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +0 -47
  172. package/packages/shared/src/__tests__/bootstrap/cube.ts +0 -66
  173. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +0 -84
  174. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +0 -90
  175. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +0 -34
  176. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +0 -20
  177. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +0 -62
  178. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +0 -34
  179. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +0 -49
  180. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +0 -12
  181. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +0 -156
  182. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +0 -157
  183. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +0 -102
  184. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +0 -76
  185. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +0 -94
  186. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +0 -87
  187. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +0 -143
  188. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +0 -64
  189. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +0 -77
  190. package/packages/shared/src/__tests__/bootstrap/families/index.ts +0 -19
  191. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +0 -61
  192. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +0 -50
  193. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +0 -272
  194. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +0 -58
  195. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +0 -84
  196. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +0 -9
  197. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +0 -85
  198. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +0 -122
  199. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +0 -36
  200. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +0 -39
  201. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +0 -220
  202. package/packages/shared/src/__tests__/bootstrap/harness.ts +0 -413
  203. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +0 -125
  204. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +0 -132
  205. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +0 -72
  206. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +0 -68
  207. package/packages/shared/src/__tests__/install-managed-node.test.ts +0 -192
  208. package/packages/shared/src/__tests__/installable-list.test.ts +0 -130
  209. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +0 -52
  210. package/packages/shared/src/bootstrap-install.ts +0 -406
  211. package/packages/shared/src/installable-list.ts +0 -152
  212. package/packages/shared/src/launch-source-flag.ts +0 -14
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Server-side cache of the most recent plugin intent per
3
+ * (pluginId, sessionId, slot). Used to replay current state to
4
+ * reconnecting clients on subscribe.
5
+ *
6
+ * See change: adopt-server-driven-intent-rendering.
7
+ */
8
+ import type { IntentNode } from "@blackbelt-technology/pi-dashboard-shared/dashboard-plugin/intent-types.js";
9
+ import type { SlotId } from "@blackbelt-technology/pi-dashboard-shared/dashboard-plugin/slot-types.js";
10
+
11
+ export interface CachedIntentEntry {
12
+ pluginId: string;
13
+ sessionId: string | null;
14
+ slot: SlotId;
15
+ intent: IntentNode;
16
+ }
17
+
18
+ function key(pluginId: string, sessionId: string | null, slot: SlotId): string {
19
+ return `${pluginId}|${sessionId ?? ""}|${slot}`;
20
+ }
21
+
22
+ export class PluginIntentCache {
23
+ private map = new Map<string, CachedIntentEntry>();
24
+
25
+ /**
26
+ * Store the intent for a (pluginId, sessionId, slot) tuple.
27
+ * If `intent` is null, remove the entry (the plugin is clearing its
28
+ * contribution to that slot).
29
+ */
30
+ set(pluginId: string, sessionId: string | null, slot: SlotId, intent: IntentNode | null): void {
31
+ const k = key(pluginId, sessionId, slot);
32
+ if (intent === null) {
33
+ this.map.delete(k);
34
+ return;
35
+ }
36
+ this.map.set(k, { pluginId, sessionId, slot, intent });
37
+ }
38
+
39
+ /** Return every intent currently cached for a given session. */
40
+ getForSession(sessionId: string | null): CachedIntentEntry[] {
41
+ const out: CachedIntentEntry[] = [];
42
+ for (const entry of this.map.values()) {
43
+ if (entry.sessionId === sessionId) out.push(entry);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Return EVERY cached intent (e.g. for global slots with sessionId=null). */
49
+ getAll(): CachedIntentEntry[] {
50
+ return Array.from(this.map.values());
51
+ }
52
+
53
+ /** Remove every entry for a given session (called on session removal). */
54
+ clearForSession(sessionId: string | null): void {
55
+ for (const [k, entry] of this.map) {
56
+ if (entry.sessionId === sessionId) this.map.delete(k);
57
+ }
58
+ }
59
+
60
+ /** Test-only: clear the entire cache. */
61
+ reset(): void {
62
+ this.map.clear();
63
+ }
64
+ }
65
+
66
+ /** Module singleton — every call site shares the same cache. */
67
+ export const pluginIntentCache = new PluginIntentCache();
@@ -1,19 +1,31 @@
1
1
  /**
2
2
  * Global UI preferences store — JSON-backed with debounced writes.
3
- * Stores cross-session state: pinned directories and session ordering.
3
+ * Stores cross-session state: pinned directories, session ordering, and
4
+ * folder-workspaces (named, collapsible containers grouping folders).
5
+ *
6
+ * Workspace membership is authoritative and orthogonal to pinning — see
7
+ * change: folder-workspaces. A folder may live in `pinnedDirectories`
8
+ * AND a workspace's `folders[]` independently; the two lists do not
9
+ * deduplicate against each other.
10
+ *
4
11
  * Replaces `state-store.ts` (hidden state moved to per-session `.meta.json`).
5
12
  */
6
13
  import path from "node:path";
14
+ import { randomUUID } from "node:crypto";
7
15
  import { CONFIG_DIR } from "@blackbelt-technology/pi-dashboard-shared/config.js";
16
+ import type { Workspace } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
8
17
  import { readJsonFile, writeJsonFile } from "./json-store.js";
9
18
  import { safeRealpathSync } from "./resolve-path.js";
10
19
  import { normalizePath } from "@blackbelt-technology/pi-dashboard-shared/platform/paths.js";
11
20
 
12
21
  export const PREFERENCES_FILE = path.join(CONFIG_DIR, "preferences.json");
13
22
 
23
+ const NAME_MAX = 80;
24
+
14
25
  interface PreferencesData {
15
26
  sessionOrder: Record<string, string[]>;
16
27
  pinnedDirectories: string[];
28
+ workspaces?: Workspace[];
17
29
  }
18
30
 
19
31
  export interface PreferencesStore {
@@ -24,14 +36,79 @@ export interface PreferencesStore {
24
36
  pinDirectory(dirPath: string): void;
25
37
  unpinDirectory(dirPath: string): void;
26
38
  reorderPinnedDirs(dirs: string[]): void;
39
+ // ── folder-workspaces ────────────────────────────────────────────
40
+ getWorkspaces(): Workspace[];
41
+ /** Returns the created workspace, or null on invalid name. */
42
+ createWorkspace(name: string): Workspace | null;
43
+ /** Returns true on mutation, false on unknown id / invalid name. */
44
+ renameWorkspace(id: string, name: string): boolean;
45
+ /** Returns true on mutation, false on unknown id. */
46
+ deleteWorkspace(id: string): boolean;
47
+ /** Returns true on mutation, false on unknown id or no-op (same value). */
48
+ setWorkspaceCollapsed(id: string, collapsed: boolean): boolean;
49
+ /**
50
+ * Adds `path` to workspace `id`. Single-membership invariant: removes
51
+ * the canonicalized path from every other workspace first. Returns
52
+ * true on mutation, false on unknown id or already-member (no-op).
53
+ */
54
+ addFolderToWorkspace(id: string, dirPath: string): boolean;
55
+ /** Returns true on mutation, false on unknown id or not-member. */
56
+ removeFolderFromWorkspace(id: string, dirPath: string): boolean;
57
+ /**
58
+ * Replaces a workspace's folder order. Rejected if `paths` does not
59
+ * equal the current member set (after canonicalization). Returns true
60
+ * on mutation, false otherwise.
61
+ */
62
+ reorderWorkspaceFolders(id: string, paths: string[]): boolean;
63
+ /** Reorders workspaces. Rejected if `ids` doesn't equal current id set. */
64
+ reorderWorkspaces(ids: string[]): boolean;
27
65
  flush(): void;
28
66
  dispose(): void;
29
67
  }
30
68
 
31
69
  const DEBOUNCE_MS = 1000;
32
70
 
71
+ function canonicalize(p: string): string {
72
+ // IMPORTANT: wrap normalizePath in an arrow so Array.prototype.map's
73
+ // (element, index, array) signature does not leak `index: number` into
74
+ // its 2nd `platform` parameter. See preferences-store git blame.
75
+ return safeRealpathSync(normalizePath(p));
76
+ }
77
+
78
+ function dedupePreserveOrder(arr: string[]): string[] {
79
+ return [...new Set(arr)];
80
+ }
81
+
82
+ function setEquals(a: string[], b: string[]): boolean {
83
+ if (a.length !== b.length) return false;
84
+ const sa = new Set(a);
85
+ for (const x of b) if (!sa.has(x)) return false;
86
+ return true;
87
+ }
88
+
89
+ function sanitizeName(input: unknown): string | null {
90
+ if (typeof input !== "string") return null;
91
+ const trimmed = input.trim();
92
+ if (trimmed.length === 0 || trimmed.length > NAME_MAX) return null;
93
+ return trimmed;
94
+ }
95
+
96
+ function normalizeWorkspaceOnLoad(ws: Workspace): Workspace {
97
+ const folders = dedupePreserveOrder((ws.folders ?? []).map((p) => canonicalize(p)));
98
+ return {
99
+ id: typeof ws.id === "string" && ws.id.length > 0 ? ws.id : `ws_${randomUUID()}`,
100
+ name: typeof ws.name === "string" ? ws.name : "",
101
+ collapsed: Boolean(ws.collapsed),
102
+ folders,
103
+ };
104
+ }
105
+
33
106
  export function createPreferencesStore(filePath: string = PREFERENCES_FILE): PreferencesStore {
34
- const data: PreferencesData = readJsonFile<PreferencesData>(filePath, { sessionOrder: {}, pinnedDirectories: [] });
107
+ const data: PreferencesData = readJsonFile<PreferencesData>(filePath, {
108
+ sessionOrder: {},
109
+ pinnedDirectories: [],
110
+ workspaces: [],
111
+ });
35
112
  let sessionOrder: Record<string, string[]> = data.sessionOrder ?? {};
36
113
  // Normalize + resolve symlinks in stored pinned paths on load. Normalize
37
114
  // FIRST so cosmetic drift (trailing separator, mixed separators,
@@ -40,19 +117,24 @@ export function createPreferencesStore(filePath: string = PREFERENCES_FILE): Pre
40
117
  // not-yet-existing paths, so we keep its best-effort fallback.
41
118
  // See change: platform-path-normalization.
42
119
  const rawPinned = data.pinnedDirectories ?? [];
43
- // IMPORTANT: wrap in arrow fn — `Array.prototype.map` passes `(element,
44
- // index, array)`, and `normalizePath`'s 2nd param is a `platform:
45
- // NodeJS.Platform`. Passing the index (a number) silently disables the
46
- // Windows branch at runtime.
47
120
  let pinnedDirectories: string[] = rawPinned
48
121
  .map((p) => normalizePath(p))
49
122
  .map((p) => safeRealpathSync(p));
50
- // Deduplicate post-normalization. Two previously-different entries that
51
- // collapse to the same canonical form (e.g., with and without trailing
52
- // slash) become one stored entry.
53
- pinnedDirectories = [...new Set(pinnedDirectories)];
123
+ pinnedDirectories = dedupePreserveOrder(pinnedDirectories);
124
+
125
+ const rawWorkspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
126
+ let workspaces: Workspace[] = rawWorkspaces.map(normalizeWorkspaceOnLoad);
54
127
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
55
- let dirty = pinnedDirectories.length !== rawPinned.length || pinnedDirectories.some((p, i) => p !== rawPinned[i]);
128
+ let dirty =
129
+ pinnedDirectories.length !== rawPinned.length ||
130
+ pinnedDirectories.some((p, i) => p !== rawPinned[i]) ||
131
+ workspaces.length !== rawWorkspaces.length ||
132
+ workspaces.some((ws, i) => {
133
+ const raw = rawWorkspaces[i];
134
+ if (!raw) return true;
135
+ const rf = (raw.folders ?? []) as string[];
136
+ return ws.folders.length !== rf.length || ws.folders.some((f, j) => f !== rf[j]);
137
+ });
56
138
 
57
139
  function scheduleSave(): void {
58
140
  dirty = true;
@@ -61,7 +143,7 @@ export function createPreferencesStore(filePath: string = PREFERENCES_FILE): Pre
61
143
  debounceTimer = null;
62
144
  if (dirty) {
63
145
  dirty = false;
64
- writeJsonFile(filePath, { sessionOrder, pinnedDirectories } satisfies PreferencesData);
146
+ writeJsonFile(filePath, { sessionOrder, pinnedDirectories, workspaces } satisfies PreferencesData);
65
147
  }
66
148
  }, DEBOUNCE_MS);
67
149
  }
@@ -73,12 +155,16 @@ export function createPreferencesStore(filePath: string = PREFERENCES_FILE): Pre
73
155
  }
74
156
  if (dirty) {
75
157
  dirty = false;
76
- writeJsonFile(filePath, { sessionOrder, pinnedDirectories } satisfies PreferencesData);
158
+ writeJsonFile(filePath, { sessionOrder, pinnedDirectories, workspaces } satisfies PreferencesData);
77
159
  }
78
160
  }
79
161
 
80
162
  if (dirty) scheduleSave();
81
163
 
164
+ function findWs(id: string): Workspace | undefined {
165
+ return workspaces.find((w) => w.id === id);
166
+ }
167
+
82
168
  return {
83
169
  getSessionOrder(): Record<string, string[]> {
84
170
  return sessionOrder;
@@ -116,6 +202,106 @@ export function createPreferencesStore(filePath: string = PREFERENCES_FILE): Pre
116
202
  scheduleSave();
117
203
  },
118
204
 
205
+ // ── folder-workspaces ───────────────────────────────────────────
206
+
207
+ getWorkspaces(): Workspace[] {
208
+ // Deep-ish clone so callers can't mutate internal state.
209
+ return workspaces.map((w) => ({ ...w, folders: [...w.folders] }));
210
+ },
211
+
212
+ createWorkspace(name: string): Workspace | null {
213
+ const clean = sanitizeName(name);
214
+ if (clean === null) return null;
215
+ const ws: Workspace = {
216
+ id: `ws_${randomUUID()}`,
217
+ name: clean,
218
+ collapsed: false,
219
+ folders: [],
220
+ };
221
+ workspaces.push(ws);
222
+ scheduleSave();
223
+ return { ...ws, folders: [...ws.folders] };
224
+ },
225
+
226
+ renameWorkspace(id: string, name: string): boolean {
227
+ const clean = sanitizeName(name);
228
+ if (clean === null) return false;
229
+ const ws = findWs(id);
230
+ if (!ws) return false;
231
+ if (ws.name === clean) return false;
232
+ ws.name = clean;
233
+ scheduleSave();
234
+ return true;
235
+ },
236
+
237
+ deleteWorkspace(id: string): boolean {
238
+ const idx = workspaces.findIndex((w) => w.id === id);
239
+ if (idx === -1) return false;
240
+ workspaces.splice(idx, 1);
241
+ scheduleSave();
242
+ return true;
243
+ },
244
+
245
+ setWorkspaceCollapsed(id: string, collapsed: boolean): boolean {
246
+ const ws = findWs(id);
247
+ if (!ws) return false;
248
+ if (ws.collapsed === collapsed) return false;
249
+ ws.collapsed = collapsed;
250
+ scheduleSave();
251
+ return true;
252
+ },
253
+
254
+ addFolderToWorkspace(id: string, dirPath: string): boolean {
255
+ const ws = findWs(id);
256
+ if (!ws) return false;
257
+ const canon = canonicalize(dirPath);
258
+ if (ws.folders.includes(canon)) return false;
259
+ // Single-membership invariant: detach from every OTHER workspace
260
+ // first. Idempotent — no-op if not currently in any other workspace.
261
+ for (const other of workspaces) {
262
+ if (other.id === id) continue;
263
+ const i = other.folders.indexOf(canon);
264
+ if (i !== -1) other.folders.splice(i, 1);
265
+ }
266
+ ws.folders.push(canon);
267
+ scheduleSave();
268
+ return true;
269
+ },
270
+
271
+ removeFolderFromWorkspace(id: string, dirPath: string): boolean {
272
+ const ws = findWs(id);
273
+ if (!ws) return false;
274
+ const canon = canonicalize(dirPath);
275
+ const i = ws.folders.indexOf(canon);
276
+ if (i === -1) return false;
277
+ ws.folders.splice(i, 1);
278
+ scheduleSave();
279
+ return true;
280
+ },
281
+
282
+ reorderWorkspaceFolders(id: string, paths: string[]): boolean {
283
+ const ws = findWs(id);
284
+ if (!ws) return false;
285
+ const canon = paths.map((p) => canonicalize(p));
286
+ // Reject if the supplied set != current set.
287
+ if (!setEquals(canon, ws.folders)) return false;
288
+ // Reject duplicates within the new order.
289
+ if (new Set(canon).size !== canon.length) return false;
290
+ ws.folders = canon;
291
+ scheduleSave();
292
+ return true;
293
+ },
294
+
295
+ reorderWorkspaces(ids: string[]): boolean {
296
+ const currentIds = workspaces.map((w) => w.id);
297
+ if (!setEquals(ids, currentIds)) return false;
298
+ if (new Set(ids).size !== ids.length) return false;
299
+ const byId = new Map(workspaces.map((w) => [w.id, w] as const));
300
+ workspaces = ids.map((id) => byId.get(id)!).filter(Boolean) as Workspace[];
301
+ scheduleSave();
302
+ return true;
303
+ },
304
+
119
305
  flush(): void {
120
306
  flushNow();
121
307
  },