agentquad 0.3.0
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/LICENSE +21 -0
- package/README.md +318 -0
- package/dist-web/assets/index-CMaXwixo.js +1234 -0
- package/dist-web/assets/index-DBHApzV1.css +32 -0
- package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +14 -0
- package/package.json +88 -0
- package/src/ask-user-buttons.js +142 -0
- package/src/claude-transcript.js +203 -0
- package/src/cli.js +1040 -0
- package/src/codex-event-emitter.js +111 -0
- package/src/codex-prompt-detector.js +53 -0
- package/src/codex-sidecar.js +52 -0
- package/src/codex-transcript.js +74 -0
- package/src/config.js +692 -0
- package/src/data/claude-code-commands.json +52 -0
- package/src/db.js +1503 -0
- package/src/dispatch.js +13 -0
- package/src/export/todoMarkdown.js +246 -0
- package/src/first-run-wizard.js +82 -0
- package/src/git/gitStatus.js +139 -0
- package/src/lark-api-client.js +205 -0
- package/src/lark-bot.js +510 -0
- package/src/lark-card.js +88 -0
- package/src/lark-config-service.js +16 -0
- package/src/lark-event-client.js +107 -0
- package/src/lark-image.js +99 -0
- package/src/lark-markdown.js +51 -0
- package/src/lark-video.js +163 -0
- package/src/mcp/audit.js +34 -0
- package/src/mcp/server.js +83 -0
- package/src/mcp/tools/destructive/index.js +252 -0
- package/src/mcp/tools/openclaw/index.js +405 -0
- package/src/mcp/tools/read/index.js +269 -0
- package/src/mcp/tools/write/index.js +157 -0
- package/src/openclaw-bridge.js +566 -0
- package/src/openclaw-hook-installer.js +338 -0
- package/src/openclaw-hook.js +908 -0
- package/src/openclaw-wizard.js +2442 -0
- package/src/pending-questions.js +297 -0
- package/src/pricing.js +45 -0
- package/src/prompt-render.js +36 -0
- package/src/pty.js +992 -0
- package/src/routes/ai-terminal.js +1228 -0
- package/src/routes/git.js +89 -0
- package/src/routes/openclaw-hook.js +67 -0
- package/src/routes/openclaw-inbound.js +36 -0
- package/src/routes/recurringRules.js +80 -0
- package/src/routes/reports.js +50 -0
- package/src/routes/search.js +46 -0
- package/src/routes/stats.js +31 -0
- package/src/routes/telegram-config.js +152 -0
- package/src/routes/telegram-sync.js +221 -0
- package/src/routes/templates.js +63 -0
- package/src/routes/todos.js +649 -0
- package/src/routes/transcripts.js +75 -0
- package/src/routes/uploads.js +107 -0
- package/src/routes/wiki.js +142 -0
- package/src/search/fts.js +209 -0
- package/src/search/index.js +199 -0
- package/src/search/transcripts.js +148 -0
- package/src/server.js +1791 -0
- package/src/session-input-dispatcher.js +256 -0
- package/src/stats/markdown.js +42 -0
- package/src/stats/report.js +207 -0
- package/src/summarize.js +84 -0
- package/src/system-rules.js +52 -0
- package/src/telegram-bot.js +875 -0
- package/src/telegram-commands.js +149 -0
- package/src/telegram-config-service.js +84 -0
- package/src/telegram-image.js +95 -0
- package/src/telegram-loading-status.js +112 -0
- package/src/telegram-markdown.js +82 -0
- package/src/telegram-reaction-tracker.js +69 -0
- package/src/telegram-video.js +75 -0
- package/src/templates/claude-hooks/notify.js +103 -0
- package/src/transcript.js +305 -0
- package/src/transcripts/blocks.js +56 -0
- package/src/transcripts/index.js +222 -0
- package/src/transcripts/indexer.js +34 -0
- package/src/transcripts/matcher.js +70 -0
- package/src/transcripts/scanner.js +259 -0
- package/src/usage-footer.js +170 -0
- package/src/usage-parser.js +132 -0
- package/src/wiki/guide.js +44 -0
- package/src/wiki/index.js +232 -0
- package/src/wiki/redact.js +34 -0
- package/src/wiki/sources.js +122 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,1791 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { createServer as createHttpServer } from "node:http";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import express from "express";
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_ROOT_DIR,
|
|
11
|
+
SUPPORTED_TOOLS,
|
|
12
|
+
inspectToolsConfig,
|
|
13
|
+
loadConfig,
|
|
14
|
+
resolveToolsConfig,
|
|
15
|
+
saveConfig,
|
|
16
|
+
} from "./config.js";
|
|
17
|
+
import { openDb } from "./db.js";
|
|
18
|
+
import { PtyManager } from "./pty.js";
|
|
19
|
+
import { createCodexSidecar } from "./codex-sidecar.js";
|
|
20
|
+
import { createCodexEventEmitter } from "./codex-event-emitter.js";
|
|
21
|
+
import { createAiTerminal } from "./routes/ai-terminal.js";
|
|
22
|
+
import { createTranscriptsRouter } from "./routes/transcripts.js";
|
|
23
|
+
import { createTranscriptsService } from "./transcripts/index.js";
|
|
24
|
+
import { createTodosRouter } from "./routes/todos.js";
|
|
25
|
+
import { createUploadsRouter } from "./routes/uploads.js";
|
|
26
|
+
import { createTemplatesRouter } from "./routes/templates.js";
|
|
27
|
+
import { createRecurringRulesRouter } from "./routes/recurringRules.js";
|
|
28
|
+
import { createStatsRouter } from "./routes/stats.js";
|
|
29
|
+
import { createReportsRouter } from "./routes/reports.js";
|
|
30
|
+
import { createWikiRouter } from "./routes/wiki.js";
|
|
31
|
+
import { createWikiService } from "./wiki/index.js";
|
|
32
|
+
import { createSearchRouter } from "./routes/search.js";
|
|
33
|
+
import { createSearchService } from "./search/index.js";
|
|
34
|
+
import { createMcpRouter } from "./mcp/server.js";
|
|
35
|
+
import { createOpenClawBridge } from "./openclaw-bridge.js";
|
|
36
|
+
import { createPendingQuestionCoordinator } from "./pending-questions.js";
|
|
37
|
+
import { createOpenClawHookHandler } from "./openclaw-hook.js";
|
|
38
|
+
import { createTelegramSyncRouter } from "./routes/telegram-sync.js";
|
|
39
|
+
import { createOpenClawHookRouter } from "./routes/openclaw-hook.js";
|
|
40
|
+
import { createOpenClawWizard } from "./openclaw-wizard.js";
|
|
41
|
+
import { createSessionInputDispatcher } from "./session-input-dispatcher.js";
|
|
42
|
+
import { createOpenClawInboundRouter } from "./routes/openclaw-inbound.js";
|
|
43
|
+
import { createTelegramConfigRouter } from "./routes/telegram-config.js";
|
|
44
|
+
import { createTelegramBot, readBotTokenWithSource } from "./telegram-bot.js";
|
|
45
|
+
import { createLarkBot } from "./lark-bot.js";
|
|
46
|
+
import { createLoadingTracker } from "./telegram-loading-status.js";
|
|
47
|
+
import { createReactionTracker } from "./telegram-reaction-tracker.js";
|
|
48
|
+
import { buildTelegramCommands } from "./telegram-commands.js";
|
|
49
|
+
import { createProbeRegistry, isMaskedToken, maskBotToken } from "./telegram-config-service.js";
|
|
50
|
+
import { isMaskedLarkAppSecret, larkAppSecretSource, maskLarkAppSecret } from "./lark-config-service.js";
|
|
51
|
+
import { createLarkApiClient } from "./lark-api-client.js";
|
|
52
|
+
import { inspectHooks as inspectClaudeHooks } from "./openclaw-hook-installer.js";
|
|
53
|
+
|
|
54
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
55
|
+
const __dirname = dirname(__filename);
|
|
56
|
+
|
|
57
|
+
function loadVersion() {
|
|
58
|
+
try {
|
|
59
|
+
const pkg = JSON.parse(
|
|
60
|
+
readFileSync(join(__dirname, "../package.json"), "utf8"),
|
|
61
|
+
);
|
|
62
|
+
return pkg.version || "0.0.0";
|
|
63
|
+
} catch {
|
|
64
|
+
return "0.0.0";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function listenWithRetry(server, port, host, { maxAttempts = 2 } = {}) {
|
|
69
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
70
|
+
const tryPort = port + i
|
|
71
|
+
try {
|
|
72
|
+
await new Promise((resolve, reject) => {
|
|
73
|
+
const onError = (err) => {
|
|
74
|
+
server.off('listening', onListening)
|
|
75
|
+
reject(err)
|
|
76
|
+
}
|
|
77
|
+
const onListening = () => {
|
|
78
|
+
server.off('error', onError)
|
|
79
|
+
resolve()
|
|
80
|
+
}
|
|
81
|
+
server.once('error', onError)
|
|
82
|
+
server.once('listening', onListening)
|
|
83
|
+
server.listen(tryPort, host)
|
|
84
|
+
})
|
|
85
|
+
return server.address().port
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err.code !== 'EADDRINUSE' || i === maxAttempts - 1) throw err
|
|
88
|
+
console.warn(`port ${tryPort} in use, retrying ${tryPort + 1}...`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickDirectoryNative({ defaultPath, prompt = "选择目录" } = {}) {
|
|
94
|
+
if (process.platform !== "darwin") {
|
|
95
|
+
const error = new Error("directory_picker_unsupported");
|
|
96
|
+
error.code = "directory_picker_unsupported";
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const safeDefaultPath =
|
|
101
|
+
defaultPath &&
|
|
102
|
+
existsSync(defaultPath) &&
|
|
103
|
+
statSync(defaultPath).isDirectory()
|
|
104
|
+
? defaultPath
|
|
105
|
+
: "";
|
|
106
|
+
|
|
107
|
+
const script = [
|
|
108
|
+
"on run argv",
|
|
109
|
+
"set promptText to item 1 of argv",
|
|
110
|
+
"set startPath to item 2 of argv",
|
|
111
|
+
'if startPath is not "" then',
|
|
112
|
+
" try",
|
|
113
|
+
" set pickedFolder to choose folder with prompt promptText default location (POSIX file startPath)",
|
|
114
|
+
" on error",
|
|
115
|
+
" set pickedFolder to choose folder with prompt promptText",
|
|
116
|
+
" end try",
|
|
117
|
+
"else",
|
|
118
|
+
" set pickedFolder to choose folder with prompt promptText",
|
|
119
|
+
"end if",
|
|
120
|
+
"return POSIX path of pickedFolder",
|
|
121
|
+
"end run",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
execFile(
|
|
126
|
+
"osascript",
|
|
127
|
+
[
|
|
128
|
+
...script.flatMap((line) => ["-e", line]),
|
|
129
|
+
"--",
|
|
130
|
+
prompt,
|
|
131
|
+
safeDefaultPath,
|
|
132
|
+
],
|
|
133
|
+
(error, stdout, stderr) => {
|
|
134
|
+
if (error) {
|
|
135
|
+
const details = `${stderr || ""} ${error.message || ""}`;
|
|
136
|
+
if (details.includes("User canceled") || details.includes("(-128)")) {
|
|
137
|
+
resolve({ path: null, cancelled: true });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
reject(error);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
resolve({ path: stdout.trim(), cancelled: false });
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function shellEscape(arg) {
|
|
150
|
+
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildShellExports(env = {}) {
|
|
154
|
+
const entries = Object.entries(env).filter(([, value]) => value != null && value !== "");
|
|
155
|
+
if (entries.length === 0) return "";
|
|
156
|
+
return `${entries.map(([key, value]) => `export ${key}=${shellEscape(value)}`).join("; ")}; `;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findNativeResumeContext({ db, todoId, sessionId, nativeSessionId, tool } = {}) {
|
|
160
|
+
if (!todoId) return { todo: null, aiSession: null };
|
|
161
|
+
const todo = db.getTodo(todoId);
|
|
162
|
+
if (!todo) return { todo: null, aiSession: null };
|
|
163
|
+
const sessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : [];
|
|
164
|
+
const aiSession = sessions.find((item) => {
|
|
165
|
+
if (!item) return false;
|
|
166
|
+
if (sessionId && item.sessionId !== sessionId) return false;
|
|
167
|
+
if (nativeSessionId && item.nativeSessionId !== nativeSessionId) return false;
|
|
168
|
+
if (tool && item.tool !== tool) return false;
|
|
169
|
+
return true;
|
|
170
|
+
}) || null;
|
|
171
|
+
return { todo, aiSession };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isCompleteTelegramRoute(route) {
|
|
175
|
+
return Boolean(route?.targetUserId && route?.threadId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 与 src/openclaw-hook.js:normalizePersistedLarkRoute 对齐:lark route 完整
|
|
179
|
+
// 至少需要 targetUserId + rootMessageId;channel 字段允许缺省(视为 lark)。
|
|
180
|
+
export function isCompleteLarkRoute(route) {
|
|
181
|
+
if (!route?.targetUserId || !route?.rootMessageId) return false;
|
|
182
|
+
if (route.channel && route.channel !== "lark") return false;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 选哪条 route 给本地 Terminal resume 用:lark 优先,与 server.js rehydration
|
|
187
|
+
// 顺序(telegram 先注册 → lark 后注册覆盖)和 openclaw-hook.restorePersistedRoute
|
|
188
|
+
// 的"优先 lark"一致。
|
|
189
|
+
export function pickNativeResumeRoute(aiSession) {
|
|
190
|
+
if (isCompleteLarkRoute(aiSession?.larkRoute)) {
|
|
191
|
+
return { channel: "lark", route: aiSession.larkRoute };
|
|
192
|
+
}
|
|
193
|
+
if (isCompleteTelegramRoute(aiSession?.telegramRoute)) {
|
|
194
|
+
return { channel: "telegram", route: aiSession.telegramRoute };
|
|
195
|
+
}
|
|
196
|
+
return { channel: null, route: null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildNativeResumeHookEnv({ tool, todo, aiSession, runtimeConfig, inspectHooks = inspectClaudeHooks } = {}) {
|
|
200
|
+
if (tool !== "claude" || !todo || !aiSession) return { env: {}, warnings: [], channel: null };
|
|
201
|
+
const warnings = [];
|
|
202
|
+
const picked = pickNativeResumeRoute(aiSession);
|
|
203
|
+
|
|
204
|
+
// hook 安装状态独立检查:即使 route 缺失也回报,前端按优先级展示
|
|
205
|
+
let hookStatus = null;
|
|
206
|
+
try {
|
|
207
|
+
hookStatus = inspectHooks();
|
|
208
|
+
} catch {
|
|
209
|
+
hookStatus = null;
|
|
210
|
+
}
|
|
211
|
+
if (!hookStatus?.scriptExists) warnings.push("hook_script_missing");
|
|
212
|
+
if (!hookStatus?.installed) warnings.push("hooks_not_installed");
|
|
213
|
+
|
|
214
|
+
if (!picked.route) {
|
|
215
|
+
warnings.push("route_missing");
|
|
216
|
+
return { env: {}, warnings, channel: null };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const port = runtimeConfig?.port || 5677;
|
|
220
|
+
const env = {
|
|
221
|
+
QUADTODO_SESSION_ID: aiSession.sessionId,
|
|
222
|
+
QUADTODO_TODO_ID: todo.id,
|
|
223
|
+
QUADTODO_TODO_TITLE: todo.title || aiSession.prompt || "",
|
|
224
|
+
QUADTODO_URL: `http://127.0.0.1:${port}`,
|
|
225
|
+
};
|
|
226
|
+
// QUADTODO_TARGET_USER 是 telegram 推送脚本专用:notify.js 把它原样转发给
|
|
227
|
+
// server,telegram 推送链路用来定位 peer。Lark 推送靠 server 端按 sessionId
|
|
228
|
+
// 反查 larkRoute.rootMessageId,不需要 hook 脚本带这个 env。
|
|
229
|
+
if (picked.channel === "telegram") {
|
|
230
|
+
env.QUADTODO_TARGET_USER = String(picked.route.targetUserId);
|
|
231
|
+
}
|
|
232
|
+
return { env, warnings, channel: picked.channel };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildNativeResumeTitle(tool, nativeSessionId) {
|
|
236
|
+
return `quadtodo:${tool}:${nativeSessionId}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function buildNativeResumeMarker(title) {
|
|
240
|
+
return `__quadtodo_resume__:${String(title || "")}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function buildNativeResumeLaunch({ cwd, command, title } = {}) {
|
|
244
|
+
const marker = buildNativeResumeMarker(title);
|
|
245
|
+
const launch = `printf '[quadtodo] session marker: %s\\n' ${shellEscape(marker)}; cd ${shellEscape(cwd)}; ${String(command || "")}`;
|
|
246
|
+
return { marker, launch };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Why a marker-in-scrollback instead of `custom title`:
|
|
250
|
+
// macOS Terminal's `custom title` gets overwritten by OSC escape sequences that
|
|
251
|
+
// Claude Code / Codex emit to display their own live status (e.g. "✳ Claude Code",
|
|
252
|
+
// "⠂ Kill all Claude Code processes"). That made the original `custom title is tabTitle`
|
|
253
|
+
// check always fail, so each button click spawned a new tab. We now print a unique
|
|
254
|
+
// marker line into the tab's scrollback before launching the CLI, and match via
|
|
255
|
+
// `history contains markerText` — the scrollback persists regardless of OSC noise.
|
|
256
|
+
function openNativeTerminalNative({ cwd, command, title } = {}) {
|
|
257
|
+
if (process.platform !== "darwin") {
|
|
258
|
+
const error = new Error("native_terminal_unsupported");
|
|
259
|
+
error.code = "native_terminal_unsupported";
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const targetCwd =
|
|
264
|
+
cwd && existsSync(cwd) && statSync(cwd).isDirectory()
|
|
265
|
+
? cwd
|
|
266
|
+
: process.env.HOME || process.cwd();
|
|
267
|
+
|
|
268
|
+
const { marker, launch } = buildNativeResumeLaunch({
|
|
269
|
+
cwd: targetCwd,
|
|
270
|
+
command,
|
|
271
|
+
title,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const script = [
|
|
275
|
+
"on run argv",
|
|
276
|
+
"set launchCmd to item 1 of argv",
|
|
277
|
+
"set markerText to item 2 of argv",
|
|
278
|
+
"set tabTitle to item 3 of argv",
|
|
279
|
+
'tell application "Terminal"',
|
|
280
|
+
" activate",
|
|
281
|
+
" set matchedWindow to missing value",
|
|
282
|
+
" set matchedTab to missing value",
|
|
283
|
+
" repeat with winIdx from 1 to (count of windows)",
|
|
284
|
+
" set win to window winIdx",
|
|
285
|
+
" repeat with tabIdx from 1 to (count of tabs of win)",
|
|
286
|
+
" set currentTab to tab tabIdx of win",
|
|
287
|
+
" try",
|
|
288
|
+
" set tabHistory to history of currentTab",
|
|
289
|
+
" if tabHistory contains markerText then",
|
|
290
|
+
" set matchedWindow to win",
|
|
291
|
+
" set matchedTab to currentTab",
|
|
292
|
+
" exit repeat",
|
|
293
|
+
" end if",
|
|
294
|
+
" end try",
|
|
295
|
+
" end repeat",
|
|
296
|
+
" if matchedTab is not missing value then exit repeat",
|
|
297
|
+
" end repeat",
|
|
298
|
+
" if matchedTab is not missing value then",
|
|
299
|
+
" try",
|
|
300
|
+
" set index of matchedWindow to 1",
|
|
301
|
+
" end try",
|
|
302
|
+
" set selected tab of matchedWindow to matchedTab",
|
|
303
|
+
' return "reused"',
|
|
304
|
+
" end if",
|
|
305
|
+
' do script ""',
|
|
306
|
+
" delay 0.1",
|
|
307
|
+
" set newTab to selected tab of front window",
|
|
308
|
+
" try",
|
|
309
|
+
" set custom title of newTab to tabTitle",
|
|
310
|
+
" end try",
|
|
311
|
+
" do script launchCmd in newTab",
|
|
312
|
+
' return "created"',
|
|
313
|
+
"end tell",
|
|
314
|
+
"end run",
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
execFile(
|
|
319
|
+
"osascript",
|
|
320
|
+
[
|
|
321
|
+
...script.flatMap((line) => ["-e", line]),
|
|
322
|
+
"--",
|
|
323
|
+
launch,
|
|
324
|
+
marker,
|
|
325
|
+
String(title || ""),
|
|
326
|
+
],
|
|
327
|
+
(error, stdout, stderr) => {
|
|
328
|
+
if (error) {
|
|
329
|
+
reject(new Error(stderr || error.message || "open_native_terminal_failed"));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const action = stdout.trim() === "reused" ? "reused" : "created";
|
|
333
|
+
resolve({
|
|
334
|
+
cwd: targetCwd,
|
|
335
|
+
command: String(command || ""),
|
|
336
|
+
title: String(title || ""),
|
|
337
|
+
action,
|
|
338
|
+
output: stdout.trim(),
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function buildNativeResumeCommand(tool, nativeSessionId, tools = {}) {
|
|
346
|
+
if (!SUPPORTED_TOOLS.includes(tool)) {
|
|
347
|
+
const error = new Error("invalid_tool");
|
|
348
|
+
error.code = "invalid_tool";
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
if (!nativeSessionId) {
|
|
352
|
+
const error = new Error("missing_native_session_id");
|
|
353
|
+
error.code = "missing_native_session_id";
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const toolConfig = tools?.[tool];
|
|
358
|
+
const bin = toolConfig?.bin || toolConfig?.command;
|
|
359
|
+
if (!bin) {
|
|
360
|
+
const error = new Error("tool_not_configured");
|
|
361
|
+
error.code = "tool_not_configured";
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
const baseArgs = Array.isArray(toolConfig?.args) ? toolConfig.args : [];
|
|
365
|
+
const resumeArgs = tool === "codex"
|
|
366
|
+
? [...baseArgs, "resume", nativeSessionId]
|
|
367
|
+
: [...baseArgs, "--resume", nativeSessionId];
|
|
368
|
+
return [bin, ...resumeArgs].map(shellEscape).join(" ");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function mergeToolConfig(currentTool = {}, nextTool = {}) {
|
|
372
|
+
const merged = {
|
|
373
|
+
...currentTool,
|
|
374
|
+
...nextTool,
|
|
375
|
+
};
|
|
376
|
+
const commandChanged =
|
|
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
|
+
}
|
|
385
|
+
|
|
386
|
+
return merged;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function splitEditorPath(rawPath = "") {
|
|
390
|
+
const trimmed = String(rawPath || "").trim();
|
|
391
|
+
const match = trimmed.match(/^(.*?)(:\d+(?::\d+)?)?$/);
|
|
392
|
+
return {
|
|
393
|
+
fsPath: match?.[1] || trimmed,
|
|
394
|
+
locationSuffix: match?.[2] || "",
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function findNestedPath(rootDir, relativePath, maxDepth = 3) {
|
|
399
|
+
const normalizedRelativePath = String(relativePath || "")
|
|
400
|
+
.replace(/^\.\/+/, "")
|
|
401
|
+
.replace(/^\/+/, "");
|
|
402
|
+
if (!normalizedRelativePath) return null;
|
|
403
|
+
|
|
404
|
+
const seen = new Set();
|
|
405
|
+
function visit(dir, depth) {
|
|
406
|
+
const directCandidate = join(dir, normalizedRelativePath);
|
|
407
|
+
if (existsSync(directCandidate)) return directCandidate;
|
|
408
|
+
if (depth >= maxDepth) return null;
|
|
409
|
+
|
|
410
|
+
let entries = [];
|
|
411
|
+
try {
|
|
412
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (const entry of entries) {
|
|
418
|
+
if (!entry.isDirectory()) continue;
|
|
419
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
420
|
+
const nextDir = join(dir, entry.name);
|
|
421
|
+
if (seen.has(nextDir)) continue;
|
|
422
|
+
seen.add(nextDir);
|
|
423
|
+
const result = visit(nextDir, depth + 1);
|
|
424
|
+
if (result) return result;
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return visit(rootDir, 0);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizeBaseDirs(baseDirs) {
|
|
433
|
+
const list = Array.isArray(baseDirs) ? baseDirs : [baseDirs];
|
|
434
|
+
return [...new Set(list.filter((item) => typeof item === "string" && item.trim()))];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function resolveEditorTargetInfo(baseDirs, rawPath) {
|
|
438
|
+
const candidates = normalizeBaseDirs(baseDirs);
|
|
439
|
+
if (!candidates.length || !rawPath) return null;
|
|
440
|
+
const { fsPath, locationSuffix } = splitEditorPath(rawPath);
|
|
441
|
+
if (!fsPath) return null;
|
|
442
|
+
const normalizedFsPath = fsPath.replace(/^\.\/+/, "").replace(/^\/+/, "");
|
|
443
|
+
|
|
444
|
+
if (fsPath.startsWith("/")) {
|
|
445
|
+
return existsSync(fsPath)
|
|
446
|
+
? { resolvedPath: `${fsPath}${locationSuffix}`, baseDir: null }
|
|
447
|
+
: null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const baseDir of candidates) {
|
|
451
|
+
const directPath = join(baseDir, normalizedFsPath);
|
|
452
|
+
if (existsSync(directPath)) {
|
|
453
|
+
return { resolvedPath: `${directPath}${locationSuffix}`, baseDir };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const baseDir of candidates) {
|
|
458
|
+
const nestedPath = findNestedPath(baseDir, normalizedFsPath, 3);
|
|
459
|
+
if (nestedPath) {
|
|
460
|
+
return {
|
|
461
|
+
resolvedPath: `${nestedPath}${locationSuffix}`,
|
|
462
|
+
baseDir: nestedPath.slice(0, -normalizedFsPath.length).replace(/\/$/, "") || baseDir,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function resolveEditorTargetPath(baseDirs, rawPath) {
|
|
471
|
+
return resolveEditorTargetInfo(baseDirs, rawPath)?.resolvedPath || null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Phase C:Codex 事件流转给 /api/openclaw/hook(与 Claude hook 同一端点,靠 source/path 区分)。
|
|
475
|
+
// 让 hook handler 走与 Claude 相同的路由 / 节流 / 推送链路,无需在内存里另开桥。
|
|
476
|
+
async function handleCodexEvent(evt, _ptyManager, runtimeConfig) {
|
|
477
|
+
if (!evt) return;
|
|
478
|
+
const port = runtimeConfig?.port || 5677;
|
|
479
|
+
console.log(`[codex-event] ${evt.event} native=${evt.nativeId}`);
|
|
480
|
+
try {
|
|
481
|
+
await fetch(`http://127.0.0.1:${port}/api/openclaw/hook`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
headers: { "content-type": "application/json" },
|
|
484
|
+
body: JSON.stringify({
|
|
485
|
+
source: "codex",
|
|
486
|
+
path: "jsonl",
|
|
487
|
+
event: evt.event,
|
|
488
|
+
nativeId: evt.nativeId,
|
|
489
|
+
transcript_path: evt.transcriptPath || null,
|
|
490
|
+
raw_event_payload: evt.rawEventPayload || null,
|
|
491
|
+
}),
|
|
492
|
+
});
|
|
493
|
+
} catch (e) {
|
|
494
|
+
console.warn("[codex-event] post failed:", e.message);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function buildSafeLarkConfig(cfg) {
|
|
499
|
+
const { appSecret: _appSecret, ...larkSafe } = cfg.lark || {};
|
|
500
|
+
return {
|
|
501
|
+
...larkSafe,
|
|
502
|
+
appSecretMasked: maskLarkAppSecret(cfg.lark?.appSecret),
|
|
503
|
+
appSecretSource: larkAppSecretSource(cfg),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @param opts.dbFile SQLite file path (or ':memory:')
|
|
509
|
+
* @param opts.logDir directory for ai session logs
|
|
510
|
+
* @param opts.tools tools config { claude: { bin, args }, codex: { ... } }
|
|
511
|
+
* @param opts.pty (optional) injected PtyManager — for tests
|
|
512
|
+
* @param opts.webDist (optional) directory with built frontend assets
|
|
513
|
+
* @param opts.strictWebDist (optional) when true, throw if webDist/index.html is missing
|
|
514
|
+
*/
|
|
515
|
+
export function createServer(opts = {}) {
|
|
516
|
+
const {
|
|
517
|
+
dbFile = ":memory:",
|
|
518
|
+
logDir,
|
|
519
|
+
tools,
|
|
520
|
+
defaultCwd,
|
|
521
|
+
configRootDir,
|
|
522
|
+
pty: injectedPty,
|
|
523
|
+
webDist,
|
|
524
|
+
strictWebDist = false,
|
|
525
|
+
pickDirectory = pickDirectoryNative,
|
|
526
|
+
openNativeTerminal = openNativeTerminalNative,
|
|
527
|
+
inspectHooks = inspectClaudeHooks,
|
|
528
|
+
} = opts;
|
|
529
|
+
|
|
530
|
+
if (strictWebDist) {
|
|
531
|
+
const indexPath = join(webDist || "", "index.html");
|
|
532
|
+
if (!webDist || !existsSync(indexPath)) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
`frontend assets missing: ${indexPath}\n` +
|
|
535
|
+
` - if you installed via npm: reinstall with \`npm i -g agentquad\`\n` +
|
|
536
|
+
` - if running from source: \`cd web && npm install && npm run build\``,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const db = openDb(dbFile);
|
|
542
|
+
const initialConfig = configRootDir
|
|
543
|
+
? loadConfig({ rootDir: configRootDir })
|
|
544
|
+
: null;
|
|
545
|
+
const runtimeConfig = {
|
|
546
|
+
defaultCwd:
|
|
547
|
+
defaultCwd ||
|
|
548
|
+
initialConfig?.defaultCwd ||
|
|
549
|
+
process.env.HOME ||
|
|
550
|
+
process.cwd(),
|
|
551
|
+
tools: tools || resolveToolsConfig(initialConfig?.tools),
|
|
552
|
+
defaultTool: initialConfig?.defaultTool || "claude",
|
|
553
|
+
};
|
|
554
|
+
// Codex sidecar:把 AgentQuad session ↔ codex native id 的映射落到 ~/.agentquad/codex-sessions/,
|
|
555
|
+
// 重启后 restoreFromDisk() 复活内存映射。Phase A 只暂存元数据;Phase C 起 IM 推送链路会用它
|
|
556
|
+
// 来反查 AgentQuad session / todoId / cwd。
|
|
557
|
+
const codexSidecar = createCodexSidecar();
|
|
558
|
+
codexSidecar.restoreFromDisk();
|
|
559
|
+
let ptyRef = null;
|
|
560
|
+
const pty =
|
|
561
|
+
injectedPty ||
|
|
562
|
+
new PtyManager({
|
|
563
|
+
tools: runtimeConfig.tools || {},
|
|
564
|
+
sidecar: codexSidecar,
|
|
565
|
+
eventEmitterFactory: (opts) =>
|
|
566
|
+
createCodexEventEmitter({
|
|
567
|
+
...opts,
|
|
568
|
+
// 把 emitterFactory 已知的 jsonl 路径注入到事件里,下游 hook 可以直接读 transcript。
|
|
569
|
+
onEvent: (evt) =>
|
|
570
|
+
handleCodexEvent(
|
|
571
|
+
{ ...evt, transcriptPath: evt?.transcriptPath || opts?.filePath || null },
|
|
572
|
+
ptyRef,
|
|
573
|
+
runtimeConfig,
|
|
574
|
+
),
|
|
575
|
+
}),
|
|
576
|
+
});
|
|
577
|
+
ptyRef = pty;
|
|
578
|
+
|
|
579
|
+
// Phase E:Codex stdout 提示词检测器命中 → 走与 Claude/Codex jsonl 相同的 hook 端点。
|
|
580
|
+
// path=detector 让 hook handler 走 handleCodexDetector 分支,推权限卡片到 IM。
|
|
581
|
+
pty.on("codex-prompt", async (data) => {
|
|
582
|
+
const port = runtimeConfig?.port || 5677;
|
|
583
|
+
try {
|
|
584
|
+
await fetch(`http://127.0.0.1:${port}/api/openclaw/hook`, {
|
|
585
|
+
method: "POST",
|
|
586
|
+
headers: { "content-type": "application/json" },
|
|
587
|
+
body: JSON.stringify({
|
|
588
|
+
source: "codex",
|
|
589
|
+
path: "detector",
|
|
590
|
+
event: "Notification",
|
|
591
|
+
sessionId: data.sessionId,
|
|
592
|
+
nativeId: data.nativeId,
|
|
593
|
+
promptText: data.promptText,
|
|
594
|
+
matchedPattern: data.matchedPattern,
|
|
595
|
+
}),
|
|
596
|
+
});
|
|
597
|
+
} catch (e) {
|
|
598
|
+
console.warn("[codex-prompt] post failed:", e.message);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Telegram 自动 topic 钩子:ait 创建在前,wizard 创建在后;用 lazy ref 桥接
|
|
603
|
+
const aiSessionHooks = {
|
|
604
|
+
onSessionSpawned: () => null,
|
|
605
|
+
onSessionEnded: () => null,
|
|
606
|
+
};
|
|
607
|
+
const ait = createAiTerminal({
|
|
608
|
+
db,
|
|
609
|
+
pty,
|
|
610
|
+
logDir,
|
|
611
|
+
getDefaultCwd: () => runtimeConfig.defaultCwd,
|
|
612
|
+
onSessionSpawned: (info) => aiSessionHooks.onSessionSpawned(info),
|
|
613
|
+
onSessionEnded: (info) => aiSessionHooks.onSessionEnded(info),
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const app = express();
|
|
617
|
+
app.use(express.json({ limit: "2mb" }));
|
|
618
|
+
|
|
619
|
+
app.get("/api/status", (_req, res) => {
|
|
620
|
+
res.json({
|
|
621
|
+
ok: true,
|
|
622
|
+
version: loadVersion(),
|
|
623
|
+
activeSessions: ait.sessions.size,
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
app.get("/api/config", (_req, res) => {
|
|
628
|
+
try {
|
|
629
|
+
const cfg = loadConfig({ rootDir: configRootDir });
|
|
630
|
+
const { token, source } = readBotTokenWithSource(() => cfg);
|
|
631
|
+
const { botToken: _botToken, ...telegramSafe } = cfg.telegram || {};
|
|
632
|
+
res.json({
|
|
633
|
+
ok: true,
|
|
634
|
+
config: {
|
|
635
|
+
...cfg,
|
|
636
|
+
tools: resolveToolsConfig(cfg.tools),
|
|
637
|
+
telegram: {
|
|
638
|
+
...telegramSafe,
|
|
639
|
+
botTokenMasked: maskBotToken(token),
|
|
640
|
+
botTokenSource: source,
|
|
641
|
+
},
|
|
642
|
+
lark: buildSafeLarkConfig(cfg),
|
|
643
|
+
},
|
|
644
|
+
toolDiagnostics: inspectToolsConfig(cfg.tools),
|
|
645
|
+
});
|
|
646
|
+
} catch (e) {
|
|
647
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
app.put("/api/config", async (req, res) => {
|
|
652
|
+
try {
|
|
653
|
+
const current = loadConfig({ rootDir: configRootDir });
|
|
654
|
+
const nextToolsPatch = req.body?.tools || {};
|
|
655
|
+
const pricingPatch = req.body?.pricing;
|
|
656
|
+
|
|
657
|
+
// Telegram token mask 处理
|
|
658
|
+
const telegramPatch = { ...(req.body?.telegram || {}) };
|
|
659
|
+
if ('botToken' in telegramPatch) {
|
|
660
|
+
const tok = telegramPatch.botToken;
|
|
661
|
+
if (isMaskedToken(tok)) {
|
|
662
|
+
// 用户没改 token —— 删除该字段,保留磁盘原值
|
|
663
|
+
delete telegramPatch.botToken;
|
|
664
|
+
} else if (tok === '') {
|
|
665
|
+
// 显式清空
|
|
666
|
+
telegramPatch.botToken = null;
|
|
667
|
+
}
|
|
668
|
+
// 其他字符串:透传作为新值
|
|
669
|
+
}
|
|
670
|
+
// botTokenMasked / botTokenSource 是 GET-only,PUT 收到的不能写回
|
|
671
|
+
delete telegramPatch.botTokenMasked;
|
|
672
|
+
delete telegramPatch.botTokenSource;
|
|
673
|
+
|
|
674
|
+
// 合并 telegram / lark 段
|
|
675
|
+
const mergedTelegram = { ...current.telegram, ...telegramPatch };
|
|
676
|
+
const larkPatch = { ...(req.body?.lark || {}) };
|
|
677
|
+
if ('appSecret' in larkPatch) {
|
|
678
|
+
const secret = larkPatch.appSecret;
|
|
679
|
+
if (isMaskedLarkAppSecret(secret) || secret === '') {
|
|
680
|
+
delete larkPatch.appSecret;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
delete larkPatch.appSecretMasked;
|
|
684
|
+
delete larkPatch.appSecretSource;
|
|
685
|
+
const mergedLark = { ...current.lark, ...larkPatch };
|
|
686
|
+
|
|
687
|
+
// 检测 bot 段是否变化(用于触发热重启)
|
|
688
|
+
const telegramChanged = JSON.stringify(mergedTelegram) !== JSON.stringify(current.telegram);
|
|
689
|
+
const larkChanged = JSON.stringify(mergedLark) !== JSON.stringify(current.lark);
|
|
690
|
+
|
|
691
|
+
// 不能直接 ...req.body 因为里面可能有原始 telegramPatch(含 mask)—— 排除掉再 spread
|
|
692
|
+
const { telegram: _t, lark: _l, ...bodyWithoutTelegram } = req.body || {};
|
|
693
|
+
|
|
694
|
+
const next = {
|
|
695
|
+
...current,
|
|
696
|
+
...bodyWithoutTelegram,
|
|
697
|
+
telegram: mergedTelegram,
|
|
698
|
+
lark: mergedLark,
|
|
699
|
+
tools: (() => {
|
|
700
|
+
const merged = { ...current.tools };
|
|
701
|
+
for (const name of SUPPORTED_TOOLS) {
|
|
702
|
+
merged[name] = mergeToolConfig(current.tools?.[name], nextToolsPatch[name]);
|
|
703
|
+
}
|
|
704
|
+
return merged;
|
|
705
|
+
})(),
|
|
706
|
+
// 深合并 pricing:允许前端只发部分字段(如仅改 cnyRate)而不清空其他。
|
|
707
|
+
// models 字段整体替换,这样 UI 里删除条目才能落到磁盘。
|
|
708
|
+
pricing: pricingPatch
|
|
709
|
+
? {
|
|
710
|
+
cnyRate: pricingPatch.cnyRate ?? current.pricing.cnyRate,
|
|
711
|
+
default: pricingPatch.default ?? current.pricing.default,
|
|
712
|
+
models: pricingPatch.models ?? current.pricing.models,
|
|
713
|
+
showInPush:
|
|
714
|
+
typeof pricingPatch.showInPush === 'boolean'
|
|
715
|
+
? pricingPatch.showInPush
|
|
716
|
+
: current.pricing.showInPush,
|
|
717
|
+
showCnyInPush:
|
|
718
|
+
typeof pricingPatch.showCnyInPush === 'boolean'
|
|
719
|
+
? pricingPatch.showCnyInPush
|
|
720
|
+
: current.pricing.showCnyInPush,
|
|
721
|
+
}
|
|
722
|
+
: current.pricing,
|
|
723
|
+
};
|
|
724
|
+
saveConfig(next, { rootDir: configRootDir });
|
|
725
|
+
|
|
726
|
+
runtimeConfig.defaultCwd = next.defaultCwd || runtimeConfig.defaultCwd;
|
|
727
|
+
runtimeConfig.defaultTool = next.defaultTool || runtimeConfig.defaultTool;
|
|
728
|
+
runtimeConfig.tools = resolveToolsConfig(next.tools);
|
|
729
|
+
pty.tools = runtimeConfig.tools;
|
|
730
|
+
|
|
731
|
+
// 触发 bot stack 热重启
|
|
732
|
+
let telegramRestart = { applied: false };
|
|
733
|
+
if (telegramChanged) {
|
|
734
|
+
try {
|
|
735
|
+
await restartTelegramStack();
|
|
736
|
+
telegramRestart = { applied: true };
|
|
737
|
+
} catch (e) {
|
|
738
|
+
telegramRestart = { applied: false, error: e.message };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
let larkRestart = { applied: false };
|
|
742
|
+
if (larkChanged) {
|
|
743
|
+
try {
|
|
744
|
+
await restartLarkStack();
|
|
745
|
+
larkRestart = { applied: true };
|
|
746
|
+
} catch (e) {
|
|
747
|
+
larkRestart = { applied: false, error: e.message };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 返回时也走 mask 逻辑(避免 token 泄漏)
|
|
752
|
+
const reloadedCfg = loadConfig({ rootDir: configRootDir });
|
|
753
|
+
const { token, source } = readBotTokenWithSource(() => reloadedCfg);
|
|
754
|
+
const { botToken: _drop, ...telegramSafe } = reloadedCfg.telegram || {};
|
|
755
|
+
|
|
756
|
+
res.json({
|
|
757
|
+
ok: true,
|
|
758
|
+
config: {
|
|
759
|
+
...reloadedCfg,
|
|
760
|
+
tools: runtimeConfig.tools,
|
|
761
|
+
telegram: {
|
|
762
|
+
...telegramSafe,
|
|
763
|
+
botTokenMasked: maskBotToken(token),
|
|
764
|
+
botTokenSource: source,
|
|
765
|
+
},
|
|
766
|
+
lark: buildSafeLarkConfig(reloadedCfg),
|
|
767
|
+
},
|
|
768
|
+
toolDiagnostics: inspectToolsConfig(next.tools),
|
|
769
|
+
runtimeApplied: {
|
|
770
|
+
defaultCwd: runtimeConfig.defaultCwd,
|
|
771
|
+
defaultTool: runtimeConfig.defaultTool,
|
|
772
|
+
larkRestart,
|
|
773
|
+
},
|
|
774
|
+
telegramRestart,
|
|
775
|
+
});
|
|
776
|
+
} catch (e) {
|
|
777
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
app.get("/api/config/lark/status", (_req, res) => {
|
|
782
|
+
try {
|
|
783
|
+
const bot = larkBotHolder.current
|
|
784
|
+
if (!bot) {
|
|
785
|
+
res.json({ ok: true, status: { running: false, reason: 'lark_bot_not_running' } })
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
const status = bot.describe?.() || null
|
|
789
|
+
res.json({ ok: true, status })
|
|
790
|
+
} catch (e) {
|
|
791
|
+
res.status(500).json({ ok: false, error: e.message })
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
app.post("/api/config/lark/test", async (req, res) => {
|
|
796
|
+
try {
|
|
797
|
+
const current = loadConfig({ rootDir: configRootDir });
|
|
798
|
+
const inputAppId = typeof req.body?.appId === "string" ? req.body.appId.trim() : "";
|
|
799
|
+
const inputSecret = typeof req.body?.appSecret === "string" ? req.body.appSecret.trim() : "";
|
|
800
|
+
const appId = inputAppId || current.lark?.appId || "";
|
|
801
|
+
const appSecret = inputSecret && !isMaskedLarkAppSecret(inputSecret)
|
|
802
|
+
? inputSecret
|
|
803
|
+
: current.lark?.appSecret || "";
|
|
804
|
+
const source = inputAppId || inputSecret ? "input" : larkAppSecretSource(current);
|
|
805
|
+
const client = createLarkApiClient({ appId, appSecret });
|
|
806
|
+
const result = await client.testConnection();
|
|
807
|
+
if (result.ok) {
|
|
808
|
+
res.json({ ok: true, source });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
res.json({ ok: false, source, errorReason: result.reason, detail: result.detail });
|
|
812
|
+
} catch (e) {
|
|
813
|
+
res.json({ ok: false, source: "input", errorReason: e.message || "unknown" });
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
app.get("/api/config/workdirs", (_req, res) => {
|
|
818
|
+
try {
|
|
819
|
+
const root = runtimeConfig.defaultCwd;
|
|
820
|
+
if (!root || !existsSync(root)) {
|
|
821
|
+
res.status(400).json({ ok: false, error: "default_cwd_not_found" });
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const entries = readdirSync(root, { withFileTypes: true })
|
|
825
|
+
.filter((entry) => entry.isDirectory())
|
|
826
|
+
.map((entry) => {
|
|
827
|
+
const absPath = join(root, entry.name);
|
|
828
|
+
const st = statSync(absPath);
|
|
829
|
+
return {
|
|
830
|
+
label: entry.name,
|
|
831
|
+
value: absPath,
|
|
832
|
+
mtimeMs: st.mtimeMs || 0,
|
|
833
|
+
};
|
|
834
|
+
})
|
|
835
|
+
.sort((a, b) => a.label.localeCompare(b.label, "zh-Hans-CN"));
|
|
836
|
+
res.json({
|
|
837
|
+
ok: true,
|
|
838
|
+
root,
|
|
839
|
+
options: [
|
|
840
|
+
{ label: `${basename(root) || root} (默认目录)`, value: root },
|
|
841
|
+
...entries,
|
|
842
|
+
],
|
|
843
|
+
});
|
|
844
|
+
} catch (e) {
|
|
845
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const EDITOR_BINS = {
|
|
850
|
+
"trae-cn": "/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn",
|
|
851
|
+
"trae": "/Applications/Trae.app/Contents/Resources/app/bin/trae",
|
|
852
|
+
"cursor": "/Applications/Cursor.app/Contents/Resources/app/bin/cursor",
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
app.post("/api/system/open-trae", (req, res) => {
|
|
856
|
+
try {
|
|
857
|
+
const cwd = req.body?.cwd || runtimeConfig.defaultCwd;
|
|
858
|
+
if (!cwd || !existsSync(cwd)) {
|
|
859
|
+
res.status(400).json({ ok: false, error: "cwd_not_found" });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const editor = req.body?.editor || "trae-cn";
|
|
863
|
+
const bin = EDITOR_BINS[editor];
|
|
864
|
+
if (!bin) {
|
|
865
|
+
res.status(400).json({ ok: false, error: "invalid_editor" });
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (!existsSync(bin)) {
|
|
869
|
+
res.status(400).json({ ok: false, error: "editor_not_installed" });
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
// 可选的具体打开目标(文件或目录);支持 path:line:col 语法
|
|
873
|
+
// 始终把 cwd 作为 workspace folder 传入,再附带具体文件,让编辑器默认打开这个目录
|
|
874
|
+
const args = ["--new-window", cwd];
|
|
875
|
+
const rawPath = req.body?.path;
|
|
876
|
+
const sessionId =
|
|
877
|
+
typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
|
878
|
+
const terminalSession = sessionId ? ait.sessions.get(sessionId) : null;
|
|
879
|
+
if (typeof rawPath === "string" && rawPath.trim()) {
|
|
880
|
+
const resolved = resolveEditorTargetInfo(
|
|
881
|
+
[
|
|
882
|
+
terminalSession?.currentCwd,
|
|
883
|
+
terminalSession?.cwd,
|
|
884
|
+
cwd,
|
|
885
|
+
runtimeConfig.defaultCwd,
|
|
886
|
+
],
|
|
887
|
+
rawPath,
|
|
888
|
+
);
|
|
889
|
+
if (!resolved) {
|
|
890
|
+
res.status(400).json({ ok: false, error: "path_not_found" });
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (terminalSession && resolved.baseDir) {
|
|
894
|
+
terminalSession.currentCwd = resolved.baseDir;
|
|
895
|
+
}
|
|
896
|
+
// VSCode 系 CLI 支持 --goto file:line:col 精确跳转
|
|
897
|
+
args.push("--goto", resolved.resolvedPath);
|
|
898
|
+
}
|
|
899
|
+
const child = spawn(bin, args, {
|
|
900
|
+
cwd,
|
|
901
|
+
stdio: "ignore",
|
|
902
|
+
detached: true,
|
|
903
|
+
});
|
|
904
|
+
child.on("error", (err) => {
|
|
905
|
+
console.warn(`[open-trae] ${editor} spawn error:`, err.message);
|
|
906
|
+
});
|
|
907
|
+
child.unref();
|
|
908
|
+
res.json({ ok: true });
|
|
909
|
+
} catch (e) {
|
|
910
|
+
console.error("[open-trae]", e);
|
|
911
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
app.post("/api/system/open-terminal", (req, res) => {
|
|
916
|
+
try {
|
|
917
|
+
const cwd = req.body?.cwd || runtimeConfig.defaultCwd;
|
|
918
|
+
if (!cwd || !existsSync(cwd)) {
|
|
919
|
+
res.status(400).json({ ok: false, error: "cwd_not_found" });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const sessionId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
923
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
924
|
+
pty.startShell({ sessionId, shell, cwd });
|
|
925
|
+
|
|
926
|
+
const session = {
|
|
927
|
+
sessionId,
|
|
928
|
+
type: "shell",
|
|
929
|
+
status: "running",
|
|
930
|
+
startedAt: Date.now(),
|
|
931
|
+
browsers: new Set(),
|
|
932
|
+
outputHistory: [],
|
|
933
|
+
outputSize: 0,
|
|
934
|
+
};
|
|
935
|
+
ait.sessions.set(sessionId, session);
|
|
936
|
+
|
|
937
|
+
const onOutput = ({ sessionId: sid, data }) => {
|
|
938
|
+
if (sid !== sessionId) return;
|
|
939
|
+
session.outputHistory.push(data);
|
|
940
|
+
session.outputSize += data.length;
|
|
941
|
+
while (session.outputSize > 512 * 1024 && session.outputHistory.length > 1) {
|
|
942
|
+
const removed = session.outputHistory.shift();
|
|
943
|
+
session.outputSize -= removed.length;
|
|
944
|
+
}
|
|
945
|
+
const msg = JSON.stringify({ type: "output", data });
|
|
946
|
+
for (const ws of session.browsers) {
|
|
947
|
+
if (ws.readyState === ws.OPEN) ws.send(msg);
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
const onDone = ({ sessionId: sid }) => {
|
|
951
|
+
if (sid !== sessionId) return;
|
|
952
|
+
session.status = "done";
|
|
953
|
+
const msg = JSON.stringify({ type: "done", exitCode: 0, status: "done" });
|
|
954
|
+
for (const ws of session.browsers) {
|
|
955
|
+
if (ws.readyState === ws.OPEN) ws.send(msg);
|
|
956
|
+
}
|
|
957
|
+
pty.removeListener("output", onOutput);
|
|
958
|
+
pty.removeListener("done", onDone);
|
|
959
|
+
};
|
|
960
|
+
pty.on("output", onOutput);
|
|
961
|
+
pty.on("done", onDone);
|
|
962
|
+
|
|
963
|
+
res.json({ ok: true, sessionId });
|
|
964
|
+
} catch (e) {
|
|
965
|
+
console.error("[open-terminal]", e);
|
|
966
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
app.post("/api/system/open-native-ai-resume", async (req, res) => {
|
|
971
|
+
try {
|
|
972
|
+
const cwd = req.body?.cwd || runtimeConfig.defaultCwd;
|
|
973
|
+
if (!cwd || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
974
|
+
res.status(400).json({ ok: false, error: "cwd_not_found" });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const tool = req.body?.tool;
|
|
978
|
+
const nativeSessionId = req.body?.nativeSessionId;
|
|
979
|
+
const title = buildNativeResumeTitle(tool, nativeSessionId);
|
|
980
|
+
const baseCommand = buildNativeResumeCommand(
|
|
981
|
+
tool,
|
|
982
|
+
nativeSessionId,
|
|
983
|
+
runtimeConfig.tools,
|
|
984
|
+
);
|
|
985
|
+
const { todo, aiSession } = findNativeResumeContext({
|
|
986
|
+
db,
|
|
987
|
+
todoId: req.body?.todoId,
|
|
988
|
+
sessionId: req.body?.sessionId,
|
|
989
|
+
nativeSessionId,
|
|
990
|
+
tool,
|
|
991
|
+
});
|
|
992
|
+
const hook = buildNativeResumeHookEnv({ tool, todo, aiSession, runtimeConfig, inspectHooks });
|
|
993
|
+
// register 顺序与 server.js rehydration(line 1497-1510)一致:
|
|
994
|
+
// telegram 先写,lark 后写覆盖。openclawBridge.sessionRoutes 是单 Map,
|
|
995
|
+
// 同 sid 只能存一条;这里两条都跑也是 idempotent 的。
|
|
996
|
+
if (isCompleteTelegramRoute(aiSession?.telegramRoute)) {
|
|
997
|
+
openclawBridge.registerSessionRoute(aiSession.sessionId, aiSession.telegramRoute);
|
|
998
|
+
}
|
|
999
|
+
if (isCompleteLarkRoute(aiSession?.larkRoute)) {
|
|
1000
|
+
openclawBridge.registerSessionRoute(aiSession.sessionId, aiSession.larkRoute);
|
|
1001
|
+
}
|
|
1002
|
+
const command = `${buildShellExports(hook.env)}${baseCommand}`;
|
|
1003
|
+
const result = await openNativeTerminal({ cwd, command, title });
|
|
1004
|
+
let markedTodo = null;
|
|
1005
|
+
if (todo && aiSession) {
|
|
1006
|
+
const openedAt = Date.now();
|
|
1007
|
+
const sessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : [];
|
|
1008
|
+
markedTodo = db.updateTodo(todo.id, {
|
|
1009
|
+
aiSessions: sessions.map((item) =>
|
|
1010
|
+
item?.sessionId === aiSession.sessionId
|
|
1011
|
+
? { ...item, localResume: { openedAt } }
|
|
1012
|
+
: item,
|
|
1013
|
+
),
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
res.json({
|
|
1017
|
+
ok: true,
|
|
1018
|
+
cwd: result?.cwd || cwd,
|
|
1019
|
+
title: result?.title || title,
|
|
1020
|
+
command: result?.command || command,
|
|
1021
|
+
action: result?.action || "created",
|
|
1022
|
+
warnings: hook.warnings,
|
|
1023
|
+
...(markedTodo ? { todo: markedTodo } : {}),
|
|
1024
|
+
});
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
const status = [
|
|
1027
|
+
"native_terminal_unsupported",
|
|
1028
|
+
"invalid_tool",
|
|
1029
|
+
"missing_native_session_id",
|
|
1030
|
+
"tool_not_configured",
|
|
1031
|
+
].includes(e?.code)
|
|
1032
|
+
? 400
|
|
1033
|
+
: 500;
|
|
1034
|
+
res.status(status).json({ ok: false, error: e.message });
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// 列出 Claude Code 可用 slash 命令,作为 Chat composer 的 / 自动补全来源
|
|
1039
|
+
// 扫描:~/.claude/{commands,skills} + <cwd>/.claude/{commands,skills} + 已安装插件的同样目录
|
|
1040
|
+
app.get("/api/claude-commands", (req, res) => {
|
|
1041
|
+
const extractDescription = (filePath) => {
|
|
1042
|
+
try {
|
|
1043
|
+
const content = readFileSync(filePath, "utf8")
|
|
1044
|
+
const fm = content.match(/^---\n([\s\S]*?)\n---/)
|
|
1045
|
+
if (fm) {
|
|
1046
|
+
const dm = fm[1].match(/^description:\s*(.+)$/m)
|
|
1047
|
+
if (dm) return dm[1].trim().replace(/^['"]|['"]$/g, "")
|
|
1048
|
+
}
|
|
1049
|
+
const body = fm ? content.slice(fm[0].length) : content
|
|
1050
|
+
const firstLine = body.split("\n").map(s => s.trim()).filter(Boolean)[0]
|
|
1051
|
+
return firstLine ? firstLine.slice(0, 200) : ""
|
|
1052
|
+
} catch { return "" }
|
|
1053
|
+
}
|
|
1054
|
+
// commands/*.md:name = 文件名
|
|
1055
|
+
const readCommandDir = (dir, scope, source) => {
|
|
1056
|
+
try {
|
|
1057
|
+
if (!existsSync(dir)) return []
|
|
1058
|
+
return readdirSync(dir).filter(f => f.endsWith(".md")).map(f => ({
|
|
1059
|
+
name: basename(f, ".md"),
|
|
1060
|
+
description: extractDescription(join(dir, f)),
|
|
1061
|
+
scope, source,
|
|
1062
|
+
}))
|
|
1063
|
+
} catch { return [] }
|
|
1064
|
+
}
|
|
1065
|
+
// skills/<name>/SKILL.md:name = 子目录名
|
|
1066
|
+
const readSkillDir = (dir, scope, source) => {
|
|
1067
|
+
try {
|
|
1068
|
+
if (!existsSync(dir)) return []
|
|
1069
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
1070
|
+
.filter(d => d.isDirectory())
|
|
1071
|
+
.map(d => {
|
|
1072
|
+
const skillPath = join(dir, d.name, "SKILL.md")
|
|
1073
|
+
if (!existsSync(skillPath)) return null
|
|
1074
|
+
return {
|
|
1075
|
+
name: d.name,
|
|
1076
|
+
description: extractDescription(skillPath),
|
|
1077
|
+
scope, source,
|
|
1078
|
+
}
|
|
1079
|
+
})
|
|
1080
|
+
.filter(Boolean)
|
|
1081
|
+
} catch { return [] }
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
const cwd = typeof req.query.cwd === "string" && req.query.cwd.trim() ? req.query.cwd : null
|
|
1085
|
+
const all = []
|
|
1086
|
+
const home = homedir()
|
|
1087
|
+
// 用户级
|
|
1088
|
+
all.push(...readCommandDir(join(home, ".claude", "commands"), "global", "user"))
|
|
1089
|
+
all.push(...readSkillDir(join(home, ".claude", "skills"), "global", "user"))
|
|
1090
|
+
// 项目级
|
|
1091
|
+
if (cwd) {
|
|
1092
|
+
all.push(...readCommandDir(join(cwd, ".claude", "commands"), "local", "project"))
|
|
1093
|
+
all.push(...readSkillDir(join(cwd, ".claude", "skills"), "local", "project"))
|
|
1094
|
+
}
|
|
1095
|
+
// 已安装插件
|
|
1096
|
+
try {
|
|
1097
|
+
const pluginsFile = join(home, ".claude", "plugins", "installed_plugins.json")
|
|
1098
|
+
if (existsSync(pluginsFile)) {
|
|
1099
|
+
const cfg = JSON.parse(readFileSync(pluginsFile, "utf8"))
|
|
1100
|
+
for (const [pluginKey, installs] of Object.entries(cfg.plugins || {})) {
|
|
1101
|
+
for (const inst of installs || []) {
|
|
1102
|
+
if (!inst?.installPath || !existsSync(inst.installPath)) continue
|
|
1103
|
+
all.push(...readCommandDir(join(inst.installPath, "commands"), "global", `plugin:${pluginKey}`))
|
|
1104
|
+
all.push(...readSkillDir(join(inst.installPath, "skills"), "global", `plugin:${pluginKey}`))
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
} catch { /* ignore */ }
|
|
1109
|
+
// 同名优先级:local > user > plugin(local 最后进也会覆盖前面)
|
|
1110
|
+
const byName = new Map()
|
|
1111
|
+
const priority = (source) => source === "project" ? 3 : source === "user" ? 2 : 1
|
|
1112
|
+
for (const c of all) {
|
|
1113
|
+
const existing = byName.get(c.name)
|
|
1114
|
+
if (!existing || priority(c.source) >= priority(existing.source)) byName.set(c.name, c)
|
|
1115
|
+
}
|
|
1116
|
+
const commands = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name))
|
|
1117
|
+
res.json({ ok: true, commands })
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
res.status(500).json({ ok: false, error: e.message })
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
app.post("/api/system/pick-directory", async (req, res) => {
|
|
1124
|
+
try {
|
|
1125
|
+
const result = await pickDirectory({
|
|
1126
|
+
defaultPath: req.body?.defaultPath || runtimeConfig.defaultCwd,
|
|
1127
|
+
prompt: req.body?.prompt || "选择目录",
|
|
1128
|
+
});
|
|
1129
|
+
res.json({
|
|
1130
|
+
ok: true,
|
|
1131
|
+
path: result?.path || null,
|
|
1132
|
+
cancelled: Boolean(result?.cancelled),
|
|
1133
|
+
});
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
const status = e?.code === "directory_picker_unsupported" ? 400 : 500;
|
|
1136
|
+
res.status(status).json({ ok: false, error: e.message });
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
// 图片粘贴/拖拽上传:单独提一个 JSON body limit(30MB)覆盖全局 2MB 限制
|
|
1141
|
+
app.use("/api/uploads", express.json({ limit: "30mb" }), createUploadsRouter({ logger: console }))
|
|
1142
|
+
|
|
1143
|
+
app.use("/api/todos", createTodosRouter({
|
|
1144
|
+
db,
|
|
1145
|
+
logDir,
|
|
1146
|
+
getPricing: () => loadConfig({ rootDir: configRootDir }).pricing,
|
|
1147
|
+
getTools: () => runtimeConfig.tools,
|
|
1148
|
+
getLiveSession: (sessionId) => ait.sessions.get(sessionId) || null,
|
|
1149
|
+
getPty: () => pty,
|
|
1150
|
+
}));
|
|
1151
|
+
app.use("/api/templates", createTemplatesRouter({ db }));
|
|
1152
|
+
app.use("/api/recurring-rules", createRecurringRulesRouter({ db }));
|
|
1153
|
+
app.use("/api/ai-terminal", ait.router);
|
|
1154
|
+
|
|
1155
|
+
const transcriptsService = createTranscriptsService({
|
|
1156
|
+
db,
|
|
1157
|
+
listTodos: () => db.listTodos(),
|
|
1158
|
+
updateTodo: (id, patch) => db.updateTodo(id, patch),
|
|
1159
|
+
});
|
|
1160
|
+
app.use("/api/transcripts", createTranscriptsRouter({ service: transcriptsService }));
|
|
1161
|
+
app.use("/api/stats", createStatsRouter({
|
|
1162
|
+
db,
|
|
1163
|
+
getPricing: () => loadConfig({ rootDir: configRootDir }).pricing,
|
|
1164
|
+
}));
|
|
1165
|
+
app.use("/api/reports", createReportsRouter({ db }));
|
|
1166
|
+
|
|
1167
|
+
const wikiConfig = (initialConfig && initialConfig.wiki) || {
|
|
1168
|
+
wikiDir: join(DEFAULT_ROOT_DIR, "wiki"),
|
|
1169
|
+
maxTailTurns: 20,
|
|
1170
|
+
tool: "claude",
|
|
1171
|
+
timeoutMs: 600_000,
|
|
1172
|
+
redact: true,
|
|
1173
|
+
};
|
|
1174
|
+
const wikiService = createWikiService({
|
|
1175
|
+
db,
|
|
1176
|
+
logDir,
|
|
1177
|
+
wikiDir: wikiConfig.wikiDir,
|
|
1178
|
+
getTools: () => runtimeConfig.tools || {},
|
|
1179
|
+
maxTailTurns: wikiConfig.maxTailTurns ?? 20,
|
|
1180
|
+
timeoutMs: wikiConfig.timeoutMs ?? 600_000,
|
|
1181
|
+
redactEnabled: wikiConfig.redact !== false,
|
|
1182
|
+
});
|
|
1183
|
+
app.use("/api/wiki", createWikiRouter({ service: wikiService }));
|
|
1184
|
+
|
|
1185
|
+
// 全局搜索:给 ⌘K 面板和 MCP 共用
|
|
1186
|
+
const searchService = createSearchService({ db, wikiDir: wikiConfig.wikiDir });
|
|
1187
|
+
try {
|
|
1188
|
+
const initResult = searchService.init();
|
|
1189
|
+
if (initResult?.rebuilt?.length) {
|
|
1190
|
+
console.log(`[search] fts ready, rebuilt: ${initResult.rebuilt.join(", ")}`);
|
|
1191
|
+
}
|
|
1192
|
+
} catch (e) {
|
|
1193
|
+
console.warn(`[search] fts init failed:`, e.message);
|
|
1194
|
+
}
|
|
1195
|
+
app.use("/api/search", createSearchRouter({ searchService }));
|
|
1196
|
+
|
|
1197
|
+
// OpenClaw 双向桥接:bridge(出站)+ pending-question 协调器(双向阻塞)
|
|
1198
|
+
const openclawBridge = createOpenClawBridge({
|
|
1199
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1200
|
+
});
|
|
1201
|
+
const pendingCoord = createPendingQuestionCoordinator({ db });
|
|
1202
|
+
pendingCoord.start();
|
|
1203
|
+
|
|
1204
|
+
// ─── Telegram stack(可热重启)─────────────────────────────────
|
|
1205
|
+
// holder 模式:所有依赖方持有 holder.current 而非裸引用,重启时只换 .current
|
|
1206
|
+
const telegramBotHolder = { current: null }
|
|
1207
|
+
const larkBotHolder = { current: null }
|
|
1208
|
+
const loadingTrackerHolder = { current: null }
|
|
1209
|
+
const reactionTrackerHolder = { current: null }
|
|
1210
|
+
const probeRegistry = createProbeRegistry()
|
|
1211
|
+
|
|
1212
|
+
// wizard lazy ref 必须先声明,因为 createTelegramBot 需要它
|
|
1213
|
+
const openclawWizardLazyRef = {
|
|
1214
|
+
handleInbound: () => Promise.resolve({ reply: 'wizard not ready' }),
|
|
1215
|
+
handleCallback: () => Promise.resolve({ toast: 'wizard not ready', action: 'invalid' }),
|
|
1216
|
+
handleTopicEvent: () => Promise.resolve({ ok: false, reason: 'wizard not ready' }),
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
function startTelegramStack() {
|
|
1220
|
+
const cfg = loadConfig({ rootDir: configRootDir })
|
|
1221
|
+
const tg = cfg.telegram || {}
|
|
1222
|
+
if (!tg.enabled) {
|
|
1223
|
+
console.log('[telegram] disabled, skipping bot start')
|
|
1224
|
+
return
|
|
1225
|
+
}
|
|
1226
|
+
// lazyRef 解循环依赖:bot dispatch 需要 reactionTracker,但 reactionTracker 又依赖 bot
|
|
1227
|
+
const reactionTrackerLazyRef = {
|
|
1228
|
+
noteUserMessage: (...args) => reactionTrackerHolder.current?.noteUserMessage?.(...args) ?? Promise.resolve(),
|
|
1229
|
+
clearReactionsForSession: (...args) => reactionTrackerHolder.current?.clearReactionsForSession?.(...args) ?? Promise.resolve({ ok: true, removed: 0 }),
|
|
1230
|
+
}
|
|
1231
|
+
const bot = createTelegramBot({
|
|
1232
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1233
|
+
wizard: {
|
|
1234
|
+
handleInbound: (...args) => openclawWizardLazyRef.handleInbound(...args),
|
|
1235
|
+
handleCallback: (...args) => openclawWizardLazyRef.handleCallback(...args),
|
|
1236
|
+
handleTopicEvent: (...args) => openclawWizardLazyRef.handleTopicEvent(...args),
|
|
1237
|
+
},
|
|
1238
|
+
reactionTracker: reactionTrackerLazyRef,
|
|
1239
|
+
logger: { warn: (...a) => console.warn(...a), info: (...a) => console.log(...a) },
|
|
1240
|
+
})
|
|
1241
|
+
telegramBotHolder.current = bot
|
|
1242
|
+
loadingTrackerHolder.current = createLoadingTracker({
|
|
1243
|
+
telegramBot: bot,
|
|
1244
|
+
openclaw: openclawBridge,
|
|
1245
|
+
logger: console,
|
|
1246
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1247
|
+
})
|
|
1248
|
+
reactionTrackerHolder.current = createReactionTracker({
|
|
1249
|
+
telegramBot: bot,
|
|
1250
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1251
|
+
logger: console,
|
|
1252
|
+
})
|
|
1253
|
+
openclawBridge.setTelegramBot(bot)
|
|
1254
|
+
bot.start()
|
|
1255
|
+
console.log(`[telegram] bot started; supergroup=${tg.supergroupId || '(unset)'} allowedChatIds=${(tg.allowedChatIds||[]).join(',')||'(empty—reject all)'}`)
|
|
1256
|
+
|
|
1257
|
+
// 注册 Claude Code slash 命令到 supergroup(per-chat scope,不影响 bot 在别处的菜单)
|
|
1258
|
+
// idempotent;失败不阻塞 boot(log warn 后继续)
|
|
1259
|
+
const supergroupId = tg.defaultSupergroupId
|
|
1260
|
+
|| (Array.isArray(tg.allowedChatIds) ? tg.allowedChatIds[0] : null)
|
|
1261
|
+
if (supergroupId) {
|
|
1262
|
+
try {
|
|
1263
|
+
const { commands, skipped } = buildTelegramCommands({ projectRoot: configRootDir, logger: console })
|
|
1264
|
+
bot.setMyCommands({ commands, scope: 'chat', chatId: supergroupId })
|
|
1265
|
+
.then(() => console.log(`[telegram] registered ${commands.length} slash command(s) for supergroup ${supergroupId}${skipped.length ? ` (skipped ${skipped.length})` : ''}`))
|
|
1266
|
+
.catch((e) => console.warn(`[telegram] setMyCommands failed: ${e.message}`))
|
|
1267
|
+
} catch (e) {
|
|
1268
|
+
console.warn(`[telegram] build commands failed: ${e.message}`)
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async function stopTelegramStack() {
|
|
1274
|
+
const bot = telegramBotHolder.current
|
|
1275
|
+
if (!bot) return
|
|
1276
|
+
try { await bot.stop?.() } catch (e) { console.warn(`[telegram] stop failed: ${e.message}`) }
|
|
1277
|
+
telegramBotHolder.current = null
|
|
1278
|
+
loadingTrackerHolder.current = null
|
|
1279
|
+
reactionTrackerHolder.current = null
|
|
1280
|
+
try { openclawBridge.setTelegramBot(null) } catch { /* ignore */ }
|
|
1281
|
+
console.log('[telegram] bot stopped')
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
async function restartTelegramStack() {
|
|
1285
|
+
await stopTelegramStack()
|
|
1286
|
+
startTelegramStack()
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function startLarkStack() {
|
|
1290
|
+
const cfg = loadConfig({ rootDir: configRootDir })
|
|
1291
|
+
const lark = cfg.lark || {}
|
|
1292
|
+
if (!lark.enabled) {
|
|
1293
|
+
larkBotHolder.current = null
|
|
1294
|
+
try { openclawBridge.setLarkBot?.(null) } catch { /* ignore */ }
|
|
1295
|
+
console.log('[lark] disabled, skipping bot start')
|
|
1296
|
+
return
|
|
1297
|
+
}
|
|
1298
|
+
if (!lark.appId || !lark.appSecret) {
|
|
1299
|
+
larkBotHolder.current = null
|
|
1300
|
+
try { openclawBridge.setLarkBot?.(null) } catch { /* ignore */ }
|
|
1301
|
+
console.warn('[lark] enabled but appId/appSecret missing; skipping bot start')
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1304
|
+
const bot = createLarkBot({
|
|
1305
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1306
|
+
wizard: {
|
|
1307
|
+
handleInbound: (...args) => openclawWizardLazyRef.handleInbound(...args),
|
|
1308
|
+
},
|
|
1309
|
+
logger: { warn: (...a) => console.warn(...a), info: (...a) => console.log(...a) },
|
|
1310
|
+
})
|
|
1311
|
+
larkBotHolder.current = bot
|
|
1312
|
+
openclawBridge.setLarkBot?.(bot)
|
|
1313
|
+
bot.start?.()
|
|
1314
|
+
console.log(`[lark] bot started; chatId=${lark.chatId || '(unset)'}`)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
async function stopLarkStack() {
|
|
1318
|
+
const bot = larkBotHolder.current
|
|
1319
|
+
larkBotHolder.current = null
|
|
1320
|
+
try { openclawBridge.setLarkBot?.(null) } catch { /* ignore */ }
|
|
1321
|
+
if (!bot) return
|
|
1322
|
+
try { await bot.stop?.() } catch (e) { console.warn(`[lark] stop failed: ${e.message}`) }
|
|
1323
|
+
console.log('[lark] bot stopped')
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
async function restartLarkStack() {
|
|
1327
|
+
await stopLarkStack()
|
|
1328
|
+
startLarkStack()
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// PTY 事件:永远走 holder.current,所以重启 bot 后还能跑
|
|
1332
|
+
pty.on('native-session', ({ sessionId }) => {
|
|
1333
|
+
loadingTrackerHolder.current?.start({ sessionId })
|
|
1334
|
+
.catch((e) => console.warn(`[loading-status] start failed: ${e.message}`))
|
|
1335
|
+
})
|
|
1336
|
+
pty.on('done', ({ sessionId, exitCode, stopped }) => {
|
|
1337
|
+
const finalStatus = stopped ? 'stopped' : (exitCode === 0 ? 'done' : 'failed')
|
|
1338
|
+
loadingTrackerHolder.current?.stop({ sessionId, finalStatus })
|
|
1339
|
+
.catch((e) => console.warn(`[loading-status] stop failed: ${e.message}`))
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
// 兜底:禁用参数失效时若仍然出现 AskUserQuestion 这类 TUI,给 Telegram 推一条提示。
|
|
1343
|
+
// pty.js 自带 30s 去抖,这里只负责把事件落地成消息。
|
|
1344
|
+
pty.on('tui-detected', ({ sessionId }) => {
|
|
1345
|
+
if (!openclawBridge.isEnabled()) return
|
|
1346
|
+
if (!openclawBridge.hasExplicitRoute(sessionId)) return
|
|
1347
|
+
const message = [
|
|
1348
|
+
'⚠️ 检测到 Claude Code 内置交互 TUI(AskUserQuestion 类)',
|
|
1349
|
+
'Telegram 没法发 Tab/↑↓/Enter/Esc,无法正常应答。',
|
|
1350
|
+
'',
|
|
1351
|
+
'回 esc / 退出菜单 → 我帮你按 Esc 退出 modal',
|
|
1352
|
+
'回 中断 / ctrl+c → 我帮你打断当前任务',
|
|
1353
|
+
].join('\n')
|
|
1354
|
+
openclawBridge.postText({ sessionId, message })
|
|
1355
|
+
.catch((e) => console.warn(`[tui-detected] postText failed: ${e.message}`))
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
// holder Proxy: 让 hook / wizard 不用改源码,每次读属性都从 holder.current 拿最新实例
|
|
1359
|
+
function unwrapHolder(holder, kind = 'instance') {
|
|
1360
|
+
return new Proxy({}, {
|
|
1361
|
+
get(_t, prop) {
|
|
1362
|
+
const inst = holder.current
|
|
1363
|
+
if (!inst) {
|
|
1364
|
+
// 当 bot 未启动时,常用方法返回安全的 reject,其他属性返回 undefined
|
|
1365
|
+
const asyncMethods = new Set([
|
|
1366
|
+
'start', 'stop', 'sendMessage', 'sendDocument', 'editMessageText', 'editMessageReplyMarkup',
|
|
1367
|
+
'answerCallbackQuery',
|
|
1368
|
+
'createForumTopic', 'closeForumTopic', 'reopenForumTopic', 'editForumTopic',
|
|
1369
|
+
'setMessageReaction', 'setMyCommands', 'deleteMyCommands', 'getMe',
|
|
1370
|
+
'replyInThread', 'handleEvent', 'handleCardAction',
|
|
1371
|
+
'sendCard', 'replyWithCard', 'clearReactionsForSession', 'noteUserMessage',
|
|
1372
|
+
])
|
|
1373
|
+
if (asyncMethods.has(prop)) {
|
|
1374
|
+
return async () => { throw new Error(`${kind}_not_running`) }
|
|
1375
|
+
}
|
|
1376
|
+
return undefined
|
|
1377
|
+
}
|
|
1378
|
+
const v = inst[prop]
|
|
1379
|
+
if (typeof v === 'function') return v.bind(inst)
|
|
1380
|
+
return v
|
|
1381
|
+
},
|
|
1382
|
+
has(_t, prop) {
|
|
1383
|
+
const inst = holder.current
|
|
1384
|
+
return inst ? (prop in inst) : false
|
|
1385
|
+
},
|
|
1386
|
+
})
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const telegramBotProxy = unwrapHolder(telegramBotHolder, 'telegram_bot')
|
|
1390
|
+
const larkBotProxy = unwrapHolder(larkBotHolder, 'lark_bot')
|
|
1391
|
+
const loadingTrackerProxy = unwrapHolder(loadingTrackerHolder, 'loading_tracker')
|
|
1392
|
+
const reactionTrackerProxy = unwrapHolder(reactionTrackerHolder, 'reaction_tracker')
|
|
1393
|
+
|
|
1394
|
+
// Session Input Dispatcher:所有 "把用户文本投递到一个 Claude Code session" 的路径都走它
|
|
1395
|
+
// 三档语义:queue_or_send / soft_interrupt (`!`) / hard_cancel (`!!` or in-topic `/stop`)
|
|
1396
|
+
//
|
|
1397
|
+
// Echo 策略(spec: reaction-first,第 1 条带文字、其后 silent):
|
|
1398
|
+
// - reactions: 由 lark-bot.handleEvent / telegram-bot reactionTracker 在收到用户消息时
|
|
1399
|
+
// 自动添加(已绑 sessionId),由 Stop / session-end hook 自动清除(已存在路径,不重复)
|
|
1400
|
+
// - 第 1 条排队的文字 reply:由 wizard 的 mapDispatcherResultToWizardReply 直接返回
|
|
1401
|
+
// (走 wizard 同步 reply 路径),不走 dispatcher 回调
|
|
1402
|
+
// - dispatcher 回调专门处理 wizard *不在场* 的事件:
|
|
1403
|
+
// - onStale: 队列卡住超过 5min,主动告知用户
|
|
1404
|
+
// - onSessionEnd: session 已结束,未投递的消息给用户做交代
|
|
1405
|
+
const sessionInputDispatcher = createSessionInputDispatcher({
|
|
1406
|
+
pty,
|
|
1407
|
+
aiTerminal: ait,
|
|
1408
|
+
callbacks: {
|
|
1409
|
+
onQueueFirstEnqueue: async () => undefined,
|
|
1410
|
+
onQueueAdditionalEnqueue: async () => undefined,
|
|
1411
|
+
onFlush: async () => undefined,
|
|
1412
|
+
onHardCancel: async () => undefined,
|
|
1413
|
+
onStale: async ({ sessionId, queueSize }) => {
|
|
1414
|
+
const text = `⚠️ session 有 ${queueSize} 条排队消息超过 5 分钟未投递,看起来卡住了。可发送 \`!!\` 中断后重新发送。`
|
|
1415
|
+
try {
|
|
1416
|
+
await openclawBridge?.postText?.({ sessionId, message: text })
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
console.warn(`[server] dispatcher.onStale postText failed: ${e.message}`)
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
onSessionEnd: async ({ sessionId, undeliveredCount, undeliveredTexts }) => {
|
|
1422
|
+
if (!undeliveredCount) return
|
|
1423
|
+
const preview = undeliveredTexts.slice(0, 3).map((t) => `• ${String(t).slice(0, 80)}`).join('\n')
|
|
1424
|
+
const more = undeliveredCount > 3 ? `\n(还有 ${undeliveredCount - 3} 条未列出)` : ''
|
|
1425
|
+
const text = `⚠️ session 已结束,未投递 ${undeliveredCount} 条消息:\n${preview}${more}`
|
|
1426
|
+
try {
|
|
1427
|
+
await openclawBridge?.postText?.({ sessionId, message: text })
|
|
1428
|
+
} catch (e) {
|
|
1429
|
+
console.warn(`[server] dispatcher.onSessionEnd postText failed: ${e.message}`)
|
|
1430
|
+
}
|
|
1431
|
+
},
|
|
1432
|
+
},
|
|
1433
|
+
logger: console,
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
const openclawHookHandler = createOpenClawHookHandler({
|
|
1437
|
+
db,
|
|
1438
|
+
openclaw: openclawBridge,
|
|
1439
|
+
aiTerminal: ait,
|
|
1440
|
+
sidecar: codexSidecar, // Codex jsonl 分支反查 nativeId → quadtodoSessionId
|
|
1441
|
+
pty,
|
|
1442
|
+
telegramBot: telegramBotProxy,
|
|
1443
|
+
larkBot: larkBotProxy, // Stop hook → 清掉 lark "在思考" reaction
|
|
1444
|
+
loadingTracker: loadingTrackerProxy, // Stop hook → 标题切 ✅/❌/⏹(终态)
|
|
1445
|
+
reactionTracker: reactionTrackerProxy, // Stop hook → 清 telegram "✍" reaction
|
|
1446
|
+
sessionInputDispatcher, // Stop / session-end → 触发 dispatcher flush / cleanup
|
|
1447
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1448
|
+
});
|
|
1449
|
+
app.use("/api/openclaw/hook", createOpenClawHookRouter({ hookHandler: openclawHookHandler }));
|
|
1450
|
+
|
|
1451
|
+
// OpenClaw wizard 状态机:peer 维度的多轮向导,OpenClaw 是消息转发器
|
|
1452
|
+
const openclawWizard = createOpenClawWizard({
|
|
1453
|
+
db,
|
|
1454
|
+
aiTerminal: ait,
|
|
1455
|
+
openclaw: openclawBridge,
|
|
1456
|
+
pending: pendingCoord,
|
|
1457
|
+
pty,
|
|
1458
|
+
telegramBot: telegramBotProxy,
|
|
1459
|
+
larkBot: larkBotProxy,
|
|
1460
|
+
loadingTracker: loadingTrackerProxy, // wizard stdin proxy → 标题切回 🔄
|
|
1461
|
+
sessionInputDispatcher, // wizard stdin proxy → 走 dispatcher 三档语义
|
|
1462
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1463
|
+
});
|
|
1464
|
+
openclawWizardLazyRef.handleInbound = (...args) => openclawWizard.handleInbound(...args);
|
|
1465
|
+
openclawWizardLazyRef.handleCallback = (...args) => openclawWizard.handleCallback(...args);
|
|
1466
|
+
openclawWizardLazyRef.handleTopicEvent = (...args) => openclawWizard.handleTopicEvent(...args);
|
|
1467
|
+
app.use("/api/openclaw/inbound", createOpenClawInboundRouter({ wizard: openclawWizard }));
|
|
1468
|
+
|
|
1469
|
+
app.use("/api/config/telegram", createTelegramConfigRouter({
|
|
1470
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1471
|
+
getTelegramBot: () => telegramBotHolder.current,
|
|
1472
|
+
probeRegistry,
|
|
1473
|
+
}))
|
|
1474
|
+
|
|
1475
|
+
// 首次启动 bot stacks(按当前 config)
|
|
1476
|
+
startTelegramStack()
|
|
1477
|
+
startLarkStack()
|
|
1478
|
+
|
|
1479
|
+
// 懒检测:bridge 推送时 topic 已被删 / thread 失效 → 走关闭流程(mark done + 杀 PTY)
|
|
1480
|
+
openclawBridge.setTopicGoneHandler?.(({ chatId, threadId }) => {
|
|
1481
|
+
openclawWizard.handleTopicEvent({ type: 'closed', chatId, threadId })
|
|
1482
|
+
.catch((e) => console.warn(`[server] topic_gone handler failed: ${e.message}`))
|
|
1483
|
+
})
|
|
1484
|
+
|
|
1485
|
+
// ─── Telegram / Lark 自动 topic 镜像(B 方案)─────────────────
|
|
1486
|
+
// 默认开;config.{telegram,lark}.autoCreateTopic = false 可关。这里必须读实时配置,
|
|
1487
|
+
// 因为设置页会热启用 Telegram/Lark,不应要求重启 AgentQuad 才生效。
|
|
1488
|
+
aiSessionHooks.onSessionSpawned = ({ sessionId, todoId }) => {
|
|
1489
|
+
const cfg = loadConfig({ rootDir: configRootDir })
|
|
1490
|
+
const telegramConfig = cfg.telegram || {}
|
|
1491
|
+
const larkConfig = cfg.lark || {}
|
|
1492
|
+
const tgEnabled = telegramConfig.enabled && telegramConfig.autoCreateTopic !== false
|
|
1493
|
+
const larkEnabled = larkConfig.enabled && larkConfig.autoCreateTopic !== false
|
|
1494
|
+
const tasks = []
|
|
1495
|
+
if (tgEnabled) {
|
|
1496
|
+
tasks.push(openclawWizard.ensureTopicForSession({ sessionId, todoId })
|
|
1497
|
+
.catch((e) => console.warn(`[server] ensureTopicForSession failed: ${e.message}`)))
|
|
1498
|
+
}
|
|
1499
|
+
if (larkEnabled) {
|
|
1500
|
+
tasks.push(openclawWizard.ensureLarkThreadForSession({ sessionId, todoId })
|
|
1501
|
+
.catch((e) => console.warn(`[server] ensureLarkThreadForSession failed: ${e.message}`)))
|
|
1502
|
+
}
|
|
1503
|
+
return tasks.length ? Promise.all(tasks) : null
|
|
1504
|
+
}
|
|
1505
|
+
aiSessionHooks.onSessionEnded = ({ sessionId, exitCode, startedAt, completedAt }) => {
|
|
1506
|
+
// 安全门槛:只对干净退出 (exitCode=0) 且寿命 ≥ 30s 的走自动关 topic。
|
|
1507
|
+
// 早夭 / 非零退出多半是 recovery 抽风、jsonl 失效、网络断 —— 不该改 todo 状态。
|
|
1508
|
+
const lifetimeMs = (completedAt || Date.now()) - (startedAt || 0)
|
|
1509
|
+
const cleanExit = exitCode === 0 && lifetimeMs >= 30_000
|
|
1510
|
+
if (!cleanExit) {
|
|
1511
|
+
console.log(`[server] skip auto-close: sid=${sessionId} exit=${exitCode} lifetime=${lifetimeMs}ms`)
|
|
1512
|
+
return null
|
|
1513
|
+
}
|
|
1514
|
+
const route = openclawBridge.resolveRoute?.(sessionId)
|
|
1515
|
+
if (!route?.threadId) return null
|
|
1516
|
+
return openclawWizard.handleTopicEvent({
|
|
1517
|
+
type: 'closed',
|
|
1518
|
+
chatId: route.targetUserId,
|
|
1519
|
+
threadId: route.threadId,
|
|
1520
|
+
}).catch((e) => console.warn(`[server] auto-close topic failed: ${e.message}`))
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// 启动期 sweep:恢复后的 running PTY session 若没绑 topic(手动 web/CLI 起的)→ 补建
|
|
1524
|
+
{
|
|
1525
|
+
const cfg = loadConfig({ rootDir: configRootDir })
|
|
1526
|
+
const sweepTg = cfg.telegram || {}
|
|
1527
|
+
const sweepLark = cfg.lark || {}
|
|
1528
|
+
const tgSweep = sweepTg.enabled && sweepTg.autoCreateTopic !== false
|
|
1529
|
+
const larkSweep = sweepLark.enabled && sweepLark.autoCreateTopic !== false
|
|
1530
|
+
if (tgSweep || larkSweep) {
|
|
1531
|
+
let sweptTg = 0
|
|
1532
|
+
let sweptLark = 0
|
|
1533
|
+
for (const [sid, sess] of ait.sessions) {
|
|
1534
|
+
if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
|
|
1535
|
+
const r = openclawBridge.resolveRoute?.(sid)
|
|
1536
|
+
if (tgSweep && !r?.threadId) {
|
|
1537
|
+
openclawWizard.ensureTopicForSession({ sessionId: sid, todoId: sess.todoId })
|
|
1538
|
+
.then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → telegram thread ${res.threadId}`))
|
|
1539
|
+
.catch((e) => console.warn(`[server] sweep ensureTopic failed for ${sid}: ${e.message}`))
|
|
1540
|
+
sweptTg++
|
|
1541
|
+
}
|
|
1542
|
+
if (larkSweep && !(r?.channel === 'lark' && r?.rootMessageId)) {
|
|
1543
|
+
openclawWizard.ensureLarkThreadForSession({ sessionId: sid, todoId: sess.todoId })
|
|
1544
|
+
.then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → lark root ${res.rootMessageId}`))
|
|
1545
|
+
.catch((e) => console.warn(`[server] sweep ensureLarkThread failed for ${sid}: ${e.message}`))
|
|
1546
|
+
sweptLark++
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (sweptTg > 0) console.log(`[server] sweep: queued ${sweptTg} session(s) for telegram auto-bind`)
|
|
1550
|
+
if (sweptLark > 0) console.log(`[server] sweep: queued ${sweptLark} session(s) for lark auto-bind`)
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// ─── 重启后路由 rehydration ───────────────────────────────────
|
|
1555
|
+
// 复活的 PTY session 用 NEW sessionId,但 DB 里 aiSessions[i].telegramRoute
|
|
1556
|
+
// 保留了 (chatId, threadId, topicName)。把它们重新注入 openclaw-bridge,让
|
|
1557
|
+
// 重启后旧 topic 的对话能继续路由到正确 PTY。
|
|
1558
|
+
{
|
|
1559
|
+
let rehydrated = 0
|
|
1560
|
+
for (const [sid, sess] of ait.sessions) {
|
|
1561
|
+
try {
|
|
1562
|
+
const todo = db.getTodo(sess.todoId)
|
|
1563
|
+
if (!todo) continue
|
|
1564
|
+
const aiSess = (todo.aiSessions || []).find((s) => s.sessionId === sid)
|
|
1565
|
+
if (isCompleteTelegramRoute(aiSess?.telegramRoute)) {
|
|
1566
|
+
openclawBridge.registerSessionRoute(sid, aiSess.telegramRoute)
|
|
1567
|
+
rehydrated++
|
|
1568
|
+
}
|
|
1569
|
+
if (aiSess?.larkRoute) {
|
|
1570
|
+
openclawBridge.registerSessionRoute(sid, aiSess.larkRoute)
|
|
1571
|
+
rehydrated++
|
|
1572
|
+
}
|
|
1573
|
+
} catch (e) {
|
|
1574
|
+
console.warn(`[server] rehydrate route failed for ${sid}: ${e.message}`)
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (rehydrated > 0) console.log(`[server] rehydrated ${rehydrated} session route(s)`)
|
|
1578
|
+
|
|
1579
|
+
// rehydration 之后注册 tracker(仅记录 in-memory state,不调 telegram API):
|
|
1580
|
+
// 这样后续 PTY done 事件能改成终态 ✅/❌/⏹。boot 时不改 🔄(topic 上一轮可能已经是终态)。
|
|
1581
|
+
// holder.current 可能为 null(telegram disabled 或 token 缺失) → 跳过 kick。
|
|
1582
|
+
const tracker = loadingTrackerHolder.current
|
|
1583
|
+
if (tracker) {
|
|
1584
|
+
let kicked = 0
|
|
1585
|
+
for (const [sid, sess] of ait.sessions) {
|
|
1586
|
+
if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
|
|
1587
|
+
const r = openclawBridge.resolveRoute?.(sid)
|
|
1588
|
+
if (!r?.threadId) continue
|
|
1589
|
+
if (tracker.has(sid)) continue
|
|
1590
|
+
tracker.start({ sessionId: sid, skipTitleRename: true })
|
|
1591
|
+
.catch((e) => console.warn(`[loading-status] rehydrate-kick failed sid=${sid}: ${e.message}`))
|
|
1592
|
+
kicked++
|
|
1593
|
+
}
|
|
1594
|
+
if (kicked > 0) console.log(`[server] loading-status: registered ${kicked} resumed session(s) (skip-rename)`)
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// 同步对账路由:覆盖 telegram + lark 两条 channel;老路径 /api/telegram-sync 保留兼容,
|
|
1599
|
+
// 新路径 /api/sync 是推荐入口。两个 mount 共享同一个 router 实例。
|
|
1600
|
+
const syncRouter = createTelegramSyncRouter({
|
|
1601
|
+
db, aiTerminal: ait, openclaw: openclawBridge, wizard: openclawWizard,
|
|
1602
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1603
|
+
}).router;
|
|
1604
|
+
app.use("/api/telegram-sync", syncRouter);
|
|
1605
|
+
app.use("/api/sync", syncRouter);
|
|
1606
|
+
|
|
1607
|
+
// MCP Streamable HTTP 端点:把 AgentQuad 暴露给 Claude Code 等 MCP 客户端
|
|
1608
|
+
try {
|
|
1609
|
+
const mcp = createMcpRouter({
|
|
1610
|
+
db,
|
|
1611
|
+
searchService,
|
|
1612
|
+
wikiDir: wikiConfig.wikiDir,
|
|
1613
|
+
rootDir: configRootDir,
|
|
1614
|
+
logDir,
|
|
1615
|
+
getVersion: loadVersion,
|
|
1616
|
+
aiTerminal: ait,
|
|
1617
|
+
openclaw: openclawBridge,
|
|
1618
|
+
pending: pendingCoord,
|
|
1619
|
+
getConfig: () => loadConfig({ rootDir: configRootDir }),
|
|
1620
|
+
});
|
|
1621
|
+
app.use("/mcp", mcp.router);
|
|
1622
|
+
} catch (e) {
|
|
1623
|
+
console.warn("[mcp] init failed:", e.message);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// kick off wiki init in background (non-blocking)
|
|
1627
|
+
Promise.resolve()
|
|
1628
|
+
.then(() => wikiService.init())
|
|
1629
|
+
.then((r) => console.log(`[wiki] init state=${r.state} dir=${r.wikiDir}`))
|
|
1630
|
+
.catch((e) => console.warn(`[wiki] init failed:`, e.message));
|
|
1631
|
+
|
|
1632
|
+
// sweep orphan wiki_runs left over from prior crashes
|
|
1633
|
+
try {
|
|
1634
|
+
const swept = wikiService.markOrphansAsFailed();
|
|
1635
|
+
if (swept > 0) console.log(`[wiki] marked ${swept} orphan run(s) as failed`);
|
|
1636
|
+
} catch (e) {
|
|
1637
|
+
console.warn(`[wiki] orphan sweep failed:`, e.message);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// async startup scan (non-blocking)
|
|
1641
|
+
Promise.resolve().then(() => transcriptsService.scanFull())
|
|
1642
|
+
.then(r => console.log(`[transcripts] full scan done newFiles=${r.newFiles} indexed=${r.indexed} autoBound=${r.autoBound} unbound=${r.unbound}`))
|
|
1643
|
+
.catch(e => {
|
|
1644
|
+
if (!/database connection is not open/i.test(e?.message || '')) {
|
|
1645
|
+
console.warn(`[transcripts] full scan failed:`, e.message);
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
// ─── static frontend ───
|
|
1650
|
+
if (webDist && existsSync(webDist)) {
|
|
1651
|
+
app.use(express.static(webDist));
|
|
1652
|
+
// SPA fallback: non-API GET falls through to index.html
|
|
1653
|
+
app.get(/^\/(?!api|ws).*/, (_req, res) => {
|
|
1654
|
+
res.sendFile(join(webDist, "index.html"));
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const httpServer = createHttpServer(app);
|
|
1659
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1660
|
+
|
|
1661
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
1662
|
+
const url = new URL(req.url || "", "http://127.0.0.1");
|
|
1663
|
+
if (url.pathname.startsWith("/ws/terminal/")) {
|
|
1664
|
+
const sessionId = url.pathname.replace("/ws/terminal/", "");
|
|
1665
|
+
wss.handleUpgrade(req, socket, head, (ws) =>
|
|
1666
|
+
handleBrowserWs(ws, sessionId),
|
|
1667
|
+
);
|
|
1668
|
+
} else {
|
|
1669
|
+
socket.destroy();
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
const HEARTBEAT_MS = 15_000;
|
|
1674
|
+
|
|
1675
|
+
function handleBrowserWs(ws, sessionId) {
|
|
1676
|
+
ait.addBrowser(sessionId, ws);
|
|
1677
|
+
|
|
1678
|
+
ws.on("message", (raw) => {
|
|
1679
|
+
try {
|
|
1680
|
+
const msg = JSON.parse(raw.toString());
|
|
1681
|
+
if (msg.type === "ping") {
|
|
1682
|
+
if (ws.readyState === ws.OPEN)
|
|
1683
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (msg.type === "pong") return;
|
|
1687
|
+
ait.handleBrowserMessage(sessionId, msg, ws);
|
|
1688
|
+
} catch {
|
|
1689
|
+
/* ignore */
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
const pingTimer = setInterval(() => {
|
|
1694
|
+
if (ws.readyState === ws.OPEN) {
|
|
1695
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
1696
|
+
} else {
|
|
1697
|
+
clearInterval(pingTimer);
|
|
1698
|
+
}
|
|
1699
|
+
}, HEARTBEAT_MS);
|
|
1700
|
+
|
|
1701
|
+
ws.on("close", () => {
|
|
1702
|
+
clearInterval(pingTimer);
|
|
1703
|
+
ait.removeBrowser(sessionId, ws);
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function listen(port, host = "127.0.0.1") {
|
|
1708
|
+
return listenWithRetry(httpServer, port, host, { maxAttempts: 2 })
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
async function close() {
|
|
1712
|
+
try { pendingCoord.stop() } catch { /* ignore */ }
|
|
1713
|
+
try { telegramBotHolder.current?.stop?.() } catch { /* ignore */ }
|
|
1714
|
+
await stopLarkStack()
|
|
1715
|
+
ait.close();
|
|
1716
|
+
return new Promise((resolve) => {
|
|
1717
|
+
wss.close(() => {
|
|
1718
|
+
httpServer.close(() => {
|
|
1719
|
+
try {
|
|
1720
|
+
db.close();
|
|
1721
|
+
} catch {
|
|
1722
|
+
/* ignore */
|
|
1723
|
+
}
|
|
1724
|
+
resolve();
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* 启动后给 telegram 推一条"重启完成 + Resume 了哪些 session"的通知,
|
|
1732
|
+
* 解决用户痛点:重启后之前的 PTY 全死了换新 sid,但用户不知道,
|
|
1733
|
+
* 等下次 stdin proxy 走到 ambiguous 提示才发现"咦我之前的会话呢?"。
|
|
1734
|
+
*
|
|
1735
|
+
* 触发条件(缺一不可):
|
|
1736
|
+
* - telegram bridge 已启用(token + enabled)
|
|
1737
|
+
* - 至少 resume 了 1 个 session(0 个不打扰,避免空通知)
|
|
1738
|
+
* - config.telegram.startupNotice !== false(默认开,有需要可关)
|
|
1739
|
+
*
|
|
1740
|
+
* 推送目标:postText 缺省 target → openclaw.targetUserId(supergroup 的话即 General)
|
|
1741
|
+
*/
|
|
1742
|
+
async function notifyStartupRecovery() {
|
|
1743
|
+
if (!openclawBridge.isEnabled()) return
|
|
1744
|
+
const cfg = loadConfig({ rootDir: configRootDir })
|
|
1745
|
+
if (cfg?.telegram?.startupNotice === false) return
|
|
1746
|
+
|
|
1747
|
+
const active = []
|
|
1748
|
+
for (const [sid, sess] of ait.sessions) {
|
|
1749
|
+
if (sess?.status === 'running' || sess?.status === 'idle' || sess?.status === 'pending_confirm') {
|
|
1750
|
+
active.push({ sid, lastOutputAt: sess.lastOutputAt || sess.startedAt || 0 })
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
if (active.length === 0) return
|
|
1754
|
+
|
|
1755
|
+
// 反查 todo title
|
|
1756
|
+
let todos = []
|
|
1757
|
+
try { todos = db.listTodos({ status: 'todo' }) || [] } catch { todos = [] }
|
|
1758
|
+
const sidToTodo = new Map()
|
|
1759
|
+
for (const t of todos) {
|
|
1760
|
+
const sessions = t.aiSessions || (t.aiSession ? [t.aiSession] : [])
|
|
1761
|
+
for (const s of sessions) {
|
|
1762
|
+
if (s?.sessionId) sidToTodo.set(s.sessionId, t)
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
active.sort((a, b) => b.lastOutputAt - a.lastOutputAt)
|
|
1767
|
+
const lines = active.slice(0, 10).map((a) => {
|
|
1768
|
+
const t = sidToTodo.get(a.sid)
|
|
1769
|
+
const rawTitle = t?.title || '(未命名)'
|
|
1770
|
+
const title = rawTitle.length > 32 ? rawTitle.slice(0, 32) + '…' : rawTitle
|
|
1771
|
+
return `• #${a.sid.slice(-4)} · ${title}`
|
|
1772
|
+
})
|
|
1773
|
+
const more = active.length > 10 ? `\n…还有 ${active.length - 10} 个` : ''
|
|
1774
|
+
const message = [
|
|
1775
|
+
'🔄 AgentQuad 重启完成(之前的 PTY 都被换了新身体)',
|
|
1776
|
+
`Resume 了 ${active.length} 个会话:`,
|
|
1777
|
+
...lines,
|
|
1778
|
+
].join('\n') + more + '\n\n可用 /list 看详情,或直接发消息(多 session 时会让你点按钮选)。'
|
|
1779
|
+
|
|
1780
|
+
try {
|
|
1781
|
+
const r = await openclawBridge.postText({ message })
|
|
1782
|
+
if (!r?.ok) {
|
|
1783
|
+
console.warn(`[server] startup notice not delivered: ${r?.reason || 'unknown'}`)
|
|
1784
|
+
}
|
|
1785
|
+
} catch (e) {
|
|
1786
|
+
console.warn(`[server] startup notice failed: ${e?.message}`)
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
return { app, httpServer, wss, db, pty, ait, listen, close, openclawBridge, pendingCoord, notifyStartupRecovery };
|
|
1791
|
+
}
|