sintbot 1.4.3 → 1.4.5

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 CHANGED
@@ -1,4 +1,66 @@
1
1
  [
2
+ {
3
+ "version": "1.4.5",
4
+ "date": "2026-11-09",
5
+ "name": "Group commands, console settings and Codex sign-in",
6
+ "items": [
7
+ {
8
+ "t": "New /skip in groups",
9
+ "d": "Drops only the current task; queued messages run next and the conversation is kept. /stop still cancels the current task and everything queued."
10
+ },
11
+ {
12
+ "t": "/help is written for each group",
13
+ "d": "It opens with the group's project and model, lists commands by urgency, describes /skip, /stop and /new in the same terms (what happens to the queue and to the conversation), and explains that recalling your own message cancels it."
14
+ },
15
+ {
16
+ "t": "New session from the console",
17
+ "d": "An idle group with every record settled starts a new conversation right away; a busy group is told to stop or wait first instead of being reported as reset."
18
+ },
19
+ {
20
+ "t": "Models can be added and configured from the model cards",
21
+ "d": "No service restart. Changing a model id, backend or endpoint waits for the groups using it to be idle and starts a new conversation for them; the default model and models in use cannot be hidden."
22
+ },
23
+ {
24
+ "t": "Fix: tool quality and thinking depth settings could not be saved",
25
+ "d": "They were wrongly reported as needing a service restart."
26
+ },
27
+ {
28
+ "t": "Codex sign-in is checked with the server",
29
+ "d": "The console's Codex model card no longer trusts the local sign-in file alone. It asks the server once with the token and shows one of three states: verified, sign-in expired (run codex login again on this machine), or unverified (the server could not be reached; not treated as signed out). A revoked token no longer keeps showing as signed in."
30
+ },
31
+ {
32
+ "t": "Fix: stopping a task before it started could swallow the next answer",
33
+ "d": "If the model process failed to start after a console stop or /skip, a stale cancelled flag could silently drop the next task's answer."
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "version": "1.4.4",
39
+ "date": "2026-11-09",
40
+ "name": "Upgrade progress in the console",
41
+ "items": [
42
+ {
43
+ "t": "Confirm the upgrade in the page",
44
+ "d": "Upgrade now asks for confirmation in the page, stating the versions and that new messages wait about a minute during the switch."
45
+ },
46
+ {
47
+ "t": "The upgrade window shows each step",
48
+ "d": "Waiting for idle groups, download and install, stopping the current service, checking records and backing up, preflight, switching and starting, and waiting for every group to connect are ticked off one by one with the elapsed time; while waiting it names the groups still working."
49
+ },
50
+ {
51
+ "t": "Reconnects while the service restarts",
52
+ "d": "During the switch the page says the service is restarting and reconnects by itself; once the new version connects every group it reloads into the new console and shows the result once. A failed step and the restored version are marked."
53
+ },
54
+ {
55
+ "t": "Can run in the background",
56
+ "d": "Continue in background keeps a spinner on the top-bar icon, which reopens the progress; a notice appears when the upgrade ends."
57
+ },
58
+ {
59
+ "t": "Takes effect from 1.4.4",
60
+ "d": "The upgrade window comes from the running version: upgrading from 1.4.3 to 1.4.4 still shows the previous screen, and later upgrades show the window."
61
+ }
62
+ ]
63
+ },
2
64
  {
3
65
  "version": "1.4.3",
4
66
  "date": "2026-11-09",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,66 @@
1
1
  [
2
+ {
3
+ "version": "1.4.5",
4
+ "date": "2026-11-09",
5
+ "name": "群命令、控制台设置与 Codex 登录态",
6
+ "items": [
7
+ {
8
+ "t": "群里新增 /skip",
9
+ "d": "只放弃手头这件,排队的消息接着处理,之前聊的还记得;/stop 仍是手头和排队的全取消。"
10
+ },
11
+ {
12
+ "t": "/help 按本群生成",
13
+ "d": "开头写明本群绑的项目和用的模型;命令按着急程度排;/skip、/stop、/new 用同一组说法讲清排队的和之前聊的会怎样;并说明撤回自己发的消息可以取消那件事。"
14
+ },
15
+ {
16
+ "t": "控制台可以开新会话",
17
+ "d": "群空闲且记录全部结账时立即开始新的一段对话;群里有任务时会提示先停止或等它结束,不会假装已重置。"
18
+ },
19
+ {
20
+ "t": "模型卡片可在线添加和设置模型",
21
+ "d": "不用重启服务。改型号、后端或端点时,用它的群须空闲,并自动开始新的一段对话;默认模型和正在使用的模型不能隐藏。"
22
+ },
23
+ {
24
+ "t": "修复:模型的工具质量、思考深度设置保存失败",
25
+ "d": "此前被误报「需要重启服务」而无法保存。"
26
+ },
27
+ {
28
+ "t": "Codex 登录态改由服务端判定",
29
+ "d": "控制台的 Codex 模型卡片不再只看本机登录文件:会带着令牌问一次服务端,按三种情况显示 —— 已验证、登录态已失效(要在这台机器上重新跑 codex login)、未验证(暂时探不到,不当成未登录)。令牌被吊销后不会再一直显示已登录。"
30
+ },
31
+ {
32
+ "t": "修复:任务还没启动就被停止时,下一条任务的回答可能被丢掉",
33
+ "d": "控制台停止或 /skip 发出后,若模型进程实际没有启动成功,此前会留下「已取消」标记,让下一条任务的回答静默消失。"
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "version": "1.4.4",
39
+ "date": "2026-11-09",
40
+ "name": "控制台升级显示进度",
41
+ "items": [
42
+ {
43
+ "t": "升级前在页面里确认",
44
+ "d": "点「现在升级」后在页面里确认,写明从哪个版本升到哪个版本,以及切换期间约一分钟不处理新消息。"
45
+ },
46
+ {
47
+ "t": "升级窗口显示每一步",
48
+ "d": "等待所有群空闲、下载安装、停止旧服务、核对记录并备份、新版本预检、切换并启动、等全部群连上,逐步打勾并显示已用时间;等群空闲时列出还在运行的群。"
49
+ },
50
+ {
51
+ "t": "服务重启时自动重连",
52
+ "d": "切换那几秒页面显示「服务正在重启」并自动重连;新版本连上全部群后自动刷新到新版控制台,再显示一次结果。失败时标出出错的那一步和已换回的版本。"
53
+ },
54
+ {
55
+ "t": "可以放到后台",
56
+ "d": "点「在后台继续」后顶栏图标转圈,点它可以再打开进度;升级结束时给一条提示。"
57
+ },
58
+ {
59
+ "t": "从 1.4.4 起生效",
60
+ "d": "升级窗口由正在运行的版本提供:从 1.4.3 升到 1.4.4 时仍是原来的界面,之后的升级才会显示。"
61
+ }
62
+ ]
63
+ },
2
64
  {
3
65
  "version": "1.4.3",
4
66
  "date": "2026-11-09",
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.3 with an AI assistant
1
+ # Install SintBot 1.4.5 with an AI assistant
2
2
 
3
3
  English · [简体中文](./zh-CN/AI-ASSISTED-INSTALL.md)
4
4
 
@@ -15,5 +15,5 @@ Use the terminal on the Mac that will run SintBot. A cloud shell cannot install
15
15
  ## Copyable request
16
16
 
17
17
  ```text
18
- Install SintBot 1.4.3 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
18
+ Install SintBot 1.4.5 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
19
19
  ```
package/docs/install.md CHANGED
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.3
1
+ # Install SintBot 1.4.5
2
2
 
3
3
  English · [简体中文](./zh-CN/install.md)
4
4
 
@@ -10,7 +10,7 @@ You need a Mac, Node.js 22 or newer, and projects stored on that Mac. Install an
10
10
  curl -fsSL https://sintbot.com/install.sh | bash
11
11
  ```
12
12
 
13
- The installer checks the environment, installs `sintbot@1.4.3` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.3`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.5` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.5`. No sudo is needed.
14
14
 
15
15
  To preview, append `-s -- --dry-run`. To choose another location, set `SINTBOT_INSTALL_DIR` and adjust the executable paths below.
16
16
 
@@ -90,21 +90,24 @@ Any text message is a task. Replies arrive in a card that appears once the model
90
90
 
91
91
  | Command | Effect |
92
92
  | --- | --- |
93
- | `/help` | List commands |
94
93
  | `/status` | Group status: model, project, busy or idle (steps and current work while busy), queue |
94
+ | `/skip` | Cancel only the current task; queued messages run next and the conversation is kept |
95
+ | `/stop` | Stop the current task and cancel queued ones; the conversation is kept |
96
+ | `/new` | Start a new conversation, forgetting the previous one (only while the group is idle) |
95
97
  | `/model` | List models; `/model name` switches (only while the group is idle; starts a new conversation) |
96
- | `/new` | Start a new conversation (only while the group is idle) |
97
- | `/stop` | Stop the current task and cancel queued ones |
98
98
  | `/hud` | Quota of the current model and usage of this conversation |
99
99
  | `/issue text` | Log feedback; review and close it in the console overview |
100
+ | `/help` | List commands, with this group's project and model |
101
+
102
+ Send a command as a message of its own; except for `/issue` and `/model`, do not add other text after it. If the Feishu app also subscribes to the message-recall event (the console's setup guide recommends it), recalling your own message cancels it: a queued message is dropped, and the running task stops if that message alone started it; in Codex groups, recalling a message that was added to the running task cancels the whole task.
100
103
 
101
104
  Messages sent while a task runs are added to that task in Codex groups (`codexSteer: true`) and queued in Claude Code groups. If a turn returns a model error or no result, only that group is asked to resend; there is no automatic retry. If a group's last result cannot be confirmed, such as a reply whose delivery is unknown, only that group is paused and answers new messages with a short notice; other groups keep working. Nothing is replayed or retried.
102
105
 
103
106
  ## 6. Console
104
107
 
105
- With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, binds new groups, and switches the model or project folder of an idle group. For LAN access, set a console password in its settings first, then enable LAN access.
108
+ With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, starts a new conversation for an idle group, binds new groups, switches the model or project folder of an idle group, and adds or configures models. For LAN access, set a console password in its settings first, then enable LAN access.
106
109
 
107
- Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. Upgrades are not announced in groups.
110
+ Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. A window shows each step, reconnects by itself while the service restarts and can be left running in the background. Upgrades are not announced in groups.
108
111
 
109
112
  You can also add a group without the console: add it to the configuration, stop the service and run `start` again; the new group is registered automatically. Bound groups cannot be removed or renamed by editing the configuration.
110
113
 
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.3
1
+ # 用 AI 助手安装 SintBot 1.4.5
2
2
 
3
3
  [English](../AI-ASSISTED-INSTALL.md) · 简体中文
4
4
 
@@ -15,5 +15,5 @@
15
15
  ## 可复制的请求
16
16
 
17
17
  ```text
18
- 按一行安装指南,在这台 Mac 安装 SintBot 1.4.3。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.5。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.3
1
+ # 安装 SintBot 1.4.5
2
2
 
3
3
  [English](../install.md) · 简体中文
4
4
 
@@ -10,7 +10,7 @@
10
10
  curl -fsSL https://sintbot.com/install.sh | bash
11
11
  ```
12
12
 
13
- 安装器会自动检查环境,把 `sintbot@1.4.3` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.3` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.5` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.5` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -90,21 +90,24 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
90
90
 
91
91
  | 命令 | 作用 |
92
92
  | --- | --- |
93
- | `/help` | 查看可用命令 |
94
93
  | `/status` | 本群状态:模型、项目、是否在忙(在忙时含步数和正在做什么)、排队数 |
94
+ | `/skip` | 只放弃当前任务,排队的接着处理,之前的对话保留 |
95
+ | `/stop` | 停止当前任务,连同排队的一起取消;之前的对话保留 |
96
+ | `/new` | 忘掉之前的对话,开始新的一段(只在本群空闲时生效) |
95
97
  | `/model` | 查看可选模型;`/model 名字` 切换(只在本群空闲时生效,切换后开始新的一段对话) |
96
- | `/new` | 开始新的一段对话(只在本群空闲时生效) |
97
- | `/stop` | 停止当前任务,连同排队的一起取消 |
98
98
  | `/hud` | 当前模型的额度与本段对话的用量 |
99
99
  | `/issue 一句话` | 记下问题反馈,在控制台概览里查看和标记处理 |
100
+ | `/help` | 查看可用命令,以及本群的项目和模型 |
101
+
102
+ 命令单独发一条;除 `/issue` 和 `/model` 外,后面不要跟别的字。如果飞书应用还订阅了消息撤回事件(控制台接入引导里建议订阅),撤回自己发的消息即取消那件事:还在排队的直接丢弃;当前任务只由这一条触发时会停下;Codex 群里撤回已补充进当前任务的那条,会连当前任务一起取消。
100
103
 
101
104
  任务进行中再发的消息:Codex 群(`codexSteer: true`)会补充进当前任务,Claude Code 群会排队,等这一轮结束再处理。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
102
105
 
103
106
  ## 6. 控制台
104
107
 
105
- 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、绑定新群、给空闲的群切换模型或项目目录。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
108
+ 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
106
109
 
107
- 「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级不在群里播报。
110
+ 「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级不在群里播报。
108
111
 
109
112
  不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
110
113
 
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.3
5
+ version=1.4.5
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",
3
+ "version": "1.4.5",
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": {
@@ -9,7 +9,11 @@
9
9
  // machine. Both return best-effort and never throw.
10
10
 
11
11
  const { spawn } = require('node:child_process')
12
+ const fs = require('node:fs')
13
+ const os = require('node:os')
14
+ const path = require('node:path')
12
15
  const { IS_WIN, resolveSpawn } = require('./winexec')
16
+ const { classifyError } = require('./privatecore') // 服务端拒绝原话 → 账号档(与出错轮同一张判据表,判据在闭源核心)
13
17
 
14
18
  // `codex login status` output → { loggedIn, mode }. The line typically reads
15
19
  // "Logged in using ChatGPT" (subscription) or mentions an API key.
@@ -70,17 +74,25 @@ function killTree(child) {
70
74
  // (never throws). Cache results upstream — each call spawns a process and may hit
71
75
  // the ChatGPT backend.
72
76
  function appServerCall(bin, method, params, mapFn, timeoutMs = 12000) {
77
+ return appServerRaw(bin, method, params, timeoutMs).then((r) => (r && r.result !== undefined ? mapFn(r.result) : null))
78
+ }
79
+
80
+ // 同一条调用的**原始结果**:{ result } | { error:{code,message} } | { timedOut:true } | { spawnError:string }。
81
+ // 为什么要分开:服务端拒绝(401 令牌已吊销)和探不到(超时 / 没装)对调用方意义相反 —— 前者是「账号不可用」的铁证,
82
+ // 后者什么都不能说明。只有 null 一种失败的话,控制台和 doctor 共用的登录态探针(下面的 authProbe / cachedAuthProbe)
83
+ // 就分不出「令牌已被吊销」和「这会儿没探到」。
84
+ function appServerRaw(bin, method, params, timeoutMs = 12000) {
73
85
  return new Promise((resolve) => {
74
86
  let child
75
87
  try {
76
88
  const { file, spawnArgs } = resolveSpawn(bin, ['app-server'])
77
89
  child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })
78
- } catch { return resolve(null) }
90
+ } catch (e) { return resolve({ spawnError: (e && e.message) || 'spawn failed' }) }
79
91
 
80
92
  let settled = false
81
93
  const finish = (v) => { if (settled) return; settled = true; clearTimeout(timer); killTree(child); resolve(v) }
82
- const timer = setTimeout(() => finish(null), timeoutMs)
83
- child.on('error', () => finish(null))
94
+ const timer = setTimeout(() => finish({ timedOut: true }), timeoutMs)
95
+ child.on('error', (e) => finish({ spawnError: e && e.code === 'ENOENT' ? `'${bin}' not found on PATH` : (e && e.message) || 'spawn error' }))
84
96
  child.stdin.on('error', () => {})
85
97
 
86
98
  const send = (obj) => { try { child.stdin.write(JSON.stringify(obj) + '\n') } catch {} }
@@ -99,7 +111,7 @@ function appServerCall(bin, method, params, mapFn, timeoutMs = 12000) {
99
111
  // server→client requests (none expected without a running turn).
100
112
  if (msg.method) continue
101
113
  if (msg.id === INIT_ID) { send({ jsonrpc: '2.0', method: 'initialized' }); send({ jsonrpc: '2.0', id: CALL_ID, method, params: params || {} }) }
102
- else if (msg.id === CALL_ID) return finish(msg.error ? null : mapFn(msg.result))
114
+ else if (msg.id === CALL_ID) return finish(msg.error ? { error: { code: msg.error.code, message: String(msg.error.message || '') } } : { result: msg.result })
103
115
  }
104
116
  })
105
117
  })
@@ -110,6 +122,119 @@ function rateLimits(bin = 'codex', timeoutMs = 12000) {
110
122
  return appServerCall(bin, 'account/rateLimits/read', {}, mapRateLimits, timeoutMs)
111
123
  }
112
124
 
125
+ // ── 登录态真判据 ──────────────────────────────────────────────────────────────────────────
126
+ // `codex login status` 只读本地文件:refresh token 被吊销后它照样打印「Logged in」,能静默坏上好几天
127
+ // (doctor / 控制台 / 它自己三处全绿,到有人用才 401)。真判据只有一条:**带着令牌去问一次服务端**。
128
+ // 额度接口(account/rateLimits/read)正好是这样一次询问 —— 零 token 消耗、2 秒;
129
+ // 令牌死了它回 401「token_revoked / invalidated oauth token」,与出错轮的账号判据(核心 classifyError)同一张表。
130
+ //
131
+ // judgeAuth(raw) 把原始结果归成三态(纯函数,单测钉死):
132
+ // { verified:true, ok:true, usage } 服务端认了这枚令牌(顺手把额度带回来)
133
+ // { verified:true, ok:false, dead:true, kind:'account', error } 服务端明确拒绝 —— 账号 / 登录态不可用
134
+ // { verified:false, ok:false, error } 探不到(超时 / 没装 / 别的错)—— **什么都不能说明**,不许当成不可用
135
+ function judgeAuth(raw) {
136
+ if (!raw) return { verified: false, ok: false, error: 'no result' }
137
+ if (raw.result !== undefined) return { verified: true, ok: true, usage: mapRateLimits(raw.result) }
138
+ if (raw.error) {
139
+ const msg = raw.error.message || ''
140
+ const q = classifyError(msg)?.quota
141
+ if (q && q.kind === 'account') return { verified: true, ok: false, dead: true, kind: 'account', error: msg.slice(0, 300) }
142
+ return { verified: false, ok: false, error: msg.slice(0, 300) || `app-server error ${raw.error.code || ''}`.trim() }
143
+ }
144
+ if (raw.timedOut) return { verified: false, ok: false, error: 'timeout' }
145
+ return { verified: false, ok: false, error: raw.spawnError || 'unknown' }
146
+ }
147
+
148
+ function authProbe(bin = 'codex', timeoutMs = 12000) {
149
+ return appServerRaw(bin, 'account/rateLimits/read', {}, timeoutMs).then(judgeAuth)
150
+ }
151
+
152
+ // TTL 缓存(与 claudestatus.cachedLoginStatus 同款):doctor 一次性,控制台 /api/state 每次动作都会重建健康行。
153
+ // 同一个 bin 同时只跑一个探针(PENDING 记在途的那一个):冷缓存时并发进来的调用(概览 / 卡片自动验证 / doctor)
154
+ // 共用一个 app-server 进程,而不是各起一个。
155
+ const CACHE = new Map() // bin -> { at, val }
156
+ const PENDING = new Map() // bin -> Promise<judgeAuth 结果>
157
+ const TTL = 60_000
158
+ // 写缓存的唯一入口(cachedAuthProbe / peekAuthProbe / 控制台验证接口都经这里,规则一致):
159
+ // 服务端拒绝过令牌(dead)之后,「探不到」(未验证)**不许**把它洗掉 —— 那只说明这会儿连不上,不说明令牌好了;
160
+ // 只有服务端再给出明确答复(认可 ⇒ 换成已验证;再拒绝 ⇒ 仍是 dead)才换。时间戳照样更新:这次探过了,
161
+ // 已知最好的答案仍是 dead,不必每次都重探。认可态被「探不到」覆盖是原语义,不变。
162
+ function storeAuthProbe(bin, val) {
163
+ const prev = CACHE.get(bin)
164
+ const keep = prev && prev.val && prev.val.dead && !(val && val.verified)
165
+ const next = keep ? prev.val : val
166
+ CACHE.set(bin, { at: Date.now(), val: next })
167
+ return next
168
+ }
169
+ function refreshAuthProbe(bin) {
170
+ let p = PENDING.get(bin)
171
+ if (!p) {
172
+ p = authProbe(bin)
173
+ .then((val) => storeAuthProbe(bin, val))
174
+ .finally(() => { if (PENDING.get(bin) === p) PENDING.delete(bin) })
175
+ PENDING.set(bin, p)
176
+ }
177
+ return p
178
+ }
179
+ async function cachedAuthProbe(bin = 'codex', ttl = TTL) {
180
+ const c = CACHE.get(bin)
181
+ if (c && Date.now() - c.at < ttl) return c.val
182
+ return refreshAuthProbe(bin)
183
+ }
184
+ // 不等探针的版本(控制台概览用):**立刻**回缓存里的结果;过期了也先给旧值,同时在后台起一次刷新(与上面共用在途表,
185
+ // 不会多起进程;出错吞掉)。只有从来没探过时才回 null(= 还不知道,调用方按「未验证」处理)。
186
+ // 为什么不等:概览每次重建都要算「可用模型」,冷缓存时等 2s、服务端连不上时等 12s 超时,控制台会像卡死一样;
187
+ // 为什么过期也给旧值:否则已知 dead 的 codex 每到过期就会被当成「还不知道」算进能用,概览一闪一闪。
188
+ function peekAuthProbe(bin = 'codex', ttl = TTL) {
189
+ const c = CACHE.get(bin)
190
+ if (c && Date.now() - c.at < ttl) return c.val
191
+ refreshAuthProbe(bin).catch(() => {})
192
+ return c ? c.val : null
193
+ }
194
+ // 让缓存全部过期(控制台额度行 ↻ 的 force 用):下次一定重探,但**不忘掉**已知结果 —— dead 仍受上面「探不到不覆盖」保护,
195
+ // peek 在重探完成前也仍给旧值。正在跑的那次探针不受影响(它本来就是「此刻」的数)。
196
+ function clearCache() { for (const c of CACHE.values()) c.at = 0 }
197
+
198
+ // 本地登录文件的离线事实(不联网):登录邮箱 / 套餐 / 上次续期 / access token 到期。
199
+ // 只做「补充说明」,不做判据 —— 令牌离到期还有好几天也照样会被服务端吊销;离线只能看出「早过期了还没续上」这一种。
200
+ // 路径:$CODEX_HOME/auth.json,否则 ~/.codex/auth.json(与 codex 自己的查找顺序一致)。
201
+ function decodeJwt(token) {
202
+ try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')) } catch { return null }
203
+ }
204
+ function decodeAuthJson(obj, now = Date.now()) {
205
+ if (!obj || typeof obj !== 'object') return { exists: false }
206
+ const t = obj.tokens || {}
207
+ const ac = decodeJwt(t.access_token), id = decodeJwt(t.id_token)
208
+ const accessExp = ac && Number.isFinite(ac.exp) ? ac.exp * 1000 : null
209
+ const auth = (id && id['https://api.openai.com/auth']) || {}
210
+ const lastRefresh = obj.last_refresh ? Date.parse(obj.last_refresh) : null
211
+ return {
212
+ exists: true,
213
+ mode: obj.auth_mode || (t.access_token ? 'chatgpt' : (obj.OPENAI_API_KEY ? 'apikey' : '')),
214
+ email: (id && id.email) || '',
215
+ plan: auth.chatgpt_plan_type || '',
216
+ lastRefresh: Number.isFinite(lastRefresh) ? lastRefresh : null,
217
+ accessExp,
218
+ expired: accessExp != null && accessExp <= now,
219
+ }
220
+ }
221
+ function readAuthFile(home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')) {
222
+ try { return decodeAuthJson(JSON.parse(fs.readFileSync(path.join(home, 'auth.json'), 'utf8'))) } catch { return { exists: false } }
223
+ }
224
+
225
+ // 三态 → 一行人话(doctor 行 / 控制台卡片共用,措辞单一事实源)。file 可选(readAuthFile 的结果),只用来补细节。
226
+ function describeLogin(s, file = null) {
227
+ const who = file && file.exists ? [file.plan, file.email].filter(Boolean).join(' · ') : ''
228
+ if (s && s.verified && s.ok) return { level: 'ok', text: '订阅登录已验证' + (who ? ' · ' + who : '') }
229
+ if (s && s.dead) return { level: 'fail', text: '登录态已失效(服务端拒绝了这台机器的令牌)— 在这台机器上重新跑 codex login' + (who ? ' · ' + who : '') }
230
+ // 原因尾巴:服务端原话里的接口地址去掉再截 60 字 —— 这行会上控制台,用户可见文案不带服务商域名
231
+ const why = s && s.error ? ' — ' + String(s.error).replace(/https?:\/\/\S+/g, '…').slice(0, 60) : ''
232
+ // 文件不在 ≠ 未登录:codex 可配成走系统钥匙串(cli_auth_credentials_store),没探到时只能说「可能」
233
+ if (file && file.exists === false) return { level: 'warn', text: '未验证 — 本机没有登录文件(可能未登录)' + why }
234
+ if (file && file.expired) return { level: 'warn', text: `未验证且令牌已过期(${new Date(file.accessExp).toISOString().slice(0, 10)}),很可能要重新登录${why}` }
235
+ return { level: 'warn', text: '未验证' + why }
236
+ }
237
+
113
238
  // model/list 响应 → 精简模型清单(过滤 hidden;efforts 兼容字符串/对象两形)。
114
239
  // 空/坏返回 → null(上游回退内置预设)。协议 v2,codex 0.144.3 真机验证(2026-07)。
115
240
  function mapModels(result) {
@@ -178,4 +303,4 @@ function formatUsage(u) {
178
303
  return parts.join(' │ ') || null
179
304
  }
180
305
 
181
- module.exports = { loginStatus, cachedLoginStatus, clearLoginCache, rateLimits, listModels, parseLoginStatus, mapRateLimits, mapModels, formatUsage, windowLabel, PRESET_MODELS }
306
+ module.exports = { loginStatus, cachedLoginStatus, clearLoginCache, rateLimits, listModels, parseLoginStatus, mapRateLimits, mapModels, formatUsage, windowLabel, PRESET_MODELS, judgeAuth, authProbe, cachedAuthProbe, peekAuthProbe, clearCache, decodeAuthJson, readAuthFile, describeLogin }
@@ -5,12 +5,24 @@ const path = require('node:path')
5
5
  const { isDeepStrictEqual } = require('node:util')
6
6
  const { atomicWriteJSON } = require('./core/store')
7
7
  const { loadInstallationFromRaw, assertJournalScope, assertRunnerExecutable,
8
- MANIFEST, VALIDATORS } = require('./c2install')
8
+ MANIFEST, VALIDATORS, SUPPORTED_RUNNERS } = require('./c2install')
9
9
  const { C2DurableJournal } = require('./core/c2durablejournal')
10
10
  const { assertNoLegacyGroupState } = require('./c2migration')
11
11
 
12
- const GLOBAL_TASK_KEYS = ['providers', 'agent', 'defaultProvider', 'timezone', 'brand']
12
+ const GLOBAL_TASK_KEYS = ['agent', 'timezone', 'brand']
13
13
  const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
14
+ const EFFORT_ENV = 'CLAUDE_CODE_EFFORT_LEVEL'
15
+
16
+ // What a group's session belongs to: backend, executable, endpoint, model and
17
+ // the environment the backend starts with. Effort, reasoning effort, tool
18
+ // quality, key, image support, visibility and order apply from the next turn.
19
+ function sessionIdentity(provider, group) {
20
+ if (!provider || typeof provider !== 'object') return null
21
+ const env = { ...(provider.env && typeof provider.env === 'object' ? provider.env : {}) }
22
+ delete env[EFFORT_ENV]
23
+ return { runner: provider.runner, bin: provider.bin || '', baseUrl: provider.baseUrl || '',
24
+ model: group?.model || provider.model || '', env }
25
+ }
14
26
 
15
27
  // Synchronous validation/application around one atomic configuration write.
16
28
  // A group-scoped edit waits only for that group to settle. Changing a group's
@@ -30,22 +42,55 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
30
42
  }
31
43
  const { cfg, routes, stateDir } = installation
32
44
  let prepared = null
45
+ let appliedResets = []
33
46
 
34
47
  function held() {
35
48
  if (assertWriterLock() !== true) throw new Error('Service writer authority is unavailable')
36
49
  }
37
- function assertSettled(chatId, binding) {
50
+ function assertSettled(chatId, binding,
51
+ message = 'Wait until the group task is settled before changing its task settings') {
38
52
  if (!binding) return
39
53
  if (binding.gate.blocked || binding.runtime.blocked ||
40
54
  [...binding.engine.rts.values()].some(rt => rt.v2NormalRouteActive ||
41
55
  rt.v2AdmissionPending || rt.v2Controller || rt.v2QueueReceiptIds?.length)) {
42
- throw new Error('Wait until the group task is settled before changing its task settings')
56
+ throw new Error(message)
43
57
  }
44
58
  assertJournalScope(binding.journal, chatId, { requireSettled: true })
45
59
  }
46
60
 
61
+ // Model library edits. A provider that is added or edited must be one the
62
+ // durable route can run; the default must stay a visible runnable model;
63
+ // a provider still used by an active group or as the default stays listed.
64
+ function assertProviderEdits(next, effective) {
65
+ const before = cfg.providers || {}, after = next.cfg.providers || {}
66
+ for (const [name, provider] of Object.entries(after)) {
67
+ if (isDeepStrictEqual(before[name], provider)) continue
68
+ if (!provider || typeof provider !== 'object' || !SUPPORTED_RUNNERS.includes(provider.runner)) {
69
+ throw new Error(`模型「${name}」的后端不受支持;只能添加或修改 Codex 或 Claude Code 模型`)
70
+ }
71
+ }
72
+ if (cfg.defaultProvider !== next.cfg.defaultProvider) {
73
+ const chosen = after[next.cfg.defaultProvider]
74
+ if (!chosen || typeof chosen !== 'object' || chosen.hidden ||
75
+ !SUPPORTED_RUNNERS.includes(chosen.runner)) {
76
+ throw new Error('默认模型须是列表中未隐藏的 Codex 或 Claude Code 模型')
77
+ }
78
+ }
79
+ for (const [name, provider] of Object.entries(before)) {
80
+ if (!provider || provider.hidden || (after[name] && !after[name].hidden)) continue
81
+ const action = after[name] ? '隐藏' : '移除'
82
+ if (name === cfg.defaultProvider) {
83
+ throw new Error(`「${name}」是默认模型,先把默认模型换成别的再${action}`)
84
+ }
85
+ const users = next.routes.filter(route => !route.paused &&
86
+ effective(route.chatId, route.group, next.cfg) === name).length
87
+ if (users) throw new Error(`「${name}」正被 ${users} 个群使用,先把这些群切到其它模型再${action}`)
88
+ }
89
+ }
90
+
47
91
  function validateConfig(raw) {
48
92
  prepared = null
93
+ appliedResets = []
49
94
  held()
50
95
  if (isFrozen()) throw new Error('SintBot is upgrading; settings cannot change until it restarts')
51
96
  const next = loadInstallationFromRaw(raw, cfg._path, { dataDir: stateDir,
@@ -67,30 +112,48 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
67
112
  if (!isDeepStrictEqual(connectionOnly(cfg.feishu), connectionOnly(next.cfg.feishu))) {
68
113
  throw new Error('Connection settings require a service restart')
69
114
  }
70
- for (const key of ['wecom', 'slack', 'proxy', 'projectsRoots', 'browser', 'providers', 'defaultProvider']) {
115
+ for (const key of ['wecom', 'slack', 'proxy', 'projectsRoots', 'browser']) {
71
116
  if (!isDeepStrictEqual(cfg[key], next.cfg[key])) throw new Error('Connection and filesystem settings require a service restart')
72
117
  }
73
118
  if (cfg.agent.maxConcurrent !== next.cfg.agent.maxConcurrent) {
74
119
  throw new Error('Changing global concurrency requires a service restart')
75
120
  }
76
- const resets = [], activations = [], changed = []
121
+ // The model a group runs: a runtime override first (as the engine
122
+ // resolves it), then the group's own provider, then the default.
123
+ const sessions = chatId => store || bindings.get(chatId)?.engine?.store || null
124
+ const effective = (chatId, group, config) =>
125
+ sessions(chatId)?.getProvider?.(chatId) || group.provider || config.defaultProvider
126
+ assertProviderEdits(next, effective)
127
+ const resets = [], activations = [], changed = [], moved = []
77
128
  const heldGroups = getHeld()
78
129
  for (const route of routes) {
79
130
  const target = next.routes.find(item => item.chatId === route.chatId)
80
131
  if (route.group.platform !== target.group.platform) {
81
132
  throw new Error('Moving a group to another platform requires an explicit state migration')
82
133
  }
134
+ // A model library or default change reaches every group that runs
135
+ // the edited provider, even though its own settings stay the same.
136
+ const providerMoved = !isDeepStrictEqual(
137
+ sessionIdentity(cfg.providers?.[effective(route.chatId, route.group, cfg)], route.group),
138
+ sessionIdentity(next.cfg.providers?.[effective(route.chatId, target.group, next.cfg)], target.group))
83
139
  if (heldGroups.has(route.chatId)) {
84
140
  // Edits elsewhere proceed; the held group keeps its exact settings.
85
141
  if (!isDeepStrictEqual(route.group, target.group)) {
86
142
  throw new Error('This group is held after an unresolved fault; seal its incident and restart before changing it')
87
143
  }
144
+ if (providerMoved) {
145
+ throw new Error('有群因未解决的故障暂停处理,它用的模型、后端或端点要等处理完并重启后才能改')
146
+ }
88
147
  continue
89
148
  }
90
149
  if (!isDeepStrictEqual(route.group, target.group)) changed.push(route.chatId)
91
- if (RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))) {
150
+ const groupReset = RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))
151
+ if (groupReset || providerMoved) {
92
152
  if (!store) throw new Error('Changing model or project requires session reset authority')
93
153
  resets.push(route.chatId)
154
+ if (!groupReset) moved.push(route.chatId)
155
+ // A reset also clears any runtime override: the group then runs
156
+ // its configured provider, which is the one probed here.
94
157
  if (!target.paused) probeRunner(target.provider, next.cfg)
95
158
  }
96
159
  if (!bindings.has(route.chatId)) {
@@ -115,6 +178,11 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
115
178
  const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
116
179
  for (const [chatId, binding] of bindings) {
117
180
  if (global || changed.includes(chatId)) assertSettled(chatId, binding)
181
+ else if (moved.includes(chatId)) {
182
+ const name = next.cfg.groups?.[chatId]?.name || chatId.slice(-8)
183
+ assertSettled(chatId, binding,
184
+ `「${name}」有任务进行中或记录未结账,等它结束后再改它用的模型、后端或端点`)
185
+ }
118
186
  }
119
187
  prepared = { raw: JSON.stringify(raw), next, resets, activations, added }
120
188
  return true
@@ -155,9 +223,15 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
155
223
  if (assertWriterLock() !== true || !prepared || prepared.raw !== JSON.stringify(raw)) {
156
224
  throw new Error('Configuration was not validated against the current service')
157
225
  }
158
- const { next, activations, added } = prepared
226
+ const { next, activations, added, resets } = prepared
159
227
  const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
228
+ // Every engine copies the model library at construction and reads it
229
+ // when a turn starts: a library edit must reach all of them, including
230
+ // busy ones (their running turn keeps the settings it started with).
231
+ const library = !isDeepStrictEqual(cfg.providers, next.cfg.providers) ||
232
+ cfg.defaultProvider !== next.cfg.defaultProvider
160
233
  prepared = null
234
+ appliedResets = [...resets]
161
235
  for (const key of Object.keys(cfg)) delete cfg[key]
162
236
  Object.assign(cfg, next.cfg)
163
237
  const bindings = getBindings()
@@ -166,7 +240,7 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
166
240
  const groupChanged = !isDeepStrictEqual(route.group, target.group)
167
241
  Object.assign(route, target)
168
242
  const engine = bindings.get(route.chatId)?.engine
169
- if (engine && (global || groupChanged)) {
243
+ if (engine && (global || groupChanged || library)) {
170
244
  engine.cfg = { ...cfg, groups: { [route.chatId]: route.group } }
171
245
  engine.tz = cfg.timezone
172
246
  }
@@ -209,7 +283,9 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
209
283
  })
210
284
  }
211
285
 
212
- return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider }
286
+ return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider,
287
+ // Groups whose session the last applied write reset (for the console).
288
+ appliedResets: () => [...appliedResets] }
213
289
  }
214
290
 
215
291
  module.exports = { createC2ConfigControl }