sintbot 1.4.15 → 1.4.17
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/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/sintbot-v2.js +104 -4
- package/docs/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/install.md +46 -10
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/zh-CN/install.md +46 -10
- package/install.sh +1 -1
- package/package.json +10 -3
- package/src/agent/claudecode.js +5 -2
- package/src/agent/claudemodels.js +79 -8
- package/src/agent/claudeusage.js +188 -3
- package/src/agent/codexapp.js +4 -3
- package/src/agent/codexcode.js +1 -1
- package/src/agent/envconflicts.js +74 -0
- package/src/agent/privatecore.js +65 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +29 -18
- package/src/c2console.js +22 -6
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +22 -2
- package/src/connect.js +488 -0
- package/src/core/brain.js +117 -0
- package/src/core/c2inputrejection.js +19 -6
- package/src/core/c2runnerinput.js +7 -2
- package/src/core/modelcmd.js +132 -0
- package/src/core/modelname.js +61 -0
- package/src/core/v2engine.js +304 -47
- package/src/groupbrain.js +135 -0
- package/src/modelcatalog.js +122 -0
- package/src/platform/feishu.js +39 -7
- package/src/platform/modelcard.js +103 -0
- package/src/ui/console.html +273 -147
- package/src/ui/serverbase.js +174 -17
- package/src/usage.js +131 -38
- package/src/modelcontext.js +0 -13
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.17
|
|
2
2
|
|
|
3
3
|
[English](../install.md) · 简体中文
|
|
4
4
|
|
|
@@ -10,13 +10,47 @@
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
安装器会自动检查环境,把 `sintbot@1.4.
|
|
13
|
+
安装器会自动检查环境,把 `sintbot@1.4.17` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.17` 就可以继续。不需要 sudo。
|
|
14
14
|
|
|
15
15
|
可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
|
|
16
16
|
|
|
17
|
-
## 2.
|
|
17
|
+
## 2. 扫码接入(推荐)
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
前提:手机飞书当前处于企业/团队身份;没有组织的个人身份扫不了,需先新建一个团队(免费)。扫码创建的应用归扫码人所在的组织,公司普通成员建了自己删不掉。
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
"$HOME/.local/share/sintbot/bin/sintbot" connect
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
终端先打印扫码链接,并打开一个本机页面显示二维码;用飞书扫码、在手机上确认。随后它依次:取得应用凭证(并打印应用 ID)→ 自检 → 放开通讯录范围与可用范围并发布版本 → 创建群并把你拉进去 → 新建项目目录 `~/Projects/sintbot`(含一个 `README.md`)→ 写入 `~/.sbot/config.json` → 初始化消息日志。每一步的结果都会打印。扫码一旦确认,应用就已在你的组织里创建;之后任一检查或建群失败都会在建目录、写配置之前停下,并给出带 `--app-id` 的重跑命令。
|
|
26
|
+
|
|
27
|
+
- 凭证明文写在 `~/.sbot/config.json`(文件 0600、目录 0700)。要改回环境变量,把 `appId` / `appSecret` 改成 `"env:FEISHU_APP_ID"` / `"env:FEISHU_APP_SECRET"`,并在每次启动前导出这两个变量。
|
|
28
|
+
- `--basic`:公司组织里的非管理员使用。不申请 `application:application:patch`,跳过放开范围与发布;之后请管理员在开放平台补这两步。
|
|
29
|
+
- `--app-id cli_…`:复用已有应用(给它追加权限,不新建),用于中途停下后重跑或审核通过后继续。只在没有配置文件、或配置文件属于这个应用且还没绑定任何群时允许;已绑定群的配置不会被替换。
|
|
30
|
+
- `--chat oc_…`:不自动建群,直接绑定你手动建好并拉入机器人的群(建群失败时的提示里会给出这条命令)。
|
|
31
|
+
- `--workdir /绝对路径`:绑定一个已存在的目录,不新建;`--name` 换默认目录名;`--provider codex|claude` 指定执行器(本机只装了一个时自动选);`--no-open` 只打印链接、不打开页面。
|
|
32
|
+
- 已有飞书配置或服务正在运行时,`connect` 会拒绝执行。扫码会申请模型卡所需的卡片回调。`/model` 点选卡没有反应时,按第 4 节检查回调订阅。
|
|
33
|
+
|
|
34
|
+
## 3. 首次启动
|
|
35
|
+
|
|
36
|
+
`connect` 已经完成初始化,直接启动:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
"$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
它会预检配置和执行器、连接飞书并打开本机控制台。保持终端开启。在群里发“读取项目 README 的第一行,只回复这一行”,并和 `~/Projects/sintbot/README.md` 对照。预检通过不代表飞书认证或消息送达已经验证。
|
|
43
|
+
|
|
44
|
+
## 4. 手动接入(备选)
|
|
45
|
+
|
|
46
|
+
扫码失败、管理员不放行,或者想自己管理应用时,走下面的手动路径;内容与之前的安装步骤相同。
|
|
47
|
+
|
|
48
|
+
<details>
|
|
49
|
+
<summary>展开手动步骤</summary>
|
|
50
|
+
|
|
51
|
+
#### 4.1 创建应用
|
|
52
|
+
|
|
53
|
+
在 [飞书开放平台](https://open.feishu.cn/) 创建企业自建应用,启用机器人、接收和回复消息的权限,事件订阅选择长连接并订阅消息接收;回调订阅同样选择长连接接收回调并订阅卡片回传交互(群里点选 `/model` 卡片要用)。发布应用后,把机器人加入要使用的群,取得每个群的 `oc_…` 群 ID。
|
|
20
54
|
|
|
21
55
|
在 macOS 默认终端中输入应用凭据(密钥输入时不显示):
|
|
22
56
|
|
|
@@ -29,7 +63,7 @@ export FEISHU_APP_ID FEISHU_APP_SECRET
|
|
|
29
63
|
|
|
30
64
|
不要把密钥发进群或提交到仓库。后续步骤都在这个终端里做;新开终端时需要重新设置这两个变量。
|
|
31
65
|
|
|
32
|
-
|
|
66
|
+
#### 4.2 手写配置
|
|
33
67
|
|
|
34
68
|
用 `mkdir -p ~/.sbot && chmod 700 ~/.sbot` 创建私有配置目录,再创建 `~/.sbot/config.json`。把群 ID 和项目绝对路径换成你自己的,项目目录必须已经存在。下面的示例绑定两个群:一个用 Codex,一个用 Claude Code。只有一个群时删掉另一项即可。
|
|
35
69
|
|
|
@@ -69,9 +103,9 @@ export FEISHU_APP_ID FEISHU_APP_SECRET
|
|
|
69
103
|
- 要固定 Claude 型号,可在 `providers.claude` 里加 `"model"`;不写就用这台机器上 Claude Code 的默认模型。
|
|
70
104
|
- 同时运行的任务默认最多 2 个,可在顶层加 `"agent": { "maxConcurrent": 4 }` 调整(1–32)。
|
|
71
105
|
|
|
72
|
-
|
|
106
|
+
#### 4.3 初始化并启动
|
|
73
107
|
|
|
74
|
-
|
|
108
|
+
在刚才的终端里一次复制执行(手动路径需要自己执行 `init`):
|
|
75
109
|
|
|
76
110
|
```bash
|
|
77
111
|
SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
@@ -84,6 +118,8 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
84
118
|
|
|
85
119
|
在群里发“读取项目 README 的第一行,只回复这一行”,并和本地文件对照。预检通过不代表飞书认证或消息送达已经验证。
|
|
86
120
|
|
|
121
|
+
</details>
|
|
122
|
+
|
|
87
123
|
## 5. 在群里使用
|
|
88
124
|
|
|
89
125
|
直接发文字就是给它派任务。回复以卡片呈现:模型开始输出时出现;25 秒还没有输出也会先出现。运行中卡片显示已跑多久、正在做什么和已完成的步骤,结束后这些步骤收进「过程」折叠区。
|
|
@@ -94,7 +130,7 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
94
130
|
| `/skip` | 只放弃当前任务,排队的接着处理,之前的对话保留 |
|
|
95
131
|
| `/stop` | 停止当前任务,连同排队的一起取消;之前的对话保留 |
|
|
96
132
|
| `/new` | 忘掉之前的对话,开始新的一段(只在本群空闲时生效) |
|
|
97
|
-
| `/model` |
|
|
133
|
+
| `/model` | 打开点选卡,选后端、型号和思考深度后点「确认更新」;也可直接发 `/model high`、`/model 型号`、`/model 后端`。换型号或调深度接着当前对话,换后端开始新的一段对话;只在本群空闲时生效 |
|
|
98
134
|
| `/hud` | 当前模型的额度与本段对话的用量 |
|
|
99
135
|
| `/issue 一句话` | 记下问题反馈,在控制台概览里查看和标记处理 |
|
|
100
136
|
| `/help` | 查看可用命令,以及本群的项目和模型 |
|
|
@@ -107,7 +143,7 @@ Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队
|
|
|
107
143
|
|
|
108
144
|
## 6. 控制台
|
|
109
145
|
|
|
110
|
-
|
|
146
|
+
型号与思考深度按群选:在「群 · 项目」卡的「模型」行选后端和型号,下一行选深度,清单取自本机 Codex / Claude Code 的实时数据;订阅登录的模型卡上不再设型号和深度,第三方端点的型号仍在模型卡上设置。升级后,模型卡上原有的型号和深度会自动写进各群,生效值不变、会话不重置,改前备份配置。模型卡区分设置值与运行回报,容量依据可在「容量与运行信息」中查看。Codex 上下文窗口和各模型自动压缩都只提供「执行器默认 / 自定义」,仅自定义时显示数值输入;从下轮生效,保留当前会话。Codex 自定义窗口不能超过已知目录上限。压缩值对 Codex 表示阈值,对 Claude Code 表示窗口,实际可能提前触发。旧配置中的全局压缩值会转换为原有各模型的独立值,已有逐模型设置优先;新模型默认交给执行器,不继续继承旧全局值。读取不改磁盘,下一次正常保存配置时写入转换结果。
|
|
111
147
|
|
|
112
148
|
概览合并群项目与并发信息,每行显示执行器、状态与耗时、项目目录、模型和配置的思考深度、最近上报的上下文用量。选择群后查看对应任务,展开任务查看工具、回复和用量。上下文是当前会话采样,不是累计消耗;容量未知或历史采样会明确标注。群行图标按当前状态提供插话、停止与新会话操作。
|
|
113
149
|
|
|
@@ -123,7 +159,7 @@ Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队
|
|
|
123
159
|
|
|
124
160
|
## 停止与重启
|
|
125
161
|
|
|
126
|
-
按 Ctrl-C
|
|
162
|
+
按 Ctrl-C 并等待退出。配置里用的是 `env:` 引用时,先确认环境变量已设置,再执行:
|
|
127
163
|
|
|
128
164
|
```bash
|
|
129
165
|
"$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
|
package/install.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# The function is parsed in full before installation starts, including when piped.
|
|
3
3
|
sintbot_install() (
|
|
4
4
|
set -eu
|
|
5
|
-
version=1.4.
|
|
5
|
+
version=1.4.17
|
|
6
6
|
prefix="${SINTBOT_INSTALL_DIR:-$HOME/.local/share/sintbot}"
|
|
7
7
|
fail() { printf 'SintBot: %s\n' "$*" >&2; exit 1; }
|
|
8
8
|
case "${1:-}" in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sintbot",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Drive your Mac projects from Feishu groups with Codex or Claude Code, using a built-in agent core.",
|
|
6
6
|
"bin": {
|
|
@@ -51,8 +51,9 @@
|
|
|
51
51
|
"src/agent/codexapp.js",
|
|
52
52
|
"src/agent/codexcode.js",
|
|
53
53
|
"src/agent/codexstatus.js",
|
|
54
|
-
"src/agent/
|
|
54
|
+
"src/agent/envconflicts.js",
|
|
55
55
|
"src/agent/errtag.js",
|
|
56
|
+
"src/agent/modelwindows.js",
|
|
56
57
|
"src/agent/privatecore.js",
|
|
57
58
|
"src/agent/privatecore.wasm",
|
|
58
59
|
"src/agent/runner.js",
|
|
@@ -69,8 +70,10 @@
|
|
|
69
70
|
"src/c2upgradeswitch.js",
|
|
70
71
|
"src/cdp.js",
|
|
71
72
|
"src/config.js",
|
|
73
|
+
"src/connect.js",
|
|
72
74
|
"src/consolesay.js",
|
|
73
75
|
"src/consoletrials.js",
|
|
76
|
+
"src/core/brain.js",
|
|
74
77
|
"src/core/c2cardeffectbridge.js",
|
|
75
78
|
"src/core/c2cardterminalpath.js",
|
|
76
79
|
"src/core/c2durableingress.js",
|
|
@@ -104,6 +107,8 @@
|
|
|
104
107
|
"src/core/issues.js",
|
|
105
108
|
"src/core/ledger.js",
|
|
106
109
|
"src/core/mdtable.js",
|
|
110
|
+
"src/core/modelcmd.js",
|
|
111
|
+
"src/core/modelname.js",
|
|
107
112
|
"src/core/proxy.js",
|
|
108
113
|
"src/core/redact.js",
|
|
109
114
|
"src/core/store.js",
|
|
@@ -114,10 +119,12 @@
|
|
|
114
119
|
"src/design.js",
|
|
115
120
|
"src/feishucli.js",
|
|
116
121
|
"src/gitrepo.js",
|
|
122
|
+
"src/groupbrain.js",
|
|
117
123
|
"src/leftover.js",
|
|
118
|
-
"src/
|
|
124
|
+
"src/modelcatalog.js",
|
|
119
125
|
"src/platform/board.js",
|
|
120
126
|
"src/platform/feishu.js",
|
|
127
|
+
"src/platform/modelcard.js",
|
|
121
128
|
"src/platform/multi.js",
|
|
122
129
|
"src/platform/noticecard.js",
|
|
123
130
|
"src/platform/noticesend.js",
|
package/src/agent/claudecode.js
CHANGED
|
@@ -55,7 +55,7 @@ function durableToolResults(ev) {
|
|
|
55
55
|
// replaces provider.baseUrl for ANTHROPIC_BASE_URL — used to point `claude` at
|
|
56
56
|
// the local vision strip-proxy for text-only providers. The auth
|
|
57
57
|
// token is unchanged: the proxy forwards it verbatim to the real upstream.
|
|
58
|
-
function buildEnv(provider = {}, baseUrlOverride, extraEnv) {
|
|
58
|
+
function buildEnv(provider = {}, baseUrlOverride, extraEnv, effort = '') {
|
|
59
59
|
const env = { ...process.env }
|
|
60
60
|
|
|
61
61
|
// The spawn env is defined ENTIRELY by the provider:
|
|
@@ -83,6 +83,8 @@ function buildEnv(provider = {}, baseUrlOverride, extraEnv) {
|
|
|
83
83
|
delete env.CLAUDECODE
|
|
84
84
|
|
|
85
85
|
if (provider.env) Object.assign(env, provider.env)
|
|
86
|
+
// The group's own thinking depth wins over a card-level value.
|
|
87
|
+
if (effort) env.CLAUDE_CODE_EFFORT_LEVEL = effort
|
|
86
88
|
// Engine-injected non-provider context (e.g. SBOT_CHAT_ID for the feishu base skill).
|
|
87
89
|
// Applied last but namespaced (SBOT_*), so it never collides with provider ANTHROPIC_* env.
|
|
88
90
|
if (extraEnv) Object.assign(env, extraEnv)
|
|
@@ -137,6 +139,7 @@ function runTurn({
|
|
|
137
139
|
prompt,
|
|
138
140
|
provider = {},
|
|
139
141
|
model,
|
|
142
|
+
effort = '',
|
|
140
143
|
workdir,
|
|
141
144
|
sessionId,
|
|
142
145
|
permissionMode,
|
|
@@ -238,7 +241,7 @@ function runTurn({
|
|
|
238
241
|
const { file, spawnArgs, fallback } = resolveSpawn(bin, args)
|
|
239
242
|
if (fallback) log(`配置的 agent 路径 '${bin}' 不存在,本轮改用 ${fallback} —— 请把配置改回正确路径`) // 兜底只救急,配置仍是错的
|
|
240
243
|
const child = spawnFn(file, spawnArgs, {
|
|
241
|
-
env: buildEnv(provider, baseUrlOverride, extraEnv),
|
|
244
|
+
env: buildEnv(provider, baseUrlOverride, extraEnv, effort),
|
|
242
245
|
cwd: workdir || process.cwd(),
|
|
243
246
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
244
247
|
windowsHide: true,
|
|
@@ -17,15 +17,16 @@ const fs = require('node:fs')
|
|
|
17
17
|
const os = require('node:os')
|
|
18
18
|
const path = require('node:path')
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// Fallback candidates, used only when the live list from the CLI handshake
|
|
21
|
+
// has never been obtained. Names follow core/modelname.js display names.
|
|
22
|
+
// Ids carry no date suffix and use hyphens for versions (claude-opus-5-5).
|
|
23
23
|
const BUILTIN = [
|
|
24
24
|
{ id: '', name: '默认(跟随 Claude Code)', note: '推荐 · 不指定型号' },
|
|
25
|
-
{ id: 'claude-opus-5', name: '
|
|
26
|
-
{ id: 'claude-opus-
|
|
27
|
-
{ id: 'claude-sonnet-5', name: '
|
|
28
|
-
{ id: 'claude-haiku-
|
|
25
|
+
{ id: 'claude-opus-5-5', name: 'Opus 5.5', note: '最新 Opus · 复杂改码 / 长链路' },
|
|
26
|
+
{ id: 'claude-opus-5', name: 'Opus 5', note: '上一代 Opus' },
|
|
27
|
+
{ id: 'claude-sonnet-5-5', name: 'Sonnet 5.5', note: '速度与智能平衡' },
|
|
28
|
+
{ id: 'claude-haiku-5-5', name: 'Haiku 5.5', note: '最快最省 · 简单任务' },
|
|
29
|
+
{ id: 'claude-opus-4-8', name: 'Opus 4.8', note: '更早一代 Opus · 额度池独立' },
|
|
29
30
|
]
|
|
30
31
|
|
|
31
32
|
// CLI 登录后写在 ~/.claude.json 的账号额外可选模型(本机这台是 Fable 5)。
|
|
@@ -57,4 +58,74 @@ function listModels({ loggedIn, home } = {}) {
|
|
|
57
58
|
return { loggedIn: true, models: mergeModels(extra), source: extra.length ? 'account' : 'builtin' }
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
// Live list: Claude Code's own initialize handshake. Start
|
|
62
|
+
// `claude -p --input-format stream-json --output-format stream-json`, send one
|
|
63
|
+
// control_request { subtype: 'initialize' }; the response's models are the
|
|
64
|
+
// CLI's /model menu for this machine and account: official display names,
|
|
65
|
+
// descriptions and the depths each model supports. No question is asked and
|
|
66
|
+
// no turn starts, so no quota is used, and the login token is never read.
|
|
67
|
+
// Only for subscription cards: endpoint/model variables are removed first so a
|
|
68
|
+
// third-party endpoint exported in the service shell cannot redirect it.
|
|
69
|
+
// Unavailable (signed out, CLI too old, timeout) ⇒ null; callers then use the
|
|
70
|
+
// last saved list or the built-in candidates.
|
|
71
|
+
function parseInitModels(models) {
|
|
72
|
+
if (!Array.isArray(models)) return null
|
|
73
|
+
const out = [], aliasOf = {}
|
|
74
|
+
let recommended = ''
|
|
75
|
+
const seen = new Set()
|
|
76
|
+
for (const m of models) {
|
|
77
|
+
if (!m || typeof m.value !== 'string' || !m.value) continue
|
|
78
|
+
const id = typeof m.resolvedModel === 'string' && m.resolvedModel ? m.resolvedModel : m.value
|
|
79
|
+
if (m.value === 'default') { recommended = id; continue }
|
|
80
|
+
// An alias (opus → claude-opus-5-5) is stored as the concrete model, so a
|
|
81
|
+
// later CLI upgrade does not silently move the group to another generation.
|
|
82
|
+
if (m.value !== id) aliasOf[m.value] = id
|
|
83
|
+
if (seen.has(id)) continue
|
|
84
|
+
seen.add(id)
|
|
85
|
+
const efforts = Array.isArray(m.supportedEffortLevels) ? m.supportedEffortLevels.filter((e) => typeof e === 'string') : []
|
|
86
|
+
out.push({ id, name: String(m.displayName || '').trim() || id, note: String(m.description || '').slice(0, 60), efforts, effort: m.supportsEffort !== false })
|
|
87
|
+
}
|
|
88
|
+
if (!out.length) return null
|
|
89
|
+
return { models: out, aliasOf, recommended: recommended || out[0].id }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function probeModels(bin = 'claude', { timeoutMs = 20000, cwd = os.tmpdir(), spawnFn = null } = {}) {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const spawn = spawnFn || require('node:child_process').spawn
|
|
95
|
+
const { resolveSpawn } = require('./winexec')
|
|
96
|
+
const env = { ...process.env }
|
|
97
|
+
delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN; delete env.ANTHROPIC_MODEL; delete env.CLAUDECODE
|
|
98
|
+
let child, buf = '', settled = false, timer = null
|
|
99
|
+
const done = (v) => {
|
|
100
|
+
if (settled) return
|
|
101
|
+
settled = true
|
|
102
|
+
clearTimeout(timer)
|
|
103
|
+
try { child.stdin.end() } catch {}
|
|
104
|
+
try { child.kill() } catch {}
|
|
105
|
+
resolve(v)
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const { file, spawnArgs } = resolveSpawn(bin, ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'])
|
|
109
|
+
child = spawn(file, spawnArgs, { cwd, env, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })
|
|
110
|
+
} catch { return resolve(null) }
|
|
111
|
+
timer = setTimeout(() => done(null), timeoutMs)
|
|
112
|
+
if (timer.unref) timer.unref()
|
|
113
|
+
child.on('error', () => done(null))
|
|
114
|
+
child.on('close', () => done(null))
|
|
115
|
+
child.stdout.on('data', (d) => {
|
|
116
|
+
buf += d.toString('utf8')
|
|
117
|
+
let i
|
|
118
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
119
|
+
const line = buf.slice(0, i); buf = buf.slice(i + 1)
|
|
120
|
+
let o; try { o = JSON.parse(line) } catch { continue }
|
|
121
|
+
if (o && o.type === 'control_response') {
|
|
122
|
+
const r = o.response && (o.response.response || o.response)
|
|
123
|
+
return done(parseInitModels(r && r.models))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
try { child.stdin.write(JSON.stringify({ type: 'control_request', request_id: 'sintbot-models', request: { subtype: 'initialize' } }) + '\n') } catch { done(null) }
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { listModels, mergeModels, parseExtraModels, readExtraModels, BUILTIN, parseInitModels, probeModels }
|
package/src/agent/claudeusage.js
CHANGED
|
@@ -180,21 +180,206 @@ async function fetchRaw(token, { ttl = 60_000, now = Date.now, log = warn, curl
|
|
|
180
180
|
return data
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// ── 令牌过期兜底(2026-11-12,cc-switch takeaways B3)──────────────────────────────
|
|
184
|
+
// 纪律 2 不变:我们**不刷新**令牌。过期时让 Claude CLI 自己在自己的锁里刷新 —— 跑一次
|
|
185
|
+
// `claude -p /usage`(官方的非交互用量面板),它顺手把额度写进 `~/.claude.json` 的
|
|
186
|
+
// `cachedUsageUtilization`,我们只读那份缓存,交给核心 `usage_parse('claude','cached')` 判
|
|
187
|
+
//(当前 CLI 使用 fetchedAtMs + utilization.limits;旧 limits[] 也保留支持,超过 645 秒判旧)。
|
|
188
|
+
// 约束:
|
|
189
|
+
// · **不阻塞调用方**:需要刷新时在后台起跑(进程内一次一个),本次立刻回 null(或在跑期间回
|
|
190
|
+
// 上一次解析出的值);之后的调用再读结果。/hud、/api/claude/verify 都是秒级回复。
|
|
191
|
+
// · **只信本进程跑成功后的文件**:退出码 0 才记一次「刷新完成时刻」,读文件以它为准;文件
|
|
192
|
+
// 里没有 fetchedAt 时就用这个完成时刻 —— 别的进程(交互式 CLI)写的旧缓存不当新数。
|
|
193
|
+
// 跑失败 / 超时 ⇒ null。
|
|
194
|
+
// · 同账号(按凭据文件路径)10 分钟最多跑一次 · 45 秒后 SIGTERM、再 5 秒 SIGKILL,子进程
|
|
195
|
+
// 没 close 之前不会起下一个 · 旗子探测合成一次(短超时、有缓存),老 CLI 不认 ⇒ 跳过。
|
|
196
|
+
// · env 走 buildEnv({}) 再去掉 ANTHROPIC_API_KEY(只认 OAuth 登录态,别让 /usage 打到 API key
|
|
197
|
+
// 或第三方网关)· cwd 用临时目录(别让它读到哪个项目的 CLAUDE.md / .mcp.json)· 输出不看不记。
|
|
198
|
+
const FALLBACK_TIMEOUT_MS = 45_000
|
|
199
|
+
const FALLBACK_KILL_GRACE_MS = 5_000
|
|
200
|
+
const FALLBACK_INTERVAL_MS = 10 * 60_000
|
|
201
|
+
const FALLBACK_FLAG_PROBE_MS = 2_000
|
|
202
|
+
const FALLBACK_ARGS = Object.freeze(['-p', '/usage', '--output-format', 'json', '--tools', '', '--strict-mcp-config', '--setting-sources', '', '--no-session-persistence'])
|
|
203
|
+
const FALLBACK_FLAGS = Object.freeze(['--tools', '--strict-mcp-config', '--setting-sources', '--no-session-persistence'])
|
|
204
|
+
const USAGE_HELP_INFLIGHT = new Map()
|
|
205
|
+
|
|
206
|
+
// Some native CLI builds exit before a long help page drains through a pipe.
|
|
207
|
+
// A private regular file receives the complete page. The four flag checks share
|
|
208
|
+
// one bounded process; this temporary file contains help text only.
|
|
209
|
+
function supportsUsageFlag(bin, flag) {
|
|
210
|
+
let probe = USAGE_HELP_INFLIGHT.get(bin)
|
|
211
|
+
if (!probe) {
|
|
212
|
+
probe = (async () => {
|
|
213
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sintbot-usage-help-'))
|
|
214
|
+
const file = path.join(dir, 'help.txt')
|
|
215
|
+
let fd
|
|
216
|
+
try {
|
|
217
|
+
fd = fs.openSync(file, 'wx', 0o600)
|
|
218
|
+
const ok = await new Promise(resolve => {
|
|
219
|
+
let child, timer, force, settled = false
|
|
220
|
+
const finish = value => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(force); resolve(value) }
|
|
221
|
+
try {
|
|
222
|
+
const command = require('./winexec').resolveSpawn(bin, ['--help'])
|
|
223
|
+
child = require('node:child_process').spawn(command.file, command.spawnArgs,
|
|
224
|
+
{ cwd: dir, stdio: ['ignore', fd, 'ignore'], windowsHide: true })
|
|
225
|
+
} catch { return finish(false) }
|
|
226
|
+
child.once('error', () => finish(false))
|
|
227
|
+
child.once('close', code => finish(code === 0))
|
|
228
|
+
timer = setTimeout(() => {
|
|
229
|
+
try { child.kill('SIGTERM') } catch {}
|
|
230
|
+
force = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 1000)
|
|
231
|
+
force.unref?.()
|
|
232
|
+
}, 5000)
|
|
233
|
+
timer.unref?.()
|
|
234
|
+
})
|
|
235
|
+
if (!ok || fs.statSync(file).size > 1024 * 1024) return null
|
|
236
|
+
return await fs.promises.readFile(file, 'utf8')
|
|
237
|
+
} finally {
|
|
238
|
+
if (fd !== undefined) fs.closeSync(fd)
|
|
239
|
+
await fs.promises.rm(dir, { recursive: true, force: true })
|
|
240
|
+
}
|
|
241
|
+
})().catch(() => null)
|
|
242
|
+
USAGE_HELP_INFLIGHT.set(bin, probe)
|
|
243
|
+
probe.finally(() => { if (USAGE_HELP_INFLIGHT.get(bin) === probe) USAGE_HELP_INFLIGHT.delete(bin) })
|
|
244
|
+
}
|
|
245
|
+
return probe.then(text => text === null ? null : text.includes(flag))
|
|
246
|
+
}
|
|
247
|
+
const FALLBACK = {
|
|
248
|
+
inflight: null, // 正在跑的那次(Promise<boolean>);子进程 close 前不清
|
|
249
|
+
lastAt: new Map(), // 凭据文件路径 -> 上次起跑时刻(节流)
|
|
250
|
+
doneAt: new Map(), // 凭据文件路径 -> 本进程最近一次退出码 0 的完成时刻(没有 = 不信文件)
|
|
251
|
+
lastGood: new Map(), // 凭据文件路径 -> 上一次解析出的一行(只在刷新进行中回它)
|
|
252
|
+
flags: null, // { bin, until, p } 合成一次的旗子探测
|
|
253
|
+
file: null, // { path, mtimeMs, body } 按 mtime 缓存的 ~/.claude.json 解析
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// `~/.claude.json` 原文 → 交给核心的 body(cachedUsageUtilization 原样)。不是对象 ⇒ null。
|
|
257
|
+
// 时效优先用对象自带的 fetchedAtMs/fetchedAt;两者都没有才用本进程刷新完成时刻。
|
|
258
|
+
function readCachedUtilization(raw) {
|
|
259
|
+
let j = null
|
|
260
|
+
try { j = JSON.parse(String(raw || '')) } catch { return null }
|
|
261
|
+
const u = j && j.cachedUsageUtilization
|
|
262
|
+
return u && typeof u === 'object' && !Array.isArray(u) ? u : null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 按 mtime 缓存的异步读:同一份文件只解析一次;stat/读失败 ⇒ null。
|
|
266
|
+
async function readClaudeJson(file, { readFile, stat }) {
|
|
267
|
+
let st = null
|
|
268
|
+
try { st = await stat(file) } catch { return null }
|
|
269
|
+
const mtimeMs = st && typeof st.mtimeMs === 'number' ? st.mtimeMs : null
|
|
270
|
+
const c = FALLBACK.file
|
|
271
|
+
if (c && c.path === file && mtimeMs !== null && c.mtimeMs === mtimeMs) return c.body
|
|
272
|
+
let raw = null
|
|
273
|
+
try { raw = await readFile(file, 'utf8') } catch { return null }
|
|
274
|
+
const body = readCachedUtilization(raw)
|
|
275
|
+
FALLBACK.file = { path: file, mtimeMs, body }
|
|
276
|
+
return body
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 核心的 windows[] → parseLimits 的形状,formatClaudeUsage 原样可用。
|
|
280
|
+
const windowsToLimits = (windows) => (Array.isArray(windows) ? windows : [])
|
|
281
|
+
.map((w) => ({ kind: w.kind, percent: w.percentUsed, resetsAt: w.resetsAt ?? null, scopeName: w.scopeName || '' }))
|
|
282
|
+
|
|
283
|
+
// 四个旗子合成**一次**探测:默认共享同一份完整 --help 输出,测试可注入探测函数。
|
|
284
|
+
// 短超时兜底;结论 true/false 缓存 10 分钟、不知道(null)缓存 60 秒。
|
|
285
|
+
function probeFlags(bin, supportsFlag, now, timeoutMs = FALLBACK_FLAG_PROBE_MS) {
|
|
286
|
+
const hit = FALLBACK.flags
|
|
287
|
+
if (hit && hit.bin === bin && now() < hit.until) return hit.p
|
|
288
|
+
const all = Promise.all(FALLBACK_FLAGS.map((f) => Promise.resolve().then(() => supportsFlag(bin, f))))
|
|
289
|
+
.then((a) => (a.every((v) => v === true) ? true : a.some((v) => v === false) ? false : null))
|
|
290
|
+
const late = new Promise((resolve) => { const t = setTimeout(() => resolve(null), timeoutMs); t.unref?.() })
|
|
291
|
+
const p = Promise.race([all, late]).catch(() => null)
|
|
292
|
+
FALLBACK.flags = { bin, until: now() + 60_000, p }
|
|
293
|
+
p.then((v) => { if (FALLBACK.flags && FALLBACK.flags.p === p) FALLBACK.flags.until = now() + (v === null ? 60_000 : 10 * 60_000) })
|
|
294
|
+
return p
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 跑一次 `claude -p /usage`;只在子进程 close(或起不来)后才落定 ⇒ 卡住的子进程不会与下一次重叠。
|
|
298
|
+
// 超时:SIGTERM,再过 killGraceMs 还没 close 就 SIGKILL;两个计时器都 unref。
|
|
299
|
+
function runUsageRefresh({ bin, spawn, buildEnv, tmpdir, log, timeoutMs, killGraceMs = FALLBACK_KILL_GRACE_MS }) {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
let child, settled = false, timedOut = false
|
|
302
|
+
const timers = []
|
|
303
|
+
const done = (ok, why) => { if (settled) return; settled = true; for (const t of timers) clearTimeout(t); if (why) log(why); resolve(ok) }
|
|
304
|
+
const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); timers.push(t) }
|
|
305
|
+
try {
|
|
306
|
+
const env = buildEnv({})
|
|
307
|
+
delete env.ANTHROPIC_API_KEY // 只认 OAuth 登录态
|
|
308
|
+
const { file, spawnArgs } = require('./winexec').resolveSpawn(bin, [...FALLBACK_ARGS])
|
|
309
|
+
child = spawn(file, spawnArgs, { cwd: tmpdir(), env, stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true })
|
|
310
|
+
} catch (e) { return done(false, '额度兜底:claude 起不来(' + ((e && e.message) || '未知错误') + ')') }
|
|
311
|
+
later(timeoutMs, () => {
|
|
312
|
+
timedOut = true
|
|
313
|
+
try { child.kill('SIGTERM') } catch {}
|
|
314
|
+
later(killGraceMs, () => { try { child.kill('SIGKILL') } catch {} })
|
|
315
|
+
})
|
|
316
|
+
child.on('error', (e) => done(false, '额度兜底:claude 起不来(' + ((e && e.code) || (e && e.message) || '未知错误') + ')'))
|
|
317
|
+
child.on('close', (code) => {
|
|
318
|
+
if (timedOut) return done(false, `额度兜底:claude -p /usage 超时(${timeoutMs}ms),已终止`)
|
|
319
|
+
done(code === 0, code === 0 ? null : `额度兜底:claude -p /usage 退出码 ${code}`)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 需要就在后台起一次刷新(不 await):进程内一次一个,同账号 10 分钟一次。
|
|
325
|
+
function startRefreshIfDue(account, { bin, now, log, spawn, supportsFlag, buildEnv, tmpdir, timeoutMs, killGraceMs, intervalMs, flagProbeMs }) {
|
|
326
|
+
if (FALLBACK.inflight) return
|
|
327
|
+
const last = FALLBACK.lastAt.get(account) || 0
|
|
328
|
+
if (last && now() - last < intervalMs) return
|
|
329
|
+
FALLBACK.lastAt.set(account, now())
|
|
330
|
+
FALLBACK.inflight = (async () => {
|
|
331
|
+
const flags = await probeFlags(bin, supportsFlag, now, flagProbeMs)
|
|
332
|
+
if (flags !== true) { log('额度兜底:本机 claude 不认所需旗子,跳过'); return false }
|
|
333
|
+
const ok = await runUsageRefresh({ bin, spawn, buildEnv, tmpdir, log, timeoutMs, killGraceMs })
|
|
334
|
+
if (ok) FALLBACK.doneAt.set(account, now())
|
|
335
|
+
else FALLBACK.doneAt.delete(account)
|
|
336
|
+
return ok
|
|
337
|
+
})().catch(() => { FALLBACK.doneAt.delete(account); return false })
|
|
338
|
+
.finally(() => { FALLBACK.inflight = null })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 过期/缺凭据时的那条腿:(必要时)后台让 CLI 刷新 → 读缓存(只信本进程刷成功后的)→ 核心判 →
|
|
342
|
+
// 一行人话;任何一步不行 ⇒ null。调用方不会被挡住:这里只 await 一次文件读。
|
|
343
|
+
async function cachedUsageFallback({ home = os.homedir(), bin = 'claude', now = Date.now, log = warn,
|
|
344
|
+
spawn = require('node:child_process').spawn, readFile = fs.promises.readFile, stat = fs.promises.stat, tmpdir = os.tmpdir,
|
|
345
|
+
supportsFlag = null, buildEnv = null, timeoutMs = FALLBACK_TIMEOUT_MS, killGraceMs = FALLBACK_KILL_GRACE_MS,
|
|
346
|
+
intervalMs = FALLBACK_INTERVAL_MS, flagProbeMs = FALLBACK_FLAG_PROBE_MS } = {}) {
|
|
347
|
+
const account = path.join(home, '.claude', '.credentials.json')
|
|
348
|
+
startRefreshIfDue(account, { bin, now, log, spawn, tmpdir, timeoutMs, killGraceMs, intervalMs, flagProbeMs,
|
|
349
|
+
supportsFlag: supportsFlag || supportsUsageFlag, buildEnv: buildEnv || require('./claudecode').buildEnv })
|
|
350
|
+
const doneAt = FALLBACK.doneAt.get(account)
|
|
351
|
+
if (!doneAt) return FALLBACK.inflight ? (FALLBACK.lastGood.get(account) || null) : null
|
|
352
|
+
const raw = await readClaudeJson(path.join(home, '.claude.json'), { readFile, stat })
|
|
353
|
+
if (!raw) return null
|
|
354
|
+
const body = raw.fetchedAtMs != null || raw.fetchedAt != null ? raw : { ...raw, fetchedAt: doneAt }
|
|
355
|
+
const parsed = require('./privatecore').usageParse('claude', 'cached', null, body, now())
|
|
356
|
+
if (!parsed.ok) return FALLBACK.inflight ? (FALLBACK.lastGood.get(account) || null) : null
|
|
357
|
+
const line = formatClaudeUsage(windowsToLimits(parsed.windows), now())
|
|
358
|
+
if (line) FALLBACK.lastGood.set(account, line)
|
|
359
|
+
return line
|
|
360
|
+
}
|
|
361
|
+
function resetFallback() { FALLBACK.inflight = null; FALLBACK.lastAt.clear(); FALLBACK.doneAt.clear(); FALLBACK.lastGood.clear(); FALLBACK.flags = null; FALLBACK.file = null }
|
|
362
|
+
// 单测用:等后台那次刷新落定(没有就立刻回)。
|
|
363
|
+
const settleFallback = () => (FALLBACK.inflight ? FALLBACK.inflight.then(() => undefined) : Promise.resolve())
|
|
364
|
+
|
|
183
365
|
// 拉一次 → 一行人话;拿不到 null。走 fetchRaw ⇒ 与告警共享那份缓存。
|
|
184
366
|
// 缓存口径与 fetchRaw 一致:成功 60s、失败 10s(两层都压死一分钟就白搭下层的重试了)。
|
|
367
|
+
// 凭据缺失/过期 ⇒ 走 cachedUsageFallback(见上),不再直接 null。
|
|
185
368
|
const CACHE = { at: 0, val: null }
|
|
186
369
|
const TTL = 60_000
|
|
187
|
-
async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToken, ...io } = {}) {
|
|
370
|
+
async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToken, fallback = cachedUsageFallback, home, bin, ...io } = {}) {
|
|
188
371
|
if (CACHE.at && now() - CACHE.at < (CACHE.val ? ttl : FAIL_TTL_MS)) return CACHE.val
|
|
189
372
|
let val = null
|
|
190
373
|
const token = readToken()
|
|
191
374
|
if (token) {
|
|
192
|
-
const data = await fetchRaw(token, { ttl, now,
|
|
375
|
+
const data = await fetchRaw(token, { ttl, now, log: io.log, curl: io.curl, sleep: io.sleep })
|
|
193
376
|
val = formatClaudeUsage(parseLimits(data), now())
|
|
377
|
+
} else {
|
|
378
|
+
try { val = await fallback({ now, ...(home ? { home } : {}), ...(bin ? { bin } : {}), ...io }) } catch { val = null }
|
|
194
379
|
}
|
|
195
380
|
CACHE.at = now(); CACHE.val = val
|
|
196
381
|
return val
|
|
197
382
|
}
|
|
198
383
|
function clearCache() { CACHE.at = 0; CACHE.val = null; RAW.at = 0; RAW.data = null }
|
|
199
384
|
|
|
200
|
-
module.exports = { usageLine, fetchRaw, clearCache, parseCredentials, parseLimits, formatClaudeUsage, readAccessToken, fetchCurl, fetchNative, readCurlResult, fmtDurDecimal, QUOTA_SEP, TIMEOUT_MS, FAIL_TTL_MS, RETRY_WAIT_MS }
|
|
385
|
+
module.exports = { usageLine, fetchRaw, clearCache, parseCredentials, parseLimits, formatClaudeUsage, readAccessToken, fetchCurl, fetchNative, readCurlResult, fmtDurDecimal, cachedUsageFallback, readCachedUtilization, readClaudeJson, supportsUsageFlag, resetFallback, settleFallback, QUOTA_SEP, TIMEOUT_MS, FAIL_TTL_MS, RETRY_WAIT_MS, FALLBACK_ARGS, FALLBACK_FLAGS, FALLBACK_TIMEOUT_MS, FALLBACK_KILL_GRACE_MS, FALLBACK_INTERVAL_MS, FALLBACK_FLAG_PROBE_MS }
|
package/src/agent/codexapp.js
CHANGED
|
@@ -64,7 +64,7 @@ function itemEvent(method, item) {
|
|
|
64
64
|
// App Server 的请求和通知共用一条 stdout 流;单轮独立进程使关闭成为续接边界。
|
|
65
65
|
function runTurn(opts = {}) {
|
|
66
66
|
const {
|
|
67
|
-
bin = 'codex', prompt, provider = {}, model, workdir, sessionId,
|
|
67
|
+
bin = 'codex', prompt, provider = {}, model, effort: wantEffort, workdir, sessionId,
|
|
68
68
|
permissionMode, autocompactTokens, appendSystemPrompt, codexApp, codexProjectDocFallback, extraEnv,
|
|
69
69
|
onEvent, onDurableEvent, onSteerReady, onSteerLanded, onTurnAccepted, onSpawnFailed, signal, log = () => {},
|
|
70
70
|
softStopGraceMs = 10000, spawnFn = spawn,
|
|
@@ -102,7 +102,8 @@ function runTurn(opts = {}) {
|
|
|
102
102
|
let childCloseObserved = false
|
|
103
103
|
let settled = false
|
|
104
104
|
let completed = false
|
|
105
|
-
|
|
105
|
+
const configuredEffort = wantEffort || provider.reasoningEffort || ''
|
|
106
|
+
let effort = configuredEffort
|
|
106
107
|
let retriedEffort = false
|
|
107
108
|
let pendingTurnStart = false
|
|
108
109
|
let acceptedTurnId = null
|
|
@@ -350,7 +351,7 @@ function runTurn(opts = {}) {
|
|
|
350
351
|
else if (action === 3) { aborted = true; lastResult = null }
|
|
351
352
|
else lastResult = { type: 'result', subtype: 'success', text: lastAgentText, usage: totalUsage, usageCumulative: !!totalUsage,
|
|
352
353
|
contextTokens, contextWindowTokens, contextCompacted }
|
|
353
|
-
if (lastResult && retriedEffort) lastResult.effortFellBack =
|
|
354
|
+
if (lastResult && retriedEffort) lastResult.effortFellBack = configuredEffort
|
|
354
355
|
if (lastResult) emit(lastResult)
|
|
355
356
|
// 让服务端自行落盘退出;收到完成通知就强杀会破坏下一轮 resume。
|
|
356
357
|
try { child.stdin.end() } catch {}
|
package/src/agent/codexcode.js
CHANGED
|
@@ -108,7 +108,7 @@ function isEffortRejection(result) {
|
|
|
108
108
|
* @returns {Promise<import('../core/interfaces').RunTurnResult>}
|
|
109
109
|
*/
|
|
110
110
|
function runTurn(opts = {}) {
|
|
111
|
-
const wantEffort = (opts.provider && opts.provider.reasoningEffort) || ''
|
|
111
|
+
const wantEffort = opts.effort || (opts.provider && opts.provider.reasoningEffort) || ''
|
|
112
112
|
if (!wantEffort) return runOnce(opts, '')
|
|
113
113
|
// Attempt 1 with the configured effort. Hold back only ERROR result events so a
|
|
114
114
|
// recoverable effort-rejection can't finalize the turn early (the engine finalizes on
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Environment conflicts (2026-11-12, cc-switch takeaways B5): things outside
|
|
4
|
+
// SintBot's configuration that can silently reroute the Claude / Codex CLI —
|
|
5
|
+
// daemon env (`ANTHROPIC_*`, `CLAUDE_CODE_*`, `AWS_*`, `VERTEX_*`, `OPENAI_*`),
|
|
6
|
+
// `~/.claude/settings.json` (`env`, top-level `model` …) and `~/.codex/config.toml`
|
|
7
|
+
// (`model_provider`, `[model_providers.*]`). The host only gathers the inputs; the
|
|
8
|
+
// rule set lives in the binary core (`env_conflicts`, ABI 36) and it returns key
|
|
9
|
+
// names only — values never leave the process. Warn-only: `buildEnv` keeps clearing
|
|
10
|
+
// exactly what it cleared before.
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs')
|
|
13
|
+
const os = require('node:os')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const { envConflicts } = require('./privatecore')
|
|
16
|
+
|
|
17
|
+
// JSON file → parsed object; missing / invalid / not an object ⇒ null.
|
|
18
|
+
function readClaudeSettings(home, readFile) {
|
|
19
|
+
try {
|
|
20
|
+
const j = JSON.parse(String(readFile(path.join(home, '.claude', 'settings.json'), 'utf8')))
|
|
21
|
+
return j && typeof j === 'object' && !Array.isArray(j) ? j : null
|
|
22
|
+
} catch { return null }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// TOML text as-is (the core matches lines; no TOML parser); missing ⇒ null.
|
|
26
|
+
function readCodexConfig(home, readFile) {
|
|
27
|
+
try { return String(readFile(path.join(home, '.codex', 'config.toml'), 'utf8')) } catch { return null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only string-valued entries travel to the core (process.env can hold undefined).
|
|
31
|
+
const snapshotEnv = (env) => Object.fromEntries(Object.entries(env || {}).filter(([, v]) => typeof v === 'string'))
|
|
32
|
+
|
|
33
|
+
// → [{ where: 'env'|'claude-settings'|'codex-config', key, reason }]. `provider` is a
|
|
34
|
+
// resolved provider ({ baseUrl, apiKey }) or an explicit { baseUrl, hasApiKey }.
|
|
35
|
+
function collectEnvConflicts({ runner = 'claude', provider = {}, env = process.env, home = os.homedir(), readFile = fs.readFileSync } = {}) {
|
|
36
|
+
const p = provider || {}
|
|
37
|
+
const input = {
|
|
38
|
+
runner: runner === 'codex' ? 'codex' : 'claude',
|
|
39
|
+
provider: { baseUrl: p.baseUrl || null, hasApiKey: typeof p.hasApiKey === 'boolean' ? p.hasApiKey : !!p.apiKey },
|
|
40
|
+
env: snapshotEnv(env),
|
|
41
|
+
claudeSettings: readClaudeSettings(home, readFile),
|
|
42
|
+
codexConfig: readCodexConfig(home, readFile),
|
|
43
|
+
}
|
|
44
|
+
return envConflicts(input).warnings.map((w) => ({ where: w.where, key: w.key, reason: w.reason }))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Preflight / log line: `<key> — <reason>`; never a value.
|
|
48
|
+
const describeWarning = (w) => `${w.key} — ${w.reason}`
|
|
49
|
+
|
|
50
|
+
// `sintbot start --preflight`: one line per key for each provider an active route
|
|
51
|
+
// uses. The snapshot is this process's environment — a launchd job inherits a
|
|
52
|
+
// different one, so the block is prefixed with that caveat. Never fatal.
|
|
53
|
+
const PREFLIGHT_SCOPE_LINE = 'preflight · 环境冲突检查基于当前终端环境(launchd 任务的环境可能不同)\n'
|
|
54
|
+
function reportEnvConflicts({ cfg, routes } = {}, write = (message) => process.stderr.write(message), { collect = collectEnvConflicts } = {}) {
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
let prefixed = false
|
|
57
|
+
for (const route of routes || []) {
|
|
58
|
+
if (!route || route.paused) continue
|
|
59
|
+
const name = route.group?.provider || cfg?.defaultProvider || ''
|
|
60
|
+
if (!name || seen.has(name)) continue
|
|
61
|
+
seen.add(name)
|
|
62
|
+
if (!prefixed) { prefixed = true; write(PREFLIGHT_SCOPE_LINE) }
|
|
63
|
+
try {
|
|
64
|
+
for (const warning of collect({ runner: route.provider?.runner, provider: route.provider })) {
|
|
65
|
+
write(`preflight · ${name}: ${describeWarning(warning)}\n`)
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
write(`preflight · ${name}: environment check skipped (${error?.message || String(error)})\n`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [...seen]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { collectEnvConflicts, describeWarning, reportEnvConflicts, PREFLIGHT_SCOPE_LINE }
|