@foxden-app/foxclaw 0.6.6 → 0.6.8

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.6.8 - 2026-08-15
6
+
7
+ ### 中文
8
+ - 修复只读观察 Codex CLI 0.147.0 会话时只显示“正在思考...”和“已完成。”、遗漏实际回复的问题。会话日志观察器现在识别新的 `item_completed` / `AgentMessage` 结构,并沿用 Codex 消息 ID,避免同一正文与 `response_item` 镜像重复发送。
9
+ - 识别新日志中的 `turn_aborted` 和 `turn_interrupted`,让被中断的外部 CLI 轮次明确结束为“已中断”,不再残留为进行中状态。
10
+
11
+ ### English
12
+ - Fixed read-only observation of Codex CLI 0.147.0 sessions showing only “Thinking...” and “Completed.” while dropping the actual response. The session-log observer now recognizes the new `item_completed` / `AgentMessage` shape and preserves Codex message IDs without duplicating the mirrored `response_item`.
13
+ - Recognizes `turn_aborted` and `turn_interrupted` in new session logs so interrupted external CLI turns finish explicitly instead of remaining active.
14
+
15
+ ## 0.6.7 - 2026-08-12
16
+
17
+ ### 中文
18
+ - 修复从 `/threads` 观察正在由 Codex CLI 执行的线程时错误调用 `thread/resume`、抢占 writer 并报 `already has an active writer` 的问题。观察现在只读绑定线程,并直接尾随 CLI session 文件。
19
+ - 打开被其他 Codex 客户端占用的线程时,仅对明确的 active-writer 冲突降级为只读选中并提示使用 `/watch`;其他错误仍然显式失败。
20
+
21
+ ### English
22
+ - Fixed `/threads` Watch attempting `thread/resume` for a thread currently running in Codex CLI, which tried to acquire its writer and failed with `already has an active writer`. Watch now binds read-only and tails the CLI session file directly.
23
+ - Opening a thread owned by another Codex client now falls back to an explicit read-only selection with a `/watch` hint only for the specific active-writer conflict. Other errors still fail visibly.
24
+
5
25
  ## 0.6.6 - 2026-08-10
6
26
 
7
27
  ### 中文
