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