pipane 0.1.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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/bin/pipane.js +22 -0
  4. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +1 -0
  5. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +1 -0
  6. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +1 -0
  7. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +1 -0
  8. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +1 -0
  9. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +1 -0
  10. package/dist/client/assets/index-CQP8RzjO.js +45 -0
  11. package/dist/client/assets/index-CVr_JVrA.js +131 -0
  12. package/dist/client/assets/index-DQxrqu4K.js +4412 -0
  13. package/dist/client/assets/index-DuJn-Nwv.js +3 -0
  14. package/dist/client/assets/index-Pj8zaBYJ.js +1 -0
  15. package/dist/client/assets/index-yL1A-vgM.css +1 -0
  16. package/dist/client/assets/pdf.worker.min-Cpi8b8z3.mjs +28 -0
  17. package/dist/client/favicon.png +0 -0
  18. package/dist/client/index.html +118 -0
  19. package/dist/server/server/attached-session.js +209 -0
  20. package/dist/server/server/load-trace-store.js +48 -0
  21. package/dist/server/server/local-settings.js +376 -0
  22. package/dist/server/server/pi-launch.js +10 -0
  23. package/dist/server/server/pi-runtime.js +32 -0
  24. package/dist/server/server/process-pool.js +254 -0
  25. package/dist/server/server/rest-api.js +289 -0
  26. package/dist/server/server/server.js +355 -0
  27. package/dist/server/server/session-cwd.js +33 -0
  28. package/dist/server/server/session-index.js +231 -0
  29. package/dist/server/server/session-jsonl.js +260 -0
  30. package/dist/server/server/session-lifecycle.js +155 -0
  31. package/dist/server/server/ws-handler.js +808 -0
  32. package/dist/server/shared/jsonl-sync.js +141 -0
  33. package/extensions/canvas.ts +57 -0
  34. package/package.json +82 -0
  35. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +3279 -0
