sintbot 1.4.16 → 1.4.18
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 +62 -0
- package/CHANGELOG.json +62 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/sintbot-v2.js +82 -4
- package/docs/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/install.md +62 -10
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/zh-CN/install.md +62 -10
- package/install.sh +1 -1
- package/package.json +4 -1
- package/src/agent/claudecode.js +41 -3
- package/src/agent/claudeusage.js +239 -5
- package/src/agent/envconflicts.js +74 -0
- package/src/agent/privatecore.js +86 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +9 -1
- package/src/c2configcontrol.js +7 -0
- package/src/c2console.js +3 -0
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +1 -1
- package/src/connect.js +566 -0
- package/src/core/c2runnerinput.js +47 -1
- package/src/core/v2engine.js +211 -11
- package/src/platform/feishu.js +22 -7
- package/src/setupserver.js +447 -0
- package/src/ui/console.html +161 -128
- package/src/ui/serverbase.js +19 -10
- package/src/usage.js +131 -38
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.18
|
|
2
2
|
|
|
3
3
|
[English](../install.md) · 简体中文
|
|
4
4
|
|
|
@@ -10,13 +10,61 @@
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
安装器会自动检查环境,把 `sintbot@1.4.
|
|
13
|
+
安装器会自动检查环境,把 `sintbot@1.4.18` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.18` 就可以继续。不需要 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" start --ui
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
还没有配置文件时,这条命令不会报错,而是在本机打开接入页(`http://127.0.0.1:4600`,只有这台 Mac 能访问)。页面上填项目名(默认 `sintbot`,会在 `~/Projects` 下新建并放一份 `README.md`)或选一个已有目录,两个执行器都装了就选一个,不是组织管理员就勾上精简接入,然后点「开始扫码」。用手机飞书扫页面上的二维码并确认。接下来它依次:取得应用凭证 → 自检 → 放开通讯录范围与可用范围并发布版本 → 创建群并把你拉进去 → 新建项目目录 → 写入 `~/.sbot/config.json` → 初始化消息日志,每一步的结果都显示在页面上。扫码一旦确认,应用就已在你的组织里创建;之后任一检查失败都会在建目录、写配置之前停下,页面给出「重试」(复用已创建的应用,不会再建一个);自动建群失败时页面会让你手动建群、拉入机器人,粘贴群 ID 后点「继续」。
|
|
26
|
+
|
|
27
|
+
显示二维码前会读取所选执行器的实时型号清单,在页面显示推荐型号和思考深度,并明确保存到新群配置。清单暂时取不到时会在创建应用之前停下;检查执行器登录和网络后重试。接入后可在控制台或通过 `/model` 调整,已有群的选择不会被覆盖。
|
|
28
|
+
|
|
29
|
+
接入完成后服务直接接着启动,页面几秒后切到控制台。保持终端开启。在新群里发“读取项目 README 的第一行,只回复这一行”,并和项目目录里的 `README.md` 对照。
|
|
30
|
+
|
|
31
|
+
### 终端方式
|
|
32
|
+
|
|
33
|
+
不想开浏览器也可以在终端做同一件事,参数对应页面上的选项:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
"$HOME/.local/share/sintbot/bin/sintbot" connect
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
在交互终端里它会先问项目名(直接回车即用默认的 `sintbot`),然后打印扫码链接,并打开一个本机页面显示二维码;步骤与上面相同,结果打印在终端,失败时给出带 `--app-id` 的重跑命令。
|
|
40
|
+
|
|
41
|
+
- 凭证明文写在 `~/.sbot/config.json`(文件 0600、目录 0700)。要改回环境变量,把 `appId` / `appSecret` 改成 `"env:FEISHU_APP_ID"` / `"env:FEISHU_APP_SECRET"`,并在每次启动前导出这两个变量。
|
|
42
|
+
- `--basic`:公司组织里的非管理员使用。不申请 `application:application:patch`,跳过放开范围与发布;之后请管理员在开放平台补这两步。
|
|
43
|
+
- `--app-id cli_…`:复用已有应用(给它追加权限,不新建),用于中途停下后重跑或审核通过后继续。只在没有配置文件、或配置文件属于这个应用且还没绑定任何群时允许;已绑定群的配置不会被替换。
|
|
44
|
+
- `--chat oc_…`:不自动建群,直接绑定你手动建好并拉入机器人的群(建群失败时的提示里会给出这条命令)。
|
|
45
|
+
- `--workdir /绝对路径`:绑定一个已存在的目录,不新建;`--name` 换默认目录名;`--provider codex|claude` 指定执行器(本机只装了一个时自动选);`--no-open` 只打印链接、不打开页面。
|
|
46
|
+
- 已有飞书配置或服务正在运行时,`connect` 会拒绝执行。扫码会申请模型卡所需的卡片回调。`/model` 点选卡没有反应时,按第 4 节检查回调订阅。
|
|
47
|
+
|
|
48
|
+
## 3. 日常启动
|
|
49
|
+
|
|
50
|
+
接入完成后以后每次启动都用同一条命令;配置在默认位置时和接入时那条一样:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
"$HOME/.local/share/sintbot/bin/sintbot" start --ui
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
它会预检配置和执行器、连接飞书并打开本机控制台。保持终端开启。用了 `--config` 或 `--data-dir` 的,启动时带同样的参数。预检通过不代表飞书认证或消息送达已经验证。
|
|
57
|
+
|
|
58
|
+
## 4. 手动接入(备选)
|
|
59
|
+
|
|
60
|
+
扫码失败、管理员不放行,或者想自己管理应用时,走下面的手动路径;内容与之前的安装步骤相同。
|
|
61
|
+
|
|
62
|
+
<details>
|
|
63
|
+
<summary>展开手动步骤</summary>
|
|
64
|
+
|
|
65
|
+
#### 4.1 创建应用
|
|
66
|
+
|
|
67
|
+
在 [飞书开放平台](https://open.feishu.cn/) 创建企业自建应用,启用机器人,在「权限管理」开通与扫码接入相同的权限:`im:message.group_msg`、`im:message:readonly`、`im:message:send_as_bot`、`im:resource`、`im:message.reactions:write_only`、`im:chat`、`cardkit:card:write`、`contact:contact.base:readonly`、`contact:department.base:readonly`、`contact:user.base:readonly`,并在「数据权限」把通讯录授权范围设为全部成员;事件订阅选择长连接并订阅消息接收;回调订阅同样选择长连接接收回调并订阅卡片回传交互(群里点选 `/model` 卡片要用)。发布应用后,把机器人加入要使用的群,取得每个群的 `oc_…` 群 ID。
|
|
20
68
|
|
|
21
69
|
在 macOS 默认终端中输入应用凭据(密钥输入时不显示):
|
|
22
70
|
|
|
@@ -29,7 +77,7 @@ export FEISHU_APP_ID FEISHU_APP_SECRET
|
|
|
29
77
|
|
|
30
78
|
不要把密钥发进群或提交到仓库。后续步骤都在这个终端里做;新开终端时需要重新设置这两个变量。
|
|
31
79
|
|
|
32
|
-
|
|
80
|
+
#### 4.2 手写配置
|
|
33
81
|
|
|
34
82
|
用 `mkdir -p ~/.sbot && chmod 700 ~/.sbot` 创建私有配置目录,再创建 `~/.sbot/config.json`。把群 ID 和项目绝对路径换成你自己的,项目目录必须已经存在。下面的示例绑定两个群:一个用 Codex,一个用 Claude Code。只有一个群时删掉另一项即可。
|
|
35
83
|
|
|
@@ -69,9 +117,9 @@ export FEISHU_APP_ID FEISHU_APP_SECRET
|
|
|
69
117
|
- 要固定 Claude 型号,可在 `providers.claude` 里加 `"model"`;不写就用这台机器上 Claude Code 的默认模型。
|
|
70
118
|
- 同时运行的任务默认最多 2 个,可在顶层加 `"agent": { "maxConcurrent": 4 }` 调整(1–32)。
|
|
71
119
|
|
|
72
|
-
|
|
120
|
+
#### 4.3 初始化并启动
|
|
73
121
|
|
|
74
|
-
|
|
122
|
+
在刚才的终端里一次复制执行(手动路径需要自己执行 `init`):
|
|
75
123
|
|
|
76
124
|
```bash
|
|
77
125
|
SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
@@ -84,6 +132,8 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
84
132
|
|
|
85
133
|
在群里发“读取项目 README 的第一行,只回复这一行”,并和本地文件对照。预检通过不代表飞书认证或消息送达已经验证。
|
|
86
134
|
|
|
135
|
+
</details>
|
|
136
|
+
|
|
87
137
|
## 5. 在群里使用
|
|
88
138
|
|
|
89
139
|
直接发文字就是给它派任务。回复以卡片呈现:模型开始输出时出现;25 秒还没有输出也会先出现。运行中卡片显示已跑多久、正在做什么和已完成的步骤,结束后这些步骤收进「过程」折叠区。
|
|
@@ -95,16 +145,18 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
95
145
|
| `/stop` | 停止当前任务,连同排队的一起取消;之前的对话保留 |
|
|
96
146
|
| `/new` | 忘掉之前的对话,开始新的一段(只在本群空闲时生效) |
|
|
97
147
|
| `/model` | 打开点选卡,选后端、型号和思考深度后点「确认更新」;也可直接发 `/model high`、`/model 型号`、`/model 后端`。换型号或调深度接着当前对话,换后端开始新的一段对话;只在本群空闲时生效 |
|
|
98
|
-
| `/hud` |
|
|
148
|
+
| `/hud` | 当前模型的额度与本段对话的用量;Claude 群还显示空闲整理线和上次整理时间 |
|
|
99
149
|
| `/issue 一句话` | 记下问题反馈,在控制台概览里查看和标记处理 |
|
|
100
150
|
| `/help` | 查看可用命令,以及本群的项目和模型 |
|
|
101
151
|
|
|
102
|
-
命令单独发一条;除 `/issue` 和 `/model`
|
|
152
|
+
命令单独发一条;除 `/issue` 和 `/model` 外,后面不要跟别的字。如果飞书应用还订阅了消息撤回事件(扫码接入会自动订阅,手动接入建议一并订阅),撤回自己发的消息即取消那件事:还在排队的直接丢弃;当前任务只由这一条触发时会停下;两种执行器中,撤回已交给执行器的补话均会取消整轮,包括已受理但尚未读取的补话。
|
|
103
153
|
|
|
104
154
|
Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队,显式 `steer` 优先于旧 Codex 开关。Claude Code 未提供所需输入能力时保留排队。长工具调用可能延迟读取;确认读取后回复卡才跟随补话,续轮答案合并为一条最终回复。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
|
|
105
155
|
|
|
106
156
|
回复卡移到已确认读取的补话下方时,会立即保留已有文字,后续输出继续更新;移动卡片不会重新启动任务。
|
|
107
157
|
|
|
158
|
+
走 Claude Code 的群会在一轮结束、群里没人等的时候整理会话:上下文到了模型卡「压缩」阈值的一半,就对同一会话跑一次 `/compact`,群里不发消息,新消息到来会先停掉整理再开始这一轮。整理占一个并发名额,只在还有空余名额时进行;阈值为 0 时不整理。Claude 订阅额度改从每轮自带的额度事件读取,额度接口最多半小时查一次。
|
|
159
|
+
|
|
108
160
|
## 6. 控制台
|
|
109
161
|
|
|
110
162
|
型号与思考深度按群选:在「群 · 项目」卡的「模型」行选后端和型号,下一行选深度,清单取自本机 Codex / Claude Code 的实时数据;订阅登录的模型卡上不再设型号和深度,第三方端点的型号仍在模型卡上设置。升级后,模型卡上原有的型号和深度会自动写进各群,生效值不变、会话不重置,改前备份配置。模型卡区分设置值与运行回报,容量依据可在「容量与运行信息」中查看。Codex 上下文窗口和各模型自动压缩都只提供「执行器默认 / 自定义」,仅自定义时显示数值输入;从下轮生效,保留当前会话。Codex 自定义窗口不能超过已知目录上限。压缩值对 Codex 表示阈值,对 Claude Code 表示窗口,实际可能提前触发。旧配置中的全局压缩值会转换为原有各模型的独立值,已有逐模型设置优先;新模型默认交给执行器,不继续继承旧全局值。读取不改磁盘,下一次正常保存配置时写入转换结果。
|
|
@@ -123,7 +175,7 @@ Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队
|
|
|
123
175
|
|
|
124
176
|
## 停止与重启
|
|
125
177
|
|
|
126
|
-
按 Ctrl-C
|
|
178
|
+
按 Ctrl-C 并等待退出。配置里用的是 `env:` 引用时,先确认环境变量已设置,再执行:
|
|
127
179
|
|
|
128
180
|
```bash
|
|
129
181
|
"$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.18
|
|
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.18",
|
|
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,6 +51,7 @@
|
|
|
51
51
|
"src/agent/codexapp.js",
|
|
52
52
|
"src/agent/codexcode.js",
|
|
53
53
|
"src/agent/codexstatus.js",
|
|
54
|
+
"src/agent/envconflicts.js",
|
|
54
55
|
"src/agent/errtag.js",
|
|
55
56
|
"src/agent/modelwindows.js",
|
|
56
57
|
"src/agent/privatecore.js",
|
|
@@ -69,6 +70,8 @@
|
|
|
69
70
|
"src/c2upgradeswitch.js",
|
|
70
71
|
"src/cdp.js",
|
|
71
72
|
"src/config.js",
|
|
73
|
+
"src/connect.js",
|
|
74
|
+
"src/setupserver.js",
|
|
72
75
|
"src/consolesay.js",
|
|
73
76
|
"src/consoletrials.js",
|
|
74
77
|
"src/core/brain.js",
|
package/src/agent/claudecode.js
CHANGED
|
@@ -96,7 +96,13 @@ function normalizeEvent(ev) {
|
|
|
96
96
|
const result = claudeNormalizeEvent(ev)
|
|
97
97
|
// JSON cannot carry `undefined`; retain the frozen JS object's optional
|
|
98
98
|
// property shape at the public runner boundary.
|
|
99
|
-
if (ev.type === 'system') {
|
|
99
|
+
if (ev.type === 'system') {
|
|
100
|
+
result.subtype = ev.subtype; result.sessionId = ev.session_id
|
|
101
|
+
// init 里 CLI 自报的可用命令:含 compact ⇒ 可以空闲整理(ABI 37 idle_compact_plan 的 canCompact);
|
|
102
|
+
// 2.1.311 实测在 slash_commands 里。哪版拿掉了,引擎就只在轮内压缩。
|
|
103
|
+
if (ev.subtype === 'init' && Array.isArray(ev.slash_commands)) result.slashCommands = ev.slash_commands.filter(c => typeof c === 'string')
|
|
104
|
+
// 压缩边界(compact_boundary)的 preTokens 由核心 normalize 带出,引擎据此判整理成败。
|
|
105
|
+
}
|
|
100
106
|
else if (ev.type === 'assistant' || ev.type === 'user') {
|
|
101
107
|
result.sessionId = ev.session_id
|
|
102
108
|
if (ev.type === 'assistant') for (const tool of result.tools) {
|
|
@@ -158,6 +164,8 @@ function runTurn({
|
|
|
158
164
|
softStopGraceMs = 10000,
|
|
159
165
|
steer = false, onSteerReady, onSteerLanded,
|
|
160
166
|
steerReceiptTimeoutMs = 10000, steerHoldMs = 20000, interruptGraceMs = 2000,
|
|
167
|
+
requireCloseProof = false,
|
|
168
|
+
closeProofGraceMs = 3000,
|
|
161
169
|
closeGraceMs = 3000, // short: the grace starts AFTER the answer, so we're only waiting for
|
|
162
170
|
// the OS to close a possibly-clinging pipe (MCP browser children / cmd.exe shim). Claude
|
|
163
171
|
// persists its session continuously, so killing ~3s post-answer is safe → the group frees
|
|
@@ -270,6 +278,7 @@ function runTurn({
|
|
|
270
278
|
let aborted = false
|
|
271
279
|
let killTimer = null
|
|
272
280
|
let closeTimer = null
|
|
281
|
+
let closeProofTimer = null
|
|
273
282
|
let settled = false
|
|
274
283
|
let childCloseObserved = false
|
|
275
284
|
let acceptanceFailed = false
|
|
@@ -279,7 +288,7 @@ function runTurn({
|
|
|
279
288
|
// Resolution is guarded (exactly once) because a close-grace timer can force the
|
|
280
289
|
// turn to settle before the 'close' event fires — otherwise a lingering pipe would
|
|
281
290
|
// wedge the per-group lock forever (never-hang invariant).
|
|
282
|
-
const finish = (fn) => { if (settled) return; settled = true; followup?.dispose(); if (killTimer) clearTimeout(killTimer); if (closeTimer) clearTimeout(closeTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
|
|
291
|
+
const finish = (fn) => { if (settled) return; settled = true; followup?.dispose(); if (killTimer) clearTimeout(killTimer); if (closeTimer) clearTimeout(closeTimer); if (closeProofTimer) clearTimeout(closeProofTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
|
|
283
292
|
const resolveRun = result => {
|
|
284
293
|
if (childCloseObserved) closedRunnerResults.add(result)
|
|
285
294
|
resolve(result)
|
|
@@ -335,6 +344,14 @@ function runTurn({
|
|
|
335
344
|
closeTimer = setTimeout(() => {
|
|
336
345
|
if (!aborted && !(lastResult && lastResult.authFailed)) log(`claude 答案已到但 ${closeGraceMs}ms 内没退出 ⇒ 强制收尾;本轮若起过后台命令(run_in_background),它会被一起收掉,下一轮可能空转一次`)
|
|
337
346
|
killTree()
|
|
347
|
+
// Compaction shares a resumable session with the next turn. A result
|
|
348
|
+
// or a sent signal does not prove the process closed its files.
|
|
349
|
+
if (requireCloseProof && !childCloseObserved) {
|
|
350
|
+
closeProofTimer = setTimeout(() => finish(() => reject(Object.assign(
|
|
351
|
+
new Error('Claude compaction process close remains unproven'),
|
|
352
|
+
{ code: 'CLAUDE_COMPACTION_CLOSE_UNPROVEN' }))), closeProofGraceMs)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
338
355
|
const complete = () => finish(() => resolveRun({ sessionId: capturedSessionId, result: lastResult, code: null, aborted }))
|
|
339
356
|
if (onDurableEvent) durableWork.then(complete, failDurableEvent)
|
|
340
357
|
else complete()
|
|
@@ -435,6 +452,11 @@ function runTurn({
|
|
|
435
452
|
armCloseGrace()
|
|
436
453
|
continue
|
|
437
454
|
}
|
|
455
|
+
// 额度事件(ABI 37):订阅登录的 Claude 每次运行都带回 5 小时窗 / 7 天窗已用比例 —— 喂进额度缓存,
|
|
456
|
+
// 页脚与 /hud 就不必再去打那个常被限流的额度接口。第三方端点 / 自带密钥不是订阅额度,不喂。
|
|
457
|
+
if (raw.type === 'rate_limit_event' && isSubscription(provider)) {
|
|
458
|
+
try { require('./claudeusage').noteRateLimit(raw.rate_limit_info) } catch { /* 额度显示出错不能碰轮次 */ }
|
|
459
|
+
}
|
|
438
460
|
let ev
|
|
439
461
|
try { ev = normalizeEvent(raw) } catch (error) { failCore(error); return }
|
|
440
462
|
if (ev.sessionId) capturedSessionId = ev.sessionId
|
|
@@ -572,4 +594,20 @@ function probeVersion(bin = 'claude') {
|
|
|
572
594
|
})
|
|
573
595
|
}
|
|
574
596
|
|
|
575
|
-
|
|
597
|
+
// 这一份 provider 跑的是不是本机登录的 Claude 订阅(额度事件只对它有意义)。env 里另指了端点 / 令牌的也不算。
|
|
598
|
+
function isSubscription(provider = {}) {
|
|
599
|
+
const env = provider.env || {}
|
|
600
|
+
return !provider.baseUrl && !provider.apiKey && !env.ANTHROPIC_BASE_URL && !env.ANTHROPIC_AUTH_TOKEN && !env.ANTHROPIC_API_KEY
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 空闲整理(ABI 37,核心 idle_compact_plan 决定起不起、保留什么):对同一个会话跑一次 `/compact`。
|
|
604
|
+
// ⚠ 参数必须与平时轮次**完全相同**(引擎用同一组启动参数):工具定义在提示最前面,换了工具集 / MCP / 系统提示附录,
|
|
605
|
+
// 整段会话的缓存全部失效(旧 sbot 11-12 实测换工具集缓存写入 2.6 倍)。输入一律纯文本,不开跟进通道。
|
|
606
|
+
// 停:引擎的 abort signal ⇒ 与普通轮同一条两段式收尾。结果:onEvent 收到 system/compact_boundary(preTokens)与 result。
|
|
607
|
+
function compactSession(opts = {}) {
|
|
608
|
+
const { onSteerReady, onSteerLanded, steer, prompt, onDurableEvent, onTurnAccepted, keep, ...rest } = opts
|
|
609
|
+
if (!keep) return Promise.reject(new Error('compactSession: keep required'))
|
|
610
|
+
return runTurn({ ...rest, requireCloseProof: true, prompt: '/compact ' + keep })
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
module.exports = { runTurn, compactSession, isSubscription, isClosedRunnerResult, isClosedRunnerError, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
|
package/src/agent/claudeusage.js
CHANGED
|
@@ -171,7 +171,56 @@ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
171
171
|
// 里所有人都查不到额度(而这接口恰恰偶发超时/限流);完全不压又让连点几下就把限流捅穿。
|
|
172
172
|
// 10 秒 = 人再问一次就真的重试,机器连点仍被挡住。
|
|
173
173
|
const RAW = { at: 0, data: null }
|
|
174
|
+
|
|
175
|
+
// ── 额度事件(ABI 37)──────────────────────────────────────────────────────────
|
|
176
|
+
// claude CLI 每次运行都在 stream 里吐一个 `rate_limit_event`(订阅登录时),runner 收到就喂进来;解析在核心
|
|
177
|
+
// `usage_parse('claude','event')`。EVENT_TTL 内有事件 ⇒ 5 小时窗 / 7 天窗用它,不打接口;按模型分的周窗
|
|
178
|
+
// 事件里没有的窗口仍取接口;事件新鲜时接口最多 SCOPED_TTL 请求一次,避免频繁探测触发限流。
|
|
179
|
+
const EVENT_TTL_MS = 10 * 60_000
|
|
180
|
+
const SCOPED_TTL_MS = 30 * 60_000
|
|
181
|
+
const EVT = { at: 0, windows: null } // windows: { session?: {percent, resetsAt, at}, weekly_all?: {…} }
|
|
182
|
+
const EVENT_KIND = { session: 'session', weekly: 'weekly_all' } // 核心的窗口名 → 接口 limits[] 的 kind
|
|
183
|
+
function noteRateLimit(info, now = Date.now()) {
|
|
184
|
+
if (!info || typeof info !== 'object') return false
|
|
185
|
+
let parsed
|
|
186
|
+
try { parsed = require('./privatecore').usageParse('claude', 'event', null, info, now) } catch { return false }
|
|
187
|
+
if (!parsed.ok) return false
|
|
188
|
+
const windows = {}
|
|
189
|
+
for (const w of parsed.windows) {
|
|
190
|
+
const kind = EVENT_KIND[w.kind]
|
|
191
|
+
if (kind) windows[kind] = { percent: w.percentUsed, resetsAt: w.resetsAt ?? null, at: now }
|
|
192
|
+
}
|
|
193
|
+
if (!Object.keys(windows).length) return false
|
|
194
|
+
EVT.windows = { ...(EVT.windows || {}), ...windows } // 只有一个窗时别把另一个窗的旧值丢了
|
|
195
|
+
EVT.at = now
|
|
196
|
+
// A new event must be visible immediately without invalidating the endpoint cache.
|
|
197
|
+
CACHE.at = 0; CACHE.val = null
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
const eventFresh = (now = Date.now()) => !!(EVT.at && now - EVT.at < EVENT_TTL_MS && EVT.windows)
|
|
201
|
+
// 事件与接口数据合成 parseLimits 的形状;同一个窗两边都有 ⇒ 取更新的那份(人点了「刷新」时接口比事件新)。
|
|
202
|
+
function mergeEvent(limits, dataAt, now = Date.now()) {
|
|
203
|
+
const out = Array.isArray(limits) ? limits.slice() : []
|
|
204
|
+
for (const [kind, w] of Object.entries(EVT.windows || {})) {
|
|
205
|
+
if (!w || now - w.at >= EVENT_TTL_MS) continue
|
|
206
|
+
const i = out.findIndex(l => l && l.kind === kind)
|
|
207
|
+
if (i >= 0 && dataAt > w.at) continue
|
|
208
|
+
const row = { kind, percent: w.percent, resetsAt: w.resetsAt, scopeName: '' }
|
|
209
|
+
if (i >= 0) out[i] = row; else out.push(row)
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
212
|
+
}
|
|
213
|
+
function clearEvents() { EVT.at = 0; EVT.windows = null } // 单测用
|
|
214
|
+
|
|
174
215
|
async function fetchRaw(token, { ttl = 60_000, now = Date.now, log = warn, curl = fetchCurl, sleep = sleepMs } = {}) {
|
|
216
|
+
const t = now()
|
|
217
|
+
if (eventFresh(t)) {
|
|
218
|
+
// 事件新鲜:接口只为补「按模型分的周窗」,最多 SCOPED_TTL 打一次(成败都算一次 —— 限流时不连环重试)。
|
|
219
|
+
if ((RAW.at && t - RAW.at < SCOPED_TTL_MS) || !token) return RAW.data
|
|
220
|
+
const data = await curl(token, { log }) // 不重试:事件已经给了主要的两个窗
|
|
221
|
+
RAW.at = now(); RAW.data = data
|
|
222
|
+
return data
|
|
223
|
+
}
|
|
175
224
|
if (RAW.at && now() - RAW.at < (RAW.data ? ttl : FAIL_TTL_MS)) return RAW.data
|
|
176
225
|
// 只走 curl(纪律 1);失败隔一下再问一次 —— 超时与短限流重试一次往往就通,再不通就认了。
|
|
177
226
|
let data = await curl(token, { log })
|
|
@@ -180,21 +229,206 @@ async function fetchRaw(token, { ttl = 60_000, now = Date.now, log = warn, curl
|
|
|
180
229
|
return data
|
|
181
230
|
}
|
|
182
231
|
|
|
232
|
+
// ── 令牌过期兜底(2026-11-12,cc-switch takeaways B3)──────────────────────────────
|
|
233
|
+
// 纪律 2 不变:我们**不刷新**令牌。过期时让 Claude CLI 自己在自己的锁里刷新 —— 跑一次
|
|
234
|
+
// `claude -p /usage`(官方的非交互用量面板),它顺手把额度写进 `~/.claude.json` 的
|
|
235
|
+
// `cachedUsageUtilization`,我们只读那份缓存,交给核心 `usage_parse('claude','cached')` 判
|
|
236
|
+
//(当前 CLI 使用 fetchedAtMs + utilization.limits;旧 limits[] 也保留支持,超过 645 秒判旧)。
|
|
237
|
+
// 约束:
|
|
238
|
+
// · **不阻塞调用方**:需要刷新时在后台起跑(进程内一次一个),本次立刻回 null(或在跑期间回
|
|
239
|
+
// 上一次解析出的值);之后的调用再读结果。/hud、/api/claude/verify 都是秒级回复。
|
|
240
|
+
// · **只信本进程跑成功后的文件**:退出码 0 才记一次「刷新完成时刻」,读文件以它为准;文件
|
|
241
|
+
// 里没有 fetchedAt 时就用这个完成时刻 —— 别的进程(交互式 CLI)写的旧缓存不当新数。
|
|
242
|
+
// 跑失败 / 超时 ⇒ null。
|
|
243
|
+
// · 同账号(按凭据文件路径)10 分钟最多跑一次 · 45 秒后 SIGTERM、再 5 秒 SIGKILL,子进程
|
|
244
|
+
// 没 close 之前不会起下一个 · 旗子探测合成一次(短超时、有缓存),老 CLI 不认 ⇒ 跳过。
|
|
245
|
+
// · env 走 buildEnv({}) 再去掉 ANTHROPIC_API_KEY(只认 OAuth 登录态,别让 /usage 打到 API key
|
|
246
|
+
// 或第三方网关)· cwd 用临时目录(别让它读到哪个项目的 CLAUDE.md / .mcp.json)· 输出不看不记。
|
|
247
|
+
const FALLBACK_TIMEOUT_MS = 45_000
|
|
248
|
+
const FALLBACK_KILL_GRACE_MS = 5_000
|
|
249
|
+
const FALLBACK_INTERVAL_MS = 10 * 60_000
|
|
250
|
+
const FALLBACK_FLAG_PROBE_MS = 2_000
|
|
251
|
+
const FALLBACK_ARGS = Object.freeze(['-p', '/usage', '--output-format', 'json', '--tools', '', '--strict-mcp-config', '--setting-sources', '', '--no-session-persistence'])
|
|
252
|
+
const FALLBACK_FLAGS = Object.freeze(['--tools', '--strict-mcp-config', '--setting-sources', '--no-session-persistence'])
|
|
253
|
+
const USAGE_HELP_INFLIGHT = new Map()
|
|
254
|
+
|
|
255
|
+
// Some native CLI builds exit before a long help page drains through a pipe.
|
|
256
|
+
// A private regular file receives the complete page. The four flag checks share
|
|
257
|
+
// one bounded process; this temporary file contains help text only.
|
|
258
|
+
function supportsUsageFlag(bin, flag) {
|
|
259
|
+
let probe = USAGE_HELP_INFLIGHT.get(bin)
|
|
260
|
+
if (!probe) {
|
|
261
|
+
probe = (async () => {
|
|
262
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sintbot-usage-help-'))
|
|
263
|
+
const file = path.join(dir, 'help.txt')
|
|
264
|
+
let fd
|
|
265
|
+
try {
|
|
266
|
+
fd = fs.openSync(file, 'wx', 0o600)
|
|
267
|
+
const ok = await new Promise(resolve => {
|
|
268
|
+
let child, timer, force, settled = false
|
|
269
|
+
const finish = value => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(force); resolve(value) }
|
|
270
|
+
try {
|
|
271
|
+
const command = require('./winexec').resolveSpawn(bin, ['--help'])
|
|
272
|
+
child = require('node:child_process').spawn(command.file, command.spawnArgs,
|
|
273
|
+
{ cwd: dir, stdio: ['ignore', fd, 'ignore'], windowsHide: true })
|
|
274
|
+
} catch { return finish(false) }
|
|
275
|
+
child.once('error', () => finish(false))
|
|
276
|
+
child.once('close', code => finish(code === 0))
|
|
277
|
+
timer = setTimeout(() => {
|
|
278
|
+
try { child.kill('SIGTERM') } catch {}
|
|
279
|
+
force = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 1000)
|
|
280
|
+
force.unref?.()
|
|
281
|
+
}, 5000)
|
|
282
|
+
timer.unref?.()
|
|
283
|
+
})
|
|
284
|
+
if (!ok || fs.statSync(file).size > 1024 * 1024) return null
|
|
285
|
+
return await fs.promises.readFile(file, 'utf8')
|
|
286
|
+
} finally {
|
|
287
|
+
if (fd !== undefined) fs.closeSync(fd)
|
|
288
|
+
await fs.promises.rm(dir, { recursive: true, force: true })
|
|
289
|
+
}
|
|
290
|
+
})().catch(() => null)
|
|
291
|
+
USAGE_HELP_INFLIGHT.set(bin, probe)
|
|
292
|
+
probe.finally(() => { if (USAGE_HELP_INFLIGHT.get(bin) === probe) USAGE_HELP_INFLIGHT.delete(bin) })
|
|
293
|
+
}
|
|
294
|
+
return probe.then(text => text === null ? null : text.includes(flag))
|
|
295
|
+
}
|
|
296
|
+
const FALLBACK = {
|
|
297
|
+
inflight: null, // 正在跑的那次(Promise<boolean>);子进程 close 前不清
|
|
298
|
+
lastAt: new Map(), // 凭据文件路径 -> 上次起跑时刻(节流)
|
|
299
|
+
doneAt: new Map(), // 凭据文件路径 -> 本进程最近一次退出码 0 的完成时刻(没有 = 不信文件)
|
|
300
|
+
lastGood: new Map(), // 凭据文件路径 -> 上一次解析出的一行(只在刷新进行中回它)
|
|
301
|
+
flags: null, // { bin, until, p } 合成一次的旗子探测
|
|
302
|
+
file: null, // { path, mtimeMs, body } 按 mtime 缓存的 ~/.claude.json 解析
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// `~/.claude.json` 原文 → 交给核心的 body(cachedUsageUtilization 原样)。不是对象 ⇒ null。
|
|
306
|
+
// 时效优先用对象自带的 fetchedAtMs/fetchedAt;两者都没有才用本进程刷新完成时刻。
|
|
307
|
+
function readCachedUtilization(raw) {
|
|
308
|
+
let j = null
|
|
309
|
+
try { j = JSON.parse(String(raw || '')) } catch { return null }
|
|
310
|
+
const u = j && j.cachedUsageUtilization
|
|
311
|
+
return u && typeof u === 'object' && !Array.isArray(u) ? u : null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 按 mtime 缓存的异步读:同一份文件只解析一次;stat/读失败 ⇒ null。
|
|
315
|
+
async function readClaudeJson(file, { readFile, stat }) {
|
|
316
|
+
let st = null
|
|
317
|
+
try { st = await stat(file) } catch { return null }
|
|
318
|
+
const mtimeMs = st && typeof st.mtimeMs === 'number' ? st.mtimeMs : null
|
|
319
|
+
const c = FALLBACK.file
|
|
320
|
+
if (c && c.path === file && mtimeMs !== null && c.mtimeMs === mtimeMs) return c.body
|
|
321
|
+
let raw = null
|
|
322
|
+
try { raw = await readFile(file, 'utf8') } catch { return null }
|
|
323
|
+
const body = readCachedUtilization(raw)
|
|
324
|
+
FALLBACK.file = { path: file, mtimeMs, body }
|
|
325
|
+
return body
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 核心的 windows[] → parseLimits 的形状,formatClaudeUsage 原样可用。
|
|
329
|
+
const windowsToLimits = (windows) => (Array.isArray(windows) ? windows : [])
|
|
330
|
+
.map((w) => ({ kind: w.kind, percent: w.percentUsed, resetsAt: w.resetsAt ?? null, scopeName: w.scopeName || '' }))
|
|
331
|
+
|
|
332
|
+
// 四个旗子合成**一次**探测:默认共享同一份完整 --help 输出,测试可注入探测函数。
|
|
333
|
+
// 短超时兜底;结论 true/false 缓存 10 分钟、不知道(null)缓存 60 秒。
|
|
334
|
+
function probeFlags(bin, supportsFlag, now, timeoutMs = FALLBACK_FLAG_PROBE_MS) {
|
|
335
|
+
const hit = FALLBACK.flags
|
|
336
|
+
if (hit && hit.bin === bin && now() < hit.until) return hit.p
|
|
337
|
+
const all = Promise.all(FALLBACK_FLAGS.map((f) => Promise.resolve().then(() => supportsFlag(bin, f))))
|
|
338
|
+
.then((a) => (a.every((v) => v === true) ? true : a.some((v) => v === false) ? false : null))
|
|
339
|
+
const late = new Promise((resolve) => { const t = setTimeout(() => resolve(null), timeoutMs); t.unref?.() })
|
|
340
|
+
const p = Promise.race([all, late]).catch(() => null)
|
|
341
|
+
FALLBACK.flags = { bin, until: now() + 60_000, p }
|
|
342
|
+
p.then((v) => { if (FALLBACK.flags && FALLBACK.flags.p === p) FALLBACK.flags.until = now() + (v === null ? 60_000 : 10 * 60_000) })
|
|
343
|
+
return p
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 跑一次 `claude -p /usage`;只在子进程 close(或起不来)后才落定 ⇒ 卡住的子进程不会与下一次重叠。
|
|
347
|
+
// 超时:SIGTERM,再过 killGraceMs 还没 close 就 SIGKILL;两个计时器都 unref。
|
|
348
|
+
function runUsageRefresh({ bin, spawn, buildEnv, tmpdir, log, timeoutMs, killGraceMs = FALLBACK_KILL_GRACE_MS }) {
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
let child, settled = false, timedOut = false
|
|
351
|
+
const timers = []
|
|
352
|
+
const done = (ok, why) => { if (settled) return; settled = true; for (const t of timers) clearTimeout(t); if (why) log(why); resolve(ok) }
|
|
353
|
+
const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); timers.push(t) }
|
|
354
|
+
try {
|
|
355
|
+
const env = buildEnv({})
|
|
356
|
+
delete env.ANTHROPIC_API_KEY // 只认 OAuth 登录态
|
|
357
|
+
const { file, spawnArgs } = require('./winexec').resolveSpawn(bin, [...FALLBACK_ARGS])
|
|
358
|
+
child = spawn(file, spawnArgs, { cwd: tmpdir(), env, stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true })
|
|
359
|
+
} catch (e) { return done(false, '额度兜底:claude 起不来(' + ((e && e.message) || '未知错误') + ')') }
|
|
360
|
+
later(timeoutMs, () => {
|
|
361
|
+
timedOut = true
|
|
362
|
+
try { child.kill('SIGTERM') } catch {}
|
|
363
|
+
later(killGraceMs, () => { try { child.kill('SIGKILL') } catch {} })
|
|
364
|
+
})
|
|
365
|
+
child.on('error', (e) => done(false, '额度兜底:claude 起不来(' + ((e && e.code) || (e && e.message) || '未知错误') + ')'))
|
|
366
|
+
child.on('close', (code) => {
|
|
367
|
+
if (timedOut) return done(false, `额度兜底:claude -p /usage 超时(${timeoutMs}ms),已终止`)
|
|
368
|
+
done(code === 0, code === 0 ? null : `额度兜底:claude -p /usage 退出码 ${code}`)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 需要就在后台起一次刷新(不 await):进程内一次一个,同账号 10 分钟一次。
|
|
374
|
+
function startRefreshIfDue(account, { bin, now, log, spawn, supportsFlag, buildEnv, tmpdir, timeoutMs, killGraceMs, intervalMs, flagProbeMs }) {
|
|
375
|
+
if (FALLBACK.inflight) return
|
|
376
|
+
const last = FALLBACK.lastAt.get(account) || 0
|
|
377
|
+
if (last && now() - last < intervalMs) return
|
|
378
|
+
FALLBACK.lastAt.set(account, now())
|
|
379
|
+
FALLBACK.inflight = (async () => {
|
|
380
|
+
const flags = await probeFlags(bin, supportsFlag, now, flagProbeMs)
|
|
381
|
+
if (flags !== true) { log('额度兜底:本机 claude 不认所需旗子,跳过'); return false }
|
|
382
|
+
const ok = await runUsageRefresh({ bin, spawn, buildEnv, tmpdir, log, timeoutMs, killGraceMs })
|
|
383
|
+
if (ok) FALLBACK.doneAt.set(account, now())
|
|
384
|
+
else FALLBACK.doneAt.delete(account)
|
|
385
|
+
return ok
|
|
386
|
+
})().catch(() => { FALLBACK.doneAt.delete(account); return false })
|
|
387
|
+
.finally(() => { FALLBACK.inflight = null })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 过期/缺凭据时的那条腿:(必要时)后台让 CLI 刷新 → 读缓存(只信本进程刷成功后的)→ 核心判 →
|
|
391
|
+
// 一行人话;任何一步不行 ⇒ null。调用方不会被挡住:这里只 await 一次文件读。
|
|
392
|
+
async function cachedUsageFallback({ home = os.homedir(), bin = 'claude', now = Date.now, log = warn,
|
|
393
|
+
spawn = require('node:child_process').spawn, readFile = fs.promises.readFile, stat = fs.promises.stat, tmpdir = os.tmpdir,
|
|
394
|
+
supportsFlag = null, buildEnv = null, timeoutMs = FALLBACK_TIMEOUT_MS, killGraceMs = FALLBACK_KILL_GRACE_MS,
|
|
395
|
+
intervalMs = FALLBACK_INTERVAL_MS, flagProbeMs = FALLBACK_FLAG_PROBE_MS } = {}) {
|
|
396
|
+
const account = path.join(home, '.claude', '.credentials.json')
|
|
397
|
+
startRefreshIfDue(account, { bin, now, log, spawn, tmpdir, timeoutMs, killGraceMs, intervalMs, flagProbeMs,
|
|
398
|
+
supportsFlag: supportsFlag || supportsUsageFlag, buildEnv: buildEnv || require('./claudecode').buildEnv })
|
|
399
|
+
const doneAt = FALLBACK.doneAt.get(account)
|
|
400
|
+
if (!doneAt) return FALLBACK.inflight ? (FALLBACK.lastGood.get(account) || null) : null
|
|
401
|
+
const raw = await readClaudeJson(path.join(home, '.claude.json'), { readFile, stat })
|
|
402
|
+
if (!raw) return null
|
|
403
|
+
const body = raw.fetchedAtMs != null || raw.fetchedAt != null ? raw : { ...raw, fetchedAt: doneAt }
|
|
404
|
+
const parsed = require('./privatecore').usageParse('claude', 'cached', null, body, now())
|
|
405
|
+
if (!parsed.ok) return FALLBACK.inflight ? (FALLBACK.lastGood.get(account) || null) : null
|
|
406
|
+
const line = formatClaudeUsage(windowsToLimits(parsed.windows), now())
|
|
407
|
+
if (line) FALLBACK.lastGood.set(account, line)
|
|
408
|
+
return line
|
|
409
|
+
}
|
|
410
|
+
function resetFallback() { FALLBACK.inflight = null; FALLBACK.lastAt.clear(); FALLBACK.doneAt.clear(); FALLBACK.lastGood.clear(); FALLBACK.flags = null; FALLBACK.file = null }
|
|
411
|
+
// 单测用:等后台那次刷新落定(没有就立刻回)。
|
|
412
|
+
const settleFallback = () => (FALLBACK.inflight ? FALLBACK.inflight.then(() => undefined) : Promise.resolve())
|
|
413
|
+
|
|
183
414
|
// 拉一次 → 一行人话;拿不到 null。走 fetchRaw ⇒ 与告警共享那份缓存。
|
|
184
415
|
// 缓存口径与 fetchRaw 一致:成功 60s、失败 10s(两层都压死一分钟就白搭下层的重试了)。
|
|
416
|
+
// 凭据缺失/过期 ⇒ 走 cachedUsageFallback(见上),不再直接 null。
|
|
185
417
|
const CACHE = { at: 0, val: null }
|
|
186
418
|
const TTL = 60_000
|
|
187
|
-
async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToken, ...io } = {}) {
|
|
419
|
+
async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToken, fallback = cachedUsageFallback, home, bin, ...io } = {}) {
|
|
188
420
|
if (CACHE.at && now() - CACHE.at < (CACHE.val ? ttl : FAIL_TTL_MS)) return CACHE.val
|
|
189
421
|
let val = null
|
|
190
422
|
const token = readToken()
|
|
191
|
-
if (token) {
|
|
192
|
-
const data = await fetchRaw(token, { ttl, now,
|
|
193
|
-
val = formatClaudeUsage(parseLimits(data), now())
|
|
423
|
+
if (token || eventFresh(now())) { // 令牌读不到(钥匙串偶发)但有新鲜的额度事件,照样出数
|
|
424
|
+
const data = token ? await fetchRaw(token, { ttl, now, log: io.log, curl: io.curl, sleep: io.sleep }) : null
|
|
425
|
+
val = formatClaudeUsage(mergeEvent(parseLimits(data), RAW.at, now()), now())
|
|
426
|
+
} else {
|
|
427
|
+
try { val = await fallback({ now, ...(home ? { home } : {}), ...(bin ? { bin } : {}), ...io }) } catch { val = null }
|
|
194
428
|
}
|
|
195
429
|
CACHE.at = now(); CACHE.val = val
|
|
196
430
|
return val
|
|
197
431
|
}
|
|
198
432
|
function clearCache() { CACHE.at = 0; CACHE.val = null; RAW.at = 0; RAW.data = null }
|
|
199
433
|
|
|
200
|
-
module.exports = { usageLine, fetchRaw, clearCache, parseCredentials, parseLimits, formatClaudeUsage, readAccessToken, fetchCurl, fetchNative, readCurlResult, fmtDurDecimal, QUOTA_SEP, TIMEOUT_MS, FAIL_TTL_MS, RETRY_WAIT_MS }
|
|
434
|
+
module.exports = { usageLine, fetchRaw, clearCache, clearEvents, noteRateLimit, eventFresh, mergeEvent, 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 }
|
|
@@ -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 }
|