@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 +24 -0
- package/dist/auth/cross_node_sync.d.ts +29 -0
- package/dist/auth/cross_node_sync.js +67 -0
- package/dist/controller/controller.d.ts +1 -0
- package/dist/controller/controller.js +86 -0
- package/dist/main.js +197 -2
- package/dist/update.d.ts +9 -0
- package/dist/update.js +35 -0
- package/package.json +1 -1
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
|
-
|
|
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) {
|