@dcrays/dcgchat 0.3.35 → 0.4.4

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/channel.ts CHANGED
@@ -1,13 +1,61 @@
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'
9
16
  import { startDcgchatGatewaySocket } from './gateway/socket.js'
10
17
 
18
+ function dcgchatChannelCfg(): DcgchatConfig {
19
+ return (getOpenClawConfig()?.channels?.["dcgchat"] as DcgchatConfig | undefined) ?? {}
20
+ }
21
+
22
+ /** `agent:<code>:mobook:direct:<agentId>:<sessionId>`(与 getSessionKey 非 real_mobook 分支一致) */
23
+ function isMobookDirectSessionKey(s: string): boolean {
24
+ const parts = s.split(':').filter((p) => p.length > 0)
25
+ const low = parts.map((p) => p.toLowerCase())
26
+ return parts.length >= 6 && low[0] === 'agent' && low[2] === 'mobook' && low[3] === 'direct'
27
+ }
28
+
29
+ /** real_mobook 等线路下 Core 分配的 `agent:<agentId>:…` sessionKey */
30
+ function isAgentPrefixedSessionKey(s: string): boolean {
31
+ const parts = s.split(':').filter((p) => p.length > 0)
32
+ return parts.length >= 3 && parts[0].toLowerCase() === 'agent'
33
+ }
34
+
35
+ /**
36
+ * 供 `messaging.targetResolver.looksLikeId` 使用:与 OpenClaw `resolveMessagingTarget` 对齐,
37
+ * 仅当 target「像合法会话路由键」时才走 id 类解析;纯数字不会命中,从而在系统层拒绝误填 userId。
38
+ */
39
+ function looksLikeDcgchatMessageToolTarget(raw: string): boolean {
40
+ let s = raw.trim()
41
+ if (!s) return false
42
+ const prefix = 'dcg-cron:'
43
+ if (s.startsWith(prefix)) {
44
+ s = s.slice(prefix.length).trim()
45
+ if (!s) return false
46
+ }
47
+ if (isMobookDirectSessionKey(s)) return true
48
+ if (isAgentPrefixedSessionKey(s)) return true
49
+ return false
50
+ }
51
+
52
+ function dcgchatMessageTargetLooksLikeId(raw: string, _normalized?: string): boolean {
53
+ if (dcgchatChannelCfg().strictMessageToolTarget === false) {
54
+ return Boolean(raw?.trim())
55
+ }
56
+ return looksLikeDcgchatMessageToolTarget(raw)
57
+ }
58
+
11
59
  export type DcgchatMediaSendOptions = {
12
60
  /** 与 setParamsMessage / map 一致,用于 getOutboundMsgParams */
13
61
  sessionKey: string
@@ -98,7 +146,8 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
98
146
  effects: true
99
147
  // blockStreaming: true,
100
148
  },
101
- reload: { configPrefixes: ['channels.dcgchat'] },
149
+ /** 当前构建的 channel id + 兼容旧配置键 `channels.dcgchat` */
150
+ reload: { configPrefixes: [`channels.${"dcgchat"}`, 'channels.dcgchat'] },
102
151
  configSchema: {
103
152
  schema: {
104
153
  type: 'object',
@@ -107,16 +156,25 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
107
156
  enabled: { type: 'boolean' },
108
157
  wsUrl: { type: 'string' },
109
158
  botToken: { type: 'string' },
110
- userId: { type: 'string', description: 'WebSocket 连接参数 _userId,与 message 工具的 target(effectiveSessionKey)无关' },
159
+ userId: { type: 'string', description: 'WebSocket 连接参数 _userId,与 dcgchat_message 工具的 target(dcgSessionKey)无关' },
111
160
  appId: { type: 'string' },
112
161
  domainId: { type: 'string' },
113
- capabilities: { type: 'array', items: { type: 'string' } }
162
+ capabilities: { type: 'array', items: { type: 'string' } },
163
+ strictMessageToolTarget: {
164
+ type: 'boolean',
165
+ description:
166
+ '默认 true:内置 message 工具的 target 须为 sessionKey 形态(如 agent:…:mobook:direct:… 或 agent: 前缀多段),禁止纯数字 WS userId。设为 false 关闭此校验。'
167
+ }
114
168
  }
115
169
  },
