@foxden-app/foxclaw 0.3.17 → 0.3.19

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.
Files changed (42) hide show
  1. package/.env.example +5 -2
  2. package/README.md +14 -4
  3. package/README_EN.md +14 -4
  4. package/dist/auth/mirror.d.ts +25 -0
  5. package/dist/auth/mirror.js +199 -0
  6. package/dist/codex_app/client.d.ts +2 -1
  7. package/dist/codex_app/client.js +10 -2
  8. package/dist/codex_app/local_usage.d.ts +7 -6
  9. package/dist/codex_app/local_usage.js +55 -66
  10. package/dist/config.d.ts +7 -0
  11. package/dist/config.js +18 -1
  12. package/dist/controller/controller.d.ts +14 -2
  13. package/dist/controller/controller.js +110 -34
  14. package/dist/core/bridge_scope.d.ts +5 -2
  15. package/dist/core/bridge_scope.js +7 -3
  16. package/dist/i18n.d.ts +4 -4
  17. package/dist/i18n.js +4 -4
  18. package/dist/main.js +144 -9
  19. package/dist/store/database.d.ts +4 -2
  20. package/dist/store/database.js +42 -6
  21. package/dist/telegram/addressing.d.ts +1 -0
  22. package/dist/telegram/addressing.js +3 -0
  23. package/dist/telegram/gateway.d.ts +4 -1
  24. package/dist/telegram/gateway.js +23 -5
  25. package/dist/types.d.ts +7 -0
  26. package/dist/update.d.ts +5 -0
  27. package/dist/update.js +93 -5
  28. package/docs/agent-assisted-install.md +4 -4
  29. package/docs/install-for-beginners.md +3 -3
  30. package/docs/troubleshooting.md +1 -1
  31. package/docs/user-manual.md +11 -11
  32. package/docs/zh/agent-assisted-install.md +4 -4
  33. package/docs/zh/foxclaw-skill.md +1 -1
  34. package/docs/zh/install-for-beginners.md +3 -3
  35. package/docs/zh/troubleshooting.md +1 -1
  36. package/docs/zh/user-manual.md +11 -11
  37. package/package.json +1 -1
  38. package/skills/foxclaw/SKILL.md +26 -19
  39. package/skills/foxclaw/references/telegram-setup.md +5 -4
  40. package/skills/foxclaw/scripts/bootstrap_host.py +11 -8
  41. package/skills/foxclaw/scripts/bootstrap_remote.py +8 -4
  42. package/skills/npm-publish/SKILL.md +3 -0
package/.env.example CHANGED
@@ -1,5 +1,8 @@
1
- # Required: one bot token per device
2
- TG_BOT_TOKEN=<telegram_bot_token>
1
+ # Required: one or more bot tokens for this host
2
+ # Comma-separated bot tokens. Each bot gets an independent Codex runtime/auth selection.
3
+ TG_BOT_TOKENS=<telegram_bot_token>
4
+ # Backward-compatible single-runtime setup:
5
+ # TG_BOT_TOKEN=<telegram_bot_token>
3
6
 
4
7
  # Required: only this Telegram user can control the bridge
5
8
  TG_ALLOWED_USER_ID=<telegram_user_id>
package/README.md CHANGED
@@ -75,7 +75,7 @@ foxclaw start
75
75
  跑 `doctor` 或 `start` 之前先把 `.env` 填好。私聊模式最小配置:
76
76
 
