viveworker 0.6.0 → 0.7.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,6 +181,28 @@ Typical commands:
181
181
 
182
182
  The current public File Share surface is focused on private static artefact delivery from your Mac and your agents.
183
183
 
184
+ ## Experimental: Deliverable Unlock Flow
185
+
186
+ `viveworker` is also experimenting with a testnet-only unlock flow for agent deliverables.
187
+
188
+ The idea is simple:
189
+
190
+ - an agent receives a task
191
+ - the work runs locally
192
+ - the result is handed back through File Share
193
+ - the requester unlocks the deliverable on testnet
194
+
195
+ This is not meant as a generic "payments feature."
196
+ The interesting part is the agent workflow: request, delivery, handoff, and unlock stay cleanly separated.
197
+
198
+ Current status:
199
+
200
+ - experimental
201
+ - testnet only
202
+ - feedback wanted from people already building agent-to-agent workflows
203
+
204
+ The goal is to learn whether deliverable unlock feels like a natural settlement point for real agent tasks.
205
+
184
206
  ## Claude Desktop Integration
185
207
 
186
208
  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.0",
3
+ "version": "0.7.0-beta.0",
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>] [--json]
10
+ * viveworker share upload <file> [--password <pw>] [--price <usd> --pay-to <0x…>] [--expires-days <n>] [--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,18 +77,26 @@ 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>] [--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>] [--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("");
80
86
  console.log(`Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}`);
81
87
  console.log("CSV files are rendered as an HTML table on view; append ?raw=1 for bytes.");
82
88
  console.log("");
89
+ console.log("Paid shares (x402 / USDC on Base — CLOSED BETA, testnet only): --price 0.10 --pay-to 0x…");
90
+ console.log(" Buyers use any x402-compatible client (e.g. `x402-fetch` on npm).");
91
+ console.log(" --price and --password are mutually exclusive on a single share.");
92
+ console.log(" `share list --metrics` prints 24h / 7d payment-flow stats for your shares.");
93
+ console.log("");
83
94
  console.log("Credentials are read from ~/.viveworker/a2a.env (same as `viveworker a2a`).");
84
95
  }
85
96
 
97
+ const PRICE_REGEX = /^\d+(\.\d{1,6})?$/;
98
+ const ETH_ADDR_REGEX = /^0x[0-9a-fA-F]{40}$/;
99
+
86
100
  // ---------------------------------------------------------------------------
87
101
  // upload
88
102
  // ---------------------------------------------------------------------------
