viveworker 0.7.0 → 0.8.1

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 (54) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +115 -4
  3. package/package.json +13 -3
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  8. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  9. package/scripts/lib/markdown-render.mjs +128 -1
  10. package/scripts/lib/remote-pairing/README.md +164 -0
  11. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  12. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  13. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  14. package/scripts/lib/remote-pairing/control.mjs +156 -0
  15. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  16. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  17. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  18. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  19. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  20. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  21. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  22. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  23. package/scripts/mcp-server.mjs +891 -0
  24. package/scripts/moltbook-scout-auto.sh +16 -8
  25. package/scripts/share-cli.mjs +14 -130
  26. package/scripts/stats-cli.mjs +683 -0
  27. package/scripts/viveworker-bridge.mjs +1676 -127
  28. package/scripts/viveworker.mjs +289 -7
  29. package/skills/viveworker-control-plane/SKILL.md +104 -0
  30. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  31. package/templates/CLAUDE.viveworker.md +67 -0
  32. package/web/app.css +683 -9
  33. package/web/app.js +1780 -187
  34. package/web/build-id.js +1 -0
  35. package/web/i18n.js +187 -9
  36. package/web/icons/apple-touch-icon.png +0 -0
  37. package/web/icons/viveworker-icon-192.png +0 -0
  38. package/web/icons/viveworker-icon-512.png +0 -0
  39. package/web/icons/viveworker-v-pulse.svg +16 -1
  40. package/web/index.html +32 -2
  41. package/web/remote-pairing/api-router.js +873 -0
  42. package/web/remote-pairing/keys.js +237 -0
  43. package/web/remote-pairing/pairing-state.js +313 -0
  44. package/web/remote-pairing/rpc-client.js +765 -0
  45. package/web/remote-pairing/transport.js +804 -0
  46. package/web/remote-pairing/wake.js +149 -0
  47. package/web/remote-pairing-test.html +400 -0
  48. package/web/remote-pairing.bundle.js +3 -0
  49. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  50. package/web/remote-pairing.bundle.js.map +7 -0
  51. package/web/sw.js +190 -20
  52. package/web/icons/viveworker-beacon-v.svg +0 -19
  53. package/web/icons/viveworker-icon-1024.png +0 -0
  54. package/web/icons/viveworker-v-check.svg +0 -19
