@foxden-app/foxclaw 0.7.0 → 0.7.2
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 +20 -0
- package/dist/codex_app/client.d.ts +3 -0
- package/dist/codex_app/client.js +16 -2
- package/dist/controller/controller.d.ts +8 -1
- package/dist/controller/controller.js +201 -27
- package/dist/i18n.d.ts +6 -2
- package/dist/i18n.js +6 -2
- package/docs/user-manual.md +3 -1
- package/docs/zh/user-manual.md +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
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.2 - 2026-08-30
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 为 Codex CLI 0.151.0 及以上版本接入原生跨客户端线程队列。在 Telegram `/watch` 观察电脑端 CLI 会话时,直接发送文字或使用 `/queue <消息>` 会把下一轮任务排入同一线程,由桌面 CLI 在当前轮结束后继续执行。
|
|
9
|
+
- 保持观察模式的 writer 安全边界:FoxClaw 不会 `resume`、中断或接管外部 CLI,`/steer` 仍明确拒绝;旧版 Codex 缺少队列接口时会提示升级,不会改写 session 文件或报告虚假成功。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Added the native cross-client thread queue available in Codex CLI 0.151.0 and later. While Telegram `/watch` observes a desktop CLI session, plain text or `/queue <message>` now queues the next task on the same thread for the desktop CLI to continue after its current turn.
|
|
13
|
+
- Preserved the writer-safety boundary of watch mode: FoxClaw does not resume, interrupt, or take over the external CLI, and `/steer` remains explicitly unavailable. Older Codex versions receive an upgrade notice instead of session-file mutation or false success.
|
|
14
|
+
|
|
15
|
+
## 0.7.1 - 2026-08-30
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- 修复 Codex app-server WebSocket 短暂断开时错误废弃活动轮次和实时预览、导致后续 Telegram 消息永久卡在队列中的问题。FoxClaw 现在会保留活动状态,等待 app-server 重新初始化后恢复线程订阅,并通过线程快照补齐断线期间遗漏的输出或完成事件,再继续处理队列。
|
|
19
|
+
- WebSocket 关闭信息现在保留真实关闭码和原因;重连恢复已在 Codex CLI 0.151.0 上通过同一 app-server 进程断线的现场验证。
|
|
20
|
+
|
|
21
|
+
### English
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
5
25
|
## 0.7.0 - 2026-08-26
|
|
6
26
|
|
|
7
27
|
### 中文
|
|
@@ -98,6 +98,9 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
98
98
|
steerTurn(threadId: string, expectedTurnId: string, input: TurnInput[]): Promise<{
|
|
99
99
|
turnId: string;
|
|
100
100
|
}>;
|
|
101
|
+
queueThreadInput(threadId: string, clientUserMessageId: string, input: TurnInput[]): Promise<{
|
|
102
|
+
queuedSubmissionId: string;
|
|
103
|
+
}>;
|
|
101
104
|
forkThread(options: {
|
|
102
105
|
threadId: string;
|
|
103
106
|
cwd: string | null;
|
package/dist/codex_app/client.js
CHANGED
|
@@ -186,6 +186,14 @@ export class CodexAppClient extends EventEmitter {
|
|
|
186
186
|
const result = await this.request('turn/steer', { threadId, expectedTurnId, input });
|
|
187
187
|
return { turnId: String(result?.turnId ?? expectedTurnId) };
|
|
188
188
|
}
|
|
189
|
+
async queueThreadInput(threadId, clientUserMessageId, input) {
|
|
190
|
+
const result = await this.request('thread/queue/add', { threadId, clientUserMessageId, input });
|
|
191
|
+
const queuedSubmissionId = result?.queuedSubmission?.id;
|
|
192
|
+
if (typeof queuedSubmissionId !== 'string' || !queuedSubmissionId) {
|
|
193
|
+
throw new Error('thread/queue/add returned no queued submission id');
|
|
194
|
+
}
|
|
195
|
+
return { queuedSubmissionId };
|
|
196
|
+
}
|
|
189
197
|
async forkThread(options) {
|
|
190
198
|
const params = {
|
|
191
199
|
threadId: options.threadId,
|
|
@@ -540,6 +548,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
540
548
|
});
|
|
541
549
|
await Promise.race([this.connectWebSocket(), spawnFailed]);
|
|
542
550
|
await this.initialize();
|
|
551
|
+
this.emit('ready');
|
|
543
552
|
}
|
|
544
553
|
async attachPersistedServer() {
|
|
545
554
|
const state = this.readServerState();
|
|
@@ -554,6 +563,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
554
563
|
try {
|
|
555
564
|
await this.connectWebSocket();
|
|
556
565
|
await this.initialize();
|
|
566
|
+
this.emit('ready');
|
|
557
567
|
this.logger.info('codex.app-server.attached', { pid: state.pid, port: state.port });
|
|
558
568
|
return true;
|
|
559
569
|
}
|
|
@@ -586,12 +596,16 @@ export class CodexAppClient extends EventEmitter {
|
|
|
586
596
|
this.socket = ws;
|
|
587
597
|
this.connected = true;
|
|
588
598
|
ws.addEventListener('message', message => this.handleMessage(String(message.data)));
|
|
589
|
-
ws.addEventListener('close', () => {
|
|
599
|
+
ws.addEventListener('close', (event) => {
|
|
590
600
|
if (this.socket !== ws) {
|
|
591
601
|
return;
|
|
592
602
|
}
|
|
593
603
|
this.socket = null;
|
|
594
|
-
this.handleDisconnect({
|
|
604
|
+
this.handleDisconnect({
|
|
605
|
+
code: event.code,
|
|
606
|
+
reason: event.reason || null,
|
|
607
|
+
source: 'websocket-close',
|
|
608
|
+
});
|
|
595
609
|
});
|
|
596
610
|
ws.addEventListener('error', err => {
|
|
597
611
|
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;
|
|
@@ -282,6 +289,7 @@ export declare class BridgeSessionCore {
|
|
|
282
289
|
private handleTakeoverCommand;
|
|
283
290
|
private handleQueueCommand;
|
|
284
291
|
private handleActiveTurnInboundMessage;
|
|
292
|
+
private queueObservedThreadMessage;
|
|
285
293
|
private queuePromptAfterActiveTurn;
|
|
286
294
|
private steerActiveTurn;
|
|
287
295
|
private enqueuePreparedTurnInput;
|
|
@@ -456,7 +464,6 @@ export declare class BridgeSessionCore {
|
|
|
456
464
|
private cleanupFinishedPreview;
|
|
457
465
|
private cleanupStaleInterruptButton;
|
|
458
466
|
private cleanupTransientPreview;
|
|
459
|
-
private abandonActiveTurns;
|
|
460
467
|
private releaseActiveTurnsForBridgeShutdown;
|
|
461
468
|
private retirePreviewMessage;
|
|
462
469
|
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();
|
|
@@ -346,6 +359,9 @@ export class BridgeSessionCore {
|
|
|
346
359
|
await this.sendMessage(scopeId, t(locale, 'auth_sync_validation_busy'));
|
|
347
360
|
return;
|
|
348
361
|
}
|
|
362
|
+
if (await this.queueObservedThreadMessage(event, locale, event.text.trim())) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
349
365
|
await this.startBoundTurnFromEvent(event, locale, event.text.trim());
|
|
350
366
|
return;
|
|
351
367
|
}
|
|
@@ -404,6 +420,9 @@ export class BridgeSessionCore {
|
|
|
404
420
|
await this.sendMessage(scopeId, t(locale, 'auth_sync_validation_busy'));
|
|
405
421
|
return;
|
|
406
422
|
}
|
|
423
|
+
if (await this.queueObservedThreadMessage(event, locale, decision.text)) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
407
426
|
await this.startBoundTurnFromEvent(event, locale, decision.text);
|
|
408
427
|
}
|
|
409
428
|
async handleCommand(event, locale, name, args) {
|
|
@@ -3802,6 +3821,120 @@ export class BridgeSessionCore {
|
|
|
3802
3821
|
}
|
|
3803
3822
|
this.observedThreadWatchers.clear();
|
|
3804
3823
|
}
|
|
3824
|
+
pauseAppSnapshotWatchers() {
|
|
3825
|
+
for (const watcher of this.observedThreadWatchers.values()) {
|
|
3826
|
+
if (watcher.mode !== 'app_snapshot') {
|
|
3827
|
+
continue;
|
|
3828
|
+
}
|
|
3829
|
+
watcher.stopped = true;
|
|
3830
|
+
if (watcher.timer) {
|
|
3831
|
+
clearTimeout(watcher.timer);
|
|
3832
|
+
watcher.timer = null;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
scheduleCodexReconnectRecovery() {
|
|
3837
|
+
const previous = this.codexReconnectRecovery ?? Promise.resolve();
|
|
3838
|
+
const recovery = previous
|
|
3839
|
+
.catch(() => undefined)
|
|
3840
|
+
.then(async () => this.recoverAfterCodexReconnect());
|
|
3841
|
+
const trackedRecovery = recovery.finally(() => {
|
|
3842
|
+
if (this.codexReconnectRecovery === trackedRecovery) {
|
|
3843
|
+
this.codexReconnectRecovery = null;
|
|
3844
|
+
}
|
|
3845
|
+
});
|
|
3846
|
+
this.codexReconnectRecovery = trackedRecovery;
|
|
3847
|
+
void this.codexReconnectRecovery.catch((error) => {
|
|
3848
|
+
this.logger.error('codex.reconnect_recovery_failed', { error: toErrorMeta(error) });
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3851
|
+
async recoverAfterCodexReconnect() {
|
|
3852
|
+
const recoveredScopes = new Set();
|
|
3853
|
+
for (const watcher of [...this.observedThreadWatchers.values()]) {
|
|
3854
|
+
if (watcher.mode !== 'app_snapshot') {
|
|
3855
|
+
continue;
|
|
3856
|
+
}
|
|
3857
|
+
try {
|
|
3858
|
+
const binding = this.store.getBinding(watcher.scopeId);
|
|
3859
|
+
const session = await this.resumeThreadForScope(watcher.scopeId, {
|
|
3860
|
+
threadId: watcher.threadId,
|
|
3861
|
+
cwd: binding?.threadId === watcher.threadId ? binding.cwd : null,
|
|
3862
|
+
});
|
|
3863
|
+
this.storeThreadSession(watcher.scopeId, session, 'seed');
|
|
3864
|
+
watcher.stopped = false;
|
|
3865
|
+
const active = watcher.activeTurnId
|
|
3866
|
+
? this.getActiveTurn(watcher.scopeId, watcher.activeTurnId)
|
|
3867
|
+
: null;
|
|
3868
|
+
if (active) {
|
|
3869
|
+
watcher.cursor = observerCursorFromActiveTurn(active);
|
|
3870
|
+
}
|
|
3871
|
+
await this.pollObservedThread(watcher);
|
|
3872
|
+
if (!watcher.stopped && this.observedThreadWatchers.get(watcher.scopeId) === watcher) {
|
|
3873
|
+
this.scheduleObservedThreadPoll(watcher);
|
|
3874
|
+
}
|
|
3875
|
+
recoveredScopes.add(watcher.scopeId);
|
|
3876
|
+
}
|
|
3877
|
+
catch (error) {
|
|
3878
|
+
watcher.stopped = false;
|
|
3879
|
+
if (this.observedThreadWatchers.get(watcher.scopeId) === watcher) {
|
|
3880
|
+
this.scheduleObservedThreadPoll(watcher);
|
|
3881
|
+
}
|
|
3882
|
+
this.logger.warn('codex.reconnect_watcher_recovery_failed', {
|
|
3883
|
+
scopeId: watcher.scopeId,
|
|
3884
|
+
threadId: watcher.threadId,
|
|
3885
|
+
error: toErrorMeta(error),
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
for (const active of [...this.activeTurns.values()]) {
|
|
3890
|
+
if (recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
|
|
3891
|
+
continue;
|
|
3892
|
+
}
|
|
3893
|
+
try {
|
|
3894
|
+
await this.recoverActiveTurnAfterCodexReconnect(active);
|
|
3895
|
+
}
|
|
3896
|
+
catch (error) {
|
|
3897
|
+
this.logger.warn('codex.reconnect_turn_recovery_failed', {
|
|
3898
|
+
scopeId: active.scopeId,
|
|
3899
|
+
threadId: active.threadId,
|
|
3900
|
+
turnId: active.turnId,
|
|
3901
|
+
error: toErrorMeta(error),
|
|
3902
|
+
});
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
await this.recoverQueuedTurns();
|
|
3906
|
+
this.updateStatus();
|
|
3907
|
+
}
|
|
3908
|
+
async recoverActiveTurnAfterCodexReconnect(active) {
|
|
3909
|
+
const binding = this.store.getBinding(active.scopeId);
|
|
3910
|
+
const session = await this.resumeThreadForScope(active.scopeId, {
|
|
3911
|
+
threadId: active.threadId,
|
|
3912
|
+
cwd: binding?.threadId === active.threadId ? binding.cwd : null,
|
|
3913
|
+
});
|
|
3914
|
+
this.storeThreadSession(active.scopeId, session, 'seed');
|
|
3915
|
+
const snapshot = await this.app.readThreadSnapshot(active.threadId);
|
|
3916
|
+
const turn = snapshot?.turns.find(candidate => candidate.turnId === active.turnId) ?? null;
|
|
3917
|
+
if (!snapshot || !turn) {
|
|
3918
|
+
throw new Error(`Active turn ${active.turnId} was not found after reconnect`);
|
|
3919
|
+
}
|
|
3920
|
+
const diff = diffObservedTurn(observerCursorFromActiveTurn(active), turn, snapshot.activeFlags.includes('waitingOnApproval'));
|
|
3921
|
+
for (const event of diff.events) {
|
|
3922
|
+
await this.handleTurnActivityEvent(event, active.scopeId);
|
|
3923
|
+
}
|
|
3924
|
+
if (diff.completed && this.getActiveTurn(active.scopeId, active.turnId)) {
|
|
3925
|
+
await this.handleTurnActivityEvent({
|
|
3926
|
+
kind: 'turn_completed',
|
|
3927
|
+
turnId: active.turnId,
|
|
3928
|
+
state: turn.status === 'interrupted' ? 'interrupted' : 'completed',
|
|
3929
|
+
}, active.scopeId);
|
|
3930
|
+
}
|
|
3931
|
+
this.logger.info('codex.reconnect_turn_recovered', {
|
|
3932
|
+
scopeId: active.scopeId,
|
|
3933
|
+
threadId: active.threadId,
|
|
3934
|
+
turnId: active.turnId,
|
|
3935
|
+
status: turn.status,
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3805
3938
|
clearObservedTurnWatcher(turnId, scopeId) {
|
|
3806
3939
|
for (const watcher of this.observedThreadWatchers.values()) {
|
|
3807
3940
|
if (scopeId && watcher.scopeId !== scopeId) {
|
|
@@ -3943,6 +4076,9 @@ export class BridgeSessionCore {
|
|
|
3943
4076
|
}
|
|
3944
4077
|
const snapshot = await this.app.readThreadSnapshot(watcher.threadId);
|
|
3945
4078
|
if (!snapshot) {
|
|
4079
|
+
if (watcher.activeTurnId && this.getActiveTurn(watcher.scopeId, watcher.activeTurnId)) {
|
|
4080
|
+
return 'active';
|
|
4081
|
+
}
|
|
3946
4082
|
await this.stopWatchingScopeThread(watcher.scopeId);
|
|
3947
4083
|
return 'idle';
|
|
3948
4084
|
}
|
|
@@ -4139,12 +4275,15 @@ export class BridgeSessionCore {
|
|
|
4139
4275
|
return;
|
|
4140
4276
|
}
|
|
4141
4277
|
const active = this.findActiveTurn(scopeId);
|
|
4142
|
-
|
|
4143
|
-
|
|
4278
|
+
const observedThreadId = active?.isObserved
|
|
4279
|
+
? active.threadId
|
|
4280
|
+
: this.observedThreadWatchers.get(scopeId)?.threadId ?? null;
|
|
4281
|
+
if (observedThreadId) {
|
|
4282
|
+
await this.queueObservedThreadMessage(event, locale, nextPrompt, observedThreadId);
|
|
4144
4283
|
return;
|
|
4145
4284
|
}
|
|
4146
|
-
if (active
|
|
4147
|
-
await this.
|
|
4285
|
+
if (!active) {
|
|
4286
|
+
await this.startBoundTurnFromEvent(event, locale, nextPrompt);
|
|
4148
4287
|
return;
|
|
4149
4288
|
}
|
|
4150
4289
|
await this.queuePromptAfterActiveTurn(event, locale, nextPrompt);
|
|
@@ -4155,7 +4294,7 @@ export class BridgeSessionCore {
|
|
|
4155
4294
|
return;
|
|
4156
4295
|
}
|
|
4157
4296
|
if (active.isObserved) {
|
|
4158
|
-
await this.
|
|
4297
|
+
await this.queueObservedThreadMessage(event, locale, text, active.threadId);
|
|
4159
4298
|
return;
|
|
4160
4299
|
}
|
|
4161
4300
|
const settings = this.store.getChatSettings(event.scopeId);
|
|
@@ -4181,6 +4320,34 @@ export class BridgeSessionCore {
|
|
|
4181
4320
|
await this.startBoundTurnFromEvent(event, locale, text);
|
|
4182
4321
|
}
|
|
4183
4322
|
}
|
|
4323
|
+
async queueObservedThreadMessage(event, locale, text, threadId = this.observedThreadWatchers.get(event.scopeId)?.threadId ?? null) {
|
|
4324
|
+
if (!threadId) {
|
|
4325
|
+
return false;
|
|
4326
|
+
}
|
|
4327
|
+
const input = await this.buildTurnInput({
|
|
4328
|
+
threadId,
|
|
4329
|
+
cwd: this.store.getBinding(event.scopeId)?.cwd ?? this.config.defaultCwd,
|
|
4330
|
+
}, { ...event, text }, locale);
|
|
4331
|
+
const clientUserMessageId = `foxclaw:${event.scopeId}:${event.messageId}`;
|
|
4332
|
+
try {
|
|
4333
|
+
const queued = await this.app.queueThreadInput(threadId, clientUserMessageId, input);
|
|
4334
|
+
await this.sendMessage(event.scopeId, t(locale, 'watch_prompt_queued', {
|
|
4335
|
+
id: shortId(queued.queuedSubmissionId),
|
|
4336
|
+
}));
|
|
4337
|
+
}
|
|
4338
|
+
catch (error) {
|
|
4339
|
+
if (!isThreadQueueUnsupportedError(error)) {
|
|
4340
|
+
throw error;
|
|
4341
|
+
}
|
|
4342
|
+
this.logger.warn('codex.observed_thread_queue_unsupported', {
|
|
4343
|
+
scopeId: event.scopeId,
|
|
4344
|
+
threadId,
|
|
4345
|
+
error: toErrorMeta(error),
|
|
4346
|
+
});
|
|
4347
|
+
await this.sendMessage(event.scopeId, t(locale, 'watch_queue_unsupported'));
|
|
4348
|
+
}
|
|
4349
|
+
return true;
|
|
4350
|
+
}
|
|
4184
4351
|
async queuePromptAfterActiveTurn(event, locale, text) {
|
|
4185
4352
|
const active = this.findActiveTurn(event.scopeId);
|
|
4186
4353
|
if (!active) {
|
|
@@ -5471,6 +5638,10 @@ export class BridgeSessionCore {
|
|
|
5471
5638
|
await this.sendMessage(scopeId, t(locale, 'no_active_turn'));
|
|
5472
5639
|
return;
|
|
5473
5640
|
}
|
|
5641
|
+
if (active.isObserved) {
|
|
5642
|
+
await this.sendMessage(scopeId, t(locale, 'watch_read_only_active'));
|
|
5643
|
+
return;
|
|
5644
|
+
}
|
|
5474
5645
|
await this.app.steerTurn(active.threadId, active.turnId, [{
|
|
5475
5646
|
type: 'text',
|
|
5476
5647
|
text,
|
|
@@ -8144,24 +8315,6 @@ export class BridgeSessionCore {
|
|
|
8144
8315
|
}
|
|
8145
8316
|
}
|
|
8146
8317
|
}
|
|
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
8318
|
releaseActiveTurnsForBridgeShutdown() {
|
|
8166
8319
|
const activeTurns = [...this.activeTurns.values()];
|
|
8167
8320
|
for (const active of activeTurns) {
|
|
@@ -10039,6 +10192,21 @@ function seedObservedTurnCursor(turn) {
|
|
|
10039
10192
|
completedItemIds: agentItems.map((item) => item.itemId),
|
|
10040
10193
|
};
|
|
10041
10194
|
}
|
|
10195
|
+
function observerCursorFromActiveTurn(active) {
|
|
10196
|
+
const itemTexts = {};
|
|
10197
|
+
const completedItemIds = [];
|
|
10198
|
+
for (const segment of active.segments) {
|
|
10199
|
+
itemTexts[segment.itemId] = segment.text;
|
|
10200
|
+
if (segment.completed) {
|
|
10201
|
+
completedItemIds.push(segment.itemId);
|
|
10202
|
+
}
|
|
10203
|
+
}
|
|
10204
|
+
return {
|
|
10205
|
+
turnId: active.turnId,
|
|
10206
|
+
itemTexts,
|
|
10207
|
+
completedItemIds,
|
|
10208
|
+
};
|
|
10209
|
+
}
|
|
10042
10210
|
function approvalKeyboard(locale, localId) {
|
|
10043
10211
|
return [[
|
|
10044
10212
|
{ text: t(locale, 'button_allow'), callback_data: `approval:${localId}:accept` },
|
|
@@ -11684,6 +11852,12 @@ function isThreadActiveWriterError(error) {
|
|
|
11684
11852
|
function isNoActiveTurnToSteerError(error) {
|
|
11685
11853
|
return error instanceof Error && /no active turn to steer/i.test(error.message);
|
|
11686
11854
|
}
|
|
11855
|
+
function isThreadQueueUnsupportedError(error) {
|
|
11856
|
+
const message = formatUserError(error).toLowerCase();
|
|
11857
|
+
return message.includes('method not found')
|
|
11858
|
+
|| message.includes('unknown method')
|
|
11859
|
+
|| message.includes('thread/queue/add') && message.includes('not supported');
|
|
11860
|
+
}
|
|
11687
11861
|
function isThreadNewCwdCreateConfirmation(text) {
|
|
11688
11862
|
return /^(y|yes|ok|okay|confirm|create|mkdir|确定|确认|创建|新建|好)$/i.test(text.trim());
|
|
11689
11863
|
}
|
package/dist/i18n.d.ts
CHANGED
|
@@ -309,7 +309,9 @@ declare const MESSAGES: {
|
|
|
309
309
|
readonly watch_already_enabled: "Already watching thread {threadId}.";
|
|
310
310
|
readonly watch_started_active: "Watching thread {threadId}. Live Codex updates from the current turn will appear here.";
|
|
311
311
|
readonly watch_started_idle: "Watching thread {threadId}. I will mirror the next live turn from Codex CLI here.";
|
|
312
|
-
readonly watch_read_only_active: "This chat is watching that turn read-only.
|
|
312
|
+
readonly watch_read_only_active: "This chat is watching that turn read-only, so this operation cannot take ownership. Send plain text or use /queue to queue the next prompt, or /unwatch to start separately.";
|
|
313
|
+
readonly watch_prompt_queued: "Queued for the watched Codex CLI session ({id}). The CLI will continue with it after the current turn.";
|
|
314
|
+
readonly watch_queue_unsupported: "This Codex version does not support cross-client session queues. Update Codex to 0.151.0 or later.";
|
|
313
315
|
readonly watch_not_enabled: "This chat is not watching any thread.";
|
|
314
316
|
readonly watch_stopped: "Stopped watching thread {threadId}.";
|
|
315
317
|
readonly queued_prompt_set: "Queued #{position} ({id}). I will send it after the current turn finishes.";
|
|
@@ -1035,7 +1037,9 @@ declare const MESSAGES: {
|
|
|
1035
1037
|
readonly watch_already_enabled: "已经在观察线程 {threadId}。";
|
|
1036
1038
|
readonly watch_started_active: "已开始观察线程 {threadId}。当前这轮 Codex 更新会同步到这里。";
|
|
1037
1039
|
readonly watch_started_idle: "已开始观察线程 {threadId}。之后 CLI 里这条线程一旦有新 live turn,我会同步到这里。";
|
|
1038
|
-
readonly watch_read_only_active: "
|
|
1040
|
+
readonly watch_read_only_active: "当前聊天正在只读观察这轮,不能接管这项操作。直接发送文字或使用 /queue 可排入下一轮;要另行启动请先 /unwatch。";
|
|
1041
|
+
readonly watch_prompt_queued: "已排入正在观察的 Codex CLI 会话({id})。当前轮结束后,CLI 会自动接着执行。";
|
|
1042
|
+
readonly watch_queue_unsupported: "当前 Codex 不支持跨客户端会话队列,请升级到 Codex 0.151.0 或更高版本。";
|
|
1039
1043
|
readonly watch_not_enabled: "当前没有正在观察的线程。";
|
|
1040
1044
|
readonly watch_stopped: "已停止观察线程 {threadId}。";
|
|
1041
1045
|
readonly queued_prompt_set: "已排队 #{position}({id})。当前回复结束后我会自动发送。";
|
package/dist/i18n.js
CHANGED
|
@@ -307,7 +307,9 @@ const MESSAGES = {
|
|
|
307
307
|
watch_already_enabled: 'Already watching thread {threadId}.',
|
|
308
308
|
watch_started_active: 'Watching thread {threadId}. Live Codex updates from the current turn will appear here.',
|
|
309
309
|
watch_started_idle: 'Watching thread {threadId}. I will mirror the next live turn from Codex CLI here.',
|
|
310
|
-
watch_read_only_active: 'This chat is watching that turn read-only.
|
|
310
|
+
watch_read_only_active: 'This chat is watching that turn read-only, so this operation cannot take ownership. Send plain text or use /queue to queue the next prompt, or /unwatch to start separately.',
|
|
311
|
+
watch_prompt_queued: 'Queued for the watched Codex CLI session ({id}). The CLI will continue with it after the current turn.',
|
|
312
|
+
watch_queue_unsupported: 'This Codex version does not support cross-client session queues. Update Codex to 0.151.0 or later.',
|
|
311
313
|
watch_not_enabled: 'This chat is not watching any thread.',
|
|
312
314
|
watch_stopped: 'Stopped watching thread {threadId}.',
|
|
313
315
|
queued_prompt_set: 'Queued #{position} ({id}). I will send it after the current turn finishes.',
|
|
@@ -1033,7 +1035,9 @@ const MESSAGES = {
|
|
|
1033
1035
|
watch_already_enabled: '已经在观察线程 {threadId}。',
|
|
1034
1036
|
watch_started_active: '已开始观察线程 {threadId}。当前这轮 Codex 更新会同步到这里。',
|
|
1035
1037
|
watch_started_idle: '已开始观察线程 {threadId}。之后 CLI 里这条线程一旦有新 live turn,我会同步到这里。',
|
|
1036
|
-
watch_read_only_active: '
|
|
1038
|
+
watch_read_only_active: '当前聊天正在只读观察这轮,不能接管这项操作。直接发送文字或使用 /queue 可排入下一轮;要另行启动请先 /unwatch。',
|
|
1039
|
+
watch_prompt_queued: '已排入正在观察的 Codex CLI 会话({id})。当前轮结束后,CLI 会自动接着执行。',
|
|
1040
|
+
watch_queue_unsupported: '当前 Codex 不支持跨客户端会话队列,请升级到 Codex 0.151.0 或更高版本。',
|
|
1037
1041
|
watch_not_enabled: '当前没有正在观察的线程。',
|
|
1038
1042
|
watch_stopped: '已停止观察线程 {threadId}。',
|
|
1039
1043
|
queued_prompt_set: '已排队 #{position}({id})。当前回复结束后我会自动发送。',
|
package/docs/user-manual.md
CHANGED
|
@@ -353,7 +353,9 @@ Usage:
|
|
|
353
353
|
- `/watch <n>`: watch item n from the latest `/threads` list.
|
|
354
354
|
- `/unwatch`: stop watching.
|
|
355
355
|
|
|
356
|
-
Watch mode mirrors live turn progress and approval requests.
|
|
356
|
+
Watch mode mirrors live turn progress and approval requests. It still does not take ownership of the CLI writer, and `/steer` cannot modify the in-flight turn. With Codex CLI 0.151.0 or later, however, plain text and `/queue <message>` use Codex's cross-client queue for the same thread. The desktop CLI automatically continues with those messages, in order, after the current turn finishes.
|
|
357
|
+
|
|
358
|
+
Older Codex versions do not expose this queue API. FoxClaw reports the required upgrade instead of falling back to writer takeover or session-file mutation. Use `/unwatch` first only when you want Telegram to start a separate turn itself.
|
|
357
359
|
|
|
358
360
|
## 6. Codex Login And Auth Rotation
|
|
359
361
|
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -353,7 +353,9 @@ FoxClaw 的聊天是“绑定线程”的。你在手机上打开某个 Codex
|
|
|
353
353
|
- `/watch <n>`:观察最近一次 `/threads` 列表里的第 n 个线程。
|
|
354
354
|
- `/unwatch`:停止观察。
|
|
355
355
|
|
|
356
|
-
观察模式会同步 live turn
|
|
356
|
+
观察模式会同步 live turn 进展和审批请求。观察中的聊天仍然不接管 CLI writer,也不能用 `/steer` 修改正在执行的 turn;但从 Codex CLI 0.151.0 开始,直接发送文字或使用 `/queue <消息>` 会通过 Codex 的跨客户端队列排入同一线程,由桌面 CLI 在当前 turn 结束后自动继续执行。连续发送多条会按顺序排队。
|
|
357
|
+
|
|
358
|
+
旧版 Codex 没有这个队列接口,FoxClaw 会明确提示升级,不会退回到抢占 writer 或改写 session 文件。要让 Telegram 自己另行启动 turn,仍需先 `/unwatch`。
|
|
357
359
|
|
|
358
360
|
## 6. Codex 登录和 auth 轮转
|
|
359
361
|
|