viveworker 0.4.6 → 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"]);
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;
@@ -85,6 +86,8 @@ const runtime = {
85
86
  moltbookItemsByToken: new Map(),
86
87
  moltbookDraftsByToken: new Map(),
87
88
  a2aTasksByToken: new Map(),
89
+ threadSharesByToken: new Map(),
90
+ threadRegistry: new Map(),
88
91
  planDetailsByToken: new Map(),
89
92
  recentHistoryItems: [],
90
93
  recentTimelineEntries: [],
@@ -94,10 +97,55 @@ const runtime = {
94
97
  stopping: false,
95
98
  };
96
99
  const state = await loadState(config.stateFile);
100
+ runtime.threadRegistry = await loadThreadRegistry(config.threadRegistryFile);
97
101
  await syncClaudeAwayModeSentinel(config, state.claudeAwayMode === true);
98
102
  const migratedPairedDevicesStateChanged = migratePairedDevicesState({ config, state });
99
103
  const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, runtime, state });
100
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?.();
101
149
  const initialHistoryItems = normalizeHistoryItems(state.recentHistoryItems ?? [], config.maxHistoryItems);
102
150
  const initialTimelineEntries = normalizeTimelineEntries(state.recentTimelineEntries ?? [], config.maxTimelineEntries);
103
151
  const normalizedHistoryStateChanged =
@@ -221,6 +269,8 @@ function buildSessionLocalePayload(config, state, deviceId) {
221
269
  moltbookEnabled: Boolean(config.moltbookApiKey),
222
270
  a2aEnabled: Boolean(config.a2aApiKey),
223
271
  a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
272
+ a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
273
+ a2aExecutorPreference: state.a2aExecutorPreference || "ask",
224
274
  };
225
275
  }
226
276
 
@@ -246,6 +296,15 @@ function kindTitle(locale, kind) {
246
296
  return t(locale, "common.fileEvent");
247
297
  case "diff_thread":
248
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";
249
308
  default:
250
309
  return t(locale, "common.item");
251
310
  }
@@ -290,6 +349,7 @@ function notificationIconPrefix(kind) {
290
349
  case "completion":
291
350
  return "✅";
292
351
  case "a2a_task":
352
+ case "a2a_task_result":
293
353
  return "🤝";
294
354
  default:
295
355
  return "";
@@ -2003,6 +2063,7 @@ function normalizeProvider(value) {
2003
2063
  if (normalized === "claude") return "claude";
2004
2064
  if (normalized === "moltbook") return "moltbook";
2005
2065
  if (normalized === "a2a") return "a2a";
2066
+ if (normalized === "viveworker") return "viveworker";
2006
2067
  return "codex";
2007
2068
  }
2008
2069
 
@@ -2047,7 +2108,12 @@ function normalizeHistoryItem(raw) {
2047
2108
  const kind = cleanText(raw.kind ?? "");
2048
2109
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
2049
2110
  const rawThreadLabel = cleanText(raw.threadLabel ?? "");
2050
- 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);
2051
2117
  const title =
2052
2118
  cleanText(raw.title ?? "") || (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2053
2119
  const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
@@ -2081,6 +2147,11 @@ function normalizeHistoryItem(raw) {
2081
2147
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
2082
2148
  tone: cleanText(raw.tone ?? "") || "secondary",
2083
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) } : {}),
2084
2155
  };
2085
2156
  }
2086
2157
 
@@ -2109,7 +2180,7 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2109
2180
  const deduped = [];
2110
2181
  const seen = new Set();
2111
2182
  const perProviderCount = {};
2112
- const knownProviders = new Set(["codex", "claude", "moltbook", "a2a"]);
2183
+ const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
2113
2184
  const saturatedProviders = new Set();
