viveworker 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,8 @@ import webPush from "web-push";
16
16
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "../web/i18n.js";
17
17
  import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
18
18
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
19
+ import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
20
+ import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult } from "./a2a-relay-client.mjs";
19
21
 
20
22
  const __filename = fileURLToPath(import.meta.url);
21
23
  const __dirname = path.dirname(__filename);
@@ -26,7 +28,7 @@ const sessionCookieName = "viveworker_session";
26
28
  const deviceCookieName = "viveworker_device";
27
29
  const historyKinds = new Set(["completion", "plan_ready", "approval", "plan", "choice", "info"]);
28
30
  const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
29
- const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event"]);
31
+ const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "completion", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft"]);
30
32
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
31
33
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
32
34
  const MAX_PAIRED_DEVICES = 200;
@@ -39,6 +41,7 @@ const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
39
41
  const cli = parseCliArgs(process.argv.slice(2));
40
42
  const envFile = resolveEnvFile(cli.envFile);
41
43
  loadEnvFile(envFile);
44
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
42
45
  await maybeRotateStartupPairingEnv(envFile);
43
46
 
44
47
  const config = buildConfig(cli);
@@ -79,6 +82,9 @@ const runtime = {
79
82
  userInputRequestsByToken: new Map(),
80
83
  userInputRequestsByRequestKey: new Map(),
81
84
  completionDetailsByToken: new Map(),
85
+ moltbookItemsByToken: new Map(),
86
+ moltbookDraftsByToken: new Map(),
87
+ a2aTasksByToken: new Map(),
82
88
  planDetailsByToken: new Map(),
83
89
  recentHistoryItems: [],
84
90
  recentTimelineEntries: [],
@@ -212,6 +218,9 @@ function buildSessionLocalePayload(config, state, deviceId) {
212
218
  deviceDetectedLocale: resolved.detectedLocale || null,
213
219
  deviceOverrideLocale: resolved.overrideLocale || null,
214
220
  claudeAwayMode: state?.claudeAwayMode === true,
221
+ moltbookEnabled: Boolean(config.moltbookApiKey),
222
+ a2aEnabled: Boolean(config.a2aApiKey),
223
+ a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
215
224
  };
216
225
  }
217
226
 
@@ -280,6 +289,8 @@ function notificationIconPrefix(kind) {
280
289
  return "☑️";
281
290
  case "completion":
282
291
  return "✅";
292
+ case "a2a_task":
293
+ return "🤝";
283
294
  default:
284
295
  return "";
285
296
  }
@@ -1989,11 +2000,18 @@ function handleSignal() {
1989
2000
 
1990
2001
  function normalizeProvider(value) {
1991
2002
  const normalized = String(value || "").toLowerCase();
1992
- return normalized === "claude" ? "claude" : "codex";
2003
+ if (normalized === "claude") return "claude";
2004
+ if (normalized === "moltbook") return "moltbook";
2005
+ if (normalized === "a2a") return "a2a";
2006
+ return "codex";
1993
2007
  }
1994
2008
 
1995
2009
  function providerDisplayName(locale, provider) {
1996
- return t(locale, normalizeProvider(provider) === "claude" ? "common.claude" : "common.codex");
2010
+ const p = normalizeProvider(provider);
2011
+ if (p === "claude") return t(locale, "common.claude");
2012
+ if (p === "moltbook") return "Moltbook";
2013
+ if (p === "a2a") return "A2A";
2014
+ return t(locale, "common.codex");
1997
2015
  }
1998
2016
 
1999
2017
  function normalizeHistoryItems(rawItems, maxItems) {
@@ -2085,17 +2103,31 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2085
2103
  }
2086
2104
  return timelineKindSortPriority(right.kind) - timelineKindSortPriority(left.kind);
2087
2105
  });
2106
+ // Per-provider eviction: each provider (codex, claude, moltbook) gets up
2107
+ // to `maxItems` entries, so entries from one busy provider don't push out
2108
+ // entries from another. Total capacity is maxItems * providerCount.
2088
2109
  const deduped = [];
2089
2110
  const seen = new Set();
2111
+ const perProviderCount = {};
2112
+ const knownProviders = new Set(["codex", "claude", "moltbook", "a2a"]);
2113
+ const saturatedProviders = new Set();
2090
2114
  for (const item of normalized) {
2091
2115
  if (seen.has(item.stableId)) {
2092
2116
  continue;
2093
2117
  }
2118
+ const prov = item.provider || "codex";
2119
+ if (saturatedProviders.has(prov)) {
2120
+ continue;
2121
+ }
2122
+ const count = (perProviderCount[prov] || 0) + 1;
2123
+ perProviderCount[prov] = count;
2124
+ if (count > maxItems) {
2125
+ saturatedProviders.add(prov);
2126
+ if (saturatedProviders.size >= knownProviders.size) break;
2127
+ continue;
2128
+ }
2094
2129
  seen.add(item.stableId);
2095
2130
  deduped.push(item);
2096
- if (deduped.length >= maxItems) {
2097
- break;
2098
- }
2099
2131
  }
2100
2132
  return deduped;
2101
2133
  }