@@ -259,6 +259,7 @@ export declare class BridgeSessionCore {
259
259
  private expireApproval;
260
260
  private tryRevealThread;
261
261
  private bindCachedThread;
262
+ private bindCachedThreadReadOnly;
262
263
  private resumeThreadForScope;
263
264
  private setChatAccessPreset;
264
265
  private clearAttachedThreadsForScope;
@@ -650,6 +650,7 @@ export class BridgeSessionCore {
650
650
  this.store.cancelQueuedTurnInputs(scopeId);
651
651
  await this.stopWatchingScopeThread(scopeId, thread.threadId);
652
652
  let binding;
653
+ let readOnly = false;
653
654
  try {
654
655
  binding = await this.bindCachedThread(scopeId, thread.threadId);
655
656
  }
@@ -658,7 +659,10 @@ export class BridgeSessionCore {
658
659
  await this.sendMessage(scopeId, t(locale, 'cached_thread_unavailable'));
659
660
  return;
660
661
  }
661
- throw error;
662
+ if (!isThreadActiveWriterError(error))
663
+ throw error;
664
+ binding = this.bindCachedThreadReadOnly(scopeId, thread);
665
+ readOnly = true;
662
666
  }
663
667
  const settings = this.store.getChatSettings(scopeId);
664
668
  const lines = [
@@ -668,7 +672,10 @@ export class BridgeSessionCore {
668
672
  t(locale, 'status_configured_effort', { value: settings?.reasoningEffort ?? t(locale, 'server_default') }),
669
673
  t(locale, 'line_cwd', { value: binding.cwd ?? this.config.defaultCwd }),
670
674
  ];
671
- if (this.config.codexAppSyncOnOpen) {
675
+ if (readOnly) {
676
+ lines.push(t(locale, 'thread_active_writer_read_only'));
677
+ }
678
+ if (!readOnly && this.config.codexAppSyncOnOpen) {
672
679
  const revealError = await this.tryRevealThread(scopeId, binding.threadId, 'open');
673
680
  lines.push(revealError ? t(locale, 'codex_sync_failed', { error: revealError }) : t(locale, 'opened_in_codex'));
674
681
  }
@@ -981,16 +988,7 @@ export class BridgeSessionCore {
981
988
  await this.sendMessage(scopeId, t(locale, 'thread_is_archived_use_unarchive'));
982
989
  return;
983
990
  }
984
- try {
985
- binding = await this.bindCachedThread(scopeId, cached.threadId);
986
- }
987
- catch (error) {
988
- if (isThreadNotFoundError(error)) {
989
- await this.sendMessage(scopeId, t(locale, 'cached_thread_unavailable'));
990
- return;
991
- }
992
- throw error;
993
- }
991
+ binding = this.bindCachedThreadReadOnly(scopeId, cached);
994
992
  }
995
993
  else {
996
994
  binding = this.store.getBinding(scopeId);
@@ -3721,6 +3719,18 @@ export class BridgeSessionCore {
3721
3719
  const session = await this.resumeThreadForScope(scopeId, { threadId, cwd: null });
3722
3720
  return this.storeThreadSession(scopeId, session, 'replace');
3723
3721
  }
3722
+ bindCachedThreadReadOnly(scopeId, thread) {
3723
+ const binding = {
3724
+ chatId: scopeId,
3725
+ threadId: thread.threadId,
3726
+ cwd: thread.cwd,
3727
+ updatedAt: Date.now(),
3728
+ };
3729
+ this.store.setBinding(scopeId, binding.threadId, binding.cwd);
3730
+ this.attachedThreads.delete(attachedThreadKey(scopeId, binding.threadId));
3731
+ this.updateStatus();
3732
+ return binding;
3733
+ }
3724
3734
  async resumeThreadForScope(scopeId, binding) {
3725
3735
  const settings = this.store.getChatSettings(scopeId);
3726
3736
  const access = this.resolveEffectiveAccess(scopeId, settings);
@@ -7007,6 +7017,7 @@ export class BridgeSessionCore {
7007
7017
  }
7008
7018
  await this.stopWatchingScopeThread(scopeId, threadId);
7009
7019
  let binding;
7020
+ let readOnly = false;
7010
7021
  try {
7011
7022
  binding = await this.bindCachedThread(scopeId, threadId);
7012
7023
  }
@@ -7015,7 +7026,10 @@ export class BridgeSessionCore {
7015
7026
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'thread_no_longer_available'));
7016
7027
  return;
7017
7028
  }
7018
- throw error;
7029
+ if (!cached || !isThreadActiveWriterError(error))
7030
+ throw error;
7031
+ binding = this.bindCachedThreadReadOnly(scopeId, cached);
7032
+ readOnly = true;
7019
7033
  }
7020
7034
  const threads = this.store.listCachedThreads(scopeId);
7021
7035
  if (threads.length > 0) {
@@ -7046,8 +7060,8 @@ export class BridgeSessionCore {
7046
7060
  await this.editHtmlMessage(scopeId, event.messageId, text, keyboard);
7047
7061
  this.scheduleStalePanelDeletion(scopeId, event.messageId);
7048
7062
  }
7049
- let callbackText = t(locale, 'thread_opened');
7050
- if (this.config.codexAppSyncOnOpen) {
7063
+ let callbackText = readOnly ? t(locale, 'thread_opened_read_only') : t(locale, 'thread_opened');
7064
+ if (!readOnly && this.config.codexAppSyncOnOpen) {
7051
7065
  const revealError = await this.tryRevealThread(scopeId, binding.threadId, 'open');
7052
7066
  callbackText = revealError ? t(locale, 'opened_sync_failed_short') : t(locale, 'opened_in_codex_short');
7053
7067
  }
@@ -7080,17 +7094,7 @@ export class BridgeSessionCore {
7080
7094
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'thread_is_archived_use_unarchive'));
7081
7095
  return;
7082
7096
  }
7083
- let binding;
7084
- try {
7085
- binding = await this.bindCachedThread(scopeId, threadId);
7086
- }
7087
- catch (error) {
7088
- if (isThreadNotFoundError(error)) {
7089
- await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'thread_no_longer_available'));
7090
- return;
7091
- }
7092
- throw error;
7093
- }
7097
+ const binding = this.bindCachedThreadReadOnly(scopeId, cached);
7094
7098
  const target = resolveScopeMessageTarget(scopeId);
