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.
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.15
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.15` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.15` 就可以继续。不需要 sudo。
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
- 在 [飞书开放平台](https://open.feishu.cn/) 创建企业自建应用,启用机器人、接收和回复消息的权限,事件订阅选择长连接并订阅消息接收。发布应用后,把机器人加入要使用的群,取得每个群的 `oc_…` 群 ID。
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
- ## 3. 绑定项目
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
- ## 4. 首次启动
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` | 查看可选模型;`/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
- 模型卡区分设置值与运行回报,容量依据可在「容量与运行信息」中查看。Codex 上下文窗口和各模型自动压缩都只提供「执行器默认 / 自定义」,仅自定义时显示数值输入;从下轮生效,保留当前会话。Codex 自定义窗口不能超过已知目录上限。压缩值对 Codex 表示阈值,对 Claude Code 表示窗口,实际可能提前触发。旧配置中的全局压缩值会转换为原有各模型的独立值,已有逐模型设置优先;新模型默认交给执行器,不继续继承旧全局值。读取不改磁盘,下一次正常保存配置时写入转换结果。
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.15
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.15",
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/modelwindows.js",
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/modelcontext.js",
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",
@@ -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
- // 当前主力档 + 上一代 Opus。刻意不铺满历史版本 —— 它是挑选快捷方式,不是权威清单;
21
- // 4.8 留着是因为它仍在服务且额度池与 Opus 5 分开(operator 点名要能选,2026-08-05)。
22
- // ⚠ id 是不带日期后缀的固定串(`claude-opus-4-8`,不是 `claude-opus-4-8-2026xxxx`)。
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: 'Claude Opus 5', note: '最强 · 复杂改码 / 长链路' },
26
- { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', note: '上一代 Opus · 额度池独立' },
27
- { id: 'claude-sonnet-5', name: 'Claude Sonnet 5', note: '速度与智能平衡' },
28
- { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', note: '最快最省 · 简单任务' },
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
- module.exports = { listModels, mergeModels, parseExtraModels, readExtraModels, BUILTIN }
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 }
@@ -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, ...io })
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 }
@@ -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
- let effort = provider.reasoningEffort || ''
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 = provider.reasoningEffort || ''
354
+ if (lastResult && retriedEffort) lastResult.effortFellBack = configuredEffort
354
355
  if (lastResult) emit(lastResult)
355
356
  // 让服务端自行落盘退出;收到完成通知就强杀会破坏下一轮 resume。
356
357
  try { child.stdin.end() } catch {}
@@ -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 }