sintbot 1.0.15 → 1.3.0

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.
Files changed (136) hide show
  1. package/CHANGELOG.en.json +118 -0
  2. package/CHANGELOG.json +118 -0
  3. package/README.md +20 -156
  4. package/README.zh-CN.md +20 -164
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +19 -0
  8. package/docs/install.md +93 -0
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
  10. package/docs/zh-CN/install.md +93 -0
  11. package/install.sh +84 -0
  12. package/package.json +74 -34
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4711
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/clilang.js +0 -31
  93. package/src/core/cron.js +0 -182
  94. package/src/core/cronfire.js +0 -120
  95. package/src/core/cronrun.js +0 -116
  96. package/src/core/engine.js +0 -2762
  97. package/src/core/interfaces.js +0 -114
  98. package/src/core/issues.js +0 -185
  99. package/src/core/ledger.js +0 -30
  100. package/src/core/opsalert.js +0 -214
  101. package/src/core/pending.js +0 -123
  102. package/src/core/proxy.js +0 -424
  103. package/src/core/quotaalert.js +0 -79
  104. package/src/core/quotaerr.js +0 -55
  105. package/src/core/scheduler.js +0 -59
  106. package/src/design.js +0 -5161
  107. package/src/gitrepo.js +0 -150
  108. package/src/health.js +0 -353
  109. package/src/installdeps.js +0 -72
  110. package/src/leftover.js +0 -174
  111. package/src/platform/boardnotice.js +0 -77
  112. package/src/platform/boardops.js +0 -36
  113. package/src/platform/boardwatch.js +0 -447
  114. package/src/platform/cli.js +0 -45
  115. package/src/platform/slack.js +0 -1289
  116. package/src/platform/takeovercard.js +0 -37
  117. package/src/platform/wecom.js +0 -938
  118. package/src/platform/wecomcli.js +0 -128
  119. package/src/platform/wecomgate.js +0 -108
  120. package/src/platform/wecomname.js +0 -43
  121. package/src/platform/wecompoll.js +0 -241
  122. package/src/preview.js +0 -581
  123. package/src/projectmemory.js +0 -175
  124. package/src/service.js +0 -317
  125. package/src/ui/auth.js +0 -31
  126. package/src/ui/browser.html +0 -392
  127. package/src/ui/browserroutes.js +0 -234
  128. package/src/ui/console.html +0 -5494
  129. package/src/ui/dashboard.js +0 -168
  130. package/src/ui/feishulogin.js +0 -136
  131. package/src/ui/server.js +0 -2314
  132. package/src/ui/sessions.js +0 -78
  133. package/src/ui/takeover.js +0 -108
  134. package/src/updater.js +0 -243
  135. package/src/upgrade.js +0 -35
  136. package/src/usage.js +0 -144
