@blackbelt-technology/pi-agent-dashboard 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/AGENTS.md +342 -0
  2. package/README.md +619 -0
  3. package/docs/architecture.md +646 -0
  4. package/package.json +92 -0
  5. package/packages/extension/package.json +33 -0
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
  8. package/packages/extension/src/__tests__/connection.test.ts +344 -0
  9. package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
  10. package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
  11. package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
  12. package/packages/extension/src/__tests__/git-info.test.ts +112 -0
  13. package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
  14. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
  15. package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
  16. package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
  17. package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
  18. package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
  19. package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
  20. package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
  21. package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
  22. package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
  23. package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
  24. package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
  25. package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
  26. package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
  27. package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
  28. package/packages/extension/src/ask-user-tool.ts +63 -0
  29. package/packages/extension/src/bridge-context.ts +64 -0
  30. package/packages/extension/src/bridge.ts +926 -0
  31. package/packages/extension/src/command-handler.ts +538 -0
  32. package/packages/extension/src/connection.ts +204 -0
  33. package/packages/extension/src/dev-build.ts +39 -0
  34. package/packages/extension/src/event-forwarder.ts +40 -0
  35. package/packages/extension/src/flow-event-wiring.ts +102 -0
  36. package/packages/extension/src/git-info.ts +65 -0
  37. package/packages/extension/src/git-link-builder.ts +112 -0
  38. package/packages/extension/src/model-tracker.ts +56 -0
  39. package/packages/extension/src/pi-env.d.ts +23 -0
  40. package/packages/extension/src/process-metrics.ts +70 -0
  41. package/packages/extension/src/process-scanner.ts +396 -0
  42. package/packages/extension/src/prompt-expander.ts +87 -0
  43. package/packages/extension/src/provider-register.ts +276 -0
  44. package/packages/extension/src/server-auto-start.ts +87 -0
  45. package/packages/extension/src/server-launcher.ts +82 -0
  46. package/packages/extension/src/server-probe.ts +33 -0
  47. package/packages/extension/src/session-sync.ts +154 -0
  48. package/packages/extension/src/source-detector.ts +26 -0
  49. package/packages/extension/src/ui-proxy.ts +269 -0
  50. package/packages/extension/tsconfig.json +11 -0
  51. package/packages/server/package.json +37 -0
  52. package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
  53. package/packages/server/src/__tests__/auth.test.ts +224 -0
  54. package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
  55. package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
  56. package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
  57. package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
  58. package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
  59. package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
  60. package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
  61. package/packages/server/src/__tests__/config-api.test.ts +104 -0
  62. package/packages/server/src/__tests__/cors.test.ts +48 -0
  63. package/packages/server/src/__tests__/directory-service.test.ts +240 -0
  64. package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
  65. package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
  66. package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
  67. package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
  68. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
  69. package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
  70. package/packages/server/src/__tests__/extension-register.test.ts +61 -0
  71. package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
  72. package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
  73. package/packages/server/src/__tests__/git-operations.test.ts +251 -0
  74. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  75. package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
  76. package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
  77. package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
  78. package/packages/server/src/__tests__/json-store.test.ts +70 -0
  79. package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
  80. package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
  81. package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
  82. package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
  83. package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
  84. package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
  85. package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
  86. package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
  87. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
  88. package/packages/server/src/__tests__/package-routes.test.ts +172 -0
  89. package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
  90. package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
  91. package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
  92. package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
  93. package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
  94. package/packages/server/src/__tests__/process-manager.test.ts +184 -0
  95. package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
  96. package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
  97. package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
  98. package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
  99. package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
  100. package/packages/server/src/__tests__/server-pid.test.ts +89 -0
  101. package/packages/server/src/__tests__/session-api.test.ts +244 -0
  102. package/packages/server/src/__tests__/session-diff.test.ts +138 -0
  103. package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
  104. package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
  105. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
  106. package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
  107. package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
  108. package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
  109. package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
  110. package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
  111. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
  112. package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
  113. package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
  114. package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
  115. package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
  116. package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
  117. package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
  118. package/packages/server/src/__tests__/tunnel.test.ts +206 -0
  119. package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
  120. package/packages/server/src/auth-plugin.ts +302 -0
  121. package/packages/server/src/auth.ts +323 -0
  122. package/packages/server/src/browse.ts +55 -0
  123. package/packages/server/src/browser-gateway.ts +495 -0
  124. package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
  125. package/packages/server/src/browser-handlers/handler-context.ts +45 -0
  126. package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
  127. package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
  128. package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
  129. package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
  130. package/packages/server/src/cli.ts +347 -0
  131. package/packages/server/src/config-api.ts +130 -0
  132. package/packages/server/src/directory-service.ts +162 -0
  133. package/packages/server/src/editor-detection.ts +60 -0
  134. package/packages/server/src/editor-manager.ts +352 -0
  135. package/packages/server/src/editor-proxy.ts +134 -0
  136. package/packages/server/src/editor-registry.ts +108 -0
  137. package/packages/server/src/event-status-extraction.ts +131 -0
  138. package/packages/server/src/event-wiring.ts +589 -0
  139. package/packages/server/src/extension-register.ts +92 -0
  140. package/packages/server/src/git-operations.ts +200 -0
  141. package/packages/server/src/headless-pid-registry.ts +207 -0
  142. package/packages/server/src/idle-timer.ts +61 -0
  143. package/packages/server/src/json-store.ts +32 -0
  144. package/packages/server/src/localhost-guard.ts +117 -0
  145. package/packages/server/src/memory-event-store.ts +193 -0
  146. package/packages/server/src/memory-session-manager.ts +123 -0
  147. package/packages/server/src/meta-persistence.ts +64 -0
  148. package/packages/server/src/migrate-persistence.ts +195 -0
  149. package/packages/server/src/npm-search-proxy.ts +143 -0
  150. package/packages/server/src/oauth-callback-server.ts +177 -0
  151. package/packages/server/src/openspec-archive.ts +60 -0
  152. package/packages/server/src/package-manager-wrapper.ts +200 -0
  153. package/packages/server/src/pending-fork-registry.ts +53 -0
  154. package/packages/server/src/pending-load-manager.ts +110 -0
  155. package/packages/server/src/pending-resume-registry.ts +69 -0
  156. package/packages/server/src/pi-gateway.ts +419 -0
  157. package/packages/server/src/pi-resource-scanner.ts +369 -0
  158. package/packages/server/src/preferences-store.ts +116 -0
  159. package/packages/server/src/process-manager.ts +311 -0
  160. package/packages/server/src/provider-auth-handlers.ts +438 -0
  161. package/packages/server/src/provider-auth-storage.ts +200 -0
  162. package/packages/server/src/resolve-path.ts +12 -0
  163. package/packages/server/src/routes/editor-routes.ts +86 -0
  164. package/packages/server/src/routes/file-routes.ts +116 -0
  165. package/packages/server/src/routes/git-routes.ts +89 -0
  166. package/packages/server/src/routes/openspec-routes.ts +99 -0
  167. package/packages/server/src/routes/package-routes.ts +172 -0
  168. package/packages/server/src/routes/provider-auth-routes.ts +244 -0
  169. package/packages/server/src/routes/provider-routes.ts +101 -0
  170. package/packages/server/src/routes/route-deps.ts +23 -0
  171. package/packages/server/src/routes/session-routes.ts +91 -0
  172. package/packages/server/src/routes/system-routes.ts +271 -0
  173. package/packages/server/src/server-pid.ts +84 -0
  174. package/packages/server/src/server.ts +554 -0
  175. package/packages/server/src/session-api.ts +330 -0
  176. package/packages/server/src/session-bootstrap.ts +80 -0
  177. package/packages/server/src/session-diff.ts +178 -0
  178. package/packages/server/src/session-discovery.ts +134 -0
  179. package/packages/server/src/session-file-reader.ts +135 -0
  180. package/packages/server/src/session-order-manager.ts +73 -0
  181. package/packages/server/src/session-scanner.ts +233 -0
  182. package/packages/server/src/session-stats-reader.ts +99 -0
  183. package/packages/server/src/terminal-gateway.ts +51 -0
  184. package/packages/server/src/terminal-manager.ts +241 -0
  185. package/packages/server/src/tunnel.ts +329 -0
  186. package/packages/server/tsconfig.json +11 -0
  187. package/packages/shared/package.json +15 -0
  188. package/packages/shared/src/__tests__/config.test.ts +358 -0
  189. package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
  190. package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
  191. package/packages/shared/src/__tests__/protocol.test.ts +243 -0
  192. package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
  193. package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
  194. package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
  195. package/packages/shared/src/archive-types.ts +11 -0
  196. package/packages/shared/src/browser-protocol.ts +534 -0
  197. package/packages/shared/src/config.ts +245 -0
  198. package/packages/shared/src/diff-types.ts +41 -0
  199. package/packages/shared/src/editor-types.ts +18 -0
  200. package/packages/shared/src/mdns-discovery.ts +248 -0
  201. package/packages/shared/src/openspec-activity-detector.ts +109 -0
  202. package/packages/shared/src/openspec-poller.ts +96 -0
  203. package/packages/shared/src/protocol.ts +369 -0
  204. package/packages/shared/src/resolve-jiti.ts +43 -0
  205. package/packages/shared/src/rest-api.ts +255 -0
  206. package/packages/shared/src/server-identity.ts +51 -0
  207. package/packages/shared/src/session-meta.ts +86 -0
  208. package/packages/shared/src/state-replay.ts +174 -0
  209. package/packages/shared/src/stats-extractor.ts +54 -0
  210. package/packages/shared/src/terminal-types.ts +18 -0
  211. package/packages/shared/src/types.ts +351 -0
  212. package/packages/shared/tsconfig.json +8 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Lightweight process metrics collector for bridge heartbeats.
