sintbot 1.4.7 → 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 +23 -0
- package/CHANGELOG.json +23 -0
- package/bin/sintbot-v2.js +36 -1
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +3 -3
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +3 -3
- package/install.sh +1 -1
- package/package.json +8 -1
- package/src/c2console.js +50 -3
- package/src/c2multigroupservice.js +88 -4
- package/src/c2updater.js +20 -3
- package/src/consolesay.js +229 -0
- package/src/core/c2sourceidentity.js +167 -0
- package/src/core/v2engine.js +41 -3
- package/src/feishucli.js +211 -0
- package/src/platform/noticecard.js +104 -0
- package/src/platform/noticesend.js +47 -0
- package/src/ui/console.html +52 -13
- package/src/ui/serverbase.js +19 -2
- package/src/upgrade.js +58 -0
- package/src/upgradeannounce.js +62 -0
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
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
|
+
},
|
|
2
25
|
{
|
|
3
26
|
"version": "1.4.7",
|
|
4
27
|
"date": "2026-11-09",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
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
|
+
},
|
|
2
25
|
{
|
|
3
26
|
"version": "1.4.7",
|
|
4
27
|
"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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
18
|
+
按一行安装指南,在这台 Mac 安装 SintBot 1.4.8。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
|
|
19
19
|
```
|
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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: '
|
|
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.
|
|
@@ -333,12 +409,17 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
333
409
|
// wait never rejects.
|
|
334
410
|
const RECALL_WAIT_MS = 60_000
|
|
335
411
|
const RECALL_POLL_MS = 25
|
|
336
|
-
const closedForGood = runtime => (runtime?.closed ?? runtime?.blocked) === true
|
|
337
|
-
const effectInFlight = runtime => runtime?.blocked === true && runtime?.closed === false
|
|
338
412
|
function recall(event, waitedMs = 0) {
|
|
339
413
|
if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
|
|
340
414
|
if (maintenance) return { ignored: true, reason: 'upgrading' }
|
|
341
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
|
+
}
|
|
342
423
|
let binding
|
|
343
424
|
try {
|
|
344
425
|
held()
|
|
@@ -450,6 +531,9 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
450
531
|
}
|
|
451
532
|
return { start, stop, routeMessage, addRoute, heldGroups, beginMaintenance, endMaintenance,
|
|
452
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 },
|
|
453
537
|
get bindings() { return new Map(bindings) },
|
|
454
538
|
get held() { return new Map([...suspended].map(([chatId, entry]) =>
|
|
455
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,
|
|
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:
|
|
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
|
}
|