@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -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
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Cross-platform process enumeration primitives: is-process-running,
3
+ * ps/tasklist pattern-matching, elapsed-time parsing.
4
+ *
5
+ * Every OS-dependent helper accepts injectable `platform` and `exec`
6
+ * parameters (defaulting to `process.platform` and `execSync`), so tests
7
+ * can exercise both branches without mutating the global `process.platform`.
8
+ * See change: consolidate-platform-handlers.
9
+ */
10
+
11
+ import { execSync } from "./exec.js";
12
+
13
+ type ExecFn = (cmd: string, opts: { encoding: "utf-8"; stdio?: any }) => string;
14
+
15
+ export interface ProcessScanOpts {
16
+ /** Override platform (defaults to process.platform). */
17
+ platform?: NodeJS.Platform;
18
+ /** Override execSync (for tests). */
19
+ exec?: ExecFn;
20
+ }
21
+
22
+ function defaultExec(cmd: string, opts: { encoding: "utf-8"; stdio?: any }): string {
23
+ return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
24
+ }
25
+
26
+ // ── Elapsed-time parsing (pure, platform-agnostic) ──────────────────────────
27
+
28
+ /**
29
+ * Parse `ps -o etime=` format into milliseconds. Handles:
30
+ * - `mm:ss` (e.g. "02:15" → 135000)
31
+ * - `hh:mm:ss` (e.g. "01:30:00" → 5400000)
32
+ * - `dd-hh:mm:ss` (e.g. "2-03:00:00" → 183600000)
33
+ *
34
+ * Returns 0 for empty or unparseable input.
35
+ */
36
+ export function parseEtime(etime: string): number {
37
+ const trimmed = etime.trim();
38
+ if (!trimmed) return 0;
39
+
40
+ let days = 0;
41
+ let rest = trimmed;
42
+
43
+ const dashIdx = rest.indexOf("-");
44
+ if (dashIdx !== -1) {
45
+ days = parseInt(rest.slice(0, dashIdx), 10);
46
+ if (isNaN(days)) return 0;
47
+ rest = rest.slice(dashIdx + 1);
48
+ }
49
+
50
+ const parts = rest.split(":").map((p) => parseInt(p, 10));
51
+ if (parts.some(isNaN)) return 0;
52
+
53
+ let hours = 0, minutes = 0, seconds = 0;
54
+ if (parts.length === 3) {
55
+ [hours, minutes, seconds] = parts;
56
+ } else if (parts.length === 2) {
57
+ [minutes, seconds] = parts;
58
+ } else {
59
+ return 0;
60
+ }
61
+
62
+ return ((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) * 1000;
63
+ }
64
+
65
+ // ── Process-running check ───────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Check whether a process matching `pattern` is currently running.
69
+ * - win32: `tasklist /FI "IMAGENAME eq <pattern>" /NH` — pattern is the
70
+ * executable image name (e.g. "Code.exe"). Returns true if the
71
+ * output contains the pattern.
72
+ * - unix: `pgrep -f "<pattern>"` — pattern is any substring of the
73
+ * command-line (e.g. "/Applications/Zed.app"). Returns true if
74
+ * pgrep exits with code 0 (at least one match).
75
+ *
76
+ * Best-effort: any failure returns `false`.
77
+ */
78
+ export function isProcessRunning(pattern: string, opts: ProcessScanOpts = {}): boolean {
79
+ const platform = opts.platform ?? process.platform;
80
+ const exec = opts.exec ?? defaultExec;
81
+ try {
82
+ if (platform === "win32") {
83
+ const result = exec(`tasklist /FI "IMAGENAME eq ${pattern}" /NH`, {
84
+ encoding: "utf-8",
85
+ stdio: "pipe",
86
+ });
87
+ return String(result).includes(pattern);
88
+ }
89
+ exec(`pgrep -f "${pattern}"`, { encoding: "utf-8", stdio: "pipe" });
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Cross-platform process primitives: port cleanup, kill, liveness, group-kill.
3
+ *
4
+ * Every OS-dependent helper takes an optional `platform` parameter
5
+ * (defaulting to `process.platform`) so tests can exercise both branches
6
+ * without mutating the global `process.platform`. See change:
7
+ * consolidate-platform-handlers.
8
+ */
9
+
10
+ import { execSync } from "./exec.js";
11
+
12
+ export type ExecFn = (cmd: string, opts: { encoding: "utf-8" }) => string;
13
+ export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
14
+
15
+ export interface ProcessOpts {
16
+ /** Override platform (defaults to process.platform). */
17
+ platform?: NodeJS.Platform;
18
+ /** Override execSync (for tests). */
19
+ exec?: ExecFn;
20
+ /** Override process.kill (for tests). */
21
+ kill?: KillFn;
22
+ }
23
+
24
+ function defaultExec(cmd: string, opts: { encoding: "utf-8" }): string {
25
+ // Always suppress the cmd.exe window flash on Windows. The primitives that
26
+ // use this (findPortHolders via netstat, killProcess via taskkill) don't
27
+ // need user visibility.
28
+ return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
29
+ }
30
+
31
+ function defaultKill(pid: number, signal: NodeJS.Signals | number): void {
32
+ process.kill(pid, signal);
33
+ }
34
+
35
+ // ── Port-holder detection ────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Parse `netstat -ano -p tcp` output for PIDs listening on a port (Windows).
39
+ * Pure function, exported for testing.
40
+ *
41
+ * Example input line:
42
+ * " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345"
43
+ */
44
+ export function parseNetstatListeners(output: string, port: number, selfPid: number): number[] {
45
+ const pids: number[] = [];
46
+ const portSuffix = `:${port}`;
47
+ for (const line of output.split(/\r?\n/)) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || !/^\s*TCP/i.test(line)) continue;
50
+ if (!/LISTENING/i.test(line)) continue;
51
+ const cols = trimmed.split(/\s+/);
52
+ if (cols.length < 5) continue;
53
+ const local = cols[1];
54
+ if (!local.endsWith(portSuffix)) continue;
55
+ const pid = Number.parseInt(cols[cols.length - 1], 10);
56
+ if (Number.isFinite(pid) && pid > 0 && pid !== selfPid) pids.push(pid);
57
+ }
58
+ return pids;
59
+ }
60
+
61
+ /**
62
+ * Find PIDs holding a TCP port. Cross-platform:
63
+ * - win32: `netstat -ano -p tcp` → parse LISTENING rows
64
+ * - unix: `lsof -t -i :<port> -sTCP:LISTEN`
65
+ *
66
+ * Best-effort: any failure returns []. Excludes the current process PID.
67
+ */
68
+ export function findPortHolders(port: number, opts: ProcessOpts = {}): number[] {
69
+ const platform = opts.platform ?? process.platform;
70
+ const exec = opts.exec ?? defaultExec;
71
+ try {
72
+ if (platform === "win32") {
73
+ const output = exec("netstat -ano -p tcp", { encoding: "utf-8" });
74
+ return parseNetstatListeners(String(output), port, process.pid);
75
+ }
76
+ const output = exec(`lsof -t -i :${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" });
77
+ return String(output).trim().split("\n").map(Number).filter((n) => n > 0 && n !== process.pid);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ // ── Liveness ─────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Check whether a PID is alive. Cross-platform via `process.kill(pid, 0)`.
87
+ */
88
+ export function isProcessAlive(pid: number, opts: { kill?: KillFn } = {}): boolean {
89
+ const kill = opts.kill ?? defaultKill;
90
+ try {
91
+ kill(pid, 0);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // ── Termination ──────────────────────────────────────────────────────────────
99
+
100
+ export interface KillProcessResult {
101
+ ok: boolean;
102
+ forced: boolean;
103
+ }
104
+
105
+ /**
106
+ * Terminate a process, cross-platform:
107
+ * - win32: `taskkill /F /T /PID <pid>` (tree kill, immediate)
108
+ * - unix: SIGTERM → wait up to `timeoutMs` → SIGKILL if still alive
109
+ *
110
+ * Returns `{ ok, forced }`. `ok` is true if the process was terminated (or
111
+ * was already dead); `forced` is true if SIGKILL was needed on Unix.
112
+ */
113
+ export async function killProcess(
114
+ pid: number,
115
+ opts: ProcessOpts & { timeoutMs?: number } = {},
116
+ ): Promise<KillProcessResult> {
117
+ const platform = opts.platform ?? process.platform;
118
+ const exec = opts.exec ?? defaultExec;
119
+ const kill = opts.kill ?? defaultKill;
120
+ const timeoutMs = opts.timeoutMs ?? 5000;
121
+
122
+ if (!isProcessAlive(pid, { kill })) return { ok: false, forced: false };
123
+
124
+ if (platform === "win32") {
125
+ try {
126
+ exec(`taskkill /F /T /PID ${pid}`, { encoding: "utf-8" });
127
+ return { ok: true, forced: false };
128
+ } catch {
129
+ return { ok: false, forced: false };
130
+ }
131
+ }
132
+
133
+ try {
134
+ kill(pid, "SIGTERM");
135
+ } catch {
136
+ return { ok: false, forced: false };
137
+ }
138
+
139
+ const deadline = Date.now() + timeoutMs;
140
+ while (Date.now() < deadline) {
141
+ await new Promise((r) => setTimeout(r, 200));
142
+ if (!isProcessAlive(pid, { kill })) return { ok: true, forced: false };
143
+ }
144
+ try {
145
+ kill(pid, "SIGKILL");
146
+ } catch {
147
+ /* already dead */
148
+ }
149
+ return { ok: true, forced: true };
150
+ }
151
+
152
+ // ── Process-group kill (for detached children) ───────────────────────────────
153
+
154
+ /**
155
+ * Signal a process, targeting the process group on Unix (negative PID) and
156
+ * the PID directly on Windows. Used for detached children spawned with their
157
+ * own process group.
158
+ */
159
+ export function killPidWithGroup(
160
+ pid: number,
161
+ signal: NodeJS.Signals,
162
+ opts: ProcessOpts = {},
163
+ ): void {
164
+ const platform = opts.platform ?? process.platform;
165
+ const kill = opts.kill ?? defaultKill;
166
+ const target = platform === "win32" ? pid : -pid;
167
+ kill(target, signal);
168
+ }