sintbot 1.4.5 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.json CHANGED
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "1.4.6",
4
+ "date": "2026-11-09",
5
+ "name": "A rejected Codex resume no longer holds the group",
6
+ "items": [
7
+ {
8
+ "t": "Fix: a group held when Codex rejected a resume",
9
+ "d": "When Codex refused to continue the previous conversation before doing any work (for example a damaged Codex session history on the machine, or a conversation whose record is gone), the whole group was paused until an administrator released it. Now the group gets one \"the last turn did not finish, please send it again\" notice, keeps serving, and starts a new conversation, so resending works."
10
+ },
11
+ {
12
+ "t": "Fix: a queued message stalled the group after a start failure",
13
+ "d": "When the model could not start (program missing or resume rejected), messages queued behind it each get a resend notice; before, the next new message was treated as a busy group and the whole group was paused."
14
+ }
15
+ ]
16
+ },
2
17
  {
3
18
  "version": "1.4.5",
4
19
  "date": "2026-11-09",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "1.4.6",
4
+ "date": "2026-11-09",
5
+ "name": "Codex 续接被拒不再挂群",
6
+ "items": [
7
+ {
8
+ "t": "修复:Codex 续接被拒时整群挂起",
9
+ "d": "Codex 还没开始干活就拒绝接着之前的对话(比如本机 Codex 的会话历史损坏,或那段对话的记录已不存在)时,此前整个群会被暂停,要管理员人工解封。现在只回一条「上一轮没有完成,请重新发送」,群照常服务,并自动开始新的一段对话,重发即可。"
10
+ },
11
+ {
12
+ "t": "修复:任务没能启动时,排在后面的消息让群卡住",
13
+ "d": "模型没能启动(找不到程序或续接被拒)时,排在后面的消息也各收到一条重发提醒;此前之后的新消息会被当成「群里有任务」而整群暂停。"
14
+ }
15
+ ]
16
+ },
2
17
  {
3
18
  "version": "1.4.5",
4
19
  "date": "2026-11-09",
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.5 with an AI assistant
1
+ # Install SintBot 1.4.6 with an AI assistant
2
2
 
3
3
  English · [简体中文](./zh-CN/AI-ASSISTED-INSTALL.md)
4
4
 
@@ -15,5 +15,5 @@ Use the terminal on the Mac that will run SintBot. A cloud shell cannot install
15
15
  ## Copyable request
16
16
 
17
17
  ```text
18
- Install SintBot 1.4.5 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.6 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
19
19
  ```
package/docs/install.md CHANGED
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.5
1
+ # Install SintBot 1.4.6
2
2
 
3
3
  English · [简体中文](./zh-CN/install.md)
4
4
 
@@ -10,7 +10,7 @@ You need a Mac, Node.js 22 or newer, and projects stored on that Mac. Install an
10
10
  curl -fsSL https://sintbot.com/install.sh | bash
11
11
  ```
12
12
 
13
- The installer checks the environment, installs `sintbot@1.4.5` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.5`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.6` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.6`. No sudo is needed.
14
14
 
15
15
  To preview, append `-s -- --dry-run`. To choose another location, set `SINTBOT_INSTALL_DIR` and adjust the executable paths below.
16
16
 
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.5
1
+ # 用 AI 助手安装 SintBot 1.4.6
2
2
 
3
3
  [English](../AI-ASSISTED-INSTALL.md) · 简体中文
4
4
 
@@ -15,5 +15,5 @@
15
15
  ## 可复制的请求
16
16
 
17
17
  ```text
18
- 按一行安装指南,在这台 Mac 安装 SintBot 1.4.5。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.6。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.5
1
+ # 安装 SintBot 1.4.6
2
2
 
3
3
  [English](../install.md) · 简体中文
4
4
 
@@ -10,7 +10,7 @@
10
10
  curl -fsSL https://sintbot.com/install.sh | bash
11
11
  ```
12
12
 
13
- 安装器会自动检查环境,把 `sintbot@1.4.5` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.5` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.6` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.6` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
package/install.sh CHANGED
@@ -2,7 +2,7 @@
2
2
  # The function is parsed in full before installation starts, including when piped.
3
3
  sintbot_install() (
4
4
  set -eu
5
- version=1.4.5
5
+ version=1.4.6
6
6
  prefix="${SINTBOT_INSTALL_DIR:-$HOME/.local/share/sintbot}"
7
7
  fail() { printf 'SintBot: %s\n' "$*" >&2; exit 1; }
8
8
  case "${1:-}" in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sintbot",
3
- "version": "1.4.5",
3
+ "version": "1.4.6",
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": {
@@ -66,11 +66,12 @@ function runTurn(opts = {}) {
66
66
  const {
67
67
  bin = 'codex', prompt, provider = {}, model, workdir, sessionId,
68
68
  permissionMode, autocompactTokens, appendSystemPrompt, codexApp, codexProjectDocFallback, extraEnv,
69
- onEvent, onDurableEvent, onSteerReady, onSteerLanded, onTurnAccepted, signal, log = () => {},
69
+ onEvent, onDurableEvent, onSteerReady, onSteerLanded, onTurnAccepted, onSpawnFailed, signal, log = () => {},
70
70
  softStopGraceMs = 10000, spawnFn = spawn,
71
71
  } = opts
72
72
  if (!prompt) return Promise.reject(new Error('runTurn: prompt required'))
73
73
  if (onDurableEvent !== undefined && typeof onDurableEvent !== 'function') return Promise.reject(new TypeError('onDurableEvent must be a function'))
74
+ if (onSpawnFailed !== undefined && typeof onSpawnFailed !== 'function') return Promise.reject(new TypeError('onSpawnFailed must be a function'))
74
75
 
75
76
  return new Promise((resolve, reject) => {
76
77
  const env = { ...process.env, ...(extraEnv || {}) }
@@ -104,6 +105,12 @@ function runTurn(opts = {}) {
104
105
  let retriedEffort = false
105
106
  let pendingTurnStart = false
106
107
  let acceptedTurnId = null
108
+ // Pre-turn rejection witness: the server answered initialize or the
109
+ // thread request with an RPC error before any turn/start was written and
110
+ // before any durable event. Only the child's own close completes it.
111
+ let turnStartWritten = false
112
+ let durableEmitted = false
113
+ let preTurnRejection = null
107
114
  const deferredTurnNotices = []
108
115
  let deferredInit = null
109
116
  const earlyCompleted = new Map()
@@ -119,6 +126,7 @@ function runTurn(opts = {}) {
119
126
  const emit = (ev) => {
120
127
  if (!ev) return
121
128
  if (!onDurableEvent) { legacyEmit(ev); return }
129
+ durableEmitted = true
122
130
  durableWork = durableWork.then(async () => {
123
131
  let timer
124
132
  try {
@@ -210,6 +218,37 @@ function runTurn(opts = {}) {
210
218
  killChild()
211
219
  settle(() => reject(error))
212
220
  }
221
+ // A durable turn whose initialize or thread request the server itself
222
+ // rejected never wrote turn/start and emitted nothing, so no model turn
223
+ // and no tool ran. Stop reading, let the child exit, and report the
224
+ // narrow witness only from the child's close event.
225
+ const canWitnessPreTurnRejection = (error) => !!onDurableEvent && !!onTurnAccepted &&
226
+ !!onSpawnFailed && error?.codexRpcError === true && !turnStartWritten &&
227
+ !pendingTurnStart && !acceptedTurnId && !activeTurnId && !durableEmitted &&
228
+ !aborted && !completed && !durableFailed && !settled && !childCloseObserved
229
+ const rejectBeforeTurn = (error) => {
230
+ preTurnRejection = error
231
+ durableFailed = true
232
+ completed = true
233
+ deactivate()
234
+ try { child.stdin.end() } catch {}
235
+ child.stdout.resume?.()
236
+ killChild()
237
+ }
238
+ const witnessPreTurnRejection = () => settle(() => {
239
+ const message = String(preTurnRejection?.message || 'Codex RPC error')
240
+ let acknowledged
241
+ try {
242
+ acknowledged = onSpawnFailed({ backend: 'codex-app-server', code: 'THREAD_REJECTED',
243
+ turnStarted: false, closed: true })
244
+ } catch (witnessError) { reject(closedError(witnessError)); return }
245
+ if (acknowledged !== true) {
246
+ reject(closedError(new Error('Codex pre-turn rejection witness was not confirmed synchronously')))
247
+ return
248
+ }
249
+ log(`codex app-server rejected the thread before any turn started: ${message.slice(0, 300)}`)
250
+ reject(closedError(Object.assign(new Error(message), { code: 'AGENT_THREAD_REJECTED' })))
251
+ })
213
252
  const onAbort = () => {
214
253
  aborted = true
215
254
  deactivate()
@@ -250,6 +289,7 @@ function runTurn(opts = {}) {
250
289
  isResume: !!sessionId, prompt, appendSystemPrompt, codexApp,
251
290
  model, providerModel: provider.model, effort, workdir: workdir || process.cwd(), permissionMode })
252
291
  pendingTurnStart = true
292
+ turnStartWritten = true
253
293
  let turn
254
294
  try { turn = await request(turnRequest.method, turnRequest.params) }
255
295
  finally { pendingTurnStart = false }
@@ -328,6 +368,7 @@ function runTurn(opts = {}) {
328
368
  // 这段回调不在任何 Promise 链里,抛错会直接带走整个进程 ⇒ 核心失败只结束当前轮。
329
369
  // 已完成的轮 fatal 会直接返回,结果不受影响。
330
370
  const handleLine = (raw) => {
371
+ if (preTurnRejection) return // the turn never started; later output is discarded
331
372
  // 对方主动发来的请求同时带 id 和 method:id 是对方的号段,可能与我们的请求相撞,绝不能当响应处理;
332
373
  // 不回应它就会一直等,这一轮挂到超时。无人值守,按协议一律拒绝。
333
374
  if (raw.id != null && raw.method) {
@@ -338,7 +379,7 @@ function runTurn(opts = {}) {
338
379
  }
339
380
  if (raw.id != null) {
340
381
  const p = requests.get(raw.id)
341
- if (p) { requests.delete(raw.id); raw.error ? p.fail(Object.assign(new Error(raw.error.message || 'Codex RPC error'), { codexRpcRejected: [-32600, -32601, -32602].includes(raw.error.code) })) : p.ok(raw.result) }
382
+ if (p) { requests.delete(raw.id); raw.error ? p.fail(Object.assign(new Error(raw.error.message || 'Codex RPC error'), { codexRpcError: true, codexRpcRejected: [-32600, -32601, -32602].includes(raw.error.code) })) : p.ok(raw.result) }
342
383
  return
343
384
  }
344
385
  if (onTurnAccepted && pendingTurnStart && !acceptedTurnId) {
@@ -431,6 +472,7 @@ function runTurn(opts = {}) {
431
472
  }
432
473
  }
433
474
  child.stdout.on('data', (chunk) => {
475
+ if (preTurnRejection) return // keep the pipe flowing so the child's close is observed
434
476
  if (!onDurableEvent) { processChunk(chunk).catch(fatal); return }
435
477
  child.stdout.pause?.()
436
478
  streamWork = streamWork.then(() => processChunk(chunk)).catch(failDurableEvent).finally(() => {
@@ -456,11 +498,15 @@ function runTurn(opts = {}) {
456
498
  })
457
499
  child.on('close', (code) => {
458
500
  childCloseObserved = true
501
+ if (preTurnRejection) { witnessPreTurnRejection(); return }
459
502
  if (onDurableEvent) streamWork.then(() => durableWork).then(() => { if (!durableFailed) completeClose(code) }, failDurableEvent)
460
503
  else completeClose(code)
461
504
  })
462
505
  start().catch((error) => {
463
- if (onDurableEvent) { failDurableEvent(error); return }
506
+ if (onDurableEvent) {
507
+ if (canWitnessPreTurnRejection(error)) { rejectBeforeTurn(error); return }
508
+ failDurableEvent(error); return
509
+ }
464
510
  if (onTurnAccepted && !acceptedTurnId && !settled) {
465
511
  aborted = true
466
512
  completed = true
@@ -683,9 +683,19 @@ class C2DurableRuntime {
683
683
  } catch (error) { this.close(); throw error }
684
684
 
685
685
  let phase = 'attempting'
686
+ // Two host witnesses prove that no model turn began: the exec child never
687
+ // spawned (no pid), or the App Server rejected initialize/thread before
688
+ // any turn/start and its child has already closed.
689
+ const runnerNotStartedWitness = failure => {
690
+ const keys = failure && typeof failure === 'object' ? Object.keys(failure).sort() : []
691
+ return (isDeepStrictEqual(keys, ['backend', 'code', 'pid']) &&
692
+ failure.backend === 'codex-exec' && failure.code === 'ENOENT' && failure.pid === null) ||
693
+ (isDeepStrictEqual(keys, ['backend', 'closed', 'code', 'turnStarted']) &&
694
+ failure.backend === 'codex-app-server' && failure.code === 'THREAD_REJECTED' &&
695
+ failure.turnStarted === false && failure.closed === true)
696
+ }
686
697
  const onSpawnFailed = (failure) => {
687
- if (phase !== 'attempting' || failure?.backend !== 'codex-exec' ||
688
- failure.code !== 'ENOENT' || failure.pid !== null ||
698
+ if (phase !== 'attempting' || !runnerNotStartedWitness(failure) ||
689
699
  !isDeepStrictEqual(witness.jobReceiptIds, [receiptId]) ||
690
700
  !isDeepStrictEqual(witness.batchReceiptIds, [receiptId]) ||
691
701
  verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
@@ -1754,7 +1754,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1754
1754
  if (controller && rt.v2Controller === controller) rt.v2Controller = null
1755
1755
  const proofId = `sbot-c2-runner-not-started-v1:${receiptId}:${generation}`
1756
1756
  const eventId = `sbot-c2-runner-not-started-commit-v1:${receiptId}:${turnToken}:${generation}`
1757
- const provenNotStarted = !accepted && error?.code === 'AGENT_BIN_MISSING' &&
1757
+ const provenNotStarted = !accepted &&
1758
+ ['AGENT_BIN_MISSING', 'AGENT_THREAD_REJECTED'].includes(error?.code) &&
1758
1759
  this.durableV2Journal?.lookupRunnerNotStarted(proofId)?.dispatchEffectId === effectId &&
1759
1760
  this.durableV2Journal?.hasEvent(eventId) === true
1760
1761
  if (provenNotStarted) {
@@ -1765,6 +1766,33 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1765
1766
  authority.close()
1766
1767
  throw new Error('C2 v2 unstarted runner receipt remains unsettled')
1767
1768
  }
1769
+ // Recovery also settles messages queued behind this turn, each with its
1770
+ // own confirmed notice. Forget their bodies and the local queue so the
1771
+ // next message is admitted as idle, not as a busy follow-up. A queue
1772
+ // the recovery left in place has no dispatcher here: fail closed.
1773
+ const durableQueue = authority.snapshot().state.lanes.queue?.[chatId]?.queue || []
1774
+ if (durableQueue.length) {
1775
+ authority.close()
1776
+ throw new Error('C2 v2 unstarted runner left queued receipts unsettled')
1777
+ }
1778
+ try {
1779
+ for (const id of rt.v2QueueReceiptIds || []) this.durableV2Payloads.dropConfirmed(id)
1780
+ } catch (dropError) { authority.close(); throw dropError }
1781
+ rt.v2QueueReceiptIds = []
1782
+ // A resume the server itself rejected leaves the saved thread unusable
1783
+ // (missing rollout, unreadable history). As /new on this now idle and
1784
+ // settled group: the next message starts a fresh session instead of
1785
+ // meeting the same rejection again.
1786
+ if (error.code === 'AGENT_THREAD_REJECTED' && this.store.getSession(chatId)) {
1787
+ let locked = false
1788
+ try { locked = this.durableV2AssertWriterLock?.() === true } catch {}
1789
+ if (!locked) {
1790
+ authority.close()
1791
+ throw new Error('C2 v2 rejected-session reset lost single-writer lock')
1792
+ }
1793
+ this.store.resetSessionAndStats(chatId)
1794
+ this.log(`group ${chatId}: Codex rejected the saved session before any turn; the next message starts a new session`)
1795
+ }
1768
1796
  rt.v2RunnerGeneration = null
1769
1797
  rt.v2RunningJobs = null
1770
1798
  rt.v2DispatchEffect = null