viveworker 0.5.1 → 0.5.3

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
@@ -219,7 +219,7 @@ Open `Settings > Moltbook` in the phone app to see the current auto-scout postin
219
219
 
220
220
  - **Receive tasks from other agents** worldwide via standard A2A JSON-RPC
221
221
  - **Human-in-the-loop**: every incoming task requires your approval on your phone before execution
222
- - **Public Agent Card**: your profile at `https://a2a.viveworker.com/<user-id>` tells other agents what you can do
222
+ - **Public Agent Card**: your profile at `https://a2a.viveworker.com/u/<user-id>` tells other agents what you can do
223
223
  - **Customizable profile**: description, skills, and avatar are all configurable
224
224
 
225
225
  ### How it works
@@ -250,7 +250,7 @@ The bridge detects the new credentials within 30 seconds and auto-connects.
250
250
 
251
251
  ### Profile page
252
252
 
253
- Visit `https://a2a.viveworker.com/<user-id>` in a browser to see your profile, or request it with `Accept: application/json` to get the Agent Card JSON.
253
+ Visit `https://a2a.viveworker.com/u/<user-id>` in a browser to see your profile, or request it with `Accept: application/json` to get the Agent Card JSON.
254
254
 
255
255
  ## Security Model
256
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Local mobile companion for Codex Desktop and Claude Desktop — approvals, code review, Moltbook drafts, and A2A (Agent-to-Agent) task relay on your LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -153,7 +153,7 @@ async function handleSetup(args) {
153
153
 
154
154
  console.log(`✅ Credentials saved\n`);
155
155
  console.log(`🚀 Setup complete! Restart your viveworker bridge to connect.`);
156
- console.log(` Your A2A endpoint: ${result.relayUrl}/${result.userId}\n`);
156
+ console.log(` Your A2A endpoint: ${result.relayUrl}/u/${result.userId}\n`);
157
157
  }
158
158
 
159
159
  // ---------------------------------------------------------------------------
@@ -39,22 +39,56 @@ export function createMoltbookClient(apiKey) {
39
39
  throw new Error("MOLTBOOK_API_KEY is required");
40
40
  }
41
41
  return async function mb(pathname, init = {}) {
42
- const res = await fetch(`${API_BASE}${pathname}`, {
43
- ...init,
44
- headers: {
45
- "content-type": "application/json",
46
- authorization: `Bearer ${apiKey}`,
47
- ...(init.headers || {}),
48
- },
49
- });
50
- const text = await res.text().catch(() => "");
51
- if (!res.ok) {
52
- throw new Error(`moltbook ${res.status} ${pathname}: ${text}`);
53
- }
42
+ const { timeoutMs: overrideTimeoutMs, ...fetchInit } = init;
43
+ const method = String(fetchInit.method || "GET").toUpperCase();
44
+ const isWrite = method !== "GET" && method !== "HEAD";
45
+ // Reads get 30s, writes 60s. A flaky upstream must not be able to hang
46
+ // the process indefinitely — this exact failure mode took out the
47
+ // moltbook-watcher in mid-April 2026 when /notifications started
48
+ // returning 500s and later stopped responding entirely. Bare fetch has
49
+ // no default timeout and the undici connection pool saturated, leaving
50
+ // the process alive but unable to log, poll, or push anything.
51
+ // Writes get a longer budget because POST /posts and /comments do
52
+ // synchronous spam-screening on the server side.
53
+ const timeoutMs = typeof overrideTimeoutMs === "number"
54
+ ? overrideTimeoutMs
55
+ : (isWrite ? 60_000 : 30_000);
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
58
  try {
55
- return JSON.parse(text);
56
- } catch {
57
- return text;
59
+ const res = await fetch(`${API_BASE}${pathname}`, {
60
+ ...fetchInit,
61
+ signal: controller.signal,
62
+ headers: {
63
+ "content-type": "application/json",
64
+ authorization: `Bearer ${apiKey}`,
65
+ ...(fetchInit.headers || {}),
66
+ },
67
+ });
68
+ const text = await res.text().catch(() => "");
69
+ if (controller.signal.aborted) {
70
+ const err = new Error("aborted");
71
+ err.name = "AbortError";
72
+ throw err;
73
+ }
74
+ if (!res.ok) {
75
+ throw new Error(`moltbook ${res.status} ${pathname}: ${text}`);
76
+ }
77
+ try {
78
+ return JSON.parse(text);
79
+ } catch {
80
+ return text;
81
+ }
82
+ } catch (error) {
83
+ if (error?.name === "AbortError") {
84
+ const hint = isWrite
85
+ ? " — may have succeeded server-side; run `moltbook reconcile` before retrying"
86
+ : "";
87
+ throw new Error(`moltbook timeout after ${timeoutMs}ms ${method} ${pathname}${hint}`);
88
+ }
89
+ throw error;
90
+ } finally {
91
+ clearTimeout(timer);
58
92
  }
59
93
  };
60
94
  }
@@ -173,7 +207,13 @@ export function rollScoutDayIfNeeded(state) {
173
207
  }
174
208
 
175
209
  export function recordComposeAttempt(state, title, postId, type = "post") {
176
- state.composedToday = (state.composedToday || 0) + 1;
210
+ // Only original posts count against the daily "本日の新規投稿数" quota
211
+ // (composedToday). Replies are still appended to recentComposeTitles so
212
+ // the "最近の投稿" list in settings shows them with their reply badge,
213
+ // but they intentionally do not inflate the counter.
214
+ if (type === "post") {
215
+ state.composedToday = (state.composedToday || 0) + 1;
216
+ }
177
217
  state.lastComposeDay = todayKey();
178
218
  if (!Array.isArray(state.recentComposeTitles)) state.recentComposeTitles = [];
179
219
  const entry = { title: String(title || ""), type };
@@ -71,18 +71,37 @@ try {
71
71
  const pending = new Map(); // sourceId -> { commentId, postId }
72
72
 
73
73
  async function pushToBridge(item) {
74
- const res = await fetch(`${VIVEWORKER_BASE}/api/providers/moltbook/events`, {
75
- method: "POST",
76
- headers: {
77
- "content-type": "application/json",
78
- "x-viveworker-hook-secret": HOOK_SECRET,
79
- },
80
- body: JSON.stringify(item),
81
- });
82
- if (!res.ok) {
83
- throw new Error(`bridge ${res.status}: ${await res.text().catch(() => "")}`);
74
+ // Loopback HTTPS call to the viveworker bridge. Normally completes in
75
+ // milliseconds, but if the bridge process is hung this fetch — like any
76
+ // unbounded fetch — would wait forever and eventually zombify the watcher
77
+ // (same failure mode that took out the Moltbook poll path in April 2026).
78
+ // 10s is a generous cap for a local call; anything slower means the bridge
79
+ // is already unhealthy and we're better off erroring out and retrying next
80
+ // poll cycle than blocking the whole watcher on it.
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), 10_000);
83
+ try {
84
+ const res = await fetch(`${VIVEWORKER_BASE}/api/providers/moltbook/events`, {
85
+ method: "POST",
86
+ headers: {
87
+ "content-type": "application/json",
88
+ "x-viveworker-hook-secret": HOOK_SECRET,
89
+ },
90
+ body: JSON.stringify(item),
91
+ signal: controller.signal,
92
+ });
93
+ if (!res.ok) {
94
+ throw new Error(`bridge ${res.status}: ${await res.text().catch(() => "")}`);
95
+ }
96
+ return await res.json();
97
+ } catch (error) {
98
+ if (error?.name === "AbortError") {
99
+ throw new Error(`bridge timeout after 10000ms POST /api/providers/moltbook/events`);
100
+ }
101
+ throw error;
102
+ } finally {
103
+ clearTimeout(timer);
84
104
  }
85
- return res.json();
86
105
  }
87
106
 
88
107
  function draftReply() {
@@ -18,7 +18,7 @@ import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "
18
18
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
19
19
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
20
20
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
21
- import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM } from "./moltbook-api.mjs";
21
+ import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, listInboxItems } from "./moltbook-api.mjs";
22
22
 