116
170
  uiHints: {
117
171
  userId: {
118
172
  label: 'WS 连接 _userId',
119
- help: '仅用于拼接网关 WebSocket URL 的查询参数,不是 Agent 发消息时的 target。发消息请使用 effectiveSessionKey(与入站上下文 SessionKey 相同,格式如 agent:main:mobook:direct:…)。'
173
+ help: '仅用于拼接网关 WebSocket URL 的查询参数,不是 Agent 发消息时的 target。发消息请使用 dcgSessionKey(与入站上下文 SessionKey 相同)。'
174
+ },
175
+ strictMessageToolTarget: {
176
+ label: '严格 message.target',
177
+ help: '开启后由通道目标解析层拒绝纯数字等非 sessionKey 的 target(推荐开启);关闭则与旧版行为一致。'
120
178
  }
121
179
  }
122
180
  },
@@ -124,16 +182,17 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
124
182
  listAccountIds: () => [DEFAULT_ACCOUNT_ID],
125
183
  resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
126
184
  defaultAccountId: () => DEFAULT_ACCOUNT_ID,
127
- setAccountEnabled: ({ cfg, enabled }) => ({
128
- ...cfg,
129
- channels: {
130
- ...cfg.channels,
131
- dcgchat: {
132
- ...(cfg.channels?.["dcgchat"] as Record<string, unknown> | undefined),
133
- enabled
185
+ setAccountEnabled: ({ cfg, enabled }) => {
186
+ const channelKey = "dcgchat"
187
+ const prev = (cfg.channels?.[channelKey as keyof NonNullable<typeof cfg.channels>] as Record<string, unknown> | undefined) ?? {}
188
+ return {
189
+ ...cfg,
190
+ channels: {
191
+ ...cfg.channels,
192
+ [channelKey]: { ...prev, enabled }
134
193
  }
135
194
  }
136
- }),
195
+ },
137
196
  isConfigured: (account) => account.configured,
138
197
  describeAccount: (account) => ({
139
198
  accountId: account.accountId,
@@ -149,14 +208,26 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
149
208
  messaging: {
150
209
  normalizeTarget: (raw) => raw || undefined,
151
210
  targetResolver: {
152
- looksLikeId: (raw) => Boolean(raw?.trim()),
153
- hint: 'effectiveSessionKey(与 SessionKey 一致;勿填配置里的 WS userId'
211
+ looksLikeId: dcgchatMessageTargetLooksLikeId,
212
+ hint: '须为完整 dcgSessionKey(与 SessionKey 一致,形如 agent:…:mobook:direct:… 或 agent: 前缀路由键);禁止填 WS userId 等纯数字。可在通道配置 strictMessageToolTarget=false 关闭校验。'
154
213
  }
155
214
  },
215
+ /**
216
+ * 与 Telegram 等通道一致:用入站路由键 `To`(即 SessionKey / OriginatingTo)作为 message 工具默认 `currentChannelId`。
217
+ * 显式 target 由 `messaging.targetResolver.looksLikeId` + OpenClaw `resolveMessagingTarget` 校验(见 strictMessageToolTarget)。
218
+ */
219
+ threading: {
220
+ buildToolContext: ({ context, hasRepliedRef }) => ({
221
+ currentChannelId: context.To?.trim() || undefined,
222
+ hasRepliedRef
223
+ })
224
+ },
156
225
  agentPrompt: {
157
226
  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 理解。'
227
+ '生成文件后,**尽可能不要**把文件路径、地址直接告诉用户。',
228
+ '生成文件后,把文件名告诉用户。',
229
+ '使用内置 `message` 或 `dcgchat_message` 发消息时,target 必须是完整 dcgSessionKey(与上下文 SessionKey 相同),禁止填 From、SenderId、WS userId 等纯数字。',
230
+ '生成文件后,须通过工具发送文件,勿在文本里直接输出路径或地址。'
160
231
  ]
161
232
  },
162
233
  outbound: {
@@ -167,6 +238,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
167
238
  }
168
239
  return { ok: true, to: to }
169
240
  },
241
+ chunker: (text, limit) => getDcgchatRuntime().channel.text.chunkMarkdownText(text, limit),
170
242
  textChunkLimit: 4000,
171
243
  sendText: async (ctx) => {
172
244
  dcgLogger(`channel sendText to ${ctx.to} `)
package/src/cron.ts CHANGED
@@ -40,11 +40,6 @@ export function readCronJob(jobPath: string, jobId: string): Record<string, any>
40
40
  }
41
41
  }
42
42
 
43
- function msgParamsToCtx(p: IMsgParams): IMsgParams | null {
44
- if (!p?.botToken) return null
45
- return p
46
- }
47
-
48
43
  const CRON_UPLOAD_DEBOUNCE_MS = 2400
49
44
 
50
45
  let cronUploadFlushTimer: ReturnType<typeof setTimeout> | null = null
@@ -63,9 +58,10 @@ async function runCronJobsUpload(sessionKey: string): Promise<void> {
63
58
  event_type: 'cron',
64
59
  operation_type: 'install',
65
60
  session_id: sessionId,
66
- agent_id: agentId
61
+ agent_id: agentId,
62
+ oss_url: url
67
63
  }
68
- sendEventMessage(url, params)
64
+ sendEventMessage(params)
69
65
  } catch (error) {
70
66
  dcgLogger(`${jobPath} upload failed: ${error}`, 'error')
71
67
  }