77
77
  ```dotenv
78
- TG_BOT_TOKEN=123456:telegram-token
78
+ TG_BOT_TOKENS=123456:telegram-token
79
79
  TG_ALLOWED_USER_ID=123456789
80
80
  DEFAULT_CWD=/absolute/path/to/workspace
81
81
  DEFAULT_APPROVAL_POLICY=on-request
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  配置文件默认在 `~/.foxclaw/.env`。想放别处的话设 `FOXCLAW_ENV=/path/to/.env`。
86
86
 
87
- `foxclaw start` 会自动检查环境并安装/重启后台服务。后续升级直接运行 `foxclaw update`,它会沿用当前的 npm/pnpm 全局安装方式,完成安装、自检和服务重启。
87
+ `foxclaw start` 会自动检查环境并安装/重启后台服务。后续升级直接运行 `foxclaw update`,它会沿用当前的 npm/pnpm 全局安装方式,尝试同步升级 npm/pnpm 安装的 Codex CLI,再完成安装、自检和服务重启。
88
88
 
89
89
  FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不代表群里所有人都能用。
90
90
 
@@ -111,6 +111,7 @@ 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
115
 
115
116
  </details>
116
117
 
@@ -178,7 +179,7 @@ FoxClaw 最初基于 `Gan-Xing/telegram-codex-app-bridge` fork 演进而来,
178
179
 
179
180
  ## Telegram 设置
180
181
 
181
- 1. 用 `@BotFather` 创建机器人,token 填入 `TG_BOT_TOKEN`。
182
+ 1. 用 `@BotFather` 创建一个或多个机器人,token 以逗号分隔填入 `TG_BOT_TOKENS`。
182
183
  2. 拿到你的 Telegram 数字用户 ID,填入 `TG_ALLOWED_USER_ID`。
183
184
  3. `foxclaw start` 启动。
184
185
  4. 打开和机器人的私聊,发 `/help`。
@@ -194,6 +195,15 @@ TG_ALLOWED_TOPIC_ID=42
194
195
  - 只填 `TG_ALLOWED_CHAT_ID` → 允许一个群组作为默认会话范围。
195
196
  - 两个都填 → 绑定到某个话题。
196
197
  - 配了群组后,`TG_ALLOWED_USER_ID` 的私聊依然可用。
198
+ - 配置多个 bot 时,同一个授权群内只有 `@botname` 命令、mention 或回复该 bot 的消息会被它处理,避免多个 Codex 会话同时响应。
199
+
200
+ 多 bot 并行示例:
201
+
202
+ ```dotenv
203
+ TG_BOT_TOKENS=123456:token_a,234567:token_b
204
+ ```
205
+
206
+ FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。
197
207
 
198
208
  **怎么找群组和话题 ID:**
199
209
 
@@ -229,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
229
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
230
240
  ```
231
241
 
232
- FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。重启时如果进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换会重启 app-server 以重新加载 `auth.json`。
242
+ FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server Codex home;重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
233
243
 
234
244
  一般不需要手动固定 app-server 端口。
235
245
 
package/README_EN.md CHANGED
@@ -75,7 +75,7 @@ foxclaw start
75
75
  Fill `.env` before running `doctor` or `start`. Minimum private-chat config:
76
76
 
77
77
  ```dotenv
78
- TG_BOT_TOKEN=123456:telegram-token
78
+ TG_BOT_TOKENS=123456:telegram-token
79
79
  TG_ALLOWED_USER_ID=123456789
80
80
  DEFAULT_CWD=/absolute/path/to/workspace
81
81
  DEFAULT_APPROVAL_POLICY=on-request
@@ -84,7 +84,7 @@ DEFAULT_SANDBOX_MODE=workspace-write
84
84
 
85
85
  The default config file is `~/.foxclaw/.env`. Set `FOXCLAW_ENV=/path/to/.env` if you want to keep it somewhere else.
86
86
 
87
- `foxclaw start` runs checks and installs or restarts the background service. For later upgrades, run `foxclaw update`; it preserves the current npm/pnpm global-install method, runs checks, and restarts the service.
87
+ `foxclaw start` runs checks and installs or restarts the background service. For later upgrades, run `foxclaw update`; it preserves the current npm/pnpm global-install method, attempts to update an npm/pnpm-managed Codex CLI, runs checks, and restarts the service.
88
88
 
89
89
  FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a group does not make it available to every group member.
90
90
 
@@ -111,6 +111,7 @@ 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
115
 
115
116
  </details>
116
117
 
@@ -178,7 +179,7 @@ FoxClaw originally evolved from a fork of `Gan-Xing/telegram-codex-app-bridge` a
178
179
 
179
180
  ## Telegram Setup
180
181
 
181
- 1. Create a bot with `@BotFather` and copy the token into `TG_BOT_TOKEN`.
182
+ 1. Create one or more bots with `@BotFather` and put their comma-separated tokens into `TG_BOT_TOKENS`.
182
183
  2. Get your Telegram numeric user id and place it into `TG_ALLOWED_USER_ID`.
183
184
  3. Start FoxClaw with `foxclaw start`.
184
185
  4. Open a private chat with the bot and send `/help`.
@@ -194,6 +195,15 @@ TG_ALLOWED_TOPIC_ID=42
194
195
  - Set `TG_ALLOWED_CHAT_ID` only to allow one group as the default conversation scope.
195
196
  - Set both `TG_ALLOWED_CHAT_ID` and `TG_ALLOWED_TOPIC_ID` to bind one topic as the default scope.
196
197
  - Private chat remains available for `TG_ALLOWED_USER_ID` even when a group is configured.
198
+ - With multiple bots, a configured group handles only `@botname` commands, mentions, or replies addressed to each bot, preventing duplicate Codex responses.
199
+
200
+ Parallel bot example:
201
+
202
+ ```dotenv
203
+ TG_BOT_TOKENS=123456:token_a,234567:token_b
204
+ ```
205
+
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.
197
207
 
198
208
  **How to find group and topic IDs:**
199
209
 
@@ -229,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
229
239
  CODEX_APP_SYNC_ON_TURN_COMPLETE=false
230
240
  ```
