sintbot 1.4.0 → 1.4.1

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,23 @@
1
1
  [
2
+ {
3
+ "version": "1.4.1",
4
+ "date": "2026-11-09",
5
+ "name": "A failing group stops only itself",
6
+ "items": [
7
+ {
8
+ "t": "One group's fault leaves the others working",
9
+ "d": "When a group's last task has an outcome that cannot be confirmed (for example a reply whose delivery is unknown, or a runner that exited without a result), only that group is paused; nothing is replayed or retried and the other groups keep working. A paused group answers new messages with a short notice."
10
+ },
11
+ {
12
+ "t": "One group no longer blocks startup",
13
+ "d": "If a group's records cannot be recovered, the other groups still start and the console marks that group Needs attention. The startup preflight fails and lists such groups."
14
+ },
15
+ {
16
+ "t": "Incident seal command",
17
+ "d": "Restart first; for a group that is still paused, stop the service and run sintbot incident seal --group <group ID>. The old records are kept unchanged, the group reopens with new records, and messages left unfinished before the seal are not processed again."
18
+ }
19
+ ]
20
+ },
2
21
  {
3
22
  "version": "1.4.0",
4
23
  "date": "2026-11-08",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "1.4.1",
4
+ "date": "2026-11-09",
5
+ "name": "一个群出问题,只停这个群",
6
+ "items": [
7
+ {
8
+ "t": "一个群出问题,其他群照常",
9
+ "d": "某个群上一个任务的结果无法确认时(例如回复是否送达不明,或执行器退出时没有结果),只暂停这个群,不重放、不重试,其他群继续处理。暂停的群收到新消息会回一句说明。"
10
+ },
11
+ {
12
+ "t": "启动时不再被一个群拖住",
13
+ "d": "某个群的记录恢复不了时,其他群照常启动,控制台把这个群标为「需要处理」。启动预检遇到这样的群会直接失败并列出原因。"
14
+ },
15
+ {
16
+ "t": "新增封存命令",
17
+ "d": "先重启服务;仍然暂停的群,在服务停止后运行 sintbot incident seal --group 群 ID。原记录原样封存,这个群换一份新记录重新开放,封存前未完成的消息不会再处理。"
18
+ }
19
+ ]
20
+ },
2
21
  {
3
22
  "version": "1.4.0",
4
23
  "date": "2026-11-08",
package/bin/sintbot-v2.js CHANGED
@@ -122,11 +122,18 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
122
122
  for (const name of ['pending-turns.json', 'engine-receipts.json']) {
123
123
  if (fs.existsSync(path.join(stateDir, name))) throw new Error('Legacy pending authority needs explicit migration')
124
124
  }
125
+ // A journal that cannot be opened or audited holds only its own group.
125
126
  const journals = new Map()
127
+ const holds = new Map()
126
128
  for (const route of routes) {
127
- const journal = C2DurableJournal.open(route.file, validators)
128
- assertJournalScope(journal, route.chatId, { requireSettled: preflightOnly || route.paused })
129
- journals.set(route.chatId, journal)
129
+ try {
130
+ const journal = C2DurableJournal.open(route.file, validators)
131
+ assertJournalScope(journal, route.chatId, { requireSettled: preflightOnly || route.paused })
132
+ journals.set(route.chatId, journal)
133
+ } catch (error) {
134
+ if (!assertWriterLock()) throw error
135
+ holds.set(route.chatId, error?.message || String(error))
136
+ }
130
137
  }
131
138
  // All group Engines must share this exact object: separate Store caches
132
139
  // would overwrite sibling sessions when they save the same state file.
@@ -136,6 +143,13 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
136
143
  'codex-exec': require('../src/agent/codexcode').isClosedRunnerResult,
137
144
  'codex-app': require('../src/agent/codexapp').isClosedRunnerResult,
138
145
  }
146
+ // A runner that failed from its own exit event has closed; this frees its
147
+ // model permit but never proves the turn's outcome.
148
+ const closeErrorChecks = {
149
+ claude: require('../src/agent/claudecode').isClosedRunnerError,
150
+ 'codex-exec': require('../src/agent/codexcode').isClosedRunnerError,
151
+ 'codex-app': require('../src/agent/codexapp').isClosedRunnerError,
152
+ }
139
153
  const limits = { maxEventCount: 100_000, maxJournalBytes: 64 * 1024 * 1024,
140
154
  maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
141
155
  let finishing = false
@@ -148,7 +162,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
148
162
  () => process.exit(result.code))
149
163
  }
150
164
  service = createC2MultiGroupService({ routes, assertWriterLock, quarantine: combinedQuarantine,
151
- closeCapacity: () => capacity.close(),
165
+ holds, closeCapacity: () => capacity.close(),
166
+ // Faults in a shared resource stop every group; others hold one group.
167
+ sharedFault: () => capacity.snapshot().closed ? 'model concurrency closed' : null,
152
168
  log: message => process.stderr.write(`C2 multi-group · ${message}\n`),
153
169
  onFatal: (error, result) => {
154
170
  process.stderr.write(`C2 multi-group failure: ${error?.message || String(error)}\n`)
@@ -199,10 +215,15 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
199
215
  verifyFile: path.join(stateDir, 'verify.json'), assertWriterLock,
200
216
  resolveTurn: identity => resolveC2LocalEffectTurn(engine, identity) })
201
217
  const closedResults = new WeakSet()
218
+ const closedErrors = new WeakSet()
202
219
  const startTurn = async input => {
203
220
  const backend = routeBackend(input.provider?.runner, input.codexApp,
204
221
  input.codexSteer, input.codexProjectDocFallback)
205
- const result = await runner.runTurn(input)
222
+ let result
223
+ try { result = await runner.runTurn(input) } catch (error) {
224
+ if (closeErrorChecks[backend]?.(error) === true) closedErrors.add(error)
225
+ throw error
226
+ }
206
227
  if (closeChecks[backend]?.(result) === true) closedResults.add(result)
207
228
  return result
208
229
  }
@@ -210,14 +231,15 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
210
231
  store, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
211
232
  durableV2Runtime: runtime, durableV2Payloads: handles,
212
233
  durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
213
- durableV2VerifyRunnerClosed: ({ result }) => !!result &&
214
- typeof result === 'object' && closedResults.has(result),
234
+ durableV2VerifyRunnerClosed: ({ result, error }) => result
235
+ ? typeof result === 'object' && closedResults.has(result)
236
+ : !!error && typeof error === 'object' && closedErrors.has(error),
215
237
  durableV2AssertWriterLock: assertWriterLock, durableV2Journal: journal,
216
238
  durableV2AcquireCapacity: options => capacity.acquire(options),
217
- // A failed model turn is closed for its group alone; every other
218
- // route error still seals shared grants before the process stops.
239
+ // A route fault holds or recovers its group alone. Shared grants
240
+ // are sealed only when the process writer lease itself is gone.
219
241
  durableV2SealCapacity: error => {
220
- if (error?.code !== 'C2_V2_UNRESOLVED_TURN') capacity.close(error)
242
+ if (assertWriterLock() !== true) capacity.close(error)
221
243
  },
222
244
  durableV2SwitchProvider: (chatId, name) => {
223
245
  if (chatId !== route.chatId) throw new Error('Model switch targets another group')
@@ -231,7 +253,8 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
231
253
  } })
232
254
  // One configuration writer for the console and the chat /model command.
233
255
  configControl = require('../src/c2configcontrol').createC2ConfigControl({
234
- installation, getBindings: () => service.bindings, assertWriterLock, store,
256
+ installation, getBindings: () => service.bindings, getHeld: () => service.held,
257
+ assertWriterLock, store,
235
258
  activateRoute: route => service.addRoute(route),
236
259
  log: message => process.stderr.write(`C2 multi-group · ${message}\n`) })
237
260
  const stop = reason => { void service.stop(reason, 0).then(finish) }