@@ -170,6 +166,7 @@ export const finishedDcgchatCron = async (jobId: string) => {
170
166
  sendFinal(merged, 'cron send')
171
167
  }
172
168
  const ws = getWsConnection()
169
+ const baseContent = getParamsDefaults()
173
170
  if (isWsOpen()) {
174
171
  ws?.send(
175
172
  JSON.stringify({
@@ -178,15 +175,17 @@ export const finishedDcgchatCron = async (jobId: string) => {
178
175
  content: {
179
176
  event_type: 'notify',
180
177
  operation_type: 'cron',
178
+ bot_token: baseContent.botToken,
179
+ app_id: baseContent.appId,
181
180
  session_id: sessionId,
182
- agentId: agentId,
181
+ agent_id: agentId,
183
182
  real_mobook: !sessionId ? 1 : '',
184
183
  title: name
185
184
  }
186
185
  })
187
186
  )
188
- dcgLogger(`定时任务执行成功: ${id}`)
189
187
  }
188
+ dcgLogger(`定时任务执行成功: ${id}`)
190
189
  removeCronMessageId(sessionKey)
191
190
  dcgLogger(`finishedDcgchatCron: job=${id} sessionKey=${sessionKey}`)
192
191
  }
@@ -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
  }
@@ -212,7 +212,7 @@ async function connectPersistentGateway(): Promise<void> {
212
212
  startPingTimer(gw)
213
213
  dcgLogger(`Gateway 持久连接成功 connId=${gw.getConnId() ?? '?'}`)
214
214
  } catch (e) {
215
- dcgLogger(`Gateway 连接失败11: ${e}`, 'error')
215
+ dcgLogger(`Gateway 连接失败: ${e}`, 'error')
216
216
  persistentConn = null
217
217
  clearPingTimer()
218
218
  if (!socketStopped) {
@@ -230,8 +230,10 @@ export function startDcgchatGatewaySocket(): void {
230
230
  socketStopped = false
231
231
  clearReconnectTimer()
232
232
  if (startupConnectTimer != null) return
233
- startupConnectTimer = null
234
- void connectPersistentGateway()
233
+ startupConnectTimer = setTimeout(() => {
234
+ startupConnectTimer = null
235
+ void connectPersistentGateway()
236
+ }, 0)
235
237
  }
236
238
 
237
239
  /**
package/src/monitor.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk'
2
2
  import WebSocket from 'ws'
3
- import { handleDcgchatMessage } from './bot.js'
4
3
  import { resolveAccount } from './channel.js'
5
- import { setWsConnection, getOpenClawConfig, setMsgStatus, getSessionKey } from './utils/global.js'
6
- import type { InboundMessage } from './types.js'
7
- import { installSkill, uninstallSkill } from './skill.js'
4
+ import { setWsConnection, getOpenClawConfig } from './utils/global.js'
8
5
  import { dcgLogger } from './utils/log.js'
9
- import { onDisabledCronJob, onEnabledCronJob, onRemoveCronJob, onRunCronJob } from './cron.js'
10
- import { ignoreToolCommand } from './utils/constant.js'
11
6
  import { isWsOpen } from './transport.js'
12
- import { onRemoveSession } from './session.js'
7
+ import { handleParsedWsMessage } from './utils/wsMessageHandler.js'
13
8
 
14
9
  export type MonitorDcgchatOpts = {
15
10
  config?: ClawdbotConfig
@@ -128,51 +123,7 @@ export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<
128
123
  dcgLogger(`用户消息content字段解析失败: invalid JSON received`, 'error')
129
124
  return
130
125
  }
131
-
132
- if (parsed.messageType == 'openclaw_bot_chat') {
133
- const msg = parsed as unknown as InboundMessage
134
- // 与 monitor 原逻辑一致:工具类指令不进入 running,避免误触工具链监控
135
- const effectiveSessionKey = getSessionKey(msg.content, account.accountId)
136
- if (!ignoreToolCommand.includes(msg.content.text?.trim() ?? '')) {
137
- setMsgStatus(effectiveSessionKey, 'running')
138
- } else {
139
- setMsgStatus(effectiveSessionKey, 'finished')
140
- }
141
- await handleDcgchatMessage(msg, account.accountId)
142
- } else if (parsed.messageType == 'openclaw_bot_event') {
143
- const { event_type, operation_type } = parsed.content ? parsed.content : ({} as Record<string, any>)
144
- if (event_type === 'skill') {
145
- const { skill_url, skill_code, skill_id, bot_token, websocket_trace_id } = parsed.content
146
- const content = { event_type, operation_type, skill_url, skill_code, skill_id, bot_token, websocket_trace_id }
147
- if (operation_type === 'install' || operation_type === 'enable' || operation_type === 'update') {
148
- installSkill({ path: skill_url, code: skill_code }, content)
149
- } else if (operation_type === 'remove' || operation_type === 'disable') {
150
- uninstallSkill({ code: skill_code }, content)
151
- } else {
152
- dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${data.toString()}`)
153
- }
154
- } else if (event_type === 'cron') {
155
- const { job_id, message_id } = parsed.content
156
- if (operation_type === 'remove') {
157
- await onRemoveCronJob(job_id)
158
- } else if (operation_type === 'enable') {
159
- await onEnabledCronJob(job_id)
160
- } else if (operation_type === 'disable') {
161
- await onDisabledCronJob(job_id)
162
- } else if (operation_type === 'run') {
163
- await onRunCronJob(job_id, message_id)
164
- }
165
- } else if (event_type === 'session') {
166
- const { agent_id, session_id, agent_clone_code } = parsed.content
167
- if (operation_type === 'remove') {
168
- await onRemoveSession({ agent_id, session_id, agent_clone_code, account_id: accountId })
169
- }
170
- } else {
171
- dcgLogger(`openclaw_bot_event unknown operation_type: ${operation_type}, ${data.toString()}`)
172
- }
173
- } else {
174
- dcgLogger(`ignoring unknown messageType: ${parsed.messageType}`, 'error')
175
- }
126
+ await handleParsedWsMessage(parsed, payloadStr, account.accountId)
176
127
  })
177
128
 
178
129
  ws.on('close', (code, reason) => {
package/src/session.ts CHANGED
@@ -6,11 +6,11 @@ interface TSession {
6
6
  agent_id: string
7
7
  session_id: string
8
8
  agent_clone_code?: string
9
- account_id?: string
9
+ account_id: string
10
10
  }
11
11
 
12
12
  export const onRemoveSession = async ({ agent_id, session_id, agent_clone_code, account_id }: TSession) => {
13
- const sessionKey = getSessionKey({ agent_id, session_id }, account_id)
13
+ const sessionKey = getSessionKey({ agent_id, session_id, agent_clone_code }, account_id)
14
14
  if (!session_id) {
15
15
  dcgLogger('onRemoveSession: empty session_id', 'error')
16
16
  return
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,243 @@ 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
+ const sk = sessionKey?.trim()
209
+ if (!sk) return Promise.resolve()
210
+
211
+ if (getActiveSubagentCount(sk) === 0) return Promise.resolve()
212
+
213
+ return new Promise<void>((resolve, reject) => {
214
+ let settled = false
215
+ const finish = (fn: () => void) => {
216
+ if (settled) return
217
+ settled = true
218
+ if (timeoutId) clearTimeout(timeoutId)
219
+ opts?.signal?.removeEventListener('abort', onAbort)
220
+ removeWaiter()
221
+ fn()
222
+ }
223
+
224
+ const removeWaiter = () => {
225
+ const bucket = subagentIdleWaiters.get(sk)
226
+ if (!bucket) return
227
+ bucket.delete(onIdle)
228
+ if (bucket.size === 0) subagentIdleWaiters.delete(sk)
229
+ }
230
+
231
+ const onIdle = () => finish(() => resolve())
232
+
233
+ const onAbort = () => {
234
+ const reason = opts?.signal?.reason
235
+ finish(() => reject(reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'))))
236
+ }
237
+
238
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
239
+ if (opts?.timeoutMs != null && opts.timeoutMs > 0) {
240
+ timeoutId = setTimeout(
241
+ () => finish(() => reject(new Error(`waitUntilSubagentsIdle timeout ${opts.timeoutMs}ms`))),
242
+ opts.timeoutMs
243
+ )
244
+ }
245
+
246
+ if (opts?.signal) {
247
+ if (opts.signal.aborted) {
248
+ onAbort()
249
+ return
250
+ }
251
+ opts.signal.addEventListener('abort', onAbort, { once: true })
252
+ }
253
+
254
+ let set = subagentIdleWaiters.get(sk)
255
+ if (!set) {
256
+ set = new Set()
257
+ subagentIdleWaiters.set(sk, set)
258
+ }
259
+ set.add(onIdle)
260
+
261
+ if (getActiveSubagentCount(sk) === 0) {
262
+ onIdle()
263
+ }
264
+ })
265
+ }
266
+
267
+ function resolveHookSessionKey(
268
+ eventName: string,
269
+ args: { sessionKey?: string; requesterSessionKey?: string; runId?: string }
270
+ ): string {
271
+ if (
272
+ eventName === 'subagent_spawned' ||
273
+ eventName === 'subagent_ended' ||
274
+ eventName === 'subagent_spawning' ||
275
+ eventName === 'subagent_delivery_target'
276
+ ) {
277
+ if (args?.runId && args?.requesterSessionKey) setSessionKeyBySubAgentRunId(args?.runId, args?.requesterSessionKey)
278
+ return (args?.requesterSessionKey || args?.sessionKey || '').trim()
279
+ }
280
+ return (args?.sessionKey || '').trim()
281
+ }
282
+
283
+ function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
284
+ if (eventName === 'subagent_spawned') {
285
+ const runId = typeof event?.runId === 'string' ? event.runId : ''
286
+ const childSessionKey = typeof event?.childSessionKey === 'string' ? event.childSessionKey : ''
287
+ const requester =
288
+ typeof args?.requesterSessionKey === 'string'
289
+ ? args.requesterSessionKey
290
+ : typeof args?.sessionKey === 'string'
291
+ ? args.sessionKey
292
+ : ''
293
+ registerSubagentSpawn(requester, runId, childSessionKey)
294
+ return
295
+ }
296
+ if (eventName === 'subagent_ended') {
297
+ const targetSessionKey = typeof event?.targetSessionKey === 'string' ? event.targetSessionKey : ''
298
+ const runId = typeof event?.runId === 'string' ? event.runId : undefined
299
+ registerSubagentEnd(args ?? {}, targetSessionKey, runId)
300
+ }
301
+ }
302
+
71
303
  export function monitoringToolMessage(api: OpenClawPluginApi) {
72
304
  for (const item of eventList) {
73
305
  api.on(item.event as PluginHookName, (event: any, args: any) => {
74
- const sk = args?.sessionKey as string
306
+ trackSubagentLifecycle(item.event, event, args)
307
+ const sk = resolveHookSessionKey(item.event, args ?? {})
75
308
  if (sk) {
76
309
  const status = getMsgStatus(sk)
77
310
  if (status === 'running') {
@@ -116,7 +349,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
116
349
  dcgLogger(`工具调用结果: ~ event:${item.event} ${status}`)
117
350
  }
118
351
  }
119
- } else {
352
+ } else if (item.event !== 'before_tool_call') {
120
353
  dcgLogger(`工具调用结果: ~ event:${item.event} ~ 没有sessionKey 为执行`)
121
354
  }
122
355
  })