package/install.sh ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # SintBot 1.3.0: isolated macOS package install. Does not change services/config.
3
+ set -u
4
+ VERSION=1.3.0
5
+ PREFIX="${HOME}/.local/share/sintbot/releases/${VERSION}"
6
+ PACKAGE="sintbot@${VERSION}"
7
+ DRY=0
8
+ STAGE=''
9
+ LOCK=''
10
+ fail() { printf '%s\n' "SintBot: $*" >&2; exit 1; }
11
+ cleanup() {
12
+ if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi
13
+ if [ -n "$LOCK" ] && [ -d "$LOCK" ]; then rmdir -- "$LOCK" 2>/dev/null || true; fi
14
+ }
15
+ trap cleanup EXIT
16
+ trap 'exit 130' INT
17
+ trap 'exit 143' TERM
18
+ while [ "$#" -gt 0 ]; do
19
+ case "$1" in
20
+ --dry-run) DRY=1; shift ;;
21
+ --prefix|--package)
22
+ [ "$#" -ge 2 ] || fail "$1 needs a value"
23
+ if [ "$1" = --prefix ]; then PREFIX="$2"; else PACKAGE="$2"; fi
24
+ shift 2 ;;
25
+ --help|-h)
26
+ printf '%s\n' 'SintBot 1.3.0 — macOS / one Feishu group / Codex' \
27
+ 'Usage: bash install.sh [--dry-run] [--prefix /new/absolute/directory]' \
28
+ ' [--package /absolute/sintbot-1.3.0.tgz]' \
29
+ 'Installs to a new private prefix. Does not replace an existing global install,' \
30
+ 'change configuration, authenticate Codex, start a bot or install a service.'
31
+ exit 0 ;;
32
+ *) fail "Unknown argument: $1" ;;
33
+ esac
34
+ done
35
+ [ "$(uname -s)" = Darwin ] || fail 'This release supports macOS only.'
36
+ command -v node >/dev/null 2>&1 || fail 'Install Node.js 22 or newer, then run this installer again.'
37
+ command -v npm >/dev/null 2>&1 || fail 'npm is required.'
38
+ node -e 'if (Number(process.versions.node.split(".")[0]) < 22) process.exit(1)' || fail 'Node.js 22 or newer is required.'
39
+ case "$PREFIX" in /*) ;; *) fail '--prefix must be an absolute path.' ;; esac
40
+ PREFIX="$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' "$PREFIX")" || fail 'Invalid prefix.'
41
+ [ "$PREFIX" != / ] || fail 'The filesystem root is not an installation prefix.'
42
+ [ ! -e "$PREFIX" ] && [ ! -L "$PREFIX" ] || fail 'The target already exists; use a new prefix. Existing installations are never overwritten.'
43
+ if [ "$PACKAGE" != "sintbot@${VERSION}" ]; then
44
+ case "$PACKAGE" in /*.tgz) ;; *) fail '--package must name an absolute local .tgz file.' ;; esac
45
+ [ -f "$PACKAGE" ] || fail 'The local package does not exist.'
46
+ fi
47
+ printf '%s\n' "SintBot ${VERSION}: isolated install into ${PREFIX}" 'Scope: macOS, one Feishu group, Codex messages; foreground operation.'
48
+ if [ "$DRY" = 1 ]; then
49
+ printf '%s\n' "Would install ${PACKAGE}, verify version/binary/platform loading, then show setup instructions." 'Preview complete. No changes were made.'
50
+ exit 0
51
+ fi
52
+ PARENT="$(dirname "$PREFIX")"
53
+ mkdir -p -- "$PARENT" || fail 'Cannot create the installation parent.'
54
+ LOCK="${PREFIX}.install-lock"
55
+ if ! mkdir -- "$LOCK" 2>/dev/null; then LOCK=''; fail 'Another install or a stale install lock exists; inspect it before retrying.'; fi
56
+ [ ! -e "$PREFIX" ] && [ ! -L "$PREFIX" ] || fail 'The target appeared during installation; refusing to overwrite it.'
57
+ STAGE="$(mktemp -d "${PARENT}/.sintbot-${VERSION}.XXXXXX")" || fail 'Cannot create staging directory.'
58
+ if ! npm install --registry=https://registry.npmjs.org --global --prefix "$STAGE" --omit=dev --ignore-scripts --no-audit --no-fund "$PACKAGE"; then
59
+ fail 'Package installation failed; the previous installation was not changed.'
60
+ fi
61
+ if ! node - "$STAGE/lib/node_modules/sintbot" "$VERSION" <<'NODE'
62
+ const path = require('node:path')
63
+ const root = process.argv[2]
64
+ const pkg = require(path.join(root, 'package.json'))
65
+ if (pkg.name !== 'sintbot' || pkg.version !== process.argv[3]) throw new Error('Package version mismatch')
66
+ if (pkg.bin?.sintbot !== 'bin/sintbot-v2.js') throw new Error('Package does not use the supported 1.3.0 entry')
67
+ require(path.join(root, 'src/agent/privatecore.js'))
68
+ require(path.join(root, 'src/platform/feishu.js'))
69
+ NODE
70
+ then fail 'Installed package failed version, entry or runtime verification.'; fi
71
+ ACTUAL="$("$STAGE/bin/sintbot" --version)" || fail 'Installed executable did not start.'
72
+ [ "$ACTUAL" = "$VERSION" ] || fail 'Installed executable reported an unexpected version.'
73
+ [ ! -e "$PREFIX" ] && [ ! -L "$PREFIX" ] || fail 'The target appeared during installation; refusing to overwrite it.'
74
+ if ! mv -- "$STAGE" "$PREFIX"; then fail 'Cannot finalize the isolated install.'; fi
75
+ STAGE=''
76
+ FINAL_VERSION="$("$PREFIX/bin/sintbot" --version)" || fail 'Final executable failed after relocation; inspect the new prefix before retrying.'
77
+ [ "$FINAL_VERSION" = "$VERSION" ] || fail 'Final executable version mismatch.'
78
+ printf '\n%s\n' "Installed and verified SintBot ${VERSION}. Existing services and configuration were not changed." \
79
+ 'Finish the one-group configuration and first run using:' \
80
+ 'https://github.com/sintlab/sintbot/blob/v1.3.0/docs/install.md' \
81
+ 'Use this exact executable for the new installation:'
82
+ printf ' %q\n' "$PREFIX/bin/sintbot"
83
+ printf '%s\n' 'An existing multi-group installation must stay on its current version.' \
84
+ 'Do not delete old state or receipts to make initialization succeed.'
package/package.json CHANGED
@@ -1,52 +1,93 @@
1
1
  {
2
2
  "name": "sintbot",
3
- "version": "1.0.15",
4
- "description": "Drive headless Claude Code (or Codex) from Slack / Feishu / WeCom group chats — one group, one project, swappable model brain. A standalone daemon, not a Claude Code plugin.",
3
+ "version": "1.3.0",
4
+ "private": false,
5
+ "description": "A built-in agent core for one Feishu group on macOS, powered by Codex.",
5
6
  "bin": {
6
- "sbot": "bin/sbot.js",
7
- "sintbot": "bin/sbot.js"
7
+ "sbot": "bin/sintbot-v2.js",
8
+ "sintbot": "bin/sintbot-v2.js"
8
9
  },
10
+ "type": "commonjs",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "os": [
15
+ "darwin"
16
+ ],
9
17
  "files": [
10
- "bin/",
11
- "src/",
12
- "assets/",
13
- "CHANGELOG.json",
14
18
  "CHANGELOG.en.json",
15
- "sbot.config.example.json",
19
+ "CHANGELOG.json",
20
+ "LICENSE",
16
21
  "README.md",
17
22
  "README.zh-CN.md",
18
- "LICENSE",
19
23
  "THIRD-PARTY-NOTICES.md",
20
- "docs/slack-app-manifest.yaml"
24
+ "bin/sintbot-v2.js",
25
+ "docs/AI-ASSISTED-INSTALL.md",
26
+ "docs/install.md",
27
+ "docs/zh-CN/AI-ASSISTED-INSTALL.md",
28
+ "docs/zh-CN/install.md",
29
+ "install.sh",
30
+ "src/agent/claudecode.js",
31
+ "src/agent/codexapp.js",
32
+ "src/agent/codexcode.js",
33
+ "src/agent/errtag.js",
34
+ "src/agent/privatecore.js",
35
+ "src/agent/privatecore.wasm",
36
+ "src/agent/runner.js",
37
+ "src/agent/visionproxy.js",
38
+ "src/agent/winexec.js",
39
+ "src/c2install.js",
40
+ "src/config.js",
41
+ "src/core/c2cardeffectbridge.js",
42
+ "src/core/c2cardterminalpath.js",
43
+ "src/core/c2durableingress.js",
44
+ "src/core/c2durablejournal.js",
45
+ "src/core/c2durableruntime.js",
46
+ "src/core/c2eventidentity.js",
47
+ "src/core/c2globalfault.js",
48
+ "src/core/c2localeffectexecutor.js",
49
+ "src/core/c2payloadhandles.js",
50
+ "src/core/c2persistrelation.js",
51
+ "src/core/c2persiststate.js",
52
+ "src/core/c2platformrecoverysender.js",
53
+ "src/core/c2recoverygate.js",
54
+ "src/core/c2runnerinput.js",
55
+ "src/core/c2servicestartgate.js",
56
+ "src/core/c2sessionbridge.js",
57
+ "src/core/c2sourcequarantine.js",
58
+ "src/core/c2startuprecovery.js",
59
+ "src/core/c2successorbridge.js",
60
+ "src/core/c2terminaleffectbridge.js",
61
+ "src/core/c2terminalprune.js",
62
+ "src/core/c2turnprojection.js",
63
+ "src/core/datadir.js",
64
+ "src/core/enginecheckpoint.js",
65
+ "src/core/engineeffectcursor.js",
66
+ "src/core/enginestepcoordinator.js",
67
+ "src/core/instancelock.js",
68
+ "src/core/mdtable.js",
69
+ "src/core/redact.js",
70
+ "src/core/store.js",
71
+ "src/core/time.js",
72
+ "src/core/v2engine.js",
73
+ "src/core/verify.js",
74
+ "src/platform/board.js",
75
+ "src/platform/feishu.js",
76
+ "src/platform/multi.js",
77
+ "src/platform/wswatchdog.js",
78
+ "src/plugins.js",
79
+ "src/projectsroot.js",
80
+ "src/setupmodels.js"
21
81
  ],
22
- "type": "commonjs",
23
- "engines": {
24
- "node": ">=22"
25
- },
26
82
  "scripts": {
27
- "start": "node bin/sbot.js",
28
- "test": "node --test",
29
- "prepublishOnly": "npm test",
30
- "test:redact": "node --test tests/unit/redact.test.js",
31
- "test:store": "node --test tests/unit/store.test.js",
32
- "test:config": "node --test tests/unit/config.test.js",
33
- "test:engine": "node --test tests/unit/engine.test.js",
34
- "test:agent": "node --test tests/unit/agent.test.js",
35
- "test:health": "node --test tests/unit/health.test.js",
36
- "test:visionproxy": "node --test tests/unit/visionproxy.test.js"
83
+ "start": "node bin/sintbot-v2.js start"
37
84
  },
38
85
  "keywords": [
39
- "claude-code",
40
86
  "codex",
41
87
  "feishu",
42
- "slack",
43
- "wecom",
44
88
  "group-chat",
45
89
  "chatops",
46
- "ai-agent",
47
- "deepseek",
48
- "self-hosted",
49
- "daemon"
90
+ "self-hosted"
50
91
  ],
51
92
  "author": "WaterTian",
52
93
  "license": "SEE LICENSE IN LICENSE",
@@ -59,7 +100,6 @@
59
100
  "url": "https://github.com/sintlab/sintbot/issues"
60
101
  },
61
102
  "dependencies": {
62
- "@larksuiteoapi/node-sdk": "^1.76.0",
63
- "ws": "^8.21.3"
103
+ "@larksuiteoapi/node-sdk": "^1.76.0"
64
104
  }
65
105
  }
@@ -14,17 +14,21 @@
14
14
  // See DESIGN.md §1 (process model) / §4.1 (permissions).
15
15
 
16
16
  const { spawn } = require('node:child_process')
17
+ const { StringDecoder } = require('node:string_decoder')
17
18
  const { ensureProxy, proxyBaseFor } = require('./visionproxy')
18
19
 
19
20
  // Always-on 群项目基础能力: auto-allowed regardless of preset (even read-only) so the
20
21
  // self-knowledge manifest's advertised base skills actually run. Both CLIs fence to the
21
22
  // group's own chat ($SBOT_CHAT_ID); feishu also fences paths to cwd. Additive; deny wins.
22
- const BASE_SKILL_ALLOWS = ['Bash(sbot feishu *)', 'Bash(sbot wecom *)', 'Bash(sbot cron *)', 'Bash(sbot imagen *)', 'Bash(sbot design *)', 'Bash(sbot preview *)']
23
+ const { claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent } = require('./privatecore')
24
+ const { coreFailedResult } = require('./errtag')
25
+ const BASE_SKILL_ALLOWS = claudeBaseSkillAllows()
23
26
  // Windows executable resolution (npm .cmd shim vs native .exe) is shared with the
24
27
  // codex runner — see winexec.js for the why (ENOENT on .cmd, never the POSIX shim).
25
28
  const { IS_WIN, resolveSpawn } = require('./winexec')
26
- const { classifyTransient } = require('./transienterr') // 瞬时错(过载 / 限流)判据,与 codex runner 共用
27
- const { classifyQuota } = require('./quotaerr') // 额度 / 账号类判据(切备用模型的依据),与 codex runner 共用
29
+ const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 codex runner 共用
30
+ const closedRunnerResults = new WeakSet()
31
+ const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
28
32
 
29
33
  // Build the child env from a resolved provider. `baseUrlOverride` (when given)
30
34
  // replaces provider.baseUrl for ANTHROPIC_BASE_URL — used to point `claude` at
@@ -66,72 +70,26 @@ function buildEnv(provider = {}, baseUrlOverride, extraEnv) {
66
70
 
67
71
  // Map a raw `claude` stream-json line to a normalized AgentEvent (interfaces.js).
68
72
  function normalizeEvent(ev) {
69
- switch (ev.type) {
70
- case 'system': {
71
- const r = { type: 'system', subtype: ev.subtype, sessionId: ev.session_id }
72
- // 压缩事件带的量(v0.11.93,2.1.260 真机核对):会话文件里是 `compactMetadata.preTokens`,SDK 输出层
73
- // 叫 `compact_metadata.pre_tokens` —— 二进制里两种拼法都在,两种都认。引擎拿它报「此前上下文约 X」:
74
- // 此前用上一条 assistant 的 usage 顶替,而读进来的工具结果还没进任何 usage,真机少报 5 倍
75
- // (报 3.6 万,实际 187836)。没有就不编数,引擎自会退回旧口径。
76
- const meta = ev.compact_metadata || ev.compactMetadata
77
- if (meta && typeof meta === 'object') {
78
- const pre = Number(meta.pre_tokens != null ? meta.pre_tokens : meta.preTokens)
79
- const post = Number(meta.post_tokens != null ? meta.post_tokens : meta.postTokens)
80
- if (pre > 0) r.preTokens = pre
81
- if (post > 0) r.postTokens = post
82
- }
83
- return r
84
- }
85
-
86
- case 'assistant': {
87
- const blocks = (ev.message && ev.message.content) || []
88
- return {
89
- type: 'assistant',
90
- text: blocks.filter((b) => b.type === 'text').map((b) => b.text).join(''),
91
- thinking: blocks.filter((b) => b.type === 'thinking').map((b) => b.thinking).join(''),
92
- tools: blocks.filter((b) => b.type === 'tool_use').map((b) => ({ name: b.name, input: b.input })),
93
- sessionId: ev.session_id,
94
- }
95
- }
96
-
97
- case 'user': {
98
- const blocks = (ev.message && ev.message.content) || []
99
- return {
100
- type: 'tool_result',
101
- results: blocks.filter((b) => b.type === 'tool_result'),
102
- sessionId: ev.session_id,
103
- }
104
- }
105
-
106
- case 'result': {
107
- // ⚠ 出错时 CLI 的原因多半**不在 result 里**而在 errors[](error_during_execution / error_max_turns 的真实
108
- // 形状,2026-09-08 审查从 2.1.260 二进制核对);API 过载(529)那种则是 subtype success + is_error +
109
- // result 带「API Error: 529 …」。两种都得让账本与瞬时错判据看见 —— 只看 result,前一种原因永远是空。
110
- const errs = ev.is_error && Array.isArray(ev.errors)
111
- ? ev.errors.map((x) => (typeof x === 'string' ? x : (x && x.message) || JSON.stringify(x))).filter(Boolean)
112
- : []
113
- const text = ev.result != null && ev.result !== '' ? ev.result : (errs.length ? errs.join('; ') : ev.result)
114
- const r = {
115
- type: 'result',
116
- subtype: ev.subtype,
117
- text,
118
- isError: ev.is_error,
119
- usage: ev.usage,
120
- sessionId: ev.session_id,
121
- }
122
- // 瞬时错(服务端过载 / 限流)给群面一句人话;text 仍是原话,留给账本(判据在 transienterr,两 runner 共用)
123
- if (ev.is_error) { const t = classifyTransient(text); if (t) r.transient = t }
124
- // 额度 / 账号类(用完了 · 账号被停 · 登录态刷新失败)标 quota:引擎据此自动切到备用模型(判据在 agent/quotaerr,两 runner 共用)
125
- if (ev.is_error) { const q = classifyQuota(text); if (q) r.quota = q }
126
- // 续接的会话在 CLI 那边已经没了(典型:Claude Code 默认清掉 30 天没动过的会话记录)。引擎据此换新会话重做,
127
- // 否则这个群之后每条消息都带着同一个死 id 去续接、条条报错,直到有人 /new。
128
- if (ev.is_error && /No conversation found with session ID/i.test(String(text || ''))) r.sessionGone = true
129
- return r
73
+ const result = claudeNormalizeEvent(ev)
74
+ // JSON cannot carry `undefined`; retain the frozen JS object's optional
75
+ // property shape at the public runner boundary.
76
+ if (ev.type === 'system') { result.subtype = ev.subtype; result.sessionId = ev.session_id }
77
+ else if (ev.type === 'assistant' || ev.type === 'user') {
78
+ result.sessionId = ev.session_id
79
+ if (ev.type === 'assistant') for (const tool of result.tools) {
80
+ if (!Object.hasOwn(tool, 'name')) tool.name = undefined
81
+ if (!Object.hasOwn(tool, 'input')) tool.input = undefined
130
82
  }
131
-
132
- default:
133
- return { type: ev.type, raw: ev }
134
83
  }
84
+ else if (ev.type === 'result') {
85
+ result.subtype = ev.subtype
86
+ result.isError = ev.is_error
87
+ result.usage = ev.usage
88
+ result.sessionId = ev.session_id
89
+ if (!Object.hasOwn(result, 'text')) result.text = undefined
90
+ if (ev.is_error) tagError(result, result.text)
91
+ }
92
+ return result
135
93
  }
136
94
 
137
95
  // key 型模型认证失败时造的那条出错 result(形状同 normalizeEvent 的 result)。quota 直接标 account:
@@ -168,6 +126,8 @@ function runTurn({
168
126
  extraEnv,
169
127
  appendSystemPrompt,
170
128
  onEvent,
129
+ onDurableEvent,
130
+ onTurnAccepted,
171
131
  signal,
172
132
  log = () => {}, // daemon logger — 与 codex runner 同一个口子(engine 两边都传 this.log)
173
133
  spawnFn = spawn, // 可注入,供单测驱动 spawn/解析环路(与 codexcode 同一形状)
@@ -180,6 +140,7 @@ function runTurn({
180
140
  } = {}) {
181
141
  return new Promise((resolve, reject) => {
182
142
  if (!prompt) return reject(new Error('runTurn: prompt required'))
143
+ if (onDurableEvent !== undefined && typeof onDurableEvent !== 'function') return reject(new TypeError('onDurableEvent must be a function'))
183
144
 
184
145
  // CLI 能力探测必须先于 spawn(见 supportsFlag)。结果按 bin 缓存 ⇒ 只有进程内第一轮真探一次
185
146
  // (`--help` 毫秒级);不需要压缩的群连探都不探。
@@ -213,19 +174,13 @@ function runTurn({
213
174
  // to start) can resolve a base-URL override first. Closes over resolve/reject.
214
175
  function launch(baseUrlOverride) {
215
176
  // Prompt travels via stdin (see spawn below), so it's absent from argv here.
216
- const args = ['-p', '--output-format', 'stream-json', '--verbose']
217
- const m = model || provider.model
218
- if (m) args.push('--model', m)
219
- if (sessionId) args.push('--resume', sessionId)
220
- if (permissionMode && permissionMode !== 'default') args.push('--permission-mode', permissionMode)
221
177
  // 长会话上下文上限:超过即让 CLI 原地压缩(--autocompact)。不传 ⇒ CLI 只在模型窗口边缘
222
178
  // 才压 —— 多天群会话实测涨到 96 万 token 都没压过一次。
223
179
  // ⚠ 旗子先经能力探测(见 supportsFlag):老 CLI 不认它,传了**每轮必崩**。探不出来也不传 ——
224
180
  // 降级(压缩不生效)好过整群跑不动。但**降级必须出声**:静默失效正是最难查的那类(教训 26),
225
181
  // 所以这里打日志、doctor 另有一行如实标。
226
- if (autocompactTokens > 0) {
227
- if (autocompactSupported === true) args.push('--autocompact', String(autocompactTokens))
228
- else if (onEvent) {
182
+ if (autocompactTokens > 0 && autocompactSupported !== true) {
183
+ if (onEvent) {
229
184
  try {
230
185
  onEvent({ type: 'log', message: autocompactSupported === false
231
186
  ? 'claude CLI 不认 --autocompact(版本偏低)⇒ 长会话自动压缩已停用;升级 claude 即可恢复'
@@ -240,34 +195,23 @@ function runTurn({
240
195
  // merged here without touching the group's saved allow list; deny still wins.
241
196
  // Groups without a plugins config keep the traditional self-loading — zero regression.
242
197
  // Config travels as a file, not inline JSON argv (Windows cmd.exe shim mangles quotes).
198
+ let mcpConfigPath, pluginAllows = []
243
199
  if (plugins && workdir) {
244
- const { buildMcpConfig, pluginAllowRules, writeMcpConfigFile } = require('../plugins')
200
+ const { buildMcpConfig, writeMcpConfigFile } = require('../plugins')
245
201
  // 传 log:跳过死 MCP 的那一行要走 daemon 带时间戳的 logger(v0.11.61 口径),不能退成裸 stderr
246
202
  const mcpCfg = buildMcpConfig(workdir, plugins.mcp || [], undefined, { log })
247
- args.push('--mcp-config', writeMcpConfigFile(workdir, mcpCfg), '--strict-mcp-config')
248
- const extra = pluginAllowRules(mcpCfg, plugins.skills || [])
249
- if (extra.length) allowedTools = [...(allowedTools || []), ...extra]
203
+ mcpConfigPath = writeMcpConfigFile(workdir, mcpCfg)
204
+ pluginAllows = claudePluginAllowRules(Object.keys(mcpCfg.mcpServers || {}), plugins.skills || [])
250
205
  }
251
- // Base skills (always-on, 群项目基础能力): auto-allow the platform skills + `sbot cron` … so a
252
- // read-only/whitelist group's agent can still use the skills its manifest advertises.
253
- // (Codex ignores this — no allow/deny; its shell needs danger-full-access instead.)
254
- allowedTools = [...(allowedTools || []), ...BASE_SKILL_ALLOWS]
255
- // Policy-driven headless permission (DESIGN §4.1): a confined allow/deny list
256
- // + `--permission-mode dontAsk` means out-of-policy tools auto-deny and never
257
- // hang. Enforced by the CLI harness, so it's identical across model backends.
258
- if (allowedTools && allowedTools.length) args.push('--allowedTools', allowedTools.join(','))
259
- if (disallowedTools && disallowedTools.length) args.push('--disallowedTools', disallowedTools.join(','))
260
- // Extra system prompt (engine injects the NO_REPLY/sender-tag contract).
261
- // Travels as argv: keep callers' text ASCII-safe so
262
- // the Windows cmd.exe shim path can't mangle it (user prompt stays on stdin).
263
- if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt)
264
- // Directory scope: cwd = the project's own workdir, and the ONLY --add-dir is that
265
- // same workdir — never an external path. So file tools (Read/Edit/Write/Grep/Glob)
266
- // stay confined to the project directory. (Bash isn't OS-jailed by the CLI — its
267
- // reach is bounded by the per-project allow list, not by a directory sandbox.)
268
- if (workdir) args.push('--add-dir', workdir)
206
+ // The binary owns the permission, tool allow/deny and argument policy.
207
+ // Node only resolves capability and writes the project-scoped MCP file.
208
+ const args = claudeBuildArgs({ model, providerModel: provider.model, sessionId,
209
+ permissionMode, autocompactTokens, autocompactSupported,
210
+ mcpConfigPath, allowedTools, pluginAllows, disallowedTools,
211
+ appendSystemPrompt, workdir })
269
212
 
270
- const { file, spawnArgs } = resolveSpawn(bin, args)
213
+ const { file, spawnArgs, fallback } = resolveSpawn(bin, args)
214
+ if (fallback) log(`配置的 agent 路径 '${bin}' 不存在,本轮改用 ${fallback} —— 请把配置改回正确路径`) // 兜底只救急,配置仍是错的
271
215
  const child = spawnFn(file, spawnArgs, {
272
216
  env: buildEnv(provider, baseUrlOverride, extraEnv),
273
217
  cwd: workdir || process.cwd(),
@@ -293,14 +237,50 @@ function runTurn({
293
237
  let killTimer = null
294
238
  let closeTimer = null
295
239
  let settled = false
240
+ let childCloseObserved = false
241
+ let acceptanceFailed = false
242
+ let turnAccepted = false
243
+ let durableWork = Promise.resolve()
244
+ let durableFailed = false
296
245
  // Resolution is guarded (exactly once) because a close-grace timer can force the
297
246
  // turn to settle before the 'close' event fires — otherwise a lingering pipe would
298
247
  // wedge the per-group lock forever (never-hang invariant).
299
248
  const finish = (fn) => { if (settled) return; settled = true; if (killTimer) clearTimeout(killTimer); if (closeTimer) clearTimeout(closeTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
249
+ const resolveRun = result => {
250
+ if (childCloseObserved) closedRunnerResults.add(result)
251
+ resolve(result)
252
+ }
300
253
  const killTree = () => {
301
254
  if (IS_WIN && child.pid) { try { spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }) } catch {}; return }
302
255
  try { child.kill('SIGKILL') } catch { /* already gone */ }
