@vibe-forge/core 0.7.3 → 0.7.5
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/package.json +1 -1
- package/src/adapter/type.ts +5 -0
- package/src/config/types.ts +1 -1
- package/src/controllers/task/generate-adapter-query-options.ts +10 -203
- package/src/controllers/task/prepare.ts +7 -1
- package/src/controllers/task/run.ts +52 -12
- package/src/hooks/bridge.ts +368 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/loader.ts +5 -1
- package/src/hooks/native.ts +116 -0
- package/src/hooks/runtime.ts +2 -2
- package/src/hooks/type.ts +30 -5
- package/src/utils/workspace-assets.ts +919 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdapterCtx,
|
|
3
|
+
AdapterEvent,
|
|
4
|
+
AdapterOutputEvent,
|
|
5
|
+
AdapterQueryOptions,
|
|
6
|
+
AdapterSession
|
|
7
|
+
} from '#~/adapter/index.js'
|
|
8
|
+
import type { ChatMessage, ChatMessageContent } from '#~/types.js'
|
|
9
|
+
import type { HookInputs } from './type'
|
|
10
|
+
|
|
11
|
+
import { callHook } from './call'
|
|
12
|
+
|
|
13
|
+
const normalizeText = (value: unknown) => (
|
|
14
|
+
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const stringifyUnknown = (value: unknown) => {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.stringify(value)
|
|
20
|
+
} catch {
|
|
21
|
+
return String(value)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const describeContentPart = (item: ChatMessageContent): string | undefined => {
|
|
26
|
+
switch (item.type) {
|
|
27
|
+
case 'text':
|
|
28
|
+
return normalizeText(item.text)
|
|
29
|
+
case 'image':
|
|
30
|
+
return normalizeText(item.name) != null ? `[Image: ${item.name}]` : '[Image attachment]'
|
|
31
|
+
case 'tool_use':
|
|
32
|
+
return `Tool request: ${item.name}`
|
|
33
|
+
case 'tool_result':
|
|
34
|
+
return typeof item.content === 'string' ? normalizeText(item.content) : stringifyUnknown(item.content)
|
|
35
|
+
default:
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const extractPromptText = (content: AdapterEvent['content']) => {
|
|
41
|
+
const parts = content
|
|
42
|
+
.map(describeContentPart)
|
|
43
|
+
.filter((value): value is string => value != null)
|
|
44
|
+
|
|
45
|
+
return parts.length > 0 ? parts.join('\n\n') : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const extractAssistantText = (message: ChatMessage | undefined) => {
|
|
49
|
+
if (message == null) return undefined
|
|
50
|
+
if (typeof message.content === 'string') return normalizeText(message.content)
|
|
51
|
+
|
|
52
|
+
const parts = message.content
|
|
53
|
+
.filter((item): item is Extract<ChatMessageContent, { type: 'text' }> => item.type === 'text')
|
|
54
|
+
.map(item => normalizeText(item.text))
|
|
55
|
+
.filter((value): value is string => value != null)
|
|
56
|
+
|
|
57
|
+
return parts.length > 0 ? parts.join('') : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isToolUse = (
|
|
61
|
+
item: ChatMessageContent
|
|
62
|
+
): item is Extract<ChatMessageContent, { type: 'tool_use' }> => item.type === 'tool_use'
|
|
63
|
+
|
|
64
|
+
const isToolResult = (
|
|
65
|
+
item: ChatMessageContent
|
|
66
|
+
): item is Extract<ChatMessageContent, { type: 'tool_result' }> => item.type === 'tool_result'
|
|
67
|
+
|
|
68
|
+
interface HookOutputLike {
|
|
69
|
+
continue?: boolean
|
|
70
|
+
stopReason?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AdapterHookBridge {
|
|
74
|
+
start: () => Promise<void>
|
|
75
|
+
prepareInitialPrompt: (prompt?: string) => Promise<string | undefined>
|
|
76
|
+
wrapSession: (session: AdapterSession) => AdapterSession
|
|
77
|
+
handleOutput: (event: AdapterOutputEvent) => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const createAdapterHookBridge = (params: {
|
|
81
|
+
ctx: Pick<AdapterCtx, 'cwd' | 'env' | 'logger'>
|
|
82
|
+
adapter: string
|
|
83
|
+
runtime: AdapterQueryOptions['runtime']
|
|
84
|
+
sessionId: string
|
|
85
|
+
type: AdapterQueryOptions['type']
|
|
86
|
+
model?: string
|
|
87
|
+
disabledEvents?: Array<keyof HookInputs>
|
|
88
|
+
}): AdapterHookBridge => {
|
|
89
|
+
const {
|
|
90
|
+
ctx,
|
|
91
|
+
adapter,
|
|
92
|
+
runtime,
|
|
93
|
+
sessionId,
|
|
94
|
+
type,
|
|
95
|
+
model,
|
|
96
|
+
disabledEvents: rawDisabledEvents = []
|
|
97
|
+
} = params
|
|
98
|
+
const disabledEvents = new Set(rawDisabledEvents)
|
|
99
|
+
|
|
100
|
+
const pendingToolCalls = new Map<string, { toolName: string; toolInput?: unknown }>()
|
|
101
|
+
let emitQueue = Promise.resolve()
|
|
102
|
+
let killRequested = false
|
|
103
|
+
let lastAssistantMessage: string | undefined
|
|
104
|
+
let sessionEnded = false
|
|
105
|
+
|
|
106
|
+
const callBridgeHook = async (
|
|
107
|
+
eventName: Parameters<typeof callHook>[0],
|
|
108
|
+
input: Record<string, unknown>,
|
|
109
|
+
options: {
|
|
110
|
+
canBlock: boolean
|
|
111
|
+
enforce?: boolean
|
|
112
|
+
blockedMessage: string
|
|
113
|
+
}
|
|
114
|
+
): Promise<HookOutputLike | undefined> => {
|
|
115
|
+
if (disabledEvents.has(eventName as keyof HookInputs)) {
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const output = await callHook(
|
|
121
|
+
eventName as never,
|
|
122
|
+
{
|
|
123
|
+
cwd: ctx.cwd,
|
|
124
|
+
sessionId,
|
|
125
|
+
adapter,
|
|
126
|
+
runtime,
|
|
127
|
+
hookSource: 'bridge',
|
|
128
|
+
canBlock: options.canBlock,
|
|
129
|
+
...input
|
|
130
|
+
} as never,
|
|
131
|
+
ctx.env
|
|
132
|
+
) as HookOutputLike
|
|
133
|
+
|
|
134
|
+
if (options.enforce && output?.continue === false) {
|
|
135
|
+
throw new Error(output.stopReason ?? options.blockedMessage)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!options.canBlock && output?.continue === false) {
|
|
139
|
+
ctx.logger.warn(
|
|
140
|
+
`[HookBridge] Ignoring blocking output from observational ${String(eventName)} hook`,
|
|
141
|
+
output.stopReason
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return output
|
|
146
|
+
} catch (error) {
|
|
147
|
+
ctx.logger.error(`[HookBridge] ${String(eventName)} failed`, error)
|
|
148
|
+
if (options.enforce) throw error
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const observeMessage = (message: ChatMessage) => {
|
|
154
|
+
const assistantText = extractAssistantText(message)
|
|
155
|
+
if (assistantText != null) lastAssistantMessage = assistantText
|
|
156
|
+
const preToolUseDisabled = disabledEvents.has('PreToolUse')
|
|
157
|
+
const postToolUseDisabled = disabledEvents.has('PostToolUse')
|
|
158
|
+
const needsPendingToolCalls = !postToolUseDisabled
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(message.content)) {
|
|
161
|
+
for (const item of message.content) {
|
|
162
|
+
if (isToolUse(item)) {
|
|
163
|
+
if (needsPendingToolCalls) {
|
|
164
|
+
pendingToolCalls.set(item.id, {
|
|
165
|
+
toolName: item.name,
|
|
166
|
+
toolInput: item.input
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
if (!preToolUseDisabled) {
|
|
170
|
+
void callBridgeHook(
|
|
171
|
+
'PreToolUse',
|
|
172
|
+
{
|
|
173
|
+
toolCallId: item.id,
|
|
174
|
+
toolName: item.name,
|
|
175
|
+
toolInput: item.input
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
canBlock: false,
|
|
179
|
+
blockedMessage: 'PreToolUse hook attempted to block an observed tool call'
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isToolResult(item)) {
|
|
186
|
+
const pending = pendingToolCalls.get(item.tool_use_id)
|
|
187
|
+
pendingToolCalls.delete(item.tool_use_id)
|
|
188
|
+
if (!postToolUseDisabled) {
|
|
189
|
+
void callBridgeHook(
|
|
190
|
+
'PostToolUse',
|
|
191
|
+
{
|
|
192
|
+
toolCallId: item.tool_use_id,
|
|
193
|
+
toolName: pending?.toolName ?? 'unknown',
|
|
194
|
+
toolInput: pending?.toolInput,
|
|
195
|
+
toolResponse: item.content,
|
|
196
|
+
isError: item.is_error ?? false
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
canBlock: false,
|
|
200
|
+
blockedMessage: 'PostToolUse hook attempted to block an observed tool result'
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (message.toolCall?.name != null) {
|
|
209
|
+
const toolCallId = message.toolCall.id ?? message.id
|
|
210
|
+
if (needsPendingToolCalls && !pendingToolCalls.has(toolCallId)) {
|
|
211
|
+
pendingToolCalls.set(toolCallId, {
|
|
212
|
+
toolName: message.toolCall.name,
|
|
213
|
+
toolInput: message.toolCall.args
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!preToolUseDisabled) {
|
|
218
|
+
void callBridgeHook(
|
|
219
|
+
'PreToolUse',
|
|
220
|
+
{
|
|
221
|
+
toolCallId,
|
|
222
|
+
toolName: message.toolCall.name,
|
|
223
|
+
toolInput: message.toolCall.args
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
canBlock: false,
|
|
227
|
+
blockedMessage: 'PreToolUse hook attempted to block a legacy observed tool call'
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!postToolUseDisabled && (message.toolCall.output != null || message.toolCall.status === 'error')) {
|
|
233
|
+
pendingToolCalls.delete(toolCallId)
|
|
234
|
+
void callBridgeHook(
|
|
235
|
+
'PostToolUse',
|
|
236
|
+
{
|
|
237
|
+
toolCallId,
|
|
238
|
+
toolName: message.toolCall.name,
|
|
239
|
+
toolInput: message.toolCall.args,
|
|
240
|
+
toolResponse: message.toolCall.output,
|
|
241
|
+
isError: message.toolCall.status === 'error'
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
canBlock: false,
|
|
245
|
+
blockedMessage: 'PostToolUse hook attempted to block a legacy observed tool result'
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
start: async () => {
|
|
254
|
+
if (disabledEvents.has('SessionStart')) return
|
|
255
|
+
await callBridgeHook(
|
|
256
|
+
'SessionStart',
|
|
257
|
+
{
|
|
258
|
+
source: type === 'resume' ? 'resume' : 'startup',
|
|
259
|
+
model
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
canBlock: true,
|
|
263
|
+
enforce: true,
|
|
264
|
+
blockedMessage: 'SessionStart hook blocked session startup'
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
},
|
|
268
|
+
prepareInitialPrompt: async (prompt) => {
|
|
269
|
+
const normalizedPrompt = normalizeText(prompt)
|
|
270
|
+
if (normalizedPrompt == null) return normalizedPrompt
|
|
271
|
+
if (disabledEvents.has('UserPromptSubmit')) return normalizedPrompt
|
|
272
|
+
|
|
273
|
+
await callBridgeHook(
|
|
274
|
+
'UserPromptSubmit',
|
|
275
|
+
{ prompt: normalizedPrompt },
|
|
276
|
+
{
|
|
277
|
+
canBlock: true,
|
|
278
|
+
enforce: true,
|
|
279
|
+
blockedMessage: 'UserPromptSubmit hook blocked the initial prompt'
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return normalizedPrompt
|
|
284
|
+
},
|
|
285
|
+
wrapSession: (session) => ({
|
|
286
|
+
kill: () => {
|
|
287
|
+
killRequested = true
|
|
288
|
+
session.kill()
|
|
289
|
+
},
|
|
290
|
+
emit: (event) => {
|
|
291
|
+
emitQueue = emitQueue
|
|
292
|
+
.catch((error) => {
|
|
293
|
+
ctx.logger.error('[HookBridge] emit queue failed', error)
|
|
294
|
+
})
|
|
295
|
+
.then(async () => {
|
|
296
|
+
if (event.type === 'message') {
|
|
297
|
+
const prompt = extractPromptText(event.content)
|
|
298
|
+
if (prompt != null && !disabledEvents.has('UserPromptSubmit')) {
|
|
299
|
+
const output = await callBridgeHook(
|
|
300
|
+
'UserPromptSubmit',
|
|
301
|
+
{ prompt },
|
|
302
|
+
{
|
|
303
|
+
canBlock: true,
|
|
304
|
+
blockedMessage: 'UserPromptSubmit hook blocked the outgoing prompt'
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if (output?.continue === false) {
|
|
309
|
+
ctx.logger.warn(
|
|
310
|
+
'[HookBridge] Dropping outgoing message blocked by UserPromptSubmit hook',
|
|
311
|
+
output.stopReason
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
session.emit(event)
|
|
319
|
+
})
|
|
320
|
+
},
|
|
321
|
+
get pid() {
|
|
322
|
+
return session.pid
|
|
323
|
+
}
|
|
324
|
+
}),
|
|
325
|
+
handleOutput: (event) => {
|
|
326
|
+
if (event.type === 'message' && event.data.role === 'assistant') {
|
|
327
|
+
observeMessage(event.data)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (event.type === 'stop') {
|
|
332
|
+
if (disabledEvents.has('Stop')) return
|
|
333
|
+
void callBridgeHook(
|
|
334
|
+
'Stop',
|
|
335
|
+
{
|
|
336
|
+
lastAssistantMessage: extractAssistantText(event.data) ?? lastAssistantMessage
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
canBlock: false,
|
|
340
|
+
blockedMessage: 'Stop hook attempted to control an observed stop event'
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (event.type === 'exit' && !sessionEnded) {
|
|
347
|
+
sessionEnded = true
|
|
348
|
+
void callBridgeHook(
|
|
349
|
+
'SessionEnd',
|
|
350
|
+
{
|
|
351
|
+
reason: killRequested
|
|
352
|
+
? 'terminated'
|
|
353
|
+
: (event.data.exitCode ?? 0) === 0
|
|
354
|
+
? 'completed'
|
|
355
|
+
: 'failed',
|
|
356
|
+
exitCode: event.data.exitCode,
|
|
357
|
+
stderr: event.data.stderr,
|
|
358
|
+
lastAssistantMessage
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
canBlock: false,
|
|
362
|
+
blockedMessage: 'SessionEnd hook attempted to control an observed session end'
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/hooks/index.ts
CHANGED
package/src/hooks/loader.ts
CHANGED
|
@@ -34,7 +34,10 @@ const loadPlugin = async (
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export const resolvePlugins = async (
|
|
37
|
+
export const resolvePlugins = async (
|
|
38
|
+
config: PluginConfig,
|
|
39
|
+
enabledPlugins: Record<string, boolean> = {}
|
|
40
|
+
): Promise<Partial<Plugin>[]> => {
|
|
38
41
|
// 1. 处理数组形式配置 (直接实例化或函数调用)
|
|
39
42
|
if (Array.isArray(config)) {
|
|
40
43
|
return config.map((p) => (typeof p === 'function' ? p() : p))
|
|
@@ -42,6 +45,7 @@ export const resolvePlugins = async (config: PluginConfig): Promise<Partial<Plug
|
|
|
42
45
|
|
|
43
46
|
// 2. 处理对象形式配置 (动态加载)
|
|
44
47
|
const entries = Object.entries(config)
|
|
48
|
+
.filter(([pkgName]) => enabledPlugins[pkgName] !== false)
|
|
45
49
|
if (entries.length === 0) return []
|
|
46
50
|
|
|
47
51
|
// 并行加载所有插件
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
import type { AdapterCtx } from '../adapter'
|
|
6
|
+
|
|
7
|
+
export interface NativeHookHandlerConfig {
|
|
8
|
+
type: 'command'
|
|
9
|
+
command: string
|
|
10
|
+
timeout?: number
|
|
11
|
+
statusMessage?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NativeHookMatcherGroup {
|
|
15
|
+
matcher?: string
|
|
16
|
+
hooks?: NativeHookHandlerConfig[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NativeHooksConfigFile {
|
|
20
|
+
hooks?: Partial<Record<string, NativeHookMatcherGroup[]>> & Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => (
|
|
24
|
+
value != null && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export const hasManagedHookPlugins = (
|
|
28
|
+
ctx: Pick<AdapterCtx, 'assets'>
|
|
29
|
+
) => (ctx.assets?.hookPlugins.length ?? 0) > 0
|
|
30
|
+
|
|
31
|
+
export const resolveMockHome = (
|
|
32
|
+
cwd: string,
|
|
33
|
+
env: Pick<AdapterCtx, 'env'>['env']
|
|
34
|
+
) => {
|
|
35
|
+
const explicitHome = env.HOME?.trim() || process.env.HOME?.trim()
|
|
36
|
+
return explicitHome ? resolve(explicitHome) : resolve(cwd, '.ai', '.mock')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const resolveHookCliPackageDir = () => {
|
|
40
|
+
try {
|
|
41
|
+
const pkgJsonPath = require.resolve('@vibe-forge/cli/package.json')
|
|
42
|
+
return dirname(pkgJsonPath)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error('Failed to resolve @vibe-forge/cli for native hooks', { cause: error })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const resolveHookCliScriptPath = (fileName: string) => (
|
|
49
|
+
resolve(resolveHookCliPackageDir(), fileName)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
export const shellQuote = (value: string) => JSON.stringify(value)
|
|
53
|
+
|
|
54
|
+
export const buildNodeScriptCommand = (params: {
|
|
55
|
+
nodePath: string
|
|
56
|
+
scriptPath: string
|
|
57
|
+
}) => `${shellQuote(params.nodePath)} ${shellQuote(params.scriptPath)}`
|
|
58
|
+
|
|
59
|
+
export const prepareManagedHookRuntime = (
|
|
60
|
+
ctx: Pick<AdapterCtx, 'cwd' | 'env'>
|
|
61
|
+
) => {
|
|
62
|
+
const mockHome = resolveMockHome(ctx.cwd, ctx.env)
|
|
63
|
+
const cliPackageDir = resolveHookCliPackageDir()
|
|
64
|
+
const nodePath = process.execPath
|
|
65
|
+
|
|
66
|
+
ctx.env.__VF_PROJECT_CLI_PACKAGE_DIR__ = cliPackageDir
|
|
67
|
+
ctx.env.__VF_PROJECT_WORKSPACE_FOLDER__ = ctx.env.__VF_PROJECT_WORKSPACE_FOLDER__ ?? ctx.cwd
|
|
68
|
+
ctx.env.__VF_PROJECT_NODE_PATH__ = nodePath
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
mockHome,
|
|
72
|
+
cliPackageDir,
|
|
73
|
+
nodePath
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const readJsonFileOrDefault = async <T>(filePath: string, fallback: T): Promise<T> => {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(await readFile(filePath, 'utf8')) as T
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return fallback
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const writeJsonFile = async (filePath: string, value: unknown) => {
|
|
87
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
88
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const mergeManagedHookGroups = (params: {
|
|
92
|
+
existing: unknown
|
|
93
|
+
eventNames: readonly string[]
|
|
94
|
+
enabled: boolean
|
|
95
|
+
isManagedGroup: (group: NativeHookMatcherGroup) => boolean
|
|
96
|
+
createGroup: (eventName: string) => NativeHookMatcherGroup
|
|
97
|
+
shouldManageEvent?: (eventName: string) => boolean
|
|
98
|
+
}): NativeHooksConfigFile => {
|
|
99
|
+
const parsed = isPlainObject(params.existing) ? params.existing as NativeHooksConfigFile : {}
|
|
100
|
+
const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : {}
|
|
101
|
+
const nextHooks: Record<string, unknown> = { ...hooks }
|
|
102
|
+
|
|
103
|
+
for (const eventName of params.eventNames) {
|
|
104
|
+
const groups = Array.isArray(hooks[eventName])
|
|
105
|
+
? (hooks[eventName] as NativeHookMatcherGroup[]).filter(group => !params.isManagedGroup(group))
|
|
106
|
+
: []
|
|
107
|
+
nextHooks[eventName] = params.enabled && (params.shouldManageEvent?.(eventName) ?? true)
|
|
108
|
+
? [...groups, params.createGroup(eventName)]
|
|
109
|
+
: groups
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...parsed,
|
|
114
|
+
hooks: nextHooks
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/hooks/runtime.ts
CHANGED
|
@@ -99,8 +99,8 @@ export const executeHookInput = async (
|
|
|
99
99
|
}
|
|
100
100
|
const [config, userConfig] = await loadConfig({ jsonVariables })
|
|
101
101
|
const plugins = [
|
|
102
|
-
...await resolvePlugins(config?.plugins ?? []),
|
|
103
|
-
...await resolvePlugins(userConfig?.plugins ?? [])
|
|
102
|
+
...await resolvePlugins(config?.plugins ?? [], config?.enabledPlugins ?? {}),
|
|
103
|
+
...await resolvePlugins(userConfig?.plugins ?? [], userConfig?.enabledPlugins ?? {})
|
|
104
104
|
]
|
|
105
105
|
|
|
106
106
|
return callPluginHook(
|
package/src/hooks/type.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AdapterQueryOptions } from '../adapter'
|
|
2
|
+
|
|
3
|
+
export type HookSource = 'native' | 'bridge'
|
|
4
|
+
|
|
5
|
+
export interface HookToolCall {
|
|
6
|
+
toolCallId?: string
|
|
7
|
+
toolName: string
|
|
8
|
+
toolInput?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HookToolResult extends HookToolCall {
|
|
12
|
+
toolResponse?: unknown
|
|
13
|
+
isError?: boolean
|
|
14
|
+
}
|
|
2
15
|
|
|
3
16
|
/**
|
|
4
17
|
* https://docs.anthropic.com/en/docs/claude-code/hooks#hook-input
|
|
@@ -7,25 +20,37 @@ export interface HookInputCore {
|
|
|
7
20
|
cwd: string
|
|
8
21
|
sessionId: string
|
|
9
22
|
hookEventName: keyof HookInputs
|
|
23
|
+
adapter?: string
|
|
24
|
+
runtime?: AdapterQueryOptions['runtime']
|
|
25
|
+
hookSource?: HookSource
|
|
26
|
+
canBlock?: boolean
|
|
10
27
|
}
|
|
11
28
|
|
|
12
29
|
export interface HookInputs {
|
|
13
30
|
/**
|
|
14
31
|
* https://docs.anthropic.com/en/docs/claude-code/hooks#pretooluse-input
|
|
15
32
|
*/
|
|
16
|
-
PreToolUse: HookInputCore &
|
|
33
|
+
PreToolUse: HookInputCore & HookToolCall
|
|
17
34
|
/**
|
|
18
35
|
* https://docs.anthropic.com/en/docs/claude-code/hooks#posttooluse-input
|
|
19
36
|
*/
|
|
20
|
-
PostToolUse: HookInputCore &
|
|
37
|
+
PostToolUse: HookInputCore & HookToolResult
|
|
21
38
|
Notification: HookInputCore
|
|
22
39
|
UserPromptSubmit: HookInputCore & { prompt: string }
|
|
23
|
-
Stop: HookInputCore
|
|
40
|
+
Stop: HookInputCore & {
|
|
41
|
+
lastAssistantMessage?: string
|
|
42
|
+
}
|
|
24
43
|
SubagentStop: HookInputCore
|
|
25
44
|
PreCompact: HookInputCore
|
|
26
|
-
SessionStart: HookInputCore
|
|
45
|
+
SessionStart: HookInputCore & {
|
|
46
|
+
source?: 'startup' | 'resume'
|
|
47
|
+
model?: string
|
|
48
|
+
}
|
|
27
49
|
SessionEnd: HookInputCore & {
|
|
28
50
|
reason: string
|
|
51
|
+
exitCode?: number
|
|
52
|
+
stderr?: string
|
|
53
|
+
lastAssistantMessage?: string
|
|
29
54
|
}
|
|
30
55
|
|
|
31
56
|
StartTasks: HookInputCore & {
|