@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 +10 -0
- package/dist/codex_app/client.js +8 -2
- package/dist/controller/controller.d.ts +7 -1
- package/dist/controller/controller.js +149 -22
- package/package.json +1 -1
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
|
### 中文
|
package/dist/codex_app/client.js
CHANGED
|
@@ -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({
|
|
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.
|
|
222
|
-
|
|
223
|
-
this.
|
|
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` },
|