@foxden-app/foxclaw 0.5.78 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/CHANGELOG.md +38 -0
- package/README.md +17 -1
- package/README_EN.md +17 -1
- package/dist/codex_app/client.d.ts +1 -0
- package/dist/codex_app/client.js +62 -11
- package/dist/config.d.ts +8 -0
- package/dist/config.js +18 -0
- package/dist/i18n.d.ts +4 -0
- package/dist/i18n.js +37 -0
- package/dist/main.js +20 -1
- package/dist/opencode/client.d.ts +66 -0
- package/dist/opencode/client.js +499 -0
- package/dist/opencode/controller.d.ts +137 -0
- package/dist/opencode/controller.js +2009 -0
- package/dist/opencode/events.d.ts +61 -0
- package/dist/opencode/events.js +145 -0
- package/dist/opencode/runtime.d.ts +14 -0
- package/dist/opencode/runtime.js +29 -0
- package/dist/store/database.d.ts +1 -0
- package/dist/store/database.js +9 -0
- package/dist/telegram/gateway.d.ts +6 -1
- package/dist/telegram/gateway.js +6 -4
- package/package.json +3 -2
package/.env.example
CHANGED
|
@@ -60,6 +60,17 @@ TELEGRAM_PREVIEW_THROTTLE_MS=800
|
|
|
60
60
|
THREAD_LIST_LIMIT=10
|
|
61
61
|
CODEX_CLI_BIN=/absolute/path/to/codex
|
|
62
62
|
|
|
63
|
+
# Optional OpenCode bridge. This starts a second, independent Telegram bot
|
|
64
|
+
# alongside every existing Codex runtime; never reuse a TG_BOT_TOKEN here.
|
|
65
|
+
# FoxClaw manages a password-protected `opencode serve` bound to 127.0.0.1.
|
|
66
|
+
# OPENCODE_BOT_TOKEN=<different_telegram_bot_token>
|
|
67
|
+
# OPENCODE_CLI_BIN=/absolute/path/to/opencode
|
|
68
|
+
# Optional fixed Basic Auth password. If omitted, FoxClaw generates one and
|
|
69
|
+
# stores it with the managed server state (mode 0600).
|
|
70
|
+
# OPENCODE_SERVER_PASSWORD=
|
|
71
|
+
# OPENCODE_SERVER_STATE_PATH=
|
|
72
|
+
# OPENCODE_SERVER_LOG_PATH=
|
|
73
|
+
|
|
63
74
|
# Optional cross-node Codex auth sync.
|
|
64
75
|
# Disabled by default. When enabled, FoxClaw uses Telegram Bot-to-Bot private
|
|
65
76
|
# messages to move encrypted auth bundles between nodes. Enable Bot-to-Bot
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.6.3 - 2026-08-09
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复 Codex app-server 重启只终止 Node 包装进程、未终止同一 detached 进程组内原生 Codex 子进程的问题。认证刷新或手动重载后,不再遗留旧服务持有 thread writer lock 并触发 `already has an active writer`。
|
|
9
|
+
- 托管服务关闭现在先向整个进程组发送 `SIGTERM`,超时后升级为 `SIGKILL`;强制终止后仍存活会明确失败并阻止新实例叠加启动。加入忽略 `SIGTERM` 的父子进程树回归测试。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Fixed Codex app-server restarts terminating only the Node wrapper while leaving the native Codex child alive in the same detached process group. Auth refreshes and manual reloads no longer leave an old server holding a thread writer lock and causing `already has an active writer`.
|
|
13
|
+
- Managed shutdown now sends `SIGTERM` to the whole process group, escalates to `SIGKILL` on timeout, and fails explicitly instead of starting another instance if the group still survives. Added a regression test with a parent/child process tree that ignores `SIGTERM`.
|
|
14
|
+
|
|
15
|
+
## 0.6.2 - 2026-08-09
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- 修复 OpenCode 流式刷新并发竞态:Telegram 首次发送尚未返回时,后续增量与结束事件会排队复用同一消息,不再把 `/home`、半截路径和最终路径拆成多条或重复发送;观察流和工具状态也使用同一串行保证。
|
|
19
|
+
- `/models` 改为 OpenCode 原生习惯的 Provider → Model 两层选择;`/setup` 对齐 Codex Bot 的同消息交互,集中显示并切换模型、variant、Agent/单次 Plan、权限和运行中新消息策略。
|
|
20
|
+
- 补齐可保持语义的菜单:原生 `/undo`/`redo`、`/review`、归档恢复,显式 `/steer`/`queue`/`takeover`,以及 Rich、Voice、MCP Apps、Plugins、Hooks、Features 和 Provider auth 状态。Codex 专属账户、配额、Goal、Remote、设备登录与升级协调入口保留在菜单中,并说明不可等价支持的原因和替代操作。
|
|
21
|
+
|
|
22
|
+
### English
|
|
23
|
+
- Fixed an OpenCode streaming race. When the first Telegram send is still pending, later deltas and completion now serialize onto the same message instead of emitting `/home`, partial paths, and duplicated final output. Watch streams and tool status use the same serialization guarantee.
|
|
24
|
+
- Changed `/models` to a native-style two-level Provider → Model selector. `/setup` now matches the Codex bot's in-place interaction and centralizes model, variant, Agent/one-shot Plan, access, and active-message behavior.
|
|
25
|
+
- Added every semantics-preserving menu path: native `/undo`/`redo`, `/review`, archive restore, explicit `/steer`/`queue`/`takeover`, Rich, Voice, MCP Apps, Plugins, Hooks, Features, and provider-auth status. Codex-only account, quota, Goal, Remote, device-login, and update-coordinator entries remain visible with precise limitations and replacement workflows.
|
|
26
|
+
|
|
27
|
+
## 0.6.1 - 2026-08-09
|
|
28
|
+
|
|
29
|
+
### 中文
|
|
30
|
+
- 新增独立 `OPENCODE_BOT_TOKEN` runtime,在不替换、不降级现有 Codex Bot 的前提下,由 Telegram 驱动本机 `opencode serve` 会话。支持全局会话列表、编号打开、观察、历史、Fork、Diff、压缩、文件搜索、Skills、MCP、Provider 与安全配置摘要。
|
|
31
|
+
- 对齐 OpenCode 1.18.15 当前 HTTP/SSE 契约:使用 `/global/health`、跨目录 `/global/event`、`permission.asked`、`question.asked` 以及当前 reply API;加入 SSE 重连、漏事件轮询兜底、崩溃后托管进程重连和跨目录待审批/提问恢复。
|
|
32
|
+
- Telegram 体验覆盖流式消息合并、最终 Rich Markdown、工具进度清理、图片/文件附件、模型与 variant、Agent/单次 Plan、read-only/default/full-access、运行中 steer/queue、审批按钮和多问题回答。`/undo`、`/rollback` 明确不做语义近似。
|
|
33
|
+
- `opencode serve` 只绑定 `127.0.0.1` 并强制 Basic Auth;未配置密码时自动生成,以 `0600` 写入状态文件。OpenCode 必须使用不同的 Telegram Bot token,`doctor` 会做 CLI 与 token 隔离检查。
|
|
34
|
+
- `v0.6.0` 保留为被回退的干扰提交快照,因此实际新实现使用 `0.6.1`,不复用同一版本号。
|
|
35
|
+
|
|
36
|
+
### English
|
|
37
|
+
- Added an independent `OPENCODE_BOT_TOKEN` runtime that drives local `opencode serve` sessions from Telegram without replacing or reducing existing Codex bots. It covers global session listing, numbered open, watch, history, fork, diff, compaction, file search, skills, MCP, providers, and a redacted config summary.
|
|
38
|
+
- Aligned with the current OpenCode 1.18.15 HTTP/SSE contract: `/global/health`, cross-directory `/global/event`, `permission.asked`, `question.asked`, and current reply endpoints. Added SSE reconnects, polling fallback, managed-process reattachment after crashes, and cross-directory pending-request recovery.
|
|
39
|
+
- Added merged streaming, final Rich Markdown, transient tool progress, image/file attachments, models and variants, Agent/one-shot Plan, three access presets, active-turn steer/queue, approval buttons, and multi-question answers. `/undo` and `/rollback` explicitly avoid semantic approximation.
|
|
40
|
+
- `opencode serve` binds only to `127.0.0.1` and always uses Basic Auth. FoxClaw generates a password when omitted and writes state with mode `0600`. OpenCode requires a separate Telegram bot token, and `doctor` checks both CLI availability and token isolation.
|
|
41
|
+
- `v0.6.0` remains the tagged snapshot of the reverted interference commit, so the corrected implementation is versioned `0.6.1` instead of reusing the same release number.
|
|
42
|
+
|
|
5
43
|
## 0.5.78 - 2026-07-12
|
|
6
44
|
|
|
7
45
|
### 中文
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**狸爪:一个更适配编程需求的移动端 Codex 控制器。**
|
|
6
6
|
|
|
7
|
-
FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex,把移动端变成一个真正能用的 web coding 入口。Telegram 或微信负责交互,`codex app-server` 负责本地执行;你发任务、看进度、批审批、切线程,
|
|
7
|
+
FoxClaw(狸爪)的目标很直接:让你用手机控制本机的 Codex 或 OpenCode,把移动端变成一个真正能用的 web coding 入口。Telegram 或微信负责交互,`codex app-server` / `opencode serve` 负责本地执行;你发任务、看进度、批审批、切线程,Agent 在电脑上继续写代码。
|
|
8
8
|
|
|
9
9
|
这适合你离开办公桌去吃饭、在旅途中、在跑步机上,或者陪小孩逛公园的时候继续工作。人可以离开电脑,Codex 不必停;它会把关键进展、错误、审批请求和最终结果同步回手机。
|
|
10
10
|
|
|
@@ -120,6 +120,22 @@ FoxClaw 只响应 `TG_ALLOWED_USER_ID` 的消息——把机器人拉进群不
|
|
|
120
120
|
|
|
121
121
|
</details>
|
|
122
122
|
|
|
123
|
+
## OpenCode 独立 Bot(0.6.1+)
|
|
124
|
+
|
|
125
|
+
现有 Codex Bot 不需要改。再向 `@BotFather` 创建一个独立机器人,把 token 写入:
|
|
126
|
+
|
|
127
|
+
```dotenv
|
|
128
|
+
OPENCODE_BOT_TOKEN=234567:different-opencode-bot-token
|
|
129
|
+
# 可选;默认从 PATH 查找
|
|
130
|
+
OPENCODE_CLI_BIN=/absolute/path/to/opencode
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
重启 FoxClaw 后,这个 Bot 会连接本机受 Basic Auth 保护、仅监听 `127.0.0.1` 的 `opencode serve`。未设置 `OPENCODE_SERVER_PASSWORD` 时,FoxClaw 自动生成密码并以 `0600` 保存到托管状态文件。`OPENCODE_BOT_TOKEN` 不能与 `TG_BOT_TOKENS` / `TG_BOT_TOKEN` 复用,否则两个 long-poll runtime 会争抢同一个 Telegram update。
|
|
134
|
+
|
|
135
|
+
OpenCode Bot 保留与 Codex Bot 相同的主要操作习惯:直接发消息或附件、`/new`、`/threads` + `/open <编号>`、`/watch`、同消息内刷新的 `/setup`、Provider → Model 两层模型选择、variant、Agent/单次 Plan、三档权限、运行中 `/steer`/`/queue`/`/takeover`、流式合并、工具进度、审批按钮和问题选项。`/undo`/`/redo`、`/review` 与归档使用 OpenCode 原生 API;`/apps` 对应 MCP,Plugins、Hooks、Features 和语音入口也保持可用。Codex 账户、配额、Goal、Remote 和 Fast service tier 没有 OpenCode serve 等价 API,保留菜单入口并明确说明替代操作。
|
|
136
|
+
|
|
137
|
+
OpenCode 自身的 Provider 登录仍在本机终端完成,例如 `opencode auth`。运行 `foxclaw doctor` 会在配置了 OpenCode Bot 时额外检查 OpenCode CLI 和 token 隔离。
|
|
138
|
+
|
|
123
139
|
## 多账号切换
|
|
124
140
|
|
|
125
141
|
FoxClaw 的一大特色是自动多账号切换。当一个账号触发用量限制时,FoxClaw 会自动切换到下一个可用账号继续工作。
|
package/README_EN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**A mobile Codex controller built for real programming workflows.**
|
|
6
6
|
|
|
7
|
-
FoxClaw turns your phone into a practical web coding cockpit for
|
|
7
|
+
FoxClaw turns your phone into a practical web coding cockpit for local Codex or OpenCode. Telegram or Weixin handles the chat interface, `codex app-server` / `opencode serve` handles local execution, and you can send tasks, inspect progress, approve actions, switch sessions, and keep working without opening a laptop.
|
|
8
8
|
|
|
9
9
|
It is built for the moments when you leave your desk for lunch, commute, travel, use a treadmill, or take the kids to the park. You can step away from the keyboard while Codex keeps coding and sends progress, errors, approval requests, and final results back to your phone.
|
|
10
10
|
|
|
@@ -120,6 +120,22 @@ FoxClaw accepts messages only from `TG_ALLOWED_USER_ID`. Putting the bot in a gr
|
|
|
120
120
|
|
|
121
121
|
</details>
|
|
122
122
|
|
|
123
|
+
## Independent OpenCode Bot (0.6.1+)
|
|
124
|
+
|
|
125
|
+
Your existing Codex bots remain unchanged. Create one more independent bot with `@BotFather` and configure:
|
|
126
|
+
|
|
127
|
+
```dotenv
|
|
128
|
+
OPENCODE_BOT_TOKEN=234567:different-opencode-bot-token
|
|
129
|
+
# Optional; defaults to PATH lookup
|
|
130
|
+
OPENCODE_CLI_BIN=/absolute/path/to/opencode
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
After FoxClaw restarts, this bot uses a managed `opencode serve` protected by Basic Auth and bound only to `127.0.0.1`. When `OPENCODE_SERVER_PASSWORD` is unset, FoxClaw generates one and saves it in the managed state file with mode `0600`. Never reuse a token from `TG_BOT_TOKENS` / `TG_BOT_TOKEN`, because two long-poll runtimes would compete for the same Telegram updates.
|
|
134
|
+
|
|
135
|
+
The OpenCode bot preserves the main Codex-bot habits: plain messages and attachments, `/new`, `/threads` plus numbered `/open`, `/watch`, an in-place `/setup` panel, a two-level Provider → Model selector, variants, Agent/one-shot Plan, three access presets, active-turn `/steer`/`queue`/`takeover`, merged streaming, tool progress, approval buttons, and question options. `/undo`/`redo`, `/review`, and archive operations use native OpenCode APIs; `/apps` maps to MCP, while Plugins, Hooks, Features, and voice entry points remain available. Codex account, quota, Goal, Remote, and Fast service-tier commands have no OpenCode serve equivalent, so their menu entries explain the limitation and replacement workflow.
|
|
136
|
+
|
|
137
|
+
Provider authentication remains an OpenCode terminal operation, such as `opencode auth`. When an OpenCode bot is configured, `foxclaw doctor` also checks the OpenCode CLI and token isolation.
|
|
138
|
+
|
|
123
139
|
## Multi-Account Rotation
|
|
124
140
|
|
|
125
141
|
A key FoxClaw feature is automatic multi-account switching. When one account's usage limit is triggered, FoxClaw automatically switches to the next available account and continues working.
|
|
@@ -176,4 +176,5 @@ export declare class CodexAppClient extends EventEmitter {
|
|
|
176
176
|
private clearServerState;
|
|
177
177
|
private clearServerStateForPid;
|
|
178
178
|
}
|
|
179
|
+
export declare function terminateManagedProcessGroup(pid: number, gracefulTimeoutMs?: number, forcedTimeoutMs?: number): Promise<'not-running' | 'terminated' | 'killed'>;
|
|
179
180
|
export {};
|
package/dist/codex_app/client.js
CHANGED
|
@@ -710,24 +710,24 @@ export class CodexAppClient extends EventEmitter {
|
|
|
710
710
|
const state = this.readServerState();
|
|
711
711
|
const pid = child?.pid ?? state?.pid ?? null;
|
|
712
712
|
this.child = null;
|
|
713
|
-
|
|
714
|
-
|
|
713
|
+
if (pid === null || !isManagedProcessGroupAlive(pid)) {
|
|
714
|
+
this.clearServerState();
|
|
715
715
|
return;
|
|
716
716
|
}
|
|
717
717
|
try {
|
|
718
|
-
|
|
718
|
+
const result = await terminateManagedProcessGroup(pid);
|
|
719
|
+
if (result === 'killed') {
|
|
720
|
+
this.logger.warn('codex.app-server.force_killed', { pid });
|
|
721
|
+
}
|
|
719
722
|
}
|
|
720
723
|
catch (error) {
|
|
721
724
|
this.logger.warn('codex.app-server.kill_failed', {
|
|
722
725
|
pid,
|
|
723
726
|
error: error instanceof Error ? error.message : String(error),
|
|
724
727
|
});
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
const exited = await waitForProcessExit(pid, 3000);
|
|
728
|
-
if (!exited) {
|
|
729
|
-
this.logger.warn('codex.app-server.kill_timeout', { pid });
|
|
728
|
+
throw error;
|
|
730
729
|
}
|
|
730
|
+
this.clearServerStateForPid(pid);
|
|
731
731
|
}
|
|
732
732
|
openServerLogFiles() {
|
|
733
733
|
fs.mkdirSync(path.dirname(this.serverLogPath), { recursive: true });
|
|
@@ -831,15 +831,66 @@ function isProcessAlive(pid) {
|
|
|
831
831
|
return error.code === 'EPERM';
|
|
832
832
|
}
|
|
833
833
|
}
|
|
834
|
-
async function
|
|
834
|
+
export async function terminateManagedProcessGroup(pid, gracefulTimeoutMs = 3000, forcedTimeoutMs = 2000) {
|
|
835
|
+
if (!isManagedProcessGroupAlive(pid)) {
|
|
836
|
+
return 'not-running';
|
|
837
|
+
}
|
|
838
|
+
signalManagedProcessGroup(pid, 'SIGTERM');
|
|
839
|
+
if (await waitForProcessGroupExit(pid, gracefulTimeoutMs)) {
|
|
840
|
+
return 'terminated';
|
|
841
|
+
}
|
|
842
|
+
signalManagedProcessGroup(pid, 'SIGKILL');
|
|
843
|
+
if (await waitForProcessGroupExit(pid, forcedTimeoutMs)) {
|
|
844
|
+
return 'killed';
|
|
845
|
+
}
|
|
846
|
+
throw new Error(`Codex app-server process group ${pid} did not exit after SIGKILL`);
|
|
847
|
+
}
|
|
848
|
+
function signalManagedProcessGroup(pid, signal) {
|
|
849
|
+
try {
|
|
850
|
+
process.kill(-pid, signal);
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
if (error.code !== 'ESRCH') {
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
process.kill(pid, signal);
|
|
858
|
+
}
|
|
859
|
+
catch (fallbackError) {
|
|
860
|
+
if (fallbackError.code !== 'ESRCH') {
|
|
861
|
+
throw fallbackError;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function isManagedProcessGroupAlive(pid) {
|
|
867
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
process.kill(-pid, 0);
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
const code = error.code;
|
|
876
|
+
if (code === 'EPERM') {
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
if (code === 'ESRCH') {
|
|
880
|
+
return isProcessAlive(pid);
|
|
881
|
+
}
|
|
882
|
+
throw error;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
async function waitForProcessGroupExit(pid, timeoutMs) {
|
|
835
886
|
const started = Date.now();
|
|
836
887
|
while (Date.now() - started < timeoutMs) {
|
|
837
|
-
if (!
|
|
888
|
+
if (!isManagedProcessGroupAlive(pid)) {
|
|
838
889
|
return true;
|
|
839
890
|
}
|
|
840
891
|
await sleep(100);
|
|
841
892
|
}
|
|
842
|
-
return !
|
|
893
|
+
return !isManagedProcessGroupAlive(pid);
|
|
843
894
|
}
|
|
844
895
|
function mapThread(raw) {
|
|
845
896
|
return {
|
package/dist/config.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export declare const DEFAULT_LOG_PATH: string;
|
|
|
7
7
|
export declare const DEFAULT_LOCK_PATH: string;
|
|
8
8
|
export declare const DEFAULT_CODEX_APP_SERVER_STATE_PATH: string;
|
|
9
9
|
export declare const DEFAULT_CODEX_APP_SERVER_LOG_PATH: string;
|
|
10
|
+
export declare const DEFAULT_OPENCODE_SERVER_STATE_PATH: string;
|
|
11
|
+
export declare const DEFAULT_OPENCODE_SERVER_LOG_PATH: string;
|
|
10
12
|
export declare const DEFAULT_CODEX_TELEGRAM_HOME: string;
|
|
11
13
|
export declare const DEFAULT_AUTH_SYNC_STATE_PATH: string;
|
|
12
14
|
export declare const DEFAULT_AUTH_SYNC_TEMP_DIR: string;
|
|
@@ -35,6 +37,11 @@ export interface AppConfig {
|
|
|
35
37
|
codexApiDefaultProvider: string | null;
|
|
36
38
|
codexAppSyncOnOpen: boolean;
|
|
37
39
|
codexAppSyncOnTurnComplete: boolean;
|
|
40
|
+
opencodeBotToken: string | null;
|
|
41
|
+
opencodeCliBin: string;
|
|
42
|
+
opencodeServerPassword: string | null;
|
|
43
|
+
opencodeServerStatePath: string;
|
|
44
|
+
opencodeServerLogPath: string;
|
|
38
45
|
storePath: string;
|
|
39
46
|
logLevel: LogLevel;
|
|
40
47
|
defaultCwd: string;
|
|
@@ -93,6 +100,7 @@ export interface CodexApiProviderConfig {
|
|
|
93
100
|
}
|
|
94
101
|
export declare function loadConfig(): AppConfig;
|
|
95
102
|
export declare function buildCodexApiProviderOverrides(providers: readonly CodexApiProviderConfig[], defaultProviderId?: string | null): string[];
|
|
103
|
+
export declare function validateOpencodeBotToken(token: string | null, codexTokens: readonly string[]): void;
|
|
96
104
|
export declare function parseCodexApiProviders(raw: string | undefined): CodexApiProviderConfig[];
|
|
97
105
|
export declare function selectDefaultRuntimeBotToken(configuredTokens: string[], legacyToken: string | null): string | null;
|
|
98
106
|
export declare function ensureAppDirs(config: AppConfig): void;
|
package/dist/config.js
CHANGED
|
@@ -10,6 +10,8 @@ export const DEFAULT_LOG_PATH = path.join(APP_HOME, 'logs', 'service.log');
|
|
|
10
10
|
export const DEFAULT_LOCK_PATH = path.join(APP_HOME, 'runtime', 'bridge.lock');
|
|
11
11
|
export const DEFAULT_CODEX_APP_SERVER_STATE_PATH = path.join(APP_HOME, 'runtime', 'codex-app-server.json');
|
|
12
12
|
export const DEFAULT_CODEX_APP_SERVER_LOG_PATH = path.join(APP_HOME, 'logs', 'codex-app-server.log');
|
|
13
|
+
export const DEFAULT_OPENCODE_SERVER_STATE_PATH = path.join(APP_HOME, 'runtime', 'opencode-server.json');
|
|
14
|
+
export const DEFAULT_OPENCODE_SERVER_LOG_PATH = path.join(APP_HOME, 'logs', 'opencode-server.log');
|
|
13
15
|
export const DEFAULT_CODEX_TELEGRAM_HOME = path.join(APP_HOME, 'codex', 'telegram');
|
|
14
16
|
export const DEFAULT_AUTH_SYNC_STATE_PATH = path.join(APP_HOME, 'runtime', 'auth-sync.json');
|
|
15
17
|
export const DEFAULT_AUTH_SYNC_TEMP_DIR = path.join(APP_HOME, 'runtime', 'auth-sync');
|
|
@@ -51,6 +53,8 @@ export function loadConfig() {
|
|
|
51
53
|
if (tgBotTokens.length === 0) {
|
|
52
54
|
throw new Error('TG_BOT_TOKENS or TG_BOT_TOKEN is required');
|
|
53
55
|
}
|
|
56
|
+
const opencodeBotToken = optional('OPENCODE_BOT_TOKEN');
|
|
57
|
+
validateOpencodeBotToken(opencodeBotToken, tgBotTokens);
|
|
54
58
|
const config = {
|
|
55
59
|
tgBotToken: tgBotTokens[0],
|
|
56
60
|
tgBotTokens,
|
|
@@ -72,6 +76,11 @@ export function loadConfig() {
|
|
|
72
76
|
codexApiDefaultProvider: optionalSanitizedProviderId(process.env.CODEX_API_DEFAULT_PROVIDER),
|
|
73
77
|
codexAppSyncOnOpen: boolEnv('CODEX_APP_SYNC_ON_OPEN', true),
|
|
74
78
|
codexAppSyncOnTurnComplete: boolEnv('CODEX_APP_SYNC_ON_TURN_COMPLETE', false),
|
|
79
|
+
opencodeBotToken,
|
|
80
|
+
opencodeCliBin: process.env.OPENCODE_CLI_BIN || resolveCommand('opencode') || 'opencode',
|
|
81
|
+
opencodeServerPassword: optional('OPENCODE_SERVER_PASSWORD'),
|
|
82
|
+
opencodeServerStatePath: process.env.OPENCODE_SERVER_STATE_PATH || DEFAULT_OPENCODE_SERVER_STATE_PATH,
|
|
83
|
+
opencodeServerLogPath: process.env.OPENCODE_SERVER_LOG_PATH || DEFAULT_OPENCODE_SERVER_LOG_PATH,
|
|
75
84
|
storePath: process.env.STORE_PATH || DEFAULT_STORE_PATH,
|
|
76
85
|
logLevel: parseLogLevel(process.env.LOG_LEVEL || 'info'),
|
|
77
86
|
defaultCwd: process.env.DEFAULT_CWD || process.cwd(),
|
|
@@ -140,6 +149,13 @@ export function buildCodexApiProviderOverrides(providers, defaultProviderId = nu
|
|
|
140
149
|
}
|
|
141
150
|
return overrides;
|
|
142
151
|
}
|
|
152
|
+
export function validateOpencodeBotToken(token, codexTokens) {
|
|
153
|
+
if (!token)
|
|
154
|
+
return;
|
|
155
|
+
if (codexTokens.includes(token)) {
|
|
156
|
+
throw new Error('OPENCODE_BOT_TOKEN must use a different bot from TG_BOT_TOKENS/TG_BOT_TOKEN');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
143
159
|
export function parseCodexApiProviders(raw) {
|
|
144
160
|
if (!raw?.trim())
|
|
145
161
|
return [];
|
|
@@ -249,6 +265,8 @@ export function ensureAppDirs(config) {
|
|
|
249
265
|
path.dirname(config.lockPath),
|
|
250
266
|
path.dirname(config.codexAppServerStatePath),
|
|
251
267
|
path.dirname(config.codexAppServerLogPath),
|
|
268
|
+
path.dirname(config.opencodeServerStatePath),
|
|
269
|
+
path.dirname(config.opencodeServerLogPath),
|
|
252
270
|
path.dirname(config.authSyncStatePath),
|
|
253
271
|
];
|
|
254
272
|
if (config.authSyncEnabled) {
|
package/dist/i18n.d.ts
CHANGED
|
@@ -1457,4 +1457,8 @@ export declare function getTelegramCommands(locale: AppLocale): Array<{
|
|
|
1457
1457
|
command: string;
|
|
1458
1458
|
description: string;
|
|
1459
1459
|
}>;
|
|
1460
|
+
export declare function getOpencodeTelegramCommands(locale: AppLocale): Array<{
|
|
1461
|
+
command: string;
|
|
1462
|
+
description: string;
|
|
1463
|
+
}>;
|
|
1460
1464
|
export {};
|
package/dist/i18n.js
CHANGED
|
@@ -1507,3 +1507,40 @@ export function getTelegramCommands(locale) {
|
|
|
1507
1507
|
{ command: 'interrupt', description: t(locale, 'cmd_desc_interrupt') },
|
|
1508
1508
|
];
|
|
1509
1509
|
}
|
|
1510
|
+
export function getOpencodeTelegramCommands(locale) {
|
|
1511
|
+
const descriptions = locale === 'zh' ? {
|
|
1512
|
+
help: '查看命令', setup: 'OpenCode 设置', status: '桥接状态', update: '请改用 Codex Bot', threads: '最近会话', open: '打开会话',
|
|
1513
|
+
watch: '观察会话', unwatch: '停止观察', new: '新建会话', models: '模型列表', model: '选择模型',
|
|
1514
|
+
effort: '推理档位', fast: 'Fast 能力说明', mode: 'Agent 或 Plan', plan: '下一轮 Plan', agent: '选择 Agent', permissions: '权限设置',
|
|
1515
|
+
active: '运行中新消息', steer: '引导当前回复', queue: '排队下一轮', takeover: '中断并接管',
|
|
1516
|
+
history: '最近消息', files: '查找文件', fork: 'Fork 会话', undo: '原生回退', redo: '原生重做', rename: '重命名会话',
|
|
1517
|
+
review: '原生代码审查', archive: '归档当前会话', unarchive: '恢复归档会话', rich: '富文本测试', voice: '朗读文本或发送语音',
|
|
1518
|
+
diff: '会话文件变更', where: '当前会话信息', reveal: '当前会话信息', compact: '压缩上下文', loaded: '已加载会话', skills: 'OpenCode Skills',
|
|
1519
|
+
mcp: 'MCP 状态', apps: 'MCP 应用', provider: '模型 Provider', auth: 'Provider 认证状态', auth_reload: '无 OpenCode 等价 API',
|
|
1520
|
+
account: '无 OpenCode 账户 API', quota: '无 OpenCode 配额 API', login_device: '请用终端 opencode auth', goal: '无 OpenCode Goal 原语', remote: 'Codex Remote 专用',
|
|
1521
|
+
plugins: 'OpenCode Plugins', hooks: 'Plugin hooks', features: '实验能力', config: '配置摘要', approve: '待审批', answer: '待回答', interrupt: '中断回复',
|
|
1522
|
+
} : {
|
|
1523
|
+
help: 'Show commands', setup: 'OpenCode settings', status: 'Bridge status', update: 'Use the Codex bot', threads: 'Recent sessions', open: 'Open a session',
|
|
1524
|
+
watch: 'Watch a session', unwatch: 'Stop watching', new: 'New session', models: 'Model list', model: 'Select model',
|
|
1525
|
+
effort: 'Reasoning variant', fast: 'Fast capability note', mode: 'Agent or Plan', plan: 'Plan next turn', agent: 'Select agent', permissions: 'Access settings',
|
|
1526
|
+
active: 'Active-turn messages', steer: 'Steer active turn', queue: 'Queue next turn', takeover: 'Interrupt and take over',
|
|
1527
|
+
history: 'Recent messages', files: 'Find files', fork: 'Fork session', undo: 'Native undo', redo: 'Native redo', rename: 'Rename session',
|
|
1528
|
+
review: 'Native code review', archive: 'Archive current session', unarchive: 'Restore archived session', rich: 'Rich-text test', voice: 'Read text or send voice',
|
|
1529
|
+
diff: 'Session file changes', where: 'Current session', reveal: 'Current session', compact: 'Compact context', loaded: 'Loaded sessions', skills: 'OpenCode skills',
|
|
1530
|
+
mcp: 'MCP status', apps: 'MCP apps', provider: 'Model providers', auth: 'Provider auth status', auth_reload: 'No OpenCode equivalent',
|
|
1531
|
+
account: 'No OpenCode account API', quota: 'No OpenCode quota API', login_device: 'Use opencode auth terminal', goal: 'No OpenCode Goal primitive', remote: 'Codex Remote only',
|
|
1532
|
+
plugins: 'OpenCode plugins', hooks: 'Plugin hooks', features: 'Experimental capabilities', config: 'Config summary', approve: 'Pending approvals', answer: 'Pending questions', interrupt: 'Stop active turn',
|
|
1533
|
+
};
|
|
1534
|
+
const localized = descriptions;
|
|
1535
|
+
const codexMenu = getTelegramCommands(locale).map((entry) => ({
|
|
1536
|
+
command: entry.command,
|
|
1537
|
+
description: localized[entry.command] ?? entry.description,
|
|
1538
|
+
}));
|
|
1539
|
+
const shared = new Set(codexMenu.map((entry) => entry.command));
|
|
1540
|
+
return [
|
|
1541
|
+
...codexMenu,
|
|
1542
|
+
...Object.entries(localized)
|
|
1543
|
+
.filter(([command]) => !shared.has(command))
|
|
1544
|
+
.map(([command, description]) => ({ command, description })),
|
|
1545
|
+
];
|
|
1546
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -552,7 +552,7 @@ function truncateStatusLine(value, maxLength) {
|
|
|
552
552
|
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
553
553
|
}
|
|
554
554
|
async function runServeCli() {
|
|
555
|
-
const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync },] = await Promise.all([
|
|
555
|
+
const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync }, { OpencodeTelegramRuntime },] = await Promise.all([
|
|
556
556
|
import('./channels/bridge_messaging_router.js'),
|
|
557
557
|
import('./channels/telegram/telegram_messaging_port.js'),
|
|
558
558
|
import('./channels/weixin/weixin_channel_adapter.js'),
|
|
@@ -567,6 +567,7 @@ async function runServeCli() {
|
|
|
567
567
|
import('./channels/telegram/telegram_channel_adapter.js'),
|
|
568
568
|
import('./auth/mirror.js'),
|
|
569
569
|
import('./auth/cross_node_sync.js'),
|
|
570
|
+
import('./opencode/runtime.js'),
|
|
570
571
|
]);
|
|
571
572
|
const config = loadConfig();
|
|
572
573
|
const logger = new Logger(config.logLevel, config.logPath);
|
|
@@ -581,8 +582,14 @@ async function runServeCli() {
|
|
|
581
582
|
let managedApps = [];
|
|
582
583
|
let activeAuthMirror = null;
|
|
583
584
|
let activeAuthSync = null;
|
|
585
|
+
let activeOpencodeRuntime = null;
|
|
584
586
|
try {
|
|
585
587
|
store = new BridgeStore(config.storePath);
|
|
588
|
+
if (config.opencodeBotToken) {
|
|
589
|
+
activeOpencodeRuntime = new OpencodeTelegramRuntime(config, store, logger);
|
|
590
|
+
await activeOpencodeRuntime.start();
|
|
591
|
+
logger.info('opencode.bridge.started', activeOpencodeRuntime.getRuntimeStatus());
|
|
592
|
+
}
|
|
586
593
|
if (config.tgMultiBotMode) {
|
|
587
594
|
const seeds = [];
|
|
588
595
|
const canonicalAuthDir = config.codexAuthDir ?? config.codexHome ?? path.join(os.homedir(), '.codex');
|
|
@@ -881,6 +888,7 @@ async function runServeCli() {
|
|
|
881
888
|
await weixinAdapter?.stop();
|
|
882
889
|
await activeWeixinCore?.stop();
|
|
883
890
|
await Promise.all(runtimes.map((runtime) => runtime.telegram.stop()));
|
|
891
|
+
await activeOpencodeRuntime?.stop();
|
|
884
892
|
writeAggregateStatus(false);
|
|
885
893
|
await Promise.all(managedApps.map((app) => app.stop({ terminateServer: true }).catch((error) => {
|
|
886
894
|
logger.warn('codex.app-server.stop_failed', { error: serializeError(error) });
|
|
@@ -1060,6 +1068,7 @@ async function runServeCli() {
|
|
|
1060
1068
|
singleMirror?.stop();
|
|
1061
1069
|
await weixinAdapter?.stop();
|
|
1062
1070
|
await telegram.stop();
|
|
1071
|
+
await activeOpencodeRuntime?.stop();
|
|
1063
1072
|
writeRuntimeStatus(config.statusPath, {
|
|
1064
1073
|
running: false,
|
|
1065
1074
|
connected: false,
|
|
@@ -1093,6 +1102,7 @@ async function runServeCli() {
|
|
|
1093
1102
|
await weixinAdapter?.stop().catch(() => { });
|
|
1094
1103
|
await activeWeixinCore?.stop().catch(() => { });
|
|
1095
1104
|
await Promise.allSettled(activeTelegramAdapters.map((adapter) => adapter.stop()));
|
|
1105
|
+
await activeOpencodeRuntime?.stop().catch(() => { });
|
|
1096
1106
|
await Promise.allSettled(managedApps.map((app) => app.stop({ terminateServer: true })));
|
|
1097
1107
|
store?.close();
|
|
1098
1108
|
processLock.release();
|
|
@@ -1778,6 +1788,15 @@ function runDoctorChecks() {
|
|
|
1778
1788
|
['telegram bot token(s) configured', Boolean(process.env.TG_BOT_TOKENS?.trim() || process.env.TG_BOT_TOKEN?.trim())],
|
|
1779
1789
|
['telegram allowed user configured', Boolean(process.env.TG_ALLOWED_USER_ID)],
|
|
1780
1790
|
];
|
|
1791
|
+
if (process.env.OPENCODE_BOT_TOKEN?.trim()) {
|
|
1792
|
+
const configuredOpencodeBin = process.env.OPENCODE_CLI_BIN;
|
|
1793
|
+
checks.push(['opencode cli available', hasConfiguredCodexBin(configuredOpencodeBin) || hasCommand('opencode')]);
|
|
1794
|
+
const codexTokens = [
|
|
1795
|
+
...(process.env.TG_BOT_TOKENS ?? '').split(','),
|
|
1796
|
+
process.env.TG_BOT_TOKEN ?? '',
|
|
1797
|
+
].map((value) => value.trim()).filter(Boolean);
|
|
1798
|
+
checks.push(['OpenCode uses an independent Telegram bot', !codexTokens.includes(process.env.OPENCODE_BOT_TOKEN.trim())]);
|
|
1799
|
+
}
|
|
1781
1800
|
if (process.env.WX_ENABLED === 'true' || process.env.WX_ENABLED === '1') {
|
|
1782
1801
|
const accountsDir = process.env.WEIXIN_ACCOUNTS_DIR || path.join(APP_HOME, 'weixin', 'accounts');
|
|
1783
1802
|
let hasAccounts = false;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { type OpencodeClient } from '@opencode-ai/sdk/v2';
|
|
3
|
+
import type { Logger } from '../logger.js';
|
|
4
|
+
import { type OpencodeBridgeEvent } from './events.js';
|
|
5
|
+
export interface OpencodeServerRuntimeStatus {
|
|
6
|
+
pid: number | null;
|
|
7
|
+
port: number | null;
|
|
8
|
+
running: boolean;
|
|
9
|
+
connected: boolean;
|
|
10
|
+
url: string | null;
|
|
11
|
+
version: string | null;
|
|
12
|
+
managed: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface OpencodeAppClientEvents {
|
|
15
|
+
event: [event: OpencodeBridgeEvent];
|
|
16
|
+
connected: [];
|
|
17
|
+
disconnected: [detail: {
|
|
18
|
+
source: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
}];
|
|
21
|
+
}
|
|
22
|
+
/** Owns one password-protected localhost `opencode serve` process. */
|
|
23
|
+
export declare class OpencodeAppClient extends EventEmitter<OpencodeAppClientEvents> {
|
|
24
|
+
private readonly cliBin;
|
|
25
|
+
private readonly configuredPassword;
|
|
26
|
+
private readonly statePath;
|
|
27
|
+
private readonly logPath;
|
|
28
|
+
private readonly logger;
|
|
29
|
+
private readonly childEnv;
|
|
30
|
+
private readonly normalizer;
|
|
31
|
+
private client;
|
|
32
|
+
private child;
|
|
33
|
+
private serverState;
|
|
34
|
+
private connected;
|
|
35
|
+
private sseAbort;
|
|
36
|
+
private sseLoop;
|
|
37
|
+
private readonly pollers;
|
|
38
|
+
private desiredRunning;
|
|
39
|
+
private starting;
|
|
40
|
+
private reconnectTimer;
|
|
41
|
+
constructor(cliBin: string, configuredPassword: string | null, statePath: string, logPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null);
|
|
42
|
+
getClient(): OpencodeClient;
|
|
43
|
+
isConnected(): boolean;
|
|
44
|
+
getServerStatus(): OpencodeServerRuntimeStatus;
|
|
45
|
+
start(): Promise<void>;
|
|
46
|
+
stop(options?: {
|
|
47
|
+
terminateServer?: boolean;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
restart(): Promise<void>;
|
|
50
|
+
watchSessionUntilIdle(sessionId: string, directory: string): void;
|
|
51
|
+
recoverPendingRequests(directories: readonly string[]): Promise<void>;
|
|
52
|
+
private pollSession;
|
|
53
|
+
private attachPersistedServer;
|
|
54
|
+
private spawnServer;
|
|
55
|
+
private adoptServer;
|
|
56
|
+
private startSseLoop;
|
|
57
|
+
private runSseLoop;
|
|
58
|
+
private acceptEvent;
|
|
59
|
+
private recoverPendingForDirectory;
|
|
60
|
+
private handleDisconnect;
|
|
61
|
+
private scheduleReconnect;
|
|
62
|
+
private writeState;
|
|
63
|
+
private readState;
|
|
64
|
+
private clearStateForPid;
|
|
65
|
+
}
|
|
66
|
+
export declare function formatSdkError(error: unknown): string;
|