@foxden-app/foxclaw 0.5.53 → 0.5.55

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/.env.example CHANGED
@@ -32,9 +32,13 @@ LOG_LEVEL=info
32
32
  DEFAULT_CWD=/absolute/path/to/workspace
33
33
  DEFAULT_APPROVAL_POLICY=on-request
34
34
  DEFAULT_SANDBOX_MODE=workspace-write
35
- TELEGRAM_POLL_INTERVAL_MS=1200
36
- TELEGRAM_PREVIEW_THROTTLE_MS=800
37
- THREAD_LIST_LIMIT=10
35
+ TELEGRAM_POLL_INTERVAL_MS=1200
36
+ TELEGRAM_PREVIEW_THROTTLE_MS=800
37
+ # Delete folded operation-detail messages after the final answer is sent.
38
+ # Set to false if you prefer to keep "read files / edited files / ran command"
39
+ # detail cards in the chat history.
40
+ # TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL=true
41
+ THREAD_LIST_LIMIT=10
38
42
  CODEX_CLI_BIN=/absolute/path/to/codex
39
43
 
40
44
  # Optional cross-node Codex auth sync.
@@ -59,6 +63,7 @@ CODEX_CLI_BIN=/absolute/path/to/codex
59
63
  # your own speech host; HTTP mode calls your own VOICE_TTS_URL and converts
60
64
  # locally. FoxClaw does not ship with a public TTS backend.
61
65
  # VOICE_TTS_ENABLED=false
66
+ # VOICE_TTS_ENGINE=qwen
62
67
  # HTTP backend example: compatible service exposing /v1/tts/custom or
63
68
  # /v1/tts/design and returning WAV audio. Local ffmpeg is required.
64
69
  # VOICE_TTS_MODE=http
@@ -70,11 +75,13 @@ CODEX_CLI_BIN=/absolute/path/to/codex
70
75
  # VOICE_TTS_MODE=ssh
71
76
  # VOICE_TTS_SSH_HOST=<ssh-host>
72
77
  # VOICE_TTS_SSH_DIR=/path/to/qwen-speech-server
78
+ # For SoulX-compatible backends, set VOICE_TTS_ENGINE=soulx and point
79
+ # VOICE_TTS_URL at the SoulX service, for example http://127.0.0.1:18082.
73
80
  # VOICE_TTS_DESIGN_INSTRUCT=用自然清晰的中文女声朗读,语速适中。
74
81
  # VOICE_SUMMARY_BUTTON_ENABLED=true
75
82
  # VOICE_SUMMARY_TEXT_LIMIT=180
76
83
  # VOICE_TEXT_LIMIT=2800
77
- # VOICE_TTS_TIMEOUT_MS=120000
84
+ # VOICE_TTS_TIMEOUT_MS=60000
78
85
 
79
86
  # Optional: standard HTTP(S) proxy for Telegram and ChatGPT/Codex backend requests.
