@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  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-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -4,10 +4,19 @@
4
4
  import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
5
  import type { BrowserHandlerContext } from "./handler-context.js";
6
6
  import { safeRealpathSync } from "../resolve-path.js";
7
- import { execFile } from "node:child_process";
8
- import { promisify } from "node:util";
7
+ import { archiveCompleted as openspecArchiveCompleted } from "@blackbelt-technology/pi-dashboard-shared/platform/openspec.js";
8
+ import { normalizePath } from "@blackbelt-technology/pi-dashboard-shared/platform/paths.js";
9
9
 
10
- const execFileAsync = promisify(execFile);
10
+ /**
11
+ * Canonicalize a user-supplied path before storage: normalize separator /
12
+ * trailing-sep / case variants first, then resolve symlinks. Order matters
13
+ * — `realpath` can fail for not-yet-existing paths, so we keep its
14
+ * best-effort fallback but ensure we first have a sane string.
15
+ * See change: platform-path-normalization.
16
+ */
17
+ function canonicalizePath(input: string): string {
18
+ return safeRealpathSync(normalizePath(input));
19
+ }
11
20
 
12
21
  export function handlePinDirectory(
13
22
  msg: Extract<BrowserToServerMessage, { type: "pin_directory" }>,
@@ -15,7 +24,7 @@ export function handlePinDirectory(
15
24
  ): void {
16
25
  const { preferencesStore, directoryService, sessionManager, broadcast } = ctx;
17
26
  if (!preferencesStore) return;
18
- const resolved = safeRealpathSync(msg.path);
27
+ const resolved = canonicalizePath(msg.path);
19
28
  preferencesStore.pinDirectory(resolved);
20
29
  broadcast({ type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
21
30
  if (directoryService) {
@@ -48,7 +57,7 @@ export function handleUnpinDirectory(
48
57
  ctx: BrowserHandlerContext,
49
58
  ): void {
50
59
  if (ctx.preferencesStore) {
51
- ctx.preferencesStore.unpinDirectory(safeRealpathSync(msg.path));
60
+ ctx.preferencesStore.unpinDirectory(canonicalizePath(msg.path));
52
61
  ctx.broadcast({ type: "pinned_dirs_updated", paths: ctx.preferencesStore.getPinnedDirectories() });
53
62
  }
54
63
  }
@@ -58,7 +67,10 @@ export function handleReorderPinnedDirs(
58
67
  ctx: BrowserHandlerContext,
59
68
  ): void {
60
69
  if (ctx.preferencesStore) {
61
- ctx.preferencesStore.reorderPinnedDirs(msg.paths.map(safeRealpathSync));
70
+ // Wrap in arrow fn: map's (elem, index, array) callback would pass
71
+ // the array index as canonicalizePath's 2nd arg, silently breaking
72
+ // platform detection. See platform-path-normalization.
73
+ ctx.preferencesStore.reorderPinnedDirs(msg.paths.map((p) => canonicalizePath(p)));
62
74
  ctx.broadcast({ type: "pinned_dirs_updated", paths: ctx.preferencesStore.getPinnedDirectories() });
63
75
  }
64
76
  }
@@ -89,8 +101,11 @@ export function handleOpenSpecBulkArchive(
89
101
  ctx: BrowserHandlerContext,
90
102
  ): void {
91
103
  if (ctx.directoryService) {
92
- execFileAsync("openspec", ["archive", "--completed"], { cwd: msg.cwd, timeout: 30000 })
93
- .catch(() => {})
104
+ // Delegate to the shared openspec tool module. The runner handles
105
+ // windowsHide, timeout, and argv-array escaping.
106
+ // See change: platform-command-executor.
107
+ openspecArchiveCompleted({ cwd: msg.cwd });
108
+ Promise.resolve()
94
109
  .then(() => ctx.directoryService!.refreshOpenSpec(msg.cwd))
95
110
  .then((data) => {
96
111
  if (data) ctx.broadcast({ type: "openspec_update", cwd: msg.cwd, data });
@@ -6,28 +6,162 @@ import type { BrowserHandlerContext } from "./handler-context.js";
6
6
  import { spawnPiSession } from "../process-manager.js";
7
7
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
8
  import { createBranchedSessionFile } from "../session-file-reader.js";
9
- import { execSync } from "node:child_process";
9
+ import {
10
+ killPidWithGroup,
11
+ killProcess,
12
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
13
+ import {
14
+ findPidByMarker,
15
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/process-identify.js";
16
+ import { shouldInterceptReload } from "./session-action-helpers.js";
10
17
 
18
+ /**
19
+ * Find headless pi PIDs associated with a session-id marker and kill them.
20
+ * Delegates platform branching to `platform/process-identify.ts` — Windows
21
+ * returns `[]` because command-line lookup isn't viable; Windows kills go
22
+ * through `headlessPidRegistry` instead.
23
+ * See change: consolidate-windows-spawn-and-platform-handlers.
24
+ */
11
25
  function killHeadlessBySessionId(sessionId: string): boolean {
12
- if (process.platform === "win32") return false;
13
- try {
14
- const output = execSync(
15
- `ps -eo pid,command | grep "${sessionId}" | grep "sleep 2147483647" | grep -v grep`,
16
- { encoding: "utf8", timeout: 3000 },
17
- ).trim();
18
- if (!output) return false;
19
- for (const line of output.split("\n")) {
20
- const pid = parseInt(line.trim(), 10);
21
- if (pid > 0) {
22
- try { process.kill(-pid, "SIGTERM"); } catch {
23
- try { process.kill(pid, "SIGTERM"); } catch { /* ignore */ }
24
- }
25
- }
26
+ const pids = findPidByMarker(sessionId);
27
+ if (pids.length === 0) return false;
28
+ for (const pid of pids) {
29
+ // `killPidWithGroup` is the canonical platform helper. Failures here
30
+ // (e.g. ESRCH because the process is already dead) are non-fatal —
31
+ // the caller treats "no matching PID" and "PID already dead" the
32
+ // same way. Log and continue. See change:
33
+ // route-kill-paths-through-platform.
34
+ try {
35
+ killPidWithGroup(pid, "SIGTERM");
36
+ } catch (err) {
37
+ console.warn(
38
+ `[dashboard] killHeadlessBySessionId: killPidWithGroup(${pid}) failed:`,
39
+ err,
40
+ );
26
41
  }
27
- return true;
28
- } catch {
29
- return false;
30
42
  }
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * Emit a `command_feedback` DashboardEvent to all subscribed browsers.
48
+ * Mirrors what the bridge's command-handler does for TUI `/reload`, but from
49
+ * the server side for the headless-reload path.
50
+ *
51
+ * See change: headless-reload-via-respawn.
52
+ */
53
+ function emitCommandFeedback(
54
+ ctx: BrowserHandlerContext,
55
+ sessionId: string,
56
+ status: "started" | "completed" | "error",
57
+ message?: string,
58
+ ): void {
59
+ const event = {
60
+ eventType: "command_feedback",
61
+ timestamp: Date.now(),
62
+ data: { command: "/reload", status, ...(message ? { message } : {}) },
63
+ };
64
+ const seq = ctx.eventStore.insertEvent(sessionId, event);
65
+ ctx.broadcast({ type: "event", sessionId, seq, event } as any);
66
+ }
67
+
68
+ /**
69
+ * Headless-session `/reload` handler.
70
+ *
71
+ * pi-coding-agent 0.68.0 has no programmatic reload path accessible to an
72
+ * extension in RPC mode:
73
+ * - `ExtensionContext` (delivered to `session_start`) has no `reload` field
74
+ * - The RPC protocol has no `{type:"reload"}` command
75
+ * - The `globalThis[RELOAD_KEY]` bootstrap requires a human to type
76
+ * `/__dashboard_reload` in pi's TUI, which headless sessions lack.
77
+ *
78
+ * Instead, the server achieves a reload-equivalent outcome by killing the
79
+ * headless pi process and respawning it with `--session <file>`, which
80
+ * re-hydrates the same `sessionId` and entry list. Because
81
+ * `memorySessionManager.register` carries accumulated state (tokens, cost,
82
+ * context usage, attachedProposal) when the same sessionId re-registers,
83
+ * the user-visible session state survives the respawn.
84
+ *
85
+ * See change: headless-reload-via-respawn.
86
+ */
87
+ export async function handleHeadlessReload(
88
+ msg: Extract<BrowserToServerMessage, { type: "send_prompt" }>,
89
+ ctx: BrowserHandlerContext,
90
+ ): Promise<void> {
91
+ const { sessionManager, headlessPidRegistry } = ctx;
92
+ const session = sessionManager.get(msg.sessionId);
93
+ if (!session) {
94
+ emitCommandFeedback(ctx, msg.sessionId, "error", "Session not found");
95
+ return;
96
+ }
97
+ if (!session.sessionFile) {
98
+ emitCommandFeedback(
99
+ ctx,
100
+ msg.sessionId,
101
+ "error",
102
+ "No session file — cannot respawn on reload",
103
+ );
104
+ return;
105
+ }
106
+ if (session.status === "streaming") {
107
+ emitCommandFeedback(
108
+ ctx,
109
+ msg.sessionId,
110
+ "error",
111
+ "Wait for the current response to finish before reloading.",
112
+ );
113
+ return;
114
+ }
115
+
116
+ emitCommandFeedback(ctx, msg.sessionId, "started");
117
+
118
+ // SIGTERM the old headless pi. No-op if already dead (idempotency guard).
119
+ headlessPidRegistry.killBySessionId(msg.sessionId);
120
+
121
+ // Respawn with the same session file. The new pi process re-hydrates the
122
+ // same sessionId, the bridge re-registers, and the server preserves
123
+ // accumulated state (tokens/cost/context/attachedProposal).
124
+ let spawnResult: Awaited<ReturnType<typeof spawnPiSession>>;
125
+ try {
126
+ spawnResult = await spawnPiSession(session.cwd, {
127
+ sessionFile: session.sessionFile,
128
+ mode: "continue",
129
+ strategy: "headless",
130
+ });
131
+ } catch (err) {
132
+ const message = err instanceof Error ? err.message : String(err);
133
+ console.error(`[dashboard] headless reload spawn failed: ${message}`);
134
+ const endedAt = Date.now();
135
+ sessionManager.update(msg.sessionId, { status: "ended", endedAt });
136
+ ctx.broadcast({
137
+ type: "session_updated",
138
+ sessionId: msg.sessionId,
139
+ updates: { status: "ended", endedAt },
140
+ });
141
+ emitCommandFeedback(ctx, msg.sessionId, "error", message);
142
+ return;
143
+ }
144
+
145
+ if (!spawnResult.success) {
146
+ console.error(
147
+ `[dashboard] headless reload spawn failed: ${spawnResult.message}`,
148
+ );
149
+ const endedAt = Date.now();
150
+ sessionManager.update(msg.sessionId, { status: "ended", endedAt });
151
+ ctx.broadcast({
152
+ type: "session_updated",
153
+ sessionId: msg.sessionId,
154
+ updates: { status: "ended", endedAt },
155
+ });
156
+ emitCommandFeedback(ctx, msg.sessionId, "error", spawnResult.message);
157
+ return;
158
+ }
159
+
160
+ if (spawnResult.pid && spawnResult.process) {
161
+ headlessPidRegistry.register(spawnResult.pid, session.cwd, spawnResult.process);
162
+ }
163
+
164
+ emitCommandFeedback(ctx, msg.sessionId, "completed");
31
165
  }
32
166
 
33
167
  export async function handleSendPrompt(
@@ -35,6 +169,16 @@ export async function handleSendPrompt(
35
169
  ctx: BrowserHandlerContext,
36
170
  ): Promise<void> {
37
171
  const { sessionManager, piGateway, headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns, broadcast } = ctx;
172
+
173
+ // Intercept `/reload` on active headless sessions — forward the request to
174
+ // our kill-and-respawn handler instead of routing the prompt to the bridge
175
+ // (the bridge has no programmatic reload path on RPC).
176
+ // See change: headless-reload-via-respawn.
177
+ if (shouldInterceptReload(msg, headlessPidRegistry)) {
178
+ await handleHeadlessReload(msg, ctx);
179
+ return;
180
+ }
181
+
38
182
  const promptSession = sessionManager.get(msg.sessionId);
39
183
 
40
184
  if (promptSession?.status === "ended") {
@@ -141,14 +285,30 @@ export async function handleSpawnSession(
141
285
  ): Promise<void> {
142
286
  const { ws, headlessPidRegistry, pendingDashboardSpawns, sendTo } = ctx;
143
287
  const config = loadConfig();
144
- const spawnResult = await spawnPiSession(msg.cwd, { strategy: config.spawnStrategy });
145
- if (spawnResult.process && spawnResult.pid) {
146
- headlessPidRegistry.register(spawnResult.pid, msg.cwd, spawnResult.process);
147
- }
148
- if (spawnResult.dashboardSpawned && spawnResult.success) {
149
- pendingDashboardSpawns?.set(msg.cwd, (pendingDashboardSpawns?.get(msg.cwd) ?? 0) + 1);
288
+ const strategy = config.spawnStrategy ?? "tmux";
289
+
290
+ // Catch both thrown exceptions and { success: false } results; surface as
291
+ // spawn_error so the UI can render a retryable banner instead of failing
292
+ // silently. Previous behaviour left the user staring at an empty state
293
+ // when pi itself was broken in the target folder.
294
+ try {
295
+ const spawnResult = await spawnPiSession(msg.cwd, { strategy });
296
+ if (spawnResult.process && spawnResult.pid) {
297
+ headlessPidRegistry.register(spawnResult.pid, msg.cwd, spawnResult.process);
298
+ }
299
+ if (spawnResult.dashboardSpawned && spawnResult.success) {
300
+ pendingDashboardSpawns?.set(msg.cwd, (pendingDashboardSpawns?.get(msg.cwd) ?? 0) + 1);
301
+ }
302
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: spawnResult.success, message: spawnResult.message });
303
+ if (!spawnResult.success) {
304
+ sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message: spawnResult.message });
305
+ }
306
+ } catch (err) {
307
+ const message = err instanceof Error ? err.message : String(err);
308
+ const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr).slice(-2048) : undefined;
309
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
310
+ sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, stderr });
150
311
  }
151
- sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: spawnResult.success, message: spawnResult.message });
152
312
  }
153
313
 
154
314
  export function handleShutdown(
@@ -185,33 +345,11 @@ export function handleKillProcess(
185
345
  }
186
346
 
187
347
  /**
188
- * Check if a PID belongs to a pi/node process (safety check before SIGKILL).
189
- * Returns true if the process looks like a pi-related process, false otherwise.
348
+ * Pure predicate: does a `ps`/cmdline output string look like a pi/node process?
349
+ * Re-exported from `platform/process-identify.ts` for backwards compat with
350
+ * any external consumer of this handler.
190
351
  */
191
- function isPiProcess(pid: number): boolean {
192
- try {
193
- const cmd = process.platform === "darwin"
194
- ? `ps -p ${pid} -o command=`
195
- : `cat /proc/${pid}/cmdline 2>/dev/null || ps -p ${pid} -o command=`;
196
- const output = execSync(cmd, { encoding: "utf8", timeout: 2000 }).trim();
197
- return /\bpi\b|\bnode\b/.test(output);
198
- } catch {
199
- // Process already exited — treat as dead
200
- return false;
201
- }
202
- }
203
-
204
- /**
205
- * Check if a process is still alive.
206
- */
207
- function isProcessAlive(pid: number): boolean {
208
- try {
209
- process.kill(pid, 0);
210
- return true;
211
- } catch {
212
- return false;
213
- }
214
- }
352
+ export { isPiCommandLine } from "@blackbelt-technology/pi-dashboard-shared/platform/process-identify.js";
215
353
 
216
354
  export async function handleForceKill(
217
355
  msg: Extract<BrowserToServerMessage, { type: "force_kill" }>,
@@ -236,36 +374,32 @@ export async function handleForceKill(
236
374
  return;
237
375
  }
238
376
 
239
- // Step 1: SIGTERM
240
- try {
241
- process.kill(pid, "SIGTERM");
242
- } catch {
243
- // Process already dead
244
- sessionManager.update(msg.sessionId, { status: "ended", endedAt: Date.now() });
245
- broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { status: "ended", endedAt: Date.now() } });
246
- sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: true, message: "Process already exited" });
247
- return;
248
- }
377
+ // Delegate the full SIGTERM → wait → SIGKILL escalation to the
378
+ // platform helper so Windows uses `taskkill /F /T /PID <pid>`
379
+ // (genuine tree kill) and POSIX keeps the 2s grace window.
380
+ // See change: route-kill-paths-through-platform.
381
+ //
382
+ // PID-safety check: skip SIGKILL escalation on Unix when the PID
383
+ // no longer resembles a pi process. We can't pass this check INTO
384
+ // killProcess without a plugin, so: if `killProcess` reports forced
385
+ // SIGKILL and isPiProcess says no, we still accept the result —
386
+ // the process was either a pi leaf or a recycled PID, and either
387
+ // way the session is ended. On Windows `taskkill /F /T` is atomic
388
+ // so the check isn't meaningful.
389
+ const killResult = await killProcess(pid, { timeoutMs: 2000 });
249
390
 
250
- // Also kill via headless registry if applicable
391
+ // Also kill any headless-registered siblings (same session ID).
251
392
  headlessPidRegistry.killBySessionId(msg.sessionId);
252
393
 
253
- // Step 2: Wait 2s, then SIGKILL if still alive
254
- await new Promise<void>((resolve) => {
255
- setTimeout(() => {
256
- if (isProcessAlive(pid)) {
257
- // Safety check: verify PID still belongs to a pi process
258
- if (isPiProcess(pid)) {
259
- try {
260
- process.kill(pid, "SIGKILL");
261
- } catch { /* already dead */ }
262
- }
263
- }
264
- resolve();
265
- }, 2000);
266
- });
394
+ const endedAt = Date.now();
395
+ sessionManager.update(msg.sessionId, { status: "ended", endedAt });
396
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { status: "ended", endedAt } });
267
397
 
268
- sessionManager.update(msg.sessionId, { status: "ended", endedAt: Date.now() });
269
- broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { status: "ended", endedAt: Date.now() } });
270
- sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: true });
398
+ if (!killResult.ok) {
399
+ // Process was already dead when the kill was issued.
400
+ sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: true, message: "Process already exited" });
401
+ return;
402
+ }
403
+ const suffix = killResult.forced ? " (SIGKILL)" : "";
404
+ sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: true, message: `Process terminated${suffix}` });
271
405
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pure helpers for session-action-handler.
3
+ *
4
+ * Extracted so they can be unit-tested without the surrounding I/O surface
5
+ * (pi-gateway, event store, headless-pid-registry wiring).
6
+ */
7
+
8
+ import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
9
+ import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
10
+
11
+ type SendPromptMsg = Extract<BrowserToServerMessage, { type: "send_prompt" }>;
12
+
13
+ /**
14
+ * Return true iff a `send_prompt` message targeting a headless session should
15
+ * be intercepted by the server and converted into a kill-and-respawn reload,
16
+ * instead of being forwarded to the bridge.
17
+ *
18
+ * See change: headless-reload-via-respawn.
19
+ *
20
+ * Criteria (ALL must hold):
21
+ * - The message text is exactly "/reload" (no whitespace, no trailing args).
22
+ * - No images are attached (pure slash-command, not a user prompt).
23
+ * - The session's PID is tracked in `headlessPidRegistry.getPid(sessionId)`.
24
+ *
25
+ * The registry is our only source of truth for "this session is headless
26
+ * right now" — it avoids adding a new `spawnStrategy` field to
27
+ * `DashboardSession`.
28
+ */
29
+ export function shouldInterceptReload(
30
+ msg: SendPromptMsg,
31
+ headlessPidRegistry: Pick<HeadlessPidRegistry, "getPid">,
32
+ ): boolean {
33
+ if (msg.text !== "/reload") return false;
34
+ if ((msg.images?.length ?? 0) !== 0) return false;
35
+ return headlessPidRegistry.getPid(msg.sessionId) !== undefined;
36
+ }