@@ -2174,14 +2206,18 @@ function normalizeTimelineEntry(raw) {
2174
2206
  formatNotificationBody(messageText, 180) ||
2175
2207
  (kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
2176
2208
  "";
2177
- const threadLabel = preferTitleOnlyJsonThreadLabel(
2178
- cleanText(raw.threadLabel ?? ""),
2179
- threadId,
2180
- raw.messageText,
2181
- raw.summary,
2182
- raw.detailText,
2183
- raw.message
2184
- );
2209
+ const rawProvider = normalizeProvider(raw.provider);
2210
+ const threadLabel =
2211
+ rawProvider === "moltbook"
2212
+ ? cleanText(raw.threadLabel ?? "")
2213
+ : preferTitleOnlyJsonThreadLabel(
2214
+ cleanText(raw.threadLabel ?? ""),
2215
+ threadId,
2216
+ raw.messageText,
2217
+ raw.summary,
2218
+ raw.detailText,
2219
+ raw.message
2220
+ );
2185
2221
  const title =
2186
2222
  cleanText(raw.title ?? "") ||
2187
2223
  (kind === "file_event" ? fileEventTitle(DEFAULT_LOCALE, fileEventType) : "") ||
@@ -2216,6 +2252,20 @@ function normalizeTimelineEntry(raw) {
2216
2252
  tone: cleanText(raw.tone ?? "") || "secondary",
2217
2253
  cwd: resolvePath(cleanText(raw.cwd || "")),
2218
2254
  provider: normalizeProvider(raw.provider),
2255
+ // Moltbook-specific fields — preserved for detail view fallback after
2256
+ // the in-memory draft/item expires.
2257
+ ...(raw.draftText != null ? { draftText: cleanText(raw.draftText) } : {}),
2258
+ ...(raw.intent != null ? { intent: cleanText(raw.intent) } : {}),
2259
+ ...(raw.postId != null ? { postId: cleanText(raw.postId) } : {}),
2260
+ ...(raw.postUrl != null ? { postUrl: cleanText(raw.postUrl) } : {}),
2261
+ ...(raw.postTitle != null ? { postTitle: cleanText(raw.postTitle) } : {}),
2262
+ ...(raw.postAuthor != null ? { postAuthor: cleanText(raw.postAuthor) } : {}),
2263
+ ...(raw.postBody != null ? { postBody: cleanText(raw.postBody) } : {}),
2264
+ ...(raw.commentAuthor != null ? { commentAuthor: cleanText(raw.commentAuthor) } : {}),
2265
+ ...(raw.contextText != null ? { contextText: cleanText(raw.contextText) } : {}),
2266
+ ...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
2267
+ ...(raw.submoltName != null ? { submoltName: cleanText(raw.submoltName) } : {}),
2268
+ ...(raw.slot != null ? { slot: cleanText(raw.slot) } : {}),
2219
2269
  };
2220
2270
  }
2221
2271
 
@@ -3066,7 +3116,7 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3066
3116
  const defaultThreadLabel = path.basename(decodedProjectDir) || "";
3067
3117
 
3068
3118
  // On startup, use the same "recent only" strategy as processRolloutFile to avoid
3069
- // re-processing old entries that would be immediately discarded by the 250-entry limit.
3119
+ // re-processing old entries that would be immediately discarded by the per-provider entry limit.
3070
3120
  let initialOffset = 0;
3071
3121
  let startupCutoffMs = 0;
3072
3122
  try {
@@ -3179,6 +3229,10 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3179
3229
  }
3180
3230
  text = cleanText(text);
3181
3231
  if (!text) continue;
3232
+ // Skip Claude Code internal system messages (task notifications, system
3233
+ // reminders, etc.) — these are XML-like tags injected into the
3234
+ // conversation that should not appear on the user-facing timeline.
3235
+ if (/^<(?:task-notification|system-reminder)\b/i.test(text.trim())) continue;
3182
3236
 
3183
3237
  const kind = type === "user" ? "user_message" : "assistant_final";
3184
3238
  const stableId = `${kind}:${threadId}:${uuid}`;
@@ -8982,6 +9036,41 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8982
9036
  });
8983
9037
  }
8984
9038
 
9039
+ for (const draft of runtime.moltbookDraftsByToken.values()) {
9040
+ if (draft.decision) continue;
9041
+ const title = draft.postTitle ? `draft → ${draft.postTitle}` : "Moltbook draft";
9042
+ items.push({
9043
+ kind: "moltbook_draft",
9044
+ token: draft.token,
9045
+ threadId: "moltbook",
9046
+ threadLabel: "Moltbook",
9047
+ title,
9048
+ summary: draft.contextSummary || String(draft.draftText || "").slice(0, 160),
9049
+ primaryLabel: t(locale, "server.action.review"),
9050
+ createdAtMs: Number(draft.createdAtMs) || now,
9051
+ provider: "moltbook",
9052
+ });
9053
+ }
9054
+
9055
+ for (const task of runtime.a2aTasksByToken.values()) {
9056
+ if (task.status !== "submitted") continue;
9057
+ items.push({
9058
+ kind: "a2a_task",
9059
+ token: task.token,
9060
+ threadId: "a2a",
9061
+ threadLabel: "A2A",
9062
+ title: `A2A: ${cleanText(task.instruction || "").slice(0, 80)}`,
9063
+ summary: cleanText(task.instruction || "").slice(0, 160),
9064
+ primaryLabel: t(locale, "server.action.review"),
9065
+ createdAtMs: Number(task.createdAtMs) || now,
9066
+ provider: "a2a",
9067
+ });
9068
+ }
9069
+
9070
+ // Moltbook reply items intentionally do not appear in the unhandled list.
9071
+ // Reply drafting is delegated to Codex/Claude Desktop via the
9072
+ // `viveworker moltbook` CLI, so they live in the timeline only.
9073
+
8985
9074
  return items.sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0));
8986
9075
  }
8987
9076
 
