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.
Files changed (58) 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/build-info.json +5 -0
  5. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  6. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  7. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  8. package/dist/client/assets/index-C7_1ODks.js +2 -0
  9. package/dist/client/assets/index-DgI_gkjg.css +1 -0
  10. package/dist/client/assets/main-DEfSB8wO.js +2822 -0
  11. package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
  12. package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
  13. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  14. package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
  15. package/dist/client/assets/ws-agent-adapter-DpkkZGPl.js +6 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  18. package/dist/server/rendezvous/server.js +247 -0
  19. package/dist/server/rendezvous/trust-store.js +432 -0
  20. package/dist/server/server/auth-guard.js +61 -0
  21. package/dist/server/server/backend-connection-authorizer.js +29 -0
  22. package/dist/server/server/backend-identity.js +167 -0
  23. package/dist/server/server/backend-protocol-handler.js +132 -0
  24. package/dist/server/server/backend-trust-store.js +157 -0
  25. package/dist/server/server/backend-webrtc.js +245 -0
  26. package/dist/server/server/build-info.js +13 -0
  27. package/dist/server/server/conversation-file-access.js +97 -0
  28. package/dist/server/server/frame-connection.js +106 -0
  29. package/dist/server/server/frame-router.js +45 -0
  30. package/dist/server/server/ice-servers.js +24 -0
  31. package/dist/server/server/local-backend-api.js +389 -0
  32. package/dist/server/server/local-settings.js +17 -4
  33. package/dist/server/server/pi-rpc-protocol.js +407 -0
  34. package/dist/server/server/process-pool.js +27 -24
  35. package/dist/server/server/rendezvous-client.js +282 -0
  36. package/dist/server/server/rest-api.js +133 -176
  37. package/dist/server/server/server.js +167 -97
  38. package/dist/server/server/session-index.js +105 -28
  39. package/dist/server/server/session-jsonl.js +82 -5
  40. package/dist/server/server/session-path.js +145 -0
  41. package/dist/server/server/update-api.js +28 -0
  42. package/dist/server/server/update-check.js +33 -13
  43. package/dist/server/server/update-manager.js +233 -0
  44. package/dist/server/server/worktree-name.js +116 -31
  45. package/dist/server/server/ws-handler.js +267 -186
  46. package/dist/server/shared/backend-api.js +1 -0
  47. package/dist/server/shared/backend-protocol.js +164 -0
  48. package/dist/server/shared/data-channel-framing.js +137 -0
  49. package/dist/server/shared/node-trust-crypto.js +61 -0
  50. package/dist/server/shared/rendezvous-protocol.js +243 -0
  51. package/dist/server/shared/tool-runtime.js +1 -0
  52. package/dist/server/shared/trust-protocol.js +97 -0
  53. package/dist/server/shared/updates.js +4 -0
  54. package/dist/server/shared/ws-protocol.js +473 -1
  55. package/docs/protocol.md +129 -0
  56. package/package.json +21 -8
  57. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  58. package/dist/client/assets/index-hNqbnG06.js +0 -2482
