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.
Files changed (55) hide show
  1. package/README.md +14 -3
  2. package/bin/pipane-rendezvous.js +21 -0
  3. package/bin/pipane.js +21 -1
  4. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  5. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  6. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  7. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  8. package/dist/client/assets/index-iblQYBAl.css +1 -0
  9. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  10. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  11. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  12. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  13. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  14. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  17. package/dist/server/rendezvous/server.js +247 -0
  18. package/dist/server/rendezvous/trust-store.js +432 -0
  19. package/dist/server/server/auth-guard.js +61 -0
  20. package/dist/server/server/backend-connection-authorizer.js +29 -0
  21. package/dist/server/server/backend-identity.js +167 -0
  22. package/dist/server/server/backend-protocol-handler.js +132 -0
  23. package/dist/server/server/backend-trust-store.js +157 -0
  24. package/dist/server/server/backend-webrtc.js +239 -0
  25. package/dist/server/server/conversation-file-access.js +97 -0
  26. package/dist/server/server/frame-connection.js +48 -0
  27. package/dist/server/server/frame-router.js +45 -0
  28. package/dist/server/server/ice-servers.js +24 -0
  29. package/dist/server/server/local-backend-api.js +389 -0
  30. package/dist/server/server/local-settings.js +17 -4
  31. package/dist/server/server/pi-rpc-protocol.js +407 -0
  32. package/dist/server/server/process-pool.js +27 -24
  33. package/dist/server/server/rendezvous-client.js +282 -0
  34. package/dist/server/server/rest-api.js +133 -176
  35. package/dist/server/server/server.js +163 -97
  36. package/dist/server/server/session-index.js +105 -28
  37. package/dist/server/server/session-jsonl.js +82 -5
  38. package/dist/server/server/session-path.js +145 -0
  39. package/dist/server/server/update-api.js +28 -0
  40. package/dist/server/server/update-check.js +33 -13
  41. package/dist/server/server/update-manager.js +231 -0
  42. package/dist/server/server/worktree-name.js +116 -31
  43. package/dist/server/server/ws-handler.js +267 -186
  44. package/dist/server/shared/backend-api.js +1 -0
  45. package/dist/server/shared/backend-protocol.js +164 -0
  46. package/dist/server/shared/node-trust-crypto.js +61 -0
  47. package/dist/server/shared/rendezvous-protocol.js +243 -0
  48. package/dist/server/shared/tool-runtime.js +1 -0
  49. package/dist/server/shared/trust-protocol.js +97 -0
  50. package/dist/server/shared/updates.js +4 -0
  51. package/dist/server/shared/ws-protocol.js +473 -1
  52. package/docs/protocol.md +127 -0
  53. package/package.json +21 -8
  54. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  55. 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
- * REST API endpoints for pipane.
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
- for await (const chunk of req)
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
- const raw = Buffer.concat(chunks).toString();
37
- return JSON.parse(raw || "{}");
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
- localSettingsStore = options.localSettingsStore ?? new LocalSettingsStore();
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 sessionIndex.listSessions());
25
+ res.json(await api.listSessions());
48
26
  }
49
- catch (err) {
50
- res.status(500).json({ error: err.message });
27
+ catch (error) {
28
+ sendError(res, error);
51
29
  }
52
30
  });