@@ -0,0 +1,381 @@
1
+ /**
2
+ * rpc.mjs — Application-level RPC framing carried inside the Noise channel.
3
+ *
4
+ * The transport (Noise + envelope) gives us an authenticated, encrypted,
5
+ * ordered byte stream between phone and bridge. On top of it we need an
6
+ * HTTP-shaped request/response abstraction so the bridge can keep using
7
+ * its existing `(req, res)` handlers and the phone PWA can keep doing
8
+ * `fetch()` whether it's on LAN or going through the relay.
9
+ *
10
+ * Wire format (UTF-8 JSON, one frame per Noise transport message):
11
+ *
12
+ * // phone → bridge: HTTP-like request
13
+ * { "t":"req", "id":"r1", "method":"GET", "path":"/api/state",
14
+ * "headers":{"cookie":"viveworker_session=..."},
15
+ * "body":"" }
16
+ *
17
+ * // bridge → phone: full response (single frame; chunked variant TBD)
18
+ * { "t":"res", "id":"r1", "status":200,
19
+ * "headers":{"content-type":"application/json"},
20
+ * "body":"{...}" }
21
+ *
22
+ * // phone → bridge: cancel an in-flight request (e.g. user navigated
23
+ * // away during a long-poll). The bridge MAY abort and skip sending res.
24
+ * { "t":"cancel", "id":"r1" }
25
+ *
26
+ * // bridge → phone: server-pushed event, no client request to correlate
27
+ * // against (e.g., "new approval item arrived; refresh inbox").
28
+ * { "t":"evt", "topic":"inbox-changed", "data":{...} }
29
+ *
30
+ * Why JSON (not CBOR / Protobuf):
31
+ * The Noise channel already gave us the cheap transport. Most viveworker
32
+ * API payloads are JSON to begin with, so the only thing we save by
33
+ * going binary is a few characters per request — not worth the parser
34
+ * surface or the loss of ad-hoc debuggability. Bodies are always
35
+ * strings; binary bodies (rare, e.g. /uploads) base64 themselves and
36
+ * set `bodyEncoding: "base64"`.
37
+ *
38
+ * Validation philosophy:
39
+ * We enforce types and a few bounds (max id length, max headers count,
40
+ * max body size) so a misbehaving peer can't DoS the dispatcher. Past
41
+ * those bounds we trust the peer because the Noise handshake already
42
+ * gave us mutual auth — there's no value in being defensive against a
43
+ * peer that's already inside the encrypted channel.
44
+ */
45
+
46
+ const TEXT_ENCODER = new TextEncoder();
47
+ const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Limits — defensive caps. Generous enough to never bite real traffic but
51
+ // tight enough to protect the dispatcher from a runaway peer.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Max ID length (UUIDs are 36 chars; 64 leaves room for prefixes). */
55
+ export const MAX_RPC_ID_LEN = 64;
56
+
57
+ /** Max number of header entries we accept. */
58
+ export const MAX_HEADERS = 64;
59
+
60
+ /** Max length of any single header name OR value. */
61
+ export const MAX_HEADER_LEN = 8 * 1024;
62
+
63
+ /** Max method string length (`GET`, `OPTIONS`, etc. — 16 covers everything reasonable). */
64
+ export const MAX_METHOD_LEN = 16;
65
+
66
+ /** Max URL path length. */
67
+ export const MAX_PATH_LEN = 4 * 1024;
68
+
69
+ /** Max body size — 4 MiB. Fits long-poll JSONs, inbox dumps, x402 payloads. */
70
+ export const MAX_BODY_BYTES = 4 * 1024 * 1024;
71
+
72
+ /** Max topic name length (server-pushed events). */
73
+ export const MAX_TOPIC_LEN = 128;
74
+
75
+ /**
76
+ * Frame type discriminators. Frozen so application code can compare with
77
+ * `===` and not worry about typos surviving as silently-different strings.
78
+ */
79
+ export const RPC = Object.freeze({
80
+ REQUEST: "req",
81
+ RESPONSE: "res",
82
+ CANCEL: "cancel",
83
+ EVENT: "evt",
84
+ });
85
+
86
+ const VALID_TYPES = new Set([RPC.REQUEST, RPC.RESPONSE, RPC.CANCEL, RPC.EVENT]);
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Encoders
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * @typedef {Object} RpcRequest
94
+ * @property {string} id client-chosen correlation id
95
+ * @property {string} method "GET" | "POST" | …
96
+ * @property {string} path URL path including query, e.g. "/api/x?foo=1"
97
+ * @property {Record<string,string>} [headers]
98
+ * @property {string} [body] UTF-8 string; binary callers must base64 + set bodyEncoding
99
+ * @property {"utf8" | "base64"} [bodyEncoding]
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} RpcResponse
104
+ * @property {string} id
105
+ * @property {number} status HTTP status code 100..599
106
+ * @property {Record<string,string>} [headers]
107
+ * @property {string} [body]
108
+ * @property {"utf8" | "base64"} [bodyEncoding]
109
+ */
110
+
111
+ /**
112
+ * @typedef {Object} RpcCancel
113
+ * @property {string} id
114
+ */
115
+
116
+ /**
117
+ * @typedef {Object} RpcEvent
118
+ * @property {string} topic
119
+ * @property {unknown} [data]
120
+ */
121
+
122
+ /** @param {RpcRequest} req @returns {Uint8Array} */
123
+ export function encodeRequest(req) {
124
+ if (typeof req?.id !== "string") throw new TypeError("request.id required (string)");
125
+ if (typeof req.method !== "string") throw new TypeError("request.method required (string)");
126
+ if (typeof req.path !== "string") throw new TypeError("request.path required (string)");
127
+ assertIdLen(req.id);
128
+ assertMethod(req.method);
129
+ assertPath(req.path);
130
+ const headers = normalizeHeaders(req.headers);
131
+ const { body, bodyEncoding } = normalizeBody(req.body, req.bodyEncoding);
132
+
133
+ const obj = { t: RPC.REQUEST, id: req.id, method: req.method, path: req.path };
134
+ if (headers) obj.headers = headers;
135
+ if (body !== undefined) {
136
+ obj.body = body;
137
+ if (bodyEncoding && bodyEncoding !== "utf8") obj.bodyEncoding = bodyEncoding;
138
+ }
139
+ return encodeJson(obj);
140
+ }
141
+
142
+ /** @param {RpcResponse} res @returns {Uint8Array} */
143
+ export function encodeResponse(res) {
144
+ if (typeof res?.id !== "string") throw new TypeError("response.id required (string)");
145
+ if (!Number.isInteger(res.status) || res.status < 100 || res.status > 599) {
146
+ throw new RangeError("response.status must be an integer in [100, 599]");
147
+ }
148
+ assertIdLen(res.id);
149
+ const headers = normalizeHeaders(res.headers);
150
+ const { body, bodyEncoding } = normalizeBody(res.body, res.bodyEncoding);
151
+
152
+ const obj = { t: RPC.RESPONSE, id: res.id, status: res.status };
153
+ if (headers) obj.headers = headers;
154
+ if (body !== undefined) {
155
+ obj.body = body;
156
+ if (bodyEncoding && bodyEncoding !== "utf8") obj.bodyEncoding = bodyEncoding;
157
+ }
158
+ return encodeJson(obj);
159
+ }
160
+
161
+ /** @param {string} id @returns {Uint8Array} */
162
+ export function encodeCancel(id) {
163
+ if (typeof id !== "string") throw new TypeError("cancel.id required (string)");
164
+ assertIdLen(id);
165
+ return encodeJson({ t: RPC.CANCEL, id });
166
+ }
167
+
168
+ /** @param {RpcEvent} ev @returns {Uint8Array} */
169
+ export function encodeEvent(ev) {
170
+ if (typeof ev?.topic !== "string") throw new TypeError("event.topic required (string)");
171
+ if (ev.topic.length > MAX_TOPIC_LEN) {
172
+ throw new RangeError(`event.topic exceeds ${MAX_TOPIC_LEN} chars`);
173
+ }
174
+ const obj = { t: RPC.EVENT, topic: ev.topic };
175
+ if (ev.data !== undefined) obj.data = ev.data;
176
+ return encodeJson(obj);
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Decoder
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * Decode a Noise plaintext frame into a typed RPC object. Throws on
185
+ * malformed JSON, missing required fields, or out-of-bounds values.
186
+ *
187
+ * @param {Uint8Array} bytes
188
+ * @returns {{type: "req"} & RpcRequest
189
+ * | {type: "res"} & RpcResponse
190
+ * | {type: "cancel"} & RpcCancel
191
+ * | {type: "evt"} & RpcEvent}
192
+ */
193
+ export function decode(bytes) {
194
+ if (!(bytes instanceof Uint8Array)) {
195
+ throw new TypeError("decode requires Uint8Array");
196
+ }
197
+ if (bytes.length > MAX_BODY_BYTES + 64 * 1024) {
198
+ // 64 KiB headroom for envelope JSON beyond the body itself.
199
+ throw new RangeError(`rpc frame too large: ${bytes.length} bytes`);
200
+ }
201
+ let str;
202
+ try {
203
+ str = TEXT_DECODER.decode(bytes);
204
+ } catch (err) {
205
+ throw new Error(`rpc frame: invalid UTF-8 (${err.message})`);
206
+ }
207
+ let obj;
208
+ try {
209
+ obj = JSON.parse(str);
210
+ } catch (err) {
211
+ throw new Error(`rpc frame: invalid JSON (${err.message})`);
212
+ }
213
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
214
+ throw new Error("rpc frame: not a JSON object");
215
+ }
216
+ const type = obj.t;
217
+ if (typeof type !== "string" || !VALID_TYPES.has(type)) {
218
+ throw new Error(`rpc frame: unknown type ${JSON.stringify(type)}`);
219
+ }
220
+
221
+ switch (type) {
222
+ case RPC.REQUEST: return decodeRequest(obj);
223
+ case RPC.RESPONSE: return decodeResponse(obj);
224
+ case RPC.CANCEL: return decodeCancel(obj);
225
+ case RPC.EVENT: return decodeEvent(obj);
226
+ }
227
+ // Unreachable — switch above is exhaustive over VALID_TYPES.
228
+ throw new Error("unreachable");
229
+ }
230
+
231
+ function decodeRequest(obj) {
232
+ const id = requireStr(obj.id, "id");
233
+ const method = requireStr(obj.method, "method");
234
+ const path = requireStr(obj.path, "path");
235
+ assertIdLen(id);
236
+ assertMethod(method);
237
+ assertPath(path);
238
+ const headers = decodeHeaders(obj.headers);
239
+ const { body, bodyEncoding } = decodeBody(obj);
240
+ return { type: RPC.REQUEST, id, method, path, headers, body, bodyEncoding };
241
+ }
242
+
243
+ function decodeResponse(obj) {
244
+ const id = requireStr(obj.id, "id");
245
+ assertIdLen(id);
246
+ const status = obj.status;
247
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
248
+ throw new RangeError("response.status must be an integer in [100, 599]");
249
+ }
250
+ const headers = decodeHeaders(obj.headers);
251
+ const { body, bodyEncoding } = decodeBody(obj);
252
+ return { type: RPC.RESPONSE, id, status, headers, body, bodyEncoding };
253
+ }
254
+
255
+ function decodeCancel(obj) {
256
+ const id = requireStr(obj.id, "id");
257
+ assertIdLen(id);
258
+ return { type: RPC.CANCEL, id };
259
+ }
260
+
261
+ function decodeEvent(obj) {
262
+ const topic = requireStr(obj.topic, "topic");
263
+ if (topic.length > MAX_TOPIC_LEN) {
264
+ throw new RangeError(`event.topic exceeds ${MAX_TOPIC_LEN} chars`);
265
+ }
266
+ return { type: RPC.EVENT, topic, data: obj.data };
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Helpers
271
+ // ---------------------------------------------------------------------------
272
+
273
+ function requireStr(v, name) {
274
+ if (typeof v !== "string") throw new TypeError(`${name} required (string)`);
275
+ return v;
276
+ }
277
+
278
+ function assertIdLen(id) {
279
+ if (id.length === 0) throw new RangeError("id must be non-empty");
280
+ if (id.length > MAX_RPC_ID_LEN) throw new RangeError(`id exceeds ${MAX_RPC_ID_LEN} chars`);
281
+ }
282
+
283
+ function assertMethod(m) {
284
+ if (m.length === 0) throw new RangeError("method must be non-empty");
285
+ if (m.length > MAX_METHOD_LEN) throw new RangeError(`method exceeds ${MAX_METHOD_LEN} chars`);
286
+ }
287
+
288
+ function assertPath(p) {
289
+ if (!p.startsWith("/")) throw new RangeError(`path must start with "/" (got ${JSON.stringify(p.slice(0, 32))})`);
290
+ if (p.length > MAX_PATH_LEN) throw new RangeError(`path exceeds ${MAX_PATH_LEN} chars`);
291
+ }
292
+
293
+ /**
294
+ * Normalize headers for encoding. Returns a plain object or undefined.
295
+ * Lowercases keys (matches Node's IncomingMessage convention) and rejects
296
+ * non-string values.
297
+ */
298
+ function normalizeHeaders(headers) {
299
+ if (headers == null) return undefined;
300
+ if (typeof headers !== "object" || Array.isArray(headers)) {
301
+ throw new TypeError("headers must be a plain object");
302
+ }
303
+ const entries = Object.entries(headers);
304
+ if (entries.length === 0) return undefined;
305
+ if (entries.length > MAX_HEADERS) {
306
+ throw new RangeError(`headers exceeds ${MAX_HEADERS} entries`);
307
+ }
308
+ const out = {};
309
+ for (const [k, v] of entries) {
310
+ if (typeof k !== "string" || typeof v !== "string") {
311
+ throw new TypeError(`headers must be string→string (got ${k}=${typeof v})`);
312
+ }
313
+ if (k.length > MAX_HEADER_LEN || v.length > MAX_HEADER_LEN) {
314
+ throw new RangeError(`header ${JSON.stringify(k)} exceeds ${MAX_HEADER_LEN} chars`);
315
+ }
316
+ out[k.toLowerCase()] = v;
317
+ }
318
+ return out;
319
+ }
320
+
321
+ function decodeHeaders(raw) {
322
+ if (raw == null) return {};
323
+ if (typeof raw !== "object" || Array.isArray(raw)) {
324
+ throw new Error("headers must be a JSON object");
325
+ }
326
+ const entries = Object.entries(raw);
327
+ if (entries.length > MAX_HEADERS) {
328
+ throw new RangeError(`headers exceeds ${MAX_HEADERS} entries`);
329
+ }
330
+ const out = {};
331
+ for (const [k, v] of entries) {
332
+ if (typeof k !== "string" || typeof v !== "string") {
333
+ throw new Error(`headers must be string→string (got ${k}=${typeof v})`);
334
+ }
335
+ if (k.length > MAX_HEADER_LEN || v.length > MAX_HEADER_LEN) {
336
+ throw new RangeError(`header ${JSON.stringify(k)} exceeds ${MAX_HEADER_LEN} chars`);
337
+ }
338
+ out[k.toLowerCase()] = v;
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function normalizeBody(body, bodyEncoding) {
344
+ if (body === undefined || body === null || body === "") return { body: undefined };
345
+ if (typeof body !== "string") {
346
+ throw new TypeError("body must be a string (binary callers: base64-encode and set bodyEncoding=\"base64\")");
347
+ }
348
+ if (bodyEncoding != null && bodyEncoding !== "utf8" && bodyEncoding !== "base64") {
349
+ throw new RangeError(`bodyEncoding must be "utf8" or "base64" (got ${JSON.stringify(bodyEncoding)})`);
350
+ }
351
+ // Approximate size check on the encoded JSON. We allow the body string
352
+ // to be up to MAX_BODY_BYTES — JSON.stringify's escaping can add a few
353
+ // bytes, but the outer bytes-length check in `decode` is the real cap.
354
+ if (body.length > MAX_BODY_BYTES) {
355
+ throw new RangeError(`body exceeds ${MAX_BODY_BYTES} chars`);
356
+ }
357
+ return { body, bodyEncoding: bodyEncoding ?? "utf8" };
358
+ }
359
+
360
+ function decodeBody(obj) {
361
+ if (obj.body === undefined) return { body: undefined };
362
+ if (typeof obj.body !== "string") throw new Error("body must be a string");
363
+ if (obj.body.length > MAX_BODY_BYTES) {
364
+ throw new RangeError(`body exceeds ${MAX_BODY_BYTES} chars`);
365
+ }
366
+ let bodyEncoding = "utf8";
367
+ if (obj.bodyEncoding !== undefined) {
368
+ if (obj.bodyEncoding !== "utf8" && obj.bodyEncoding !== "base64") {
369
+ throw new Error(`bodyEncoding must be "utf8" or "base64"`);
370
+ }
371
+ bodyEncoding = obj.bodyEncoding;
372
+ }
373
+ return { body: obj.body, bodyEncoding };
374
+ }
375
+
376
+ function encodeJson(obj) {
377
+ // JSON.stringify rejects undefined values automatically; we explicitly
378
+ // omit them above. Use TextEncoder so the wire is canonical UTF-8 (no
379
+ // BOM, no surprise locale handling).
380
+ return TEXT_ENCODER.encode(JSON.stringify(obj));
381
+ }