@dcrays/dcgchat 0.4.12 → 0.4.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat",
3
- "version": "0.4.12",
3
+ "version": "0.4.18",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/agent.ts CHANGED
@@ -66,30 +66,30 @@ export async function onCreateAgent(params: Record<string, any>) {
66
66
  } catch (err: unknown) {
67
67
  dcgLogger(`agents.update failed: ${String(err)}`, 'error')
68
68
  }
69
- if (description?.trim()) {
70
- try {
71
- await sendMessageToGateway(
72
- JSON.stringify({
73
- method: 'agents.files.set',
74
- params: { agentId: clone_code, name: 'IDENTITY.md', content: description.trim() }
75
- })
76
- )
77
- } catch {
78
- // Non-fatal
79
- }
80
- }
81
- if (name?.trim()) {
82
- try {
83
- await sendMessageToGateway(
84
- JSON.stringify({
85
- method: 'agents.files.set',
86
- params: { agentId: clone_code, name: 'USER.md', content: name.trim() }
87
- })
88
- )
89
- } catch {
90
- // Non-fatal
91
- }
92
- }
69
+ // if (description?.trim()) {
70
+ // try {
71
+ // await sendMessageToGateway(
72
+ // JSON.stringify({
73
+ // method: 'agents.files.set',
74
+ // params: { agentId: clone_code, name: 'IDENTITY.md', content: description.trim() }
75
+ // })
76
+ // )
77
+ // } catch {
78
+ // // Non-fatal
79
+ // }
80
+ // }
81
+ // if (name?.trim()) {
82
+ // try {
83
+ // await sendMessageToGateway(
84
+ // JSON.stringify({
85
+ // method: 'agents.files.set',
86
+ // params: { agentId: clone_code, name: 'USER.md', content: name.trim() }
87
+ // })
88
+ // )
89
+ // } catch {
90
+ // // Non-fatal
91
+ // }
92
+ // }
93
93
  copyAgentsFiles(clone_code)
94
94
  sendEvent({ ...params, status: 'ok' })
95
95
  }
package/src/bot.ts CHANGED
@@ -17,7 +17,7 @@ import { dcgLogger } from './utils/log.js'
17
17
  import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
18
18
  import { sendGatewayRpc } from './gateway/socket.js'
19
19
  import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
