@foxden-app/foxclaw 0.5.18 → 0.5.19

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,16 @@
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.19 - 2026-06-08
6
+
7
+ ### 中文
8
+ - 主动后台 auth 刷新现在只保留一条私聊状态消息:开始时发送,完成或拿不到刷新锁时编辑为最终结果,减少开始/完成两条消息的打扰。
9
+ - 本机 auth 镜像和跨节点 auth 同步的刷新 burst 现在会短窗口汇总,把候选镜像、跨节点发送、收到远端包、导入/跳过/失败合成摘要;恢复失败和人工介入提示仍会明确发出。
10
+
11
+ ### English
12
+ - Background proactive auth refresh now keeps one private status message: it sends the starting state and edits that message to the final result or lease failure, reducing separate start/done notifications.
13
+ - Same-node auth mirroring and cross-node auth sync now group refresh bursts into short summaries covering mirror writes, peer sends, received remote bundles, import/skip/failure results, while recovery failures and manual-intervention notices remain explicit.
14
+
5
15
  ## 0.5.18 - 2026-06-08
6
16
 
7
17
  ### 中文
package/README.md CHANGED
@@ -216,7 +216,7 @@ TG_BOT_TOKEN=123456:token_a
216
216
 
217
217
  FoxClaw 仍然只运行一个系统服务。默认情况下,它会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新在线验证后镜像同步;切换或重载前还会从其他 Codex home 恢复同账号较新凭据。各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、runtime 类型、当前 auth 和活动 turn 摘要。
218
218
 
219
- 多台机器共享同一合法账号池时,可以启用可选跨节点 auth 同步:`AUTH_SYNC_ENABLED=true`、`AUTH_SYNC_KEY` 和 `AUTH_SYNC_PEERS=@peer_contact_bot`。推荐每台机器只选一个联系人 bot;同一节点内的其他 bot 继续走本机 auth 镜像。多 bot 模式下,默认用 `TG_BOT_TOKENS` 的第一个 token 作为联系人 bot。FoxClaw 会通过 Telegram Bot-to-Bot 私聊传输加密 auth 包;本机验证刷新后主动 push,发现本机候选失效时主动 pull peer 已持有的有效副本,并在联系人 bot 私聊里报告发送、接收、排队、导入、失败和人工介入提示。`/auth sync events [过滤]` 和 `/auth sync trace <requestId>` 可查看最近通讯流水。跨节点恢复不会自动刷新 token,`/auth refresh all confirm` 会先申请跨节点刷新锁。完整配置、`@BotFather` 操作和验证步骤见 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
219
+ 多台机器共享同一合法账号池时,可以启用可选跨节点 auth 同步:`AUTH_SYNC_ENABLED=true`、`AUTH_SYNC_KEY` 和 `AUTH_SYNC_PEERS=@peer_contact_bot`。推荐每台机器只选一个联系人 bot;同一节点内的其他 bot 继续走本机 auth 镜像。多 bot 模式下,默认用 `TG_BOT_TOKENS` 的第一个 token 作为联系人 bot。FoxClaw 会通过 Telegram Bot-to-Bot 私聊传输加密 auth 包;本机验证刷新后主动 push,发现本机候选失效时主动 pull peer 已持有的有效副本,并在联系人 bot 私聊里汇总报告发送、接收、排队、导入、失败和人工介入提示。`/auth sync events [过滤]` 和 `/auth sync trace <requestId>` 可查看最近通讯流水。跨节点恢复不会自动刷新 token,`/auth refresh all confirm` 会先申请跨节点刷新锁。完整配置、`@BotFather` 操作和验证步骤见 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
220
220
 
221
221
  如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
222
222
 
package/README_EN.md CHANGED
@@ -216,7 +216,7 @@ TG_BOT_TOKEN=123456:token_a
216
216
 
217
217
  FoxClaw remains one system service. By default, it starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after online-validated login or refresh; before switching or reloading, FoxClaw also restores a newer same-account credential from another Codex home when available. Current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, runtime type, selected auth, and active turns.
218
218
 
219
- When multiple machines share the same legally owned account pool, optional cross-node auth sync can be enabled with `AUTH_SYNC_ENABLED=true`, `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS=@peer_contact_bot`. The recommended topology is one contact bot per node; other bots on the same node continue to use local auth mirroring. In multi-bot mode, the first token in `TG_BOT_TOKENS` is the default contact bot. FoxClaw sends encrypted auth bundles through Telegram Bot-to-Bot private messages. A locally verified refresh is pushed to peers, and a node with a bad local candidate can pull an already-held valid peer copy. The contact bot private chat reports send, receive, queue, import, failure, and manual-intervention states. `/auth sync events [filter]` and `/auth sync trace <requestId>` inspect recent traffic. Cross-node recovery never auto-refreshes tokens; `/auth refresh all confirm` first requests a cross-node refresh lease. For full config, `@BotFather` operations, and verification steps, read the [Cross-Node Auth Sync Setup Guide](./docs/cross-node-auth-sync.md).
219
+ When multiple machines share the same legally owned account pool, optional cross-node auth sync can be enabled with `AUTH_SYNC_ENABLED=true`, `AUTH_SYNC_KEY`, and `AUTH_SYNC_PEERS=@peer_contact_bot`. The recommended topology is one contact bot per node; other bots on the same node continue to use local auth mirroring. In multi-bot mode, the first token in `TG_BOT_TOKENS` is the default contact bot. FoxClaw sends encrypted auth bundles through Telegram Bot-to-Bot private messages. A locally verified refresh is pushed to peers, and a node with a bad local candidate can pull an already-held valid peer copy. The contact bot private chat reports grouped send, receive, queue, import, failure, and manual-intervention states. `/auth sync events [filter]` and `/auth sync trace <requestId>` inspect recent traffic. Cross-node recovery never auto-refreshes tokens; `/auth refresh all confirm` first requests a cross-node refresh lease. For full config, `@BotFather` operations, and verification steps, read the [Cross-Node Auth Sync Setup Guide](./docs/cross-node-auth-sync.md).
220
220
 
