viveworker 0.4.5 → 0.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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",
@@ -29,6 +29,8 @@ const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
29
29
 
30
30
  let pollTimer = null;
31
31
  let isPolling = false;
32
+ let lastPollOk = false;
33
+ let lastPollAtMs = 0;
32
34
 
33
35
  // ---------------------------------------------------------------------------
34
36
  // Registration
@@ -164,6 +166,17 @@ export function stopRelayPolling() {
164
166
  console.log("[a2a-relay] Polling stopped");
165
167
  }
166
168
 
169
+ /**
170
+ * Return current relay connection status for the settings UI.
171
+ */
172
+ export function getRelayStatus() {
173
+ return {
174
+ polling: pollTimer !== null,
175
+ lastPollOk,
176
+ lastPollAtMs,
177
+ };
178
+ }
179
+
167
180
  /**
168
181
  * Single poll cycle: fetch pending tasks from the relay and ingest them.
169
182
  */
@@ -186,9 +199,14 @@ async function pollOnce({ config, runtime, state, helpers }) {
186
199
  if (!res.ok) {
187
200
  const text = await res.text().catch(() => "");
188
201
  console.error(`[a2a-relay] Poll failed: HTTP ${res.status} ${text.slice(0, 200)}`);
202
+ lastPollOk = false;
203
+ lastPollAtMs = Date.now();
189
204
  return;
190
205
  }
191
206
 
207
+ lastPollOk = true;
208
+ lastPollAtMs = Date.now();
209
+
192
210
  const data = await res.json();
193
211
  const tasks = data.tasks || [];
194
212
 
@@ -17,7 +17,7 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale,
17
17
  import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
18
18
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
19
19
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
20
- import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult } from "./a2a-relay-client.mjs";
20
+ import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus } from "./a2a-relay-client.mjs";
21
21
 
22
22
  const __filename = fileURLToPath(import.meta.url);
23
23
  const __dirname = path.dirname(__filename);
@@ -28,7 +28,7 @@ const sessionCookieName = "viveworker_session";
28
28
  const deviceCookieName = "viveworker_device";
29
29
  const historyKinds = new Set(["completion", "plan_ready", "approval", "plan", "choice", "info"]);
30
30
  const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
31
- const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft"]);
31
+ const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share"]);
32
32
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
33
33
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
34
34
  const MAX_PAIRED_DEVICES = 200;
@@ -85,6 +85,8 @@ const runtime = {
85
85
  moltbookItemsByToken: new Map(),
86
86
  moltbookDraftsByToken: new Map(),
87
87
  a2aTasksByToken: new Map(),
88
+ threadSharesByToken: new Map(),
89
+ threadRegistry: new Map(),
88
90
  planDetailsByToken: new Map(),
89
91
  recentHistoryItems: [],
90
92
  recentTimelineEntries: [],
@@ -94,6 +96,7 @@ const runtime = {
94
96
  stopping: false,
95
97
  };
96
98
  const state = await loadState(config.stateFile);
99
+ runtime.threadRegistry = await loadThreadRegistry(config.threadRegistryFile);
97
100
  await syncClaudeAwayModeSentinel(config, state.claudeAwayMode === true);
98
101
  const migratedPairedDevicesStateChanged = migratePairedDevicesState({ config, state });
99
102
  const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, runtime, state });
@@ -6970,6 +6973,20 @@ class NativeIpcClient {
6970
6973
 
6971
6974
  this.runtime.threadStates.set(normalized.conversationId, nextState);
6972
6975
 
6976
+ // Register / update in the persistent thread registry.
6977
+ const regLabel = extractThreadLabelFromState(nextState)
6978
+ || getThreadName(this.runtime.sessionIndex, this.runtime.rolloutThreadLabels, normalized.conversationId, nextState.cwd || "", "");
6979
+ if (registerThread(this.runtime, {
6980
+ id: normalized.conversationId,
6981
+ label: regLabel,
6982
+ tool: "codex",
6983
+ cwd: nextState.cwd || "",
6984
+ lastSeenAtMs: Date.now(),
6985
+ active: true,
6986
+ })) {
6987
+ saveThreadRegistry(this.config.threadRegistryFile, this.runtime.threadRegistry).catch(() => {});
6988
+ }
6989
+
6973
6990
  await this.onThreadStateChanged({
6974
6991
  conversationId: normalized.conversationId,
6975
6992
  previousRequests,
@@ -9067,6 +9084,24 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9067
9084
  });
9068
9085
  }
9069
9086
 
9087
+ for (const share of runtime.threadSharesByToken.values()) {
9088
+ if (share.decision) continue;
9089
+ const title = share.sourceLabel
9090
+ ? `${share.sourceLabel} → ${share.targetLabel || share.targetTool}`
9091
+ : `→ ${share.targetLabel || share.targetTool}`;
9092
+ items.push({
9093
+ kind: "thread_share",
9094
+ token: share.token,
9095
+ threadId: "thread_share",
9096
+ threadLabel: "Thread Share",
9097
+ title,
9098
+ summary: String(share.content || "").slice(0, 160),
9099
+ primaryLabel: t(locale, "server.action.review"),
9100
+ createdAtMs: Number(share.createdAtMs) || now,
9101
+ provider: "viveworker",
9102
+ });
9103
+ }
9104
+
9070
9105
  // Moltbook reply items intentionally do not appear in the unhandled list.
9071
9106
  // Reply drafting is delegated to Codex/Claude Desktop via the
9072
9107
  // `viveworker moltbook` CLI, so they live in the timeline only.
@@ -10611,6 +10646,42 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10611
10646
  };
10612
10647
  }
10613
10648
 
10649
+ if (kind === "thread_share") {
10650
+ const share = runtime.threadSharesByToken.get(token);
10651
+ const entry = share
10652
+ ? null
10653
+ : runtime.recentTimelineEntries.find((e) => e.kind === "thread_share" && e.token === token);
10654
+ const source = share || entry;
10655
+ if (!source) return null;
10656
+ const content = share ? share.content : entry.messageText || entry.summary || "";
10657
+ const messageHtml = escapeHtml(content)
10658
+ .split("\n")
10659
+ .map((line) => `<p>${line}</p>`)
10660
+ .join("");
10661
+ return {
10662
+ kind: "thread_share",
10663
+ token,
10664
+ threadId: "thread_share",
10665
+ threadLabel: "Thread Share",
10666
+ title: source.title || "Thread Share",
10667
+ summary: source.summary || cleanText(content).slice(0, 160),
10668
+ messageHtml,
10669
+ provider: "viveworker",
10670
+ shareContent: content,
10671
+ shareType: share?.shareType || entry?.shareType || "message",
10672
+ sourceTool: share?.sourceTool || entry?.sourceTool || "",
10673
+ sourceLabel: share?.sourceLabel || entry?.sourceLabel || "",
10674
+ targetTool: share?.targetTool || entry?.targetTool || "",
10675
+ targetLabel: share?.targetLabel || entry?.targetLabel || "",
10676
+ targetConversationId: share?.targetConversationId || entry?.targetConversationId || "",
10677
+ contextFiles: share?.contextFiles || entry?.contextFiles || [],
10678
+ createdAtMs: source.createdAtMs || Date.now(),
10679
+ readOnly: !share || Boolean(share?.decision),
10680
+ actions: [],
10681
+ threadShareEnabled: Boolean(share) && !share.decision,
10682
+ };
10683
+ }
10684
+
10614
10685
  const historyItem = historyItemByToken(runtime, kind, token);
10615
10686
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
10616
10687
  }
@@ -11128,6 +11199,339 @@ function createNativeApprovalServer({ config, runtime, state }) {
11128
11199
  }
11129
11200
  }
11130
11201
 
