sintbot 1.4.10 → 1.4.12

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,30 @@
1
1
  [
2
+ {
3
+ "version": "1.4.12",
4
+ "date": "2026-11-10",
5
+ "name": "Projects and live activity",
6
+ "items": [
7
+ {
8
+ "t": "Projects, concurrency and context in one panel",
9
+ "d": "Show project paths, runtime, configured reasoning and context together; restore row icon actions and display task tools, reply and compact metrics after selecting a group. Remove the top available-controls notice."
10
+ },
11
+ {
12
+ "t": "Actual task details and reported context",
13
+ "d": "Shows requests, follow-ups, progress, replies and the latest reported context. Reuses existing events without extra model calls; details load on demand and restored samples are labeled historical."
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "version": "1.4.11",
19
+ "date": "2026-11-10",
20
+ "name": "Unified busy follow-ups",
21
+ "items": [
22
+ {
23
+ "t": "Busy follow-ups default on for Codex and Claude Code",
24
+ "d": "One per-group switch preserves explicit disabling. Claude follow-ups join after confirmed reading, and continuation answers form one final reply. Recalling handed-over input cancels the whole task; uncertain delivery is never replayed."
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "version": "1.4.10",
4
30
  "date": "2026-11-10",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "version": "1.4.12",
4
+ "date": "2026-11-10",
5
+ "name": "群项目与实时活动",
6
+ "items": [
7
+ {
8
+ "t": "群项目、并发与上下文集中展示",
9
+ "d": "群项目、运行耗时、项目路径、模型思考深度与上下文集中展示;恢复群行图标操作,选择群后查看任务,详情采用工具标签、回复正文和紧凑统计。 移除顶部当前可用操作说明卡片。"
10
+ },
11
+ {
12
+ "t": "真实任务详情与可信上下文",
13
+ "d": "展示请求、补话、进度、回复及最近上报的上下文。使用既有事件,不额外请求模型;详情按需加载,重启后历史采样明确标注。"
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "version": "1.4.11",
19
+ "date": "2026-11-10",
20
+ "name": "统一忙时跟进",
21
+ "items": [
22
+ {
23
+ "t": "Codex 与 Claude Code 默认开启忙时跟进",
24
+ "d": "统一按群开关并持久保存关闭;Claude 确认读取后加入任务,续轮答案合并为一条最终回复。撤回已交给执行器的补话取消整轮,未知送达不重放。"
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "version": "1.4.10",
4
30
  "date": "2026-11-10",
package/bin/sintbot-v2.js CHANGED
@@ -114,6 +114,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
114
114
  let configControl
115
115
  let updater
116
116
  let liveTransport
117
+ let activity
117
118
  let onSigint, onSigterm
118
119
  try {
119
120
  const migrationFence = require('../src/c2migration').loadC2MigrationFence({ stateDir, routes })
@@ -139,6 +140,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
139
140
  // All group Engines must share this exact object: separate Store caches
140
141
  // would overwrite sibling sessions when they save the same state file.
141
142
  const store = new StateStore(path.join(stateDir, 'state.json'))
143
+ activity = require('../src/ui/liveactivity').createLiveActivity({
144
+ file: path.join(stateDir, 'activity-v1.json'), canWrite: assertWriterLock,
145
+ log: message => process.stderr.write(`Console · ${message}\n`) })
142
146
  const closeChecks = {
143
147
  claude: require('../src/agent/claudecode').isClosedRunnerResult,
144
148
  'codex-exec': require('../src/agent/codexcode').isClosedRunnerResult,
@@ -154,11 +158,12 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
154
158
  const limits = { maxEventCount: 100_000, maxJournalBytes: 64 * 1024 * 1024,
155
159
  maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
156
160
  let finishing = false
157
- const finish = result => {
161
+ const finish = async result => {
158
162
  if (finishing) return
159
163
  finishing = true
160
164
  ui?.close()
161
165
  updater?.close()
166
+ await activity?.close()
162
167
  lock.release()
163
168
  process.stderr.write(`C2 multi-group · ${result.reason}; platform stopped=${result.platformStopped}; runner drained=${result.drained}\n`,
164
169
  () => process.exit(result.code))
@@ -230,7 +235,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
230
235
  return result
231
236
  }
232
237
  engine = new Engine({ ...cfg, groups: { [route.chatId]: route.group } }, {
233
- store, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
238
+ store, activity, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
234
239
  durableV2Runtime: runtime, durableV2Payloads: handles,
235
240
  durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
236
241
  durableV2VerifyRunnerClosed: ({ result, error }) => result
@@ -284,7 +289,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
284
289
  process.on('SIGTERM', onSigterm)
285
290
  if (uiEnabled && !preflightOnly) {
286
291
  ui = require('../src/c2console').createC2Console({
287
- configPath: cfg._path, stateDir, host: uiHost,
292
+ configPath: cfg._path, stateDir, host: uiHost, activity, store,
288
293
  getConfig: () => cfg, getBindings: () => service.bindings,
289
294
  getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
290
295
  getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
@@ -339,6 +344,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
339
344
  throw error
340
345
  } finally {
341
346
  if (!running) {
347
+ await activity?.close()
342
348
  updater?.close()
343
349
  if (onSigint) process.off('SIGINT', onSigint)
344
350
  if (onSigterm) process.off('SIGTERM', onSigterm)
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.10 with an AI assistant
1
+ # Install SintBot 1.4.12 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.10 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.12 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.10
1
+ # Install SintBot 1.4.12
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.10` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.10`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.12` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.12`. 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
 
@@ -51,7 +51,7 @@ Create a private directory with `mkdir -p ~/.sbot && chmod 700 ~/.sbot`, then cr
51
51
  "provider": "codex",
52
52
  "workdir": "/absolute/path/to/first/project",
53
53
  "permissionMode": "dontAsk",
54
- "codexSteer": true
54
+ "steer": true
55
55
  },
56
56
  "oc_REPLACE_WITH_SECOND_CHAT_ID": {
57
57
  "platform": "feishu",
@@ -99,13 +99,15 @@ Any text message is a task. Replies arrive in a card that appears once the model
99
99
  | `/issue text` | Log feedback; review and close it in the console overview |
100
100
  | `/help` | List commands, with this group's project and model |
101
101
 
102
- Send a command as a message of its own; except for `/issue` and `/model`, do not add other text after it. If the Feishu app also subscribes to the message-recall event (the console's setup guide recommends it), recalling your own message cancels it: a queued message is dropped, and the running task stops if that message alone started it; in Codex groups, recalling a message that was added to the running task cancels the whole task.
102
+ Send a command as a message of its own; except for `/issue` and `/model`, do not add other text after it. If the Feishu app also subscribes to the message-recall event (the console's setup guide recommends it), recalling your own message cancels it: a queued message is dropped, and the running task stops if that message alone started it; recalling a follow-up handed to either runner cancels the whole task, including accepted input not yet read.
103
103
 
104
- Messages sent while a task runs are added to that task in Codex groups (`codexSteer: true`) and queued in Claude Code groups. If a turn returns a model error or no result, only that group is asked to resend; there is no automatic retry. If a group's last result cannot be confirmed, such as a reply whose delivery is unknown, only that group is paused and answers new messages with a short notice; other groups keep working. Nothing is replayed or retried.
104
+ Busy follow-ups default on for both Codex and Claude Code. Set `steer: false` to queue them instead; an explicit `steer` value overrides the legacy Codex setting. Claude Code must advertise the required input capabilities, otherwise messages stay queued. Reading can wait until a long-running tool finishes. The reply card follows confirmed reading, and continuation answers are combined into one final reply. If a turn returns a model error or no result, only that group is asked to resend; there is no automatic retry. If a group's last result cannot be confirmed, such as a reply whose delivery is unknown, only that group is paused and answers new messages with a short notice; other groups keep working. Nothing is replayed or retried.
105
105
 
106
106
  ## 6. Console
107
107
 
108
- With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, starts a new conversation for an idle group, binds new groups, switches the model or project folder of an idle group, and adds or configures models. For LAN access, set a console password in its settings first, then enable LAN access.
108
+ The overview combines group projects and concurrency. Each row shows the runner, state and elapsed time, project folder, model and configured reasoning effort, and the latest reported context usage. Select a group to view its tasks, then expand a task for tools, reply and usage. Context is a session sample, not cumulative token consumption; unknown capacity and older samples are labeled. Row icons provide console messages, stop and new-session controls according to the current group state.
109
+
110
+ With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group, stops tasks, starts a new conversation for an idle group, binds new groups, switches the model or project folder of an idle group, and adds or configures models. For LAN access, set a console password in its settings first, then enable LAN access.
109
111
 
110
112
  Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. A window shows each step, reconnects by itself while the service restarts and can be left running in the background. On the first start after an upgrade, each serving group gets one card listing what changed; to turn this off, clear "Announce upgrades in groups" under Settings → Software updates.
111
113
 
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.10
1
+ # 用 AI 助手安装 SintBot 1.4.12
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.10。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.12。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.10
1
+ # 安装 SintBot 1.4.12
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.10` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.10` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.12` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.12` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -51,7 +51,7 @@ export FEISHU_APP_ID FEISHU_APP_SECRET
51
51
  "provider": "codex",
52
52
  "workdir": "/absolute/path/to/first/project",
53
53
  "permissionMode": "dontAsk",
54
- "codexSteer": true
54
+ "steer": true
55
55
  },
56
56
  "oc_REPLACE_WITH_SECOND_CHAT_ID": {
57
57
  "platform": "feishu",
@@ -99,13 +99,15 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
99
99
  | `/issue 一句话` | 记下问题反馈,在控制台概览里查看和标记处理 |
100
100
  | `/help` | 查看可用命令,以及本群的项目和模型 |
101
101
 
102
- 命令单独发一条;除 `/issue` 和 `/model` 外,后面不要跟别的字。如果飞书应用还订阅了消息撤回事件(控制台接入引导里建议订阅),撤回自己发的消息即取消那件事:还在排队的直接丢弃;当前任务只由这一条触发时会停下;Codex 群里撤回已补充进当前任务的那条,会连当前任务一起取消。
102
+ 命令单独发一条;除 `/issue` 和 `/model` 外,后面不要跟别的字。如果飞书应用还订阅了消息撤回事件(控制台接入引导里建议订阅),撤回自己发的消息即取消那件事:还在排队的直接丢弃;当前任务只由这一条触发时会停下;两种执行器中,撤回已交给执行器的补话均会取消整轮,包括已受理但尚未读取的补话。
103
103
 
104
- 任务进行中再发的消息:Codex 群(`codexSteer: true`)会补充进当前任务,Claude Code 群会排队,等这一轮结束再处理。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
104
+ Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队,显式 `steer` 优先于旧 Codex 开关。Claude Code 未提供所需输入能力时保留排队。长工具调用可能延迟读取;确认读取后回复卡才跟随补话,续轮答案合并为一条最终回复。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
105
105
 
106
106
  ## 6. 控制台
107
107
 
108
- 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
108
+ 概览合并群项目与并发信息,每行显示执行器、状态与耗时、项目目录、模型和配置的思考深度、最近上报的上下文用量。选择群后查看对应任务,展开任务查看工具、回复和用量。上下文是当前会话采样,不是累计消耗;容量未知或历史采样会明确标注。群行图标按当前状态提供插话、停止与新会话操作。
109
+
110
+ 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
109
111
 
110
112
  「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级后首次启动会在每个在服务的群发一张版本公告卡,列出这一版的更新;不想播报就在「设置 → 软件更新」取消「升级后向群里播报」。
111
113
 
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.10
5
+ version=1.4.12
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.10",
3
+ "version": "1.4.12",
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": {
@@ -44,6 +44,7 @@
44
44
  "docs/zh-CN/install.md",
45
45
  "install.sh",
46
46
  "src/agent/claudecode.js",
47
+ "src/agent/claudefollowup.js",
47
48
  "src/agent/claudemodels.js",
48
49
  "src/agent/claudestatus.js",
49
50
  "src/agent/claudeusage.js",
@@ -134,6 +135,8 @@
134
135
  "src/ui/console.html",
135
136
  "src/ui/dashboard.js",
136
137
  "src/ui/feishulogin.js",
138
+ "src/ui/liveactivity-client.js",
139
+ "src/ui/liveactivity.js",
137
140
  "src/ui/serverbase.js",
138
141
  "src/ui/sessions.js",
139
142
  "src/ui/takeover.js",
@@ -15,6 +15,7 @@
15
15
 
16
16
  const { spawn } = require('node:child_process')
17
17
  const { StringDecoder } = require('node:string_decoder')
18
+ const { createClaudeFollowup } = require('./claudefollowup')
18
19
  const { ensureProxy, proxyBaseFor } = require('./visionproxy')
19
20
 
20
21
  // Always-on 群项目基础能力: auto-allowed regardless of preset (even read-only) so the
@@ -35,7 +36,7 @@ const closedRunnerErrors = new WeakSet()
35
36
  const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
36
37
  const closedError = error => { closedRunnerErrors.add(error); return error }
37
38
  const PRE_INIT_HOOK_EVENTS = new Set(['hook_started', 'hook_response'])
38
- const DURABLE_EVENT_TYPES = new Set(['system', 'assistant', 'user', 'result', 'tool_result'])
39
+ const DURABLE_EVENT_TYPES = new Set(['system', 'assistant', 'user', 'result', 'tool_result', 'turn_continue'])
39
40
  // Tool output is completion evidence only, never answer text.
40
41
  function durableToolResults(ev) {
41
42
  const results = Array.isArray(ev.results) ? ev.results : []
@@ -152,6 +153,8 @@ function runTurn({
152
153
  log = () => {}, // daemon logger — 与 codex runner 同一个口子(engine 两边都传 this.log)
153
154
  spawnFn = spawn, // 可注入,供单测驱动 spawn/解析环路(与 codexcode 同一形状)
154
155
  softStopGraceMs = 10000,
156
+ steer = false, onSteerReady, onSteerLanded,
157
+ steerReceiptTimeoutMs = 10000, steerHoldMs = 20000, interruptGraceMs = 2000,
155
158
  closeGraceMs = 3000, // short: the grace starts AFTER the answer, so we're only waiting for
156
159
  // the OS to close a possibly-clinging pipe (MCP browser children / cmd.exe shim). Claude
157
160
  // persists its session continuously, so killing ~3s post-answer is safe → the group frees
@@ -225,7 +228,7 @@ function runTurn({
225
228
  }
226
229
  // The binary owns the permission, tool allow/deny and argument policy.
227
230
  // Node only resolves capability and writes the project-scoped MCP file.
228
- const args = claudeBuildArgs({ model, providerModel: provider.model, sessionId,
231
+ const args = claudeBuildArgs({ steer, model, providerModel: provider.model, sessionId,
229
232
  permissionMode, autocompactTokens, autocompactSupported,
230
233
  mcpConfigPath, allowedTools, pluginAllows, disallowedTools,
231
234
  appendSystemPrompt, workdir })
@@ -242,8 +245,14 @@ function runTurn({
242
245
  // Feed the prompt over stdin instead of as an argv element: cross-platform,
243
246
  // and it removes ALL Windows argv/cmd.exe escaping risk for arbitrary chat
244
247
  // text (quotes, &, |, %, newlines). Close stdin so `claude -p` starts.
245
- child.stdin.on('error', () => { /* child may exit before the write drains */ })
246
- child.stdin.end(prompt)
248
+ let followup = null
249
+ let stdinOpen = true
250
+ const endStdin = () => { if (stdinOpen) { stdinOpen = false; child.stdin.end() } }
251
+ const writeFrame = frame => {
252
+ if (!stdinOpen || childCloseObserved) throw new Error('Claude input is closed')
253
+ child.stdin.write(JSON.stringify(frame) + '\n')
254
+ }
255
+ child.stdin.on('error', error => { if (steer && !settled) followup?.fail(error) })
247
256
 
248
257
  let capturedSessionId = sessionId || null
249
258
  let lastResult = null
@@ -265,7 +274,7 @@ function runTurn({
265
274
  // Resolution is guarded (exactly once) because a close-grace timer can force the
266
275
  // turn to settle before the 'close' event fires — otherwise a lingering pipe would
267
276
  // wedge the per-group lock forever (never-hang invariant).
268
- const finish = (fn) => { if (settled) return; settled = true; if (killTimer) clearTimeout(killTimer); if (closeTimer) clearTimeout(closeTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
277
+ const finish = (fn) => { if (settled) return; settled = true; followup?.dispose(); if (killTimer) clearTimeout(killTimer); if (closeTimer) clearTimeout(closeTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
269
278
  const resolveRun = result => {
270
279
  if (childCloseObserved) closedRunnerResults.add(result)
271
280
  resolve(result)
@@ -332,12 +341,21 @@ function runTurn({
332
341
  // Windows has no POSIX signals, and killing the cmd.exe shim would orphan
333
342
  // the real claude process underneath. taskkill /T /F ends the whole tree.
334
343
  try { spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }) } catch {}
344
+ } else if (followup && stdinOpen) {
345
+ try { followup.stop() } catch { /* close proof remains authoritative */ }
346
+ killTimer = setTimeout(() => {
347
+ try { child.kill('SIGTERM') } catch {}
348
+ killTimer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
349
+ }, interruptGraceMs)
335
350
  } else {
336
351
  try { child.kill('SIGTERM') } catch { /* already gone */ }
337
352
  killTimer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
338
353
  }
339
354
  armCloseGrace() // even after a kill, 'close' can hang on a clinging pipe — bound it
340
355
  }
356
+ if (steer) followup = createClaudeFollowup({ write: writeFrame, end: endStdin,
357
+ onReady: onSteerReady, onLanded: onSteerLanded, onFailure: failDurableEvent,
358
+ receiptTimeoutMs: steerReceiptTimeoutMs, holdMs: steerHoldMs })
341
359
  if (signal) {
342
360
  if (signal.aborted) onAbort()
343
361
  else signal.addEventListener('abort', onAbort, { once: true })
@@ -370,6 +388,7 @@ function runTurn({
370
388
  if (!line) continue
371
389
  let raw
372
390
  try { raw = JSON.parse(line) } catch { continue } // skip non-JSON noise
391
+ if (followup && followup.protocol(raw)) continue
373
392
  if (onTurnAccepted && !turnAccepted) {
374
393
  // Locally configured session hooks report their own lifecycle before
375
394
  // init. They are not model output; skip them and keep waiting.
@@ -396,6 +415,9 @@ function runTurn({
396
415
  }
397
416
  turnAccepted = true
398
417
  }
418
+ if (followup && raw.type === 'system' && raw.subtype === 'init') {
419
+ if (followup.init(raw) === 'unavailable') log('当前模型程序未提供忙时跟进能力,本轮后续消息排队')
420
+ }
399
421
  // key 错快速失败(1.0.3):claude 对 401 / 403 默认重试约 3 分钟才放弃,第一次重试时(实测 0.8 秒)就会先报一条
400
422
  // `system/api_retry` 带 error_status。只对 API key 型模型(有 baseUrl)当场收尾 —— 订阅登录的 401 可能是
401
423
  // Claude Code 自己在刷新登录,让它重试;5xx / 529 这类过载照旧重试。造一条出错 result 交给上层:账号类失败
@@ -417,12 +439,20 @@ function runTurn({
417
439
  ev.contextTokens = (lastMsgUsage.input_tokens || 0) + (lastMsgUsage.cache_read_input_tokens || 0) + (lastMsgUsage.cache_creation_input_tokens || 0)
418
440
  }
419
441
  if (ev.type === 'result') {
442
+ const windows = Object.values(raw.modelUsage || {}).map(value => value?.contextWindow)
443
+ // No model-name guesses; a single reported capacity is unambiguous.
444
+ if (windows.length && windows.every(value => Number.isFinite(value) && value > 0) &&
445
+ new Set(windows).size === 1) ev.contextWindowTokens = windows[0]
420
446
  // 当前上下文规模 = 最后一次 API 请求的输入侧总量(未命中 + 缓存读 + 缓存写)。
421
447
  // result.usage 是整轮跨请求聚合、不能当上下文用;逐请求数在 assistant 事件的
422
448
  // message.usage 里(2.1.222 真机核对),兜底走 usage.iterations 末元素(同一形状)。
423
449
  const last = lastMsgUsage || (ev.usage && Array.isArray(ev.usage.iterations) ? ev.usage.iterations[ev.usage.iterations.length - 1] : null)
424
450
  if (last) ev.contextTokens = (last.input_tokens || 0) + (last.cache_read_input_tokens || 0) + (last.cache_creation_input_tokens || 0)
425
451
  }
452
+ if (followup && !aborted && ev.type === 'result') {
453
+ ev = followup.result(ev)
454
+ if (ev.type === 'turn_continue') lastMsgUsage = null
455
+ }
426
456
  // Only transcript events reach the durable projection. Informational
427
457
  // stream additions (for example rate_limit_event) carry no answer.
428
458
  // Claude reports several tool results per event, with array content
@@ -469,6 +499,10 @@ function runTurn({
469
499
  if (onDurableEvent) durableWork.then(() => { if (!durableFailed) completeClose(code) })
470
500
  else completeClose(code)
471
501
  })
502
+ if (!aborted) {
503
+ if (followup) followup.start(prompt)
504
+ else child.stdin.end(prompt)
505
+ }
472
506
  }
473
507
  })
474
508
  }
@@ -0,0 +1,92 @@
1
+ 'use strict'
2
+
3
+ // Transport adapter only: the binary decides readiness, landing and segment boundaries.
4
+ const { randomUUID } = require('node:crypto')
5
+ const { claudeFollowupStep } = require('./privatecore')
6
+
7
+ function createClaudeFollowup({ write, end, onReady, onLanded, onFailure,
8
+ receiptTimeoutMs = 10000, holdMs = 20000 } = {}) {
9
+ let state = null, disposed = false, holdTimer = null, sessionId = null
10
+ const firstId = randomUUID()
11
+ const pending = new Map(), messages = new Map()
12
+ const step = event => { const result = claudeFollowupStep(state, event); state = result.state; return result }
13
+ const clearHold = () => { clearTimeout(holdTimer); holdTimer = null }
14
+ const settle = (id, error) => {
15
+ const waiter = pending.get(id)
16
+ if (!waiter) return
17
+ pending.delete(id); clearTimeout(waiter.timer)
18
+ if (error) waiter.reject(error); else waiter.resolve(true)
19
+ }
20
+ const dispose = () => {
21
+ disposed = true; clearHold()
22
+ for (const id of pending.keys()) settle(id, new Error('Follow-up closed before receipt'))
23
+ }
24
+ const fail = error => { if (disposed) return; dispose(); onFailure(error) }
25
+ const control = {
26
+ steerDurable({ msgId, text } = {}) {
27
+ if (disposed) return Promise.resolve(false)
28
+ if (typeof msgId !== 'string' || !msgId || typeof text !== 'string' || !text || text.length > 200000) {
29
+ return Promise.resolve(false)
30
+ }
31
+ if ([...messages.values()].includes(msgId)) return Promise.reject(new Error('Duplicate follow-up message'))
32
+ const id = randomUUID()
33
+ const decision = step({ type: 'write', id })
34
+ if (decision.action === 'reject') return Promise.resolve(false)
35
+ messages.set(id, msgId)
36
+ const promise = new Promise((resolve, reject) => {
37
+ const timer = setTimeout(() => fail(new Error('Follow-up receipt timed out; delivery unknown')), receiptTimeoutMs)
38
+ pending.set(id, { resolve, reject, timer })
39
+ })
40
+ // Register identity and waiter before writing: a receipt may arrive immediately.
41
+ try { write({ type: 'user', uuid: id, message: { role: 'user', content: text } }) }
42
+ catch (error) { fail(error) }
43
+ return promise
44
+ },
45
+ }
46
+ return {
47
+ start(prompt) { write({ type: 'user', uuid: firstId, message: { role: 'user', content: prompt } }) },
48
+ protocol(raw) {
49
+ if (raw.type === 'control_response') return true
50
+ if (raw.type !== 'command_lifecycle') return false
51
+ if (raw.command_uuid === firstId) return true
52
+ if (sessionId && raw.session_id && raw.session_id !== sessionId) throw new Error('Follow-up session mismatch')
53
+ if (disposed) return true
54
+ const decision = step({ type: 'lifecycle', id: raw.command_uuid, status: raw.state })
55
+ if (decision.accepted) settle(raw.command_uuid)
56
+ if (decision.landed) { clearHold(); onLanded?.(messages.get(raw.command_uuid)) }
57
+ return true
58
+ },
59
+ init(raw) {
60
+ if (sessionId) {
61
+ if (raw.session_id !== sessionId) throw new Error('Follow-up init session changed')
62
+ return 'none'
63
+ }
64
+ sessionId = raw.session_id
65
+ const decision = step({ type: 'init', capabilities: raw.capabilities })
66
+ if (decision.action === 'ready') onReady?.(control)
67
+ else end()
68
+ return decision.action
69
+ },
70
+ result(event) {
71
+ const decision = step({ type: 'result', usage: event.usage, isError: event.isError })
72
+ clearHold()
73
+ if (decision.action === 'continue') {
74
+ holdTimer = setTimeout(() => fail(new Error('Follow-up landing timed out; replay forbidden')), holdMs)
75
+ return { ...event, type: 'turn_continue', usage: decision.usage }
76
+ }
77
+ end()
78
+ return { ...event, usage: { ...event.usage, ...decision.usage } }
79
+ },
80
+ stop() {
81
+ if (!disposed) step({ type: 'stop' })
82
+ dispose()
83
+ write({ type: 'control_request', request_id: `stop-${randomUUID()}`,
84
+ request: { subtype: 'interrupt', cancel_queued: true } })
85
+ end()
86
+ },
87
+ dispose,
88
+ fail,
89
+ }
90
+ }
91
+
92
+ module.exports = { createClaudeFollowup }
@@ -143,6 +143,9 @@ function runTurn(opts = {}) {
143
143
  totalUsage = usageFromTotal(usage.total)
144
144
  if (Number.isFinite(usage.last && usage.last.inputTokens) && usage.last.inputTokens > 0) contextTokens = usage.last.inputTokens
145
145
  if (Number.isFinite(usage.modelContextWindow) && usage.modelContextWindow > 0) contextWindowTokens = usage.modelContextWindow
146
+ // Observational only: do not add telemetry to the durable transcript.
147
+ if (!completed && contextTokens > 0) legacyEmit({ type: 'system', subtype: 'context',
148
+ sessionId: capturedSessionId, contextTokens, contextWindowTokens })
146
149
  }
147
150
  const write = (obj) => {
148
151
  if (child.stdin.destroyed) throw new Error('codex app-server stdin closed')
@@ -402,7 +405,8 @@ function runTurn(opts = {}) {
402
405
  const next = appDeltaNext(messageDeltas.get(params.itemId) || '', delta)
403
406
  if (next !== null) {
404
407
  messageDeltas.set(params.itemId, next)
405
- emit({ type: 'assistant', text: delta, contextTokens })
408
+ emit({ type: 'assistant', text: delta,
409
+ ...(contextTokens > 0 ? { contextTokens, contextWindowTokens, contextSampled: false } : {}) })
406
410
  }
407
411
  } else if (action === 4) {
408
412
  const item = params.item
@@ -1,9 +1,9 @@
1
- // ABI v34 adapter for the private binary decision core. Manifest assembly still stays in JS.
1
+ // ABI v35 adapter for the private binary decision core. Manifest assembly still stays in JS.
2
2
  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 = '8c55d4f97a738e14885841f9b8f83906cca1e8e9aef46bb28f31022aef1fce2d'
6
+ const EXPECTED_SHA256 = '9c29c9123b38aa75b9c47066c809d844f938baca41779372b77c02fa7baa9989'
7
7
  let bytes
8
8
  try {
9
9
  bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
@@ -26,7 +26,7 @@ try {
26
26
  } catch (cause) {
27
27
  throw new Error('SintBot binary core cannot start on this runtime', { cause })
28
28
  }
29
- if (core.abi_version() !== 34) throw new Error('SintBot binary core ABI mismatch')
29
+ if (core.abi_version() !== 35) throw new Error('SintBot binary core ABI mismatch')
30
30
  const encoder = new TextEncoder()
31
31
  const decoder = new TextDecoder('utf-8', { fatal: true })
32
32
 
@@ -784,7 +784,21 @@ function guarded(fn) {
784
784
  }
785
785
  }
786
786
 
787
- const entries = { classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
787
+ function followupCall(name, value) {
788
+ let ptr = 0, size = 0
789
+ try {
790
+ ptr = callText(name, JSON.stringify(value))
791
+ if (!ptr) throw new Error('Follow-up core rejected protocol state')
792
+ size = new DataView(core.memory.buffer).getUint32(ptr, true) + 4
793
+ return JSON.parse(decoder.decode(new Uint8Array(core.memory.buffer, ptr + 4, size - 4)))
794
+ } finally {
795
+ if (ptr && size) { new Uint8Array(core.memory.buffer).fill(0, ptr, ptr + size); core.dealloc(ptr, size) }
796
+ }
797
+ }
798
+ function followupEnabled(group, runner) { return followupCall('followup_policy', { group, runner }) }
799
+ function claudeFollowupStep(state, event) { return followupCall('claude_followup_step', { state, event }) }
800
+
801
+ const entries = { followupEnabled, claudeFollowupStep, classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
788
802
  for (const name of Object.keys(entries)) entries[name] = guarded(entries[name])
789
803
 
790
804
  module.exports = { ...entries, engineStep, resetBinaryCore, HUMAN_BY_KIND: HUMAN }
Binary file