20
- import { getChildSessionKeysTrackedForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
20
+ import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
21
21
 
22
22
  type MediaInfo = {
23
23
  path: string
@@ -30,7 +30,7 @@ type TFileInfo = { name: string; url: string }
30
30
 
31
31
  const mediaMaxBytes = 300 * 1024 * 1024
32
32
 
33
- /** 当前会话最近一次 agent run 的 runId(供 chat.abort 精确打断;无则仅传 sessionKey 仍会中止该会话全部活动运行) */
33
+ /** 当前会话最近一次 agent run 的 runId(供观测;/stop 时对子会话与主会话使用不传 runId 的 chat.abort 以终止该键下全部活跃 run) */
34
34
  const activeRunIdBySessionKey = new Map<string, string>()
35
35
 
36
36
  /**
@@ -143,7 +143,6 @@ const typingCallbacks = createTypingCallbacks({
143
143
  * 处理一条用户消息,调用 Agent 并返回回复
144
144
  */
145
145
  export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
146
- let completeText = ''
147
146
  const config = getOpenClawConfig()
148
147
  if (!config) {
149
148
  dcgLogger('no config available', 'error')
@@ -251,7 +250,8 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
251
250
 
252
251
  const sentMediaKeys = new Set<string>()
253
252
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
254
- let streamedTextLen = 0
253
+ /** Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
254
+ let lastStreamSnapshot = ''
255
255
 
256
256
  if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
257
257
  const workspaceDir = getWorkspaceDir()
@@ -326,38 +326,32 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
326
326
  dcgLogger(`interrupt command: ${text}`)
327
327
  const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
328
328
  sendFinal(ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }, 'abort')
329
- sendText('会话已终止', outboundCtx)
330
329
  sessionStreamSuppressed.add(dcgSessionKey)
331
- const abortOneSession = async (sessionKey: string) => {
330
+ // 网关侧彻底中止:子 agent 使用独立 sessionKey,仅 abort 主会话不会停子 run;自深到浅 abort 后代,再 abort 主会话;不传 runId 以终止该键下全部活跃 chat run
331
+ const descendantKeys = getDescendantSessionKeysForRequester(dcgSessionKey)
332
+ const abortSubKeys = [...descendantKeys].reverse()
333
+ if (abortSubKeys.length > 0) {
334
+ dcgLogger(`interrupt: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
335
+ }
336
+ for (const subKey of abortSubKeys) {
332
337
  try {
333
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
338
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
334
339
  } catch (e) {
335
- dcgLogger(`chat.abort ${sessionKey}: ${String(e)}`, 'error')
340
+ dcgLogger(`chat.abort subagent ${subKey}: ${String(e)}`, 'error')
336
341
  }
337
342
  }
338
- const keysToAbort = new Set<string>(getChildSessionKeysTrackedForRequester(dcgSessionKey))
339
343
  try {
340
- const listed = await sendGatewayRpc<{ sessions?: Array<{ key?: string }> }>({
341
- method: 'sessions.list',
342
- params: { spawnedBy: dcgSessionKey, limit: 256 }
343
- })
344
- for (const s of listed?.sessions ?? []) {
345
- const k = typeof s?.key === 'string' ? s.key.trim() : ''
346
- if (k) keysToAbort.add(k)
347
- }
344
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey } })
348
345
  } catch (e) {
349
- dcgLogger(`sessions.list spawnedBy: ${String(e)}`, 'error')
350
- }
351
- for (const sk of keysToAbort) {
352
- await abortOneSession(sk)
346
+ dcgLogger(`chat.abort main ${dcgSessionKey}: ${String(e)}`, 'error')
353
347
  }
354
- await abortOneSession(dcgSessionKey)
348
+ activeRunIdBySessionKey.delete(dcgSessionKey)
355
349
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
350
+ clearSentMediaKeys(msg.content.message_id)
356
351
  resetSubagentStateForRequesterSession(dcgSessionKey)
357
352
  setMsgStatus(dcgSessionKey, 'finished')
358
- clearSentMediaKeys(msg.content.message_id)
359
353
  clearParamsMessage(dcgSessionKey)
360
- clearParamsMessage(userId)
354
+ sendText('会话已终止', outboundCtx)
361
355
  sendFinal(outboundCtx, 'stop')
362
356
  return
363
357
  } else {
@@ -394,22 +388,27 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
394
388
  onPartialReply: async (payload: ReplyPayload) => {
395
389
  if (sessionStreamSuppressed.has(dcgSessionKey)) return
396
390
 
397
- if (payload.text) {
398
- completeText = payload.text
399
- }
400
391
  // --- Streaming text chunks ---
401
392
  if (payload.text) {
402
- const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
403
- ? payload.text.slice(streamedTextLen)
404
- : payload.text
393
+ const t = payload.text
394
+ let delta = ''
395
+ if (t.startsWith(lastStreamSnapshot)) {
396
+ delta = t.slice(lastStreamSnapshot.length)
397
+ lastStreamSnapshot = t
398
+ } else if (lastStreamSnapshot.startsWith(t)) {
399
+ // 快照缩短(模型修订等):不重复下发
400
+ } else {
401
+ // 与上一轮快照不衔接(常见于工具后快照从新的助手片段重新开始):整段下发
402
+ delta = t
403
+ lastStreamSnapshot = t
404
+ }
405
405
  if (delta.trim()) {
406
406
  const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
407
407
  streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
408
408
  sendChunk(delta, outboundCtx, prev)
409
409
  }
410
- streamedTextLen = payload.text.length
411
410
  } else {
412
- dcgLogger(`onPartialReply no text: ${JSON.stringify(payload)}`, 'error')
411
+ dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
413
412
  }
414
413
  // --- Media from payload ---
415
414
  const mediaList = resolveReplyMediaList(payload)
package/src/channel.ts CHANGED
@@ -14,6 +14,7 @@ import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw
14
14
  import { dcgLogger, setLogger } from './utils/log.js'
15
15
  import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
16
16
  import { startDcgchatGatewaySocket } from './gateway/socket.js'
17
+ import { getCronJobsPath, readCronJob } from './cron.js'
17
18
 
18
19
  function dcgchatChannelCfg(): DcgchatConfig {
19
20
  return (getOpenClawConfig()?.channels?.["dcgchat"] as DcgchatConfig | undefined) ?? {}
@@ -61,6 +62,8 @@ export type DcgchatMediaSendOptions = {
61
62
  sessionKey: string
62
63
  mediaUrl?: string
63
64
  text?: string
65
+ /** 定时任务等场景须与 `getCronMessageId` 一致,避免沿用 map 里上一条用户消息的 messageId */
66
+ messageId?: string
64
67
  }
65
68
 
66
69
  function normalizeSessionTarget(rawTo: string): string {
@@ -69,6 +72,29 @@ function normalizeSessionTarget(rawTo: string): string {
69
72
  return getParamsMessage(cleaned)?.sessionKey?.trim() || cleaned
70
73
  }
71
74
 
75
+ /**
76
+ * OpenClaw 定时任务 `sessionTarget: isolated` 时,出站 `to` 常为 `agent:<code>:cron:<jobId>[:run:…]`,
77
+ * 与 `paramsMessageMap` / `getCronMessageId` 使用的 jobs.json `sessionKey`(mobook 用户会话)不一致,导致发文件时 sessionId、messageId 错位或缺省。
78
+ */
79
+ function extractCronJobIdFromIsolatedSessionKey(sessionKey: string): string | null {
80
+ const parts = sessionKey.split(':').filter((p) => p.length > 0)
81
+ const i = parts.findIndex((p) => p.toLowerCase() === 'cron')
82
+ if (i < 0 || i + 1 >= parts.length) return null
83
+ return parts[i + 1] ?? null
84
+ }
85
+
86
+ function resolveIsolatedCronSessionToJobSessionKey(sessionKey: string): string {
87
+ const jobId = extractCronJobIdFromIsolatedSessionKey(sessionKey)
88
+ if (!jobId) return sessionKey
89
+ const job = readCronJob(getCronJobsPath(), jobId)
90
+ const sk = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
91
+ if (!sk) {
92
+ dcgLogger(`dcgchat: cron job ${jobId} has no sessionKey in jobs.json, keep outbound key=${sessionKey}`, 'error')
93
+ return sessionKey
94
+ }
95
+ return sk
96
+ }
97
+
72
98
  /** 出站返回的 chatId:含 `dcg-cron:` 时保留原始 `to`,便于下游识别定时投递 */
73
99
  function outboundChatId(rawTo: string | undefined, normalizedTo: string): string {
74
100
  const raw = rawTo?.trim() ?? ''
@@ -76,8 +102,10 @@ function outboundChatId(rawTo: string | undefined, normalizedTo: string): string
76
102
  }
77
103
 
78
104
  export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<void> {
79
- const sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
80
- const msgCtx = getOutboundMsgParams(sessionKey)
105
+ let sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
106
+ sessionKey = resolveIsolatedCronSessionToJobSessionKey(sessionKey)
107
+ const baseCtx = getOutboundMsgParams(sessionKey)
108
+ const msgCtx = opts.messageId?.trim() ? { ...baseCtx, messageId: opts.messageId.trim() } : baseCtx
81
109
  if (!isWsOpen()) {
82
110
  dcgLogger(`outbound media skipped -> ws not open: ${opts.mediaUrl ?? ''}`)
83
111
  return
@@ -98,9 +126,13 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
98
126
  try {
99
127
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat"]?.botToken ?? ''
100
128
  const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
129
+ if (!msgCtx.sessionId) {
130
+ const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
131
+ msgCtx.sessionId = sessionId
132
+ msgCtx.agentId = agentId
133
+ }
101
134
  wsSendRaw(msgCtx, {
102
135
  response: opts.text ?? '',
103
- message_tags: { source: 'file' },
104
136
  files: [{ url, name: fileName }]
105
137
  })
106
138
  dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
@@ -254,19 +286,19 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
254
286
  const isCron = ctx.to.indexOf('dcg-cron:') >= 0
255
287
  const outboundCtx = getOutboundMsgParams(to)
256
288
  const content: Record<string, unknown> = { response: ctx.text }
257
- if (isCron) {
258
- messageId = getCronMessageId(to) || `${Date.now()}`
259
- const { sessionId, agentId } = getInfoBySessionKey(to)
260
- content.is_finish = -1
261
- content.message_tags = { source: 'cron' }
262
- const merged = mergeDefaultParams({
263
- agentId: agentId,
264
- sessionId: `${sessionId}`,
265
- messageId: messageId,
266
- real_mobook: !sessionId ? 1 : ''
267
- })
268
- wsSendRaw(merged, content)
269
- } else {
289
+ if (!isCron) {
290
+ // messageId = getCronMessageId(to) || `${Date.now()}`
291
+ // const { sessionId, agentId } = getInfoBySessionKey(to)
292
+ // content.is_finish = -1
293
+ // content.message_tags = { source: 'cron' }
294
+ // const merged = mergeDefaultParams({
295
+ // agentId: agentId,
296
+ // sessionId: `${sessionId}`,
297
+ // messageId: messageId,
298
+ // real_mobook: !sessionId ? 1 : ''
299
+ // })
300
+ // wsSendRaw(merged, content)
301
+ // } else {
270
302
  if (outboundCtx?.sessionId) {
271
303
  messageId = outboundCtx?.messageId || `${Date.now()}`
272
304
  const newCtx = { ...outboundCtx, messageId }
@@ -283,8 +315,9 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
283
315
  }
284
316
  },
285
317
  sendMedia: async (ctx) => {
286
- const isCron = ctx.to.indexOf('dcg-cron:') >= 0
287
- const to = normalizeSessionTarget(ctx.to)
318
+ const normalizedFromTo = normalizeSessionTarget(ctx.to)
319
+ const isCron = ctx.to.indexOf('dcg-cron:') >= 0 || extractCronJobIdFromIsolatedSessionKey(normalizedFromTo) !== null
320
+ const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
288
321
  const outboundCtx = getOutboundMsgParams(to)
289
322
  const msgCtx = getParamsMessage(to) ?? outboundCtx
290
323
  const cronMsgId = getCronMessageId(to)
@@ -301,7 +334,11 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
301
334
  }
302
335
 
303
336
  dcgLogger(`channel sendMedia to ${ctx.to}`)
304
- await sendDcgchatMedia({ sessionKey: to || '', mediaUrl: ctx.mediaUrl || '' })
337
+ await sendDcgchatMedia({
338
+ sessionKey: to || '',
339
+ mediaUrl: ctx.mediaUrl || '',
340
+ ...(isCron ? { messageId } : {})
341
+ })
305
342
  return {
306
343
  channel: "dcgchat",
307
344
  messageId,
package/src/cron.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import fs from 'node:fs'
3
3
  import type { IMsgParams } from './types.js'
4
- import { isWsOpen, mergeDefaultParams, sendEventMessage, sendFinal } from './transport.js'
4
+ import { isWsOpen, mergeDefaultParams, sendEventMessage, sendFinal, wsSendRaw } from './transport.js'
5
5
  import { getCronMessageId, getWorkspaceDir, getWsConnection, removeCronMessageId, setCronMessageId } from './utils/global.js'
6
6
  import { ossUpload } from './request/oss.js'
7
7
  import { dcgLogger } from './utils/log.js'
@@ -135,7 +135,7 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
135
135
  )
136
136
  sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
137
137
  }
138
- export const finishedDcgchatCron = async (jobId: string) => {
138
+ export const finishedDcgchatCron = async (jobId: string, summary: string) => {
139
139
  const id = jobId?.trim()
140
140
  if (!id) {
141
141
  dcgLogger('finishedDcgchatCron: empty jobId', 'error')
@@ -147,24 +147,23 @@ export const finishedDcgchatCron = async (jobId: string) => {
147
147
  dcgLogger(`finishedDcgchatCron: no sessionKey for job id=${id}`, 'error')
148
148
  return
149
149
  }
150
- const outboundCtx = getEffectiveMsgParams(sessionKey)
151
- const messageId = getCronMessageId(sessionKey)
150
+ const messageId = getCronMessageId(sessionKey) || `${Date.now()}`
152
151
  const sessionInfo = sessionKey.split(':')
153
152
  const sessionId = sessionInfo.at(-1) ?? ''
154
153
  const agentId = sessionInfo.at(-2) ?? ''
155
- if (outboundCtx?.sessionId) {
156
- const newCtx = messageId ? { ...outboundCtx, messageId } : outboundCtx
157
- sendFinal(newCtx, 'cron send')
158
- } else {
159
- const merged = mergeDefaultParams({
160
- agentId: agentId,
161
- sessionId: `${sessionId}`,
162
- messageId: messageId,
163
- is_finish: -1,
164
- real_mobook: !sessionId ? 1 : ''
165
- })
154
+ // if (outboundCtx?.sessionId) {
155
+
156
+ const merged = mergeDefaultParams({
157
+ agentId: agentId,
158
+ sessionId: `${sessionId}`,
159
+ messageId: messageId || `${Date.now()}`,
160
+ real_mobook: !sessionId ? 1 : ''
161
+ })
162
+ wsSendRaw(merged, { response: summary, message_tags: { source: 'cron' }, is_finish: -1 })
163
+ setTimeout(() => {
166
164
  sendFinal(merged, 'cron send')
167
- }
165
+ }, 200)
166
+ // }
168
167
  const ws = getWsConnection()
169
168
  const baseContent = getParamsDefaults()
170
169
  if (isWsOpen()) {
@@ -132,14 +132,13 @@ function injectBestEffort(params: Record<string, unknown>, sk: string): Record<s
132
132
  return newParams
133
133
  }
134
134
 
135
- // job.delivery(须改 job 内对象,勿写 newParams.delivery——顶层可能不存在)
135
+ // job.delivery
136
136
  const job = newParams.job as Record<string, unknown> | undefined
137
137
  if (job?.delivery && typeof job.delivery === 'object') {
138
- const del = job.delivery as CronDelivery
139
- del.bestEffort = true
140
- del.to = `dcg-cron:${sk}`
141
- del.accountId = agentId
142
- del.channel = "dcgchat"
138
+ ;(job.delivery as CronDelivery).bestEffort = true
139
+ ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
140
+ ;(newParams.delivery as CronDelivery).accountId = agentId
141
+ ;(newParams.delivery as CronDelivery).channel = "dcgchat"
143
142
  newParams.sessionKey = sk
144
143
  return newParams
145
144
  }
package/src/tool.ts CHANGED
@@ -51,8 +51,30 @@ const eventList = [
51
51
  { event: 'after_tool_call', message: '' }
52
52
  ]
53
53
 
54
+ /** 子 agent 的 sessionKey 往往未写入 params map,回落到主会话 outbound 参数避免 messageId 缺失 */
55
+ function resolveOutboundParamsForSession(sk: string) {
56
+ const k = sk.trim()
57
+ let params = getEffectiveMsgParams(k)
58
+ if (params.messageId?.trim() || params.sessionId?.trim()) return params
59
+ const parent = requesterByChildSessionKey.get(k)
60
+ if (parent) {
61
+ const parentParams = getEffectiveMsgParams(parent)
62
+ if (parentParams.messageId?.trim() || parentParams.sessionId?.trim()) return parentParams
63
+ }
64
+ return params
65
+ }
66
+
67
+ /** 主会话已 running 时,子会话上的工具/事件也应下发(否则子 key 无 running 状态会整段丢消息) */
68
+ function isSessionActiveForTool(sk: string): boolean {
69
+ const k = sk.trim()
70
+ if (!k) return false
71
+ if (getMsgStatus(k) === 'running') return true
72
+ const parent = requesterByChildSessionKey.get(k)
73
+ return parent ? getMsgStatus(parent) === 'running' : false
74
+ }
75
+
54
76
  function sendToolCallMessage(sk: string, text: string, toolCallId: string, isCover: number) {
55
- const params = getEffectiveMsgParams(sk)
77
+ const params = resolveOutboundParamsForSession(sk)
56
78
  const content = { is_finish: -1, tool_call_id: toolCallId, is_cover: isCover, thinking_content: text, response: '' }
57
79
  wsSendRaw(params, content, false)
58
80
  }
@@ -158,6 +180,29 @@ export function getChildSessionKeysTrackedForRequester(requesterSessionKey: stri
158
180
  return out
159
181
  }
160
182
 
183
+ /**
184
+ * 自根 requester 起 BFS 收集所有已跟踪后代子会话(含嵌套)。网关 abort 时宜自深到浅,调用方对结果 `.reverse()` 后再逐个 chat.abort。
185
+ */
186
+ export function getDescendantSessionKeysForRequester(rootRequesterSessionKey: string): string[] {
187
+ const root = rootRequesterSessionKey.trim()
188
+ if (!root) return []
189
+ const ordered: string[] = []
190
+ const seen = new Set<string>()
191
+ let frontier = getChildSessionKeysTrackedForRequester(root)
192
+ while (frontier.length > 0) {
193
+ const next: string[] = []
194
+ for (const sk of frontier) {
195
+ const k = sk.trim()
196
+ if (!k || seen.has(k)) continue
197
+ seen.add(k)
198
+ ordered.push(k)
199
+ next.push(...getChildSessionKeysTrackedForRequester(k))
200
+ }
201
+ frontier = next
202
+ }
203
+ return ordered
204
+ }
205
+
161
206
  /**
162
207
  * 打断后清空本地子 agent 跟踪(runId、父子映射、子 runId→sessionKey),并唤醒 waitUntilSubagentsIdle,避免永久挂起。
163
208
  */
@@ -306,14 +351,27 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
306
351
  trackSubagentLifecycle(item.event, event, args)
307
352
  const sk = resolveHookSessionKey(item.event, args ?? {})
308
353
  if (sk) {
309
- const status = getMsgStatus(sk)
310
- if (status === 'running') {
354
+ if (isSessionActiveForTool(sk)) {
311
355
  if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
312
356
  const { result: _result, ...rest } = event
313
357
  dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
314
358
 
315
359
  if (item.event === 'before_tool_call') {
316
- return cronToolCall(rest, sk)
360
+ const hookResult = cronToolCall(rest, sk)
361
+ const text = JSON.stringify({
362
+ type: item.event,
363
+ specialIdentification: 'dcgchat_tool_call_special_identification',
364
+ callId: event.toolCallId || event.runId || Date.now().toString(),
365
+ ...rest,
366
+ status: 'running'
367
+ })
368
+ sendToolCallMessage(
369
+ sk,
370
+ text,
371
+ event.toolCallId || event.runId || Date.now().toString(),
372
+ 0
373
+ )
374
+ return hookResult
317
375
  }
318
376
  const text = JSON.stringify({
319
377
  type: item.event,
@@ -329,7 +387,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
329
387
  item.event === 'after_tool_call' ? 1 : 0
330
388
  )
331
389
  } else if (item.event) {
332
- const msgCtx = getEffectiveMsgParams(sk)
390
+ const msgCtx = resolveOutboundParamsForSession(sk)
333
391
  if (item.event === 'llm_output') {
334
392
  if (event.lastAssistant?.errorMessage === '1003-额度不足请充值') {
335
393
  const message = '您的积分已消耗完,您可以通过充值积分来继续使用'
@@ -346,7 +404,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
346
404
  params: item.message
347
405
  })
348
406
  sendToolCallMessage(sk, text, event.runId || Date.now().toString(), 0)
349
- dcgLogger(`工具调用结果: ~ event:${item.event} ${status}`)
407
+ dcgLogger(`工具调用结果: ~ event:${item.event}`)
350
408
  }
351
409
  }
352
410
  } else if (item.event !== 'before_tool_call') {
@@ -1,14 +1,17 @@
1
1
  import type { GatewayEvent } from '../gateway/index.js'
2
2
  import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
3
3
  import { dcgLogger } from './log.js'
4
- import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
- import { sendChunk } from '../transport.js'
4
+ import { clearParamsMessage, getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
+ import { sendChunk, sendFinal, sendText } from '../transport.js'
6
+ import { resetSubagentStateForRequesterSession } from '../tool.js'
7
+ import { setMsgStatus } from './global.js'
6
8
 
7
9
  /**
8
10
  * 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
9
11
  */
10
12
  export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
11
13
  try {
14
+ // 子agent消息输出
12
15
  if (msg.event === 'agent') {
13
16
  const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
14
17
  const sessionKey = getSessionKeyBySubAgentRunId(pl.runId)
@@ -19,6 +22,7 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
19
22
  }
20
23
  }
21
24
  }
25
+ // 定时任务
22
26
  if (msg.event === 'cron') {
23
27
  const p = msg.payload
24
28
  dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
@@ -31,8 +35,8 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
31
35
  if (p?.action === 'removed') {
32
36
  sendDcgchatCron(p?.jobId as string)
33
37
  }
34
- if (p?.action === 'finished') {
35
- finishedDcgchatCron(p?.jobId as string)
38
+ if (p?.action === 'finished' && p?.status === 'ok') {
39
+ finishedDcgchatCron(p?.jobId as string, p?.summary as string)
36
40
  }
37
41
  }
38
42
  } catch (error) {
@@ -108,18 +108,35 @@ export function clearSentMediaKeys(messageId?: string) {
108
108
  }
109
109
  }
110
110
 
111
- const cronMessageIdMap = new Map<string, string>()
112
-
113
- export function setCronMessageId(sk: string, messageId: string) {
114
- cronMessageIdMap.set(sk, messageId)
111
+ /** 每个 sessionKey 下多个定时任务 messageId,入队在尾、出队在头(FIFO) */
112
+ const cronMessageIdMap = new Map<string, string[]>()
113
+
114
+ export function setCronMessageId(sk: string, messageId: string | number | null | undefined) {
115
+ const mid = messageId != null && messageId !== '' ? String(messageId).trim() : ''
116
+ if (!sk?.trim() || !mid) return
117
+ let q = cronMessageIdMap.get(sk)
118
+ if (!q) {
119
+ q = []
120
+ cronMessageIdMap.set(sk, q)
121
+ }
122
+ q.push(mid)
115
123
  }
116
124
 
125
+ /** 窥视队首 messageId(不移除),供发送中与 finished 配对使用 */
117
126
  export function getCronMessageId(sk: string): string {
118
- return cronMessageIdMap.get(sk) ?? ''
127
+ if (!sk?.trim()) return ''
128
+ return cronMessageIdMap.get(sk)?.[0] ?? ''
119
129
  }
120
130
 
131
+ /** 弹出队首一条,与一次 finished 对应;队列为空时移除 key */
121
132
  export function removeCronMessageId(sk: string) {
122
- cronMessageIdMap.delete(sk)
133
+ if (!sk?.trim()) return
134
+ const q = cronMessageIdMap.get(sk)
135
+ if (!q?.length) return
136
+ q.shift()
137
+ if (q.length === 0) {
138
+ cronMessageIdMap.delete(sk)
139
+ }
123
140
  }
124
141
 
125
142
  export const getSessionKey = (content: any, accountId: string) => {