7095
7099
  if (!target) {
7096
7100
  await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
@@ -11674,6 +11678,9 @@ function formatShortStatusError(error) {
11674
11678
  function isThreadNotFoundError(error) {
11675
11679
  return error instanceof Error && /(thread not found|no rollout found for thread id)/i.test(error.message);
11676
11680
  }
11681
+ function isThreadActiveWriterError(error) {
11682
+ return error instanceof Error && /thread\s+\S+\s+already has an active writer/i.test(error.message);
11683
+ }
11677
11684
  function isNoActiveTurnToSteerError(error) {
11678
11685
  return error instanceof Error && /no active turn to steer/i.test(error.message);
11679
11686
  }
@@ -104,6 +104,16 @@ function applySessionRecord(record, cursor) {
104
104
  if (type === 'event_msg' && payload?.type === 'agent_message' && typeof payload.message === 'string') {
105
105
  return createSessionTextEvents(activeTurnId, cursor, payload.message, typeof payload.phase === 'string' ? payload.phase : null, false);
106
106
  }
107
+ if (type === 'event_msg'
108
+ && payload?.type === 'item_completed'
109
+ && payload?.turn_id === activeTurnId
110
+ && normalizeSessionItemType(payload?.item) === 'agentmessage') {
111
+ const text = extractSessionItemText(payload.item);
112
+ if (text === null) {
113
+ return { cursor, events: [], startedTurnId: null, turnCompleted: false };
114
+ }
115
+ return createSessionTextEvents(activeTurnId, cursor, text, typeof payload.item.phase === 'string' ? payload.item.phase : null, false, false, typeof payload.item.id === 'string' ? payload.item.id : null);
116
+ }
107
117
  if (type === 'response_item' && payload?.type === 'plan' && typeof payload.text === 'string') {
108
118
  return createSessionTextEvents(activeTurnId, cursor, payload.text, 'commentary', true, true);
109
119
  }
@@ -169,6 +179,20 @@ function applySessionRecord(record, cursor) {
169
179
  turnCompleted: true,
170
180
  };
171
181
  }
182
+ if (type === 'event_msg'
183
+ && (payload?.type === 'turn_aborted' || payload?.type === 'turn_interrupted')
184
+ && payload?.turn_id === activeTurnId) {
185
+ return {
186
+ cursor: { activeTurnId: null, nextMessageIndex: 0 },
187
+ events: [{
188
+ kind: 'turn_completed',
189
+ turnId: activeTurnId,
190
+ state: 'interrupted',
191
+ }],
192
+ startedTurnId: null,
193
+ turnCompleted: true,
194
+ };
195
+ }
172
196
  return {
173
197
  cursor,
174
198
  events: [],
@@ -179,8 +203,8 @@ function applySessionRecord(record, cursor) {
179
203
  function buildSessionItemId(turnId, index) {
180
204
  return `${turnId}:session:${index}`;
181
205
  }
182
- function createSessionTextEvents(activeTurnId, cursor, text, phase, forceCommentary, isPlan = false) {
183
- const itemId = buildSessionItemId(activeTurnId, cursor.nextMessageIndex + 1);
206
+ function createSessionTextEvents(activeTurnId, cursor, text, phase, forceCommentary, isPlan = false, sourceItemId = null) {
207
+ const itemId = sourceItemId ?? buildSessionItemId(activeTurnId, cursor.nextMessageIndex + 1);
184
208
  const outputKind = forceCommentary ? 'commentary' : classifyAgentOutput(phase, true);
185
209
  const streamOutputKind = forceCommentary ? 'commentary' : classifyAgentOutput(phase, false);
186
210
  return {
@@ -219,6 +243,24 @@ function createSessionTextEvents(activeTurnId, cursor, text, phase, forceComment
219
243
  turnCompleted: false,
220
244
  };
221
245
  }
246
+ function normalizeSessionItemType(item) {
247
+ if (typeof item?.type !== 'string') {
248
+ return null;
249
+ }
250
+ return item.type.replace(/[^a-z]/gi, '').toLowerCase();
251
+ }
252
+ function extractSessionItemText(item) {
253
+ if (typeof item?.text === 'string') {
254
+ return item.text;
255
+ }
256
+ if (!Array.isArray(item?.content)) {
257
+ return null;
258
+ }
259
+ const parts = item.content
260
+ .map((entry) => typeof entry?.text === 'string' ? entry.text : '')
261
+ .filter(Boolean);
262
+ return parts.length > 0 ? parts.join('') : null;
263
+ }
222
264
  function createExecStartEvent(turnId, payload) {
223
265
  const callId = typeof payload.call_id === 'string' ? payload.call_id : null;
224
266
  if (!callId) {
package/dist/i18n.d.ts CHANGED
@@ -325,6 +325,7 @@ declare const MESSAGES: {
325
325
  readonly attachment_batch_button_analyze: "Analyze";
326
326
  readonly attachment_batch_button_clear: "Clear";
327
327
  readonly bound_to_thread: "Bound to thread {threadId}";
328
+ readonly thread_active_writer_read_only: "Another Codex client is currently writing this thread. It is selected read-only; use /watch to mirror live output.";
328
329
  readonly line_title: "Title: {value}";
329
330
  readonly line_cwd: "CWD: {value}";
330
331
  readonly codex_sync_failed: "Codex.app sync failed: {error}";
@@ -407,6 +408,7 @@ declare const MESSAGES: {
407
408
  readonly fast_configured: "Fast: {value}";
408
409
  readonly thread_no_longer_available: "Thread is no longer available";
409
410
  readonly thread_opened: "Thread opened";
411
+ readonly thread_opened_read_only: "Selected read-only; tap Watch for live output";
410
412
  readonly opened_sync_failed_short: "Opened, Codex.app sync failed";
411
413
  readonly opened_in_codex_short: "Opened in Codex.app";
412
414
  readonly turn_already_finished: "Turn already finished";
@@ -1049,6 +1051,7 @@ declare const MESSAGES: {
1049
1051
  readonly attachment_batch_button_analyze: "分析";
1050
1052
  readonly attachment_batch_button_clear: "清除";
1051
1053
  readonly bound_to_thread: "已绑定到线程 {threadId}";
1054
+ readonly thread_active_writer_read_only: "另一个 Codex 客户端正在写入这个线程。现已只读选中;使用 /watch 可同步实时输出。";
1052
1055
  readonly line_title: "标题:{value}";
1053
1056
  readonly line_cwd: "工作目录:{value}";
1054
1057
  readonly codex_sync_failed: "同步到 Codex.app 失败:{error}";
@@ -1131,6 +1134,7 @@ declare const MESSAGES: {
1131
1134
  readonly fast_configured: "Fast:{value}";
1132
1135
  readonly thread_no_longer_available: "这个线程已经不可用";
1133
1136
  readonly thread_opened: "线程已打开";
1137
+ readonly thread_opened_read_only: "已只读选中;点观察可同步输出";
1134
1138
  readonly opened_sync_failed_short: "已打开,但同步到 Codex.app 失败";
1135
1139
  readonly opened_in_codex_short: "已在 Codex.app 中打开";
1136
1140
  readonly turn_already_finished: "这轮回复已经结束了";
package/dist/i18n.js CHANGED
@@ -323,6 +323,7 @@ const MESSAGES = {
323
323
  attachment_batch_button_analyze: 'Analyze',
324
324
  attachment_batch_button_clear: 'Clear',
325
325
  bound_to_thread: 'Bound to thread {threadId}',
326
+ thread_active_writer_read_only: 'Another Codex client is currently writing this thread. It is selected read-only; use /watch to mirror live output.',
326
327
  line_title: 'Title: {value}',
327
328
  line_cwd: 'CWD: {value}',
328
329
  codex_sync_failed: 'Codex.app sync failed: {error}',
@@ -405,6 +406,7 @@ const MESSAGES = {
405
406
  fast_configured: 'Fast: {value}',
406
407
  thread_no_longer_available: 'Thread is no longer available',
407
408
  thread_opened: 'Thread opened',
409
+ thread_opened_read_only: 'Selected read-only; tap Watch for live output',
408
410
  opened_sync_failed_short: 'Opened, Codex.app sync failed',
409
411
  opened_in_codex_short: 'Opened in Codex.app',
410
412
  turn_already_finished: 'Turn already finished',
@@ -1047,6 +1049,7 @@ const MESSAGES = {
1047
1049
  attachment_batch_button_analyze: '分析',
1048
1050
  attachment_batch_button_clear: '清除',
1049
1051
  bound_to_thread: '已绑定到线程 {threadId}',
1052
+ thread_active_writer_read_only: '另一个 Codex 客户端正在写入这个线程。现已只读选中;使用 /watch 可同步实时输出。',
1050
1053
  line_title: '标题:{value}',
1051
1054
  line_cwd: '工作目录:{value}',
1052
1055
  codex_sync_failed: '同步到 Codex.app 失败:{error}',
@@ -1129,6 +1132,7 @@ const MESSAGES = {
1129
1132
  fast_configured: 'Fast:{value}',
1130
1133
  thread_no_longer_available: '这个线程已经不可用',
1131
1134
  thread_opened: '线程已打开',
1135
+ thread_opened_read_only: '已只读选中;点观察可同步输出',
1132
1136
  opened_sync_failed_short: '已打开,但同步到 Codex.app 失败',
1133
1137
  opened_in_codex_short: '已在 Codex.app 中打开',
1134
1138
  turn_already_finished: '这轮回复已经结束了',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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",