🛟 Updated.
f7f4a05 parent: 5f6abb3 modified
.tickets/issues/0036-hooks.yaml +517 -3 | @@ -6,9 +6,523 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T06:32:48.566Z |
| 9 | -updatedAt: 2026-09-22T21:02:05.404Z | |
| 9 | +updatedAt: 2026-09-22T21:54:11.101Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.1.1 | |
| 12 | -body: "" | |
| 11 | +milestone: v0.1.2 | |
| 12 | +body: | | |
| 13 | + ## Overview | |
| 14 | + Design a hook system for mini-me to allow extensibility at key points in the agent lifecycle | |
| 15 | + and execution flow without modifying core code. | |
| 16 | + | |
| 17 | + ## Key Requirements (from user feedback) | |
| 18 | + - Hooks can be written in **any language** (Python, Bash, Node.js, Lua, etc.) | |
| 19 | + - Hooks communicate via **stdin/stdout** with JSON payloads | |
| 20 | + - Must be able to: | |
| 21 | + 1. **Detect** patterns in user message before it reaches LLM | |
| 22 | + 2. **Modify/Retreat** the user message before sending | |
| 23 | + 3. **Stop** the turn based on detection/validation results | |
| 24 | + 4. **Add context** (additional messages, metadata) to the conversation | |
| 25 | + | |
| 26 | + ## Architecture Design | |
| 27 | + | |
| 28 | + ### 1. Hook Types (Extension Points) | |
| 29 | + | |
| 30 | + #### Lifecycle Hooks | |
| 31 | + - **preStartup**: Before configuration loading (can modify args/env) | |
| 32 | + - **postConfig**: After configuration loaded, before engine init | |
| 33 | + - **preShutdown**: Before agent exits | |
| 34 | + - **onError**: When an error occurs | |
| 35 | + | |
| 36 | + #### Generation Hooks | |
| 37 | + - **preGeneration**: Before `engine.Generate()` call - **PRIMARY FOR USER REQUIREMENTS** | |
| 38 | + - **postGeneration**: After receiving model response | |
| 39 | + - **onMessage**: When user message is received | |
| 40 | + | |
| 41 | + #### Tool Hooks | |
| 42 | + - **preToolCall**: Before executing any tool | |
| 43 | + - **postToolCall**: After tool execution completes | |
| 44 | + | |
| 45 | + #### Session Hooks | |
| 46 | + - **onSessionStart**: When new session begins | |
| 47 | + - **onSessionEnd**: When session ends | |
| 48 | + | |
| 49 | + #### Frontend Hooks | |
| 50 | + - **onTUIOpen/onTUIClose**: TUI window events | |
| 51 | + - **onACPConnect/onACPDisconnect**: ACP client events | |
| 52 | + - **onPromptMode**: When running in prompt mode | |
| 53 | + | |
| 54 | + ### 2. Implementation: stdin/stdout Communication | |
| 55 | + | |
| 56 | + All hooks execute as external processes communicating via stdin/stdout: | |
| 57 | + | |
| 58 | + ``` | |
| 59 | + mini-me | |
| 60 | + │ | |
| 61 | + ├─▶ [STDIN] → JSON payload | |
| 62 | + │ | |
| 63 | + hook_script (any language) | |
| 64 | + │ | |
| 65 | + ├─◀ [STDOUT] → JSON result | |
| 66 | + └─◀ [STDERR] → Hook's own logs (captured separately) | |
| 67 | + ``` | |
| 68 | + | |
| 69 | + **Pros**: Language agnostic, no recompilation, isolated errors, maximum flexibility | |
| 70 | + **Cons**: Process overhead, requires JSON, security considerations | |
| 71 | + | |
| 72 | + ### 3. Communication Protocol | |
| 73 | + | |
| 74 | + #### Input Format (mini-me → hook via STDIN) | |
| 75 | + ```json | |
| 76 | + { | |
| 77 | + "hook": { | |
| 78 | + "type": "preGeneration", | |
| 79 | + "timestamp": "2026-09-22T12:00:00Z", | |
| 80 | + "timeout": 5 | |
| 81 | + }, | |
| 82 | + "context": { | |
| 83 | + "session_id": "abc123", | |
| 84 | + "working_dir": "/home/user/project", | |
| 85 | + "system_prompt": "Tu es un assistant utile...", | |
| 86 | + "tools": ["bash", "read_file", "write_file"] | |
| 87 | + }, | |
| 88 | + "payload": { | |
| 89 | + "messages": [ | |
| 90 | + {"role": "system", "content": [{"type": "text", "text": "Tu es un assistant..."}]}, | |
| 91 | + {"role": "user", "content": [{"type": "text", "text": "list files"}]} | |
| 92 | + ], | |
| 93 | + "current_message": {"role": "user", "content": [{"type": "text", "text": "list files"}]}, | |
| 94 | + "current_text": "list files" | |
| 95 | + }, | |
| 96 | + "metadata": { | |
| 97 | + "hook_chain": ["securityHook", "previousHook"], | |
| 98 | + "previous_hook_result": null | |
| 99 | + } | |
| 100 | + } | |
| 101 | + ``` | |
| 102 | + | |
| 103 | + #### Output Format (hook → mini-me via STDOUT) | |
| 104 | + | |
| 105 | + **Case 1: Continue with (possibly modified) messages** | |
| 106 | + ```json | |
| 107 | + { | |
| 108 | + "action": "continue", | |
| 109 | + "messages": [ | |
| 110 | + {"role": "system", "content": [{"type": "text", "text": "System message"}]}, | |
| 111 | + {"role": "user", "content": [{"type": "text", "text": "modified prompt"}]} | |
| 112 | + ], | |
| 113 | + "metadata": {"hook_name": "myHook", "processing_time_ms": 15} | |
| 114 | + } | |
| 115 | + ``` | |
| 116 | + | |
| 117 | + **Case 2: Stop the turn** | |
| 118 | + ```json | |
| 119 | + { | |
| 120 | + "action": "stop", | |
| 121 | + "response": "⚠️ This prompt contains a forbidden pattern.", | |
| 122 | + "reason": "security_violation", | |
| 123 | + "metadata": {"blocked_pattern": "rm -rf"} | |
| 124 | + } | |
| 125 | + ``` | |
| 126 | + | |
| 127 | + **Case 3: Hook execution error** | |
| 128 | + ```json | |
| 129 | + { | |
| 130 | + "action": "error", | |
| 131 | + "error": "Failed to read context file", | |
| 132 | + "error_type": "io_error" | |
| 133 | + } | |
| 134 | + ``` | |
| 135 | + | |
| 136 | + ### 4. Hook Implementation Examples | |
| 137 | + | |
| 138 | + All hooks receive JSON via stdin, return JSON via stdout. | |
| 139 | + | |
| 140 | + #### Python: Full Feature Example (Detect + Retreat + Add Context + Stop) | |
| 141 | + ```python | |
| 142 | + #!/usr/bin/env python3 | |
| 143 | + import json, sys, re, os | |
| 144 | + | |
| 145 | + def main(): | |
| 146 | + try: | |
| 147 | + input_data = json.load(sys.stdin) | |
| 148 | + current_text = input_data["payload"]["current_text"] | |
| 149 | + messages = input_data["payload"]["messages"] | |
| 150 | + working_dir = input_data["context"]["working_dir"] | |
| 151 | + | |
| 152 | + # 1. DETECT: Block dangerous patterns | |
| 153 | + dangerous = ["rm -rf", "; sudo ", "eval ", "curl.*|.*bash"] | |
| 154 | + for pattern in dangerous: | |
| 155 | + if re.search(pattern, current_text, re.IGNORECASE): | |
| 156 | + print(json.dumps({ | |
| 157 | + "action": "stop", | |
| 158 | + "response": "⚠️ Forbidden pattern detected.", | |
| 159 | + "reason": "security_blocked", | |
| 160 | + "metadata": {"pattern": pattern, "hook_name": "security"} | |
| 161 | + })) | |
| 162 | + return | |
| 163 | + | |
| 164 | + # 2. RETREAT: Spell checking | |
| 165 | + corrected = current_text | |
| 166 | + replacements = { | |
| 167 | + r"a la\b": "à la", | |
| 168 | + r"ces ci\b": "ceci", | |
| 169 | + r"bcp\b": "beaucoup" | |
| 170 | + } | |
| 171 | + for pattern, replacement in replacements.items(): | |
| 172 | + corrected = re.sub(pattern, replacement, corrected, flags=re.IGNORECASE) | |
| 173 | + | |
| 174 | + # 3. ADD CONTEXT: Inject relevant files | |
| 175 | + new_messages = messages.copy() | |
| 176 | + if "main.go" in corrected.lower(): | |
| 177 | + try: | |
| 178 | + filepath = os.path.join(working_dir, "main.go") | |
| 179 | + with open(filepath) as f: | |
| 180 | + new_messages.insert(1, { | |
| 181 | + "role": "system", | |
| 182 | + "content": [{"type": "text", "text": f"File: main.go\n```go\n{f.read()}\n```"}] | |
| 183 | + }) | |
| 184 | + except: pass | |
| 185 | + | |
| 186 | + # Update user message with corrected text | |
| 187 | + if corrected != current_text: | |
| 188 | + new_messages[-1] = { | |
| 189 | + "role": "user", | |
| 190 | + "content": [{"type": "text", "text": corrected}] | |
| 191 | + } | |
| 192 | + | |
| 193 | + # 4. Return result | |
| 194 | + print(json.dumps({ | |
| 195 | + "action": "continue", | |
| 196 | + "messages": new_messages, | |
| 197 | + "metadata": { | |
| 198 | + "hook_name": "py_full_hook", | |
| 199 | + "corrections_made": corrected != current_text, | |
| 200 | + "context_added": len(new_messages) > len(messages) | |
| 201 | + } | |
| 202 | + })) | |
| 203 | + | |
| 204 | + except json.JSONDecodeError as e: | |
| 205 | + print(json.dumps({"action": "error", "error": f"Invalid JSON: {str(e)}", "error_type": "parse_error"})) | |
| 206 | + except Exception as e: | |
| 207 | + print(json.dumps({"action": "error", "error": str(e), "error_type": "runtime_error"})) | |
| 208 | + | |
| 209 | + if __name__ == "__main__": | |
| 210 | + main() | |
| 211 | + ``` | |
| 212 | + | |
| 213 | + #### Bash: Simple Validation + Stop | |
| 214 | + ```bash | |
| 215 | + #!/bin/bash | |
| 216 | + input=$(cat) | |
| 217 | + | |
| 218 | + current_text=$(echo "$input" | jq -r '.payload.current_text') | |
| 219 | + session_id=$(echo "$input" | jq -r '.context.session_id') | |
| 220 | + | |
| 221 | + # 1. DETECT: Empty message | |
| 222 | + if [ -z "$current_text" ] || [ "$current_text" = " " ]; then | |
| 223 | + jq -n \ | |
| 224 | + --arg response "⚠️ Empty message, please provide a prompt." \ | |
| 225 | + --arg reason "empty_prompt" \ | |
| 226 | + '{action: "stop", response: $response, reason: $reason, metadata: {hook_name: "validator"}}' | |
| 227 | + exit 0 | |
| 228 | + fi | |
| 229 | + | |
| 230 | + # 2. VALIDATE: Minimum length | |
| 231 | + min_length=3 | |
| 232 | + if [ ${#current_text} -lt $min_length ]; then | |
| 233 | + jq -n \ | |
| 234 | + --arg response "⚠️ Message too short (min $min_length characters)." \ | |
| 235 | + --arg reason "too_short" \ | |
| 236 | + '{action: "stop", response: $response, reason: $reason, metadata: {min_length: '"$min_length"', hook_name: "validator"}}' | |
| 237 | + exit 0 | |
| 238 | + fi | |
| 239 | + | |
| 240 | + # 3. Continue with original messages | |
| 241 | + jq -n --argjson messages "$(echo "$input" | jq '.payload.messages')" '{ | |
| 242 | + action: "continue", | |
| 243 | + messages: $messages, | |
| 244 | + metadata: {hook_name: "validator", validated: true} | |
| 245 | + }' | |
| 246 | + ``` | |
| 247 | + | |
| 248 | + ### 5. Configuration Schema | |
| 249 | + | |
| 250 | + ```yaml | |
| 251 | + hooks: | |
| 252 | + # Security settings for ALL hooks | |
| 253 | + security: | |
| 254 | + allowedPaths: | |
| 255 | + - /usr/local/bin/mm-hooks | |
| 256 | + - /home/user/.mm/hooks | |
| 257 | + - /opt/mm/hooks | |
| 258 | + allowedExtensions: | |
| 259 | + - .sh | |
| 260 | + - .py | |
| 261 | + - .js | |
| 262 | + - .lua | |
| 263 | + - .rb | |
| 264 | + - "" # Allow scripts without extension | |
| 265 | + forbiddenExtensions: | |
| 266 | + - .go # Prevent compiled Go hooks | |
| 267 | + defaultTimeout: 5s | |
| 268 | + maxOutputSize: 10240 # 10KB max per hook | |
| 269 | + | |
| 270 | + # Hook definitions by type | |
| 271 | + preGeneration: | |
| 272 | + # Priority 10 = highest (executes first) | |
| 273 | + - command: /path/to/security.py | |
| 274 | + priority: 10 | |
| 275 | + mode: sync | |
| 276 | + onError: fail | |
| 277 | + timeout: 3s | |
| 278 | + | |
| 279 | + # Priority 20 = second | |
| 280 | + - command: /path/to/spellcheck.py | |
| 281 | + priority: 20 | |
| 282 | + mode: sync | |
| 283 | + onError: warn | |
| 284 | + timeout: 2s | |
| 285 | + | |
| 286 | + # Priority 30 = third | |
| 287 | + - command: /path/to/context-injector.sh | |
| 288 | + priority: 30 | |
| 289 | + mode: sync | |
| 290 | + onError: warn | |
| 291 | + timeout: 3s | |
| 292 | + | |
| 293 | + # Priority 40 = fourth | |
| 294 | + - command: /path/to/validator.py | |
| 295 | + priority: 40 | |
| 296 | + mode: sync | |
| 297 | + onError: fail | |
| 298 | + timeout: 2s | |
| 299 | + | |
| 300 | + # Priority 100 = last (async, non-blocking) | |
| 301 | + - command: /path/to/logger.sh | |
| 302 | + priority: 100 | |
| 303 | + mode: async | |
| 304 | + onError: ignore | |
| 305 | + timeout: 5s | |
| 306 | + ``` | |
| 307 | + | |
| 308 | + **Priority System**: Lower number = higher priority = executes first | |
| 309 | + | |
| 310 | + ### 6. Error Handling and Execution Modes | |
| 311 | + | |
| 312 | + | Mode | Blocking | Error Behavior | Use Case | | |
| 313 | + |------|----------|----------------|----------| | |
| 314 | + | `sync` | Yes | According to `onError` (fail/warn/ignore) | Critical hooks | | |
| 315 | + | `async` | No | Always log, never block | Non-critical hooks | | |
| 316 | + | |
| 317 | + **onError values:** | |
| 318 | + - `fail`: Stop execution, return error to user | |
| 319 | + - `warn`: Log warning, continue execution | |
| 320 | + - `ignore`: Silent continue | |
| 321 | + | |
| 322 | + **Exit codes from hooks:** | |
| 323 | + - `0`: Success | |
| 324 | + - `1`: JSON parse error | |
| 325 | + - `2`: Runtime error | |
| 326 | + - `143`: Timeout (SIGTERM) | |
| 327 | + | |
| 328 | + ### 7. Execution Flow | |
| 329 | + | |
| 330 | + ``` | |
| 331 | + User Input: "delete all files" | |
| 332 | + │ | |
| 333 | + ▼ | |
| 334 | + ┌─────────────────────────────────────────┐ | |
| 335 | + │ mini-me: Build JSON payload │ | |
| 336 | + │ {type: "preGeneration", current_text: ...}│ | |
| 337 | + └───────────────────────┬─────────────────┘ | |
| 338 | + │ | |
| 339 | + ▼ | |
| 340 | + ┌─────────────────────────────────────────┐ | |
| 341 | + │ mini-me: Execute hook #1 (security.py) │ | |
| 342 | + │ ├─ stdin: JSON payload │ | |
| 343 | + │ └─ stdout: JSON result │ | |
| 344 | + └───────────────────────┬─────────────────┘ | |
| 345 | + │ | |
| 346 | + ▼ | |
| 347 | + ┌─────────────────────────────────────────┐ | |
| 348 | + │ Hook 1 checks for patterns │ | |
| 349 | + │ if "rm -rf" found: │ | |
| 350 | + │ return {"action": "stop", ...} │ | |
| 351 | + │ else: │ | |
| 352 | + │ return {"action": "continue", ...} │ | |
| 353 | + └───────────────────────┬─────────────────┘ | |
| 354 | + │ | |
| 355 | + ┌───────────────┴───────────────┐ | |
| 356 | + ▼ ▼ | |
| 357 | + ┌─────────────────┐ ┌─────────────────┐ | |
| 358 | + │ Hook #2 │ │ STOP TURN │ | |
| 359 | + │ (spellcheck) │ │ Return response │ | |
| 360 | + └─────────────────┘ └─────────────────┘ | |
| 361 | + │ | |
| 362 | + ▼ | |
| 363 | + ┌─────────────────────────┐ | |
| 364 | + │ Hook #3 (context) │ | |
| 365 | + └───────────────────┬───────┘ | |
| 366 | + │ | |
| 367 | + ▼ | |
| 368 | + ┌───────────────────┐ | |
| 369 | + │ Hook #4 (validate)│ | |
| 370 | + └───────────────────┘ | |
| 371 | + │ | |
| 372 | + ▼ | |
| 373 | + ┌─────────────────────────────────────────┐ | |
| 374 | + │ engine.Generate() with final messages │ | |
| 375 | + │ (potentially modified by hooks) │ | |
| 376 | + └─────────────────────────────────────────┘ | |
| 377 | + ``` | |
| 378 | + | |
| 379 | + ### 8. Security Considerations | |
| 380 | + | |
| 381 | + | Risk | Mitigation | | |
| 382 | + |------|------------| | |
| 383 | + | Malicious hook script | Allowlist of executable paths, allowed extensions | | |
| 384 | + | Blocking hook (sync mode) | Configurable timeout (default: 5s) | | |
| 385 | + | Resource exhaustion | Max output size limit (default: 10KB) | | |
| 386 | + | Hook crashes | Process isolation, stderr captured separately | | |
| 387 | + | Sensitive data in hooks | Hooks receive only configured payload fields | | |
| 388 | + | |
| 389 | + ### 9. Implementation Sketch (Go) | |
| 390 | + | |
| 391 | + ```go | |
| 392 | + // internal/hooks/runner.go | |
| 393 | + | |
| 394 | + type HookResult struct { | |
| 395 | + Action string `json:"action"` // continue, stop, error | |
| 396 | + Messages []*ai.Message `json:"messages,omitempty"` | |
| 397 | + Response string `json:"response,omitempty"` | |
| 398 | + Reason string `json:"reason,omitempty"` | |
| 399 | + Error string `json:"error,omitempty"` | |
| 400 | + ErrorType string `json:"error_type,omitempty"` | |
| 401 | + Metadata map[string]interface{} `json:"metadata,omitempty"` | |
| 402 | + } | |
| 403 | + | |
| 404 | + type HookStopResult struct { | |
| 405 | + Response string | |
| 406 | + Reason string | |
| 407 | + } | |
| 408 | + | |
| 409 | + func (r *HookRunner) RunPreGenerationHooks( | |
| 410 | + ctx context.Context, | |
| 411 | + messages []*ai.Message, | |
| 412 | + system string, | |
| 413 | + tools []ai.ToolRef, | |
| 414 | + sessionID string, | |
| 415 | + workingDir string, | |
| 416 | + ) ([]*ai.Message, *HookStopResult, error) { | |
| 417 | + | |
| 418 | + payload := PreGenerationPayload{ | |
| 419 | + Hook: HookInfo{Type: "preGeneration", Timestamp: time.Now()}, | |
| 420 | + Context: HookContext{ | |
| 421 | + SessionID: sessionID, | |
| 422 | + WorkingDir: workingDir, | |
| 423 | + System: system, | |
| 424 | + Tools: tools, | |
| 425 | + }, | |
| 426 | + Payload: PreGenerationPayloadData{ | |
| 427 | + Messages: messages, | |
| 428 | + CurrentMessage: messages[len(messages)-1], | |
| 429 | + CurrentText: messages[len(messages)-1].Content[0].Text, | |
| 430 | + }, | |
| 431 | + } | |
| 432 | + | |
| 433 | + jsonInput, _ := json.Marshal(payload) | |
| 434 | + hooks := r.getSortedHooks(HookTypePreGeneration) | |
| 435 | + var finalMessages = messages | |
| 436 | + | |
| 437 | + for _, hook := range hooks { | |
| 438 | + result, err := r.runExternalHook(ctx, hook, jsonInput) | |
| 439 | + if err != nil { | |
| 440 | + if hook.Mode == "sync" && hook.OnError == "fail" { | |
| 441 | + return nil, nil, err | |
| 442 | + } | |
| 443 | + log.Printf("[hooks] %s error: %v", hook.Command, err) | |
| 444 | + continue | |
| 445 | + } | |
| 446 | + | |
| 447 | + switch result.Action { | |
| 448 | + case "continue": | |
| 449 | + finalMessages = result.Messages | |
| 450 | + case "stop": | |
| 451 | + return nil, &HookStopResult{ | |
| 452 | + Response: result.Response, | |
| 453 | + Reason: result.Reason, | |
| 454 | + }, nil | |
| 455 | + case "error": | |
| 456 | + if hook.Mode == "sync" && hook.OnError == "fail" { | |
| 457 | + return nil, nil, fmt.Errorf("hook error: %s", result.Error) | |
| 458 | + } | |
| 459 | + } | |
| 460 | + } | |
| 461 | + | |
| 462 | + return finalMessages, nil, nil | |
| 463 | + } | |
| 464 | + | |
| 465 | + func (r *HookRunner) runExternalHook( | |
| 466 | + ctx context.Context, | |
| 467 | + hook HookConfig, | |
| 468 | + input []byte, | |
| 469 | + ) (HookResult, error) { | |
| 470 | + | |
| 471 | + cmd := exec.CommandContext(ctx, hook.Command) | |
| 472 | + cmd.Stdin = bytes.NewReader(input) | |
| 473 | + | |
| 474 | + var stdoutBuf, stderrBuf bytes.Buffer | |
| 475 | + cmd.Stdout = &stdoutBuf | |
| 476 | + cmd.Stderr = &stderrBuf | |
| 477 | + | |
| 478 | + err := cmd.Run() | |
| 479 | + | |
| 480 | + if err != nil { | |
| 481 | + return HookResult{}, fmt.Errorf("hook %s failed: %v (stderr: %s)", | |
| 482 | + hook.Command, err, stderrBuf.String()) | |
| 483 | + } | |
| 484 | + | |
| 485 | + var result HookResult | |
| 486 | + if err := json.Unmarshal(stdoutBuf.Bytes(), &result); err != nil { | |
| 487 | + return HookResult{}, fmt.Errorf("hook %s returned invalid JSON: %v", | |
| 488 | + hook.Command, err) | |
| 489 | + } | |
| 490 | + | |
| 491 | + return result, nil | |
| 492 | + } | |
| 493 | + ``` | |
| 494 | + | |
| 495 | + ### 10. Summary: How User Requirements Are Met | |
| 496 | + | |
| 497 | + | Requirement | Mechanism | Example | | |
| 498 | + |------------|-----------|---------| | |
| 499 | + | **Detect** patterns | Analyze `payload.current_text` in preGeneration hook | Block "rm -rf" | | |
| 500 | + | **Modify/Retreat** message | Return modified `messages` array | Fix spelling, reformat | | |
| 501 | + | **Add context** | Insert system messages into `messages` | Inject file contents | | |
| 502 | + | **Stop turn** | Return `{action: "stop", response: "..."}` | Validation failed | | |
| 503 | + | |
| 504 | + All capabilities work via **stdin/stdout JSON communication** with hooks in **any language**. | |
| 505 | + | |
| 506 | + ### 11. Open Questions | |
| 507 | + | |
| 508 | + 1. Should hooks have access to full conversation history (beyond current turn)? | |
| 509 | + - Current: Only current turn messages | |
| 510 | + - Option: Add `conversation_history` with configurable depth | |
| 511 | + | |
| 512 | + 2. Should hooks be able to persist state between calls? | |
| 513 | + - Current: Stateless | |
| 514 | + - Option: Designated state directory for hooks | |
| 515 | + | |
| 516 | + 3. Should hooks be able to execute tools? | |
| 517 | + - Risk: Security | |
| 518 | + - Suggestion: No, hooks should only observe/modify data | |
| 519 | + | |
| 520 | + 4. Should there be a total timeout for all hooks of a type? | |
| 521 | + - Current: Per-hook timeout | |
| 522 | + - Option: Add `totalTimeout` for hook chains | |
| 523 | + | |
| 524 | + 5. Should hooks be able to modify tool configurations? | |
| 525 | + - Current: Tools are read-only | |
| 526 | + - Suggestion: No, for security | |
| 13 | 527 | tasks: [] |
| 14 | 528 | comments: [] |
| @@ -6,9 +6,523 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T06:32:48.566Z | 8 | createdAt: 2026-09-21T06:32:48.566Z |
| 9 | -updatedAt: 2026-09-22T21:02:05.404Z | 9 | +updatedAt: 2026-09-22T21:54:11.101Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.1.1 | 11 | +milestone: v0.1.2 |
| 12 | -body: "" | 12 | +body: | |
| 13 | + ## Overview | ||
| 14 | + Design a hook system for mini-me to allow extensibility at key points in the agent lifecycle | ||
| 15 | + and execution flow without modifying core code. | ||
| 16 | + | ||
| 17 | + ## Key Requirements (from user feedback) | ||
| 18 | + - Hooks can be written in **any language** (Python, Bash, Node.js, Lua, etc.) | ||
| 19 | + - Hooks communicate via **stdin/stdout** with JSON payloads | ||
| 20 | + - Must be able to: | ||
| 21 | + 1. **Detect** patterns in user message before it reaches LLM | ||
| 22 | + 2. **Modify/Retreat** the user message before sending | ||
| 23 | + 3. **Stop** the turn based on detection/validation results | ||
| 24 | + 4. **Add context** (additional messages, metadata) to the conversation | ||
| 25 | + | ||
| 26 | + ## Architecture Design | ||
| 27 | + | ||
| 28 | + ### 1. Hook Types (Extension Points) | ||
| 29 | + | ||
| 30 | + #### Lifecycle Hooks | ||
| 31 | + - **preStartup**: Before configuration loading (can modify args/env) | ||
| 32 | + - **postConfig**: After configuration loaded, before engine init | ||
| 33 | + - **preShutdown**: Before agent exits | ||
| 34 | + - **onError**: When an error occurs | ||
| 35 | + | ||
| 36 | + #### Generation Hooks | ||
| 37 | + - **preGeneration**: Before `engine.Generate()` call - **PRIMARY FOR USER REQUIREMENTS** | ||
| 38 | + - **postGeneration**: After receiving model response | ||
| 39 | + - **onMessage**: When user message is received | ||
| 40 | + | ||
| 41 | + #### Tool Hooks | ||
| 42 | + - **preToolCall**: Before executing any tool | ||
| 43 | + - **postToolCall**: After tool execution completes | ||
| 44 | + | ||
| 45 | + #### Session Hooks | ||
| 46 | + - **onSessionStart**: When new session begins | ||
| 47 | + - **onSessionEnd**: When session ends | ||
| 48 | + | ||
| 49 | + #### Frontend Hooks | ||
| 50 | + - **onTUIOpen/onTUIClose**: TUI window events | ||
| 51 | + - **onACPConnect/onACPDisconnect**: ACP client events | ||
| 52 | + - **onPromptMode**: When running in prompt mode | ||
| 53 | + | ||
| 54 | + ### 2. Implementation: stdin/stdout Communication | ||
| 55 | + | ||
| 56 | + All hooks execute as external processes communicating via stdin/stdout: | ||
| 57 | + | ||
| 58 | + ``` | ||
| 59 | + mini-me | ||
| 60 | + │ | ||
| 61 | + ├─▶ [STDIN] → JSON payload | ||
| 62 | + │ | ||
| 63 | + hook_script (any language) | ||
| 64 | + │ | ||
| 65 | + ├─◀ [STDOUT] → JSON result | ||
| 66 | + └─◀ [STDERR] → Hook's own logs (captured separately) | ||
| 67 | + ``` | ||
| 68 | + | ||
| 69 | + **Pros**: Language agnostic, no recompilation, isolated errors, maximum flexibility | ||
| 70 | + **Cons**: Process overhead, requires JSON, security considerations | ||
| 71 | + | ||
| 72 | + ### 3. Communication Protocol | ||
| 73 | + | ||
| 74 | + #### Input Format (mini-me → hook via STDIN) | ||
| 75 | + ```json | ||
| 76 | + { | ||
| 77 | + "hook": { | ||
| 78 | + "type": "preGeneration", | ||
| 79 | + "timestamp": "2026-09-22T12:00:00Z", | ||
| 80 | + "timeout": 5 | ||
| 81 | + }, | ||
| 82 | + "context": { | ||
| 83 | + "session_id": "abc123", | ||
| 84 | + "working_dir": "/home/user/project", | ||
| 85 | + "system_prompt": "Tu es un assistant utile...", | ||
| 86 | + "tools": ["bash", "read_file", "write_file"] | ||
| 87 | + }, | ||
| 88 | + "payload": { | ||
| 89 | + "messages": [ | ||
| 90 | + {"role": "system", "content": [{"type": "text", "text": "Tu es un assistant..."}]}, | ||
| 91 | + {"role": "user", "content": [{"type": "text", "text": "list files"}]} | ||
| 92 | + ], | ||
| 93 | + "current_message": {"role": "user", "content": [{"type": "text", "text": "list files"}]}, | ||
| 94 | + "current_text": "list files" | ||
| 95 | + }, | ||
| 96 | + "metadata": { | ||
| 97 | + "hook_chain": ["securityHook", "previousHook"], | ||
| 98 | + "previous_hook_result": null | ||
| 99 | + } | ||
| 100 | + } | ||
| 101 | + ``` | ||
| 102 | + | ||
| 103 | + #### Output Format (hook → mini-me via STDOUT) | ||
| 104 | + | ||
| 105 | + **Case 1: Continue with (possibly modified) messages** | ||
| 106 | + ```json | ||
| 107 | + { | ||
| 108 | + "action": "continue", | ||
| 109 | + "messages": [ | ||
| 110 | + {"role": "system", "content": [{"type": "text", "text": "System message"}]}, | ||
| 111 | + {"role": "user", "content": [{"type": "text", "text": "modified prompt"}]} | ||
| 112 | + ], | ||
| 113 | + "metadata": {"hook_name": "myHook", "processing_time_ms": 15} | ||
| 114 | + } | ||
| 115 | + ``` | ||
| 116 | + | ||
| 117 | + **Case 2: Stop the turn** | ||
| 118 | + ```json | ||
| 119 | + { | ||
| 120 | + "action": "stop", | ||
| 121 | + "response": "⚠️ This prompt contains a forbidden pattern.", | ||
| 122 | + "reason": "security_violation", | ||
| 123 | + "metadata": {"blocked_pattern": "rm -rf"} | ||
| 124 | + } | ||
| 125 | + ``` | ||
| 126 | + | ||
| 127 | + **Case 3: Hook execution error** | ||
| 128 | + ```json | ||
| 129 | + { | ||
| 130 | + "action": "error", | ||
| 131 | + "error": "Failed to read context file", | ||
| 132 | + "error_type": "io_error" | ||
| 133 | + } | ||
| 134 | + ``` | ||
| 135 | + | ||
| 136 | + ### 4. Hook Implementation Examples | ||
| 137 | + | ||
| 138 | + All hooks receive JSON via stdin, return JSON via stdout. | ||
| 139 | + | ||
| 140 | + #### Python: Full Feature Example (Detect + Retreat + Add Context + Stop) | ||
| 141 | + ```python | ||
| 142 | + #!/usr/bin/env python3 | ||
| 143 | + import json, sys, re, os | ||
| 144 | + | ||
| 145 | + def main(): | ||
| 146 | + try: | ||
| 147 | + input_data = json.load(sys.stdin) | ||
| 148 | + current_text = input_data["payload"]["current_text"] | ||
| 149 | + messages = input_data["payload"]["messages"] | ||
| 150 | + working_dir = input_data["context"]["working_dir"] | ||
| 151 | + | ||
| 152 | + # 1. DETECT: Block dangerous patterns | ||
| 153 | + dangerous = ["rm -rf", "; sudo ", "eval ", "curl.*|.*bash"] | ||
| 154 | + for pattern in dangerous: | ||
| 155 | + if re.search(pattern, current_text, re.IGNORECASE): | ||
| 156 | + print(json.dumps({ | ||
| 157 | + "action": "stop", | ||
| 158 | + "response": "⚠️ Forbidden pattern detected.", | ||
| 159 | + "reason": "security_blocked", | ||
| 160 | + "metadata": {"pattern": pattern, "hook_name": "security"} | ||
| 161 | + })) | ||
| 162 | + return | ||
| 163 | + | ||
| 164 | + # 2. RETREAT: Spell checking | ||
| 165 | + corrected = current_text | ||
| 166 | + replacements = { | ||
| 167 | + r"a la\b": "à la", | ||
| 168 | + r"ces ci\b": "ceci", | ||
| 169 | + r"bcp\b": "beaucoup" | ||
| 170 | + } | ||
| 171 | + for pattern, replacement in replacements.items(): | ||
| 172 | + corrected = re.sub(pattern, replacement, corrected, flags=re.IGNORECASE) | ||
| 173 | + | ||
| 174 | + # 3. ADD CONTEXT: Inject relevant files | ||
| 175 | + new_messages = messages.copy() | ||
| 176 | + if "main.go" in corrected.lower(): | ||
| 177 | + try: | ||
| 178 | + filepath = os.path.join(working_dir, "main.go") | ||
| 179 | + with open(filepath) as f: | ||
| 180 | + new_messages.insert(1, { | ||
| 181 | + "role": "system", | ||
| 182 | + "content": [{"type": "text", "text": f"File: main.go\n```go\n{f.read()}\n```"}] | ||
| 183 | + }) | ||
| 184 | + except: pass | ||
| 185 | + | ||
| 186 | + # Update user message with corrected text | ||
| 187 | + if corrected != current_text: | ||
| 188 | + new_messages[-1] = { | ||
| 189 | + "role": "user", | ||
| 190 | + "content": [{"type": "text", "text": corrected}] | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + # 4. Return result | ||
| 194 | + print(json.dumps({ | ||
| 195 | + "action": "continue", | ||
| 196 | + "messages": new_messages, | ||
| 197 | + "metadata": { | ||
| 198 | + "hook_name": "py_full_hook", | ||
| 199 | + "corrections_made": corrected != current_text, | ||
| 200 | + "context_added": len(new_messages) > len(messages) | ||
| 201 | + } | ||
| 202 | + })) | ||
| 203 | + | ||
| 204 | + except json.JSONDecodeError as e: | ||
| 205 | + print(json.dumps({"action": "error", "error": f"Invalid JSON: {str(e)}", "error_type": "parse_error"})) | ||
| 206 | + except Exception as e: | ||
| 207 | + print(json.dumps({"action": "error", "error": str(e), "error_type": "runtime_error"})) | ||
| 208 | + | ||
| 209 | + if __name__ == "__main__": | ||
| 210 | + main() | ||
| 211 | + ``` | ||
| 212 | + | ||
| 213 | + #### Bash: Simple Validation + Stop | ||
| 214 | + ```bash | ||
| 215 | + #!/bin/bash | ||
| 216 | + input=$(cat) | ||
| 217 | + | ||
| 218 | + current_text=$(echo "$input" | jq -r '.payload.current_text') | ||
| 219 | + session_id=$(echo "$input" | jq -r '.context.session_id') | ||
| 220 | + | ||
| 221 | + # 1. DETECT: Empty message | ||
| 222 | + if [ -z "$current_text" ] || [ "$current_text" = " " ]; then | ||
| 223 | + jq -n \ | ||
| 224 | + --arg response "⚠️ Empty message, please provide a prompt." \ | ||
| 225 | + --arg reason "empty_prompt" \ | ||
| 226 | + '{action: "stop", response: $response, reason: $reason, metadata: {hook_name: "validator"}}' | ||
| 227 | + exit 0 | ||
| 228 | + fi | ||
| 229 | + | ||
| 230 | + # 2. VALIDATE: Minimum length | ||
| 231 | + min_length=3 | ||
| 232 | + if [ ${#current_text} -lt $min_length ]; then | ||
| 233 | + jq -n \ | ||
| 234 | + --arg response "⚠️ Message too short (min $min_length characters)." \ | ||
| 235 | + --arg reason "too_short" \ | ||
| 236 | + '{action: "stop", response: $response, reason: $reason, metadata: {min_length: '"$min_length"', hook_name: "validator"}}' | ||
| 237 | + exit 0 | ||
| 238 | + fi | ||
| 239 | + | ||
| 240 | + # 3. Continue with original messages | ||
| 241 | + jq -n --argjson messages "$(echo "$input" | jq '.payload.messages')" '{ | ||
| 242 | + action: "continue", | ||
| 243 | + messages: $messages, | ||
| 244 | + metadata: {hook_name: "validator", validated: true} | ||
| 245 | + }' | ||
| 246 | + ``` | ||
| 247 | + | ||
| 248 | + ### 5. Configuration Schema | ||
| 249 | + | ||
| 250 | + ```yaml | ||
| 251 | + hooks: | ||
| 252 | + # Security settings for ALL hooks | ||
| 253 | + security: | ||
| 254 | + allowedPaths: | ||
| 255 | + - /usr/local/bin/mm-hooks | ||
| 256 | + - /home/user/.mm/hooks | ||
| 257 | + - /opt/mm/hooks | ||
| 258 | + allowedExtensions: | ||
| 259 | + - .sh | ||
| 260 | + - .py | ||
| 261 | + - .js | ||
| 262 | + - .lua | ||
| 263 | + - .rb | ||
| 264 | + - "" # Allow scripts without extension | ||
| 265 | + forbiddenExtensions: | ||
| 266 | + - .go # Prevent compiled Go hooks | ||
| 267 | + defaultTimeout: 5s | ||
| 268 | + maxOutputSize: 10240 # 10KB max per hook | ||
| 269 | + | ||
| 270 | + # Hook definitions by type | ||
| 271 | + preGeneration: | ||
| 272 | + # Priority 10 = highest (executes first) | ||
| 273 | + - command: /path/to/security.py | ||
| 274 | + priority: 10 | ||
| 275 | + mode: sync | ||
| 276 | + onError: fail | ||
| 277 | + timeout: 3s | ||
| 278 | + | ||
| 279 | + # Priority 20 = second | ||
| 280 | + - command: /path/to/spellcheck.py | ||
| 281 | + priority: 20 | ||
| 282 | + mode: sync | ||
| 283 | + onError: warn | ||
| 284 | + timeout: 2s | ||
| 285 | + | ||
| 286 | + # Priority 30 = third | ||
| 287 | + - command: /path/to/context-injector.sh | ||
| 288 | + priority: 30 | ||
| 289 | + mode: sync | ||
| 290 | + onError: warn | ||
| 291 | + timeout: 3s | ||
| 292 | + | ||
| 293 | + # Priority 40 = fourth | ||
| 294 | + - command: /path/to/validator.py | ||
| 295 | + priority: 40 | ||
| 296 | + mode: sync | ||
| 297 | + onError: fail | ||
| 298 | + timeout: 2s | ||
| 299 | + | ||
| 300 | + # Priority 100 = last (async, non-blocking) | ||
| 301 | + - command: /path/to/logger.sh | ||
| 302 | + priority: 100 | ||
| 303 | + mode: async | ||
| 304 | + onError: ignore | ||
| 305 | + timeout: 5s | ||
| 306 | + ``` | ||
| 307 | + | ||
| 308 | + **Priority System**: Lower number = higher priority = executes first | ||
| 309 | + | ||
| 310 | + ### 6. Error Handling and Execution Modes | ||
| 311 | + | ||
| 312 | + | Mode | Blocking | Error Behavior | Use Case | | ||
| 313 | + |------|----------|----------------|----------| | ||
| 314 | + | `sync` | Yes | According to `onError` (fail/warn/ignore) | Critical hooks | | ||
| 315 | + | `async` | No | Always log, never block | Non-critical hooks | | ||
| 316 | + | ||
| 317 | + **onError values:** | ||
| 318 | + - `fail`: Stop execution, return error to user | ||
| 319 | + - `warn`: Log warning, continue execution | ||
| 320 | + - `ignore`: Silent continue | ||
| 321 | + | ||
| 322 | + **Exit codes from hooks:** | ||
| 323 | + - `0`: Success | ||
| 324 | + - `1`: JSON parse error | ||
| 325 | + - `2`: Runtime error | ||
| 326 | + - `143`: Timeout (SIGTERM) | ||
| 327 | + | ||
| 328 | + ### 7. Execution Flow | ||
| 329 | + | ||
| 330 | + ``` | ||
| 331 | + User Input: "delete all files" | ||
| 332 | + │ | ||
| 333 | + ▼ | ||
| 334 | + ┌─────────────────────────────────────────┐ | ||
| 335 | + │ mini-me: Build JSON payload │ | ||
| 336 | + │ {type: "preGeneration", current_text: ...}│ | ||
| 337 | + └───────────────────────┬─────────────────┘ | ||
| 338 | + │ | ||
| 339 | + ▼ | ||
| 340 | + ┌─────────────────────────────────────────┐ | ||
| 341 | + │ mini-me: Execute hook #1 (security.py) │ | ||
| 342 | + │ ├─ stdin: JSON payload │ | ||
| 343 | + │ └─ stdout: JSON result │ | ||
| 344 | + └───────────────────────┬─────────────────┘ | ||
| 345 | + │ | ||
| 346 | + ▼ | ||
| 347 | + ┌─────────────────────────────────────────┐ | ||
| 348 | + │ Hook 1 checks for patterns │ | ||
| 349 | + │ if "rm -rf" found: │ | ||
| 350 | + │ return {"action": "stop", ...} │ | ||
| 351 | + │ else: │ | ||
| 352 | + │ return {"action": "continue", ...} │ | ||
| 353 | + └───────────────────────┬─────────────────┘ | ||
| 354 | + │ | ||
| 355 | + ┌───────────────┴───────────────┐ | ||
| 356 | + ▼ ▼ | ||
| 357 | + ┌─────────────────┐ ┌─────────────────┐ | ||
| 358 | + │ Hook #2 │ │ STOP TURN │ | ||
| 359 | + │ (spellcheck) │ │ Return response │ | ||
| 360 | + └─────────────────┘ └─────────────────┘ | ||
| 361 | + │ | ||
| 362 | + ▼ | ||
| 363 | + ┌─────────────────────────┐ | ||
| 364 | + │ Hook #3 (context) │ | ||
| 365 | + └───────────────────┬───────┘ | ||
| 366 | + │ | ||
| 367 | + ▼ | ||
| 368 | + ┌───────────────────┐ | ||
| 369 | + │ Hook #4 (validate)│ | ||
| 370 | + └───────────────────┘ | ||
| 371 | + │ | ||
| 372 | + ▼ | ||
| 373 | + ┌─────────────────────────────────────────┐ | ||
| 374 | + │ engine.Generate() with final messages │ | ||
| 375 | + │ (potentially modified by hooks) │ | ||
| 376 | + └─────────────────────────────────────────┘ | ||
| 377 | + ``` | ||
| 378 | + | ||
| 379 | + ### 8. Security Considerations | ||
| 380 | + | ||
| 381 | + | Risk | Mitigation | | ||
| 382 | + |------|------------| | ||
| 383 | + | Malicious hook script | Allowlist of executable paths, allowed extensions | | ||
| 384 | + | Blocking hook (sync mode) | Configurable timeout (default: 5s) | | ||
| 385 | + | Resource exhaustion | Max output size limit (default: 10KB) | | ||
| 386 | + | Hook crashes | Process isolation, stderr captured separately | | ||
| 387 | + | Sensitive data in hooks | Hooks receive only configured payload fields | | ||
| 388 | + | ||
| 389 | + ### 9. Implementation Sketch (Go) | ||
| 390 | + | ||
| 391 | + ```go | ||
| 392 | + // internal/hooks/runner.go | ||
| 393 | + | ||
| 394 | + type HookResult struct { | ||
| 395 | + Action string `json:"action"` // continue, stop, error | ||
| 396 | + Messages []*ai.Message `json:"messages,omitempty"` | ||
| 397 | + Response string `json:"response,omitempty"` | ||
| 398 | + Reason string `json:"reason,omitempty"` | ||
| 399 | + Error string `json:"error,omitempty"` | ||
| 400 | + ErrorType string `json:"error_type,omitempty"` | ||
| 401 | + Metadata map[string]interface{} `json:"metadata,omitempty"` | ||
| 402 | + } | ||
| 403 | + | ||
| 404 | + type HookStopResult struct { | ||
| 405 | + Response string | ||
| 406 | + Reason string | ||
| 407 | + } | ||
| 408 | + | ||
| 409 | + func (r *HookRunner) RunPreGenerationHooks( | ||
| 410 | + ctx context.Context, | ||
| 411 | + messages []*ai.Message, | ||
| 412 | + system string, | ||
| 413 | + tools []ai.ToolRef, | ||
| 414 | + sessionID string, | ||
| 415 | + workingDir string, | ||
| 416 | + ) ([]*ai.Message, *HookStopResult, error) { | ||
| 417 | + | ||
| 418 | + payload := PreGenerationPayload{ | ||
| 419 | + Hook: HookInfo{Type: "preGeneration", Timestamp: time.Now()}, | ||
| 420 | + Context: HookContext{ | ||
| 421 | + SessionID: sessionID, | ||
| 422 | + WorkingDir: workingDir, | ||
| 423 | + System: system, | ||
| 424 | + Tools: tools, | ||
| 425 | + }, | ||
| 426 | + Payload: PreGenerationPayloadData{ | ||
| 427 | + Messages: messages, | ||
| 428 | + CurrentMessage: messages[len(messages)-1], | ||
| 429 | + CurrentText: messages[len(messages)-1].Content[0].Text, | ||
| 430 | + }, | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + jsonInput, _ := json.Marshal(payload) | ||
| 434 | + hooks := r.getSortedHooks(HookTypePreGeneration) | ||
| 435 | + var finalMessages = messages | ||
| 436 | + | ||
| 437 | + for _, hook := range hooks { | ||
| 438 | + result, err := r.runExternalHook(ctx, hook, jsonInput) | ||
| 439 | + if err != nil { | ||
| 440 | + if hook.Mode == "sync" && hook.OnError == "fail" { | ||
| 441 | + return nil, nil, err | ||
| 442 | + } | ||
| 443 | + log.Printf("[hooks] %s error: %v", hook.Command, err) | ||
| 444 | + continue | ||
| 445 | + } | ||
| 446 | + | ||
| 447 | + switch result.Action { | ||
| 448 | + case "continue": | ||
| 449 | + finalMessages = result.Messages | ||
| 450 | + case "stop": | ||
| 451 | + return nil, &HookStopResult{ | ||
| 452 | + Response: result.Response, | ||
| 453 | + Reason: result.Reason, | ||
| 454 | + }, nil | ||
| 455 | + case "error": | ||
| 456 | + if hook.Mode == "sync" && hook.OnError == "fail" { | ||
| 457 | + return nil, nil, fmt.Errorf("hook error: %s", result.Error) | ||
| 458 | + } | ||
| 459 | + } | ||
| 460 | + } | ||
| 461 | + | ||
| 462 | + return finalMessages, nil, nil | ||
| 463 | + } | ||
| 464 | + | ||
| 465 | + func (r *HookRunner) runExternalHook( | ||
| 466 | + ctx context.Context, | ||
| 467 | + hook HookConfig, | ||
| 468 | + input []byte, | ||
| 469 | + ) (HookResult, error) { | ||
| 470 | + | ||
| 471 | + cmd := exec.CommandContext(ctx, hook.Command) | ||
| 472 | + cmd.Stdin = bytes.NewReader(input) | ||
| 473 | + | ||
| 474 | + var stdoutBuf, stderrBuf bytes.Buffer | ||
| 475 | + cmd.Stdout = &stdoutBuf | ||
| 476 | + cmd.Stderr = &stderrBuf | ||
| 477 | + | ||
| 478 | + err := cmd.Run() | ||
| 479 | + | ||
| 480 | + if err != nil { | ||
| 481 | + return HookResult{}, fmt.Errorf("hook %s failed: %v (stderr: %s)", | ||
| 482 | + hook.Command, err, stderrBuf.String()) | ||
| 483 | + } | ||
| 484 | + | ||
| 485 | + var result HookResult | ||
| 486 | + if err := json.Unmarshal(stdoutBuf.Bytes(), &result); err != nil { | ||
| 487 | + return HookResult{}, fmt.Errorf("hook %s returned invalid JSON: %v", | ||
| 488 | + hook.Command, err) | ||
| 489 | + } | ||
| 490 | + | ||
| 491 | + return result, nil | ||
| 492 | + } | ||
| 493 | + ``` | ||
| 494 | + | ||
| 495 | + ### 10. Summary: How User Requirements Are Met | ||
| 496 | + | ||
| 497 | + | Requirement | Mechanism | Example | | ||
| 498 | + |------------|-----------|---------| | ||
| 499 | + | **Detect** patterns | Analyze `payload.current_text` in preGeneration hook | Block "rm -rf" | | ||
| 500 | + | **Modify/Retreat** message | Return modified `messages` array | Fix spelling, reformat | | ||
| 501 | + | **Add context** | Insert system messages into `messages` | Inject file contents | | ||
| 502 | + | **Stop turn** | Return `{action: "stop", response: "..."}` | Validation failed | | ||
| 503 | + | ||
| 504 | + All capabilities work via **stdin/stdout JSON communication** with hooks in **any language**. | ||
| 505 | + | ||
| 506 | + ### 11. Open Questions | ||
| 507 | + | ||
| 508 | + 1. Should hooks have access to full conversation history (beyond current turn)? | ||
| 509 | + - Current: Only current turn messages | ||
| 510 | + - Option: Add `conversation_history` with configurable depth | ||
| 511 | + | ||
| 512 | + 2. Should hooks be able to persist state between calls? | ||
| 513 | + - Current: Stateless | ||
| 514 | + - Option: Designated state directory for hooks | ||
| 515 | + | ||
| 516 | + 3. Should hooks be able to execute tools? | ||
| 517 | + - Risk: Security | ||
| 518 | + - Suggestion: No, hooks should only observe/modify data | ||
| 519 | + | ||
| 520 | + 4. Should there be a total timeout for all hooks of a type? | ||
| 521 | + - Current: Per-hook timeout | ||
| 522 | + - Option: Add `totalTimeout` for hook chains | ||
| 523 | + | ||
| 524 | + 5. Should hooks be able to modify tool configurations? | ||
| 525 | + - Current: Tools are read-only | ||
| 526 | + - Suggestion: No, for security | ||
| 13 | tasks: [] | 527 | tasks: [] |
| 14 | comments: [] | 528 | comments: [] |
modified
.tickets/milestones.yaml +4 -1 | @@ -7,5 +7,8 @@ milestones: | ||
| 7 | 7 | state: closed |
| 8 | 8 | dueOn: 2026-09-30 |
| 9 | 9 | - title: v0.1.1 |
| 10 | - state: open | |
| 10 | + state: closed | |
| 11 | 11 | dueOn: 2026-10-15 |
| 12 | + - title: v0.1.2 | |
| 13 | + state: open | |
| 14 | + dueOn: 2026-10-30 | |
| @@ -7,5 +7,8 @@ milestones: | |||
| 7 | state: closed | 7 | state: closed |
| 8 | dueOn: 2026-09-30 | 8 | dueOn: 2026-09-30 |
| 9 | - title: v0.1.1 | 9 | - title: v0.1.1 |
| 10 | - state: open | 10 | + state: closed |
| 11 | dueOn: 2026-10-15 | 11 | dueOn: 2026-10-15 |
| 12 | + - title: v0.1.2 | ||
| 13 | + state: open | ||
| 14 | + dueOn: 2026-10-30 | ||