sintbot 1.4.10 → 1.4.11

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,15 @@
1
1
  [
2
+ {
3
+ "version": "1.4.11",
4
+ "date": "2026-11-10",
5
+ "name": "Unified busy follow-ups",
6
+ "items": [
7
+ {
8
+ "t": "Busy follow-ups default on for Codex and Claude Code",
9
+ "d": "One per-group switch preserves explicit disabling. Claude follow-ups join after confirmed reading, and continuation answers form one final reply. Recalling handed-over input cancels the whole task; uncertain delivery is never replayed."
10
+ }
11
+ ]
12
+ },
2
13
  {
3
14
  "version": "1.4.10",
4
15
  "date": "2026-11-10",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,15 @@
1
1
  [
2
+ {
3
+ "version": "1.4.11",
4
+ "date": "2026-11-10",
5
+ "name": "统一忙时跟进",
6
+ "items": [
7
+ {
8
+ "t": "Codex 与 Claude Code 默认开启忙时跟进",
9
+ "d": "统一按群开关并持久保存关闭;Claude 确认读取后加入任务,续轮答案合并为一条最终回复。撤回已交给执行器的补话取消整轮,未知送达不重放。"
10
+ }
11
+ ]
12
+ },
2
13
  {
3
14
  "version": "1.4.10",
4
15
  "date": "2026-11-10",
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.10 with an AI assistant
1
+ # Install SintBot 1.4.11 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.10 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.11 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.10
1
+ # Install SintBot 1.4.11
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.10` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.10`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.11` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.11`. 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
 
@@ -51,7 +51,7 @@ Create a private directory with `mkdir -p ~/.sbot && chmod 700 ~/.sbot`, then cr
51
51
  "provider": "codex",
52
52
  "workdir": "/absolute/path/to/first/project",
53
53
  "permissionMode": "dontAsk",
54
- "codexSteer": true
54
+ "steer": true
55
55
  },
56
56
  "oc_REPLACE_WITH_SECOND_CHAT_ID": {
57
57
  "platform": "feishu",
@@ -99,9 +99,9 @@ Any text message is a task. Replies arrive in a card that appears once the model
99
99
  | `/issue text` | Log feedback; review and close it in the console overview |
100
100
  | `/help` | List commands, with this group's project and model |
101
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.
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; recalling a follow-up handed to either runner cancels the whole task, including accepted input not yet read.
103
103
 
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.
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
106
  ## 6. Console
107
107
 
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.10
1
+ # 用 AI 助手安装 SintBot 1.4.11
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.10。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.11。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.10
1
+ # 安装 SintBot 1.4.11
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.10` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.10` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.11` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.11` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -51,7 +51,7 @@ export FEISHU_APP_ID FEISHU_APP_SECRET
51
51
  "provider": "codex",
52
52
  "workdir": "/absolute/path/to/first/project",
53
53
  "permissionMode": "dontAsk",
54
- "codexSteer": true
54
+ "steer": true
55
55
  },
56
56
  "oc_REPLACE_WITH_SECOND_CHAT_ID": {
57
57
  "platform": "feishu",
@@ -99,9 +99,9 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
99
99
  | `/issue 一句话` | 记下问题反馈,在控制台概览里查看和标记处理 |
100
100
  | `/help` | 查看可用命令,以及本群的项目和模型 |
101
101
 
102
- 命令单独发一条;除 `/issue` 和 `/model` 外,后面不要跟别的字。如果飞书应用还订阅了消息撤回事件(控制台接入引导里建议订阅),撤回自己发的消息即取消那件事:还在排队的直接丢弃;当前任务只由这一条触发时会停下;Codex 群里撤回已补充进当前任务的那条,会连当前任务一起取消。
102
+ 命令单独发一条;除 `/issue` 和 `/model` 外,后面不要跟别的字。如果飞书应用还订阅了消息撤回事件(控制台接入引导里建议订阅),撤回自己发的消息即取消那件事:还在排队的直接丢弃;当前任务只由这一条触发时会停下;两种执行器中,撤回已交给执行器的补话均会取消整轮,包括已受理但尚未读取的补话。
103
103
 
104
- 任务进行中再发的消息:Codex 群(`codexSteer: true`)会补充进当前任务,Claude Code 群会排队,等这一轮结束再处理。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
104
+ Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队,显式 `steer` 优先于旧 Codex 开关。Claude Code 未提供所需输入能力时保留排队。长工具调用可能延迟读取;确认读取后回复卡才跟随补话,续轮答案合并为一条最终回复。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
105
105
 
106
106
  ## 6. 控制台
107
107
 
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.10
5
+ version=1.4.11
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.10",
3
+ "version": "1.4.11",
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": {
@@ -44,6 +44,7 @@
44
44
  "docs/zh-CN/install.md",
45
45
  "install.sh",
46
46
  "src/agent/claudecode.js",
47
+ "src/agent/claudefollowup.js",
47
48
  "src/agent/claudemodels.js",
48
49
  "src/agent/claudestatus.js",
49
50
  "src/agent/claudeusage.js",
@@ -15,6 +15,7 @@
15
15
 
16
16
  const { spawn } = require('node:child_process')
17
17
  const { StringDecoder } = require('node:string_decoder')
18
+ const { createClaudeFollowup } = require('./claudefollowup')
18
19
  const { ensureProxy, proxyBaseFor } = require('./visionproxy')
19
20
 
20
21
  // Always-on 群项目基础能力: auto-allowed regardless of preset (even read-only) so the
@@ -35,7 +36,7 @@ const closedRunnerErrors = new WeakSet()
35
36
  const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
36
37
  const closedError = error => { closedRunnerErrors.add(error); return error }
37
38
  const PRE_INIT_HOOK_EVENTS = new Set(['hook_started', 'hook_response'])
38
- const DURABLE_EVENT_TYPES = new Set(['system', 'assistant', 'user', 'result', 'tool_result'])
39
+ const DURABLE_EVENT_TYPES = new Set(['system', 'assistant', 'user', 'result', 'tool_result', 'turn_continue'])
39
40
  // Tool output is completion evidence only, never answer text.
40
41
  function durableToolResults(ev) {
41
42
  const results = Array.isArray(ev.results) ? ev.results : []
@@ -152,6 +153,8 @@ function runTurn({
152
153
  log = () => {}, // daemon logger — 与 codex runner 同一个口子(engine 两边都传 this.log)
153
154
  spawnFn = spawn, // 可注入,供单测驱动 spawn/解析环路(与 codexcode 同一形状)
154
155
  softStopGraceMs = 10000,
156
+ steer = false, onSteerReady, onSteerLanded,
157
+ steerReceiptTimeoutMs = 10000, steerHoldMs = 20000, interruptGraceMs = 2000,
155
158
  closeGraceMs = 3000, // short: the grace starts AFTER the answer, so we're only waiting for
156
159
  // the OS to close a possibly-clinging pipe (MCP browser children / cmd.exe shim). Claude
157
160
  // persists its session continuously, so killing ~3s post-answer is safe → the group frees
@@ -225,7 +228,7 @@ function runTurn({
225
228
  }
226
229
  // The binary owns the permission, tool allow/deny and argument policy.
227
230
  // Node only resolves capability and writes the project-scoped MCP file.
228
- const args = claudeBuildArgs({ model, providerModel: provider.model, sessionId,
231
+ const args = claudeBuildArgs({ steer, model, providerModel: provider.model, sessionId,
229
232
  permissionMode, autocompactTokens, autocompactSupported,
230
233
  mcpConfigPath, allowedTools, pluginAllows, disallowedTools,
231
234
  appendSystemPrompt, workdir })
@@ -242,8 +245,14 @@ function runTurn({
242
245
  // Feed the prompt over stdin instead of as an argv element: cross-platform,
243
246
  // and it removes ALL Windows argv/cmd.exe escaping risk for arbitrary chat
244
247
  // text (quotes, &, |, %, newlines). Close stdin so `claude -p` starts.
245
- child.stdin.on('error', () => { /* child may exit before the write drains */ })
246
- child.stdin.end(prompt)
248
+ let followup = null
249
+ let stdinOpen = true
250
+ const endStdin = () => { if (stdinOpen) { stdinOpen = false; child.stdin.end() } }
251
+ const writeFrame = frame => {
252
+ if (!stdinOpen || childCloseObserved) throw new Error('Claude input is closed')
253
+ child.stdin.write(JSON.stringify(frame) + '\n')
254
+ }
255
+ child.stdin.on('error', error => { if (steer && !settled) followup?.fail(error) })
247
256
 
248
257
  let capturedSessionId = sessionId || null
249
258
  let lastResult = null
@@ -265,7 +274,7 @@ function runTurn({
265
274
  // Resolution is guarded (exactly once) because a close-grace timer can force the
266
275
  // turn to settle before the 'close' event fires — otherwise a lingering pipe would
267
276
  // wedge the per-group lock forever (never-hang invariant).
268
- const finish = (fn) => { if (settled) return; settled = true; if (killTimer) clearTimeout(killTimer); if (closeTimer) clearTimeout(closeTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
277
+ const finish = (fn) => { if (settled) return; settled = true; followup?.dispose(); if (killTimer) clearTimeout(killTimer); if (closeTimer) clearTimeout(closeTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
269
278
  const resolveRun = result => {
270
279
  if (childCloseObserved) closedRunnerResults.add(result)
271
280
  resolve(result)
@@ -332,12 +341,21 @@ function runTurn({
332
341
  // Windows has no POSIX signals, and killing the cmd.exe shim would orphan
333
342
  // the real claude process underneath. taskkill /T /F ends the whole tree.
334
343
  try { spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }) } catch {}
344
+ } else if (followup && stdinOpen) {
345
+ try { followup.stop() } catch { /* close proof remains authoritative */ }
346
+ killTimer = setTimeout(() => {
347
+ try { child.kill('SIGTERM') } catch {}
348
+ killTimer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
349
+ }, interruptGraceMs)
335
350
  } else {
336
351
  try { child.kill('SIGTERM') } catch { /* already gone */ }
337
352
  killTimer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
338
353
  }
339
354
  armCloseGrace() // even after a kill, 'close' can hang on a clinging pipe — bound it
340
355
  }
356
+ if (steer) followup = createClaudeFollowup({ write: writeFrame, end: endStdin,
357
+ onReady: onSteerReady, onLanded: onSteerLanded, onFailure: failDurableEvent,
358
+ receiptTimeoutMs: steerReceiptTimeoutMs, holdMs: steerHoldMs })
341
359
  if (signal) {
342
360
  if (signal.aborted) onAbort()
343
361
  else signal.addEventListener('abort', onAbort, { once: true })
@@ -370,6 +388,7 @@ function runTurn({
370
388
  if (!line) continue
371
389
  let raw
372
390
  try { raw = JSON.parse(line) } catch { continue } // skip non-JSON noise
391
+ if (followup && followup.protocol(raw)) continue
373
392
  if (onTurnAccepted && !turnAccepted) {
374
393
  // Locally configured session hooks report their own lifecycle before
375
394
  // init. They are not model output; skip them and keep waiting.
@@ -396,6 +415,9 @@ function runTurn({
396
415
  }
397
416
  turnAccepted = true
398
417
  }
418
+ if (followup && raw.type === 'system' && raw.subtype === 'init') {
419
+ if (followup.init(raw) === 'unavailable') log('当前模型程序未提供忙时跟进能力,本轮后续消息排队')
420
+ }
399
421
  // key 错快速失败(1.0.3):claude 对 401 / 403 默认重试约 3 分钟才放弃,第一次重试时(实测 0.8 秒)就会先报一条
400
422
  // `system/api_retry` 带 error_status。只对 API key 型模型(有 baseUrl)当场收尾 —— 订阅登录的 401 可能是
401
423
  // Claude Code 自己在刷新登录,让它重试;5xx / 529 这类过载照旧重试。造一条出错 result 交给上层:账号类失败
@@ -423,6 +445,10 @@ function runTurn({
423
445
  const last = lastMsgUsage || (ev.usage && Array.isArray(ev.usage.iterations) ? ev.usage.iterations[ev.usage.iterations.length - 1] : null)
424
446
  if (last) ev.contextTokens = (last.input_tokens || 0) + (last.cache_read_input_tokens || 0) + (last.cache_creation_input_tokens || 0)
425
447
  }
448
+ if (followup && !aborted && ev.type === 'result') {
449
+ ev = followup.result(ev)
450
+ if (ev.type === 'turn_continue') lastMsgUsage = null
451
+ }
426
452
  // Only transcript events reach the durable projection. Informational
427
453
  // stream additions (for example rate_limit_event) carry no answer.
428
454
  // Claude reports several tool results per event, with array content
@@ -469,6 +495,10 @@ function runTurn({
469
495
  if (onDurableEvent) durableWork.then(() => { if (!durableFailed) completeClose(code) })
470
496
  else completeClose(code)
471
497
  })
498
+ if (!aborted) {
499
+ if (followup) followup.start(prompt)
500
+ else child.stdin.end(prompt)
501
+ }
472
502
  }
473
503
  })
474
504
  }
@@ -0,0 +1,92 @@
1
+ 'use strict'
2
+
3
+ // Transport adapter only: the binary decides readiness, landing and segment boundaries.
4
+ const { randomUUID } = require('node:crypto')
5
+ const { claudeFollowupStep } = require('./privatecore')
6
+
7
+ function createClaudeFollowup({ write, end, onReady, onLanded, onFailure,
8
+ receiptTimeoutMs = 10000, holdMs = 20000 } = {}) {
9
+ let state = null, disposed = false, holdTimer = null, sessionId = null
10
+ const firstId = randomUUID()
11
+ const pending = new Map(), messages = new Map()
12
+ const step = event => { const result = claudeFollowupStep(state, event); state = result.state; return result }
13
+ const clearHold = () => { clearTimeout(holdTimer); holdTimer = null }
14
+ const settle = (id, error) => {
15
+ const waiter = pending.get(id)
16
+ if (!waiter) return
17
+ pending.delete(id); clearTimeout(waiter.timer)
18
+ if (error) waiter.reject(error); else waiter.resolve(true)
19
+ }
20
+ const dispose = () => {
21
+ disposed = true; clearHold()
22
+ for (const id of pending.keys()) settle(id, new Error('Follow-up closed before receipt'))
23
+ }
24
+ const fail = error => { if (disposed) return; dispose(); onFailure(error) }
25
+ const control = {
26
+ steerDurable({ msgId, text } = {}) {
27
+ if (disposed) return Promise.resolve(false)
28
+ if (typeof msgId !== 'string' || !msgId || typeof text !== 'string' || !text || text.length > 200000) {
29
+ return Promise.resolve(false)
30
+ }
31
+ if ([...messages.values()].includes(msgId)) return Promise.reject(new Error('Duplicate follow-up message'))
32
+ const id = randomUUID()
33
+ const decision = step({ type: 'write', id })
34
+ if (decision.action === 'reject') return Promise.resolve(false)
35
+ messages.set(id, msgId)
36
+ const promise = new Promise((resolve, reject) => {
37
+ const timer = setTimeout(() => fail(new Error('Follow-up receipt timed out; delivery unknown')), receiptTimeoutMs)
38
+ pending.set(id, { resolve, reject, timer })
39
+ })
40
+ // Register identity and waiter before writing: a receipt may arrive immediately.
41
+ try { write({ type: 'user', uuid: id, message: { role: 'user', content: text } }) }
42
+ catch (error) { fail(error) }
43
+ return promise
44
+ },
45
+ }
46
+ return {
47
+ start(prompt) { write({ type: 'user', uuid: firstId, message: { role: 'user', content: prompt } }) },
48
+ protocol(raw) {
49
+ if (raw.type === 'control_response') return true
50
+ if (raw.type !== 'command_lifecycle') return false
51
+ if (raw.command_uuid === firstId) return true
52
+ if (sessionId && raw.session_id && raw.session_id !== sessionId) throw new Error('Follow-up session mismatch')
53
+ if (disposed) return true
54
+ const decision = step({ type: 'lifecycle', id: raw.command_uuid, status: raw.state })
55
+ if (decision.accepted) settle(raw.command_uuid)
56
+ if (decision.landed) { clearHold(); onLanded?.(messages.get(raw.command_uuid)) }
57
+ return true
58
+ },
59
+ init(raw) {
60
+ if (sessionId) {
61
+ if (raw.session_id !== sessionId) throw new Error('Follow-up init session changed')
62
+ return 'none'
63
+ }
64
+ sessionId = raw.session_id
65
+ const decision = step({ type: 'init', capabilities: raw.capabilities })
66
+ if (decision.action === 'ready') onReady?.(control)
67
+ else end()
68
+ return decision.action
69
+ },
70
+ result(event) {
71
+ const decision = step({ type: 'result', usage: event.usage, isError: event.isError })
72
+ clearHold()
73
+ if (decision.action === 'continue') {
74
+ holdTimer = setTimeout(() => fail(new Error('Follow-up landing timed out; replay forbidden')), holdMs)
75
+ return { ...event, type: 'turn_continue', usage: decision.usage }
76
+ }
77
+ end()
78
+ return { ...event, usage: { ...event.usage, ...decision.usage } }
79
+ },
80
+ stop() {
81
+ if (!disposed) step({ type: 'stop' })
82
+ dispose()
83
+ write({ type: 'control_request', request_id: `stop-${randomUUID()}`,
84
+ request: { subtype: 'interrupt', cancel_queued: true } })
85
+ end()
86
+ },
87
+ dispose,
88
+ fail,
89
+ }
90
+ }
91
+
92
+ module.exports = { createClaudeFollowup }
@@ -1,9 +1,9 @@
1
- // ABI v34 adapter for the private binary decision core. Manifest assembly still stays in JS.
1
+ // ABI v35 adapter for the private binary decision core. Manifest assembly still stays in JS.
2
2
  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 = '8c55d4f97a738e14885841f9b8f83906cca1e8e9aef46bb28f31022aef1fce2d'
6
+ const EXPECTED_SHA256 = '9c29c9123b38aa75b9c47066c809d844f938baca41779372b77c02fa7baa9989'
7
7
  let bytes
8
8
  try {
9
9
  bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
@@ -26,7 +26,7 @@ try {
26
26
  } catch (cause) {
27
27
  throw new Error('SintBot binary core cannot start on this runtime', { cause })
28
28
  }
29
- if (core.abi_version() !== 34) throw new Error('SintBot binary core ABI mismatch')
29
+ if (core.abi_version() !== 35) throw new Error('SintBot binary core ABI mismatch')
30
30
  const encoder = new TextEncoder()
31
31
  const decoder = new TextDecoder('utf-8', { fatal: true })
32
32
 
@@ -784,7 +784,21 @@ function guarded(fn) {
784
784
  }
785
785
  }
786
786
 
787
- const entries = { classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
787
+ function followupCall(name, value) {
788
+ let ptr = 0, size = 0
789
+ try {
790
+ ptr = callText(name, JSON.stringify(value))
791
+ if (!ptr) throw new Error('Follow-up core rejected protocol state')
792
+ size = new DataView(core.memory.buffer).getUint32(ptr, true) + 4
793
+ return JSON.parse(decoder.decode(new Uint8Array(core.memory.buffer, ptr + 4, size - 4)))
794
+ } finally {
795
+ if (ptr && size) { new Uint8Array(core.memory.buffer).fill(0, ptr, ptr + size); core.dealloc(ptr, size) }
796
+ }
797
+ }
798
+ function followupEnabled(group, runner) { return followupCall('followup_policy', { group, runner }) }
799
+ function claudeFollowupStep(state, event) { return followupCall('claude_followup_step', { state, event }) }
800
+
801
+ const entries = { followupEnabled, claudeFollowupStep, classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
788
802
  for (const name of Object.keys(entries)) entries[name] = guarded(entries[name])
789
803
 
790
804
  module.exports = { ...entries, engineStep, resetBinaryCore, HUMAN_BY_KIND: HUMAN }
Binary file
package/src/c2console.js CHANGED
@@ -18,7 +18,7 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
18
18
  '/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
19
19
  '/api/groups', '/api/groups/provider', '/api/fs/mkdir',
20
20
  '/api/groups/order', '/api/groups/policy', '/api/groups/redact',
21
- '/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
21
+ '/api/groups/codex-steer', '/api/groups/steer', '/api/groups/pause', '/api/groups/stop',
22
22
  // A fresh session only for an idle, settled group (same rule as /new).
23
23
  '/api/groups/new', '/api/groups/interrupt',
24
24
  '/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
package/src/c2install.js CHANGED
@@ -123,6 +123,7 @@ function resolveInstallation(cfg, configFile, stateDir, { allowAddedGroups = fal
123
123
  if (group.paused !== undefined && typeof group.paused !== 'boolean') {
124
124
  throw new Error('Group paused must be a boolean')
125
125
  }
126
+ if (group.steer !== undefined && typeof group.steer !== 'boolean') throw new Error('Group steer must be a boolean')
126
127
  if (group.codexSteer !== undefined && typeof group.codexSteer !== 'boolean') {
127
128
  throw new Error('Group codexSteer must be a boolean')
128
129
  }
package/src/config.js CHANGED
@@ -40,8 +40,8 @@ const DEFAULTS = {
40
40
  // 写死会**覆盖 CLI 的默认**,于是每次 Anthropic 出新一代,sbot 的默认就悄悄落后一代
41
41
  // (2026-08-03 核对时正卡在 opus-4-8,而 CLI 默认已是 Opus 5)。要钉死型号在控制台选。
42
42
  providers: { claude: {} },
43
- // "<chatId>": { workdir, provider?, model?, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
44
- // codexSteer defaults on for Codex groups; explicit false opts out.
43
+ // "<chatId>": { workdir, provider?, model?, steer?: boolean, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
44
+ // steer defaults on for both executors; explicit false opts out. Legacy codexSteer applies only to Codex.
45
45
  // — one project per group; permissions are scoped to that group's own workdir.
46
46
  groups: {},
47
47
  }
@@ -1,4 +1,5 @@
1
1
  'use strict'
2
+ const { followupEnabled } = require('../agent/privatecore')
2
3
 
3
4
  // Assemble the same user-facing runner inputs as Engine._runTurn after the
4
5
  // host has prepared its timestamped prompt and manifest. This only assembles
@@ -25,12 +26,13 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
25
26
  throw new TypeError('C2 runner input requires an exact process-local turn')
26
27
  }
27
28
  const isCodex = provider.runner === 'codex'
28
- const codexSteer = isCodex && group.codexSteer !== false
29
+ const enabled = followupEnabled(group, provider.runner)
30
+ const codexSteer = isCodex && enabled
29
31
  if ((onSteerReady !== undefined && typeof onSteerReady !== 'function') ||
30
32
  (onSteerLanded !== undefined && typeof onSteerLanded !== 'function') ||
31
- (codexSteer && (typeof onSteerReady !== 'function' ||
33
+ (enabled && (typeof onSteerReady !== 'function' ||
32
34
  typeof onSteerLanded !== 'function'))) {
33
- throw new TypeError('C2 Codex follow-up needs durable control callbacks')
35
+ throw new TypeError('C2 follow-up needs durable control callbacks')
34
36
  }
35
37
  const rawCompact = Number(config.agent.autocompactTokens) || 0
36
38
  const autocompactTokens = rawCompact > 0
@@ -40,6 +42,7 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
40
42
  prompt, appendSystemPrompt, provider,
41
43
  codexApp: isCodex && group.codexApp === true,
42
44
  codexSteer,
45
+ steer: !isCodex && enabled,
43
46
  codexProjectDocFallback: isCodex && group.codexProjectDocFallback === true,
44
47
  onSteerReady, onSteerLanded,
45
48
  model: group.model,
@@ -24,6 +24,8 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
24
24
  }
25
25
  let bufferText = ''
26
26
  let answerText = ''
27
+ let keptText = ''
28
+ let segmentBreak = false
27
29
  let fullText = ''
28
30
  let sessionId = null
29
31
  let contextTokens = 0
@@ -68,6 +70,11 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
68
70
  throw new TypeError('C2 projection tool names invalid')
69
71
  }
70
72
  if (event.text) {
73
+ if (segmentBreak) {
74
+ segmentBreak = false
75
+ if (bufferText) bufferText += '\n\n'
76
+ if (fullText) fullText += '\n\n'
77
+ }
71
78
  fullText = append(fullText, event.text)
72
79
  bufferText = append(bufferText, event.text)
73
80
  answerText = append(answerText, event.text)
@@ -78,6 +85,13 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
78
85
  // Claude can report text and tool use in one assistant event. Text before
79
86
  // the tool is progress, while the final answer begins after that tool.
80
87
  if (tools.length) answerText = ''
88
+ } else if (event.type === 'turn_continue') {
89
+ if (event.text !== undefined && typeof event.text !== 'string') throw new TypeError('C2 segment text invalid')
90
+ const text = (answerText.trim() ? answerText : event.text || '').trim()
91
+ if (text && text !== NO_REPLY) keptText += (keptText ? '\n\n' : '') + text
92
+ if (bufferText.trim() === NO_REPLY) bufferText = ''
93
+ if (fullText.trim() === NO_REPLY) fullText = ''
94
+ answerText = ''; segmentBreak = true
81
95
  } else if (event.type === 'result') {
82
96
  if (event.text !== undefined && typeof event.text !== 'string') {
83
97
  throw new TypeError('C2 projection result text invalid')
@@ -138,7 +152,11 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
138
152
  }
139
153
  completed = true
140
154
  const runnerExit = requireResult && !observedResult && !runnerResult.aborted
141
- const resultText = observedResult?.text || ''
155
+ const rawResultText = observedResult?.text || ''
156
+ const tail = (answerText.trim() ? answerText : rawResultText).trim()
157
+ const joined = keptText ? keptText + (tail && tail !== NO_REPLY ? '\n\n' + tail : '') : ''
158
+ const resultText = joined || rawResultText
159
+ if (joined) answerText = joined
142
160
  const payload = Object.freeze({
143
161
  type: 'finalizeEffectCommit', receiptId, turnToken, quiet,
144
162
  source: observedResult ? 'result' : runnerExit ? 'runner-exit' : 'close',
@@ -9,7 +9,7 @@ const { isDeepStrictEqual } = require('node:util')
9
9
  const { redact } = require('./redact')
10
10
  const { fmtStamp, normalizeTz } = require('./time')
11
11
  const { settleUsage, manifestPolicy, manifestHeader, manifestContext, manifestFeatures, manifestSkills,
12
- systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
12
+ followupEnabled, systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
13
13
  const { taskId } = require('./enginecheckpoint')
14
14
  const { prepareCardTerminalPath } = require('./c2cardterminalpath')
15
15
  const { terminalReplyText } = require('./c2terminaleffectbridge')
@@ -468,7 +468,7 @@ async _durableV2Command(msg, cmd, rest) {
468
468
  group.allowedTools, group.disallowedTools).tier)
469
469
  } catch {}
470
470
  return reject('help', T.help({ project: projectName(group), model: label(name, provider),
471
- steer: isCodex && group.codexSteer !== false, readOnly }))
471
+ steer: followupEnabled(group, provider.runner), readOnly }))
472
472
  }
473
473
  if (cmd === '/status') {
474
474
  const rt = this.rts.get(msg.chatId)
@@ -651,7 +651,7 @@ async runDurableV2Message(msg, platform) {
651
651
  lane?.handoff?.[msg.chatId]?.phase === 'running' ||
652
652
  Array.isArray(queue) && queue.length > 0 ||
653
653
  rt.v2RunningJobs || rt.v2QueueReceiptIds?.length
654
- if (busy && provider.runner === 'codex' && group.codexSteer !== false &&
654
+ if (busy && rt.v2FollowupEnabled &&
655
655
  rt.v2SteerControl && rt.v2NormalRouteActive && !rt.v2RunnerStopVerified &&
656
656
  !rt.v2TerminalStarted && !rt.v2StreamQuiescing && rt.v2Controller &&
657
657
  !rt.v2Controller.signal.aborted && !rt.v2SteerClosing) {
@@ -1534,6 +1534,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1534
1534
  throw new Error('C2 v2 runner provider unavailable')
1535
1535
  }
1536
1536
  rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1537
+ rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
1537
1538
  rt.v2SteerControl = null
1538
1539
  rt.v2SteerEntries = new Map()
1539
1540
  rt.v2SteerClosing = false
@@ -2619,7 +2619,7 @@ const RUNTIME_UI_TEXT={
2619
2619
  "paused": "已暂停:不接收本群新消息,配置与会话保留。",
2620
2620
  "resume": "恢复本群:重新接收本群消息,配置与会话保留。",
2621
2621
  "resumeLabel": "恢复",
2622
- "steer": "群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。",
2622
+ "steer": "Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话仅空闲时可用。",
2623
2623
  "preview": "当前版本未接入预览服务。",
2624
2624
  "providers": "可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。",
2625
2625
  "providersView": "当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。",
@@ -2631,7 +2631,7 @@ function renderGroups(){
2631
2631
  // Codex(GPT)跑自己的 agent 框架:只吃 permissionMode→沙箱层级,allow/deny 与 MCP 门控
2632
2632
  // 都是 Claude 专属(codexcode 根本不接收这些字段)。所以 codex 群不渲染那些无效控件。
2633
2633
  const pr=provOf(g), isCodex=!!pr&&pr.runner==='codex';
2634
- const showCodexSteer=runsCodex(g);
2634
+
2635
2635
  // 群属于哪个平台(服务端按配置 / id 形态算好):企微群没有画布、没有解散、基础技能文案不同;徽标只在配了企微 / Slack 时出(只配飞书的机器零变化)
2636
2636
  const isWe=g.platform==='wecom', isSl=g.platform==='slack', showPlat=!!(S.platforms&&(S.platforms.wecom||S.platforms.slack));
2637
2637
  const platTag=showPlat?`<span class="tag tan" title="${isWe?'企业微信群:只收 @ 机器人的消息;没有画布、表情回应与撤回即停':isSl?'Slack 频道:没有画布与表情回应,群内收发文件未接;控制面用 !命令 或 /sint':'飞书群'}">${isWe?'企业微信':isSl?'Slack':'飞书'}</span>`:'';
@@ -2845,13 +2845,13 @@ function renderGroups(){
2845
2845
  <select class="gprov" data-g="${esc(g.chatId)}" style="width:auto;min-width:110px;padding:5px 30px 5px 10px;font-size:11.5px">
2846
2846
  ${S.providers.filter(pp=>!pp.hidden&&pp.configured).map(pp=>`<option value="${esc(pp.name)}" ${pp.name===(g.provider||S.defaultProvider)?'selected':''}>${esc(pp.name)}${pp.model?' · '+esc(pp.model):''}</option>`).join('')}
2847
2847
  </select></div>
2848
- ${showCodexSteer?`<div class="kv" style="align-items:flex-start"><span class="kk">忙时跟进</span><span class="vv" style="min-width:0">
2849
- <label class="inline" style="gap:6px;cursor:pointer" title="${esc(S.runtimeCapabilities?RUNTIME_UI_TEXT.steer:'开启后,Codex 处理任务期间:群成员的后续消息、控制台勾了「回复」的插话,都会加入当前任务 —— 相关要求及时吸收,独立请求按先后回应。定时任务和不回复的插话仍排在后面。')}">
2850
- <input type="checkbox" class="gsteer" data-g="${esc(g.chatId)}"${g.codexSteer?' checked':''} style="width:auto;margin:0"/>
2848
+ <div class="kv" style="align-items:flex-start"><span class="kk">忙时跟进</span><span class="vv" style="min-width:0">
2849
+ <label class="inline" style="gap:6px;cursor:pointer" title="${esc(S.runtimeCapabilities?RUNTIME_UI_TEXT.steer:'开启后,处理任务期间:群成员的后续消息、控制台勾了「回复」的插话,都会加入当前任务 —— 相关要求及时吸收,独立请求按先后回应。定时任务和不回复的插话仍排在后面。')}">
2850
+ <input type="checkbox" class="gsteer" data-g="${esc(g.chatId)}"${g.followupEnabled?' checked':''} style="width:auto;margin:0"/>
2851
2851
  <span style="font-size:11.5px;color:var(--dim)">后续消息加入当前处理</span>
2852
2852
  </label>
2853
- <span class="faint" style="font-size:11px">仅 Codex · 默认开启 · 下轮生效</span>
2854
- </span></div>`:''}
2853
+ <span class="faint" style="font-size:11px">默认开启 · 下轮生效</span>
2854
+ </span></div>
2855
2855
  <div class="kv"><span class="kk">执行模式</span>${modeControl}</div>
2856
2856
  ${permPlug}
2857
2857
  <div class="spacer"></div>
@@ -2890,7 +2890,7 @@ function renderGroups(){
2890
2890
  try{S=await api('/api/groups/pause','POST',{chatId:b.dataset.gpause,paused:on});renderGroups();renderOverview();toast(S.runtimeCapabilities?(on?'已暂停':'已恢复'):(on?'已暂停 · 群内已通知':'已恢复 · 群内已通知'),'ok');}catch(e){toast(e.message,'err');}});
2891
2891
  $$('#groups .gprov').forEach(sel=>sel.onchange=async()=>{try{const r=await api('/api/groups/provider','POST',{chatId:sel.dataset.g,provider:sel.value});S=r;renderOverview();renderGroups();toast(r.switched&&r.switched.live?('已切到 '+sel.value+' · 会话已重置,下条消息生效'):('已保存 '+sel.value+' · daemon 未运行下次生效'),'ok');}catch(e){toast(e.message,'err');}});
2892
2892
  $$('#groups .gsteer').forEach(c=>c.onchange=async()=>{const on=c.checked;
2893
- try{S=await api('/api/groups/codex-steer','POST',{chatId:c.dataset.g,enabled:on});renderGroups();toast(on?'已开启 · 下轮生效':'已关闭 · 下轮恢复排队','ok');}
2893
+ try{S=await api('/api/groups/steer','POST',{chatId:c.dataset.g,enabled:on});renderGroups();toast(on?'已开启 · 下轮生效':'已关闭 · 下轮恢复排队','ok');}
2894
2894
  catch(e){c.checked=!on;toast(e.message,'err');}});
2895
2895
  $$('#groups [data-pol]').forEach(b=>b.onclick=async()=>{const g=b.dataset.pol,allow=splitList($('.gallow[data-g="'+g+'"]').value),deny=splitList($('.gdeny[data-g="'+g+'"]').value);try{S=await api('/api/groups/policy','POST',{chatId:g,allowedTools:allow,disallowedTools:deny});toast('权限已保存 · 下轮生效','ok');}catch(e){toast(e.message,'err');}});
2896
2896
  // 仓库地址:保存 = 校验 + git init(若非仓库)+ 设 origin(幂等,不 clone/不提交);留空保存 = 解除关联。
@@ -3330,7 +3330,7 @@ function sayPlan(chatId,reply){
3330
3330
  if(lv.finalizing) return {busy:true,join:false,why:'closing',lv};
3331
3331
  if(lv.followups) return reply?{busy:true,join:true,lv}:{busy:true,join:false,why:'quiet',lv};
3332
3332
  const g=(S.groups||[]).find(x=>x.chatId===chatId)||{};
3333
- return {busy:true,join:false,why:!runsCodex(g)?'other':(!g.codexSteer?'off':'turn'),lv};
3333
+ return {busy:true,join:false,why:!g.followupEnabled?'off':'turn',lv};
3334
3334
  }
3335
3335
  /* 这个群眼下实际跑的是不是 Codex(按生效中的模型,含临时切换)—— 群卡「忙时跟进」开关显不显示、插话框说不说
3336
3336
  「去打开忙时跟进」,都按这一个判。两处各写一份,开关和说明就会对不上。 */
@@ -4311,10 +4311,11 @@ const I18N_EN=[
4311
4311
  ["已暂停:不接收本群新消息,配置与会话保留。", "Paused: no new group messages are accepted; configuration and sessions are retained."],
4312
4312
  ["恢复本群:重新接收本群消息,配置与会话保留。", "Resume this group: its messages are accepted again; configuration and sessions are retained."],
4313
4313
  ["恢复", "Resume"],
4314
- ["群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。", "Followup text messages from group members can join the current task. Delivery is confirmed only when the model receives the message. Console messages work only while the group is idle and never join the current task; schedules are unavailable."],
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."],
4315
4315
  ["当前版本未接入预览服务。", "Preview services are unavailable in this version."],
4316
4316
  ["当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。", "Model configuration and login status are shown here. Switch each group's model under Groups. Adding or changing models is unavailable."],
4317
4317
  ["可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。", "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."],
4318
+ ["默认开启 · 下轮生效", "On by default · Applies next turn"],
4318
4319
  ["当前只能添加或修改 Codex 或 Claude Code 模型", "Only Codex or Claude Code models can be added or changed right now."],
4319
4320
  ["默认模型须是列表中未隐藏的 Codex 或 Claude Code 模型", "The default model must be a visible Codex or Claude Code model from the list."],
4320
4321
  ["有群因未解决的故障暂停处理,它用的模型、后端或端点要等处理完并重启后才能改", "A group is held after an unresolved fault; the model, backend or endpoint it uses can change only after the fault is handled and the service restarts."],
@@ -5,6 +5,7 @@
5
5
  // two stay in sync.
6
6
 
7
7
  const http = require('node:http')
8
+ const { followupEnabled } = require('../agent/privatecore')
8
9
  const crypto = require('node:crypto')
9
10
  const fs = require('node:fs')
10
11
  const os = require('node:os')
@@ -507,7 +508,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
507
508
  // repo:群项目关联的 git 远端(可空)。repoState 走纯 fs 的 .git/config 读取(quickLinkState)
508
509
  // —— 群卡标签要反映真实关联(origin 被手改/删掉时不能还绿着),但这里每个 API 响应都跑一遍,
509
510
  // per-group spawn git 会把控制台拖垮,所以只读文件、不 spawn。
510
- .map(([chatId, g]) => ({ chatId, name: g.name || '', workdir: g.workdir || '', repo: g.repo || '', platform: chatPlatformOf(raw, chatId) /* 群属于哪个平台:配置写了就认,没写按 id 形态 / 唯一平台推断(与 config.normalizeGroupPlatforms 同口径) */, repoState: gitrepo.quickLinkState(g.workdir, g.repo), board: g.board && g.board.url ? { url: g.board.url, createdAt: g.board.createdAt || null } : null, provider: g.provider || '', effectiveProvider: (control && control.providerOf && control.providerOf(chatId)) || g.provider || raw.defaultProvider || cfg.defaultProvider, codexSteer: g.codexSteer !== false, model: g.model || '', allowedTools: g.allowedTools || [], disallowedTools: g.disallowedTools || [], permissionMode: g.permissionMode || '', paused: g.paused === true, plugins: g.plugins || null,
511
+ .map(([chatId, g]) => ({ chatId, name: g.name || '', workdir: g.workdir || '', repo: g.repo || '', platform: chatPlatformOf(raw, chatId) /* 群属于哪个平台:配置写了就认,没写按 id 形态 / 唯一平台推断(与 config.normalizeGroupPlatforms 同口径) */, repoState: gitrepo.quickLinkState(g.workdir, g.repo), board: g.board && g.board.url ? { url: g.board.url, createdAt: g.board.createdAt || null } : null, provider: g.provider || '', effectiveProvider: (control && control.providerOf && control.providerOf(chatId)) || g.provider || raw.defaultProvider || cfg.defaultProvider, codexSteer: followupEnabled(g, 'codex'), steer: g.steer ?? null, followupEnabled: followupEnabled(g, cfg.providers[(control && control.providerOf && control.providerOf(chatId)) || g.provider || cfg.defaultProvider]?.runner || 'claude'), model: g.model || '', allowedTools: g.allowedTools || [], disallowedTools: g.disallowedTools || [], permissionMode: g.permissionMode || '', paused: g.paused === true, plugins: g.plugins || null,
511
512
  // 项目级插件/技能(workdir 自带声明,随项目恒加载)—— 群卡摘要行不点开也看得见。
512
513
  // projectPlugins 纯 fs 读且各错各兜(readJson/scanSkills 都 catch),与同行的 projectStyleCard 同量级。
513
514
  // ⚠ workdir 必须先判空:空串会让 path.join 退化成 daemon cwd 相对读(实验证实能读到别的项目的
@@ -1795,15 +1796,15 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1795
1796
  }
1796
1797
  // Codex 忙时跟进默认开启;显式 false 才关闭。当前轮按启动时配置运行。
1797
1798
  // providerOf 覆盖运行时模型切换/额度回退,因此必须以实际有效 runner 校验。
1798
- if (p === '/api/groups/codex-steer' && req.method === 'POST') {
1799
+ if ((p === '/api/groups/steer' || p === '/api/groups/codex-steer') && req.method === 'POST') {
1799
1800
  const { chatId, enabled } = await body(req); const raw = readRaw()
1800
1801
  if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1801
1802
  if (typeof enabled !== 'boolean') return sendJSON(res, 400, { error: 'enabled 必须为布尔值' })
1802
1803
  const name = (control && control.providerOf && control.providerOf(chatId)) || raw.groups[chatId].provider || raw.defaultProvider || loadConfig(configPath).defaultProvider
1803
1804
  const provider = { ...DEFAULTS.providers, ...(raw.providers || {}) }[name]
1804
- if (!provider || provider.runner !== 'codex') return sendJSON(res, 400, { error: '仅实际使用 Codex 的群项目可开启忙时跟进' })
1805
- if (enabled) delete raw.groups[chatId].codexSteer
1806
- else raw.groups[chatId].codexSteer = false
1805
+ if (p === '/api/groups/codex-steer' && (!provider || provider.runner !== 'codex')) return sendJSON(res, 400, { error: '仅实际使用 Codex 的群项目可开启忙时跟进' })
1806
+ raw.groups[chatId].steer = enabled
1807
+ delete raw.groups[chatId].codexSteer
1807
1808
  writeRaw(raw)
1808
1809
  return sendJSON(res, 200, await buildState())
1809
1810
  }