@@ -9301,7 +9390,7 @@ function buildTimelineThreads(entries, config) {
9301
9390
  const byThread = new Map();
9302
9391
  for (const entry of entries) {
9303
9392
  const threadId = cleanText(entry.threadId || "");
9304
- if (!threadId) {
9393
+ if (!threadId || threadId === "moltbook" || threadId.startsWith("draft:") || (entry.kind || "").startsWith("moltbook_")) {
9305
9394
  continue;
9306
9395
  }
9307
9396
  const preferredLabel =
@@ -10414,6 +10503,113 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10414
10503
  return historicalChoice ? buildHistoryDetail(historicalChoice, locale, runtime) : null;
10415
10504
  }
10416
10505
 
10506
+ if (kind === "moltbook_reply") {
10507
+ const item = runtime.moltbookItemsByToken.get(token);
10508
+ // Fall back to the persisted timeline entry so that items which have
10509
+ // been resolved (removed from the pending map) still render their
10510
+ // detail view from history.
10511
+ const entry = item
10512
+ ? null
10513
+ : runtime.recentTimelineEntries.find((e) => e.kind === "moltbook_reply" && e.token === token);
10514
+ const source = item || entry;
10515
+ if (!source) return null;
10516
+ const contextText = item
10517
+ ? item.contextText || item.summary || ""
10518
+ : entry.messageText || entry.summary || "";
10519
+ const contextHtml = escapeHtml(contextText)
10520
+ .split("\n")
10521
+ .map((line) => `<p>${line}</p>`)
10522
+ .join("");
10523
+ return {
10524
+ kind: "moltbook_reply",
10525
+ token,
10526
+ threadId: source.threadId || "",
10527
+ threadLabel: source.threadLabel || "Moltbook",
10528
+ title: source.title || "Moltbook reply",
10529
+ summary: source.summary || "",
10530
+ messageHtml: contextHtml,
10531
+ postUrl: source.postUrl || "",
10532
+ commentAuthor: source.commentAuthor || "",
10533
+ provider: "moltbook",
10534
+ draftReply: item?.draftReply || "",
10535
+ createdAtMs: source.createdAtMs || Date.now(),
10536
+ readOnly: !item,
10537
+ actions: [],
10538
+ moltbookReplyEnabled: Boolean(item),
10539
+ };
10540
+ }
10541
+
10542
+ if (kind === "moltbook_draft") {
10543
+ const draft = runtime.moltbookDraftsByToken.get(token);
10544
+ const entry = draft
10545
+ ? null
10546
+ : runtime.recentTimelineEntries.find((e) => e.kind === "moltbook_draft" && e.token === token);
10547
+ const source = draft || entry;
10548
+ if (!source) return null;
10549
+ const draftText = draft ? draft.draftText : entry.draftText || entry.messageText || "";
10550
+ const contextSummary = draft ? draft.contextSummary || "" : entry.summary || "";
10551
+ const messageHtml = escapeHtml(contextSummary)
10552
+ .split("\n")
10553
+ .map((line) => `<p>${line}</p>`)
10554
+ .join("");
10555
+ return {
10556
+ kind: "moltbook_draft",
10557
+ token,
10558
+ threadId: "moltbook",
10559
+ threadLabel: "Moltbook",
10560
+ title: source.title || "Moltbook draft",
10561
+ summary: source.summary || contextSummary || "",
10562
+ messageHtml,
10563
+ provider: "moltbook",
10564
+ draftText,
10565
+ postId: draft?.postId || entry?.postId || "",
10566
+ postUrl: draft?.postUrl || entry?.postUrl || "",
10567
+ postTitle: draft?.postTitle || entry?.postTitle || "",
10568
+ postAuthor: draft?.postAuthor || entry?.postAuthor || "",
10569
+ postBody: draft?.postBody || entry?.postBody || "",
10570
+ draftType: draft?.draftType || entry?.draftType || "reply",
10571
+ submoltName: draft?.submoltName || entry?.submoltName || "",
10572
+ intent: draft?.intent || entry?.intent || "",
10573
+ slot: draft?.slot || entry?.slot || "",
10574
+ createdAtMs: source.createdAtMs || Date.now(),
10575
+ readOnly: !draft || Boolean(draft?.decision),
10576
+ actions: [],
10577
+ moltbookDraftEnabled: Boolean(draft) && !draft.decision,
10578
+ };
10579
+ }
10580
+
10581
+ if (kind === "a2a_task") {
10582
+ const task = runtime.a2aTasksByToken.get(token);
10583
+ const entry = task
10584
+ ? null
10585
+ : runtime.recentTimelineEntries.find((e) => e.kind === "a2a_task" && e.token === token);
10586
+ const source = task || entry;
10587
+ if (!source) return null;
10588
+ const instruction = task ? task.instruction : entry.messageText || entry.summary || "";
10589
+ const messageHtml = escapeHtml(instruction)
10590
+ .split("\n")
10591
+ .map((line) => `<p>${line}</p>`)
10592
+ .join("");
10593
+ return {
10594
+ kind: "a2a_task",
10595
+ token,
10596
+ threadId: "a2a",
10597
+ threadLabel: "A2A",
10598
+ title: source.title || `A2A Task`,
10599
+ summary: source.summary || cleanText(instruction).slice(0, 160),
10600
+ messageHtml,
10601
+ provider: "a2a",
10602
+ instruction,
10603
+ taskId: task?.id || "",
10604
+ taskStatus: task?.status || entry?.taskStatus || "unknown",
10605
+ callerInfo: task?.callerInfo || {},
10606
+ createdAtMs: source.createdAtMs || Date.now(),
10607
+ readOnly: !task || task.status !== "submitted",
10608
+ actions: [],
10609
+ a2aTaskEnabled: Boolean(task) && task.status === "submitted",
10610
+ };
10611
+ }
10612
+
10417
10613
  const historyItem = historyItemByToken(runtime, kind, token);
10418
10614
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
10419
10615
  }
@@ -10631,6 +10827,104 @@ function createNativeApprovalServer({ config, runtime, state }) {
10631
10827
  }
10632
10828
  }
10633
10829
 
