@foxden-app/foxclaw 0.4.0 → 0.4.2

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,7 +1,9 @@
1
- # Required for new installs: one or more bot tokens for this host
2
- # Comma-separated bot tokens. Each bot gets an independent Codex runtime/auth selection.
1
+ # Required for new installs: one or more bot tokens for this host.
2
+ # Comma-separated bot tokens. By default, each bot gets an independent Codex runtime/auth selection.
3
3
  TG_BOT_TOKENS=<telegram_bot_token>
4
- # Backward-compatible single-runtime setup:
4
+ # Backward-compatible single-runtime setup.
5
+ # In TG_BOT_TOKENS mode, if this exact token is also present in TG_BOT_TOKENS,
6
+ # that bot uses the default terminal CODEX_HOME/auth instead of an isolated home.
5
7
  # TG_BOT_TOKEN=<telegram_bot_token>
6
8
 
7
9
  # Required: only this Telegram user can control the bridge
@@ -16,7 +18,8 @@ TG_ALLOWED_TOPIC_ID=
16
18
 
17
19
  # Optional Codex Desktop bridge settings.
18
20
  # In TG_BOT_TOKENS mode isolated Telegram runtimes start app-server directly and
19
- # do not auto-launch Codex Desktop; this remains applicable to the default/Weixin runtime.
21
+ # do not auto-launch Codex Desktop; this remains applicable to the default/shared
22
+ # terminal runtime and Weixin runtime.
20
23
  CODEX_APP_AUTOLAUNCH=true
21
24
  CODEX_APP_LAUNCH_CMD=codex app
22
25
  # Optional: persist the managed app-server pid/port and collect its stdout/stderr
package/README.md CHANGED
@@ -40,7 +40,7 @@ FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex,
40
40
  - Git、Node、`.env` 都玩得转?直接往下看快速设置。
41
41
  - 卡住了?看 [故障排查](./docs/zh/troubleshooting.md)。
42
42
 
43
- 最低要求:一个或多个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。新安装请使用 `TG_BOT_TOKENS`;`TG_BOT_TOKEN` 只用于兼容旧的单 runtime 配置。
43
+ 最低要求:一个或多个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。新安装请使用 `TG_BOT_TOKENS`;`TG_BOT_TOKEN` 兼容旧的单 runtime 配置,也可在多 bot 模式中标记一个“默认/终端共享”bot。
44
44
 
45
45
  **30 秒体验**:启动 FoxClaw 后,给你的 Telegram 机器人发一句 `List files in DEFAULT_CWD`。Codex 会在本地检查那个目录,然后把结果发回 Telegram。
46
46
 
@@ -111,7 +111,8 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
111
111
  **可靠性:**
112
112
  - SQLite 持久化:绑定、offset、审批、待处理提示、审计日志
113
113
  - 单实例进程锁,防止同一 bot token 重复 polling
114
- - `TG_BOT_TOKENS` 支持一台机器同时运行多个 Telegram bot;每个 bot 使用独立 Codex app-server、会话和 `/auth` 选择
114
+ - `TG_BOT_TOKENS` 支持一台机器同时运行多个 Telegram bot;默认每个 bot 使用独立 Codex app-server、会话和 `/auth` 选择
115
+ - 如果同时设置 `TG_BOT_TOKEN`,且它的值也出现在 `TG_BOT_TOKENS` 中,匹配的那个 bot 会共享终端默认 `CODEX_HOME` 和 auth,其他 bot 继续隔离
115
116
 
116
117
  </details>
117
118
 
@@ -201,9 +202,13 @@ TG_ALLOWED_TOPIC_ID=42
201
202
 
202
203
  ```dotenv
203
204
  TG_BOT_TOKENS=123456:token_a,234567:token_b
205
+ # Optional: mark token_a as the bot sharing terminal/default CODEX_HOME and auth
206
+ TG_BOT_TOKEN=123456:token_a
204
207
  ```
205
208
 
206
- FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、当前 auth 和活动 turn 摘要。
209
+ FoxClaw 仍然只运行一个系统服务。默认情况下,它会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、runtime 类型、当前 auth 和活动 turn 摘要。
210
+
211
+ 如果你需要一路 Telegram 与终端互通 session,把同一个 token 同时填入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME`(未设置时通常是 `~/.codex`)和默认 auth,因此能看到终端 Codex 的本地线程;它不再享有隔离 runtime 的“互不影响”保证,切换 auth 会影响终端和其他默认 runtime。
207
212
 
208
213
  **怎么找群组和话题 ID:**
209
214
 
@@ -239,7 +244,7 @@ CODEX_APP_SYNC_ON_OPEN=true
239
244
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
240
245
  ```
241
246
 
242
- FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server 与 Codex home,并在该隔离 runtime 内强制使用文件凭据存储;隔离 bot 不自动拉起 Codex Desktop,避免多个新 home 同时初始化桌面状态。重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
247
+ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,默认每个 bot 都有自己的 app-server 与 Codex home,并在该隔离 runtime 内强制使用文件凭据存储;隔离 bot 不自动拉起 Codex Desktop,避免多个新 home 同时初始化桌面状态。用 `TG_BOT_TOKEN` 标记的默认/终端共享 bot 例外:它使用默认 Codex home、默认 auth 和默认 app-server 配置。重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
243
248
 