303
256
  }
257
+ // The durable v2 dispatch ACK is a separate, synchronous boundary. Renderer
258
+ // callbacks intentionally swallow errors; this callback must never do so.
259
+ // No normalized event is delivered before the child's init identifies the
260
+ // accepted turn. CLI tools may already have executed before observation.
261
+ const failTurnAcceptance = (error) => {
262
+ acceptanceFailed = true
263
+ buf = ''
264
+ killTree()
265
+ finish(() => reject(error))
266
+ }
267
+ const failDurableEvent = (error) => {
268
+ if (durableFailed || settled) return
269
+ durableFailed = true
270
+ buf = ''
271
+ killTree()
272
+ finish(() => reject(error))
273
+ }
274
+ const persistEvent = async (event) => {
275
+ if (!onDurableEvent || !event) return
276
+ let timer
277
+ try {
278
+ await Promise.race([
279
+ Promise.resolve().then(() => onDurableEvent(event)),
280
+ new Promise((_, fail) => { timer = setTimeout(() => fail(new Error('Claude durable event hook timed out')), 10000) }),
281
+ ])
282
+ } finally { clearTimeout(timer) }
283
+ }
304
284
  // Force-resolve if 'close' doesn't arrive within closeGraceMs of the trigger (the
305
285
  // final result, or an abort). On Windows the cmd.exe shim / spawned MCP servers can
