viveworker 0.6.3 → 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -190,6 +190,28 @@ Typical commands:
190
190
 
191
191
  The current public File Share surface is focused on private static artefact delivery from your Mac and your agents.
192
192
 
193
+ ## Experimental: Deliverable Unlock Flow
194
+
195
+ `viveworker` is also experimenting with a testnet-only unlock flow for agent deliverables.
196
+
197
+ The idea is simple:
198
+
199
+ - an agent receives a task
200
+ - the work runs locally
201
+ - the result is handed back through File Share
202
+ - the requester unlocks the deliverable on testnet
203
+
204
+ This is not meant as a generic "payments feature."
205
+ The interesting part is the agent workflow: request, delivery, handoff, and unlock stay cleanly separated.
206
+
207
+ Current status:
208
+
209
+ - experimental
210
+ - testnet only
211
+ - feedback wanted from people already building agent-to-agent workflows
212
+
213
+ The goal is to learn whether deliverable unlock feels like a natural settlement point for real agent tasks.
214
+
193
215
  ## Claude Desktop Integration
194
216
 
195
217
  During `npx viveworker setup`, viveworker checks whether Claude Desktop is already installed and, if so, automatically installs hook entries into `~/.claude/settings.json` (`UserPromptSubmit`, `Notification`, `Stop`, `PermissionRequest`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `SessionEnd`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.6.3",
3
+ "version": "0.7.0-beta.1",
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",
@@ -7,12 +7,18 @@
7
7
  * .html .htm .pdf .png .jpg .jpeg .gif .webp .csv
8
8
  *
9
9
  * Commands:
10
- * viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--no-optimize] [--json]
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] [--expires-days <n>] [--json]
12
+ * viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]
13
13
  * viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]
14
14
  * viveworker share delete <slug>
15
15
  *
16
+ * Paid shares: `--price 0.10 --pay-to 0x…` attaches an x402 payment gate.
17
+ * Buyers hit HTTP 402 on first view, pay USDC on Base (testnet or mainnet
18
+ * depending on worker config), and the worker serves the content. Any
19
+ * x402-compatible client (e.g. `x402-fetch` on npm) can pay; viveworker
20
+ * itself does not currently ship a buyer wallet.
21
+ *
16
22
  * Environment overrides:
