pipane 0.1.5 → 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 +160 -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 +577 -352
  78. package/dist/server/shared/jsonl-sync.js +41 -0
  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-BtDAXAo1.css +0 -1
  91. package/dist/client/assets/index-CAQqlK61.js +0 -131
  92. package/dist/client/assets/index-CJEeBmBj.js +0 -3
  93. package/dist/client/assets/index-DUEhKmPQ.js +0 -1
  94. package/dist/client/assets/index-XEVKscP-.js +0 -4443
  95. package/dist/client/assets/index-woI4agCy.js +0 -45
  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,74 +444,96 @@ 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;
449
+ let unownedLease;
450
+ let generation;
383
451
  try {
452
+ let newSessionCwd;
384
453
  if (sessionPath === "__new__") {
385
- const cwd = command.cwd || this.defaultCwd;
386
- proc = await this.acquireProcess(cwd);
454
+ newSessionCwd = command.cwd || this.defaultCwd;
455
+ unownedLease = await this.acquireProcess(newSessionCwd);
456
+ proc = unownedLease.process;
387
457
  await this.pool.waitForReady(proc);
388
- await this.pool.sendRpc(proc, { type: "new_session" });
458
+ this.beginPendingExtensionStatusCapture(proc);
459
+ await this.replacePiSession(proc, { type: "new_session" }, "new_session");
389
460
  const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
390
461
  sessionPath = stateResp.data?.sessionFile;
391
462
  if (!sessionPath)
392
463
  throw new Error("Failed to get session path from new session");
393
- this.busyProcesses.add(proc);
394
- // Create attached session with empty state (new session)
395
- this.createAttachedSession(sessionPath);
396
- ws.send(JSON.stringify({
397
- type: "session_attached",
398
- sessionPath,
399
- cwd,
400
- firstMessage: command.message,
401
- }));
402
- this.lifecycle.attach(sessionPath, proc);
403
- }
404
- else {
405
- proc = await this.acquireForSession(sessionPath);
406
464
  }
407
- if (!command.model) {
408
- throw new Error(`BUG: prompt command received without model. sessionPath=${sessionPath}`);
409
- }
410
- await this.pool.sendRpcChecked(proc, {
411
- type: "set_model",
412
- provider: command.model.provider,
413
- modelId: command.model.modelId,
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 };
414
498
  });
415
- const modelState = await this.pool.sendRpcChecked(proc, { type: "get_state" });
416
- const activeModel = modelState.data?.model;
417
- if (!activeModel || activeModel.provider !== command.model.provider || activeModel.id !== command.model.modelId) {
418
- throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
419
- }
420
- if (command.thinkingLevel) {
421
- await this.pool.sendRpcChecked(proc, { type: "set_thinking_level", level: command.thinkingLevel });
422
- }
423
- this.setupTurnEventForwarding(proc, sessionPath, ws, turnId);
424
- const promptCmd = { type: "prompt", message: command.message };
425
- if (command.images?.length > 0) {
426
- promptCmd.images = command.images;
499
+ if (!start) {
500
+ ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
501
+ return;
427
502
  }
428
- const response = await this.pool.sendRpc(proc, promptCmd);
429
- const enriched = { ...response };
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 };
430
511
  if (!enriched.data)
431
512
  enriched.data = {};
432
513
  enriched.data.newSessionPath = sessionPath;
433
514
  ws.send(JSON.stringify({ ...enriched, id, command: "prompt" }));
434
515
  }
435
516
  catch (err) {
436
- if (proc && sessionPath && this.lifecycle.getAttachedProcess(sessionPath) === proc) {
437
- const detailed = this.buildPromptFailureMessage(err, proc, sessionPath);
438
- this.injectSessionError(sessionPath, detailed);
439
- this.releaseProcess(sessionPath);
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
+ });
440
530
  if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
441
531
  proc.process.kill("SIGTERM");
442
532
  }
443
533
  throw new Error(detailed);
444
534
  }
445
- if (proc) {
446
- this.busyProcesses.delete(proc);
447
- if (proc.process.exitCode === null)
448
- proc.process.kill("SIGTERM");
449
- }
535
+ if (proc && proc.process.exitCode === null)
536
+ proc.process.kill("SIGTERM");
450
537
  throw err;
451
538
  }
452
539
  }
