sintbot 1.4.12 → 1.4.13

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,43 @@
1
1
  [
2
+ {
3
+ "version": "1.4.13",
4
+ "date": "2026-11-10",
5
+ "name": "Reliability and task follow-up improvements",
6
+ "items": [
7
+ {
8
+ "t": "Restore Claude Code health information",
9
+ "d": "Visible Claude Code models show executor version and probe results. Hidden models do not trigger extra probes."
10
+ },
11
+ {
12
+ "t": "Retry failed upgrades safely",
13
+ "d": "Keep failed installations for inspection. Retry in a separate directory and verify before switching, without overwriting existing installations."
14
+ },
15
+ {
16
+ "t": "Align runtime version, model and capability information",
17
+ "d": "Each turn receives the running service version and its exact tool entry. Distinguish service, global installation and available updates; consistently resolve group model overrides and defaults."
18
+ },
19
+ {
20
+ "t": "Read locally retained group replies",
21
+ "d": "Look up this group’s recorded replies by source message or turn, with explicit provenance, status, truncation and retention limits. Unreadable Feishu cards are never reconstructed by guesswork."
22
+ },
23
+ {
24
+ "t": "Apply concurrency and maintenance checks to trials",
25
+ "d": "Allow one console trial within shared model capacity. Refuse starts during maintenance, abort and skip saving after writer loss, and retain capacity when process closure is unknown."
26
+ },
27
+ {
28
+ "t": "Handle early timeout callbacks",
29
+ "d": "If a timer callback arrives before the deadline, wait for the remaining interval. Abort only with elapsed-time evidence so a normal timeout is not misclassified as a fault."
30
+ },
31
+ {
32
+ "t": "Send console messages while a task is running",
33
+ "d": "Project messages use the same followup or queue path as group messages. The console distinguishes acceptance, waiting for the model to read, and confirmed joining; repeated submissions are deduplicated."
34
+ },
35
+ {
36
+ "t": "Continue output after moving a reply card",
37
+ "d": "Busy follow-ups preserve already displayed text on the relocated card, then stream subsequent output without replaying the old text. Updates arriving during the move and the final answer remain ordered."
38
+ }
39
+ ]
40
+ },
2
41
  {
3
42
  "version": "1.4.12",
4
43
  "date": "2026-11-10",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,43 @@
1
1
  [
2
+ {
3
+ "version": "1.4.13",
4
+ "date": "2026-11-10",
5
+ "name": "可靠性与任务跟进体验",
6
+ "items": [
7
+ {
8
+ "t": "恢复 Claude Code 健康信息",
9
+ "d": "可见的 Claude Code 模型显示执行器版本与探测结果;主动隐藏的模型不额外探测。"
10
+ },
11
+ {
12
+ "t": "升级失败可重新尝试",
13
+ "d": "失败安装保留供排查;重试使用独立目录,校验后才能切换,不覆盖已有安装。"
14
+ },
15
+ {
16
+ "t": "统一运行版本、模型与能力说明",
17
+ "d": "每轮注入当前服务版本和准确工具入口,区分服务、全局安装与可用更新;群模型覆盖和执行器默认值统一展示。"
18
+ },
19
+ {
20
+ "t": "读取本群已保存答复",
21
+ "d": "可按消息或轮次读取本机保存的答复,明确来源、状态、截断与保留范围,不补造不可读的飞书卡片。"
22
+ },
23
+ {
24
+ "t": "试运行纳入并发与维护准入",
25
+ "d": "最多一项控制台试运行,共享模型并发名额;维护时拒绝启动,失去写入权限后中止且不保存结果,进程关闭未知时保留名额。"
26
+ },
27
+ {
28
+ "t": "修正硬超时提前触发",
29
+ "d": "计时回调早于截止时间时继续等待;获得超时证明后才中止任务,避免正常超时被误判为异常收尾。"
30
+ },
31
+ {
32
+ "t": "任务进行中可从控制台插话",
33
+ "d": "群项目列表不再因任务忙碌禁用插话;沿用群内跟进或排队流程,区分已受理、等待模型读取与已加入,重复提交不重复处理。"
34
+ },
35
+ {
36
+ "t": "卡片移位后接续输出",
37
+ "d": "收到忙时补充后,新卡直接保留已显示内容,后续输出继续追加,避免旧内容从头打字;移位期间的新内容和最终结果保持顺序。"
38
+ }
39
+ ]
40
+ },
2
41
  {
3
42
  "version": "1.4.12",
4
43
  "date": "2026-11-10",
package/bin/sintbot-v2.js CHANGED
@@ -254,6 +254,8 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
254
254
  },
255
255
  durableV2UsageProbe: usageProbe, durableV2Issues: issues,
256
256
  durableV2Version: require('../package.json').version,
257
+ durableV2DataDir: stateDir,
258
+ durableV2ConsoleEnabled: uiEnabled,
257
259
  durableV2NormalRoute: true, durableV2StreamFrames: streamFrames(cfg) })
258
260
  return { journal, gate, runtime, engine, handles }
259
261
  } catch (error) { gate.stop(); throw error }
@@ -292,6 +294,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
292
294
  configPath: cfg._path, stateDir, host: uiHost, activity, store,
293
295
  getConfig: () => cfg, getBindings: () => service.bindings,
294
296
  getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
297
+ acquireCapacity: options => capacity.acquire(options),
295
298
  getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
296
299
  // Console injection enters through the same ingress as a group
297
300
  // message, posted and answered with that group's own adapter.
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.12 with an AI assistant
1
+ # Install SintBot 1.4.13 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.12 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.13 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.12
1
+ # Install SintBot 1.4.13
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.12` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.12`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.13` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.13`. 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
 
@@ -103,13 +103,21 @@ Send a command as a message of its own; except for `/issue` and `/model`, do not
103
103
 
104
104
  Busy follow-ups default on for both Codex and Claude Code. Set `steer: false` to queue them instead; an explicit `steer` value overrides the legacy Codex setting. Claude Code must advertise the required input capabilities, otherwise messages stay queued. Reading can wait until a long-running tool finishes. The reply card follows confirmed reading, and continuation answers are combined into one final reply. 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.
105
105
 
106
+ When the reply card moves below a confirmed follow-up, its existing text is shown immediately and subsequent output continues from that point. Moving the card does not restart the task.
107
+
106
108
  ## 6. Console
107
109
 
108
110
  The overview combines group projects and concurrency. Each row shows the runner, state and elapsed time, project folder, model and configured reasoning effort, and the latest reported context usage. Select a group to view its tasks, then expand a task for tools, reply and usage. Context is a session sample, not cumulative token consumption; unknown capacity and older samples are labeled. Row icons provide console messages, stop and new-session controls according to the current group state.
109
111
 
110
112
  With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group, 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.
111
113
 
112
- 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. On the first start after an upgrade, each serving group gets one card listing what changed; to turn this off, clear "Announce upgrades in groups" under Settings → Software updates.
114
+ The Message action also works while a group is busy. It follows the group’s follow-up setting: the message waits for model reading or joins the queue. “Accepted” does not yet confirm that the model has read it. New sessions and model changes still require an idle group.
115
+
116
+ 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 an isolated release directory 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. On the first start after an upgrade, each serving group gets one card listing what changed; to turn this off, clear "Announce upgrades in groups" under The Message action also works while a group is busy. It follows the group’s follow-up setting: the message waits for model reading or joins the queue. “Accepted” does not yet confirm that the model has read it. New sessions and model changes still require an idle group.
117
+
118
+ Settings → Software updates.
119
+
120
+ If an installation fails, retry uses a fresh directory and preserves the failed attempt for diagnosis. It switches only after verification.
113
121
 
114
122
  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.
115
123
 
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.12
1
+ # 用 AI 助手安装 SintBot 1.4.13
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.12。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.13。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.12
1
+ # 安装 SintBot 1.4.13
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.12` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.12` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.13` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.13` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -103,16 +103,22 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
103
103
 
104
104
  Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队,显式 `steer` 优先于旧 Codex 开关。Claude Code 未提供所需输入能力时保留排队。长工具调用可能延迟读取;确认读取后回复卡才跟随补话,续轮答案合并为一条最终回复。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
105
105
 
106
+ 回复卡移到已确认读取的补话下方时,会立即保留已有文字,后续输出继续更新;移动卡片不会重新启动任务。
107
+
106
108
  ## 6. 控制台
107
109
 
108
110
  概览合并群项目与并发信息,每行显示执行器、状态与耗时、项目目录、模型和配置的思考深度、最近上报的上下文用量。选择群后查看对应任务,展开任务查看工具、回复和用量。上下文是当前会话采样,不是累计消耗;容量未知或历史采样会明确标注。群行图标按当前状态提供插话、停止与新会话操作。
109
111
 
110
112
  带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
111
113
 
112
- 「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级后首次启动会在每个在服务的群发一张版本公告卡,列出这一版的更新;不想播报就在「设置 → 软件更新」取消「升级后向群里播报」。
114
+ 群忙时也可以使用控制台「插话」:按本群跟进设置,等待模型读取或进入队列。「已受理」不代表模型已经读取。新会话与模型切换仍需等待群空闲。
115
+
116
+ 「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到独立的发行目录并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级后首次启动会在每个在服务的群发一张版本公告卡,列出这一版的更新;不想播报就在「设置 → 软件更新」取消「升级后向群里播报」。
113
117
 
114
118
  不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
115
119
 
120
+ 升级安装失败后可重新尝试:重试使用新目录,保留失败产物供排查,校验成功后才切换。
121
+
116
122
  ## 停止与重启
117
123
 
118
124
  按 Ctrl-C 并等待退出。确认飞书环境变量已设置,再执行:
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.12
5
+ version=1.4.13
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.12",
3
+ "version": "1.4.13",
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": {
@@ -69,6 +69,7 @@
69
69
  "src/cdp.js",
70
70
  "src/config.js",
71
71
  "src/consolesay.js",
72
+ "src/consoletrials.js",
72
73
  "src/core/c2cardeffectbridge.js",
73
74
  "src/core/c2cardterminalpath.js",
74
75
  "src/core/c2durableingress.js",
@@ -113,6 +114,7 @@
113
114
  "src/feishucli.js",
