@foxden-app/foxclaw 0.5.40 → 0.5.42

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.5.42 - 2026-06-18
6
+
7
+ ### 中文
8
+ - 新增跨节点升级广播:一个节点升级成功后会通过现有 auth sync 加密通道广播目标版本,其他节点收到后会在 runtime/auth sync 空闲时自动执行本机 self-update;终端 `foxclaw update` 成功后也会在服务重启时触发广播。
9
+ - 为避免升级风暴,收到广播的节点如果已经是目标版本会跳过;远端请求会记录到 auth sync recent events,并可在 `/auth sync status/events` 中排查。
10
+ - 精简终端 `foxclaw status` 默认输出,集中展示运行状态、Codex/app-server、工作队列、bot 概况、auth sync backlog、最近升级和错误;完整原始 JSON 可通过 `foxclaw status --json` 查看。
11
+
12
+ ### English
13
+ - Added cross-node update broadcasting: after one node updates successfully, it broadcasts the target version over the existing encrypted auth sync channel, and peers schedule their own self-update when runtime/auth sync work is idle; terminal `foxclaw update` also broadcasts after the restarted service comes back.
14
+ - To avoid update storms, peers skip broadcasts that target their current version; remote update requests are recorded in auth sync recent events for `/auth sync status/events` diagnostics.
15
+ - Condensed terminal `foxclaw status` by default, focusing on runtime health, Codex/app-server, work queues, bot overview, auth sync backlog, recent update, and errors; full raw JSON remains available via `foxclaw status --json`.
16
+
17
+ ## 0.5.41 - 2026-06-18
18
+
19
+ ### 中文
20
+ - 将 FoxClaw 的普通 Telegram 回复统一升级为 RichMessage Markdown 优先发送,覆盖短提示、usage、操作结果、升级开始/完成、错误通知和大多数诊断文本。
21
+ - 保持稳定降级:Telegram rich markdown 失败时自动回普通文本;`/status`、`/auth`、`/update` 阻塞诊断等结构化面板继续使用已验证的 RichMessage HTML 表格/列表/details。
22
+
23
+ ### English
24
+ - Upgraded FoxClaw's normal Telegram replies to prefer RichMessage Markdown, covering short notices, usage text, action results, update start/completion messages, error notifications, and most diagnostic text.
25
+ - Stable fallback is preserved: Telegram rich markdown failures automatically return to plain text, while structured panels such as `/status`, `/auth`, and blocked `/update` diagnostics continue using the verified RichMessage HTML tables/lists/details path.
26
+
5
27
  ## 0.5.40 - 2026-06-18
6
28
 
7
29
  ### 中文
@@ -198,6 +198,20 @@ export type AuthSyncNotification = {
198
198
  peer: string;
199
199
  result: AuthSyncPullResponseResult;
200
200
  reason: string | null;
201
+ } | {
202
+ kind: 'service_update_sent';
203
+ requestId: string;
204
+ targetVersion: string | null;
205
+ peers: string[];
206
+ } | {
207
+ kind: 'service_update_received';
208
+ requestId: string;
209
+ targetVersion: string | null;
210
+ sourceNodeId: string;
211
+ sourceLabel: string;
212
+ peer: string;
213
+ accepted: boolean;
214
+ reason: string | null;
201
215
  } | {
202
216
  kind: 'sync_error';
203
217
  reason: string;
@@ -219,6 +233,16 @@ export interface AuthSyncImportCallbacks {
219
233
  deleted: boolean;
220
234
  reason?: string | null;
221
235
  }>;
236
+ scheduleServiceUpdate?: (source: {
237
+ requestId: string;
238
+ nodeId: string;
239
+ label?: string | null;
240
+ targetVersion: string | null;
241
+ requestedAt: string;
242
+ }) => Promise<{
243
+ accepted: boolean;
244
+ reason?: string | null;
245
+ }>;
222
246
  isIdle: () => boolean;
223
247
  notify?: (event: AuthSyncNotification) => Promise<void>;
224
248
  }
