agentquad 0.3.1 → 0.4.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/README.md +159 -220
- package/README.zh-CN.md +257 -0
- package/dist-web/assets/{index-j5Dz0G5-.js → index-oovKASxm.js} +196 -196
- package/dist-web/index.html +1 -1
- package/package.json +1 -1
- package/src/cli.js +187 -41
- package/src/codex-hook-installer.js +361 -0
- package/src/config.js +5 -3
- package/src/cursor-hook-installer.js +296 -0
- package/src/dispatch.js +0 -1
- package/src/first-run-wizard.js +2 -13
- package/src/openclaw-wizard.js +1 -1
- package/src/server.js +0 -3
- package/src/templates/codex-hooks/notify.js +103 -0
- package/src/templates/cursor-hooks/notify.js +103 -0
package/src/config.js
CHANGED
|
@@ -311,7 +311,6 @@ function defaultConfig() {
|
|
|
311
311
|
// 要让同网段其他设备(含 Tailscale 虚拟网段 100.x.x.x)访问,可设为 "0.0.0.0"。
|
|
312
312
|
// CLI 上也可以用 `agentquad start --expose` / `--host 0.0.0.0` 临时覆盖。
|
|
313
313
|
host: "127.0.0.1",
|
|
314
|
-
defaultTool: "claude",
|
|
315
314
|
defaultCwd: homedir(),
|
|
316
315
|
defaultPermissionMode: "default",
|
|
317
316
|
tools: resolveToolsConfig(),
|
|
@@ -341,7 +340,7 @@ function defaultConfig() {
|
|
|
341
340
|
}
|
|
342
341
|
|
|
343
342
|
function normalizeDispatch(d = {}) {
|
|
344
|
-
const channels = ['lark', 'telegram'
|
|
343
|
+
const channels = ['lark', 'telegram'];
|
|
345
344
|
const out = {};
|
|
346
345
|
for (const ch of channels) {
|
|
347
346
|
const src = (d && typeof d[ch] === 'object' && d[ch] !== null) ? d[ch] : {};
|
|
@@ -352,6 +351,9 @@ function normalizeDispatch(d = {}) {
|
|
|
352
351
|
|
|
353
352
|
export function normalizeConfig(cfg = {}) {
|
|
354
353
|
const defaults = defaultConfig();
|
|
354
|
+
// 旧 config.json 里残留的 defaultTool 字段(已废弃)。剥离后再 spread,
|
|
355
|
+
// 避免 ...cfg 把死字段又拷回 normalized config。
|
|
356
|
+
const { defaultTool: _ignoredDefaultTool, ...cfgRest } = cfg;
|
|
355
357
|
const mergedTools = {
|
|
356
358
|
...defaults.tools,
|
|
357
359
|
...(cfg.tools || {}),
|
|
@@ -366,7 +368,7 @@ export function normalizeConfig(cfg = {}) {
|
|
|
366
368
|
}
|
|
367
369
|
return {
|
|
368
370
|
...defaults,
|
|
369
|
-
...
|
|
371
|
+
...cfgRest,
|
|
370
372
|
defaultPermissionMode: normalizePermissionMode(cfg.defaultPermissionMode, "default"),
|
|
371
373
|
tools: {
|
|
372
374
|
...mergedTools,
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Agent CLI hooks 安装器:
|
|
3
|
+
* - 把 hook entry 合并写入 `~/.cursor/hooks.json`,不破坏用户现有 hook
|
|
4
|
+
* - 写 `"version": 1` 协议头(Cursor 1.7+ 要求)
|
|
5
|
+
*
|
|
6
|
+
* Cursor 事件:stop(turn end)/ beforeSubmitPrompt(等用户)/ sessionEnd
|
|
7
|
+
*
|
|
8
|
+
* 合并策略:
|
|
9
|
+
* - 已有 hooks.<event> 数组 → append;不删除已有 entry
|
|
10
|
+
* - AgentQuad 加的 entry 用 `_agentquadManaged: true` 标记
|
|
11
|
+
* - hooks.json 不存在 → 创建(带 version:1)
|
|
12
|
+
* - hooks.json 损坏 → warn-skip
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { DEFAULT_ROOT_DIR } from './config.js'
|
|
19
|
+
|
|
20
|
+
const MANAGED_KEY = '_agentquadManaged'
|
|
21
|
+
const HOOK_EVENTS = ['stop', 'beforeSubmitPrompt', 'sessionEnd']
|
|
22
|
+
const HOOK_VERSION_RE = /quadtodo-hook-version:\s*(\d+)/
|
|
23
|
+
const SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
function defaultHookScriptPath() {
|
|
26
|
+
return join(DEFAULT_ROOT_DIR, 'cursor-hooks', 'notify.js')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultHooksJsonPath() {
|
|
30
|
+
return join(homedir(), '.cursor', 'hooks.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function defaultTemplatePath() {
|
|
34
|
+
return fileURLToPath(new URL('./templates/cursor-hooks/notify.js', import.meta.url))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultUninstallMarkerPath() {
|
|
38
|
+
return join(DEFAULT_ROOT_DIR, 'cursor-hooks', '.uninstalled')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseHookVersion(content) {
|
|
42
|
+
if (!content) return null
|
|
43
|
+
const m = content.match(HOOK_VERSION_RE)
|
|
44
|
+
return m ? Number(m[1]) : 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function eventToArg(event) {
|
|
48
|
+
if (event === 'beforeSubmitPrompt') return 'notification'
|
|
49
|
+
if (event === 'sessionEnd') return 'session-end'
|
|
50
|
+
return 'stop'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildHookEntry(event, hookScriptPath) {
|
|
54
|
+
// Cursor 的 hook entry 是扁平的 object(不像 Claude 的 matcher+hooks 嵌套)
|
|
55
|
+
return {
|
|
56
|
+
type: 'command',
|
|
57
|
+
command: `node ${hookScriptPath} ${eventToArg(event)}`,
|
|
58
|
+
timeout: 30,
|
|
59
|
+
[MANAGED_KEY]: true,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadHooksJson(path) {
|
|
64
|
+
if (!existsSync(path)) return { version: SCHEMA_VERSION }
|
|
65
|
+
const raw = readFileSync(path, 'utf8')
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const err = new Error(`cursor hooks.json malformed: ${e.message}`)
|
|
70
|
+
err.code = 'malformed_hooks_json'
|
|
71
|
+
err.path = path
|
|
72
|
+
throw err
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveHooksJson(path, data) {
|
|
77
|
+
const dir = dirname(path)
|
|
78
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
79
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function backupFile(path) {
|
|
83
|
+
if (!existsSync(path)) return null
|
|
84
|
+
const bak = `${path}.bak.${Date.now()}`
|
|
85
|
+
copyFileSync(path, bak)
|
|
86
|
+
return bak
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function installHooks({
|
|
90
|
+
hooksPath = defaultHooksJsonPath(),
|
|
91
|
+
hookScriptPath = defaultHookScriptPath(),
|
|
92
|
+
events = HOOK_EVENTS,
|
|
93
|
+
uninstallMarkerPath = defaultUninstallMarkerPath(),
|
|
94
|
+
clearUninstallMarker = true,
|
|
95
|
+
} = {}) {
|
|
96
|
+
if (!existsSync(hookScriptPath)) {
|
|
97
|
+
const err = new Error(`hook script not found: ${hookScriptPath}`)
|
|
98
|
+
err.code = 'hook_script_missing'
|
|
99
|
+
throw err
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = loadHooksJson(hooksPath)
|
|
103
|
+
const backup = backupFile(hooksPath)
|
|
104
|
+
if (!data.version) data.version = SCHEMA_VERSION
|
|
105
|
+
if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {}
|
|
106
|
+
|
|
107
|
+
const added = []
|
|
108
|
+
for (const event of events) {
|
|
109
|
+
if (!Array.isArray(data.hooks[event])) data.hooks[event] = []
|
|
110
|
+
data.hooks[event] = data.hooks[event].filter((entry) => !entry?.[MANAGED_KEY])
|
|
111
|
+
data.hooks[event].push(buildHookEntry(event, hookScriptPath))
|
|
112
|
+
added.push(event)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
saveHooksJson(hooksPath, data)
|
|
116
|
+
let markerCleared = false
|
|
117
|
+
if (clearUninstallMarker && existsSync(uninstallMarkerPath)) {
|
|
118
|
+
try { unlinkSync(uninstallMarkerPath); markerCleared = true } catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
return { hooksPath, backup, added, skipped: [], markerCleared }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function uninstallHooks({
|
|
124
|
+
hooksPath = defaultHooksJsonPath(),
|
|
125
|
+
uninstallMarkerPath = defaultUninstallMarkerPath(),
|
|
126
|
+
writeUninstallMarker = true,
|
|
127
|
+
} = {}) {
|
|
128
|
+
let markerWritten = false
|
|
129
|
+
const writeMarker = () => {
|
|
130
|
+
if (!writeUninstallMarker) return
|
|
131
|
+
try {
|
|
132
|
+
const dir = dirname(uninstallMarkerPath)
|
|
133
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
134
|
+
writeFileSync(uninstallMarkerPath, `${new Date().toISOString()}\n`)
|
|
135
|
+
markerWritten = true
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!existsSync(hooksPath)) {
|
|
140
|
+
writeMarker()
|
|
141
|
+
return { hooksPath, removed: [], backup: null, markerWritten }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const data = loadHooksJson(hooksPath)
|
|
145
|
+
const backup = backupFile(hooksPath)
|
|
146
|
+
const removed = []
|
|
147
|
+
|
|
148
|
+
if (data.hooks && typeof data.hooks === 'object') {
|
|
149
|
+
for (const event of Object.keys(data.hooks)) {
|
|
150
|
+
if (!Array.isArray(data.hooks[event])) continue
|
|
151
|
+
const before = data.hooks[event].length
|
|
152
|
+
data.hooks[event] = data.hooks[event].filter((entry) => !entry?.[MANAGED_KEY])
|
|
153
|
+
if (data.hooks[event].length !== before) {
|
|
154
|
+
removed.push({ event, removedCount: before - data.hooks[event].length })
|
|
155
|
+
}
|
|
156
|
+
if (data.hooks[event].length === 0) delete data.hooks[event]
|
|
157
|
+
}
|
|
158
|
+
if (Object.keys(data.hooks).length === 0) delete data.hooks
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
saveHooksJson(hooksPath, data)
|
|
162
|
+
writeMarker()
|
|
163
|
+
return { hooksPath, removed, backup, markerWritten }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function inspectHooks({
|
|
167
|
+
hooksPath = defaultHooksJsonPath(),
|
|
168
|
+
hookScriptPath = defaultHookScriptPath(),
|
|
169
|
+
} = {}) {
|
|
170
|
+
const scriptExists = existsSync(hookScriptPath)
|
|
171
|
+
if (!existsSync(hooksPath)) {
|
|
172
|
+
return { installed: false, eventsInstalled: [], hooksPath, hookScriptPath, scriptExists }
|
|
173
|
+
}
|
|
174
|
+
let data
|
|
175
|
+
try {
|
|
176
|
+
data = loadHooksJson(hooksPath)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return { installed: false, eventsInstalled: [], hooksPath, hookScriptPath, scriptExists, error: e.code }
|
|
179
|
+
}
|
|
180
|
+
const eventsInstalled = []
|
|
181
|
+
for (const event of HOOK_EVENTS) {
|
|
182
|
+
const arr = data?.hooks?.[event]
|
|
183
|
+
if (!Array.isArray(arr)) continue
|
|
184
|
+
if (arr.some((entry) => entry?.[MANAGED_KEY])) eventsInstalled.push(event)
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
installed: eventsInstalled.length === HOOK_EVENTS.length,
|
|
188
|
+
eventsInstalled,
|
|
189
|
+
hooksPath,
|
|
190
|
+
hookScriptPath,
|
|
191
|
+
scriptExists,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function deployHookScript({
|
|
196
|
+
scriptPath = defaultHookScriptPath(),
|
|
197
|
+
templatePath = defaultTemplatePath(),
|
|
198
|
+
} = {}) {
|
|
199
|
+
if (!existsSync(templatePath)) {
|
|
200
|
+
const err = new Error(`hook template not found: ${templatePath}`)
|
|
201
|
+
err.code = 'hook_template_missing'
|
|
202
|
+
throw err
|
|
203
|
+
}
|
|
204
|
+
const templateContent = readFileSync(templatePath, 'utf8')
|
|
205
|
+
const templateVersion = parseHookVersion(templateContent)
|
|
206
|
+
|
|
207
|
+
const dir = dirname(scriptPath)
|
|
208
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
209
|
+
|
|
210
|
+
const previousVersion = existsSync(scriptPath)
|
|
211
|
+
? parseHookVersion(readFileSync(scriptPath, 'utf8'))
|
|
212
|
+
: null
|
|
213
|
+
|
|
214
|
+
if (previousVersion !== null && previousVersion === templateVersion) {
|
|
215
|
+
return { action: 'unchanged', version: templateVersion, previousVersion, scriptPath, backup: null }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let backup = null
|
|
219
|
+
if (previousVersion !== null) {
|
|
220
|
+
backup = `${scriptPath}.bak.${Date.now()}`
|
|
221
|
+
copyFileSync(scriptPath, backup)
|
|
222
|
+
}
|
|
223
|
+
writeFileSync(scriptPath, templateContent)
|
|
224
|
+
return {
|
|
225
|
+
action: previousVersion === null ? 'installed' : 'upgraded',
|
|
226
|
+
version: templateVersion,
|
|
227
|
+
previousVersion,
|
|
228
|
+
scriptPath,
|
|
229
|
+
backup,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function bootstrapCursorHooks({
|
|
234
|
+
hooksPath = defaultHooksJsonPath(),
|
|
235
|
+
scriptPath = defaultHookScriptPath(),
|
|
236
|
+
templatePath = defaultTemplatePath(),
|
|
237
|
+
uninstallMarkerPath = defaultUninstallMarkerPath(),
|
|
238
|
+
respectUninstallMarker = true,
|
|
239
|
+
} = {}) {
|
|
240
|
+
if (respectUninstallMarker && existsSync(uninstallMarkerPath)) {
|
|
241
|
+
return { skipped: true, reason: 'uninstall_marker', uninstallMarkerPath }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let markerCleared = false
|
|
245
|
+
if (!respectUninstallMarker && existsSync(uninstallMarkerPath)) {
|
|
246
|
+
try { unlinkSync(uninstallMarkerPath); markerCleared = true } catch { /* ignore */ }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const scriptResult = deployHookScript({ scriptPath, templatePath })
|
|
250
|
+
|
|
251
|
+
const inspect = inspectHooks({ hooksPath, hookScriptPath: scriptPath })
|
|
252
|
+
if (inspect.error === 'malformed_hooks_json') {
|
|
253
|
+
return {
|
|
254
|
+
skipped: true,
|
|
255
|
+
reason: 'malformed_hooks_json',
|
|
256
|
+
hooksPath,
|
|
257
|
+
scriptResult,
|
|
258
|
+
markerCleared,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (inspect.installed) {
|
|
263
|
+
return {
|
|
264
|
+
skipped: false,
|
|
265
|
+
alreadyInstalled: true,
|
|
266
|
+
scriptResult,
|
|
267
|
+
hookResult: null,
|
|
268
|
+
markerCleared,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const hookResult = installHooks({
|
|
273
|
+
hooksPath,
|
|
274
|
+
hookScriptPath: scriptPath,
|
|
275
|
+
uninstallMarkerPath,
|
|
276
|
+
clearUninstallMarker: false,
|
|
277
|
+
})
|
|
278
|
+
return {
|
|
279
|
+
skipped: false,
|
|
280
|
+
alreadyInstalled: false,
|
|
281
|
+
scriptResult,
|
|
282
|
+
hookResult,
|
|
283
|
+
markerCleared,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const __test__ = {
|
|
288
|
+
buildHookEntry,
|
|
289
|
+
MANAGED_KEY,
|
|
290
|
+
HOOK_EVENTS,
|
|
291
|
+
SCHEMA_VERSION,
|
|
292
|
+
parseHookVersion,
|
|
293
|
+
eventToArg,
|
|
294
|
+
defaultTemplatePath,
|
|
295
|
+
defaultUninstallMarkerPath,
|
|
296
|
+
}
|
package/src/dispatch.js
CHANGED
|
@@ -8,6 +8,5 @@ export function resolveTool({ channel, userId, chatId, override } = {}, config =
|
|
|
8
8
|
if (chatId && ch.perChat && SUPPORTED_TOOLS.includes(ch.perChat[chatId])) return ch.perChat[chatId]
|
|
9
9
|
if (SUPPORTED_TOOLS.includes(ch.default)) return ch.default
|
|
10
10
|
}
|
|
11
|
-
if (SUPPORTED_TOOLS.includes(config?.defaultTool)) return config.defaultTool
|
|
12
11
|
return 'claude'
|
|
13
12
|
}
|
package/src/first-run-wizard.js
CHANGED
|
@@ -52,7 +52,7 @@ export async function runFirstRunWizard({
|
|
|
52
52
|
let skippedInstall = false
|
|
53
53
|
|
|
54
54
|
if (missing.length > 0) {
|
|
55
|
-
log(`[1/
|
|
55
|
+
log(`[1/1] 检测到未安装:${missing.join(', ')}(AI 终端必需)`)
|
|
56
56
|
const ans = (await ask(` 运行 'agentquad install-tools --all' 自动安装?(Y/n) `)).trim().toLowerCase()
|
|
57
57
|
if (ans === '' || ans === 'y' || ans === 'yes') {
|
|
58
58
|
try {
|
|
@@ -67,16 +67,5 @@ export async function runFirstRunWizard({
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
if (claudeOK || installedTools.includes('claude')) available.push('claude')
|
|
72
|
-
if (codexOK || installedTools.includes('codex')) available.push('codex')
|
|
73
|
-
|
|
74
|
-
let defaultTool = 'claude'
|
|
75
|
-
if (available.length > 0) {
|
|
76
|
-
const optsStr = available.join(' / ')
|
|
77
|
-
const ans = (await ask(`[2/2] 选择默认 AI 工具 (${optsStr}) [默认: ${available[0]}]: `)).trim().toLowerCase()
|
|
78
|
-
defaultTool = available.includes(ans) ? ans : available[0]
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return { skipped: false, installedTools, defaultTool, skippedInstall }
|
|
70
|
+
return { skipped: false, installedTools, skippedInstall }
|
|
82
71
|
}
|
package/src/openclaw-wizard.js
CHANGED
|
@@ -341,7 +341,7 @@ function mapDispatcherResultToWizardReply(result, sid, imagePaths) {
|
|
|
341
341
|
* - aiTerminal: spawnSession({sessionId, todoId, prompt, tool, cwd, permissionMode, label, extraEnv})
|
|
342
342
|
* - openclaw: registerSessionRoute(sessionId, {targetUserId, threadId?, topicName?, ...})
|
|
343
343
|
* - pending: submitReply(text), listPending() (不直接被调用,但提供给路由层判断)
|
|
344
|
-
* - getConfig: () => 配置快照(拿 defaultCwd / port /
|
|
344
|
+
* - getConfig: () => 配置快照(拿 defaultCwd / port / dispatch / 渠道权限模式)
|
|
345
345
|
* - telegramBot: 可选,提供 createForumTopic / sendMessage —— 启用每任务一 topic
|
|
346
346
|
*/
|
|
347
347
|
export function createOpenClawWizard({
|
package/src/server.js
CHANGED
|
@@ -549,7 +549,6 @@ export function createServer(opts = {}) {
|
|
|
549
549
|
process.env.HOME ||
|
|
550
550
|
process.cwd(),
|
|
551
551
|
tools: tools || resolveToolsConfig(initialConfig?.tools),
|
|
552
|
-
defaultTool: initialConfig?.defaultTool || "claude",
|
|
553
552
|
};
|
|
554
553
|
// Codex sidecar:把 AgentQuad session ↔ codex native id 的映射落到 ~/.agentquad/codex-sessions/,
|
|
555
554
|
// 重启后 restoreFromDisk() 复活内存映射。Phase A 只暂存元数据;Phase C 起 IM 推送链路会用它
|
|
@@ -724,7 +723,6 @@ export function createServer(opts = {}) {
|
|
|
724
723
|
saveConfig(next, { rootDir: configRootDir });
|
|
725
724
|
|
|
726
725
|
runtimeConfig.defaultCwd = next.defaultCwd || runtimeConfig.defaultCwd;
|
|
727
|
-
runtimeConfig.defaultTool = next.defaultTool || runtimeConfig.defaultTool;
|
|
728
726
|
runtimeConfig.tools = resolveToolsConfig(next.tools);
|
|
729
727
|
pty.tools = runtimeConfig.tools;
|
|
730
728
|
|
|
@@ -768,7 +766,6 @@ export function createServer(opts = {}) {
|
|
|
768
766
|
toolDiagnostics: inspectToolsConfig(next.tools),
|
|
769
767
|
runtimeApplied: {
|
|
770
768
|
defaultCwd: runtimeConfig.defaultCwd,
|
|
771
|
-
defaultTool: runtimeConfig.defaultTool,
|
|
772
769
|
larkRestart,
|
|
773
770
|
},
|
|
774
771
|
telegramRestart,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// quadtodo-hook-version: 2
|
|
3
|
+
/**
|
|
4
|
+
* AgentQuad Claude Code hook —— 把 PTY 内 Claude Code 的状态事件转推到微信。
|
|
5
|
+
*
|
|
6
|
+
* 调用约定:
|
|
7
|
+
* - argv[2] = 事件名: stop | notification | session-end
|
|
8
|
+
* - stdin = Claude Code 注入的 hook payload(JSON 文本,可空)
|
|
9
|
+
* - env: QUADTODO_SESSION_ID (空 = 非 AgentQuad 启动的 Claude Code,立刻 exit 0)
|
|
10
|
+
* QUADTODO_TARGET_USER (微信 peer id)
|
|
11
|
+
* QUADTODO_TODO_ID
|
|
12
|
+
* QUADTODO_TODO_TITLE
|
|
13
|
+
*
|
|
14
|
+
* 故障策略:失败一律静默。这个脚本绝不能阻塞 Claude Code。
|
|
15
|
+
* - 没注 env → 仍然记日志("no env"),exit 0
|
|
16
|
+
* - AgentQuad 没起 / 网络失败 → catch 后记日志,exit 0
|
|
17
|
+
* - JSON 解析失败 → 当作空 payload 继续
|
|
18
|
+
*
|
|
19
|
+
* Debug log: 写到 ~/.agentquad/claude-hooks/hook.log,记每次 fire。
|
|
20
|
+
* 这样能 100% 区分"hook 没 fire" vs "fire 了但 AgentQuad 没收到"。
|
|
21
|
+
*
|
|
22
|
+
* 这个文件是模板源;安装器会拷贝到 ~/.agentquad/claude-hooks/notify.js。
|
|
23
|
+
* 顶部 `quadtodo-hook-version` 行用于版本比对,升级 AgentQuad 时会自动覆盖旧脚本(带备份)。
|
|
24
|
+
* 注意:脚本独立运行(不能 import config.js)。LOG_PATH 用 import.meta.url 派生,
|
|
25
|
+
* 跟随脚本所在目录,自动适配 ~/.agentquad / ~/.quadtodo(legacy)。
|
|
26
|
+
*/
|
|
27
|
+
import { appendFileSync } from 'node:fs'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
|
|
31
|
+
const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), 'hook.log')
|
|
32
|
+
|
|
33
|
+
function logLine(obj) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n', 'utf8')
|
|
36
|
+
} catch { /* ignore — log 失败也不能阻塞 */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const event = (process.argv[2] || 'unknown').toLowerCase()
|
|
40
|
+
const SESSION_ID = process.env.QUADTODO_SESSION_ID
|
|
41
|
+
if (!SESSION_ID) {
|
|
42
|
+
logLine({ event, status: 'skipped_no_env', argv: process.argv.slice(2) })
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const QUADTODO_URL = process.env.QUADTODO_URL || 'http://127.0.0.1:5677'
|
|
47
|
+
const ENDPOINT = `${QUADTODO_URL}/api/openclaw/hook`
|
|
48
|
+
logLine({ event, status: 'fired', sessionId: SESSION_ID, todoTitle: process.env.QUADTODO_TODO_TITLE })
|
|
49
|
+
|
|
50
|
+
let raw = ''
|
|
51
|
+
process.stdin.setEncoding('utf8')
|
|
52
|
+
process.stdin.on('data', (chunk) => {
|
|
53
|
+
raw += chunk
|
|
54
|
+
// 防止超大 payload 把这个进程占内存
|
|
55
|
+
if (raw.length > 64 * 1024) raw = raw.slice(0, 64 * 1024)
|
|
56
|
+
})
|
|
57
|
+
process.stdin.on('end', send)
|
|
58
|
+
// 没有 stdin 也要发(例如 SessionEnd 可能不带 payload)
|
|
59
|
+
setTimeout(() => { if (!sent) send() }, 1500).unref?.()
|
|
60
|
+
|
|
61
|
+
let sent = false
|
|
62
|
+
async function send() {
|
|
63
|
+
if (sent) return
|
|
64
|
+
sent = true
|
|
65
|
+
|
|
66
|
+
let hookPayload = null
|
|
67
|
+
if (raw.trim()) {
|
|
68
|
+
try { hookPayload = JSON.parse(raw) } catch { hookPayload = { _raw: raw.slice(0, 240) } }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
event,
|
|
73
|
+
sessionId: SESSION_ID,
|
|
74
|
+
targetUserId: process.env.QUADTODO_TARGET_USER || null,
|
|
75
|
+
todoId: process.env.QUADTODO_TODO_ID || null,
|
|
76
|
+
todoTitle: process.env.QUADTODO_TODO_TITLE || null,
|
|
77
|
+
hookPayload,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// 30s timeout:openclaw CLI shell-out 实测 4-6s,留足余量;
|
|
82
|
+
// Claude Code 默认等 hook 60s,所以 30s 安全。
|
|
83
|
+
const ctrl = new AbortController()
|
|
84
|
+
const timer = setTimeout(() => ctrl.abort(), 30_000)
|
|
85
|
+
timer.unref?.()
|
|
86
|
+
const res = await fetch(ENDPOINT, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body,
|
|
90
|
+
signal: ctrl.signal,
|
|
91
|
+
})
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
if (res.ok) {
|
|
94
|
+
const data = await res.json().catch(() => null)
|
|
95
|
+
logLine({ event, status: 'sent', sessionId: SESSION_ID, action: data?.action, reason: data?.reason })
|
|
96
|
+
} else {
|
|
97
|
+
const text = await res.text().catch(() => '')
|
|
98
|
+
logLine({ event, status: 'http_error', code: res.status, body: text.slice(0, 200) })
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logLine({ event, status: 'fetch_error', error: e?.message || String(e) })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// quadtodo-hook-version: 2
|
|
3
|
+
/**
|
|
4
|
+
* AgentQuad Claude Code hook —— 把 PTY 内 Claude Code 的状态事件转推到微信。
|
|
5
|
+
*
|
|
6
|
+
* 调用约定:
|
|
7
|
+
* - argv[2] = 事件名: stop | notification | session-end
|
|
8
|
+
* - stdin = Claude Code 注入的 hook payload(JSON 文本,可空)
|
|
9
|
+
* - env: QUADTODO_SESSION_ID (空 = 非 AgentQuad 启动的 Claude Code,立刻 exit 0)
|
|
10
|
+
* QUADTODO_TARGET_USER (微信 peer id)
|
|
11
|
+
* QUADTODO_TODO_ID
|
|
12
|
+
* QUADTODO_TODO_TITLE
|
|
13
|
+
*
|
|
14
|
+
* 故障策略:失败一律静默。这个脚本绝不能阻塞 Claude Code。
|
|
15
|
+
* - 没注 env → 仍然记日志("no env"),exit 0
|
|
16
|
+
* - AgentQuad 没起 / 网络失败 → catch 后记日志,exit 0
|
|
17
|
+
* - JSON 解析失败 → 当作空 payload 继续
|
|
18
|
+
*
|
|
19
|
+
* Debug log: 写到 ~/.agentquad/claude-hooks/hook.log,记每次 fire。
|
|
20
|
+
* 这样能 100% 区分"hook 没 fire" vs "fire 了但 AgentQuad 没收到"。
|
|
21
|
+
*
|
|
22
|
+
* 这个文件是模板源;安装器会拷贝到 ~/.agentquad/claude-hooks/notify.js。
|
|
23
|
+
* 顶部 `quadtodo-hook-version` 行用于版本比对,升级 AgentQuad 时会自动覆盖旧脚本(带备份)。
|
|
24
|
+
* 注意:脚本独立运行(不能 import config.js)。LOG_PATH 用 import.meta.url 派生,
|
|
25
|
+
* 跟随脚本所在目录,自动适配 ~/.agentquad / ~/.quadtodo(legacy)。
|
|
26
|
+
*/
|
|
27
|
+
import { appendFileSync } from 'node:fs'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
|
|
31
|
+
const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), 'hook.log')
|
|
32
|
+
|
|
33
|
+
function logLine(obj) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n', 'utf8')
|
|
36
|
+
} catch { /* ignore — log 失败也不能阻塞 */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const event = (process.argv[2] || 'unknown').toLowerCase()
|
|
40
|
+
const SESSION_ID = process.env.QUADTODO_SESSION_ID
|
|
41
|
+
if (!SESSION_ID) {
|
|
42
|
+
logLine({ event, status: 'skipped_no_env', argv: process.argv.slice(2) })
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const QUADTODO_URL = process.env.QUADTODO_URL || 'http://127.0.0.1:5677'
|
|
47
|
+
const ENDPOINT = `${QUADTODO_URL}/api/openclaw/hook`
|
|
48
|
+
logLine({ event, status: 'fired', sessionId: SESSION_ID, todoTitle: process.env.QUADTODO_TODO_TITLE })
|
|
49
|
+
|
|
50
|
+
let raw = ''
|
|
51
|
+
process.stdin.setEncoding('utf8')
|
|
52
|
+
process.stdin.on('data', (chunk) => {
|
|
53
|
+
raw += chunk
|
|
54
|
+
// 防止超大 payload 把这个进程占内存
|
|
55
|
+
if (raw.length > 64 * 1024) raw = raw.slice(0, 64 * 1024)
|
|
56
|
+
})
|
|
57
|
+
process.stdin.on('end', send)
|
|
58
|
+
// 没有 stdin 也要发(例如 SessionEnd 可能不带 payload)
|
|
59
|
+
setTimeout(() => { if (!sent) send() }, 1500).unref?.()
|
|
60
|
+
|
|
61
|
+
let sent = false
|
|
62
|
+
async function send() {
|
|
63
|
+
if (sent) return
|
|
64
|
+
sent = true
|
|
65
|
+
|
|
66
|
+
let hookPayload = null
|
|
67
|
+
if (raw.trim()) {
|
|
68
|
+
try { hookPayload = JSON.parse(raw) } catch { hookPayload = { _raw: raw.slice(0, 240) } }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
event,
|
|
73
|
+
sessionId: SESSION_ID,
|
|
74
|
+
targetUserId: process.env.QUADTODO_TARGET_USER || null,
|
|
75
|
+
todoId: process.env.QUADTODO_TODO_ID || null,
|
|
76
|
+
todoTitle: process.env.QUADTODO_TODO_TITLE || null,
|
|
77
|
+
hookPayload,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// 30s timeout:openclaw CLI shell-out 实测 4-6s,留足余量;
|
|
82
|
+
// Claude Code 默认等 hook 60s,所以 30s 安全。
|
|
83
|
+
const ctrl = new AbortController()
|
|
84
|
+
const timer = setTimeout(() => ctrl.abort(), 30_000)
|
|
85
|
+
timer.unref?.()
|
|
86
|
+
const res = await fetch(ENDPOINT, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body,
|
|
90
|
+
signal: ctrl.signal,
|
|
91
|
+
})
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
if (res.ok) {
|
|
94
|
+
const data = await res.json().catch(() => null)
|
|
95
|
+
logLine({ event, status: 'sent', sessionId: SESSION_ID, action: data?.action, reason: data?.reason })
|
|
96
|
+
} else {
|
|
97
|
+
const text = await res.text().catch(() => '')
|
|
98
|
+
logLine({ event, status: 'http_error', code: res.status, body: text.slice(0, 200) })
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logLine({ event, status: 'fetch_error', error: e?.message || String(e) })
|
|
102
|
+
}
|
|
103
|
+
}
|