23
23
  const __filename = fileURLToPath(import.meta.url);
24
24
  const __dirname = path.dirname(__filename);
@@ -95,6 +95,11 @@ const runtime = {
95
95
  pairingAttemptsByRemoteAddress: new Map(),
96
96
  ipcClient: null,
97
97
  stopping: false,
98
+ // In-memory cache for the `/api/share/status` endpoint. A 30s TTL avoids
99
+ // hammering the share worker on every settings-page render. Purely runtime —
100
+ // deliberately NOT persisted via `state` since the cache is worthless after
101
+ // a restart and would just bloat the state file.
102
+ a2aShareStatusCache: null,
98
103
  };
99
104
  const state = await loadState(config.stateFile);
100
105
  runtime.threadRegistry = await loadThreadRegistry(config.threadRegistryFile);
@@ -158,6 +163,7 @@ state.recentHistoryItems = initialHistoryItems;
158
163
  state.recentTimelineEntries = initialTimelineEntries;
159
164
  const migratedRecentCodeEventsStateChanged = migrateRecentCodeEventsState({ config, runtime, state });
160
165
  const restoredTimelineImagePathsStateChanged = await backfillPersistedTimelineImagePaths({ config, runtime, state });
166
+ const backfilledMoltbookInboxChanged = await backfillMoltbookInboxHistory({ config, runtime, state });
161
167
  runtime.historyFileState.offset = Number(state.historyFileOffset) || 0;
162
168
  runtime.historyFileState.sourceFile = cleanText(state.historyFileSourceFile ?? "");
163
169
 
@@ -269,6 +275,9 @@ function buildSessionLocalePayload(config, state, deviceId) {
269
275
  moltbookEnabled: Boolean(config.moltbookApiKey),
270
276
  a2aEnabled: Boolean(config.a2aApiKey),
271
277
  a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
278
+ // Share piggy-backs on A2A credentials — it's "enabled" as soon as both
279
+ // halves (user id + API key) are provisioned.
280
+ a2aShareEnabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
272
281
  a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
273
282
  a2aExecutorPreference: state.a2aExecutorPreference || "ask",
274
283
  };
@@ -2549,6 +2558,26 @@ function recordHistoryItem({ config, runtime, state, item }) {
2549
2558
  return changed;
2550
2559
  }
2551
2560
 
