viveworker 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ const sessionCookieName = "viveworker_session";
26
26
  const deviceCookieName = "viveworker_device";
27
27
  const historyKinds = new Set(["completion", "plan_ready", "approval", "plan", "choice", "info"]);
28
28
  const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
29
- const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event"]);
29
+ const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft"]);
30
30
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
31
31
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
32
32
  const MAX_PAIRED_DEVICES = 200;
@@ -79,6 +79,8 @@ const runtime = {
79
79
  userInputRequestsByToken: new Map(),
80
80
  userInputRequestsByRequestKey: new Map(),
81
81
  completionDetailsByToken: new Map(),
82
+ moltbookItemsByToken: new Map(),
83
+ moltbookDraftsByToken: new Map(),
82
84
  planDetailsByToken: new Map(),
83
85
  recentHistoryItems: [],
84
86
  recentTimelineEntries: [],
@@ -212,6 +214,7 @@ function buildSessionLocalePayload(config, state, deviceId) {
212
214
  deviceDetectedLocale: resolved.detectedLocale || null,
213
215
  deviceOverrideLocale: resolved.overrideLocale || null,
214
216
  claudeAwayMode: state?.claudeAwayMode === true,
217
+ moltbookEnabled: Boolean(config.moltbookApiKey),
215
218
  };
216
219
  }
217
220
 
@@ -1989,11 +1992,16 @@ function handleSignal() {
1989
1992
 
1990
1993
  function normalizeProvider(value) {
1991
1994
  const normalized = String(value || "").toLowerCase();
1992
- return normalized === "claude" ? "claude" : "codex";
1995
+ if (normalized === "claude") return "claude";
1996
+ if (normalized === "moltbook") return "moltbook";
1997
+ return "codex";
1993
1998
  }
1994
1999
 
1995
2000
  function providerDisplayName(locale, provider) {
1996
- return t(locale, normalizeProvider(provider) === "claude" ? "common.claude" : "common.codex");
2001
+ const p = normalizeProvider(provider);
2002
+ if (p === "claude") return t(locale, "common.claude");
2003
+ if (p === "moltbook") return "Moltbook";
2004
+ return t(locale, "common.codex");
1997
2005
  }
1998
2006
 
1999
2007
  function normalizeHistoryItems(rawItems, maxItems) {
@@ -2174,14 +2182,18 @@ function normalizeTimelineEntry(raw) {
2174
2182
  formatNotificationBody(messageText, 180) ||
2175
2183
  (kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
2176
2184
  "";
2177
- const threadLabel = preferTitleOnlyJsonThreadLabel(
2178
- cleanText(raw.threadLabel ?? ""),
2179
- threadId,
2180
- raw.messageText,
2181
- raw.summary,
2182
- raw.detailText,
2183
- raw.message
2184
- );
2185
+ const rawProvider = normalizeProvider(raw.provider);
2186
+ const threadLabel =
2187
+ rawProvider === "moltbook"
2188
+ ? cleanText(raw.threadLabel ?? "")
2189
+ : preferTitleOnlyJsonThreadLabel(
2190
+ cleanText(raw.threadLabel ?? ""),
2191
+ threadId,
2192
+ raw.messageText,
2193
+ raw.summary,
2194
+ raw.detailText,
2195
+ raw.message
2196
+ );
2185
2197
  const title =
2186
2198
  cleanText(raw.title ?? "") ||
2187
2199
  (kind === "file_event" ? fileEventTitle(DEFAULT_LOCALE, fileEventType) : "") ||
@@ -2216,6 +2228,19 @@ function normalizeTimelineEntry(raw) {
2216
2228
  tone: cleanText(raw.tone ?? "") || "secondary",
2217
2229
  cwd: resolvePath(cleanText(raw.cwd || "")),
2218
2230
  provider: normalizeProvider(raw.provider),
2231
+ // Moltbook-specific fields — preserved for detail view fallback after
2232
+ // the in-memory draft/item expires.
2233
+ ...(raw.draftText != null ? { draftText: cleanText(raw.draftText) } : {}),
2234
+ ...(raw.intent != null ? { intent: cleanText(raw.intent) } : {}),
2235
+ ...(raw.postId != null ? { postId: cleanText(raw.postId) } : {}),
2236
+ ...(raw.postUrl != null ? { postUrl: cleanText(raw.postUrl) } : {}),
2237
+ ...(raw.postTitle != null ? { postTitle: cleanText(raw.postTitle) } : {}),
2238
+ ...(raw.postAuthor != null ? { postAuthor: cleanText(raw.postAuthor) } : {}),
2239
+ ...(raw.postBody != null ? { postBody: cleanText(raw.postBody) } : {}),
2240
+ ...(raw.commentAuthor != null ? { commentAuthor: cleanText(raw.commentAuthor) } : {}),
2241
+ ...(raw.contextText != null ? { contextText: cleanText(raw.contextText) } : {}),
2242
+ ...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
2243
+ ...(raw.submoltName != null ? { submoltName: cleanText(raw.submoltName) } : {}),
2219
2244
  };
2220
2245
  }
2221
2246
 
@@ -8982,6 +9007,26 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8982
9007
  });
8983
9008
  }