2114
2185
  for (const item of normalized) {
2115
2186
  if (seen.has(item.stableId)) {
@@ -2207,8 +2278,10 @@ function normalizeTimelineEntry(raw) {
2207
2278
  (kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
2208
2279
  "";
2209
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";
2210
2283
  const threadLabel =
2211
- rawProvider === "moltbook"
2284
+ skipThreadLabelRewrite
2212
2285
  ? cleanText(raw.threadLabel ?? "")
2213
2286
  : preferTitleOnlyJsonThreadLabel(
2214
2287
  cleanText(raw.threadLabel ?? ""),
@@ -2266,6 +2339,9 @@ function normalizeTimelineEntry(raw) {
2266
2339
  ...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
2267
2340
  ...(raw.submoltName != null ? { submoltName: cleanText(raw.submoltName) } : {}),
2268
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) } : {}),
2269
2345
  };
2270
2346
  }
2271
2347
 
@@ -3234,8 +3310,19 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3234
3310
  // conversation that should not appear on the user-facing timeline.
3235
3311
  if (/^<(?:task-notification|system-reminder)\b/i.test(text.trim())) continue;
3236
3312
 
3237
- const kind = type === "user" ? "user_message" : "assistant_final";
3238
- 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}`;
3239
3326
  const entry = normalizeTimelineEntry({
3240
3327
  stableId,
3241
3328
  token: historyToken(stableId),
@@ -3252,6 +3339,46 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3252
3339
  });
3253
3340
  if (entry) {
3254
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
+ }
3255
3382
  }
3256
3383
  }
3257
3384
 
@@ -3397,6 +3524,29 @@ async function processSqliteTimelineLog({ config, runtime, state, now }) {
3397
3524
  state,
3398
3525
  entry,
3399
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
+ }
3400
3550
  }
3401
3551
 
3402
3552
  state.sqliteMessageCursorId = cursorId;
@@ -6970,6 +7120,20 @@ class NativeIpcClient {
6970
7120
 
6971
7121
  this.runtime.threadStates.set(normalized.conversationId, nextState);
6972
7122
 
7123
+ // Register / update in the persistent thread registry.
7124
+ const regLabel = extractThreadLabelFromState(nextState)
7125
+ || getThreadName(this.runtime.sessionIndex, this.runtime.rolloutThreadLabels, normalized.conversationId, nextState.cwd || "", "");
7126
+ if (registerThread(this.runtime, {
7127
+ id: normalized.conversationId,
7128
+ label: regLabel,
7129
+ tool: "codex",
7130
+ cwd: nextState.cwd || "",
7131
+ lastSeenAtMs: Date.now(),
7132
+ active: true,
7133
+ })) {
7134
+ saveThreadRegistry(this.config.threadRegistryFile, this.runtime.threadRegistry).catch(() => {});
7135
+ }
7136
+
6973
7137
  await this.onThreadStateChanged({
6974
7138
  conversationId: normalized.conversationId,
6975
7139
  previousRequests,
@@ -9039,16 +9203,18 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9039
9203
  for (const draft of runtime.moltbookDraftsByToken.values()) {
9040
9204
  if (draft.decision) continue;
9041
9205
  const title = draft.postTitle ? `draft → ${draft.postTitle}` : "Moltbook draft";
9206
+ const draftThreadLabel = draft.postTitle || "Moltbook";
9042
9207
  items.push({
9043
9208
  kind: "moltbook_draft",
9044
9209
  token: draft.token,
9045
9210
  threadId: "moltbook",
9046
- threadLabel: "Moltbook",
9211
+ threadLabel: draftThreadLabel,
9047
9212
  title,
9048
9213
  summary: draft.contextSummary || String(draft.draftText || "").slice(0, 160),
9049
9214
  primaryLabel: t(locale, "server.action.review"),
9050
9215
  createdAtMs: Number(draft.createdAtMs) || now,
9051
9216
  provider: "moltbook",
9217
+ draftType: draft.draftType || "reply",
9052
9218
  });
9053
9219
  }
9054
9220
 
@@ -9058,7 +9224,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9058
9224
  kind: "a2a_task",
9059
9225
  token: task.token,
9060
9226
  threadId: "a2a",
9061
- threadLabel: "A2A",
9227
+ threadLabel: cleanText(task.instruction || "").slice(0, 80),
9062
9228
  title: `A2A: ${cleanText(task.instruction || "").slice(0, 80)}`,
9063
9229
  summary: cleanText(task.instruction || "").slice(0, 160),
9064
9230
  primaryLabel: t(locale, "server.action.review"),
@@ -9067,6 +9233,24 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9067
9233
  });
9068
9234
  }
9069
9235
 
9236
+ for (const share of runtime.threadSharesByToken.values()) {
9237
+ if (share.decision) continue;
9238
+ const title = share.sourceLabel
9239
+ ? `${share.sourceLabel} → ${share.targetLabel || share.targetTool}`
9240
+ : `→ ${share.targetLabel || share.targetTool}`;
9241
+ items.push({
9242
+ kind: "thread_share",
9243
+ token: share.token,
9244
+ threadId: "thread_share",
9245
+ threadLabel: "Thread Share",
9246
+ title,
9247
+ summary: String(share.content || "").slice(0, 160),
9248
+ primaryLabel: t(locale, "server.action.review"),
9249
+ createdAtMs: Number(share.createdAtMs) || now,
9250
+ provider: "viveworker",
9251
+ });
9252
+ }
9253
+
9070
9254
  // Moltbook reply items intentionally do not appear in the unhandled list.
9071
9255
  // Reply drafting is delegated to Codex/Claude Desktop via the
9072
9256
  // `viveworker moltbook` CLI, so they live in the timeline only.
@@ -9077,8 +9261,15 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9077
9261
  function buildCompletedInboxItems(runtime, state, config, locale) {
9078
9262
  const items = normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems);
9079
9263
  runtime.recentHistoryItems = items;
9264
+ const completedKinds = new Set(["assistant_final", "approval", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task_result"]);
9080
9265
  return items
9081
- .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
+ })
9082
9273
  .slice()
9083
9274
  .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0))
9084
9275
  .map((item) => ({
@@ -9086,12 +9277,19 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9086
9277
  token: item.token,
9087
9278
  threadId: cleanText(item.threadId || extractConversationIdFromStableId(item.stableId) || ""),
9088
9279
  threadLabel: item.threadLabel || "",
9089
- 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),
9090
9287
  summary: item.summary,
9091
9288
  fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
9092
9289
  primaryLabel: t(locale, "server.action.detail"),
9093
9290
  createdAtMs: item.createdAtMs,
9094
9291
  provider: normalizeProvider(item.provider),
9292
+ ...(item.draftType != null ? { draftType: item.draftType } : {}),
9095
9293
  }));
9096
9294
  }
9097
9295
 
@@ -9445,6 +9643,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
9445
9643
  outcome: entry.outcome || "",
9446
9644
  createdAtMs: entry.createdAtMs,
9447
9645
  provider: normalizeProvider(entry.provider),
9646
+ ...(entry.draftType != null ? { draftType: entry.draftType } : {}),
9448
9647
  }));
9449
9648
 
9450
9649
  return {
@@ -9661,8 +9860,11 @@ function historyItemThreadId(item) {
9661
9860
  return cleanText(item?.threadId || extractConversationIdFromStableId(item?.stableId) || "");
9662
9861
  }
9663
9862
 
9664
- function isLatestCompletionHistoryItem(runtime, item) {
9665
- 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)) {
9666
9868
  return false;
9667
9869
  }
9668
9870
 
@@ -9672,14 +9874,24 @@ function isLatestCompletionHistoryItem(runtime, item) {
9672
9874
  return false;
9673
9875
  }
9674
9876
 
9675
- const latestForThread = runtime.recentHistoryItems.find(
9676
- (entry) => cleanText(entry?.kind || "") === "completion" && historyItemThreadId(entry) === threadId
9677
- );
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
+ }
9678
9889
  return cleanText(latestForThread?.token || "") === token;
9679
9890
  }
9680
9891
 
9681
9892
  function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
9682
- if (!runtime || cleanText(completionItem?.kind || "") !== "completion") {
9893
+ const itemKind = cleanText(completionItem?.kind || "");
9894
+ if (!runtime || !REPLYABLE_HISTORY_KINDS.has(itemKind)) {
9683
9895
  return null;
9684
9896
  }
9685
9897
 
@@ -9705,10 +9917,10 @@ function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
9705
9917
  function buildHistoryDetail(item, locale, runtime = null) {
9706
9918
  const threadId = historyItemThreadId(item);
9707
9919
  const replyEnabled =
9708
- item.kind === "completion" &&
9920
+ REPLYABLE_HISTORY_KINDS.has(item.kind) &&
9709
9921
  Boolean(threadId) &&
9710
9922
  Boolean(runtime?.ipcClient) &&
9711
- isLatestCompletionHistoryItem(runtime, item);
9923
+ isLatestReplyableHistoryItem(runtime, item);
9712
9924
  return {
9713
9925
  kind: item.kind,
9714
9926
  token: item.token,
@@ -10162,7 +10374,20 @@ async function handleCompletionReply({
10162
10374
  if (!conversationId) {
10163
10375
  throw new Error("completion-reply-unavailable");
10164
10376
  }
10165
- 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)) {
10166
10391
  throw new Error("completion-reply-unavailable");
10167
10392
  }
10168
10393
  const newerThreadMessage = findNewerThreadMessageAfterCompletion(runtime, completionItem);
@@ -10449,7 +10674,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
10449
10674
  title: approval.title,
10450
10675
  threadLabel: approval.threadLabel || "",
10451
10676
  messageText: `${approvalDecisionMessage(decision, config.defaultLocale, approval.provider)}\n\n${approval.messageText}`,
10452
- summary: approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
10677
+ summary: formatNotificationBody(approval.messageText, 160) || approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
10453
10678
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
10454
10679
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
10455
10680
  diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
@@ -10476,7 +10701,32 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10476
10701
  }
10477
10702
  if (timelineMessageKinds.has(kind)) {
10478
10703
  const entry = timelineEntryByToken(runtime, token, kind);
10479
- 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;
10480
10730
  }
10481
10731
  if (kind === "approval") {
10482
10732
  const approval = runtime.nativeApprovalsByToken.get(token);
@@ -10579,20 +10829,21 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10579
10829
  };
10580
10830
  }
10581
10831
 
10582
- if (kind === "a2a_task") {
10583
- 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;
10584
10834
  const entry = task
10585
10835
  ? null
10586
- : runtime.recentTimelineEntries.find((e) => e.kind === "a2a_task" && e.token === token);
10836
+ : runtime.recentTimelineEntries.find((e) => e.kind === kind && e.token === token);
10587
10837
  const source = task || entry;
10588
10838
  if (!source) return null;
10589
- 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 || "" : "";
10590
10841
  const messageHtml = escapeHtml(instruction)
10591
10842
  .split("\n")
10592
10843
  .map((line) => `<p>${line}</p>`)
10593
10844
  .join("");
10594
10845
  return {
10595
- kind: "a2a_task",
10846
+ kind,
10596
10847
  token,
10597
10848
  threadId: "a2a",
10598
10849
  threadLabel: "A2A",
@@ -10601,8 +10852,9 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10601
10852
  messageHtml,
10602
10853
  provider: "a2a",
10603
10854
  instruction,
10855
+ messageText: responseText,
10604
10856
  taskId: task?.id || "",
10605
- taskStatus: task?.status || entry?.taskStatus || "unknown",
10857
+ taskStatus: task?.status || entry?.taskStatus || (kind === "a2a_task_result" ? "completed" : "submitted"),
10606
10858
  callerInfo: task?.callerInfo || {},
10607
10859
  createdAtMs: source.createdAtMs || Date.now(),
10608
10860
  readOnly: !task || task.status !== "submitted",
@@ -10611,6 +10863,42 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10611
10863
  };
10612
10864
  }
10613
10865
 
10866
+ if (kind === "thread_share") {
10867
+ const share = runtime.threadSharesByToken.get(token);
10868
+ const entry = share
10869
+ ? null
10870
+ : runtime.recentTimelineEntries.find((e) => e.kind === "thread_share" && e.token === token);
10871
+ const source = share || entry;
10872
+ if (!source) return null;
10873
+ const content = share ? share.content : entry.messageText || entry.summary || "";
10874
+ const messageHtml = escapeHtml(content)
10875
+ .split("\n")
10876
+ .map((line) => `<p>${line}</p>`)
10877
+ .join("");
10878
+ return {
10879
+ kind: "thread_share",
10880
+ token,
10881
+ threadId: "thread_share",
10882
+ threadLabel: "Thread Share",
10883
+ title: source.title || "Thread Share",
10884
+ summary: source.summary || cleanText(content).slice(0, 160),
10885
+ messageHtml,
10886
+ provider: "viveworker",
10887
+ shareContent: content,
10888
+ shareType: share?.shareType || entry?.shareType || "message",
10889
+ sourceTool: share?.sourceTool || entry?.sourceTool || "",
10890
+ sourceLabel: share?.sourceLabel || entry?.sourceLabel || "",
10891
+ targetTool: share?.targetTool || entry?.targetTool || "",
10892
+ targetLabel: share?.targetLabel || entry?.targetLabel || "",
10893
+ targetConversationId: share?.targetConversationId || entry?.targetConversationId || "",
10894
+ contextFiles: share?.contextFiles || entry?.contextFiles || [],
10895
+ createdAtMs: source.createdAtMs || Date.now(),
10896
+ readOnly: !share || Boolean(share?.decision),
10897
+ actions: [],
10898
+ threadShareEnabled: Boolean(share) && !share.decision,
10899
+ };
10900
+ }
10901
+
10614
10902
  const historyItem = historyItemByToken(runtime, kind, token);
10615
10903
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
10616
10904
  }
@@ -10871,14 +11159,25 @@ function createNativeApprovalServer({ config, runtime, state }) {
10871
11159
  }
10872
11160
  const action = String(body.action || "") === "approve" ? "approve" : "deny";
10873
11161
  const instruction = cleanText(body.instruction || "");
11162
+ const requestedExecutor = cleanText(body.executor || "");
10874
11163
  resolveA2ATaskDecision(task, { action, instruction });
10875
11164
 
10876
11165
  if (action === "approve") {
10877
- // 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
10878
11177
  (async () => {
10879
11178
  try {
10880
11179
  const { executeA2ATask } = await import("./a2a-executor.mjs");
10881
- await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState });
11180
+ await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState }, executor);
10882
11181
  } catch (error) {
10883
11182
  console.error(`[a2a-execute-error] ${error.message}`);
10884
11183
  failA2ATask(task, error.message);
@@ -11094,6 +11393,24 @@ function createNativeApprovalServer({ config, runtime, state }) {
11094
11393
  return writeJson(res, 200, { ok: true, enabled });
11095
11394
  }
11096
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
+
11097
11414
  if (url.pathname === "/api/moltbook/scout-status" && req.method === "GET") {
11098
11415
  const session = requireApiSession(req, res, config, state);
11099
11416
  if (!session) {
@@ -11146,9 +11463,347 @@ function createNativeApprovalServer({ config, runtime, state }) {
11146
11463
  userId: config.a2aRelayUserId,
11147
11464
  relayUrl: config.a2aRelayUrl,
11148
11465
  apiKeyConfigured: Boolean(config.a2aApiKey),
11466
+ acceptPublicTasks: config.a2aAcceptPublicTasks === true,
11149
11467
  });
11150
11468
  }
11151
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
+
11495
+ // ─── Thread sharing ──────────────────────────────────────────────
11496
+
11497
+ function buildThreadShareContent(shareType, body) {
11498
+ if (shareType === "plan_review") {
11499
+ const sections = [];
11500
+ if (body.context) sections.push(`## Context\n${body.context}`);
11501
+ if (body.plan) sections.push(`## Plan\n${body.plan}`);
11502
+ if (Array.isArray(body.files) && body.files.length) {
11503
+ sections.push(`## Relevant files\n${body.files.map((f) => `- ${f}`).join("\n")}`);
11504
+ }
11505
+ sections.push(`## Instruction\n${body.instruction || "共有されたプランの内容を把握し、実現可能性や改善点を検討してください。"}`);
11506
+ return sections.join("\n\n");
11507
+ }
11508
+ if (shareType === "handoff") {
11509
+ const sections = [];
11510
+ if (body.summary) sections.push(`## Summary\n${body.summary}`);
11511
+ if (Array.isArray(body.completed) && body.completed.length) {
11512
+ sections.push(`## Completed\n${body.completed.map((t) => `- ${t}`).join("\n")}`);
11513
+ }
11514
+ if (Array.isArray(body.remaining) && body.remaining.length) {
11515
+ sections.push(`## Remaining\n${body.remaining.map((t) => `- ${t}`).join("\n")}`);
11516
+ }
11517
+ if (Array.isArray(body.decisions) && body.decisions.length) {
11518
+ sections.push(`## Key decisions\n${body.decisions.map((d) => `- ${d}`).join("\n")}`);
11519
+ }
11520
+ if (Array.isArray(body.modifiedFiles) && body.modifiedFiles.length) {
11521
+ sections.push(`## Modified files\n${body.modifiedFiles.map((f) => `- ${f}`).join("\n")}`);
11522
+ }
11523
+ sections.push(`## Instruction\n${body.instruction || "これまでの作業内容と経緯を把握した上で、次に何をすべきか検討してください。"}`);
11524
+ return sections.join("\n\n");
11525
+ }
11526
+ // Default: message type — use raw content.
11527
+ return cleanText(body?.content || "");
11528
+ }
11529
+
11530
+ // List known threads (active + registry) for the share target picker.
11531
+ if (url.pathname === "/api/threads/list" && req.method === "GET") {
11532
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11533
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
11534
+ if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
11535
+ const codexConnected = Boolean(runtime.ipcClient?.clientId);
11536
+ const activeIds = new Set();
11537
+ const threads = [];
11538
+ // Active Codex threads (from IPC).
11539
+ for (const [conversationId, threadState] of runtime.threadStates) {
11540
+ activeIds.add(conversationId);
11541
+ const label = getThreadName(
11542
+ runtime.sessionIndex,
11543
+ runtime.rolloutThreadLabels,
11544
+ conversationId,
11545
+ cleanText(threadState?.cwd || ""),
11546
+ ""
11547
+ );
11548
+ threads.push({
11549
+ conversationId,
11550
+ label: label || conversationId.slice(0, 8),
11551
+ tool: "codex",
11552
+ cwd: cleanText(threadState?.cwd || ""),
11553
+ hasOwner: runtime.threadOwnerClientIds.has(conversationId),
11554
+ active: true,
11555
+ });
11556
+ }
11557
+ // Registry entries not already covered by active threads.
11558
+ for (const entry of runtime.threadRegistry.values()) {
11559
+ if (!activeIds.has(entry.id)) {
11560
+ threads.push({
11561
+ conversationId: entry.id,
11562
+ label: entry.label || entry.id.slice(0, 8),
11563
+ tool: entry.tool || "codex",
11564
+ cwd: entry.cwd || "",
11565
+ hasOwner: false,
11566
+ active: false,
11567
+ lastSeenAtMs: entry.lastSeenAtMs || 0,
11568
+ });
11569
+ }
11570
+ }
11571
+ return writeJson(res, 200, { threads, codexConnected });
11572
+ }
11573
+
11574
+ // Submit a thread share request (creates an approval item on the phone).
11575
+ if (url.pathname === "/api/threads/share" && req.method === "POST") {
11576
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11577
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
11578
+ if (!hookAuth) { const session = requireApiSession(req, res, config, state); if (!session) return; }
11579
+ const body = await parseJsonBody(req);
11580
+ const shareType = cleanText(body?.shareType || "message");
11581
+ const sourceTool = cleanText(body?.sourceTool || "");
11582
+ const sourceLabel = cleanText(body?.sourceLabel || "");
11583
+ const targetConversationId = cleanText(body?.targetConversationId || "");
11584
+ let targetTool = cleanText(body?.targetTool || "");
11585
+ let targetCwd = cleanText(body?.targetCwd || "");
11586
+ const contextFiles = Array.isArray(body?.contextFiles)
11587
+ ? body.contextFiles.map((f) => cleanText(String(f))).filter(Boolean)
11588
+ : [];
11589
+
11590
+ // Auto-resolve targetTool and targetCwd from registry if not provided.
11591
+ if (targetConversationId) {
11592
+ const regEntry = runtime.threadRegistry.get(targetConversationId);
11593
+ if (regEntry) {
11594
+ if (!targetTool) targetTool = regEntry.tool || "";
11595
+ if (!targetCwd) targetCwd = regEntry.cwd || "";
11596
+ }
11597
+ }
11598
+
11599
+ // Build content from structured fields depending on shareType.
11600
+ const content = buildThreadShareContent(shareType, body);
11601
+ if (!content && contextFiles.length === 0) {
11602
+ return writeJson(res, 400, { error: "missing-content" });
11603
+ }
11604
+ if (!targetConversationId && !targetTool) {
11605
+ return writeJson(res, 400, { error: "missing-target" });
11606
+ }
11607
+ const shareId = crypto.randomUUID();
11608
+ const token = historyToken(`thread_share:${shareId}`);
11609
+ const targetLabel = targetConversationId
11610
+ ? (getThreadName(runtime.sessionIndex, runtime.rolloutThreadLabels, targetConversationId, runtime.threadStates.get(targetConversationId)?.cwd || "", "") || runtime.threadRegistry.get(targetConversationId)?.label || "")
11611
+ : targetTool;
11612
+ const share = {
11613
+ token,
11614
+ shareId,
11615
+ shareType,
11616
+ content: content || "",
11617
+ contextFiles,
11618
+ sourceTool,
11619
+ sourceLabel,
11620
+ targetConversationId,
11621
+ targetTool: targetTool || "codex",
11622
+ targetCwd,
11623
+ targetLabel,
11624
+ createdAtMs: Date.now(),
11625
+ decision: null,
11626
+ decisionWaiters: [],
11627
+ };
11628
+ runtime.threadSharesByToken.set(token, share);
11629
+ try {
11630
+ const title = sourceLabel
11631
+ ? `${sourceLabel} → ${targetLabel || targetTool}`
11632
+ : `→ ${targetLabel || targetTool}`;
11633
+ recordTimelineEntry({
11634
+ config, runtime, state,
11635
+ entry: {
11636
+ stableId: `thread_share:${shareId}`,
11637
+ token,
11638
+ kind: "thread_share",
11639
+ threadId: "thread_share",
11640
+ threadLabel: "Thread Share",
11641
+ title,
11642
+ summary: String(content).slice(0, 160),
11643
+ messageText: content,
11644
+ createdAtMs: share.createdAtMs,
11645
+ readOnly: false,
11646
+ provider: "viveworker",
11647
+ },
11648
+ });
11649
+ await saveState(config.stateFile, state);
11650
+ } catch (error) {
11651
+ console.error(`[thread-share-timeline] ${error.message}`);
11652
+ }
11653
+ try {
11654
+ await deliverWebPushItem({
11655
+ config, state,
11656
+ kind: "thread_share",
11657
+ token,
11658
+ stableId: `thread_share:${shareId}`,
11659
+ title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
11660
+ body: String(content).slice(0, 160),
11661
+ });
11662
+ } catch (error) {
11663
+ console.error(`[thread-share-push] ${error.message}`);
11664
+ }
11665
+ return writeJson(res, 200, { ok: true, token, shareId });
11666
+ }
11667
+
11668
+ // Decision endpoint for thread share (approve / deny from phone).
11669
+ const threadShareDecisionMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/decision$/);
11670
+ if (threadShareDecisionMatch && req.method === "POST") {
11671
+ const session = requireApiSession(req, res, config, state);
11672
+ if (!session) return;
11673
+ const token = cleanText(threadShareDecisionMatch[1]);
11674
+ const share = runtime.threadSharesByToken.get(token);
11675
+ if (!share) {
11676
+ return writeJson(res, 404, { error: "not-found" });
11677
+ }
11678
+ if (share.decision) {
11679
+ return writeJson(res, 200, { ok: true, decision: share.decision });
11680
+ }
11681
+ const body = await parseJsonBody(req);
11682
+ const decision = cleanText(body?.decision || "");
11683
+ const editedContent = typeof body?.editedContent === "string" ? body.editedContent : null;
11684
+ if (decision !== "approve" && decision !== "deny") {
11685
+ return writeJson(res, 400, { error: "invalid-decision" });
11686
+ }
11687
+ share.decision = decision;
11688
+ if (editedContent !== null) {
11689
+ share.content = editedContent;
11690
+ }
11691
+
11692
+ // Deliver the shared content to the target.
11693
+ let deliveryStatus = "delivered";
11694
+ if (decision === "approve") {
11695
+ const prefix = share.sourceLabel ? `[Shared from ${share.sourceLabel}]\n\n` : "[Shared from another thread]\n\n";
11696
+ // Append context file references if present.
11697
+ if (share.contextFiles && share.contextFiles.length > 0) {
11698
+ const fileList = share.contextFiles.map((f) => `- ${f}`).join("\n");
11699
+ share.content = (share.content ? share.content + "\n\n" : "")
11700
+ + `## Context files\n\nRead the following files for full conversation context:\n${fileList}`;
11701
+ }
11702
+ let injectedViaIpc = false;
11703
+
11704
+ // Try Codex IPC injection if targeting a specific thread.
11705
+ if (share.targetConversationId && runtime.ipcClient?.clientId) {
11706
+ const ownerClientId = runtime.threadOwnerClientIds.get(share.targetConversationId) ?? null;
11707
+ const turnStartParams = { input: buildTurnInput(prefix + share.content) };
11708
+ // Try direct first, then fall back to thread-follower.
11709
+ for (const transport of ["direct", "follower"]) {
11710
+ try {
11711
+ if (transport === "direct" && ownerClientId) {
11712
+ await runtime.ipcClient.startTurnDirect(share.targetConversationId, turnStartParams, ownerClientId);
11713
+ } else {
11714
+ await runtime.ipcClient.startTurn(share.targetConversationId, turnStartParams, ownerClientId);
11715
+ }
11716
+ injectedViaIpc = true;
11717
+ console.log(`[thread-share] Delivered to Codex thread ${share.targetConversationId} via ${transport}`);
11718
+ break;
11719
+ } catch (error) {
11720
+ console.error(`[thread-share-ipc-${transport}] ${error.message}`);
11721
+ }
11722
+ }
11723
+ }
11724
+
11725
+ // Write to file inbox (always for claude-code targets, fallback for failed Codex delivery).
11726
+ if (!injectedViaIpc) {
11727
+ try {
11728
+ const inboxDir = path.join(os.homedir(), ".viveworker", "thread-inbox");
11729
+ await fs.mkdir(inboxDir, { recursive: true });
11730
+ const inboxFile = path.join(inboxDir, `${share.shareId}.json`);
11731
+ await fs.writeFile(inboxFile, JSON.stringify({
11732
+ shareId: share.shareId,
11733
+ content: share.content,
11734
+ contextFiles: share.contextFiles || [],
11735
+ targetCwd: share.targetCwd || "",
11736
+ sourceTool: share.sourceTool,
11737
+ sourceLabel: share.sourceLabel,
11738
+ targetTool: share.targetTool,
11739
+ createdAtMs: share.createdAtMs,
11740
+ deliveredAtMs: Date.now(),
11741
+ }, null, 2), "utf8");
11742
+ console.log(`[thread-share] Written to inbox: ${inboxFile}`);
11743
+ } catch (error) {
11744
+ console.error(`[thread-share-inbox-write] ${error.message}`);
11745
+ }
11746
+
11747
+ // If the user intended Codex but it wasn't reachable, notify.
11748
+ if (share.targetConversationId && share.targetTool !== "claude-code") {
11749
+ deliveryStatus = "inbox_fallback";
11750
+ try {
11751
+ await deliverWebPushItem({
11752
+ config, state,
11753
+ kind: "thread_share",
11754
+ token: share.token,
11755
+ stableId: `thread_share_fallback:${share.shareId}`,
11756
+ title: "Thread Share: target unreachable",
11757
+ body: `Codex thread "${share.targetLabel || share.targetConversationId.slice(0, 8)}" is not connected. Content saved to inbox.`,
11758
+ });
11759
+ } catch (error) {
11760
+ console.error(`[thread-share-fallback-push] ${error.message}`);
11761
+ }
11762
+ }
11763
+ }
11764
+ }
11765
+
11766
+ // Wake any long-poll waiters.
11767
+ for (const waiter of share.decisionWaiters) {
11768
+ waiter({ decision: share.decision, content: share.content });
11769
+ }
11770
+ share.decisionWaiters = [];
11771
+ return writeJson(res, 200, { ok: true, decision: share.decision });
11772
+ }
11773
+
11774
+ // Long-poll: wait for a thread share decision (used by the CLI or hook).
11775
+ const threadSharePollMatch = url.pathname.match(/^\/api\/threads\/share\/([^/]+)\/poll$/);
11776
+ if (threadSharePollMatch && req.method === "GET") {
11777
+ const session = requireApiSession(req, res, config, state);
11778
+ if (!session) return;
11779
+ const token = cleanText(threadSharePollMatch[1]);
11780
+ const share = runtime.threadSharesByToken.get(token);
11781
+ if (!share) {
11782
+ return writeJson(res, 404, { error: "not-found" });
11783
+ }
11784
+ if (share.decision) {
11785
+ return writeJson(res, 200, { decision: share.decision, content: share.content });
11786
+ }
11787
+ const timeoutMs = Math.min(Number(url.searchParams.get("timeout")) || 120_000, 300_000);
11788
+ await new Promise((resolve) => {
11789
+ const timer = setTimeout(() => {
11790
+ const idx = share.decisionWaiters.indexOf(done);
11791
+ if (idx >= 0) share.decisionWaiters.splice(idx, 1);
11792
+ resolve();
11793
+ }, timeoutMs);
11794
+ timer.unref?.();
11795
+ function done(result) {
11796
+ clearTimeout(timer);
11797
+ resolve(result);
11798
+ }
11799
+ share.decisionWaiters.push(done);
11800
+ });
11801
+ if (share.decision) {
11802
+ return writeJson(res, 200, { decision: share.decision, content: share.content });
11803
+ }
11804
+ return writeJson(res, 200, { decision: null, timeout: true });
11805
+ }
11806
+
11152
11807
  // Activity summary for compose (original post) drafting.
