agentquad 0.4.4 → 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-DuZ_lMdf.js → index-BEiPvgk7.js} +243 -237
- 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 +59 -25
- 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 +263 -54
- package/src/routes/ai-terminal.js +133 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/server.js +92 -23
- 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/src/transcript.js +17 -4
- package/dist-web/assets/index-CEiuiF0m.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
|
|
@@ -1231,6 +1280,27 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1231
1280
|
nativeSessionMap.clear()
|
|
1232
1281
|
}
|
|
1233
1282
|
|
|
1283
|
+
// recoverPendingTodosOnStartup 的 spawn 失败 catch 路径用:把 mergeTodoAiSessions
|
|
1284
|
+
// 之前刚写进 DB 的 status='running' 那条改回 'failed',其它 aiSessions 原样保留。
|
|
1285
|
+
function markRecoveryFailed(todoId, sessionId) {
|
|
1286
|
+
try {
|
|
1287
|
+
const todoNow = db.getTodo(todoId)
|
|
1288
|
+
if (!todoNow) return
|
|
1289
|
+
const aiSessions = Array.isArray(todoNow.aiSessions) ? todoNow.aiSessions : []
|
|
1290
|
+
let mutated = false
|
|
1291
|
+
const next = aiSessions.map((s) => {
|
|
1292
|
+
if (s?.sessionId === sessionId) {
|
|
1293
|
+
mutated = true
|
|
1294
|
+
return { ...s, status: 'failed', completedAt: Date.now() }
|
|
1295
|
+
}
|
|
1296
|
+
return s
|
|
1297
|
+
})
|
|
1298
|
+
if (mutated) db.updateTodo(todoId, { aiSessions: next })
|
|
1299
|
+
} catch (e) {
|
|
1300
|
+
console.warn('[ai-terminal] markRecoveryFailed failed:', e.message)
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1234
1304
|
function recoverPendingTodosOnStartup() {
|
|
1235
1305
|
// 启动期一次性读 config:恢复一条没记 permissionMode 的老 session 时回退到全局默认。
|
|
1236
1306
|
// 用户在设置里选了"完全托管"但 DB 里没存 → 这里把意图重新接上,否则 claude --resume
|
|
@@ -1329,6 +1399,10 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1329
1399
|
todoSessionMap.delete(todo.id)
|
|
1330
1400
|
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1331
1401
|
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
1402
|
+
// recoverPendingTodosOnStartup 此前已 mergeTodoAiSessions 把该 session 写成
|
|
1403
|
+
// status='running';spawn 失败必须把那条改回 'failed',否则前端读到 running
|
|
1404
|
+
// 会渲染"运行中"且没有对应 PTY。与 markOrphanedSessionsAsFailed 互为冗余。
|
|
1405
|
+
markRecoveryFailed(todo.id, sessionId)
|
|
1332
1406
|
db.updateTodo(todo.id, { status: 'todo' })
|
|
1333
1407
|
})
|
|
1334
1408
|
} catch (e) {
|
|
@@ -1337,6 +1411,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1337
1411
|
todoSessionMap.delete(todo.id)
|
|
1338
1412
|
const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
|
|
1339
1413
|
if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
|
|
1414
|
+
markRecoveryFailed(todo.id, sessionId)
|
|
1340
1415
|
db.updateTodo(todo.id, { status: 'todo' })
|
|
1341
1416
|
}
|
|
1342
1417
|
}
|
|
@@ -1372,8 +1447,40 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
|
|
|
1372
1447
|
}
|
|
1373
1448
|
}
|
|
1374
1449
|
|
|
1450
|
+
// 服务硬重启 / crash 时 PTY 进程没机会触发 onExit,DB 里 aiSession.status='running'
|
|
1451
|
+
// (或 idle / pending_confirm) 会留作"僵尸",前端读到后渲染成「运行中」却没有对应 PTY。
|
|
1452
|
+
// 启动期一次性把所有"看起来还活着但无对应 live PTY"的 aiSession 改成 'failed'。
|
|
1453
|
+
// 必须在 recoverPendingTodosOnStartup 之后调用:成功 recover 的 session 此时已在
|
|
1454
|
+
// nativeSessionMap 里,扫描会跳过它们;只有真正的孤儿会被改写。
|
|
1455
|
+
function markOrphanedSessionsAsFailed() {
|
|
1456
|
+
const ALIVE_LOOKING = new Set(['running', 'idle', 'pending_confirm'])
|
|
1457
|
+
let swept = 0
|
|
1458
|
+
try {
|
|
1459
|
+
for (const todo of db.listTodos()) {
|
|
1460
|
+
const aiSessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : []
|
|
1461
|
+
let changed = false
|
|
1462
|
+
const nextSessions = aiSessions.map((s) => {
|
|
1463
|
+
if (!s || !ALIVE_LOOKING.has(s.status)) return s
|
|
1464
|
+
const key = s.tool && s.nativeSessionId ? `${s.tool}:${s.nativeSessionId}` : null
|
|
1465
|
+
if (key && nativeSessionMap.has(key)) return s
|
|
1466
|
+
changed = true
|
|
1467
|
+
return { ...s, status: 'failed', completedAt: Date.now() }
|
|
1468
|
+
})
|
|
1469
|
+
if (!changed) continue
|
|
1470
|
+
db.updateTodo(todo.id, { aiSessions: nextSessions })
|
|
1471
|
+
swept += 1
|
|
1472
|
+
}
|
|
1473
|
+
if (swept > 0) {
|
|
1474
|
+
console.log(`[ai-terminal] orphan sweep: marked ${swept} sessions as failed`)
|
|
1475
|
+
}
|
|
1476
|
+
} catch (e) {
|
|
1477
|
+
console.warn('[ai-terminal] markOrphanedSessionsAsFailed failed:', e.message)
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1375
1481
|
sweepStuckPendingConfirm()
|
|
1376
1482
|
recoverPendingTodosOnStartup()
|
|
1483
|
+
markOrphanedSessionsAsFailed()
|
|
1377
1484
|
|
|
1378
1485
|
return {
|
|
1379
1486
|
router,
|
|
@@ -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";
|
|
@@ -369,21 +370,21 @@ function buildNativeResumeCommand(tool, nativeSessionId, tools = {}) {
|
|
|
369
370
|
}
|
|
370
371
|
|
|
371
372
|
function mergeToolConfig(currentTool = {}, nextTool = {}) {
|
|
372
|
-
|
|
373
|
+
// 用户字段即真理:直接合并,不再因为 command 变了就悄悄清空 bin。
|
|
374
|
+
// PTY 启动时 bin 为空会 fallback 到 command 名走 PATH 解析。
|
|
375
|
+
return {
|
|
373
376
|
...currentTool,
|
|
374
377
|
...nextTool,
|
|
375
378
|
};
|
|
376
|
-
|
|
377
|
-
nextTool.command !== undefined &&
|
|
378
|
-
nextTool.command !== (currentTool.command || "");
|
|
379
|
-
const binUnchanged =
|
|
380
|
-
nextTool.bin !== undefined && nextTool.bin === (currentTool.bin || "");
|
|
381
|
-
|
|
382
|
-
if (commandChanged && binUnchanged) {
|
|
383
|
-
merged.bin = "";
|
|
384
|
-
}
|
|
379
|
+
}
|
|
385
380
|
|
|
386
|
-
|
|
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
|
+
}
|
|
387
388
|
}
|
|
388
389
|
|
|
389
390
|
function splitEditorPath(rawPath = "") {
|
|
@@ -598,6 +599,34 @@ export function createServer(opts = {}) {
|
|
|
598
599
|
}
|
|
599
600
|
});
|
|
600
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
|
+
|
|
601
630
|
// Telegram 自动 topic 钩子:ait 创建在前,wizard 创建在后;用 lazy ref 桥接
|
|
602
631
|
const aiSessionHooks = {
|
|
603
632
|
onSessionSpawned: () => null,
|
|
@@ -649,6 +678,7 @@ export function createServer(opts = {}) {
|
|
|
649
678
|
|
|
650
679
|
app.put("/api/config", async (req, res) => {
|
|
651
680
|
try {
|
|
681
|
+
const result = await withConfigLock(async () => {
|
|
652
682
|
const current = loadConfig({ rootDir: configRootDir });
|
|
653
683
|
const nextToolsPatch = req.body?.tools || {};
|
|
654
684
|
const pricingPatch = req.body?.pricing;
|
|
@@ -669,6 +699,12 @@ export function createServer(opts = {}) {
|
|
|
669
699
|
// botTokenMasked / botTokenSource 是 GET-only,PUT 收到的不能写回
|
|
670
700
|
delete telegramPatch.botTokenMasked;
|
|
671
701
|
delete telegramPatch.botTokenSource;
|
|
702
|
+
// 防御性 guard:任何 string 字段为 '' 都视为「保留磁盘原值」。
|
|
703
|
+
// Drawer 在 form 未就绪时会把整段 telegram/lark 都用 '' 兜底;
|
|
704
|
+
// 没有这层保护就会把磁盘上现存的 appId / supergroupId / chatId 等清空,
|
|
705
|
+
// 然后每次保存都延续空值,造成「偶尔丢、丢了之后回不来」。
|
|
706
|
+
// 显式清空请用 null。
|
|
707
|
+
stripEmptyStrings(telegramPatch);
|
|
672
708
|
|
|
673
709
|
// 合并 telegram / lark 段
|
|
674
710
|
const mergedTelegram = { ...current.telegram, ...telegramPatch };
|
|
@@ -681,6 +717,7 @@ export function createServer(opts = {}) {
|
|
|
681
717
|
}
|
|
682
718
|
delete larkPatch.appSecretMasked;
|
|
683
719
|
delete larkPatch.appSecretSource;
|
|
720
|
+
stripEmptyStrings(larkPatch);
|
|
684
721
|
const mergedLark = { ...current.lark, ...larkPatch };
|
|
685
722
|
|
|
686
723
|
// 检测 bot 段是否变化(用于触发热重启)
|
|
@@ -751,7 +788,7 @@ export function createServer(opts = {}) {
|
|
|
751
788
|
const { token, source } = readBotTokenWithSource(() => reloadedCfg);
|
|
752
789
|
const { botToken: _drop, ...telegramSafe } = reloadedCfg.telegram || {};
|
|
753
790
|
|
|
754
|
-
|
|
791
|
+
return {
|
|
755
792
|
ok: true,
|
|
756
793
|
config: {
|
|
757
794
|
...reloadedCfg,
|
|
@@ -769,7 +806,9 @@ export function createServer(opts = {}) {
|
|
|
769
806
|
larkRestart,
|
|
770
807
|
},
|
|
771
808
|
telegramRestart,
|
|
809
|
+
};
|
|
772
810
|
});
|
|
811
|
+
res.json(result);
|
|
773
812
|
} catch (e) {
|
|
774
813
|
res.status(500).json({ ok: false, error: e.message });
|
|
775
814
|
}
|
|
@@ -1194,6 +1233,30 @@ export function createServer(opts = {}) {
|
|
|
1194
1233
|
// OpenClaw 双向桥接:bridge(出站)+ pending-question 协调器(双向阻塞)
|
|
1195
1234
|
const openclawBridge = createOpenClawBridge({
|
|
1196
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
|
+
},
|
|
1197
1260
|
});
|
|
1198
1261
|
const pendingCoord = createPendingQuestionCoordinator({ db });
|
|
1199
1262
|
pendingCoord.start();
|
|
@@ -1302,6 +1365,11 @@ export function createServer(opts = {}) {
|
|
|
1302
1365
|
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1303
1366
|
wizard: {
|
|
1304
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),
|
|
1305
1373
|
},
|
|
1306
1374
|
logger: { warn: (...a) => console.warn(...a), info: (...a) => console.log(...a) },
|
|
1307
1375
|
})
|
|
@@ -1348,8 +1416,8 @@ export function createServer(opts = {}) {
|
|
|
1348
1416
|
'回 esc / 退出菜单 → 我帮你按 Esc 退出 modal',
|
|
1349
1417
|
'回 中断 / ctrl+c → 我帮你打断当前任务',
|
|
1350
1418
|
].join('\n')
|
|
1351
|
-
openclawBridge.
|
|
1352
|
-
.catch((e) => console.warn(`[tui-detected]
|
|
1419
|
+
openclawBridge.broadcastText({ sessionId, message })
|
|
1420
|
+
.catch((e) => console.warn(`[tui-detected] broadcastText failed: ${e.message}`))
|
|
1353
1421
|
})
|
|
1354
1422
|
|
|
1355
1423
|
// holder Proxy: 让 hook / wizard 不用改源码,每次读属性都从 holder.current 拿最新实例
|
|
@@ -1410,9 +1478,9 @@ export function createServer(opts = {}) {
|
|
|
1410
1478
|
onStale: async ({ sessionId, queueSize }) => {
|
|
1411
1479
|
const text = `⚠️ session 有 ${queueSize} 条排队消息超过 5 分钟未投递,看起来卡住了。可发送 \`!!\` 中断后重新发送。`
|
|
1412
1480
|
try {
|
|
1413
|
-
await openclawBridge?.
|
|
1481
|
+
await openclawBridge?.broadcastText?.({ sessionId, message: text })
|
|
1414
1482
|
} catch (e) {
|
|
1415
|
-
console.warn(`[server] dispatcher.onStale
|
|
1483
|
+
console.warn(`[server] dispatcher.onStale broadcastText failed: ${e.message}`)
|
|
1416
1484
|
}
|
|
1417
1485
|
},
|
|
1418
1486
|
onSessionEnd: async ({ sessionId, undeliveredCount, undeliveredTexts }) => {
|
|
@@ -1421,9 +1489,9 @@ export function createServer(opts = {}) {
|
|
|
1421
1489
|
const more = undeliveredCount > 3 ? `\n(还有 ${undeliveredCount - 3} 条未列出)` : ''
|
|
1422
1490
|
const text = `⚠️ session 已结束,未投递 ${undeliveredCount} 条消息:\n${preview}${more}`
|
|
1423
1491
|
try {
|
|
1424
|
-
await openclawBridge?.
|
|
1492
|
+
await openclawBridge?.broadcastText?.({ sessionId, message: text })
|
|
1425
1493
|
} catch (e) {
|
|
1426
|
-
console.warn(`[server] dispatcher.onSessionEnd
|
|
1494
|
+
console.warn(`[server] dispatcher.onSessionEnd broadcastText failed: ${e.message}`)
|
|
1427
1495
|
}
|
|
1428
1496
|
},
|
|
1429
1497
|
},
|
|
@@ -1508,7 +1576,7 @@ export function createServer(opts = {}) {
|
|
|
1508
1576
|
console.log(`[server] skip auto-close: sid=${sessionId} exit=${exitCode} lifetime=${lifetimeMs}ms`)
|
|
1509
1577
|
return null
|
|
1510
1578
|
}
|
|
1511
|
-
const route = openclawBridge.resolveRoute?.(sessionId)
|
|
1579
|
+
const route = openclawBridge.resolveRoute?.(sessionId, 'telegram')
|
|
1512
1580
|
if (!route?.threadId) return null
|
|
1513
1581
|
return openclawWizard.handleTopicEvent({
|
|
1514
1582
|
type: 'closed',
|
|
@@ -1529,14 +1597,15 @@ export function createServer(opts = {}) {
|
|
|
1529
1597
|
let sweptLark = 0
|
|
1530
1598
|
for (const [sid, sess] of ait.sessions) {
|
|
1531
1599
|
if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
|
|
1532
|
-
const
|
|
1533
|
-
if (tgSweep && !
|
|
1600
|
+
const rTg = tgSweep ? openclawBridge.resolveRoute?.(sid, 'telegram') : null
|
|
1601
|
+
if (tgSweep && !rTg?.threadId) {
|
|
1534
1602
|
openclawWizard.ensureTopicForSession({ sessionId: sid, todoId: sess.todoId })
|
|
1535
1603
|
.then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → telegram thread ${res.threadId}`))
|
|
1536
1604
|
.catch((e) => console.warn(`[server] sweep ensureTopic failed for ${sid}: ${e.message}`))
|
|
1537
1605
|
sweptTg++
|
|
1538
1606
|
}
|
|
1539
|
-
|
|
1607
|
+
const rLark = larkSweep ? openclawBridge.resolveRoute?.(sid, 'lark') : null
|
|
1608
|
+
if (larkSweep && !rLark?.rootMessageId) {
|
|
1540
1609
|
openclawWizard.ensureLarkThreadForSession({ sessionId: sid, todoId: sess.todoId })
|
|
1541
1610
|
.then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → lark root ${res.rootMessageId}`))
|
|
1542
1611
|
.catch((e) => console.warn(`[server] sweep ensureLarkThread failed for ${sid}: ${e.message}`))
|
|
@@ -1581,7 +1650,7 @@ export function createServer(opts = {}) {
|
|
|
1581
1650
|
let kicked = 0
|
|
1582
1651
|
for (const [sid, sess] of ait.sessions) {
|
|
1583
1652
|
if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
|
|
1584
|
-
const r = openclawBridge.resolveRoute?.(sid)
|
|
1653
|
+
const r = openclawBridge.resolveRoute?.(sid, 'telegram')
|
|
1585
1654
|
if (!r?.threadId) continue
|
|
1586
1655
|
if (tracker.has(sid)) continue
|
|
1587
1656
|
tracker.start({ sessionId: sid, skipTitleRename: true })
|