244
249
  一般不需要手动固定 app-server 端口。
245
250
 
package/README_EN.md CHANGED
@@ -40,7 +40,7 @@ FoxClaw is more than message forwarding. It provides Telegram panels for Codex w
40
40
  - Already comfortable with Git, Node, and `.env` files? Use the quick setup below.
41
41
  - Something failed? Check [Troubleshooting](./docs/troubleshooting.md).
42
42
 
43
- The minimum install needs one or more Telegram bot tokens, your numeric Telegram user id, Node.js 24+, and a logged-in `codex` CLI. A first install usually takes 10–20 minutes. New installs should use `TG_BOT_TOKENS`; `TG_BOT_TOKEN` exists only for legacy single-runtime compatibility.
43
+ The minimum install needs one or more Telegram bot tokens, your numeric Telegram user id, Node.js 24+, and a logged-in `codex` CLI. A first install usually takes 10–20 minutes. New installs should use `TG_BOT_TOKENS`; `TG_BOT_TOKEN` remains compatible with legacy single-runtime setups and can mark one multi-bot runtime as default/shared-terminal.
44
44
 
45
45
  **30-second demo**: after FoxClaw is running, send `List files in DEFAULT_CWD` to your Telegram bot. FoxClaw asks local Codex to inspect that folder on your computer and sends the answer back to Telegram.
46
46
 
@@ -111,7 +111,8 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
111
111
  **Reliability:**
112
112
  - SQLite persistence for bindings, offsets, approvals, pending input prompts, and audit logs
113
113
  - Single-instance process lock to prevent duplicate Telegram polling on the same bot token
114
- - `TG_BOT_TOKENS` runs multiple Telegram bots on one host with independent Codex app-servers, sessions, and `/auth` selections
114
+ - `TG_BOT_TOKENS` runs multiple Telegram bots on one host with independent Codex app-servers, sessions, and `/auth` selections by default
115
+ - If `TG_BOT_TOKEN` is also set and exactly matches one token in `TG_BOT_TOKENS`, that bot shares the terminal/default `CODEX_HOME` and auth while the other bots stay isolated
115
116
 
116
117
  </details>
117
118
 
@@ -201,9 +202,13 @@ Parallel bot example:
201
202
 
202
203
  ```dotenv
203
204
  TG_BOT_TOKENS=123456:token_a,234567:token_b
205
+ # Optional: mark token_a as the bot sharing terminal/default CODEX_HOME and auth
206
+ TG_BOT_TOKEN=123456:token_a
204
207
  ```
205
208
 
206
- FoxClaw remains one system service, but starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after validated login or refresh; current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, selected auth, and active turns.
209
+ FoxClaw remains one system service. By default, it starts an independent `codex app-server` and `CODEX_HOME` for each bot. While bot A is running a turn, bot B can switch its own `/auth` selection. Candidate credentials are mirrored only after validated login or refresh; current selections remain independent. Send `/help` and `/status` in a private chat with each bot after installation; `/auth` names the runtime being managed, and `/status` summarizes every bot's connection, runtime type, selected auth, and active turns.
210
+
211
+ To keep one Telegram bot interoperable with terminal Codex sessions, put the same token in both `TG_BOT_TOKENS` and `TG_BOT_TOKEN`. That bot uses the default `CODEX_HOME` (usually `~/.codex` when unset) and default auth, so it can see local terminal sessions. It no longer has the isolated runtime guarantee: `/auth` switches also affect the terminal and other default runtimes.
207
212
 
208
213
  **How to find group and topic IDs:**
209
214
 
