@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.
- package/.env.example +5 -2
- package/README.md +14 -4
- package/README_EN.md +14 -4
- package/dist/auth/mirror.d.ts +25 -0
- package/dist/auth/mirror.js +199 -0
- package/dist/codex_app/client.d.ts +2 -1
- package/dist/codex_app/client.js +10 -2
- package/dist/codex_app/local_usage.d.ts +7 -6
- package/dist/codex_app/local_usage.js +55 -66
- package/dist/config.d.ts +7 -0
- package/dist/config.js +18 -1
- package/dist/controller/controller.d.ts +14 -2
- package/dist/controller/controller.js +110 -34
- package/dist/core/bridge_scope.d.ts +5 -2
- package/dist/core/bridge_scope.js +7 -3
- package/dist/i18n.d.ts +4 -4
- package/dist/i18n.js +4 -4
- package/dist/main.js +144 -9
- package/dist/store/database.d.ts +4 -2
- package/dist/store/database.js +42 -6
- package/dist/telegram/addressing.d.ts +1 -0
- package/dist/telegram/addressing.js +3 -0
- package/dist/telegram/gateway.d.ts +4 -1
- package/dist/telegram/gateway.js +23 -5
- package/dist/types.d.ts +7 -0
- package/dist/update.d.ts +5 -0
- package/dist/update.js +93 -5
- package/docs/agent-assisted-install.md +4 -4
- package/docs/install-for-beginners.md +3 -3
- package/docs/troubleshooting.md +1 -1
- package/docs/user-manual.md +11 -11
- package/docs/zh/agent-assisted-install.md +4 -4
- package/docs/zh/foxclaw-skill.md +1 -1
- package/docs/zh/install-for-beginners.md +3 -3
- package/docs/zh/troubleshooting.md +1 -1
- package/docs/zh/user-manual.md +11 -11
- package/package.json +1 -1
- package/skills/foxclaw/SKILL.md +26 -19
- package/skills/foxclaw/references/telegram-setup.md +5 -4
- package/skills/foxclaw/scripts/bootstrap_host.py +11 -8
- package/skills/foxclaw/scripts/bootstrap_remote.py +8 -4
- package/skills/npm-publish/SKILL.md +3 -0
package/.env.example
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
# Required: one bot
|
|
2
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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;
|
package/dist/codex_app/client.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
export interface CodexLocalResponseThroughputStats {
|
|
22
|
+
completedTurns: number;
|
|
23
|
+
visibleOutputTokens: number;
|
|
24
24
|
seconds: number;
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
const
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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,
|
|
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
|
|
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(
|
|
167
|
-
&& isFiniteNumber(
|
|
168
|
-
&& isFiniteNumber(
|
|
169
|
-
&& isFiniteNumber(
|
|
170
|
-
&&
|
|
171
|
-
&&
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
}
|