@dcrays/dcgchat-test 0.3.36 → 0.3.37

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-test",
3
- "version": "0.3.36",
3
+ "version": "0.3.37",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/bot.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import path from 'node:path'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
- import type { ReplyPayload } from 'openclaw/plugin-sdk'
5
- import { createReplyPrefixContext } from 'openclaw/plugin-sdk'
4
+ import type { PluginRuntime, ReplyPayload } from 'openclaw/plugin-sdk'
5
+ import { createPluginRuntimeStore, createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
6
6
  import type { InboundMessage } from './types.js'
7
7
  import {
8
8
  clearSentMediaKeys,
@@ -18,8 +18,9 @@ import { extractMobookFiles } from './utils/searchFile.js'
18
18
  import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
19
19
  import { dcgLogger } from './utils/log.js'
20
20
  import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
21
- import { sendMessageToGateway } from './gateway/socket.js'
22
- import { getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
21
+ import { sendGatewayRpc } from './gateway/socket.js'
22
+ import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
23
+ import { getChildSessionKeysTrackedForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
23
24
 
24
25
  type MediaInfo = {
25
26
  path: string
@@ -44,13 +45,6 @@ const sessionStreamSuppressed = new Set<string>()
44
45
  /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
45
46
  const streamChunkIdxBySessionKey = new Map<string, number>()
46
47
 
47
- /** Active LLM generation abort controllers, keyed by conversationId */
48
- // const activeGenerations = new Map<string, AbortController>()
49
-
50
- /**
51
- * Extract agentId from conversation_id formatted as "agentId::suffix".
52
- * Returns null if the conversation_id does not contain the "::" separator.
53
- */
54
48
  export function extractAgentIdFromConversationId(conversationId: string): string | null {
55
49
  const idx = conversationId.indexOf('::')
56
50
  if (idx <= 0) return null
@@ -132,7 +126,14 @@ function resolveReplyMediaList(payload: ReplyPayload): string[] {
132
126
  if (payload.mediaUrls?.length) return payload.mediaUrls.filter(Boolean)
133
127
  return payload.mediaUrl ? [payload.mediaUrl] : []
134
128
  }
135
-
129
+ const typingCallbacks = createTypingCallbacks({
130
+ start: async () => {
131
+ console.log('typing start')
132
+ },
133
+ onStartError: (err) => {
134
+ console.log('typing start error', err)
135
+ }
136
+ })
136
137
  /**
137
138
  * 处理一条用户消息,调用 Agent 并返回回复
138
139
  */
@@ -178,10 +179,6 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
178
179
  real_mobook
179
180
  }
180
181
  setParamsMessage(effectiveSessionKey, mergedParams)
181
- // 与 OpenClaw 会话投递里仍可能出现的 ctx.to=SenderId(userId)对齐,便于 getOutboundMsgParams 命中
182
- dcgLogger(
183
- `target normalize: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, conversationId=${conversationId ?? ''}, messageId=${msg.content.message_id}`
184
- )
185
182
  setParamsMessage(userId, mergedParams)
186
183
  dcgLogger(`target alias bound: aliasTarget=${userId} -> sessionKey=${effectiveSessionKey}`)
187
184
  const outboundCtx = getEffectiveMsgParams(effectiveSessionKey)
@@ -189,29 +186,15 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
189
186
  effectiveAgentId && effectiveAgentId !== 'main' ? config.agents?.list?.find((a) => a.id === effectiveAgentId) : undefined
190
187
  const agentDisplayName = agentEntry?.name ?? (effectiveAgentId && effectiveAgentId !== 'main' ? effectiveAgentId : undefined)
191
188
 
192
- const safeSendFinal = (tag: string) => {
193
- if (finalSent) return
194
- finalSent = true
195
- sendFinal(outboundCtx, tag)
196
- setMsgStatus(effectiveSessionKey, 'finished')
197
- }
198
-
199
189
  const text = msg.content.text?.trim()
200
190
 
201
191
  if (!text) {
202
192
  sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
203
- safeSendFinal('not text')
193
+ sendFinal(outboundCtx, 'not text')
204
194
  return
205
195
  }
206
196
 
207
197
  try {
208
- // Abort any existing generation for this conversation, then start a new one
209
- // const existingCtrl = activeGenerations.get(conversationId)
210
- // if (existingCtrl) existingCtrl.abort()
211
- // const genCtrl = new AbortController()
212
- // const genSignal = genCtrl.signal
213
- // activeGenerations.set(conversationId, genCtrl)
214
-
215
198
  // 处理用户上传的文件
216
199
  const files = msg.content.files ?? []
217
200
  let mediaPayload: Record<string, unknown> = {}
@@ -249,11 +232,10 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
249
232
  CommandAuthorized: true,
250
233
  OriginatingChannel: "dcgchat-test",
251
234
  OriginatingTo: effectiveSessionKey,
235
+ Target: effectiveSessionKey,
236
+ SourceTarget: effectiveSessionKey,
252
237
  ...mediaPayload
253
238
  })
254
- dcgLogger(
255
- `inbound context target: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, ctx.To=${String(ctxPayload.To ?? '')}, ctx.SessionKey=${String(ctxPayload.SessionKey ?? '')}, ctx.OriginatingTo=${String(ctxPayload.OriginatingTo ?? '')}`
256
- )
257
239
 
258
240
  const sentMediaKeys = new Set<string>()
259
241
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
@@ -266,7 +248,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
266
248
  accountId: account.accountId
267
249
  })
268
250
 
269
- const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({
251
+ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
270
252
  responsePrefix: prefixContext.responsePrefix,
271
253
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
272
254
  humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
@@ -282,7 +264,8 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
282
264
  }
283
265
  },
284
266
  onError: (err: unknown, info: { kind: string }) => {
285
- safeSendFinal('error')
267
+ setMsgStatus(effectiveSessionKey, 'finished')
268
+ sendFinal(outboundCtx, 'error')
286
269
  dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
287
270
  activeRunIdBySessionKey.delete(effectiveSessionKey)
288
271
  streamChunkIdxBySessionKey.delete(effectiveSessionKey)
@@ -292,7 +275,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
292
275
  }
293
276
  dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
294
277
  },
295
- onIdle: () => {}
278
+ onIdle: () => {
279
+ typingCallbacks.onIdle?.()
280
+ }
296
281
  })