80
87
  # Put these in the same env file that `foxclaw start` installs into systemd/launchd.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
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.5.55 - 2026-06-21
6
+
7
+ ### 中文
8
+ - 最终答复发出后,默认删除已折叠的工具操作明细消息,让 Telegram 回顾时更接近“一问一答”;新增 `TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL=false` 可保留这些明细。
9
+ - “听总结”新增 `VOICE_TTS_ENGINE=soulx`,SSH 模式会先检查 SoulX `/health`,再调用 `/v1/tts` 并转成 Telegram voice;语音 TTS 默认超时缩短到 60 秒。
10
+ - 降噪内部状态通知:不再向 Telegram 展示“目标已清除”“线程 active/running”“MCP ready/starting”等低价值生命周期消息,错误和需要处理的状态仍会提示。
11
+
12
+ ### English
13
+ - After a final answer is sent, FoxClaw now deletes archived tool-detail messages by default so Telegram history reads closer to one user turn and one final reply. Set `TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL=false` to keep them.
14
+ - Added `VOICE_TTS_ENGINE=soulx` for voice summaries. SSH mode checks the SoulX `/health` endpoint, calls `/v1/tts`, and converts the result to Telegram voice; the default TTS timeout is now 60 seconds.
15
+ - Reduced noisy internal lifecycle notifications: Telegram no longer shows low-value "goal cleared", active/running thread status, or MCP ready/starting messages, while errors and actionable states are still reported.
16
+
17
+ ## 0.5.54 - 2026-06-20
18
+
19
+ ### 中文
20
+ - 修复升级到 Codex CLI `0.141.0` 后 Telegram 看不到工具执行明细的问题;FoxClaw 现在支持现代 app-server 的 `item/started` / `item/completed` 工具生命周期。
21
+ - 命令执行、文件修改、网页搜索、MCP/动态工具、图片查看和生成、协作 Agent 操作会重新实时显示;阶段结束后仍压缩为可展开明细,最终过程汇报继续独立折叠。
22
+ - 保留旧版 `codex/event/exec_command_begin/end` 兼容,避免旧节点或旧 Codex CLI 失去过程状态。
23
+
24
+ ### English
25
+ - Fixed Telegram tool activity details disappearing after upgrading to Codex CLI `0.141.0`; FoxClaw now supports the modern app-server `item/started` and `item/completed` tool lifecycle.
26
+ - Command execution, file changes, web search, MCP/dynamic tools, image activity, and collaborative agent operations are visible in real time again, then archived into expandable stage details.
27
+ - Kept compatibility with legacy `codex/event/exec_command_begin/end` notifications for older Codex CLI installations.
28
+
5
29
  ## 0.5.53 - 2026-06-20
6
30
 
7
31
  ### 中文
