viveworker 0.4.7 → 0.4.8

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.
@@ -17,7 +17,8 @@ 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, getRelayStatus } from "./a2a-relay-client.mjs";
20
+ import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
21
+ import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM } from "./moltbook-api.mjs";
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = path.dirname(__filename);
@@ -26,9 +27,9 @@ const webRoot = path.join(workspaceRoot, "web");
26
27
  const appPackageVersion = readPackageVersion();
27
28
  const sessionCookieName = "viveworker_session";
28
29
  const deviceCookieName = "viveworker_device";
29
- const historyKinds = new Set(["completion", "plan_ready", "approval", "plan", "choice", "info"]);
30
+ const historyKinds = new Set(["completion", "assistant_final", "plan_ready", "approval", "plan", "choice", "info", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
30
31
  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", "thread_share"]);
32
+ const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
32
33
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
33
34
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
34
35
  const MAX_PAIRED_DEVICES = 200;
@@ -101,6 +102,50 @@ await syncClaudeAwayModeSentinel(config, state.claudeAwayMode === true);
101
102
  const migratedPairedDevicesStateChanged = migratePairedDevicesState({ config, state });
102
103
  const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, runtime, state });
103
104
  const restoredPendingUserInputStateChanged = restorePendingUserInputRequests({ config, runtime, state });
105
+
106
+ // Detect available A2A executors (codex / claude CLIs).
107
+ try {
108
+ const { detectAvailableExecutors } = await import("./a2a-executor.mjs");
109
+ runtime.a2aAvailableExecutors = detectAvailableExecutors();
110
+ console.log(`[a2a-executors] codex=${runtime.a2aAvailableExecutors.codex}, claude=${runtime.a2aAvailableExecutors.claude}`);
111
+ } catch (error) {
112
+ runtime.a2aAvailableExecutors = { codex: false, claude: false };
113
+ console.error(`[a2a-executors] Detection failed: ${error.message}`);
114
+ }
115
+
116
+ // Restore persisted moltbook drafts that haven't expired.
117
+ try {
118
+ const pendingDrafts = await listPendingDrafts();
119
+ const now = Date.now();
120
+ for (const draft of pendingDrafts) {
121
+ const age = now - (draft.createdAtMs || 0);
122
+ const ttl = draft.ttlMs || 86400000;
123
+ if (age > ttl) {
124
+ await deleteDraft(draft.token).catch(() => {});
125
+ continue;
126
+ }
127
+ runtime.moltbookDraftsByToken.set(draft.token, { ...draft, decisionWaiters: [] });
128
+ }
129
+ if (runtime.moltbookDraftsByToken.size) {
130
+ console.log(`[moltbook-drafts] Restored ${runtime.moltbookDraftsByToken.size} pending draft(s) from disk`);
131
+ }
132
+ } catch (error) {
133
+ console.error(`[moltbook-drafts-restore] ${error.message}`);
134
+ }
135
+
136
+ // Periodic TTL sweeper for expired drafts (every 60s).
137
+ setInterval(async () => {
138
+ const now = Date.now();
139
+ for (const [token, draft] of runtime.moltbookDraftsByToken) {
140
+ if (draft.decision) continue;
141
+ const age = now - (draft.createdAtMs || 0);
142
+ if (age > (draft.ttlMs || 86400000)) {
143
+ runtime.moltbookDraftsByToken.delete(token);
144
+ await deleteDraft(token).catch(() => {});
145
+ console.log(`[moltbook-draft-expired] ${token} (age=${Math.round(age / 1000)}s)`);
146
+ }
147
+ }
148
+ }, 60_000).unref?.();
104
149
  const initialHistoryItems = normalizeHistoryItems(state.recentHistoryItems ?? [], config.maxHistoryItems);
105
150
  const initialTimelineEntries = normalizeTimelineEntries(state.recentTimelineEntries ?? [], config.maxTimelineEntries);
106
151
  const normalizedHistoryStateChanged =
@@ -224,6 +269,8 @@ function buildSessionLocalePayload(config, state, deviceId) {
224
269
  moltbookEnabled: Boolean(config.moltbookApiKey),
225
270
  a2aEnabled: Boolean(config.a2aApiKey),
226
271
  a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
272
+ a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
273
+ a2aExecutorPreference: state.a2aExecutorPreference || "ask",
227
274
  };
228
275
  }
229
276
 
@@ -249,6 +296,15 @@ function kindTitle(locale, kind) {
249
296
  return t(locale, "common.fileEvent");
250
297
  case "diff_thread":
251
298
  return t(locale, "common.diff");
299
+ case "a2a_task":
300
+ return "A2A";
301
+ case "a2a_task_result":
302
+ return "A2A";
303
+ case "moltbook_reply":
304
+ case "moltbook_draft":
305
+ return "Moltbook";
306
+ case "thread_share":
307
+ return t(locale, "server.title.threadShare") || "Thread Share";
252
308
  default:
253
309
  return t(locale, "common.item");
254
310
  }
