viveworker 0.4.6 → 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.6",
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",
@@ -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
  }
@@ -11149,6 +11220,318 @@ function createNativeApprovalServer({ config, runtime, state }) {
11149
11220
  });
11150
11221
  }
11151
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
+
11152
11535
  // Activity summary for compose (original post) drafting.
11153
11536
  // Supports ?slot=morning|noon|evening and ?date=YYYY-MM-DD for
11154
11537
  // time-slot-based compose. Defaults to full-day today.
@@ -11432,6 +11815,27 @@ function createNativeApprovalServer({ config, runtime, state }) {
11432
11815
  return writeJson(res, 200, {});
11433
11816
  }
11434
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
+
11435
11839
  if (eventType !== "approval_request") {
11436
11840
  // Non-approval events (Notification, Stop, etc.) — acknowledge immediately
11437
11841
  return writeJson(res, 200, {});
@@ -13828,6 +14232,7 @@ function buildConfig(cli) {
13828
14232
  historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
13829
14233
  codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
13830
14234
  stateFile,
14235
+ threadRegistryFile: resolvePath(process.env.THREAD_REGISTRY_FILE || path.join(os.homedir(), ".viveworker", "thread-registry.json")),
13831
14236
  replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
13832
14237
  timelineAttachmentsDir: resolvePath(
13833
14238
  process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
@@ -14140,6 +14545,59 @@ async function saveState(stateFile, state) {
14140
14545
  await fs.writeFile(stateFile, `${output}\n`, "utf8");
14141
14546
  }
14142
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
+
14143
14601
  async function loadSessionIndex(filePath) {
14144
14602
  const result = new Map();
14145
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
  // ---------------------------------------------------------------------------
package/web/app.js CHANGED
@@ -993,6 +993,9 @@ function shouldDeferRenderForActiveInteraction() {
993
993
  if (state.currentDetail?.kind === "moltbook_draft") {
994
994
  return true;
995
995
  }
996
+ if (state.currentDetail?.kind === "thread_share" && state.currentDetail?.threadShareEnabled) {
997
+ return true;
998
+ }
996
999
  if (
997
1000
  activeElement instanceof HTMLTextAreaElement &&
998
1001
  activeElement.matches("[data-claude-question-note]")
@@ -3459,14 +3462,15 @@ function renderStandardDetailDesktop(detail) {
3459
3462
  <div class="detail-shell">
3460
3463
  ${renderDetailMetaRow(detail, kindInfo)}
3461
3464
  <h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
3462
- ${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)}
3463
3466
  ${renderPreviousContextCard(detail)}
3464
3467
  ${renderInterruptedDetailNotice(detail)}
3465
3468
  ${renderMoltbookReplyComposer(detail)}
3466
3469
  ${renderMoltbookDraftComposer(detail)}
3467
3470
  ${renderA2ATaskDetail(detail)}
3471
+ ${renderThreadShareDetail(detail)}
3468
3472
  ${
3469
- 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"
3470
3474
  ? ""
3471
3475
  : plainIntro
3472
3476
  ? plainIntro
@@ -3502,8 +3506,9 @@ function renderStandardDetailMobile(detail) {
3502
3506
  ${renderMoltbookReplyComposer(detail, { mobile: true })}
3503
3507
  ${renderMoltbookDraftComposer(detail, { mobile: true })}
3504
3508
  ${renderA2ATaskDetail(detail, { mobile: true })}
3509
+ ${renderThreadShareDetail(detail, { mobile: true })}
3505
3510
  ${
3506
- 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"
3507
3512
  ? ""
3508
3513
  : plainIntro
3509
3514
  ? plainIntro
@@ -4112,6 +4117,56 @@ function renderA2ATaskDetail(detail, options = {}) {
4112
4117
  `;
4113
4118
  }
4114
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
+
4115
4170
  function renderCompletionReplyComposer(detail, options = {}) {
4116
4171
  if (detail.kind !== "completion" || detail.reply?.enabled !== true) {
4117
4172
  return "";
@@ -5305,6 +5360,72 @@ function bindShellInteractions() {
5305
5360
  });
5306
5361
  }
5307
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
+
5308
5429
  const replyForm = document.querySelector("[data-completion-reply-form]");
5309
5430
  if (replyForm) {
5310
5431
  const token = replyForm.dataset.token || "";
@@ -5786,6 +5907,8 @@ function kindMeta(kind) {
5786
5907
  case "moltbook_reply":
5787
5908
  case "moltbook_draft":
5788
5909
  return { label: L("common.sns"), tone: "neutral", icon: "item" };
5910
+ case "thread_share":
5911
+ return { label: L("common.threadShare"), tone: "neutral", icon: "link" };
5789
5912
  default:
5790
5913
  return { label: L("common.item"), tone: "neutral", icon: "item" };
5791
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",
@@ -400,6 +401,16 @@ const translations = {
400
401
  "a2a.task.statusFailed": "Failed",
401
402
  "a2a.task.statusCanceled": "Canceled",
402
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",
403
414
  "notice.notificationsEnabled": "Notifications are enabled on this device.",
404
415
  "notice.notificationsDisabled": "Notifications are disabled on this device.",
405
416
  "notice.testNotificationSent": "Test notification sent.",
@@ -666,6 +677,7 @@ const translations = {
666
677
  "common.assistantFinal": "最終回答",
667
678
  "common.item": "項目",
668
679
  "common.sns": "SNS",
680
+ "common.threadShare": "スレッド共有",
669
681
  "common.untitledItem": "無題の項目",
670
682
  "common.useDeviceLanguage": "端末の言語を使う",
671
683
  "language.option.en": "English",
@@ -1013,6 +1025,16 @@ const translations = {
1013
1025
  "a2a.task.statusFailed": "失敗",
1014
1026
  "a2a.task.statusCanceled": "キャンセル",
1015
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": "引き継ぎ",
1016
1038
  "notice.notificationsEnabled": "この端末で通知を有効にしました。",
1017
1039
  "notice.notificationsDisabled": "この端末の通知を無効にしました。",
1018
1040
  "notice.testNotificationSent": "テスト通知を送信しました。",