viveworker 0.8.3 → 0.8.5
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 +13 -1
- package/package.json +1 -1
- package/scripts/mcp-server.mjs +111 -5
- package/scripts/share-cli.mjs +131 -58
- package/scripts/viveworker-bridge.mjs +42 -8
- package/scripts/viveworker.mjs +9 -0
- package/templates/CLAUDE.viveworker.md +3 -1
- package/web/app.js +8 -1
- package/web/build-id.js +1 -1
- package/web/i18n.js +6 -2
- package/web/remote-pairing/transport.js +27 -3
package/README.md
CHANGED
|
@@ -163,6 +163,8 @@ Use these commands most often:
|
|
|
163
163
|
start `viveworker` again using the saved config
|
|
164
164
|
- `npx viveworker stop`
|
|
165
165
|
stop the local background service
|
|
166
|
+
- `npx viveworker restart`
|
|
167
|
+
restart the local bridge using the saved config
|
|
166
168
|
- `npx viveworker status`
|
|
167
169
|
show the current app URL, launchd/background status, and health
|
|
168
170
|
- `npx viveworker doctor`
|
|
@@ -266,6 +268,7 @@ What it supports:
|
|
|
266
268
|
- PNG / JPG / GIF / WebP
|
|
267
269
|
- CSV rendered as an HTML table by default
|
|
268
270
|
- optional password protection
|
|
271
|
+
- short-lived token URLs for password-protected shares
|
|
269
272
|
- optional expiry
|
|
270
273
|
|
|
271
274
|
HTML uploads are optimized by default when possible.
|
|
@@ -284,12 +287,21 @@ Typical commands:
|
|
|
284
287
|
- `npx viveworker share upload report.pdf --password "hunter2" --expires-days 7`
|
|
285
288
|
- `npx viveworker share upload deck_standalone.html --no-optimize`
|
|
286
289
|
- `npx viveworker share list`
|
|
290
|
+
- `npx viveworker share replace <slug> updated-report.html`
|
|
291
|
+
- `npx viveworker share update <slug> --file updated-report.html`
|
|
287
292
|
- `npx viveworker share update <slug> --password "hunter2"`
|
|
288
293
|
- `npx viveworker share update <slug> --expires-days 7`
|
|
289
|
-
- `npx viveworker share link <slug
|
|
294
|
+
- `npx viveworker share link <slug> --password "hunter2" --ttl-hours 24`
|
|
290
295
|
- `VIVEWORKER_BUYER_PRIVATE_KEY=0x... npx viveworker share pay https://share.viveworker.com/v/<slug> --output ./deliverable.pdf`
|
|
291
296
|
- `npx viveworker share pay https://share.viveworker.com/v/<slug> --wallet hazbase --output ./deliverable.pdf`
|
|
292
297
|
|
|
298
|
+
`share link` is for agent handoff. It verifies the current password locally with
|
|
299
|
+
the File Share worker and returns a short-lived `https://share.viveworker.com/v/<slug>?t=<token>`
|
|
300
|
+
URL, so the recipient can open the share without seeing the password. The token
|
|
301
|
+
defaults to 24 hours, can be capped with `--ttl-hours` up to 720 hours, never
|
|
302
|
+
outlives the share expiry, and is invalidated when you rotate the share password
|
|
303
|
+
with `share update --password`.
|
|
304
|
+
|
|
293
305
|
`share pay` is human-in-the-loop by default. EOA mode reads the x402 payment
|
|
294
306
|
requirements and asks the paired device to approve before signing. `--wallet
|
|
295
307
|
hazbase` instead sends the payment request to the paired device, asks for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "Open mobile control surface for Codex, Claude, Thread Sharing, File Share, Moltbook, and A2A tasks on your trusted LAN.",
|
|
5
5
|
"author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
package/scripts/mcp-server.mjs
CHANGED
|
@@ -146,6 +146,8 @@ async function callTool(params) {
|
|
|
146
146
|
return toolOk(await toolRequestApproval(args));
|
|
147
147
|
case "viveworker_share_file":
|
|
148
148
|
return toolOk(await toolShareFile(args));
|
|
149
|
+
case "viveworker_share_link":
|
|
150
|
+
return toolOk(await toolShareLink(args));
|
|
149
151
|
case "viveworker_thread_share":
|
|
150
152
|
return toolOk(await toolThreadShare(args));
|
|
151
153
|
case "viveworker_send_a2a_task":
|
|
@@ -199,6 +201,13 @@ async function toolShareFile(args) {
|
|
|
199
201
|
const workspaceRoot = optionalString(args.workspaceRoot) || process.env.VIVEWORKER_MCP_WORKSPACE_ROOT || process.cwd();
|
|
200
202
|
const file = await validateSharePath(requestedPath, workspaceRoot);
|
|
201
203
|
const stat = await fs.stat(file.realPath);
|
|
204
|
+
const password = optionalString(args.password);
|
|
205
|
+
const expiresDays = optionalString(args.expiresDays ?? args["expires-days"]);
|
|
206
|
+
const tokenize = optionalBoolean(args.tokenize) || optionalBoolean(args.tokenizedUrl) || optionalBoolean(args.passwordlessUrl);
|
|
207
|
+
const tokenTtlHours = normalizeShareTokenTtlHours(args.tokenTtlHours ?? args["token-ttl-hours"] ?? args.ttlHours ?? args["ttl-hours"]);
|
|
208
|
+
if (tokenize && !password) {
|
|
209
|
+
throw rpcInvalidParams("tokenize requires password because tokenized URLs are minted from password-protected shares");
|
|
210
|
+
}
|
|
202
211
|
const approval = await bridgeEvent({
|
|
203
212
|
eventType: "approval_request",
|
|
204
213
|
title: "Share file with viveworker File Share",
|
|
@@ -207,6 +216,8 @@ async function toolShareFile(args) {
|
|
|
207
216
|
"",
|
|
208
217
|
`File: ${file.displayPath}`,
|
|
209
218
|
`Size: ${stat.size} bytes`,
|
|
219
|
+
password ? "Password gate: enabled" : "Password gate: disabled",
|
|
220
|
+
tokenize ? `Token URL: create short-lived passwordless ?t= URL${tokenTtlHours ? ` (${tokenTtlHours}h)` : " (24h)"}` : "",
|
|
210
221
|
"",
|
|
211
222
|
"Approve only if this file is safe to send outside this Mac.",
|
|
212
223
|
].join("\n"),
|
|
@@ -219,12 +230,52 @@ async function toolShareFile(args) {
|
|
|
219
230
|
}
|
|
220
231
|
|
|
221
232
|
const uploadArgs = ["share", "upload", file.realPath, "--json"];
|
|
222
|
-
const password = optionalString(args.password);
|
|
223
|
-
const expiresDays = optionalString(args.expiresDays ?? args["expires-days"]);
|
|
224
233
|
if (password) uploadArgs.push("--password", password);
|
|
225
234
|
if (expiresDays) uploadArgs.push("--expires-days", expiresDays);
|
|
226
235
|
const upload = await runViveworkerCliJson(uploadArgs, 90_000);
|
|
227
|
-
|
|
236
|
+
if (!tokenize) {
|
|
237
|
+
return { approved: true, share: upload };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const slug = optionalString(upload.slug) || slugFromShareUrl(upload.url);
|
|
241
|
+
if (!slug) {
|
|
242
|
+
throw new Error("share upload did not return a slug for tokenized URL creation");
|
|
243
|
+
}
|
|
244
|
+
const link = await createShareTokenLink(slug, password, tokenTtlHours);
|
|
245
|
+
return { approved: true, share: upload, tokenizedLink: link };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function toolShareLink(args) {
|
|
249
|
+
const slug = requiredString(args.slug, "slug");
|
|
250
|
+
if (!/^[A-Za-z0-9]+$/.test(slug)) {
|
|
251
|
+
throw rpcInvalidParams("slug must contain only letters and numbers");
|
|
252
|
+
}
|
|
253
|
+
const password = requiredString(args.password, "password");
|
|
254
|
+
if (password.length > 256) {
|
|
255
|
+
throw rpcInvalidParams("password is too long (max 256 characters)");
|
|
256
|
+
}
|
|
257
|
+
const ttlHours = normalizeShareTokenTtlHours(args.ttlHours ?? args["ttl-hours"] ?? args.tokenTtlHours ?? args["token-ttl-hours"]);
|
|
258
|
+
const approval = await bridgeEvent({
|
|
259
|
+
eventType: "approval_request",
|
|
260
|
+
title: "Create File Share token link",
|
|
261
|
+
message: [
|
|
262
|
+
"An MCP client wants to mint a short-lived passwordless File Share URL.",
|
|
263
|
+
"",
|
|
264
|
+
`Share slug: ${slug}`,
|
|
265
|
+
`Token TTL: ${ttlHours || 24}h`,
|
|
266
|
+
"",
|
|
267
|
+
"Anyone with the returned ?t= URL can open this share until the token or share expires.",
|
|
268
|
+
"Approve only if you are comfortable handing off this password-protected share.",
|
|
269
|
+
].join("\n"),
|
|
270
|
+
approvalKind: "file_share_link",
|
|
271
|
+
timeoutMs: clampTimeout(args.timeoutMs),
|
|
272
|
+
}, clampTimeout(args.timeoutMs));
|
|
273
|
+
if (!approval.approved) {
|
|
274
|
+
return { approved: false, decision: approval.decision || "rejected" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const link = await createShareTokenLink(slug, password, ttlHours);
|
|
278
|
+
return { approved: true, link };
|
|
228
279
|
}
|
|
229
280
|
|
|
230
281
|
async function toolThreadShare(args) {
|
|
@@ -577,6 +628,43 @@ function normalizeStringArray(value) {
|
|
|
577
628
|
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
|
578
629
|
}
|
|
579
630
|
|
|
631
|
+
function optionalBoolean(value) {
|
|
632
|
+
if (value === true) return true;
|
|
633
|
+
if (value === false || value == null) return false;
|
|
634
|
+
const text = String(value).trim().toLowerCase();
|
|
635
|
+
return text === "1" || text === "true" || text === "yes" || text === "on";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function normalizeShareTokenTtlHours(value) {
|
|
639
|
+
if (value == null || value === "") return undefined;
|
|
640
|
+
const n = Number(value);
|
|
641
|
+
if (!Number.isFinite(n) || n <= 0 || n > 720) {
|
|
642
|
+
throw rpcInvalidParams("token TTL must be a number between 1 and 720 hours");
|
|
643
|
+
}
|
|
644
|
+
return n;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function slugFromShareUrl(value) {
|
|
648
|
+
const text = optionalString(value);
|
|
649
|
+
if (!text) return "";
|
|
650
|
+
try {
|
|
651
|
+
const url = new URL(text);
|
|
652
|
+
const match = url.pathname.match(/^\/v\/([A-Za-z0-9]+)/u);
|
|
653
|
+
return match?.[1] || "";
|
|
654
|
+
} catch {
|
|
655
|
+
const match = text.match(/\/v\/([A-Za-z0-9]+)/u);
|
|
656
|
+
return match?.[1] || "";
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function createShareTokenLink(slug, password, ttlHours) {
|
|
661
|
+
const linkArgs = ["share", "link", slug, "--password", password, "--json"];
|
|
662
|
+
if (ttlHours !== undefined) {
|
|
663
|
+
linkArgs.push("--ttl-hours", String(ttlHours));
|
|
664
|
+
}
|
|
665
|
+
return runViveworkerCliJson(linkArgs, 30_000);
|
|
666
|
+
}
|
|
667
|
+
|
|
580
668
|
function requiredString(value, name) {
|
|
581
669
|
const text = optionalString(value);
|
|
582
670
|
if (!text) throw rpcInvalidParams(`${name} is required`);
|
|
@@ -789,7 +877,7 @@ const TOOLS = [
|
|
|
789
877
|
{
|
|
790
878
|
name: "viveworker_share_file",
|
|
791
879
|
title: "Share file",
|
|
792
|
-
description: "After phone approval, upload a workspace file to viveworker File Share and return the limited URL.",
|
|
880
|
+
description: "After phone approval, upload a workspace file to viveworker File Share and return the limited URL. If password is set, pass tokenize=true to also mint a short-lived passwordless ?t= URL.",
|
|
793
881
|
inputSchema: {
|
|
794
882
|
type: "object",
|
|
795
883
|
additionalProperties: false,
|
|
@@ -798,11 +886,29 @@ const TOOLS = [
|
|
|
798
886
|
workspaceRoot: { type: "string" },
|
|
799
887
|
password: { type: "string" },
|
|
800
888
|
expiresDays: { type: "string" },
|
|
889
|
+
tokenize: { type: "boolean" },
|
|
890
|
+
tokenTtlHours: { type: "number" },
|
|
801
891
|
timeoutMs: { type: "number" },
|
|
802
892
|
},
|
|
803
893
|
required: ["path"],
|
|
804
894
|
},
|
|
805
895
|
},
|
|
896
|
+
{
|
|
897
|
+
name: "viveworker_share_link",
|
|
898
|
+
title: "Create File Share token URL",
|
|
899
|
+
description: "After phone approval, mint a short-lived passwordless ?t= URL for an existing password-protected File Share slug.",
|
|
900
|
+
inputSchema: {
|
|
901
|
+
type: "object",
|
|
902
|
+
additionalProperties: false,
|
|
903
|
+
properties: {
|
|
904
|
+
slug: { type: "string" },
|
|
905
|
+
password: { type: "string" },
|
|
906
|
+
ttlHours: { type: "number" },
|
|
907
|
+
timeoutMs: { type: "number" },
|
|
908
|
+
},
|
|
909
|
+
required: ["slug", "password"],
|
|
910
|
+
},
|
|
911
|
+
},
|
|
806
912
|
{
|
|
807
913
|
name: "viveworker_thread_share",
|
|
808
914
|
title: "Thread share",
|
|
@@ -871,7 +977,7 @@ const PROMPTS = [
|
|
|
871
977
|
title: "Share deliverable",
|
|
872
978
|
description: "Package a local deliverable through viveworker File Share with phone approval.",
|
|
873
979
|
},
|
|
874
|
-
text: "When the user asks to share a report, prototype, screenshot, CSV, or standalone HTML deliverable, use viveworker_share_file. Only share files inside the workspace and never share secrets or credentials.",
|
|
980
|
+
text: "When the user asks to share a report, prototype, screenshot, CSV, or standalone HTML deliverable, use viveworker_share_file. Only share files inside the workspace and never share secrets or credentials. If the user wants a password-protected share but the recipient should not know the password, set password plus tokenize=true, or use viveworker_share_link for an existing password-protected slug to mint a short-lived ?t= URL.",
|
|
875
981
|
},
|
|
876
982
|
{
|
|
877
983
|
definition: {
|
package/scripts/share-cli.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* Commands:
|
|
10
10
|
* viveworker share upload <file> [--password <pw>] [--price <usd> --pay-to <0x…>] [--expires-days <n>] [--no-optimize] [--json]
|
|
11
11
|
* viveworker share list [--json]
|
|
12
|
-
* viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]
|
|
12
|
+
* viveworker share update <slug> [--file <file>] [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--no-optimize] [--json]
|
|
13
|
+
* viveworker share replace <slug> <file> [--expires-days <n>] [--no-optimize] [--json]
|
|
13
14
|
* viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]
|
|
14
15
|
* viveworker share pay <url> [--output <file>] [--dry-run] [--wallet eoa|hazbase] [--no-approval] [--json]
|
|
15
16
|
* viveworker share delete <slug>
|
|
@@ -80,6 +81,8 @@ export async function runShareCli(args) {
|
|
|
80
81
|
return handleList(args.slice(1));
|
|
81
82
|
case "update":
|
|
82
83
|
return handleUpdate(args.slice(1));
|
|
84
|
+
case "replace":
|
|
85
|
+
return handleReplace(args.slice(1));
|
|
83
86
|
case "link":
|
|
84
87
|
return handleLink(args.slice(1));
|
|
85
88
|
case "pay":
|
|
@@ -99,7 +102,8 @@ function printHelp() {
|
|
|
99
102
|
console.log("Commands:");
|
|
100
103
|
console.log(" viveworker share upload <file> [--password <pw>] [--price <usd> --pay-to <0x…>] [--expires-days <n>] [--no-optimize] [--json]");
|
|
101
104
|
console.log(" viveworker share list [--metrics] [--json]");
|
|
102
|
-
console.log(" viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]");
|
|
105
|
+
console.log(" viveworker share update <slug> [--file <file>] [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--no-optimize] [--json]");
|
|
106
|
+
console.log(" viveworker share replace <slug> <file> [--expires-days <n>] [--no-optimize] [--json]");
|
|
103
107
|
console.log(" viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
|
|
104
108
|
console.log(" viveworker share pay <url> [--output <file>] [--dry-run] [--no-approval] [--json]");
|
|
105
109
|
console.log(" viveworker share delete <slug>");
|
|
@@ -133,28 +137,6 @@ async function handleUpload(args) {
|
|
|
133
137
|
throw new Error("Usage: viveworker share upload <file> [--password <pw>] [--expires-days <n>]");
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
const absolute = path.resolve(filePath);
|
|
137
|
-
let stat;
|
|
138
|
-
try {
|
|
139
|
-
stat = await fs.stat(absolute);
|
|
140
|
-
} catch {
|
|
141
|
-
throw new Error(`File not found: ${filePath}`);
|
|
142
|
-
}
|
|
143
|
-
if (!stat.isFile()) {
|
|
144
|
-
throw new Error(`Not a regular file: ${filePath}`);
|
|
145
|
-
}
|
|
146
|
-
if (stat.size === 0) {
|
|
147
|
-
throw new Error(`File is empty: ${filePath}`);
|
|
148
|
-
}
|
|
149
|
-
const ext = path.extname(absolute).toLowerCase();
|
|
150
|
-
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}. ` +
|
|
153
|
-
`Got: ${ext || "(no extension)"}`
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
const { mime } = SHARE_TYPES[ext];
|
|
157
|
-
|
|
158
140
|
const password = flags["password"] || "";
|
|
159
141
|
const expiresDays = flags["expires-days"] || flags["expiresDays"] || "";
|
|
160
142
|
const price = flags["price"] || "";
|
|
@@ -190,21 +172,10 @@ async function handleUpload(args) {
|
|
|
190
172
|
|
|
191
173
|
const { apiKey, userId, shareUrl } = await resolveCredentials();
|
|
192
174
|
|
|
193
|
-
const
|
|
194
|
-
const optimization =
|
|
195
|
-
flags["no-optimize"] || flags["noOptimize"]
|
|
196
|
-
? { bytes: originalBytes, info: null }
|
|
197
|
-
: maybeOptimizeUpload({ absolute, ext, bytes: originalBytes });
|
|
198
|
-
const bytes = optimization.bytes;
|
|
199
|
-
if (bytes.length > MAX_FILE_SIZE) {
|
|
200
|
-
const detail = optimization.info
|
|
201
|
-
? ` after optimization to ${formatSize(bytes.length)}`
|
|
202
|
-
: "";
|
|
203
|
-
throw new Error(`File too large (${originalBytes.length} bytes, max ${MAX_FILE_SIZE})${detail}`);
|
|
204
|
-
}
|
|
175
|
+
const preparedFile = await prepareShareFile(filePath, flags);
|
|
205
176
|
const form = new FormData();
|
|
206
|
-
const blob = new Blob([bytes], { type: mime });
|
|
207
|
-
const file = new File([blob], path.basename(absolute), { type: mime });
|
|
177
|
+
const blob = new Blob([preparedFile.bytes], { type: preparedFile.mime });
|
|
178
|
+
const file = new File([blob], path.basename(preparedFile.absolute), { type: preparedFile.mime });
|
|
208
179
|
form.set("file", file);
|
|
209
180
|
if (password) form.set("password", password);
|
|
210
181
|
if (expiresDays) form.set("expiresDays", String(expiresDays));
|
|
@@ -233,14 +204,14 @@ async function handleUpload(args) {
|
|
|
233
204
|
}
|
|
234
205
|
|
|
235
206
|
console.log("");
|
|
236
|
-
console.log(`✅ Uploaded ${body.originalName || path.basename(absolute)} (${formatSize(body.size)})`);
|
|
207
|
+
console.log(`✅ Uploaded ${body.originalName || path.basename(preparedFile.absolute)} (${formatSize(body.size)})`);
|
|
237
208
|
console.log("");
|
|
238
|
-
if (optimization.info) {
|
|
209
|
+
if (preparedFile.optimization.info) {
|
|
239
210
|
console.log(
|
|
240
|
-
` Optimized HTML: ${formatSize(optimization.info.originalBytes)} → ${formatSize(optimization.info.optimizedBytes)}`
|
|
211
|
+
` Optimized HTML: ${formatSize(preparedFile.optimization.info.originalBytes)} → ${formatSize(preparedFile.optimization.info.optimizedBytes)}`
|
|
241
212
|
);
|
|
242
213
|
console.log(
|
|
243
|
-
` Removed embedded fonts: ${optimization.info.removedFonts}`
|
|
214
|
+
` Removed embedded fonts: ${preparedFile.optimization.info.removedFonts}`
|
|
244
215
|
);
|
|
245
216
|
console.log("");
|
|
246
217
|
}
|
|
@@ -262,6 +233,44 @@ async function handleUpload(args) {
|
|
|
262
233
|
console.log("");
|
|
263
234
|
}
|
|
264
235
|
|
|
236
|
+
async function prepareShareFile(filePath, flags = {}) {
|
|
237
|
+
const absolute = path.resolve(String(filePath || ""));
|
|
238
|
+
let stat;
|
|
239
|
+
try {
|
|
240
|
+
stat = await fs.stat(absolute);
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error(`File not found: ${filePath}`);
|
|
243
|
+
}
|
|
244
|
+
if (!stat.isFile()) {
|
|
245
|
+
throw new Error(`Not a regular file: ${filePath}`);
|
|
246
|
+
}
|
|
247
|
+
if (stat.size === 0) {
|
|
248
|
+
throw new Error(`File is empty: ${filePath}`);
|
|
249
|
+
}
|
|
250
|
+
const ext = path.extname(absolute).toLowerCase();
|
|
251
|
+
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}. ` +
|
|
254
|
+
`Got: ${ext || "(no extension)"}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const { mime } = SHARE_TYPES[ext];
|
|
258
|
+
const originalBytes = await fs.readFile(absolute);
|
|
259
|
+
const optimization =
|
|
260
|
+
flags["no-optimize"] || flags["noOptimize"]
|
|
261
|
+
? { bytes: originalBytes, info: null }
|
|
262
|
+
: maybeOptimizeUpload({ absolute, ext, bytes: originalBytes });
|
|
263
|
+
const bytes = optimization.bytes;
|
|
264
|
+
if (bytes.length > MAX_FILE_SIZE) {
|
|
265
|
+
const detail = optimization.info
|
|
266
|
+
? ` after optimization to ${formatSize(bytes.length)}`
|
|
267
|
+
: "";
|
|
268
|
+
throw new Error(`File too large (${originalBytes.length} bytes, max ${MAX_FILE_SIZE})${detail}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { absolute, ext, mime, bytes, optimization };
|
|
272
|
+
}
|
|
273
|
+
|
|
265
274
|
function maybeOptimizeUpload({ absolute, ext, bytes }) {
|
|
266
275
|
if (ext !== ".html" && ext !== ".htm") {
|
|
267
276
|
return { bytes, info: null };
|
|
@@ -506,15 +515,34 @@ function printMetricsSection(metrics, err) {
|
|
|
506
515
|
|
|
507
516
|
async function handleUpdate(args) {
|
|
508
517
|
const flags = parseFlags(args);
|
|
518
|
+
return handleUpdateWithFlags(flags, "update");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function handleReplace(args) {
|
|
522
|
+
const flags = parseFlags(args);
|
|
523
|
+
const slug = flags._[0];
|
|
524
|
+
const filePath = flags._[1] || flags.file || flags.f;
|
|
525
|
+
if (!slug || !filePath || flags._.length > 2) {
|
|
526
|
+
throw new Error("Usage: viveworker share replace <slug> <file> [--expires-days <n>] [--no-optimize] [--json]");
|
|
527
|
+
}
|
|
528
|
+
flags._ = [slug];
|
|
529
|
+
flags.file = filePath;
|
|
530
|
+
return handleUpdateWithFlags(flags, "replace");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function handleUpdateWithFlags(flags, mode = "update") {
|
|
509
534
|
const slug = flags._[0];
|
|
510
535
|
if (!slug) {
|
|
511
536
|
throw new Error(
|
|
512
|
-
"Usage: viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>]"
|
|
537
|
+
"Usage: viveworker share update <slug> [--file <file>] [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--no-optimize]"
|
|
513
538
|
);
|
|
514
539
|
}
|
|
515
540
|
if (!/^[A-Za-z0-9]+$/.test(slug)) {
|
|
516
541
|
throw new Error(`Invalid slug: ${slug}`);
|
|
517
542
|
}
|
|
543
|
+
if (flags._.length > 1) {
|
|
544
|
+
throw new Error("Unexpected positional argument. Use `share replace <slug> <file>` or `share update <slug> --file <file>`.");
|
|
545
|
+
}
|
|
518
546
|
|
|
519
547
|
const hasPassword = Object.prototype.hasOwnProperty.call(flags, "password");
|
|
520
548
|
const hasNoPassword = Object.prototype.hasOwnProperty.call(flags, "no-password");
|
|
@@ -525,6 +553,9 @@ async function handleUpdate(args) {
|
|
|
525
553
|
Object.prototype.hasOwnProperty.call(flags, "noPrice");
|
|
526
554
|
const hasPayTo = Object.prototype.hasOwnProperty.call(flags, "pay-to") ||
|
|
527
555
|
Object.prototype.hasOwnProperty.call(flags, "payTo");
|
|
556
|
+
const hasFile = Object.prototype.hasOwnProperty.call(flags, "file") ||
|
|
557
|
+
Object.prototype.hasOwnProperty.call(flags, "f");
|
|
558
|
+
const filePath = flags.file || flags.f || "";
|
|
528
559
|
|
|
529
560
|
if (hasPassword && hasNoPassword) {
|
|
530
561
|
throw new Error("Pass either --password OR --no-password, not both");
|
|
@@ -535,9 +566,12 @@ async function handleUpdate(args) {
|
|
|
535
566
|
if (hasPrice && hasPassword) {
|
|
536
567
|
throw new Error("--price and --password cannot be combined on a single share");
|
|
537
568
|
}
|
|
538
|
-
if (
|
|
569
|
+
if (hasFile && (typeof filePath !== "string" || filePath.length === 0)) {
|
|
570
|
+
throw new Error("--file requires a path");
|
|
571
|
+
}
|
|
572
|
+
if (!hasPassword && !hasNoPassword && !hasExpires && !hasPrice && !hasNoPrice && !hasPayTo && !hasFile) {
|
|
539
573
|
throw new Error(
|
|
540
|
-
"Nothing to update — specify at least one of --password <pw>, --no-password, --price <usd>, --no-price, --pay-to <0x…>, --expires-days <n>"
|
|
574
|
+
"Nothing to update — specify at least one of --file <file>, --password <pw>, --no-password, --price <usd>, --no-price, --pay-to <0x…>, --expires-days <n>"
|
|
541
575
|
);
|
|
542
576
|
}
|
|
543
577
|
|
|
@@ -603,19 +637,44 @@ async function handleUpdate(args) {
|
|
|
603
637
|
|
|
604
638
|
const { apiKey, userId, shareUrl } = await resolveCredentials();
|
|
605
639
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
640
|
+
let preparedFile = null;
|
|
641
|
+
let requestBody;
|
|
642
|
+
let requestHeaders;
|
|
643
|
+
let timeoutMs;
|
|
644
|
+
if (hasFile) {
|
|
645
|
+
preparedFile = await prepareShareFile(filePath, flags);
|
|
646
|
+
const form = new FormData();
|
|
647
|
+
const blob = new Blob([preparedFile.bytes], { type: preparedFile.mime });
|
|
648
|
+
const file = new File([blob], path.basename(preparedFile.absolute), { type: preparedFile.mime });
|
|
649
|
+
form.set("file", file);
|
|
650
|
+
for (const [key, value] of Object.entries(body)) {
|
|
651
|
+
form.set(key, value === null ? "" : String(value));
|
|
652
|
+
}
|
|
653
|
+
requestBody = form;
|
|
654
|
+
requestHeaders = {
|
|
655
|
+
"x-a2a-user": userId,
|
|
656
|
+
"x-a2a-key": apiKey,
|
|
657
|
+
};
|
|
658
|
+
timeoutMs = 60_000;
|
|
659
|
+
} else {
|
|
660
|
+
requestBody = JSON.stringify(body);
|
|
661
|
+
requestHeaders = {
|
|
609
662
|
"content-type": "application/json",
|
|
610
663
|
"x-a2a-user": userId,
|
|
611
664
|
"x-a2a-key": apiKey,
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
}
|
|
665
|
+
};
|
|
666
|
+
timeoutMs = 30_000;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const res = await fetchWithTimeout(`${shareUrl}/api/share/${encodeURIComponent(slug)}`, {
|
|
670
|
+
method: "PATCH",
|
|
671
|
+
headers: requestHeaders,
|
|
672
|
+
body: requestBody,
|
|
673
|
+
}, timeoutMs);
|
|
615
674
|
|
|
616
675
|
const respBody = await readJson(res);
|
|
617
676
|
if (!res.ok || respBody.error) {
|
|
618
|
-
throw new Error(formatApiError("Update", res.status, respBody));
|
|
677
|
+
throw new Error(formatApiError(mode === "replace" ? "Replace" : "Update", res.status, respBody));
|
|
619
678
|
}
|
|
620
679
|
|
|
621
680
|
if (flags.json) {
|
|
@@ -624,17 +683,25 @@ async function handleUpdate(args) {
|
|
|
624
683
|
}
|
|
625
684
|
|
|
626
685
|
console.log("");
|
|
627
|
-
console.log(`✅ Updated ${slug}`);
|
|
686
|
+
console.log(hasFile ? `✅ Replaced file for ${slug}` : `✅ Updated ${slug}`);
|
|
628
687
|
console.log("");
|
|
629
688
|
console.log(` ${respBody.url}`);
|
|
630
689
|
console.log("");
|
|
690
|
+
if (hasFile) {
|
|
691
|
+
console.log(` File: ${respBody.originalName || path.basename(preparedFile.absolute)} (${formatSize(respBody.size || preparedFile.bytes.length)})`);
|
|
692
|
+
if (preparedFile.optimization.info) {
|
|
693
|
+
console.log(
|
|
694
|
+
` Optimized HTML: ${formatSize(preparedFile.optimization.info.originalBytes)} → ${formatSize(preparedFile.optimization.info.optimizedBytes)}`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
631
698
|
if (respBody.hasPassword) {
|
|
632
699
|
console.log(` 🔒 Password-protected${hasPassword ? " (existing unlock cookies invalidated)" : ""}`);
|
|
633
700
|
} else if (hasNoPassword) {
|
|
634
701
|
console.log(` 🔓 Password removed`);
|
|
635
702
|
}
|
|
636
703
|
if (respBody.price && respBody.payTo) {
|
|
637
|
-
const rotated = hasPrice ? " (paid sessions invalidated)" : "";
|
|
704
|
+
const rotated = hasPrice || respBody.paymentSessionsInvalidated ? " (paid sessions invalidated)" : "";
|
|
638
705
|
console.log(
|
|
639
706
|
` 💰 Paid — ${formatUsdc(respBody.price)} USDC on ${respBody.network || "?"} → ${respBody.payTo}${rotated}`
|
|
640
707
|
);
|
|
@@ -652,7 +719,7 @@ async function handleUpdate(args) {
|
|
|
652
719
|
// password-protected share to another agent without disclosing the password.
|
|
653
720
|
//
|
|
654
721
|
// The owner keeps the password on their side; the receiver only needs to GET
|
|
655
|
-
// the returned URL. Tokens default to 24h, capped at
|
|
722
|
+
// the returned URL. Tokens default to 24h, capped at 720h (30d) and capped by
|
|
656
723
|
// the share's own `expiresAtMs`. Rotating the password via `share update
|
|
657
724
|
// --password ...` invalidates every outstanding token for the slug.
|
|
658
725
|
// ---------------------------------------------------------------------------
|
|
@@ -681,8 +748,8 @@ async function handleLink(args) {
|
|
|
681
748
|
if (hasTtl) {
|
|
682
749
|
const raw = flags["ttl-hours"] || flags["ttlHours"];
|
|
683
750
|
const n = Number(raw);
|
|
684
|
-
if (!Number.isFinite(n) || n <= 0 || n >
|
|
685
|
-
throw new Error("--ttl-hours must be a number between 1 and
|
|
751
|
+
if (!Number.isFinite(n) || n <= 0 || n > 720) {
|
|
752
|
+
throw new Error("--ttl-hours must be a number between 1 and 720");
|
|
686
753
|
}
|
|
687
754
|
ttlHours = n;
|
|
688
755
|
}
|
|
@@ -1181,6 +1248,12 @@ function formatApiError(op, status, body) {
|
|
|
1181
1248
|
return `${op} failed (${status}): file count exceeded — ${body.current}/${body.max}. Delete something first.`;
|
|
1182
1249
|
case "file-too-large":
|
|
1183
1250
|
return `${op} failed (${status}): file too large (max ${formatSize(body.maxBytes)})`;
|
|
1251
|
+
case "empty-file":
|
|
1252
|
+
return `${op} failed (${status}): file is empty`;
|
|
1253
|
+
case "invalid-form-data":
|
|
1254
|
+
return `${op} failed (${status}): invalid multipart form data`;
|
|
1255
|
+
case "invalid-file-field":
|
|
1256
|
+
return `${op} failed (${status}): invalid file field`;
|
|
1184
1257
|
case "unsupported-extension":
|
|
1185
1258
|
return `${op} failed (${status}): unsupported file type. Accepted: ${(body.allowed || []).join(" / ") || "n/a"}`;
|
|
1186
1259
|
case "unsupported-content-type":
|
|
@@ -1196,7 +1269,7 @@ function formatApiError(op, status, body) {
|
|
|
1196
1269
|
case "not-password-protected":
|
|
1197
1270
|
return `${op} failed (${status}): share has no password — no link token needed, just share the URL directly`;
|
|
1198
1271
|
case "invalid-ttlHours":
|
|
1199
|
-
return `${op} failed (${status}): --ttl-hours must be between 1 and ${body.maxHours ||
|
|
1272
|
+
return `${op} failed (${status}): --ttl-hours must be between 1 and ${body.maxHours || 720}`;
|
|
1200
1273
|
// x402 / paid-share error codes — thrown by the worker on upload,
|
|
1201
1274
|
// PATCH, or view. Keep the messages actionable; agents read these
|
|
1202
1275
|
// back to the user verbatim.
|
|
@@ -9631,6 +9631,15 @@ function isStartTurnAckTimeout(errorValue) {
|
|
|
9631
9631
|
message === "turn/start-timeout";
|
|
9632
9632
|
}
|
|
9633
9633
|
|
|
9634
|
+
function isIpcNoClientFoundError(errorValue) {
|
|
9635
|
+
const message = normalizeIpcErrorMessage(errorValue).toLowerCase();
|
|
9636
|
+
return message === "no-client-found" ||
|
|
9637
|
+
message === "client-not-found" ||
|
|
9638
|
+
message === "target-client-not-found" ||
|
|
9639
|
+
message.includes("no client found") ||
|
|
9640
|
+
message.includes("client not found");
|
|
9641
|
+
}
|
|
9642
|
+
|
|
9634
9643
|
function buildDefaultCollaborationMode(threadState) {
|
|
9635
9644
|
// Fallback turns must leave Plan mode unless the caller explicitly opts in.
|
|
9636
9645
|
return buildRequestedCollaborationMode(threadState, "default");
|
|
@@ -9749,14 +9758,36 @@ class NativeIpcClient {
|
|
|
9749
9758
|
);
|
|
9750
9759
|
}
|
|
9751
9760
|
|
|
9752
|
-
sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null, options = {}) {
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
|
|
9761
|
+
async sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null, options = {}) {
|
|
9762
|
+
const targetClientId =
|
|
9763
|
+
ownerClientId ??
|
|
9764
|
+
this.runtime.threadOwnerClientIds.get(conversationId) ??
|
|
9765
|
+
null;
|
|
9766
|
+
|
|
9767
|
+
try {
|
|
9768
|
+
return await this.sendRequest(method, params, {
|
|
9769
|
+
targetClientId,
|
|
9770
|
+
...options,
|
|
9771
|
+
});
|
|
9772
|
+
} catch (error) {
|
|
9773
|
+
if (!targetClientId || !isIpcNoClientFoundError(error)) {
|
|
9774
|
+
throw error;
|
|
9775
|
+
}
|
|
9776
|
+
|
|
9777
|
+
const mappedOwner = this.runtime.threadOwnerClientIds.get(conversationId) ?? null;
|
|
9778
|
+
if (mappedOwner === targetClientId) {
|
|
9779
|
+
this.runtime.threadOwnerClientIds.delete(conversationId);
|
|
9780
|
+
}
|
|
9781
|
+
console.warn(
|
|
9782
|
+
`[ipc] stale owner client for thread=${cleanText(conversationId || "") || "unknown"} ` +
|
|
9783
|
+
`target=${cleanText(targetClientId || "") || "unknown"} method=${cleanText(method || "") || "unknown"}; retrying without target`
|
|
9784
|
+
);
|
|
9785
|
+
|
|
9786
|
+
return this.sendRequest(method, params, {
|
|
9787
|
+
...options,
|
|
9788
|
+
targetClientId: null,
|
|
9789
|
+
});
|
|
9790
|
+
}
|
|
9760
9791
|
}
|
|
9761
9792
|
|
|
9762
9793
|
sendRequest(method, params, options = {}) {
|
|
@@ -19041,6 +19072,9 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
|
|
|
19041
19072
|
if (error.message === "codex-ipc-not-connected") {
|
|
19042
19073
|
return writeJson(res, 503, { error: error.message });
|
|
19043
19074
|
}
|
|
19075
|
+
if (isIpcNoClientFoundError(error)) {
|
|
19076
|
+
return writeJson(res, 503, { error: "codex-client-not-found" });
|
|
19077
|
+
}
|
|
19044
19078
|
return writeJson(res, 500, { error: error.message });
|
|
19045
19079
|
}
|
|
19046
19080
|
}
|
package/scripts/viveworker.mjs
CHANGED
|
@@ -115,6 +115,9 @@ async function main(cliOptions) {
|
|
|
115
115
|
case "stop":
|
|
116
116
|
await runStop(cliOptions);
|
|
117
117
|
return;
|
|
118
|
+
case "restart":
|
|
119
|
+
await runRestart(cliOptions);
|
|
120
|
+
return;
|
|
118
121
|
case "status":
|
|
119
122
|
await runStatus(cliOptions);
|
|
120
123
|
return;
|
|
@@ -1181,6 +1184,11 @@ async function runStop(cliOptions) {
|
|
|
1181
1184
|
]);
|
|
1182
1185
|
}
|
|
1183
1186
|
|
|
1187
|
+
async function runRestart(cliOptions) {
|
|
1188
|
+
await runStop(cliOptions);
|
|
1189
|
+
await runStart(cliOptions);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1184
1192
|
async function runStatus(cliOptions) {
|
|
1185
1193
|
const configDir = resolvePath(cliOptions.configDir || defaultConfigDir);
|
|
1186
1194
|
const envFile = resolvePath(cliOptions.envFile || path.join(configDir, "config.env"));
|
|
@@ -1834,6 +1842,7 @@ ${t(locale, "cli.help.commands")}
|
|
|
1834
1842
|
${t(locale, "cli.help.enable")}
|
|
1835
1843
|
${t(locale, "cli.help.start")}
|
|
1836
1844
|
${t(locale, "cli.help.stop")}
|
|
1845
|
+
${t(locale, "cli.help.restart")}
|
|
1837
1846
|
${t(locale, "cli.help.status")}
|
|
1838
1847
|
${t(locale, "cli.help.doctor")}
|
|
1839
1848
|
${t(locale, "cli.help.update")}
|
|
@@ -26,7 +26,8 @@ Then restart the Claude Code session. If the tools are not available, ask the us
|
|
|
26
26
|
- `viveworker_notify` sends an informational phone notification and records a timeline entry.
|
|
27
27
|
- `viveworker_ask` asks the paired phone a question and waits for the answer.
|
|
28
28
|
- `viveworker_request_approval` asks the phone to approve or reject a proposed action.
|
|
29
|
-
- `viveworker_share_file` uploads a workspace file to File Share after phone approval.
|
|
29
|
+
- `viveworker_share_file` uploads a workspace file to File Share after phone approval. For password-protected handoff, pass `tokenize: true` to return a short-lived passwordless `?t=` URL.
|
|
30
|
+
- `viveworker_share_link` mints a short-lived passwordless `?t=` URL for an existing password-protected File Share slug after phone approval.
|
|
30
31
|
- `viveworker_thread_share` shares context into another Codex / Claude / inbox thread.
|
|
31
32
|
- `viveworker_send_a2a_task` sends a task to a registered A2A target after phone approval.
|
|
32
33
|
|
|
@@ -35,6 +36,7 @@ Then restart the Claude Code session. If the tools are not available, ask the us
|
|
|
35
36
|
- If the user says "ask me on my phone", "スマホに聞いて", or a short decision blocks progress, use `viveworker_ask`.
|
|
36
37
|
- If the user asks you to proceed with a risky, external, irreversible, payment-related, or user-visible action, use `viveworker_request_approval`.
|
|
37
38
|
- If the user asks for a report, prototype, screenshot, PDF, CSV, or standalone HTML to become a shareable link, use `viveworker_share_file`.
|
|
39
|
+
- If the user asks for a password-protected share but wants the recipient to open it without knowing the password, use `viveworker_share_file` with `password` and `tokenize: true`, or use `viveworker_share_link` for an existing slug.
|
|
38
40
|
- If the user says "share this with Codex/Claude", "Aの内容をBに共有して", or wants context handed to another session, use `viveworker_thread_share`.
|
|
39
41
|
- If the user wants another registered agent to do work, use `viveworker_send_a2a_task`.
|
|
40
42
|
|
package/web/app.js
CHANGED
|
@@ -43,6 +43,7 @@ const DETAIL_REFRESH_FALLBACK_TIMEOUT_MS = 2_500;
|
|
|
43
43
|
const DETAIL_STICKY_LAN_PROBE_TIMEOUT_MS = 350;
|
|
44
44
|
const COMPLETION_REPLY_SEND_TIMEOUT_MS = 22_000;
|
|
45
45
|
const COMPLETION_REPLY_OPTIMISTIC_SENT_MS = 1_600;
|
|
46
|
+
const LAN_FETCH_TIMEOUT_MESSAGE = "LAN fetch timed out";
|
|
46
47
|
const TIMELINE_REFRESH_TIMEOUT_MS = 8_000;
|
|
47
48
|
const TIMELINE_POLL_TIMEOUT_MS = 4_500;
|
|
48
49
|
const FAST_POLL_STEP_TIMEOUT_MS = 4_500;
|
|
@@ -2755,6 +2756,11 @@ function completionReplyWarningMatchesSentText(error, text, attachmentCount = 0)
|
|
|
2755
2756
|
return Boolean(warningText && sentText && warningText === sentText);
|
|
2756
2757
|
}
|
|
2757
2758
|
|
|
2759
|
+
function isCompletionReplyLateNetworkResult(error) {
|
|
2760
|
+
return error?.errorKey === "request-timeout" ||
|
|
2761
|
+
error?.message === LAN_FETCH_TIMEOUT_MESSAGE;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2758
2764
|
function normalizeCompletionReplyAttachments(values) {
|
|
2759
2765
|
const rawValues = Array.isArray(values)
|
|
2760
2766
|
? values
|
|
@@ -10027,7 +10033,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
|
|
|
10027
10033
|
} catch (error) {
|
|
10028
10034
|
const optimisticDraft = getCompletionReplyDraft(token);
|
|
10029
10035
|
if (
|
|
10030
|
-
error
|
|
10036
|
+
isCompletionReplyLateNetworkResult(error) &&
|
|
10031
10037
|
optimisticDraft.collapsedAfterSend &&
|
|
10032
10038
|
optimisticDraft.sentText === text
|
|
10033
10039
|
) {
|
|
@@ -11504,6 +11510,7 @@ function localizeApiError(value) {
|
|
|
11504
11510
|
"completion-reply-image-limit": "error.completionReplyImageLimit",
|
|
11505
11511
|
"completion-reply-image-invalid-upload": "error.completionReplyImageInvalidUpload",
|
|
11506
11512
|
"codex-ipc-not-connected": "error.codexIpcNotConnected",
|
|
11513
|
+
"codex-client-not-found": "error.codexClientNotFound",
|
|
11507
11514
|
"approval-not-found": "error.approvalNotFound",
|
|
11508
11515
|
"approval-already-handled": "error.approvalAlreadyHandled",
|
|
11509
11516
|
"plan-request-not-found": "error.planRequestNotFound",
|
package/web/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const APP_BUILD_ID = "
|
|
1
|
+
export const APP_BUILD_ID = "20260514-lan-timeout-reply-ack";
|
package/web/i18n.js
CHANGED
|
@@ -758,6 +758,7 @@ const translations = {
|
|
|
758
758
|
"error.completionReplyImageLimit": "Attach up to {count} images at a time.",
|
|
759
759
|
"error.completionReplyImageInvalidUpload": "That image could not be uploaded.",
|
|
760
760
|
"error.codexIpcNotConnected": "Codex desktop is not connected right now.",
|
|
761
|
+
"error.codexClientNotFound": "Codex changed its active client. Reopen the thread on your PC and try again.",
|
|
761
762
|
"error.approvalNotFound": "This approval is no longer available.",
|
|
762
763
|
"error.approvalAlreadyHandled": "This approval was already handled.",
|
|
763
764
|
"error.planRequestNotFound": "This plan request is no longer available.",
|
|
@@ -884,13 +885,14 @@ const translations = {
|
|
|
884
885
|
"server.page.choiceMissing": "Choice input token not found",
|
|
885
886
|
"server.page.approvalHandled": "Approval already handled",
|
|
886
887
|
"server.page.detailMissing": "Completion detail not found",
|
|
887
|
-
"cli.help.usage": "Usage: viveworker <setup|pair|enable|start|stop|status|doctor|update> [options]",
|
|
888
|
+
"cli.help.usage": "Usage: viveworker <setup|pair|enable|start|stop|restart|status|doctor|update> [options]",
|
|
888
889
|
"cli.help.commands": "Commands:",
|
|
889
890
|
"cli.help.setup": "setup Create config, register launchd, and start the base viveworker bridge",
|
|
890
891
|
"cli.help.pair": "pair Refresh pairing info for another trusted device",
|
|
891
892
|
"cli.help.enable": "enable Enable an optional integration or feature",
|
|
892
893
|
"cli.help.start": "start Start the bridge using the saved config",
|
|
893
894
|
"cli.help.stop": "stop Stop the bridge",
|
|
895
|
+
"cli.help.restart": "restart Restart the bridge using the saved config",
|
|
894
896
|
"cli.help.status": "status Print launchd/background status and health",
|
|
895
897
|
"cli.help.doctor": "doctor Diagnose the local setup",
|
|
896
898
|
"cli.help.update": "update Update to the latest version and restart all services",
|
|
@@ -1835,6 +1837,7 @@ const translations = {
|
|
|
1835
1837
|
"error.completionReplyImageLimit": "画像は最大 {count} 枚まで添付できます。",
|
|
1836
1838
|
"error.completionReplyImageInvalidUpload": "この画像はアップロードできませんでした。",
|
|
1837
1839
|
"error.codexIpcNotConnected": "いまは Codex desktop に接続できていません。",
|
|
1840
|
+
"error.codexClientNotFound": "Codex 側の接続先が切り替わりました。PC で対象スレッドを開き直して、もう一度送信してください。",
|
|
1838
1841
|
"error.approvalNotFound": "この承認はもう利用できません。",
|
|
1839
1842
|
"error.approvalAlreadyHandled": "この承認はすでに処理済みです。",
|
|
1840
1843
|
"error.planRequestNotFound": "このプラン確認はもう利用できません。",
|
|
@@ -1961,13 +1964,14 @@ const translations = {
|
|
|
1961
1964
|
"server.page.choiceMissing": "選択入力トークンが見つかりません",
|
|
1962
1965
|
"server.page.approvalHandled": "この承認はすでに処理済みです",
|
|
1963
1966
|
"server.page.detailMissing": "完了詳細が見つかりません",
|
|
1964
|
-
"cli.help.usage": "Usage: viveworker <setup|pair|enable|start|stop|status|doctor|update> [options]",
|
|
1967
|
+
"cli.help.usage": "Usage: viveworker <setup|pair|enable|start|stop|restart|status|doctor|update> [options]",
|
|
1965
1968
|
"cli.help.commands": "Commands:",
|
|
1966
1969
|
"cli.help.setup": "setup base の設定を作成し、launchd に登録して viveworker bridge を起動します",
|
|
1967
1970
|
"cli.help.pair": "pair 追加する信頼済み端末向けに pairing 情報を更新します",
|
|
1968
1971
|
"cli.help.enable": "enable オプション機能や連携を有効化します",
|
|
1969
1972
|
"cli.help.start": "start 保存済み設定で bridge を起動します",
|
|
1970
1973
|
"cli.help.stop": "stop bridge を停止します",
|
|
1974
|
+
"cli.help.restart": "restart 保存済み設定で bridge を再起動します",
|
|
1971
1975
|
"cli.help.status": "status launchd / バックグラウンド状態と health を表示します",
|
|
1972
1976
|
"cli.help.doctor": "doctor ローカル設定を診断します",
|
|
1973
1977
|
"cli.help.update": "update 最新バージョンに更新し、全サービスを再起動します",
|
|
@@ -88,6 +88,7 @@ const DEFAULT_FAILURE_WINDOW_MS = 60_000;
|
|
|
88
88
|
const DEFAULT_FAILURE_THRESHOLD = 4;
|
|
89
89
|
const DEFAULT_CIRCUIT_BREAKER_MS = 60_000;
|
|
90
90
|
const DEFAULT_MAX_CIRCUIT_BREAKER_MS = 10 * 60_000;
|
|
91
|
+
const DEFAULT_STABLE_CONNECTION_MS = 15_000;
|
|
91
92
|
const DEFAULT_PROLOGUE = new TextEncoder().encode("viveworker/remote-pairing/v1");
|
|
92
93
|
|
|
93
94
|
// CloseEvent codes we emit. 1000 is normal; 4xxx is application-defined.
|
|
@@ -121,6 +122,7 @@ const CLOSE_RELAY_RESET_SESSION = 4004;
|
|
|
121
122
|
* @property {number} [failureThreshold]
|
|
122
123
|
* @property {number} [circuitBreakerMs]
|
|
123
124
|
* @property {number} [maxCircuitBreakerMs]
|
|
125
|
+
* @property {number} [stableConnectionMs]
|
|
124
126
|
* @property {typeof WebSocket} [WebSocketImpl] injectable for Node-side tests
|
|
125
127
|
* @property {{debug?: Function, warn?: Function}} [logger]
|
|
126
128
|
*/
|
|
@@ -166,6 +168,7 @@ export class RemotePairingTransport {
|
|
|
166
168
|
this._failureThreshold = opts.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
|
167
169
|
this._circuitBreakerMs = opts.circuitBreakerMs ?? DEFAULT_CIRCUIT_BREAKER_MS;
|
|
168
170
|
this._maxCircuitBreakerMs = opts.maxCircuitBreakerMs ?? DEFAULT_MAX_CIRCUIT_BREAKER_MS;
|
|
171
|
+
this._stableConnectionMs = opts.stableConnectionMs ?? DEFAULT_STABLE_CONNECTION_MS;
|
|
169
172
|
|
|
170
173
|
this._WebSocketImpl = opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
171
174
|
if (typeof this._WebSocketImpl !== "function") {
|
|
@@ -200,6 +203,8 @@ export class RemotePairingTransport {
|
|
|
200
203
|
this._pingTimer = null;
|
|
201
204
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
202
205
|
this._handshakeTimer = null;
|
|
206
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
207
|
+
this._stableConnectionTimer = null;
|
|
203
208
|
/** @type {number[]} recent reconnect-triggering failures. */
|
|
204
209
|
this._recentFailureAtMs = [];
|
|
205
210
|
/** If > Date.now(), reconnect attempts are intentionally delayed. */
|
|
@@ -317,6 +322,7 @@ export class RemotePairingTransport {
|
|
|
317
322
|
this._closed = true;
|
|
318
323
|
this._started = false;
|
|
319
324
|
this._cancelReconnectTimer();
|
|
325
|
+
this._cancelStableConnectionTimer();
|
|
320
326
|
this._stopPing();
|
|
321
327
|
this._stopHandshakeTimer();
|
|
322
328
|
if (this._ws) {
|
|
@@ -682,6 +688,24 @@ export class RemotePairingTransport {
|
|
|
682
688
|
}
|
|
683
689
|
}
|
|
684
690
|
|
|
691
|
+
_scheduleStableConnectionReset() {
|
|
692
|
+
const delay = Math.max(0, Number(this._stableConnectionMs) || 0);
|
|
693
|
+
this._stableConnectionTimer = setTimeout(() => {
|
|
694
|
+
this._stableConnectionTimer = null;
|
|
695
|
+
if (this._closed || this._state !== STATE.CONNECTED) return;
|
|
696
|
+
this._recentFailureAtMs = [];
|
|
697
|
+
this._circuitOpenUntilMs = 0;
|
|
698
|
+
this._circuitOpenCount = 0;
|
|
699
|
+
}, delay);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
_cancelStableConnectionTimer() {
|
|
703
|
+
if (this._stableConnectionTimer != null) {
|
|
704
|
+
clearTimeout(this._stableConnectionTimer);
|
|
705
|
+
this._stableConnectionTimer = null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
685
709
|
_startPing() {
|
|
686
710
|
this._stopPing();
|
|
687
711
|
this._pingTimer = setInterval(() => {
|
|
@@ -742,11 +766,10 @@ export class RemotePairingTransport {
|
|
|
742
766
|
if (this._state === newState) return;
|
|
743
767
|
const prev = this._state;
|
|
744
768
|
this._state = newState;
|
|
769
|
+
this._cancelStableConnectionTimer();
|
|
745
770
|
if (newState === STATE.CONNECTED) {
|
|
746
771
|
this._reconnectAttempt = 0;
|
|
747
|
-
this.
|
|
748
|
-
this._circuitOpenUntilMs = 0;
|
|
749
|
-
this._circuitOpenCount = 0;
|
|
772
|
+
this._scheduleStableConnectionReset();
|
|
750
773
|
}
|
|
751
774
|
try {
|
|
752
775
|
this._onStateChange(newState, prev, info);
|
|
@@ -771,6 +794,7 @@ export class RemotePairingTransport {
|
|
|
771
794
|
this._stopPing();
|
|
772
795
|
this._stopHandshakeTimer();
|
|
773
796
|
this._cancelReconnectTimer();
|
|
797
|
+
this._cancelStableConnectionTimer();
|
|
774
798
|
if (this._ws) {
|
|
775
799
|
try { this._ws.close(CLOSE_FATAL, "fatal-error"); } catch {}
|
|
776
800
|
this._ws = null;
|