@foxden-app/foxclaw 0.3.19 → 0.4.1
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 +4 -2
- package/README.md +5 -5
- package/README_EN.md +5 -5
- package/dist/auth/mirror.d.ts +25 -1
- package/dist/auth/mirror.js +96 -8
- package/dist/codex_app/client.d.ts +3 -1
- package/dist/codex_app/client.js +14 -3
- package/dist/controller/controller.d.ts +12 -2
- package/dist/controller/controller.js +67 -11
- package/dist/i18n.d.ts +20 -2
- package/dist/i18n.js +20 -2
- package/dist/main.js +89 -15
- package/dist/types.d.ts +19 -0
- package/docs/agent-assisted-install.md +3 -2
- package/docs/install-for-beginners.md +10 -2
- package/docs/troubleshooting.md +12 -0
- package/docs/user-manual.md +4 -4
- package/docs/zh/agent-assisted-install.md +3 -2
- package/docs/zh/foxclaw-skill.md +3 -1
- package/docs/zh/install-for-beginners.md +10 -2
- package/docs/zh/troubleshooting.md +12 -0
- package/docs/zh/user-manual.md +4 -4
- package/package.json +1 -1
- package/skills/foxclaw/SKILL.md +5 -2
- package/skills/foxclaw/references/telegram-setup.md +4 -2
package/.env.example
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Required: one or more bot tokens for this host
|
|
1
|
+
# Required for new installs: one or more bot tokens for this host
|
|
2
2
|
# Comma-separated bot tokens. Each bot gets an independent Codex runtime/auth selection.
|
|
3
3
|
TG_BOT_TOKENS=<telegram_bot_token>
|
|
4
4
|
# Backward-compatible single-runtime setup:
|
|
@@ -14,7 +14,9 @@ TG_ALLOWED_CHAT_ID=
|
|
|
14
14
|
# Leave empty to use the whole allowed group as the default scope
|
|
15
15
|
TG_ALLOWED_TOPIC_ID=
|
|
16
16
|
|
|
17
|
-
# Optional Codex Desktop bridge settings
|
|
17
|
+
# Optional Codex Desktop bridge settings.
|
|
18
|
+
# 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.
|
|
18
20
|
CODEX_APP_AUTOLAUNCH=true
|
|
19
21
|
CODEX_APP_LAUNCH_CMD=codex app
|
|
20
22
|
# 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
|
-
|
|
43
|
+
最低要求:一个或多个 Telegram bot token、你的 Telegram 数字用户 ID、Node.js 24+、一份已登录的 `codex` CLI。首次安装大约 10–20 分钟。新安装请使用 `TG_BOT_TOKENS`;`TG_BOT_TOKEN` 只用于兼容旧的单 runtime 配置。
|
|
44
44
|
|
|
45
45
|
**30 秒体验**:启动 FoxClaw 后,给你的 Telegram 机器人发一句 `List files in DEFAULT_CWD`。Codex 会在本地检查那个目录,然后把结果发回 Telegram。
|
|
46
46
|
|
|
@@ -93,7 +93,7 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
|
|
|
93
93
|
|
|
94
94
|
**核心能力:**
|
|
95
95
|
- 通过 Telegram 私聊、群组、话题控制本地 Codex
|
|
96
|
-
- 可选微信/iLink
|
|
96
|
+
- 可选微信/iLink 通道;在多 Telegram bot 模式下仍使用原默认 Codex runtime,不与隔离 bot 会话混用
|
|
97
97
|
- 手机上完整管理 Codex 线程生命周期:创建、重命名、归档、fork、回滚、compact、review、diff
|
|
98
98
|
- 命令、文件变更、细粒度权限审批的内联按钮——手机上一键审批
|
|
99
99
|
- MCP elicitation 卡片——工具在 turn 中提出结构化问题时展示
|
|
@@ -203,7 +203,7 @@ TG_ALLOWED_TOPIC_ID=42
|
|
|
203
203
|
TG_BOT_TOKENS=123456:token_a,234567:token_b
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot
|
|
206
|
+
FoxClaw 仍然只运行一个系统服务,但会为每个 bot 启动独立 `codex app-server` 和独立 `CODEX_HOME`。因此 A 私聊运行 turn 时,B 私聊仍可独立切换自己的 `/auth`。候选凭据由 FoxClaw 在登录或刷新验证后镜像同步,各 bot 的当前选择互不影响。每个 bot 首次私聊发送 `/help` 和 `/status`;`/auth` 会标明正在操作的 bot runtime,`/status` 会列出全部 bot 的连接、当前 auth 和活动 turn 摘要。
|
|
207
207
|
|
|
208
208
|
**怎么找群组和话题 ID:**
|
|
209
209
|
|
|
@@ -239,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
|
|
|
239
239
|
CODEX_APP_SYNC_ON_TURN_COMPLETE=false
|
|
240
240
|
```
|
|
241
241
|
|
|
242
|
-
FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server 与 Codex home
|
|
242
|
+
FoxClaw 会把 `codex app-server` 作为 detached 子进程启动,记录其 pid 和端口。使用 `TG_BOT_TOKENS` 时,每个 bot 都有自己的 app-server 与 Codex home,并在该隔离 runtime 内强制使用文件凭据存储;隔离 bot 不自动拉起 Codex Desktop,避免多个新 home 同时初始化桌面状态。重启时如果对应进程还活着就直接重连,否则拉起新进程。`/auth_reload` 和认证切换只重启发起操作的 bot runtime。
|
|
243
243
|
|
|
244
244
|
一般不需要手动固定 app-server 端口。
|
|
245
245
|
|
|
@@ -284,7 +284,7 @@ WX_ALLOWED_ILINK_USER_IDS=
|
|
|
284
284
|
foxclaw weixin-login
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
-
微信运行时文件在 `~/.foxclaw/weixin
|
|
287
|
+
微信运行时文件在 `~/.foxclaw/weixin`。启用 `TG_BOT_TOKENS` 时,微信继续连接默认 Codex runtime 与原 home,不会查看或恢复隔离 Telegram bot 的线程。
|
|
288
288
|
|
|
289
289
|
## Codex Skill
|
|
290
290
|
|
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
|
|
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.
|
|
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
|
|
|
@@ -93,7 +93,7 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
|
|
|
93
93
|
|
|
94
94
|
**Core capabilities:**
|
|
95
95
|
- Telegram private chat, group, and topic control for your local Codex
|
|
96
|
-
- Optional Weixin/iLink channel
|
|
96
|
+
- Optional Weixin/iLink channel; in multi-Telegram mode it stays on the original default Codex runtime rather than sharing an isolated bot session
|
|
97
97
|
- Full thread lifecycle management from mobile: create, rename, archive, fork, rollback, compact, review, diff
|
|
98
98
|
- Inline approval buttons for commands, file changes, and granular permissions — one tap to approve
|
|
99
99
|
- MCP elicitation cards for structured questions raised by tools during a turn
|
|
@@ -203,7 +203,7 @@ Parallel bot example:
|
|
|
203
203
|
TG_BOT_TOKENS=123456:token_a,234567:token_b
|
|
204
204
|
```
|
|
205
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.
|
|
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.
|
|
207
207
|
|
|
208
208
|
**How to find group and topic IDs:**
|
|
209
209
|
|
|
@@ -239,7 +239,7 @@ CODEX_APP_SYNC_ON_OPEN=true
|
|
|
239
239
|
CODEX_APP_SYNC_ON_TURN_COMPLETE=false
|
|
240
240
|
```
|
|
241
241
|
|
|
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.
|
|
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.
|
|
243
243
|
|
|
244
244
|
No static Codex app-server port is required in normal installs.
|
|
245
245
|
|
|
@@ -284,7 +284,7 @@ Run the QR login helper once after building:
|
|
|
284
284
|
foxclaw weixin-login
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
-
Weixin runtime files default to `~/.foxclaw/weixin`.
|
|
287
|
+
Weixin runtime files default to `~/.foxclaw/weixin`. When `TG_BOT_TOKENS` is enabled, Weixin continues to use the default Codex runtime and original home; it does not inspect or resume isolated Telegram bot threads.
|
|
288
288
|
|
|
289
289
|
## Codex Skill
|
|
290
290
|
|
package/dist/auth/mirror.d.ts
CHANGED
|
@@ -1,22 +1,46 @@
|
|
|
1
1
|
import type { Logger } from '../logger.js';
|
|
2
2
|
export interface AuthMirrorRuntime {
|
|
3
3
|
id: string;
|
|
4
|
+
label?: string;
|
|
4
5
|
authDir: string;
|
|
5
6
|
notify?: (message: string) => Promise<void>;
|
|
7
|
+
validate?: (context: AuthMirrorValidationContext) => Promise<AuthMirrorValidationResult | boolean>;
|
|
8
|
+
}
|
|
9
|
+
export interface AuthMirrorStatus {
|
|
10
|
+
candidateName: string;
|
|
11
|
+
sourceRuntimeId: string;
|
|
12
|
+
sourceLabel: string;
|
|
13
|
+
syncedAt: string;
|
|
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;
|
|
6
23
|
}
|
|
7
24
|
export declare class AuthCandidateMirror {
|
|
8
25
|
private readonly canonicalDir;
|
|
9
26
|
private readonly runtimes;
|
|
10
27
|
private readonly logger;
|
|
28
|
+
private readonly statusPath;
|
|
11
29
|
private timer;
|
|
12
30
|
private readonly lastSyncedRefresh;
|
|
13
|
-
|
|
31
|
+
private activeOperations;
|
|
32
|
+
private lastStatus;
|
|
33
|
+
constructor(canonicalDir: string, runtimes: AuthMirrorRuntime[], logger: Logger, statusPath?: string | null);
|
|
14
34
|
initialize(): Promise<void>;
|
|
15
35
|
start(): void;
|
|
16
36
|
stop(): void;
|
|
37
|
+
isIdle(): boolean;
|
|
38
|
+
getStatus(): AuthMirrorStatus | null;
|
|
17
39
|
syncRuntimeCandidate(runtimeId: string, candidateName: string): Promise<boolean>;
|
|
18
40
|
private scan;
|
|
19
41
|
private propagateValidatedCandidate;
|
|
42
|
+
private validateRuntimeCandidate;
|
|
43
|
+
private withActivity;
|
|
20
44
|
private ensureCanonicalDefaultCandidate;
|
|
21
45
|
private collectCandidateNames;
|
|
22
46
|
private reconcileCandidateAtStartup;
|
package/dist/auth/mirror.js
CHANGED
|
@@ -5,14 +5,19 @@ export class AuthCandidateMirror {
|
|
|
5
5
|
canonicalDir;
|
|
6
6
|
runtimes;
|
|
7
7
|
logger;
|
|
8
|
+
statusPath;
|
|
8
9
|
timer = null;
|
|
9
10
|
lastSyncedRefresh = new Map();
|
|
10
|
-
|
|
11
|
+
activeOperations = 0;
|
|
12
|
+
lastStatus = null;
|
|
13
|
+
constructor(canonicalDir, runtimes, logger, statusPath = null) {
|
|
11
14
|
this.canonicalDir = canonicalDir;
|
|
12
15
|
this.runtimes = runtimes;
|
|
13
16
|
this.logger = logger;
|
|
17
|
+
this.statusPath = statusPath;
|
|
14
18
|
}
|
|
15
19
|
async initialize() {
|
|
20
|
+
this.lastStatus = await readMirrorStatus(this.statusPath);
|
|
16
21
|
await fs.mkdir(this.canonicalDir, { recursive: true, mode: 0o700 });
|
|
17
22
|
await this.ensureCanonicalDefaultCandidate();
|
|
18
23
|
const candidateNames = await this.collectCandidateNames();
|
|
@@ -52,21 +57,29 @@ export class AuthCandidateMirror {
|
|
|
52
57
|
clearInterval(this.timer);
|
|
53
58
|
this.timer = null;
|
|
54
59
|
}
|
|
60
|
+
isIdle() {
|
|
61
|
+
return this.activeOperations === 0;
|
|
62
|
+
}
|
|
63
|
+
getStatus() {
|
|
64
|
+
return this.lastStatus;
|
|
65
|
+
}
|
|
55
66
|
async syncRuntimeCandidate(runtimeId, candidateName) {
|
|
56
67
|
if (!isAuthCandidateName(candidateName))
|
|
57
68
|
return false;
|
|
58
69
|
const runtime = this.runtimes.find((entry) => entry.id === runtimeId);
|
|
59
70
|
if (!runtime)
|
|
60
71
|
return false;
|
|
61
|
-
return this.propagateValidatedCandidate(runtime, candidateName);
|
|
72
|
+
return this.withActivity(() => this.propagateValidatedCandidate(runtime, candidateName));
|
|
62
73
|
}
|
|
63
74
|
async scan() {
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
await this.withActivity(async () => {
|
|
76
|
+
for (const runtime of this.runtimes) {
|
|
77
|
+
const names = await listAuthCandidateNames(runtime.authDir);
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
await this.propagateValidatedCandidate(runtime, name);
|
|
80
|
+
}
|
|
68
81
|
}
|
|
69
|
-
}
|
|
82
|
+
});
|
|
70
83
|
}
|
|
71
84
|
async propagateValidatedCandidate(runtime, name) {
|
|
72
85
|
const sourcePath = path.join(runtime.authDir, name);
|
|
@@ -83,6 +96,15 @@ export class AuthCandidateMirror {
|
|
|
83
96
|
if (record.lastRefreshMs <= previousRefresh) {
|
|
84
97
|
return false;
|
|
85
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
|
+
}
|
|
86
108
|
await atomicWrite(canonicalPath, record.raw);
|
|
87
109
|
for (const target of this.runtimes) {
|
|
88
110
|
if (target.id !== runtime.id) {
|
|
@@ -90,11 +112,47 @@ export class AuthCandidateMirror {
|
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
114
|
this.lastSyncedRefresh.set(name, record.lastRefreshMs);
|
|
115
|
+
const sourceLabel = runtime.label ?? runtime.id;
|
|
116
|
+
this.lastStatus = {
|
|
117
|
+
candidateName: name,
|
|
118
|
+
sourceRuntimeId: runtime.id,
|
|
119
|
+
sourceLabel,
|
|
120
|
+
syncedAt: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
await writeMirrorStatus(this.statusPath, this.lastStatus);
|
|
93
123
|
this.logger.info('auth.mirror.synced', { sourceRuntimeId: runtime.id, name });
|
|
94
|
-
const message = `${name} has been refreshed by ${
|
|
124
|
+
const message = `${name} has been refreshed by ${sourceLabel} and synchronized to the other Codex homes.`;
|
|
95
125
|
await Promise.allSettled(this.runtimes.map((target) => target.notify?.(message)));
|
|
96
126
|
return true;
|
|
97
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
|
+
}
|
|
147
|
+
async withActivity(operation) {
|
|
148
|
+
this.activeOperations += 1;
|
|
149
|
+
try {
|
|
150
|
+
return await operation();
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
this.activeOperations -= 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
98
156
|
async ensureCanonicalDefaultCandidate() {
|
|
99
157
|
const names = await listAuthCandidateNames(this.canonicalDir);
|
|
100
158
|
if (names.length > 0)
|
|
@@ -197,3 +255,33 @@ async function exists(filePath) {
|
|
|
197
255
|
function formatError(error) {
|
|
198
256
|
return error instanceof Error ? error.message : String(error);
|
|
199
257
|
}
|
|
258
|
+
async function readMirrorStatus(statusPath) {
|
|
259
|
+
if (!statusPath)
|
|
260
|
+
return null;
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(await fs.readFile(statusPath, 'utf8'));
|
|
263
|
+
if (typeof parsed.candidateName !== 'string'
|
|
264
|
+
|| typeof parsed.sourceRuntimeId !== 'string'
|
|
265
|
+
|| typeof parsed.sourceLabel !== 'string'
|
|
266
|
+
|| typeof parsed.syncedAt !== 'string') {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
candidateName: parsed.candidateName,
|
|
271
|
+
sourceRuntimeId: parsed.sourceRuntimeId,
|
|
272
|
+
sourceLabel: parsed.sourceLabel,
|
|
273
|
+
syncedAt: parsed.syncedAt,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function writeMirrorStatus(statusPath, status) {
|
|
281
|
+
if (!statusPath)
|
|
282
|
+
return;
|
|
283
|
+
await fs.mkdir(path.dirname(statusPath), { recursive: true, mode: 0o700 });
|
|
284
|
+
const temporary = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
|
|
285
|
+
await fs.writeFile(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
286
|
+
await fs.rename(temporary, statusPath);
|
|
287
|
+
}
|
|
@@ -58,6 +58,7 @@ interface CodexAppServerRuntimeStatus {
|
|
|
58
58
|
interface StopOptions {
|
|
59
59
|
terminateServer?: boolean;
|
|
60
60
|
}
|
|
61
|
+
export declare function buildCodexAppServerArgs(port: number, configOverrides?: readonly string[]): string[];
|
|
61
62
|
export declare class CodexAppClient extends EventEmitter {
|
|
62
63
|
private readonly codexCliBin;
|
|
63
64
|
private readonly launchCommand;
|
|
@@ -66,6 +67,7 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
66
67
|
private readonly serverLogPath;
|
|
67
68
|
private readonly logger;
|
|
68
69
|
private readonly childEnv;
|
|
70
|
+
private readonly appServerConfigOverrides;
|
|
69
71
|
private child;
|
|
70
72
|
private socket;
|
|
71
73
|
private requestId;
|
|
@@ -76,7 +78,7 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
76
78
|
private port;
|
|
77
79
|
private connected;
|
|
78
80
|
private userAgent;
|
|
79
|
-
constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null);
|
|
81
|
+
constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null, appServerConfigOverrides?: readonly string[]);
|
|
80
82
|
isConnected(): boolean;
|
|
81
83
|
getUserAgent(): string | null;
|
|
82
84
|
getServerStatus(): CodexAppServerRuntimeStatus;
|
package/dist/codex_app/client.js
CHANGED
|
@@ -6,6 +6,14 @@ import { spawn } from 'node:child_process';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { buildThreadDeepLink, openUrl } from './deeplink.js';
|
|
8
8
|
const CLIENT_VERSION = readPackageVersion();
|
|
9
|
+
export function buildCodexAppServerArgs(port, configOverrides = []) {
|
|
10
|
+
const args = ['app-server'];
|
|
11
|
+
for (const override of configOverrides) {
|
|
12
|
+
args.push('-c', override);
|
|
13
|
+
}
|
|
14
|
+
args.push('--listen', `ws://127.0.0.1:${port}`);
|
|
15
|
+
return args;
|
|
16
|
+
}
|
|
9
17
|
export class CodexAppClient extends EventEmitter {
|
|
10
18
|
codexCliBin;
|
|
11
19
|
launchCommand;
|
|
@@ -14,6 +22,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
14
22
|
serverLogPath;
|
|
15
23
|
logger;
|
|
16
24
|
childEnv;
|
|
25
|
+
appServerConfigOverrides;
|
|
17
26
|
child = null;
|
|
18
27
|
socket = null;
|
|
19
28
|
requestId = 0;
|
|
@@ -24,7 +33,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
24
33
|
port = null;
|
|
25
34
|
connected = false;
|
|
26
35
|
userAgent = null;
|
|
27
|
-
constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null) {
|
|
36
|
+
constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null, appServerConfigOverrides = []) {
|
|
28
37
|
super();
|
|
29
38
|
this.codexCliBin = codexCliBin;
|
|
30
39
|
this.launchCommand = launchCommand;
|
|
@@ -33,6 +42,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
33
42
|
this.serverLogPath = serverLogPath;
|
|
34
43
|
this.logger = logger;
|
|
35
44
|
this.childEnv = childEnv;
|
|
45
|
+
this.appServerConfigOverrides = appServerConfigOverrides;
|
|
36
46
|
}
|
|
37
47
|
isConnected() {
|
|
38
48
|
return this.connected;
|
|
@@ -474,10 +484,11 @@ export class CodexAppClient extends EventEmitter {
|
|
|
474
484
|
launcher.unref();
|
|
475
485
|
}
|
|
476
486
|
this.port = await reservePort();
|
|
487
|
+
const serverArgs = buildCodexAppServerArgs(this.port, this.appServerConfigOverrides);
|
|
477
488
|
const [stdoutFd, stderrFd] = this.openServerLogFiles();
|
|
478
489
|
let child;
|
|
479
490
|
try {
|
|
480
|
-
child = spawn(this.codexCliBin,
|
|
491
|
+
child = spawn(this.codexCliBin, serverArgs, {
|
|
481
492
|
detached: true,
|
|
482
493
|
stdio: ['ignore', stdoutFd, stderrFd],
|
|
483
494
|
env: this.childEnv ? { ...process.env, ...this.childEnv } : process.env,
|
|
@@ -496,7 +507,7 @@ export class CodexAppClient extends EventEmitter {
|
|
|
496
507
|
this.writeServerState({
|
|
497
508
|
pid: child.pid,
|
|
498
509
|
port: this.port,
|
|
499
|
-
command:
|
|
510
|
+
command: [this.codexCliBin, ...serverArgs].join(' '),
|
|
500
511
|
logPath: this.serverLogPath,
|
|
501
512
|
bridgePid: process.pid,
|
|
502
513
|
startedAt: new Date().toISOString(),
|
|
@@ -5,11 +5,18 @@ import type { RuntimeStatus } from '../types.js';
|
|
|
5
5
|
import type { TelegramGateway, TelegramTextEvent } from '../telegram/gateway.js';
|
|
6
6
|
import { BridgeMessagingRouter } from '../channels/bridge_messaging_router.js';
|
|
7
7
|
import type { CodexAppClient } from '../codex_app/client.js';
|
|
8
|
-
import type { SelfUpdateRuntime } from '../update.js';
|
|
8
|
+
import type { SelfUpdateRuntime, SelfUpdateStatus } from '../update.js';
|
|
9
9
|
export interface CoreCoordinator {
|
|
10
10
|
canSelfUpdate?: () => boolean;
|
|
11
11
|
authCandidateUpdated?: (runtimeId: string, candidateName: string) => Promise<void>;
|
|
12
12
|
statusUpdated?: (status: RuntimeStatus) => void;
|
|
13
|
+
getServiceStatus?: () => Promise<{
|
|
14
|
+
bots: NonNullable<RuntimeStatus['bots']>;
|
|
15
|
+
weixinRuntime?: RuntimeStatus['weixinRuntime'];
|
|
16
|
+
authMirror?: RuntimeStatus['authMirror'];
|
|
17
|
+
lastUpdate?: SelfUpdateStatus | null;
|
|
18
|
+
}>;
|
|
19
|
+
selfUpdateCompleted?: (status: SelfUpdateStatus) => void;
|
|
13
20
|
}
|
|
14
21
|
export declare class BridgeSessionCore {
|
|
15
22
|
private readonly config;
|
|
@@ -19,6 +26,7 @@ export declare class BridgeSessionCore {
|
|
|
19
26
|
private readonly app;
|
|
20
27
|
private readonly selfUpdater;
|
|
21
28
|
private readonly coordinator;
|
|
29
|
+
private readonly ownsTelegramRuntime;
|
|
22
30
|
private activeTurns;
|
|
23
31
|
private activeTurnsByTurnId;
|
|
24
32
|
private observedThreadWatchers;
|
|
@@ -57,7 +65,7 @@ export declare class BridgeSessionCore {
|
|
|
57
65
|
/** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
|
|
58
66
|
private threadListPresentationState;
|
|
59
67
|
private readonly messaging;
|
|
60
|
-
constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null, coordinator?: CoreCoordinator | null);
|
|
68
|
+
constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: CodexAppClient, outbound: BridgeMessagingRouter, selfUpdater?: SelfUpdateRuntime | null, coordinator?: CoreCoordinator | null, ownsTelegramRuntime?: boolean);
|
|
61
69
|
/** Wire Telegram inbound events. Call before {@link startCodexApp}. */
|
|
62
70
|
registerTelegramInboundHandlers(): void;
|
|
63
71
|
/**
|
|
@@ -146,9 +154,11 @@ export declare class BridgeSessionCore {
|
|
|
146
154
|
private stageAttachments;
|
|
147
155
|
private registerActiveTurn;
|
|
148
156
|
private createActiveTurnState;
|
|
157
|
+
getCurrentAuthLabel(): Promise<string | null>;
|
|
149
158
|
isIdleForServiceUpdate(): boolean;
|
|
150
159
|
private hasLocalBlockingActivity;
|
|
151
160
|
private authRuntimeId;
|
|
161
|
+
private authDisplayBotLabel;
|
|
152
162
|
private ownsScope;
|
|
153
163
|
private setActiveTurn;
|
|
154
164
|
private getActiveTurn;
|
|
@@ -94,6 +94,7 @@ export class BridgeSessionCore {
|
|
|
94
94
|
app;
|
|
95
95
|
selfUpdater;
|
|
96
96
|
coordinator;
|
|
97
|
+
ownsTelegramRuntime;
|
|
97
98
|
activeTurns = new Map();
|
|
98
99
|
activeTurnsByTurnId = new Map();
|
|
99
100
|
observedThreadWatchers = new Map();
|
|
@@ -132,7 +133,7 @@ export class BridgeSessionCore {
|
|
|
132
133
|
/** Last threads-panel pagination state per scope (Telegram inline nav + /open index alignment). */
|
|
133
134
|
threadListPresentationState = new Map();
|
|
134
135
|
messaging;
|
|
135
|
-
constructor(config, store, logger, bot, app, outbound, selfUpdater = null, coordinator = null) {
|
|
136
|
+
constructor(config, store, logger, bot, app, outbound, selfUpdater = null, coordinator = null, ownsTelegramRuntime = true) {
|
|
136
137
|
this.config = config;
|
|
137
138
|
this.store = store;
|
|
138
139
|
this.logger = logger;
|
|
@@ -140,6 +141,7 @@ export class BridgeSessionCore {
|
|
|
140
141
|
this.app = app;
|
|
141
142
|
this.selfUpdater = selfUpdater;
|
|
142
143
|
this.coordinator = coordinator;
|
|
144
|
+
this.ownsTelegramRuntime = ownsTelegramRuntime;
|
|
143
145
|
this.messaging = outbound;
|
|
144
146
|
}
|
|
145
147
|
/** Wire Telegram inbound events. Call before {@link startCodexApp}. */
|
|
@@ -198,13 +200,13 @@ export class BridgeSessionCore {
|
|
|
198
200
|
this.logger.warn('codex.local_usage_background_refresh_failed', { error: formatUserError(error) });
|
|
199
201
|
});
|
|
200
202
|
this.updateStatus();
|
|
203
|
+
this.scheduleSelfUpdateStatusPoll(0);
|
|
201
204
|
}
|
|
202
205
|
/** Begin Telegram Bot API long-polling after handlers and Codex are ready. */
|
|
203
206
|
async startTelegramPolling() {
|
|
204
207
|
await this.bot.start();
|
|
205
208
|
this.botUsername = this.bot.username;
|
|
206
209
|
this.updateStatus();
|
|
207
|
-
this.scheduleSelfUpdateStatusPoll(0);
|
|
208
210
|
}
|
|
209
211
|
/** Telegram-only default startup (single channel). */
|
|
210
212
|
async start() {
|
|
@@ -258,7 +260,7 @@ export class BridgeSessionCore {
|
|
|
258
260
|
lastError: this.lastError,
|
|
259
261
|
updatedAt: new Date().toISOString(),
|
|
260
262
|
channels: {
|
|
261
|
-
telegram:
|
|
263
|
+
telegram: this.ownsTelegramRuntime,
|
|
262
264
|
weixin: Boolean(this.config.wxEnabled && this.messaging.hasWeixinTransport),
|
|
263
265
|
},
|
|
264
266
|
};
|
|
@@ -360,10 +362,13 @@ export class BridgeSessionCore {
|
|
|
360
362
|
const binding = this.store.getBinding(scopeId);
|
|
361
363
|
const settings = this.store.getChatSettings(scopeId);
|
|
362
364
|
const access = this.resolveEffectiveAccess(scopeId, settings);
|
|
363
|
-
const [fastStatus, codexUsageLines, codexLocalUsageLines] = await Promise.all([
|
|
365
|
+
const [fastStatus, codexUsageLines, codexLocalUsageLines, serviceStatus] = await Promise.all([
|
|
364
366
|
this.resolveFastStatusLabel(locale, settings),
|
|
365
367
|
this.buildCodexUsageStatusLines(locale),
|
|
366
368
|
this.buildCodexLocalUsageStatusLines(locale),
|
|
369
|
+
this.config.tgMultiBotMode
|
|
370
|
+
? (this.coordinator?.getServiceStatus?.() ?? Promise.resolve(null))
|
|
371
|
+
: Promise.resolve(null),
|
|
367
372
|
]);
|
|
368
373
|
const appServer = this.app.getServerStatus();
|
|
369
374
|
const appServerLabel = appServer.pid && appServer.port
|
|
@@ -392,6 +397,43 @@ export class BridgeSessionCore {
|
|
|
392
397
|
t(locale, 'status_pending_user_inputs', { value: this.store.countPendingUserInputs() }),
|
|
393
398
|
t(locale, 'status_active_turns', { value: this.activeTurns.size }),
|
|
394
399
|
];
|
|
400
|
+
if (serviceStatus) {
|
|
401
|
+
lines.push('', t(locale, 'status_runtime_overview'));
|
|
402
|
+
for (const runtime of serviceStatus.bots) {
|
|
403
|
+
lines.push(t(locale, 'status_runtime_bot', {
|
|
404
|
+
bot: runtime.username ? `@${runtime.username}` : runtime.id,
|
|
405
|
+
connected: t(locale, runtime.connected ? 'yes' : 'no'),
|
|
406
|
+
auth: runtime.currentAuth ?? t(locale, 'none'),
|
|
407
|
+
turns: runtime.activeTurns,
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
if (serviceStatus.weixinRuntime) {
|
|
411
|
+
lines.push(t(locale, 'status_runtime_weixin', {
|
|
412
|
+
connected: t(locale, serviceStatus.weixinRuntime.connected ? 'yes' : 'no'),
|
|
413
|
+
turns: serviceStatus.weixinRuntime.activeTurns,
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
lines.push(serviceStatus.authMirror
|
|
417
|
+
? t(locale, 'status_auth_mirror_synced', {
|
|
418
|
+
candidate: serviceStatus.authMirror.candidateName,
|
|
419
|
+
source: serviceStatus.authMirror.sourceLabel,
|
|
420
|
+
time: serviceStatus.authMirror.syncedAt,
|
|
421
|
+
})
|
|
422
|
+
: t(locale, 'status_auth_mirror_none'));
|
|
423
|
+
if (serviceStatus.lastUpdate) {
|
|
424
|
+
lines.push(t(locale, 'status_last_update', {
|
|
425
|
+
from: serviceStatus.lastUpdate.fromVersion,
|
|
426
|
+
to: serviceStatus.lastUpdate.toVersion ?? t(locale, 'unknown'),
|
|
427
|
+
time: serviceStatus.lastUpdate.updatedAt,
|
|
428
|
+
}));
|
|
429
|
+
if (serviceStatus.lastUpdate.codexUpdate) {
|
|
430
|
+
lines.push(t(locale, 'status_last_codex_update', { value: serviceStatus.lastUpdate.codexUpdate }));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
lines.push(t(locale, 'status_last_update_none'));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
395
437
|
lines.push(...codexUsageLines);
|
|
396
438
|
lines.push(...codexLocalUsageLines);
|
|
397
439
|
await this.sendMessage(scopeId, lines.join('\n'));
|
|
@@ -2551,6 +2593,9 @@ export class BridgeSessionCore {
|
|
|
2551
2593
|
resolver,
|
|
2552
2594
|
};
|
|
2553
2595
|
}
|
|
2596
|
+
async getCurrentAuthLabel() {
|
|
2597
|
+
return (await this.listCodexAuthState()).currentLabel;
|
|
2598
|
+
}
|
|
2554
2599
|
isIdleForServiceUpdate() {
|
|
2555
2600
|
return this.activeTurns.size === 0
|
|
2556
2601
|
&& this.pendingApprovalMessages.size === 0
|
|
@@ -2565,10 +2610,18 @@ export class BridgeSessionCore {
|
|
|
2565
2610
|
authRuntimeId() {
|
|
2566
2611
|
return this.config.tgScopeBotId ?? 'default';
|
|
2567
2612
|
}
|
|
2613
|
+
authDisplayBotLabel() {
|
|
2614
|
+
if (!this.config.tgScopeBotId)
|
|
2615
|
+
return null;
|
|
2616
|
+
return this.botUsername ? `@${this.botUsername}` : this.config.tgScopeBotId;
|
|
2617
|
+
}
|
|
2568
2618
|
ownsScope(scopeId) {
|
|
2569
2619
|
if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
|
|
2570
2620
|
return this.messaging.hasWeixinTransport;
|
|
2571
2621
|
}
|
|
2622
|
+
if (!this.ownsTelegramRuntime) {
|
|
2623
|
+
return false;
|
|
2624
|
+
}
|
|
2572
2625
|
if (!this.config.tgScopeBotId) {
|
|
2573
2626
|
return parseTelegramTargetFromBridgeScope(scopeId).botId === null;
|
|
2574
2627
|
}
|
|
@@ -3774,6 +3827,7 @@ export class BridgeSessionCore {
|
|
|
3774
3827
|
this.scheduleSelfUpdateStatusPoll();
|
|
3775
3828
|
return;
|
|
3776
3829
|
}
|
|
3830
|
+
this.coordinator?.selfUpdateCompleted?.(status);
|
|
3777
3831
|
await this.sendMessage(status.scopeId, this.formatSelfUpdateResult(status));
|
|
3778
3832
|
await this.selfUpdater?.clearStatus();
|
|
3779
3833
|
}
|
|
@@ -3820,7 +3874,7 @@ export class BridgeSessionCore {
|
|
|
3820
3874
|
createdAt: Date.now(),
|
|
3821
3875
|
};
|
|
3822
3876
|
this.pendingAuthChoiceLists.set(record.localId, record);
|
|
3823
|
-
const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null), authChoiceKeyboard(locale, record));
|
|
3877
|
+
const messageId = await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null), authChoiceKeyboard(locale, record));
|
|
3824
3878
|
record.messageId = messageId;
|
|
3825
3879
|
}
|
|
3826
3880
|
async handleAuthUseCommand(scopeId, locale, args) {
|
|
@@ -3842,7 +3896,7 @@ export class BridgeSessionCore {
|
|
|
3842
3896
|
if (!candidate) {
|
|
3843
3897
|
await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
|
|
3844
3898
|
const state = await this.listCodexAuthState();
|
|
3845
|
-
await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null));
|
|
3899
|
+
await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
|
|
3846
3900
|
return;
|
|
3847
3901
|
}
|
|
3848
3902
|
const switchLabels = await this.readCodexAuthSwitchLabels(candidate);
|
|
@@ -3859,7 +3913,7 @@ export class BridgeSessionCore {
|
|
|
3859
3913
|
const candidate = state.candidates[index - 1];
|
|
3860
3914
|
if (!candidate) {
|
|
3861
3915
|
await this.sendMessage(scopeId, t(locale, 'auth_choice_expired'));
|
|
3862
|
-
await this.sendMessage(scopeId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(scopeId) !== null));
|
|
3916
|
+
await this.sendMessage(scopeId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null));
|
|
3863
3917
|
return;
|
|
3864
3918
|
}
|
|
3865
3919
|
this.store.setCodexAuthCandidateDisabled(candidate.name, disabled, this.authRuntimeId());
|
|
@@ -4319,7 +4373,7 @@ export class BridgeSessionCore {
|
|
|
4319
4373
|
record.candidates = state.candidates;
|
|
4320
4374
|
await this.messaging.answerCallback(event.callbackQueryId, t(locale, disabled ? 'auth_candidate_disabled_short' : 'auth_candidate_enabled_short'));
|
|
4321
4375
|
if (record.messageId !== null) {
|
|
4322
|
-
await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, parseWeixinBridgeScope(event.scopeId) !== null), authChoiceKeyboard(locale, record));
|
|
4376
|
+
await this.editMessage(event.scopeId, record.messageId, renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(event.scopeId) !== null), authChoiceKeyboard(locale, record));
|
|
4323
4377
|
}
|
|
4324
4378
|
}
|
|
4325
4379
|
async handleAuthSwitchCallback(event, localId, index, locale) {
|
|
@@ -7304,12 +7358,14 @@ async function switchCodexAuth(targetPath, explicitAuthDir = null) {
|
|
|
7304
7358
|
toLabel: await authPathDisplayLabel(candidate.path),
|
|
7305
7359
|
};
|
|
7306
7360
|
}
|
|
7307
|
-
function renderAuthListMessage(locale, state, includeWeixinCopyPaste = false) {
|
|
7361
|
+
function renderAuthListMessage(locale, state, botLabel = null, includeWeixinCopyPaste = false) {
|
|
7308
7362
|
const lines = [
|
|
7309
7363
|
t(locale, 'auth_list_title'),
|
|
7310
|
-
t(locale, 'auth_current', { value: state.currentLabel ?? t(locale, 'none') }),
|
|
7311
|
-
t(locale, 'auth_dir', { value: state.authDir }),
|
|
7312
7364
|
];
|
|
7365
|
+
if (botLabel) {
|
|
7366
|
+
lines.push(t(locale, 'auth_bot', { value: botLabel }));
|
|
7367
|
+
}
|
|
7368
|
+
lines.push(t(locale, 'auth_current', { value: state.currentLabel ?? t(locale, 'none') }), t(locale, 'auth_dir', { value: state.authDir }));
|
|
7313
7369
|
if (state.candidates.length === 0) {
|
|
7314
7370
|
lines.push(t(locale, 'auth_no_candidates'));
|
|
7315
7371
|
if (includeWeixinCopyPaste) {
|