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 +14 -3
- package/package.json +1 -1
- package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +7 -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/mcp-server.mjs +240 -16
- 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 +483 -234
- package/scripts/viveworker-claude-hook.mjs +5 -1
- package/scripts/viveworker.mjs +5 -0
- package/skills/viveworker-control-plane/SKILL.md +7 -0
- package/templates/CLAUDE.viveworker.md +12 -1
- 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
|
|
@@ -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 `
|
|
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.
|
|
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
|
-
-
|
|
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
|
|