pipane 0.1.5 → 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 (138) hide show
  1. package/README.md +45 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/bin/pipane-rendezvous.js +21 -0
  4. package/bin/pipane.js +21 -1
  5. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  65. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  66. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  67. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  68. package/dist/client/assets/index-iblQYBAl.css +1 -0
  69. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  70. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  71. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  72. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  73. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  74. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  75. package/dist/client/index.html +2 -2
  76. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  77. package/dist/server/rendezvous/server.js +247 -0
  78. package/dist/server/rendezvous/trust-store.js +432 -0
  79. package/dist/server/server/auth-guard.js +61 -0
  80. package/dist/server/server/backend-connection-authorizer.js +29 -0
  81. package/dist/server/server/backend-identity.js +167 -0
  82. package/dist/server/server/backend-protocol-handler.js +132 -0
  83. package/dist/server/server/backend-trust-store.js +157 -0
  84. package/dist/server/server/backend-webrtc.js +239 -0
  85. package/dist/server/server/bundled-extensions.js +6 -0
  86. package/dist/server/server/conversation-file-access.js +97 -0
  87. package/dist/server/server/extension-status.js +31 -0
  88. package/dist/server/server/frame-connection.js +48 -0
  89. package/dist/server/server/frame-router.js +45 -0
  90. package/dist/server/server/ice-servers.js +24 -0
  91. package/dist/server/server/local-backend-api.js +389 -0
  92. package/dist/server/server/local-settings.js +28 -0
  93. package/dist/server/server/pi-rpc-protocol.js +407 -0
  94. package/dist/server/server/pi-runtime.js +1 -1
  95. package/dist/server/server/process-pool.js +179 -136
  96. package/dist/server/server/rendezvous-client.js +282 -0
  97. package/dist/server/server/rest-api.js +121 -231
  98. package/dist/server/server/server.js +188 -134
  99. package/dist/server/server/session-actor.js +202 -0
  100. package/dist/server/server/session-index.js +111 -18
  101. package/dist/server/server/session-jsonl.js +128 -8
  102. package/dist/server/server/session-path.js +145 -0
  103. package/dist/server/server/session-registry.js +67 -0
  104. package/dist/server/server/update-api.js +28 -0
  105. package/dist/server/server/update-check.js +33 -13
  106. package/dist/server/server/update-manager.js +231 -0
  107. package/dist/server/server/worktree-name.js +124 -0
  108. package/dist/server/server/ws-handler.js +784 -478
  109. package/dist/server/shared/backend-api.js +1 -0
  110. package/dist/server/shared/backend-protocol.js +164 -0
  111. package/dist/server/shared/jsonl-sync.js +41 -0
  112. package/dist/server/shared/node-trust-crypto.js +61 -0
  113. package/dist/server/shared/rendezvous-protocol.js +243 -0
  114. package/dist/server/shared/rpc-timeouts.js +4 -0
  115. package/dist/server/shared/thinking-levels.js +94 -0
  116. package/dist/server/shared/tool-runtime.js +1 -0
  117. package/dist/server/shared/trust-protocol.js +97 -0
  118. package/dist/server/shared/updates.js +4 -0
  119. package/dist/server/shared/ws-protocol.js +473 -0
  120. package/docs/protocol.md +127 -0
  121. package/extensions/canvas.ts +1 -1
  122. package/package.json +42 -17
  123. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  124. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  125. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  126. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  127. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  128. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  129. package/dist/client/assets/index-BtDAXAo1.css +0 -1
  130. package/dist/client/assets/index-CAQqlK61.js +0 -131
  131. package/dist/client/assets/index-CJEeBmBj.js +0 -3
  132. package/dist/client/assets/index-DUEhKmPQ.js +0 -1
  133. package/dist/client/assets/index-XEVKscP-.js +0 -4443
  134. package/dist/client/assets/index-woI4agCy.js +0 -45
  135. package/dist/server/server/attached-session.js +0 -209
  136. package/dist/server/server/load-trace-store.js +0 -48
  137. package/dist/server/server/session-lifecycle.js +0 -155
  138. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