@@ -454,11 +541,10 @@ export class WsHandler {
454
541
  const sessionPath = command.sessionPath;
455
542
  if (!sessionPath)
456
543
  throw new Error("Missing sessionPath");
457
- const proc = this.lifecycle.getAttachedProcess(sessionPath);
458
- if (!proc)
544
+ const actor = this.registry.find(sessionPath);
545
+ if (!actor)
459
546
  throw new Error("Session is not attached (agent not running)");
460
- this.lifecycle.enqueueSteering(sessionPath, command.message);
461
- await this.pool.sendRpc(proc, { type: "steer", message: command.message });
547
+ await actor.enqueue("steer", () => this.sendSteering(actor, command.message));
462
548
  ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
463
549
  }
464
550
  async handleRemoveSteering(ws, id, command) {
@@ -468,64 +554,95 @@ export class WsHandler {
468
554
  const index = command.index;
469
555
  if (typeof index !== "number")
470
556
  throw new Error("Missing index");
471
- this.lifecycle.removeSteeringByIndex(sessionPath, index);
557
+ const actor = this.registry.find(sessionPath);
558
+ if (actor)
559
+ await actor.enqueue("remove steering", () => actor.removeSteeringByIndex(index));
472
560
  ws.send(JSON.stringify({ id, type: "response", command: "remove_steering", success: true }));
473
561
  }
474
562
  async handleAbort(ws, id, command) {
475
563
  const sessionPath = command.sessionPath;
476
- const proc = sessionPath ? this.lifecycle.getAttachedProcess(sessionPath) : undefined;
477
- if (proc) {
478
- 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
+ });
479
571
  }
480
572
  ws.send(JSON.stringify({ id, type: "response", command: "abort", success: true }));
481
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
+ }
482
604
  async handleCompact(ws, id, command) {
483
605
  const sessionPath = command.sessionPath;
484
606
  if (!sessionPath)
485
607
  throw new Error("Missing sessionPath");
486
- const proc = await this.acquireForSession(sessionPath);
487
- const response = await this.pool.sendRpc(proc, { type: "compact", customInstructions: command.customInstructions });
488
- 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
+ });
489
622
  ws.send(JSON.stringify({ ...response, id, command: "compact" }));
490
623
  }
491
624
  async handleGetAvailableModels(ws, id) {
492
- const proc = this.getAnyProcess();
493
- const response = await this.pool.sendRpc(proc, { type: "get_available_models" });
494
- 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
+ }
495
633
  }
496
634
  async handleGetCommands(ws, id) {
497
- const proc = this.getAnyProcess();
498
- const response = await this.pool.sendRpc(proc, { type: "get_commands" });
499
- 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
+ }
500
643
  }
501
644
  async handleReloadProcesses(ws, id) {
502
- const all = this.pool.getAllProcesses();
503
- let killed = 0;
504
- let draining = 0;
505
- for (const proc of all) {
506
- if (proc.process.exitCode !== null)
507
- continue;
508
- const sessionPath = this.lifecycle.getAttachedSessionForProcess(proc);
509
- if (sessionPath) {
510
- // Graceful path: keep running turns alive, but decommission the process
511
- // once the turn ends (releaseProcess will terminate it).
512
- if (!this.decommissionProcesses.has(proc)) {
513
- this.decommissionProcesses.add(proc);
514
- draining += 1;
515
- }
516
- continue;
517
- }
518
- // Idle/unattached process: terminate immediately.
519
- const cleanup = this.procEventCleanup.get(proc);
520
- if (cleanup) {
521
- cleanup();
522
- this.procEventCleanup.delete(proc);
523
- }
524
- this.busyProcesses.delete(proc);
525
- this.decommissionProcesses.delete(proc);
526
- proc.process.kill("SIGTERM");
527
- killed += 1;
528
- }
645
+ const { killed, draining } = this.pool.decommissionAll();
529
646
  this.ensurePool();
530
647
  ws.send(JSON.stringify({
531
648
  id,
@@ -536,14 +653,19 @@ export class WsHandler {
536
653
  }));
537
654
  }
538
655
  async handleGetDefaultModel(ws, id) {
539
- const proc = this.getAnyProcess();
540
- const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
541
- const model = stateResp.data?.model ?? null;
542
- const thinkingLevel = stateResp.data?.thinkingLevel ?? "off";
543
- 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
+ }
544
666
  }
545
667
  handleGetSessionStatuses(ws, id) {
546
- 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() } }));
547
669
  }
548
670
  async handleFork(ws, id, command) {
549
671
  const sessionPath = command.sessionPath;
@@ -552,17 +674,25 @@ export class WsHandler {
552
674
  const entryId = command.entryId;
553
675
  if (!entryId)
554
676
  throw new Error("Missing entryId");
555
- const proc = await this.acquireForSession(sessionPath);
556
- const response = await this.pool.sendRpc(proc, { type: "fork", entryId });
557
- const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
558
- const newSessionPath = stateResp.data?.sessionFile;
559
- 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
+ });
560
690
  ws.send(JSON.stringify({
561
691
  id, type: "response", command: "fork", success: true,
562
692
  data: {
563
- text: response.data?.text ?? "",
564
- cancelled: response.data?.cancelled ?? false,
565
- newSessionPath: newSessionPath ?? null,
693
+ text: result.response.data?.text ?? "",
694
+ cancelled: result.response.data?.cancelled ?? false,
695
+ newSessionPath: result.newSessionPath ?? null,
566
696
  },
567
697
  }));
568
698
  }
@@ -576,57 +706,68 @@ export class WsHandler {
576
706
  const sessionsDir = path.join(getAgentDir(), "sessions");
577
707
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
578
708
  const newId = crypto.randomUUID().slice(0, 8);
579
- const newFilename = `${timestamp}_${newId}.jsonl`;
580
- const newSessionPath = path.join(sessionsDir, newFilename);
581
- 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
+ });
582
715
  const forkCwd = getSessionCwd(sessionPath);
583
716
  const cwd = (forkCwd && existsSync(forkCwd)) ? forkCwd : this.defaultCwd;
584
- const proc = await this.acquireProcess(cwd);
585
- await this.pool.waitForReady(proc);
586
- this.busyProcesses.add(proc);
717
+ const actor = this.registry.get(newSessionPath);
718
+ let proc;
719
+ let unownedLease;
587
720
  try {
588
- // Create attached session, seeded from the forked file
589
- this.createAttachedSession(newSessionPath);
590
- ws.send(JSON.stringify({ type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message }));
591
- this.lifecycle.attach(newSessionPath, proc);
592
- await this.pool.sendRpc(proc, { type: "switch_session", sessionPath: newSessionPath });
593
- if (!command.model) {
594
- throw new Error(`BUG: fork_prompt command received without model. sessionPath=${newSessionPath}`);
595
- }
596
- await this.pool.sendRpcChecked(proc, {
597
- type: "set_model",
598
- provider: command.model.provider,
599
- modelId: command.model.modelId,
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);
600
748
  });
601
- const modelState = await this.pool.sendRpcChecked(proc, { type: "get_state" });
602
- const activeModel = modelState.data?.model;
603
- if (!activeModel || activeModel.provider !== command.model.provider || activeModel.id !== command.model.modelId) {
604
- throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
605
- }
606
- if (command.thinkingLevel) {
607
- await this.pool.sendRpcChecked(proc, { type: "set_thinking_level", level: command.thinkingLevel });
608
- }
609
- const turnId = makeTurnId();
610
- this.setupTurnEventForwarding(proc, newSessionPath, ws, turnId);
611
- const promptCmd = { type: "prompt", message };
612
- if (command.images?.length > 0) {
613
- promptCmd.images = command.images;
614
- }
615
- await this.pool.sendRpc(proc, promptCmd);
616
749
  ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
617
750
  }
618
751
  catch (err) {
619
- if (this.lifecycle.getAttachedProcess(newSessionPath) === proc) {
620
- const detailed = this.buildPromptFailureMessage(err, proc, newSessionPath);
621
- this.injectSessionError(newSessionPath, detailed);
622
- this.releaseProcess(newSessionPath);
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
+ });
623
765
  if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
624
766
  proc.process.kill("SIGTERM");
625
767
  }
626
768
  throw new Error(detailed);
627
769
  }
628
- this.busyProcesses.delete(proc);
629
- if (proc.process.exitCode === null)
770
+ if (proc && proc.process.exitCode === null)
630
771
  proc.process.kill("SIGTERM");
631
772
  throw err;
632
773
  }
@@ -635,16 +776,112 @@ export class WsHandler {
635
776
  const sessionPath = command.sessionPath;
636
777
  if (!sessionPath)
637
778
  throw new Error("Missing sessionPath");
638
- const proc = await this.acquireForSession(sessionPath);
639
- const response = await this.pool.sendRpc(proc, { type: "set_session_name", name: command.name });
640
- 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
+ });
641
790
  ws.send(JSON.stringify({ ...response, id, command: "set_session_name" }));
642
791
  }
643
792
  // ── Internal helpers ─────────────────────────────────────────────────