3
+ * Uses Node.js built-in APIs — no external dependencies.
4
+ */
5
+ import os from "node:os";
6
+ import { monitorEventLoopDelay, type IntervalHistogram } from "node:perf_hooks";
7
+ import type { ProcessMetrics } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
8
+
9
+ const ELD_RESOLUTION_MS = 20;
10
+
11
+ let lastCpuUsage: NodeJS.CpuUsage | undefined;
12
+ let lastCpuTime: number | undefined;
13
+ let eld: IntervalHistogram | undefined;
14
+
15
+ /** Start event loop delay monitoring. Call once at init. */
16
+ export function startMetricsMonitor(): void {
17
+ if (eld) return; // already started
18
+ try {
19
+ eld = monitorEventLoopDelay({ resolution: ELD_RESOLUTION_MS });
20
+ eld.enable();
21
+ } catch {
22
+ // monitorEventLoopDelay not available in older Node versions
23
+ }
24
+ }
25
+
26
+ /** Stop event loop delay monitoring. */
27
+ export function stopMetricsMonitor(): void {
28
+ if (eld) {
29
+ eld.disable();
30
+ eld = undefined;
31
+ }
32
+ }
33
+
34
+ /** Collect current process metrics and reset deltas. */
35
+ export function collectMetrics(): ProcessMetrics {
36
+ const mem = process.memoryUsage();
37
+
38
+ // CPU percent since last call
39
+ const now = Date.now();
40
+ const cpuNow = process.cpuUsage();
41
+ let cpuPercent = 0;
42
+ if (lastCpuUsage && lastCpuTime) {
43
+ const elapsedMs = now - lastCpuTime;
44
+ if (elapsedMs > 0) {
45
+ const userDelta = cpuNow.user - lastCpuUsage.user; // microseconds
46
+ const systemDelta = cpuNow.system - lastCpuUsage.system;
47
+ // Total CPU microseconds / elapsed wall-clock microseconds * 100
48
+ cpuPercent = ((userDelta + systemDelta) / (elapsedMs * 1000)) * 100;
49
+ }
50
+ }
51
+ lastCpuUsage = cpuNow;
52
+ lastCpuTime = now;
53
+
54
+ // Event loop max delay since last reset
55
+ let eventLoopMaxMs: number | undefined;
56
+ if (eld) {
57
+ // max is in nanoseconds
58
+ eventLoopMaxMs = Math.round(eld.max / 1_000_000);
59
+ eld.reset();
60
+ }
61
+
62
+ return {
63
+ rss: mem.rss,
64
+ heapUsed: mem.heapUsed,
65
+ heapTotal: mem.heapTotal,
66
+ cpuPercent: Math.round(cpuPercent * 10) / 10,
67
+ eventLoopMaxMs,
68
+ loadAvg1m: Math.round(os.loadavg()[0] * 100) / 100,
69
+ };
70
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Process scanner for detecting child processes of a pi session.
3
+ * Supports Unix (macOS + Linux) via ps/PGID and Windows via wmic/tasklist.
4
+ *
5
+ * Two-phase approach (Unix):
6
+ * 1. CAPTURE: During active bash tool calls, `ps -eo pid=,ppid=` finds children
7
+ * of the pi process (pgrep is not used — it misses detached children on macOS).
8
+ * Grandchildren are found by recursing one level. PGIDs are stored in a tracked set.
9
+ * 2. CHECK: On every scan, verify which tracked PGIDs are still alive via ps.
10
+ * Dead ones are removed from the set.
11
+ *
12
+ * This handles the reparenting problem: children get reparented to PID 1
13
+ * when the bash wrapper exits, but we captured their PGIDs while alive.
14
+ */
15
+ import { spawnSync as defaultSpawnSync } from "node:child_process";
16
+ import type { SpawnSyncReturns } from "node:child_process";
17
+
18
+ export interface ChildProcessInfo {
19
+ pid: number;
20
+ pgid: number;
21
+ command: string;
22
+ elapsedMs: number;
23
+ }
24
+
25
+ /**
26
+ * Parse ps ETIME format into milliseconds.
27
+ * Formats: mm:ss, hh:mm:ss, dd-hh:mm:ss
28
+ */
29
+ export function parseEtime(etime: string): number {
30
+ const trimmed = etime.trim();
31
+ if (!trimmed) return 0;
32
+
33
+ let days = 0;
34
+ let rest = trimmed;
35
+
36
+ const dashIdx = rest.indexOf("-");
37
+ if (dashIdx !== -1) {
38
+ days = parseInt(rest.slice(0, dashIdx), 10);
39
+ if (isNaN(days)) return 0;
40
+ rest = rest.slice(dashIdx + 1);
41
+ }
42
+
43
+ const parts = rest.split(":").map((p) => parseInt(p, 10));
44
+ if (parts.some(isNaN)) return 0;
45
+
46
+ let hours = 0, minutes = 0, seconds = 0;
47
+ if (parts.length === 3) {
48
+ [hours, minutes, seconds] = parts;
49
+ } else if (parts.length === 2) {
50
+ [minutes, seconds] = parts;
51
+ } else {
52
+ return 0;
53
+ }
54
+
55
+ return ((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) * 1000;
56
+ }
57
+
58
+ const DEFAULT_MIN_ELAPSED_MS = 30_000;
59
+
60
+ export type SpawnSyncFn = (cmd: string, args: string[], opts: any) => SpawnSyncReturns<string>;
61
+
62
+ /** Get direct child PIDs of a parent using ps (pgrep misses detached children on macOS). */
63
+ function getChildPids(parentPid: number, spawnSync: SpawnSyncFn): number[] {
64
+ try {
65
+ const result = spawnSync("ps", ["-eo", "pid=,ppid="], {
66
+ encoding: "utf-8",
67
+ timeout: 5000,
68
+ stdio: ["pipe", "pipe", "pipe"],
69
+ });
70
+ if (result.status !== 0 || !result.stdout) return [];
71
+ const pids: number[] = [];
72
+ for (const line of result.stdout.split("\n")) {
73
+ const parts = line.trim().split(/\s+/);
74
+ if (parts.length === 2) {
75
+ const pid = parseInt(parts[0], 10);
76
+ const ppid = parseInt(parts[1], 10);
77
+ if (ppid === parentPid && !isNaN(pid)) pids.push(pid);
78
+ }
79
+ }
80
+ return pids;
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /** Parse one line of ps output: " PID PGID ETIME ARGS..." */
87
+ function parsePsLine(line: string): ChildProcessInfo | null {
88
+ const trimmed = line.trim();
89
+ if (!trimmed) return null;
90
+
91
+ const match = trimmed.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
92
+ if (!match) return null;
93
+
94
+ return {
95
+ pid: parseInt(match[1], 10),
96
+ pgid: parseInt(match[2], 10),
97
+ elapsedMs: parseEtime(match[3]),
98
+ command: match[4],
99
+ };
100
+ }
101
+
102
+ export interface ScanOptions {
103
+ _spawnSync?: SpawnSyncFn;
104
+ }
105
+
106
+ /**
107
+ * Captures new child PIDs of the pi process and adds their PGIDs to the tracked set.
108
+ * Call this during active bash tool calls when children are still in the process tree.
109
+ */
110
+ export function captureChildPgids(
111
+ parentPid: number,
112
+ trackedPgids: Set<number>,
113
+ options?: ScanOptions,
114
+ ): void {
115
+ if ((options as any)?._platform === "win32" || (!((options as any)?._platform) && process.platform === "win32")) return;
116
+
117
+ const spawnSync: SpawnSyncFn = options?._spawnSync ?? defaultSpawnSync;
118
+
119
+ const directChildren = getChildPids(parentPid, spawnSync);
120
+ if (directChildren.length === 0) return;
121
+
122
+ // Collect all PIDs (children + grandchildren)
123
+ const allPids: number[] = [];
124
+ for (const childPid of directChildren) {
125
+ const grandchildren = getChildPids(childPid, spawnSync);
126
+ if (grandchildren.length > 0) {
127
+ allPids.push(...grandchildren);
128
+ } else {
129
+ allPids.push(childPid);
130
+ }
131
+ }
132
+
133
+ if (allPids.length === 0) return;
134
+
135
+ // Get PGIDs for all discovered PIDs
136
+ try {
137
+ const result = spawnSync("ps", ["-p", allPids.join(","), "-o", "pgid="], {
138
+ encoding: "utf-8",
139
+ timeout: 5000,
140
+ stdio: ["pipe", "pipe", "pipe"],
141
+ });
142
+ if (result.status !== 0 || !result.stdout) return;
143
+
144
+ for (const line of result.stdout.split("\n")) {
145
+ const pgid = parseInt(line.trim(), 10);
146
+ if (!isNaN(pgid) && pgid > 0) {
147
+ trackedPgids.add(pgid);
148
+ }
149
+ }
150
+ } catch {
151
+ // ignore
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Scans tracked PGIDs to find which are still alive.
157
+ * Returns live processes, removes dead PGIDs from the set.
158
+ */
159
+ export function scanTrackedProcesses(
160
+ trackedPgids: Set<number>,
161
+ minElapsedMs: number = DEFAULT_MIN_ELAPSED_MS,
162
+ options?: ScanOptions,
163
+ ): ChildProcessInfo[] {
164
+ const platform = (options as any)?._platform ?? process.platform;
165
+ if (platform === "win32" || trackedPgids.size === 0) return [];
166
+
167
+ const spawnSync: SpawnSyncFn = options?._spawnSync ?? defaultSpawnSync;
168
+
169
+ // Find all processes belonging to tracked PGIDs
170
+ // Use ps to find processes by PGID — we check all at once
171
+ const pgidList = Array.from(trackedPgids);
172
+
173
+ try {
174
+ // Get all processes, then filter by PGID
175
+ const result = spawnSync("ps", ["-eo", "pid=,pgid=,etime=,args="], {
176
+ encoding: "utf-8",
177
+ timeout: 5000,
178
+ stdio: ["pipe", "pipe", "pipe"],
179
+ });
180
+ if (result.status !== 0 || !result.stdout) return [];
181
+
182
+ const pgidSet = new Set(pgidList);
183
+ const alivePgids = new Set<number>();
184
+ const processes: ChildProcessInfo[] = [];
185
+
186
+ for (const line of result.stdout.split("\n")) {
187
+ const info = parsePsLine(line);
188
+ if (!info) continue;
189
+ if (!pgidSet.has(info.pgid)) continue;
190
+
191
+ alivePgids.add(info.pgid);
192
+
193
+ // Skip bash/sh wrappers (show the actual commands, not the shell)
194
+ const binary = info.command.split(/\s/)[0]?.split("/").pop() ?? "";
195
+ if (binary === "bash" || binary === "sh") continue;
196
+
197
+ if (info.elapsedMs >= minElapsedMs) {
198
+ processes.push(info);
199
+ }
200
+ }
201
+
202
+ // Remove dead PGIDs from tracked set
203
+ for (const pgid of pgidList) {
204
+ if (!alivePgids.has(pgid)) {
205
+ trackedPgids.delete(pgid);
206
+ }
207
+ }
208
+
209
+ return processes;
210
+ } catch {
211
+ return [];
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Combined scan: capture new children + check tracked PGIDs.
217
+ * Convenience wrapper for the bridge timer.
218
+ */
219
+ export function scanChildProcesses(
220
+ parentPid: number,
221
+ trackedPgids: Set<number>,
222
+ minElapsedMs: number = DEFAULT_MIN_ELAPSED_MS,
223
+ options?: ScanOptions,
224
+ ): ChildProcessInfo[] {
225
+ const platform = (options as any)?._platform ?? process.platform;
226
+ if (platform === "win32") {
227
+ return scanWindowsProcesses(parentPid, minElapsedMs, options);
228
+ }
229
+
230
+ // Phase 1: Capture any new children (during active bash calls)
231
+ captureChildPgids(parentPid, trackedPgids, options);
232
+
233
+ // Phase 2: Check which tracked PGIDs are still alive
234
+ return scanTrackedProcesses(trackedPgids, minElapsedMs, options);
235
+ }
236
+
237
+ /**
238
+ * Kill a process group by PGID using SIGTERM (Unix) or taskkill (Windows).
239
+ * Returns true if signal was sent, false if process was already dead.
240
+ */
241
+ export function killProcessByPgid(pgid: number, options?: ScanOptions): boolean {
242
+ const platform = (options as any)?._platform ?? process.platform;
243
+ if (platform === "win32") {
244
+ return killWindowsProcess(pgid, options);
245
+ }
246
+ try {
247
+ process.kill(-pgid, "SIGTERM");
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ // ---- Windows support ----
255
+
256
+ /** Parse wmic output lines into child process info. */
257
+ function parseWmicLine(line: string): { pid: number; ppid: number; command: string; creationDate: string } | null {
258
+ // wmic outputs: CommandLine CreationDate ParentProcessId ProcessId
259
+ // with fixed-width columns separated by whitespace
260
+ const parts = line.trim().split(/\s{2,}/);
261
+ if (parts.length < 4) return null;
262
+ const pid = parseInt(parts[3], 10);
263
+ const ppid = parseInt(parts[2], 10);
264
+ if (isNaN(pid) || isNaN(ppid)) return null;
265
+ return { pid, ppid, command: parts[0] || "", creationDate: parts[1] || "" };
266
+ }
267
+
268
+ /** Convert wmic CreationDate (yyyyMMddHHmmss.ffffff+ZZZ) to elapsed ms. */
269
+ function wmicDateToElapsedMs(creationDate: string): number {
270
+ if (!creationDate) return 0;
271
+ // Format: 20260410225300.123456+060
272
+ const match = creationDate.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
273
+ if (!match) return 0;
274
+ const created = new Date(
275
+ parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]),
276
+ parseInt(match[4]), parseInt(match[5]), parseInt(match[6])
277
+ );
278
+ return Math.max(0, Date.now() - created.getTime());
279
+ }
280
+
281
+ /** Find all descendant PIDs of a parent on Windows. */
282
+ function getWindowsDescendants(parentPid: number, spawnSync: SpawnSyncFn): ChildProcessInfo[] {
283
+ try {
284
+ const result = spawnSync(
285
+ "wmic",
286
+ ["process", "where", `ParentProcessId=${parentPid}`, "get", "CommandLine,CreationDate,ParentProcessId,ProcessId", "/format:list"],
287
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
288
+ );
289
+ if (result.status !== 0 || !result.stdout) {
290
+ // wmic removed in newer Windows 11 — fallback to tasklist
291
+ return getWindowsDescendantsTasklist(parentPid, spawnSync);
292
+ }
293
+
294
+ const processes: ChildProcessInfo[] = [];
295
+ let current: Partial<{ pid: number; command: string; elapsed: number }> = {};
296
+
297
+ for (const line of result.stdout.split("\n")) {
298
+ const trimmed = line.trim();
299
+ if (!trimmed) {
300
+ if (current.pid) {
301
+ processes.push({
302
+ pid: current.pid,
303
+ pgid: current.pid, // Windows has no PGID; use PID
304
+ command: current.command || "",
305
+ elapsedMs: current.elapsed || 0,
306
+ });
307
+ }
308
+ current = {};
309
+ continue;
310
+ }
311
+ const [key, ...valueParts] = trimmed.split("=");
312
+ const value = valueParts.join("=");
313
+ if (key === "ProcessId") current.pid = parseInt(value, 10);
314
+ if (key === "CommandLine") current.command = value;
315
+ if (key === "CreationDate") current.elapsed = wmicDateToElapsedMs(value);
316
+ }
317
+ // Flush last entry
318
+ if (current.pid) {
319
+ processes.push({
320
+ pid: current.pid,
321
+ pgid: current.pid,
322
+ command: current.command || "",
323
+ elapsedMs: current.elapsed || 0,
324
+ });
325
+ }
326
+
327
+ return processes;
328
+ } catch {
329
+ return [];
330
+ }
331
+ }
332
+
333
+ /** Fallback: use tasklist when wmic is unavailable. */
334
+ function getWindowsDescendantsTasklist(parentPid: number, spawnSync: SpawnSyncFn): ChildProcessInfo[] {
335
+ try {
336
+ // tasklist /FI filters by parent — but tasklist doesn't support ParentProcessId filter
337
+ // Use PowerShell Get-CimInstance as fallback
338
+ const result = spawnSync(
339
+ "powershell",
340
+ ["-NoProfile", "-Command", `Get-CimInstance Win32_Process -Filter "ParentProcessId=${parentPid}" | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json`],
341
+ { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
342
+ );
343
+ if (result.status !== 0 || !result.stdout) return [];
344
+
345
+ const data = JSON.parse(result.stdout);
346
+ const items = Array.isArray(data) ? data : [data];
347
+ return items
348
+ .filter((item: any) => item?.ProcessId)
349
+ .map((item: any) => ({
350
+ pid: item.ProcessId,
351
+ pgid: item.ProcessId,
352
+ command: item.CommandLine || "",
353
+ elapsedMs: item.CreationDate ? Math.max(0, Date.now() - new Date(item.CreationDate).getTime()) : 0,
354
+ }));
355
+ } catch {
356
+ return [];
357
+ }
358
+ }
359
+
360
+ /** Scan child processes on Windows using wmic/PowerShell. */
361
+ export function scanWindowsProcesses(
362
+ parentPid: number,
363
+ minElapsedMs: number = DEFAULT_MIN_ELAPSED_MS,
364
+ options?: ScanOptions,
365
+ ): ChildProcessInfo[] {
366
+ const spawnSync: SpawnSyncFn = options?._spawnSync ?? defaultSpawnSync;
367
+ const children = getWindowsDescendants(parentPid, spawnSync);
368
+
369
+ // Recurse one level for grandchildren
370
+ const all: ChildProcessInfo[] = [];
371
+ for (const child of children) {
372
+ const grandchildren = getWindowsDescendants(child.pid, spawnSync);
373
+ if (grandchildren.length > 0) {
374
+ all.push(...grandchildren);
375
+ } else {
376
+ all.push(child);
377
+ }
378
+ }
379
+
380
+ return all.filter(p => p.elapsedMs >= minElapsedMs);
381
+ }
382
+
383
+ /** Kill a process tree on Windows using taskkill. */
384
+ export function killWindowsProcess(pid: number, options?: ScanOptions): boolean {
385
+ const spawnSync: SpawnSyncFn = options?._spawnSync ?? defaultSpawnSync;
386
+ try {
387
+ const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
388
+ encoding: "utf-8",
389
+ timeout: 5000,
390
+ stdio: ["pipe", "pipe", "pipe"],
391
+ });
392
+ return result.status === 0;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Expand prompt templates from disk for slash commands sent via the dashboard.
3
+ *
4
+ * pi.sendUserMessage() calls session.prompt() with expandPromptTemplates: false,
5
+ * which skips prompt template and skill expansion. This module provides a workaround
6
+ * by reading template/skill files directly and expanding them.
7
+ */
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { readdirSync, statSync } from "node:fs";
11
+
12
+ /** Scan directories for .md prompt template files */
13
+ function findPromptTemplates(cwd: string): Map<string, string> {
14
+ const templates = new Map<string, string>();
15
+ const dirs = [
16
+ join(cwd, ".pi", "prompts"),
17
+ join(cwd, ".pi", "skills"),
18
+ ];
19
+
20
+ for (const dir of dirs) {
21
+ if (!existsSync(dir)) continue;
22
+ try {
23
+ scanDir(dir, templates);
24
+ } catch { /* ignore */ }
25
+ }
26
+ return templates;
27
+ }
28
+
29
+ function scanDir(dir: string, templates: Map<string, string>): void {
30
+ for (const entry of readdirSync(dir)) {
31
+ const fullPath = join(dir, entry);
32
+ try {
33
+ const stat = statSync(fullPath);
34
+ if (stat.isDirectory()) {
35
+ // Check for SKILL.md inside directory
36
+ const skillFile = join(fullPath, "SKILL.md");
37
+ if (existsSync(skillFile)) {
38
+ templates.set(`skill:${entry}`, skillFile);
39
+ }
40
+ } else if (entry.endsWith(".md")) {
41
+ const name = entry.replace(/\.md$/, "");
42
+ templates.set(name, fullPath);
43
+ }
44
+ } catch { /* ignore */ }
45
+ }
46
+ }
47
+
48
+ /** Read template content, stripping YAML frontmatter */
49
+ function readTemplate(filePath: string): string {
50
+ const content = readFileSync(filePath, "utf-8");
51
+ // Strip YAML frontmatter (---\n...\n---)
52
+ const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
53
+ return match ? match[1].trim() : content.trim();
54
+ }
55
+
56
+ /**
57
+ * Expand a slash command by finding and reading the prompt template from disk.
58
+ * Returns the expanded text, or the original text if no template found.
59
+ */
60
+ export function expandPromptTemplateFromDisk(text: string, cwd: string): string {
61
+ if (!text.startsWith("/")) return text;
62
+
63
+ const spaceIndex = text.indexOf(" ");
64
+ const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
65
+ const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
66
+
67
+ const templates = findPromptTemplates(cwd);
68
+ let filePath = templates.get(templateName);
69
+
70
+ // Support colon as alias for hyphen (e.g. /opsx:continue → opsx-continue)
71
+ if (!filePath && templateName.includes(":")) {
72
+ filePath = templates.get(templateName.replace(/:/g, "-"));
73
+ }
74
+
75
+ if (!filePath) return text;
76
+
77
+ try {
78
+ const content = readTemplate(filePath);
79
+ // Simple arg substitution: replace $1, $2, etc. or just append args
80
+ if (argsString) {
81
+ return `${content}\n\n${argsString}`;
82
+ }
83
+ return content;
84
+ } catch {
85
+ return text;
86
+ }
87
+ }