@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.2
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 +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- package/packages/shared/src/resolve-jiti.ts +0 -155
|
@@ -11,6 +11,18 @@ import path from "node:path";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import { isUnsafeTestHomeScan } from "./test-env-guard.js";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Minimal interface the registry depends on for keeper-mediated writes
|
|
16
|
+
* and orphan reconciliation. Implemented by `KeeperManager` in
|
|
17
|
+
* `rpc-keeper/keeper-manager.ts`. Injected via
|
|
18
|
+
* `HeadlessPidRegistryOptions.keeperManager` to avoid a circular dep at
|
|
19
|
+
* module load. See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
|
|
20
|
+
*/
|
|
21
|
+
export interface KeeperWriter {
|
|
22
|
+
writeRpcToSockPath(sockPath: string, line: string): Promise<boolean>;
|
|
23
|
+
discoverExistingKeepers(): Promise<Array<{ sessionId: string; keeperPid: number; sockPath: string }>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
/** Default PID file path */
|
|
15
27
|
const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "headless-pids.json");
|
|
16
28
|
|
|
@@ -30,31 +42,99 @@ export interface HeadlessEntry {
|
|
|
30
42
|
* See change: spawn-correlation-token.
|
|
31
43
|
*/
|
|
32
44
|
spawnToken?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Pi process PID, distinct from the spawn-time PID when an RPC keeper
|
|
47
|
+
* sidecar owns pi's stdin (see `rpc-keeper-sidecar`). Set by
|
|
48
|
+
* `linkByToken` from `session_register.pid` once the bridge connects.
|
|
49
|
+
* In non-keeper mode it remains undefined; consumers fall back to
|
|
50
|
+
* `entry.pid`. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
51
|
+
*/
|
|
52
|
+
piPid?: number;
|
|
53
|
+
/**
|
|
54
|
+
* RPC keeper sidecar PID. Set at register-time when the entry was
|
|
55
|
+
* spawned through `spawnHeadlessViaKeeper`. In keeper mode this equals
|
|
56
|
+
* `entry.pid` (the keeper IS the spawned child); the explicit field
|
|
57
|
+
* makes the keeper-vs-non-keeper branch unambiguous in `killBySessionId`
|
|
58
|
+
* and `writeRpc`. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
59
|
+
*/
|
|
60
|
+
keeperPid?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Absolute UDS / named-pipe path the keeper listens on. Set at
|
|
63
|
+
* register-time alongside `keeperPid`. Used by `writeRpc` to forward
|
|
64
|
+
* `dispatch_extension_command` lines without re-deriving the path.
|
|
65
|
+
*/
|
|
66
|
+
keeperSockPath?: string;
|
|
33
67
|
}
|
|
34
68
|
|
|
35
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Serialized format for disk persistence. ALL identity-bearing fields
|
|
71
|
+
* must round-trip across server restart so `linkByToken` / `linkByPid`
|
|
72
|
+
* keep their precise matching after `cleanupOrphans` reclaims entries.
|
|
73
|
+
*
|
|
74
|
+
* Without this, after a dashboard restart the rebuilt entry only has
|
|
75
|
+
* (pid, cwd, spawnedAt). The bridge then re-registers with
|
|
76
|
+
* `{pid: piPid, spawnToken: <omitted-on-reattach>}` and the registry
|
|
77
|
+
* falls all the way through to the cwd-FIFO tier, which mis-maps
|
|
78
|
+
* sessionIds to keeper entries when two sessions share a cwd. Symptom:
|
|
79
|
+
* `/ctx-stats` in session A dispatches to pi-B's keeper; killing A
|
|
80
|
+
* SIGTERMs B's pi. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
81
|
+
*/
|
|
36
82
|
interface PersistedEntry {
|
|
37
83
|
pid: number;
|
|
38
84
|
cwd: string;
|
|
39
85
|
spawnedAt: string;
|
|
86
|
+
spawnToken?: string;
|
|
87
|
+
piPid?: number;
|
|
88
|
+
keeperPid?: number;
|
|
89
|
+
keeperSockPath?: string;
|
|
40
90
|
}
|
|
41
91
|
|
|
42
92
|
interface PidFileData {
|
|
43
93
|
entries: PersistedEntry[];
|
|
44
94
|
}
|
|
45
95
|
|
|
96
|
+
export interface KeeperRegisterOptions {
|
|
97
|
+
keeperPid: number;
|
|
98
|
+
keeperSockPath: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure helper: derive the optional `KeeperRegisterOptions` from a
|
|
103
|
+
* SpawnResult-shaped object. Returns `undefined` when the spawn was not
|
|
104
|
+
* keeper-mediated (no `keeperSockPath`). Lets registration call sites
|
|
105
|
+
* stay one-liners across all spawn paths.
|
|
106
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
107
|
+
*/
|
|
108
|
+
export function keeperOptsFromSpawnResult(
|
|
109
|
+
result: { pid?: number; keeperSockPath?: string },
|
|
110
|
+
): KeeperRegisterOptions | undefined {
|
|
111
|
+
if (typeof result.pid !== "number" || !result.keeperSockPath) return undefined;
|
|
112
|
+
return { keeperPid: result.pid, keeperSockPath: result.keeperSockPath };
|
|
113
|
+
}
|
|
114
|
+
|
|
46
115
|
export interface HeadlessPidRegistry {
|
|
47
116
|
/**
|
|
48
117
|
* Register a newly spawned headless process. The optional `spawnToken`
|
|
49
118
|
* is the server-minted UUID injected into the spawned process's env;
|
|
50
119
|
* storing it lets `linkByToken` resolve identity precisely later.
|
|
51
|
-
*
|
|
120
|
+
* The optional `keeperOpts` marks this entry as keeper-mediated and
|
|
121
|
+
* stores the keeper PID + socket path for `writeRpc` / `killBySessionId`.
|
|
122
|
+
* See change: spawn-correlation-token, add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
52
123
|
*/
|
|
53
|
-
register(
|
|
124
|
+
register(
|
|
125
|
+
pid: number,
|
|
126
|
+
cwd: string,
|
|
127
|
+
proc: ChildProcess,
|
|
128
|
+
spawnToken?: string,
|
|
129
|
+
keeperOpts?: KeeperRegisterOptions,
|
|
130
|
+
): void;
|
|
54
131
|
/**
|
|
55
132
|
* Tier 1 link: find entry by `spawnToken`, set its `sessionId`. Returns
|
|
56
133
|
* `true` on match. The strongest identity — used when the bridge sent
|
|
57
|
-
* `session_register.spawnToken`.
|
|
134
|
+
* `session_register.spawnToken`. When `pid` is provided AND the entry
|
|
135
|
+
* is keeper-mediated, `entry.piPid` is set so `killBySessionId` can
|
|
136
|
+
* SIGTERM pi directly (the keeper auto-exits on pi exit). See change:
|
|
137
|
+
* spawn-correlation-token, add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
58
138
|
*/
|
|
59
139
|
linkByToken(spawnToken: string, sessionId: string, pid?: number): boolean;
|
|
60
140
|
/**
|
|
@@ -70,9 +150,19 @@ export interface HeadlessPidRegistry {
|
|
|
70
150
|
* concurrent same-cwd spawns; tiers 1–2 should pre-empt this.
|
|
71
151
|
*/
|
|
72
152
|
linkSession(sessionId: string, cwd: string): boolean;
|
|
73
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Get the PID linked to a session ID. In keeper mode returns the pi
|
|
155
|
+
* PID once linked (`entry.piPid`), falling back to `entry.pid` (= keeper
|
|
156
|
+
* PID at spawn) if the bridge hasn't connected yet. In non-keeper mode
|
|
157
|
+
* returns `entry.pid` unchanged.
|
|
158
|
+
*/
|
|
74
159
|
getPid(sessionId: string): number | undefined;
|
|
75
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Send SIGTERM to the process linked to a session ID. Returns true if
|
|
162
|
+
* killed. In keeper mode kills pi first (so the keeper's auto-exit-on-
|
|
163
|
+
* pi-exit fires) and falls back to killing the keeper after a brief
|
|
164
|
+
* delay if it survives. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
165
|
+
*/
|
|
76
166
|
killBySessionId(sessionId: string): boolean;
|
|
77
167
|
/** Remove a tracked process by PID. */
|
|
78
168
|
remove(pid: number): void;
|
|
@@ -82,23 +172,69 @@ export interface HeadlessPidRegistry {
|
|
|
82
172
|
size(): number;
|
|
83
173
|
/** Clean up orphan processes from a previous server instance. */
|
|
84
174
|
cleanupOrphans(): void;
|
|
175
|
+
/**
|
|
176
|
+
* Connect to the keeper UDS for `sessionId` and write `line + \n`.
|
|
177
|
+
* Returns false if no entry, no keeper for this session, or if the
|
|
178
|
+
* 3-attempt write to the socket fails. Never throws. Used by the
|
|
179
|
+
* server's dispatch handler to forward extension slash commands to pi.
|
|
180
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6/8).
|
|
181
|
+
*/
|
|
182
|
+
writeRpc(sessionId: string, line: string): Promise<boolean>;
|
|
183
|
+
/**
|
|
184
|
+
* Async startup pass: scan the sessions dir for live keeper sidecars
|
|
185
|
+
* (via the injected `KeeperManager.discoverExistingKeepers`) and
|
|
186
|
+
* reconcile them with the in-memory registry. Live keepers whose
|
|
187
|
+
* registry entry doesn't yet exist are skipped (the bridge will
|
|
188
|
+
* register them on connect). No-op when no `keeperManager` was injected.
|
|
189
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
|
|
190
|
+
*/
|
|
191
|
+
cleanupKeeperOrphans(): Promise<void>;
|
|
192
|
+
/**
|
|
193
|
+
* Inject the keeper writer / discoverer after construction. Necessary
|
|
194
|
+
* because `browser-gateway.ts` constructs the registry before the
|
|
195
|
+
* server creates the `KeeperManager`. Pass `null` to clear (used by tests).
|
|
196
|
+
*/
|
|
197
|
+
setKeeperWriter(writer: KeeperWriter | null): void;
|
|
85
198
|
}
|
|
86
199
|
|
|
87
200
|
export interface HeadlessPidRegistryOptions {
|
|
88
201
|
pidFilePath?: string;
|
|
202
|
+
/**
|
|
203
|
+
* Optional `KeeperWriter` (typically a `KeeperManager`) wired so the
|
|
204
|
+
* registry can delegate UDS writes and orphan reconciliation. May be
|
|
205
|
+
* supplied after construction via `setKeeperWriter` instead.
|
|
206
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
|
|
207
|
+
*/
|
|
208
|
+
keeperManager?: KeeperWriter;
|
|
89
209
|
}
|
|
90
210
|
|
|
91
211
|
export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions): HeadlessPidRegistry {
|
|
92
212
|
const entries = new Map<number, HeadlessEntry>();
|
|
93
213
|
const pidFilePath = options?.pidFilePath ?? DEFAULT_PID_FILE;
|
|
214
|
+
let keeperWriter: KeeperWriter | null = options?.keeperManager ?? null;
|
|
215
|
+
|
|
216
|
+
/** Internal: locate entry by sessionId. */
|
|
217
|
+
function findBySessionId(sessionId: string): HeadlessEntry | undefined {
|
|
218
|
+
for (const entry of entries.values()) {
|
|
219
|
+
if (entry.sessionId === sessionId) return entry;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
94
223
|
|
|
95
224
|
function persist() {
|
|
96
225
|
const data: PidFileData = {
|
|
97
|
-
entries: [...entries.values()].map((e) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
226
|
+
entries: [...entries.values()].map((e) => {
|
|
227
|
+
const out: PersistedEntry = {
|
|
228
|
+
pid: e.pid,
|
|
229
|
+
cwd: e.cwd,
|
|
230
|
+
spawnedAt: new Date(e.spawnedAt).toISOString(),
|
|
231
|
+
};
|
|
232
|
+
if (e.spawnToken) out.spawnToken = e.spawnToken;
|
|
233
|
+
if (e.piPid !== undefined) out.piPid = e.piPid;
|
|
234
|
+
if (e.keeperPid !== undefined) out.keeperPid = e.keeperPid;
|
|
235
|
+
if (e.keeperSockPath) out.keeperSockPath = e.keeperSockPath;
|
|
236
|
+
return out;
|
|
237
|
+
}),
|
|
102
238
|
};
|
|
103
239
|
try {
|
|
104
240
|
writeJsonFile(pidFilePath, data);
|
|
@@ -113,8 +249,25 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
113
249
|
}
|
|
114
250
|
|
|
115
251
|
return {
|
|
116
|
-
register(
|
|
117
|
-
|
|
252
|
+
register(
|
|
253
|
+
pid: number,
|
|
254
|
+
cwd: string,
|
|
255
|
+
proc: ChildProcess,
|
|
256
|
+
spawnToken?: string,
|
|
257
|
+
keeperOpts?: KeeperRegisterOptions,
|
|
258
|
+
) {
|
|
259
|
+
const entry: HeadlessEntry = {
|
|
260
|
+
pid,
|
|
261
|
+
cwd,
|
|
262
|
+
process: proc,
|
|
263
|
+
spawnedAt: Date.now(),
|
|
264
|
+
spawnToken,
|
|
265
|
+
};
|
|
266
|
+
if (keeperOpts) {
|
|
267
|
+
entry.keeperPid = keeperOpts.keeperPid;
|
|
268
|
+
entry.keeperSockPath = keeperOpts.keeperSockPath;
|
|
269
|
+
}
|
|
270
|
+
entries.set(pid, entry);
|
|
118
271
|
proc.on("exit", () => {
|
|
119
272
|
entries.delete(pid);
|
|
120
273
|
persist();
|
|
@@ -122,11 +275,22 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
122
275
|
persist();
|
|
123
276
|
},
|
|
124
277
|
|
|
125
|
-
linkByToken(spawnToken: string, sessionId: string,
|
|
278
|
+
linkByToken(spawnToken: string, sessionId: string, pid?: number): boolean {
|
|
126
279
|
if (!spawnToken) return false;
|
|
127
280
|
for (const entry of entries.values()) {
|
|
128
281
|
if (entry.spawnToken === spawnToken && !entry.sessionId) {
|
|
129
282
|
entry.sessionId = sessionId;
|
|
283
|
+
// Keeper-mode: store pi's PID separately so killBySessionId can
|
|
284
|
+
// SIGTERM pi directly (the keeper auto-exits on pi exit).
|
|
285
|
+
// Non-keeper mode leaves piPid undefined; getPid falls back to
|
|
286
|
+
// entry.pid. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
287
|
+
if (entry.keeperPid !== undefined && pid !== undefined && pid !== entry.pid) {
|
|
288
|
+
entry.piPid = pid;
|
|
289
|
+
// Persist immediately so a future cleanupOrphans reclaim has
|
|
290
|
+
// piPid available for linkByPid to match against (the token
|
|
291
|
+
// is omitted by the bridge on reattach).
|
|
292
|
+
persist();
|
|
293
|
+
}
|
|
130
294
|
return true;
|
|
131
295
|
}
|
|
132
296
|
}
|
|
@@ -134,11 +298,27 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
134
298
|
},
|
|
135
299
|
|
|
136
300
|
linkByPid(sessionId: string, pid: number): boolean {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
301
|
+
// Tier 2a: match by Map key (= spawn-time pid). Always tried first
|
|
302
|
+
// because it's O(1) and exact for non-keeper mode.
|
|
303
|
+
const direct = entries.get(pid);
|
|
304
|
+
if (direct && !direct.sessionId) {
|
|
305
|
+
direct.sessionId = sessionId;
|
|
140
306
|
return true;
|
|
141
307
|
}
|
|
308
|
+
// Tier 2b: keeper-mode reattach. After server restart, the Map is
|
|
309
|
+
// keyed by `keeperPid` (the spawn-time pid in keeper mode); pi's
|
|
310
|
+
// actual PID lives in `entry.piPid`. The bridge's session_register
|
|
311
|
+
// sends pi's PID, so direct Map lookup misses. Iterate to find a
|
|
312
|
+
// matching piPid — critical for correct sessionId↔pi mapping when
|
|
313
|
+
// multiple sessions share a cwd (otherwise the cwd-FIFO fallback
|
|
314
|
+
// mis-maps and `/ctx-stats` dispatches to the wrong keeper).
|
|
315
|
+
// See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
316
|
+
for (const entry of entries.values()) {
|
|
317
|
+
if (entry.piPid === pid && !entry.sessionId) {
|
|
318
|
+
entry.sessionId = sessionId;
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
142
322
|
return false;
|
|
143
323
|
},
|
|
144
324
|
|
|
@@ -153,32 +333,62 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
153
333
|
},
|
|
154
334
|
|
|
155
335
|
getPid(sessionId: string): number | undefined {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
336
|
+
const entry = findBySessionId(sessionId);
|
|
337
|
+
if (!entry) return undefined;
|
|
338
|
+
// Keeper mode: prefer the linked pi PID; fall back to entry.pid (=
|
|
339
|
+
// keeper PID) when the bridge hasn't connected yet. Non-keeper mode
|
|
340
|
+
// returns entry.pid unchanged.
|
|
341
|
+
// See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
342
|
+
return entry.piPid ?? entry.pid;
|
|
162
343
|
},
|
|
163
344
|
|
|
164
345
|
killBySessionId(sessionId: string): boolean {
|
|
165
|
-
|
|
166
|
-
|
|
346
|
+
const entry = findBySessionId(sessionId);
|
|
347
|
+
if (!entry) return false;
|
|
348
|
+
|
|
349
|
+
// Keeper-mediated entry: kill pi first so the keeper's
|
|
350
|
+
// auto-exit-on-pi-exit handler fires; schedule a fallback SIGTERM
|
|
351
|
+
// to the keeper if it survives the brief grace window.
|
|
352
|
+
// See change: add-rpc-stdin-dispatch-with-keeper-sidecar (task 6.4).
|
|
353
|
+
if (entry.keeperPid !== undefined) {
|
|
354
|
+
const piPid = entry.piPid;
|
|
355
|
+
const keeperPid = entry.keeperPid;
|
|
356
|
+
let killedSomething = false;
|
|
357
|
+
if (piPid !== undefined) {
|
|
167
358
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return false;
|
|
359
|
+
killPidWithGroup(piPid, "SIGTERM");
|
|
360
|
+
killedSomething = true;
|
|
361
|
+
} catch { /* pi may already be dead */ }
|
|
362
|
+
}
|
|
363
|
+
// Fallback: 200 ms grace for the keeper's auto-exit; SIGTERM if it
|
|
364
|
+
// survives. Fire-and-forget to keep killBySessionId synchronous.
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
if (isProcessAlive(keeperPid)) {
|
|
367
|
+
try { killPidWithGroup(keeperPid, "SIGTERM"); } catch { /* ignore */ }
|
|
178
368
|
}
|
|
369
|
+
}, 200).unref?.();
|
|
370
|
+
// If pi was unknown (bridge never connected), fall through to
|
|
371
|
+
// killing the keeper directly so the spawn cleanup completes.
|
|
372
|
+
if (!killedSomething) {
|
|
373
|
+
try { killPidWithGroup(keeperPid, "SIGTERM"); killedSomething = true; }
|
|
374
|
+
catch { /* ignore */ }
|
|
179
375
|
}
|
|
376
|
+
entries.delete(entry.pid);
|
|
377
|
+
persist();
|
|
378
|
+
return killedSomething;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Non-keeper path (legacy): kill the spawn-time PID directly.
|
|
382
|
+
try {
|
|
383
|
+
killPidWithGroup(entry.pid, "SIGTERM");
|
|
384
|
+
entries.delete(entry.pid);
|
|
385
|
+
persist();
|
|
386
|
+
return true;
|
|
387
|
+
} catch {
|
|
388
|
+
entries.delete(entry.pid);
|
|
389
|
+
persist();
|
|
390
|
+
return false;
|
|
180
391
|
}
|
|
181
|
-
return false;
|
|
182
392
|
},
|
|
183
393
|
|
|
184
394
|
remove(pid: number) {
|
|
@@ -207,6 +417,46 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
207
417
|
return entries.size;
|
|
208
418
|
},
|
|
209
419
|
|
|
420
|
+
setKeeperWriter(writer: KeeperWriter | null) {
|
|
421
|
+
keeperWriter = writer;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
async writeRpc(sessionId: string, line: string): Promise<boolean> {
|
|
425
|
+
const entry = findBySessionId(sessionId);
|
|
426
|
+
if (!entry || !entry.keeperSockPath || !keeperWriter) return false;
|
|
427
|
+
return keeperWriter.writeRpcToSockPath(entry.keeperSockPath, line);
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
async cleanupKeeperOrphans(): Promise<void> {
|
|
431
|
+
if (isUnsafeTestHomeScan()) {
|
|
432
|
+
console.warn("[headless-pid-registry] cleanupKeeperOrphans() blocked: running under vitest with real HOME");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!keeperWriter) return;
|
|
436
|
+
// KeeperManager.discoverExistingKeepers does the heavy lifting:
|
|
437
|
+
// unlinks stale sockets, SIGTERMs orphans whose pi child is dead.
|
|
438
|
+
// The registry only needs to know about live pairs so it can
|
|
439
|
+
// attach keeper info to existing entries (matched by spawn-time
|
|
440
|
+
// pid via the persisted sidecar PID).
|
|
441
|
+
try {
|
|
442
|
+
const live = await keeperWriter.discoverExistingKeepers();
|
|
443
|
+
for (const k of live) {
|
|
444
|
+
// Reattach to any existing entry whose spawn-time PID matches
|
|
445
|
+
// the keeper PID (set when the previous server instance ran
|
|
446
|
+
// spawnHeadlessViaKeeper). Defensive: do not blow away entries
|
|
447
|
+
// that already have keeperPid set.
|
|
448
|
+
const existing = entries.get(k.keeperPid);
|
|
449
|
+
if (existing && existing.keeperPid === undefined) {
|
|
450
|
+
existing.keeperPid = k.keeperPid;
|
|
451
|
+
existing.keeperSockPath = k.sockPath;
|
|
452
|
+
persist();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
console.warn("[headless-pid-registry] cleanupKeeperOrphans failed", err);
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
|
|
210
460
|
cleanupOrphans() {
|
|
211
461
|
if (isUnsafeTestHomeScan()) {
|
|
212
462
|
console.warn("[headless-pid-registry] cleanupOrphans() blocked: running under vitest with real HOME");
|
|
@@ -234,16 +484,24 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
234
484
|
continue;
|
|
235
485
|
}
|
|
236
486
|
|
|
237
|
-
// Alive and not too old — reclaim into registry
|
|
238
|
-
//
|
|
239
|
-
//
|
|
487
|
+
// Alive and not too old — reclaim into registry.
|
|
488
|
+
// ALL identity fields are restored so the post-restart three-tier
|
|
489
|
+
// link (token → pid → cwd-FIFO) keeps its precision; without piPid
|
|
490
|
+
// / spawnToken, keeper-mode sessions mis-map under cwd-FIFO when
|
|
491
|
+
// two sessions share a cwd. See change:
|
|
492
|
+
// add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
240
493
|
const dummyProc = new EventEmitter() as ChildProcess;
|
|
241
|
-
|
|
494
|
+
const reclaimed: HeadlessEntry = {
|
|
242
495
|
pid: entry.pid,
|
|
243
496
|
cwd: entry.cwd,
|
|
244
497
|
process: dummyProc,
|
|
245
498
|
spawnedAt,
|
|
246
|
-
}
|
|
499
|
+
};
|
|
500
|
+
if (entry.spawnToken) reclaimed.spawnToken = entry.spawnToken;
|
|
501
|
+
if (entry.piPid !== undefined) reclaimed.piPid = entry.piPid;
|
|
502
|
+
if (entry.keeperPid !== undefined) reclaimed.keeperPid = entry.keeperPid;
|
|
503
|
+
if (entry.keeperSockPath) reclaimed.keeperSockPath = entry.keeperSockPath;
|
|
504
|
+
entries.set(entry.pid, reclaimed);
|
|
247
505
|
}
|
|
248
506
|
|
|
249
507
|
persist();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects and removes legacy `@mariozechner/pi-coding-agent` installs.
|
|
3
|
+
*
|
|
4
|
+
* Pi was renamed to `@earendil-works/pi-coding-agent` at v0.74. The legacy
|
|
5
|
+
* scope is published only up to v0.73.x and conflicts with the new scope's
|
|
6
|
+
* `bin/pi` symlink in npm-global (EEXIST). This module surfaces legacy
|
|
7
|
+
* installs so the UI can offer a one-click cleanup.
|
|
8
|
+
*
|
|
9
|
+
* Detection is read-only and cheap (3 fs.stat calls + optional `npm root
|
|
10
|
+
* -g`). Cleanup is gated behind a POST endpoint — never silent.
|
|
11
|
+
*
|
|
12
|
+
* Scopes scanned:
|
|
13
|
+
* - npm-global: `$(npm root -g)/@mariozechner/pi-coding-agent`
|
|
14
|
+
* - npx-cache: `~/.npm/_npx/<hash>/node_modules/@mariozechner/pi-coding-agent`
|
|
15
|
+
* - managed: `~/.pi-dashboard/node_modules/@mariozechner/pi-coding-agent`
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import { execSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
21
|
+
|
|
22
|
+
export const LEGACY_PI_PACKAGE = "@mariozechner/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
export type LegacyPiScope = "npm-global" | "npx-cache" | "managed";
|
|
25
|
+
|
|
26
|
+
export interface LegacyPiInstall {
|
|
27
|
+
scope: LegacyPiScope;
|
|
28
|
+
path: string;
|
|
29
|
+
version: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LegacyPiCleanupResult {
|
|
33
|
+
scope: LegacyPiScope;
|
|
34
|
+
path: string;
|
|
35
|
+
removed: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Pure helpers (no I/O) ──────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Build the legacy package path under a given node_modules root. */
|
|
42
|
+
export function legacyPathUnder(nodeModulesDir: string): string {
|
|
43
|
+
return path.join(nodeModulesDir, ...LEGACY_PI_PACKAGE.split("/"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read `version` from a package.json blob; returns null on any parse failure. */
|
|
47
|
+
export function parseVersion(packageJsonRaw: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(packageJsonRaw);
|
|
50
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Detection ──────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function safeStatDir(p: string): boolean {
|
|
59
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readVersionOf(packageDir: string): string | null {
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(path.join(packageDir, "package.json"), "utf-8");
|
|
65
|
+
return parseVersion(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectNpmGlobal(): LegacyPiInstall | null {
|
|
72
|
+
let globalRoot: string;
|
|
73
|
+
try {
|
|
74
|
+
globalRoot = execSync("npm root -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
75
|
+
} catch {
|
|
76
|
+
return null; // npm not available, or call failed — treat as no install
|
|
77
|
+
}
|
|
78
|
+
if (!globalRoot) return null;
|
|
79
|
+
const pkgDir = legacyPathUnder(globalRoot);
|
|
80
|
+
if (!safeStatDir(pkgDir)) return null;
|
|
81
|
+
return { scope: "npm-global", path: pkgDir, version: readVersionOf(pkgDir) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function detectNpxCache(): LegacyPiInstall[] {
|
|
85
|
+
const root = path.join(os.homedir(), ".npm", "_npx");
|
|
86
|
+
let entries: string[];
|
|
87
|
+
try { entries = fs.readdirSync(root); } catch { return []; }
|
|
88
|
+
const found: LegacyPiInstall[] = [];
|
|
89
|
+
for (const hash of entries) {
|
|
90
|
+
const pkgDir = legacyPathUnder(path.join(root, hash, "node_modules"));
|
|
91
|
+
if (safeStatDir(pkgDir)) {
|
|
92
|
+
found.push({ scope: "npx-cache", path: pkgDir, version: readVersionOf(pkgDir) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detectManaged(): LegacyPiInstall | null {
|
|
99
|
+
const pkgDir = legacyPathUnder(path.join(os.homedir(), ".pi-dashboard", "node_modules"));
|
|
100
|
+
if (!safeStatDir(pkgDir)) return null;
|
|
101
|
+
return { scope: "managed", path: pkgDir, version: readVersionOf(pkgDir) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Scan all three locations for legacy pi installs. Synchronous because
|
|
106
|
+
* the cost is dominated by one `npm root -g` invocation (~50ms once);
|
|
107
|
+
* everything else is fs.statSync. Called at startup and on POST refresh.
|
|
108
|
+
*/
|
|
109
|
+
export function detectLegacyPiInstalls(): LegacyPiInstall[] {
|
|
110
|
+
const found: LegacyPiInstall[] = [];
|
|
111
|
+
const g = detectNpmGlobal();
|
|
112
|
+
if (g) found.push(g);
|
|
113
|
+
found.push(...detectNpxCache());
|
|
114
|
+
const m = detectManaged();
|
|
115
|
+
if (m) found.push(m);
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Cleanup ────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function rmrf(target: string): void {
|
|
122
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function removeOne(install: LegacyPiInstall): LegacyPiCleanupResult {
|
|
126
|
+
const base: Pick<LegacyPiCleanupResult, "scope" | "path"> = { scope: install.scope, path: install.path };
|
|
127
|
+
try {
|
|
128
|
+
if (install.scope === "npm-global") {
|
|
129
|
+
// npm-global needs the package-manager call so any bin symlinks are
|
|
130
|
+
// cleaned up too. Using `--no-fund --no-audit` to keep output quiet.
|
|
131
|
+
execSync(`npm uninstall -g --no-fund --no-audit ${LEGACY_PI_PACKAGE}`, {
|
|
132
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
133
|
+
encoding: "utf-8",
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
// npx-cache and managed are plain node_modules subtrees we can rm.
|
|
137
|
+
rmrf(install.path);
|
|
138
|
+
}
|
|
139
|
+
return { ...base, removed: true };
|
|
140
|
+
} catch (err: any) {
|
|
141
|
+
return { ...base, removed: false, error: err?.message ?? String(err) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove all detected legacy installs. Each scope is attempted
|
|
147
|
+
* independently; one failure does not abort the others.
|
|
148
|
+
*/
|
|
149
|
+
export function uninstallLegacyPi(installs: readonly LegacyPiInstall[]): LegacyPiCleanupResult[] {
|
|
150
|
+
return installs.map(removeOne);
|
|
151
|
+
}
|