@foxden-app/foxclaw 0.7.2 → 0.7.3
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 +1 -0
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/cross_node_sync.js +2 -2
- package/dist/codex_app/client.d.ts +1 -0
- package/dist/codex_app/client.js +35 -4
- package/dist/codex_app/force_takeover.d.ts +12 -0
- package/dist/codex_app/force_takeover.js +27 -0
- package/dist/controller/controller.d.ts +7 -0
- package/dist/controller/controller.js +214 -12
- package/dist/i18n.d.ts +30 -6
- package/dist/i18n.js +32 -6
- package/dist/main.js +44 -8
- package/dist/telegram/api.js +4 -0
- package/dist/telegram/bot_home.d.ts +3 -0
- package/dist/telegram/bot_home.js +107 -0
- package/dist/telegram/gateway.d.ts +1 -0
- package/dist/telegram/gateway.js +10 -0
- package/dist/voice/target.js +2 -1
- package/docs/user-manual.md +11 -1
- package/docs/zh/2026-09-05-bot-home-migration.md +26 -0
- package/docs/zh/2026-09-05-reliability-review.md +53 -0
- package/docs/zh/troubleshooting.md +12 -0
- package/docs/zh/user-manual.md +13 -1
- package/package.json +1 -1
- package/scripts/force-takeover.py +118 -0
- package/scripts/force-takeover.test.py +122 -0
package/.env.example
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Required for new installs: one or more bot tokens for this host.
|
|
2
2
|
# Comma-separated bot tokens. By default, each bot gets an independent Codex runtime/auth selection.
|
|
3
|
+
# Homes use ~/.foxclaw/codex/telegram/@TelegramUsername/home (old numeric paths remain as links).
|
|
3
4
|
TG_BOT_TOKENS=<telegram_bot_token>
|
|
4
5
|
# Backward-compatible single-runtime setup.
|
|
5
6
|
# In TG_BOT_TOKENS mode, if this exact token is also present in TG_BOT_TOKENS,
|
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.3 - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复代理或网络不稳时 Telegram、Codex RPC 和 WebSocket 可能长期无响应的问题:请求有明确截止时间,活动会话断连、恢复失败和 writer 冲突都会给出可继续操作的错误,不重放结果未知的请求。
|
|
9
|
+
- 登录、auth add 和 auth repair 流程增加取消按钮;取消失败也释放本地流程,旧按钮和其他聊天不能误取消当前登录。
|
|
10
|
+
- 新增 `/cli` 与 `foxclaw resume`,可让终端连接桥的同一 Codex app-server;`/watch` 继续只读,外部 CLI 的消息可用原生线程队列安全排队。
|
|
11
|
+
- 新增 `/takeover --force <消息>`:可信 Telegram 用户二次确认后,可精确停止占用目标 thread 的本机交互式 CLI,验证锁释放并恢复原 thread 后再提交消息。进程、锁或 thread 身份变化时明确拒绝,不删除锁、不改写 session 文件。
|
|
12
|
+
- 多 bot Codex 数据目录默认使用真实 `@Telegram用户名`。迁移既有数字目录并保留兼容链接,共享终端的 bot 使用同名目录入口指向原 home。
|
|
13
|
+
- 并行读取用户名,断网重启沿用已有名称;媒体发送通过稳定身份记录保持正确路由,目录冲突明确报错。
|
|
14
|
+
- auth 问号、修复和删除操作会重新读取最新状态;安全同步文案区分“已发送”和“远端已导入”,状态更新未生效时不再报告虚假成功。
|
|
15
|
+
|
|
16
|
+
### English
|
|
17
|
+
- Bound Telegram, Codex RPC, and WebSocket waits so unstable proxy or network links fail explicitly instead of hanging. Active-session disconnects, recovery failures, and writer conflicts now include actionable recovery guidance without replaying ambiguous requests.
|
|
18
|
+
- Added cancel buttons to login, auth-add, and auth-repair flows. Local state is released even when cancellation fails, while stale or foreign-chat buttons cannot cancel the current login.
|
|
19
|
+
- Added `/cli` and `foxclaw resume` to connect a terminal to the bridge's Codex app-server. `/watch` remains read-only and external CLI prompts use Codex's native thread queue.
|
|
20
|
+
- Added `/takeover --force <message>` for a trusted Telegram user to confirm a precise local interactive-CLI handoff. FoxClaw revalidates process, lock, and thread identity, verifies lock release, resumes the original thread, and only then submits the prompt; it never deletes locks or edits session files.
|
|
21
|
+
- Name multi-bot Codex homes after verified Telegram usernames, migrating numeric directories with compatibility links and preserving shared terminal homes.
|
|
22
|
+
- Resolve usernames in parallel, reuse stored paths offline, preserve media routing through stable identity metadata, and reject directory conflicts.
|
|
23
|
+
- Auth repair/question-mark/delete actions now refresh current state first. Safe-sync wording distinguishes dispatch from confirmed remote import, and unapplied state changes no longer report false success.
|
|
24
|
+
|
|
5
25
|
## 0.7.2 - 2026-08-30
|
|
6
26
|
|
|
7
27
|
### 中文
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex 或
|
|
|
30
30
|
|
|
31
31
|

