viveworker 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,8 +27,9 @@ keep your AI session moving, keep context close, and keep your momentum.
27
27
 
28
28
  ## What Ships Today
29
29
 
30
- `viveworker` already covers five connected loops:
30
+ `viveworker` already covers seven connected loops:
31
31
 
32
+ - **Today**: see work that needs you, active agent runs, and results completed today across providers
32
33
  - **AI coding sessions**: approvals, plan checks, questions, completions, and mobile code review for Codex and Claude
33
34
  - **Thread Sharing**: pass context, plan-review requests, or full handoffs between Codex and Claude sessions
34
35
  - **Remote connection**: reach your Mac from a paired device outside your LAN through an end-to-end encrypted relay
@@ -218,10 +219,19 @@ If you prefer manual setup, add this to an MCP client:
218
219
  Available tools:
219
220
 
220
221
  - `viveworker_status` checks bridge, pairing, Remote connection, A2A, File Share, and Moltbook status
222
+ - `viveworker_stats` reads package adoption and usage stats
223
+ - `viveworker_share_list` lists File Share uploads and optional usage metrics
224
+ - `viveworker_a2a_activity` reads local A2A activity
225
+ - `viveworker_a2a_card` reads the current local A2A agent card
226
+ - `viveworker_moltbook_list` lists Moltbook inbox comments
227
+ - `viveworker_moltbook_show` reads one Moltbook comment
228
+ - `viveworker_moltbook_thread` reads the thread for one Moltbook comment
221
229
  - `viveworker_notify` sends an informational phone notification and timeline entry
222
230
  - `viveworker_ask` asks a question on the paired phone and waits for the answer
223
231
  - `viveworker_request_approval` asks the phone to approve or reject a proposed action
224
232
  - `viveworker_share_file` uploads a workspace file to File Share after phone approval
233
+ - `viveworker_share_replace` replaces the file behind an existing File Share slug after phone approval
234
+ - `viveworker_share_link` mints a short-lived passwordless File Share token URL after phone approval
225
235
  - `viveworker_thread_share` shares context into another Codex / Claude / inbox thread
226
236
  - `viveworker_send_a2a_task` sends a task to a registered A2A target after phone approval
227
237
 
@@ -230,8 +240,9 @@ Available prompts include setup guidance, control-plane usage, risky-action appr
230
240
  Security defaults:
231
241
 
232
242
  - MCP is stdio-only in this release; there is no HTTP MCP server
243
+ - MCP read-only inspection tools use a fixed command allowlist; MCP is not a generic shell or CLI executor
233
244
  - file sharing is limited to the current workspace and refuses `.env`, credential directories, private keys, and secret-looking paths
234
- - File Share and A2A task sending require phone approval
245
+ - File Share uploads/replacements and A2A task sending require phone approval
235
246
  - MCP tool calls are recorded locally with `provider: "mcp"`
236
247
  - prompts, message bodies, file contents, file paths, command text, tokens, public keys, and IP addresses are not sent to central analytics
237
248
 
@@ -399,7 +410,7 @@ Because the Claude hook opens browser windows and returns focus to Claude Deskto
399
410
  ### What it does
400
411
 
401
412
  - **Incoming reply drafts**: detects when other agents comment on your posts, drafts a contextual reply first, and sends it to your phone for approval
402
- - **Draft approval on phone**: reply drafts and original post drafts appear in `Tasks` and `Timeline`, where you can approve, deny, or edit them from your phone
413
+ - **Draft approval on phone**: reply drafts and original post drafts appear in `Today` and `Timeline`, where you can approve, deny, or edit them from your phone
403
414
  - **Auto-scout replies**: every 2 minutes, handles pending incoming comments first, then scans the Moltbook feed, scores posts against your agent's persona (0–100), batches candidates over a 30-minute window, picks the best match, drafts a reply via LLM, and proposes it for your approval
