@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 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;
@@ -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({ code: 'ws-close', source: 'websocket-close' });
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.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();
@@ -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
- if (!active) {
4143
- await this.startBoundTurnFromEvent(event, locale, nextPrompt);
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.isObserved) {
4147
- await this.sendMessage(scopeId, t(locale, 'watch_read_only_active'));
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.sendMessage(event.scopeId, t(locale, 'watch_read_only_active'));
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. Use approval commands if needed, or /unwatch before sending a new prompt.";
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: "当前聊天正在只读观察这轮。需要审批时可用审批命令;要发送新 prompt 请先 /unwatch。";
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. Use approval commands if needed, or /unwatch before sending a new prompt.',
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: '当前聊天正在只读观察这轮。需要审批时可用审批命令;要发送新 prompt 请先 /unwatch。',
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})。当前回复结束后我会自动发送。',
@@ -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. The watching chat is read-only for normal prompts during the observed turn. Send `/unwatch` before starting a new prompt from that chat, or wait for the turn to finish.
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
 
@@ -353,7 +353,9 @@ FoxClaw 的聊天是“绑定线程”的。你在手机上打开某个 Codex
353
353
  - `/watch <n>`:观察最近一次 `/threads` 列表里的第 n 个线程。
354
354
  - `/unwatch`:停止观察。
355
355
 
356
- 观察模式会同步 live turn 进展和审批请求。观察中的聊天默认是只读的,普通 prompt 不会直接插入正在观察的 turn;需要发新任务时先 `/unwatch`,或等当前 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
 
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.2",
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",