@blackbelt-technology/pi-agent-dashboard 0.2.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 (212) hide show
  1. package/AGENTS.md +342 -0
  2. package/README.md +619 -0
  3. package/docs/architecture.md +646 -0
  4. package/package.json +92 -0
  5. package/packages/extension/package.json +33 -0
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
  8. package/packages/extension/src/__tests__/connection.test.ts +344 -0
  9. package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
  10. package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
  11. package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
  12. package/packages/extension/src/__tests__/git-info.test.ts +112 -0
  13. package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
  14. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
  15. package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
  16. package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
  17. package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
  18. package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
  19. package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
  20. package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
  21. package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
  22. package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
  23. package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
  24. package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
  25. package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
  26. package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
  27. package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
  28. package/packages/extension/src/ask-user-tool.ts +63 -0
  29. package/packages/extension/src/bridge-context.ts +64 -0
  30. package/packages/extension/src/bridge.ts +926 -0
  31. package/packages/extension/src/command-handler.ts +538 -0
  32. package/packages/extension/src/connection.ts +204 -0
  33. package/packages/extension/src/dev-build.ts +39 -0
  34. package/packages/extension/src/event-forwarder.ts +40 -0
  35. package/packages/extension/src/flow-event-wiring.ts +102 -0
  36. package/packages/extension/src/git-info.ts +65 -0
  37. package/packages/extension/src/git-link-builder.ts +112 -0
  38. package/packages/extension/src/model-tracker.ts +56 -0
  39. package/packages/extension/src/pi-env.d.ts +23 -0
  40. package/packages/extension/src/process-metrics.ts +70 -0
  41. package/packages/extension/src/process-scanner.ts +396 -0
  42. package/packages/extension/src/prompt-expander.ts +87 -0
  43. package/packages/extension/src/provider-register.ts +276 -0
  44. package/packages/extension/src/server-auto-start.ts +87 -0
  45. package/packages/extension/src/server-launcher.ts +82 -0
  46. package/packages/extension/src/server-probe.ts +33 -0
  47. package/packages/extension/src/session-sync.ts +154 -0
  48. package/packages/extension/src/source-detector.ts +26 -0
  49. package/packages/extension/src/ui-proxy.ts +269 -0
  50. package/packages/extension/tsconfig.json +11 -0
  51. package/packages/server/package.json +37 -0
  52. package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
  53. package/packages/server/src/__tests__/auth.test.ts +224 -0
  54. package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
  55. package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
  56. package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
  57. package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
  58. package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
  59. package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
  60. package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
  61. package/packages/server/src/__tests__/config-api.test.ts +104 -0
  62. package/packages/server/src/__tests__/cors.test.ts +48 -0
  63. package/packages/server/src/__tests__/directory-service.test.ts +240 -0
  64. package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
  65. package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
  66. package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
  67. package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
  68. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
  69. package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
  70. package/packages/server/src/__tests__/extension-register.test.ts +61 -0
  71. package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
  72. package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
  73. package/packages/server/src/__tests__/git-operations.test.ts +251 -0
  74. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  75. package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
  76. package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
  77. package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
  78. package/packages/server/src/__tests__/json-store.test.ts +70 -0
  79. package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
  80. package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
  81. package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
  82. package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
  83. package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
  84. package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
  85. package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
  86. package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
  87. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
  88. package/packages/server/src/__tests__/package-routes.test.ts +172 -0
  89. package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
  90. package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
  91. package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
  92. package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
  93. package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
  94. package/packages/server/src/__tests__/process-manager.test.ts +184 -0
  95. package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
  96. package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
  97. package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
  98. package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
  99. package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
  100. package/packages/server/src/__tests__/server-pid.test.ts +89 -0
  101. package/packages/server/src/__tests__/session-api.test.ts +244 -0
  102. package/packages/server/src/__tests__/session-diff.test.ts +138 -0
  103. package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
  104. package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
  105. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
  106. package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
  107. package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
  108. package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
  109. package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
  110. package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
  111. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
  112. package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
  113. package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
  114. package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
  115. package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
  116. package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
  117. package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
  118. package/packages/server/src/__tests__/tunnel.test.ts +206 -0
  119. package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
  120. package/packages/server/src/auth-plugin.ts +302 -0
  121. package/packages/server/src/auth.ts +323 -0
  122. package/packages/server/src/browse.ts +55 -0
  123. package/packages/server/src/browser-gateway.ts +495 -0
  124. package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
  125. package/packages/server/src/browser-handlers/handler-context.ts +45 -0
  126. package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
  127. package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
  128. package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
  129. package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
  130. package/packages/server/src/cli.ts +347 -0
  131. package/packages/server/src/config-api.ts +130 -0
  132. package/packages/server/src/directory-service.ts +162 -0
  133. package/packages/server/src/editor-detection.ts +60 -0
  134. package/packages/server/src/editor-manager.ts +352 -0
  135. package/packages/server/src/editor-proxy.ts +134 -0
  136. package/packages/server/src/editor-registry.ts +108 -0
  137. package/packages/server/src/event-status-extraction.ts +131 -0
  138. package/packages/server/src/event-wiring.ts +589 -0
  139. package/packages/server/src/extension-register.ts +92 -0
  140. package/packages/server/src/git-operations.ts +200 -0
  141. package/packages/server/src/headless-pid-registry.ts +207 -0
  142. package/packages/server/src/idle-timer.ts +61 -0
  143. package/packages/server/src/json-store.ts +32 -0
  144. package/packages/server/src/localhost-guard.ts +117 -0
  145. package/packages/server/src/memory-event-store.ts +193 -0
  146. package/packages/server/src/memory-session-manager.ts +123 -0
  147. package/packages/server/src/meta-persistence.ts +64 -0
  148. package/packages/server/src/migrate-persistence.ts +195 -0
  149. package/packages/server/src/npm-search-proxy.ts +143 -0
  150. package/packages/server/src/oauth-callback-server.ts +177 -0
  151. package/packages/server/src/openspec-archive.ts +60 -0
  152. package/packages/server/src/package-manager-wrapper.ts +200 -0
  153. package/packages/server/src/pending-fork-registry.ts +53 -0
  154. package/packages/server/src/pending-load-manager.ts +110 -0
  155. package/packages/server/src/pending-resume-registry.ts +69 -0
  156. package/packages/server/src/pi-gateway.ts +419 -0
  157. package/packages/server/src/pi-resource-scanner.ts +369 -0
  158. package/packages/server/src/preferences-store.ts +116 -0
  159. package/packages/server/src/process-manager.ts +311 -0
  160. package/packages/server/src/provider-auth-handlers.ts +438 -0
  161. package/packages/server/src/provider-auth-storage.ts +200 -0
  162. package/packages/server/src/resolve-path.ts +12 -0
  163. package/packages/server/src/routes/editor-routes.ts +86 -0
  164. package/packages/server/src/routes/file-routes.ts +116 -0
  165. package/packages/server/src/routes/git-routes.ts +89 -0
  166. package/packages/server/src/routes/openspec-routes.ts +99 -0
  167. package/packages/server/src/routes/package-routes.ts +172 -0
  168. package/packages/server/src/routes/provider-auth-routes.ts +244 -0
  169. package/packages/server/src/routes/provider-routes.ts +101 -0
  170. package/packages/server/src/routes/route-deps.ts +23 -0
  171. package/packages/server/src/routes/session-routes.ts +91 -0
  172. package/packages/server/src/routes/system-routes.ts +271 -0
  173. package/packages/server/src/server-pid.ts +84 -0
  174. package/packages/server/src/server.ts +554 -0
  175. package/packages/server/src/session-api.ts +330 -0
  176. package/packages/server/src/session-bootstrap.ts +80 -0
  177. package/packages/server/src/session-diff.ts +178 -0
  178. package/packages/server/src/session-discovery.ts +134 -0
  179. package/packages/server/src/session-file-reader.ts +135 -0
  180. package/packages/server/src/session-order-manager.ts +73 -0
  181. package/packages/server/src/session-scanner.ts +233 -0
  182. package/packages/server/src/session-stats-reader.ts +99 -0
  183. package/packages/server/src/terminal-gateway.ts +51 -0
  184. package/packages/server/src/terminal-manager.ts +241 -0
  185. package/packages/server/src/tunnel.ts +329 -0
  186. package/packages/server/tsconfig.json +11 -0
  187. package/packages/shared/package.json +15 -0
  188. package/packages/shared/src/__tests__/config.test.ts +358 -0
  189. package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
  190. package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
  191. package/packages/shared/src/__tests__/protocol.test.ts +243 -0
  192. package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
  193. package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
  194. package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
  195. package/packages/shared/src/archive-types.ts +11 -0
  196. package/packages/shared/src/browser-protocol.ts +534 -0
  197. package/packages/shared/src/config.ts +245 -0
  198. package/packages/shared/src/diff-types.ts +41 -0
  199. package/packages/shared/src/editor-types.ts +18 -0
  200. package/packages/shared/src/mdns-discovery.ts +248 -0
  201. package/packages/shared/src/openspec-activity-detector.ts +109 -0
  202. package/packages/shared/src/openspec-poller.ts +96 -0
  203. package/packages/shared/src/protocol.ts +369 -0
  204. package/packages/shared/src/resolve-jiti.ts +43 -0
  205. package/packages/shared/src/rest-api.ts +255 -0
  206. package/packages/shared/src/server-identity.ts +51 -0
  207. package/packages/shared/src/session-meta.ts +86 -0
  208. package/packages/shared/src/state-replay.ts +174 -0
  209. package/packages/shared/src/stats-extractor.ts +54 -0
  210. package/packages/shared/src/terminal-types.ts +18 -0
  211. package/packages/shared/src/types.ts +351 -0
  212. package/packages/shared/tsconfig.json +8 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Pi Resource Scanner — discovers extensions, skills, and prompts
