agentquad 0.4.5 → 0.4.7

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.
@@ -7,7 +7,7 @@ import { homedir } from 'node:os'
7
7
  import pidusage from 'pidusage'
8
8
  import { loadConfig, resolveToolsConfig, SUPPORTED_TOOLS, DEFAULT_ROOT_DIR } from '../config.js'
9
9
  import { writeRuntimeMcpConfig } from '../agent-installer-shared.js'
10
- import { CLAUDE_DEFAULT_PERMISSION_OPTIONS, extractPermissionPrompt, formatToolUseAsPrompt } from '../permission-prompt.js'
10
+ import { CLAUDE_DEFAULT_PERMISSION_OPTIONS, extractPermissionPrompt, formatToolUseAsPrompt, parsePermissionOptions } from '../permission-prompt.js'
11
11
  import { findLatestPendingToolUse } from '../claude-transcript.js'
12
12
 
13
13
  const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024
@@ -189,16 +189,31 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
189
189
  // 也翻 pending_confirm,会让 session 在 AI 完成回话之后无故卡在"待确认",前端没有任何
190
190
  // 入口能把它清掉(focus 只清 unread;markSessionRunningAfterInput 需要真实输入)。
191
191
  // 所以 idle / 其它非 running 状态下,直接拒绝翻转。
192
+ //
193
+ // 例外 allowIdleFlip=true:来自 Claude PTY-detector 兜底路径。它要求 anchor + ≥2 个数字
194
+ // 选项才 emit,假阳性概率极低;而 Claude 在 `permissions.defaultMode='auto'` 或用户在
195
+ // 终端里 Shift+Tab 切到 plan/acceptEdits 时,权限框可能在 Stop hook 之后(status=idle)
196
+ // 才浮出来——这种 case 真权限信号下,必须允许从 idle 翻 pending_confirm。
197
+ //
192
198
  // promptText 可由 caller 显式传入(Codex 的 prompt-detector 已经握有一段干净文本);
193
199
  // 不传则从 session.recentOutput 提取尾部(Claude Notification 路径走这条)。
194
200
  // 状态已经是 pending_confirm 时也允许更新 pendingPrompt —— 同一轮等待中 PTY
195
201
  // 可能继续追加输出(如选项变化、高亮位移),让前端拿到最新文案。
196
- function markPendingConfirm(sessionId, { source = null, promptText = null } = {}) {
202
+ function markPendingConfirm(sessionId, { source = null, promptText = null, allowIdleFlip = false } = {}) {
197
203
  const session = sessions.get(sessionId)
198
204
  if (!session) return false
199
205
  if (!LIVE_AI_STATUSES.has(session.status)) return false
206
+ // Cursor 的 notification 语义跟 Claude 完全不一样:cursor-hooks 把 cursor 的
207
+ // `beforeSubmitPrompt`("我要把 prompt 发给 AI 了")映射成 notification 事件,
208
+ // 它**不是**权限请求。如果照旧翻 pending_confirm,cursor session 在 acceptEdits
209
+ // (--force) 模式下每次用户发消息都被卡进"待确认"状态出不来。
210
+ // 现阶段 cursor 没有"真权限请求"的可靠信号(cursor-prompt-detector 不存在,
211
+ // cursor TUI 也没像 Claude 那样的 Esc to cancel 页脚),所以直接拒所有 cursor
212
+ // 的 pending_confirm 翻转。如果将来加 cursor 专用 detector,再放开。
213
+ if (session.tool === 'cursor') return false
200
214
  const wasPending = session.status === 'pending_confirm'
201
- if (!wasPending && session.status !== 'running') return false
215
+ const wasIdle = session.status === 'idle'
216
+ if (!wasPending && session.status !== 'running' && !(wasIdle && allowIdleFlip)) return false
202
217
 
203
218
  let text = ''
204
219
  let options = []
@@ -219,18 +234,24 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
219
234
  }
220
235
 
221
236
  // 兜底:从 PTY 提取(Codex 主路径 / Claude jsonl 拿不到时的 backup)。
222
- // recentOutput 是 4KB 滑窗,TUI redraw 抖动会冲掉真实 prompt 文本;
223
- // 再兜底用 outputHistory(最大 5MB)的尾部 ~64KB,让 extractor 能找到锚点。
224
237
  if (!text) {
225
- const extractSource = promptText || session.recentOutput || ''
226
- let historicalRaw = null
227
- if (!promptText && Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
228
- const joined = session.outputHistory.join('')
229
- historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
238
+ if (promptText) {
239
+ // caller 显式给了 promptText(codex-prompt-detector / claude-prompt-detector
240
+ // 已经在自己那侧做过严格匹配 + 清洗,这里完全相信,只跑一遍 options 解析)
241
+ text = String(promptText).slice(-1200)
242
+ options = parsePermissionOptions(text)
243
+ } else {
244
+ // 真正只从 PTY 缓冲爬:recentOutput 4KB 滑窗 + outputHistory 5MB 尾部
245
+ // extractPermissionPrompt 内部走严格 anchor + 数字选项 + 末尾 footer 的窗口规则。
246
+ let historicalRaw = null
247
+ if (Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
248
+ const joined = session.outputHistory.join('')
249
+ historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
250
+ }
251
+ const r = extractPermissionPrompt(session.recentOutput || '', { historicalRaw })
252
+ text = r.text
253
+ options = r.options
230
254
  }
231
- const r = extractPermissionPrompt(extractSource, { historicalRaw })
232
- text = r.text
233
- options = r.options
234
255
  }
235
256
  const hasContent = !!(text || options.length)
236
257
  const prevPrompt = session.permissionPrompt || null
@@ -509,7 +530,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
509
530
  })