@@ -239,7 +244,7 @@ CODEX_APP_SYNC_ON_OPEN=true
239
244
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
240
245
  ```
241
246
 
242
- FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. With `TG_BOT_TOKENS`, each bot has its own app-server and Codex home, and its isolated runtime is forced to use file-backed credentials. Isolated Telegram runtimes do not auto-launch Codex Desktop, avoiding simultaneous desktop-state initialization for fresh homes. On restart, FoxClaw reconnects to each live recorded app-server or starts a replacement. `/auth_reload` and auth switching restart only the requesting bot runtime.
247
+ FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. With `TG_BOT_TOKENS`, each bot has its own app-server and Codex home by default, and its isolated runtime is forced to use file-backed credentials. Isolated Telegram runtimes do not auto-launch Codex Desktop, avoiding simultaneous desktop-state initialization for fresh homes. The default/shared-terminal bot marked with `TG_BOT_TOKEN` is the exception: it uses the default Codex home, default auth, and default app-server settings. On restart, FoxClaw reconnects to each live recorded app-server or starts a replacement. `/auth_reload` and auth switching restart only the requesting bot runtime.
243
248
 
244
249
  No static Codex app-server port is required in normal installs.
245
250
 
@@ -4,6 +4,7 @@ export interface AuthMirrorRuntime {
4
4
  label?: string;
5
5
  authDir: string;
6
6
  notify?: (message: string) => Promise<void>;
7
+ validate?: (context: AuthMirrorValidationContext) => Promise<AuthMirrorValidationResult | boolean>;
7
8
  }
8
9
  export interface AuthMirrorStatus {
9
10
  candidateName: string;
@@ -11,6 +12,15 @@ export interface AuthMirrorStatus {
11
12
  sourceLabel: string;
12
13
  syncedAt: string;
13
14
  }
15
+ export interface AuthMirrorValidationContext {
16
+ candidateName: string;
17
+ accountId: string;
18
+ lastRefreshMs: number;
19
+ }
20
+ export interface AuthMirrorValidationResult {
21
+ ok: boolean;
22
+ reason?: string | null;
23
+ }
14
24
  export declare class AuthCandidateMirror {
15
25
  private readonly canonicalDir;
16
26
  private readonly runtimes;
@@ -29,6 +39,7 @@ export declare class AuthCandidateMirror {
29
39
  syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
30
40
  private scan;
31
41
  private propagateValidatedCandidate;
42
+ private validateRuntimeCandidate;
32
43
  private withActivity;
33
44
  private ensureCanonicalDefaultCandidate;
34
45
  private collectCandidateNames;
@@ -96,6 +96,15 @@ export class AuthCandidateMirror {
96
96
  if (record.lastRefreshMs <= previousRefresh) {
97
97
  return false;
98
98
  }
99
+ const validation = await this.validateRuntimeCandidate(runtime, name, record);
100
+ if (!validation.ok) {
101
+ this.logger.warn('auth.mirror.validation_failed', {
102
+ runtimeId: runtime.id,
103
+ name,
104
+ reason: validation.reason ?? 'unknown',
105
+ });
106
+ return false;
107
+ }
99
108
  await atomicWrite(canonicalPath, record.raw);
100
109
  for (const target of this.runtimes) {
101
110
  if (target.id !== runtime.id) {
@@ -116,6 +125,25 @@ export class AuthCandidateMirror {
116
125
  await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
117
126
  return true;
118
127
  }
128
+ async validateRuntimeCandidate(runtime, name, record) {
129
+ if (!runtime.validate) {
130
+ return { ok: true };
131
+ }
132
+ try {
133
+ const result = await runtime.validate({
134
+ candidateName: name,
135
+ accountId: record.accountId,
136
+ lastRefreshMs: record.lastRefreshMs,
137
+ });
138
+ if (typeof result === 'boolean') {
139
+ return { ok: result };
140
+ }
141
+ return { ok: Boolean(result.ok), reason: result.reason ?? null };
142
+ }
143
+ catch (error) {
144
+ return { ok: false, reason: formatError(error) };
145
+ }
146
+ }
119
147
  async withActivity(operation) {
120
148
  this.activeOperations += 1;
121
149
  try {
package/dist/config.d.ts CHANGED
@@ -16,6 +16,7 @@ export interface AppConfig {
16
16
  tgBotToken: string;
17
17
  tgBotTokens: string[];
18
18
  tgMultiBotMode: boolean;
19
+ tgDefaultRuntimeBotToken: string | null;
19
20
  tgScopeBotId: string | null;
20
21
  tgRequireExplicitGroupAddressing: boolean;
21
22
  tgAllowedUserId: string;
@@ -52,4 +53,5 @@ export interface AppConfig {
52
53
  wxIlinkRouteTag: string | null;
53
54
  }
54
55
  export declare function loadConfig(): AppConfig;
56
+ export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
55
57
  export declare function ensureAppDirs(config: AppConfig): void;
package/dist/config.js CHANGED
@@ -40,6 +40,7 @@ export function loadConfig() {
40
40
  loadEnv();
41
41
  const configuredTokens = parseCommaSeparatedIds(process.env.TG_BOT_TOKENS);
42
42
  const legacyToken = optional('TG_BOT_TOKEN');
43
+ const tgDefaultRuntimeBotToken = selectDefaultRuntimeBotToken(configuredTokens, legacyToken);
43
44
  const tgBotTokens = configuredTokens.length > 0
44
45
  ? configuredTokens
45
46
  : legacyToken
@@ -52,6 +53,7 @@ export function loadConfig() {
52
53
  tgBotToken: tgBotTokens[0],
53
54
  tgBotTokens,
54
55
  tgMultiBotMode: configuredTokens.length > 0,
56
+ tgDefaultRuntimeBotToken,
55
57
  tgScopeBotId: null,
56
58
  tgRequireExplicitGroupAddressing: configuredTokens.length > 1,
57
59
  tgAllowedUserId: required('TG_ALLOWED_USER_ID'),
@@ -87,6 +89,11 @@ export function loadConfig() {
87
89
  ensureAppDirs(config);
88
90
  return config;
89
91
  }
92
+ export function selectDefaultRuntimeBotToken(configuredTokens, legacyToken) {
93
+ if (configuredTokens.length === 0 || !legacyToken)
94
+ return null;
95
+ return configuredTokens.includes(legacyToken) ? legacyToken : null;
96
+ }
90
97
  export function ensureAppDirs(config) {
91
98
  const dirs = [
92
99
  path.dirname(config.storePath),
@@ -403,6 +403,7 @@ export class BridgeSessionCore {
403
403
  lines.push(t(locale, 'status_runtime_bot', {
404
404
  bot: runtime.username ? `@${runtime.username}` : runtime.id,
405
405
  connected: t(locale, runtime.connected ? 'yes' : 'no'),
406
+ runtime: t(locale, runtime.runtimeKind === 'default' ? 'status_runtime_kind_default' : 'status_runtime_kind_isolated'),
406
407
  auth: runtime.currentAuth ?? t(locale, 'none'),
407
408
  turns: runtime.activeTurns,
408
409
  }));
package/dist/i18n.d.ts CHANGED
@@ -119,7 +119,9 @@ declare const MESSAGES: {
119
119
  readonly status_codex_credits: "Codex credits: {value}";
120
120
  readonly status_codex_limit_reached: "Codex limit: {value}";
121
121
  readonly status_runtime_overview: "Telegram bot runtimes:";
122
- readonly status_runtime_bot: "- {bot}: connected {connected}, auth {auth}, active turns {turns}";
122
+ readonly status_runtime_bot: "- {bot}: connected {connected}, runtime {runtime}, auth {auth}, active turns {turns}";
123
+ readonly status_runtime_kind_default: "default/shared terminal";
124
+ readonly status_runtime_kind_isolated: "isolated";
123
125
  readonly status_runtime_weixin: "- Weixin default runtime: connected {connected}, active turns {turns}";
124
126
  readonly status_auth_mirror_none: "Last auth mirror: none recorded";
125
127
  readonly status_auth_mirror_synced: "Last auth mirror: {candidate} from {source} at {time}";
@@ -692,7 +694,9 @@ declare const MESSAGES: {
692
694
  readonly status_codex_credits: "Codex 额度:{value}";
693
695
  readonly status_codex_limit_reached: "Codex 限制:{value}";
694
696
  readonly status_runtime_overview: "Telegram bot 运行时:";
695
- readonly status_runtime_bot: "- {bot}:连接 {connected},auth {auth},进行中回复 {turns}";
697
+ readonly status_runtime_bot: "- {bot}:连接 {connected},runtime {runtime},auth {auth},进行中回复 {turns}";
698
+ readonly status_runtime_kind_default: "默认/终端共享";
699
+ readonly status_runtime_kind_isolated: "隔离";
696
700
  readonly status_runtime_weixin: "- 微信默认运行时:连接 {connected},进行中回复 {turns}";
697
701
  readonly status_auth_mirror_none: "最近 auth 镜像:暂无记录";
698
702
  readonly status_auth_mirror_synced: "最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步";
package/dist/i18n.js CHANGED
@@ -117,7 +117,9 @@ const MESSAGES = {
117
117
  status_codex_credits: 'Codex credits: {value}',
118
118
  status_codex_limit_reached: 'Codex limit: {value}',
119
119
  status_runtime_overview: 'Telegram bot runtimes:',
120
- status_runtime_bot: '- {bot}: connected {connected}, auth {auth}, active turns {turns}',
120
+ status_runtime_bot: '- {bot}: connected {connected}, runtime {runtime}, auth {auth}, active turns {turns}',
121
+ status_runtime_kind_default: 'default/shared terminal',
122
+ status_runtime_kind_isolated: 'isolated',
121
123
  status_runtime_weixin: '- Weixin default runtime: connected {connected}, active turns {turns}',
122
124
  status_auth_mirror_none: 'Last auth mirror: none recorded',
123
125
  status_auth_mirror_synced: 'Last auth mirror: {candidate} from {source} at {time}',
@@ -690,7 +692,9 @@ const MESSAGES = {
690
692
  status_codex_credits: 'Codex 额度:{value}',
691
693
  status_codex_limit_reached: 'Codex 限制:{value}',
692
694
  status_runtime_overview: 'Telegram bot 运行时:',
693
- status_runtime_bot: '- {bot}:连接 {connected},auth {auth},进行中回复 {turns}',
695
+ status_runtime_bot: '- {bot}:连接 {connected},runtime {runtime},auth {auth},进行中回复 {turns}',
696
+ status_runtime_kind_default: '默认/终端共享',
697
+ status_runtime_kind_isolated: '隔离',
694
698
  status_runtime_weixin: '- 微信默认运行时:连接 {connected},进行中回复 {turns}',
695
699
  status_auth_mirror_none: '最近 auth 镜像:暂无记录',
696
700
  status_auth_mirror_synced: '最近 auth 镜像:{candidate} 由 {source} 于 {time} 同步',
package/dist/main.js CHANGED
@@ -170,33 +170,47 @@ async function runServeCli() {
170
170
  store = new BridgeStore(config.storePath);
171
171
  if (config.tgMultiBotMode) {
172
172
  const seeds = [];
173
+ const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
173
174
  for (const token of config.tgBotTokens) {
174
175
  const bot = new TelegramGateway(token, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, true);
175
176
  const id = await bot.initializeIdentity();
176
177
  if (seeds.some((runtime) => runtime.id === id)) {
177
178
  throw new Error(`TG_BOT_TOKENS contains duplicate Telegram bot identity: ${id}`);
178
179
  }
179
- const home = path.join(DEFAULT_CODEX_TELEGRAM_HOME, id, 'home');
180
- fs.mkdirSync(home, { recursive: true, mode: 0o700 });
180
+ const sharedDefaultRuntime = config.tgDefaultRuntimeBotToken === token;
181
+ const home = sharedDefaultRuntime
182
+ ? (config.codexHome ?? path.join(os.homedir(), '.codex'))
183
+ : path.join(DEFAULT_CODEX_TELEGRAM_HOME, id, 'home');
184
+ const authDir = sharedDefaultRuntime ? canonicalAuthDir : home;
185
+ if (!sharedDefaultRuntime) {
186
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
187
+ }
181
188
  const runtimeConfig = {
182
189
  ...config,
183
190
  tgBotToken: token,
184
191
  tgBotTokens: [token],
185
192
  tgScopeBotId: id,
186
- codexAuthDir: home,
187
- codexHome: home,
188
- codexAppAutolaunch: false,
189
- codexAppServerStatePath: path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`),
190
- codexAppServerLogPath: path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`),
193
+ codexAuthDir: sharedDefaultRuntime ? config.codexAuthDir : home,
194
+ codexHome: sharedDefaultRuntime ? config.codexHome : home,
195
+ codexAppAutolaunch: sharedDefaultRuntime ? config.codexAppAutolaunch : false,
196
+ codexAppServerStatePath: sharedDefaultRuntime
197
+ ? config.codexAppServerStatePath
198
+ : path.join(APP_HOME, 'runtime', `codex-app-server-${id}.json`),
199
+ codexAppServerLogPath: sharedDefaultRuntime
200
+ ? config.codexAppServerLogPath
201
+ : path.join(APP_HOME, 'logs', `codex-app-server-${id}.log`),
191
202
  };
192
- const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, { CODEX_HOME: home }, ['cli_auth_credentials_store="file"']);
193
- seeds.push({ id, home, config: runtimeConfig, bot, app });
203
+ const childEnv = sharedDefaultRuntime
204
+ ? (config.codexHome ? { CODEX_HOME: config.codexHome } : null)
205
+ : { CODEX_HOME: home };
206
+ const app = new CodexAppClient(runtimeConfig.codexCliBin, runtimeConfig.codexAppLaunchCmd, runtimeConfig.codexAppAutolaunch, runtimeConfig.codexAppServerStatePath, runtimeConfig.codexAppServerLogPath, logger, childEnv, sharedDefaultRuntime ? [] : ['cli_auth_credentials_store="file"']);
207
+ seeds.push({ id, home, authDir, sharedDefaultRuntime, config: runtimeConfig, bot, app });
194
208
  }
195
- const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
196
209
  const mirror = new AuthCandidateMirror(canonicalAuthDir, seeds.map((runtime) => ({
197
210
  id: runtime.id,
198
211
  label: runtime.bot.username ? `@${runtime.bot.username}` : runtime.id,
199
- authDir: runtime.home,
212
+ authDir: runtime.authDir,
213
+ validate: async (context) => validateRefreshedAuthCandidate(runtime, context.candidateName),
200
214
  notify: async (message) => {
201
215
  const chatId = store.getTelegramPrivateChatId(runtime.id);
202
216
  if (chatId) {
@@ -246,6 +260,7 @@ async function runServeCli() {
246
260
  username: statuses[index]?.botUsername ?? runtime.bot.username,
247
261
  connected: running && Boolean(statuses[index]?.connected),
248
262
  activeTurns: running ? (statuses[index]?.activeTurns ?? 0) : 0,
263
+ runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
249
264
  ...(statuses[index]?.codexAppServer ? { codexAppServer: statuses[index].codexAppServer } : {}),
250
265
  })),
251
266
  ...(weixinStatus ? {
@@ -273,6 +288,7 @@ async function runServeCli() {
273
288
  username: status.botUsername ?? runtime.bot.username,
274
289
  connected: status.connected,
275
290
  activeTurns: status.activeTurns,
291
+ runtimeKind: runtime.sharedDefaultRuntime ? 'default' : 'isolated',
276
292
  currentAuth: await runtime.core.getCurrentAuthLabel().catch(() => null),
277
293
  ...(status.codexAppServer ? { codexAppServer: status.codexAppServer } : {}),
278
294
  };
@@ -412,6 +428,26 @@ async function runServeCli() {
412
428
  throw error;
413
429
  }
414
430
  }
431
+ async function validateRefreshedAuthCandidate(runtime, candidateName) {
432
+ const authPath = path.join(runtime.authDir, 'auth.json');
433
+ const candidatePath = path.join(runtime.authDir, candidateName);
434
+ const [currentTarget, candidateTarget] = await Promise.all([
435
+ fs.promises.realpath(authPath).catch(() => null),
436
+ fs.promises.realpath(candidatePath).catch(() => null),
437
+ ]);
438
+ if (!currentTarget || !candidateTarget || currentTarget !== candidateTarget) {
439
+ return { ok: false, reason: 'candidate is not the current auth target' };
440
+ }
441
+ if (!runtime.app.isConnected()) {
442
+ return { ok: false, reason: 'source app-server is not connected' };
443
+ }
444
+ const account = await runtime.app.readAccount();
445
+ if (!account || account.type !== 'chatgpt' || account.requiresOpenaiAuth) {
446
+ return { ok: false, reason: 'source app-server did not report an active ChatGPT account' };
447
+ }
448
+ await runtime.app.readAccountRateLimits();
449
+ return { ok: true };
450
+ }
415
451
  async function initConfig() {
416
452
  const envPath = process.env.FOXCLAW_ENV?.trim() || DEFAULT_ENV_PATH;
417
453
  fs.mkdirSync(path.dirname(envPath), { recursive: true });
package/dist/types.d.ts CHANGED
@@ -345,6 +345,7 @@ export interface RuntimeStatus {
345
345
  username: string | null;
346
346
  connected: boolean;
347
347
  activeTurns: number;
348
+ runtimeKind?: 'default' | 'isolated';
348
349
  currentAuth?: string | null;
349
350
  codexAppServer?: RuntimeStatus['codexAppServer'];
350
351
  }>;
@@ -162,6 +162,15 @@ TG_BOT_TOKENS=123456789:token_a,234567890:token_b,345678901:token_c
162
162
 
163
163
  FoxClaw still installs one service, but each bot receives its own app-server, session home, and current auth selection. After startup, privately send `/help` and `/status` to each bot.
164
164
 
165
+ If you want one of those bots to share terminal Codex sessions and the default auth, also set `TG_BOT_TOKEN` to the exact same token:
166
+
167
+ ```dotenv
168
+ TG_BOT_TOKENS=123456789:token_a,234567890:token_b
169
+ TG_BOT_TOKEN=123456789:token_a
170
+ ```
171
+
172
+ Only the matching bot uses the default/shared-terminal runtime; the others stay isolated.
173
+
165
174
  `DEFAULT_CWD` must be a real folder. Examples:
166
175
 
167
176
  ```dotenv