8984
9009
 
9010
+ for (const draft of runtime.moltbookDraftsByToken.values()) {
9011
+ if (draft.decision) continue;
9012
+ const title = draft.postTitle ? `draft → ${draft.postTitle}` : "Moltbook draft";
9013
+ items.push({
9014
+ kind: "moltbook_draft",
9015
+ token: draft.token,
9016
+ threadId: "moltbook",
9017
+ threadLabel: "Moltbook",
9018
+ title,
9019
+ summary: draft.contextSummary || String(draft.draftText || "").slice(0, 160),
9020
+ primaryLabel: t(locale, "server.action.review"),
9021
+ createdAtMs: Number(draft.createdAtMs) || now,
9022
+ provider: "moltbook",
9023
+ });
9024
+ }
9025
+
9026
+ // Moltbook reply items intentionally do not appear in the unhandled list.
9027
+ // Reply drafting is delegated to Codex/Claude Desktop via the
9028
+ // `viveworker moltbook` CLI, so they live in the timeline only.
9029
+
8985
9030
  return items.sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0));
8986
9031
  }
8987
9032
 
@@ -9301,7 +9346,7 @@ function buildTimelineThreads(entries, config) {
9301
9346
  const byThread = new Map();
9302
9347
  for (const entry of entries) {
9303
9348
  const threadId = cleanText(entry.threadId || "");
9304
- if (!threadId) {
9349
+ if (!threadId || threadId === "moltbook" || threadId.startsWith("draft:") || (entry.kind || "").startsWith("moltbook_")) {
9305
9350
  continue;
9306
9351
  }
9307
9352
  const preferredLabel =
@@ -10414,6 +10459,80 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10414
10459
  return historicalChoice ? buildHistoryDetail(historicalChoice, locale, runtime) : null;
10415
10460
  }
10416
10461
 
10462
+ if (kind === "moltbook_reply") {
10463
+ const item = runtime.moltbookItemsByToken.get(token);
10464
+ // Fall back to the persisted timeline entry so that items which have
10465
+ // been resolved (removed from the pending map) still render their
10466
+ // detail view from history.
10467
+ const entry = item
10468
+ ? null
10469
+ : runtime.recentTimelineEntries.find((e) => e.kind === "moltbook_reply" && e.token === token);
10470
+ const source = item || entry;
10471
+ if (!source) return null;
10472
+ const contextText = item
10473
+ ? item.contextText || item.summary || ""
10474
+ : entry.messageText || entry.summary || "";
10475
+ const contextHtml = escapeHtml(contextText)
10476
+ .split("\n")
10477
+ .map((line) => `<p>${line}</p>`)
10478
+ .join("");
10479
+ return {
10480
+ kind: "moltbook_reply",
10481
+ token,
10482
+ threadId: source.threadId || "",
10483
+ threadLabel: source.threadLabel || "Moltbook",
10484
+ title: source.title || "Moltbook reply",
10485
+ summary: source.summary || "",
10486
+ messageHtml: contextHtml,
10487
+ postUrl: source.postUrl || "",
10488
+ commentAuthor: source.commentAuthor || "",
10489
+ provider: "moltbook",
10490
+ draftReply: item?.draftReply || "",
10491
+ createdAtMs: source.createdAtMs || Date.now(),
10492
+ readOnly: !item,
10493
+ actions: [],
10494
+ moltbookReplyEnabled: Boolean(item),
10495
+ };
10496
+ }
10497
+
10498
+ if (kind === "moltbook_draft") {
10499
+ const draft = runtime.moltbookDraftsByToken.get(token);
10500
+ const entry = draft
10501
+ ? null
10502
+ : runtime.recentTimelineEntries.find((e) => e.kind === "moltbook_draft" && e.token === token);
10503
+ const source = draft || entry;
10504
+ if (!source) return null;
10505
+ const draftText = draft ? draft.draftText : entry.draftText || entry.messageText || "";
10506
+ const contextSummary = draft ? draft.contextSummary || "" : entry.summary || "";
10507
+ const messageHtml = escapeHtml(contextSummary)
10508
+ .split("\n")
10509
+ .map((line) => `<p>${line}</p>`)
10510
+ .join("");
10511
+ return {
10512
+ kind: "moltbook_draft",
10513
+ token,
10514
+ threadId: "moltbook",
10515
+ threadLabel: "Moltbook",
10516
+ title: source.title || "Moltbook draft",
10517
+ summary: source.summary || contextSummary || "",
10518
+ messageHtml,
10519
+ provider: "moltbook",
10520
+ draftText,
10521
+ postId: draft?.postId || entry?.postId || "",
10522
+ postUrl: draft?.postUrl || entry?.postUrl || "",
10523
+ postTitle: draft?.postTitle || entry?.postTitle || "",
10524
+ postAuthor: draft?.postAuthor || entry?.postAuthor || "",
10525
+ postBody: draft?.postBody || entry?.postBody || "",
10526
+ draftType: draft?.draftType || entry?.draftType || "reply",
10527
+ submoltName: draft?.submoltName || entry?.submoltName || "",
10528
+ intent: draft?.intent || entry?.intent || "",
10529
+ createdAtMs: source.createdAtMs || Date.now(),
10530
+ readOnly: !draft || Boolean(draft?.decision),
10531
+ actions: [],
10532
+ moltbookDraftEnabled: Boolean(draft) && !draft.decision,
10533
+ };
10534
+ }
10535
+
10417
10536
  const historyItem = historyItemByToken(runtime, kind, token);
10418
10537
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
10419
10538
  }
@@ -10799,6 +10918,96 @@ function createNativeApprovalServer({ config, runtime, state }) {
10799
10918
  return writeJson(res, 200, { ok: true, enabled });
10800
10919
  }
10801
10920
 
10921
+ if (url.pathname === "/api/moltbook/scout-status" && req.method === "GET") {
10922
+ const session = requireApiSession(req, res, config, state);
10923
+ if (!session) {
10924
+ return;
10925
+ }
10926
+ if (!config.moltbookApiKey) {
10927
+ return writeJson(res, 200, { enabled: false });
10928
+ }
10929
+ try {
10930
+ const { readScoutState, rollScoutDayIfNeeded } = await import("./moltbook-api.mjs");
10931
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
10932
+ const batch = scoutState.batch;
10933
+ const batchInfo = batch && batch.candidates?.length ? {
10934
+ collecting: true,
10935
+ candidateCount: batch.candidates.length,
10936
+ topScore: Math.max(...batch.candidates.map((c) => c.score || 0)),
10937
+ remainingSeconds: Math.max(0, Math.round(((batch.startedAt || 0) + (batch.windowMs || 1800000) - Date.now()) / 1000)),
10938
+ } : null;
10939
+ return writeJson(res, 200, {
10940
+ enabled: true,
10941
+ day: scoutState.day,
10942
+ sentToday: scoutState.sentToday,
10943
+ maxDaily: 5,
10944
+ seenPostCount: Object.keys(scoutState.seenPostIds || {}).length,
10945
+ batch: batchInfo,
10946
+ composedToday: scoutState.composedToday || 0,
10947
+ composeSlotsAttempted: Array.isArray(scoutState.composeSlotsAttempted) ? scoutState.composeSlotsAttempted : [],
10948
+ recentComposeTitles: Array.isArray(scoutState.recentComposeTitles) ? scoutState.recentComposeTitles : [],
10949
+ });
10950
+ } catch {
10951
+ return writeJson(res, 200, { enabled: true, day: "", sentToday: 0, maxDaily: 5, seenPostCount: 0 });
10952
+ }
10953
+ }
10954
+
10955
+ // Activity summary for compose (original post) drafting.
10956
+ // Supports ?slot=morning|noon|evening and ?date=YYYY-MM-DD for
10957
+ // time-slot-based compose. Defaults to full-day today.
10958
+ if (url.pathname === "/api/providers/moltbook/activity-summary" && req.method === "GET") {
10959
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
10960
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
10961
+ return writeJson(res, 403, { error: "forbidden" });
10962
+ }
10963
+ const slot = String(url.searchParams.get("slot") || "").toLowerCase();
10964
+ const dateParam = String(url.searchParams.get("date") || "");
10965
+ const now = new Date();
10966
+ const localHour = now.getHours();
10967
+
10968
+ // Determine time range based on slot.
10969
+ let rangeStart, rangeEnd, rangeDate;
10970
+ if (slot === "morning" && dateParam) {
10971
+ // Morning slot: previous day's activities (dateParam = yesterday).
10972
+ const d = new Date(dateParam + "T00:00:00");
10973
+ rangeStart = d.getTime();
10974
+ rangeEnd = rangeStart + 24 * 60 * 60 * 1000;
10975
+ rangeDate = dateParam;
10976
+ } else if (slot === "noon") {
10977
+ // Noon slot: today 00:00 – 12:00 local.
10978
+ const d = new Date(now); d.setHours(0, 0, 0, 0);
10979
+ rangeStart = d.getTime();
10980
+ const noon = new Date(now); noon.setHours(12, 0, 0, 0);
10981
+ rangeEnd = noon.getTime();
10982
+ rangeDate = d.toISOString().slice(0, 10);
10983
+ } else if (slot === "evening") {
10984
+ // Evening slot: today full day.
10985
+ const d = new Date(now); d.setHours(0, 0, 0, 0);
10986
+ rangeStart = d.getTime();
10987
+ rangeEnd = Date.now();
10988
+ rangeDate = d.toISOString().slice(0, 10);
10989
+ } else {
10990
+ // Default: today full day (UTC midnight to now).
10991
+ const d = new Date(now);
10992
+ d.setUTCHours(0, 0, 0, 0);
10993
+ rangeStart = d.getTime();
10994
+ rangeEnd = Date.now();
10995
+ rangeDate = d.toISOString().slice(0, 10);
10996
+ }
10997
+
10998
+ const relevantKinds = new Set(["file_event", "completion", "plan_ready", "assistant_final"]);
10999
+ const entries = (runtime.recentTimelineEntries || [])
11000
+ .filter((e) => e.createdAtMs >= rangeStart && e.createdAtMs < rangeEnd && relevantKinds.has(e.kind))
11001
+ .map((e) => ({
11002
+ kind: e.kind,
11003
+ title: e.title || "",
11004
+ summary: e.summary || "",
11005
+ threadLabel: e.threadLabel || "",
11006
+ createdAtMs: e.createdAtMs,
11007
+ }));
11008
+ return writeJson(res, 200, { date: rangeDate, slot: slot || "full", entries });
11009
+ }
11010
+
10802
11011
  if (url.pathname === "/api/push/status" && req.method === "GET") {
10803
11012
  const session = requireApiSession(req, res, config, state);
10804
11013
  if (!session) {
@@ -11135,6 +11344,309 @@ function createNativeApprovalServer({ config, runtime, state }) {
11135
11344
  });
11136
11345
  }
