@foxden-app/foxclaw 0.7.0 → 0.7.1

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.7.1 - 2026-08-30
6
+
7
+ ### 中文
8
+ - 修复 Codex app-server WebSocket 短暂断开时错误废弃活动轮次和实时预览、导致后续 Telegram 消息永久卡在队列中的问题。FoxClaw 现在会保留活动状态,等待 app-server 重新初始化后恢复线程订阅,并通过线程快照补齐断线期间遗漏的输出或完成事件,再继续处理队列。
9
+ - WebSocket 关闭信息现在保留真实关闭码和原因;重连恢复已在 Codex CLI 0.151.0 上通过同一 app-server 进程断线的现场验证。
10
+
11
+ ### English
12
+ - Fixed transient Codex app-server WebSocket disconnects abandoning active turns and live previews, which could leave later Telegram messages permanently queued. FoxClaw now preserves active state, restores the thread subscription after app-server initialization, reconciles output or completion events missed during the disconnect from the thread snapshot, and then continues the queue.
13
+ - WebSocket close metadata now preserves the actual close code and reason. Reconnect recovery was live-verified against Codex CLI 0.151.0 with the same app-server process disconnecting and reattaching.
14
+
5
15
  ## 0.7.0 - 2026-08-26
6
16
 
7
17
  ### 中文
@@ -540,6 +540,7 @@ export class CodexAppClient extends EventEmitter {
540
540
  });
541
541
  await Promise.race([this.connectWebSocket(), spawnFailed]);
542
542
  await this.initialize();
543
+ this.emit('ready');
543
544
  }
