sintbot 1.4.3 → 1.4.5

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/src/c2console.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const path = require('node:path')
4
4
  const { createUiServer } = require('./ui/serverbase')
5
5
  const { START_PATH, CALLBACK_PATH } = require('./ui/feishulogin')
6
+ const { assertJournalScope } = require('./c2install')
6
7
 
7
8
  // A host facade over existing v2 authorities, not a second execution engine.
8
9
  // The allowlist applies after the existing console's authentication boundary.
@@ -10,13 +11,23 @@ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
10
11
  '/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
11
12
  '/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
12
13
  '/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
13
- '/api/feishu/chats', '/api/feishu/chat-members', START_PATH, CALLBACK_PATH]
14
+ '/api/feishu/chats', '/api/feishu/chat-members', '/api/update', '/api/claude/models',
15
+ START_PATH, CALLBACK_PATH]
14
16
  const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
15
17
  '/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
16
18
  '/api/groups', '/api/groups/provider', '/api/fs/mkdir',
17
19
  '/api/groups/order', '/api/groups/policy', '/api/groups/redact',
18
20
  '/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
21
+ // A fresh session only for an idle, settled group (same rule as /new).
22
+ '/api/groups/new', '/api/groups/interrupt',
19
23
  '/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
24
+ // The model library goes through the same validated writer: an edit that
25
+ // changes what a group runs waits for that group and starts a new session.
26
+ '/api/providers/add', '/api/provider-key', '/api/providers/hidden', '/api/providers/order',
27
+ '/api/default-provider', '/api/claude/verify',
28
+ // One headless trial reply outside every journal and session (like a
29
+ // one-off agent run); it does not take a group concurrency slot.
30
+ '/api/test',
20
31
  '/api/inbox/dismiss', '/api/issues/status',
21
32
  // Software updates: check and ignore anywhere; install only when launchd
22
33
  // manages this exact entry (the updater refuses every other run mode).
@@ -24,9 +35,10 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
24
35
  const SUPPORTED_RUNNERS = ['claude', 'codex']
25
36
 
26
37
  function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
27
- assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true, now = Date.now } = {}) {
38
+ assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
39
+ appliedResets = () => [], now = Date.now } = {}) {
28
40
  for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
29
- validateConfig, applyConfig, beforeWrite, now]) {
41
+ validateConfig, applyConfig, beforeWrite, appliedResets, now]) {
30
42
  if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
31
43
  }
