viveworker 0.3.0 → 0.4.1

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);
@@ -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);
@@ -81,6 +84,7 @@ const runtime = {
81
84
  completionDetailsByToken: new Map(),
82
85
  moltbookItemsByToken: new Map(),
83
86
  moltbookDraftsByToken: new Map(),
87
+ a2aTasksByToken: new Map(),
84
88
  planDetailsByToken: new Map(),
85
89
  recentHistoryItems: [],
86
90
  recentTimelineEntries: [],
@@ -215,6 +219,8 @@ function buildSessionLocalePayload(config, state, deviceId) {
215
219
  deviceOverrideLocale: resolved.overrideLocale || null,
216
220
  claudeAwayMode: state?.claudeAwayMode === true,
217
221
  moltbookEnabled: Boolean(config.moltbookApiKey),
222
+ a2aEnabled: Boolean(config.a2aApiKey),
223
+ a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
218
224
  };
219
225
  }
220
226
 
@@ -283,6 +289,8 @@ function notificationIconPrefix(kind) {
283
289
  return "☑️";
284
290
  case "completion":
285
291
  return "✅";
292
+ case "a2a_task":
293
+ return "🤝";
286
294
  default:
287
295
  return "";
288
296
  }
@@ -1994,6 +2002,7 @@ function normalizeProvider(value) {
1994
2002
  const normalized = String(value || "").toLowerCase();
1995
2003
  if (normalized === "claude") return "claude";
1996
2004
  if (normalized === "moltbook") return "moltbook";
2005
+ if (normalized === "a2a") return "a2a";
1997
2006
  return "codex";
1998
2007
  }
1999
2008
 
@@ -2001,6 +2010,7 @@ function providerDisplayName(locale, provider) {
2001
2010
  const p = normalizeProvider(provider);
2002
2011
  if (p === "claude") return t(locale, "common.claude");
2003
2012
  if (p === "moltbook") return "Moltbook";
2013
+ if (p === "a2a") return "A2A";
2004
2014
  return t(locale, "common.codex");
2005
2015
  }
2006
2016
 
@@ -2093,17 +2103,31 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2093
2103
  }
2094
2104
  return timelineKindSortPriority(right.kind) - timelineKindSortPriority(left.kind);
2095
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.
2096
2109
  const deduped = [];
2097
2110
  const seen = new Set();
2111
+ const perProviderCount = {};
2112
+ const knownProviders = new Set(["codex", "claude", "moltbook", "a2a"]);
2113
+ const saturatedProviders = new Set();
2098
2114
  for (const item of normalized) {
2099
2115
  if (seen.has(item.stableId)) {
2100
2116
  continue;
2101
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
+ }
2102
2129
  seen.add(item.stableId);
2103
2130
  deduped.push(item);
2104
- if (deduped.length >= maxItems) {
2105
- break;
2106
- }
2107
2131
  }
2108
2132
  return deduped;
2109
2133
  }
@@ -2241,6 +2265,7 @@ function normalizeTimelineEntry(raw) {
2241
2265
  ...(raw.contextText != null ? { contextText: cleanText(raw.contextText) } : {}),
2242
2266
  ...(raw.draftType != null ? { draftType: cleanText(raw.draftType) } : {}),
2243
2267
  ...(raw.submoltName != null ? { submoltName: cleanText(raw.submoltName) } : {}),
2268
+ ...(raw.slot != null ? { slot: cleanText(raw.slot) } : {}),
2244
2269
  };
2245
2270
  }
2246
2271
 
@@ -3091,7 +3116,7 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3091
3116
  const defaultThreadLabel = path.basename(decodedProjectDir) || "";
3092
3117
 
3093
3118
  // On startup, use the same "recent only" strategy as processRolloutFile to avoid
3094
- // 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.
3095
3120
  let initialOffset = 0;
3096
3121
  let startupCutoffMs = 0;
3097
3122
  try {
@@ -3204,6 +3229,10 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3204
3229
  }
3205
3230
  text = cleanText(text);
3206
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;
3207
3236
 
3208
3237
  const kind = type === "user" ? "user_message" : "assistant_final";
3209
3238
  const stableId = `${kind}:${threadId}:${uuid}`;
@@ -9023,6 +9052,21 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9023
9052
  });
9024
9053
  }
9025
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
+
9026
9070
  // Moltbook reply items intentionally do not appear in the unhandled list.
9027
9071
  // Reply drafting is delegated to Codex/Claude Desktop via the
9028
9072
  // `viveworker moltbook` CLI, so they live in the timeline only.
@@ -10526,6 +10570,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10526
10570
  draftType: draft?.draftType || entry?.draftType || "reply",
10527
10571
  submoltName: draft?.submoltName || entry?.submoltName || "",
10528
10572
  intent: draft?.intent || entry?.intent || "",
10573
+ slot: draft?.slot || entry?.slot || "",
10529
10574
  createdAtMs: source.createdAtMs || Date.now(),
10530
10575
  readOnly: !draft || Boolean(draft?.decision),
10531
10576
  actions: [],
@@ -10533,6 +10578,38 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10533
10578
  };
10534
10579
  }