17
23
  * VIVEWORKER_SHARE_URL — share worker base URL (default: https://share.viveworker.com)
18
24
  */
@@ -71,9 +77,9 @@ export async function runShareCli(args) {
71
77
 
72
78
  function printHelp() {
73
79
  console.log("Commands:");
74
- console.log(" viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--no-optimize] [--json]");
75
- console.log(" viveworker share list [--json]");
76
- console.log(" viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>] [--json]");
80
+ console.log(" viveworker share upload <file> [--password <pw>] [--price <usd> --pay-to <0x…>] [--expires-days <n>] [--no-optimize] [--json]");
81
+ console.log(" viveworker share list [--metrics] [--json]");
82
+ console.log(" viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>] [--json]");
77
83
  console.log(" viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
78
84
  console.log(" viveworker share delete <slug>");
79
85
  console.log("");
@@ -81,9 +87,17 @@ function printHelp() {
81
87
  console.log("CSV files are rendered as an HTML table on view; append ?raw=1 for bytes.");
82
88
  console.log("HTML uploads are optimized by default when possible (use --no-optimize to disable).");
83
89
  console.log("");
90
+ console.log("Paid shares (x402 / USDC on Base — CLOSED BETA, testnet only): --price 0.10 --pay-to 0x…");
91
+ console.log(" Buyers use any x402-compatible client (e.g. `x402-fetch` on npm).");
92
+ console.log(" --price and --password are mutually exclusive on a single share.");
93
+ console.log(" `share list --metrics` prints 24h / 7d payment-flow stats for your shares.");
94
+ console.log("");
84
95
  console.log("Credentials are read from ~/.viveworker/a2a.env (same as `viveworker a2a`).");
85
96
  }
86
97
 
98
+ const PRICE_REGEX = /^\d+(\.\d{1,6})?$/;
99
+ const ETH_ADDR_REGEX = /^0x[0-9a-fA-F]{40}$/;
100
+
87
101
  // ---------------------------------------------------------------------------
88
102
  // upload
89
103
  // ---------------------------------------------------------------------------
@@ -119,6 +133,8 @@ async function handleUpload(args) {
119
133
 
120
134
  const password = flags["password"] || "";
121
135
  const expiresDays = flags["expires-days"] || flags["expiresDays"] || "";
136
+ const price = flags["price"] || "";
137
+ const payTo = flags["pay-to"] || flags["payTo"] || "";
122
138
 
123
139
  if (password && password.length > 256) {
124
140
  throw new Error("Password too long (max 256 chars)");
@@ -130,6 +146,24 @@ async function handleUpload(args) {
130
146
  }
131
147
  }
132
148
 
149
+ // --price / --pay-to are both required or both absent.
150
+ const anyPrice = Boolean(price);
151
+ const anyPayTo = Boolean(payTo);
152
+ if (anyPrice !== anyPayTo) {
153
+ throw new Error("--price and --pay-to must be supplied together");
154
+ }
155
+ if (anyPrice) {
156
+ if (!PRICE_REGEX.test(price)) {
157
+ throw new Error("--price must be a decimal with ≤6 fractional digits (e.g. `0.10`)");
158
+ }
159
+ if (!ETH_ADDR_REGEX.test(payTo)) {
160
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
161
+ }
162
+ if (password) {
163
+ throw new Error("--price and --password cannot be combined on a single share");
164
+ }
165
+ }
166
+
133
167
  const { apiKey, userId, shareUrl } = await resolveCredentials();
134
168
 
135
169
  const originalBytes = await fs.readFile(absolute);
@@ -150,6 +184,10 @@ async function handleUpload(args) {
150
184
  form.set("file", file);
151
185
  if (password) form.set("password", password);
152
186
  if (expiresDays) form.set("expiresDays", String(expiresDays));
187
+ if (anyPrice) {
188
+ form.set("price", price);
189
+ form.set("payTo", payTo);
190
+ }
153
191
 
154
192
  const res = await fetchWithTimeout(`${shareUrl}/api/upload`, {
155
193
  method: "POST",
@@ -185,8 +223,11 @@ async function handleUpload(args) {
185
223
  console.log(` ${body.url}`);
186
224
  console.log("");
187
225
  if (body.hasPassword) console.log(` 🔒 Password-protected`);
226
+ if (body.price && body.payTo) {
227
+ console.log(` 💰 Paid — ${formatUsdc(body.price)} USDC on ${body.network || "?"} → ${body.payTo}`);
228
+ }
188
229
  if (body.expiresAtMs) console.log(` ⏱ Expires ${new Date(body.expiresAtMs).toISOString()}`);
189
- if (body.hasPassword || body.expiresAtMs) console.log("");
230
+ if (body.hasPassword || body.price || body.expiresAtMs) console.log("");
190
231
  console.log(` Slug: ${body.slug}`);
191
232
  console.log(` Delete: viveworker share delete ${body.slug}`);
192
233
  if (body.quota) {
@@ -294,25 +335,51 @@ function stringifyForHtmlScriptTag(value) {
294
335
 
295
336
  async function handleList(args) {
296
337
  const flags = parseFlags(args);
338
+ const wantsMetrics = Boolean(flags.metrics);
297
339
  const { apiKey, userId, shareUrl } = await resolveCredentials();
298
340
 
299
- const res = await fetchWithTimeout(`${shareUrl}/api/list`, {
341
+ // Fire both requests in parallel when --metrics is set; the file list is
342
+ // authoritative, the metrics bucket is best-effort (it 501s when the
343
+ // worker's CF_ACCOUNT_ID / CF_API_TOKEN secrets aren't configured).
344
+ const listReq = fetchWithTimeout(`${shareUrl}/api/list`, {
300
345
  method: "GET",
301
- headers: {
302
- "x-a2a-user": userId,
303
- "x-a2a-key": apiKey,
304
- },
346
+ headers: { "x-a2a-user": userId, "x-a2a-key": apiKey },
305
347
  }, 30_000);
306
-
348
+ const metricsReq = wantsMetrics
349
+ ? fetchWithTimeout(`${shareUrl}/api/metrics`, {
350
+ method: "GET",
351
+ headers: { "x-a2a-user": userId, "x-a2a-key": apiKey },
352
+ }, 30_000).catch((err) => ({ _netErr: String(err?.message || err) }))
353
+ : null;
354
+
355
+ const res = await listReq;
307
356
  const body = await readJson(res);
308
357
  if (!res.ok || body.error) {
309
358
  throw new Error(formatApiError("List", res.status, body));
310
359
  }
311
360
 
361
+ let metricsBody = null;
362
+ let metricsErr = null;
363
+ if (metricsReq) {
364
+ const mRes = await metricsReq;
365
+ if (mRes && mRes._netErr) {
366
+ metricsErr = `metrics fetch failed: ${mRes._netErr}`;
367
+ } else {
368
+ metricsBody = await readJson(mRes);
369
+ if (!mRes.ok || metricsBody?.error) {
370
+ metricsErr = formatApiError("Metrics", mRes.status, metricsBody);
371
+ metricsBody = null;
372
+ }
373
+ }
374
+ }
375
+
312
376
  const items = body.items || [];
313
377
 
314
378
  if (flags.json) {
315
- console.log(JSON.stringify(body, null, 2));
379
+ const combined = wantsMetrics
380
+ ? { ...body, metrics: metricsBody, metricsError: metricsErr }
381
+ : body;
382
+ console.log(JSON.stringify(combined, null, 2));
316
383
  return;
317
384
  }
318
385
 
@@ -324,6 +391,7 @@ async function handleList(args) {
324
391
  } else {
325
392
  console.log("(no uploads yet)");
326
393
  }
394
+ if (wantsMetrics) printMetricsSection(metricsBody, metricsErr);
327
395
  return;
328
396
  }
329
397
 
@@ -335,13 +403,77 @@ async function handleList(args) {
335
403
  for (const item of items) {
336
404
  const age = formatRelative(now - (item.createdAtMs || now));
337
405
  const size = formatSize(item.size || 0);
338
- const lockIcon = item.hasPassword ? "🔒" : " ";
406
+ const gateIcon = item.hasPassword
407
+ ? "🔒"
408
+ : item.price
409
+ ? "💰"
410
+ : " ";
339
411
  const expiry = item.expiresAtMs ? ` · exp ${new Date(item.expiresAtMs).toISOString().slice(0, 10)}` : "";
340
- console.log(` ${lockIcon} ${item.slug} ${size.padStart(8)} ${age.padStart(10)}${expiry}`);
412
+ console.log(` ${gateIcon} ${item.slug} ${size.padStart(8)} ${age.padStart(10)}${expiry}`);
341
413
  console.log(` ${item.url}`);
342
414
  if (item.originalName) console.log(` ${item.originalName}`);
415
+ if (item.price && item.payTo) {
416
+ console.log(` 💰 ${formatUsdc(item.price)} USDC on ${item.network || "?"} → ${item.payTo}`);
417
+ }
343
418
  console.log("");
344
419
  }
420
+
421
+ if (wantsMetrics) printMetricsSection(metricsBody, metricsErr);
422
+ }
423
+
424
+ // Pretty-prints the Analytics Engine summary returned by GET /api/metrics.
425
+ // Kept inline with handleList because it's only invoked from there; lifting it
426
+ // into its own subcommand would duplicate auth + formatters for no real gain.
427
+ function printMetricsSection(metrics, err) {
428
+ console.log("Paid-share metrics");
429
+ if (err) {
430
+ console.log(` ⚠ ${err}`);
431
+ console.log("");
432
+ return;
433
+ }
434
+ if (!metrics) {
435
+ console.log(" ⚠ no data returned from /api/metrics");
436
+ console.log("");
437
+ return;
438
+ }
439
+ const networkLabel = metrics.network ? ` on ${metrics.network}` : "";
440
+ console.log(` (x402 closed beta${networkLabel})`);
441
+ console.log("");
442
+ const labels = {
443
+ upload_paid: "paid shares uploaded",
444
+ "402_served": "402 responses served (GET)",
445
+ "402_served_head": "402 responses served (HEAD)",
446
+ paid_view: "verified paid views",
447
+ paid_cookie_hit: "paid session reloads",
448
+ verify_failed: "verification rejections",
449
+ facilitator_unavailable: "facilitator unreachable",
450
+ settle_failed: "async settle failures",
451
+ };
452
+ const pad = Math.max(...Object.values(labels).map((s) => s.length));
453
+ const row = (label, d, w) => {
454
+ const padded = label.padEnd(pad);
455
+ const dStr = String(d).padStart(6);
456
+ const wStr = String(w).padStart(6);
457
+ console.log(` ${padded} 24h ${dStr} · 7d ${wStr}`);
458
+ };
459
+ for (const [k, label] of Object.entries(labels)) {
460
+ row(label, metrics.last24h?.[k] ?? 0, metrics.last7d?.[k] ?? 0);
461
+ }
462
+ const perSlug = Array.isArray(metrics.perSlug7d) ? metrics.perSlug7d : [];
463
+ if (perSlug.length > 0) {
464
+ console.log("");
465
+ console.log(` Per-share (last 7d, top ${Math.min(5, perSlug.length)}):`);
466
+ for (const entry of perSlug.slice(0, 5)) {
467
+ const c = entry.counts || {};
468
+ const views = (c.paid_view || 0) + (c.paid_cookie_hit || 0);
469
+ const fails = (c.verify_failed || 0) + (c.facilitator_unavailable || 0) + (c.settle_failed || 0);
470
+ const fourOhTwos = (c["402_served"] || 0) + (c["402_served_head"] || 0);
471
+ console.log(
472
+ ` ${entry.slug} views=${views} 402=${fourOhTwos} fails=${fails}`
473
+ );
474
+ }
475
+ }
476
+ console.log("");
345
477
  }
346
478
 
347
479
  // ---------------------------------------------------------------------------
@@ -353,7 +485,7 @@ async function handleUpdate(args) {
353
485
  const slug = flags._[0];
354
486
  if (!slug) {
355
487
  throw new Error(
356
- "Usage: viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>]"
488
+ "Usage: viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>]"
357
489
  );
358
490
  }
359
491
  if (!/^[A-Za-z0-9]+$/.test(slug)) {
@@ -364,13 +496,24 @@ async function handleUpdate(args) {
364
496
  const hasNoPassword = Object.prototype.hasOwnProperty.call(flags, "no-password");
365
497
  const hasExpires = Object.prototype.hasOwnProperty.call(flags, "expires-days") ||
366
498
  Object.prototype.hasOwnProperty.call(flags, "expiresDays");
499
+ const hasPrice = Object.prototype.hasOwnProperty.call(flags, "price");
500
+ const hasNoPrice = Object.prototype.hasOwnProperty.call(flags, "no-price") ||
501
+ Object.prototype.hasOwnProperty.call(flags, "noPrice");
502
+ const hasPayTo = Object.prototype.hasOwnProperty.call(flags, "pay-to") ||
503
+ Object.prototype.hasOwnProperty.call(flags, "payTo");
367
504
 
368
505
  if (hasPassword && hasNoPassword) {
369
506
  throw new Error("Pass either --password OR --no-password, not both");
370
507
  }
371
- if (!hasPassword && !hasNoPassword && !hasExpires) {
508
+ if (hasPrice && hasNoPrice) {
509
+ throw new Error("Pass either --price OR --no-price, not both");
510
+ }
511
+ if (hasPrice && hasPassword) {
512
+ throw new Error("--price and --password cannot be combined on a single share");
513
+ }
514
+ if (!hasPassword && !hasNoPassword && !hasExpires && !hasPrice && !hasNoPrice && !hasPayTo) {
372
515
  throw new Error(
373
- "Nothing to update — specify at least one of --password <pw>, --no-password, --expires-days <n>"
516
+ "Nothing to update — specify at least one of --password <pw>, --no-password, --price <usd>, --no-price, --pay-to <0x…>, --expires-days <n>"
374
517
  );
375
518
  }
376
519
 
@@ -389,6 +532,42 @@ async function handleUpdate(args) {
389
532
  body.password = null;
390
533
  }
391
534
 
535
+ if (hasPrice) {
536
+ const pv = flags.price;
537
+ if (typeof pv !== "string" || pv.length === 0) {
538
+ throw new Error("--price requires a value (use --no-price to clear)");
539
+ }
540
+ if (!PRICE_REGEX.test(pv)) {
541
+ throw new Error("--price must be a decimal with ≤6 fractional digits (e.g. `0.10`)");
542
+ }
543
+ body.price = pv;
544
+ // --pay-to is only required on first set; the worker rejects with
545
+ // `payTo-required-on-first-price` if the share had no payTo before, so
546
+ // let it do the policy enforcement. But if the user passed --pay-to
547
+ // alongside --price, thread it through now.
548
+ if (hasPayTo) {
549
+ const addr = flags["pay-to"] || flags["payTo"];
550
+ if (typeof addr !== "string" || !ETH_ADDR_REGEX.test(addr)) {
551
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
552
+ }
553
+ body.payTo = addr;
554
+ }
555
+ } else if (hasNoPrice) {
556
+ body.price = null;
557
+ if (hasPayTo) {
558
+ throw new Error("--no-price clears both price and payTo; drop --pay-to when removing the gate");
559
+ }
560
+ } else if (hasPayTo) {
561
+ // Change recipient only — does not rotate paymentSalt; existing paid
562
+ // sessions keep working. Worker rejects this when the share has no
563
+ // price gate attached.
564
+ const addr = flags["pay-to"] || flags["payTo"];
565
+ if (typeof addr !== "string" || !ETH_ADDR_REGEX.test(addr)) {
566
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
567
+ }
568
+ body.payTo = addr;
569
+ }
570
+
392
571
  if (hasExpires) {
393
572
  const raw = flags["expires-days"] || flags["expiresDays"];
394
573
  const n = Number(raw);
@@ -430,6 +609,14 @@ async function handleUpdate(args) {
430
609
  } else if (hasNoPassword) {
431
610
  console.log(` 🔓 Password removed`);
432
611
  }
612
+ if (respBody.price && respBody.payTo) {
613
+ const rotated = hasPrice ? " (paid sessions invalidated)" : "";
614
+ console.log(
615
+ ` 💰 Paid — ${formatUsdc(respBody.price)} USDC on ${respBody.network || "?"} → ${respBody.payTo}${rotated}`
616
+ );
617
+ } else if (hasNoPrice) {
618
+ console.log(` 💸 Payment gate removed`);
619
+ }
433
620
  if (respBody.expiresAtMs) {
434
621
  console.log(` ⏱ Expires ${new Date(respBody.expiresAtMs).toISOString()}`);
435
622
  }
@@ -575,6 +762,42 @@ function formatApiError(op, status, body) {
575
762
  return `${op} failed (${status}): share has no password — no link token needed, just share the URL directly`;
576
763
  case "invalid-ttlHours":
577
764
  return `${op} failed (${status}): --ttl-hours must be between 1 and ${body.maxHours || 168}`;
765
+ // x402 / paid-share error codes — thrown by the worker on upload,
766
+ // PATCH, or view. Keep the messages actionable; agents read these
767
+ // back to the user verbatim.
768
+ case "invalid-price":
769
+ return `${op} failed (${status}): --price must be a decimal with ≤6 fractional digits (e.g. \`0.10\`)`;
770
+ case "price-out-of-range":
771
+ return `${op} failed (${status}): price must be between $${formatUsdc(body.minAtomic || "10000")} and $${formatUsdc(body.maxAtomic || "1000000000")}`;
772
+ case "invalid-payTo":
773
+ return `${op} failed (${status}): --pay-to must be a 0x-prefixed 40-hex-char EVM address`;
774
+ case "price-payTo-both-required":
775
+ return `${op} failed (${status}): both --price and --pay-to must be supplied together`;
776
+ case "price-and-password-mutually-exclusive":
777
+ return `${op} failed (${status}): --price and --password cannot be combined on a single share`;
778
+ case "payTo-without-price":
779
+ return `${op} failed (${status}): cannot set --pay-to on a free share — use --price together with --pay-to to add the gate first`;
780
+ case "payTo-cannot-be-cleared-alone":
781
+ return `${op} failed (${status}): --no-price clears both price and payTo; clearing payTo alone is not supported`;
782
+ case "payment-network-not-configured":
783
+ return `${op} failed (${status}): the worker's X402_NETWORK var is not set to a supported chain (base / base-sepolia)`;
784
+ case "payment-required":
785
+ return `${op} failed (${status}): this share requires payment (x402). Use an x402-compatible client (e.g. \`x402-fetch\` on npm) to pay.`;
786
+ case "payment-verification-failed":
787
+ case "malformed-x-payment":
788
+ return `${op} failed (${status}): payment verification failed — the facilitator rejected the X-PAYMENT header`;
789
+ case "facilitator-unavailable":
790
+ return `${op} failed (${status}): x402 facilitator is unreachable — try again shortly`;
791
+ case "paid-shares-closed-beta": {
792
+ // Closed-beta gate on --price / --pay-to uploads and PATCHes while x402
793
+ // is pinned to testnet. The hint + network come from the worker so the
794
+ // message stays accurate even if we flip to mainnet.
795
+ const net = body?.network ? ` (network: ${body.network})` : "";
796
+ const hint = body?.hint ? `\n hint: ${body.hint}` : "";
797
+ return `${op} failed (${status}): paid shares are in closed beta${net}.${hint}`;
798
+ }
799
+ case "metrics-not-configured":
800
+ return `${op} failed (${status}): metrics endpoint is not configured on the worker (missing CF_ACCOUNT_ID / CF_API_TOKEN secrets). Ask the operator to set them.`;
578
801
  default:
579
802
  return `${op} failed (${status}): ${code || body?.statusText || "unknown error"}`;
580
803
  }
@@ -670,6 +893,26 @@ function formatSize(bytes) {
670
893
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
671
894
  }
672
895
 
896
+ // USDC uses 6-decimals. `atomic` is a stringified integer ("100000" = $0.10).
897
+ // Returns a human-facing decimal with at least two fractional digits and
898
+ // trailing zeros beyond that trimmed (so "1000000" → "1.00", "123456" →
899
+ // "0.123456", "12" → "0.000012"). Accepts either strings or bigints; falls
900
+ // back to "0.00" on anything unparseable so we never crash the CLI output
901
+ // on a malformed server response.
902
+ function formatUsdc(atomic) {
903
+ let n;
904
+ try {
905
+ n = BigInt(String(atomic ?? "0"));
906
+ } catch {
907
+ return "0.00";
908
+ }
909
+ if (n < 0n) n = -n;
910
+ const whole = n / 1_000_000n;
911
+ const frac = (n % 1_000_000n).toString().padStart(6, "0").replace(/0+$/u, "");
912
+ if (!frac) return `${whole}.00`;
913
+ return `${whole}.${frac.padEnd(2, "0")}`;
914
+ }
915
+
673
916
  function formatRelative(ms) {
674
917
  const sec = Math.floor(ms / 1000);
675
918
  if (sec < 60) return `${sec}s ago`;
package/web/app.css CHANGED
@@ -623,12 +623,15 @@ code {
623
623
  white-space: nowrap;
624
624
  }
625
625
 
626
+ /* When a badge renders an SVG icon instead of a text label (e.g. the
627
+ * password / paid markers on the File Share settings page), tighten the
628
+ * padding and size the icon to match the surrounding line height. The SVG
629
+ * inherits color via currentColor, so the badge's `color` rule drives it. */
626
630
  .settings-compose-badge svg {
627
631
  width: 0.85rem;
628
632
  height: 0.85rem;
629
633
  display: block;
630
634
  }
631
-
632
635
  .settings-compose-badge:has(> svg) {
633
636
  padding: 0.18rem 0.3rem;
634
637
  }
@@ -643,6 +646,13 @@ code {
643
646
  color: rgba(190, 160, 255, 0.92);
644
647
  }
645
648
 
649
+ /* Paid-share marker on the A2A Share settings page. Amber tone echoes the
650
+ * 💰 emoji and the CLOSED BETA banner on the 402 HTML. */
651
+ .settings-compose-badge--paid {
652
+ background: rgba(240, 180, 40, 0.2);
653
+ color: rgba(255, 210, 110, 0.95);
654
+ }
655
+
646
656
  .settings-compose-entry {
647
657
  display: flex;
648
658
  flex-direction: column;
package/web/app.js CHANGED
@@ -3316,7 +3316,7 @@ function renderSettingsDevicePage(context) {
3316
3316
  </div>
3317
3317
  <div class="settings-command-card">
3318
3318
  <span class="settings-command-card__label">${escapeHtml(L("settings.device.addAnother.commandLabel"))}</span>
3319
- <code class="settings-command-card__value">npx viveworker setup --pair</code>
3319
+ <code class="settings-command-card__value">npx viveworker pair</code>
3320
3320
  </div>
3321
3321
  </div>
3322
3322
  </section>
@@ -3619,10 +3619,27 @@ function renderSettingsA2aSharePage(context) {
3619
3619
  const visible = items.slice(0, visibleCount);
3620
3620
  const hasMore = items.length > visibleCount;
3621
3621
  const filesList = visible.map((item) => {
3622
+ // Badges use SVG icons (see renderIcon "lock" / "coin") to match the
3623
+ // rest of the settings UI. `title` drives the hover tooltip;
3624
+ // `aria-label` names the span for screen readers since the SVG itself
3625
+ // is decorative.
3622
3626
  const passwordLabel = L("settings.a2aShare.passwordProtected");
3623
3627
  const lock = item.hasPassword
3624
3628
  ? `<span class="settings-compose-badge settings-compose-badge--reply" role="img" title="${escapeHtml(passwordLabel)}" aria-label="${escapeHtml(passwordLabel)}">${renderIcon("lock")}</span>`
3625
3629
  : "";
3630
+ // Paid-share badge. `price` is atomic USDC (6-decimals) on the item.
3631
+ // Mutually exclusive with hasPassword at upload time, so the two badges
3632
+ // never both render, but the HTML doesn't assume that — it just renders
3633
+ // whichever are set.
3634
+ const paidLabel = item.price
3635
+ ? L("settings.a2aShare.paidShare", {
3636
+ price: formatUsdcAtomic(item.price),
3637
+ network: item.network || "?",
3638
+ })
3639
+ : "";
3640
+ const paid = item.price
3641
+ ? `<span class="settings-compose-badge settings-compose-badge--paid" role="img" title="${escapeHtml(paidLabel)}" aria-label="${escapeHtml(paidLabel)}">${renderIcon("coin")}</span>`
3642
+ : "";
3626
3643
  const label = escapeHtml(item.originalName || item.slug);
3627
3644
  const link = item.url
3628
3645
  ? `<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener">${label}</a>`
@@ -3641,7 +3658,7 @@ function renderSettingsA2aSharePage(context) {
3641
3658
  <span class="settings-icon-entry__body">
3642
3659
  <span class="settings-icon-entry__title-row">
3643
3660
  <span class="settings-compose-entry__title">${link}</span>
3644
- ${lock}
3661
+ ${lock}${paid}
3645
3662
  </span>
3646
3663
  ${meta ? `<span class="settings-compose-entry__meta muted">${meta}</span>` : ""}
3647
3664
  </span>
@@ -3718,6 +3735,24 @@ function formatExpiresIn(ms) {
3718
3735
  return rtf ? rtf.format(hr, "hour") : `in ${hr}h`;
3719
3736
  }
3720
3737
 
3738
+ // USDC has 6 decimals, stored as atomic units in a string (e.g. "100000" ⇒
3739
+ // $0.10). Mirrors formatUsdc() in scripts/share-cli.mjs — kept as a small
3740
+ // standalone helper rather than a shared module since the app bundle has no
3741
+ // build step that pulls from scripts/.
3742
+ function formatUsdcAtomic(atomic) {
3743
+ let n;
3744
+ try {
3745
+ n = BigInt(String(atomic ?? "0"));
3746
+ } catch {
3747
+ return "0.00";
3748
+ }
3749
+ if (n < 0n) n = -n;
3750
+ const whole = n / 1_000_000n;
3751
+ const frac = (n % 1_000_000n).toString().padStart(6, "0").replace(/0+$/u, "");
3752
+ if (!frac) return `${whole}.00`;
3753
+ return `${whole}.${frac.padEnd(2, "0")}`;
3754
+ }
3755
+
3721
3756
  function renderSettingsInfoRow(label, value, options = {}) {
3722
3757
  const rowClassName = ["settings-info-row", options.rowClassName || ""].filter(Boolean).join(" ");
3723
3758
  const valueClassName = ["settings-info-row__value", options.valueClassName || ""].filter(Boolean).join(" ");
@@ -6860,6 +6895,8 @@ function renderIcon(name) {
6860
6895
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6.8 12.5 3.2 3.2 7.2-7.4"/></svg>`;
6861
6896
  case "lock":
6862
6897
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="10.5" width="13" height="9" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/></svg>`;
6898
+ case "coin":
6899
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7v10"/><path d="M14.5 9.3c-.6-.8-1.5-1.3-2.5-1.3-1.4 0-2.5 1-2.5 2.2 0 1.3 1.1 2 2.5 2s2.5.7 2.5 2c0 1.2-1.1 2.2-2.5 2.2-1 0-1.9-.5-2.5-1.3"/></svg>`;
6863
6900
  case "back":
6864
6901
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>`;
6865
6902
  case "chevron-down":
package/web/i18n.js CHANGED
@@ -333,8 +333,8 @@ const translations = {
333
333
  "settings.device.section.current": "This device",
334
334
  "settings.device.section.other": "Other trusted devices",
335
335
  "settings.device.thisDevice": "This device",
336
- "settings.device.emptyCurrent": "This device is not listed right now.",
337
- "settings.device.emptyOther": "No other trusted devices are active.",
336
+ "settings.device.emptyCurrent": "This device is not in the active trusted-device list right now. Its previous trust may have expired or been revoked. Re-pair it from your Mac with `npx viveworker pair` if needed.",
337
+ "settings.device.emptyOther": "No other trusted devices are active. Expired or revoked devices are hidden here. Re-pair another device from your Mac with `npx viveworker pair` if needed.",
338
338
  "settings.device.addAnother.title": "Add another device",
339
339
  "settings.device.addAnother.heading": "Add a new device or browser",
340
340
  "settings.device.addAnother.copy":
@@ -455,6 +455,7 @@ const translations = {
455
455
  "settings.a2aShare.days": ({ count }) => `${count} ${count === 1 ? "day" : "days"}`,
456
456
  "settings.a2aShare.ratePerHour": ({ count }) => `${count} / hour`,
457
457
  "settings.a2aShare.passwordProtected": "Password protected",
458
+ "settings.a2aShare.paidShare": ({ price, network }) => `Paid — ${price} USDC on ${network}`,
458
459
  "settings.a2aShare.showMore": "{count} more…",
459
460
  "settings.a2aShare.showLess": "Show less",
460
461
  "settings.a2aShare.expired": "Expired",
@@ -1111,8 +1112,8 @@ const translations = {
1111
1112
  "settings.device.section.current": "この端末",
1112
1113
  "settings.device.section.other": "他の信頼済み端末",
1113
1114
  "settings.device.thisDevice": "この端末",
1114
- "settings.device.emptyCurrent": "この端末の情報はまだ表示できません。",
1115
- "settings.device.emptyOther": "他に有効な信頼済み端末はありません。",
1115
+ "settings.device.emptyCurrent": "この端末は現在の有効な信頼済み端末一覧には入っていません。以前の信頼期限が切れたか、削除された可能性があります。必要なら Mac で `npx viveworker pair` を実行して再ペアリングしてください。",
1116
+ "settings.device.emptyOther": "他に有効な信頼済み端末はありません。期限切れまたは削除済みの端末はここには表示されません。必要なら Mac で `npx viveworker pair` を実行して再ペアリングしてください。",
1116
1117
  "settings.device.addAnother.title": "端末を追加する",
1117
1118
  "settings.device.addAnother.heading": "新しい端末 / ブラウザを追加",
1118
1119
  "settings.device.addAnother.copy":
@@ -1233,6 +1234,7 @@ const translations = {
1233
1234
  "settings.a2aShare.days": ({ count }) => `${count} 日`,
1234
1235
  "settings.a2aShare.ratePerHour": ({ count }) => `${count} 件 / 1 時間`,
1235
1236
  "settings.a2aShare.passwordProtected": "パスワード保護",
1237
+ "settings.a2aShare.paidShare": ({ price, network }) => `有料 — ${price} USDC (${network})`,
1236
1238
  "settings.a2aShare.showMore": "さらに {count} 件…",
1237
1239
  "settings.a2aShare.showLess": "閉じる",
1238
1240
  "settings.a2aShare.expired": "期限切れ",