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
|
@@ -1 +1,473 @@
|
|
|
1
|
-
|
|
1
|
+
/** Increment when a breaking WebSocket wire change is introduced. */
|
|
2
|
+
export const WS_PROTOCOL_VERSION = 1;
|
|
3
|
+
class ValidationFailure extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, path, message) {
|
|
6
|
+
super(`${path}: ${message}`);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function fail(path, message, code = "invalid_message") {
|
|
11
|
+
throw new ValidationFailure(code, path, message);
|
|
12
|
+
}
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function record(value, path) {
|
|
17
|
+
if (!isRecord(value))
|
|
18
|
+
fail(path, "expected an object");
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function string(value, path, allowEmpty = true) {
|
|
22
|
+
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
|
|
23
|
+
fail(path, allowEmpty ? "expected a string" : "expected a non-empty string");
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function optionalString(value, path) {
|
|
28
|
+
return value === undefined ? undefined : string(value, path);
|
|
29
|
+
}
|
|
30
|
+
function boolean(value, path) {
|
|
31
|
+
if (typeof value !== "boolean")
|
|
32
|
+
fail(path, "expected a boolean");
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function integer(value, path) {
|
|
36
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
37
|
+
fail(path, "expected a non-negative safe integer");
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function finiteNumber(value, path) {
|
|
42
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
43
|
+
fail(path, "expected a finite number");
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function array(value, path) {
|
|
47
|
+
if (!Array.isArray(value))
|
|
48
|
+
fail(path, "expected an array");
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function stringArray(value, path) {
|
|
52
|
+
return array(value, path).map((item, index) => string(item, `${path}[${index}]`));
|
|
53
|
+
}
|
|
54
|
+
function stringRecord(value, path) {
|
|
55
|
+
const source = record(value, path);
|
|
56
|
+
for (const [key, item] of Object.entries(source))
|
|
57
|
+
string(item, `${path}.${key}`);
|
|
58
|
+
return source;
|
|
59
|
+
}
|
|
60
|
+
function statusRecord(value, path) {
|
|
61
|
+
const source = record(value, path);
|
|
62
|
+
for (const [key, item] of Object.entries(source)) {
|
|
63
|
+
if (item !== "running" && item !== "done")
|
|
64
|
+
fail(`${path}.${key}`, "expected 'running' or 'done'");
|
|
65
|
+
}
|
|
66
|
+
return source;
|
|
67
|
+
}
|
|
68
|
+
function queuesRecord(value, path) {
|
|
69
|
+
const source = record(value, path);
|
|
70
|
+
for (const [key, item] of Object.entries(source))
|
|
71
|
+
stringArray(item, `${path}.${key}`);
|
|
72
|
+
return source;
|
|
73
|
+
}
|
|
74
|
+
function thinkingLevel(value, path) {
|
|
75
|
+
if (!["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(value))) {
|
|
76
|
+
fail(path, "expected a supported thinking level");
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function compactModel(value, path) {
|
|
81
|
+
const model = record(value, path);
|
|
82
|
+
string(model.provider, `${path}.provider`, false);
|
|
83
|
+
string(model.modelId, `${path}.modelId`, false);
|
|
84
|
+
return model;
|
|
85
|
+
}
|
|
86
|
+
function wireModel(value, path) {
|
|
87
|
+
const model = record(value, path);
|
|
88
|
+
string(model.provider, `${path}.provider`, false);
|
|
89
|
+
const id = optionalString(model.id, `${path}.id`);
|
|
90
|
+
const modelId = optionalString(model.modelId, `${path}.modelId`);
|
|
91
|
+
if (!id && !modelId)
|
|
92
|
+
fail(path, "expected id or modelId");
|
|
93
|
+
return model;
|
|
94
|
+
}
|
|
95
|
+
function images(value, path) {
|
|
96
|
+
return array(value, path).map((item, index) => {
|
|
97
|
+
const image = record(item, `${path}[${index}]`);
|
|
98
|
+
if (image.type !== "image")
|
|
99
|
+
fail(`${path}[${index}].type`, "expected 'image'");
|
|
100
|
+
string(image.data, `${path}[${index}].data`);
|
|
101
|
+
string(image.mimeType, `${path}[${index}].mimeType`, false);
|
|
102
|
+
return image;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function optionalCommandFields(command, path) {
|
|
106
|
+
if (command.thinkingLevel !== undefined)
|
|
107
|
+
thinkingLevel(command.thinkingLevel, `${path}.thinkingLevel`);
|
|
108
|
+
if (command.controlRevision !== undefined)
|
|
109
|
+
integer(command.controlRevision, `${path}.controlRevision`);
|
|
110
|
+
if (command.images !== undefined)
|
|
111
|
+
images(command.images, `${path}.images`);
|
|
112
|
+
}
|
|
113
|
+
function validateVersion(message, path) {
|
|
114
|
+
if (message.protocolVersion !== WS_PROTOCOL_VERSION) {
|
|
115
|
+
const actual = message.protocolVersion === undefined ? "missing" : JSON.stringify(message.protocolVersion);
|
|
116
|
+
fail(`${path}.protocolVersion`, `unsupported protocol version ${actual}; expected ${WS_PROTOCOL_VERSION}`, "unsupported_version");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function decodeJson(raw) {
|
|
120
|
+
try {
|
|
121
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: {
|
|
127
|
+
code: "invalid_json",
|
|
128
|
+
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
requestId: null,
|
|
130
|
+
command: "parse",
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function decodeFailure(value, failure, fallbackCommand) {
|
|
136
|
+
const source = isRecord(value) ? value : undefined;
|
|
137
|
+
const requestId = typeof source?.id === "string" ? source.id : null;
|
|
138
|
+
const command = typeof source?.type === "string" ? source.type : fallbackCommand;
|
|
139
|
+
const validation = failure instanceof ValidationFailure
|
|
140
|
+
? failure
|
|
141
|
+
: new ValidationFailure("invalid_message", "$", failure instanceof Error ? failure.message : String(failure));
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: {
|
|
145
|
+
code: validation.code,
|
|
146
|
+
message: validation.message,
|
|
147
|
+
requestId,
|
|
148
|
+
command,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export function decodeClientCommand(raw) {
|
|
153
|
+
const parsed = decodeJson(raw);
|
|
154
|
+
if (!parsed.ok)
|
|
155
|
+
return parsed;
|
|
156
|
+
const value = parsed.value;
|
|
157
|
+
try {
|
|
158
|
+
const command = record(value, "$command");
|
|
159
|
+
validateVersion(command, "$command");
|
|
160
|
+
string(command.id, "$command.id", false);
|
|
161
|
+
const type = string(command.type, "$command.type", false);
|
|
162
|
+
const sessionPath = () => string(command.sessionPath, "$command.sessionPath");
|
|
163
|
+
switch (type) {
|
|
164
|
+
case "install_pi":
|
|
165
|
+
case "get_available_models":
|
|
166
|
+
case "get_default_model":
|
|
167
|
+
case "get_session_statuses":
|
|
168
|
+
case "reload_processes":
|
|
169
|
+
break;
|
|
170
|
+
case "get_commands":
|
|
171
|
+
optionalString(command.sessionPath, "$command.sessionPath");
|
|
172
|
+
optionalString(command.cwd, "$command.cwd");
|
|
173
|
+
break;
|
|
174
|
+
case "subscribe_session":
|
|
175
|
+
case "abort":
|
|
176
|
+
case "hard_kill":
|
|
177
|
+
case "get_session_stats":
|
|
178
|
+
sessionPath();
|
|
179
|
+
break;
|
|
180
|
+
case "prompt":
|
|
181
|
+
sessionPath();
|
|
182
|
+
string(command.message, "$command.message");
|
|
183
|
+
optionalString(command.cwd, "$command.cwd");
|
|
184
|
+
compactModel(command.model, "$command.model");
|
|
185
|
+
optionalCommandFields(command, "$command");
|
|
186
|
+
break;
|
|
187
|
+
case "steer":
|
|
188
|
+
sessionPath();
|
|
189
|
+
string(command.message, "$command.message");
|
|
190
|
+
break;
|
|
191
|
+
case "remove_steering":
|
|
192
|
+
sessionPath();
|
|
193
|
+
integer(command.index, "$command.index");
|
|
194
|
+
break;
|
|
195
|
+
case "compact":
|
|
196
|
+
sessionPath();
|
|
197
|
+
optionalString(command.customInstructions, "$command.customInstructions");
|
|
198
|
+
break;
|
|
199
|
+
case "fork":
|
|
200
|
+
sessionPath();
|
|
201
|
+
string(command.entryId, "$command.entryId", false);
|
|
202
|
+
break;
|
|
203
|
+
case "fork_prompt":
|
|
204
|
+
sessionPath();
|
|
205
|
+
string(command.message, "$command.message");
|
|
206
|
+
compactModel(command.model, "$command.model");
|
|
207
|
+
optionalCommandFields(command, "$command");
|
|
208
|
+
break;
|
|
209
|
+
case "set_session_name":
|
|
210
|
+
sessionPath();
|
|
211
|
+
string(command.name, "$command.name");
|
|
212
|
+
break;
|
|
213
|
+
default:
|
|
214
|
+
fail("$command.type", `unknown command '${type}'`, "unknown_command");
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, value: command };
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return decodeFailure(value, error, "unknown");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function validateSlashCommands(value, path) {
|
|
223
|
+
array(value, path).forEach((item, index) => {
|
|
224
|
+
const command = record(item, `${path}[${index}]`);
|
|
225
|
+
string(command.name, `${path}[${index}].name`, false);
|
|
226
|
+
optionalString(command.description, `${path}[${index}].description`);
|
|
227
|
+
if (!["extension", "prompt", "skill"].includes(String(command.source))) {
|
|
228
|
+
fail(`${path}[${index}].source`, "expected extension, prompt, or skill");
|
|
229
|
+
}
|
|
230
|
+
if (command.sourceInfo !== undefined)
|
|
231
|
+
record(command.sourceInfo, `${path}[${index}].sourceInfo`);
|
|
232
|
+
optionalString(command.location, `${path}[${index}].location`);
|
|
233
|
+
optionalString(command.path, `${path}[${index}].path`);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function validateSessionStats(value, path) {
|
|
237
|
+
const stats = record(value, path);
|
|
238
|
+
string(stats.sessionFile, `${path}.sessionFile`, false);
|
|
239
|
+
string(stats.sessionId, `${path}.sessionId`, false);
|
|
240
|
+
for (const field of ["userMessages", "assistantMessages", "toolCalls", "toolResults", "totalMessages"]) {
|
|
241
|
+
integer(stats[field], `${path}.${field}`);
|
|
242
|
+
}
|
|
243
|
+
const tokens = record(stats.tokens, `${path}.tokens`);
|
|
244
|
+
for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"]) {
|
|
245
|
+
integer(tokens[field], `${path}.tokens.${field}`);
|
|
246
|
+
}
|
|
247
|
+
if (finiteNumber(stats.cost, `${path}.cost`) < 0)
|
|
248
|
+
fail(`${path}.cost`, "expected a non-negative number");
|
|
249
|
+
if (stats.contextUsage !== undefined) {
|
|
250
|
+
const context = record(stats.contextUsage, `${path}.contextUsage`);
|
|
251
|
+
if (context.tokens !== null)
|
|
252
|
+
integer(context.tokens, `${path}.contextUsage.tokens`);
|
|
253
|
+
integer(context.contextWindow, `${path}.contextUsage.contextWindow`);
|
|
254
|
+
if (context.percent !== null && finiteNumber(context.percent, `${path}.contextUsage.percent`) < 0) {
|
|
255
|
+
fail(`${path}.contextUsage.percent`, "expected a non-negative number or null");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function validateSuccessData(command, value, path) {
|
|
260
|
+
const data = record(value, path);
|
|
261
|
+
switch (command) {
|
|
262
|
+
case "install_pi":
|
|
263
|
+
case "subscribe_session":
|
|
264
|
+
case "steer":
|
|
265
|
+
case "remove_steering":
|
|
266
|
+
case "abort":
|
|
267
|
+
case "set_session_name":
|
|
268
|
+
break;
|
|
269
|
+
case "prompt":
|
|
270
|
+
case "fork_prompt":
|
|
271
|
+
string(data.newSessionPath, `${path}.newSessionPath`, false);
|
|
272
|
+
break;
|
|
273
|
+
case "hard_kill":
|
|
274
|
+
boolean(data.killed, `${path}.killed`);
|
|
275
|
+
if (data.reason !== undefined && data.reason !== "signal_failed" && data.reason !== "not_attached") {
|
|
276
|
+
fail(`${path}.reason`, "expected signal_failed or not_attached");
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
case "compact":
|
|
280
|
+
break;
|
|
281
|
+
case "get_available_models":
|
|
282
|
+
array(data.models, `${path}.models`).forEach((model, index) => wireModel(model, `${path}.models[${index}]`));
|
|
283
|
+
break;
|
|
284
|
+
case "get_default_model":
|
|
285
|
+
if (data.model !== null)
|
|
286
|
+
wireModel(data.model, `${path}.model`);
|
|
287
|
+
thinkingLevel(data.thinkingLevel, `${path}.thinkingLevel`);
|
|
288
|
+
break;
|
|
289
|
+
case "get_session_statuses":
|
|
290
|
+
statusRecord(data.statuses, `${path}.statuses`);
|
|
291
|
+
break;
|
|
292
|
+
case "get_session_stats":
|
|
293
|
+
validateSessionStats(data, path);
|
|
294
|
+
break;
|
|
295
|
+
case "fork":
|
|
296
|
+
string(data.text, `${path}.text`);
|
|
297
|
+
boolean(data.cancelled, `${path}.cancelled`);
|
|
298
|
+
if (data.newSessionPath !== null)
|
|
299
|
+
string(data.newSessionPath, `${path}.newSessionPath`, false);
|
|
300
|
+
break;
|
|
301
|
+
case "get_commands":
|
|
302
|
+
validateSlashCommands(data.commands, `${path}.commands`);
|
|
303
|
+
break;
|
|
304
|
+
case "reload_processes":
|
|
305
|
+
integer(data.killed, `${path}.killed`);
|
|
306
|
+
integer(data.draining, `${path}.draining`);
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const CLIENT_COMMAND_TYPES = new Set([
|
|
311
|
+
"install_pi",
|
|
312
|
+
"subscribe_session",
|
|
313
|
+
"prompt",
|
|
314
|
+
"steer",
|
|
315
|
+
"remove_steering",
|
|
316
|
+
"abort",
|
|
317
|
+
"hard_kill",
|
|
318
|
+
"compact",
|
|
319
|
+
"get_available_models",
|
|
320
|
+
"get_default_model",
|
|
321
|
+
"get_session_statuses",
|
|
322
|
+
"get_session_stats",
|
|
323
|
+
"fork",
|
|
324
|
+
"fork_prompt",
|
|
325
|
+
"set_session_name",
|
|
326
|
+
"get_commands",
|
|
327
|
+
"reload_processes",
|
|
328
|
+
]);
|
|
329
|
+
function clientCommandType(value, path) {
|
|
330
|
+
const type = string(value, path, false);
|
|
331
|
+
if (!CLIENT_COMMAND_TYPES.has(type))
|
|
332
|
+
fail(path, `unknown response command '${type}'`, "unknown_message");
|
|
333
|
+
return type;
|
|
334
|
+
}
|
|
335
|
+
function validatePatches(value, path) {
|
|
336
|
+
array(value, path).forEach((item, index) => {
|
|
337
|
+
const patch = record(item, `${path}[${index}]`);
|
|
338
|
+
integer(patch.offset, `${path}[${index}].offset`);
|
|
339
|
+
integer(patch.deleteCount, `${path}[${index}].deleteCount`);
|
|
340
|
+
string(patch.insert, `${path}[${index}].insert`);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
export function decodeServerMessage(raw) {
|
|
344
|
+
const parsed = decodeJson(raw);
|
|
345
|
+
if (!parsed.ok)
|
|
346
|
+
return parsed;
|
|
347
|
+
const value = parsed.value;
|
|
348
|
+
try {
|
|
349
|
+
const message = record(value, "$message");
|
|
350
|
+
validateVersion(message, "$message");
|
|
351
|
+
const type = string(message.type, "$message.type", false);
|
|
352
|
+
switch (type) {
|
|
353
|
+
case "response": {
|
|
354
|
+
const success = boolean(message.success, "$message.success");
|
|
355
|
+
if (success) {
|
|
356
|
+
string(message.id, "$message.id", false);
|
|
357
|
+
const command = clientCommandType(message.command, "$message.command");
|
|
358
|
+
validateSuccessData(command, message.data, "$message.data");
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
if (message.id !== null)
|
|
362
|
+
string(message.id, "$message.id", false);
|
|
363
|
+
string(message.command, "$message.command", false);
|
|
364
|
+
string(message.error, "$message.error", false);
|
|
365
|
+
if (!["invalid_json", "invalid_message", "unsupported_version", "unknown_command", "unknown_message", "command_failed"].includes(String(message.code))) {
|
|
366
|
+
fail("$message.code", "expected a protocol error code");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case "init":
|
|
372
|
+
statusRecord(message.sessionStatuses, "$message.sessionStatuses");
|
|
373
|
+
queuesRecord(message.steeringQueues, "$message.steeringQueues");
|
|
374
|
+
stringRecord(message.providerUsageStatuses, "$message.providerUsageStatuses");
|
|
375
|
+
break;
|
|
376
|
+
case "pi_install_required":
|
|
377
|
+
string(message.command, "$message.command", false);
|
|
378
|
+
boolean(message.installable, "$message.installable");
|
|
379
|
+
boolean(message.installing, "$message.installing");
|
|
380
|
+
string(message.message, "$message.message", false);
|
|
381
|
+
break;
|
|
382
|
+
case "session_status_change":
|
|
383
|
+
string(message.sessionPath, "$message.sessionPath", false);
|
|
384
|
+
if (message.status !== "running" && message.status !== "done")
|
|
385
|
+
fail("$message.status", "expected running or done");
|
|
386
|
+
break;
|
|
387
|
+
case "provider_usage":
|
|
388
|
+
stringRecord(message.statuses, "$message.statuses");
|
|
389
|
+
break;
|
|
390
|
+
case "extension_status":
|
|
391
|
+
string(message.sessionPath, "$message.sessionPath", false);
|
|
392
|
+
stringRecord(message.statuses, "$message.statuses");
|
|
393
|
+
break;
|
|
394
|
+
case "session_sync":
|
|
395
|
+
string(message.sessionPath, "$message.sessionPath", false);
|
|
396
|
+
integer(message.revision, "$message.revision");
|
|
397
|
+
string(message.hash, "$message.hash", false);
|
|
398
|
+
if (message.op === "full") {
|
|
399
|
+
string(message.data, "$message.data");
|
|
400
|
+
}
|
|
401
|
+
else if (message.op === "delta") {
|
|
402
|
+
string(message.baseHash, "$message.baseHash", false);
|
|
403
|
+
validatePatches(message.patches, "$message.patches");
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
fail("$message.op", "expected full or delta");
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
case "control_state":
|
|
410
|
+
string(message.sessionPath, "$message.sessionPath", false);
|
|
411
|
+
if (message.controlRevision !== undefined)
|
|
412
|
+
integer(message.controlRevision, "$message.controlRevision");
|
|
413
|
+
wireModel(message.model, "$message.model");
|
|
414
|
+
thinkingLevel(message.thinkingLevel, "$message.thinkingLevel");
|
|
415
|
+
break;
|
|
416
|
+
case "session_attached":
|
|
417
|
+
string(message.sessionPath, "$message.sessionPath", false);
|
|
418
|
+
optionalString(message.cwd, "$message.cwd");
|
|
419
|
+
optionalString(message.firstMessage, "$message.firstMessage");
|
|
420
|
+
break;
|
|
421
|
+
case "sessions_changed":
|
|
422
|
+
string(message.file, "$message.file");
|
|
423
|
+
break;
|
|
424
|
+
default:
|
|
425
|
+
fail("$message.type", `unknown server message '${type}'`, "unknown_message");
|
|
426
|
+
}
|
|
427
|
+
return { ok: true, value: message };
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
return decodeFailure(value, error, "unknown");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
export function decodeSessionStateJson(raw) {
|
|
434
|
+
const parsed = decodeJson(raw);
|
|
435
|
+
if (!parsed.ok)
|
|
436
|
+
return parsed;
|
|
437
|
+
const value = parsed.value;
|
|
438
|
+
try {
|
|
439
|
+
const state = record(value, "$session");
|
|
440
|
+
array(state.messages, "$session.messages").forEach((item, index) => {
|
|
441
|
+
const message = record(item, `$session.messages[${index}]`);
|
|
442
|
+
string(message.role, `$session.messages[${index}].role`, false);
|
|
443
|
+
});
|
|
444
|
+
boolean(state.isStreaming, "$session.isStreaming");
|
|
445
|
+
stringArray(state.pendingToolCalls, "$session.pendingToolCalls");
|
|
446
|
+
const timings = record(state.toolCallTimings, "$session.toolCallTimings");
|
|
447
|
+
for (const [toolCallId, value] of Object.entries(timings)) {
|
|
448
|
+
const timing = record(value, `$session.toolCallTimings.${toolCallId}`);
|
|
449
|
+
finiteNumber(timing.startedAt, `$session.toolCallTimings.${toolCallId}.startedAt`);
|
|
450
|
+
if (timing.completedAt !== undefined) {
|
|
451
|
+
finiteNumber(timing.completedAt, `$session.toolCallTimings.${toolCallId}.completedAt`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (state.model !== null)
|
|
455
|
+
compactModel(state.model, "$session.model");
|
|
456
|
+
thinkingLevel(state.thinkingLevel, "$session.thinkingLevel");
|
|
457
|
+
stringArray(state.steeringQueue, "$session.steeringQueue");
|
|
458
|
+
optionalString(state.error, "$session.error");
|
|
459
|
+
return { ok: true, value: state };
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
return decodeFailure(value, error, "session_sync");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
export function encodeClientCommand(payload, id) {
|
|
466
|
+
return JSON.stringify({ protocolVersion: WS_PROTOCOL_VERSION, id, ...payload });
|
|
467
|
+
}
|
|
468
|
+
export function encodeServerMessage(payload) {
|
|
469
|
+
return JSON.stringify({ protocolVersion: WS_PROTOCOL_VERSION, ...payload });
|
|
470
|
+
}
|
|
471
|
+
export function assertNever(value) {
|
|
472
|
+
throw new Error(`Unhandled protocol variant: ${JSON.stringify(value)}`);
|
|
473
|
+
}
|
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# pipane Protocol Contracts
|
|
2
|
+
|
|
3
|
+
## Browser WebSocket protocol
|
|
4
|
+
|
|
5
|
+
The shared contract lives in `src/shared/ws-protocol.ts`. Every browser/server message is a JSON object containing:
|
|
6
|
+
|
|
7
|
+
- `protocolVersion`: currently `1`
|
|
8
|
+
- `type`: a discriminant for exhaustive dispatch
|
|
9
|
+
- `id`: required on client commands and correlated command responses
|
|
10
|
+
|
|
11
|
+
The server rejects invalid JSON, unsupported versions, unknown commands, and invalid fields before command dispatch. Failures use a `response` envelope with a stable error `code`, a useful `error` message, and the request id when one could be recovered.
|
|
12
|
+
|
|
13
|
+
### Client commands
|
|
14
|
+
|
|
15
|
+
The v1 command union covers installation, session subscription, prompting and steering, steering removal, abort and hard kill, compaction, model and command discovery, session statuses and authoritative session statistics, fork and fork/prompt, session naming, and process reload.
|
|
16
|
+
|
|
17
|
+
`WsAgentAdapter.send()` is generic over this union. Its result type is selected from `CommandResponseDataMap`, so a response for one command cannot be consumed as another command's data. The browser also checks that the response command matches the pending request before resolving it. Command discovery may include an active `sessionPath` or virtual-session `cwd`; the server uses that context so Pi returns the correct project-scoped prompts and skills.
|
|
18
|
+
|
|
19
|
+
### Server messages
|
|
20
|
+
|
|
21
|
+
The v1 server union covers:
|
|
22
|
+
|
|
23
|
+
- command success and failure responses
|
|
24
|
+
- initialization and Pi-install status
|
|
25
|
+
- global session status changes
|
|
26
|
+
- account and session extension statuses
|
|
27
|
+
- session synchronization
|
|
28
|
+
- effective model/thinking controls
|
|
29
|
+
- newly attached sessions
|
|
30
|
+
- session-directory change notifications
|
|
31
|
+
|
|
32
|
+
All outbound production messages use `encodeServerMessage()`, which attaches the current protocol version. The browser validates the complete envelope before mutating client state and uses exhaustive dispatch.
|
|
33
|
+
|
|
34
|
+
### Session revisions
|
|
35
|
+
|
|
36
|
+
Every `session_sync` message includes a non-negative `revision`. Revisions increase when the authoritative serialized state changes. A full snapshot establishes a revision; each following delta must be the next revision. A gap causes the browser to discard its sync base and request a fresh authoritative snapshot.
|
|
37
|
+
|
|
38
|
+
The existing hash and patch fields remain in v1. Revisions make ordering and recovery explicit without pre-empting the future semantic-update protocol.
|
|
39
|
+
|
|
40
|
+
## Rendezvous, pairing, and connection trust
|
|
41
|
+
|
|
42
|
+
The control-plane contract lives in `src/shared/rendezvous-protocol.ts` and is independently versioned by `RENDEZVOUS_PROTOCOL_VERSION`, currently `2`. The standalone rendezvous process exposes:
|
|
43
|
+
|
|
44
|
+
- `/v2/rendezvous/backend` for persistent outbound backend registration, pairing control, and signaling
|
|
45
|
+
- `/v2/rendezvous/browser` for one ticket-authorized browser/backend signaling route
|
|
46
|
+
|
|
47
|
+
Every frame is validated JSON containing `protocolVersion` and a `type` discriminant. Rendezvous forwards trust metadata plus ICE descriptions/candidates only; application frames never pass through it.
|
|
48
|
+
|
|
49
|
+
### Backend registration and ICE
|
|
50
|
+
|
|
51
|
+
The server sends a fresh random `challenge`. A backend responds with `register_backend`, containing its P-256 public key, metadata, and an ES256 signature over the domain-separated challenge. Rendezvous derives `backendId` from the SHA-256 fingerprint of canonical SPKI bytes and replies with `registered`, its ticket-verification public key, and short-lived ICE server credentials. Backend identity and trust files are mode `0600` and survive restarts.
|
|
52
|
+
|
|
53
|
+
The backend client reconnects its outbound WebSocket with bounded exponential backoff and repeats challenge authentication. STUN servers may be static. TURN credentials use coturn's REST convention: an expiring `timestamp:subject` username and HMAC-SHA1 credential; fresh backend credentials accompany each `connection_request` rather than expiring in a long-running peer manager. Browser and backend support relay-only ICE policy; deterministic E2E forces both peers through a local UDP TURN relay.
|
|
54
|
+
|
|
55
|
+
Backend registration uses `PIPANE_RENDEZVOUS_URL` and optional `PIPANE_APP_URL`/`PIPANE_BACKEND_NAME`. The rendezvous executable reads comma-separated `PIPANE_STUN_URLS` and `PIPANE_TURN_URLS`, plus `PIPANE_TURN_SECRET`; durable central identity/account state defaults to `~/.config/pipane-rendezvous` or `PIPANE_RENDEZVOUS_DATA_DIR`.
|
|
56
|
+
|
|
57
|
+
### Device identity and anonymous accounts
|
|
58
|
+
|
|
59
|
+
The browser creates a non-exportable P-256 private key and stores the `CryptoKey` in IndexedDB. `deviceId` is the SHA-256 fingerprint of its canonical public SPKI. Central trust endpoints issue one-use challenges for pairing, normal connection tickets, authorized-backend discovery, device revocation, and backend-grant revocation. The device signs every challenge; no permanent bearer credential is stored in the browser.
|
|
60
|
+
|
|
61
|
+
- `POST /v1/auth/challenges`
|
|
62
|
+
- `POST /v1/pairings/:pairId/tickets`
|
|
63
|
+
- `POST /v1/connections/tickets`
|
|
64
|
+
- `POST /v1/accounts/backends`
|
|
65
|
+
- `POST /v1/revocations/devices`
|
|
66
|
+
- `POST /v1/revocations/backends`
|
|
67
|
+
|
|
68
|
+
The first backend-confirmed pairing creates an anonymous account. Later terminal pairings can add another browser device to the backend's owner account, or an authenticated device can add an unowned backend to its account. A signed `discover` challenge returns only that account's backend grants, with registration metadata and current reachability. A backend has one owning account in this protocol version.
|
|
69
|
+
|
|
70
|
+
### Pairing capabilities
|
|
71
|
+
|
|
72
|
+
The backend creates a 256-bit secret, stores only its hash, and publishes the opaque pair id and expiry with `open_pairing`. The QR URL has this shape:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
https://app.example/pair/pair_id#backend=b_id&secret=secret
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The secret remains in the URL fragment and never reaches HTTP access logs. Capabilities expire within fifteen minutes and are single-use at both rendezvous and backend. `pipane pair` asks the running local backend for a fresh QR link. The backend validates the secret over the end-to-end DataChannel before sending `confirm_pairing`; only then does rendezvous create or extend the anonymous account and backend grant.
|
|
79
|
+
|
|
80
|
+
### Connection tickets and signaling
|
|
81
|
+
|
|
82
|
+
A browser first signs a purpose- and route-scoped central challenge. Rendezvous then signs a short-lived ticket containing ticket id, kind, account (after pairing), device id and public key, backend id, browser-generated connection id, issue time, expiry, and optional pair id. `connect_backend` requires this ticket. Rendezvous and backend each reject ticket replay, expiry, route mismatch, revoked devices, and revoked backend grants.
|
|
83
|
+
|
|
84
|
+
The browser may send only an SDP offer and the registered backend only an answer. The backend signs a `connection_binding` over the connection id, SHA-256 hashes of both exact SDP descriptions, the answer's DTLS certificate fingerprint, and ticket expiry. Rendezvous validates it against the observed signaling transcript; the browser validates the signature and pins the public key fingerprint to `backendId` before applying the answer.
|
|
85
|
+
|
|
86
|
+
### Authenticated DataChannel
|
|
87
|
+
|
|
88
|
+
The answer-side implementation accepts only a reliable ordered DataChannel with label `pipane` and subprotocol `pipane.v1`. Its first frame must contain the exact connection ticket, backend-binding signature, a device signature over both, and the pairing secret when applicable. The backend exposes the channel to `WsHandler` only after all central-ticket, local-owner, replay, revocation, device-proof, and optional pairing-secret checks pass.
|
|
89
|
+
|
|
90
|
+
Authenticated DataChannels carry the existing versioned v1 application frames through the same server connection boundary as local WebSockets. A frame router keeps those application frames isolated from semantic v2 responses on the same ordered channel. Revocation closes active rendezvous routes and matching backend peers, prevents new ticket issuance, and is retained centrally so an offline backend clears stale local ownership when it next registers.
|
|
91
|
+
|
|
92
|
+
### Semantic backend protocol v2
|
|
93
|
+
|
|
94
|
+
`src/shared/backend-protocol.ts` defines the carrier-neutral request protocol independently from application v1:
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
{ v: 2, kind: "request", id, method, params }
|
|
98
|
+
{ v: 2, kind: "response", id, method, success, result | error }
|
|
99
|
+
{ v: 2, kind: "event", cursor, type, data }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The currently implemented semantic methods are:
|
|
103
|
+
|
|
104
|
+
- `backend.capabilities`
|
|
105
|
+
- `sessions.list`, `sessions.delete`, `sessions.forkMessages`, `sessions.raw`
|
|
106
|
+
- `files.read`, `files.upload.create`, `files.upload.append`, `files.upload.complete`
|
|
107
|
+
- `host.browse`, `host.mkdir`
|
|
108
|
+
- `settings.get`, `settings.validate`, `settings.patch`, `settings.save`
|
|
109
|
+
- `updates.get`, `updates.run`
|
|
110
|
+
|
|
111
|
+
Every method has runtime-validated parameters, correlated responses, stable error codes, bounded concurrency, and a bounded device-scoped completed-request cache. File uploads use bounded, offset-addressed base64 chunks so arbitrary non-image attachments can cross either local HTTP or the authenticated DataChannel, land in a private temporary backend path, and be referenced in the agent prompt. Pending browser requests retain their id across a carrier reconnect, so an in-flight mutation is resumed or answered from the cache instead of being executed twice. The backend uses one `LocalBackendApi` implementation for both the legacy local HTTP facade and semantic DataChannel requests. Remote session results are scoped to a structured `{ backendId, path }` identity; paths remain backend-local identifiers rather than authorization.
|
|
112
|
+
|
|
113
|
+
The browser's `RemoteBackendManager` maintains one client/store per active backend id, requests a fresh ticket whenever a WebRTC carrier reconnects, and never treats an arbitrary URL backend id as authorized until signed account discovery includes it. The product UI exposes reachable authorized backends and uses on-demand pairwise connections rather than a full mesh. Terminal `pipane pair` remains the no-email recovery path when browser storage is lost.
|
|
114
|
+
|
|
115
|
+
Application streaming, turn control, and session snapshots remain on validated v1 frames during parity migration. Semantic v2 is deployed beside v1 rather than changing v1's renderer-state contract in place.
|
|
116
|
+
|
|
117
|
+
## Pi subprocess RPC protocol
|
|
118
|
+
|
|
119
|
+
`src/server/pi-rpc-protocol.ts` defines the typed subset of Pi RPC commands used by pipane and validates all corresponding responses, agent/session events, and extension UI requests before they reach session state. Pi's built-in TUI commands are not forwarded by RPC `prompt`; browser equivalents such as `/session` map to explicit typed RPC operations (`get_session_stats`) instead.
|
|
120
|
+
|
|
121
|
+
`ProcessPool.sendRpc()` is generic over Pi's exported `RpcCommand`/`RpcResponse` types. Pending requests retain their expected command, and a mismatched response is rejected rather than resolving the wrong operation.
|
|
122
|
+
|
|
123
|
+
Pi RPC uses strict JSONL framing. Records are split only on LF; Unicode line separators inside JSON strings are preserved. Invalid JSON, unknown event types, malformed event payloads, and malformed command responses are logged and ignored before event dispatch.
|
|
124
|
+
|
|
125
|
+
## Compatibility policy
|
|
126
|
+
|
|
127
|
+
A breaking browser/backend application-frame change must increment `WS_PROTOCOL_VERSION`; a breaking semantic method-envelope change must increment `BACKEND_PROTOCOL_VERSION`; a breaking rendezvous control-plane change must increment `RENDEZVOUS_PROTOCOL_VERSION`. Update the corresponding decoders and contract tests. Additive fields may be introduced without a version increment when old peers can safely ignore them. New discriminants require explicit validation and exhaustive handling on both sides.
|