510
531
 
511
532
  // ─── 程序化 session 启动入口(供 orchestrator 等模块直接调用,跳过 HTTP) ───
512
- function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false, parentTodoId = null }) {
533
+ function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false, parentTodoId = null, suppressStaleTurnDetect = false }) {
513
534
  if (!todoId || typeof prompt !== 'string' || !tool) {
514
535
  const err = new Error('missing todoId, prompt, or tool'); err.code = 'bad_request'
515
536
  throw err
@@ -636,6 +657,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
636
657
  extraEnv: { ...(extraEnv || {}), ...autoEnv },
637
658
  mcpConfigPath: runtimeMcpPath,
638
659
  codexMcpUrl,
660
+ suppressStaleTurnDetect,
639
661
  })
640
662
  // 2. 读出 preset nativeId(claude 新会话 = randomUUID, resume = resumeNativeId, codex 新 = null)。
641
663
  // 这是让"首屏即正确"成立的核心:先于 db.updateTodo 拿到值。
@@ -661,18 +683,20 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
661
683
  ...(label ? { label } : {}),
662
684
  }),
663
685
  })
664
- // 4. 5s 兜底:前端如果一直没发合法 init(极少见 — /exec 返回后 WS 还没连上),
686
+ // 4. 30s 兜底:前端如果一直没发合法 init(极少见 — 旧版本前端 / 网络真的挂了),
665
687
  // 用老的 80×24 兜底 spawn,避免 session 永远卡在 create 状态。
688
+ // 30s(不是 5s)的理由:新前端在隐藏挂载时会延迟到 IO 可见才发 init,主人停留在
689
+ // conversation tab 默认体验里也不应该撞兜底;30s 既给足切换窗口、又保留挂掉时的退路。
666
690
  session.spawnFallbackTimer = setTimeout(() => {
667
691
  session.spawnFallbackTimer = null
668
692
  if (session.spawned) return
669
- console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 5s)`)
693
+ console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 30s)`)
670
694
  session.spawned = true
671
695
  pty.startWithSize(sessionId, 80, 24).catch((e) => {
672
696
  console.warn(`[ai-terminal] spawn fallback failed: ${e.message}`)
673
697
  session.spawned = false
674
698
  })
675
- }, 5000)
699
+ }, 30000)
676
700
  session.spawnFallbackTimer.unref?.()
677
701
  } catch (error) {
678
702
  sessions.delete(sessionId)
@@ -762,6 +786,9 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
762
786
  outputBytesTotal: s.outputBytesTotal || 0,
763
787
  awaitingReply: !!s.awaitingReply,
764
788
  permissionPrompt: s.permissionPrompt || null,
789
+ // s 是 route 自己的 sessions Map 里的对象;usage 是 PtyManager 的 watcher
790
+ // 写在它自己 sessions Map 的对象上,必须显式跨读,不能直接 s.usage。
791
+ usage: pty.getUsage(sessionId),
765
792
  })
766
793
  }
767
794
  res.json({ ok: true, sessions: out })
@@ -1058,20 +1085,27 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1058
1085
  session.autoMode = nextAutoMode
1059
1086
  broadcastToSession(session, { type: 'auto_mode', autoMode: session.autoMode || null })
1060
1087
 