11202
+ // A2A relay status for the settings UI.
11203
+ if (url.pathname === "/api/a2a/relay-status" && req.method === "GET") {
11204
+ const session = requireApiSession(req, res, config, state);
11205
+ if (!session) return;
11206
+ const relayEnabled = Boolean(config.a2aRelayUrl && config.a2aRelayUserId);
11207
+ if (!relayEnabled) {
11208
+ return writeJson(res, 200, { enabled: false });
11209
+ }
11210
+ const relay = getRelayStatus();
11211
+ return writeJson(res, 200, {
11212
+ enabled: true,
11213
+ connected: relay.polling && relay.lastPollOk,
11214
+ polling: relay.polling,
11215
+ lastPollOk: relay.lastPollOk,
11216
+ lastPollAtMs: relay.lastPollAtMs,
11217
+ userId: config.a2aRelayUserId,
11218
+ relayUrl: config.a2aRelayUrl,
11219
+ apiKeyConfigured: Boolean(config.a2aApiKey),
11220
+ });
11221
+ }
11222
+
11223
+ // ─── Thread sharing ──────────────────────────────────────────────
11224
+
11225
+ function buildThreadShareContent(shareType, body) {
11226
+ if (shareType === "plan_review") {
11227
+ const sections = [];
11228
+ if (body.context) sections.push(`## Context\n${body.context}`);
11229
+ if (body.plan) sections.push(`## Plan\n${body.plan}`);
11230
+ if (Array.isArray(body.files) && body.files.length) {
11231
+ sections.push(`## Relevant files\n${body.files.map((f) => `- ${f}`).join("\n")}`);
11232
+ }
11233
+ sections.push(`## Instruction\n${body.instruction || "共有されたプランの内容を把握し、実現可能性や改善点を検討してください。"}`);
11234
+ return sections.join("\n\n");
11235
+ }
11236
+ if (shareType === "handoff") {
11237
+ const sections = [];
11238
+ if (body.summary) sections.push(`## Summary\n${body.summary}`);
11239
+ if (Array.isArray(body.completed) && body.completed.length) {
11240
+ sections.push(`## Completed\n${body.completed.map((t) => `- ${t}`).join("\n")}`);
11241
+ }
11242
+ if (Array.isArray(body.remaining) && body.remaining.length) {
11243
+ sections.push(`## Remaining\n${body.remaining.map((t) => `- ${t}`).join("\n")}`);
11244
+ }
11245
+ if (Array.isArray(body.decisions) && body.decisions.length) {
11246
+ sections.push(`## Key decisions\n${body.decisions.map((d) => `- ${d}`).join("\n")}`);
11247
+ }
11248
+ if (Array.isArray(body.modifiedFiles) && body.modifiedFiles.length) {
11249
+ sections.push(`## Modified files\n${body.modifiedFiles.map((f) => `- ${f}`).join("\n")}`);
11250
+ }
11251
+ sections.push(`## Instruction\n${body.instruction || "これまでの作業内容と経緯を把握した上で、次に何をすべきか検討してください。"}`);
11252
+ return sections.join("\n\n");
11253
+ }
11254
+ // Default: message type — use raw content.
11255
+ return cleanText(body?.content || "");
11256
+ }
11257
+
11258
+ // List known threads (active + registry) for the share target picker.
11259
+ if (url.pathname === "/api/threads/list" && req.method === "GET") {
11260
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11261
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
11262
+ if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
11263
+ const codexConnected = Boolean(runtime.ipcClient?.clientId);
11264
+ const activeIds = new Set();
11265
+ const threads = [];
11266
+ // Active Codex threads (from IPC).
11267
+ for (const [conversationId, threadState] of runtime.threadStates) {
11268
+ activeIds.add(conversationId);
11269
+ const label = getThreadName(
11270
+ runtime.sessionIndex,
11271
+ runtime.rolloutThreadLabels,
11272
+ conversationId,
11273
+ cleanText(threadState?.cwd || ""),
11274
+ ""
11275
+ );
11276
+ threads.push({
11277
+ conversationId,
11278
+ label: label || conversationId.slice(0, 8),
11279
+ tool: "codex",
11280
+ cwd: cleanText(threadState?.cwd || ""),
11281
+ hasOwner: runtime.threadOwnerClientIds.has(conversationId),
11282
+ active: true,
11283
+ });
11284
+ }
11285
+ // Registry entries not already covered by active threads.
11286
+ for (const entry of runtime.threadRegistry.values()) {
11287
+ if (!activeIds.has(entry.id)) {
11288
+ threads.push({
11289
+ conversationId: entry.id,
11290
+ label: entry.label || entry.id.slice(0, 8),
11291
+ tool: entry.tool || "codex",
11292
+ cwd: entry.cwd || "",
11293
+ hasOwner: false,
11294
+ active: false,
11295
+ lastSeenAtMs: entry.lastSeenAtMs || 0,
11296
+ });
11297
+ }
11298
+ }
11299
+ return writeJson(res, 200, { threads, codexConnected });
11300
+ }
11301
+
11302
+ // Submit a thread share request (creates an approval item on the phone).
11303
+ if (url.pathname === "/api/threads/share" && req.method === "POST") {
11304
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11305
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
11306
+ if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
11307
+ const body = await parseJsonBody(req);
11308
+ const shareType = cleanText(body?.shareType || "message");
11309
+ const sourceTool = cleanText(body?.sourceTool || "");
11310
+ const sourceLabel = cleanText(body?.sourceLabel || "");
11311
+ const targetConversationId = cleanText(body?.targetConversationId || "");
11312
+ let targetTool = cleanText(body?.targetTool || "");
11313
+ let targetCwd = cleanText(body?.targetCwd || "");
11314
+ const contextFiles = Array.isArray(body?.contextFiles)
11315
+ ? body.contextFiles.map((f) => cleanText(String(f))).filter(Boolean)
11316
+ : [];
11317
+
11318
+ // Auto-resolve targetTool and targetCwd from registry if not provided.
11319
+ if (targetConversationId) {
11320
+ const regEntry = runtime.threadRegistry.get(targetConversationId);
11321
+ if (regEntry) {
11322
+ if (!targetTool) targetTool = regEntry.tool || "";
11323
+ if (!targetCwd) targetCwd = regEntry.cwd || "";
11324
+ }
11325
+ }
11326
+
11327
+ // Build content from structured fields depending on shareType.
11328
+ const content = buildThreadShareContent(shareType, body);
11329
+ if (!content && contextFiles.length === 0) {
11330
+ return writeJson(res, 400, { error: "missing-content" });
11331
+ }
11332
+ if (!targetConversationId && !targetTool) {
11333
+ return writeJson(res, 400, { error: "missing-target" });
11334
+ }
11335
+ const shareId = crypto.randomUUID();
11336
+ const token = historyToken(`thread_share:${shareId}`);
11337
+ const targetLabel = targetConversationId
11338
+ ? (getThreadName(runtime.sessionIndex, runtime.rolloutThreadLabels, targetConversationId, runtime.threadStates.get(targetConversationId)?.cwd || "", "") || runtime.threadRegistry.get(targetConversationId)?.label || "")
11339
+ : targetTool;
11340
+ const share = {
11341
+ token,
11342
+ shareId,
11343
+ shareType,
11344
+ content: content || "",
11345
+ contextFiles,
11346
+ sourceTool,
11347
+ sourceLabel,
11348
+ targetConversationId,
11349
+ targetTool: targetTool || "codex",
11350
+ targetCwd,
11351
+ targetLabel,
11352
+ createdAtMs: Date.now(),
11353
+ decision: null,
11354
+ decisionWaiters: [],
11355
+ };
11356
+ runtime.threadSharesByToken.set(token, share);
11357
+ try {
11358
+ const title = sourceLabel
11359
+ ? `${sourceLabel} → ${targetLabel || targetTool}`
11360
+ : `→ ${targetLabel || targetTool}`;
11361
+ recordTimelineEntry({
11362
+ config, runtime, state,
11363
+ entry: {
11364
+ stableId: `thread_share:${shareId}`,
11365
+ token,
11366
+ kind: "thread_share",
11367
+ threadId: "thread_share",
11368
+ threadLabel: "Thread Share",
11369
+ title,
11370
+ summary: String(content).slice(0, 160),
11371
+ messageText: content,
11372
+ createdAtMs: share.createdAtMs,
11373
+ readOnly: false,
11374
+ provider: "viveworker",
11375
+ },
11376
+ });
11377
+ await saveState(config.stateFile, state);
11378
+ } catch (error) {
11379
+ console.error(`[thread-share-timeline] ${error.message}`);
11380
+ }
11381
+ try {
11382
+ await deliverWebPushItem({
11383
+ config, state,
11384
+ kind: "thread_share",
11385
+ token,
11386
+ stableId: `thread_share:${shareId}`,
11387
+ title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
11388
+ body: String(content).slice(0, 160),
11389
+ });
11390
+ } catch (error) {
11391
+ console.error(`[thread-share-push] ${error.message}`);
11392
+ }
11393
+ return writeJson(res, 200, { ok: true, token, shareId });
11394
+ }
11395
+
11396
+ // Decision endpoint for thread share (approve / deny from phone).
11397
+ const threadShareDecisionMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/decision$/);
11398
+ if (threadShareDecisionMatch && req.method === "POST") {
11399
+ const session = requireApiSession(req, res, config, state);
11400
+ if (!session) return;
11401
+ const token = cleanText(threadShareDecisionMatch[1]);
11402
+ const share = runtime.threadSharesByToken.get(token);
11403
+ if (!share) {
11404
+ return writeJson(res, 404, { error: "not-found" });
11405
+ }
11406
+ if (share.decision) {
11407
+ return writeJson(res, 200, { ok: true, decision: share.decision });
11408
+ }
11409
+ const body = await parseJsonBody(req);
11410
+ const decision = cleanText(body?.decision || "");
11411
+ const editedContent = typeof body?.editedContent === "string" ? body.editedContent : null;
11412
+ if (decision !== "approve" && decision !== "deny") {
11413
+ return writeJson(res, 400, { error: "invalid-decision" });
11414
+ }
11415
+ share.decision = decision;
11416
+ if (editedContent !== null) {
11417
+ share.content = editedContent;
11418
+ }
11419
+
11420
+ // Deliver the shared content to the target.
11421
+ let deliveryStatus = "delivered";
11422
+ if (decision === "approve") {
11423
+ const prefix = share.sourceLabel ? `[Shared from ${share.sourceLabel}]\n\n` : "[Shared from another thread]\n\n";
11424
+ // Append context file references if present.
11425
+ if (share.contextFiles && share.contextFiles.length > 0) {
11426
+ const fileList = share.contextFiles.map((f) => `- ${f}`).join("\n");
11427
+ share.content = (share.content ? share.content + "\n\n" : "")
11428
+ + `## Context files\n\nRead the following files for full conversation context:\n${fileList}`;
11429
+ }
11430
+ let injectedViaIpc = false;
11431
+
11432
+ // Try Codex IPC injection if targeting a specific thread.
11433
+ if (share.targetConversationId && runtime.ipcClient?.clientId) {
11434
+ const ownerClientId = runtime.threadOwnerClientIds.get(share.targetConversationId) ?? null;
11435
+ const turnStartParams = { input: buildTurnInput(prefix + share.content) };
11436
+ // Try direct first, then fall back to thread-follower.
11437
+ for (const transport of ["direct", "follower"]) {
11438
+ try {
11439
+ if (transport === "direct" && ownerClientId) {
11440
+ await runtime.ipcClient.startTurnDirect(share.targetConversationId, turnStartParams, ownerClientId);
11441
+ } else {
11442
+ await runtime.ipcClient.startTurn(share.targetConversationId, turnStartParams, ownerClientId);
11443
+ }
11444
+ injectedViaIpc = true;
11445
+ console.log(`[thread-share] Delivered to Codex thread ${share.targetConversationId} via ${transport}`);
11446
+ break;
11447
+ } catch (error) {
11448
+ console.error(`[thread-share-ipc-${transport}] ${error.message}`);
11449
+ }
11450
+ }
11451
+ }
11452
+
11453
+ // Write to file inbox (always for claude-code targets, fallback for failed Codex delivery).
11454
+ if (!injectedViaIpc) {
11455
+ try {
11456
+ const inboxDir = path.join(os.homedir(), ".viveworker", "thread-inbox");
11457
+ await fs.mkdir(inboxDir, { recursive: true });
11458
+ const inboxFile = path.join(inboxDir, `${share.shareId}.json`);
11459
+ await fs.writeFile(inboxFile, JSON.stringify({
11460
+ shareId: share.shareId,
11461
+ content: share.content,
11462
+ contextFiles: share.contextFiles || [],
11463
+ targetCwd: share.targetCwd || "",
11464
+ sourceTool: share.sourceTool,
11465
+ sourceLabel: share.sourceLabel,
11466
+ targetTool: share.targetTool,
11467
+ createdAtMs: share.createdAtMs,
11468
+ deliveredAtMs: Date.now(),
11469
+ }, null, 2), "utf8");
11470
+ console.log(`[thread-share] Written to inbox: ${inboxFile}`);
11471
+ } catch (error) {
11472
+ console.error(`[thread-share-inbox-write] ${error.message}`);
11473
+ }
11474
+
11475
+ // If the user intended Codex but it wasn't reachable, notify.
11476
+ if (share.targetConversationId && share.targetTool !== "claude-code") {
11477
+ deliveryStatus = "inbox_fallback";
11478
+ try {
11479
+ await deliverWebPushItem({
11480
+ config, state,
11481
+ kind: "thread_share",
11482
+ token: share.token,
11483
+ stableId: `thread_share_fallback:${share.shareId}`,
11484
+ title: "Thread Share: target unreachable",
11485
+ body: `Codex thread "${share.targetLabel || share.targetConversationId.slice(0, 8)}" is not connected. Content saved to inbox.`,
11486
+ });
11487
+ } catch (error) {
11488
+ console.error(`[thread-share-fallback-push] ${error.message}`);
11489
+ }
11490
+ }
11491
+ }
11492
+ }
11493
+
11494
+ // Wake any long-poll waiters.
11495
+ for (const waiter of share.decisionWaiters) {
11496
+ waiter({ decision: share.decision, content: share.content });
11497
+ }
11498
+ share.decisionWaiters = [];
11499
+ return writeJson(res, 200, { ok: true, decision: share.decision });
11500
+ }
11501
+
11502
+ // Long-poll: wait for a thread share decision (used by the CLI or hook).
11503
+ const threadSharePollMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/poll$/);
11504
+ if (threadSharePollMatch && req.method === "GET") {
11505
+ const session = requireApiSession(req, res, config, state);
11506
+ if (!session) return;
11507
+ const token = cleanText(threadSharePollMatch[1]);
11508
+ const share = runtime.threadSharesByToken.get(token);
11509
+ if (!share) {
11510
+ return writeJson(res, 404, { error: "not-found" });
11511
+ }
11512
+ if (share.decision) {
11513
+ return writeJson(res, 200, { decision: share.decision, content: share.content });
11514
+ }
11515
+ const timeoutMs = Math.min(Number(url.searchParams.get("timeout")) || 120_000, 300_000);
11516
+ await new Promise((resolve) => {
11517
+ const timer = setTimeout(() => {
11518
+ const idx = share.decisionWaiters.indexOf(done);
11519
+ if (idx >= 0) share.decisionWaiters.splice(idx, 1);
11520
+ resolve();
11521
+ }, timeoutMs);
11522
+ timer.unref?.();
11523
+ function done(result) {
11524
+ clearTimeout(timer);
11525
+ resolve(result);
11526
+ }
11527
+ share.decisionWaiters.push(done);
11528
+ });
11529
+ if (share.decision) {
11530
+ return writeJson(res, 200, { decision: share.decision, content: share.content });
11531
+ }
11532
+ return writeJson(res, 200, { decision: null, timeout: true });
11533
+ }
11534
+
11131
11535
  // Activity summary for compose (original post) drafting.