@@ -184,7 +184,7 @@ tail -f ~/.foxclaw/logs/service.log
184
184
 
185
185
  ## Checking Multi-Bot Mode
186
186
 
187
- After configuring `TG_BOT_TOKENS`, `foxclaw status` should contain one bot id, connection status, and independent app-server for each token. Send `/status` privately to any bot to see the runtime summary; send `/auth` to confirm the panel names the current `@botname` and its auth directory.
187
+ After configuring `TG_BOT_TOKENS`, `foxclaw status` should contain one bot id, connection status, and runtime type for each token. Send `/status` privately to any bot to see the runtime summary; send `/auth` to confirm the panel names the current `@botname` and its auth directory. If `TG_BOT_TOKEN` exactly matches one token in `TG_BOT_TOKENS`, that bot should appear as the default/shared-terminal runtime; the others should appear as isolated.
188
188
 
189
189
  Each isolated app-server log is stored at:
190
190
 
@@ -120,7 +120,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
120
120
 
121
121
  Fields:
122
122
 
123
- - `TG_BOT_TOKENS`: one or more `@BotFather` tokens separated by commas. The legacy single-bot `TG_BOT_TOKEN` setting remains compatible.
123
+ - `TG_BOT_TOKENS`: one or more `@BotFather` tokens separated by commas. The legacy single-bot `TG_BOT_TOKEN` setting remains compatible. In multi-bot mode, if the exact `TG_BOT_TOKEN` value also appears in `TG_BOT_TOKENS`, that bot uses the default/shared-terminal runtime.
124
124
  - `TG_ALLOWED_USER_ID`: your numeric Telegram user id.