221
221
  To keep one Telegram bot interoperable with terminal Codex sessions, put the same token in both `TG_BOT_TOKENS` and `TG_BOT_TOKEN`. That bot uses the default `CODEX_HOME` (usually `~/.codex` when unset) and default auth, so it can see local terminal sessions. It no longer has the isolated runtime guarantee: `/auth` switches also affect the terminal and other default runtimes.
222
222
 
@@ -3,7 +3,7 @@ export interface AuthMirrorRuntime {
3
3
  id: string;
4
4
  label?: string;
5
5
  authDir: string;
6
- notify?: (message: string) => Promise<void>;
6
+ notify?: (event: AuthMirrorNotification) => Promise<void>;
7
7
  validate?: (context: AuthMirrorValidationContext) => Promise<AuthMirrorValidationResult | boolean>;
8
8
  }
9
9
  export interface AuthMirrorStatus {
@@ -62,6 +62,17 @@ export interface AuthMirrorSyncAllResult {
62
62
  export interface AuthMirrorHooks {
63
63
  onSynced?: (event: AuthMirrorSyncedEvent) => Promise<void> | void;
64
64
  }
65
+ export type AuthMirrorNotification = {
66
+ kind: 'local_synced';
67
+ candidateName: string;
68
+ sourceRuntimeId: string;
69
+ sourceLabel: string;
70
+ } | {
71
+ kind: 'remote_imported';
72
+ candidateName: string;
73
+ sourceNodeId: string;
74
+ sourceLabel: string;
75
+ };
65
76
  export declare class AuthCandidateMirror {
66
77
  private readonly canonicalDir;
67
78
  private readonly runtimes;
@@ -297,8 +297,13 @@ export class AuthCandidateMirror {
297
297
  };
298
298
  await writeMirrorStatus(this.statusPath, this.lastStatus);
299
299
  this.logger.info('auth.mirror.remote_imported', { candidateName, sourceNodeId: source.nodeId });
300
- const message = `${candidateName} has been synchronized from remote node ${sourceLabel}.`;
301
- await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
300
+ const notification = {
301
+ kind: 'remote_imported',
302
+ candidateName,
303
+ sourceNodeId: source.nodeId,
304
+ sourceLabel,
305
+ };
306
+ await Promise.allSettled(this.runtimes.map((target) => target.notify?.(notification)));
302
307
  return {
303
308
  ok: true,
304
309
  imported: true,
@@ -382,8 +387,13 @@ export class AuthCandidateMirror {
382
387
  };
383
388
  await writeMirrorStatus(this.statusPath, this.lastStatus);
384
389
  this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
385
- const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
386
- await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
390
+ const notification = {
391
+ kind: 'local_synced',
392
+ candidateName: name,
393
+ sourceRuntimeId: runtime.id,
394
+ sourceLabel,
395
+ };
396
+ await Promise.allSettled(this.runtimes.map((target) => target.notify?.(notification)));
387
397
  await this.hooks.onSynced?.({
388
398
  status: this.lastStatus,
389
399
  record: {
@@ -0,0 +1,27 @@
1
+ import type { AppLocale } from '../types.js';
2
+ import type { AuthSyncNotification } from './cross_node_sync.js';
3
+ import type { AuthMirrorNotification } from './mirror.js';
4
+ export type AuthRefreshNotificationItem = {
5
+ source: 'mirror';
6
+ event: AuthMirrorNotification;
7
+ } | {
8
+ source: 'auth_sync';
9
+ event: AuthSyncNotification;
10
+ };
11
+ export interface AuthRefreshNotificationDestination {
12
+ key: string;
13
+ locale: AppLocale;
14
+ sendMessage: (text: string) => Promise<unknown>;
15
+ }
16
+ export interface AuthRefreshNotificationAggregator {
17
+ enqueueMirror(destination: AuthRefreshNotificationDestination, event: AuthMirrorNotification): void;
18
+ enqueueAuthSync(destination: AuthRefreshNotificationDestination, event: AuthSyncNotification): boolean;
19
+ flushAll(): Promise<void>;
20
+ }
21
+ interface AuthNotificationLogger {
22
+ warn(message: string, meta?: unknown): void;
23
+ }
24
+ export declare function createAuthRefreshNotificationAggregator(logger: AuthNotificationLogger, delayMs?: number): AuthRefreshNotificationAggregator;
25
+ export declare function isAggregateableAuthSyncNotification(event: AuthSyncNotification): boolean;
26
+ export declare function formatAggregatedAuthRefreshNotifications(locale: AppLocale, items: AuthRefreshNotificationItem[]): string | null;
27
+ export {};
@@ -0,0 +1,235 @@
1
+ const AUTH_REFRESH_NOTIFICATION_AGGREGATE_DELAY_MS = 8_000;
2
+ const AUTH_REFRESH_NOTIFICATION_LIST_LIMIT = 8;
3
+ export function createAuthRefreshNotificationAggregator(logger, delayMs = AUTH_REFRESH_NOTIFICATION_AGGREGATE_DELAY_MS) {
4
+ const queues = new Map();
5
+ const flush = async (key) => {
6
+ const queue = queues.get(key);
7
+ if (!queue)
8
+ return;
9
+ if (queue.timer) {
10
+ clearTimeout(queue.timer);
11
+ queue.timer = null;
12
+ }
13
+ queues.delete(key);
14
+ const message = formatAggregatedAuthRefreshNotifications(queue.locale, queue.items);
15
+ if (!message)
16
+ return;
17
+ try {
18
+ await queue.sendMessage(message);
19
+ }
20
+ catch (error) {
21
+ logger.warn('auth.notification_aggregate_send_failed', { error: formatError(error) });
22
+ }
23
+ };
24
+ const enqueue = (destination, item) => {
25
+ const existing = queues.get(destination.key);
26
+ const queue = existing ?? {
27
+ locale: destination.locale,
28
+ sendMessage: destination.sendMessage,
29
+ items: [],
30
+ timer: null,
31
+ };
32
+ queue.locale = destination.locale;
33
+ queue.sendMessage = destination.sendMessage;
34
+ queue.items.push(item);
35
+ if (queue.timer) {
36
+ clearTimeout(queue.timer);
37
+ }
38
+ queue.timer = setTimeout(() => {
39
+ void flush(destination.key);
40
+ }, delayMs);
41
+ queue.timer.unref();
42
+ queues.set(destination.key, queue);
43
+ };
44
+ return {
45
+ enqueueMirror(destination, event) {
46
+ enqueue(destination, { source: 'mirror', event });
47
+ },
48
+ enqueueAuthSync(destination, event) {
49
+ if (!isAggregateableAuthSyncNotification(event)) {
50
+ return false;
51
+ }
52
+ enqueue(destination, { source: 'auth_sync', event });
53
+ return true;
54
+ },
55
+ async flushAll() {
56
+ await Promise.all([...queues.keys()].map((key) => flush(key)));
57
+ },
58
+ };
59
+ }
60
+ export function isAggregateableAuthSyncNotification(event) {
61
+ switch (event.kind) {
62
+ case 'candidate_publish_started':
63
+ case 'candidate_publish_completed':
64
+ case 'candidate_publish_failed':
65
+ case 'remote_bundle_received':
66
+ case 'remote_import_imported':
67
+ case 'remote_import_skipped':
68
+ case 'remote_import_failed':
69
+ return true;
70
+ default:
71
+ return false;
72
+ }
73
+ }
74
+ export function formatAggregatedAuthRefreshNotifications(locale, items) {
75
+ const localSynced = [];
76
+ const mirrorRemoteImports = [];
77
+ const publishes = new Map();
78
+ const remoteBundles = new Map();
79
+ const remoteImports = new Map();
80
+ for (const item of items) {
81
+ if (item.source === 'mirror') {
82
+ if (item.event.kind === 'local_synced') {
83
+ localSynced.push({
84
+ candidateName: item.event.candidateName,
85
+ sourceLabel: item.event.sourceLabel,
86
+ });
87
+ }
88
+ else {
89
+ mirrorRemoteImports.push({
90
+ candidateName: item.event.candidateName,
91
+ sourceNodeId: item.event.sourceNodeId,
92
+ sourceLabel: item.event.sourceLabel,
93
+ });
94
+ }
95
+ continue;
96
+ }
97
+ const event = item.event;
98
+ switch (event.kind) {
99
+ case 'candidate_publish_started':
100
+ publishes.set(event.candidateName, {
101
+ candidateName: event.candidateName,
102
+ peers: event.peers,
103
+ state: 'sending',
104
+ reason: null,
105
+ });
106
+ break;
107
+ case 'candidate_publish_completed':
108
+ publishes.set(event.candidateName, {
109
+ candidateName: event.candidateName,
110
+ peers: event.peers,
111
+ state: 'sent',
112
+ reason: null,
113
+ });
114
+ break;
115
+ case 'candidate_publish_failed':
116
+ publishes.set(event.candidateName, {
117
+ candidateName: event.candidateName,
118
+ peers: event.peers,
119
+ state: 'failed',
120
+ reason: event.reason,
121
+ });
122
+ break;
123
+ case 'remote_bundle_received':
124
+ remoteBundles.set(remoteKey(event), {
125
+ candidateName: event.candidateName,
126
+ sourceNodeId: event.sourceNodeId,
127
+ sourceLabel: event.sourceLabel,
128
+ peer: event.peer,
129
+ queued: event.queued,
130
+ });
131
+ break;
132
+ case 'remote_import_imported':
133
+ case 'remote_import_skipped':
134
+ case 'remote_import_failed':
135
+ remoteImports.set(remoteKey(event), {
136
+ candidateName: event.candidateName,
137
+ sourceNodeId: event.sourceNodeId,
138
+ sourceLabel: event.sourceLabel,
139
+ peer: event.peer,
140
+ state: event.kind === 'remote_import_imported'
141
+ ? 'imported'
142
+ : event.kind === 'remote_import_skipped'
143
+ ? 'skipped'
144
+ : 'failed',
145
+ reason: 'reason' in event ? event.reason : null,
146
+ });
147
+ break;
148
+ default:
149
+ break;
150
+ }
151
+ }
152
+ const lines = [locale === 'zh' ? 'auth 刷新/同步汇总' : 'Auth refresh/sync summary'];
153
+ const failedPublishes = [...publishes.values()].filter((entry) => entry.state === 'failed');
154
+ const failedImports = [...remoteImports.values()].filter((entry) => entry.state === 'failed');
155
+ const sentPublishes = [...publishes.values()].filter((entry) => entry.state === 'sent');
156
+ const sendingPublishes = [...publishes.values()].filter((entry) => entry.state === 'sending');
157
+ const imported = [...remoteImports.values()].filter((entry) => entry.state === 'imported');
158
+ const skipped = [...remoteImports.values()].filter((entry) => entry.state === 'skipped');
159
+ const pendingRemote = [...remoteBundles.entries()]
160
+ .filter(([key]) => !remoteImports.has(key))
161
+ .map(([, entry]) => entry);
162
+ const coveredRemoteImportKeys = new Set([...remoteImports.values()]
163
+ .map((entry) => `${entry.candidateName}\0${entry.sourceNodeId}`));
164
+ const mirrorRemoteOnly = mirrorRemoteImports
165
+ .filter((entry) => !coveredRemoteImportKeys.has(`${entry.candidateName}\0${entry.sourceNodeId}`));
166
+ if (failedPublishes.length > 0) {
167
+ lines.push(formatLine(locale, 'Cross-node send failed', '跨节点发送失败', failedPublishes.map((entry) => `${entry.candidateName} -> ${formatPeerList(entry.peers, locale)} (${clipInline(entry.reason ?? '')})`)));
168
+ }
169
+ if (failedImports.length > 0) {
170
+ lines.push(formatLine(locale, 'Cross-node import failed', '跨节点导入失败', failedImports.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)} (${clipInline(entry.reason ?? '')})`)));
171
+ }
172
+ if (localSynced.length > 0) {
173
+ lines.push(formatLine(locale, 'Same-node mirror', '同节点镜像', dedupe(localSynced.map((entry) => `${entry.candidateName} <- ${entry.sourceLabel}`))));
174
+ }
175
+ if (sentPublishes.length > 0) {
176
+ lines.push(formatLine(locale, 'Cross-node sent', '跨节点已发送', sentPublishes.map((entry) => `${entry.candidateName} -> ${formatPeerList(entry.peers, locale)}`)));
177
+ }
178
+ if (sendingPublishes.length > 0) {
179
+ lines.push(formatLine(locale, 'Cross-node sending', '跨节点发送中', sendingPublishes.map((entry) => `${entry.candidateName} -> ${formatPeerList(entry.peers, locale)}`)));
180
+ }
181
+ if (imported.length > 0) {
182
+ lines.push(formatLine(locale, 'Cross-node imported', '跨节点已导入', imported.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)}`)));
183
+ }
184
+ if (skipped.length > 0) {
185
+ lines.push(formatLine(locale, 'Cross-node skipped', '跨节点已跳过', skipped.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)} (${clipInline(entry.reason ?? '')})`)));
186
+ }
187
+ if (pendingRemote.length > 0) {
188
+ lines.push(formatLine(locale, 'Cross-node pending', '跨节点待处理', pendingRemote.map((entry) => {
189
+ const state = locale === 'zh'
190
+ ? (entry.queued ? '已排队' : '验证中')
191
+ : (entry.queued ? 'queued' : 'validating');
192
+ return `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)} (${state})`;
193
+ })));
194
+ }
195
+ if (mirrorRemoteOnly.length > 0) {
196
+ lines.push(formatLine(locale, 'Remote mirror write', '远端镜像写入', mirrorRemoteOnly.map((entry) => `${entry.candidateName} <- ${formatSource(entry.sourceLabel, entry.sourceNodeId)}`)));
197
+ }
198
+ return lines.length > 1 ? lines.join('\n') : null;
199
+ }
200
+ function remoteKey(event) {
201
+ return `${event.candidateName}\0${event.sourceNodeId}\0${event.peer}\0${event.mode ?? 'push'}`;
202
+ }
203
+ function formatLine(locale, enLabel, zhLabel, values) {
204
+ return `${locale === 'zh' ? zhLabel : enLabel}: ${formatCompactList(values, locale)}`;
205
+ }
206
+ function formatCompactList(values, locale) {
207
+ const unique = dedupe(values).filter(Boolean);
208
+ if (unique.length <= AUTH_REFRESH_NOTIFICATION_LIST_LIMIT) {
209
+ return unique.join(', ');
210
+ }
211
+ const shown = unique.slice(0, AUTH_REFRESH_NOTIFICATION_LIST_LIMIT).join(', ');
212
+ const remaining = unique.length - AUTH_REFRESH_NOTIFICATION_LIST_LIMIT;
213
+ return locale === 'zh'
214
+ ? `${shown},另 ${remaining} 项`
215
+ : `${shown}, and ${remaining} more`;
216
+ }
217
+ function formatPeerList(peers, locale) {
218
+ return peers.length > 0 ? peers.join(', ') : (locale === 'zh' ? '无' : 'none');
219
+ }
220
+ function formatSource(sourceLabel, sourceNodeId) {
221
+ return sourceLabel === sourceNodeId ? sourceNodeId : `${sourceLabel} / ${sourceNodeId}`;
222
+ }
223
+ function dedupe(values) {
224
+ return [...new Set(values)];
225
+ }
226
+ function clipInline(value, limit = 120) {
227
+ const clean = value.replace(/\s+/g, ' ').trim();
228
+ if (clean.length <= limit) {
229
+ return clean;
230
+ }
231
+ return `${clean.slice(0, Math.max(0, limit - 3))}...`;
232
+ }
233
+ function formatError(error) {
234
+ return error instanceof Error ? error.message : String(error);
235
+ }
@@ -87,6 +87,7 @@ export declare class BridgeSessionCore {
87
87
  private selfUpdatePollTimer;
88
88
  private proactiveAuthRefreshTimer;
89
89
  private proactiveAuthRefreshInProgress;
90
+ private proactiveAuthRefreshStatusMessage;
90
91
  private attachedThreads;
91
92
  private botUsername;
92
93
  private lastError;
@@ -136,6 +136,7 @@ export class BridgeSessionCore {
136
136
  selfUpdatePollTimer = null;
137
137
  proactiveAuthRefreshTimer = null;
138
138
  proactiveAuthRefreshInProgress = false;
139
+ proactiveAuthRefreshStatusMessage = null;
139
140
  attachedThreads = new Set();
140
141
  botUsername = null;
141
142
  lastError = null;
@@ -4388,11 +4389,11 @@ export class BridgeSessionCore {
4388
4389
  this.logger.warn('codex.auth_proactive_refresh_lease_failed', { reason: lease.reason });
4389
4390
  await this.notifyProactiveAuthRefresh(locale, t(locale, 'auth_proactive_refresh_lease_failed', {
4390
4391
  error: lease.reason ?? t(locale, 'unknown'),
4391
- }));
4392
+ }), true);
4392
4393
  return;
4393
4394
  }
4394
4395
  const result = await this.refreshCodexAuthCandidates(new Set(dueCandidates.map(candidate => candidate.name)));
4395
- await this.notifyProactiveAuthRefresh(locale, formatAuthRefreshAllResult(locale, result, 'proactive'));
4396
+ await this.notifyProactiveAuthRefresh(locale, formatAuthRefreshAllResult(locale, result, 'proactive'), true);
4396
4397
  }
4397
4398
  finally {
4398
4399
  await this.coordinator?.releaseAuthRefreshLease?.(lease?.leaseId ?? null);
@@ -4408,14 +4409,32 @@ export class BridgeSessionCore {
4408
4409
  return 'en';
4409
4410
  return this.localeForChat(privateScope.scopeId);
4410
4411
  }
4411
- async notifyProactiveAuthRefresh(locale, message) {
4412
+ async notifyProactiveAuthRefresh(locale, message, final = false) {
4412
4413
  const identity = this.bot.identity;
4413
4414
  if (!identity)
4414
4415
  return;
4415
4416
  const privateScope = this.store.getTelegramPrivateScope(identity);
4416
4417
  if (!privateScope)
4417
4418
  return;
4418
- await this.bot.sendMessage(privateScope.chatId, message).catch((error) => {
4419
+ const previous = this.proactiveAuthRefreshStatusMessage;
4420
+ if (previous?.chatId === privateScope.chatId) {
4421
+ try {
4422
+ await this.bot.editMessage(privateScope.chatId, previous.messageId, message, []);
4423
+ if (final) {
4424
+ this.proactiveAuthRefreshStatusMessage = null;
4425
+ }
4426
+ return;
4427
+ }
4428
+ catch (error) {
4429
+ this.logger.warn('codex.auth_proactive_refresh_notify_edit_failed', { error: toErrorMeta(error) });
4430
+ }
4431
+ }
4432
+ await this.bot.sendMessage(privateScope.chatId, message).then((messageId) => {
4433
+ this.proactiveAuthRefreshStatusMessage = final ? null : { chatId: privateScope.chatId, messageId };
4434
+ }).catch((error) => {
4435
+ if (final) {
4436
+ this.proactiveAuthRefreshStatusMessage = null;
4437
+ }
4419
4438
  this.logger.warn('codex.auth_proactive_refresh_notify_failed', { error: toErrorMeta(error) });
4420
4439
  });
4421
4440
  }
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ import { createInterface } from 'node:readline/promises';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { APP_HOME, DEFAULT_CODEX_TELEGRAM_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
11
+ import { createAuthRefreshNotificationAggregator, } from './auth/notifications.js';
11
12
  import { acquireProcessLock, LockHeldError } from './lock.js';
12
13
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
13
14
  import { buildFoxclawSystemdUnitText, buildSystemdRestartHelperArgs, cgroupContainsSystemdUnit, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns, } from './systemd.js';
@@ -196,6 +197,7 @@ async function runServeCli() {
196
197
  ]);
197
198
  const config = loadConfig();
198
199
  const logger = new Logger(config.logLevel, config.logPath);
200
+ const authNotificationAggregator = createAuthRefreshNotificationAggregator(logger);
199
201
  attachIlinkRuntimeFromBridgeLogger(logger, config.wxIlinkRouteTag);
200
202
  const processLock = acquireProcessLock(config.lockPath);
201
203
  let store = null;
@@ -251,12 +253,7 @@ async function runServeCli() {
251
253
  label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
252
254
  authDir: runtime.authDir,
253
255
  validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
254
- notify: async (message) => {
255
- const chatId = store.getTelegramPrivateChatId(runtime.id);
256
- if (chatId) {
257
- await runtime.bot.sendMessage(chatId, message);
258
- }
259
- },
256
+ notify: createAuthMirrorNotifier(store, runtime.id, runtime.bot, authNotificationAggregator),
260
257
  })), logger, path.join(APP_HOME, 'runtime', 'auth-mirror.json'), {
261
258
  onSynced: async (event) => {
262
259
  await authSync?.publishCandidate(event.record.candidateName);
@@ -428,7 +425,7 @@ async function runServeCli() {
428
425
  },
429
426
  importCandidate: (candidateName, raw, source) => mirror.importExternalCandidate(candidateName, raw, source),
430
427
  isIdle: authSyncLocalIdle,
431
- notify: createAuthSyncNotifier(store, authSyncTransportBot.bot),
428
+ notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator),
432
429
  });
433
430
  await authSync.initialize();
434
431
  activeAuthSync = authSync;
@@ -453,6 +450,7 @@ async function runServeCli() {
453
450
  logger.info('bridge.started', { bots: runtimes.map((runtime) => runtime.id) });
454
451
  const shutdown = async (signal) => {
455
452
  logger.info('bridge.shutting_down', { signal });
453
+ await authNotificationAggregator.flushAll();
456
454
  authSync?.stop();
457
455
  mirror.stop();
458
456
  await weixinAdapter?.stop();
@@ -573,7 +571,7 @@ async function runServeCli() {
573
571
  },
574
572
  importCandidate: (candidateName, raw, source) => singleMirror.importExternalCandidate(candidateName, raw, source),
575
573
  isIdle: singleAuthSyncLocalIdle,
576
- notify: createAuthSyncNotifier(store, bot),
574
+ notify: createAuthSyncNotifier(store, bot, authNotificationAggregator),
577
575
  });
578
576
  await singleAuthSync.initialize();
579
577
  activeAuthSync = singleAuthSync;
@@ -600,6 +598,7 @@ async function runServeCli() {
600
598
  logger.info('bridge.started', core.getRuntimeStatus());
601
599
  const shutdown = async (signal) => {
602
600
  logger.info('bridge.shutting_down', { signal });
601
+ await authNotificationAggregator.flushAll();
603
602
  singleAuthSync?.stop();
604
603
  singleMirror?.stop();
605
604
  await weixinAdapter?.stop();
@@ -630,6 +629,7 @@ async function runServeCli() {
630
629
  process.on('SIGTERM', () => void shutdown('SIGTERM'));
631
630
  }
632
631
  catch (error) {
632
+ await authNotificationAggregator.flushAll().catch(() => { });
633
633
  activeAuthSync?.stop();
634
634
  activeAuthMirror?.stop();
635
635
  await weixinAdapter?.stop().catch(() => { });
@@ -641,7 +641,20 @@ async function runServeCli() {
641
641
  throw error;
642
642
  }
643
643
  }
644
- function createAuthSyncNotifier(store, bot) {
644
+ function createAuthMirrorNotifier(store, botId, bot, aggregator) {
645
+ return async (event) => {
646
+ const privateScope = store.getTelegramPrivateScope(botId);
647
+ if (!privateScope)
648
+ return;
649
+ const locale = store.getChatSettings(privateScope.scopeId)?.locale ?? 'en';
650
+ aggregator.enqueueMirror({
651
+ key: authNotificationDestinationKey(botId, privateScope.chatId),
652
+ locale,
653
+ sendMessage: (text) => bot.sendMessage(privateScope.chatId, text),
654
+ }, event);
655
+ };
656
+ }
657
+ function createAuthSyncNotifier(store, bot, aggregator) {
645
658
  return async (event) => {
646
659
  if (!bot.identity)
647
660
  return;
@@ -649,9 +662,20 @@ function createAuthSyncNotifier(store, bot) {
649
662
  if (!privateScope)
650
663
  return;
651
664
  const locale = store.getChatSettings(privateScope.scopeId)?.locale ?? 'en';
665
+ const destination = {
666
+ key: authNotificationDestinationKey(bot.identity, privateScope.chatId),
667
+ locale,
668
+ sendMessage: (text) => bot.sendMessage(privateScope.chatId, text),
669
+ };
670
+ if (aggregator.enqueueAuthSync(destination, event)) {
671
+ return;
672
+ }
652
673
  await bot.sendMessage(privateScope.chatId, formatAuthSyncNotification(locale, event));
653
674
  };
654
675
  }
676
+ function authNotificationDestinationKey(botId, chatId) {
677
+ return `${botId}:${chatId}`;
678
+ }
655
679
  function formatAuthSyncNotification(locale, event) {
656
680
  const peers = 'peers' in event ? formatPeerList(event.peers, locale) : '';
657
681
  if (locale === 'zh') {
@@ -166,7 +166,7 @@ Confirm that pending imports were processed, or that the candidate exists or has
166
166
 
167
167
  Note: `/auth sync push all` saying “sent” only means this node successfully handed encrypted packages to Telegram. It does not prove the peer wrote files. The peer imports only when it is globally idle, usage validation succeeds, same-name candidates belong to the same account id and compatible ChatGPT user/email identity, and the remote `last_refresh` is newer than the local copy. If the local file is already equal or newer, it will not change and `Last import` may remain empty.
168
168
 
169
- When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Notifications never include auth contents, tokens, or encrypted bundle payloads.
169
+ When cross-node sync is enabled, the contact bot private chat receives node-level notifications: local auth updates and the peers being contacted, received remote bundles and whether they were queued or immediately validated, import success/skip/failure reasons, recovery peer queries and peer replies, and a manual-intervention notice when every peer lacks an importable copy. Refresh/send/import bursts are grouped into short summaries so one candidate update does not produce separate start, receive, mirror-write, and completion messages. Recovery and manual-intervention notices remain explicit. Notifications never include auth contents, tokens, or encrypted bundle payloads.
170
170
 
171
171
  Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from per-auth `Candidate failures`. For example, a remote candidate that returns `token_invalidated` or has an expired access token is recorded under that candidate name only; current `auth.json` health is still determined by validating the current auth usage. `local candidate is already newer or equal` is a normal skip, not an error.
172
172
 
@@ -421,11 +421,11 @@ When an auth candidate has already failed while in use and FoxClaw cannot recove
421
421
 
422
422
  `/auth refresh all` is a command-only maintenance action because ChatGPT refresh tokens are rotated. It is allowed only when every Telegram runtime, the Weixin runtime, approvals, inputs, logins, and auth mirroring are idle. The command first shows a risk confirmation: if OpenAI/Codex consumes an old refresh token but the new token cannot be saved because of network, process, or disk failure, that candidate may require device login or phone verification again. After confirmation, FoxClaw visits every ChatGPT candidate, asks Codex to force-refresh tokens with `account/read refreshToken=true`, verifies the result through the usage endpoint, mirrors successful candidates, restores the original current auth, and shows a summary.
423
423
 
424
- OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`. FoxClaw also checks once per hour in the background: if an enabled ChatGPT candidate has a `last_refresh` older than 9 days, FoxClaw proactively refreshes that batch only when every runtime is idle, no approvals/inputs/logins/auth mirror writes are active, and the node holds the cross-node refresh lease. The private bot chat reports the proactive refresh result, and newer candidates continue through same-node mirroring and cross-node sync.
424
+ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token replay grace period. Codex refreshes automatically when an access token approaches expiry; when it cannot parse the access-token `exp`, current Codex uses a `last_refresh` fallback of about 8 days. The panel labels candidates without a refresh record in that interval as `not recently refreshed`. FoxClaw also checks once per hour in the background: if an enabled ChatGPT candidate has a `last_refresh` older than 9 days, FoxClaw proactively refreshes that batch only when every runtime is idle, no approvals/inputs/logins/auth mirror writes are active, and the node holds the cross-node refresh lease. The private bot chat shows one proactive-refresh status message and edits it to the final result. Newer candidates continue through same-node mirroring and cross-node sync, and bursty mirror/cross-node refresh notices are grouped into short summary messages.
425
425
 
426
426
  ### 6.4 Cross-Node Auth Sync
427
427
 
428
- Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states; per-candidate validation failures are shown as candidate failures instead of overwriting the sync-system last error. Recent bot-to-bot traffic is also kept in an event ring so `/auth sync events [filter]` and `/auth sync trace <requestId>` can explain a specific candidate, peer, or request.
428
+ Cross-node auth sync is disabled by default. It is for multiple machines you control that share the same legally owned ChatGPT auth candidate pool, so a token refreshed by Codex on one node can be copied to the others. v1 uses Telegram Bot-to-Bot private messages to carry encrypted files, so it does not require public IPs or FRP. The recommended default is one contact bot per node; other bots on the same node keep using local auth mirroring. In multi-bot mode, the default contact is the first token in `TG_BOT_TOKENS`. The contact bot private chat reports send, receive, queue, import, failure, recovery-query, and manual-intervention states; refresh/send/import bursts are grouped into summaries, while recovery and manual-intervention notices remain explicit. Per-candidate validation failures are shown as candidate failures instead of overwriting the sync-system last error. Recent bot-to-bot traffic is also kept in an event ring so `/auth sync events [filter]` and `/auth sync trace <requestId>` can explain a specific candidate, peer, or request.
429
429
 
430
430
  For the full design, safety boundaries, `.env` examples, and troubleshooting, read the [Cross-Node Auth Sync Setup Guide](./cross-node-auth-sync.md).
431
431
 
@@ -166,7 +166,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
166
166
 
167
167
  注意:`/auth sync push all` 的“已发送”只代表本节点把加密包发给 Telegram 成功,不代表对端已经写盘。对端只有在全局空闲、usage 验证通过、同名候选 account id 一致且 ChatGPT 用户/邮箱身份兼容,并且远端 `last_refresh` 比本地更新时才会覆盖文件。如果本地已经是相同或更新版本,文件不会变化,`最近导入` 也可能保持为空。
168
168
 
169
- 启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。通知不会包含 auth 内容、token 或同步密文。
169
+ 启用跨节点同步后,联系人 bot 的私聊会收到节点级通知:本机 auth 更新并开始发往哪些 peer、收到远端包后是排队还是立即验证、导入成功/跳过/失败原因、auth 恢复时正在查询哪些 peer、peer 回应了什么,以及所有 peer 都无法提供可用副本时的人工介入提示。刷新、发送、导入密集发生时会合并成简短汇总,避免一个候选更新拆成开始、收到、镜像写入和完成多条消息;恢复和人工介入提示仍会明确发出。通知不会包含 auth 内容、token 或同步密文。
170
170
 
171
171
  从 0.5.2 起,`/auth sync status` 会把同步系统级 `最近错误` 和单个 auth 的 `候选失败` 分开显示。比如某个远端候选返回 `token_invalidated` 或 access token 过期时,只会记录到该候选名下面;当前 `auth.json` 是否健康仍以当前 auth 的 usage 验证为准。`local candidate is already newer or equal` 属于正常跳过,不会记为错误。
172
172
 
@@ -421,11 +421,11 @@ Candidates: 2
421
421
 
422
422
  `/auth refresh all` 是仅命令入口的维护操作,因为 ChatGPT refresh token 会被轮换。只有所有 Telegram runtime、微信 runtime、审批、待输入、登录流程和 auth 镜像写入都空闲时才允许执行。命令会先显示风险确认:如果 OpenAI/Codex 已经消费旧 refresh token,但因为网络、进程或磁盘故障导致新 token 没能成功保存,该候选可能需要重新设备登录,甚至重新手机号验证。确认后,它会逐个访问 ChatGPT 候选,让 Codex 通过 `account/read refreshToken=true` 强制刷新 token,再用 usage 接口验证,成功后镜像到其他 bot home,最后恢复原本的当前 auth 并显示摘要。
423
423
 
424
- OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”。FoxClaw 还会在后台每小时检查一次:已启用的 ChatGPT 候选如果 `last_refresh` 超过 9 天,会在所有 runtime 空闲、没有审批/待输入/登录/auth 镜像写入,并且拿到跨节点刷新锁后,主动刷新这一批候选。主动刷新完成后会私聊通知,并把较新的候选继续镜像和跨节点同步。
424
+ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放宽限期。Codex 会在 access token 临近到期时自动刷新;如果 access token 里无法解析 `exp`,Codex 当前使用 `last_refresh` 超过约 8 天作为兜底刷新条件。面板把超过 8 天没有刷新记录的候选标为“长期未刷新”。FoxClaw 还会在后台每小时检查一次:已启用的 ChatGPT 候选如果 `last_refresh` 超过 9 天,会在所有 runtime 空闲、没有审批/待输入/登录/auth 镜像写入,并且拿到跨节点刷新锁后,主动刷新这一批候选。私聊里会显示一条主动刷新状态消息,并在结束时编辑成最终结果;较新的候选会继续镜像和跨节点同步,成批出现的镜像/跨节点刷新通知会合并成简短汇总。
425
425
 
426
426
  ### 6.4 跨节点 auth 同步
427
427
 
428
- 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示;单个候选验证失败会作为“候选失败”显示,不会覆盖同步系统级最近错误。最近 bot-to-bot 通讯会保存在事件环里,可用 `/auth sync events [过滤]` 和 `/auth sync trace <requestId>` 追查某个候选、peer 或请求。
428
+ 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示;刷新、发送、导入密集发生时会合并成汇总,恢复和人工介入提示仍会明确发出。单个候选验证失败会作为“候选失败”显示,不会覆盖同步系统级最近错误。最近 bot-to-bot 通讯会保存在事件环里,可用 `/auth sync events [过滤]` 和 `/auth sync trace <requestId>` 追查某个候选、peer 或请求。
429
429
 
430
430
  完整设计、安全边界、`.env` 示例和排查步骤见 [跨节点 auth 同步配置指南](./cross-node-auth-sync.md)。
431
431
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.18",
3
+ "version": "0.5.19",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",