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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/pipane.js +22 -0
- package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +1 -0
- package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +1 -0
- package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +1 -0
- package/dist/client/assets/_node-stub_node_os-TTKJha15.js +1 -0
- package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +1 -0
- package/dist/client/assets/_node-stub_stream-DDexIdn_.js +1 -0
- package/dist/client/assets/index-CQP8RzjO.js +45 -0
- package/dist/client/assets/index-CVr_JVrA.js +131 -0
- package/dist/client/assets/index-DQxrqu4K.js +4412 -0
- package/dist/client/assets/index-DuJn-Nwv.js +3 -0
- package/dist/client/assets/index-Pj8zaBYJ.js +1 -0
- package/dist/client/assets/index-yL1A-vgM.css +1 -0
- package/dist/client/assets/pdf.worker.min-Cpi8b8z3.mjs +28 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/index.html +118 -0
- package/dist/server/server/attached-session.js +209 -0
- package/dist/server/server/load-trace-store.js +48 -0
- package/dist/server/server/local-settings.js +376 -0
- package/dist/server/server/pi-launch.js +10 -0
- package/dist/server/server/pi-runtime.js +32 -0
- package/dist/server/server/process-pool.js +254 -0
- package/dist/server/server/rest-api.js +289 -0
- package/dist/server/server/server.js +355 -0
- package/dist/server/server/session-cwd.js +33 -0
- package/dist/server/server/session-index.js +231 -0
- package/dist/server/server/session-jsonl.js +260 -0
- package/dist/server/server/session-lifecycle.js +155 -0
- package/dist/server/server/ws-handler.js +808 -0
- package/dist/server/shared/jsonl-sync.js +141 -0
- package/extensions/canvas.ts +57 -0
- package/package.json +82 -0
- package/patches/@mariozechner+pi-web-ui+0.55.3.patch +3279 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket handler for pipane.
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* - Sessions are either "attached" (pi process running, full state in memory)
|
|
6
|
+
* or "detached" (no in-memory state, read from JSONL on disk on demand).
|
|
7
|
+
* - Any number of clients can connect simultaneously.
|
|
8
|
+
* - Each client can subscribe to one session at a time.
|
|
9
|
+
* - Attached sessions push stream_delta or snapshot ops to subscribed clients.
|
|
10
|
+
* - Detached sessions are read from disk when a client subscribes.
|
|
11
|
+
*/
|
|
12
|
+
import { WebSocket } from "ws";
|
|
13
|
+
import { copyFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { URL } from "node:url";
|
|
16
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { SessionJsonl, readSessionFromDisk, getSessionFileSize, } from "./session-jsonl.js";
|
|
18
|
+
import { getSessionCwd } from "./session-cwd.js";
|
|
19
|
+
import { checkCommandAvailable, installPiGlobal, isPiInstallable, makePiNotFoundMessage } from "./pi-runtime.js";
|
|
20
|
+
let nextTurnId = 0;
|
|
21
|
+
function makeTurnId() {
|
|
22
|
+
return `turn_${Date.now()}_${++nextTurnId}`;
|
|
23
|
+
}
|
|
24
|
+
function debugTurn(stage, data) {
|
|
25
|
+
console.log(`[turn] ${stage} ${JSON.stringify(data)}`);
|
|
26
|
+
}
|
|
27
|
+
export class WsHandler {
|
|
28
|
+
lifecycle;
|
|
29
|
+
pool;
|
|
30
|
+
defaultCwd;
|
|
31
|
+
piLaunch;
|
|
32
|
+
ensurePool;
|
|
33
|
+
isRequestAuthorized;
|
|
34
|
+
traceStore;
|
|
35
|
+
clients = new Map();
|
|
36
|
+
wsTraceIds = new Map();
|
|
37
|
+
busyProcesses = new Set();
|
|
38
|
+
procEventCleanup = new Map();
|
|
39
|
+
/** Processes marked for graceful decommission after current turn ends. */
|
|
40
|
+
decommissionProcesses = new Set();
|
|
41
|
+
/**
|
|
42
|
+
* In-memory state for sessions with an attached pi process.
|
|
43
|
+
* Keyed by session path. Created on attach, deleted on detach.
|
|
44
|
+
*/
|
|
45
|
+
attachedSessions = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* Track file sizes for detached sessions that clients are subscribed to.
|
|
48
|
+
* Used for change detection when the file watcher fires.
|
|
49
|
+
*/
|
|
50
|
+
subscribedFileSizes = new Map();
|
|
51
|
+
piAvailable;
|
|
52
|
+
piInstalling = false;
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.lifecycle = options.lifecycle;
|
|
55
|
+
this.pool = options.pool;
|
|
56
|
+
this.defaultCwd = options.defaultCwd;
|
|
57
|
+
this.piLaunch = options.piLaunch;
|
|
58
|
+
this.ensurePool = options.ensurePool;
|
|
59
|
+
this.isRequestAuthorized = options.isRequestAuthorized;
|
|
60
|
+
this.traceStore = options.traceStore;
|
|
61
|
+
this.piAvailable = checkCommandAvailable(this.piLaunch.command);
|
|
62
|
+
this.lifecycle.subscribe((event) => {
|
|
63
|
+
switch (event.type) {
|
|
64
|
+
case "session_attached":
|
|
65
|
+
this.broadcast({
|
|
66
|
+
type: "session_status_change",
|
|
67
|
+
sessionPath: event.sessionPath,
|
|
68
|
+
status: "running",
|
|
69
|
+
});
|
|
70
|
+
break;
|
|
71
|
+
case "session_detached":
|
|
72
|
+
this.broadcast({
|
|
73
|
+
type: "session_status_change",
|
|
74
|
+
sessionPath: event.sessionPath,
|
|
75
|
+
status: "done",
|
|
76
|
+
});
|
|
77
|
+
break;
|
|
78
|
+
case "steering_queue_update": {
|
|
79
|
+
const session = this.attachedSessions.get(event.sessionPath);
|
|
80
|
+
if (session) {
|
|
81
|
+
session.steeringQueue = [...event.queue];
|
|
82
|
+
this.pushUpdateToSubscribers(event.sessionPath, session);
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
get isPiAvailable() {
|
|
90
|
+
return this.piAvailable;
|
|
91
|
+
}
|
|
92
|
+
recordTrace(traceId, source, kind, name, durationMs, attrs) {
|
|
93
|
+
if (!traceId || !this.traceStore)
|
|
94
|
+
return;
|
|
95
|
+
this.traceStore.record(traceId, {
|
|
96
|
+
ts: new Date().toISOString(),
|
|
97
|
+
source,
|
|
98
|
+
kind,
|
|
99
|
+
name,
|
|
100
|
+
durationMs,
|
|
101
|
+
attrs,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
getTraceIdForMessage(ws, command) {
|
|
105
|
+
const fromCommand = command?.__trace?.traceId;
|
|
106
|
+
if (typeof fromCommand === "string" && fromCommand.length > 0)
|
|
107
|
+
return fromCommand;
|
|
108
|
+
return this.wsTraceIds.get(ws);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Called by the file watcher when a JSONL file changes on disk.
|
|
112
|
+
* For detached sessions with subscribers, re-reads from disk and pushes a snapshot.
|
|
113
|
+
* Ignores attached sessions (their state comes from streaming events).
|
|
114
|
+
*/
|
|
115
|
+
notifySessionFileChanged(sessionPath) {
|
|
116
|
+
// If the session is attached, ignore — streaming events are authoritative
|
|
117
|
+
if (this.attachedSessions.has(sessionPath))
|
|
118
|
+
return;
|
|
119
|
+
// Check if any client is subscribed to this session
|
|
120
|
+
let hasSubscribers = false;
|
|
121
|
+
for (const [, client] of this.clients) {
|
|
122
|
+
if (client.subscribedSession === sessionPath) {
|
|
123
|
+
hasSubscribers = true;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!hasSubscribers)
|
|
128
|
+
return;
|
|
129
|
+
// Check if the file actually changed
|
|
130
|
+
const oldSize = this.subscribedFileSizes.get(sessionPath) ?? 0;
|
|
131
|
+
const newSize = getSessionFileSize(sessionPath);
|
|
132
|
+
if (newSize === oldSize)
|
|
133
|
+
return;
|
|
134
|
+
// File changed — read from disk and push snapshot to subscribers
|
|
135
|
+
this.subscribedFileSizes.set(sessionPath, newSize);
|
|
136
|
+
const { json, hash } = readSessionFromDisk(sessionPath);
|
|
137
|
+
this.pushSnapshotToSubscribers(sessionPath, json, hash);
|
|
138
|
+
}
|
|
139
|
+
getDebugState() {
|
|
140
|
+
const processes = this.pool.getAllProcesses().map((p) => ({
|
|
141
|
+
id: p.id,
|
|
142
|
+
pid: p.process.pid ?? null,
|
|
143
|
+
alive: p.process.exitCode === null,
|
|
144
|
+
exitCode: p.process.exitCode,
|
|
145
|
+
cwd: p.cwd,
|
|
146
|
+
busy: this.busyProcesses.has(p),
|
|
147
|
+
decommissioning: this.decommissionProcesses.has(p),
|
|
148
|
+
attachedSession: this.lifecycle.getAttachedSessionForProcess(p) ?? null,
|
|
149
|
+
pendingRequests: p.pendingRequests.size,
|
|
150
|
+
}));
|
|
151
|
+
return {
|
|
152
|
+
now: new Date().toISOString(),
|
|
153
|
+
totalProcesses: this.pool.totalProcesses,
|
|
154
|
+
attachedSessionCount: this.lifecycle.attachedCount,
|
|
155
|
+
sessionStatuses: this.lifecycle.getAllStatuses(),
|
|
156
|
+
connectedWsOpen: Array.from(this.clients.keys()).filter((ws) => ws.readyState === WebSocket.OPEN).length,
|
|
157
|
+
processes,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
register(wss) {
|
|
161
|
+
wss.on("connection", (ws, req) => this.handleConnection(ws, req));
|
|
162
|
+
}
|
|
163
|
+
handleConnection(ws, req) {
|
|
164
|
+
if (!this.isRequestAuthorized(req)) {
|
|
165
|
+
ws.close(1008, "Unauthorized");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const reqUrl = req.url || "/ws";
|
|
169
|
+
const parsed = new URL(reqUrl, "http://localhost");
|
|
170
|
+
const traceId = parsed.searchParams.get("traceId") || undefined;
|
|
171
|
+
if (traceId) {
|
|
172
|
+
this.wsTraceIds.set(ws, traceId);
|
|
173
|
+
this.recordTrace(traceId, "backend", "instant", "ws connection open");
|
|
174
|
+
}
|
|
175
|
+
console.log("WebSocket client connected");
|
|
176
|
+
this.clients.set(ws, {
|
|
177
|
+
subscribedSession: null,
|
|
178
|
+
lastVersion: 0,
|
|
179
|
+
lastJson: "",
|
|
180
|
+
lastHash: "",
|
|
181
|
+
});
|
|
182
|
+
ws.send(JSON.stringify({
|
|
183
|
+
type: "init",
|
|
184
|
+
sessionStatuses: this.lifecycle.getAllStatuses(),
|
|
185
|
+
steeringQueues: this.lifecycle.getAllSteeringQueues(),
|
|
186
|
+
}));
|
|
187
|
+
if (!this.piAvailable) {
|
|
188
|
+
ws.send(JSON.stringify({
|
|
189
|
+
type: "pi_install_required",
|
|
190
|
+
command: this.piLaunch.command,
|
|
191
|
+
installable: isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs),
|
|
192
|
+
installing: this.piInstalling,
|
|
193
|
+
message: makePiNotFoundMessage(this.piLaunch.command),
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
|
|
197
|
+
ws.on("close", () => {
|
|
198
|
+
const wsTraceId = this.wsTraceIds.get(ws);
|
|
199
|
+
this.recordTrace(wsTraceId, "backend", "instant", "ws connection close");
|
|
200
|
+
console.log("WebSocket client disconnected");
|
|
201
|
+
this.clients.delete(ws);
|
|
202
|
+
this.wsTraceIds.delete(ws);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
broadcast(payload) {
|
|
206
|
+
for (const ws of this.clients.keys()) {
|
|
207
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
208
|
+
ws.send(JSON.stringify(payload));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async handleMessage(ws, raw) {
|
|
213
|
+
let command;
|
|
214
|
+
try {
|
|
215
|
+
command = JSON.parse(raw);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
ws.send(JSON.stringify({ type: "response", command: "parse", success: false, error: "Invalid JSON" }));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const id = command.id;
|
|
222
|
+
const traceId = this.getTraceIdForMessage(ws, command);
|
|
223
|
+
const commandStart = performance.now();
|
|
224
|
+
this.recordTrace(traceId, "backend", "instant", `ws command received: ${command.type}`);
|
|
225
|
+
try {
|
|
226
|
+
if (!this.piAvailable && command.type !== "install_pi" && command.type !== "get_session_statuses") {
|
|
227
|
+
ws.send(JSON.stringify({
|
|
228
|
+
type: "pi_install_required",
|
|
229
|
+
command: this.piLaunch.command,
|
|
230
|
+
installable: isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs),
|
|
231
|
+
installing: this.piInstalling,
|
|
232
|
+
message: makePiNotFoundMessage(this.piLaunch.command),
|
|
233
|
+
}));
|
|
234
|
+
if (id) {
|
|
235
|
+
ws.send(JSON.stringify({
|
|
236
|
+
id, type: "response", command: command.type, success: false,
|
|
237
|
+
error: makePiNotFoundMessage(this.piLaunch.command),
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
switch (command.type) {
|
|
243
|
+
case "install_pi":
|
|
244
|
+
await this.handleInstallPi(ws, id);
|
|
245
|
+
break;
|
|
246
|
+
case "subscribe_session":
|
|
247
|
+
this.handleSubscribeSession(ws, id, command);
|
|
248
|
+
break;
|
|
249
|
+
case "prompt":
|
|
250
|
+
await this.handlePrompt(ws, id, command);
|
|
251
|
+
break;
|
|
252
|
+
case "steer":
|
|
253
|
+
await this.handleSteer(ws, id, command);
|
|
254
|
+
break;
|
|
255
|
+
case "remove_steering":
|
|
256
|
+
await this.handleRemoveSteering(ws, id, command);
|
|
257
|
+
break;
|
|
258
|
+
case "abort":
|
|
259
|
+
await this.handleAbort(ws, id, command);
|
|
260
|
+
break;
|
|
261
|
+
case "compact":
|
|
262
|
+
await this.handleCompact(ws, id, command);
|
|
263
|
+
break;
|
|
264
|
+
case "get_available_models":
|
|
265
|
+
await this.handleGetAvailableModels(ws, id);
|
|
266
|
+
break;
|
|
267
|
+
case "get_default_model":
|
|
268
|
+
await this.handleGetDefaultModel(ws, id);
|
|
269
|
+
break;
|
|
270
|
+
case "get_session_statuses":
|
|
271
|
+
this.handleGetSessionStatuses(ws, id);
|
|
272
|
+
break;
|
|
273
|
+
case "fork":
|
|
274
|
+
await this.handleFork(ws, id, command);
|
|
275
|
+
break;
|
|
276
|
+
case "fork_prompt":
|
|
277
|
+
await this.handleForkPrompt(ws, id, command);
|
|
278
|
+
break;
|
|
279
|
+
case "set_session_name":
|
|
280
|
+
await this.handleSetSessionName(ws, id, command);
|
|
281
|
+
break;
|
|
282
|
+
case "get_commands":
|
|
283
|
+
await this.handleGetCommands(ws, id);
|
|
284
|
+
break;
|
|
285
|
+
case "reload_processes":
|
|
286
|
+
await this.handleReloadProcesses(ws, id);
|
|
287
|
+
break;
|
|
288
|
+
default:
|
|
289
|
+
ws.send(JSON.stringify({
|
|
290
|
+
id, type: "response", command: command.type, success: false,
|
|
291
|
+
error: `Unknown command: ${command.type}`,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
debugTurn("command_error", { commandType: command?.type, requestId: id, error: err?.message });
|
|
297
|
+
ws.send(JSON.stringify({ id, type: "response", command: command.type, success: false, error: err.message }));
|
|
298
|
+
this.recordTrace(traceId, "backend", "span", `ws command ${command.type}`, Number((performance.now() - commandStart).toFixed(2)), { success: false, error: err?.message });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.recordTrace(traceId, "backend", "span", `ws command ${command.type}`, Number((performance.now() - commandStart).toFixed(2)), { success: true });
|
|
302
|
+
}
|
|
303
|
+
async handleInstallPi(ws, id) {
|
|
304
|
+
const installable = isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs);
|
|
305
|
+
if (!installable) {
|
|
306
|
+
throw new Error(`Automatic install not supported for command '${this.piLaunch.command}'. Set PI_CLI or install manually.`);
|
|
307
|
+
}
|
|
308
|
+
if (!this.piInstalling) {
|
|
309
|
+
this.piInstalling = true;
|
|
310
|
+
this.broadcast({
|
|
311
|
+
type: "pi_install_required",
|
|
312
|
+
command: this.piLaunch.command,
|
|
313
|
+
installable: true,
|
|
314
|
+
installing: true,
|
|
315
|
+
message: "Installing pi...",
|
|
316
|
+
});
|
|
317
|
+
const ok = await installPiGlobal();
|
|
318
|
+
this.piInstalling = false;
|
|
319
|
+
this.piAvailable = checkCommandAvailable(this.piLaunch.command);
|
|
320
|
+
if (!ok || !this.piAvailable) {
|
|
321
|
+
throw new Error("pi installation failed. Please install manually and restart the server.");
|
|
322
|
+
}
|
|
323
|
+
console.log("[pi] pi installed successfully");
|
|
324
|
+
this.ensurePool();
|
|
325
|
+
}
|
|
326
|
+
ws.send(JSON.stringify({ id, type: "response", command: "install_pi", success: true, data: {} }));
|
|
327
|
+
}
|
|
328
|
+
handleSubscribeSession(ws, id, command) {
|
|
329
|
+
const client = this.clients.get(ws);
|
|
330
|
+
if (!client)
|
|
331
|
+
return;
|
|
332
|
+
const sessionPath = command.sessionPath;
|
|
333
|
+
if (!sessionPath) {
|
|
334
|
+
client.subscribedSession = null;
|
|
335
|
+
client.lastVersion = 0;
|
|
336
|
+
client.lastJson = "";
|
|
337
|
+
client.lastHash = "";
|
|
338
|
+
ws.send(JSON.stringify({ id, type: "response", command: "subscribe_session", success: true, data: {} }));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
client.subscribedSession = sessionPath;
|
|
342
|
+
// If the session is attached, send from in-memory state
|
|
343
|
+
const attached = this.attachedSessions.get(sessionPath);
|
|
344
|
+
if (attached) {
|
|
345
|
+
// Send full sync
|
|
346
|
+
client.lastJson = attached.json;
|
|
347
|
+
client.lastHash = attached.hash;
|
|
348
|
+
client.lastVersion = attached.version;
|
|
349
|
+
ws.send(JSON.stringify({
|
|
350
|
+
type: "session_sync",
|
|
351
|
+
sessionPath,
|
|
352
|
+
op: "full",
|
|
353
|
+
data: attached.json,
|
|
354
|
+
hash: attached.hash,
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// Detached — read from disk
|
|
359
|
+
const { json, hash } = readSessionFromDisk(sessionPath);
|
|
360
|
+
client.lastJson = json;
|
|
361
|
+
client.lastHash = hash;
|
|
362
|
+
client.lastVersion = 0;
|
|
363
|
+
// Track file size for change detection
|
|
364
|
+
this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
|
|
365
|
+
ws.send(JSON.stringify({
|
|
366
|
+
type: "session_sync",
|
|
367
|
+
sessionPath,
|
|
368
|
+
op: "full",
|
|
369
|
+
data: json,
|
|
370
|
+
hash,
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
ws.send(JSON.stringify({ id, type: "response", command: "subscribe_session", success: true, data: {} }));
|
|
374
|
+
}
|
|
375
|
+
async handlePrompt(ws, id, command) {
|
|
376
|
+
let sessionPath = command.sessionPath;
|
|
377
|
+
if (!sessionPath)
|
|
378
|
+
throw new Error("Missing sessionPath");
|
|
379
|
+
const turnId = makeTurnId();
|
|
380
|
+
debugTurn("prompt_start", { turnId, sessionPath, hasModel: !!command.model });
|
|
381
|
+
let proc;
|
|
382
|
+
if (sessionPath === "__new__") {
|
|
383
|
+
const cwd = command.cwd || this.defaultCwd;
|
|
384
|
+
proc = await this.acquireProcess(cwd);
|
|
385
|
+
await this.pool.waitForReady(proc);
|
|
386
|
+
await this.pool.sendRpc(proc, { type: "new_session" });
|
|
387
|
+
const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
|
|
388
|
+
sessionPath = stateResp.data?.sessionFile;
|
|
389
|
+
if (!sessionPath)
|
|
390
|
+
throw new Error("Failed to get session path from new session");
|
|
391
|
+
this.busyProcesses.add(proc);
|
|
392
|
+
// Create attached session with empty state (new session)
|
|
393
|
+
this.createAttachedSession(sessionPath);
|
|
394
|
+
ws.send(JSON.stringify({
|
|
395
|
+
type: "session_attached",
|
|
396
|
+
sessionPath,
|
|
397
|
+
cwd,
|
|
398
|
+
firstMessage: command.message,
|
|
399
|
+
}));
|
|
400
|
+
this.lifecycle.attach(sessionPath, proc);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
proc = await this.acquireForSession(sessionPath);
|
|
404
|
+
}
|
|
405
|
+
if (!command.model) {
|
|
406
|
+
throw new Error(`BUG: prompt command received without model. sessionPath=${sessionPath}`);
|
|
407
|
+
}
|
|
408
|
+
await this.pool.sendRpcChecked(proc, {
|
|
409
|
+
type: "set_model",
|
|
410
|
+
provider: command.model.provider,
|
|
411
|
+
modelId: command.model.modelId,
|
|
412
|
+
});
|
|
413
|
+
const modelState = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
414
|
+
const activeModel = modelState.data?.model;
|
|
415
|
+
if (!activeModel || activeModel.provider !== command.model.provider || activeModel.id !== command.model.modelId) {
|
|
416
|
+
throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
|
|
417
|
+
}
|
|
418
|
+
if (command.thinkingLevel) {
|
|
419
|
+
await this.pool.sendRpcChecked(proc, { type: "set_thinking_level", level: command.thinkingLevel });
|
|
420
|
+
}
|
|
421
|
+
this.setupTurnEventForwarding(proc, sessionPath, ws, turnId);
|
|
422
|
+
const promptCmd = { type: "prompt", message: command.message };
|
|
423
|
+
if (command.images?.length > 0) {
|
|
424
|
+
promptCmd.images = command.images;
|
|
425
|
+
}
|
|
426
|
+
const response = await this.pool.sendRpc(proc, promptCmd);
|
|
427
|
+
const enriched = { ...response };
|
|
428
|
+
if (!enriched.data)
|
|
429
|
+
enriched.data = {};
|
|
430
|
+
enriched.data.newSessionPath = sessionPath;
|
|
431
|
+
ws.send(JSON.stringify({ ...enriched, id, command: "prompt" }));
|
|
432
|
+
}
|
|
433
|
+
async handleSteer(ws, id, command) {
|
|
434
|
+
const sessionPath = command.sessionPath;
|
|
435
|
+
if (!sessionPath)
|
|
436
|
+
throw new Error("Missing sessionPath");
|
|
437
|
+
const proc = this.lifecycle.getAttachedProcess(sessionPath);
|
|
438
|
+
if (!proc)
|
|
439
|
+
throw new Error("Session is not attached (agent not running)");
|
|
440
|
+
this.lifecycle.enqueueSteering(sessionPath, command.message);
|
|
441
|
+
await this.pool.sendRpc(proc, { type: "steer", message: command.message });
|
|
442
|
+
ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
|
|
443
|
+
}
|
|
444
|
+
async handleRemoveSteering(ws, id, command) {
|
|
445
|
+
const sessionPath = command.sessionPath;
|
|
446
|
+
if (!sessionPath)
|
|
447
|
+
throw new Error("Missing sessionPath");
|
|
448
|
+
const index = command.index;
|
|
449
|
+
if (typeof index !== "number")
|
|
450
|
+
throw new Error("Missing index");
|
|
451
|
+
this.lifecycle.removeSteeringByIndex(sessionPath, index);
|
|
452
|
+
ws.send(JSON.stringify({ id, type: "response", command: "remove_steering", success: true }));
|
|
453
|
+
}
|
|
454
|
+
async handleAbort(ws, id, command) {
|
|
455
|
+
const sessionPath = command.sessionPath;
|
|
456
|
+
const proc = sessionPath ? this.lifecycle.getAttachedProcess(sessionPath) : undefined;
|
|
457
|
+
if (proc) {
|
|
458
|
+
await this.pool.sendRpc(proc, { type: "abort" });
|
|
459
|
+
}
|
|
460
|
+
ws.send(JSON.stringify({ id, type: "response", command: "abort", success: true }));
|
|
461
|
+
}
|
|
462
|
+
async handleCompact(ws, id, command) {
|
|
463
|
+
const sessionPath = command.sessionPath;
|
|
464
|
+
if (!sessionPath)
|
|
465
|
+
throw new Error("Missing sessionPath");
|
|
466
|
+
const proc = await this.acquireForSession(sessionPath);
|
|
467
|
+
const response = await this.pool.sendRpc(proc, { type: "compact", customInstructions: command.customInstructions });
|
|
468
|
+
this.releaseProcess(sessionPath);
|
|
469
|
+
ws.send(JSON.stringify({ ...response, id, command: "compact" }));
|
|
470
|
+
}
|
|
471
|
+
async handleGetAvailableModels(ws, id) {
|
|
472
|
+
const proc = this.getAnyProcess();
|
|
473
|
+
const response = await this.pool.sendRpc(proc, { type: "get_available_models" });
|
|
474
|
+
ws.send(JSON.stringify({ ...response, id, command: "get_available_models" }));
|
|
475
|
+
}
|
|
476
|
+
async handleGetCommands(ws, id) {
|
|
477
|
+
const proc = this.getAnyProcess();
|
|
478
|
+
const response = await this.pool.sendRpc(proc, { type: "get_commands" });
|
|
479
|
+
ws.send(JSON.stringify({ ...response, id, command: "get_commands" }));
|
|
480
|
+
}
|
|
481
|
+
async handleReloadProcesses(ws, id) {
|
|
482
|
+
const all = this.pool.getAllProcesses();
|
|
483
|
+
let killed = 0;
|
|
484
|
+
let draining = 0;
|
|
485
|
+
for (const proc of all) {
|
|
486
|
+
if (proc.process.exitCode !== null)
|
|
487
|
+
continue;
|
|
488
|
+
const sessionPath = this.lifecycle.getAttachedSessionForProcess(proc);
|
|
489
|
+
if (sessionPath) {
|
|
490
|
+
// Graceful path: keep running turns alive, but decommission the process
|
|
491
|
+
// once the turn ends (releaseProcess will terminate it).
|
|
492
|
+
if (!this.decommissionProcesses.has(proc)) {
|
|
493
|
+
this.decommissionProcesses.add(proc);
|
|
494
|
+
draining += 1;
|
|
495
|
+
}
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
// Idle/unattached process: terminate immediately.
|
|
499
|
+
const cleanup = this.procEventCleanup.get(proc);
|
|
500
|
+
if (cleanup) {
|
|
501
|
+
cleanup();
|
|
502
|
+
this.procEventCleanup.delete(proc);
|
|
503
|
+
}
|
|
504
|
+
this.busyProcesses.delete(proc);
|
|
505
|
+
this.decommissionProcesses.delete(proc);
|
|
506
|
+
proc.process.kill("SIGTERM");
|
|
507
|
+
killed += 1;
|
|
508
|
+
}
|
|
509
|
+
this.ensurePool();
|
|
510
|
+
ws.send(JSON.stringify({
|
|
511
|
+
id,
|
|
512
|
+
type: "response",
|
|
513
|
+
command: "reload_processes",
|
|
514
|
+
success: true,
|
|
515
|
+
data: { killed, draining },
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
async handleGetDefaultModel(ws, id) {
|
|
519
|
+
const proc = this.getAnyProcess();
|
|
520
|
+
const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
|
|
521
|
+
const model = stateResp.data?.model ?? null;
|
|
522
|
+
const thinkingLevel = stateResp.data?.thinkingLevel ?? "off";
|
|
523
|
+
ws.send(JSON.stringify({ id, type: "response", command: "get_default_model", success: true, data: { model, thinkingLevel } }));
|
|
524
|
+
}
|
|
525
|
+
handleGetSessionStatuses(ws, id) {
|
|
526
|
+
ws.send(JSON.stringify({ id, type: "response", command: "get_session_statuses", success: true, data: { statuses: this.lifecycle.getAllStatuses() } }));
|
|
527
|
+
}
|
|
528
|
+
async handleFork(ws, id, command) {
|
|
529
|
+
const sessionPath = command.sessionPath;
|
|
530
|
+
if (!sessionPath)
|
|
531
|
+
throw new Error("Missing sessionPath");
|
|
532
|
+
const entryId = command.entryId;
|
|
533
|
+
if (!entryId)
|
|
534
|
+
throw new Error("Missing entryId");
|
|
535
|
+
const proc = await this.acquireForSession(sessionPath);
|
|
536
|
+
const response = await this.pool.sendRpc(proc, { type: "fork", entryId });
|
|
537
|
+
const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
|
|
538
|
+
const newSessionPath = stateResp.data?.sessionFile;
|
|
539
|
+
this.releaseProcess(sessionPath);
|
|
540
|
+
ws.send(JSON.stringify({
|
|
541
|
+
id, type: "response", command: "fork", success: true,
|
|
542
|
+
data: {
|
|
543
|
+
text: response.data?.text ?? "",
|
|
544
|
+
cancelled: response.data?.cancelled ?? false,
|
|
545
|
+
newSessionPath: newSessionPath ?? null,
|
|
546
|
+
},
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
async handleForkPrompt(ws, id, command) {
|
|
550
|
+
const sessionPath = command.sessionPath;
|
|
551
|
+
if (!sessionPath)
|
|
552
|
+
throw new Error("Missing sessionPath");
|
|
553
|
+
const message = command.message;
|
|
554
|
+
if (!message)
|
|
555
|
+
throw new Error("Missing message");
|
|
556
|
+
const sessionsDir = path.join(getAgentDir(), "sessions");
|
|
557
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
558
|
+
const newId = crypto.randomUUID().slice(0, 8);
|
|
559
|
+
const newFilename = `${timestamp}_${newId}.jsonl`;
|
|
560
|
+
const newSessionPath = path.join(sessionsDir, newFilename);
|
|
561
|
+
await copyFile(sessionPath, newSessionPath);
|
|
562
|
+
const cwd = getSessionCwd(sessionPath) || this.defaultCwd;
|
|
563
|
+
const proc = await this.acquireProcess(cwd);
|
|
564
|
+
await this.pool.waitForReady(proc);
|
|
565
|
+
this.busyProcesses.add(proc);
|
|
566
|
+
// Create attached session, seeded from the forked file
|
|
567
|
+
this.createAttachedSession(newSessionPath);
|
|
568
|
+
ws.send(JSON.stringify({ type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message }));
|
|
569
|
+
this.lifecycle.attach(newSessionPath, proc);
|
|
570
|
+
await this.pool.sendRpc(proc, { type: "switch_session", sessionPath: newSessionPath });
|
|
571
|
+
if (!command.model) {
|
|
572
|
+
throw new Error(`BUG: fork_prompt command received without model. sessionPath=${newSessionPath}`);
|
|
573
|
+
}
|
|
574
|
+
await this.pool.sendRpcChecked(proc, {
|
|
575
|
+
type: "set_model",
|
|
576
|
+
provider: command.model.provider,
|
|
577
|
+
modelId: command.model.modelId,
|
|
578
|
+
});
|
|
579
|
+
const modelState = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
580
|
+
const activeModel = modelState.data?.model;
|
|
581
|
+
if (!activeModel || activeModel.provider !== command.model.provider || activeModel.id !== command.model.modelId) {
|
|
582
|
+
throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
|
|
583
|
+
}
|
|
584
|
+
if (command.thinkingLevel) {
|
|
585
|
+
await this.pool.sendRpcChecked(proc, { type: "set_thinking_level", level: command.thinkingLevel });
|
|
586
|
+
}
|
|
587
|
+
const turnId = makeTurnId();
|
|
588
|
+
this.setupTurnEventForwarding(proc, newSessionPath, ws, turnId);
|
|
589
|
+
const promptCmd = { type: "prompt", message };
|
|
590
|
+
if (command.images?.length > 0) {
|
|
591
|
+
promptCmd.images = command.images;
|
|
592
|
+
}
|
|
593
|
+
await this.pool.sendRpc(proc, promptCmd);
|
|
594
|
+
ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
|
|
595
|
+
}
|
|
596
|
+
async handleSetSessionName(ws, id, command) {
|
|
597
|
+
const sessionPath = command.sessionPath;
|
|
598
|
+
if (!sessionPath)
|
|
599
|
+
throw new Error("Missing sessionPath");
|
|
600
|
+
const proc = await this.acquireForSession(sessionPath);
|
|
601
|
+
const response = await this.pool.sendRpc(proc, { type: "set_session_name", name: command.name });
|
|
602
|
+
this.releaseProcess(sessionPath);
|
|
603
|
+
ws.send(JSON.stringify({ ...response, id, command: "set_session_name" }));
|
|
604
|
+
}
|
|
605
|
+
// ── Internal helpers ─────────────────────────────────────────────────
|
|
606
|
+
sleep(ms) {
|
|
607
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
608
|
+
}
|
|
609
|
+
/** Busy + decommissioned processes are unavailable for reuse. */
|
|
610
|
+
getUnavailableProcesses() {
|
|
611
|
+
return new Set([...this.busyProcesses, ...this.decommissionProcesses]);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Create a SessionJsonl for a session path.
|
|
615
|
+
* Reads existing JSONL from disk to seed the initial messages.
|
|
616
|
+
*/
|
|
617
|
+
createAttachedSession(sessionPath) {
|
|
618
|
+
const { state } = readSessionFromDisk(sessionPath);
|
|
619
|
+
const session = new SessionJsonl({
|
|
620
|
+
messages: state.messages,
|
|
621
|
+
model: state.model,
|
|
622
|
+
thinkingLevel: state.thinkingLevel,
|
|
623
|
+
});
|
|
624
|
+
this.attachedSessions.set(sessionPath, session);
|
|
625
|
+
return session;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Acquire a process for an existing session. Resolves the session's cwd
|
|
629
|
+
* from its JSONL header and gets a process from the matching pool.
|
|
630
|
+
*/
|
|
631
|
+
async acquireForSession(sessionPath) {
|
|
632
|
+
const existing = this.lifecycle.getAttachedProcess(sessionPath);
|
|
633
|
+
if (existing)
|
|
634
|
+
return existing;
|
|
635
|
+
const cwd = getSessionCwd(sessionPath) || this.defaultCwd;
|
|
636
|
+
const proc = await this.acquireProcess(cwd);
|
|
637
|
+
this.busyProcesses.add(proc);
|
|
638
|
+
this.lifecycle.attach(sessionPath, proc);
|
|
639
|
+
// Create attached session if it doesn't exist yet
|
|
640
|
+
if (!this.attachedSessions.has(sessionPath)) {
|
|
641
|
+
this.createAttachedSession(sessionPath);
|
|
642
|
+
}
|
|
643
|
+
await this.pool.sendRpc(proc, { type: "switch_session", sessionPath });
|
|
644
|
+
console.log(`[ws] pi#${proc.id} attached to ${path.basename(sessionPath)} (cwd: ${cwd})`);
|
|
645
|
+
return proc;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Acquire a process for a given cwd. Spawns if needed.
|
|
649
|
+
*/
|
|
650
|
+
async acquireProcess(cwd) {
|
|
651
|
+
if (!this.piAvailable) {
|
|
652
|
+
throw new Error(makePiNotFoundMessage(this.piLaunch.command));
|
|
653
|
+
}
|
|
654
|
+
const timeoutMs = 60000;
|
|
655
|
+
const start = Date.now();
|
|
656
|
+
while (true) {
|
|
657
|
+
const unavailable = this.getUnavailableProcesses();
|
|
658
|
+
const proc = this.pool.acquire(cwd, unavailable);
|
|
659
|
+
if (proc)
|
|
660
|
+
return proc;
|
|
661
|
+
const evicted = this.pool.evictIdleDifferentCwd(cwd, unavailable);
|
|
662
|
+
if (evicted) {
|
|
663
|
+
await this.sleep(50);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (Date.now() - start >= timeoutMs) {
|
|
667
|
+
throw new Error(`Timed out waiting for available pi process for cwd: ${cwd}`);
|
|
668
|
+
}
|
|
669
|
+
await this.sleep(100);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
releaseProcess(sessionPath) {
|
|
673
|
+
const proc = this.lifecycle.getAttachedProcess(sessionPath);
|
|
674
|
+
if (proc) {
|
|
675
|
+
const cleanup = this.procEventCleanup.get(proc);
|
|
676
|
+
if (cleanup) {
|
|
677
|
+
cleanup();
|
|
678
|
+
this.procEventCleanup.delete(proc);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// Delete the attached session — no more in-memory state
|
|
682
|
+
this.attachedSessions.delete(sessionPath);
|
|
683
|
+
this.lifecycle.detach(sessionPath);
|
|
684
|
+
// Read final state from disk and push to subscribers
|
|
685
|
+
const { json, hash } = readSessionFromDisk(sessionPath);
|
|
686
|
+
this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
|
|
687
|
+
this.pushSnapshotToSubscribers(sessionPath, json, hash);
|
|
688
|
+
if (proc) {
|
|
689
|
+
const shouldDecommission = this.decommissionProcesses.has(proc);
|
|
690
|
+
if (shouldDecommission) {
|
|
691
|
+
this.decommissionProcesses.delete(proc);
|
|
692
|
+
console.log(`[pool] Decommissioning pi#${proc.id} after completed turn`);
|
|
693
|
+
proc.process.kill("SIGTERM");
|
|
694
|
+
}
|
|
695
|
+
this.busyProcesses.delete(proc);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
getAnyProcess() {
|
|
699
|
+
let proc = this.pool.getAny(this.getUnavailableProcesses());
|
|
700
|
+
if (!proc) {
|
|
701
|
+
proc = this.pool.spawn(this.defaultCwd);
|
|
702
|
+
}
|
|
703
|
+
return proc;
|
|
704
|
+
}
|
|
705
|
+
pushSnapshotToSubscribers(sessionPath, json, hash) {
|
|
706
|
+
for (const [ws, client] of this.clients) {
|
|
707
|
+
if (client.subscribedSession !== sessionPath)
|
|
708
|
+
continue;
|
|
709
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
710
|
+
continue;
|
|
711
|
+
ws.send(JSON.stringify({
|
|
712
|
+
type: "session_sync",
|
|
713
|
+
sessionPath,
|
|
714
|
+
op: "full",
|
|
715
|
+
data: json,
|
|
716
|
+
hash,
|
|
717
|
+
}));
|
|
718
|
+
client.lastJson = json;
|
|
719
|
+
client.lastHash = hash;
|
|
720
|
+
client.lastVersion = 0;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Push an update to all clients subscribed to an attached session.
|
|
725
|
+
* Uses the hash-verified diff protocol for efficient incremental sync.
|
|
726
|
+
*/
|
|
727
|
+
pushUpdateToSubscribers(sessionPath, session) {
|
|
728
|
+
for (const [ws, client] of this.clients) {
|
|
729
|
+
if (client.subscribedSession !== sessionPath)
|
|
730
|
+
continue;
|
|
731
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
732
|
+
continue;
|
|
733
|
+
const syncOp = session.computeSyncOp(client.lastJson, client.lastHash, client.lastVersion);
|
|
734
|
+
if (!syncOp)
|
|
735
|
+
continue;
|
|
736
|
+
ws.send(JSON.stringify({
|
|
737
|
+
type: "session_sync",
|
|
738
|
+
sessionPath,
|
|
739
|
+
...syncOp,
|
|
740
|
+
}));
|
|
741
|
+
client.lastJson = session.json;
|
|
742
|
+
client.lastHash = session.hash;
|
|
743
|
+
client.lastVersion = session.version;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
setupTurnEventForwarding(proc, sessionPath, ws, turnId) {
|
|
747
|
+
const existingCleanup = this.procEventCleanup.get(proc);
|
|
748
|
+
if (existingCleanup) {
|
|
749
|
+
existingCleanup();
|
|
750
|
+
this.procEventCleanup.delete(proc);
|
|
751
|
+
}
|
|
752
|
+
// Capture the SessionJsonl object — if it gets deleted (detach),
|
|
753
|
+
// the handler becomes a no-op because we check for it.
|
|
754
|
+
const sessionRef = this.attachedSessions.get(sessionPath);
|
|
755
|
+
if (!sessionRef) {
|
|
756
|
+
console.error(`[turn] setupTurnEventForwarding called but no SessionJsonl for ${sessionPath}`);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const lineHandler = (line) => {
|
|
760
|
+
let data;
|
|
761
|
+
try {
|
|
762
|
+
data = JSON.parse(line);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (data.type === "response" && data.id)
|
|
768
|
+
return;
|
|
769
|
+
// Guard: if the attached session was deleted (turn ended),
|
|
770
|
+
// this handler is stale — skip.
|
|
771
|
+
const currentSession = this.attachedSessions.get(sessionPath);
|
|
772
|
+
if (currentSession !== sessionRef)
|
|
773
|
+
return;
|
|
774
|
+
// Apply event to the in-memory attached session
|
|
775
|
+
let changed = currentSession.applyEvent(data);
|
|
776
|
+
// After auto-compaction, the pi process rewrites the JSONL and calls
|
|
777
|
+
// replaceMessages() internally. SessionJsonl doesn't know about this,
|
|
778
|
+
// so re-read the session from disk to pick up the compacted state.
|
|
779
|
+
if (data.type === "auto_compaction_end" && data.result) {
|
|
780
|
+
const { state } = readSessionFromDisk(sessionPath);
|
|
781
|
+
currentSession.replaceMessages(state.messages);
|
|
782
|
+
changed = true;
|
|
783
|
+
}
|
|
784
|
+
// Side-channel raw event for UI hooks (canvas/jsonl), not state updates
|
|
785
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
786
|
+
ws.send(JSON.stringify({ type: "agent_event", sessionPath, event: data }));
|
|
787
|
+
}
|
|
788
|
+
if (data.type === "message_end" && data.message?.role === "user") {
|
|
789
|
+
const text = typeof data.message.content === "string"
|
|
790
|
+
? data.message.content
|
|
791
|
+
: (data.message.content || []).filter((c) => c.type === "text").map((c) => c.text).join(" ");
|
|
792
|
+
this.lifecycle.dequeueSteering(sessionPath, text);
|
|
793
|
+
}
|
|
794
|
+
// Push update to all subscribed clients
|
|
795
|
+
if (changed) {
|
|
796
|
+
this.pushUpdateToSubscribers(sessionPath, currentSession);
|
|
797
|
+
}
|
|
798
|
+
if (data.type === "agent_end") {
|
|
799
|
+
debugTurn("agent_end_received", { turnId, procId: proc.id, sessionPath });
|
|
800
|
+
this.lifecycle.clearSteering(sessionPath);
|
|
801
|
+
this.releaseProcess(sessionPath);
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
proc.rl.on("line", lineHandler);
|
|
805
|
+
const cleanup = () => proc.rl.removeListener("line", lineHandler);
|
|
806
|
+
this.procEventCleanup.set(proc, cleanup);
|
|
807
|
+
}
|
|
808
|
+
}
|