306
286
  // hold the stdout pipe open long past claude's own exit, so 'close' may lag or never
@@ -321,7 +301,9 @@ function runTurn({
321
301
  closeTimer = setTimeout(() => {
322
302
  if (!aborted && !(lastResult && lastResult.authFailed)) log(`claude 答案已到但 ${closeGraceMs}ms 内没退出 ⇒ 强制收尾;本轮若起过后台命令(run_in_background),它会被一起收掉,下一轮可能空转一次`)
323
303
  killTree()
324
- finish(() => resolve({ sessionId: capturedSessionId, result: lastResult, code: null, aborted }))
304
+ const complete = () => finish(() => resolveRun({ sessionId: capturedSessionId, result: lastResult, code: null, aborted }))
305
+ if (onDurableEvent) durableWork.then(complete, failDurableEvent)
306
+ else complete()
325
307
  }, closeGraceMs)
326
308
  }
327
309
  const onAbort = () => {
@@ -341,8 +323,26 @@ function runTurn({
341
323
  else signal.addEventListener('abort', onAbort, { once: true })
342
324
  }
343
325
 
344
- child.stdout.on('data', (chunk) => {
345
- buf += chunk.toString('utf8')
326
+ // 这段回调不在任何 Promise 链里,抛错会直接带走整个进程 ⇒ 核心失败只结束当前轮。
327
+ // 已经拿到结果时保留结果(答案已交付,只差收尾)。原因只进日志,不带事件原文。
328
+ let coreFailed = false
329
+ const failCore = (error) => {
330
+ if (onDurableEvent) { failDurableEvent(error); return }
331
+ coreFailed = true
332
+ buf = ''
333
+ log(`binary core failed in claude stream: ${error && error.message}`)
334
+ if (lastResult) return
335
+ lastResult = coreFailedResult(capturedSessionId)
336
+ if (onEvent) { try { onEvent(lastResult) } catch { /* renderer must not kill the turn */ } }
337
+ killTree()
338
+ armCloseGrace()
339
+ }
340
+ const stdoutDecoder = new StringDecoder('utf8') // 一个汉字可能被拆在两个分块里,逐块 toString 会变成乱码
341
+ const processChunk = async (chunk) => {
342
+ // Legacy callers may still receive a late buffered line after close;
343
+ // only the v2 gate forbids post-settlement delivery.
344
+ if (coreFailed || acceptanceFailed || durableFailed || ((onTurnAccepted || onDurableEvent) && settled)) return
345
+ buf += stdoutDecoder.write(chunk)
346
346
  let nl
347
347
  while ((nl = buf.indexOf('\n')) >= 0) {
348
348
  const line = buf.slice(0, nl).trim()
@@ -350,18 +350,43 @@ function runTurn({
350
350
  if (!line) continue
351
351
  let raw
352
352
  try { raw = JSON.parse(line) } catch { continue } // skip non-JSON noise
353
+ if (onTurnAccepted && !turnAccepted) {
354
+ if (raw.type !== 'system' || raw.subtype !== 'init' || typeof raw.session_id !== 'string' || !raw.session_id) {
355
+ failTurnAcceptance(new Error('Claude turn emitted an event before start confirmation'))
356
+ return
357
+ }
358
+ let accepted
359
+ try { accepted = onTurnAccepted({ channel: 'claude-code', sessionId: raw.session_id }) } catch (error) {
360
+ failTurnAcceptance(error)
361
+ return
362
+ }
363
+ let asyncAccepted = false
364
+ try { asyncAccepted = !!accepted && typeof accepted.then === 'function' } catch (error) {
365
+ failTurnAcceptance(error)
366
+ return
367
+ }
368
+ if (asyncAccepted) {
369
+ // An async write cannot guard following lines in this same chunk.
370
+ Promise.resolve(accepted).catch(() => {})
371
+ failTurnAcceptance(new Error('Claude turn start confirmation must be synchronous'))
372
+ return
373
+ }
374
+ turnAccepted = true
375
+ }
353
376
  // key 错快速失败(1.0.3):claude 对 401 / 403 默认重试约 3 分钟才放弃,第一次重试时(实测 0.8 秒)就会先报一条
354
377
  // `system/api_retry` 带 error_status。只对 API key 型模型(有 baseUrl)当场收尾 —— 订阅登录的 401 可能是
355
378
  // Claude Code 自己在刷新登录,让它重试;5xx / 529 这类过载照旧重试。造一条出错 result 交给上层:账号类失败
356
379
  // 那套(备用模型 / 运维告警 / 群里人话)和重试耗尽时一样走,只是不用等。
357
380
  if (raw.type === 'system' && raw.subtype === 'api_retry' && provider.baseUrl && (raw.error_status === 401 || raw.error_status === 403) && !lastResult && !aborted) {
358
381
  lastResult = authFailedResult(raw.error_status, raw.error, capturedSessionId)
382
+ if (onDurableEvent) await persistEvent(lastResult)
359
383
  if (onEvent) { try { onEvent(lastResult) } catch { /* renderer must not kill the turn */ } }
360
384
  killTree()
361
385
  armCloseGrace()
362
386
  continue
363
387
  }
364
- const ev = normalizeEvent(raw)
388
+ let ev
389
+ try { ev = normalizeEvent(raw) } catch (error) { failCore(error); return }
365
390
  if (ev.sessionId) capturedSessionId = ev.sessionId
366
391
  if (ev.type === 'assistant' && raw.message && raw.message.usage) {
367
392
  lastMsgUsage = raw.message.usage
@@ -374,27 +399,43 @@ function runTurn({
374
399
  // message.usage 里(2.1.222 真机核对),兜底走 usage.iterations 末元素(同一形状)。
375
400
  const last = lastMsgUsage || (ev.usage && Array.isArray(ev.usage.iterations) ? ev.usage.iterations[ev.usage.iterations.length - 1] : null)
376
401
  if (last) ev.contextTokens = (last.input_tokens || 0) + (last.cache_read_input_tokens || 0) + (last.cache_creation_input_tokens || 0)
377
- lastResult = ev; armCloseGrace() // answer in → bound the wait for 'close'
378
402
  }
403
+ if (onDurableEvent) await persistEvent(ev)
404
+ if (ev.type === 'result') { lastResult = ev; armCloseGrace() } // answer in → bound the wait for 'close'
379
405
  if (onEvent) { try { onEvent(ev) } catch { /* renderer must not kill the turn */ } }
380
406
  }
407
+ }
408
+ child.stdout.on('data', (chunk) => {
409
+ if (!onDurableEvent) { processChunk(chunk).catch(failDurableEvent); return }
410
+ child.stdout.pause?.()
411
+ durableWork = durableWork.then(() => processChunk(chunk)).catch(failDurableEvent).finally(() => {
412
+ if (!durableFailed && !settled) child.stdout.resume?.()
413
+ })
381
414
  })
382
415
 
383
416
  child.stderr.on('data', (c) => { stderr += c.toString('utf8') })
384
417
 
385
418
  child.on('error', (err) => finish(() => {
419
+ // 配置路径与各候选位置都没有:群里只说人话(这条 message 会原样进群卡与账本),路径与产品名只进日志。
420
+ // 英文原话带安装路径,不能进群。code 给引擎认(发运维告警),不按文案判。
386
421
  if (err.code === 'ENOENT') {
387
- return reject(new Error(`cannot find agent binary '${bin}' on PATH — is Claude Code installed?`))
422
+ log(`cannot find agent binary '${bin}' (and no fallback copy on PATH / ~/.local/bin / nvm) — is Claude Code installed?`)
423
+ return reject(Object.assign(new Error('模型程序在这台机器上找不到了,需要管理员处理'), { code: 'AGENT_BIN_MISSING' }))
388
424
  }
389
425
  reject(err)
390
426
  }))
391
427
 
392
- child.on('close', (code) => finish(() => {
428
+ const completeClose = (code) => finish(() => {
393
429
  if (code !== 0 && !lastResult && !aborted) {
394
430
  return reject(new Error(`claude exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`))
395
431
  }
396
- resolve({ sessionId: capturedSessionId, result: lastResult, code, aborted })
397
- }))
432
+ resolveRun({ sessionId: capturedSessionId, result: lastResult, code, aborted })
433
+ })
434
+ child.on('close', (code) => {
435
+ childCloseObserved = true
436
+ if (onDurableEvent) durableWork.then(() => { if (!durableFailed) completeClose(code) })
437
+ else completeClose(code)
438
+ })
398
439
  }
399
440
  })
400
441
  }
@@ -459,4 +500,4 @@ function probeVersion(bin = 'claude') {
459
500
  })
460
501
  }
461
502
 
462
- module.exports = { runTurn, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
503
+ module.exports = { runTurn, isClosedRunnerResult, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }