@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 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
  ![FoxClaw Telegram 功能预览](./docs/assets/telegram-preview-zh.svg)
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
  ![FoxClaw Telegram feature preview](./docs/assets/telegram-preview-en.svg)
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;
@@ -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', String(err.message ?? 'unknown'));
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
- this.pending.set(id, { resolve, reject });
645
- this.send({ jsonrpc: '2.0', id, method, params });
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.startServer();
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;