10535
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
+
10536
10613
  const historyItem = historyItemByToken(runtime, kind, token);
10537
10614
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
10538
10615
  }
@@ -10750,6 +10827,104 @@ function createNativeApprovalServer({ config, runtime, state }) {
10750
10827
  }
10751
10828
  }
10752
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
+
10753
10928
  if (url.pathname === "/api/session" && req.method === "GET") {
10754
10929
  const session = readSession(req, config, state);
10755
10930
  const localeInfo = resolveDeviceLocaleInfo(config, state, session.deviceId);
@@ -11507,6 +11682,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11507
11682
  const postAuthor = cleanText(body.postAuthor || "");
11508
11683
  const postBody = typeof body.postBody === "string" ? body.postBody : "";
11509
11684
  const intent = typeof body.intent === "string" ? body.intent : "";
11685
+ const slot = cleanText(body.slot || "");
11510
11686
  const draft = {
11511
11687
  token,
11512
11688
  sourceId,
@@ -11520,6 +11696,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11520
11696
  draftType,
11521
11697
  submoltName,
11522
11698
  intent,
11699
+ slot,
11523
11700
  contextSummary,
11524
11701
  createdAtMs: Date.now(),
11525
11702
  decisionWaiters: [],
@@ -11546,6 +11723,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11546
11723
  draftType,
11547
11724
  submoltName,
11548
11725
  intent,
11726
+ slot,
11549
11727
  postId,
11550
11728
  postUrl,
11551
11729
  postTitle,
@@ -11561,16 +11739,28 @@ function createNativeApprovalServer({ config, runtime, state }) {
11561
11739
  console.error(`[moltbook-draft-timeline-save] ${error.message}`);
11562
11740
  }
11563
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
+ })();
11564
11756
  await deliverWebPushItem({
11565
11757
  config,
11566
11758
  state,
11567
11759
  kind: "moltbook_draft",
11568
11760
  token,
11569
11761
  stableId: `moltbook_draft:${sourceId}`,
11570
- title: draftType === "original_post"
11571
- ? (postTitle || "Moltbook new post")
11572
- : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
11573
- body: String(draftText).slice(0, 160),
11762
+ title: pushTitle,
11763
+ body: draftType === "original_post" ? (postTitle || String(draftText).slice(0, 160)) : String(draftText).slice(0, 160),
11574
11764
  });
