pipane 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +34 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/client/assets/index-Dl_wdLZH.css +1 -0
  63. package/dist/client/assets/index-hNqbnG06.js +2482 -0
  64. package/dist/client/index.html +2 -2
  65. package/dist/server/server/bundled-extensions.js +6 -0
  66. package/dist/server/server/extension-status.js +31 -0
  67. package/dist/server/server/local-settings.js +15 -0
  68. package/dist/server/server/pi-runtime.js +1 -1
  69. package/dist/server/server/process-pool.js +177 -120
  70. package/dist/server/server/rest-api.js +7 -74
  71. package/dist/server/server/server.js +37 -49
  72. package/dist/server/server/session-actor.js +202 -0
  73. package/dist/server/server/session-index.js +18 -2
  74. package/dist/server/server/session-jsonl.js +49 -6
  75. package/dist/server/server/session-registry.js +67 -0
  76. package/dist/server/server/worktree-name.js +39 -0
  77. package/dist/server/server/ws-handler.js +630 -342
  78. package/dist/server/shared/jsonl-sync.js +114 -3
  79. package/dist/server/shared/rpc-timeouts.js +4 -0
  80. package/dist/server/shared/thinking-levels.js +94 -0
  81. package/dist/server/shared/ws-protocol.js +1 -0
  82. package/extensions/canvas.ts +1 -1
  83. package/package.json +26 -14
  84. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  85. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  86. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  87. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  88. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  89. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  90. package/dist/client/assets/index-Bf_69wyB.js +0 -3
  91. package/dist/client/assets/index-CUE9pgq9.css +0 -1
  92. package/dist/client/assets/index-CekVLaL6.js +0 -1
  93. package/dist/client/assets/index-Cp2vV6HJ.js +0 -4433
  94. package/dist/client/assets/index-D14II7Hd.js +0 -45
  95. package/dist/client/assets/index-D16nGmo3.js +0 -131
  96. package/dist/server/server/attached-session.js +0 -209
  97. package/dist/server/server/load-trace-store.js +0 -48
  98. package/dist/server/server/session-lifecycle.js +0 -155
  99. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
@@ -2,22 +2,23 @@
2
2
  * WebSocket handler for pipane.
3
3
  *
4
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.
5
+ * - SessionRegistry provides one serialized SessionActor per session path.
6
+ * - The actor owns its process lease, phase, materialized state, steering, and cleanup.
7
+ * - WsHandler validates/routes transport commands and publishes actor updates.
8
+ * - Any number of clients can connect; each subscribes to one session at a time.
9
+ * - Detached sessions are read from JSONL on demand.
11
10
  */
12
11
  import { WebSocket } from "ws";
13
12
  import { copyFile } from "node:fs/promises";
14
13
  import { existsSync } from "node:fs";
15
14
  import path from "node:path";
16
- import { URL } from "node:url";
17
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
18
- import { SessionJsonl, readSessionFromDisk, getSessionFileSize, } from "./session-jsonl.js";
15
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import { SessionJsonl, readSessionFromDisk, getSessionFileSize, serializeSessionState, } from "./session-jsonl.js";
19
17
  import { getSessionCwd } from "./session-cwd.js";
20
18
  import { checkCommandAvailable, installPiGlobal, isPiInstallable, makePiNotFoundMessage } from "./pi-runtime.js";
19
+ import { modelsMatch, toCompactModelRef } from "../shared/thinking-levels.js";
20
+ import { COMPACT_RPC_TIMEOUT_MS } from "../shared/rpc-timeouts.js";
21
+ import { extensionStatusSnapshot, isValidExtensionStatusKey, normalizeExtensionStatusText, providerForUsageStatus, PROVIDER_USAGE_STATUS_KEY, } from "./extension-status.js";
21
22
  let nextTurnId = 0;