114
115
  "src/gitrepo.js",
115
116
  "src/leftover.js",
117
+ "src/modelcontext.js",
116
118
  "src/platform/board.js",
117
119
  "src/platform/feishu.js",
118
120
  "src/platform/multi.js",
@@ -128,6 +130,8 @@
128
130
  "src/preview.js",
129
131
  "src/projectmemory.js",
130
132
  "src/projectsroot.js",
133
+ "src/recordedanswers.js",
134
+ "src/runtimefacts.js",
131
135
  "src/setupmodels.js",
132
136
  "src/ui/auth.js",
133
137
  "src/ui/browser.html",
@@ -229,6 +229,8 @@ function runTurn({
229
229
  // The binary owns the permission, tool allow/deny and argument policy.
230
230
  // Node only resolves capability and writes the project-scoped MCP file.
231
231
  const args = claudeBuildArgs({ steer, model, providerModel: provider.model, sessionId,
232
+ runtimeCommand: extraEnv?.SBOT_RUNTIME_ENTRY
233
+ ? require('../runtimefacts').runtimeCommand(extraEnv.SBOT_RUNTIME_ENTRY) : '',
232
234
  permissionMode, autocompactTokens, autocompactSupported,
233
235
  mcpConfigPath, allowedTools, pluginAllows, disallowedTools,
234
236
  appendSystemPrompt, workdir })
@@ -3,7 +3,7 @@ const fs = require('node:fs')
3
3
  const path = require('node:path')
4
4
  const { createHash } = require('node:crypto')
5
5
 
6
- const EXPECTED_SHA256 = '9c29c9123b38aa75b9c47066c809d844f938baca41779372b77c02fa7baa9989'
6
+ const EXPECTED_SHA256 = 'a7ad2bd9210906ee6f5d7893ed3d9632769b6e2dbec285c1102492e3ba1257b5'
7
7
  let bytes
8
8
  try {
9
9
  bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
Binary file
@@ -17,4 +17,6 @@ function runTurn(opts) {
17
17
  return claudeAgent.runTurn(opts)
18
18
  }
19
19
 
20
- module.exports = { runTurn, probeVersion: claudeAgent.probeVersion }
20
+ const isClosedRunnerResult = value => [claudeAgent, codexAgent, codexApp].some(agent => agent.isClosedRunnerResult(value))
21
+ const isClosedRunnerError = value => [claudeAgent, codexAgent, codexApp].some(agent => agent.isClosedRunnerError(value))
22
+ module.exports = { runTurn, probeVersion: claudeAgent.probeVersion, isClosedRunnerResult, isClosedRunnerError }
package/src/c2console.js CHANGED
@@ -30,7 +30,7 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
30
30
  // one-off agent run); it does not take a group concurrency slot.
31
31
  '/api/test',
32
32
  '/api/inbox/dismiss', '/api/issues/status',
33
- // Console injection, first version: an idle group only, the line shown in
33
+ // Console injection through the ordinary busy-followup ingress, the line shown in
34
34
  // the group and the reply delivered there. The posted line is the platform
35
35
  // message that drives the turn (see consolesay.createDurableConsoleSay).
36
36
  '/api/groups/say',
@@ -38,6 +38,8 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
38
38
  // manages this exact entry (the updater refuses every other run mode).
39
39
  '/api/update/check', '/api/update/start', '/api/update/ignore']
40
40
  const SUPPORTED_RUNNERS = ['claude', 'codex']
41
+ const { modelContext } = require('./modelcontext')
42
+ const { GROUP_COMMANDS } = require('./runtimefacts')
41
43
 
42
44
  function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(), activity = null, store = null,
43
45
  assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
@@ -45,6 +47,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
45
47
  // Console injection reaches a group only through the service's own ingress
46
48
  // and that group's real platform adapter. Without them it is refused.
47
49
  getPlatform = null, routeMessage = null, isServing = () => false, isFrozen = () => false,
50
+ acquireCapacity = null,
48
51
  log = () => {} } = {}) {
49
52
  for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
50
53
  validateConfig, applyConfig, beforeWrite, appliedResets, now, isServing, isFrozen, log]) {
@@ -58,9 +61,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
58
61
  throw new TypeError('C2 console configuration hooks must be synchronous')
59
62
  }
60
63
  const startedAt = now()
64
+ const trials = require('./consoletrials').createConsoleTrials({ getCapacity, acquireCapacity,
65
+ assertWriterLock, isServing, isFrozen })
61
66
  const capabilities = Object.freeze({ mode: 'durable-v2',
67
+ groupCommands: GROUP_COMMANDS,
62
68
  reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
63
- summary: '实时状态、认证管理、停止任务、空闲群开新会话、空闲群的控制台插话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话或插话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。插话会把这句话(标着「管理员」)和回复都发进群,群忙时不排队、也不加入当前任务;静默插话和不回复的插话暂不支持。解除绑定、移除模型、定时任务和画布暂不可用。',
69
+ summary: '实时状态、认证管理、停止任务、空闲群开新会话、支持忙时跟进的控制台插话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。插话会把这句话(标着「管理员」)和回复都发进群,群忙时按该群的忙时跟进设置尝试加入当前任务,关闭或暂不支持时排队;确认模型读取后才显示已加入;静默插话和不回复的插话暂不支持。解除绑定、移除模型、定时任务和画布暂不可用。',
64
70
  allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
65
71
  function held() {
66
72
  if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
@@ -97,7 +103,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
97
103
  binding.engine.store.resetSessionAndStats(chatId)
98
104
  return true
99
105
  }
100
- // Console injection, first version: an open, idle group only. Checked
106
+ // Console injection: an open group, idle or running. Checked
101
107
  // before the line is posted, so a refused group shows nothing, and again
102
108
  // right after; the service ingress remains the final authority.
103
109
  function sayGate(chatId) {
@@ -110,8 +116,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
110
116
  const binding = bindings().get(chatId)
111
117
  if (!binding || binding.runtime?.closed === true) return 'not-serving'
112
118
  const rt = binding.engine?.rts?.get(chatId)
113
- if (groupBusy(chatId, binding) || rt?.v2RunningJobs || rt?.v2StopControl || rt?.v2SkipWaiter ||
114
- (binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot())?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
119
+ const snapshot = binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot()
120
+ if (rt?.v2StopControl || rt?.v2SkipWaiter || snapshot?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
121
+ // A normal active turn may accept ordinary messages. Unowned pending work
122
+ // still refuses; it must not become a new execution path or bypass recovery.
123
+ if (!rt?.v2NormalRouteActive && !rt?.v2AdmissionPending && (groupBusy(chatId, binding) || rt?.v2RunningJobs)) return 'busy'
124
+ const queued = snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length || 0
125
+ if (queued >= (rt?.v2AdmissionPending ? 3 : 2)) return 'queue-full'
115
126
  return null
116
127
  }
117
128
  function receiptState(chatId, receiptId) {
@@ -120,9 +131,22 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
120
131
  if (receipts?.completed?.some(item => item.id === receiptId)) return 'completed'
121
132
  return null
122
133
  }
134
+ function locateSay(chatId, receiptId) {
135
+ const binding = bindings().get(chatId)
136
+ const rt = binding?.engine?.rts?.get(chatId)
137
+ const state = receiptState(chatId, receiptId)
138
+ if (state === 'completed') return { state: 'done' }
139
+ const steer = rt?.v2SteerEntries?.get(receiptId)
140
+ if (steer) return { state: steer.accepted && steer.landed ? 'joined' : 'following' }
141
+ const queue = (binding?.journal?.consoleSnapshot?.() || binding?.journal?.snapshot())?.state?.lanes?.queue?.[chatId]?.queue || []
142
+ const index = queue.findIndex(item => item.id === `receipt:job:${receiptId}`)
143
+ if (index >= 0) return { state: 'queued', ahead: index, join: false }
144
+ if (rt?.v2RunningJobs?.some(job => job.msg?._c2ReceiptId === receiptId)) return { state: 'started' }
145
+ return { state: 'accepted' }
146
+ }
123
147
  const say = getPlatform ? createDurableConsoleSay({ getPlatform,
124
148
  getGroup: chatId => getConfig().groups?.[chatId] || null,
125
- gate: sayGate, route: routeMessage, receiptState, log, now }) : null
149
+ gate: sayGate, route: routeMessage, receiptState, locate: locateSay, log, now }) : null
126
150
  function getLiveState() {
127
151
  const cfg = getConfig()
128
152
  const capacity = getCapacity()
@@ -198,7 +222,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
198
222
  ? { usedTokens: stats.ctx, windowTokens: null, measuredAt: null, quality: 'historical' } : null)
199
223
  return { chatId, name: group.name || chatId.slice(-8), project: path.basename(group.workdir || '') || '—',
200
224
  workdir: group.workdir || '', runner: provider.runner || 'claude',
201
- model: group.model || provider.model || provider.env?.ANTHROPIC_MODEL || providerName,
225
+ ...modelContext(group, provider),
202
226
  reasoningEffort: (provider.runner === 'codex' ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL) || '',
203
227
  paused: !!group.paused, ...live.groups[chatId], context }
204
228
  })
@@ -209,6 +233,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
209
233
  }