125
125
  - `TG_ALLOWED_CHAT_ID`: leave empty for the first private-chat setup.
126
126
  - `TG_ALLOWED_TOPIC_ID`: leave empty unless binding a Telegram topic.
@@ -335,11 +335,13 @@ Watch mode mirrors live turn progress and approval requests. The watching chat i
335
335
 
336
336
  ## 6. Codex Login And Auth Rotation
337
337
 
338
- This is a key FoxClaw feature. Codex auth is usually stored at `~/.codex/auth.json`. FoxClaw stores multiple accounts as candidate files and switches which candidate the active `auth.json` points to. In `TG_BOT_TOKENS` mode, each bot has an isolated Codex home, app-server, and current candidate, so bots can run and switch accounts independently; isolated Telegram runtimes force file-backed credential storage. Validated login/refresh credentials are safely mirrored between bot homes, but sessions are never shared.
338
+ This is a key FoxClaw feature. Codex auth is usually stored at `~/.codex/auth.json`. FoxClaw stores multiple accounts as candidate files and switches which candidate the active `auth.json` points to. In `TG_BOT_TOKENS` mode, each bot has an isolated Codex home, app-server, and current candidate by default, so bots can run and switch accounts independently; isolated Telegram runtimes force file-backed credential storage. Validated login/refresh credentials are safely mirrored between bot homes, but isolated sessions are never shared.
339
+
340
+ To keep one Telegram bot interoperable with terminal Codex sessions, put the same token in both `TG_BOT_TOKENS` and `TG_BOT_TOKEN`. That bot uses the default `CODEX_HOME` and default auth, so `/threads` can see local terminal sessions; its `/auth` switches also affect the terminal default auth. Bots listed only in `TG_BOT_TOKENS` stay isolated.
339
341
 