@@ -284,8 +308,13 @@ export declare class CrossNodeAuthSync {
284
308
  acquireRefreshLease(reason: string): Promise<AuthSyncLeaseResult>;
285
309
  releaseRefreshLease(leaseId: string | null): Promise<void>;
286
310
  testPeers(): Promise<AuthSyncTestResult>;
311
+ publishServiceUpdateRequest(targetVersion: string | null): Promise<{
312
+ sent: number;
313
+ peers: string[];
314
+ }>;
287
315
  handleIncomingEnvelope(rawEnvelope: string, peer: AuthSyncPeerIdentity): Promise<boolean>;
288
316
  private handleMessage;
317
+ private handleServiceUpdateRequest;
289
318
  private handlePullRequest;
290
319
  private handlePullResponse;
291
320
  private markPullPeerUnavailable;
@@ -460,6 +460,35 @@ export class CrossNodeAuthSync {
460
460
  }
461
461
  return resultPromise;
462
462
  }
463
+ async publishServiceUpdateRequest(targetVersion) {
464
+ if (!this.isReady()) {
465
+ return { sent: 0, peers: [] };
466
+ }
467
+ const requestId = crypto.randomUUID();
468
+ const peers = [...this.peers];
469
+ await this.sendToAll({
470
+ kind: 'service.update.request',
471
+ requestId,
472
+ targetVersion,
473
+ requestedAt: new Date().toISOString(),
474
+ });
475
+ this.recordEvent({
476
+ direction: 'local',
477
+ kind: 'service.update.request',
478
+ stage: 'broadcast',
479
+ peer: null,
480
+ requestId,
481
+ candidateName: null,
482
+ detail: targetVersion ? `target=${targetVersion}` : null,
483
+ });
484
+ this.notify({
485
+ kind: 'service_update_sent',
486
+ requestId,
487
+ targetVersion,
488
+ peers,
489
+ });
490
+ return { sent: peers.length, peers };
491
+ }
463
492
  async handleIncomingEnvelope(rawEnvelope, peer) {
464
493
  if (!this.isReady() || !this.isAllowedPeer(peer)) {
465
494
  return false;
@@ -531,10 +560,47 @@ export class CrossNodeAuthSync {
531
560
  this.recordEvent({ direction: 'in', kind: 'lease.release', stage: 'released', peer: normalizePeerIdentity(peer), requestId: message.leaseId, candidateName: null, detail: null });
532
561
  }
533
562
  return;
563
+ case 'service.update.request':
564
+ await this.handleServiceUpdateRequest(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
565
+ return;
534
566
  default:
535
567
  return;
536
568
  }
537
569
  }
570
+ async handleServiceUpdateRequest(message, senderNodeId, sourceLabel, peer) {
571
+ let result = {
572
+ accepted: false,
573
+ reason: 'service update scheduling is not configured',
574
+ };
575
+ if (this.callbacks.scheduleServiceUpdate) {
576
+ result = await this.callbacks.scheduleServiceUpdate({
577
+ requestId: message.requestId,
578
+ nodeId: senderNodeId,
579
+ label: sourceLabel,
580
+ targetVersion: message.targetVersion,
581
+ requestedAt: message.requestedAt,
582
+ });
583
+ }
584
+ this.recordEvent({
585
+ direction: 'local',
586
+ kind: 'service.update.request',
587
+ stage: result.accepted ? 'accepted' : 'skipped',
588
+ peer,
589
+ requestId: message.requestId,
590
+ candidateName: null,
591
+ detail: result.reason ?? (message.targetVersion ? `target=${message.targetVersion}` : null),
592
+ });
593
+ this.notify({
594
+ kind: 'service_update_received',
595
+ requestId: message.requestId,
596
+ targetVersion: message.targetVersion,
597
+ sourceNodeId: senderNodeId,
598
+ sourceLabel,
599
+ peer,
600
+ accepted: result.accepted,
601
+ reason: result.reason ?? null,
602
+ });
603
+ }
538
604
  async handlePullRequest(message, requesterNodeId, peer) {
539
605
  if (!isAuthCandidateName(message.candidateName))
540
606
  return;
@@ -1413,6 +1479,7 @@ function requestIdFromMessage(message) {
1413
1479
  case 'delete.candidate':
1414
1480
  case 'test.ping':
1415
1481
  case 'test.pong':
1482
+ case 'service.update.request':
1416
1483
  return message.requestId;
1417
1484
  case 'lease.request':
1418
1485
  return message.leaseId;
@@ -3361,7 +3361,7 @@ export class BridgeSessionCore {
3361
3361
  writeRuntimeStatus(this.config.statusPath, status);
3362
3362
  }
3363
3363
  async sendMessage(scopeId, text, inlineKeyboard) {
3364
- return this.messaging.sendPlain(scopeId, text, inlineKeyboard);
3364
+ return this.sendRichMarkdownMessage(scopeId, text, inlineKeyboard);
3365
3365
  }
3366
3366
  async sendHtmlMessage(scopeId, text, inlineKeyboard) {
3367
3367
  return this.messaging.sendHtml(scopeId, text, inlineKeyboard);
package/dist/main.js CHANGED
@@ -13,7 +13,7 @@ import { acquireProcessLock, LockHeldError } from './lock.js';
13
13
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
14
14
  import { buildFoxclawLaunchdPlistText, extractNodePathFromLaunchdPlist, } from './launchd.js';
15
15
  import { buildFoxclawSystemdUnitText, buildSystemdRestartHelperArgs, cgroupContainsSystemdUnit, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns, } from './systemd.js';
16
- import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
16
+ import { clearPendingClusterUpdateBroadcast, createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readPendingClusterUpdateBroadcast, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
17
17
  const rawCommand = process.argv[2];
18
18
  const command = rawCommand || 'serve';
19
19
  loadEnv();
@@ -31,6 +31,86 @@ const PROXY_ENV_KEYS = [
31
31
  ];
32
32
  const STANDARD_NODE_PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'];
33
33
  const LOCAL_AUTH_REFRESH_LEASE_TTL_MS = 10 * 60_000;
34
+ const CLUSTER_UPDATE_BROADCAST_PATH = path.join(APP_HOME, 'runtime', 'pending-cluster-update-broadcast.json');
35
+ function createClusterUpdateScheduler(options) {
36
+ let pending = null;
37
+ let timer = null;
38
+ const scheduleRetry = () => {
39
+ if (timer)
40
+ return;
41
+ timer = setTimeout(() => {
42
+ timer = null;
43
+ void tryLaunch().catch((error) => {
44
+ options.logger.warn('cluster.update.launch_failed', { error: serializeError(error) });
45
+ scheduleRetry();
46
+ });
47
+ }, 30_000);
48
+ timer.unref();
49
+ };
50
+ const tryLaunch = async () => {
51
+ if (!pending)
52
+ return;
53
+ const source = pending;
54
+ if (source.targetVersion && source.targetVersion === options.currentVersion()) {
55
+ options.logger.info('cluster.update.skipped_current', { targetVersion: source.targetVersion, requestId: source.requestId });
56
+ pending = null;
57
+ return;
58
+ }
59
+ if (!options.canUpdate()) {
60
+ scheduleRetry();
61
+ return;
62
+ }
63
+ pending = null;
64
+ options.logger.info('cluster.update.starting', {
65
+ requestId: source.requestId,
66
+ sourceNodeId: source.nodeId,
67
+ sourceLabel: source.label ?? null,
68
+ targetVersion: source.targetVersion,
69
+ });
70
+ await options.selfUpdater.launch(`cluster:${source.nodeId}:${source.requestId}`, 'zh');
71
+ };
72
+ return {
73
+ schedule: async (source) => {
74
+ if (source.targetVersion && source.targetVersion === options.currentVersion()) {
75
+ return { accepted: false, reason: `already at ${source.targetVersion}` };
76
+ }
77
+ if (pending?.requestId === source.requestId) {
78
+ return { accepted: false, reason: 'update request is already scheduled' };
79
+ }
80
+ pending = source;
81
+ if (options.canUpdate()) {
82
+ void tryLaunch().catch((error) => {
83
+ options.logger.warn('cluster.update.launch_failed', { error: serializeError(error) });
84
+ scheduleRetry();
85
+ });
86
+ }
87
+ else {
88
+ scheduleRetry();
89
+ }
90
+ return { accepted: true, reason: options.canUpdate() ? 'starting when updater accepts the request' : 'queued until runtime is idle' };
91
+ },
92
+ };
93
+ }
94
+ async function publishPendingClusterUpdateBroadcast(authSync, logger) {
95
+ if (!authSync)
96
+ return;
97
+ const pending = readPendingClusterUpdateBroadcast(CLUSTER_UPDATE_BROADCAST_PATH);
98
+ if (!pending)
99
+ return;
100
+ try {
101
+ const result = await authSync.publishServiceUpdateRequest(pending.targetVersion);
102
+ clearPendingClusterUpdateBroadcast(CLUSTER_UPDATE_BROADCAST_PATH);
103
+ logger.info('cluster.update.broadcast_sent', {
104
+ targetVersion: pending.targetVersion,
105
+ fromVersion: pending.fromVersion,
106
+ sent: result.sent,
107
+ peers: result.peers,
108
+ });
109
+ }
110
+ catch (error) {
111
+ logger.warn('cluster.update.broadcast_failed', { error: serializeError(error) });
112
+ }
113
+ }
34
114
  function createLocalAuthRefreshLease() {
35
115
  let active = null;
36
116
  const expire = () => {
@@ -98,6 +178,7 @@ async function main() {
98
178
  entryPoint,
99
179
  nodePath: process.execPath,
100
180
  version: readPackageVersion(),
181
+ clusterBroadcastFile: CLUSTER_UPDATE_BROADCAST_PATH,
101
182
  ...(process.env.CODEX_CLI_BIN || resolveCommand('codex')
102
183
  ? { codexCliBin: process.env.CODEX_CLI_BIN || resolveCommand('codex') }
103
184
  : {}),
@@ -129,7 +210,12 @@ async function main() {
129
210
  console.log('No runtime status found.');
130
211
  process.exit(1);
131
212
  }
132
- console.log(JSON.stringify(status, null, 2));
213
+ if (process.argv.slice(3).includes('--json')) {
214
+ console.log(JSON.stringify(status, null, 2));
215
+ }
216
+ else {
217
+ console.log(formatRuntimeStatusSummary(status));
218
+ }
133
219
  return;
134
220
  }
135
221
  if (command === 'doctor') {
@@ -183,6 +269,77 @@ Usage:
183
269
  foxclaw --version
184
270
  foxclaw --help`);
185
271
  }
272
+ function formatRuntimeStatusSummary(status) {
273
+ const lines = [];
274
+ const age = formatAge(status.updatedAt);
275
+ lines.push(`FoxClaw status: ${status.running ? 'running' : 'stopped'}${status.connected ? ', connected' : ', disconnected'}${age ? ` (${age})` : ''}`);
276
+ if (status.userAgent) {
277
+ lines.push(`Codex: ${status.userAgent}`);
278
+ }
279
+ if (status.codexAppServer) {
280
+ lines.push(`App server: ${status.codexAppServer.running ? 'running' : 'stopped'}${status.codexAppServer.pid ? ` pid=${status.codexAppServer.pid}` : ''}${status.codexAppServer.port ? ` port=${status.codexAppServer.port}` : ''}`);
281
+ }
282
+ lines.push(`Work: active ${status.activeTurns}, queued ${status.queuedTurns}, approvals ${status.pendingApprovals}, questions ${status.pendingUserInputs}`);
283
+ if (status.bots?.length) {
284
+ const activeBots = status.bots
285
+ .filter(bot => bot.activeTurns > 0 || !bot.connected)
286
+ .map(bot => `${bot.username ? `@${bot.username}` : bot.id}:${bot.connected ? `${bot.activeTurns} active` : 'offline'}`);
287
+ lines.push(`Bots: ${status.bots.length}${activeBots.length ? ` (${activeBots.join(', ')})` : ''}`);
288
+ }
289
+ else if (status.botUsername) {
290
+ lines.push(`Bot: @${status.botUsername}`);
291
+ }
292
+ if (status.weixinRuntime) {
293
+ lines.push(`Weixin: ${status.weixinRuntime.connected ? 'connected' : 'disconnected'}, active ${status.weixinRuntime.activeTurns}`);
294
+ }
295
+ if (status.authSync?.enabled) {
296
+ const failures = status.authSync.candidateFailures?.length ?? 0;
297
+ lines.push(`Auth sync: peers ${status.authSync.peers.length}, pending imports ${status.authSync.pendingImports}, failures ${failures}${status.authSync.lastReceivedAt ? `, last received ${formatAge(status.authSync.lastReceivedAt)}` : ''}`);
298
+ if (status.authSync.lastError) {
299
+ lines.push(`Auth sync error: ${status.authSync.lastError}`);
300
+ }
301
+ const latestFailure = status.authSync.candidateFailures?.[0];
302
+ if (latestFailure) {
303
+ lines.push(`Latest auth failure: ${latestFailure.candidateName}: ${truncateStatusLine(latestFailure.reason, 140)}`);
304
+ }
305
+ }
306
+ if (status.authMirror) {
307
+ lines.push(`Auth mirror: ${status.authMirror.candidateName} from ${status.authMirror.sourceLabel} ${formatAge(status.authMirror.syncedAt)}`);
308
+ }
309
+ if (status.authProactiveRefresh) {
310
+ lines.push(`Auth refresh: ${status.authProactiveRefresh.state}${status.authProactiveRefresh.finishedAt ? ` ${formatAge(status.authProactiveRefresh.finishedAt)}` : ''}`);
311
+ }
312
+ if (status.lastUpdate) {
313
+ lines.push(`Last update: ${status.lastUpdate.fromVersion} -> ${status.lastUpdate.toVersion ?? 'unknown'} ${formatAge(status.lastUpdate.updatedAt)}`);
314
+ }
315
+ if (status.lastError) {
316
+ lines.push(`Last error: ${truncateStatusLine(status.lastError, 160)}`);
317
+ }
318
+ lines.push('Use `foxclaw status --json` for full raw status.');
319
+ return lines.join('\n');
320
+ }
321
+ function formatAge(value) {
322
+ if (!value)
323
+ return '';
324
+ const ms = Date.now() - Date.parse(value);
325
+ if (!Number.isFinite(ms))
326
+ return value;
327
+ const seconds = Math.max(0, Math.round(ms / 1000));
328
+ if (seconds < 60)
329
+ return `${seconds}s ago`;
330
+ const minutes = Math.round(seconds / 60);
331
+ if (minutes < 60)
332
+ return `${minutes}m ago`;
333
+ const hours = Math.round(minutes / 60);
334
+ if (hours < 48)
335
+ return `${hours}h ago`;
336
+ const days = Math.round(hours / 24);
337
+ return `${days}d ago`;
338
+ }
339
+ function truncateStatusLine(value, maxLength) {
340
+ const normalized = value.replace(/\s+/g, ' ').trim();
341
+ return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
342
+ }
186
343
  async function runServeCli() {
187
344
  const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync },] = await Promise.all([
188
345
  import('./channels/bridge_messaging_router.js'),
@@ -330,6 +487,13 @@ async function runServeCli() {
330
487
  const authSyncLocalIdle = () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate())
331
488
  && (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
332
489
  && mirror.isIdle();
490
+ const clusterUpdateScheduler = createClusterUpdateScheduler({
491
+ selfUpdater,
492
+ canUpdate: () => authSyncLocalIdle()
493
+ && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
494
+ currentVersion: readPackageVersion,
495
+ logger,
496
+ });
333
497
  const coordinator = {
334
498
  canSelfUpdate: () => authSyncLocalIdle()
335
499
  && (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
@@ -453,6 +617,7 @@ async function runServeCli() {
453
617
  }
454
618
  return { ok: true, deleted: true, reason: source.reason ?? null };
455
619
  },
620
+ scheduleServiceUpdate: (source) => clusterUpdateScheduler.schedule(source),
456
621
  isIdle: authSyncLocalIdle,
457
622
  notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator, {
458
623
  quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
@@ -463,6 +628,7 @@ async function runServeCli() {
463
628
  activeAuthSync = authSync;
464
629
  attachTelegramAuthSync(authSyncTransportBot.bot, authSync, config, logger);
465
630
  authSync.start();
631
+ void publishPendingClusterUpdateBroadcast(authSync, logger);
466
632
  }
467
633
  mirror.start();
468
634
  process.on('unhandledRejection', (error) => {
@@ -522,6 +688,13 @@ async function runServeCli() {
522
688
  const singleLocalAuthRefreshLease = createLocalAuthRefreshLease();
523
689
  const singleAuthSyncLocalIdle = () => Boolean(core?.isIdleForServiceUpdate())
524
690
  && (!singleMirror || singleMirror.isIdle());
691
+ const singleClusterUpdateScheduler = createClusterUpdateScheduler({
692
+ selfUpdater,
693
+ canUpdate: () => singleAuthSyncLocalIdle()
694
+ && (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
695
+ currentVersion: readPackageVersion,
696
+ logger,
697
+ });
525
698
  const singleCoordinator = config.authSyncEnabled ? {
526
699
  canSelfUpdate: () => singleAuthSyncLocalIdle()
527
700
  && (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
@@ -614,6 +787,7 @@ async function runServeCli() {
614
787
  await core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
615
788
  return { ok: true, deleted: true, reason: source.reason ?? null };
616
789
  },
790
+ scheduleServiceUpdate: (source) => singleClusterUpdateScheduler.schedule(source),
617
791
  isIdle: singleAuthSyncLocalIdle,
618
792
  notify: createAuthSyncNotifier(store, bot, authNotificationAggregator, {
619
793
  quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
@@ -624,6 +798,7 @@ async function runServeCli() {
624
798
  activeAuthSync = singleAuthSync;
625
799
  attachTelegramAuthSync(bot, singleAuthSync, config, logger);
626
800
  singleAuthSync.start();
801
+ void publishPendingClusterUpdateBroadcast(singleAuthSync, logger);
627
802
  singleMirror.start();
628
803
  }
629
804
  const telegram = new TelegramChannelAdapter(core);
@@ -791,6 +966,8 @@ function isQuietAuthPoolNotification(event) {
791
966
  case 'recovery_failed':
792
967
  case 'pull_request_received':
793
968
  case 'pull_response_sent':
969
+ case 'service_update_sent':
970
+ case 'service_update_received':
794
971
  return true;
795
972
  default:
796
973
  return false;
@@ -871,6 +1048,15 @@ function formatAuthSyncNotification(locale, event) {
871
1048
  return `收到 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer},请求节点:${event.requesterNodeId}`;
872
1049
  case 'pull_response_sent':
873
1050
  return `已回应 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer}\n结果:${formatPullResponseResult(event.result, event.reason, locale)}`;
1051
+ case 'service_update_sent':
1052
+ return `跨节点升级广播已发出:${event.targetVersion ?? 'latest'}\nRequest:${event.requestId}\nPeer:${peers}`;
1053
+ case 'service_update_received':
1054
+ return [
1055
+ `收到跨节点升级请求:${event.targetVersion ?? 'latest'}`,
1056
+ `来源:${formatSource(event.sourceLabel, event.sourceNodeId)},peer ${event.peer}`,
1057
+ `处理:${event.accepted ? '已安排本机空闲后升级' : '已跳过'}`,
1058
+ event.reason ? `原因:${event.reason}` : null,
1059
+ ].filter(Boolean).join('\n');
874
1060
  case 'sync_error':
875
1061
  return `auth sync 需要注意:\n${event.reason}`;
876
1062
  }
@@ -937,6 +1123,15 @@ function formatAuthSyncNotification(locale, event) {
937
1123
  return `Received peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}, requester node: ${event.requesterNodeId}`;
938
1124
  case 'pull_response_sent':
939
1125
  return `Replied to peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}\nResult: ${formatPullResponseResult(event.result, event.reason, locale)}`;
1126
+ case 'service_update_sent':
1127
+ return `Cross-node update broadcast sent: ${event.targetVersion ?? 'latest'}\nRequest: ${event.requestId}\nPeers: ${peers}`;
1128
+ case 'service_update_received':
1129
+ return [
1130
+ `Received cross-node update request: ${event.targetVersion ?? 'latest'}`,
1131
+ `Source: ${formatSource(event.sourceLabel, event.sourceNodeId)}, peer ${event.peer}`,
1132
+ `Action: ${event.accepted ? 'scheduled local update when idle' : 'skipped'}`,
1133
+ event.reason ? `Reason: ${event.reason}` : null,
1134
+ ].filter(Boolean).join('\n');
940
1135
  case 'sync_error':
941
1136
  return `Auth sync needs attention:\n${event.reason}`;
942
1137
  }
package/dist/update.d.ts CHANGED
@@ -42,6 +42,7 @@ interface PerformSelfUpdateOptions {
42
42
  nodePath: string;
43
43
  version: string;
44
44
  notificationFile?: string;
45
+ clusterBroadcastFile?: string;
45
46
  codexCliBin?: string;
46
47
  env?: NodeJS.ProcessEnv;
47
48
  }
@@ -57,6 +58,11 @@ export interface SelfUpdateOutcome {
57
58
  toVersion: string | null;
58
59
  error: string | null;
59
60
  }
61
+ export interface PendingClusterUpdateBroadcast {
62
+ targetVersion: string | null;
63
+ fromVersion: string;
64
+ updatedAt: string;
65
+ }
60
66
  export declare function selfUpdateStatusPath(statusPath: string): string;
61
67
  export declare function inferPnpmHomeFromEntryPoint(entryPoint: string): string | null;
62
68
  export declare function resolveSelfUpdateInstaller(entryPoint: string, nodePath?: string, exists?: (target: string) => boolean, env?: NodeJS.ProcessEnv): SelfUpdateInstaller;
@@ -76,5 +82,8 @@ export declare function buildSelfUpdateLaunchCommand(options: {
76
82
  unitName?: string;
77
83
  }): SelfUpdateLaunchCommand;
78
84
  export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
85
+ export declare function readPendingClusterUpdateBroadcast(filePath: string): PendingClusterUpdateBroadcast | null;
86
+ export declare function writePendingClusterUpdateBroadcast(filePath: string, value: PendingClusterUpdateBroadcast): void;
87
+ export declare function clearPendingClusterUpdateBroadcast(filePath: string): void;
79
88
  export declare function extractReleaseNotes(changelog: string, version: string, locale: AppLocale): string[] | null;
80
89
  export {};
package/dist/update.js CHANGED
@@ -308,6 +308,13 @@ export function performSelfUpdate(options) {
308
308
  console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
309
309
  runInherited(options.nodePath, [updatedEntryPoint, 'start'], installerEnv);
310
310
  completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null, releaseNotes);
311
+ if (options.clusterBroadcastFile && env.FOXCLAW_SUPPRESS_UPDATE_BROADCAST !== '1') {
312
+ writePendingClusterUpdateBroadcast(options.clusterBroadcastFile, {
313
+ targetVersion: toVersion,
314
+ fromVersion: options.version,
315
+ updatedAt: new Date().toISOString(),
316
+ });
317
+ }
311
318
  console.log(`[OK] FoxClaw updated and restarted: ${options.version} -> ${toVersion}`);
312
319
  return {
313
320
  ok: true,
@@ -328,6 +335,34 @@ export function performSelfUpdate(options) {
328
335
  };
329
336
  }
330
337
  }
338
+ export function readPendingClusterUpdateBroadcast(filePath) {
339
+ try {
340
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
341
+ if ((typeof parsed.targetVersion !== 'string' && parsed.targetVersion !== null)
342
+ || typeof parsed.fromVersion !== 'string'
343
+ || typeof parsed.updatedAt !== 'string'
344
+ || !Number.isFinite(Date.parse(parsed.updatedAt))) {
345
+ return null;
346
+ }
347
+ return {
348
+ targetVersion: parsed.targetVersion ?? null,
349
+ fromVersion: parsed.fromVersion,
350
+ updatedAt: parsed.updatedAt,
351
+ };
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ }
357
+ export function writePendingClusterUpdateBroadcast(filePath, value) {
358
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
359
+ const temporaryFile = `${filePath}.${process.pid}.tmp`;
360
+ fs.writeFileSync(temporaryFile, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
361
+ fs.renameSync(temporaryFile, filePath);
362
+ }
363
+ export function clearPendingClusterUpdateBroadcast(filePath) {
364
+ fs.rmSync(filePath, { force: true });
365
+ }
331
366
  function updateManagedCodexCli(codexCliBin, nodePath, env) {
332
367
  const fromVersion = readCodexCliVersion(codexCliBin, env);
333
368
  if (!codexCliBin) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.40",
3
+ "version": "0.5.42",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",