@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.
- package/bin/mipham.ts +186 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -88
- 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/server.ts +31 -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 +144 -3
- 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 +11 -0
- 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 +221 -36
- package/src/index.tsx +20 -0
- 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/shared/types.ts +61 -1
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +46 -0
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- 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 +3 -0
- package/src/ui/app.tsx +10 -1
- package/src/ui/commands.ts +99 -30
- package/src/ui/input.tsx +123 -8
- 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/context.ts
CHANGED
|
@@ -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
|
*
|
package/src/core/engine.ts
CHANGED
|
@@ -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.
|
|
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 —
|
|
210
|
-
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
|
+
}
|
|
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
|
+
}
|