viveworker 0.4.8 → 0.4.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Local mobile companion for Codex Desktop and Claude Desktop — approvals, code review, Moltbook drafts, and A2A (Agent-to-Agent) task relay on your LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -14,6 +14,67 @@ import { completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
14
14
 
15
15
  const APP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
16
16
 
17
+ // ---------------------------------------------------------------------------
18
+ // PATH augmentation for launchd environments
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Build an augmented PATH that includes nvm, homebrew, and other common
23
+ * directories so spawned processes (claude, codex) can find `node`, etc.
24
+ *
25
+ * Under launchd the inherited PATH is minimal (/usr/bin:/bin:/usr/sbin:/sbin).
26
+ * We prepend well-known bin directories so child processes work correctly.
27
+ *
28
+ * @param {string} [binPath] - Resolved path to the binary being spawned;
29
+ * its parent directory is prepended to PATH.
30
+ */
31
+ function buildAugmentedPath(binPath) {
32
+ const extra = [];
33
+
34
+ // Include the directory of the resolved binary itself
35
+ if (binPath && binPath !== "codex" && binPath !== "claude") {
36
+ extra.push(path.dirname(binPath));
37
+ }
38
+
39
+ // nvm — find the active or latest node version's bin directory
40
+ const nvmBase = path.join(os.homedir(), ".nvm", "versions", "node");
41
+ if (existsSync(nvmBase)) {
42
+ // Prefer NVM_BIN if set (interactive shell), otherwise scan versions
43
+ if (process.env.NVM_BIN && existsSync(process.env.NVM_BIN)) {
44
+ extra.push(process.env.NVM_BIN);
45
+ } else {
46
+ const ls = spawnSync("ls", [nvmBase], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
47
+ if (ls.status === 0 && ls.stdout) {
48
+ const versions = ls.stdout.trim().split("\n").filter(Boolean).reverse();
49
+ for (const v of versions) {
50
+ const binDir = path.join(nvmBase, v, "bin");
51
+ if (existsSync(path.join(binDir, "node"))) {
52
+ extra.push(binDir);
53
+ break; // use the latest version that has node
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ // Homebrew and common paths
61
+ for (const p of ["/opt/homebrew/bin", "/usr/local/bin"]) {
62
+ if (existsSync(p)) extra.push(p);
63
+ }
64
+
65
+ const basePath = process.env.PATH || "/usr/bin:/bin:/usr/sbin:/sbin";
66
+ // Deduplicate while preserving order
67
+ const seen = new Set();
68
+ const parts = [];
69
+ for (const dir of [...extra, ...basePath.split(":")]) {
70
+ if (dir && !seen.has(dir)) {
71
+ seen.add(dir);
72
+ parts.push(dir);
73
+ }
74
+ }
75
+ return parts.join(":");
76
+ }
77
+
17
78
  /**
18
79
  * Try to find the claude binary by walking well-known locations.
19
80
  * Returns the absolute path if found, or null.
@@ -98,9 +159,11 @@ const EXEC_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
98
159
  * @param {object} helpers
99
160
  * @param {Function} helpers.recordTimelineEntry
100
161
  * @param {Function} helpers.saveState
162
+ * @param {Function} [helpers.recordHistoryItem] - Optional history-item recorder (adds to completed inbox)
163
+ * @param {Function} [helpers.deliverWebPushItem] - Optional web push delivery helper
101
164
  * @param {string} [executor] - "codex" or "claude" (auto-detect if omitted)
102
165
  */
103
- export async function executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState }, executor) {
166
+ export async function executeA2ATask(task, config, runtime, state, { recordTimelineEntry, recordHistoryItem, saveState, deliverWebPushItem }, executor) {
104
167
  const instruction = task.instruction || "";
105
168
  if (!instruction) {
106
169
  failA2ATask(task, "No instruction provided");
@@ -126,38 +189,59 @@ export async function executeA2ATask(task, config, runtime, state, { recordTimel
126
189
  console.error(`[a2a-exec] Task ${task.id} failed via ${executor}: ${error.message}`);
127
190
  }
128
191
 
129
- // Record completion in timeline
192
+ // Record completion in timeline AND history (history drives completed inbox list).
193
+ const resultEntry = {
194
+ stableId: `a2a_task_result:${task.id}`,
195
+ token: task.token,
196
+ kind: "a2a_task_result",
197
+ threadId: "a2a",
198
+ threadLabel: instruction.slice(0, 80),
199
+ title: task.status === "completed"
200
+ ? `A2A ✅: ${instruction.slice(0, 60)}`
201
+ : `A2A ❌: ${instruction.slice(0, 60)}`,
202
+ summary: task.status === "completed"
203
+ ? (task.artifacts?.[0]?.parts?.[0]?.text || "").slice(0, 160)
204
+ : task.statusMessage || "Failed",
205
+ instruction,
206
+ messageText: task.status === "completed"
207
+ ? (task.artifacts?.[0]?.parts?.[0]?.text || "").slice(0, 500)
208
+ : task.statusMessage || "Failed",
209
+ taskStatus: task.status,
210
+ createdAtMs: task.updatedAtMs || Date.now(),
211
+ readOnly: true,
212
+ provider: "a2a",
213
+ };
130
214
  try {
131
- recordTimelineEntry({
132
- config,
133
- runtime,
134
- state,
135
- entry: {
136
- stableId: `a2a_task_result:${task.id}`,
137
- token: task.token,
138
- kind: "a2a_task_result",
139
- threadId: "a2a",
140
- threadLabel: instruction.slice(0, 80),
141
- title: task.status === "completed"
142
- ? `A2A ✅: ${instruction.slice(0, 60)}`
143
- : `A2A ❌: ${instruction.slice(0, 60)}`,
144
- summary: task.status === "completed"
145
- ? (task.artifacts?.[0]?.parts?.[0]?.text || "").slice(0, 160)
146
- : task.statusMessage || "Failed",
147
- instruction,
148
- messageText: task.status === "completed"
149
- ? (task.artifacts?.[0]?.parts?.[0]?.text || "").slice(0, 500)
150
- : task.statusMessage || "Failed",
151
- taskStatus: task.status,
152
- createdAtMs: task.updatedAtMs || Date.now(),
153
- readOnly: true,
154
- provider: "a2a",
155
- },
156
- });
215
+ recordTimelineEntry({ config, runtime, state, entry: resultEntry });
216
+ if (typeof recordHistoryItem === "function") {
217
+ recordHistoryItem({ config, runtime, state, item: resultEntry });
218
+ }
157
219
  await saveState(config.stateFile, state);
158
220
  } catch (error) {
159
221
  console.error(`[a2a-exec-timeline] ${error.message}`);
160
222
  }
223
+
224
+ // Send web push notification for completion/failure.
225
+ if (typeof deliverWebPushItem === "function") {
226
+ try {
227
+ const isCompleted = task.status === "completed";
228
+ const icon = isCompleted ? "✅" : "❌";
229
+ const resultBody = isCompleted
230
+ ? (task.artifacts?.[0]?.parts?.[0]?.text || "").slice(0, 160)
231
+ : (task.statusMessage || "Failed").slice(0, 160);
232
+ await deliverWebPushItem({
233
+ config,
234
+ state,
235
+ kind: "a2a_task_result",
236
+ token: task.token,
237
+ stableId: `a2a_task_result:${task.id}`,
238
+ title: `A2A ${icon}: ${instruction.slice(0, 60)}`,
239
+ body: resultBody || instruction.slice(0, 160),
240
+ });
241
+ } catch (error) {
242
+ console.error(`[a2a-exec-push] ${error.message}`);
243
+ }
244
+ }
161
245
  }
162
246
 
163
247
  /**
@@ -172,7 +256,11 @@ function runCodexExec(instruction, config) {
172
256
  const child = spawn(codexBin, args, {
173
257
  cwd,
174
258
  stdio: ["pipe", "pipe", "pipe"],
175
- env: { ...process.env, CODEX_HOME: config.codexHome || path.join(os.homedir(), ".codex") },
259
+ env: {
260
+ ...process.env,
261
+ PATH: buildAugmentedPath(codexBin),
262
+ CODEX_HOME: config.codexHome || path.join(os.homedir(), ".codex"),
263
+ },
176
264
  timeout: EXEC_TIMEOUT_MS,
177
265
  });
178
266
 
@@ -223,7 +311,7 @@ function runClaudeExec(instruction) {
223
311
  const child = spawn(claudeBin, args, {
224
312
  cwd: os.tmpdir(),
225
313
  stdio: ["pipe", "pipe", "pipe"],
226
- env: { ...process.env },
314
+ env: { ...process.env, PATH: buildAugmentedPath(claudeBin) },
227
315
  timeout: EXEC_TIMEOUT_MS,
228
316
  });
229
317
 
@@ -291,6 +291,10 @@ async function handleMessageSend({
291
291
 
292
292
  runtime.a2aTasksByToken.set(token, task);
293
293
 
294
+ // Update received task counter.
295
+ if (!state.a2aTaskStats) state.a2aTaskStats = { received: 0, completed: 0, denied: 0 };
296
+ state.a2aTaskStats.received += 1;
297
+
294
298
  // Record timeline entry
295
299
  try {
296
300
  recordTimelineEntry({
@@ -270,6 +270,10 @@ async function ingestRelayTask({ relayTask, config, runtime, state, helpers }) {
270
270
 
271
271
  runtime.a2aTasksByToken.set(token, task);
272
272
 
273
+ // Update received task counter.
274
+ if (!state.a2aTaskStats) state.a2aTaskStats = { received: 0, completed: 0, denied: 0 };
275
+ state.a2aTaskStats.received += 1;
276
+
273
277
  // Record timeline entry (same as local A2A handler)
274
278
  try {
275
279
  recordTimelineEntry({
@@ -172,14 +172,14 @@ export function rollScoutDayIfNeeded(state) {
172
172
  return state;
173
173
  }
174
174
 
175
- export function recordComposeAttempt(state, title, postId) {
175
+ export function recordComposeAttempt(state, title, postId, type = "post") {
176
176
  state.composedToday = (state.composedToday || 0) + 1;
177
177
  state.lastComposeDay = todayKey();
178
178
  if (!Array.isArray(state.recentComposeTitles)) state.recentComposeTitles = [];
179
- state.recentComposeTitles.unshift(
180
- postId ? { title: String(title || ""), postId: String(postId) } : String(title || "")
181
- );
182
- if (state.recentComposeTitles.length > 10) state.recentComposeTitles.length = 10;
179
+ const entry = { title: String(title || ""), type };
180
+ if (postId) entry.postId = String(postId);
181
+ state.recentComposeTitles.unshift(entry);
182
+ if (state.recentComposeTitles.length > 30) state.recentComposeTitles.length = 30;
183
183
  return state;
184
184
  }
185
185
 
@@ -11162,6 +11162,15 @@ function createNativeApprovalServer({ config, runtime, state }) {
11162
11162
  const requestedExecutor = cleanText(body.executor || "");
11163
11163
  resolveA2ATaskDecision(task, { action, instruction });
11164
11164
 
11165
+ // Update task stats.
11166
+ if (!state.a2aTaskStats) state.a2aTaskStats = { received: 0, completed: 0, denied: 0 };
11167
+ if (action === "approve") {
11168
+ state.a2aTaskStats.completed += 1;
11169
+ } else {
11170
+ state.a2aTaskStats.denied += 1;
11171
+ }
11172
+ saveState(config.stateFile, state).catch(() => {});
11173
+
11165
11174
  if (action === "approve") {
11166
11175
  // Resolve which executor to use.
11167
11176
  const available = runtime.a2aAvailableExecutors || { codex: false, claude: false };
@@ -11177,7 +11186,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11177
11186
  (async () => {
11178
11187
  try {
11179
11188
  const { executeA2ATask } = await import("./a2a-executor.mjs");
11180
- await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState }, executor);
11189
+ await executeA2ATask(task, config, runtime, state, { recordTimelineEntry, recordHistoryItem, saveState, deliverWebPushItem }, executor);
11181
11190
  } catch (error) {
11182
11191
  console.error(`[a2a-execute-error] ${error.message}`);
11183
11192
  failA2ATask(task, error.message);
@@ -11454,6 +11463,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11454
11463
  return writeJson(res, 200, { enabled: false });
11455
11464
  }
11456
11465
  const relay = getRelayStatus();
11466
+ const stats = state.a2aTaskStats || { received: 0, completed: 0, denied: 0 };
11457
11467
  return writeJson(res, 200, {
11458
11468
  enabled: true,
11459
11469
  connected: relay.polling && relay.lastPollOk,
@@ -11464,6 +11474,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11464
11474
  relayUrl: config.a2aRelayUrl,
11465
11475
  apiKeyConfigured: Boolean(config.a2aApiKey),
11466
11476
  acceptPublicTasks: config.a2aAcceptPublicTasks === true,
11477
+ taskStats: stats,
11467
11478
  });
11468
11479
  }
11469
11480
 
@@ -14536,7 +14547,7 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
14536
14547
  console.log(`[moltbook-draft-post] Posted original post (id=${post?.id})`);
14537
14548
 
14538
14549
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
14539
- recordComposeAttempt(scoutState, finalTitle, post?.id);
14550
+ recordComposeAttempt(scoutState, finalTitle, post?.id, "post");
14540
14551
  await writeScoutState(scoutState);
14541
14552
  } else {
14542
14553
  const result = await mb(`/posts/${draft.postId}/comments`, {
@@ -14553,6 +14564,7 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
14553
14564
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
14554
14565
  scoutState.sentToday += 1;
14555
14566
  markPostSeen(scoutState, draft.postId, "published");
14567
+ recordComposeAttempt(scoutState, draft.postTitle || draft.postId, draft.postId, "reply");
14556
14568
  await writeScoutState(scoutState);
14557
14569
  }
14558
14570
 
package/web/app.css CHANGED
@@ -609,6 +609,71 @@ code {
609
609
  color: rgba(121, 196, 255, 0.82);
610
610
  }
611
611
 
612
+ .settings-compose-badge {
613
+ display: inline-block;
614
+ align-self: flex-start;
615
+ padding: 0.12rem 0.42rem;
616
+ border-radius: 6px;
617
+ font-size: 0.7rem;
618
+ font-weight: 600;
619
+ letter-spacing: 0.03em;
620
+ line-height: 1.3;
621
+ }
622
+
623
+ .settings-compose-badge--post {
624
+ background: rgba(67, 160, 255, 0.18);
625
+ color: rgba(121, 196, 255, 0.92);
626
+ }
627
+
628
+ .settings-compose-badge--reply {
629
+ background: rgba(160, 120, 255, 0.18);
630
+ color: rgba(190, 160, 255, 0.92);
631
+ }
632
+
633
+ .settings-compose-entry {
634
+ display: flex;
635
+ flex-direction: column;
636
+ gap: 0.32rem;
637
+ padding: 0.72rem 1rem;
638
+ }
639
+
640
+ .settings-compose-entry + .settings-compose-entry {
641
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
642
+ }
643
+
644
+ .settings-compose-entry__title {
645
+ font-size: 0.88rem;
646
+ line-height: 1.4;
647
+ color: var(--text);
648
+ }
649
+
650
+ .settings-compose-entry__title a {
651
+ color: var(--accent, #00d4aa);
652
+ text-decoration: none;
653
+ }
654
+
655
+ .settings-compose-entry__title a:hover {
656
+ text-decoration: underline;
657
+ }
658
+
659
+ .settings-compose-more {
660
+ display: block;
661
+ width: 100%;
662
+ padding: 0.68rem 1rem;
663
+ background: transparent;
664
+ border: 0;
665
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
666
+ color: rgba(121, 196, 255, 0.82);
667
+ font-size: 0.83rem;
668
+ text-align: center;
669
+ cursor: pointer;
670
+ -webkit-tap-highlight-color: transparent;
671
+ }
672
+
673
+ .settings-compose-more:active {
674
+ background: rgba(255, 255, 255, 0.035);
675
+ }
676
+
612
677
  .settings-row__icon {
613
678
  width: 1.95rem;
614
679
  height: 1.95rem;
@@ -3250,6 +3315,67 @@ button:disabled {
3250
3315
  font-size: 0.88rem;
3251
3316
  }
3252
3317
 
3318
+ .a2a-executor-picker {
3319
+ display: flex;
3320
+ gap: 0.5rem;
3321
+ }
3322
+
3323
+ .a2a-executor-picker__option {
3324
+ flex: 1;
3325
+ display: flex;
3326
+ align-items: center;
3327
+ justify-content: center;
3328
+ gap: 0.5rem;
3329
+ padding: 0.62rem 0.8rem;
3330
+ border: 1px solid rgba(156, 181, 197, 0.16);
3331
+ border-radius: 12px;
3332
+ background: rgba(255, 255, 255, 0.035);
3333
+ cursor: pointer;
3334
+ font-size: 0.9rem;
3335
+ color: var(--text);
3336
+ transition: border-color 160ms ease, background 160ms ease;
3337
+ -webkit-tap-highlight-color: transparent;
3338
+ }
3339
+
3340
+ .a2a-executor-picker__option:has(input:checked) {
3341
+ border-color: rgba(121, 196, 255, 0.5);
3342
+ background: rgba(67, 160, 255, 0.12);
3343
+ }
3344
+
3345
+ .a2a-executor-picker__option:active {
3346
+ background: rgba(255, 255, 255, 0.06);
3347
+ }
3348
+
3349
+ .a2a-executor-picker__option input[type="radio"] {
3350
+ appearance: none;
3351
+ -webkit-appearance: none;
3352
+ width: 1.1rem;
3353
+ height: 1.1rem;
3354
+ border: 2px solid rgba(156, 181, 197, 0.32);
3355
+ border-radius: 50%;
3356
+ background: transparent;
3357
+ flex-shrink: 0;
3358
+ position: relative;
3359
+ cursor: pointer;
3360
+ transition: border-color 160ms ease;
3361
+ }
3362
+
3363
+ .a2a-executor-picker__option input[type="radio"]:checked {
3364
+ border-color: rgba(121, 196, 255, 0.92);
3365
+ }
3366
+
3367
+ .a2a-executor-picker__option input[type="radio"]:checked::after {
3368
+ content: "";
3369
+ position: absolute;
3370
+ top: 50%;
3371
+ left: 50%;
3372
+ transform: translate(-50%, -50%);
3373
+ width: 0.46rem;
3374
+ height: 0.46rem;
3375
+ border-radius: 50%;
3376
+ background: rgba(121, 196, 255, 0.92);
3377
+ }
3378
+
3253
3379
  .field input {
3254
3380
  width: 100%;
3255
3381
  min-height: 3.25rem;
package/web/app.js CHANGED
@@ -48,7 +48,9 @@ const state = {
48
48
  pairNotice: "",
49
49
  pushStatus: null,
50
50
  moltbookScoutStatus: null,
51
+ moltbookRecentTitlesExpanded: 0,
51
52
  a2aRelayStatus: null,
53
+ a2aTaskExecutorPick: "codex",
52
54
  pushNotice: "",
53
55
  pushError: "",
54
56
  deviceNotice: "",
@@ -3398,16 +3400,33 @@ function renderSettingsMoltbookPage(context) {
3398
3400
  renderSettingsInfoRow(L("settings.row.moltbookSeenPosts"), String(scout.seenPostCount)),
3399
3401
  ])}
3400
3402
  ${batchRows.length ? renderSettingsGroup(L("settings.moltbook.batchTitle"), batchRows) : ""}
3401
- ${Array.isArray(scout.recentComposeTitles) && scout.recentComposeTitles.length
3402
- ? renderSettingsGroup(L("settings.row.moltbookRecentTitles"), scout.recentComposeTitles.map((t) => {
3403
- const title = typeof t === "string" ? t : (t.title || "");
3404
- const postId = typeof t === "object" ? t.postId : "";
3405
- const display = postId
3406
- ? `<a href="https://www.moltbook.com/post/${escapeHtml(postId)}" target="_blank" rel="noopener">${escapeHtml(title)}</a>`
3407
- : escapeHtml(title);
3408
- return renderSettingsInfoRow("", display, { rawValue: true, rowClassName: "settings-info-row--stacked" });
3409
- }))
3410
- : ""}
3403
+ ${(() => {
3404
+ const titles = Array.isArray(scout.recentComposeTitles) ? scout.recentComposeTitles : [];
3405
+ if (!titles.length) return "";
3406
+ const PAGE_SIZE = 5;
3407
+ const visibleCount = state.moltbookRecentTitlesExpanded || PAGE_SIZE;
3408
+ const visible = titles.slice(0, visibleCount);
3409
+ const hasMore = titles.length > visibleCount;
3410
+ const rows = visible.map((t) => {
3411
+ const title = typeof t === "string" ? t : (t.title || "");
3412
+ const postId = typeof t === "object" ? t.postId : "";
3413
+ const type = typeof t === "object" ? (t.type || "post") : "post";
3414
+ const badge = type === "reply"
3415
+ ? `<span class="settings-compose-badge settings-compose-badge--reply">${escapeHtml(L("settings.moltbook.typeReply"))}</span>`
3416
+ : `<span class="settings-compose-badge settings-compose-badge--post">${escapeHtml(L("settings.moltbook.typePost"))}</span>`;
3417
+ const link = postId
3418
+ ? `<a href="https://www.moltbook.com/post/${escapeHtml(postId)}" target="_blank" rel="noopener">${escapeHtml(title)}</a>`
3419
+ : escapeHtml(title);
3420
+ return `<div class="settings-compose-entry">${badge}<span class="settings-compose-entry__title">${link}</span></div>`;
3421
+ });
3422
+ if (hasMore) {
3423
+ const remaining = titles.length - visibleCount;
3424
+ rows.push(`<button type="button" class="settings-compose-more" data-moltbook-titles-more>${escapeHtml(L("settings.moltbook.showMore", { count: remaining }))}</button>`);
3425
+ } else if (titles.length > PAGE_SIZE) {
3426
+ rows.push(`<button type="button" class="settings-compose-more" data-moltbook-titles-collapse>${escapeHtml(L("settings.moltbook.showLess"))}</button>`);
3427
+ }
3428
+ return renderSettingsGroup(L("settings.row.moltbookRecentTitles"), rows);
3429
+ })()}
3411
3430
  </div>
3412
3431
  `;
3413
3432
  }
@@ -3446,15 +3465,21 @@ function renderSettingsA2aRelayPage(context) {
3446
3465
 
3447
3466
  return `
3448
3467
  <div class="settings-page">
3449
- ${renderSettingsGroup("", [
3450
- renderSettingsInfoRow(L("settings.row.a2aStatus"), statusLabel),
3451
- renderSettingsInfoRow(L("settings.row.a2aUserId"), userIdLink, { rawValue: true }),
3452
- renderSettingsInfoRow(L("settings.row.a2aRelay"), relayHost),
3453
- renderSettingsInfoRow(L("settings.row.a2aApiKey"), relay.apiKeyConfigured ? L("settings.a2aRelay.apiKey.configured") : L("settings.a2aRelay.apiKey.notConfigured")),
3454
- relay.lastPollAtMs
3455
- ? renderSettingsInfoRow(L("settings.row.a2aLastPoll"), new Date(relay.lastPollAtMs).toLocaleString(state.locale))
3456
- : "",
3457
- ].filter(Boolean))}
3468
+ ${(() => {
3469
+ const stats = relay.taskStats || { received: 0, completed: 0, denied: 0 };
3470
+ return renderSettingsGroup("", [
3471
+ renderSettingsInfoRow(L("settings.row.a2aStatus"), statusLabel),
3472
+ renderSettingsInfoRow(L("settings.row.a2aUserId"), userIdLink, { rawValue: true }),
3473
+ renderSettingsInfoRow(L("settings.row.a2aRelay"), relayHost),
3474
+ renderSettingsInfoRow(L("settings.row.a2aApiKey"), relay.apiKeyConfigured ? L("settings.a2aRelay.apiKey.configured") : L("settings.a2aRelay.apiKey.notConfigured")),
3475
+ relay.lastPollAtMs
3476
+ ? renderSettingsInfoRow(L("settings.row.a2aLastPoll"), new Date(relay.lastPollAtMs).toLocaleString(state.locale))
3477
+ : "",
3478
+ renderSettingsInfoRow(L("settings.row.a2aTaskReceived"), String(stats.received)),
3479
+ renderSettingsInfoRow(L("settings.row.a2aTaskCompleted"), String(stats.completed)),
3480
+ renderSettingsInfoRow(L("settings.row.a2aTaskDenied"), String(stats.denied)),
3481
+ ].filter(Boolean));
3482
+ })()}
3458
3483
  <section class="settings-group">
3459
3484
  <p class="settings-group__title">${escapeHtml(L("settings.a2aRelay.publicTasks.title"))}</p>
3460
3485
  <label class="reply-mode-switch reply-mode-switch--settings" data-a2a-public-toggle>
@@ -4224,17 +4249,18 @@ function renderA2ATaskDetail(detail, options = {}) {
4224
4249
  const executors = state.session?.a2aExecutors || { codex: false, claude: false };
4225
4250
  const executorPref = state.session?.a2aExecutorPreference || "auto";
4226
4251
  const showExecutorPicker = enabled && executorPref === "ask" && executors.codex && executors.claude;
4252
+ const pickedExecutor = state.a2aTaskExecutorPick || "codex";
4227
4253
  const executorPicker = showExecutorPicker
4228
4254
  ? `
4229
4255
  <div class="reply-composer__instruction">
4230
4256
  <label class="field-label">${escapeHtml(L("a2a.task.executor"))}</label>
4231
4257
  <div class="a2a-executor-picker">
4232
4258
  <label class="a2a-executor-picker__option">
4233
- <input type="radio" name="executor" value="codex" checked />
4259
+ <input type="radio" name="executor" value="codex" ${pickedExecutor === "codex" ? "checked" : ""} />
4234
4260
  <span>${escapeHtml(L("a2a.executor.codex"))}</span>
4235
4261
  </label>
4236
4262
  <label class="a2a-executor-picker__option">
4237
- <input type="radio" name="executor" value="claude" />
4263
+ <input type="radio" name="executor" value="claude" ${pickedExecutor === "claude" ? "checked" : ""} />
4238
4264
  <span>${escapeHtml(L("a2a.executor.claude"))}</span>
4239
4265
  </label>
4240
4266
  </div>
@@ -5354,6 +5380,19 @@ function bindShellInteractions() {
5354
5380
  });
5355
5381
  }
5356
5382
 
5383
+ for (const btn of document.querySelectorAll("[data-moltbook-titles-more]")) {
5384
+ btn.addEventListener("click", async () => {
5385
+ state.moltbookRecentTitlesExpanded = (state.moltbookRecentTitlesExpanded || 5) + 5;
5386
+ await renderShell();
5387
+ });
5388
+ }
5389
+ for (const btn of document.querySelectorAll("[data-moltbook-titles-collapse]")) {
5390
+ btn.addEventListener("click", async () => {
5391
+ state.moltbookRecentTitlesExpanded = 0;
5392
+ await renderShell();
5393
+ });
5394
+ }
5395
+
5357
5396
  for (const button of document.querySelectorAll("[data-locale-option]")) {
5358
5397
  button.addEventListener("click", async () => {
5359
5398
  state.pushError = "";
@@ -5487,6 +5526,12 @@ function bindShellInteractions() {
5487
5526
  });
5488
5527
  }
5489
5528
 
5529
+ for (const radio of document.querySelectorAll(".a2a-executor-picker input[type='radio']")) {
5530
+ radio.addEventListener("change", () => {
5531
+ if (radio.checked) state.a2aTaskExecutorPick = radio.value;
5532
+ });
5533
+ }
5534
+
5490
5535
  const a2aTaskForm = document.querySelector("[data-a2a-task-form]");
5491
5536
  if (a2aTaskForm) {
5492
5537
  const token = a2aTaskForm.dataset.token || "";
package/web/i18n.js CHANGED
@@ -371,7 +371,11 @@ const translations = {
371
371
  "settings.row.moltbookBatchTopScore": "Top score",
372
372
  "settings.row.moltbookBatchRemaining": "Time remaining",
373
373
  "settings.row.moltbookComposed": "Original posts today",
374
- "settings.row.moltbookRecentTitles": "Recent original posts",
374
+ "settings.row.moltbookRecentTitles": "Recent posts",
375
+ "settings.moltbook.typePost": "Post",
376
+ "settings.moltbook.typeReply": "Reply",
377
+ "settings.moltbook.showMore": "{count} more…",
378
+ "settings.moltbook.showLess": "Show less",
375
379
  "moltbook.draft.eyebrowPost": "Moltbook new post",
376
380
  "moltbook.draft.eyebrowReply": "Moltbook draft",
377
381
  "moltbook.draft.intent": "Intent",
@@ -400,6 +404,9 @@ const translations = {
400
404
  "settings.row.a2aRelay": "Relay",
401
405
  "settings.row.a2aApiKey": "API Key",
402
406
  "settings.row.a2aLastPoll": "Last poll",
407
+ "settings.row.a2aTaskReceived": "Tasks received",
408
+ "settings.row.a2aTaskCompleted": "Tasks completed",
409
+ "settings.row.a2aTaskDenied": "Tasks denied",
403
410
  "a2a.task.eyebrow": "A2A Task Request",
404
411
  "a2a.task.from": "From",
405
412
  "a2a.task.instruction": "Task",
@@ -1020,7 +1027,11 @@ const translations = {
1020
1027
  "settings.row.moltbookBatchTopScore": "最高スコア",
1021
1028
  "settings.row.moltbookBatchRemaining": "残り時間",
1022
1029
  "settings.row.moltbookComposed": "本日の新規投稿数",
1023
- "settings.row.moltbookRecentTitles": "最近の新規投稿タイトル",
1030
+ "settings.row.moltbookRecentTitles": "最近の投稿",
1031
+ "settings.moltbook.typePost": "投稿",
1032
+ "settings.moltbook.typeReply": "返信",
1033
+ "settings.moltbook.showMore": "さらに {count} 件…",
1034
+ "settings.moltbook.showLess": "閉じる",
1024
1035
  "moltbook.draft.eyebrowPost": "Moltbook 新規投稿",
1025
1036
  "moltbook.draft.eyebrowReply": "Moltbook ドラフト",
1026
1037
  "moltbook.draft.intent": "意図",
@@ -1049,6 +1060,9 @@ const translations = {
1049
1060
  "settings.row.a2aRelay": "リレー",
1050
1061
  "settings.row.a2aApiKey": "API キー",
1051
1062
  "settings.row.a2aLastPoll": "最終ポーリング",
1063
+ "settings.row.a2aTaskReceived": "受信タスク",
1064
+ "settings.row.a2aTaskCompleted": "完了タスク",
1065
+ "settings.row.a2aTaskDenied": "拒否タスク",
1052
1066
  "a2a.task.eyebrow": "A2A タスクリクエスト",
1053
1067
  "a2a.task.from": "送信元",
1054
1068
  "a2a.task.instruction": "タスク内容",