@miphamai/cli 0.4.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.
Files changed (56) hide show
  1. package/bin/mipham.ts +186 -0
  2. package/package.json +1 -1
  3. package/src/agent/agent-context.ts +56 -0
  4. package/src/agent/agent-registry.ts +134 -0
  5. package/src/agent/sub-agent.ts +73 -88
  6. package/src/agent/types.ts +39 -0
  7. package/src/agent-view/agent-view-manager.ts +217 -0
  8. package/src/agent-view/dashboard.tsx +218 -0
  9. package/src/agent-view/session-peek.tsx +72 -0
  10. package/src/agent-view/session-row.tsx +70 -0
  11. package/src/artifacts/server.ts +31 -0
  12. package/src/artifacts/versioning.ts +127 -0
  13. package/src/core/context-compact.ts +50 -0
  14. package/src/core/context-drain.ts +54 -0
  15. package/src/core/context-microcompact.ts +132 -0
  16. package/src/core/context-snip.ts +74 -0
  17. package/src/core/context-token.ts +108 -0
  18. package/src/core/context.ts +169 -0
  19. package/src/core/engine.ts +144 -3
  20. package/src/core/hooks-config.ts +52 -0
  21. package/src/core/hooks-executor.ts +113 -0
  22. package/src/core/hooks.ts +90 -30
  23. package/src/core/instructions.ts +11 -0
  24. package/src/core/memory/memory-loader.ts +23 -0
  25. package/src/core/memory/memory-manager.ts +192 -0
  26. package/src/core/memory/memory-writer.ts +72 -0
  27. package/src/core/permission-config.ts +34 -0
  28. package/src/core/permission-rules.ts +66 -0
  29. package/src/core/permission.ts +221 -36
  30. package/src/index.tsx +20 -0
  31. package/src/plugin/plugin-loader.ts +36 -0
  32. package/src/plugin/plugin-manager.ts +125 -0
  33. package/src/plugin/plugin-validator.ts +41 -0
  34. package/src/shared/types.ts +61 -1
  35. package/src/skills/fork-executor.ts +40 -0
  36. package/src/skills/loader.ts +46 -0
  37. package/src/tools/agent/agent.ts +20 -11
  38. package/src/tools/agent/skill.ts +32 -2
  39. package/src/tools/computer/app-launcher.ts +39 -0
  40. package/src/tools/computer/browser.ts +48 -0
  41. package/src/tools/computer/computer-use.ts +88 -0
  42. package/src/tools/computer/playwright.d.ts +7 -0
  43. package/src/tools/computer/screenshot.ts +57 -0
  44. package/src/tools/index.ts +3 -0
  45. package/src/ui/app.tsx +10 -1
  46. package/src/ui/commands.ts +99 -30
  47. package/src/ui/input.tsx +123 -8
  48. package/src/ui/vim-motions.ts +96 -0
  49. package/src/workflow/budget.ts +33 -0
  50. package/src/workflow/journal.ts +105 -0
  51. package/src/workflow/primitives/agent.ts +51 -0
  52. package/src/workflow/primitives/parallel.ts +8 -0
  53. package/src/workflow/primitives/phase.ts +19 -0
  54. package/src/workflow/primitives/pipeline.ts +24 -0
  55. package/src/workflow/runtime.ts +87 -0
  56. package/src/workflow/sandbox.ts +75 -0
@@ -1,4 +1,9 @@
1
1
  import type { Message } from '../shared/index.ts'
2
+ import { snipMessages } from './context-snip'
3
+ import { microcompact } from './context-microcompact'
4
+ import { reactiveCompact } from './context-compact'
5
+ import { emergencyDrain } from './context-drain'
6
+ import { NoopCacheTracker, type CacheTracker, type CacheStatus } from './context-token'
2
7
 
3
8
  export type Summarizer = (messages: Message[], heading: string) => Promise<string>
4
9
 
@@ -7,6 +12,17 @@ interface ContextConfig {
7
12
  compactionThreshold: number // e.g. 0.9 → compact at 90% usage
8
13
  }
9
14
 
