sintbot 1.4.2 → 1.4.3

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,27 @@
1
1
  [
2
+ {
3
+ "version": "1.4.3",
4
+ "date": "2026-11-09",
5
+ "name": "Progress on the reply card",
6
+ "items": [
7
+ {
8
+ "t": "A card appears even before any output",
9
+ "d": "If nothing has been produced after 25 seconds, a \"Received, working on it\" card appears and the answer later replaces it in place. A turn that ends without a reply recalls that card."
10
+ },
11
+ {
12
+ "t": "The card shows how far the task has got",
13
+ "d": "While running, one line updates every 15 seconds: how long it has run and what it is doing (reading, editing or writing which file, running a command, searching the web; file names only). After 10 minutes it updates once a minute, and notes when nothing has happened for more than 2 minutes. Delivered steps such as writing files, committing or pushing, and sending files to the group are listed with ▶ at the top of the card and fold into a \"Process\" section when the turn ends."
14
+ },
15
+ {
16
+ "t": "/status shows progress",
17
+ "d": "While running it shows how many steps have run, how long ago the last activity was, and what it is doing."
18
+ },
19
+ {
20
+ "t": "Fix: a streamed reply could fail the whole turn",
21
+ "d": "Text that arrived while the previous update was still being sent used to fail the turn; it now queues behind it and keeps updating the card."
22
+ }
23
+ ]
24
+ },
2
25
  {
3
26
  "version": "1.4.2",
4
27
  "date": "2026-11-09",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,27 @@
1
1
  [
2
+ {
3
+ "version": "1.4.3",
4
+ "date": "2026-11-09",
5
+ "name": "回复卡片显示进度",
6
+ "items": [
7
+ {
8
+ "t": "一时没有输出也先回一张卡",
9
+ "d": "25 秒还没有任何输出时,先出一张「已收到,正在处理」卡片;回答出来后在同一张卡里替换。这一轮最后决定不回复时,撤回这张卡。"
10
+ },
11
+ {
12
+ "t": "卡片显示做到哪了",
13
+ "d": "运行中每 15 秒更新一行:已跑多久、最近在做什么(看、改、写哪个文件,跑命令,查网页等,只给文件名);跑满 10 分钟后改为每分钟一次,超过 2 分钟没有新动静会注明。写改文件、提交推送、发文件到群这类交付动作以 ▶ 列在卡片上方,结束后收进「过程」折叠区。"
14
+ },
15
+ {
16
+ "t": "/status 显示进度",
17
+ "d": "运行中会显示已执行几步、最近一次动静在多久前,以及最近在做什么。"
18
+ },
19
+ {
20
+ "t": "修复:流式回复偶发整轮失败",
21
+ "d": "上一段内容还在发送时又来一段文字,原来会让这一轮失败;现在排在后面继续更新卡片。"
22
+ }
23
+ ]
24
+ },
2
25
  {
3
26
  "version": "1.4.2",
4
27
  "date": "2026-11-09",
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.2 with an AI assistant
1
+ # Install SintBot 1.4.3 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.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.
18
+ Install SintBot 1.4.3 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.2
1
+ # Install SintBot 1.4.3
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.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.
13
+ The installer checks the environment, installs `sintbot@1.4.3` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.3`. 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
 
@@ -86,12 +86,12 @@ In a group, send "Read the first line of the project README and reply with only
86
86
 
87
87
  ## 5. Use it in a group
88
88
 
89
- Any text message is a task. Replies arrive in a card that appears once the model starts answering and refreshes as it works.
89
+ Any text message is a task. Replies arrive in a card that appears once the model starts answering, or after 25 seconds if it is still working without output. While the task runs, the card shows how long it has run, what it is doing and the steps already delivered; at the end those steps fold into a Process section.
90
90
 
91
91
  | Command | Effect |
92
92
  | --- | --- |
93
93
  | `/help` | List commands |
94
- | `/status` | Group status: model, project, busy or idle, queue |
94
+ | `/status` | Group status: model, project, busy or idle (steps and current work while busy), queue |
95
95
  | `/model` | List models; `/model name` switches (only while the group is idle; starts a new conversation) |
96
96
  | `/new` | Start a new conversation (only while the group is idle) |
97
97
  | `/stop` | Stop the current task and cancel queued ones |
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.2
1
+ # 用 AI 助手安装 SintBot 1.4.3
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.2。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.3。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.2
1
+ # 安装 SintBot 1.4.3
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.2` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.2` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.3` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.3` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -86,12 +86,12 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
86
86
 
87
87
  ## 5. 在群里使用
88
88
 
89
- 直接发文字就是给它派任务。回复以卡片呈现,模型开始输出后就会出现并随进度刷新。
89
+ 直接发文字就是给它派任务。回复以卡片呈现:模型开始输出时出现;25 秒还没有输出也会先出现。运行中卡片显示已跑多久、正在做什么和已完成的步骤,结束后这些步骤收进「过程」折叠区。
90
90
 
91
91
  | 命令 | 作用 |
92
92
  | --- | --- |
93
93
  | `/help` | 查看可用命令 |
94
- | `/status` | 本群状态:模型、项目、是否在忙、排队数 |
94
+ | `/status` | 本群状态:模型、项目、是否在忙(在忙时含步数和正在做什么)、排队数 |
95
95
  | `/model` | 查看可选模型;`/model 名字` 切换(只在本群空闲时生效,切换后开始新的一段对话) |
96
96
  | `/new` | 开始新的一段对话(只在本群空闲时生效) |
97
97
  | `/stop` | 停止当前任务,连同排队的一起取消 |
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.2
5
+ version=1.4.3
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.2",
3
+ "version": "1.4.3",
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": {
@@ -103,6 +103,7 @@
103
103
  "src/core/redact.js",
104
104
  "src/core/store.js",
105
105
  "src/core/time.js",
106
+ "src/core/turnprogress.js",
106
107
  "src/core/v2engine.js",
107
108
  "src/core/verify.js",
108
109
  "src/design.js",
@@ -6,6 +6,7 @@
6
6
  // outbound executor a second time.
7
7
  const { createC2CardEffectBoundary, confirmCardOperation } = require('./c2cardeffectbridge')
8
8
  const { prepareTerminalEffectBridge, terminalReplyText } = require('./c2terminaleffectbridge')
9
+ const { stripMilestones } = require('./turnprogress')
9
10
 
10
11
  function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
11
12
  receiptId, chatId, turnToken, generation, verifyGeneration,
@@ -130,7 +131,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
130
131
  return true
131
132
  } catch (error) { return fail(error) }
132
133
  },
133
- async complete({ payload, replyText, paused = false, footer = '' } = {}) {
134
+ async complete({ payload, replyText, paused = false, footer = '', milestones = [] } = {}) {
134
135
  if (phase !== 'open' && phase !== 'plain') {
135
136
  throw new Error('C2 card terminal path is not open')
136
137
  }
@@ -143,7 +144,8 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
143
144
  typeof terminalPayload.quiet !== 'boolean' || terminalPayload.quiet !== receipt.quiet ||
144
145
  typeof terminalPayload.resultText !== 'string' ||
145
146
  typeof terminalPayload.bufferText !== 'string' ||
146
- typeof footer !== 'string') {
147
+ typeof footer !== 'string' || !Array.isArray(milestones) ||
148
+ !milestones.every(item => typeof item === 'string' && item.trim())) {
147
149
  throw new TypeError('C2 card terminal payload differs from running turn')
148
150
  }
149
151
  if (card && await card.flush() !== true) {
@@ -184,8 +186,20 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
184
186
  // is therefore not suitable for a durable final effect.
185
187
  return sendPlainReply(target, visibleText)
186
188
  }
189
+ // Display only: milestone lines leave the answer and go into the
190
+ // card's "process" fold; an answer that was only milestones shows
191
+ // them as its body. The durable reply text stays exact.
192
+ let shownText = visibleText
193
+ const finishOpts = { footer }
194
+ if (milestones.length) {
195
+ const stripped = stripMilestones(visibleText)
196
+ if (stripped.trim()) {
197
+ shownText = stripped
198
+ finishOpts.milestones = [...milestones]
199
+ } else shownText = milestones.map(item => '▶ ' + item).join('\n')
200
+ }
187
201
  let calls = 0
188
- const finished = await card.finish(visibleText, { footer,
202
+ const finished = await card.finish(shownText, { ...finishOpts,
189
203
  terminalOperationBoundary: async operation => {
190
204
  if (!operation || operation.kind !== 'card.update' ||
191
205
  operation.cardId == null || typeof operation.invoke !== 'function' ||
@@ -205,6 +205,12 @@ class C2DurableRuntime {
205
205
  get blocked() { return this.#closed || this.#opening || this.#externalEffectInFlight || !this.#ingress ||
206
206
  this.#ingress.blocked || this.#journal.blocked }
207
207
 
208
+ // Closed or sealed for good. Unlike `blocked`, an outbound effect that is
209
+ // still awaiting its platform response does not count: a streaming card's
210
+ // next frame queues behind it instead of failing the turn.
211
+ get closed() { return this.#closed || !this.#ingress || this.#ingress.blocked ||
212
+ this.#journal.blocked }
213
+
208
214
  hasRecoveryPolicy({ assertWriterLock, singleNoticePerGroup } = {}) {
209
215
  return typeof assertWriterLock === 'function' &&
210
216
  this.#assertWriterLock === assertWriterLock &&
@@ -0,0 +1,135 @@
1
+ 'use strict'
2
+
3
+ // 卡片进度:里程碑 · 「最近在做」· 脉搏文案(纯函数)。
4
+ // 发行宿主(v2engine)与卡片收尾共用一份。只做展示,不参与任何收据或效果判定。
5
+ // 卡片文案与旧引擎逐字一致,便于逐字对照。
6
+
7
+ // 长任务的「活着」信号与「做到哪了」。⚠ 只在卡里滚动,不另发消息 —— 群里多一条就是一次刷屏。
8
+ const T = {
9
+ // 迟迟没动静时先回一声:填进本来就要建的那张流式卡,答案到了原地替换 —— 不多发消息。
10
+ // 文案过零工具名,且不说「马上/很快」这类无法兑现的承诺。
11
+ ackWorking: '⏳ 已收到,正在处理…',
12
+ // 「最近动静」只在**停滞**时才有信息量 ⇒ 超过 cardPulseStaleMs(默认 2 分钟)没动静才追加。
13
+ // 还没调过任何工具时退回「处理中 · 已跑」。
14
+ pulse: (ran, doing, stale) => (doing && doing.length ? `⏳ 已跑 ${ran} · ${T.pulseDoing(doing)}` : `⏳ 处理中 · 已跑 ${ran}`) + (stale ? ` · 最近动静 ${stale}前` : ''),
15
+ // 由工具调用**确定性**推出(describeTool),模型一个字不说也有东西看。脉搏与 /status 共用。
16
+ // 只给文件名不给路径、不给命令内容 —— 群面零机器路径。
17
+ pulseDoing: (list) => `最近在做:${list.join(' · ')}`,
18
+ }
19
+
20
+ // 脉搏「已跑多久」:分钟粒度够用(卡片底部那行的耗时另算到秒)。
21
+ function humanDur(ms) {
22
+ const s = Math.max(0, Math.round(ms / 1000))
23
+ if (s < 60) return `${s} 秒`
24
+ const m = Math.floor(s / 60)
25
+ if (m < 60) return `${m} 分钟`
26
+ const h = Math.floor(m / 60)
27
+ return m % 60 ? `${h} 小时 ${m % 60} 分钟` : `${h} 小时`
28
+ }
29
+
30
+ // ── 里程碑与「最近在做」─────────────────────────────────────────────────────
31
+ // 模型每完成一个阶段输出一行 `>> …`,引擎认它 —— 跑的时候随打字机进卡(显示成 ▶),收尾收进卡片
32
+ // 折叠区,不再被「只留最后一段」丢掉。判据收在这里,不散在三处(打字机 / 脉搏 / 收尾)。
33
+ const MILESTONE_MAX = 12
34
+ const MILESTONE_RE = /^>>\s*(.*\S)\s*$/
35
+ // 逐行扫、跳过代码围栏 —— shell 的 `>> file` 与 Python 的 `>>>` 都可能出现在代码块里。
36
+ function scanMilestoneLines(text, onLine) {
37
+ let fence = false
38
+ for (const line of String(text || '').split('\n')) {
39
+ if (/^\s*```/.test(line)) { fence = !fence; onLine(line, null); continue }
40
+ const m = fence ? null : MILESTONE_RE.exec(line)
41
+ onLine(line, m ? m[1] : null)
42
+ }
43
+ }
44
+ // 本轮全部里程碑(去重、封顶),从流式全量文本里取 —— 中途说的与收尾说的都算。
45
+ function milestonesOf(text) {
46
+ const out = []
47
+ scanMilestoneLines(text, (_l, m) => { if (m && !out.includes(m) && out.length < MILESTONE_MAX) out.push(m) })
48
+ return out
49
+ }
50
+ // 打字机 / 脉搏用:`>> ` 原地换成 ▶(不换的话 markdown 会把 `>>` 渲染成嵌套引用)。
51
+ function renderMilestones(text) {
52
+ const lines = []
53
+ scanMilestoneLines(text, (l, m) => lines.push(m == null ? l : '▶ ' + m))
54
+ return lines.join('\n')
55
+ }
56
+ // 收尾用:正文里的里程碑行剔掉(它们进折叠区),只剩答案。
57
+ function stripMilestones(text) {
58
+ const lines = []
59
+ scanMilestoneLines(text, (l, m) => { if (m == null) lines.push(l) })
60
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
61
+ }
62
+ // 「最近在做」:一次工具调用 → 一句人话。只给文件名不给路径(群面零机器路径);命令内容不给
63
+ // (那是 agent 写的,可能带机器路径);插件按名字;认不出的返回 null,不拿框架里的工具名去凑数。
64
+ // codex 的工具名经 codexcode 映射后与 claude 同形(Bash / Write / MCP)。
65
+ const baseName = (p) => String(p || '').split(/[\\/]/).filter(Boolean).pop() || ''
66
+ function describeTool(name, input = {}) {
67
+ const n = String(name || '')
68
+ const inp = input && typeof input === 'object' ? input : {}
69
+ const file = baseName(inp.file_path || inp.path || inp.notebook_path)
70
+ const withFile = (verb) => (file ? `${verb} ${file}` : `${verb}文件`)
71
+ const mcp = /^mcp__(.+?)__/.exec(n)
72
+ if (mcp) return `用插件 ${mcp[1]}`
73
+ switch (n) {
74
+ case 'Read': return withFile('看')
75
+ case 'Edit': case 'MultiEdit': case 'NotebookEdit': return withFile('改')
76
+ case 'Write': return withFile('写')
77
+ case 'Delete': return withFile('删')
78
+ case 'Bash': return '跑命令'
79
+ case 'Glob': case 'Grep': case 'FileSearch': return '搜代码'
80
+ case 'WebFetch': case 'WebSearch': return '查网页'
81
+ case 'Task': case 'Agent': return '派子任务'
82
+ case 'MCP': return `用插件${inp.server ? ' ' + baseName(inp.server) : ''}`
83
+ default: return null
84
+ }
85
+ }
86
+ // ── 确定性里程碑 ──────────────────────────────────────────────────────────────
87
+ // 「最近在做」只滚最近 3 条,回答的是「还活着吗」;这里回答「做到哪了」:从工具调用里只认**交付类**
88
+ // 动作(写 / 改文件 · 发到群 · 上画布 / 生图 / 预览 · git 提交推送 · 出 PDF),累积、去重、封顶,
89
+ // 与模型自己的 `>> ` 行并排进卡、收尾同进折叠区。只给文件名不给路径,临时目录里的脚本不算
90
+ // (那是它写给自己用的),命令内容不给 —— 群面零机器路径。一次 Bash 只记最重的一条。
91
+ const TMP_PATH_RE = /^(?:\/private)?\/tmp\/|^\/var\/folders\/|^\$TMPDIR|^%TEMP%|\\Temp\\/i
92
+ const unquote = (s) => String(s || '').trim().replace(/^(["'])(.*)\1$/, '$2')
93
+ const SH_ARG = '("[^"]+"|\'[^\']+\'|[^\\s;|&<>]+)'
94
+ function autoMilestone(name, input = {}) {
95
+ const n = String(name || '')
96
+ const inp = input && typeof input === 'object' ? input : {}
97
+ // 路径里带 shell 变量(`$OUT`)或命令替换 ⇒ 名字解不出来,只报动作不报名
98
+ const fileMs = (verb, raw) => { const p = unquote(raw); const f = baseName(p); return f && !TMP_PATH_RE.test(p) && !/[$`]/.test(p) ? `${verb} ${f}` : null }
99
+ const target = inp.file_path || inp.path || inp.notebook_path
100
+ switch (n) {
101
+ case 'Write': return fileMs('写了', target)
102
+ case 'Edit': case 'MultiEdit': case 'NotebookEdit': return fileMs('改了', target)
103
+ case 'Bash': break
104
+ default: return null
105
+ }
106
+ const cmd = String(inp.command || '')
107
+ const grab = (re) => { const m = re.exec(cmd); return m ? m[1] : '' }
108
+ let a
109
+ // 平台基础技能的命令名不写死(sbot feishu / sbot wecom …):里程碑只认动作名
110
+ if ((a = grab(new RegExp('\\bsbot\\s+[a-z]+\\s+send-file\\s+' + SH_ARG)))) return fileMs('已发文件', a) || '已发文件到群'
111
+ if ((a = grab(new RegExp('\\bsbot\\s+[a-z]+\\s+send-image\\s+' + SH_ARG)))) return fileMs('已发图片', a) || '已发图片到群'
112
+ if (/\bsbot\s+[a-z]+\s+send-notice\b/.test(cmd)) return '已发通知到群'
113
+ if (/\bsbot\s+[a-z]+\s+send-msg\b/.test(cmd)) return '已发消息到群'
114
+ if (/\bsbot\s+feishu\s+board-add-(?:image|diagram|nodes)\b/.test(cmd)) return '已上画布'
115
+ if (/\bsbot\s+imagen\s/.test(cmd) && /--out\b/.test(cmd)) return '已提交生图'
116
+ if (/\bsbot\s+preview\s+start\b/.test(cmd)) return '已开预览'
117
+ if (/\bgit\s+(?:-C\s+\S+\s+)?push\b/.test(cmd)) return '已推送远端'
118
+ if (/\bgit\s+(?:-C\s+\S+\s+)?commit\b/.test(cmd)) return '已提交代码'
119
+ if ((a = grab(new RegExp('--print-to-pdf=' + SH_ARG)))) return fileMs('已出 PDF', a) || '已出 PDF'
120
+ if (/--print-to-pdf\b/.test(cmd)) return '已出 PDF'
121
+ if ((a = grab(new RegExp('(?:^|[\\s;&|(])cat\\s*>{1,2}\\s*' + SH_ARG)))) return fileMs('写了', a)
122
+ return null
123
+ }
124
+ // 最多留 3 条、去重、最新在最后(脉搏与 /status 共用同一个列表)。
125
+ function pushDoing(list, d) {
126
+ if (!d) return list
127
+ const i = list.indexOf(d)
128
+ if (i >= 0) list.splice(i, 1)
129
+ list.push(d)
130
+ while (list.length > 3) list.shift()
131
+ return list
132
+ }
133
+
134
+ module.exports = { T, humanDur, MILESTONE_MAX, milestonesOf, renderMilestones,
135
+ stripMilestones, describeTool, autoMilestone, pushDoing }
@@ -15,6 +15,7 @@ const { createC2TurnProjection } = require('./c2turnprojection')
15
15
  const { prepareC2RunnerInput } = require('./c2runnerinput')
16
16
  const { continueC2Successor } = require('./c2successorbridge')
17
17
  const { rejectC2Input } = require('./c2inputrejection')
18
+ const progress = require('./turnprogress')
18
19
  const NO_REPLY = 'NO_REPLY'
19
20
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
20
21
  const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
@@ -47,8 +48,10 @@ const T = {
47
48
  '/issue 一句话 记下问题反馈',
48
49
  steer ? '直接发文字就是派任务;任务进行中再发的消息会补充进当前任务。'
49
50
  : '直接发文字就是派任务;任务进行中再发的消息会排队,等这一轮结束再处理。'].join('\n'),
50
- status: ({ model, project, state, queue, session }) => ['【本群状态】', `· 模型:${model}`,
51
- `· 项目:${project}`, `· 状态:${state}`, `· 排队:${queue}`, `· 对话:${session}`].join('\n'),
51
+ status: ({ model, project, state, doing = [], queue, session }) => ['【本群状态】', `· 模型:${model}`,
52
+ `· 项目:${project}`, `· 状态:${state}`,
53
+ ...(doing.length ? [`· 最近在做:${doing.join(' · ')}`] : []),
54
+ `· 排队:${queue}`, `· 对话:${session}`].join('\n'),
52
55
  freshSession: '✅ 已开始新的一段对话,之前聊的内容不再带着',
53
56
  newBusy: '⚠ 手头还有任务在跑 —— 等它跑完,或先发 /stop 再发 /new',
54
57
  quotaShow: (name, value) => `${name} 额度:${value}`,
@@ -68,6 +71,7 @@ const T = {
68
71
  stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
69
72
  stopUnavailable: '当前任务正在收尾,暂时无法停止。',
70
73
  stoppedWithQueue: n => `已停止 — 连同排队的 ${n} 条一并取消`,
74
+ quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
71
75
  cardFooter: ({ ms, tokens }) => {
72
76
  const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
73
77
  const tokenLabel = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
@@ -351,11 +355,14 @@ async _durableV2Command(msg, cmd, rest) {
351
355
  if (cmd === '/status') {
352
356
  const rt = this.rts.get(msg.chatId)
353
357
  const queued = this.durableV2Runtime.snapshot().state?.lanes?.queue?.[msg.chatId]?.queue?.length || 0
354
- const state = rt?.v2NormalRouteActive
355
- ? `运行中(已跑 ${humanDuration(Date.now() - (rt.v2StartedAt || Date.now()))})`
358
+ // "Running for long" is not "stuck": steps and the last activity tell them apart.
359
+ const busy = !!rt?.v2NormalRouteActive
360
+ const state = busy
361
+ ? `运行中(已跑 ${humanDuration(Date.now() - (rt.v2StartedAt || Date.now()))}${rt.v2Steps ? ` · 已执行 ${rt.v2Steps} 步` : ''}${rt.v2AliveAt ? ` · 最近动静 ${humanDuration(Date.now() - rt.v2AliveAt)}前` : ''})`
356
362
  : rt?.v2AdmissionPending ? '消息已登记,等待并发名额' : '空闲'
357
363
  const project = String(group.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
358
- return reject('status', T.status({ model: label(name, provider), project, state, queue: queued,
364
+ return reject('status', T.status({ model: label(name, provider), project, state,
365
+ doing: busy && Array.isArray(rt.v2Doing) ? rt.v2Doing : [], queue: queued,
359
366
  session: this.store.getSession(msg.chatId) ? '进行中(记着上下文)' : '未开始' }))
360
367
  }
361
368
  if (cmd === '/new') {
@@ -1258,6 +1265,13 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1258
1265
  const generation = randomBytes(16).toString('hex')
1259
1266
  rt.v2RunnerGeneration = generation
1260
1267
  rt.v2StartedAt = Date.now()
1268
+ // Evidence that the turn is still moving, for /status and the card pulse;
1269
+ // every turn starts from zero.
1270
+ rt.v2Steps = 0
1271
+ rt.v2AliveAt = 0
1272
+ rt.v2Doing = []
1273
+ rt.v2AutoMilestones = []
1274
+ rt.v2CardQuotaFailure = false
1261
1275
  rt.v2CardBinding = null
1262
1276
  rt.v2StreamRequested = false
1263
1277
  rt.v2StreamQuiescing = false
@@ -1271,6 +1285,13 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1271
1285
  let accepted = false
1272
1286
  let controller = null
1273
1287
  let capTimer = null
1288
+ let ackTimer = null
1289
+ let ackDue = false
1290
+ let pulseTimer = null
1291
+ const stopProgressTimers = () => {
1292
+ if (ackTimer) { clearTimeout(ackTimer); ackTimer = null }
1293
+ if (pulseTimer) { clearInterval(pulseTimer); pulseTimer = null }
1294
+ }
1274
1295
  try {
1275
1296
  capability = authority.beginRunnerStart({ receiptId, chatId, turnToken,
1276
1297
  generation, verifyGeneration })
@@ -1346,19 +1367,28 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1346
1367
  rt.v2RunnerNotStarted = false
1347
1368
  let streamChain = Promise.resolve()
1348
1369
  let streamFailure = null
1349
- const enqueueFrame = event => {
1350
- if (!this.durableV2StreamFrames || event.type !== 'assistant') return
1351
- if (!event.text || controller.signal.aborted || rt.v2CancelControl ||
1352
- rt.v2StreamQuiescing ||
1353
- authority.scopeState('stopIntent', chatId)) return
1354
- const buffer = projection.streamSnapshot().bufferText
1355
- const trimmed = buffer.trim()
1356
- if (!trimmed || NO_REPLY.startsWith(trimmed)) return
1357
- const text = buffer
1370
+ // Card progress (same mechanism and wording as the legacy engine): deterministic
1371
+ // milestones and "recently doing" from tool calls, an early "received" card
1372
+ // and a pulse line. All of it is process-local card text; the card itself is
1373
+ // still opened through the durable terminal path below.
1374
+ const autoMs = []
1375
+ rt.v2AutoMilestones = autoMs
1376
+ const pushAuto = m => {
1377
+ if (m && !autoMs.includes(m) && autoMs.length < progress.MILESTONE_MAX) autoMs.push(m)
1378
+ }
1379
+ const autoBlock = () => (autoMs.length ? autoMs.map(m => '▶ ' + m).join('\n') + '\n\n' : '')
1380
+ const pulseFrom = Date.now()
1381
+ let cardTouchedAt = 0
1382
+ const progressClosed = () => rt.v2RunnerGeneration !== generation ||
1383
+ rt.v2TerminalStarted || controller.signal.aborted || rt.v2CancelControl ||
1384
+ rt.v2StreamQuiescing || !!authority.scopeState('stopIntent', chatId)
1385
+ const enqueueCardText = text => {
1358
1386
  rt.v2StreamRequested = true
1359
1387
  streamChain = streamChain.then(async () => {
1360
1388
  if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
1361
- if (authority.blocked) throw new Error('C2 v2 stream authority closed')
1389
+ // An earlier frame or pulse may still await its platform response;
1390
+ // only a closed authority ends the stream.
1391
+ if (authority.closed) throw new Error('C2 v2 stream authority closed')
1362
1392
  if (controller.signal.aborted || rt.v2CancelControl ||
1363
1393
  authority.scopeState('stopIntent', chatId)) return
1364
1394
  if (!rt.v2CardBinding) {
@@ -1368,8 +1398,10 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1368
1398
  generation, verifyGeneration,
1369
1399
  applyLocalEffect: this.durableV2ApplyLocalEffect,
1370
1400
  assertWriterLock: this.durableV2AssertWriterLock,
1371
- redactText: value => redact(value,
1372
- { pii: job.group?.redactPii !== false }),
1401
+ // An early card may end as the supported quota failure; its final
1402
+ // text then uses the same fixed notice as an unopened card.
1403
+ redactText: value => redact(rt.v2CardQuotaFailure === true
1404
+ ? T.quotaFailure : value, { pii: job.group?.redactPii !== false }),
1373
1405
  sendPlainReply: typeof job.platform.replyOnce === 'function'
1374
1406
  ? (target, value) => job.platform.replyOnce(target.replyCtx, value)
1375
1407
  : null })
@@ -1385,6 +1417,82 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1385
1417
  try { controller.abort() } catch {}
1386
1418
  })
1387
1419
  }
1420
+ const enqueueFrame = event => {
1421
+ if (event.type !== 'assistant') return
1422
+ // /status and the pulse read these; a late event from an older
1423
+ // generation must not write progress into this turn.
1424
+ if (rt.v2RunnerGeneration === generation) {
1425
+ rt.v2AliveAt = Date.now()
1426
+ if (event.tools && event.tools.length) {
1427
+ rt.v2Steps = (rt.v2Steps || 0) + event.tools.length
1428
+ for (const tool of event.tools) {
1429
+ progress.pushDoing(rt.v2Doing, progress.describeTool(tool.name, tool.input))
1430
+ pushAuto(progress.autoMilestone(tool.name, tool.input))
1431
+ }
1432
+ }
1433
+ }
1434
+ if (!this.durableV2StreamFrames) return
1435
+ if (!event.text || controller.signal.aborted || rt.v2CancelControl ||
1436
+ rt.v2StreamQuiescing ||
1437
+ authority.scopeState('stopIntent', chatId)) return
1438
+ const buffer = projection.streamSnapshot().bufferText
1439
+ const trimmed = buffer.trim()
1440
+ if (!trimmed || NO_REPLY.startsWith(trimmed)) return
1441
+ cardTouchedAt = Date.now()
1442
+ enqueueCardText(autoBlock() + progress.renderMilestones(buffer))
1443
+ }
1444
+ const cardsVisible = this.durableV2StreamFrames && !receipt.quiet &&
1445
+ typeof job.platform?.createStreamingCard === 'function'
1446
+ // No visible output after ackDelayMs: open the card that would be opened
1447
+ // anyway and fill it with "received"; the answer replaces it in place and
1448
+ // a turn that ends silent recalls it at the terminal.
1449
+ const ackMs = this.cfg.agent?.ackDelayMs == null ? 25_000 : Number(this.cfg.agent.ackDelayMs)
1450
+ // No card before the runner is accepted. A runner accepted after the
1451
+ // delay (Codex holds its events until its turn start returns) gets the
1452
+ // card as soon as it is accepted.
1453
+ const tryAck = () => {
1454
+ if (!accepted) { ackDue = true; return }
1455
+ if (progressClosed() || rt.v2CardBinding || rt.v2StreamRequested ||
1456
+ projection.streamSnapshot().bufferText.trim()) return
1457
+ cardTouchedAt = Date.now()
1458
+ enqueueCardText(progress.T.ackWorking)
1459
+ }
1460
+ const armAck = delayMs => {
1461
+ ackTimer = setTimeout(() => { ackTimer = null; tryAck() }, delayMs)
1462
+ if (ackTimer.unref) ackTimer.unref()
1463
+ }
1464
+ if (cardsVisible && ackMs > 0) armAck(ackMs)
1465
+ // Pulse: only rewrites the open card. Every PULSE_MS for the first
1466
+ // PULSE_SLOW_AFTER_MS, then every PULSE_SLOW_MS; "recent activity" is only
1467
+ // added after PULSE_STALE_MS without an assistant event.
1468
+ const agentCfg = this.cfg.agent || {}
1469
+ const PULSE_MS = agentCfg.cardPulseMs == null ? 15_000 : Number(agentCfg.cardPulseMs)
1470
+ const PULSE_SLOW_MS = agentCfg.cardPulseSlowMs == null ? 60_000 : Number(agentCfg.cardPulseSlowMs)
1471
+ const PULSE_SLOW_AFTER_MS = agentCfg.cardPulseSlowAfterMs == null ? 10 * 60 * 1000 : Number(agentCfg.cardPulseSlowAfterMs)
1472
+ const PULSE_STALE_MS = agentCfg.cardPulseStaleMs == null ? 2 * 60 * 1000 : Number(agentCfg.cardPulseStaleMs)
1473
+ if (cardsVisible && PULSE_MS > 0) {
1474
+ const pulseEvery = () => (Date.now() - pulseFrom >= PULSE_SLOW_AFTER_MS
1475
+ ? Math.max(PULSE_MS, PULSE_SLOW_MS) : PULSE_MS)
1476
+ pulseTimer = setInterval(() => {
1477
+ const binding = rt.v2CardBinding
1478
+ if (progressClosed() || !binding?.opened || binding.generation !== generation) return
1479
+ if (Date.now() - cardTouchedAt < pulseEvery()) return
1480
+ cardTouchedAt = Date.now()
1481
+ const sinceAlive = Date.now() - (rt.v2AliveAt || pulseFrom)
1482
+ const stale = sinceAlive >= PULSE_STALE_MS ? progress.humanDur(sinceAlive) : ''
1483
+ // Same gate as the stream frame: text that may still become NO_REPLY
1484
+ // never reaches the card.
1485
+ const buffer = projection.streamSnapshot().bufferText
1486
+ const sofar = buffer.trim()
1487
+ const body = autoBlock() + (sofar && !NO_REPLY.startsWith(sofar)
1488
+ ? progress.renderMilestones(buffer) + '\n\n' : '')
1489
+ try {
1490
+ binding.path.update(body + progress.T.pulse(
1491
+ progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
1492
+ } catch { /* a closed card never affects the turn */ }
1493
+ }, PULSE_MS)
1494
+ if (pulseTimer.unref) pulseTimer.unref()
1495
+ }
1388
1496
  const maxMins = Number(this.cfg.agent.maxTurnTimeMins) || 0
1389
1497
  const timeoutMs = maxMins > 0 ? maxMins * 60_000 : 0
1390
1498
  const timeoutDeadlineAt = timeoutMs > 0 ? Date.now() + timeoutMs : null
@@ -1416,6 +1524,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1416
1524
  onTurnAccepted: value => {
1417
1525
  const ack = capability.onTurnAccepted(value)
1418
1526
  accepted = true
1527
+ if (ackDue) { ackDue = false; armAck(0) }
1419
1528
  return ack
1420
1529
  },
1421
1530
  onSpawnFailed: value => capability.onSpawnFailed(value),
@@ -1440,6 +1549,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1440
1549
  let result
1441
1550
  try {
1442
1551
  result = await running
1552
+ stopProgressTimers()
1443
1553
  rt.v2SteerClosing = true
1444
1554
  rt.v2SteerControl = null
1445
1555
  if (rt.v2SteerDrain) await rt.v2SteerDrain
@@ -1462,6 +1572,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1462
1572
  }
1463
1573
  finally {
1464
1574
  if (capTimer) clearTimeout(capTimer)
1575
+ stopProgressTimers()
1465
1576
  if (rt.v2Controller === controller) rt.v2Controller = null
1466
1577
  }
1467
1578
  if (authority.blocked && rt.v2RunnerStopVerified !== true) {
@@ -1512,6 +1623,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1512
1623
  return { effectId, accepted: true, outputHeld: true }
1513
1624
  } catch (error) {
1514
1625
  if (capTimer) clearTimeout(capTimer)
1626
+ stopProgressTimers()
1515
1627
  if (controller && rt.v2Controller === controller) rt.v2Controller = null
1516
1628
  const proofId = `sbot-c2-runner-not-started-v1:${receiptId}:${generation}`
1517
1629
  const eventId = `sbot-c2-runner-not-started-commit-v1:${receiptId}:${turnToken}:${generation}`
@@ -1620,7 +1732,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1620
1732
  verifyGeneration, applyLocalEffect: this.durableV2ApplyLocalEffect,
1621
1733
  assertWriterLock: this.durableV2AssertWriterLock,
1622
1734
  redactText: text => redact(quotaFailure
1623
- ? 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。'
1735
+ ? T.quotaFailure
1624
1736
  : text, { pii: job.group?.redactPii !== false }),
1625
1737
  sendPlainReply: typeof job.platform.replyOnce === 'function'
1626
1738
  ? (target, text) => job.platform.replyOnce(target.replyCtx, text)
@@ -1631,7 +1743,15 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1631
1743
  for (const text of updates) path.update(text)
1632
1744
  const footer = T.cardFooter({ ms: Math.max(0, Date.now() - rt.v2StartedAt),
1633
1745
  tokens: rt.v2UsageDecision?.tokens || null })
1634
- const completed = await path.complete({ payload, replyText, paused, footer })
1746
+ // Milestones from tool calls first, then the model's own `>> ` lines;
1747
+ // the card shows them in its "process" fold (display only).
1748
+ rt.v2CardQuotaFailure = !!quotaFailure
1749
+ const redactMilestone = value => redact(value, { pii: job.group?.redactPii !== false })
1750
+ const milestones = [...(rt.v2AutoMilestones || []),
1751
+ ...progress.milestonesOf(payload.bufferText)]
1752
+ .filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
1753
+ .filter(m => typeof m === 'string' && m.trim())
1754
+ const completed = await path.complete({ payload, replyText, paused, footer, milestones })
1635
1755
  const after = authority.snapshot()
1636
1756
  const queue = after.state?.lanes?.queue?.[chatId]?.queue
1637
1757
  let successor = null
@@ -1668,6 +1788,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1668
1788
  rt.v2TerminalProjection = null
1669
1789
  rt.v2UsageDecision = null
1670
1790
  rt.v2StartedAt = null
1791
+ rt.v2AutoMilestones = []
1792
+ rt.v2CardQuotaFailure = false
1671
1793
  rt.v2CardBinding = null
1672
1794
  rt.v2StreamRequested = false
1673
1795
  rt.v2TimeoutProof = null