pipane 0.1.6 → 0.1.8
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/build-info.json +5 -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-C7_1ODks.js +2 -0
- package/dist/client/assets/index-DgI_gkjg.css +1 -0
- package/dist/client/assets/main-DEfSB8wO.js +2822 -0
- package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
- package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
- package/dist/client/assets/ws-agent-adapter-DpkkZGPl.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 +245 -0
- package/dist/server/server/build-info.js +13 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/frame-connection.js +106 -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 +167 -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 +233 -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/data-channel-framing.js +137 -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 +129 -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,282 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { RENDEZVOUS_PROTOCOL_VERSION, decodeBackendMessage, encodeRendezvousMessage, rendezvousWebSocketUrl, } from "../shared/rendezvous-protocol.js";
|
|
3
|
+
import { signBackendChallenge } from "./backend-identity.js";
|
|
4
|
+
export { rendezvousWebSocketUrl } from "../shared/rendezvous-protocol.js";
|
|
5
|
+
export class BackendRendezvousClient {
|
|
6
|
+
endpoint;
|
|
7
|
+
identity;
|
|
8
|
+
metadata;
|
|
9
|
+
createWebSocket;
|
|
10
|
+
schedule;
|
|
11
|
+
cancelSchedule;
|
|
12
|
+
maxReconnectDelayMs;
|
|
13
|
+
statusListeners = new Set();
|
|
14
|
+
connectionListeners = new Set();
|
|
15
|
+
signalListeners = new Set();
|
|
16
|
+
closedListeners = new Set();
|
|
17
|
+
revocationListeners = new Set();
|
|
18
|
+
errorListeners = new Set();
|
|
19
|
+
activePairings = new Map();
|
|
20
|
+
pairingResolvers = new Map();
|
|
21
|
+
confirmationResolvers = new Map();
|
|
22
|
+
socket = null;
|
|
23
|
+
reconnectTimer;
|
|
24
|
+
reconnectAttempt = 0;
|
|
25
|
+
stopped = true;
|
|
26
|
+
registered = false;
|
|
27
|
+
firstRegistrationSettled = false;
|
|
28
|
+
firstRegistration;
|
|
29
|
+
resolveFirstRegistration;
|
|
30
|
+
rejectFirstRegistration;
|
|
31
|
+
_ticketPublicKey;
|
|
32
|
+
_iceServers = [];
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.endpoint = rendezvousWebSocketUrl(options.url, "backend");
|
|
35
|
+
this.identity = options.identity;
|
|
36
|
+
this.metadata = options.metadata;
|
|
37
|
+
this.createWebSocket = options.createWebSocket ?? ((url) => new WebSocket(url));
|
|
38
|
+
this.schedule = options.schedule ?? globalThis.setTimeout.bind(globalThis);
|
|
39
|
+
this.cancelSchedule = options.cancelSchedule ?? globalThis.clearTimeout.bind(globalThis);
|
|
40
|
+
this.maxReconnectDelayMs = options.maxReconnectDelayMs ?? 10_000;
|
|
41
|
+
this.firstRegistration = new Promise((resolve, reject) => {
|
|
42
|
+
this.resolveFirstRegistration = resolve;
|
|
43
|
+
this.rejectFirstRegistration = reject;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
get isRegistered() {
|
|
47
|
+
return this.registered;
|
|
48
|
+
}
|
|
49
|
+
get ticketPublicKey() {
|
|
50
|
+
if (!this._ticketPublicKey)
|
|
51
|
+
throw new Error("Rendezvous ticket verification key is unavailable");
|
|
52
|
+
return this._ticketPublicKey;
|
|
53
|
+
}
|
|
54
|
+
get iceServers() {
|
|
55
|
+
return this._iceServers.map((server) => ({
|
|
56
|
+
...server,
|
|
57
|
+
urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
start() {
|
|
61
|
+
if (this.stopped) {
|
|
62
|
+
this.stopped = false;
|
|
63
|
+
this.open();
|
|
64
|
+
}
|
|
65
|
+
return this.firstRegistration;
|
|
66
|
+
}
|
|
67
|
+
stop() {
|
|
68
|
+
if (this.stopped)
|
|
69
|
+
return;
|
|
70
|
+
this.stopped = true;
|
|
71
|
+
this.registered = false;
|
|
72
|
+
if (this.reconnectTimer) {
|
|
73
|
+
this.cancelSchedule(this.reconnectTimer);
|
|
74
|
+
this.reconnectTimer = undefined;
|
|
75
|
+
}
|
|
76
|
+
const error = new Error("Rendezvous client stopped");
|
|
77
|
+
if (!this.firstRegistrationSettled) {
|
|
78
|
+
this.firstRegistrationSettled = true;
|
|
79
|
+
this.rejectFirstRegistration(error);
|
|
80
|
+
}
|
|
81
|
+
for (const pending of this.pairingResolvers.values())
|
|
82
|
+
pending.reject(error);
|
|
83
|
+
for (const pending of this.confirmationResolvers.values())
|
|
84
|
+
pending.reject(error);
|
|
85
|
+
this.pairingResolvers.clear();
|
|
86
|
+
this.confirmationResolvers.clear();
|
|
87
|
+
this.socket?.close(1000, "Backend stopped");
|
|
88
|
+
this.socket = null;
|
|
89
|
+
}
|
|
90
|
+
onStatus(listener) {
|
|
91
|
+
this.statusListeners.add(listener);
|
|
92
|
+
return () => this.statusListeners.delete(listener);
|
|
93
|
+
}
|
|
94
|
+
onConnectionRequest(listener) {
|
|
95
|
+
this.connectionListeners.add(listener);
|
|
96
|
+
return () => this.connectionListeners.delete(listener);
|
|
97
|
+
}
|
|
98
|
+
onSignal(listener) {
|
|
99
|
+
this.signalListeners.add(listener);
|
|
100
|
+
return () => this.signalListeners.delete(listener);
|
|
101
|
+
}
|
|
102
|
+
onConnectionClosed(listener) {
|
|
103
|
+
this.closedListeners.add(listener);
|
|
104
|
+
return () => this.closedListeners.delete(listener);
|
|
105
|
+
}
|
|
106
|
+
onAuthorizationRevoked(listener) {
|
|
107
|
+
this.revocationListeners.add(listener);
|
|
108
|
+
return () => this.revocationListeners.delete(listener);
|
|
109
|
+
}
|
|
110
|
+
onError(listener) {
|
|
111
|
+
this.errorListeners.add(listener);
|
|
112
|
+
return () => this.errorListeners.delete(listener);
|
|
113
|
+
}
|
|
114
|
+
openPairing(pairId, expiresAt) {
|
|
115
|
+
this.activePairings.set(pairId, expiresAt);
|
|
116
|
+
const existing = this.pairingResolvers.get(pairId);
|
|
117
|
+
if (existing)
|
|
118
|
+
return Promise.reject(new Error("Pairing request is already pending"));
|
|
119
|
+
const promise = new Promise((resolve, reject) => this.pairingResolvers.set(pairId, { resolve, reject }));
|
|
120
|
+
if (this.registered)
|
|
121
|
+
this.send({ type: "open_pairing", pairId, expiresAt });
|
|
122
|
+
return promise;
|
|
123
|
+
}
|
|
124
|
+
confirmPairing(connectionId) {
|
|
125
|
+
if (this.confirmationResolvers.has(connectionId))
|
|
126
|
+
return Promise.reject(new Error("Pairing confirmation is already pending"));
|
|
127
|
+
const promise = new Promise((resolve, reject) => {
|
|
128
|
+
this.confirmationResolvers.set(connectionId, { resolve, reject });
|
|
129
|
+
});
|
|
130
|
+
this.send({ type: "confirm_pairing", connectionId });
|
|
131
|
+
return promise;
|
|
132
|
+
}
|
|
133
|
+
sendSignal(connectionId, signal) {
|
|
134
|
+
this.send({ type: "signal", connectionId, signal });
|
|
135
|
+
}
|
|
136
|
+
sendIdentityBinding(connectionId, binding) {
|
|
137
|
+
this.send({ type: "connection_binding", connectionId, binding });
|
|
138
|
+
}
|
|
139
|
+
closeConnection(connectionId, reason) {
|
|
140
|
+
this.send({ type: "close_connection", connectionId, reason });
|
|
141
|
+
}
|
|
142
|
+
open() {
|
|
143
|
+
if (this.stopped)
|
|
144
|
+
return;
|
|
145
|
+
const socket = this.createWebSocket(this.endpoint);
|
|
146
|
+
this.socket = socket;
|
|
147
|
+
socket.on("message", (raw) => this.handleMessage(socket, raw.toString()));
|
|
148
|
+
socket.on("error", (error) => this.emitError(error));
|
|
149
|
+
socket.on("close", () => {
|
|
150
|
+
if (this.socket !== socket)
|
|
151
|
+
return;
|
|
152
|
+
this.socket = null;
|
|
153
|
+
const interruption = new Error("Rendezvous disconnected during pairing confirmation");
|
|
154
|
+
for (const pending of this.confirmationResolvers.values())
|
|
155
|
+
pending.reject(interruption);
|
|
156
|
+
this.confirmationResolvers.clear();
|
|
157
|
+
const wasRegistered = this.registered;
|
|
158
|
+
this.registered = false;
|
|
159
|
+
if (wasRegistered)
|
|
160
|
+
this.emitStatus(false);
|
|
161
|
+
if (!this.stopped)
|
|
162
|
+
this.scheduleReconnect();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
handleMessage(socket, raw) {
|
|
166
|
+
if (socket !== this.socket)
|
|
167
|
+
return;
|
|
168
|
+
const decoded = decodeBackendMessage(raw);
|
|
169
|
+
if (!decoded.ok) {
|
|
170
|
+
this.emitError(new Error(decoded.error.message));
|
|
171
|
+
socket.close(1002, "Invalid rendezvous message");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const message = decoded.value;
|
|
175
|
+
switch (message.type) {
|
|
176
|
+
case "challenge":
|
|
177
|
+
this.sendRaw({
|
|
178
|
+
type: "register_backend",
|
|
179
|
+
publicKey: this.identity.publicKey,
|
|
180
|
+
signature: signBackendChallenge(this.identity, message.nonce),
|
|
181
|
+
metadata: this.metadata,
|
|
182
|
+
});
|
|
183
|
+
break;
|
|
184
|
+
case "registered":
|
|
185
|
+
if (message.backendId !== this.identity.backendId) {
|
|
186
|
+
this.emitError(new Error("Rendezvous registered an unexpected backend identity"));
|
|
187
|
+
socket.close(1002, "Backend identity mismatch");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this._ticketPublicKey = message.ticketPublicKey;
|
|
191
|
+
this._iceServers = message.iceServers;
|
|
192
|
+
this.registered = true;
|
|
193
|
+
this.reconnectAttempt = 0;
|
|
194
|
+
this.emitStatus(true);
|
|
195
|
+
if (!this.firstRegistrationSettled) {
|
|
196
|
+
this.firstRegistrationSettled = true;
|
|
197
|
+
this.resolveFirstRegistration(message.backendId);
|
|
198
|
+
}
|
|
199
|
+
for (const [pairId, expiresAt] of this.activePairings) {
|
|
200
|
+
if (expiresAt > Date.now())
|
|
201
|
+
this.send({ type: "open_pairing", pairId, expiresAt });
|
|
202
|
+
else
|
|
203
|
+
this.activePairings.delete(pairId);
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
case "pairing_opened": {
|
|
207
|
+
const pending = this.pairingResolvers.get(message.pairId);
|
|
208
|
+
this.pairingResolvers.delete(message.pairId);
|
|
209
|
+
pending?.resolve();
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "pairing_confirmed": {
|
|
213
|
+
const pending = this.confirmationResolvers.get(message.connectionId);
|
|
214
|
+
this.confirmationResolvers.delete(message.connectionId);
|
|
215
|
+
this.activePairings.delete(message.pairId);
|
|
216
|
+
pending?.resolve(message);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "connection_request":
|
|
220
|
+
for (const listener of this.connectionListeners)
|
|
221
|
+
listener({
|
|
222
|
+
connectionId: message.connectionId,
|
|
223
|
+
ticket: message.ticket,
|
|
224
|
+
iceServers: message.iceServers,
|
|
225
|
+
});
|
|
226
|
+
break;
|
|
227
|
+
case "signal":
|
|
228
|
+
for (const listener of this.signalListeners)
|
|
229
|
+
listener(message.connectionId, message.signal);
|
|
230
|
+
break;
|
|
231
|
+
case "connection_closed": {
|
|
232
|
+
const pending = this.confirmationResolvers.get(message.connectionId);
|
|
233
|
+
this.confirmationResolvers.delete(message.connectionId);
|
|
234
|
+
pending?.reject(new Error(message.reason));
|
|
235
|
+
for (const listener of this.closedListeners)
|
|
236
|
+
listener(message.connectionId, message.reason);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case "authorization_revoked":
|
|
240
|
+
for (const listener of this.revocationListeners)
|
|
241
|
+
listener(message);
|
|
242
|
+
break;
|
|
243
|
+
case "error": {
|
|
244
|
+
this.emitError(message);
|
|
245
|
+
if (message.connectionId) {
|
|
246
|
+
const pending = this.confirmationResolvers.get(message.connectionId);
|
|
247
|
+
this.confirmationResolvers.delete(message.connectionId);
|
|
248
|
+
pending?.reject(new Error(message.message));
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
send(command) {
|
|
255
|
+
if (!this.registered)
|
|
256
|
+
throw new Error("Backend is not registered with rendezvous");
|
|
257
|
+
this.sendRaw(command);
|
|
258
|
+
}
|
|
259
|
+
sendRaw(command) {
|
|
260
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
261
|
+
throw new Error("Rendezvous WebSocket is not connected");
|
|
262
|
+
this.socket.send(encodeRendezvousMessage({ protocolVersion: RENDEZVOUS_PROTOCOL_VERSION, ...command }));
|
|
263
|
+
}
|
|
264
|
+
scheduleReconnect() {
|
|
265
|
+
if (this.reconnectTimer || this.stopped)
|
|
266
|
+
return;
|
|
267
|
+
this.reconnectAttempt++;
|
|
268
|
+
const delay = Math.min(500 * 2 ** (this.reconnectAttempt - 1), this.maxReconnectDelayMs);
|
|
269
|
+
this.reconnectTimer = this.schedule(() => {
|
|
270
|
+
this.reconnectTimer = undefined;
|
|
271
|
+
this.open();
|
|
272
|
+
}, delay);
|
|
273
|
+
}
|
|
274
|
+
emitStatus(connected) {
|
|
275
|
+
for (const listener of this.statusListeners)
|
|
276
|
+
listener(connected);
|
|
277
|
+
}
|
|
278
|
+
emitError(error) {
|
|
279
|
+
for (const listener of this.errorListeners)
|
|
280
|
+
listener(error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -1,222 +1,179 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Stateless handlers that read session data from JSONL files on disk.
|
|
5
|
-
*/
|
|
6
|
-
import { existsSync, readFileSync, readdirSync, watchFile } from "node:fs";
|
|
7
|
-
import { unlink } from "node:fs/promises";
|
|
8
|
-
import path from "node:path";
|
|
9
|
-
import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { SessionIndex } from "./session-index.js";
|
|
11
|
-
import { LocalSettingsStore } from "./local-settings.js";
|
|
12
|
-
let localSettingsStore;
|
|
13
|
-
let sessionIndex;
|
|
14
|
-
let localSettingsWatcherStarted = false;
|
|
15
|
-
function startLocalSettingsWatcher(onLocalSettingsReloaded) {
|
|
16
|
-
if (localSettingsWatcherStarted)
|
|
17
|
-
return;
|
|
18
|
-
localSettingsWatcherStarted = true;
|
|
19
|
-
let debounceTimer = null;
|
|
20
|
-
watchFile(localSettingsStore.path, { interval: 500 }, () => {
|
|
21
|
-
if (debounceTimer)
|
|
22
|
-
clearTimeout(debounceTimer);
|
|
23
|
-
debounceTimer = setTimeout(async () => {
|
|
24
|
-
const changed = localSettingsStore.reloadFromDiskIfValid();
|
|
25
|
-
if (!changed)
|
|
26
|
-
return;
|
|
27
|
-
await sessionIndex.invalidateAll();
|
|
28
|
-
onLocalSettingsReloaded?.();
|
|
29
|
-
}, 150);
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
async function readJsonBody(req) {
|
|
1
|
+
/** HTTP compatibility facade over the carrier-neutral backend service. */
|
|
2
|
+
import { LocalBackendApi, LocalBackendApiError } from "./local-backend-api.js";
|
|
3
|
+
async function readJsonBody(req, maxBytes = Number.POSITIVE_INFINITY) {
|
|
33
4
|
const chunks = [];
|
|
34
|
-
|
|
5
|
+
let size = 0;
|
|
6
|
+
for await (const chunk of req) {
|
|
7
|
+
size += chunk.length;
|
|
8
|
+
if (size > maxBytes)
|
|
9
|
+
throw new LocalBackendApiError("Request body is too large", 413, "invalid_request");
|
|
35
10
|
chunks.push(chunk);
|
|
36
|
-
|
|
37
|
-
return JSON.parse(
|
|
11
|
+
}
|
|
12
|
+
return JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
|
13
|
+
}
|
|
14
|
+
function sendError(res, error) {
|
|
15
|
+
if (error instanceof LocalBackendApiError) {
|
|
16
|
+
res.status(error.status).json({ error: error.message, code: error.code });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
38
20
|
}
|
|
39
21
|
export function registerRestApi(app, options = {}) {
|
|
40
|
-
|
|
41
|
-
sessionIndex = new SessionIndex({
|
|
42
|
-
cwdDisplayFormatter: (cwd) => localSettingsStore.formatCwdTitle(cwd),
|
|
43
|
-
});
|
|
44
|
-
startLocalSettingsWatcher(options.onLocalSettingsReloaded);
|
|
22
|
+
const api = options.api ?? new LocalBackendApi(options);
|
|
45
23
|
app.get("/api/sessions", async (_req, res) => {
|
|
46
24
|
try {
|
|
47
|
-
res.json(await
|
|
25
|
+
res.json(await api.listSessions());
|
|
48
26
|
}
|
|
49
|
-
catch (
|
|
50
|
-
res
|
|
27
|
+
catch (error) {
|
|
28
|
+
sendError(res, error);
|
|
51
29
|
}
|
|
52
30
|
});
|
|
53
|
-
app.
|
|
31
|
+
app.delete("/api/sessions", async (req, res) => {
|
|
54
32
|
try {
|
|
55
|
-
|
|
33
|
+
const body = await readJsonBody(req);
|
|
34
|
+
if (typeof body.path !== "string")
|
|
35
|
+
throw new LocalBackendApiError("Missing 'path' string", 400, "invalid_request");
|
|
36
|
+
await api.deleteSession(body.path);
|
|
37
|
+
res.json({ success: true });
|
|
56
38
|
}
|
|
57
|
-
catch (
|
|
58
|
-
res
|
|
39
|
+
catch (error) {
|
|
40
|
+
sendError(res, error);
|
|
59
41
|
}
|
|
60
42
|
});
|
|
61
|
-
app.
|
|
43
|
+
app.get("/api/sessions/fork-messages", async (req, res) => {
|
|
62
44
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
res.json(localSettingsStore.validate(body.content));
|
|
45
|
+
if (typeof req.query.path !== "string")
|
|
46
|
+
throw new LocalBackendApiError("Missing 'path' query parameter", 400, "invalid_request");
|
|
47
|
+
res.json({ messages: await api.listForkMessages(req.query.path) });
|
|
69
48
|
}
|
|
70
|
-
catch (
|
|
71
|
-
res
|
|
49
|
+
catch (error) {
|
|
50
|
+
sendError(res, error);
|
|
72
51
|
}
|
|
73
52
|
});
|
|
74
|
-
app.
|
|
53
|
+
app.get("/api/sessions/raw", async (req, res) => {
|
|
75
54
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const result = localSettingsStore.patch(body);
|
|
82
|
-
if (!result.valid) {
|
|
83
|
-
res.status(400).json(result);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
await sessionIndex.invalidateAll();
|
|
87
|
-
options.onLocalSettingsReloaded?.();
|
|
88
|
-
res.json(result);
|
|
55
|
+
if (typeof req.query.path !== "string")
|
|
56
|
+
throw new LocalBackendApiError("Missing 'path' query parameter", 400, "invalid_request");
|
|
57
|
+
res.type("text/plain").send(await api.getRawSession(req.query.path));
|
|
89
58
|
}
|
|
90
|
-
catch (
|
|
91
|
-
res
|
|
59
|
+
catch (error) {
|
|
60
|
+
sendError(res, error);
|
|
92
61
|
}
|
|
93
62
|
});
|
|
94
|
-
app.
|
|
63
|
+
app.get("/api/files/content", async (req, res) => {
|
|
95
64
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
res.status(400).json({ error: "Missing 'content' string" });
|
|
99
|
-
return;
|
|
65
|
+
if (typeof req.query.sessionPath !== "string" || typeof req.query.path !== "string") {
|
|
66
|
+
throw new LocalBackendApiError("Missing 'sessionPath' or 'path' query parameter", 400, "invalid_request");
|
|
100
67
|
}
|
|
101
|
-
|
|
102
|
-
if (!result.valid) {
|
|
103
|
-
res.status(400).json(result);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
await sessionIndex.invalidateAll();
|
|
107
|
-
options.onLocalSettingsReloaded?.();
|
|
108
|
-
res.json(result);
|
|
68
|
+
res.json(await api.getFileContent(req.query.sessionPath, req.query.path));
|
|
109
69
|
}
|
|
110
|
-
catch (
|
|
111
|
-
res
|
|
70
|
+
catch (error) {
|
|
71
|
+
sendError(res, error);
|
|
112
72
|
}
|
|
113
73
|
});
|
|
114
|
-
app.
|
|
74
|
+
app.post("/api/files/uploads", async (req, res) => {
|
|
115
75
|
try {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
120
|
-
const { path: sessionPath } = body;
|
|
121
|
-
if (!sessionPath || typeof sessionPath !== "string") {
|
|
122
|
-
res.status(400).json({ error: "Missing session path" });
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (!sessionPath.endsWith(".jsonl") || !existsSync(sessionPath)) {
|
|
126
|
-
res.status(404).json({ error: "Session not found" });
|
|
127
|
-
return;
|
|
76
|
+
const body = await readJsonBody(req, 512 * 1024);
|
|
77
|
+
if (typeof body.fileName !== "string" || typeof body.mimeType !== "string" || typeof body.size !== "number") {
|
|
78
|
+
throw new LocalBackendApiError("Missing file upload metadata", 400, "invalid_request");
|
|
128
79
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await remove();
|
|
135
|
-
}
|
|
136
|
-
res.json({ success: true });
|
|
80
|
+
res.status(201).json(await api.createFileUpload({
|
|
81
|
+
fileName: body.fileName,
|
|
82
|
+
mimeType: body.mimeType,
|
|
83
|
+
size: body.size,
|
|
84
|
+
}));
|
|
137
85
|
}
|
|
138
|
-
catch (
|
|
139
|
-
res
|
|
86
|
+
catch (error) {
|
|
87
|
+
sendError(res, error);
|
|
140
88
|
}
|
|
141
89
|
});
|
|
142
|
-
app.
|
|
90
|
+
app.post("/api/files/uploads/:uploadId/chunks", async (req, res) => {
|
|
143
91
|
try {
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
return;
|
|
92
|
+
const body = await readJsonBody(req, 512 * 1024);
|
|
93
|
+
if (typeof body.offset !== "number" || typeof body.data !== "string") {
|
|
94
|
+
throw new LocalBackendApiError("Missing file upload chunk", 400, "invalid_request");
|
|
148
95
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const entries = parseSessionEntries(content);
|
|
155
|
-
const messages = [];
|
|
156
|
-
for (const entry of entries) {
|
|
157
|
-
if (entry.type !== "message")
|
|
158
|
-
continue;
|
|
159
|
-
const msg = entry.message;
|
|
160
|
-
if (!msg || msg.role !== "user")
|
|
161
|
-
continue;
|
|
162
|
-
let text = "";
|
|
163
|
-
if (typeof msg.content === "string") {
|
|
164
|
-
text = msg.content;
|
|
165
|
-
}
|
|
166
|
-
else if (Array.isArray(msg.content)) {
|
|
167
|
-
text = msg.content
|
|
168
|
-
.filter((c) => c.type === "text")
|
|
169
|
-
.map((c) => c.text)
|
|
170
|
-
.join("");
|
|
171
|
-
}
|
|
172
|
-
if (text && entry.id) {
|
|
173
|
-
messages.push({ entryId: entry.id, text });
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
res.json({ messages });
|
|
96
|
+
res.json(await api.appendFileUpload({
|
|
97
|
+
uploadId: req.params.uploadId,
|
|
98
|
+
offset: body.offset,
|
|
99
|
+
data: body.data,
|
|
100
|
+
}));
|
|
177
101
|
}
|
|
178
|
-
catch (
|
|
179
|
-
res
|
|
102
|
+
catch (error) {
|
|
103
|
+
sendError(res, error);
|
|
180
104
|
}
|
|
181
105
|
});
|
|
182
|
-
app.
|
|
106
|
+
app.post("/api/files/uploads/:uploadId/complete", async (req, res) => {
|
|
183
107
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
108
|
+
res.json(await api.completeFileUpload(req.params.uploadId));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
sendError(res, error);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
app.get("/api/browse", async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
res.json(await api.browseDirectory(typeof req.query.path === "string" ? req.query.path : ""));
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
sendError(res, error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
app.post("/api/directories", async (req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
const body = await readJsonBody(req, 16 * 1024);
|
|
125
|
+
if (typeof body.parentPath !== "string" || typeof body.name !== "string") {
|
|
126
|
+
throw new LocalBackendApiError("Missing 'parentPath' or 'name' string", 400, "invalid_request");
|
|
192
127
|
}
|
|
193
|
-
|
|
194
|
-
res.type("text/plain").send(content);
|
|
128
|
+
res.status(201).json(await api.createDirectory(body.parentPath, body.name));
|
|
195
129
|
}
|
|
196
|
-
catch (
|
|
197
|
-
res
|
|
130
|
+
catch (error) {
|
|
131
|
+
sendError(res, error);
|
|
198
132
|
}
|
|
199
133
|
});
|
|
200
|
-
app.get("/api/
|
|
134
|
+
app.get("/api/settings/local", async (_req, res) => {
|
|
201
135
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
136
|
+
res.json(await api.getLocalSettings());
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
sendError(res, error);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
app.post("/api/settings/local/validate", async (req, res) => {
|
|
143
|
+
try {
|
|
144
|
+
const body = await readJsonBody(req);
|
|
145
|
+
if (typeof body.content !== "string")
|
|
146
|
+
throw new LocalBackendApiError("Missing 'content' string", 400, "invalid_request");
|
|
147
|
+
res.json(await api.validateLocalSettings(body.content));
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
sendError(res, error);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
app.patch("/api/settings/local", async (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
const body = await readJsonBody(req);
|
|
156
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
157
|
+
throw new LocalBackendApiError("Request body must be a JSON object", 400, "invalid_request");
|
|
207
158
|
}
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
159
|
+
const result = await api.patchLocalSettings(body);
|
|
160
|
+
res.status(result.valid ? 200 : 400).json(result);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
sendError(res, error);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
app.put("/api/settings/local", async (req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const body = await readJsonBody(req);
|
|
169
|
+
if (typeof body.content !== "string")
|
|
170
|
+
throw new LocalBackendApiError("Missing 'content' string", 400, "invalid_request");
|
|
171
|
+
const result = await api.saveLocalSettings(body.content);
|
|
172
|
+
res.status(result.valid ? 200 : 400).json(result);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
sendError(res, error);
|
|
220
176
|
}
|
|
221
177
|
});
|
|
178
|
+
return api;
|
|
222
179
|
}
|