@@ -293,6 +349,7 @@ function notificationIconPrefix(kind) {
293
349
  case "completion":
294
350
  return "✅";
295
351
  case "a2a_task":
352
+ case "a2a_task_result":
296
353
  return "🤝";
297
354
  default:
298
355
  return "";
@@ -2006,6 +2063,7 @@ function normalizeProvider(value) {
2006
2063
  if (normalized === "claude") return "claude";
2007
2064
  if (normalized === "moltbook") return "moltbook";
2008
2065
  if (normalized === "a2a") return "a2a";
2066
+ if (normalized === "viveworker") return "viveworker";
2009
2067
  return "codex";
2010
2068
  }
2011
2069
 
@@ -2050,7 +2108,12 @@ function normalizeHistoryItem(raw) {
2050
2108
  const kind = cleanText(raw.kind ?? "");
2051
2109
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
2052
2110
  const rawThreadLabel = cleanText(raw.threadLabel ?? "");
2053
- const threadLabel = preferTitleOnlyJsonThreadLabel(rawThreadLabel, threadId, raw.messageText, raw.summary, raw.detailText, raw.message);
2111
+ const historyProvider = normalizeProvider(raw.provider);
2112
+ const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker"
2113
+ || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
2114
+ const threadLabel = skipHistoryThreadLabelRewrite
2115
+ ? rawThreadLabel
2116
+ : preferTitleOnlyJsonThreadLabel(rawThreadLabel, threadId, raw.messageText, raw.summary, raw.detailText, raw.message);
2054
2117
  const title =
2055
2118
  cleanText(raw.title ?? "") || (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2056
2119
  const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
@@ -2084,6 +2147,11 @@ function normalizeHistoryItem(raw) {
2084
2147
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
2085
2148
  tone: cleanText(raw.tone ?? "") || "secondary",
2086
2149
  provider: normalizeProvider(raw.provider),
2150
+ // Moltbook draft-specific
2151
+ ...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
2152
+ // A2A task-specific
2153
+ ...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
2154
+ ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2087
2155
  };
2088
2156
  }
2089
2157
 
@@ -2112,7 +2180,7 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2112
2180
  const deduped = [];
2113
2181
  const seen = new Set();
2114
2182
  const perProviderCount = {};
2115
- const knownProviders = new Set(["codex", "claude", "moltbook", "a2a"]);
2183
+ const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
2116
2184
  const saturatedProviders = new Set();
2117
2185
  for (const item of normalized) {
2118
2186
  if (seen.has(item.stableId)) {
@@ -2210,8 +2278,10 @@ function normalizeTimelineEntry(raw) {
2210
2278
  (kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
2211
2279
  "";
2212
2280
  const rawProvider = normalizeProvider(raw.provider);
2281
+ const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker"
2282
+ || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
2213
2283
  const threadLabel =
2214
- rawProvider === "moltbook"
2284
+ skipThreadLabelRewrite
2215
2285
  ? cleanText(raw.threadLabel ?? "")
2216
2286
  : preferTitleOnlyJsonThreadLabel(
2217
2287
  cleanText(raw.threadLabel ?? ""),
@@ -2269,6 +2339,9 @@ function normalizeTimelineEntry(raw) {
2269
2339
  ...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
2270
2340
  ...(raw.submoltName != null ? { submoltName: cleanText(raw.submoltName) } : {}),
2271
2341
  ...(raw.slot != null ? { slot: cleanText(raw.slot) } : {}),
2342
+ // A2A task-specific fields
2343
+ ...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
2344
+ ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2272
2345
  };
2273
2346
  }
2274
2347
 
@@ -3237,8 +3310,19 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3237
3310
  // conversation that should not appear on the user-facing timeline.
3238
3311
  if (/^<(?:task-notification|system-reminder)\b/i.test(text.trim())) continue;
3239
3312
 
3240
- const kind = type === "user" ? "user_message" : "assistant_final";
3241
- const stableId = `${kind}:${threadId}:${uuid}`;
3313
+ // Classify assistant messages using Claude's stop_reason:
3314
+ // "end_turn" → final answer (like Codex phase "final_answer")
3315
+ // "tool_use" → intermediate, about to call tools (commentary)
3316
+ // null/other → intermediate thinking (commentary)
3317
+ const stopReason = msg.stop_reason || "";
3318
+ const kind = type === "user"
3319
+ ? "user_message"
3320
+ : stopReason === "end_turn"
3321
+ ? "assistant_final"
3322
+ : "assistant_commentary";
3323
+ // Use kind-independent stableId so re-scans with corrected classification
3324
+ // replace old entries rather than creating duplicates.
3325
+ const stableId = `claude_msg:${threadId}:${uuid}`;
3242
3326
  const entry = normalizeTimelineEntry({
3243
3327
  stableId,
3244
3328
  token: historyToken(stableId),
@@ -3255,6 +3339,46 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3255
3339
  });
3256
3340
  if (entry) {
3257
3341
  dirty = recordTimelineEntry({ config, runtime, state, entry }) || dirty;
3342
+
3343
+ // Also record Claude assistant_final as a history item for the completed list
3344
+ if (kind === "assistant_final") {
3345
+ dirty = recordHistoryItem({
3346
+ config, runtime, state,
3347
+ item: {
3348
+ stableId: entry.stableId,
3349
+ kind: entry.kind,
3350
+ threadId: entry.threadId,
3351
+ title: entry.title,
3352
+ threadLabel: entry.threadLabel,
3353
+ summary: entry.summary,
3354
+ messageText: entry.messageText,
3355
+ createdAtMs: entry.createdAtMs,
3356
+ readOnly: true,
3357
+ provider: "claude",
3358
+ },
3359
+ }) || dirty;
3360
+ }
3361
+
3362
+ // Push notification for Claude final answers (skip during startup replay)
3363
+ if (kind === "assistant_final" && !fileState.startupCutoffMs) {
3364
+ try {
3365
+ await deliverWebPushItem({
3366
+ config,
3367
+ state,
3368
+ kind: "assistant_final",
3369
+ token: entry.token,
3370
+ stableId: entry.stableId,
3371
+ title: threadLabel || "Claude",
3372
+ body: truncate(singleLine(text), 160),
3373
+ buildLocalizedContent: ({ locale }) => ({
3374
+ title: formatLocalizedTitle(locale, "server.title.assistantFinal", threadLabel),
3375
+ body: truncate(singleLine(text), 160),
3376
+ }),
3377
+ });
3378
+ } catch (pushError) {
3379
+ console.error(`[claude-push-error] ${pushError.message}`);
3380
+ }
3381
+ }
3258
3382
  }
3259
3383
  }
3260
3384
 
@@ -3400,6 +3524,29 @@ async function processSqliteTimelineLog({ config, runtime, state, now }) {
3400
3524
  state,
3401
3525
  entry,
3402
3526
  }) || dirty;
3527
+
3528
+ // Also record Codex assistant_final as a history item so it appears
3529
+ // in the completed inbox and supports reply (replaces "completion").
3530
+ if (entry.kind === "assistant_final") {
3531
+ dirty =
3532
+ recordHistoryItem({
3533
+ config,
3534
+ runtime,
3535
+ state,
3536
+ item: {
3537
+ stableId: entry.stableId,
3538
+ kind: entry.kind,
3539
+ threadId: entry.threadId,
3540
+ title: entry.title,
3541
+ threadLabel: entry.threadLabel,
3542
+ summary: entry.summary,
3543
+ messageText: entry.messageText,
3544
+ createdAtMs: entry.createdAtMs,
3545
+ readOnly: true,
3546
+ provider: "codex",
3547
+ },
3548
+ }) || dirty;
3549
+ }
3403
3550
  }
3404
3551
 
3405
3552
  state.sqliteMessageCursorId = cursorId;
@@ -9056,16 +9203,18 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9056
9203
  for (const draft of runtime.moltbookDraftsByToken.values()) {
9057
9204
  if (draft.decision) continue;
9058
9205
  const title = draft.postTitle ? `draft → ${draft.postTitle}` : "Moltbook draft";
9206
+ const draftThreadLabel = draft.postTitle || "Moltbook";
9059
9207
  items.push({
9060
9208
  kind: "moltbook_draft",
9061
9209
  token: draft.token,
9062
9210
  threadId: "moltbook",
9063
- threadLabel: "Moltbook",
9211
+ threadLabel: draftThreadLabel,
9064
9212
  title,
9065
9213
  summary: draft.contextSummary || String(draft.draftText || "").slice(0, 160),
9066
9214
  primaryLabel: t(locale, "server.action.review"),
9067
9215
  createdAtMs: Number(draft.createdAtMs) || now,
9068
9216
  provider: "moltbook",
9217
+ draftType: draft.draftType || "reply",
9069
9218
  });
9070
9219
  }
9071
9220
 
@@ -9075,7 +9224,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9075
9224
  kind: "a2a_task",
9076
9225
  token: task.token,
9077
9226
  threadId: "a2a",
9078
- threadLabel: "A2A",
9227
+ threadLabel: cleanText(task.instruction || "").slice(0, 80),
9079
9228
  title: `A2A: ${cleanText(task.instruction || "").slice(0, 80)}`,
9080
9229
  summary: cleanText(task.instruction || "").slice(0, 160),
9081
9230
  primaryLabel: t(locale, "server.action.review"),
@@ -9112,8 +9261,15 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9112
9261
  function buildCompletedInboxItems(runtime, state, config, locale) {
9113
9262
  const items = normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems);
9114
9263
  runtime.recentHistoryItems = items;
9264
+ const completedKinds = new Set(["assistant_final", "approval", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task_result"]);
9115
9265
  return items
9116
- .filter((item) => cleanText(item?.kind || "") === "completion")
9266
+ .filter((item) => {
9267
+ const k = cleanText(item?.kind || "");
9268
+ if (!completedKinds.has(k)) return false;
9269
+ // Only resolved approvals (readOnly = true)
9270
+ if (k === "approval" && !item.readOnly) return false;
9271
+ return true;
9272
+ })
9117
9273
  .slice()
9118
9274
  .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0))
9119
9275
  .map((item) => ({
@@ -9121,12 +9277,19 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9121
9277
  token: item.token,
9122
9278
  threadId: cleanText(item.threadId || extractConversationIdFromStableId(item.stableId) || ""),
9123
9279
  threadLabel: item.threadLabel || "",
9124
- title: item.threadLabel ? formatTitle(kindTitle(locale, item.kind), item.threadLabel) : item.title,
9280
+ title: item.kind === "assistant_final"
9281
+ ? (item.threadLabel ? formatTitle(kindTitle(locale, item.kind), item.threadLabel) : item.title)
9282
+ : (item.kind === "a2a_task_result" && item.instruction)
9283
+ ? cleanText(item.instruction).slice(0, 80)
9284
+ : (item.kind === "moltbook_reply" || item.kind === "moltbook_draft")
9285
+ ? cleanText(item.summary || item.messageText || "").slice(0, 80) || item.title
9286
+ : item.title || kindTitle(locale, item.kind),
9125
9287
  summary: item.summary,
9126
9288
  fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
9127
9289
  primaryLabel: t(locale, "server.action.detail"),
9128
9290
  createdAtMs: item.createdAtMs,
9129
9291
  provider: normalizeProvider(item.provider),
9292
+ ...(item.draftType != null ? { draftType: item.draftType } : {}),
9130
9293
  }));
9131
9294
  }
9132
9295
 
@@ -9480,6 +9643,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
9480
9643
  outcome: entry.outcome || "",
9481
9644
  createdAtMs: entry.createdAtMs,
9482
9645
  provider: normalizeProvider(entry.provider),
9646
+ ...(entry.draftType != null ? { draftType: entry.draftType } : {}),
9483
9647
  }));