10830
+ // ---------------------------------------------------------------
10831
+ // A2A (Agent2Agent) Protocol endpoints
10832
+ // ---------------------------------------------------------------
10833
+
10834
+ if (url.pathname === "/.well-known/agent.json" && req.method === "GET") {
10835
+ if (!config.a2aApiKey) {
10836
+ return writeJson(res, 404, { error: "a2a-not-configured" });
10837
+ }
10838
+ return writeJson(res, 200, buildAgentCard(config));
10839
+ }
10840
+
10841
+ if (url.pathname === "/a2a" && req.method === "POST") {
10842
+ if (!config.a2aApiKey) {
10843
+ return writeJson(res, 404, { error: "a2a-not-configured" });
10844
+ }
10845
+ let body;
10846
+ try {
10847
+ body = await parseJsonBody(req);
10848
+ } catch {
10849
+ return writeJson(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
10850
+ }
10851
+ return handleA2ARequest({
10852
+ req, res, body, config, runtime, state,
10853
+ writeJson, recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText,
10854
+ });
10855
+ }
10856
+
10857
+ // A2A task decision (user approves/denies from PWA)
10858
+ const apiA2ATaskDecide = url.pathname.match(/^\/api\/items\/a2a-task\/([^/]+)\/decision$/u);
10859
+ if (apiA2ATaskDecide && req.method === "POST") {
10860
+ const session = requireMutatingApiSession(req, res, config, state);
10861
+ if (!session) return;
10862
+ const token = decodeURIComponent(apiA2ATaskDecide[1]);
10863
+ const task = runtime.a2aTasksByToken.get(token);
10864
+ if (!task) return writeJson(res, 404, { error: "task-not-found" });
10865
+ let body;
10866
+ try {
10867
+ body = await parseJsonBody(req);
10868
+ } catch {
10869
+ return writeJson(res, 400, { error: "invalid-json-body" });
10870
+ }
10871
+ const action = String(body.action || "") === "approve" ? "approve" : "deny";
10872
+ const instruction = cleanText(body.instruction || "");
10873
+ resolveA2ATaskDecision(task, { action, instruction });
10874
+
10875
+ if (action === "approve") {
10876
+ // Execute task asynchronously via Codex
10877
+ (async () => {
10878
+ try {
10879
+ const { executeA2ATask } = await import("./a2a-executor.mjs");
10880
+ await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState });
10881
+ } catch (error) {
10882
+ console.error(`[a2a-execute-error] ${error.message}`);
10883
+ failA2ATask(task, error.message);
10884
+ }
10885
+ // Post result to relay if this is a relay-originated task
10886
+ if (task.relayTaskId) {
10887
+ postRelayResult({ config, task }).catch((err) =>
10888
+ console.error(`[a2a-relay] Result post error: ${err.message}`)
10889
+ );
10890
+ }
10891
+ })();
10892
+ } else if (task.relayTaskId) {
10893
+ // Denied — post rejection back to relay
10894
+ postRelayResult({ config, task }).catch((err) =>
10895
+ console.error(`[a2a-relay] Rejection post error: ${err.message}`)
10896
+ );
10897
+ }
10898
+
10899
+ // Keep entry briefly for polling
10900
+ setTimeout(() => {
10901
+ if (task.status === "rejected" || task.status === "canceled") {
10902
+ runtime.a2aTasksByToken.delete(token);
10903
+ }
10904
+ }, 300_000).unref?.(); // 5 min cleanup for rejected/canceled
10905
+
10906
+ return writeJson(res, 200, { ok: true, action });
10907
+ }
10908
+
10909
+ // A2A task status polling (for external callers, authenticated via A2A key)
10910
+ const apiA2ATaskStatus = url.pathname.match(/^\/api\/providers\/a2a\/tasks\/([^/]+)\/status$/u);
10911
+ if (apiA2ATaskStatus && req.method === "GET") {
10912
+ const apiKey = req.headers["x-a2a-key"] || "";
10913
+ if (!config.a2aApiKey || apiKey !== config.a2aApiKey) {
10914
+ return writeJson(res, 401, { error: "unauthorized" });
10915
+ }
10916
+ const token = decodeURIComponent(apiA2ATaskStatus[1]);
10917
+ const task = runtime.a2aTasksByToken.get(token);
10918
+ if (!task) return writeJson(res, 404, { error: "task-not-found" });
10919
+ return writeJson(res, 200, {
10920
+ id: task.id,
10921
+ status: task.status,
10922
+ statusMessage: task.statusMessage || "",
10923
+ artifacts: task.artifacts,
10924
+ updatedAtMs: task.updatedAtMs,
10925
+ });
10926
+ }
10927
+
10634
10928
  if (url.pathname === "/api/session" && req.method === "GET") {
10635
10929
  const session = readSession(req, config, state);
10636
10930
  const localeInfo = resolveDeviceLocaleInfo(config, state, session.deviceId);
@@ -10799,6 +11093,96 @@ function createNativeApprovalServer({ config, runtime, state }) {
10799
11093
  return writeJson(res, 200, { ok: true, enabled });
10800
11094
  }
10801
11095
 
11096
+ if (url.pathname === "/api/moltbook/scout-status" && req.method === "GET") {
11097
+ const session = requireApiSession(req, res, config, state);
11098
+ if (!session) {
11099
+ return;
11100
+ }
11101
+ if (!config.moltbookApiKey) {
11102
+ return writeJson(res, 200, { enabled: false });
11103
+ }
11104
+ try {
11105
+ const { readScoutState, rollScoutDayIfNeeded } = await import("./moltbook-api.mjs");
11106
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
11107
+ const batch = scoutState.batch;
11108
+ const batchInfo = batch && batch.candidates?.length ? {
11109
+ collecting: true,
11110
+ candidateCount: batch.candidates.length,
11111
+ topScore: Math.max(...batch.candidates.map((c) => c.score || 0)),
11112
+ remainingSeconds: Math.max(0, Math.round(((batch.startedAt || 0) + (batch.windowMs || 1800000) - Date.now()) / 1000)),
11113
+ } : null;
11114
+ return writeJson(res, 200, {
11115
+ enabled: true,
11116
+ day: scoutState.day,
11117
+ sentToday: scoutState.sentToday,
11118
+ maxDaily: 5,
11119
+ seenPostCount: Object.keys(scoutState.seenPostIds || {}).length,
11120
+ batch: batchInfo,
11121
+ composedToday: scoutState.composedToday || 0,
11122
+ composeSlotsAttempted: Array.isArray(scoutState.composeSlotsAttempted) ? scoutState.composeSlotsAttempted : [],
11123
+ recentComposeTitles: Array.isArray(scoutState.recentComposeTitles) ? scoutState.recentComposeTitles : [],
11124
+ });
11125
+ } catch {
11126
+ return writeJson(res, 200, { enabled: true, day: "", sentToday: 0, maxDaily: 5, seenPostCount: 0 });
11127
+ }
11128
+ }
11129
+
11130
+ // Activity summary for compose (original post) drafting.
11131
+ // Supports ?slot=morning|noon|evening and ?date=YYYY-MM-DD for
11132
+ // time-slot-based compose. Defaults to full-day today.
11133
+ if (url.pathname === "/api/providers/moltbook/activity-summary" && req.method === "GET") {
11134
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11135
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
11136
+ return writeJson(res, 403, { error: "forbidden" });
11137
+ }
11138
+ const slot = String(url.searchParams.get("slot") || "").toLowerCase();
11139
+ const dateParam = String(url.searchParams.get("date") || "");
11140
+ const now = new Date();
11141
+ const localHour = now.getHours();
11142
+
11143
+ // Determine time range based on slot.
11144
+ let rangeStart, rangeEnd, rangeDate;
11145
+ if (slot === "morning" && dateParam) {
11146
+ // Morning slot: previous day's activities (dateParam = yesterday).
11147
+ const d = new Date(dateParam + "T00:00:00");
11148
+ rangeStart = d.getTime();
11149
+ rangeEnd = rangeStart + 24 * 60 * 60 * 1000;
11150
+ rangeDate = dateParam;
11151
+ } else if (slot === "noon") {
11152
+ // Noon slot: today 00:00 – 12:00 local.
11153
+ const d = new Date(now); d.setHours(0, 0, 0, 0);
11154
+ rangeStart = d.getTime();
11155
+ const noon = new Date(now); noon.setHours(12, 0, 0, 0);
11156
+ rangeEnd = noon.getTime();
11157
+ rangeDate = d.toISOString().slice(0, 10);
11158
+ } else if (slot === "evening") {
11159
+ // Evening slot: today full day.
11160
+ const d = new Date(now); d.setHours(0, 0, 0, 0);
11161
+ rangeStart = d.getTime();
11162
+ rangeEnd = Date.now();
11163
+ rangeDate = d.toISOString().slice(0, 10);
11164
+ } else {
11165
+ // Default: today full day (UTC midnight to now).
11166
+ const d = new Date(now);
11167
+ d.setUTCHours(0, 0, 0, 0);
11168
+ rangeStart = d.getTime();
11169
+ rangeEnd = Date.now();
11170
+ rangeDate = d.toISOString().slice(0, 10);
11171
+ }
11172
+
11173
+ const relevantKinds = new Set(["file_event", "completion", "plan_ready", "assistant_final"]);
11174
+ const entries = (runtime.recentTimelineEntries || [])
11175
+ .filter((e) => e.createdAtMs >= rangeStart && e.createdAtMs < rangeEnd && relevantKinds.has(e.kind))
11176
+ .map((e) => ({
11177
+ kind: e.kind,
11178
+ title: e.title || "",
11179
+ summary: e.summary || "",
11180
+ threadLabel: e.threadLabel || "",
11181
+ createdAtMs: e.createdAtMs,
11182
+ }));
11183
+ return writeJson(res, 200, { date: rangeDate, slot: slot || "full", entries });
11184
+ }
11185
+
10802
11186
  if (url.pathname === "/api/push/status" && req.method === "GET") {
10803
11187
  const session = requireApiSession(req, res, config, state);
10804
11188
  if (!session) {
@@ -11135,6 +11519,324 @@ function createNativeApprovalServer({ config, runtime, state }) {
11135
11519
  });
11136
11520
  }