11153
11808
  // Supports ?slot=morning|noon|evening and ?date=YYYY-MM-DD for
11154
11809
  // time-slot-based compose. Defaults to full-day today.
@@ -11432,6 +12087,27 @@ function createNativeApprovalServer({ config, runtime, state }) {
11432
12087
  return writeJson(res, 200, {});
11433
12088
  }
11434
12089
 
12090
+ // Register Claude Code thread in persistent registry on every event.
12091
+ {
12092
+ const regThreadId = String(body.threadId || body.sessionId || "");
12093
+ const regCwd = String(body.cwd || "");
12094
+ if (regThreadId) {
12095
+ const regLabel = runtime.claudeSessionTitles.get(regThreadId)
12096
+ || (regCwd ? path.basename(regCwd) : "")
12097
+ || regThreadId.slice(0, 40);
12098
+ if (registerThread(runtime, {
12099
+ id: regThreadId,
12100
+ label: regLabel,
12101
+ tool: "claude-code",
12102
+ cwd: regCwd,
12103
+ lastSeenAtMs: Date.now(),
12104
+ active: eventType !== "Stop" && eventType !== "SessionEnd",
12105
+ })) {
12106
+ saveThreadRegistry(config.threadRegistryFile, runtime.threadRegistry).catch(() => {});
12107
+ }
12108
+ }
12109
+ }
12110
+
11435
12111
  if (eventType !== "approval_request") {
11436
12112
  // Non-approval events (Notification, Stop, etc.) — acknowledge immediately
11437
12113
  return writeJson(res, 200, {});
@@ -11670,10 +12346,12 @@ function createNativeApprovalServer({ config, runtime, state }) {
11670
12346
  // ---------------------------------------------------------------
11671
12347
  // Moltbook scout draft channel
11672
12348
  //
11673
- // The standalone scout CLI (`viveworker moltbook propose`) submits
11674
- // a draft reply via POST /api/providers/moltbook/draft, then long-
11675
- // polls /api/providers/moltbook/draft/:token/decision until the
11676
- // 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.
11677
12355
  // ---------------------------------------------------------------
11678
12356
 
11679
12357
  if (url.pathname === "/api/providers/moltbook/draft" && req.method === "POST") {
@@ -11706,6 +12384,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11706
12384
  const postBody = typeof body.postBody === "string" ? body.postBody : "";
11707
12385
  const intent = typeof body.intent === "string" ? body.intent : "";
11708
12386
  const slot = cleanText(body.slot || "");
12387
+ const ttlMs = Math.max(60000, Math.min(Number(body.ttlMs) || 86400000, 86400000));
11709
12388
  const draft = {
11710
12389
  token,
11711
12390
  sourceId,
@@ -11721,11 +12400,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
11721
12400
  intent,
11722
12401
  slot,
11723
12402
  contextSummary,
12403
+ ttlMs,
11724
12404
  createdAtMs: Date.now(),
11725
12405
  decisionWaiters: [],
11726
12406
  decision: null,
11727
12407
  };
11728
12408
  runtime.moltbookDraftsByToken.set(token, draft);
12409
+ await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist] ${e.message}`));
11729
12410
  try {
11730
12411
  recordTimelineEntry({
11731
12412
  config,
@@ -11736,7 +12417,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11736
12417
  token,
11737
12418
  kind: "moltbook_draft",
11738
12419
  threadId: "moltbook",
11739
- threadLabel: "Moltbook",
12420
+ threadLabel: postTitle || "Moltbook",
11740
12421
  title: draftType === "original_post"
11741
12422
  ? (postTitle || "Moltbook new post")
11742
12423
  : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
@@ -11854,9 +12535,33 @@ function createNativeApprovalServer({ config, runtime, state }) {
11854
12535
  // ignore
11855
12536
  }
11856
12537
  }
11857
- // Keep the entry in moltbookDraftsByToken briefly so a slow long-
11858
- // poll can still pick it up; sweeper below removes it after 2min.
11859
- 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
+ }
11860
12565
  return writeJson(res, 200, { ok: true, action });
11861
12566
  }
11862
12567
 
@@ -11922,15 +12627,19 @@ function createNativeApprovalServer({ config, runtime, state }) {
11922
12627
  return writeJson(res, 200, detail);
11923
12628
  }
11924
12629
 
11925
- const apiCompletionReplyMatch = url.pathname.match(/^\/api\/items\/completion\/([^/]+)\/reply$/u);
12630
+ const apiCompletionReplyMatch = url.pathname.match(/^\/api\/items\/(completion|assistant_final)\/([^/]+)\/reply$/u);
11926
12631
  if (apiCompletionReplyMatch && req.method === "POST") {
11927
12632
  const session = requireMutatingApiSession(req, res, config, state);
11928
12633
  if (!session) {
11929
12634
  return;
11930
12635
  }
11931
12636
 
11932
- const token = decodeURIComponent(apiCompletionReplyMatch[1]);
11933
- 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);
11934
12643
  if (!completionItem) {
11935
12644
  return writeJson(res, 404, { error: "item-not-found" });
11936
12645
  }
@@ -13800,6 +14509,105 @@ function readMoltbookEnvKey() {
13800
14509
  return "";
13801
14510
  }
13802
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
+
13803
14611
  function buildConfig(cli) {
13804
14612
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
13805
14613
  const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
@@ -13828,6 +14636,7 @@ function buildConfig(cli) {
13828
14636
  historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
13829
14637
  codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
13830
14638
  stateFile,
14639
+ threadRegistryFile: resolvePath(process.env.THREAD_REGISTRY_FILE || path.join(os.homedir(), ".viveworker", "thread-registry.json")),
13831
14640
  replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
13832
14641
  timelineAttachmentsDir: resolvePath(
13833
14642
  process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
@@ -14084,6 +14893,7 @@ async function loadState(stateFile) {
14084
14893
  pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
14085
14894
  pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
14086
14895
  claudeAwayMode: parsed.claudeAwayMode === true,
14896
+ a2aAcceptPublicTasks: parsed.a2aAcceptPublicTasks === true,
14087
14897
  };
14088
14898
  } catch {
14089
14899
  return {
@@ -14140,6 +14950,59 @@ async function saveState(stateFile, state) {
14140
14950
  await fs.writeFile(stateFile, `${output}\n`, "utf8");
14141
14951
  }
14142
14952
 
14953
+ // ---------------------------------------------------------------------------
14954
+ // Thread registry — persistent record of all known threads (survives restarts)
14955
+ // ---------------------------------------------------------------------------
14956
+
14957
+ async function loadThreadRegistry(filePath) {
14958
+ const registry = new Map();
14959
+ try {
14960
+ const raw = await fs.readFile(filePath, "utf8");
14961
+ const arr = JSON.parse(raw);
14962
+ if (Array.isArray(arr)) {
14963
+ for (const entry of arr) {
14964
+ if (entry && entry.id) {
14965
+ registry.set(entry.id, entry);
14966
+ }
14967
+ }
14968
+ }
14969
+ } catch {
14970
+ // Missing or corrupt file — start with empty registry.
14971
+ }
14972
+ return registry;
14973
+ }
14974
+
14975
+ async function saveThreadRegistry(filePath, registry) {
14976
+ const arr = [...registry.values()].sort((a, b) => (b.lastSeenAtMs || 0) - (a.lastSeenAtMs || 0));
14977
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
14978
+ await fs.writeFile(filePath, JSON.stringify(arr, null, 2) + "\n", "utf8");
14979
+ }
14980
+
14981
+ /** Upsert a thread in the registry. Returns true if something changed. */
14982
+ function registerThread(runtime, entry) {
14983
+ const existing = runtime.threadRegistry.get(entry.id);
14984
+ const merged = {
14985
+ id: entry.id,
14986
+ label: entry.label || existing?.label || "",
14987
+ tool: entry.tool || existing?.tool || "codex",
14988
+ cwd: entry.cwd || existing?.cwd || "",
14989
+ lastSeenAtMs: Math.max(entry.lastSeenAtMs || Date.now(), existing?.lastSeenAtMs || 0),
14990
+ active: entry.active !== undefined ? entry.active : (existing?.active ?? true),
14991
+ };
14992
+ const changed = !existing
14993
+ || existing.label !== merged.label
14994
+ || existing.tool !== merged.tool
14995
+ || existing.cwd !== merged.cwd
14996
+ || existing.active !== merged.active
14997
+ || existing.lastSeenAtMs !== merged.lastSeenAtMs;
14998
+ if (changed) {
14999
+ runtime.threadRegistry.set(entry.id, merged);
15000
+ }
15001
+ return changed;
15002
+ }
15003
+
15004
+ // ---------------------------------------------------------------------------
15005
+
14143
15006
  async function loadSessionIndex(filePath) {
14144
15007
  const result = new Map();
14145
15008
  try {
@@ -14576,11 +15439,17 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
14576
15439
 
14577
15440
  const nextHistoryItems = normalizeHistoryItems(
14578
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
+ }
14579
15447
  const conversationId = extractConversationIdFromStableId(item.stableId);
14580
15448
  if (!conversationId) {
14581
15449
  return item;
14582
15450
  }
14583
- const nativeThreadLabel = getNativeThreadLabel({
15451
+ const claudeTitle = runtime.claudeSessionTitles.get(conversationId) || "";
15452
+ const nativeThreadLabel = claudeTitle || getNativeThreadLabel({
14584
15453
  runtime,
14585
15454
  conversationId,
14586
15455
  cwd: "",
@@ -14611,10 +15480,10 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
14611
15480
 
14612
15481
  const nextTimelineEntries = normalizeTimelineEntries(
14613
15482
  runtime.recentTimelineEntries.map((entry) => {
14614
- // Moltbook entries carry their own author-provided title/threadLabel
14615
- // (e.g. "@broanbot commented"). Skip the native-thread relabel pass so
14616
- // we don't overwrite them with the UUID-head fallback.
14617
- 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") {
14618
15487
  return entry;
14619
15488
  }
14620
15489
  const threadId = cleanText(entry.threadId || "");
@@ -15193,6 +16062,7 @@ async function main() {
15193
16062
 
15194
16063
  // --- A2A Relay ---
15195
16064
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
16065
+ config.a2aAcceptPublicTasks = state.a2aAcceptPublicTasks === true;
15196
16066
  const regResult = await registerWithRelay({ config, buildAgentCard });
15197
16067
  if (regResult.ok) {
15198
16068
  startRelayPolling({