340
342
  ### 6.1 File Format
341
343
 
342
- In single-bot compatibility mode, candidate files live in the Codex auth directory, usually `~/.codex/`. If `CODEX_AUTH_DIR` is set, FoxClaw uses that directory. Multi-bot mode treats that directory as its candidate source and stores isolated bot copies under `~/.foxclaw/codex/telegram/bot<id>/home/`.
344
+ In single-bot compatibility mode, candidate files live in the Codex auth directory, usually `~/.codex/`. If `CODEX_AUTH_DIR` is set, FoxClaw uses that directory. Multi-bot mode treats that directory as its candidate source and stores isolated bot copies under `~/.foxclaw/codex/telegram/bot<id>/home/`. A default/shared-terminal bot does not get an isolated copy; it uses the default auth directory directly.
343
345
 
344
346
  Recommended layout:
345
347
 
@@ -19,6 +19,7 @@
19
19
  ## 注意事项
20
20
 
21
21
  - 不要让 agent 把完整 bot token 打印到日志或提交到仓库。
22
+ - FoxClaw 仓库收尾提交默认使用 `中文 | English` 的双语 subject,方便国内和国际协作者同时阅读。
22
23
  - 第一次请先用私聊模式跑通。
23
24
  - 多个 token 会在同一服务中建立多个独立 Codex home、session 与 auth 选择;群组中必须点名或回复目标 bot。
