sintbot 1.4.4 → 1.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +9 -6
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +9 -6
- package/install.sh +1 -1
- package/package.json +1 -1
- package/src/agent/codexapp.js +49 -3
- package/src/agent/codexstatus.js +130 -5
- package/src/c2configcontrol.js +86 -10
- package/src/c2console.js +52 -4
- package/src/core/c2durableruntime.js +12 -2
- package/src/core/v2engine.js +177 -15
- package/src/ui/console.html +64 -21
- package/src/ui/serverbase.js +59 -19
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.6",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "A rejected Codex resume no longer holds the group",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "Fix: a group held when Codex rejected a resume",
|
|
9
|
+
"d": "When Codex refused to continue the previous conversation before doing any work (for example a damaged Codex session history on the machine, or a conversation whose record is gone), the whole group was paused until an administrator released it. Now the group gets one \"the last turn did not finish, please send it again\" notice, keeps serving, and starts a new conversation, so resending works."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "Fix: a queued message stalled the group after a start failure",
|
|
13
|
+
"d": "When the model could not start (program missing or resume rejected), messages queued behind it each get a resend notice; before, the next new message was treated as a busy group and the whole group was paused."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "1.4.5",
|
|
19
|
+
"date": "2026-11-09",
|
|
20
|
+
"name": "Group commands, console settings and Codex sign-in",
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"t": "New /skip in groups",
|
|
24
|
+
"d": "Drops only the current task; queued messages run next and the conversation is kept. /stop still cancels the current task and everything queued."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"t": "/help is written for each group",
|
|
28
|
+
"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."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"t": "New session from the console",
|
|
32
|
+
"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."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"t": "Models can be added and configured from the model cards",
|
|
36
|
+
"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."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"t": "Fix: tool quality and thinking depth settings could not be saved",
|
|
40
|
+
"d": "They were wrongly reported as needing a service restart."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"t": "Codex sign-in is checked with the server",
|
|
44
|
+
"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."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"t": "Fix: stopping a task before it started could swallow the next answer",
|
|
48
|
+
"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."
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
2
52
|
{
|
|
3
53
|
"version": "1.4.4",
|
|
4
54
|
"date": "2026-11-09",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.6",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "Codex 续接被拒不再挂群",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "修复:Codex 续接被拒时整群挂起",
|
|
9
|
+
"d": "Codex 还没开始干活就拒绝接着之前的对话(比如本机 Codex 的会话历史损坏,或那段对话的记录已不存在)时,此前整个群会被暂停,要管理员人工解封。现在只回一条「上一轮没有完成,请重新发送」,群照常服务,并自动开始新的一段对话,重发即可。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "修复:任务没能启动时,排在后面的消息让群卡住",
|
|
13
|
+
"d": "模型没能启动(找不到程序或续接被拒)时,排在后面的消息也各收到一条重发提醒;此前之后的新消息会被当成「群里有任务」而整群暂停。"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "1.4.5",
|
|
19
|
+
"date": "2026-11-09",
|
|
20
|
+
"name": "群命令、控制台设置与 Codex 登录态",
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"t": "群里新增 /skip",
|
|
24
|
+
"d": "只放弃手头这件,排队的消息接着处理,之前聊的还记得;/stop 仍是手头和排队的全取消。"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"t": "/help 按本群生成",
|
|
28
|
+
"d": "开头写明本群绑的项目和用的模型;命令按着急程度排;/skip、/stop、/new 用同一组说法讲清排队的和之前聊的会怎样;并说明撤回自己发的消息可以取消那件事。"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"t": "控制台可以开新会话",
|
|
32
|
+
"d": "群空闲且记录全部结账时立即开始新的一段对话;群里有任务时会提示先停止或等它结束,不会假装已重置。"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"t": "模型卡片可在线添加和设置模型",
|
|
36
|
+
"d": "不用重启服务。改型号、后端或端点时,用它的群须空闲,并自动开始新的一段对话;默认模型和正在使用的模型不能隐藏。"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"t": "修复:模型的工具质量、思考深度设置保存失败",
|
|
40
|
+
"d": "此前被误报「需要重启服务」而无法保存。"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"t": "Codex 登录态改由服务端判定",
|
|
44
|
+
"d": "控制台的 Codex 模型卡片不再只看本机登录文件:会带着令牌问一次服务端,按三种情况显示 —— 已验证、登录态已失效(要在这台机器上重新跑 codex login)、未验证(暂时探不到,不当成未登录)。令牌被吊销后不会再一直显示已登录。"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"t": "修复:任务还没启动就被停止时,下一条任务的回答可能被丢掉",
|
|
48
|
+
"d": "控制台停止或 /skip 发出后,若模型进程实际没有启动成功,此前会留下「已取消」标记,让下一条任务的回答静默消失。"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
2
52
|
{
|
|
3
53
|
"version": "1.4.4",
|
|
4
54
|
"date": "2026-11-09",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.6 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.
|
|
18
|
+
Install SintBot 1.4.6 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.
|
|
1
|
+
# Install SintBot 1.4.6
|
|
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.
|
|
13
|
+
The installer checks the environment, installs `sintbot@1.4.6` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.6`. 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,19 +90,22 @@ 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,
|
|
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
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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 用 AI 助手安装 SintBot 1.4.
|
|
1
|
+
# 用 AI 助手安装 SintBot 1.4.6
|
|
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.
|
|
18
|
+
按一行安装指南,在这台 Mac 安装 SintBot 1.4.6。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
|
|
19
19
|
```
|
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.6
|
|
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.
|
|
13
|
+
安装器会自动检查环境,把 `sintbot@1.4.6` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.6` 就可以继续。不需要 sudo。
|
|
14
14
|
|
|
15
15
|
可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
|
|
16
16
|
|
|
@@ -90,19 +90,22 @@ 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
110
|
「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级不在群里播报。
|
|
108
111
|
|
package/install.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# The function is parsed in full before installation starts, including when piped.
|
|
3
3
|
sintbot_install() (
|
|
4
4
|
set -eu
|
|
5
|
-
version=1.4.
|
|
5
|
+
version=1.4.6
|
|
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
package/src/agent/codexapp.js
CHANGED
|
@@ -66,11 +66,12 @@ function runTurn(opts = {}) {
|
|
|
66
66
|
const {
|
|
67
67
|
bin = 'codex', prompt, provider = {}, model, workdir, sessionId,
|
|
68
68
|
permissionMode, autocompactTokens, appendSystemPrompt, codexApp, codexProjectDocFallback, extraEnv,
|
|
69
|
-
onEvent, onDurableEvent, onSteerReady, onSteerLanded, onTurnAccepted, signal, log = () => {},
|
|
69
|
+
onEvent, onDurableEvent, onSteerReady, onSteerLanded, onTurnAccepted, onSpawnFailed, signal, log = () => {},
|
|
70
70
|
softStopGraceMs = 10000, spawnFn = spawn,
|
|
71
71
|
} = opts
|
|
72
72
|
if (!prompt) return Promise.reject(new Error('runTurn: prompt required'))
|
|
73
73
|
if (onDurableEvent !== undefined && typeof onDurableEvent !== 'function') return Promise.reject(new TypeError('onDurableEvent must be a function'))
|
|
74
|
+
if (onSpawnFailed !== undefined && typeof onSpawnFailed !== 'function') return Promise.reject(new TypeError('onSpawnFailed must be a function'))
|
|
74
75
|
|
|
75
76
|
return new Promise((resolve, reject) => {
|
|
76
77
|
const env = { ...process.env, ...(extraEnv || {}) }
|
|
@@ -104,6 +105,12 @@ function runTurn(opts = {}) {
|
|
|
104
105
|
let retriedEffort = false
|
|
105
106
|
let pendingTurnStart = false
|
|
106
107
|
let acceptedTurnId = null
|
|
108
|
+
// Pre-turn rejection witness: the server answered initialize or the
|
|
109
|
+
// thread request with an RPC error before any turn/start was written and
|
|
110
|
+
// before any durable event. Only the child's own close completes it.
|
|
111
|
+
let turnStartWritten = false
|
|
112
|
+
let durableEmitted = false
|
|
113
|
+
let preTurnRejection = null
|
|
107
114
|
const deferredTurnNotices = []
|
|
108
115
|
let deferredInit = null
|
|
109
116
|
const earlyCompleted = new Map()
|
|
@@ -119,6 +126,7 @@ function runTurn(opts = {}) {
|
|
|
119
126
|
const emit = (ev) => {
|
|
120
127
|
if (!ev) return
|
|
121
128
|
if (!onDurableEvent) { legacyEmit(ev); return }
|
|
129
|
+
durableEmitted = true
|
|
122
130
|
durableWork = durableWork.then(async () => {
|
|
123
131
|
let timer
|
|
124
132
|
try {
|
|
@@ -210,6 +218,37 @@ function runTurn(opts = {}) {
|
|
|
210
218
|
killChild()
|
|
211
219
|
settle(() => reject(error))
|
|
212
220
|
}
|
|
221
|
+
// A durable turn whose initialize or thread request the server itself
|
|
222
|
+
// rejected never wrote turn/start and emitted nothing, so no model turn
|
|
223
|
+
// and no tool ran. Stop reading, let the child exit, and report the
|
|
224
|
+
// narrow witness only from the child's close event.
|
|
225
|
+
const canWitnessPreTurnRejection = (error) => !!onDurableEvent && !!onTurnAccepted &&
|
|
226
|
+
!!onSpawnFailed && error?.codexRpcError === true && !turnStartWritten &&
|
|
227
|
+
!pendingTurnStart && !acceptedTurnId && !activeTurnId && !durableEmitted &&
|
|
228
|
+
!aborted && !completed && !durableFailed && !settled && !childCloseObserved
|
|
229
|
+
const rejectBeforeTurn = (error) => {
|
|
230
|
+
preTurnRejection = error
|
|
231
|
+
durableFailed = true
|
|
232
|
+
completed = true
|
|
233
|
+
deactivate()
|
|
234
|
+
try { child.stdin.end() } catch {}
|
|
235
|
+
child.stdout.resume?.()
|
|
236
|
+
killChild()
|
|
237
|
+
}
|
|
238
|
+
const witnessPreTurnRejection = () => settle(() => {
|
|
239
|
+
const message = String(preTurnRejection?.message || 'Codex RPC error')
|
|
240
|
+
let acknowledged
|
|
241
|
+
try {
|
|
242
|
+
acknowledged = onSpawnFailed({ backend: 'codex-app-server', code: 'THREAD_REJECTED',
|
|
243
|
+
turnStarted: false, closed: true })
|
|
244
|
+
} catch (witnessError) { reject(closedError(witnessError)); return }
|
|
245
|
+
if (acknowledged !== true) {
|
|
246
|
+
reject(closedError(new Error('Codex pre-turn rejection witness was not confirmed synchronously')))
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
log(`codex app-server rejected the thread before any turn started: ${message.slice(0, 300)}`)
|
|
250
|
+
reject(closedError(Object.assign(new Error(message), { code: 'AGENT_THREAD_REJECTED' })))
|
|
251
|
+
})
|
|
213
252
|
const onAbort = () => {
|
|
214
253
|
aborted = true
|
|
215
254
|
deactivate()
|
|
@@ -250,6 +289,7 @@ function runTurn(opts = {}) {
|
|
|
250
289
|
isResume: !!sessionId, prompt, appendSystemPrompt, codexApp,
|
|
251
290
|
model, providerModel: provider.model, effort, workdir: workdir || process.cwd(), permissionMode })
|
|
252
291
|
pendingTurnStart = true
|
|
292
|
+
turnStartWritten = true
|
|
253
293
|
let turn
|
|
254
294
|
try { turn = await request(turnRequest.method, turnRequest.params) }
|
|
255
295
|
finally { pendingTurnStart = false }
|
|
@@ -328,6 +368,7 @@ function runTurn(opts = {}) {
|
|
|
328
368
|
// 这段回调不在任何 Promise 链里,抛错会直接带走整个进程 ⇒ 核心失败只结束当前轮。
|
|
329
369
|
// 已完成的轮 fatal 会直接返回,结果不受影响。
|
|
330
370
|
const handleLine = (raw) => {
|
|
371
|
+
if (preTurnRejection) return // the turn never started; later output is discarded
|
|
331
372
|
// 对方主动发来的请求同时带 id 和 method:id 是对方的号段,可能与我们的请求相撞,绝不能当响应处理;
|
|
332
373
|
// 不回应它就会一直等,这一轮挂到超时。无人值守,按协议一律拒绝。
|
|
333
374
|
if (raw.id != null && raw.method) {
|
|
@@ -338,7 +379,7 @@ function runTurn(opts = {}) {
|
|
|
338
379
|
}
|
|
339
380
|
if (raw.id != null) {
|
|
340
381
|
const p = requests.get(raw.id)
|
|
341
|
-
if (p) { requests.delete(raw.id); raw.error ? p.fail(Object.assign(new Error(raw.error.message || 'Codex RPC error'), { codexRpcRejected: [-32600, -32601, -32602].includes(raw.error.code) })) : p.ok(raw.result) }
|
|
382
|
+
if (p) { requests.delete(raw.id); raw.error ? p.fail(Object.assign(new Error(raw.error.message || 'Codex RPC error'), { codexRpcError: true, codexRpcRejected: [-32600, -32601, -32602].includes(raw.error.code) })) : p.ok(raw.result) }
|
|
342
383
|
return
|
|
343
384
|
}
|
|
344
385
|
if (onTurnAccepted && pendingTurnStart && !acceptedTurnId) {
|
|
@@ -431,6 +472,7 @@ function runTurn(opts = {}) {
|
|
|
431
472
|
}
|
|
432
473
|
}
|
|
433
474
|
child.stdout.on('data', (chunk) => {
|
|
475
|
+
if (preTurnRejection) return // keep the pipe flowing so the child's close is observed
|
|
434
476
|
if (!onDurableEvent) { processChunk(chunk).catch(fatal); return }
|
|
435
477
|
child.stdout.pause?.()
|
|
436
478
|
streamWork = streamWork.then(() => processChunk(chunk)).catch(failDurableEvent).finally(() => {
|
|
@@ -456,11 +498,15 @@ function runTurn(opts = {}) {
|
|
|
456
498
|
})
|
|
457
499
|
child.on('close', (code) => {
|
|
458
500
|
childCloseObserved = true
|
|
501
|
+
if (preTurnRejection) { witnessPreTurnRejection(); return }
|
|
459
502
|
if (onDurableEvent) streamWork.then(() => durableWork).then(() => { if (!durableFailed) completeClose(code) }, failDurableEvent)
|
|
460
503
|
else completeClose(code)
|
|
461
504
|
})
|
|
462
505
|
start().catch((error) => {
|
|
463
|
-
if (onDurableEvent) {
|
|
506
|
+
if (onDurableEvent) {
|
|
507
|
+
if (canWitnessPreTurnRejection(error)) { rejectBeforeTurn(error); return }
|
|
508
|
+
failDurableEvent(error); return
|
|
509
|
+
}
|
|
464
510
|
if (onTurnAccepted && !acceptedTurnId && !settled) {
|
|
465
511
|
aborted = true
|
|
466
512
|
completed = true
|
package/src/agent/codexstatus.js
CHANGED
|
@@ -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(
|
|
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(
|
|
83
|
-
child.on('error', () => finish(
|
|
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 ?
|
|
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 }
|