pipane 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +177 -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 +630 -342
- package/dist/server/shared/jsonl-sync.js +114 -3
- 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-Bf_69wyB.js +0 -3
- package/dist/client/assets/index-CUE9pgq9.css +0 -1
- package/dist/client/assets/index-CekVLaL6.js +0 -1
- package/dist/client/assets/index-Cp2vV6HJ.js +0 -4433
- package/dist/client/assets/index-D14II7Hd.js +0 -45
- package/dist/client/assets/index-D16nGmo3.js +0 -131
- 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,67 +444,107 @@ export class WsHandler {
|
|
|
379
444
|
throw new Error("Missing sessionPath");
|
|
380
445
|
const turnId = makeTurnId();
|
|
381
446
|
debugTurn("prompt_start", { turnId, sessionPath, hasModel: !!command.model });
|
|
447
|
+
let actor;
|
|
382
448
|
let proc;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
449
|
+
let unownedLease;
|
|
450
|
+
let generation;
|
|
451
|
+
try {
|
|
452
|
+
let newSessionCwd;
|
|
453
|
+
if (sessionPath === "__new__") {
|
|
454
|
+
newSessionCwd = command.cwd || this.defaultCwd;
|
|
455
|
+
unownedLease = await this.acquireProcess(newSessionCwd);
|
|
456
|
+
proc = unownedLease.process;
|
|
457
|
+
await this.pool.waitForReady(proc);
|
|
458
|
+
this.beginPendingExtensionStatusCapture(proc);
|
|
459
|
+
await this.replacePiSession(proc, { type: "new_session" }, "new_session");
|
|
460
|
+
const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
|
|
461
|
+
sessionPath = stateResp.data?.sessionFile;
|
|
462
|
+
if (!sessionPath)
|
|
463
|
+
throw new Error("Failed to get session path from new session");
|
|
464
|
+
}
|
|
465
|
+
actor = this.registry.get(sessionPath);
|
|
466
|
+
const start = await actor.enqueue("prompt start", async () => {
|
|
467
|
+
// A second prompt that arrived during startup observes the committed
|
|
468
|
+
// actor phase and becomes steering rather than a second turn owner.
|
|
469
|
+
if (actor.isTurnActive) {
|
|
470
|
+
await this.sendSteering(actor, command.message);
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
actor.assertAvailable("start prompt");
|
|
474
|
+
if (unownedLease) {
|
|
475
|
+
proc = unownedLease.process;
|
|
476
|
+
actor.attach(unownedLease, this.createSessionState(sessionPath));
|
|
477
|
+
unownedLease = undefined;
|
|
478
|
+
this.commitPendingExtensionStatuses(proc, sessionPath);
|
|
479
|
+
ws.send(JSON.stringify({
|
|
480
|
+
type: "session_attached",
|
|
481
|
+
sessionPath,
|
|
482
|
+
cwd: newSessionCwd,
|
|
483
|
+
firstMessage: command.message,
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
proc = await this.acquireForActor(actor);
|
|
488
|
+
}
|
|
489
|
+
generation = actor.beginTurn();
|
|
490
|
+
const observer = this.setupTurnEventForwarding(actor, proc, generation, turnId);
|
|
491
|
+
await this.applyRequestedControlState(proc, actor, ws, command);
|
|
492
|
+
const promptCmd = { type: "prompt", message: command.message };
|
|
493
|
+
if (command.images?.length > 0)
|
|
494
|
+
promptCmd.images = command.images;
|
|
495
|
+
const response = await this.pool.sendRpc(proc, promptCmd);
|
|
496
|
+
await this.reconcileEffectiveControlState(proc, actor);
|
|
497
|
+
return { observer, response, generation };
|
|
498
|
+
});
|
|
499
|
+
if (!start) {
|
|
500
|
+
ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
await this.waitForPromptSettlement(proc, start.observer);
|
|
504
|
+
await actor.enqueue("prompt settlement", async () => {
|
|
505
|
+
if (!actor.owns(proc, start.generation))
|
|
506
|
+
return;
|
|
507
|
+
await this.reconcileEffectiveControlState(proc, actor);
|
|
508
|
+
this.releaseActor(actor);
|
|
509
|
+
});
|
|
510
|
+
const enriched = { ...start.response };
|
|
511
|
+
if (!enriched.data)
|
|
512
|
+
enriched.data = {};
|
|
513
|
+
enriched.data.newSessionPath = sessionPath;
|
|
514
|
+
ws.send(JSON.stringify({ ...enriched, id, command: "prompt" }));
|
|
421
515
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
516
|
+
catch (err) {
|
|
517
|
+
if (proc)
|
|
518
|
+
this.pendingExtensionStatuses.delete(proc);
|
|
519
|
+
unownedLease?.release();
|
|
520
|
+
if (actor && proc && actor.process === proc) {
|
|
521
|
+
let detailed = this.buildPromptFailureMessage(err, proc, actor);
|
|
522
|
+
await actor.enqueue("prompt failure", () => {
|
|
523
|
+
if (actor.process !== proc)
|
|
524
|
+
return;
|
|
525
|
+
detailed = this.buildPromptFailureMessage(err, proc, actor);
|
|
526
|
+
actor.markFailed();
|
|
527
|
+
this.injectSessionError(actor, detailed);
|
|
528
|
+
this.releaseActor(actor);
|
|
529
|
+
});
|
|
530
|
+
if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
|
|
531
|
+
proc.process.kill("SIGTERM");
|
|
532
|
+
}
|
|
533
|
+
throw new Error(detailed);
|
|
534
|
+
}
|
|
535
|
+
if (proc && proc.process.exitCode === null)
|
|
536
|
+
proc.process.kill("SIGTERM");
|
|
537
|
+
throw err;
|
|
426
538
|
}
|
|
427
|
-
const response = await this.pool.sendRpc(proc, promptCmd);
|
|
428
|
-
const enriched = { ...response };
|
|
429
|
-
if (!enriched.data)
|
|
430
|
-
enriched.data = {};
|
|
431
|
-
enriched.data.newSessionPath = sessionPath;
|
|
432
|
-
ws.send(JSON.stringify({ ...enriched, id, command: "prompt" }));
|
|
433
539
|
}
|
|
434
540
|
async handleSteer(ws, id, command) {
|
|
435
541
|
const sessionPath = command.sessionPath;
|
|
436
542
|
if (!sessionPath)
|
|
437
543
|
throw new Error("Missing sessionPath");
|
|
438
|
-
const
|
|
439
|
-
if (!
|
|
544
|
+
const actor = this.registry.find(sessionPath);
|
|
545
|
+
if (!actor)
|
|
440
546
|
throw new Error("Session is not attached (agent not running)");
|
|
441
|
-
this.
|
|
442
|
-
await this.pool.sendRpc(proc, { type: "steer", message: command.message });
|
|
547
|
+
await actor.enqueue("steer", () => this.sendSteering(actor, command.message));
|
|
443
548
|
ws.send(JSON.stringify({ id, type: "response", command: "steer", success: true }));
|
|
444
549
|
}
|
|
445
550
|
async handleRemoveSteering(ws, id, command) {
|
|
@@ -449,64 +554,95 @@ export class WsHandler {
|
|
|
449
554
|
const index = command.index;
|
|
450
555
|
if (typeof index !== "number")
|
|
451
556
|
throw new Error("Missing index");
|
|
452
|
-
this.
|
|
557
|
+
const actor = this.registry.find(sessionPath);
|
|
558
|
+
if (actor)
|
|
559
|
+
await actor.enqueue("remove steering", () => actor.removeSteeringByIndex(index));
|
|
453
560
|
ws.send(JSON.stringify({ id, type: "response", command: "remove_steering", success: true }));
|
|
454
561
|
}
|
|
455
562
|
async handleAbort(ws, id, command) {
|
|
456
563
|
const sessionPath = command.sessionPath;
|
|
457
|
-
const
|
|
458
|
-
if (
|
|
459
|
-
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
|
+
});
|
|
460
571
|
}
|
|
461
572
|
ws.send(JSON.stringify({ id, type: "response", command: "abort", success: true }));
|
|
462
573
|
}
|
|
574
|
+
async handleHardKill(ws, id, command) {
|
|
575
|
+
const sessionPath = command.sessionPath;
|
|
576
|
+
if (!sessionPath)
|
|
577
|
+
throw new Error("Missing sessionPath");
|
|
578
|
+
const actor = this.registry.find(sessionPath);
|
|
579
|
+
let hadProcess = false;
|
|
580
|
+
let killed = false;
|
|
581
|
+
if (actor) {
|
|
582
|
+
await actor.enqueue("hard kill", () => {
|
|
583
|
+
const proc = actor.process;
|
|
584
|
+
if (!proc)
|
|
585
|
+
return;
|
|
586
|
+
hadProcess = true;
|
|
587
|
+
killed = this.pool.forceKill(proc);
|
|
588
|
+
actor.markFailed();
|
|
589
|
+
this.releaseActor(actor, false);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
if (hadProcess)
|
|
593
|
+
this.ensurePool();
|
|
594
|
+
ws.send(JSON.stringify({
|
|
595
|
+
id,
|
|
596
|
+
type: "response",
|
|
597
|
+
command: "hard_kill",
|
|
598
|
+
success: true,
|
|
599
|
+
data: killed
|
|
600
|
+
? { killed: true }
|
|
601
|
+
: { killed: false, reason: hadProcess ? "signal_failed" : "not_attached" },
|
|
602
|
+
}));
|
|
603
|
+
}
|
|
463
604
|
async handleCompact(ws, id, command) {
|
|
464
605
|
const sessionPath = command.sessionPath;
|
|
465
606
|
if (!sessionPath)
|
|
466
607
|
throw new Error("Missing sessionPath");
|
|
467
|
-
const
|
|
468
|
-
const response = await
|
|
469
|
-
|
|
608
|
+
const actor = this.registry.get(sessionPath);
|
|
609
|
+
const response = await actor.enqueue("compact", async () => {
|
|
610
|
+
actor.assertAvailable("compact");
|
|
611
|
+
const proc = await this.acquireForActor(actor);
|
|
612
|
+
try {
|
|
613
|
+
// Pi returns only after the summarization model call and session write.
|
|
614
|
+
// The generic 30s RPC timeout can expire while compaction is healthy,
|
|
615
|
+
// causing its eventual response and compaction_end event to be orphaned.
|
|
616
|
+
return await this.pool.sendRpc(proc, { type: "compact", customInstructions: command.customInstructions }, COMPACT_RPC_TIMEOUT_MS);
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
this.releaseActor(actor);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
470
622
|
ws.send(JSON.stringify({ ...response, id, command: "compact" }));
|
|
471
623
|
}
|
|
472
624
|
async handleGetAvailableModels(ws, id) {
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
625
|
+
const lease = await this.acquireAnyProcess();
|
|
626
|
+
try {
|
|
627
|
+
const response = await this.pool.sendRpc(lease.process, { type: "get_available_models" });
|
|
628
|
+
ws.send(JSON.stringify({ ...response, id, command: "get_available_models" }));
|
|
629
|
+
}
|
|
630
|
+
finally {
|
|
631
|
+
lease.release();
|
|
632
|
+
}
|
|
476
633
|
}
|
|
477
634
|
async handleGetCommands(ws, id) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
635
|
+
const lease = await this.acquireAnyProcess();
|
|
636
|
+
try {
|
|
637
|
+
const response = await this.pool.sendRpc(lease.process, { type: "get_commands" });
|
|
638
|
+
ws.send(JSON.stringify({ ...response, id, command: "get_commands" }));
|
|
639
|
+
}
|
|
640
|
+
finally {
|
|
641
|
+
lease.release();
|
|
642
|
+
}
|
|
481
643
|
}
|
|
482
644
|
async handleReloadProcesses(ws, id) {
|
|
483
|
-
const
|
|
484
|
-
let killed = 0;
|
|
485
|
-
let draining = 0;
|
|
486
|
-
for (const proc of all) {
|
|
487
|
-
if (proc.process.exitCode !== null)
|
|
488
|
-
continue;
|
|
489
|
-
const sessionPath = this.lifecycle.getAttachedSessionForProcess(proc);
|
|
490
|
-
if (sessionPath) {
|
|
491
|
-
// Graceful path: keep running turns alive, but decommission the process
|
|
492
|
-
// once the turn ends (releaseProcess will terminate it).
|
|
493
|
-
if (!this.decommissionProcesses.has(proc)) {
|
|
494
|
-
this.decommissionProcesses.add(proc);
|
|
495
|
-
draining += 1;
|
|
496
|
-
}
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
// Idle/unattached process: terminate immediately.
|
|
500
|
-
const cleanup = this.procEventCleanup.get(proc);
|
|
501
|
-
if (cleanup) {
|
|
502
|
-
cleanup();
|
|
503
|
-
this.procEventCleanup.delete(proc);
|
|
504
|
-
}
|
|
505
|
-
this.busyProcesses.delete(proc);
|
|
506
|
-
this.decommissionProcesses.delete(proc);
|
|
507
|
-
proc.process.kill("SIGTERM");
|
|
508
|
-
killed += 1;
|
|
509
|
-
}
|
|
645
|
+
const { killed, draining } = this.pool.decommissionAll();
|
|
510
646
|
this.ensurePool();
|
|
511
647
|
ws.send(JSON.stringify({
|
|
512
648
|
id,
|
|
@@ -517,14 +653,19 @@ export class WsHandler {
|
|
|
517
653
|
}));
|
|
518
654
|
}
|
|
519
655
|
async handleGetDefaultModel(ws, id) {
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
656
|
+
const lease = await this.acquireAnyProcess();
|
|
657
|
+
try {
|
|
658
|
+
const stateResp = await this.pool.sendRpc(lease.process, { type: "get_state" });
|
|
659
|
+
const model = stateResp.data?.model ?? null;
|
|
660
|
+
const thinkingLevel = stateResp.data?.thinkingLevel ?? "off";
|
|
661
|
+
ws.send(JSON.stringify({ id, type: "response", command: "get_default_model", success: true, data: { model, thinkingLevel } }));
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
lease.release();
|
|
665
|
+
}
|
|
525
666
|
}
|
|
526
667
|
handleGetSessionStatuses(ws, id) {
|
|
527
|
-
ws.send(JSON.stringify({ id, type: "response", command: "get_session_statuses", success: true, data: { statuses: this.
|
|
668
|
+
ws.send(JSON.stringify({ id, type: "response", command: "get_session_statuses", success: true, data: { statuses: this.registry.getAllStatuses() } }));
|
|
528
669
|
}
|
|
529
670
|
async handleFork(ws, id, command) {
|
|
530
671
|
const sessionPath = command.sessionPath;
|
|
@@ -533,17 +674,25 @@ export class WsHandler {
|
|
|
533
674
|
const entryId = command.entryId;
|
|
534
675
|
if (!entryId)
|
|
535
676
|
throw new Error("Missing entryId");
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
677
|
+
const actor = this.registry.get(sessionPath);
|
|
678
|
+
const result = await actor.enqueue("fork", async () => {
|
|
679
|
+
actor.assertAvailable("fork");
|
|
680
|
+
const proc = await this.acquireForActor(actor);
|
|
681
|
+
try {
|
|
682
|
+
const response = await this.pool.sendRpc(proc, { type: "fork", entryId });
|
|
683
|
+
const stateResp = await this.pool.sendRpc(proc, { type: "get_state" });
|
|
684
|
+
return { response, newSessionPath: stateResp.data?.sessionFile };
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
this.releaseActor(actor);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
541
690
|
ws.send(JSON.stringify({
|
|
542
691
|
id, type: "response", command: "fork", success: true,
|
|
543
692
|
data: {
|
|
544
|
-
text: response.data?.text ?? "",
|
|
545
|
-
cancelled: response.data?.cancelled ?? false,
|
|
546
|
-
newSessionPath: newSessionPath ?? null,
|
|
693
|
+
text: result.response.data?.text ?? "",
|
|
694
|
+
cancelled: result.response.data?.cancelled ?? false,
|
|
695
|
+
newSessionPath: result.newSessionPath ?? null,
|
|
547
696
|
},
|
|
548
697
|
}));
|
|
549
698
|
}
|
|
@@ -557,112 +706,267 @@ export class WsHandler {
|
|
|
557
706
|
const sessionsDir = path.join(getAgentDir(), "sessions");
|
|
558
707
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
559
708
|
const newId = crypto.randomUUID().slice(0, 8);
|
|
560
|
-
const
|
|
561
|
-
const
|
|
562
|
-
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
|
+
});
|
|
563
715
|
const forkCwd = getSessionCwd(sessionPath);
|
|
564
716
|
const cwd = (forkCwd && existsSync(forkCwd)) ? forkCwd : this.defaultCwd;
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
717
|
+
const actor = this.registry.get(newSessionPath);
|
|
718
|
+
let proc;
|
|
719
|
+
let unownedLease;
|
|
720
|
+
try {
|
|
721
|
+
const start = await actor.enqueue("fork prompt start", async () => {
|
|
722
|
+
actor.assertAvailable("fork and prompt");
|
|
723
|
+
unownedLease = await this.acquireProcess(cwd);
|
|
724
|
+
proc = unownedLease.process;
|
|
725
|
+
await this.pool.waitForReady(proc);
|
|
726
|
+
this.beginPendingExtensionStatusCapture(proc);
|
|
727
|
+
await this.replacePiSession(proc, { type: "switch_session", sessionPath: newSessionPath }, "switch_session");
|
|
728
|
+
actor.attach(unownedLease, this.createSessionState(newSessionPath));
|
|
729
|
+
unownedLease = undefined;
|
|
730
|
+
this.commitPendingExtensionStatuses(proc, newSessionPath);
|
|
731
|
+
ws.send(JSON.stringify({ type: "session_attached", sessionPath: newSessionPath, cwd, firstMessage: message }));
|
|
732
|
+
const generation = actor.beginTurn();
|
|
733
|
+
const observer = this.setupTurnEventForwarding(actor, proc, generation, makeTurnId());
|
|
734
|
+
await this.applyRequestedControlState(proc, actor, ws, command);
|
|
735
|
+
const promptCmd = { type: "prompt", message };
|
|
736
|
+
if (command.images?.length > 0)
|
|
737
|
+
promptCmd.images = command.images;
|
|
738
|
+
await this.pool.sendRpc(proc, promptCmd);
|
|
739
|
+
await this.reconcileEffectiveControlState(proc, actor);
|
|
740
|
+
return { observer, generation };
|
|
741
|
+
});
|
|
742
|
+
await this.waitForPromptSettlement(proc, start.observer);
|
|
743
|
+
await actor.enqueue("fork prompt settlement", async () => {
|
|
744
|
+
if (!actor.owns(proc, start.generation))
|
|
745
|
+
return;
|
|
746
|
+
await this.reconcileEffectiveControlState(proc, actor);
|
|
747
|
+
this.releaseActor(actor);
|
|
748
|
+
});
|
|
749
|
+
ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
|
|
588
750
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
751
|
+
catch (err) {
|
|
752
|
+
if (proc)
|
|
753
|
+
this.pendingExtensionStatuses.delete(proc);
|
|
754
|
+
unownedLease?.release();
|
|
755
|
+
if (proc && actor.process === proc) {
|
|
756
|
+
let detailed = this.buildPromptFailureMessage(err, proc, actor);
|
|
757
|
+
await actor.enqueue("fork prompt failure", () => {
|
|
758
|
+
if (actor.process !== proc)
|
|
759
|
+
return;
|
|
760
|
+
detailed = this.buildPromptFailureMessage(err, proc, actor);
|
|
761
|
+
actor.markFailed();
|
|
762
|
+
this.injectSessionError(actor, detailed);
|
|
763
|
+
this.releaseActor(actor);
|
|
764
|
+
});
|
|
765
|
+
if ((err?.message || "").includes("Timeout waiting for RPC response to prompt") && proc.process.exitCode === null) {
|
|
766
|
+
proc.process.kill("SIGTERM");
|
|
767
|
+
}
|
|
768
|
+
throw new Error(detailed);
|
|
769
|
+
}
|
|
770
|
+
if (proc && proc.process.exitCode === null)
|
|
771
|
+
proc.process.kill("SIGTERM");
|
|
772
|
+
throw err;
|
|
594
773
|
}
|
|
595
|
-
await this.pool.sendRpc(proc, promptCmd);
|
|
596
|
-
ws.send(JSON.stringify({ id, type: "response", command: "fork_prompt", success: true, data: { newSessionPath } }));
|
|
597
774
|
}
|
|
598
775
|
async handleSetSessionName(ws, id, command) {
|
|
599
776
|
const sessionPath = command.sessionPath;
|
|
600
777
|
if (!sessionPath)
|
|
601
778
|
throw new Error("Missing sessionPath");
|
|
602
|
-
const
|
|
603
|
-
const response = await
|
|
604
|
-
|
|
779
|
+
const actor = this.registry.get(sessionPath);
|
|
780
|
+
const response = await actor.enqueue("set session name", async () => {
|
|
781
|
+
actor.assertAvailable("rename session");
|
|
782
|
+
const proc = await this.acquireForActor(actor);
|
|
783
|
+
try {
|
|
784
|
+
return await this.pool.sendRpc(proc, { type: "set_session_name", name: command.name });
|
|
785
|
+
}
|
|
786
|
+
finally {
|
|
787
|
+
this.releaseActor(actor);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
605
790
|
ws.send(JSON.stringify({ ...response, id, command: "set_session_name" }));
|
|
606
791
|
}
|
|
607
792
|
// ── Internal helpers ─────────────────────────────────────────────────
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
return new Set([...this.busyProcesses, ...this.decommissionProcesses]);
|
|
793
|
+
async replacePiSession(proc, command, operation) {
|
|
794
|
+
const response = await this.pool.sendRpcChecked(proc, command);
|
|
795
|
+
if (response.data?.cancelled) {
|
|
796
|
+
throw new Error(`Pi ${operation} was cancelled`);
|
|
797
|
+
}
|
|
614
798
|
}
|
|
615
799
|
/**
|
|
616
|
-
*
|
|
617
|
-
*
|
|
800
|
+
* Apply a client's requested controls as one ordered RPC transaction, then
|
|
801
|
+
* publish pi's effective (possibly clamped) state before the prompt starts.
|
|
618
802
|
*/
|
|
619
|
-
|
|
803
|
+
async applyRequestedControlState(proc, actor, ws, command) {
|
|
804
|
+
const sessionPath = actor.sessionPath;
|
|
805
|
+
if (!command.model) {
|
|
806
|
+
throw new Error(`BUG: prompt command received without model. sessionPath=${sessionPath}`);
|
|
807
|
+
}
|
|
808
|
+
let stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
809
|
+
if (!modelsMatch(stateResponse.data?.model, command.model)) {
|
|
810
|
+
await this.pool.sendRpcChecked(proc, {
|
|
811
|
+
type: "set_model",
|
|
812
|
+
provider: command.model.provider,
|
|
813
|
+
modelId: command.model.modelId,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
if (typeof command.thinkingLevel === "string") {
|
|
817
|
+
await this.pool.sendRpcChecked(proc, {
|
|
818
|
+
type: "set_thinking_level",
|
|
819
|
+
level: command.thinkingLevel,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
// set_thinking_level has no effective value in its response and set_model
|
|
823
|
+
// may clamp effort, so get_state is the authoritative transaction result.
|
|
824
|
+
stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
825
|
+
const activeModel = stateResponse.data?.model;
|
|
826
|
+
if (!modelsMatch(activeModel, command.model)) {
|
|
827
|
+
throw new Error(`Failed to switch model to ${command.model.provider}/${command.model.modelId}`);
|
|
828
|
+
}
|
|
829
|
+
this.publishEffectiveControlState(actor, stateResponse.data);
|
|
830
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
831
|
+
ws.send(JSON.stringify({
|
|
832
|
+
type: "control_state",
|
|
833
|
+
sessionPath,
|
|
834
|
+
controlRevision: command.controlRevision,
|
|
835
|
+
model: activeModel,
|
|
836
|
+
thinkingLevel: stateResponse.data?.thinkingLevel ?? "off",
|
|
837
|
+
}));
|
|
838
|
+
}
|
|
839
|
+
return stateResponse.data;
|
|
840
|
+
}
|
|
841
|
+
async waitForPromptSettlement(proc, observer) {
|
|
842
|
+
const state = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
843
|
+
// Pi >= 0.80 acknowledges a prompt only after preflight. Before that
|
|
844
|
+
// acknowledgement is written, a real agent run synchronously marks the
|
|
845
|
+
// session streaming; an extension-handled input remains idle and emits no
|
|
846
|
+
// agent lifecycle. This authoritative state check avoids timing guesses.
|
|
847
|
+
if (!state.data?.isStreaming && !observer.hasStarted())
|
|
848
|
+
return;
|
|
849
|
+
let removeExitListener = () => { };
|
|
850
|
+
const exited = new Promise((_resolve, reject) => {
|
|
851
|
+
if (proc.process.exitCode !== null) {
|
|
852
|
+
reject(new Error(`pi process #${proc.id} exited before the turn settled`));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const onExit = () => reject(new Error(`pi process #${proc.id} exited before the turn settled`));
|
|
856
|
+
proc.process.once("exit", onExit);
|
|
857
|
+
removeExitListener = () => proc.process.removeListener("exit", onExit);
|
|
858
|
+
});
|
|
859
|
+
try {
|
|
860
|
+
await Promise.race([observer.settled, exited]);
|
|
861
|
+
}
|
|
862
|
+
finally {
|
|
863
|
+
removeExitListener();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** Refresh controls after extensions or model clamps that happened in-turn. */
|
|
867
|
+
async reconcileEffectiveControlState(proc, actor) {
|
|
868
|
+
const stateResponse = await this.pool.sendRpcChecked(proc, { type: "get_state" });
|
|
869
|
+
this.publishEffectiveControlState(actor, stateResponse.data);
|
|
870
|
+
}
|
|
871
|
+
publishEffectiveControlState(actor, rpcState) {
|
|
872
|
+
const session = actor.session;
|
|
873
|
+
if (!session)
|
|
874
|
+
return;
|
|
875
|
+
const model = toCompactModelRef(rpcState?.model ?? {});
|
|
876
|
+
if (!model)
|
|
877
|
+
return;
|
|
878
|
+
const changed = session.setControlState(model, rpcState?.thinkingLevel ?? "off");
|
|
879
|
+
if (changed)
|
|
880
|
+
this.pushUpdateToSubscribers(actor.sessionPath, session);
|
|
881
|
+
}
|
|
882
|
+
buildPromptFailureMessage(err, proc, actor) {
|
|
883
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
884
|
+
const sessionError = actor.session?.toState().error;
|
|
885
|
+
const stderrTail = this.pool.getRecentStderr(proc, 12);
|
|
886
|
+
let message = raw;
|
|
887
|
+
if (raw.includes("Timeout waiting for RPC response to prompt")) {
|
|
888
|
+
message = "Prompt timed out waiting for pi RPC response";
|
|
889
|
+
}
|
|
890
|
+
if (sessionError) {
|
|
891
|
+
message += `\nLast agent error: ${sessionError}`;
|
|
892
|
+
}
|
|
893
|
+
if (stderrTail.length > 0) {
|
|
894
|
+
message += `\nRecent pi stderr:\n${stderrTail.join("\n")}`;
|
|
895
|
+
}
|
|
896
|
+
return message;
|
|
897
|
+
}
|
|
898
|
+
injectSessionError(actor, errorMessage) {
|
|
899
|
+
const session = actor.session;
|
|
900
|
+
if (!session)
|
|
901
|
+
return;
|
|
902
|
+
session.applyEvent({
|
|
903
|
+
type: "turn_end",
|
|
904
|
+
message: { role: "assistant", errorMessage },
|
|
905
|
+
});
|
|
906
|
+
this.pushUpdateToSubscribers(actor.sessionPath, session);
|
|
907
|
+
}
|
|
908
|
+
async sendSteering(actor, message) {
|
|
909
|
+
if (!actor.isTurnActive || !actor.process) {
|
|
910
|
+
throw new Error("Session is not attached (agent not running)");
|
|
911
|
+
}
|
|
912
|
+
actor.enqueueSteering(message);
|
|
913
|
+
try {
|
|
914
|
+
await this.pool.sendRpc(actor.process, { type: "steer", message });
|
|
915
|
+
}
|
|
916
|
+
catch (error) {
|
|
917
|
+
actor.dequeueSteering(message);
|
|
918
|
+
throw error;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
sleep(ms) {
|
|
922
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
923
|
+
}
|
|
924
|
+
/** Build the active materialized view owned by a SessionActor. */
|
|
925
|
+
createSessionState(sessionPath) {
|
|
620
926
|
const { state } = readSessionFromDisk(sessionPath);
|
|
621
|
-
|
|
927
|
+
return new SessionJsonl({
|
|
622
928
|
messages: state.messages,
|
|
623
929
|
model: state.model,
|
|
624
930
|
thinkingLevel: state.thinkingLevel,
|
|
625
931
|
});
|
|
626
|
-
this.attachedSessions.set(sessionPath, session);
|
|
627
|
-
return session;
|
|
628
932
|
}
|
|
629
|
-
/**
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const existing = this.lifecycle.getAttachedProcess(sessionPath);
|
|
635
|
-
if (existing)
|
|
636
|
-
return existing;
|
|
933
|
+
/** Acquire, switch, and transfer a process lease to a detached actor. */
|
|
934
|
+
async acquireForActor(actor) {
|
|
935
|
+
if (actor.process)
|
|
936
|
+
return actor.process;
|
|
937
|
+
const sessionPath = actor.sessionPath;
|
|
637
938
|
const sessionCwd = getSessionCwd(sessionPath);
|
|
638
939
|
const cwd = (sessionCwd && existsSync(sessionCwd)) ? sessionCwd : this.defaultCwd;
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
this.
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
this.
|
|
645
|
-
|
|
646
|
-
|
|
940
|
+
const lease = await this.acquireProcess(cwd);
|
|
941
|
+
const proc = lease.process;
|
|
942
|
+
this.beginPendingExtensionStatusCapture(proc, sessionPath);
|
|
943
|
+
try {
|
|
944
|
+
await this.replacePiSession(proc, { type: "switch_session", sessionPath }, "switch_session");
|
|
945
|
+
actor.attach(lease, this.createSessionState(sessionPath));
|
|
946
|
+
this.commitPendingExtensionStatuses(proc, sessionPath);
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
this.pendingExtensionStatuses.delete(proc);
|
|
950
|
+
if (actor.process === proc)
|
|
951
|
+
actor.detach();
|
|
952
|
+
else
|
|
953
|
+
lease.release();
|
|
954
|
+
throw err;
|
|
955
|
+
}
|
|
647
956
|
console.log(`[ws] pi#${proc.id} attached to ${path.basename(sessionPath)} (cwd: ${cwd})`);
|
|
648
957
|
return proc;
|
|
649
958
|
}
|
|
650
|
-
/**
|
|
651
|
-
* Acquire a process for a given cwd. Spawns if needed.
|
|
652
|
-
*/
|
|
959
|
+
/** Acquire an atomically reserved process lease for a cwd. */
|
|
653
960
|
async acquireProcess(cwd) {
|
|
654
|
-
if (!this.piAvailable)
|
|
961
|
+
if (!this.piAvailable)
|
|
655
962
|
throw new Error(makePiNotFoundMessage(this.piLaunch.command));
|
|
656
|
-
}
|
|
657
963
|
const timeoutMs = 60000;
|
|
658
964
|
const start = Date.now();
|
|
659
965
|
while (true) {
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const evicted = this.pool.evictIdleDifferentCwd(cwd, unavailable);
|
|
665
|
-
if (evicted) {
|
|
966
|
+
const lease = this.pool.acquire(cwd);
|
|
967
|
+
if (lease)
|
|
968
|
+
return lease;
|
|
969
|
+
if (this.pool.evictIdleDifferentCwd(cwd)) {
|
|
666
970
|
await this.sleep(50);
|
|
667
971
|
continue;
|
|
668
972
|
}
|
|
@@ -672,38 +976,32 @@ export class WsHandler {
|
|
|
672
976
|
await this.sleep(100);
|
|
673
977
|
}
|
|
674
978
|
}
|
|
675
|
-
|
|
676
|
-
const
|
|
677
|
-
if (
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
979
|
+
async acquireAnyProcess() {
|
|
980
|
+
const existing = this.pool.acquireAny();
|
|
981
|
+
if (existing)
|
|
982
|
+
return existing;
|
|
983
|
+
return this.acquireProcess(this.defaultCwd);
|
|
984
|
+
}
|
|
985
|
+
releaseActor(actor, preserveLiveControls = true) {
|
|
986
|
+
const sessionPath = actor.sessionPath;
|
|
987
|
+
const proc = actor.process;
|
|
988
|
+
const liveState = actor.session?.toState();
|
|
989
|
+
if (proc)
|
|
990
|
+
this.pendingExtensionStatuses.delete(proc);
|
|
991
|
+
actor.detach();
|
|
992
|
+
// Read final messages from disk, but preserve the just-reconciled controls.
|
|
993
|
+
// This prevents JSONL flush timing from regressing the detach snapshot.
|
|
994
|
+
const disk = readSessionFromDisk(sessionPath);
|
|
995
|
+
if (liveState) {
|
|
996
|
+
if (preserveLiveControls) {
|
|
997
|
+
disk.state.model = liveState.model;
|
|
998
|
+
disk.state.thinkingLevel = liveState.thinkingLevel;
|
|
682
999
|
}
|
|
1000
|
+
disk.state.error = liveState.error;
|
|
683
1001
|
}
|
|
684
|
-
|
|
685
|
-
this.attachedSessions.delete(sessionPath);
|
|
686
|
-
this.lifecycle.detach(sessionPath);
|
|
687
|
-
// Read final state from disk and push to subscribers
|
|
688
|
-
const { json, hash } = readSessionFromDisk(sessionPath);
|
|
1002
|
+
const { json, hash } = serializeSessionState(disk.state);
|
|
689
1003
|
this.subscribedFileSizes.set(sessionPath, getSessionFileSize(sessionPath));
|
|
690
1004
|
this.pushSnapshotToSubscribers(sessionPath, json, hash);
|
|
691
|
-
if (proc) {
|
|
692
|
-
const shouldDecommission = this.decommissionProcesses.has(proc);
|
|
693
|
-
if (shouldDecommission) {
|
|
694
|
-
this.decommissionProcesses.delete(proc);
|
|
695
|
-
console.log(`[pool] Decommissioning pi#${proc.id} after completed turn`);
|
|
696
|
-
proc.process.kill("SIGTERM");
|
|
697
|
-
}
|
|
698
|
-
this.busyProcesses.delete(proc);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
getAnyProcess() {
|
|
702
|
-
let proc = this.pool.getAny(this.getUnavailableProcesses());
|
|
703
|
-
if (!proc) {
|
|
704
|
-
proc = this.pool.spawn(this.defaultCwd);
|
|
705
|
-
}
|
|
706
|
-
return proc;
|
|
707
1005
|
}
|
|
708
1006
|
pushSnapshotToSubscribers(sessionPath, json, hash) {
|
|
709
1007
|
for (const [ws, client] of this.clients) {
|
|
@@ -746,66 +1044,56 @@ export class WsHandler {
|
|
|
746
1044
|
client.lastVersion = session.version;
|
|
747
1045
|
}
|
|
748
1046
|
}
|
|
749
|
-
setupTurnEventForwarding(
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
this.procEventCleanup.delete(proc);
|
|
754
|
-
}
|
|
755
|
-
// Capture the SessionJsonl object — if it gets deleted (detach),
|
|
756
|
-
// the handler becomes a no-op because we check for it.
|
|
757
|
-
const sessionRef = this.attachedSessions.get(sessionPath);
|
|
758
|
-
if (!sessionRef) {
|
|
759
|
-
console.error(`[turn] setupTurnEventForwarding called but no SessionJsonl for ${sessionPath}`);
|
|
760
|
-
return;
|
|
1047
|
+
setupTurnEventForwarding(actor, proc, generation, turnId) {
|
|
1048
|
+
const sessionPath = actor.sessionPath;
|
|
1049
|
+
if (!actor.session || !actor.owns(proc, generation)) {
|
|
1050
|
+
throw new Error(`setupTurnEventForwarding called without actor ownership for ${sessionPath}`);
|
|
761
1051
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
// Guard: if the attached session was deleted (turn ended),
|
|
773
|
-
// this handler is stale — skip.
|
|
774
|
-
const currentSession = this.attachedSessions.get(sessionPath);
|
|
775
|
-
if (currentSession !== sessionRef)
|
|
1052
|
+
let hasStarted = false;
|
|
1053
|
+
let hasSettled = false;
|
|
1054
|
+
let resolveStarted;
|
|
1055
|
+
let resolveEnded;
|
|
1056
|
+
let resolveSettled;
|
|
1057
|
+
const started = new Promise((resolve) => { resolveStarted = resolve; });
|
|
1058
|
+
const ended = new Promise((resolve) => { resolveEnded = resolve; });
|
|
1059
|
+
const settled = new Promise((resolve) => { resolveSettled = resolve; });
|
|
1060
|
+
const eventHandler = (sourceProc, data) => {
|
|
1061
|
+
if (sourceProc !== proc || data.type === "extension_ui_request")
|
|
776
1062
|
return;
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1063
|
+
const replacementMessages = data.type === "auto_compaction_end" && data.result
|
|
1064
|
+
? readSessionFromDisk(sessionPath).state.messages
|
|
1065
|
+
: undefined;
|
|
1066
|
+
void actor.applyProcessEvent(proc, generation, data, replacementMessages).then((result) => {
|
|
1067
|
+
if (!result.accepted)
|
|
1068
|
+
return;
|
|
1069
|
+
if (result.started) {
|
|
1070
|
+
hasStarted = true;
|
|
1071
|
+
resolveStarted();
|
|
1072
|
+
}
|
|
1073
|
+
if (result.ended)
|
|
1074
|
+
resolveEnded();
|
|
1075
|
+
if (result.settled) {
|
|
1076
|
+
hasSettled = true;
|
|
1077
|
+
resolveSettled();
|
|
1078
|
+
}
|
|
1079
|
+
if (result.changed && actor.session) {
|
|
1080
|
+
this.pushUpdateToSubscribers(sessionPath, actor.session);
|
|
1081
|
+
}
|
|
1082
|
+
if (data.type === "agent_end") {
|
|
1083
|
+
debugTurn("agent_end_received", { turnId, procId: proc.id, sessionPath });
|
|
1084
|
+
}
|
|
1085
|
+
}).catch((error) => {
|
|
1086
|
+
console.error(`[session-actor] Failed to apply ${data.type} for ${sessionPath}:`, error);
|
|
1087
|
+
});
|
|
1088
|
+
};
|
|
1089
|
+
const cleanup = this.pool.subscribeEvents(eventHandler);
|
|
1090
|
+
actor.setTurnEventCleanup(generation, cleanup);
|
|
1091
|
+
return {
|
|
1092
|
+
started,
|
|
1093
|
+
ended,
|
|
1094
|
+
settled,
|
|
1095
|
+
hasStarted: () => hasStarted,
|
|
1096
|
+
hasSettled: () => hasSettled,
|
|
806
1097
|
};
|
|
807
|
-
proc.rl.on("line", lineHandler);
|
|
808
|
-
const cleanup = () => proc.rl.removeListener("line", lineHandler);
|
|
809
|
-
this.procEventCleanup.set(proc, cleanup);
|
|
810
1098
|
}
|
|
811
1099
|
}
|