@@ -0,0 +1,389 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync, watchFile } from "node:fs";
3
+ import { mkdir, mkdtemp, open, rm, unlink } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
7
+ import { MAX_UPLOAD_FILE_BYTES } from "../shared/backend-api.js";
8
+ import { BACKEND_PROTOCOL_VERSION } from "../shared/backend-protocol.js";
9
+ import { WS_PROTOCOL_VERSION } from "../shared/ws-protocol.js";
10
+ import { conversationMentionsFile } from "./conversation-file-access.js";
11
+ import { LocalSettingsStore } from "./local-settings.js";
12
+ import { SessionIndex } from "./session-index.js";
13
+ import { getSessionCwd } from "./session-cwd.js";
14
+ import { SessionPathError, SessionPathGuard } from "./session-path.js";
15
+ const MAX_PREVIEW_FILE_BYTES = 2 * 1024 * 1024;
16
+ const MAX_UPLOAD_CHUNK_BYTES = 256 * 1024;
17
+ const MAX_ACTIVE_UPLOADS = 32;
18
+ const watchedSettingsPaths = new Set();
19
+ export class LocalBackendApiError extends Error {
20
+ status;
21
+ code;
22
+ constructor(message, status, code) {
23
+ super(message);
24
+ this.status = status;
25
+ this.code = code;
26
+ this.name = "LocalBackendApiError";
27
+ }
28
+ }
29
+ /** One semantic implementation shared by local HTTP and authenticated DataChannels. */
30
+ export class LocalBackendApi {
31
+ localSettingsStore;
32
+ sessionPaths;
33
+ backendId;
34
+ uploadDirectory;
35
+ updateManager;
36
+ onLocalSettingsReloaded;
37
+ runSessionMutation;
38
+ sessionIndex;
39
+ uploads = new Map();
40
+ constructor(options = {}) {
41
+ this.localSettingsStore = options.localSettingsStore ?? new LocalSettingsStore();
42
+ this.sessionPaths = options.sessionPaths ?? new SessionPathGuard();
43
+ const configuredBackendId = options.backendId;
44
+ this.backendId = typeof configuredBackendId === "function" ? configuredBackendId : () => configuredBackendId;
45
+ this.uploadDirectory = path.resolve(options.uploadDirectory ?? os.tmpdir());
46
+ this.updateManager = options.updateManager;
47
+ this.onLocalSettingsReloaded = options.onLocalSettingsReloaded;
48
+ this.runSessionMutation = options.runSessionMutation;
49
+ this.sessionIndex = new SessionIndex({
50
+ cwdDisplayFormatter: (cwd) => this.localSettingsStore.formatCwdTitle(cwd),
51
+ });
52
+ this.startLocalSettingsWatcher();
53
+ }
54
+ async getCapabilities() {
55
+ const backendId = this.backendId();
56
+ if (!backendId)
57
+ throw new LocalBackendApiError("Backend identity is unavailable", 503, "conflict");
58
+ return {
59
+ backendId,
60
+ semanticProtocolVersion: BACKEND_PROTOCOL_VERSION,
61
+ applicationProtocolVersions: [WS_PROTOCOL_VERSION],
62
+ features: [
63
+ "sessions",
64
+ "host-browse",
65
+ "host-mkdir",
66
+ "file-preview",
67
+ "file-upload",
68
+ "local-settings",
69
+ "updates",
70
+ ],
71
+ };
72
+ }
73
+ listSessions() {
74
+ return this.sessionIndex.listSessions();
75
+ }
76
+ async deleteSession(sessionPath) {
77
+ const resolved = this.resolveSession(sessionPath);
78
+ const remove = () => unlink(resolved);
79
+ if (this.runSessionMutation)
80
+ await this.runSessionMutation(resolved, "delete session", remove);
81
+ else
82
+ await remove();
83
+ }
84
+ async listForkMessages(sessionPath) {
85
+ const content = readFileSync(this.resolveSession(sessionPath), "utf8");
86
+ const entries = parseSessionEntries(content);
87
+ const messages = [];
88
+ for (const entry of entries) {
89
+ if (entry.type !== "message")
90
+ continue;
91
+ const message = entry.message;
92
+ if (!message || message.role !== "user")
93
+ continue;
94
+ const text = typeof message.content === "string"
95
+ ? message.content
96
+ : Array.isArray(message.content)
97
+ ? message.content.filter((part) => part.type === "text").map((part) => part.text).join("")
98
+ : "";
99
+ if (text && entry.id)
100
+ messages.push({ entryId: entry.id, text });
101
+ }
102
+ return messages;
103
+ }
104
+ browseDirectory(requestedPath) {
105
+ const resolved = resolveHostPath(requestedPath);
106
+ if (!existsSync(resolved))
107
+ throw new LocalBackendApiError("Path not found", 404, "not_found");
108
+ const dirs = readdirSync(resolved, { withFileTypes: true })
109
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
110
+ .map((entry) => ({ name: entry.name, path: path.join(resolved, entry.name) }))
111
+ .sort((left, right) => left.name.localeCompare(right.name));
112
+ return Promise.resolve({ path: resolved, dirs });
113
+ }
114
+ async createDirectory(parentPath, name) {
115
+ if (!name.trim() || name === "." || name === ".." || name.includes("/") || name.includes("\0") || Buffer.byteLength(name) > 255) {
116
+ throw new LocalBackendApiError("Folder name is invalid", 400, "invalid_request");
117
+ }
118
+ const resolvedParent = resolveHostPath(parentPath);
119
+ if (!existsSync(resolvedParent))
120
+ throw new LocalBackendApiError("Parent folder not found", 404, "not_found");
121
+ if (!statSync(resolvedParent).isDirectory()) {
122
+ throw new LocalBackendApiError("Parent path is not a folder", 400, "invalid_request");
123
+ }
124
+ const directoryPath = path.join(resolvedParent, name);
125
+ try {
126
+ await mkdir(directoryPath);
127
+ }
128
+ catch (error) {
129
+ if (error?.code === "EEXIST")
130
+ throw new LocalBackendApiError("A folder with that name already exists", 409, "conflict");
131
+ if (error?.code === "ENOENT")
132
+ throw new LocalBackendApiError("Parent folder not found", 404, "not_found");
133
+ if (error?.code === "EACCES" || error?.code === "EPERM" || error?.code === "EROFS") {
134
+ throw new LocalBackendApiError("Cannot create a folder here", 403, "forbidden");
135
+ }
136
+ throw error;
137
+ }
138
+ return { name, path: directoryPath };
139
+ }
140
+ getRawSession(sessionPath) {
141
+ return Promise.resolve(readFileSync(this.resolveSession(sessionPath), "utf8"));
142
+ }
143
+ getFileContent(sessionPath, requestedPath) {
144
+ const resolvedSession = this.resolveSession(sessionPath);
145
+ const sessionCwd = getSessionCwd(resolvedSession);
146
+ if (!sessionCwd)
147
+ throw new LocalBackendApiError("Session has no working directory", 400, "invalid_request");
148
+ const root = this.realpath(path.resolve(sessionCwd.replace(/^~/, process.env.HOME || "/")));
149
+ const requested = path.isAbsolute(requestedPath) ? requestedPath : path.resolve(root, requestedPath);
150
+ const resolved = this.realpath(requested);
151
+ if (!isPathInside(root, resolved) && !conversationMentionsFile({
152
+ sessionPath: resolvedSession,
153
+ sessionCwd,
154
+ rawRequestPath: requestedPath,
155
+ requestedPath: requested,
156
+ resolvedPath: resolved,
157
+ })) {
158
+ throw new LocalBackendApiError("File is outside the session working directory and was not mentioned in the conversation", 403, "forbidden");
159
+ }
160
+ const stat = statSync(resolved);
161
+ if (!stat.isFile())
162
+ throw new LocalBackendApiError("Path is not a file", 400, "invalid_request");
163
+ if (stat.size > MAX_PREVIEW_FILE_BYTES)
164
+ throw new LocalBackendApiError("File is too large to preview", 413, "invalid_request");
165
+ const bytes = readFileSync(resolved);
166
+ if (bytes.includes(0))
167
+ throw new LocalBackendApiError("Binary files cannot be previewed", 415, "invalid_request");
168
+ return Promise.resolve({ path: resolved, content: bytes.toString("utf8") });
169
+ }
170
+ async createFileUpload(metadata) {
171
+ if (!metadata || typeof metadata.fileName !== "string" || metadata.fileName.length === 0) {
172
+ throw new LocalBackendApiError("Upload filename is required", 400, "invalid_request");
173
+ }
174
+ if (typeof metadata.mimeType !== "string" || metadata.mimeType.length === 0 || metadata.mimeType.length > 255) {
175
+ throw new LocalBackendApiError("Upload MIME type is invalid", 400, "invalid_request");
176
+ }
177
+ if (!Number.isSafeInteger(metadata.size) || metadata.size < 0 || metadata.size > MAX_UPLOAD_FILE_BYTES) {
178
+ throw new LocalBackendApiError(`Upload exceeds the ${Math.round(MAX_UPLOAD_FILE_BYTES / 1024 / 1024)}MB size limit`, 413, "invalid_request");
179
+ }
180
+ if (this.uploads.size >= MAX_ACTIVE_UPLOADS) {
181
+ throw new LocalBackendApiError("Too many file uploads are active", 409, "conflict");
182
+ }
183
+ await mkdir(this.uploadDirectory, { recursive: true, mode: 0o700 });
184
+ const directory = await mkdtemp(path.join(this.uploadDirectory, "pipane-upload-"));
185
+ const fileName = safeUploadFileName(metadata.fileName);
186
+ const uploadPath = path.join(directory, fileName);
187
+ try {
188
+ const file = await open(uploadPath, "wx", 0o600);
189
+ try {
190
+ await file.truncate(metadata.size);
191
+ }
192
+ finally {
193
+ await file.close();
194
+ }
195
+ }
196
+ catch (error) {
197
+ await rm(directory, { recursive: true, force: true });
198
+ throw error;
199
+ }
200
+ const uploadId = randomUUID();
201
+ this.uploads.set(uploadId, {
202
+ fileName,
203
+ mimeType: metadata.mimeType,
204
+ size: metadata.size,
205
+ path: uploadPath,
206
+ received: 0,
207
+ chunks: new Map(),
208
+ });
209
+ return { uploadId };
210
+ }
211
+ async appendFileUpload(chunk) {
212
+ const upload = this.uploads.get(chunk.uploadId);
213
+ if (!upload)
214
+ throw new LocalBackendApiError("File upload was not found", 404, "not_found");
215
+ if (!Number.isSafeInteger(chunk.offset) || chunk.offset < 0) {
216
+ throw new LocalBackendApiError("Upload chunk offset is invalid", 400, "invalid_request");
217
+ }
218
+ const bytes = decodeUploadChunk(chunk.data);
219
+ if (bytes.length === 0 || bytes.length > MAX_UPLOAD_CHUNK_BYTES) {
220
+ throw new LocalBackendApiError("Upload chunk size is invalid", 413, "invalid_request");
221
+ }
222
+ const end = chunk.offset + bytes.length;
223
+ if (end > upload.size) {
224
+ throw new LocalBackendApiError("Upload chunk exceeds the declared file size", 400, "invalid_request");
225
+ }
226
+ const digest = createHash("sha256").update(bytes).digest("hex");
227
+ const prior = upload.chunks.get(chunk.offset);
228
+ if (prior) {
229
+ if (prior.length !== bytes.length || prior.digest !== digest) {
230
+ throw new LocalBackendApiError("Upload chunk conflicts with existing data", 409, "conflict");
231
+ }
232
+ await prior.operation;
233
+ return { nextOffset: end };
234
+ }
235
+ for (const [existingOffset, existing] of upload.chunks) {
236
+ if (chunk.offset < existingOffset + existing.length && existingOffset < end) {
237
+ throw new LocalBackendApiError("Upload chunk overlaps existing data", 409, "conflict");
238
+ }
239
+ }
240
+ const operation = writeUploadChunk(upload.path, chunk.offset, bytes).then(() => {
241
+ upload.received += bytes.length;
242
+ });
243
+ upload.chunks.set(chunk.offset, { length: bytes.length, digest, operation });
244
+ try {
245
+ await operation;
246
+ }
247
+ catch (error) {
248
+ upload.chunks.delete(chunk.offset);
249
+ throw error;
250
+ }
251
+ return { nextOffset: end };
252
+ }
253
+ async completeFileUpload(uploadId) {
254
+ const upload = this.uploads.get(uploadId);
255
+ if (!upload)
256
+ throw new LocalBackendApiError("File upload was not found", 404, "not_found");
257
+ await Promise.all([...upload.chunks.values()].map((chunk) => chunk.operation));
258
+ if (upload.received !== upload.size) {
259
+ throw new LocalBackendApiError(`File upload is incomplete (${upload.received} of ${upload.size} bytes received)`, 409, "conflict");
260
+ }
261
+ this.uploads.delete(uploadId);
262
+ return {
263
+ path: upload.path,
264
+ fileName: upload.fileName,
265
+ mimeType: upload.mimeType,
266
+ size: upload.size,
267
+ };
268
+ }
269
+ getLocalSettings() {
270
+ return Promise.resolve(this.localSettingsStore.read());
271
+ }
272
+ validateLocalSettings(content) {
273
+ return Promise.resolve(this.localSettingsStore.validate(content));
274
+ }
275
+ async patchLocalSettings(patch) {
276
+ const result = this.localSettingsStore.patch(patch);
277
+ if (result.valid)
278
+ await this.settingsChanged();
279
+ return result;
280
+ }
281
+ async saveLocalSettings(content) {
282
+ const result = this.localSettingsStore.save(content);
283
+ if (result.valid)
284
+ await this.settingsChanged();
285
+ return result;
286
+ }
287
+ async getUpdates() {
288
+ if (!this.updateManager)
289
+ throw new LocalBackendApiError("Update service is unavailable", 503, "conflict");
290
+ return this.updateManager.check();
291
+ }
292
+ async runUpdate(target) {
293
+ if (!this.updateManager)
294
+ throw new LocalBackendApiError("Update service is unavailable", 503, "conflict");
295
+ const result = await this.updateManager.run(target);
296
+ return { result, snapshot: this.updateManager.currentSnapshot };
297
+ }
298
+ resolveSession(sessionPath) {
299
+ try {
300
+ return this.sessionPaths.resolveExisting(sessionPath);
301
+ }
302
+ catch (error) {
303
+ if (error instanceof SessionPathError) {
304
+ throw new LocalBackendApiError(error.message, error.code === "not_found" ? 404 : 400, error.code === "not_found" ? "not_found" : "invalid_request");
305
+ }
306
+ throw error;
307
+ }
308
+ }
309
+ realpath(candidate) {
310
+ try {
311
+ return realpathSync(candidate);
312
+ }
313
+ catch (error) {
314
+ if (error?.code === "ENOENT")
315
+ throw new LocalBackendApiError("File not found", 404, "not_found");
316
+ throw error;
317
+ }
318
+ }
319
+ async settingsChanged() {
320
+ await this.sessionIndex.invalidateAll();
321
+ this.onLocalSettingsReloaded?.();
322
+ }
323
+ startLocalSettingsWatcher() {
324
+ const settingsPath = this.localSettingsStore.path;
325
+ if (watchedSettingsPaths.has(settingsPath))
326
+ return;
327
+ watchedSettingsPaths.add(settingsPath);
328
+ let debounceTimer;
329
+ watchFile(settingsPath, { interval: 500 }, () => {
330
+ if (debounceTimer)
331
+ clearTimeout(debounceTimer);
332
+ debounceTimer = setTimeout(() => {
333
+ if (!this.localSettingsStore.reloadFromDiskIfValid())
334
+ return;
335
+ void this.settingsChanged();
336
+ }, 150);
337
+ });
338
+ }
339
+ }
340
+ function resolveHostPath(requestedPath) {
341
+ return path.resolve((requestedPath || process.env.HOME || "/").replace(/^~/, process.env.HOME || "/"));
342
+ }
343
+ function safeUploadFileName(fileName) {
344
+ const baseName = path.basename(fileName.replaceAll("\\", "/"))
345
+ .replace(/[\u0000-\u001f\u007f]/gu, "_")
346
+ .trim();
347
+ if (!baseName || baseName === "." || baseName === "..")
348
+ return "upload.bin";
349
+ const extension = truncateUtf8(path.extname(baseName), 40);
350
+ const stem = truncateUtf8(baseName.slice(0, Math.max(0, baseName.length - path.extname(baseName).length)), 200 - Buffer.byteLength(extension));
351
+ return `${stem || "upload"}${extension}`;
352
+ }
353
+ function truncateUtf8(value, maxBytes) {
354
+ const characters = Array.from(value);
355
+ while (characters.length > 0 && Buffer.byteLength(characters.join("")) > maxBytes)
356
+ characters.pop();
357
+ return characters.join("");
358
+ }
359
+ function decodeUploadChunk(data) {
360
+ if (typeof data !== "string" || data.length === 0 || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(data)) {
361
+ throw new LocalBackendApiError("Upload chunk is not valid base64", 400, "invalid_request");
362
+ }
363
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
364
+ const expectedBytes = (data.length / 4) * 3 - padding;
365
+ const bytes = Buffer.from(data, "base64");
366
+ if (bytes.length !== expectedBytes) {
367
+ throw new LocalBackendApiError("Upload chunk is not valid base64", 400, "invalid_request");
368
+ }
369
+ return bytes;
370
+ }
371
+ async function writeUploadChunk(uploadPath, offset, bytes) {
372
+ const file = await open(uploadPath, "r+");
373
+ try {
374
+ let written = 0;
375
+ while (written < bytes.length) {
376
+ const result = await file.write(bytes, written, bytes.length - written, offset + written);
377
+ if (result.bytesWritten <= 0)
378
+ throw new Error("Failed to write uploaded file chunk");
379
+ written += result.bytesWritten;
380
+ }
381
+ }
382
+ finally {
383
+ await file.close();
384
+ }
385
+ }
386
+ function isPathInside(root, candidate) {
387
+ const relative = path.relative(root, candidate);
388
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
389
+ }
@@ -350,11 +350,24 @@ function validateSettingsObject(value, errors) {
350
350
  if (!mc || typeof mc !== "object" || Array.isArray(mc)) {
351
351
  errors.push("messages must be an object");
352
352
  }
353
- else if (typeof mc.initialCount !== "number" || !Number.isFinite(mc.initialCount) || mc.initialCount < 0 || Math.floor(mc.initialCount) !== mc.initialCount) {
354
- errors.push("messages.initialCount must be a non-negative integer");
355
- }
356
353
  else {
357
- messagesConfig = { initialCount: mc.initialCount };
354
+ const initialCount = mc.initialCount;
355
+ const hideOlderThinking = mc.hideOlderThinking === undefined ? false : mc.hideOlderThinking;
356
+ const keepThinkingParts = mc.keepThinkingParts === undefined ? 3 : mc.keepThinkingParts;
357
+ if (typeof initialCount !== "number" || !Number.isFinite(initialCount) || initialCount < 0 || Math.floor(initialCount) !== initialCount) {
358
+ errors.push("messages.initialCount must be a non-negative integer");
359
+ }
360
+ if (typeof hideOlderThinking !== "boolean") {
361
+ errors.push("messages.hideOlderThinking must be a boolean");
362
+ }
363
+ if (typeof keepThinkingParts !== "number" || !Number.isFinite(keepThinkingParts) || keepThinkingParts < 0 || Math.floor(keepThinkingParts) !== keepThinkingParts) {
364
+ errors.push("messages.keepThinkingParts must be a non-negative integer");
365
+ }
366
+ if (typeof initialCount === "number" && Number.isFinite(initialCount) && initialCount >= 0 && Math.floor(initialCount) === initialCount
367
+ && typeof hideOlderThinking === "boolean"
368
+ && typeof keepThinkingParts === "number" && Number.isFinite(keepThinkingParts) && keepThinkingParts >= 0 && Math.floor(keepThinkingParts) === keepThinkingParts) {
369
+ messagesConfig = { initialCount, hideOlderThinking, keepThinkingParts };
370
+ }
358
371
  }
359
372
  }
360
373
  if (errors.length > 0)