@@ -243,7 +266,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
243
266
  ui = require('../src/c2console').createC2Console({
244
267
  configPath: cfg._path, stateDir, host: uiHost,
245
268
  getConfig: () => cfg, getBindings: () => service.bindings,
246
- getCapacity: () => capacity.snapshot(),
269
+ getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
247
270
  getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
248
271
  assertWriterLock, ...configControl,
249
272
  log: message => process.stderr.write(`Console · ${message}\n`) })
@@ -261,6 +284,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
261
284
  process.on('exit', lock.release)
262
285
  running = true
263
286
  process.stderr.write(`SintBot Feishu ingress connected for ${started.groups} groups\n`)
287
+ for (const item of started.held || []) {
288
+ process.stderr.write(`SintBot group ${item.chatId} is held: ${item.reason}\n`)
289
+ }
264
290
  } catch (error) {
265
291
  ui?.close()
266
292
  const stopped = await service?.stop('startup-failed', 1)
@@ -303,6 +329,7 @@ const HELP = `SintBot macOS Feishu multi-group candidate
303
329
  Usage:
304
330
  sintbot init [--config <absolute path>] [--data-dir <absolute path>]
305
331
  sintbot start [--config <absolute path>] [--data-dir <absolute path>] [--preflight] [--ui] [--ui-host <address>] [--ui-port <port>] [--no-open]
332
+ sintbot incident seal --group <oc_…> [--config <absolute path>] [--data-dir <absolute path>] [--reason <text>]
306
333
  sintbot version
307
334
  sintbot help
308
335
 
@@ -311,7 +338,10 @@ Initialization is for a new installation only; existing journals are never reset
311
338
  Console shows live groups, binds groups and switches models or projects of idle groups;
312
339
  in a group, /model lists or switches models. Unsupported actions stay disabled.
313
340
  Canvas, scheduled tasks, terminal chat and other platforms are unavailable.
314
- Group faults stop the shared process safely; unknown effects are never replayed.
341
+ A group whose last result cannot be proven is held alone; the other groups keep serving.
342
+ Restart first: a journal that can recover reopens on its own. If the group is still held,
343
+ stop the service, run incident seal for that group, then start again.
344
+ Sealing keeps the old journal unchanged; unknown effects are never replayed.
315
345
  `
316
346
 
317
347
  async function main(argv = process.argv.slice(2)) {
@@ -326,10 +356,27 @@ async function main(argv = process.argv.slice(2)) {
326
356
  process.stdout.write(HELP)
327
357
  return
328
358
  }
329
- if (!['init', 'start'].includes(sub)) {
359
+ if (!['init', 'start', 'incident'].includes(sub)) {
330
360
  throw new Error(`${sub} is unavailable in this release; use sintbot help`)
331
361
  }
332
362
  if (process.platform !== 'darwin') throw new Error('this release supports macOS only')
363
+ if (sub === 'incident') {
364
+ const [action, ...args] = rest
365
+ if (rest.some(arg => arg === '--help' || arg === '-h')) { process.stdout.write(HELP); return }
366
+ if (action !== 'seal') throw new Error('Usage: sintbot incident seal --group <oc_…> [--config <path>] [--data-dir <path>]')
367
+ const { values } = parseArgs({ args, allowPositionals: false, options: {
368
+ config: { type: 'string' }, 'data-dir': { type: 'string' },
369
+ group: { type: 'string' }, reason: { type: 'string' } } })
370
+ const installation = require('../src/c2install').loadInstallation(values.config,
371
+ { dataDir: values['data-dir'], allowAddedGroups: true })
372
+ const result = require('../src/c2incident').sealC2GroupIncident({ installation,
373
+ chatId: values.group, ...(values.reason !== undefined ? { reason: values.reason } : {}),
374
+ version: require('../package.json').version })
375
+ process.stdout.write(`Sealed the group's previous journal unchanged in ${result.incident}.\n` +
376
+ `${result.refusedSources} unsettled message(s) will not be processed again. ` +
377
+ 'Start the service to reopen the group with a new journal.\n')
378
+ return
379
+ }
333
380
  if (sub === 'init') {
334
381
  const { values } = parseArgs({ args: rest, allowPositionals: false,
335
382
  options: { config: { type: 'string' }, 'data-dir': { type: 'string' } } })
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.0 with an AI assistant
1
+ # Install SintBot 1.4.1 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.0 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.1 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.0
1
+ # Install SintBot 1.4.1
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.0` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.0`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.1` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.1`. 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
 
@@ -98,11 +98,11 @@ Any text message is a task. Replies arrive in a card that appears once the model
98
98
  | `/hud` | Quota of the current model and usage of this conversation |
99
99
  | `/issue text` | Log feedback; review and close it in the console overview |
100
100
 
101
- 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 and other groups are unaffected.
101
+ Messages sent while a task runs are added to that task in Codex groups (`codexSteer: true`) and queued in Claude Code groups. If a turn returns a model error or no result, only that group is asked to resend; there is no automatic retry. If a group's last result cannot be confirmed, such as a reply whose delivery is unknown, only that group is paused and answers new messages with a short notice; other groups keep working. Nothing is replayed or retried.
102
102
 
103
103
  ## 6. Console
104
104
 
105
- With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group, stops tasks, binds new groups, and switches the model or project folder of an idle group. For LAN access, set a console password in its settings first, then enable LAN access.
105
+ With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, binds new groups, and switches the model or project folder of an idle group. For LAN access, set a console password in its settings first, then enable LAN access.
106
106
 
107
107
  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.
108
108
 
@@ -114,6 +114,12 @@ Press Ctrl-C and wait for exit. With the Feishu variables set, run:
114
114
  "$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
115
115
  ```
116
116
 
117
- If startup reports an unsettled receipt or a result is uncertain, keep the journals and check what actually happened; never delete journals or resend to get around it. Run only one connected process per Feishu app. This release does not register a background service.
117
+ If a group stays paused, first restart: a group whose records can recover reopens on its own and is told once to resend. If it is still paused, check what actually happened in that group, then stop the service and seal the group's incident:
118
+
119
+ ```bash
120
+ "$HOME/.local/share/sintbot/bin/sintbot" incident seal --config "$HOME/.sbot/config.json" --group oc_PAUSED_GROUP_ID
121
+ ```
122
+
123
+ Sealing keeps the old journal unchanged under `incidents/` in the data directory and gives the group a new journal; messages left unfinished before the seal are never processed again. Start the service again to reopen the group. A preflight fails while any group would stay paused. Never delete journals or resend to get around a paused group. Run only one connected process per Feishu app. This release does not register a background service.
118
124
 
119
125
  [Illustrated website guide](https://sintbot.com/guide/install/) · [AI-assisted installation](./AI-ASSISTED-INSTALL.md)
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.0
1
+ # 用 AI 助手安装 SintBot 1.4.1
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.0。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.1。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.0
1
+ # 安装 SintBot 1.4.1
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.0` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.0` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.1` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.1` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -98,11 +98,11 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
98
98
  | `/hud` | 当前模型的额度与本段对话的用量 |
99
99
  | `/issue 一句话` | 记下问题反馈,在控制台概览里查看和标记处理 |
100
100
 
101
- 任务进行中再发的消息:Codex 群(`codexSteer: true`)会补充进当前任务,Claude Code 群会排队,等这一轮结束再处理。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试,也不影响其他群。
101
+ 任务进行中再发的消息:Codex 群(`codexSteer: true`)会补充进当前任务,Claude Code 群会排队,等这一轮结束再处理。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
102
102
 
103
103
  ## 6. 控制台
104
104
 
105
- 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态、停止任务、绑定新群、给空闲的群切换模型或项目目录。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
105
+ 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、绑定新群、给空闲的群切换模型或项目目录。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
106
106
 
107
107
  不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
108
108
 
@@ -114,6 +114,12 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
114
114
  "$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
115
115
  ```
116
116
 
117
- 如果启动提示有未结收据,或任务结果不明,保留日志并核对实际结果;不要通过删除日志或重复发送来绕过。一个飞书应用同一时间只运行一个连接进程。本版不自动注册后台服务。
117
+ 某个群一直暂停时,先重启:记录能恢复的群会自己重新开放,并提示一次重新发送。重启后仍暂停,先核对这个群实际发生了什么,再停止服务,封存这个群的事故记录:
118
+
119
+ ```bash
120
+ "$HOME/.local/share/sintbot/bin/sintbot" incident seal --config "$HOME/.sbot/config.json" --group oc_暂停的群ID
121
+ ```
122
+
123
+ 封存会把原日志原样留在数据目录的 `incidents/` 下,并给这个群换一份新日志;封存前没处理完的消息不会再处理。再次启动服务,这个群就重新开放。有群会保持暂停时,预检会失败。不要通过删除日志或重复发送来绕过暂停。一个飞书应用同一时间只运行一个连接进程。本版不自动注册后台服务。
118
124
 
119
125
  [带图示的官网指南](https://sintbot.com/zh/guide/install/) · [AI 辅助安装](./AI-ASSISTED-INSTALL.md)
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.0
5
+ version=1.4.1
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.0",
3
+ "version": "1.4.1",
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": {
@@ -58,6 +58,7 @@
58
58
  "src/agent/winexec.js",
59
59
  "src/c2configcontrol.js",
60
60
  "src/c2console.js",
61
+ "src/c2incident.js",
61
62
  "src/c2install.js",
62
63
  "src/c2migration.js",
63
64
  "src/c2multigroupcapacity.js",
@@ -29,6 +29,11 @@ const { IS_WIN, resolveSpawn } = require('./winexec')
29
29
  const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 codex runner 共用
30
30
  const closedRunnerResults = new WeakSet()
31
31
  const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
32
+ // A rejection raised from the child's own 'close' event: the process has
33
+ // exited even though the turn has no result. It never proves the turn's outcome.
34
+ const closedRunnerErrors = new WeakSet()
35
+ const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
36
+ const closedError = error => { closedRunnerErrors.add(error); return error }
32
37
  const PRE_INIT_HOOK_EVENTS = new Set(['hook_started', 'hook_response'])
33
38
  const DURABLE_EVENT_TYPES = new Set(['system', 'assistant', 'user', 'result', 'tool_result'])
34
39
  // Tool output is completion evidence only, never answer text.
@@ -451,7 +456,7 @@ function runTurn({
451
456
 
452
457
  const completeClose = (code) => finish(() => {
453
458
  if (code !== 0 && !lastResult && !aborted) {
454
- return reject(new Error(`claude exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`))
459
+ return reject(closedError(new Error(`claude exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`)))
455
460
  }
456
461
  resolveRun({ sessionId: capturedSessionId, result: lastResult, code, aborted })
457
462
  })
@@ -524,4 +529,4 @@ function probeVersion(bin = 'claude') {
524
529
  })
525
530
  }
526
531
 
527
- module.exports = { runTurn, isClosedRunnerResult, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
532
+ module.exports = { runTurn, isClosedRunnerResult, isClosedRunnerError, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
@@ -4,6 +4,11 @@
4
4
  const { spawn } = require('node:child_process')
5
5
  const closedRunnerResults = new WeakSet()
6
6
  const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
7
+ // A rejection raised from the child's own 'close' event: the process has
8
+ // exited even though the turn has no result. It never proves the turn's outcome.
9
+ const closedRunnerErrors = new WeakSet()
10
+ const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
11
+ const closedError = error => { closedRunnerErrors.add(error); return error }
7
12
  const { IS_WIN, resolveSpawn } = require('./winexec')
8
13
  const { normalizeEvent, isEffortRejection } = require('./codexcode')
9
14
  const { coreFailedResult, isCoreFailure } = require('./errtag')
@@ -439,7 +444,7 @@ function runTurn(opts = {}) {
439
444
  })
440
445
  const completeClose = (code) => settle(() => {
441
446
  if (onDurableEvent && !completed && !aborted) {
442
- reject(new Error(`codex app-server exited ${code} before a durable result`))
447
+ reject(closedError(new Error(`codex app-server exited ${code} before a durable result`)))
443
448
  return
444
449
  }
445
450
  if (!completed && !aborted) {
@@ -467,4 +472,4 @@ function runTurn(opts = {}) {
467
472
  })
468
473
  }
469
474
 
470
- module.exports = { runTurn, isClosedRunnerResult, usageFromTotal, itemEvent, failedTurnEvent }
475
+ module.exports = { runTurn, isClosedRunnerResult, isClosedRunnerError, usageFromTotal, itemEvent, failedTurnEvent }
@@ -13,6 +13,11 @@
13
13
  const { spawn } = require('node:child_process')
14
14
  const closedRunnerResults = new WeakSet()
15
15
  const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
16
+ // A rejection raised from the child's own 'close' event: the process has
17
+ // exited even though the turn has no result. It never proves the turn's outcome.
18
+ const closedRunnerErrors = new WeakSet()
19
+ const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
20
+ const closedError = error => { closedRunnerErrors.add(error); return error }
16
21
  const { StringDecoder } = require('node:string_decoder')
17
22
  const { IS_WIN, resolveSpawn } = require('./winexec')
18
23
  const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 claude runner 共用
@@ -398,11 +403,11 @@ function runOnce(opts, effort) {
398
403
 
399
404
  const completeClose = (code) => {
400
405
  if (onDurableEvent && code !== 0 && !lastResult && !aborted) {
401
- done(() => reject(new Error(`codex exited ${code} before a durable result`)))
406
+ done(() => reject(closedError(new Error(`codex exited ${code} before a durable result`))))
402
407
  return
403
408
  }
404
409
  if (durableStart && !acceptedStart && !aborted && !acceptanceFailed) {
405
- done(() => reject(new Error('Codex exited before turn.started acceptance')))
410
+ done(() => reject(closedError(new Error('Codex exited before turn.started acceptance'))))
406
411
  return
407
412
  }
408
413
  // Watch the answer→close (rollout-flush) lag. Healthy = ~2–3s (real-machine verified),
@@ -450,4 +455,4 @@ function probeVersion(bin = 'codex') {
450
455
  })
451
456
  }
452
457
 
453
- module.exports = { runTurn, runOnce, isClosedRunnerResult, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
458
+ module.exports = { runTurn, runOnce, isClosedRunnerResult, isClosedRunnerError, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
@@ -17,10 +17,11 @@ const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
17
17
  // model, project or backend clears its session before the new configuration
18
18
  // is written, so no later turn can resume a session from another backend.
19
19
  // It never moves a journal, settles a receipt or reconnects the platform.
20
- function createC2ConfigControl({ installation, getBindings, assertWriterLock,
21
- store = null, activateRoute = null, probeRunner = assertRunnerExecutable,
20
+ function createC2ConfigControl({ installation, getBindings, getHeld = () => new Map(),
21
+ assertWriterLock, store = null, activateRoute = null, probeRunner = assertRunnerExecutable,
22
22
  log = () => {} } = {}) {
23
23
  if (!installation?.cfg?._path || typeof getBindings !== 'function' ||
24
+ typeof getHeld !== 'function' ||
24
25
  typeof assertWriterLock !== 'function' ||
25
26
  store !== null && typeof store.resetGroupSession !== 'function' ||
26
27
  activateRoute !== null && typeof activateRoute !== 'function' ||
@@ -72,11 +73,19 @@ function createC2ConfigControl({ installation, getBindings, assertWriterLock,
72
73
  throw new Error('Changing global concurrency requires a service restart')
73
74
  }
74
75
  const resets = [], activations = [], changed = []
76
+ const heldGroups = getHeld()
75
77
  for (const route of routes) {
76
78
  const target = next.routes.find(item => item.chatId === route.chatId)
77
79
  if (route.group.platform !== target.group.platform) {
78
80
  throw new Error('Moving a group to another platform requires an explicit state migration')
79
81
  }
82
+ if (heldGroups.has(route.chatId)) {
83
+ // Edits elsewhere proceed; the held group keeps its exact settings.
84
+ if (!isDeepStrictEqual(route.group, target.group)) {
85
+ throw new Error('This group is held after an unresolved fault; seal its incident and restart before changing it')
86
+ }
87
+ continue
88
+ }
80
89
  if (!isDeepStrictEqual(route.group, target.group)) changed.push(route.chatId)
81
90
  if (RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))) {
82
91
  if (!store) throw new Error('Changing model or project requires session reset authority')
package/src/c2console.js CHANGED
@@ -20,9 +20,9 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
20
20
  '/api/inbox/dismiss', '/api/issues/status']
21
21
  const SUPPORTED_RUNNERS = ['claude', 'codex']
22
22
 
23
- function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
23
+ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
24
24
  assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true, now = Date.now } = {}) {
25
- for (const callback of [getBindings, getCapacity, getConfig, assertWriterLock,
25
+ for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
26
26
  validateConfig, applyConfig, beforeWrite, now]) {
27
27
  if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
28
28
  }
@@ -45,10 +45,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
45
45
  function getLiveState() {
46
46
  const cfg = getConfig()
47
47
  const capacity = getCapacity()
48
+ const heldGroups = getHeld()
48
49
  const groups = {}
49
50
  let queue = 0
50
51
  for (const [chatId, group] of Object.entries(cfg.groups || {})) {
51
52
  const binding = bindings().get(chatId)
53
+ const hold = heldGroups.get(chatId)
52
54
  const rt = binding?.engine?.rts.get(chatId)
53
55
  const snapshot = binding?.journal?.snapshot()
54
56
  const pending = snapshot?.receipts?.pending || []
@@ -63,14 +65,17 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
63
65
  waitingForCapacity: !!rt?.v2AdmissionPending,
64
66
  sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
65
67
  sessionId: binding?.engine?.store?.getSession(chatId) || '',
68
+ // A held group needs the operator: its last result is unknown.
69
+ ...(hold ? { held: true, heldReason: hold.reason, heldSinceMs: Math.max(0, now() - hold.since) } : {}),
66
70
  }
67
71
  }
68
72
  const activeBindingsReady = Object.entries(cfg.groups || {})
69
- .filter(([, group]) => !group.paused).every(([chatId]) => bindings().has(chatId))
73
+ .filter(([chatId, group]) => !group.paused && !heldGroups.has(chatId))
74
+ .every(([chatId]) => bindings().has(chatId))
70
75
  return { running: assertWriterLock() === true && !capacity.closed && activeBindingsReady,
71
76
  uptimeMs: Math.max(0, now() - startedAt),
72
77
  concurrency: { active: capacity.active, max: capacity.maxConcurrent },
73
- queue, waiting: capacity.waiting, groups }
78
+ queue, waiting: capacity.waiting, held: heldGroups.size, groups }
74
79
  }
75
80
  function getActivity() {
76
81
  const cfg = getConfig(), items = []
@@ -0,0 +1,125 @@
1
+ 'use strict'
2
+
3
+ // Operator tool for a group the service holds after an unresolved fault.
4
+ // With the service stopped and the writer lock held, it moves that group's
5
+ // journal unchanged under <data>/incidents/, records the source messages it
6
+ // left unsettled and the receipts it had settled, then gives the group a new
7
+ // empty journal under the same mapped name. Nothing is settled, rewritten,
8
+ // replayed or notified; every later start refuses those messages from
9
+ // incident.json (see loadC2IncidentQuarantine).
10
+ const fs = require('node:fs')
11
+ const path = require('node:path')
12
+ const { createHash } = require('node:crypto')
13
+ const { C2DurableJournal } = require('./core/c2durablejournal')
14
+ const { assertJournalScope, assertPrivateDirectory, VALIDATORS } = require('./c2install')
15
+
16
+ const GROUP_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
17
+ const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
18
+ const SCHEMA = 'sbot-c2-incident-seal-v1'
19
+
20
+ const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex')
21
+ function privateFile(file, label) {
22
+ const st = fs.lstatSync(file)
23
+ if (!st.isFile() || st.isSymbolicLink() ||
24
+ typeof process.getuid === 'function' && st.uid !== process.getuid() ||
25
+ process.platform !== 'win32' && (st.mode & 0o077)) {
26
+ throw new Error(`${label} is not a private regular file owned by this user; preserve it and review`)
27
+ }
28
+ return st
29
+ }
30
+ function writeNew(file, value) {
31
+ const fd = fs.openSync(file, 'wx', 0o600)
32
+ try { fs.writeSync(fd, JSON.stringify(value, null, 2) + '\n'); fs.fsyncSync(fd) }
33
+ finally { fs.closeSync(fd) }
34
+ }
35
+ function fsyncDir(dir) {
36
+ const fd = fs.openSync(dir, 'r')
37
+ try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
38
+ }
39
+ const pad = n => String(n).padStart(2, '0')
40
+ function stamp(ms) {
41
+ const d = new Date(ms)
42
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
43
+ }
44
+
45
+ function sealC2GroupIncident({ installation, chatId, reason = 'operator sealed a held group',
46
+ now = Date.now, acquireLock = require('./core/instancelock').acquire, version = '' } = {}) {
47
+ if (typeof chatId !== 'string' || !GROUP_ID.test(chatId)) {
48
+ throw new Error('Name the held group with --group oc_…')
49
+ }
50
+ if (typeof reason !== 'string' || !reason.trim() || reason.length > 500) {
51
+ throw new Error('The incident reason must be 1 to 500 characters')
52
+ }
53
+ const { stateDir, routes } = installation || {}
54
+ const route = routes?.find(item => item.chatId === chatId)
55
+ if (!route) throw new Error('This group is not in the configuration')
56
+ if ((installation.addedRoutes || []).some(item => item.chatId === chatId)) {
57
+ throw new Error('This group has no journal yet; nothing to seal')
58
+ }
59
+ assertPrivateDirectory(stateDir)
60
+ const lockFile = path.join(stateDir, 'daemon.lock')
61
+ const lock = acquireLock(lockFile)
62
+ if (!lock?.ok) throw new Error('The service is running; stop it before sealing an incident')
63
+ const held = () => {
64
+ try { return fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid) } catch { return false }
65
+ }
66
+ try {
67
+ const file = route.file
68
+ const stat = privateFile(file, 'The group journal')
69
+ const before = { sha256: sha256(file), bytes: stat.size }
70
+ let journal
71
+ try { journal = C2DurableJournal.open(file, VALIDATORS) } catch (error) {
72
+ throw new Error(`The group journal cannot be read (${error.message}); preserve it unchanged and review it manually`)
73
+ }
74
+ const snapshot = assertJournalScope(journal, chatId)
75
+ let settled = true
76
+ try { assertJournalScope(journal, chatId, { requireSettled: true }) } catch { settled = false }
77
+ if (settled) throw new Error('This group journal is settled; nothing to seal. Start the service normally')
78
+ if (sha256(file) !== before.sha256) {
79
+ throw new Error('Opening the group journal changed it; preserve every file for review')
80
+ }
81
+ const pending = snapshot.receipts.pending
82
+ if (pending.some(item => item.source !== 'message' || !FEISHU_MESSAGE_ID.test(item.sourceId))) {
83
+ throw new Error('The journal holds a receipt that is not a Feishu message; review it manually')
84
+ }
85
+ const incidents = path.join(stateDir, 'incidents')
86
+ fs.mkdirSync(incidents, { recursive: true, mode: 0o700 })
87
+ fs.chmodSync(incidents, 0o700)
88
+ const sealedAt = now()
89
+ const dir = path.join(incidents, `${stamp(sealedAt)}-group-${chatId.slice(-8)}`)
90
+ fs.mkdirSync(dir, { mode: 0o700 })
91
+ writeNew(path.join(dir, 'incident.json'), { schema: SCHEMA, chatId,
92
+ journal: path.basename(file), sha256: before.sha256, bytes: before.bytes,
93
+ cursor: snapshot.cursor,
94
+ pending: pending.map(item => ({ id: item.id, sourceId: item.sourceId })),
95
+ completedReceiptIds: snapshot.receipts.completed.map(item => item.id),
96
+ sealedAt, reason: reason.trim(),
97
+ handling: 'frozen unchanged; receipts not settled; turn not replayed; source messages refused at admission from incident.json',
98
+ tool: 'sintbot incident seal', version })
99
+ if (!held()) throw new Error('The writer lock was lost before sealing; preserve every file for review')
100
+ const sealed = path.join(dir, path.basename(file))
101
+ fs.renameSync(file, sealed)
102
+ fsyncDir(stateDir); fsyncDir(dir)
103
+ if (sha256(sealed) !== before.sha256 || fs.existsSync(file)) {
104
+ throw new Error('The sealed journal differs; preserve every file for review')
105
+ }
106
+ if (!held()) throw new Error('The writer lock was lost before the new journal; preserve every file for review')
107
+ C2DurableJournal.initialize(file, { state: { version: 2, seq: 0, lanes: {} },
108
+ receipts: { pending: [], completed: [] } }, VALIDATORS)
109
+ fs.chmodSync(file, 0o600)
110
+ const fresh = C2DurableJournal.open(file, VALIDATORS)
111
+ const empty = assertJournalScope(fresh, chatId, { requireSettled: true })
112
+ if (fresh.blocked || empty.cursor !== 0 || empty.eventCount !== 0 ||
113
+ empty.receipts.pending.length || empty.receipts.completed.length ||
114
+ empty.effects.size || empty.notices.size || Object.keys(empty.state.lanes).length) {
115
+ throw new Error('New group journal verification failed; preserve every file for review')
116
+ }
117
+ const result = Object.freeze({ incident: dir, sealed, sealedSha256: before.sha256,
118
+ newJournal: file, newJournalSha256: sha256(file),
119
+ refusedSources: pending.length, settledRemembered: snapshot.receipts.completed.length })
120
+ writeNew(path.join(dir, 'result.json'), result)
121
+ return result
122
+ } finally { lock.release() }
123
+ }
124
+
125
+ module.exports = { sealC2GroupIncident }
package/src/c2install.js CHANGED
@@ -201,7 +201,7 @@ function assertJournalScope(journal, chatId, { requireSettled = false } = {}) {
201
201
  Object.values(snap.state.lanes.queue || {}).some(lane => lane.queue.length) ||
202
202
  Object.values(snap.state.lanes.session || {}).some(lane => lane.settled !== true) ||
203
203
  ['terminal', 'stopIntent', 'cardCleanup', 'steer'].some(domain => Object.keys(snap.state.lanes[domain] || {}).length))) {
204
- throw new Error('Paused group journal has unresolved work; preserve it and explicitly review recovery')
204
+ throw new Error('Group journal has unresolved work; preserve it and explicitly review recovery')
205
205
  }
206
206
  return snap
207
207
  }
@@ -4,6 +4,8 @@ const { isC2InputRejection, answerC2InputRejection, rejectC2Input } = require('.
4
4
  const { coordinateC2GlobalFault } = require('./core/c2globalfault')
5
5
 
6
6
  const RECOVERING_NOTICE = '上一个任务出错,正在收尾,这条消息没有处理,请稍后重发。'
7
+ const HELD_NOTICE = '本群上一个任务结果不明,已暂停处理,等待人工确认;其他群不受影响。'
8
+ const reasonOf = error => String(error?.message || error || 'unknown').slice(0, 300)
7
9
 
8
10
  // Turns a group binding still holds: a running turn, a non-idle handoff or a
9
11
  // busy lifecycle. Scoped to one group's own journal.
@@ -23,12 +25,20 @@ function activeTurns(snapshot) {
23
25
  return turns.sort((a, b) => a.chatId.localeCompare(b.chatId))
24
26
  }
25
27
 
28
+ const runtimesOf = binding => binding?.engine?.rts instanceof Map ? [...binding.engine.rts.values()] : []
29
+
26
30
  // Process-local organization only. Each binding owns a distinct durable
27
31
  // authority; the transport and writer lease belong to this single process.
32
+ // A fault that only one group's journal can explain holds that group: its
33
+ // sealed binding stays as evidence, nothing is replayed or retried, and every
34
+ // other group keeps serving. Only a shared resource (writer lease, transport,
35
+ // model capacity) stops the process. `holds` lists groups whose journal could
36
+ // not be opened before the service was created.
28
37
  function createC2MultiGroupService({ routes, assertWriterLock,
29
38
  createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
30
39
  quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
31
- createRecoveryAuthority = null, log = () => {} } = {}) {
40
+ createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
41
+ now = Date.now, log = () => {} } = {}) {
32
42
  if (!Array.isArray(routes) || !routes.length ||
33
43
  new Set(routes.map(route => route.chatId)).size !== routes.length ||
34
44
  routes.some(route => !route || typeof route.chatId !== 'string' ||
@@ -36,7 +46,9 @@ function createC2MultiGroupService({ routes, assertWriterLock,
36
46
  typeof assertWriterLock !== 'function' ||
37
47
  typeof createTransport !== 'function' || typeof createBinding !== 'function' ||
38
48
  typeof onFatal !== 'function' || typeof closeCapacity !== 'function' ||
39
- typeof log !== 'function' ||
49
+ typeof log !== 'function' || typeof sharedFault !== 'function' ||
50
+ typeof now !== 'function' || !(holds instanceof Map) ||
51
+ [...holds.keys()].some(chatId => !routes.some(route => route.chatId === chatId)) ||
40
52
  createRecoveryAuthority !== null && typeof createRecoveryAuthority !== 'function' ||
41
53
  !Number.isSafeInteger(stopTimeoutMs) || stopTimeoutMs < 1 ||
42
54
  !Number.isSafeInteger(drainTimeoutMs) || drainTimeoutMs < 1) {
@@ -44,6 +56,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
44
56
  }
45
57
  const configured = new Map(routes.map(route => [route.chatId, route]))
46
58
  const bindings = new Map()
59
+ // chatId -> { reason, source: 'startup' | 'runtime', since, binding }
60
+ const suspended = new Map()
47
61
  let transport = null
48
62
  let started = false
49
63
  let starting = false
@@ -92,7 +106,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
92
106
  () => transport.stopPlatform(), stopTimeoutMs) === true
93
107
  const drained = (await Promise.all([...bindings.values()].map(binding =>
94
108
  bounded(() => binding.engine.drainDurableV2(drainTimeoutMs), drainTimeoutMs + 100))))
95
- .every(value => value === true)
109
+ .every(value => value === true) && [...suspended.values()].every(entry =>
110
+ runtimesOf(entry.binding).every(rt => !rt.v2Controller))
96
111
  return Object.freeze({ reason, code: code === 0 && startupStopped && platformStopped && drained ? 0 : 1,
97
112
  startupStopped, platformStopped, drained })
98
113
  })()
@@ -105,6 +120,22 @@ function createC2MultiGroupService({ routes, assertWriterLock,
105
120
  await onFatal(error, result)
106
121
  return result
107
122
  }
123
+ // Seal one group in place. Its binding and journal are kept unchanged as
124
+ // evidence; a later restart reopens it only through the normal recovery
125
+ // gate, or after its incident has been sealed by the operator.
126
+ function suspend(chatId, error, { source, binding = null }) {
127
+ if (suspended.has(chatId)) return suspended.get(chatId)
128
+ const entry = Object.freeze({ reason: reasonOf(error), source, since: now(), binding })
129
+ suspended.set(chatId, entry)
130
+ for (const sealed of new Set([binding, bindings.get(chatId)])) {
131
+ if (!sealed) continue
132
+ try { sealed.gate.stop() } catch {}
133
+ try { sealed.engine.shutdown() } catch {}
134
+ }
135
+ bindings.delete(chatId)
136
+ log(`group ${chatId}: held (${entry.reason}); other groups keep serving`)
137
+ return entry
138
+ }
108
139
  function allowed(chatId) {
109
140
  const route = configured.get(chatId)
110
141
  if (!route || route.paused === true) return false
@@ -155,6 +186,34 @@ function createC2MultiGroupService({ routes, assertWriterLock,
155
186
  work.then(() => recovering.delete(chatId), () => recovering.delete(chatId))
156
187
  return work
157
188
  }
189
+ // One group's route, recall or ingress failed. Close the failed turn when
190
+ // its journal proves every effect; otherwise hold the group alone. Returns
191
+ // 'recovered', 'held', 'fatal' (process stop begun) or 'stopping'.
192
+ async function groupFault(chatId, binding, error) {
193
+ const shared = () => !writerHeld() ? 'writer lock lost' : sharedFault()
194
+ if (shared()) { await fatal(error); return 'fatal' }
195
+ if (!started || stopPromise) return 'stopping'
196
+ if (suspended.has(chatId)) return 'held'
197
+ const current = bindings.get(chatId)
198
+ if (!binding || current !== binding && !failedBindings.has(binding)) {
199
+ // An error from a binding this service never published is not attributable.
200
+ await fatal(error); return 'fatal'
201
+ }
202
+ if (!createRecoveryAuthority) { await fatal(error); return 'fatal' }
203
+ try {
204
+ await recoverGroup(chatId, binding, error)
205
+ } catch (failure) {
206
+ log(`group ${chatId}: failed turn could not be closed: ${reasonOf(failure)}`)
207
+ if (shared()) { await fatal(failure); return 'fatal' }
208
+ if (!started || stopPromise) return 'stopping'
209
+ suspend(chatId, failure, { source: 'runtime', binding })
210
+ }
211
+ return suspended.has(chatId) ? 'held' : 'recovered'
212
+ }
213
+ function heldReply(msg, sourceAdapter) {
214
+ return answerC2InputRejection({ error: rejectC2Input('C2 group held',
215
+ { reason: 'group-held', notice: HELD_NOTICE }), msg, platform: sourceAdapter, log })
216
+ }
158
217
  // Open one more group on the live transport: a newly added route or one
159
218
  // paused since startup. It runs the same recovery gate as startup; only a
160
219
  // fully opened binding is published. Failure leaves the group unserved.
@@ -162,13 +221,16 @@ function createC2MultiGroupService({ routes, assertWriterLock,
162
221
  if (!route || typeof route.chatId !== 'string' || typeof route.file !== 'string') {
163
222
  return Promise.reject(new TypeError('C2 online route needs an explicit group and journal'))
164
223
  }
224
+ if (suspended.has(route.chatId)) {
225
+ return Promise.reject(new Error('This group is held after an unresolved fault; seal its incident and restart'))
226
+ }
165
227
  if (bindings.has(route.chatId)) return Promise.resolve(bindings.get(route.chatId))
166
228
  if (activating.has(route.chatId)) return activating.get(route.chatId)
167
229
  const opening = (async () => {
168
230
  if (!started || starting || stopPromise) throw new Error('C2 service is not serving')
169
231
  held()
170
232
  const binding = await createBinding({ route, transport })
171
- if (!started || stopPromise || !writerHeld() ||
233
+ if (!started || stopPromise || !writerHeld() || suspended.has(route.chatId) ||
172
234
  !binding || !binding.gate || !binding.engine || binding.gate.blocked) {
173
235
  binding?.gate?.stop?.()
174
236
  binding?.engine?.shutdown?.()
@@ -184,6 +246,12 @@ function createC2MultiGroupService({ routes, assertWriterLock,
184
246
  return opening
185
247
  }
186
248
  function routeMessage(msg, sourceAdapter) {
249
+ const route = configured.get(msg?.chatId)
250
+ if (route && route.paused !== true && suspended.has(msg.chatId)) {
251
+ // A held group admits nothing; a replay of a sealed source stays silent.
252
+ if (quarantine?.rejects(msg)) return { quarantined: true }
253
+ if (started && !stopPromise) return heldReply(msg, sourceAdapter)
254
+ }
187
255
  // Foreign/paused group events are not faults and never get a receipt.
188
256
  if (!allowed(msg?.chatId)) return { ignored: true, reason: 'unconfigured-or-paused' }
189
257
  if (quarantine?.rejects(msg)) return { quarantined: true }
@@ -192,32 +260,46 @@ function createC2MultiGroupService({ routes, assertWriterLock,
192
260
  return answerC2InputRejection({ error: rejectC2Input('C2 group recovering',
193
261
  { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
194
262
  }
263
+ let binding
195
264
  try {
196
265
  held()
197
- const binding = bindings.get(msg.chatId)
198
- if (!started || starting || stopPromise || !binding || binding.gate.blocked) {
266
+ binding = bindings.get(msg.chatId)
267
+ if (!started || starting || stopPromise || !binding) {
199
268
  throw new Error('C2 group ingress authority closed')
200
269
  }
270
+ if (binding.gate.blocked) {
271
+ // A turn still running under a closed authority delivers its own
272
+ // rejection; until then this message, which has no receipt, is
273
+ // refused. An idle closed group is closed as its own fault here.
274
+ if (runtimesOf(binding).some(rt => rt.v2NormalRouteActive)) {
275
+ return answerC2InputRejection({ error: rejectC2Input('C2 group closing',
276
+ { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
277
+ }
278
+ return groupFault(msg.chatId, binding, new Error('C2 group ingress authority closed'))
279
+ .then(async outcome => {
280
+ if (outcome === 'held') return heldReply(msg, sourceAdapter)
281
+ if (outcome === 'recovered') {
282
+ return answerC2InputRejection({ error: rejectC2Input('C2 group recovered',
283
+ { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
284
+ }
285
+ throw new Error('C2 group ingress authority closed')
286
+ })
287
+ }
201
288
  const running = binding.engine.runDurableV2Message(msg, sourceAdapter)
202
289
  return Promise.resolve(running).catch(async error => {
203
290
  // A refused command or premature /stop wrote nothing durable. Answer it
204
- // once and keep every group serving; any other error stays fatal.
291
+ // once and keep every group serving.
205
292
  if (isC2InputRejection(error) && started && !stopPromise &&
206
293
  !binding.gate.blocked && writerHeld()) {
207
294
  log(`group ${msg.chatId}: refused input before receipt (${error.reason})`)
208
295
  return answerC2InputRejection({ error, msg, platform: sourceAdapter, log })
209
296
  }
210
- // A closed model turn that failed (error or empty result) is closed for
211
- // this group alone. Other errors from that same superseded binding are
212
- // part of the same fault and wait for its recovery.
213
- if (createRecoveryAuthority && started && !stopPromise &&
214
- (failedBindings.has(binding) ||
215
- error?.code === 'C2_V2_UNRESOLVED_TURN' && bindings.get(msg.chatId) === binding)) {
216
- const recovered = await recoverGroup(msg.chatId, binding, error).then(() => true, failure => {
217
- log(`group ${msg.chatId}: failed turn could not be closed: ${failure?.message || String(failure)}`)
218
- return false
219
- })
220
- if (recovered) return { recovered: true, reason: 'group-fault-closed' }
297
+ // Any other route error belongs to this group alone unless a shared
298
+ // resource failed. Errors from a superseded binding join its fault.
299
+ if (started && !stopPromise) {
300
+ const outcome = await groupFault(msg.chatId, binding, error)
301
+ if (outcome === 'recovered') return { recovered: true, reason: 'group-fault-closed' }
302
+ if (outcome === 'held') return { held: true, reason: 'group-held' }
221
303
  }
222
304
  await fatal(error)
223
305
  throw error
@@ -236,15 +318,22 @@ function createC2MultiGroupService({ routes, assertWriterLock,
236
318
  }
237
319
  }
238
320
  function recall(event) {
321
+ if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
239
322
  if (!allowed(event?.chatId)) return { ignored: true }
323
+ let binding
240
324
  try {
241
325
  held()
242
- const binding = bindings.get(event.chatId)
326
+ binding = bindings.get(event.chatId)
243
327
  if (!started || stopPromise || !binding) return { ignored: true, reason: 'ingress-closed' }
244
328
  const handled = binding.engine.handleRecall?.(event) === true
245
329
  if (binding.runtime?.blocked) throw new Error('Recall closed its durable authority')
246
330
  return handled ? { handled: true } : { ignored: true, reason: 'unsupported-or-inactive-recall' }
247
- } catch (error) { return fatal(error) }
331
+ } catch (error) {
332
+ if (!binding || !writerHeld() || !started || stopPromise) return fatal(error)
333
+ return groupFault(event.chatId, binding, error).then(outcome =>
334
+ outcome === 'recovered' || outcome === 'held'
335
+ ? { ignored: true, reason: `group-${outcome}` } : fatal(error))
336
+ }
248
337
  }
249
338
  async function start({ preflightOnly = false } = {}) {
250
339
  if (started || starting || stopPromise) throw new Error('C2 service already started or stopped')
@@ -260,9 +349,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
260
349
  typeof transport.stopPlatform !== 'function') {
261
350
  throw new TypeError('C2 transport lacks a single start/stop boundary')
262
351
  }
352
+ for (const [chatId, reason] of holds) suspend(chatId, reason, { source: 'startup' })
263
353
  for (const route of routes) {
264
- if (route.paused) continue // Its settled journal is audited before start.
265
- const binding = await createBinding({ route, transport })
354
+ if (route.paused || suspended.has(route.chatId)) continue // Paused journals are audited before start.
355
+ let binding
356
+ try {
357
+ binding = await createBinding({ route, transport })
358
+ } catch (error) {
359
+ held()
360
+ if (stopPromise) throw new Error('C2 service stopped during group recovery')
361
+ // This group's journal could not be recovered; it alone stays closed.
362
+ suspend(route.chatId, error, { source: 'startup' })
363
+ continue
364
+ }
266
365
  held()
267
366
  if (stopPromise) {
268
367
  binding?.gate?.stop?.()
@@ -273,11 +372,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
273
372
  if (!binding || !binding.gate || !binding.engine || binding.gate.blocked) {
274
373
  binding?.gate?.stop?.()
275
374
  binding?.engine?.shutdown?.()
276
- throw new Error('C2 group binding did not open its durable authority')
375
+ suspend(route.chatId, 'C2 group binding did not open its durable authority', { source: 'startup' })
376
+ continue
277
377
  }
278
378
  bindings.set(route.chatId, binding)
279
379
  }
280
380
  held()
381
+ const heldList = [...suspended].map(([chatId, entry]) => `${chatId}: ${entry.reason}`)
382
+ if (preflightOnly && heldList.length) {
383
+ throw new Error(`Startup preflight would hold ${heldList.length} group(s): ${heldList.join('; ')}`)
384
+ }
385
+ if (heldList.length && !bindings.size) {
386
+ throw new Error(`Every active group is held: ${heldList.join('; ')}`)
387
+ }
281
388
  if (preflightOnly) {
282
389
  finishStartup()
283
390
  await stop('preflight', 0)
@@ -291,14 +398,21 @@ function createC2MultiGroupService({ routes, assertWriterLock,
291
398
  onCardAction: unsupported('card action'), onBoardUpdated: unsupported('board update') })
292
399
  held()
293
400
  if (stopPromise) throw new Error('C2 service stopped during connection')
294
- return { groups: bindings.size, connected: true }
401
+ return { groups: bindings.size, connected: true, ...(suspended.size ? { held: heldGroups() } : {}) }
295
402
  } catch (error) {
296
403
  finishStartup()
297
404
  await stop('startup-failed', 1)
298
405
  throw error
299
406
  } finally { starting = false; finishStartup() }
300
407
  }
301
- return { start, stop, routeMessage, addRoute, get bindings() { return new Map(bindings) } }
408
+ function heldGroups() {
409
+ return [...suspended].map(([chatId, entry]) => Object.freeze({ chatId,
410
+ reason: entry.reason, source: entry.source, since: entry.since }))
411
+ }
412
+ return { start, stop, routeMessage, addRoute, heldGroups,
413
+ get bindings() { return new Map(bindings) },
414
+ get held() { return new Map([...suspended].map(([chatId, entry]) =>
415
+ [chatId, { reason: entry.reason, source: entry.source, since: entry.since }])) } }
302
416
  }
303
417
 
304
418
  module.exports = { createC2MultiGroupService }
@@ -29,14 +29,21 @@ function createC2V2SourceQuarantine(value, { required = false } = {}) {
29
29
 
30
30
  // Every sealed incident under <data>/incidents/*/incident.json keeps its
31
31
  // unsettled source messages out of admission for that group, on every start.
32
- // A malformed seal stops startup: an unreadable fence cannot be skipped.
32
+ // A seal written by `sintbot incident seal` also lists the receipts its
33
+ // journal had already settled, so the group's new journal never runs one of
34
+ // those messages again. A malformed seal stops startup: an unreadable fence
35
+ // cannot be skipped.
33
36
  const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
37
+ const RECEIPT_ID = /^[0-9a-f]{32}$/
34
38
  function loadC2IncidentQuarantine(stateDir, { fs = require('node:fs'), path = require('node:path') } = {}) {
35
39
  if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir)) {
36
40
  throw new TypeError('C2 incident quarantine needs the installation directory')
37
41
  }
38
42
  const root = path.join(stateDir, 'incidents')
43
+ // Loaded here so the source quarantine above stays a standalone module.
44
+ const { taskId } = require('./enginecheckpoint')
39
45
  const byChat = new Map()
46
+ const settled = new Map()
40
47
  const fail = name => { throw new Error(`Sealed incident record is invalid: ${name}; preserve it and review`) }
41
48
  let entries = []
42
49
  try { entries = fs.readdirSync(root, { withFileTypes: true }) }
@@ -53,14 +60,26 @@ function loadC2IncidentQuarantine(stateDir, { fs = require('node:fs'), path = re
53
60
  try { record = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { fail(entry.name) }
54
61
  if (record?.schema !== 'sbot-c2-incident-seal-v1' || !CHAT_ID.test(record.chatId || '') ||
55
62
  !Array.isArray(record.pending) ||
56
- record.pending.some(item => !FEISHU_MESSAGE_ID.test(item?.sourceId || ''))) fail(entry.name)
63
+ record.pending.some(item => !FEISHU_MESSAGE_ID.test(item?.sourceId || '')) ||
64
+ record.completedReceiptIds !== undefined && (!Array.isArray(record.completedReceiptIds) ||
65
+ record.completedReceiptIds.some(id => typeof id !== 'string' || !RECEIPT_ID.test(id)))) fail(entry.name)
57
66
  const ids = byChat.get(record.chatId) || new Set()
58
67
  for (const item of record.pending) ids.add(item.sourceId)
59
68
  byChat.set(record.chatId, ids)
69
+ if (record.completedReceiptIds?.length) {
70
+ const done = settled.get(record.chatId) || new Set()
71
+ for (const id of record.completedReceiptIds) done.add(id)
72
+ settled.set(record.chatId, done)
73
+ }
60
74
  }
61
75
  const sourceIds = Object.freeze([...byChat.values()].flatMap(ids => [...ids]))
76
+ const settledBefore = message => {
77
+ const done = settled.get(message?.chatId)
78
+ if (!done || typeof message.msgId !== 'string' || !message.msgId) return false
79
+ return done.has(taskId({ source: 'message', chatId: message.chatId, sourceId: message.msgId }))
80
+ }
62
81
  return Object.freeze({ sourceIds,
63
- rejects: message => !!byChat.get(message?.chatId)?.has(message?.msgId) })
82
+ rejects: message => !!byChat.get(message?.chatId)?.has(message?.msgId) || settledBefore(message) })
64
83
  }
65
84
 
66
85
  module.exports = { createC2V2SourceQuarantine, loadC2IncidentQuarantine }
@@ -596,6 +596,7 @@ async runDurableV2Message(msg, platform) {
596
596
  rt.v2NormalRouteActive = true
597
597
  const execute = async () => {
598
598
  let release = null
599
+ let failed = false
599
600
  try {
600
601
  let receiptId = accepted.receiptId
601
602
  if (this.durableV2AcquireCapacity) {
@@ -628,12 +629,13 @@ async runDurableV2Message(msg, platform) {
628
629
  // Seal shared grants before releasing this turn's permit. Waiting
629
630
  // siblings must not dispatch in the microtask before the service sees
630
631
  // this rejection and begins its process-wide stop.
632
+ failed = true
631
633
  this.durableV2SealCapacity?.(error)
632
634
  throw error
633
635
  } finally {
634
636
  rt.v2AdmissionPending = false
635
637
  rt.v2AdmissionController = null
636
- release?.()
638
+ if (release) this._releaseDurableV2Capacity(rt, release, failed)
637
639
  }
638
640
  }
639
641
  const execution = execute()
@@ -649,6 +651,24 @@ async runDurableV2Message(msg, platform) {
649
651
  }
650
652
  }
651
653
 
654
+ // A failed route returns its shared model permit only once its runner is
655
+ // proven closed or proven never started. Otherwise the permit stays taken
656
+ // until the process exits, so a sibling group never runs beside a child that
657
+ // may still be alive.
658
+ _releaseDurableV2Capacity(rt, release, failed) {
659
+ const closed = () => !rt.v2Controller && (rt.v2RunnerStopVerified === true ||
660
+ rt.v2RunnerNotStarted === true)
661
+ const stop = rt.v2RunnerStopPromise
662
+ if (!failed || !rt.v2Controller && (!stop || closed())) return release()
663
+ const kept = () => this.log(`group ${rt.chatId}: model permit kept until restart; runner close unproven`)
664
+ if (!stop) return kept()
665
+ stop.then(() => {
666
+ if (!closed()) return kept()
667
+ // A failed binary release closes the shared capacity itself.
668
+ try { release() } catch (error) { this.log(`group ${rt.chatId}: model permit release failed: ${error.message}`) }
669
+ }, kept)
670
+ }
671
+
652
672
  async _runDurableV2Steer(rt, receiptId) {
653
673
  const authority = this.durableV2Runtime
654
674
  const chatId = rt.chatId
@@ -1323,6 +1343,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1323
1343
  rt.v2Controller = controller
1324
1344
  rt.v2RunnerStopPromise = null
1325
1345
  rt.v2RunnerStopVerified = false
1346
+ rt.v2RunnerNotStarted = false
1326
1347
  let streamChain = Promise.resolve()
1327
1348
  let streamFailure = null
1328
1349
  const enqueueFrame = event => {
@@ -1508,6 +1529,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1508
1529
  rt.v2RunnerGeneration = null
1509
1530
  rt.v2RunningJobs = null
1510
1531
  rt.v2DispatchEffect = null
1532
+ rt.v2RunnerNotStarted = true
1511
1533
  return { effectId, accepted: false, startFailureRecovered: true }
1512
1534
  }
1513
1535
  if (capability && !accepted) {
@@ -392,6 +392,7 @@ code{font-family:var(--mono);font-size:.95em} /* 中文说明里内嵌的命令/
392
392
  }
393
393
  .tag{font-family:var(--mono);font-size:10.5px;letter-spacing:.5px;padding:3px 8px;border-radius:5px;border:1px solid var(--tag-line,var(--line-hi));color:var(--tag-ink,var(--dim));background:var(--tag-bg,transparent);white-space:nowrap}
394
394
  .tag.busy{color:var(--ember);border-color:rgba(var(--ember-rgb),.4);box-shadow:0 0 10px rgba(var(--ember-rgb),.12) inset}
395
+ .tag.held{color:var(--fail);border-color:rgba(var(--fail-rgb),.42);background:rgba(var(--fail-rgb),.08)}
395
396
  .tag.idle{color:var(--ok);border-color:rgba(var(--ok-rgb),.28)}
396
397
  .tag.pz{color:var(--muted);border-color:var(--line-hi);background:transparent} /* 暂停态:中性灰,不是错误(bad 红留给真异常) */
397
398
  .card.gpaused{opacity:.55;transition:opacity .15s} /* 暂停群卡整体降透明度示意下线;hover 提回可读 —— 配置项不禁用,暂停期间照样可调 */
@@ -1353,9 +1354,9 @@ function updateOverviewLive(){
1353
1354
  const a=$('#ovActive'); if(a)a.textContent=active;
1354
1355
  const q=$('#ovQueue'); if(q)q.textContent=live.queue||0;
1355
1356
  $$('#overview [data-g]').forEach(el=>{const lv=(live.groups&&live.groups[el.dataset.g])||{};
1356
- el.className='tag glive '+(lv.busy?'busy':'idle');
1357
- el.textContent=lv.queueFaulted?'待处理异常':lv.waitingForCapacity?'等待容量':lv.finalizing?'收尾中':(lv.busy?('busy '+(lv.sinceMs?Math.round(lv.sinceMs/1000)+'s':'')):'idle');
1358
- el.title=(S.runtimeCapabilities?(lv.queueFaulted?'服务当前未开放此群':lv.waitingForCapacity?'消息已登记,等待并发名额':lv.finalizing?'正在确认发送与结账结果':lv.busy?'运行中':'空闲'):(lv.finalizing?'答案已发送 · 进程收尾中(新消息会排队)':(lv.busy?'运行中':'空闲')))+(lv.sessionId?(' · 会话 #'+String(lv.sessionId).slice(-6)):' · 无会话');});
1357
+ el.className='tag glive '+(lv.held?'held':lv.busy?'busy':'idle');
1358
+ el.textContent=lv.held?'需要处理':lv.queueFaulted?'待处理异常':lv.waitingForCapacity?'等待容量':lv.finalizing?'收尾中':(lv.busy?('busy '+(lv.sinceMs?Math.round(lv.sinceMs/1000)+'s':'')):'idle');
1359
+ el.title=lv.held?('上一个任务结果不明,此群已暂停,其他群照常。原因:'+(lv.heldReason||'未知')+'。处理:先重启服务,能自动收尾的会恢复;重启后仍需处理,再停止服务,运行 sintbot incident seal --group '+el.dataset.g+',然后启动。'):(S.runtimeCapabilities?(lv.queueFaulted?'服务当前未开放此群':lv.waitingForCapacity?'消息已登记,等待并发名额':lv.finalizing?'正在确认发送与结账结果':lv.busy?'运行中':'空闲'):(lv.finalizing?'答案已发送 · 进程收尾中(新消息会排队)':(lv.busy?'运行中':'空闲')))+(lv.sessionId?(' · 会话 #'+String(lv.sessionId).slice(-6)):' · 无会话');});
1359
1360
  // Self-heal stuck 'running' rows: a done SSE frame can be lost on a reconnect gap and
1360
1361
  // SSE has no replay, so the row would spin forever. Per-group turns are serialized, so a
1361
1362
  // 'running' row whose group is no longer busy has finished (busy=false ⇔ the turn settled
@@ -4029,6 +4030,7 @@ updateThemeBtn();
4029
4030
  /* I18N_DICT_BEGIN —— 合并翻译片段时整块替换这两个数组,不要改此标记之外的代码
4030
4031
  ⚠ 正则一律 ^…$ 整串、不加 g 标志;更具体的条目必须排在泛化条之前。 */
4031
4032
  const I18N_EN=[
4033
+ ["需要处理", "Needs attention"],
4032
4034
  ["当前可用操作", "Available controls"],
4033
4035
  ["当前支持多群消息、流式卡片回复、忙时跟进、停止任务、状态查看、认证管理、绑定新群,以及空闲群的模型(Codex / Claude Code)和项目目录切换。新会话、解除绑定、控制台插话、定时任务、生图、画布、预览与升级暂不可用。", "Available: group messages, streaming reply cards, busy-turn followups, stopping tasks, live status, authentication, binding new groups, and switching the model (Codex / Claude Code) or project of an idle group. New sessions, unbinding, console messages, schedules, image generation, canvas, previews and upgrades are unavailable."],
4034
4036
  ["可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务在「概览」。群里发 /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 in Overview. Members can also switch models with /model in the group."],