15
+ export interface CompactionStats {
16
+ snipCount: number
17
+ snipMessagesRemoved: number
18
+ microcompactCount: number
19
+ microcompactTokensSaved: number
20
+ compactCount: number
21
+ compactTokensSaved: number
22
+ drainCount: number
23
+ lastCompaction: Date | null
24
+ }
25
+
10
26
  interface Checkpoint {
11
27
  id: number
12
28
  messages: Message[]
@@ -23,6 +39,20 @@ export class ContextManager {
23
39
  private checkpointCounter = 0
24
40
  private summarizer?: Summarizer
25
41
 
42
+ // ── Compression state ──
43
+ private cacheTracker: CacheTracker = new NoopCacheTracker()
44
+ private compactionStats: CompactionStats = {
45
+ snipCount: 0,
46
+ snipMessagesRemoved: 0,
47
+ microcompactCount: 0,
48
+ microcompactTokensSaved: 0,
49
+ compactCount: 0,
50
+ compactTokensSaved: 0,
51
+ drainCount: 0,
52
+ lastCompaction: null,
53
+ }
54
+ private compressionPending = false
55
+
26
56
  constructor(private config: ContextConfig) {}
27
57
 
28
58
  /** Set an optional LLM summarizer for intelligent compaction. */
@@ -44,6 +74,9 @@ export class ContextManager {
44
74
  this.estimatedTokens += this.estimateTokens(
45
75
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
46
76
  )
77
+
78
+ // Auto-trigger compression checks (fire-and-forget, don't await)
79
+ this.checkCompression()
47
80
  }
48
81
 
49
82
  getMessages(): Message[] {
@@ -102,6 +135,21 @@ export class ContextManager {
102
135
  return this.messages.length
103
136
  }
104
137
 
138
+ /**
139
+ * Replace all messages atomically (used by compaction layers).
140
+ * Preserves system prompt. Does NOT trigger compaction checks.
141
+ */
142
+ replaceMessages(messages: Message[]): void {
143
+ this.messages = messages
144
+ // Re-estimate tokens
145
+ this.estimatedTokens = this.estimateTokens(this.systemPrompt)
146
+ for (const msg of messages) {
147
+ this.estimatedTokens += this.estimateTokens(
148
+ typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
149
+ )
150
+ }
151
+ }
152
+
105
153
  // ── Checkpoint / Rewind ──
106
154
 
107
155
  saveCheckpoint(label = 'auto'): number {
@@ -153,6 +201,127 @@ export class ContextManager {
153
201
  return this.checkpoints.at(-1)?.id
154
202
  }
155
203
 
204
+ // ── Cache tracker integration ──
205
+
206
+ /** Register a cache tracker for cache-aware microcompaction decisions. */
207
+ setCacheTracker(tracker: CacheTracker): void {
208
+ this.cacheTracker = tracker
209
+ }
210
+
211
+ /** Get a snapshot of the provider prompt-cache state. */
212
+ getCacheStatus(): CacheStatus {
213
+ return this.cacheTracker.getStatus()
214
+ }
215
+
216
+ // ── Compression stats ──
217
+
218
+ /** Return a copy of the current compaction statistics. */
219
+ getCompactionStats(): CompactionStats {
220
+ return { ...this.compactionStats }
221
+ }
222
+
223
+ // ── 413 recovery ──
224
+
225
+ /**
226
+ * Emergency context drain for 413 (context too large) errors.
227
+ * Progressively strips messages until the context fits.
228
+ * Returns true if recovery was possible, false if already minimal.
229
+ */
230
+ async on413Error(): Promise<boolean> {
231
+ const result = await emergencyDrain(this)
232
+ if (result) {
233
+ this.compactionStats.drainCount++
234
+ this.compactionStats.lastCompaction = new Date()
235
+ }
236
+ return result
237
+ }
238
+
239
+ // ── Forced compaction (e.g. /compact command) ──
240
+
241
+ /**
242
+ * Force compaction of the conversation history.
243
+ *
244
+ * Uses the provided summarizer, the internal summarizer, or falls back
245
+ * to emergency drain if neither is available.
246
+ */
247
+ async forceCompact(heading: string, summarizer?: Summarizer): Promise<void> {
248
+ const beforeTokens = this.estimatedTokens
249
+
250
+ if (summarizer) {
251
+ await reactiveCompact(this, summarizer, heading)
252
+ } else if (this.summarizer) {
253
+ await reactiveCompact(this, this.summarizer, heading)
254
+ } else {
255
+ // Fallback: simple truncation via drain
256
+ await emergencyDrain(this)
257
+ }
258
+
259
+ const tokensSaved = beforeTokens - this.estimatedTokens
260
+ this.compactionStats.compactCount++
261
+ if (tokensSaved > 0) {
262
+ this.compactionStats.compactTokensSaved += tokensSaved
263
+ }
264
+ this.compactionStats.lastCompaction = new Date()
265
+ }
266
+
267
+ // ── Private compression helpers ──
268
+
269
+ /**
270
+ * Check estimated token usage and auto-trigger compression.
271
+ *
272
+ * - At 70%: run microcompact (fire-and-forget)
273
+ * - At 85%: the existing needsCompaction() serves as a compact hint
274
+ */
275
+ private checkCompression(): void {
276
+ if (this.compressionPending) return
277
+
278
+ const usage = this.estimatedTokens / this.config.maxTokens
279
+
280
+ if (usage > 0.7) {
281
+ this.compressionPending = true
282
+ // Schedule microcompact asynchronously (fire-and-forget)
283
+ Promise.resolve().then(() => {
284
+ this.runMicrocompact()
285
+ this.compressionPending = false
286
+ })
287
+ }
288
+ }
289
+
290
+ /** Run snip + microcompact inline and update stats. */
291
+ private runMicrocompact(): void {
292
+ const { messages: snipped, removed } = snipMessages(this.messages)
293
+
294
+ if (removed > 0) {
295
+ this.compactionStats.snipCount++
296
+ this.compactionStats.snipMessagesRemoved += removed
297
+ }
298
+
299
+ // Microcompact: compress tool_results not needed for recent context
300
+ const keepRecent = 3
301
+ const { messages: compacted, tokensSaved } = microcompact(snipped, this.cacheTracker, {
302
+ keepRecent,
303
+ })
304
+
305
+ if (tokensSaved > 0) {
306
+ this.compactionStats.microcompactCount++
307
+ this.compactionStats.microcompactTokensSaved += tokensSaved
308
+ this.compactionStats.lastCompaction = new Date()
309
+ }
310
+
311
+ this.messages = compacted
312
+ this.reEstimateTokens()
313
+ }
314
+
315
+ /** Re-estimate tokens from system prompt + current messages. */
316
+ private reEstimateTokens(): void {
317
+ this.estimatedTokens = this.estimateTokens(this.systemPrompt)
318
+ for (const msg of this.messages) {
319
+ this.estimatedTokens += this.estimateTokens(
320
+ typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
321
+ )
322
+ }
323
+ }
324
+
156
325
  /**
157
326
  * Estimate token count for a text string.
158
327
  *
@@ -4,10 +4,19 @@ import { ContextManager } from './context'
4
4
  import { PermissionSystem } from './permission'
5
5
  import type { HookEngine } from './hooks'
6
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'
7
11
 
8
12
  export class QueryEngine {
9
13
  private hookEngine?: HookEngine
10
14
  private artifactServer?: ArtifactServer
15
+ private agentRegistry?: AgentRegistry
16
+ private agentViewManager?: AgentViewManager
17
+ private goal?: string
18
+ private maxGoalLoops = 20
19
+ private lastAssistantContent?: string
11
20
 
12
21
  constructor(
13
22
  private registry: ProviderRegistry,
@@ -26,6 +35,36 @@ export class QueryEngine {
26
35
  this.artifactServer = server
27
36
  }
28
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
+
29
68
  /** Wire LLM-based conversation summarization into the context manager. */
30
69
  setupContextSummarizer(): void {
31
70
  this.context.setSummarizer(async (messages, heading) => {
@@ -70,12 +109,30 @@ export class QueryEngine {
70
109
  }
71
110
 
72
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
+
73
130
  // Add user message to context
74
131
  this.context.addMessage({ role: 'user', content: userInput })
75
132
 
76
133
  // Check compaction before processing
77
134
  if (this.context.needsCompaction()) {
78
- await this.context.compact('conversation summary')
135
+ await this.compactWithHooks('conversation summary')
79
136
  }
80
137
 
81
138
  const systemPrompt = this.context.getSystemPrompt()
@@ -133,6 +190,20 @@ export class QueryEngine {
133
190
  return
134
191
  }
135
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
+
136
207
  // Execute any tools that were requested
137
208
  for (const toolUse of toolUses) {
138
209
  const result = await this.executeTool(toolUse.name, toolUse.input)
@@ -156,6 +227,26 @@ export class QueryEngine {
156
227
  // If tools were executed, recursively continue the conversation
157
228
  if (toolUses.length > 0) {
158
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
159
250
  }
160
251
  }
161
252
 
@@ -206,8 +297,11 @@ export class QueryEngine {
206
297
  this.context.addMessage({ role: 'assistant', content: assistantContent })
207
298
  }
208
299
 
209
- // No more tool calls — conversation complete
210
- if (toolUses.length === 0) return
300
+ // No more tool calls — fire Stop hook and potentially continue
301
+ if (toolUses.length === 0) {
302
+ yield* this.checkStopHook(signal)
303
+ return
304
+ }
211
305
 
212
306
  // Execute tools and feed results back to the model for the next turn
213
307
  for (const toolUse of toolUses) {
@@ -268,7 +362,10 @@ export class QueryEngine {
268
362
  sessionId: 'session-1',
269
363
  provider: this.registry.getActive().config.id,
270
364
  model: this.registry.getActiveModel(),
365
+ registry: this.registry,
366
+ toolRegistry: this.tools,
271
367
  artifactServer: this.artifactServer,
368
+ agentRegistry: this.agentRegistry,
272
369
  })
273
370
 
274
371
  // Run PostToolUse hooks
@@ -295,6 +392,10 @@ export class QueryEngine {
295
392
  return this.context
296
393
  }
297
394
 
395
+ getRegistry(): ProviderRegistry {
396
+ return this.registry
397
+ }
398
+
298
399
  getTools(): Map<string, ToolDefinition> {
299
400
  return this.tools
300
401
  }
@@ -302,6 +403,46 @@ export class QueryEngine {
302
403
  switchProvider(providerId: string, modelId?: string): void {
303
404
  this.registry.switchProvider(providerId, modelId)
304
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
+ }
305
446
  }
306
447
 
307
448
  function isAbortError(err: unknown): boolean {
@@ -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
+ }