sintbot 1.4.6 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.json CHANGED
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "version": "1.4.8",
4
+ "date": "2026-11-10",
5
+ "name": "Console interjections, upgrade notices, group messaging skills",
6
+ "items": [
7
+ {
8
+ "t": "Fix: a message sent while the previous reply was still being delivered was refused with \"wrapping up, please resend\"",
9
+ "d": "A message arriving at the moment a reply, card or the stop confirmation was still on its way to Feishu used to be refused. It now waits for that delivery and is handled in arrival order; a message sent right after /stop is answered normally."
10
+ },
11
+ {
12
+ "t": "New: the console can post a line into an idle group",
13
+ "d": "A line written in the console is posted to the group as the administrator and handled as an ordinary group message, with the reply in the group. A busy, held or upgrading group is refused with the reason; resubmitting the same line is not processed twice."
14
+ },
15
+ {
16
+ "t": "New: a version notice card in each group after an upgrade",
17
+ "d": "On the first start after an upgrade, each serving group receives one card listing what changed. Not sent on a first install or a downgrade; can be turned off in the console."
18
+ },
19
+ {
20
+ "t": "New: the agent in a group can send messages, notice cards and files, read history, reply, edit and recall",
21
+ "d": "The agent gains group skills: send messages, notice cards, files and images, read the group history, reply to, edit and recall its own messages, download group files and add reactions."
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "version": "1.4.7",
27
+ "date": "2026-11-09",
28
+ "name": "/stop no longer pauses the group",
29
+ "items": [
30
+ {
31
+ "t": "Fix: /stop during a task could pause the whole group",
32
+ "d": "/stop removes the task's reply card, and Feishu then reports that removal as a recall. When it arrived while \"stopped\" was still being sent, it was treated as a fault: the group was paused until an administrator released it, and later /model and /new were refused. The recall now waits for that reply to finish and the group keeps serving."
33
+ },
34
+ {
35
+ "t": "Fix: recalling a queued message could pause the group",
36
+ "d": "Recalling a message while its \"queued\" notice was still being sent also paused the whole group; the message is now cancelled once the notice has been sent."
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "version": "1.4.6",
4
42
  "date": "2026-11-09",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "version": "1.4.8",
4
+ "date": "2026-11-10",
5
+ "name": "控制台插话、升级公告、群消息技能",
6
+ "items": [
7
+ {
8
+ "t": "修复:上一条回复还在发送时发新消息,会被回「正在收尾,请稍后重发」",
9
+ "d": "回复、卡片或「已停止」还在发往飞书的那一瞬间到达的新消息,此前会被拒收。现在会等那次发送完成,再按到达顺序处理;/stop 后紧接着发的消息也照常回答。"
10
+ },
11
+ {
12
+ "t": "新增:控制台可以给空闲的群插话",
13
+ "d": "在控制台写一句话,会以管理员的名义发进群里,按一条普通群消息处理,回复也在群里。群正忙、待人工处理或升级中时不发,并说明原因;重复提交同一句不会处理两次。"
14
+ },
15
+ {
16
+ "t": "新增:升级后在各群发一张版本公告卡",
17
+ "d": "版本升级后首次启动,向每个在服务的群发一张卡片,列出这一版的更新。首次安装和降级不发;可以在控制台关闭。"
18
+ },
19
+ {
20
+ "t": "新增:群里的 Agent 可以发消息、发通知卡、发文件、读历史、回复、编辑和撤回",
21
+ "d": "Agent 在群里能用的技能多了:发消息、通知卡、文件和图片,读群历史,回复、编辑、撤回自己发的消息,下载群文件,加表情回应。"
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "version": "1.4.7",
27
+ "date": "2026-11-09",
28
+ "name": "/stop 后群不再被暂停",
29
+ "items": [
30
+ {
31
+ "t": "修复:任务进行中发 /stop,群可能被整个暂停",
32
+ "d": "/stop 会删掉这条任务的回复卡片,飞书随后把这次删除当作一次撤回通知回来;若它恰好在「已停止」还在发送时到达,此前会被当成故障,群被暂停、要管理员人工解封,后面的 /model、/new 也都不响应。现在会等那条回复发完再处理,群照常服务。"
33
+ },
34
+ {
35
+ "t": "修复:撤回排队中的消息时群可能被暂停",
36
+ "d": "在「已排队」提示还在发送时撤回那条消息,此前也会让整群暂停;现在等提示发完再取消那条消息。"
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "version": "1.4.6",
4
42
  "date": "2026-11-09",
package/bin/sintbot-v2.js CHANGED
@@ -273,6 +273,8 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
273
273
  endMaintenance: () => service.endMaintenance(), expectedGroups: () => service.bindings.size,
274
274
  registries: c2updates.registriesFromEnv(),
275
275
  fetchImpl: require('../src/core/proxy').getProxyFetch() || globalThis.fetch,
276
+ // The same store as the startup announcement below.
277
+ setAnnouncementBaseline: version => store.setLastVersion(version),
276
278
  autoCheck: uiEnabled, log: message => process.stderr.write(`C2 update · ${message}\n`) })
277
279
  }
278
280
  const stop = reason => { void service.stop(reason, 0).then(finish) }
@@ -286,6 +288,11 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
286
288
  getConfig: () => cfg, getBindings: () => service.bindings,
287
289
  getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
288
290
  getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
291
+ // Console injection enters through the same ingress as a group
292
+ // message, posted and answered with that group's own adapter.
293
+ getPlatform: chatId => liveTransport?.platform.forChat(chatId) || null,
294
+ routeMessage: (msg, adapter) => service.routeMessage(msg, adapter),
295
+ isServing: () => service.serving, isFrozen: () => service.inMaintenance,
289
296
  assertWriterLock, ...configControl, updater,
290
297
  log: message => process.stderr.write(`Console · ${message}\n`) })
291
298
  await new Promise((resolve, reject) => {
@@ -305,6 +312,19 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
305
312
  for (const item of started.held || []) {
306
313
  process.stderr.write(`SintBot group ${item.chatId} is held: ${item.reason}\n`)
307
314
  }
315
+ // Groups hear about a version change once, as a notice card. A console
316
+ // upgrade to this version that cannot connect every group is rolled back
317
+ // by the switch process, so that start announces nothing and leaves the
318
+ // baseline as it was.
319
+ const version = require('../package.json').version
320
+ const switchJob = started.held?.length ? updater?.status().job : null
321
+ if (!(switchJob?.state === 'switching' && switchJob.to === version)) {
322
+ const announce = require('../src/upgradeannounce')
323
+ announce.announceUpgradeOnStart({ store, cfg, platform: liveTransport?.platform,
324
+ changelog: announce.readChangelog(), currentVersion: version,
325
+ heldGroups: (started.held || []).map(item => item.chatId),
326
+ log: message => process.stderr.write(`SintBot · ${message}\n`) })
327
+ }
308
328
  // A console upgrade that switched to this version is done once every
309
329
  // group is connected; then the daily version check starts.
310
330
  if (!started.held?.length) updater?.confirmStarted()
@@ -353,6 +373,7 @@ Usage:
353
373
  sintbot init [--config <absolute path>] [--data-dir <absolute path>]
354
374
  sintbot start [--config <absolute path>] [--data-dir <absolute path>] [--preflight] [--ui] [--ui-host <address>] [--ui-port <port>] [--no-open]
355
375
  sintbot incident seal --group <oc_…> [--config <absolute path>] [--data-dir <absolute path>] [--reason <text>]
376
+ sintbot feishu <action> [arguments…] [--chat <oc_…>] [--config <path>]
356
377
  sintbot version
357
378
  sintbot help
358
379
 
@@ -360,6 +381,8 @@ Supports explicit Feishu groups using Codex or Claude Code. Start runs in the fo
360
381
  Initialization is for a new installation only; existing journals are never reset.
361
382
  Console shows live groups, binds groups and switches models or projects of idle groups;
362
383
  in a group, /model lists or switches models. Unsupported actions stay disabled.
384
+ feishu sends to a group without the service (send-msg · send-notice · send-file · send-image ·
385
+ history · reply · edit · recall · download · react); sintbot feishu --help lists the arguments.
363
386
  Canvas, scheduled tasks, terminal chat and other platforms are unavailable.
364
387
  A group whose last result cannot be proven is held alone; the other groups keep serving.
365
388
  Restart first: a journal that can recover reopens on its own. If the group is still held,
@@ -387,10 +410,22 @@ async function main(argv = process.argv.slice(2)) {
387
410
  process.exitCode = outcome === 'done' ? 0 : 1
388
411
  return
389
412
  }
390
- if (!['init', 'start', 'incident'].includes(sub)) {
413
+ if (!['init', 'start', 'incident', 'feishu'].includes(sub)) {
391
414
  throw new Error(`${sub} is unavailable in this release; use sintbot help`)
392
415
  }
393
416
  if (process.platform !== 'darwin') throw new Error('this release supports macOS only')
417
+ if (sub === 'feishu') {
418
+ // The agent's group skills. Each call uses the app credentials directly;
419
+ // the service need not run. Text after the action may itself start with
420
+ // '-', so only the first two positions can ask for help.
421
+ const feishuCli = require('../src/feishucli')
422
+ if (rest.slice(0, 2).some(arg => arg === '--help' || arg === '-h')) {
423
+ process.stdout.write(feishuCli.feishuUsage('sintbot'))
424
+ return
425
+ }
426
+ await feishuCli.runFeishuAction(rest)
427
+ return
428
+ }
394
429
  if (sub === 'incident') {
395
430
  const [action, ...args] = rest
396
431
  if (rest.some(arg => arg === '--help' || arg === '-h')) { process.stdout.write(HELP); return }
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.6 with an AI assistant
1
+ # Install SintBot 1.4.8 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.6 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
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.
19
19
  ```
package/docs/install.md CHANGED
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.6
1
+ # Install SintBot 1.4.8
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.6` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.6`. No sudo is needed.
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.
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
 
@@ -107,7 +107,7 @@ Messages sent while a task runs are added to that task in Codex groups (`codexSt
107
107
 
108
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.
109
109
 
110
- 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. Upgrades are not announced in groups.
110
+ 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
111
 
112
112
  You can also add a group without the console: add it to the configuration, stop the service and run `start` again; the new group is registered automatically. Bound groups cannot be removed or renamed by editing the configuration.
113
113
 
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.6
1
+ # 用 AI 助手安装 SintBot 1.4.8
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.6。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.8。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.6
1
+ # 安装 SintBot 1.4.8
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.6` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.6` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.8` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.8` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -107,7 +107,7 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
107
107
 
108
108
  带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
109
109
 
110
- 「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级不在群里播报。
110
+ 「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级后首次启动会在每个在服务的群发一张版本公告卡,列出这一版的更新;不想播报就在「设置 → 软件更新」取消「升级后向群里播报」。
111
111
 
112
112
  不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
113
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.6
5
+ version=1.4.8
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.6",
3
+ "version": "1.4.8",
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": {
@@ -67,6 +67,7 @@
67
67
  "src/c2upgradeswitch.js",
68
68
  "src/cdp.js",
69
69
  "src/config.js",
70
+ "src/consolesay.js",
70
71
  "src/core/c2cardeffectbridge.js",
71
72
  "src/core/c2cardterminalpath.js",
72
73
  "src/core/c2durableingress.js",
@@ -84,6 +85,7 @@
84
85
  "src/core/c2runnerinput.js",
85
86
  "src/core/c2servicestartgate.js",
86
87
  "src/core/c2sessionbridge.js",
88
+ "src/core/c2sourceidentity.js",
87
89
  "src/core/c2sourcequarantine.js",
88
90
  "src/core/c2startuprecovery.js",
89
91
  "src/core/c2successorbridge.js",
@@ -107,11 +109,14 @@
107
109
  "src/core/v2engine.js",
108
110
  "src/core/verify.js",
109
111
  "src/design.js",
112
+ "src/feishucli.js",
110
113
  "src/gitrepo.js",
111
114
  "src/leftover.js",
112
115
  "src/platform/board.js",
113
116
  "src/platform/feishu.js",
114
117
  "src/platform/multi.js",
118
+ "src/platform/noticecard.js",
119
+ "src/platform/noticesend.js",
115
120
  "src/platform/slack.js",
116
121
  "src/platform/wecom.js",
117
122
  "src/platform/wecomcli.js",
@@ -133,6 +138,8 @@
133
138
  "src/ui/sessions.js",
134
139
  "src/ui/takeover.js",
135
140
  "src/updater.js",
141
+ "src/upgrade.js",
142
+ "src/upgradeannounce.js",
136
143
  "src/usage.js"
137
144
  ],
138
145
  "scripts": {
package/src/c2console.js CHANGED
@@ -4,6 +4,7 @@ const path = require('node:path')
4
4
  const { createUiServer } = require('./ui/serverbase')
5
5
  const { START_PATH, CALLBACK_PATH } = require('./ui/feishulogin')
6
6
  const { assertJournalScope } = require('./c2install')
7
+ const { createDurableConsoleSay } = require('./consolesay')
7
8
 
8
9
  // A host facade over existing v2 authorities, not a second execution engine.
9
10
  // The allowlist applies after the existing console's authentication boundary.
@@ -29,6 +30,10 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
29
30
  // one-off agent run); it does not take a group concurrency slot.
30
31
  '/api/test',
31
32
  '/api/inbox/dismiss', '/api/issues/status',
33
+ // Console injection, first version: an idle group only, the line shown in
34
+ // the group and the reply delivered there. The posted line is the platform
35
+ // message that drives the turn (see consolesay.createDurableConsoleSay).
36
+ '/api/groups/say',
32
37
  // Software updates: check and ignore anywhere; install only when launchd
33
38
  // manages this exact entry (the updater refuses every other run mode).
34
39
  '/api/update/check', '/api/update/start', '/api/update/ignore']
@@ -36,18 +41,26 @@ const SUPPORTED_RUNNERS = ['claude', 'codex']
36
41
 
37
42
  function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
38
43
  assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
39
- appliedResets = () => [], now = Date.now } = {}) {
44
+ appliedResets = () => [], now = Date.now,
45
+ // Console injection reaches a group only through the service's own ingress
46
+ // and that group's real platform adapter. Without them it is refused.
47
+ getPlatform = null, routeMessage = null, isServing = () => false, isFrozen = () => false,
48
+ log = () => {} } = {}) {
40
49
  for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
41
- validateConfig, applyConfig, beforeWrite, appliedResets, now]) {
50
+ validateConfig, applyConfig, beforeWrite, appliedResets, now, isServing, isFrozen, log]) {
42
51
  if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
43
52
  }
53
+ if ((getPlatform === null) !== (routeMessage === null) ||
54
+ getPlatform !== null && (typeof getPlatform !== 'function' || typeof routeMessage !== 'function')) {
55
+ throw new TypeError('C2 console injection needs both the group platform and the service ingress')
56
+ }
44
57
  if ([validateConfig, applyConfig, beforeWrite].some(callback => callback.constructor?.name === 'AsyncFunction')) {
45
58
  throw new TypeError('C2 console configuration hooks must be synchronous')
46
59
  }
47
60
  const startedAt = now()
48
61
  const capabilities = Object.freeze({ mode: 'durable-v2',
49
62
  reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
50
- summary: '实时状态、认证管理、停止任务、空闲群开新会话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。解除绑定、移除模型、控制台插话、定时任务和画布暂不可用。',
63
+ summary: '实时状态、认证管理、停止任务、空闲群开新会话、空闲群的控制台插话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话或插话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。插话会把这句话(标着「管理员」)和回复都发进群,群忙时不排队、也不加入当前任务;静默插话和不回复的插话暂不支持。解除绑定、移除模型、定时任务和画布暂不可用。',
51
64
  allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
52
65
  function held() {
53
66
  if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
@@ -84,6 +97,32 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
84
97
  binding.engine.store.resetSessionAndStats(chatId)
85
98
  return true
86
99
  }
100
+ // Console injection, first version: an open, idle group only. Checked
101
+ // before the line is posted, so a refused group shows nothing, and again
102
+ // right after; the service ingress remains the final authority.
103
+ function sayGate(chatId) {
104
+ let locked = false
105
+ try { locked = assertWriterLock() === true } catch {}
106
+ if (!locked || isServing() !== true) return 'not-serving'
107
+ if (getConfig().groups?.[chatId]?.paused) return 'paused'
108
+ if (getHeld().has(chatId)) return 'held'
109
+ if (isFrozen() === true) return 'upgrading'
110
+ const binding = bindings().get(chatId)
111
+ if (!binding || binding.runtime?.closed === true) return 'not-serving'
112
+ const rt = binding.engine?.rts?.get(chatId)
113
+ if (groupBusy(chatId, binding) || rt?.v2RunningJobs || rt?.v2StopControl || rt?.v2SkipWaiter ||
114
+ binding.journal?.snapshot()?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
115
+ return null
116
+ }
117
+ function receiptState(chatId, receiptId) {
118
+ const receipts = bindings().get(chatId)?.journal?.snapshot()?.receipts
119
+ if (receipts?.pending?.some(item => item.id === receiptId)) return 'pending'
120
+ if (receipts?.completed?.some(item => item.id === receiptId)) return 'completed'
121
+ return null
122
+ }
123
+ const say = getPlatform ? createDurableConsoleSay({ getPlatform,
124
+ getGroup: chatId => getConfig().groups?.[chatId] || null,
125
+ gate: sayGate, route: routeMessage, receiptState, log, now }) : null
87
126
  function getLiveState() {
88
127
  const cfg = getConfig()
89
128
  const capacity = getCapacity()
@@ -107,6 +146,8 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
107
146
  waitingForCapacity: !!rt?.v2AdmissionPending,
108
147
  sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
109
148
  sessionId: binding?.engine?.store?.getSession(chatId) || '',
149
+ // Why a console injection would be refused right now (null = accepted).
150
+ sayBlocked: say ? sayGate(chatId) : 'not-serving',
110
151
  // A held group needs the operator: its last result is unknown.
111
152
  ...(hold ? { held: true, heldReason: hold.reason, heldSinceMs: Math.max(0, now() - hold.since) } : {}),
112
153
  }
@@ -168,6 +209,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
168
209
  return binding.engine.stop(chatId)
169
210
  },
170
211
  clearSession,
212
+ // Same signature as the legacy daemon's say; the result says honestly
213
+ // whether the line drove a turn ({ ok, where }) or why it did not.
214
+ say(chatId, text, shown, quiet, options) {
215
+ return say ? say(chatId, text, shown, quiet, options)
216
+ : Promise.resolve({ ok: false, reason: 'no-daemon' })
217
+ },
171
218
  // A durable stop settles later, so a busy group is never reset here:
172
219
  // the operator stops it first and starts the new session once idle.
173
220
  interrupt(chatId) {
@@ -39,7 +39,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
39
39
  createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
40
40
  quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
41
41
  createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
42
- now = Date.now, log = () => {} } = {}) {
42
+ ingressWaitMs = 60_000, now = Date.now, log = () => {} } = {}) {
43
43
  if (!Array.isArray(routes) || !routes.length ||
44
44
  new Set(routes.map(route => route.chatId)).size !== routes.length ||
45
45
  routes.some(route => !route || typeof route.chatId !== 'string' ||
@@ -52,7 +52,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
52
52
  [...holds.keys()].some(chatId => !routes.some(route => route.chatId === chatId)) ||
53
53
  createRecoveryAuthority !== null && typeof createRecoveryAuthority !== 'function' ||
54
54
  !Number.isSafeInteger(stopTimeoutMs) || stopTimeoutMs < 1 ||
55
- !Number.isSafeInteger(drainTimeoutMs) || drainTimeoutMs < 1) {
55
+ !Number.isSafeInteger(drainTimeoutMs) || drainTimeoutMs < 1 ||
56
+ !Number.isSafeInteger(ingressWaitMs) || ingressWaitMs < 1) {
56
57
  throw new TypeError('C2 multi-group service needs explicit routes and controls')
57
58
  }
58
59
  const configured = new Map(routes.map(route => [route.chatId, route]))
@@ -248,7 +249,75 @@ function createC2MultiGroupService({ routes, assertWriterLock,
248
249
  opening.then(() => activating.delete(route.chatId), () => activating.delete(route.chatId))
249
250
  return opening
250
251
  }
252
+ // A reply or card operation awaiting its platform response blocks the
253
+ // durable authority only for that moment: blocked, but not closed.
254
+ const closedForGood = runtime => (runtime?.closed ?? runtime?.blocked) === true
255
+ const effectInFlight = runtime => runtime?.blocked === true && runtime?.closed === false
256
+ // Per-group ingress order. The platform runs every inbound event on its own
257
+ // listener and does not await this route, so messages of one group can reach
258
+ // it concurrently. A message that finds its group blocked only by an
259
+ // in-flight effect waits for that effect (polling, bounded) instead of being
260
+ // refused, and every later message of that group queues behind it in arrival
261
+ // order until it has been handed to admission or answered. Other groups
262
+ // never wait, and a group with nothing waiting takes the direct path.
263
+ // chatId -> { tail, waiting: msgId -> count, recalled: Set of msgId }
264
+ const INGRESS_POLL_MS = 25
265
+ const AWAIT_EFFECT = Symbol('await in-flight effect')
266
+ const ingress = new Map()
267
+ const pause = () => new Promise(resolve => setTimeout(resolve, INGRESS_POLL_MS))
251
268
  function routeMessage(msg, sourceAdapter) {
269
+ if (!ingress.has(msg?.chatId)) {
270
+ const outcome = admitMessage(msg, sourceAdapter, false)
271
+ if (outcome !== AWAIT_EFFECT) return outcome
272
+ return queueMessage(msg, sourceAdapter, true)
273
+ }
274
+ return queueMessage(msg, sourceAdapter, false)
275
+ }
276
+ function queueMessage(msg, sourceAdapter, evaluated) {
277
+ const { chatId, msgId } = msg
278
+ // The bound counts from arrival, including time spent behind earlier messages.
279
+ const deadline = performance.now() + ingressWaitMs
280
+ let lane = ingress.get(chatId)
281
+ if (!lane) {
282
+ lane = { tail: Promise.resolve(), waiting: new Map(), recalled: new Set() }
283
+ ingress.set(chatId, lane)
284
+ }
285
+ lane.waiting.set(msgId, (lane.waiting.get(msgId) || 0) + 1)
286
+ let release
287
+ const turn = new Promise(resolve => { release = resolve })
288
+ const prior = lane.tail
289
+ lane.tail = turn
290
+ const admitted = (async () => {
291
+ try {
292
+ await prior
293
+ // A box, so the next message does not wait for this one's turn to end.
294
+ return await awaitAdmission(msg, sourceAdapter, lane, evaluated, deadline)
295
+ } finally {
296
+ const left = lane.waiting.get(msgId) - 1
297
+ if (left > 0) lane.waiting.set(msgId, left)
298
+ else lane.waiting.delete(msgId)
299
+ if (lane.tail === turn && ingress.get(chatId) === lane) ingress.delete(chatId)
300
+ release()
301
+ }
302
+ })()
303
+ return admitted.then(box => box.outcome)
304
+ }
305
+ async function awaitAdmission(msg, sourceAdapter, lane, evaluated, deadline) {
306
+ if (evaluated) await pause()
307
+ for (;;) {
308
+ // Never admitted, so no receipt exists: a stop or a recall meanwhile
309
+ // only drops this message and is no fault.
310
+ if (!started || stopPromise) return { outcome: { ignored: true, reason: 'ingress-closed' } }
311
+ if (lane.recalled.has(msg.msgId)) {
312
+ log(`group ${msg.chatId}: message recalled before admission`)
313
+ return { outcome: { ignored: true, reason: 'recalled-before-admission' } }
314
+ }
315
+ const outcome = admitMessage(msg, sourceAdapter, performance.now() >= deadline)
316
+ if (outcome !== AWAIT_EFFECT) return { outcome }
317
+ await pause()
318
+ }
319
+ }
320
+ function admitMessage(msg, sourceAdapter, expired) {
252
321
  const route = configured.get(msg?.chatId)
253
322
  if (route && route.paused !== true && suspended.has(msg.chatId)) {
254
323
  // A held group admits nothing; a replay of a sealed source stays silent.
@@ -275,6 +344,13 @@ function createC2MultiGroupService({ routes, assertWriterLock,
275
344
  throw new Error('C2 group ingress authority closed')
276
345
  }
277
346
  if (binding.gate.blocked) {
347
+ if (effectInFlight(binding.runtime)) {
348
+ if (!expired) return AWAIT_EFFECT
349
+ // Still not settled after the bound: refuse as before, no fault.
350
+ log(`group ${msg.chatId}: message refused; an outbound effect stayed in flight`)
351
+ return answerC2InputRejection({ error: rejectC2Input('C2 group effect in flight',
352
+ { reason: 'effect-in-flight', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
353
+ }
278
354
  // A turn still running under a closed authority delivers its own
279
355
  // rejection; until then this message, which has no receipt, is
280
356
  // refused. An idle closed group is closed as its own fault here.
@@ -324,17 +400,45 @@ function createC2MultiGroupService({ routes, assertWriterLock,
324
400
  return { ignored: true, reason: `unsupported-${source}` }
325
401
  }
326
402
  }
327
- function recall(event) {
403
+ // A reply or card update awaiting its platform response blocks the durable
404
+ // authority only for that moment. The bot's own card deleted by /stop or
405
+ // /skip comes back as a recall exactly then, and a person may recall a
406
+ // queued message while its queue notice is still on its way. The recall
407
+ // waits for that effect to settle; only an authority closed for good after
408
+ // the recall is a fault. The platform does not await this result, so the
409
+ // wait never rejects.
410
+ const RECALL_WAIT_MS = 60_000
411
+ const RECALL_POLL_MS = 25
412
+ function recall(event, waitedMs = 0) {
328
413
  if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
329
414
  if (maintenance) return { ignored: true, reason: 'upgrading' }
330
415
  if (!allowed(event?.chatId)) return { ignored: true }
416
+ // A message still waiting for admission has no receipt yet; it is
417
+ // dropped there instead of being admitted after its own recall.
418
+ const lane = ingress.get(event.chatId)
419
+ if (lane?.waiting.has(event.msgId)) {
420
+ lane.recalled.add(event.msgId)
421
+ return { handled: true }
422
+ }
331
423
  let binding
332
424
  try {
333
425
  held()
334
426
  binding = bindings.get(event.chatId)
335
427
  if (!started || stopPromise || !binding) return { ignored: true, reason: 'ingress-closed' }
428
+ if (effectInFlight(binding.runtime)) {
429
+ if (waitedMs >= RECALL_WAIT_MS) {
430
+ log(`group ${event.chatId}: recall not applied; an outbound effect stayed in flight`)
431
+ return { ignored: true, reason: 'recall-effect-in-flight' }
432
+ }
433
+ return new Promise(resolve => setTimeout(resolve, RECALL_POLL_MS))
434
+ .then(() => recall(event, waitedMs + RECALL_POLL_MS))
435
+ .catch(error => {
436
+ log(`group ${event.chatId}: deferred recall failed: ${reasonOf(error)}`)
437
+ return { ignored: true, reason: 'recall-failed' }
438
+ })
439
+ }
336
440
  const handled = binding.engine.handleRecall?.(event) === true
337
- if (binding.runtime?.blocked) throw new Error('Recall closed its durable authority')
441
+ if (closedForGood(binding.runtime)) throw new Error('Recall closed its durable authority')
338
442
  return handled ? { handled: true } : { ignored: true, reason: 'unsupported-or-inactive-recall' }
339
443
  } catch (error) {
340
444
  if (!binding || !writerHeld() || !started || stopPromise) return fatal(error)
@@ -427,6 +531,9 @@ function createC2MultiGroupService({ routes, assertWriterLock,
427
531
  }
428
532
  return { start, stop, routeMessage, addRoute, heldGroups, beginMaintenance, endMaintenance,
429
533
  get inMaintenance() { return maintenance },
534
+ // True only while the transport serves: the console may inject then and
535
+ // never during startup or a stop, when routing would close the service.
536
+ get serving() { return started && !starting && !stopPromise && !failed },
430
537
  get bindings() { return new Map(bindings) },
431
538
  get held() { return new Map([...suspended].map(([chatId, entry]) =>
432
539
  [chatId, { reason: entry.reason, source: entry.source, since: entry.since }])) } }
package/src/c2updater.js CHANGED
@@ -162,11 +162,24 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
162
162
  installVersion = defaultInstallVersion, verifyInstall = defaultVerifyInstall,
163
163
  spawnSwitch = defaultSpawnSwitch, hashJournals = journalHashes, isAlive = alive,
164
164
  now = Date.now, waitMs = 5_000, queueTimeoutMs = QUEUE_TIMEOUT_MS,
165
- checkIntervalMs = CHECK_INTERVAL_MS, autoCheck = true, log = () => {} } = {}) {
165
+ checkIntervalMs = CHECK_INTERVAL_MS, autoCheck = true, setAnnouncementBaseline = null,
166
+ log = () => {} } = {}) {
166
167
  if (typeof currentVersion !== 'string' || !runMode || typeof runMode.mode !== 'string' ||
167
168
  typeof stateFile !== 'string' || !path.isAbsolute(stateFile) || typeof gate !== 'function') {
168
169
  throw new TypeError('C2 updater needs the version, run mode, state file and idle gate')
169
170
  }
171
+ if (setAnnouncementBaseline !== null && typeof setAnnouncementBaseline !== 'function') {
172
+ throw new TypeError('C2 updater announcement baseline must be a function')
173
+ }
174
+ // The new version announces itself in the groups on its first start; an
175
+ // upgrade started without announcement records the target version as the
176
+ // baseline first, so that start finds nothing new.
177
+ function baseline(version) {
178
+ try { setAnnouncementBaseline(version); return true } catch (error) {
179
+ log(`update announcement baseline: ${error?.message || String(error)}`)
180
+ return false
181
+ }
182
+ }
170
183
  const current = stableVersion(currentVersion)
171
184
  let data = readJSON(stateFile)
172
185
  let timer = null
@@ -205,7 +218,7 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
205
218
  currentVersion, latestVersion: latest, checkedAt: Number(data.checkedAt) || null,
206
219
  source: data.source || '', ignoredVersion: data.ignoredVersion || '',
207
220
  available: !!current && !!latest && compareVersion(latest, current) > 0 && data.ignoredVersion !== latest,
208
- mode: runMode.mode, upgradeSupported, announceSupported: false,
221
+ mode: runMode.mode, upgradeSupported, announceSupported: !!setAnnouncementBaseline,
209
222
  job: data.job ? { ...data.job } : null,
210
223
  }
211
224
  }
@@ -243,7 +256,7 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
243
256
  return status()
244
257
  }
245
258
 
246
- function startUpgrade({ version } = {}) {
259
+ function startUpgrade({ version, announce = true } = {}) {
247
260
  refresh()
248
261
  if (runMode.mode !== 'managed') {
249
262
  throw new Error(runMode.mode === 'dev' ? '开发版请用部署脚本升级' : '前台运行请按卡片上的步骤升级')
@@ -256,6 +269,7 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
256
269
  if (gate().includes('held')) throw new Error('有暂停的群,先处理后再升级')
257
270
  data.job = { id: `${now()}-${Math.random().toString(36).slice(2, 8)}`, state: 'queued',
258
271
  from: current, to: v, registry: data.registry || registries[0].registry,
272
+ announce: announce !== false,
259
273
  createdAt: now(), updatedAt: now(), message: '等所有群空闲、记录结清后升级' }
260
274
  persist()
261
275
  schedule(0)
@@ -300,6 +314,7 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
300
314
  if (settled.length) { endMaintenance(); schedule(); return status() }
301
315
  let hashes
302
316
  try { hashes = hashJournals(dataDir) } catch (error) { endMaintenance(); return fail(`无法读取群记录:${error.message}`) }
317
+ const silent = job.announce === false && !!setAnnouncementBaseline && baseline(job.to)
303
318
  setJob({ state: 'switching', oldEntry: runMode.entry, newEntry: entryOf(prefix), label: runMode.label,
304
319
  plist: runMode.plist, logFile: runMode.logFile, dataDir, configPath,
305
320
  expectedGroups: expectedGroups(), journals: hashes, servicePid: process.pid,
@@ -309,6 +324,8 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
309
324
  const pid = spawnSwitch({ entry: runMode.entry, stateFile })
310
325
  setJob({ helperPid: pid })
311
326
  } catch (error) {
327
+ // This version keeps running: its own start recorded it as the baseline.
328
+ if (silent) baseline(currentVersion)
312
329
  endMaintenance()
313
330
  return fail(`切换进程没有启动:${error.message}`)
314
331
  }