sintbot 1.3.0 → 1.4.1

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 (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
package/bin/sintbot-v2.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- // Restricted macOS Feishu single-group entry. No legacy daemon or terminal
5
- // chat dispatch is reachable from this entry.
4
+ // macOS Feishu entry. Each group has an independent durable authority;
5
+ // the process owns one transport, writer lease and shared session store.
6
6
  const fs = require('node:fs')
7
7
  const path = require('node:path')
8
8
  const { parseArgs } = require('node:util')
@@ -11,293 +11,297 @@ const { secureDataDir } = require('../src/core/datadir')
11
11
  const { StateStore } = require('../src/core/store')
12
12
  const { createFeishuPlatform, feishuClient } = require('../src/platform/feishu')
13
13
  const { createMultiPlatform } = require('../src/platform/multi')
14
+ const { isC2InputRejection, answerC2InputRejection } = require('../src/core/c2inputrejection')
15
+
16
+ // Answers stream into the reply card while the model works. The card is
17
+ // registered durably before any frame; feishu.card.streaming:false restores
18
+ // the single final card.
19
+ const streamFrames = cfg => cfg.feishu?.card?.streaming !== false
14
20
 
15
21
  async function startService(rest) {
16
22
  const { values } = parseArgs({ args: rest, allowPositionals: false,
17
- options: { config: { type: 'string' }, ui: { type: 'boolean' },
23
+ options: { config: { type: 'string' }, 'data-dir': { type: 'string' }, ui: { type: 'boolean' },
18
24
  'no-open': { type: 'boolean' }, 'ui-port': { type: 'string' },
19
25
  'ui-host': { type: 'string' }, preflight: { type: 'boolean' } } })
20
26
  const preflightOnly = values.preflight === true
21
- if (values.ui || values['no-open'] || values['ui-port'] || values['ui-host']) {
22
- throw new Error('C2 v2 single-group service has no durable UI route; remove UI options')
27
+ const { loadInstallation, assertPrivateDirectory, assertInstallationJournals, assertJournalScope } = require('../src/c2install')
28
+ // Groups added to the configuration since the last start are registered
29
+ // below, after the writer lock is held; existing journals stay as mapped.
30
+ const installation = loadInstallation(values.config, { dataDir: values['data-dir'], allowAddedGroups: true })
31
+ const { stateDir, file, cfg, groups, routes, serviceChatId, provider } = installation
32
+ const uiEnabled = values.ui === true || cfg.ui?.enabled === true
33
+ const uiHost = values['ui-host'] || cfg.ui?.host || '127.0.0.1'
34
+ const uiPort = Number(values['ui-port'] || cfg.ui?.port || 4600)
35
+ if (uiEnabled && (!['127.0.0.1', 'localhost', '::1', '0.0.0.0'].includes(uiHost) ||
36
+ !Number.isSafeInteger(uiPort) || uiPort < 1 || uiPort > 65535)) {
37
+ throw new Error('Console address or port is invalid')
23
38
  }
24
- const { loadInstallation, assertPrivateDirectory } = require('../src/c2install')
25
- const { stateDir, file, cfg, groups, serviceChatId, provider } = loadInstallation(values.config)
26
- assertPrivateDirectory(stateDir)
27
- let journalStat
28
- try { journalStat = fs.lstatSync(file) } catch {}
29
- if (!journalStat?.isFile() || journalStat.isSymbolicLink()) {
30
- throw new Error('No initialized journal; use sintbot init for a new installation. Existing state must be reviewed before migration.')
39
+ if (uiEnabled && uiHost === '0.0.0.0' && !cfg.ui?.auth) {
40
+ throw new Error('Set a console access password before enabling network access')
31
41
  }
32
- applyProxyEnv(cfg)
33
- const { spawnSync } = require('node:child_process')
34
- const probe = spawnSync(provider.bin || 'codex', ['--version'], {
35
- encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024,
36
- env: { ...process.env, ...(provider.env || {}) },
37
- })
38
- if (probe.error || probe.status !== 0 || !/codex/i.test(probe.stdout || '')) {
39
- throw new Error('Codex executable preflight failed; install and sign in to Codex, then check the configured executable.')
42
+ if (!uiEnabled && (values['ui-port'] || values['ui-host'])) {
43
+ throw new Error('Console options require --ui or ui.enabled:true')
40
44
  }
45
+ assertInstallationJournals(installation)
46
+ // Every installation, including one group, runs the multi-group service:
47
+ // chat commands, the console writer and per-group fault closing live there.
48
+ return startMultiGroupService({ installation, preflightOnly, uiEnabled, uiHost, uiPort })
49
+ }
50
+
51
+ async function startMultiGroupService({ installation, preflightOnly, uiEnabled = false, uiHost, uiPort }) {
52
+ const { stateDir, cfg, routes } = installation
53
+ const { assertPrivateDirectory, assertJournalScope } = require('../src/c2install')
54
+ const { C2DurableJournal } = require('../src/core/c2durablejournal')
55
+ const { validatePersistentState } = require('../src/core/c2persiststate')
56
+ const { validatePersistentReceipts, validatePersistentRelation } = require('../src/core/c2persistrelation')
57
+ const { createC2ServiceStartGate } = require('../src/core/c2servicestartgate')
58
+ const { C2PayloadHandles } = require('../src/core/c2payloadhandles')
59
+ const { createC2LocalEffectExecutor, resolveC2LocalEffectTurn } = require('../src/core/c2localeffectexecutor')
60
+ const { createFeishuRecoverySender } = require('../src/core/c2platformrecoverysender')
61
+ const { createC2V2SourceQuarantine } = require('../src/core/c2sourcequarantine')
62
+ const { Engine } = require('../src/core/v2engine')
63
+ const { createC2MultiGroupService } = require('../src/c2multigroupservice')
64
+ const { C2MultiGroupCapacity } = require('../src/c2multigroupcapacity')
65
+ const { routeBackend } = require('../src/agent/privatecore')
66
+ const { assertRunnerExecutable } = require('../src/c2install')
67
+ const { C2DurableRuntime } = require('../src/core/c2durableruntime')
68
+ // /hud reads the group's model quota; /issue appends to the console's log.
69
+ const usageProbe = require('../src/usage').createUsageProbe({
70
+ codexUsage: async () => { const status = require('../src/agent/codexstatus')
71
+ return status.formatUsage(await status.rateLimits('codex')) } })
72
+ const issues = require('../src/core/issues').createIssues(path.join(stateDir, 'issues.jsonl'),
73
+ { withFileLock: require('../src/core/store').withFileLock,
74
+ log: message => process.stderr.write(`C2 multi-group · ${message}\n`) })
41
75
  const runner = require('../src/agent/runner')
76
+ const validators = { validateState: validatePersistentState,
77
+ validateReceipts: validatePersistentReceipts, validateRelation: validatePersistentRelation }
78
+ assertPrivateDirectory(stateDir)
79
+ applyProxyEnv(cfg)
80
+ const adding = new Set((installation.addedRoutes || []).map(route => route.chatId))
81
+ for (const route of routes) {
82
+ if (adding.has(route.chatId)) {
83
+ if (!route.paused) assertRunnerExecutable(route.provider, cfg)
84
+ continue
85
+ }
86
+ const stat = fs.lstatSync(route.file)
87
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('C2 group journal is not a real initialized file')
88
+ if (route.paused) continue
89
+ assertRunnerExecutable(route.provider, cfg)
90
+ }
91
+ const capacity = new C2MultiGroupCapacity({ maxConcurrent: cfg.agent.maxConcurrent })
92
+ const quarantine = createC2V2SourceQuarantine([
93
+ process.env.SBOT_C2_V2_BLOCK_SOURCE_ID, process.env.SBOT_C2_V2_BLOCK_SOURCE_ID_2],
94
+ { required: process.env.SBOT_C2_V2_REQUIRE_BLOCK_SOURCE_ID === '1' })
95
+ if (process.env.SBOT_C2_V2_REQUIRE_BLOCK_SOURCE_ID !== undefined &&
96
+ !['0', '1'].includes(process.env.SBOT_C2_V2_REQUIRE_BLOCK_SOURCE_ID)) {
97
+ throw new Error('C2 source quarantine requirement must be 0 or 1')
98
+ }
42
99
  secureDataDir(stateDir)
43
100
  const lockFile = path.join(stateDir, 'daemon.lock')
44
101
  const lock = require('../src/core/instancelock').acquire(lockFile)
45
- if (!lock.ok) throw new Error('C2 v2 development service cannot hold the single-writer lock')
102
+ if (!lock.ok) throw new Error('C2 multi-group service cannot hold the single-writer lock')
103
+ try {
104
+ const registered = require('../src/c2install').registerAddedGroups(installation)
105
+ if (registered.length) process.stderr.write(`C2 multi-group · registered ${registered.length} new group(s) with empty journals\n`)
106
+ } catch (error) { lock.release(); throw error }
107
+ const assertWriterLock = () => {
108
+ try { return fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid) }
109
+ catch { return false }
110
+ }
46
111
  let running = false
112
+ let service
113
+ let ui
114
+ let configControl
115
+ let liveTransport
116
+ let onSigint, onSigterm
47
117
  try {
48
- // The old daemon records interrupted work here and consumes it on restart.
49
- // Starting v2 while such a file exists would leave that work invisible;
50
- // never consume, delete, or reinterpret it without an explicit migration.
118
+ const migrationFence = require('../src/c2migration').loadC2MigrationFence({ stateDir, routes })
119
+ const incidentQuarantine = require('../src/core/c2sourcequarantine').loadC2IncidentQuarantine(stateDir)
120
+ const combinedQuarantine = { rejects: msg => !!quarantine?.rejects(msg) ||
121
+ !!migrationFence?.rejects(msg) || incidentQuarantine.rejects(msg) }
51
122
  for (const name of ['pending-turns.json', 'engine-receipts.json']) {
123
+ if (fs.existsSync(path.join(stateDir, name))) throw new Error('Legacy pending authority needs explicit migration')
124
+ }
125
+ // A journal that cannot be opened or audited holds only its own group.
126
+ const journals = new Map()
127
+ const holds = new Map()
128
+ for (const route of routes) {
52
129
  try {
53
- fs.lstatSync(path.join(stateDir, name))
130
+ const journal = C2DurableJournal.open(route.file, validators)
131
+ assertJournalScope(journal, route.chatId, { requireSettled: preflightOnly || route.paused })
132
+ journals.set(route.chatId, journal)
54
133
  } catch (error) {
55
- if (error.code === 'ENOENT') continue
56
- throw error
134
+ if (!assertWriterLock()) throw error
135
+ holds.set(route.chatId, error?.message || String(error))
57
136
  }
58
- throw new Error(`C2 v2 cannot start with legacy ${name}; preserve it and drain with the old service or use a reviewed migration`)
59
- }
60
- const { createC2V2SourceQuarantine } = require('../src/core/c2sourcequarantine')
61
- const requireQuarantine = process.env.SBOT_C2_V2_REQUIRE_BLOCK_SOURCE_ID
62
- if (requireQuarantine !== undefined &&
63
- requireQuarantine !== '0' && requireQuarantine !== '1') {
64
- throw new Error('C2 v2 source quarantine requirement must be 0 or 1')
65
137
  }
66
- const sourceQuarantine = createC2V2SourceQuarantine([
67
- process.env.SBOT_C2_V2_BLOCK_SOURCE_ID,
68
- process.env.SBOT_C2_V2_BLOCK_SOURCE_ID_2],
69
- { required: requireQuarantine === '1' })
70
- const assertWriterLock = () => {
71
- try { return fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid) }
72
- catch { return false }
73
- }
74
- const { C2DurableJournal } = require('../src/core/c2durablejournal')
75
- const { validatePersistentState } = require('../src/core/c2persiststate')
76
- const { validatePersistentReceipts, validatePersistentRelation } = require('../src/core/c2persistrelation')
77
- const { createC2ServiceStartGate } = require('../src/core/c2servicestartgate')
78
- const { C2PayloadHandles } = require('../src/core/c2payloadhandles')
79
- const { createC2LocalEffectExecutor, resolveC2LocalEffectTurn } =
80
- require('../src/core/c2localeffectexecutor')
81
- const { createFeishuRecoverySender } = require('../src/core/c2platformrecoverysender')
82
- const journal = C2DurableJournal.open(file, { validateState: validatePersistentState,
83
- validateReceipts: validatePersistentReceipts,
84
- validateRelation: validatePersistentRelation })
85
- const beforeRecovery = journal.snapshot()
86
- if (preflightOnly && (beforeRecovery.receipts.pending.length !== 0 ||
87
- Object.keys(beforeRecovery.state.lanes).length !== 0 ||
88
- [...beforeRecovery.effects.values()].some(status => status !== 'acked') ||
89
- [...beforeRecovery.notices.values()].some(notice =>
90
- notice.status === 'attempting' || notice.status === 'unknown'))) {
91
- throw new Error('C2 v2 service preflight requires a settled journal; use recovery in a controlled service window')
92
- }
93
- const limits = { maxEventCount: 100_000,
94
- maxJournalBytes: 64 * 1024 * 1024,
95
- maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
96
- const { routeBackend } = require('../src/agent/privatecore')
97
- const { C2DurableRuntime } = require('../src/core/c2durableruntime')
98
- const { coordinateC2GlobalFault } = require('../src/core/c2globalfault')
138
+ // All group Engines must share this exact object: separate Store caches
139
+ // would overwrite sibling sessions when they save the same state file.
140
+ const store = new StateStore(path.join(stateDir, 'state.json'))
99
141
  const closeChecks = {
100
142
  claude: require('../src/agent/claudecode').isClosedRunnerResult,
101
143
  'codex-exec': require('../src/agent/codexcode').isClosedRunnerResult,
102
144
  'codex-app': require('../src/agent/codexapp').isClosedRunnerResult,
103
145
  }
104
- const validators = { validateState: validatePersistentState,
105
- validateReceipts: validatePersistentReceipts,
106
- validateRelation: validatePersistentRelation }
107
- const createTransport = () => {
108
- const client = feishuClient(cfg.feishu.appId, cfg.feishu.appSecret)
109
- const adapter = createFeishuPlatform({ appId: cfg.feishu.appId,
110
- appSecret: cfg.feishu.appSecret, botOpenId: cfg.feishu.botOpenId,
111
- onlyWhenMentioned: cfg.feishu.onlyWhenMentioned === true,
112
- ws: cfg.feishu.ws || {}, card: cfg.feishu.card || {}, client })
113
- const platform = createMultiPlatform({ adapters: { feishu: adapter },
114
- resolve: chatId => chatPlatform(cfg, chatId) })
115
- const sender = createFeishuRecoverySender({ cfg, platform, client })
116
- for (const [chatId] of groups) sender.resolveTarget({ id: '0'.repeat(32),
117
- chatId, source: 'message', sourceId: 'om_c2_service_probe' })
118
- return { platform, sender, stopPlatform: () => adapter.stop({ strict: true }) }
119
- }
120
- const buildBinding = async authorityJournal => {
121
- const { platform, sender, stopPlatform } = createTransport()
122
- const gate = createC2ServiceStartGate({ enabled: true,
123
- journal: authorityJournal, assertWriterLock,
124
- resolveTarget: sender.resolveTarget, send: sender.send,
125
- singleNoticePerGroup: true, ...limits })
126
- try {
127
- await gate.start()
128
- const runtime = gate.runtime
129
- if (!runtime || runtime.blocked || !assertWriterLock()) {
130
- throw new Error('C2 v2 development service recovery did not open ingress')
131
- }
132
- const handles = new C2PayloadHandles({ journal: authorityJournal })
133
- handles.reopenAfterRecovery()
134
- if (handles.blocked || runtime.snapshot().cursor !== authorityJournal.snapshot().cursor) {
135
- throw new Error('C2 v2 development service payload authority differs after recovery')
136
- }
137
- const store = new StateStore(path.join(stateDir, 'state.json'))
138
- const closedResults = new WeakSet()
139
- const startTurn = async input => {
140
- const backend = routeBackend(input.provider?.runner, input.codexApp,
141
- input.codexSteer, input.codexProjectDocFallback)
142
- const result = await runner.runTurn(input)
143
- if (closeChecks[backend]?.(result) === true) closedResults.add(result)
144
- return result
145
- }
146
- let engine
147
- const applyLocalEffect = createC2LocalEffectExecutor({
148
- journal: authorityJournal, store,
149
- verifyFile: path.join(stateDir, 'verify.json'), assertWriterLock,
150
- resolveTurn: identity => resolveC2LocalEffectTurn(engine, identity) })
151
- const { Engine } = require('../src/core/v2engine')
152
- engine = new Engine(cfg, { runner, store, log: message =>
153
- process.stderr.write(`C2 v2 · ${message}\n`),
154
- durableV2Runtime: runtime, durableV2Payloads: handles,
155
- durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
156
- durableV2VerifyRunnerClosed: ({ result }) => !!result &&
157
- typeof result === 'object' && closedResults.has(result),
158
- durableV2AssertWriterLock: assertWriterLock,
159
- durableV2Journal: authorityJournal, durableV2NormalRoute: true })
160
- return { journal: authorityJournal, platform, sender, stopPlatform, gate,
161
- runtime, handles, store, engine }
162
- } catch (error) { gate.stop(); throw error }
163
- }
164
- let active = await buildBinding(journal)
165
- if (preflightOnly) {
166
- active.gate.stop()
167
- process.stdout.write('SintBot startup preflight passed; Feishu socket stayed disconnected\n')
168
- return
146
+ // A runner that failed from its own exit event has closed; this frees its
147
+ // model permit but never proves the turn's outcome.
148
+ const closeErrorChecks = {
149
+ claude: require('../src/agent/claudecode').isClosedRunnerError,
150
+ 'codex-exec': require('../src/agent/codexcode').isClosedRunnerError,
151
+ 'codex-app': require('../src/agent/codexapp').isClosedRunnerError,
169
152
  }
170
- let stopping = false
171
- let rebuilds = 0
153
+ const limits = { maxEventCount: 100_000, maxJournalBytes: 64 * 1024 * 1024,
154
+ maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
172
155
  let finishing = false
173
- const finish = (reason, code, platformStopped, drained) => {
156
+ const finish = result => {
174
157
  if (finishing) return
175
158
  finishing = true
159
+ ui?.close()
176
160
  lock.release()
177
- process.stderr.write(`C2 v2 · ${reason}; platform stopped=${platformStopped}; runner drained=${drained}\n`,
178
- () => process.exit(code))
161
+ process.stderr.write(`C2 multi-group · ${result.reason}; platform stopped=${result.platformStopped}; runner drained=${result.drained}\n`,
162
+ () => process.exit(result.code))
179
163
  }
180
- const activeTurns = snapshot => {
181
- const lanes = snapshot.state.lanes
182
- const turns = []
183
- for (const chatId of new Set([...Object.keys(lanes.turn || {}),
184
- ...Object.keys(lanes.handoff || {}),
185
- ...Object.keys(lanes.lifecycle || {})])) {
186
- const turn = lanes.turn?.[chatId]
187
- const handoff = lanes.handoff?.[chatId]
188
- const lifecycle = lanes.lifecycle?.[chatId]
189
- if (turn?.active != null || handoff?.phase && handoff.phase !== 'idle' ||
190
- lifecycle?.busy === true || lifecycle?.controller === true) {
191
- turns.push({ chatId, turnToken: turn?.active ?? handoff?.turnToken ?? null })
164
+ service = createC2MultiGroupService({ routes, assertWriterLock, quarantine: combinedQuarantine,
165
+ holds, closeCapacity: () => capacity.close(),
166
+ // Faults in a shared resource stop every group; others hold one group.
167
+ sharedFault: () => capacity.snapshot().closed ? 'model concurrency closed' : null,
168
+ log: message => process.stderr.write(`C2 multi-group · ${message}\n`),
169
+ onFatal: (error, result) => {
170
+ process.stderr.write(`C2 multi-group failure: ${error?.message || String(error)}\n`)
171
+ finish(result)
172
+ },
173
+ createTransport: () => {
174
+ const client = feishuClient(cfg.feishu.appId, cfg.feishu.appSecret)
175
+ const adapter = createFeishuPlatform({ appId: cfg.feishu.appId,
176
+ appSecret: cfg.feishu.appSecret, botOpenId: cfg.feishu.botOpenId,
177
+ onlyWhenMentioned: cfg.feishu.onlyWhenMentioned === true,
178
+ ws: cfg.feishu.ws || {}, card: cfg.feishu.card || {}, client })
179
+ const platform = createMultiPlatform({ adapters: { feishu: adapter },
180
+ resolve: chatId => chatPlatform(cfg, chatId) })
181
+ const sender = createFeishuRecoverySender({ cfg, platform, client })
182
+ liveTransport = { platform, sender, stopPlatform: () => adapter.stop({ strict: true }) }
183
+ return liveTransport
184
+ },
185
+ createRecoveryAuthority: ({ route, transport, assertWriterLock: lock }) => {
186
+ const nextJournal = C2DurableJournal.open(route.file, validators)
187
+ const nextRuntime = new C2DurableRuntime({ journal: nextJournal,
188
+ resolveTarget: transport.sender.resolveTarget, send: transport.sender.send,
189
+ assertWriterLock: lock, singleNoticePerGroup: true, ...limits })
190
+ return { journal: nextJournal, runtime: nextRuntime }
191
+ },
192
+ createBinding: async ({ route, transport, journal: recoveredJournal = null }) => {
193
+ if (recoveredJournal) journals.set(route.chatId, recoveredJournal)
194
+ let journal = journals.get(route.chatId)
195
+ if (!journal) {
196
+ // A group added or resumed online opens its own mapped journal here.
197
+ const stat = fs.lstatSync(route.file)
198
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('C2 group journal is not a real initialized file')
199
+ journal = C2DurableJournal.open(route.file, validators)
200
+ assertJournalScope(journal, route.chatId)
201
+ journals.set(route.chatId, journal)
192
202
  }
193
- }
194
- return turns.sort((a, b) => a.chatId.localeCompare(b.chatId))
203
+ const gate = createC2ServiceStartGate({ enabled: true, journal, assertWriterLock,
204
+ resolveTarget: transport.sender.resolveTarget, send: transport.sender.send,
205
+ singleNoticePerGroup: true, ...limits })
206
+ try {
207
+ await gate.start()
208
+ const runtime = gate.runtime
209
+ if (!runtime || runtime.blocked || !assertWriterLock()) throw new Error('C2 group recovery did not open ingress')
210
+ const handles = new C2PayloadHandles({ journal })
211
+ handles.reopenAfterRecovery()
212
+ if (handles.blocked) throw new Error('C2 group payload authority stayed closed')
213
+ let engine
214
+ const applyLocalEffect = createC2LocalEffectExecutor({ journal, store,
215
+ verifyFile: path.join(stateDir, 'verify.json'), assertWriterLock,
216
+ resolveTurn: identity => resolveC2LocalEffectTurn(engine, identity) })
217
+ const closedResults = new WeakSet()
218
+ const closedErrors = new WeakSet()
219
+ const startTurn = async input => {
220
+ const backend = routeBackend(input.provider?.runner, input.codexApp,
221
+ input.codexSteer, input.codexProjectDocFallback)
222
+ let result
223
+ try { result = await runner.runTurn(input) } catch (error) {
224
+ if (closeErrorChecks[backend]?.(error) === true) closedErrors.add(error)
225
+ throw error
226
+ }
227
+ if (closeChecks[backend]?.(result) === true) closedResults.add(result)
228
+ return result
229
+ }
230
+ engine = new Engine({ ...cfg, groups: { [route.chatId]: route.group } }, {
231
+ store, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
232
+ durableV2Runtime: runtime, durableV2Payloads: handles,
233
+ durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
234
+ durableV2VerifyRunnerClosed: ({ result, error }) => result
235
+ ? typeof result === 'object' && closedResults.has(result)
236
+ : !!error && typeof error === 'object' && closedErrors.has(error),
237
+ durableV2AssertWriterLock: assertWriterLock, durableV2Journal: journal,
238
+ durableV2AcquireCapacity: options => capacity.acquire(options),
239
+ // A route fault holds or recovers its group alone. Shared grants
240
+ // are sealed only when the process writer lease itself is gone.
241
+ durableV2SealCapacity: error => {
242
+ if (assertWriterLock() !== true) capacity.close(error)
243
+ },
244
+ durableV2SwitchProvider: (chatId, name) => {
245
+ if (chatId !== route.chatId) throw new Error('Model switch targets another group')
246
+ configControl.switchGroupProvider(chatId, name)
247
+ },
248
+ durableV2UsageProbe: usageProbe, durableV2Issues: issues,
249
+ durableV2Version: require('../package.json').version,
250
+ durableV2NormalRoute: true, durableV2StreamFrames: streamFrames(cfg) })
251
+ return { journal, gate, runtime, engine, handles }
252
+ } catch (error) { gate.stop(); throw error }
253
+ } })
254
+ // One configuration writer for the console and the chat /model command.
255
+ configControl = require('../src/c2configcontrol').createC2ConfigControl({
256
+ installation, getBindings: () => service.bindings, getHeld: () => service.held,
257
+ assertWriterLock, store,
258
+ activateRoute: route => service.addRoute(route),
259
+ log: message => process.stderr.write(`C2 multi-group · ${message}\n`) })
260
+ const stop = reason => { void service.stop(reason, 0).then(finish) }
261
+ onSigint = () => stop('SIGINT')
262
+ onSigterm = () => stop('SIGTERM')
263
+ process.on('SIGINT', onSigint)
264
+ process.on('SIGTERM', onSigterm)
265
+ if (uiEnabled && !preflightOnly) {
266
+ ui = require('../src/c2console').createC2Console({
267
+ configPath: cfg._path, stateDir, host: uiHost,
268
+ getConfig: () => cfg, getBindings: () => service.bindings,
269
+ getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
270
+ getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
271
+ assertWriterLock, ...configControl,
272
+ log: message => process.stderr.write(`Console · ${message}\n`) })
273
+ await new Promise((resolve, reject) => {
274
+ ui.server.once('error', reject)
275
+ ui.listen(uiPort, uiHost, () => { ui.server.off('error', reject); resolve() })
276
+ })
277
+ process.stderr.write(`SintBot console listening on port ${uiPort}\n`)
195
278
  }
196
- const launch = async binding => {
197
- const onFatal = createC2ReceiptIngressFatalHook({
198
- log: message => process.stderr.write(`C2 v2 · ${message}\n`),
199
- shutdown: reason => shutdown(binding, reason, 1) })
200
- const unsupported = source => () => {
201
- void onFatal(new Error(`C2 v2 unsupported Feishu ${source} source`))
202
- }
203
- await binding.platform.start((msg, sourceAdapter) => {
204
- if (binding !== active || binding.gate.blocked || !assertWriterLock()) {
205
- throw new Error('C2 v2 development service ingress lost durable authority')
206
- }
207
- if (msg?.chatId !== serviceChatId) {
208
- throw new Error('C2 v2 development service received a message outside its single group')
209
- }
210
- if (sourceQuarantine?.rejects(msg)) {
211
- process.stderr.write('C2 v2 · quarantined historical source message before receipt\n')
212
- return { quarantined: true }
213
- }
214
- return binding.engine.runDurableV2Message(msg, sourceAdapter)
215
- }, { onFatal, onRecall: unsupported('recall'),
216
- onReaction: unsupported('reaction'),
217
- onCardAction: unsupported('card action'),
218
- onBoardUpdated: unsupported('board update') })
219
- process.stderr.write('SintBot single-group Feishu ingress connected\n')
220
- }
221
- const shutdown = async (binding, reason, code = 1) => {
222
- if (stopping || binding !== active) return
223
- stopping = true
224
- binding.gate.stop()
225
- binding.engine.shutdown()
226
- let stopTimer
227
- const platformStopped = await Promise.race([
228
- Promise.resolve().then(() => binding.stopPlatform()).then(value => value === true, () => false),
229
- new Promise(resolve => {
230
- stopTimer = setTimeout(() => resolve(false), 2_000)
231
- }),
232
- ])
233
- clearTimeout(stopTimer)
234
- const drained = await binding.engine.drainDurableV2(12_000).catch(() => false)
235
- // A failed route may have already settled, leaving a closed child and
236
- // active durable receipt for global-fault reconciliation. A still-live
237
- // route or child cannot be replaced, even if the drain timer expired.
238
- const quiescent = [...binding.engine.rts.values()].every(rt =>
239
- !rt.v2Controller && !rt.v2NormalRouteActive)
240
- if (code === 0 || !platformStopped || !quiescent || !assertWriterLock() ||
241
- rebuilds >= 1) {
242
- finish(reason, code === 0 && platformStopped && drained ? 0 : 1,
243
- platformStopped, drained)
244
- return
245
- }
246
- rebuilds++
247
- try {
248
- const current = binding.journal.snapshot()
249
- const turns = activeTurns(current)
250
- const proofs = turns.length
251
- ? binding.engine.durableV2ActiveFaultProofs?.(turns) || null : null
252
- const result = await coordinateC2GlobalFault({
253
- runtime: binding.runtime, journal: binding.journal, assertWriterLock,
254
- abortTurn: turn => binding.engine.abortDurableV2FaultTurn?.(turn) === true,
255
- activeTurnProofs: proofs,
256
- createAuthority: () => {
257
- const nextJournal = C2DurableJournal.open(file, validators)
258
- const nextRuntime = new C2DurableRuntime({ journal: nextJournal,
259
- resolveTarget: binding.sender.resolveTarget,
260
- send: binding.sender.send, assertWriterLock,
261
- singleNoticePerGroup: true, ...limits })
262
- return { journal: nextJournal, runtime: nextRuntime }
263
- } })
264
- if (result.status !== 'recovered' || !assertWriterLock()) {
265
- finish(`C2 v2 recovery sealed: ${result.reason || 'writer lock lost'}`,
266
- 1, platformStopped, drained)
267
- return
268
- }
269
- result.runtime.close()
270
- const next = await buildBinding(result.journal)
271
- active = next
272
- // A fatal callback may arrive synchronously while the fresh platform
273
- // starts. It must not be swallowed by the old binding's stop guard.
274
- stopping = false
275
- await launch(next)
276
- process.stderr.write('C2 v2 process-local authority rebuilt\n')
277
- } catch (error) {
278
- finish(`C2 v2 recovery failed: ${error?.message || String(error)}`,
279
- 1, platformStopped, drained)
280
- }
279
+ const started = await service.start({ preflightOnly })
280
+ if (preflightOnly) {
281
+ process.stdout.write(`SintBot startup preflight passed for ${started.groups} groups; Feishu socket stayed disconnected\n`)
282
+ return
281
283
  }
282
- process.on('SIGINT', () => { void shutdown(active, 'SIGINT', 0) })
283
- process.on('SIGTERM', () => { void shutdown(active, 'SIGTERM', 0) })
284
284
  process.on('exit', lock.release)
285
- try {
286
- await launch(active)
287
- running = true
288
- } catch (error) {
289
- active.gate.stop()
290
- active.engine.shutdown()
291
- let timer
292
- await Promise.race([
293
- Promise.resolve().then(() => active.stopPlatform()).catch(() => false),
294
- new Promise(resolve => { timer = setTimeout(resolve, 2_000) }),
295
- ])
296
- clearTimeout(timer)
297
- await active.engine.drainDurableV2(12_000).catch(() => false)
298
- throw error
285
+ running = true
286
+ process.stderr.write(`SintBot Feishu ingress connected for ${started.groups} groups\n`)
287
+ for (const item of started.held || []) {
288
+ process.stderr.write(`SintBot group ${item.chatId} is held: ${item.reason}\n`)
299
289
  }
300
- } finally { if (!running) lock.release() }
290
+ } catch (error) {
291
+ ui?.close()
292
+ const stopped = await service?.stop('startup-failed', 1)
293
+ if (stopped && (!stopped.startupStopped || !stopped.platformStopped || !stopped.drained)) {
294
+ process.stderr.write('C2 multi-group startup cleanup was not confirmed; exiting with evidence preserved\n',
295
+ () => process.exit(1))
296
+ }
297
+ throw error
298
+ } finally {
299
+ if (!running) {
300
+ if (onSigint) process.off('SIGINT', onSigint)
301
+ if (onSigterm) process.off('SIGTERM', onSigterm)
302
+ capacity.close(); lock.release()
303
+ }
304
+ }
301
305
  }
302
306
 
303
307
  // The opt-in receipt authority cannot keep accepting Feishu messages after
@@ -321,16 +325,23 @@ function fail(message) {
321
325
  process.exitCode = 1
322
326
  }
323
327
 
324
- const HELP = `SintBot macOS Feishu single-group release
328
+ const HELP = `SintBot macOS Feishu multi-group candidate
325
329
  Usage:
326
- sintbot init [--config <absolute path>]
327
- sintbot start [--config <absolute path>] [--preflight]
330
+ sintbot init [--config <absolute path>] [--data-dir <absolute path>]
331
+ sintbot start [--config <absolute path>] [--data-dir <absolute path>] [--preflight] [--ui] [--ui-host <address>] [--ui-port <port>] [--no-open]
332
+ sintbot incident seal --group <oc_…> [--config <absolute path>] [--data-dir <absolute path>] [--reason <text>]
328
333
  sintbot version
329
334
  sintbot help
330
335
 
331
- Supports one explicit Feishu group using Codex. Start runs in the foreground.
336
+ Supports explicit Feishu groups using Codex or Claude Code. Start runs in the foreground.
332
337
  Initialization is for a new installation only; existing journals are never reset.
333
- Canvas, multiple groups, console, scheduled tasks, terminal chat and other platforms are unavailable.
338
+ Console shows live groups, binds groups and switches models or projects of idle groups;
339
+ in a group, /model lists or switches models. Unsupported actions stay disabled.
340
+ Canvas, scheduled tasks, terminal chat and other platforms are unavailable.
341
+ A group whose last result cannot be proven is held alone; the other groups keep serving.
342
+ Restart first: a journal that can recover reopens on its own. If the group is still held,
343
+ stop the service, run incident seal for that group, then start again.
344
+ Sealing keeps the old journal unchanged; unknown effects are never replayed.
334
345
  `
335
346
 
336
347
  async function main(argv = process.argv.slice(2)) {
@@ -345,15 +356,32 @@ async function main(argv = process.argv.slice(2)) {
345
356
  process.stdout.write(HELP)
346
357
  return
347
358
  }
348
- if (!['init', 'start'].includes(sub)) {
349
- throw new Error(`${sub} is unavailable in this single-group release; use sintbot help`)
359
+ if (!['init', 'start', 'incident'].includes(sub)) {
360
+ throw new Error(`${sub} is unavailable in this release; use sintbot help`)
350
361
  }
351
362
  if (process.platform !== 'darwin') throw new Error('this release supports macOS only')
363
+ if (sub === 'incident') {
364
+ const [action, ...args] = rest
365
+ if (rest.some(arg => arg === '--help' || arg === '-h')) { process.stdout.write(HELP); return }
366
+ if (action !== 'seal') throw new Error('Usage: sintbot incident seal --group <oc_…> [--config <path>] [--data-dir <path>]')
367
+ const { values } = parseArgs({ args, allowPositionals: false, options: {
368
+ config: { type: 'string' }, 'data-dir': { type: 'string' },
369
+ group: { type: 'string' }, reason: { type: 'string' } } })
370
+ const installation = require('../src/c2install').loadInstallation(values.config,
371
+ { dataDir: values['data-dir'], allowAddedGroups: true })
372
+ const result = require('../src/c2incident').sealC2GroupIncident({ installation,
373
+ chatId: values.group, ...(values.reason !== undefined ? { reason: values.reason } : {}),
374
+ version: require('../package.json').version })
375
+ process.stdout.write(`Sealed the group's previous journal unchanged in ${result.incident}.\n` +
376
+ `${result.refusedSources} unsettled message(s) will not be processed again. ` +
377
+ 'Start the service to reopen the group with a new journal.\n')
378
+ return
379
+ }
352
380
  if (sub === 'init') {
353
381
  const { values } = parseArgs({ args: rest, allowPositionals: false,
354
- options: { config: { type: 'string' } } })
355
- require('../src/c2install').initializeInstallation(values.config)
356
- process.stdout.write('SintBot journal initialized; run sintbot start to connect the configured group.\n')
382
+ options: { config: { type: 'string' }, 'data-dir': { type: 'string' } } })
383
+ require('../src/c2install').initializeInstallation(values.config, { dataDir: values['data-dir'] })
384
+ process.stdout.write('SintBot journal initialized; run sintbot start to connect the configured groups.\n')
357
385
  return
358
386
  }
359
387
  await startService(rest)