@foxden-app/foxclaw 0.5.7 → 0.5.9

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,32 @@
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.9 - 2026-06-07
6
+
7
+ ### 中文
8
+ - Linux 用户级 systemd 安装现在会在 `foxclaw start` / `restart` / `install-systemd` 时自动尝试启用 systemd user linger,避免用户退出 SSH 或桌面会话后 FoxClaw 停止接收 Telegram 消息。
9
+ - `foxclaw doctor` 新增 linger 状态检查;如果自动启用失败,会提示使用 `sudo loginctl enable-linger <user>` 手动修复。
10
+ - 中文/英文安装指南和故障排查文档更新为默认自动处理 linger,失败时再手动介入。
11
+
12
+ ### English
13
+ - Linux user-systemd installation now tries to enable systemd user linger during `foxclaw start`, `restart`, and `install-systemd`, preventing FoxClaw from stopping after SSH or desktop logout.
14
+ - `foxclaw doctor` now checks linger state and tells users to run `sudo loginctl enable-linger <user>` if automatic setup fails.
15
+ - Updated the Chinese and English install and troubleshooting docs to describe automatic linger setup with manual recovery only when needed.
16
+
17
+ ## 0.5.8 - 2026-06-05
18
+
19
+ ### 中文
20
+ - 为跨节点 auth 同步新增持久化事件环,记录 push、pull、lease、test 和远端导入的发送、接收、超时、跳过、导入、失败等阶段。
21
+ - `/auth sync status` 现在展示 peer 最近活跃时间和最近事件,让“peer 在线但某次请求超时”“候选失败但同步系统正常”更容易判断。
22
+ - 新增 `/auth sync events [过滤]` 和 `/auth sync trace <requestId>`,可按候选名、peer、事件类型或请求 ID 查看 bot_to_bot 通讯流水。
23
+ - 事件记录包含 requestId、peer、candidateName 和阶段详情,同时保留既有加密协议兼容性。
24
+
25
+ ### English
26
+ - Added a persisted event ring for cross-node auth sync, recording send, receive, timeout, skip, import, and failure stages for push, pull, lease, test, and remote-import flows.
27
+ - `/auth sync status` now includes peer recent activity and recent events, making it easier to distinguish "peer reachable but this request timed out" from candidate-specific failures.
28
+ - Added `/auth sync events [filter]` and `/auth sync trace <requestId>` to inspect bot-to-bot traffic by candidate name, peer, event kind, or request ID.
29
+ - Event records include requestId, peer, candidateName, and stage details while keeping the existing encrypted protocol backward-compatible.
30
+
5
31
  ## 0.5.7 - 2026-06-05
6
32
 
7
33
  ### 中文
package/README.md CHANGED
@@ -215,7 +215,7 @@ TG_BOT_TOKEN=123456:token_a
215
215
 
216
216
  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 摘要。
217
217
 
218
- 多台机器共享同一合法账号池时,可以启用可选跨节点 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 私聊里报告发送、接收、排队、导入、失败和人工介入提示。跨节点恢复不会自动刷新 token,`/auth refresh all confirm` 会先申请跨节点刷新锁。完整配置、`@BotFather` 操作和验证步骤见 [跨节点 auth 同步配置指南](./docs/zh/cross-node-auth-sync.md)。
218
+ 多台机器共享同一合法账号池时,可以启用可选跨节点 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
219
 
220
220
  如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
221
221
 
package/README_EN.md CHANGED
@@ -215,7 +215,7 @@ TG_BOT_TOKEN=123456:token_a
215
215
 
216
216
  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.
217
217
 
218
- 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. 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).
218
+ 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
219
 
220
220
  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.
221
221
 
@@ -31,6 +31,8 @@ export interface AuthSyncStatus {
31
31
  lastError: string | null;
32
32
  candidateFailures: AuthSyncCandidateFailure[];
33
33
  activeLeaseId: string | null;
34
+ peerStatuses: AuthSyncPeerStatus[];
35
+ recentEvents: AuthSyncEventRecord[];
34
36
  }
