@foxden-app/foxclaw 0.7.1 → 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 +30 -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 +4 -0
- package/dist/codex_app/client.js +43 -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 +8 -0
- package/dist/controller/controller.js +266 -17
- package/dist/i18n.d.ts +36 -8
- package/dist/i18n.js +38 -8
- 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 +14 -2
- 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 +16 -2
- 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,36 @@
|
|
|
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
|
+
|
|
25
|
+
## 0.7.2 - 2026-08-30
|
|
26
|
+
|
|
27
|
+
### 中文
|
|
28
|
+
- 为 Codex CLI 0.151.0 及以上版本接入原生跨客户端线程队列。在 Telegram `/watch` 观察电脑端 CLI 会话时,直接发送文字或使用 `/queue <消息>` 会把下一轮任务排入同一线程,由桌面 CLI 在当前轮结束后继续执行。
|
|
29
|
+
- 保持观察模式的 writer 安全边界:FoxClaw 不会 `resume`、中断或接管外部 CLI,`/steer` 仍明确拒绝;旧版 Codex 缺少队列接口时会提示升级,不会改写 session 文件或报告虚假成功。
|
|
30
|
+
|
|
31
|
+
### English
|
|
32
|
+
- 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.
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
5
35
|
## 0.7.1 - 2026-08-30
|
|
6
36
|
|
|
7
37
|
### 中文
|
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;
|
|
@@ -98,6 +99,9 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
98
99
|
steerTurn(threadId: string, expectedTurnId: string, input: TurnInput[]): Promise<{
|
|
99
100
|
turnId: string;
|
|
100
101
|
}>;
|
|
102
|
+
queueThreadInput(threadId: string, clientUserMessageId: string, input: TurnInput[]): Promise<{
|
|
103
|
+
queuedSubmissionId: string;
|
|
104
|
+
}>;
|
|
101
105
|
forkThread(options: {
|
|
102
106
|
threadId: string;
|
|
103
107
|
cwd: 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;
|
|
@@ -186,6 +187,14 @@ export class CodexAppClient extends EventEmitter {
|
|
|
186
187
|
const result = await this.request('turn/steer', { threadId, expectedTurnId, input });
|
|
187
188
|
return { turnId: String(result?.turnId ?? expectedTurnId) };
|
|
188
189
|
}
|
|
190
|
+
async queueThreadInput(threadId, clientUserMessageId, input) {
|
|
191
|
+
const result = await this.request('thread/queue/add', { threadId, clientUserMessageId, input });
|
|
192
|
+
const queuedSubmissionId = result?.queuedSubmission?.id;
|
|
193
|
+
if (typeof queuedSubmissionId !== 'string' || !queuedSubmissionId) {
|
|
194
|
+
throw new Error('thread/queue/add returned no queued submission id');
|
|
195
|
+
}
|
|
196
|
+
return { queuedSubmissionId };
|
|
197
|
+
}
|
|
189
198
|
async forkThread(options) {
|
|
190
199
|
const params = {
|
|
191
200
|
threadId: options.threadId,
|
|
@@ -569,6 +578,9 @@ export class CodexAppClient extends EventEmitter {
|
|
|
569
578
|
port: state.port,
|
|
570
579
|
error: error instanceof Error ? error.message : String(error),
|
|
571
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
|
+
}
|
|
572
584
|
this.clearServerStateForPid(state.pid);
|
|
573
585
|
return false;
|
|
574
586
|
}
|
|
@@ -580,11 +592,18 @@ export class CodexAppClient extends EventEmitter {
|
|
|
580
592
|
try {
|
|
581
593
|
await new Promise((resolve, reject) => {
|
|
582
594
|
const ws = new WebSocket(url);
|
|
595
|
+
const timer = setTimeout(() => {
|
|
596
|
+
ws.close();
|
|
597
|
+
reject(new Error('WebSocket handshake timed out'));
|
|
598
|
+
}, 2000);
|
|
583
599
|
const onError = (event) => {
|
|
600
|
+
clearTimeout(timer);
|
|
584
601
|
ws.close();
|
|
585
602
|
reject(new Error(`WebSocket connect failed: ${String(event.type)}`));
|
|
586
603
|
};
|
|
587
604
|
ws.addEventListener('open', () => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
ws.removeEventListener('error', onError);
|
|
588
607
|
this.socket = ws;
|
|
589
608
|
this.connected = true;
|
|
590
609
|
ws.addEventListener('message', message => this.handleMessage(String(message.data)));
|
|
@@ -600,7 +619,12 @@ export class CodexAppClient extends EventEmitter {
|
|
|
600
619
|
});
|
|
601
620
|
});
|
|
602
621
|
ws.addEventListener('error', err => {
|
|
603
|
-
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
|
+
}
|
|
604
628
|
});
|
|
605
629
|
resolve();
|
|
606
630
|
}, { once: true });
|
|
@@ -641,8 +665,22 @@ export class CodexAppClient extends EventEmitter {
|
|
|
641
665
|
}
|
|
642
666
|
const id = String(++this.requestId);
|
|
643
667
|
return new Promise((resolve, reject) => {
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
}
|
|
646
684
|
});
|
|
647
685
|
}
|
|
648
686
|
send(payload) {
|
|
@@ -682,6 +720,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
682
720
|
}
|
|
683
721
|
}
|
|
684
722
|
handleDisconnect(meta) {
|
|
723
|
+
this.logger.warn('codex.disconnected', meta);
|
|
685
724
|
if (this.connected) {
|
|
686
725
|
this.connected = false;
|
|
687
726
|
}
|
|
@@ -703,7 +742,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
703
742
|
this.reconnectTimer = setTimeout(async () => {
|
|
704
743
|
this.reconnectTimer = null;
|
|
705
744
|
try {
|
|
706
|
-
await this.
|
|
745
|
+
await this.start();
|
|
707
746
|
}
|
|
708
747
|
catch (error) {
|
|
709
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,8 +290,11 @@ 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;
|
|
297
|
+
private queueObservedThreadMessage;
|
|
292
298
|
private queuePromptAfterActiveTurn;
|
|
293
299
|
private steerActiveTurn;
|
|
294
300
|
private enqueuePreparedTurnInput;
|
|
@@ -340,6 +346,7 @@ export declare class BridgeSessionCore {
|
|
|
340
346
|
private handleVoiceCallback;
|
|
341
347
|
private sendVoiceForText;
|
|
342
348
|
private handleLoginDeviceCommand;
|
|
349
|
+
private loginCancelKeyboard;
|
|
343
350
|
private handleLoginCancelCommand;
|
|
344
351
|
private handleLogoutCommand;
|
|
345
352
|
private handleSteerCommand;
|
|
@@ -376,6 +383,7 @@ export declare class BridgeSessionCore {
|
|
|
376
383
|
private requireReadyBinding;
|
|
377
384
|
private handleAuthPanelActionCallback;
|
|
378
385
|
private handleAuthListViewCallback;
|
|
386
|
+
private refreshRepairedAuthChoice;
|
|
379
387
|
private handleAuthRepairMenuCallback;
|
|
380
388
|
private handleAuthRepairActionCallback;
|
|
381
389
|
private handleAuthToggleCallback;
|