@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.
@@ -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
+ }
@@ -34,6 +34,8 @@ export type PluginConfig =
34
34
  | Partial<PluginMap>
35
35
 
36
36
  export * from './call'
37
+ export * from './bridge'
37
38
  export * from './loader'
39
+ export * from './native'
38
40
  export * from './runtime'
39
41
  export * from './type'
@@ -34,7 +34,10 @@ const loadPlugin = async (
34
34
  }
35
35
  }
36
36
 
37
- export const resolvePlugins = async (config: PluginConfig): Promise<Partial<Plugin>[]> => {
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
+ }
@@ -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 { ToolInput, ToolOutput } from '../tools'
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 & ToolInput
33
+ PreToolUse: HookInputCore & HookToolCall
17
34
  /**
18
35
  * https://docs.anthropic.com/en/docs/claude-code/hooks#posttooluse-input
19
36
  */
20
- PostToolUse: HookInputCore & ToolOutput
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 & {