404
415
  - **Original post drafts**: based on your daily coding activity, composes new posts in your agent's voice and proposes them at natural intervals — morning (yesterday recap), noon (morning progress), and evening (full-day summary). Up to 3 per day; deny any slot you don't want
405
416
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.8.9",
3
+ "version": "0.9.0",
4
4
  "description": "Mobile control plane for AI agents. Approve, answer, and hand off work for Codex, Claude, MCP-ready tools, and A2A from one phone.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -37,7 +37,12 @@ Use this flow when the user says "set up viveworker", "pair my phone", "vivework
37
37
  - Use `viveworker_ask` for a short user decision that blocks progress.
38
38
  - Use `viveworker_request_approval` before risky, external, irreversible, payment-related, or user-visible actions.
39
39
  - Use `viveworker_notify` for informational updates that should appear on the phone and timeline.
40
+ - Use `viveworker_stats` for package adoption or usage stats.
41
+ - Use `viveworker_share_list` to inspect File Share uploads or usage metrics.
42
+ - Use `viveworker_a2a_activity` or `viveworker_a2a_card` to inspect A2A activity or local agent-card settings.
43
+ - Use `viveworker_moltbook_list`, `viveworker_moltbook_show`, or `viveworker_moltbook_thread` to inspect Moltbook inbox comments or threads.
40
44
  - Use `viveworker_share_file` when a local deliverable should become a limited File Share URL.
45
+ - Use `viveworker_share_replace` when an existing File Share slug should point at a new file.
41
46
  - Use `viveworker_thread_share` when context should move to another Codex / Claude / inbox thread.
42
47
  - Use `viveworker_send_a2a_task` when the user asks to delegate to a registered A2A target.
43
48
 
@@ -47,7 +52,8 @@ Use this flow when the user says "set up viveworker", "pair my phone", "vivework
47
52
  - Treat timeout, rejection, or missing response as not approved.
48
53
  - Do not send secrets, private keys, `.env` content, credentials, relay tokens, public keys, or unnecessary file contents through MCP.
49
54
  - Do not use MCP as a shell executor. It creates control-plane events; it should not run arbitrary commands.
50
- - Do not upload files through File Share without phone approval.
55
+ - Read-only inspection tools use fixed command allowlists and must not mutate local state.
56
+ - Do not upload or replace files through File Share without phone approval.
51
57
  - Use registered A2A target aliases only. Do not inline API keys.
52
58
 
53
59
  ## Natural language triggers