544
545
  async attachPersistedServer() {
545
546
  const state = this.readServerState();
@@ -554,6 +555,7 @@ export class CodexAppClient extends EventEmitter {
554
555
  try {
555
556
  await this.connectWebSocket();
556
557
  await this.initialize();
558
+ this.emit('ready');
557
559
  this.logger.info('codex.app-server.attached', { pid: state.pid, port: state.port });
558
560
  return true;
559
561
  }
@@ -586,12 +588,16 @@ export class CodexAppClient extends EventEmitter {
586
588
  this.socket = ws;
587
589
  this.connected = true;
588
590
  ws.addEventListener('message', message => this.handleMessage(String(message.data)));
589
- ws.addEventListener('close', () => {
591
+ ws.addEventListener('close', (event) => {
590
592
  if (this.socket !== ws) {
591
593
  return;
592
594
  }
593
595
  this.socket = null;
594
- this.handleDisconnect({ code: 'ws-close', source: 'websocket-close' });
596
+ this.handleDisconnect({
597
+ code: event.code,
598
+ reason: event.reason || null,
599
+ source: 'websocket-close',
600
+ });
595
601
  });
596
602
  ws.addEventListener('error', err => {
597
603
  this.logger.warn('codex.ws.error', String(err.message ?? 'unknown'));
@@ -100,6 +100,9 @@ export declare class BridgeSessionCore {
100
100
  private proactiveAuthRefreshStatus;
101
101
  private stalePanelDeleteTimers;
102
102
  private attachedThreads;
103
+ private codexReconnectPending;
104
+ private codexReconnectRecovery;
105
+ private stopping;
103
106
  private botUsername;
104
107
  private lastError;
105
108
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
@@ -268,6 +271,10 @@ export declare class BridgeSessionCore {
268
271
  private localeForChat;
269
272
  private findActiveTurn;
270
273
  private clearObservedThreadWatchers;
274
+ private pauseAppSnapshotWatchers;
275
+ private scheduleCodexReconnectRecovery;
276
+ private recoverAfterCodexReconnect;
277
+ private recoverActiveTurnAfterCodexReconnect;
271
278
  private clearObservedTurnWatcher;
272
279
  private stopWatchingScopeThread;
273
280
  private forgetStaleActiveTurn;
@@ -456,7 +463,6 @@ export declare class BridgeSessionCore {
456
463
  private cleanupFinishedPreview;
457
464
  private cleanupStaleInterruptButton;
458
465
  private cleanupTransientPreview;
459
- private abandonActiveTurns;
460
466
  private releaseActiveTurnsForBridgeShutdown;
461
467
  private retirePreviewMessage;
462
468
  private forgetPreviewRecord;
@@ -156,6 +156,9 @@ export class BridgeSessionCore {
156
156
  proactiveAuthRefreshStatus = null;
157
157
  stalePanelDeleteTimers = new Map();
158
158
  attachedThreads = new Set();
159
+ codexReconnectPending = false;
160
+ codexReconnectRecovery = null;
161
+ stopping = false;
159
162
  botUsername = null;
160
163
  lastError = null;
161
164
  /** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
@@ -200,6 +203,7 @@ export class BridgeSessionCore {
200
203
  }
201
204
  /** Start Codex app-server transport and attach RPC listeners. */
202
205
  async startCodexApp() {
206
+ this.stopping = false;
203
207
  this.app.on('notification', (msg) => {
204
208
  void this.handleNotification(msg).catch((error) => {
205
209
  void this.handleAsyncError('codex.notification', error);
@@ -215,13 +219,20 @@ export class BridgeSessionCore {
215
219
  this.lastError = null;
216
220
  this.updateStatus();
217
221
  });
222
+ this.app.on('ready', () => {
223
+ if (!this.codexReconnectPending || this.stopping) {
224
+ return;
225
+ }
226
+ this.codexReconnectPending = false;
227
+ this.scheduleCodexReconnectRecovery();
228
+ });
218
229
  this.app.on('disconnected', () => {
219
230
  this.attachedThreads.clear();
220
231
  this.threadTokenUsageAlerts.clear();
221
- this.clearObservedThreadWatchers();
222
- void this.abandonActiveTurns().catch((error) => {
223
- this.logger.error('codex.disconnect_cleanup_failed', { error: toErrorMeta(error) });
224
- });
232
+ if (!this.stopping) {
233
+ this.codexReconnectPending = true;
234
+ this.pauseAppSnapshotWatchers();
235
+ }
225
236
  this.updateStatus();
226
237
  });
227
238
  await this.app.start();
@@ -252,6 +263,8 @@ export class BridgeSessionCore {
252
263
  await this.startTelegramPolling();
253
264
  }
254
265
  async stop() {
266
+ this.stopping = true;
267
+ this.codexReconnectPending = false;
255
268
  this.pendingTurnErrors.clear();
256
269
  this.pendingUserInputs.clear();
257
270
  this.pendingMcpElicitations.clear();
@@ -3802,6 +3815,120 @@ export class BridgeSessionCore {
3802
3815
  }
3803
3816
  this.observedThreadWatchers.clear();
3804
3817
  }
3818
+ pauseAppSnapshotWatchers() {
3819
+ for (const watcher of this.observedThreadWatchers.values()) {
3820
+ if (watcher.mode !== 'app_snapshot') {
3821
+ continue;
3822
+ }
3823
+ watcher.stopped = true;
3824
+ if (watcher.timer) {
3825
+ clearTimeout(watcher.timer);
3826
+ watcher.timer = null;
3827
+ }
3828
+ }
3829
+ }
3830
+ scheduleCodexReconnectRecovery() {
3831
+ const previous = this.codexReconnectRecovery ?? Promise.resolve();
3832
+ const recovery = previous
3833
+ .catch(() => undefined)
3834
+ .then(async () => this.recoverAfterCodexReconnect());
3835
+ const trackedRecovery = recovery.finally(() => {
3836
+ if (this.codexReconnectRecovery === trackedRecovery) {
3837
+ this.codexReconnectRecovery = null;
3838
+ }
3839
+ });
3840
+ this.codexReconnectRecovery = trackedRecovery;
3841
+ void this.codexReconnectRecovery.catch((error) => {
3842
+ this.logger.error('codex.reconnect_recovery_failed', { error: toErrorMeta(error) });
3843
+ });
3844
+ }
3845
+ async recoverAfterCodexReconnect() {
3846
+ const recoveredScopes = new Set();
3847
+ for (const watcher of [...this.observedThreadWatchers.values()]) {
3848
+ if (watcher.mode !== 'app_snapshot') {
3849
+ continue;
3850
+ }
3851
+ try {
3852
+ const binding = this.store.getBinding(watcher.scopeId);
3853
+ const session = await this.resumeThreadForScope(watcher.scopeId, {
3854
+ threadId: watcher.threadId,
3855
+ cwd: binding?.threadId === watcher.threadId ? binding.cwd : null,
3856
+ });
3857
+ this.storeThreadSession(watcher.scopeId, session, 'seed');
3858
+ watcher.stopped = false;
3859
+ const active = watcher.activeTurnId
3860
+ ? this.getActiveTurn(watcher.scopeId, watcher.activeTurnId)
3861
+ : null;
3862
+ if (active) {
3863
+ watcher.cursor = observerCursorFromActiveTurn(active);
3864
+ }
3865
+ await this.pollObservedThread(watcher);
3866
+ if (!watcher.stopped && this.observedThreadWatchers.get(watcher.scopeId) === watcher) {
3867
+ this.scheduleObservedThreadPoll(watcher);
3868
+ }
3869
+ recoveredScopes.add(watcher.scopeId);
3870
+ }
3871
+ catch (error) {
3872
+ watcher.stopped = false;
3873
+ if (this.observedThreadWatchers.get(watcher.scopeId) === watcher) {
3874
+ this.scheduleObservedThreadPoll(watcher);
3875
+ }
3876
+ this.logger.warn('codex.reconnect_watcher_recovery_failed', {
3877
+ scopeId: watcher.scopeId,
3878
+ threadId: watcher.threadId,
3879
+ error: toErrorMeta(error),
3880
+ });
3881
+ }
3882
+ }
3883
+ for (const active of [...this.activeTurns.values()]) {
3884
+ if (recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
3885
+ continue;
3886
+ }
3887
+ try {
3888
+ await this.recoverActiveTurnAfterCodexReconnect(active);
3889
+ }
3890
+ catch (error) {
3891
+ this.logger.warn('codex.reconnect_turn_recovery_failed', {
3892
+ scopeId: active.scopeId,
3893
+ threadId: active.threadId,
3894
+ turnId: active.turnId,
3895
+ error: toErrorMeta(error),
3896
+ });
3897
+ }
3898
+ }
3899
+ await this.recoverQueuedTurns();
3900
+ this.updateStatus();
3901
+ }
3902
+ async recoverActiveTurnAfterCodexReconnect(active) {
3903
+ const binding = this.store.getBinding(active.scopeId);
3904
+ const session = await this.resumeThreadForScope(active.scopeId, {
3905
+ threadId: active.threadId,
3906
+ cwd: binding?.threadId === active.threadId ? binding.cwd : null,
3907
+ });
3908
+ this.storeThreadSession(active.scopeId, session, 'seed');
3909
+ const snapshot = await this.app.readThreadSnapshot(active.threadId);
3910
+ const turn = snapshot?.turns.find(candidate => candidate.turnId === active.turnId) ?? null;
3911
+ if (!snapshot || !turn) {
3912
+ throw new Error(`Active turn ${active.turnId} was not found after reconnect`);
3913
+ }
3914
+ const diff = diffObservedTurn(observerCursorFromActiveTurn(active), turn, snapshot.activeFlags.includes('waitingOnApproval'));
3915
+ for (const event of diff.events) {
3916
+ await this.handleTurnActivityEvent(event, active.scopeId);
3917
+ }
3918
+ if (diff.completed && this.getActiveTurn(active.scopeId, active.turnId)) {
3919
+ await this.handleTurnActivityEvent({
3920
+ kind: 'turn_completed',
3921
+ turnId: active.turnId,
3922
+ state: turn.status === 'interrupted' ? 'interrupted' : 'completed',
3923
+ }, active.scopeId);
3924
+ }
3925
+ this.logger.info('codex.reconnect_turn_recovered', {
3926
+ scopeId: active.scopeId,
3927
+ threadId: active.threadId,
3928
+ turnId: active.turnId,
3929
+ status: turn.status,
3930
+ });
3931
+ }
3805
3932
  clearObservedTurnWatcher(turnId, scopeId) {
3806
3933
  for (const watcher of this.observedThreadWatchers.values()) {
3807
3934
  if (scopeId && watcher.scopeId !== scopeId) {
@@ -3943,6 +4070,9 @@ export class BridgeSessionCore {
3943
4070
  }
3944
4071
  const snapshot = await this.app.readThreadSnapshot(watcher.threadId);
3945
4072
  if (!snapshot) {
4073
+ if (watcher.activeTurnId && this.getActiveTurn(watcher.scopeId, watcher.activeTurnId)) {
4074
+ return 'active';
4075
+ }
3946
4076
  await this.stopWatchingScopeThread(watcher.scopeId);
3947
4077
  return 'idle';
3948
4078
  }
@@ -8144,24 +8274,6 @@ export class BridgeSessionCore {
8144
8274
  }
8145
8275
  }
8146
8276
  }
8147
- async abandonActiveTurns() {
8148
- const activeTurns = [...this.activeTurns.values()];
8149
- for (const active of activeTurns) {
8150
- this.clearToolBatchTimer(active.toolBatch);
8151
- this.clearRenderRetry(active);
8152
- if (active.previewActive) {
8153
- await this.retirePreviewMessage(active.scopeId, active.previewMessageId, t(this.localeForChat(active.scopeId), 'stale_preview_expired'), active.turnId);
8154
- }
8155
- if (active.queuedInputId) {
8156
- this.store.updateQueuedTurnInputStatus(active.queuedInputId, 'queued');
8157
- }
8158
- active.resolver();
8159
- this.deleteActiveTurnRecord(active);
8160
- }
8161
- if (activeTurns.length > 0) {
8162
- this.updateStatus();
8163
- }
8164
- }
8165
8277
  releaseActiveTurnsForBridgeShutdown() {
8166
8278
  const activeTurns = [...this.activeTurns.values()];
8167
8279
  for (const active of activeTurns) {
@@ -10039,6 +10151,21 @@ function seedObservedTurnCursor(turn) {
10039
10151
  completedItemIds: agentItems.map((item) => item.itemId),
10040
10152
  };
10041
10153
  }
10154
+ function observerCursorFromActiveTurn(active) {
10155
+ const itemTexts = {};
10156
+ const completedItemIds = [];
10157
+ for (const segment of active.segments) {
10158
+ itemTexts[segment.itemId] = segment.text;
10159
+ if (segment.completed) {
10160
+ completedItemIds.push(segment.itemId);
10161
+ }
10162
+ }
10163
+ return {
10164
+ turnId: active.turnId,
10165
+ itemTexts,
10166
+ completedItemIds,
10167
+ };
10168
+ }
10042
10169
  function approvalKeyboard(locale, localId) {
10043
10170
  return [[
10044
10171
  { text: t(locale, 'button_allow'), callback_data: `approval:${localId}:accept` },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Foxden local execution claw for controlling Codex and OpenCode from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",