32
44
  if ([validateConfig, applyConfig, beforeWrite].some(callback => callback.constructor?.name === 'AsyncFunction')) {
@@ -35,7 +47,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
35
47
  const startedAt = now()
36
48
  const capabilities = Object.freeze({ mode: 'durable-v2',
37
49
  reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
38
- summary: '实时状态、认证管理、停止任务、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改。开新会话、解除绑定、控制台插话、定时任务和画布暂不可用。',
50
+ summary: '实时状态、认证管理、停止任务、空闲群开新会话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。解除绑定、移除模型、控制台插话、定时任务和画布暂不可用。',
39
51
  allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
40
52
  function held() {
41
53
  if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
@@ -45,6 +57,33 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
45
57
  if (!(value instanceof Map)) throw new TypeError('C2 console bindings must be a Map')
46
58
  return value
47
59
  }
60
+ // The engine's /new guard, read from the same runtime and journal the live
61
+ // view uses: a running, admitted, controlled or queued turn, any receipt of
62
+ // this group's journal not yet settled, or an outbound effect still in
63
+ // flight keeps the current session.
64
+ function groupBusy(chatId, binding) {
65
+ const rt = binding.engine?.rts?.get(chatId)
66
+ const snapshot = binding.journal?.snapshot()
67
+ return !!(rt?.v2NormalRouteActive || rt?.v2AdmissionPending || rt?.v2Controller ||
68
+ rt?.v2QueueReceiptIds?.length || snapshot?.receipts?.pending?.length ||
69
+ snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length ||
70
+ binding.gate?.blocked || binding.runtime?.blocked)
71
+ }
72
+ function clearSession(chatId) {
73
+ held()
74
+ const binding = bindings().get(chatId)
75
+ if (!binding) throw new Error('此群未运行,未开新会话')
76
+ if (binding.runtime?.closed === true) throw new Error('此群当前未开放,未开新会话')
77
+ const busy = '该群有任务进行中或记录未结账,先停止或等它结束再开新会话'
78
+ if (groupBusy(chatId, binding)) throw new Error(busy)
79
+ // The configuration writer's settled check as well: an effect not yet
80
+ // acknowledged or a notice of unknown outcome also keeps the session.
81
+ try { assertJournalScope(binding.journal, chatId, { requireSettled: true }) } catch (error) {
82
+ throw new Error(busy, { cause: error })
83
+ }
84
+ binding.engine.store.resetSessionAndStats(chatId)
85
+ return true
86
+ }
48
87
  function getLiveState() {
49
88
  const cfg = getConfig()
50
89
  const capacity = getCapacity()
@@ -109,6 +148,8 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
109
148
  validateConfig(raw) { held(); validateConfig(raw) },
110
149
  beforeWrite(raw) { held(); beforeWrite(raw) },
111
150
  applyConfig(raw) { held(); applyConfig(raw) },
151
+ // Groups whose session the last applied configuration write reset.
152
+ appliedResets: () => appliedResets(),
112
153
  decorateState(state) {
113
154
  state.runtimeCapabilities = capabilities
114
155
  state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
@@ -126,6 +167,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
126
167
  if (!binding) throw new Error('此群未运行,未执行停止')
127
168
  return binding.engine.stop(chatId)
128
169
  },
170
+ clearSession,
171
+ // A durable stop settles later, so a busy group is never reset here:
172
+ // the operator stops it first and starts the new session once idle.
173
+ interrupt(chatId) {
174
+ clearSession(chatId)
175
+ return { wasBusy: false }
176
+ },
129
177
  },
130
178
  }
131
179
  }
@@ -19,6 +19,10 @@ const progress = require('./turnprogress')
19
19
  const NO_REPLY = 'NO_REPLY'
20
20
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
21
21
  const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
22
+ // Permission tiers the manifest describes to the agent as read-only: 1 is
23
+ // the Codex read-only sandbox, 3 the fail-closed default. 0 is full access
24
+ // and 2 an allow-listed tool set, which may still change files.
25
+ const READ_ONLY_TIERS = Object.freeze([1, 3])
22
26
  const DURABLE_V2_COMMANDS = new Set(['/help', '/status', '/model', '/new', '/hud', '/issue'])
23
27
  function fmtWan(n) {
24
28
  n = Number(n) || 0
@@ -33,21 +37,43 @@ function humanDuration(ms) {
33
37
  if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
34
38
  return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
35
39
  }
40
+ // Group texts name the project by its folder only, never by a machine path.
41
+ function projectName(group) {
42
+ return String(group?.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
43
+ }
36
44
  const T = {
37
45
  queued: n => n > 1 ? `⏳ 已排队 — 当前任务结束后与前面 ${n - 1} 条一并处理` : '⏳ 已排队 — 当前任务结束后处理',
38
46
  stopped: '已停止',
39
47
  noActiveTask: '没有正在运行的任务',
40
- unsupportedCommand: '这个命令暂不支持。要停止当前任务请发 /stop,其他需求直接用文字说明。',
48
+ unsupportedCommand: '这个命令暂不支持。只放弃手头这件发 /skip,连排队的一起取消发 /stop;其他需求直接用文字说明。',
41
49
  unsupportedCommandWithModel: '这个命令暂不支持,发 /help 查看可用命令;其他需求直接用文字说明。',
42
- help: steer => ['【可用命令】',
43
- '/status 本群状态:模型、项目、是否在忙',
44
- '/model 查看模型;/model 名字 切换(会开始新的一段对话)',
45
- '/new 开始新的一段对话',
46
- '/stop 停止当前任务,连同排队的一起取消',
47
- '/hud 额度与本段对话的用量',
48
- '/issue 一句话 记下问题反馈',
49
- steer ? '直接发文字就是派任务;任务进行中再发的消息会补充进当前任务。'
50
- : '直接发文字就是派任务;任务进行中再发的消息会排队,等这一轮结束再处理。'].join('\n'),
50
+ // Plain text: 【】 sections and "· " items, each read in one glance, no
51
+ // alignment. Commands go by urgency; the three cancel commands rise in what
52
+ // they discard and describe the queue and the conversation in the same words.
53
+ help: ({ project, model, steer, readOnly }) => ['【这是什么】',
54
+ `· 本群绑着项目「${project}」,模型 ${model}`,
55
+ readOnly ? '· 直接问它,它看代码、答问题;之前聊的都记得'
56
+ : '· 直接说事它就干,做完在群里回你;之前聊的都记得',
57
+ steer ? '· 任务进行中再发的消息会补充进当前任务'
58
+ : '· 任务进行中再发的消息会排队,等这一轮结束再处理',
59
+ '',
60
+ '【命令】',
61
+ '· /status — 跑了多久、还有没有动静、排队几条',
62
+ '· /skip — 只放弃手头这件,排队的继续;之前聊的还记得',
63
+ '· /stop — 手头和排队的全取消;之前聊的还记得',
64
+ '· /new — 忘掉之前聊的,从头开始(要等手头任务结束)',
65
+ '· /model — 本群在用的模型;换:/model 名字(要等手头任务结束,换完之前聊的不再带着)',
66
+ '· /hud — 额度与本段对话的用量',
67
+ '· /issue 一句话 — 记下问题反馈,给管理员看',
68
+ '· /help — 这份说明',
69
+ // Not "trailing text is ignored": /stop with text after it is not recognised.
70
+ '⚠ 命令单独发一条,后面别跟别的字(/issue、/model 除外)',
71
+ // Same boundary as handleRecall: a queued message, or the task running on
72
+ // that message alone; a steered follow-up takes its whole task with it.
73
+ steer ? '⚠ 撤回自己发的消息 = 取消那件事;撤回补进当前任务的那条,会连当前任务一起取消'
74
+ : '⚠ 撤回自己发的消息 = 取消那件事(已和别的消息合成一轮在做的除外)',
75
+ ...(readOnly ? ['⚠ 本群为只读模式:能看代码、答问题;要改文件需管理员放开权限'] : []),
76
+ ].join('\n'),
51
77
  status: ({ model, project, state, doing = [], queue, session }) => ['【本群状态】', `· 模型:${model}`,
52
78
  `· 项目:${project}`, `· 状态:${state}`,
53
79
  ...(doing.length ? [`· 最近在做:${doing.join(' · ')}`] : []),
@@ -71,6 +97,9 @@ const T = {
71
97
  stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
72
98
  stopUnavailable: '当前任务正在收尾,暂时无法停止。',
73
99
  stoppedWithQueue: n => `已停止 — 连同排队的 ${n} 条一并取消`,
100
+ skipped: n => n > 0 ? `已跳过当前任务(后面还有 ${n} 条排队,接着处理)` : '已跳过当前任务',
101
+ skipUnavailable: '当前任务正在收尾,暂时无法跳过。',
102
+ skipPending: '正在跳过当前任务,这条消息没有处理,请稍后重发。',
74
103
  quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
75
104
  cardFooter: ({ ms, tokens }) => {
76
105
  const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
@@ -347,10 +376,20 @@ async _durableV2Command(msg, cmd, rest) {
347
376
  const group = this._resolveGroup(msg.chatId)
348
377
  if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
349
378
  const { name, provider } = this._resolveProvider(msg.chatId, group)
350
- const label = (n, p) => p?.model ? `${n} · ${p.model}` : n
379
+ // A group may pin its own model; the runner uses it before the provider default.
380
+ const label = (n, p) => (group.model || p?.model) ? `${n} · ${group.model || p.model}` : n
351
381
  if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
352
382
  if (cmd === '/help') {
353
- return reject('help', T.help(provider?.runner === 'codex' && group.codexSteer !== false))
383
+ const isCodex = provider?.runner === 'codex'
384
+ // Read-only exactly when the manifest tells the agent so, for either
385
+ // runner; an unreadable policy reads as read-only.
386
+ let readOnly = true
387
+ try {
388
+ readOnly = READ_ONLY_TIERS.includes(manifestPolicy(group.permissionMode, isCodex,
389
+ group.allowedTools, group.disallowedTools).tier)
390
+ } catch {}
391
+ return reject('help', T.help({ project: projectName(group), model: label(name, provider),
392
+ steer: isCodex && group.codexSteer !== false, readOnly }))
354
393
  }
355
394
  if (cmd === '/status') {
356
395
  const rt = this.rts.get(msg.chatId)
@@ -360,8 +399,7 @@ async _durableV2Command(msg, cmd, rest) {
360
399
  const state = busy
361
400
  ? `运行中(已跑 ${humanDuration(Date.now() - (rt.v2StartedAt || Date.now()))}${rt.v2Steps ? ` · 已执行 ${rt.v2Steps} 步` : ''}${rt.v2AliveAt ? ` · 最近动静 ${humanDuration(Date.now() - rt.v2AliveAt)}前` : ''})`
362
401
  : rt?.v2AdmissionPending ? '消息已登记,等待并发名额' : '空闲'
363
- const project = String(group.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
364
- return reject('status', T.status({ model: label(name, provider), project, state,
402
+ return reject('status', T.status({ model: label(name, provider), project: projectName(group), state,
365
403
  doing: busy && Array.isArray(rt.v2Doing) ? rt.v2Doing : [], queue: queued,
366
404
  session: this.store.getSession(msg.chatId) ? '进行中(记着上下文)' : '未开始' }))
367
405
  }
@@ -437,6 +475,9 @@ async runDurableV2Message(msg, platform) {
437
475
  if (msg?.text?.trim() === '/stop') {
438
476
  return this.runDurableV2Stop(msg, platform)
439
477
  }
478
+ if (typeof msg?.text === 'string' && /^\/skip(?:\s|$)/.test(msg.text.trim())) {
479
+ throw await this.runDurableV2Skip(msg)
480
+ }
440
481
  if (!this.durableV2NormalRoute) {
441
482
  const error = new Error('C2 v2 normal route is closed: runner transcript, tool/stream effects, and terminal projection lack durable evidence')
442
483
  error.code = 'C2_V2_NORMAL_ROUTE_CLOSED'
@@ -501,6 +542,12 @@ async runDurableV2Message(msg, platform) {
501
542
  throw rejectC2Input('C2 v2 ordinary route is waiting for stop acknowledgement',
502
543
  { reason: 'stop-pending', notice: T.stopPending })
503
544
  }
545
+ // Between /skip and its durable cancellation the runner may already be
546
+ // closed; refuse before receipt instead of reaching the closed busy route.
547
+ if (rt.v2SkipWaiter) {
548
+ throw rejectC2Input('C2 v2 ordinary route is waiting for /skip to settle',
549
+ { reason: 'skip-pending', notice: T.skipPending })
550
+ }
504
551
  const lane = before.state?.lanes
505
552
  const queue = lane?.queue?.[msg.chatId]?.queue
506
553
  if (rt.v2AdmissionPending) {
@@ -850,6 +897,79 @@ async runDurableV2Stop(msg, platform) {
850
897
  }
851
898
  }
852
899
 
900
+ // /skip cancels only the running turn, through the same durable cancellation
901
+ // as the console stop: queued receipts stay queued and run next, and the
902
+ // session is kept. Like the other chat commands it gets no receipt and is
903
+ // answered as an input rejection, here only after the cancellation has
904
+ // committed. That reply is best effort: the durable stop acknowledgement
905
+ // exists only for /stop, whose transition empties the queue.
906
+ async runDurableV2Skip(msg) {
907
+ const authority = this.durableV2Runtime
908
+ const chatId = msg?.chatId
909
+ if (!this.durableV2NormalRoute || !authority ||
910
+ !msg || msg.synthetic || msg.cron || msg.polled || msg.quiet ||
911
+ msg.wantsAck || msg.batch ||
912
+ typeof chatId !== 'string' || !chatId ||
913
+ typeof msg.msgId !== 'string' ||
914
+ !/^om_[A-Za-z0-9_-]{1,124}$/.test(msg.msgId) ||
915
+ msg.replyCtx?.chatId !== chatId ||
916
+ msg.replyCtx?.messageId !== msg.msgId) {
917
+ throw new Error('C2 v2 /skip is unsupported or closed')
918
+ }
919
+ const group = this._resolveGroup(chatId)
920
+ if (!group || group.paused || group.platform && group.platform !== 'feishu') {
921
+ throw new Error('C2 v2 /skip group control is not migrated')
922
+ }
923
+ // As for /stop, a /skip with nothing it may cancel is refused before receipt.
924
+ const reject = (reason, notice) => rejectC2Input(reason === 'skipped'
925
+ ? 'C2 v2 /skip answered after durable cancellation'
926
+ : 'C2 v2 /skip is unsupported or closed', { reason, notice })
927
+ // Without a receipt nothing durable dedupes this command, so a platform
928
+ // redelivery must not cancel a later task: a repeat this process has seen
929
+ // is dropped silently, and so is a /skip sent before the newest message of
930
+ // the running task (both are platform times).
931
+ const seen = this.durableV2SkipSeen || (this.durableV2SkipSeen = new Set())
932
+ const key = `${chatId}\n${msg.msgId}`
933
+ if (seen.has(key)) return reject('skip-duplicate', null)
934
+ seen.add(key)
935
+ if (seen.size > 256) seen.delete(seen.values().next().value)
936
+ const rt = this.rts.get(chatId)
937
+ if (!rt?.v2NormalRouteActive || !rt.v2RunningJobs?.length) {
938
+ return reject('skip-idle', T.noActiveTask)
939
+ }
940
+ const sentAt = Number(msg.createTimeMs)
941
+ if (Number.isSafeInteger(sentAt) && sentAt > 0 && rt.v2RunningJobs.some(job =>
942
+ Number(job?.msg?.createTimeMs) > sentAt)) {
943
+ return reject('skip-stale', null)
944
+ }
945
+ const route = rt.v2NormalRoutePromise
946
+ let settle
947
+ const settled = new Promise(resolve => { settle = resolve })
948
+ const waiter = { turnToken: rt.v2TurnToken, settle }
949
+ if (rt.v2SkipWaiter || !route || !Number.isSafeInteger(waiter.turnToken)) {
950
+ return reject('skip-unavailable', T.skipUnavailable)
951
+ }
952
+ rt.v2SkipWaiter = waiter
953
+ try {
954
+ if (!this.requestDurableV2Stop(chatId)) {
955
+ if (authority.blocked) throw new Error('C2 v2 /skip closed its durable authority')
956
+ return reject('skip-unavailable', T.skipUnavailable)
957
+ }
958
+ const queued = await Promise.race([settled, route.then(() => null)])
959
+ if (queued === null) {
960
+ // The turn ended without this cancellation, e.g. its runner was proven
961
+ // never started. Nothing was skipped and the group is not at fault;
962
+ // only a closed authority is.
963
+ if (authority.blocked) throw new Error('C2 v2 /skip cancellation was not durably settled')
964
+ return reject('skip-unavailable', T.skipUnavailable)
965
+ }
966
+ this.log(`/skip: ${chatId} (by ${msg.senderName || 'unknown'}), ${queued} queued`)
967
+ return reject('skipped', T.skipped(queued))
968
+ } finally {
969
+ if (rt.v2SkipWaiter === waiter) rt.v2SkipWaiter = null
970
+ }
971
+ }
972
+
853
973
  async _cleanupDurableV2CancelledCard(rt, identity, generation,
854
974
  verifyGeneration) {
855
975
  if (!rt.v2CardBinding?.opened) return null
@@ -963,6 +1083,13 @@ async _drainDurableV2NormalChain(initial) {
963
1083
  terminalEffectId: null, cancellationEventId: cancelled.eventId })
964
1084
  }
965
1085
  if (authority.blocked) throw new Error('C2 v2 cancellation closed ingress')
1086
+ // A chat /skip answers once its own turn is durably cancelled; what
1087
+ // stays in the queue is the work that runs next.
1088
+ const skip = rt.v2SkipWaiter
1089
+ if (!stop && skip?.turnToken === identity.turnToken) {
1090
+ rt.v2SkipWaiter = null
1091
+ skip.settle(authority.snapshot().state.lanes.queue?.[identity.chatId]?.queue?.length || 0)
1092
+ }
966
1093
  const nextReceiptId = this._nextDurableV2NormalAnchor(identity.chatId)
967
1094
  if (nextReceiptId) {
968
1095
  const next = this.commitDurableV2InitialDispatch({
@@ -1642,6 +1769,13 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1642
1769
  rt.v2RunningJobs = null
1643
1770
  rt.v2DispatchEffect = null
1644
1771
  rt.v2RunnerNotStarted = true
1772
+ // A console stop or /skip asked of this turn has nothing left to
1773
+ // cancel; carried over, it would cancel the group's next turn.
1774
+ if (!rt.v2StopControl) {
1775
+ rt.v2CancelControl = null
1776
+ rt.v2StreamQuiescing = false
1777
+ rt.v2StopIntentGate = null
1778
+ }
1645
1779
  return { effectId, accepted: false, startFailureRecovered: true }
1646
1780
  }
1647
1781
  if (capability && !accepted) {