22
23
  function makeTurnId() {
23
24
  return `turn_${Date.now()}_${++nextTurnId}`;
@@ -26,24 +27,19 @@ function debugTurn(stage, data) {
26
27
  console.log(`[turn] ${stage} ${JSON.stringify(data)}`);
27
28
  }
28
29
  export class WsHandler {
29
- lifecycle;
30
+ registry;
30
31
  pool;
31
32
  defaultCwd;
32
33
  piLaunch;
33
34
  ensurePool;
34
35
  isRequestAuthorized;
35
- traceStore;
36
36
  clients = new Map();
37
- wsTraceIds = new Map();
38
- busyProcesses = new Set();
39
- procEventCleanup = new Map();
40
- /** Processes marked for graceful decommission after current turn ends. */
41
- decommissionProcesses = new Set();
42
- /**
43
- * In-memory state for sessions with an attached pi process.
44
- * Keyed by session path. Created on attach, deleted on detach.
45
- */
46
- attachedSessions = new Map();
37
+ /** Last known extension statuses, retained while a session is detached. */
38
+ extensionStatusesBySession = new Map();
39
+ /** Latest successful account-wide subscription usage from any process. */
40
+ providerUsageStatuses = new Map();
41
+ /** Statuses emitted while a process is switching to a not-yet-attached session. */
42
+ pendingExtensionStatuses = new WeakMap();
47
43
  /**
48
44
  * Track file sizes for detached sessions that clients are subscribed to.
49
45
  * Used for change detection when the file watcher fires.
@@ -52,15 +48,15 @@ export class WsHandler {
52
48
  piAvailable;
53
49
  piInstalling = false;
54
50
  constructor(options) {
55
- this.lifecycle = options.lifecycle;
51
+ this.registry = options.registry;
56
52
  this.pool = options.pool;
57
53
  this.defaultCwd = options.defaultCwd;
58
54
  this.piLaunch = options.piLaunch;
59
55
  this.ensurePool = options.ensurePool;
60
56
  this.isRequestAuthorized = options.isRequestAuthorized;
61
- this.traceStore = options.traceStore;
62
57
  this.piAvailable = checkCommandAvailable(this.piLaunch.command);
63
- this.lifecycle.subscribe((event) => {
58
+ this.pool.subscribeEvents((proc, event) => this.handleProcessEvent(proc, event));
59
+ this.registry.subscribe((event) => {
64
60
  switch (event.type) {
65
61
  case "session_attached":
66
62
  this.broadcast({
@@ -77,11 +73,9 @@ export class WsHandler {
77
73
  });
78
74
  break;
79
75
  case "steering_queue_update": {
80
- const session = this.attachedSessions.get(event.sessionPath);
81
- if (session) {
82
- session.steeringQueue = [...event.queue];
76
+ const session = this.registry.find(event.sessionPath)?.session;
77
+ if (session)
83
78
  this.pushUpdateToSubscribers(event.sessionPath, session);
84
- }
85
79
  break;
86
80
  }
87
81
  }
@@ -90,23 +84,100 @@ export class WsHandler {
90
84
  get isPiAvailable() {
91
85
  return this.piAvailable;
92
86
  }
93
- recordTrace(traceId, source, kind, name, durationMs, attrs) {
94
- if (!traceId || !this.traceStore)
87
+ /** Idempotently clean up the owning actor after an RPC process exits. */
88
+ handleProcessExit(proc) {
89
+ this.pendingExtensionStatuses.delete(proc);
90
+ const actor = this.registry.getActorForProcess(proc);
91
+ if (!actor)
95
92
  return;
96
- this.traceStore.record(traceId, {
97
- ts: new Date().toISOString(),
98
- source,
99
- kind,
100
- name,
101
- durationMs,
102
- attrs,
93
+ console.log(`[pool] pi#${proc.id} crashed while attached to ${path.basename(actor.sessionPath)} — marking done`);
94
+ void actor.enqueue("process exit", () => {
95
+ if (actor.process !== proc)
96
+ return;
97
+ actor.markFailed();
98
+ // Without a final get_state, disk is safer than potentially stale
99
+ // in-memory controls (pi may have persisted a model change before crash).
100
+ this.releaseActor(actor, false);
103
101
  });
104
102
  }
105
- getTraceIdForMessage(ws, command) {
106
- const fromCommand = command?.__trace?.traceId;
107
- if (typeof fromCommand === "string" && fromCommand.length > 0)
108
- return fromCommand;
109
- return this.wsTraceIds.get(ws);
103
+ handleProcessEvent(proc, event) {
104
+ if (event.type !== "extension_ui_request" || event.method !== "setStatus")
105
+ return;
106
+ if (!isValidExtensionStatusKey(event.statusKey))
107
+ return;
108
+ const normalizedStatusText = typeof event.statusText === "string"
109
+ ? normalizeExtensionStatusText(event.statusText)
110
+ : undefined;
111
+ // Subscription usage belongs to the provider account, not a conversation.
112
+ // Capture successful values even from unattached prewarm processes so a
113
+ // virtual or never-run conversation can still display the latest quota.
114
+ if (event.statusKey === PROVIDER_USAGE_STATUS_KEY && normalizedStatusText) {
115
+ const provider = providerForUsageStatus(normalizedStatusText);
116
+ if (provider && this.providerUsageStatuses.get(provider) !== normalizedStatusText) {
117
+ this.providerUsageStatuses.set(provider, normalizedStatusText);
118
+ this.broadcast(this.makeProviderUsageMessage());
119
+ }
120
+ }
121
+ const sessionPath = this.registry.getActorForProcess(proc)?.sessionPath;
122
+ const statuses = sessionPath
123
+ ? (this.extensionStatusesBySession.get(sessionPath) ?? new Map())
124
+ : this.pendingExtensionStatuses.get(proc);
125
+ if (!statuses)
126
+ return;
127
+ if (sessionPath && !this.extensionStatusesBySession.has(sessionPath)) {
128
+ this.extensionStatusesBySession.set(sessionPath, statuses);
129
+ }
130
+ const previous = statuses.get(event.statusKey);
131
+ if (event.statusText === undefined) {
132
+ statuses.delete(event.statusKey);
133
+ }
134
+ else if (normalizedStatusText !== undefined) {
135
+ if (normalizedStatusText)
136
+ statuses.set(event.statusKey, normalizedStatusText);
137
+ else
138
+ statuses.delete(event.statusKey);
139
+ }
140
+ else {
141
+ return;
142
+ }
143
+ if (sessionPath && previous !== statuses.get(event.statusKey)) {
144
+ this.pushExtensionStatusesToSubscribers(sessionPath);
145
+ }
146
+ }
147
+ beginPendingExtensionStatusCapture(proc, sessionPath) {
148
+ const existing = sessionPath ? this.extensionStatusesBySession.get(sessionPath) : undefined;
149
+ this.pendingExtensionStatuses.set(proc, new Map(existing ?? []));
150
+ }
151
+ commitPendingExtensionStatuses(proc, sessionPath) {
152
+ const pending = this.pendingExtensionStatuses.get(proc);
153
+ this.pendingExtensionStatuses.delete(proc);
154
+ if (pending)
155
+ this.extensionStatusesBySession.set(sessionPath, pending);
156
+ else if (!this.extensionStatusesBySession.has(sessionPath)) {
157
+ this.extensionStatusesBySession.set(sessionPath, new Map());
158
+ }
159
+ this.pushExtensionStatusesToSubscribers(sessionPath);
160
+ }
161
+ makeExtensionStatusMessage(sessionPath) {
162
+ return {
163
+ type: "extension_status",
164
+ sessionPath,
165
+ statuses: extensionStatusSnapshot(this.extensionStatusesBySession.get(sessionPath)),
166
+ };
167
+ }
168
+ makeProviderUsageMessage() {
169
+ return {
170
+ type: "provider_usage",
171
+ statuses: Object.fromEntries(this.providerUsageStatuses),
172
+ };
173
+ }
174
+ pushExtensionStatusesToSubscribers(sessionPath) {
175
+ const message = JSON.stringify(this.makeExtensionStatusMessage(sessionPath));
176
+ for (const [ws, client] of this.clients) {
177
+ if (client.subscribedSession !== sessionPath || ws.readyState !== WebSocket.OPEN)
178
+ continue;
179
+ ws.send(message);
180
+ }
110
181
  }
111
182
  /**
112
183
  * Called by the file watcher when a JSONL file changes on disk.
@@ -115,7 +186,7 @@ export class WsHandler {
115
186
  */
116
187
  notifySessionFileChanged(sessionPath) {
117
188
  // If the session is attached, ignore — streaming events are authoritative
118
- if (this.attachedSessions.has(sessionPath))
189
+ if (this.registry.find(sessionPath)?.isAttached)
119
190
  return;
120
191
  // Check if any client is subscribed to this session
121
192
  let hasSubscribers = false;
@@ -138,22 +209,25 @@ export class WsHandler {
138
209
  this.pushSnapshotToSubscribers(sessionPath, json, hash);
139
210
  }
140
211
  getDebugState() {
141
- const processes = this.pool.getAllProcesses().map((p) => ({
142
- id: p.id,
143
- pid: p.process.pid ?? null,
144
- alive: p.process.exitCode === null,
145
- exitCode: p.process.exitCode,
146
- cwd: p.cwd,
147
- busy: this.busyProcesses.has(p),
148
- decommissioning: this.decommissionProcesses.has(p),
149
- attachedSession: this.lifecycle.getAttachedSessionForProcess(p) ?? null,
150
- pendingRequests: p.pendingRequests.size,
212
+ const processes = this.pool.getAllProcesses().map((proc) => ({
213
+ id: proc.id,
214
+ pid: proc.process.pid ?? null,
215
+ alive: proc.process.exitCode === null,
216
+ exitCode: proc.process.exitCode,
217
+ cwd: proc.cwd,
218
+ busy: this.pool.isLeased(proc),
219
+ decommissioning: this.pool.isDecommissioning(proc),
220
+ attachedSession: this.registry.getActorForProcess(proc)?.sessionPath ?? null,
221
+ pendingRequests: proc.pendingRequests.size,
151
222
  }));
152
223
  return {
153
224
  now: new Date().toISOString(),
154
225
  totalProcesses: this.pool.totalProcesses,
155
- attachedSessionCount: this.lifecycle.attachedCount,
156
- sessionStatuses: this.lifecycle.getAllStatuses(),
226
+ attachedSessionCount: this.registry.attachedCount,
227
+ attachedSessionPaths: processes
228
+ .map((process) => process.attachedSession)
229
+ .filter((sessionPath) => sessionPath !== null),
230
+ sessionStatuses: this.registry.getAllStatuses(),
157
231
  connectedWsOpen: Array.from(this.clients.keys()).filter((ws) => ws.readyState === WebSocket.OPEN).length,
158
232
  processes,
159
233
  };
@@ -166,13 +240,6 @@ export class WsHandler {
166
240
  ws.close(1008, "Unauthorized");
167
241
  return;
168
242
  }
169
- const reqUrl = req.url || "/ws";
170
- const parsed = new URL(reqUrl, "http://localhost");
171
- const traceId = parsed.searchParams.get("traceId") || undefined;
172
- if (traceId) {
173
- this.wsTraceIds.set(ws, traceId);
174
- this.recordTrace(traceId, "backend", "instant", "ws connection open");
175
- }
176
243
  console.log("WebSocket client connected");
177
244
  this.clients.set(ws, {
178
245
  subscribedSession: null,
@@ -182,8 +249,9 @@ export class WsHandler {
182
249
  });
183
250
  ws.send(JSON.stringify({
184
251
  type: "init",
185
- sessionStatuses: this.lifecycle.getAllStatuses(),
186
- steeringQueues: this.lifecycle.getAllSteeringQueues(),
252
+ sessionStatuses: this.registry.getAllStatuses(),
253
+ steeringQueues: this.registry.getAllSteeringQueues(),
254
+ providerUsageStatuses: this.makeProviderUsageMessage().statuses,
187
255
  }));
188
256
  if (!this.piAvailable) {
189
257
  ws.send(JSON.stringify({
@@ -196,11 +264,8 @@ export class WsHandler {
196
264
  }
197
265
  ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
198
266
  ws.on("close", () => {
199
- const wsTraceId = this.wsTraceIds.get(ws);
200
- this.recordTrace(wsTraceId, "backend", "instant", "ws connection close");
201
267
  console.log("WebSocket client disconnected");
202
268
  this.clients.delete(ws);
203
- this.wsTraceIds.delete(ws);
204
269
  });
205
270
  }
206
271
  broadcast(payload) {
@@ -220,9 +285,6 @@ export class WsHandler {
220
285
  return;
221
286
  }
222
287
  const id = command.id;
223
- const traceId = this.getTraceIdForMessage(ws, command);
224
- const commandStart = performance.now();
225
- this.recordTrace(traceId, "backend", "instant", `ws command received: ${command.type}`);
226
288
  try {
227
289
  if (!this.piAvailable && command.type !== "install_pi" && command.type !== "get_session_statuses") {
228
290
  ws.send(JSON.stringify({
@@ -259,6 +321,9 @@ export class WsHandler {
259
321
  case "abort":
260
322
  await this.handleAbort(ws, id, command);
261
323
  break;
324
+ case "hard_kill":
325
+ await this.handleHardKill(ws, id, command);
326
+ break;
262
327
  case "compact":
263
328
  await this.handleCompact(ws, id, command);
264
329
  break;
@@ -296,10 +361,7 @@ export class WsHandler {
296
361
  catch (err) {
297
362
  debugTurn("command_error", { commandType: command?.type, requestId: id, error: err?.message });
298
363
  ws.send(JSON.stringify({ id, type: "response", command: command.type, success: false, error: err.message }));
299
- this.recordTrace(traceId, "backend", "span", `ws command ${command.type}`, Number((performance.now() - commandStart).toFixed(2)), { success: false, error: err?.message });
300
- return;
301
364
  }
302
- this.recordTrace(traceId, "backend", "span", `ws command ${command.type}`, Number((performance.now() - commandStart).toFixed(2)), { success: true });
303
365
  }
304
366
  async handleInstallPi(ws, id) {
305
367
  const installable = isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs);
@@ -340,8 +402,8 @@ export class WsHandler {
340
402
  return;
341
403
  }
342
404
  client.subscribedSession = sessionPath;
343
- // If the session is attached, send from in-memory state
344
- const attached = this.attachedSessions.get(sessionPath);
405
+ // If the session is attached, send from its actor-owned in-memory state
406
+ const attached = this.registry.find(sessionPath)?.session;
345
407
  if (attached) {
346
408
  // Send full sync
347
409
  client.lastJson = attached.json;
@@ -371,6 +433,9 @@ export class WsHandler {
371
433
  hash,
372
434
  }));
373
435
  }
436
+ // Extension status is a separate, authoritative snapshot so reconnects
437
+ // and session switches replace rather than merge stale client state.
438
+ ws.send(JSON.stringify(this.makeExtensionStatusMessage(sessionPath)));
374
439
  ws.send(JSON.stringify({ id, type: "response", command: "subscribe_session", success: true, data: {} }));
375
440
  }
376
441
  async handlePrompt(ws, id, command) {
@@ -379,67 +444,107 @@ export class WsHandler {
379
444
  throw new Error("Missing sessionPath");
380
445
  const turnId = makeTurnId();
381
446
  debugTurn("prompt_start", { turnId, sessionPath, hasModel: !!command.model });
447
+ let actor;
382
448
  let proc;
383
- if (sessionPath === "__new__") {
384
- const cwd = command.cwd || this.defaultCwd;
385
- proc = await this.acquireProcess(cwd);
386
- await this.pool.waitForReady(proc);
387
- await this.pool.sendRpc(proc, { type: "new_session" });
388
- const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
389
- sessionPath = stateResp.data?.sessionFile;
390
- if (!sessionPath)
391
- throw new Error("Failed to get session path from new session");
392
- this.busyProcesses.add(proc);
393
- // Create attached session with empty state (new session)
394
- this.createAttachedSession(sessionPath);
395
- ws.send(JSON.stringify({
396
- type: "session_attached",
397
- sessionPath,
398
- cwd,
399
- firstMessage: command.message,
400
- }));
401
- this.lifecycle.attach(sessionPath, proc);
402
- }
403
- else {
404
- proc = await this.acquireForSession(sessionPath);
405
- }
406
- if (!command.model) {
407
- throw new Error(`BUG: prompt command received without model. sessionPath=${sessionPath}`);
408
- }
409
- await this.pool.sendRpcChecked(proc, {
410
- type: "set_model",
411
- provider: command.model.provider,
412
- modelId: command.model.modelId,
413
- });
414
- const modelState = await this.pool.sendRpcChecked(proc, { type: "get_state" });
415
- const activeModel = modelState.data?.model;
416
- if (!activeModel || activeModel.provider !== command.model.provider || activeModel.id !== command.model.modelId) {
417
- throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
418
- }
419
- if (command.thinkingLevel) {
420
- await this.pool.sendRpcChecked(proc, { type: "set_thinking_level", level: command.thinkingLevel });
449
+ let unownedLease;
450
+ let generation;
451
+ try {
452
+ let newSessionCwd;
453
+ if (sessionPath === "__new__") {
454
+ newSessionCwd = command.cwd || this.defaultCwd;
455
+ unownedLease = await this.acquireProcess(newSessionCwd);
456
+ proc = unownedLease.process;
457
+ await this.pool.waitForReady(proc);
458
+ this.beginPendingExtensionStatusCapture(proc);
459
+ 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)
463
+ throw new Error("Failed to get session path from new session");
464
+ }
465
+ actor = this.registry.get(sessionPath);
466
+ const start = await actor.enqueue("prompt start", async () => {
467
+ // A second prompt that arrived during startup observes the committed
468
+ // actor phase and becomes steering rather than a second turn owner.
469
+ if (actor.isTurnActive) {
470
+ await this.sendSteering(actor, command.message);
471
+ return undefined;
472
+ }
473
+ actor.assertAvailable("start prompt");
474
+ if (unownedLease) {
475
+ proc = unownedLease.process;
476
+ actor.attach(unownedLease, this.createSessionState(sessionPath));
477
+ unownedLease = undefined;
478
+ this.commitPendingExtensionStatuses(proc, sessionPath);
479
+ ws.send(JSON.stringify({
480
+ type: "session_attached",
481
+ sessionPath,
482
+ cwd: newSessionCwd,
483
+ firstMessage: command.message,
484
+ }));
485
+ }
486
+ else {
487
+ proc = await this.acquireForActor(actor);
488
+ }
489
+ generation = actor.beginTurn();
490
+ const observer = this.setupTurnEventForwarding(actor, proc, generation, turnId);
491
+ 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);
496
+ await this.reconcileEffectiveControlState(proc, actor);
497
+ return { observer, response, generation };
498
+ });
499
+ if (!start) {
500
+ ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
501
+ return;
502
+ }
503
+ await this.waitForPromptSettlement(proc, start.observer);
504
+ await actor.enqueue("prompt settlement", async () => {
505
+ if (!actor.owns(proc, start.generation))
506
+ return;
507
+ await this.reconcileEffectiveControlState(proc, actor);
508
+ this.releaseActor(actor);
509
+ });
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" }));
421
515
  }
422
- this.setupTurnEventForwarding(proc, sessionPath, ws, turnId);
423
- const promptCmd = { type: "prompt", message: command.message };
424
- if (command.images?.length > 0) {
425
- promptCmd.images = command.images;
516
+ catch (err) {
517
+ if (proc)
518
+ this.pendingExtensionStatuses.delete(proc);
519
+ unownedLease?.release();
520
+ if (actor && proc && actor.process === proc) {
521
+ let detailed = this.buildPromptFailureMessage(err, proc, actor);
522
+ await actor.enqueue("prompt failure", () => {
523
+ if (actor.process !== proc)
524
+ return;
525
+ detailed = this.buildPromptFailureMessage(err, proc, actor);
526
+ actor.markFailed();
527
+ this.injectSessionError(actor, detailed);
528
+ this.releaseActor(actor);
529
+ });
530
+ if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
531
+ proc.process.kill("SIGTERM");
532
+ }
533
+ throw new Error(detailed);
534
+ }
535
+ if (proc && proc.process.exitCode === null)
536
+ proc.process.kill("SIGTERM");
537
+ throw err;
426
538
  }
427
- const response = await this.pool.sendRpc(proc, promptCmd);
428
- const enriched = { ...response };
429
- if (!enriched.data)
430
- enriched.data = {};
431
- enriched.data.newSessionPath = sessionPath;
432
- ws.send(JSON.stringify({ ...enriched, id, command: "prompt" }));
433
539
  }
434
540
  async handleSteer(ws, id, command) {
435
541
  const sessionPath = command.sessionPath;
436
542
  if (!sessionPath)
437
543
  throw new Error("Missing sessionPath");
438
- const proc = this.lifecycle.getAttachedProcess(sessionPath);
439
- if (!proc)
544
+ const actor = this.registry.find(sessionPath);
545
+ if (!actor)
440
546
  throw new Error("Session is not attached (agent not running)");
441
- this.lifecycle.enqueueSteering(sessionPath, command.message);
442
- await this.pool.sendRpc(proc, { type: "steer", message: command.message });
547
+ await actor.enqueue("steer", () => this.sendSteering(actor, command.message));
443
548
  ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
444
549
  }
445
550
  async handleRemoveSteering(ws, id, command) {
@@ -449,64 +554,95 @@ export class WsHandler {
449
554
  const index = command.index;
450
555
  if (typeof index !== "number")
451
556
  throw new Error("Missing index");
452
- this.lifecycle.removeSteeringByIndex(sessionPath, index);
557
+ const actor = this.registry.find(sessionPath);
558
+ if (actor)
559
+ await actor.enqueue("remove steering", () => actor.removeSteeringByIndex(index));
453
560
  ws.send(JSON.stringify({ id, type: "response", command: "remove_steering", success: true }));
454
561
  }
455
562
  async handleAbort(ws, id, command) {
456
563
  const sessionPath = command.sessionPath;
457
- const proc = sessionPath ? this.lifecycle.getAttachedProcess(sessionPath) : undefined;
458
- if (proc) {
459
- await this.pool.sendRpc(proc, { type: "abort" });
564
+ const actor = sessionPath ? this.registry.find(sessionPath) : undefined;
565
+ if (actor) {
566
+ await actor.enqueue("abort", async () => {
567
+ if (actor.isTurnActive && actor.process) {
568
+ await this.pool.sendRpc(actor.process, { type: "abort" });
569
+ }
570
+ });
460
571
  }
461
572
  ws.send(JSON.stringify({ id, type: "response", command: "abort", success: true }));
462
573
  }
574
+ async handleHardKill(ws, id, command) {
575
+ const sessionPath = command.sessionPath;
576
+ if (!sessionPath)
577
+ throw new Error("Missing sessionPath");
578
+ const actor = this.registry.find(sessionPath);
579
+ let hadProcess = false;
580
+ let killed = false;
581
+ if (actor) {
582
+ await actor.enqueue("hard kill", () => {
583
+ const proc = actor.process;
584
+ if (!proc)
585
+ return;
586
+ hadProcess = true;
587
+ killed = this.pool.forceKill(proc);
588
+ actor.markFailed();
589
+ this.releaseActor(actor, false);
590
+ });
591
+ }
592
+ if (hadProcess)
593
+ 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
+ }));
603
+ }
463
604
  async handleCompact(ws, id, command) {
464
605
  const sessionPath = command.sessionPath;
465
606
  if (!sessionPath)
466
607
  throw new Error("Missing sessionPath");
467
- const proc = await this.acquireForSession(sessionPath);
468
- const response = await this.pool.sendRpc(proc, { type: "compact", customInstructions: command.customInstructions });
469
- this.releaseProcess(sessionPath);
608
+ const actor = this.registry.get(sessionPath);
609
+ const response = await actor.enqueue("compact", async () => {
610
+ actor.assertAvailable("compact");
611
+ const proc = await this.acquireForActor(actor);
612
+ try {
613
+ // Pi returns only after the summarization model call and session write.
614
+ // The generic 30s RPC timeout can expire while compaction is healthy,
615
+ // causing its eventual response and compaction_end event to be orphaned.
616
+ return await this.pool.sendRpc(proc, { type: "compact", customInstructions: command.customInstructions }, COMPACT_RPC_TIMEOUT_MS);
617
+ }
618
+ finally {
619
+ this.releaseActor(actor);
620
+ }
621
+ });
470
622
  ws.send(JSON.stringify({ ...response, id, command: "compact" }));
471
623
  }
472
624
  async handleGetAvailableModels(ws, id) {
473
- const proc = this.getAnyProcess();
474
- const response = await this.pool.sendRpc(proc, { type: "get_available_models" });
475
- ws.send(JSON.stringify({ ...response, id, command: "get_available_models" }));
625
+ const lease = await this.acquireAnyProcess();
626
+ 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" }));
629
+ }
630
+ finally {
631
+ lease.release();
632
+ }
476
633
  }
477
634
  async handleGetCommands(ws, id) {
478
- const proc = this.getAnyProcess();
479
- const response = await this.pool.sendRpc(proc, { type: "get_commands" });
480
- ws.send(JSON.stringify({ ...response, id, command: "get_commands" }));
635
+ const lease = await this.acquireAnyProcess();
636
+ try {
637
+ const response = await this.pool.sendRpc(lease.process, { type: "get_commands" });
638
+ ws.send(JSON.stringify({ ...response, id, command: "get_commands" }));
639
+ }
640
+ finally {
641
+ lease.release();
642
+ }
481
643
  }
482
644
  async handleReloadProcesses(ws, id) {
483
- const all = this.pool.getAllProcesses();
484
- let killed = 0;
485
- let draining = 0;
486
- for (const proc of all) {
487
- if (proc.process.exitCode !== null)
488
- continue;
489
- const sessionPath = this.lifecycle.getAttachedSessionForProcess(proc);
490
- if (sessionPath) {
491
- // Graceful path: keep running turns alive, but decommission the process
492
- // once the turn ends (releaseProcess will terminate it).
493
- if (!this.decommissionProcesses.has(proc)) {
494
- this.decommissionProcesses.add(proc);
495
- draining += 1;
496
- }
497
- continue;
498
- }
499
- // Idle/unattached process: terminate immediately.
500
- const cleanup = this.procEventCleanup.get(proc);
501
- if (cleanup) {
502
- cleanup();
503
- this.procEventCleanup.delete(proc);
504
- }
505
- this.busyProcesses.delete(proc);
506
- this.decommissionProcesses.delete(proc);
507
- proc.process.kill("SIGTERM");
508
- killed += 1;
509
- }
645
+ const { killed, draining } = this.pool.decommissionAll();
510
646
  this.ensurePool();
511
647
  ws.send(JSON.stringify({
512
648
  id,
@@ -517,14 +653,19 @@ export class WsHandler {
517
653
  }));
518
654
  }
519
655
  async handleGetDefaultModel(ws, id) {
520
- const proc = this.getAnyProcess();
521
- const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
522
- const model = stateResp.data?.model ?? null;
523
- const thinkingLevel = stateResp.data?.thinkingLevel ?? "off";
524
- ws.send(JSON.stringify({ id, type: "response", command: "get_default_model", success: true, data: { model, thinkingLevel } }));
656
+ const lease = await this.acquireAnyProcess();
657
+ 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 } }));
662
+ }
663
+ finally {
664
+ lease.release();
665
+ }
525
666
  }
526
667
  handleGetSessionStatuses(ws, id) {
527
- ws.send(JSON.stringify({ id, type: "response", command: "get_session_statuses", success: true, data: { statuses: this.lifecycle.getAllStatuses() } }));
668
+ ws.send(JSON.stringify({ id, type: "response", command: "get_session_statuses", success: true, data: { statuses: this.registry.getAllStatuses() } }));
528
669
  }
529
670
  async handleFork(ws, id, command) {
530
671
  const sessionPath = command.sessionPath;
@@ -533,17 +674,25 @@ export class WsHandler {
533
674
  const entryId = command.entryId;
534
675
  if (!entryId)
535
676
  throw new Error("Missing entryId");
536
- const proc = await this.acquireForSession(sessionPath);
537
- const response = await this.pool.sendRpc(proc, { type: "fork", entryId });
538
- const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
539
- const newSessionPath = stateResp.data?.sessionFile;
540
- this.releaseProcess(sessionPath);
677
+ const actor = this.registry.get(sessionPath);
678
+ const result = await actor.enqueue("fork", async () => {
679
+ actor.assertAvailable("fork");
680
+ const proc = await this.acquireForActor(actor);
681
+ 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 };
685
+ }
686
+ finally {
687
+ this.releaseActor(actor);
688
+ }
689
+ });
541
690
  ws.send(JSON.stringify({
542
691
  id, type: "response", command: "fork", success: true,
543
692
  data: {
544
- text: response.data?.text ?? "",
545
- cancelled: response.data?.cancelled ?? false,
546
- newSessionPath: newSessionPath ?? null,
693
+ text: result.response.data?.text ?? "",
694
+ cancelled: result.response.data?.cancelled ?? false,
695
+ newSessionPath: result.newSessionPath ?? null,
547
696
  },
548
697
  }));
549
698
  }
@@ -557,112 +706,267 @@ export class WsHandler {
557
706
  const sessionsDir = path.join(getAgentDir(), "sessions");
558
707
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
559
708
  const newId = crypto.randomUUID().slice(0, 8);
560
- const newFilename = `${timestamp}_${newId}.jsonl`;
561
- const newSessionPath = path.join(sessionsDir, newFilename);
562
- await copyFile(sessionPath, newSessionPath);
709
+ const newSessionPath = path.join(sessionsDir, `${timestamp}_${newId}.jsonl`);
710
+ const sourceActor = this.registry.get(sessionPath);
711
+ await sourceActor.enqueue("fork prompt source", async () => {
712
+ sourceActor.assertAvailable("fork and prompt");
713
+ await copyFile(sessionPath, newSessionPath);
714
+ });
563
715
  const forkCwd = getSessionCwd(sessionPath);
564
716
  const cwd = (forkCwd && existsSync(forkCwd)) ? forkCwd : this.defaultCwd;
565
- const proc = await this.acquireProcess(cwd);
566
- await this.pool.waitForReady(proc);
567
- this.busyProcesses.add(proc);
568
- // Create attached session, seeded from the forked file
569
- this.createAttachedSession(newSessionPath);
570
- ws.send(JSON.stringify({ type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message }));
571
- this.lifecycle.attach(newSessionPath, proc);
572
- await this.pool.sendRpc(proc, { type: "switch_session", sessionPath: newSessionPath });
573
- if (!command.model) {
574
- throw new Error(`BUG: fork_prompt command received without model. sessionPath=${newSessionPath}`);
575
- }
576
- await this.pool.sendRpcChecked(proc, {
577
- type: "set_model",
578
- provider: command.model.provider,
579
- modelId: command.model.modelId,
580
- });
581
- const modelState = await this.pool.sendRpcChecked(proc, { type: "get_state" });
582
- const activeModel = modelState.data?.model;
583
- if (!activeModel || activeModel.provider !== command.model.provider || activeModel.id !== command.model.modelId) {
584
- throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
585
- }
586
- if (command.thinkingLevel) {
587
- await this.pool.sendRpcChecked(proc, { type: "set_thinking_level", level: command.thinkingLevel });
717
+ const actor = this.registry.get(newSessionPath);
718
+ let proc;
719
+ let unownedLease;
720
+ try {
721
+ const start = await actor.enqueue("fork prompt start", async () => {
722
+ actor.assertAvailable("fork and prompt");
723
+ unownedLease = await this.acquireProcess(cwd);
724
+ proc = unownedLease.process;
725
+ await this.pool.waitForReady(proc);
726
+ this.beginPendingExtensionStatusCapture(proc);
727
+ await this.replacePiSession(proc, { type: "switch_session", sessionPath: newSessionPath }, "switch_session");
728
+ actor.attach(unownedLease, this.createSessionState(newSessionPath));
729
+ unownedLease = undefined;
730
+ this.commitPendingExtensionStatuses(proc, newSessionPath);
731
+ ws.send(JSON.stringify({ type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message }));
732
+ const generation = actor.beginTurn();
733
+ const observer = this.setupTurnEventForwarding(actor, proc, generation, makeTurnId());
734
+ 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);
739
+ await this.reconcileEffectiveControlState(proc, actor);
740
+ return { observer, generation };
741
+ });
742
+ await this.waitForPromptSettlement(proc, start.observer);
743
+ await actor.enqueue("fork prompt settlement", async () => {
744
+ if (!actor.owns(proc, start.generation))
745
+ return;
746
+ await this.reconcileEffectiveControlState(proc, actor);
747
+ this.releaseActor(actor);
748
+ });
749
+ ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
588
750
  }
589
- const turnId = makeTurnId();
590
- this.setupTurnEventForwarding(proc, newSessionPath, ws, turnId);
591
- const promptCmd = { type: "prompt", message };
592
- if (command.images?.length > 0) {
593
- promptCmd.images = command.images;
751
+ catch (err) {
752
+ if (proc)
753
+ this.pendingExtensionStatuses.delete(proc);
754
+ unownedLease?.release();
755
+ if (proc && actor.process === proc) {
756
+ let detailed = this.buildPromptFailureMessage(err, proc, actor);
757
+ await actor.enqueue("fork prompt failure", () => {
758
+ if (actor.process !== proc)
759
+ return;
760
+ detailed = this.buildPromptFailureMessage(err, proc, actor);
761
+ actor.markFailed();
762
+ this.injectSessionError(actor, detailed);
763
+ this.releaseActor(actor);
764
+ });
765
+ if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
766
+ proc.process.kill("SIGTERM");
767
+ }
768
+ throw new Error(detailed);
769
+ }
770
+ if (proc && proc.process.exitCode === null)
771
+ proc.process.kill("SIGTERM");
772
+ throw err;
594
773
  }
595
- await this.pool.sendRpc(proc, promptCmd);
596
- ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
597
774
  }
598
775
  async handleSetSessionName(ws, id, command) {
599
776
  const sessionPath = command.sessionPath;
600
777
  if (!sessionPath)
601
778
  throw new Error("Missing sessionPath");
602
- const proc = await this.acquireForSession(sessionPath);
603
- const response = await this.pool.sendRpc(proc, { type: "set_session_name", name: command.name });
604
- this.releaseProcess(sessionPath);
779
+ const actor = this.registry.get(sessionPath);
780
+ const response = await actor.enqueue("set session name", async () => {
781
+ actor.assertAvailable("rename session");
782
+ const proc = await this.acquireForActor(actor);
783
+ try {
784
+ return await this.pool.sendRpc(proc, { type: "set_session_name", name: command.name });
785
+ }
786
+ finally {
787
+ this.releaseActor(actor);
788
+ }
789
+ });
605
790
  ws.send(JSON.stringify({ ...response, id, command: "set_session_name" }));
606
791
  }
607
792
  // ── Internal helpers ─────────────────────────────────────────────────
608
- sleep(ms) {
609
- return new Promise((resolve) => setTimeout(resolve, ms));
610
- }
611
- /** Busy + decommissioned processes are unavailable for reuse. */
612
- getUnavailableProcesses() {
613
- return new Set([...this.busyProcesses, ...this.decommissionProcesses]);
793
+ async replacePiSession(proc, command, operation) {
794
+ const response = await this.pool.sendRpcChecked(proc, command);
795
+ if (response.data?.cancelled) {
796
+ throw new Error(`Pi ${operation} was cancelled`);
797
+ }
614
798
  }
615
799
  /**
616
- * Create a SessionJsonl for a session path.
617
- * Reads existing JSONL from disk to seed the initial messages.
800
+ * Apply a client's requested controls as one ordered RPC transaction, then
801
+ * publish pi's effective (possibly clamped) state before the prompt starts.
618
802
  */
619
- createAttachedSession(sessionPath) {
803
+ async applyRequestedControlState(proc, actor, ws, command) {
804
+ const sessionPath = actor.sessionPath;
805
+ if (!command.model) {
806
+ throw new Error(`BUG: prompt command received without model. sessionPath=${sessionPath}`);
807
+ }
808
+ let stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
809
+ if (!modelsMatch(stateResponse.data?.model, command.model)) {
810
+ await this.pool.sendRpcChecked(proc, {
811
+ type: "set_model",
812
+ provider: command.model.provider,
813
+ modelId: command.model.modelId,
814
+ });
815
+ }
816
+ if (typeof command.thinkingLevel === "string") {
817
+ await this.pool.sendRpcChecked(proc, {
818
+ type: "set_thinking_level",
819
+ level: command.thinkingLevel,
820
+ });
821
+ }
822
+ // set_thinking_level has no effective value in its response and set_model
823
+ // may clamp effort, so get_state is the authoritative transaction result.
824
+ stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
825
+ const activeModel = stateResponse.data?.model;
826
+ if (!modelsMatch(activeModel, command.model)) {
827
+ throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
828
+ }
829
+ this.publishEffectiveControlState(actor, stateResponse.data);
830
+ if (ws.readyState === WebSocket.OPEN) {
831
+ ws.send(JSON.stringify({
832
+ type: "control_state",
833
+ sessionPath,
834
+ controlRevision: command.controlRevision,
835
+ model: activeModel,
836
+ thinkingLevel: stateResponse.data?.thinkingLevel ?? "off",
837
+ }));
838
+ }
839
+ return stateResponse.data;
840
+ }
841
+ async waitForPromptSettlement(proc, observer) {
842
+ const state = await this.pool.sendRpcChecked(proc, { type: "get_state" });
843
+ // Pi >= 0.80 acknowledges a prompt only after preflight. Before that
844
+ // acknowledgement is written, a real agent run synchronously marks the
845
+ // session streaming; an extension-handled input remains idle and emits no
846
+ // agent lifecycle. This authoritative state check avoids timing guesses.
847
+ if (!state.data?.isStreaming && !observer.hasStarted())
848
+ return;
849
+ let removeExitListener = () => { };
850
+ const exited = new Promise((_resolve, reject) => {
851
+ if (proc.process.exitCode !== null) {
852
+ reject(new Error(`pi process #${proc.id} exited before the turn settled`));
853
+ return;
854
+ }
855
+ const onExit = () => reject(new Error(`pi process #${proc.id} exited before the turn settled`));
856
+ proc.process.once("exit", onExit);
857
+ removeExitListener = () => proc.process.removeListener("exit", onExit);
858
+ });
859
+ try {
860
+ await Promise.race([observer.settled, exited]);
861
+ }
862
+ finally {
863
+ removeExitListener();
864
+ }
865
+ }
866
+ /** Refresh controls after extensions or model clamps that happened in-turn. */
867
+ async reconcileEffectiveControlState(proc, actor) {
868
+ const stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
869
+ this.publishEffectiveControlState(actor, stateResponse.data);
870
+ }
871
+ publishEffectiveControlState(actor, rpcState) {
872
+ const session = actor.session;
873
+ if (!session)
874
+ return;
875
+ const model = toCompactModelRef(rpcState?.model ?? {});
876
+ if (!model)
877
+ return;
878
+ const changed = session.setControlState(model, rpcState?.thinkingLevel ?? "off");
879
+ if (changed)
880
+ this.pushUpdateToSubscribers(actor.sessionPath, session);
881
+ }
882
+ buildPromptFailureMessage(err, proc, actor) {
883
+ const raw = err instanceof Error ? err.message : String(err);
884
+ const sessionError = actor.session?.toState().error;
885
+ const stderrTail = this.pool.getRecentStderr(proc, 12);
886
+ let message = raw;
887
+ if (raw.includes("Timeout waiting for RPC response to prompt")) {
888
+ message = "Prompt timed out waiting for pi RPC response";
889
+ }
890
+ if (sessionError) {
891
+ message += `\nLast agent error: ${sessionError}`;
892
+ }
893
+ if (stderrTail.length > 0) {
894
+ message += `\nRecent pi stderr:\n${stderrTail.join("\n")}`;
895
+ }
896
+ return message;
897
+ }
898
+ injectSessionError(actor, errorMessage) {
899
+ const session = actor.session;
900
+ if (!session)
901
+ return;
902
+ session.applyEvent({
903
+ type: "turn_end",
904
+ message: { role: "assistant", errorMessage },
905
+ });
906
+ this.pushUpdateToSubscribers(actor.sessionPath, session);
907
+ }
908
+ async sendSteering(actor, message) {
909
+ if (!actor.isTurnActive || !actor.process) {
910
+ throw new Error("Session is not attached (agent not running)");
911
+ }
912
+ actor.enqueueSteering(message);
913
+ try {
914
+ await this.pool.sendRpc(actor.process, { type: "steer", message });
915
+ }
916
+ catch (error) {
917
+ actor.dequeueSteering(message);
918
+ throw error;
919
+ }
920
+ }
921
+ sleep(ms) {
922
+ return new Promise((resolve) => setTimeout(resolve, ms));
923
+ }
924
+ /** Build the active materialized view owned by a SessionActor. */
925
+ createSessionState(sessionPath) {
620
926
  const { state } = readSessionFromDisk(sessionPath);
621
- const session = new SessionJsonl({
927
+ return new SessionJsonl({
622
928
  messages: state.messages,
623
929
  model: state.model,
624
930
  thinkingLevel: state.thinkingLevel,
625
931
  });
626
- this.attachedSessions.set(sessionPath, session);
627
- return session;
628
932
  }
629
- /**
630
- * Acquire a process for an existing session. Resolves the session's cwd
631
- * from its JSONL header and gets a process from the matching pool.
632
- */
633
- async acquireForSession(sessionPath) {
634
- const existing = this.lifecycle.getAttachedProcess(sessionPath);
635
- if (existing)
636
- return existing;
933
+ /** Acquire, switch, and transfer a process lease to a detached actor. */
934
+ async acquireForActor(actor) {
935
+ if (actor.process)
936
+ return actor.process;
937
+ const sessionPath = actor.sessionPath;
637
938
  const sessionCwd = getSessionCwd(sessionPath);
638
939
  const cwd = (sessionCwd && existsSync(sessionCwd)) ? sessionCwd : this.defaultCwd;
639
- const proc = await this.acquireProcess(cwd);
640
- this.busyProcesses.add(proc);
641
- this.lifecycle.attach(sessionPath, proc);
642
- // Create attached session if it doesn't exist yet
643
- if (!this.attachedSessions.has(sessionPath)) {
644
- this.createAttachedSession(sessionPath);
645
- }
646
- await this.pool.sendRpc(proc, { type: "switch_session", sessionPath });
940
+ const lease = await this.acquireProcess(cwd);
941
+ const proc = lease.process;
942
+ this.beginPendingExtensionStatusCapture(proc, sessionPath);
943
+ try {
944
+ await this.replacePiSession(proc, { type: "switch_session", sessionPath }, "switch_session");
945
+ actor.attach(lease, this.createSessionState(sessionPath));
946
+ this.commitPendingExtensionStatuses(proc, sessionPath);
947
+ }
948
+ catch (err) {
949
+ this.pendingExtensionStatuses.delete(proc);
950
+ if (actor.process === proc)
951
+ actor.detach();
952
+ else
953
+ lease.release();
954
+ throw err;
955
+ }
647
956
  console.log(`[ws] pi#${proc.id} attached to ${path.basename(sessionPath)} (cwd: ${cwd})`);
648
957
  return proc;
649
958
  }
650
- /**
651
- * Acquire a process for a given cwd. Spawns if needed.
652
- */
959
+ /** Acquire an atomically reserved process lease for a cwd. */
653
960
  async acquireProcess(cwd) {
654
- if (!this.piAvailable) {
961
+ if (!this.piAvailable)
655
962
  throw new Error(makePiNotFoundMessage(this.piLaunch.command));
656
- }
657
963
  const timeoutMs = 60000;
658
964
  const start = Date.now();
659
965
  while (true) {
660
- const unavailable = this.getUnavailableProcesses();
661
- const proc = this.pool.acquire(cwd, unavailable);
662
- if (proc)
663
- return proc;
664
- const evicted = this.pool.evictIdleDifferentCwd(cwd, unavailable);
665
- if (evicted) {
966
+ const lease = this.pool.acquire(cwd);
967
+ if (lease)
968
+ return lease;
969
+ if (this.pool.evictIdleDifferentCwd(cwd)) {
666
970
  await this.sleep(50);
667
971
  continue;
668
972
  }
@@ -672,38 +976,32 @@ export class WsHandler {
672
976
  await this.sleep(100);
673
977
  }
674
978
  }
675
- releaseProcess(sessionPath) {
676
- const proc = this.lifecycle.getAttachedProcess(sessionPath);
677
- if (proc) {
678
- const cleanup = this.procEventCleanup.get(proc);
679
- if (cleanup) {
680
- cleanup();
681
- this.procEventCleanup.delete(proc);
979
+ async acquireAnyProcess() {
980
+ const existing = this.pool.acquireAny();
981
+ if (existing)
982
+ return existing;
983
+ return this.acquireProcess(this.defaultCwd);
984
+ }
985
+ releaseActor(actor, preserveLiveControls = true) {
986
+ const sessionPath = actor.sessionPath;
987
+ const proc = actor.process;
988
+ const liveState = actor.session?.toState();
989
+ if (proc)
990
+ this.pendingExtensionStatuses.delete(proc);
991
+ actor.detach();
992
+ // Read final messages from disk, but preserve the just-reconciled controls.
993
+ // This prevents JSONL flush timing from regressing the detach snapshot.
994
+ const disk = readSessionFromDisk(sessionPath);
995
+ if (liveState) {
996
+ if (preserveLiveControls) {
997
+ disk.state.model = liveState.model;
998
+ disk.state.thinkingLevel = liveState.thinkingLevel;
682
999
  }
1000
+ disk.state.error = liveState.error;
683
1001
  }
684
- // Delete the attached session no more in-memory state
685
- this.attachedSessions.delete(sessionPath);
686
- this.lifecycle.detach(sessionPath);
687
- // Read final state from disk and push to subscribers
688
- const { json, hash } = readSessionFromDisk(sessionPath);
1002
+ const { json, hash } = serializeSessionState(disk.state);
689
1003
  this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
690
1004
  this.pushSnapshotToSubscribers(sessionPath, json, hash);
691
- if (proc) {
692
- const shouldDecommission = this.decommissionProcesses.has(proc);
693
- if (shouldDecommission) {
694
- this.decommissionProcesses.delete(proc);
695
- console.log(`[pool] Decommissioning pi#${proc.id} after completed turn`);
696
- proc.process.kill("SIGTERM");
697
- }
698
- this.busyProcesses.delete(proc);
699
- }
700
- }
701
- getAnyProcess() {
702
- let proc = this.pool.getAny(this.getUnavailableProcesses());
703
- if (!proc) {
704
- proc = this.pool.spawn(this.defaultCwd);
705
- }
706
- return proc;
707
1005
  }
708
1006
  pushSnapshotToSubscribers(sessionPath, json, hash) {
709
1007
  for (const [ws, client] of this.clients) {
@@ -746,66 +1044,56 @@ export class WsHandler {
746
1044
  client.lastVersion = session.version;
747
1045
  }
748
1046
  }
749
- setupTurnEventForwarding(proc, sessionPath, ws, turnId) {
750
- const existingCleanup = this.procEventCleanup.get(proc);
751
- if (existingCleanup) {
752
- existingCleanup();
753
- this.procEventCleanup.delete(proc);
754
- }
755
- // Capture the SessionJsonl object — if it gets deleted (detach),
756
- // the handler becomes a no-op because we check for it.
757
- const sessionRef = this.attachedSessions.get(sessionPath);
758
- if (!sessionRef) {
759
- console.error(`[turn] setupTurnEventForwarding called but no SessionJsonl for ${sessionPath}`);
760
- return;
1047
+ setupTurnEventForwarding(actor, proc, generation, turnId) {
1048
+ const sessionPath = actor.sessionPath;
1049
+ if (!actor.session || !actor.owns(proc, generation)) {
1050
+ throw new Error(`setupTurnEventForwarding called without actor ownership for ${sessionPath}`);
761
1051
  }
762
- const lineHandler = (line) => {
763
- let data;
764
- try {
765
- data = JSON.parse(line);
766
- }
767
- catch {
768
- return;
769
- }
770
- if (data.type === "response" && data.id)
771
- return;
772
- // Guard: if the attached session was deleted (turn ended),
773
- // this handler is stale — skip.
774
- const currentSession = this.attachedSessions.get(sessionPath);
775
- if (currentSession !== sessionRef)
1052
+ let hasStarted = false;
1053
+ let hasSettled = false;
1054
+ let resolveStarted;
1055
+ let resolveEnded;
1056
+ let resolveSettled;
1057
+ const started = new Promise((resolve) => { resolveStarted = resolve; });
1058
+ const ended = new Promise((resolve) => { resolveEnded = resolve; });
1059
+ const settled = new Promise((resolve) => { resolveSettled = resolve; });
1060
+ const eventHandler = (sourceProc, data) => {
1061
+ if (sourceProc !== proc || data.type === "extension_ui_request")
776
1062
  return;
777
- // Apply event to the in-memory attached session
778
- let changed = currentSession.applyEvent(data);
779
- // After auto-compaction, the pi process rewrites the JSONL and calls
780
- // replaceMessages() internally. SessionJsonl doesn't know about this,
781
- // so re-read the session from disk to pick up the compacted state.
782
- if (data.type === "auto_compaction_end" && data.result) {
783
- const { state } = readSessionFromDisk(sessionPath);
784
- currentSession.replaceMessages(state.messages);
785
- changed = true;
786
- }
787
- // Side-channel raw event for UI hooks (canvas/jsonl), not state updates
788
- if (ws.readyState === WebSocket.OPEN) {
789
- ws.send(JSON.stringify({ type: "agent_event", sessionPath, event: data }));
790
- }
791
- if (data.type === "message_end" && data.message?.role === "user") {
792
- const text = typeof data.message.content === "string"
793
- ? data.message.content
794
- : (data.message.content || []).filter((c) => c.type === "text").map((c) => c.text).join(" ");
795
- this.lifecycle.dequeueSteering(sessionPath, text);
796
- }
797
- // Push update to all subscribed clients
798
- if (changed) {
799
- this.pushUpdateToSubscribers(sessionPath, currentSession);
800
- }
801
- if (data.type === "agent_end") {
802
- debugTurn("agent_end_received", { turnId, procId: proc.id, sessionPath });
803
- this.lifecycle.clearSteering(sessionPath);
804
- this.releaseProcess(sessionPath);
805
- }
1063
+ const replacementMessages = data.type === "auto_compaction_end" && data.result
1064
+ ? readSessionFromDisk(sessionPath).state.messages
1065
+ : undefined;
1066
+ void actor.applyProcessEvent(proc, generation, data, replacementMessages).then((result) => {
1067
+ if (!result.accepted)
1068
+ return;
1069
+ if (result.started) {
1070
+ hasStarted = true;
1071
+ resolveStarted();
1072
+ }
1073
+ if (result.ended)
1074
+ resolveEnded();
1075
+ if (result.settled) {
1076
+ hasSettled = true;
1077
+ resolveSettled();
1078
+ }
1079
+ if (result.changed && actor.session) {
1080
+ this.pushUpdateToSubscribers(sessionPath, actor.session);
1081
+ }
1082
+ if (data.type === "agent_end") {
1083
+ debugTurn("agent_end_received", { turnId, procId: proc.id, sessionPath });
1084
+ }
1085
+ }).catch((error) => {
1086
+ console.error(`[session-actor] Failed to apply ${data.type} for ${sessionPath}:`, error);
1087
+ });
1088
+ };
1089
+ const cleanup = this.pool.subscribeEvents(eventHandler);
1090
+ actor.setTurnEventCleanup(generation, cleanup);
1091
+ return {
1092
+ started,
1093
+ ended,
1094
+ settled,
1095
+ hasStarted: () => hasStarted,
1096
+ hasSettled: () => hasSettled,
806
1097
  };
807
- proc.rl.on("line", lineHandler);
808
- const cleanup = () => proc.rl.removeListener("line", lineHandler);
809
- this.procEventCleanup.set(proc, cleanup);
810
1098
  }
811
1099
  }