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

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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * npm tool module — Recipe-based API for the npm CLI.
3
+ *
4
+ * Covers the subset of npm operations the dashboard actually invokes:
5
+ * - `npm root -g` (resolve the global node_modules path)
6
+ * - `npm outdated` (check for updates, local or global)
7
+ * - `npm install` (install a package, local or global)
8
+ * - `npm view <pkg> version` (read upstream version)
9
+ *
10
+ * See change: platform-command-executor.
11
+ */
12
+ import { run, unwrap, type Recipe, type Result } from "./runner.js";
13
+
14
+ const NPM_TIMEOUT_FAST = 10_000;
15
+ const NPM_TIMEOUT_INSTALL = 120_000;
16
+
17
+ interface WithCwd {
18
+ cwd?: string;
19
+ }
20
+
21
+ // ── Recipes ─────────────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * `npm root -g` — returns the absolute path to the global node_modules.
25
+ * Cached by callers (it's stable per Node install).
26
+ */
27
+ export const NPM_ROOT_GLOBAL: Recipe<Record<string, never>, string> = {
28
+ argv: () => ["npm", "root", "-g"],
29
+ parse: (out) => out.trim(),
30
+ timeout: NPM_TIMEOUT_FAST,
31
+ };
32
+
33
+ /**
34
+ * `npm outdated <pkg> --json` (or without `<pkg>` for project-wide).
35
+ * npm exits 1 when updates are available and 0 when up-to-date — we tolerate
36
+ * exit 1 so callers see the JSON body either way.
37
+ */
38
+ export const NPM_OUTDATED: Recipe<WithCwd & { pkg?: string }, unknown | null> = {
39
+ argv: ({ pkg }) => pkg === undefined
40
+ ? ["npm", "outdated", "--json"]
41
+ : ["npm", "outdated", pkg, "--json"],
42
+ parse: (out) => {
43
+ const trimmed = out.trim();
44
+ if (!trimmed) return null;
45
+ try { return JSON.parse(trimmed); } catch { return null; }
46
+ },
47
+ timeout: NPM_TIMEOUT_FAST,
48
+ tolerate: [1],
49
+ };
50
+
51
+ /**
52
+ * `npm outdated -g <pkg> --json`. Same exit-1 tolerance.
53
+ */
54
+ export const NPM_OUTDATED_GLOBAL: Recipe<{ pkg?: string }, unknown | null> = {
55
+ argv: ({ pkg }) => pkg === undefined
56
+ ? ["npm", "outdated", "-g", "--json"]
57
+ : ["npm", "outdated", "-g", pkg, "--json"],
58
+ parse: (out) => {
59
+ const trimmed = out.trim();
60
+ if (!trimmed) return null;
61
+ try { return JSON.parse(trimmed); } catch { return null; }
62
+ },
63
+ timeout: NPM_TIMEOUT_FAST,
64
+ tolerate: [1],
65
+ };
66
+
67
+ /**
68
+ * `npm install <pkg>@<version>` — local install. Long timeout.
69
+ */
70
+ export const NPM_INSTALL: Recipe<WithCwd & { pkg: string; version?: string }, string> = {
71
+ argv: ({ pkg, version }) => ["npm", "install", version ? `${pkg}@${version}` : pkg],
72
+ parse: (out) => out,
73
+ timeout: NPM_TIMEOUT_INSTALL,
74
+ };
75
+
76
+ /**
77
+ * `npm install -g <pkg>@<version>` — global install.
78
+ */
79
+ export const NPM_INSTALL_GLOBAL: Recipe<{ pkg: string; version?: string }, string> = {
80
+ argv: ({ pkg, version }) => ["npm", "install", "-g", version ? `${pkg}@${version}` : pkg],
81
+ parse: (out) => out,
82
+ timeout: NPM_TIMEOUT_INSTALL,
83
+ };
84
+
85
+ /**
86
+ * `npm view <pkg> version` — the single-value shorthand for "latest version".
87
+ */
88
+ export const NPM_VIEW_VERSION: Recipe<{ pkg: string }, string> = {
89
+ argv: ({ pkg }) => ["npm", "view", pkg, "version"],
90
+ parse: (out) => out.trim(),
91
+ timeout: NPM_TIMEOUT_FAST,
92
+ };
93
+
94
+ export const NPM_RECIPES = {
95
+ NPM_ROOT_GLOBAL,
96
+ NPM_OUTDATED,
97
+ NPM_OUTDATED_GLOBAL,
98
+ NPM_INSTALL,
99
+ NPM_INSTALL_GLOBAL,
100
+ NPM_VIEW_VERSION,
101
+ } as const;
102
+
103
+ // ── Public API ──────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * `npm root -g`. Returns a `Result` for explicit error handling; use
107
+ * `rootGlobalOr` for best-effort semantics.
108
+ *
109
+ * Previous versions cached the result in a module-level variable. That
110
+ * cache is now owned by `ToolRegistry` (the runner consults the
111
+ * registry for every resolved binary including `npm` itself). Cache
112
+ * invalidation flows through `registry.rescan()`.
113
+ *
114
+ * See change: consolidate-tool-resolution.
115
+ */
116
+ export function rootGlobal(): Result<string> {
117
+ return run(NPM_ROOT_GLOBAL, {}, {});
118
+ }
119
+
120
+ /**
121
+ * Test-only no-op kept for backward compatibility with existing test
122
+ * suites. The `cachedGlobalRoot` variable no longer exists.
123
+ */
124
+ export function _resetNpmRootCache(): void { /* no-op */ }
125
+
126
+ export function outdated(input: WithCwd & { pkg?: string }): Result<unknown | null> {
127
+ return run(NPM_OUTDATED, input, { cwd: input.cwd });
128
+ }
129
+
130
+ export function outdatedGlobal(input: { pkg?: string } = {}): Result<unknown | null> {
131
+ return run(NPM_OUTDATED_GLOBAL, input, {});
132
+ }
133
+
134
+ export function install(input: WithCwd & { pkg: string; version?: string }): Result<string> {
135
+ return run(NPM_INSTALL, input, { cwd: input.cwd });
136
+ }
137
+
138
+ export function installGlobal(input: { pkg: string; version?: string }): Result<string> {
139
+ return run(NPM_INSTALL_GLOBAL, input, {});
140
+ }
141
+
142
+ export function viewVersion(input: { pkg: string }): Result<string> {
143
+ return run(NPM_VIEW_VERSION, input, {});
144
+ }
145
+
146
+ // ── Best-effort variants ────────────────────────────────────────────────────
147
+
148
+ export function rootGlobalOr(fallback = ""): string {
149
+ return unwrap(rootGlobal(), fallback);
150
+ }
151
+
152
+ export function outdatedOr(input: WithCwd & { pkg?: string }, fallback: unknown | null = null): unknown | null {
153
+ return unwrap(outdated(input), fallback);
154
+ }
155
+
156
+ export function outdatedGlobalOr(input: { pkg?: string } = {}, fallback: unknown | null = null): unknown | null {
157
+ return unwrap(outdatedGlobal(input), fallback);
158
+ }
159
+
160
+ export function viewVersionOr(input: { pkg: string }, fallback = ""): string {
161
+ return unwrap(viewVersion(input), fallback);
162
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * OpenSpec tool module — Recipe-based API for the openspec CLI.
3
+ *
4
+ * Replaces the ad-hoc spawnSync/execFile calls in `openspec-poller.ts`
5
+ * with typed Recipes executed through the runner. The higher-level
6
+ * `pollOpenSpec` / `pollOpenSpecAsync` functions remain in
7
+ * `openspec-poller.ts` (they aggregate list + per-change status into
8
+ * the dashboard's OpenSpecData shape) and now use these primitives.
9
+ *
10
+ * See change: platform-command-executor.
11
+ */
12
+ import { run, unwrap, type Recipe, type Result } from "./runner.js";
13
+
14
+ const OPENSPEC_TIMEOUT = 10_000;
15
+
16
+ interface WithCwd {
17
+ cwd: string;
18
+ }
19
+
20
+ /** Parse JSON from stdout; returns null on parse failure. */
21
+ function parseJsonOrNull(out: string): unknown | null {
22
+ const trimmed = out.trim();
23
+ if (!trimmed) return null;
24
+ try {
25
+ return JSON.parse(trimmed);
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ // ── Recipes ─────────────────────────────────────────────────────────────────
32
+
33
+ export const OPENSPEC_LIST: Recipe<WithCwd, unknown | null> = {
34
+ argv: () => ["openspec", "list", "--json"],
35
+ parse: parseJsonOrNull,
36
+ timeout: OPENSPEC_TIMEOUT,
37
+ };
38
+
39
+ export const OPENSPEC_STATUS: Recipe<WithCwd & { change: string }, unknown | null> = {
40
+ argv: ({ change }) => ["openspec", "status", "--change", change, "--json"],
41
+ parse: parseJsonOrNull,
42
+ timeout: OPENSPEC_TIMEOUT,
43
+ };
44
+
45
+ /**
46
+ * `openspec archive --completed` — bulk-archives all completed changes.
47
+ * Stdout is human-readable (not JSON); callers typically don't parse it,
48
+ * they just await success/failure.
49
+ */
50
+ export const OPENSPEC_ARCHIVE_COMPLETED: Recipe<WithCwd, string> = {
51
+ argv: () => ["openspec", "archive", "--completed"],
52
+ parse: (out) => out,
53
+ // Archive operations can be slow when many changes are processed.
54
+ timeout: 30_000,
55
+ };
56
+
57
+ export const OPENSPEC_RECIPES = {
58
+ OPENSPEC_LIST,
59
+ OPENSPEC_STATUS,
60
+ OPENSPEC_ARCHIVE_COMPLETED,
61
+ } as const;
62
+
63
+ // ── Public API ──────────────────────────────────────────────────────────────
64
+
65
+ /** Run `openspec list --json` and return the parsed JSON, or null on failure. */
66
+ export function list(input: WithCwd): Result<unknown | null> {
67
+ return run(OPENSPEC_LIST, input, { cwd: input.cwd });
68
+ }
69
+
70
+ /** Run `openspec status --change <name> --json` and return parsed JSON or null. */
71
+ export function status(input: WithCwd & { change: string }): Result<unknown | null> {
72
+ return run(OPENSPEC_STATUS, input, { cwd: input.cwd });
73
+ }
74
+
75
+ /** Run `openspec archive --completed`. Returns raw stdout on success. */
76
+ export function archiveCompleted(input: WithCwd): Result<string> {
77
+ return run(OPENSPEC_ARCHIVE_COMPLETED, input, { cwd: input.cwd });
78
+ }
79
+
80
+ // ── Best-effort variants (mirror the pattern established in git.ts) ─────────
81
+
82
+ export function listOr(input: WithCwd, fallback: unknown | null = null): unknown | null {
83
+ return unwrap(list(input), fallback);
84
+ }
85
+
86
+ export function statusOr(
87
+ input: WithCwd & { change: string },
88
+ fallback: unknown | null = null,
89
+ ): unknown | null {
90
+ return unwrap(status(input), fallback);
91
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * OS-aware filesystem path primitives.
3
+ *
4
+ * The dashboard uses paths in three places that need OS-correct behaviour:
5
+ * 1. Pin/unpin directory storage (server-side).
6
+ * 2. Session grouping — matching a session's `cwd` against pinned entries.
7
+ * 3. Path picker UI — parsing user-typed input.
8
+ *
9
+ * This module is the single source of truth. All exported helpers that
10
+ * depend on OS conventions take a trailing `platform: NodeJS.Platform`
11
+ * parameter defaulting to `process.platform` — tests pass it explicitly
12
+ * to exercise both Windows and Unix branches without mutating
13
+ * `process.platform`.
14
+ *
15
+ * ISOMORPHIC: implemented with string operations only (no `node:path`)
16
+ * so the module loads in the browser. The client imports `normalizePath`
17
+ * and `parsePathInput` directly; using `node:path` would have forced
18
+ * Vite to externalize the import and crash the SPA at load time.
19
+ *
20
+ * Windows specifics:
21
+ * - Each drive letter (A:, B:, …, Z:) is a distinct filesystem root.
22
+ * `samePath` NEVER merges different drives.
23
+ * - Drive letters are case-insensitive (`B:\` == `b:\`).
24
+ * - Path components are case-insensitive on NTFS (default) and HFS+.
25
+ * - UNC paths (`\\server\share`) are distinct from drive-letter paths.
26
+ * - Bare drive-relative input (`B:`, `B:Dev`) is defensively treated
27
+ * as drive-root-plus-partial, NOT as the B-drive's current directory
28
+ * (which is cwd-dependent and useless in a pin dialog).
29
+ *
30
+ * See change: platform-path-normalization.
31
+ */
32
+
33
+ // ── Helpers ────────────────────────────────────────────────────────────────
34
+
35
+ /** True if input is a Windows drive-letter form (`B:`, `B:Dev`) without separator. */
36
+ function isDriveLetterForm(value: string): boolean {
37
+ return /^[A-Za-z]:(?![\\/])/.test(value);
38
+ }
39
+
40
+ /** Extract the `B:` prefix from `B:Dev`, else null. */
41
+ function driveLetterPrefix(value: string): string | null {
42
+ const m = value.match(/^([A-Za-z]:)(?![\\/])/);
43
+ return m ? m[1] : null;
44
+ }
45
+
46
+ /** Detect the root portion of a path. Returns "" when no root. */
47
+ function getRoot(p: string, platform: NodeJS.Platform): string {
48
+ if (platform === "win32") {
49
+ // UNC: \\server\share (captures up to the share name, no trailing sep)
50
+ const unc = p.match(/^(?:\\\\|\/\/)([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/);
51
+ if (unc) return `\\\\${unc[1]}\\${unc[2]}\\`;
52
+ // Drive root: "C:\" or "C:/"
53
+ const drive = p.match(/^([A-Za-z]:)[\\/]/);
54
+ if (drive) return `${drive[1]}\\`;
55
+ return "";
56
+ }
57
+ // POSIX
58
+ return p.startsWith("/") ? "/" : "";
59
+ }
60
+
61
+ /**
62
+ * Split a path into segments, collapsing `.` and `..`. Operates on a
63
+ * rootless remainder; caller is responsible for re-prepending the root.
64
+ */
65
+ function normalizeSegments(rest: string, sep: string): string[] {
66
+ const split = rest.split(/[\\/]+/).filter((s) => s.length > 0);
67
+ const out: string[] = [];
68
+ for (const seg of split) {
69
+ if (seg === ".") continue;
70
+ if (seg === "..") {
71
+ if (out.length > 0 && out[out.length - 1] !== "..") out.pop();
72
+ // Rootless `..` that can't be resolved stays (we only call this with
73
+ // rootful paths via getRoot, so this arm is mostly defensive).
74
+ continue;
75
+ }
76
+ out.push(seg);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // ── Public API ──────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Canonicalize a path to the OS-native form:
85
+ * - Separators match the OS (`\\` on win32, `/` elsewhere).
86
+ * - Redundant separators collapsed.
87
+ * - `.` and `..` segments resolved.
88
+ * - Trailing separator removed EXCEPT for roots.
89
+ * - Original case preserved (NO lowercasing).
90
+ *
91
+ * Windows subtleties:
92
+ * - Bare drive-letter input (`B:`, `B:Dev`) is treated defensively as
93
+ * drive-rooted (`B:\` / `B:\Dev`), NOT as cwd-relative on that drive
94
+ * (which would be useless for a pin dialog — the dashboard's
95
+ * `process.cwd()` has no relationship to what the user typed).
96
+ * - UNC paths are preserved as-is (with the `\\server\share\` root).
97
+ */
98
+ export function normalizePath(
99
+ p: string,
100
+ platform: NodeJS.Platform = process.platform,
101
+ ): string {
102
+ if (!p) return p;
103
+
104
+ if (platform === "win32") {
105
+ // Handle drive-relative forms defensively.
106
+ if (isDriveLetterForm(p)) {
107
+ const prefix = driveLetterPrefix(p)!; // "B:"
108
+ const rest = p.slice(prefix.length);
109
+ if (!rest) return prefix + "\\"; // bare "B:" → "B:\\"
110
+ // "B:Dev" → normalize as if it were "B:\\Dev"
111
+ return normalizePath(prefix + "\\" + rest, "win32");
112
+ }
113
+
114
+ const root = getRoot(p, "win32");
115
+ if (root) {
116
+ const rest = p.slice(root.length);
117
+ const segments = normalizeSegments(rest, "\\");
118
+ if (segments.length === 0) return root;
119
+ // Drive root: "C:\" → segments joined with \ after root (no extra sep).
120
+ // UNC root: "\\server\share\" → same pattern.
121
+ return root + segments.join("\\");
122
+ }
123
+ // No root detected — relative path. Normalize separators + segments,
124
+ // leave without a leading root.
125
+ const segments = normalizeSegments(p, "\\");
126
+ return segments.join("\\");
127
+ }
128
+
129
+ // POSIX
130
+ const root = getRoot(p, platform);
131
+ if (root) {
132
+ const segments = normalizeSegments(p.slice(root.length), "/");
133
+ if (segments.length === 0) return root;
134
+ return root + segments.join("/");
135
+ }
136
+ const segments = normalizeSegments(p, "/");
137
+ return segments.join("/");
138
+ }
139
+
140
+ /**
141
+ * Filesystem-level equality.
142
+ * - win32/darwin: case-insensitive (Windows NTFS + macOS HFS+ defaults).
143
+ * - linux: case-sensitive.
144
+ *
145
+ * Runs both inputs through `normalizePath` first so separator and
146
+ * trailing-separator drift is tolerated uniformly. Cross-drive safety
147
+ * on Windows is automatic — the drive letter is preserved and compared.
148
+ */
149
+ export function samePath(
150
+ a: string,
151
+ b: string,
152
+ platform: NodeJS.Platform = process.platform,
153
+ ): boolean {
154
+ if (!a || !b) return a === b;
155
+ const na = normalizePath(a, platform);
156
+ const nb = normalizePath(b, platform);
157
+ if (platform === "linux") return na === nb;
158
+ return na.toLowerCase() === nb.toLowerCase();
159
+ }
160
+
161
+ /**
162
+ * Parse user-typed path input into `{ parent, partial }`:
163
+ * - `parent` is the directory to browse.
164
+ * - `partial` is the in-progress filter / typed segment after `parent`.
165
+ *
166
+ * Handles Windows drive-letter roots, UNC roots, Unix roots, mixed
167
+ * separators, and trailing separators.
168
+ */
169
+ export function parsePathInput(
170
+ value: string,
171
+ platform: NodeJS.Platform = process.platform,
172
+ ): { parent: string; partial: string } {
173
+ if (!value) return { parent: platform === "win32" ? "" : "/", partial: "" };
174
+
175
+ if (platform === "win32") {
176
+ // Bare drive letter "B:" → drive root.
177
+ if (/^[A-Za-z]:$/.test(value)) {
178
+ return { parent: value[0] + ":\\", partial: "" };
179
+ }
180
+ // Drive-relative "B:Dev" → drive root + partial.
181
+ if (isDriveLetterForm(value)) {
182
+ const prefix = driveLetterPrefix(value)!;
183
+ return { parent: prefix + "\\", partial: value.slice(prefix.length) };
184
+ }
185
+
186
+ const lastBackslash = value.lastIndexOf("\\");
187
+ const lastForward = value.lastIndexOf("/");
188
+ const lastSep = Math.max(lastBackslash, lastForward);
189
+
190
+ if (lastSep < 0) {
191
+ // No separator — treat whole input as partial.
192
+ return { parent: "", partial: value };
193
+ }
194
+
195
+ if (lastSep === value.length - 1) {
196
+ // Ends with separator.
197
+ const parent = value.slice(0, lastSep);
198
+ if (/^[A-Za-z]:$/.test(parent)) return { parent: parent + "\\", partial: "" };
199
+ return { parent: normalizePath(parent, "win32"), partial: "" };
200
+ }
201
+
202
+ const parent = value.slice(0, lastSep);
203
+ const partial = value.slice(lastSep + 1);
204
+ const normalizedParent = /^[A-Za-z]:$/.test(parent)
205
+ ? parent + "\\"
206
+ : normalizePath(parent, "win32");
207
+ return { parent: normalizedParent, partial };
208
+ }
209
+
210
+ // POSIX
211
+ if (value === "/") return { parent: "/", partial: "" };
212
+ if (value.endsWith("/")) {
213
+ const parent = value.slice(0, -1) || "/";
214
+ return { parent, partial: "" };
215
+ }
216
+ const lastSep = value.lastIndexOf("/");
217
+ if (lastSep < 0) return { parent: "/", partial: value };
218
+ const parent = value.slice(0, lastSep) || "/";
219
+ const partial = value.slice(lastSep + 1);
220
+ return { parent, partial };
221
+ }
222
+
223
+ /** Append the OS-native separator to a path if not already terminated. */
224
+ export function withTrailingSep(
225
+ p: string,
226
+ platform: NodeJS.Platform = process.platform,
227
+ ): string {
228
+ if (!p) return p;
229
+ const sep = platform === "win32" ? "\\" : "/";
230
+ if (p.endsWith("\\") || p.endsWith("/")) return p;
231
+ return p + sep;
232
+ }
233
+
234
+ /** Join two path segments with the OS-native separator. */
235
+ export function joinForDisplay(
236
+ parent: string,
237
+ child: string,
238
+ platform: NodeJS.Platform = process.platform,
239
+ ): string {
240
+ if (!parent) return child;
241
+ if (!child) return parent;
242
+ const sep = platform === "win32" ? "\\" : "/";
243
+ const parentTrimmed = parent.replace(/[\\/]+$/, "");
244
+ const childTrimmed = child.replace(/^[\\/]+/, "");
245
+ // Preserve root's trailing sep — `C:\` + `Users` → `C:\Users`, not `C:Users`.
246
+ if (platform === "win32" && /^[A-Za-z]:$/.test(parentTrimmed)) {
247
+ return parentTrimmed + "\\" + childTrimmed;
248
+ }
249
+ if (parentTrimmed === "") return sep + childTrimmed; // POSIX root case
250
+ return parentTrimmed + sep + childTrimmed;
251
+ }
252
+
253
+ /**
254
+ * True iff `resolved` is a filesystem root on `platform`. Used by
255
+ * server-side `browse.ts` to compute `parent = null` uniformly
256
+ * (replacing the Unix-only `resolved === "/"` check).
257
+ */
258
+ export function isFilesystemRoot(
259
+ resolved: string,
260
+ platform: NodeJS.Platform = process.platform,
261
+ ): boolean {
262
+ if (!resolved) return false;
263
+ if (platform === "win32") {
264
+ // Drive-letter root: "C:\" (also accept forward slash form)
265
+ if (/^[A-Za-z]:[\\/]$/.test(resolved)) return true;
266
+ // UNC root: "\\server\share" with optional trailing sep
267
+ if (/^\\\\[^\\]+\\[^\\]+\\?$/.test(resolved)) return true;
268
+ // Bare separator as "current drive root" — Node's path.dirname("/")
269
+ // returns "/" even on Windows, and listDirectories("/") is a valid
270
+ // call for "root of the current drive". Treat it as a root so the
271
+ // picker doesn't show a useless `..` entry.
272
+ if (resolved === "/" || resolved === "\\") return true;
273
+ return false;
274
+ }
275
+ return resolved === "/";
276
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Process identification primitives — find PIDs by command-line marker,
3
+ * check if a PID looks like a pi-related process.
4
+ *
5
+ * Every OS-dependent helper accepts injectable `platform` and `exec`
6
+ * parameters, defaulting to `process.platform` and a safe `execSync`.
7
+ * Tests exercise both branches without mutating `process.platform`.
8
+ *
9
+ * Windows branches are intentional stubs today: there is no cheap,
10
+ * format-stable cross-command way to inspect a PID's command line
11
+ * (tasklist /V is slow and locale-dependent). Windows pi-ness is
12
+ * verified via `headlessPidRegistry` at the server level, which tracks
13
+ * PID → session identity directly at spawn time. Future work can
14
+ * extend these Windows branches with WMIC / PowerShell probing in
15
+ * ONE place (here) instead of the three scattered inline checks in
16
+ * session-action-handler.ts.
17
+ *
18
+ * See change: consolidate-windows-spawn-and-platform-handlers.
19
+ */
20
+ import { execSync } from "./exec.js";
21
+
22
+ type ExecFn = (cmd: string, opts: { encoding: "utf-8"; timeout?: number; stdio?: any }) => string;
23
+
24
+ export interface ProcessIdentifyOpts {
25
+ /** Override platform (defaults to process.platform). */
26
+ platform?: NodeJS.Platform;
27
+ /** Override execSync (for tests). */
28
+ exec?: ExecFn;
29
+ }
30
+
31
+ function defaultExec(cmd: string, opts: { encoding: "utf-8"; timeout?: number; stdio?: any }): string {
32
+ return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
33
+ }
34
+
35
+ // ── Pattern matcher ─────────────────────────────────────────────────────────
36
+
37
+ /** Returns true iff the given command-line string references pi or node. */
38
+ export function isPiCommandLine(commandLine: string): boolean {
39
+ return /\bpi\b|\bnode\b/.test(commandLine);
40
+ }
41
+
42
+ // ── findPidByMarker ─────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Find PIDs whose command line contains `marker`. Unix uses ps|grep;
46
+ * Windows returns `[]` (command-line lookup is delegated to
47
+ * headlessPidRegistry at the server level).
48
+ *
49
+ * Never throws. Returns `[]` on any error.
50
+ */
51
+ export function findPidByMarker(marker: string, opts: ProcessIdentifyOpts = {}): number[] {
52
+ const platform = opts.platform ?? process.platform;
53
+ if (platform === "win32") return [];
54
+
55
+ const exec = opts.exec ?? defaultExec;
56
+ // Additional sentinels help distinguish pi headless spawns from other
57
+ // processes that happen to contain the session ID in an env var or
58
+ // unrelated argument. The canonical sentinels match the Unix headless
59
+ // wrapper strings.
60
+ const sentinels = ["sleep 2147483647", "tail -f /dev/null"];
61
+
62
+ try {
63
+ const out = exec(
64
+ `ps -eo pid,command | grep ${shellQuote(marker)} | grep -v grep`,
65
+ { encoding: "utf-8", timeout: 3000 },
66
+ ).trim();
67
+ if (!out) return [];
68
+
69
+ const pids: number[] = [];
70
+ for (const line of out.split("\n")) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed) continue;
73
+ // Must also contain one of the pi headless sentinels, else it's
74
+ // probably a grep/editor/tail-of-log matching the session id.
75
+ const hasSentinel = sentinels.some((s) => trimmed.includes(s));
76
+ if (!hasSentinel) continue;
77
+ const pidStr = trimmed.split(/\s+/, 1)[0];
78
+ const pid = parseInt(pidStr, 10);
79
+ if (pid > 0) pids.push(pid);
80
+ }
81
+ return pids;
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ // ── isProcessLikePi ────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Check if a PID belongs to a pi/node process. Safety check before
91
+ * SIGKILL on Unix; no-op on Windows where pi-ness is tracked by
92
+ * the PID registry at spawn time.
93
+ *
94
+ * Unix behaviour:
95
+ * - macOS: `ps -p <pid> -o command=`
96
+ * - Linux: `/proc/<pid>/cmdline` with `ps` fallback via `cat`
97
+ *
98
+ * Returns `false` if the process has already exited (command fails).
99
+ * Returns `true` on Windows unconditionally.
100
+ */
101
+ export function isProcessLikePi(pid: number, opts: ProcessIdentifyOpts = {}): boolean {
102
+ const platform = opts.platform ?? process.platform;
103
+ if (platform === "win32") return true;
104
+
105
+ const exec = opts.exec ?? defaultExec;
106
+ const cmd = platform === "darwin"
107
+ ? `ps -p ${pid} -o command=`
108
+ : `cat /proc/${pid}/cmdline 2>/dev/null || ps -p ${pid} -o command=`;
109
+
110
+ try {
111
+ const output = exec(cmd, { encoding: "utf-8", timeout: 2000 }).trim();
112
+ return isPiCommandLine(output);
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ // ── helpers ────────────────────────────────────────────────────────────────
119
+
120
+ function shellQuote(s: string): string {
121
+ // Strict allow-list: if the marker is purely [A-Za-z0-9._-], leave it alone;
122
+ // otherwise single-quote it safely. Session IDs are UUIDs or similar and
123
+ // fall into the allow-list in practice, so this is almost always a no-op.
124
+ if (/^[A-Za-z0-9._-]+$/.test(s)) return `"${s}"`;
125
+ return `'${s.replace(/'/g, "'\\''")}'`;
126
+ }