pipane 0.1.4 → 0.1.6
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/README.md +34 -1
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/index-Dl_wdLZH.css +1 -0
- package/dist/client/assets/index-hNqbnG06.js +2482 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/bundled-extensions.js +6 -0
- package/dist/server/server/extension-status.js +31 -0
- package/dist/server/server/local-settings.js +15 -0
- package/dist/server/server/pi-runtime.js +1 -1
- package/dist/server/server/process-pool.js +177 -120
- package/dist/server/server/rest-api.js +7 -74
- package/dist/server/server/server.js +37 -49
- package/dist/server/server/session-actor.js +202 -0
- package/dist/server/server/session-index.js +18 -2
- package/dist/server/server/session-jsonl.js +49 -6
- package/dist/server/server/session-registry.js +67 -0
- package/dist/server/server/worktree-name.js +39 -0
- package/dist/server/server/ws-handler.js +630 -342
- package/dist/server/shared/jsonl-sync.js +114 -3
- package/dist/server/shared/rpc-timeouts.js +4 -0
- package/dist/server/shared/thinking-levels.js +94 -0
- package/dist/server/shared/ws-protocol.js +1 -0
- package/extensions/canvas.ts +1 -1
- package/package.json +26 -14
- package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
- package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
- package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
- package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
- package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
- package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
- package/dist/client/assets/index-Bf_69wyB.js +0 -3
- package/dist/client/assets/index-CUE9pgq9.css +0 -1
- package/dist/client/assets/index-CekVLaL6.js +0 -1
- package/dist/client/assets/index-Cp2vV6HJ.js +0 -4433
- package/dist/client/assets/index-D14II7Hd.js +0 -45
- package/dist/client/assets/index-D16nGmo3.js +0 -131
- package/dist/server/server/attached-session.js +0 -209
- package/dist/server/server/load-trace-store.js +0 -48
- package/dist/server/server/session-lifecycle.js +0 -155
- package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
package/dist/client/index.html
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
if (colorTheme && colorTheme !== "default") root.setAttribute("data-color-theme", colorTheme);
|
|
18
18
|
})();
|
|
19
19
|
</script>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
21
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-hNqbnG06.js"></script>
|
|
21
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Dl_wdLZH.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
|
24
24
|
<div id="app">
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
/** Absolute entrypoint for the bundled common-subscription usage extension. */
|
|
4
|
+
export function resolveUsageExtensionPath() {
|
|
5
|
+
return require.resolve("@sreetej510/pi-usage/dist/index.js");
|
|
6
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
export const PROVIDER_USAGE_STATUS_KEY = "provider-usage";
|
|
3
|
+
const MAX_STATUS_KEY_LENGTH = 128;
|
|
4
|
+
const MAX_STATUS_TEXT_LENGTH = 512;
|
|
5
|
+
const KEY_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
|
|
6
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
|
|
7
|
+
export function isValidExtensionStatusKey(value) {
|
|
8
|
+
return typeof value === "string"
|
|
9
|
+
&& value.length > 0
|
|
10
|
+
&& value.length <= MAX_STATUS_KEY_LENGTH
|
|
11
|
+
&& !KEY_CONTROL_CHARACTER.test(value);
|
|
12
|
+
}
|
|
13
|
+
export function normalizeExtensionStatusText(value) {
|
|
14
|
+
return stripVTControlCharacters(value)
|
|
15
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
16
|
+
.replace(CONTROL_CHARACTERS, "")
|
|
17
|
+
.replace(/\s{2,}/g, " ")
|
|
18
|
+
.trim()
|
|
19
|
+
.slice(0, MAX_STATUS_TEXT_LENGTH);
|
|
20
|
+
}
|
|
21
|
+
export function extensionStatusSnapshot(statuses) {
|
|
22
|
+
return Object.fromEntries(statuses ?? []);
|
|
23
|
+
}
|
|
24
|
+
/** Identify successful pi-usage status text; transient states such as "checking" do not match. */
|
|
25
|
+
export function providerForUsageStatus(text) {
|
|
26
|
+
if (/^codex(?:\s|$)/i.test(text))
|
|
27
|
+
return "codex";
|
|
28
|
+
if (/^(?:anthropic|claude)(?:\s|$)/i.test(text))
|
|
29
|
+
return "anthropic";
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
@@ -343,6 +343,20 @@ function validateSettingsObject(value, errors) {
|
|
|
343
343
|
toolCollapse = { keepOpen: tc.keepOpen };
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
|
+
// Validate messages (optional)
|
|
347
|
+
let messagesConfig;
|
|
348
|
+
if (value.messages !== undefined) {
|
|
349
|
+
const mc = value.messages;
|
|
350
|
+
if (!mc || typeof mc !== "object" || Array.isArray(mc)) {
|
|
351
|
+
errors.push("messages must be an object");
|
|
352
|
+
}
|
|
353
|
+
else if (typeof mc.initialCount !== "number" || !Number.isFinite(mc.initialCount) || mc.initialCount < 0 || Math.floor(mc.initialCount) !== mc.initialCount) {
|
|
354
|
+
errors.push("messages.initialCount must be a non-negative integer");
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
messagesConfig = { initialCount: mc.initialCount };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
346
360
|
if (errors.length > 0)
|
|
347
361
|
return null;
|
|
348
362
|
return {
|
|
@@ -362,6 +376,7 @@ function validateSettingsObject(value, errors) {
|
|
|
362
376
|
showTokenUsage,
|
|
363
377
|
},
|
|
364
378
|
...(toolCollapse ? { toolCollapse } : {}),
|
|
379
|
+
...(messagesConfig ? { messages: messagesConfig } : {}),
|
|
365
380
|
};
|
|
366
381
|
}
|
|
367
382
|
function compileFilters(settings) {
|
|
@@ -24,7 +24,7 @@ export function makePiNotFoundMessage(command) {
|
|
|
24
24
|
}
|
|
25
25
|
export async function installPiGlobal(deps = {}) {
|
|
26
26
|
const spawnSync = deps.spawnSync ?? childSpawnSync;
|
|
27
|
-
const result = spawnSync("npm", ["install", "-g", "@
|
|
27
|
+
const result = spawnSync("npm", ["install", "-g", "@earendil-works/pi-coding-agent"], {
|
|
28
28
|
stdio: "inherit",
|
|
29
29
|
env: process.env,
|
|
30
30
|
});
|
|
@@ -1,29 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CWD-aware process pool for pi RPC processes.
|
|
2
|
+
* CWD-aware, leased process pool for pi RPC processes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
4
|
+
* The pool is the sole owner of process availability. Callers receive an
|
|
5
|
+
* idempotent lease instead of passing a separate busy set, so a process is
|
|
6
|
+
* reserved atomically before control returns to asynchronous application code.
|
|
14
7
|
*/
|
|
15
8
|
import { spawn } from "node:child_process";
|
|
16
9
|
import * as readline from "node:readline";
|
|
17
10
|
import { existsSync } from "node:fs";
|
|
11
|
+
/** Exclusive ownership of one pooled process. */
|
|
12
|
+
export class RpcProcessLease {
|
|
13
|
+
process;
|
|
14
|
+
pool;
|
|
15
|
+
_released = false;
|
|
16
|
+
constructor(pool, process) {
|
|
17
|
+
this.pool = pool;
|
|
18
|
+
this.process = process;
|
|
19
|
+
}
|
|
20
|
+
get released() { return this._released; }
|
|
21
|
+
release() {
|
|
22
|
+
this.pool.releaseLease(this);
|
|
23
|
+
}
|
|
24
|
+
/** @internal */
|
|
25
|
+
markReleased() {
|
|
26
|
+
this._released = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
18
29
|
export class ProcessPool {
|
|
19
|
-
/** All live processes, keyed by cwd */
|
|
20
30
|
pools = new Map();
|
|
31
|
+
leases = new Map();
|
|
32
|
+
decommissioning = new Set();
|
|
33
|
+
finalizedProcesses = new WeakSet();
|
|
21
34
|
nextProcId = 0;
|
|
22
35
|
spawnConfig;
|
|
23
36
|
maxProcesses;
|
|
24
37
|
prewarmCount;
|
|
25
38
|
rpcTimeout;
|
|
26
39
|
onProcessExit;
|
|
40
|
+
eventListeners = new Set();
|
|
27
41
|
constructor(spawnConfig, options) {
|
|
28
42
|
this.spawnConfig = spawnConfig;
|
|
29
43
|
this.maxProcesses = options?.maxProcesses ?? 6;
|
|
@@ -31,34 +45,50 @@ export class ProcessPool {
|
|
|
31
45
|
this.rpcTimeout = options?.rpcTimeout ?? 30000;
|
|
32
46
|
this.onProcessExit = options?.onProcessExit;
|
|
33
47
|
}
|
|
34
|
-
/** Get the total count of live processes across all cwds */
|
|
35
48
|
get totalProcesses() {
|
|
36
49
|
let count = 0;
|
|
37
50
|
for (const procs of this.pools.values()) {
|
|
38
|
-
count += procs.filter((
|
|
51
|
+
count += procs.filter((proc) => proc.process.exitCode === null).length;
|
|
39
52
|
}
|
|
40
53
|
return count;
|
|
41
54
|
}
|
|
42
|
-
|
|
55
|
+
subscribeEvents(listener) {
|
|
56
|
+
this.eventListeners.add(listener);
|
|
57
|
+
return () => this.eventListeners.delete(listener);
|
|
58
|
+
}
|
|
59
|
+
emitEvent(proc, event) {
|
|
60
|
+
for (const listener of this.eventListeners) {
|
|
61
|
+
try {
|
|
62
|
+
listener(proc, event);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.error(`[pool] pi#${proc.id} event listener failed:`, err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
43
69
|
getAllProcesses() {
|
|
44
70
|
const all = [];
|
|
45
|
-
for (const procs of this.pools.values())
|
|
71
|
+
for (const procs of this.pools.values())
|
|
46
72
|
all.push(...procs);
|
|
47
|
-
}
|
|
48
73
|
return all;
|
|
49
74
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
isLeased(proc) {
|
|
76
|
+
return this.leases.has(proc);
|
|
77
|
+
}
|
|
78
|
+
isDecommissioning(proc) {
|
|
79
|
+
return this.decommissioning.has(proc);
|
|
80
|
+
}
|
|
81
|
+
getRecentStderr(proc, maxLines = 12) {
|
|
82
|
+
if (!proc?.recentStderr?.length)
|
|
83
|
+
return [];
|
|
84
|
+
return proc.recentStderr.slice(Math.max(0, proc.recentStderr.length - maxLines));
|
|
85
|
+
}
|
|
54
86
|
spawn(cwd) {
|
|
55
87
|
if (!existsSync(cwd)) {
|
|
56
88
|
throw new Error(`Cannot spawn pi process: directory does not exist: ${cwd}`);
|
|
57
89
|
}
|
|
58
90
|
const procId = ++this.nextProcId;
|
|
59
91
|
console.log(`[pool] Spawning pi process #${procId} (cwd: ${cwd})...`);
|
|
60
|
-
// Strip NODE_ENV from the child environment so tools spawned by pi
|
|
61
|
-
// (e.g. bash) don't inherit pipane's "production" setting.
|
|
62
92
|
const { NODE_ENV: _, ...parentEnv } = process.env;
|
|
63
93
|
const baseArgs = typeof this.spawnConfig.baseArgs === "function"
|
|
64
94
|
? this.spawnConfig.baseArgs()
|
|
@@ -71,8 +101,18 @@ export class ProcessPool {
|
|
|
71
101
|
env: { ...parentEnv, ...(this.spawnConfig.env ?? {}) },
|
|
72
102
|
stdio: ["pipe", "pipe", "pipe"],
|
|
73
103
|
});
|
|
104
|
+
const recentStderr = [];
|
|
74
105
|
child.stderr?.on("data", (data) => {
|
|
75
|
-
|
|
106
|
+
const text = data.toString();
|
|
107
|
+
process.stderr.write(`[pi#${procId}] ${text}`);
|
|
108
|
+
for (const line of text.split(/\r?\n/)) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed)
|
|
111
|
+
continue;
|
|
112
|
+
recentStderr.push(trimmed);
|
|
113
|
+
if (recentStderr.length > 60)
|
|
114
|
+
recentStderr.splice(0, recentStderr.length - 60);
|
|
115
|
+
}
|
|
76
116
|
});
|
|
77
117
|
const rl = readline.createInterface({ input: child.stdout, terminal: false });
|
|
78
118
|
const proc = {
|
|
@@ -83,8 +123,8 @@ export class ProcessPool {
|
|
|
83
123
|
pendingRequests: new Map(),
|
|
84
124
|
requestId: 0,
|
|
85
125
|
lastResponseTime: Date.now(),
|
|
126
|
+
recentStderr,
|
|
86
127
|
};
|
|
87
|
-
// Set up response handler
|
|
88
128
|
rl.on("line", (line) => {
|
|
89
129
|
let data;
|
|
90
130
|
try {
|
|
@@ -93,51 +133,25 @@ export class ProcessPool {
|
|
|
93
133
|
catch {
|
|
94
134
|
return;
|
|
95
135
|
}
|
|
96
|
-
if (data.type === "response"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
136
|
+
if (data.type === "response") {
|
|
137
|
+
if (data.id && proc.pendingRequests.has(data.id)) {
|
|
138
|
+
const pending = proc.pendingRequests.get(data.id);
|
|
139
|
+
proc.pendingRequests.delete(data.id);
|
|
140
|
+
proc.lastResponseTime = Date.now();
|
|
141
|
+
pending.resolve(data);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
101
144
|
}
|
|
145
|
+
this.emitEvent(proc, data);
|
|
102
146
|
});
|
|
103
147
|
child.on("error", (err) => {
|
|
104
148
|
console.error(`[pool] pi#${proc.id} spawn error: ${err.message}`);
|
|
105
|
-
|
|
106
|
-
const poolForCwd = this.pools.get(cwd);
|
|
107
|
-
if (poolForCwd) {
|
|
108
|
-
const idx = poolForCwd.indexOf(proc);
|
|
109
|
-
if (idx !== -1)
|
|
110
|
-
poolForCwd.splice(idx, 1);
|
|
111
|
-
if (poolForCwd.length === 0)
|
|
112
|
-
this.pools.delete(cwd);
|
|
113
|
-
}
|
|
114
|
-
// Reject any pending requests
|
|
115
|
-
for (const [, pending] of proc.pendingRequests) {
|
|
116
|
-
pending.reject(new Error(`pi process #${proc.id} failed to spawn: ${err.message}`));
|
|
117
|
-
}
|
|
118
|
-
proc.pendingRequests.clear();
|
|
119
|
-
this.onProcessExit?.(proc);
|
|
149
|
+
this.finalizeProcess(proc, new Error(`pi process #${proc.id} failed to spawn: ${err.message}`));
|
|
120
150
|
});
|
|
121
151
|
child.on("exit", (code) => {
|
|
122
152
|
console.log(`[pool] pi#${proc.id} exited (code ${code})`);
|
|
123
|
-
|
|
124
|
-
const poolForCwd = this.pools.get(cwd);
|
|
125
|
-
if (poolForCwd) {
|
|
126
|
-
const idx = poolForCwd.indexOf(proc);
|
|
127
|
-
if (idx !== -1)
|
|
128
|
-
poolForCwd.splice(idx, 1);
|
|
129
|
-
if (poolForCwd.length === 0)
|
|
130
|
-
this.pools.delete(cwd);
|
|
131
|
-
}
|
|
132
|
-
// Reject any pending requests
|
|
133
|
-
for (const [, pending] of proc.pendingRequests) {
|
|
134
|
-
pending.reject(new Error(`pi process #${proc.id} exited unexpectedly (code ${code})`));
|
|
135
|
-
}
|
|
136
|
-
proc.pendingRequests.clear();
|
|
137
|
-
// Notify caller
|
|
138
|
-
this.onProcessExit?.(proc);
|
|
153
|
+
this.finalizeProcess(proc, new Error(`pi process #${proc.id} exited unexpectedly (code ${code})`));
|
|
139
154
|
});
|
|
140
|
-
// Add to pool
|
|
141
155
|
let poolForCwd = this.pools.get(cwd);
|
|
142
156
|
if (!poolForCwd) {
|
|
143
157
|
poolForCwd = [];
|
|
@@ -147,10 +161,28 @@ export class ProcessPool {
|
|
|
147
161
|
console.log(`[pool] pi#${procId} spawned (total: ${this.totalProcesses})`);
|
|
148
162
|
return proc;
|
|
149
163
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
164
|
+
finalizeProcess(proc, error) {
|
|
165
|
+
if (this.finalizedProcesses.has(proc))
|
|
166
|
+
return;
|
|
167
|
+
this.finalizedProcesses.add(proc);
|
|
168
|
+
const poolForCwd = this.pools.get(proc.cwd);
|
|
169
|
+
if (poolForCwd) {
|
|
170
|
+
const index = poolForCwd.indexOf(proc);
|
|
171
|
+
if (index !== -1)
|
|
172
|
+
poolForCwd.splice(index, 1);
|
|
173
|
+
if (poolForCwd.length === 0)
|
|
174
|
+
this.pools.delete(proc.cwd);
|
|
175
|
+
}
|
|
176
|
+
const lease = this.leases.get(proc);
|
|
177
|
+
if (lease)
|
|
178
|
+
lease.markReleased();
|
|
179
|
+
this.leases.delete(proc);
|
|
180
|
+
this.decommissioning.delete(proc);
|
|
181
|
+
for (const pending of proc.pendingRequests.values())
|
|
182
|
+
pending.reject(error);
|
|
183
|
+
proc.pendingRequests.clear();
|
|
184
|
+
this.onProcessExit?.(proc);
|
|
185
|
+
}
|
|
154
186
|
async waitForReady(proc, timeoutMs = 5000) {
|
|
155
187
|
try {
|
|
156
188
|
await this.sendRpc(proc, { type: "get_state" }, timeoutMs);
|
|
@@ -160,84 +192,109 @@ export class ProcessPool {
|
|
|
160
192
|
return false;
|
|
161
193
|
}
|
|
162
194
|
}
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
* An "idle" process is one that's alive and not in the busySet.
|
|
166
|
-
*/
|
|
167
|
-
acquire(cwd, busySet) {
|
|
195
|
+
/** Atomically reserve a matching process, spawning one when capacity allows. */
|
|
196
|
+
acquire(cwd) {
|
|
168
197
|
const poolForCwd = this.pools.get(cwd);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
// Check global cap
|
|
175
|
-
if (this.totalProcesses >= this.maxProcesses) {
|
|
198
|
+
const idle = poolForCwd?.find((proc) => this.isAvailable(proc));
|
|
199
|
+
if (idle)
|
|
200
|
+
return this.reserve(idle);
|
|
201
|
+
if (this.totalProcesses >= this.maxProcesses)
|
|
176
202
|
return null;
|
|
177
|
-
|
|
178
|
-
return this.spawn(cwd);
|
|
203
|
+
return this.reserve(this.spawn(cwd));
|
|
179
204
|
}
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
* need a specific cwd.
|
|
183
|
-
*/
|
|
184
|
-
getAny(busySet) {
|
|
185
|
-
// Prefer idle
|
|
205
|
+
/** Reserve any idle live process, regardless of cwd. */
|
|
206
|
+
acquireAny() {
|
|
186
207
|
for (const procs of this.pools.values()) {
|
|
187
|
-
const idle = procs.find((
|
|
208
|
+
const idle = procs.find((proc) => this.isAvailable(proc));
|
|
188
209
|
if (idle)
|
|
189
|
-
return idle;
|
|
190
|
-
}
|
|
191
|
-
// Fall back to any live process
|
|
192
|
-
for (const procs of this.pools.values()) {
|
|
193
|
-
const live = procs.find((p) => p.process.exitCode === null);
|
|
194
|
-
if (live)
|
|
195
|
-
return live;
|
|
210
|
+
return this.reserve(idle);
|
|
196
211
|
}
|
|
197
212
|
return null;
|
|
198
213
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
214
|
+
isAvailable(proc) {
|
|
215
|
+
return proc.process.exitCode === null
|
|
216
|
+
&& !this.leases.has(proc)
|
|
217
|
+
&& !this.decommissioning.has(proc);
|
|
218
|
+
}
|
|
219
|
+
reserve(proc) {
|
|
220
|
+
if (!this.isAvailable(proc))
|
|
221
|
+
throw new Error(`pi process #${proc.id} is not available`);
|
|
222
|
+
const lease = new RpcProcessLease(this, proc);
|
|
223
|
+
this.leases.set(proc, lease);
|
|
224
|
+
return lease;
|
|
225
|
+
}
|
|
226
|
+
/** @internal Called only by RpcProcessLease.release(). */
|
|
227
|
+
releaseLease(lease) {
|
|
228
|
+
if (lease.released)
|
|
229
|
+
return;
|
|
230
|
+
const proc = lease.process;
|
|
231
|
+
if (this.leases.get(proc) !== lease) {
|
|
232
|
+
lease.markReleased();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.leases.delete(proc);
|
|
236
|
+
lease.markReleased();
|
|
237
|
+
if (this.decommissioning.has(proc) && proc.process.exitCode === null && !proc.process.killed) {
|
|
238
|
+
// Keep the marker until the exit event removes the process. Otherwise a
|
|
239
|
+
// concurrent acquisition could lease the dying process before SIGTERM lands.
|
|
240
|
+
console.log(`[pool] Decommissioning pi#${proc.id} after completed operation`);
|
|
241
|
+
proc.process.kill("SIGTERM");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
decommission(proc) {
|
|
245
|
+
if (proc.process.exitCode !== null || this.decommissioning.has(proc))
|
|
246
|
+
return "ignored";
|
|
247
|
+
this.decommissioning.add(proc);
|
|
248
|
+
if (this.leases.has(proc))
|
|
249
|
+
return "draining";
|
|
250
|
+
proc.process.kill("SIGTERM");
|
|
251
|
+
return "killed";
|
|
252
|
+
}
|
|
253
|
+
/** Evict an idle process from a different cwd to free capacity. */
|
|
254
|
+
evictIdleDifferentCwd(targetCwd) {
|
|
204
255
|
for (const [cwd, procs] of this.pools) {
|
|
205
256
|
if (cwd === targetCwd)
|
|
206
257
|
continue;
|
|
207
|
-
const victim = procs.find((
|
|
258
|
+
const victim = procs.find((proc) => this.isAvailable(proc));
|
|
208
259
|
if (!victim)
|
|
209
260
|
continue;
|
|
210
261
|
console.log(`[pool] Evicting idle pi#${victim.id} from cwd ${cwd} to make room for ${targetCwd}`);
|
|
211
|
-
|
|
262
|
+
this.decommission(victim);
|
|
212
263
|
return victim;
|
|
213
264
|
}
|
|
214
265
|
return null;
|
|
215
266
|
}
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
267
|
+
/** Immediately kill one process while keeping it unavailable until exit. */
|
|
268
|
+
forceKill(proc) {
|
|
269
|
+
if (proc.process.exitCode !== null)
|
|
270
|
+
return false;
|
|
271
|
+
this.decommissioning.add(proc);
|
|
272
|
+
return proc.process.kill("SIGKILL");
|
|
273
|
+
}
|
|
274
|
+
/** Kill idle processes now and mark leased processes for kill on release. */
|
|
275
|
+
decommissionAll() {
|
|
276
|
+
let killed = 0;
|
|
277
|
+
let draining = 0;
|
|
278
|
+
for (const proc of this.getAllProcesses()) {
|
|
279
|
+
const result = this.decommission(proc);
|
|
280
|
+
if (result === "killed")
|
|
281
|
+
killed += 1;
|
|
282
|
+
if (result === "draining")
|
|
283
|
+
draining += 1;
|
|
284
|
+
}
|
|
285
|
+
return { killed, draining };
|
|
286
|
+
}
|
|
225
287
|
async prewarm(cwd) {
|
|
226
|
-
const existing = this.pools.get(cwd)?.filter((
|
|
288
|
+
const existing = this.pools.get(cwd)?.filter((proc) => proc.process.exitCode === null).length ?? 0;
|
|
227
289
|
const needed = Math.min(this.prewarmCount, this.maxProcesses) - existing;
|
|
228
290
|
for (let i = 0; i < needed; i++) {
|
|
229
291
|
if (this.totalProcesses >= this.maxProcesses)
|
|
230
292
|
break;
|
|
231
293
|
const proc = this.spawn(cwd);
|
|
232
|
-
if (i < needed - 1)
|
|
233
|
-
// Wait for this process to be ready before spawning the next one
|
|
294
|
+
if (i < needed - 1)
|
|
234
295
|
await this.waitForReady(proc);
|
|
235
|
-
}
|
|
236
296
|
}
|
|
237
297
|
}
|
|
238
|
-
/**
|
|
239
|
-
* Send an RPC command to a process and wait for a response.
|
|
240
|
-
*/
|
|
241
298
|
sendRpc(proc, command, timeoutMs) {
|
|
242
299
|
if (!proc.process || proc.process.exitCode !== null) {
|
|
243
300
|
return Promise.reject(new Error("RPC process is dead"));
|
|
@@ -248,6 +305,9 @@ export class ProcessPool {
|
|
|
248
305
|
return new Promise((resolve, reject) => {
|
|
249
306
|
const timer = setTimeout(() => {
|
|
250
307
|
proc.pendingRequests.delete(id);
|
|
308
|
+
// A timed-out RPC may still be executing. Never return that process to
|
|
309
|
+
// the idle pool where a new session command could overlap with it.
|
|
310
|
+
this.decommission(proc);
|
|
251
311
|
reject(new Error(`Timeout waiting for RPC response to ${command.type}`));
|
|
252
312
|
}, timeout);
|
|
253
313
|
proc.pendingRequests.set(id, {
|
|
@@ -263,9 +323,6 @@ export class ProcessPool {
|
|
|
263
323
|
proc.process.stdin.write(JSON.stringify(fullCommand) + "\n");
|
|
264
324
|
});
|
|
265
325
|
}
|
|
266
|
-
/**
|
|
267
|
-
* Send an RPC command and throw if it fails.
|
|
268
|
-
*/
|
|
269
326
|
async sendRpcChecked(proc, command) {
|
|
270
327
|
const response = await this.sendRpc(proc, command);
|
|
271
328
|
if (!response?.success) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync, readFileSync, readdirSync, watchFile } from "node:fs";
|
|
7
7
|
import { unlink } from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { SessionIndex } from "./session-index.js";
|
|
11
11
|
import { LocalSettingsStore } from "./local-settings.js";
|
|
12
12
|
let localSettingsStore;
|
|
@@ -37,60 +37,11 @@ async function readJsonBody(req) {
|
|
|
37
37
|
return JSON.parse(raw || "{}");
|
|
38
38
|
}
|
|
39
39
|
export function registerRestApi(app, options = {}) {
|
|
40
|
-
const traceStore = options.traceStore;
|
|
41
40
|
localSettingsStore = options.localSettingsStore ?? new LocalSettingsStore();
|
|
42
41
|
sessionIndex = new SessionIndex({
|
|
43
42
|
cwdDisplayFormatter: (cwd) => localSettingsStore.formatCwdTitle(cwd),
|
|
44
43
|
});
|
|
45
44
|
startLocalSettingsWatcher(options.onLocalSettingsReloaded);
|
|
46
|
-
app.post("/api/debug/load-trace/event", async (req, res) => {
|
|
47
|
-
try {
|
|
48
|
-
if (!traceStore) {
|
|
49
|
-
res.status(404).json({ error: "Tracing disabled" });
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const chunks = [];
|
|
53
|
-
for await (const chunk of req)
|
|
54
|
-
chunks.push(chunk);
|
|
55
|
-
const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
|
56
|
-
const traceId = String(body.traceId || req.headers["x-pi-trace-id"] || "");
|
|
57
|
-
if (!traceId) {
|
|
58
|
-
res.status(400).json({ error: "Missing traceId" });
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
traceStore.record(traceId, {
|
|
62
|
-
ts: new Date().toISOString(),
|
|
63
|
-
source: "frontend",
|
|
64
|
-
kind: body.durationMs != null ? "span" : "instant",
|
|
65
|
-
name: String(body.name || "frontend_event"),
|
|
66
|
-
durationMs: typeof body.durationMs === "number" ? body.durationMs : undefined,
|
|
67
|
-
attrs: body.attrs && typeof body.attrs === "object" ? body.attrs : undefined,
|
|
68
|
-
});
|
|
69
|
-
res.json({ ok: true });
|
|
70
|
-
}
|
|
71
|
-
catch (err) {
|
|
72
|
-
res.status(500).json({ error: err.message });
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
app.get("/api/debug/load-trace/latest", (_req, res) => {
|
|
76
|
-
if (!traceStore) {
|
|
77
|
-
res.status(404).json({ error: "Tracing disabled" });
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
res.json({ traces: traceStore.getLatest() });
|
|
81
|
-
});
|
|
82
|
-
app.get("/api/debug/load-trace/:traceId", (req, res) => {
|
|
83
|
-
if (!traceStore) {
|
|
84
|
-
res.status(404).json({ error: "Tracing disabled" });
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
const trace = traceStore.get(req.params.traceId);
|
|
88
|
-
if (!trace) {
|
|
89
|
-
res.status(404).json({ error: "Trace not found" });
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
res.json(trace);
|
|
93
|
-
});
|
|
94
45
|
app.get("/api/sessions", async (_req, res) => {
|
|
95
46
|
try {
|
|
96
47
|
res.json(await sessionIndex.listSessions());
|
|
@@ -175,32 +126,14 @@ export function registerRestApi(app, options = {}) {
|
|
|
175
126
|
res.status(404).json({ error: "Session not found" });
|
|
176
127
|
return;
|
|
177
128
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
catch (err) {
|
|
182
|
-
res.status(500).json({ error: err.message });
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
app.get("/api/sessions/messages", (req, res) => {
|
|
186
|
-
try {
|
|
187
|
-
const sessionPath = req.query.path;
|
|
188
|
-
if (!sessionPath || !sessionPath.endsWith(".jsonl")) {
|
|
189
|
-
res.status(400).json({ error: "Missing or invalid session path" });
|
|
190
|
-
return;
|
|
129
|
+
const remove = () => unlink(sessionPath);
|
|
130
|
+
if (options.runSessionMutation) {
|
|
131
|
+
await options.runSessionMutation(sessionPath, "delete session", remove);
|
|
191
132
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return;
|
|
133
|
+
else {
|
|
134
|
+
await remove();
|
|
195
135
|
}
|
|
196
|
-
|
|
197
|
-
const entries = parseSessionEntries(content);
|
|
198
|
-
const context = buildSessionContext(entries);
|
|
199
|
-
res.json({
|
|
200
|
-
messages: context.messages,
|
|
201
|
-
model: context.model,
|
|
202
|
-
thinkingLevel: context.thinkingLevel,
|
|
203
|
-
});
|
|
136
|
+
res.json({ success: true });
|
|
204
137
|
}
|
|
205
138
|
catch (err) {
|
|
206
139
|
res.status(500).json({ error: err.message });
|