@foxden-app/foxclaw 0.5.41 → 0.5.43

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,30 @@
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.43 - 2026-06-18
6
+
7
+ ### 中文
8
+ - Codex 最终答复出现后,FoxClaw 会把本轮所有过程汇报合并到第一条过程消息,并编辑为默认收起的 Telegram RichMessage `details`;最终答复继续独立展示,使历史记录保持清晰的一问一答结构。
9
+ - 折叠摘要显示过程条数和整体起止时间,展开后每段过程汇报保留精确到秒的起止时间及原有 Markdown 富文本效果;普通 Telegram 回合和 CLI 观察回合统一生效。
10
+ - 如果 RichMessage 编辑失败,原过程消息全部保留且不执行删除;超出 Telegram 单条富消息上限时会明确标注未收入归档的条数。
11
+
12
+ ### English
13
+ - After a Codex final answer arrives, FoxClaw consolidates all progress updates from the turn into the first progress message and edits it into a collapsed Telegram RichMessage `details` block; the final answer remains separate for a clean question-and-answer history.
14
+ - The collapsed summary shows the update count and overall time range, while each expanded update preserves second-level start/end timestamps and Markdown-rich rendering; this applies to both normal Telegram turns and CLI-observed turns.
15
+ - If RichMessage editing fails, every original progress message is preserved and no deletion is attempted; updates exceeding Telegram's single-rich-message limit are reported explicitly.
16
+
17
+ ## 0.5.42 - 2026-06-18
18
+
19
+ ### 中文
20
+ - 新增跨节点升级广播:一个节点升级成功后会通过现有 auth sync 加密通道广播目标版本,其他节点收到后会在 runtime/auth sync 空闲时自动执行本机 self-update;终端 `foxclaw update` 成功后也会在服务重启时触发广播。
21
+ - 为避免升级风暴,收到广播的节点如果已经是目标版本会跳过;远端请求会记录到 auth sync recent events,并可在 `/auth sync status/events` 中排查。
22
+ - 精简终端 `foxclaw status` 默认输出,集中展示运行状态、Codex/app-server、工作队列、bot 概况、auth sync backlog、最近升级和错误;完整原始 JSON 可通过 `foxclaw status --json` 查看。
23
+
24
+ ### English
25
+ - 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.
26
+ - 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.
27
+ - 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`.
28
+
5
29
  ## 0.5.41 - 2026-06-18
6
30
 
7
31
  ### 中文
@@ -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;
@@ -236,6 +236,7 @@ export declare class BridgeSessionCore {
236
236
  private deleteMessage;
237
237
  private sendTyping;
238
238
  private sendObservedCliUserMessage;
239
+ private collapseTurnCommentary;
239
240
  private cleanupObservedTransientMessages;
240
241
  private hasObservedPersistentReply;
241
242
  private ensureThreadReady;
@@ -1855,6 +1855,7 @@ export class BridgeSessionCore {
1855
1855
  const segment = ensureTurnSegment(active, `${active.turnId}:codex-error`, 'final_answer', 'final_answer', false);
1856
1856
  segment.text = text;
1857
1857
  segment.completed = true;
1858
+ segment.completedAtMs = Date.now();
1858
1859
  await this.queueTurnRender(active, { forceStatus: true, forceStream: true });
1859
1860
  }
1860
1861
  async finishTerminalErroredActiveTurn(active) {
@@ -3131,6 +3132,7 @@ export class BridgeSessionCore {
3131
3132
  let shouldMarkPartialOutput = false;
3132
3133
  try {
3133
3134
  await this.queueTurnRender(active, { forceStatus: true, forceStream: true });
3135
+ await this.collapseTurnCommentary(active);
3134
3136
  const renderedMessages = active.segments.reduce((count, segment) => count + segment.messages.length, 0);
3135
3137
  if (renderedMessages === 0) {
3136
3138
  const fallbackKey = active.interruptRequested ? 'interrupted' : 'completed';
@@ -3186,6 +3188,7 @@ export class BridgeSessionCore {
3186
3188
  }
3187
3189
  }
3188
3190
  segment.completed = true;
3191
+ segment.completedAtMs = Date.now();
3189
3192
  await this.queueTurnRender(active, { forceStream: true, forceStatus: true });
3190
3193
  return;
3191
3194
  }
@@ -3421,6 +3424,50 @@ export class BridgeSessionCore {
3421
3424
  await this.sendHtmlMessage(scopeId, body);
3422
3425
  }
3423
3426
  }
3427
+ async collapseTurnCommentary(active) {
3428
+ if (active.scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX) || !this.hasObservedPersistentReply(active)) {
3429
+ return;
3430
+ }
3431
+ const segments = active.segments.filter(segment => (segment.outputKind === 'commentary' && Boolean(segment.text.trim()) && segment.messages.length > 0));
3432
+ const messages = segments.flatMap(segment => segment.messages);
3433
+ const firstMessage = messages[0];
3434
+ if (!firstMessage) {
3435
+ return;
3436
+ }
3437
+ const locale = this.localeForChat(active.scopeId);
3438
+ const html = renderCollapsedCommentary(locale, segments);
3439
+ const fallback = locale === 'zh'
3440
+ ? `过程汇报(${segments.length} 条,已折叠)`
3441
+ : `Progress updates (${segments.length}, collapsed)`;
3442
+ try {
3443
+ await this.messaging.editRichHtml(active.scopeId, firstMessage.messageId, html, fallback, []);
3444
+ }
3445
+ catch (error) {
3446
+ this.logger.warn('telegram.commentary_collapse_failed', {
3447
+ error: String(error),
3448
+ turnId: active.turnId,
3449
+ messageId: firstMessage.messageId,
3450
+ });
3451
+ return;
3452
+ }
3453
+ for (const message of messages.slice(1)) {
3454
+ try {
3455
+ await this.deleteMessage(active.scopeId, message.messageId);
3456
+ }
3457
+ catch (error) {
3458
+ if (!isTelegramMessageGone(error)) {
3459
+ this.logger.warn('telegram.commentary_collapse_delete_failed', {
3460
+ error: String(error),
3461
+ turnId: active.turnId,
3462
+ messageId: message.messageId,
3463
+ });
3464
+ }
3465
+ }
3466
+ }
3467
+ for (const segment of segments) {
3468
+ segment.messages = [];
3469
+ }
3470
+ }
3424
3471
  async cleanupObservedTransientMessages(active) {
3425
3472
  if (!active.isObserved || !this.hasObservedPersistentReply(active)) {
3426
3473
  return;
@@ -8235,11 +8282,50 @@ function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
8235
8282
  isPlan: Boolean(isPlan),
8236
8283
  text: '',
8237
8284
  completed: false,
8285
+ startedAtMs: Date.now(),
8286
+ completedAtMs: null,
8238
8287
  messages: [],
8239
8288
  };
8240
8289
  active.segments.push(segment);
8241
8290
  return segment;
8242
8291
  }
8292
+ function renderCollapsedCommentary(locale, segments) {
8293
+ const firstAt = segments[0]?.startedAtMs ?? Date.now();
8294
+ const lastSegment = segments[segments.length - 1];
8295
+ const lastAt = lastSegment?.completedAtMs ?? lastSegment?.startedAtMs ?? firstAt;
8296
+ const range = firstAt === lastAt
8297
+ ? formatClockTimestamp(firstAt)
8298
+ : `${formatClockTimestamp(firstAt)} - ${formatClockTimestamp(lastAt)}`;
8299
+ const summary = locale === 'zh'
8300
+ ? `过程汇报 · ${segments.length} 条 · ${range}`
8301
+ : `Progress · ${segments.length} updates · ${range}`;
8302
+ const maxBodyLength = TELEGRAM_RICH_MESSAGE_TEXT_LIMIT - summary.length - 1024;
8303
+ const blocks = [];
8304
+ let bodyLength = 0;
8305
+ let omitted = 0;
8306
+ for (const segment of segments) {
8307
+ const endAt = segment.completedAtMs ?? segment.startedAtMs;
8308
+ const timestamp = endAt === segment.startedAtMs
8309
+ ? formatClockTimestamp(segment.startedAtMs)
8310
+ : `${formatClockTimestamp(segment.startedAtMs)} - ${formatClockTimestamp(endAt)}`;
8311
+ const block = `<h4>${escapeTelegramHtml(timestamp)}</h4>\n${renderTelegramMarkdownRichHtml(segment.text)}`;
8312
+ if (bodyLength + block.length > maxBodyLength) {
8313
+ omitted += 1;
8314
+ continue;
8315
+ }
8316
+ blocks.push(block);
8317
+ bodyLength += block.length;
8318
+ }
8319
+ if (omitted > 0) {
8320
+ blocks.push(`<p>${escapeTelegramHtml(locale === 'zh' ? `另有 ${omitted} 条过长内容未收入归档` : `${omitted} oversized updates omitted`)}</p>`);
8321
+ }
8322
+ return telegramDetails(summary, blocks.join('\n'));
8323
+ }
8324
+ function formatClockTimestamp(timestampMs) {
8325
+ const date = new Date(timestampMs);
8326
+ const pad = (value) => String(value).padStart(2, '0');
8327
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
8328
+ }
8243
8329
  function createToolBatchState() {
8244
8330
  return {
8245
8331
  openCallIds: new Set(),
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.41",
3
+ "version": "0.5.43",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",