3
+ * from local (.pi/), global (~/.pi/agent/), and installed packages.
4
+ */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+ import { execSync } from "node:child_process";
9
+ import type { PiResource, PiResourceScope, PiPackageInfo, PiResourcesResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
10
+
11
+ // ── Frontmatter Parsing ─────────────────────────────────────────────
12
+
13
+ export function parseFrontmatter(
14
+ content: string,
15
+ fallbackFirstLine = false,
16
+ ): { name?: string; description?: string } {
17
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
18
+ if (!match) {
19
+ if (fallbackFirstLine) {
20
+ const firstLine = content.split(/\r?\n/).find((l) => l.trim().length > 0);
21
+ return { description: firstLine?.trim() };
22
+ }
23
+ return {};
24
+ }
25
+
26
+ const yaml = match[1];
27
+ const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim();
28
+
29
+ // Handle both single-line and multi-line (>) description
30
+ let description: string | undefined;
31
+ // Check for multi-line (> or |) first, then single-line
32
+ const multiMatch = yaml.match(/^description:\s*[>|]-?\s*\r?\n((?:[ \t]+.+(?:\r?\n|$))*)/m);
33
+ if (multiMatch) {
34
+ description = multiMatch[1]
35
+ .split(/\r?\n/)
36
+ .map((l) => l.trim())
37
+ .filter(Boolean)
38
+ .join(" ");
39
+ } else {
40
+ const singleLine = yaml.match(/^description:\s*(.+)$/m);
41
+ if (singleLine) {
42
+ description = singleLine[1].trim();
43
+ }
44
+ }
45
+
46
+ return { name, description };
47
+ }
48
+
49
+ // ── Directory Scanning Helpers ──────────────────────────────────────
50
+
51
+ function safeReaddir(dir: string): string[] {
52
+ try {
53
+ return fs.readdirSync(dir);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ function safeReadFile(filePath: string): string | undefined {
60
+ try {
61
+ return fs.readFileSync(filePath, "utf-8");
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ function safeIsDirectory(p: string): boolean {
68
+ try {
69
+ return fs.statSync(p).isDirectory();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function discoverSkills(skillsDir: string): PiResource[] {
76
+ const skills: PiResource[] = [];
77
+ for (const entry of safeReaddir(skillsDir)) {
78
+ const entryPath = path.join(skillsDir, entry);
79
+ if (safeIsDirectory(entryPath)) {
80
+ // Directory with SKILL.md
81
+ const skillFile = path.join(entryPath, "SKILL.md");
82
+ const content = safeReadFile(skillFile);
83
+ if (content) {
84
+ const fm = parseFrontmatter(content);
85
+ skills.push({
86
+ name: fm.name ?? entry,
87
+ description: fm.description,
88
+ filePath: skillFile,
89
+ type: "skill",
90
+ });
91
+ }
92
+ } else if (entry.endsWith(".md")) {
93
+ // Root .md file as single skill
94
+ const content = safeReadFile(entryPath);
95
+ const fm = content ? parseFrontmatter(content) : {};
96
+ skills.push({
97
+ name: fm.name ?? entry.replace(/\.md$/, ""),
98
+ description: fm.description,
99
+ filePath: entryPath,
100
+ type: "skill",
101
+ });
102
+ }
103
+ }
104
+ return skills;
105
+ }
106
+
107
+ function discoverExtensions(extDir: string): PiResource[] {
108
+ const extensions: PiResource[] = [];
109
+ for (const entry of safeReaddir(extDir)) {
110
+ const entryPath = path.join(extDir, entry);
111
+ if (entry.endsWith(".ts") || entry.endsWith(".js")) {
112
+ extensions.push({
113
+ name: entry.replace(/\.(ts|js)$/, ""),
114
+ filePath: entryPath,
115
+ type: "extension",
116
+ });
117
+ } else if (safeIsDirectory(entryPath)) {
118
+ const indexTs = path.join(entryPath, "index.ts");
119
+ const indexJs = path.join(entryPath, "index.js");
120
+ const indexFile = fs.existsSync(indexTs) ? indexTs : fs.existsSync(indexJs) ? indexJs : null;
121
+ if (indexFile) {
122
+ extensions.push({
123
+ name: entry,
124
+ filePath: indexFile,
125
+ type: "extension",
126
+ });
127
+ }
128
+ }
129
+ }
130
+ return extensions;
131
+ }
132
+
133
+ function discoverPrompts(promptsDir: string): PiResource[] {
134
+ const prompts: PiResource[] = [];
135
+ for (const entry of safeReaddir(promptsDir)) {
136
+ if (!entry.endsWith(".md")) continue;
137
+ const entryPath = path.join(promptsDir, entry);
138
+ if (safeIsDirectory(entryPath)) continue;
139
+ const content = safeReadFile(entryPath);
140
+ const fm = content ? parseFrontmatter(content, true) : {};
141
+ prompts.push({
142
+ name: entry.replace(/\.md$/, ""),
143
+ description: fm.description,
144
+ filePath: entryPath,
145
+ type: "prompt",
146
+ });
147
+ }
148
+ return prompts;
149
+ }
150
+
151
+ function emptyScope(): PiResourceScope {
152
+ return { extensions: [], skills: [], prompts: [] };
153
+ }
154
+
155
+ // ── Scope Scanners ──────────────────────────────────────────────────
156
+
157
+ export function scanLocalResources(cwd: string): PiResourceScope {
158
+ const piDir = path.join(cwd, ".pi");
159
+ if (!fs.existsSync(piDir)) return emptyScope();
160
+ return {
161
+ extensions: discoverExtensions(path.join(piDir, "extensions")),
162
+ skills: discoverSkills(path.join(piDir, "skills")),
163
+ prompts: discoverPrompts(path.join(piDir, "prompts")),
164
+ };
165
+ }
166
+
167
+ export function scanGlobalResources(globalDir: string): PiResourceScope {
168
+ if (!fs.existsSync(globalDir)) return emptyScope();
169
+ return {
170
+ extensions: discoverExtensions(path.join(globalDir, "extensions")),
171
+ skills: discoverSkills(path.join(globalDir, "skills")),
172
+ prompts: discoverPrompts(path.join(globalDir, "prompts")),
173
+ };
174
+ }
175
+
176
+ // ── Package Resolution ──────────────────────────────────────────────
177
+
178
+ let cachedNpmGlobalRoot: string | null = null;
179
+
180
+ function getNpmGlobalRoot(): string | null {
181
+ if (cachedNpmGlobalRoot !== null) return cachedNpmGlobalRoot;
182
+ try {
183
+ cachedNpmGlobalRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 10_000 }).trim();
184
+ return cachedNpmGlobalRoot;
185
+ } catch {
186
+ cachedNpmGlobalRoot = "";
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /** Visible for testing — reset cached npm root */
192
+ export function _resetNpmRootCache() {
193
+ cachedNpmGlobalRoot = null;
194
+ }
195
+
196
+ function resolvePackagePath(entry: string, settingsDir: string, scope: "local" | "global", cwd?: string): { resolved: string; source: string } | null {
197
+ if (typeof entry === "object") {
198
+ // Object-form package with source key
199
+ entry = (entry as any).source ?? "";
200
+ }
201
+
202
+ if (entry.startsWith("npm:")) {
203
+ const pkgName = entry.slice(4).replace(/@[^/]*$/, ""); // strip version
204
+ const npmRoot = getNpmGlobalRoot();
205
+ if (!npmRoot) return null;
206
+ return { resolved: path.join(npmRoot, pkgName), source: entry };
207
+ }
208
+
209
+ if (entry.startsWith("git:") || entry.startsWith("https://") || entry.startsWith("ssh://") || entry.startsWith("http://")) {
210
+ // Extract host/path from git URL
211
+ let url = entry.replace(/^git:/, "");
212
+ // Handle git@host:path format
213
+ url = url.replace(/^git@([^:]+):/, "$1/");
214
+ // Strip protocol
215
+ url = url.replace(/^(https?|ssh|git):\/\//, "");
216
+ // Strip auth
217
+ url = url.replace(/^[^@]+@/, "");
218
+ // Strip .git suffix and version ref
219
+ url = url.replace(/\.git$/, "").replace(/@[^/]*$/, "");
220
+
221
+ const baseDir = scope === "local" && cwd
222
+ ? path.join(cwd, ".pi", "git")
223
+ : path.join(os.homedir(), ".pi", "agent", "git");
224
+ return { resolved: path.join(baseDir, url), source: entry };
225
+ }
226
+
227
+ // Local path (relative or absolute)
228
+ if (path.isAbsolute(entry)) {
229
+ return { resolved: entry, source: entry };
230
+ }
231
+ return { resolved: path.resolve(settingsDir, entry), source: entry };
232
+ }
233
+
234
+ function scanPackageDir(pkgDir: string): PiResourceScope {
235
+ // Try pi manifest from package.json
236
+ const pkgJsonPath = path.join(pkgDir, "package.json");
237
+ const pkgJsonStr = safeReadFile(pkgJsonPath);
238
+ if (pkgJsonStr) {
239
+ try {
240
+ const pkgJson = JSON.parse(pkgJsonStr);
241
+ if (pkgJson.pi) {
242
+ const scope = emptyScope();
243
+ if (Array.isArray(pkgJson.pi.extensions)) {
244
+ for (const extPath of pkgJson.pi.extensions) {
245
+ const resolved = path.resolve(pkgDir, extPath);
246
+ if (fs.existsSync(resolved)) {
247
+ if (safeIsDirectory(resolved)) {
248
+ scope.extensions.push(...discoverExtensions(resolved));
249
+ } else {
250
+ const name = path.basename(resolved).replace(/\.(ts|js)$/, "");
251
+ scope.extensions.push({ name, filePath: resolved, type: "extension" });
252
+ }
253
+ }
254
+ }
255
+ }
256
+ if (Array.isArray(pkgJson.pi.skills)) {
257
+ for (const skillPath of pkgJson.pi.skills) {
258
+ const resolved = path.resolve(pkgDir, skillPath);
259
+ if (safeIsDirectory(resolved)) {
260
+ scope.skills.push(...discoverSkills(resolved));
261
+ }
262
+ }
263
+ }
264
+ if (Array.isArray(pkgJson.pi.prompts)) {
265
+ for (const promptPath of pkgJson.pi.prompts) {
266
+ const resolved = path.resolve(pkgDir, promptPath);
267
+ if (safeIsDirectory(resolved)) {
268
+ scope.prompts.push(...discoverPrompts(resolved));
269
+ }
270
+ }
271
+ }
272
+ return scope;
273
+ }
274
+ } catch {
275
+ // Invalid JSON, fall through to conventional
276
+ }
277
+ }
278
+
279
+ // Conventional directory discovery
280
+ return {
281
+ extensions: discoverExtensions(path.join(pkgDir, "extensions")),
282
+ skills: discoverSkills(path.join(pkgDir, "skills")),
283
+ prompts: discoverPrompts(path.join(pkgDir, "prompts")),
284
+ };
285
+ }
286
+
287
+ function readSettingsPackages(settingsPath: string): string[] {
288
+ const content = safeReadFile(settingsPath);
289
+ if (!content) return [];
290
+ try {
291
+ const settings = JSON.parse(content);
292
+ if (!Array.isArray(settings.packages)) return [];
293
+ return settings.packages.map((p: string | { source: string }) =>
294
+ typeof p === "string" ? p : p.source,
295
+ );
296
+ } catch {
297
+ return [];
298
+ }
299
+ }
300
+
301
+ export function resolvePackages(
302
+ entries: string[],
303
+ settingsDir: string,
304
+ scope: "local" | "global" = "local",
305
+ cwd?: string,
306
+ ): PiPackageInfo[] {
307
+ const packages: PiPackageInfo[] = [];
308
+ const seen = new Set<string>();
309
+
310
+ for (const entry of entries) {
311
+ const resolved = resolvePackagePath(entry, settingsDir, scope, cwd);
312
+ if (!resolved || !fs.existsSync(resolved.resolved)) continue;
313
+
314
+ const realDir = resolved.resolved;
315
+ if (seen.has(realDir)) continue;
316
+ seen.add(realDir);
317
+
318
+ // Read package.json for metadata
319
+ const pkgJsonStr = safeReadFile(path.join(realDir, "package.json"));
320
+ let name = path.basename(realDir);
321
+ let description: string | undefined;
322
+ if (pkgJsonStr) {
323
+ try {
324
+ const pkgJson = JSON.parse(pkgJsonStr);
325
+ name = pkgJson.name ?? name;
326
+ description = pkgJson.description;
327
+ } catch { /* ignore */ }
328
+ }
329
+
330
+ const resources = scanPackageDir(realDir);
331
+ packages.push({ name, description, source: resolved.source, resources, scope });
332
+ }
333
+
334
+ return packages;
335
+ }
336
+
337
+ // ── Main Entry Point ────────────────────────────────────────────────
338
+
339
+ export interface ScanOptions {
340
+ globalDir?: string;
341
+ }
342
+
343
+ export async function scanPiResources(cwd: string, options?: ScanOptions): Promise<PiResourcesResult> {
344
+ const globalDir = options?.globalDir ?? path.join(os.homedir(), ".pi", "agent");
345
+
346
+ const local = scanLocalResources(cwd);
347
+ const global = scanGlobalResources(globalDir);
348
+
349
+ // Collect package entries from both settings files
350
+ const localSettingsPath = path.join(cwd, ".pi", "settings.json");
351
+ const globalSettingsPath = path.join(globalDir, "settings.json");
352
+
353
+ const localPackageEntries = readSettingsPackages(localSettingsPath);
354
+ const globalPackageEntries = readSettingsPackages(globalSettingsPath);
355
+
356
+ // Local packages first (they win on dedup)
357
+ const localPackages = resolvePackages(localPackageEntries, path.dirname(localSettingsPath), "local", cwd);
358
+ const globalPackages = resolvePackages(globalPackageEntries, path.dirname(globalSettingsPath), "global");
359
+
360
+ // Deduplicate: local wins
361
+ const localNames = new Set(localPackages.map((p) => p.name));
362
+ const dedupedGlobal = globalPackages.filter((p) => !localNames.has(p.name));
363
+
364
+ return {
365
+ local,
366
+ global,
367
+ packages: [...localPackages, ...dedupedGlobal],
368
+ };
369
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Global UI preferences store — JSON-backed with debounced writes.
3
+ * Stores cross-session state: pinned directories and session ordering.
4
+ * Replaces `state-store.ts` (hidden state moved to per-session `.meta.json`).
5
+ */
6
+ import path from "node:path";
7
+ import { CONFIG_DIR } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
+ import { readJsonFile, writeJsonFile } from "./json-store.js";
9
+ import { safeRealpathSync } from "./resolve-path.js";
10
+
11
+ export const PREFERENCES_FILE = path.join(CONFIG_DIR, "preferences.json");
12
+
13
+ interface PreferencesData {
14
+ sessionOrder: Record<string, string[]>;
15
+ pinnedDirectories: string[];
16
+ }
17
+
18
+ export interface PreferencesStore {
19
+ getSessionOrder(): Record<string, string[]>;
20
+ setSessionOrder(order: Record<string, string[]>): void;
21
+ getPinnedDirectories(): string[];
22
+ setPinnedDirectories(dirs: string[]): void;
23
+ pinDirectory(dirPath: string): void;
24
+ unpinDirectory(dirPath: string): void;
25
+ reorderPinnedDirs(dirs: string[]): void;
26
+ flush(): void;
27
+ dispose(): void;
28
+ }
29
+
30
+ const DEBOUNCE_MS = 1000;
31
+
32
+ export function createPreferencesStore(filePath: string = PREFERENCES_FILE): PreferencesStore {
33
+ const data: PreferencesData = readJsonFile<PreferencesData>(filePath, { sessionOrder: {}, pinnedDirectories: [] });
34
+ let sessionOrder: Record<string, string[]> = data.sessionOrder ?? {};
35
+ // Resolve symlinks in stored pinned paths on load
36
+ const rawPinned = data.pinnedDirectories ?? [];
37
+ let pinnedDirectories: string[] = rawPinned.map(safeRealpathSync);
38
+ // Deduplicate in case symlinks resolved to the same path
39
+ pinnedDirectories = [...new Set(pinnedDirectories)];
40
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
41
+ let dirty = pinnedDirectories.length !== rawPinned.length || pinnedDirectories.some((p, i) => p !== rawPinned[i]);
42
+
43
+ function scheduleSave(): void {
44
+ dirty = true;
45
+ if (debounceTimer) return;
46
+ debounceTimer = setTimeout(() => {
47
+ debounceTimer = null;
48
+ if (dirty) {
49
+ dirty = false;
50
+ writeJsonFile(filePath, { sessionOrder, pinnedDirectories } satisfies PreferencesData);
51
+ }
52
+ }, DEBOUNCE_MS);
53
+ }
54
+
55
+ function flushNow(): void {
56
+ if (debounceTimer) {
57
+ clearTimeout(debounceTimer);
58
+ debounceTimer = null;
59
+ }
60
+ if (dirty) {
61
+ dirty = false;
62
+ writeJsonFile(filePath, { sessionOrder, pinnedDirectories } satisfies PreferencesData);
63
+ }
64
+ }
65
+
66
+ if (dirty) scheduleSave();
67
+
68
+ return {
69
+ getSessionOrder(): Record<string, string[]> {
70
+ return sessionOrder;
71
+ },
72
+
73
+ setSessionOrder(order: Record<string, string[]>): void {
74
+ sessionOrder = order;
75
+ scheduleSave();
76
+ },
77
+
78
+ getPinnedDirectories(): string[] {
79
+ return [...pinnedDirectories];
80
+ },
81
+
82
+ setPinnedDirectories(dirs: string[]): void {
83
+ pinnedDirectories = [...dirs];
84
+ scheduleSave();
85
+ },
86
+
87
+ pinDirectory(dirPath: string): void {
88
+ if (pinnedDirectories.includes(dirPath)) return;
89
+ pinnedDirectories.push(dirPath);
90
+ scheduleSave();
91
+ },
92
+
93
+ unpinDirectory(dirPath: string): void {
94
+ const idx = pinnedDirectories.indexOf(dirPath);
95
+ if (idx === -1) return;
96
+ pinnedDirectories.splice(idx, 1);
97
+ scheduleSave();
98
+ },
99
+
100
+ reorderPinnedDirs(dirs: string[]): void {
101
+ pinnedDirectories = [...dirs];
102
+ scheduleSave();
103
+ },
104
+
105
+ flush(): void {
106
+ flushNow();
107
+ },
108
+
109
+ dispose(): void {
110
+ if (debounceTimer) {
111
+ clearTimeout(debounceTimer);
112
+ debounceTimer = null;
113
+ }
114
+ },
115
+ };
116
+ }