11137
11521
 
11522
+ if (url.pathname === "/api/providers/moltbook/events" && req.method === "POST") {
11523
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11524
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
11525
+ return writeJson(res, 401, { error: "unauthorized" });
11526
+ }
11527
+ let body;
11528
+ try {
11529
+ body = await parseJsonBody(req);
11530
+ } catch {
11531
+ return writeJson(res, 400, { error: "invalid-json-body" });
11532
+ }
11533
+ const sourceId = cleanText(body.sourceId || "");
11534
+ if (!sourceId) {
11535
+ return writeJson(res, 400, { error: "missing-sourceId" });
11536
+ }
11537
+ const eventType = String(body.eventType || "reply_request");
11538
+ if (eventType === "resolve") {
11539
+ const token = historyToken(`moltbook:${sourceId}`);
11540
+ runtime.moltbookItemsByToken.delete(token);
11541
+ return writeJson(res, 200, { ok: true });
11542
+ }
11543
+ const token = historyToken(`moltbook:${sourceId}`);
11544
+ const item = {
11545
+ token,
11546
+ sourceId,
11547
+ threadId: cleanText(body.threadId || "moltbook"),
11548
+ threadLabel: cleanText(body.threadLabel || "Moltbook"),
11549
+ title: cleanText(body.title || "Moltbook reply"),
11550
+ summary: cleanText(body.summary || ""),
11551
+ contextText: cleanText(body.contextText || ""),
11552
+ draftReply: cleanText(body.draftReply || ""),
11553
+ callbackUrl: cleanText(body.callbackUrl || ""),
11554
+ postUrl: cleanText(body.postUrl || ""),
11555
+ commentAuthor: cleanText(body.commentAuthor || ""),
11556
+ createdAtMs: Number(body.createdAtMs) || Date.now(),
11557
+ resolved: false,
11558
+ };
11559
+ runtime.moltbookItemsByToken.set(token, item);
11560
+ try {
11561
+ recordTimelineEntry({
11562
+ config,
11563
+ runtime,
11564
+ state,
11565
+ entry: {
11566
+ stableId: `moltbook_reply:${sourceId}`,
11567
+ token,
11568
+ kind: "moltbook_reply",
11569
+ threadId: item.threadId,
11570
+ threadLabel: item.threadLabel,
11571
+ title: item.title,
11572
+ summary: item.summary,
11573
+ messageText: item.contextText,
11574
+ createdAtMs: item.createdAtMs,
11575
+ readOnly: false,
11576
+ provider: "moltbook",
11577
+ },
11578
+ });
11579
+ await saveState(config.stateFile, state);
11580
+ } catch (error) {
11581
+ console.error(`[moltbook-timeline-save] ${error.message}`);
11582
+ }
11583
+ try {
11584
+ await deliverWebPushItem({
11585
+ config,
11586
+ state,
11587
+ kind: "moltbook_reply",
11588
+ token,
11589
+ stableId: `moltbook_reply:${item.sourceId}`,
11590
+ title: item.title,
11591
+ body: item.summary,
11592
+ });
11593
+ } catch (error) {
11594
+ console.error(`[moltbook-push-error] ${error.message}`);
11595
+ }
11596
+ return writeJson(res, 200, { ok: true, token });
11597
+ }
11598
+
11599
+ const apiMoltbookReplyMatch = url.pathname.match(/^\/api\/items\/moltbook\/([^/]+)\/reply$/u);
11600
+ if (apiMoltbookReplyMatch && req.method === "POST") {
11601
+ const session = requireMutatingApiSession(req, res, config, state);
11602
+ if (!session) {
11603
+ return;
11604
+ }
11605
+ const token = decodeURIComponent(apiMoltbookReplyMatch[1]);
11606
+ const item = runtime.moltbookItemsByToken.get(token);
11607
+ if (!item) {
11608
+ return writeJson(res, 404, { error: "item-not-found" });
11609
+ }
11610
+ let body;
11611
+ try {
11612
+ body = await parseJsonBody(req);
11613
+ } catch {
11614
+ return writeJson(res, 400, { error: "invalid-json-body" });
11615
+ }
11616
+ const action = String(body.action || "send");
11617
+ const text = cleanText(body.text || "");
11618
+ if (action === "send" && !text) {
11619
+ return writeJson(res, 400, { error: "empty-reply" });
11620
+ }
11621
+ if (item.callbackUrl) {
11622
+ try {
11623
+ const callbackRes = await fetch(item.callbackUrl, {
11624
+ method: "POST",
11625
+ headers: {
11626
+ "content-type": "application/json",
11627
+ "x-viveworker-hook-secret": config.sessionSecret || "",
11628
+ },
11629
+ body: JSON.stringify({
11630
+ sourceId: item.sourceId,
11631
+ action,
11632
+ text,
11633
+ }),
11634
+ });
11635
+ if (!callbackRes.ok) {
11636
+ return writeJson(res, 502, { error: `callback-failed-${callbackRes.status}` });
11637
+ }
11638
+ } catch (error) {
11639
+ return writeJson(res, 502, { error: `callback-error: ${error.message}` });
11640
+ }
11641
+ }
11642
+ item.resolved = true;
11643
+ runtime.moltbookItemsByToken.delete(token);
11644
+ return writeJson(res, 200, { ok: true, action });
11645
+ }
11646
+
11647
+ // ---------------------------------------------------------------
11648
+ // Moltbook scout draft channel
11649
+ //
11650
+ // The standalone scout CLI (`viveworker moltbook propose`) submits
11651
+ // a draft reply via POST /api/providers/moltbook/draft, then long-
11652
+ // polls /api/providers/moltbook/draft/:token/decision until the
11653
+ // phone responds via POST /api/items/moltbook-draft/:token/decision.
11654
+ // ---------------------------------------------------------------
11655
+
11656
+ if (url.pathname === "/api/providers/moltbook/draft" && req.method === "POST") {
11657
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11658
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
11659
+ return writeJson(res, 401, { error: "unauthorized" });
11660
+ }
11661
+ let body;
11662
+ try {
11663
+ body = await parseJsonBody(req);
11664
+ } catch {
11665
+ return writeJson(res, 400, { error: "invalid-json-body" });
11666
+ }
11667
+ const sourceId = cleanText(body.sourceId || "");
11668
+ const postId = cleanText(body.postId || "");
11669
+ const draftText = cleanText(body.draftText || "");
11670
+ const draftType = cleanText(body.draftType || "reply");
11671
+ const submoltName = cleanText(body.submoltName || "");
11672
+ if (!sourceId || !draftText) {
11673
+ return writeJson(res, 400, { error: "missing-sourceId-or-draftText" });
11674
+ }
11675
+ if (draftType === "reply" && !postId) {
11676
+ return writeJson(res, 400, { error: "missing-postId-for-reply" });
11677
+ }
11678
+ const token = historyToken(`moltbook_draft:${sourceId}`);
11679
+ const postTitle = cleanText(body.postTitle || "");
11680
+ const postUrl = cleanText(body.postUrl || `https://www.moltbook.com/post/${postId}`);
11681
+ const contextSummary = cleanText(body.contextSummary || "");
11682
+ const postAuthor = cleanText(body.postAuthor || "");
11683
+ const postBody = typeof body.postBody === "string" ? body.postBody : "";
11684
+ const intent = typeof body.intent === "string" ? body.intent : "";
11685
+ const slot = cleanText(body.slot || "");
11686
+ const draft = {
11687
+ token,
11688
+ sourceId,
11689
+ postId,
11690
+ postTitle,
11691
+ postAuthor,
11692
+ postBody,
11693
+ postUrl,
11694
+ parentCommentId: cleanText(body.parentCommentId || ""),
11695
+ draftText,
11696
+ draftType,
11697
+ submoltName,
11698
+ intent,
11699
+ slot,
11700
+ contextSummary,
11701
+ createdAtMs: Date.now(),
11702
+ decisionWaiters: [],
11703
+ decision: null,
11704
+ };
11705
+ runtime.moltbookDraftsByToken.set(token, draft);
11706
+ try {
11707
+ recordTimelineEntry({
11708
+ config,
11709
+ runtime,
11710
+ state,
11711
+ entry: {
11712
+ stableId: `moltbook_draft:${sourceId}`,
11713
+ token,
11714
+ kind: "moltbook_draft",
11715
+ threadId: "moltbook",
11716
+ threadLabel: "Moltbook",
11717
+ title: draftType === "original_post"
11718
+ ? (postTitle || "Moltbook new post")
11719
+ : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
11720
+ summary: contextSummary || String(draftText).slice(0, 160),
11721
+ messageText: contextSummary || draftText,
11722
+ draftText,
11723
+ draftType,
11724
+ submoltName,
11725
+ intent,
11726
+ slot,
11727
+ postId,
11728
+ postUrl,
11729
+ postTitle,
11730
+ postAuthor,
11731
+ postBody,
11732
+ createdAtMs: draft.createdAtMs,
11733
+ readOnly: false,
11734
+ provider: "moltbook",
11735
+ },
11736
+ });
11737
+ await saveState(config.stateFile, state);
11738
+ } catch (error) {
11739
+ console.error(`[moltbook-draft-timeline-save] ${error.message}`);
11740
+ }
11741
+ try {
11742
+ const pushTitle = (() => {
11743
+ if (draftType !== "original_post") {
11744
+ return postTitle ? `draft → ${postTitle}` : "Moltbook draft";
11745
+ }
11746
+ const greetings = {
11747
+ morning: { en: "Good morning! Share yesterday's highlights?", ja: "おはようございます!昨日の成果をシェアしませんか?" },
11748
+ noon: { en: "Nice progress! Ready to share your morning work?", ja: "午前中お疲れ様でした!進捗をシェアしませんか?" },
11749
+ evening: { en: "Great work today! Let's share what you built.", ja: "今日もお疲れ様でした!成果をシェアしませんか?" },
11750
+ };
11751
+ const locale = config.locale || "en";
11752
+ const lang = locale.startsWith("ja") ? "ja" : "en";
11753
+ const g = greetings[slot];
11754
+ return g ? g[lang] : (postTitle || "Moltbook new post");
11755
+ })();
11756
+ await deliverWebPushItem({
11757
+ config,
11758
+ state,
11759
+ kind: "moltbook_draft",
11760
+ token,
11761
+ stableId: `moltbook_draft:${sourceId}`,
11762
+ title: pushTitle,
11763
+ body: draftType === "original_post" ? (postTitle || String(draftText).slice(0, 160)) : String(draftText).slice(0, 160),
11764
+ });
11765
+ } catch (error) {
11766
+ console.error(`[moltbook-draft-push-error] ${error.message}`);
11767
+ }
11768
+ return writeJson(res, 200, { ok: true, token });
11769
+ }
11770
+
11771
+ const apiMoltbookDraftDecisionGet = url.pathname.match(
11772
+ /^\/api\/providers\/moltbook\/draft\/([^/]+)\/decision$/u
11773
+ );
11774
+ if (apiMoltbookDraftDecisionGet && req.method === "GET") {
11775
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
11776
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
11777
+ return writeJson(res, 401, { error: "unauthorized" });
11778
+ }
11779
+ const token = decodeURIComponent(apiMoltbookDraftDecisionGet[1]);
11780
+ const draft = runtime.moltbookDraftsByToken.get(token);
11781
+ if (!draft) return writeJson(res, 404, { error: "draft-not-found" });
11782
+ if (draft.decision) {
11783
+ return writeJson(res, 200, { status: "decided", ...draft.decision });
11784
+ }
11785
+ const waitParam = Number(url.searchParams.get("wait")) || 25;
11786
+ const waitMs = Math.min(Math.max(waitParam, 1), 60) * 1000;
11787
+ const decided = await new Promise((resolve) => {
11788
+ const timer = setTimeout(() => {
11789
+ const idx = draft.decisionWaiters.indexOf(resolve);
11790
+ if (idx !== -1) draft.decisionWaiters.splice(idx, 1);
11791
+ resolve(null);
11792
+ }, waitMs);
11793
+ draft.decisionWaiters.push((d) => {
11794
+ clearTimeout(timer);
11795
+ resolve(d);
11796
+ });
11797
+ });
11798
+ if (decided) return writeJson(res, 200, { status: "decided", ...decided });
11799
+ return writeJson(res, 200, { status: "pending" });
11800
+ }
11801
+
11802
+ const apiMoltbookDraftDecide = url.pathname.match(
11803
+ /^\/api\/items\/moltbook-draft\/([^/]+)\/decision$/u
11804
+ );
11805
+ if (apiMoltbookDraftDecide && req.method === "POST") {
11806
+ const session = requireMutatingApiSession(req, res, config, state);
11807
+ if (!session) return;
11808
+ const token = decodeURIComponent(apiMoltbookDraftDecide[1]);
11809
+ const draft = runtime.moltbookDraftsByToken.get(token);
11810
+ if (!draft) return writeJson(res, 404, { error: "draft-not-found" });
11811
+ let body;
11812
+ try {
11813
+ body = await parseJsonBody(req);
11814
+ } catch {
11815
+ return writeJson(res, 400, { error: "invalid-json-body" });
11816
+ }
11817
+ const action = String(body.action || "") === "approve" ? "approve" : "deny";
11818
+ const editedText = cleanText(body.editedText || "");
11819
+ const editedTitle = cleanText(body.editedTitle || "");
11820
+ const decision = {
11821
+ action,
11822
+ text: action === "approve" ? editedText || draft.draftText : "",
11823
+ title: action === "approve" ? editedTitle || draft.postTitle : "",
11824
+ decidedAtMs: Date.now(),
11825
+ };
11826
+ draft.decision = decision;
11827
+ for (const waiter of draft.decisionWaiters.splice(0)) {
11828
+ try {
11829
+ waiter(decision);
11830
+ } catch {
11831
+ // ignore
11832
+ }
11833
+ }
11834
+ // Keep the entry in moltbookDraftsByToken briefly so a slow long-
11835
+ // poll can still pick it up; sweeper below removes it after 2min.
11836
+ setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120 * 1000).unref?.();
11837
+ return writeJson(res, 200, { ok: true, action });
11838
+ }
11839
+
11138
11840
  if (url.pathname === "/api/inbox" && req.method === "GET") {
11139
11841
  const session = requireApiSession(req, res, config, state);
11140
11842
  if (!session) {
@@ -13044,6 +13746,37 @@ function isLoopbackHostname(value) {
13044
13746
  return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
13045
13747
  }
13046
13748
 
13749
+ function readA2AEnvKey() {
13750
+ try {
13751
+ const envPath = path.join(os.homedir(), ".viveworker", "a2a.env");
13752
+ const raw = readFileSync(envPath, "utf8");
13753
+ for (const line of raw.split(/\r?\n/u)) {
13754
+ const m = line.match(/^\s*A2A_API_KEY\s*=\s*(.+?)\s*$/u);
13755
+ if (m) return m[1].replace(/^['"]|['"]$/gu, "");
13756
+ }
13757
+ } catch {
13758
+ // ignore
13759
+ }
13760
+ return "";
13761
+ }
13762
+
13763
+ function readMoltbookEnvKey() {
13764
+ // Fall back to the standalone watcher's env file so the bridge can detect
13765
+ // a configured Moltbook integration without requiring MOLTBOOK_API_KEY to
13766
+ // be duplicated into its launchd plist.
13767
+ try {
13768
+ const envPath = path.join(os.homedir(), ".viveworker", "moltbook.env");
13769
+ const raw = readFileSync(envPath, "utf8");
13770
+ for (const line of raw.split(/\r?\n/u)) {
13771
+ const m = line.match(/^\s*MOLTBOOK_API_KEY\s*=\s*(.+?)\s*$/u);
13772
+ if (m) return m[1].replace(/^['"]|['"]$/gu, "");
13773
+ }
13774
+ } catch {
13775
+ // ignore
13776
+ }
13777
+ return "";
13778
+ }
13779
+
13047
13780
  function buildConfig(cli) {
13048
13781
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
13049
13782
  const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
@@ -13051,6 +13784,13 @@ function buildConfig(cli) {
13051
13784
  dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
13052
13785
  once: cli.once,
13053
13786
  codexHome,
13787
+ moltbookApiKey: cleanText(process.env.MOLTBOOK_API_KEY || readMoltbookEnvKey() || ""),
13788
+ a2aApiKey: cleanText(process.env.A2A_API_KEY || readA2AEnvKey() || ""),
13789
+ a2aPublicUrl: cleanText(process.env.A2A_PUBLIC_URL || ""),
13790
+ a2aRelayUrl: cleanText(process.env.A2A_RELAY_URL || ""),
13791
+ a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
13792
+ a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
13793
+ a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
13054
13794
  webUiEnabled: boolEnv("WEB_UI_ENABLED", true),
13055
13795
  authRequired: boolEnv("AUTH_REQUIRED", true),
13056
13796
  webPushEnabled: boolEnv("WEB_PUSH_ENABLED", false),
@@ -13844,6 +14584,12 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
13844
14584
 
13845
14585
  const nextTimelineEntries = normalizeTimelineEntries(
13846
14586
  runtime.recentTimelineEntries.map((entry) => {
14587
+ // Moltbook entries carry their own author-provided title/threadLabel
14588
+ // (e.g. "@broanbot commented"). Skip the native-thread relabel pass so
14589
+ // we don't overwrite them with the UUID-head fallback.
14590
+ if (normalizeProvider(entry.provider) === "moltbook") {
14591
+ return entry;
14592
+ }
13847
14593
  const threadId = cleanText(entry.threadId || "");
13848
14594
  if (!threadId) {
13849
14595
  return entry;
@@ -14418,6 +15164,24 @@ async function main() {
14418
15164
  }
14419
15165
  }
14420
15166
 
15167
+ // --- A2A Relay ---
15168
+ if (config.a2aRelayUrl && config.a2aRelayUserId) {
15169
+ const regResult = await registerWithRelay({ config, buildAgentCard });
15170
+ if (regResult.ok) {
15171
+ startRelayPolling({
15172
+ config,
15173
+ runtime,
15174
+ state,
15175
+ helpers: { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText },
15176
+ });
15177
+ } else {
15178
+ console.error(`[a2a-relay] Registration failed: ${regResult.error}`);
15179
+ }
15180
+ }
15181
+
15182
+ let lastA2aEnvCheckAt = 0;
15183
+ const A2A_ENV_CHECK_INTERVAL_MS = 30_000; // check every 30 seconds
15184
+
14421
15185
  while (!runtime.stopping) {
14422
15186
  try {
14423
15187
  const dirty = await scanOnce({ config, runtime, state });
@@ -14428,6 +15192,38 @@ async function main() {
14428
15192
  console.error(`[scan-error] ${error.message}`);
14429
15193
  }
14430
15194
 
15195
+ // Hot-reload: detect a2a.env changes and auto-start relay
15196
+ const now = Date.now();
15197
+ if (!config.a2aRelayUrl && now - lastA2aEnvCheckAt > A2A_ENV_CHECK_INTERVAL_MS) {
15198
+ lastA2aEnvCheckAt = now;
15199
+ try {
15200
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
15201
+ const newRelayUrl = cleanText(process.env.A2A_RELAY_URL || "");
15202
+ const newRelayUserId = cleanText(process.env.A2A_RELAY_USER_ID || "");
15203
+ const newRelayRegisterSecret = cleanText(process.env.A2A_RELAY_REGISTER_SECRET || "");
15204
+ if (newRelayUrl && newRelayUserId) {
15205
+ config.a2aRelayUrl = newRelayUrl;
15206
+ config.a2aRelayUserId = newRelayUserId;
15207
+ config.a2aRelayRegisterSecret = newRelayRegisterSecret;
15208
+ config.a2aApiKey = cleanText(process.env.A2A_API_KEY || config.a2aApiKey || "");
15209
+ console.log(`[a2a-relay] Detected new relay config, registering...`);
15210
+ const regResult = await registerWithRelay({ config, buildAgentCard });
15211
+ if (regResult.ok) {
15212
+ startRelayPolling({
15213
+ config,
15214
+ runtime,
15215
+ state,
15216
+ helpers: { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText },
15217
+ });
15218
+ } else {
15219
+ console.error(`[a2a-relay] Registration failed: ${regResult.error}`);
15220
+ }
15221
+ }
15222
+ } catch (error) {
15223
+ // ignore — env file may not exist yet
15224
+ }
15225
+ }
15226
+
14431
15227
  if (config.once) {
14432
15228
  break;
14433
15229
  }
@@ -14437,6 +15233,8 @@ async function main() {
14437
15233
  } finally {
14438
15234
  runtime.stopping = true;
14439
15235
 
15236
+ stopRelayPolling();
15237
+
14440
15238
  if (runtime.ipcClient) {
14441
15239
  runtime.ipcClient.stop();
14442
15240
  }