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.
Files changed (58) hide show
  1. package/README.md +14 -3
  2. package/bin/pipane-rendezvous.js +21 -0
  3. package/bin/pipane.js +21 -1
  4. package/dist/build-info.json +5 -0
  5. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  6. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  7. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  8. package/dist/client/assets/index-C7_1ODks.js +2 -0
  9. package/dist/client/assets/index-DgI_gkjg.css +1 -0
  10. package/dist/client/assets/main-DEfSB8wO.js +2822 -0
  11. package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
  12. package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
  13. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  14. package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
  15. package/dist/client/assets/ws-agent-adapter-DpkkZGPl.js +6 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  18. package/dist/server/rendezvous/server.js +247 -0
  19. package/dist/server/rendezvous/trust-store.js +432 -0
  20. package/dist/server/server/auth-guard.js +61 -0
  21. package/dist/server/server/backend-connection-authorizer.js +29 -0
  22. package/dist/server/server/backend-identity.js +167 -0
  23. package/dist/server/server/backend-protocol-handler.js +132 -0
  24. package/dist/server/server/backend-trust-store.js +157 -0
  25. package/dist/server/server/backend-webrtc.js +245 -0
  26. package/dist/server/server/build-info.js +13 -0
  27. package/dist/server/server/conversation-file-access.js +97 -0
  28. package/dist/server/server/frame-connection.js +106 -0
  29. package/dist/server/server/frame-router.js +45 -0
  30. package/dist/server/server/ice-servers.js +24 -0
  31. package/dist/server/server/local-backend-api.js +389 -0
  32. package/dist/server/server/local-settings.js +17 -4
  33. package/dist/server/server/pi-rpc-protocol.js +407 -0
  34. package/dist/server/server/process-pool.js +27 -24
  35. package/dist/server/server/rendezvous-client.js +282 -0
  36. package/dist/server/server/rest-api.js +133 -176
  37. package/dist/server/server/server.js +167 -97
  38. package/dist/server/server/session-index.js +105 -28
  39. package/dist/server/server/session-jsonl.js +82 -5
  40. package/dist/server/server/session-path.js +145 -0
  41. package/dist/server/server/update-api.js +28 -0
  42. package/dist/server/server/update-check.js +33 -13
  43. package/dist/server/server/update-manager.js +233 -0
  44. package/dist/server/server/worktree-name.js +116 -31
  45. package/dist/server/server/ws-handler.js +267 -186
  46. package/dist/server/shared/backend-api.js +1 -0
  47. package/dist/server/shared/backend-protocol.js +164 -0
  48. package/dist/server/shared/data-channel-framing.js +137 -0
  49. package/dist/server/shared/node-trust-crypto.js +61 -0
  50. package/dist/server/shared/rendezvous-protocol.js +243 -0
  51. package/dist/server/shared/tool-runtime.js +1 -0
  52. package/dist/server/shared/trust-protocol.js +97 -0
  53. package/dist/server/shared/updates.js +4 -0
  54. package/dist/server/shared/ws-protocol.js +473 -1
  55. package/docs/protocol.md +129 -0
  56. package/package.json +21 -8
  57. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  58. 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 { WebSocket } from "ws";
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 = JSON.stringify(this.makeExtensionStatusMessage(sessionPath));
180
+ const message = encodeServerMessage(this.makeExtensionStatusMessage(sessionPath));
176
181
  for (const [ws, client] of this.clients) {
177
- if (client.subscribedSession !== sessionPath || ws.readyState !== WebSocket.OPEN)
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 === WebSocket.OPEN).length,
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) => this.handleConnection(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
- handleConnection(ws, req) {
239
- if (!this.isRequestAuthorized(req)) {
240
- ws.close(1008, "Unauthorized");
241
- return;
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({
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("WebSocket client disconnected");
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 === WebSocket.OPEN) {
274
- ws.send(JSON.stringify(payload));
275
- }
303
+ if (ws.readyState === FRAME_CONNECTION_OPEN)
304
+ ws.send(message);
276
305
  }
277
306
  }
278
307
  async handleMessage(ws, raw) {
279
- let command;
280
- try {
281
- command = JSON.parse(raw);
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
- ws.send(JSON.stringify({
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
- if (id) {
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, id);
329
+ await this.handleInstallPi(ws, command);
308
330
  break;
309
331
  case "subscribe_session":
310
- this.handleSubscribeSession(ws, id, command);
332
+ this.handleSubscribeSession(ws, command);
311
333
  break;
312
334
  case "prompt":
313
- await this.handlePrompt(ws, id, command);
335
+ await this.handlePrompt(ws, command);
314
336
  break;
315
337
  case "steer":
316
- await this.handleSteer(ws, id, command);
338
+ await this.handleSteer(ws, command);
317
339
  break;
318
340
  case "remove_steering":
319
- await this.handleRemoveSteering(ws, id, command);
341
+ await this.handleRemoveSteering(ws, command);
320
342
  break;
321
343
  case "abort":
322
- await this.handleAbort(ws, id, command);
344
+ await this.handleAbort(ws, command);
323
345
  break;
324
346
  case "hard_kill":
325
- await this.handleHardKill(ws, id, command);
347
+ await this.handleHardKill(ws, command);
326
348
  break;
327
349
  case "compact":
328
- await this.handleCompact(ws, id, command);
350
+ await this.handleCompact(ws, command);
329
351
  break;
330
352
  case "get_available_models":
331
- await this.handleGetAvailableModels(ws, id);
353
+ await this.handleGetAvailableModels(ws, command);
332
354
  break;
333
355
  case "get_default_model":
334
- await this.handleGetDefaultModel(ws, id);
356
+ await this.handleGetDefaultModel(ws, command);
335
357
  break;
336
358
  case "get_session_statuses":
337
- this.handleGetSessionStatuses(ws, id);
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, id, command);
365
+ await this.handleFork(ws, command);
341
366
  break;
342
367
  case "fork_prompt":
343
- await this.handleForkPrompt(ws, id, command);
368
+ await this.handleForkPrompt(ws, command);
344
369
  break;
345
370
  case "set_session_name":
346
- await this.handleSetSessionName(ws, id, command);
371
+ await this.handleSetSessionName(ws, command);
347
372
  break;
348
373
  case "get_commands":
349
- await this.handleGetCommands(ws, id);
374
+ await this.handleGetCommands(ws, command);
350
375
  break;
351
376
  case "reload_processes":
352
- await this.handleReloadProcesses(ws, id);
377
+ await this.handleReloadProcesses(ws, command);
353
378
  break;
354
379
  default:
355
- ws.send(JSON.stringify({
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 (err) {
362
- debugTurn("command_error", { commandType: command?.type, requestId: id, error: err?.message });
363
- ws.send(JSON.stringify({ id, type: "response", command: command.type, success: false, error: err.message }));
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, id) {
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
- ws.send(JSON.stringify({ id, type: "response", command: "install_pi", success: true, data: {} }));
412
+ this.sendSuccess(ws, command.id, "install_pi", {});
390
413
  }
391
- handleSubscribeSession(ws, id, command) {
414
+ handleSubscribeSession(ws, command) {
392
415
  const client = this.clients.get(ws);
393
416
  if (!client)
394
417
  return;
395
- const sessionPath = command.sessionPath;
396
- if (!sessionPath) {
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
- ws.send(JSON.stringify({ id, type: "response", command: "subscribe_session", success: true, data: {} }));
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify(this.makeExtensionStatusMessage(sessionPath)));
439
- ws.send(JSON.stringify({ id, type: "response", command: "subscribe_session", success: true, data: {} }));
477
+ this.sendMessage(ws, this.makeExtensionStatusMessage(sessionPath));
478
+ this.sendSuccess(ws, command.id, "subscribe_session", {});
440
479
  }
441
- async handlePrompt(ws, id, command) {
442
- let sessionPath = command.sessionPath;
443
- if (!sessionPath)
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.sendRpc(proc, { type: "get_state" });
461
- sessionPath = stateResp.data?.sessionFile;
462
- if (!sessionPath)
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
- ws.send(JSON.stringify({
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 promptCmd = { type: "prompt", message: command.message };
493
- if (command.images?.length > 0)
494
- promptCmd.images = command.images;
495
- const response = await this.pool.sendRpc(proc, promptCmd);
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
- ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
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
- const enriched = { ...start.response };
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 (err) {
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(err, proc, actor);
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(err, proc, actor);
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
- if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
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 err;
580
+ throw error;
538
581
  }
539
582
  }
540
- async handleSteer(ws, id, command) {
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
- ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
591
+ this.sendSuccess(ws, command.id, "steer", {});
549
592
  }
550
- async handleRemoveSteering(ws, id, command) {
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
- ws.send(JSON.stringify({ id, type: "response", command: "remove_steering", success: true }));
603
+ this.sendSuccess(ws, command.id, "remove_steering", {});
561
604
  }
562
- async handleAbort(ws, id, command) {
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
- ws.send(JSON.stringify({ id, type: "response", command: "abort", success: true }));
615
+ this.sendSuccess(ws, command.id, "abort", {});
573
616
  }
574
- async handleHardKill(ws, id, command) {
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
- ws.send(JSON.stringify({
595
- id,
596
- type: "response",
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, id, command) {
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
- ws.send(JSON.stringify({ ...response, id, command: "compact" }));
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, id) {
661
+ async handleGetAvailableModels(ws, command) {
625
662
  const lease = await this.acquireAnyProcess();
626
663
  try {
627
- const response = await this.pool.sendRpc(lease.process, { type: "get_available_models" });
628
- ws.send(JSON.stringify({ ...response, id, command: "get_available_models" }));
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, id) {
635
- const lease = await this.acquireAnyProcess();
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.sendRpc(lease.process, { type: "get_commands" });
638
- ws.send(JSON.stringify({ ...response, id, command: "get_commands" }));
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, id) {
693
+ async handleReloadProcesses(ws, command) {
645
694
  const { killed, draining } = this.pool.decommissionAll();
646
695
  this.ensurePool();
647
- ws.send(JSON.stringify({
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, id) {
698
+ async handleGetDefaultModel(ws, command) {
656
699
  const lease = await this.acquireAnyProcess();
657
700
  try {
658
- const stateResp = await this.pool.sendRpc(lease.process, { type: "get_state" });
659
- const model = stateResp.data?.model ?? null;
660
- const thinkingLevel = stateResp.data?.thinkingLevel ?? "off";
661
- ws.send(JSON.stringify({ id, type: "response", command: "get_default_model", success: true, data: { model, thinkingLevel } }));
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, id) {
668
- ws.send(JSON.stringify({ id, type: "response", command: "get_session_statuses", success: true, data: { statuses: this.registry.getAllStatuses() } }));
713
+ handleGetSessionStatuses(ws, command) {
714
+ this.sendSuccess(ws, command.id, "get_session_statuses", { statuses: this.registry.getAllStatuses() });
669
715
  }
670
- async handleFork(ws, id, command) {
671
- const sessionPath = command.sessionPath;
672
- if (!sessionPath)
673
- throw new Error("Missing sessionPath");
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.sendRpc(proc, { type: "fork", entryId });
683
- const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
684
- return { response, newSessionPath: stateResp.data?.sessionFile };
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
- ws.send(JSON.stringify({
691
- id, type: "response", command: "fork", success: true,
692
- data: {
693
- text: result.response.data?.text ?? "",
694
- cancelled: result.response.data?.cancelled ?? false,
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, id, command) {
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
- const newSessionPath = path.join(sessionsDir, `${timestamp}_${newId}.jsonl`);
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
- ws.send(JSON.stringify({ type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message }));
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
- const promptCmd = { type: "prompt", message };
736
- if (command.images?.length > 0)
737
- promptCmd.images = command.images;
738
- await this.pool.sendRpc(proc, promptCmd);
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
- ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
816
+ this.sendSuccess(ws, command.id, "fork_prompt", { newSessionPath });
750
817
  }
751
- catch (err) {
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(err, proc, actor);
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(err, proc, actor);
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
- if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
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 err;
840
+ throw error;
773
841
  }
774
842
  }
775
- async handleSetSessionName(ws, id, command) {
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
- ws.send(JSON.stringify({ ...response, id, command: "set_session_name" }));
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 === WebSocket.OPEN) {
831
- ws.send(JSON.stringify({
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?.thinkingLevel ?? "off",
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 !== WebSocket.OPEN)
1088
+ if (ws.readyState !== FRAME_CONNECTION_OPEN)
1011
1089
  continue;
1012
- ws.send(JSON.stringify({
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 !== WebSocket.OPEN)
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
- ws.send(JSON.stringify({
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 === "auto_compaction_end" && data.result
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) => {