sintbot 1.4.8 → 1.4.9

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.9",
4
+ "date": "2026-11-10",
5
+ "name": "Failure completion and reply card improvements",
6
+ "items": [
7
+ {
8
+ "t": "Fix: a reply card could remain in progress after a failed task",
9
+ "d": "After a confirmed executor error and exit, the existing card shows a failure and the group can handle the next message. Without an open card, one failure notice is sent. Uncertain delivery remains held without automatic retries."
10
+ },
11
+ {
12
+ "t": "Improve: reply cards show the actual model and reasoning effort",
13
+ "d": "The final card shows the selected model and effort. If an unsupported effort falls back to the model default, it no longer shows that rejected effort. Long durations use minutes or hours, and absent usage no longer appears as zero tokens."
14
+ },
15
+ {
16
+ "t": "Fix: answers stayed at the old card position after a busy-turn follow-up",
17
+ "d": "After Codex confirms a follow-up landed, the reply card moves beneath that message. An open card is copied to the new position before the old one is recalled."
18
+ }
19
+ ]
20
+ },
2
21
  {
3
22
  "version": "1.4.8",
4
23
  "date": "2026-11-10",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "1.4.9",
4
+ "date": "2026-11-10",
5
+ "name": "故障收尾与回复卡片优化",
6
+ "items": [
7
+ {
8
+ "t": "修复:执行失败后回复卡仍停在处理中",
9
+ "d": "执行器明确报错并确认退出后,原卡片显示失败,群可继续处理下一条消息;没有卡片时只发一条失败提示。发送结果不明时仍保留现场,不自动重试。"
10
+ },
11
+ {
12
+ "t": "优化:回复卡片显示实际模型和推理强度",
13
+ "d": "完成时在卡片底部显示本轮选用的型号与强度;如果强度被拒后回退默认,不再显示被拒的强度。长任务耗时改用分和小时,未报告用量时不显示零 token。"
14
+ },
15
+ {
16
+ "t": "修复:忙时补话后答案还留在旧卡片位置",
17
+ "d": "Codex 确认收到补话后,回复卡会跟到那条消息下方;卡片已出现时先将内容追平到新卡,再撤回旧卡。"
18
+ }
19
+ ]
20
+ },
2
21
  {
3
22
  "version": "1.4.8",
4
23
  "date": "2026-11-10",
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.8 with an AI assistant
1
+ # Install SintBot 1.4.9 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.8 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.9 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.8
1
+ # Install SintBot 1.4.9
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.8` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.8`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.9` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.9`. 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.8
1
+ # 用 AI 助手安装 SintBot 1.4.9
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.8。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.9。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.8
1
+ # 安装 SintBot 1.4.9
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.8` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.8` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.9` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.9` 就可以继续。不需要 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.8
5
+ version=1.4.9
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.8",
3
+ "version": "1.4.9",
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": {
@@ -3,7 +3,7 @@ const fs = require('node:fs')
3
3
  const path = require('node:path')
4
4
  const { createHash } = require('node:crypto')
5
5
 
6
- const EXPECTED_SHA256 = '1ab99efd538588accb90756aff9843063337cf666a679b2897630477c912bdf0'
6
+ const EXPECTED_SHA256 = '70bb1b33ba1d9f3249659a4721ed047350f866dd2b3601a67376df604a23b3fd'
7
7
  let bytes
8
8
  try {
9
9
  bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
Binary file
@@ -9,6 +9,7 @@ const { prepareTerminalEffectBridge, terminalReplyText } = require('./c2terminal
9
9
  const { stripMilestones } = require('./turnprogress')
10
10
 
11
11
  function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
12
+ cardAnchor = null, preferPlain = false,
12
13
  receiptId, chatId, turnToken, generation, verifyGeneration,
13
14
  applyLocalEffect, assertWriterLock, sendPlainReply = null, redactText = value => value,
14
15
  now = Date.now } = {}) {
@@ -20,7 +21,8 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
20
21
  typeof sendPlainReply !== 'function')) {
21
22
  throw new TypeError('C2 card terminal path needs one exact message target')
22
23
  }