53
- app.get("/api/settings/local", (_req, res) => {
31
+ app.delete("/api/sessions", async (req, res) => {
54
32
  try {
55
- res.json(localSettingsStore.read());
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 (err) {
58
- res.status(500).json({ error: err.message });
39
+ catch (error) {
40
+ sendError(res, error);
59
41
  }
60
42
  });
61
- app.post("/api/settings/local/validate", async (req, res) => {
43
+ app.get("/api/sessions/fork-messages", async (req, res) => {
62
44
  try {
63
- const body = await readJsonBody(req);
64
- if (typeof body.content !== "string") {
65
- res.status(400).json({ error: "Missing 'content' string" });
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 (err) {
71
- res.status(500).json({ error: err.message });
49
+ catch (error) {
50
+ sendError(res, error);
72
51
  }
73
52
  });
74
- app.patch("/api/settings/local", async (req, res) => {
53
+ app.get("/api/sessions/raw", async (req, res) => {
75
54
  try {
76
- const body = await readJsonBody(req);
77
- if (!body || typeof body !== "object") {
78
- res.status(400).json({ error: "Request body must be a JSON object" });
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 (err) {
91
- res.status(500).json({ error: err.message });
59
+ catch (error) {
60
+ sendError(res, error);
92
61
  }
93
62
  });
94
- app.put("/api/settings/local", async (req, res) => {
63
+ app.get("/api/files/content", async (req, res) => {
95
64
  try {
96
- const body = await readJsonBody(req);
97
- if (typeof body.content !== "string") {
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
- const result = localSettingsStore.save(body.content);
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 (err) {
111
- res.status(500).json({ error: err.message });
70
+ catch (error) {
71
+ sendError(res, error);
112
72
  }
113
73
  });
114
- app.delete("/api/sessions", async (req, res) => {
74
+ app.post("/api/files/uploads", async (req, res) => {
115
75
  try {
116
- const chunks = [];
117
- for await (const chunk of req)
118
- chunks.push(chunk);
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
- const remove = () => unlink(sessionPath);
130
- if (options.runSessionMutation) {
131
- await options.runSessionMutation(sessionPath, "delete session", remove);
132
- }
133
- else {
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 (err) {
139
- res.status(500).json({ error: err.message });
86
+ catch (error) {
87
+ sendError(res, error);
140
88
  }
141
89
  });
142
- app.get("/api/sessions/fork-messages", (req, res) => {
90
+ app.post("/api/files/uploads/:uploadId/chunks", async (req, res) => {
143
91
  try {
144
- const sessionPath = req.query.path;
145
- if (!sessionPath || !sessionPath.endsWith(".jsonl")) {
146
- res.status(400).json({ error: "Missing or invalid session path" });
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
- if (!existsSync(sessionPath)) {
150
- res.status(404).json({ error: "Session file not found" });
151
- return;
152
- }
153
- const content = readFileSync(sessionPath, "utf8");
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 (err) {
179
- res.status(500).json({ error: err.message });
102
+ catch (error) {
103
+ sendError(res, error);
180
104
  }
181
105
  });
182
- app.get("/api/sessions/raw", (req, res) => {
106
+ app.post("/api/files/uploads/:uploadId/complete", async (req, res) => {
183
107
  try {
184
- const sessionPath = req.query.path;
185
- if (!sessionPath || !sessionPath.endsWith(".jsonl")) {
186
- res.status(400).json({ error: "Missing or invalid session path" });
187
- return;
188
- }
189
- if (!existsSync(sessionPath)) {
190
- res.status(404).json({ error: "Session file not found" });
191
- return;
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
- const content = readFileSync(sessionPath, "utf8");
194
- res.type("text/plain").send(content);
128
+ res.status(201).json(await api.createDirectory(body.parentPath, body.name));
195
129
  }
196
- catch (err) {
197
- res.status(500).json({ error: err.message });
130
+ catch (error) {
131
+ sendError(res, error);
198
132
  }
199
133
  });
200
- app.get("/api/browse", (req, res) => {
134
+ app.get("/api/settings/local", async (_req, res) => {
201
135
  try {
202
- const requestedPath = req.query.path || process.env.HOME || "/";
203
- const resolved = path.resolve(requestedPath.replace(/^~/, process.env.HOME || "/"));
204
- if (!existsSync(resolved)) {
205
- res.status(404).json({ error: "Path not found" });
206
- return;
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 entries = readdirSync(resolved, { withFileTypes: true });
209
- const dirs = entries
210
- .filter((e) => e.isDirectory() && !e.name.startsWith("."))
211
- .map((e) => ({
212
- name: e.name,
213
- path: path.join(resolved, e.name),
214
- }))
215
- .sort((a, b) => a.name.localeCompare(b.name));
216
- res.json({ path: resolved, dirs });
217
- }
218
- catch (err) {
219
- res.status(500).json({ error: err.message });
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
  }