@@ -121,6 +135,8 @@ async function handleUpload(args) {
121
135
 
122
136
  const password = flags["password"] || "";
123
137
  const expiresDays = flags["expires-days"] || flags["expiresDays"] || "";
138
+ const price = flags["price"] || "";
139
+ const payTo = flags["pay-to"] || flags["payTo"] || "";
124
140
 
125
141
  if (password && password.length > 256) {
126
142
  throw new Error("Password too long (max 256 chars)");
@@ -132,6 +148,24 @@ async function handleUpload(args) {
132
148
  }
133
149
  }
134
150
 
151
+ // --price / --pay-to are both required or both absent.
152
+ const anyPrice = Boolean(price);
153
+ const anyPayTo = Boolean(payTo);
154
+ if (anyPrice !== anyPayTo) {
155
+ throw new Error("--price and --pay-to must be supplied together");
156
+ }
157
+ if (anyPrice) {
158
+ if (!PRICE_REGEX.test(price)) {
159
+ throw new Error("--price must be a decimal with ≤6 fractional digits (e.g. `0.10`)");
160
+ }
161
+ if (!ETH_ADDR_REGEX.test(payTo)) {
162
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
163
+ }
164
+ if (password) {
165
+ throw new Error("--price and --password cannot be combined on a single share");
166
+ }
167
+ }
168
+
135
169
  const { apiKey, userId, shareUrl } = await resolveCredentials();
136
170
 
137
171
  const bytes = await fs.readFile(absolute);
@@ -141,6 +175,10 @@ async function handleUpload(args) {
141
175
  form.set("file", file);
142
176
  if (password) form.set("password", password);
143
177
  if (expiresDays) form.set("expiresDays", String(expiresDays));
178
+ if (anyPrice) {
179
+ form.set("price", price);
180
+ form.set("payTo", payTo);
181
+ }
144
182
 
145
183
  const res = await fetchWithTimeout(`${shareUrl}/api/upload`, {
146
184
  method: "POST",
@@ -167,8 +205,11 @@ async function handleUpload(args) {
167
205
  console.log(` ${body.url}`);
168
206
  console.log("");
169
207
  if (body.hasPassword) console.log(` 🔒 Password-protected`);
208
+ if (body.price && body.payTo) {
209
+ console.log(` 💰 Paid — ${formatUsdc(body.price)} USDC on ${body.network || "?"} → ${body.payTo}`);
210
+ }
170
211
  if (body.expiresAtMs) console.log(` ⏱ Expires ${new Date(body.expiresAtMs).toISOString()}`);
171
- if (body.hasPassword || body.expiresAtMs) console.log("");
212
+ if (body.hasPassword || body.price || body.expiresAtMs) console.log("");
172
213
  console.log(` Slug: ${body.slug}`);
173
214
  console.log(` Delete: viveworker share delete ${body.slug}`);
174
215
  if (body.quota) {
@@ -185,25 +226,51 @@ async function handleUpload(args) {
185
226
 
186
227
  async function handleList(args) {
187
228
  const flags = parseFlags(args);
229
+ const wantsMetrics = Boolean(flags.metrics);
188
230
  const { apiKey, userId, shareUrl } = await resolveCredentials();
189
231
 
190
- const res = await fetchWithTimeout(`${shareUrl}/api/list`, {
232
+ // Fire both requests in parallel when --metrics is set; the file list is
233
+ // authoritative, the metrics bucket is best-effort (it 501s when the
234
+ // worker's CF_ACCOUNT_ID / CF_API_TOKEN secrets aren't configured).
235
+ const listReq = fetchWithTimeout(`${shareUrl}/api/list`, {
191
236
  method: "GET",
192
- headers: {
193
- "x-a2a-user": userId,
194
- "x-a2a-key": apiKey,
195
- },
237
+ headers: { "x-a2a-user": userId, "x-a2a-key": apiKey },
196
238
  }, 30_000);
197
-
239
+ const metricsReq = wantsMetrics
240
+ ? fetchWithTimeout(`${shareUrl}/api/metrics`, {
241
+ method: "GET",
242
+ headers: { "x-a2a-user": userId, "x-a2a-key": apiKey },
243
+ }, 30_000).catch((err) => ({ _netErr: String(err?.message || err) }))
244
+ : null;
245
+
246
+ const res = await listReq;
198
247
  const body = await readJson(res);
199
248
  if (!res.ok || body.error) {
200
249
  throw new Error(formatApiError("List", res.status, body));
201
250
  }
202
251
 
252
+ let metricsBody = null;
253
+ let metricsErr = null;
254
+ if (metricsReq) {
255
+ const mRes = await metricsReq;
256
+ if (mRes && mRes._netErr) {
257
+ metricsErr = `metrics fetch failed: ${mRes._netErr}`;
258
+ } else {
259
+ metricsBody = await readJson(mRes);
260
+ if (!mRes.ok || metricsBody?.error) {
261
+ metricsErr = formatApiError("Metrics", mRes.status, metricsBody);
262
+ metricsBody = null;
263
+ }
264
+ }
265
+ }
266
+
203
267
  const items = body.items || [];
204
268
 
205
269
  if (flags.json) {
206
- console.log(JSON.stringify(body, null, 2));
270
+ const combined = wantsMetrics
271
+ ? { ...body, metrics: metricsBody, metricsError: metricsErr }
272
+ : body;
273
+ console.log(JSON.stringify(combined, null, 2));
207
274
  return;
208
275
  }
209
276
 
@@ -215,6 +282,7 @@ async function handleList(args) {
215
282
  } else {
216
283
  console.log("(no uploads yet)");
217
284
  }
285
+ if (wantsMetrics) printMetricsSection(metricsBody, metricsErr);
218
286
  return;
219
287
  }
220
288
 
@@ -226,13 +294,77 @@ async function handleList(args) {
226
294
  for (const item of items) {
227
295
  const age = formatRelative(now - (item.createdAtMs || now));
228
296
  const size = formatSize(item.size || 0);
229
- const lockIcon = item.hasPassword ? "🔒" : " ";
297
+ const gateIcon = item.hasPassword
298
+ ? "🔒"
299
+ : item.price
300
+ ? "💰"
301
+ : " ";
230
302
  const expiry = item.expiresAtMs ? ` · exp ${new Date(item.expiresAtMs).toISOString().slice(0, 10)}` : "";
231
- console.log(` ${lockIcon} ${item.slug} ${size.padStart(8)} ${age.padStart(10)}${expiry}`);
303
+ console.log(` ${gateIcon} ${item.slug} ${size.padStart(8)} ${age.padStart(10)}${expiry}`);
232
304
  console.log(` ${item.url}`);
233
305
  if (item.originalName) console.log(` ${item.originalName}`);
306
+ if (item.price && item.payTo) {
307
+ console.log(` 💰 ${formatUsdc(item.price)} USDC on ${item.network || "?"} → ${item.payTo}`);
308
+ }
309
+ console.log("");
310
+ }
311
+
312
+ if (wantsMetrics) printMetricsSection(metricsBody, metricsErr);
313
+ }
314
+
315
+ // Pretty-prints the Analytics Engine summary returned by GET /api/metrics.
316
+ // Kept inline with handleList because it's only invoked from there; lifting it
317
+ // into its own subcommand would duplicate auth + formatters for no real gain.
318
+ function printMetricsSection(metrics, err) {
319
+ console.log("Paid-share metrics");
320
+ if (err) {
321
+ console.log(` ⚠ ${err}`);
322
+ console.log("");
323
+ return;
324
+ }
325
+ if (!metrics) {
326
+ console.log(" ⚠ no data returned from /api/metrics");
234
327
  console.log("");
328
+ return;
235
329
  }
330
+ const networkLabel = metrics.network ? ` on ${metrics.network}` : "";
331
+ console.log(` (x402 closed beta${networkLabel})`);
332
+ console.log("");
333
+ const labels = {
334
+ upload_paid: "paid shares uploaded",
335
+ "402_served": "402 responses served (GET)",
336
+ "402_served_head": "402 responses served (HEAD)",
337
+ paid_view: "verified paid views",
338
+ paid_cookie_hit: "paid session reloads",
339
+ verify_failed: "verification rejections",
340
+ facilitator_unavailable: "facilitator unreachable",
341
+ settle_failed: "async settle failures",
342
+ };
343
+ const pad = Math.max(...Object.values(labels).map((s) => s.length));
344
+ const row = (label, d, w) => {
345
+ const padded = label.padEnd(pad);
346
+ const dStr = String(d).padStart(6);
347
+ const wStr = String(w).padStart(6);
348
+ console.log(` ${padded} 24h ${dStr} · 7d ${wStr}`);
349
+ };
350
+ for (const [k, label] of Object.entries(labels)) {
351
+ row(label, metrics.last24h?.[k] ?? 0, metrics.last7d?.[k] ?? 0);
352
+ }
353
+ const perSlug = Array.isArray(metrics.perSlug7d) ? metrics.perSlug7d : [];
354
+ if (perSlug.length > 0) {
355
+ console.log("");
356
+ console.log(` Per-share (last 7d, top ${Math.min(5, perSlug.length)}):`);
357
+ for (const entry of perSlug.slice(0, 5)) {
358
+ const c = entry.counts || {};
359
+ const views = (c.paid_view || 0) + (c.paid_cookie_hit || 0);
360
+ const fails = (c.verify_failed || 0) + (c.facilitator_unavailable || 0) + (c.settle_failed || 0);
361
+ const fourOhTwos = (c["402_served"] || 0) + (c["402_served_head"] || 0);
362
+ console.log(
363
+ ` ${entry.slug} views=${views} 402=${fourOhTwos} fails=${fails}`
364
+ );
365
+ }
366
+ }
367
+ console.log("");
236
368
  }
237
369
 
238
370
  // ---------------------------------------------------------------------------
@@ -244,7 +376,7 @@ async function handleUpdate(args) {
244
376
  const slug = flags._[0];
245
377
  if (!slug) {
246
378
  throw new Error(
247
- "Usage: viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>]"
379
+ "Usage: viveworker share update <slug> [--password <pw>] [--no-password] [--price <usd>] [--no-price] [--pay-to <0x…>] [--expires-days <n>]"
248
380
  );
249
381
  }
250
382
  if (!/^[A-Za-z0-9]+$/.test(slug)) {
@@ -255,13 +387,24 @@ async function handleUpdate(args) {
255
387
  const hasNoPassword = Object.prototype.hasOwnProperty.call(flags, "no-password");
256
388
  const hasExpires = Object.prototype.hasOwnProperty.call(flags, "expires-days") ||
257
389
  Object.prototype.hasOwnProperty.call(flags, "expiresDays");
390
+ const hasPrice = Object.prototype.hasOwnProperty.call(flags, "price");
391
+ const hasNoPrice = Object.prototype.hasOwnProperty.call(flags, "no-price") ||
392
+ Object.prototype.hasOwnProperty.call(flags, "noPrice");
393
+ const hasPayTo = Object.prototype.hasOwnProperty.call(flags, "pay-to") ||
394
+ Object.prototype.hasOwnProperty.call(flags, "payTo");
258
395
 
259
396
  if (hasPassword && hasNoPassword) {
260
397
  throw new Error("Pass either --password OR --no-password, not both");
261
398
  }
262
- if (!hasPassword && !hasNoPassword && !hasExpires) {
399
+ if (hasPrice && hasNoPrice) {
400
+ throw new Error("Pass either --price OR --no-price, not both");
401
+ }
402
+ if (hasPrice && hasPassword) {
403
+ throw new Error("--price and --password cannot be combined on a single share");
404
+ }
405
+ if (!hasPassword && !hasNoPassword && !hasExpires && !hasPrice && !hasNoPrice && !hasPayTo) {
263
406
  throw new Error(
264
- "Nothing to update — specify at least one of --password <pw>, --no-password, --expires-days <n>"
407
+ "Nothing to update — specify at least one of --password <pw>, --no-password, --price <usd>, --no-price, --pay-to <0x…>, --expires-days <n>"
265
408
  );
266
409
  }
267
410
 
@@ -280,6 +423,42 @@ async function handleUpdate(args) {
280
423
  body.password = null;
281
424
  }
282
425
 
426
+ if (hasPrice) {
427
+ const pv = flags.price;
428
+ if (typeof pv !== "string" || pv.length === 0) {
429
+ throw new Error("--price requires a value (use --no-price to clear)");
430
+ }
431
+ if (!PRICE_REGEX.test(pv)) {
432
+ throw new Error("--price must be a decimal with ≤6 fractional digits (e.g. `0.10`)");
433
+ }
434
+ body.price = pv;
435
+ // --pay-to is only required on first set; the worker rejects with
436
+ // `payTo-required-on-first-price` if the share had no payTo before, so
437
+ // let it do the policy enforcement. But if the user passed --pay-to
438
+ // alongside --price, thread it through now.
439
+ if (hasPayTo) {
440
+ const addr = flags["pay-to"] || flags["payTo"];
441
+ if (typeof addr !== "string" || !ETH_ADDR_REGEX.test(addr)) {
442
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
443
+ }
444
+ body.payTo = addr;
445
+ }
446
+ } else if (hasNoPrice) {
447
+ body.price = null;
448
+ if (hasPayTo) {
449
+ throw new Error("--no-price clears both price and payTo; drop --pay-to when removing the gate");
450
+ }
451
+ } else if (hasPayTo) {
452
+ // Change recipient only — does not rotate paymentSalt; existing paid
453
+ // sessions keep working. Worker rejects this when the share has no
454
+ // price gate attached.
455
+ const addr = flags["pay-to"] || flags["payTo"];
456
+ if (typeof addr !== "string" || !ETH_ADDR_REGEX.test(addr)) {
457
+ throw new Error("--pay-to must be a 0x-prefixed 40-hex-char EVM address");
458
+ }
459
+ body.payTo = addr;
460
+ }
461
+
283
462
  if (hasExpires) {
284
463
  const raw = flags["expires-days"] || flags["expiresDays"];
285
464
  const n = Number(raw);
@@ -321,6 +500,14 @@ async function handleUpdate(args) {
321
500
  } else if (hasNoPassword) {
322
501
  console.log(` 🔓 Password removed`);
323
502
  }
503
+ if (respBody.price && respBody.payTo) {
504
+ const rotated = hasPrice ? " (paid sessions invalidated)" : "";
505
+ console.log(
506
+ ` 💰 Paid — ${formatUsdc(respBody.price)} USDC on ${respBody.network || "?"} → ${respBody.payTo}${rotated}`
507
+ );
508
+ } else if (hasNoPrice) {
509
+ console.log(` 💸 Payment gate removed`);
510
+ }
324
511
  if (respBody.expiresAtMs) {
325
512
  console.log(` ⏱ Expires ${new Date(respBody.expiresAtMs).toISOString()}`);
326
513
  }
@@ -466,6 +653,42 @@ function formatApiError(op, status, body) {
466
653
  return `${op} failed (${status}): share has no password — no link token needed, just share the URL directly`;
467
654
  case "invalid-ttlHours":
468
655
  return `${op} failed (${status}): --ttl-hours must be between 1 and ${body.maxHours || 168}`;
656
+ // x402 / paid-share error codes — thrown by the worker on upload,
657
+ // PATCH, or view. Keep the messages actionable; agents read these
658
+ // back to the user verbatim.
659
+ case "invalid-price":
660
+ return `${op} failed (${status}): --price must be a decimal with ≤6 fractional digits (e.g. \`0.10\`)`;
661
+ case "price-out-of-range":
662
+ return `${op} failed (${status}): price must be between $${formatUsdc(body.minAtomic || "10000")} and $${formatUsdc(body.maxAtomic || "1000000000")}`;
663
+ case "invalid-payTo":
664
+ return `${op} failed (${status}): --pay-to must be a 0x-prefixed 40-hex-char EVM address`;
665
+ case "price-payTo-both-required":
666
+ return `${op} failed (${status}): both --price and --pay-to must be supplied together`;
667
+ case "price-and-password-mutually-exclusive":
668
+ return `${op} failed (${status}): --price and --password cannot be combined on a single share`;
669
+ case "payTo-without-price":
670
+ return `${op} failed (${status}): cannot set --pay-to on a free share — use --price together with --pay-to to add the gate first`;
671
+ case "payTo-cannot-be-cleared-alone":
672
+ return `${op} failed (${status}): --no-price clears both price and payTo; clearing payTo alone is not supported`;
673
+ case "payment-network-not-configured":
674
+ return `${op} failed (${status}): the worker's X402_NETWORK var is not set to a supported chain (base / base-sepolia)`;
675
+ case "payment-required":
676
+ return `${op} failed (${status}): this share requires payment (x402). Use an x402-compatible client (e.g. \`x402-fetch\` on npm) to pay.`;
677
+ case "payment-verification-failed":
678
+ case "malformed-x-payment":
679
+ return `${op} failed (${status}): payment verification failed — the facilitator rejected the X-PAYMENT header`;
680
+ case "facilitator-unavailable":
681
+ return `${op} failed (${status}): x402 facilitator is unreachable — try again shortly`;
682
+ case "paid-shares-closed-beta": {
683
+ // Closed-beta gate on --price / --pay-to uploads and PATCHes while x402
684
+ // is pinned to testnet. The hint + network come from the worker so the
685
+ // message stays accurate even if we flip to mainnet.
686
+ const net = body?.network ? ` (network: ${body.network})` : "";
687
+ const hint = body?.hint ? `\n hint: ${body.hint}` : "";
688
+ return `${op} failed (${status}): paid shares are in closed beta${net}.${hint}`;
689
+ }
690
+ case "metrics-not-configured":
691
+ 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.`;
469
692
  default:
470
693
  return `${op} failed (${status}): ${code || body?.statusText || "unknown error"}`;
471
694
  }
@@ -561,6 +784,26 @@ function formatSize(bytes) {
561
784
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
562
785
  }
563
786
 
787
+ // USDC uses 6-decimals. `atomic` is a stringified integer ("100000" = $0.10).
788
+ // Returns a human-facing decimal with at least two fractional digits and
789
+ // trailing zeros beyond that trimmed (so "1000000" → "1.00", "123456" →
790
+ // "0.123456", "12" → "0.000012"). Accepts either strings or bigints; falls
791
+ // back to "0.00" on anything unparseable so we never crash the CLI output
792
+ // on a malformed server response.
793
+ function formatUsdc(atomic) {
794
+ let n;
795
+ try {
796
+ n = BigInt(String(atomic ?? "0"));
797
+ } catch {
798
+ return "0.00";
799
+ }
800
+ if (n < 0n) n = -n;
801
+ const whole = n / 1_000_000n;
802
+ const frac = (n % 1_000_000n).toString().padStart(6, "0").replace(/0+$/u, "");
803
+ if (!frac) return `${whole}.00`;
804
+ return `${whole}.${frac.padEnd(2, "0")}`;
805
+ }
806
+
564
807
  function formatRelative(ms) {
565
808
  const sec = Math.floor(ms / 1000);
566
809
  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": "期限切れ",