1061
- if (nextAutoMode !== 'bypass' || session.tool !== 'claude') return
1088
+ // 对称重启:claude/codex/cursor 三家 CLI permission mode 都是启动参数注入,
1089
+ // 运行时切换必须重启 PTY 才能让新 mode 真正生效。只要前后 effective mode 不同就重启;
1090
+ // 相同 / 工具不支持 / 没有 nativeSessionId 时退路(broadcast / 软通知)。
1091
+ const prevEffective = session.permissionMode || 'default'
1092
+ const nextEffective = nextAutoMode || 'default'
1093
+ if (prevEffective === nextEffective) return
1094
+ const RESTARTABLE_TOOLS = new Set(['claude', 'codex', 'cursor'])
1095
+ if (!RESTARTABLE_TOOLS.has(session.tool)) return
1062
1096
 
1063
1097
  if (!session.nativeSessionId) {
1064
1098
  sendToBrowser(ws, {
1065
1099
  type: 'auto_mode_notice',
1066
- autoMode: 'bypass',
1100
+ autoMode: nextAutoMode,
1067
1101
  immediate: false,
1068
1102
  reason: 'native_session_missing',
1069
- message: '当前 Claude 会话尚未拿到原生 session id,全托管将仅对后续启动/恢复的会话生效。',
1103
+ message: '当前会话尚未拿到原生 session id,模式切换将仅对后续启动/恢复的会话生效。',
1070
1104
  })
1071
1105
  return
1072
1106
  }
1073
1107
 
1074
- broadcastToSession(session, { type: 'auto_mode_switching', target: 'bypass' })
1108
+ broadcastToSession(session, { type: 'auto_mode_switching', target: nextEffective })
1075
1109
  const todoSnapshot = db.getTodo(session.todoId)
1076
1110
  session.replacedBySessionId = '__pending__'
1077
1111
  let restarted
@@ -1082,29 +1116,44 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1082
1116
  tool: session.tool,
1083
1117
  cwd: session.cwd || undefined,
1084
1118
  resumeNativeId: session.nativeSessionId,
1085
- permissionMode: 'bypass',
1086
- label: 'runtime:bypass',
1119
+ permissionMode: nextEffective,
1120
+ label: `runtime:${nextEffective}`,
1087
1121
  skipTelegram: true,
1088
1122
  ignoreExistingNativeSessionId: true,
1123
+ // 老 PTY 即将被 kill,新的 claude --resume 只是接管 jsonl —— 没有真跑新一轮。
1124
+ // 让 watcher 吃掉第一帧 stale 状态,避免它把刚翻成 idle 的状态再翻回 running。
1125
+ suppressStaleTurnDetect: true,
1089
1126
  })
1090
1127
  } catch (e) {
1091
1128
  delete session.replacedBySessionId
1092
1129
  restoreSessionAsCurrent(session, todoSnapshot)
1093
1130
  sendToBrowser(ws, {
1094
1131
  type: 'auto_mode_notice',
1095
- autoMode: 'bypass',
1132
+ autoMode: nextAutoMode,
1096
1133
  immediate: false,
1097
1134
  reason: 'restart_failed',
1098
- message: `切换全托管失败:${e.message}`,
1135
+ message: `切换托管模式失败:${e.message}`,
1099
1136
  })
1100
1137
  return
1101
1138
  }
1102
1139
 
1140
+ // 把新 session 翻到 idle:托管模式切换的语义就是"打断当前轮、换一套权限再起来",
1141
+ // 老 PTY 已经被 kill 在 mid-turn,新的 claude --resume 不会自动续上那一轮 ——
1142
+ // 它只是接管 jsonl 等用户输入。spawnSession 默认 status='running' 适用于"首次启动 +
1143
+ // CLI 直接吃 prompt"的场景;resume-after-mode-switch 都应该是 idle。
1144
+ // RESTARTABLE_TOOLS (claude/codex/cursor) 一视同仁——三家都是"resume 不续轮"的语义。
1145
+ const newSession = sessions.get(restarted.sessionId)
1146
+ if (newSession && LIVE_AI_STATUSES.has(newSession.status)) {
1147
+ newSession.status = 'idle'
1148
+ newSession.awaitingReply = true
1149
+ newSession.lastTurnDoneAt = Date.now()
1150
+ persistLiveSessionState(newSession, 'idle', 'ai_running', { lastTurnDoneAt: newSession.lastTurnDoneAt })
1151
+ }
1103
1152
  broadcastToSession(session, {
1104
1153
  type: 'session_restarted',
1105
1154
  oldSessionId: sessionId,
1106
1155
  newSessionId: restarted.sessionId,
1107
- autoMode: 'bypass',
1156
+ autoMode: nextEffective,
1108
1157
  })