@@ -0,0 +1,376 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const VALID_COLOR_THEMES = ["default", "gruvbox"];
5
+ const VALID_DARK_MODES = ["light", "dark", "system"];
6
+ const DEFAULT_SESSIONS_PER_PROJECT = 5;
7
+ const DEFAULT_SETTINGS = {
8
+ version: 1,
9
+ sidebar: {
10
+ cwdTitle: {
11
+ filters: [],
12
+ },
13
+ sessionsPerProject: DEFAULT_SESSIONS_PER_PROJECT,
14
+ },
15
+ canvas: {
16
+ enabled: false,
17
+ },
18
+ appearance: {
19
+ colorTheme: "gruvbox",
20
+ darkMode: "dark",
21
+ showTokenUsage: true,
22
+ },
23
+ };
24
+ export function getLocalSettingsPath(homeDir = os.homedir()) {
25
+ return path.join(homeDir, ".piweb", "settings.json");
26
+ }
27
+ export function formatSettingsJson(settings) {
28
+ return `${JSON.stringify(settings, null, 2)}\n`;
29
+ }
30
+ export function normalizeCwdForDisplay(cwd, homeDir = os.homedir()) {
31
+ if (!cwd)
32
+ return cwd;
33
+ if (!homeDir)
34
+ return cwd;
35
+ if (cwd === homeDir)
36
+ return "~";
37
+ if (cwd.startsWith(`${homeDir}/`))
38
+ return `~${cwd.slice(homeDir.length)}`;
39
+ return cwd;
40
+ }
41
+ export function applyCwdFilters(input, filters) {
42
+ let out = input;
43
+ for (const filter of filters) {
44
+ out = out.replace(filter.re, filter.replacement);
45
+ }
46
+ return out;
47
+ }
48
+ export class LocalSettingsStore {
49
+ settingsPath;
50
+ homeDir;
51
+ currentSettings = structuredClone(DEFAULT_SETTINGS);
52
+ compiledFilters = [];
53
+ loadErrors = [];
54
+ constructor(opts) {
55
+ this.homeDir = opts?.homeDir ?? os.homedir();
56
+ this.settingsPath = opts?.settingsPath ?? getLocalSettingsPath(this.homeDir);
57
+ this.loadFromDisk();
58
+ }
59
+ get path() {
60
+ return this.settingsPath;
61
+ }
62
+ get settings() {
63
+ return this.currentSettings;
64
+ }
65
+ get errors() {
66
+ return [...this.loadErrors];
67
+ }
68
+ get canvasEnabled() {
69
+ return this.currentSettings.canvas.enabled;
70
+ }
71
+ formatCwdTitle(cwd) {
72
+ const normalized = normalizeCwdForDisplay(cwd, this.homeDir);
73
+ return applyCwdFilters(normalized, this.compiledFilters);
74
+ }
75
+ read() {
76
+ return {
77
+ path: this.settingsPath,
78
+ exists: existsSync(this.settingsPath),
79
+ errors: [...this.loadErrors],
80
+ settings: this.currentSettings,
81
+ formatted: formatSettingsJson(this.currentSettings),
82
+ };
83
+ }
84
+ loadFromDisk() {
85
+ if (!existsSync(this.settingsPath)) {
86
+ this.currentSettings = structuredClone(DEFAULT_SETTINGS);
87
+ this.compiledFilters = [];
88
+ this.loadErrors = [];
89
+ return;
90
+ }
91
+ let content;
92
+ try {
93
+ content = readFileSync(this.settingsPath, "utf8");
94
+ }
95
+ catch (err) {
96
+ this.currentSettings = structuredClone(DEFAULT_SETTINGS);
97
+ this.compiledFilters = [];
98
+ this.loadErrors = [
99
+ `Failed to read local settings at ${this.settingsPath}: ${String(err?.message ?? err)}`,
100
+ ];
101
+ return;
102
+ }
103
+ const result = this.validate(content);
104
+ if (!result.valid || !result.settings) {
105
+ this.currentSettings = structuredClone(DEFAULT_SETTINGS);
106
+ this.compiledFilters = [];
107
+ this.loadErrors = result.errors;
108
+ return;
109
+ }
110
+ this.currentSettings = result.settings;
111
+ this.compiledFilters = compileFilters(result.settings);
112
+ this.loadErrors = [];
113
+ }
114
+ /**
115
+ * Reload settings from disk, but only apply when the on-disk content is valid.
116
+ * Invalid edits are reported in `errors` but do not clobber the last good config.
117
+ *
118
+ * Returns true when the effective in-memory settings changed.
119
+ */
120
+ reloadFromDiskIfValid() {
121
+ const prevFormatted = formatSettingsJson(this.currentSettings);
122
+ if (!existsSync(this.settingsPath)) {
123
+ const next = structuredClone(DEFAULT_SETTINGS);
124
+ const changed = prevFormatted !== formatSettingsJson(next);
125
+ this.currentSettings = next;
126
+ this.compiledFilters = [];
127
+ this.loadErrors = [];
128
+ return changed;
129
+ }
130
+ let content;
131
+ try {
132
+ content = readFileSync(this.settingsPath, "utf8");
133
+ }
134
+ catch (err) {
135
+ this.loadErrors = [
136
+ `Failed to read local settings at ${this.settingsPath}: ${String(err?.message ?? err)}`,
137
+ ];
138
+ return false;
139
+ }
140
+ const result = this.validate(content);
141
+ if (!result.valid || !result.settings) {
142
+ this.loadErrors = result.errors;
143
+ return false;
144
+ }
145
+ this.currentSettings = result.settings;
146
+ this.compiledFilters = compileFilters(result.settings);
147
+ this.loadErrors = [];
148
+ return prevFormatted !== formatSettingsJson(this.currentSettings);
149
+ }
150
+ validate(content) {
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(content);
154
+ }
155
+ catch (err) {
156
+ return {
157
+ valid: false,
158
+ errors: [`Invalid JSON: ${String(err?.message ?? err)}`],
159
+ };
160
+ }
161
+ const errors = [];
162
+ const settings = validateSettingsObject(parsed, errors);
163
+ if (!settings) {
164
+ return { valid: false, errors };
165
+ }
166
+ // Compile regexes during validation so users see precise errors before save.
167
+ for (const [i, filter] of settings.sidebar.cwdTitle.filters.entries()) {
168
+ try {
169
+ new RegExp(filter.pattern, filter.flags ?? "");
170
+ }
171
+ catch (err) {
172
+ errors.push(`sidebar.cwdTitle.filters[${i}].pattern is invalid regex: ${String(err?.message ?? err)}`);
173
+ }
174
+ }
175
+ if (errors.length > 0) {
176
+ return { valid: false, errors };
177
+ }
178
+ return {
179
+ valid: true,
180
+ errors: [],
181
+ settings,
182
+ formatted: formatSettingsJson(settings),
183
+ };
184
+ }
185
+ save(content) {
186
+ const result = this.validate(content);
187
+ if (!result.valid || !result.settings || !result.formatted) {
188
+ return result;
189
+ }
190
+ try {
191
+ mkdirSync(path.dirname(this.settingsPath), { recursive: true });
192
+ const tmp = `${this.settingsPath}.tmp`;
193
+ writeFileSync(tmp, result.formatted, "utf8");
194
+ renameSync(tmp, this.settingsPath);
195
+ }
196
+ catch (err) {
197
+ return {
198
+ valid: false,
199
+ errors: [`Failed to write settings: ${String(err?.message ?? err)}`],
200
+ };
201
+ }
202
+ this.currentSettings = result.settings;
203
+ this.compiledFilters = compileFilters(result.settings);
204
+ this.loadErrors = [];
205
+ return result;
206
+ }
207
+ /**
208
+ * Merge a partial settings object into the current settings.
209
+ * Only the provided top-level sections are merged (deep-merged one level).
210
+ */
211
+ patch(partial) {
212
+ const merged = structuredClone(this.currentSettings);
213
+ for (const key of Object.keys(partial)) {
214
+ if (key === "version")
215
+ continue; // never patch version
216
+ if (merged[key] && typeof merged[key] === "object" && typeof partial[key] === "object" && !Array.isArray(partial[key])) {
217
+ Object.assign(merged[key], partial[key]);
218
+ }
219
+ else {
220
+ merged[key] = partial[key];
221
+ }
222
+ }
223
+ const json = JSON.stringify(merged, null, 2);
224
+ return this.save(json);
225
+ }
226
+ }
227
+ function validateSettingsObject(value, errors) {
228
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
229
+ errors.push("Settings must be a JSON object");
230
+ return null;
231
+ }
232
+ if (value.version !== 1) {
233
+ errors.push("version must be 1");
234
+ }
235
+ const sidebar = value.sidebar;
236
+ if (!sidebar || typeof sidebar !== "object" || Array.isArray(sidebar)) {
237
+ errors.push("sidebar must be an object");
238
+ }
239
+ const cwdTitle = sidebar?.cwdTitle;
240
+ if (!cwdTitle || typeof cwdTitle !== "object" || Array.isArray(cwdTitle)) {
241
+ errors.push("sidebar.cwdTitle must be an object");
242
+ }
243
+ const filtersRaw = cwdTitle?.filters;
244
+ if (!Array.isArray(filtersRaw)) {
245
+ errors.push("sidebar.cwdTitle.filters must be an array");
246
+ }
247
+ const filters = [];
248
+ if (Array.isArray(filtersRaw)) {
249
+ filtersRaw.forEach((raw, i) => {
250
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
251
+ errors.push(`sidebar.cwdTitle.filters[${i}] must be an object`);
252
+ return;
253
+ }
254
+ if (typeof raw.pattern !== "string") {
255
+ errors.push(`sidebar.cwdTitle.filters[${i}].pattern must be a string`);
256
+ }
257
+ if (typeof raw.replacement !== "string") {
258
+ errors.push(`sidebar.cwdTitle.filters[${i}].replacement must be a string`);
259
+ }
260
+ if (raw.flags !== undefined && typeof raw.flags !== "string") {
261
+ errors.push(`sidebar.cwdTitle.filters[${i}].flags must be a string when provided`);
262
+ }
263
+ if (typeof raw.pattern === "string" && typeof raw.replacement === "string") {
264
+ filters.push({
265
+ pattern: raw.pattern,
266
+ replacement: raw.replacement,
267
+ ...(typeof raw.flags === "string" ? { flags: raw.flags } : {}),
268
+ });
269
+ }
270
+ });
271
+ }
272
+ // sidebar.sessionsPerProject (optional, defaults to DEFAULT_SESSIONS_PER_PROJECT)
273
+ let sessionsPerProject = DEFAULT_SESSIONS_PER_PROJECT;
274
+ if (sidebar?.sessionsPerProject !== undefined) {
275
+ if (typeof sidebar.sessionsPerProject !== "number" || !Number.isInteger(sidebar.sessionsPerProject) || sidebar.sessionsPerProject < 1) {
276
+ errors.push("sidebar.sessionsPerProject must be a positive integer");
277
+ }
278
+ else {
279
+ sessionsPerProject = sidebar.sessionsPerProject;
280
+ }
281
+ }
282
+ // canvas section (optional, defaults to { enabled: false })
283
+ const canvasRaw = value.canvas;
284
+ let canvasEnabled = false;
285
+ if (canvasRaw !== undefined) {
286
+ if (!canvasRaw || typeof canvasRaw !== "object" || Array.isArray(canvasRaw)) {
287
+ errors.push("canvas must be an object");
288
+ }
289
+ else if (typeof canvasRaw.enabled !== "boolean") {
290
+ errors.push("canvas.enabled must be a boolean");
291
+ }
292
+ else {
293
+ canvasEnabled = canvasRaw.enabled;
294
+ }
295
+ }
296
+ // appearance section (optional, defaults to { colorTheme: "gruvbox", darkMode: "dark", showTokenUsage: true })
297
+ const appearanceRaw = value.appearance;
298
+ let colorTheme = "gruvbox";
299
+ let darkMode = "dark";
300
+ let showTokenUsage = true;
301
+ if (appearanceRaw !== undefined) {
302
+ if (!appearanceRaw || typeof appearanceRaw !== "object" || Array.isArray(appearanceRaw)) {
303
+ errors.push("appearance must be an object");
304
+ }
305
+ else {
306
+ if (appearanceRaw.colorTheme !== undefined) {
307
+ if (typeof appearanceRaw.colorTheme !== "string" || !VALID_COLOR_THEMES.includes(appearanceRaw.colorTheme)) {
308
+ errors.push(`appearance.colorTheme must be one of: ${VALID_COLOR_THEMES.join(", ")}`);
309
+ }
310
+ else {
311
+ colorTheme = appearanceRaw.colorTheme;
312
+ }
313
+ }
314
+ if (appearanceRaw.darkMode !== undefined) {
315
+ if (typeof appearanceRaw.darkMode !== "string" || !VALID_DARK_MODES.includes(appearanceRaw.darkMode)) {
316
+ errors.push(`appearance.darkMode must be one of: ${VALID_DARK_MODES.join(", ")}`);
317
+ }
318
+ else {
319
+ darkMode = appearanceRaw.darkMode;
320
+ }
321
+ }
322
+ if (appearanceRaw.showTokenUsage !== undefined) {
323
+ if (typeof appearanceRaw.showTokenUsage !== "boolean") {
324
+ errors.push("appearance.showTokenUsage must be a boolean");
325
+ }
326
+ else {
327
+ showTokenUsage = appearanceRaw.showTokenUsage;
328
+ }
329
+ }
330
+ }
331
+ }
332
+ // Validate toolCollapse (optional)
333
+ let toolCollapse;
334
+ if (value.toolCollapse !== undefined) {
335
+ const tc = value.toolCollapse;
336
+ if (!tc || typeof tc !== "object" || Array.isArray(tc)) {
337
+ errors.push("toolCollapse must be an object");
338
+ }
339
+ else if (typeof tc.keepOpen !== "number" || !Number.isFinite(tc.keepOpen) || tc.keepOpen < 0 || Math.floor(tc.keepOpen) !== tc.keepOpen) {
340
+ errors.push("toolCollapse.keepOpen must be a non-negative integer");
341
+ }
342
+ else {
343
+ toolCollapse = { keepOpen: tc.keepOpen };
344
+ }
345
+ }
346
+ if (errors.length > 0)
347
+ return null;
348
+ return {
349
+ version: 1,
350
+ sidebar: {
351
+ cwdTitle: {
352
+ filters,
353
+ },
354
+ sessionsPerProject,
355
+ },
356
+ canvas: {
357
+ enabled: canvasEnabled,
358
+ },
359
+ appearance: {
360
+ colorTheme,
361
+ darkMode,
362
+ showTokenUsage,
363
+ },
364
+ ...(toolCollapse ? { toolCollapse } : {}),
365
+ };
366
+ }
367
+ function compileFilters(settings) {
368
+ const compiled = [];
369
+ for (const filter of settings.sidebar.cwdTitle.filters) {
370
+ compiled.push({
371
+ re: new RegExp(filter.pattern, filter.flags ?? ""),
372
+ replacement: filter.replacement,
373
+ });
374
+ }
375
+ return compiled;
376
+ }
@@ -0,0 +1,10 @@
1
+ export function resolvePiLaunch(piCli) {
2
+ const cli = (piCli ?? "").trim();
3
+ if (!cli) {
4
+ return { command: "pi", baseArgs: [] };
5
+ }
6
+ if (cli.endsWith(".js") || cli.endsWith(".mjs") || cli.endsWith(".cjs")) {
7
+ return { command: "node", baseArgs: [cli] };
8
+ }
9
+ return { command: cli, baseArgs: [] };
10
+ }
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+ import { existsSync as fsExistsSync } from "node:fs";
3
+ import { spawnSync as childSpawnSync } from "node:child_process";
4
+ function looksLikePath(command) {
5
+ return command.includes("/") || command.includes(path.sep);
6
+ }
7
+ export function checkCommandAvailable(command, deps = {}) {
8
+ const existsSync = deps.existsSync ?? fsExistsSync;
9
+ const spawnSync = deps.spawnSync ?? childSpawnSync;
10
+ if (looksLikePath(command)) {
11
+ return existsSync(command);
12
+ }
13
+ const result = spawnSync("which", [command], {
14
+ stdio: "ignore",
15
+ env: process.env,
16
+ });
17
+ return result.status === 0;
18
+ }
19
+ export function isPiInstallable(command, baseArgs) {
20
+ return command === "pi" && baseArgs.length === 0;
21
+ }
22
+ export function makePiNotFoundMessage(command) {
23
+ return `pi command not found: '${command}'. Install pi or set PI_CLI to a working CLI path/binary.`;
24
+ }
25
+ export async function installPiGlobal(deps = {}) {
26
+ const spawnSync = deps.spawnSync ?? childSpawnSync;
27
+ const result = spawnSync("npm", ["install", "-g", "@mariozechner/pi-coding-agent"], {
28
+ stdio: "inherit",
29
+ env: process.env,
30
+ });
31
+ return result.status === 0;
32
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * CWD-aware process pool for pi RPC processes.
3
+ *
4
+ * Processes are grouped by cwd. When acquiring a process for a session,
5
+ * the caller provides the session's cwd and gets a process that was
6
+ * spawned in that directory. This ensures bash/read/edit/write tools
7
+ * operate in the correct project directory.
8
+ *
9
+ * Features:
10
+ * - Lazy spawning per-cwd with configurable pre-warming
11
+ * - Readiness check via get_state RPC (replaces setTimeout(500) hack)
12
+ * - Dead process cleanup on exit
13
+ * - Global process cap
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import * as readline from "node:readline";
17
+ export class ProcessPool {
18
+ /** All live processes, keyed by cwd */
19
+ pools = new Map();
20
+ nextProcId = 0;
21
+ spawnConfig;
22
+ maxProcesses;
23
+ prewarmCount;
24
+ rpcTimeout;
25
+ onProcessExit;
26
+ constructor(spawnConfig, options) {
27
+ this.spawnConfig = spawnConfig;
28
+ this.maxProcesses = options?.maxProcesses ?? 6;
29
+ this.prewarmCount = options?.prewarmCount ?? 2;
30
+ this.rpcTimeout = options?.rpcTimeout ?? 30000;
31
+ this.onProcessExit = options?.onProcessExit;
32
+ }
33
+ /** Get the total count of live processes across all cwds */
34
+ get totalProcesses() {
35
+ let count = 0;
36
+ for (const procs of this.pools.values()) {
37
+ count += procs.filter((p) => p.process.exitCode === null).length;
38
+ }
39
+ return count;
40
+ }
41
+ /** Get all processes (for debug endpoint) */
42
+ getAllProcesses() {
43
+ const all = [];
44
+ for (const procs of this.pools.values()) {
45
+ all.push(...procs);
46
+ }
47
+ return all;
48
+ }
49
+ /**
50
+ * Spawn a new RPC process for the given cwd.
51
+ * Does not wait for readiness — call waitForReady() separately.
52
+ */
53
+ spawn(cwd) {
54
+ const procId = ++this.nextProcId;
55
+ console.log(`[pool] Spawning pi process #${procId} (cwd: ${cwd})...`);
56
+ // Strip NODE_ENV from the child environment so tools spawned by pi
57
+ // (e.g. bash) don't inherit pipane's "production" setting.
58
+ const { NODE_ENV: _, ...parentEnv } = process.env;
59
+ const baseArgs = typeof this.spawnConfig.baseArgs === "function"
60
+ ? this.spawnConfig.baseArgs()
61
+ : this.spawnConfig.baseArgs;
62
+ const child = spawn(this.spawnConfig.command, [
63
+ ...baseArgs,
64
+ ...(this.spawnConfig.extraArgs ?? []),
65
+ ], {
66
+ cwd,
67
+ env: { ...parentEnv, ...(this.spawnConfig.env ?? {}) },
68
+ stdio: ["pipe", "pipe", "pipe"],
69
+ });
70
+ child.stderr?.on("data", (data) => {
71
+ process.stderr.write(`[pi#${procId}] ${data.toString()}`);
72
+ });
73
+ const rl = readline.createInterface({ input: child.stdout, terminal: false });
74
+ const proc = {
75
+ id: procId,
76
+ cwd,
77
+ process: child,
78
+ rl,
79
+ pendingRequests: new Map(),
80
+ requestId: 0,
81
+ lastResponseTime: Date.now(),
82
+ };
83
+ // Set up response handler
84
+ rl.on("line", (line) => {
85
+ let data;
86
+ try {
87
+ data = JSON.parse(line);
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ if (data.type === "response" && data.id && proc.pendingRequests.has(data.id)) {
93
+ const pending = proc.pendingRequests.get(data.id);
94
+ proc.pendingRequests.delete(data.id);
95
+ proc.lastResponseTime = Date.now();
96
+ pending.resolve(data);
97
+ }
98
+ });
99
+ child.on("exit", (code) => {
100
+ console.log(`[pool] pi#${proc.id} exited (code ${code})`);
101
+ // Remove from pool
102
+ const poolForCwd = this.pools.get(cwd);
103
+ if (poolForCwd) {
104
+ const idx = poolForCwd.indexOf(proc);
105
+ if (idx !== -1)
106
+ poolForCwd.splice(idx, 1);
107
+ if (poolForCwd.length === 0)
108
+ this.pools.delete(cwd);
109
+ }
110
+ // Reject any pending requests
111
+ for (const [, pending] of proc.pendingRequests) {
112
+ pending.reject(new Error(`pi process #${proc.id} exited unexpectedly (code ${code})`));
113
+ }
114
+ proc.pendingRequests.clear();
115
+ // Notify caller
116
+ this.onProcessExit?.(proc);
117
+ });
118
+ // Add to pool
119
+ let poolForCwd = this.pools.get(cwd);
120
+ if (!poolForCwd) {
121
+ poolForCwd = [];
122
+ this.pools.set(cwd, poolForCwd);
123
+ }
124
+ poolForCwd.push(proc);
125
+ console.log(`[pool] pi#${procId} spawned (total: ${this.totalProcesses})`);
126
+ return proc;
127
+ }
128
+ /**
129
+ * Wait for a process to be ready by sending a get_state RPC.
130
+ * Falls back to a timeout if the process doesn't respond.
131
+ */
132
+ async waitForReady(proc, timeoutMs = 5000) {
133
+ try {
134
+ await this.sendRpc(proc, { type: "get_state" }, timeoutMs);
135
+ return true;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ /**
142
+ * Get an idle process for the given cwd, or spawn one.
143
+ * An "idle" process is one that's alive and not in the busySet.
144
+ */
145
+ acquire(cwd, busySet) {
146
+ const poolForCwd = this.pools.get(cwd);
147
+ if (poolForCwd) {
148
+ const idle = poolForCwd.find((p) => p.process.exitCode === null && !busySet.has(p));
149
+ if (idle)
150
+ return idle;
151
+ }
152
+ // Check global cap
153
+ if (this.totalProcesses >= this.maxProcesses) {
154
+ return null;
155
+ }
156
+ return this.spawn(cwd);
157
+ }
158
+ /**
159
+ * Get any live process (idle preferred). For model queries that don't
160
+ * need a specific cwd.
161
+ */
162
+ getAny(busySet) {
163
+ // Prefer idle
164
+ for (const procs of this.pools.values()) {
165
+ const idle = procs.find((p) => p.process.exitCode === null && !busySet.has(p));
166
+ if (idle)
167
+ return idle;
168
+ }
169
+ // Fall back to any live process
170
+ for (const procs of this.pools.values()) {
171
+ const live = procs.find((p) => p.process.exitCode === null);
172
+ if (live)
173
+ return live;
174
+ }
175
+ return null;
176
+ }
177
+ /**
178
+ * Evict one idle process from a different cwd to free capacity.
179
+ * Returns the evicted process, or null if none can be evicted.
180
+ */
181
+ evictIdleDifferentCwd(targetCwd, busySet) {
182
+ for (const [cwd, procs] of this.pools) {
183
+ if (cwd === targetCwd)
184
+ continue;
185
+ const victim = procs.find((p) => p.process.exitCode === null && !busySet.has(p));
186
+ if (!victim)
187
+ continue;
188
+ console.log(`[pool] Evicting idle pi#${victim.id} from cwd ${cwd} to make room for ${targetCwd}`);
189
+ victim.process.kill("SIGTERM");
190
+ return victim;
191
+ }
192
+ return null;
193
+ }
194
+ /**
195
+ * Pre-warm the pool with processes for the given cwd.
196
+ */
197
+ /**
198
+ * Pre-warm the pool with processes for the given cwd.
199
+ * Spawns are staggered: each process must be ready (via get_state RPC)
200
+ * before the next one is spawned. This prevents lock contention on
201
+ * shared resources like auth.json during startup.
202
+ */
203
+ async prewarm(cwd) {
204
+ const existing = this.pools.get(cwd)?.filter((p) => p.process.exitCode === null).length ?? 0;
205
+ const needed = Math.min(this.prewarmCount, this.maxProcesses) - existing;
206
+ for (let i = 0; i < needed; i++) {
207
+ if (this.totalProcesses >= this.maxProcesses)
208
+ break;
209
+ const proc = this.spawn(cwd);
210
+ if (i < needed - 1) {
211
+ // Wait for this process to be ready before spawning the next one
212
+ await this.waitForReady(proc);
213
+ }
214
+ }
215
+ }
216
+ /**
217
+ * Send an RPC command to a process and wait for a response.
218
+ */
219
+ sendRpc(proc, command, timeoutMs) {
220
+ if (!proc.process || proc.process.exitCode !== null) {
221
+ return Promise.reject(new Error("RPC process is dead"));
222
+ }
223
+ const timeout = timeoutMs ?? this.rpcTimeout;
224
+ const id = `req_${++proc.requestId}`;
225
+ const fullCommand = { ...command, id };
226
+ return new Promise((resolve, reject) => {
227
+ const timer = setTimeout(() => {
228
+ proc.pendingRequests.delete(id);
229
+ reject(new Error(`Timeout waiting for RPC response to ${command.type}`));
230
+ }, timeout);
231
+ proc.pendingRequests.set(id, {
232
+ resolve: (data) => {
233
+ clearTimeout(timer);
234
+ resolve(data);
235
+ },
236
+ reject: (err) => {
237
+ clearTimeout(timer);
238
+ reject(err);
239
+ },
240
+ });
241
+ proc.process.stdin.write(JSON.stringify(fullCommand) + "\n");
242
+ });
243
+ }
244
+ /**
245
+ * Send an RPC command and throw if it fails.
246
+ */
247
+ async sendRpcChecked(proc, command) {
248
+ const response = await this.sendRpc(proc, command);
249
+ if (!response?.success) {
250
+ throw new Error(response?.error || `RPC command failed: ${command.type}`);
251
+ }
252
+ return response;
253
+ }
254
+ }