210
234
  return {
211
235
  capabilities,
236
+ beginTrial: controller => trials.begin(controller),
212
237
  allows: (method, pathname) => (capabilities.allowed[method] || []).includes(pathname),
213
238
  // A provider without a runner field is Claude Code, as everywhere else.
214
239
  providerSupported: provider => SUPPORTED_RUNNERS.includes(provider?.runner || 'claude'),
@@ -221,6 +246,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
221
246
  appliedResets: () => appliedResets(),
222
247
  decorateState(state) {
223
248
  state.runtimeCapabilities = capabilities
249
+ for (const group of state.groups || []) {
250
+ const cfg = getConfig(), source = cfg.groups?.[group.chatId] || {}
251
+ const provider = cfg.providers?.[group.effectiveProvider || source.provider || cfg.defaultProvider]
252
+ const model = modelContext(source, provider)
253
+ Object.assign(group, { configuredModel: model.model, modelSource: model.source, actualModel: model.actualModel })
254
+ }
224
255
  state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
225
256
  state.leftovers = { supported: false, rows: [], error: null }
226
257
  },
package/src/c2updater.js CHANGED
@@ -289,16 +289,24 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
289
289
  const blocked = gate()
290
290
  if (blocked.includes('held')) return fail('有暂停的群,升级已取消;先处理暂停的群')
291
291
  if (blocked.length) { schedule(); return status() }
292
- const prefix = path.join(releasesRoot, job.to)
292
+ let prefix = job.installed ? job.prefix : path.join(releasesRoot, job.to)
293
293
  if (!job.installed) {
294
294
  setJob({ state: 'installing', prefix }, `正在安装 v${job.to}`)
295
295
  try {
296
+ let reusable = false
296
297
  if (fs.existsSync(prefix)) {
297
- // A directory left by an earlier attempt or a manual install is reused
298
- // only when it verifies as exactly this version.
299
- await verifyInstall({ version: job.to, prefix })
300
- } else {
298
+ // Never repair an existing tree in place: another process may still
299
+ // use it. A failed check leaves it intact as evidence.
300
+ try { await verifyInstall({ version: job.to, prefix }); reusable = true }
301
+ catch { /* retry in an independent installation directory */ }
302
+ }
303
+ if (!reusable) {
301
304
  fs.mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
305
+ // The directory becomes eligible for switching only after validation.
306
+ // Keeping its unique path also avoids overwriting a concurrently
307
+ // installed or running release when a download is retried.
308
+ prefix = fs.mkdtempSync(path.join(releasesRoot, `${job.to}-`))
309
+ setJob({ prefix })
302
310
  await installVersion({ version: job.to, prefix, registry: job.registry })
303
311
  await verifyInstall({ version: job.to, prefix })
304
312
  }
package/src/consolesay.js CHANGED
@@ -86,7 +86,7 @@ function createConsoleSay({ getPlatform, getGroup, handle, locate, log = () => {
86
86
  }
87
87
  }
88
88
 
89
- // ── 持久处理流程(sintbot-v2)的插话,第 1 版:只接「空闲群 · 这句话进群 · 回复进群」 ──
89
+ // ── 持久处理流程(sintbot-v2)的插话,这句话与回复进群,忙时沿用普通群消息的跟进/排队流程 ──
90
90
  //
91
91
  // 群里那行「【管理员】…」就是驱动这一轮的那条平台消息:它的 om_ 消息 id 就是这笔任务的来源身份
92
92
  // (source 'message'),与群成员自己发的一条消息同形 —— 收据、回复锚点、/stop、重启后的中断告知
@@ -97,7 +97,6 @@ function createConsoleSay({ getPlatform, getGroup, handle, locate, log = () => {
97
97
  // 第 1 版不做的,都在发群消息**之前**就拒,群里不留痕:
98
98
  // · 静默插话(这句不进群):没有平台消息可锚,需要另一种来源和重启告知路径;
99
99
  // · 不回复的插话(quiet):持久路径还不接静默收据;
100
- // · 群正忙:不排队、也不加入当前任务;
101
100
  // · 斜杠命令:群命令不走插话(停止、开新会话在概览里有按钮)。
102
101
  //
103
102
  // requestId:控制台每次提交带的 32 位十六进制幂等键(经 syntheticSource 校验并折成键)。发出那行字
@@ -128,9 +127,10 @@ function refusalOf(value) {
128
127
  // route(msg, platform) 服务的统一入口(routeMessage):暂停 / 待人工 / 收尾中 / 升级中都在那里把关
129
128
  // receiptState(chatId, id) 该群日志里这笔收据:'pending' | 'completed' | null
130
129
  function createDurableConsoleSay({ getPlatform, getGroup, gate, route, receiptState,
130
+ locate = () => ({ state: 'accepted' }),
131
131
  log = () => {}, now = () => Date.now(), sendTimeoutMs = SEND_TIMEOUT_MS,
132
132
  confirmMs = CONFIRM_MS, pollMs = POLL_MS, maxRequests = MAX_REQUESTS } = {}) {
133
- for (const hook of [getPlatform, getGroup, gate, route, receiptState, log, now]) {
133
+ for (const hook of [getPlatform, getGroup, gate, route, receiptState, locate, log, now]) {
134
134
  if (typeof hook !== 'function') throw new TypeError('durable console say needs explicit hooks')
135
135
  }
136
136
  const posted = new Map() // 请求键 → 群里那行字的消息 id(按插入顺序,超过上限丢最早的)
@@ -171,7 +171,7 @@ function createDurableConsoleSay({ getPlatform, getGroup, gate, route, receiptSt
171
171
  return { ok: false, reason: 'shown-failed' }
172
172
  }
173
173
  remember(key, shownId)
174
- // 发这行字的这会儿群里可能刚开始了别的任务:此时不注入(第 1 版忙时不排队、不加入)
174
+ // 发送期间再次检查暂停、停止、维护及容量,正常忙时仍走统一入口
175
175
  const late = gate(chatId)
176
176
  if (late) return { ok: false, reason: late === 'busy' ? 'busy-after-post' : late }
177
177
  }
@@ -186,12 +186,12 @@ function createDurableConsoleSay({ getPlatform, getGroup, gate, route, receiptSt
186
186
  if (first.admitted || first.timeout) {
187
187
  // 这一轮在后台继续;它之后的成败由该群自己的持久路径收尾,这里只留日志
188
188
  settled.then(({ error }) => { if (error) log('say: ' + (error && error.message)) })
189
- return first.admitted ? { ok: true, where: { state: 'started' } } : { ok: false, reason: 'unconfirmed' }
189
+ return first.admitted ? { ok: true, where: locate(chatId, receiptId) } : { ok: false, reason: 'unconfirmed' }
190
190
  }
191
191
  const { value, error } = first
192
192
  if (value && value.duplicate) return { ok: true, where: { state: 'duplicate' } }
193
193
  const state = receiptState(chatId, receiptId)
194
- if (state) return { ok: true, where: { state: state === 'completed' ? 'done' : 'started' } }
194
+ if (state) return { ok: true, where: state === 'completed' ? { state: 'done' } : locate(chatId, receiptId) }
195
195
  if (error) {
196
196
  log('say: ' + (error && error.message))
197
197
  return { ok: false, reason: 'inject-failed' }
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ // One bounded console trial, sharing the real model capacity. A timeout or
4
+ // lease loss asks the runner to stop; only its close proof releases capacity.
5
+ function createConsoleTrials({ getCapacity, acquireCapacity, assertWriterLock, isServing, isFrozen }) {
6
+ let active = false
7
+ function assertWritable() {
8
+ if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,试运行结果未保存')
9
+ }
10
+ function assertAdmission() {
11
+ assertWritable()
12
+ if (!isServing() || isFrozen()) throw new Error('服务未就绪或正在维护,暂不能试运行')
13
+ }
14
+ async function begin(controller) {
15
+ assertAdmission()
16
+ const capacity = getCapacity()
17
+ if (active || capacity.closed || capacity.active + capacity.waiting >= capacity.maxConcurrent) {
18
+ throw new Error('试运行或模型并发名额已满,请稍后重试')
19
+ }
20
+ if (typeof acquireCapacity !== 'function') throw new Error('当前服务未接入试运行并发准入')
21
+ active = true
22
+ let release
23
+ try {
24
+ release = await acquireCapacity({ signal: controller.signal })
25
+ assertAdmission()
26
+ } catch (error) { active = false; release?.(); throw error }
27
+ let finished = false
28
+ const monitor = setInterval(() => {
29
+ try { assertAdmission() } catch (error) { controller.abort(error) }
30
+ }, 100)
31
+ monitor.unref?.()
32
+ return { assertWritable,
33
+ finish(closed) {
34
+ if (finished) return
35
+ finished = true; clearInterval(monitor)
36
+ // Unknown closure keeps the shared slot occupied and blocks upgrades.
37
+ if (closed) { active = false; release() }
38
+ },
39
+ }
40
+ }
41
+ return { begin, active: () => active }
42
+ }
43
+ module.exports = { createConsoleTrials }
@@ -129,7 +129,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
129
129
  const nextCtx = landedAnchor(anchor)
130
130
  if (phase !== 'open' || !card) return false
131
131
  if (activeAnchorCtx.messageId === nextCtx.messageId) return true
132
- if (!lastCardText) throw new Error('C2 card move has no visible text to replay')
132
+ if (!lastCardText) throw new Error('C2 card move has no visible text to preserve')
133
133
  phase = 'relocating'
134
134
  try {
135
135
  if (await card.flush() !== true) throw new Error('C2 old card flush unconfirmed')
@@ -138,6 +138,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
138
138
  const fresh = await platform.createStreamingCard(nextCtx, {
139
139
  operationKey: `${receiptId}:followup:${anchor.receiptId}`,
140
140
  operationBoundary,
141
+ initialText: lastCardText,
141
142
  })
142
143
  if (!fresh || typeof fresh.update !== 'function' ||
143
144
  typeof fresh.flush !== 'function' || typeof fresh.recall !== 'function') {
@@ -18,6 +18,8 @@ const { prepareC2RunnerInput } = require('./c2runnerinput')
18
18
  const { continueC2Successor } = require('./c2successorbridge')
19
19
  const { rejectC2Input } = require('./c2inputrejection')
20
20
  const progress = require('./turnprogress')
21
+ const { modelContext } = require('../modelcontext')
22
+ const { GROUP_COMMANDS, RUNTIME_ENTRY, runtimeCommand } = require('../runtimefacts')
21
23
  const NO_REPLY = 'NO_REPLY'
22
24
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
23
25
  const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
@@ -25,7 +27,7 @@ const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
25
27
  // the Codex read-only sandbox, 3 the fail-closed default. 0 is full access
26
28
  // and 2 an allow-listed tool set, which may still change files.
27
29
  const READ_ONLY_TIERS = Object.freeze([1, 3])
28
- const DURABLE_V2_COMMANDS = new Set(['/help', '/status', '/model', '/new', '/hud', '/issue'])
30
+ const DURABLE_V2_COMMANDS = new Set(GROUP_COMMANDS.filter(cmd => !['/skip', '/stop'].includes(cmd)))
29
31
  function fmtWan(n) {
30
32
  n = Number(n) || 0
31
33
  if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1).replace(/\.0$/, '')}亿`
@@ -51,8 +53,7 @@ function cardDuration(ms) {
51
53
  }
52
54
  function cardModelInfo(group, provider) {
53
55
  if (!provider) return { model: '', effort: '' }
54
- const model = group?.model || provider.model ||
55
- (provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL) || ''
56
+ const { model } = modelContext(group, provider)
56
57
  const effort = provider.runner === 'codex'
57
58
  ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
58
59
  const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
@@ -224,10 +225,11 @@ function v2QueueSelection(queue) {
224
225
  function releaseSkillLines(platform) {
225
226
  const features = manifestFeatures({ ...(platform?.features || {}),
226
227
  boards: false, imagen: false, takeoverCard: false })
227
- const lines = manifestSkills({ features,
228
+ const lines = manifestSkills({ features: { ...features, localAnswers: true },
228
229
  platformName: String(platform?.chatLabel || '飞书群').replace(/群$/, '') })
229
230
  const cron = lines.findIndex(line => line.startsWith('【定时任务】'))
230
- return cron < 0 ? lines : lines.slice(0, cron)
231
+ return (cron < 0 ? lines : lines.slice(0, cron))
232
+ .map(line => line.replace(/sbot feishu/g, `${runtimeCommand()} feishu`))
231
233
  }
232
234
 
233
235
  // The group skills call `sbot feishu`. A machine can carry another `sbot` on
@@ -256,7 +258,7 @@ class Engine {
256
258
  durableV2NormalRoute = true, durableV2StreamFrames = false,
257
259
  durableV2AcquireCapacity = null, durableV2SealCapacity = null,
258
260
  durableV2SwitchProvider = null, durableV2UsageProbe = null, durableV2Issues = null,
259
- durableV2Version = '' } = options
261
+ durableV2Version = '', durableV2DataDir = '', durableV2ConsoleEnabled = false } = options
260
262
  const sync = f => typeof f === 'function' && f.constructor?.name !== 'AsyncFunction'
261
263
  if (!cfg || !cfg.agent || !cfg.groups || !cfg.providers ||
262
264
  options.receiptJournal || options.unifiedStepHost ||
@@ -289,7 +291,8 @@ class Engine {
289
291
  durableV2StartTurn, durableV2ApplyLocalEffect, durableV2VerifyRunnerClosed,
290
292
  durableV2AssertWriterLock, durableV2Journal, durableV2NormalRoute,
291
293
  durableV2StreamFrames, durableV2AcquireCapacity, durableV2SealCapacity,
292
- durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version })
294
+ durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version,
295
+ durableV2DataDir, durableV2ConsoleEnabled })
293
296
  this.projectMemory = options.projectMemory || null
294
297
  this.repoInfo = options.repoInfo || null
295
298
  this.activity = options.activity || null
@@ -433,6 +436,8 @@ baseSkillEnv(chatId) {
433
436
  const g = (this.cfg.groups || {})[chatId]
434
437
  const bin = releaseBinDir()
435
438
  return { ...(bin ? { PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter) } : {}),
439
+ SBOT_RUNTIME_ENTRY: RUNTIME_ENTRY, SBOT_RUNTIME_VERSION: this.durableV2Version || '',
440
+ ...(this.durableV2DataDir ? { SBOT_DATA_DIR: this.durableV2DataDir } : {}),
436
441
  SBOT_CHAT_ID: chatId, ...(g && g.platform ? { SBOT_PLATFORM: g.platform } : {}), TZ: this.tz, ...(this.cfg._path ? { SBOT_CONFIG: this.cfg._path } : {}), ...(this.uiLocalUrl ? { SBOT_UI_URL: this.uiLocalUrl } : {}) }
437
442
  }
438
443
 
@@ -463,7 +468,7 @@ async _durableV2Command(msg, cmd, rest) {
463
468
  if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
464
469
  const { name, provider } = this._resolveProvider(msg.chatId, group)
465
470
  // A group may pin its own model; the runner uses it before the provider default.
466
- const label = (n, p) => (group.model || p?.model) ? `${n} · ${group.model || p.model}` : n
471
+ const label = (n, p) => { const { model } = modelContext(group, p); return model ? `${n} · ${model}` : n }
467
472
  if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
468
473
  if (cmd === '/help') {
469
474
  const isCodex = provider?.runner === 'codex'
@@ -532,12 +537,15 @@ async _durableV2Command(msg, cmd, rest) {
532
537
  _durableV2ModelCommand(msg, want, group) {
533
538
  const reject = (reason, notice) =>
534
539
  rejectC2Input('C2 v2 model command answered before receipt', { reason, notice })
535
- const label = (name, provider) => provider?.model ? `${name} · ${provider.model}` : name
540
+ const label = (name, provider, scope = {}) => {
541
+ const { model } = modelContext(scope, provider)
542
+ return model ? `${name} · ${model}` : name
543
+ }
536
544
  const available = Object.entries(this.cfg.providers || {})
537
545
  .filter(([, provider]) => provider && !provider.hidden &&
538
- ['claude', 'codex'].includes(provider.runner))
546
+ ['claude', 'codex'].includes(provider.runner || 'claude'))
539
547
  const { name: currentName, provider: currentProvider } = this._resolveProvider(msg.chatId, group)
540
- const current = label(currentName, currentProvider)
548
+ const current = label(currentName, currentProvider, group)
541
549
  const list = available.map(([name, provider]) => label(name, provider)).join(' / ')
542
550
  if (!want) {
543
551
  const example = available.find(([name]) => name !== currentName)?.[0] || currentName
@@ -1377,6 +1385,7 @@ stageDurableV2Message(msg, platform) {
1377
1385
  if (!rt.v2QueueReceiptIds) rt.v2QueueReceiptIds = []
1378
1386
  rt.v2QueueReceiptIds.push(receipt.id)
1379
1387
  this._observeActivity('receive', { chatId: msg.chatId, receiptId: receipt.id,
1388
+ sourceMessageId: msg.msgId,
1380
1389
  text: msg.text, sender: msg.senderName || '', acceptedAt })
1381
1390
  return { receiptId: receipt.id, cursor: result.cursor, staged: true,
1382
1391
  ingressClosed: !!authority.blocked }
@@ -1778,18 +1787,22 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1778
1787
  (!Number.isSafeInteger(timeoutDeadlineAt) || timeoutDeadlineAt <= 0)) {
1779
1788
  throw new Error('C2 v2 hard timeout deadline invalid')
1780
1789
  }
1781
- capTimer = timeoutDeadlineAt !== null
1782
- ? setTimeout(() => {
1790
+ const onHardDeadline = () => {
1783
1791
  const elapsedAt = Date.now()
1784
1792
  if (rt.v2RunnerGeneration !== generation ||
1785
1793
  rt.v2Controller !== controller || rt.v2CancelControl ||
1786
1794
  rt.v2StreamQuiescing) return
1787
- if (elapsedAt >= timeoutDeadlineAt) {
1788
- rt.v2TimeoutProof = { receiptId, turnToken, generation,
1789
- deadlineAt: timeoutDeadlineAt, elapsedAt }
1795
+ // Timers and Date.now need not tick together. An early callback (or
1796
+ // a backward wall-clock adjustment) is not proof of elapsed time.
1797
+ if (elapsedAt < timeoutDeadlineAt) {
1798
+ capTimer = setTimeout(onHardDeadline, timeoutDeadlineAt - elapsedAt)
1799
+ return
1790
1800
  }
1801
+ rt.v2TimeoutProof = { receiptId, turnToken, generation,
1802
+ deadlineAt: timeoutDeadlineAt, elapsedAt }
1791
1803
  controller.abort()
1792
- }, timeoutMs) : null
1804
+ }
1805
+ capTimer = timeoutDeadlineAt !== null ? setTimeout(onHardDeadline, timeoutMs) : null
1793
1806
  const input = prepareC2RunnerInput({ prompt,
1794
1807
  appendSystemPrompt: SYSTEM_APPENDIX, msg: job.msg, group: job.group,
1795
1808
  provider, config: this.cfg,
@@ -2213,21 +2226,25 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2213
2226
  const policy = manifestPolicy(group.permissionMode, provider.runner === 'codex',
2214
2227
  group.allowedTools, group.disallowedTools)
2215
2228
  const brand = this.cfg.brand || 'SintBot'
2229
+ const groupTools = group.platform === 'feishu' && manifestFeatures(platform.features || {}).skills
2216
2230
  const info = group.repo && this.repoInfo ? this.repoInfo(group.workdir, group.repo) : null
2217
2231
  const manifest = [
2218
2232
  ...manifestHeader({ restrictedV2: true, brand, chatLabel: platform.chatLabel || '项目群',
2219
2233
  chatId: msg.chatId, groupName: group.name || msg.chatId,
2220
2234
  workdir: group.workdir, repo: group.repo || '', repoState: info?.state || '',
2221
2235
  repoOrigin: info?.origin || '未知', pushDenied: policy.pushDenied,
2222
- providerName, model: group.model || provider.model || '',
2236
+ providerName, model: modelContext(group, provider).model,
2237
+ runtimeVersion: this.durableV2Version || '', runtimeCommand: runtimeCommand(),
2223
2238
  noVision: provider.vision === false, platformName: platform.chatLabel || '项目群',
2224
2239
  permissionTier: policy.tier, tz: this.tz, publicUrl: '', uiUrl: '', lanUrl: '' }),
2225
2240
  ...manifestContext({ restrictedV2: true, brand, isCodex: provider.runner === 'codex',
2241
+ groupCommands: GROUP_COMMANDS, groupTools,
2242
+ consoleEnabled: this.durableV2ConsoleEnabled === true,
2226
2243
  pmem: this.projectMemory ? this.projectMemory(group.workdir,
2227
2244
  { full: policy.full, isCodex: provider.runner === 'codex' }) : null,
2228
2245
  unredactedPii: group.redactPii === false,
2229
2246
  cardPinned: platform.features?.cardPinned === true, plainSend: false }),
2230
- ...releaseSkillLines(platform),
2247
+ ...(groupTools ? releaseSkillLines(platform) : []),
2231
2248
  ].join('\n')
2232
2249
  // Reinject current supported context each turn. No legacy feature menu,
2233
2250
  // external-message commands or v1 manifest refresh scheduler is included.
package/src/feishucli.js CHANGED
@@ -20,7 +20,7 @@ const { redact } = require('./core/redact')
20
20
  const { fmtStamp, normalizeTz } = require('./core/time')
21
21
 
22
22
  const FEISHU_MESSAGE_ACTIONS = Object.freeze(['send-msg', 'send-notice', 'send-file', 'send-image',
23
- 'history', 'reply', 'edit', 'recall', 'delete', 'download', 'react'])
23
+ 'history', 'answers', 'reply', 'edit', 'recall', 'delete', 'download', 'react'])
24
24
 
25
25
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
26
26
  const green = (s) => `\x1b[32m${s}\x1b[0m`
@@ -44,6 +44,8 @@ function parseFeishuArgs(action, rawArgs) {
44
44
  else if (a.startsWith('--chat=')) o.chat = a.slice(7)
45
45
  else if (a === '--config') o.config = rawArgs[++i]
46
46
  else if (a.startsWith('--config=')) o.config = a.slice(9)
47
+ else if (a === '--data-dir') o.dataDir = rawArgs[++i]
48
+ else if (a.startsWith('--data-dir=')) o.dataDir = a.slice(11)
47
49
  else if (action === 'send-notice' && a === '--sub') o.subtitle = rawArgs[++i]
48
50
  else if (action === 'send-notice' && a === '--tone') o.tone = rawArgs[++i]
49
51
  else if (action === 'send-notice' && a === '--warn') o.warnings.push(rawArgs[++i])
@@ -102,6 +104,15 @@ async function runFeishuAction(args, deps = {}) {
102
104
  const out = deps.stdout || ((s) => process.stdout.write(s))
103
105
  const err = deps.stderr || ((s) => process.stderr.write(s))
104
106
  const p = opts.positionals
107
+ if (action === 'answers') {
108
+ if (!cfg.groups?.[chatId]) refuse('目标群没有绑定,不能读取本机答复记录')
109
+ const { readRecordedAnswers } = require('./recordedanswers')
110
+ const result = readRecordedAnswers({ dataDir: opts.dataDir || (deps.env || process.env).SBOT_DATA_DIR,
111
+ chatId, id: p[0] || '' })
112
+ out(JSON.stringify(result, null, 2) + '\n')
113
+ if (!result.answers.length) err('未找到本群匹配记录;可能已过保留范围或尚未落盘,不能推断原卡正文。\n')
114
+ return result
115
+ }
105
116
  // Usage errors before any network call, worded as the legacy entry did.
106
117
  let notice
107
118
  if (action === 'send-msg' && !p.join(' ').trim()) refuse('缺少消息内容 — sbot feishu send-msg "<文本>"')
@@ -200,6 +211,7 @@ function feishuUsage(cmd = 'sintbot') {
200
211
  + ' send-notice "<标题>" "<正文>" 发群通知卡(可加 --sub / --tone / --warn / --from)\n'
201
212
  + ' send-file <路径> · send-image <路径> 发文件 / 图片\n'
202
213
  + ' history [n] 拉最近 n 条(默认 20,最多 50):每条带 message_id,并标 →@某某·未@bot / →@bot\n'
214
+ + ' answers [消息或轮次ID] 只读本群本机答复记录;默认最近 10 轮,不代表完整飞书历史\n'
203
215
  + ' reply <message_id> "<文本>" 回复某条\n'
204
216
  + ' edit <message_id> "<文本>" 改自己发过的消息\n'
205
217
  + ' recall <message_id> 撤回自己发过的消息(别名 delete)\n'
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ // Configuration projection shared by chat, cards and the console. A configured
4
+ // model is not proof of what an executor ultimately selected or fell back to.
5
+ function modelContext(group = {}, provider = {}) {
6
+ group ||= {}; provider ||= {}
7
+ const environment = provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL
8
+ const model = group.model || provider.model || environment || ''
9
+ return { model, source: group.model ? 'group' : provider.model ? 'provider' : environment ? 'environment' : 'default',
10
+ actualModel: null }
11
+ }
12
+
13
+ module.exports = { modelContext }
@@ -181,9 +181,10 @@ function createCardWriter({
181
181
  client, cardId, elementId = 'answer', log = () => {}, now = () => Date.now(),
182
182
  throttleMs = 0, streamWindowMs = STREAM_WINDOW_MS, setTimer = setTimeout, clearTimer = clearTimeout,
183
183
  operationBoundary = null, operationKey = null,
184
+ initialText = '', initialSequence = 1,
184
185
  } = {}) {
185
186
  const openedAt = now()
186
- let seq = 1 // 流式与整卡更新共用的**唯一**序号分配器(混用同一张卡必须同源)
187
+ let seq = initialSequence // Includes any confirmed settings call before this writer opens.
187
188
  let streamOpen = true // 飞书侧流式模式还开着吗
188
189
  // ⚠ 「已收尾」与「已冻结」是两件事,合成一个标记会吃掉一条兜底路径:撤回失败时调用方
189
190
  // 要退一句中性收尾(engine 的 dropAckCard),而 recall 若把 finish 一并封死,那张卡就
@@ -195,8 +196,9 @@ function createCardWriter({
195
196
  let pushes = 0 // 流式帧 cardElement.content
196
197
  let fulls = 0 // 整卡更新 card.update
197
198
  let closes = 0 // 关流式 card.settings
198
- let lastSeen = '' // 最近一次收到的累积文本(判重用)
199
- let confirmedText = null // 只记录平台确认过的正文,不能把已派发当成已送达
199
+ const resumes = initialText ? 1 : 0 // Confirmed pre-writer streaming enable.
200
+ let lastSeen = initialText // Seed only after the snapshot's create/post have been confirmed.
201
+ let confirmedText = initialText || null // 只记录平台确认过的正文,不能把已派发当成已送达
200
202
  let chain = Promise.resolve()
201
203
  let finishResult = null
202
204
  let boundaryError = null
@@ -363,7 +365,7 @@ function createCardWriter({
363
365
  // ⚠ 这行必须打在**收尾之后**:收尾自己那一次调用(整卡更新 / 关流式)就发生在这个
364
366
  // 回调里。原先它打在回调外、且只报节流器的派发次数 ⇒ 降级后的整卡更新与收尾那次
365
367
  // 全在账外(实测漏计 ≈44 次/天)。注释写着「配额账可实测」,那就得真能对上账。
366
- log(`card ops: ${pushes + fulls + closes} 次调用(流式 ${pushes} · 整卡 ${fulls} · 关流式 ${closes})`)
368
+ log(`card ops: ${pushes + fulls + closes + resumes} 次调用(流式 ${pushes} · 整卡 ${fulls} · 关流式 ${closes}${resumes ? ` · 恢复流式 ${resumes}` : ''})`)
367
369
  }
368
370
  }, { terminal: true })
369
371
  return finishResult
@@ -371,7 +373,7 @@ function createCardWriter({
371
373
  // recall:先断掉后续流式帧再去删消息。⚠ 只冻结不收尾 —— 撤不掉时调用方还要退一句中性收尾。
372
374
  markClosed() { frozen = true; flushedRevision = -1 },
373
375
  settle: () => chain.then((result) => { if (boundaryError) throw boundaryError; return result }),
374
- stats: () => ({ pushes, fullUpdates: fulls, closes, calls: pushes + fulls + closes, streamOpen, seq }),
376
+ stats: () => ({ pushes, fullUpdates: fulls, closes, calls: pushes + fulls + closes + resumes, streamOpen, seq }),
375
377
  }
376
378
  }
377
379
 
@@ -858,9 +860,10 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
858
860
  // effect. `finish` flips streaming_mode off so the card settles.
859
861
  // All ops are serialized so the per-card `sequence` stays strictly increasing.
860
862
  async createStreamingCard(replyCtx, { title = 'sbot', operationBoundary = null,
861
- operationKey = null } = {}) {
863
+ operationKey = null, initialText = '' } = {}) {
862
864
  const chatId = replyCtx && replyCtx.chatId
863
865
  if (!chatId) return null
866
+ if (typeof initialText !== 'string') throw new TypeError('card initial text must be a string')
864
867
  if (operationBoundary && (typeof operationBoundary !== 'function' ||
865
868
  typeof operationKey !== 'string' || !operationKey ||
866
869
  Buffer.byteLength(operationKey) > 256)) {
@@ -870,8 +873,8 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
870
873
  // No header — the reply card shows just the answer body (no "sbot" title line).
871
874
  const cardJson = {
872
875
  schema: '2.0',
873
- config: { streaming_mode: true, summary: { content: '' }, streaming_config: { print_strategy: 'fast' } },
874
- body: { elements: [{ tag: 'markdown', content: '', element_id: ELEM }] },
876
+ config: { streaming_mode: !initialText, summary: { content: '' }, streaming_config: { print_strategy: 'fast' } },
877
+ body: { elements: [{ tag: 'markdown', content: initialText, element_id: ELEM }] },
875
878
  }
876
879
 
877
880
  let cardId
@@ -900,6 +903,22 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
900
903
  const posted = await postCard(client, chatId, replyCtx && replyCtx.messageId, cardId,
901
904
  { operationBoundary, operationKey })
902
905
  msgId = posted
906
+ // A moved card is first delivered with its complete, static snapshot.
907
+ // Enable streaming only afterward: old text never enters the typewriter
908
+ // endpoint, and the next cumulative frame continues from this baseline.
909
+ if (initialText) {
910
+ const sequence = 1
911
+ const uuid = crypto.randomUUID()
912
+ await cardOperation(operationBoundary, operationKey, 'card.settings',
913
+ { cardId, sequence, uuid }, async () => {
914
+ const response = assertCardOk(await client.cardkit.v1.card.settings({
915
+ path: { card_id: cardId },
916
+ data: { settings: JSON.stringify({ config: { streaming_mode: true } }), sequence, uuid },
917
+ }), '恢复流式')
918
+ if (response?.code !== 0) throw new Error('恢复流式未确认:未返回 code=0')
919
+ return response
920
+ })
921
+ }
903
922
  } catch (e) {
904
923
  log('feishu card create error: ' + (e && e.message))
905
924
  if (operationBoundary) throw e
@@ -908,7 +927,8 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
908
927
 
909
928
  // 全部写操作交给写入器(流式窗口判断、序号分配、错误码检查都在它一处)。
910
929
  const writer = createCardWriter({ client, cardId, elementId: ELEM, log,
911
- throttleMs: cardThrottleMs, operationBoundary, operationKey })
930
+ throttleMs: cardThrottleMs, operationBoundary, operationKey,
931
+ initialText, initialSequence: initialText ? 2 : 1 })
912
932
 
913
933
  return {
914
934
  durableTarget() {
@@ -0,0 +1,27 @@
1
+ 'use strict'
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const { redact } = require('./core/redact')
5
+
6
+ // Read only the bounded observation file. Never open/rewrite execution journals
7
+ // or infer that a stored draft reached Feishu.
8
+ function readRecordedAnswers({ dataDir, chatId, id = '' }) {
9
+ if (!path.isAbsolute(dataDir || '') || !chatId) throw new Error('缺少当前服务的数据目录或群信息,无法读取本机答复记录')
10
+ const file = path.join(dataDir, 'activity-v1.json')
11
+ let data
12
+ try {
13
+ if (fs.statSync(file).size > 12 * 1024 * 1024) throw new Error('size')
14
+ data = JSON.parse(fs.readFileSync(file, 'utf8'))
15
+ if (data.version !== 1 || !Array.isArray(data.rows) || data.rows.length > 256) throw new Error('format')
16
+ } catch { throw new Error('本机答复记录不存在或不可读;不能据此判断机器人没有回复') }
17
+ const rows = data.rows.filter(row => row && row.chatId === chatId && typeof row.id === 'string' &&
18
+ (!id || row.id === id || Array.isArray(row.receiptIds) && row.receiptIds.includes(id) ||
19
+ Array.isArray(row.messages) && row.messages.some(message => message?.sourceMessageId === id)))
20
+ .sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, id ? 1 : 10)
21
+ return { source: 'local-activity-v1', retention: '全服务最多保留最近 256 轮;每轮答复最多 16384 字符;旧记录可能没有消息 ID',
22
+ chatId, answers: rows.map(row => ({ id: row.id, ts: row.ts,
23
+ status: row.status, deliveryConfirmed: row.status === 'answered',
24
+ truncated: !!row.truncated, reply: redact(String(row.reply || ''), { pii: false }).slice(0, 16384),
25
+ sourceMessageIds: (Array.isArray(row.messages) ? row.messages : []).map(message => message?.sourceMessageId).filter(id => typeof id === 'string') })) }
26
+ }
27
+ module.exports = { readRecordedAnswers }
@@ -0,0 +1,11 @@
1
+ 'use strict'
2
+
3
+ // Supported surfaces of the release host. These are facts passed to the core
4
+ // renderer, not an alternative implementation of core policy.
5
+ const GROUP_COMMANDS = Object.freeze(['/help', '/status', '/model', '/new', '/hud', '/issue', '/skip', '/stop'])
6
+ const RUNTIME_ENTRY = require('node:path').join(__dirname, '..', 'bin', 'sintbot-v2.js')
7
+ const quoteShell = value => "'" + String(value).replace(/'/g, "'\\''") + "'"
8
+ function runtimeCommand(entry = RUNTIME_ENTRY, node = process.execPath) {
9
+ return `${quoteShell(node)} ${quoteShell(entry)}`
10
+ }
11
+ module.exports = { GROUP_COMMANDS, RUNTIME_ENTRY, runtimeCommand }
@@ -2621,7 +2621,7 @@ const RUNTIME_UI_TEXT={
2621
2621
  "paused": "已暂停:不接收本群新消息,配置与会话保留。",
2622
2622
  "resume": "恢复本群:重新接收本群消息,配置与会话保留。",
2623
2623
  "resumeLabel": "恢复",
2624
- "steer": "Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话仅空闲时可用。",
2624
+ "steer": "Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话同样支持忙时跟进;关闭或暂不支持时排队。",
2625
2625
  "preview": "当前版本未接入预览服务。",
2626
2626
  "providers": "可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。",
2627
2627
  "providersView": "当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。",
@@ -3339,18 +3339,17 @@ function sayPlan(chatId,reply){
3339
3339
  function runsCodex(g){const pr=(S.providers||[]).find(pp=>pp.name===g.effectiveProvider);return !!pr&&pr.runner==='codex';}
3340
3340
  function sayAge(ms){const m=Math.floor((ms||0)/60e3);if(m<1)return '不到 1 分钟';if(m<60)return m+' 分钟';return Math.floor(m/60)+' 小时'+(m%60?' '+(m%60)+' 分钟':'');}
3341
3341
  let sayRefresh=null; // 浮层开着时,概览的实时刷新顺手重算一遍(打字的这会儿任务可能刚好跑完)
3342
- /* 持久处理流程(当前版本)的插话只有一种形态:空闲群、这句话进群、回复进群。群忙 / 待人工 / 升级中等
3343
- 直接说明白、按钮不可点 —— 不再说「加入当前任务 / 排队」,那是旧流程的行为。原因由服务端实时状态
3344
- (live.groups[chatId].sayBlocked,与插话接口同一个判据)给出;没有实时状态时以接口回报为准。 */
3342
+ /* 插话按服务端准入显示阻塞原因;正常忙时允许提交,是否已加入只以模型读取确认判断。 */
3345
3343
  const SAY_V1={
3346
- ok:'当前版本的插话:这句话(标着「管理员」)和它做完的回复都会发进群;静默插话、不回复的插话和群忙时的插话暂不支持。',
3347
- busy:'该群有任务进行中,当前版本的插话不排队也不加入当前任务,等它结束再插。',
3344
+ ok:'这句话(标着「管理员」)和回复都会发进群。任务进行中也可以插话;确认模型读取后才算已加入。',
3345
+ busy:'该群正在停止、跳过或处理未结记录,请稍后再插话。',
3346
+ 'queue-full':'该群等待处理的消息已满,请稍后再插话。',
3348
3347
  held:'该群上一个任务结果不明,已暂停处理、等待人工确认;现在不能插话。',
3349
3348
  upgrading:'正在升级,现在不能插话,请一分钟后再试。',
3350
3349
  paused:'这个群项目已暂停,先恢复再插话。',
3351
3350
  closed:'服务正在启动或停止,或该群当前未开放;现在不能插话。'
3352
3351
  };
3353
- function sayV1Blocked(chatId){const lv=((S.live||{}).groups||{})[chatId]||{};return lv.sayBlocked||(lv.busy?'busy':null);}
3352
+ function sayV1Blocked(chatId){const lv=((S.live||{}).groups||{})[chatId]||{};return lv.sayBlocked||null;}
3354
3353
  /* 每次提交一个 32 位十六进制幂等键:同一段文字没成功前重点一次,沿用同一个键,服务端据此不重复处理 */
3355
3354
  function sayRequestId(){const a=new Uint8Array(16);crypto.getRandomValues(a);return Array.from(a,b=>b.toString(16).padStart(2,'0')).join('');}
3356
3355
  /* 「向群里插一句」浮层。入口在概览「群·项目」每一行的 ✎ —— 动作长在对象身上,
@@ -3380,12 +3379,13 @@ function openSay(chatId){
3380
3379
  const v1=!!S.runtimeCapabilities; let sending=false, req=null;
3381
3380
  // 当前版本两个开关固定勾上:只支持「这句话进群 + 回复进群」,开关留着,让人看见这句会以什么样子出现
3382
3381
  if(v1){ for(const c of [sw,rp]){ c.checked=true; c.disabled=true; c.parentElement.title='当前版本固定:这句话和回复都发进群'; } }
3383
- const preview=()=>{ const a=sw.checked,b=rp.checked, plan=v1?null:sayPlan(chatId,b);
3382
+ const preview=()=>{ const a=sw.checked,b=rp.checked, plan=sayPlan(chatId,b);
3384
3383
  if(v1){
3385
3384
  const why=sayV1Blocked(chatId), lv=((S.live||{}).groups||{})[chatId]||{};
3386
- bz.innerHTML=why==='busy'?`<span class="dot"></span>${lv.busy?'正在处理任务 · 已 '+sayAge(lv.sinceMs):'正在收尾'}`:why==='held'?'<span class="dot"></span>待人工确认':'';
3387
- pv.textContent=why?(SAY_V1[why]||SAY_V1.closed):SAY_V1.ok;
3388
- if(!sending){ go.disabled=!!why; goTxt.textContent=why?'暂不能插':'插入'; }
3385
+ bz.innerHTML=lv.busy?`<span class="dot"></span><span>${lv.finalizing?'正在收尾':'正在处理任务'}</span> · ${fmtDur(lv.sinceMs)}`:why==='held'?'<span class="dot"></span>待人工确认':'';
3386
+ const detail=lv.busy?(lv.followups?'将尝试跟进当前任务;长命令执行期间需等待工具步骤结束,实际进度请查看实时活动。':'当前任务暂不接收跟进,这句话会排队处理。'):'';
3387
+ pv.innerHTML=why?esc(SAY_V1[why]||SAY_V1.closed):`<span>${esc(detail)}</span> <span>${esc(SAY_V1.ok)}</span>`;
3388
+ if(!sending){ go.disabled=!!why; goTxt.textContent=why?'暂不能插':lv.busy?(lv.followups?'发送补充':'排队'):'插入'; }
3389
3389
  return;
3390
3390
  }
3391
3391
  const vis = a&&b ? '群里会看到:你插的这句话(标着「管理员」)和它做完的回复。'
@@ -3422,7 +3422,10 @@ function openSay(chatId){
3422
3422
  // 回报照实说清两件事:这句**落在哪**(服务端按引擎实际落点回报),以及群里会/不会出现什么 —— 只说「已插入」等于没说
3423
3423
  const vis = inj&&inj.show ? (inj.reply?'群里能看到这句话和回复':'群里只看到这句话') : (inj&&inj.reply?'群里只会出现回复':'群里不会有任何动静');
3424
3424
  const w=(inj&&inj.where)||{};
3425
- const head = w.state==='joined' ? '已加入当前任务'+(inj&&inj.show?' · 回复卡片会跟到这句话下面':'')
3425
+ const head = w.state==='following' ? '已受理 · 等待模型读取,尚未确认加入'
3426
+ : w.state==='accepted' ? '已受理 · 处理状态请查看实时活动'
3427
+ : w.state==='done' ? '已处理'
3428
+ : w.state==='joined' ? '已加入当前任务'+(inj&&inj.show?' · 回复卡片会跟到这句话下面':'')
3426
3429
  : w.state==='queued' ? (w.join ? '已排队 · 当前任务一就绪就会加入' : '群正忙,已排队'+(w.ahead?`(前面还有 ${w.ahead} 条)`:'')+' · 当前任务结束后处理')
3427
3430
  : '已插入';
3428
3431
  toast(head+' · '+vis,'ok');
@@ -4302,6 +4305,10 @@ updateThemeBtn();
4302
4305
  /* I18N_DICT_BEGIN —— 合并翻译片段时整块替换这两个数组,不要改此标记之外的代码
4303
4306
  ⚠ 正则一律 ^…$ 整串、不加 g 标志;更具体的条目必须排在泛化条之前。 */
4304
4307
  const I18N_EN=[
4308
+ ["正在处理任务", "Task in progress"],
4309
+ ["已受理", "Accepted"],
4310
+ ["等待模型读取,尚未确认加入", "Waiting for the model to read it; not yet confirmed joined"],
4311
+ ["处理状态请查看实时活动", "Check live activity for processing status"],
4305
4312
  ["需要处理", "Needs attention"],
4306
4313
  ["可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务和开新会话在「概览」,开新会话须等该群空闲。群里发 /model 也能切换模型。", "Bind new groups and change the model, project folder, permissions, data display and busy-turn followups of a group with no running task or unsettled record. Changing the model or project starts a new conversation. Stop tasks and start new sessions in Overview; a new session needs the group to be idle. Members can also switch models with /model in the group."],
4307
4314
  ["当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。", "Feishu text messages, busy-turn followups, task replies and stopping are supported. Schedules, image generation, group message management, canvas and previews are unavailable."],
@@ -4311,7 +4318,7 @@ const I18N_EN=[
4311
4318
  ["已暂停:不接收本群新消息,配置与会话保留。", "Paused: no new group messages are accepted; configuration and sessions are retained."],
4312
4319
  ["恢复本群:重新接收本群消息,配置与会话保留。", "Resume this group: its messages are accepted again; configuration and sessions are retained."],
4313
4320
  ["恢复", "Resume"],
4314
- ["Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话仅空闲时可用。", "Busy-turn followups are on by default for Codex and Claude Code. Followups are read between tool steps; a long command must finish first. Joined means the model has read the message. Unsupported clients queue followups. Console messages require an idle group."],
4321
+ ["Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话同样支持忙时跟进;关闭或暂不支持时排队。", "Busy-turn followups are on by default for Codex and Claude Code. Followups are read between tool steps; a long command must finish first. Joined means the model has read the message. Unsupported clients queue followups. Console messages also support busy-turn followups, or queue when followups are disabled or unavailable."],
4315
4322
  ["当前版本未接入预览服务。", "Preview services are unavailable in this version."],
4316
4323
  ["当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。", "Model configuration and login status are shown here. Switch each group's model under Groups. Adding or changing models is unavailable."],
4317
4324
  ["可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。", "Add models; change their model id, endpoint, key, thinking depth and reasoning effort; test, hide or drag to reorder them. Switch each group's model under Groups. Changing a model id, backend or endpoint needs the groups using it to be idle and starts a new conversation for them. The default model and models in use by a group cannot be hidden. Removing models is unavailable."],
@@ -4388,6 +4395,12 @@ const I18N_EN=[
4388
4395
  ["此群当前未开放,未开新会话","This group is not open right now; no new session was started."],
4389
4396
  ["还没有群项目 · 去「群·项目」配置","No group projects yet · set one up on the Groups · Projects page"],
4390
4397
  ["健康","Health"],
4398
+ ["模型已隐藏,不能试运行","This model is hidden and cannot be tested."],
4399
+ ["服务写入权限已失效,试运行结果未保存","The service lost write access; the trial result was not saved."],
4400
+ ["服务未就绪或正在维护,暂不能试运行","The service is not ready or is under maintenance; trials are unavailable."],
4401
+ ["试运行或模型并发名额已满,请稍后重试","The trial or model concurrency limit is reached. Try again later."],
4402
+ ["当前服务未接入试运行并发准入","This service has no trial admission control."],
4403
+ ["试运行进程关闭状态未知,结果未保存,并发名额已保留","Trial process closure is unknown; the result was not saved and its capacity slot remains reserved."],
4391
4404
  ["并发","Concurrency"],
4392
4405
  ["活动","Active"],
4393
4406
  ["· 无上限","· No limit"],
@@ -4986,6 +4999,14 @@ const I18N_EN=[
4986
4999
  ["输入文件夹名","Enter a folder name"],
4987
5000
  ["要它做什么,像跟人说话那样写…","What should it do? Write it the way you would tell a person…"],
4988
5001
  ["群里可见","Visible in the group"],
5002
+ ["发送补充", "Send followup"],
5003
+ ["将尝试跟进当前任务;长命令执行期间需等待工具步骤结束,实际进度请查看实时活动。", "Will try to follow up on the current task. Long commands must finish first; check live activity for progress."],
5004
+ ["当前任务暂不接收跟进,这句话会排队处理。", "The current task is not accepting followups; this message will queue."],
5005
+ ["已受理 · 等待模型读取,尚未确认加入", "Accepted · waiting for the model to read it; not yet confirmed joined"],
5006
+ ["已受理 · 处理状态请查看实时活动", "Accepted · check live activity for processing status"],
5007
+ ["该群等待处理的消息已满,请稍后再插话。", "The message queue is full. Try again shortly."],
5008
+ ["该群等待处理的消息已满,请稍后再插话", "The message queue is full. Try again shortly."],
5009
+ ["该群正在停止、跳过或处理未结记录,请稍后再插话", "This group is stopping, skipping, or settling pending records. Try again shortly."],
4989
5010
  ["你插的这句话本身要不要发进群(会标着「管理员」)","Whether the line you interject is itself posted in the group (marked \"admin\")"],
4990
5011
  ["插话","Interject"],
4991
5012
  ["它做完之后的回复要不要发进群;取消后连报错也不会说","Whether its reply is posted in the group when it finishes; untick and even errors stay silent"],
@@ -5000,8 +5021,8 @@ const I18N_EN=[
5000
5021
  ["什么都不会出现","shows nothing at all"],
5001
5022
  [",连出错也不会说 —— 状态去「实时活动」看。",", not even errors — check the status under \"Live activity\"."],
5002
5023
  ["插入中…","Inserting…"],
5003
- ["当前版本的插话:这句话(标着「管理员」)和它做完的回复都会发进群;静默插话、不回复的插话和群忙时的插话暂不支持。","In this version a console message always posts the line (marked \"admin\") and its reply to the group; silent messages, messages without a reply and messages to a busy group are unavailable."],
5004
- ["该群有任务进行中,当前版本的插话不排队也不加入当前任务,等它结束再插。","This group has a task in progress. In this version a console message neither queues nor joins the current task; send it once the task ends."],
5024
+ ["这句话(标着「管理员」)和回复都会发进群。任务进行中也可以插话;确认模型读取后才算已加入。","The line (marked \"admin\") and its reply are posted to the group. You can send while a task is running; joined means the model has read it."],
5025
+ ["该群正在停止、跳过或处理未结记录,请稍后再插话。","This group is stopping, skipping, or settling pending records. Try again shortly."],
5005
5026
  ["该群上一个任务结果不明,已暂停处理、等待人工确认;现在不能插话。","The outcome of this group's last task is unknown; the group is paused for operator review, so console messages are unavailable."],
5006
5027
  ["正在升级,现在不能插话,请一分钟后再试。","An upgrade is in progress; try the console message again in a minute."],
5007
5028
  ["这个群项目已暂停,先恢复再插话。","This group project is paused; resume it before sending a console message."],
@@ -5967,7 +5988,7 @@ function disableRuntimeControl(el,reason){
5967
5988
  function applyRuntimeCapabilities(){
5968
5989
  if(!S||!S.runtimeCapabilities)return;
5969
5990
  document.querySelectorAll(RUNTIME_DISABLED_SELECTORS).forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
5970
- // 插话照服务端白名单放行(第 1 版只接空闲群,忙不忙在浮层里按实时状态说)
5991
+ // 插话照服务端白名单放行(忙时去向在浮层里按实时状态说明)
5971
5992
  if(!runtimeAllows('POST','/api/groups/say'))document.querySelectorAll('[data-osay]').forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
5972
5993
  if(!runtimeAllows('POST','/api/providers/order'))document.querySelectorAll('#pgrid .drag').forEach(el=>{el.draggable=false;el.title=trText(RUNTIME_UI_TEXT.unavailable);});
5973
5994
  }
@@ -121,14 +121,14 @@ const activityPanel = (() => {
121
121
  state.dataset.status = g.held || g.queueFaulted ? 'attention' : g.busy ? 'running' : 'idle'
122
122
  if (g.busy) text(state, state.textContent + ' ' + Math.floor((g.sinceMs || 0)/1000) + 's')
123
123
  if (g.queue) text(state, state.textContent + ' · ' + tr('排队 ','Queued ') + g.queue)
124
- text(b.children[0], g.model)
124
+ text(b.children[0], g.model || tr('执行器默认(未回报实际型号)','Executor default (actual model unknown)'))
125
125
  text(b.children[1], tr('思考深度 · ','Reasoning · ') + (g.reasoningEffort || tr('默认','Default')))
126
126
  b.children[1].title = tr('当前模型配置;未指定时使用模型默认值','Current model configuration; unspecified uses the model default')
127
127
  const blocked = !S?.live?.running || g.paused || g.held || g.queueFaulted
128
128
  const busy = g.busy || g.pending > 0 || g.queue > 0 || g.waitingForCapacity || g.finalizing
129
129
  for (const [action,zh,eng] of [['say','插话','Message'],['stop','停止任务','Stop task'],['new','新会话','New session']]) {
130
130
  const button = n.querySelector('[data-action='+action+']')
131
- button.disabled = actionPending.has(g.chatId + ':' + action) || blocked || (action === 'stop' ? !busy : !!busy || action === 'say' && !!g.sayBlocked)
131
+ button.disabled = actionPending.has(g.chatId + ':' + action) || blocked || (action === 'stop' ? !busy : action === 'new' ? !!busy : !!g.sayBlocked)
132
132
  button.setAttribute('aria-label',tr(zh,eng) + ' · ' + g.name)
133
133
  button.title = tr(zh,eng) + ' · ' + (button.disabled ? tr('当前状态不可操作','Unavailable in the current state') : action === 'say' ? tr('这句话和回复都会发进该群','Your message and reply will be sent to this group') : action === 'new' ? tr('开始新会话,清除当前会话上下文','Start a new session with fresh context') : tr('停止当前任务,保留会话','Stop the task and keep the session'))
134
134
  }
@@ -34,11 +34,12 @@ function createLiveActivity({ file, now = Date.now, log = () => {}, canWrite = (
34
34
  for (const id of oldest.receiptIds) aliases.delete(key(oldest.chatId, id))
35
35
  }
36
36
  }
37
- function receive({ chatId, receiptId, text, sender, acceptedAt }) {
37
+ function receive({ chatId, receiptId, sourceMessageId, text, sender, acceptedAt }) {
38
38
  if (locate(chatId, receiptId)) return
39
39
  const row = { id: receiptId, chatId, receiptIds: [receiptId], ts: acceptedAt || now(),
40
40
  status: 'queued', text: clean(text, 160), sender: clean(sender, 120),
41
- messages: [{ id: receiptId, text: clean(text), sender: clean(sender, 120), state: 'accepted' }],
41
+ messages: [{ id: receiptId, sourceMessageId: typeof sourceMessageId === 'string' ? sourceMessageId : null,
42
+ text: clean(text), sender: clean(sender, 120), state: 'accepted' }],
42
43
  truncated: String(text || '').length > TEXT, reply: '', tools: [], milestones: [] }
43
44
  rows.set(key(chatId, receiptId), row); touch(row, true)
44
45
  }
@@ -18,7 +18,7 @@ const { createLedger } = require('../core/ledger')
18
18
  const { normalizeTz, isValidTz, tzOffsetMin, tzParts } = require('../core/time')
19
19
  // Runner dispatch (Claude vs Codex by provider.runner) — so 测试/试跑 run codex
20
20
  // providers on the Codex CLI, not Claude. Same module the daemon/CLI use.
21
- const { runTurn, probeVersion } = require('../agent/runner')
21
+ const { runTurn, probeVersion, isClosedRunnerResult, isClosedRunnerError } = require('../agent/runner')
22
22
  const codexstatus = require('../agent/codexstatus') // codex 订阅登录态 + 用量(卡片「验证登录」)
23
23
  const claudestatus = require('../agent/claudestatus') // Claude 订阅登录态(卡片「检测登录」· doctor 同源)
24
24
  const claudemodels = require('../agent/claudemodels') // Claude 模型候选(仅登录后给)
@@ -405,9 +405,9 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
405
405
  // each click doesn't spawn a `claude --version` child.
406
406
  let probeCache = null // { val, ts }
407
407
  const probeCached = async (bin) => {
408
- if (probeCache && Date.now() - probeCache.ts < 60_000) return probeCache.val
408
+ if (probeCache && probeCache.bin === bin && Date.now() - probeCache.ts < 60_000) return probeCache.val
409
409
  const val = await probeVersion(bin)
410
- probeCache = { val, ts: Date.now() }
410
+ probeCache = { val, bin, ts: Date.now() }
411
411
  return val
412
412
  }
413
413
  // Same 60s cache for the codex CLI probe — only run when a codex provider exists.
@@ -522,7 +522,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
522
522
  icons: g.workdir ? design.projectIconCard(g.workdir) : null, crons: (Array.isArray(g.crons) ? g.crons : []).map((c) => ({ ...c, human: describeTask(c, nowTzStr), expired: cronExpired(c.at) })), exists: g.workdir ? fs.existsSync(g.workdir) : false }))
523
523
  // Console overview health — deliberately minimal: backend CLI 探针 + "有无可用模型/群"。
524
524
  // Codex CLI 行仅在配了 codex provider 时出现(与 doctor 一致);`sbot doctor` 保留全量。
525
- const v = runtimePolicy ? null : await probeCached(cfg.agent.bin)
525
+ const hasClaude = providers.some((p) => !p.hidden && p.runner !== 'codex')
526
+ const v = !runtimePolicy || hasClaude ? await probeCached(cfg.agent.bin) : null
526
527
  const hasCodex = providers.some((p) => !p.hidden && p.runner === 'codex')
527
528
  const cv = hasCodex ? await probeCodexCached() : null
528
529
  // 「可用模型」= 能用的,不只是配了的(1.0.3;此前没登录的 Claude 订阅、试跑失败的 key 都算在内)。
@@ -684,14 +685,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
684
685
  const cfg = loadConfig(configPath); applyProxyEnv(cfg)
685
686
  const provider = cfg.providers[name]
686
687
  if (!provider) return { ok: false, error: `unknown provider '${name}'` }
688
+ if (provider.hidden) return { ok: false, error: '模型已隐藏,不能试运行' }
687
689
  if (provider.baseUrl && !provider.apiKey) return { ok: false, error: 'no apiKey' }
688
690
  const ac = new AbortController()
691
+ let lease, closed = false
692
+ try { lease = runtimePolicy ? await runtimePolicy.beginTrial(ac) : null }
693
+ catch (error) { return { ok: false, error: error.message } }
689
694
  // codex 跑整轮 GPT(冷启动 + reasoning)远慢于 Anthropic 的快问快答;给更长的测试超时。
690
695
  // 中止现在真生效(codexcode 强制收尾),超时到点能掐断,不会再干等到自然结束。
691
696
  const timer = setTimeout(() => ac.abort(), provider.runner === 'codex' ? 180000 : 60000)
692
697
  const t0 = Date.now()
693
698
  try {
694
- const { result } = await runTurn({
699
+ const execution = await runTurn({
695
700
  bin: cfg.agent.bin,
696
701
  prompt: prompt || verify.TRIAL_PROMPT,
697
702
  provider,
@@ -699,13 +704,22 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
699
704
  permissionMode: cfg.agent.permissionMode,
700
705
  signal: ac.signal, // a one-line reply — needs no tools, so no allow list
701
706
  })
707
+ closed = isClosedRunnerResult(execution)
708
+ const { result } = execution
709
+ if (lease && !closed) throw new Error('试运行进程关闭状态未知,结果未保存,并发名额已保留')
710
+ lease?.assertWritable()
702
711
  const ok = !(result && result.isError)
703
712
  verify.recordTurn(provider, ok, (result && result.text) || '', vopts) // doctor / 概览据此报「能用」或「key 不对」
704
713
  return { ok, model: provider.model, reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
705
714
  } catch (e) {
706
- verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
715
+ closed ||= isClosedRunnerError(e)
716
+ if (!lease) verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
717
+ else if (closed) {
718
+ try { lease.assertWritable(); verify.recordTurn(provider, false, e.message || String(e), vopts) }
719
+ catch { /* lease loss must never write verification evidence */ }
720
+ }
707
721
  return { ok: false, error: (e && e.message) || String(e), ms: Date.now() - t0 }
708
- } finally { clearTimeout(timer) }
722
+ } finally { clearTimeout(timer); lease?.finish(closed) }
709
723
  }
710
724
 
711
725
  // ── request handling ──────────────────────────────────────────────────────
@@ -1119,7 +1133,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1119
1133
  const why = { 'no-daemon': '守护进程没在跑(纯配置模式)—— 插话需要它在线', paused: '这个群项目已暂停,先恢复再插话', unbound: '没有这个群项目', 'inject-failed': '注入失败(守护进程侧报错,看日志)',
1120
1134
  // 持久处理流程(第 1 版插话)的拒绝原因。shown-failed 之前的几条在发群消息之前就拒,群里没有任何字;
1121
1135
  // 从 shown-failed 起,群里可能已经有那行「【管理员】…」
1122
- busy: '该群有任务进行中,当前版本的插话不排队也不加入当前任务,等它结束再插',
1136
+ busy: '该群正在停止、跳过或处理未结记录,请稍后再插话',
1137
+ 'queue-full': '该群等待处理的消息已满,请稍后再插话',
1123
1138
  held: '该群上一个任务结果不明,已暂停处理、等待人工确认;插话未执行',
1124
1139
  recovering: '该群上一个任务出错,正在收尾;插话未执行,请稍后再试',
1125
1140
  upgrading: '正在升级,插话未执行,请一分钟后再试',
@@ -1130,7 +1145,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1130
1145
  'request-id': '这次提交缺少有效的提交编号,请刷新控制台后再试',
1131
1146
  empty: '内容不能为空',
1132
1147
  'shown-failed': '群里那行「【管理员】…」没能确认发出(失败或超时),插话未执行;即使群里随后出现这行,它也不会被处理',
1133
- 'busy-after-post': '这行字发进群时该群刚好开始了别的任务,这句没有处理;等它结束后原样再点一次插入,会沿用群里已有的那行',
1148
+ 'busy-after-post': '这行字发进群时该群正在停止或处理未结记录,这句没有处理;稍后原样再点一次插入,会沿用群里已有的那行',
1134
1149
  refused: '该群拒收了这句(原因已回复在群里那行下面);插话未执行',
1135
1150
  quarantined: '这句的来源已被隔离,未执行',
1136
1151
  unconfirmed: '这行字已发进群,但没在 5 秒内确认登记,结果以群里和「实时活动」为准;原样再点一次插入不会重复处理' }[r.reason] || '插话失败'