35
37
  export interface AuthSyncCandidateFailure {
36
38
  candidateName: string;
@@ -41,6 +43,21 @@ export interface AuthSyncCandidateFailure {
41
43
  mode: AuthSyncRemoteImportMode;
42
44
  updatedAt: string;
43
45
  }
46
+ export interface AuthSyncPeerStatus {
47
+ peer: string;
48
+ lastReceivedAt: string | null;
49
+ }
50
+ export interface AuthSyncEventRecord {
51
+ id: string;
52
+ createdAt: string;
53
+ direction: 'in' | 'out' | 'local';
54
+ kind: string;
55
+ stage: string;
56
+ peer: string | null;
57
+ requestId: string | null;
58
+ candidateName: string | null;
59
+ detail: string | null;
60
+ }
44
61
  export interface AuthSyncValidationResult {
45
62
  ok: boolean;
46
63
  reason?: string | null;
@@ -240,6 +257,7 @@ export declare class CrossNodeAuthSync {
240
257
  private recordCandidateFailure;
241
258
  private clearCandidateFailure;
242
259
  private notePeerActivity;
260
+ private recordEvent;
243
261
  private describePeerReachability;
244
262
  private notify;
245
263
  private notifyError;
@@ -10,6 +10,7 @@ const TEST_TIMEOUT_MS = 8_000;
10
10
  const LEASE_TIMEOUT_MS = 8_000;
11
11
  const LEASE_TTL_MS = 10 * 60_000;
12
12
  const REMOTE_ACCESS_TOKEN_MIN_TTL_MS = 60_000;
13
+ const RECENT_EVENT_LIMIT = 120;
13
14
  export class CrossNodeAuthSync {
14
15
  config;
15
16
  logger;
@@ -39,6 +40,7 @@ export class CrossNodeAuthSync {
39
40
  lastPullCandidate: null,
40
41
  lastError: null,
41
42
  lastCandidateFailures: {},
43
+ recentEvents: [],
42
44
  };
43
45
  constructor(config, logger, transport, callbacks) {
44
46
  this.config = config;
@@ -67,6 +69,7 @@ export class CrossNodeAuthSync {
67
69
  lastPullCandidate: stored.lastPullCandidate ?? null,
68
70
  lastError: stored.lastError ?? null,
69
71
  lastCandidateFailures: normalizeCandidateFailures(stored.lastCandidateFailures ?? {}),
72
+ recentEvents: normalizeRecentEvents(stored.recentEvents ?? []),
70
73
  };
71
74
  await fs.mkdir(this.config.tempDir, { recursive: true, mode: 0o700 });
72
75
  await this.writeState();
@@ -109,6 +112,14 @@ export class CrossNodeAuthSync {
109
112
  candidateFailures: Object.values(this.state.lastCandidateFailures)
110
113
  .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
111
114
  activeLeaseId: this.activeLocalLease?.leaseId ?? this.activeRemoteLease?.leaseId ?? null,
115
+ peerStatuses: this.peers.map((peer) => {
116
+ const lastActivityAt = this.lastPeerActivityAt.get(peer) ?? null;
117
+ return {
118
+ peer,
119
+ lastReceivedAt: lastActivityAt === null ? null : new Date(lastActivityAt).toISOString(),
120
+ };
121
+ }),
122
+ recentEvents: [...this.state.recentEvents],
112
123
  };
113
124
  }
114
125
  isIdle() {
@@ -126,16 +137,29 @@ export class CrossNodeAuthSync {
126
137
  const record = await this.callbacks.readLocalCandidate(candidateName);
127
138
  if (!record)
128
139
  return false;
140
+ const requestId = crypto.randomUUID();
141
+ this.recordEvent({
142
+ direction: 'local',
143
+ kind: 'candidate.publish',
144
+ stage: 'started',
145
+ peer: null,
146
+ requestId,
147
+ candidateName,
148
+ detail: `peers=${this.peers.join(', ') || 'none'}`,
149
+ });
129
150
  this.notify({ kind: 'candidate_publish_started', candidateName, peers: [...this.peers] });
130
151
  try {
131
152
  await this.sendToAll({
132
153
  kind: 'push.bundle',
154
+ requestId,
133
155
  ...bundleFromRecord(record),
134
156
  });
157
+ this.recordEvent({ direction: 'local', kind: 'candidate.publish', stage: 'completed', peer: null, requestId, candidateName, detail: null });
135
158
  this.notify({ kind: 'candidate_publish_completed', candidateName, peers: [...this.peers] });
136
159
  }
137
160
  catch (error) {
138
161
  this.recordError(`candidate publish failed for ${candidateName}: ${formatError(error)}`, false);
162
+ this.recordEvent({ direction: 'local', kind: 'candidate.publish', stage: 'failed', peer: null, requestId, candidateName, detail: formatError(error) });
139
163
  this.notify({
140
164
  kind: 'candidate_publish_failed',
141
165
  candidateName,
@@ -161,6 +185,7 @@ export class CrossNodeAuthSync {
161
185
  }
162
186
  await this.sendToAll({
163
187
  kind: 'push.bundle',
188
+ requestId: crypto.randomUUID(),
164
189
  ...bundleFromRecord(record),
165
190
  });
166
191
  sent += 1;
@@ -213,6 +238,15 @@ export class CrossNodeAuthSync {
213
238
  waitMs,
214
239
  peerReachability,
215
240
  });
241
+ this.recordEvent({
242
+ direction: 'local',
243
+ kind: 'pull.request',
244
+ stage: 'timeout',
245
+ peer: null,
246
+ requestId,
247
+ candidateName,
248
+ detail: reason,
249
+ });
216
250
  this.notify({
217
251
  kind: 'recovery_failed',
218
252
  candidateName,
@@ -247,6 +281,15 @@ export class CrossNodeAuthSync {
247
281
  this.pendingPulls.delete(requestId);
248
282
  this.recordError(`pull request failed: ${formatError(error)}`, false);
249
283
  const waitMs = Date.now() - startedAt;
284
+ this.recordEvent({
285
+ direction: 'local',
286
+ kind: 'pull.request',
287
+ stage: 'send_failed',
288
+ peer: null,
289
+ requestId,
290
+ candidateName,
291
+ detail: formatError(error),
292
+ });
250
293
  this.notify({
251
294
  kind: 'recovery_failed',
252
295
  candidateName,
@@ -272,6 +315,7 @@ export class CrossNodeAuthSync {
272
315
  if (!this.isReady() || this.peers.length === 0) {
273
316
  const leaseId = crypto.randomUUID();
274
317
  this.activeLocalLease = { leaseId, expiresAt: Date.now() + LEASE_TTL_MS };
318
+ this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'granted_local', peer: null, requestId: leaseId, candidateName: null, detail: reason });
275
319
  return { ok: true, leaseId };
276
320
  }
277
321
  if (!this.callbacks.isIdle()) {
@@ -282,6 +326,15 @@ export class CrossNodeAuthSync {
282
326
  const result = await new Promise((resolve) => {
283
327
  const timer = setTimeout(() => {
284
328
  this.pendingLeases.delete(leaseId);
329
+ this.recordEvent({
330
+ direction: 'local',
331
+ kind: 'lease.request',
332
+ stage: 'timeout',
333
+ peer: null,
334
+ requestId: leaseId,
335
+ candidateName: null,
336
+ detail: `timed out waiting for ${this.peers.length} auth sync peer lease grant(s); reason=${reason}`,
337
+ });
285
338
  resolve({
286
339
  ok: false,
287
340
  leaseId: null,
@@ -299,13 +352,16 @@ export class CrossNodeAuthSync {
299
352
  void this.sendToAll({ kind: 'lease.request', leaseId, reason, expiresAt }).catch((error) => {
300
353
  clearTimeout(timer);
301
354
  this.pendingLeases.delete(leaseId);
355
+ this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'send_failed', peer: null, requestId: leaseId, candidateName: null, detail: formatError(error) });
302
356
  resolve({ ok: false, leaseId: null, reason: formatError(error) });
303
357
  });
304
358
  });
305
359
  if (result.ok) {
306
360
  this.activeLocalLease = { leaseId, expiresAt };
361
+ this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'granted', peer: null, requestId: leaseId, candidateName: null, detail: reason });
307
362
  }
308
363
  else {
364
+ this.recordEvent({ direction: 'local', kind: 'lease.request', stage: 'denied', peer: null, requestId: leaseId, candidateName: null, detail: result.reason ?? reason });
309
365
  await this.releaseRefreshLease(leaseId);
310
366
  }
311
367
  return result;
@@ -329,6 +385,7 @@ export class CrossNodeAuthSync {
329
385
  const peers = [...this.peers];
330
386
  const resultPromise = new Promise((resolve) => {
331
387
  const timer = setTimeout(() => {
388
+ this.recordEvent({ direction: 'local', kind: 'test.ping', stage: 'timeout', peer: null, requestId, candidateName: null, detail: null });
332
389
  this.finishPendingTest(requestId);
333
390
  }, TEST_TIMEOUT_MS);
334
391
  timer.unref();
@@ -370,8 +427,18 @@ export class CrossNodeAuthSync {
370
427
  }
371
428
  this.seenNonces.set(nonceKey, Date.now());
372
429
  this.state.lastReceivedAt = new Date().toISOString();
430
+ const normalizedPeer = normalizePeerIdentity(peer);
431
+ this.recordEvent({
432
+ direction: 'in',
433
+ kind: opened.message.kind,
434
+ stage: 'received',
435
+ peer: normalizedPeer,
436
+ requestId: requestIdFromMessage(opened.message),
437
+ candidateName: candidateNameFromMessage(opened.message),
438
+ detail: `sender=${opened.sender}`,
439
+ });
373
440
  await this.writeState();
374
- this.notePeerActivity(normalizePeerIdentity(peer));
441
+ this.notePeerActivity(normalizedPeer);
375
442
  await this.handleMessage(opened.message, opened.sender, peer);
376
443
  return true;
377
444
  }
@@ -411,6 +478,7 @@ export class CrossNodeAuthSync {
411
478
  case 'lease.release':
412
479
  if (this.activeRemoteLease?.leaseId === message.leaseId) {
413
480
  this.activeRemoteLease = null;
481
+ this.recordEvent({ direction: 'in', kind: 'lease.release', stage: 'released', peer: normalizePeerIdentity(peer), requestId: message.leaseId, candidateName: null, detail: null });
414
482
  }
415
483
  return;
416
484
  default:
@@ -429,6 +497,7 @@ export class CrossNodeAuthSync {
429
497
  const record = await this.callbacks.readLocalCandidate(message.candidateName);
430
498
  if (!record) {
431
499
  await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'candidate not found' });
500
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'candidate_not_found', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'candidate not found' });
432
501
  this.notify({
433
502
  kind: 'pull_response_sent',
434
503
  candidateName: message.candidateName,
@@ -440,6 +509,7 @@ export class CrossNodeAuthSync {
440
509
  }
441
510
  if (message.accountId && record.accountId !== message.accountId) {
442
511
  await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'account mismatch' });
512
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'account_mismatch', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'account mismatch' });
443
513
  this.notify({
444
514
  kind: 'pull_response_sent',
445
515
  candidateName: message.candidateName,
@@ -451,6 +521,7 @@ export class CrossNodeAuthSync {
451
521
  }
452
522
  if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
453
523
  await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
524
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'not_newer', peer, requestId: message.requestId, candidateName: message.candidateName, detail: 'not newer' });
454
525
  this.notify({
455
526
  kind: 'pull_response_sent',
456
527
  candidateName: message.candidateName,
@@ -465,6 +536,7 @@ export class CrossNodeAuthSync {
465
536
  requestId: message.requestId,
466
537
  bundle: bundleFromRecord(record),
467
538
  });
539
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'sent_bundle', peer, requestId: message.requestId, candidateName: message.candidateName, detail: null });
468
540
  this.notify({
469
541
  kind: 'pull_response_sent',
470
542
  candidateName: message.candidateName,
@@ -480,6 +552,7 @@ export class CrossNodeAuthSync {
480
552
  const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
481
553
  if (!message.bundle || message.bundle.candidateName !== pending.candidateName) {
482
554
  const reason = message.reason ?? 'peer did not return a matching candidate';
555
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'empty', peer, requestId: message.requestId, candidateName: pending.candidateName, detail: reason });
483
556
  this.notify({
484
557
  kind: 'recovery_peer_empty',
485
558
  candidateName: pending.candidateName,
@@ -498,12 +571,14 @@ export class CrossNodeAuthSync {
498
571
  const outcome = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer, 'pull');
499
572
  if (!outcome.imported) {
500
573
  this.markPullPeerUnavailable(message.requestId, matchedPeer, outcome.reason ?? 'peer candidate was not imported');
574
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'not_imported', peer, requestId: message.requestId, candidateName: pending.candidateName, detail: outcome.reason ?? 'peer candidate was not imported' });
501
575
  return;
502
576
  }
503
577
  pending.finished = true;
504
578
  clearTimeout(pending.timer);
505
579
  this.pendingPulls.delete(message.requestId);
506
580
  pending.resolve(true);
581
+ this.recordEvent({ direction: 'local', kind: 'pull.response', stage: 'imported', peer, requestId: message.requestId, candidateName: pending.candidateName, detail: null });
507
582
  }
508
583
  markPullPeerUnavailable(requestId, peer, reason) {
509
584
  const pending = this.pendingPulls.get(requestId);
@@ -525,6 +600,15 @@ export class CrossNodeAuthSync {
525
600
  peers: pending.peers,
526
601
  reason: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
527
602
  });
603
+ this.recordEvent({
604
+ direction: 'local',
605
+ kind: 'pull.request',
606
+ stage: 'failed',
607
+ peer: null,
608
+ requestId,
609
+ candidateName: pending.candidateName,
610
+ detail: `all peers replied without an importable auth candidate${details ? ` (${details})` : ''}`,
611
+ });
528
612
  pending.resolve(false);
529
613
  }
530
614
  async handleDigest(message, peer) {
@@ -546,10 +630,12 @@ export class CrossNodeAuthSync {
546
630
  this.expireLeases();
547
631
  if (!this.callbacks.isIdle()) {
548
632
  await this.sendToPeer(peer, { kind: 'lease.deny', leaseId: message.leaseId, reason: 'runtime is not idle' });
633
+ this.recordEvent({ direction: 'local', kind: 'lease.deny', stage: 'sent', peer, requestId: message.leaseId, candidateName: null, detail: 'runtime is not idle' });
549
634
  return;
550
635
  }
551
636
  if (this.activeRemoteLease && this.activeRemoteLease.leaseId !== message.leaseId) {
552
637
  await this.sendToPeer(peer, { kind: 'lease.deny', leaseId: message.leaseId, reason: 'another refresh lease is active' });
638
+ this.recordEvent({ direction: 'local', kind: 'lease.deny', stage: 'sent', peer, requestId: message.leaseId, candidateName: null, detail: 'another refresh lease is active' });
553
639
  return;
554
640
  }
555
641
  this.activeRemoteLease = {
@@ -558,6 +644,7 @@ export class CrossNodeAuthSync {
558
644
  expiresAt: Math.min(message.expiresAt, Date.now() + LEASE_TTL_MS),
559
645
  };
560
646
  await this.sendToPeer(peer, { kind: 'lease.grant', leaseId: message.leaseId, expiresAt: this.activeRemoteLease.expiresAt });
647
+ this.recordEvent({ direction: 'local', kind: 'lease.grant', stage: 'sent', peer, requestId: message.leaseId, candidateName: null, detail: null });
561
648
  }
562
649
  handleLeaseReply(message, peer) {
563
650
  const pending = this.pendingLeases.get(message.leaseId);
@@ -565,9 +652,11 @@ export class CrossNodeAuthSync {
565
652
  return;
566
653
  if (message.kind === 'lease.deny') {
567
654
  pending.denies.push(`${peer}: ${message.reason}`);
655
+ this.recordEvent({ direction: 'in', kind: 'lease.deny', stage: 'received', peer, requestId: message.leaseId, candidateName: null, detail: message.reason });
568
656
  }
569
657
  else {
570
658
  pending.grants.add(peer);
659
+ this.recordEvent({ direction: 'in', kind: 'lease.grant', stage: 'received', peer, requestId: message.leaseId, candidateName: null, detail: null });
571
660
  }
572
661
  if (pending.denies.length > 0) {
573
662
  clearTimeout(pending.timer);
@@ -590,6 +679,15 @@ export class CrossNodeAuthSync {
590
679
  receivedAt: Date.now(),
591
680
  fromPeer,
592
681
  });
682
+ this.recordEvent({
683
+ direction: 'local',
684
+ kind: 'push.bundle',
685
+ stage: queued ? 'queued' : 'processing',
686
+ peer: fromPeer,
687
+ requestId: bundle.requestId ?? null,
688
+ candidateName: bundle.candidateName,
689
+ detail: `source=${sourceNodeId}; queue=${this.pendingImports.length}`,
690
+ });
593
691
  this.notify({
594
692
  kind: 'remote_bundle_received',
595
693
  candidateName: bundle.candidateName,
@@ -655,6 +753,15 @@ export class CrossNodeAuthSync {
655
753
  this.state.lastError = null;
656
754
  await this.writeState();
657
755
  this.logger.info('auth.sync.imported', { candidateName: bundle.candidateName, sourceNodeId });
756
+ this.recordEvent({
757
+ direction: 'local',
758
+ kind: mode === 'pull' ? 'pull.response' : 'push.bundle',
759
+ stage: 'imported',
760
+ peer: fromPeer,
761
+ requestId: bundle.requestId ?? null,
762
+ candidateName: bundle.candidateName,
763
+ detail: `source=${sourceNodeId}`,
764
+ });
658
765
  this.notify({
659
766
  kind: 'remote_import_imported',
660
767
  candidateName: bundle.candidateName,
@@ -665,6 +772,15 @@ export class CrossNodeAuthSync {
665
772
  });
666
773
  }
667
774
  else {
775
+ this.recordEvent({
776
+ direction: 'local',
777
+ kind: mode === 'pull' ? 'pull.response' : 'push.bundle',
778
+ stage: 'skipped',
779
+ peer: fromPeer,
780
+ requestId: bundle.requestId ?? null,
781
+ candidateName: bundle.candidateName,
782
+ detail: result.reason ?? 'local candidate did not need an update',
783
+ });
668
784
  this.notify({
669
785
  kind: 'remote_import_skipped',
670
786
  candidateName: bundle.candidateName,
@@ -685,7 +801,30 @@ export class CrossNodeAuthSync {
685
801
  }
686
802
  async sendToPeer(peer, message) {
687
803
  const envelope = this.sealEnvelope(message);
688
- await this.transport.send(peer, envelope);
804
+ try {
805
+ await this.transport.send(peer, envelope);
806
+ this.recordEvent({
807
+ direction: 'out',
808
+ kind: message.kind,
809
+ stage: 'sent',
810
+ peer,
811
+ requestId: requestIdFromMessage(message),
812
+ candidateName: candidateNameFromMessage(message),
813
+ detail: null,
814
+ });
815
+ }
816
+ catch (error) {
817
+ this.recordEvent({
818
+ direction: 'out',
819
+ kind: message.kind,
820
+ stage: 'send_failed',
821
+ peer,
822
+ requestId: requestIdFromMessage(message),
823
+ candidateName: candidateNameFromMessage(message),
824
+ detail: formatError(error),
825
+ });
826
+ throw error;
827
+ }
689
828
  this.state.lastSentAt = new Date().toISOString();
690
829
  if (this.state.lastError?.includes('USER_BOT_TO_BOT_DISABLED')) {
691
830
  this.state.lastError = null;
@@ -698,6 +837,7 @@ export class CrossNodeAuthSync {
698
837
  return;
699
838
  const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
700
839
  pending.replies.add(matchedPeer);
840
+ this.recordEvent({ direction: 'local', kind: 'test.pong', stage: 'matched', peer: matchedPeer, requestId, candidateName: null, detail: null });
701
841
  if (pending.peers.every(peerName => pending.replies.has(peerName))) {
702
842
  this.finishPendingTest(requestId);
703
843
  }
@@ -804,6 +944,15 @@ export class CrossNodeAuthSync {
804
944
  }
805
945
  }
806
946
  async rejectImport(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason) {
947
+ this.recordEvent({
948
+ direction: 'local',
949
+ kind: mode === 'pull' ? 'pull.response' : 'push.bundle',
950
+ stage: 'failed',
951
+ peer: fromPeer,
952
+ requestId: bundle.requestId ?? null,
953
+ candidateName: typeof bundle.candidateName === 'string' ? bundle.candidateName : null,
954
+ detail: reason,
955
+ });
807
956
  await this.recordCandidateFailure(bundle, sourceNodeId, sourceLabel, fromPeer, mode, reason);
808
957
  this.notify({
809
958
  kind: 'remote_import_failed',
@@ -851,6 +1000,24 @@ export class CrossNodeAuthSync {
851
1000
  const matchedPeer = this.matchConfiguredPeer(peer) ?? peer;
852
1001
  this.lastPeerActivityAt.set(matchedPeer, Date.now());
853
1002
  }
1003
+ recordEvent(event) {
1004
+ const record = {
1005
+ id: crypto.randomBytes(6).toString('hex'),
1006
+ createdAt: new Date().toISOString(),
1007
+ direction: event.direction,
1008
+ kind: event.kind,
1009
+ stage: event.stage,
1010
+ peer: event.peer,
1011
+ requestId: event.requestId,
1012
+ candidateName: event.candidateName,
1013
+ detail: event.detail,
1014
+ };
1015
+ this.state.recentEvents = pruneRecentEvents([...this.state.recentEvents, record]);
1016
+ this.logger.info('auth.sync.event', record);
1017
+ void this.writeState().catch((error) => {
1018
+ this.logger.warn('auth.sync.state_write_failed', { error: formatError(error) });
1019
+ });
1020
+ }
854
1021
  describePeerReachability(peers, sinceMs) {
855
1022
  return peers.map((peer) => {
856
1023
  const lastActivityAt = this.lastPeerActivityAt.get(peer) ?? null;
@@ -879,7 +1046,7 @@ export class CrossNodeAuthSync {
879
1046
  return;
880
1047
  this.seenNonces = pruneSeenNonces(this.seenNonces);
881
1048
  await fs.mkdir(path.dirname(this.config.statePath), { recursive: true, mode: 0o700 });
882
- const temporary = `${this.config.statePath}.${process.pid}.${Date.now()}.tmp`;
1049
+ const temporary = `${this.config.statePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(3).toString('hex')}.tmp`;
883
1050
  const state = {
884
1051
  seenNonces: Object.fromEntries(this.seenNonces),
885
1052
  ...this.state,
@@ -998,6 +1165,56 @@ function pruneCandidateFailures(failures) {
998
1165
  .sort(([, left], [, right]) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
999
1166
  .slice(0, 20));
1000
1167
  }
1168
+ function normalizeRecentEvents(raw) {
1169
+ if (!Array.isArray(raw))
1170
+ return [];
1171
+ return pruneRecentEvents(raw.filter((event) => (event
1172
+ && typeof event.id === 'string'
1173
+ && typeof event.createdAt === 'string'
1174
+ && Number.isFinite(Date.parse(event.createdAt))
1175
+ && (event.direction === 'in' || event.direction === 'out' || event.direction === 'local')
1176
+ && typeof event.kind === 'string'
1177
+ && typeof event.stage === 'string'
1178
+ && (typeof event.peer === 'string' || event.peer === null)
1179
+ && (typeof event.requestId === 'string' || event.requestId === null)
1180
+ && (typeof event.candidateName === 'string' || event.candidateName === null)
1181
+ && (typeof event.detail === 'string' || event.detail === null))));
1182
+ }
1183
+ function pruneRecentEvents(events) {
1184
+ return events
1185
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
1186
+ .slice(-RECENT_EVENT_LIMIT);
1187
+ }
1188
+ function requestIdFromMessage(message) {
1189
+ switch (message.kind) {
1190
+ case 'push.bundle':
1191
+ return message.requestId ?? null;
1192
+ case 'pull.request':
1193
+ case 'pull.response':
1194
+ case 'test.ping':
1195
+ case 'test.pong':
1196
+ return message.requestId;
1197
+ case 'lease.request':
1198
+ return message.leaseId;
1199
+ case 'lease.grant':
1200
+ case 'lease.deny':
1201
+ case 'lease.release':
1202
+ return message.leaseId;
1203
+ case 'digest':
1204
+ return null;
1205
+ }
1206
+ }
1207
+ function candidateNameFromMessage(message) {
1208
+ switch (message.kind) {
1209
+ case 'push.bundle':
1210
+ case 'pull.request':
1211
+ return message.candidateName;
1212
+ case 'pull.response':
1213
+ return message.bundle?.candidateName ?? null;
1214
+ default:
1215
+ return null;
1216
+ }
1217
+ }
1001
1218
  function formatPullTimeoutReason(pending, waitMs, peerReachability) {
1002
1219
  const reachable = peerReachability
1003
1220
  .filter((entry) => entry.reachableDuringRequest)
@@ -4101,6 +4101,19 @@ export class BridgeSessionCore {
4101
4101
  await this.sendMessage(scopeId, formatAuthSyncStatus(locale, this.coordinator?.getAuthSyncStatus?.() ?? null));
4102
4102
  return;
4103
4103
  }
4104
+ if (action === 'events') {
4105
+ await this.sendMessage(scopeId, formatAuthSyncEvents(locale, this.coordinator?.getAuthSyncStatus?.() ?? null, args.slice(1).join(' ').trim() || null));
4106
+ return;
4107
+ }
4108
+ if (action === 'trace') {
4109
+ const requestId = args[1]?.trim() || null;
4110
+ if (!requestId) {
4111
+ await this.sendMessage(scopeId, t(locale, 'auth_sync_trace_missing'));
4112
+ return;
4113
+ }
4114
+ await this.sendMessage(scopeId, formatAuthSyncTrace(locale, this.coordinator?.getAuthSyncStatus?.() ?? null, requestId));
4115
+ return;
4116
+ }
4104
4117
  if (action === 'test') {
4105
4118
  const result = await this.coordinator?.authSyncTest?.();
4106
4119
  if (!result) {
@@ -8249,6 +8262,15 @@ function formatAuthSyncStatus(locale, status) {
8249
8262
  if (status.lastError) {
8250
8263
  lines.push(t(locale, 'auth_sync_status_error', { value: status.lastError }));
8251
8264
  }
8265
+ if (status.peerStatuses?.length) {
8266
+ lines.push(t(locale, 'auth_sync_status_peer_activity'));
8267
+ for (const peer of status.peerStatuses.slice(0, 5)) {
8268
+ lines.push(t(locale, 'auth_sync_status_peer_activity_item', {
8269
+ peer: peer.peer,
8270
+ time: peer.lastReceivedAt ?? t(locale, 'none'),
8271
+ }));
8272
+ }
8273
+ }
8252
8274
  if (status.candidateFailures?.length) {
8253
8275
  lines.push(t(locale, 'auth_sync_status_candidate_failures'));
8254
8276
  for (const failure of status.candidateFailures.slice(0, 5)) {
@@ -8261,8 +8283,77 @@ function formatAuthSyncStatus(locale, status) {
8261
8283
  }));
8262
8284
  }
8263
8285
  }
8286
+ const recentEvents = [...(status.recentEvents ?? [])].slice(-5);
8287
+ if (recentEvents.length > 0) {
8288
+ lines.push(t(locale, 'auth_sync_status_recent_events'));
8289
+ for (const event of recentEvents) {
8290
+ lines.push(formatAuthSyncEventLine(event));
8291
+ }
8292
+ }
8264
8293
  return lines.join('\n');
8265
8294
  }
8295
+ function formatAuthSyncEvents(locale, status, filter) {
8296
+ if (!status?.enabled) {
8297
+ return t(locale, 'auth_sync_disabled');
8298
+ }
8299
+ const events = filterAuthSyncEvents(status.recentEvents ?? [], filter).slice(-15);
8300
+ if (events.length === 0) {
8301
+ return [
8302
+ t(locale, 'auth_sync_events_title'),
8303
+ t(locale, 'auth_sync_events_empty'),
8304
+ ].join('\n');
8305
+ }
8306
+ return [
8307
+ t(locale, 'auth_sync_events_title'),
8308
+ ...events.map(formatAuthSyncEventLine),
8309
+ ].join('\n');
8310
+ }
8311
+ function formatAuthSyncTrace(locale, status, requestId) {
8312
+ if (!status?.enabled) {
8313
+ return t(locale, 'auth_sync_disabled');
8314
+ }
8315
+ const events = (status.recentEvents ?? [])
8316
+ .filter(event => event.requestId === requestId || event.id === requestId)
8317
+ .slice(-25);
8318
+ if (events.length === 0) {
8319
+ return [
8320
+ t(locale, 'auth_sync_trace_title', { value: requestId }),
8321
+ t(locale, 'auth_sync_events_empty'),
8322
+ ].join('\n');
8323
+ }
8324
+ return [
8325
+ t(locale, 'auth_sync_trace_title', { value: requestId }),
8326
+ ...events.map(formatAuthSyncEventLine),
8327
+ ].join('\n');
8328
+ }
8329
+ function filterAuthSyncEvents(events, filter) {
8330
+ const normalized = filter?.trim().toLowerCase() ?? '';
8331
+ if (!normalized) {
8332
+ return events;
8333
+ }
8334
+ return events.filter((event) => [
8335
+ event.id,
8336
+ event.requestId,
8337
+ event.candidateName,
8338
+ event.peer,
8339
+ event.kind,
8340
+ event.stage,
8341
+ event.detail,
8342
+ ].some(value => value?.toLowerCase().includes(normalized)));
8343
+ }
8344
+ function formatAuthSyncEventLine(event) {
8345
+ const fields = [
8346
+ event.createdAt,
8347
+ event.direction,
8348
+ event.kind,
8349
+ event.stage,
8350
+ event.peer ? `peer=${event.peer}` : null,
8351
+ event.requestId ? `requestId=${event.requestId}` : null,
8352
+ event.candidateName ? `candidate=${event.candidateName}` : null,
8353
+ event.detail ? truncateInline(event.detail, 160) : null,
8354
+ ].filter(Boolean);
8355
+ return `- ${fields.join(' | ')}`;
8356
+ }
8266
8357
  function normalizeHelpUsageKey(name) {
8267
8358
  const normalized = name.toLowerCase();
8268
8359
  switch (normalized) {
package/dist/i18n.d.ts CHANGED
@@ -142,7 +142,7 @@ declare const MESSAGES: {
142
142
  readonly auth_reload_done: "Codex app-server restarted. Current auth has been reloaded.";
143
143
  readonly auth_reload_blocked_active: "Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.";
144
144
  readonly usage_auth: "Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]";
145
- readonly usage_auth_sync: "Usage: /auth sync <status|test|push all>";
145
+ readonly usage_auth_sync: "Usage: /auth sync <status|events [filter]|trace <requestId>|test|push all>";
146
146
  readonly usage_auth_add: "Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.";
147
147
  readonly auth_list_title: "Codex auth files:";
148
148
  readonly auth_bot: "Bot runtime: {value}";
@@ -224,6 +224,13 @@ declare const MESSAGES: {
224
224
  readonly auth_sync_status_error: "Last error: {value}";
225
225
  readonly auth_sync_status_candidate_failures: "Candidate failures:";
226
226
  readonly auth_sync_status_candidate_failure: "- {candidate}: {reason} (source {source}, peer {peer}, at {time})";
227
+ readonly auth_sync_status_peer_activity: "Peer activity:";
228
+ readonly auth_sync_status_peer_activity_item: "- {peer}: last received {time}";
229
+ readonly auth_sync_status_recent_events: "Recent events:";
230
+ readonly auth_sync_events_title: "Auth sync events:";
231
+ readonly auth_sync_events_empty: "No matching auth sync events.";
232
+ readonly auth_sync_trace_title: "Auth sync trace: {value}";
233
+ readonly auth_sync_trace_missing: "Usage: /auth sync trace <requestId>";
227
234
  readonly button_login_device: "🔑 Login";
228
235
  readonly button_auth_reload: "🔄 Reload auth";
229
236
  readonly button_auth_refresh_all_confirm: "⚠️ Accept risk & refresh";
@@ -776,7 +783,7 @@ declare const MESSAGES: {
776
783
  readonly auth_reload_done: "Codex app-server 已重启,当前 auth 已重新读取。";
777
784
  readonly auth_reload_blocked_active: "当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。";
778
785
  readonly usage_auth: "用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]";
779
- readonly usage_auth_sync: "用法:/auth sync <status|test|push all>";
786
+ readonly usage_auth_sync: "用法:/auth sync <status|events [过滤]|trace <requestId>|test|push all>";
780
787
  readonly usage_auth_add: "用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。";
781
788
  readonly auth_list_title: "Codex auth 文件:";
782
789
  readonly auth_bot: "Bot runtime:{value}";
@@ -858,6 +865,13 @@ declare const MESSAGES: {
858
865
  readonly auth_sync_status_error: "最近错误:{value}";
859
866
  readonly auth_sync_status_candidate_failures: "候选失败:";
860
867
  readonly auth_sync_status_candidate_failure: "- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})";
868
+ readonly auth_sync_status_peer_activity: "Peer 活动:";
869
+ readonly auth_sync_status_peer_activity_item: "- {peer}:最近接收 {time}";
870
+ readonly auth_sync_status_recent_events: "最近事件:";
871
+ readonly auth_sync_events_title: "auth sync 事件:";
872
+ readonly auth_sync_events_empty: "没有匹配的 auth sync 事件。";
873
+ readonly auth_sync_trace_title: "auth sync trace:{value}";
874
+ readonly auth_sync_trace_missing: "用法:/auth sync trace <requestId>";
861
875
  readonly button_login_device: "🔑 设备登录";
862
876
  readonly button_auth_reload: "🔄 重载 auth";
863
877
  readonly button_auth_refresh_all_confirm: "⚠️ 接受风险并刷新";
package/dist/i18n.js CHANGED
@@ -140,7 +140,7 @@ const MESSAGES = {
140
140
  auth_reload_done: 'Codex app-server restarted. Current auth has been reloaded.',
141
141
  auth_reload_blocked_active: 'Cannot reload Codex auth while a turn, approval, or question is active. Wait or use /interrupt first.',
142
142
  usage_auth: 'Usage: /auth [list [keyword]|filter <all|enabled|attention>|page <n>|use <n>|enable <n>|disable <n>|reload|refresh all [confirm]|sync <status|test|push all>|add <name>]',
143
- usage_auth_sync: 'Usage: /auth sync <status|test|push all>',
143
+ usage_auth_sync: 'Usage: /auth sync <status|events [filter]|trace <requestId>|test|push all>',
144
144
  usage_auth_add: 'Usage: /auth add <name>. Use letters, numbers, dot, dash, or underscore.',
145
145
  auth_list_title: 'Codex auth files:',
146
146
  auth_bot: 'Bot runtime: {value}',
@@ -222,6 +222,13 @@ const MESSAGES = {
222
222
  auth_sync_status_error: 'Last error: {value}',
223
223
  auth_sync_status_candidate_failures: 'Candidate failures:',
224
224
  auth_sync_status_candidate_failure: '- {candidate}: {reason} (source {source}, peer {peer}, at {time})',
225
+ auth_sync_status_peer_activity: 'Peer activity:',
226
+ auth_sync_status_peer_activity_item: '- {peer}: last received {time}',
227
+ auth_sync_status_recent_events: 'Recent events:',
228
+ auth_sync_events_title: 'Auth sync events:',
229
+ auth_sync_events_empty: 'No matching auth sync events.',
230
+ auth_sync_trace_title: 'Auth sync trace: {value}',
231
+ auth_sync_trace_missing: 'Usage: /auth sync trace <requestId>',
225
232
  button_login_device: '🔑 Login',
226
233
  button_auth_reload: '🔄 Reload auth',
227
234
  button_auth_refresh_all_confirm: '⚠️ Accept risk & refresh',
@@ -774,7 +781,7 @@ const MESSAGES = {
774
781
  auth_reload_done: 'Codex app-server 已重启,当前 auth 已重新读取。',
775
782
  auth_reload_blocked_active: '当前有回复、审批或问题在进行中,不能重载 Codex auth。请先等待,或使用 /interrupt。',
776
783
  usage_auth: '用法:/auth [list [关键词]|filter <all|enabled|attention>|page <页码>|use <编号>|enable <编号>|disable <编号>|reload|refresh all [confirm]|sync <status|test|push all>|add <名称>]',
777
- usage_auth_sync: '用法:/auth sync <status|test|push all>',
784
+ usage_auth_sync: '用法:/auth sync <status|events [过滤]|trace <requestId>|test|push all>',
778
785
  usage_auth_add: '用法:/auth add <名称>。名称只能包含字母、数字、点、短横线或下划线。',
779
786
  auth_list_title: 'Codex auth 文件:',
780
787
  auth_bot: 'Bot runtime:{value}',
@@ -856,6 +863,13 @@ const MESSAGES = {
856
863
  auth_sync_status_error: '最近错误:{value}',
857
864
  auth_sync_status_candidate_failures: '候选失败:',
858
865
  auth_sync_status_candidate_failure: '- {candidate}:{reason}(来源 {source},peer {peer},时间 {time})',
866
+ auth_sync_status_peer_activity: 'Peer 活动:',
867
+ auth_sync_status_peer_activity_item: '- {peer}:最近接收 {time}',
868
+ auth_sync_status_recent_events: '最近事件:',
869
+ auth_sync_events_title: 'auth sync 事件:',
870
+ auth_sync_events_empty: '没有匹配的 auth sync 事件。',
871
+ auth_sync_trace_title: 'auth sync trace:{value}',
872
+ auth_sync_trace_missing: '用法:/auth sync trace <requestId>',
859
873
  button_login_device: '🔑 设备登录',
860
874
  button_auth_reload: '🔄 重载 auth',
861
875
  button_auth_refresh_all_confirm: '⚠️ 接受风险并刷新',
package/dist/main.js CHANGED
@@ -1132,6 +1132,7 @@ function runDoctorChecks() {
1132
1132
  warnIfProxyEnvMissingFromLoadedEnv();
1133
1133
  warnIfProxyConfigNeedsAttention();
1134
1134
  warnIfInstalledServiceNodeLooksWrong();
1135
+ warnIfSystemdUserLingerDisabled();
1135
1136
  return passed;
1136
1137
  }
1137
1138
  function warnIfProxyConfigNeedsAttention() {
@@ -1204,6 +1205,21 @@ function warnIfInstalledServiceNodeLooksWrong() {
1204
1205
  console.log(`[WARN] installed service node is older than 24: ${nodePath}${version ? ` (${version})` : ''}`);
1205
1206
  console.log('[WARN] Run foxclaw start from a Node 24 shell to refresh the service unit.');
1206
1207
  }
1208
+ function warnIfSystemdUserLingerDisabled() {
1209
+ if (process.platform !== 'linux' || !hasCommand('loginctl')) {
1210
+ return;
1211
+ }
1212
+ const user = currentServiceUser();
1213
+ const linger = readSystemdUserLinger(user);
1214
+ if (linger === 'enabled') {
1215
+ console.log(`[OK] systemd user linger enabled: ${user}`);
1216
+ return;
1217
+ }
1218
+ if (linger === 'disabled') {
1219
+ console.log(`[WARN] systemd user linger is disabled for ${user}; user services may stop after logout.`);
1220
+ console.log('[WARN] Run foxclaw start to let FoxClaw enable it, or run: sudo loginctl enable-linger "$USER"');
1221
+ }
1222
+ }
1207
1223
  function extractNodePathFromExecStart(execStart) {
1208
1224
  const tokens = execStart.split(/\s+/).map(systemdUnescape).filter(Boolean);
1209
1225
  const directNode = tokens[0] || '';
@@ -1262,6 +1278,7 @@ function installSystemd() {
1262
1278
  console.log(`[OK] updated FoxClaw ExecStart override: ${update.path}`);
1263
1279
  }
1264
1280
  }
1281
+ ensureSystemdUserLingerEnabled();
1265
1282
  spawnChecked('systemctl', ['--user', 'daemon-reload']);
1266
1283
  spawnChecked('systemctl', ['--user', 'enable', unitName]);
1267
1284
  const restarted = spawnSync('systemctl', ['--user', 'restart', unitName], { stdio: 'inherit' });
@@ -1272,6 +1289,32 @@ function installSystemd() {
1272
1289
  console.log(`Status: systemctl --user status ${unitName}`);
1273
1290
  console.log(`Logs: journalctl --user -u ${unitName} -f`);
1274
1291
  }
1292
+ function ensureSystemdUserLingerEnabled() {
1293
+ if (process.platform !== 'linux') {
1294
+ return;
1295
+ }
1296
+ if (!hasCommand('loginctl')) {
1297
+ console.log('[WARN] loginctl not found; cannot enable systemd user linger automatically.');
1298
+ return;
1299
+ }
1300
+ const user = currentServiceUser();
1301
+ const before = readSystemdUserLinger(user);
1302
+ if (before === 'enabled') {
1303
+ console.log(`[OK] systemd user linger enabled: ${user}`);
1304
+ return;
1305
+ }
1306
+ console.log(`[INFO] Enabling systemd user linger for ${user} so FoxClaw keeps running after logout.`);
1307
+ let result = spawnSync('loginctl', ['enable-linger', user], { stdio: 'inherit' });
1308
+ if (result.status !== 0 && hasCommand('sudo')) {
1309
+ result = spawnSync('sudo', ['-n', 'loginctl', 'enable-linger', user], { stdio: 'inherit' });
1310
+ }
1311
+ if (result.status === 0 && readSystemdUserLinger(user) === 'enabled') {
1312
+ console.log(`[OK] systemd user linger enabled: ${user}`);
1313
+ return;
1314
+ }
1315
+ console.log(`[WARN] Could not enable systemd user linger automatically for ${user}.`);
1316
+ console.log(`[WARN] FoxClaw is installed, but it may stop after logout until you run: sudo loginctl enable-linger ${shellQuote(user)}`);
1317
+ }
1275
1318
  function uninstallSystemd() {
1276
1319
  if (!hasCommand('systemctl')) {
1277
1320
  console.error('systemctl not found');
@@ -1392,6 +1435,36 @@ function buildServicePath(nodeDir) {
1392
1435
  function serviceEnvPath() {
1393
1436
  return path.resolve(process.env.FOXCLAW_ENV?.trim() || getLoadedEnvPath() || DEFAULT_ENV_PATH);
1394
1437
  }
1438
+ function currentServiceUser() {
1439
+ return process.env.USER?.trim()
1440
+ || process.env.LOGNAME?.trim()
1441
+ || os.userInfo().username;
1442
+ }
1443
+ function readSystemdUserLinger(user) {
1444
+ const valueResult = spawnSync('loginctl', ['show-user', user, '-p', 'Linger', '--value'], { encoding: 'utf8' });
1445
+ if (valueResult.status === 0) {
1446
+ return parseSystemdUserLinger(valueResult.stdout);
1447
+ }
1448
+ const propertyResult = spawnSync('loginctl', ['show-user', user, '-p', 'Linger'], { encoding: 'utf8' });
1449
+ if (propertyResult.status === 0) {
1450
+ return parseSystemdUserLinger(propertyResult.stdout);
1451
+ }
1452
+ return 'unknown';
1453
+ }
1454
+ function parseSystemdUserLinger(output) {
1455
+ const value = output.trim().replace(/^Linger=/, '').toLowerCase();
1456
+ if (value === 'yes')
1457
+ return 'enabled';
1458
+ if (value === 'no')
1459
+ return 'disabled';
1460
+ return 'unknown';
1461
+ }
1462
+ function shellQuote(value) {
1463
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) {
1464
+ return value;
1465
+ }
1466
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1467
+ }
1395
1468
  function hasStandardNodeProxyEnv() {
1396
1469
  return STANDARD_NODE_PROXY_ENV_KEYS.some((key) => Boolean(proxyEnvValue(key)));
1397
1470
  }
package/dist/types.d.ts CHANGED
@@ -383,6 +383,21 @@ export interface RuntimeStatus {
383
383
  updatedAt: string;
384
384
  }>;
385
385
  activeLeaseId: string | null;
386
+ peerStatuses?: Array<{
387
+ peer: string;
388
+ lastReceivedAt: string | null;
389
+ }>;
390
+ recentEvents?: Array<{
391
+ id: string;
392
+ createdAt: string;
393
+ direction: 'in' | 'out' | 'local';
394
+ kind: string;
395
+ stage: string;
396
+ peer: string | null;
397
+ requestId: string | null;
398
+ candidateName: string | null;
399
+ detail: string | null;
400
+ }>;
386
401
  } | null;
387
402
  lastUpdate?: {
388
403
  state: string;
@@ -172,6 +172,8 @@ Starting in 0.5.2, `/auth sync status` separates sync-system `Last error` from p
172
172
 
173
173
  Manual `/auth` switches and `/auth reload` recover from same-node local mirrors only and do not send cross-node pull requests. FoxClaw queries peers only during automatic recovery after it detects a real auth problem. Recovery timeout notifications include the request id, candidate name, peer list, and wait duration; if another auth sync message arrived from the same peer during that wait, the notification says the peer was reachable but this request timed out.
174
174
 
175
+ Starting in 0.5.8, `/auth sync status` also shows peer activity and the latest sync events. Use `/auth sync events [filter]` to search recent event records by candidate, peer, request id, kind, stage, or detail. Use `/auth sync trace <requestId>` when a notification includes a request id and you want the recent send/receive/result records for that request.
176
+
175
177
  If an upgrade or restart interrupts remote candidate usage validation, older versions could leave `auth.json -> .auth-sync-validate-*` behind. Starting in 0.5.2, FoxClaw checks for this at startup, restores `auth.json` to the mirror-status candidate or the newest parseable real `auth.json_*` candidate in the same directory, and removes stale validation temp files.
176
178
 
177
179
  5. Only test refresh-token rotation after you understand the risk:
@@ -197,6 +199,7 @@ With cross-node sync enabled, this command first requests a cross-node refresh l
197
199
  - The local node may not be globally idle. Active turns, approvals, inputs, login flows, and mirror writes make imports wait.
198
200
  - Usage validation failure rejects the write.
199
201
  - Same-name candidates from different account ids are refused.
202
+ - Run `/auth sync events <candidate>` or `/auth sync trace <requestId>` to see the receive, validation, skip, or failure records kept by FoxClaw.
200
203
 
201
204
  **Should I periodically run `/auth refresh all confirm` as keepalive?**
202
205
 
@@ -250,17 +250,17 @@ Create a short README-style summary of this folder.
250
250
 
251
251
  ## 10. Service Commands
252
252
 
253
- On Linux, `foxclaw start` manages a user-level systemd service. Check it with:
253
+ On Linux, `foxclaw start` manages a user-level systemd service and tries to enable systemd user linger so the service keeps running after you leave SSH or log out. Check it with:
254
254
 
255
255
  ```bash
256
256
  systemctl --user status foxclaw.service
257
257
  journalctl --user -u foxclaw.service -f
258
258
  ```
259
259
 
260
- The service starts again when your user session starts. If you need it to start after reboot before you log in, run:
260
+ If install reports that linger could not be enabled automatically, run:
261
261
 
262
262
  ```bash
263
- loginctl enable-linger "$USER"
263
+ sudo loginctl enable-linger "$USER"
264
264
  ```
265
265
 
266
266
  On macOS, `foxclaw start` manages launchd and starts FoxClaw when you log in.
@@ -261,10 +261,10 @@ Linux user systemd:
261
261
  systemctl --user is-enabled foxclaw.service
262
262
  ```
263
263
 
264
- `enabled` means it starts with your user session. To start after reboot before login:
264
+ `enabled` means it starts with your user session. `foxclaw start` tries to enable systemd user linger automatically so the service keeps running after SSH logout and before login. If automatic linger setup fails:
265
265
 
266
266
  ```bash
267
- loginctl enable-linger "$USER"
267
+ sudo loginctl enable-linger "$USER"
268
268
  ```
269
269
 
270
270
  macOS launchd starts FoxClaw when you log in after running:
@@ -423,7 +423,7 @@ OpenAI does not publish a fixed ChatGPT refresh-token lifetime or an old-token r
423
423
 
424
424
  ### 6.4 Cross-Node Auth Sync
425
425
 
426
- 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.
426
+ 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.
427
427
 
428
428
  For the full design, safety boundaries, `.env` examples, and troubleshooting, read the [Cross-Node Auth Sync Setup Guide](./cross-node-auth-sync.md).
429
429
 
@@ -464,7 +464,9 @@ Dual-active behavior:
464
464
 
465
465
  Commands:
466
466
 
467
- - `/auth sync status`: show node id, peers, recent sends/receives/imports, pending imports, the sync-system latest error, and per-candidate failures.
467
+ - `/auth sync status`: show node id, peers, peer activity, recent sync events, pending imports, the sync-system latest error, and per-candidate failures.
468
+ - `/auth sync events [filter]`: show recent sync event records, optionally filtered by candidate, peer, request id, kind, stage, or detail.
469
+ - `/auth sync trace <requestId>`: show recent records for one request id or event id.
468
470
  - `/auth sync test`: send an encrypted ping and wait for peer pong replies to verify peer config, shared key, and Bot-to-Bot private messages.
469
471
  - `/auth sync push all`: manually broadcast all locally verified candidates without refreshing tokens. “Sent” does not mean the peer imported files; check `/auth sync status` and `/auth` on the peer.
470
472
 
@@ -479,7 +481,7 @@ Equivalent commands:
479
481
  - `/auth reload` or `/auth_reload`: restart app-server and reload the current `auth.json`.
480
482
  - `/auth refresh all`: show the refresh-token rotation risk confirmation.
481
483
  - `/auth refresh all confirm`: run Refresh all after accepting the token-rotation risk.
482
- - `/auth sync status|test|push all`: inspect, test, or manually push cross-node auth sync.
484
+ - `/auth sync status|events|trace|test|push all`: inspect, trace, test, or manually push cross-node auth sync.
483
485
 
484
486
  If the requesting bot runtime has active turns, pending approvals, pending user inputs, or MCP elicitations, FoxClaw refuses manual auth switching to avoid changing accounts mid-request; another idle bot is unaffected.
485
487
 
@@ -172,6 +172,8 @@ auth sync 测试完成:已发送 1,收到回应 1。
172
172
 
173
173
  手动 `/auth` 切换和 `/auth reload` 只会尝试同节点本地 mirror 恢复,不会主动向跨节点 peer 发起 pull。只有 FoxClaw 检测到当前 auth 真的出现认证问题并进入自动恢复时,才会向 peer 查询可用副本。恢复超时通知会包含 request id、候选名、peer 列表和等待时长;如果等待期间收到过同 peer 的其他 auth sync 消息,通知会标明 peer 可达但该请求超时。
174
174
 
175
+ 从 0.5.8 起,`/auth sync status` 还会显示 peer 最近活跃时间和最近同步事件。`/auth sync events [过滤]` 可按候选名、peer、request id、事件类型、阶段或详情搜索近期事件。通知里带 request id 时,可用 `/auth sync trace <requestId>` 查看该请求最近的发送、接收和处理结果。
176
+
175
177
  如果升级或重启正好打断远端候选 usage 验证,旧版本可能留下 `auth.json -> .auth-sync-validate-*` 临时 symlink。0.5.2 起 FoxClaw 启动时会自动检测并恢复到 mirror 状态记录的候选,或同目录最近修改且可解析的真实 `auth.json_*` 候选,然后清理临时文件。
176
178
 
177
179
  5. 只有在完全理解 refresh token 轮换风险时,才测试:
@@ -197,6 +199,7 @@ auth sync 测试完成:已发送 1,收到回应 1。
197
199
  - 本机可能不是全局空闲;有 turn、审批、待输入、登录流程或镜像写入时会排队。
198
200
  - usage 验证失败会拒绝写盘。
199
201
  - 同名候选属于不同 account id 时会拒绝覆盖。
202
+ - 执行 `/auth sync events <候选名>` 或 `/auth sync trace <requestId>`,查看 FoxClaw 记录的接收、验证、跳过或失败流水。
200
203
 
201
204
  **要不要定期 `/auth refresh all confirm` 保活**
202
205
 
@@ -248,17 +248,17 @@ Create a short README-style summary of this folder.
248
248
 
249
249
  ## 10. 服务命令
250
250
 
251
- Linux 上 `foxclaw start` 管理用户级 systemd 服务。查看状态:
251
+ Linux 上 `foxclaw start` 管理用户级 systemd 服务,并会尝试启用 systemd user linger,让服务在你退出 SSH 或桌面会话后继续运行。查看状态:
252
252
 
253
253
  ```bash
254
254
  systemctl --user status foxclaw.service
255
255
  journalctl --user -u foxclaw.service -f
256
256
  ```
257
257
 
258
- 如果希望重启后未登录也能启动用户服务:
258
+ 如果安装时提示 linger 启用失败,手动执行:
259
259
 
260
260
  ```bash
261
- loginctl enable-linger "$USER"
261
+ sudo loginctl enable-linger "$USER"
262
262
  ```
263
263
 
264
264
  macOS 上 `foxclaw start` 管理 launchd,并在你登录后启动 FoxClaw。
@@ -262,10 +262,10 @@ Linux 用户级 systemd:
262
262
  systemctl --user is-enabled foxclaw.service
263
263
  ```
264
264
 
265
- `enabled` 表示会随用户会话启动。如果希望机器重启后未登录也启动用户服务:
265
+ `enabled` 表示会随用户会话启动。`foxclaw start` 会尝试自动启用 systemd user linger,让服务在退出 SSH 或未登录时也继续运行。如果自动启用失败:
266
266
 
267
267
  ```bash
268
- loginctl enable-linger "$USER"
268
+ sudo loginctl enable-linger "$USER"
269
269
  ```
270
270
 
271
271
  macOS 上,运行过下面命令后,FoxClaw 会在你登录时由 launchd 启动:
@@ -423,7 +423,7 @@ OpenAI 没有公开 ChatGPT refresh token 的固定有效期或旧 token 重放
423
423
 
424
424
  ### 6.4 跨节点 auth 同步
425
425
 
426
- 跨节点 auth 同步默认关闭。它适合你在多台自己控制的机器上使用同一组合法 ChatGPT 账号候选,并希望某台机器上 Codex 自动刷新出的新 token 能同步到其他机器。v1 使用 Telegram Bot-to-Bot 私聊传输加密文件,不需要公网 IP 或 FRP。推荐每台机器选择一个联系人 bot;同一节点内其他 bot 继续使用本机 auth 镜像。多 bot 模式下,默认联系人是 `TG_BOT_TOKENS` 的第一个 token。联系人 bot 的私聊会报告发送、接收、排队、导入、失败、恢复查询和人工介入提示;单个候选验证失败会作为“候选失败”显示,不会覆盖同步系统级最近错误。
426
+ 跨节点 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 或请求。
427
427
 
428
428
  完整设计、安全边界、`.env` 示例和排查步骤见 [跨节点 auth 同步配置指南](./cross-node-auth-sync.md)。
429
429
 
@@ -464,7 +464,9 @@ AUTH_SYNC_NODE_ID=workstation-a
464
464
 
465
465
  命令:
466
466
 
467
- - `/auth sync status`:查看 node id、peer、最近收发、最近导入、待导入、系统级最近错误和单候选失败。
467
+ - `/auth sync status`:查看 node id、peer、peer 活跃时间、最近同步事件、待导入、系统级最近错误和单候选失败。
468
+ - `/auth sync events [过滤]`:查看最近同步事件,可按候选名、peer、request id、事件类型、阶段或详情过滤。
469
+ - `/auth sync trace <requestId>`:查看某个 request id 或事件 id 的最近流水。
468
470
  - `/auth sync test`:发送加密 ping 并等待 peer 返回 pong,确认 peer、共享密钥和 Bot-to-Bot 私聊可用。
469
471
  - `/auth sync push all`:手动广播当前节点已验证的全部候选,不刷新 token;“已发送”不等于对端已经导入,需要在 peer 上看 `/auth sync status` 和 `/auth`。
470
472
 
@@ -479,7 +481,7 @@ AUTH_SYNC_NODE_ID=workstation-a
479
481
  - `/auth reload` 或 `/auth_reload`:重启 app-server,重新加载当前 `auth.json`。
480
482
  - `/auth refresh all`:显示 refresh token 轮换风险确认。
481
483
  - `/auth refresh all confirm`:接受 token 轮换风险后执行刷新全部。
482
- - `/auth sync status|test|push all`:查看、测试或手动推送跨节点 auth 同步。
484
+ - `/auth sync status|events|trace|test|push all`:查看、追踪、测试或手动推送跨节点 auth 同步。
483
485
 
484
486
  切换 auth 时,如果当前 bot runtime 还有活跃 turn、待审批、待用户输入或 MCP elicitation,FoxClaw 会先拒绝切换,避免中途换号破坏正在进行的请求;另一个空闲 bot 不受影响。
485
487
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",