11132
11536
  // Supports ?slot=morning|noon|evening and ?date=YYYY-MM-DD for
11133
11537
  // time-slot-based compose. Defaults to full-day today.
@@ -11411,6 +11815,27 @@ function createNativeApprovalServer({ config, runtime, state }) {
11411
11815
  return writeJson(res, 200, {});
11412
11816
  }
11413
11817
 
11818
+ // Register Claude Code thread in persistent registry on every event.
11819
+ {
11820
+ const regThreadId = String(body.threadId || body.sessionId || "");
11821
+ const regCwd = String(body.cwd || "");
11822
+ if (regThreadId) {
11823
+ const regLabel = runtime.claudeSessionTitles.get(regThreadId)
11824
+ || (regCwd ? path.basename(regCwd) : "")
11825
+ || regThreadId.slice(0, 40);
11826
+ if (registerThread(runtime, {
11827
+ id: regThreadId,
11828
+ label: regLabel,
11829
+ tool: "claude-code",
11830
+ cwd: regCwd,
11831
+ lastSeenAtMs: Date.now(),
11832
+ active: eventType !== "Stop" && eventType !== "SessionEnd",
11833
+ })) {
11834
+ saveThreadRegistry(config.threadRegistryFile, runtime.threadRegistry).catch(() => {});
11835
+ }
11836
+ }
11837
+ }
11838
+
11414
11839
  if (eventType !== "approval_request") {
11415
11840
  // Non-approval events (Notification, Stop, etc.) — acknowledge immediately
11416
11841
  return writeJson(res, 200, {});
@@ -13807,6 +14232,7 @@ function buildConfig(cli) {
13807
14232
  historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
13808
14233
  codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
13809
14234
  stateFile,
14235
+ threadRegistryFile: resolvePath(process.env.THREAD_REGISTRY_FILE || path.join(os.homedir(), ".viveworker", "thread-registry.json")),
13810
14236
  replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
13811
14237
  timelineAttachmentsDir: resolvePath(
13812
14238
  process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
@@ -14119,6 +14545,59 @@ async function saveState(stateFile, state) {
14119
14545
  await fs.writeFile(stateFile, `${output}\n`, "utf8");
14120
14546
  }
14121
14547
 
14548
+ // ---------------------------------------------------------------------------
14549
+ // Thread registry — persistent record of all known threads (survives restarts)
14550
+ // ---------------------------------------------------------------------------
14551
+
14552
+ async function loadThreadRegistry(filePath) {
14553
+ const registry = new Map();
14554
+ try {
14555
+ const raw = await fs.readFile(filePath, "utf8");
14556
+ const arr = JSON.parse(raw);
14557
+ if (Array.isArray(arr)) {
14558
+ for (const entry of arr) {
14559
+ if (entry && entry.id) {
14560
+ registry.set(entry.id, entry);
14561
+ }
14562
+ }
14563
+ }
14564
+ } catch {
14565
+ // Missing or corrupt file — start with empty registry.
14566
+ }
14567
+ return registry;
14568
+ }
14569
+
14570
+ async function saveThreadRegistry(filePath, registry) {
14571
+ const arr = [...registry.values()].sort((a, b) => (b.lastSeenAtMs || 0) - (a.lastSeenAtMs || 0));
14572
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
14573
+ await fs.writeFile(filePath, JSON.stringify(arr, null, 2) + "\n", "utf8");
14574
+ }
14575
+
14576
+ /** Upsert a thread in the registry. Returns true if something changed. */
14577
+ function registerThread(runtime, entry) {
14578
+ const existing = runtime.threadRegistry.get(entry.id);
14579
+ const merged = {
14580
+ id: entry.id,
14581
+ label: entry.label || existing?.label || "",
14582
+ tool: entry.tool || existing?.tool || "codex",
14583
+ cwd: entry.cwd || existing?.cwd || "",
14584
+ lastSeenAtMs: Math.max(entry.lastSeenAtMs || Date.now(), existing?.lastSeenAtMs || 0),
14585
+ active: entry.active !== undefined ? entry.active : (existing?.active ?? true),
14586
+ };
14587
+ const changed = !existing
14588
+ || existing.label !== merged.label
14589
+ || existing.tool !== merged.tool
14590
+ || existing.cwd !== merged.cwd
14591
+ || existing.active !== merged.active
14592
+ || existing.lastSeenAtMs !== merged.lastSeenAtMs;
14593
+ if (changed) {
14594
+ runtime.threadRegistry.set(entry.id, merged);
14595
+ }
14596
+ return changed;
14597
+ }
14598
+
14599
+ // ---------------------------------------------------------------------------
14600
+
14122
14601
  async function loadSessionIndex(filePath) {
14123
14602
  const result = new Map();
14124
14603
  try {
@@ -107,8 +107,11 @@ switch (hookEventName) {
107
107
  case "PostToolUseFailure":
108
108
  await handlePostToolUse();
109
109
  break;
110
+ case "UserPromptSubmit":
111
+ await handleUserPromptSubmit();
112
+ break;
110
113
  default:
111
- // Notification, Stop, UserPromptSubmit, SessionEnd
114
+ // Notification, Stop, SessionEnd
112
115
  await postEvent(hookEventName, { sessionId, toolName, cwd }, DEFAULT_EVENT_TIMEOUT_MS);
113
116
  break;
114
117
  }
@@ -399,6 +402,76 @@ async function handlePostToolUse() {
399
402
  );
400
403
  }
401
404
 
405
+ // ---------------------------------------------------------------------------
406
+ // UserPromptSubmit — thread inbox auto-read
407
+ // ---------------------------------------------------------------------------
408
+
409
+ async function handleUserPromptSubmit() {
410
+ // Check ~/.viveworker/thread-inbox/ for pending shared content from other
411
+ // AI tool sessions. If files are found, inject their content as additional
412
+ // context so the assistant sees them automatically.
413
+ //
414
+ // Targeting: if an inbox file has a `targetCwd` field, only consume it when
415
+ // the current session's cwd starts with that path (project match). Files
416
+ // without targetCwd are consumed by any session.
417
+ const inboxDir = path.join(os.homedir(), ".viveworker", "thread-inbox");
418
+ let inboxFiles = [];
419
+ try {
420
+ const entries = await fs.readdir(inboxDir);
421
+ inboxFiles = entries.filter((e) => e.endsWith(".json"));
422
+ } catch {
423
+ // Inbox dir doesn't exist or is unreadable — nothing to inject.
424
+ }
425
+
426
+ if (inboxFiles.length > 0) {
427
+ const parts = [];
428
+ for (const file of inboxFiles) {
429
+ const filePath = path.join(inboxDir, file);
430
+ try {
431
+ const raw = await fs.readFile(filePath, "utf8");
432
+ const share = JSON.parse(raw);
433
+ // Skip files targeted at a different project.
434
+ if (share.targetCwd && !cwd.startsWith(share.targetCwd)) {
435
+ continue;
436
+ }
437
+ const sourceLabel = share.sourceLabel || share.sourceTool || "another session";
438
+ const header = `[Shared from ${sourceLabel}]`;
439
+ let body = share.content || "";
440
+ // Append context file references if the content doesn't already include them.
441
+ const ctxFiles = Array.isArray(share.contextFiles) ? share.contextFiles.filter(Boolean) : [];
442
+ if (ctxFiles.length > 0 && !body.includes("Context files")) {
443
+ const fileList = ctxFiles.map((f) => `- ${f}`).join("\n");
444
+ body += `\n\n## Context files\n\nRead the following files for full conversation context:\n${fileList}`;
445
+ }
446
+ parts.push(`${header}\n\n${body}`);
447
+ // Mark as consumed by removing the file.
448
+ await fs.unlink(filePath);
449
+ } catch {
450
+ // Skip malformed or unreadable files.
451
+ }
452
+ }
453
+
454
+ if (parts.length > 0) {
455
+ // Cap at 10,000 chars (Claude Code hook output limit).
456
+ let combined = parts.join("\n\n---\n\n");
457
+ if (combined.length > 9800) {
458
+ combined = combined.slice(0, 9800) + "\n\n… (truncated)";
459
+ }
460
+ process.stdout.write(
461
+ JSON.stringify({
462
+ hookSpecificOutput: {
463
+ hookEventName: "UserPromptSubmit",
464
+ additionalContext: combined,
465
+ },
466
+ }) + "\n"
467
+ );
468
+ }
469
+ }
470
+
471
+ // Forward event to bridge as usual.
472
+ await postEvent("UserPromptSubmit", { sessionId, toolName, cwd }, DEFAULT_EVENT_TIMEOUT_MS);
473
+ }
474
+
402
475
  // ---------------------------------------------------------------------------
403
476
  // Diff computation
404
477
  // ---------------------------------------------------------------------------
@@ -82,6 +82,9 @@ async function main(cliOptions) {
82
82
  case "doctor":
83
83
  await runDoctor(cliOptions);
84
84
  return;
85
+ case "update":
86
+ await runUpdate(cliOptions);
87
+ return;
85
88
  case "help":
86
89
  default:
87
90
  printHelp();
@@ -846,6 +849,67 @@ async function runDoctor(cliOptions) {
846
849
  }
847
850
  }
848
851
 
852
+ async function runUpdate(cliOptions) {
853
+ const locale = await resolveCliLocale(cliOptions);
854
+ const progress = createCliProgressReporter(locale);
855
+
856
+ // 1. Check current vs latest version
857
+ progress.update("cli.update.progress.checkVersion");
858
+ const pkg = JSON.parse(await fs.readFile(path.join(packageRoot, "package.json"), "utf8"));
859
+ const currentVersion = pkg.version;
860
+ const { stdout: latestRaw } = await execCommand(["npm", "view", "viveworker", "version"], { ignoreError: true });
861
+ const latestVersion = (latestRaw || "").trim();
862
+
863
+ if (!latestVersion) {
864
+ progress.done("cli.update.checkFailed");
865
+ return;
866
+ }
867
+
868
+ if (currentVersion === latestVersion) {
869
+ progress.done("cli.update.alreadyLatest", { version: currentVersion });
870
+ return;
871
+ }
872
+
873
+ console.log(t(locale, "cli.update.versionInfo", { current: currentVersion, latest: latestVersion }));
874
+
875
+ // 2. Clear npx cache and re-fetch
876
+ progress.update("cli.update.progress.install");
877
+ const npxCacheResult = await execCommand(["npx", "--yes", "viveworker@latest", "--version"], { ignoreError: true });
878
+ if (!npxCacheResult.ok) {
879
+ // Fallback: try global install
880
+ await execCommand(["npm", "install", "-g", "viveworker@latest"], { ignoreError: true });
881
+ }
882
+
883
+ // 3. Restart bridge
884
+ const launchAgentPath = resolvePath(cliOptions.launchAgentPath || defaultLaunchAgentPath);
885
+ if (await fileExists(launchAgentPath)) {
886
+ progress.update("cli.update.progress.restartBridge");
887
+ await execCommand(["launchctl", "bootout", `gui/${process.getuid()}`, launchAgentPath], { ignoreError: true });
888
+ await execCommand(["launchctl", "bootstrap", `gui/${process.getuid()}`, launchAgentPath], { ignoreError: true });
889
+ await execCommand(["launchctl", "kickstart", "-k", `gui/${process.getuid()}/${defaultLabel}`], { ignoreError: true });
890
+ }
891
+
892
+ // 4. Restart moltbook-watcher if present
893
+ const watcherLabel = "com.viveworker.moltbook-watcher";
894
+ const watcherPlistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${watcherLabel}.plist`);
895
+ if (await fileExists(watcherPlistPath)) {
896
+ progress.update("cli.update.progress.restartWatcher");
897
+ await execCommand(["launchctl", "bootout", `gui/${process.getuid()}`, watcherPlistPath], { ignoreError: true });
898
+ await execCommand(["launchctl", "bootstrap", `gui/${process.getuid()}`, watcherPlistPath], { ignoreError: true });
899
+ }
900
+
901
+ // 5. Restart moltbook-scout if present
902
+ const scoutLabel = "com.viveworker.moltbook-scout";
903
+ const scoutPlistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${scoutLabel}.plist`);
904
+ if (await fileExists(scoutPlistPath)) {
905
+ progress.update("cli.update.progress.restartScout");
906
+ await execCommand(["launchctl", "bootout", `gui/${process.getuid()}`, scoutPlistPath], { ignoreError: true });
907
+ await execCommand(["launchctl", "bootstrap", `gui/${process.getuid()}`, scoutPlistPath], { ignoreError: true });
908
+ }
909
+
910
+ progress.done("cli.update.done", { version: latestVersion });
911
+ }
912
+
849
913
  function parseArgs(argv) {
850
914
  const parsed = {
851
915
  command: "help",
@@ -1019,6 +1083,7 @@ ${t(locale, "cli.help.commands")}
1019
1083
  ${t(locale, "cli.help.stop")}
1020
1084
  ${t(locale, "cli.help.status")}
1021
1085
  ${t(locale, "cli.help.doctor")}
1086
+ ${t(locale, "cli.help.update")}
1022
1087
 
1023
1088
  ${t(locale, "cli.help.commonOptions")}
1024
1089
  --port <n>
package/web/app.js CHANGED
@@ -48,6 +48,7 @@ const state = {
48
48
  pairNotice: "",
49
49
  pushStatus: null,
50
50
  moltbookScoutStatus: null,
51
+ a2aRelayStatus: null,
51
52
  pushNotice: "",
52
53
  pushError: "",
53
54
  deviceNotice: "",
@@ -214,6 +215,7 @@ async function refreshAuthenticatedState() {
214
215
  await refreshDevices();
215
216
  await refreshPushStatus();
216
217
  await fetchMoltbookScoutStatus();
218
+ await fetchA2aRelayStatus();
217
219
  ensureCurrentSelection();
218
220
  }
219
221
 
@@ -331,6 +333,18 @@ async function fetchMoltbookScoutStatus() {
331
333
  }
332
334
  }
333
335
 
336
+ async function fetchA2aRelayStatus() {
337
+ if (!state.session?.a2aRelayEnabled) {
338
+ state.a2aRelayStatus = null;
339
+ return;
340
+ }
341
+ try {
342
+ state.a2aRelayStatus = await apiGet("/api/a2a/relay-status");
343
+ } catch {
344
+ state.a2aRelayStatus = null;
345
+ }
346
+ }
347
+
334
348
  async function getClientPushState() {
335
349
  const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready.catch(() => null));
336
350
  if (registration) {
@@ -979,6 +993,9 @@ function shouldDeferRenderForActiveInteraction() {
979
993
  if (state.currentDetail?.kind === "moltbook_draft") {
980
994
  return true;
981
995
  }
996
+ if (state.currentDetail?.kind === "thread_share" && state.currentDetail?.threadShareEnabled) {
997
+ return true;
998
+ }
982
999
  if (
983
1000
  activeElement instanceof HTMLTextAreaElement &&
984
1001
  activeElement.matches("[data-claude-question-note]")
@@ -2754,6 +2771,7 @@ function buildSettingsContext() {
2754
2771
  serverEnabled,
2755
2772
  }),
2756
2773
  moltbookScout: state.moltbookScoutStatus,
2774
+ a2aRelay: state.a2aRelayStatus,
2757
2775
  };
2758
2776
  }
2759
2777
 
@@ -2907,6 +2925,13 @@ function settingsPageMeta(page) {
2907
2925
  description: L("settings.moltbook.copy"),
2908
2926
  icon: "item",
2909
2927
  };
2928
+ case "a2aRelay":
2929
+ return {
2930
+ id: "a2aRelay",
2931
+ title: L("settings.a2aRelay.title"),
2932
+ description: L("settings.a2aRelay.copy"),
2933
+ icon: "link",
2934
+ };
2910
2935
  default:
2911
2936
  return settingsPageMeta("notifications");
2912
2937
  }
@@ -2976,15 +3001,22 @@ function renderSettingsRoot(context, { mobile }) {
2976
3001
  `
2977
3002
  }
2978
3003
  ${renderSettingsGroup(L("settings.group.general"), generalRows)}
2979
- ${state.session?.moltbookEnabled ? renderSettingsGroup(L("common.sns"), [
2980
- renderSettingsNavRow({
3004
+ ${(state.session?.moltbookEnabled || state.session?.a2aRelayEnabled) ? renderSettingsGroup(L("settings.group.integrations"), [
3005
+ state.session?.moltbookEnabled ? renderSettingsNavRow({
2981
3006
  page: "moltbook",
2982
3007
  icon: "item",
2983
3008
  title: L("settings.moltbook.title"),
2984
3009
  subtitle: L("settings.moltbook.subtitle"),
2985
3010
  value: context.moltbookScout?.enabled ? `${context.moltbookScout.sentToday} / ${context.moltbookScout.maxDaily}` : "",
2986
- }),
2987
- ]) : ""}
3011
+ }) : "",
3012
+ state.session?.a2aRelayEnabled ? renderSettingsNavRow({
3013
+ page: "a2aRelay",
3014
+ icon: "link",
3015
+ title: L("settings.a2aRelay.title"),
3016
+ subtitle: L("settings.a2aRelay.subtitle"),
3017
+ value: context.a2aRelay?.connected ? L("settings.status.connected") : L("settings.a2aRelay.status.disconnected"),
3018
+ }) : "",
3019
+ ].filter(Boolean)) : ""}
2988
3020
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
2989
3021
  ${renderSettingsGroup(L("settings.group.advanced"), advancedRows)}
2990
3022
  </div>
@@ -3031,6 +3063,9 @@ function renderSettingsSubpage(context, { mobile }) {
3031
3063
  case "moltbook":
3032
3064
  content = renderSettingsMoltbookPage(context);
3033
3065
  break;
3066
+ case "a2aRelay":
3067
+ content = renderSettingsA2aRelayPage(context);
3068
+ break;
3034
3069
  default:
3035
3070
  content = "";
3036
3071
  }
@@ -3282,6 +3317,38 @@ function renderSettingsMoltbookPage(context) {
3282
3317
  `;
3283
3318
  }
3284
3319
 
3320
+ function renderSettingsA2aRelayPage(context) {
3321
+ const relay = context.a2aRelay;
3322
+ if (!relay?.enabled) {
3323
+ return `
3324
+ <div class="settings-page">
3325
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.a2aRelay.unavailable"))}</p>
3326
+ </div>
3327
+ `;
3328
+ }
3329
+ const statusLabel = relay.connected
3330
+ ? L("settings.status.connected")
3331
+ : relay.polling
3332
+ ? L("settings.a2aRelay.status.polling")
3333
+ : L("settings.a2aRelay.status.disconnected");
3334
+ const profileUrl = `${relay.relayUrl}/${relay.userId}`;
3335
+ const userIdLink = `<a href="${escapeHtml(profileUrl)}" target="_blank" rel="noopener">${escapeHtml(relay.userId)}</a>`;
3336
+ const relayHost = (() => { try { return new URL(relay.relayUrl).host; } catch { return relay.relayUrl; } })();
3337
+ return `
3338
+ <div class="settings-page">
3339
+ ${renderSettingsGroup("", [
3340
+ renderSettingsInfoRow(L("settings.row.a2aStatus"), statusLabel),
3341
+ renderSettingsInfoRow(L("settings.row.a2aUserId"), userIdLink, { rawValue: true }),
3342
+ renderSettingsInfoRow(L("settings.row.a2aRelay"), relayHost),
3343
+ renderSettingsInfoRow(L("settings.row.a2aApiKey"), relay.apiKeyConfigured ? L("settings.a2aRelay.apiKey.configured") : L("settings.a2aRelay.apiKey.notConfigured")),
3344
+ relay.lastPollAtMs
3345
+ ? renderSettingsInfoRow(L("settings.row.a2aLastPoll"), new Date(relay.lastPollAtMs).toLocaleString(state.locale))
3346
+ : "",
3347
+ ].filter(Boolean))}
3348
+ </div>
3349
+ `;
3350
+ }
3351
+
3285
3352
  function renderSettingsInfoRow(label, value, options = {}) {
3286
3353
  const rowClassName = ["settings-info-row", options.rowClassName || ""].filter(Boolean).join(" ");
3287
3354
  const valueClassName = ["settings-info-row__value", options.valueClassName || ""].filter(Boolean).join(" ");
@@ -3395,14 +3462,15 @@ function renderStandardDetailDesktop(detail) {
3395
3462
  <div class="detail-shell">
3396
3463
  ${renderDetailMetaRow(detail, kindInfo)}
3397
3464
  <h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
3398
- ${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" ? "" : renderDetailLead(detail, kindInfo)}
3465
+ ${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "thread_share" ? "" : renderDetailLead(detail, kindInfo)}
3399
3466
  ${renderPreviousContextCard(detail)}
3400
3467
  ${renderInterruptedDetailNotice(detail)}
3401
3468
  ${renderMoltbookReplyComposer(detail)}
3402
3469
  ${renderMoltbookDraftComposer(detail)}
3403
3470
  ${renderA2ATaskDetail(detail)}
3471
+ ${renderThreadShareDetail(detail)}
3404
3472
  ${
3405
- detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task"
3473
+ detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "thread_share"
3406
3474
  ? ""
3407
3475
  : plainIntro
3408
3476
  ? plainIntro
@@ -3438,8 +3506,9 @@ function renderStandardDetailMobile(detail) {
3438
3506
  ${renderMoltbookReplyComposer(detail, { mobile: true })}
3439
3507
  ${renderMoltbookDraftComposer(detail, { mobile: true })}
3440
3508
  ${renderA2ATaskDetail(detail, { mobile: true })}
3509
+ ${renderThreadShareDetail(detail, { mobile: true })}
3441
3510
  ${
3442
- detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task"
3511
+ detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "thread_share"
3443
3512
  ? ""
3444
3513
  : plainIntro
3445
3514
  ? plainIntro
@@ -4048,6 +4117,56 @@ function renderA2ATaskDetail(detail, options = {}) {
4048
4117
  `;
4049
4118
  }
4050
4119
 
4120
+ function renderThreadShareDetail(detail, options = {}) {
4121
+ if (detail.kind !== "thread_share") return "";
4122
+ const enabled = detail.threadShareEnabled !== false && detail.readOnly !== true;
4123
+ const content = detail.shareContent || "";
4124
+ const fromLabel = detail.sourceLabel || detail.sourceTool || "agent";
4125
+ const toLabel = detail.targetLabel || detail.targetConversationId?.slice(0, 8) || detail.targetTool || "thread";
4126
+ const contextFiles = Array.isArray(detail.contextFiles) ? detail.contextFiles : [];
4127
+ const shareTypeLabel = {
4128
+ plan_review: L("threadShare.type.planReview"),
4129
+ handoff: L("threadShare.type.handoff"),
4130
+ message: L("threadShare.type.message"),
4131
+ }[detail.shareType] || L("threadShare.type.message");
4132
+
4133
+ const contextFilesHtml = contextFiles.length > 0
4134
+ ? `<div class="reply-composer__instruction">
4135
+ <label class="field-label">${escapeHtml(L("threadShare.contextFiles"))}</label>
4136
+ <ul class="context-files-list">${contextFiles.map((f) => `<li class="context-file-item"><code>${escapeHtml(f)}</code></li>`).join("")}</ul>
4137
+ </div>`
4138
+ : "";
4139
+
4140
+ const buttons = enabled
4141
+ ? `
4142
+ <div class="actions actions--stack${options.mobile ? " actions--sticky" : ""}">
4143
+ <button type="submit" data-action="approve" class="primary primary--wide">${escapeHtml(L("threadShare.approve"))}</button>
4144
+ <button type="submit" data-action="deny" class="danger danger--wide">${escapeHtml(L("threadShare.deny"))}</button>
4145
+ </div>
4146
+ `
4147
+ : `<p class="muted reply-composer__description">${escapeHtml(L("threadShare.resolved"))}</p>`;
4148
+ const buttonsWrapped = enabled && options.mobile ? `<div class="detail-action-bar">${buttons}</div>` : buttons;
4149
+
4150
+ return `
4151
+ <section class="detail-card detail-card--reply ${options.mobile ? "detail-card--mobile" : ""}">
4152
+ <form class="reply-composer" data-thread-share-form data-token="${escapeHtml(detail.token || "")}">
4153
+ <div class="reply-composer__copy">
4154
+ <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("threadShare.eyebrow"))}</span>
4155
+ <span class="eyebrow-pill eyebrow-pill--subtle">${escapeHtml(shareTypeLabel)}</span>
4156
+ <p class="reply-composer__author-meta muted">${escapeHtml(fromLabel)} → ${escapeHtml(toLabel)}</p>
4157
+ <p class="muted reply-composer__description">${enabled ? escapeHtml(L("threadShare.editHint")) : ""}</p>
4158
+ </div>
4159
+ ${contextFilesHtml}
4160
+ <div class="reply-composer__instruction">
4161
+ <label class="field-label">${escapeHtml(L("threadShare.content"))}</label>
4162
+ <textarea name="shareContent" class="reply-composer__textarea" rows="12" ${enabled ? "" : "readonly"}>${escapeHtml(content)}</textarea>
4163
+ </div>
4164
+ ${buttonsWrapped}
4165
+ </form>
4166
+ </section>
4167
+ `;
4168
+ }
4169
+
4051
4170
  function renderCompletionReplyComposer(detail, options = {}) {
4052
4171
  if (detail.kind !== "completion" || detail.reply?.enabled !== true) {
4053
4172
  return "";
@@ -5241,6 +5360,72 @@ function bindShellInteractions() {
5241
5360
  });
5242
5361
  }
5243
5362
 
5363
+ const threadShareForm = document.querySelector("[data-thread-share-form]");
5364
+ if (threadShareForm) {
5365
+ const token = threadShareForm.dataset.token || "";
5366
+ let submittedAction = "approve";
5367
+ threadShareForm.querySelectorAll("button[type='submit']").forEach((btn) => {
5368
+ btn.addEventListener("click", () => {
5369
+ submittedAction = btn.dataset.action || "approve";
5370
+ });
5371
+ });
5372
+ threadShareForm.addEventListener("submit", async (event) => {
5373
+ event.preventDefault();
5374
+ if (threadShareForm.dataset.submitting === "1") return;
5375
+ threadShareForm.dataset.submitting = "1";
5376
+ const buttons = threadShareForm.querySelectorAll("button[type='submit']");
5377
+ const textarea = threadShareForm.querySelector("textarea");
5378
+ const labelCache = new Map();
5379
+ buttons.forEach((btn) => {
5380
+ labelCache.set(btn, btn.innerHTML);
5381
+ btn.disabled = true;
5382
+ if (btn.dataset.action === submittedAction) {
5383
+ btn.innerHTML = submittedAction === "approve" ? "Sharing…" : "Denying…";
5384
+ btn.classList.add("is-loading");
5385
+ } else {
5386
+ btn.classList.add("is-dimmed");
5387
+ }
5388
+ });
5389
+ if (textarea) textarea.readOnly = true;
5390
+ const editedContent = normalizeClientText(new FormData(threadShareForm).get("shareContent"));
5391
+ try {
5392
+ const res = await fetch(`/api/threads/share/${encodeURIComponent(token)}/decision`, {
5393
+ method: "POST",
5394
+ headers: { "content-type": "application/json" },
5395
+ credentials: "same-origin",
5396
+ body: JSON.stringify({ decision: submittedAction, editedContent }),
5397
+ });
5398
+ if (!res.ok) {
5399
+ const errBody = await res.json().catch(() => ({}));
5400
+ alert(`Thread share ${submittedAction} failed: ${errBody.error || res.status}`);
5401
+ buttons.forEach((btn) => {
5402
+ btn.disabled = false;
5403
+ btn.classList.remove("is-loading", "is-dimmed");
5404
+ btn.innerHTML = labelCache.get(btn);
5405
+ });
5406
+ if (textarea) textarea.readOnly = false;
5407
+ threadShareForm.dataset.submitting = "";
5408
+ return;
5409
+ }
5410
+ if (state.currentDetail?.kind === "thread_share") {
5411
+ state.currentDetail.threadShareEnabled = false;
5412
+ state.currentDetail.readOnly = true;
5413
+ }
5414
+ await refreshAuthenticatedState();
5415
+ await renderShell();
5416
+ } catch (error) {
5417
+ alert(`Thread share error: ${error.message}`);
5418
+ buttons.forEach((btn) => {
5419
+ btn.disabled = false;
5420
+ btn.classList.remove("is-loading", "is-dimmed");
5421
+ btn.innerHTML = labelCache.get(btn);
5422
+ });
5423
+ if (textarea) textarea.readOnly = false;
5424
+ threadShareForm.dataset.submitting = "";
5425
+ }
5426
+ });
5427
+ }
5428
+
5244
5429
  const replyForm = document.querySelector("[data-completion-reply-form]");
5245
5430
  if (replyForm) {
5246
5431
  const token = replyForm.dataset.token || "";
@@ -5722,6 +5907,8 @@ function kindMeta(kind) {
5722
5907
  case "moltbook_reply":
5723
5908
  case "moltbook_draft":
5724
5909
  return { label: L("common.sns"), tone: "neutral", icon: "item" };
5910
+ case "thread_share":
5911
+ return { label: L("common.threadShare"), tone: "neutral", icon: "link" };
5725
5912
  default:
5726
5913
  return { label: L("common.item"), tone: "neutral", icon: "item" };
5727
5914
  }
package/web/i18n.js CHANGED
@@ -49,6 +49,7 @@ const translations = {
49
49
  "common.assistantFinal": "Final reply",
50
50
  "common.item": "Item",
51
51
  "common.sns": "SNS",
52
+ "common.threadShare": "Thread Share",
52
53
  "common.untitledItem": "Untitled item",
53
54
  "common.useDeviceLanguage": "Use device language",
54
55
  "language.option.en": "English",
@@ -373,6 +374,20 @@ const translations = {
373
374
  "moltbook.draft.greetNoon": "Nice progress! Ready to share your morning work?",
374
375
  "moltbook.draft.greetEvening": "Great work today! Let's share what you built.",
375
376
  "moltbook.draft.greetFallback": "New post ready for review",
377
+ "settings.group.integrations": "Integrations",
378
+ "settings.a2aRelay.title": "A2A Relay",
379
+ "settings.a2aRelay.subtitle": "Agent-to-Agent relay connection",
380
+ "settings.a2aRelay.copy": "View your A2A relay connection status and configuration.",
381
+ "settings.a2aRelay.unavailable": "A2A Relay is not configured.",
382
+ "settings.a2aRelay.status.disconnected": "Disconnected",
383
+ "settings.a2aRelay.status.polling": "Connecting...",
384
+ "settings.a2aRelay.apiKey.configured": "Configured",
385
+ "settings.a2aRelay.apiKey.notConfigured": "Not configured",
386
+ "settings.row.a2aStatus": "Status",
387
+ "settings.row.a2aUserId": "User ID",
388
+ "settings.row.a2aRelay": "Relay",
389
+ "settings.row.a2aApiKey": "API Key",
390
+ "settings.row.a2aLastPoll": "Last poll",
376
391
  "a2a.task.eyebrow": "A2A Task Request",
377
392
  "a2a.task.from": "From",
378
393
  "a2a.task.instruction": "Task",
@@ -386,6 +401,16 @@ const translations = {
386
401
  "a2a.task.statusFailed": "Failed",
387
402
  "a2a.task.statusCanceled": "Canceled",
388
403
  "a2a.task.statusRejected": "Rejected",
404
+ "threadShare.eyebrow": "Thread Share",
405
+ "threadShare.content": "Shared content",
406
+ "threadShare.approve": "Approve & Share",
407
+ "threadShare.deny": "Deny",
408
+ "threadShare.editHint": "Review the content below. Edit if needed, then approve to share or deny to skip.",
409
+ "threadShare.resolved": "This share request has been resolved.",
410
+ "threadShare.contextFiles": "Context files (read by recipient)",
411
+ "threadShare.type.message": "Message",
412
+ "threadShare.type.planReview": "Plan Review",
413
+ "threadShare.type.handoff": "Handoff",
389
414
  "notice.notificationsEnabled": "Notifications are enabled on this device.",
390
415
  "notice.notificationsDisabled": "Notifications are disabled on this device.",
391
416
  "notice.testNotificationSent": "Test notification sent.",
@@ -485,13 +510,23 @@ const translations = {
485
510
  "server.page.choiceMissing": "Choice input token not found",
486
511
  "server.page.approvalHandled": "Approval already handled",
487
512
  "server.page.detailMissing": "Completion detail not found",
488
- "cli.help.usage": "Usage: viveworker <setup|start|stop|status|doctor> [options]",
513
+ "cli.help.usage": "Usage: viveworker <setup|start|stop|status|doctor|update> [options]",
489
514
  "cli.help.commands": "Commands:",
490
515
  "cli.help.setup": "setup Create config, generate pairing info, register launchd, start the bridge",
491
516
  "cli.help.start": "start Start the bridge using the saved config",
492
517
  "cli.help.stop": "stop Stop the bridge",
493
518
  "cli.help.status": "status Print launchd/background status and health",
494
519
  "cli.help.doctor": "doctor Diagnose the local setup",
520
+ "cli.help.update": "update Update to the latest version and restart all services",
521
+ "cli.update.progress.checkVersion": "Checking for updates...",
522
+ "cli.update.progress.install": "Installing latest version...",
523
+ "cli.update.progress.restartBridge": "Restarting bridge...",
524
+ "cli.update.progress.restartWatcher": "Restarting Moltbook watcher...",
525
+ "cli.update.progress.restartScout": "Restarting Moltbook scout...",
526
+ "cli.update.checkFailed": "Could not check the latest version from npm",
527
+ "cli.update.alreadyLatest": "Already up to date (v{version})",
528
+ "cli.update.versionInfo": "Updating v{current} -> v{latest}",
529
+ "cli.update.done": "Updated to v{version} — all services restarted",
495
530
  "cli.help.commonOptions": "Common options:",
496
531
  "cli.setup.complete": "viveworker setup complete",
497
532
  "cli.setup.progress.prepare": "Preparing local viveworker setup...",
@@ -642,6 +677,7 @@ const translations = {
642
677
  "common.assistantFinal": "最終回答",
643
678
  "common.item": "項目",
644
679
  "common.sns": "SNS",
680
+ "common.threadShare": "スレッド共有",
645
681
  "common.untitledItem": "無題の項目",
646
682
  "common.useDeviceLanguage": "端末の言語を使う",
647
683
  "language.option.en": "English",
@@ -962,6 +998,20 @@ const translations = {
962
998
  "moltbook.draft.greetNoon": "午前中お疲れ様でした!進捗をシェアしませんか?",
963
999
  "moltbook.draft.greetEvening": "今日もお疲れ様でした!成果をシェアしませんか?",
964
1000
  "moltbook.draft.greetFallback": "投稿ドラフトができました",
1001
+ "settings.group.integrations": "連携サービス",
1002
+ "settings.a2aRelay.title": "A2A Relay",
1003
+ "settings.a2aRelay.subtitle": "Agent-to-Agent リレー接続",
1004
+ "settings.a2aRelay.copy": "A2A リレーの接続状態と設定を確認できます。",
1005
+ "settings.a2aRelay.unavailable": "A2A Relay が設定されていません。",
1006
+ "settings.a2aRelay.status.disconnected": "切断",
1007
+ "settings.a2aRelay.status.polling": "接続中...",
1008
+ "settings.a2aRelay.apiKey.configured": "設定済み",
1009
+ "settings.a2aRelay.apiKey.notConfigured": "未設定",
1010
+ "settings.row.a2aStatus": "ステータス",
1011
+ "settings.row.a2aUserId": "ユーザー ID",
1012
+ "settings.row.a2aRelay": "リレー",
1013
+ "settings.row.a2aApiKey": "API キー",
1014
+ "settings.row.a2aLastPoll": "最終ポーリング",
965
1015
  "a2a.task.eyebrow": "A2A タスクリクエスト",
966
1016
  "a2a.task.from": "送信元",
967
1017
  "a2a.task.instruction": "タスク内容",
@@ -975,6 +1025,16 @@ const translations = {
975
1025
  "a2a.task.statusFailed": "失敗",
976
1026
  "a2a.task.statusCanceled": "キャンセル",
977
1027
  "a2a.task.statusRejected": "拒否済み",
1028
+ "threadShare.eyebrow": "スレッド共有",
1029
+ "threadShare.content": "共有内容",
1030
+ "threadShare.approve": "承認して共有",
1031
+ "threadShare.deny": "拒否",
1032
+ "threadShare.editHint": "内容を確認してください。必要に応じて編集し、承認で共有、拒否でスキップします。",
1033
+ "threadShare.resolved": "この共有リクエストは処理済みです。",
1034
+ "threadShare.contextFiles": "コンテキストファイル(受信側が読み込みます)",
1035
+ "threadShare.type.message": "メッセージ",
1036
+ "threadShare.type.planReview": "プランレビュー",
1037
+ "threadShare.type.handoff": "引き継ぎ",
978
1038
  "notice.notificationsEnabled": "この端末で通知を有効にしました。",
979
1039
  "notice.notificationsDisabled": "この端末の通知を無効にしました。",
980
1040
  "notice.testNotificationSent": "テスト通知を送信しました。",
@@ -1074,13 +1134,23 @@ const translations = {
1074
1134
  "server.page.choiceMissing": "選択入力トークンが見つかりません",
1075
1135
  "server.page.approvalHandled": "この承認はすでに処理済みです",
1076
1136
  "server.page.detailMissing": "完了詳細が見つかりません",
1077
- "cli.help.usage": "Usage: viveworker <setup|start|stop|status|doctor> [options]",
1137
+ "cli.help.usage": "Usage: viveworker <setup|start|stop|status|doctor|update> [options]",
1078
1138
  "cli.help.commands": "Commands:",
1079
1139
  "cli.help.setup": "setup 設定を作成し、ペアリング情報を生成し、launchd に登録して bridge を起動します",
1080
1140
  "cli.help.start": "start 保存済み設定で bridge を起動します",
1081
1141
  "cli.help.stop": "stop bridge を停止します",
1082
1142
  "cli.help.status": "status launchd / バックグラウンド状態と health を表示します",
1083
1143
  "cli.help.doctor": "doctor ローカル設定を診断します",
1144
+ "cli.help.update": "update 最新バージョンに更新し、全サービスを再起動します",
1145
+ "cli.update.progress.checkVersion": "更新を確認しています...",
1146
+ "cli.update.progress.install": "最新バージョンをインストールしています...",
1147
+ "cli.update.progress.restartBridge": "bridge を再起動しています...",
1148
+ "cli.update.progress.restartWatcher": "Moltbook watcher を再起動しています...",
1149
+ "cli.update.progress.restartScout": "Moltbook scout を再起動しています...",
1150
+ "cli.update.checkFailed": "npm から最新バージョンを確認できませんでした",
1151
+ "cli.update.alreadyLatest": "すでに最新です (v{version})",
1152
+ "cli.update.versionInfo": "v{current} -> v{latest} に更新します",
1153
+ "cli.update.done": "v{version} に更新しました — 全サービスを再起動しました",
1084
1154
  "cli.help.commonOptions": "共通オプション:",
1085
1155
  "cli.setup.complete": "viveworker の setup が完了しました",
1086
1156
  "cli.setup.progress.prepare": "viveworker のローカル設定を準備しています...",