11137
11346
 
11347
+ if (url.pathname === "/api/providers/moltbook/events" && req.method === "POST") {
11348
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11349
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
11350
+ return writeJson(res, 401, { error: "unauthorized" });
11351
+ }
11352
+ let body;
11353
+ try {
11354
+ body = await parseJsonBody(req);
11355
+ } catch {
11356
+ return writeJson(res, 400, { error: "invalid-json-body" });
11357
+ }
11358
+ const sourceId = cleanText(body.sourceId || "");
11359
+ if (!sourceId) {
11360
+ return writeJson(res, 400, { error: "missing-sourceId" });
11361
+ }
11362
+ const eventType = String(body.eventType || "reply_request");
11363
+ if (eventType === "resolve") {
11364
+ const token = historyToken(`moltbook:${sourceId}`);
11365
+ runtime.moltbookItemsByToken.delete(token);
11366
+ return writeJson(res, 200, { ok: true });
11367
+ }
11368
+ const token = historyToken(`moltbook:${sourceId}`);
11369
+ const item = {
11370
+ token,
11371
+ sourceId,
11372
+ threadId: cleanText(body.threadId || "moltbook"),
11373
+ threadLabel: cleanText(body.threadLabel || "Moltbook"),
11374
+ title: cleanText(body.title || "Moltbook reply"),
11375
+ summary: cleanText(body.summary || ""),
11376
+ contextText: cleanText(body.contextText || ""),
11377
+ draftReply: cleanText(body.draftReply || ""),
11378
+ callbackUrl: cleanText(body.callbackUrl || ""),
11379
+ postUrl: cleanText(body.postUrl || ""),
11380
+ commentAuthor: cleanText(body.commentAuthor || ""),
11381
+ createdAtMs: Number(body.createdAtMs) || Date.now(),
11382
+ resolved: false,
11383
+ };
11384
+ runtime.moltbookItemsByToken.set(token, item);
11385
+ try {
11386
+ recordTimelineEntry({
11387
+ config,
11388
+ runtime,
11389
+ state,
11390
+ entry: {
11391
+ stableId: `moltbook_reply:${sourceId}`,
11392
+ token,
11393
+ kind: "moltbook_reply",
11394
+ threadId: item.threadId,
11395
+ threadLabel: item.threadLabel,
11396
+ title: item.title,
11397
+ summary: item.summary,
11398
+ messageText: item.contextText,
11399
+ createdAtMs: item.createdAtMs,
11400
+ readOnly: false,
11401
+ provider: "moltbook",
11402
+ },
11403
+ });
11404
+ await saveState(config.stateFile, state);
11405
+ } catch (error) {
11406
+ console.error(`[moltbook-timeline-save] ${error.message}`);
11407
+ }
11408
+ try {
11409
+ await deliverWebPushItem({
11410
+ config,
11411
+ state,
11412
+ kind: "moltbook_reply",
11413
+ token,
11414
+ stableId: `moltbook_reply:${item.sourceId}`,
11415
+ title: item.title,
11416
+ body: item.summary,
11417
+ });
11418
+ } catch (error) {
11419
+ console.error(`[moltbook-push-error] ${error.message}`);
11420
+ }
11421
+ return writeJson(res, 200, { ok: true, token });
11422
+ }
11423
+
11424
+ const apiMoltbookReplyMatch = url.pathname.match(/^\/api\/items\/moltbook\/([^/]+)\/reply$/u);
11425
+ if (apiMoltbookReplyMatch && req.method === "POST") {
11426
+ const session = requireMutatingApiSession(req, res, config, state);
11427
+ if (!session) {
11428
+ return;
11429
+ }
11430
+ const token = decodeURIComponent(apiMoltbookReplyMatch[1]);
11431
+ const item = runtime.moltbookItemsByToken.get(token);
11432
+ if (!item) {
11433
+ return writeJson(res, 404, { error: "item-not-found" });
11434
+ }
11435
+ let body;
11436
+ try {
11437
+ body = await parseJsonBody(req);
11438
+ } catch {
11439
+ return writeJson(res, 400, { error: "invalid-json-body" });
11440
+ }
11441
+ const action = String(body.action || "send");
11442
+ const text = cleanText(body.text || "");
11443
+ if (action === "send" && !text) {
11444
+ return writeJson(res, 400, { error: "empty-reply" });
11445
+ }
11446
+ if (item.callbackUrl) {
11447
+ try {
11448
+ const callbackRes = await fetch(item.callbackUrl, {
11449
+ method: "POST",
11450
+ headers: {
11451
+ "content-type": "application/json",
11452
+ "x-viveworker-hook-secret": config.sessionSecret || "",
11453
+ },
11454
+ body: JSON.stringify({
11455
+ sourceId: item.sourceId,
11456
+ action,
11457
+ text,
11458
+ }),
11459
+ });
11460
+ if (!callbackRes.ok) {
11461
+ return writeJson(res, 502, { error: `callback-failed-${callbackRes.status}` });
11462
+ }
11463
+ } catch (error) {
11464
+ return writeJson(res, 502, { error: `callback-error: ${error.message}` });
11465
+ }
11466
+ }
11467
+ item.resolved = true;
11468
+ runtime.moltbookItemsByToken.delete(token);
11469
+ return writeJson(res, 200, { ok: true, action });
11470
+ }
11471
+
11472
+ // ---------------------------------------------------------------
11473
+ // Moltbook scout draft channel
11474
+ //
11475
+ // The standalone scout CLI (`viveworker moltbook propose`) submits
11476
+ // a draft reply via POST /api/providers/moltbook/draft, then long-
11477
+ // polls /api/providers/moltbook/draft/:token/decision until the
11478
+ // phone responds via POST /api/items/moltbook-draft/:token/decision.
11479
+ // ---------------------------------------------------------------
11480
+
11481
+ if (url.pathname === "/api/providers/moltbook/draft" && req.method === "POST") {
11482
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11483
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
11484
+ return writeJson(res, 401, { error: "unauthorized" });
11485
+ }
11486
+ let body;
11487
+ try {
11488
+ body = await parseJsonBody(req);
11489
+ } catch {
11490
+ return writeJson(res, 400, { error: "invalid-json-body" });
11491
+ }
11492
+ const sourceId = cleanText(body.sourceId || "");
11493
+ const postId = cleanText(body.postId || "");
11494
+ const draftText = cleanText(body.draftText || "");
11495
+ const draftType = cleanText(body.draftType || "reply");
11496
+ const submoltName = cleanText(body.submoltName || "");
11497
+ if (!sourceId || !draftText) {
11498
+ return writeJson(res, 400, { error: "missing-sourceId-or-draftText" });
11499
+ }
11500
+ if (draftType === "reply" && !postId) {
11501
+ return writeJson(res, 400, { error: "missing-postId-for-reply" });
11502
+ }
11503
+ const token = historyToken(`moltbook_draft:${sourceId}`);
11504
+ const postTitle = cleanText(body.postTitle || "");
11505
+ const postUrl = cleanText(body.postUrl || `https://www.moltbook.com/post/${postId}`);
11506
+ const contextSummary = cleanText(body.contextSummary || "");
11507
+ const postAuthor = cleanText(body.postAuthor || "");
11508
+ const postBody = typeof body.postBody === "string" ? body.postBody : "";
11509
+ const intent = typeof body.intent === "string" ? body.intent : "";
11510
+ const draft = {
11511
+ token,
11512
+ sourceId,
11513
+ postId,
11514
+ postTitle,
11515
+ postAuthor,
11516
+ postBody,
11517
+ postUrl,
11518
+ parentCommentId: cleanText(body.parentCommentId || ""),
11519
+ draftText,
11520
+ draftType,
11521
+ submoltName,
11522
+ intent,
11523
+ contextSummary,
11524
+ createdAtMs: Date.now(),
11525
+ decisionWaiters: [],
11526
+ decision: null,
11527
+ };
11528
+ runtime.moltbookDraftsByToken.set(token, draft);
11529
+ try {
11530
+ recordTimelineEntry({
11531
+ config,
11532
+ runtime,
11533
+ state,
11534
+ entry: {
11535
+ stableId: `moltbook_draft:${sourceId}`,
11536
+ token,
11537
+ kind: "moltbook_draft",
11538
+ threadId: "moltbook",
11539
+ threadLabel: "Moltbook",
11540
+ title: draftType === "original_post"
11541
+ ? (postTitle || "Moltbook new post")
11542
+ : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
11543
+ summary: contextSummary || String(draftText).slice(0, 160),
11544
+ messageText: contextSummary || draftText,
11545
+ draftText,
11546
+ draftType,
11547
+ submoltName,
11548
+ intent,
11549
+ postId,
11550
+ postUrl,
11551
+ postTitle,
11552
+ postAuthor,
11553
+ postBody,
11554
+ createdAtMs: draft.createdAtMs,
11555
+ readOnly: false,
11556
+ provider: "moltbook",
11557
+ },
11558
+ });
11559
+ await saveState(config.stateFile, state);
11560
+ } catch (error) {
11561
+ console.error(`[moltbook-draft-timeline-save] ${error.message}`);
11562
+ }
11563
+ try {
11564
+ await deliverWebPushItem({
11565
+ config,
11566
+ state,
11567
+ kind: "moltbook_draft",
11568
+ token,
11569
+ stableId: `moltbook_draft:${sourceId}`,
11570
+ title: draftType === "original_post"
11571
+ ? (postTitle || "Moltbook new post")
11572
+ : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
11573
+ body: String(draftText).slice(0, 160),
11574
+ });
11575
+ } catch (error) {
11576
+ console.error(`[moltbook-draft-push-error] ${error.message}`);
11577
+ }
11578
+ return writeJson(res, 200, { ok: true, token });
11579
+ }
11580
+
11581
+ const apiMoltbookDraftDecisionGet = url.pathname.match(
11582
+ /^\/api\/providers\/moltbook\/draft\/([^/]+)\/decision$/u
11583
+ );
11584
+ if (apiMoltbookDraftDecisionGet && req.method === "GET") {
11585
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11586
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
11587
+ return writeJson(res, 401, { error: "unauthorized" });
11588
+ }
11589
+ const token = decodeURIComponent(apiMoltbookDraftDecisionGet[1]);
11590
+ const draft = runtime.moltbookDraftsByToken.get(token);
11591
+ if (!draft) return writeJson(res, 404, { error: "draft-not-found" });
11592
+ if (draft.decision) {
11593
+ return writeJson(res, 200, { status: "decided", ...draft.decision });
11594
+ }
11595
+ const waitParam = Number(url.searchParams.get("wait")) || 25;
11596
+ const waitMs = Math.min(Math.max(waitParam, 1), 60) * 1000;
11597
+ const decided = await new Promise((resolve) => {
11598
+ const timer = setTimeout(() => {
11599
+ const idx = draft.decisionWaiters.indexOf(resolve);
11600
+ if (idx !== -1) draft.decisionWaiters.splice(idx, 1);
11601
+ resolve(null);
11602
+ }, waitMs);
11603
+ draft.decisionWaiters.push((d) => {
11604
+ clearTimeout(timer);
11605
+ resolve(d);
11606
+ });
11607
+ });
11608
+ if (decided) return writeJson(res, 200, { status: "decided", ...decided });
11609
+ return writeJson(res, 200, { status: "pending" });
11610
+ }
11611
+
11612
+ const apiMoltbookDraftDecide = url.pathname.match(
11613
+ /^\/api\/items\/moltbook-draft\/([^/]+)\/decision$/u
11614
+ );
11615
+ if (apiMoltbookDraftDecide && req.method === "POST") {
11616
+ const session = requireMutatingApiSession(req, res, config, state);
11617
+ if (!session) return;
11618
+ const token = decodeURIComponent(apiMoltbookDraftDecide[1]);
11619
+ const draft = runtime.moltbookDraftsByToken.get(token);
11620
+ if (!draft) return writeJson(res, 404, { error: "draft-not-found" });
11621
+ let body;
11622
+ try {
11623
+ body = await parseJsonBody(req);
11624
+ } catch {
11625
+ return writeJson(res, 400, { error: "invalid-json-body" });
11626
+ }
11627
+ const action = String(body.action || "") === "approve" ? "approve" : "deny";
11628
+ const editedText = cleanText(body.editedText || "");
11629
+ const editedTitle = cleanText(body.editedTitle || "");
11630
+ const decision = {
11631
+ action,
11632
+ text: action === "approve" ? editedText || draft.draftText : "",
11633
+ title: action === "approve" ? editedTitle || draft.postTitle : "",
11634
+ decidedAtMs: Date.now(),
11635
+ };
11636
+ draft.decision = decision;
11637
+ for (const waiter of draft.decisionWaiters.splice(0)) {
11638
+ try {
11639
+ waiter(decision);
11640
+ } catch {
11641
+ // ignore
11642
+ }
11643
+ }
11644
+ // Keep the entry in moltbookDraftsByToken briefly so a slow long-
11645
+ // poll can still pick it up; sweeper below removes it after 2min.
11646
+ setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120 * 1000).unref?.();
11647
+ return writeJson(res, 200, { ok: true, action });
11648
+ }
11649
+
11138
11650
  if (url.pathname === "/api/inbox" && req.method === "GET") {
11139
11651
  const session = requireApiSession(req, res, config, state);
11140
11652
  if (!session) {
@@ -13044,6 +13556,23 @@ function isLoopbackHostname(value) {
13044
13556
  return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
13045
13557
  }
13046
13558
 
13559
+ function readMoltbookEnvKey() {
13560
+ // Fall back to the standalone watcher's env file so the bridge can detect
13561
+ // a configured Moltbook integration without requiring MOLTBOOK_API_KEY to
13562
+ // be duplicated into its launchd plist.
13563
+ try {
13564
+ const envPath = path.join(os.homedir(), ".viveworker", "moltbook.env");
13565
+ const raw = readFileSync(envPath, "utf8");
13566
+ for (const line of raw.split(/\r?\n/u)) {
13567
+ const m = line.match(/^\s*MOLTBOOK_API_KEY\s*=\s*(.+?)\s*$/u);
13568
+ if (m) return m[1].replace(/^['"]|['"]$/gu, "");
13569
+ }
13570
+ } catch {
13571
+ // ignore
13572
+ }
13573
+ return "";
13574
+ }
13575
+
13047
13576
  function buildConfig(cli) {
13048
13577
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
13049
13578
  const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
@@ -13051,6 +13580,7 @@ function buildConfig(cli) {
13051
13580
  dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
13052
13581
  once: cli.once,
13053
13582
  codexHome,
13583
+ moltbookApiKey: cleanText(process.env.MOLTBOOK_API_KEY || readMoltbookEnvKey() || ""),
13054
13584
  webUiEnabled: boolEnv("WEB_UI_ENABLED", true),
13055
13585
  authRequired: boolEnv("AUTH_REQUIRED", true),
13056
13586
  webPushEnabled: boolEnv("WEB_PUSH_ENABLED", false),
@@ -13844,6 +14374,12 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
13844
14374
 
13845
14375
  const nextTimelineEntries = normalizeTimelineEntries(
13846
14376
  runtime.recentTimelineEntries.map((entry) => {
14377
+ // Moltbook entries carry their own author-provided title/threadLabel
14378
+ // (e.g. "@broanbot commented"). Skip the native-thread relabel pass so
14379
+ // we don't overwrite them with the UUID-head fallback.
14380
+ if (normalizeProvider(entry.provider) === "moltbook") {
14381
+ return entry;
14382
+ }
13847
14383
  const threadId = cleanText(entry.threadId || "");
13848
14384
  if (!threadId) {
13849
14385
  return entry;
@@ -181,7 +181,13 @@ async function handlePermissionRequest() {
181
181
  }
182
182
 
183
183
  const result = await postEvent("PermissionRequest", body, APPROVAL_TIMEOUT_MS);
184
- const behavior = String(result?.permissionDecision || "deny") === "allow" ? "allow" : "deny";
184
+ // If the bridge is unreachable, postEvent resolves to {} with no
185
+ // permissionDecision field. Distinguish that from an explicit deny and
186
+ // fall back to Claude Code's default permission flow instead of blocking.
187
+ if (!result || typeof result.permissionDecision !== "string") {
188
+ return;
189
+ }
190
+ const behavior = result.permissionDecision === "allow" ? "allow" : "deny";
185
191
  const reason = typeof result?.permissionDecisionReason === "string" ? result.permissionDecisionReason : "";
186
192
 
187
193
  const decision = { behavior };