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.
- package/README.md +34 -1
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/index-Dl_wdLZH.css +1 -0
- package/dist/client/assets/index-hNqbnG06.js +2482 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/bundled-extensions.js +6 -0
- package/dist/server/server/extension-status.js +31 -0
- package/dist/server/server/local-settings.js +15 -0
- package/dist/server/server/pi-runtime.js +1 -1
- package/dist/server/server/process-pool.js +160 -120
- package/dist/server/server/rest-api.js +7 -74
- package/dist/server/server/server.js +37 -49
- package/dist/server/server/session-actor.js +202 -0
- package/dist/server/server/session-index.js +18 -2
- package/dist/server/server/session-jsonl.js +49 -6
- package/dist/server/server/session-registry.js +67 -0
- package/dist/server/server/worktree-name.js +39 -0
- package/dist/server/server/ws-handler.js +577 -352
- package/dist/server/shared/jsonl-sync.js +41 -0
- package/dist/server/shared/rpc-timeouts.js +4 -0
- package/dist/server/shared/thinking-levels.js +94 -0
- package/dist/server/shared/ws-protocol.js +1 -0
- package/extensions/canvas.ts +1 -1
- package/package.json +26 -14
- package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
- package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
- package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
- package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
- package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
- package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
- package/dist/client/assets/index-BtDAXAo1.css +0 -1
- package/dist/client/assets/index-CAQqlK61.js +0 -131
- package/dist/client/assets/index-CJEeBmBj.js +0 -3
- package/dist/client/assets/index-DUEhKmPQ.js +0 -1
- package/dist/client/assets/index-XEVKscP-.js +0 -4443
- package/dist/client/assets/index-woI4agCy.js +0 -45
- package/dist/server/server/attached-session.js +0 -209
- package/dist/server/server/load-trace-store.js +0 -48
- package/dist/server/server/session-lifecycle.js +0 -155
- 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
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
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((
|
|
142
|
-
id:
|
|
143
|
-
pid:
|
|
144
|
-
alive:
|
|
145
|
-
exitCode:
|
|
146
|
-
cwd:
|
|
147
|
-
busy: this.
|
|
148
|
-
decommissioning: this.
|
|
149
|
-
attachedSession: this.
|
|
150
|
-
pendingRequests:
|
|
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.
|
|
156
|
-
|
|
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.
|
|
186
|
-
steeringQueues: this.
|
|
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.
|
|
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
|
-
|
|
386
|
-
|
|
454
|
+
newSessionCwd = command.cwd || this.defaultCwd;
|
|
455
|
+
unownedLease = await this.acquireProcess(newSessionCwd);
|
|
456
|
+
proc = unownedLease.process;
|
|
387
457
|
await this.pool.waitForReady(proc);
|
|
388
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
458
|
-
if (!
|
|
544
|
+
const actor = this.registry.find(sessionPath);
|
|
545
|
+
if (!actor)
|
|
459
546
|
throw new Error("Session is not attached (agent not running)");
|
|
460
|
-
this.
|
|
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.
|
|
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
|
|
477
|
-
if (
|
|
478
|
-
await
|
|
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
|
|
487
|
-
const response = await
|
|
488
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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.
|
|
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
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
580
|
-
const
|
|
581
|
-
await
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
717
|
+
const actor = this.registry.get(newSessionPath);
|
|
718
|
+
let proc;
|
|
719
|
+
let unownedLease;
|
|
587
720
|
try {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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 (
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
|
639
|
-
const response = await
|
|
640
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
662
|
-
const session =
|
|
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
|
-
/**
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
this.
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
this.
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
739
|
-
const
|
|
740
|
-
if (
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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(
|
|
813
|
-
const
|
|
814
|
-
if (
|
|
815
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
}
|