sintbot 1.0.16 → 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 (137) hide show
  1. package/CHANGELOG.en.json +99 -0
  2. package/CHANGELOG.json +99 -0
  3. package/README.md +20 -158
  4. package/README.zh-CN.md +20 -166
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +11 -35
  8. package/docs/install.md +61 -167
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
  10. package/docs/zh-CN/install.md +61 -165
  11. package/install.sh +84 -0
  12. package/package.json +73 -37
  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 -4716
  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/cli-secret.js +0 -36
  93. package/src/clilang.js +0 -31
  94. package/src/core/cron.js +0 -182
  95. package/src/core/cronfire.js +0 -120
  96. package/src/core/cronrun.js +0 -116
  97. package/src/core/engine.js +0 -2758
  98. package/src/core/interfaces.js +0 -114
  99. package/src/core/issues.js +0 -185
  100. package/src/core/ledger.js +0 -30
  101. package/src/core/opsalert.js +0 -214
  102. package/src/core/pending.js +0 -123
  103. package/src/core/proxy.js +0 -424
  104. package/src/core/quotaalert.js +0 -79
  105. package/src/core/quotaerr.js +0 -55
  106. package/src/core/scheduler.js +0 -59
  107. package/src/design.js +0 -5161
  108. package/src/gitrepo.js +0 -150
  109. package/src/health.js +0 -353
  110. package/src/installdeps.js +0 -72
  111. package/src/leftover.js +0 -174
  112. package/src/platform/boardnotice.js +0 -77
  113. package/src/platform/boardops.js +0 -36
  114. package/src/platform/boardwatch.js +0 -447
  115. package/src/platform/cli.js +0 -45
  116. package/src/platform/slack.js +0 -1289
  117. package/src/platform/takeovercard.js +0 -37
  118. package/src/platform/wecom.js +0 -938
  119. package/src/platform/wecomcli.js +0 -128
  120. package/src/platform/wecomgate.js +0 -108
  121. package/src/platform/wecomname.js +0 -43
  122. package/src/platform/wecompoll.js +0 -241
  123. package/src/preview.js +0 -581
  124. package/src/projectmemory.js +0 -175
  125. package/src/service.js +0 -317
  126. package/src/ui/auth.js +0 -31
  127. package/src/ui/browser.html +0 -392
  128. package/src/ui/browserroutes.js +0 -234
  129. package/src/ui/console.html +0 -5526
  130. package/src/ui/dashboard.js +0 -168
  131. package/src/ui/feishulogin.js +0 -136
  132. package/src/ui/server.js +0 -2314
  133. package/src/ui/sessions.js +0 -78
  134. package/src/ui/takeover.js +0 -108
  135. package/src/updater.js +0 -243
  136. package/src/upgrade.js +0 -35
  137. package/src/usage.js +0 -144
Binary file
@@ -1,16 +1,18 @@
1
- // Runner dispatch — selects the AgentRunner by provider so the rest of sbot (engine,
2
- // CLI, UI server) only ever calls one `runTurn` and stays backend-agnostic. A provider
3
- // with `runner: "codex"` drives OpenAI Codex CLI (GPT; DESIGN §1); everything
4
- // else drives Claude Code. The caller's `bin` is the Claude bin (cfg.agent.bin) — override
5
- // it to the codex bin when routing there. probeVersion (the Claude-bin startup probe) is
6
- // re-exported; codex has its own probe via codexcode.probeVersion (used by health.js).
1
+ // Runner dispatch — selects the AgentRunner by provider so the rest of SintBot calls one
2
+ // `runTurn`. Codex uses App Server by default and `codex exec` when the group
3
+ // explicitly disables busy follow-up; all other providers use Claude Code. The caller's `bin` is
4
+ // the Claude bin (cfg.agent.bin) — override it when routing to Codex.
7
5
 
8
6
  const claudeAgent = require('./claudecode')
9
7
  const codexAgent = require('./codexcode')
8
+ const codexApp = require('./codexapp')
9
+ const { routeBackend } = require('./privatecore')
10
10
 
