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
@@ -0,0 +1,385 @@
1
+ 'use strict'
2
+
3
+ const { isDeepStrictEqual } = require('node:util')
4
+ const { recoverMissingPayload } = require('./c2startuprecovery')
5
+ const { pruneConfirmedTerminal } = require('./c2terminalprune')
6
+ const { validatePersistentReceipts, validatePersistentRelation } = require('./c2persistrelation')
7
+
8
+ const STARTUP_EFFECT = /^sbot-c2-effect-v1:(\d+):(sbot-c2-startup-queue-v1:.+):0$/
9
+ const FEISHU_REPLY_ID = /^om_[A-Za-z0-9_-]{1,124}$/
10
+
11
+ function validTime(value) {
12
+ if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError('C2 startup time invalid')
13
+ return value
14
+ }
15
+
16
+ function startupEffect(effectId, journal) {
17
+ const match = STARTUP_EFFECT.exec(effectId)
18
+ return !!match && Number(match[1]) === Buffer.byteLength(match[2]) &&
19
+ journal.hasEvent(match[2])
20
+ }
21
+
22
+ function startupEffectForScope(effectId, journal, scope) {
23
+ const match = STARTUP_EFFECT.exec(effectId)
24
+ return !!match && startupEffect(effectId, journal) &&
25
+ match[2].startsWith(`sbot-c2-startup-queue-v1:${scope}:`)
26
+ }
27
+
28
+ function siblingProjection(snapshot, scope, journal, ownIds) {
29
+ const lanes = {}
30
+ for (const [domain, byScope] of Object.entries(snapshot.state.lanes)) {
31
+ lanes[domain] = Object.fromEntries(Object.entries(byScope)
32
+ .filter(([chatId]) => chatId !== scope))
33
+ }
34
+ const siblingReceipts = {
35
+ pending: snapshot.receipts.pending.filter(item => item.chatId !== scope),
36
+ completed: snapshot.receipts.completed.filter(item => !ownIds.has(item.id)),
37
+ }
38
+ const effects = new Map([...snapshot.effects].filter(([id]) =>
39
+ journal.effectDescriptor(id)?.scope !== scope &&
40
+ !startupEffectForScope(id, journal, scope)))
41
+ const notices = new Map([...snapshot.notices].filter(([id]) => !ownIds.has(id)))
42
+ return { lanes, receipts: siblingReceipts, effects, notices }
43
+ }
44
+
45
+ function assertSiblingsUnchanged(before, after, scope, journal) {
46
+ const ownIds = new Set(before.receipts.pending.filter(item => item.chatId === scope)
47
+ .map(item => item.id))
48
+ if (!isDeepStrictEqual(siblingProjection(before, scope, journal, ownIds),
49
+ siblingProjection(after, scope, journal, ownIds))) {
50
+ throw new Error('C2 scoped recovery changed sibling durable state')
51
+ }
52
+ }
53
+
54
+ function assertAuthority(journal, coordinator) {
55
+ const snapshot = journal.snapshot()
56
+ validatePersistentReceipts(snapshot.receipts)
57
+ validatePersistentRelation(snapshot.state, snapshot.receipts)
58
+ if (snapshot.cursor !== snapshot.state.seq ||
59
+ !isDeepStrictEqual(coordinator.snapshot(), snapshot.state)) {
60
+ throw new Error('C2 startup state differs from durable authority')
61
+ }
62
+ return snapshot
63
+ }
64
+
65
+ function unstartedEventId(proof) {
66
+ return `sbot-c2-runner-not-started-commit-v1:${proof.receiptId}:${proof.turnToken}:${proof.generation}`
67
+ }
68
+
69
+ function provedNotStartedEffect(effectId, journal) {
70
+ if (journal.effectStatus(effectId) !== 'notStarted') return false
71
+ const proofs = journal.runnerNotStartedProofs().filter(item =>
72
+ item.dispatchEffectId === effectId)
73
+ return proofs.length === 1 && journal.hasEvent(unstartedEventId(proofs[0]))
74
+ }
75
+
76
+ function resumeUnstartedDispatch(journal, coordinator, scope, assertLease) {
77
+ for (const proof of journal.runnerNotStartedProofs()) {
78
+ if (scope !== null && proof.chatId !== scope) continue
79
+ const eventId = unstartedEventId(proof)
80
+ if (journal.hasEvent(eventId)) continue
81
+ assertLease()
82
+ const before = assertAuthority(journal, coordinator)
83
+ const descriptor = journal.effectDescriptor(proof.dispatchEffectId)
84
+ if (journal.effectStatus(proof.dispatchEffectId) !== 'notStarted' ||
85
+ descriptor?.domain !== 'atomicHandoff' || descriptor.type !== 'dispatch' ||
86
+ descriptor.scope !== proof.chatId || descriptor.receiptId !== proof.receiptId ||
87
+ descriptor.turnToken !== proof.turnToken ||
88
+ !before.receipts.pending.some(item => item.id === proof.receiptId &&
89
+ item.chatId === proof.chatId) ||
90
+ before.state.lanes.turn?.[proof.chatId]?.active !== proof.turnToken ||
91
+ before.state.lanes.handoff?.[proof.chatId]?.phase !== 'running') {
92
+ throw new Error('C2 startup not-started proof differs from active dispatch')
93
+ }
94
+ const result = coordinator.transition({ id: eventId,
95
+ domain: 'runnerStartFailure', scope: proof.chatId,
96
+ payload: { type: 'abortUnstartedDispatch', receiptId: proof.receiptId,
97
+ turnToken: proof.turnToken, runnerGeneration: proof.generation,
98
+ dispatchEffectId: proof.dispatchEffectId,
99
+ notStartedEvidenceId: proof.evidenceId },
100
+ validate: candidate => {
101
+ if (candidate.effects.length ||
102
+ !isDeepStrictEqual(candidate.decision,
103
+ { abandonedReceiptId: proof.receiptId,
104
+ requiresStartupRecovery: true, scope: proof.chatId,
105
+ dispatchEffectId: proof.dispatchEffectId })) {
106
+ throw new Error('C2 startup not-started decision invalid')
107
+ }
108
+ } })
109
+ const after = assertAuthority(journal, coordinator)
110
+ if (result.decision.requiresStartupRecovery !== true ||
111
+ !isDeepStrictEqual(before.receipts, after.receipts) ||
112
+ after.state.lanes.turn?.[proof.chatId]?.active !== null ||
113
+ after.state.lanes.handoff?.[proof.chatId]?.phase !== 'idle') {
114
+ throw new Error('C2 startup not-started transition changed receipts')
115
+ }
116
+ }
117
+ }
118
+
119
+ // A process restart has no runner, controller, body or scheduled handoff
120
+ // capability. Even an atomically settled terminal receipt cannot make an old
121
+ // running turn safe to resume or let its successor begin automatically.
122
+ function assertNoUnrestorableRun(snapshot, scope = null) {
123
+ const scoped = byScope => scope === null ? Object.values(byScope || {}) :
124
+ Object.hasOwn(byScope || {}, scope) ? [byScope[scope]] : []
125
+ if (scoped(snapshot.state.lanes.stopIntent).length ||
126
+ scoped(snapshot.state.lanes.turn).some(lane => lane.active !== null) ||
127
+ scoped(snapshot.state.lanes.handoff).some(lane => lane.phase !== 'idle') ||
128
+ scoped(snapshot.state.lanes.lifecycle).some(lane =>
129
+ lane.busy === true || lane.controller === true)) {
130
+ throw new Error('C2 startup has unrecoverable running turn or handoff')
131
+ }
132
+ }
133
+
134
+ // The current journal settles one receipt per confirmed notice. Until it can
135
+ // atomically settle several receipts from one group, a restart may send at
136
+ // most one nonquiet notification per group. Ambiguous siblings stay sealed.
137
+ function assertUniqueGroupNotices(snapshot, scope = null) {
138
+ const seen = new Set()
139
+ for (const receipt of snapshot.receipts.pending) {
140
+ if (scope !== null && receipt.chatId !== scope) continue
141
+ if (receipt.quiet) continue
142
+ if (seen.has(receipt.chatId)) {
143
+ throw new Error('C2 startup has multiple visible receipts in one group')
144
+ }
145
+ seen.add(receipt.chatId)
146
+ }
147
+ }
148
+
149
+ // Read-only precondition for any future per-chat startup recovery. This does
150
+ // not open the global gate, send notices, settle receipts or change a sibling.
151
+ // A scoped writer must supply a separate queue interruption and notice path.
152
+ function assessScopedRecovery(journal, coordinator, chatId) {
153
+ if (typeof chatId !== 'string' || !chatId ||
154
+ !journal || typeof journal.snapshot !== 'function' ||
155
+ typeof coordinator?.snapshot !== 'function') {
156
+ throw new TypeError('C2 scoped recovery needs chat ID and durable authority')
157
+ }
158
+ const snapshot = assertAuthority(journal, coordinator)
159
+ const lanes = snapshot.state.lanes
160
+ const own = snapshot.receipts.pending.filter(item => item.chatId === chatId)
161
+ const visible = own.filter(item => !item.quiet)
162
+ if (visible.length > 1) throw new Error('C2 scoped recovery has multiple visible receipts')
163
+ if (lanes.turn?.[chatId]?.active != null ||
164
+ lanes.handoff?.[chatId]?.phase && lanes.handoff[chatId].phase !== 'idle' ||
165
+ lanes.lifecycle?.[chatId]?.busy === true ||
166
+ lanes.lifecycle?.[chatId]?.controller === true) {
167
+ throw new Error('C2 scoped recovery has unrecoverable target turn')
168
+ }
169
+ // Unknown external outcomes anywhere in the same journal keep the writer
170
+ // sealed. In particular, a scoped notification cannot safely infer whether
171
+ // another chat has already emitted a reply after an ACK was lost.
172
+ if ([...snapshot.effects.values()].some(status => status !== 'acked') ||
173
+ [...snapshot.notices.values()].some(notice =>
174
+ notice.status === 'attempting' || notice.status === 'unknown')) {
175
+ throw new Error('C2 scoped recovery has unresolved external outcome')
176
+ }
177
+ if (lanes.queue?.[chatId]?.queue?.length) {
178
+ throw new Error('C2 scoped recovery needs scoped queue interruption')
179
+ }
180
+ return Object.freeze({ chatId, pendingReceiptCount: own.length,
181
+ siblingPendingReceiptCount: snapshot.receipts.pending.length - own.length,
182
+ cursor: snapshot.cursor, canOpen: false,
183
+ reason: 'scoped-ingress-and-settlement-unavailable' })
184
+ }
185
+
186
+ // Development-only startup gate. The caller owns the daemon single-writer
187
+ // lock and a platform sender that returns a confirmed reply ID. A begun notice
188
+ // whose outcome is lost remains ambiguous on disk and is never retried here.
189
+ async function recoverAndOpen({ gate, journal, coordinator, resolveTarget, send,
190
+ now = Date.now, assertWriterLock = null, singleNoticePerGroup = false,
191
+ scope = null } = {}) {
192
+ if (!gate || typeof gate.close !== 'function' || typeof gate.open !== 'function' ||
193
+ typeof gate.isClosed !== 'function' || !journal ||
194
+ !['snapshot', 'hasEvent', 'effectStatus', 'effectDescriptor',
195
+ 'runnerNotStartedProofs', 'terminalSettlement',
196
+ 'noticeStatus', 'beginNotice',
197
+ 'recordNoticeOutcome', 'settleQuietReceipt', 'recordEffect'].every(name =>
198
+ typeof journal[name] === 'function') ||
199
+ !coordinator || typeof coordinator.snapshot !== 'function' ||
200
+ typeof coordinator.transition !== 'function' ||
201
+ typeof resolveTarget !== 'function' || typeof send !== 'function' ||
202
+ typeof now !== 'function' ||
203
+ (assertWriterLock !== null && typeof assertWriterLock !== 'function') ||
204
+ typeof singleNoticePerGroup !== 'boolean') {
205
+ throw new TypeError('C2 recovery gate needs durable authority and confirmed sender')
206
+ }
207
+ if (scope !== null && (typeof scope !== 'string' ||
208
+ !/^[A-Za-z0-9_-]{1,128}$/.test(scope) || gate.scope !== scope ||
209
+ typeof assertWriterLock !== 'function')) {
210
+ throw new TypeError('C2 scoped recovery needs matching scoped gate and writer lock')
211
+ }
212
+ const assertLease = () => {
213
+ if (assertWriterLock && assertWriterLock() !== true) {
214
+ throw new Error('C2 startup writer lock lost')
215
+ }
216
+ }
217
+ gate.close()
218
+ if (gate.isClosed() !== true) throw new Error('C2 startup ingress is not closed')
219
+
220
+ assertLease()
221
+ resumeUnstartedDispatch(journal, coordinator, scope, assertLease)
222
+ const beforeRecovery = assertAuthority(journal, coordinator)
223
+ assertNoUnrestorableRun(beforeRecovery, scope)
224
+ if (singleNoticePerGroup) assertUniqueGroupNotices(beforeRecovery, scope)
225
+ if (scope !== null && ([...beforeRecovery.effects].some(([effectId, status]) =>
226
+ status !== 'acked' && !provedNotStartedEffect(effectId, journal) &&
227
+ (status !== 'pending' || !startupEffectForScope(effectId, journal, scope))) ||
228
+ [...beforeRecovery.notices.values()].some(notice =>
229
+ notice.status === 'attempting' || notice.status === 'unknown'))) {
230
+ throw new Error('C2 scoped recovery has unresolved external outcome')
231
+ }
232
+ assertLease()
233
+ recoverMissingPayload({ gate, journal, coordinator, scope })
234
+ const initial = assertAuthority(journal, coordinator)
235
+ if (scope !== null) assertSiblingsUnchanged(beforeRecovery, initial, scope, journal)
236
+ assertNoUnrestorableRun(initial, scope)
237
+ if (singleNoticePerGroup) assertUniqueGroupNotices(initial, scope)
238
+ assertLease()
239
+ const pending = initial.receipts.pending.filter(item => scope === null || item.chatId === scope)
240
+ .sort((a, b) =>
241
+ a.chatId.localeCompare(b.chatId) || a.id.localeCompare(b.id))
242
+ // Prove that all receipts can be completed without a queue or handoff owner.
243
+ // Resolve every external target before the first durable notice intent.
244
+ const hypothetical = { pending: initial.receipts.pending.filter(item =>
245
+ scope !== null && item.chatId !== scope), completed: [
246
+ ...initial.receipts.completed,
247
+ ...pending.map(item => ({ id: item.id, settledAt: validTime(now()) })),
248
+ ] }
249
+ validatePersistentRelation(initial.state, hypothetical)
250
+ const completed = new Map(initial.receipts.completed.map(item => [item.id, item.settledAt]))
251
+ const terminalFinalIds = new Set(Object.values(initial.state.lanes.terminal || {})
252
+ .filter(lane => lane.finalized).map(lane => lane.pendingEffectIds.at(-1)))
253
+ for (const [effectId, status] of initial.effects) {
254
+ const descriptor = journal.effectDescriptor(effectId)
255
+ if (status === 'acked' && (terminalFinalIds.has(effectId) ||
256
+ descriptor?.domain === 'terminal' &&
257
+ ['finalReply', 'quietCompletion'].includes(descriptor.type))) {
258
+ const settlement = journal.terminalSettlement(effectId)
259
+ if (!descriptor || !settlement || settlement.effectId !== effectId ||
260
+ settlement.receiptId !== descriptor.receiptId ||
261
+ settlement.chatId !== descriptor.scope ||
262
+ settlement.turnToken !== descriptor.turnToken ||
263
+ completed.get(descriptor.receiptId) !== settlement.settledAt) {
264
+ throw new Error('C2 terminal effect ACK lacks atomic receipt settlement')
265
+ }
266
+ }
267
+ if (status !== 'acked' && !provedNotStartedEffect(effectId, journal) &&
268
+ (status !== 'pending' || !startupEffect(effectId, journal) ||
269
+ scope !== null && !startupEffectForScope(effectId, journal, scope))) {
270
+ throw new Error('C2 startup has unresolved external effect')
271
+ }
272
+ }
273
+ // A terminal ACK and its receipt may already have shared one fsynced record
274
+ // when the process died before the separate, idempotent lane prune. Keep
275
+ // ingress closed until each such lane is durably removed. No external send
276
+ // or receipt settlement is needed for this cleanup.
277
+ for (const terminalScope of (scope === null
278
+ ? Object.keys(initial.state.lanes.terminal || {}).sort()
279
+ : Object.hasOwn(initial.state.lanes.terminal || {}, scope) ? [scope] : [])) {
280
+ assertLease()
281
+ const lane = initial.state.lanes.terminal[terminalScope]
282
+ const finalEffectId = lane.pendingEffectIds?.at(-1)
283
+ if (!lane.finalized || !finalEffectId ||
284
+ !journal.terminalSettlement(finalEffectId)) {
285
+ throw new Error('C2 startup terminal lane lacks atomic settlement proof')
286
+ }
287
+ pruneConfirmedTerminal({ journal, coordinator, scope: terminalScope })
288
+ }
289
+ if (scope !== null) assertSiblingsUnchanged(initial,
290
+ assertAuthority(journal, coordinator), scope, journal)
291
+ const targets = new Map()
292
+ for (const receipt of pending) {
293
+ assertLease()
294
+ if (receipt.quiet) continue
295
+ if (journal.noticeStatus(receipt.id) !== null) {
296
+ throw new Error('C2 startup notice attempt is ambiguous')
297
+ }
298
+ const target = await resolveTarget({ id: receipt.id, chatId: receipt.chatId,
299
+ source: receipt.source, sourceId: receipt.sourceId })
300
+ assertLease()
301
+ const afterTarget = assertAuthority(journal, coordinator)
302
+ if (scope !== null) assertSiblingsUnchanged(initial, afterTarget, scope, journal)
303
+ if (!target || typeof target !== 'object' || Array.isArray(target) ||
304
+ target.receiptId !== receipt.id || target.chatId !== receipt.chatId ||
305
+ target.sourceId !== receipt.sourceId || !Object.hasOwn(target, 'replyCtx') ||
306
+ target.replyCtx == null) {
307
+ throw new Error('C2 startup notification target is unverified')
308
+ }
309
+ targets.set(receipt.id, target)
310
+ }
311
+
312
+ const settled = []
313
+ for (const receipt of pending) {
314
+ assertLease()
315
+ if (!gate.isClosed()) throw new Error('C2 startup ingress opened during recovery')
316
+ if (receipt.quiet) {
317
+ journal.settleQuietReceipt({ receiptId: receipt.id, chatId: receipt.chatId,
318
+ settledAt: validTime(now()) })
319
+ } else {
320
+ const attemptedAt = validTime(now())
321
+ journal.beginNotice({ receiptId: receipt.id, chatId: receipt.chatId, attemptedAt })
322
+ assertLease()
323
+ let replyId
324
+ try { replyId = await send({ id: receipt.id, chatId: receipt.chatId,
325
+ source: receipt.source, sourceId: receipt.sourceId }, targets.get(receipt.id)) }
326
+ catch { replyId = null }
327
+ // If the lease was lost during the SDK call, keep the fsynced attempt
328
+ // ambiguous. Another process must not treat its outcome as replayable.
329
+ assertLease()
330
+ const at = Math.max(attemptedAt, validTime(now()))
331
+ if (typeof replyId !== 'string' || !FEISHU_REPLY_ID.test(replyId)) {
332
+ journal.recordNoticeOutcome({ receiptId: receipt.id, chatId: receipt.chatId,
333
+ status: 'unknown', resolvedAt: at })
334
+ throw new Error('C2 startup notification outcome is unknown')
335
+ }
336
+ journal.recordNoticeOutcome({ receiptId: receipt.id, chatId: receipt.chatId,
337
+ status: 'acked', settledAt: at })
338
+ }
339
+ const after = assertAuthority(journal, coordinator)
340
+ if (scope !== null) assertSiblingsUnchanged(initial, after, scope, journal)
341
+ if (after.receipts.pending.some(item => item.id === receipt.id) ||
342
+ !after.receipts.completed.some(item => item.id === receipt.id)) {
343
+ throw new Error('C2 startup receipt was not durably settled')
344
+ }
345
+ settled.push(receipt.id)
346
+ }
347
+
348
+ // The interruptMissingPayload effect is an internal recovery instruction.
349
+ // It is complete only after all affected receipts have been settled.
350
+ const beforeOpen = assertAuthority(journal, coordinator)
351
+ for (const [effectId, status] of beforeOpen.effects) {
352
+ assertLease()
353
+ if (status === 'pending' && startupEffect(effectId, journal) &&
354
+ (scope === null || startupEffectForScope(effectId, journal, scope))) {
355
+ journal.recordEffect(effectId, 'acked')
356
+ }
357
+ }
358
+ const final = assertAuthority(journal, coordinator)
359
+ if (scope !== null) assertSiblingsUnchanged(initial, final, scope, journal)
360
+ if (final.receipts.pending.some(item => scope === null || item.chatId === scope) ||
361
+ (scope === null
362
+ ? Object.values(final.state.lanes.queue || {}).some(lane => lane.queue.length)
363
+ : (final.state.lanes.queue?.[scope]?.queue?.length || 0) > 0) ||
364
+ (scope === null
365
+ ? Object.keys(final.state.lanes.terminal || {}).length > 0
366
+ : Object.hasOwn(final.state.lanes.terminal || {}, scope)) ||
367
+ [...final.effects].some(([effectId, status]) =>
368
+ status !== 'acked' && !provedNotStartedEffect(effectId, journal)) ||
369
+ [...final.notices.values()].some(notice =>
370
+ notice.status === 'attempting' || notice.status === 'unknown')) {
371
+ throw new Error('C2 startup has unresolved durable work')
372
+ }
373
+ try {
374
+ assertLease()
375
+ gate.open()
376
+ if (gate.isClosed() !== false) throw new Error('C2 startup ingress did not open')
377
+ assertLease()
378
+ } catch (error) {
379
+ gate.close()
380
+ throw error
381
+ }
382
+ return { settledReceiptIds: settled, ingressClosed: false }
383
+ }
384
+
385
+ module.exports = { recoverAndOpen, assessScopedRecovery }
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+
3
+ // Assemble the same user-facing runner inputs as Engine._runTurn after the
4
+ // host has prepared its timestamped prompt and manifest. This only assembles
5
+ // process-local arguments. It neither starts a child nor persists text,
6
+ // credentials, callback handles, or environment values.
7
+ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider,
8
+ config, sessionId = null, signal, extraEnv, onTurnAccepted, onSpawnFailed, onDurableEvent,
9
+ onEvent = () => {}, onSteerReady, onSteerLanded, log = () => {} } = {}) {
10
+ if (typeof prompt !== 'string' || !prompt ||
11
+ typeof appendSystemPrompt !== 'string' || !appendSystemPrompt ||
12
+ !msg || typeof msg.chatId !== 'string' || !msg.chatId ||
13
+ !group || typeof group.workdir !== 'string' || !group.workdir ||
14
+ !provider || !['claude', 'codex'].includes(provider.runner) ||
15
+ !config || !config.agent || typeof config.agent !== 'object' ||
16
+ (sessionId !== null && (typeof sessionId !== 'string' || !sessionId)) ||
17
+ !signal || typeof signal.aborted !== 'boolean' ||
18
+ !extraEnv || typeof extraEnv !== 'object' || Array.isArray(extraEnv) ||
19
+ extraEnv.SBOT_CHAT_ID !== msg.chatId ||
20
+ Object.values(extraEnv).some(value => typeof value !== 'string') ||
21
+ typeof onTurnAccepted !== 'function' ||
22
+ (onSpawnFailed !== undefined && typeof onSpawnFailed !== 'function') ||
23
+ typeof onDurableEvent !== 'function' ||
24
+ typeof onEvent !== 'function' || typeof log !== 'function') {
25
+ throw new TypeError('C2 runner input requires an exact process-local turn')
26
+ }
27
+ const isCodex = provider.runner === 'codex'
28
+ const codexSteer = isCodex && group.codexSteer !== false
29
+ if ((onSteerReady !== undefined && typeof onSteerReady !== 'function') ||
30
+ (onSteerLanded !== undefined && typeof onSteerLanded !== 'function') ||
31
+ (codexSteer && (typeof onSteerReady !== 'function' ||
32
+ typeof onSteerLanded !== 'function'))) {
33
+ throw new TypeError('C2 Codex follow-up needs durable control callbacks')
34
+ }
35
+ const rawCompact = Number(config.agent.autocompactTokens) || 0
36
+ const autocompactTokens = rawCompact > 0
37
+ ? Math.min(1_000_000, Math.max(100_000, Math.floor(rawCompact))) : 0
38
+ return {
39
+ bin: config.agent.bin,
40
+ prompt, appendSystemPrompt, provider,
41
+ codexApp: isCodex && group.codexApp === true,
42
+ codexSteer,
43
+ codexProjectDocFallback: isCodex && group.codexProjectDocFallback === true,
44
+ onSteerReady, onSteerLanded,
45
+ model: group.model,
46
+ workdir: group.workdir,
47
+ sessionId,
48
+ permissionMode: group.permissionMode || 'dontAsk',
49
+ autocompactTokens,
50
+ allowedTools: group.allowedTools,
51
+ disallowedTools: group.disallowedTools,
52
+ plugins: group.plugins,
53
+ signal,
54
+ extraEnv: { ...extraEnv },
55
+ log,
56
+ onEvent,
57
+ onDurableEvent,
58
+ onTurnAccepted,
59
+ onSpawnFailed,
60
+ }
61
+ }
62
+
63
+ module.exports = { prepareC2RunnerInput }
@@ -0,0 +1,103 @@
1
+ 'use strict'
2
+
3
+ const { C2DurableJournal } = require('./c2durablejournal')
4
+ const { C2DurableRuntime } = require('./c2durableruntime')
5
+
6
+ // The daemon supplies its single-writer lock and installs `accept` as its
7
+ // ingress callback before calling start(). This opt-in bridge never installs
8
+ // a route or starts a service by itself. In particular, journal recovery may
9
+ // await a platform notice while the callback remains closed.
10
+ function createC2ServiceStartGate({ enabled = false, journal, assertWriterLock,
11
+ resolveTarget, send, now, maxEventCount, maxJournalBytes,
12
+ maxCompletionEvents, maxCompletionBytes, singleNoticePerGroup = false } = {}) {
13
+ if (enabled !== true || !(journal instanceof C2DurableJournal) ||
14
+ typeof assertWriterLock !== 'function' ||
15
+ assertWriterLock.constructor?.name === 'AsyncFunction' ||
16
+ typeof resolveTarget !== 'function' || typeof send !== 'function' ||
17
+ typeof singleNoticePerGroup !== 'boolean') {
18
+ throw new TypeError('C2 service start requires explicit durable authority and writer lock')
19
+ }
20
+
21
+ const runtime = new C2DurableRuntime({ journal,
22
+ resolveTarget: (...args) => {
23
+ assertHeld()
24
+ return resolveTarget(...args)
25
+ },
26
+ send: async (...args) => {
27
+ assertHeld()
28
+ const result = await send(...args)
29
+ assertHeld()
30
+ return result
31
+ }, now,
32
+ maxEventCount, maxJournalBytes, maxCompletionEvents, maxCompletionBytes,
33
+ assertWriterLock, singleNoticePerGroup })
34
+ let started = false
35
+ let starting = false
36
+
37
+ function assertHeld() {
38
+ if (assertWriterLock() !== true) {
39
+ throw new Error('C2 service writer lock is not held')
40
+ }
41
+ }
42
+
43
+ function accept(options) {
44
+ if (!started || starting || runtime.blocked) {
45
+ throw new Error('C2 service ingress is closed')
46
+ }
47
+ try {
48
+ assertHeld()
49
+ return runtime.enqueueMessage(options)
50
+ } catch (error) {
51
+ runtime.close()
52
+ started = false
53
+ throw error
54
+ }
55
+ }
56
+
57
+ async function start() {
58
+ if (starting) throw new Error('C2 service startup already in progress')
59
+ if (started) {
60
+ try { assertHeld() } catch (error) {
61
+ stop()
62
+ throw error
63
+ }
64
+ if (runtime.blocked) throw new Error('C2 service authority closed')
65
+ return runtime.snapshot()
66
+ }
67
+ starting = true
68
+ try {
69
+ assertHeld()
70
+ const snapshot = await runtime.open()
71
+ // A lock lost during an awaited notice must not expose the reopened
72
+ // runtime to the platform callback.
73
+ assertHeld()
74
+ if (runtime.blocked) throw new Error('C2 service authority remained closed')
75
+ started = true
76
+ return snapshot
77
+ } catch (error) {
78
+ runtime.close()
79
+ started = false
80
+ throw error
81
+ } finally { starting = false }
82
+ }
83
+
84
+ function stop() {
85
+ started = false
86
+ runtime.close()
87
+ }
88
+
89
+ return { start, stop, accept,
90
+ get blocked() { return !started || starting || runtime.blocked },
91
+ // An Engine integration may use the authority only after start() proves
92
+ // recovery and the writer lock. It cannot bypass the closed ingress gate.
93
+ get runtime() {
94
+ if (!started || starting || runtime.blocked) return null
95
+ try { assertHeld() } catch (error) {
96
+ stop()
97
+ throw error
98
+ }
99
+ return runtime
100
+ } }
101
+ }
102
+
103
+ module.exports = { createC2ServiceStartGate }
@@ -0,0 +1,72 @@
1
+ 'use strict'
2
+
3
+ // Pure preparation for the v2 runner-close session transition. The caller
4
+ // must use its existing single-writer coordinator to commit the returned
5
+ // event, then fsync an effect attempt before touching Store. This helper has
6
+ // no access to the model session ID after it computes the presence bit.
7
+ const { isDeepStrictEqual } = require('node:util')
8
+
9
+ const HEX32 = /^[0-9a-f]{32}$/
10
+
11
+ function prepareClosedSessionEvent({ journal, receiptId, chatId, turnToken,
12
+ generation, sessionId = null, aborted, verifyGeneration } = {}) {
13
+ if (!journal || typeof journal.snapshot !== 'function' ||
14
+ typeof journal.lookupRunnerClosed !== 'function' ||
15
+ typeof journal.effectDescriptor !== 'function' ||
16
+ typeof journal.effectStatus !== 'function' ||
17
+ !HEX32.test(receiptId || '') || !HEX32.test(generation || '') ||
18
+ typeof chatId !== 'string' || !chatId ||
19
+ !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
20
+ (sessionId !== null && (typeof sessionId !== 'string' || !sessionId)) ||
21
+ typeof aborted !== 'boolean' || typeof verifyGeneration !== 'function' ||
22
+ verifyGeneration.constructor?.name === 'AsyncFunction') {
23
+ throw new TypeError('C2 closed session needs exact runner identity')
24
+ }
25
+ const before = journal.snapshot()
26
+ const evidenceId = `sbot-c2-runner-closed-v1:${receiptId}:${generation}`
27
+ const proof = journal.lookupRunnerClosed(evidenceId)
28
+ const dispatch = proof && journal.effectDescriptor(proof.dispatchEffectId)
29
+ const pending = before.receipts?.pending?.find(item => item.id === receiptId)
30
+ const gate = before.state?.lanes?.turn?.[chatId]
31
+ const handoff = before.state?.lanes?.handoff?.[chatId]
32
+ const session = before.state?.lanes?.session?.[chatId]
33
+ if (journal.blocked || before.state?.version !== 2 ||
34
+ pending?.chatId !== chatId || proof?.receiptId !== receiptId ||
35
+ proof.chatId !== chatId || proof.turnToken !== turnToken ||
36
+ proof.generation !== generation || proof.evidenceId !== evidenceId ||
37
+ dispatch?.type !== 'dispatch' || dispatch.scope !== chatId ||
38
+ dispatch.receiptId !== receiptId || dispatch.turnToken !== turnToken ||
39
+ journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
40
+ gate?.active !== turnToken || gate.epoch !== turnToken ||
41
+ handoff?.phase !== 'running' || handoff.turnToken !== turnToken ||
42
+ (session && (session.epoch > turnToken ||
43
+ session.epoch === turnToken && session.closeSeen === true)) ||
44
+ verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
45
+ throw new Error('C2 closed session lacks accepted running runner proof')
46
+ }
47
+ const eventId = `sbot-c2-session-close-v1:${receiptId}:${generation}`
48
+ if (journal.hasEvent?.(eventId)) throw new Error('C2 session close already committed')
49
+ const payload = { type: 'runnerClosedCommit', receiptId, generation, turnToken,
50
+ closeEvidenceId: evidenceId, sessionIdPresent: sessionId !== null, aborted }
51
+ const expectedTypes = sessionId === null ? [] : aborted
52
+ ? ['writeSession', 'markTainted'] : ['writeSession']
53
+ const validate = candidate => {
54
+ const lane = candidate?.state?.lanes?.session?.[chatId]
55
+ const effects = candidate?.effects
56
+ const descriptors = candidate?.effectDescriptors
57
+ if (candidate?.eventId !== eventId || candidate?.cursor !== before.cursor + 1 ||
58
+ lane?.epoch !== turnToken || lane.closeSeen !== true || lane.settled !== false ||
59
+ !Array.isArray(effects) || !isDeepStrictEqual(effects.map(item => item.type), expectedTypes) ||
60
+ !Array.isArray(descriptors) || descriptors.length !== effects.length ||
61
+ descriptors.some((item, index) => item.effectId !== effects[index].effectId ||
62
+ item.domain !== 'session' || item.scope !== chatId ||
63
+ item.type !== effects[index].type || item.turnToken !== turnToken ||
64
+ item.receiptId !== receiptId || item.targetId !== null)) {
65
+ throw new Error('C2 closed session transition differs from accepted runner')
66
+ }
67
+ }
68
+ return Object.freeze({ id: eventId, domain: 'session', scope: chatId,
69
+ payload: Object.freeze(payload), validate })
70
+ }
71
+
72
+ module.exports = { prepareClosedSessionEvent }
@@ -0,0 +1,30 @@
1
+ 'use strict'
2
+
3
+ const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
4
+
5
+ // An operator must carry these exact source IDs across every restart of a
6
+ // restricted trial. A fresh journal alone cannot recognize messages already
7
+ // accepted by different, preserved authorities.
8
+ function createC2V2SourceQuarantine(value, { required = false } = {}) {
9
+ if (typeof required !== 'boolean') throw new TypeError('C2 source quarantine mode invalid')
10
+ const raw = Array.isArray(value) ? value : [value]
11
+ if (!raw.length || raw.length > 2 ||
12
+ (raw.length === 2 && (raw[0] === undefined || raw[0] === '') &&
13
+ raw[1] !== undefined && raw[1] !== '')) {
14
+ throw new TypeError('C2 v2 blocked source list invalid')
15
+ }
16
+ const ids = raw.filter(id => id !== undefined && id !== '')
17
+ if (!ids.length) {
18
+ if (required) throw new Error('C2 v2 formal service needs an explicit blocked source message ID')
19
+ return null
20
+ }
21
+ if (ids.some(id => typeof id !== 'string' || !FEISHU_MESSAGE_ID.test(id)) ||
22
+ new Set(ids).size !== ids.length) {
23
+ throw new TypeError('C2 v2 blocked source message ID invalid')
24
+ }
25
+ const sourceIds = Object.freeze([...ids])
26
+ return Object.freeze({ sourceId: sourceIds[0], sourceIds,
27
+ rejects: message => sourceIds.includes(message?.msgId) })
28
+ }
29
+
30
+ module.exports = { createC2V2SourceQuarantine }