|
|
32
32
|
|
|
33
|
-
手机端不是简单转发消息,而是给 Codex 的常用工作流做了 Telegram 面板:`/setup` 调模型、推理强度、Fast tier、权限和 Agent/Plan 模式;`/auth` 管理多个 Codex 登录候选,触发限制时自动轮转;`/threads`、`/watch`
|
|
33
|
+
手机端不是简单转发消息,而是给 Codex 的常用工作流做了 Telegram 面板:`/setup` 调模型、推理强度、Fast tier、权限和 Agent/Plan 模式;`/auth` 管理多个 Codex 登录候选,触发限制时自动轮转;`/threads`、`/watch` 和审批按钮用于切线程、观察进度和处理权限请求。终端 CLI 卡住且持有 thread 时,可信用户还可用 `/takeover --force <消息>` 核对 PID、目录并二次确认,将同一 thread 安全交给桥继续执行。
|
|
34
34
|
|
|
35
35
|
## 从这里开始
|
|
36
36
|
|
package/README_EN.md
CHANGED
|
@@ -30,7 +30,7 @@ No public server required. FoxClaw runs on your own computer, talks to `codex ap
|
|
|
30
30
|
|
|
31
31
|

|
|
32
32
|
|
|
33
|
-
FoxClaw is more than message forwarding. It provides Telegram panels for Codex workflows: `/setup` adjusts model, reasoning, Fast tier, access, and Agent/Plan mode; `/auth` manages multiple Codex auth candidates and rotates them on usage limits; `/threads`, `/watch`, and approval buttons handle thread switching, progress monitoring, and permission requests.
|
|
33
|
+
FoxClaw is more than message forwarding. It provides Telegram panels for Codex workflows: `/setup` adjusts model, reasoning, Fast tier, access, and Agent/Plan mode; `/auth` manages multiple Codex auth candidates and rotates them on usage limits; `/threads`, `/watch`, and approval buttons handle thread switching, progress monitoring, and permission requests. If a local interactive Codex CLI is stuck while holding a thread, the trusted Telegram user can use `/takeover --force <message>`, verify its PID and working directory, and explicitly confirm a safe handoff to the bridge.
|
|
34
34
|
|
|
35
35
|
## Start Here
|
|
36
36
|
|
|
@@ -776,11 +776,11 @@ export class CrossNodeAuthSync {
|
|
|
776
776
|
this.recordEvent({
|
|
777
777
|
direction: 'local',
|
|
778
778
|
kind: 'audit.state',
|
|
779
|
-
stage: message.state,
|
|
779
|
+
stage: applied ? message.state : 'skipped',
|
|
780
780
|
peer: null,
|
|
781
781
|
requestId: message.requestId,
|
|
782
782
|
candidateName: message.candidateName,
|
|
783
|
-
detail: null,
|
|
783
|
+
detail: applied ? null : 'local candidate did not match the audited identity or refresh timestamp',
|
|
784
784
|
});
|
|
785
785
|
}
|
|
786
786
|
async buildAuditReport() {
|
|
@@ -78,6 +78,7 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
78
78
|
private port;
|
|
79
79
|
private connected;
|
|
80
80
|
private userAgent;
|
|
81
|
+
private readonly requestTimeoutMs;
|
|
81
82
|
constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null, appServerConfigOverrides?: readonly string[]);
|
|
82
83
|
isConnected(): boolean;
|
|
83
84
|
getUserAgent(): string | null;
|
package/dist/codex_app/client.js
CHANGED
|
@@ -33,6 +33,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
33
33
|
port = null;
|
|
34
34
|
connected = false;
|
|
35
35
|
userAgent = null;
|
|
36
|
+
requestTimeoutMs = 30_000;
|
|
36
37
|
constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null, appServerConfigOverrides = []) {
|
|
37
38
|
super();
|
|
38
39
|
this.codexCliBin = codexCliBin;
|
|
@@ -577,6 +578,9 @@ export class CodexAppClient extends EventEmitter {
|
|
|
577
578
|
port: state.port,
|
|
578
579
|
error: error instanceof Error ? error.message : String(error),
|
|
579
580
|
});
|
|
581
|
+
if (isProcessAlive(state.pid)) {
|
|
582
|
+
throw new Error(`Managed Codex app-server pid ${state.pid} is alive but unreachable; retry the connection or explicitly restart it.`, { cause: error });
|
|
583
|
+
}
|
|
580
584
|
this.clearServerStateForPid(state.pid);
|
|
581
585
|
return false;
|
|
582
586
|
}
|
|
@@ -588,11 +592,18 @@ export class CodexAppClient extends EventEmitter {
|
|
|
588
592
|
try {
|
|
589
593
|
await new Promise((resolve, reject) => {
|
|
590
594
|
const ws = new WebSocket(url);
|
|
595
|
+
const timer = setTimeout(() => {
|
|
596
|
+
ws.close();
|
|
597
|
+
reject(new Error('WebSocket handshake timed out'));
|
|
598
|
+
}, 2000);
|
|
591
599
|
const onError = (event) => {
|
|
600
|
+
clearTimeout(timer);
|
|
592
601
|
ws.close();
|
|
593
602
|
reject(new Error(`WebSocket connect failed: ${String(event.type)}`));
|
|
594
603
|
};
|
|
595
604
|
ws.addEventListener('open', () => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
ws.removeEventListener('error', onError);
|
|
596
607
|
this.socket = ws;
|
|
597
608
|
this.connected = true;
|
|
598
609
|
ws.addEventListener('message', message => this.handleMessage(String(message.data)));
|
|
@@ -608,7 +619,12 @@ export class CodexAppClient extends EventEmitter {
|
|
|
608
619
|
});
|
|
609
620
|
});
|
|
610
621
|
ws.addEventListener('error', err => {
|
|
611
|
-
this.logger.warn('codex.ws.error',
|
|
622
|
+
this.logger.warn('codex.ws.error', { message: err.message || 'WebSocket transport error', port: this.port });
|
|
623
|
+
if (this.socket === ws) {
|
|
624
|
+
this.socket = null;
|
|
625
|
+
ws.close();
|
|
626
|
+
this.handleDisconnect({ source: 'websocket-error', port: this.port });
|
|
627
|
+
}
|
|
612
628
|
});
|
|
613
629
|
resolve();
|
|
614
630
|
}, { once: true });
|
|
@@ -649,8 +665,22 @@ export class CodexAppClient extends EventEmitter {
|
|
|
649
665
|
}
|
|
650
666
|
const id = String(++this.requestId);
|
|
651
667
|
return new Promise((resolve, reject) => {
|
|
652
|
-
|
|
653
|
-
|
|
668
|
+
const timer = setTimeout(() => {
|
|
669
|
+
this.pending.delete(id);
|
|
670
|
+
this.logger.warn('codex.request_timeout', { method, timeoutMs: this.requestTimeoutMs });
|
|
671
|
+
reject(new Error(`Codex request timed out: ${method}. The result is unknown; check /status or use foxclaw resume before retrying.`));
|
|
672
|
+
}, this.requestTimeoutMs);
|
|
673
|
+
this.pending.set(id, {
|
|
674
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
675
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
676
|
+
});
|
|
677
|
+
try {
|
|
678
|
+
this.send({ jsonrpc: '2.0', id, method, params });
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
this.pending.get(id)?.reject(error);
|
|
682
|
+
this.pending.delete(id);
|
|
683
|
+
}
|
|
654
684
|
});
|
|
655
685
|
}
|
|
656
686
|
send(payload) {
|
|
@@ -690,6 +720,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
690
720
|
}
|
|
691
721
|
}
|
|
692
722
|
handleDisconnect(meta) {
|
|
723
|
+
this.logger.warn('codex.disconnected', meta);
|
|
693
724
|
if (this.connected) {
|
|
694
725
|
this.connected = false;
|
|
695
726
|
}
|
|
@@ -711,7 +742,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
711
742
|
this.reconnectTimer = setTimeout(async () => {
|
|
712
743
|
this.reconnectTimer = null;
|
|
713
744
|
try {
|
|
714
|
-
await this.
|
|
745
|
+
await this.start();
|
|
715
746
|
}
|
|
716
747
|
catch (error) {
|
|
717
748
|
this.logger.error('codex.reconnect_failed', { error: String(error) });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ExternalWriterIdentity {
|
|
2
|
+
pid: number;
|
|
3
|
+
startTime: string;
|
|
4
|
+
exe: string;
|
|
5
|
+
lockDevice: string;
|
|
6
|
+
lockInode: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const externalWriterControl: {
|
|
10
|
+
inspect(home: string, threadId: string): Promise<ExternalWriterIdentity>;
|
|
11
|
+
stop(home: string, threadId: string, expected: ExternalWriterIdentity): Promise<void>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const helper = fileURLToPath(new URL('../../scripts/force-takeover.py', import.meta.url));
|
|
6
|
+
async function runHelper(home, threadId, expected) {
|
|
7
|
+
if (process.platform !== 'linux')
|
|
8
|
+
throw new Error('Force takeover requires Linux/WSL with pidfd support');
|
|
9
|
+
const args = [helper, home, threadId];
|
|
10
|
+
if (expected)
|
|
11
|
+
args.push(JSON.stringify(expected));
|
|
12
|
+
const { stdout } = await execFileAsync('python3', args, { timeout: 12_000, maxBuffer: 16_384 });
|
|
13
|
+
const response = JSON.parse(stdout);
|
|
14
|
+
if (!response.ok)
|
|
15
|
+
throw new Error(response.error ?? 'External writer inspection failed');
|
|
16
|
+
return response.result;
|
|
17
|
+
}
|
|
18
|
+
export const externalWriterControl = {
|
|
19
|
+
async inspect(home, threadId) {
|
|
20
|
+
return await runHelper(home, threadId);
|
|
21
|
+
},
|
|
22
|
+
async stop(home, threadId, expected) {
|
|
23
|
+
const result = await runHelper(home, threadId, expected);
|
|
24
|
+
if (result.stopped !== true)
|
|
25
|
+
throw new Error('CLI stop was not confirmed');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -66,6 +66,9 @@ export declare class BridgeSessionCore {
|
|
|
66
66
|
private pendingUserInputs;
|
|
67
67
|
private pendingMcpElicitations;
|
|
68
68
|
private pendingLoginsByScope;
|
|
69
|
+
private externalWriterControl;
|
|
70
|
+
private forceTakeoversInProgress;
|
|
71
|
+
private pendingForceTakeovers;
|
|
69
72
|
private pendingLoginScopesById;
|
|
70
73
|
private pendingAuthAddsByLoginId;
|
|
71
74
|
private latestTurnDiffs;
|
|
@@ -287,6 +290,8 @@ export declare class BridgeSessionCore {
|
|
|
287
290
|
private applyObservedSessionEvents;
|
|
288
291
|
private applyObservedTurnSnapshot;
|
|
289
292
|
private handleTakeoverCommand;
|
|
293
|
+
private prepareForceTakeover;
|
|
294
|
+
private handleForceTakeoverCallback;
|
|
290
295
|
private handleQueueCommand;
|
|
291
296
|
private handleActiveTurnInboundMessage;
|
|
292
297
|
private queueObservedThreadMessage;
|
|
@@ -341,6 +346,7 @@ export declare class BridgeSessionCore {
|
|
|
341
346
|
private handleVoiceCallback;
|
|
342
347
|
private sendVoiceForText;
|
|
343
348
|
private handleLoginDeviceCommand;
|
|
349
|
+
private loginCancelKeyboard;
|
|
344
350
|
private handleLoginCancelCommand;
|
|
345
351
|
private handleLogoutCommand;
|
|
346
352
|
private handleSteerCommand;
|
|
@@ -377,6 +383,7 @@ export declare class BridgeSessionCore {
|
|
|
377
383
|
private requireReadyBinding;
|
|
378
384
|
private handleAuthPanelActionCallback;
|
|
379
385
|
private handleAuthListViewCallback;
|
|
386
|
+
private refreshRepairedAuthChoice;
|
|
380
387
|
private handleAuthRepairMenuCallback;
|
|
381
388
|
private handleAuthRepairActionCallback;
|
|
382
389
|
private handleAuthToggleCallback;
|
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { externalWriterControl } from '../codex_app/force_takeover.js';
|
|
5
6
|
import { TELEGRAM_VOICE_MAX_BYTES, TELEGRAM_VOICE_SUPPORTED_EXTENSIONS, telegramVoiceContentType, } from '../voice/files.js';
|
|
6
7
|
import { normalizeLocale, t } from '../i18n.js';
|
|
7
8
|
import { chatGptAuthMetadataMatchesCandidateName, parseChatGptAuthMetadata, readChatGptAuthRecord, readChatGptAuthMetadata, } from '../auth/mirror.js';
|
|
@@ -64,6 +65,8 @@ const DYNAMIC_HELP_COMMANDS = [
|
|
|
64
65
|
{ key: 'quota', line: '/quota' },
|
|
65
66
|
{ key: 'update', line: '/update' },
|
|
66
67
|
{ key: 'login_device', line: '/login_device' },
|
|
68
|
+
{ key: 'login_cancel', line: '/login_cancel' },
|
|
69
|
+
{ key: 'cli', line: '/cli' },
|
|
67
70
|
{ key: 'threads_archived', line: '/threads archived [query]' },
|
|
68
71
|
{ key: 'open', line: '/open <n>' },
|
|
69
72
|
{ key: 'goal', line: '/goal [objective|pause|resume|done|budget <tokens|off>|clear confirm]' },
|
|
@@ -122,6 +125,9 @@ export class BridgeSessionCore {
|
|
|
122
125
|
pendingUserInputs = new Map();
|
|
123
126
|
pendingMcpElicitations = new Map();
|
|
124
127
|
pendingLoginsByScope = new Map();
|
|
128
|
+
externalWriterControl = externalWriterControl;
|
|
129
|
+
forceTakeoversInProgress = new Set();
|
|
130
|
+
pendingForceTakeovers = new Map();
|
|
125
131
|
pendingLoginScopesById = new Map();
|
|
126
132
|
pendingAuthAddsByLoginId = new Map();
|
|
127
133
|
latestTurnDiffs = new Map();
|
|
@@ -182,9 +188,7 @@ export class BridgeSessionCore {
|
|
|
182
188
|
this.updateStatus();
|
|
183
189
|
});
|
|
184
190
|
this.bot.on('text', (event) => {
|
|
185
|
-
|
|
186
|
-
void this.handleAsyncError('telegram.text', error, event.scopeId);
|
|
187
|
-
});
|
|
191
|
+
this.dispatchInboundLikeTelegramText(event);
|
|
188
192
|
});
|
|
189
193
|
this.bot.on('callback', (event) => {
|
|
190
194
|
void this.handleCallback(event).catch((error) => {
|
|
@@ -197,7 +201,14 @@ export class BridgeSessionCore {
|
|
|
197
201
|
* (used by the Weixin adapter).
|
|
198
202
|
*/
|
|
199
203
|
dispatchInboundLikeTelegramText(event) {
|
|
200
|
-
|
|
204
|
+
const command = event.attachments.length === 0 ? parseCommand(event.text) : null;
|
|
205
|
+
const name = command?.name;
|
|
206
|
+
// Recovery commands must remain reachable while a normal command is waiting.
|
|
207
|
+
// handleText still performs normal addressing and authorization checks.
|
|
208
|
+
const urgent = name && (['status', 'cli', 'login_cancel', 'interrupt'].includes(name)
|
|
209
|
+
|| (name === 'auth' && command?.args[0] === 'sync' && ['status', 'events', 'trace'].includes(command.args[1] ?? 'status')));
|
|
210
|
+
const task = urgent ? this.handleText(event) : this.withLock(event.scopeId, async () => this.handleText(event));
|
|
211
|
+
void task.catch((error) => {
|
|
201
212
|
void this.handleAsyncError('channel.text', error, event.scopeId);
|
|
202
213
|
});
|
|
203
214
|
}
|
|
@@ -230,6 +241,15 @@ export class BridgeSessionCore {
|
|
|
230
241
|
this.attachedThreads.clear();
|
|
231
242
|
this.threadTokenUsageAlerts.clear();
|
|
232
243
|
if (!this.stopping) {
|
|
244
|
+
if (!this.codexReconnectPending) {
|
|
245
|
+
for (const scopeId of new Set([...this.activeTurns.values()].filter(active => !active.isObserved).map(active => active.scopeId))) {
|
|
246
|
+
const locale = this.localeForChat(scopeId);
|
|
247
|
+
void this.sendMessage(scopeId, locale === 'zh'
|
|
248
|
+
? 'Codex 连接中断,正在重连。任务结果尚未确认,请先用 /status 检查;也可用 /cli 从本机继续。'
|
|
249
|
+
: 'Codex disconnected; reconnecting. The task result is unconfirmed. Check /status or use /cli locally.')
|
|
250
|
+
.catch(error => this.logger.warn('codex.disconnect_notice_failed', { scopeId, error: toErrorMeta(error) }));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
233
253
|
this.codexReconnectPending = true;
|
|
234
254
|
this.pauseAppSnapshotWatchers();
|
|
235
255
|
}
|
|
@@ -582,6 +602,20 @@ export class BridgeSessionCore {
|
|
|
582
602
|
await this.handleLoginCancelCommand(scopeId, locale, args);
|
|
583
603
|
return;
|
|
584
604
|
}
|
|
605
|
+
case 'cli': {
|
|
606
|
+
const binding = this.store.getBinding(scopeId);
|
|
607
|
+
const server = this.app.getServerStatus();
|
|
608
|
+
if (!server.running || !server.port) {
|
|
609
|
+
await this.sendMessage(scopeId, locale === 'zh' ? 'Codex 服务不可用,请在本机检查 foxclaw status。' : 'Codex server unavailable. Check foxclaw status locally.');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const threadArg = binding ? ` ${JSON.stringify(binding.threadId)}` : '';
|
|
613
|
+
await this.sendMessage(scopeId, (locale === 'zh'
|
|
614
|
+
? '在运行桥的这台机器上执行,进入同一个 Codex 服务后可中断或继续会话:\n'
|
|
615
|
+
: 'Run on the bridge host to interrupt or continue in the same Codex server:\n')
|
|
616
|
+
+ `codex resume --remote ws://127.0.0.1:${server.port}${threadArg}`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
585
619
|
case 'logout': {
|
|
586
620
|
await this.handleLogoutCommand(scopeId, locale, args);
|
|
587
621
|
return;
|
|
@@ -1072,6 +1106,21 @@ export class BridgeSessionCore {
|
|
|
1072
1106
|
async handleCallback(event) {
|
|
1073
1107
|
const scopeId = event.scopeId;
|
|
1074
1108
|
const locale = this.localeForChat(scopeId, event.languageCode);
|
|
1109
|
+
if (this.forceTakeoversInProgress.has(scopeId)) {
|
|
1110
|
+
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'force_takeover_busy'));
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const forceTakeoverMatch = /^takeover:(confirm|cancel):([a-f0-9]+)$/.exec(event.data);
|
|
1114
|
+
if (forceTakeoverMatch) {
|
|
1115
|
+
await this.handleForceTakeoverCallback(event, locale, forceTakeoverMatch[1], forceTakeoverMatch[2]);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const loginCancelMatch = /^login:cancel:(.+)$/.exec(event.data);
|
|
1119
|
+
if (loginCancelMatch) {
|
|
1120
|
+
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'button_cancel'));
|
|
1121
|
+
await this.handleLoginCancelCommand(scopeId, locale, [loginCancelMatch[1]]);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1075
1124
|
const interruptMatch = /^turn:interrupt:(.+)$/.exec(event.data);
|
|
1076
1125
|
if (interruptMatch) {
|
|
1077
1126
|
await this.handleTurnInterruptCallback(event, interruptMatch[1], locale);
|
|
@@ -3654,7 +3703,9 @@ export class BridgeSessionCore {
|
|
|
3654
3703
|
if (!scopeId)
|
|
3655
3704
|
return;
|
|
3656
3705
|
try {
|
|
3657
|
-
|
|
3706
|
+
const locale = this.localeForChat(scopeId);
|
|
3707
|
+
const hint = isThreadActiveWriterError(error) ? `\n${t(locale, 'force_takeover_hint')}` : '';
|
|
3708
|
+
await this.sendMessage(scopeId, t(locale, 'bridge_error', { error: formatUserError(error) }) + hint);
|
|
3658
3709
|
}
|
|
3659
3710
|
catch (notifyError) {
|
|
3660
3711
|
this.logger.error('telegram.error_notification_failed', { error: toErrorMeta(notifyError), scopeId });
|
|
@@ -3887,7 +3938,7 @@ export class BridgeSessionCore {
|
|
|
3887
3938
|
}
|
|
3888
3939
|
}
|
|
3889
3940
|
for (const active of [...this.activeTurns.values()]) {
|
|
3890
|
-
if (recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
|
|
3941
|
+
if (active.isObserved || recoveredScopes.has(active.scopeId) || !this.getActiveTurn(active.scopeId, active.turnId)) {
|
|
3891
3942
|
continue;
|
|
3892
3943
|
}
|
|
3893
3944
|
try {
|
|
@@ -3900,6 +3951,10 @@ export class BridgeSessionCore {
|
|
|
3900
3951
|
turnId: active.turnId,
|
|
3901
3952
|
error: toErrorMeta(error),
|
|
3902
3953
|
});
|
|
3954
|
+
await this.sendMessage(active.scopeId, this.localeForChat(active.scopeId) === 'zh'
|
|
3955
|
+
? '连接已恢复,但无法确认原任务状态。任务未自动重发,请用 /cli 检查原会话后再继续。'
|
|
3956
|
+
: 'Connected again, but the original task state could not be confirmed. It was not resent. Use /cli to inspect the session.')
|
|
3957
|
+
.catch(notifyError => this.logger.warn('codex.recovery_notice_failed', { error: toErrorMeta(notifyError) }));
|
|
3903
3958
|
}
|
|
3904
3959
|
}
|
|
3905
3960
|
await this.recoverQueuedTurns();
|
|
@@ -4246,6 +4301,10 @@ export class BridgeSessionCore {
|
|
|
4246
4301
|
}
|
|
4247
4302
|
}
|
|
4248
4303
|
async handleTakeoverCommand(event, locale, args) {
|
|
4304
|
+
if (args[0] === '--force') {
|
|
4305
|
+
await this.prepareForceTakeover(event, locale, args.slice(1).join(' ').trim());
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4249
4308
|
const scopeId = event.scopeId;
|
|
4250
4309
|
const nextPrompt = args.join(' ').trim();
|
|
4251
4310
|
if (!nextPrompt) {
|
|
@@ -4263,10 +4322,113 @@ export class BridgeSessionCore {
|
|
|
4263
4322
|
await this.requestInterrupt(active);
|
|
4264
4323
|
}
|
|
4265
4324
|
await this.sendMessage(scopeId, t(locale, 'interrupt_requested_waiting'));
|
|
4266
|
-
|
|
4325
|
+
let timer;
|
|
4326
|
+
try {
|
|
4327
|
+
await Promise.race([
|
|
4328
|
+
active.completion,
|
|
4329
|
+
new Promise((_, reject) => {
|
|
4330
|
+
timer = setTimeout(() => reject(new Error(locale === 'zh'
|
|
4331
|
+
? '等待中断完成超时,尚未发送新任务。请用 /status 检查,或用 /cli 从终端恢复。'
|
|
4332
|
+
: 'Interrupt confirmation timed out. No replacement task was sent. Check /status or use /cli to recover.')), 30_000);
|
|
4333
|
+
}),
|
|
4334
|
+
]);
|
|
4335
|
+
}
|
|
4336
|
+
finally {
|
|
4337
|
+
clearTimeout(timer);
|
|
4338
|
+
}
|
|
4267
4339
|
}
|
|
4268
4340
|
await this.startBoundTurnFromEvent(event, locale, nextPrompt);
|
|
4269
4341
|
}
|
|
4342
|
+
async prepareForceTakeover(event, locale, prompt) {
|
|
4343
|
+
const scopeId = event.scopeId;
|
|
4344
|
+
this.pendingForceTakeovers.delete(scopeId);
|
|
4345
|
+
if (!prompt) {
|
|
4346
|
+
await this.sendMessage(scopeId, t(locale, 'usage_takeover'));
|
|
4347
|
+
return;
|
|
4348
|
+
}
|
|
4349
|
+
if (!scopeId.startsWith('telegram:') || event.userId !== this.config.tgAllowedUserId) {
|
|
4350
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_trusted_telegram_only'));
|
|
4351
|
+
return;
|
|
4352
|
+
}
|
|
4353
|
+
const binding = this.store.getBinding(scopeId);
|
|
4354
|
+
if (!binding) {
|
|
4355
|
+
await this.sendMessage(scopeId, t(locale, 'watch_no_thread_bound'));
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
4358
|
+
const home = this.config.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
4359
|
+
try {
|
|
4360
|
+
const writer = await this.externalWriterControl.inspect(home, binding.threadId);
|
|
4361
|
+
const id = crypto.randomBytes(8).toString('hex');
|
|
4362
|
+
this.pendingForceTakeovers.set(scopeId, {
|
|
4363
|
+
id, expiresAt: Date.now() + 60_000, event, threadId: binding.threadId, home, prompt, writer,
|
|
4364
|
+
});
|
|
4365
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_confirm', {
|
|
4366
|
+
threadId: binding.threadId, pid: String(writer.pid), cwd: writer.cwd, prompt,
|
|
4367
|
+
}), [[
|
|
4368
|
+
{ text: t(locale, 'button_force_takeover'), callback_data: `takeover:confirm:${id}` },
|
|
4369
|
+
{ text: t(locale, 'button_cancel'), callback_data: `takeover:cancel:${id}` },
|
|
4370
|
+
]]);
|
|
4371
|
+
}
|
|
4372
|
+
catch (error) {
|
|
4373
|
+
this.pendingForceTakeovers.delete(scopeId);
|
|
4374
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_failed', { error: formatUserError(error) }));
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
async handleForceTakeoverCallback(event, locale, action, id) {
|
|
4378
|
+
const scopeId = event.scopeId;
|
|
4379
|
+
const pending = this.pendingForceTakeovers.get(scopeId);
|
|
4380
|
+
if (!pending || pending.id !== id || pending.event.userId !== event.userId
|
|
4381
|
+
|| event.userId !== this.config.tgAllowedUserId || pending.expiresAt <= Date.now()) {
|
|
4382
|
+
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'force_takeover_expired'));
|
|
4383
|
+
return;
|
|
4384
|
+
}
|
|
4385
|
+
// Claim before any await: duplicate callbacks cannot stop or submit twice.
|
|
4386
|
+
this.pendingForceTakeovers.delete(scopeId);
|
|
4387
|
+
await this.messaging.answerCallback(event.callbackQueryId, t(locale, action === 'cancel' ? 'button_cancel' : 'button_force_takeover'));
|
|
4388
|
+
if (action === 'cancel') {
|
|
4389
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_cancelled'));
|
|
4390
|
+
return;
|
|
4391
|
+
}
|
|
4392
|
+
await this.withLock(scopeId, async () => {
|
|
4393
|
+
if (pending.expiresAt <= Date.now() || this.store.getBinding(scopeId)?.threadId !== pending.threadId) {
|
|
4394
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_expired'));
|
|
4395
|
+
return;
|
|
4396
|
+
}
|
|
4397
|
+
if (this.externalAuthValidationInProgress) {
|
|
4398
|
+
await this.sendMessage(scopeId, t(locale, 'auth_sync_validation_busy'));
|
|
4399
|
+
return;
|
|
4400
|
+
}
|
|
4401
|
+
this.forceTakeoversInProgress.add(scopeId);
|
|
4402
|
+
this.turnStartInProgress += 1;
|
|
4403
|
+
try {
|
|
4404
|
+
try {
|
|
4405
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_stopping', { pid: String(pending.writer.pid) }));
|
|
4406
|
+
await this.externalWriterControl.stop(pending.home, pending.threadId, pending.writer);
|
|
4407
|
+
this.logger.warn('codex.external_writer_stopped', { scopeId, threadId: pending.threadId, pid: pending.writer.pid });
|
|
4408
|
+
await this.stopWatchingScopeThread(scopeId);
|
|
4409
|
+
this.attachedThreads.delete(attachedThreadKey(scopeId, pending.threadId));
|
|
4410
|
+
// Never replace a missing thread with an unrelated new conversation.
|
|
4411
|
+
const binding = this.store.getBinding(scopeId);
|
|
4412
|
+
if (binding?.threadId !== pending.threadId)
|
|
4413
|
+
throw new Error(t(locale, 'force_takeover_expired'));
|
|
4414
|
+
await this.ensureThreadReady(scopeId, binding, { recoverMissingThread: false });
|
|
4415
|
+
}
|
|
4416
|
+
catch (error) {
|
|
4417
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_failed', { error: formatUserError(error) }));
|
|
4418
|
+
return;
|
|
4419
|
+
}
|
|
4420
|
+
this.store.cancelQueuedTurnInputs(scopeId);
|
|
4421
|
+
await this.sendMessage(scopeId, t(locale, 'force_takeover_acquired', { threadId: pending.threadId }));
|
|
4422
|
+
// Normal turn-start error handling preserves ambiguous submission failures;
|
|
4423
|
+
// do not automatically retry a possibly accepted prompt.
|
|
4424
|
+
await this.startBoundTurnFromEvent(pending.event, locale, pending.prompt);
|
|
4425
|
+
}
|
|
4426
|
+
finally {
|
|
4427
|
+
this.forceTakeoversInProgress.delete(scopeId);
|
|
4428
|
+
this.turnStartInProgress -= 1;
|
|
4429
|
+
}
|
|
4430
|
+
});
|
|
4431
|
+
}
|
|
4270
4432
|
async handleQueueCommand(event, locale, args) {
|
|
4271
4433
|
const scopeId = event.scopeId;
|
|
4272
4434
|
const nextPrompt = args.join(' ').trim();
|
|
@@ -5376,7 +5538,7 @@ export class BridgeSessionCore {
|
|
|
5376
5538
|
t(locale, 'login_code', { value: login.userCode }),
|
|
5377
5539
|
t(locale, 'login_id', { value: login.loginId }),
|
|
5378
5540
|
t(locale, 'login_cancel_hint', { value: login.loginId }),
|
|
5379
|
-
].join('\n'));
|
|
5541
|
+
].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
|
|
5380
5542
|
}
|
|
5381
5543
|
catch (error) {
|
|
5382
5544
|
await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
|
|
@@ -5419,7 +5581,7 @@ export class BridgeSessionCore {
|
|
|
5419
5581
|
t(locale, 'login_code', { value: login.userCode }),
|
|
5420
5582
|
t(locale, 'login_id', { value: login.loginId }),
|
|
5421
5583
|
t(locale, 'login_cancel_hint', { value: login.loginId }),
|
|
5422
|
-
].join('\n'));
|
|
5584
|
+
].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
|
|
5423
5585
|
}
|
|
5424
5586
|
catch (error) {
|
|
5425
5587
|
await this.restoreAuthAfterAddFailure(state.authDir, state.authPath, state.currentTargetPath);
|
|
@@ -5584,6 +5746,11 @@ export class BridgeSessionCore {
|
|
|
5584
5746
|
}
|
|
5585
5747
|
}
|
|
5586
5748
|
async handleLoginDeviceCommand(scopeId, locale) {
|
|
5749
|
+
const pendingLoginId = this.pendingLoginsByScope.get(scopeId);
|
|
5750
|
+
if (pendingLoginId) {
|
|
5751
|
+
await this.sendMessage(scopeId, t(locale, 'login_cancel_hint', { value: pendingLoginId }), this.loginCancelKeyboard(pendingLoginId, locale));
|
|
5752
|
+
return;
|
|
5753
|
+
}
|
|
5587
5754
|
const login = await this.app.startDeviceLogin();
|
|
5588
5755
|
const oldLoginId = this.pendingLoginsByScope.get(scopeId);
|
|
5589
5756
|
if (oldLoginId) {
|
|
@@ -5598,24 +5765,41 @@ export class BridgeSessionCore {
|
|
|
5598
5765
|
t(locale, 'login_code', { value: login.userCode }),
|
|
5599
5766
|
t(locale, 'login_id', { value: login.loginId }),
|
|
5600
5767
|
t(locale, 'login_cancel_hint', { value: login.loginId }),
|
|
5601
|
-
].join('\n'));
|
|
5768
|
+
].join('\n'), this.loginCancelKeyboard(login.loginId, locale));
|
|
5769
|
+
}
|
|
5770
|
+
loginCancelKeyboard(loginId, locale) {
|
|
5771
|
+
return [[{ text: t(locale, 'button_cancel'), callback_data: `login:cancel:${loginId}` }]];
|
|
5602
5772
|
}
|
|
5603
5773
|
async handleLoginCancelCommand(scopeId, locale, args) {
|
|
5604
5774
|
const loginId = args[0]?.trim() || this.pendingLoginsByScope.get(scopeId) || null;
|
|
5605
|
-
if (!loginId) {
|
|
5775
|
+
if (!loginId || this.pendingLoginsByScope.get(scopeId) !== loginId) {
|
|
5606
5776
|
await this.sendMessage(scopeId, t(locale, 'login_cancel_no_pending'));
|
|
5607
5777
|
return;
|
|
5608
5778
|
}
|
|
5609
5779
|
const pendingAuthAdd = this.pendingAuthAddsByLoginId.get(loginId) ?? null;
|
|
5610
|
-
|
|
5780
|
+
// Claim cancellation before awaiting RPC: completion notifications may arrive first.
|
|
5611
5781
|
this.pendingLoginsByScope.delete(scopeId);
|
|
5612
5782
|
this.pendingLoginScopesById.delete(loginId);
|
|
5613
5783
|
this.pendingAuthAddsByLoginId.delete(loginId);
|
|
5784
|
+
let cancelError;
|
|
5785
|
+
try {
|
|
5786
|
+
await this.app.cancelLogin(loginId);
|
|
5787
|
+
}
|
|
5788
|
+
catch (error) {
|
|
5789
|
+
cancelError = error;
|
|
5790
|
+
this.logger.warn('codex.login_cancel_failed', { error: toErrorMeta(error) });
|
|
5791
|
+
}
|
|
5614
5792
|
if (pendingAuthAdd) {
|
|
5615
5793
|
await this.restorePendingAuthAdd(pendingAuthAdd);
|
|
5616
5794
|
await this.sendMessage(scopeId, t(locale, 'auth_add_cancelled'));
|
|
5617
5795
|
return;
|
|
5618
5796
|
}
|
|
5797
|
+
if (cancelError) {
|
|
5798
|
+
await this.sendMessage(scopeId, locale === 'zh'
|
|
5799
|
+
? '已退出本地登录流程,但未能确认服务端取消。请勿继续使用旧验证码;可重新登录或用 /codex_restart 重置登录服务。'
|
|
5800
|
+
: 'Local login flow cleared, but server cancellation is unconfirmed. Do not use the old code; start a new login or use /codex_restart.');
|
|
5801
|
+
return;
|
|
5802
|
+
}
|
|
5619
5803
|
await this.sendMessage(scopeId, t(locale, 'login_cancelled'));
|
|
5620
5804
|
}
|
|
5621
5805
|
async handleLogoutCommand(scopeId, locale, args) {
|
|
@@ -6162,6 +6346,20 @@ export class BridgeSessionCore {
|
|
|
6162
6346
|
await this.editAuthPanelMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
|
|
6163
6347
|
}
|
|
6164
6348
|
}
|
|
6349
|
+
async refreshRepairedAuthChoice(event, record, candidateName, locale) {
|
|
6350
|
+
const state = await this.listCodexAuthState();
|
|
6351
|
+
if (state.candidates.find(candidate => candidate.name === candidateName)?.state === 'needs_repair')
|
|
6352
|
+
return false;
|
|
6353
|
+
record.candidates = state.candidates;
|
|
6354
|
+
record.createdAt = Date.now();
|
|
6355
|
+
clampCodexAuthListOffset(record);
|
|
6356
|
+
await this.messaging.answerCallback(event.callbackQueryId, locale === 'zh'
|
|
6357
|
+
? '授权状态已变化,已刷新面板。' : 'Auth state changed; panel refreshed.');
|
|
6358
|
+
if (record.messageId !== null) {
|
|
6359
|
+
await this.editAuthPanelMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null, record), authChoiceKeyboard(locale, record));
|
|
6360
|
+
}
|
|
6361
|
+
return true;
|
|
6362
|
+
}
|
|
6165
6363
|
async handleAuthRepairMenuCallback(event, localId, index, locale) {
|
|
6166
6364
|
const record = this.pendingAuthChoiceLists.get(localId);
|
|
6167
6365
|
if (!record) {
|
|
@@ -6177,6 +6375,8 @@ export class BridgeSessionCore {
|
|
|
6177
6375
|
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
|
|
6178
6376
|
return;
|
|
6179
6377
|
}
|
|
6378
|
+
if (await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
|
|
6379
|
+
return;
|
|
6180
6380
|
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'auth_repair_actions_short'));
|
|
6181
6381
|
if (record.messageId !== null) {
|
|
6182
6382
|
await this.editAuthPanelMessage(event.scopeId, record.messageId, t(locale, 'auth_repair_actions_message', { value: formatCodexAuthCandidateDisplayName(candidate.name) }), authRepairKeyboard(locale, record, index));
|
|
@@ -6197,6 +6397,8 @@ export class BridgeSessionCore {
|
|
|
6197
6397
|
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'unsupported_action'));
|
|
6198
6398
|
return;
|
|
6199
6399
|
}
|
|
6400
|
+
if (action !== 'cancel' && await this.refreshRepairedAuthChoice(event, record, candidate.name, locale))
|
|
6401
|
+
return;
|
|
6200
6402
|
if (action === 'cancel') {
|
|
6201
6403
|
await this.messaging.answerCallback(event.callbackQueryId, t(locale, 'decision_recorded'));
|
|
6202
6404
|
const state = await this.listCodexAuthState();
|