231
241
 
232
- FoxClaw starts `codex app-server` as a detached, bridge-managed process and records its pid and port. On restart, it reconnects to the recorded app-server if that process is still alive; otherwise it starts a new one. `/auth_reload` and auth switching restart the managed app-server so the current `auth.json` is reloaded.
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. 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.
233
243
 
234
244
  No static Codex app-server port is required in normal installs.
235
245
 
@@ -0,0 +1,25 @@
1
+ import type { Logger } from '../logger.js';
2
+ export interface AuthMirrorRuntime {
3
+ id: string;
4
+ authDir: string;
5
+ notify?: (message: string) => Promise<void>;
6
+ }
7
+ export declare class AuthCandidateMirror {
8
+ private readonly canonicalDir;
9
+ private readonly runtimes;
10
+ private readonly logger;
11
+ private timer;
12
+ private readonly lastSyncedRefresh;
13
+ constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger);
14
+ initialize(): Promise<void>;
15
+ start(): void;
16
+ stop(): void;
17
+ syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
18
+ private scan;
19
+ private propagateValidatedCandidate;
20
+ private ensureCanonicalDefaultCandidate;
21
+ private collectCandidateNames;
22
+ private reconcileCandidateAtStartup;
23
+ private resolveCanonicalCurrentCandidate;
24
+ }
25
+ export declare function isAuthCandidateName(name: string): boolean;
@@ -0,0 +1,199 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const AUTH_SCAN_INTERVAL_MS = 5_000;
4
+ export class AuthCandidateMirror {
5
+ canonicalDir;
6
+ runtimes;
7
+ logger;
8
+ timer = null;
9
+ lastSyncedRefresh = new Map();
10
+ constructor(canonicalDir, runtimes, logger) {
11
+ this.canonicalDir = canonicalDir;
12
+ this.runtimes = runtimes;
13
+ this.logger = logger;
14
+ }
15
+ async initialize() {
16
+ await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
17
+ await this.ensureCanonicalDefaultCandidate();
18
+ const candidateNames = await this.collectCandidateNames();
19
+ for (const name of candidateNames) {
20
+ await this.reconcileCandidateAtStartup(name);
21
+ }
22
+ const canonicalCandidateNames = await listAuthCandidateNames(this.canonicalDir);
23
+ const defaultCandidate = await this.resolveCanonicalCurrentCandidate()
24
+ ?? canonicalCandidateNames.sort()[0]
25
+ ?? null;
26
+ for (const runtime of this.runtimes) {
27
+ await fs.mkdir(runtime.authDir, { recursive: true, mode: 0o700 });
28
+ for (const name of canonicalCandidateNames) {
29
+ const destination = path.join(runtime.authDir, name);
30
+ if (!(await exists(destination))) {
31
+ await atomicCopy(path.join(this.canonicalDir, name), destination);
32
+ }
33
+ }
34
+ if (defaultCandidate && !(await exists(path.join(runtime.authDir, 'auth.json')))) {
35
+ await pointAuthSymlink(runtime.authDir, defaultCandidate);
36
+ }
37
+ }
38
+ }
39
+ start() {
40
+ if (this.timer)
41
+ return;
42
+ this.timer = setInterval(() => {
43
+ void this.scan().catch((error) => {
44
+ this.logger.warn('auth.mirror.scan_failed', { error: formatError(error) });
45
+ });
46
+ }, AUTH_SCAN_INTERVAL_MS);
47
+ this.timer.unref();
48
+ }
49
+ stop() {
50
+ if (!this.timer)
51
+ return;
52
+ clearInterval(this.timer);
53
+ this.timer = null;
54
+ }
55
+ async syncRuntimeCandidate(runtimeId, candidateName) {
56
+ if (!isAuthCandidateName(candidateName))
57
+ return false;
58
+ const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
59
+ if (!runtime)
60
+ return false;
61
+ return this.propagateValidatedCandidate(runtime, candidateName);
62
+ }
63
+ async scan() {
64
+ for (const runtime of this.runtimes) {
65
+ const names = await listAuthCandidateNames(runtime.authDir);
66
+ for (const name of names) {
67
+ await this.propagateValidatedCandidate(runtime, name);
68
+ }
69
+ }
70
+ }
71
+ async propagateValidatedCandidate(runtime, name) {
72
+ const sourcePath = path.join(runtime.authDir, name);
73
+ const record = await readChatGptAuthRecord(sourcePath);
74
+ if (!record)
75
+ return false;
76
+ const canonicalPath = path.join(this.canonicalDir, name);
77
+ const canonical = await readChatGptAuthRecord(canonicalPath);
78
+ if (canonical && canonical.accountId !== record.accountId) {
79
+ this.logger.warn('auth.mirror.account_conflict', { runtimeId: runtime.id, name });
80
+ return false;
81
+ }
82
+ const previousRefresh = Math.max(canonical?.lastRefreshMs ?? 0, this.lastSyncedRefresh.get(name) ?? 0);
83
+ if (record.lastRefreshMs <= previousRefresh) {
84
+ return false;
85
+ }
86
+ await atomicWrite(canonicalPath, record.raw);
87
+ for (const target of this.runtimes) {
88
+ if (target.id !== runtime.id) {
89
+ await atomicWrite(path.join(target.authDir, name), record.raw);
90
+ }
91
+ }
92
+ this.lastSyncedRefresh.set(name, record.lastRefreshMs);
93
+ this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
94
+ const message = `${name} has been refreshed by ${runtime.id} and synchronized to the other Codex homes.`;
95
+ await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
96
+ return true;
97
+ }
98
+ async ensureCanonicalDefaultCandidate() {
99
+ const names = await listAuthCandidateNames(this.canonicalDir);
100
+ if (names.length > 0)
101
+ return;
102
+ const authPath = path.join(this.canonicalDir, 'auth.json');
103
+ if (!(await exists(authPath)))
104
+ return;
105
+ const finalPath = await resolveFinalPath(authPath);
106
+ if (isAuthCandidateName(path.basename(finalPath)))
107
+ return;
108
+ await atomicCopy(authPath, path.join(this.canonicalDir, 'auth.json_default'));
109
+ }
110
+ async collectCandidateNames() {
111
+ const all = new Set(await listAuthCandidateNames(this.canonicalDir));
112
+ for (const runtime of this.runtimes) {
113
+ for (const name of await listAuthCandidateNames(runtime.authDir)) {
114
+ all.add(name);
115
+ }
116
+ }
117
+ return [...all];
118
+ }
119
+ async reconcileCandidateAtStartup(name) {
120
+ const paths = [
121
+ path.join(this.canonicalDir, name),
122
+ ...this.runtimes.map((runtime) => path.join(runtime.authDir, name)),
123
+ ];
124
+ const records = (await Promise.all(paths.map(async (sourcePath) => ({
125
+ sourcePath,
126
+ record: await readChatGptAuthRecord(sourcePath),
127
+ })))).filter((entry) => entry.record !== null);
128
+ if (records.length === 0)
129
+ return;
130
+ const accountIds = new Set(records.map((entry) => entry.record.accountId));
131
+ if (accountIds.size !== 1) {
132
+ this.logger.warn('auth.mirror.startup_conflict', { name });
133
+ return;
134
+ }
135
+ const newest = records.reduce((current, entry) => (entry.record.lastRefreshMs > current.record.lastRefreshMs ? entry : current));
136
+ this.lastSyncedRefresh.set(name, newest.record.lastRefreshMs);
137
+ for (const destination of paths) {
138
+ await atomicWrite(destination, newest.record.raw);
139
+ }
140
+ }
141
+ async resolveCanonicalCurrentCandidate() {
142
+ const finalPath = await resolveFinalPath(path.join(this.canonicalDir, 'auth.json'));
143
+ const name = path.basename(finalPath);
144
+ return isAuthCandidateName(name) ? name : null;
145
+ }
146
+ }
147
+ export function isAuthCandidateName(name) {
148
+ return name !== 'auth.json'
149
+ && !name.startsWith('.auth.json.')
150
+ && (name.startsWith('auth.json_') || name.startsWith('auth.json.') || name.startsWith('auth.json-'));
151
+ }
152
+ async function listAuthCandidateNames(dir) {
153
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
154
+ return entries
155
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && isAuthCandidateName(entry.name))
156
+ .map((entry) => entry.name);
157
+ }
158
+ async function readChatGptAuthRecord(filePath) {
159
+ try {
160
+ const raw = await fs.readFile(filePath, 'utf8');
161
+ const parsed = JSON.parse(raw);
162
+ const accountId = typeof parsed.tokens?.account_id === 'string' ? parsed.tokens.account_id : '';
163
+ const lastRefreshMs = typeof parsed.last_refresh === 'string' ? Date.parse(parsed.last_refresh) : NaN;
164
+ if (!accountId || !Number.isFinite(lastRefreshMs))
165
+ return null;
166
+ return { raw, accountId, lastRefreshMs };
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ async function atomicCopy(source, destination) {
173
+ await atomicWrite(destination, await fs.readFile(source, 'utf8'));
174
+ }
175
+ async function atomicWrite(destination, contents) {
176
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
177
+ const temporary = path.join(path.dirname(destination), `.auth.json.${process.pid}.${Date.now()}.tmp`);
178
+ await fs.writeFile(temporary, contents, { encoding: 'utf8', mode: 0o600 });
179
+ await fs.rename(temporary, destination);
180
+ }
181
+ async function pointAuthSymlink(dir, candidateName) {
182
+ const temporary = path.join(dir, `.auth.json.${process.pid}.${Date.now()}.link`);
183
+ await fs.symlink(path.join(dir, candidateName), temporary);
184
+ await fs.rename(temporary, path.join(dir, 'auth.json'));
185
+ }
186
+ async function resolveFinalPath(sourcePath) {
187
+ try {
188
+ return await fs.realpath(sourcePath);
189
+ }
190
+ catch {
191
+ return sourcePath;
192
+ }
193
+ }
194
+ async function exists(filePath) {
195
+ return fs.lstat(filePath).then(() => true).catch(() => false);
196
+ }
197
+ function formatError(error) {
198
+ return error instanceof Error ? error.message : String(error);
199
+ }
@@ -65,6 +65,7 @@ export declare class CodexAppClient extends EventEmitter {
65
65
  private readonly serverStatePath;
66
66
  private readonly serverLogPath;
67
67
  private readonly logger;
68
+ private readonly childEnv;
68
69
  private child;
69
70
  private socket;
70
71
  private requestId;
@@ -75,7 +76,7 @@ export declare class CodexAppClient extends EventEmitter {
75
76
  private port;
76
77
  private connected;
77
78
  private userAgent;
78
- constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger);
79
+ constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null);
79
80
  isConnected(): boolean;
80
81
  getUserAgent(): string | null;
81
82
  getServerStatus(): CodexAppServerRuntimeStatus;
@@ -13,6 +13,7 @@ export class CodexAppClient extends EventEmitter {
13
13
  serverStatePath;
14
14
  serverLogPath;
15
15
  logger;
16
+ childEnv;
16
17
  child = null;
17
18
  socket = null;
18
19
  requestId = 0;
@@ -23,7 +24,7 @@ export class CodexAppClient extends EventEmitter {
23
24
  port = null;
24
25
  connected = false;
25
26
  userAgent = null;
26
- constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger) {
27
+ constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null) {
27
28
  super();
28
29
  this.codexCliBin = codexCliBin;
29
30
  this.launchCommand = launchCommand;
@@ -31,6 +32,7 @@ export class CodexAppClient extends EventEmitter {
31
32
  this.serverStatePath = serverStatePath;
32
33
  this.serverLogPath = serverLogPath;
33
34
  this.logger = logger;
35
+ this.childEnv = childEnv;
34
36
  }
35
37
  isConnected() {
36
38
  return this.connected;
@@ -463,7 +465,12 @@ export class CodexAppClient extends EventEmitter {
463
465
  return;
464
466
  }
465
467
  if (this.autolaunch) {
466
- const launcher = spawn(this.launchCommand, { shell: true, detached: true, stdio: 'ignore' });
468
+ const launcher = spawn(this.launchCommand, {
469
+ shell: true,
470
+ detached: true,
471
+ stdio: 'ignore',
472
+ env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
473
+ });
467
474
  launcher.unref();
468
475
  }
469
476
  this.port = await reservePort();
@@ -473,6 +480,7 @@ export class CodexAppClient extends EventEmitter {
473
480
  child = spawn(this.codexCliBin, ['app-server', '--listen', `ws://127.0.0.1:${this.port}`], {
474
481
  detached: true,
475
482
  stdio: ['ignore', stdoutFd, stderrFd],
483
+ env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
476
484
  });
477
485
  }
478
486
  finally {
@@ -11,19 +11,20 @@ export interface CodexLocalUsageStats {
11
11
  turns: number;
12
12
  usageEvents: number;
13
13
  totals: CodexLocalUsageTotals;
14
- outputSpeed: CodexLocalOutputSpeedStats;
14
+ responseThroughput: CodexLocalResponseThroughputStats;
15
15
  latestSessionMtimeMs: number | null;
16
16
  }
17
17
  export interface CodexLocalUsageSnapshot {
18
18
  computedAtMs: number;
19
19
  stats: CodexLocalUsageStats;
20
20
  }
21
- export interface CodexLocalOutputSpeedStats {
22
- samples: number;
23
- outputTokens: number;
21
+ export interface CodexLocalResponseThroughputStats {
22
+ completedTurns: number;
23
+ visibleOutputTokens: number;
24
24
  seconds: number;
25
- latestTokensPerSecond: number | null;
26
- latestSampleAtMs: number | null;
25
+ recentCompletedTurns: number;
26
+ recentVisibleOutputTokens: number;
27
+ recentSeconds: number;
27
28
  }
28
29
  export declare function readCodexLocalUsageStats(codexHome?: string): Promise<CodexLocalUsageStats>;
29
30
  export declare function readCodexLocalUsageSnapshot(snapshotPath: string): Promise<CodexLocalUsageSnapshot | null>;
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import readline from 'node:readline';
6
+ const RECENT_COMPLETED_TURNS = 10;
6
7
  export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
7
8
  const sessionFiles = await listJsonlFiles([
8
9
  path.join(codexHome, 'sessions'),
@@ -13,7 +14,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
13
14
  let sessionsWithUsage = 0;
14
15
  let usageEvents = 0;
15
16
  let latestSessionMtimeMs = null;
16
- const outputSpeed = emptyOutputSpeedStats();
17
+ const completedTurnSamples = [];
17
18
  for (const filePath of sessionFiles) {
18
19
  const stat = await fs.stat(filePath).catch(() => null);
19
20
  if (stat) {
@@ -21,7 +22,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
21
22
  }
22
23
  const fileUsage = await readSessionUsage(filePath, turnIds);
23
24
  usageEvents += fileUsage.usageEvents;
24
- addOutputSpeed(outputSpeed, fileUsage.outputSpeed);
25
+ completedTurnSamples.push(...fileUsage.completedTurnSamples);
25
26
  if (fileUsage.totalUsage) {
26
27
  sessionsWithUsage += 1;
27
28
  addUsage(totals, fileUsage.totalUsage);
@@ -33,7 +34,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
33
34
  turns: turnIds.size,
34
35
  usageEvents,
35
36
  totals,
36
- outputSpeed,
37
+ responseThroughput: summarizeResponseThroughput(completedTurnSamples),
37
38
  latestSessionMtimeMs,
38
39
  };
39
40
  }
@@ -87,8 +88,8 @@ async function readSessionUsage(filePath, turnIds) {
87
88
  const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
88
89
  let totalUsage = null;
89
90
  let usageEvents = 0;
90
- let generationStartMs = null;
91
- const outputSpeed = emptyOutputSpeedStats();
91
+ let activeTurn = null;
92
+ const completedTurnSamples = [];
92
93
  try {
93
94
  for await (const line of reader) {
94
95
  if (!line.trim())
@@ -105,28 +106,49 @@ async function readSessionUsage(filePath, turnIds) {
105
106
  if (turnId) {
106
107
  turnIds.add(turnId);
107
108
  }
108
- if (timestampMs !== null && isGenerationBoundary(event)) {
109
- generationStartMs = timestampMs;
109
+ if (timestampMs !== null
110
+ && turnId
111
+ && event?.type === 'event_msg'
112
+ && event?.payload?.type === 'task_started') {
113
+ activeTurn = { turnId, startedAtMs: timestampMs, visibleOutputTokens: 0 };
110
114
  }
111
115
  const info = event?.payload?.info;
112
116
  const lastTokenUsage = info?.last_token_usage ?? info?.lastTokenUsage;
113
117
  if (lastTokenUsage) {
114
118
  usageEvents += 1;
115
- addOutputSpeedSample(outputSpeed, lastTokenUsage, generationStartMs, timestampMs);
116
- if (timestampMs !== null) {
117
- generationStartMs = timestampMs;
119
+ if (activeTurn) {
120
+ activeTurn.visibleOutputTokens += visibleOutputTokens(lastTokenUsage);
118
121
  }
119
122
  }
120
123
  const totalTokenUsage = info?.total_token_usage ?? info?.totalTokenUsage;
121
124
  if (totalTokenUsage) {
122
125
  totalUsage = totalTokenUsage;
123
126
  }
127
+ const completedTurn = activeTurn;
128
+ if (completedTurn !== null
129
+ && timestampMs !== null
130
+ && turnId
131
+ && completedTurn.turnId === turnId
132
+ && event?.type === 'event_msg'
133
+ && (event?.payload?.type === 'task_complete' || event?.payload?.type === 'turn_aborted')) {
134
+ if (event.payload.type === 'task_complete' && completedTurn.visibleOutputTokens > 0) {
135
+ const seconds = (timestampMs - completedTurn.startedAtMs) / 1000;
136
+ if (Number.isFinite(seconds) && seconds > 0) {
137
+ completedTurnSamples.push({
138
+ completedAtMs: timestampMs,
139
+ visibleOutputTokens: completedTurn.visibleOutputTokens,
140
+ seconds,
141
+ });
142
+ }
143
+ }
144
+ activeTurn = null;
145
+ }
124
146
  }
125
147
  }
126
148
  finally {
127
149
  reader.close();
128
150
  }
129
- return { usageEvents, totalUsage, outputSpeed };
151
+ return { usageEvents, totalUsage, completedTurnSamples };
130
152
  }
131
153
  function emptyTotals() {
132
154
  return {
@@ -137,22 +159,13 @@ function emptyTotals() {
137
159
  totalTokens: 0,
138
160
  };
139
161
  }
140
- function emptyOutputSpeedStats() {
141
- return {
142
- samples: 0,
143
- outputTokens: 0,
144
- seconds: 0,
145
- latestTokensPerSecond: null,
146
- latestSampleAtMs: null,
147
- };
148
- }
149
162
  function isCodexLocalUsageStats(value) {
150
163
  if (!value || typeof value !== 'object') {
151
164
  return false;
152
165
  }
153
166
  const stats = value;
154
167
  const totals = stats.totals;
155
- const speed = stats.outputSpeed;
168
+ const throughput = stats.responseThroughput;
156
169
  return isFiniteNumber(stats.sessionFiles)
157
170
  && isFiniteNumber(stats.sessionsWithUsage)
158
171
  && isFiniteNumber(stats.turns)
@@ -163,12 +176,13 @@ function isCodexLocalUsageStats(value) {
163
176
  && isFiniteNumber(totals?.outputTokens)
164
177
  && isFiniteNumber(totals?.reasoningOutputTokens)
165
178
  && isFiniteNumber(totals?.totalTokens)
166
- && Boolean(speed)
167
- && isFiniteNumber(speed?.samples)
168
- && isFiniteNumber(speed?.outputTokens)
169
- && isFiniteNumber(speed?.seconds)
170
- && isNullableFiniteNumber(speed?.latestTokensPerSecond)
171
- && isNullableFiniteNumber(speed?.latestSampleAtMs)
179
+ && Boolean(throughput)
180
+ && isFiniteNumber(throughput?.completedTurns)
181
+ && isFiniteNumber(throughput?.visibleOutputTokens)
182
+ && isFiniteNumber(throughput?.seconds)
183
+ && isFiniteNumber(throughput?.recentCompletedTurns)
184
+ && isFiniteNumber(throughput?.recentVisibleOutputTokens)
185
+ && isFiniteNumber(throughput?.recentSeconds)
172
186
  && isNullableFiniteNumber(stats.latestSessionMtimeMs);
173
187
  }
174
188
  function isFiniteNumber(value) {
@@ -187,34 +201,21 @@ function addUsage(totals, usage) {
187
201
  totals.reasoningOutputTokens += numberField(usage, 'reasoning_output_tokens', 'reasoningOutputTokens');
188
202
  totals.totalTokens += totalTokens || inputTokens + outputTokens;
189
203
  }
190
- function addOutputSpeed(target, source) {
191
- target.samples += source.samples;
192
- target.outputTokens += source.outputTokens;
193
- target.seconds += source.seconds;
194
- if (source.latestTokensPerSecond !== null
195
- && source.latestSampleAtMs !== null
196
- && (target.latestSampleAtMs === null || source.latestSampleAtMs > target.latestSampleAtMs)) {
197
- target.latestTokensPerSecond = source.latestTokensPerSecond;
198
- target.latestSampleAtMs = source.latestSampleAtMs;
199
- }
204
+ function visibleOutputTokens(usage) {
205
+ return Math.max(0, numberField(usage, 'output_tokens', 'outputTokens')
206
+ - numberField(usage, 'reasoning_output_tokens', 'reasoningOutputTokens'));
200
207
  }
201
- function addOutputSpeedSample(stats, usage, generationStartMs, timestampMs) {
202
- if (generationStartMs === null || timestampMs === null || timestampMs <= generationStartMs) {
203
- return;
204
- }
205
- const outputTokens = numberField(usage, 'output_tokens', 'outputTokens');
206
- if (outputTokens <= 0) {
207
- return;
208
- }
209
- const seconds = (timestampMs - generationStartMs) / 1000;
210
- if (!Number.isFinite(seconds) || seconds <= 0) {
211
- return;
212
- }
213
- stats.samples += 1;
214
- stats.outputTokens += outputTokens;
215
- stats.seconds += seconds;
216
- stats.latestTokensPerSecond = outputTokens / seconds;
217
- stats.latestSampleAtMs = timestampMs;
208
+ function summarizeResponseThroughput(samples) {
209
+ const ordered = samples.slice().sort((left, right) => left.completedAtMs - right.completedAtMs);
210
+ const recent = ordered.slice(-RECENT_COMPLETED_TURNS);
211
+ return {
212
+ completedTurns: ordered.length,
213
+ visibleOutputTokens: ordered.reduce((total, sample) => total + sample.visibleOutputTokens, 0),
214
+ seconds: ordered.reduce((total, sample) => total + sample.seconds, 0),
215
+ recentCompletedTurns: recent.length,
216
+ recentVisibleOutputTokens: recent.reduce((total, sample) => total + sample.visibleOutputTokens, 0),
217
+ recentSeconds: recent.reduce((total, sample) => total + sample.seconds, 0),
218
+ };
218
219
  }
219
220
  function numberField(source, snakeKey, camelKey) {
220
221
  const snakeValue = source[snakeKey];
@@ -229,15 +230,3 @@ function parseTimestampMs(value) {
229
230
  const timestamp = Date.parse(value);
230
231
  return Number.isFinite(timestamp) ? timestamp : null;
231
232
  }
232
- function isGenerationBoundary(event) {
233
- const payload = event?.payload;
234
- if (event?.type === 'response_item' && payload?.type === 'function_call_output') {
235
- return true;
236
- }
237
- if (event?.type !== 'event_msg') {
238
- return false;
239
- }
240
- return payload?.type === 'task_started'
241
- || payload?.type === 'exec_command_end'
242
- || payload?.type === 'user_message';
243
- }