11575
11765
  } catch (error) {
11576
11766
  console.error(`[moltbook-draft-push-error] ${error.message}`);
@@ -13556,6 +13746,20 @@ function isLoopbackHostname(value) {
13556
13746
  return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
13557
13747
  }
13558
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
+
13559
13763
  function readMoltbookEnvKey() {
13560
13764
  // Fall back to the standalone watcher's env file so the bridge can detect
13561
13765
  // a configured Moltbook integration without requiring MOLTBOOK_API_KEY to
@@ -13581,6 +13785,14 @@ function buildConfig(cli) {
13581
13785
  once: cli.once,
13582
13786
  codexHome,
13583
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
+ a2aDescription: cleanText(process.env.A2A_DESCRIPTION || ""),
13791
+ a2aSkills: cleanText(process.env.A2A_SKILLS || ""),
13792
+ a2aRelayUrl: cleanText(process.env.A2A_RELAY_URL || ""),
13793
+ a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
13794
+ a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
13795
+ a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
13584
13796
  webUiEnabled: boolEnv("WEB_UI_ENABLED", true),
13585
13797
  authRequired: boolEnv("AUTH_REQUIRED", true),
13586
13798
  webPushEnabled: boolEnv("WEB_PUSH_ENABLED", false),
@@ -14954,6 +15166,24 @@ async function main() {
14954
15166
  }
14955
15167
  }
14956
15168
 
15169
+ // --- A2A Relay ---
15170
+ if (config.a2aRelayUrl && config.a2aRelayUserId) {
15171
+ const regResult = await registerWithRelay({ config, buildAgentCard });
15172
+ if (regResult.ok) {
15173
+ startRelayPolling({
15174
+ config,
15175
+ runtime,
15176
+ state,
15177
+ helpers: { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText },
15178
+ });
15179
+ } else {
15180
+ console.error(`[a2a-relay] Registration failed: ${regResult.error}`);
15181
+ }
15182
+ }
15183
+
15184
+ let lastA2aEnvCheckAt = 0;
15185
+ const A2A_ENV_CHECK_INTERVAL_MS = 30_000; // check every 30 seconds
15186
+
14957
15187
  while (!runtime.stopping) {
14958
15188
  try {
14959
15189
  const dirty = await scanOnce({ config, runtime, state });
@@ -14964,6 +15194,38 @@ async function main() {
14964
15194
  console.error(`[scan-error] ${error.message}`);
14965
15195
  }
14966
15196
 
15197
+ // Hot-reload: detect a2a.env changes and auto-start relay
15198
+ const now = Date.now();
15199
+ if (!config.a2aRelayUrl && now - lastA2aEnvCheckAt > A2A_ENV_CHECK_INTERVAL_MS) {
15200
+ lastA2aEnvCheckAt = now;
15201
+ try {
15202
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
15203
+ const newRelayUrl = cleanText(process.env.A2A_RELAY_URL || "");
15204
+ const newRelayUserId = cleanText(process.env.A2A_RELAY_USER_ID || "");
15205
+ const newRelayRegisterSecret = cleanText(process.env.A2A_RELAY_REGISTER_SECRET || "");
15206
+ if (newRelayUrl && newRelayUserId) {
15207
+ config.a2aRelayUrl = newRelayUrl;
15208
+ config.a2aRelayUserId = newRelayUserId;
15209
+ config.a2aRelayRegisterSecret = newRelayRegisterSecret;
15210
+ config.a2aApiKey = cleanText(process.env.A2A_API_KEY || config.a2aApiKey || "");
15211
+ console.log(`[a2a-relay] Detected new relay config, registering...`);
15212
+ const regResult = await registerWithRelay({ config, buildAgentCard });
15213
+ if (regResult.ok) {
15214
+ startRelayPolling({
15215
+ config,
15216
+ runtime,
15217
+ state,
15218
+ helpers: { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText },
15219
+ });
15220
+ } else {
15221
+ console.error(`[a2a-relay] Registration failed: ${regResult.error}`);
15222
+ }
15223
+ }
15224
+ } catch (error) {
15225
+ // ignore — env file may not exist yet
15226
+ }
15227
+ }
15228
+
14967
15229
  if (config.once) {
14968
15230
  break;
14969
15231
  }
@@ -14973,6 +15235,8 @@ async function main() {
14973
15235
  } finally {
14974
15236
  runtime.stopping = true;
14975
15237
 
15238
+ stopRelayPolling();
15239
+
14976
15240
  if (runtime.ipcClient) {
14977
15241
  runtime.ipcClient.stop();
14978
15242
  }
@@ -42,6 +42,17 @@ if (rawArgs[0] === "moltbook") {
42
42
  }
43
43
  }
44
44
 
45
+ if (rawArgs[0] === "a2a") {
46
+ const { runA2ACli } = await import("./a2a-cli.mjs");
47
+ try {
48
+ await runA2ACli(rawArgs.slice(1));
49
+ process.exit(0);
50
+ } catch (error) {
51
+ console.error(error.message || String(error));
52
+ process.exit(1);
53
+ }
54
+ }
55
+
45
56
  const cli = parseArgs(rawArgs);
46
57
 
47
58
  try {
package/web/app.css CHANGED
@@ -2318,6 +2318,25 @@ code {
2318
2318
  border-color: var(--accent, #6ea8ff);
2319
2319
  }
2320
2320
 
2321
+ .compose-greeting {
2322
+ display: flex;
2323
+ align-items: center;
2324
+ gap: 0.55rem;
2325
+ margin: 0.6rem 0 0.4rem;
2326
+ padding: 0.65rem 0.85rem;
2327
+ border-radius: 12px;
2328
+ border-left: 3px solid var(--completion, #d2aa63);
2329
+ background: rgba(210, 170, 99, 0.10);
2330
+ }
2331
+ .compose-greeting__icon {
2332
+ font-size: 1.25rem;
2333
+ line-height: 1;
2334
+ }
2335
+ .compose-greeting__text {
2336
+ font-size: 0.92rem;
2337
+ color: var(--text);
2338
+ font-weight: 500;
2339
+ }
2321
2340
  .reply-field--title {
2322
2341
  margin-bottom: 0.5rem;
2323
2342
  }
@@ -3421,6 +3440,10 @@ button[data-action-url][aria-busy="true"]:not(.is-loading) {
3421
3440
  color: #e07a5f;
3422
3441
  background: rgba(224, 122, 95, 0.12);
3423
3442
  }
3443
+ .provider-badge--a2a {
3444
+ color: #81c784;
3445
+ background: rgba(129, 199, 132, 0.12);
3446
+ }
3424
3447
 
3425
3448
  .provider-filter {
3426
3449
  display: inline-flex;
@@ -3431,18 +3454,22 @@ button[data-action-url][aria-busy="true"]:not(.is-loading) {
3431
3454
  background: rgba(255, 255, 255, 0.04);
3432
3455
  border: 1px solid rgba(255, 255, 255, 0.08);
3433
3456
  border-radius: 999px;
3434
- width: fit-content;
3457
+ max-width: 100%;
3458
+ overflow-x: auto;
3459
+ -webkit-overflow-scrolling: touch;
3435
3460
  }
3436
3461
  .provider-filter__button {
3437
3462
  appearance: none;
3438
3463
  background: transparent;
3439
3464
  color: var(--text-muted, #9aa6b2);
3440
3465
  border: 0;
3441
- padding: 0.3rem 0.85rem;
3466
+ padding: 0.3rem 0.65rem;
3442
3467
  font-size: 0.78rem;
3443
3468
  font-weight: 600;
3444
3469
  border-radius: 999px;
3445
3470
  cursor: pointer;
3471
+ white-space: nowrap;
3472
+ flex-shrink: 0;
3446
3473
  transition: background 0.15s ease, color 0.15s ease;
3447
3474
  }
3448
3475
  .provider-filter__button:hover {