2561
+ // Flip a previously-recorded history item's readOnly flag without disturbing
2562
+ // its ordering or other fields. Used by moltbook_reply / moltbook_draft flows
2563
+ // to transition pending → resolved so the entry starts matching the completed
2564
+ // tab's readOnly filter. Returns true if a mutation happened (caller should
2565
+ // saveState in that case).
2566
+ function flipHistoryItemReadOnly({ runtime, state, stableId, readOnly = true }) {
2567
+ const normalizedStableId = cleanText(stableId || "");
2568
+ if (!normalizedStableId) return false;
2569
+ const idx = runtime.recentHistoryItems.findIndex((entry) => entry.stableId === normalizedStableId);
2570
+ if (idx === -1) return false;
2571
+ const current = runtime.recentHistoryItems[idx];
2572
+ if (current.readOnly === readOnly) return false;
2573
+ const next = { ...current, readOnly };
2574
+ const nextItems = runtime.recentHistoryItems.slice();
2575
+ nextItems[idx] = next;
2576
+ runtime.recentHistoryItems = nextItems;
2577
+ state.recentHistoryItems = nextItems;
2578
+ return true;
2579
+ }
2580
+
2552
2581
  function recordActionHistoryItem({
2553
2582
  config,
2554
2583
  runtime,
@@ -3760,6 +3789,66 @@ async function backfillPersistedTimelineImagePaths({ config, runtime, state }) {
3760
3789
  return true;
3761
3790
  }
3762
3791
 
3792
+ // Walk ~/.viveworker/moltbook-inbox/ on startup and synthesize readOnly
3793
+ // history entries for items the CLI / reconcile marked as replied or skipped.
3794
+ // Without this, an inbox item that was handled before the history hooks
3795
+ // existed — or that was resolved purely via the CLI path, which doesn't touch
3796
+ // the bridge's in-memory maps — never makes it into `recentHistoryItems` and
3797
+ // so never appears in the completed tab. We match the same stableId / token
3798
+ // scheme as the live push (sourceId = `comment:<commentId>`) so a future
3799
+ // watcher push for the same commentId replaces this entry cleanly rather than
3800
+ // creating a duplicate.
3801
+ async function backfillMoltbookInboxHistory({ config, runtime, state }) {
3802
+ let changed = false;
3803
+ try {
3804
+ const inboxItems = await listInboxItems();
3805
+ for (const inbox of inboxItems) {
3806
+ const status = String(inbox?.status || "").toLowerCase();
3807
+ if (status !== "replied" && status !== "skipped") continue;
3808
+ const commentId = String(inbox?.commentId || "");
3809
+ if (!commentId) continue;
3810
+ const sourceId = `comment:${commentId}`;
3811
+ const stableId = `moltbook_reply:${sourceId}`;
3812
+ if (runtime.recentHistoryItems.some((entry) => entry.stableId === stableId)) continue;
3813
+ const token = historyToken(`moltbook:${sourceId}`);
3814
+ const createdAtMs = Number(Date.parse(inbox.updatedAt || inbox.createdAt || "")) || Date.now();
3815
+ const postTitle = cleanText(inbox.postTitle || "");
3816
+ const contextText = cleanText(inbox.contextText || "");
3817
+ const replyText = cleanText(inbox.replyText || "");
3818
+ const messageText = status === "replied" ? (replyText || contextText) : contextText;
3819
+ const summary = (status === "replied" ? (replyText || contextText) : contextText).slice(0, 160);
3820
+ const titlePrefix = status === "replied" ? "replied" : "skipped";
3821
+ const title = postTitle ? `${titlePrefix} → ${postTitle}` : (status === "replied" ? "Moltbook reply" : "Moltbook skipped");
3822
+ const recorded = recordHistoryItem({
3823
+ config,
3824
+ runtime,
3825
+ state,
3826
+ item: {
3827
+ stableId,
3828
+ token,
3829
+ kind: "moltbook_reply",
3830
+ threadId: "moltbook",
3831
+ threadLabel: postTitle || "Moltbook",
3832
+ title,
3833
+ summary,
3834
+ messageText,
3835
+ createdAtMs,
3836
+ readOnly: true,
3837
+ provider: "moltbook",
3838
+ },
3839
+ });
3840
+ if (recorded) changed = true;
3841
+ }
3842
+ } catch (error) {
3843
+ console.error(`[moltbook-inbox-backfill] ${error.message}`);
3844
+ return false;
3845
+ }
3846
+ if (changed) {
3847
+ console.log(`[moltbook-inbox-backfill] Synthesized history entries for resolved inbox items`);
3848
+ }
3849
+ return changed;
3850
+ }
3851
+
3763
3852
  async function backfillRecentTimelineEntryDiffs({ config, runtime, state }) {
3764
3853
  const nextEntries = runtime.recentTimelineEntries.map((entry) => ({ ...entry }));
3765
3854
  let changed = false;
@@ -9266,8 +9355,11 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9266
9355
  .filter((item) => {
9267
9356
  const k = cleanText(item?.kind || "");
9268
9357
  if (!completedKinds.has(k)) return false;
9269
- // Only resolved approvals (readOnly = true)
9270
- if (k === "approval" && !item.readOnly) return false;
9358
+ // Only resolved approvals (readOnly = true). Moltbook reply/draft items
9359
+ // are gated the same way so pending entries don't double-show as
9360
+ // completed while they're also surfaced via moltbookItemsByToken /
9361
+ // moltbookDraftsByToken on the pending list.
9362
+ if ((k === "approval" || k === "moltbook_reply" || k === "moltbook_draft") && !item.readOnly) return false;
9271
9363
  return true;
9272
9364
  })
9273
9365
  .slice()
@@ -11429,7 +11521,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11429
11521
  return writeJson(res, 200, { enabled: false });
11430
11522
  }
11431
11523
  try {
11432
- const { readScoutState, rollScoutDayIfNeeded } = await import("./moltbook-api.mjs");
11524
+ const { readScoutState, rollScoutDayIfNeeded, createMoltbookClient } = await import("./moltbook-api.mjs");
11433
11525
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
11434
11526
  const batch = scoutState.batch;
11435
11527
  const batchInfo = batch && batch.candidates?.length ? {
@@ -11438,8 +11530,39 @@ function createNativeApprovalServer({ config, runtime, state }) {
11438
11530
  topScore: Math.max(...batch.candidates.map((c) => c.score || 0)),
11439
11531
  remainingSeconds: Math.max(0, Math.round(((batch.startedAt || 0) + (batch.windowMs || 1800000) - Date.now()) / 1000)),
11440
11532
  } : null;
11533
+
11534
+ // Agent profile — lazy-fetched, cached 1h. Stale cache survives
11535
+ // fetch failures so the UI never goes blank mid-outage.
11536
+ let account = null;
11537
+ try {
11538
+ const PROFILE_TTL_MS = 60 * 60 * 1000;
11539
+ const cached = runtime.moltbookAgentProfile;
11540
+ const nowMs = Date.now();
11541
+ if (!cached || nowMs - (cached.fetchedAtMs || 0) > PROFILE_TTL_MS) {
11542
+ const mb = createMoltbookClient(config.moltbookApiKey);
11543
+ const meRes = await mb("/agents/me");
11544
+ const name = cleanText(meRes?.agent?.name || meRes?.agent?.display_name || "");
11545
+ if (name) {
11546
+ runtime.moltbookAgentProfile = {
11547
+ name,
11548
+ profileUrl: `https://www.moltbook.com/u/${encodeURIComponent(name)}`,
11549
+ fetchedAtMs: nowMs,
11550
+ };
11551
+ }
11552
+ }
11553
+ } catch {
11554
+ // Fall through — if fetch fails, use whatever's cached (possibly null).
11555
+ }
11556
+ if (runtime.moltbookAgentProfile) {
11557
+ account = {
11558
+ name: runtime.moltbookAgentProfile.name,
11559
+ profileUrl: runtime.moltbookAgentProfile.profileUrl,
11560
+ };
11561
+ }
11562
+
11441
11563
  return writeJson(res, 200, {
11442
11564
  enabled: true,
11565
+ account,
11443
11566
  day: scoutState.day,
11444
11567
  sentToday: scoutState.sentToday,
11445
11568
  maxDaily: 5,
@@ -11478,6 +11601,82 @@ function createNativeApprovalServer({ config, runtime, state }) {
11478
11601
  });
11479
11602
  }
11480
11603
 
11604
+ // A2A Share status (HTML hosting) for the settings UI. Reuses A2A
11605
+ // credentials; enabled iff both user id + API key are provisioned.
11606
+ // Results from the upstream `/api/list` call are cached for 30s so a
11607
+ // settings-page render doesn't hammer the worker on every refresh tick.
11608
+ if (url.pathname === "/api/share/status" && req.method === "GET") {
11609
+ const session = requireApiSession(req, res, config, state);
11610
+ if (!session) return;
11611
+ const enabled = Boolean(config.a2aRelayUserId && config.a2aApiKey);
11612
+ if (!enabled) {
11613
+ return writeJson(res, 200, { enabled: false });
11614
+ }
11615
+ const shareHost = (() => {
11616
+ try { return new URL(config.a2aShareUrl).host; } catch { return config.a2aShareUrl || ""; }
11617
+ })();
11618
+ // Mirror the constants baked into share-worker/worker.js. If those
11619
+ // change, bump here too — there's no shared module between the two.
11620
+ const limits = {
11621
+ maxFileBytes: 5 * 1024 * 1024,
11622
+ maxTotalBytes: 5 * 1024 * 1024,
11623
+ maxFiles: 10,
11624
+ defaultExpiresDays: 30,
11625
+ maxExpiresDays: 30,
11626
+ uploadRatePerHour: 10,
11627
+ };
11628
+ const cacheKey = `${config.a2aRelayUserId}|${config.a2aShareUrl}`;
11629
+ const nowMs = Date.now();
11630
+ const cached = runtime.a2aShareStatusCache;
11631
+ if (cached && cached.key === cacheKey && nowMs - cached.fetchedAtMs < 30_000) {
11632
+ return writeJson(res, 200, {
11633
+ enabled: true,
11634
+ userId: config.a2aRelayUserId,
11635
+ shareUrl: config.a2aShareUrl,
11636
+ shareHost,
11637
+ limits,
11638
+ ...cached.payload,
11639
+ fetchedAtMs: cached.fetchedAtMs,
11640
+ });
11641
+ }
11642
+ let upstream = { items: [], quota: null, error: null };
11643
+ const controller = new AbortController();
11644
+ const timer = setTimeout(() => controller.abort(), 10_000);
11645
+ try {
11646
+ const resUpstream = await fetch(`${config.a2aShareUrl}/api/list`, {
11647
+ method: "GET",
11648
+ headers: {
11649
+ "x-a2a-user": config.a2aRelayUserId,
11650
+ "x-a2a-key": config.a2aApiKey,
11651
+ },
11652
+ signal: controller.signal,
11653
+ });
11654
+ if (resUpstream.ok) {
11655
+ const body = await resUpstream.json().catch(() => ({}));
11656
+ upstream.items = Array.isArray(body.items) ? body.items : [];
11657
+ upstream.quota = body.quota || null;
11658
+ } else {
11659
+ upstream.error = `HTTP ${resUpstream.status}`;
11660
+ }
11661
+ } catch (error) {
11662
+ upstream.error = error?.name === "AbortError"
11663
+ ? "upstream timeout"
11664
+ : (error?.message || String(error));
11665
+ } finally {
11666
+ clearTimeout(timer);
11667
+ }
11668
+ runtime.a2aShareStatusCache = { key: cacheKey, fetchedAtMs: nowMs, payload: upstream };
11669
+ return writeJson(res, 200, {
11670
+ enabled: true,
11671
+ userId: config.a2aRelayUserId,
11672
+ shareUrl: config.a2aShareUrl,
11673
+ shareHost,
11674
+ limits,
11675
+ ...upstream,
11676
+ fetchedAtMs: nowMs,
11677
+ });
11678
+ }
11679
+
11481
11680
  // Toggle acceptPublicTasks on the A2A relay.
11482
11681
  if (url.pathname === "/api/a2a/public-tasks" && req.method === "POST") {
11483
11682
  const session = requireApiSession(req, res, config, state);
@@ -12247,6 +12446,14 @@ function createNativeApprovalServer({ config, runtime, state }) {
12247
12446
  if (eventType === "resolve") {
12248
12447
  const token = historyToken(`moltbook:${sourceId}`);
12249
12448
  runtime.moltbookItemsByToken.delete(token);
12449
+ // Flip the history item to readOnly so it moves from pending → completed.
12450
+ try {
12451
+ if (flipHistoryItemReadOnly({ runtime, state, stableId: `moltbook_reply:${sourceId}` })) {
12452
+ await saveState(config.stateFile, state);
12453
+ }
12454
+ } catch (error) {
12455
+ console.error(`[moltbook-reply-resolve] ${error.message}`);
12456
+ }
12250
12457
  return writeJson(res, 200, { ok: true });
12251
12458
  }
12252
12459
  const token = historyToken(`moltbook:${sourceId}`);
@@ -12268,24 +12475,21 @@ function createNativeApprovalServer({ config, runtime, state }) {
12268
12475
  };
12269
12476
  runtime.moltbookItemsByToken.set(token, item);
12270
12477
  try {
12271
- recordTimelineEntry({
12272
- config,
12273
- runtime,
12274
- state,
12275
- entry: {
12276
- stableId: `moltbook_reply:${sourceId}`,
12277
- token,
12278
- kind: "moltbook_reply",
12279
- threadId: item.threadId,
12280
- threadLabel: item.threadLabel,
12281
- title: item.title,
12282
- summary: item.summary,
12283
- messageText: item.contextText,
12284
- createdAtMs: item.createdAtMs,
12285
- readOnly: false,
12286
- provider: "moltbook",
12287
- },
12288
- });
12478
+ const historyEntry = {
12479
+ stableId: `moltbook_reply:${sourceId}`,
12480
+ token,
12481
+ kind: "moltbook_reply",
12482
+ threadId: item.threadId,
12483
+ threadLabel: item.threadLabel,
12484
+ title: item.title,
12485
+ summary: item.summary,
12486
+ messageText: item.contextText,
12487
+ createdAtMs: item.createdAtMs,
12488
+ readOnly: false,
12489
+ provider: "moltbook",
12490
+ };
12491
+ recordTimelineEntry({ config, runtime, state, entry: historyEntry });
12492
+ recordHistoryItem({ config, runtime, state, item: historyEntry });
12289
12493
  await saveState(config.stateFile, state);
12290
12494
  } catch (error) {
12291
12495
  console.error(`[moltbook-timeline-save] ${error.message}`);
@@ -12351,6 +12555,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
12351
12555
  }
12352
12556
  item.resolved = true;
12353
12557
  runtime.moltbookItemsByToken.delete(token);
12558
+ try {
12559
+ if (flipHistoryItemReadOnly({ runtime, state, stableId: `moltbook_reply:${item.sourceId}` })) {
12560
+ await saveState(config.stateFile, state);
12561
+ }
12562
+ } catch (error) {
12563
+ console.error(`[moltbook-reply-phone] ${error.message}`);
12564
+ }
12354
12565
  return writeJson(res, 200, { ok: true, action });
12355
12566
  }
12356
12567
 
@@ -12419,36 +12630,33 @@ function createNativeApprovalServer({ config, runtime, state }) {
12419
12630
  runtime.moltbookDraftsByToken.set(token, draft);
12420
12631
  await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist] ${e.message}`));
12421
12632
  try {
12422
- recordTimelineEntry({
12423
- config,
12424
- runtime,
12425
- state,
12426
- entry: {
12427
- stableId: `moltbook_draft:${sourceId}`,
12428
- token,
12429
- kind: "moltbook_draft",
12430
- threadId: "moltbook",
12431
- threadLabel: postTitle || "Moltbook",
12432
- title: draftType === "original_post"
12433
- ? (postTitle || "Moltbook new post")
12434
- : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
12435
- summary: contextSummary || String(draftText).slice(0, 160),
12436
- messageText: contextSummary || draftText,
12437
- draftText,
12438
- draftType,
12439
- submoltName,
12440
- intent,
12441
- slot,
12442
- postId,
12443
- postUrl,
12444
- postTitle,
12445
- postAuthor,
12446
- postBody,
12447
- createdAtMs: draft.createdAtMs,
12448
- readOnly: false,
12449
- provider: "moltbook",
12450
- },
12451
- });
12633
+ const draftEntry = {
12634
+ stableId: `moltbook_draft:${sourceId}`,
12635
+ token,
12636
+ kind: "moltbook_draft",
12637
+ threadId: "moltbook",
12638
+ threadLabel: postTitle || "Moltbook",
12639
+ title: draftType === "original_post"
12640
+ ? (postTitle || "Moltbook new post")
12641
+ : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
12642
+ summary: contextSummary || String(draftText).slice(0, 160),
12643
+ messageText: contextSummary || draftText,
12644
+ draftText,
12645
+ draftType,
12646
+ submoltName,
12647
+ intent,
12648
+ slot,
12649
+ postId,
12650
+ postUrl,
12651
+ postTitle,
12652
+ postAuthor,
12653
+ postBody,
12654
+ createdAtMs: draft.createdAtMs,
12655
+ readOnly: false,
12656
+ provider: "moltbook",
12657
+ };
12658
+ recordTimelineEntry({ config, runtime, state, entry: draftEntry });
12659
+ recordHistoryItem({ config, runtime, state, item: draftEntry });
12452
12660
  await saveState(config.stateFile, state);
12453
12661
  } catch (error) {
12454
12662
  console.error(`[moltbook-draft-timeline-save] ${error.message}`);
@@ -12549,6 +12757,15 @@ function createNativeApprovalServer({ config, runtime, state }) {
12549
12757
  // Persist decision to disk.
12550
12758
  await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist-decision] ${e.message}`));
12551
12759
 
12760
+ // Flip the history entry to readOnly so it moves from pending → completed.
12761
+ try {
12762
+ if (flipHistoryItemReadOnly({ runtime, state, stableId: `moltbook_draft:${draft.sourceId}` })) {
12763
+ await saveState(config.stateFile, state);
12764
+ }
12765
+ } catch (error) {
12766
+ console.error(`[moltbook-draft-decision-flip] ${error.message}`);
12767
+ }
12768
+
12552
12769
  if (action === "approve") {
12553
12770
  // Execute posting asynchronously — fire-and-forget from the HTTP handler.
12554
12771
  (async () => {
@@ -14564,6 +14781,10 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
14564
14781
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
14565
14782
  scoutState.sentToday += 1;
14566
14783
  markPostSeen(scoutState, draft.postId, "published");
14784
+ // Append the reply to `recentComposeTitles` so it shows up in the
14785
+ // "最近の投稿" list with its reply badge. `recordComposeAttempt` knows
14786
+ // not to bump `composedToday` when type === "reply", so the
14787
+ // "本日の新規投稿数" counter only reflects original posts.
14567
14788
  recordComposeAttempt(scoutState, draft.postTitle || draft.postId, draft.postId, "reply");
14568
14789
  await writeScoutState(scoutState);
14569
14790
  }
@@ -14638,6 +14859,11 @@ function buildConfig(cli) {
14638
14859
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
14639
14860
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
14640
14861
  a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
14862
+ // share.viveworker.com — HTML hosting backed by the same A2A credentials.
14863
+ // Override for staging / self-hosted deployments via VIVEWORKER_SHARE_URL.
14864
+ a2aShareUrl: stripTrailingSlash(
14865
+ process.env.VIVEWORKER_SHARE_URL || "https://share.viveworker.com"
14866
+ ),
14641
14867
  webUiEnabled: boolEnv("WEB_UI_ENABLED", true),
14642
14868
  authRequired: boolEnv("AUTH_REQUIRED", true),
14643
14869
  webPushEnabled: boolEnv("WEB_PUSH_ENABLED", false),
@@ -15989,6 +16215,7 @@ async function main() {
15989
16215
  restoredTimelineImagePathsStateChanged ||
15990
16216
  migratedRecentCodeEventsStateChanged ||
15991
16217
  restoredPendingUserInputStateChanged ||
16218
+ backfilledMoltbookInboxChanged ||
15992
16219
  refreshResolvedThreadLabels({ config, runtime, state })
15993
16220
  ) {
15994
16221
  await saveState(config.stateFile, state);
package/web/app.css CHANGED
@@ -610,14 +610,16 @@ code {
610
610
  }
611
611
 
612
612
  .settings-compose-badge {
613
- display: inline-block;
613
+ display: inline-flex;
614
614
  align-self: flex-start;
615
+ flex: 0 0 auto;
615
616
  padding: 0.12rem 0.42rem;
616
617
  border-radius: 6px;
617
618
  font-size: 0.7rem;
618
619
  font-weight: 600;
619
620
  letter-spacing: 0.03em;
620
621
  line-height: 1.3;
622
+ white-space: nowrap;
621
623
  }
622
624
 
623
625
  .settings-compose-badge--post {
@@ -637,6 +639,78 @@ code {
637
639
  padding: 0.72rem 1rem;
638
640
  }
639
641
 
642
+ .settings-icon-entry,
643
+ .settings-share-file-entry {
644
+ display: grid;
645
+ grid-template-columns: 1.65rem minmax(0, 1fr);
646
+ align-items: flex-start;
647
+ gap: 0.62rem;
648
+ }
649
+
650
+ .settings-icon-entry__icon,
651
+ .settings-share-file-entry__icon {
652
+ display: inline-flex;
653
+ align-items: center;
654
+ justify-content: center;
655
+ width: 1.65rem;
656
+ height: 1.65rem;
657
+ border-radius: 10px;
658
+ color: rgba(121, 196, 255, 0.9);
659
+ background: rgba(121, 196, 255, 0.11);
660
+ box-shadow: inset 0 0 0 1px rgba(121, 196, 255, 0.14);
661
+ }
662
+
663
+ .settings-icon-entry__icon--post {
664
+ color: rgba(121, 196, 255, 0.9);
665
+ background: rgba(121, 196, 255, 0.11);
666
+ box-shadow: inset 0 0 0 1px rgba(121, 196, 255, 0.14);
667
+ }
668
+
669
+ .settings-icon-entry__icon--reply {
670
+ color: rgba(190, 160, 255, 0.92);
671
+ background: rgba(160, 120, 255, 0.13);
672
+ box-shadow: inset 0 0 0 1px rgba(160, 120, 255, 0.16);
673
+ }
674
+
675
+ .settings-icon-entry__icon--file {
676
+ color: rgba(121, 196, 255, 0.9);
677
+ background: rgba(121, 196, 255, 0.11);
678
+ box-shadow: inset 0 0 0 1px rgba(121, 196, 255, 0.14);
679
+ }
680
+
681
+ .settings-icon-entry__icon svg,
682
+ .settings-share-file-entry__icon svg {
683
+ width: 0.95rem;
684
+ height: 0.95rem;
685
+ }
686
+
687
+ .settings-icon-entry__body,
688
+ .settings-share-file-entry__body {
689
+ min-width: 0;
690
+ display: grid;
691
+ gap: 0.22rem;
692
+ }
693
+
694
+ .settings-icon-entry__title-row,
695
+ .settings-share-file-entry__title-row {
696
+ display: flex;
697
+ align-items: center;
698
+ gap: 0.38rem;
699
+ min-width: 0;
700
+ }
701
+
702
+ .settings-icon-entry__title-row .settings-compose-entry__title,
703
+ .settings-share-file-entry__title-row .settings-compose-entry__title {
704
+ flex: 1 1 auto;
705
+ min-width: 0;
706
+ overflow-wrap: anywhere;
707
+ }
708
+
709
+ .settings-icon-entry__title-row .settings-compose-badge,
710
+ .settings-share-file-entry__title-row .settings-compose-badge {
711
+ margin-left: auto;
712
+ }
713
+
640
714
  .settings-compose-entry + .settings-compose-entry {
641
715
  border-top: 1px solid rgba(255, 255, 255, 0.06);
642
716
  }
package/web/app.js CHANGED
@@ -50,6 +50,8 @@ const state = {
50
50
  moltbookScoutStatus: null,
51
51
  moltbookRecentTitlesExpanded: 0,
52
52
  a2aRelayStatus: null,
53
+ a2aShareStatus: null,
54
+ a2aShareRecentExpanded: 0,
53
55
  a2aTaskExecutorPick: "codex",
54
56
  pushNotice: "",
55
57
  pushError: "",
@@ -218,6 +220,7 @@ async function refreshAuthenticatedState() {
218
220
  await refreshPushStatus();
219
221
  await fetchMoltbookScoutStatus();
220
222
  await fetchA2aRelayStatus();
223
+ await fetchA2aShareStatus();
221
224
  ensureCurrentSelection();
222
225
  }
223
226
 
@@ -347,6 +350,18 @@ async function fetchA2aRelayStatus() {
347
350
  }
348
351
  }
349
352
 
353
+ async function fetchA2aShareStatus() {
354
+ if (!state.session?.a2aShareEnabled) {
355
+ state.a2aShareStatus = null;
356
+ return;
357
+ }
358
+ try {
359
+ state.a2aShareStatus = await apiGet("/api/share/status");
360
+ } catch {
361
+ state.a2aShareStatus = null;
362
+ }
363
+ }
364
+
350
365
  async function getClientPushState() {
351
366
  const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready.catch(() => null));
352
367
  if (registration) {
@@ -2865,6 +2880,7 @@ function buildSettingsContext() {
2865
2880
  }),
2866
2881
  moltbookScout: state.moltbookScoutStatus,
2867
2882
  a2aRelay: state.a2aRelayStatus,
2883
+ a2aShare: state.a2aShareStatus,
2868
2884
  };
2869
2885
  }
2870
2886
 
@@ -3025,6 +3041,13 @@ function settingsPageMeta(page) {
3025
3041
  description: L("settings.a2aRelay.copy"),
3026
3042
  icon: "link",
3027
3043
  };
3044
+ case "a2aShare":
3045
+ return {
3046
+ id: "a2aShare",
3047
+ title: L("settings.a2aShare.title"),
3048
+ description: L("settings.a2aShare.copy"),
3049
+ icon: "link",
3050
+ };
3028
3051
  case "a2aExecutor":
3029
3052
  // Executor settings integrated into a2aRelay page — redirect.
3030
3053
  return settingsPageMeta("a2aRelay");
@@ -3097,13 +3120,13 @@ function renderSettingsRoot(context, { mobile }) {
3097
3120
  `
3098
3121
  }
3099
3122
  ${renderSettingsGroup(L("settings.group.general"), generalRows)}
3100
- ${(state.session?.moltbookEnabled || state.session?.a2aRelayEnabled) ? renderSettingsGroup(L("settings.group.integrations"), [
3123
+ ${(state.session?.moltbookEnabled || state.session?.a2aRelayEnabled || state.session?.a2aShareEnabled) ? renderSettingsGroup(L("settings.group.integrations"), [
3101
3124
  state.session?.moltbookEnabled ? renderSettingsNavRow({
3102
3125
  page: "moltbook",
3103
3126
  icon: "item",
3104
3127
  title: L("settings.moltbook.title"),
3105
3128
  subtitle: L("settings.moltbook.subtitle"),
3106
- value: context.moltbookScout?.enabled ? `${context.moltbookScout.sentToday} / ${context.moltbookScout.maxDaily}` : "",
3129
+ value: context.moltbookScout?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
3107
3130
  }) : "",
3108
3131
  state.session?.a2aRelayEnabled ? renderSettingsNavRow({
3109
3132
  page: "a2aRelay",
@@ -3112,6 +3135,13 @@ function renderSettingsRoot(context, { mobile }) {
3112
3135
  subtitle: L("settings.a2aRelay.subtitle"),
3113
3136
  value: context.a2aRelay?.connected ? L("settings.status.connected") : L("settings.a2aRelay.status.disconnected"),
3114
3137
  }) : "",
3138
+ state.session?.a2aShareEnabled ? renderSettingsNavRow({
3139
+ page: "a2aShare",
3140
+ icon: "link",
3141
+ title: L("settings.a2aShare.title"),
3142
+ subtitle: L("settings.a2aShare.subtitle"),
3143
+ value: context.a2aShare?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
3144
+ }) : "",
3115
3145
  ].filter(Boolean)) : ""}
3116
3146
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
3117
3147
  ${renderSettingsGroup(L("settings.group.advanced"), advancedRows)}
@@ -3163,6 +3193,9 @@ function renderSettingsSubpage(context, { mobile }) {
3163
3193
  case "a2aExecutor":
3164
3194
  content = renderSettingsA2aRelayPage(context);
3165
3195
  break;
3196
+ case "a2aShare":
3197
+ content = renderSettingsA2aSharePage(context);
3198
+ break;
3166
3199
  default:
3167
3200
  content = "";
3168
3201
  }
@@ -3392,8 +3425,16 @@ function renderSettingsMoltbookPage(context) {
3392
3425
  renderSettingsInfoRow(L("settings.row.moltbookBatchTopScore"), String(scout.batch.topScore)),
3393
3426
  renderSettingsInfoRow(L("settings.row.moltbookBatchRemaining"), `${Math.floor(scout.batch.remainingSeconds / 60)}:${String(scout.batch.remainingSeconds % 60).padStart(2, "0")}`),
3394
3427
  ] : [];
3428
+ const accountRow = scout.account?.name && scout.account?.profileUrl
3429
+ ? renderSettingsInfoRow(
3430
+ L("settings.row.moltbookAccount"),
3431
+ `<a href="${escapeHtml(scout.account.profileUrl)}" target="_blank" rel="noopener">${escapeHtml(scout.account.name)}</a>`,
3432
+ { rawValue: true }
3433
+ )
3434
+ : null;
3395
3435
  return `
3396
3436
  <div class="settings-page">
3437
+ ${accountRow ? renderSettingsGroup("", [accountRow]) : ""}
3397
3438
  ${renderSettingsGroup("", [
3398
3439
  renderSettingsInfoRow(L("settings.row.moltbookQuota"), `${scout.sentToday} / ${scout.maxDaily}`),
3399
3440
  renderSettingsInfoRow(L("settings.row.moltbookComposed"), `${scout.composedToday || 0} / 3`),
@@ -3417,7 +3458,19 @@ function renderSettingsMoltbookPage(context) {
3417
3458
  const link = postId
3418
3459
  ? `<a href="https://www.moltbook.com/post/${escapeHtml(postId)}" target="_blank" rel="noopener">${escapeHtml(title)}</a>`
3419
3460
  : escapeHtml(title);
3420
- return `<div class="settings-compose-entry">${badge}<span class="settings-compose-entry__title">${link}</span></div>`;
3461
+ const iconName = type === "reply" ? "moltbook-reply" : "moltbook-draft";
3462
+ const iconTone = type === "reply" ? "settings-icon-entry__icon--reply" : "settings-icon-entry__icon--post";
3463
+ return `
3464
+ <div class="settings-compose-entry settings-icon-entry">
3465
+ <span class="settings-icon-entry__icon ${iconTone}" aria-hidden="true">${renderIcon(iconName)}</span>
3466
+ <span class="settings-icon-entry__body">
3467
+ <span class="settings-icon-entry__title-row">
3468
+ <span class="settings-compose-entry__title">${link}</span>
3469
+ ${badge}
3470
+ </span>
3471
+ </span>
3472
+ </div>
3473
+ `;
3421
3474
  });
3422
3475
  if (hasMore) {
3423
3476
  const remaining = titles.length - visibleCount;
@@ -3445,7 +3498,7 @@ function renderSettingsA2aRelayPage(context) {
3445
3498
  : relay.polling
3446
3499
  ? L("settings.a2aRelay.status.polling")
3447
3500
  : L("settings.a2aRelay.status.disconnected");
3448
- const profileUrl = `${relay.relayUrl}/${relay.userId}`;
3501
+ const profileUrl = `${relay.relayUrl}/u/${relay.userId}`;
3449
3502
  const userIdLink = `<a href="${escapeHtml(profileUrl)}" target="_blank" rel="noopener">${escapeHtml(relay.userId)}</a>`;
3450
3503
  const relayHost = (() => { try { return new URL(relay.relayUrl).host; } catch { return relay.relayUrl; } })();
3451
3504
  const publicChecked = relay.acceptPublicTasks === true;
@@ -3465,20 +3518,22 @@ function renderSettingsA2aRelayPage(context) {
3465
3518
 
3466
3519
  return `
3467
3520
  <div class="settings-page">
3521
+ ${renderSettingsGroup("", [
3522
+ renderSettingsInfoRow(L("settings.row.a2aStatus"), statusLabel),
3523
+ renderSettingsInfoRow(L("settings.row.a2aUserId"), userIdLink, { rawValue: true }),
3524
+ renderSettingsInfoRow(L("settings.row.a2aRelay"), relayHost),
3525
+ renderSettingsInfoRow(L("settings.row.a2aApiKey"), relay.apiKeyConfigured ? L("settings.a2aRelay.apiKey.configured") : L("settings.a2aRelay.apiKey.notConfigured")),
3526
+ relay.lastPollAtMs
3527
+ ? renderSettingsInfoRow(L("settings.row.a2aLastPoll"), new Date(relay.lastPollAtMs).toLocaleString(state.locale))
3528
+ : "",
3529
+ ].filter(Boolean))}
3468
3530
  ${(() => {
3469
3531
  const stats = relay.taskStats || { received: 0, completed: 0, denied: 0 };
3470
- return renderSettingsGroup("", [
3471
- renderSettingsInfoRow(L("settings.row.a2aStatus"), statusLabel),
3472
- renderSettingsInfoRow(L("settings.row.a2aUserId"), userIdLink, { rawValue: true }),
3473
- renderSettingsInfoRow(L("settings.row.a2aRelay"), relayHost),
3474
- renderSettingsInfoRow(L("settings.row.a2aApiKey"), relay.apiKeyConfigured ? L("settings.a2aRelay.apiKey.configured") : L("settings.a2aRelay.apiKey.notConfigured")),
3475
- relay.lastPollAtMs
3476
- ? renderSettingsInfoRow(L("settings.row.a2aLastPoll"), new Date(relay.lastPollAtMs).toLocaleString(state.locale))
3477
- : "",
3532
+ return renderSettingsGroup(L("settings.a2aRelay.taskStats.title"), [
3478
3533
  renderSettingsInfoRow(L("settings.row.a2aTaskReceived"), String(stats.received)),
3479
3534
  renderSettingsInfoRow(L("settings.row.a2aTaskCompleted"), String(stats.completed)),
3480
3535
  renderSettingsInfoRow(L("settings.row.a2aTaskDenied"), String(stats.denied)),
3481
- ].filter(Boolean));
3536
+ ]);
3482
3537
  })()}
3483
3538
  <section class="settings-group">
3484
3539
  <p class="settings-group__title">${escapeHtml(L("settings.a2aRelay.publicTasks.title"))}</p>
@@ -3506,6 +3561,150 @@ function renderSettingsA2aRelayPage(context) {
3506
3561
  `;
3507
3562
  }
3508
3563
 
3564
+ function renderSettingsA2aSharePage(context) {
3565
+ const share = context.a2aShare;
3566
+ if (!share?.enabled) {
3567
+ return `
3568
+ <div class="settings-page">
3569
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.a2aShare.unavailable"))}</p>
3570
+ </div>
3571
+ `;
3572
+ }
3573
+ const quota = share.quota || { bytes: 0, maxBytes: 0, count: 0, maxCount: 0 };
3574
+ const limits = share.limits || {};
3575
+ const items = Array.isArray(share.items) ? share.items : [];
3576
+ const statusLabel = share.error
3577
+ ? L("settings.a2aShare.status.unreachable")
3578
+ : L("settings.status.enabled");
3579
+ const formatBytes = (bytes) => {
3580
+ if (!Number.isFinite(bytes)) return "—";
3581
+ if (bytes < 1024) return `${bytes} B`;
3582
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
3583
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
3584
+ };
3585
+ const storageValue = share.quota
3586
+ ? `${formatBytes(quota.bytes)} / ${formatBytes(quota.maxBytes || limits.maxTotalBytes || 0)}`
3587
+ : "—";
3588
+ const filesValue = share.quota
3589
+ ? `${quota.count} / ${quota.maxCount || limits.maxFiles || 0}`
3590
+ : "—";
3591
+ const storageRows = [
3592
+ renderSettingsInfoRow(L("settings.row.a2aShareStorage"), storageValue),
3593
+ renderSettingsInfoRow(L("settings.row.a2aShareFiles"), filesValue),
3594
+ renderSettingsInfoRow(L("settings.row.a2aShareMaxFileSize"), formatBytes(limits.maxFileBytes || 0)),
3595
+ renderSettingsInfoRow(
3596
+ L("settings.row.a2aShareDefaultExpiry"),
3597
+ L("settings.a2aShare.days", { count: limits.defaultExpiresDays || 30 })
3598
+ ),
3599
+ renderSettingsInfoRow(
3600
+ L("settings.row.a2aShareUploadRate"),
3601
+ L("settings.a2aShare.ratePerHour", { count: limits.uploadRatePerHour || 10 })
3602
+ ),
3603
+ ];
3604
+
3605
+ const PAGE_SIZE = 5;
3606
+ const visibleCount = state.a2aShareRecentExpanded || PAGE_SIZE;
3607
+ const visible = items.slice(0, visibleCount);
3608
+ const hasMore = items.length > visibleCount;
3609
+ const filesList = visible.map((item) => {
3610
+ const lock = item.hasPassword
3611
+ ? `<span class="settings-compose-badge settings-compose-badge--reply" title="${escapeHtml(L("settings.a2aShare.passwordProtected"))}">🔒</span>`
3612
+ : "";
3613
+ const label = escapeHtml(item.originalName || item.slug);
3614
+ const link = item.url
3615
+ ? `<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener">${label}</a>`
3616
+ : label;
3617
+ const sizeText = escapeHtml(formatBytes(item.size || 0));
3618
+ const createdText = item.createdAtMs
3619
+ ? escapeHtml(formatRelativeAge(Date.now() - item.createdAtMs))
3620
+ : "";
3621
+ const expiresText = item.expiresAtMs
3622
+ ? escapeHtml(formatExpiresIn(item.expiresAtMs - Date.now()))
3623
+ : "";
3624
+ const meta = [sizeText, createdText, expiresText].filter(Boolean).join(" · ");
3625
+ return `
3626
+ <div class="settings-compose-entry settings-icon-entry">
3627
+ <span class="settings-icon-entry__icon settings-icon-entry__icon--file" aria-hidden="true">${renderIcon("file-event")}</span>
3628
+ <span class="settings-icon-entry__body">
3629
+ <span class="settings-icon-entry__title-row">
3630
+ <span class="settings-compose-entry__title">${link}</span>
3631
+ ${lock}
3632
+ </span>
3633
+ ${meta ? `<span class="settings-compose-entry__meta muted">${meta}</span>` : ""}
3634
+ </span>
3635
+ </div>
3636
+ `;
3637
+ });
3638
+ if (hasMore) {
3639
+ const remaining = items.length - visibleCount;
3640
+ filesList.push(
3641
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-more>${escapeHtml(L("settings.a2aShare.showMore", { count: remaining }))}</button>`
3642
+ );
3643
+ } else if (items.length > PAGE_SIZE) {
3644
+ filesList.push(
3645
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-collapse>${escapeHtml(L("settings.a2aShare.showLess"))}</button>`
3646
+ );
3647
+ }
3648
+
3649
+ return `
3650
+ <div class="settings-page">
3651
+ ${renderSettingsGroup("", [
3652
+ renderSettingsInfoRow(L("settings.row.a2aShareStatus"), statusLabel),
3653
+ renderSettingsInfoRow(L("settings.row.a2aShareEndpoint"), share.shareHost || share.shareUrl || ""),
3654
+ renderSettingsInfoRow(L("settings.row.a2aShareUserId"), share.userId || ""),
3655
+ ])}
3656
+ ${renderSettingsGroup(L("settings.a2aShare.storage.title"), storageRows)}
3657
+ ${items.length
3658
+ ? renderSettingsGroup(L("settings.a2aShare.files.title"), filesList)
3659
+ : renderSettingsGroup(L("settings.a2aShare.files.title"), [
3660
+ `<p class="settings-group__description muted">${escapeHtml(L("settings.a2aShare.files.empty"))}</p>`,
3661
+ ])}
3662
+ ${share.error ? `<p class="settings-page-copy muted">${escapeHtml(L("settings.a2aShare.error", { reason: share.error }))}</p>` : ""}
3663
+ </div>
3664
+ `;
3665
+ }
3666
+
3667
+ function formatRelativeAge(ms) {
3668
+ if (!Number.isFinite(ms) || ms < 0) return "";
3669
+ // Intl.RelativeTimeFormat with numeric:"auto" gives us locale-aware phrasing
3670
+ // ("5 minutes ago" / "5分前") without needing dedicated translation keys.
3671
+ const locale = state.locale || DEFAULT_LOCALE;
3672
+ let rtf;
3673
+ try {
3674
+ rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
3675
+ } catch {
3676
+ rtf = null;
3677
+ }
3678
+ const sec = Math.floor(ms / 1000);
3679
+ const pick = (value, unit, fallback) => (rtf ? rtf.format(-value, unit) : fallback);
3680
+ if (sec < 60) return pick(sec, "second", `${sec}s ago`);
3681
+ const min = Math.floor(sec / 60);
3682
+ if (min < 60) return pick(min, "minute", `${min}m ago`);
3683
+ const hr = Math.floor(min / 60);
3684
+ if (hr < 24) return pick(hr, "hour", `${hr}h ago`);
3685
+ const day = Math.floor(hr / 24);
3686
+ if (day < 30) return pick(day, "day", `${day}d ago`);
3687
+ const mo = Math.floor(day / 30);
3688
+ if (mo < 12) return pick(mo, "month", `${mo}mo ago`);
3689
+ return pick(Math.floor(mo / 12), "year", `${Math.floor(mo / 12)}y ago`);
3690
+ }
3691
+
3692
+ function formatExpiresIn(ms) {
3693
+ if (!Number.isFinite(ms)) return "";
3694
+ if (ms <= 0) return L("settings.a2aShare.expired");
3695
+ const locale = state.locale || DEFAULT_LOCALE;
3696
+ let rtf;
3697
+ try {
3698
+ rtf = new Intl.RelativeTimeFormat(locale, { numeric: "always" });
3699
+ } catch {
3700
+ rtf = null;
3701
+ }
3702
+ const day = Math.floor(ms / 86400000);
3703
+ if (day >= 1) return rtf ? rtf.format(day, "day") : `in ${day}d`;
3704
+ const hr = Math.max(1, Math.floor(ms / 3600000));
3705
+ return rtf ? rtf.format(hr, "hour") : `in ${hr}h`;
3706
+ }
3707
+
3509
3708
  function renderSettingsInfoRow(label, value, options = {}) {
3510
3709
  const rowClassName = ["settings-info-row", options.rowClassName || ""].filter(Boolean).join(" ");
3511
3710
  const valueClassName = ["settings-info-row__value", options.valueClassName || ""].filter(Boolean).join(" ");
@@ -5393,6 +5592,19 @@ function bindShellInteractions() {
5393
5592
  });
5394
5593
  }
5395
5594
 
5595
+ for (const btn of document.querySelectorAll("[data-a2a-share-files-more]")) {
5596
+ btn.addEventListener("click", async () => {
5597
+ state.a2aShareRecentExpanded = (state.a2aShareRecentExpanded || 5) + 5;
5598
+ await renderShell();
5599
+ });
5600
+ }
5601
+ for (const btn of document.querySelectorAll("[data-a2a-share-files-collapse]")) {
5602
+ btn.addEventListener("click", async () => {
5603
+ state.a2aShareRecentExpanded = 0;
5604
+ await renderShell();
5605
+ });
5606
+ }
5607
+
5396
5608
  for (const button of document.querySelectorAll("[data-locale-option]")) {
5397
5609
  button.addEventListener("click", async () => {
5398
5610
  state.pushError = "";
package/web/i18n.js CHANGED
@@ -286,6 +286,8 @@ const translations = {
286
286
  "settings.status.installed": "Installed",
287
287
  "settings.status.notInstalled": "Not installed",
288
288
  "settings.status.connected": "Connected",
289
+ "settings.status.enabled": "Enabled",
290
+ "settings.status.disabled": "Disabled",
289
291
  "settings.status.info": "Info",
290
292
  "settings.notifications.title": "Notifications",
291
293
  "settings.claudeAway.title": "Claude",
@@ -361,9 +363,10 @@ const translations = {
361
363
  "settings.row.languageSource": "Language source",
362
364
  "settings.row.defaultLanguage": "Setup default",
363
365
  "settings.moltbook.title": "Moltbook",
364
- "settings.moltbook.subtitle": "Auto-scout posting quota",
366
+ "settings.moltbook.subtitle": "Auto-scout & notifications",
365
367
  "settings.moltbook.copy": "View your daily auto-scout posting status and quota.",
366
368
  "settings.moltbook.unavailable": "Moltbook is not configured.",
369
+ "settings.row.moltbookAccount": "Profile",
367
370
  "settings.row.moltbookQuota": "Replies today",
368
371
  "settings.row.moltbookSeenPosts": "Seen posts",
369
372
  "settings.moltbook.batchTitle": "Current batch",
@@ -407,6 +410,7 @@ const translations = {
407
410
  "settings.row.a2aTaskReceived": "Tasks received",
408
411
  "settings.row.a2aTaskCompleted": "Tasks completed",
409
412
  "settings.row.a2aTaskDenied": "Tasks denied",
413
+ "settings.a2aRelay.taskStats.title": "Task history",
410
414
  "a2a.task.eyebrow": "A2A Task Request",
411
415
  "a2a.task.from": "From",
412
416
  "a2a.task.instruction": "Task",
@@ -426,6 +430,29 @@ const translations = {
426
430
  "settings.a2aExecutor.ask": "Ask every time",
427
431
  "settings.a2aExecutor.unavailable": "Not detected",
428
432
  "settings.a2aExecutor.detected": "Detected",
433
+ "settings.a2aShare.title": "A2A Share",
434
+ "settings.a2aShare.subtitle": "HTML hosting",
435
+ "settings.a2aShare.copy": "Host static HTML files on a private URL. Shares the A2A credentials; no separate setup needed.",
436
+ "settings.a2aShare.unavailable": "A2A Share is not configured. Run `viveworker a2a setup` to provision credentials.",
437
+ "settings.a2aShare.storage.title": "Storage",
438
+ "settings.a2aShare.files.title": "Active shares",
439
+ "settings.a2aShare.files.empty": "No active shares yet.",
440
+ "settings.a2aShare.days": ({ count }) => `${count} ${count === 1 ? "day" : "days"}`,
441
+ "settings.a2aShare.ratePerHour": ({ count }) => `${count} / hour`,
442
+ "settings.a2aShare.passwordProtected": "Password protected",
443
+ "settings.a2aShare.showMore": "{count} more…",
444
+ "settings.a2aShare.showLess": "Show less",
445
+ "settings.a2aShare.expired": "Expired",
446
+ "settings.a2aShare.status.unreachable": "Unreachable",
447
+ "settings.a2aShare.error": "Upstream fetch failed: {reason}",
448
+ "settings.row.a2aShareStatus": "Status",
449
+ "settings.row.a2aShareEndpoint": "Endpoint",
450
+ "settings.row.a2aShareUserId": "User ID",
451
+ "settings.row.a2aShareStorage": "Storage used",
452
+ "settings.row.a2aShareFiles": "Active files",
453
+ "settings.row.a2aShareMaxFileSize": "Max file size",
454
+ "settings.row.a2aShareDefaultExpiry": "Default expiry",
455
+ "settings.row.a2aShareUploadRate": "Upload rate limit",
429
456
  "a2a.task.resolved": "This task has already been resolved.",
430
457
  "a2a.task.statusSubmitted": "Awaiting your decision",
431
458
  "a2a.task.statusWorking": "Executing...",
@@ -942,6 +969,8 @@ const translations = {
942
969
  "settings.status.installed": "追加済み",
943
970
  "settings.status.notInstalled": "未追加",
944
971
  "settings.status.connected": "接続済み",
972
+ "settings.status.enabled": "有効",
973
+ "settings.status.disabled": "無効",
945
974
  "settings.status.info": "情報",
946
975
  "settings.notifications.title": "通知",
947
976
  "settings.claudeAway.title": "Claude",
@@ -1017,9 +1046,10 @@ const translations = {
1017
1046
  "settings.row.languageSource": "言語ソース",
1018
1047
  "settings.row.defaultLanguage": "setup 時の既定値",
1019
1048
  "settings.moltbook.title": "Moltbook",
1020
- "settings.moltbook.subtitle": "自動スカウトの投稿枠",
1049
+ "settings.moltbook.subtitle": "自動スカウト・通知連携",
1021
1050
  "settings.moltbook.copy": "本日の自動スカウト投稿状況と上限を確認できます。",
1022
1051
  "settings.moltbook.unavailable": "Moltbook が設定されていません。",
1052
+ "settings.row.moltbookAccount": "プロフィール",
1023
1053
  "settings.row.moltbookQuota": "本日の返信数",
1024
1054
  "settings.row.moltbookSeenPosts": "巡回済み投稿数",
1025
1055
  "settings.moltbook.batchTitle": "現在のバッチ",
@@ -1063,6 +1093,7 @@ const translations = {
1063
1093
  "settings.row.a2aTaskReceived": "受信タスク",
1064
1094
  "settings.row.a2aTaskCompleted": "完了タスク",
1065
1095
  "settings.row.a2aTaskDenied": "拒否タスク",
1096
+ "settings.a2aRelay.taskStats.title": "タスク履歴",
1066
1097
  "a2a.task.eyebrow": "A2A タスクリクエスト",
1067
1098
  "a2a.task.from": "送信元",
1068
1099
  "a2a.task.instruction": "タスク内容",
@@ -1082,6 +1113,29 @@ const translations = {
1082
1113
  "settings.a2aExecutor.ask": "毎回確認する",
1083
1114
  "settings.a2aExecutor.unavailable": "未検出",
1084
1115
  "settings.a2aExecutor.detected": "検出",
1116
+ "settings.a2aShare.title": "A2A Share",
1117
+ "settings.a2aShare.subtitle": "HTML ホスティング",
1118
+ "settings.a2aShare.copy": "静的 HTML ファイルを非公開 URL でホストします。A2A と認証情報を共有するため追加の設定は不要です。",
1119
+ "settings.a2aShare.unavailable": "A2A Share が設定されていません。`viveworker a2a setup` を実行して認証情報を設定してください。",
1120
+ "settings.a2aShare.storage.title": "ストレージ",
1121
+ "settings.a2aShare.files.title": "公開中のファイル",
1122
+ "settings.a2aShare.files.empty": "公開中のファイルはありません。",
1123
+ "settings.a2aShare.days": ({ count }) => `${count} 日`,
1124
+ "settings.a2aShare.ratePerHour": ({ count }) => `${count} 件 / 1 時間`,
1125
+ "settings.a2aShare.passwordProtected": "パスワード保護",
1126
+ "settings.a2aShare.showMore": "さらに {count} 件…",
1127
+ "settings.a2aShare.showLess": "閉じる",
1128
+ "settings.a2aShare.expired": "期限切れ",
1129
+ "settings.a2aShare.status.unreachable": "到達不可",
1130
+ "settings.a2aShare.error": "上流への問い合わせに失敗しました: {reason}",
1131
+ "settings.row.a2aShareStatus": "ステータス",
1132
+ "settings.row.a2aShareEndpoint": "エンドポイント",
1133
+ "settings.row.a2aShareUserId": "ユーザー ID",
1134
+ "settings.row.a2aShareStorage": "使用容量",
1135
+ "settings.row.a2aShareFiles": "公開中ファイル数",
1136
+ "settings.row.a2aShareMaxFileSize": "1 ファイル上限",
1137
+ "settings.row.a2aShareDefaultExpiry": "既定の公開期限",
1138
+ "settings.row.a2aShareUploadRate": "アップロード上限",
1085
1139
  "a2a.task.resolved": "このタスクは既に処理済みです。",
1086
1140
  "a2a.task.statusSubmitted": "承認待ち",
1087
1141
  "a2a.task.statusWorking": "実行中...",