@miphamai/cli 0.3.0 → 0.5.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/bin/mipham.ts +188 -0
- package/dist/mipham +0 -0
- package/package.json +9 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
- package/skills/mipham/om-security.mipham-skill.md +11 -8
- package/skills/standard/doc-generator.SKILL.md +6 -1
- package/skills/standard/github-ops.SKILL.md +12 -7
- package/skills/standard/memory.SKILL.md +1 -0
- package/skills/standard/self-review.SKILL.md +1 -0
- package/skills/standard/superpower.SKILL.md +7 -7
- package/skills/standard/web-search.SKILL.md +3 -0
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -89
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +374 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +207 -58
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +12 -1
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +224 -34
- package/src/index.tsx +30 -2
- package/src/mcp/transport.ts +42 -5
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/providers/fetch-utils.ts +1 -3
- package/src/providers/openai-compat.ts +2 -11
- package/src/shared/constants.ts +8 -1
- package/src/shared/types.ts +82 -2
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +48 -2
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +6 -0
- package/src/ui/app.tsx +20 -7
- package/src/ui/chat.tsx +9 -5
- package/src/ui/commands.ts +185 -40
- package/src/ui/input.tsx +203 -27
- package/src/ui/picker.tsx +2 -5
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- package/src/workflow/sandbox.ts +75 -0
package/src/core/engine.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StreamChunk, ToolDefinition, ToolResult } from '../shared/index.ts'
|
|
2
2
|
import { ProviderRegistry } from '../providers/registry'
|
|
3
3
|
import { ContextManager } from './context'
|
|
4
4
|
import { PermissionSystem } from './permission'
|
|
5
5
|
import type { HookEngine } from './hooks'
|
|
6
|
+
import type { ArtifactServer } from '../artifacts/server'
|
|
7
|
+
import type { AgentRegistry } from '../agent/agent-registry'
|
|
8
|
+
import { analyzeForMemory } from './memory/memory-writer'
|
|
9
|
+
import { getMemoryManager } from './memory/memory-loader'
|
|
10
|
+
import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
6
11
|
|
|
7
12
|
export class QueryEngine {
|
|
8
13
|
private hookEngine?: HookEngine
|
|
14
|
+
private artifactServer?: ArtifactServer
|
|
15
|
+
private agentRegistry?: AgentRegistry
|
|
16
|
+
private agentViewManager?: AgentViewManager
|
|
17
|
+
private goal?: string
|
|
18
|
+
private maxGoalLoops = 20
|
|
19
|
+
private lastAssistantContent?: string
|
|
9
20
|
|
|
10
21
|
constructor(
|
|
11
22
|
private registry: ProviderRegistry,
|
|
@@ -19,6 +30,41 @@ export class QueryEngine {
|
|
|
19
30
|
this.hookEngine = hooks
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
/** Register the artifact server for tool context. */
|
|
34
|
+
setArtifactServer(server: ArtifactServer): void {
|
|
35
|
+
this.artifactServer = server
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register the agent registry for custom agent definitions. */
|
|
39
|
+
setAgentRegistry(reg: AgentRegistry): void {
|
|
40
|
+
this.agentRegistry = reg
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Get the registered agent registry (may be undefined if not wired). */
|
|
44
|
+
getAgentRegistry(): AgentRegistry | undefined {
|
|
45
|
+
return this.agentRegistry
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Register the AgentViewManager for background agent session management. */
|
|
49
|
+
setAgentViewManager(mgr: AgentViewManager): void {
|
|
50
|
+
this.agentViewManager = mgr
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get the AgentViewManager (may be undefined if not wired). */
|
|
54
|
+
getAgentViewManager(): AgentViewManager | undefined {
|
|
55
|
+
return this.agentViewManager
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Set the session goal for goal-driven execution. */
|
|
59
|
+
setGoal(goal: string): void {
|
|
60
|
+
this.goal = goal
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Get the last assistant text content. */
|
|
64
|
+
getLastAssistantContent(): string | undefined {
|
|
65
|
+
return this.lastAssistantContent
|
|
66
|
+
}
|
|
67
|
+
|
|
22
68
|
/** Wire LLM-based conversation summarization into the context manager. */
|
|
23
69
|
setupContextSummarizer(): void {
|
|
24
70
|
this.context.setSummarizer(async (messages, heading) => {
|
|
@@ -31,8 +77,7 @@ export class QueryEngine {
|
|
|
31
77
|
.join('\n')
|
|
32
78
|
|
|
33
79
|
// Build a minimal system prompt for summarization
|
|
34
|
-
const summaryPrompt =
|
|
35
|
-
`You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
|
|
80
|
+
const summaryPrompt = `You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
|
|
36
81
|
|
|
37
82
|
// Collect full summary text from streaming response
|
|
38
83
|
let summary = ''
|
|
@@ -64,12 +109,30 @@ export class QueryEngine {
|
|
|
64
109
|
}
|
|
65
110
|
|
|
66
111
|
async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
112
|
+
// Fire UserPromptSubmit hooks before processing
|
|
113
|
+
if (this.hookEngine) {
|
|
114
|
+
const submitResult = await this.hookEngine.executeUserPromptSubmit(userInput, 'session-1')
|
|
115
|
+
if (submitResult.additionalContext) {
|
|
116
|
+
this.context.addMessage({
|
|
117
|
+
role: 'user',
|
|
118
|
+
content: `[Hook context]: ${submitResult.additionalContext}`,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
if (!submitResult.allowed) {
|
|
122
|
+
yield {
|
|
123
|
+
type: 'error',
|
|
124
|
+
error: submitResult.reason || 'User input blocked by hook.',
|
|
125
|
+
}
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
// Add user message to context
|
|
68
131
|
this.context.addMessage({ role: 'user', content: userInput })
|
|
69
132
|
|
|
70
133
|
// Check compaction before processing
|
|
71
134
|
if (this.context.needsCompaction()) {
|
|
72
|
-
await this.
|
|
135
|
+
await this.compactWithHooks('conversation summary')
|
|
73
136
|
}
|
|
74
137
|
|
|
75
138
|
const systemPrompt = this.context.getSystemPrompt()
|
|
@@ -81,39 +144,39 @@ export class QueryEngine {
|
|
|
81
144
|
|
|
82
145
|
// Stream model response
|
|
83
146
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (chunk.type === 'error') {
|
|
94
|
-
this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
|
|
95
|
-
return
|
|
96
|
-
}
|
|
147
|
+
for await (const chunk of this.registry.chat({
|
|
148
|
+
model: this.registry.getActiveModel(),
|
|
149
|
+
messages,
|
|
150
|
+
systemPrompt,
|
|
151
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
152
|
+
signal,
|
|
153
|
+
})) {
|
|
154
|
+
yield chunk
|
|
97
155
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
if (chunk.type === 'error') {
|
|
157
|
+
this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
|
|
158
|
+
return
|
|
159
|
+
}
|
|
101
160
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
name: chunk.toolUse.name,
|
|
106
|
-
input: chunk.toolUse.input,
|
|
107
|
-
})
|
|
108
|
-
}
|
|
161
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
162
|
+
assistantContent += chunk.content
|
|
163
|
+
}
|
|
109
164
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
165
|
+
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
166
|
+
toolUses.push({
|
|
167
|
+
id: chunk.toolUse.id,
|
|
168
|
+
name: chunk.toolUse.name,
|
|
169
|
+
input: chunk.toolUse.input,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (chunk.type === 'stop') {
|
|
174
|
+
// Add assistant response to context
|
|
175
|
+
if (assistantContent) {
|
|
176
|
+
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
177
|
+
}
|
|
114
178
|
}
|
|
115
179
|
}
|
|
116
|
-
}
|
|
117
180
|
} catch (err) {
|
|
118
181
|
if (isAbortError(err)) {
|
|
119
182
|
// User interrupted — keep partial content, stop gracefully
|
|
@@ -127,13 +190,27 @@ export class QueryEngine {
|
|
|
127
190
|
return
|
|
128
191
|
}
|
|
129
192
|
|
|
193
|
+
// Track last assistant content for goal checking
|
|
194
|
+
if (assistantContent) {
|
|
195
|
+
this.lastAssistantContent = assistantContent
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Analyze user message for memory-worthy content after AI response
|
|
199
|
+
if (assistantContent && userInput) {
|
|
200
|
+
try {
|
|
201
|
+
analyzeForMemory(userInput, getMemoryManager())
|
|
202
|
+
} catch {
|
|
203
|
+
// memory analysis is non-critical
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
130
207
|
// Execute any tools that were requested
|
|
131
208
|
for (const toolUse of toolUses) {
|
|
132
209
|
const result = await this.executeTool(toolUse.name, toolUse.input)
|
|
133
210
|
yield {
|
|
134
211
|
type: 'tool_result',
|
|
135
212
|
tool_use_id: toolUse.id,
|
|
136
|
-
content: result.success ? result.content :
|
|
213
|
+
content: result.success ? result.content : result.error || result.content,
|
|
137
214
|
}
|
|
138
215
|
|
|
139
216
|
// Add tool use + result to context
|
|
@@ -150,6 +227,26 @@ export class QueryEngine {
|
|
|
150
227
|
// If tools were executed, recursively continue the conversation
|
|
151
228
|
if (toolUses.length > 0) {
|
|
152
229
|
yield* this.continueWithTools(signal)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Fire Stop hooks when AI finishes with no tool calls
|
|
234
|
+
yield* this.checkStopHook(signal)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async *processWithGoal(input: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
238
|
+
let loop = 0
|
|
239
|
+
while (loop < this.maxGoalLoops) {
|
|
240
|
+
yield* this.process(input, signal)
|
|
241
|
+
loop++
|
|
242
|
+
|
|
243
|
+
if (!this.goal) break
|
|
244
|
+
|
|
245
|
+
// Ask AI to check the goal condition
|
|
246
|
+
const checkMsg = `Has this goal been achieved? "${this.goal}" Answer YES or NO with reason.`
|
|
247
|
+
yield* this.process(checkMsg, signal)
|
|
248
|
+
// If AI responds "YES", break
|
|
249
|
+
if (this.lastAssistantContent?.includes('YES')) break
|
|
153
250
|
}
|
|
154
251
|
}
|
|
155
252
|
|
|
@@ -165,26 +262,26 @@ export class QueryEngine {
|
|
|
165
262
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
166
263
|
|
|
167
264
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
265
|
+
for await (const chunk of this.registry.chat({
|
|
266
|
+
model: this.registry.getActiveModel(),
|
|
267
|
+
messages,
|
|
268
|
+
systemPrompt,
|
|
269
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
270
|
+
signal,
|
|
271
|
+
})) {
|
|
272
|
+
yield chunk
|
|
176
273
|
|
|
177
|
-
|
|
178
|
-
|
|
274
|
+
if (chunk.type === 'error') return
|
|
275
|
+
if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
|
|
179
276
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
277
|
+
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
278
|
+
toolUses.push({
|
|
279
|
+
id: chunk.toolUse.id,
|
|
280
|
+
name: chunk.toolUse.name,
|
|
281
|
+
input: chunk.toolUse.input,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
186
284
|
}
|
|
187
|
-
}
|
|
188
285
|
} catch (err) {
|
|
189
286
|
if (isAbortError(err)) {
|
|
190
287
|
if (assistantContent) {
|
|
@@ -200,8 +297,11 @@ export class QueryEngine {
|
|
|
200
297
|
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
201
298
|
}
|
|
202
299
|
|
|
203
|
-
// No more tool calls —
|
|
204
|
-
if (toolUses.length === 0)
|
|
300
|
+
// No more tool calls — fire Stop hook and potentially continue
|
|
301
|
+
if (toolUses.length === 0) {
|
|
302
|
+
yield* this.checkStopHook(signal)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
205
305
|
|
|
206
306
|
// Execute tools and feed results back to the model for the next turn
|
|
207
307
|
for (const toolUse of toolUses) {
|
|
@@ -243,11 +343,7 @@ export class QueryEngine {
|
|
|
243
343
|
// Run PreToolUse hooks
|
|
244
344
|
let effectiveParams = params
|
|
245
345
|
if (this.hookEngine) {
|
|
246
|
-
const preResult = await this.hookEngine.executePreToolUse(
|
|
247
|
-
name,
|
|
248
|
-
params,
|
|
249
|
-
'session-1',
|
|
250
|
-
)
|
|
346
|
+
const preResult = await this.hookEngine.executePreToolUse(name, params, 'session-1')
|
|
251
347
|
if (!preResult.allowed) {
|
|
252
348
|
return {
|
|
253
349
|
success: false,
|
|
@@ -266,6 +362,10 @@ export class QueryEngine {
|
|
|
266
362
|
sessionId: 'session-1',
|
|
267
363
|
provider: this.registry.getActive().config.id,
|
|
268
364
|
model: this.registry.getActiveModel(),
|
|
365
|
+
registry: this.registry,
|
|
366
|
+
toolRegistry: this.tools,
|
|
367
|
+
artifactServer: this.artifactServer,
|
|
368
|
+
agentRegistry: this.agentRegistry,
|
|
269
369
|
})
|
|
270
370
|
|
|
271
371
|
// Run PostToolUse hooks
|
|
@@ -292,6 +392,10 @@ export class QueryEngine {
|
|
|
292
392
|
return this.context
|
|
293
393
|
}
|
|
294
394
|
|
|
395
|
+
getRegistry(): ProviderRegistry {
|
|
396
|
+
return this.registry
|
|
397
|
+
}
|
|
398
|
+
|
|
295
399
|
getTools(): Map<string, ToolDefinition> {
|
|
296
400
|
return this.tools
|
|
297
401
|
}
|
|
@@ -299,10 +403,55 @@ export class QueryEngine {
|
|
|
299
403
|
switchProvider(providerId: string, modelId?: string): void {
|
|
300
404
|
this.registry.switchProvider(providerId, modelId)
|
|
301
405
|
}
|
|
406
|
+
|
|
407
|
+
/** Wrap context compaction with PreCompact/PostCompact hooks. */
|
|
408
|
+
private async compactWithHooks(heading: string): Promise<void> {
|
|
409
|
+
if (this.hookEngine) {
|
|
410
|
+
const preResult = await this.hookEngine.executePreCompact('session-1')
|
|
411
|
+
if (preResult.additionalContext) {
|
|
412
|
+
this.context.addMessage({
|
|
413
|
+
role: 'user',
|
|
414
|
+
content: `[Pre-compact context]: ${preResult.additionalContext}`,
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await this.context.compact(heading)
|
|
420
|
+
|
|
421
|
+
if (this.hookEngine) {
|
|
422
|
+
const postResult = await this.hookEngine.executePostCompact('session-1')
|
|
423
|
+
if (postResult.additionalContext) {
|
|
424
|
+
this.context.addMessage({
|
|
425
|
+
role: 'user',
|
|
426
|
+
content: `[Post-compact context]: ${postResult.additionalContext}`,
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Fire Stop hook. If blocked, feed the reason back to the AI and continue. */
|
|
433
|
+
private async *checkStopHook(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
434
|
+
if (!this.hookEngine) return
|
|
435
|
+
|
|
436
|
+
const stopResult = await this.hookEngine.executeStop('session-1')
|
|
437
|
+
if (stopResult.decision === 'block') {
|
|
438
|
+
// Feed the block reason back to the AI and continue
|
|
439
|
+
this.context.addMessage({
|
|
440
|
+
role: 'user',
|
|
441
|
+
content: `[The Stop hook blocked completion]: ${stopResult.reason || 'Continue working.'}`,
|
|
442
|
+
})
|
|
443
|
+
yield* this.continueWithTools(signal)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
302
446
|
}
|
|
303
447
|
|
|
304
448
|
function isAbortError(err: unknown): boolean {
|
|
305
449
|
if (err instanceof Error && err.name === 'AbortError') return true
|
|
306
|
-
if (
|
|
450
|
+
if (
|
|
451
|
+
typeof DOMException !== 'undefined' &&
|
|
452
|
+
err instanceof DOMException &&
|
|
453
|
+
err.name === 'AbortError'
|
|
454
|
+
)
|
|
455
|
+
return true
|
|
307
456
|
return false
|
|
308
457
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { HookConfig, HookEvent, HookDefinition, HookContext } from '../shared/index.ts'
|
|
2
|
+
import { executeHook } from './hooks-executor'
|
|
3
|
+
|
|
4
|
+
interface HookConfigEntry {
|
|
5
|
+
matcher: string
|
|
6
|
+
hooks: HookConfig[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface SettingsHooks {
|
|
10
|
+
PreToolUse?: HookConfigEntry[]
|
|
11
|
+
PostToolUse?: HookConfigEntry[]
|
|
12
|
+
Stop?: HookConfigEntry[]
|
|
13
|
+
UserPromptSubmit?: HookConfigEntry[]
|
|
14
|
+
PreCompact?: HookConfigEntry[]
|
|
15
|
+
PostCompact?: HookConfigEntry[]
|
|
16
|
+
SessionStart?: HookConfigEntry[]
|
|
17
|
+
SessionEnd?: HookConfigEntry[]
|
|
18
|
+
Notification?: HookConfigEntry[]
|
|
19
|
+
ConfigChange?: HookConfigEntry[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load hook configurations from a settings object and convert them to
|
|
24
|
+
* executable HookDefinitions suitable for the HookEngine.
|
|
25
|
+
*/
|
|
26
|
+
export function loadHookConfigs(configs: SettingsHooks): HookDefinition[] {
|
|
27
|
+
const definitions: HookDefinition[] = []
|
|
28
|
+
|
|
29
|
+
for (const [eventName, entries] of Object.entries(configs)) {
|
|
30
|
+
if (!entries || !Array.isArray(entries)) continue
|
|
31
|
+
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const matcherRegex = entry.matcher ? new RegExp(entry.matcher) : null
|
|
34
|
+
|
|
35
|
+
for (const hookCfg of entry.hooks) {
|
|
36
|
+
definitions.push({
|
|
37
|
+
event: eventName as HookEvent,
|
|
38
|
+
toolName: entry.matcher || undefined,
|
|
39
|
+
handler: async (ctx: HookContext) => {
|
|
40
|
+
// Check matcher if tool-specific
|
|
41
|
+
if (matcherRegex && ctx.toolName && !matcherRegex.test(ctx.toolName)) {
|
|
42
|
+
return { allowed: true }
|
|
43
|
+
}
|
|
44
|
+
return executeHook(hookCfg, ctx)
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return definitions
|
|
52
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import type { HookConfig, HookContext, HookResult } from '../shared/index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute a hook based on its type, returning a HookResult.
|
|
6
|
+
*
|
|
7
|
+
* Supported types:
|
|
8
|
+
* - command: Execute a shell command. Exit code 0 = allow, 2 = block with stderr as reason.
|
|
9
|
+
* - http: POST to a URL, response body becomes additionalContext.
|
|
10
|
+
* - mcp_tool: Call an MCP tool (delegates to MCP client -- stub for now).
|
|
11
|
+
* - code: No-op (handled inline by the handler function directly).
|
|
12
|
+
*/
|
|
13
|
+
export async function executeHook(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
|
|
14
|
+
switch (cfg.type) {
|
|
15
|
+
case 'command':
|
|
16
|
+
return executeCommand(cfg, ctx)
|
|
17
|
+
case 'http':
|
|
18
|
+
return executeHttp(cfg, ctx)
|
|
19
|
+
case 'mcp_tool':
|
|
20
|
+
return executeMcpTool(cfg, ctx)
|
|
21
|
+
default:
|
|
22
|
+
return { allowed: true }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function substituteVars(template: string, ctx: HookContext): string {
|
|
27
|
+
return template
|
|
28
|
+
.replace(/\$TOOL_NAME/g, ctx.toolName || '')
|
|
29
|
+
.replace(/\$INPUT/g, ctx.toolInput ? JSON.stringify(ctx.toolInput) : '')
|
|
30
|
+
.replace(/\$SESSION_ID/g, ctx.sessionId)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
|
|
34
|
+
if (!cfg.command) return { allowed: true }
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const args = cfg.args ? cfg.args.map((a) => substituteVars(a, ctx)) : []
|
|
38
|
+
|
|
39
|
+
const cmd = [cfg.command, ...args].join(' ')
|
|
40
|
+
|
|
41
|
+
execSync(cmd, {
|
|
42
|
+
timeout: 30_000,
|
|
43
|
+
encoding: 'utf-8',
|
|
44
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Exit code 0 = success, allow
|
|
48
|
+
return { allowed: true }
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const stderr = (err as { stderr?: string }).stderr || String(err)
|
|
51
|
+
|
|
52
|
+
// Exit code 2 = block with reason
|
|
53
|
+
if ((err as { status?: number }).status === 2) {
|
|
54
|
+
return {
|
|
55
|
+
allowed: false,
|
|
56
|
+
reason: stderr.trim() || 'Blocked by hook',
|
|
57
|
+
additionalContext: cfg.continueOnBlock ? stderr.trim() : undefined,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Other non-zero exit: don't block, log the error as context
|
|
62
|
+
return {
|
|
63
|
+
allowed: true,
|
|
64
|
+
additionalContext: `Hook warning (${cfg.command}): ${stderr.trim()}`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function executeHttp(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
|
|
70
|
+
if (!cfg.url) return { allowed: true }
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(cfg.url, {
|
|
74
|
+
method: cfg.method || 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
...(cfg.headers || {}),
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
event: ctx.event,
|
|
81
|
+
toolName: ctx.toolName,
|
|
82
|
+
sessionId: ctx.sessionId,
|
|
83
|
+
}),
|
|
84
|
+
signal: AbortSignal.timeout(10_000),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const body = await response.text()
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
return {
|
|
91
|
+
allowed: false,
|
|
92
|
+
reason: `HTTP hook returned ${response.status}: ${body.slice(0, 200)}`,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
allowed: true,
|
|
98
|
+
additionalContext: body.slice(0, 2000) || undefined,
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// HTTP hook failures should not block
|
|
102
|
+
return {
|
|
103
|
+
allowed: true,
|
|
104
|
+
additionalContext: `HTTP hook error (${cfg.url}): ${String(err)}`,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function executeMcpTool(_cfg: HookConfig, _ctx: HookContext): Promise<HookResult> {
|
|
110
|
+
// Stub: MCP tool hook execution requires MCP client integration.
|
|
111
|
+
// For now, return allow to not block execution.
|
|
112
|
+
return { allowed: true }
|
|
113
|
+
}
|