sintbot 1.4.0 → 1.4.2

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,42 @@
1
1
  [
2
+ {
3
+ "version": "1.4.2",
4
+ "date": "2026-11-09",
5
+ "name": "Check and install new versions from the console",
6
+ "items": [
7
+ {
8
+ "t": "Version checks in the console",
9
+ "d": "Software updates in Settings checks for stable releases at startup and daily and shows release notes when a new version is available."
10
+ },
11
+ {
12
+ "t": "One-click upgrade for background runs",
13
+ "d": "When a launchd agent runs SintBot, Upgrade now waits until every group is idle and settled, installs the new version into its own folder and switches to it; if it does not connect every group within 90 seconds, the previous version is restored. New messages wait about a minute during the switch and are asked to be resent."
14
+ },
15
+ {
16
+ "t": "Steps for foreground runs",
17
+ "d": "A foreground run shows how to stop, reinstall and start. Upgrades are not announced in groups."
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "version": "1.4.1",
23
+ "date": "2026-11-09",
24
+ "name": "A failing group stops only itself",
25
+ "items": [
26
+ {
27
+ "t": "One group's fault leaves the others working",
28
+ "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."
29
+ },
30
+ {
31
+ "t": "One group no longer blocks startup",
32
+ "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."
33
+ },
34
+ {
35
+ "t": "Incident seal command",
36
+ "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."
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "version": "1.4.0",
4
42
  "date": "2026-11-08",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "version": "1.4.2",
4
+ "date": "2026-11-09",
5
+ "name": "在控制台查看和安装新版本",
6
+ "items": [
7
+ {
8
+ "t": "控制台检查新版本",
9
+ "d": "设置页的软件更新启动时和每天各检查一次正式版,有新版本时提示并附发布说明。"
10
+ },
11
+ {
12
+ "t": "后台运行时一键升级",
13
+ "d": "由 launchd 后台任务运行时,点「现在升级」会等所有群空闲、记录结清后,把新版本装到单独的目录再切换;新版本 90 秒内没连上全部群会自动换回旧版。切换期间约一分钟不处理新消息,会提示稍后重发。"
14
+ },
15
+ {
16
+ "t": "前台运行给出升级步骤",
17
+ "d": "前台运行时卡片说明如何停止、重新安装并启动。升级不在群里播报。"
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "version": "1.4.1",
23
+ "date": "2026-11-09",
24
+ "name": "一个群出问题,只停这个群",
25
+ "items": [
26
+ {
27
+ "t": "一个群出问题,其他群照常",
28
+ "d": "某个群上一个任务的结果无法确认时(例如回复是否送达不明,或执行器退出时没有结果),只暂停这个群,不重放、不重试,其他群继续处理。暂停的群收到新消息会回一句说明。"
29
+ },
30
+ {
31
+ "t": "启动时不再被一个群拖住",
32
+ "d": "某个群的记录恢复不了时,其他群照常启动,控制台把这个群标为「需要处理」。启动预检遇到这样的群会直接失败并列出原因。"
33
+ },
34
+ {
35
+ "t": "新增封存命令",
36
+ "d": "先重启服务;仍然暂停的群,在服务停止后运行 sintbot incident seal --group 群 ID。原记录原样封存,这个群换一份新记录重新开放,封存前未完成的消息不会再处理。"
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "version": "1.4.0",
4
42
  "date": "2026-11-08",
package/bin/sintbot-v2.js CHANGED
@@ -112,6 +112,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
112
112
  let service
113
113
  let ui
114
114
  let configControl
115
+ let updater
115
116
  let liveTransport
116
117
  let onSigint, onSigterm
117
118
  try {
@@ -122,11 +123,18 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
122
123
  for (const name of ['pending-turns.json', 'engine-receipts.json']) {
123
124
  if (fs.existsSync(path.join(stateDir, name))) throw new Error('Legacy pending authority needs explicit migration')
124
125
  }
126
+ // A journal that cannot be opened or audited holds only its own group.
125
127
  const journals = new Map()
128
+ const holds = new Map()
126
129
  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)
130
+ try {
131
+ const journal = C2DurableJournal.open(route.file, validators)
132
+ assertJournalScope(journal, route.chatId, { requireSettled: preflightOnly || route.paused })
133
+ journals.set(route.chatId, journal)
134
+ } catch (error) {
135
+ if (!assertWriterLock()) throw error
136
+ holds.set(route.chatId, error?.message || String(error))
137
+ }
130
138
  }
131
139
  // All group Engines must share this exact object: separate Store caches
132
140
  // would overwrite sibling sessions when they save the same state file.
@@ -136,6 +144,13 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
136
144
  'codex-exec': require('../src/agent/codexcode').isClosedRunnerResult,
137
145
  'codex-app': require('../src/agent/codexapp').isClosedRunnerResult,
138
146
  }
147
+ // A runner that failed from its own exit event has closed; this frees its
148
+ // model permit but never proves the turn's outcome.
149
+ const closeErrorChecks = {
150
+ claude: require('../src/agent/claudecode').isClosedRunnerError,
151
+ 'codex-exec': require('../src/agent/codexcode').isClosedRunnerError,
152
+ 'codex-app': require('../src/agent/codexapp').isClosedRunnerError,
153
+ }
139
154
  const limits = { maxEventCount: 100_000, maxJournalBytes: 64 * 1024 * 1024,
140
155
  maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
141
156
  let finishing = false
@@ -143,12 +158,15 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
143
158
  if (finishing) return
144
159
  finishing = true
145
160
  ui?.close()
161
+ updater?.close()
146
162
  lock.release()
147
163
  process.stderr.write(`C2 multi-group · ${result.reason}; platform stopped=${result.platformStopped}; runner drained=${result.drained}\n`,
148
164
  () => process.exit(result.code))
149
165
  }
150
166
  service = createC2MultiGroupService({ routes, assertWriterLock, quarantine: combinedQuarantine,
151
- closeCapacity: () => capacity.close(),
167
+ holds, closeCapacity: () => capacity.close(),
168
+ // Faults in a shared resource stop every group; others hold one group.
169
+ sharedFault: () => capacity.snapshot().closed ? 'model concurrency closed' : null,
152
170
  log: message => process.stderr.write(`C2 multi-group · ${message}\n`),
153
171
  onFatal: (error, result) => {
154
172
  process.stderr.write(`C2 multi-group failure: ${error?.message || String(error)}\n`)
@@ -199,10 +217,15 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
199
217
  verifyFile: path.join(stateDir, 'verify.json'), assertWriterLock,
200
218
  resolveTurn: identity => resolveC2LocalEffectTurn(engine, identity) })
201
219
  const closedResults = new WeakSet()
220
+ const closedErrors = new WeakSet()
202
221
  const startTurn = async input => {
203
222
  const backend = routeBackend(input.provider?.runner, input.codexApp,
204
223
  input.codexSteer, input.codexProjectDocFallback)
205
- const result = await runner.runTurn(input)
224
+ let result
225
+ try { result = await runner.runTurn(input) } catch (error) {
226
+ if (closeErrorChecks[backend]?.(error) === true) closedErrors.add(error)
227
+ throw error
228
+ }
206
229
  if (closeChecks[backend]?.(result) === true) closedResults.add(result)
207
230
  return result
208
231
  }
@@ -210,14 +233,15 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
210
233
  store, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
211
234
  durableV2Runtime: runtime, durableV2Payloads: handles,
212
235
  durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
213
- durableV2VerifyRunnerClosed: ({ result }) => !!result &&
214
- typeof result === 'object' && closedResults.has(result),
236
+ durableV2VerifyRunnerClosed: ({ result, error }) => result
237
+ ? typeof result === 'object' && closedResults.has(result)
238
+ : !!error && typeof error === 'object' && closedErrors.has(error),
215
239
  durableV2AssertWriterLock: assertWriterLock, durableV2Journal: journal,
216
240
  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.
241
+ // A route fault holds or recovers its group alone. Shared grants
242
+ // are sealed only when the process writer lease itself is gone.
219
243
  durableV2SealCapacity: error => {
220
- if (error?.code !== 'C2_V2_UNRESOLVED_TURN') capacity.close(error)
244
+ if (assertWriterLock() !== true) capacity.close(error)
221
245
  },
222
246
  durableV2SwitchProvider: (chatId, name) => {
223
247
  if (chatId !== route.chatId) throw new Error('Model switch targets another group')
@@ -231,9 +255,26 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
231
255
  } })
232
256
  // One configuration writer for the console and the chat /model command.
233
257
  configControl = require('../src/c2configcontrol').createC2ConfigControl({
234
- installation, getBindings: () => service.bindings, assertWriterLock, store,
235
- activateRoute: route => service.addRoute(route),
258
+ installation, getBindings: () => service.bindings, getHeld: () => service.held,
259
+ assertWriterLock, store,
260
+ activateRoute: route => service.addRoute(route), isFrozen: () => service.inMaintenance,
236
261
  log: message => process.stderr.write(`C2 multi-group · ${message}\n`) })
262
+ if (!preflightOnly) {
263
+ // Console software updates. Only a launchd job that starts this exact
264
+ // entry may upgrade, and only while every group is idle and settled.
265
+ const c2updates = require('../src/c2updater')
266
+ const pkg = require('../package.json')
267
+ const upgradeGate = c2updates.createC2UpgradeGate({ isRunning: () => running,
268
+ service: () => service, capacity, assertJournalScope })
269
+ updater = c2updates.createC2Updater({ currentVersion: pkg.version,
270
+ runMode: c2updates.detectRunMode({ privateBuild: pkg.private === true, entry: __filename }),
271
+ stateFile: path.join(stateDir, 'update.json'), dataDir: stateDir, configPath: cfg._path,
272
+ gate: upgradeGate, beginMaintenance: () => service.beginMaintenance(),
273
+ endMaintenance: () => service.endMaintenance(), expectedGroups: () => service.bindings.size,
274
+ registries: c2updates.registriesFromEnv(),
275
+ fetchImpl: require('../src/core/proxy').getProxyFetch() || globalThis.fetch,
276
+ autoCheck: uiEnabled, log: message => process.stderr.write(`C2 update · ${message}\n`) })
277
+ }
237
278
  const stop = reason => { void service.stop(reason, 0).then(finish) }
238
279
  onSigint = () => stop('SIGINT')
239
280
  onSigterm = () => stop('SIGTERM')
@@ -243,9 +284,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
243
284
  ui = require('../src/c2console').createC2Console({
244
285
  configPath: cfg._path, stateDir, host: uiHost,
245
286
  getConfig: () => cfg, getBindings: () => service.bindings,
246
- getCapacity: () => capacity.snapshot(),
287
+ getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
247
288
  getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
248
- assertWriterLock, ...configControl,
289
+ assertWriterLock, ...configControl, updater,
249
290
  log: message => process.stderr.write(`Console · ${message}\n`) })
250
291
  await new Promise((resolve, reject) => {
251
292
  ui.server.once('error', reject)
@@ -261,6 +302,13 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
261
302
  process.on('exit', lock.release)
262
303
  running = true
263
304
  process.stderr.write(`SintBot Feishu ingress connected for ${started.groups} groups\n`)
305
+ for (const item of started.held || []) {
306
+ process.stderr.write(`SintBot group ${item.chatId} is held: ${item.reason}\n`)
307
+ }
308
+ // A console upgrade that switched to this version is done once every
309
+ // group is connected; then the daily version check starts.
310
+ if (!started.held?.length) updater?.confirmStarted()
311
+ void updater?.start()
264
312
  } catch (error) {
265
313
  ui?.close()
266
314
  const stopped = await service?.stop('startup-failed', 1)
@@ -271,6 +319,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
271
319
  throw error
272
320
  } finally {
273
321
  if (!running) {
322
+ updater?.close()
274
323
  if (onSigint) process.off('SIGINT', onSigint)
275
324
  if (onSigterm) process.off('SIGTERM', onSigterm)
276
325
  capacity.close(); lock.release()
@@ -303,6 +352,7 @@ const HELP = `SintBot macOS Feishu multi-group candidate
303
352
  Usage:
304
353
  sintbot init [--config <absolute path>] [--data-dir <absolute path>]
305
354
  sintbot start [--config <absolute path>] [--data-dir <absolute path>] [--preflight] [--ui] [--ui-host <address>] [--ui-port <port>] [--no-open]
355
+ sintbot incident seal --group <oc_…> [--config <absolute path>] [--data-dir <absolute path>] [--reason <text>]
306
356
  sintbot version
307
357
  sintbot help
308
358
 
@@ -311,7 +361,10 @@ Initialization is for a new installation only; existing journals are never reset
311
361
  Console shows live groups, binds groups and switches models or projects of idle groups;
312
362
  in a group, /model lists or switches models. Unsupported actions stay disabled.
313
363
  Canvas, scheduled tasks, terminal chat and other platforms are unavailable.
314
- Group faults stop the shared process safely; unknown effects are never replayed.
364
+ A group whose last result cannot be proven is held alone; the other groups keep serving.
365
+ Restart first: a journal that can recover reopens on its own. If the group is still held,
366
+ stop the service, run incident seal for that group, then start again.
367
+ Sealing keeps the old journal unchanged; unknown effects are never replayed.
315
368
  `
316
369
 
317
370
  async function main(argv = process.argv.slice(2)) {
@@ -326,10 +379,35 @@ async function main(argv = process.argv.slice(2)) {
326
379
  process.stdout.write(HELP)
327
380
  return
328
381
  }
329
- if (!['init', 'start'].includes(sub)) {
382
+ if (sub === 'upgrade-switch') {
383
+ // Internal: started detached by a console upgrade; never run it by hand.
384
+ const { values } = parseArgs({ args: rest, allowPositionals: false, options: { state: { type: 'string' } } })
385
+ if (!values.state || !path.isAbsolute(values.state)) throw new Error('upgrade-switch needs --state <absolute path>')
386
+ const outcome = await require('../src/c2upgradeswitch').runUpgradeSwitch({ stateFile: values.state })
387
+ process.exitCode = outcome === 'done' ? 0 : 1
388
+ return
389
+ }
390
+ if (!['init', 'start', 'incident'].includes(sub)) {
330
391
  throw new Error(`${sub} is unavailable in this release; use sintbot help`)
331
392
  }
332
393
  if (process.platform !== 'darwin') throw new Error('this release supports macOS only')
394
+ if (sub === 'incident') {
395
+ const [action, ...args] = rest
396
+ if (rest.some(arg => arg === '--help' || arg === '-h')) { process.stdout.write(HELP); return }
397
+ if (action !== 'seal') throw new Error('Usage: sintbot incident seal --group <oc_…> [--config <path>] [--data-dir <path>]')
398
+ const { values } = parseArgs({ args, allowPositionals: false, options: {
399
+ config: { type: 'string' }, 'data-dir': { type: 'string' },
400
+ group: { type: 'string' }, reason: { type: 'string' } } })
401
+ const installation = require('../src/c2install').loadInstallation(values.config,
402
+ { dataDir: values['data-dir'], allowAddedGroups: true })
403
+ const result = require('../src/c2incident').sealC2GroupIncident({ installation,
404
+ chatId: values.group, ...(values.reason !== undefined ? { reason: values.reason } : {}),
405
+ version: require('../package.json').version })
406
+ process.stdout.write(`Sealed the group's previous journal unchanged in ${result.incident}.\n` +
407
+ `${result.refusedSources} unsettled message(s) will not be processed again. ` +
408
+ 'Start the service to reopen the group with a new journal.\n')
409
+ return
410
+ }
333
411
  if (sub === 'init') {
334
412
  const { values } = parseArgs({ args: rest, allowPositionals: false,
335
413
  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.2 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.2 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.2
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.2` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.2`. 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,13 @@ 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
+
107
+ Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. Upgrades are not announced in groups.
106
108
 
107
109
  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
110
 
@@ -114,6 +116,12 @@ Press Ctrl-C and wait for exit. With the Feishu variables set, run:
114
116
  "$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
115
117
  ```
116
118
 
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.
119
+ 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:
120
+
121
+ ```bash
122
+ "$HOME/.local/share/sintbot/bin/sintbot" incident seal --config "$HOME/.sbot/config.json" --group oc_PAUSED_GROUP_ID
123
+ ```
124
+
125
+ 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
126
 
119
127
  [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.2
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.2。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.0
1
+ # 安装 SintBot 1.4.2
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.2` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.2` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -98,11 +98,13 @@ 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
+
107
+ 「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级不在群里播报。
106
108
 
107
109
  不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
108
110
 
@@ -114,6 +116,12 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
114
116
  "$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
115
117
  ```
116
118
 
117
- 如果启动提示有未结收据,或任务结果不明,保留日志并核对实际结果;不要通过删除日志或重复发送来绕过。一个飞书应用同一时间只运行一个连接进程。本版不自动注册后台服务。
119
+ 某个群一直暂停时,先重启:记录能恢复的群会自己重新开放,并提示一次重新发送。重启后仍暂停,先核对这个群实际发生了什么,再停止服务,封存这个群的事故记录:
120
+
121
+ ```bash
122
+ "$HOME/.local/share/sintbot/bin/sintbot" incident seal --config "$HOME/.sbot/config.json" --group oc_暂停的群ID
123
+ ```
124
+
125
+ 封存会把原日志原样留在数据目录的 `incidents/` 下,并给这个群换一份新日志;封存前没处理完的消息不会再处理。再次启动服务,这个群就重新开放。有群会保持暂停时,预检会失败。不要通过删除日志或重复发送来绕过暂停。一个飞书应用同一时间只运行一个连接进程。本版不自动注册后台服务。
118
126
 
119
127
  [带图示的官网指南](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.2
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.2",
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,10 +58,13 @@
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",
64
65
  "src/c2multigroupservice.js",
66
+ "src/c2updater.js",
67
+ "src/c2upgradeswitch.js",
65
68
  "src/cdp.js",
66
69
  "src/config.js",
67
70
  "src/core/c2cardeffectbridge.js",
@@ -128,6 +131,7 @@
128
131
  "src/ui/serverbase.js",
129
132
  "src/ui/sessions.js",
130
133
  "src/ui/takeover.js",
134
+ "src/updater.js",
131
135
  "src/usage.js"
132
136
  ],
133
137
  "scripts": {
@@ -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,
22
- log = () => {} } = {}) {
20
+ function createC2ConfigControl({ installation, getBindings, getHeld = () => new Map(),
21
+ assertWriterLock, store = null, activateRoute = null, probeRunner = assertRunnerExecutable,
22
+ isFrozen = () => false, log = () => {} } = {}) {
23
23
  if (!installation?.cfg?._path || typeof getBindings !== 'function' ||
24
+ typeof getHeld !== 'function' || typeof isFrozen !== 'function' ||
24
25
  typeof assertWriterLock !== 'function' ||
25
26
  store !== null && typeof store.resetGroupSession !== 'function' ||
26
27
  activateRoute !== null && typeof activateRoute !== 'function' ||
@@ -46,6 +47,7 @@ function createC2ConfigControl({ installation, getBindings, assertWriterLock,
46
47
  function validateConfig(raw) {
47
48
  prepared = null
48
49
  held()
50
+ if (isFrozen()) throw new Error('SintBot is upgrading; settings cannot change until it restarts')
49
51
  const next = loadInstallationFromRaw(raw, cfg._path, { dataDir: stateDir,
50
52
  allowAddedGroups: !!activateRoute && !!store })
51
53
  const bindings = getBindings()
@@ -72,11 +74,19 @@ function createC2ConfigControl({ installation, getBindings, assertWriterLock,
72
74
  throw new Error('Changing global concurrency requires a service restart')
73
75
  }
74
76
  const resets = [], activations = [], changed = []
77
+ const heldGroups = getHeld()
75
78
  for (const route of routes) {
76
79
  const target = next.routes.find(item => item.chatId === route.chatId)
77
80
  if (route.group.platform !== target.group.platform) {
78
81
  throw new Error('Moving a group to another platform requires an explicit state migration')
79
82
  }
83
+ if (heldGroups.has(route.chatId)) {
84
+ // Edits elsewhere proceed; the held group keeps its exact settings.
85
+ if (!isDeepStrictEqual(route.group, target.group)) {
86
+ throw new Error('This group is held after an unresolved fault; seal its incident and restart before changing it')
87
+ }
88
+ continue
89
+ }
80
90
  if (!isDeepStrictEqual(route.group, target.group)) changed.push(route.chatId)
81
91
  if (RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))) {
82
92
  if (!store) throw new Error('Changing model or project requires session reset authority')