11
11
  function runTurn(opts) {
12
- if (opts && opts.provider && opts.provider.runner === 'codex') {
13
- return codexAgent.runTurn({ ...opts, bin: (opts.provider && opts.provider.bin) || 'codex' })
12
+ const route = routeBackend(opts && opts.provider && opts.provider.runner, opts && opts.codexApp, opts && opts.codexSteer, opts && opts.codexProjectDocFallback)
13
+ if (route !== 'claude') {
14
+ const codexOpts = { ...opts, bin: (opts.provider && opts.provider.bin) || 'codex' }
15
+ return route === 'codex-app' ? codexApp.runTurn(codexOpts) : codexAgent.runTurn(codexOpts)
14
16
  }
15
17
  return claudeAgent.runTurn(opts)
16
18
  }
@@ -11,19 +11,49 @@
11
11
  // same as calling the bin directly.
12
12
 
13
13
  const fs = require('node:fs')
14
+ const os = require('node:os')
14
15
  const path = require('node:path')
15
16
 
16
17
  const IS_WIN = process.platform === 'win32'
17
18
 
19
+ const isFile = (p) => { try { return fs.statSync(p).isFile() } catch { return false } }
20
+
21
+ // nvm 把每个 node 版本各装一套,bin 目录名里带版本号;新到旧排,先试最近装的那套
22
+ function nvmBinDirs(home) {
23
+ const root = path.join(home, '.nvm', 'versions', 'node')
24
+ try { return fs.readdirSync(root).sort().reverse().map((v) => path.join(root, v, 'bin')) } catch { return [] }
25
+ }
26
+
27
+ // 配置钉死的绝对路径指空时,找**同一个程序**的另一份 —— 不切模型、不清会话、不报错。
28
+ // 典型情况:CLI 自更新把新版装进 nvm 前缀、homebrew 那份被清掉,配置钉的路径指空
29
+ // ⇒ 每轮都失败,而新版明明就在 ~/.nvm/…/bin 里。候选顺序 = PATH 各目录 → 原生安装器 ~/.local/bin →
30
+ // homebrew / usr-local → nvm 各版本。只管「绝对路径且文件不在」;裸名字照旧交给 PATH。
31
+ // 返回 { bin, fallback }:fallback 非空 = 这次用的不是配置那条,调用方记一行日志让人去改配置。
32
+ const FIXED_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'] // 两个常见全局前缀;单测把它换成空,免得本机真实安装混进沙箱
33
+ function locateBin(bin, { fixedDirs = FIXED_DIRS } = {}) {
34
+ if (!bin || !path.isAbsolute(bin) || isFile(bin)) return { bin, fallback: null }
35
+ const home = os.homedir()
36
+ const dirs = [...(process.env.PATH || '').split(path.delimiter), path.join(home, '.local', 'bin'), ...fixedDirs, ...nvmBinDirs(home)]
37
+ const name = path.basename(bin)
38
+ for (const d of dirs) {
39
+ if (!d) continue
40
+ const p = path.join(d, name)
41
+ if (p !== bin && isFile(p)) return { bin: p, fallback: p }
42
+ }
43
+ return { bin, fallback: null }
44
+ }
45
+
18
46
  function resolveSpawn(bin, args) {
19
- if (!IS_WIN) return { file: bin, spawnArgs: args }
47
+ const loc = locateBin(bin) // 所有起 agent 进程的地方(轮次 / 旗子探针 / 版本探针 / 登录态探针)都经这里,一处兜底全覆盖
48
+ bin = loc.bin
49
+ if (!IS_WIN) return { file: bin, spawnArgs: args, fallback: loc.fallback }
20
50
  const resolved = resolveWinExecutable(bin)
21
- if (!resolved) return { file: bin, spawnArgs: args } // not found → let ENOENT surface with the caller's hint
51
+ if (!resolved) return { file: bin, spawnArgs: args, fallback: loc.fallback } // not found → let ENOENT surface with the caller's hint
22
52
  const ext = path.extname(resolved).toLowerCase()
23
53
  if (ext === '.cmd' || ext === '.bat') {
24
- return { file: process.env.ComSpec || 'cmd.exe', spawnArgs: ['/d', '/s', '/c', resolved, ...args] }
54
+ return { file: process.env.ComSpec || 'cmd.exe', spawnArgs: ['/d', '/s', '/c', resolved, ...args], fallback: loc.fallback }
25
55
  }
26
- return { file: resolved, spawnArgs: args } // real .exe → spawn directly, cleanest
56
+ return { file: resolved, spawnArgs: args, fallback: loc.fallback } // real .exe → spawn directly, cleanest
27
57
  }
28
58
 
29
59
  // A minimal `where`: resolve a bare command against PATH + PATHEXT. PATHEXT order
@@ -47,4 +77,4 @@ function resolveWinExecutable(bin) {
47
77
  return null
48
78
  }
49
79
 
50
- module.exports = { IS_WIN, resolveSpawn, resolveWinExecutable }
80
+ module.exports = { IS_WIN, resolveSpawn, resolveWinExecutable, locateBin }
@@ -0,0 +1,117 @@
1
+ 'use strict'
2
+
3
+ // First-install filesystem preparation only. Existing authorities and legacy
4
+ // state are never adopted, cleared, migrated or replayed by this entry.
5
+ const fs = require('node:fs')
6
+ const os = require('node:os')
7
+ const path = require('node:path')
8
+ const { loadConfig, platformsConfigured } = require('./config')
9
+ const { C2DurableJournal } = require('./core/c2durablejournal')
10
+ const { validatePersistentState } = require('./core/c2persiststate')
11
+ const { validatePersistentReceipts, validatePersistentRelation } = require('./core/c2persistrelation')
12
+ const VALIDATORS = { validateState: validatePersistentState,
13
+ validateReceipts: validatePersistentReceipts, validateRelation: validatePersistentRelation }
14
+
15
+ function loadInstallation(explicitConfig) {
16
+ const stateDir = path.join(os.homedir(), '.sbot')
17
+ const file = process.env.SBOT_C2_V2_JOURNAL || path.join(stateDir, 'c2-v2.log')
18
+ if (!path.isAbsolute(file) || file !== path.resolve(file) ||
19
+ path.dirname(file) !== stateDir || !/^c2-v2(?:[._-][a-zA-Z0-9._-]+)?\.log$/.test(path.basename(file))) {
20
+ throw new Error('The journal must be a c2-v2*.log file directly in the installation state directory')
21
+ }
22
+ const configFile = explicitConfig || process.env.SBOT_CONFIG || path.join(stateDir, 'config.json')
23
+ if (!path.isAbsolute(configFile) || !fs.existsSync(configFile)) {
24
+ throw new Error('An existing configuration file with an absolute path is required')
25
+ }
26
+ const cfg = loadConfig(configFile)
27
+ const configured = platformsConfigured(cfg)
28
+ const groups = Object.entries(cfg.groups || {})
29
+ if (groups.length !== 1) throw new Error('This release requires exactly one explicit Feishu group')
30
+ const [serviceChatId, group] = groups[0]
31
+ const provider = cfg.providers?.[group?.provider || cfg.defaultProvider]
32
+ if (path.resolve(cfg._path || '') !== path.resolve(configFile) ||
33
+ !configured.feishu || configured.wecom || configured.slack || cfg.wecom || cfg.slack ||
34
+ (cfg._groupPlatform?.inferred || []).length ||
35
+ !/^oc_[A-Za-z0-9_-]{1,124}$/.test(serviceChatId) || !group ||
36
+ group.platform !== 'feishu' || group.paused || group.board ||
37
+ group.crons && (!Array.isArray(group.crons) || group.crons.length) ||
38
+ provider?.runner !== 'codex' || group.codexSteer !== false || group.codexApp === true ||
39
+ cfg.alerts?.chatId || cfg.ui && cfg.ui.enabled !== false) {
40
+ throw new Error('This release accepts one explicit Feishu Codex message route, with codexSteer:false and without canvas, scheduled tasks, console or synthetic sources')
41
+ }
42
+ if (cfg.feishu.onlyWhenMentioned === true &&
43
+ !/^ou_[a-zA-Z0-9_-]+$/.test(cfg.feishu.botOpenId || '')) {
44
+ throw new Error('Mention-only messages require the Feishu botOpenId; otherwise set onlyWhenMentioned:false for the dedicated group')
45
+ }
46
+ if (typeof group.workdir !== 'string' || !path.isAbsolute(group.workdir) ||
47
+ !fs.existsSync(group.workdir) || !fs.statSync(group.workdir).isDirectory()) {
48
+ throw new Error('The group workdir must be an existing absolute project directory')
49
+ }
50
+ return { stateDir, file, cfg, groups, serviceChatId, provider }
51
+ }
52
+
53
+ function assertPrivateDirectory(dir) {
54
+ const stat = fs.lstatSync(dir)
55
+ if (!stat.isDirectory() || stat.isSymbolicLink() ||
56
+ typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
57
+ throw new Error('The installation state directory must be a real directory owned by this user')
58
+ }
59
+ }
60
+
61
+ function absent(file, label) {
62
+ try { fs.lstatSync(file) } catch (error) { if (error.code === 'ENOENT') return; throw error }
63
+ throw new Error(`${label} already exists; initialization never overwrites or migrates existing state`)
64
+ }
65
+
66
+ function assertFirstInstall(stateDir, file) {
67
+ assertPrivateDirectory(stateDir)
68
+ for (const name of ['state.json', 'pending-turns.json', 'engine-receipts.json']) {
69
+ absent(path.join(stateDir, name), name)
70
+ }
71
+ absent(file, 'Journal')
72
+ // An independently named journal must not make existing authority disappear.
73
+ if (fs.readdirSync(stateDir).some(name => /^c2-v2.*\.log$/.test(name))) {
74
+ throw new Error('An existing journal requires review; initialization cannot replace its authority')
75
+ }
76
+ }
77
+
78
+ function initializeInstallation(explicitConfig) {
79
+ const { stateDir, file } = loadInstallation(explicitConfig)
80
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })
81
+ assertFirstInstall(stateDir, file)
82
+ const lockFile = path.join(stateDir, 'daemon.lock')
83
+ // Unlike normal start, initialization refuses even stale locks.
84
+ absent(lockFile, 'Service lock')
85
+ let fd
86
+ let ownsLock = false
87
+ try {
88
+ fd = fs.openSync(lockFile, 'wx', 0o600)
89
+ ownsLock = true
90
+ fs.writeSync(fd, String(process.pid))
91
+ fs.fsyncSync(fd)
92
+ fs.closeSync(fd); fd = undefined
93
+ assertFirstInstall(stateDir, file)
94
+ fs.chmodSync(stateDir, 0o700)
95
+ C2DurableJournal.initialize(file, {
96
+ state: { version: 2, seq: 0, lanes: {} },
97
+ receipts: { pending: [], completed: [] },
98
+ }, VALIDATORS)
99
+ const journal = C2DurableJournal.open(file, VALIDATORS)
100
+ const snapshot = journal.snapshot()
101
+ if (journal.blocked || snapshot.cursor !== 0 || snapshot.eventCount !== 0 ||
102
+ snapshot.receipts.pending.length || snapshot.receipts.completed.length ||
103
+ snapshot.effects.size || snapshot.notices.size || Object.keys(snapshot.state.lanes).length) {
104
+ throw new Error('New journal verification failed; preserve the file for review')
105
+ }
106
+ return file
107
+ } finally {
108
+ if (fd !== undefined) fs.closeSync(fd)
109
+ if (ownsLock) {
110
+ try {
111
+ if (fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid)) fs.unlinkSync(lockFile)
112
+ } catch { /* Do not remove a lock that no longer belongs to this operation. */ }
113
+ }
114
+ }
115
+ }
116
+
117
+ module.exports = { loadInstallation, initializeInstallation, assertPrivateDirectory }
package/src/config.js CHANGED
@@ -40,7 +40,8 @@ const DEFAULTS = {
40
40
  // 写死会**覆盖 CLI 的默认**,于是每次 Anthropic 出新一代,sbot 的默认就悄悄落后一代
41
41
  // (2026-08-03 核对时正卡在 opus-4-8,而 CLI 默认已是 Opus 5)。要钉死型号在控制台选。
42
42
  providers: { claude: {} },
43
- // "<chatId>": { workdir, provider?, model?, allowedTools?: [], disallowedTools?: [] }
43
+ // "<chatId>": { workdir, provider?, model?, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
44
+ // codexSteer defaults on for Codex groups; explicit false opts out.
44
45
  // — one project per group; permissions are scoped to that group's own workdir.
45
46
  groups: {},
46
47
  }
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+
3
+ const { C2DurableRuntime } = require('./c2durableruntime')
4
+
5
+ const RECEIPT_ID = /^[0-9a-f]{32}$/
6
+ const GENERATION = /^[0-9a-f]{32}$/
7
+
8
+ function nonempty(value) { return typeof value === 'string' && value.length > 0 }
9
+
10
+ // This is deliberately narrower than a generic Feishu success check. A card
11
+ // or message ID is required when later operations depend on that target. The
12
+ // live target and SDK response remain in memory; only the runtime's hashed
13
+ // operation identity and effect outcome may enter its journal.
14
+ function confirmCardOperation(response, operation) {
15
+ if (!operation || typeof operation !== 'object') return false
16
+ switch (operation.kind) {
17
+ case 'card.create':
18
+ return response?.code === 0 && nonempty(response?.data?.card_id)
19
+ case 'message.reply':
20
+ case 'message.create':
21
+ return response?.code === 0 &&
22
+ nonempty(response?.data?.message_id || response?.message_id)
23
+ case 'cardElement.content':
24
+ return response === true
25
+ case 'card.update':
26
+ case 'card.settings':
27
+ case 'message.delete':
28
+ return response?.code === 0
29
+ default:
30
+ return false
31
+ }
32
+ }
33
+
34
+ // Pass the returned function only as Feishu's explicit operationBoundary for
35
+ // this one live turn. Normal card behavior is unaffected unless the caller
36
+ // opts in; a failed or ambiguous operation must propagate instead of falling
37
+ // through to a second SDK call.
38
+ function createC2CardEffectBoundary({ runtime, receiptId, chatId, turnToken,
39
+ generation, verifyGeneration } = {}) {
40
+ if (!(runtime instanceof C2DurableRuntime) || !RECEIPT_ID.test(receiptId) ||
41
+ !nonempty(chatId) || !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
42
+ !GENERATION.test(generation) || typeof verifyGeneration !== 'function' ||
43
+ verifyGeneration.constructor?.name === 'AsyncFunction') {
44
+ throw new TypeError('C2 card effect bridge needs a live turn authority')
45
+ }
46
+ return operation => runtime.performOutboundCardOperation({ receiptId, chatId,
47
+ turnToken, generation, verifyGeneration, operation,
48
+ confirm: confirmCardOperation })
49
+ }
50
+
51
+ module.exports = { createC2CardEffectBoundary, confirmCardOperation }
@@ -0,0 +1,216 @@
1
+ 'use strict'
2
+
3
+ // Opt-in, process-local composition for one message receipt. A streaming card's
4
+ // earlier operations use independent outbound effects; its final card.update
5
+ // belongs to the terminal finalReply effect and therefore cannot enter the
6
+ // outbound executor a second time.
7
+ const { createC2CardEffectBoundary, confirmCardOperation } = require('./c2cardeffectbridge')
8
+ const { prepareTerminalEffectBridge, terminalReplyText } = require('./c2terminaleffectbridge')
9
+
10
+ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
11
+ receiptId, chatId, turnToken, generation, verifyGeneration,
12
+ applyLocalEffect, assertWriterLock, sendPlainReply = null, redactText = value => value,
13
+ now = Date.now } = {}) {
14
+ const receipt = runtime?.snapshot?.().receipts?.pending?.find(item => item.id === receiptId)
15
+ if (!receipt || receipt.source !== 'message' || receipt.chatId !== chatId ||
16
+ !replyCtx || replyCtx.chatId !== chatId ||
17
+ replyCtx.messageId !== receipt.sourceId ||
18
+ !platform || (typeof platform.createStreamingCard !== 'function' &&
19
+ typeof sendPlainReply !== 'function')) {
20
+ throw new TypeError('C2 card terminal path needs one exact message target')
21
+ }
22
+ if (sendPlainReply !== null && typeof sendPlainReply !== 'function') {
23
+ throw new TypeError('C2 plain reply needs one strict sender')
24
+ }
25
+ if (typeof redactText !== 'function' ||
26
+ redactText.constructor?.name === 'AsyncFunction') {
27
+ throw new TypeError('C2 card text redaction must be synchronous')
28
+ }
29
+ const targetCtx = Object.freeze({ ...replyCtx })
30
+ // Registration is durable before open() can expose any runner callback.
31
+ const terminal = prepareTerminalEffectBridge({ runtime, payloadHandles,
32
+ receiptId, chatId, turnToken, generation, verifyGeneration,
33
+ applyLocalEffect, assertWriterLock, now })
34
+ const operationBoundary = createC2CardEffectBoundary({ runtime, receiptId,
35
+ chatId, turnToken, generation, verifyGeneration })
36
+ let phase = 'prepared'
37
+ let card = null
38
+ let suppressedCard = false
39
+ const fail = error => { phase = 'closed'; runtime.close(); throw error }
40
+ const canUsePlain = before => {
41
+ if (!sendPlainReply || runtime.blocked) return false
42
+ const after = runtime.snapshot()
43
+ // The trusted platform adapter must route every card SDK call through the
44
+ // operation boundary. Once an outbound effect exists, even a rejected SDK
45
+ // response is ambiguous and a second delivery would risk a duplicate.
46
+ return after.cursor === before.cursor &&
47
+ after.effects.size === before.effects.size &&
48
+ [...after.effects.keys()].every(id => before.effects.has(id))
49
+ }
50
+
51
+ return Object.freeze({
52
+ async open({ suppressCard = false } = {}) {
53
+ if (phase !== 'prepared') throw new Error('C2 card terminal path already opened')
54
+ if (typeof suppressCard !== 'boolean') throw new TypeError('C2 card suppression must be boolean')
55
+ if (suppressCard) {
56
+ // A silent turn with no assistant output never exposed a card in v1.
57
+ // No platform operation or outbound effect is needed before settlement.
58
+ suppressedCard = true
59
+ phase = 'plain'
60
+ return false
61
+ }
62
+ phase = 'opening'
63
+ const before = runtime.snapshot()
64
+ if (typeof platform.createStreamingCard !== 'function') {
65
+ if (!canUsePlain(before)) return fail(new Error('C2 plain reply lacks unopened card proof'))
66
+ phase = 'plain'
67
+ return false
68
+ }
69
+ try {
70
+ card = await platform.createStreamingCard(targetCtx,
71
+ { operationKey: receiptId, operationBoundary })
72
+ if (!card && canUsePlain(before)) {
73
+ phase = 'plain'
74
+ return false
75
+ }
76
+ if (!card || typeof card.update !== 'function' ||
77
+ typeof card.flush !== 'function' || typeof card.finish !== 'function' ||
78
+ typeof card.recall !== 'function') {
79
+ throw new Error('C2 card terminal path has no durable card')
80
+ }
81
+ phase = 'open'
82
+ return true
83
+ } catch (error) {
84
+ if (canUsePlain(before)) {
85
+ phase = 'plain'
86
+ return false
87
+ }
88
+ return fail(error)
89
+ }
90
+ },
91
+ update(text) {
92
+ if (phase !== 'open' && phase !== 'plain') {
93
+ throw new Error('C2 card terminal path is not open')
94
+ }
95
+ if (suppressedCard) throw new Error('C2 suppressed card cannot stream an update')
96
+ if (typeof text !== 'string') throw new TypeError('C2 card update needs process-local text')
97
+ if (card) card.update(redactText(text))
98
+ },
99
+ async flushForTerminal() {
100
+ if (phase !== 'open' || !card || await card.flush() !== true) {
101
+ return fail(new Error('C2 stream frame flush unconfirmed'))
102
+ }
103
+ return true
104
+ },
105
+ cancellationTarget() {
106
+ if (!['open', 'recalled'].includes(phase) || !card ||
107
+ typeof card.durableTarget !== 'function') {
108
+ throw new Error('C2 cancellation card target unavailable')
109
+ }
110
+ const target = card.durableTarget()
111
+ if (target?.chatId !== chatId || !target.cardId || !target.msgId) {
112
+ throw new Error('C2 cancellation card target differs from turn')
113
+ }
114
+ return target
115
+ },
116
+ async recallForCancellation() {
117
+ if (phase === 'plain' && !card) {
118
+ phase = 'recalled'
119
+ return false
120
+ }
121
+ if (phase !== 'open' || !card) {
122
+ throw new Error('C2 cancellation has no open streaming card')
123
+ }
124
+ phase = 'recalling'
125
+ try {
126
+ if (await card.flush() !== true || await card.recall() !== true) {
127
+ throw new Error('C2 cancellation card recall unconfirmed')
128
+ }
129
+ phase = 'recalled'
130
+ return true
131
+ } catch (error) { return fail(error) }
132
+ },
133
+ async complete({ payload, replyText, paused = false, footer = '' } = {}) {
134
+ if (phase !== 'open' && phase !== 'plain') {
135
+ throw new Error('C2 card terminal path is not open')
136
+ }
137
+ phase = 'completing'
138
+ try {
139
+ const terminalPayload = payload && typeof payload === 'object' &&
140
+ !Array.isArray(payload) ? { ...payload } : null
141
+ if (!terminalPayload || terminalPayload.type !== 'finalizeEffectCommit' ||
142
+ terminalPayload.receiptId !== receiptId || terminalPayload.turnToken !== turnToken ||
143
+ typeof terminalPayload.quiet !== 'boolean' || terminalPayload.quiet !== receipt.quiet ||
144
+ typeof terminalPayload.resultText !== 'string' ||
145
+ typeof terminalPayload.bufferText !== 'string' ||
146
+ typeof footer !== 'string') {
147
+ throw new TypeError('C2 card terminal payload differs from running turn')
148
+ }
149
+ if (card && await card.flush() !== true) {
150
+ throw new Error('C2 card prior operations lack confirmed flush')
151
+ }
152
+ // Mirrors the private reducer's silent predicate. A posted card must
153
+ // be durably recalled before the zero-send quiet settlement.
154
+ const quiet = receipt.quiet || terminalPayload.resultText.trim() === 'NO_REPLY' ||
155
+ (!terminalPayload.resultText.trim() && terminalPayload.bufferText.trim() === 'NO_REPLY')
156
+ if (suppressedCard && !quiet) {
157
+ throw new Error('C2 suppressed card needs a silent terminal')
158
+ }
159
+ let result
160
+ if (quiet) {
161
+ if (replyText !== undefined) {
162
+ throw new Error('C2 quiet card cannot carry reply text')
163
+ }
164
+ if (card && await card.recall() !== true) {
165
+ throw new Error('C2 quiet card recall unconfirmed')
166
+ }
167
+ result = await terminal.complete({ payload: terminalPayload, paused })
168
+ } else {
169
+ if (typeof replyText !== 'string' || !replyText ||
170
+ replyText !== terminalReplyText(terminalPayload)) {
171
+ throw new TypeError('C2 visible card needs exact process-local final text')
172
+ }
173
+ const visibleText = redactText(replyText)
174
+ if (typeof visibleText !== 'string' || !visibleText) {
175
+ throw new TypeError('C2 visible card redaction removed final text')
176
+ }
177
+ result = await terminal.complete({ payload: terminalPayload, replyText, paused,
178
+ resolveTarget: item => ({ receiptId: item.id, chatId: item.chatId,
179
+ sourceId: item.sourceId, replyCtx: targetCtx }),
180
+ sendReply: async (target, text) => {
181
+ if (!card) {
182
+ // The caller supplies a strict single-call sender. The normal
183
+ // platform.reply has a hidden reply-to-direct-send retry and
184
+ // is therefore not suitable for a durable final effect.
185
+ return sendPlainReply(target, visibleText)
186
+ }
187
+ let calls = 0
188
+ const finished = await card.finish(visibleText, { footer,
189
+ terminalOperationBoundary: async operation => {
190
+ if (!operation || operation.kind !== 'card.update' ||
191
+ operation.cardId == null || typeof operation.invoke !== 'function' ||
192
+ ++calls !== 1) {
193
+ throw new Error('C2 final card needs exactly one update operation')
194
+ }
195
+ const response = await operation.invoke()
196
+ if (!confirmCardOperation(response, operation)) {
197
+ throw new Error('C2 final card update unconfirmed')
198
+ }
199
+ return response
200
+ },
201
+ })
202
+ return { finished, calls }
203
+ },
204
+ confirmReply: value => card
205
+ ? value?.finished === true && value.calls === 1
206
+ : value?.code === 0 && typeof (value.data?.message_id || value.message_id) === 'string' &&
207
+ !!(value.data?.message_id || value.message_id) })
208
+ }
209
+ phase = 'complete'
210
+ return result
211
+ } catch (error) { return fail(error) }
212
+ },
213
+ })
214
+ }
215
+
216
+ module.exports = { prepareCardTerminalPath }
@@ -0,0 +1,148 @@
1
+ 'use strict'
2
+
3
+ const { isDeepStrictEqual } = require('node:util')
4
+ const { taskId } = require('./enginecheckpoint')
5
+ const { platformMessageEventId } = require('./c2eventidentity')
6
+ const { validatePersistentReceipts, validatePersistentRelation } = require('./c2persistrelation')
7
+ const { EngineStepCoordinator } = require('./enginestepcoordinator')
8
+
9
+ const RECEIPT_KEYS = ['id', 'source', 'chatId', 'sourceId', 'acceptedAt', 'quiet']
10
+ const FLAGS = ['quiet', 'polled', 'synthetic', 'wantsAck', 'resetSessionAfter',
11
+ 'emptyRetried', 'freshRetried', 'firstResumed', 'failedOver']
12
+ const MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
13
+ const SCOPE = /^[A-Za-z0-9_-]{1,128}$/
14
+
15
+ function exactKeys(value, keys, label) {
16
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
17
+ Object.getPrototypeOf(value) !== Object.prototype ||
18
+ Reflect.ownKeys(value).length !== keys.length ||
19
+ Reflect.ownKeys(value).some(key => typeof key !== 'string' || !keys.includes(key) ||
20
+ !Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
21
+ !Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
22
+ throw new TypeError(`invalid C2 ${label}`)
23
+ }
24
+ return value
25
+ }
26
+
27
+ // Development-only first acceptance. The platform source ID must already be
28
+ // stable, but its receipt is still a candidate here. Identity and receipt are
29
+ // published only by the same fsynced queue transition; a crash before commit
30
+ // leaves neither accepted. The daemon's single-writer lock is required.
31
+ class C2DurableIngress {
32
+ #journal
33
+ #host
34
+ #startupBlocked
35
+ #gate = null
36
+
37
+ constructor(journal, { host = null } = {}) {
38
+ if (!journal || typeof journal.snapshot !== 'function' ||
39
+ typeof journal.hasEvent !== 'function' ||
40
+ typeof journal.appendTransition !== 'function') {
41
+ throw new TypeError('C2 durable ingress needs a journal')
42
+ }
43
+ this.#journal = journal
44
+ this.#host = host || new EngineStepCoordinator({ journal })
45
+ if (host && (typeof host.snapshot !== 'function' || typeof host.prepare !== 'function' ||
46
+ host.blocked || !isDeepStrictEqual(host.snapshot(), journal.snapshot().state))) {
47
+ throw new Error('C2 ingress coordinator differs from durable authority')
48
+ }
49
+ // A newly constructed adapter has no proof of prior payloads, callbacks
50
+ // or external effects. Only a fresh genesis may admit first acceptance;
51
+ // formal startup recovery must supply a separate verified reopen path.
52
+ const snapshot = journal.snapshot()
53
+ this.#startupBlocked = snapshot.cursor !== 0 ||
54
+ snapshot.receipts.pending.length !== 0 ||
55
+ Object.keys(snapshot.state.lanes).length !== 0
56
+ }
57
+
58
+ // Recovery may settle old receipts, but it cannot recreate lost payloads or
59
+ // runner handles. A new ingress adapter is authorized only by the opened
60
+ // startup gate and a completely quiescent durable snapshot.
61
+ static resumeAfterVerifiedRecovery({ journal, coordinator, gate } = {}) {
62
+ if (!journal || !coordinator || typeof coordinator.snapshot !== 'function' ||
63
+ coordinator.blocked ||
64
+ !gate || typeof gate.isClosed !== 'function' || gate.isClosed() !== false ||
65
+ journal.blocked) throw new Error('C2 ingress recovery gate is not verified open')
66
+ const snapshot = journal.snapshot()
67
+ if (snapshot.state?.version !== 2 || snapshot.cursor !== snapshot.state.seq ||
68
+ !isDeepStrictEqual(coordinator.snapshot(), snapshot.state) ||
69
+ !snapshot.effects || !(snapshot.effects instanceof Map) ||
70
+ !snapshot.notices || !(snapshot.notices instanceof Map)) {
71
+ throw new Error('C2 ingress differs from verified durable authority')
72
+ }
73
+ validatePersistentReceipts(snapshot.receipts)
74
+ validatePersistentRelation(snapshot.state, snapshot.receipts)
75
+ const lanes = snapshot.state.lanes
76
+ const unresolved = snapshot.receipts.pending.length > 0 ||
77
+ Object.values(lanes.queue || {}).some(lane => lane.queue.length > 0) ||
78
+ [...snapshot.effects].some(([effectId, status]) => status !== 'acked' &&
79
+ !(status === 'notStarted' && journal.runnerNotStartedProofs().some(proof =>
80
+ proof.dispatchEffectId === effectId && journal.hasEvent(
81
+ `sbot-c2-runner-not-started-commit-v1:${proof.receiptId}:${proof.turnToken}:${proof.generation}`)))) ||
82
+ [...snapshot.notices.values()].some(notice =>
83
+ notice.status === 'attempting' || notice.status === 'unknown') ||
84
+ Object.values(lanes.lifecycle || {}).some(lane => lane.busy || lane.controller) ||
85
+ Object.values(lanes.turn || {}).some(lane => lane.active !== null) ||
86
+ Object.values(lanes.terminal || {}).some(lane => lane.finalized) ||
87
+ Object.values(lanes.handoff || {}).some(lane => lane.phase !== 'idle') ||
88
+ Object.values(lanes.intake || {}).some(lane => lane.pending !== null) ||
89
+ Object.values(lanes.session || {}).some(lane => !lane.settled)
90
+ if (unresolved) throw new Error('C2 ingress has unresolved durable or host work')
91
+ const ingress = new C2DurableIngress(journal, { host: coordinator })
92
+ ingress.#startupBlocked = false
93
+ ingress.#gate = gate
94
+ return ingress
95
+ }
96
+
97
+ get blocked() { return this.#startupBlocked || this.#host.blocked || this.#journal.blocked ||
98
+ (this.#gate !== null && this.#gate.isClosed() !== false) }
99
+ snapshot() { return this.#journal.snapshot() }
100
+
101
+ enqueueMessage({ receipt, flags, front = false } = {}) {
102
+ if (this.blocked) throw new Error('C2 ingress closed pending startup recovery')
103
+ exactKeys(receipt, RECEIPT_KEYS, 'message receipt')
104
+ exactKeys(flags, FLAGS, 'queue flags')
105
+ if (receipt.source !== 'message' || typeof receipt.sourceId !== 'string' ||
106
+ !MESSAGE_ID.test(receipt.sourceId) || typeof receipt.chatId !== 'string' ||
107
+ !SCOPE.test(receipt.chatId) || ['__proto__', 'constructor', 'prototype'].includes(receipt.chatId) ||
108
+ receipt.id !== taskId(receipt) ||
109
+ flags.quiet !== receipt.quiet || flags.synthetic !== false ||
110
+ typeof front !== 'boolean') {
111
+ throw new TypeError('C2 ingress needs a stable platform message and matching flags')
112
+ }
113
+ for (const key of FLAGS) {
114
+ if (typeof flags[key] !== 'boolean') throw new TypeError(`invalid C2 queue flag ${key}`)
115
+ }
116
+ validatePersistentReceipts({ pending: [receipt], completed: [] })
117
+ const before = this.#journal.snapshot()
118
+ if (before.state.version !== 2 || this.#host.snapshot().seq !== before.cursor ||
119
+ !isDeepStrictEqual(this.#host.snapshot(), before.state)) {
120
+ throw new Error('C2 ingress differs from durable authority')
121
+ }
122
+ if (before.receipts.pending.some(item => item.id === receipt.id) ||
123
+ before.receipts.completed.some(item => item.id === receipt.id)) {
124
+ throw new Error('C2 message receipt already accepted')
125
+ }
126
+ const id = platformMessageEventId({ receiptId: receipt.id, domain: 'queue',
127
+ kind: 'enqueue', lookupReceipt: lookupId => lookupId === receipt.id ? receipt : null })
128
+ if (this.#journal.hasEvent(id)) throw new Error('C2 platform event already committed')
129
+ const job = { id: `receipt:job:${receipt.id}` }
130
+ for (const key of FLAGS) job[key] = flags[key]
131
+ const receipts = { pending: [...before.receipts.pending, structuredClone(receipt)],
132
+ completed: before.receipts.completed }
133
+ const prepared = this.#host.prepare({ id, domain: 'queue', scope: receipt.chatId,
134
+ ...(before.state.lanes.queue?.[receipt.chatId] ? {} : { seed: { queue: [] } }),
135
+ payload: { type: 'enqueue', job, front }, receipts,
136
+ validate: result => {
137
+ const lane = result.state?.lanes?.queue?.[receipt.chatId]
138
+ if (!lane || !Array.isArray(lane.queue) ||
139
+ lane.queue.filter(item => item.id === job.id).length !== 1 ||
140
+ result.effects.length !== 0) {
141
+ throw new Error('C2 ingress returned invalid queue result')
142
+ }
143
+ } })
144
+ return prepared.commit()
145
+ }
146
+ }
147
+
148
+ module.exports = { C2DurableIngress }