pipane 0.1.5 → 0.1.7

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 (138) hide show
  1. package/README.md +45 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/bin/pipane-rendezvous.js +21 -0
  4. package/bin/pipane.js +21 -1
  5. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  65. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  66. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  67. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  68. package/dist/client/assets/index-iblQYBAl.css +1 -0
  69. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  70. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  71. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  72. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  73. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  74. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  75. package/dist/client/index.html +2 -2
  76. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  77. package/dist/server/rendezvous/server.js +247 -0
  78. package/dist/server/rendezvous/trust-store.js +432 -0
  79. package/dist/server/server/auth-guard.js +61 -0
  80. package/dist/server/server/backend-connection-authorizer.js +29 -0
  81. package/dist/server/server/backend-identity.js +167 -0
  82. package/dist/server/server/backend-protocol-handler.js +132 -0
  83. package/dist/server/server/backend-trust-store.js +157 -0
  84. package/dist/server/server/backend-webrtc.js +239 -0
  85. package/dist/server/server/bundled-extensions.js +6 -0
  86. package/dist/server/server/conversation-file-access.js +97 -0
  87. package/dist/server/server/extension-status.js +31 -0
  88. package/dist/server/server/frame-connection.js +48 -0
  89. package/dist/server/server/frame-router.js +45 -0
  90. package/dist/server/server/ice-servers.js +24 -0
  91. package/dist/server/server/local-backend-api.js +389 -0
  92. package/dist/server/server/local-settings.js +28 -0
  93. package/dist/server/server/pi-rpc-protocol.js +407 -0
  94. package/dist/server/server/pi-runtime.js +1 -1
  95. package/dist/server/server/process-pool.js +179 -136
  96. package/dist/server/server/rendezvous-client.js +282 -0
  97. package/dist/server/server/rest-api.js +121 -231
  98. package/dist/server/server/server.js +188 -134
  99. package/dist/server/server/session-actor.js +202 -0
  100. package/dist/server/server/session-index.js +111 -18
  101. package/dist/server/server/session-jsonl.js +128 -8
  102. package/dist/server/server/session-path.js +145 -0
  103. package/dist/server/server/session-registry.js +67 -0
  104. package/dist/server/server/update-api.js +28 -0
  105. package/dist/server/server/update-check.js +33 -13
  106. package/dist/server/server/update-manager.js +231 -0
  107. package/dist/server/server/worktree-name.js +124 -0
  108. package/dist/server/server/ws-handler.js +784 -478
  109. package/dist/server/shared/backend-api.js +1 -0
  110. package/dist/server/shared/backend-protocol.js +164 -0
  111. package/dist/server/shared/jsonl-sync.js +41 -0
  112. package/dist/server/shared/node-trust-crypto.js +61 -0
  113. package/dist/server/shared/rendezvous-protocol.js +243 -0
  114. package/dist/server/shared/rpc-timeouts.js +4 -0
  115. package/dist/server/shared/thinking-levels.js +94 -0
  116. package/dist/server/shared/tool-runtime.js +1 -0
  117. package/dist/server/shared/trust-protocol.js +97 -0
  118. package/dist/server/shared/updates.js +4 -0
  119. package/dist/server/shared/ws-protocol.js +473 -0
  120. package/docs/protocol.md +127 -0
  121. package/extensions/canvas.ts +1 -1
  122. package/package.json +42 -17
  123. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  124. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  125. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  126. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  127. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  128. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  129. package/dist/client/assets/index-BtDAXAo1.css +0 -1
  130. package/dist/client/assets/index-CAQqlK61.js +0 -131
  131. package/dist/client/assets/index-CJEeBmBj.js +0 -3
  132. package/dist/client/assets/index-DUEhKmPQ.js +0 -1
  133. package/dist/client/assets/index-XEVKscP-.js +0 -4443
  134. package/dist/client/assets/index-woI4agCy.js +0 -45
  135. package/dist/server/server/attached-session.js +0 -209
  136. package/dist/server/server/load-trace-store.js +0 -48
  137. package/dist/server/server/session-lifecycle.js +0 -155
  138. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
@@ -2,22 +2,24 @@
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
- import { WebSocket } from "ws";
11
+ import { FRAME_CONNECTION_OPEN } from "./frame-connection.js";
13
12
  import { copyFile } from "node:fs/promises";
14
- import { existsSync } from "node:fs";
13
+ import { constants as fsConstants, 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 { SessionJsonl, readSessionFromDisk, getSessionFileSize, serializeSessionState, } from "./session-jsonl.js";
19
16
  import { getSessionCwd } from "./session-cwd.js";
20
17
  import { checkCommandAvailable, installPiGlobal, isPiInstallable, makePiNotFoundMessage } from "./pi-runtime.js";
18
+ import { modelsMatch, toCompactModelRef } from "../shared/thinking-levels.js";
19
+ import { COMPACT_RPC_TIMEOUT_MS } from "../shared/rpc-timeouts.js";
20
+ import { assertNever, decodeClientCommand, encodeServerMessage, } from "../shared/ws-protocol.js";
21
+ import { SessionPathError, SessionPathGuard } from "./session-path.js";
22
+ import { extensionStatusSnapshot, isValidExtensionStatusKey, normalizeExtensionStatusText, providerForUsageStatus, PROVIDER_USAGE_STATUS_KEY, } from "./extension-status.js";
21
23
  let nextTurnId = 0;
22
24
  function makeTurnId() {
23
25
  return `turn_${Date.now()}_${++nextTurnId}`;
@@ -26,41 +28,40 @@ function debugTurn(stage, data) {
26
28
  console.log(`[turn] ${stage} ${JSON.stringify(data)}`);
27
29
  }
28
30
  export class WsHandler {
29
- lifecycle;
31
+ registry;
30
32
  pool;
33
+ sessionPaths;
31
34
  defaultCwd;
32
35
  piLaunch;
33
36
  ensurePool;
34
37
  isRequestAuthorized;
35
- traceStore;
36
38
  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();
39
+ /** Last known extension statuses, retained while a session is detached. */
40
+ extensionStatusesBySession = new Map();
41
+ /** Latest successful account-wide subscription usage from any process. */
42
+ providerUsageStatuses = new Map();
43
+ /** Statuses emitted while a process is switching to a not-yet-attached session. */
44
+ pendingExtensionStatuses = new WeakMap();
47
45
  /**
48
46
  * Track file sizes for detached sessions that clients are subscribed to.
49
47
  * Used for change detection when the file watcher fires.
50
48
  */
51
49
  subscribedFileSizes = new Map();
50
+ /** Last revision/hash published for each authoritative session state. */
51
+ sessionRevisions = new Map();
52
52
  piAvailable;
53
53
  piInstalling = false;
54
54
  constructor(options) {
55
- this.lifecycle = options.lifecycle;
55
+ this.registry = options.registry;
56
56
  this.pool = options.pool;
57
+ this.sessionPaths = options.sessionPaths ?? new SessionPathGuard();
57
58
  this.defaultCwd = options.defaultCwd;
58
59
  this.piLaunch = options.piLaunch;
59
60
  this.ensurePool = options.ensurePool;
60
61
  this.isRequestAuthorized = options.isRequestAuthorized;
61
- this.traceStore = options.traceStore;
62
62
  this.piAvailable = checkCommandAvailable(this.piLaunch.command);
63
- this.lifecycle.subscribe((event) => {
63
+ this.pool.subscribeEvents((proc, event) => this.handleProcessEvent(proc, event));
64
+ this.registry.subscribe((event) => {
64
65
  switch (event.type) {
65
66
  case "session_attached":
66
67
  this.broadcast({
@@ -77,11 +78,9 @@ export class WsHandler {
77
78
  });
78
79
  break;
79
80
  case "steering_queue_update": {
80
- const session = this.attachedSessions.get(event.sessionPath);
81
- if (session) {
82
- session.steeringQueue = [...event.queue];
81
+ const session = this.registry.find(event.sessionPath)?.session;
82
+ if (session)
83
83
  this.pushUpdateToSubscribers(event.sessionPath, session);
84
- }
85
84
  break;
86
85
  }
87
86
  }
@@ -90,23 +89,100 @@ export class WsHandler {
90
89
  get isPiAvailable() {
91
90
  return this.piAvailable;
92
91
  }
93
- recordTrace(traceId, source, kind, name, durationMs, attrs) {
94
- if (!traceId || !this.traceStore)
92
+ /** Idempotently clean up the owning actor after an RPC process exits. */
93
+ handleProcessExit(proc) {
94
+ this.pendingExtensionStatuses.delete(proc);
95
+ const actor = this.registry.getActorForProcess(proc);
96
+ if (!actor)
95
97
  return;
96
- this.traceStore.record(traceId, {
97
- ts: new Date().toISOString(),
98
- source,
99
- kind,
100
- name,
101
- durationMs,
102
- attrs,
98
+ console.log(`[pool] pi#${proc.id} crashed while attached to ${path.basename(actor.sessionPath)} — marking done`);
99
+ void actor.enqueue("process exit", () => {
100
+ if (actor.process !== proc)
101
+ return;
102
+ actor.markFailed();
103
+ // Without a final get_state, disk is safer than potentially stale
104
+ // in-memory controls (pi may have persisted a model change before crash).
105
+ this.releaseActor(actor, false);
103
106
  });
104
107
  }
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);
108
+ handleProcessEvent(proc, event) {
109
+ if (event.type !== "extension_ui_request" || event.method !== "setStatus")
110
+ return;
111
+ if (!isValidExtensionStatusKey(event.statusKey))
112
+ return;
113
+ const normalizedStatusText = typeof event.statusText === "string"
114
+ ? normalizeExtensionStatusText(event.statusText)
115
+ : undefined;
116
+ // Subscription usage belongs to the provider account, not a conversation.
117
+ // Capture successful values even from unattached prewarm processes so a
118
+ // virtual or never-run conversation can still display the latest quota.
119
+ if (event.statusKey === PROVIDER_USAGE_STATUS_KEY && normalizedStatusText) {
120
+ const provider = providerForUsageStatus(normalizedStatusText);
121
+ if (provider && this.providerUsageStatuses.get(provider) !== normalizedStatusText) {
122
+ this.providerUsageStatuses.set(provider, normalizedStatusText);
123
+ this.broadcast(this.makeProviderUsageMessage());
124
+ }
125
+ }
126
+ const sessionPath = this.registry.getActorForProcess(proc)?.sessionPath;
127
+ const statuses = sessionPath
128
+ ? (this.extensionStatusesBySession.get(sessionPath) ?? new Map())
129
+ : this.pendingExtensionStatuses.get(proc);
130
+ if (!statuses)
131
+ return;
132
+ if (sessionPath && !this.extensionStatusesBySession.has(sessionPath)) {
133
+ this.extensionStatusesBySession.set(sessionPath, statuses);
134
+ }
135
+ const previous = statuses.get(event.statusKey);
136
+ if (event.statusText === undefined) {
137
+ statuses.delete(event.statusKey);
138
+ }
139
+ else if (normalizedStatusText !== undefined) {
140
+ if (normalizedStatusText)
141
+ statuses.set(event.statusKey, normalizedStatusText);
142
+ else
143
+ statuses.delete(event.statusKey);
144
+ }
145
+ else {
146
+ return;
147
+ }
148
+ if (sessionPath && previous !== statuses.get(event.statusKey)) {
149
+ this.pushExtensionStatusesToSubscribers(sessionPath);
150
+ }
151
+ }
152
+ beginPendingExtensionStatusCapture(proc, sessionPath) {
153
+ const existing = sessionPath ? this.extensionStatusesBySession.get(sessionPath) : undefined;
154
+ this.pendingExtensionStatuses.set(proc, new Map(existing ?? []));
155
+ }
156
+ commitPendingExtensionStatuses(proc, sessionPath) {
157
+ const pending = this.pendingExtensionStatuses.get(proc);
158
+ this.pendingExtensionStatuses.delete(proc);
159
+ if (pending)
160
+ this.extensionStatusesBySession.set(sessionPath, pending);
161
+ else if (!this.extensionStatusesBySession.has(sessionPath)) {
162
+ this.extensionStatusesBySession.set(sessionPath, new Map());
163
+ }
164
+ this.pushExtensionStatusesToSubscribers(sessionPath);
165
+ }
166
+ makeExtensionStatusMessage(sessionPath) {
167
+ return {
168
+ type: "extension_status",
169
+ sessionPath,
170
+ statuses: extensionStatusSnapshot(this.extensionStatusesBySession.get(sessionPath)),
171
+ };
172
+ }
173
+ makeProviderUsageMessage() {
174
+ return {
175
+ type: "provider_usage",
176
+ statuses: Object.fromEntries(this.providerUsageStatuses),
177
+ };
178
+ }
179
+ pushExtensionStatusesToSubscribers(sessionPath) {
180
+ const message = encodeServerMessage(this.makeExtensionStatusMessage(sessionPath));
181
+ for (const [ws, client] of this.clients) {
182
+ if (client.subscribedSession !== sessionPath || ws.readyState !== FRAME_CONNECTION_OPEN)
183
+ continue;
184
+ ws.send(message);
185
+ }
110
186
  }
111
187
  /**
112
188
  * Called by the file watcher when a JSONL file changes on disk.
@@ -115,7 +191,7 @@ export class WsHandler {
115
191
  */
116
192
  notifySessionFileChanged(sessionPath) {
117
193
  // If the session is attached, ignore — streaming events are authoritative
118
- if (this.attachedSessions.has(sessionPath))
194
+ if (this.registry.find(sessionPath)?.isAttached)
119
195
  return;
120
196
  // Check if any client is subscribed to this session
121
197
  let hasSubscribers = false;
@@ -138,170 +214,179 @@ export class WsHandler {
138
214
  this.pushSnapshotToSubscribers(sessionPath, json, hash);
139
215
  }
140
216
  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,
217
+ const processes = this.pool.getAllProcesses().map((proc) => ({
218
+ id: proc.id,
219
+ pid: proc.process.pid ?? null,
220
+ alive: proc.process.exitCode === null,
221
+ exitCode: proc.process.exitCode,
222
+ cwd: proc.cwd,
223
+ busy: this.pool.isLeased(proc),
224
+ decommissioning: this.pool.isDecommissioning(proc),
225
+ attachedSession: this.registry.getActorForProcess(proc)?.sessionPath ?? null,
226
+ pendingRequests: proc.pendingRequests.size,
151
227
  }));
152
228
  return {
153
229
  now: new Date().toISOString(),
154
230
  totalProcesses: this.pool.totalProcesses,
155
- attachedSessionCount: this.lifecycle.attachedCount,
156
- sessionStatuses: this.lifecycle.getAllStatuses(),
157
- connectedWsOpen: Array.from(this.clients.keys()).filter((ws) => ws.readyState === WebSocket.OPEN).length,
231
+ attachedSessionCount: this.registry.attachedCount,
232
+ attachedSessionPaths: processes
233
+ .map((process) => process.attachedSession)
234
+ .filter((sessionPath) => sessionPath !== null),
235
+ sessionStatuses: this.registry.getAllStatuses(),
236
+ connectedWsOpen: Array.from(this.clients.keys()).filter((ws) => ws.readyState === FRAME_CONNECTION_OPEN).length,
158
237
  processes,
159
238
  };
160
239
  }
161
240
  register(wss) {
162
- wss.on("connection", (ws, req) => this.handleConnection(ws, req));
241
+ wss.on("connection", (ws, req) => {
242
+ if (!this.isRequestAuthorized(req)) {
243
+ ws.close(1008, "Unauthorized");
244
+ return;
245
+ }
246
+ this.acceptConnection(ws);
247
+ });
163
248
  }
164
- handleConnection(ws, req) {
165
- if (!this.isRequestAuthorized(req)) {
166
- ws.close(1008, "Unauthorized");
167
- return;
168
- }
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
- console.log("WebSocket client connected");
249
+ acceptAuthenticatedConnection(connection) {
250
+ this.acceptConnection(connection);
251
+ }
252
+ acceptConnection(ws) {
253
+ console.log("Frame client connected");
177
254
  this.clients.set(ws, {
178
255
  subscribedSession: null,
179
256
  lastVersion: 0,
180
257
  lastJson: "",
181
258
  lastHash: "",
182
259
  });
183
- ws.send(JSON.stringify({
260
+ this.sendMessage(ws, {
184
261
  type: "init",
185
- sessionStatuses: this.lifecycle.getAllStatuses(),
186
- steeringQueues: this.lifecycle.getAllSteeringQueues(),
187
- }));
262
+ sessionStatuses: this.registry.getAllStatuses(),
263
+ steeringQueues: this.registry.getAllSteeringQueues(),
264
+ providerUsageStatuses: this.makeProviderUsageMessage().statuses,
265
+ });
188
266
  if (!this.piAvailable) {
189
- ws.send(JSON.stringify({
267
+ this.sendMessage(ws, {
190
268
  type: "pi_install_required",
191
269
  command: this.piLaunch.command,
192
270
  installable: isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs),
193
271
  installing: this.piInstalling,
194
272
  message: makePiNotFoundMessage(this.piLaunch.command),
195
- }));
273
+ });
196
274
  }
197
275
  ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
198
276
  ws.on("close", () => {
199
- const wsTraceId = this.wsTraceIds.get(ws);
200
- this.recordTrace(wsTraceId, "backend", "instant", "ws connection close");
201
- console.log("WebSocket client disconnected");
277
+ console.log("Frame client disconnected");
202
278
  this.clients.delete(ws);
203
- this.wsTraceIds.delete(ws);
204
279
  });
205
280
  }
281
+ sendMessage(ws, payload) {
282
+ if (ws.readyState === FRAME_CONNECTION_OPEN)
283
+ ws.send(encodeServerMessage(payload));
284
+ }
285
+ sendSuccess(ws, id, command, data) {
286
+ this.sendMessage(ws, {
287
+ type: "response",
288
+ id,
289
+ command,
290
+ success: true,
291
+ data,
292
+ });
293
+ }
294
+ sendError(ws, id, command, error, code) {
295
+ this.sendMessage(ws, { type: "response", id, command, success: false, code, error });
296
+ }
297
+ notifySessionsChanged(file) {
298
+ this.broadcast({ type: "sessions_changed", file });
299
+ }
206
300
  broadcast(payload) {
301
+ const message = encodeServerMessage(payload);
207
302
  for (const ws of this.clients.keys()) {
208
- if (ws.readyState === WebSocket.OPEN) {
209
- ws.send(JSON.stringify(payload));
210
- }
303
+ if (ws.readyState === FRAME_CONNECTION_OPEN)
304
+ ws.send(message);
211
305
  }
212
306
  }
213
307
  async handleMessage(ws, raw) {
214
- let command;
215
- try {
216
- command = JSON.parse(raw);
217
- }
218
- catch {
219
- ws.send(JSON.stringify({ type: "response", command: "parse", success: false, error: "Invalid JSON" }));
308
+ const decoded = decodeClientCommand(raw);
309
+ if (!decoded.ok) {
310
+ this.sendError(ws, decoded.error.requestId, decoded.error.command, decoded.error.message, decoded.error.code);
220
311
  return;
221
312
  }
313
+ const command = decoded.value;
222
314
  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
315
  try {
227
316
  if (!this.piAvailable && command.type !== "install_pi" && command.type !== "get_session_statuses") {
228
- ws.send(JSON.stringify({
317
+ this.sendMessage(ws, {
229
318
  type: "pi_install_required",
230
319
  command: this.piLaunch.command,
231
320
  installable: isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs),
232
321
  installing: this.piInstalling,
233
322
  message: makePiNotFoundMessage(this.piLaunch.command),
234
- }));
235
- if (id) {
236
- ws.send(JSON.stringify({
237
- id, type: "response", command: command.type, success: false,
238
- error: makePiNotFoundMessage(this.piLaunch.command),
239
- }));
240
- }
323
+ });
324
+ this.sendError(ws, id, command.type, makePiNotFoundMessage(this.piLaunch.command), "command_failed");
241
325
  return;
242
326
  }
243
327
  switch (command.type) {
244
328
  case "install_pi":
245
- await this.handleInstallPi(ws, id);
329
+ await this.handleInstallPi(ws, command);
246
330
  break;
247
331
  case "subscribe_session":
248
- this.handleSubscribeSession(ws, id, command);
332
+ this.handleSubscribeSession(ws, command);
249
333
  break;
250
334
  case "prompt":
251
- await this.handlePrompt(ws, id, command);
335
+ await this.handlePrompt(ws, command);
252
336
  break;
253
337
  case "steer":
254
- await this.handleSteer(ws, id, command);
338
+ await this.handleSteer(ws, command);
255
339
  break;
256
340
  case "remove_steering":
257
- await this.handleRemoveSteering(ws, id, command);
341
+ await this.handleRemoveSteering(ws, command);
258
342
  break;
259
343
  case "abort":
260
- await this.handleAbort(ws, id, command);
344
+ await this.handleAbort(ws, command);
345
+ break;
346
+ case "hard_kill":
347
+ await this.handleHardKill(ws, command);
261
348
  break;
262
349
  case "compact":
263
- await this.handleCompact(ws, id, command);
350
+ await this.handleCompact(ws, command);
264
351
  break;
265
352
  case "get_available_models":
266
- await this.handleGetAvailableModels(ws, id);
353
+ await this.handleGetAvailableModels(ws, command);
267
354
  break;
268
355
  case "get_default_model":
269
- await this.handleGetDefaultModel(ws, id);
356
+ await this.handleGetDefaultModel(ws, command);
270
357
  break;
271
358
  case "get_session_statuses":
272
- this.handleGetSessionStatuses(ws, id);
359
+ this.handleGetSessionStatuses(ws, command);
360
+ break;
361
+ case "get_session_stats":
362
+ await this.handleGetSessionStats(ws, command);
273
363
  break;
274
364
  case "fork":
275
- await this.handleFork(ws, id, command);
365
+ await this.handleFork(ws, command);
276
366
  break;
277
367
  case "fork_prompt":
278
- await this.handleForkPrompt(ws, id, command);
368
+ await this.handleForkPrompt(ws, command);
279
369
  break;
280
370
  case "set_session_name":
281
- await this.handleSetSessionName(ws, id, command);
371
+ await this.handleSetSessionName(ws, command);
282
372
  break;
283
373
  case "get_commands":
284
- await this.handleGetCommands(ws, id);
374
+ await this.handleGetCommands(ws, command);
285
375
  break;
286
376
  case "reload_processes":
287
- await this.handleReloadProcesses(ws, id);
377
+ await this.handleReloadProcesses(ws, command);
288
378
  break;
289
379
  default:
290
- ws.send(JSON.stringify({
291
- id, type: "response", command: command.type, success: false,
292
- error: `Unknown command: ${command.type}`,
293
- }));
380
+ assertNever(command);
294
381
  }
295
382
  }
296
- catch (err) {
297
- debugTurn("command_error", { commandType: command?.type, requestId: id, error: err?.message });
298
- 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;
383
+ catch (error) {
384
+ const message = error instanceof Error ? error.message : String(error);
385
+ debugTurn("command_error", { commandType: command.type, requestId: id, error: message });
386
+ this.sendError(ws, id, command.type, message, "command_failed");
301
387
  }
302
- this.recordTrace(traceId, "backend", "span", `ws command ${command.type}`, Number((performance.now() - commandStart).toFixed(2)), { success: true });
303
388
  }
304
- async handleInstallPi(ws, id) {
389
+ async handleInstallPi(ws, command) {
305
390
  const installable = isPiInstallable(this.piLaunch.command, this.piLaunch.baseArgs);
306
391
  if (!installable) {
307
392
  throw new Error(`Automatic install not supported for command '${this.piLaunch.command}'. Set PI_CLI or install manually.`);
@@ -324,36 +409,51 @@ export class WsHandler {
324
409
  console.log("[pi] pi installed successfully");
325
410
  this.ensurePool();
326
411
  }
327
- ws.send(JSON.stringify({ id, type: "response", command: "install_pi", success: true, data: {} }));
412
+ this.sendSuccess(ws, command.id, "install_pi", {});
328
413
  }
329
- handleSubscribeSession(ws, id, command) {
414
+ handleSubscribeSession(ws, command) {
330
415
  const client = this.clients.get(ws);
331
416
  if (!client)
332
417
  return;
333
- const sessionPath = command.sessionPath;
334
- if (!sessionPath) {
418
+ const requestedPath = command.sessionPath;
419
+ if (!requestedPath) {
335
420
  client.subscribedSession = null;
336
421
  client.lastVersion = 0;
337
422
  client.lastJson = "";
338
423
  client.lastHash = "";
339
- ws.send(JSON.stringify({ id, type: "response", command: "subscribe_session", success: true, data: {} }));
424
+ this.sendSuccess(ws, command.id, "subscribe_session", {});
340
425
  return;
341
426
  }
427
+ let sessionPath;
428
+ try {
429
+ sessionPath = this.sessionPaths.resolveExisting(requestedPath);
430
+ }
431
+ catch (error) {
432
+ // Pi allocates a new path before its first prompt flushes the file. The
433
+ // actor proves that such a confined pending path belongs to this server.
434
+ if (!(error instanceof SessionPathError) || error.code !== "not_found")
435
+ throw error;
436
+ const pendingPath = this.sessionPaths.resolvePending(requestedPath);
437
+ if (!this.registry.find(pendingPath)?.isAttached)
438
+ throw error;
439
+ sessionPath = pendingPath;
440
+ }
342
441
  client.subscribedSession = sessionPath;
343
- // If the session is attached, send from in-memory state
344
- const attached = this.attachedSessions.get(sessionPath);
442
+ // If the session is attached, send from its actor-owned in-memory state
443
+ const attached = this.registry.find(sessionPath)?.session;
345
444
  if (attached) {
346
445
  // Send full sync
347
446
  client.lastJson = attached.json;
348
447
  client.lastHash = attached.hash;
349
448
  client.lastVersion = attached.version;
350
- ws.send(JSON.stringify({
449
+ this.sendMessage(ws, {
351
450
  type: "session_sync",
352
451
  sessionPath,
452
+ revision: this.revisionForState(sessionPath, attached.hash),
353
453
  op: "full",
354
454
  data: attached.json,
355
455
  hash: attached.hash,
356
- }));
456
+ });
357
457
  }
358
458
  else {
359
459
  // Detached — read from disk
@@ -363,288 +463,492 @@ export class WsHandler {
363
463
  client.lastVersion = 0;
364
464
  // Track file size for change detection
365
465
  this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
366
- ws.send(JSON.stringify({
466
+ this.sendMessage(ws, {
367
467
  type: "session_sync",
368
468
  sessionPath,
469
+ revision: this.revisionForState(sessionPath, hash),
369
470
  op: "full",
370
471
  data: json,
371
472
  hash,
372
- }));
473
+ });
373
474
  }
374
- ws.send(JSON.stringify({ id, type: "response", command: "subscribe_session", success: true, data: {} }));
475
+ // Extension status is a separate, authoritative snapshot so reconnects
476
+ // and session switches replace rather than merge stale client state.
477
+ this.sendMessage(ws, this.makeExtensionStatusMessage(sessionPath));
478
+ this.sendSuccess(ws, command.id, "subscribe_session", {});
375
479
  }
376
- async handlePrompt(ws, id, command) {
377
- let sessionPath = command.sessionPath;
378
- if (!sessionPath)
480
+ async handlePrompt(ws, command) {
481
+ const requestedPath = command.sessionPath;
482
+ if (!requestedPath)
379
483
  throw new Error("Missing sessionPath");
484
+ let sessionPath = requestedPath === "__new__"
485
+ ? requestedPath
486
+ : this.sessionPaths.resolveExisting(requestedPath);
380
487
  const turnId = makeTurnId();
381
488
  debugTurn("prompt_start", { turnId, sessionPath, hasModel: !!command.model });
489
+ let actor;
382
490
  let proc;
491
+ let unownedLease;
492
+ let generation;
383
493
  try {
494
+ let newSessionCwd;
384
495
  if (sessionPath === "__new__") {
385
- const cwd = command.cwd || this.defaultCwd;
386
- proc = await this.acquireProcess(cwd);
496
+ newSessionCwd = command.cwd || this.defaultCwd;
497
+ unownedLease = await this.acquireProcess(newSessionCwd);
498
+ proc = unownedLease.process;
387
499
  await this.pool.waitForReady(proc);
388
- await this.pool.sendRpc(proc, { type: "new_session" });
389
- const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
390
- sessionPath = stateResp.data?.sessionFile;
391
- if (!sessionPath)
500
+ this.beginPendingExtensionStatusCapture(proc);
501
+ await this.replacePiSession(proc, { type: "new_session" }, "new_session");
502
+ const stateResp = await this.pool.sendRpcChecked(proc, { type: "get_state" });
503
+ const createdPath = stateResp.data.sessionFile;
504
+ if (!createdPath)
392
505
  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
- }
407
- if (!command.model) {
408
- throw new Error(`BUG: prompt command received without model. sessionPath=${sessionPath}`);
506
+ sessionPath = this.sessionPaths.resolvePending(createdPath);
409
507
  }
410
- await this.pool.sendRpcChecked(proc, {
411
- type: "set_model",
412
- provider: command.model.provider,
413
- modelId: command.model.modelId,
508
+ actor = this.registry.get(sessionPath);
509
+ const start = await actor.enqueue("prompt start", async () => {
510
+ // A second prompt that arrived during startup observes the committed
511
+ // actor phase and becomes steering rather than a second turn owner.
512
+ if (actor.isTurnActive) {
513
+ await this.sendSteering(actor, command.message);
514
+ return undefined;
515
+ }
516
+ actor.assertAvailable("start prompt");
517
+ if (unownedLease) {
518
+ proc = unownedLease.process;
519
+ actor.attach(unownedLease, this.createSessionState(sessionPath));
520
+ unownedLease = undefined;
521
+ this.commitPendingExtensionStatuses(proc, sessionPath);
522
+ this.sendMessage(ws, {
523
+ type: "session_attached",
524
+ sessionPath,
525
+ cwd: newSessionCwd,
526
+ firstMessage: command.message,
527
+ });
528
+ }
529
+ else {
530
+ proc = await this.acquireForActor(actor);
531
+ }
532
+ generation = actor.beginTurn();
533
+ const observer = this.setupTurnEventForwarding(actor, proc, generation, turnId);
534
+ await this.applyRequestedControlState(proc, actor, ws, command);
535
+ const response = await this.pool.sendRpc(proc, {
536
+ type: "prompt",
537
+ message: command.message,
538
+ ...(command.images?.length ? { images: command.images } : {}),
539
+ });
540
+ if (!response.success)
541
+ throw new Error(response.error);
542
+ await this.reconcileEffectiveControlState(proc, actor);
543
+ return { observer, response, generation };
414
544
  });
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;
545
+ if (!start) {
546
+ this.sendSuccess(ws, command.id, "prompt", { newSessionPath: sessionPath });
547
+ return;
427
548
  }
428
- const response = await this.pool.sendRpc(proc, promptCmd);
429
- const enriched = { ...response };
430
- if (!enriched.data)
431
- enriched.data = {};
432
- enriched.data.newSessionPath = sessionPath;
433
- ws.send(JSON.stringify({ ...enriched, id, command: "prompt" }));
549
+ await this.waitForPromptSettlement(proc, start.observer);
550
+ await actor.enqueue("prompt settlement", async () => {
551
+ if (!actor.owns(proc, start.generation))
552
+ return;
553
+ await this.reconcileEffectiveControlState(proc, actor);
554
+ this.releaseActor(actor);
555
+ });
556
+ this.sendSuccess(ws, command.id, "prompt", { newSessionPath: sessionPath });
434
557
  }
435
- 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);
440
- if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
558
+ catch (error) {
559
+ if (proc)
560
+ this.pendingExtensionStatuses.delete(proc);
561
+ unownedLease?.release();
562
+ if (actor && proc && actor.process === proc) {
563
+ let detailed = this.buildPromptFailureMessage(error, proc, actor);
564
+ await actor.enqueue("prompt failure", () => {
565
+ if (actor.process !== proc)
566
+ return;
567
+ detailed = this.buildPromptFailureMessage(error, proc, actor);
568
+ actor.markFailed();
569
+ this.injectSessionError(actor, detailed);
570
+ this.releaseActor(actor);
571
+ });
572
+ const rawMessage = error instanceof Error ? error.message : String(error);
573
+ if (rawMessage.includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
441
574
  proc.process.kill("SIGTERM");
442
575
  }
443
576
  throw new Error(detailed);
444
577
  }
445
- if (proc) {
446
- this.busyProcesses.delete(proc);
447
- if (proc.process.exitCode === null)
448
- proc.process.kill("SIGTERM");
449
- }
450
- throw err;
578
+ if (proc && proc.process.exitCode === null)
579
+ proc.process.kill("SIGTERM");
580
+ throw error;
451
581
  }
452
582
  }
453
- async handleSteer(ws, id, command) {
583
+ async handleSteer(ws, command) {
454
584
  const sessionPath = command.sessionPath;
455
585
  if (!sessionPath)
456
586
  throw new Error("Missing sessionPath");
457
- const proc = this.lifecycle.getAttachedProcess(sessionPath);
458
- if (!proc)
587
+ const actor = this.registry.find(sessionPath);
588
+ if (!actor)
459
589
  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 });
462
- ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
590
+ await actor.enqueue("steer", () => this.sendSteering(actor, command.message));
591
+ this.sendSuccess(ws, command.id, "steer", {});
463
592
  }
464
- async handleRemoveSteering(ws, id, command) {
593
+ async handleRemoveSteering(ws, command) {
465
594
  const sessionPath = command.sessionPath;
466
595
  if (!sessionPath)
467
596
  throw new Error("Missing sessionPath");
468
597
  const index = command.index;
469
598
  if (typeof index !== "number")
470
599
  throw new Error("Missing index");
471
- this.lifecycle.removeSteeringByIndex(sessionPath, index);
472
- ws.send(JSON.stringify({ id, type: "response", command: "remove_steering", success: true }));
600
+ const actor = this.registry.find(sessionPath);
601
+ if (actor)
602
+ await actor.enqueue("remove steering", () => actor.removeSteeringByIndex(index));
603
+ this.sendSuccess(ws, command.id, "remove_steering", {});
473
604
  }
474
- async handleAbort(ws, id, command) {
605
+ async handleAbort(ws, command) {
475
606
  const sessionPath = command.sessionPath;
476
- const proc = sessionPath ? this.lifecycle.getAttachedProcess(sessionPath) : undefined;
477
- if (proc) {
478
- await this.pool.sendRpc(proc, { type: "abort" });
607
+ const actor = sessionPath ? this.registry.find(sessionPath) : undefined;
608
+ if (actor) {
609
+ await actor.enqueue("abort", async () => {
610
+ if (actor.isTurnActive && actor.process) {
611
+ await this.pool.sendRpc(actor.process, { type: "abort" });
612
+ }
613
+ });
479
614
  }
480
- ws.send(JSON.stringify({ id, type: "response", command: "abort", success: true }));
615
+ this.sendSuccess(ws, command.id, "abort", {});
481
616
  }
482
- async handleCompact(ws, id, command) {
617
+ async handleHardKill(ws, command) {
483
618
  const sessionPath = command.sessionPath;
484
619
  if (!sessionPath)
485
620
  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);
489
- ws.send(JSON.stringify({ ...response, id, command: "compact" }));
490
- }
491
- 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" }));
495
- }
496
- 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" }));
500
- }
501
- 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;
621
+ const actor = this.registry.find(sessionPath);
622
+ let hadProcess = false;
623
+ let killed = false;
624
+ if (actor) {
625
+ await actor.enqueue("hard kill", () => {
626
+ const proc = actor.process;
627
+ if (!proc)
628
+ return;
629
+ hadProcess = true;
630
+ killed = this.pool.forceKill(proc);
631
+ actor.markFailed();
632
+ this.releaseActor(actor, false);
633
+ });
634
+ }
635
+ if (hadProcess)
636
+ this.ensurePool();
637
+ this.sendSuccess(ws, command.id, "hard_kill", killed
638
+ ? { killed: true }
639
+ : { killed: false, reason: hadProcess ? "signal_failed" : "not_attached" });
640
+ }
641
+ async handleCompact(ws, command) {
642
+ const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
643
+ const actor = this.registry.get(sessionPath);
644
+ const response = await actor.enqueue("compact", async () => {
645
+ actor.assertAvailable("compact");
646
+ const proc = await this.acquireForActor(actor);
647
+ try {
648
+ // Pi returns only after the summarization model call and session write.
649
+ // The generic 30s RPC timeout can expire while compaction is healthy,
650
+ // causing its eventual response and compaction_end event to be orphaned.
651
+ return await this.pool.sendRpc(proc, { type: "compact", customInstructions: command.customInstructions }, COMPACT_RPC_TIMEOUT_MS);
517
652
  }
518
- // Idle/unattached process: terminate immediately.
519
- const cleanup = this.procEventCleanup.get(proc);
520
- if (cleanup) {
521
- cleanup();
522
- this.procEventCleanup.delete(proc);
653
+ finally {
654
+ this.releaseActor(actor);
523
655
  }
524
- this.busyProcesses.delete(proc);
525
- this.decommissionProcesses.delete(proc);
526
- proc.process.kill("SIGTERM");
527
- killed += 1;
656
+ });
657
+ if (!response.success)
658
+ throw new Error(response.error);
659
+ this.sendSuccess(ws, command.id, "compact", { ...response.data });
660
+ }
661
+ async handleGetAvailableModels(ws, command) {
662
+ const lease = await this.acquireAnyProcess();
663
+ try {
664
+ const response = await this.pool.sendRpcChecked(lease.process, { type: "get_available_models" });
665
+ this.sendSuccess(ws, command.id, "get_available_models", {
666
+ models: response.data.models,
667
+ });
528
668
  }
669
+ finally {
670
+ lease.release();
671
+ }
672
+ }
673
+ async handleGetCommands(ws, command) {
674
+ let cwd = command.cwd || this.defaultCwd;
675
+ if (command.sessionPath) {
676
+ const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
677
+ const sessionCwd = getSessionCwd(sessionPath);
678
+ cwd = sessionCwd && existsSync(sessionCwd) ? sessionCwd : this.defaultCwd;
679
+ }
680
+ // Project prompts and skills are loaded when Pi starts, so command discovery
681
+ // must use a process for the conversation's cwd rather than any idle worker.
682
+ const lease = await this.acquireProcess(cwd);
683
+ try {
684
+ const response = await this.pool.sendRpcChecked(lease.process, { type: "get_commands" });
685
+ this.sendSuccess(ws, command.id, "get_commands", {
686
+ commands: response.data.commands,
687
+ });
688
+ }
689
+ finally {
690
+ lease.release();
691
+ }
692
+ }
693
+ async handleReloadProcesses(ws, command) {
694
+ const { killed, draining } = this.pool.decommissionAll();
529
695
  this.ensurePool();
530
- ws.send(JSON.stringify({
531
- id,
532
- type: "response",
533
- command: "reload_processes",
534
- success: true,
535
- data: { killed, draining },
536
- }));
696
+ this.sendSuccess(ws, command.id, "reload_processes", { killed, draining });
537
697
  }
538
- 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 } }));
698
+ async handleGetDefaultModel(ws, command) {
699
+ const lease = await this.acquireAnyProcess();
700
+ try {
701
+ const stateResp = await this.pool.sendRpcChecked(lease.process, { type: "get_state" });
702
+ const model = stateResp.data.model ?? null;
703
+ const thinkingLevel = stateResp.data.thinkingLevel;
704
+ this.sendSuccess(ws, command.id, "get_default_model", {
705
+ model: model,
706
+ thinkingLevel,
707
+ });
708
+ }
709
+ finally {
710
+ lease.release();
711
+ }
544
712
  }
545
- handleGetSessionStatuses(ws, id) {
546
- ws.send(JSON.stringify({ id, type: "response", command: "get_session_statuses", success: true, data: { statuses: this.lifecycle.getAllStatuses() } }));
713
+ handleGetSessionStatuses(ws, command) {
714
+ this.sendSuccess(ws, command.id, "get_session_statuses", { statuses: this.registry.getAllStatuses() });
547
715
  }
548
- async handleFork(ws, id, command) {
549
- const sessionPath = command.sessionPath;
550
- if (!sessionPath)
551
- throw new Error("Missing sessionPath");
716
+ async handleGetSessionStats(ws, command) {
717
+ const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
718
+ const actor = this.registry.get(sessionPath);
719
+ const response = await actor.enqueue("get session stats", async () => {
720
+ const wasAttached = actor.process !== undefined;
721
+ const proc = await this.acquireForActor(actor);
722
+ try {
723
+ return await this.pool.sendRpcChecked(proc, { type: "get_session_stats" });
724
+ }
725
+ finally {
726
+ // A running turn owns its process lease. Only release a lease acquired
727
+ // temporarily to inspect a detached session.
728
+ if (!wasAttached)
729
+ this.releaseActor(actor);
730
+ }
731
+ });
732
+ this.sendSuccess(ws, command.id, "get_session_stats", {
733
+ ...response.data,
734
+ sessionFile: response.data.sessionFile ?? sessionPath,
735
+ });
736
+ }
737
+ async handleFork(ws, command) {
738
+ const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
552
739
  const entryId = command.entryId;
553
740
  if (!entryId)
554
741
  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);
560
- ws.send(JSON.stringify({
561
- id, type: "response", command: "fork", success: true,
562
- data: {
563
- text: response.data?.text ?? "",
564
- cancelled: response.data?.cancelled ?? false,
565
- newSessionPath: newSessionPath ?? null,
566
- },
567
- }));
742
+ const actor = this.registry.get(sessionPath);
743
+ const result = await actor.enqueue("fork", async () => {
744
+ actor.assertAvailable("fork");
745
+ const proc = await this.acquireForActor(actor);
746
+ try {
747
+ const response = await this.pool.sendRpcChecked(proc, { type: "fork", entryId });
748
+ const stateResp = await this.pool.sendRpcChecked(proc, { type: "get_state" });
749
+ const returnedPath = stateResp.data.sessionFile;
750
+ return {
751
+ response,
752
+ newSessionPath: returnedPath
753
+ ? this.sessionPaths.resolveExisting(returnedPath)
754
+ : undefined,
755
+ };
756
+ }
757
+ finally {
758
+ this.releaseActor(actor);
759
+ }
760
+ });
761
+ this.sendSuccess(ws, command.id, "fork", {
762
+ text: result.response.data.text,
763
+ cancelled: result.response.data.cancelled,
764
+ newSessionPath: result.newSessionPath ?? null,
765
+ });
568
766
  }
569
- async handleForkPrompt(ws, id, command) {
570
- const sessionPath = command.sessionPath;
571
- if (!sessionPath)
572
- throw new Error("Missing sessionPath");
767
+ async handleForkPrompt(ws, command) {
768
+ const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
573
769
  const message = command.message;
574
770
  if (!message)
575
771
  throw new Error("Missing message");
576
- const sessionsDir = path.join(getAgentDir(), "sessions");
577
772
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
578
773
  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);
774
+ let newSessionPath = this.sessionPaths.createPath(`${timestamp}_${newId}.jsonl`);
775
+ const sourceActor = this.registry.get(sessionPath);
776
+ await sourceActor.enqueue("fork prompt source", async () => {
777
+ sourceActor.assertAvailable("fork and prompt");
778
+ await copyFile(sessionPath, newSessionPath, fsConstants.COPYFILE_EXCL);
779
+ newSessionPath = this.sessionPaths.resolveExisting(newSessionPath);
780
+ });
582
781
  const forkCwd = getSessionCwd(sessionPath);
583
782
  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);
783
+ const actor = this.registry.get(newSessionPath);
784
+ let proc;
785
+ let unownedLease;
587
786
  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,
787
+ const start = await actor.enqueue("fork prompt start", async () => {
788
+ actor.assertAvailable("fork and prompt");
789
+ unownedLease = await this.acquireProcess(cwd);
790
+ proc = unownedLease.process;
791
+ await this.pool.waitForReady(proc);
792
+ this.beginPendingExtensionStatusCapture(proc);
793
+ await this.replacePiSession(proc, { type: "switch_session", sessionPath: newSessionPath }, "switch_session");
794
+ actor.attach(unownedLease, this.createSessionState(newSessionPath));
795
+ unownedLease = undefined;
796
+ this.commitPendingExtensionStatuses(proc, newSessionPath);
797
+ this.sendMessage(ws, { type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message });
798
+ const generation = actor.beginTurn();
799
+ const observer = this.setupTurnEventForwarding(actor, proc, generation, makeTurnId());
800
+ await this.applyRequestedControlState(proc, actor, ws, command);
801
+ await this.pool.sendRpcChecked(proc, {
802
+ type: "prompt",
803
+ message,
804
+ ...(command.images?.length ? { images: command.images } : {}),
805
+ });
806
+ await this.reconcileEffectiveControlState(proc, actor);
807
+ return { observer, generation };
600
808
  });
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
- ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
809
+ await this.waitForPromptSettlement(proc, start.observer);
810
+ await actor.enqueue("fork prompt settlement", async () => {
811
+ if (!actor.owns(proc, start.generation))
812
+ return;
813
+ await this.reconcileEffectiveControlState(proc, actor);
814
+ this.releaseActor(actor);
815
+ });
816
+ this.sendSuccess(ws, command.id, "fork_prompt", { newSessionPath });
617
817
  }
618
- 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);
623
- if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
818
+ catch (error) {
819
+ if (proc)
820
+ this.pendingExtensionStatuses.delete(proc);
821
+ unownedLease?.release();
822
+ if (proc && actor.process === proc) {
823
+ let detailed = this.buildPromptFailureMessage(error, proc, actor);
824
+ await actor.enqueue("fork prompt failure", () => {
825
+ if (actor.process !== proc)
826
+ return;
827
+ detailed = this.buildPromptFailureMessage(error, proc, actor);
828
+ actor.markFailed();
829
+ this.injectSessionError(actor, detailed);
830
+ this.releaseActor(actor);
831
+ });
832
+ const rawMessage = error instanceof Error ? error.message : String(error);
833
+ if (rawMessage.includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
624
834
  proc.process.kill("SIGTERM");
625
835
  }
626
836
  throw new Error(detailed);
627
837
  }
628
- this.busyProcesses.delete(proc);
629
- if (proc.process.exitCode === null)
838
+ if (proc && proc.process.exitCode === null)
630
839
  proc.process.kill("SIGTERM");
631
- throw err;
840
+ throw error;
632
841
  }
633
842
  }
634
- async handleSetSessionName(ws, id, command) {
635
- const sessionPath = command.sessionPath;
636
- if (!sessionPath)
637
- 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);
641
- ws.send(JSON.stringify({ ...response, id, command: "set_session_name" }));
843
+ async handleSetSessionName(ws, command) {
844
+ const sessionPath = this.sessionPaths.resolveExisting(command.sessionPath);
845
+ const actor = this.registry.get(sessionPath);
846
+ const response = await actor.enqueue("set session name", async () => {
847
+ actor.assertAvailable("rename session");
848
+ const proc = await this.acquireForActor(actor);
849
+ try {
850
+ return await this.pool.sendRpc(proc, { type: "set_session_name", name: command.name });
851
+ }
852
+ finally {
853
+ this.releaseActor(actor);
854
+ }
855
+ });
856
+ if (!response.success)
857
+ throw new Error(response.error);
858
+ this.sendSuccess(ws, command.id, "set_session_name", {});
642
859
  }
643
860
  // ── Internal helpers ─────────────────────────────────────────────────
644
- buildPromptFailureMessage(err, proc, sessionPath) {
861
+ async replacePiSession(proc, command, operation) {
862
+ const response = await this.pool.sendRpcChecked(proc, command);
863
+ if (response.data?.cancelled) {
864
+ throw new Error(`Pi ${operation} was cancelled`);
865
+ }
866
+ }
867
+ /**
868
+ * Apply a client's requested controls as one ordered RPC transaction, then
869
+ * publish pi's effective (possibly clamped) state before the prompt starts.
870
+ */
871
+ async applyRequestedControlState(proc, actor, ws, command) {
872
+ const sessionPath = actor.sessionPath;
873
+ if (!command.model) {
874
+ throw new Error(`BUG: prompt command received without model. sessionPath=${sessionPath}`);
875
+ }
876
+ let stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
877
+ if (!modelsMatch(stateResponse.data?.model, command.model)) {
878
+ await this.pool.sendRpcChecked(proc, {
879
+ type: "set_model",
880
+ provider: command.model.provider,
881
+ modelId: command.model.modelId,
882
+ });
883
+ }
884
+ if (typeof command.thinkingLevel === "string") {
885
+ await this.pool.sendRpcChecked(proc, {
886
+ type: "set_thinking_level",
887
+ level: command.thinkingLevel,
888
+ });
889
+ }
890
+ // set_thinking_level has no effective value in its response and set_model
891
+ // may clamp effort, so get_state is the authoritative transaction result.
892
+ stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
893
+ const activeModel = stateResponse.data?.model;
894
+ if (!modelsMatch(activeModel, command.model)) {
895
+ throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
896
+ }
897
+ this.publishEffectiveControlState(actor, stateResponse.data);
898
+ if (ws.readyState === FRAME_CONNECTION_OPEN) {
899
+ this.sendMessage(ws, {
900
+ type: "control_state",
901
+ sessionPath,
902
+ controlRevision: command.controlRevision,
903
+ model: activeModel,
904
+ thinkingLevel: stateResponse.data.thinkingLevel,
905
+ });
906
+ }
907
+ }
908
+ async waitForPromptSettlement(proc, observer) {
909
+ const state = await this.pool.sendRpcChecked(proc, { type: "get_state" });
910
+ // Pi >= 0.80 acknowledges a prompt only after preflight. Before that
911
+ // acknowledgement is written, a real agent run synchronously marks the
912
+ // session streaming; an extension-handled input remains idle and emits no
913
+ // agent lifecycle. This authoritative state check avoids timing guesses.
914
+ if (!state.data?.isStreaming && !observer.hasStarted())
915
+ return;
916
+ let removeExitListener = () => { };
917
+ const exited = new Promise((_resolve, reject) => {
918
+ if (proc.process.exitCode !== null) {
919
+ reject(new Error(`pi process #${proc.id} exited before the turn settled`));
920
+ return;
921
+ }
922
+ const onExit = () => reject(new Error(`pi process #${proc.id} exited before the turn settled`));
923
+ proc.process.once("exit", onExit);
924
+ removeExitListener = () => proc.process.removeListener("exit", onExit);
925
+ });
926
+ try {
927
+ await Promise.race([observer.settled, exited]);
928
+ }
929
+ finally {
930
+ removeExitListener();
931
+ }
932
+ }
933
+ /** Refresh controls after extensions or model clamps that happened in-turn. */
934
+ async reconcileEffectiveControlState(proc, actor) {
935
+ const stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
936
+ this.publishEffectiveControlState(actor, stateResponse.data);
937
+ }
938
+ publishEffectiveControlState(actor, rpcState) {
939
+ const session = actor.session;
940
+ if (!session)
941
+ return;
942
+ const model = toCompactModelRef(rpcState?.model ?? {});
943
+ if (!model)
944
+ return;
945
+ const changed = session.setControlState(model, rpcState?.thinkingLevel ?? "off");
946
+ if (changed)
947
+ this.pushUpdateToSubscribers(actor.sessionPath, session);
948
+ }
949
+ buildPromptFailureMessage(err, proc, actor) {
645
950
  const raw = err instanceof Error ? err.message : String(err);
646
- const session = this.attachedSessions.get(sessionPath);
647
- const sessionError = session?.toState().error;
951
+ const sessionError = actor.session?.toState().error;
648
952
  const stderrTail = this.pool.getRecentStderr(proc, 12);
649
953
  let message = raw;
650
954
  if (raw.includes("Timeout waiting for RPC response to prompt")) {
@@ -658,74 +962,79 @@ export class WsHandler {
658
962
  }
659
963
  return message;
660
964
  }
661
- injectSessionError(sessionPath, errorMessage) {
662
- const session = this.attachedSessions.get(sessionPath);
965
+ injectSessionError(actor, errorMessage) {
966
+ const session = actor.session;
663
967
  if (!session)
664
968
  return;
665
969
  session.applyEvent({
666
970
  type: "turn_end",
667
971
  message: { role: "assistant", errorMessage },
668
972
  });
669
- this.pushUpdateToSubscribers(sessionPath, session);
973
+ this.pushUpdateToSubscribers(actor.sessionPath, session);
974
+ }
975
+ async sendSteering(actor, message) {
976
+ if (!actor.isTurnActive || !actor.process) {
977
+ throw new Error("Session is not attached (agent not running)");
978
+ }
979
+ actor.enqueueSteering(message);
980
+ try {
981
+ await this.pool.sendRpc(actor.process, { type: "steer", message });
982
+ }
983
+ catch (error) {
984
+ actor.dequeueSteering(message);
985
+ throw error;
986
+ }
670
987
  }
671
988
  sleep(ms) {
672
989
  return new Promise((resolve) => setTimeout(resolve, ms));
673
990
  }
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) {
991
+ /** Build the active materialized view owned by a SessionActor. */
992
+ createSessionState(sessionPath) {
683
993
  const { state } = readSessionFromDisk(sessionPath);
684
- const session = new SessionJsonl({
994
+ return new SessionJsonl({
685
995
  messages: state.messages,
686
996
  model: state.model,
687
997
  thinkingLevel: state.thinkingLevel,
998
+ toolCallTimings: state.toolCallTimings,
688
999
  });
689
- this.attachedSessions.set(sessionPath, session);
690
- return session;
691
1000
  }
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;
1001
+ /** Acquire, switch, and transfer a process lease to a detached actor. */
1002
+ async acquireForActor(actor) {
1003
+ if (actor.process)
1004
+ return actor.process;
1005
+ const sessionPath = actor.sessionPath;
700
1006
  const sessionCwd = getSessionCwd(sessionPath);
701
1007
  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 });
1008
+ const lease = await this.acquireProcess(cwd);
1009
+ const proc = lease.process;
1010
+ this.beginPendingExtensionStatusCapture(proc, sessionPath);
1011
+ try {
1012
+ await this.replacePiSession(proc, { type: "switch_session", sessionPath }, "switch_session");
1013
+ actor.attach(lease, this.createSessionState(sessionPath));
1014
+ this.commitPendingExtensionStatuses(proc, sessionPath);
1015
+ }
1016
+ catch (err) {
1017
+ this.pendingExtensionStatuses.delete(proc);
1018
+ if (actor.process === proc)
1019
+ actor.detach();
1020
+ else
1021
+ lease.release();
1022
+ throw err;
1023
+ }
710
1024
  console.log(`[ws] pi#${proc.id} attached to ${path.basename(sessionPath)} (cwd: ${cwd})`);
711
1025
  return proc;
712
1026
  }
713
- /**
714
- * Acquire a process for a given cwd. Spawns if needed.
715
- */
1027
+ /** Acquire an atomically reserved process lease for a cwd. */
716
1028
  async acquireProcess(cwd) {
717
- if (!this.piAvailable) {
1029
+ if (!this.piAvailable)
718
1030
  throw new Error(makePiNotFoundMessage(this.piLaunch.command));
719
- }
720
1031
  const timeoutMs = 60000;
721
1032
  const start = Date.now();
722
1033
  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) {
1034
+ const lease = this.pool.acquire(cwd);
1035
+ if (lease)
1036
+ return lease;
1037
+ if (this.pool.evictIdleDifferentCwd(cwd)) {
729
1038
  await this.sleep(50);
730
1039
  continue;
731
1040
  }
@@ -735,52 +1044,57 @@ export class WsHandler {
735
1044
  await this.sleep(100);
736
1045
  }
737
1046
  }
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);
1047
+ async acquireAnyProcess() {
1048
+ const existing = this.pool.acquireAny();
1049
+ if (existing)
1050
+ return existing;
1051
+ return this.acquireProcess(this.defaultCwd);
1052
+ }
1053
+ releaseActor(actor, preserveLiveControls = true) {
1054
+ const sessionPath = actor.sessionPath;
1055
+ const proc = actor.process;
1056
+ const liveState = actor.session?.toState();
1057
+ if (proc)
1058
+ this.pendingExtensionStatuses.delete(proc);
1059
+ actor.detach();
1060
+ // Read final messages from disk, but preserve the just-reconciled controls.
1061
+ // This prevents JSONL flush timing from regressing the detach snapshot.
1062
+ const disk = readSessionFromDisk(sessionPath);
1063
+ if (liveState) {
1064
+ if (preserveLiveControls) {
1065
+ disk.state.model = liveState.model;
1066
+ disk.state.thinkingLevel = liveState.thinkingLevel;
745
1067
  }
1068
+ disk.state.toolCallTimings = liveState.toolCallTimings;
1069
+ disk.state.error = liveState.error;
746
1070
  }
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);
1071
+ const { json, hash } = serializeSessionState(disk.state);
752
1072
  this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
753
1073
  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
1074
  }
764
- getAnyProcess() {
765
- let proc = this.pool.getAny(this.getUnavailableProcesses());
766
- if (!proc) {
767
- proc = this.pool.spawn(this.defaultCwd);
768
- }
769
- return proc;
1075
+ revisionForState(sessionPath, hash) {
1076
+ const current = this.sessionRevisions.get(sessionPath);
1077
+ if (current?.hash === hash)
1078
+ return current.revision;
1079
+ const revision = (current?.revision ?? 0) + 1;
1080
+ this.sessionRevisions.set(sessionPath, { revision, hash });
1081
+ return revision;
770
1082
  }
771
1083
  pushSnapshotToSubscribers(sessionPath, json, hash) {
1084
+ const revision = this.revisionForState(sessionPath, hash);
772
1085
  for (const [ws, client] of this.clients) {
773
1086
  if (client.subscribedSession !== sessionPath)
774
1087
  continue;
775
- if (ws.readyState !== WebSocket.OPEN)
1088
+ if (ws.readyState !== FRAME_CONNECTION_OPEN)
776
1089
  continue;
777
- ws.send(JSON.stringify({
1090
+ this.sendMessage(ws, {
778
1091
  type: "session_sync",
779
1092
  sessionPath,
1093
+ revision,
780
1094
  op: "full",
781
1095
  data: json,
782
1096
  hash,
783
- }));
1097
+ });
784
1098
  client.lastJson = json;
785
1099
  client.lastHash = hash;
786
1100
  client.lastVersion = 0;
@@ -791,84 +1105,76 @@ export class WsHandler {
791
1105
  * Uses the hash-verified diff protocol for efficient incremental sync.
792
1106
  */
793
1107
  pushUpdateToSubscribers(sessionPath, session) {
1108
+ const revision = this.revisionForState(sessionPath, session.hash);
794
1109
  for (const [ws, client] of this.clients) {
795
1110
  if (client.subscribedSession !== sessionPath)
796
1111
  continue;
797
- if (ws.readyState !== WebSocket.OPEN)
1112
+ if (ws.readyState !== FRAME_CONNECTION_OPEN)
798
1113
  continue;
799
1114
  const syncOp = session.computeSyncOp(client.lastJson, client.lastHash, client.lastVersion);
800
1115
  if (!syncOp)
801
1116
  continue;
802
- ws.send(JSON.stringify({
1117
+ this.sendMessage(ws, {
803
1118
  type: "session_sync",
804
1119
  sessionPath,
1120
+ revision,
805
1121
  ...syncOp,
806
- }));
1122
+ });
807
1123
  client.lastJson = session.json;
808
1124
  client.lastHash = session.hash;
809
1125
  client.lastVersion = session.version;
810
1126
  }
811
1127
  }
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;
1128
+ setupTurnEventForwarding(actor, proc, generation, turnId) {
1129
+ const sessionPath = actor.sessionPath;
1130
+ if (!actor.session || !actor.owns(proc, generation)) {
1131
+ throw new Error(`setupTurnEventForwarding called without actor ownership for ${sessionPath}`);
824
1132
  }
825
- const lineHandler = (line) => {
826
- let data;
827
- try {
828
- data = JSON.parse(line);
829
- }
830
- catch {
1133
+ let hasStarted = false;
1134
+ let hasSettled = false;
1135
+ let resolveStarted;
1136
+ let resolveEnded;
1137
+ let resolveSettled;
1138
+ const started = new Promise((resolve) => { resolveStarted = resolve; });
1139
+ const ended = new Promise((resolve) => { resolveEnded = resolve; });
1140
+ const settled = new Promise((resolve) => { resolveSettled = resolve; });
1141
+ const eventHandler = (sourceProc, data) => {
1142
+ if (sourceProc !== proc || data.type === "extension_ui_request")
831
1143
  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)
839
- 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
- }
1144
+ const replacementMessages = data.type === "compaction_end" && data.result
1145
+ ? readSessionFromDisk(sessionPath).state.messages
1146
+ : undefined;
1147
+ void actor.applyProcessEvent(proc, generation, data, replacementMessages).then((result) => {
1148
+ if (!result.accepted)
1149
+ return;
1150
+ if (result.started) {
1151
+ hasStarted = true;
1152
+ resolveStarted();
1153
+ }
1154
+ if (result.ended)
1155
+ resolveEnded();
1156
+ if (result.settled) {
1157
+ hasSettled = true;
1158
+ resolveSettled();
1159
+ }
1160
+ if (result.changed && actor.session) {
1161
+ this.pushUpdateToSubscribers(sessionPath, actor.session);
1162
+ }
1163
+ if (data.type === "agent_end") {
1164
+ debugTurn("agent_end_received", { turnId, procId: proc.id, sessionPath });
1165
+ }
1166
+ }).catch((error) => {
1167
+ console.error(`[session-actor] Failed to apply ${data.type} for ${sessionPath}:`, error);
1168
+ });
1169
+ };
1170
+ const cleanup = this.pool.subscribeEvents(eventHandler);
1171
+ actor.setTurnEventCleanup(generation, cleanup);
1172
+ return {
1173
+ started,
1174
+ ended,
1175
+ settled,
1176
+ hasStarted: () => hasStarted,
1177
+ hasSettled: () => hasSettled,
869
1178
  };
870
- proc.rl.on("line", lineHandler);
871
- const cleanup = () => proc.rl.removeListener("line", lineHandler);
872
- this.procEventCleanup.set(proc, cleanup);
873
1179
  }
874
1180
  }