@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 +20 -0
- package/dist/controller/controller.d.ts +1 -0
- package/dist/controller/controller.js +33 -26
- package/dist/controller/session_observer.js +44 -2
- package/dist/i18n.d.ts +4 -0
- package/dist/i18n.js +4 -0
- 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.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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '这轮回复已经结束了',
|