24
25
  - 同时启用微信时,微信仍使用默认 Codex runtime,不共享隔离 Telegram bot 的线程。
@@ -160,6 +160,15 @@ TG_BOT_TOKENS=123456789:token_a,234567890:token_b,345678901:token_c
160
160
 
161
161
  FoxClaw 仍只安装一个服务,但每个 bot 会有独立 app-server、会话目录和当前 auth。服务启动后请分别私聊每个 bot 发送 `/help` 与 `/status`。
162
162
 
163
+ 如果你希望其中一个 bot 和终端 Codex 共享 session 与默认 auth,再把 `TG_BOT_TOKEN` 设置为同一个 token:
164
+
165
+ ```dotenv
166
+ TG_BOT_TOKENS=123456789:token_a,234567890:token_b
167
+ TG_BOT_TOKEN=123456789:token_a
168
+ ```
169
+
170
+ 只有匹配的 bot 使用默认/终端共享 runtime,其他 bot 仍然隔离。
171
+
163
172
  `DEFAULT_CWD` 必须是真实存在的目录,例如:
164
173
 
165
174
  ```dotenv
@@ -185,7 +185,7 @@ tail -f ~/.foxclaw/logs/service.log
185
185
 
186
186
  ## 多 bot 模式核查
187
187
 
188
- 配置 `TG_BOT_TOKENS` 后,`foxclaw status` 的 `bots` 列表应为每个 token 显示一个 bot id、连接状态和独立 app-server。私聊任一 bot 发送 `/status` 会显示全部 runtime 摘要;发送 `/auth` 应显示当前 `@botname` 和该 bot 的 auth 目录。
188
+ 配置 `TG_BOT_TOKENS` 后,`foxclaw status` 的 `bots` 列表应为每个 token 显示一个 bot id、连接状态和 runtime 类型。私聊任一 bot 发送 `/status` 会显示全部 runtime 摘要;发送 `/auth` 应显示当前 `@botname` 和该 bot 的 auth 目录。如果 `TG_BOT_TOKEN` 精确匹配 `TG_BOT_TOKENS` 中的一个 token,该 bot 应显示为默认/终端共享 runtime;其他 bot 应显示为隔离 runtime。
189
189
 
190
190
  每个隔离 app-server 的日志路径为:
191
191
 
@@ -120,7 +120,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
120
120
 
121
121
  字段说明:
122
122
 
123
- - `TG_BOT_TOKENS`:从 `@BotFather` 拿到的一个或多个 bot token,多项用英文逗号分隔。旧版单 bot 配置 `TG_BOT_TOKEN` 仍兼容。
123
+ - `TG_BOT_TOKENS`:从 `@BotFather` 拿到的一个或多个 bot token,多项用英文逗号分隔。旧版单 bot 配置 `TG_BOT_TOKEN` 仍兼容;多 bot 模式下,如果 `TG_BOT_TOKEN` 的值也出现在 `TG_BOT_TOKENS` 中,匹配的 bot 会使用默认/终端共享 runtime。
124
124
  - `TG_ALLOWED_USER_ID`:你的 Telegram 数字用户 ID。
125
125
  - `TG_ALLOWED_CHAT_ID`:第一次保持为空,表示私聊模式。
126
126
  - `TG_ALLOWED_TOPIC_ID`:第一次保持为空,表示不绑定 Telegram 话题。
@@ -335,11 +335,13 @@ FoxClaw 的聊天是“绑定线程”的。你在手机上打开某个 Codex
335
335
 
336
336
  ## 6. Codex 登录和 auth 轮转
337
337
 
338
- 这是 FoxClaw 的特色功能。Codex 的登录状态通常保存在 `~/.codex/auth.json`。FoxClaw 把多个账号保存成候选文件,并通过切换 `auth.json` 指向哪个候选来换号。启用 `TG_BOT_TOKENS` 多 bot 模式后,每个 bot 使用独立 Codex home、独立 app-server 和独立当前候选,因此可以并行运行、单独切号;隔离 Telegram runtime 会强制使用文件凭据存储。已验证的登录/刷新凭据会安全镜像到其他 bot home,但不会共享 session。
338
+ 这是 FoxClaw 的特色功能。Codex 的登录状态通常保存在 `~/.codex/auth.json`。FoxClaw 把多个账号保存成候选文件,并通过切换 `auth.json` 指向哪个候选来换号。启用 `TG_BOT_TOKENS` 多 bot 模式后,默认每个 bot 使用独立 Codex home、独立 app-server 和独立当前候选,因此可以并行运行、单独切号;隔离 Telegram runtime 会强制使用文件凭据存储。已验证的登录/刷新凭据会安全镜像到其他 bot home,但不会共享 session。
339
+
340
+ 如果你想保留一路和终端互通 session 的 Telegram bot,把该 token 同时写入 `TG_BOT_TOKENS` 和 `TG_BOT_TOKEN`。这个 bot 使用默认 `CODEX_HOME` 和默认 auth,因此能看到终端 Codex 的本地线程;它的 `/auth` 切换也会影响终端默认 auth。其他只出现在 `TG_BOT_TOKENS` 的 bot 仍然完全隔离。
339
341
 
340
342
  ### 6.1 文件格式
341
343
 
342
- 单 bot 兼容模式的候选文件放在 Codex auth 目录,默认是 `~/.codex/`。如果你设置了 `CODEX_AUTH_DIR`,则使用那个目录。多 bot 模式以这个目录作为候选源,并在 `~/.foxclaw/codex/telegram/bot<id>/home/` 下为每个 bot 保存隔离副本。
344
+ 单 bot 兼容模式的候选文件放在 Codex auth 目录,默认是 `~/.codex/`。如果你设置了 `CODEX_AUTH_DIR`,则使用那个目录。多 bot 模式以这个目录作为候选源,并在 `~/.foxclaw/codex/telegram/bot<id>/home/` 下为隔离 bot 保存副本。默认/终端共享 bot 不创建隔离副本,而是直接使用这个默认 auth 目录。
343
345
 
344
346
  推荐命名:
345
347
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: foxclaw
3
- description: Deploy, configure, validate, develop, and release FoxClaw. Use when Codex needs to clone or update the FoxClaw repo, collect Telegram values, write `.env`, enable launchd/systemd, guide first-message tests, or perform FoxClaw repo wrap-up actions such as Chinese commit messages, push, npm publish, and local install/service update.
3
+ description: Deploy, configure, validate, develop, and release FoxClaw. Use when Codex needs to clone or update the FoxClaw repo, collect Telegram values, write `.env`, enable launchd/systemd, guide first-message tests, or perform FoxClaw repo wrap-up actions such as bilingual Chinese | English commit messages, push, npm publish, and local install/service update.
4
4
  ---
5
5
 
6
6
  # FoxClaw
@@ -66,6 +66,7 @@ Telegram behavior:
66
66
  - `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` choose the default group or topic scope; they do not disable private chat
67
67
  - when `TG_BOT_TOKENS` contains multiple bots, one FoxClaw service starts independent Codex app-servers and auth selections per bot
68
68
  - isolated Telegram runtimes use independent `CODEX_HOME` directories and file-backed credentials; they do not share Codex sessions
69
+ - if `TG_BOT_TOKEN` is also set to one exact token from `TG_BOT_TOKENS`, that bot uses the default/shared-terminal runtime instead of an isolated Telegram home
69
70
  - if Weixin is enabled alongside multiple Telegram bots, it remains on the default Codex runtime instead of borrowing a Telegram bot runtime
70
71
  - in a group with multiple configured bots, address a bot by mention, reply, or suffixed command such as `/status@botname`
71
72
 
@@ -169,7 +170,7 @@ Do not describe the setup as "done" until this smoke test has either passed or b
169
170
 
170
171
  ## Development Wrap-Up
171
172
 
172
- Use this checklist when the user asks for standard closing actions, release wrap-up, local install updates, npm publish, or says things like "收尾动作", "中文 commit msg", "push", "npm publish", or "本地安装更新".
173
+ Use this checklist when the user asks for standard closing actions, release wrap-up, local install updates, npm publish, or says things like "收尾动作", "中文 commit msg", "中英双语 commit msg", "push", "npm publish", or "本地安装更新".
173
174
 
174
175
  1. Inspect scope before staging:
175
176
  - `git status -sb`
@@ -183,7 +184,9 @@ Use this checklist when the user asks for standard closing actions, release wrap
183
184
  - `npm run build`
184
185
  4. Commit intentionally:
185
186
  - Stage only the changed files that belong to the task.
186
- - Use a Chinese commit message when the user asked in Chinese or explicitly said "中文 commit msg".
187
+ - For this repo, prefer a bilingual one-line subject in the form `中文 | English`.
188
+ - Put Chinese first, keep the English half semantically equivalent, and keep both halves concise.
189
+ - For release commits, use the same format, for example `发布 0.4.0:支持多机器人隔离 | Release 0.4.0: support multi-bot isolation`.
187
190
  - Never stage unrelated local changes.
188
191
  5. Push the current branch after a successful commit:
189
192
  - `git push origin <branch>`
@@ -37,7 +37,7 @@ Use this skill to publish an npm package from a repo. Prefer CI trusted publishi
37
37
  5. Commit and push source changes before `npm publish` when the user asked to push or when this is a normal release:
38
38
  ```bash
39
39
  git add <changed-files>
40
- git commit -m "<release message>"
40
+ git commit -m "<Chinese | English release message>"
41
41
  git push
42
42
  ```
43
43
 
@@ -56,7 +56,7 @@ Use this path when the repo has `.github/workflows/publish.yml` and npmjs.com ha
56
56
  ```bash
57
57
  npm version patch --no-git-tag-version
58
58
  git add package.json package-lock.json
59
- git commit -m "<Chinese release message when appropriate>"
59
+ git commit -m "<Chinese | English release message when appropriate>"
60
60
  git push origin <branch>
61
61
  ```
62
62