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.
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.16
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.16` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.16` 就可以继续。不需要 sudo。
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
- 在 [飞书开放平台](https://open.feishu.cn/) 创建企业自建应用,启用机器人、接收和回复消息的权限,事件订阅选择长连接并订阅消息接收;回调订阅同样选择长连接接收回调并订阅卡片回传交互(群里点选 `/model` 卡片要用)。发布应用后,把机器人加入要使用的群,取得每个群的 `oc_…` 群 ID。
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
- ## 3. 绑定项目
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
- ## 4. 首次启动
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.16
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.16",
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",
@@ -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') { result.subtype = ev.subtype; result.sessionId = ev.session_id }
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
- module.exports = { runTurn, isClosedRunnerResult, isClosedRunnerError, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
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 }
@@ -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, ...io })
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 }