644
- buildPromptFailureMessage(err, proc, sessionPath) {
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
+ }
798
+ }
799
+ /**
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.
802
+ */
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) {
645
883
  const raw = err instanceof Error ? err.message : String(err);
646
- const session = this.attachedSessions.get(sessionPath);
647
- const sessionError = session?.toState().error;
884
+ const sessionError = actor.session?.toState().error;
648
885
  const stderrTail = this.pool.getRecentStderr(proc, 12);
649
886
  let message = raw;
650
887
  if (raw.includes("Timeout waiting for RPC response to prompt")) {
@@ -658,74 +895,78 @@ export class WsHandler {
658
895
  }
659
896
  return message;
660
897
  }
661
- injectSessionError(sessionPath, errorMessage) {
662
- const session = this.attachedSessions.get(sessionPath);
898
+ injectSessionError(actor, errorMessage) {
899
+ const session = actor.session;
663
900
  if (!session)
664
901
  return;
665
902
  session.applyEvent({
666
903
  type: "turn_end",
667
904
  message: { role: "assistant", errorMessage },
668
905
  });
669
- this.pushUpdateToSubscribers(sessionPath, session);
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
+ }
670
920
  }
671
921
  sleep(ms) {
672
922
  return new Promise((resolve) => setTimeout(resolve, ms));
673
923
  }
674
- /** Busy + decommissioned processes are unavailable for reuse. */
675
- getUnavailableProcesses() {
676
- return new Set([...this.busyProcesses, ...this.decommissionProcesses]);
677
- }
678
- /**
679
- * Create a SessionJsonl for a session path.
680
- * Reads existing JSONL from disk to seed the initial messages.
681
- */
682
- createAttachedSession(sessionPath) {
924
+ /** Build the active materialized view owned by a SessionActor. */
925
+ createSessionState(sessionPath) {
683
926
  const { state } = readSessionFromDisk(sessionPath);
684
- const session = new SessionJsonl({
927
+ return new SessionJsonl({
685
928
  messages: state.messages,
686
929
  model: state.model,
687
930
  thinkingLevel: state.thinkingLevel,
688
931
  });
689
- this.attachedSessions.set(sessionPath, session);
690
- return session;
691
932
  }
692
- /**
693
- * Acquire a process for an existing session. Resolves the session's cwd
694
- * from its JSONL header and gets a process from the matching pool.
695
- */
696
- async acquireForSession(sessionPath) {
697
- const existing = this.lifecycle.getAttachedProcess(sessionPath);
698
- if (existing)
699
- 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;
700
938
  const sessionCwd = getSessionCwd(sessionPath);
701
939
  const cwd = (sessionCwd && existsSync(sessionCwd)) ? sessionCwd : this.defaultCwd;
702
- const proc = await this.acquireProcess(cwd);
703
- this.busyProcesses.add(proc);
704
- this.lifecycle.attach(sessionPath, proc);
705
- // Create attached session if it doesn't exist yet
706
- if (!this.attachedSessions.has(sessionPath)) {
707
- this.createAttachedSession(sessionPath);
708
- }
709
- 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
+ }
710
956
  console.log(`[ws] pi#${proc.id} attached to ${path.basename(sessionPath)} (cwd: ${cwd})`);
711
957
  return proc;
712
958
  }
713
- /**
714
- * Acquire a process for a given cwd. Spawns if needed.
715
- */
959
+ /** Acquire an atomically reserved process lease for a cwd. */
716
960
  async acquireProcess(cwd) {
717
- if (!this.piAvailable) {
961
+ if (!this.piAvailable)
718
962
  throw new Error(makePiNotFoundMessage(this.piLaunch.command));
719
- }
720
963
  const timeoutMs = 60000;
721
964
  const start = Date.now();
722
965
  while (true) {
723
- const unavailable = this.getUnavailableProcesses();
724
- const proc = this.pool.acquire(cwd, unavailable);
725
- if (proc)
726
- return proc;
727
- const evicted = this.pool.evictIdleDifferentCwd(cwd, unavailable);
728
- if (evicted) {
966
+ const lease = this.pool.acquire(cwd);
967
+ if (lease)
968
+ return lease;
969
+ if (this.pool.evictIdleDifferentCwd(cwd)) {
729
970
  await this.sleep(50);
730
971
  continue;
731
972
  }
@@ -735,38 +976,32 @@ export class WsHandler {
735
976
  await this.sleep(100);
736
977
  }
737
978
  }
738
- releaseProcess(sessionPath) {
739
- const proc = this.lifecycle.getAttachedProcess(sessionPath);
740
- if (proc) {
741
- const cleanup = this.procEventCleanup.get(proc);
742
- if (cleanup) {
743
- cleanup();
744
- 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;
745
999
  }
1000
+ disk.state.error = liveState.error;
746
1001
  }
747
- // Delete the attached session no more in-memory state
748
- this.attachedSessions.delete(sessionPath);
749
- this.lifecycle.detach(sessionPath);
750
- // Read final state from disk and push to subscribers
751
- const { json, hash } = readSessionFromDisk(sessionPath);
1002
+ const { json, hash } = serializeSessionState(disk.state);
752
1003
  this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
753
1004
  this.pushSnapshotToSubscribers(sessionPath, json, hash);
754
- if (proc) {
755
- const shouldDecommission = this.decommissionProcesses.has(proc);
756
- if (shouldDecommission) {
757
- this.decommissionProcesses.delete(proc);
758
- console.log(`[pool] Decommissioning pi#${proc.id} after completed turn`);
759
- proc.process.kill("SIGTERM");
760
- }
761
- this.busyProcesses.delete(proc);
762
- }
763
- }
764
- getAnyProcess() {
765
- let proc = this.pool.getAny(this.getUnavailableProcesses());
766
- if (!proc) {
767
- proc = this.pool.spawn(this.defaultCwd);
768
- }
769
- return proc;
770
1005
  }
771
1006
  pushSnapshotToSubscribers(sessionPath, json, hash) {
772
1007
  for (const [ws, client] of this.clients) {
@@ -809,66 +1044,56 @@ export class WsHandler {
809
1044
  client.lastVersion = session.version;
810
1045
  }
811
1046
  }
812
- setupTurnEventForwarding(proc, sessionPath, ws, turnId) {
813
- const existingCleanup = this.procEventCleanup.get(proc);
814
- if (existingCleanup) {
815
- existingCleanup();
816
- this.procEventCleanup.delete(proc);
817
- }
818
- // Capture the SessionJsonl object — if it gets deleted (detach),
819
- // the handler becomes a no-op because we check for it.
820
- const sessionRef = this.attachedSessions.get(sessionPath);
821
- if (!sessionRef) {
822
- console.error(`[turn] setupTurnEventForwarding called but no SessionJsonl for ${sessionPath}`);
823
- 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}`);
824
1051
  }
825
- const lineHandler = (line) => {
826
- let data;
827
- try {
828
- data = JSON.parse(line);
829
- }
830
- catch {
831
- return;
832
- }
833
- if (data.type === "response" && data.id)
834
- return;
835
- // Guard: if the attached session was deleted (turn ended),
836
- // this handler is stale — skip.
837
- const currentSession = this.attachedSessions.get(sessionPath);
838
- 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")
839
1062
  return;
840
- // Apply event to the in-memory attached session
841
- let changed = currentSession.applyEvent(data);
842
- // After auto-compaction, the pi process rewrites the JSONL and calls
843
- // replaceMessages() internally. SessionJsonl doesn't know about this,
844
- // so re-read the session from disk to pick up the compacted state.
845
- if (data.type === "auto_compaction_end" && data.result) {
846
- const { state } = readSessionFromDisk(sessionPath);
847
- currentSession.replaceMessages(state.messages);
848
- changed = true;
849
- }
850
- // Side-channel raw event for UI hooks (canvas/jsonl), not state updates
851
- if (ws.readyState === WebSocket.OPEN) {
852
- ws.send(JSON.stringify({ type: "agent_event", sessionPath, event: data }));
853
- }
854
- if (data.type === "message_end" && data.message?.role === "user") {
855
- const text = typeof data.message.content === "string"
856
- ? data.message.content
857
- : (data.message.content || []).filter((c) => c.type === "text").map((c) => c.text).join(" ");
858
- this.lifecycle.dequeueSteering(sessionPath, text);
859
- }
860
- // Push update to all subscribed clients
861
- if (changed) {
862
- this.pushUpdateToSubscribers(sessionPath, currentSession);
863
- }
864
- if (data.type === "agent_end") {
865
- debugTurn("agent_end_received", { turnId, procId: proc.id, sessionPath });
866
- this.lifecycle.clearSteering(sessionPath);
867
- this.releaseProcess(sessionPath);
868
- }
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,
869
1097
  };
870
- proc.rl.on("line", lineHandler);
871
- const cleanup = () => proc.rl.removeListener("line", lineHandler);
872
- this.procEventCleanup.set(proc, cleanup);
873
1098
  }
874
1099
  }