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.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- 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
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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 (
|
|
56
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|