agentquad 0.4.5 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +4 -3
  2. package/dist-web/assets/index-DdqC2CwH.css +32 -0
  3. package/dist-web/assets/{index-By--XlP3.js → index-DkI6ZJx_.js} +411 -399
  4. package/dist-web/assets/logo-Cxw7XzHl.png +0 -0
  5. package/dist-web/favicon.png +0 -0
  6. package/dist-web/index.html +2 -2
  7. package/package.json +7 -1
  8. package/src/claude-prompt-detector.js +72 -0
  9. package/src/cli.js +1 -1
  10. package/src/codex-hook-installer.js +1 -1
  11. package/src/codex-prompt-detector.js +104 -13
  12. package/src/config.js +33 -5
  13. package/src/db.js +77 -31
  14. package/src/export/todoMarkdown.js +1 -9
  15. package/src/lark-bot.js +44 -5
  16. package/src/mcp/tools/destructive/index.js +22 -16
  17. package/src/mcp/tools/openclaw/index.js +12 -16
  18. package/src/mcp/tools/read/index.js +7 -7
  19. package/src/mcp/tools/write/index.js +9 -6
  20. package/src/openclaw-bridge.js +176 -28
  21. package/src/openclaw-hook-installer.js +2 -1
  22. package/src/openclaw-hook.js +127 -9
  23. package/src/openclaw-wizard.js +168 -191
  24. package/src/permission-prompt.js +113 -31
  25. package/src/prompt-render.js +0 -8
  26. package/src/pty.js +183 -49
  27. package/src/routes/ai-terminal.js +90 -26
  28. package/src/routes/telegram-sync.js +7 -5
  29. package/src/routes/todos.js +8 -14
  30. package/src/server.js +90 -12
  31. package/src/session-input-dispatcher.js +48 -4
  32. package/src/stats/report.js +1 -6
  33. package/src/telegram-bot.js +82 -15
  34. package/src/telegram-loading-status.js +1 -1
  35. package/src/templates/claude-hooks/notify.js +1 -1
  36. package/src/templates/codex-hooks/notify.js +1 -1
  37. package/src/wiki/index.js +1 -1
  38. package/src/wiki/sources.js +0 -1
  39. package/dist-web/assets/index-8A0oLLcX.css +0 -32
  40. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