package/dist/config.d.ts CHANGED
@@ -40,6 +40,7 @@ export interface AppConfig {
40
40
  defaultSandboxMode: SandboxModeValue;
41
41
  telegramPollIntervalMs: number;
42
42
  telegramPreviewThrottleMs: number;
43
+ telegramDeleteToolDetailsAfterFinal: boolean;
43
44
  threadListLimit: number;
44
45
  statusPath: string;
45
46
  logPath: string;
@@ -64,6 +65,7 @@ export interface AppConfig {
64
65
  authSyncTempDir: string;
65
66
  authAutoDeleteNeedsRepair: boolean;
66
67
  voiceTtsEnabled: boolean;
68
+ voiceTtsEngine: 'qwen' | 'soulx';
67
69
  voiceTtsMode: 'http' | 'ssh';
68
70
  voiceTtsUrl: string | null;
69
71
  voiceTtsToken: string | null;
package/dist/config.js CHANGED
@@ -77,6 +77,7 @@ export function loadConfig() {
77
77
  defaultSandboxMode: parseSandboxMode(process.env.DEFAULT_SANDBOX_MODE || 'workspace-write'),
78
78
  telegramPollIntervalMs: intEnv('TELEGRAM_POLL_INTERVAL_MS', 1200),
79
79
  telegramPreviewThrottleMs: intEnv('TELEGRAM_PREVIEW_THROTTLE_MS', 800),
80
+ telegramDeleteToolDetailsAfterFinal: boolEnv('TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL', true),
80
81
  threadListLimit: intEnv('THREAD_LIST_LIMIT', 10),
81
82
  statusPath: DEFAULT_STATUS_PATH,
82
83
  logPath: DEFAULT_LOG_PATH,
@@ -98,6 +99,7 @@ export function loadConfig() {
98
99
  authSyncTempDir: process.env.AUTH_SYNC_TEMP_DIR || DEFAULT_AUTH_SYNC_TEMP_DIR,
99
100
  authAutoDeleteNeedsRepair: boolEnv('AUTH_AUTO_DELETE_NEEDS_REPAIR', false),
100
101
  voiceTtsEnabled: boolEnv('VOICE_TTS_ENABLED', false),
102
+ voiceTtsEngine: parseVoiceTtsEngine(process.env.VOICE_TTS_ENGINE || 'qwen'),
101
103
  voiceTtsMode: parseVoiceTtsMode(process.env.VOICE_TTS_MODE || (process.env.VOICE_TTS_SSH_HOST?.trim() ? 'ssh' : 'http')),
102
104
  voiceTtsUrl: optional('VOICE_TTS_URL'),
103
105
  voiceTtsToken: optional('VOICE_TTS_TOKEN'),
@@ -108,7 +110,7 @@ export function loadConfig() {
108
110
  voiceSummaryButtonEnabled: boolEnv('VOICE_SUMMARY_BUTTON_ENABLED', true),
109
111
  voiceSummaryTextLimit: intEnv('VOICE_SUMMARY_TEXT_LIMIT', 180),
110
112
  voiceTextLimit: intEnv('VOICE_TEXT_LIMIT', 2800),
111
- voiceTtsTimeoutMs: intEnv('VOICE_TTS_TIMEOUT_MS', 120_000),
113
+ voiceTtsTimeoutMs: intEnv('VOICE_TTS_TIMEOUT_MS', 60_000),
112
114
  };
113
115
  ensureAppDirs(config);
114
116
  return config;
@@ -197,6 +199,9 @@ function parseSandboxMode(value) {
197
199
  function parseVoiceTtsMode(value) {
198
200
  return value.trim().toLowerCase() === 'http' ? 'http' : 'ssh';
199
201
  }
202
+ function parseVoiceTtsEngine(value) {
203
+ return value.trim().toLowerCase() === 'soulx' ? 'soulx' : 'qwen';
204
+ }
200
205
  function resolveCommand(commandName) {
201
206
  try {
202
207
  const which = process.platform === 'win32' ? 'where' : 'which';
@@ -52,6 +52,10 @@ function normalizeStartedEvent(params) {
52
52
  state: 'thinking',
53
53
  };
54
54
  }
55
+ const toolEvent = normalizeThreadItemToolEvent(params, 'tool_started');
56
+ if (toolEvent) {
57
+ return toolEvent;
58
+ }
55
59
  if (!isRelayableTextItemType(itemType)) {
56
60
  return null;
57
61
  }
@@ -113,6 +117,10 @@ function normalizeCompletedEvent(params) {
113
117
  state: 'thinking',
114
118
  };
115
119
  }
120
+ const toolEvent = normalizeThreadItemToolEvent(params, 'tool_completed');
121
+ if (toolEvent) {
122
+ return toolEvent;
123
+ }
116
124
  if (!isRelayableTextItemType(itemType)) {
117
125
  return null;
118
126
  }
@@ -161,6 +169,86 @@ function normalizeToolEvent(params, kind) {
161
169
  state: inferToolActivityState(exec),
162
170
  };
163
171
  }
172
+ function normalizeThreadItemToolEvent(params, kind) {
173
+ const turnId = extractTurnId(params);
174
+ const item = params?.item;
175
+ const itemId = extractItemId(item);
176
+ const itemType = normalizeEventItemType(item);
177
+ if (!turnId || !itemId || !itemType) {
178
+ return null;
179
+ }
180
+ let command = [];
181
+ let cwd = null;
182
+ let parsedCmd = [];
183
+ switch (itemType) {
184
+ case 'commandexecution':
185
+ command = typeof item.command === 'string' ? [item.command] : [];
186
+ cwd = typeof item.cwd === 'string' ? item.cwd : null;
187
+ parsedCmd = normalizeCommandActions(item.commandActions);
188
+ break;
189
+ case 'filechange':
190
+ parsedCmd = normalizeFileChanges(item.changes);
191
+ break;
192
+ case 'websearch':
193
+ parsedCmd = [{
194
+ type: 'search',
195
+ query: typeof item.query === 'string' ? item.query : '',
196
+ path: null,
197
+ }];
198
+ break;
199
+ case 'imageview':
200
+ parsedCmd = [{ type: 'read', path: typeof item.path === 'string' ? item.path : null }];
201
+ break;
202
+ case 'mcptoolcall':
203
+ command = [`MCP ${String(item.server ?? 'server')}/${String(item.tool ?? 'tool')}`];
204
+ break;
205
+ case 'dynamictoolcall':
206
+ command = [`Tool ${item.namespace ? `${String(item.namespace)}/` : ''}${String(item.tool ?? 'tool')}`];
207
+ break;
208
+ case 'collabagenttoolcall':
209
+ command = [`Agent ${String(item.tool ?? 'operation')}`];
210
+ break;
211
+ case 'imagegeneration':
212
+ command = ['Image generation'];
213
+ break;
214
+ default:
215
+ return null;
216
+ }
217
+ const exec = {
218
+ callId: itemId,
219
+ turnId,
220
+ command,
221
+ cwd,
222
+ parsedCmd,
223
+ };
224
+ return {
225
+ kind,
226
+ turnId,
227
+ exec,
228
+ state: inferToolActivityState(exec),
229
+ };
230
+ }
231
+ function normalizeCommandActions(value) {
232
+ if (!Array.isArray(value)) {
233
+ return [];
234
+ }
235
+ return value.map((entry) => {
236
+ const type = typeof entry?.type === 'string' ? entry.type : '';
237
+ if (type === 'listFiles') {
238
+ return { ...entry, type: 'list_files' };
239
+ }
240
+ return entry;
241
+ });
242
+ }
243
+ function normalizeFileChanges(value) {
244
+ if (!Array.isArray(value)) {
245
+ return [];
246
+ }
247
+ return value.map((entry) => ({
248
+ type: 'apply_patch',
249
+ path: typeof entry?.path === 'string' ? entry.path : null,
250
+ }));
251
+ }
164
252
  export function classifyAgentOutput(phase, completed) {
165
253
  if (!phase) {
166
254
  return completed ? 'final_answer' : 'commentary';
@@ -239,7 +239,7 @@ export declare class BridgeSessionCore {
239
239
  private sendTyping;
240
240
  private sendObservedCliUserMessage;
241
241
  private collapseTurnCommentary;
242
- private cleanupObservedTransientMessages;
242
+ private cleanupTransientProgressMessages;
243
243
  private hasObservedPersistentReply;
244
244
  private ensureThreadReady;
245
245
  private handleAsyncError;
@@ -1495,6 +1495,9 @@ export class BridgeSessionCore {
1495
1495
  if (status === 'idle') {
1496
1496
  return;
1497
1497
  }
1498
+ if (status === 'active' || status === 'running') {
1499
+ return;
1500
+ }
1498
1501
  const locale = this.localeForChat(scopeId);
1499
1502
  await this.sendMessage(scopeId, t(locale, 'thread_status_changed', { threadId, status }));
1500
1503
  }
@@ -1534,7 +1537,6 @@ export class BridgeSessionCore {
1534
1537
  }
1535
1538
  const locale = this.localeForChat(scopeId);
1536
1539
  if (method === 'thread/goal/cleared') {
1537
- await this.sendMessage(scopeId, t(locale, 'goal_cleared_notification', { threadId }));
1538
1540
  return;
1539
1541
  }
1540
1542
  const goal = mapGoalNotification(params?.goal);
@@ -1828,6 +1830,10 @@ export class BridgeSessionCore {
1828
1830
  const message = params?.error
1829
1831
  ? `MCP ${name}: ${status} (${String(params.error)})`
1830
1832
  : `MCP ${name}: ${status}`;
1833
+ const normalized = status.toLowerCase();
1834
+ if (!params?.error && ['ready', 'running', 'starting', 'connected'].includes(normalized)) {
1835
+ return;
1836
+ }
1831
1837
  await this.notifyBoundScopes(message);
1832
1838
  }
1833
1839
  async handleMcpOauthLoginCompleted(params) {
@@ -1877,7 +1883,7 @@ export class BridgeSessionCore {
1877
1883
  }
1878
1884
  try {
1879
1885
  await this.completeTurn(active);
1880
- await this.cleanupObservedTransientMessages(active);
1886
+ await this.cleanupTransientProgressMessages(active);
1881
1887
  await this.finalizeUserInputsForTurn(active, 'resolved');
1882
1888
  this.markQueuedTurnCompleted(active);
1883
1889
  }
@@ -3234,7 +3240,7 @@ export class BridgeSessionCore {
3234
3240
  try {
3235
3241
  this.promoteReadyToolBatch(active);
3236
3242
  await this.completeTurn(active);
3237
- await this.cleanupObservedTransientMessages(active);
3243
+ await this.cleanupTransientProgressMessages(active);
3238
3244
  await this.finalizeUserInputsForTurn(active, active.interruptRequested ? 'interrupted' : 'resolved');
3239
3245
  await this.maybeSendPlanImplementationPrompt(active);
3240
3246
  this.markQueuedTurnCompleted(active);
@@ -3481,21 +3487,25 @@ export class BridgeSessionCore {
3481
3487
  segment.messages = [];
3482
3488
  }
3483
3489
  }
3484
- async cleanupObservedTransientMessages(active) {
3485
- if (!active.isObserved || !this.hasObservedPersistentReply(active)) {
3490
+ async cleanupTransientProgressMessages(active) {
3491
+ if (!this.hasObservedPersistentReply(active)) {
3486
3492
  return;
3487
3493
  }
3488
3494
  const messageIds = new Set();
3489
- for (const segment of active.segments) {
3490
- if (segment.outputKind === 'final_answer') {
3491
- continue;
3492
- }
3493
- for (const message of segment.messages) {
3494
- messageIds.add(message.messageId);
3495
+ if (active.isObserved) {
3496
+ for (const segment of active.segments) {
3497
+ if (segment.outputKind === 'final_answer') {
3498
+ continue;
3499
+ }
3500
+ for (const message of segment.messages) {
3501
+ messageIds.add(message.messageId);
3502
+ }
3495
3503
  }
3496
3504
  }
3497
- for (const messageId of active.archivedMessageIds) {
3498
- messageIds.add(messageId);
3505
+ if (this.config.telegramDeleteToolDetailsAfterFinal) {
3506
+ for (const messageId of active.archivedMessageIds) {
3507
+ messageIds.add(messageId);
3508
+ }
3499
3509
  }
3500
3510
  for (const messageId of messageIds) {
3501
3511
  try {
@@ -3503,7 +3513,7 @@ export class BridgeSessionCore {
3503
3513
  }
3504
3514
  catch (error) {
3505
3515
  if (!isTelegramMessageGone(error)) {
3506
- this.logger.warn('telegram.observed_cleanup_delete_failed', {
3516
+ this.logger.warn('telegram.transient_progress_cleanup_delete_failed', {
3507
3517
  error: String(error),
3508
3518
  turnId: active.turnId,
3509
3519
  messageId,
@@ -8080,7 +8090,7 @@ export class BridgeSessionCore {
8080
8090
  else {
8081
8091
  messageId = await this.sendMessage(active.scopeId, content.text);
8082
8092
  }
8083
- if (active.isObserved && messageId !== null) {
8093
+ if (messageId !== null) {
8084
8094
  active.archivedMessageIds.push(messageId);
8085
8095
  }
8086
8096
  }
@@ -8098,9 +8108,7 @@ export class BridgeSessionCore {
8098
8108
  else {
8099
8109
  await this.editMessage(active.scopeId, active.previewMessageId, content.text, []);
8100
8110
  }
8101
- if (active.isObserved) {
8102
- active.archivedMessageIds.push(active.previewMessageId);
8103
- }
8111
+ active.archivedMessageIds.push(active.previewMessageId);
8104
8112
  }
8105
8113
  catch (error) {
8106
8114
  if (isTelegramMessageGone(error)) {
package/dist/voice/tts.js CHANGED
@@ -41,6 +41,9 @@ async function synthesizeViaHttp(text, config) {
41
41
  if (!config.voiceTtsUrl) {
42
42
  throw new Error('VOICE_TTS_URL is not configured');
43
43
  }
44
+ if (config.voiceTtsEngine === 'soulx') {
45
+ return convertToOggOpus(await requestTtsWav(config, '/v1/tts', { text, seed: '7' }), config.voiceFfmpegBin);
46
+ }
44
47
  try {
45
48
  return convertToOggOpus(await requestTtsWav(config, '/v1/tts/custom', { text, language: 'zh' }), config.voiceFfmpegBin);
46
49
  }
@@ -114,6 +117,9 @@ async function synthesizeViaSsh(text, config) {
114
117
  if (!config.voiceTtsSshDir) {
115
118
  throw new Error('VOICE_TTS_SSH_DIR is not configured');
116
119
  }
120
+ if (config.voiceTtsEngine === 'soulx') {
121
+ return synthesizeSoulxViaSsh(text, config);
122
+ }
117
123
  const encodedText = Buffer.from(text, 'utf8').toString('base64');
118
124
  const script = String.raw `set -euo pipefail
119
125
  TEXT="$(printf '%s' "$1" | base64 -d)"
@@ -177,6 +183,63 @@ PY
177
183
  config.voiceTtsDesignInstruct,
178
184
  ], script, config.voiceTtsTimeoutMs);
179
185
  }
186
+ async function synthesizeSoulxViaSsh(text, config) {
187
+ const sshHost = config.voiceTtsSshHost;
188
+ const envDir = config.voiceTtsSshDir;
189
+ if (!sshHost) {
190
+ throw new Error('VOICE_TTS_SSH_HOST is not configured');
191
+ }
192
+ if (!envDir) {
193
+ throw new Error('VOICE_TTS_SSH_DIR is not configured');
194
+ }
195
+ const encodedText = Buffer.from(text, 'utf8').toString('base64');
196
+ const baseUrl = config.voiceTtsUrl || 'http://127.0.0.1:18082';
197
+ const script = String.raw `set -euo pipefail
198
+ TEXT="$(printf '%s' "$1" | base64 -d)"
199
+ ENV_DIR="$2"
200
+ BASE_URL="$3"
201
+ cd "$ENV_DIR"
202
+ set -a
203
+ . ./.env
204
+ set +a
205
+ tmp="$(mktemp -d)"
206
+ trap 'rm -rf "$tmp"' EXIT
207
+ curl -sS "\${BASE_URL}/health" >/dev/null
208
+ TEXT="$TEXT" python3 - <<'PY' > "$tmp/body.json"
209
+ import json
210
+ import os
211
+ print(json.dumps({"text": os.environ["TEXT"], "seed": 7, "dialect_prompt": ""}, ensure_ascii=False))
212
+ PY
213
+ status="$(curl -sS -w '%{http_code}' -X POST "\${BASE_URL}/v1/tts" \
214
+ -H "Authorization: Bearer $QWEN_SPEECH_API_TOKEN" \
215
+ -H "Content-Type: application/json" \
216
+ --data-binary "@$tmp/body.json" \
217
+ --output "$tmp/tts.wav")"
218
+ if [[ "$status" != "200" ]]; then
219
+ python3 - "$tmp/tts.wav" <<'PY' >&2
220
+ import pathlib
221
+ import sys
222
+ sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
223
+ PY
224
+ exit 22
225
+ fi
226
+ ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
227
+ python3 - "$tmp/voice.ogg" <<'PY'
228
+ import pathlib
229
+ import sys
230
+ sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
231
+ PY
232
+ `;
233
+ return runSshBinary([
234
+ sshHost,
235
+ 'bash',
236
+ '-s',
237
+ '--',
238
+ encodedText,
239
+ envDir,
240
+ baseUrl,
241
+ ], script, config.voiceTtsTimeoutMs);
242
+ }
180
243
  async function runSshBinary(args, stdin, timeoutMs) {
181
244
  return new Promise((resolve, reject) => {
182
245
  const child = spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.53",
3
+ "version": "0.5.55",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",