pipane 0.1.6 → 0.1.8
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 +14 -3
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- package/dist/build-info.json +5 -0
- package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
- package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
- package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
- package/dist/client/assets/index-C7_1ODks.js +2 -0
- package/dist/client/assets/index-DgI_gkjg.css +1 -0
- package/dist/client/assets/main-DEfSB8wO.js +2822 -0
- package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
- package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
- package/dist/client/assets/ws-agent-adapter-DpkkZGPl.js +6 -0
- package/dist/client/index.html +2 -2
- package/dist/server/rendezvous/rendezvous-hub.js +426 -0
- package/dist/server/rendezvous/server.js +247 -0
- package/dist/server/rendezvous/trust-store.js +432 -0
- package/dist/server/server/auth-guard.js +61 -0
- package/dist/server/server/backend-connection-authorizer.js +29 -0
- package/dist/server/server/backend-identity.js +167 -0
- package/dist/server/server/backend-protocol-handler.js +132 -0
- package/dist/server/server/backend-trust-store.js +157 -0
- package/dist/server/server/backend-webrtc.js +245 -0
- package/dist/server/server/build-info.js +13 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/frame-connection.js +106 -0
- package/dist/server/server/frame-router.js +45 -0
- package/dist/server/server/ice-servers.js +24 -0
- package/dist/server/server/local-backend-api.js +389 -0
- package/dist/server/server/local-settings.js +17 -4
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/process-pool.js +27 -24
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +133 -176
- package/dist/server/server/server.js +167 -97
- package/dist/server/server/session-index.js +105 -28
- package/dist/server/server/session-jsonl.js +82 -5
- package/dist/server/server/session-path.js +145 -0
- package/dist/server/server/update-api.js +28 -0
- package/dist/server/server/update-check.js +33 -13
- package/dist/server/server/update-manager.js +233 -0
- package/dist/server/server/worktree-name.js +116 -31
- package/dist/server/server/ws-handler.js +267 -186
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/data-channel-framing.js +137 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -0
- package/dist/server/shared/tool-runtime.js +1 -0
- package/dist/server/shared/trust-protocol.js +97 -0
- package/dist/server/shared/updates.js +4 -0
- package/dist/server/shared/ws-protocol.js +473 -1
- package/docs/protocol.md +129 -0
- package/package.json +21 -8
- package/dist/client/assets/index-Dl_wdLZH.css +0 -1
- package/dist/client/assets/index-hNqbnG06.js +0 -2482
|
@@ -8,16 +8,17 @@
|
|
|
8
8
|
* - Any number of clients can connect; each subscribes to one session at a time.
|
|
9
9
|
* - Detached sessions are read from JSONL on demand.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { FRAME_CONNECTION_OPEN } from "./frame-connection.js";
|
|
12
12
|
import { copyFile } from "node:fs/promises";
|
|
13
|
-
import { existsSync } from "node:fs";
|
|
13
|
+
import { constants as fsConstants, existsSync } from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
15
|
import { SessionJsonl, readSessionFromDisk, getSessionFileSize, serializeSessionState, } from "./session-jsonl.js";
|
|
17
16
|
import { getSessionCwd } from "./session-cwd.js";
|
|
18
17
|
import { checkCommandAvailable, installPiGlobal, isPiInstallable, makePiNotFoundMessage } from "./pi-runtime.js";
|
|
19
18
|
import { modelsMatch, toCompactModelRef } from "../shared/thinking-levels.js";
|
|
20
19
|
import { COMPACT_RPC_TIMEOUT_MS } from "../shared/rpc-timeouts.js";
|
|
20
|
+
import { assertNever, decodeClientCommand, encodeServerMessage, } from "../shared/ws-protocol.js";
|
|
21
|
+
import { SessionPathError, SessionPathGuard } from "./session-path.js";
|
|
21
22
|
import { extensionStatusSnapshot, isValidExtensionStatusKey, normalizeExtensionStatusText, providerForUsageStatus, PROVIDER_USAGE_STATUS_KEY, } from "./extension-status.js";
|
|
22
23
|
let nextTurnId = 0;
|
|
23
24
|
function makeTurnId() {
|
|
@@ -29,6 +30,7 @@ function debugTurn(stage, data) {
|
|
|
29
30
|
export class WsHandler {
|
|
30
31
|
registry;
|
|
31
32
|
pool;
|
|
33
|
+
sessionPaths;
|
|
32
34
|
defaultCwd;
|
|
33
35
|
piLaunch;
|
|
34
36
|
ensurePool;
|
|
@@ -45,11 +47,14 @@ export class WsHandler {
|
|
|
45
47
|
* Used for change detection when the file watcher fires.
|
|
46
48
|
*/
|
|
47
49
|
subscribedFileSizes = new Map();
|
|
50
|
+
/** Last revision/hash published for each authoritative session state. */
|
|
51
|
+
sessionRevisions = new Map();
|
|
48
52
|
piAvailable;
|
|
49
53
|
piInstalling = false;
|
|
50
54
|
constructor(options) {
|
|
51
55
|
this.registry = options.registry;
|
|
52
56
|
this.pool = options.pool;
|
|
57
|
+
this.sessionPaths = options.sessionPaths ?? new SessionPathGuard();
|
|
53
58
|
this.defaultCwd = options.defaultCwd;
|
|
54
59
|
this.piLaunch = options.piLaunch;
|
|
55
60
|
this.ensurePool = options.ensurePool;
|
|
@@ -172,9 +177,9 @@ export class WsHandler {
|
|
|
172
177
|
};
|
|
173
178
|
}
|
|
174
179
|
pushExtensionStatusesToSubscribers(sessionPath) {
|
|
175
|
-
const message =
|
|
180
|
+
const message = encodeServerMessage(this.makeExtensionStatusMessage(sessionPath));
|
|
176
181
|
for (const [ws, client] of this.clients) {
|
|
177
|
-
if (client.subscribedSession !== sessionPath || ws.readyState !==
|
|
182
|
+
if (client.subscribedSession !== sessionPath || ws.readyState !== FRAME_CONNECTION_OPEN)
|
|
178
183
|
continue;
|
|
179
184
|
ws.send(message);
|
|
180
185
|
}
|
|
@@ -228,142 +233,160 @@ export class WsHandler {
|
|
|
228
233
|
.map((process) => process.attachedSession)
|
|
229
234
|
.filter((sessionPath) => sessionPath !== null),
|
|
230
235
|
sessionStatuses: this.registry.getAllStatuses(),
|
|
231
|
-
connectedWsOpen: Array.from(this.clients.keys()).filter((ws) => ws.readyState ===
|
|
236
|
+
connectedWsOpen: Array.from(this.clients.keys()).filter((ws) => ws.readyState === FRAME_CONNECTION_OPEN).length,
|
|
232
237
|
processes,
|
|
233
238
|
};
|
|
234
239
|
}
|
|
235
240
|
register(wss) {
|
|
236
|
-
wss.on("connection", (ws, req) =>
|
|
241
|
+
wss.on("connection", (ws, req) => {
|
|
242
|
+
if (!this.isRequestAuthorized(req)) {
|
|
243
|
+
ws.close(1008, "Unauthorized");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.acceptConnection(ws);
|
|
247
|
+
});
|
|
237
248
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
console.log("WebSocket client connected");
|
|
249
|
+
acceptAuthenticatedConnection(connection) {
|
|
250
|
+
this.acceptConnection(connection);
|
|
251
|
+
}
|
|
252
|
+
acceptConnection(ws) {
|
|
253
|
+
console.log("Frame client connected");
|
|
244
254
|
this.clients.set(ws, {
|
|
245
255
|
subscribedSession: null,
|
|
246
256
|
lastVersion: 0,
|
|
247
257
|
lastJson: "",
|
|
248
258
|
lastHash: "",
|
|
249
259
|
});
|
|
250
|
-
|
|
260
|
+
this.sendMessage(ws, {
|
|
251
261
|
type: "init",
|
|
252
262
|
sessionStatuses: this.registry.getAllStatuses(),
|
|
253
263
|
steeringQueues: this.registry.getAllSteeringQueues(),
|
|
254
264
|
providerUsageStatuses: this.makeProviderUsageMessage().statuses,
|
|
255
|
-
})
|
|
265
|
+
});
|
|
256
266
|
if (!this.piAvailable) {
|
|
257
|
-
|
|
267
|
+
this.sendMessage(ws, {
|
|
258
268
|
type: "pi_install_required",
|
|
259
269
|
command: this.piLaunch.command,
|
|
260
270
|
installable: isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs),
|
|
261
271
|
installing: this.piInstalling,
|
|
262
272
|
message: makePiNotFoundMessage(this.piLaunch.command),
|
|
263
|
-
})
|
|
273
|
+
});
|
|
264
274
|
}
|
|
265
275
|
ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
|
|
266
276
|
ws.on("close", () => {
|
|
267
|
-
console.log("
|
|
277
|
+
console.log("Frame client disconnected");
|
|
268
278
|
this.clients.delete(ws);
|
|
269
279
|
});
|
|
270
280
|
}
|
|
281
|
+
sendMessage(ws, payload) {
|
|
282
|
+
if (ws.readyState === FRAME_CONNECTION_OPEN)
|
|
283
|
+
ws.send(encodeServerMessage(payload));
|
|
284
|
+
}
|
|
285
|
+
sendSuccess(ws, id, command, data) {
|
|
286
|
+
this.sendMessage(ws, {
|
|
287
|
+
type: "response",
|
|
288
|
+
id,
|
|
289
|
+
command,
|
|
290
|
+
success: true,
|
|
291
|
+
data,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
sendError(ws, id, command, error, code) {
|
|
295
|
+
this.sendMessage(ws, { type: "response", id, command, success: false, code, error });
|
|
296
|
+
}
|
|
297
|
+
notifySessionsChanged(file) {
|
|
298
|
+
this.broadcast({ type: "sessions_changed", file });
|
|
299
|
+
}
|
|
271
300
|
broadcast(payload) {
|
|
301
|
+
const message = encodeServerMessage(payload);
|
|
272
302
|
for (const ws of this.clients.keys()) {
|
|
273
|
-
if (ws.readyState ===
|
|
274
|
-
ws.send(
|
|
275
|
-
}
|
|
303
|
+
if (ws.readyState === FRAME_CONNECTION_OPEN)
|
|
304
|
+
ws.send(message);
|
|
276
305
|
}
|
|
277
306
|
}
|
|
278
307
|
async handleMessage(ws, raw) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
command
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
ws.send(JSON.stringify({ type: "response", command: "parse", success: false, error: "Invalid JSON" }));
|
|
308
|
+
const decoded = decodeClientCommand(raw);
|
|
309
|
+
if (!decoded.ok) {
|
|
310
|
+
this.sendError(ws, decoded.error.requestId, decoded.error.command, decoded.error.message, decoded.error.code);
|
|
285
311
|
return;
|
|
286
312
|
}
|
|
313
|
+
const command = decoded.value;
|
|
287
314
|
const id = command.id;
|
|
288
315
|
try {
|
|
289
316
|
if (!this.piAvailable && command.type !== "install_pi" && command.type !== "get_session_statuses") {
|
|
290
|
-
|
|
317
|
+
this.sendMessage(ws, {
|
|
291
318
|
type: "pi_install_required",
|
|
292
319
|
command: this.piLaunch.command,
|
|
293
320
|
installable: isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs),
|
|
294
321
|
installing: this.piInstalling,
|
|
295
322
|
message: makePiNotFoundMessage(this.piLaunch.command),
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
ws.send(JSON.stringify({
|
|
299
|
-
id, type: "response", command: command.type, success: false,
|
|
300
|
-
error: makePiNotFoundMessage(this.piLaunch.command),
|
|
301
|
-
}));
|
|
302
|
-
}
|
|
323
|
+
});
|
|
324
|
+
this.sendError(ws, id, command.type, makePiNotFoundMessage(this.piLaunch.command), "command_failed");
|
|
303
325
|
return;
|
|
304
326
|
}
|
|
305
327
|
switch (command.type) {
|
|
306
328
|
case "install_pi":
|
|
307
|
-
await this.handleInstallPi(ws,
|
|
329
|
+
await this.handleInstallPi(ws, command);
|
|
308
330
|
break;
|
|
309
331
|
case "subscribe_session":
|
|
310
|
-
this.handleSubscribeSession(ws,
|
|
332
|
+
this.handleSubscribeSession(ws, command);
|
|
311
333
|
break;
|
|
312
334
|
case "prompt":
|
|
313
|
-
await this.handlePrompt(ws,
|
|
335
|
+
await this.handlePrompt(ws, command);
|
|
314
336
|
break;
|
|
315
337
|
case "steer":
|
|
316
|
-
await this.handleSteer(ws,
|
|
338
|
+
await this.handleSteer(ws, command);
|
|
317
339
|
break;
|
|
318
340
|
case "remove_steering":
|
|
319
|
-
await this.handleRemoveSteering(ws,
|
|
341
|
+
await this.handleRemoveSteering(ws, command);
|
|
320
342
|
break;
|
|
321
343
|
case "abort":
|
|
322
|
-
await this.handleAbort(ws,
|
|
344
|
+
await this.handleAbort(ws, command);
|
|
323
345
|
break;
|
|
324
346
|
case "hard_kill":
|
|
325
|
-
await this.handleHardKill(ws,
|
|
347
|
+
await this.handleHardKill(ws, command);
|
|
326
348
|
break;
|
|
327
349
|
case "compact":
|
|
328
|
-
await this.handleCompact(ws,
|
|
350
|
+
await this.handleCompact(ws, command);
|
|
329
351
|
break;
|
|
330
352
|
case "get_available_models":
|
|
331
|
-
await this.handleGetAvailableModels(ws,
|
|
353
|
+
await this.handleGetAvailableModels(ws, command);
|
|
332
354
|
break;
|
|
333
355
|
case "get_default_model":
|
|
334
|
-
await this.handleGetDefaultModel(ws,
|
|
356
|
+
await this.handleGetDefaultModel(ws, command);
|
|
335
357
|
break;
|
|
336
358
|
case "get_session_statuses":
|
|
337
|
-
this.handleGetSessionStatuses(ws,
|
|
359
|
+
this.handleGetSessionStatuses(ws, command);
|
|
360
|
+
break;
|
|
361
|
+
case "get_session_stats":
|
|
362
|
+
await this.handleGetSessionStats(ws, command);
|
|
338
363
|
break;
|
|
339
364
|
case "fork":
|
|
340
|
-
await this.handleFork(ws,
|
|
365
|
+
await this.handleFork(ws, command);
|
|
341
366
|
break;
|
|
342
367
|
case "fork_prompt":
|
|
343
|
-
await this.handleForkPrompt(ws,
|
|
368
|
+
await this.handleForkPrompt(ws, command);
|
|
344
369
|
break;
|
|
345
370
|
case "set_session_name":
|
|
346
|
-
await this.handleSetSessionName(ws,
|
|
371
|
+
await this.handleSetSessionName(ws, command);
|
|
347
372
|
break;
|
|
348
373
|
case "get_commands":
|
|
349
|
-
await this.handleGetCommands(ws,
|
|
374
|
+
await this.handleGetCommands(ws, command);
|
|
350
375
|
break;
|
|
351
376
|
case "reload_processes":
|
|
352
|
-
await this.handleReloadProcesses(ws,
|
|
377
|
+
await this.handleReloadProcesses(ws, command);
|
|
353
378
|
break;
|
|
354
379
|
default:
|
|
355
|
-
|
|
356
|
-
id, type: "response", command: command.type, success: false,
|
|
357
|
-
error: `Unknown command: ${command.type}`,
|
|
358
|
-
}));
|
|
380
|
+
assertNever(command);
|
|
359
381
|
}
|
|
360
382
|
}
|
|
361
|
-
catch (
|
|
362
|
-
|
|
363
|
-
|
|
383
|
+
catch (error) {
|
|
384
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
385
|
+
debugTurn("command_error", { commandType: command.type, requestId: id, error: message });
|
|
386
|
+
this.sendError(ws, id, command.type, message, "command_failed");
|
|
364
387
|
}
|
|
365
388
|
}
|
|
366
|
-
async handleInstallPi(ws,
|
|
389
|
+
async handleInstallPi(ws, command) {
|
|
367
390
|
const installable = isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs);
|
|
368
391
|
if (!installable) {
|
|
369
392
|
throw new Error(`Automatic install not supported for command '${this.piLaunch.command}'. Set PI_CLI or install manually.`);
|
|
@@ -386,21 +409,35 @@ export class WsHandler {
|
|
|
386
409
|
console.log("[pi] pi installed successfully");
|
|
387
410
|
this.ensurePool();
|
|
388
411
|
}
|
|
389
|
-
|
|
412
|
+
this.sendSuccess(ws, command.id, "install_pi", {});
|
|
390
413
|
}
|
|
391
|
-
handleSubscribeSession(ws,
|
|
414
|
+
handleSubscribeSession(ws, command) {
|
|
392
415
|
const client = this.clients.get(ws);
|
|
393
416
|
if (!client)
|
|
394
417
|
return;
|
|
395
|
-
const
|
|
396
|
-
if (!
|
|
418
|
+
const requestedPath = command.sessionPath;
|
|
419
|
+
if (!requestedPath) {
|
|
397
420
|
client.subscribedSession = null;
|
|
398
421
|
client.lastVersion = 0;
|
|
399
422
|
client.lastJson = "";
|
|
400
423
|
client.lastHash = "";
|
|
401
|
-
|
|
424
|
+
this.sendSuccess(ws, command.id, "subscribe_session", {});
|
|
402
425
|
return;
|
|
403
426
|
}
|
|
427
|
+
let sessionPath;
|
|
428
|
+
try {
|
|
429
|
+
sessionPath = this.sessionPaths.resolveExisting(requestedPath);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
// Pi allocates a new path before its first prompt flushes the file. The
|
|
433
|
+
// actor proves that such a confined pending path belongs to this server.
|
|
434
|
+
if (!(error instanceof SessionPathError) || error.code !== "not_found")
|
|
435
|
+
throw error;
|
|
436
|
+
const pendingPath = this.sessionPaths.resolvePending(requestedPath);
|
|
437
|
+
if (!this.registry.find(pendingPath)?.isAttached)
|
|
438
|
+
throw error;
|
|
439
|
+
sessionPath = pendingPath;
|
|
440
|
+
}
|
|
404
441
|
client.subscribedSession = sessionPath;
|
|
405
442
|
// If the session is attached, send from its actor-owned in-memory state
|
|
406
443
|
const attached = this.registry.find(sessionPath)?.session;
|
|
@@ -409,13 +446,14 @@ export class WsHandler {
|
|
|
409
446
|
client.lastJson = attached.json;
|
|
410
447
|
client.lastHash = attached.hash;
|
|
411
448
|
client.lastVersion = attached.version;
|
|
412
|
-
|
|
449
|
+
this.sendMessage(ws, {
|
|
413
450
|
type: "session_sync",
|
|
414
451
|
sessionPath,
|
|
452
|
+
revision: this.revisionForState(sessionPath, attached.hash),
|
|
415
453
|
op: "full",
|
|
416
454
|
data: attached.json,
|
|
417
455
|
hash: attached.hash,
|
|
418
|
-
})
|
|
456
|
+
});
|
|
419
457
|
}
|
|
420
458
|
else {
|
|
421
459
|
// Detached — read from disk
|
|
@@ -425,23 +463,27 @@ export class WsHandler {
|
|
|
425
463
|
client.lastVersion = 0;
|
|
426
464
|
// Track file size for change detection
|
|
427
465
|
this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
|
|
428
|
-
|
|
466
|
+
this.sendMessage(ws, {
|
|
429
467
|
type: "session_sync",
|
|
430
468
|
sessionPath,
|
|
469
|
+
revision: this.revisionForState(sessionPath, hash),
|
|
431
470
|
op: "full",
|
|
432
471
|
data: json,
|
|
433
472
|
hash,
|
|
434
|
-
})
|
|
473
|
+
});
|
|
435
474
|
}
|
|
436
475
|
// Extension status is a separate, authoritative snapshot so reconnects
|
|
437
476
|
// and session switches replace rather than merge stale client state.
|
|
438
|
-
|
|
439
|
-
|
|
477
|
+
this.sendMessage(ws, this.makeExtensionStatusMessage(sessionPath));
|
|
478
|
+
this.sendSuccess(ws, command.id, "subscribe_session", {});
|
|
440
479
|
}
|
|
441
|
-
async handlePrompt(ws,
|
|
442
|
-
|
|
443
|
-
if (!
|
|
480
|
+
async handlePrompt(ws, command) {
|
|
481
|
+
const requestedPath = command.sessionPath;
|
|
482
|
+
if (!requestedPath)
|
|
444
483
|
throw new Error("Missing sessionPath");
|
|
484
|
+
let sessionPath = requestedPath === "__new__"
|
|
485
|
+
? requestedPath
|
|
486
|
+
: this.sessionPaths.resolveExisting(requestedPath);
|
|
445
487
|
const turnId = makeTurnId();
|
|
446
488
|
debugTurn("prompt_start", { turnId, sessionPath, hasModel: !!command.model });
|
|
447
489
|
let actor;
|
|
@@ -457,10 +499,11 @@ export class WsHandler {
|
|
|
457
499
|
await this.pool.waitForReady(proc);
|
|
458
500
|
this.beginPendingExtensionStatusCapture(proc);
|
|
459
501
|
await this.replacePiSession(proc, { type: "new_session" }, "new_session");
|
|
460
|
-
const stateResp = await this.pool.
|
|
461
|
-
|
|
462
|
-
if (!
|
|
502
|
+
const stateResp = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
503
|
+
const createdPath = stateResp.data.sessionFile;
|
|
504
|
+
if (!createdPath)
|
|
463
505
|
throw new Error("Failed to get session path from new session");
|
|
506
|
+
sessionPath = this.sessionPaths.resolvePending(createdPath);
|
|
464
507
|
}
|
|
465
508
|
actor = this.registry.get(sessionPath);
|
|
466
509
|
const start = await actor.enqueue("prompt start", async () => {
|
|
@@ -476,12 +519,12 @@ export class WsHandler {
|
|
|
476
519
|
actor.attach(unownedLease, this.createSessionState(sessionPath));
|
|
477
520
|
unownedLease = undefined;
|
|
478
521
|
this.commitPendingExtensionStatuses(proc, sessionPath);
|
|
479
|
-
|
|
522
|
+
this.sendMessage(ws, {
|
|
480
523
|
type: "session_attached",
|
|
481
524
|
sessionPath,
|
|
482
525
|
cwd: newSessionCwd,
|
|
483
526
|
firstMessage: command.message,
|
|
484
|
-
})
|
|
527
|
+
});
|
|
485
528
|
}
|
|
486
529
|
else {
|
|
487
530
|
proc = await this.acquireForActor(actor);
|
|
@@ -489,15 +532,18 @@ export class WsHandler {
|
|
|
489
532
|
generation = actor.beginTurn();
|
|
490
533
|
const observer = this.setupTurnEventForwarding(actor, proc, generation, turnId);
|
|
491
534
|
await this.applyRequestedControlState(proc, actor, ws, command);
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
535
|
+
const response = await this.pool.sendRpc(proc, {
|
|
536
|
+
type: "prompt",
|
|
537
|
+
message: command.message,
|
|
538
|
+
...(command.images?.length ? { images: command.images } : {}),
|
|
539
|
+
});
|
|
540
|
+
if (!response.success)
|
|
541
|
+
throw new Error(response.error);
|
|
496
542
|
await this.reconcileEffectiveControlState(proc, actor);
|
|
497
543
|
return { observer, response, generation };
|
|
498
544
|
});
|
|
499
545
|
if (!start) {
|
|
500
|
-
|
|
546
|
+
this.sendSuccess(ws, command.id, "prompt", { newSessionPath: sessionPath });
|
|
501
547
|
return;
|
|
502
548
|
}
|
|
503
549
|
await this.waitForPromptSettlement(proc, start.observer);
|
|
@@ -507,37 +553,34 @@ export class WsHandler {
|
|
|
507
553
|
await this.reconcileEffectiveControlState(proc, actor);
|
|
508
554
|
this.releaseActor(actor);
|
|
509
555
|
});
|
|
510
|
-
|
|
511
|
-
if (!enriched.data)
|
|
512
|
-
enriched.data = {};
|
|
513
|
-
enriched.data.newSessionPath = sessionPath;
|
|
514
|
-
ws.send(JSON.stringify({ ...enriched, id, command: "prompt" }));
|
|
556
|
+
this.sendSuccess(ws, command.id, "prompt", { newSessionPath: sessionPath });
|
|
515
557
|
}
|
|
516
|
-
catch (
|
|
558
|
+
catch (error) {
|
|
517
559
|
if (proc)
|
|
518
560
|
this.pendingExtensionStatuses.delete(proc);
|
|
519
561
|
unownedLease?.release();
|
|
520
562
|
if (actor && proc && actor.process === proc) {
|
|
521
|
-
let detailed = this.buildPromptFailureMessage(
|
|
563
|
+
let detailed = this.buildPromptFailureMessage(error, proc, actor);
|
|
522
564
|
await actor.enqueue("prompt failure", () => {
|
|
523
565
|
if (actor.process !== proc)
|
|
524
566
|
return;
|
|
525
|
-
detailed = this.buildPromptFailureMessage(
|
|
567
|
+
detailed = this.buildPromptFailureMessage(error, proc, actor);
|
|
526
568
|
actor.markFailed();
|
|
527
569
|
this.injectSessionError(actor, detailed);
|
|
528
570
|
this.releaseActor(actor);
|
|
529
571
|
});
|
|
530
|
-
|
|
572
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
573
|
+
if (rawMessage.includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
|
|
531
574
|
proc.process.kill("SIGTERM");
|
|
532
575
|
}
|
|
533
576
|
throw new Error(detailed);
|
|
534
577
|
}
|
|
535
578
|
if (proc && proc.process.exitCode === null)
|
|
536
579
|
proc.process.kill("SIGTERM");
|
|
537
|
-
throw
|
|
580
|
+
throw error;
|
|
538
581
|
}
|
|
539
582
|
}
|
|
540
|
-
async handleSteer(ws,
|
|
583
|
+
async handleSteer(ws, command) {
|
|
541
584
|
const sessionPath = command.sessionPath;
|
|
542
585
|
if (!sessionPath)
|
|
543
586
|
throw new Error("Missing sessionPath");
|
|
@@ -545,9 +588,9 @@ export class WsHandler {
|
|
|
545
588
|
if (!actor)
|
|
546
589
|
throw new Error("Session is not attached (agent not running)");
|
|
547
590
|
await actor.enqueue("steer", () => this.sendSteering(actor, command.message));
|
|
548
|
-
|
|
591
|
+
this.sendSuccess(ws, command.id, "steer", {});
|
|
549
592
|
}
|
|
550
|
-
async handleRemoveSteering(ws,
|
|
593
|
+
async handleRemoveSteering(ws, command) {
|
|
551
594
|
const sessionPath = command.sessionPath;
|
|
552
595
|
if (!sessionPath)
|
|
553
596
|
throw new Error("Missing sessionPath");
|
|
@@ -557,9 +600,9 @@ export class WsHandler {
|
|
|
557
600
|
const actor = this.registry.find(sessionPath);
|
|
558
601
|
if (actor)
|
|
559
602
|
await actor.enqueue("remove steering", () => actor.removeSteeringByIndex(index));
|
|
560
|
-
|
|
603
|
+
this.sendSuccess(ws, command.id, "remove_steering", {});
|
|
561
604
|
}
|
|
562
|
-
async handleAbort(ws,
|
|
605
|
+
async handleAbort(ws, command) {
|
|
563
606
|
const sessionPath = command.sessionPath;
|
|
564
607
|
const actor = sessionPath ? this.registry.find(sessionPath) : undefined;
|
|
565
608
|
if (actor) {
|
|
@@ -569,9 +612,9 @@ export class WsHandler {
|
|
|
569
612
|
}
|
|
570
613
|
});
|
|
571
614
|
}
|
|
572
|
-
|
|
615
|
+
this.sendSuccess(ws, command.id, "abort", {});
|
|
573
616
|
}
|
|
574
|
-
async handleHardKill(ws,
|
|
617
|
+
async handleHardKill(ws, command) {
|
|
575
618
|
const sessionPath = command.sessionPath;
|
|
576
619
|
if (!sessionPath)
|
|
577
620
|
throw new Error("Missing sessionPath");
|
|
@@ -591,20 +634,12 @@ export class WsHandler {
|
|
|
591
634
|
}
|
|
592
635
|
if (hadProcess)
|
|
593
636
|
this.ensurePool();
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
command: "hard_kill",
|
|
598
|
-
success: true,
|
|
599
|
-
data: killed
|
|
600
|
-
? { killed: true }
|
|
601
|
-
: { killed: false, reason: hadProcess ? "signal_failed" : "not_attached" },
|
|
602
|
-
}));
|
|
637
|
+
this.sendSuccess(ws, command.id, "hard_kill", killed
|
|
638
|
+
? { killed: true }
|
|
639
|
+
: { killed: false, reason: hadProcess ? "signal_failed" : "not_attached" });
|
|
603
640
|
}
|
|
604
|
-
async handleCompact(ws,
|
|
605
|
-
const sessionPath = command.sessionPath;
|
|
606
|
-
if (!sessionPath)
|
|
607
|
-
throw new Error("Missing sessionPath");
|
|
641
|
+
async handleCompact(ws, command) {
|
|
642
|
+
const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
|
|
608
643
|
const actor = this.registry.get(sessionPath);
|
|
609
644
|
const response = await actor.enqueue("compact", async () => {
|
|
610
645
|
actor.assertAvailable("compact");
|
|
@@ -619,58 +654,88 @@ export class WsHandler {
|
|
|
619
654
|
this.releaseActor(actor);
|
|
620
655
|
}
|
|
621
656
|
});
|
|
622
|
-
|
|
657
|
+
if (!response.success)
|
|
658
|
+
throw new Error(response.error);
|
|
659
|
+
this.sendSuccess(ws, command.id, "compact", { ...response.data });
|
|
623
660
|
}
|
|
624
|
-
async handleGetAvailableModels(ws,
|
|
661
|
+
async handleGetAvailableModels(ws, command) {
|
|
625
662
|
const lease = await this.acquireAnyProcess();
|
|
626
663
|
try {
|
|
627
|
-
const response = await this.pool.
|
|
628
|
-
|
|
664
|
+
const response = await this.pool.sendRpcChecked(lease.process, { type: "get_available_models" });
|
|
665
|
+
this.sendSuccess(ws, command.id, "get_available_models", {
|
|
666
|
+
models: response.data.models,
|
|
667
|
+
});
|
|
629
668
|
}
|
|
630
669
|
finally {
|
|
631
670
|
lease.release();
|
|
632
671
|
}
|
|
633
672
|
}
|
|
634
|
-
async handleGetCommands(ws,
|
|
635
|
-
|
|
673
|
+
async handleGetCommands(ws, command) {
|
|
674
|
+
let cwd = command.cwd || this.defaultCwd;
|
|
675
|
+
if (command.sessionPath) {
|
|
676
|
+
const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
|
|
677
|
+
const sessionCwd = getSessionCwd(sessionPath);
|
|
678
|
+
cwd = sessionCwd && existsSync(sessionCwd) ? sessionCwd : this.defaultCwd;
|
|
679
|
+
}
|
|
680
|
+
// Project prompts and skills are loaded when Pi starts, so command discovery
|
|
681
|
+
// must use a process for the conversation's cwd rather than any idle worker.
|
|
682
|
+
const lease = await this.acquireProcess(cwd);
|
|
636
683
|
try {
|
|
637
|
-
const response = await this.pool.
|
|
638
|
-
|
|
684
|
+
const response = await this.pool.sendRpcChecked(lease.process, { type: "get_commands" });
|
|
685
|
+
this.sendSuccess(ws, command.id, "get_commands", {
|
|
686
|
+
commands: response.data.commands,
|
|
687
|
+
});
|
|
639
688
|
}
|
|
640
689
|
finally {
|
|
641
690
|
lease.release();
|
|
642
691
|
}
|
|
643
692
|
}
|
|
644
|
-
async handleReloadProcesses(ws,
|
|
693
|
+
async handleReloadProcesses(ws, command) {
|
|
645
694
|
const { killed, draining } = this.pool.decommissionAll();
|
|
646
695
|
this.ensurePool();
|
|
647
|
-
|
|
648
|
-
id,
|
|
649
|
-
type: "response",
|
|
650
|
-
command: "reload_processes",
|
|
651
|
-
success: true,
|
|
652
|
-
data: { killed, draining },
|
|
653
|
-
}));
|
|
696
|
+
this.sendSuccess(ws, command.id, "reload_processes", { killed, draining });
|
|
654
697
|
}
|
|
655
|
-
async handleGetDefaultModel(ws,
|
|
698
|
+
async handleGetDefaultModel(ws, command) {
|
|
656
699
|
const lease = await this.acquireAnyProcess();
|
|
657
700
|
try {
|
|
658
|
-
const stateResp = await this.pool.
|
|
659
|
-
const model = stateResp.data
|
|
660
|
-
const thinkingLevel = stateResp.data
|
|
661
|
-
|
|
701
|
+
const stateResp = await this.pool.sendRpcChecked(lease.process, { type: "get_state" });
|
|
702
|
+
const model = stateResp.data.model ?? null;
|
|
703
|
+
const thinkingLevel = stateResp.data.thinkingLevel;
|
|
704
|
+
this.sendSuccess(ws, command.id, "get_default_model", {
|
|
705
|
+
model: model,
|
|
706
|
+
thinkingLevel,
|
|
707
|
+
});
|
|
662
708
|
}
|
|
663
709
|
finally {
|
|
664
710
|
lease.release();
|
|
665
711
|
}
|
|
666
712
|
}
|
|
667
|
-
handleGetSessionStatuses(ws,
|
|
668
|
-
|
|
713
|
+
handleGetSessionStatuses(ws, command) {
|
|
714
|
+
this.sendSuccess(ws, command.id, "get_session_statuses", { statuses: this.registry.getAllStatuses() });
|
|
669
715
|
}
|
|
670
|
-
async
|
|
671
|
-
const sessionPath = command.sessionPath;
|
|
672
|
-
|
|
673
|
-
|
|
716
|
+
async handleGetSessionStats(ws, command) {
|
|
717
|
+
const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
|
|
718
|
+
const actor = this.registry.get(sessionPath);
|
|
719
|
+
const response = await actor.enqueue("get session stats", async () => {
|
|
720
|
+
const wasAttached = actor.process !== undefined;
|
|
721
|
+
const proc = await this.acquireForActor(actor);
|
|
722
|
+
try {
|
|
723
|
+
return await this.pool.sendRpcChecked(proc, { type: "get_session_stats" });
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
// A running turn owns its process lease. Only release a lease acquired
|
|
727
|
+
// temporarily to inspect a detached session.
|
|
728
|
+
if (!wasAttached)
|
|
729
|
+
this.releaseActor(actor);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
this.sendSuccess(ws, command.id, "get_session_stats", {
|
|
733
|
+
...response.data,
|
|
734
|
+
sessionFile: response.data.sessionFile ?? sessionPath,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
async handleFork(ws, command) {
|
|
738
|
+
const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
|
|
674
739
|
const entryId = command.entryId;
|
|
675
740
|
if (!entryId)
|
|
676
741
|
throw new Error("Missing entryId");
|
|
@@ -679,38 +744,39 @@ export class WsHandler {
|
|
|
679
744
|
actor.assertAvailable("fork");
|
|
680
745
|
const proc = await this.acquireForActor(actor);
|
|
681
746
|
try {
|
|
682
|
-
const response = await this.pool.
|
|
683
|
-
const stateResp = await this.pool.
|
|
684
|
-
|
|
747
|
+
const response = await this.pool.sendRpcChecked(proc, { type: "fork", entryId });
|
|
748
|
+
const stateResp = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
749
|
+
const returnedPath = stateResp.data.sessionFile;
|
|
750
|
+
return {
|
|
751
|
+
response,
|
|
752
|
+
newSessionPath: returnedPath
|
|
753
|
+
? this.sessionPaths.resolveExisting(returnedPath)
|
|
754
|
+
: undefined,
|
|
755
|
+
};
|
|
685
756
|
}
|
|
686
757
|
finally {
|
|
687
758
|
this.releaseActor(actor);
|
|
688
759
|
}
|
|
689
760
|
});
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
newSessionPath: result.newSessionPath ?? null,
|
|
696
|
-
},
|
|
697
|
-
}));
|
|
761
|
+
this.sendSuccess(ws, command.id, "fork", {
|
|
762
|
+
text: result.response.data.text,
|
|
763
|
+
cancelled: result.response.data.cancelled,
|
|
764
|
+
newSessionPath: result.newSessionPath ?? null,
|
|
765
|
+
});
|
|
698
766
|
}
|
|
699
|
-
async handleForkPrompt(ws,
|
|
700
|
-
const sessionPath = command.sessionPath;
|
|
701
|
-
if (!sessionPath)
|
|
702
|
-
throw new Error("Missing sessionPath");
|
|
767
|
+
async handleForkPrompt(ws, command) {
|
|
768
|
+
const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
|
|
703
769
|
const message = command.message;
|
|
704
770
|
if (!message)
|
|
705
771
|
throw new Error("Missing message");
|
|
706
|
-
const sessionsDir = path.join(getAgentDir(), "sessions");
|
|
707
772
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
708
773
|
const newId = crypto.randomUUID().slice(0, 8);
|
|
709
|
-
|
|
774
|
+
let newSessionPath = this.sessionPaths.createPath(`${timestamp}_${newId}.jsonl`);
|
|
710
775
|
const sourceActor = this.registry.get(sessionPath);
|
|
711
776
|
await sourceActor.enqueue("fork prompt source", async () => {
|
|
712
777
|
sourceActor.assertAvailable("fork and prompt");
|
|
713
|
-
await copyFile(sessionPath, newSessionPath);
|
|
778
|
+
await copyFile(sessionPath, newSessionPath, fsConstants.COPYFILE_EXCL);
|
|
779
|
+
newSessionPath = this.sessionPaths.resolveExisting(newSessionPath);
|
|
714
780
|
});
|
|
715
781
|
const forkCwd = getSessionCwd(sessionPath);
|
|
716
782
|
const cwd = (forkCwd && existsSync(forkCwd)) ? forkCwd : this.defaultCwd;
|
|
@@ -728,14 +794,15 @@ export class WsHandler {
|
|
|
728
794
|
actor.attach(unownedLease, this.createSessionState(newSessionPath));
|
|
729
795
|
unownedLease = undefined;
|
|
730
796
|
this.commitPendingExtensionStatuses(proc, newSessionPath);
|
|
731
|
-
|
|
797
|
+
this.sendMessage(ws, { type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message });
|
|
732
798
|
const generation = actor.beginTurn();
|
|
733
799
|
const observer = this.setupTurnEventForwarding(actor, proc, generation, makeTurnId());
|
|
734
800
|
await this.applyRequestedControlState(proc, actor, ws, command);
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
801
|
+
await this.pool.sendRpcChecked(proc, {
|
|
802
|
+
type: "prompt",
|
|
803
|
+
message,
|
|
804
|
+
...(command.images?.length ? { images: command.images } : {}),
|
|
805
|
+
});
|
|
739
806
|
await this.reconcileEffectiveControlState(proc, actor);
|
|
740
807
|
return { observer, generation };
|
|
741
808
|
});
|
|
@@ -746,36 +813,35 @@ export class WsHandler {
|
|
|
746
813
|
await this.reconcileEffectiveControlState(proc, actor);
|
|
747
814
|
this.releaseActor(actor);
|
|
748
815
|
});
|
|
749
|
-
|
|
816
|
+
this.sendSuccess(ws, command.id, "fork_prompt", { newSessionPath });
|
|
750
817
|
}
|
|
751
|
-
catch (
|
|
818
|
+
catch (error) {
|
|
752
819
|
if (proc)
|
|
753
820
|
this.pendingExtensionStatuses.delete(proc);
|
|
754
821
|
unownedLease?.release();
|
|
755
822
|
if (proc && actor.process === proc) {
|
|
756
|
-
let detailed = this.buildPromptFailureMessage(
|
|
823
|
+
let detailed = this.buildPromptFailureMessage(error, proc, actor);
|
|
757
824
|
await actor.enqueue("fork prompt failure", () => {
|
|
758
825
|
if (actor.process !== proc)
|
|
759
826
|
return;
|
|
760
|
-
detailed = this.buildPromptFailureMessage(
|
|
827
|
+
detailed = this.buildPromptFailureMessage(error, proc, actor);
|
|
761
828
|
actor.markFailed();
|
|
762
829
|
this.injectSessionError(actor, detailed);
|
|
763
830
|
this.releaseActor(actor);
|
|
764
831
|
});
|
|
765
|
-
|
|
832
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
833
|
+
if (rawMessage.includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
|
|
766
834
|
proc.process.kill("SIGTERM");
|
|
767
835
|
}
|
|
768
836
|
throw new Error(detailed);
|
|
769
837
|
}
|
|
770
838
|
if (proc && proc.process.exitCode === null)
|
|
771
839
|
proc.process.kill("SIGTERM");
|
|
772
|
-
throw
|
|
840
|
+
throw error;
|
|
773
841
|
}
|
|
774
842
|
}
|
|
775
|
-
async handleSetSessionName(ws,
|
|
776
|
-
const sessionPath = command.sessionPath;
|
|
777
|
-
if (!sessionPath)
|
|
778
|
-
throw new Error("Missing sessionPath");
|
|
843
|
+
async handleSetSessionName(ws, command) {
|
|
844
|
+
const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
|
|
779
845
|
const actor = this.registry.get(sessionPath);
|
|
780
846
|
const response = await actor.enqueue("set session name", async () => {
|
|
781
847
|
actor.assertAvailable("rename session");
|
|
@@ -787,7 +853,9 @@ export class WsHandler {
|
|
|
787
853
|
this.releaseActor(actor);
|
|
788
854
|
}
|
|
789
855
|
});
|
|
790
|
-
|
|
856
|
+
if (!response.success)
|
|
857
|
+
throw new Error(response.error);
|
|
858
|
+
this.sendSuccess(ws, command.id, "set_session_name", {});
|
|
791
859
|
}
|
|
792
860
|
// ── Internal helpers ─────────────────────────────────────────────────
|
|
793
861
|
async replacePiSession(proc, command, operation) {
|
|
@@ -827,16 +895,15 @@ export class WsHandler {
|
|
|
827
895
|
throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
|
|
828
896
|
}
|
|
829
897
|
this.publishEffectiveControlState(actor, stateResponse.data);
|
|
830
|
-
if (ws.readyState ===
|
|
831
|
-
|
|
898
|
+
if (ws.readyState === FRAME_CONNECTION_OPEN) {
|
|
899
|
+
this.sendMessage(ws, {
|
|
832
900
|
type: "control_state",
|
|
833
901
|
sessionPath,
|
|
834
902
|
controlRevision: command.controlRevision,
|
|
835
903
|
model: activeModel,
|
|
836
|
-
thinkingLevel: stateResponse.data
|
|
837
|
-
})
|
|
904
|
+
thinkingLevel: stateResponse.data.thinkingLevel,
|
|
905
|
+
});
|
|
838
906
|
}
|
|
839
|
-
return stateResponse.data;
|
|
840
907
|
}
|
|
841
908
|
async waitForPromptSettlement(proc, observer) {
|
|
842
909
|
const state = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
@@ -928,6 +995,7 @@ export class WsHandler {
|
|
|
928
995
|
messages: state.messages,
|
|
929
996
|
model: state.model,
|
|
930
997
|
thinkingLevel: state.thinkingLevel,
|
|
998
|
+
toolCallTimings: state.toolCallTimings,
|
|
931
999
|
});
|
|
932
1000
|
}
|
|
933
1001
|
/** Acquire, switch, and transfer a process lease to a detached actor. */
|
|
@@ -997,25 +1065,36 @@ export class WsHandler {
|
|
|
997
1065
|
disk.state.model = liveState.model;
|
|
998
1066
|
disk.state.thinkingLevel = liveState.thinkingLevel;
|
|
999
1067
|
}
|
|
1068
|
+
disk.state.toolCallTimings = liveState.toolCallTimings;
|
|
1000
1069
|
disk.state.error = liveState.error;
|
|
1001
1070
|
}
|
|
1002
1071
|
const { json, hash } = serializeSessionState(disk.state);
|
|
1003
1072
|
this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
|
|
1004
1073
|
this.pushSnapshotToSubscribers(sessionPath, json, hash);
|
|
1005
1074
|
}
|
|
1075
|
+
revisionForState(sessionPath, hash) {
|
|
1076
|
+
const current = this.sessionRevisions.get(sessionPath);
|
|
1077
|
+
if (current?.hash === hash)
|
|
1078
|
+
return current.revision;
|
|
1079
|
+
const revision = (current?.revision ?? 0) + 1;
|
|
1080
|
+
this.sessionRevisions.set(sessionPath, { revision, hash });
|
|
1081
|
+
return revision;
|
|
1082
|
+
}
|
|
1006
1083
|
pushSnapshotToSubscribers(sessionPath, json, hash) {
|
|
1084
|
+
const revision = this.revisionForState(sessionPath, hash);
|
|
1007
1085
|
for (const [ws, client] of this.clients) {
|
|
1008
1086
|
if (client.subscribedSession !== sessionPath)
|
|
1009
1087
|
continue;
|
|
1010
|
-
if (ws.readyState !==
|
|
1088
|
+
if (ws.readyState !== FRAME_CONNECTION_OPEN)
|
|
1011
1089
|
continue;
|
|
1012
|
-
|
|
1090
|
+
this.sendMessage(ws, {
|
|
1013
1091
|
type: "session_sync",
|
|
1014
1092
|
sessionPath,
|
|
1093
|
+
revision,
|
|
1015
1094
|
op: "full",
|
|
1016
1095
|
data: json,
|
|
1017
1096
|
hash,
|
|
1018
|
-
})
|
|
1097
|
+
});
|
|
1019
1098
|
client.lastJson = json;
|
|
1020
1099
|
client.lastHash = hash;
|
|
1021
1100
|
client.lastVersion = 0;
|
|
@@ -1026,19 +1105,21 @@ export class WsHandler {
|
|
|
1026
1105
|
* Uses the hash-verified diff protocol for efficient incremental sync.
|
|
1027
1106
|
*/
|
|
1028
1107
|
pushUpdateToSubscribers(sessionPath, session) {
|
|
1108
|
+
const revision = this.revisionForState(sessionPath, session.hash);
|
|
1029
1109
|
for (const [ws, client] of this.clients) {
|
|
1030
1110
|
if (client.subscribedSession !== sessionPath)
|
|
1031
1111
|
continue;
|
|
1032
|
-
if (ws.readyState !==
|
|
1112
|
+
if (ws.readyState !== FRAME_CONNECTION_OPEN)
|
|
1033
1113
|
continue;
|
|
1034
1114
|
const syncOp = session.computeSyncOp(client.lastJson, client.lastHash, client.lastVersion);
|
|
1035
1115
|
if (!syncOp)
|
|
1036
1116
|
continue;
|
|
1037
|
-
|
|
1117
|
+
this.sendMessage(ws, {
|
|
1038
1118
|
type: "session_sync",
|
|
1039
1119
|
sessionPath,
|
|
1120
|
+
revision,
|
|
1040
1121
|
...syncOp,
|
|
1041
|
-
})
|
|
1122
|
+
});
|
|
1042
1123
|
client.lastJson = session.json;
|
|
1043
1124
|
client.lastHash = session.hash;
|
|
1044
1125
|
client.lastVersion = session.version;
|
|
@@ -1060,7 +1141,7 @@ export class WsHandler {
|
|
|
1060
1141
|
const eventHandler = (sourceProc, data) => {
|
|
1061
1142
|
if (sourceProc !== proc || data.type === "extension_ui_request")
|
|
1062
1143
|
return;
|
|
1063
|
-
const replacementMessages = data.type === "
|
|
1144
|
+
const replacementMessages = data.type === "compaction_end" && data.result
|
|
1064
1145
|
? readSessionFromDisk(sessionPath).state.messages
|
|
1065
1146
|
: undefined;
|
|
1066
1147
|
void actor.applyProcessEvent(proc, generation, data, replacementMessages).then((result) => {
|