@@ -195,12 +195,15 @@ export function registerDestructiveTools(server, { db, audit }) {
195
195
  'bulk_update',
196
196
  {
197
197
  description:
198
- '对一组 todo id 批量 patch。允许字段:quadrant / status / archived / dueDate。默认 confirm:false 返回 preview(列出将要修改的 todos,最多 20 条)。',
198
+ '对一组 todo id 批量 patch。允许字段:status / archived / dueDate。' +
199
+ '(quadrant 字段保留接口以兼容旧调用方,传入会被忽略。)' +
200
+ '默认 confirm:false 返回 preview(列出将要修改的 todos,最多 20 条)。',
199
201
  inputSchema: {
200
202
  ids: z.array(z.string().min(1)).min(1),
201
203
  patch: z
202
204
  .object({
203
- quadrant: z.number().int().min(1).max(4).optional(),
205
+ quadrant: z.number().int().min(1).max(4).optional()
206
+ .describe('【已退役】象限概念已移除,传入会被忽略'),
204
207
  status: z.enum(['todo', 'done']).optional(),
205
208
  archived: z.boolean().optional(),
206
209
  dueDate: z.number().int().nullable().optional(),
@@ -211,40 +214,43 @@ export function registerDestructiveTools(server, { db, audit }) {
211
214
  },
212
215
  },
213
216
  async (args) => {
217
+ // 剥掉退役字段,避免 db 层试图写 quadrant
218
+ const { quadrant: _ignored, ...effectivePatch } = args.patch || {}
219
+ const sanitizedArgs = { ...args, patch: effectivePatch }
214
220
  try {
215
221
  const preview = {
216
- ids: args.ids,
217
- patch: args.patch,
222
+ ids: sanitizedArgs.ids,
223
+ patch: sanitizedArgs.patch,
218
224
  affected: [],
219
225
  missing: [],
220
226
  }
221
- for (const id of args.ids.slice(0, 20)) {
227
+ for (const id of sanitizedArgs.ids.slice(0, 20)) {
222
228
  const t = db.getTodo(id)
223
- if (t) preview.affected.push({ id: t.id, title: t.title, quadrant: t.quadrant, status: t.status })
229
+ if (t) preview.affected.push({ id: t.id, title: t.title, status: t.status })
224
230
  else preview.missing.push(id)
225
231
  }
226
- preview.totalTargeted = args.ids.length
227
- preview.previewTruncated = args.ids.length > 20
228
- if (!args.confirm) {
229
- const patchKeys = Object.keys(args.patch).join(', ')
232
+ preview.totalTargeted = sanitizedArgs.ids.length
233
+ preview.previewTruncated = sanitizedArgs.ids.length > 20
234
+ if (!sanitizedArgs.confirm) {
235
+ const patchKeys = Object.keys(sanitizedArgs.patch).join(', ') || '(空 — quadrant 已退役)'
230
236
  return previewResponse({
231
237
  toolName: 'bulk_update',
232
- summary: `将对 ${args.ids.length} 条 todo 批量 patch 以下字段:${patchKeys}。命中 ${preview.affected.length} 条、未找到 ${preview.missing.length} 条。`,
238
+ summary: `将对 ${sanitizedArgs.ids.length} 条 todo 批量 patch 以下字段:${patchKeys}。命中 ${preview.affected.length} 条、未找到 ${preview.missing.length} 条。`,
233
239
  impact: preview,
234
- args,
240
+ args: sanitizedArgs,
235
241
  })
236
242
  }
237
- const result = db.bulkUpdateTodos({ ids: args.ids, patch: args.patch })
243
+ const result = db.bulkUpdateTodos({ ids: sanitizedArgs.ids, patch: sanitizedArgs.patch })
238
244
  audit?.append({
239
245
  tool: 'bulk_update',
240
246
  ok: true,
241
- args: { ids: args.ids, patch: args.patch },
247
+ args: { ids: sanitizedArgs.ids, patch: sanitizedArgs.patch },
242
248
  result: { changedCount: result.count, changedIds: result.changedIds },
243
- confirmNote: args.confirmNote || null,
249
+ confirmNote: sanitizedArgs.confirmNote || null,
244
250
  })
245
251
  return asText({ ok: true, result })
246
252
  } catch (e) {
247
- audit?.append({ tool: 'bulk_update', ok: false, args: { ids: args.ids, patch: args.patch }, error: e?.message })
253
+ audit?.append({ tool: 'bulk_update', ok: false, args: { ids: sanitizedArgs.ids, patch: sanitizedArgs.patch }, error: e?.message })
248
254
  return asError(e?.message || 'bulk_update_failed')
249
255
  }
250
256
  },
@@ -1,10 +1,10 @@
1
1
  /**
2
- * OpenClaw 双向桥接的 MCP 工具集(8 个):
2
+ * OpenClaw 双向桥接的 MCP 工具集:
3
3
  *
4
4
  * 创建任务向导(OpenClaw skill 调):
5
5
  * - list_workdir_options
6
- * - list_quadrants
7
- * - list_templates
6
+ * - list_quadrants (已废弃,仅为兼容旧 skill;返回空数组 + deprecated 标记)
7
+ * - list_templates (用户面叫 "agent / 员工",工具名保留向后兼容)
8
8
  *
9
9
  * 启动 PTY(OpenClaw skill 调):
10
10
  * - start_ai_session
@@ -37,13 +37,6 @@ function asError(message, extra = null) {
37
37
  }
38
38
  }
39
39
 
40
- const QUADRANTS = [
41
- { id: 1, label: '重要紧急', shortLabel: 'Q1', isDefault: false },
42
- { id: 2, label: '重要不紧急', shortLabel: 'Q2', isDefault: true },
43
- { id: 3, label: '紧急不重要', shortLabel: 'Q3', isDefault: false },
44
- { id: 4, label: '不重要不紧急', shortLabel: 'Q4', isDefault: false },
45
- ]
46
-
47
40
  function expandHome(p) {
48
41
  if (!p) return p
49
42
  if (p === '~' || p.startsWith('~/')) return p === '~' ? homedir() : join(homedir(), p.slice(2))
@@ -105,22 +98,25 @@ export function registerOpenClawTools(server, deps) {
105
98
  },
106
99
  )
107
100
 
108
- // ─── 2. list_quadrants ──────────────────────────────────────────
101
+ // ─── 2. list_quadrants(已废弃;保留以兼容旧版 OpenClaw skill)──
109
102
  server.registerTool(
110
103
  'list_quadrants',
111
104
  {
112
- description: '返回 4 个象限的元数据(id/label/isDefault),创建向导第二步用。',
105
+ description:
106
+ '【已废弃】象限概念已从 AgentQuad 移除。本工具仅为兼容旧 skill 而保留,' +
107
+ '返回空数组 + deprecated 标记。新创建任务无需再选象限。',
113
108
  inputSchema: {},
114
109
  },
115
- async () => asText({ quadrants: QUADRANTS }),
110
+ async () => asText({ quadrants: [], deprecated: true }),
116
111
  )
117
112
 
118
- // ─── 3. list_templates ──────────────────────────────────────────
113
+ // ─── 3. list_templates (用户面叫 agent / 员工) ─────────────────────
119
114
  server.registerTool(
120
115
  'list_templates',
121
116
  {
122
117
  description:
123
- '返回所有提示词模板(id/name/description/builtin/contentPreview),创建向导第三步用。' +
118
+ '返回所有可指派的 agent / 员工(id/name/description/builtin/contentPreview),' +
119
+ '创建向导选 agent 那一步用。' +
124
120
  '完整 content 不返回(避免上下文爆);启动会话时按 templateId 自动注入。',
125
121
  inputSchema: {},
126
122
  },
@@ -315,7 +311,7 @@ export function registerOpenClawTools(server, deps) {
315
311
  // 拼按钮失败不阻塞;纯文本兜底(用户照样能数字回复)
316
312
  }
317
313
 
318
- const sendResult = await openclaw.postText({
314
+ const sendResult = await openclaw.broadcastText({
319
315
  sessionId,
320
316
  message: lines.join('\n'),
321
317
  replyMarkup: askUserMarkup,
@@ -53,7 +53,8 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
53
53
  description:
54
54
  '按过滤条件列出 todos。不做全文搜索——用 search 做模糊搜索。返回元数据数组,不含 AI 会话详情(用 get_todo 取)。',
55
55
  inputSchema: {
56
- quadrant: z.number().int().min(1).max(4).optional().describe('1=重要且紧急 2=重要不紧急 3=紧急不重要 4=不重要不紧急'),
56
+ quadrant: z.number().int().min(1).max(4).optional()
57
+ .describe('【已退役】象限概念已移除;保留参数用于兼容旧调用方,传入会被忽略'),
57
58
  status: z.enum(['todo', 'done', 'all']).optional().describe('默认 all'),
58
59
  archived: z.union([z.boolean(), z.literal('all')]).optional().describe('默认 false 只看未归档;"all" 两者都要'),
59
60
  parentId: z.string().optional().describe('仅列某 parent 下的子任务'),
@@ -63,8 +64,8 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
63
64
  async (args = {}) => {
64
65
  try {
65
66
  const rawStatus = args.status === 'all' ? '' : args.status
67
+ // quadrant 已退役,不再透传 db 层
66
68
  const list = db.listTodos({
67
- quadrant: args.quadrant,
68
69
  status: rawStatus,
69
70
  archived: args.archived,
70
71
  })
@@ -78,7 +79,6 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
78
79
  id: t.id,
79
80
  parentId: t.parentId,
80
81
  title: t.title,
81
- quadrant: t.quadrant,
82
82
  status: t.status,
83
83
  dueDate: t.dueDate,
84
84
  workDir: t.workDir,
@@ -151,7 +151,7 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
151
151
  'get_stats',
152
152
  {
153
153
  description:
154
- '一个当前快照:按象限/状态的分布、今日截止、本周完成、最近 7 天活跃度、归档数量。轻量实时计算。',
154
+ '一个当前快照:按 todo 状态分布、今日截止、本周完成、最近 7 天活跃度、归档数量。轻量实时计算。',
155
155
  inputSchema: {},
156
156
  },
157
157
  async () => {
@@ -162,15 +162,15 @@ export function registerReadTools(server, { db, searchService, wikiDir, transcri
162
162
  const all = db.listTodos({ archived: 'all' })
163
163
  const open = all.filter((t) => t.status !== 'done' && t.status !== 'missed' && t.archivedAt == null)
164
164
  const archivedCount = all.filter((t) => t.archivedAt != null).length
165
- const byQuadrant = {}
166
- for (const t of open) byQuadrant[t.quadrant] = (byQuadrant[t.quadrant] || 0) + 1
165
+ const byStatus = {}
166
+ for (const t of open) byStatus[t.status] = (byStatus[t.status] || 0) + 1
167
167
  const overdue = open.filter((t) => t.dueDate && t.dueDate < now)
168
168
  const dueToday = open.filter((t) => t.dueDate && t.dueDate >= startOfDay.getTime() && t.dueDate < startOfDay.getTime() + 86_400_000)
169
169
  const weekDone = db.listCompletedTodos({ since: startOfWeek.getTime(), until: now + 1 })
170
170
  return asText({
171
171
  openCount: open.length,
172
172
  archivedCount,
173
- byQuadrant,
173
+ byStatus,
174
174
  overdue: { count: overdue.length, ids: overdue.slice(0, 10).map((t) => t.id) },
175
175
  dueToday: { count: dueToday.length, ids: dueToday.map((t) => t.id) },
176
176
  completedThisWeek: weekDone.length,
@@ -23,12 +23,14 @@ export function registerWriteTools(server, { db }) {
23
23
  'create_todo',
24
24
  {
25
25
  description:
26
- '新建一条 todo。至少需要 title + quadrant。象限语义:1=重要且紧急,2=重要不紧急,3=紧急不重要,4=不重要不紧急。',
26
+ '新建一条 todo。只需 title。' +
27
+ '(quadrant 字段已退役但接口仍接受以兼容旧调用方;传入会被忽略,DB 写入默认值。)',
27
28
  inputSchema: {
28
29
  title: z.string().min(1).describe('标题,必填'),
29
- quadrant: z.number().int().min(1).max(4).describe('1 / 2 / 3 / 4'),
30
+ quadrant: z.number().int().min(1).max(4).optional()
31
+ .describe('【已退役】象限概念已移除,传入会被忽略'),
30
32
  description: z.string().optional(),
31
- parentId: z.string().optional().describe('如果是子任务,指定父 todo id(子任务会继承父的象限)'),
33
+ parentId: z.string().optional().describe('已退役:子任务概念已从 UI 移除;接口保留但不再分组展示'),
32
34
  dueDate: z.number().int().optional().describe('截止时间戳(毫秒 epoch)'),
33
35
  workDir: z.string().optional().describe('关联的代码仓路径'),
34
36
  brainstorm: z.boolean().optional(),
@@ -39,7 +41,7 @@ export function registerWriteTools(server, { db }) {
39
41
  if (!args.title?.trim()) return asError('title_required')
40
42
  const created = db.createTodo({
41
43
  title: args.title.trim(),
42
- quadrant: args.quadrant,
44
+ // quadrant 不再透传 —— 让 db 层用默认值
43
45
  description: args.description || '',
44
46
  parentId: args.parentId,
45
47
  dueDate: args.dueDate,
@@ -63,7 +65,8 @@ export function registerWriteTools(server, { db }) {
63
65
  id: z.string().min(1),
64
66
  title: z.string().optional(),
65
67
  description: z.string().optional(),
66
- quadrant: z.number().int().min(1).max(4).optional(),
68
+ quadrant: z.number().int().min(1).max(4).optional()
69
+ .describe('【已退役】象限概念已移除,传入会被忽略'),
67
70
  dueDate: z.number().int().nullable().optional().describe('传 null 显式清除'),
68
71
  workDir: z.string().nullable().optional(),
69
72
  parentId: z.string().nullable().optional().describe('传 null 升为顶层 todo'),
@@ -71,7 +74,7 @@ export function registerWriteTools(server, { db }) {
71
74
  },
72
75
  async (args) => {
73
76
  try {
74
- const { id, ...patch } = args
77
+ const { id, quadrant: _ignored, ...patch } = args
75
78
  if (!id) return asError('id_required')
76
79
  // 禁止通过此工具改 status,status 变更有专用工具(complete/archive)
77
80
  const cleanPatch = {}
@@ -132,6 +132,7 @@ export function createOpenClawBridge({
132
132
  telegramSender = sendViaTelegramAPI, // 测试用:可 mock fake fetch
133
133
  telegramBot: initialTelegramBot = null, // 可选:用于 sendDocument 附件
134
134
  larkBot: initialLarkBot = null,
135
+ getRoutesForSession = null, // 注入:读 db 双路由(telegram + lark),broadcastEcho 用
135
136
  } = {}) {
136
137
  let telegramBot = initialTelegramBot
137
138
  let larkBot = initialLarkBot
@@ -140,7 +141,8 @@ export function createOpenClawBridge({
140
141
 
141
142
  // 出站限流环形缓冲:每分钟 ≤ rateLimitPerMin 条
142
143
  const sendTimestamps = []
143
- // sessionId → { targetUserId, account, channel }
144
+ // sessionId → Map<channel, route> —— 一个 session 可同时绑 telegram + lark + weixin
145
+ // Map 保留插入顺序,"最新注册的那条"可以靠遍历最后一个值拿到,用于无 channel hint 的 fallback。
144
146
  const sessionRoutes = new Map()
145
147
  // peerUserId → { sessionId, sentAt } — 最近一次推到该 peer 的 session
146
148
  // 用于 PTY stdin proxy:用户在微信回话时知道往哪个 PTY 写
@@ -168,18 +170,38 @@ export function createOpenClawBridge({
168
170
  sendTimestamps.push(nowMs())
169
171
  }
170
172
 
173
+ function getRoutesInner(sessionId) {
174
+ return sessionRoutes.get(sessionId) || null
175
+ }
176
+
177
+ function ensureRoutesInner(sessionId) {
178
+ let inner = sessionRoutes.get(sessionId)
179
+ if (!inner) {
180
+ inner = new Map()
181
+ sessionRoutes.set(sessionId, inner)
182
+ }
183
+ return inner
184
+ }
185
+
186
+ function allRoutesForSession(sessionId) {
187
+ const inner = getRoutesInner(sessionId)
188
+ return inner ? Array.from(inner.values()) : []
189
+ }
190
+
171
191
  function registerSessionRoute(sessionId, { targetUserId, account, channel, threadId, rootMessageId, topicName, triggerMessageId, messageAppLink } = {}) {
172
192
  if (!sessionId || !targetUserId) return
173
- sessionRoutes.set(sessionId, {
193
+ const effectiveChannel = channel || getOpenClawConfig().channel || 'openclaw-weixin'
194
+ const route = {
174
195
  targetUserId,
175
196
  account: account || null,
176
- channel: channel || getOpenClawConfig().channel || 'openclaw-weixin',
177
- threadId: threadId != null ? threadId : null, // ← Telegram Topic 路由用
197
+ channel: effectiveChannel,
198
+ threadId: threadId != null ? threadId : null,
178
199
  rootMessageId: rootMessageId || null,
179
- topicName: topicName || null, // ← SessionEnd 改名 ✅ 用
180
- triggerMessageId: triggerMessageId != null ? triggerMessageId : null, // D 方案:reaction 加在用户触发消息上
200
+ topicName: topicName || null,
201
+ triggerMessageId: triggerMessageId != null ? triggerMessageId : null,
181
202
  messageAppLink: messageAppLink || null,
182
- })
203
+ }
204
+ ensureRoutesInner(sessionId).set(effectiveChannel, route)
183
205
  }
184
206
 
185
207
  function clearSessionRoute(sessionId, reason = 'unknown') {
@@ -190,12 +212,20 @@ export function createOpenClawBridge({
190
212
  }
191
213
 
192
214
  function hasExplicitRoute(sessionId) {
193
- return Boolean(sessionId && sessionRoutes.has(sessionId))
215
+ if (!sessionId) return false
216
+ const inner = sessionRoutes.get(sessionId)
217
+ return Boolean(inner && inner.size > 0)
194
218
  }
195
219
 
196
- function resolveRoute(sessionId) {
197
- const explicit = sessionRoutes.get(sessionId)
198
- if (explicit) return explicit
220
+ function resolveRoute(sessionId, channel = null) {
221
+ const inner = getRoutesInner(sessionId)
222
+ if (inner && inner.size > 0) {
223
+ if (channel) return inner.get(channel) || null
224
+ // 无 channel hint:返回最新注册的(Map 保留插入顺序,最后一个 value 是 latest)
225
+ let last = null
226
+ for (const r of inner.values()) last = r
227
+ return last
228
+ }
199
229
  const oc = getOpenClawConfig()
200
230
  if (!oc.targetUserId) return null
201
231
  return {
@@ -219,7 +249,7 @@ export function createOpenClawBridge({
219
249
  if (!rateLimitOk()) return { ok: false, reason: 'rate_limited' }
220
250
 
221
251
  const oc = getOpenClawConfig()
222
- const route = sessionId ? resolveRoute(sessionId) : null
252
+ const route = sessionId ? resolveRoute(sessionId, channel || null) : null
223
253
  const effectiveChannel = channel || route?.channel || oc.channel || 'openclaw-weixin'
224
254
  const rawTarget = target || route?.targetUserId || oc.targetUserId
225
255
  const effectiveTarget = normalizeTarget(rawTarget, effectiveChannel)
@@ -269,8 +299,8 @@ export function createOpenClawBridge({
269
299
  const threadIdForSend = route?.threadId || null
270
300
  // 防御兜底:sessionId-routed 但没拿到 thread → fallback 路径,不能静默落 General。
271
301
  // 仅当 caller 传了 sessionId 时启用(无 sessionId 是显式 broadcast,允许直发默认 chat)。
272
- if (!threadIdForSend && sessionId && !sessionRoutes.has(sessionId)) {
273
- logger.warn?.(`[openclaw-bridge] refuse send to telegram general: sid=${sessionId} has no registered route (routesSize=${sessionRoutes.size}); would have leaked to General. msgLen=${message.length}`)
302
+ if (!threadIdForSend && sessionId && !hasExplicitRoute(sessionId)) {
303
+ logger.warn?.(`[openclaw-bridge] refuse send to telegram general: sid=${sessionId} has no registered route (routesSize=${sessionRoutes.size}); would have leaked to General. msgLen=${message.length}`) // sessionRoutes.size = outer Map size (number of sessions)
274
304
  return { ok: false, reason: 'no_thread_id_route_missing' }
275
305
  }
276
306
  logger.info?.(`[openclaw-bridge] telegram send sessionId=${sessionId} chatId=${effectiveTarget} threadId=${threadIdForSend} (route=${route ? JSON.stringify({tid: route.threadId, tn: route.topicName}) : 'null'}) attachment=${attachment ? 'yes' : 'no'} msgLen=${message.length}`)
@@ -445,12 +475,16 @@ export function createOpenClawBridge({
445
475
  function findSessionsByTarget(peer) {
446
476
  if (!peer) return []
447
477
  const out = []
448
- for (const [sid, info] of sessionRoutes) {
449
- // 同一个 peer 可能有多个 session;route 里 targetUserId 可能带或不带后缀
450
- const tgt = info?.targetUserId || ''
451
- if (tgt === peer || tgt.startsWith(peer + '@') || peer.startsWith(tgt + '@')) {
452
- out.push(sid)
478
+ for (const [sid, inner] of sessionRoutes) {
479
+ let matched = false
480
+ for (const info of inner.values()) {
481
+ const tgt = info?.targetUserId || ''
482
+ if (tgt === peer || tgt.startsWith(peer + '@') || peer.startsWith(tgt + '@')) {
483
+ matched = true
484
+ break
485
+ }
453
486
  }
487
+ if (matched) out.push(sid)
454
488
  }
455
489
  return out
456
490
  }
@@ -479,15 +513,17 @@ export function createOpenClawBridge({
479
513
  function findSessionByRoute({ channel = null, chatId, threadId = null, rootMessageId = null } = {}) {
480
514
  if (!chatId) return null
481
515
  const targetStr = String(chatId)
482
- for (const [sid, info] of sessionRoutes) {
483
- if (channel && info?.channel !== channel) continue
484
- if (String(info?.targetUserId || '') !== targetStr) continue
485
- if (rootMessageId) {
486
- if (info?.rootMessageId === rootMessageId) return sid
487
- continue
516
+ for (const [sid, inner] of sessionRoutes) {
517
+ for (const info of inner.values()) {
518
+ if (channel && info?.channel !== channel) continue
519
+ if (String(info?.targetUserId || '') !== targetStr) continue
520
+ if (rootMessageId) {
521
+ if (info?.rootMessageId === rootMessageId) return sid
522
+ continue
523
+ }
524
+ if ((info?.threadId || null) !== (threadId || null)) continue
525
+ return sid
488
526
  }
489
- if ((info?.threadId || null) !== (threadId || null)) continue
490
- return sid
491
527
  }
492
528
  return null
493
529
  }
@@ -535,15 +571,127 @@ export function createOpenClawBridge({
535
571
  }
536
572
  }
537
573
 
574
+ /**
575
+ * 把 user prompt echo 到所有已绑定的 IM thread,排除 origin channel。
576
+ * 路由从注入的 getRoutesForSession 拿(读 db 双路由),不依赖 in-memory sessionRoutes
577
+ * (后者每 session 只有一条 route,无法跨 telegram + lark 同时发)。
578
+ *
579
+ * 失败一律静默 warn —— echo 是辅助路径,不能影响 agent 的 Stop hook 主流程。
580
+ */
581
+ async function broadcastEcho({ sessionId, message, excludeChannel } = {}) {
582
+ if (!sessionId || !message) return { skipped: true, reason: 'missing_args' }
583
+ if (typeof getRoutesForSession !== 'function') return { skipped: true, reason: 'no_routes_fn' }
584
+ if (!rateLimitOk()) return { skipped: true, reason: 'rate_limited' }
585
+
586
+ let routes
587
+ try {
588
+ routes = getRoutesForSession(sessionId) || {}
589
+ } catch (e) {
590
+ logger?.warn?.(`[openclaw-bridge] broadcastEcho getRoutesForSession threw: ${e.message}`)
591
+ return { skipped: true, reason: 'routes_lookup_failed' }
592
+ }
593
+ const { telegram: tg, lark: lk } = routes
594
+ const results = { telegram: null, lark: null }
595
+
596
+ if (excludeChannel === 'telegram') {
597
+ results.telegram = { skipped: true, reason: 'excluded' }
598
+ } else if (!tg?.threadId || !tg?.targetUserId) {
599
+ results.telegram = { skipped: true, reason: 'no_route' }
600
+ } else {
601
+ const token = getTelegramTokenFromConfig(getConfig())
602
+ if (!token) {
603
+ results.telegram = { ok: false, reason: 'no_token' }
604
+ } else {
605
+ try {
606
+ results.telegram = await telegramSender({
607
+ token,
608
+ chatId: String(tg.targetUserId),
609
+ threadId: Number(tg.threadId),
610
+ text: message,
611
+ logger,
612
+ })
613
+ if (results.telegram?.ok) recordSend()
614
+ } catch (e) {
615
+ logger?.warn?.(`[openclaw-bridge] broadcastEcho telegram threw: ${e.message}`)
616
+ results.telegram = { ok: false, reason: 'threw', detail: e.message }
617
+ }
618
+ }
619
+ }
620
+
621
+ if (excludeChannel === 'lark') {
622
+ results.lark = { skipped: true, reason: 'excluded' }
623
+ } else if (!lk?.rootMessageId || !larkBot?.replyInThread) {
624
+ results.lark = { skipped: true, reason: 'no_route' }
625
+ } else {
626
+ try {
627
+ results.lark = await larkBot.replyInThread({
628
+ rootMessageId: String(lk.rootMessageId),
629
+ text: message,
630
+ })
631
+ if (results.lark?.ok) recordSend()
632
+ } catch (e) {
633
+ logger?.warn?.(`[openclaw-bridge] broadcastEcho lark threw: ${e.message}`)
634
+ results.lark = { ok: false, reason: 'threw', detail: e.message }
635
+ }
636
+ }
637
+
638
+ return results
639
+ }
640
+
641
+ /**
642
+ * Fan-out 文本到 sessionId 当前所有绑定的 channel。Stop hook / ask_user / dispatcher 警告类
643
+ * 消息走这里,确保 telegram + lark 都能看到(cross-channel mirror 对齐)。
644
+ *
645
+ * 实现:对每个绑定的 channel 调用一次 postText(显式带 channel),复用现有的限流、
646
+ * fast-path、CLI fallback。返回 byChannel 聚合结果。
647
+ */
648
+ async function broadcastText({ sessionId, message, replyMarkup = null, attachment = null, excludeChannel = null } = {}) {
649
+ if (!sessionId || !message) return { skipped: true, reason: 'missing_args' }
650
+ const routes = allRoutesForSession(sessionId)
651
+ if (!routes.length) {
652
+ // session 没有任何 in-memory 路由 → 退回单 postText(用 config 默认 target)。
653
+ // 行为对齐改造前的 postText({sessionId, message}) 老语义。
654
+ const r = await postText({ sessionId, message, replyMarkup, attachment })
655
+ return { ok: !!r?.ok, byChannel: { default: r }, fanout: false }
656
+ }
657
+ const byChannel = {}
658
+ let anyOk = false
659
+ for (const route of routes) {
660
+ if (route.channel === excludeChannel) {
661
+ byChannel[route.channel] = { skipped: true, reason: 'excluded' }
662
+ continue
663
+ }
664
+ const r = await postText({
665
+ sessionId,
666
+ message,
667
+ channel: route.channel,
668
+ target: route.targetUserId,
669
+ replyMarkup,
670
+ attachment,
671
+ })
672
+ byChannel[route.channel] = r
673
+ if (r?.ok) anyOk = true
674
+ }
675
+ return { ok: anyOk, byChannel, fanout: true }
676
+ }
677
+
538
678
  function setTelegramBot(bot) { telegramBot = bot }
539
679
  function setLarkBot(bot) { larkBot = bot || null }
540
680
  function setTopicGoneHandler(fn) { topicGoneHandler = typeof fn === 'function' ? fn : null }
541
681
  function listSessionRoutes() {
542
- return Array.from(sessionRoutes.entries()).map(([sessionId, info]) => ({ sessionId, ...info }))
682
+ const out = []
683
+ for (const [sid, inner] of sessionRoutes) {
684
+ for (const route of inner.values()) {
685
+ out.push({ sessionId: sid, ...route })
686
+ }
687
+ }
688
+ return out
543
689
  }
544
690
 
545
691
  return {
546
692
  postText,
693
+ broadcastText,
694
+ broadcastEcho,
547
695
  healthCheck,
548
696
  isEnabled,
549
697
  registerSessionRoute,
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url'
23
23
  import { DEFAULT_ROOT_DIR } from './config.js'
24
24
 
25
25
  const QUADTODO_MANAGED_KEY = '_quadtodoManaged'
26
- const HOOK_EVENTS = ['Stop', 'Notification', 'SessionEnd']
26
+ const HOOK_EVENTS = ['Stop', 'Notification', 'SessionEnd', 'UserPromptSubmit']
27
27
  const HOOK_VERSION_RE = /quadtodo-hook-version:\s*(\d+)/
28
28
 
29
29
  function defaultHookScriptPath() {
@@ -52,6 +52,7 @@ function buildHookEntry(event, hookScriptPath) {
52
52
  // Claude Code hook 格式(参考其文档):matchers 数组里每项有 type+command
53
53
  const eventLower = event === 'SessionEnd' ? 'session-end'
54
54
  : event === 'Notification' ? 'notification'
55
+ : event === 'UserPromptSubmit' ? 'user-prompt-submit'
55
56
  : 'stop'
56
57
  return {
57
58
  matcher: '*',