297
282
 
298
283
  try {
@@ -302,42 +287,78 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
302
287
  }
303
288
 
304
289
  if (systemCommand.includes(text?.trim())) {
305
- dcgLogger(`dispatching /new`)
306
- await core.channel.reply.dispatchReplyFromConfig({
307
- ctx: ctxPayload,
308
- cfg: config,
290
+ dcgLogger(`dispatching ${text?.trim()}`)
291
+ await core.channel.reply.withReplyDispatcher({
309
292
  dispatcher,
310
- replyOptions: {
311
- ...replyOptions,
312
- onModelSelected: prefixContext.onModelSelected,
313
- onAgentRunStart: (runId) => {
314
- activeRunIdBySessionKey.set(effectiveSessionKey, runId)
315
- }
316
- }
293
+ onSettled: () => markDispatchIdle(),
294
+ run: () =>
295
+ core.channel.reply.dispatchReplyFromConfig({
296
+ ctx: ctxPayload,
297
+ cfg: config,
298
+ dispatcher,
299
+ replyOptions: {
300
+ ...replyOptions,
301
+ onModelSelected: prefixContext.onModelSelected,
302
+ onAgentRunStart: (runId) => {
303
+ activeRunIdBySessionKey.set(effectiveSessionKey, runId)
304
+ }
305
+ }
306
+ })
317
307
  })
318
308
  } else if (interruptCommand.includes(text?.trim())) {
319
309
  dcgLogger(`interrupt command: ${text}`)
320
310
  sendFinal(outboundCtx, 'abort')
321
311
  sendText('会话已终止', outboundCtx)
322
312
  sessionStreamSuppressed.add(effectiveSessionKey)
323
- const runId = activeRunIdBySessionKey.get(effectiveSessionKey)
324
- sendMessageToGateway(
325
- JSON.stringify({
326
- method: 'chat.abort',
327
- params: {
328
- sessionKey: effectiveSessionKey,
329
- ...(runId ? { runId } : {})
330
- }
313
+
314
+ const abortOneSession = async (sessionKey: string) => {
315
+ try {
316
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
317
+ } catch (e) {
318
+ dcgLogger(`chat.abort ${sessionKey}: ${String(e)}`, 'error')
319
+ }
320
+ }
321
+
322
+ const keysToAbort = new Set<string>(getChildSessionKeysTrackedForRequester(effectiveSessionKey))
323
+ try {
324
+ const listed = await sendGatewayRpc<{ sessions?: Array<{ key?: string }> }>({
325
+ method: 'sessions.list',
326
+ params: { spawnedBy: effectiveSessionKey, limit: 256 }
327
+ })
328
+ for (const s of listed?.sessions ?? []) {
329
+ const k = typeof s?.key === 'string' ? s.key.trim() : ''
330
+ if (k) keysToAbort.add(k)
331
+ }
332
+ } catch (e) {
333
+ dcgLogger(`sessions.list spawnedBy: ${String(e)}`, 'error')
334
+ }
335
+ for (const sk of keysToAbort) {
336
+ await abortOneSession(sk)
337
+ }
338
+ await abortOneSession(effectiveSessionKey)
339
+
340
+ try {
341
+ await sendGatewayRpc({
342
+ method: 'sessions.reset',
343
+ params: { key: effectiveSessionKey, reason: 'reset' }
331
344
  })
332
- )
333
- if (runId) activeRunIdBySessionKey.delete(effectiveSessionKey)
334
- safeSendFinal('stop')
345
+ } catch (e) {
346
+ dcgLogger(`sessions.reset: ${String(e)}`, 'error')
347
+ }
348
+
349
+ activeRunIdBySessionKey.delete(effectiveSessionKey)
350
+ streamChunkIdxBySessionKey.delete(effectiveSessionKey)
351
+ resetSubagentStateForRequesterSession(effectiveSessionKey)
352
+ setMsgStatus(effectiveSessionKey, 'finished')
353
+ clearSentMediaKeys(msg.content.message_id)
354
+ clearParamsMessage(effectiveSessionKey)
355
+ clearParamsMessage(userId)
356
+
357
+ sendFinal(outboundCtx, 'stop')
335
358
  return
336
359
  } else {
337
- dcgLogger(`dispatching to agent (session=${route.sessionKey})`)
338
360
  const params = getEffectiveMsgParams(effectiveSessionKey)
339
361
  if (!ignoreToolCommand.includes(text?.trim())) {
340
- // message_received 没有 sessionKey 前置到bot中执行
341
362
  wsSendRaw(params, {
342
363
  is_finish: -1,
343
364
  tool_call_id: Date.now().toString(),
@@ -352,70 +373,58 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
352
373
  response: ''
353
374
  })
354
375
  }
355
- await core.channel.reply.dispatchReplyFromConfig({
356
- ctx: ctxPayload,
357
- cfg: config,
376
+ await core.channel.reply.withReplyDispatcher({
358
377
  dispatcher,
359
- replyOptions: {
360
- ...replyOptions,
361
- // abortSignal: genSignal,
362
- onModelSelected: prefixContext.onModelSelected,
363
- onAgentRunStart: (runId) => {
364
- activeRunIdBySessionKey.set(effectiveSessionKey, runId)
365
- },
366
- onPartialReply: async (payload: ReplyPayload) => {
367
- if (sessionStreamSuppressed.has(effectiveSessionKey)) return
368
-
369
- // Accumulate full text
370
- if (payload.text) {
371
- completeText = payload.text
372
- }
373
- // --- Streaming text chunks ---
374
- if (payload.text) {
375
- const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
376
- ? payload.text.slice(streamedTextLen)
377
- : payload.text
378
- if (delta.trim()) {
379
- const prev = streamChunkIdxBySessionKey.get(effectiveSessionKey) ?? 0
380
- streamChunkIdxBySessionKey.set(effectiveSessionKey, prev + 1)
381
- sendChunk(delta, outboundCtx, prev)
382
- dcgLogger(`[stream]: chunkIdx=${prev} len=${delta.length} user=${msg._userId} ${delta.slice(0, 100)}`)
378
+ onSettled: () => markDispatchIdle(),
379
+ run: () =>
380
+ core.channel.reply.dispatchReplyFromConfig({
381
+ ctx: ctxPayload,
382
+ cfg: config,
383
+ dispatcher,
384
+ replyOptions: {
385
+ ...replyOptions,
386
+ onModelSelected: prefixContext.onModelSelected,
387
+ onAgentRunStart: (runId) => {
388
+ activeRunIdBySessionKey.set(effectiveSessionKey, runId)
389
+ },
390
+ onPartialReply: async (payload: ReplyPayload) => {
391
+ if (sessionStreamSuppressed.has(effectiveSessionKey)) return
392
+
393
+ if (payload.text) {
394
+ completeText = payload.text
395
+ }
396
+ // --- Streaming text chunks ---
397
+ if (payload.text) {
398
+ const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
399
+ ? payload.text.slice(streamedTextLen)
400
+ : payload.text
401
+ if (delta.trim()) {
402
+ const prev = streamChunkIdxBySessionKey.get(effectiveSessionKey) ?? 0
403
+ streamChunkIdxBySessionKey.set(effectiveSessionKey, prev + 1)
404
+ sendChunk(delta, outboundCtx, prev)
405
+ dcgLogger(`[stream]: chunkIdx=${prev} len=${delta.length} user=${msg._userId} ${delta.slice(0, 100)}`)
406
+ }
407
+ streamedTextLen = payload.text.length
408
+ } else {
409
+ dcgLogger(`onPartialReply no text: ${JSON.stringify(payload)}`, 'error')
410
+ }
411
+ // --- Media from payload ---
412
+ const mediaList = resolveReplyMediaList(payload)
413
+ for (const mediaUrl of mediaList) {
414
+ const key = getMediaKey(mediaUrl)
415
+ if (sentMediaKeys.has(key)) continue
416
+ sentMediaKeys.add(key)
417
+ await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl, text: '' })
418
+ }
383
419
  }
384
- streamedTextLen = payload.text.length
385
- }
386
- // --- Media from payload ---
387
- const mediaList = resolveReplyMediaList(payload)
388
- for (const mediaUrl of mediaList) {
389
- const key = getMediaKey(mediaUrl)
390
- if (sentMediaKeys.has(key)) continue
391
- sentMediaKeys.add(key)
392
- await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl, text: '' })
393
420
  }
394
- }
395
- }
421
+ })
396
422
  })
397
423
  }
398
424
  } catch (err: unknown) {
399
- // if (genSignal.aborted) {
400
- // wasAborted = true
401
- // dcgLogger(` generation aborted for conversationId=${conversationId}`)
402
- // } else if (err instanceof Error && err.name === 'AbortError') {
403
- // wasAborted = true
404
- // dcgLogger(` generation aborted for conversationId=${conversationId}`)
405
- // } else {
406
- // dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
407
- // }
408
- } finally {
409
- // if (activeGenerations.get(conversationId) === genCtrl) {
410
- // activeGenerations.delete(conversationId)
411
- // }
412
- }
413
- try {
414
- markRunComplete()
415
- markDispatchIdle()
416
- } catch (err) {
417
- dcgLogger(` markRunComplete||markRunComplete error: ${String(err)}`, 'error')
425
+ dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
418
426
  }
427
+
419
428
  if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
420
429
  if (sessionStreamSuppressed.has(effectiveSessionKey)) {
421
430
  sessionStreamSuppressed.delete(effectiveSessionKey)
@@ -442,11 +451,10 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
442
451
  }
443
452
  }
444
453
  }
445
- safeSendFinal('end')
446
454
  clearSentMediaKeys(msg.content.message_id)
447
-
448
- // Record session metadata
449
455
  const storePath = core.channel.session.resolveStorePath(config.session?.store)
456
+ await waitUntilSubagentsIdle(effectiveSessionKey, { timeoutMs: 600_000 })
457
+ sendFinal(outboundCtx, 'end')
450
458
  dcgLogger(
451
459
  `record session route: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, updateLastRoute.to=${effectiveSessionKey}, accountId=${route.accountId}`
452
460
  )
@@ -455,7 +463,6 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
455
463
  storePath,
456
464
  sessionKey: effectiveSessionKey,
457
465
  ctx: ctxPayload,
458
- // 与 Telegram/Discord 等一致:写入 deliveryContext.to,否则投递可能回退为 From(userId),channel sendMedia 里 ctx.to 会变成数字 userId
459
466
  updateLastRoute: {
460
467
  sessionKey: effectiveSessionKey,
461
468
  channel: "dcgchat-test",
@@ -472,7 +479,5 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
472
479
  } catch (err) {
473
480
  dcgLogger(` handle message failed: ${String(err)}`, 'error')
474
481
  sendError(err instanceof Error ? err.message : String(err), outboundCtx)
475
- } finally {
476
- safeSendFinal('finally')
477
482
  }
478
483
  }
