@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.
- package/AGENTS.md +342 -0
- package/README.md +619 -0
- package/docs/architecture.md +646 -0
- package/package.json +92 -0
- package/packages/extension/package.json +33 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
- package/packages/extension/src/__tests__/connection.test.ts +344 -0
- package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
- package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
- package/packages/extension/src/__tests__/git-info.test.ts +112 -0
- package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
- package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
- package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
- package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
- package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
- package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
- package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
- package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
- package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
- package/packages/extension/src/ask-user-tool.ts +63 -0
- package/packages/extension/src/bridge-context.ts +64 -0
- package/packages/extension/src/bridge.ts +926 -0
- package/packages/extension/src/command-handler.ts +538 -0
- package/packages/extension/src/connection.ts +204 -0
- package/packages/extension/src/dev-build.ts +39 -0
- package/packages/extension/src/event-forwarder.ts +40 -0
- package/packages/extension/src/flow-event-wiring.ts +102 -0
- package/packages/extension/src/git-info.ts +65 -0
- package/packages/extension/src/git-link-builder.ts +112 -0
- package/packages/extension/src/model-tracker.ts +56 -0
- package/packages/extension/src/pi-env.d.ts +23 -0
- package/packages/extension/src/process-metrics.ts +70 -0
- package/packages/extension/src/process-scanner.ts +396 -0
- package/packages/extension/src/prompt-expander.ts +87 -0
- package/packages/extension/src/provider-register.ts +276 -0
- package/packages/extension/src/server-auto-start.ts +87 -0
- package/packages/extension/src/server-launcher.ts +82 -0
- package/packages/extension/src/server-probe.ts +33 -0
- package/packages/extension/src/session-sync.ts +154 -0
- package/packages/extension/src/source-detector.ts +26 -0
- package/packages/extension/src/ui-proxy.ts +269 -0
- package/packages/extension/tsconfig.json +11 -0
- package/packages/server/package.json +37 -0
- package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
- package/packages/server/src/__tests__/auth.test.ts +224 -0
- package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
- package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
- package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
- package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
- package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
- package/packages/server/src/__tests__/config-api.test.ts +104 -0
- package/packages/server/src/__tests__/cors.test.ts +48 -0
- package/packages/server/src/__tests__/directory-service.test.ts +240 -0
- package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
- package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
- package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
- package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
- package/packages/server/src/__tests__/extension-register.test.ts +61 -0
- package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
- package/packages/server/src/__tests__/git-operations.test.ts +251 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
- package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
- package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
- package/packages/server/src/__tests__/json-store.test.ts +70 -0
- package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
- package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
- package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
- package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
- package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
- package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
- package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
- package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
- package/packages/server/src/__tests__/package-routes.test.ts +172 -0
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
- package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
- package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
- package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
- package/packages/server/src/__tests__/process-manager.test.ts +184 -0
- package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
- package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
- package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
- package/packages/server/src/__tests__/server-pid.test.ts +89 -0
- package/packages/server/src/__tests__/session-api.test.ts +244 -0
- package/packages/server/src/__tests__/session-diff.test.ts +138 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
- package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
- package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
- package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
- package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
- package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
- package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
- package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
- package/packages/server/src/__tests__/tunnel.test.ts +206 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
- package/packages/server/src/auth-plugin.ts +302 -0
- package/packages/server/src/auth.ts +323 -0
- package/packages/server/src/browse.ts +55 -0
- package/packages/server/src/browser-gateway.ts +495 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
- package/packages/server/src/browser-handlers/handler-context.ts +45 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
- package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
- package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
- package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
- package/packages/server/src/cli.ts +347 -0
- package/packages/server/src/config-api.ts +130 -0
- package/packages/server/src/directory-service.ts +162 -0
- package/packages/server/src/editor-detection.ts +60 -0
- package/packages/server/src/editor-manager.ts +352 -0
- package/packages/server/src/editor-proxy.ts +134 -0
- package/packages/server/src/editor-registry.ts +108 -0
- package/packages/server/src/event-status-extraction.ts +131 -0
- package/packages/server/src/event-wiring.ts +589 -0
- package/packages/server/src/extension-register.ts +92 -0
- package/packages/server/src/git-operations.ts +200 -0
- package/packages/server/src/headless-pid-registry.ts +207 -0
- package/packages/server/src/idle-timer.ts +61 -0
- package/packages/server/src/json-store.ts +32 -0
- package/packages/server/src/localhost-guard.ts +117 -0
- package/packages/server/src/memory-event-store.ts +193 -0
- package/packages/server/src/memory-session-manager.ts +123 -0
- package/packages/server/src/meta-persistence.ts +64 -0
- package/packages/server/src/migrate-persistence.ts +195 -0
- package/packages/server/src/npm-search-proxy.ts +143 -0
- package/packages/server/src/oauth-callback-server.ts +177 -0
- package/packages/server/src/openspec-archive.ts +60 -0
- package/packages/server/src/package-manager-wrapper.ts +200 -0
- package/packages/server/src/pending-fork-registry.ts +53 -0
- package/packages/server/src/pending-load-manager.ts +110 -0
- package/packages/server/src/pending-resume-registry.ts +69 -0
- package/packages/server/src/pi-gateway.ts +419 -0
- package/packages/server/src/pi-resource-scanner.ts +369 -0
- package/packages/server/src/preferences-store.ts +116 -0
- package/packages/server/src/process-manager.ts +311 -0
- package/packages/server/src/provider-auth-handlers.ts +438 -0
- package/packages/server/src/provider-auth-storage.ts +200 -0
- package/packages/server/src/resolve-path.ts +12 -0
- package/packages/server/src/routes/editor-routes.ts +86 -0
- package/packages/server/src/routes/file-routes.ts +116 -0
- package/packages/server/src/routes/git-routes.ts +89 -0
- package/packages/server/src/routes/openspec-routes.ts +99 -0
- package/packages/server/src/routes/package-routes.ts +172 -0
- package/packages/server/src/routes/provider-auth-routes.ts +244 -0
- package/packages/server/src/routes/provider-routes.ts +101 -0
- package/packages/server/src/routes/route-deps.ts +23 -0
- package/packages/server/src/routes/session-routes.ts +91 -0
- package/packages/server/src/routes/system-routes.ts +271 -0
- package/packages/server/src/server-pid.ts +84 -0
- package/packages/server/src/server.ts +554 -0
- package/packages/server/src/session-api.ts +330 -0
- package/packages/server/src/session-bootstrap.ts +80 -0
- package/packages/server/src/session-diff.ts +178 -0
- package/packages/server/src/session-discovery.ts +134 -0
- package/packages/server/src/session-file-reader.ts +135 -0
- package/packages/server/src/session-order-manager.ts +73 -0
- package/packages/server/src/session-scanner.ts +233 -0
- package/packages/server/src/session-stats-reader.ts +99 -0
- package/packages/server/src/terminal-gateway.ts +51 -0
- package/packages/server/src/terminal-manager.ts +241 -0
- package/packages/server/src/tunnel.ts +329 -0
- package/packages/server/tsconfig.json +11 -0
- package/packages/shared/package.json +15 -0
- package/packages/shared/src/__tests__/config.test.ts +358 -0
- package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
- package/packages/shared/src/__tests__/protocol.test.ts +243 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
- package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
- package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
- package/packages/shared/src/archive-types.ts +11 -0
- package/packages/shared/src/browser-protocol.ts +534 -0
- package/packages/shared/src/config.ts +245 -0
- package/packages/shared/src/diff-types.ts +41 -0
- package/packages/shared/src/editor-types.ts +18 -0
- package/packages/shared/src/mdns-discovery.ts +248 -0
- package/packages/shared/src/openspec-activity-detector.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +96 -0
- package/packages/shared/src/protocol.ts +369 -0
- package/packages/shared/src/resolve-jiti.ts +43 -0
- package/packages/shared/src/rest-api.ts +255 -0
- package/packages/shared/src/server-identity.ts +51 -0
- package/packages/shared/src/session-meta.ts +86 -0
- package/packages/shared/src/state-replay.ts +174 -0
- package/packages/shared/src/stats-extractor.ts +54 -0
- package/packages/shared/src/terminal-types.ts +18 -0
- package/packages/shared/src/types.ts +351 -0
- 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
|
+
}
|