1109
1158
  if (restarted.sessionId !== sessionId) {
1110
1159
  session.replacedBySessionId = restarted.sessionId
@@ -56,12 +56,14 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
56
56
  const sid = aiSess.sessionId
57
57
  if (!sid) continue
58
58
  const liveSess = aiTerminal.sessions.get(sid)
59
- const bridgeRoute = openclaw.resolveRoute?.(sid) || null
59
+ // per-channel route lookup(dual-bound session 时避免用错渠道的路由)
60
+ const bridgeTgRoute = openclaw.resolveRoute?.(sid, 'telegram') || null
61
+ const bridgeLarkRoute = openclaw.resolveRoute?.(sid, 'lark') || null
60
62
 
61
63
  // ── Telegram 分支 ──
62
64
  if (telegramEnabled) {
63
65
  const dbHasTgRoute = !!aiSess.telegramRoute?.threadId
64
- const bridgeHasTgRoute = bridgeIsTelegram(bridgeRoute)
66
+ const bridgeHasTgRoute = bridgeIsTelegram(bridgeTgRoute)
65
67
  if (isAlive(liveSess)) {
66
68
  if (!dbHasTgRoute && !bridgeHasTgRoute) {
67
69
  actions.push({
@@ -74,7 +76,7 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
74
76
  })
75
77
  }
76
78
  } else if ((dbHasTgRoute || bridgeHasTgRoute) && t.status !== 'done') {
77
- const tgRoute = aiSess.telegramRoute || (bridgeHasTgRoute ? bridgeRoute : null)
79
+ const tgRoute = aiSess.telegramRoute || (bridgeHasTgRoute ? bridgeTgRoute : null)
78
80
  if (tgRoute) {
79
81
  actions.push({
80
82
  type: 'close_topic',
@@ -93,7 +95,7 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
93
95
  // ── Lark 分支 ──
94
96
  if (larkEnabled) {
95
97
  const dbHasLarkRoute = !!aiSess.larkRoute?.rootMessageId
96
- const bridgeHasLarkRoute = bridgeIsLark(bridgeRoute)
98
+ const bridgeHasLarkRoute = bridgeIsLark(bridgeLarkRoute)
97
99
  if (isAlive(liveSess)) {
98
100
  if (!dbHasLarkRoute && !bridgeHasLarkRoute) {
99
101
  actions.push({
@@ -106,7 +108,7 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
106
108
  })
107
109
  }
108
110
  } else if ((dbHasLarkRoute || bridgeHasLarkRoute) && t.status !== 'done') {
109
- const larkRoute = aiSess.larkRoute || (bridgeHasLarkRoute ? bridgeRoute : null)
111
+ const larkRoute = aiSess.larkRoute || (bridgeHasLarkRoute ? bridgeLarkRoute : null)
110
112
  if (larkRoute) {
111
113
  actions.push({
112
114
  type: 'close_thread',
package/src/server.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  loadConfig,
14
14
  resolveToolsConfig,
15
15
  saveConfig,
16
+ withConfigLock,
16
17
  } from "./config.js";
17
18
  import { openDb } from "./db.js";
18
19
  import { PtyManager } from "./pty.js";
@@ -377,6 +378,15 @@ function mergeToolConfig(currentTool = {}, nextTool = {}) {
377
378
  };
378
379
  }
379
380
 
381
+ // "" => 视作「该字段未修改」,从 patch 里删掉。null => 显式清空,保留。
382
+ // 仅扫一层(telegram / lark 的字段都在顶层,没有嵌套 string)。
383
+ function stripEmptyStrings(patch) {
384
+ if (!patch || typeof patch !== "object") return;
385
+ for (const key of Object.keys(patch)) {
386
+ if (patch[key] === "") delete patch[key];
387
+ }
388
+ }
389
+
380
390
  function splitEditorPath(rawPath = "") {
381
391
  const trimmed = String(rawPath || "").trim();
382
392
  const match = trimmed.match(/^(.*?)(:\d+(?::\d+)?)?$/);
@@ -589,6 +599,34 @@ export function createServer(opts = {}) {
589
599
  }
590
600
  });
591
601
 
602
+ // Claude stdout 提示词检测器命中 → 兜底 Notification hook 不 fire 的场景
603
+ // (settings.json permissions.defaultMode='auto' 时 model classifier 触发的权限框
604
+ // 实测不走 Notification hook)。复用 handleClaude 的 Notification 分支:
605
+ // markPendingConfirm + IM 推送都靠现有逻辑跑;与真 Notification 之间用 cooldown 去重。
606
+ pty.on("claude-prompt", async (data) => {
607
+ const port = runtimeConfig?.port || 5677;
608
+ // 显式 info 日志:detector 失火 vs Notification hook 失火很难肉眼区分,留个面包屑
609
+ // 方便定位"宝子又说没生效"那类问题(grep server.log "claude-prompt")。
610
+ console.log(`[claude-prompt] detector fired sid=${data.sessionId} opts=${(data.options || []).length} prompt=${(data.promptText || '').slice(0, 80).replace(/\n/g, ' ')}`);
611
+ try {
612
+ await fetch(`http://127.0.0.1:${port}/api/openclaw/hook`, {
613
+ method: "POST",
614
+ headers: { "content-type": "application/json" },
615
+ body: JSON.stringify({
616
+ source: "claude",
617
+ path: "detector",
618
+ event: "Notification",
619
+ sessionId: data.sessionId,
620
+ nativeId: data.nativeId,
621
+ promptText: data.promptText,
622
+ options: data.options,
623
+ }),
624
+ });
625
+ } catch (e) {
626
+ console.warn("[claude-prompt] post failed:", e.message);
627
+ }
628
+ });
629
+
592
630
  // Telegram 自动 topic 钩子:ait 创建在前,wizard 创建在后;用 lazy ref 桥接
593
631
  const aiSessionHooks = {
594
632
  onSessionSpawned: () => null,
@@ -640,6 +678,7 @@ export function createServer(opts = {}) {
640
678
 
641
679
  app.put("/api/config", async (req, res) => {
642
680
  try {
681
+ const result = await withConfigLock(async () => {
643
682
  const current = loadConfig({ rootDir: configRootDir });
644
683
  const nextToolsPatch = req.body?.tools || {};
645
684
  const pricingPatch = req.body?.pricing;
@@ -660,6 +699,12 @@ export function createServer(opts = {}) {
660
699
  // botTokenMasked / botTokenSource 是 GET-only,PUT 收到的不能写回
661
700
  delete telegramPatch.botTokenMasked;
662
701
  delete telegramPatch.botTokenSource;
702
+ // 防御性 guard:任何 string 字段为 '' 都视为「保留磁盘原值」。
703
+ // Drawer 在 form 未就绪时会把整段 telegram/lark 都用 '' 兜底;
704
+ // 没有这层保护就会把磁盘上现存的 appId / supergroupId / chatId 等清空,
705
+ // 然后每次保存都延续空值,造成「偶尔丢、丢了之后回不来」。
706
+ // 显式清空请用 null。
707
+ stripEmptyStrings(telegramPatch);
663
708
 
664
709
  // 合并 telegram / lark 段
665
710
  const mergedTelegram = { ...current.telegram, ...telegramPatch };
@@ -672,6 +717,7 @@ export function createServer(opts = {}) {
672
717
  }
673
718
  delete larkPatch.appSecretMasked;
674
719
  delete larkPatch.appSecretSource;
720
+ stripEmptyStrings(larkPatch);
675
721
  const mergedLark = { ...current.lark, ...larkPatch };
676
722
 
677
723
  // 检测 bot 段是否变化(用于触发热重启)
@@ -742,7 +788,7 @@ export function createServer(opts = {}) {
742
788
  const { token, source } = readBotTokenWithSource(() => reloadedCfg);
743
789
  const { botToken: _drop, ...telegramSafe } = reloadedCfg.telegram || {};
744
790
 
745
- res.json({
791
+ return {
746
792
  ok: true,
747
793
  config: {
748
794
  ...reloadedCfg,
@@ -760,7 +806,9 @@ export function createServer(opts = {}) {
760
806
  larkRestart,
761
807
  },
762
808
  telegramRestart,
809
+ };
763
810
  });
811
+ res.json(result);
764
812
  } catch (e) {
765
813
  res.status(500).json({ ok: false, error: e.message });
766
814
  }
@@ -1185,6 +1233,30 @@ export function createServer(opts = {}) {
1185
1233
  // OpenClaw 双向桥接:bridge(出站)+ pending-question 协调器(双向阻塞)
1186
1234
  const openclawBridge = createOpenClawBridge({
1187
1235
  getConfig: () => loadConfig({ rootDir: configRootDir }),
1236
+ getRoutesForSession: (sessionId) => {
1237
+ if (!sessionId) return { telegram: null, lark: null }
1238
+ // 优先用 in-memory session 拿 todoId(O(1)),失败再 fallback listTodos 全扫
1239
+ let todoId = null
1240
+ try {
1241
+ const sess = ait?.sessions?.get?.(sessionId)
1242
+ todoId = sess?.todoId || null
1243
+ } catch { /* ignore */ }
1244
+ if (todoId) {
1245
+ try {
1246
+ const todo = db.getTodo?.(todoId)
1247
+ const ai = (todo?.aiSessions || []).find(s => s?.sessionId === sessionId)
1248
+ if (ai) return { telegram: ai.telegramRoute || null, lark: ai.larkRoute || null }
1249
+ } catch { /* fallthrough */ }
1250
+ }
1251
+ try {
1252
+ const todos = db.listTodos?.({ status: 'all', archived: 'all' }) || []
1253
+ for (const t of todos) {
1254
+ const ai = (t.aiSessions || []).find(s => s?.sessionId === sessionId)
1255
+ if (ai) return { telegram: ai.telegramRoute || null, lark: ai.larkRoute || null }
1256
+ }
1257
+ } catch { /* ignore */ }
1258
+ return { telegram: null, lark: null }
1259
+ },
1188
1260
  });
1189
1261
  const pendingCoord = createPendingQuestionCoordinator({ db });
1190
1262
  pendingCoord.start();
@@ -1293,6 +1365,11 @@ export function createServer(opts = {}) {
1293
1365
  getConfig: () => loadConfig({ rootDir: configRootDir }),
1294
1366
  wizard: {
1295
1367
  handleInbound: (...args) => openclawWizardLazyRef.handleInbound(...args),
1368
+ // 历史 bug:早先这里只挂了 handleInbound,导致 lark-bot.handleCardAction
1369
+ // 调 wizard.handleCallback 时 typeof !== 'function' → 所有飞书卡片按钮
1370
+ // click 一律被 drop,Lark UI 弹「服务未就绪」。telegram 注入路径(上方
1371
+ // createTelegramBot 那段)一直有这条,本次对齐补全。
1372
+ handleCallback: (...args) => openclawWizardLazyRef.handleCallback(...args),
1296
1373
  },
1297
1374
  logger: { warn: (...a) => console.warn(...a), info: (...a) => console.log(...a) },
1298
1375
  })
@@ -1339,8 +1416,8 @@ export function createServer(opts = {}) {
1339
1416
  '回 esc / 退出菜单 → 我帮你按 Esc 退出 modal',
1340
1417
  '回 中断 / ctrl+c → 我帮你打断当前任务',
1341
1418
  ].join('\n')
1342
- openclawBridge.postText({ sessionId, message })
1343
- .catch((e) => console.warn(`[tui-detected] postText failed: ${e.message}`))
1419
+ openclawBridge.broadcastText({ sessionId, message })
1420
+ .catch((e) => console.warn(`[tui-detected] broadcastText failed: ${e.message}`))
1344
1421
  })
1345
1422
 
1346
1423
  // holder Proxy: 让 hook / wizard 不用改源码,每次读属性都从 holder.current 拿最新实例
@@ -1401,9 +1478,9 @@ export function createServer(opts = {}) {
1401
1478
  onStale: async ({ sessionId, queueSize }) => {
1402
1479
  const text = `⚠️ session 有 ${queueSize} 条排队消息超过 5 分钟未投递,看起来卡住了。可发送 \`!!\` 中断后重新发送。`
1403
1480
  try {
1404
- await openclawBridge?.postText?.({ sessionId, message: text })
1481
+ await openclawBridge?.broadcastText?.({ sessionId, message: text })
1405
1482
  } catch (e) {
1406
- console.warn(`[server] dispatcher.onStale postText failed: ${e.message}`)
1483
+ console.warn(`[server] dispatcher.onStale broadcastText failed: ${e.message}`)
1407
1484
  }
1408
1485
  },
1409
1486
  onSessionEnd: async ({ sessionId, undeliveredCount, undeliveredTexts }) => {
@@ -1412,9 +1489,9 @@ export function createServer(opts = {}) {
1412
1489
  const more = undeliveredCount > 3 ? `\n(还有 ${undeliveredCount - 3} 条未列出)` : ''
1413
1490
  const text = `⚠️ session 已结束,未投递 ${undeliveredCount} 条消息:\n${preview}${more}`
1414
1491
  try {
1415
- await openclawBridge?.postText?.({ sessionId, message: text })
1492
+ await openclawBridge?.broadcastText?.({ sessionId, message: text })
1416
1493
  } catch (e) {
1417
- console.warn(`[server] dispatcher.onSessionEnd postText failed: ${e.message}`)
1494
+ console.warn(`[server] dispatcher.onSessionEnd broadcastText failed: ${e.message}`)
1418
1495
  }
1419
1496
  },
1420
1497
  },
@@ -1499,7 +1576,7 @@ export function createServer(opts = {}) {
1499
1576
  console.log(`[server] skip auto-close: sid=${sessionId} exit=${exitCode} lifetime=${lifetimeMs}ms`)
1500
1577
  return null
1501
1578
  }
1502
- const route = openclawBridge.resolveRoute?.(sessionId)
1579
+ const route = openclawBridge.resolveRoute?.(sessionId, 'telegram')
1503
1580
  if (!route?.threadId) return null
1504
1581
  return openclawWizard.handleTopicEvent({
1505
1582
  type: 'closed',
@@ -1520,14 +1597,15 @@ export function createServer(opts = {}) {
1520
1597
  let sweptLark = 0
1521
1598
  for (const [sid, sess] of ait.sessions) {
1522
1599
  if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
1523
- const r = openclawBridge.resolveRoute?.(sid)
1524
- if (tgSweep && !r?.threadId) {
1600
+ const rTg = tgSweep ? openclawBridge.resolveRoute?.(sid, 'telegram') : null
1601
+ if (tgSweep && !rTg?.threadId) {
1525
1602
  openclawWizard.ensureTopicForSession({ sessionId: sid, todoId: sess.todoId })
1526
1603
  .then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → telegram thread ${res.threadId}`))
1527
1604
  .catch((e) => console.warn(`[server] sweep ensureTopic failed for ${sid}: ${e.message}`))
1528
1605
  sweptTg++
1529
1606
  }
1530
- if (larkSweep && !(r?.channel === 'lark' && r?.rootMessageId)) {
1607
+ const rLark = larkSweep ? openclawBridge.resolveRoute?.(sid, 'lark') : null
1608
+ if (larkSweep && !rLark?.rootMessageId) {
1531
1609
  openclawWizard.ensureLarkThreadForSession({ sessionId: sid, todoId: sess.todoId })
1532
1610
  .then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → lark root ${res.rootMessageId}`))
1533
1611
  .catch((e) => console.warn(`[server] sweep ensureLarkThread failed for ${sid}: ${e.message}`))
@@ -1572,7 +1650,7 @@ export function createServer(opts = {}) {
1572
1650
  let kicked = 0
1573
1651
  for (const [sid, sess] of ait.sessions) {
1574
1652
  if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
1575
- const r = openclawBridge.resolveRoute?.(sid)
1653
+ const r = openclawBridge.resolveRoute?.(sid, 'telegram')
1576
1654
  if (!r?.threadId) continue
1577
1655
  if (tracker.has(sid)) continue
1578
1656
  tracker.start({ sessionId: sid, skipTitleRename: true })
@@ -8,6 +8,8 @@
8
8
  * - hard_cancel :`!!` 前缀 或精确 `/stop`,busy 时 Ctrl+C,不投递文本
9
9
  */
10
10
 
11
+ import { createHash } from 'node:crypto'
12
+
11
13
  const QUEUE_LIMIT = 20
12
14
  const STALE_MS = 5 * 60 * 1000
13
15
  const SOFT_INTERRUPT_DELAY_MS = 250
@@ -18,6 +20,14 @@ const SOFT_INTERRUPT_DELAY_MS = 250
18
20
  // 输出 spinner / token,3s 静默基本不可能是真 busy)。
19
21
  const IDLE_GRACE_MS = 3000
20
22
 
23
+ const ORIGIN_TTL_MS = 30_000
24
+ const ORIGIN_LIMIT = 16
25
+
26
+ function normalizeAndHash(text) {
27
+ const normalized = String(text || '').trim().replace(/\s+/g, ' ')
28
+ return createHash('sha1').update(normalized).digest('hex')
29
+ }
30
+
21
31
  export function parseTrigger(rawText) {
22
32
  const text = String(rawText || '').trim()
23
33
  if (text === '/stop') return { mode: 'hard_cancel', stripped: '' }
@@ -56,6 +66,33 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
56
66
  // sessionId set: 软中断 250ms 窗口内
57
67
  const softInterrupting = new Set()
58
68
 
69
+ // sessionId → Array<{ hash, channel, ts }>。30s TTL,FIFO 上限 ORIGIN_LIMIT。
70
+ // 用于让 UserPromptSubmit hook 区分"这条 prompt 来自 telegram / lark / PC"。
71
+ const lastOrigins = new Map()
72
+
73
+ function recordOrigin(sessionId, text, channel) {
74
+ if (!sessionId || !text || !channel) return
75
+ const now = Date.now()
76
+ const prior = (lastOrigins.get(sessionId) || []).filter(e => now - e.ts < ORIGIN_TTL_MS)
77
+ const trimmed = prior.slice(-(ORIGIN_LIMIT - 1))
78
+ trimmed.push({ hash: normalizeAndHash(text), channel, ts: now })
79
+ lastOrigins.set(sessionId, trimmed)
80
+ }
81
+
82
+ function consumeOrigin(sessionId, text) {
83
+ if (!sessionId || !text) return null
84
+ const arr = lastOrigins.get(sessionId)
85
+ if (!arr || !arr.length) return null
86
+ const h = normalizeAndHash(text)
87
+ const now = Date.now()
88
+ const idx = arr.findIndex(e => e.hash === h && now - e.ts < ORIGIN_TTL_MS)
89
+ if (idx < 0) return null
90
+ const { channel } = arr[idx]
91
+ arr.splice(idx, 1)
92
+ if (!arr.length) lastOrigins.delete(sessionId)
93
+ return channel
94
+ }
95
+
59
96
  function getOrCreateQueue(sessionId) {
60
97
  let q = queues.get(sessionId)
61
98
  if (!q) {
@@ -70,7 +107,7 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
70
107
  if (q.items.length >= QUEUE_LIMIT) {
71
108
  return { full: true, queueSize: q.items.length }
72
109
  }
73
- q.items.push({ text: stripped, imagePaths, enqueuedAt: Date.now() })
110
+ q.items.push({ text: stripped, imagePaths, channel, enqueuedAt: Date.now() })
74
111
  if (q.staleTimer) clearTimeout(q.staleTimer)
75
112
  q.staleTimer = setTimeout(() => {
76
113
  if (callbacks.onStale) {
@@ -120,6 +157,7 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
120
157
  const payload = buildPayload(stripped, imagePaths)
121
158
  writeToPty(pty, sessionId, payload, logger)
122
159
  markBusyAfterWrite(aiTerminal, sessionId)
160
+ if (channel && stripped) recordOrigin(sessionId, stripped, channel)
123
161
  return { action: 'sent', sessionId }
124
162
  }
125
163
 
@@ -144,7 +182,7 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
144
182
  if (r.full) return { action: 'queue_full', queueSize: r.queueSize, sessionId }
145
183
  return { action: 'queued', queueSize: r.queueSize, reason: 'soft_interrupt_in_progress', sessionId }
146
184
  }
147
- return await performSoftInterrupt({ sessionId, stripped, imagePaths })
185
+ return await performSoftInterrupt({ sessionId, stripped, imagePaths, channel })
148
186
  }
149
187
 
150
188
  if (mode === 'hard_cancel') {
@@ -168,7 +206,7 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
168
206
  return { action: 'hard_cancelled', sessionId }
169
207
  }
170
208
 
171
- async function performSoftInterrupt({ sessionId, stripped, imagePaths }) {
209
+ async function performSoftInterrupt({ sessionId, stripped, imagePaths, channel }) {
172
210
  // 丢弃旧队列
173
211
  const q = queues.get(sessionId)
174
212
  if (q) {
@@ -186,6 +224,7 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
186
224
  const payload = buildPayload(stripped, imagePaths)
187
225
  writeToPty(pty, sessionId, payload, logger)
188
226
  markBusyAfterWrite(aiTerminal, sessionId)
227
+ if (channel && stripped) recordOrigin(sessionId, stripped, channel)
189
228
  }
190
229
  return { action: 'soft_interrupted', sessionId }
191
230
  }
@@ -211,6 +250,10 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
211
250
  logger?.warn?.(`[dispatcher] flush write failed sid=${sessionId}: ${e.message}`)
212
251
  return { flushed: 0, error: e.message }
213
252
  }
253
+ // 写入成功才记 origin —— 跟 idle / soft-interrupt 两条路径保持一致(recordOrigin 不抛错,所以放 try 外)
254
+ // 取队列里最新的 channel;混合 channel 场景极少见,echo 会跳过那一个 channel
255
+ const lastChan = q.items[q.items.length - 1]?.channel
256
+ if (lastChan && combinedText) recordOrigin(sessionId, combinedText, lastChan)
214
257
  if (callbacks.onFlush) {
215
258
  try { await callbacks.onFlush({ sessionId, count }) }
216
259
  catch (e) { logger?.warn?.(`[dispatcher] onFlush callback failed: ${e.message}`) }
@@ -223,6 +266,7 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
223
266
  }
224
267
 
225
268
  async function onSessionEnd(sessionId) {
269
+ lastOrigins.delete(sessionId)
226
270
  const q = queues.get(sessionId)
227
271
  if (!q) return
228
272
  if (q.staleTimer) clearTimeout(q.staleTimer)
@@ -252,5 +296,5 @@ export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {},
252
296
  return { sessions: queues.size, byId }
253
297
  }
254
298
 
255
- return { send, onSessionIdle, onSessionEnd, describe, __test__: { queues, parseTrigger } }
299
+ return { send, onSessionIdle, onSessionEnd, describe, recordOrigin, consumeOrigin, __test__: { queues, parseTrigger } }
256
300
  }