package/src/channel.ts CHANGED
@@ -1,8 +1,15 @@
1
- import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk'
2
- import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
1
+ import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
2
+ import { createPluginRuntimeStore, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
3
3
  import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
4
4
  import { ossUpload } from './request/oss.js'
5
- import { addSentMediaKey, getCronMessageId, getInfoBySessionKey, getOpenClawConfig, hasSentMediaKey } from './utils/global.js'
5
+ import {
6
+ addSentMediaKey,
7
+ getCronMessageId,
8
+ getDcgchatRuntime,
9
+ getInfoBySessionKey,
10
+ getOpenClawConfig,
11
+ hasSentMediaKey
12
+ } from './utils/global.js'
6
13
  import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
7
14
  import { dcgLogger, setLogger } from './utils/log.js'
8
15
  import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
@@ -116,7 +123,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
116
123
  uiHints: {
117
124
  userId: {
118
125
  label: 'WS 连接 _userId',
119
- help: '仅用于拼接网关 WebSocket URL 的查询参数,不是 Agent 发消息时的 target。发消息请使用 effectiveSessionKey(与入站上下文 SessionKey 相同,格式如 agent:main:mobook:direct:…)。'
126
+ help: '仅用于拼接网关 WebSocket URL 的查询参数,不是 Agent 发消息时的 target。发消息请使用 effectiveSessionKey(与入站上下文 SessionKey 相同,格式如 agent:main:mobook:direct:<agent_id>:<session_id>)。'
120
127
  }
121
128
  }
122
129
  },