@@ -0,0 +1 @@
1
+ export const MAX_UPLOAD_FILE_BYTES = 20 * 1024 * 1024;
@@ -0,0 +1,164 @@
1
+ /** Semantic hidden-backend request protocol carried beside application v1 frames. */
2
+ export const BACKEND_PROTOCOL_VERSION = 2;
3
+ export const BACKEND_PROTOCOL_MAX_FRAME_BYTES = 3 * 1024 * 1024;
4
+ export function encodeBackendFrame(frame) {
5
+ return JSON.stringify(frame);
6
+ }
7
+ export function decodeBackendRequest(raw) {
8
+ const envelope = parseEnvelope(raw);
9
+ if (!envelope.ok)
10
+ return envelope;
11
+ const value = envelope.value;
12
+ const requestId = typeof value.id === "string" ? value.id : null;
13
+ const method = typeof value.method === "string" ? value.method : "";
14
+ if (value.kind !== "request" || !isNonEmptyString(value.id) || !isNonEmptyString(value.method) || !isRecord(value.params)) {
15
+ return invalid("invalid_request", "Backend request envelope is malformed", requestId, method);
16
+ }
17
+ if (!isBackendMethod(value.method)) {
18
+ return invalid("unknown_method", `Unknown backend method: ${value.method}`, value.id, value.method);
19
+ }
20
+ if (!validateParams(value.method, value.params)) {
21
+ return invalid("invalid_request", `Invalid parameters for ${value.method}`, value.id, value.method);
22
+ }
23
+ return { ok: true, value: value };
24
+ }
25
+ export function decodeBackendResponse(raw) {
26
+ const envelope = parseEnvelope(raw);
27
+ if (!envelope.ok)
28
+ return envelope;
29
+ const value = envelope.value;
30
+ const requestId = typeof value.id === "string" ? value.id : null;
31
+ const method = typeof value.method === "string" ? value.method : "";
32
+ if (value.kind !== "response" || !isNonEmptyString(value.id) || !isNonEmptyString(value.method) || typeof value.success !== "boolean") {
33
+ return invalid("invalid_request", "Backend response envelope is malformed", requestId, method);
34
+ }
35
+ if (value.success) {
36
+ if (!("result" in value))
37
+ return invalid("invalid_request", "Successful response is missing result", value.id, value.method);
38
+ }
39
+ else if (!isApiError(value.error)) {
40
+ return invalid("invalid_request", "Failed response is missing a valid error", value.id, value.method);
41
+ }
42
+ return { ok: true, value: value };
43
+ }
44
+ export function isBackendProtocolFrame(raw) {
45
+ // `encodeBackendFrame` emits the version first. Keep v1 streaming frames on
46
+ // their hot path without parsing large snapshots twice.
47
+ const prefix = raw.slice(0, 128);
48
+ return /"v"\s*:\s*2(?:\s*[,}])/u.test(prefix)
49
+ && /"kind"\s*:\s*"(?:request|response|event)"/u.test(prefix);
50
+ }
51
+ function parseEnvelope(raw) {
52
+ if (new TextEncoder().encode(raw).byteLength > BACKEND_PROTOCOL_MAX_FRAME_BYTES) {
53
+ return invalid("invalid_request", "Backend frame exceeds the size limit", null, "");
54
+ }
55
+ let value;
56
+ try {
57
+ value = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ return invalid("invalid_json", "Backend frame is not valid JSON", null, "");
61
+ }
62
+ if (!isRecord(value))
63
+ return invalid("invalid_request", "Backend frame must be an object", null, "");
64
+ if (value.v !== BACKEND_PROTOCOL_VERSION) {
65
+ return invalid("unsupported_version", `Unsupported backend protocol version: ${String(value.v)}`, null, "");
66
+ }
67
+ return { ok: true, value };
68
+ }
69
+ function validateParams(method, params) {
70
+ switch (method) {
71
+ case "backend.capabilities":
72
+ case "sessions.list":
73
+ case "settings.get":
74
+ case "updates.get":
75
+ return hasOnlyKeys(params, []);
76
+ case "sessions.delete":
77
+ case "sessions.forkMessages":
78
+ case "sessions.raw":
79
+ return hasOnlyKeys(params, ["sessionPath"]) && isNonEmptyString(params.sessionPath);
80
+ case "files.read":
81
+ return hasOnlyKeys(params, ["sessionPath", "path"])
82
+ && isNonEmptyString(params.sessionPath)
83
+ && isNonEmptyString(params.path);
84
+ case "files.upload.create":
85
+ return hasOnlyKeys(params, ["fileName", "mimeType", "size"])
86
+ && isNonEmptyString(params.fileName)
87
+ && isNonEmptyString(params.mimeType)
88
+ && Number.isSafeInteger(params.size)
89
+ && params.size >= 0;
90
+ case "files.upload.append":
91
+ return hasOnlyKeys(params, ["uploadId", "offset", "data"])
92
+ && isNonEmptyString(params.uploadId)
93
+ && Number.isSafeInteger(params.offset)
94
+ && params.offset >= 0
95
+ && isNonEmptyString(params.data);
96
+ case "files.upload.complete":
97
+ return hasOnlyKeys(params, ["uploadId"]) && isNonEmptyString(params.uploadId);
98
+ case "host.browse":
99
+ return hasOnlyKeys(params, ["path"]) && typeof params.path === "string";
100
+ case "host.mkdir":
101
+ return hasOnlyKeys(params, ["parentPath", "name"])
102
+ && isNonEmptyString(params.parentPath)
103
+ && isNonEmptyString(params.name);
104
+ case "settings.validate":
105
+ case "settings.save":
106
+ return hasOnlyKeys(params, ["content"]) && typeof params.content === "string";
107
+ case "settings.patch":
108
+ return hasOnlyKeys(params, ["patch"]) && isRecord(params.patch);
109
+ case "updates.run":
110
+ return hasOnlyKeys(params, ["target"])
111
+ && (params.target === "pipane" || params.target === "pi" || params.target === "extensions");
112
+ }
113
+ }
114
+ function isBackendMethod(value) {
115
+ return [
116
+ "backend.capabilities",
117
+ "sessions.list",
118
+ "sessions.delete",
119
+ "sessions.forkMessages",
120
+ "sessions.raw",
121
+ "files.read",
122
+ "files.upload.create",
123
+ "files.upload.append",
124
+ "files.upload.complete",
125
+ "host.browse",
126
+ "host.mkdir",
127
+ "settings.get",
128
+ "settings.validate",
129
+ "settings.patch",
130
+ "settings.save",
131
+ "updates.get",
132
+ "updates.run",
133
+ ].includes(value);
134
+ }
135
+ function isApiError(value) {
136
+ return isRecord(value)
137
+ && isBackendErrorCode(value.code)
138
+ && isNonEmptyString(value.message);
139
+ }
140
+ function isBackendErrorCode(value) {
141
+ return typeof value === "string" && [
142
+ "invalid_json",
143
+ "invalid_request",
144
+ "unsupported_version",
145
+ "unknown_method",
146
+ "not_found",
147
+ "forbidden",
148
+ "conflict",
149
+ "internal_error",
150
+ ].includes(value);
151
+ }
152
+ function invalid(code, message, requestId, method) {
153
+ return { ok: false, error: { code, message }, requestId, method };
154
+ }
155
+ function hasOnlyKeys(value, keys) {
156
+ const actual = Object.keys(value);
157
+ return actual.length === keys.length && actual.every((key) => keys.includes(key));
158
+ }
159
+ function isRecord(value) {
160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
161
+ }
162
+ function isNonEmptyString(value) {
163
+ return typeof value === "string" && value.length > 0;
164
+ }
@@ -209,3 +209,44 @@ export async function applySyncOp(currentStr, currentHash, op) {
209
209
  }