@@ -0,0 +1,142 @@
1
+ function isContainer(value) {
2
+ return Boolean(value) && typeof value === "object";
3
+ }
4
+
5
+ function isArrayPointerSegment(segment) {
6
+ return segment === "-" || /^\d+$/u.test(String(segment || ""));
7
+ }
8
+
9
+ function decodeJsonPointer(pointer) {
10
+ if (Array.isArray(pointer)) {
11
+ return pointer.map((segment) => String(segment));
12
+ }
13
+ if (!pointer || pointer === "/") {
14
+ return [];
15
+ }
16
+ return String(pointer)
17
+ .split("/")
18
+ .slice(1)
19
+ .map((segment) => segment.replace(/~1/gu, "/").replace(/~0/gu, "~"));
20
+ }
21
+
22
+ function cloneContainer(value, nextSegment = "") {
23
+ if (Array.isArray(value)) {
24
+ return value.slice();
25
+ }
26
+ if (isContainer(value)) {
27
+ return { ...value };
28
+ }
29
+ return isArrayPointerSegment(nextSegment) ? [] : {};
30
+ }
31
+
32
+ function arrayIndex(segment, length, { allowEnd = false } = {}) {
33
+ if (segment === "-") {
34
+ return allowEnd ? length : -1;
35
+ }
36
+ const parsed = Number.parseInt(String(segment), 10);
37
+ if (!Number.isInteger(parsed) || parsed < 0) {
38
+ return -1;
39
+ }
40
+ if (parsed < length || (allowEnd && parsed === length)) {
41
+ return parsed;
42
+ }
43
+ return -1;
44
+ }
45
+
46
+ function readPointerChild(container, segment) {
47
+ if (Array.isArray(container)) {
48
+ const index = arrayIndex(segment, container.length, { allowEnd: true });
49
+ return index >= 0 && index < container.length ? container[index] : undefined;
50
+ }
51
+ return isContainer(container) ? container[segment] : undefined;
52
+ }
53
+
54
+ function writeObjectKey(target, key, value) {
55
+ Object.defineProperty(target, key, {
56
+ value,
57
+ writable: true,
58
+ enumerable: true,
59
+ configurable: true,
60
+ });
61
+ }
62
+
63
+ function writePointerChild(container, segment, value) {
64
+ if (Array.isArray(container)) {
65
+ const index = arrayIndex(segment, container.length, { allowEnd: true });
66
+ if (index < 0) return false;
67
+ if (index === container.length) container.push(value);
68
+ else container[index] = value;
69
+ return true;
70
+ }
71
+ writeObjectKey(container, segment, value);
72
+ return true;
73
+ }
74
+
75
+ function applyLeafOperation(container, segment, operation, value) {
76
+ if (Array.isArray(container)) {
77
+ if (operation === "add") {
78
+ const index = arrayIndex(segment, container.length, { allowEnd: true });
79
+ if (index < 0) return false;
80
+ if (index === container.length) container.push(value);
81
+ else container.splice(index, 0, value);
82
+ return true;
83
+ }
84
+
85
+ const index = arrayIndex(segment, container.length);
86
+ if (index < 0) return false;
87
+ if (operation === "remove") container.splice(index, 1);
88
+ else if (operation === "replace") container[index] = value;
89
+ else return false;
90
+ return true;
91
+ }
92
+
93
+ if (operation === "remove") {
94
+ delete container[segment];
95
+ return true;
96
+ }
97
+ if (operation === "add" || operation === "replace") {
98
+ writeObjectKey(container, segment, value);
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ export function applyJsonPatch(document, patch) {
105
+ const operation = String(patch?.op || "");
106
+ if (!["add", "remove", "replace"].includes(operation)) {
107
+ return document;
108
+ }
109
+
110
+ const pathSegments = decodeJsonPointer(patch.path);
111
+ if (pathSegments.length === 0) {
112
+ if (operation === "remove") return {};
113
+ return patch.value;
114
+ }
115
+
116
+ const root = cloneContainer(document, pathSegments[0]);
117
+ let sourceNode = document;
118
+ let targetNode = root;
119
+
120
+ for (let index = 0; index < pathSegments.length - 1; index += 1) {
121
+ const segment = pathSegments[index];
122
+ const nextSegment = pathSegments[index + 1];
123
+ const sourceChild = readPointerChild(sourceNode, segment);
124
+ const targetChild = cloneContainer(sourceChild, nextSegment);
125
+ if (!writePointerChild(targetNode, segment, targetChild)) {
126
+ return document;
127
+ }
128
+ sourceNode = sourceChild;
129
+ targetNode = targetChild;
130
+ }
131
+
132
+ const changed = applyLeafOperation(targetNode, pathSegments.at(-1), operation, patch.value);
133
+ return changed ? root : document;
134
+ }
135
+
136
+ export function applyJsonPatches(document, patches) {
137
+ let next = isContainer(document) ? document : {};
138
+ for (const patch of Array.isArray(patches) ? patches : []) {
139
+ next = applyJsonPatch(next, patch);
140
+ }
141
+ return next;
142
+ }
@@ -0,0 +1,30 @@
1
+ import { parentPort } from "node:worker_threads";
2
+
3
+ import { projectIpcMessage } from "./ipc-message-projector.mjs";
4
+
5
+ if (!parentPort) {
6
+ throw new Error("ipc-frame-worker requires a parent port");
7
+ }
8
+
9
+ parentPort.on("message", (payload) => {
10
+ if (payload?.type !== "frame" || !payload.frame) return;
11
+ const startedAt = performance.now();
12
+ const frame = Buffer.from(payload.frame.buffer, payload.frame.byteOffset, payload.frame.byteLength);
13
+ try {
14
+ const message = JSON.parse(frame.toString("utf8"));
15
+ const parsedAt = performance.now();
16
+ parentPort.postMessage({
17
+ type: "message",
18
+ message: projectIpcMessage(message),
19
+ frameBytes: frame.length,
20
+ parseMs: Math.round(parsedAt - startedAt),
21
+ totalMs: Math.round(performance.now() - startedAt),
22
+ });
23
+ } catch (error) {
24
+ parentPort.postMessage({
25
+ type: "error",
26
+ error: String(error?.message || error || "IPC frame parse failed"),
27
+ frameBytes: frame.length,
28
+ });
29
+ }
30
+ });
@@ -0,0 +1,221 @@
1
+ const THREAD_STATE_SCALAR_KEYS = [
2
+ "id",
3
+ "cwd",
4
+ "thread_name",
5
+ "threadName",
6
+ "title",
7
+ "name",
8
+ "conversationTitle",
9
+ "threadTitle",
10
+ "label",
11
+ "summary",
12
+ "archived",
13
+ "isArchived",
14
+ "hidden",
15
+ "isHidden",
16
+ "deleted",
17
+ "isDeleted",
18
+ "closed",
19
+ "isClosed",
20
+ "status",
21
+ "state",
22
+ "visibility",
23
+ "lifecycle",
24
+ ];
25
+
26
+ const METADATA_KEYS = [
27
+ "thread_name",
28
+ "threadName",
29
+ "title",
30
+ "archived",
31
+ "isArchived",
32
+ "hidden",
33
+ "isHidden",
34
+ "deleted",
35
+ "isDeleted",
36
+ "status",
37
+ "state",
38
+ "visibility",
39
+ ];
40
+
41
+ const THREAD_KEYS = [
42
+ "id",
43
+ "title",
44
+ "name",
45
+ "archived",
46
+ "isArchived",
47
+ "hidden",
48
+ "isHidden",
49
+ "deleted",
50
+ "isDeleted",
51
+ "status",
52
+ "state",
53
+ "visibility",
54
+ ];
55
+
56
+ const RELEVANT_PATCH_ROOTS = new Set([
57
+ ...THREAD_STATE_SCALAR_KEYS,
58
+ "requests",
59
+ "latestCollaborationMode",
60
+ "metadata",
61
+ "thread",
62
+ ]);
63
+
64
+ function isObject(value) {
65
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
66
+ }
67
+
68
+ function own(value, key) {
69
+ return Object.prototype.hasOwnProperty.call(value, key);
70
+ }
71
+
72
+ function boundedLabelValue(value) {
73
+ return typeof value === "string" && value.length > 512 ? value.slice(0, 512) : value;
74
+ }
75
+
76
+ function pickObject(value, keys) {
77
+ if (!isObject(value)) return undefined;
78
+ const projected = {};
79
+ for (const key of keys) {
80
+ if (own(value, key)) projected[key] = boundedLabelValue(value[key]);
81
+ }
82
+ return projected;
83
+ }
84
+
85
+ function projectCollaborationMode(value) {
86
+ if (!isObject(value)) return undefined;
87
+ const projected = {};
88
+ if (own(value, "mode")) projected.mode = value.mode;
89
+ if (isObject(value.settings)) {
90
+ projected.settings = {
91
+ ...(own(value.settings, "model") ? { model: value.settings.model } : {}),
92
+ ...(own(value.settings, "reasoning_effort")
93
+ ? { reasoning_effort: value.settings.reasoning_effort }
94
+ : {}),
95
+ };
96
+ }
97
+ return projected;
98
+ }
99
+
100
+ function projectRequest(value) {
101
+ if (!isObject(value)) return value;
102
+ return {
103
+ ...(own(value, "id") ? { id: value.id } : {}),
104
+ ...(own(value, "method") ? { method: value.method } : {}),
105
+ ...(own(value, "params") ? { params: value.params } : {}),
106
+ };
107
+ }
108
+
109
+ function projectRequests(value) {
110
+ return Array.isArray(value) ? value.map(projectRequest) : value;
111
+ }
112
+
113
+ export function projectConversationState(value) {
114
+ if (!isObject(value)) return {};
115
+ const projected = {};
116
+ for (const key of THREAD_STATE_SCALAR_KEYS) {
117
+ if (own(value, key)) projected[key] = boundedLabelValue(value[key]);
118
+ }
119
+ if (own(value, "requests")) projected.requests = projectRequests(value.requests);
120
+ if (own(value, "latestCollaborationMode")) {
121
+ projected.latestCollaborationMode = projectCollaborationMode(value.latestCollaborationMode);
122
+ }
123
+ if (own(value, "metadata")) projected.metadata = pickObject(value.metadata, METADATA_KEYS);
124
+ if (own(value, "thread")) projected.thread = pickObject(value.thread, THREAD_KEYS);
125
+ return projected;
126
+ }
127
+
128
+ function pointerSegments(pointer) {
129
+ if (Array.isArray(pointer)) return pointer.map(String);
130
+ if (!pointer || pointer === "/") return [];
131
+ return String(pointer)
132
+ .split("/")
133
+ .slice(1)
134
+ .map((segment) => segment.replace(/~1/gu, "/").replace(/~0/gu, "~"));
135
+ }
136
+
137
+ function projectPatchValue(segments, value) {
138
+ if (segments.length === 0) return projectConversationState(value);
139
+ const root = segments[0];
140
+ if (root === "requests") {
141
+ if (segments.length === 1) return projectRequests(value);
142
+ if (segments.length === 2) return projectRequest(value);
143
+ return value;
144
+ }
145
+ if (root === "latestCollaborationMode" && segments.length === 1) {
146
+ return projectCollaborationMode(value);
147
+ }
148
+ if (root === "metadata" && segments.length === 1) return pickObject(value, METADATA_KEYS);
149
+ if (root === "thread" && segments.length === 1) return pickObject(value, THREAD_KEYS);
150
+ return boundedLabelValue(value);
151
+ }
152
+
153
+ function isRelevantPatchPath(segments) {
154
+ if (segments.length === 0) return true;
155
+ const root = segments[0];
156
+ if (!RELEVANT_PATCH_ROOTS.has(root)) return false;
157
+ if (root === "metadata") return segments.length === 1 || METADATA_KEYS.includes(segments[1]);
158
+ if (root === "thread") return segments.length === 1 || THREAD_KEYS.includes(segments[1]);
159
+ if (root === "latestCollaborationMode") {
160
+ return (
161
+ segments.length === 1 ||
162
+ segments[1] === "mode" ||
163
+ (segments[1] === "settings" && [undefined, "model", "reasoning_effort"].includes(segments[2]))
164
+ );
165
+ }
166
+ return true;
167
+ }
168
+
169
+ function projectPatch(patch) {
170
+ if (!isObject(patch)) return null;
171
+ const segments = pointerSegments(patch.path);
172
+ if (!isRelevantPatchPath(segments)) return null;
173
+ return {
174
+ op: patch.op,
175
+ path: patch.path,
176
+ ...(own(patch, "value") ? { value: projectPatchValue(segments, patch.value) } : {}),
177
+ };
178
+ }
179
+
180
+ function projectPatches(value) {
181
+ const patches = Array.isArray(value) ? value : value ? [value] : [];
182
+ return patches.map(projectPatch).filter(Boolean);
183
+ }
184
+
185
+ function projectThreadStateParams(params) {
186
+ const source = isObject(params) ? params : {};
187
+ const projected = {};
188
+ for (const key of ["conversationId", "threadId", "id"]) {
189
+ if (own(source, key)) projected[key] = source[key];
190
+ }
191
+ if (isObject(source.change)) {
192
+ if (source.change.type === "snapshot") {
193
+ projected.change = {
194
+ type: "snapshot",
195
+ conversationState: projectConversationState(source.change.conversationState),
196
+ };
197
+ } else if (source.change.type === "patches") {
198
+ projected.change = { type: "patches", patches: projectPatches(source.change.patches) };
199
+ }
200
+ }
201
+ if (own(source, "state")) projected.state = projectConversationState(source.state);
202
+ if (own(source, "conversation")) projected.conversation = projectConversationState(source.conversation);
203
+ if (own(source, "thread")) projected.thread = projectConversationState(source.thread);
204
+ if (own(source, "requests")) projected.requests = projectRequests(source.requests);
205
+ for (const key of ["patch", "patches", "operations", "ops"]) {
206
+ if (own(source, key)) projected[key] = projectPatches(source[key]);
207
+ }
208
+ return projected;
209
+ }
210
+
211
+ export function projectIpcMessage(message) {
212
+ if (!isObject(message) || message.type !== "broadcast" || message.method !== "thread-stream-state-changed") {
213
+ return message;
214
+ }
215
+ return {
216
+ type: message.type,
217
+ method: message.method,
218
+ ...(own(message, "sourceClientId") ? { sourceClientId: message.sourceClientId } : {}),
219
+ params: projectThreadStateParams(message.params),
220
+ };
221
+ }
@@ -0,0 +1,128 @@
1
+ const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024 * 1024;
2
+
3
+ export class LengthPrefixedFrameDecoder {
4
+ constructor({ maxFrameBytes = DEFAULT_MAX_FRAME_BYTES } = {}) {
5
+ this.maxFrameBytes = Math.max(1, Number(maxFrameBytes) || DEFAULT_MAX_FRAME_BYTES);
6
+ this.reset();
7
+ }
8
+
9
+ get bufferedBytes() {
10
+ return this.byteLength;
11
+ }
12
+
13
+ reset() {
14
+ this.chunks = [];
15
+ this.headIndex = 0;
16
+ this.byteLength = 0;
17
+ }
18
+
19
+ push(value) {
20
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value || []);
21
+ if (chunk.length > 0) {
22
+ this.chunks.push(chunk);
23
+ this.byteLength += chunk.length;
24
+ }
25
+
26
+ const frames = [];
27
+ while (this.byteLength >= 4) {
28
+ const frameBytes = this.peekUInt32LE();
29
+ if (frameBytes > this.maxFrameBytes) {
30
+ this.reset();
31
+ throw new RangeError(`IPC frame exceeds ${this.maxFrameBytes} bytes`);
32
+ }
33
+ if (this.byteLength < frameBytes + 4) {
34
+ break;
35
+ }
36
+ this.consume(4);
37
+ frames.push(this.consume(frameBytes));
38
+ }
39
+ return frames;
40
+ }
41
+
42
+ peekUInt32LE() {
43
+ const first = this.chunks[this.headIndex];
44
+ if (first?.length >= 4) {
45
+ return first.readUInt32LE(0);
46
+ }
47
+ const prefix = Buffer.allocUnsafe(4);
48
+ this.copyInto(prefix, 4, false);
49
+ return prefix.readUInt32LE(0);
50
+ }
51
+
52
+ consume(length) {
53
+ if (length === 0) {
54
+ return Buffer.alloc(0);
55
+ }
56
+ if (length < 0 || length > this.byteLength) {
57
+ throw new RangeError("Cannot consume beyond buffered IPC bytes");
58
+ }
59
+
60
+ const first = this.chunks[this.headIndex];
61
+ if (first.length >= length) {
62
+ const result = first.subarray(0, length);
63
+ if (first.length === length) {
64
+ this.headIndex += 1;
65
+ } else {
66
+ this.chunks[this.headIndex] = first.subarray(length);
67
+ }
68
+ this.byteLength -= length;
69
+ this.compactChunks();
70
+ return result;
71
+ }
72
+
73
+ const result = Buffer.allocUnsafe(length);
74
+ this.copyInto(result, length, true);
75
+ return result;
76
+ }
77
+
78
+ copyInto(target, length, consume) {
79
+ let remaining = length;
80
+ let targetOffset = 0;
81
+ let chunkIndex = this.headIndex;
82
+ let firstChunkOffset = 0;
83
+
84
+ while (remaining > 0) {
85
+ const chunk = this.chunks[chunkIndex];
86
+ if (!chunk) {
87
+ throw new RangeError("Incomplete IPC frame buffer");
88
+ }
89
+ const available = chunk.length - firstChunkOffset;
90
+ const copyLength = Math.min(remaining, available);
91
+ chunk.copy(target, targetOffset, firstChunkOffset, firstChunkOffset + copyLength);
92
+ targetOffset += copyLength;
93
+ remaining -= copyLength;
94
+
95
+ if (!consume) {
96
+ chunkIndex += 1;
97
+ firstChunkOffset = 0;
98
+ continue;
99
+ }
100
+
101
+ if (copyLength === available) {
102
+ this.headIndex += 1;
103
+ chunkIndex = this.headIndex;
104
+ } else {
105
+ this.chunks[this.headIndex] = chunk.subarray(copyLength);
106
+ chunkIndex = this.headIndex;
107
+ }
108
+ firstChunkOffset = 0;
109
+ this.byteLength -= copyLength;
110
+ }
111
+
112
+ if (consume) {
113
+ this.compactChunks();
114
+ }
115
+ }
116
+
117
+ compactChunks() {
118
+ if (this.headIndex === this.chunks.length) {
119
+ this.chunks = [];
120
+ this.headIndex = 0;
121
+ return;
122
+ }
123
+ if (this.headIndex >= 1024 && this.headIndex * 2 >= this.chunks.length) {
124
+ this.chunks = this.chunks.slice(this.headIndex);
125
+ this.headIndex = 0;
126
+ }
127
+ }
128
+ }
@@ -68,6 +68,10 @@ import {
68
68
  * (long-poll + a few short ones), so 64 is way above the working set.
69
69
  */
70
70
  export const MAX_INFLIGHT_PER_SESSION = 64;
71
+ export const BRIDGE_RELAY_FAILURE_THRESHOLD = 2;
72
+ export const BRIDGE_RELAY_CIRCUIT_BREAKER_MS = 5 * 60_000;
73
+ export const BRIDGE_RELAY_MAX_CIRCUIT_BREAKER_MS = 30 * 60_000;
74
+ export const BRIDGE_RELAY_STABLE_CONNECTION_MS = 60_000;
71
75
 
72
76
  // ---------------------------------------------------------------------------
73
77
  // BridgeRelayClient — coordinates many BridgePairingSessions
@@ -89,6 +93,11 @@ export const MAX_INFLIGHT_PER_SESSION = 64;
89
93
  * @property {number[]} [backoffMs] forwarded to the transport
90
94
  * @property {number} [handshakeTimeoutMs] forwarded to the transport
91
95
  * @property {Uint8Array} [prologue] forwarded to the transport
96
+ * @property {number} [failureThreshold] bridge-side relay reset circuit threshold
97
+ * @property {number} [failureWindowMs] bridge-side relay reset circuit window
98
+ * @property {number} [circuitBreakerMs] bridge-side relay reset cooldown
99
+ * @property {number} [maxCircuitBreakerMs] bridge-side max relay reset cooldown
100
+ * @property {number} [stableConnectionMs] bridge-side stable duration before clearing circuit
92
101
  */
93
102
 
94
103
  /**
@@ -284,6 +293,11 @@ export class BridgeRelayClient {
284
293
  backoffMs: this._opts.backoffMs,
285
294
  handshakeTimeoutMs: this._opts.handshakeTimeoutMs,
286
295
  prologue: this._opts.prologue,
296
+ failureThreshold: this._opts.failureThreshold,
297
+ failureWindowMs: this._opts.failureWindowMs,
298
+ circuitBreakerMs: this._opts.circuitBreakerMs,
299
+ maxCircuitBreakerMs: this._opts.maxCircuitBreakerMs,
300
+ stableConnectionMs: this._opts.stableConnectionMs,
287
301
  });
288
302
  this._sessions.set(pairing.pairingId, session);
289
303
  session.start().catch((err) => {
@@ -334,6 +348,11 @@ class BridgePairingSession {
334
348
  backoffMs,
335
349
  handshakeTimeoutMs,
336
350
  prologue,
351
+ failureThreshold,
352
+ failureWindowMs,
353
+ circuitBreakerMs,
354
+ maxCircuitBreakerMs,
355
+ stableConnectionMs,
337
356
  }) {
338
357
  this.pairing = pairing;
339
358
  this._dispatch = dispatch;
@@ -370,6 +389,11 @@ class BridgePairingSession {
370
389
  backoffMs,
371
390
  handshakeTimeoutMs,
372
391
  prologue,
392
+ failureThreshold: failureThreshold ?? BRIDGE_RELAY_FAILURE_THRESHOLD,
393
+ failureWindowMs,
394
+ circuitBreakerMs: circuitBreakerMs ?? BRIDGE_RELAY_CIRCUIT_BREAKER_MS,
395
+ maxCircuitBreakerMs: maxCircuitBreakerMs ?? BRIDGE_RELAY_MAX_CIRCUIT_BREAKER_MS,
396
+ stableConnectionMs: stableConnectionMs ?? BRIDGE_RELAY_STABLE_CONNECTION_MS,
373
397
  logger: logger,
374
398
  };
375
399
  this._transport = this._createTransport();
@@ -68,6 +68,11 @@ const RELOAD_DEBOUNCE_MS = 250;
68
68
  * @property {number[]} [backoffMs] forwarded to BridgeRelayClient
69
69
  * @property {number} [handshakeTimeoutMs] forwarded to BridgeRelayClient
70
70
  * @property {Uint8Array} [prologue] forwarded to BridgeRelayClient
71
+ * @property {number} [failureThreshold] forwarded to BridgeRelayClient
72
+ * @property {number} [failureWindowMs] forwarded to BridgeRelayClient
73
+ * @property {number} [circuitBreakerMs] forwarded to BridgeRelayClient
74
+ * @property {number} [maxCircuitBreakerMs] forwarded to BridgeRelayClient
75
+ * @property {number} [stableConnectionMs] forwarded to BridgeRelayClient
71
76
  * @property {boolean} [watchPairingsFile] defaults to true
72
77
  * @property {(event: object) => void | Promise<void>} [auditEventSink]
73
78
  */
@@ -227,6 +232,11 @@ export async function startRemotePairingRelay(opts) {
227
232
  backoffMs: opts.backoffMs,
228
233
  handshakeTimeoutMs: opts.handshakeTimeoutMs,
229
234
  prologue: opts.prologue,
235
+ failureThreshold: opts.failureThreshold,
236
+ failureWindowMs: opts.failureWindowMs,
237
+ circuitBreakerMs: opts.circuitBreakerMs,
238
+ maxCircuitBreakerMs: opts.maxCircuitBreakerMs,
239
+ stableConnectionMs: opts.stableConnectionMs,
230
240
  logger: log,
231
241
  });
232
242