@@ -155,8 +162,9 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
155
162
  },
156
163
  agentPrompt: {
157
164
  messageToolHints: () => [
158
- '书灵墨宝:message 工具的 target 必须填 effectiveSessionKey(与当前会话 SessionKey / OriginatingTo 相同),形如 agent:main:mobook:direct:<agent_id>:<session_id>;不要填 channels.dcgchat.userId 或纯数字 userId。',
159
- 'OpenClaw 自带的 target 字段说明里仍可能出现 “user id”,在本频道请忽略该字样,一律按 effectiveSessionKey 理解。'
165
+ '生成文件后,**尽可能不要**把文件路径、地址直接告诉用户。',
166
+ '生成文件后,把文件名告诉用户。',
167
+ '生成文件后,必须调用 message 工具发送文件,不可以直接在文本回复里包含文件路径、文件名、地址。'
160
168
  ]
161
169
  },
162
170
  outbound: {
@@ -167,6 +175,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
167
175
  }
168
176
  return { ok: true, to: to }
169
177
  },
178
+ chunker: (text, limit) => getDcgchatRuntime().channel.text.chunkMarkdownText(text, limit),
170
179
  textChunkLimit: 4000,
171
180
  sendText: async (ctx) => {
172
181
  dcgLogger(`channel sendText to ${ctx.to} `)
@@ -3,8 +3,8 @@ import { WebSocket } from 'ws'
3
3
  import crypto from 'crypto'
4
4
  import { deriveDeviceIdFromPublicKey, publicKeyRawBase64UrlFromPem, buildDeviceAuthPayloadV3, signDevicePayload } from './security.js'
5
5
  import { dcgLogger } from '../utils/log.js'
6
- import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
7
6
  import { getWorkspaceDir } from '../utils/global.js'
7
+ import { handleGatewayEventMessage } from '../utils/gatewayMsgHanlder.js'
8
8
 
9
9
  export interface GatewayEvent {
10
10
  type: string
@@ -358,31 +358,7 @@ export class GatewayConnection {
358
358
  }
359
359
 
360
360
  if (msg.type === 'event') {
361
- try {
362
- // 定时任务相关事件
363
- if (msg.event === 'cron') {
364
- dcgLogger(`[Gateway] 收到事件: ${JSON.stringify(msg)}`)
365
- if (msg.payload?.action === 'added') {
366
- sendDcgchatCron(msg.payload?.jobId)
367
- }
368
- if (msg.payload?.action === 'updated') {
369
- sendDcgchatCron(msg.payload?.jobId as string)
370
- }
371
- if (msg.payload?.action === 'removed') {
372
- sendDcgchatCron(msg.payload?.jobId as string)
373
- }
374
- if (msg.payload?.action === 'finished') {
375
- finishedDcgchatCron(msg.payload?.jobId as string)
376
- }
377
- }
378
- } catch (error) {
379
- dcgLogger(`[Gateway] 处理事件失败: ${error}`, 'error')
380
- }
381
- const event: GatewayEvent = {
382
- type: msg.event as string,
383
- payload: msg.payload as Record<string, unknown> | undefined,
384
- seq: msg.seq as number | undefined
385
- }
361
+ const event = handleGatewayEventMessage(msg)
386
362
  this.eventHandlers.forEach((h) => h(event))
387
363
  }
388
364
  }
package/src/tool.ts CHANGED
@@ -2,7 +2,7 @@ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
2
2
  import { getMsgStatus } from './utils/global.js'
3
3
  import { dcgLogger } from './utils/log.js'
4
4
  import { sendFinal, sendText, wsSendRaw } from './transport.js'
5
- import { getEffectiveMsgParams } from './utils/params.js'
5
+ import { getEffectiveMsgParams, deleteSessionKeyBySubAgentRunId, setSessionKeyBySubAgentRunId } from './utils/params.js'
6
6
  import { cronToolCall } from './cronToolCall.js'
7
7
 
8
8
  type PluginHookName =
@@ -68,10 +68,244 @@ interface CronDelivery {
68
68
  [key: string]: unknown
69
69
  }
70
70
 
71
+ // --- Subagent 活跃跟踪(按主会话 requesterSessionKey)---
72
+
73
+ /** 主会话 sessionKey -> 仍活跃的子 agent runId */
74
+ const activeSubagentRunIdsByRequester = new Map<string, Set<string>>()
75
+ /** 子会话 childSessionKey -> 主会话 requesterSessionKey */
76
+ const requesterByChildSessionKey = new Map<string, string>()
77
+ /** 子会话 childSessionKey -> spawn 时的 runId(ended 事件可能不带 runId) */
78
+ const runIdByChildSessionKey = new Map<string, string>()
79
+ /** 主会话 -> 等待「子 agent 全部结束」的回调 */
80
+ const subagentIdleWaiters = new Map<string, Set<() => void>>()
81
+
82
+ function getOrCreateRunIdSet(requesterSessionKey: string): Set<string> {
83
+ let set = activeSubagentRunIdsByRequester.get(requesterSessionKey)
84
+ if (!set) {
85
+ set = new Set()
86
+ activeSubagentRunIdsByRequester.set(requesterSessionKey, set)
87
+ }
88
+ return set
89
+ }
90
+
91
+ function flushSubagentIdleWaiters(requesterSessionKey: string): void {
92
+ const set = activeSubagentRunIdsByRequester.get(requesterSessionKey)
93
+ if (set && set.size > 0) return
94
+ activeSubagentRunIdsByRequester.delete(requesterSessionKey)
95
+ const waiters = subagentIdleWaiters.get(requesterSessionKey)
96
+ if (!waiters?.size) return
97
+ subagentIdleWaiters.delete(requesterSessionKey)
98
+ for (const w of waiters) {
99
+ try {
100
+ w()
101
+ } catch (e) {
102
+ dcgLogger(`subagent idle waiter error: ${String(e)}`, 'error')
103
+ }
104
+ }
105
+ }
106
+
107
+ function registerSubagentSpawn(requesterSessionKey: string, runId: string, childSessionKey: string): void {
108
+ const req = requesterSessionKey.trim()
109
+ const rid = runId.trim()
110
+ const child = childSessionKey.trim()
111
+ if (!req || !rid || !child) {
112
+ dcgLogger(`subagent track spawn skipped: missing key req=${req} runId=${rid} child=${child}`)
113
+ return
114
+ }
115
+ getOrCreateRunIdSet(req).add(rid)
116
+ requesterByChildSessionKey.set(child, req)
117
+ runIdByChildSessionKey.set(child, rid)
118
+ dcgLogger(`subagent track spawn: requester=${req} runId=${rid} child=${child} active=${getOrCreateRunIdSet(req).size}`)
119
+ }
120
+
121
+ function registerSubagentEnd(
122
+ ctx: { requesterSessionKey?: string; sessionKey?: string },
123
+ targetSessionKey: string,
124
+ runId?: string
125
+ ): void {
126
+ const child = targetSessionKey.trim()
127
+ if (!child) return
128
+ const req = ctx.requesterSessionKey?.trim() || requesterByChildSessionKey.get(child) || ctx.sessionKey?.trim() || ''
129
+ const resolvedRunId = (runId?.trim() || runIdByChildSessionKey.get(child) || '').trim()
130
+ deleteSessionKeyBySubAgentRunId(resolvedRunId)
131
+ if (!req) {
132
+ dcgLogger(`subagent track end: no requester for child=${child} runId=${resolvedRunId}`)
133
+ requesterByChildSessionKey.delete(child)
134
+ runIdByChildSessionKey.delete(child)
135
+ return
136
+ }
137
+ const set = activeSubagentRunIdsByRequester.get(req)
138
+ if (set && resolvedRunId) {
139
+ set.delete(resolvedRunId)
140
+ }
141
+ requesterByChildSessionKey.delete(child)
142
+ runIdByChildSessionKey.delete(child)
143
+ dcgLogger(`subagent track end: requester=${req} runId=${resolvedRunId || 'n/a'} remaining=${set?.size ?? 0}`)
144
+ if (set && set.size === 0) {
145
+ activeSubagentRunIdsByRequester.delete(req)
146
+ }
147
+ flushSubagentIdleWaiters(req)
148
+ }
149
+
150
+ /** 当前跟踪到的、挂在该主会话下的子会话 sessionKey(供 /stop 时逐个 chat.abort) */
151
+ export function getChildSessionKeysTrackedForRequester(requesterSessionKey: string): string[] {
152
+ const req = requesterSessionKey.trim()
153
+ if (!req) return []
154
+ const out: string[] = []
155
+ for (const [child, parent] of requesterByChildSessionKey.entries()) {
156
+ if (parent === req) out.push(child)
157
+ }
158
+ return out
159
+ }
160
+
161
+ /**
162
+ * 打断后清空本地子 agent 跟踪(runId、父子映射、子 runId→sessionKey),并唤醒 waitUntilSubagentsIdle,避免永久挂起。
163
+ */
164
+ export function resetSubagentStateForRequesterSession(requesterSessionKey: string): void {
165
+ const req = requesterSessionKey.trim()
166
+ if (!req) return
167
+
168
+ const runIdSet = activeSubagentRunIdsByRequester.get(req)
169
+ if (runIdSet) {
170
+ for (const rid of runIdSet) {
171
+ deleteSessionKeyBySubAgentRunId(rid)
172
+ }
173
+ }
174
+
175
+ for (const [child, parent] of [...requesterByChildSessionKey.entries()]) {
176
+ if (parent === req) {
177
+ requesterByChildSessionKey.delete(child)
178
+ runIdByChildSessionKey.delete(child)
179
+ }
180
+ }
181
+
182
+ activeSubagentRunIdsByRequester.delete(req)
183
+
184
+ const waiters = subagentIdleWaiters.get(req)
185
+ if (!waiters?.size) return
186
+ subagentIdleWaiters.delete(req)
187
+ for (const w of waiters) {
188
+ try {
189
+ w()
190
+ } catch (e) {
191
+ dcgLogger(`subagent idle waiter error: ${String(e)}`, 'error')
192
+ }
193
+ }
194
+ }
195
+
196
+ /** 当前主会话下仍在跑的子 agent 数量(按 spawn 时 runId 去重) */
197
+ export function getActiveSubagentCount(sessionKey: string): number {
198
+ const sk = sessionKey?.trim()
199
+ if (!sk) return 0
200
+ return activeSubagentRunIdsByRequester.get(sk)?.size ?? 0
201
+ }
202
+
203
+ /**
204
+ * 等到指定主会话下已跟踪的子 agent 全部结束(spawn 失败等路径也会触发 subagent_ended,一般会配对清理)。
205
+ * 注意:须在收到 `subagent_spawned` 之后才会计入;仅 spawning 未 spawned 的不会阻塞。
206
+ */
207
+ export function waitUntilSubagentsIdle(sessionKey: string, opts?: { timeoutMs?: number; signal?: AbortSignal }): Promise<void> {
208
+ console.log('🚀 ~ waitUntilSubagentsIdle ~ sessionKey:', sessionKey)
209
+ const sk = sessionKey?.trim()
210
+ if (!sk) return Promise.resolve()
211
+
212
+ if (getActiveSubagentCount(sk) === 0) return Promise.resolve()
213
+
214
+ return new Promise<void>((resolve, reject) => {
215
+ let settled = false
216
+ const finish = (fn: () => void) => {
217
+ if (settled) return
218
+ settled = true
219
+ if (timeoutId) clearTimeout(timeoutId)
220
+ opts?.signal?.removeEventListener('abort', onAbort)
221
+ removeWaiter()
222
+ fn()
223
+ }
224
+
225
+ const removeWaiter = () => {
226
+ const bucket = subagentIdleWaiters.get(sk)
227
+ if (!bucket) return
228
+ bucket.delete(onIdle)
229
+ if (bucket.size === 0) subagentIdleWaiters.delete(sk)
230
+ }
231
+
232
+ const onIdle = () => finish(() => resolve())
233
+
234
+ const onAbort = () => {
235
+ const reason = opts?.signal?.reason
236
+ finish(() => reject(reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'))))
237
+ }
238
+
239
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
240
+ if (opts?.timeoutMs != null && opts.timeoutMs > 0) {
241
+ timeoutId = setTimeout(
242
+ () => finish(() => reject(new Error(`waitUntilSubagentsIdle timeout ${opts.timeoutMs}ms`))),
243
+ opts.timeoutMs
244
+ )
245
+ }
246
+
247
+ if (opts?.signal) {
248
+ if (opts.signal.aborted) {
249
+ onAbort()
250
+ return
251
+ }
252
+ opts.signal.addEventListener('abort', onAbort, { once: true })
253
+ }
254
+
255
+ let set = subagentIdleWaiters.get(sk)
256
+ if (!set) {
257
+ set = new Set()
258
+ subagentIdleWaiters.set(sk, set)
259
+ }
260
+ set.add(onIdle)
261
+
262
+ if (getActiveSubagentCount(sk) === 0) {
263
+ onIdle()
264
+ }
265
+ })
266
+ }
267
+
268
+ function resolveHookSessionKey(
269
+ eventName: string,
270
+ args: { sessionKey?: string; requesterSessionKey?: string; runId?: string }
271
+ ): string {
272
+ if (
273
+ eventName === 'subagent_spawned' ||
274
+ eventName === 'subagent_ended' ||
275
+ eventName === 'subagent_spawning' ||
276
+ eventName === 'subagent_delivery_target'
277
+ ) {
278
+ if (args?.runId && args?.requesterSessionKey) setSessionKeyBySubAgentRunId(args?.runId, args?.requesterSessionKey)
279
+ return (args?.requesterSessionKey || args?.sessionKey || '').trim()
280
+ }
281
+ return (args?.sessionKey || '').trim()
282
+ }
283
+
284
+ function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
285
+ if (eventName === 'subagent_spawned') {
286
+ const runId = typeof event?.runId === 'string' ? event.runId : ''
287
+ const childSessionKey = typeof event?.childSessionKey === 'string' ? event.childSessionKey : ''
288
+ const requester =
289
+ typeof args?.requesterSessionKey === 'string'
290
+ ? args.requesterSessionKey
291
+ : typeof args?.sessionKey === 'string'
292
+ ? args.sessionKey
293
+ : ''
294
+ registerSubagentSpawn(requester, runId, childSessionKey)
295
+ return
296
+ }
297
+ if (eventName === 'subagent_ended') {
298
+ const targetSessionKey = typeof event?.targetSessionKey === 'string' ? event.targetSessionKey : ''
299
+ const runId = typeof event?.runId === 'string' ? event.runId : undefined
300
+ registerSubagentEnd(args ?? {}, targetSessionKey, runId)
301
+ }
302
+ }
303
+
71
304
  export function monitoringToolMessage(api: OpenClawPluginApi) {
72
305
  for (const item of eventList) {
73
306
  api.on(item.event as PluginHookName, (event: any, args: any) => {
74
- const sk = args?.sessionKey as string
307
+ trackSubagentLifecycle(item.event, event, args)
308
+ const sk = resolveHookSessionKey(item.event, args ?? {})
75
309
  if (sk) {
76
310
  const status = getMsgStatus(sk)
77
311
  if (status === 'running') {
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'test'
2
2
 
3
3
 
4
4
  export const systemCommand = ['/new', '/status']
5
- export const interruptCommand = ['chat.stop']
5
+ export const interruptCommand = ['/stop']
6
6
 
7
- export const ignoreToolCommand = ['/search', '/abort', '/stop', '/queue interrupt', ...systemCommand, ...interruptCommand]
7
+ export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...interruptCommand]
@@ -0,0 +1,43 @@
1
+ import type { GatewayEvent } from '../gateway/index.js'
2
+ import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
3
+ import { dcgLogger } from './log.js'
4
+ import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
5
+ import { sendChunk } from '../transport.js'
6
+
7
+ /**
8
+ * 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
9
+ */
10
+ export function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): GatewayEvent {
11
+ try {
12
+ if (msg.event === 'agent') {
13
+ dcgLogger(`[Gateway] 收到agent事件: ${JSON.stringify(msg).slice(0, 100)}`)
14
+ const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
15
+ const sessionKey = getSessionKeyBySubAgentRunId(pl.runId)
16
+ const outboundCtx = getEffectiveMsgParams(sessionKey)
17
+ if (outboundCtx.sessionId && pl.data?.delta) sendChunk(pl.data.delta as string, outboundCtx, 0)
18
+ }
19
+ if (msg.event === 'cron') {
20
+ const p = msg.payload
21
+ dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
22
+ if (p?.action === 'added') {
23
+ sendDcgchatCron(p?.jobId as string)
24
+ }
25
+ if (p?.action === 'updated') {
26
+ sendDcgchatCron(p?.jobId as string)
27
+ }
28
+ if (p?.action === 'removed') {
29
+ sendDcgchatCron(p?.jobId as string)
30
+ }
31
+ if (p?.action === 'finished') {
32
+ finishedDcgchatCron(p?.jobId as string)
33
+ }
34
+ }
35
+ } catch (error) {
36
+ dcgLogger(`[Gateway] 处理事件失败: ${error}`, 'error')
37
+ }
38
+ return {
39
+ type: msg.event as string,
40
+ payload: msg.payload,
41
+ seq: msg.seq as number | undefined
42
+ }
43
+ }
@@ -22,7 +22,7 @@ export function getOpenClawConfig(): OpenClawConfig | null {
22
22
  return config
23
23
  }
24
24
 
25
- import type { OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
25
+ import { createPluginRuntimeStore, type OpenClawConfig, type PluginRuntime } from 'openclaw/plugin-sdk'
26
26
  import { dcgLogger } from './log.js'
27
27
  import { channelInfo, ENV } from './constant.js'
28
28
 
@@ -42,7 +42,6 @@ function getWorkspacePath() {
42
42
  return null
43
43
  }
44
44
 
45
- let runtime: PluginRuntime | null = null
46
45
  let workspaceDir: string = getWorkspacePath()
47
46
 
48
47
  export function setWorkspaceDir(dir?: string) {
@@ -56,17 +55,10 @@ export function getWorkspaceDir(): string {
56
55
  }
57
56
  return workspaceDir
58
57
  }
59
-
60
- export function setDcgchatRuntime(next: PluginRuntime) {
61
- runtime = next
62
- }
63
-
64
- export function getDcgchatRuntime(): PluginRuntime {
65
- if (!runtime) {
66
- dcgLogger?.('runtime not initialized', 'error')
67
- }
68
- return runtime as PluginRuntime
69
- }
58
+ const { setRuntime: setDcgchatRuntime, getRuntime: getDcgchatRuntime } = createPluginRuntimeStore<PluginRuntime>(
59
+ `${"dcgchat-test"} runtime not initialized`
60
+ )
61
+ export { setDcgchatRuntime, getDcgchatRuntime }
70
62
 
71
63
  export type MsgSessionStatus = 'running' | 'finished' | ''
72
64
 
@@ -69,3 +69,21 @@ export function setParamsMessage(sessionKey: string, params: Partial<IMsgParams>
69
69
  export function getParamsMessage(sessionKey: string): IMsgParams | undefined {
70
70
  return paramsMessageMap.get(sessionKey)
71
71
  }
72
+
73
+ export function clearParamsMessage(sessionKey: string): void {
74
+ const k = sessionKey?.trim()
75
+ if (!k) return
76
+ paramsMessageMap.delete(k)
77
+ }
78
+
79
+ // sessionKey 对应的 子agent的runId
80
+ const subagentRunIdMap = new Map<string, string>()
81
+ export function getSessionKeyBySubAgentRunId(runId: string): string | undefined {
82
+ return subagentRunIdMap.get(runId)
83
+ }
84
+ export function setSessionKeyBySubAgentRunId(runId: string, sessionKey: string) {
85
+ subagentRunIdMap.set(runId, sessionKey)
86
+ }
87
+ export function deleteSessionKeyBySubAgentRunId(runId: string) {
88
+ subagentRunIdMap.delete(runId)
89
+ }