210
210
  return { data: newStr, hash: op.hash };
211
211
  }
212
+ /**
213
+ * Client-side: apply an ordered batch of hash-chained sync operations.
214
+ *
215
+ * Every advertised base hash is checked, but the resulting data is hashed only
216
+ * once after the final operation. Verifying the final SHA-256 still detects any
217
+ * corrupted intermediate patch while avoiding O(batch size) full-state hashes
218
+ * during bursty streaming.
219
+ */
220
+ export async function applySyncOps(currentStr, currentHash, ops) {
221
+ if (ops.length === 0)
222
+ return { data: currentStr, hash: currentHash };
223
+ let data = currentStr;
224
+ let hash = currentHash;
225
+ let finalOp;
226
+ for (const op of ops) {
227
+ finalOp = op;
228
+ if (op.op === "full") {
229
+ data = op.data;
230
+ hash = op.hash;
231
+ continue;
232
+ }
233
+ if (op.baseHash !== hash) {
234
+ console.warn("[jsonl-sync] Base hash mismatch, need full sync", {
235
+ expected: op.baseHash,
236
+ actual: hash,
237
+ });
238
+ return null;
239
+ }
240
+ data = applyPatches(data, op.patches);
241
+ hash = op.hash;
242
+ }
243
+ const actualHash = await computeHash(data);
244
+ if (actualHash !== hash) {
245
+ const message = finalOp?.op === "full"
246
+ ? "[jsonl-sync] Full sync hash mismatch"
247
+ : "[jsonl-sync] Post-patch hash mismatch";
248
+ console.error(message, { expected: hash, actual: actualHash });
249
+ return null;
250
+ }
251
+ return { data, hash };
252
+ }
@@ -0,0 +1,61 @@
1
+ import { createHash, createPublicKey, verify } from "node:crypto";
2
+ import { connectionTicketSignaturePayload, parseConnectionTicketClaims, } from "./trust-protocol.js";
3
+ const DEVICE_ID_PREFIX = "d_";
4
+ export function deriveDeviceId(publicKey) {
5
+ const bytes = decodeP256PublicKey(publicKey);
6
+ return `${DEVICE_ID_PREFIX}${createHash("sha256").update(bytes).digest("base64url")}`;
7
+ }
8
+ export function verifyDeviceSignature(publicKey, payload, signature) {
9
+ try {
10
+ const key = createPublicKey({ key: decodeP256PublicKey(publicKey), format: "der", type: "spki" });
11
+ return verify("sha256", Buffer.from(payload), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"));
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export function verifyConnectionTicket(ticket, publicKey, now = Date.now()) {
18
+ const parts = ticket.split(".");
19
+ if (parts.length !== 2 || !parts[0] || !parts[1])
20
+ throw new Error("Malformed connection ticket");
21
+ const [encodedClaims, encodedSignature] = parts;
22
+ let claims;
23
+ try {
24
+ const bytes = Buffer.from(encodedClaims, "base64url");
25
+ if (bytes.toString("base64url") !== encodedClaims)
26
+ throw new Error("non-canonical payload");
27
+ claims = JSON.parse(bytes.toString("utf8"));
28
+ }
29
+ catch {
30
+ throw new Error("Malformed connection ticket claims");
31
+ }
32
+ const parsed = parseConnectionTicketClaims(claims);
33
+ if (!parsed)
34
+ throw new Error("Invalid connection ticket claims");
35
+ if (!verifyDeviceSignature(publicKey, connectionTicketSignaturePayload(encodedClaims), encodedSignature)) {
36
+ throw new Error("Invalid connection ticket signature");
37
+ }
38
+ if (parsed.expiresAt <= now)
39
+ throw new Error("Connection ticket expired");
40
+ if (parsed.issuedAt > now + 30_000)
41
+ throw new Error("Connection ticket was issued in the future");
42
+ return parsed;
43
+ }
44
+ export function decodeP256PublicKey(publicKey) {
45
+ if (!/^[A-Za-z0-9_-]+$/.test(publicKey))
46
+ throw new Error("Invalid public key encoding");
47
+ const bytes = Buffer.from(publicKey, "base64url");
48
+ if (bytes.length === 0 || bytes.toString("base64url") !== publicKey)
49
+ throw new Error("Invalid public key encoding");
50
+ const key = createPublicKey({ key: bytes, format: "der", type: "spki" });
51
+ assertP256Key(key);
52
+ const canonical = Buffer.from(key.export({ type: "spki", format: "der" }));
53
+ if (!canonical.equals(bytes))
54
+ throw new Error("Public key is not canonical");
55
+ return bytes;
56
+ }
57
+ export function assertP256Key(key) {
58
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
59
+ throw new Error("Identity must use P-256");
60
+ }
61
+ }
@@ -0,0 +1,243 @@
1
+ export const RENDEZVOUS_PROTOCOL_VERSION = 2;
2
+ export function rendezvousWebSocketUrl(baseUrl, role) {
3
+ const url = new URL(baseUrl);
4
+ if (url.protocol === "http:")
5
+ url.protocol = "ws:";
6
+ else if (url.protocol === "https:")
7
+ url.protocol = "wss:";
8
+ else if (url.protocol !== "ws:" && url.protocol !== "wss:") {
9
+ throw new Error(`Unsupported rendezvous URL protocol: ${url.protocol}`);
10
+ }
11
+ url.pathname = `/v${RENDEZVOUS_PROTOCOL_VERSION}/rendezvous/${role}`;
12
+ url.search = "";
13
+ url.hash = "";
14
+ return url.toString();
15
+ }
16
+ export function backendRegistrationPayload(nonce) {
17
+ return `pipane-rendezvous-v${RENDEZVOUS_PROTOCOL_VERSION}\n${nonce}`;
18
+ }
19
+ export function encodeRendezvousMessage(message) {
20
+ return JSON.stringify(message);
21
+ }
22
+ export function decodeBackendCommand(raw) {
23
+ return decodeCommand(raw, "backend");
24
+ }
25
+ export function decodeBrowserCommand(raw) {
26
+ return decodeCommand(raw, "browser");
27
+ }
28
+ export function decodeBackendMessage(raw) {
29
+ return decodeServerMessage(raw, "backend");
30
+ }
31
+ export function decodeBrowserMessage(raw) {
32
+ return decodeServerMessage(raw, "browser");
33
+ }
34
+ function decodeCommand(raw, role) {
35
+ const envelope = parseEnvelope(raw);
36
+ if (!envelope.ok)
37
+ return envelope;
38
+ const value = envelope.value;
39
+ switch (value.type) {
40
+ case "register_backend":
41
+ if (role !== "backend")
42
+ return unknownMessage(value.type);
43
+ if (!isString(value.publicKey) || !isString(value.signature) || !isBackendMetadata(value.metadata)) {
44
+ return invalidMessage("register_backend requires publicKey, signature, and valid metadata");
45
+ }
46
+ break;
47
+ case "open_pairing":
48
+ if (role !== "backend")
49
+ return unknownMessage(value.type);
50
+ if (!isString(value.pairId) || !isPositiveInteger(value.expiresAt)) {
51
+ return invalidMessage("open_pairing requires pairId and expiresAt");
52
+ }
53
+ break;
54
+ case "confirm_pairing":
55
+ if (role !== "backend")
56
+ return unknownMessage(value.type);
57
+ if (!isString(value.connectionId))
58
+ return invalidMessage("confirm_pairing requires connectionId");
59
+ break;
60
+ case "connect_backend":
61
+ if (role !== "browser")
62
+ return unknownMessage(value.type);
63
+ if (!isString(value.backendId) || !isString(value.ticket)) {
64
+ return invalidMessage("connect_backend requires backendId and ticket");
65
+ }
66
+ break;
67
+ case "signal":
68
+ if (!isString(value.connectionId) || !isIceSignal(value.signal)) {
69
+ return invalidMessage("signal requires connectionId and a valid ICE signal");
70
+ }
71
+ break;
72
+ case "connection_binding":
73
+ if (role !== "backend")
74
+ return unknownMessage(value.type);
75
+ if (!isString(value.connectionId) || !isBackendIdentityBinding(value.binding)) {
76
+ return invalidMessage("connection_binding requires connectionId and a valid binding");
77
+ }
78
+ break;
79
+ case "close_connection":
80
+ if (!isString(value.connectionId) || !isOptionalString(value.reason)) {
81
+ return invalidMessage("close_connection requires connectionId and an optional reason");
82
+ }
83
+ break;
84
+ default:
85
+ return unknownMessage(value.type);
86
+ }
87
+ return { ok: true, value: value };
88
+ }
89
+ function decodeServerMessage(raw, role) {
90
+ const envelope = parseEnvelope(raw);
91
+ if (!envelope.ok)
92
+ return envelope;
93
+ const value = envelope.value;
94
+ switch (value.type) {
95
+ case "challenge":
96
+ if (role !== "backend" || !isString(value.nonce))
97
+ return invalidMessage("invalid backend challenge");
98
+ break;
99
+ case "registered":
100
+ if (role !== "backend" || !isString(value.backendId) || !isString(value.ticketPublicKey) || !isIceServers(value.iceServers)) {
101
+ return invalidMessage("invalid backend registration");
102
+ }
103
+ break;
104
+ case "pairing_opened":
105
+ if (role !== "backend" || !isString(value.pairId) || !isPositiveInteger(value.expiresAt)) {
106
+ return invalidMessage("invalid pairing acknowledgement");
107
+ }
108
+ break;
109
+ case "pairing_confirmed":
110
+ if (role !== "backend"
111
+ || !isString(value.connectionId)
112
+ || !isString(value.pairId)
113
+ || !isString(value.accountId)
114
+ || !isString(value.deviceId)) {
115
+ return invalidMessage("invalid pairing confirmation");
116
+ }
117
+ break;
118
+ case "connection_request":
119
+ if (role !== "backend" || !isString(value.connectionId) || !isString(value.ticket) || !isIceServers(value.iceServers)) {
120
+ return invalidMessage("invalid connection request");
121
+ }
122
+ break;
123
+ case "backend_connected":
124
+ if (role !== "browser" || !isString(value.backendId) || !isString(value.connectionId)) {
125
+ return invalidMessage("invalid browser connection acknowledgement");
126
+ }
127
+ break;
128
+ case "signal":
129
+ if (!isString(value.connectionId) || !isIceSignal(value.signal))
130
+ return invalidMessage("invalid ICE signal");
131
+ break;
132
+ case "connection_binding":
133
+ if (role !== "browser" || !isString(value.connectionId) || !isBackendIdentityBinding(value.binding)) {
134
+ return invalidMessage("invalid backend identity binding");
135
+ }
136
+ break;
137
+ case "connection_closed":
138
+ if (!isString(value.connectionId) || !isString(value.reason))
139
+ return invalidMessage("invalid connection closure");
140
+ break;
141
+ case "authorization_revoked":
142
+ if (role !== "backend" || !isString(value.accountId) || !isOptionalString(value.deviceId)) {
143
+ return invalidMessage("invalid authorization revocation");
144
+ }
145
+ break;
146
+ case "error":
147
+ if (!isErrorCode(value.code) || !isString(value.message) || !isOptionalString(value.connectionId)) {
148
+ return invalidMessage("invalid rendezvous error");
149
+ }
150
+ break;
151
+ default:
152
+ return unknownMessage(value.type);
153
+ }
154
+ return { ok: true, value: value };
155
+ }
156
+ function parseEnvelope(raw) {
157
+ let value;
158
+ try {
159
+ value = JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return { ok: false, error: { code: "invalid_json", message: "Message is not valid JSON" } };
163
+ }
164
+ if (!isRecord(value) || !isString(value.type))
165
+ return invalidMessage("Message must be an object with a type");
166
+ if (value.protocolVersion !== RENDEZVOUS_PROTOCOL_VERSION) {
167
+ return {
168
+ ok: false,
169
+ error: {
170
+ code: "unsupported_version",
171
+ message: `Unsupported rendezvous protocol version: ${String(value.protocolVersion)}`,
172
+ },
173
+ };
174
+ }
175
+ return { ok: true, value };
176
+ }
177
+ function isBackendMetadata(value) {
178
+ return isRecord(value)
179
+ && isOptionalString(value.name)
180
+ && isString(value.softwareVersion)
181
+ && Array.isArray(value.protocolVersions)
182
+ && value.protocolVersions.length > 0
183
+ && value.protocolVersions.every((version) => Number.isSafeInteger(version) && version > 0);
184
+ }
185
+ function isIceSignal(value) {
186
+ if (!isRecord(value) || !isString(value.kind))
187
+ return false;
188
+ if (value.kind === "description") {
189
+ return (value.type === "offer" || value.type === "answer") && isString(value.sdp);
190
+ }
191
+ if (value.kind === "candidate") {
192
+ return isString(value.candidate)
193
+ && (value.sdpMid === null || isString(value.sdpMid))
194
+ && (value.sdpMLineIndex === null || (Number.isSafeInteger(value.sdpMLineIndex) && value.sdpMLineIndex >= 0));
195
+ }
196
+ return false;
197
+ }
198
+ function isBackendIdentityBinding(value) {
199
+ return isRecord(value)
200
+ && value.version === 1
201
+ && isString(value.backendId)
202
+ && isString(value.publicKey)
203
+ && isString(value.connectionId)
204
+ && isString(value.offerSha256)
205
+ && isString(value.answerSha256)
206
+ && isString(value.dtlsFingerprint)
207
+ && isPositiveInteger(value.expiresAt)
208
+ && isString(value.signature);
209
+ }
210
+ function isIceServers(value) {
211
+ return Array.isArray(value) && value.every((server) => isRecord(server)
212
+ && (isString(server.urls) || (Array.isArray(server.urls) && server.urls.length > 0 && server.urls.every(isString)))
213
+ && isOptionalString(server.username)
214
+ && isOptionalString(server.credential));
215
+ }
216
+ function isErrorCode(value) {
217
+ return value === "invalid_json"
218
+ || value === "invalid_message"
219
+ || value === "unsupported_version"
220
+ || value === "unknown_message"
221
+ || value === "backend_offline"
222
+ || value === "unauthorized_connection"
223
+ || value === "invalid_ticket"
224
+ || value === "invalid_pairing";
225
+ }
226
+ function invalidMessage(message) {
227
+ return { ok: false, error: { code: "invalid_message", message } };
228
+ }
229
+ function unknownMessage(type) {
230
+ return { ok: false, error: { code: "unknown_message", message: `Unknown rendezvous message: ${String(type)}` } };
231
+ }
232
+ function isRecord(value) {
233
+ return !!value && typeof value === "object" && !Array.isArray(value);
234
+ }
235
+ function isString(value) {
236
+ return typeof value === "string" && value.length > 0;
237
+ }
238
+ function isOptionalString(value) {
239
+ return value === undefined || isString(value);
240
+ }
241
+ function isPositiveInteger(value) {
242
+ return Number.isSafeInteger(value) && value > 0;
243
+ }
@@ -0,0 +1,4 @@
1
+ /** Compaction performs an LLM summarization and can legitimately take minutes. */
2
+ export const COMPACT_RPC_TIMEOUT_MS = 10 * 60_000;
3
+ /** Allow for process acquisition/switching before the server starts compaction. */
4
+ export const COMPACT_CLIENT_TIMEOUT_MS = COMPACT_RPC_TIMEOUT_MS + 90_000;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Version-tolerant thinking-level helpers.
3
+ *
4
+ * pipane can talk to a newer pi RPC process than the pi packages it compiles
5
+ * against, so this module intentionally uses structural types instead of
6
+ * importing ThinkingLevel or Model from a pinned dependency.
7
+ */
8
+ export const THINKING_LEVEL_ORDER = [
9
+ "off",
10
+ "minimal",
11
+ "low",
12
+ "medium",
13
+ "high",
14
+ "xhigh",
15
+ "max",
16
+ ];
17
+ /** Return the model ID from either a full RPC model or a compact session ref. */
18
+ export function getModelId(model) {
19
+ return model?.id ?? model?.modelId;
20
+ }
21
+ /** Compare full models and compact model refs by their stable wire identity. */
22
+ export function modelsMatch(a, b) {
23
+ const aId = getModelId(a);
24
+ const bId = getModelId(b);
25
+ return !!a && !!b && !!aId && !!bId && a.provider === b.provider && aId === bId;
26
+ }
27
+ /** Convert a full model or compact ref to the shape accepted by pi RPC. */
28
+ export function toCompactModelRef(model) {
29
+ const modelId = getModelId(model);
30
+ if (!model.provider || !modelId)
31
+ return undefined;
32
+ return { provider: model.provider, modelId };
33
+ }
34
+ /**
35
+ * Whether the model has adjustable reasoning controls.
36
+ *
37
+ * Modern pi models include `reasoning`; the fallbacks keep compatibility with
38
+ * older/custom model metadata where that field may be absent.
39
+ */
40
+ export function modelSupportsThinking(model) {
41
+ if (!model)
42
+ return false;
43
+ if (typeof model.reasoning === "boolean")
44
+ return model.reasoning;
45
+ if (model.thinkingLevelMap)
46
+ return true;
47
+ const provider = String(model.provider ?? "").toLowerCase();
48
+ const id = String(getModelId(model) ?? "").toLowerCase();
49
+ if (provider === "openai-codex")
50
+ return true;
51
+ if (provider === "openai" && id.startsWith("gpt-5"))
52
+ return true;
53
+ return false;
54
+ }
55
+ /**
56
+ * Mirror current pi capability semantics:
57
+ * - non-reasoning models support only `off`
58
+ * - standard levels through `high` are supported unless explicitly null
59
+ * - `xhigh` and `max` are opt-in and require a non-null map entry
60
+ */
61
+ export function getSupportedThinkingLevels(model) {
62
+ if (!modelSupportsThinking(model))
63
+ return ["off"];
64
+ const map = model?.thinkingLevelMap;
65
+ return THINKING_LEVEL_ORDER.filter((level) => {
66
+ const mapped = map?.[level];
67
+ if (mapped === null)
68
+ return false;
69
+ if (level === "xhigh" || level === "max")
70
+ return mapped !== undefined;
71
+ return true;
72
+ });
73
+ }
74
+ /** Clamp exactly as pi does: prefer the next supported level, then search down. */
75
+ export function clampThinkingLevel(model, requested) {
76
+ const available = getSupportedThinkingLevels(model);
77
+ if (available.includes(requested)) {
78
+ return requested;
79
+ }
80
+ const requestedIndex = THINKING_LEVEL_ORDER.indexOf(requested);
81
+ if (requestedIndex === -1)
82
+ return available[0] ?? "off";
83
+ for (let i = requestedIndex; i < THINKING_LEVEL_ORDER.length; i++) {
84
+ const candidate = THINKING_LEVEL_ORDER[i];
85
+ if (available.includes(candidate))
86
+ return candidate;
87
+ }
88
+ for (let i = requestedIndex - 1; i >= 0; i--) {
89
+ const candidate = THINKING_LEVEL_ORDER[i];
90
+ if (available.includes(candidate))
91
+ return candidate;
92
+ }
93
+ return available[0] ?? "off";
94
+ }
@@ -0,0 +1 @@
1
+ export {};