@foxden-app/foxclaw 0.5.44 → 0.5.46
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.46 - 2026-06-20
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复跨节点 pull 回来的较新 auth 候选在本机 runtime 忙碌时被立即判定失败的问题;现在会先接受并进入远端导入队列,等 runtime 空闲后再验证和导入,避免 RT/WSL 节点已有更新却同步不到本机。
|
|
9
|
+
- 调整主动刷新租约策略:如果 peer 明确拒绝仍会停止,但 peer 慢回包或暂时无响应时不再让刷新一直失败;FoxClaw 会记录 `granted_partial` 事件并优先保持授权可用性和连续性。
|
|
10
|
+
- 增加回归测试覆盖忙碌期间的 pull recovery 排队导入,以及 peer 超时但无明确拒绝时的刷新租约继续执行。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- Fixed cross-node pull recovery dropping newer auth candidates when the local runtime is busy; pulled bundles are now accepted into the remote import queue and validated/imported once the runtime becomes idle, so updates from RT/WSL peers are not lost.
|
|
14
|
+
- Relaxed proactive refresh leases for availability: explicit peer denials still stop the refresh, but slow or temporarily silent peers no longer make the refresh fail indefinitely. FoxClaw records `granted_partial` events and prioritizes auth continuity.
|
|
15
|
+
- Added regression tests for queued pull recovery while busy and refresh lease continuation when peers time out without an explicit denial.
|
|
16
|
+
|
|
17
|
+
## 0.5.45 - 2026-06-18
|
|
18
|
+
|
|
19
|
+
### 中文
|
|
20
|
+
- 将 `/update` 完成通知升级为 RichMessage 表格,分别展示 FoxClaw 版本与重启结果、Codex CLI 版本与升级状态、集群广播目标及真实发送 peer。
|
|
21
|
+
- 发布说明改为默认收起的 details,避免较长更新内容挤占聊天页面;RichMessage 不可用时仍保留清晰的 HTML 降级文本。
|
|
22
|
+
- 升级完成后会短暂等待 auth sync 广播事件,只展示本次升级对应的广播结果;未启用跨节点同步、无 peer 或广播仍在等待时会给出明确状态。
|
|
23
|
+
|
|
24
|
+
### English
|
|
25
|
+
- Upgraded `/update` completion notifications to a RichMessage table with separate FoxClaw version/restart, Codex CLI version/state, and cluster broadcast target/peer results.
|
|
26
|
+
- Release notes now live in a collapsed details block to keep update notifications compact, with a clear HTML fallback when RichMessage is unavailable.
|
|
27
|
+
- The completion report briefly waits for the matching auth sync broadcast event and only shows results from the current update; disabled sync, no peers, and pending broadcasts are reported explicitly.
|
|
28
|
+
|
|
5
29
|
## 0.5.44 - 2026-06-18
|
|
6
30
|
|
|
7
31
|
### 中文
|
|
@@ -317,10 +317,12 @@ export declare class CrossNodeAuthSync {
|
|
|
317
317
|
private handleServiceUpdateRequest;
|
|
318
318
|
private handlePullRequest;
|
|
319
319
|
private handlePullResponse;
|
|
320
|
+
private finishPendingPullAsAccepted;
|
|
320
321
|
private markPullPeerUnavailable;
|
|
321
322
|
private handleDigest;
|
|
322
323
|
private handleLeaseRequest;
|
|
323
324
|
private handleLeaseReply;
|
|
325
|
+
private shouldQueueRemoteImport;
|
|
324
326
|
private enqueueImport;
|
|
325
327
|
private processPendingImports;
|
|
326
328
|
private enqueueDelete;
|
|
@@ -372,7 +372,22 @@ export class CrossNodeAuthSync {
|
|
|
372
372
|
const expiresAt = Date.now() + LEASE_TTL_MS;
|
|
373
373
|
const result = await new Promise((resolve) => {
|
|
374
374
|
const timer = setTimeout(() => {
|
|
375
|
+
const pending = this.pendingLeases.get(leaseId);
|
|
375
376
|
this.pendingLeases.delete(leaseId);
|
|
377
|
+
if (pending && pending.denies.length === 0) {
|
|
378
|
+
const detail = `partial grants=${pending.grants.size}/${this.peers.length}; reason=${reason}`;
|
|
379
|
+
this.recordEvent({
|
|
380
|
+
direction: 'local',
|
|
381
|
+
kind: 'lease.request',
|
|
382
|
+
stage: 'partial_granted',
|
|
383
|
+
peer: null,
|
|
384
|
+
requestId: leaseId,
|
|
385
|
+
candidateName: null,
|
|
386
|
+
detail,
|
|
387
|
+
});
|
|
388
|
+
resolve({ ok: true, leaseId, reason: detail });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
376
391
|
this.recordEvent({
|
|
377
392
|
direction: 'local',
|
|
378
393
|
kind: 'lease.request',
|
|
@@ -405,7 +420,7 @@ export class CrossNodeAuthSync {
|
|
|
405
420
|
});
|
|
406
421
|
if (result.ok) {
|
|
407
422
|
this.activeLocalLease = { leaseId, expiresAt };
|
|
408
|
-
this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'granted', peer: null, requestId: leaseId, candidateName: null, detail: reason });
|
|
423
|
+
this.recordEvent({ direction: 'local', kind: 'lease.request', stage: result.reason?.startsWith('partial grants=') ? 'granted_partial' : 'granted', peer: null, requestId: leaseId, candidateName: null, detail: result.reason ?? reason });
|
|
409
424
|
}
|
|
410
425
|
else {
|
|
411
426
|
this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'denied', peer: null, requestId: leaseId, candidateName: null, detail: result.reason ?? reason });
|
|
@@ -696,6 +711,11 @@ export class CrossNodeAuthSync {
|
|
|
696
711
|
peer,
|
|
697
712
|
sourceNodeId: senderNodeId,
|
|
698
713
|
});
|
|
714
|
+
if (this.shouldQueueRemoteImport()) {
|
|
715
|
+
this.finishPendingPullAsAccepted(pending, peer, 'queued until local runtime is idle');
|
|
716
|
+
this.enqueueImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
699
719
|
const outcome = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
|
|
700
720
|
if (!outcome.imported) {
|
|
701
721
|
this.markPullPeerUnavailable(message.requestId, matchedPeer, outcome.reason ?? 'peer candidate was not imported');
|
|
@@ -708,6 +728,21 @@ export class CrossNodeAuthSync {
|
|
|
708
728
|
pending.resolve(true);
|
|
709
729
|
this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'imported', peer, requestId: message.requestId, candidateName: pending.candidateName, detail: null });
|
|
710
730
|
}
|
|
731
|
+
finishPendingPullAsAccepted(pending, peer, detail) {
|
|
732
|
+
pending.finished = true;
|
|
733
|
+
clearTimeout(pending.timer);
|
|
734
|
+
this.pendingPulls.delete(pending.requestId);
|
|
735
|
+
this.recordEvent({
|
|
736
|
+
direction: 'local',
|
|
737
|
+
kind: 'pull.response',
|
|
738
|
+
stage: 'queued',
|
|
739
|
+
peer,
|
|
740
|
+
requestId: pending.requestId,
|
|
741
|
+
candidateName: pending.candidateName,
|
|
742
|
+
detail,
|
|
743
|
+
});
|
|
744
|
+
pending.resolve(true);
|
|
745
|
+
}
|
|
711
746
|
markPullPeerUnavailable(requestId, peer, reason) {
|
|
712
747
|
const pending = this.pendingPulls.get(requestId);
|
|
713
748
|
if (!pending || pending.finished)
|
|
@@ -798,18 +833,26 @@ export class CrossNodeAuthSync {
|
|
|
798
833
|
pending.resolve({ ok: true, leaseId: message.leaseId });
|
|
799
834
|
}
|
|
800
835
|
}
|
|
801
|
-
|
|
802
|
-
|
|
836
|
+
shouldQueueRemoteImport() {
|
|
837
|
+
return this.importProcessorActive
|
|
838
|
+
|| this.deleteProcessorActive
|
|
839
|
+
|| !this.callbacks.isIdle()
|
|
840
|
+
|| this.pendingImports.length > 0
|
|
841
|
+
|| this.pendingDeletes.length > 0;
|
|
842
|
+
}
|
|
843
|
+
enqueueImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode = 'push') {
|
|
844
|
+
const queued = this.shouldQueueRemoteImport();
|
|
803
845
|
this.pendingImports.push({
|
|
804
846
|
bundle,
|
|
805
847
|
sourceNodeId,
|
|
806
848
|
sourceLabel,
|
|
807
849
|
receivedAt: Date.now(),
|
|
808
850
|
fromPeer,
|
|
851
|
+
mode,
|
|
809
852
|
});
|
|
810
853
|
this.recordEvent({
|
|
811
854
|
direction: 'local',
|
|
812
|
-
kind: 'push.bundle',
|
|
855
|
+
kind: mode === 'pull' ? 'pull.response' : 'push.bundle',
|
|
813
856
|
stage: queued ? 'queued' : 'processing',
|
|
814
857
|
peer: fromPeer,
|
|
815
858
|
requestId: bundle.requestId ?? null,
|
|
@@ -842,7 +885,7 @@ export class CrossNodeAuthSync {
|
|
|
842
885
|
if (!this.callbacks.isIdle())
|
|
843
886
|
return;
|
|
844
887
|
const pending = this.pendingImports.shift();
|
|
845
|
-
await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer,
|
|
888
|
+
await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer, pending.mode);
|
|
846
889
|
}
|
|
847
890
|
}
|
|
848
891
|
finally {
|
|
@@ -296,6 +296,7 @@ export declare class BridgeSessionCore {
|
|
|
296
296
|
private clearSelfUpdateStatusPoll;
|
|
297
297
|
private pollSelfUpdateStatus;
|
|
298
298
|
private formatSelfUpdateResult;
|
|
299
|
+
private resolveSelfUpdateBroadcastSummary;
|
|
299
300
|
private formatSelfUpdateReleaseNotes;
|
|
300
301
|
private formatCodexUpdateResult;
|
|
301
302
|
private scheduleProactiveAuthRefresh;
|
|
@@ -4585,21 +4585,84 @@ export class BridgeSessionCore {
|
|
|
4585
4585
|
return;
|
|
4586
4586
|
}
|
|
4587
4587
|
this.coordinator?.selfUpdateCompleted?.(status);
|
|
4588
|
-
await this.
|
|
4588
|
+
const broadcast = await this.resolveSelfUpdateBroadcastSummary(status);
|
|
4589
|
+
const result = this.formatSelfUpdateResult(status, broadcast);
|
|
4590
|
+
await this.sendRichHtmlMessage(status.scopeId, result.html, result.fallbackHtml);
|
|
4589
4591
|
await this.selfUpdater?.clearStatus();
|
|
4590
4592
|
}
|
|
4591
|
-
formatSelfUpdateResult(status) {
|
|
4593
|
+
formatSelfUpdateResult(status, broadcast) {
|
|
4592
4594
|
const codexUpdateLine = this.formatCodexUpdateResult(status);
|
|
4593
4595
|
const releaseNotes = this.formatSelfUpdateReleaseNotes(status);
|
|
4594
4596
|
if (status.state === 'succeeded') {
|
|
4595
|
-
const
|
|
4597
|
+
const foxclawResult = t(status.locale, 'update_succeeded', {
|
|
4596
4598
|
from: status.fromVersion,
|
|
4597
4599
|
to: status.toVersion ?? t(status.locale, 'unknown'),
|
|
4598
4600
|
});
|
|
4599
|
-
|
|
4601
|
+
const broadcastLine = formatSelfUpdateBroadcastLine(status.locale, status.toVersion, broadcast);
|
|
4602
|
+
const rows = [
|
|
4603
|
+
['FoxClaw', `${status.fromVersion} -> ${status.toVersion ?? t(status.locale, 'unknown')}`, status.locale === 'zh' ? '升级完成,服务已重启' : 'Updated; service restarted'],
|
|
4604
|
+
['Codex CLI', formatSelfUpdateVersionTransition(status.codexFromVersion, status.codexToVersion, status.locale), formatCodexUpdateState(status)],
|
|
4605
|
+
[status.locale === 'zh' ? '集群广播' : 'Cluster broadcast', status.toVersion ?? t(status.locale, 'unknown'), broadcastLine],
|
|
4606
|
+
];
|
|
4607
|
+
const notes = status.releaseNotes?.filter(note => note.trim()) ?? [];
|
|
4608
|
+
const notesHtml = notes.length > 0
|
|
4609
|
+
? telegramDetails(status.locale === 'zh' ? `查看更新内容 · ${notes.length} 项` : `Release notes · ${notes.length} items`, `<ul>${notes.map(note => `<li>${escapeTelegramHtml(note)}</li>`).join('')}</ul>`)
|
|
4610
|
+
: '';
|
|
4611
|
+
const title = status.locale === 'zh' ? 'FoxClaw 升级完成' : 'FoxClaw update completed';
|
|
4612
|
+
const html = [
|
|
4613
|
+
`<h3>${escapeTelegramHtml(title)}</h3>`,
|
|
4614
|
+
renderTelegramTable(status.locale === 'zh' ? ['组件', '版本', '结果'] : ['Component', 'Version', 'Result'], rows),
|
|
4615
|
+
notesHtml,
|
|
4616
|
+
'<footer>FoxClaw · update</footer>',
|
|
4617
|
+
].filter(Boolean).join('\n');
|
|
4618
|
+
const fallbackHtml = [
|
|
4619
|
+
telegramBold(title),
|
|
4620
|
+
escapeTelegramHtml(foxclawResult),
|
|
4621
|
+
escapeTelegramHtml(codexUpdateLine ?? (status.locale === 'zh' ? 'Codex CLI:未执行升级。' : 'Codex CLI: not updated.')),
|
|
4622
|
+
escapeTelegramHtml(broadcastLine),
|
|
4623
|
+
releaseNotes ? escapeTelegramHtml(releaseNotes) : '',
|
|
4624
|
+
].filter(Boolean).join('\n');
|
|
4625
|
+
return { html, fallbackHtml };
|
|
4600
4626
|
}
|
|
4601
4627
|
const result = t(status.locale, 'update_failed', { error: status.error ?? t(status.locale, 'unknown') });
|
|
4602
|
-
|
|
4628
|
+
const fallback = codexUpdateLine ? `${result}\n${codexUpdateLine}` : result;
|
|
4629
|
+
return {
|
|
4630
|
+
html: `<h3>${escapeTelegramHtml(status.locale === 'zh' ? 'FoxClaw 升级失败' : 'FoxClaw update failed')}</h3><p>${escapeTelegramHtml(fallback)}</p>`,
|
|
4631
|
+
fallbackHtml: escapeTelegramHtml(fallback),
|
|
4632
|
+
};
|
|
4633
|
+
}
|
|
4634
|
+
async resolveSelfUpdateBroadcastSummary(status) {
|
|
4635
|
+
if (status.state !== 'succeeded' || !status.toVersion || !this.coordinator?.getServiceStatus) {
|
|
4636
|
+
return { state: 'pending', sent: 0, peers: [] };
|
|
4637
|
+
}
|
|
4638
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
4639
|
+
const serviceStatus = await this.coordinator.getServiceStatus().catch(() => null);
|
|
4640
|
+
const authSync = serviceStatus?.authSync;
|
|
4641
|
+
if (!authSync?.enabled) {
|
|
4642
|
+
return { state: 'disabled', sent: 0, peers: [] };
|
|
4643
|
+
}
|
|
4644
|
+
const recentEvents = authSync.recentEvents ?? [];
|
|
4645
|
+
const updateCompletedAt = Date.parse(status.updatedAt);
|
|
4646
|
+
const broadcast = [...recentEvents].reverse().find(event => (event.kind === 'service.update.request'
|
|
4647
|
+
&& event.stage === 'broadcast'
|
|
4648
|
+
&& event.detail === `target=${status.toVersion}`
|
|
4649
|
+
&& (!Number.isFinite(updateCompletedAt) || Date.parse(event.createdAt) >= updateCompletedAt)));
|
|
4650
|
+
if (broadcast) {
|
|
4651
|
+
const peers = recentEvents
|
|
4652
|
+
.filter(event => (event.kind === 'service.update.request'
|
|
4653
|
+
&& event.stage === 'sent'
|
|
4654
|
+
&& event.direction === 'out'
|
|
4655
|
+
&& event.requestId === broadcast.requestId
|
|
4656
|
+
&& Boolean(event.peer)))
|
|
4657
|
+
.map(event => event.peer)
|
|
4658
|
+
.filter((peer, index, all) => all.indexOf(peer) === index);
|
|
4659
|
+
return { state: 'sent', sent: peers.length, peers };
|
|
4660
|
+
}
|
|
4661
|
+
if (attempt < 4) {
|
|
4662
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
return { state: 'pending', sent: 0, peers: [] };
|
|
4603
4666
|
}
|
|
4604
4667
|
formatSelfUpdateReleaseNotes(status) {
|
|
4605
4668
|
const notes = status.releaseNotes?.filter(note => note.trim()) ?? [];
|
|
@@ -8896,6 +8959,45 @@ function formatRichInternalValue(value) {
|
|
|
8896
8959
|
const escaped = escapeTelegramHtml(value);
|
|
8897
8960
|
return escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
8898
8961
|
}
|
|
8962
|
+
function renderTelegramTable(headers, rows) {
|
|
8963
|
+
return [
|
|
8964
|
+
'<table bordered striped>',
|
|
8965
|
+
`<thead><tr>${headers.map(header => `<th>${escapeTelegramHtml(header)}</th>`).join('')}</tr></thead>`,
|
|
8966
|
+
`<tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${escapeTelegramHtml(cell)}</td>`).join('')}</tr>`).join('')}</tbody>`,
|
|
8967
|
+
'</table>',
|
|
8968
|
+
].join('');
|
|
8969
|
+
}
|
|
8970
|
+
function formatSelfUpdateVersionTransition(fromVersion, toVersion, locale) {
|
|
8971
|
+
if (!fromVersion && !toVersion) {
|
|
8972
|
+
return locale === 'zh' ? '未检测' : 'Not detected';
|
|
8973
|
+
}
|
|
8974
|
+
return `${fromVersion ?? '?'} -> ${toVersion ?? '?'}`;
|
|
8975
|
+
}
|
|
8976
|
+
function formatCodexUpdateState(status) {
|
|
8977
|
+
if (status.codexFromVersion && status.codexToVersion) {
|
|
8978
|
+
if (status.codexFromVersion === status.codexToVersion) {
|
|
8979
|
+
return status.locale === 'zh' ? '已是最新版本' : 'Already current';
|
|
8980
|
+
}
|
|
8981
|
+
return status.locale === 'zh' ? '升级完成' : 'Updated';
|
|
8982
|
+
}
|
|
8983
|
+
return status.codexUpdate ?? (status.locale === 'zh' ? '未执行升级' : 'Not updated');
|
|
8984
|
+
}
|
|
8985
|
+
function formatSelfUpdateBroadcastLine(locale, targetVersion, broadcast) {
|
|
8986
|
+
if (broadcast.state === 'disabled') {
|
|
8987
|
+
return locale === 'zh' ? '未启用跨节点同步' : 'Cross-node sync is disabled';
|
|
8988
|
+
}
|
|
8989
|
+
if (broadcast.state === 'pending') {
|
|
8990
|
+
return locale === 'zh'
|
|
8991
|
+
? `目标 ${targetVersion ?? '?'},等待广播结果`
|
|
8992
|
+
: `Target ${targetVersion ?? '?'}; waiting for broadcast result`;
|
|
8993
|
+
}
|
|
8994
|
+
if (broadcast.peers.length === 0) {
|
|
8995
|
+
return locale === 'zh' ? '广播完成,无已配置 peer' : 'Broadcast completed; no configured peers';
|
|
8996
|
+
}
|
|
8997
|
+
return locale === 'zh'
|
|
8998
|
+
? `已发送 ${broadcast.sent} 个 peer:${broadcast.peers.join('、')}`
|
|
8999
|
+
: `Sent to ${broadcast.sent} peers: ${broadcast.peers.join(', ')}`;
|
|
9000
|
+
}
|
|
8899
9001
|
function clipRichMessageText(value, limit) {
|
|
8900
9002
|
if (value.length <= limit) {
|
|
8901
9003
|
return value;
|