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.
- package/dist-web/assets/{index-By--XlP3.js → index-BEiPvgk7.js} +237 -235
- package/dist-web/assets/index-qY2UiOW2.css +32 -0
- package/dist-web/index.html +2 -2
- package/package.json +7 -1
- package/src/claude-prompt-detector.js +72 -0
- package/src/codex-hook-installer.js +1 -1
- package/src/codex-prompt-detector.js +104 -13
- package/src/config.js +33 -5
- package/src/db.js +53 -31
- package/src/lark-bot.js +44 -5
- package/src/mcp/tools/openclaw/index.js +1 -1
- package/src/openclaw-bridge.js +176 -28
- package/src/openclaw-hook-installer.js +2 -1
- package/src/openclaw-hook.js +127 -9
- package/src/openclaw-wizard.js +119 -24
- package/src/permission-prompt.js +113 -31
- package/src/pty.js +183 -49
- package/src/routes/ai-terminal.js +75 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/server.js +90 -12
- package/src/session-input-dispatcher.js +48 -4
- package/src/telegram-bot.js +82 -15
- package/src/telegram-loading-status.js +1 -1
- package/src/templates/claude-hooks/notify.js +1 -1
- package/src/templates/codex-hooks/notify.js +1 -1
- package/dist-web/assets/index-8A0oLLcX.css +0 -32
|
@@ -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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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:
|
|
1100
|
+
autoMode: nextAutoMode,
|
|
1067
1101
|
immediate: false,
|
|
1068
1102
|
reason: 'native_session_missing',
|
|
1069
|
-
message: '
|
|
1103
|
+
message: '当前会话尚未拿到原生 session id,模式切换将仅对后续启动/恢复的会话生效。',
|
|
1070
1104
|
})
|
|
1071
1105
|
return
|
|
1072
1106
|
}
|
|
1073
1107
|
|
|
1074
|
-
broadcastToSession(session, { type: 'auto_mode_switching', target:
|
|
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:
|
|
1086
|
-
label:
|
|
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:
|
|
1132
|
+
autoMode: nextAutoMode,
|
|
1096
1133
|
immediate: false,
|
|
1097
1134
|
reason: 'restart_failed',
|
|
1098
|
-
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:
|
|
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
|
-
|
|
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(
|
|
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 ?
|
|
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(
|
|
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 ?
|
|
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
|
-
|
|
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.
|
|
1343
|
-
.catch((e) => console.warn(`[tui-detected]
|
|
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?.
|
|
1481
|
+
await openclawBridge?.broadcastText?.({ sessionId, message: text })
|
|
1405
1482
|
} catch (e) {
|
|
1406
|
-
console.warn(`[server] dispatcher.onStale
|
|
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?.
|
|
1492
|
+
await openclawBridge?.broadcastText?.({ sessionId, message: text })
|
|
1416
1493
|
} catch (e) {
|
|
1417
|
-
console.warn(`[server] dispatcher.onSessionEnd
|
|
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
|
|
1524
|
-
if (tgSweep && !
|
|
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
|
-
|
|
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
|
}
|