pipane 0.1.6 → 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 +14 -3
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- 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/conversation-file-access.js +97 -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 +17 -4
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/process-pool.js +27 -24
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +133 -176
- package/dist/server/server/server.js +163 -97
- package/dist/server/server/session-index.js +105 -28
- package/dist/server/server/session-jsonl.js +82 -5
- package/dist/server/server/session-path.js +145 -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 +116 -31
- package/dist/server/server/ws-handler.js +267 -186
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -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 -1
- package/docs/protocol.md +127 -0
- package/package.json +21 -8
- package/dist/client/assets/index-Dl_wdLZH.css +0 -1
- package/dist/client/assets/index-hNqbnG06.js +0 -2482
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
const SUPPORTED_COMMAND_TYPES = [
|
|
3
|
+
"prompt",
|
|
4
|
+
"steer",
|
|
5
|
+
"abort",
|
|
6
|
+
"new_session",
|
|
7
|
+
"get_state",
|
|
8
|
+
"set_model",
|
|
9
|
+
"get_available_models",
|
|
10
|
+
"set_thinking_level",
|
|
11
|
+
"compact",
|
|
12
|
+
"get_session_stats",
|
|
13
|
+
"switch_session",
|
|
14
|
+
"fork",
|
|
15
|
+
"set_session_name",
|
|
16
|
+
"get_commands",
|
|
17
|
+
];
|
|
18
|
+
class ValidationFailure extends Error {
|
|
19
|
+
constructor(path, message) {
|
|
20
|
+
super(`${path}: ${message}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function fail(path, message) {
|
|
24
|
+
throw new ValidationFailure(path, message);
|
|
25
|
+
}
|
|
26
|
+
function isRecord(value) {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
function record(value, path) {
|
|
30
|
+
if (!isRecord(value))
|
|
31
|
+
fail(path, "expected an object");
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function string(value, path, allowEmpty = true) {
|
|
35
|
+
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
|
|
36
|
+
fail(path, allowEmpty ? "expected a string" : "expected a non-empty string");
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function optionalString(value, path) {
|
|
41
|
+
if (value !== undefined)
|
|
42
|
+
string(value, path);
|
|
43
|
+
}
|
|
44
|
+
function boolean(value, path) {
|
|
45
|
+
if (typeof value !== "boolean")
|
|
46
|
+
fail(path, "expected a boolean");
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function integer(value, path) {
|
|
50
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
51
|
+
fail(path, "expected a non-negative safe integer");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function finiteNumber(value, path) {
|
|
55
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
56
|
+
fail(path, "expected a finite number");
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function array(value, path) {
|
|
60
|
+
if (!Array.isArray(value))
|
|
61
|
+
fail(path, "expected an array");
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function stringArray(value, path) {
|
|
65
|
+
array(value, path).forEach((item, index) => string(item, `${path}[${index}]`));
|
|
66
|
+
}
|
|
67
|
+
function thinkingLevel(value, path) {
|
|
68
|
+
if (!["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(value))) {
|
|
69
|
+
fail(path, "expected a supported thinking level");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function model(value, path) {
|
|
73
|
+
const source = record(value, path);
|
|
74
|
+
string(source.provider, `${path}.provider`, false);
|
|
75
|
+
string(source.id, `${path}.id`, false);
|
|
76
|
+
}
|
|
77
|
+
function agentMessage(value, path) {
|
|
78
|
+
const message = record(value, path);
|
|
79
|
+
string(message.role, `${path}.role`, false);
|
|
80
|
+
}
|
|
81
|
+
function validateState(value, path) {
|
|
82
|
+
const state = record(value, path);
|
|
83
|
+
if (state.model !== undefined && state.model !== null)
|
|
84
|
+
model(state.model, `${path}.model`);
|
|
85
|
+
thinkingLevel(state.thinkingLevel, `${path}.thinkingLevel`);
|
|
86
|
+
boolean(state.isStreaming, `${path}.isStreaming`);
|
|
87
|
+
boolean(state.isCompacting, `${path}.isCompacting`);
|
|
88
|
+
if (state.steeringMode !== "all" && state.steeringMode !== "one-at-a-time") {
|
|
89
|
+
fail(`${path}.steeringMode`, "expected all or one-at-a-time");
|
|
90
|
+
}
|
|
91
|
+
if (state.followUpMode !== "all" && state.followUpMode !== "one-at-a-time") {
|
|
92
|
+
fail(`${path}.followUpMode`, "expected all or one-at-a-time");
|
|
93
|
+
}
|
|
94
|
+
optionalString(state.sessionFile, `${path}.sessionFile`);
|
|
95
|
+
string(state.sessionId, `${path}.sessionId`);
|
|
96
|
+
optionalString(state.sessionName, `${path}.sessionName`);
|
|
97
|
+
boolean(state.autoCompactionEnabled, `${path}.autoCompactionEnabled`);
|
|
98
|
+
integer(state.messageCount, `${path}.messageCount`);
|
|
99
|
+
integer(state.pendingMessageCount, `${path}.pendingMessageCount`);
|
|
100
|
+
}
|
|
101
|
+
function validateSlashCommands(value, path) {
|
|
102
|
+
array(value, path).forEach((item, index) => {
|
|
103
|
+
const command = record(item, `${path}[${index}]`);
|
|
104
|
+
string(command.name, `${path}[${index}].name`, false);
|
|
105
|
+
optionalString(command.description, `${path}[${index}].description`);
|
|
106
|
+
if (!["extension", "prompt", "skill"].includes(String(command.source))) {
|
|
107
|
+
fail(`${path}[${index}].source`, "expected extension, prompt, or skill");
|
|
108
|
+
}
|
|
109
|
+
if (command.sourceInfo !== undefined)
|
|
110
|
+
record(command.sourceInfo, `${path}[${index}].sourceInfo`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function validateSessionStats(value, path) {
|
|
114
|
+
const stats = record(value, path);
|
|
115
|
+
optionalString(stats.sessionFile, `${path}.sessionFile`);
|
|
116
|
+
string(stats.sessionId, `${path}.sessionId`, false);
|
|
117
|
+
for (const field of ["userMessages", "assistantMessages", "toolCalls", "toolResults", "totalMessages"]) {
|
|
118
|
+
integer(stats[field], `${path}.${field}`);
|
|
119
|
+
}
|
|
120
|
+
const tokens = record(stats.tokens, `${path}.tokens`);
|
|
121
|
+
for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"]) {
|
|
122
|
+
integer(tokens[field], `${path}.tokens.${field}`);
|
|
123
|
+
}
|
|
124
|
+
if (finiteNumber(stats.cost, `${path}.cost`) < 0)
|
|
125
|
+
fail(`${path}.cost`, "expected a non-negative number");
|
|
126
|
+
if (stats.contextUsage !== undefined) {
|
|
127
|
+
const context = record(stats.contextUsage, `${path}.contextUsage`);
|
|
128
|
+
if (context.tokens !== null)
|
|
129
|
+
integer(context.tokens, `${path}.contextUsage.tokens`);
|
|
130
|
+
integer(context.contextWindow, `${path}.contextUsage.contextWindow`);
|
|
131
|
+
if (context.percent !== null && finiteNumber(context.percent, `${path}.contextUsage.percent`) < 0) {
|
|
132
|
+
fail(`${path}.contextUsage.percent`, "expected a non-negative number or null");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const SUPPORTED_COMMAND_SET = new Set(SUPPORTED_COMMAND_TYPES);
|
|
137
|
+
function commandType(value, path) {
|
|
138
|
+
const type = string(value, path, false);
|
|
139
|
+
if (!SUPPORTED_COMMAND_SET.has(type))
|
|
140
|
+
fail(path, `unexpected command '${type}'`);
|
|
141
|
+
return type;
|
|
142
|
+
}
|
|
143
|
+
function validateResponse(value) {
|
|
144
|
+
string(value.id, "$rpc.id", false);
|
|
145
|
+
const command = commandType(value.command, "$rpc.command");
|
|
146
|
+
const success = boolean(value.success, "$rpc.success");
|
|
147
|
+
if (!success) {
|
|
148
|
+
string(value.error, "$rpc.error", false);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
switch (command) {
|
|
152
|
+
case "prompt":
|
|
153
|
+
case "steer":
|
|
154
|
+
case "abort":
|
|
155
|
+
case "set_thinking_level":
|
|
156
|
+
case "set_session_name":
|
|
157
|
+
break;
|
|
158
|
+
case "new_session":
|
|
159
|
+
case "switch_session": {
|
|
160
|
+
const data = record(value.data, "$rpc.data");
|
|
161
|
+
boolean(data.cancelled, "$rpc.data.cancelled");
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case "get_state":
|
|
165
|
+
validateState(value.data, "$rpc.data");
|
|
166
|
+
break;
|
|
167
|
+
case "set_model":
|
|
168
|
+
model(value.data, "$rpc.data");
|
|
169
|
+
break;
|
|
170
|
+
case "get_available_models": {
|
|
171
|
+
const data = record(value.data, "$rpc.data");
|
|
172
|
+
array(data.models, "$rpc.data.models").forEach((item, index) => model(item, `$rpc.data.models[${index}]`));
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "compact":
|
|
176
|
+
record(value.data, "$rpc.data");
|
|
177
|
+
break;
|
|
178
|
+
case "get_session_stats":
|
|
179
|
+
validateSessionStats(value.data, "$rpc.data");
|
|
180
|
+
break;
|
|
181
|
+
case "fork": {
|
|
182
|
+
const data = record(value.data, "$rpc.data");
|
|
183
|
+
string(data.text, "$rpc.data.text");
|
|
184
|
+
boolean(data.cancelled, "$rpc.data.cancelled");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "get_commands": {
|
|
188
|
+
const data = record(value.data, "$rpc.data");
|
|
189
|
+
validateSlashCommands(data.commands, "$rpc.data.commands");
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
function validateExtensionUiRequest(value) {
|
|
196
|
+
string(value.id, "$rpc.id", false);
|
|
197
|
+
const method = string(value.method, "$rpc.method", false);
|
|
198
|
+
switch (method) {
|
|
199
|
+
case "select":
|
|
200
|
+
string(value.title, "$rpc.title");
|
|
201
|
+
stringArray(value.options, "$rpc.options");
|
|
202
|
+
if (value.timeout !== undefined)
|
|
203
|
+
integer(value.timeout, "$rpc.timeout");
|
|
204
|
+
break;
|
|
205
|
+
case "confirm":
|
|
206
|
+
string(value.title, "$rpc.title");
|
|
207
|
+
string(value.message, "$rpc.message");
|
|
208
|
+
if (value.timeout !== undefined)
|
|
209
|
+
integer(value.timeout, "$rpc.timeout");
|
|
210
|
+
break;
|
|
211
|
+
case "input":
|
|
212
|
+
string(value.title, "$rpc.title");
|
|
213
|
+
optionalString(value.placeholder, "$rpc.placeholder");
|
|
214
|
+
if (value.timeout !== undefined)
|
|
215
|
+
integer(value.timeout, "$rpc.timeout");
|
|
216
|
+
break;
|
|
217
|
+
case "editor":
|
|
218
|
+
string(value.title, "$rpc.title");
|
|
219
|
+
optionalString(value.prefill, "$rpc.prefill");
|
|
220
|
+
break;
|
|
221
|
+
case "notify":
|
|
222
|
+
string(value.message, "$rpc.message");
|
|
223
|
+
if (value.notifyType !== undefined && !["info", "warning", "error"].includes(String(value.notifyType))) {
|
|
224
|
+
fail("$rpc.notifyType", "expected info, warning, or error");
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
case "setStatus":
|
|
228
|
+
string(value.statusKey, "$rpc.statusKey", false);
|
|
229
|
+
optionalString(value.statusText, "$rpc.statusText");
|
|
230
|
+
break;
|
|
231
|
+
case "setWidget":
|
|
232
|
+
string(value.widgetKey, "$rpc.widgetKey", false);
|
|
233
|
+
if (value.widgetLines !== undefined)
|
|
234
|
+
stringArray(value.widgetLines, "$rpc.widgetLines");
|
|
235
|
+
if (value.widgetPlacement !== undefined && value.widgetPlacement !== "aboveEditor" && value.widgetPlacement !== "belowEditor") {
|
|
236
|
+
fail("$rpc.widgetPlacement", "expected aboveEditor or belowEditor");
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
case "setTitle":
|
|
240
|
+
string(value.title, "$rpc.title");
|
|
241
|
+
break;
|
|
242
|
+
case "set_editor_text":
|
|
243
|
+
string(value.text, "$rpc.text");
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
fail("$rpc.method", `unknown extension UI method '${method}'`);
|
|
247
|
+
}
|
|
248
|
+
return value;
|
|
249
|
+
}
|
|
250
|
+
function validateEvent(value, type) {
|
|
251
|
+
switch (type) {
|
|
252
|
+
case "agent_start":
|
|
253
|
+
case "agent_settled":
|
|
254
|
+
case "turn_start":
|
|
255
|
+
break;
|
|
256
|
+
case "agent_end":
|
|
257
|
+
array(value.messages, "$rpc.messages").forEach((item, index) => agentMessage(item, `$rpc.messages[${index}]`));
|
|
258
|
+
boolean(value.willRetry, "$rpc.willRetry");
|
|
259
|
+
break;
|
|
260
|
+
case "turn_end":
|
|
261
|
+
agentMessage(value.message, "$rpc.message");
|
|
262
|
+
array(value.toolResults, "$rpc.toolResults").forEach((item, index) => agentMessage(item, `$rpc.toolResults[${index}]`));
|
|
263
|
+
break;
|
|
264
|
+
case "message_start":
|
|
265
|
+
case "message_end":
|
|
266
|
+
agentMessage(value.message, "$rpc.message");
|
|
267
|
+
break;
|
|
268
|
+
case "message_update":
|
|
269
|
+
agentMessage(value.message, "$rpc.message");
|
|
270
|
+
record(value.assistantMessageEvent, "$rpc.assistantMessageEvent");
|
|
271
|
+
break;
|
|
272
|
+
case "tool_execution_start":
|
|
273
|
+
string(value.toolCallId, "$rpc.toolCallId", false);
|
|
274
|
+
string(value.toolName, "$rpc.toolName", false);
|
|
275
|
+
record(value.args, "$rpc.args");
|
|
276
|
+
break;
|
|
277
|
+
case "tool_execution_update":
|
|
278
|
+
string(value.toolCallId, "$rpc.toolCallId", false);
|
|
279
|
+
string(value.toolName, "$rpc.toolName", false);
|
|
280
|
+
record(value.args, "$rpc.args");
|
|
281
|
+
record(value.partialResult, "$rpc.partialResult");
|
|
282
|
+
break;
|
|
283
|
+
case "tool_execution_end":
|
|
284
|
+
string(value.toolCallId, "$rpc.toolCallId", false);
|
|
285
|
+
string(value.toolName, "$rpc.toolName", false);
|
|
286
|
+
record(value.result, "$rpc.result");
|
|
287
|
+
boolean(value.isError, "$rpc.isError");
|
|
288
|
+
break;
|
|
289
|
+
case "queue_update":
|
|
290
|
+
stringArray(value.steering, "$rpc.steering");
|
|
291
|
+
stringArray(value.followUp, "$rpc.followUp");
|
|
292
|
+
break;
|
|
293
|
+
case "compaction_start":
|
|
294
|
+
if (!["manual", "threshold", "overflow"].includes(String(value.reason)))
|
|
295
|
+
fail("$rpc.reason", "expected a compaction reason");
|
|
296
|
+
break;
|
|
297
|
+
case "compaction_end":
|
|
298
|
+
if (!["manual", "threshold", "overflow"].includes(String(value.reason)))
|
|
299
|
+
fail("$rpc.reason", "expected a compaction reason");
|
|
300
|
+
if (value.result !== undefined && value.result !== null)
|
|
301
|
+
record(value.result, "$rpc.result");
|
|
302
|
+
boolean(value.aborted, "$rpc.aborted");
|
|
303
|
+
boolean(value.willRetry, "$rpc.willRetry");
|
|
304
|
+
optionalString(value.errorMessage, "$rpc.errorMessage");
|
|
305
|
+
break;
|
|
306
|
+
case "auto_retry_start":
|
|
307
|
+
integer(value.attempt, "$rpc.attempt");
|
|
308
|
+
integer(value.maxAttempts, "$rpc.maxAttempts");
|
|
309
|
+
integer(value.delayMs, "$rpc.delayMs");
|
|
310
|
+
string(value.errorMessage, "$rpc.errorMessage");
|
|
311
|
+
break;
|
|
312
|
+
case "auto_retry_end":
|
|
313
|
+
boolean(value.success, "$rpc.success");
|
|
314
|
+
integer(value.attempt, "$rpc.attempt");
|
|
315
|
+
optionalString(value.finalError, "$rpc.finalError");
|
|
316
|
+
break;
|
|
317
|
+
case "entry_appended":
|
|
318
|
+
record(value.entry, "$rpc.entry");
|
|
319
|
+
break;
|
|
320
|
+
case "session_info_changed":
|
|
321
|
+
optionalString(value.name, "$rpc.name");
|
|
322
|
+
break;
|
|
323
|
+
case "thinking_level_changed":
|
|
324
|
+
thinkingLevel(value.level, "$rpc.level");
|
|
325
|
+
break;
|
|
326
|
+
case "extension_error":
|
|
327
|
+
string(value.extensionPath, "$rpc.extensionPath");
|
|
328
|
+
string(value.event, "$rpc.event");
|
|
329
|
+
string(value.error, "$rpc.error");
|
|
330
|
+
break;
|
|
331
|
+
default:
|
|
332
|
+
fail("$rpc.type", `unknown event '${type}'`);
|
|
333
|
+
}
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
/** Validate one complete line emitted by Pi RPC before it reaches state mutation. */
|
|
337
|
+
export function decodePiRpcLine(line) {
|
|
338
|
+
let parsed;
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(line);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
error: {
|
|
346
|
+
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
347
|
+
line,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const value = record(parsed, "$rpc");
|
|
353
|
+
const type = string(value.type, "$rpc.type", false);
|
|
354
|
+
if (type === "response")
|
|
355
|
+
return { ok: true, value: validateResponse(value) };
|
|
356
|
+
if (type === "extension_ui_request")
|
|
357
|
+
return { ok: true, value: validateExtensionUiRequest(value) };
|
|
358
|
+
return { ok: true, value: validateEvent(value, type) };
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
error: {
|
|
364
|
+
message: error instanceof Error ? error.message : String(error),
|
|
365
|
+
line,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Pi RPC is strict JSONL: only LF delimits records. Node readline also splits on
|
|
372
|
+
* U+2028/U+2029, so use this decoder for protocol-compliant framing.
|
|
373
|
+
*/
|
|
374
|
+
export function attachStrictJsonlReader(stream, onLine) {
|
|
375
|
+
const decoder = new StringDecoder("utf8");
|
|
376
|
+
let buffer = "";
|
|
377
|
+
const consume = (chunk) => {
|
|
378
|
+
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
379
|
+
while (true) {
|
|
380
|
+
const newline = buffer.indexOf("\n");
|
|
381
|
+
if (newline === -1)
|
|
382
|
+
break;
|
|
383
|
+
let line = buffer.slice(0, newline);
|
|
384
|
+
buffer = buffer.slice(newline + 1);
|
|
385
|
+
if (line.endsWith("\r"))
|
|
386
|
+
line = line.slice(0, -1);
|
|
387
|
+
if (line.length > 0)
|
|
388
|
+
onLine(line);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
const end = () => {
|
|
392
|
+
buffer += decoder.end();
|
|
393
|
+
if (buffer.length > 0)
|
|
394
|
+
onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
|
|
395
|
+
buffer = "";
|
|
396
|
+
};
|
|
397
|
+
stream.on("data", consume);
|
|
398
|
+
stream.on("end", end);
|
|
399
|
+
return () => {
|
|
400
|
+
stream.removeListener("data", consume);
|
|
401
|
+
stream.removeListener("end", end);
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/** Build a correlated command line after static command typing has succeeded. */
|
|
405
|
+
export function encodePiRpcCommand(command, id) {
|
|
406
|
+
return `${JSON.stringify({ ...command, id })}\n`;
|
|
407
|
+
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* reserved atomically before control returns to asynchronous application code.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import * as readline from "node:readline";
|
|
10
9
|
import { existsSync } from "node:fs";
|
|
10
|
+
import { attachStrictJsonlReader, decodePiRpcLine, encodePiRpcCommand, } from "./pi-rpc-protocol.js";
|
|
11
11
|
/** Exclusive ownership of one pooled process. */
|
|
12
12
|
export class RpcProcessLease {
|
|
13
13
|
process;
|
|
@@ -114,35 +114,37 @@ export class ProcessPool {
|
|
|
114
114
|
recentStderr.splice(0, recentStderr.length - 60);
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
|
-
const rl = readline.createInterface({ input: child.stdout, terminal: false });
|
|
118
117
|
const proc = {
|
|
119
118
|
id: procId,
|
|
120
119
|
cwd,
|
|
121
120
|
process: child,
|
|
122
|
-
|
|
121
|
+
stopReadingStdout: () => { },
|
|
123
122
|
pendingRequests: new Map(),
|
|
124
123
|
requestId: 0,
|
|
125
124
|
lastResponseTime: Date.now(),
|
|
126
125
|
recentStderr,
|
|
127
126
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
127
|
+
proc.stopReadingStdout = attachStrictJsonlReader(child.stdout, (line) => {
|
|
128
|
+
const decoded = decodePiRpcLine(line);
|
|
129
|
+
if (!decoded.ok) {
|
|
130
|
+
console.warn(`[pool] pi#${proc.id} ignored invalid RPC output: ${decoded.error.message}`);
|
|
134
131
|
return;
|
|
135
132
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
133
|
+
const message = decoded.value;
|
|
134
|
+
if (message.type === "response") {
|
|
135
|
+
const pending = message.id ? proc.pendingRequests.get(message.id) : undefined;
|
|
136
|
+
if (!pending || !message.id)
|
|
137
|
+
return;
|
|
138
|
+
proc.pendingRequests.delete(message.id);
|
|
139
|
+
proc.lastResponseTime = Date.now();
|
|
140
|
+
if (message.command !== pending.command) {
|
|
141
|
+
pending.reject(new Error(`Mismatched RPC response for ${pending.command}: received ${message.command}`));
|
|
142
|
+
return;
|
|
142
143
|
}
|
|
144
|
+
pending.resolve(message);
|
|
143
145
|
return;
|
|
144
146
|
}
|
|
145
|
-
this.emitEvent(proc,
|
|
147
|
+
this.emitEvent(proc, message);
|
|
146
148
|
});
|
|
147
149
|
child.on("error", (err) => {
|
|
148
150
|
console.error(`[pool] pi#${proc.id} spawn error: ${err.message}`);
|
|
@@ -178,6 +180,7 @@ export class ProcessPool {
|
|
|
178
180
|
lease.markReleased();
|
|
179
181
|
this.leases.delete(proc);
|
|
180
182
|
this.decommissioning.delete(proc);
|
|
183
|
+
proc.stopReadingStdout();
|
|
181
184
|
for (const pending of proc.pendingRequests.values())
|
|
182
185
|
pending.reject(error);
|
|
183
186
|
proc.pendingRequests.clear();
|
|
@@ -301,7 +304,6 @@ export class ProcessPool {
|
|
|
301
304
|
}
|
|
302
305
|
const timeout = timeoutMs ?? this.rpcTimeout;
|
|
303
306
|
const id = `req_${++proc.requestId}`;
|
|
304
|
-
const fullCommand = { ...command, id };
|
|
305
307
|
return new Promise((resolve, reject) => {
|
|
306
308
|
const timer = setTimeout(() => {
|
|
307
309
|
proc.pendingRequests.delete(id);
|
|
@@ -311,22 +313,23 @@ export class ProcessPool {
|
|
|
311
313
|
reject(new Error(`Timeout waiting for RPC response to ${command.type}`));
|
|
312
314
|
}, timeout);
|
|
313
315
|
proc.pendingRequests.set(id, {
|
|
314
|
-
|
|
316
|
+
command: command.type,
|
|
317
|
+
resolve: (response) => {
|
|
315
318
|
clearTimeout(timer);
|
|
316
|
-
resolve(
|
|
319
|
+
resolve(response);
|
|
317
320
|
},
|
|
318
|
-
reject: (
|
|
321
|
+
reject: (error) => {
|
|
319
322
|
clearTimeout(timer);
|
|
320
|
-
reject(
|
|
323
|
+
reject(error);
|
|
321
324
|
},
|
|
322
325
|
});
|
|
323
|
-
proc.process.stdin.write(
|
|
326
|
+
proc.process.stdin.write(encodePiRpcCommand(command, id));
|
|
324
327
|
});
|
|
325
328
|
}
|
|
326
329
|
async sendRpcChecked(proc, command) {
|
|
327
330
|
const response = await this.sendRpc(proc, command);
|
|
328
|
-
if (!response
|
|
329
|
-
throw new Error(response
|
|
331
|
+
if (!response.success) {
|
|
332
|
+
throw new Error(response.error || `RPC command failed: ${command.type}`);
|
|
330
333
|
}
|
|
331
334
|
return response;
|
|
332
335
|
}
|