9484
9648
 
9485
9649
  return {
@@ -9696,8 +9860,11 @@ function historyItemThreadId(item) {
9696
9860
  return cleanText(item?.threadId || extractConversationIdFromStableId(item?.stableId) || "");
9697
9861
  }
9698
9862
 
9699
- function isLatestCompletionHistoryItem(runtime, item) {
9700
- if (!runtime || cleanText(item?.kind || "") !== "completion") {
9863
+ const REPLYABLE_HISTORY_KINDS = new Set(["assistant_final"]);
9864
+
9865
+ function isLatestReplyableHistoryItem(runtime, item) {
9866
+ const itemKind = cleanText(item?.kind || "");
9867
+ if (!runtime || !REPLYABLE_HISTORY_KINDS.has(itemKind)) {
9701
9868
  return false;
9702
9869
  }
9703
9870
 
@@ -9707,14 +9874,24 @@ function isLatestCompletionHistoryItem(runtime, item) {
9707
9874
  return false;
9708
9875
  }
9709
9876
 
9710
- const latestForThread = runtime.recentHistoryItems.find(
9711
- (entry) => cleanText(entry?.kind || "") === "completion" && historyItemThreadId(entry) === threadId
9712
- );
9877
+ // Find the latest replyable item (completion or assistant_final) for this thread
9878
+ // Sort by createdAtMs descending since array order is not guaranteed
9879
+ let latestForThread = null;
9880
+ let latestTs = 0;
9881
+ for (const entry of runtime.recentHistoryItems) {
9882
+ if (!REPLYABLE_HISTORY_KINDS.has(cleanText(entry?.kind || "")) || historyItemThreadId(entry) !== threadId) continue;
9883
+ const ts = Number(entry.createdAtMs) || 0;
9884
+ if (!latestForThread || ts > latestTs) {
9885
+ latestForThread = entry;
9886
+ latestTs = ts;
9887
+ }
9888
+ }
9713
9889
  return cleanText(latestForThread?.token || "") === token;
9714
9890
  }
9715
9891
 
9716
9892
  function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
9717
- if (!runtime || cleanText(completionItem?.kind || "") !== "completion") {
9893
+ const itemKind = cleanText(completionItem?.kind || "");
9894
+ if (!runtime || !REPLYABLE_HISTORY_KINDS.has(itemKind)) {
9718
9895
  return null;
9719
9896
  }
9720
9897
 
@@ -9740,10 +9917,10 @@ function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
9740
9917
  function buildHistoryDetail(item, locale, runtime = null) {
9741
9918
  const threadId = historyItemThreadId(item);
9742
9919
  const replyEnabled =
9743
- item.kind === "completion" &&
9920
+ REPLYABLE_HISTORY_KINDS.has(item.kind) &&
9744
9921
  Boolean(threadId) &&
9745
9922
  Boolean(runtime?.ipcClient) &&
9746
- isLatestCompletionHistoryItem(runtime, item);
9923
+ isLatestReplyableHistoryItem(runtime, item);
9747
9924
  return {
9748
9925
  kind: item.kind,
9749
9926
  token: item.token,
@@ -10197,7 +10374,20 @@ async function handleCompletionReply({
10197
10374
  if (!conversationId) {
10198
10375
  throw new Error("completion-reply-unavailable");
10199
10376
  }
10200
- if (!isLatestCompletionHistoryItem(runtime, completionItem)) {
10377
+ // For assistant_final, check against timeline entries (avoids maxHistoryItems eviction).
10378
+ // For legacy completion, check against history items.
10379
+ const itemKind = cleanText(completionItem?.kind || "");
10380
+ if (itemKind === "assistant_final") {
10381
+ const itemTs = Number(completionItem.createdAtMs) || 0;
10382
+ const hasNewer = runtime.recentTimelineEntries.some(
10383
+ (e) => e.kind === "assistant_final" &&
10384
+ cleanText(e.threadId || "") === conversationId &&
10385
+ (Number(e.createdAtMs) || 0) > itemTs
10386
+ );
10387
+ if (hasNewer) {
10388
+ throw new Error("completion-reply-unavailable");
10389
+ }
10390
+ } else if (!isLatestReplyableHistoryItem(runtime, completionItem)) {
10201
10391
  throw new Error("completion-reply-unavailable");
10202
10392
  }
10203
10393
  const newerThreadMessage = findNewerThreadMessageAfterCompletion(runtime, completionItem);
@@ -10484,7 +10674,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
10484
10674
  title: approval.title,
10485
10675
  threadLabel: approval.threadLabel || "",
10486
10676
  messageText: `${approvalDecisionMessage(decision, config.defaultLocale, approval.provider)}\n\n${approval.messageText}`,
10487
- summary: approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
10677
+ summary: formatNotificationBody(approval.messageText, 160) || approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
10488
10678
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
10489
10679
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
10490
10680
  diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
@@ -10511,7 +10701,32 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10511
10701
  }
10512
10702
  if (timelineMessageKinds.has(kind)) {
10513
10703
  const entry = timelineEntryByToken(runtime, token, kind);
10514
- return entry ? buildTimelineMessageDetail(entry, locale, runtime) : null;
10704
+ if (!entry) return null;
10705
+ const detail = buildTimelineMessageDetail(entry, locale, runtime);
10706
+ // Add reply support for Codex assistant_final entries only (replaces completion reply).
10707
+ // Check directly against timeline entries (not history items) to avoid
10708
+ // maxHistoryItems eviction issues.
10709
+ if (kind === "assistant_final") {
10710
+ const entryProvider = normalizeProvider(entry.provider);
10711
+ if (entryProvider === "codex" && runtime?.ipcClient) {
10712
+ const entryThreadId = cleanText(entry.threadId || "");
10713
+ const entryTs = Number(entry.createdAtMs) || 0;
10714
+ let isLatestForThread = true;
10715
+ for (const other of runtime.recentTimelineEntries) {
10716
+ if (other.kind === "assistant_final" &&
10717
+ cleanText(other.threadId || "") === entryThreadId &&
10718
+ (Number(other.createdAtMs) || 0) > entryTs) {
10719
+ isLatestForThread = false;
10720
+ break;
10721
+ }
10722
+ }
10723
+ if (isLatestForThread) {
10724
+ detail.reply = { enabled: true, supportsPlanMode: true, supportsImages: true };
10725
+ detail.provider = entryProvider;
10726
+ }
10727
+ }
10728
+ }
10729
+ return detail;
10515
10730
  }
10516
10731
  if (kind === "approval") {
10517
10732
  const approval = runtime.nativeApprovalsByToken.get(token);
@@ -10614,20 +10829,21 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10614
10829
  };
10615
10830
  }
10616
10831
 
10617
- if (kind === "a2a_task") {
10618
- const task = runtime.a2aTasksByToken.get(token);
10832
+ if (kind === "a2a_task" || kind === "a2a_task_result") {
10833
+ const task = kind === "a2a_task" ? runtime.a2aTasksByToken.get(token) : null;
10619
10834
  const entry = task
10620
10835
  ? null
10621
- : runtime.recentTimelineEntries.find((e) => e.kind === "a2a_task" && e.token === token);
10836
+ : runtime.recentTimelineEntries.find((e) => e.kind === kind && e.token === token);
10622
10837
  const source = task || entry;
10623
10838
  if (!source) return null;
10624
- const instruction = task ? task.instruction : entry.messageText || entry.summary || "";
10839
+ const instruction = task ? task.instruction : entry.instruction || entry.summary || "";
10840
+ const responseText = kind === "a2a_task_result" && !task ? entry.messageText || "" : "";
10625
10841
  const messageHtml = escapeHtml(instruction)
10626
10842
  .split("\n")
10627
10843
  .map((line) => `<p>${line}</p>`)
10628
10844
  .join("");
10629
10845
  return {
10630
- kind: "a2a_task",
10846
+ kind,
10631
10847
  token,
10632
10848
  threadId: "a2a",
10633
10849
  threadLabel: "A2A",
@@ -10636,8 +10852,9 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10636
10852
  messageHtml,
10637
10853
  provider: "a2a",
10638
10854
  instruction,
10855
+ messageText: responseText,
10639
10856
  taskId: task?.id || "",
10640
- taskStatus: task?.status || entry?.taskStatus || "unknown",
10857
+ taskStatus: task?.status || entry?.taskStatus || (kind === "a2a_task_result" ? "completed" : "submitted"),
10641
10858
  callerInfo: task?.callerInfo || {},
10642
10859
  createdAtMs: source.createdAtMs || Date.now(),
10643
10860
  readOnly: !task || task.status !== "submitted",
@@ -10942,14 +11159,25 @@ function createNativeApprovalServer({ config, runtime, state }) {
10942
11159
  }
10943
11160
  const action = String(body.action || "") === "approve" ? "approve" : "deny";
10944
11161
  const instruction = cleanText(body.instruction || "");
11162
+ const requestedExecutor = cleanText(body.executor || "");
10945
11163
  resolveA2ATaskDecision(task, { action, instruction });
10946
11164
 
10947
11165
  if (action === "approve") {
10948
- // Execute task asynchronously via Codex
11166
+ // Resolve which executor to use.
11167
+ const available = runtime.a2aAvailableExecutors || { codex: false, claude: false };
11168
+ const preference = requestedExecutor || state.a2aExecutorPreference || "ask";
11169
+ let executor;
11170
+ if (preference === "codex" && available.codex) executor = "codex";
11171
+ else if (preference === "claude" && available.claude) executor = "claude";
11172
+ else if (available.codex) executor = "codex";
11173
+ else if (available.claude) executor = "claude";
11174
+ else executor = "codex"; // fallback
11175
+
11176
+ // Execute task asynchronously
10949
11177
  (async () => {
10950
11178
  try {
10951
11179
  const { executeA2ATask } = await import("./a2a-executor.mjs");
10952
- await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState });
11180
+ await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState }, executor);
10953
11181
  } catch (error) {
10954
11182
  console.error(`[a2a-execute-error] ${error.message}`);
10955
11183
  failA2ATask(task, error.message);
@@ -11165,6 +11393,24 @@ function createNativeApprovalServer({ config, runtime, state }) {
11165
11393
  return writeJson(res, 200, { ok: true, enabled });
11166
11394
  }
11167
11395
 
11396
+ if (url.pathname === "/api/settings/a2a-executor" && req.method === "POST") {
11397
+ const session = requireMutatingApiSession(req, res, config, state);
11398
+ if (!session) return;
11399
+ let payload;
11400
+ try {
11401
+ payload = await parseJsonBody(req);
11402
+ } catch {
11403
+ return writeJson(res, 400, { error: "invalid-json-body" });
11404
+ }
11405
+ const valid = new Set(["auto", "codex", "claude", "ask"]);
11406
+ const preference = valid.has(payload?.preference) ? payload.preference : "auto";
11407
+ if (state.a2aExecutorPreference !== preference) {
11408
+ state.a2aExecutorPreference = preference;
11409
+ await saveState(config.stateFile, state);
11410
+ }
11411
+ return writeJson(res, 200, { ok: true, preference });
11412
+ }
11413
+
11168
11414
  if (url.pathname === "/api/moltbook/scout-status" && req.method === "GET") {
11169
11415
  const session = requireApiSession(req, res, config, state);
11170
11416
  if (!session) {
@@ -11217,9 +11463,35 @@ function createNativeApprovalServer({ config, runtime, state }) {
11217
11463
  userId: config.a2aRelayUserId,
11218
11464
  relayUrl: config.a2aRelayUrl,
11219
11465
  apiKeyConfigured: Boolean(config.a2aApiKey),
11466
+ acceptPublicTasks: config.a2aAcceptPublicTasks === true,
11220
11467
  });
11221
11468
  }
11222
11469
 
11470
+ // Toggle acceptPublicTasks on the A2A relay.
11471
+ if (url.pathname === "/api/a2a/public-tasks" && req.method === "POST") {
11472
+ const session = requireApiSession(req, res, config, state);
11473
+ if (!session) return;
11474
+ const body = await parseJsonBody(req);
11475
+ const accept = body?.accept === true;
11476
+ config.a2aAcceptPublicTasks = accept;
11477
+ state.a2aAcceptPublicTasks = accept;
11478
+ try {
11479
+ await saveState(config.stateFile, state);
11480
+ } catch (error) {
11481
+ console.error(`[a2a-public-tasks-save] ${error.message}`);
11482
+ }
11483
+ // Re-register with relay to propagate the flag.
11484
+ if (config.a2aRelayUrl && config.a2aRelayUserId) {
11485
+ try {
11486
+ await updatePublicTasksFlag({ config, buildAgentCard, acceptPublicTasks: accept });
11487
+ console.log(`[a2a-relay] acceptPublicTasks updated to ${accept}`);
11488
+ } catch (error) {
11489
+ console.error(`[a2a-relay-public-update] ${error.message}`);
11490
+ }
11491
+ }
11492
+ return writeJson(res, 200, { ok: true, acceptPublicTasks: accept });
11493
+ }
11494
+
11223
11495
  // ─── Thread sharing ──────────────────────────────────────────────
11224
11496
 
11225
11497
  function buildThreadShareContent(shareType, body) {
@@ -12074,10 +12346,12 @@ function createNativeApprovalServer({ config, runtime, state }) {
12074
12346
  // ---------------------------------------------------------------
12075
12347
  // Moltbook scout draft channel
12076
12348
  //
12077
- // The standalone scout CLI (`viveworker moltbook propose`) submits
12078
- // a draft reply via POST /api/providers/moltbook/draft, then long-
12079
- // polls /api/providers/moltbook/draft/:token/decision until the
12080
- // phone responds via POST /api/items/moltbook-draft/:token/decision.
12349
+ // The CLI submits a draft via POST /api/providers/moltbook/draft
12350
+ // and exits immediately (fire-and-forget). The draft is persisted
12351
+ // to ~/.viveworker/moltbook-drafts/ and survives bridge restarts.
12352
+ // When the phone approves via POST /api/items/moltbook-draft/:token/decision,
12353
+ // the bridge posts to the Moltbook API and solves the verification
12354
+ // puzzle inline.
12081
12355
  // ---------------------------------------------------------------
12082
12356
 
12083
12357
  if (url.pathname === "/api/providers/moltbook/draft" && req.method === "POST") {
@@ -12110,6 +12384,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12110
12384
  const postBody = typeof body.postBody === "string" ? body.postBody : "";
12111
12385
  const intent = typeof body.intent === "string" ? body.intent : "";
12112
12386
  const slot = cleanText(body.slot || "");
12387
+ const ttlMs = Math.max(60000, Math.min(Number(body.ttlMs) || 86400000, 86400000));
12113
12388
  const draft = {
12114
12389
  token,
12115
12390
  sourceId,
@@ -12125,11 +12400,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
12125
12400
  intent,
12126
12401
  slot,
12127
12402
  contextSummary,
12403
+ ttlMs,
12128
12404
  createdAtMs: Date.now(),
12129
12405
  decisionWaiters: [],
12130
12406
  decision: null,
12131
12407
  };
12132
12408
  runtime.moltbookDraftsByToken.set(token, draft);
12409
+ await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist] ${e.message}`));
12133
12410
  try {
12134
12411
  recordTimelineEntry({
12135
12412
  config,
@@ -12140,7 +12417,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12140
12417
  token,
12141
12418
  kind: "moltbook_draft",
12142
12419
  threadId: "moltbook",
12143
- threadLabel: "Moltbook",
12420
+ threadLabel: postTitle || "Moltbook",
12144
12421
  title: draftType === "original_post"
12145
12422
  ? (postTitle || "Moltbook new post")
12146
12423
  : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
@@ -12258,9 +12535,33 @@ function createNativeApprovalServer({ config, runtime, state }) {
12258
12535
  // ignore
12259
12536
  }
12260
12537
  }
12261
- // Keep the entry in moltbookDraftsByToken briefly so a slow long-
12262
- // poll can still pick it up; sweeper below removes it after 2min.
12263
- setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120 * 1000).unref?.();
12538
+ // Persist decision to disk.
12539
+ await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist-decision] ${e.message}`));
12540
+
12541
+ if (action === "approve") {
12542
+ // Execute posting asynchronously — fire-and-forget from the HTTP handler.
12543
+ (async () => {
12544
+ try {
12545
+ await executeMoltbookDraftPost(draft, config, runtime, state);
12546
+ } catch (postError) {
12547
+ console.error(`[moltbook-draft-post-error] ${postError.message}`);
12548
+ try {
12549
+ await deliverWebPushItem({
12550
+ config, state, kind: "moltbook_draft", token: draft.token,
12551
+ stableId: `moltbook_draft_failed:${draft.sourceId}`,
12552
+ title: "Draft post failed",
12553
+ body: String(postError.message || "").slice(0, 160),
12554
+ });
12555
+ } catch { /* ignore push error */ }
12556
+ }
12557
+ await deleteDraft(token).catch(() => {});
12558
+ setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
12559
+ })();
12560
+ } else {
12561
+ // Deny — clean up.
12562
+ await deleteDraft(token).catch(() => {});
12563
+ setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
12564
+ }
12264
12565
  return writeJson(res, 200, { ok: true, action });
12265
12566
  }
12266
12567
 
@@ -12326,15 +12627,19 @@ function createNativeApprovalServer({ config, runtime, state }) {
12326
12627
  return writeJson(res, 200, detail);
12327
12628
  }
12328
12629
 
12329
- const apiCompletionReplyMatch = url.pathname.match(/^\/api\/items\/completion\/([^/]+)\/reply$/u);
12630
+ const apiCompletionReplyMatch = url.pathname.match(/^\/api\/items\/(completion|assistant_final)\/([^/]+)\/reply$/u);
12330
12631
  if (apiCompletionReplyMatch && req.method === "POST") {
12331
12632
  const session = requireMutatingApiSession(req, res, config, state);
12332
12633
  if (!session) {
12333
12634
  return;
12334
12635
  }
12335
12636
 
12336
- const token = decodeURIComponent(apiCompletionReplyMatch[1]);
12337
- const completionItem = historyItemByToken(runtime, "completion", token);
12637
+ const replyKind = apiCompletionReplyMatch[1];
12638
+ const token = decodeURIComponent(apiCompletionReplyMatch[2]);
12639
+ // Try history items first, fall back to timeline entries for assistant_final
12640
+ // (history items may be evicted by maxHistoryItems limit)
12641
+ const completionItem = historyItemByToken(runtime, replyKind, token)
12642
+ || (replyKind === "assistant_final" ? timelineEntryByToken(runtime, token, replyKind) : null);
12338
12643
  if (!completionItem) {
12339
12644
  return writeJson(res, 404, { error: "item-not-found" });
12340
12645
  }
@@ -14204,6 +14509,105 @@ function readMoltbookEnvKey() {
14204
14509
  return "";
14205
14510
  }
14206
14511
 
14512
+ // Post an approved Moltbook draft (reply or original post), solve the
14513
+ // verification puzzle, and update scout state. Called asynchronously from
14514
+ // the decision handler.
14515
+ async function executeMoltbookDraftPost(draft, config, runtime, state) {
14516
+ if (!config.moltbookApiKey) throw new Error("MOLTBOOK_API_KEY not configured");
14517
+ const mb = createMoltbookClient(config.moltbookApiKey);
14518
+
14519
+ const finalText = draft.decision.text || draft.draftText;
14520
+ const finalTitle = draft.decision.title || draft.postTitle;
14521
+
14522
+ let verification;
14523
+
14524
+ if (draft.draftType === "original_post") {
14525
+ const result = await mb("/posts", {
14526
+ method: "POST",
14527
+ body: JSON.stringify({
14528
+ submolt_name: draft.submoltName,
14529
+ submolt: draft.submoltName,
14530
+ title: finalTitle,
14531
+ content: finalText,
14532
+ }),
14533
+ });
14534
+ const post = result?.post || null;
14535
+ verification = post?.verification || null;
14536
+ console.log(`[moltbook-draft-post] Posted original post (id=${post?.id})`);
14537
+
14538
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
14539
+ recordComposeAttempt(scoutState, finalTitle, post?.id);
14540
+ await writeScoutState(scoutState);
14541
+ } else {
14542
+ const result = await mb(`/posts/${draft.postId}/comments`, {
14543
+ method: "POST",
14544
+ body: JSON.stringify({
14545
+ content: finalText,
14546
+ ...(draft.parentCommentId ? { parent_id: draft.parentCommentId } : {}),
14547
+ }),
14548
+ });
14549
+ const comment = result?.comment || null;
14550
+ verification = comment?.verification || null;
14551
+ console.log(`[moltbook-draft-post] Posted reply (commentId=${comment?.id}) to post ${draft.postId}`);
14552
+
14553
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
14554
+ scoutState.sentToday += 1;
14555
+ markPostSeen(scoutState, draft.postId, "published");
14556
+ await writeScoutState(scoutState);
14557
+ }
14558
+
14559
+ // Solve verification puzzle if present.
14560
+ if (verification) {
14561
+ let answer = solveVerificationPuzzle(verification.challenge_text);
14562
+ const source = answer != null ? "solver" : null;
14563
+ if (answer == null) {
14564
+ answer = await solvePuzzleWithLLM(verification.challenge_text);
14565
+ }
14566
+ if (answer != null) {
14567
+ try {
14568
+ await mb("/verify", {
14569
+ method: "POST",
14570
+ body: JSON.stringify({ verification_code: verification.verification_code, answer }),
14571
+ });
14572
+ console.log(`[moltbook-draft-verify] Verified with answer ${answer}`);
14573
+ } catch (verifyError) {
14574
+ // Wrong answer from solver — retry with LLM.
14575
+ if (/incorrect/i.test(verifyError.message) && source === "solver") {
14576
+ const llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
14577
+ if (llmAnswer && llmAnswer !== answer) {
14578
+ try {
14579
+ await mb("/verify", {
14580
+ method: "POST",
14581
+ body: JSON.stringify({ verification_code: verification.verification_code, answer: llmAnswer }),
14582
+ });
14583
+ console.log(`[moltbook-draft-verify] Verified with LLM retry answer ${llmAnswer}`);
14584
+ } catch (retryError) {
14585
+ console.error(`[moltbook-draft-verify] LLM retry failed: ${retryError.message}`);
14586
+ }
14587
+ }
14588
+ } else {
14589
+ console.error(`[moltbook-draft-verify] Failed: ${verifyError.message}`);
14590
+ }
14591
+ }
14592
+ } else {
14593
+ console.error(`[moltbook-draft-verify] Could not solve puzzle for draft ${draft.token}`);
14594
+ }
14595
+ }
14596
+
14597
+ // Push notification for successful post.
14598
+ try {
14599
+ const pushTitle = draft.draftType === "original_post" ? finalTitle : `Reply → ${draft.postTitle || "Moltbook"}`;
14600
+ await deliverWebPushItem({
14601
+ config, state, kind: "moltbook_draft", token: draft.token,
14602
+ stableId: `moltbook_draft_posted:${draft.sourceId}`,
14603
+ title: "Moltbook posted",
14604
+ body: truncate(singleLine(pushTitle), 160),
14605
+ });
14606
+ } catch { /* ignore push error */ }
14607
+
14608
+ console.log(`[moltbook-draft] Successfully posted draft ${draft.token} (type=${draft.draftType})`);
14609
+ }
14610
+
14207
14611
  function buildConfig(cli) {
14208
14612
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
14209
14613
  const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
@@ -14489,6 +14893,7 @@ async function loadState(stateFile) {
14489
14893
  pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
14490
14894
  pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
14491
14895
  claudeAwayMode: parsed.claudeAwayMode === true,
14896
+ a2aAcceptPublicTasks: parsed.a2aAcceptPublicTasks === true,
14492
14897
  };
14493
14898
  } catch {
14494
14899
  return {
@@ -15034,11 +15439,17 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
15034
15439
 
15035
15440
  const nextHistoryItems = normalizeHistoryItems(
15036
15441
  runtime.recentHistoryItems.map((item) => {
15442
+ // Moltbook / A2A / thread-share entries carry their own titles — skip relabel.
15443
+ const itemKind = item.kind || "";
15444
+ if (itemKind === "moltbook_reply" || itemKind === "moltbook_draft" || itemKind === "a2a_task" || itemKind === "a2a_task_result" || itemKind === "thread_share") {
15445
+ return item;
15446
+ }
15037
15447
  const conversationId = extractConversationIdFromStableId(item.stableId);
15038
15448
  if (!conversationId) {
15039
15449
  return item;
15040
15450
  }
15041
- const nativeThreadLabel = getNativeThreadLabel({
15451
+ const claudeTitle = runtime.claudeSessionTitles.get(conversationId) || "";
15452
+ const nativeThreadLabel = claudeTitle || getNativeThreadLabel({
15042
15453
  runtime,
15043
15454
  conversationId,
15044
15455
  cwd: "",
@@ -15069,10 +15480,10 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
15069
15480
 
15070
15481
  const nextTimelineEntries = normalizeTimelineEntries(
15071
15482
  runtime.recentTimelineEntries.map((entry) => {
15072
- // Moltbook entries carry their own author-provided title/threadLabel
15073
- // (e.g. "@broanbot commented"). Skip the native-thread relabel pass so
15074
- // we don't overwrite them with the UUID-head fallback.
15075
- if (normalizeProvider(entry.provider) === "moltbook") {
15483
+ // Moltbook / A2A / thread-share entries carry their own titles.
15484
+ // Skip the native-thread relabel pass so we don't overwrite them.
15485
+ const entryKind = entry.kind || "";
15486
+ if (entryKind === "moltbook_reply" || entryKind === "moltbook_draft" || entryKind === "a2a_task" || entryKind === "a2a_task_result" || entryKind === "thread_share") {
15076
15487
  return entry;
15077
15488
  }
15078
15489
  const threadId = cleanText(entry.threadId || "");
@@ -15651,6 +16062,7 @@ async function main() {
15651
16062
 
15652
16063
  // --- A2A Relay ---
15653
16064
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
16065
+ config.a2aAcceptPublicTasks = state.a2aAcceptPublicTasks === true;
15654
16066
  const regResult = await registerWithRelay({ config, buildAgentCard });
15655
16067
  if (regResult.ok) {
15656
16068
  startRelayPolling({