viveworker 0.8.10 → 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 +3 -2
- package/package.json +1 -1
- package/scripts/lib/immutable-json-patch.mjs +142 -0
- package/scripts/lib/ipc-frame-worker.mjs +30 -0
- package/scripts/lib/ipc-message-projector.mjs +221 -0
- package/scripts/lib/length-prefixed-frame-decoder.mjs +128 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +24 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +10 -0
- package/scripts/lib/work-items.mjs +179 -0
- package/scripts/moltbook-api.mjs +27 -0
- package/scripts/moltbook-cli.mjs +12 -4
- package/scripts/moltbook-scout-auto.sh +105 -37
- package/scripts/viveworker-bridge.mjs +472 -206
- package/scripts/viveworker.mjs +5 -0
- package/web/app.css +106 -0
- package/web/app.js +183 -28
- package/web/build-id.js +1 -1
- package/web/i18n.js +44 -10
- package/web/remote-pairing/api-router.js +356 -11
- package/web/remote-pairing/transport.js +7 -1
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
|
|
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
|
|
@@ -409,7 +410,7 @@ Because the Claude hook opens browser windows and returns focus to Claude Deskto
|
|
|
409
410
|
### What it does
|
|
410
411
|
|
|
411
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
|
|
412
|
-
- **Draft approval on phone**: reply drafts and original post drafts appear in `
|
|
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
|
|
413
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
|
|
414
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
|
|
415
416
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -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
|
|