23
- if (sendPlainReply !== null && typeof sendPlainReply !== 'function') {
24
+ if (typeof preferPlain !== 'boolean' ||
25
+ sendPlainReply !== null && typeof sendPlainReply !== 'function') {
24
26
  throw new TypeError('C2 plain reply needs one strict sender')
25
27
  }
26
28
  if (typeof redactText !== 'function' ||
@@ -28,6 +30,27 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
28
30
  throw new TypeError('C2 card text redaction must be synchronous')
29
31
  }
30
32
  const targetCtx = Object.freeze({ ...replyCtx })
33
+ const landedAnchor = anchor => {
34
+ if (!anchor || !/^[0-9a-f]{32}$/.test(anchor.receiptId || '') ||
35
+ anchor.replyCtx?.chatId !== chatId ||
36
+ typeof anchor.replyCtx.messageId !== 'string' ||
37
+ !anchor.replyCtx.messageId) {
38
+ throw new TypeError('C2 card anchor needs one landed follow-up')
39
+ }
40
+ const snapshot = runtime.snapshot()
41
+ const steer = snapshot.state?.lanes?.steer?.[chatId]
42
+ const followup = snapshot.receipts?.pending?.find(item => item.id === anchor.receiptId)
43
+ if (steer?.parentReceiptId !== receiptId || steer.turnToken !== turnToken ||
44
+ steer.generation !== generation ||
45
+ !steer.entries?.some(item => item.receiptId === anchor.receiptId &&
46
+ item.status === 'landed') || followup?.chatId !== chatId ||
47
+ followup.source !== 'message' ||
48
+ followup.sourceId !== anchor.replyCtx.messageId) {
49
+ throw new Error('C2 card anchor differs from landed steer receipt')
50
+ }
51
+ return Object.freeze({ ...anchor.replyCtx })
52
+ }
53
+ let activeAnchorCtx = cardAnchor ? landedAnchor(cardAnchor) : targetCtx
31
54
  // Registration is durable before open() can expose any runner callback.
32
55
  const terminal = prepareTerminalEffectBridge({ runtime, payloadHandles,
33
56
  receiptId, chatId, turnToken, generation, verifyGeneration,
@@ -36,6 +59,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
36
59
  chatId, turnToken, generation, verifyGeneration })
37
60
  let phase = 'prepared'
38
61
  let card = null
62
+ let lastCardText = ''
39
63
  let suppressedCard = false
40
64
  const fail = error => { phase = 'closed'; runtime.close(); throw error }
41
65
  const canUsePlain = before => {
@@ -62,13 +86,13 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
62
86
  }
63
87
  phase = 'opening'
64
88
  const before = runtime.snapshot()
65
- if (typeof platform.createStreamingCard !== 'function') {
89
+ if (preferPlain || typeof platform.createStreamingCard !== 'function') {
66
90
  if (!canUsePlain(before)) return fail(new Error('C2 plain reply lacks unopened card proof'))
67
91
  phase = 'plain'
68
92
  return false
69
93
  }
70
94
  try {
71
- card = await platform.createStreamingCard(targetCtx,
95
+ card = await platform.createStreamingCard(activeAnchorCtx,
72
96
  { operationKey: receiptId, operationBoundary })
73
97
  if (!card && canUsePlain(before)) {
74
98
  phase = 'plain'
@@ -95,7 +119,37 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
95
119
  }
96
120
  if (suppressedCard) throw new Error('C2 suppressed card cannot stream an update')
97
121
  if (typeof text !== 'string') throw new TypeError('C2 card update needs process-local text')
98
- if (card) card.update(redactText(text))
122
+ if (card) {
123
+ lastCardText = redactText(text)
124
+ card.update(lastCardText)
125
+ }
126
+ },
127
+ async relocate(anchor) {
128
+ const nextCtx = landedAnchor(anchor)
129
+ if (phase !== 'open' || !card) return false
130
+ if (activeAnchorCtx.messageId === nextCtx.messageId) return true
131
+ if (!lastCardText) throw new Error('C2 card move has no visible text to replay')
132
+ phase = 'relocating'
133
+ try {
134
+ if (await card.flush() !== true) throw new Error('C2 old card flush unconfirmed')
135
+ // A different operation key gives each card its own durable create,
136
+ // post, frame and delete identities under the same running turn.
137
+ const fresh = await platform.createStreamingCard(nextCtx, {
138
+ operationKey: `${receiptId}:followup:${anchor.receiptId}`,
139
+ operationBoundary,
140
+ })
141
+ if (!fresh || typeof fresh.update !== 'function' ||
142
+ typeof fresh.flush !== 'function' || typeof fresh.recall !== 'function') {
143
+ throw new Error('C2 moved card has no durable writer')
144
+ }
145
+ fresh.update(lastCardText)
146
+ if (await fresh.flush() !== true) throw new Error('C2 moved card text unconfirmed')
147
+ if (await card.recall() !== true) throw new Error('C2 old card delete unconfirmed')
148
+ card = fresh
149
+ activeAnchorCtx = nextCtx
150
+ phase = 'open'
151
+ return true
152
+ } catch (error) { return fail(error) }
99
153
  },
100
154
  async flushForTerminal() {
101
155
  if (phase !== 'open' || !card || await card.flush() !== true) {
@@ -131,7 +185,8 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
131
185
  return true
132
186
  } catch (error) { return fail(error) }
133
187
  },
134
- async complete({ payload, replyText, paused = false, footer = '', milestones = [] } = {}) {
188
+ async complete({ payload, replyText, paused = false, footer = '',
189
+ footerDetail = '', milestones = [] } = {}) {
135
190
  if (phase !== 'open' && phase !== 'plain') {
136
191
  throw new Error('C2 card terminal path is not open')
137
192
  }
@@ -144,7 +199,8 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
144
199
  typeof terminalPayload.quiet !== 'boolean' || terminalPayload.quiet !== receipt.quiet ||
145
200
  typeof terminalPayload.resultText !== 'string' ||
146
201
  typeof terminalPayload.bufferText !== 'string' ||
147
- typeof footer !== 'string' || !Array.isArray(milestones) ||
202
+ typeof footer !== 'string' || typeof footerDetail !== 'string' ||
203
+ !Array.isArray(milestones) ||
148
204
  !milestones.every(item => typeof item === 'string' && item.trim())) {
149
205
  throw new TypeError('C2 card terminal payload differs from running turn')
150
206
  }
@@ -190,7 +246,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
190
246
  // card's "process" fold; an answer that was only milestones shows
191
247
  // them as its body. The durable reply text stays exact.
192
248
  let shownText = visibleText
193
- const finishOpts = { footer }
249
+ const finishOpts = { footer, footerDetail }
194
250
  if (milestones.length) {
195
251
  const stripped = stripMilestones(visibleText)
196
252
  if (stripped.trim()) {
@@ -1204,16 +1204,56 @@ class C2DurableRuntime {
1204
1204
  // Isolated v2 terminal boundary. The callback is registered before the
1205
1205
  // runner is exposed, and only this process can prove which pending receipt
1206
1206
  // was actually dispatched. No text or reply context enters the journal.
1207
- registerTerminalCallback({ receiptId, generation, registeredAt } = {}) {
1207
+ registerTerminalCallback({ receiptId, generation, registeredAt, reuse = false } = {}) {
1208
1208
  this.#completionCapacity()
1209
1209
  const id = callbackRegistrationId({ receiptId, generation,
1210
1210
  domain: 'terminal', kind: 'finalizeEffectCommit' })
1211
+ if (reuse) {
1212
+ const prior = this.#journal.lookupRegistration(id)
1213
+ if (prior) {
1214
+ if (prior.receiptId !== receiptId || prior.generation !== generation ||
1215
+ prior.domain !== 'terminal' || prior.kind !== 'finalizeEffectCommit' ||
1216
+ prior.ordinal !== 0) throw new Error('C2 terminal registration identity differs')
1217
+ return prior
1218
+ }
1219
+ }
1211
1220
  const result = this.#journal.registerCallback({ id, receiptId, generation,
1212
1221
  domain: 'terminal', kind: 'finalizeEffectCommit', ordinal: 0, registeredAt })
1213
1222
  if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
1214
1223
  return result
1215
1224
  }
1216
1225
 
1226
+ assertClosedFailureReady({ receiptId, chatId, turnToken, generation,
1227
+ verifyGeneration, assertWriterLock } = {}) {
1228
+ const before = this.#completionCapacity()
1229
+ const witness = this.#activeRuns.get(chatId)
1230
+ const proof = this.#journal.lookupRunnerClosed(
1231
+ `sbot-c2-runner-closed-v1:${receiptId}:${generation}`)
1232
+ const receipt = before.receipts.pending.find(item => item.id === receiptId)
1233
+ const dispatch = proof && this.#journal.effectDescriptor(proof.dispatchEffectId)
1234
+ if (this.blocked || assertWriterLock?.() !== true ||
1235
+ verifyGeneration?.({ receiptId, chatId, turnToken, generation }) !== true ||
1236
+ receipt?.chatId !== chatId || witness?.receiptId !== receiptId ||
1237
+ witness.turnToken !== turnToken || proof?.receiptId !== receiptId ||
1238
+ proof.chatId !== chatId || proof.turnToken !== turnToken ||
1239
+ proof.generation !== generation || dispatch?.type !== 'dispatch' ||
1240
+ dispatch.scope !== chatId || dispatch.receiptId !== receiptId ||
1241
+ dispatch.turnToken !== turnToken ||
1242
+ this.#journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
1243
+ !this.#journal.hasEvent(`sbot-c2-session-close-v1:${receiptId}:${generation}`) ||
1244
+ before.state.lanes.session?.[chatId]?.closeSeen !== true ||
1245
+ before.state.lanes.turn?.[chatId]?.active !== turnToken ||
1246
+ before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
1247
+ before.state.lanes.steer?.[chatId]?.entries?.some(item => item.status !== 'landed') ||
1248
+ [...before.effects].some(([id, status]) =>
1249
+ this.#journal.effectDescriptor(id)?.scope === chatId && status !== 'acked') ||
1250
+ !isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
1251
+ this.close()
1252
+ throw new Error('C2 failure terminal lacks settled durable proof')
1253
+ }
1254
+ return true
1255
+ }
1256
+
1217
1257
  commitTerminalEffect({ receiptId, generation, turnToken, payload } = {}) {
1218
1258
  this.#completionCapacity()
1219
1259
  if (!RECEIPT_ID.test(receiptId) || !GENERATION.test(generation) ||
@@ -2200,7 +2240,7 @@ class C2DurableRuntime {
2200
2240
  // this yet. It executes one externally visible reply only after a durable
2201
2241
  // attempt; a lost ACK leaves the receipt and effect ambiguous on restart.
2202
2242
  async executeTerminalEffect({ effectId, receiptId, chatId, turnToken, generation,
2203
- verifyGeneration, resolveTarget, perform, confirm } = {}) {
2243
+ verifyGeneration, resolveTarget, perform, confirm, assertWriterLock = () => true } = {}) {
2204
2244
  const before = this.#completionCapacity()
2205
2245
  if (typeof effectId !== 'string' || !RECEIPT_ID.test(receiptId) ||
2206
2246
  typeof chatId !== 'string' || !chatId || !GENERATION.test(generation) ||
@@ -2242,7 +2282,8 @@ class C2DurableRuntime {
2242
2282
  descriptor.receiptId !== receiptId || descriptor.targetId !== null ||
2243
2283
  this.#journal.effectStatus(effectId) !== 'pending' ||
2244
2284
  registration?.generation !== generation || registration.receiptId !== receiptId ||
2245
- verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
2285
+ verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
2286
+ assertWriterLock() !== true) {
2246
2287
  throw new Error('C2 terminal effect lacks exact live authority')
2247
2288
  }
2248
2289
  if (expectedType === 'quietCompletion') {
@@ -2278,13 +2319,16 @@ class C2DurableRuntime {
2278
2319
  const reply = await perform({ effectId, target })
2279
2320
  confirmed = confirm(reply) === true &&
2280
2321
  this.#effectAuthorityReady &&
2322
+ assertWriterLock() === true &&
2281
2323
  verifyGeneration({ chatId, receiptId, turnToken, generation }) === true
2282
2324
  } catch { /* External outcome is unknown after the durable attempt. */ }
2283
2325
  const now = this.#now()
2284
2326
  const resolvedAt = Number.isSafeInteger(now) && now > 0
2285
2327
  ? Math.max(attemptedAt, now) : attemptedAt
2286
2328
  if (!confirmed) {
2287
- this.#journal.recordEffectOutcome({ effectId, status: 'unknown', resolvedAt })
2329
+ if (assertWriterLock() === true) {
2330
+ this.#journal.recordEffectOutcome({ effectId, status: 'unknown', resolvedAt })
2331
+ }
2288
2332
  this.close()
2289
2333
  throw new Error('C2 final reply outcome unknown; manual recovery required')
2290
2334
  }
@@ -147,12 +147,11 @@ function createC2LocalEffectExecutor({ journal, store, resolveTurn,
147
147
  const chatId = descriptor.scope
148
148
  if (effect.type === 'verifyTurn' && effect.success === false) {
149
149
  const outcome = journal.snapshot().state.lanes.terminal?.[chatId]?.outcome
150
- // A known quota failure says nothing about credential validity. The
150
+ // A failed task alone says nothing about credential validity. The
151
151
  // binary's failed verification intent is acknowledged without writing
152
152
  // a false success or altering the previous credential-health record.
153
- if (outcome?.status !== 'error' || outcome.quotaError !== true ||
154
- outcome.sideEffects !== false) {
155
- throw new Error('C2 local effect payload differs: failed verification lacks a supported quota outcome')
153
+ if (outcome?.status !== 'error') {
154
+ throw new Error('C2 local effect payload differs: failed verification lacks a core error outcome')
156
155
  }
157
156
  return true
158
157
  }
@@ -6,6 +6,9 @@
6
6
  // receive only the v2 decision and stable effect/receipt identities.
7
7
  const { C2DurableRuntime } = require('./c2durableruntime')
8
8
  const { C2PayloadHandles } = require('./c2payloadhandles')
9
+ const { engineTerminalStep } = require('../agent/privatecore')
10
+
11
+ const FAILURE_NOTICE = '本次任务执行失败。若已执行过工具,请先核对实际结果,再决定是否重试。'
9
12
 
10
13
  const RECEIPT_ID = /^[0-9a-f]{32}$/
11
14
  const GENERATION = /^[0-9a-f]{32}$/
@@ -23,6 +26,14 @@ function terminalReplyText(payload) {
23
26
  typeof payload.answerText !== 'string') {
24
27
  throw new TypeError('C2 terminal reply text fields invalid')
25
28
  }
29
+ // The core classifies the result; the host supplies a fixed display string.
30
+ // Neither a provider error nor unfinished assistant text is a final answer.
31
+ if (payload.source === 'result' && payload.isError === true) {
32
+ const event = { ...payload, type: 'finalize' }
33
+ delete event.receiptId; delete event.turnToken; delete event.quiet
34
+ if (engineTerminalStep({ finalized: false, outcome: null }, event)
35
+ .state.outcome.status === 'error') return FAILURE_NOTICE
36
+ }
26
37
  return (payload.answerText.trim() ? payload.answerText : payload.bufferText) ||
27
38
  payload.resultText
28
39
  }
@@ -106,7 +117,7 @@ function prepareTerminalEffectBridge({ runtime, payloadHandles, receiptId, chatI
106
117
  }
107
118
  }
108
119
  const effect = { effectId: final.effectId, receiptId, chatId,
109
- turnToken, generation, verifyGeneration }
120
+ turnToken, generation, verifyGeneration, assertWriterLock }
110
121
  if (final.type === 'quietCompletion') {
111
122
  if (replyText !== undefined || resolveTarget !== undefined ||
112
123
  sendReply !== undefined || confirmReply !== undefined) {
@@ -39,6 +39,31 @@ function humanDuration(ms) {
39
39
  if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
40
40
  return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
41
41
  }
42
+ // The final card is an accounting line: keep seconds for short turns and
43
+ // switch to minutes/hours before a long turn becomes an unreadable number.
44
+ function cardDuration(ms) {
45
+ const seconds = Math.max(0, Math.round(ms / 1000))
46
+ if (seconds < 60) return `${seconds} 秒`
47
+ const minutes = Math.floor(seconds / 60)
48
+ if (minutes < 60) return seconds % 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${minutes} 分钟`
49
+ const hours = Math.floor(minutes / 60)
50
+ return minutes % 60 ? `${hours} 小时 ${minutes % 60} 分` : `${hours} 小时`
51
+ }
52
+ function cardModelInfo(group, provider) {
53
+ if (!provider) return { model: '', effort: '' }
54
+ const model = group?.model || provider.model ||
55
+ (provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL) || ''
56
+ const effort = provider.runner === 'codex'
57
+ ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
58
+ const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
59
+ return { model: clean(model), effort: clean(effort) }
60
+ }
61
+ function cardModelDetail(info, result) {
62
+ // A rejected configured effort may have been retried at the model default.
63
+ const fellBack = result?.effortFellBack || result?.result?.effortFellBack
64
+ return [info?.model, fellBack ? '' : info?.effort]
65
+ .filter(Boolean).join(' ')
66
+ }
42
67
  // Group texts name the project by its folder only, never by a machine path.
43
68
  function projectName(group) {
44
69
  return String(group?.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
@@ -104,9 +129,11 @@ const T = {
104
129
  skipPending: '正在跳过当前任务,这条消息没有处理,请稍后重发。',
105
130
  quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
106
131
  cardFooter: ({ ms, tokens }) => {
107
- const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
132
+ const parts = [`耗时 ${cardDuration(ms)}`]
108
133
  const tokenLabel = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
109
- if (tokens) parts.push(`tokens ↑${tokenLabel((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0))} ↓${tokenLabel(tokens.out || 0)}`)
134
+ if (tokens && ((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0) + (tokens.out || 0)) > 0) {
135
+ parts.push(`tokens ↑${tokenLabel((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0))} ↓${tokenLabel(tokens.out || 0)}`)
136
+ }
110
137
  return parts.join(' · ')
111
138
  },
112
139
  }
@@ -136,6 +163,20 @@ function supportedV2QuotaFailure(payload, runnerResult) {
136
163
  outcome.sideEffects === false
137
164
  }
138
165
 
166
+ // Admit only an observed, closed result on this release's no-retry route.
167
+ // Error classification remains in the core; close/session/effect authority is
168
+ // checked by the terminal path before any delivery can be attempted.
169
+ function supportedV2ClosedFailure(payload, runnerResult) {
170
+ if (!payload || payload.source !== 'result' || payload.aborted ||
171
+ payload.sessionMissing || payload.transientNetwork || payload.hasQuota ||
172
+ payload.freshRetried || payload.resumedSessionCurrent ||
173
+ runnerResult?.aborted !== false) return false
174
+ const terminal = { ...payload, type: 'finalize' }
175
+ delete terminal.receiptId; delete terminal.turnToken; delete terminal.quiet
176
+ return engineTerminalStep({ finalized: false, outcome: null }, terminal)
177
+ .state.outcome.status === 'error'
178
+ }
179
+
139
180
  function ordinaryV2Cohort(snapshot, jobs, binding, chatId, receiptId) {
140
181
  if (!Array.isArray(jobs) || !jobs.length ||
141
182
  jobs.some(job => !job || job.msg?.batch || job.msg?.synthetic ||
@@ -781,7 +822,9 @@ async _runDurableV2Steer(rt, receiptId) {
781
822
  invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
782
823
  entry.accepted = accepted
783
824
  if (accepted) {
784
- if (entry.landed) authority.commitSteerObservation({ ...identity, type: 'landed' })
825
+ if (entry.landed) {
826
+ authority.commitSteerObservation({ ...identity, type: 'landed' })
827
+ }
785
828
  return { receiptId, steered: true, accepted: true, landed: entry.landed, staged: true }
786
829
  }
787
830
  if (entry.landed) throw new Error('C2 rejected steer also landed; outcome inconsistent')
@@ -803,9 +846,20 @@ async _runDurableV2Steer(rt, receiptId) {
803
846
  return { receiptId, queued: true, staged: true, accepted: false }
804
847
  })()
805
848
  rt.v2QueueNoticePromise = work
806
- try { return await work }
849
+ let outcome
850
+ try { outcome = await work }
807
851
  catch (error) { authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error); throw error }
808
852
  finally { if (rt.v2QueueNoticePromise === work) rt.v2QueueNoticePromise = null }
853
+ try {
854
+ if (outcome.accepted && entry.landed) {
855
+ await rt.v2SteerLandedChain
856
+ await rt.v2MoveCard?.(entry)
857
+ }
858
+ return outcome
859
+ } catch (error) {
860
+ authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error)
861
+ throw error
862
+ }
809
863
  }
810
864
 
811
865
  async runDurableV2Stop(msg, platform) {
@@ -1203,7 +1257,8 @@ async _drainDurableV2NormalChain(initial) {
1203
1257
  const outcome = engineTerminalStep({ finalized: false, outcome: null },
1204
1258
  terminal).state.outcome
1205
1259
  const quotaFailure = supportedV2QuotaFailure(payload, rt.v2RunnerResult)
1206
- if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure) {
1260
+ const closedFailure = supportedV2ClosedFailure(payload, rt.v2RunnerResult)
1261
+ if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure && !closedFailure) {
1207
1262
  const classified = this.classifyDurableV2UnresolvedTurn({ ...identity,
1208
1263
  payload })
1209
1264
  const error = new Error('C2 v2 ordinary result requires fault recovery')
@@ -1214,7 +1269,7 @@ async _drainDurableV2NormalChain(initial) {
1214
1269
  if (payload.source !== 'result' || payload.sessionMissing ||
1215
1270
  (payload.hasQuota && !quotaFailure) || payload.transientNetwork || payload.freshRetried ||
1216
1271
  payload.resumedSessionCurrent ||
1217
- (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure)) {
1272
+ (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure && !closedFailure)) {
1218
1273
  authority.close()
1219
1274
  const error = new Error('C2 v2 ordinary result needs a retry or notice decision')
1220
1275
  error.code = 'C2_V2_UNRESOLVED_TURN'
@@ -1232,7 +1287,7 @@ async _drainDurableV2NormalChain(initial) {
1232
1287
  for (const settledReceiptId of completed.settledReceiptIds ||
1233
1288
  [identity.receiptId]) {
1234
1289
  settled.push({ receiptId: settledReceiptId,
1235
- status: quotaFailure ? 'error' : silent ? 'silent' : 'answered',
1290
+ status: quotaFailure || closedFailure ? 'error' : silent ? 'silent' : 'answered',
1236
1291
  terminalEffectId: completed.terminalEffectId })
1237
1292
  }
1238
1293
  if (authority.blocked) {
@@ -1436,7 +1491,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1436
1491
  rt.v2Doing = []
1437
1492
  rt.v2AutoMilestones = []
1438
1493
  rt.v2CardQuotaFailure = false
1494
+ rt.v2CardModelInfo = null
1439
1495
  rt.v2CardBinding = null
1496
+ rt.v2CardAnchor = null
1497
+ rt.v2CardJoinedNames = []
1498
+ rt.v2LastCardText = ''
1499
+ rt.v2MoveCard = null
1440
1500
  rt.v2StreamRequested = false
1441
1501
  rt.v2StreamQuiescing = false
1442
1502
  rt.v2StreamChain = Promise.resolve()
@@ -1472,6 +1532,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1472
1532
  if (!provider || (provider.baseUrl && !provider.apiKey)) {
1473
1533
  throw new Error('C2 v2 runner provider unavailable')
1474
1534
  }
1535
+ rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1475
1536
  rt.v2SteerControl = null
1476
1537
  rt.v2SteerEntries = new Map()
1477
1538
  rt.v2SteerClosing = false
@@ -1516,6 +1577,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1516
1577
  if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1517
1578
  if (rt.v2RunnerGeneration !== generation) return
1518
1579
  authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
1580
+ await rt.v2MoveCard?.(entry)
1519
1581
  })
1520
1582
  rt.v2SteerLandedChain = observation
1521
1583
  observation.catch(() => { authority.close(); controller?.abort() })
@@ -1531,6 +1593,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1531
1593
  rt.v2RunnerNotStarted = false
1532
1594
  let streamChain = Promise.resolve()
1533
1595
  let streamFailure = null
1596
+ const joinedLine = () => rt.v2CardJoinedNames.length
1597
+ ? `\n\n↪ 已加入 ${rt.v2CardJoinedNames.join('、')} 的补充` : ''
1534
1598
  // Card progress (same mechanism and wording as the legacy engine): deterministic
1535
1599
  // milestones and "recently doing" from tool calls, an early "received" card
1536
1600
  // and a pulse line. All of it is process-local card text; the card itself is
@@ -1547,6 +1611,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1547
1611
  rt.v2TerminalStarted || controller.signal.aborted || rt.v2CancelControl ||
1548
1612
  rt.v2StreamQuiescing || !!authority.scopeState('stopIntent', chatId)
1549
1613
  const enqueueCardText = text => {
1614
+ rt.v2LastCardText = text
1550
1615
  rt.v2StreamRequested = true
1551
1616
  streamChain = streamChain.then(async () => {
1552
1617
  if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
@@ -1558,7 +1623,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1558
1623
  if (!rt.v2CardBinding) {
1559
1624
  const path = prepareCardTerminalPath({ runtime: authority,
1560
1625
  payloadHandles: this.durableV2Payloads, platform: job.platform,
1561
- replyCtx: job.msg.replyCtx, receiptId, chatId, turnToken,
1626
+ replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor,
1627
+ receiptId, chatId, turnToken,
1562
1628
  generation, verifyGeneration,
1563
1629
  applyLocalEffect: this.durableV2ApplyLocalEffect,
1564
1630
  assertWriterLock: this.durableV2AssertWriterLock,
@@ -1573,7 +1639,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1573
1639
  rt.v2CardBinding = { path, receiptId, turnToken,
1574
1640
  generation, opened }
1575
1641
  }
1576
- if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text)
1642
+ if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text + joinedLine())
1577
1643
  })
1578
1644
  rt.v2StreamChain = streamChain
1579
1645
  streamChain.catch(error => {
@@ -1581,6 +1647,33 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1581
1647
  try { controller.abort() } catch {}
1582
1648
  })
1583
1649
  }
1650
+ rt.v2MoveCard = entry => {
1651
+ if (entry.movePromise) return entry.movePromise
1652
+ const move = streamChain.then(async () => {
1653
+ if (progressClosed() || job.platform.features?.cardPinned !== true) return false
1654
+ const anchor = { receiptId: entry.identity.receiptId,
1655
+ replyCtx: entry.job.msg.replyCtx }
1656
+ const who = entry.job.msg.senderName || entry.job.msg.userId || 'user'
1657
+ if (!rt.v2CardJoinedNames.includes(who)) rt.v2CardJoinedNames.push(who)
1658
+ if (!rt.v2CardBinding) {
1659
+ rt.v2CardAnchor = anchor
1660
+ return true
1661
+ }
1662
+ if (!rt.v2CardBinding.opened) return false
1663
+ rt.v2CardBinding.path.update((rt.v2LastCardText || progress.T.ackWorking) + joinedLine())
1664
+ const moved = await rt.v2CardBinding.path.relocate(anchor)
1665
+ if (moved) rt.v2CardAnchor = anchor
1666
+ return moved
1667
+ })
1668
+ streamChain = move
1669
+ rt.v2StreamChain = move
1670
+ entry.movePromise = move
1671
+ move.catch(error => {
1672
+ streamFailure = error
1673
+ try { controller.abort() } catch {}
1674
+ })
1675
+ return move
1676
+ }
1584
1677
  const enqueueFrame = event => {
1585
1678
  if (event.type !== 'assistant') return
1586
1679
  // /status and the pulse read these; a late event from an older
@@ -1650,10 +1743,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1650
1743
  const sofar = buffer.trim()
1651
1744
  const body = autoBlock() + (sofar && !NO_REPLY.startsWith(sofar)
1652
1745
  ? progress.renderMilestones(buffer) + '\n\n' : '')
1653
- try {
1654
- binding.path.update(body + progress.T.pulse(
1655
- progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
1656
- } catch { /* a closed card never affects the turn */ }
1746
+ enqueueCardText(body + progress.T.pulse(
1747
+ progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
1657
1748
  }, PULSE_MS)
1658
1749
  if (pulseTimer.unref) pulseTimer.unref()
1659
1750
  }
@@ -1872,6 +1963,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1872
1963
  const projected = rt?.v2TerminalProjection
1873
1964
  const resultText = projected?.payload?.resultText
1874
1965
  const quotaFailure = supportedV2QuotaFailure(payload, rt?.v2RunnerResult)
1966
+ const closedFailure = supportedV2ClosedFailure(payload, rt?.v2RunnerResult)
1875
1967
  if (!/^[0-9a-f]{32}$/.test(receiptId || '') ||
1876
1968
  !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
1877
1969
  typeof effectId !== 'string' || !effectId ||
@@ -1903,10 +1995,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1903
1995
  (!isDeepStrictEqual(payload.batchReceiptIds, cohort.receiptIds) ||
1904
1996
  payload.replyTargetReceiptId !== receiptId)) ||
1905
1997
  !isDeepStrictEqual(payload, projected.payload) ||
1906
- // Only successful answers and binary-classified, side-effect-free
1907
- // quota failures use this no-retry terminal bridge. Unknown failures
1908
- // and lost-session outcomes still need a separate recovery decision.
1909
- payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure) ||
1998
+ // A deterministic failure is delivered once, after the same durable
1999
+ // close checks as success. Retry and unknown outcomes stay sealed.
2000
+ payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure && !closedFailure) ||
1910
2001
  payload.sessionMissing || (payload.hasQuota && !quotaFailure) || payload.transientNetwork ||
1911
2002
  payload.freshRetried || payload.resumedSessionCurrent ||
1912
2003
  (replyText !== undefined && replyText !== terminalReplyText(projected.payload)) ||
@@ -1920,6 +2011,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1920
2011
  value.generation === generation && rt.v2RunnerGeneration === generation
1921
2012
  try {
1922
2013
  const preopened = rt.v2CardBinding
2014
+ if (closedFailure) authority.assertClosedFailureReady({ receiptId, chatId,
2015
+ turnToken, generation, verifyGeneration,
2016
+ assertWriterLock: this.durableV2AssertWriterLock })
1923
2017
  if (preopened && (preopened.receiptId !== receiptId ||
1924
2018
  preopened.turnToken !== turnToken ||
1925
2019
  preopened.generation !== generation)) {
@@ -1927,7 +2021,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1927
2021
  }
1928
2022
  const path = preopened?.path || prepareCardTerminalPath({ runtime: authority,
1929
2023
  payloadHandles: handles, platform: job.platform,
1930
- replyCtx: job.msg.replyCtx, receiptId, chatId, turnToken, generation,
2024
+ replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor, preferPlain: closedFailure,
2025
+ receiptId, chatId, turnToken, generation,
1931
2026
  verifyGeneration, applyLocalEffect: this.durableV2ApplyLocalEffect,
1932
2027
  assertWriterLock: this.durableV2AssertWriterLock,
1933
2028
  redactText: text => redact(quotaFailure
@@ -1942,6 +2037,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1942
2037
  for (const text of updates) path.update(text)
1943
2038
  const footer = T.cardFooter({ ms: Math.max(0, Date.now() - rt.v2StartedAt),
1944
2039
  tokens: rt.v2UsageDecision?.tokens || null })
2040
+ const footerDetail = job.platform.features?.cardQuota === true
2041
+ ? cardModelDetail(rt.v2CardModelInfo, rt.v2RunnerResult) : ''
1945
2042
  // Milestones from tool calls first, then the model's own `>> ` lines;
1946
2043
  // the card shows them in its "process" fold (display only).
1947
2044
  rt.v2CardQuotaFailure = !!quotaFailure
@@ -1950,7 +2047,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1950
2047
  ...progress.milestonesOf(payload.bufferText)]
1951
2048
  .filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
1952
2049
  .filter(m => typeof m === 'string' && m.trim())
1953
- const completed = await path.complete({ payload, replyText, paused, footer, milestones })
2050
+ const completed = await path.complete({ payload, replyText, paused,
2051
+ footer, footerDetail, milestones })
1954
2052
  const after = authority.snapshot()
1955
2053
  const queue = after.state?.lanes?.queue?.[chatId]?.queue
1956
2054
  let successor = null
@@ -2064,7 +2162,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2064
2162
  }
2065
2163
  rt.v2TerminalStarted = true
2066
2164
  authority.registerTerminalCallback({ receiptId, generation,
2067
- registeredAt: Date.now() })
2165
+ registeredAt: Date.now(), reuse: true })
2068
2166
  return authority.commitUnresolvedClassification({ receiptId, generation,
2069
2167
  turnToken, status, payload,
2070
2168
  verifyGeneration: value => value.chatId === chatId &&
@@ -605,7 +605,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
605
605
  }
606
606
 
607
607
  return {
608
- features: { cardQuota: true, noticeCard: true }, // 飞书支持通知卡;企微/Slack 退回文本
608
+ features: { cardQuota: true, noticeCard: true, cardPinned: true }, // 回复卡钉在触发消息下;补话落地后可搬到新消息下
609
609
  async start(onMessage, hooks = {}) {
610
610
  if (!botOpenId && !onlyWhenMentioned) log('warning: feishu.botOpenId not set — cannot tell @bot from @others; all group messages will be handled')
611
611
  if (hooks.onFatal !== undefined && typeof hooks.onFatal !== 'function') {