bots-garden/mini-mepublic Fork
⑂ 46087c3
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

🛟 Updated.

k33g committed 2026-10-14T20:23:29+02:00 Browse files
46087c3 parent: bbcc277
modified .tickets/issues/0036-hooks.yaml +109 -2
@@ -6,7 +6,7 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T06:32:48.566Z
9-updatedAt: 2026-10-06T13:16:31.213Z
9+updatedAt: 2026-10-14T12:06:43.000Z
1010 labels: []
1111 epic: Loop
1212 body: |
@@ -524,5 +524,112 @@ body: |
524524 5. Should hooks be able to modify tool configurations?
525525 - Current: Tools are read-only
526526 - Suggestion: No, for security
527-tasks: []
527+
528+ ## Design retained for v1 (2026-10-14)
529+
530+ The sections above are the original exploration. v1 keeps the core of that design and simplifies it. This section is what is actually implemented.
531+
532+ ### Where the hooks run
533+ All four front ends (REPL, window, ACP, `-p`) go through `engine.Generate`, and Genkit's middleware there already has a `WrapTool` that sees **every** tool call, built-in and MCP. Hooks plug in at those two points, so no front end has to change.
534+
535+ ### Events
536+ | Event | When | Can do |
537+ | --- | --- | --- |
538+ | `preGeneration` | once per user question, before the first model call | rewrite the prompt (`prompt`), add context (`context`), stop the turn (`action: stop` + `response`) |
539+ | `preToolCall` | before each tool call; optional `matcher` (regexp on the tool name) | deny the call (`action: deny` + `reason`, which the model reads as the tool's output) or replace its `input` |
540+ | `postToolCall` | after each tool call; optional `matcher` | observe; append `context` to the tool's output |
541+ | `postGeneration` | after the turn, success or failure | observe (logging, notification) |
542+
543+ ### Protocol
544+ - **stdin:** one JSON object, for example `{"event":"preToolCall","cwd":"/abs/dir","model":"…","tool":{"id":"…","name":"bash","input":{…}}}`. `preGeneration` carries `prompt`; `postToolCall` adds `output`; `postGeneration` carries `answer` and `error`.
545+ - **stdout:** one JSON object, or **nothing, which means `continue`**. A logging hook does not have to print anything.
546+ - **stderr:** shown only when the hook fails.
547+ - **Execution:** `sh -c "<command>"` in the session's working directory, with `MM_HOOK_EVENT` and `MM_CONFIG_DIR` (the YAML file's directory) in the environment, so you can write `node "$MM_CONFIG_DIR/hooks/x.js"`.
548+ - **Chain:** hooks run in list order, each one sees the previous one's rewrite, and the first `stop`/`deny` ends the chain.
549+ - **Failure** (non-zero exit, timeout, invalid JSON, `action: error`): `onError: fail | warn | ignore`, default `warn`. `fail` stops the turn or denies the tool. `timeout` defaults to 10s. Output is capped at 1 MiB.
550+ - **Stopped turn:** the history keeps `[prompt blocked by a hook: <reason>]` and the hook's response. The blocked text (a secret, for example) never reaches the model, not even on a later turn. This was the user's choice.
551+
552+ ### Configuration
553+ ```yaml
554+ hooks:
555+ preGeneration:
556+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
557+ timeout: 5s
558+ onError: fail
559+ preToolCall:
560+ - matcher: ^bash$
561+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
562+ postToolCall:
563+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
564+ postGeneration:
565+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
566+ ```
567+
568+ ### Left out of v1 (possible follow-ups)
569+ - **Lifecycle hooks** (preStartup, postConfig, preShutdown, onError), **session hooks** and **front-end hooks**: no use case yet.
570+ - **`async` mode and numeric priorities:** list order is enough, and a log hook is fast.
571+ - **Returning the full `messages` array:** fragile, since a hook could rewrite history or drop the system prompt. `prompt` + `context` cover the four requirements (detect, rewrite, add context, stop).
572+ - **`allowedPaths` / `allowedExtensions`:** the YAML already starts MCP servers and gives the model `bash`. Whoever writes the YAML is trusted, so an allowlist would protect nothing more.
573+ - On the original open questions: no hook state directory (a hook can write files itself); hooks cannot run tools; there is no total chain timeout, only a per-hook one.
574+tasks:
575+ - id: 1
576+ title: "Config: hooks block and validation"
577+ state: closed
578+ priority: high
579+ author:
580+ name: k33g
581+ email: ph.charriere@gmail.com
582+ createdAt: 2026-10-14T11:42:48.000Z
583+ updatedAt: 2026-10-14T11:44:02.000Z
584+ closedAt: 2026-10-14T11:44:02.000Z
585+ - id: 2
586+ title: "internal/hooks: process runner, protocol, chain, onError"
587+ state: closed
588+ priority: high
589+ author:
590+ name: k33g
591+ email: ph.charriere@gmail.com
592+ createdAt: 2026-10-14T11:42:48.000Z
593+ updatedAt: 2026-10-14T11:53:54.000Z
594+ closedAt: 2026-10-14T11:53:54.000Z
595+ - id: 3
596+ title: "Engine: preGeneration and postGeneration"
597+ state: closed
598+ priority: high
599+ author:
600+ name: k33g
601+ email: ph.charriere@gmail.com
602+ createdAt: 2026-10-14T11:42:48.000Z
603+ updatedAt: 2026-10-14T11:57:09.000Z
604+ closedAt: 2026-10-14T11:57:09.000Z
605+ - id: 4
606+ title: "Engine: preToolCall and postToolCall (WrapTool), display, banner"
607+ state: closed
608+ priority: high
609+ author:
610+ name: k33g
611+ email: ph.charriere@gmail.com
612+ createdAt: 2026-10-14T11:42:48.000Z
613+ updatedAt: 2026-10-14T11:57:09.000Z
614+ closedAt: 2026-10-14T11:57:09.000Z
615+ - id: 5
616+ title: "Demo demos/hooks with Node.js hooks (README EN+FR)"
617+ state: closed
618+ priority: high
619+ author:
620+ name: k33g
621+ email: ph.charriere@gmail.com
622+ createdAt: 2026-10-14T11:42:48.000Z
623+ updatedAt: 2026-10-14T12:00:05.000Z
624+ closedAt: 2026-10-14T12:00:05.000Z
625+ - id: 6
626+ title: "Quality gate, docs EN+FR, packages diagram, .memory"
627+ state: closed
628+ priority: high
629+ author:
630+ name: k33g
631+ email: ph.charriere@gmail.com
632+ createdAt: 2026-10-14T11:42:48.000Z
633+ updatedAt: 2026-10-14T12:06:43.000Z
634+ closedAt: 2026-10-14T12:06:43.000Z
528635 comments: []
@@ -6,7 +6,7 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T06:32:48.566Z8 createdAt: 2026-09-21T06:32:48.566Z
9-updatedAt: 2026-10-06T13:16:31.213Z9+updatedAt: 2026-10-14T12:06:43.000Z
10 labels: []10 labels: []
11 epic: Loop11 epic: Loop
12 body: |12 body: |
@@ -524,5 +524,112 @@ body: |
524 5. Should hooks be able to modify tool configurations?524 5. Should hooks be able to modify tool configurations?
525 - Current: Tools are read-only525 - Current: Tools are read-only
526 - Suggestion: No, for security526 - Suggestion: No, for security
527-tasks: []527+
528+ ## Design retained for v1 (2026-10-14)
529+
530+ The sections above are the original exploration. v1 keeps the core of that design and simplifies it. This section is what is actually implemented.
531+
532+ ### Where the hooks run
533+ All four front ends (REPL, window, ACP, `-p`) go through `engine.Generate`, and Genkit's middleware there already has a `WrapTool` that sees **every** tool call, built-in and MCP. Hooks plug in at those two points, so no front end has to change.
534+
535+ ### Events
536+ | Event | When | Can do |
537+ | --- | --- | --- |
538+ | `preGeneration` | once per user question, before the first model call | rewrite the prompt (`prompt`), add context (`context`), stop the turn (`action: stop` + `response`) |
539+ | `preToolCall` | before each tool call; optional `matcher` (regexp on the tool name) | deny the call (`action: deny` + `reason`, which the model reads as the tool's output) or replace its `input` |
540+ | `postToolCall` | after each tool call; optional `matcher` | observe; append `context` to the tool's output |
541+ | `postGeneration` | after the turn, success or failure | observe (logging, notification) |
542+
543+ ### Protocol
544+ - **stdin:** one JSON object, for example `{"event":"preToolCall","cwd":"/abs/dir","model":"…","tool":{"id":"…","name":"bash","input":{…}}}`. `preGeneration` carries `prompt`; `postToolCall` adds `output`; `postGeneration` carries `answer` and `error`.
545+ - **stdout:** one JSON object, or **nothing, which means `continue`**. A logging hook does not have to print anything.
546+ - **stderr:** shown only when the hook fails.
547+ - **Execution:** `sh -c "<command>"` in the session's working directory, with `MM_HOOK_EVENT` and `MM_CONFIG_DIR` (the YAML file's directory) in the environment, so you can write `node "$MM_CONFIG_DIR/hooks/x.js"`.
548+ - **Chain:** hooks run in list order, each one sees the previous one's rewrite, and the first `stop`/`deny` ends the chain.
549+ - **Failure** (non-zero exit, timeout, invalid JSON, `action: error`): `onError: fail | warn | ignore`, default `warn`. `fail` stops the turn or denies the tool. `timeout` defaults to 10s. Output is capped at 1 MiB.
550+ - **Stopped turn:** the history keeps `[prompt blocked by a hook: <reason>]` and the hook's response. The blocked text (a secret, for example) never reaches the model, not even on a later turn. This was the user's choice.
551+
552+ ### Configuration
553+ ```yaml
554+ hooks:
555+ preGeneration:
556+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
557+ timeout: 5s
558+ onError: fail
559+ preToolCall:
560+ - matcher: ^bash$
561+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
562+ postToolCall:
563+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
564+ postGeneration:
565+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
566+ ```
567+
568+ ### Left out of v1 (possible follow-ups)
569+ - **Lifecycle hooks** (preStartup, postConfig, preShutdown, onError), **session hooks** and **front-end hooks**: no use case yet.
570+ - **`async` mode and numeric priorities:** list order is enough, and a log hook is fast.
571+ - **Returning the full `messages` array:** fragile, since a hook could rewrite history or drop the system prompt. `prompt` + `context` cover the four requirements (detect, rewrite, add context, stop).
572+ - **`allowedPaths` / `allowedExtensions`:** the YAML already starts MCP servers and gives the model `bash`. Whoever writes the YAML is trusted, so an allowlist would protect nothing more.
573+ - On the original open questions: no hook state directory (a hook can write files itself); hooks cannot run tools; there is no total chain timeout, only a per-hook one.
574+tasks:
575+ - id: 1
576+ title: "Config: hooks block and validation"
577+ state: closed
578+ priority: high
579+ author:
580+ name: k33g
581+ email: ph.charriere@gmail.com
582+ createdAt: 2026-10-14T11:42:48.000Z
583+ updatedAt: 2026-10-14T11:44:02.000Z
584+ closedAt: 2026-10-14T11:44:02.000Z
585+ - id: 2
586+ title: "internal/hooks: process runner, protocol, chain, onError"
587+ state: closed
588+ priority: high
589+ author:
590+ name: k33g
591+ email: ph.charriere@gmail.com
592+ createdAt: 2026-10-14T11:42:48.000Z
593+ updatedAt: 2026-10-14T11:53:54.000Z
594+ closedAt: 2026-10-14T11:53:54.000Z
595+ - id: 3
596+ title: "Engine: preGeneration and postGeneration"
597+ state: closed
598+ priority: high
599+ author:
600+ name: k33g
601+ email: ph.charriere@gmail.com
602+ createdAt: 2026-10-14T11:42:48.000Z
603+ updatedAt: 2026-10-14T11:57:09.000Z
604+ closedAt: 2026-10-14T11:57:09.000Z
605+ - id: 4
606+ title: "Engine: preToolCall and postToolCall (WrapTool), display, banner"
607+ state: closed
608+ priority: high
609+ author:
610+ name: k33g
611+ email: ph.charriere@gmail.com
612+ createdAt: 2026-10-14T11:42:48.000Z
613+ updatedAt: 2026-10-14T11:57:09.000Z
614+ closedAt: 2026-10-14T11:57:09.000Z
615+ - id: 5
616+ title: "Demo demos/hooks with Node.js hooks (README EN+FR)"
617+ state: closed
618+ priority: high
619+ author:
620+ name: k33g
621+ email: ph.charriere@gmail.com
622+ createdAt: 2026-10-14T11:42:48.000Z
623+ updatedAt: 2026-10-14T12:00:05.000Z
624+ closedAt: 2026-10-14T12:00:05.000Z
625+ - id: 6
626+ title: "Quality gate, docs EN+FR, packages diagram, .memory"
627+ state: closed
628+ priority: high
629+ author:
630+ name: k33g
631+ email: ph.charriere@gmail.com
632+ createdAt: 2026-10-14T11:42:48.000Z
633+ updatedAt: 2026-10-14T12:06:43.000Z
634+ closedAt: 2026-10-14T12:06:43.000Z
528 comments: []635 comments: []
modified README.md +2 -0
@@ -116,6 +116,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
116116 - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md).
117117 - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md).
118118 - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md).
119+- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, and after the turn; they can deny a call, stop or rewrite a question, add context, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md) and `demos/hooks/`.
119120 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).
120121
121122 ## Demos
@@ -139,6 +140,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
139140 | `demos/codex-style/` | the same idea with Codex CLI's default instructions (preambles, plans, ambition vs. precision, final-answer format), adapted to mm and to gpt-oss 20B (harmony format, `reasoning_effort`, `model_identity`) on llmman; measured results |
140141 | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results |
141142 | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results |
143+| `demos/hooks/` | hooks in Node.js around a small shell assistant (Gemma 4 E4B on llmman): a question holding a secret never reaches the model, today's date and the file list are added to every question, `rm -r` / `sudo` / `curl \| sh` are denied, every tool call and turn is logged to `.mm/hooks-audit.jsonl` |
142144
143145 ## Learn more
144146
@@ -116,6 +116,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
116 - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md).116 - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md).
117 - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md).117 - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md).
118 - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md).118 - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md).
119+- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, and after the turn; they can deny a call, stop or rewrite a question, add context, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md) and `demos/hooks/`.
119 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).120 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).
120 121
121 ## Demos122 ## Demos
@@ -139,6 +140,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
139 | `demos/codex-style/` | the same idea with Codex CLI's default instructions (preambles, plans, ambition vs. precision, final-answer format), adapted to mm and to gpt-oss 20B (harmony format, `reasoning_effort`, `model_identity`) on llmman; measured results |140 | `demos/codex-style/` | the same idea with Codex CLI's default instructions (preambles, plans, ambition vs. precision, final-answer format), adapted to mm and to gpt-oss 20B (harmony format, `reasoning_effort`, `model_identity`) on llmman; measured results |
140 | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results |141 | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results |
141 | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results |142 | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results |
143+| `demos/hooks/` | hooks in Node.js around a small shell assistant (Gemma 4 E4B on llmman): a question holding a secret never reaches the model, today's date and the file list are added to every question, `rm -r` / `sudo` / `curl \| sh` are denied, every tool call and turn is logged to `.mm/hooks-audit.jsonl` |
142 144
143 ## Learn more145 ## Learn more
144 146
added demos/hooks/README.en.md +125 -0
new file mode 100644
@@ -0,0 +1,125 @@
1+# Hooks demo — four Node.js hooks around a small shell assistant
2+
3+*[Version française](README.fr.md)*
4+
5+A hook is a command mm runs at a fixed moment of a turn. It reads one JSON object on stdin and may answer one JSON object on stdout. This demo puts four hooks, written in Node.js, around an agent that can run shell commands. Each hook shows one thing a hook can do:
6+
7+| Hook | Event | What it shows |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preGeneration` | **stop** a turn: a question holding a password or an API key never reaches the model |
10+| `hooks/add-context.js` | `preGeneration` | **add context**: today's date and the list of files, appended to every question |
11+| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **deny** a tool call: `rm -r`, `sudo`, `curl … \| sh` never run, and the model reads why |
12+| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observe**: one JSON line per tool call and per turn in `.mm/hooks-audit.jsonl` |
13+
14+Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 GB) on **llmman**. The hooks themselves do not depend on the model: they run the same way whatever answers.
15+
16+## Run
17+
18+Prerequisites: [llmman](https://llmmanorg.github.io/) running, and Node.js on the PATH.
19+
20+```bash
21+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
22+cd demos/hooks/playground
23+mm ../agent.yaml
24+```
25+
26+The banner lists the hooks:
27+
28+```
29+… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
30+```
31+
32+The agent works in `playground/`, a directory with three harmless files. The hooks are found through `$MM_CONFIG_DIR`, the directory of `agent.yaml`, so mm can be started from any directory.
33+
34+## Try it
35+
36+Each line below is a question to type, followed by what was seen on 2026-10-14 (`mm -p "<question>" ../agent.yaml`):
37+
38+**1. A secret is stopped.** The model is never called:
39+
40+```
41+> My password: hunter2 — can you store it in a file?
42+🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again.
43+```
44+
45+The history keeps `[prompt blocked by a hook: the prompt contains a password]`, never the password. The audit log gets the same marker.
46+
47+**2. Context is added.** The model answers without running anything, from what `add-context.js` added:
48+
49+```
50+> Which files are here, and what is today's date?
51+🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14.
52+```
53+
54+**3. A dangerous command is denied.** The model asks for `rm -rf old-drafts`, the hook refuses it, the directory is still there:
55+
56+```
57+> Delete the old-drafts directory with rm -rf.
58+🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed]
59+🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook.
60+```
61+
62+**4. Everything is logged.** After an ordinary question (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` holds:
63+
64+```json
65+{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"}
66+{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"}
67+```
68+
69+## How a hook is written
70+
71+`hooks/bash-guard.js`, without its comments:
72+
73+```js
74+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
75+const command = input.tool.input.command || "";
76+
77+const rules = [
78+ { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ },
79+ { reason: "sudo is not allowed", pattern: /\bsudo\b/ },
80+];
81+
82+const broken = rules.find((r) => r.pattern.test(command));
83+if (broken) {
84+ console.log(JSON.stringify({ action: "deny", reason: broken.reason }));
85+}
86+```
87+
88+It reads its input, decides, and prints a reply only when it has something to say: **an empty stdout means "continue"**. Test a hook without mm by giving it some input:
89+
90+```bash
91+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \
92+ | node hooks/bash-guard.js
93+# {"action":"deny","reason":"sudo is not allowed"}
94+```
95+
96+## The configuration
97+
98+```yaml
99+hooks:
100+ preGeneration:
101+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
102+ timeout: 5s
103+ onError: fail # a guard that cannot run must not let the question through
104+ - command: node "$MM_CONFIG_DIR/hooks/add-context.js"
105+ timeout: 5s
106+ preToolCall:
107+ - matcher: ^bash$ # a regular expression on the tool name
108+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
109+ timeout: 5s
110+ onError: fail
111+ postToolCall:
112+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
113+ onError: ignore # losing an audit line must not disturb the session
114+ postGeneration:
115+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
116+ onError: ignore
117+```
118+
119+`yolo: true` here: the tools run without asking, and `bash-guard.js` is what stands between the model and a dangerous command. A `preToolCall` hook runs **before** the tool's own consent prompt, so with `yolo: false` a denied call never even asks.
120+
121+## Going further
122+
123+- Every field of the input and of the reply, the order hooks run in, `onError`: [Hooks reference](../../docs/en/reference/hooks.md).
124+- Writing your own hook step by step: [Guard the agent with hooks](../../docs/en/how-to/guard-the-agent-with-hooks.md).
125+- Why hooks are external commands and where they plug in: [Hooks](../../docs/en/explanation/hooks.md).
new file mode 100644
@@ -0,0 +1,125 @@
1+# Hooks demo — four Node.js hooks around a small shell assistant
2+
3+*[Version française](README.fr.md)*
4+
5+A hook is a command mm runs at a fixed moment of a turn. It reads one JSON object on stdin and may answer one JSON object on stdout. This demo puts four hooks, written in Node.js, around an agent that can run shell commands. Each hook shows one thing a hook can do:
6+
7+| Hook | Event | What it shows |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preGeneration` | **stop** a turn: a question holding a password or an API key never reaches the model |
10+| `hooks/add-context.js` | `preGeneration` | **add context**: today's date and the list of files, appended to every question |
11+| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **deny** a tool call: `rm -r`, `sudo`, `curl … \| sh` never run, and the model reads why |
12+| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observe**: one JSON line per tool call and per turn in `.mm/hooks-audit.jsonl` |
13+
14+Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 GB) on **llmman**. The hooks themselves do not depend on the model: they run the same way whatever answers.
15+
16+## Run
17+
18+Prerequisites: [llmman](https://llmmanorg.github.io/) running, and Node.js on the PATH.
19+
20+```bash
21+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
22+cd demos/hooks/playground
23+mm ../agent.yaml
24+```
25+
26+The banner lists the hooks:
27+
28+```
29+… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
30+```
31+
32+The agent works in `playground/`, a directory with three harmless files. The hooks are found through `$MM_CONFIG_DIR`, the directory of `agent.yaml`, so mm can be started from any directory.
33+
34+## Try it
35+
36+Each line below is a question to type, followed by what was seen on 2026-10-14 (`mm -p "<question>" ../agent.yaml`):
37+
38+**1. A secret is stopped.** The model is never called:
39+
40+```
41+> My password: hunter2 — can you store it in a file?
42+🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again.
43+```
44+
45+The history keeps `[prompt blocked by a hook: the prompt contains a password]`, never the password. The audit log gets the same marker.
46+
47+**2. Context is added.** The model answers without running anything, from what `add-context.js` added:
48+
49+```
50+> Which files are here, and what is today's date?
51+🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14.
52+```
53+
54+**3. A dangerous command is denied.** The model asks for `rm -rf old-drafts`, the hook refuses it, the directory is still there:
55+
56+```
57+> Delete the old-drafts directory with rm -rf.
58+🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed]
59+🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook.
60+```
61+
62+**4. Everything is logged.** After an ordinary question (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` holds:
63+
64+```json
65+{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"}
66+{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"}
67+```
68+
69+## How a hook is written
70+
71+`hooks/bash-guard.js`, without its comments:
72+
73+```js
74+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
75+const command = input.tool.input.command || "";
76+
77+const rules = [
78+ { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ },
79+ { reason: "sudo is not allowed", pattern: /\bsudo\b/ },
80+];
81+
82+const broken = rules.find((r) => r.pattern.test(command));
83+if (broken) {
84+ console.log(JSON.stringify({ action: "deny", reason: broken.reason }));
85+}
86+```
87+
88+It reads its input, decides, and prints a reply only when it has something to say: **an empty stdout means "continue"**. Test a hook without mm by giving it some input:
89+
90+```bash
91+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \
92+ | node hooks/bash-guard.js
93+# {"action":"deny","reason":"sudo is not allowed"}
94+```
95+
96+## The configuration
97+
98+```yaml
99+hooks:
100+ preGeneration:
101+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
102+ timeout: 5s
103+ onError: fail # a guard that cannot run must not let the question through
104+ - command: node "$MM_CONFIG_DIR/hooks/add-context.js"
105+ timeout: 5s
106+ preToolCall:
107+ - matcher: ^bash$ # a regular expression on the tool name
108+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
109+ timeout: 5s
110+ onError: fail
111+ postToolCall:
112+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
113+ onError: ignore # losing an audit line must not disturb the session
114+ postGeneration:
115+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
116+ onError: ignore
117+```
118+
119+`yolo: true` here: the tools run without asking, and `bash-guard.js` is what stands between the model and a dangerous command. A `preToolCall` hook runs **before** the tool's own consent prompt, so with `yolo: false` a denied call never even asks.
120+
121+## Going further
122+
123+- Every field of the input and of the reply, the order hooks run in, `onError`: [Hooks reference](../../docs/en/reference/hooks.md).
124+- Writing your own hook step by step: [Guard the agent with hooks](../../docs/en/how-to/guard-the-agent-with-hooks.md).
125+- Why hooks are external commands and where they plug in: [Hooks](../../docs/en/explanation/hooks.md).
added demos/hooks/README.fr.md +125 -0
new file mode 100644
@@ -0,0 +1,125 @@
1+# Démo des hooks — quatre hooks Node.js autour d'un petit assistant shell
2+
3+*[English version](README.en.md)*
4+
5+Un hook est une commande que mm lance à un moment précis d'un tour. Il lit un objet JSON sur son entrée standard et peut répondre par un objet JSON sur sa sortie standard. Cette démo place quatre hooks, écrits en Node.js, autour d'un agent qui peut lancer des commandes shell. Chacun montre une chose qu'un hook sait faire :
6+
7+| Hook | Événement | Ce qu'il montre |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preGeneration` | **arrêter** un tour : une question qui contient un mot de passe ou une clé d'API n'arrive jamais au modèle |
10+| `hooks/add-context.js` | `preGeneration` | **ajouter du contexte** : la date du jour et la liste des fichiers, ajoutées à chaque question |
11+| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **refuser** un appel d'outil : `rm -r`, `sudo`, `curl … \| sh` ne s'exécutent jamais, et le modèle lit pourquoi |
12+| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observer** : une ligne JSON par appel d'outil et par tour dans `.mm/hooks-audit.jsonl` |
13+
14+Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 Go) sur **llmman**. Les hooks eux-mêmes ne dépendent pas du modèle : ils s'exécutent de la même façon quel que soit celui qui répond.
15+
16+## Lancer la démo
17+
18+Prérequis : [llmman](https://llmmanorg.github.io/) démarré, et Node.js dans le PATH.
19+
20+```bash
21+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
22+cd demos/hooks/playground
23+mm ../agent.yaml
24+```
25+
26+La bannière liste les hooks :
27+
28+```
29+… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
30+```
31+
32+L'agent travaille dans `playground/`, un dossier qui contient trois fichiers sans importance. Les hooks sont trouvés grâce à `$MM_CONFIG_DIR`, le dossier d'`agent.yaml` : mm peut donc être lancé depuis n'importe quel dossier.
33+
34+## Essayer
35+
36+Chaque ligne ci-dessous est une question à taper, suivie de ce qui a été observé le 2026-10-14 (`mm -p "<question>" ../agent.yaml`). Les questions sont en anglais, comme pendant l'essai ; l'agent répond dans la langue de la question.
37+
38+**1. Un secret est arrêté.** Le modèle n'est jamais appelé :
39+
40+```
41+> My password: hunter2 — can you store it in a file?
42+🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again.
43+```
44+
45+L'historique garde `[prompt blocked by a hook: the prompt contains a password]`, jamais le mot de passe. Le journal d'audit reçoit le même marqueur.
46+
47+**2. Du contexte est ajouté.** Le modèle répond sans rien exécuter, à partir de ce qu'`add-context.js` a ajouté :
48+
49+```
50+> Which files are here, and what is today's date?
51+🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14.
52+```
53+
54+**3. Une commande dangereuse est refusée.** Le modèle demande `rm -rf old-drafts`, le hook refuse, et le dossier est toujours là :
55+
56+```
57+> Delete the old-drafts directory with rm -rf.
58+🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed]
59+🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook.
60+```
61+
62+**4. Tout est journalisé.** Après une question ordinaire (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` contient :
63+
64+```json
65+{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"}
66+{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"}
67+```
68+
69+## Écrire un hook
70+
71+`hooks/bash-guard.js`, sans ses commentaires :
72+
73+```js
74+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
75+const command = input.tool.input.command || "";
76+
77+const rules = [
78+ { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ },
79+ { reason: "sudo is not allowed", pattern: /\bsudo\b/ },
80+];
81+
82+const broken = rules.find((r) => r.pattern.test(command));
83+if (broken) {
84+ console.log(JSON.stringify({ action: "deny", reason: broken.reason }));
85+}
86+```
87+
88+Il lit son entrée, décide, et n'écrit une réponse que s'il a quelque chose à dire : **une sortie vide veut dire « continuer »**. On teste un hook sans mm en lui donnant une entrée :
89+
90+```bash
91+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \
92+ | node hooks/bash-guard.js
93+# {"action":"deny","reason":"sudo is not allowed"}
94+```
95+
96+## La configuration
97+
98+```yaml
99+hooks:
100+ preGeneration:
101+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
102+ timeout: 5s
103+ onError: fail # un garde qui ne peut pas s'exécuter ne doit pas laisser passer la question
104+ - command: node "$MM_CONFIG_DIR/hooks/add-context.js"
105+ timeout: 5s
106+ preToolCall:
107+ - matcher: ^bash$ # une expression régulière sur le nom de l'outil
108+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
109+ timeout: 5s
110+ onError: fail
111+ postToolCall:
112+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
113+ onError: ignore # perdre une ligne d'audit ne doit pas perturber la session
114+ postGeneration:
115+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
116+ onError: ignore
117+```
118+
119+Ici `yolo: true` : les outils s'exécutent sans demander, et c'est `bash-guard.js` qui se tient entre le modèle et une commande dangereuse. Un hook `preToolCall` s'exécute **avant** la demande d'autorisation de l'outil : avec `yolo: false`, un appel refusé ne demande donc même pas.
120+
121+## Pour aller plus loin
122+
123+- Tous les champs de l'entrée et de la réponse, l'ordre d'exécution des hooks, `onError` : [Référence des hooks](../../docs/fr/reference/hooks.md).
124+- Écrire son propre hook pas à pas : [Protéger l'agent avec des hooks](../../docs/fr/how-to/guard-the-agent-with-hooks.md).
125+- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks](../../docs/fr/explanation/hooks.md).
new file mode 100644
@@ -0,0 +1,125 @@
1+# Démo des hooks — quatre hooks Node.js autour d'un petit assistant shell
2+
3+*[English version](README.en.md)*
4+
5+Un hook est une commande que mm lance à un moment précis d'un tour. Il lit un objet JSON sur son entrée standard et peut répondre par un objet JSON sur sa sortie standard. Cette démo place quatre hooks, écrits en Node.js, autour d'un agent qui peut lancer des commandes shell. Chacun montre une chose qu'un hook sait faire :
6+
7+| Hook | Événement | Ce qu'il montre |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preGeneration` | **arrêter** un tour : une question qui contient un mot de passe ou une clé d'API n'arrive jamais au modèle |
10+| `hooks/add-context.js` | `preGeneration` | **ajouter du contexte** : la date du jour et la liste des fichiers, ajoutées à chaque question |
11+| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **refuser** un appel d'outil : `rm -r`, `sudo`, `curl … \| sh` ne s'exécutent jamais, et le modèle lit pourquoi |
12+| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observer** : une ligne JSON par appel d'outil et par tour dans `.mm/hooks-audit.jsonl` |
13+
14+Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 Go) sur **llmman**. Les hooks eux-mêmes ne dépendent pas du modèle : ils s'exécutent de la même façon quel que soit celui qui répond.
15+
16+## Lancer la démo
17+
18+Prérequis : [llmman](https://llmmanorg.github.io/) démarré, et Node.js dans le PATH.
19+
20+```bash
21+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
22+cd demos/hooks/playground
23+mm ../agent.yaml
24+```
25+
26+La bannière liste les hooks :
27+
28+```
29+… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
30+```
31+
32+L'agent travaille dans `playground/`, un dossier qui contient trois fichiers sans importance. Les hooks sont trouvés grâce à `$MM_CONFIG_DIR`, le dossier d'`agent.yaml` : mm peut donc être lancé depuis n'importe quel dossier.
33+
34+## Essayer
35+
36+Chaque ligne ci-dessous est une question à taper, suivie de ce qui a été observé le 2026-10-14 (`mm -p "<question>" ../agent.yaml`). Les questions sont en anglais, comme pendant l'essai ; l'agent répond dans la langue de la question.
37+
38+**1. Un secret est arrêté.** Le modèle n'est jamais appelé :
39+
40+```
41+> My password: hunter2 — can you store it in a file?
42+🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again.
43+```
44+
45+L'historique garde `[prompt blocked by a hook: the prompt contains a password]`, jamais le mot de passe. Le journal d'audit reçoit le même marqueur.
46+
47+**2. Du contexte est ajouté.** Le modèle répond sans rien exécuter, à partir de ce qu'`add-context.js` a ajouté :
48+
49+```
50+> Which files are here, and what is today's date?
51+🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14.
52+```
53+
54+**3. Une commande dangereuse est refusée.** Le modèle demande `rm -rf old-drafts`, le hook refuse, et le dossier est toujours là :
55+
56+```
57+> Delete the old-drafts directory with rm -rf.
58+🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed]
59+🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook.
60+```
61+
62+**4. Tout est journalisé.** Après une question ordinaire (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` contient :
63+
64+```json
65+{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"}
66+{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"}
67+```
68+
69+## Écrire un hook
70+
71+`hooks/bash-guard.js`, sans ses commentaires :
72+
73+```js
74+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
75+const command = input.tool.input.command || "";
76+
77+const rules = [
78+ { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ },
79+ { reason: "sudo is not allowed", pattern: /\bsudo\b/ },
80+];
81+
82+const broken = rules.find((r) => r.pattern.test(command));
83+if (broken) {
84+ console.log(JSON.stringify({ action: "deny", reason: broken.reason }));
85+}
86+```
87+
88+Il lit son entrée, décide, et n'écrit une réponse que s'il a quelque chose à dire : **une sortie vide veut dire « continuer »**. On teste un hook sans mm en lui donnant une entrée :
89+
90+```bash
91+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \
92+ | node hooks/bash-guard.js
93+# {"action":"deny","reason":"sudo is not allowed"}
94+```
95+
96+## La configuration
97+
98+```yaml
99+hooks:
100+ preGeneration:
101+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
102+ timeout: 5s
103+ onError: fail # un garde qui ne peut pas s'exécuter ne doit pas laisser passer la question
104+ - command: node "$MM_CONFIG_DIR/hooks/add-context.js"
105+ timeout: 5s
106+ preToolCall:
107+ - matcher: ^bash$ # une expression régulière sur le nom de l'outil
108+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
109+ timeout: 5s
110+ onError: fail
111+ postToolCall:
112+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
113+ onError: ignore # perdre une ligne d'audit ne doit pas perturber la session
114+ postGeneration:
115+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
116+ onError: ignore
117+```
118+
119+Ici `yolo: true` : les outils s'exécutent sans demander, et c'est `bash-guard.js` qui se tient entre le modèle et une commande dangereuse. Un hook `preToolCall` s'exécute **avant** la demande d'autorisation de l'outil : avec `yolo: false`, un appel refusé ne demande donc même pas.
120+
121+## Pour aller plus loin
122+
123+- Tous les champs de l'entrée et de la réponse, l'ordre d'exécution des hooks, `onError` : [Référence des hooks](../../docs/fr/reference/hooks.md).
124+- Écrire son propre hook pas à pas : [Protéger l'agent avec des hooks](../../docs/fr/how-to/guard-the-agent-with-hooks.md).
125+- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks](../../docs/fr/explanation/hooks.md).
added demos/hooks/agent.yaml +75 -0
new file mode 100644
@@ -0,0 +1,75 @@
1+# Hooks demo — a small shell assistant guarded by four Node.js hooks.
2+#
3+# hooks/no-secrets.js preGeneration stops a question that holds a secret
4+# hooks/add-context.js preGeneration adds today's date and the file list
5+# hooks/bash-guard.js preToolCall denies rm -r, sudo, curl | sh
6+# hooks/audit.js postToolCall writes .mm/hooks-audit.jsonl
7+# postGeneration
8+#
9+# Run it from the playground (any directory works: the hooks are found
10+# through $MM_CONFIG_DIR, the directory of this file):
11+#
12+# cd demos/hooks/playground
13+# mm ../agent.yaml
14+#
15+# Every key is optional; reference: docs/en/reference/configuration.md.
16+
17+provider: llmman
18+model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
19+baseUrl: http://127.0.0.1:17434/v1
20+fallback: http://host.docker.internal:17434/v1
21+
22+sampling:
23+ temperature: 0.2
24+ parallel_tool_calls: false
25+
26+firstTokenTimeout: 3m
27+watchdogTimeout: 3m
28+maxTurns: 8
29+
30+bashTool: true
31+editTools: false
32+planTool: false
33+fetchContentTool: false
34+askUserTool: false
35+skillsDir: ""
36+
37+# The tools run without asking: the bash-guard hook is what stands between
38+# the model and a dangerous command here. A hook runs BEFORE the tool's own
39+# consent prompt, so with `yolo: false` a denied call never even asks.
40+yolo: true
41+
42+agentsMd:
43+ enabled: false
44+
45+acp:
46+ name: hooks-demo
47+ title: 🪝 Hooks demo
48+
49+hooks:
50+ preGeneration:
51+ # onError: fail — a guard that cannot run must not let the question through.
52+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
53+ timeout: 5s
54+ onError: fail
55+ - command: node "$MM_CONFIG_DIR/hooks/add-context.js"
56+ timeout: 5s
57+ preToolCall:
58+ - matcher: ^bash$
59+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
60+ timeout: 5s
61+ onError: fail
62+ postToolCall:
63+ # onError: ignore — losing an audit line must not disturb the session.
64+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
65+ onError: ignore
66+ postGeneration:
67+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
68+ onError: ignore
69+
70+system: |
71+ You are a shell assistant working in the current directory.
72+ Use the bash tool to look at files and to run commands; never guess what a
73+ file contains. Answer in one or two short sentences, in the user's language.
74+ If a tool result says "[denied by a hook: …]", the command was refused:
75+ tell the user why, and do not try to get around the refusal.
new file mode 100644
@@ -0,0 +1,75 @@
1+# Hooks demo — a small shell assistant guarded by four Node.js hooks.
2+#
3+# hooks/no-secrets.js preGeneration stops a question that holds a secret
4+# hooks/add-context.js preGeneration adds today's date and the file list
5+# hooks/bash-guard.js preToolCall denies rm -r, sudo, curl | sh
6+# hooks/audit.js postToolCall writes .mm/hooks-audit.jsonl
7+# postGeneration
8+#
9+# Run it from the playground (any directory works: the hooks are found
10+# through $MM_CONFIG_DIR, the directory of this file):
11+#
12+# cd demos/hooks/playground
13+# mm ../agent.yaml
14+#
15+# Every key is optional; reference: docs/en/reference/configuration.md.
16+
17+provider: llmman
18+model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
19+baseUrl: http://127.0.0.1:17434/v1
20+fallback: http://host.docker.internal:17434/v1
21+
22+sampling:
23+ temperature: 0.2
24+ parallel_tool_calls: false
25+
26+firstTokenTimeout: 3m
27+watchdogTimeout: 3m
28+maxTurns: 8
29+
30+bashTool: true
31+editTools: false
32+planTool: false
33+fetchContentTool: false
34+askUserTool: false
35+skillsDir: ""
36+
37+# The tools run without asking: the bash-guard hook is what stands between
38+# the model and a dangerous command here. A hook runs BEFORE the tool's own
39+# consent prompt, so with `yolo: false` a denied call never even asks.
40+yolo: true
41+
42+agentsMd:
43+ enabled: false
44+
45+acp:
46+ name: hooks-demo
47+ title: 🪝 Hooks demo
48+
49+hooks:
50+ preGeneration:
51+ # onError: fail — a guard that cannot run must not let the question through.
52+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
53+ timeout: 5s
54+ onError: fail
55+ - command: node "$MM_CONFIG_DIR/hooks/add-context.js"
56+ timeout: 5s
57+ preToolCall:
58+ - matcher: ^bash$
59+ command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
60+ timeout: 5s
61+ onError: fail
62+ postToolCall:
63+ # onError: ignore — losing an audit line must not disturb the session.
64+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
65+ onError: ignore
66+ postGeneration:
67+ - command: node "$MM_CONFIG_DIR/hooks/audit.js"
68+ onError: ignore
69+
70+system: |
71+ You are a shell assistant working in the current directory.
72+ Use the bash tool to look at files and to run commands; never guess what a
73+ file contains. Answer in one or two short sentences, in the user's language.
74+ If a tool result says "[denied by a hook: …]", the command was refused:
75+ tell the user why, and do not try to get around the refusal.
added demos/hooks/hooks/add-context.js +22 -0
new file mode 100755
@@ -0,0 +1,22 @@
1+#!/usr/bin/env node
2+// preGeneration hook: adds context the model cannot guess — today's date and
3+// the files of the working directory.
4+//
5+// Printing {"context":"…"} appends that text to the question, under the
6+// label "[context added by a hook]". The user's words are left as they are
7+// (a hook could also replace them with {"prompt":"…"}).
8+
9+const fs = require("fs");
10+const input = JSON.parse(fs.readFileSync(0, "utf8"));
11+
12+const today = new Date().toISOString().slice(0, 10);
13+let files = [];
14+try {
15+ files = fs.readdirSync(input.cwd).filter((f) => !f.startsWith("."));
16+} catch {
17+ // An unreadable directory is not worth failing the turn for.
18+}
19+
20+console.log(JSON.stringify({
21+ context: `Today is ${today}. Files in ${input.cwd}: ${files.join(", ") || "none"}.`,
22+}));
new file mode 100755
@@ -0,0 +1,22 @@
1+#!/usr/bin/env node
2+// preGeneration hook: adds context the model cannot guess — today's date and
3+// the files of the working directory.
4+//
5+// Printing {"context":"…"} appends that text to the question, under the
6+// label "[context added by a hook]". The user's words are left as they are
7+// (a hook could also replace them with {"prompt":"…"}).
8+
9+const fs = require("fs");
10+const input = JSON.parse(fs.readFileSync(0, "utf8"));
11+
12+const today = new Date().toISOString().slice(0, 10);
13+let files = [];
14+try {
15+ files = fs.readdirSync(input.cwd).filter((f) => !f.startsWith("."));
16+} catch {
17+ // An unreadable directory is not worth failing the turn for.
18+}
19+
20+console.log(JSON.stringify({
21+ context: `Today is ${today}. Files in ${input.cwd}: ${files.join(", ") || "none"}.`,
22+}));
added demos/hooks/hooks/audit.js +25 -0
new file mode 100755
@@ -0,0 +1,25 @@
1+#!/usr/bin/env node
2+// postToolCall and postGeneration hook: writes one line per event to
3+// .mm/hooks-audit.jsonl in the working directory.
4+//
5+// It only observes, so it prints nothing on stdout. One script serves two
6+// events: the event's name is in the input (and in $MM_HOOK_EVENT).
7+
8+const fs = require("fs");
9+const path = require("path");
10+const input = JSON.parse(fs.readFileSync(0, "utf8"));
11+
12+const line = { at: new Date().toISOString(), event: input.event };
13+if (input.event === "postToolCall") {
14+ line.tool = input.tool.name;
15+ line.input = input.tool.input;
16+ line.output = input.output.slice(0, 200);
17+} else {
18+ line.prompt = input.prompt;
19+ line.answer = (input.answer || "").slice(0, 200);
20+ if (input.error) line.error = input.error;
21+}
22+
23+const dir = path.join(input.cwd, ".mm");
24+fs.mkdirSync(dir, { recursive: true });
25+fs.appendFileSync(path.join(dir, "hooks-audit.jsonl"), JSON.stringify(line) + "\n");
new file mode 100755
@@ -0,0 +1,25 @@
1+#!/usr/bin/env node
2+// postToolCall and postGeneration hook: writes one line per event to
3+// .mm/hooks-audit.jsonl in the working directory.
4+//
5+// It only observes, so it prints nothing on stdout. One script serves two
6+// events: the event's name is in the input (and in $MM_HOOK_EVENT).
7+
8+const fs = require("fs");
9+const path = require("path");
10+const input = JSON.parse(fs.readFileSync(0, "utf8"));
11+
12+const line = { at: new Date().toISOString(), event: input.event };
13+if (input.event === "postToolCall") {
14+ line.tool = input.tool.name;
15+ line.input = input.tool.input;
16+ line.output = input.output.slice(0, 200);
17+} else {
18+ line.prompt = input.prompt;
19+ line.answer = (input.answer || "").slice(0, 200);
20+ if (input.error) line.error = input.error;
21+}
22+
23+const dir = path.join(input.cwd, ".mm");
24+fs.mkdirSync(dir, { recursive: true });
25+fs.appendFileSync(path.join(dir, "hooks-audit.jsonl"), JSON.stringify(line) + "\n");
added demos/hooks/hooks/bash-guard.js +21 -0
new file mode 100755
@@ -0,0 +1,21 @@
1+#!/usr/bin/env node
2+// preToolCall hook (matcher ^bash$): refuses dangerous shell commands.
3+//
4+// mm writes {"event":"preToolCall","tool":{"name":"bash","input":{"command":"…"}},…}.
5+// Printing {"action":"deny","reason":"…"} means the command never runs: the
6+// model reads "[denied by a hook: <reason>]" as the tool's output, and can
7+// try something else. Printing nothing lets the command run.
8+
9+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
10+const command = input.tool.input.command || "";
11+
12+const rules = [
13+ { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ },
14+ { reason: "sudo is not allowed", pattern: /\bsudo\b/ },
15+ { reason: "piping a download into a shell is not allowed", pattern: /\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b/ },
16+];
17+
18+const broken = rules.find((r) => r.pattern.test(command));
19+if (broken) {
20+ console.log(JSON.stringify({ action: "deny", reason: broken.reason }));
21+}
new file mode 100755
@@ -0,0 +1,21 @@
1+#!/usr/bin/env node
2+// preToolCall hook (matcher ^bash$): refuses dangerous shell commands.
3+//
4+// mm writes {"event":"preToolCall","tool":{"name":"bash","input":{"command":"…"}},…}.
5+// Printing {"action":"deny","reason":"…"} means the command never runs: the
6+// model reads "[denied by a hook: <reason>]" as the tool's output, and can
7+// try something else. Printing nothing lets the command run.
8+
9+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
10+const command = input.tool.input.command || "";
11+
12+const rules = [
13+ { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ },
14+ { reason: "sudo is not allowed", pattern: /\bsudo\b/ },
15+ { reason: "piping a download into a shell is not allowed", pattern: /\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b/ },
16+];
17+
18+const broken = rules.find((r) => r.pattern.test(command));
19+if (broken) {
20+ console.log(JSON.stringify({ action: "deny", reason: broken.reason }));
21+}
added demos/hooks/hooks/no-secrets.js +27 -0
new file mode 100755
@@ -0,0 +1,27 @@
1+#!/usr/bin/env node
2+// preGeneration hook: stops the turn when the question holds a secret.
3+//
4+// mm writes one JSON object on stdin: {"event":"preGeneration","prompt":"…",…}.
5+// Printing {"action":"stop",…} means the model never sees the question; mm
6+// shows `response` instead and keeps "[prompt blocked by a hook: <reason>]"
7+// in the history, never the secret itself.
8+// Printing nothing means "continue".
9+
10+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
11+
12+const secrets = [
13+ { name: "a password", pattern: /\b(password|passwd|pwd|mot de passe)\s*[:=]\s*\S+/i },
14+ { name: "an OpenAI-style API key", pattern: /\bsk-[A-Za-z0-9_-]{20,}/ },
15+ { name: "a GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}/ },
16+ { name: "an AWS access key", pattern: /\bAKIA[0-9A-Z]{16}\b/ },
17+];
18+
19+const found = secrets.find((s) => s.pattern.test(input.prompt));
20+if (found) {
21+ console.log(JSON.stringify({
22+ action: "stop",
23+ reason: `the prompt contains ${found.name}`,
24+ response: `🔐 Your message looks like it contains ${found.name}. ` +
25+ "It was not sent to the model. Remove it and ask again.",
26+ }));
27+}
new file mode 100755
@@ -0,0 +1,27 @@
1+#!/usr/bin/env node
2+// preGeneration hook: stops the turn when the question holds a secret.
3+//
4+// mm writes one JSON object on stdin: {"event":"preGeneration","prompt":"…",…}.
5+// Printing {"action":"stop",…} means the model never sees the question; mm
6+// shows `response` instead and keeps "[prompt blocked by a hook: <reason>]"
7+// in the history, never the secret itself.
8+// Printing nothing means "continue".
9+
10+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
11+
12+const secrets = [
13+ { name: "a password", pattern: /\b(password|passwd|pwd|mot de passe)\s*[:=]\s*\S+/i },
14+ { name: "an OpenAI-style API key", pattern: /\bsk-[A-Za-z0-9_-]{20,}/ },
15+ { name: "a GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}/ },
16+ { name: "an AWS access key", pattern: /\bAKIA[0-9A-Z]{16}\b/ },
17+];
18+
19+const found = secrets.find((s) => s.pattern.test(input.prompt));
20+if (found) {
21+ console.log(JSON.stringify({
22+ action: "stop",
23+ reason: `the prompt contains ${found.name}`,
24+ response: `🔐 Your message looks like it contains ${found.name}. ` +
25+ "It was not sent to the model. Remove it and ask again.",
26+ }));
27+}
added demos/hooks/playground/notes.md +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+# Notes
2+
3+The meeting is on Friday.
new file mode 100644
@@ -0,0 +1,3 @@
1+# Notes
2+
3+The meeting is on Friday.
added demos/hooks/playground/old-drafts/draft1.txt +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+draft 1
new file mode 100644
@@ -0,0 +1 @@
1+draft 1
added demos/hooks/playground/todo.txt +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+Buy milk
2+Call the plumber
new file mode 100644
@@ -0,0 +1,2 @@
1+Buy milk
2+Call the plumber
modified docs/diagrams/packages.drawio +1 -1
@@ -1,3 +1,3 @@
11 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
22 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1,3 +1,3 @@
1 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>1 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +3 -0
@@ -35,6 +35,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
3535 - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md)
3636 - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53
3737 - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it
38+- [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language
3839
3940 ### MCP (Model Context Protocol) How-to
4041 - [Use Context7 for documentation](how-to/mcp-use-context7.md)
@@ -48,6 +49,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
4849 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
4950 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
5051 - [Skill file format](reference/skill-format.md)
52+- [Hooks](reference/hooks.md) — the four events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
5153 - [Agent Client Protocol surface](reference/acp.md)
5254
5355 ### MCP (Model Context Protocol) Reference
@@ -69,6 +71,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
6971 - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer
7072 - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit
7173 - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap
74+- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out
7275 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
7376
7477 ### MCP (Model Context Protocol) Explanation
@@ -35,6 +35,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
35 - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md)35 - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md)
36 - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 5336 - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53
37 - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it37 - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it
38+- [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language
38 39
39 ### MCP (Model Context Protocol) How-to40 ### MCP (Model Context Protocol) How-to
40 - [Use Context7 for documentation](how-to/mcp-use-context7.md)41 - [Use Context7 for documentation](how-to/mcp-use-context7.md)
@@ -48,6 +49,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
48 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule49 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
49 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`50 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
50 - [Skill file format](reference/skill-format.md)51 - [Skill file format](reference/skill-format.md)
52+- [Hooks](reference/hooks.md) — the four events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
51 - [Agent Client Protocol surface](reference/acp.md)53 - [Agent Client Protocol surface](reference/acp.md)
52 54
53 ### MCP (Model Context Protocol) Reference55 ### MCP (Model Context Protocol) Reference
@@ -69,6 +71,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
69 - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer71 - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer
70 - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit72 - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit
71 - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap73 - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap
74+- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out
72 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)75 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
73 76
74 ### MCP (Model Context Protocol) Explanation77 ### MCP (Model Context Protocol) Explanation
modified docs/en/explanation/architecture.md +1 -0
@@ -27,6 +27,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
2727 | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |
2828 | `ui` | Where human-facing output goes, and the event sink the front ends share. |
2929 | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). |
30+| `hooks` | The `hooks:` block (ticket 36): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). |
3031 | `agent` | The terminal REPL. |
3132 | `acp` | The Agent Client Protocol façade. |
3233 | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |
@@ -27,6 +27,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
27 | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |27 | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |
28 | `ui` | Where human-facing output goes, and the event sink the front ends share. |28 | `ui` | Where human-facing output goes, and the event sink the front ends share. |
29 | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). |29 | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). |
30+| `hooks` | The `hooks:` block (ticket 36): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). |
30 | `agent` | The terminal REPL. |31 | `agent` | The terminal REPL. |
31 | `acp` | The Agent Client Protocol façade. |32 | `acp` | The Agent Client Protocol façade. |
32 | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |33 | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |
added docs/en/explanation/hooks.md +69 -0
new file mode 100644
@@ -0,0 +1,69 @@
1+# Hooks — explanation
2+
3+## What is this about?
4+
5+A hook is a command `mm` runs at four moments of a turn: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), and once the turn is over (`postGeneration`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code.
6+
7+The design started from ticket 36, which explored a much larger system. This page explains what was kept, where the hooks plug in, and what was left out.
8+
9+## Why external commands
10+
11+The ticket's first requirement was that hooks can be written in **any language**. That rules out a Go plugin interface (it would need a recompilation, and Go's `plugin` package does not exist on every platform `mm` is built for) and an embedded interpreter (it would pick one language for everyone). A process reading stdin and writing stdout is the one interface every language has.
12+
13+It has costs, and they were accepted on purpose:
14+
15+- **A process start per event.** A `preToolCall` hook runs once per tool call; `node` takes some tens of milliseconds to start. Against a local model that spends seconds per answer, this is noise. It is also why every hook has a `timeout`, and why the default is short (10 s): a hook sits in the middle of the turn, and a slow one is felt at once.
16+- **JSON as the contract.** The input and the reply are small and flat, so that a hook in shell with `jq` is as easy to write as one in Python or Node.
17+
18+The same shape is used elsewhere in the ecosystem (editor and agent hooks, git hooks with their arguments): someone who has written one recognises the other.
19+
20+## Where they plug in
21+
22+Every front end — the line mode, the window, the ACP façade, `-p` — hands its turn to one function, `engine.Generate`. And inside it, Genkit's middleware already has a `WrapTool` hook that sees **every** tool call, built-in or MCP, before it runs. Those two points are where the hooks run:
23+
24+```
25+front end ──▶ engine.Generate
26+ ├─ preGeneration (the question, before the first model call)
27+ ├─ model ⇄ tools ── WrapTool
28+ │ ├─ preToolCall (before the tool, and its consent prompt)
29+ │ └─ postToolCall (after the tool)
30+ └─ postGeneration (the turn is over, whatever its outcome)
31+```
32+
33+Placing them there rather than in each front end means one implementation, one set of tests, and no front end that forgets them. A new front end gets the hooks for free.
34+
35+`preToolCall` runs **before** the tool's consent prompt. A denied call therefore never asks the user anything; and a guard keeps working with `yolo: true`, which is when it matters most.
36+
37+The package `internal/hooks` runs the commands and turns their replies into decisions; it knows nothing about Genkit or the front ends. `internal/engine/hooks.go` applies those decisions to the messages and shows them: as the answer, as a failed tool call, or as a notice — a line in the terminal, a dim line in the window, the editor's logs over ACP.
38+
39+## Decisions worth knowing
40+
41+**A stopped question leaves a marker, not the question.** The obvious reason to stop a question is that it holds something that must not reach the model: a password, a key. Keeping it in the history would send it on the next turn. The history therefore keeps `[prompt blocked by a hook: <reason>]` followed by the hook's response — enough for the model to understand what happened, nothing it must not read. The first live run of the demo found a second leak: the audit hook on `postGeneration` received the blocked question and wrote the secret to its log. The post hooks now read the marker too.
42+
43+**Failures follow `onError`, and the default is `warn`.** A hook that crashes is shown, and the turn goes on: a broken logging script must not lock the user out of their agent. A guard should say `onError: fail` — a guard that cannot run must not let the call through. The two needs are opposite, so the choice is per hook.
44+
45+**An unknown event name is an error.** YAML ignores keys it does not know; `preToolcall:` would have been a guard that silently never runs. Refusing to start is the safer failure.
46+
47+**A denied tool call answers the model as text.** Like every tool failure in `mm`, the refusal is the tool's output: `[denied by a hook: <reason>]`. The model can tell the user or try another way, rather than the turn ending on an error.
48+
49+**`postGeneration` is not cancelled with the turn.** An audit log that loses the turns the user interrupted is the wrong way round. The hook runs after a cancellation too, bounded by its own timeout.
50+
51+**A replaced tool input is not written back into the history.** The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later.
52+
53+## Rejected alternatives
54+
55+Ticket 36 explored more than what was built. What was left out, and why:
56+
57+- **Returning the whole `messages` array.** The ticket's first protocol let a hook rewrite the conversation. It is fragile — a hook could drop the system prompt or rewrite past turns, and every hook would have to round-trip the whole history — and the four needs (detect, rewrite, add context, stop) are covered by `prompt`, `context` and `action`.
58+- **Numeric priorities.** The order of the YAML list is the order of execution. A priority field adds a second ordering to keep in mind for no gain when the list is short.
59+- **An `async` mode.** A logging hook is fast; when it is not, `onError: ignore` with a short `timeout` bounds it. A background mode would need a queue, a shutdown flush and a way to report late failures.
60+- **`allowedPaths` / `allowedExtensions`.** The YAML file already starts MCP servers and gives the model `bash`. Whoever writes it is trusted with far more than a hook path; an allowlist would protect nothing.
61+- **Lifecycle, session and front-end hooks** (start-up, configuration loaded, shutdown, window opened, editor connected). No use case asked for them yet. They can be added as events later without changing the protocol.
62+
63+## How it relates to the rest
64+
65+- **Yolo and consent.** Hooks and `yolo: false` are complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See [Yolo mode and permissions](yolo-and-permissions.md).
66+- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's.
67+- **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it.
68+
69+See the [hooks reference](../reference/hooks.md) for every field, and [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one.
new file mode 100644
@@ -0,0 +1,69 @@
1+# Hooks — explanation
2+
3+## What is this about?
4+
5+A hook is a command `mm` runs at four moments of a turn: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), and once the turn is over (`postGeneration`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code.
6+
7+The design started from ticket 36, which explored a much larger system. This page explains what was kept, where the hooks plug in, and what was left out.
8+
9+## Why external commands
10+
11+The ticket's first requirement was that hooks can be written in **any language**. That rules out a Go plugin interface (it would need a recompilation, and Go's `plugin` package does not exist on every platform `mm` is built for) and an embedded interpreter (it would pick one language for everyone). A process reading stdin and writing stdout is the one interface every language has.
12+
13+It has costs, and they were accepted on purpose:
14+
15+- **A process start per event.** A `preToolCall` hook runs once per tool call; `node` takes some tens of milliseconds to start. Against a local model that spends seconds per answer, this is noise. It is also why every hook has a `timeout`, and why the default is short (10 s): a hook sits in the middle of the turn, and a slow one is felt at once.
16+- **JSON as the contract.** The input and the reply are small and flat, so that a hook in shell with `jq` is as easy to write as one in Python or Node.
17+
18+The same shape is used elsewhere in the ecosystem (editor and agent hooks, git hooks with their arguments): someone who has written one recognises the other.
19+
20+## Where they plug in
21+
22+Every front end — the line mode, the window, the ACP façade, `-p` — hands its turn to one function, `engine.Generate`. And inside it, Genkit's middleware already has a `WrapTool` hook that sees **every** tool call, built-in or MCP, before it runs. Those two points are where the hooks run:
23+
24+```
25+front end ──▶ engine.Generate
26+ ├─ preGeneration (the question, before the first model call)
27+ ├─ model ⇄ tools ── WrapTool
28+ │ ├─ preToolCall (before the tool, and its consent prompt)
29+ │ └─ postToolCall (after the tool)
30+ └─ postGeneration (the turn is over, whatever its outcome)
31+```
32+
33+Placing them there rather than in each front end means one implementation, one set of tests, and no front end that forgets them. A new front end gets the hooks for free.
34+
35+`preToolCall` runs **before** the tool's consent prompt. A denied call therefore never asks the user anything; and a guard keeps working with `yolo: true`, which is when it matters most.
36+
37+The package `internal/hooks` runs the commands and turns their replies into decisions; it knows nothing about Genkit or the front ends. `internal/engine/hooks.go` applies those decisions to the messages and shows them: as the answer, as a failed tool call, or as a notice — a line in the terminal, a dim line in the window, the editor's logs over ACP.
38+
39+## Decisions worth knowing
40+
41+**A stopped question leaves a marker, not the question.** The obvious reason to stop a question is that it holds something that must not reach the model: a password, a key. Keeping it in the history would send it on the next turn. The history therefore keeps `[prompt blocked by a hook: <reason>]` followed by the hook's response — enough for the model to understand what happened, nothing it must not read. The first live run of the demo found a second leak: the audit hook on `postGeneration` received the blocked question and wrote the secret to its log. The post hooks now read the marker too.
42+
43+**Failures follow `onError`, and the default is `warn`.** A hook that crashes is shown, and the turn goes on: a broken logging script must not lock the user out of their agent. A guard should say `onError: fail` — a guard that cannot run must not let the call through. The two needs are opposite, so the choice is per hook.
44+
45+**An unknown event name is an error.** YAML ignores keys it does not know; `preToolcall:` would have been a guard that silently never runs. Refusing to start is the safer failure.
46+
47+**A denied tool call answers the model as text.** Like every tool failure in `mm`, the refusal is the tool's output: `[denied by a hook: <reason>]`. The model can tell the user or try another way, rather than the turn ending on an error.
48+
49+**`postGeneration` is not cancelled with the turn.** An audit log that loses the turns the user interrupted is the wrong way round. The hook runs after a cancellation too, bounded by its own timeout.
50+
51+**A replaced tool input is not written back into the history.** The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later.
52+
53+## Rejected alternatives
54+
55+Ticket 36 explored more than what was built. What was left out, and why:
56+
57+- **Returning the whole `messages` array.** The ticket's first protocol let a hook rewrite the conversation. It is fragile — a hook could drop the system prompt or rewrite past turns, and every hook would have to round-trip the whole history — and the four needs (detect, rewrite, add context, stop) are covered by `prompt`, `context` and `action`.
58+- **Numeric priorities.** The order of the YAML list is the order of execution. A priority field adds a second ordering to keep in mind for no gain when the list is short.
59+- **An `async` mode.** A logging hook is fast; when it is not, `onError: ignore` with a short `timeout` bounds it. A background mode would need a queue, a shutdown flush and a way to report late failures.
60+- **`allowedPaths` / `allowedExtensions`.** The YAML file already starts MCP servers and gives the model `bash`. Whoever writes it is trusted with far more than a hook path; an allowlist would protect nothing.
61+- **Lifecycle, session and front-end hooks** (start-up, configuration loaded, shutdown, window opened, editor connected). No use case asked for them yet. They can be added as events later without changing the protocol.
62+
63+## How it relates to the rest
64+
65+- **Yolo and consent.** Hooks and `yolo: false` are complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See [Yolo mode and permissions](yolo-and-permissions.md).
66+- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's.
67+- **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it.
68+
69+See the [hooks reference](../reference/hooks.md) for every field, and [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one.
modified docs/en/explanation/yolo-and-permissions.md +1 -0
@@ -51,3 +51,4 @@ One refinement keeps the same principle: the user's file may give an asked agent
5151 - How to turn it on: [How to confirm tool calls](../how-to/confirm-tool-calls.md).
5252 - The event path the front ends share (`ui.Sink`, `Allow`): [Architecture](architecture.md).
5353 - Asking another agent: [Asking another agent](asking-another-agent.md).
54+- Deciding by rule rather than by asking: [Hooks](hooks.md) — a `preToolCall` hook runs before the consent question, so a call it denies is never put to the user.
@@ -51,3 +51,4 @@ One refinement keeps the same principle: the user's file may give an asked agent
51 - How to turn it on: [How to confirm tool calls](../how-to/confirm-tool-calls.md).51 - How to turn it on: [How to confirm tool calls](../how-to/confirm-tool-calls.md).
52 - The event path the front ends share (`ui.Sink`, `Allow`): [Architecture](architecture.md).52 - The event path the front ends share (`ui.Sink`, `Allow`): [Architecture](architecture.md).
53 - Asking another agent: [Asking another agent](asking-another-agent.md).53 - Asking another agent: [Asking another agent](asking-another-agent.md).
54+- Deciding by rule rather than by asking: [Hooks](hooks.md) — a `preToolCall` hook runs before the consent question, so a call it denies is never put to the user.
modified docs/en/how-to/confirm-tool-calls.md +1 -0
@@ -55,3 +55,4 @@ By default mini-me runs in **yolo mode**: `bash`, file writes and edits run as s
5555 - Every key and its default: [configuration reference](../reference/configuration.md) (`yolo`, `permissions`).
5656 - What each front end shows: [CLI reference](../reference/cli.md), [ACP reference](../reference/acp.md#sessionrequest_permission-requests-sent).
5757 - Why yolo is the default and why the asking agent wins: [Yolo mode and permissions](../explanation/yolo-and-permissions.md).
58+- Refuse some calls automatically, by rule, without asking: [Guard the agent with hooks](guard-the-agent-with-hooks.md).
@@ -55,3 +55,4 @@ By default mini-me runs in **yolo mode**: `bash`, file writes and edits run as s
55 - Every key and its default: [configuration reference](../reference/configuration.md) (`yolo`, `permissions`).55 - Every key and its default: [configuration reference](../reference/configuration.md) (`yolo`, `permissions`).
56 - What each front end shows: [CLI reference](../reference/cli.md), [ACP reference](../reference/acp.md#sessionrequest_permission-requests-sent).56 - What each front end shows: [CLI reference](../reference/cli.md), [ACP reference](../reference/acp.md#sessionrequest_permission-requests-sent).
57 - Why yolo is the default and why the asking agent wins: [Yolo mode and permissions](../explanation/yolo-and-permissions.md).57 - Why yolo is the default and why the asking agent wins: [Yolo mode and permissions](../explanation/yolo-and-permissions.md).
58+- Refuse some calls automatically, by rule, without asking: [Guard the agent with hooks](guard-the-agent-with-hooks.md).
added docs/en/how-to/guard-the-agent-with-hooks.md +100 -0
new file mode 100644
@@ -0,0 +1,100 @@
1+# How to guard the agent with hooks
2+
3+This guide shows how to run your own command at fixed moments of a turn: to refuse a dangerous tool call, to stop a question before the model sees it, to add context, or to log what the agent does. It assumes you have an agent that runs, and a language you can write a small script in. The examples use Node.js; any program that reads stdin and writes stdout works.
4+
5+## Steps
6+
7+### 1. Pick the event
8+
9+| You want to… | Event |
10+|--------------|-------|
11+| refuse some tool calls, or change their arguments | `preToolCall` |
12+| stop or rewrite a question, or add context to it | `preGeneration` |
13+| add a note after a tool's output | `postToolCall` |
14+| log or notify, without changing anything | `postToolCall`, `postGeneration` |
15+
16+### 2. Write the hook
17+
18+The hook reads one JSON object on stdin and, when it has a decision, prints one on stdout. Printing nothing lets the turn go on. This one refuses `git push --force`:
19+
20+```js
21+// hooks/no-force-push.js
22+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
23+const command = input.tool.input.command || "";
24+
25+if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) {
26+ console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" }));
27+}
28+```
29+
30+The reason is what the model reads instead of the command's output: write it for the model, so it can tell the user or try something else.
31+
32+### 3. Try it without mm
33+
34+Feed it the input mm would send:
35+
36+```bash
37+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \
38+ | node hooks/no-force-push.js
39+```
40+
41+You should see:
42+
43+```
44+{"action":"deny","reason":"force-push is not allowed in this project"}
45+```
46+
47+And nothing at all for `git push origin main`.
48+
49+### 4. Declare it
50+
51+In the agent's YAML file, next to the script:
52+
53+```yaml
54+hooks:
55+ preToolCall:
56+ - matcher: ^bash$
57+ command: node "$MM_CONFIG_DIR/hooks/no-force-push.js"
58+ onError: fail
59+```
60+
61+- `matcher` limits the hook to the `bash` tool; without it, the hook runs for every tool call.
62+- `$MM_CONFIG_DIR` is the directory of the YAML file, so the script is found whatever directory `mm` is started from.
63+- `onError: fail` denies the call when the hook itself fails (Node missing, a syntax error, a timeout). Use it for guards; keep the default, `warn`, for hooks whose failure must not get in the way.
64+
65+### 5. Start the agent and check
66+
67+```bash
68+mm agent.yaml
69+```
70+
71+The banner ends with the hooks it loaded:
72+
73+```
74+… | yolo: on | hooks: preToolCall×1
75+```
76+
77+Ask for something that triggers the rule (`force-push the main branch`). The line mode shows:
78+
79+```
80+🪝 bash [denied by a hook: force-push is not allowed in this project]
81+```
82+
83+and the model's answer explains the refusal.
84+
85+## Variants
86+
87+- **Stop a question.** On `preGeneration`, read `input.prompt` and print `{"action":"stop","reason":"…","response":"…"}`. The user reads `response`; the model is not called, and the question is replaced in the history by `[prompt blocked by a hook: <reason>]`.
88+- **Add context.** On `preGeneration`, print `{"context":"…"}`: the text is appended to the question under `[context added by a hook]`. On `postToolCall`, the same field is appended to the tool's output.
89+- **Rewrite.** `{"prompt":"…"}` on `preGeneration` replaces the question; `{"input":{…}}` on `preToolCall` replaces the tool's arguments — the whole object, so copy the fields you keep.
90+- **Log.** On `postToolCall` and `postGeneration`, append `input` to a file and print nothing; set `onError: ignore` so a full disk never interrupts a session. One script can serve several events: read `input.event` or `$MM_HOOK_EVENT`.
91+- **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work.
92+- **Several hooks on one event** run in the order written; each one sees the previous one's rewrite, and the first `stop` or `deny` ends the chain.
93+- **With `yolo: false`**, a `preToolCall` hook runs before the consent question: a denied call is never put to the user.
94+- **In an editor** (`mm -acp`), the hooks run in the session's working directory, and a denied call shows as a failed tool call titled `<tool> — denied by a hook`.
95+
96+## See also
97+
98+- Every field, action, failure and limit: [Hooks reference](../reference/hooks.md)
99+- Why hooks are external commands, and where they plug in: [Hooks — explanation](../explanation/hooks.md)
100+- A complete example with four hooks: `demos/hooks/`
new file mode 100644
@@ -0,0 +1,100 @@
1+# How to guard the agent with hooks
2+
3+This guide shows how to run your own command at fixed moments of a turn: to refuse a dangerous tool call, to stop a question before the model sees it, to add context, or to log what the agent does. It assumes you have an agent that runs, and a language you can write a small script in. The examples use Node.js; any program that reads stdin and writes stdout works.
4+
5+## Steps
6+
7+### 1. Pick the event
8+
9+| You want to… | Event |
10+|--------------|-------|
11+| refuse some tool calls, or change their arguments | `preToolCall` |
12+| stop or rewrite a question, or add context to it | `preGeneration` |
13+| add a note after a tool's output | `postToolCall` |
14+| log or notify, without changing anything | `postToolCall`, `postGeneration` |
15+
16+### 2. Write the hook
17+
18+The hook reads one JSON object on stdin and, when it has a decision, prints one on stdout. Printing nothing lets the turn go on. This one refuses `git push --force`:
19+
20+```js
21+// hooks/no-force-push.js
22+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
23+const command = input.tool.input.command || "";
24+
25+if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) {
26+ console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" }));
27+}
28+```
29+
30+The reason is what the model reads instead of the command's output: write it for the model, so it can tell the user or try something else.
31+
32+### 3. Try it without mm
33+
34+Feed it the input mm would send:
35+
36+```bash
37+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \
38+ | node hooks/no-force-push.js
39+```
40+
41+You should see:
42+
43+```
44+{"action":"deny","reason":"force-push is not allowed in this project"}
45+```
46+
47+And nothing at all for `git push origin main`.
48+
49+### 4. Declare it
50+
51+In the agent's YAML file, next to the script:
52+
53+```yaml
54+hooks:
55+ preToolCall:
56+ - matcher: ^bash$
57+ command: node "$MM_CONFIG_DIR/hooks/no-force-push.js"
58+ onError: fail
59+```
60+
61+- `matcher` limits the hook to the `bash` tool; without it, the hook runs for every tool call.
62+- `$MM_CONFIG_DIR` is the directory of the YAML file, so the script is found whatever directory `mm` is started from.
63+- `onError: fail` denies the call when the hook itself fails (Node missing, a syntax error, a timeout). Use it for guards; keep the default, `warn`, for hooks whose failure must not get in the way.
64+
65+### 5. Start the agent and check
66+
67+```bash
68+mm agent.yaml
69+```
70+
71+The banner ends with the hooks it loaded:
72+
73+```
74+… | yolo: on | hooks: preToolCall×1
75+```
76+
77+Ask for something that triggers the rule (`force-push the main branch`). The line mode shows:
78+
79+```
80+🪝 bash [denied by a hook: force-push is not allowed in this project]
81+```
82+
83+and the model's answer explains the refusal.
84+
85+## Variants
86+
87+- **Stop a question.** On `preGeneration`, read `input.prompt` and print `{"action":"stop","reason":"…","response":"…"}`. The user reads `response`; the model is not called, and the question is replaced in the history by `[prompt blocked by a hook: <reason>]`.
88+- **Add context.** On `preGeneration`, print `{"context":"…"}`: the text is appended to the question under `[context added by a hook]`. On `postToolCall`, the same field is appended to the tool's output.
89+- **Rewrite.** `{"prompt":"…"}` on `preGeneration` replaces the question; `{"input":{…}}` on `preToolCall` replaces the tool's arguments — the whole object, so copy the fields you keep.
90+- **Log.** On `postToolCall` and `postGeneration`, append `input` to a file and print nothing; set `onError: ignore` so a full disk never interrupts a session. One script can serve several events: read `input.event` or `$MM_HOOK_EVENT`.
91+- **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work.
92+- **Several hooks on one event** run in the order written; each one sees the previous one's rewrite, and the first `stop` or `deny` ends the chain.
93+- **With `yolo: false`**, a `preToolCall` hook runs before the consent question: a denied call is never put to the user.
94+- **In an editor** (`mm -acp`), the hooks run in the session's working directory, and a denied call shows as a failed tool call titled `<tool> — denied by a hook`.
95+
96+## See also
97+
98+- Every field, action, failure and limit: [Hooks reference](../reference/hooks.md)
99+- Why hooks are external commands, and where they plug in: [Hooks — explanation](../explanation/hooks.md)
100+- A complete example with four hooks: `demos/hooks/`
modified docs/en/reference/acp.md +2 -1
@@ -8,7 +8,7 @@
88 |--------|-------|
99 | Input | stdin, one JSON-RPC message per line. |
1010 | Output | stdout, JSON-RPC messages only. |
11-| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. |
11+| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`). |
1212 | Lifetime | Until the client closes stdin or the process context is cancelled. |
1313
1414 ## `initialize` response
@@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
9797 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
9898 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
9999 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
100+| `tool_call` then `tool_call_update` status `failed`, title `<tool> — denied by a hook` | A `preToolCall` [hook](hooks.md) denied the call: the tool did not run and no permission was requested; the `text` content is `[denied by a hook: <reason>]`. |
100101 | `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. |
101102
102103 ## Available commands
@@ -8,7 +8,7 @@
8 |--------|-------|8 |--------|-------|
9 | Input | stdin, one JSON-RPC message per line. |9 | Input | stdin, one JSON-RPC message per line. |
10 | Output | stdout, JSON-RPC messages only. |10 | Output | stdout, JSON-RPC messages only. |
11-| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. |11+| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`). |
12 | Lifetime | Until the client closes stdin or the process context is cancelled. |12 | Lifetime | Until the client closes stdin or the process context is cancelled. |
13 13
14 ## `initialize` response14 ## `initialize` response
@@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
97 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |97 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
98 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |98 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
99 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |99 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
100+| `tool_call` then `tool_call_update` status `failed`, title `<tool> — denied by a hook` | A `preToolCall` [hook](hooks.md) denied the call: the tool did not run and no permission was requested; the `text` content is `[denied by a hook: <reason>]`. |
100 | `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. |101 | `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. |
101 102
102 ## Available commands103 ## Available commands
modified docs/en/reference/cli.md +1 -1
@@ -75,7 +75,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
7575 | Line | Content |
7676 |------|---------|
7777 | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), the deprecated `permissions` key. |
78-| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. |
78+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `hooks:` (when the configuration declares any): `<event>×<count>` per event that has hooks, e.g. `hooks: preGeneration×1 preToolCall×2` — see [hooks](hooks.md#start-up-banner). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. |
7979 | `Agent ready. Commands: …` | Terminal mode only. |
8080 | `Shortcut: Ctrl+C …` | Terminal mode only. |
8181 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. |
@@ -75,7 +75,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
75 | Line | Content |75 | Line | Content |
76 |------|---------|76 |------|---------|
77 | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), the deprecated `permissions` key. |77 | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), the deprecated `permissions` key. |
78-| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. |78+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `hooks:` (when the configuration declares any): `<event>×<count>` per event that has hooks, e.g. `hooks: preGeneration×1 preToolCall×2` — see [hooks](hooks.md#start-up-banner). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. |
79 | `Agent ready. Commands: …` | Terminal mode only. |79 | `Agent ready. Commands: …` | Terminal mode only. |
80 | `Shortcut: Ctrl+C …` | Terminal mode only. |80 | `Shortcut: Ctrl+C …` | Terminal mode only. |
81 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. |81 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. |
modified docs/en/reference/configuration.md +2 -0
@@ -34,6 +34,7 @@
3434 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |
3535 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |
3636 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
37+| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) and after the turn (`postGeneration`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool events only), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). |
3738 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
3839 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
3940 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |
@@ -211,6 +212,7 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev
211212 | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |
212213 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |
213214 | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` |
215+| `hooks: unknown event "x" (…)`, `hooks.<event>[N]: …` | an invalid `hooks:` block — see [hooks](hooks.md#validation-errors) |
214216 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. |
215217
216218 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`).
@@ -34,6 +34,7 @@
34 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |34 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |
35 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |35 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |
36 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |36 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
37+| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) and after the turn (`postGeneration`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool events only), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). |
37 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |38 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
38 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |39 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
39 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |40 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |
@@ -211,6 +212,7 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev
211 | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |212 | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |
212 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |213 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |
213 | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` |214 | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` |
215+| `hooks: unknown event "x" (…)`, `hooks.<event>[N]: …` | an invalid `hooks:` block — see [hooks](hooks.md#validation-errors) |
214 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. |216 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. |
215 217
216 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`).218 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`).
added docs/en/reference/hooks.md +206 -0
new file mode 100644
@@ -0,0 +1,206 @@
1+# Reference: hooks
2+
3+> The `hooks:` block of the configuration file: the events, the JSON a hook reads and writes, the environment it runs in, and what each decision does to the turn.
4+
5+## Configuration
6+
7+```yaml
8+hooks:
9+ preGeneration: [<hook>, …]
10+ preToolCall: [<hook>, …]
11+ postToolCall: [<hook>, …]
12+ postGeneration: [<hook>, …]
13+```
14+
15+Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No `hooks:` block = no hook.
16+
17+### Hook entry
18+
19+| Key | Type | Default | Description |
20+|-----|------|---------|-------------|
21+| `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. |
22+| `matcher` | string (regular expression, Go syntax) | `""` (every tool) | `preToolCall` and `postToolCall` only. The hook runs for the tools whose name matches. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. |
23+| `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. |
24+| `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). |
25+
26+### Example
27+
28+```yaml
29+hooks:
30+ preGeneration:
31+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
32+ timeout: 5s
33+ onError: fail
34+ preToolCall:
35+ - matcher: ^(bash|write_file|edit_file)$
36+ command: ./hooks/guard.sh
37+ postGeneration:
38+ - command: python3 "$MM_CONFIG_DIR/hooks/log.py"
39+ onError: ignore
40+```
41+
42+## Events
43+
44+| Event | Runs | Tools concerned | Can |
45+|-------|------|-----------------|-----|
46+| `preGeneration` | once per question, before the first call to the model | — | rewrite the question, add context, stop the turn |
47+| `preToolCall` | before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by `matcher` | deny the call, replace its arguments |
48+| `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output |
49+| `postGeneration` | once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only |
50+
51+All four run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`, and an agent started by `/ask`, `ask_agent` or `mm --ask` (with the hooks of **its own** configuration file). A `preGeneration` hook does not run on a context-compression summary.
52+
53+## Execution
54+
55+| Aspect | Value |
56+|--------|-------|
57+| Command | `sh -c "<command>"` |
58+| Working directory | the session's: the directory `mm` runs in, or the editor session's `cwd` over ACP |
59+| Environment | `mm`'s own, plus the two variables below |
60+| stdin | one JSON object ([Input](#input)), then end of file |
61+| stdout | empty, or one JSON object ([Reply](#reply)); at most 1 MiB |
62+| stderr | quoted (first 300 bytes) in the failure line when the run fails; otherwise ignored |
63+| Concurrency | hooks of one event run one after the other; `preToolCall`/`postToolCall` may run concurrently for two tool calls of one model reply |
64+
65+### Environment variables
66+
67+| Variable | Value |
68+|----------|-------|
69+| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall` or `postGeneration` |
70+| `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook |
71+
72+## Input
73+
74+The JSON object written on the hook's stdin.
75+
76+| Field | Type | Events | Description |
77+|-------|------|--------|-------------|
78+| `event` | string | all | the event's name |
79+| `cwd` | string | all | the session's working directory, absolute |
80+| `model` | string | all | the model reference, `<provider>/<model>` |
81+| `prompt` | string | `preGeneration`, `postGeneration` | the question as typed — `@path` and `$skill` lines included. In a chain, the question as the previous hook left it. In `postGeneration`: the question the model read, or the marker `[prompt blocked by a hook: <reason>]` when a hook stopped the turn. |
82+| `tool` | object | `preToolCall`, `postToolCall` | the tool call: `id` (the call's reference, may be empty), `name`, `input` (the arguments, as the model sent them or as the previous hook replaced them) |
83+| `output` | string | `postToolCall` | the tool's output, as the model will read it (JSON when the tool returns something other than text) |
84+| `answer` | string | `postGeneration` | the text of the last answer; empty when there is none |
85+| `error` | string | `postGeneration` | the turn's error; absent when the turn succeeded |
86+
87+Empty fields are omitted.
88+
89+### Examples
90+
91+```json
92+{"event":"preGeneration","cwd":"/home/me/project","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"}
93+```
94+
95+```json
96+{"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97+```
98+
99+## Reply
100+
101+The JSON object a hook may write on stdout. **An empty stdout (or whitespace only) is `{"action":"continue"}`.** Every field is optional.
102+
103+| Field | Type | Read on | Description |
104+|-------|------|---------|-------------|
105+| `action` | string | all | `continue` (default, also when absent), `stop`, `deny` or `error` — see below |
106+| `prompt` | string | `preGeneration` | replaces the question. Absent = unchanged; `""` = an empty question. |
107+| `context` | string | `preGeneration`, `postToolCall` | text for the model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. |
108+| `input` | any JSON value | `preToolCall` | replaces the tool call's arguments. Absent = unchanged. |
109+| `reason` | string | `preGeneration` (`stop`), `preToolCall` (`deny`) | why. |
110+| `response` | string | `preGeneration` (`stop`) | what the user reads instead of an answer. Absent or blank: `⛔ This request was blocked by a hook: <reason>`. |
111+| `error` | string | all (`error`) | the hook's own failure message. |
112+
113+Fields read on another event are ignored.
114+
115+### Actions
116+
117+| Action | Allowed on | Effect |
118+|--------|-----------|--------|
119+| `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied |
120+| `stop` | `preGeneration` | the turn ends without calling the model; the following hooks of the event do not run |
121+| `deny` | `preToolCall` | the tool does not run; the following hooks of the event do not run |
122+| `error` | all | a failure, handled according to `onError` |
123+| anything else, or `stop`/`deny` on another event | — | a failure: `action "<action>" is not valid for <event>` |
124+
125+## Effects on the turn
126+
127+### `preGeneration`
128+
129+| Decision | The model reads | The history keeps | The user sees |
130+|----------|-----------------|-------------------|---------------|
131+| no change | the question | the question | — |
132+| `prompt` and/or `context` | the rewritten question, then each context | the rewritten question (the front end's own copy is not changed during the turn) | — |
133+| `stop` | nothing: the model is not called | `[prompt blocked by a hook: <reason>]` (or `[prompt blocked by a hook]`) followed by the `response` as the answer | the `response`, as the answer |
134+
135+A chain: each hook reads the `prompt` the previous one left; contexts accumulate in hook order.
136+
137+### `preToolCall`
138+
139+| Decision | The tool | The model reads | The user sees |
140+|----------|----------|-----------------|---------------|
141+| no change | runs with the model's arguments | the output | the usual tool line |
142+| `input` | runs with the replaced arguments | the output | the usual tool line (with the replaced arguments) |
143+| `deny` | does not run, and does not ask for consent | `[denied by a hook: <reason>]` as the tool's output | line mode: `🪝 <tool> [denied by a hook: <reason>]`; window and editor: a failed tool call titled `<tool> — denied by a hook` |
144+
145+The history keeps the tool call as the model wrote it, even when `input` replaced its arguments.
146+
147+### `postToolCall`
148+
149+`context` from every matching hook is joined and appended to the output: `<output>\n\n[context added by a hook]\n<context>`. The tool's output shown to the user is unchanged.
150+
151+### `postGeneration`
152+
153+No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored.
154+
155+## Failures
156+
157+A run fails when:
158+
159+| Failure | Report |
160+|---------|--------|
161+| the command exits with a non-zero status | `exit status <n>: <stderr>` |
162+| it runs longer than `timeout` | `timed out after <timeout>` |
163+| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |
164+| stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` |
165+| stdout is not JSON | `invalid JSON on stdout: …` |
166+| `action` is `error` | the reply's `error`, or `the hook reported an error` |
167+| `action` is unknown or not allowed on the event | `action "<action>" is not valid for <event>` |
168+
169+`postGeneration` is not cancelled with the turn: it runs after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by its `timeout`.
170+
171+What follows depends on `onError`:
172+
173+| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |
174+|-----------|-----------------|---------------|----------------------------------|
175+| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call denied (`reason`: `hook failed: …`) | report line, carry on |
176+| `warn` | report line, next hook | report line, next hook | report line, carry on |
177+| `ignore` | next hook, silently | next hook, silently | carry on, silently |
178+
179+The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP.
180+
181+## Start-up banner
182+
183+When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`:
184+
185+```
186+mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
187+```
188+
189+## Validation errors
190+
191+Reported at start-up as `[config error: <path>: <message>]`, exit code 1.
192+
193+| Message | Cause |
194+|---------|-------|
195+| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) |
196+| `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one |
197+| `hooks.<event>[N]: matcher is only for preToolCall and postToolCall` | a `matcher` on `preGeneration` or `postGeneration` |
198+| `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile |
199+| `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` |
200+| `hooks.<event>[N]: onError must be "fail", "warn" or "ignore"` | any other `onError` |
201+
202+## See also
203+
204+- [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md)
205+- [Hooks — explanation](../explanation/hooks.md)
206+- The demo: `demos/hooks/` (four Node.js hooks)
new file mode 100644
@@ -0,0 +1,206 @@
1+# Reference: hooks
2+
3+> The `hooks:` block of the configuration file: the events, the JSON a hook reads and writes, the environment it runs in, and what each decision does to the turn.
4+
5+## Configuration
6+
7+```yaml
8+hooks:
9+ preGeneration: [<hook>, …]
10+ preToolCall: [<hook>, …]
11+ postToolCall: [<hook>, …]
12+ postGeneration: [<hook>, …]
13+```
14+
15+Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No `hooks:` block = no hook.
16+
17+### Hook entry
18+
19+| Key | Type | Default | Description |
20+|-----|------|---------|-------------|
21+| `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. |
22+| `matcher` | string (regular expression, Go syntax) | `""` (every tool) | `preToolCall` and `postToolCall` only. The hook runs for the tools whose name matches. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. |
23+| `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. |
24+| `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). |
25+
26+### Example
27+
28+```yaml
29+hooks:
30+ preGeneration:
31+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
32+ timeout: 5s
33+ onError: fail
34+ preToolCall:
35+ - matcher: ^(bash|write_file|edit_file)$
36+ command: ./hooks/guard.sh
37+ postGeneration:
38+ - command: python3 "$MM_CONFIG_DIR/hooks/log.py"
39+ onError: ignore
40+```
41+
42+## Events
43+
44+| Event | Runs | Tools concerned | Can |
45+|-------|------|-----------------|-----|
46+| `preGeneration` | once per question, before the first call to the model | — | rewrite the question, add context, stop the turn |
47+| `preToolCall` | before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by `matcher` | deny the call, replace its arguments |
48+| `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output |
49+| `postGeneration` | once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only |
50+
51+All four run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`, and an agent started by `/ask`, `ask_agent` or `mm --ask` (with the hooks of **its own** configuration file). A `preGeneration` hook does not run on a context-compression summary.
52+
53+## Execution
54+
55+| Aspect | Value |
56+|--------|-------|
57+| Command | `sh -c "<command>"` |
58+| Working directory | the session's: the directory `mm` runs in, or the editor session's `cwd` over ACP |
59+| Environment | `mm`'s own, plus the two variables below |
60+| stdin | one JSON object ([Input](#input)), then end of file |
61+| stdout | empty, or one JSON object ([Reply](#reply)); at most 1 MiB |
62+| stderr | quoted (first 300 bytes) in the failure line when the run fails; otherwise ignored |
63+| Concurrency | hooks of one event run one after the other; `preToolCall`/`postToolCall` may run concurrently for two tool calls of one model reply |
64+
65+### Environment variables
66+
67+| Variable | Value |
68+|----------|-------|
69+| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall` or `postGeneration` |
70+| `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook |
71+
72+## Input
73+
74+The JSON object written on the hook's stdin.
75+
76+| Field | Type | Events | Description |
77+|-------|------|--------|-------------|
78+| `event` | string | all | the event's name |
79+| `cwd` | string | all | the session's working directory, absolute |
80+| `model` | string | all | the model reference, `<provider>/<model>` |
81+| `prompt` | string | `preGeneration`, `postGeneration` | the question as typed — `@path` and `$skill` lines included. In a chain, the question as the previous hook left it. In `postGeneration`: the question the model read, or the marker `[prompt blocked by a hook: <reason>]` when a hook stopped the turn. |
82+| `tool` | object | `preToolCall`, `postToolCall` | the tool call: `id` (the call's reference, may be empty), `name`, `input` (the arguments, as the model sent them or as the previous hook replaced them) |
83+| `output` | string | `postToolCall` | the tool's output, as the model will read it (JSON when the tool returns something other than text) |
84+| `answer` | string | `postGeneration` | the text of the last answer; empty when there is none |
85+| `error` | string | `postGeneration` | the turn's error; absent when the turn succeeded |
86+
87+Empty fields are omitted.
88+
89+### Examples
90+
91+```json
92+{"event":"preGeneration","cwd":"/home/me/project","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"}
93+```
94+
95+```json
96+{"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97+```
98+
99+## Reply
100+
101+The JSON object a hook may write on stdout. **An empty stdout (or whitespace only) is `{"action":"continue"}`.** Every field is optional.
102+
103+| Field | Type | Read on | Description |
104+|-------|------|---------|-------------|
105+| `action` | string | all | `continue` (default, also when absent), `stop`, `deny` or `error` — see below |
106+| `prompt` | string | `preGeneration` | replaces the question. Absent = unchanged; `""` = an empty question. |
107+| `context` | string | `preGeneration`, `postToolCall` | text for the model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. |
108+| `input` | any JSON value | `preToolCall` | replaces the tool call's arguments. Absent = unchanged. |
109+| `reason` | string | `preGeneration` (`stop`), `preToolCall` (`deny`) | why. |
110+| `response` | string | `preGeneration` (`stop`) | what the user reads instead of an answer. Absent or blank: `⛔ This request was blocked by a hook: <reason>`. |
111+| `error` | string | all (`error`) | the hook's own failure message. |
112+
113+Fields read on another event are ignored.
114+
115+### Actions
116+
117+| Action | Allowed on | Effect |
118+|--------|-----------|--------|
119+| `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied |
120+| `stop` | `preGeneration` | the turn ends without calling the model; the following hooks of the event do not run |
121+| `deny` | `preToolCall` | the tool does not run; the following hooks of the event do not run |
122+| `error` | all | a failure, handled according to `onError` |
123+| anything else, or `stop`/`deny` on another event | — | a failure: `action "<action>" is not valid for <event>` |
124+
125+## Effects on the turn
126+
127+### `preGeneration`
128+
129+| Decision | The model reads | The history keeps | The user sees |
130+|----------|-----------------|-------------------|---------------|
131+| no change | the question | the question | — |
132+| `prompt` and/or `context` | the rewritten question, then each context | the rewritten question (the front end's own copy is not changed during the turn) | — |
133+| `stop` | nothing: the model is not called | `[prompt blocked by a hook: <reason>]` (or `[prompt blocked by a hook]`) followed by the `response` as the answer | the `response`, as the answer |
134+
135+A chain: each hook reads the `prompt` the previous one left; contexts accumulate in hook order.
136+
137+### `preToolCall`
138+
139+| Decision | The tool | The model reads | The user sees |
140+|----------|----------|-----------------|---------------|
141+| no change | runs with the model's arguments | the output | the usual tool line |
142+| `input` | runs with the replaced arguments | the output | the usual tool line (with the replaced arguments) |
143+| `deny` | does not run, and does not ask for consent | `[denied by a hook: <reason>]` as the tool's output | line mode: `🪝 <tool> [denied by a hook: <reason>]`; window and editor: a failed tool call titled `<tool> — denied by a hook` |
144+
145+The history keeps the tool call as the model wrote it, even when `input` replaced its arguments.
146+
147+### `postToolCall`
148+
149+`context` from every matching hook is joined and appended to the output: `<output>\n\n[context added by a hook]\n<context>`. The tool's output shown to the user is unchanged.
150+
151+### `postGeneration`
152+
153+No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored.
154+
155+## Failures
156+
157+A run fails when:
158+
159+| Failure | Report |
160+|---------|--------|
161+| the command exits with a non-zero status | `exit status <n>: <stderr>` |
162+| it runs longer than `timeout` | `timed out after <timeout>` |
163+| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |
164+| stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` |
165+| stdout is not JSON | `invalid JSON on stdout: …` |
166+| `action` is `error` | the reply's `error`, or `the hook reported an error` |
167+| `action` is unknown or not allowed on the event | `action "<action>" is not valid for <event>` |
168+
169+`postGeneration` is not cancelled with the turn: it runs after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by its `timeout`.
170+
171+What follows depends on `onError`:
172+
173+| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |
174+|-----------|-----------------|---------------|----------------------------------|
175+| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call denied (`reason`: `hook failed: …`) | report line, carry on |
176+| `warn` | report line, next hook | report line, next hook | report line, carry on |
177+| `ignore` | next hook, silently | next hook, silently | carry on, silently |
178+
179+The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP.
180+
181+## Start-up banner
182+
183+When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`:
184+
185+```
186+mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
187+```
188+
189+## Validation errors
190+
191+Reported at start-up as `[config error: <path>: <message>]`, exit code 1.
192+
193+| Message | Cause |
194+|---------|-------|
195+| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) |
196+| `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one |
197+| `hooks.<event>[N]: matcher is only for preToolCall and postToolCall` | a `matcher` on `preGeneration` or `postGeneration` |
198+| `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile |
199+| `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` |
200+| `hooks.<event>[N]: onError must be "fail", "warn" or "ignore"` | any other `onError` |
201+
202+## See also
203+
204+- [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md)
205+- [Hooks — explanation](../explanation/hooks.md)
206+- The demo: `demos/hooks/` (four Node.js hooks)
modified docs/fr/README.md +3 -0
@@ -35,6 +35,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
3535 - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md)
3636 - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53
3737 - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher
38+- [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage
3839
3940 ### Guides pratiques MCP (Model Context Protocol)
4041 - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md)
@@ -48,6 +49,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
4849 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
4950 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
5051 - [Format d'un fichier de skill](reference/skill-format.md)
52+- [Les hooks](reference/hooks.md) — les quatre événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
5153 - [Surface Agent Client Protocol](reference/acp.md)
5254
5355 ### Référence MCP (Model Context Protocol)
@@ -69,6 +71,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
6971 - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse
7072 - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur
7173 - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP
74+- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté
7275 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
7376
7477 ### Explication MCP (Model Context Protocol)
@@ -35,6 +35,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
35 - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md)35 - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md)
36 - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 5336 - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53
37 - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher37 - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher
38+- [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage
38 39
39 ### Guides pratiques MCP (Model Context Protocol)40 ### Guides pratiques MCP (Model Context Protocol)
40 - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md)41 - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md)
@@ -48,6 +49,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
48 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation49 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
49 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`50 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
50 - [Format d'un fichier de skill](reference/skill-format.md)51 - [Format d'un fichier de skill](reference/skill-format.md)
52+- [Les hooks](reference/hooks.md) — les quatre événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
51 - [Surface Agent Client Protocol](reference/acp.md)53 - [Surface Agent Client Protocol](reference/acp.md)
52 54
53 ### Référence MCP (Model Context Protocol)55 ### Référence MCP (Model Context Protocol)
@@ -69,6 +71,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
69 - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse71 - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse
70 - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur72 - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur
71 - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP73 - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP
74+- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté
72 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)75 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
73 76
74 ### Explication MCP (Model Context Protocol)77 ### Explication MCP (Model Context Protocol)
modified docs/fr/explanation/architecture.md +1 -0
@@ -27,6 +27,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
2727 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
2828 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
2929 | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). |
30+| `hooks` | Le bloc `hooks:` (ticket 36) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). |
3031 | `agent` | Le REPL du terminal. |
3132 | `acp` | La façade Agent Client Protocol. |
3233 | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |
@@ -27,6 +27,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
27 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |27 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
28 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |28 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
29 | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). |29 | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). |
30+| `hooks` | Le bloc `hooks:` (ticket 36) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). |
30 | `agent` | Le REPL du terminal. |31 | `agent` | Le REPL du terminal. |
31 | `acp` | La façade Agent Client Protocol. |32 | `acp` | La façade Agent Client Protocol. |
32 | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |33 | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |
added docs/fr/explanation/hooks.md +69 -0
new file mode 100644
@@ -0,0 +1,69 @@
1+# Les hooks — explication
2+
3+## De quoi s'agit-il ?
4+
5+Un hook est une commande que `mm` lance à quatre moments d'un tour : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), et une fois le tour terminé (`postGeneration`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`.
6+
7+La conception part du ticket 36, qui explorait un système bien plus vaste. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté.
8+
9+## Pourquoi des commandes externes
10+
11+La première exigence du ticket était qu'on puisse écrire un hook dans **n'importe quel langage**. Cela exclut une interface de plugins Go (il faudrait recompiler, et le paquet `plugin` de Go n'existe pas sur toutes les plateformes pour lesquelles `mm` est construit) et un interpréteur embarqué (il imposerait un langage à tout le monde). Un processus qui lit stdin et écrit sur stdout est la seule interface que tous les langages ont.
12+
13+Elle a des coûts, acceptés en connaissance de cause :
14+
15+- **Un lancement de processus par événement.** Un hook `preToolCall` s'exécute à chaque appel d'outil ; `node` met quelques dizaines de millisecondes à démarrer. Face à un modèle local qui passe des secondes sur chaque réponse, c'est négligeable. C'est aussi pourquoi chaque hook a un `timeout`, et pourquoi la valeur par défaut est courte (10 s) : un hook est au milieu du tour, et un hook lent se sent tout de suite.
16+- **Le JSON comme contrat.** L'entrée et la réponse sont petites et plates, pour qu'un hook en shell avec `jq` soit aussi simple à écrire qu'en Python ou en Node.
17+
18+On retrouve la même forme ailleurs dans l'écosystème (hooks d'éditeurs et d'agents, hooks git et leurs arguments) : qui en a écrit un reconnaît l'autre.
19+
20+## Où ils se branchent
21+
22+Chaque front end — le mode ligne, la fenêtre, la façade ACP, `-p` — confie son tour à une seule fonction, `engine.Generate`. Et à l'intérieur, le middleware de Genkit a déjà un point d'accroche `WrapTool` qui voit **chaque** appel d'outil, intégré ou MCP, avant qu'il s'exécute. Les hooks s'exécutent à ces deux endroits :
23+
24+```
25+front end ──▶ engine.Generate
26+ ├─ preGeneration (la question, avant le premier appel au modèle)
27+ ├─ modèle ⇄ outils ── WrapTool
28+ │ ├─ preToolCall (avant l'outil, et sa demande d'autorisation)
29+ │ └─ postToolCall (après l'outil)
30+ └─ postGeneration (le tour est terminé, quelle qu'en soit l'issue)
31+```
32+
33+Les placer là plutôt que dans chaque front end, c'est une seule implémentation, un seul jeu de tests, et aucun front end qui les oublie. Un nouveau front end a les hooks sans rien faire.
34+
35+`preToolCall` s'exécute **avant** la demande d'autorisation de l'outil. Un appel refusé ne demande donc jamais rien à l'utilisateur ; et un garde continue de fonctionner avec `yolo: true`, c'est-à-dire justement quand il compte le plus.
36+
37+Le paquet `internal/hooks` lance les commandes et transforme leurs réponses en décisions ; il ne sait rien de Genkit ni des front ends. `internal/engine/hooks.go` applique ces décisions aux messages et les montre : comme réponse, comme appel d'outil en échec, ou comme remarque — une ligne dans le terminal, une ligne grisée dans la fenêtre, les journaux de l'éditeur en ACP.
38+
39+## Les décisions à connaître
40+
41+**Une question arrêtée laisse un marqueur, pas la question.** La raison évidente d'arrêter une question, c'est qu'elle contient quelque chose qui ne doit pas atteindre le modèle : un mot de passe, une clé. La garder dans l'historique l'enverrait au tour suivant. L'historique garde donc `[prompt blocked by a hook: <raison>]` suivi de la réponse du hook — assez pour que le modèle comprenne ce qui s'est passé, rien qu'il ne doive pas lire. Le premier essai réel de la démo a trouvé une seconde fuite : le hook d'audit sur `postGeneration` recevait la question bloquée et écrivait le secret dans son journal. Les hooks `post*` lisent désormais le marqueur, eux aussi.
42+
43+**Les échecs suivent `onError`, et la valeur par défaut est `warn`.** Un hook qui plante est signalé, et le tour continue : un script de journalisation cassé ne doit pas priver l'utilisateur de son agent. Un garde, lui, doit déclarer `onError: fail` — un garde qui ne peut pas s'exécuter ne doit pas laisser passer l'appel. Les deux besoins sont opposés, d'où un choix par hook.
44+
45+**Un nom d'événement inconnu est une erreur.** YAML ignore les clés qu'il ne connaît pas ; `preToolcall:` aurait été un garde qui ne s'exécute jamais, sans rien dire. Refuser de démarrer est l'échec le plus sûr.
46+
47+**Un appel d'outil refusé répond au modèle sous forme de texte.** Comme tout échec d'outil dans `mm`, le refus est la sortie de l'outil : `[denied by a hook: <raison>]`. Le modèle peut prévenir l'utilisateur ou s'y prendre autrement, au lieu que le tour se termine sur une erreur.
48+
49+**`postGeneration` n'est pas annulé avec le tour.** Un journal d'audit qui perd les tours interrompus par l'utilisateur, c'est le monde à l'envers. Le hook s'exécute aussi après une annulation, borné par son propre délai.
50+
51+**Des arguments d'outil remplacés ne sont pas réécrits dans l'historique.** L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard.
52+
53+## Les alternatives écartées
54+
55+Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi :
56+
57+- **Renvoyer le tableau `messages` entier.** Le premier protocole du ticket laissait un hook réécrire la conversation. C'est fragile — un hook pourrait supprimer le prompt système ou réécrire les tours passés, et chaque hook devrait faire l'aller-retour de tout l'historique — et les quatre besoins (détecter, réécrire, ajouter du contexte, arrêter) sont couverts par `prompt`, `context` et `action`.
58+- **Des priorités numériques.** L'ordre de la liste YAML est l'ordre d'exécution. Un champ de priorité ajoute un second ordre à garder en tête, sans rien apporter quand la liste est courte.
59+- **Un mode `async`.** Un hook de journalisation est rapide ; quand il ne l'est pas, `onError: ignore` avec un `timeout` court le borne. Un mode en arrière-plan demanderait une file d'attente, un vidage à l'arrêt et une façon de signaler les échecs tardifs.
60+- **`allowedPaths` / `allowedExtensions`.** Le fichier YAML lance déjà des serveurs MCP et donne `bash` au modèle. On confie à qui l'écrit bien plus qu'un chemin de hook ; une liste blanche ne protégerait rien.
61+- **Les hooks de cycle de vie, de session et de front end** (démarrage, configuration chargée, arrêt, fenêtre ouverte, éditeur connecté). Aucun cas d'usage ne les a encore demandés. On pourra les ajouter plus tard comme nouveaux événements, sans changer le protocole.
62+
63+## Les liens avec le reste
64+
65+- **Yolo et autorisations.** Les hooks et `yolo: false` se complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir [Le mode yolo et les permissions](yolo-and-permissions.md).
66+- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge.
67+- **La compression du contexte** demande un résumé au modèle par un autre chemin que `Generate` ; les hooks ne s'y exécutent pas.
68+
69+Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, et [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un.
new file mode 100644
@@ -0,0 +1,69 @@
1+# Les hooks — explication
2+
3+## De quoi s'agit-il ?
4+
5+Un hook est une commande que `mm` lance à quatre moments d'un tour : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), et une fois le tour terminé (`postGeneration`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`.
6+
7+La conception part du ticket 36, qui explorait un système bien plus vaste. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté.
8+
9+## Pourquoi des commandes externes
10+
11+La première exigence du ticket était qu'on puisse écrire un hook dans **n'importe quel langage**. Cela exclut une interface de plugins Go (il faudrait recompiler, et le paquet `plugin` de Go n'existe pas sur toutes les plateformes pour lesquelles `mm` est construit) et un interpréteur embarqué (il imposerait un langage à tout le monde). Un processus qui lit stdin et écrit sur stdout est la seule interface que tous les langages ont.
12+
13+Elle a des coûts, acceptés en connaissance de cause :
14+
15+- **Un lancement de processus par événement.** Un hook `preToolCall` s'exécute à chaque appel d'outil ; `node` met quelques dizaines de millisecondes à démarrer. Face à un modèle local qui passe des secondes sur chaque réponse, c'est négligeable. C'est aussi pourquoi chaque hook a un `timeout`, et pourquoi la valeur par défaut est courte (10 s) : un hook est au milieu du tour, et un hook lent se sent tout de suite.
16+- **Le JSON comme contrat.** L'entrée et la réponse sont petites et plates, pour qu'un hook en shell avec `jq` soit aussi simple à écrire qu'en Python ou en Node.
17+
18+On retrouve la même forme ailleurs dans l'écosystème (hooks d'éditeurs et d'agents, hooks git et leurs arguments) : qui en a écrit un reconnaît l'autre.
19+
20+## Où ils se branchent
21+
22+Chaque front end — le mode ligne, la fenêtre, la façade ACP, `-p` — confie son tour à une seule fonction, `engine.Generate`. Et à l'intérieur, le middleware de Genkit a déjà un point d'accroche `WrapTool` qui voit **chaque** appel d'outil, intégré ou MCP, avant qu'il s'exécute. Les hooks s'exécutent à ces deux endroits :
23+
24+```
25+front end ──▶ engine.Generate
26+ ├─ preGeneration (la question, avant le premier appel au modèle)
27+ ├─ modèle ⇄ outils ── WrapTool
28+ │ ├─ preToolCall (avant l'outil, et sa demande d'autorisation)
29+ │ └─ postToolCall (après l'outil)
30+ └─ postGeneration (le tour est terminé, quelle qu'en soit l'issue)
31+```
32+
33+Les placer là plutôt que dans chaque front end, c'est une seule implémentation, un seul jeu de tests, et aucun front end qui les oublie. Un nouveau front end a les hooks sans rien faire.
34+
35+`preToolCall` s'exécute **avant** la demande d'autorisation de l'outil. Un appel refusé ne demande donc jamais rien à l'utilisateur ; et un garde continue de fonctionner avec `yolo: true`, c'est-à-dire justement quand il compte le plus.
36+
37+Le paquet `internal/hooks` lance les commandes et transforme leurs réponses en décisions ; il ne sait rien de Genkit ni des front ends. `internal/engine/hooks.go` applique ces décisions aux messages et les montre : comme réponse, comme appel d'outil en échec, ou comme remarque — une ligne dans le terminal, une ligne grisée dans la fenêtre, les journaux de l'éditeur en ACP.
38+
39+## Les décisions à connaître
40+
41+**Une question arrêtée laisse un marqueur, pas la question.** La raison évidente d'arrêter une question, c'est qu'elle contient quelque chose qui ne doit pas atteindre le modèle : un mot de passe, une clé. La garder dans l'historique l'enverrait au tour suivant. L'historique garde donc `[prompt blocked by a hook: <raison>]` suivi de la réponse du hook — assez pour que le modèle comprenne ce qui s'est passé, rien qu'il ne doive pas lire. Le premier essai réel de la démo a trouvé une seconde fuite : le hook d'audit sur `postGeneration` recevait la question bloquée et écrivait le secret dans son journal. Les hooks `post*` lisent désormais le marqueur, eux aussi.
42+
43+**Les échecs suivent `onError`, et la valeur par défaut est `warn`.** Un hook qui plante est signalé, et le tour continue : un script de journalisation cassé ne doit pas priver l'utilisateur de son agent. Un garde, lui, doit déclarer `onError: fail` — un garde qui ne peut pas s'exécuter ne doit pas laisser passer l'appel. Les deux besoins sont opposés, d'où un choix par hook.
44+
45+**Un nom d'événement inconnu est une erreur.** YAML ignore les clés qu'il ne connaît pas ; `preToolcall:` aurait été un garde qui ne s'exécute jamais, sans rien dire. Refuser de démarrer est l'échec le plus sûr.
46+
47+**Un appel d'outil refusé répond au modèle sous forme de texte.** Comme tout échec d'outil dans `mm`, le refus est la sortie de l'outil : `[denied by a hook: <raison>]`. Le modèle peut prévenir l'utilisateur ou s'y prendre autrement, au lieu que le tour se termine sur une erreur.
48+
49+**`postGeneration` n'est pas annulé avec le tour.** Un journal d'audit qui perd les tours interrompus par l'utilisateur, c'est le monde à l'envers. Le hook s'exécute aussi après une annulation, borné par son propre délai.
50+
51+**Des arguments d'outil remplacés ne sont pas réécrits dans l'historique.** L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard.
52+
53+## Les alternatives écartées
54+
55+Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi :
56+
57+- **Renvoyer le tableau `messages` entier.** Le premier protocole du ticket laissait un hook réécrire la conversation. C'est fragile — un hook pourrait supprimer le prompt système ou réécrire les tours passés, et chaque hook devrait faire l'aller-retour de tout l'historique — et les quatre besoins (détecter, réécrire, ajouter du contexte, arrêter) sont couverts par `prompt`, `context` et `action`.
58+- **Des priorités numériques.** L'ordre de la liste YAML est l'ordre d'exécution. Un champ de priorité ajoute un second ordre à garder en tête, sans rien apporter quand la liste est courte.
59+- **Un mode `async`.** Un hook de journalisation est rapide ; quand il ne l'est pas, `onError: ignore` avec un `timeout` court le borne. Un mode en arrière-plan demanderait une file d'attente, un vidage à l'arrêt et une façon de signaler les échecs tardifs.
60+- **`allowedPaths` / `allowedExtensions`.** Le fichier YAML lance déjà des serveurs MCP et donne `bash` au modèle. On confie à qui l'écrit bien plus qu'un chemin de hook ; une liste blanche ne protégerait rien.
61+- **Les hooks de cycle de vie, de session et de front end** (démarrage, configuration chargée, arrêt, fenêtre ouverte, éditeur connecté). Aucun cas d'usage ne les a encore demandés. On pourra les ajouter plus tard comme nouveaux événements, sans changer le protocole.
62+
63+## Les liens avec le reste
64+
65+- **Yolo et autorisations.** Les hooks et `yolo: false` se complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir [Le mode yolo et les permissions](yolo-and-permissions.md).
66+- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge.
67+- **La compression du contexte** demande un résumé au modèle par un autre chemin que `Generate` ; les hooks ne s'y exécutent pas.
68+
69+Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, et [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un.
modified docs/fr/explanation/yolo-and-permissions.md +1 -0
@@ -51,3 +51,4 @@ Un raffinement garde le même principe : le fichier de l'utilisateur peut donner
5151 - Comment l'activer : [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md).
5252 - Le chemin d'événements que partagent les façades (`ui.Sink`, `Allow`) : [Architecture](architecture.md).
5353 - Interroger un autre agent : [Interroger un autre agent](asking-another-agent.md).
54+- Décider selon une règle plutôt qu'en demandant : [Les hooks](hooks.md) — un hook `preToolCall` s'exécute avant la demande d'autorisation, si bien qu'un appel qu'il refuse n'est jamais soumis à l'utilisateur.
@@ -51,3 +51,4 @@ Un raffinement garde le même principe : le fichier de l'utilisateur peut donner
51 - Comment l'activer : [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md).51 - Comment l'activer : [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md).
52 - Le chemin d'événements que partagent les façades (`ui.Sink`, `Allow`) : [Architecture](architecture.md).52 - Le chemin d'événements que partagent les façades (`ui.Sink`, `Allow`) : [Architecture](architecture.md).
53 - Interroger un autre agent : [Interroger un autre agent](asking-another-agent.md).53 - Interroger un autre agent : [Interroger un autre agent](asking-another-agent.md).
54+- Décider selon une règle plutôt qu'en demandant : [Les hooks](hooks.md) — un hook `preToolCall` s'exécute avant la demande d'autorisation, si bien qu'un appel qu'il refuse n'est jamais soumis à l'utilisateur.
modified docs/fr/how-to/confirm-tool-calls.md +1 -0
@@ -55,3 +55,4 @@ Par défaut, mini-me tourne en **mode yolo** : `bash`, les écritures et les mod
5555 - Chaque clé et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) (`yolo`, `permissions`).
5656 - Ce que montre chaque façade : [référence de la CLI](../reference/cli.md), [référence ACP](../reference/acp.md#requêtes-sessionrequest_permission-envoyées).
5757 - Pourquoi yolo est la valeur par défaut et pourquoi l'agent qui interroge l'emporte : [Le mode yolo et les permissions](../explanation/yolo-and-permissions.md).
58+- Refuser certains appels automatiquement, selon une règle, sans rien demander : [Protéger l'agent avec des hooks](guard-the-agent-with-hooks.md).
@@ -55,3 +55,4 @@ Par défaut, mini-me tourne en **mode yolo** : `bash`, les écritures et les mod
55 - Chaque clé et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) (`yolo`, `permissions`).55 - Chaque clé et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) (`yolo`, `permissions`).
56 - Ce que montre chaque façade : [référence de la CLI](../reference/cli.md), [référence ACP](../reference/acp.md#requêtes-sessionrequest_permission-envoyées).56 - Ce que montre chaque façade : [référence de la CLI](../reference/cli.md), [référence ACP](../reference/acp.md#requêtes-sessionrequest_permission-envoyées).
57 - Pourquoi yolo est la valeur par défaut et pourquoi l'agent qui interroge l'emporte : [Le mode yolo et les permissions](../explanation/yolo-and-permissions.md).57 - Pourquoi yolo est la valeur par défaut et pourquoi l'agent qui interroge l'emporte : [Le mode yolo et les permissions](../explanation/yolo-and-permissions.md).
58+- Refuser certains appels automatiquement, selon une règle, sans rien demander : [Protéger l'agent avec des hooks](guard-the-agent-with-hooks.md).
added docs/fr/how-to/guard-the-agent-with-hooks.md +100 -0
new file mode 100644
@@ -0,0 +1,100 @@
1+# Protéger l'agent avec des hooks
2+
3+Ce guide montre comment lancer votre propre commande à des moments précis d'un tour : pour refuser un appel d'outil dangereux, arrêter une question avant que le modèle la voie, ajouter du contexte, ou journaliser ce que fait l'agent. Il suppose que vous avez un agent qui fonctionne, et un langage dans lequel écrire un petit script. Les exemples sont en Node.js ; tout programme qui lit stdin et écrit sur stdout convient.
4+
5+## Étapes
6+
7+### 1. Choisir l'événement
8+
9+| Vous voulez… | Événement |
10+|--------------|-----------|
11+| refuser certains appels d'outil, ou changer leurs arguments | `preToolCall` |
12+| arrêter ou réécrire une question, ou lui ajouter du contexte | `preGeneration` |
13+| ajouter une note après la sortie d'un outil | `postToolCall` |
14+| journaliser ou notifier, sans rien changer | `postToolCall`, `postGeneration` |
15+
16+### 2. Écrire le hook
17+
18+Le hook lit un objet JSON sur stdin et, quand il a une décision, en écrit un sur stdout. Ne rien écrire laisse le tour continuer. Celui-ci refuse `git push --force` :
19+
20+```js
21+// hooks/no-force-push.js
22+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
23+const command = input.tool.input.command || "";
24+
25+if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) {
26+ console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" }));
27+}
28+```
29+
30+La raison est ce que le modèle lit à la place de la sortie de la commande : écrivez-la pour le modèle, afin qu'il puisse prévenir l'utilisateur ou essayer autre chose.
31+
32+### 3. L'essayer sans mm
33+
34+Donnez-lui l'entrée que mm enverrait :
35+
36+```bash
37+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \
38+ | node hooks/no-force-push.js
39+```
40+
41+Vous devez voir :
42+
43+```
44+{"action":"deny","reason":"force-push is not allowed in this project"}
45+```
46+
47+Et rien du tout pour `git push origin main`.
48+
49+### 4. Le déclarer
50+
51+Dans le fichier YAML de l'agent, à côté du script :
52+
53+```yaml
54+hooks:
55+ preToolCall:
56+ - matcher: ^bash$
57+ command: node "$MM_CONFIG_DIR/hooks/no-force-push.js"
58+ onError: fail
59+```
60+
61+- `matcher` limite le hook à l'outil `bash` ; sans lui, le hook s'exécute pour chaque appel d'outil.
62+- `$MM_CONFIG_DIR` est le dossier du fichier YAML : le script est trouvé quel que soit le dossier d'où `mm` est lancé.
63+- `onError: fail` refuse l'appel quand le hook lui-même échoue (Node absent, erreur de syntaxe, délai dépassé). Utilisez-le pour les gardes ; gardez la valeur par défaut, `warn`, pour les hooks dont l'échec ne doit pas gêner.
64+
65+### 5. Lancer l'agent et vérifier
66+
67+```bash
68+mm agent.yaml
69+```
70+
71+La bannière se termine par les hooks chargés :
72+
73+```
74+… | yolo: on | hooks: preToolCall×1
75+```
76+
77+Demandez quelque chose qui déclenche la règle (`force-push the main branch`). Le mode ligne affiche :
78+
79+```
80+🪝 bash [denied by a hook: force-push is not allowed in this project]
81+```
82+
83+et la réponse du modèle explique le refus.
84+
85+## Variantes
86+
87+- **Arrêter une question.** Sur `preGeneration`, lisez `input.prompt` et écrivez `{"action":"stop","reason":"…","response":"…"}`. L'utilisateur lit `response` ; le modèle n'est pas appelé, et la question est remplacée dans l'historique par `[prompt blocked by a hook: <raison>]`.
88+- **Ajouter du contexte.** Sur `preGeneration`, écrivez `{"context":"…"}` : le texte est ajouté après la question, sous `[context added by a hook]`. Sur `postToolCall`, le même champ est ajouté après la sortie de l'outil.
89+- **Réécrire.** `{"prompt":"…"}` sur `preGeneration` remplace la question ; `{"input":{…}}` sur `preToolCall` remplace les arguments de l'outil — l'objet entier, donc recopiez les champs que vous gardez.
90+- **Journaliser.** Sur `postToolCall` et `postGeneration`, ajoutez `input` à un fichier et n'écrivez rien ; mettez `onError: ignore` pour qu'un disque plein n'interrompe jamais une session. Un même script peut servir plusieurs événements : lisez `input.event` ou `$MM_HOOK_EVENT`.
91+- **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous.
92+- **Plusieurs hooks sur un événement** s'exécutent dans l'ordre écrit ; chacun voit la réécriture du précédent, et le premier `stop` ou `deny` arrête la chaîne.
93+- **Avec `yolo: false`**, un hook `preToolCall` s'exécute avant la demande d'autorisation : un appel refusé n'est jamais soumis à l'utilisateur.
94+- **Dans un éditeur** (`mm -acp`), les hooks s'exécutent dans le dossier de travail de la session, et un appel refusé apparaît comme un appel d'outil en échec intitulé `<outil> — denied by a hook`.
95+
96+## Voir aussi
97+
98+- Chaque champ, action, échec et limite : [Référence des hooks](../reference/hooks.md)
99+- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks — explication](../explanation/hooks.md)
100+- Un exemple complet avec quatre hooks : `demos/hooks/`
new file mode 100644
@@ -0,0 +1,100 @@
1+# Protéger l'agent avec des hooks
2+
3+Ce guide montre comment lancer votre propre commande à des moments précis d'un tour : pour refuser un appel d'outil dangereux, arrêter une question avant que le modèle la voie, ajouter du contexte, ou journaliser ce que fait l'agent. Il suppose que vous avez un agent qui fonctionne, et un langage dans lequel écrire un petit script. Les exemples sont en Node.js ; tout programme qui lit stdin et écrit sur stdout convient.
4+
5+## Étapes
6+
7+### 1. Choisir l'événement
8+
9+| Vous voulez… | Événement |
10+|--------------|-----------|
11+| refuser certains appels d'outil, ou changer leurs arguments | `preToolCall` |
12+| arrêter ou réécrire une question, ou lui ajouter du contexte | `preGeneration` |
13+| ajouter une note après la sortie d'un outil | `postToolCall` |
14+| journaliser ou notifier, sans rien changer | `postToolCall`, `postGeneration` |
15+
16+### 2. Écrire le hook
17+
18+Le hook lit un objet JSON sur stdin et, quand il a une décision, en écrit un sur stdout. Ne rien écrire laisse le tour continuer. Celui-ci refuse `git push --force` :
19+
20+```js
21+// hooks/no-force-push.js
22+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
23+const command = input.tool.input.command || "";
24+
25+if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) {
26+ console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" }));
27+}
28+```
29+
30+La raison est ce que le modèle lit à la place de la sortie de la commande : écrivez-la pour le modèle, afin qu'il puisse prévenir l'utilisateur ou essayer autre chose.
31+
32+### 3. L'essayer sans mm
33+
34+Donnez-lui l'entrée que mm enverrait :
35+
36+```bash
37+echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \
38+ | node hooks/no-force-push.js
39+```
40+
41+Vous devez voir :
42+
43+```
44+{"action":"deny","reason":"force-push is not allowed in this project"}
45+```
46+
47+Et rien du tout pour `git push origin main`.
48+
49+### 4. Le déclarer
50+
51+Dans le fichier YAML de l'agent, à côté du script :
52+
53+```yaml
54+hooks:
55+ preToolCall:
56+ - matcher: ^bash$
57+ command: node "$MM_CONFIG_DIR/hooks/no-force-push.js"
58+ onError: fail
59+```
60+
61+- `matcher` limite le hook à l'outil `bash` ; sans lui, le hook s'exécute pour chaque appel d'outil.
62+- `$MM_CONFIG_DIR` est le dossier du fichier YAML : le script est trouvé quel que soit le dossier d'où `mm` est lancé.
63+- `onError: fail` refuse l'appel quand le hook lui-même échoue (Node absent, erreur de syntaxe, délai dépassé). Utilisez-le pour les gardes ; gardez la valeur par défaut, `warn`, pour les hooks dont l'échec ne doit pas gêner.
64+
65+### 5. Lancer l'agent et vérifier
66+
67+```bash
68+mm agent.yaml
69+```
70+
71+La bannière se termine par les hooks chargés :
72+
73+```
74+… | yolo: on | hooks: preToolCall×1
75+```
76+
77+Demandez quelque chose qui déclenche la règle (`force-push the main branch`). Le mode ligne affiche :
78+
79+```
80+🪝 bash [denied by a hook: force-push is not allowed in this project]
81+```
82+
83+et la réponse du modèle explique le refus.
84+
85+## Variantes
86+
87+- **Arrêter une question.** Sur `preGeneration`, lisez `input.prompt` et écrivez `{"action":"stop","reason":"…","response":"…"}`. L'utilisateur lit `response` ; le modèle n'est pas appelé, et la question est remplacée dans l'historique par `[prompt blocked by a hook: <raison>]`.
88+- **Ajouter du contexte.** Sur `preGeneration`, écrivez `{"context":"…"}` : le texte est ajouté après la question, sous `[context added by a hook]`. Sur `postToolCall`, le même champ est ajouté après la sortie de l'outil.
89+- **Réécrire.** `{"prompt":"…"}` sur `preGeneration` remplace la question ; `{"input":{…}}` sur `preToolCall` remplace les arguments de l'outil — l'objet entier, donc recopiez les champs que vous gardez.
90+- **Journaliser.** Sur `postToolCall` et `postGeneration`, ajoutez `input` à un fichier et n'écrivez rien ; mettez `onError: ignore` pour qu'un disque plein n'interrompe jamais une session. Un même script peut servir plusieurs événements : lisez `input.event` ou `$MM_HOOK_EVENT`.
91+- **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous.
92+- **Plusieurs hooks sur un événement** s'exécutent dans l'ordre écrit ; chacun voit la réécriture du précédent, et le premier `stop` ou `deny` arrête la chaîne.
93+- **Avec `yolo: false`**, un hook `preToolCall` s'exécute avant la demande d'autorisation : un appel refusé n'est jamais soumis à l'utilisateur.
94+- **Dans un éditeur** (`mm -acp`), les hooks s'exécutent dans le dossier de travail de la session, et un appel refusé apparaît comme un appel d'outil en échec intitulé `<outil> — denied by a hook`.
95+
96+## Voir aussi
97+
98+- Chaque champ, action, échec et limite : [Référence des hooks](../reference/hooks.md)
99+- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks — explication](../explanation/hooks.md)
100+- Un exemple complet avec quatre hooks : `demos/hooks/`
modified docs/fr/reference/acp.md +2 -1
@@ -8,7 +8,7 @@
88 |--------|--------|
99 | Entrée | stdin, un message JSON-RPC par ligne. |
1010 | Sortie | stdout, messages JSON-RPC uniquement. |
11-| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. |
11+| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`). |
1212 | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |
1313
1414 ## Réponse à `initialize`
@@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
9797 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
9898 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
9999 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
100+| `tool_call` puis `tool_call_update` statut `failed`, titre `<outil> — denied by a hook` | Un [hook](hooks.md) `preToolCall` a refusé l'appel : l'outil ne s'est pas exécuté et aucune permission n'a été demandée ; le contenu `text` est `[denied by a hook: <raison>]`. |
100101 | `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. |
101102
102103 ## Commandes disponibles
@@ -8,7 +8,7 @@
8 |--------|--------|8 |--------|--------|
9 | Entrée | stdin, un message JSON-RPC par ligne. |9 | Entrée | stdin, un message JSON-RPC par ligne. |
10 | Sortie | stdout, messages JSON-RPC uniquement. |10 | Sortie | stdout, messages JSON-RPC uniquement. |
11-| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. |11+| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`). |
12 | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |12 | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |
13 13
14 ## Réponse à `initialize`14 ## Réponse à `initialize`
@@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
97 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |97 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
98 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |98 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
99 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |99 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
100+| `tool_call` puis `tool_call_update` statut `failed`, titre `<outil> — denied by a hook` | Un [hook](hooks.md) `preToolCall` a refusé l'appel : l'outil ne s'est pas exécuté et aucune permission n'a été demandée ; le contenu `text` est `[denied by a hook: <raison>]`. |
100 | `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. |101 | `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. |
101 102
102 ## Commandes disponibles103 ## Commandes disponibles
modified docs/fr/reference/cli.md +1 -1
@@ -75,7 +75,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
7575 | Ligne | Contenu |
7676 |-------|---------|
7777 | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), la clé obsolète `permissions`. |
78-| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. |
78+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `hooks:` (quand la configuration en déclare) : `<événement>×<nombre>` par événement qui a des hooks, par exemple `hooks: preGeneration×1 preToolCall×2` — voir [les hooks](hooks.md#bannière-de-démarrage). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. |
7979 | `Agent ready. Commands: …` | Mode terminal seulement. |
8080 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |
8181 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. |
@@ -75,7 +75,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
75 | Ligne | Contenu |75 | Ligne | Contenu |
76 |-------|---------|76 |-------|---------|
77 | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), la clé obsolète `permissions`. |77 | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), la clé obsolète `permissions`. |
78-| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. |78+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `hooks:` (quand la configuration en déclare) : `<événement>×<nombre>` par événement qui a des hooks, par exemple `hooks: preGeneration×1 preToolCall×2` — voir [les hooks](hooks.md#bannière-de-démarrage). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. |
79 | `Agent ready. Commands: …` | Mode terminal seulement. |79 | `Agent ready. Commands: …` | Mode terminal seulement. |
80 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |80 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |
81 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. |81 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. |
modified docs/fr/reference/configuration.md +2 -0
@@ -34,6 +34,7 @@
3434 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |
3535 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |
3636 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
37+| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) et après le tour (`postGeneration`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil seulement), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). |
3738 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
3839 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
3940 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |
@@ -211,6 +212,7 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code
211212 | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |
212213 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |
213214 | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` |
215+| `hooks: unknown event "x" (…)`, `hooks.<événement>[N]: …` | un bloc `hooks:` invalide — voir [les hooks](hooks.md#erreurs-de-validation) |
214216 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
215217
216218 Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`).
@@ -34,6 +34,7 @@
34 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |34 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |
35 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |35 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |
36 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |36 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
37+| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) et après le tour (`postGeneration`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil seulement), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). |
37 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |38 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
38 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |39 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
39 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |40 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |
@@ -211,6 +212,7 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code
211 | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |212 | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |
212 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |213 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |
213 | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` |214 | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` |
215+| `hooks: unknown event "x" (…)`, `hooks.<événement>[N]: …` | un bloc `hooks:` invalide — voir [les hooks](hooks.md#erreurs-de-validation) |
214 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. |216 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
215 217
216 Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`).218 Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`).
added docs/fr/reference/hooks.md +206 -0
new file mode 100644
@@ -0,0 +1,206 @@
1+# Référence : les hooks
2+
3+> Le bloc `hooks:` du fichier de configuration : les événements, le JSON qu'un hook lit et écrit, l'environnement dans lequel il s'exécute, et ce que chaque décision fait au tour.
4+
5+## Configuration
6+
7+```yaml
8+hooks:
9+ preGeneration: [<hook>, …]
10+ preToolCall: [<hook>, …]
11+ postToolCall: [<hook>, …]
12+ postGeneration: [<hook>, …]
13+```
14+
15+Chaque événement prend une liste ; les hooks d'un événement s'exécutent dans l'ordre où ils sont écrits. Un événement absent, ou une liste vide, n'exécute rien. Pas de bloc `hooks:` = aucun hook.
16+
17+### Entrée d'un hook
18+
19+| Clé | Type | Défaut | Description |
20+|-----|------|--------|-------------|
21+| `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. |
22+| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils) | `preToolCall` et `postToolCall` seulement. Le hook s'exécute pour les outils dont le nom correspond. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. |
23+| `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. |
24+| `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). |
25+
26+### Exemple
27+
28+```yaml
29+hooks:
30+ preGeneration:
31+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
32+ timeout: 5s
33+ onError: fail
34+ preToolCall:
35+ - matcher: ^(bash|write_file|edit_file)$
36+ command: ./hooks/guard.sh
37+ postGeneration:
38+ - command: python3 "$MM_CONFIG_DIR/hooks/log.py"
39+ onError: ignore
40+```
41+
42+## Événements
43+
44+| Événement | S'exécute | Outils concernés | Peut |
45+|-----------|-----------|------------------|------|
46+| `preGeneration` | une fois par question, avant le premier appel au modèle | — | réécrire la question, ajouter du contexte, arrêter le tour |
47+| `preToolCall` | avant chaque appel d'outil, avant la demande d'autorisation de l'outil | intégrés et MCP, filtrés par `matcher` | refuser l'appel, remplacer ses arguments |
48+| `postToolCall` | après chaque appel d'outil exécuté | intégrés et MCP, filtrés par `matcher` | ajouter du contexte après la sortie de l'outil |
49+| `postGeneration` | une fois le tour terminé : répondu, en échec, annulé ou arrêté par un hook | — | observer seulement |
50+
51+Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`, et un agent démarré par `/ask`, `ask_agent` ou `mm --ask` (avec les hooks de **son propre** fichier de configuration). Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.
52+
53+## Exécution
54+
55+| Aspect | Valeur |
56+|--------|--------|
57+| Commande | `sh -c "<command>"` |
58+| Dossier de travail | celui de la session : le dossier où `mm` s'exécute, ou le `cwd` de la session de l'éditeur en ACP |
59+| Environnement | celui de `mm`, plus les deux variables ci-dessous |
60+| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier |
61+| stdout | vide, ou un objet JSON ([Réponse](#réponse)) ; 1 Mio au plus |
62+| stderr | cité (300 premiers octets) dans la ligne d'échec quand l'exécution échoue ; ignoré sinon |
63+| Concurrence | les hooks d'un événement s'exécutent l'un après l'autre ; `preToolCall`/`postToolCall` peuvent s'exécuter en parallèle pour deux appels d'outil d'une même réponse du modèle |
64+
65+### Variables d'environnement
66+
67+| Variable | Valeur |
68+|----------|--------|
69+| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall` ou `postGeneration` |
70+| `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook |
71+
72+## Entrée
73+
74+L'objet JSON écrit sur l'entrée standard du hook.
75+
76+| Champ | Type | Événements | Description |
77+|-------|------|------------|-------------|
78+| `event` | chaîne | tous | le nom de l'événement |
79+| `cwd` | chaîne | tous | le dossier de travail de la session, absolu |
80+| `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` |
81+| `prompt` | chaîne | `preGeneration`, `postGeneration` | la question telle que tapée — lignes `@path` et `$skill` comprises. Dans une chaîne, la question telle que le hook précédent l'a laissée. Dans `postGeneration` : la question que le modèle a lue, ou le marqueur `[prompt blocked by a hook: <raison>]` quand un hook a arrêté le tour. |
82+| `tool` | objet | `preToolCall`, `postToolCall` | l'appel d'outil : `id` (la référence de l'appel, peut être vide), `name`, `input` (les arguments, tels que le modèle les a envoyés ou que le hook précédent les a remplacés) |
83+| `output` | chaîne | `postToolCall` | la sortie de l'outil, telle que le modèle la lira (en JSON quand l'outil renvoie autre chose que du texte) |
84+| `answer` | chaîne | `postGeneration` | le texte de la dernière réponse ; vide s'il n'y en a pas |
85+| `error` | chaîne | `postGeneration` | l'erreur du tour ; absent quand le tour a réussi |
86+
87+Les champs vides sont omis.
88+
89+### Exemples
90+
91+```json
92+{"event":"preGeneration","cwd":"/home/moi/projet","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"}
93+```
94+
95+```json
96+{"event":"preToolCall","cwd":"/home/moi/projet","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97+```
98+
99+## Réponse
100+
101+L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (ou faite seulement d'espaces) vaut `{"action":"continue"}`.** Tous les champs sont facultatifs.
102+
103+| Champ | Type | Lu sur | Description |
104+|-------|------|--------|-------------|
105+| `action` | chaîne | tous | `continue` (par défaut, y compris quand il est absent), `stop`, `deny` ou `error` — voir plus bas |
106+| `prompt` | chaîne | `preGeneration` | remplace la question. Absent = inchangée ; `""` = une question vide. |
107+| `context` | chaîne | `preGeneration`, `postToolCall` | du texte pour le modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. |
108+| `input` | toute valeur JSON | `preToolCall` | remplace les arguments de l'appel d'outil. Absent = inchangés. |
109+| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` (`deny`) | la raison. |
110+| `response` | chaîne | `preGeneration` (`stop`) | ce que l'utilisateur lit à la place d'une réponse. Absente ou blanche : `⛔ This request was blocked by a hook: <raison>`. |
111+| `error` | chaîne | tous (`error`) | le message d'échec du hook lui-même. |
112+
113+Les champs lus sur un autre événement sont ignorés.
114+
115+### Actions
116+
117+| Action | Permise sur | Effet |
118+|--------|-------------|-------|
119+| `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués |
120+| `stop` | `preGeneration` | le tour se termine sans appeler le modèle ; les hooks suivants de l'événement ne s'exécutent pas |
121+| `deny` | `preToolCall` | l'outil ne s'exécute pas ; les hooks suivants de l'événement ne s'exécutent pas |
122+| `error` | tous | un échec, traité selon `onError` |
123+| toute autre valeur, ou `stop`/`deny` sur un autre événement | — | un échec : `action "<action>" is not valid for <événement>` |
124+
125+## Effets sur le tour
126+
127+### `preGeneration`
128+
129+| Décision | Le modèle lit | L'historique garde | L'utilisateur voit |
130+|----------|---------------|--------------------|--------------------|
131+| aucun changement | la question | la question | — |
132+| `prompt` et/ou `context` | la question réécrite, puis chaque contexte | la question réécrite (la copie propre au front end n'est pas modifiée pendant le tour) | — |
133+| `stop` | rien : le modèle n'est pas appelé | `[prompt blocked by a hook: <raison>]` (ou `[prompt blocked by a hook]`) suivi de la `response` comme réponse | la `response`, comme réponse |
134+
135+En chaîne : chaque hook lit le `prompt` laissé par le précédent ; les contextes s'accumulent dans l'ordre des hooks.
136+
137+### `preToolCall`
138+
139+| Décision | L'outil | Le modèle lit | L'utilisateur voit |
140+|----------|---------|---------------|--------------------|
141+| aucun changement | s'exécute avec les arguments du modèle | la sortie | la ligne habituelle de l'outil |
142+| `input` | s'exécute avec les arguments remplacés | la sortie | la ligne habituelle de l'outil (avec les arguments remplacés) |
143+| `deny` | ne s'exécute pas, et ne demande pas d'autorisation | `[denied by a hook: <raison>]` comme sortie de l'outil | mode ligne : `🪝 <outil> [denied by a hook: <raison>]` ; fenêtre et éditeur : un appel d'outil en échec intitulé `<outil> — denied by a hook` |
144+
145+L'historique garde l'appel d'outil tel que le modèle l'a écrit, même quand `input` a remplacé ses arguments.
146+
147+### `postToolCall`
148+
149+Les `context` de tous les hooks qui correspondent sont réunis et ajoutés à la sortie : `<sortie>\n\n[context added by a hook]\n<contexte>`. La sortie de l'outil montrée à l'utilisateur ne change pas.
150+
151+### `postGeneration`
152+
153+Aucun effet sur le tour. La réponse est décodée (une réponse invalide est un échec), puis ignorée.
154+
155+## Échecs
156+
157+Une exécution échoue quand :
158+
159+| Échec | Message |
160+|-------|---------|
161+| la commande se termine avec un code non nul | `exit status <n>: <stderr>` |
162+| elle dure plus que `timeout` | `timed out after <timeout>` |
163+| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |
164+| stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` |
165+| stdout n'est pas du JSON | `invalid JSON on stdout: …` |
166+| `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` |
167+| `action` est inconnue ou non permise sur l'événement | `action "<action>" is not valid for <événement>` |
168+
169+`postGeneration` n'est pas annulé avec le tour : il s'exécute aussi après une annulation (Échap, Ctrl-C, `/abort`), borné par son `timeout`.
170+
171+La suite dépend d'`onError` :
172+
173+| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |
174+|-----------|-----------------|---------------|----------------------------------|
175+| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue |
176+| `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue |
177+| `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire |
178+
179+La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP.
180+
181+## Bannière de démarrage
182+
183+Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration` :
184+
185+```
186+mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
187+```
188+
189+## Erreurs de validation
190+
191+Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, code de sortie 1.
192+
193+| Message | Cause |
194+|---------|-------|
195+| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) |
196+| `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche |
197+| `hooks.<événement>[N]: matcher is only for preToolCall and postToolCall` | un `matcher` sur `preGeneration` ou `postGeneration` |
198+| `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas |
199+| `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif |
200+| `hooks.<événement>[N]: onError must be "fail", "warn" or "ignore"` | toute autre valeur d'`onError` |
201+
202+## Voir aussi
203+
204+- [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md)
205+- [Les hooks — explication](../explanation/hooks.md)
206+- La démo : `demos/hooks/` (quatre hooks Node.js)
new file mode 100644
@@ -0,0 +1,206 @@
1+# Référence : les hooks
2+
3+> Le bloc `hooks:` du fichier de configuration : les événements, le JSON qu'un hook lit et écrit, l'environnement dans lequel il s'exécute, et ce que chaque décision fait au tour.
4+
5+## Configuration
6+
7+```yaml
8+hooks:
9+ preGeneration: [<hook>, …]
10+ preToolCall: [<hook>, …]
11+ postToolCall: [<hook>, …]
12+ postGeneration: [<hook>, …]
13+```
14+
15+Chaque événement prend une liste ; les hooks d'un événement s'exécutent dans l'ordre où ils sont écrits. Un événement absent, ou une liste vide, n'exécute rien. Pas de bloc `hooks:` = aucun hook.
16+
17+### Entrée d'un hook
18+
19+| Clé | Type | Défaut | Description |
20+|-----|------|--------|-------------|
21+| `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. |
22+| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils) | `preToolCall` et `postToolCall` seulement. Le hook s'exécute pour les outils dont le nom correspond. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. |
23+| `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. |
24+| `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). |
25+
26+### Exemple
27+
28+```yaml
29+hooks:
30+ preGeneration:
31+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
32+ timeout: 5s
33+ onError: fail
34+ preToolCall:
35+ - matcher: ^(bash|write_file|edit_file)$
36+ command: ./hooks/guard.sh
37+ postGeneration:
38+ - command: python3 "$MM_CONFIG_DIR/hooks/log.py"
39+ onError: ignore
40+```
41+
42+## Événements
43+
44+| Événement | S'exécute | Outils concernés | Peut |
45+|-----------|-----------|------------------|------|
46+| `preGeneration` | une fois par question, avant le premier appel au modèle | — | réécrire la question, ajouter du contexte, arrêter le tour |
47+| `preToolCall` | avant chaque appel d'outil, avant la demande d'autorisation de l'outil | intégrés et MCP, filtrés par `matcher` | refuser l'appel, remplacer ses arguments |
48+| `postToolCall` | après chaque appel d'outil exécuté | intégrés et MCP, filtrés par `matcher` | ajouter du contexte après la sortie de l'outil |
49+| `postGeneration` | une fois le tour terminé : répondu, en échec, annulé ou arrêté par un hook | — | observer seulement |
50+
51+Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`, et un agent démarré par `/ask`, `ask_agent` ou `mm --ask` (avec les hooks de **son propre** fichier de configuration). Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.
52+
53+## Exécution
54+
55+| Aspect | Valeur |
56+|--------|--------|
57+| Commande | `sh -c "<command>"` |
58+| Dossier de travail | celui de la session : le dossier où `mm` s'exécute, ou le `cwd` de la session de l'éditeur en ACP |
59+| Environnement | celui de `mm`, plus les deux variables ci-dessous |
60+| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier |
61+| stdout | vide, ou un objet JSON ([Réponse](#réponse)) ; 1 Mio au plus |
62+| stderr | cité (300 premiers octets) dans la ligne d'échec quand l'exécution échoue ; ignoré sinon |
63+| Concurrence | les hooks d'un événement s'exécutent l'un après l'autre ; `preToolCall`/`postToolCall` peuvent s'exécuter en parallèle pour deux appels d'outil d'une même réponse du modèle |
64+
65+### Variables d'environnement
66+
67+| Variable | Valeur |
68+|----------|--------|
69+| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall` ou `postGeneration` |
70+| `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook |
71+
72+## Entrée
73+
74+L'objet JSON écrit sur l'entrée standard du hook.
75+
76+| Champ | Type | Événements | Description |
77+|-------|------|------------|-------------|
78+| `event` | chaîne | tous | le nom de l'événement |
79+| `cwd` | chaîne | tous | le dossier de travail de la session, absolu |
80+| `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` |
81+| `prompt` | chaîne | `preGeneration`, `postGeneration` | la question telle que tapée — lignes `@path` et `$skill` comprises. Dans une chaîne, la question telle que le hook précédent l'a laissée. Dans `postGeneration` : la question que le modèle a lue, ou le marqueur `[prompt blocked by a hook: <raison>]` quand un hook a arrêté le tour. |
82+| `tool` | objet | `preToolCall`, `postToolCall` | l'appel d'outil : `id` (la référence de l'appel, peut être vide), `name`, `input` (les arguments, tels que le modèle les a envoyés ou que le hook précédent les a remplacés) |
83+| `output` | chaîne | `postToolCall` | la sortie de l'outil, telle que le modèle la lira (en JSON quand l'outil renvoie autre chose que du texte) |
84+| `answer` | chaîne | `postGeneration` | le texte de la dernière réponse ; vide s'il n'y en a pas |
85+| `error` | chaîne | `postGeneration` | l'erreur du tour ; absent quand le tour a réussi |
86+
87+Les champs vides sont omis.
88+
89+### Exemples
90+
91+```json
92+{"event":"preGeneration","cwd":"/home/moi/projet","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"}
93+```
94+
95+```json
96+{"event":"preToolCall","cwd":"/home/moi/projet","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97+```
98+
99+## Réponse
100+
101+L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (ou faite seulement d'espaces) vaut `{"action":"continue"}`.** Tous les champs sont facultatifs.
102+
103+| Champ | Type | Lu sur | Description |
104+|-------|------|--------|-------------|
105+| `action` | chaîne | tous | `continue` (par défaut, y compris quand il est absent), `stop`, `deny` ou `error` — voir plus bas |
106+| `prompt` | chaîne | `preGeneration` | remplace la question. Absent = inchangée ; `""` = une question vide. |
107+| `context` | chaîne | `preGeneration`, `postToolCall` | du texte pour le modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. |
108+| `input` | toute valeur JSON | `preToolCall` | remplace les arguments de l'appel d'outil. Absent = inchangés. |
109+| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` (`deny`) | la raison. |
110+| `response` | chaîne | `preGeneration` (`stop`) | ce que l'utilisateur lit à la place d'une réponse. Absente ou blanche : `⛔ This request was blocked by a hook: <raison>`. |
111+| `error` | chaîne | tous (`error`) | le message d'échec du hook lui-même. |
112+
113+Les champs lus sur un autre événement sont ignorés.
114+
115+### Actions
116+
117+| Action | Permise sur | Effet |
118+|--------|-------------|-------|
119+| `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués |
120+| `stop` | `preGeneration` | le tour se termine sans appeler le modèle ; les hooks suivants de l'événement ne s'exécutent pas |
121+| `deny` | `preToolCall` | l'outil ne s'exécute pas ; les hooks suivants de l'événement ne s'exécutent pas |
122+| `error` | tous | un échec, traité selon `onError` |
123+| toute autre valeur, ou `stop`/`deny` sur un autre événement | — | un échec : `action "<action>" is not valid for <événement>` |
124+
125+## Effets sur le tour
126+
127+### `preGeneration`
128+
129+| Décision | Le modèle lit | L'historique garde | L'utilisateur voit |
130+|----------|---------------|--------------------|--------------------|
131+| aucun changement | la question | la question | — |
132+| `prompt` et/ou `context` | la question réécrite, puis chaque contexte | la question réécrite (la copie propre au front end n'est pas modifiée pendant le tour) | — |
133+| `stop` | rien : le modèle n'est pas appelé | `[prompt blocked by a hook: <raison>]` (ou `[prompt blocked by a hook]`) suivi de la `response` comme réponse | la `response`, comme réponse |
134+
135+En chaîne : chaque hook lit le `prompt` laissé par le précédent ; les contextes s'accumulent dans l'ordre des hooks.
136+
137+### `preToolCall`
138+
139+| Décision | L'outil | Le modèle lit | L'utilisateur voit |
140+|----------|---------|---------------|--------------------|
141+| aucun changement | s'exécute avec les arguments du modèle | la sortie | la ligne habituelle de l'outil |
142+| `input` | s'exécute avec les arguments remplacés | la sortie | la ligne habituelle de l'outil (avec les arguments remplacés) |
143+| `deny` | ne s'exécute pas, et ne demande pas d'autorisation | `[denied by a hook: <raison>]` comme sortie de l'outil | mode ligne : `🪝 <outil> [denied by a hook: <raison>]` ; fenêtre et éditeur : un appel d'outil en échec intitulé `<outil> — denied by a hook` |
144+
145+L'historique garde l'appel d'outil tel que le modèle l'a écrit, même quand `input` a remplacé ses arguments.
146+
147+### `postToolCall`
148+
149+Les `context` de tous les hooks qui correspondent sont réunis et ajoutés à la sortie : `<sortie>\n\n[context added by a hook]\n<contexte>`. La sortie de l'outil montrée à l'utilisateur ne change pas.
150+
151+### `postGeneration`
152+
153+Aucun effet sur le tour. La réponse est décodée (une réponse invalide est un échec), puis ignorée.
154+
155+## Échecs
156+
157+Une exécution échoue quand :
158+
159+| Échec | Message |
160+|-------|---------|
161+| la commande se termine avec un code non nul | `exit status <n>: <stderr>` |
162+| elle dure plus que `timeout` | `timed out after <timeout>` |
163+| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |
164+| stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` |
165+| stdout n'est pas du JSON | `invalid JSON on stdout: …` |
166+| `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` |
167+| `action` est inconnue ou non permise sur l'événement | `action "<action>" is not valid for <événement>` |
168+
169+`postGeneration` n'est pas annulé avec le tour : il s'exécute aussi après une annulation (Échap, Ctrl-C, `/abort`), borné par son `timeout`.
170+
171+La suite dépend d'`onError` :
172+
173+| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |
174+|-----------|-----------------|---------------|----------------------------------|
175+| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue |
176+| `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue |
177+| `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire |
178+
179+La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP.
180+
181+## Bannière de démarrage
182+
183+Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration` :
184+
185+```
186+mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
187+```
188+
189+## Erreurs de validation
190+
191+Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, code de sortie 1.
192+
193+| Message | Cause |
194+|---------|-------|
195+| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) |
196+| `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche |
197+| `hooks.<événement>[N]: matcher is only for preToolCall and postToolCall` | un `matcher` sur `preGeneration` ou `postGeneration` |
198+| `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas |
199+| `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif |
200+| `hooks.<événement>[N]: onError must be "fail", "warn" or "ignore"` | toute autre valeur d'`onError` |
201+
202+## Voir aussi
203+
204+- [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md)
205+- [Les hooks — explication](../explanation/hooks.md)
206+- La démo : `demos/hooks/` (quatre hooks Node.js)
modified internal/config/config.go +10 -0
@@ -209,6 +209,12 @@ type Config struct {
209209 // is relative to THIS file's directory, and Load makes it absolute
210210 // (agents.go). Each name becomes a command name, hence its format.
211211 MMConfigs map[string]AgentRef `yaml:"mmConfigs"`
212+
213+ // Hooks: external commands run at four moments of a turn — before the
214+ // model sees a question, before and after each tool call, after the
215+ // turn (internal/hooks, ticket 36). They read one JSON object on stdin
216+ // and may answer one on stdout. None by default.
217+ Hooks HooksConfig `yaml:"hooks"`
212218 }
213219
214220 // YoloEnv names the variable that overrides `yolo` for one run ("true",
@@ -619,6 +625,7 @@ func Load(cliPath string) (string, error) {
619625 return "", err
620626 }
621627 resolveAgentPaths(path)
628+ resolveHooks(path)
622629 if err := Cfg.validate(); err != nil {
623630 return "", fmt.Errorf("%s: %w", path, err)
624631 }
@@ -794,5 +801,8 @@ func (c Config) validate() error {
794801 if err := c.validateAgents(); err != nil {
795802 return err
796803 }
804+ if err := c.Hooks.validate(); err != nil {
805+ return err
806+ }
797807 return c.LSP.validate()
798808 }
@@ -209,6 +209,12 @@ type Config struct {
209 // is relative to THIS file's directory, and Load makes it absolute209 // is relative to THIS file's directory, and Load makes it absolute
210 // (agents.go). Each name becomes a command name, hence its format.210 // (agents.go). Each name becomes a command name, hence its format.
211 MMConfigs map[string]AgentRef `yaml:"mmConfigs"`211 MMConfigs map[string]AgentRef `yaml:"mmConfigs"`
212+
213+ // Hooks: external commands run at four moments of a turn — before the
214+ // model sees a question, before and after each tool call, after the
215+ // turn (internal/hooks, ticket 36). They read one JSON object on stdin
216+ // and may answer one on stdout. None by default.
217+ Hooks HooksConfig `yaml:"hooks"`
212 }218 }
213 219
214 // YoloEnv names the variable that overrides `yolo` for one run ("true",220 // YoloEnv names the variable that overrides `yolo` for one run ("true",
@@ -619,6 +625,7 @@ func Load(cliPath string) (string, error) {
619 return "", err625 return "", err
620 }626 }
621 resolveAgentPaths(path)627 resolveAgentPaths(path)
628+ resolveHooks(path)
622 if err := Cfg.validate(); err != nil {629 if err := Cfg.validate(); err != nil {
623 return "", fmt.Errorf("%s: %w", path, err)630 return "", fmt.Errorf("%s: %w", path, err)
624 }631 }
@@ -794,5 +801,8 @@ func (c Config) validate() error {
794 if err := c.validateAgents(); err != nil {801 if err := c.validateAgents(); err != nil {
795 return err802 return err
796 }803 }
804+ if err := c.Hooks.validate(); err != nil {
805+ return err
806+ }
797 return c.LSP.validate()807 return c.LSP.validate()
798 }808 }
added internal/config/hooks.go +213 -0
new file mode 100644
@@ -0,0 +1,213 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
9+ "time"
10+
11+ "gopkg.in/yaml.v3"
12+)
13+
14+// The four moments a hook can run at (ticket 36). The names are the keys of
15+// the `hooks:` block AND the "event" field of the JSON a hook reads.
16+const (
17+ // HookPreGeneration runs once per user question, before the first call
18+ // to the model: it may rewrite the prompt, add context or stop the turn.
19+ HookPreGeneration = "preGeneration"
20+ // HookPreToolCall runs before each tool call: it may deny the call or
21+ // replace its input.
22+ HookPreToolCall = "preToolCall"
23+ // HookPostToolCall runs after each tool call: it may append context to
24+ // the tool's output.
25+ HookPostToolCall = "postToolCall"
26+ // HookPostGeneration runs once the turn is over, success or failure. It
27+ // only observes.
28+ HookPostGeneration = "postGeneration"
29+)
30+
31+// The three values of Hook.OnError: what a failing hook (non-zero exit,
32+// timeout, unreadable output) does to the turn.
33+const (
34+ // HookOnErrorFail stops the turn (preGeneration) or denies the call
35+ // (preToolCall). On the two post* events there is nothing left to stop:
36+ // it is reported like warn.
37+ HookOnErrorFail = "fail"
38+ // HookOnErrorWarn reports the failure in one line and carries on. The
39+ // default: a broken hook must be seen, and must not lock the user out.
40+ HookOnErrorWarn = "warn"
41+ // HookOnErrorIgnore carries on without a word.
42+ HookOnErrorIgnore = "ignore"
43+)
44+
45+// DefaultHookTimeout is how long a hook may run when its entry gives no
46+// `timeout`. A hook sits in the middle of every turn — or of every tool
47+// call — so a slow one is felt at once; ten seconds leaves room for a
48+// `node` start-up and a `git` call, not for a hung network request.
49+const DefaultHookTimeout = 10 * time.Second
50+
51+// hookEvents is the order hooks are listed in (banner, errors).
52+var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration}
53+
54+// HooksConfig is the `hooks:` block of the YAML: for each event, the hooks
55+// to run, in the order they are written.
56+//
57+// hooks:
58+// preToolCall:
59+// - matcher: ^bash$
60+// command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
61+// onError: fail
62+type HooksConfig struct {
63+ PreGeneration []Hook `yaml:"preGeneration"`
64+ PreToolCall []Hook `yaml:"preToolCall"`
65+ PostToolCall []Hook `yaml:"postToolCall"`
66+ PostGeneration []Hook `yaml:"postGeneration"`
67+
68+ // ConfigDir is the directory of the YAML file that declared the hooks,
69+ // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook
70+ // script shipped beside its agent.yaml is found whatever directory mm
71+ // was started from.
72+ ConfigDir string `yaml:"-"`
73+}
74+
75+// Hook is one entry of an event's list.
76+type Hook struct {
77+ // Command is run with `sh -c`, in the session's working directory.
78+ Command string `yaml:"command"`
79+ // Matcher is a regular expression on the tool name (preToolCall and
80+ // postToolCall only). Empty = every tool. Unanchored, like grep:
81+ // "bash" also matches "my_bash"; write "^bash$" for the tool alone.
82+ Matcher string `yaml:"matcher"`
83+ // Timeout bounds one run of the hook. 0 = DefaultHookTimeout.
84+ Timeout time.Duration `yaml:"timeout"`
85+ // OnError is fail, warn or ignore. Empty = warn.
86+ OnError string `yaml:"onError"`
87+}
88+
89+// UnmarshalYAML reads the `hooks:` block and REJECTS an unknown event name.
90+// A plain struct would ignore `preToolcall:` without a word, and a security
91+// hook that silently never runs is worse than an agent that refuses to start.
92+func (h *HooksConfig) UnmarshalYAML(node *yaml.Node) error {
93+ var lists map[string][]Hook
94+ if err := node.Decode(&lists); err != nil {
95+ return err
96+ }
97+ for event, list := range lists {
98+ slot := h.slot(event)
99+ if slot == nil {
100+ return fmt.Errorf("hooks: unknown event %q (want one of %s)", event, strings.Join(hookEvents, ", "))
101+ }
102+ *slot = list
103+ }
104+ return nil
105+}
106+
107+// slot returns the list that holds an event's hooks, nil for an unknown
108+// event.
109+func (h *HooksConfig) slot(event string) *[]Hook {
110+ switch event {
111+ case HookPreGeneration:
112+ return &h.PreGeneration
113+ case HookPreToolCall:
114+ return &h.PreToolCall
115+ case HookPostToolCall:
116+ return &h.PostToolCall
117+ case HookPostGeneration:
118+ return &h.PostGeneration
119+ }
120+ return nil
121+}
122+
123+// For returns the hooks declared for an event, in order; nil for an event
124+// with none (or an unknown one).
125+//
126+// for _, h := range config.Cfg.Hooks.For(config.HookPreToolCall) {
127+// fmt.Println(h.Command)
128+// }
129+func (h HooksConfig) For(event string) []Hook {
130+ if slot := h.slot(event); slot != nil {
131+ return *slot
132+ }
133+ return nil
134+}
135+
136+// Summary describes the declared hooks for the banner, one "event×count"
137+// per event that has any, in event order: "preGeneration×1 preToolCall×2".
138+// Empty when there is none.
139+func (h HooksConfig) Summary() string {
140+ var parts []string
141+ for _, event := range hookEvents {
142+ if n := len(h.For(event)); n > 0 {
143+ parts = append(parts, fmt.Sprintf("%s×%d", event, n))
144+ }
145+ }
146+ return strings.Join(parts, " ")
147+}
148+
149+// Matches reports whether the hook applies to a tool. A hook without a
150+// matcher applies to every tool. Load has rejected a pattern that does not
151+// compile; one that reaches here anyway (a Config built by hand) matches
152+// nothing rather than panicking in the middle of a turn.
153+func (h Hook) Matches(tool string) bool {
154+ if h.Matcher == "" {
155+ return true
156+ }
157+ re, err := regexp.Compile(h.Matcher)
158+ return err == nil && re.MatchString(tool)
159+}
160+
161+// validate checks every hook entry: a command, a matcher only where a tool
162+// is involved and one that compiles, a timeout that is not negative, and a
163+// known onError.
164+func (h HooksConfig) validate() error {
165+ for _, event := range hookEvents {
166+ for i, hook := range h.For(event) {
167+ if err := hook.validate(event); err != nil {
168+ return fmt.Errorf("hooks.%s[%d]: %w", event, i, err)
169+ }
170+ }
171+ }
172+ return nil
173+}
174+
175+func (h Hook) validate(event string) error {
176+ toolEvent := event == HookPreToolCall || event == HookPostToolCall
177+ switch {
178+ case strings.TrimSpace(h.Command) == "":
179+ return errors.New("command must not be empty")
180+ case h.Matcher != "" && !toolEvent:
181+ return fmt.Errorf("matcher is only for %s and %s", HookPreToolCall, HookPostToolCall)
182+ case h.Timeout < 0:
183+ return errors.New("timeout must be >= 0")
184+ }
185+ switch h.OnError {
186+ case "", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore:
187+ default:
188+ return fmt.Errorf("onError must be %q, %q or %q", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore)
189+ }
190+ if _, err := regexp.Compile(h.Matcher); err != nil {
191+ return fmt.Errorf("matcher: %w", err)
192+ }
193+ return nil
194+}
195+
196+// resolveHooks fills what an entry may leave out (timeout, onError) and
197+// records the directory of the file the hooks came from.
198+func resolveHooks(configPath string) {
199+ if dir, err := filepath.Abs(filepath.Dir(configPath)); err == nil {
200+ Cfg.Hooks.ConfigDir = dir
201+ }
202+ for _, event := range hookEvents {
203+ list := Cfg.Hooks.For(event)
204+ for i := range list {
205+ if list[i].Timeout == 0 {
206+ list[i].Timeout = DefaultHookTimeout
207+ }
208+ if list[i].OnError == "" {
209+ list[i].OnError = HookOnErrorWarn
210+ }
211+ }
212+ }
213+}
new file mode 100644
@@ -0,0 +1,213 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
9+ "time"
10+
11+ "gopkg.in/yaml.v3"
12+)
13+
14+// The four moments a hook can run at (ticket 36). The names are the keys of
15+// the `hooks:` block AND the "event" field of the JSON a hook reads.
16+const (
17+ // HookPreGeneration runs once per user question, before the first call
18+ // to the model: it may rewrite the prompt, add context or stop the turn.
19+ HookPreGeneration = "preGeneration"
20+ // HookPreToolCall runs before each tool call: it may deny the call or
21+ // replace its input.
22+ HookPreToolCall = "preToolCall"
23+ // HookPostToolCall runs after each tool call: it may append context to
24+ // the tool's output.
25+ HookPostToolCall = "postToolCall"
26+ // HookPostGeneration runs once the turn is over, success or failure. It
27+ // only observes.
28+ HookPostGeneration = "postGeneration"
29+)
30+
31+// The three values of Hook.OnError: what a failing hook (non-zero exit,
32+// timeout, unreadable output) does to the turn.
33+const (
34+ // HookOnErrorFail stops the turn (preGeneration) or denies the call
35+ // (preToolCall). On the two post* events there is nothing left to stop:
36+ // it is reported like warn.
37+ HookOnErrorFail = "fail"
38+ // HookOnErrorWarn reports the failure in one line and carries on. The
39+ // default: a broken hook must be seen, and must not lock the user out.
40+ HookOnErrorWarn = "warn"
41+ // HookOnErrorIgnore carries on without a word.
42+ HookOnErrorIgnore = "ignore"
43+)
44+
45+// DefaultHookTimeout is how long a hook may run when its entry gives no
46+// `timeout`. A hook sits in the middle of every turn — or of every tool
47+// call — so a slow one is felt at once; ten seconds leaves room for a
48+// `node` start-up and a `git` call, not for a hung network request.
49+const DefaultHookTimeout = 10 * time.Second
50+
51+// hookEvents is the order hooks are listed in (banner, errors).
52+var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration}
53+
54+// HooksConfig is the `hooks:` block of the YAML: for each event, the hooks
55+// to run, in the order they are written.
56+//
57+// hooks:
58+// preToolCall:
59+// - matcher: ^bash$
60+// command: node "$MM_CONFIG_DIR/hooks/bash-guard.js"
61+// onError: fail
62+type HooksConfig struct {
63+ PreGeneration []Hook `yaml:"preGeneration"`
64+ PreToolCall []Hook `yaml:"preToolCall"`
65+ PostToolCall []Hook `yaml:"postToolCall"`
66+ PostGeneration []Hook `yaml:"postGeneration"`
67+
68+ // ConfigDir is the directory of the YAML file that declared the hooks,
69+ // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook
70+ // script shipped beside its agent.yaml is found whatever directory mm
71+ // was started from.
72+ ConfigDir string `yaml:"-"`
73+}
74+
75+// Hook is one entry of an event's list.
76+type Hook struct {
77+ // Command is run with `sh -c`, in the session's working directory.
78+ Command string `yaml:"command"`
79+ // Matcher is a regular expression on the tool name (preToolCall and
80+ // postToolCall only). Empty = every tool. Unanchored, like grep:
81+ // "bash" also matches "my_bash"; write "^bash$" for the tool alone.
82+ Matcher string `yaml:"matcher"`
83+ // Timeout bounds one run of the hook. 0 = DefaultHookTimeout.
84+ Timeout time.Duration `yaml:"timeout"`
85+ // OnError is fail, warn or ignore. Empty = warn.
86+ OnError string `yaml:"onError"`
87+}
88+
89+// UnmarshalYAML reads the `hooks:` block and REJECTS an unknown event name.
90+// A plain struct would ignore `preToolcall:` without a word, and a security
91+// hook that silently never runs is worse than an agent that refuses to start.
92+func (h *HooksConfig) UnmarshalYAML(node *yaml.Node) error {
93+ var lists map[string][]Hook
94+ if err := node.Decode(&lists); err != nil {
95+ return err
96+ }
97+ for event, list := range lists {
98+ slot := h.slot(event)
99+ if slot == nil {
100+ return fmt.Errorf("hooks: unknown event %q (want one of %s)", event, strings.Join(hookEvents, ", "))
101+ }
102+ *slot = list
103+ }
104+ return nil
105+}
106+
107+// slot returns the list that holds an event's hooks, nil for an unknown
108+// event.
109+func (h *HooksConfig) slot(event string) *[]Hook {
110+ switch event {
111+ case HookPreGeneration:
112+ return &h.PreGeneration
113+ case HookPreToolCall:
114+ return &h.PreToolCall
115+ case HookPostToolCall:
116+ return &h.PostToolCall
117+ case HookPostGeneration:
118+ return &h.PostGeneration
119+ }
120+ return nil
121+}
122+
123+// For returns the hooks declared for an event, in order; nil for an event
124+// with none (or an unknown one).
125+//
126+// for _, h := range config.Cfg.Hooks.For(config.HookPreToolCall) {
127+// fmt.Println(h.Command)
128+// }
129+func (h HooksConfig) For(event string) []Hook {
130+ if slot := h.slot(event); slot != nil {
131+ return *slot
132+ }
133+ return nil
134+}
135+
136+// Summary describes the declared hooks for the banner, one "event×count"
137+// per event that has any, in event order: "preGeneration×1 preToolCall×2".
138+// Empty when there is none.
139+func (h HooksConfig) Summary() string {
140+ var parts []string
141+ for _, event := range hookEvents {
142+ if n := len(h.For(event)); n > 0 {
143+ parts = append(parts, fmt.Sprintf("%s×%d", event, n))
144+ }
145+ }
146+ return strings.Join(parts, " ")
147+}
148+
149+// Matches reports whether the hook applies to a tool. A hook without a
150+// matcher applies to every tool. Load has rejected a pattern that does not
151+// compile; one that reaches here anyway (a Config built by hand) matches
152+// nothing rather than panicking in the middle of a turn.
153+func (h Hook) Matches(tool string) bool {
154+ if h.Matcher == "" {
155+ return true
156+ }
157+ re, err := regexp.Compile(h.Matcher)
158+ return err == nil && re.MatchString(tool)
159+}
160+
161+// validate checks every hook entry: a command, a matcher only where a tool
162+// is involved and one that compiles, a timeout that is not negative, and a
163+// known onError.
164+func (h HooksConfig) validate() error {
165+ for _, event := range hookEvents {
166+ for i, hook := range h.For(event) {
167+ if err := hook.validate(event); err != nil {
168+ return fmt.Errorf("hooks.%s[%d]: %w", event, i, err)
169+ }
170+ }
171+ }
172+ return nil
173+}
174+
175+func (h Hook) validate(event string) error {
176+ toolEvent := event == HookPreToolCall || event == HookPostToolCall
177+ switch {
178+ case strings.TrimSpace(h.Command) == "":
179+ return errors.New("command must not be empty")
180+ case h.Matcher != "" && !toolEvent:
181+ return fmt.Errorf("matcher is only for %s and %s", HookPreToolCall, HookPostToolCall)
182+ case h.Timeout < 0:
183+ return errors.New("timeout must be >= 0")
184+ }
185+ switch h.OnError {
186+ case "", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore:
187+ default:
188+ return fmt.Errorf("onError must be %q, %q or %q", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore)
189+ }
190+ if _, err := regexp.Compile(h.Matcher); err != nil {
191+ return fmt.Errorf("matcher: %w", err)
192+ }
193+ return nil
194+}
195+
196+// resolveHooks fills what an entry may leave out (timeout, onError) and
197+// records the directory of the file the hooks came from.
198+func resolveHooks(configPath string) {
199+ if dir, err := filepath.Abs(filepath.Dir(configPath)); err == nil {
200+ Cfg.Hooks.ConfigDir = dir
201+ }
202+ for _, event := range hookEvents {
203+ list := Cfg.Hooks.For(event)
204+ for i := range list {
205+ if list[i].Timeout == 0 {
206+ list[i].Timeout = DefaultHookTimeout
207+ }
208+ if list[i].OnError == "" {
209+ list[i].OnError = HookOnErrorWarn
210+ }
211+ }
212+ }
213+}
added internal/config/hooks_test.go +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+package config
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+ "time"
8+)
9+
10+// No `hooks:` block: no hook, and an empty banner summary.
11+func TestHooksDefaultToNone(t *testing.T) {
12+ if err := loadYAML(t, "model: m\n"); err != nil {
13+ t.Fatal(err)
14+ }
15+ if got := Cfg.Hooks.Summary(); got != "" {
16+ t.Errorf("Summary() = %q, want empty", got)
17+ }
18+}
19+
20+// Every event is read, in order, and an entry's missing timeout and onError
21+// get their defaults.
22+func TestHooksAreReadWithTheirDefaults(t *testing.T) {
23+ err := loadYAML(t, `
24+hooks:
25+ preGeneration:
26+ - command: node a.js
27+ preToolCall:
28+ - matcher: ^bash$
29+ command: node b.js
30+ timeout: 3s
31+ onError: fail
32+ - command: node c.js
33+ postToolCall:
34+ - command: node d.js
35+ onError: ignore
36+ postGeneration:
37+ - command: node e.js
38+`)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ h := Cfg.Hooks
43+ if got, want := h.Summary(), "preGeneration×1 preToolCall×2 postToolCall×1 postGeneration×1"; got != want {
44+ t.Errorf("Summary() = %q, want %q", got, want)
45+ }
46+ first := h.For(HookPreToolCall)[0]
47+ if first.Timeout != 3*time.Second || first.OnError != HookOnErrorFail || first.Matcher != "^bash$" {
48+ t.Errorf("preToolCall[0] = %+v, want timeout 3s, onError fail, matcher ^bash$", first)
49+ }
50+ second := h.For(HookPreToolCall)[1]
51+ if second.Timeout != DefaultHookTimeout || second.OnError != HookOnErrorWarn {
52+ t.Errorf("preToolCall[1] = %+v, want the defaults (10s, warn)", second)
53+ }
54+ if !filepath.IsAbs(h.ConfigDir) {
55+ t.Errorf("ConfigDir = %q, want an absolute directory", h.ConfigDir)
56+ }
57+}
58+
59+// Each mistake is refused at start-up with the entry it is in, rather than
60+// discovered when the hook silently does not run.
61+func TestInvalidHooksAreRejected(t *testing.T) {
62+ cases := map[string]struct{ yaml, want string }{
63+ "unknown event": {
64+ "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`},
65+ "empty command": {
66+ "hooks:\n preGeneration:\n - command: \" \"\n", "hooks.preGeneration[0]: command must not be empty"},
67+ "matcher outside tool events": {
68+ "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"},
69+ "bad matcher": {
70+ "hooks:\n postToolCall:\n - command: x\n matcher: \"(\"\n", "hooks.postToolCall[0]: matcher:"},
71+ "negative timeout": {
72+ "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"},
73+ "unknown onError": {
74+ "hooks:\n preToolCall:\n - command: x\n onError: retry\n", "onError must be"},
75+ }
76+ for name, tc := range cases {
77+ t.Run(name, func(t *testing.T) {
78+ err := loadYAML(t, tc.yaml)
79+ if err == nil || !strings.Contains(err.Error(), tc.want) {
80+ t.Errorf("Load() error = %v, want it to contain %q", err, tc.want)
81+ }
82+ })
83+ }
84+}
85+
86+func TestHookMatches(t *testing.T) {
87+ cases := []struct {
88+ matcher, tool string
89+ want bool
90+ }{
91+ {"", "bash", true},
92+ {"^bash$", "bash", true},
93+ {"^bash$", "my_bash", false},
94+ {"bash", "my_bash", true},
95+ {"^(write|edit)_file$", "edit_file", true},
96+ {"(", "bash", false}, // never validated: matches nothing, no panic
97+ }
98+ for _, tc := range cases {
99+ if got := (Hook{Matcher: tc.matcher}).Matches(tc.tool); got != tc.want {
100+ t.Errorf("Hook{Matcher: %q}.Matches(%q) = %v, want %v", tc.matcher, tc.tool, got, tc.want)
101+ }
102+ }
103+}
new file mode 100644
@@ -0,0 +1,103 @@
1+package config
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+ "time"
8+)
9+
10+// No `hooks:` block: no hook, and an empty banner summary.
11+func TestHooksDefaultToNone(t *testing.T) {
12+ if err := loadYAML(t, "model: m\n"); err != nil {
13+ t.Fatal(err)
14+ }
15+ if got := Cfg.Hooks.Summary(); got != "" {
16+ t.Errorf("Summary() = %q, want empty", got)
17+ }
18+}
19+
20+// Every event is read, in order, and an entry's missing timeout and onError
21+// get their defaults.
22+func TestHooksAreReadWithTheirDefaults(t *testing.T) {
23+ err := loadYAML(t, `
24+hooks:
25+ preGeneration:
26+ - command: node a.js
27+ preToolCall:
28+ - matcher: ^bash$
29+ command: node b.js
30+ timeout: 3s
31+ onError: fail
32+ - command: node c.js
33+ postToolCall:
34+ - command: node d.js
35+ onError: ignore
36+ postGeneration:
37+ - command: node e.js
38+`)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ h := Cfg.Hooks
43+ if got, want := h.Summary(), "preGeneration×1 preToolCall×2 postToolCall×1 postGeneration×1"; got != want {
44+ t.Errorf("Summary() = %q, want %q", got, want)
45+ }
46+ first := h.For(HookPreToolCall)[0]
47+ if first.Timeout != 3*time.Second || first.OnError != HookOnErrorFail || first.Matcher != "^bash$" {
48+ t.Errorf("preToolCall[0] = %+v, want timeout 3s, onError fail, matcher ^bash$", first)
49+ }
50+ second := h.For(HookPreToolCall)[1]
51+ if second.Timeout != DefaultHookTimeout || second.OnError != HookOnErrorWarn {
52+ t.Errorf("preToolCall[1] = %+v, want the defaults (10s, warn)", second)
53+ }
54+ if !filepath.IsAbs(h.ConfigDir) {
55+ t.Errorf("ConfigDir = %q, want an absolute directory", h.ConfigDir)
56+ }
57+}
58+
59+// Each mistake is refused at start-up with the entry it is in, rather than
60+// discovered when the hook silently does not run.
61+func TestInvalidHooksAreRejected(t *testing.T) {
62+ cases := map[string]struct{ yaml, want string }{
63+ "unknown event": {
64+ "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`},
65+ "empty command": {
66+ "hooks:\n preGeneration:\n - command: \" \"\n", "hooks.preGeneration[0]: command must not be empty"},
67+ "matcher outside tool events": {
68+ "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"},
69+ "bad matcher": {
70+ "hooks:\n postToolCall:\n - command: x\n matcher: \"(\"\n", "hooks.postToolCall[0]: matcher:"},
71+ "negative timeout": {
72+ "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"},
73+ "unknown onError": {
74+ "hooks:\n preToolCall:\n - command: x\n onError: retry\n", "onError must be"},
75+ }
76+ for name, tc := range cases {
77+ t.Run(name, func(t *testing.T) {
78+ err := loadYAML(t, tc.yaml)
79+ if err == nil || !strings.Contains(err.Error(), tc.want) {
80+ t.Errorf("Load() error = %v, want it to contain %q", err, tc.want)
81+ }
82+ })
83+ }
84+}
85+
86+func TestHookMatches(t *testing.T) {
87+ cases := []struct {
88+ matcher, tool string
89+ want bool
90+ }{
91+ {"", "bash", true},
92+ {"^bash$", "bash", true},
93+ {"^bash$", "my_bash", false},
94+ {"bash", "my_bash", true},
95+ {"^(write|edit)_file$", "edit_file", true},
96+ {"(", "bash", false}, // never validated: matches nothing, no panic
97+ }
98+ for _, tc := range cases {
99+ if got := (Hook{Matcher: tc.matcher}).Matches(tc.tool); got != tc.want {
100+ t.Errorf("Hook{Matcher: %q}.Matches(%q) = %v, want %v", tc.matcher, tc.tool, got, tc.want)
101+ }
102+ }
103+}
modified internal/engine/engine.go +22 -2
@@ -21,6 +21,7 @@ import (
2121 "sync/atomic"
2222
2323 "mm/internal/config"
24+ "mm/internal/hooks"
2425 "mm/internal/spinner"
2526 "mm/internal/ui"
2627
@@ -50,6 +51,10 @@ type Engine struct {
5051 // recent call to the model, captured in Generate's WrapModel hook. It is
5152 // the exact figure the compression trigger prefers over its estimate.
5253 lastInput atomic.Int64
54+
55+ // hooks runs the `hooks:` block (hooks.go). nil — an Engine built by
56+ // hand, in a test — runs none.
57+ hooks *hooks.Runner
5358 }
5459
5560 // New reads the provider named in the config, resolves its backend (URL, key,
@@ -69,7 +74,8 @@ func New(ctx context.Context) (*Engine, error) {
6974 if err != nil {
7075 return nil, err
7176 }
72- return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil
77+ return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow,
78+ hooks: hooks.New(config.Cfg.Hooks, ui.Notice)}, nil
7379 }
7480
7581 // Probe asks the provider what it can learn about the server BEFORE the first
@@ -241,6 +247,15 @@ func squeeze(s string) string {
241247 // is hung and we cancel the context. This is the "silent loop" of
242248 // STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one.
243249 func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
250+ if e.hooks.Enabled() {
251+ return e.generateWithHooks(ctx, messages, tools)
252+ }
253+ return e.generate(ctx, messages, tools)
254+}
255+
256+// generate is Generate without the generation hooks (hooks.go): the turn
257+// itself. The tool hooks run inside it, in the WrapTool middleware.
258+func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
244259 ctx = WithConversation(ctx, messages)
245260 // Genkit only hands us back the LAST message. The intermediate turns — "I
246261 // am calling bash", "here is the output" — exist only in the request it
@@ -340,9 +355,14 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
340355 // whitespace the previous text left pending. The hook lands at
341356 // exactly the right moment — just before the tool prints its 🛠️
342357 // line.
358+ // The tool hooks run here too (hooks.go): one place sees every
359+ // call, built-in and MCP, before the tool asks for consent.
343360 WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
344361 gaps.drop()
345- return next(ctx, params)
362+ if !e.hooks.Enabled() {
363+ return next(ctx, params)
364+ }
365+ return e.runToolHooks(ctx, params, next)
346366 },
347367 }, nil
348368 })
@@ -21,6 +21,7 @@ import (
21 "sync/atomic"21 "sync/atomic"
22 22
23 "mm/internal/config"23 "mm/internal/config"
24+ "mm/internal/hooks"
24 "mm/internal/spinner"25 "mm/internal/spinner"
25 "mm/internal/ui"26 "mm/internal/ui"
26 27
@@ -50,6 +51,10 @@ type Engine struct {
50 // recent call to the model, captured in Generate's WrapModel hook. It is51 // recent call to the model, captured in Generate's WrapModel hook. It is
51 // the exact figure the compression trigger prefers over its estimate.52 // the exact figure the compression trigger prefers over its estimate.
52 lastInput atomic.Int6453 lastInput atomic.Int64
54+
55+ // hooks runs the `hooks:` block (hooks.go). nil — an Engine built by
56+ // hand, in a test — runs none.
57+ hooks *hooks.Runner
53 }58 }
54 59
55 // New reads the provider named in the config, resolves its backend (URL, key,60 // New reads the provider named in the config, resolves its backend (URL, key,
@@ -69,7 +74,8 @@ func New(ctx context.Context) (*Engine, error) {
69 if err != nil {74 if err != nil {
70 return nil, err75 return nil, err
71 }76 }
72- return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil77+ return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow,
78+ hooks: hooks.New(config.Cfg.Hooks, ui.Notice)}, nil
73 }79 }
74 80
75 // Probe asks the provider what it can learn about the server BEFORE the first81 // Probe asks the provider what it can learn about the server BEFORE the first
@@ -241,6 +247,15 @@ func squeeze(s string) string {
241 // is hung and we cancel the context. This is the "silent loop" of247 // is hung and we cancel the context. This is the "silent loop" of
242 // STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one.248 // STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one.
243 func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {249 func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
250+ if e.hooks.Enabled() {
251+ return e.generateWithHooks(ctx, messages, tools)
252+ }
253+ return e.generate(ctx, messages, tools)
254+}
255+
256+// generate is Generate without the generation hooks (hooks.go): the turn
257+// itself. The tool hooks run inside it, in the WrapTool middleware.
258+func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
244 ctx = WithConversation(ctx, messages)259 ctx = WithConversation(ctx, messages)
245 // Genkit only hands us back the LAST message. The intermediate turns — "I260 // Genkit only hands us back the LAST message. The intermediate turns — "I
246 // am calling bash", "here is the output" — exist only in the request it261 // am calling bash", "here is the output" — exist only in the request it
@@ -340,9 +355,14 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
340 // whitespace the previous text left pending. The hook lands at355 // whitespace the previous text left pending. The hook lands at
341 // exactly the right moment — just before the tool prints its 🛠️356 // exactly the right moment — just before the tool prints its 🛠️
342 // line.357 // line.
358+ // The tool hooks run here too (hooks.go): one place sees every
359+ // call, built-in and MCP, before the tool asks for consent.
343 WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {360 WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
344 gaps.drop()361 gaps.drop()
345- return next(ctx, params)362+ if !e.hooks.Enabled() {
363+ return next(ctx, params)
364+ }
365+ return e.runToolHooks(ctx, params, next)
346 },366 },
347 }, nil367 }, nil
348 })368 })
added internal/engine/hooks.go +230 -0
new file mode 100644
@@ -0,0 +1,230 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "slices"
9+ "strings"
10+
11+ "mm/internal/hooks"
12+ "mm/internal/ui"
13+
14+ "github.com/firebase/genkit/go/ai"
15+)
16+
17+// The hooks of the `hooks:` block (ticket 36) plug in at the two places every
18+// front end goes through — REPL, window, ACP, -p alike:
19+//
20+// - Generate: preGeneration on the question before the first model call,
21+// postGeneration once the turn is over;
22+// - the WrapTool middleware: preToolCall and postToolCall around EVERY tool
23+// call, built-in and MCP, before the tool's own consent prompt.
24+//
25+// internal/hooks runs the commands and decides; this file applies the
26+// decisions to Genkit's messages and shows them.
27+
28+// blockedPrompt replaces a question a preGeneration hook stopped, in the
29+// history. The question itself is never kept: a hook that stops a prompt
30+// because it holds a secret must not have it sent on the NEXT turn.
31+const blockedPrompt = "[prompt blocked by a hook"
32+
33+// hookContextLabel introduces the text a hook adds for the model, so the
34+// model — and whoever reads a session file — can tell it from what the user
35+// typed or what the tool printed.
36+const hookContextLabel = "[context added by a hook]\n"
37+
38+// generateWithHooks wraps generate with the two generation events. Nothing
39+// here runs when no hook is declared.
40+func (e *Engine) generateWithHooks(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
41+ question := lastUserMessage(messages)
42+ if question == nil {
43+ return e.generate(ctx, messages, tools)
44+ }
45+ base := e.hookInput()
46+
47+ in := base
48+ in.Prompt = question.Text()
49+ d := e.hooks.PreGeneration(ctx, in)
50+
51+ var (
52+ resp *ai.ModelResponse
53+ full []*ai.Message
54+ err error
55+ )
56+ after := base
57+ after.Prompt = d.Prompt
58+ if d.Stopped {
59+ resp, full = blockedTurn(messages, d)
60+ // The post hooks read the marker too, never the blocked text: an
61+ // audit hook would otherwise write the secret to its log.
62+ after.Prompt = blockedMarker(d.Reason)
63+ say(d.Response)
64+ } else {
65+ if d.Changed(in.Prompt) {
66+ messages = withQuestion(messages, rewrittenQuestion(question, d))
67+ }
68+ resp, full, err = e.generate(ctx, messages, tools)
69+ }
70+
71+ if resp != nil {
72+ after.Answer = resp.Text()
73+ }
74+ if err != nil {
75+ after.Error = err.Error()
76+ }
77+ // Not the turn's context: a cancelled turn (Esc, Ctrl-C, /abort) is
78+ // one an audit hook must still see. The hook's own timeout bounds it.
79+ e.hooks.PostGeneration(context.WithoutCancel(ctx), after)
80+ return resp, full, err
81+}
82+
83+// hookInput fills what every event shares: where the session works, and
84+// which model answers.
85+func (e *Engine) hookInput() hooks.Input {
86+ return hooks.Input{Cwd: workDir(), Model: e.Model}
87+}
88+
89+// workDir is the session's working directory: the sink's (an editor's
90+// session cwd over ACP), else the process's own.
91+func workDir() string {
92+ if s := ui.ActiveSink(); s != nil && s.WorkDir() != "" {
93+ return s.WorkDir()
94+ }
95+ dir, _ := os.Getwd()
96+ return dir
97+}
98+
99+// lastUserMessage returns the question of the turn — the last message, when
100+// it is the user's — or nil (a history that ends otherwise has no question
101+// for preGeneration to look at).
102+func lastUserMessage(messages []*ai.Message) *ai.Message {
103+ if len(messages) == 0 {
104+ return nil
105+ }
106+ last := messages[len(messages)-1]
107+ if last == nil || last.Role != ai.RoleUser {
108+ return nil
109+ }
110+ return last
111+}
112+
113+// withQuestion returns a copy of messages whose last one is q: the front
114+// end's slice is left as it was, and the history Generate returns carries the
115+// rewritten question — the one the model actually read.
116+func withQuestion(messages []*ai.Message, q *ai.Message) []*ai.Message {
117+ out := slices.Clone(messages)
118+ out[len(out)-1] = q
119+ return out
120+}
121+
122+// rewrittenQuestion builds the question the model reads: the hooks' prompt,
123+// then each context they added. Parts that are not text (an image) are kept.
124+func rewrittenQuestion(original *ai.Message, d hooks.PromptDecision) *ai.Message {
125+ var parts []*ai.Part
126+ for _, p := range original.Content {
127+ if !p.IsText() {
128+ parts = append(parts, p)
129+ }
130+ }
131+ parts = append(parts, ai.NewTextPart(d.Prompt))
132+ for _, c := range d.Context {
133+ parts = append(parts, ai.NewTextPart("\n\n"+hookContextLabel+c))
134+ }
135+ return ai.NewUserMessage(parts...)
136+}
137+
138+// blockedTurn is the turn a preGeneration hook stopped: no model call, the
139+// question replaced by a marker, the hook's response as the answer. The
140+// returned history is one message longer than the question, so every front
141+// end keeps it (session.AfterTurn) — marker included.
142+func blockedTurn(messages []*ai.Message, d hooks.PromptDecision) (*ai.ModelResponse, []*ai.Message) {
143+ answer := ai.NewModelTextMessage(d.Response)
144+ full := append(slices.Clone(messages[:len(messages)-1]), ai.NewUserTextMessage(blockedMarker(d.Reason)), answer)
145+ return &ai.ModelResponse{Message: answer, FinishReason: ai.FinishReasonStop}, full
146+}
147+
148+// blockedMarker is what stands for a stopped question, in the history and
149+// for the postGeneration hooks.
150+func blockedMarker(reason string) string {
151+ if reason == "" {
152+ return blockedPrompt + "]"
153+ }
154+ return blockedPrompt + ": " + reason + "]"
155+}
156+
157+// say shows text as the agent's answer, the way a streamed answer is shown:
158+// the sink's Text (window, editor) or the terminal's 🤖 line.
159+func say(text string) {
160+ if s := ui.ActiveSink(); s != nil {
161+ s.Text(text)
162+ return
163+ }
164+ fmt.Print("🤖 " + text)
165+}
166+
167+// runToolHooks is the WrapTool side: preToolCall may deny the call or
168+// replace its input, postToolCall may add context to its output.
169+func (e *Engine) runToolHooks(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
170+ in := e.hookInput()
171+ in.Tool = &hooks.ToolCall{ID: params.Request.Ref, Name: params.Request.Name, Input: params.Request.Input}
172+
173+ d := e.hooks.PreToolCall(ctx, in)
174+ if d.Denied {
175+ return deniedCall(in.Tool, d.Reason), nil
176+ }
177+ // A rebuilt request rather than an edited one: params.Request is the
178+ // model's own tool request, and the history must say what it asked for.
179+ req := *params.Request
180+ req.Input = d.Input
181+ resp, err := next(ctx, &ai.ToolParams{Request: &req, Tool: params.Tool})
182+ if err != nil || resp == nil {
183+ return resp, err
184+ }
185+
186+ in.Tool.Input = d.Input
187+ in.Output = outputText(resp.Output)
188+ if added := e.hooks.PostToolCall(ctx, in); len(added) > 0 {
189+ resp.Output = in.Output + "\n\n" + hookContextLabel + strings.Join(added, "\n\n")
190+ }
191+ return resp, nil
192+}
193+
194+// deniedCall is what the model reads instead of the tool's output — a
195+// failure as text, like every tool failure — and what the user sees: a failed
196+// tool entry in the window and the editor, a line in the terminal.
197+func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse {
198+ text := "[denied by a hook]"
199+ if reason != "" {
200+ text = "[denied by a hook: " + reason + "]"
201+ }
202+ if s := ui.ActiveSink(); s != nil {
203+ ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: call.Name, Title: call.Name + " — denied by a hook", Kind: "other", Input: inputMap(call.Input)}
204+ s.ToolStart(ev)
205+ s.ToolEnd(ev.ID, ui.ToolResult{Output: text, Failed: true})
206+ } else {
207+ ui.Notice("🪝 " + call.Name + " " + text)
208+ }
209+ return &ai.MultipartToolResponse{Output: text}
210+}
211+
212+// inputMap gives a client the call's arguments as an object when they are
213+// one, nil otherwise.
214+func inputMap(input any) map[string]any {
215+ m, _ := input.(map[string]any)
216+ return m
217+}
218+
219+// outputText is a tool's output as the postToolCall hook reads it — and as
220+// the model reads it once context is added: the text itself, or its JSON.
221+func outputText(output any) string {
222+ if s, ok := output.(string); ok {
223+ return s
224+ }
225+ data, err := json.Marshal(output)
226+ if err != nil {
227+ return fmt.Sprint(output)
228+ }
229+ return string(data)
230+}
new file mode 100644
@@ -0,0 +1,230 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "slices"
9+ "strings"
10+
11+ "mm/internal/hooks"
12+ "mm/internal/ui"
13+
14+ "github.com/firebase/genkit/go/ai"
15+)
16+
17+// The hooks of the `hooks:` block (ticket 36) plug in at the two places every
18+// front end goes through — REPL, window, ACP, -p alike:
19+//
20+// - Generate: preGeneration on the question before the first model call,
21+// postGeneration once the turn is over;
22+// - the WrapTool middleware: preToolCall and postToolCall around EVERY tool
23+// call, built-in and MCP, before the tool's own consent prompt.
24+//
25+// internal/hooks runs the commands and decides; this file applies the
26+// decisions to Genkit's messages and shows them.
27+
28+// blockedPrompt replaces a question a preGeneration hook stopped, in the
29+// history. The question itself is never kept: a hook that stops a prompt
30+// because it holds a secret must not have it sent on the NEXT turn.
31+const blockedPrompt = "[prompt blocked by a hook"
32+
33+// hookContextLabel introduces the text a hook adds for the model, so the
34+// model — and whoever reads a session file — can tell it from what the user
35+// typed or what the tool printed.
36+const hookContextLabel = "[context added by a hook]\n"
37+
38+// generateWithHooks wraps generate with the two generation events. Nothing
39+// here runs when no hook is declared.
40+func (e *Engine) generateWithHooks(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
41+ question := lastUserMessage(messages)
42+ if question == nil {
43+ return e.generate(ctx, messages, tools)
44+ }
45+ base := e.hookInput()
46+
47+ in := base
48+ in.Prompt = question.Text()
49+ d := e.hooks.PreGeneration(ctx, in)
50+
51+ var (
52+ resp *ai.ModelResponse
53+ full []*ai.Message
54+ err error
55+ )
56+ after := base
57+ after.Prompt = d.Prompt
58+ if d.Stopped {
59+ resp, full = blockedTurn(messages, d)
60+ // The post hooks read the marker too, never the blocked text: an
61+ // audit hook would otherwise write the secret to its log.
62+ after.Prompt = blockedMarker(d.Reason)
63+ say(d.Response)
64+ } else {
65+ if d.Changed(in.Prompt) {
66+ messages = withQuestion(messages, rewrittenQuestion(question, d))
67+ }
68+ resp, full, err = e.generate(ctx, messages, tools)
69+ }
70+
71+ if resp != nil {
72+ after.Answer = resp.Text()
73+ }
74+ if err != nil {
75+ after.Error = err.Error()
76+ }
77+ // Not the turn's context: a cancelled turn (Esc, Ctrl-C, /abort) is
78+ // one an audit hook must still see. The hook's own timeout bounds it.
79+ e.hooks.PostGeneration(context.WithoutCancel(ctx), after)
80+ return resp, full, err
81+}
82+
83+// hookInput fills what every event shares: where the session works, and
84+// which model answers.
85+func (e *Engine) hookInput() hooks.Input {
86+ return hooks.Input{Cwd: workDir(), Model: e.Model}
87+}
88+
89+// workDir is the session's working directory: the sink's (an editor's
90+// session cwd over ACP), else the process's own.
91+func workDir() string {
92+ if s := ui.ActiveSink(); s != nil && s.WorkDir() != "" {
93+ return s.WorkDir()
94+ }
95+ dir, _ := os.Getwd()
96+ return dir
97+}
98+
99+// lastUserMessage returns the question of the turn — the last message, when
100+// it is the user's — or nil (a history that ends otherwise has no question
101+// for preGeneration to look at).
102+func lastUserMessage(messages []*ai.Message) *ai.Message {
103+ if len(messages) == 0 {
104+ return nil
105+ }
106+ last := messages[len(messages)-1]
107+ if last == nil || last.Role != ai.RoleUser {
108+ return nil
109+ }
110+ return last
111+}
112+
113+// withQuestion returns a copy of messages whose last one is q: the front
114+// end's slice is left as it was, and the history Generate returns carries the
115+// rewritten question — the one the model actually read.
116+func withQuestion(messages []*ai.Message, q *ai.Message) []*ai.Message {
117+ out := slices.Clone(messages)
118+ out[len(out)-1] = q
119+ return out
120+}
121+
122+// rewrittenQuestion builds the question the model reads: the hooks' prompt,
123+// then each context they added. Parts that are not text (an image) are kept.
124+func rewrittenQuestion(original *ai.Message, d hooks.PromptDecision) *ai.Message {
125+ var parts []*ai.Part
126+ for _, p := range original.Content {
127+ if !p.IsText() {
128+ parts = append(parts, p)
129+ }
130+ }
131+ parts = append(parts, ai.NewTextPart(d.Prompt))
132+ for _, c := range d.Context {
133+ parts = append(parts, ai.NewTextPart("\n\n"+hookContextLabel+c))
134+ }
135+ return ai.NewUserMessage(parts...)
136+}
137+
138+// blockedTurn is the turn a preGeneration hook stopped: no model call, the
139+// question replaced by a marker, the hook's response as the answer. The
140+// returned history is one message longer than the question, so every front
141+// end keeps it (session.AfterTurn) — marker included.
142+func blockedTurn(messages []*ai.Message, d hooks.PromptDecision) (*ai.ModelResponse, []*ai.Message) {
143+ answer := ai.NewModelTextMessage(d.Response)
144+ full := append(slices.Clone(messages[:len(messages)-1]), ai.NewUserTextMessage(blockedMarker(d.Reason)), answer)
145+ return &ai.ModelResponse{Message: answer, FinishReason: ai.FinishReasonStop}, full
146+}
147+
148+// blockedMarker is what stands for a stopped question, in the history and
149+// for the postGeneration hooks.
150+func blockedMarker(reason string) string {
151+ if reason == "" {
152+ return blockedPrompt + "]"
153+ }
154+ return blockedPrompt + ": " + reason + "]"
155+}
156+
157+// say shows text as the agent's answer, the way a streamed answer is shown:
158+// the sink's Text (window, editor) or the terminal's 🤖 line.
159+func say(text string) {
160+ if s := ui.ActiveSink(); s != nil {
161+ s.Text(text)
162+ return
163+ }
164+ fmt.Print("🤖 " + text)
165+}
166+
167+// runToolHooks is the WrapTool side: preToolCall may deny the call or
168+// replace its input, postToolCall may add context to its output.
169+func (e *Engine) runToolHooks(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
170+ in := e.hookInput()
171+ in.Tool = &hooks.ToolCall{ID: params.Request.Ref, Name: params.Request.Name, Input: params.Request.Input}
172+
173+ d := e.hooks.PreToolCall(ctx, in)
174+ if d.Denied {
175+ return deniedCall(in.Tool, d.Reason), nil
176+ }
177+ // A rebuilt request rather than an edited one: params.Request is the
178+ // model's own tool request, and the history must say what it asked for.
179+ req := *params.Request
180+ req.Input = d.Input
181+ resp, err := next(ctx, &ai.ToolParams{Request: &req, Tool: params.Tool})
182+ if err != nil || resp == nil {
183+ return resp, err
184+ }
185+
186+ in.Tool.Input = d.Input
187+ in.Output = outputText(resp.Output)
188+ if added := e.hooks.PostToolCall(ctx, in); len(added) > 0 {
189+ resp.Output = in.Output + "\n\n" + hookContextLabel + strings.Join(added, "\n\n")
190+ }
191+ return resp, nil
192+}
193+
194+// deniedCall is what the model reads instead of the tool's output — a
195+// failure as text, like every tool failure — and what the user sees: a failed
196+// tool entry in the window and the editor, a line in the terminal.
197+func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse {
198+ text := "[denied by a hook]"
199+ if reason != "" {
200+ text = "[denied by a hook: " + reason + "]"
201+ }
202+ if s := ui.ActiveSink(); s != nil {
203+ ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: call.Name, Title: call.Name + " — denied by a hook", Kind: "other", Input: inputMap(call.Input)}
204+ s.ToolStart(ev)
205+ s.ToolEnd(ev.ID, ui.ToolResult{Output: text, Failed: true})
206+ } else {
207+ ui.Notice("🪝 " + call.Name + " " + text)
208+ }
209+ return &ai.MultipartToolResponse{Output: text}
210+}
211+
212+// inputMap gives a client the call's arguments as an object when they are
213+// one, nil otherwise.
214+func inputMap(input any) map[string]any {
215+ m, _ := input.(map[string]any)
216+ return m
217+}
218+
219+// outputText is a tool's output as the postToolCall hook reads it — and as
220+// the model reads it once context is added: the text itself, or its JSON.
221+func outputText(output any) string {
222+ if s, ok := output.(string); ok {
223+ return s
224+ }
225+ data, err := json.Marshal(output)
226+ if err != nil {
227+ return fmt.Sprint(output)
228+ }
229+ return string(data)
230+}
added internal/engine/hooks_test.go +272 -0
new file mode 100644
@@ -0,0 +1,272 @@
1+package engine
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "mm/internal/config"
13+ "mm/internal/hooks"
14+ "mm/internal/ui"
15+
16+ "github.com/firebase/genkit/go/ai"
17+)
18+
19+// The hooks under test are tiny `sh -c` scripts: what is checked here is
20+// how the engine applies their decisions to the conversation, not the
21+// protocol (internal/hooks has its own tests).
22+
23+// hookedEngine is testEngine with hooks, every entry given the defaults Load
24+// would have filled in.
25+func hookedEngine(t *testing.T, e *Engine, cfg config.HooksConfig) *recordingSink {
26+ t.Helper()
27+ for _, list := range []*[]config.Hook{&cfg.PreGeneration, &cfg.PreToolCall, &cfg.PostToolCall, &cfg.PostGeneration} {
28+ for i := range *list {
29+ (*list)[i].Timeout = 5 * time.Second
30+ (*list)[i].OnError = config.HookOnErrorWarn
31+ }
32+ }
33+ e.hooks = hooks.New(cfg, ui.Notice)
34+ sink := &recordingSink{dir: t.TempDir()}
35+ ui.SetActive(sink)
36+ t.Cleanup(func() { ui.SetActive(nil) })
37+ return sink
38+}
39+
40+// recordingSink is a front end that keeps what it was shown.
41+type recordingSink struct {
42+ ui.Sink
43+ dir string
44+ mu sync.Mutex
45+ text strings.Builder
46+ tools []ui.ToolEvent
47+ results []ui.ToolResult
48+ notices []string
49+}
50+
51+func (s *recordingSink) WorkDir() string { return s.dir }
52+func (s *recordingSink) Text(t string) { s.mu.Lock(); s.text.WriteString(t); s.mu.Unlock() }
53+func (s *recordingSink) Reasoning(string) {}
54+func (s *recordingSink) ToolRunning(string) {}
55+func (s *recordingSink) ToolStart(ev ui.ToolEvent) {
56+ s.mu.Lock()
57+ s.tools = append(s.tools, ev)
58+ s.mu.Unlock()
59+}
60+func (s *recordingSink) ToolEnd(_ string, res ui.ToolResult) {
61+ s.mu.Lock()
62+ s.results = append(s.results, res)
63+ s.mu.Unlock()
64+}
65+func (s *recordingSink) Notice(text string) {
66+ s.mu.Lock()
67+ s.notices = append(s.notices, text)
68+ s.mu.Unlock()
69+}
70+
71+func bashCall(command string) *ai.ModelResponse {
72+ return modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": command}, Ref: "1"}))
73+}
74+
75+// toolOutput returns the text of the tool response the model read on its
76+// second call.
77+func toolOutput(t *testing.T, req *ai.ModelRequest) string {
78+ t.Helper()
79+ for _, m := range req.Messages {
80+ for _, p := range m.Content {
81+ if p.IsToolResponse() {
82+ return outputText(p.ToolResponse.Output)
83+ }
84+ }
85+ }
86+ t.Fatal("no tool response in the request")
87+ return ""
88+}
89+
90+// The model reads the rewritten prompt and the added context; the history
91+// handed back carries them; the front end's own slice is untouched.
92+func TestPreGenerationRewritesWhatTheModelReads(t *testing.T) {
93+ g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("done")))
94+ e := testEngine(g)
95+ hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{
96+ {Command: `echo '{"prompt":"list the files","context":"branch: main"}'`},
97+ }})
98+
99+ messages := []*ai.Message{ai.NewUserTextMessage("lsit teh files")}
100+ _, full, err := e.Generate(context.Background(), messages, nil)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ read := (*seen)[0].Messages[0].Text()
105+ if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hookContextLabel+"branch: main") {
106+ t.Errorf("the model read %q", read)
107+ }
108+ if full[0].Text() != read {
109+ t.Errorf("history question = %q, want what the model read", full[0].Text())
110+ }
111+ if messages[0].Text() != "lsit teh files" {
112+ t.Errorf("the front end's message changed to %q", messages[0].Text())
113+ }
114+}
115+
116+// A stopped turn never reaches the model, shows the hook's response, and
117+// keeps a marker — never the blocked text — in the history.
118+func TestPreGenerationStopNeverCallsTheModel(t *testing.T) {
119+ g, seen := scriptedGenkit(t) // any model call fails the test
120+ e := testEngine(g)
121+ sink := hookedEngine(t, e, config.HooksConfig{
122+ PreGeneration: []config.Hook{
123+ {Command: `echo '{"action":"stop","reason":"secret detected","response":"Remove the secret first."}'`},
124+ },
125+ PostGeneration: []config.Hook{{Command: `cat > after.json`}},
126+ })
127+
128+ before := []*ai.Message{ai.NewSystemTextMessage("sys"), ai.NewUserTextMessage("my password is hunter2")}
129+ resp, full, err := e.Generate(context.Background(), before, nil)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ if len(*seen) != 0 {
134+ t.Fatalf("the model was called %d times", len(*seen))
135+ }
136+ if resp.Text() != "Remove the secret first." || sink.text.String() != "Remove the secret first." {
137+ t.Errorf("answer %q, shown %q", resp.Text(), sink.text.String())
138+ }
139+ if len(full) != 3 {
140+ t.Fatalf("history has %d messages, want system + marker + answer", len(full))
141+ }
142+ marker := full[1].Text()
143+ if strings.Contains(marker, "hunter2") || marker != "[prompt blocked by a hook: secret detected]" {
144+ t.Errorf("history keeps %q", marker)
145+ }
146+ // Found live: an audit hook on postGeneration wrote the secret to its
147+ // log. The post hooks read the marker as well.
148+ after, err := os.ReadFile(filepath.Join(sink.dir, "after.json"))
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ if strings.Contains(string(after), "hunter2") || !strings.Contains(string(after), "prompt blocked by a hook") {
153+ t.Errorf("postGeneration read %s", after)
154+ }
155+}
156+
157+// A denied call never runs: the model reads the reason as the tool's output,
158+// the front end sees a failed tool entry.
159+func TestPreToolCallDenyIsReadByTheModel(t *testing.T) {
160+ g, seen := scriptedGenkit(t, bashCall("rm -rf /"), modelTurn(ai.NewTextPart("ok, I will not")))
161+ e := testEngine(g)
162+ sink := hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{
163+ {Matcher: "^bash$", Command: `grep -q 'rm -rf' && echo '{"action":"deny","reason":"rm -rf is forbidden"}'`},
164+ }})
165+
166+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("clean up")}, []ai.ToolRef{ai.ToolName("bash")})
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ if got := toolOutput(t, (*seen)[1]); got != "[denied by a hook: rm -rf is forbidden]" {
171+ t.Errorf("the model read %q", got)
172+ }
173+ if len(sink.results) != 1 || !sink.results[0].Failed || !strings.Contains(sink.tools[0].Title, "denied") {
174+ t.Errorf("front end saw %+v / %+v, want one failed, denied tool entry", sink.tools, sink.results)
175+ }
176+}
177+
178+// A replaced input is what the tool runs with; the history keeps what the
179+// model asked for.
180+func TestPreToolCallReplacesTheInput(t *testing.T) {
181+ g, seen := scriptedGenkit(t, bashCall("ls"), modelTurn(ai.NewTextPart("listed")))
182+ e := testEngine(g)
183+ hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{
184+ {Command: `echo '{"input":{"command":"ls -la"}}'`},
185+ }})
186+
187+ _, full, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("list")}, []ai.ToolRef{ai.ToolName("bash")})
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ if got := toolOutput(t, (*seen)[1]); got != "hello from ls -la" {
192+ t.Errorf("the tool returned %q, want it run with the replaced input", got)
193+ }
194+ if cmds := CommandList(full); len(cmds) != 1 || cmds[0] != "ls" {
195+ t.Errorf("history commands = %v, want the model's own [ls]", cmds)
196+ }
197+}
198+
199+// postToolCall context is appended to what the model reads of the output.
200+func TestPostToolCallAddsContextToTheOutput(t *testing.T) {
201+ g, seen := scriptedGenkit(t, bashCall("go test"), modelTurn(ai.NewTextPart("fine")))
202+ e := testEngine(g)
203+ hookedEngine(t, e, config.HooksConfig{PostToolCall: []config.Hook{
204+ {Command: `grep -q '"output":"hello from go test"' && echo '{"context":"tests are flaky on CI"}'`},
205+ }})
206+
207+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil {
208+ t.Fatal(err)
209+ }
210+ if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hookContextLabel+"tests are flaky on CI"; got != want {
211+ t.Errorf("the model read %q, want %q", got, want)
212+ }
213+}
214+
215+// postGeneration sees the question and the answer, in the session's
216+// directory.
217+func TestPostGenerationSeesTheTurn(t *testing.T) {
218+ g, _ := scriptedGenkit(t, modelTurn(ai.NewTextPart("42")))
219+ e := testEngine(g)
220+ sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{
221+ {Command: `cat > turn.json`},
222+ }})
223+
224+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("the answer?")}, nil); err != nil {
225+ t.Fatal(err)
226+ }
227+ data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json"))
228+ if err != nil {
229+ t.Fatalf("the hook did not run in the session directory: %v", err)
230+ }
231+ for _, want := range []string{`"event":"postGeneration"`, `"prompt":"the answer?"`, `"answer":"42"`} {
232+ if !strings.Contains(string(data), want) {
233+ t.Errorf("the hook read %s, want %s in it", data, want)
234+ }
235+ }
236+}
237+
238+// A failing hook with onError warn is reported to the front end and the turn
239+// goes on.
240+func TestFailingHookIsReportedAndTheTurnGoesOn(t *testing.T) {
241+ g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("answered")))
242+ e := testEngine(g)
243+ sink := hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{{Command: `exit 7`}}})
244+
245+ resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil)
246+ if err != nil || resp.Text() != "answered" || len(*seen) != 1 {
247+ t.Fatalf("resp %v err %v calls %d, want the turn to go on", resp, err, len(*seen))
248+ }
249+ if len(sink.notices) != 1 || !strings.Contains(sink.notices[0], "exit status 7") {
250+ t.Errorf("notices = %v", sink.notices)
251+ }
252+}
253+
254+// A cancelled turn still reaches postGeneration: an audit log must not lose
255+// the turns the user interrupted.
256+func TestPostGenerationRunsAfterACancelledTurn(t *testing.T) {
257+ g, _ := scriptedGenkit(t, context.Canceled, context.Canceled) // the call, and the no-streaming retry
258+ e := testEngine(g)
259+ sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{{Command: `cat > turn.json`}}})
260+
261+ ctx, cancel := context.WithCancel(context.Background())
262+ cancel()
263+ _, _, _ = e.Generate(ctx, []*ai.Message{ai.NewUserTextMessage("long job")}, nil)
264+
265+ data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json"))
266+ if err != nil {
267+ t.Fatalf("postGeneration did not run after the cancellation: %v (notices %v)", err, sink.notices)
268+ }
269+ if !strings.Contains(string(data), `"error":`) {
270+ t.Errorf("the hook read %s, want the turn's error in it", data)
271+ }
272+}
new file mode 100644
@@ -0,0 +1,272 @@
1+package engine
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "mm/internal/config"
13+ "mm/internal/hooks"
14+ "mm/internal/ui"
15+
16+ "github.com/firebase/genkit/go/ai"
17+)
18+
19+// The hooks under test are tiny `sh -c` scripts: what is checked here is
20+// how the engine applies their decisions to the conversation, not the
21+// protocol (internal/hooks has its own tests).
22+
23+// hookedEngine is testEngine with hooks, every entry given the defaults Load
24+// would have filled in.
25+func hookedEngine(t *testing.T, e *Engine, cfg config.HooksConfig) *recordingSink {
26+ t.Helper()
27+ for _, list := range []*[]config.Hook{&cfg.PreGeneration, &cfg.PreToolCall, &cfg.PostToolCall, &cfg.PostGeneration} {
28+ for i := range *list {
29+ (*list)[i].Timeout = 5 * time.Second
30+ (*list)[i].OnError = config.HookOnErrorWarn
31+ }
32+ }
33+ e.hooks = hooks.New(cfg, ui.Notice)
34+ sink := &recordingSink{dir: t.TempDir()}
35+ ui.SetActive(sink)
36+ t.Cleanup(func() { ui.SetActive(nil) })
37+ return sink
38+}
39+
40+// recordingSink is a front end that keeps what it was shown.
41+type recordingSink struct {
42+ ui.Sink
43+ dir string
44+ mu sync.Mutex
45+ text strings.Builder
46+ tools []ui.ToolEvent
47+ results []ui.ToolResult
48+ notices []string
49+}
50+
51+func (s *recordingSink) WorkDir() string { return s.dir }
52+func (s *recordingSink) Text(t string) { s.mu.Lock(); s.text.WriteString(t); s.mu.Unlock() }
53+func (s *recordingSink) Reasoning(string) {}
54+func (s *recordingSink) ToolRunning(string) {}
55+func (s *recordingSink) ToolStart(ev ui.ToolEvent) {
56+ s.mu.Lock()
57+ s.tools = append(s.tools, ev)
58+ s.mu.Unlock()
59+}
60+func (s *recordingSink) ToolEnd(_ string, res ui.ToolResult) {
61+ s.mu.Lock()
62+ s.results = append(s.results, res)
63+ s.mu.Unlock()
64+}
65+func (s *recordingSink) Notice(text string) {
66+ s.mu.Lock()
67+ s.notices = append(s.notices, text)
68+ s.mu.Unlock()
69+}
70+
71+func bashCall(command string) *ai.ModelResponse {
72+ return modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": command}, Ref: "1"}))
73+}
74+
75+// toolOutput returns the text of the tool response the model read on its
76+// second call.
77+func toolOutput(t *testing.T, req *ai.ModelRequest) string {
78+ t.Helper()
79+ for _, m := range req.Messages {
80+ for _, p := range m.Content {
81+ if p.IsToolResponse() {
82+ return outputText(p.ToolResponse.Output)
83+ }
84+ }
85+ }
86+ t.Fatal("no tool response in the request")
87+ return ""
88+}
89+
90+// The model reads the rewritten prompt and the added context; the history
91+// handed back carries them; the front end's own slice is untouched.
92+func TestPreGenerationRewritesWhatTheModelReads(t *testing.T) {
93+ g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("done")))
94+ e := testEngine(g)
95+ hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{
96+ {Command: `echo '{"prompt":"list the files","context":"branch: main"}'`},
97+ }})
98+
99+ messages := []*ai.Message{ai.NewUserTextMessage("lsit teh files")}
100+ _, full, err := e.Generate(context.Background(), messages, nil)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ read := (*seen)[0].Messages[0].Text()
105+ if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hookContextLabel+"branch: main") {
106+ t.Errorf("the model read %q", read)
107+ }
108+ if full[0].Text() != read {
109+ t.Errorf("history question = %q, want what the model read", full[0].Text())
110+ }
111+ if messages[0].Text() != "lsit teh files" {
112+ t.Errorf("the front end's message changed to %q", messages[0].Text())
113+ }
114+}
115+
116+// A stopped turn never reaches the model, shows the hook's response, and
117+// keeps a marker — never the blocked text — in the history.
118+func TestPreGenerationStopNeverCallsTheModel(t *testing.T) {
119+ g, seen := scriptedGenkit(t) // any model call fails the test
120+ e := testEngine(g)
121+ sink := hookedEngine(t, e, config.HooksConfig{
122+ PreGeneration: []config.Hook{
123+ {Command: `echo '{"action":"stop","reason":"secret detected","response":"Remove the secret first."}'`},
124+ },
125+ PostGeneration: []config.Hook{{Command: `cat > after.json`}},
126+ })
127+
128+ before := []*ai.Message{ai.NewSystemTextMessage("sys"), ai.NewUserTextMessage("my password is hunter2")}
129+ resp, full, err := e.Generate(context.Background(), before, nil)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ if len(*seen) != 0 {
134+ t.Fatalf("the model was called %d times", len(*seen))
135+ }
136+ if resp.Text() != "Remove the secret first." || sink.text.String() != "Remove the secret first." {
137+ t.Errorf("answer %q, shown %q", resp.Text(), sink.text.String())
138+ }
139+ if len(full) != 3 {
140+ t.Fatalf("history has %d messages, want system + marker + answer", len(full))
141+ }
142+ marker := full[1].Text()
143+ if strings.Contains(marker, "hunter2") || marker != "[prompt blocked by a hook: secret detected]" {
144+ t.Errorf("history keeps %q", marker)
145+ }
146+ // Found live: an audit hook on postGeneration wrote the secret to its
147+ // log. The post hooks read the marker as well.
148+ after, err := os.ReadFile(filepath.Join(sink.dir, "after.json"))
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ if strings.Contains(string(after), "hunter2") || !strings.Contains(string(after), "prompt blocked by a hook") {
153+ t.Errorf("postGeneration read %s", after)
154+ }
155+}
156+
157+// A denied call never runs: the model reads the reason as the tool's output,
158+// the front end sees a failed tool entry.
159+func TestPreToolCallDenyIsReadByTheModel(t *testing.T) {
160+ g, seen := scriptedGenkit(t, bashCall("rm -rf /"), modelTurn(ai.NewTextPart("ok, I will not")))
161+ e := testEngine(g)
162+ sink := hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{
163+ {Matcher: "^bash$", Command: `grep -q 'rm -rf' && echo '{"action":"deny","reason":"rm -rf is forbidden"}'`},
164+ }})
165+
166+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("clean up")}, []ai.ToolRef{ai.ToolName("bash")})
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ if got := toolOutput(t, (*seen)[1]); got != "[denied by a hook: rm -rf is forbidden]" {
171+ t.Errorf("the model read %q", got)
172+ }
173+ if len(sink.results) != 1 || !sink.results[0].Failed || !strings.Contains(sink.tools[0].Title, "denied") {
174+ t.Errorf("front end saw %+v / %+v, want one failed, denied tool entry", sink.tools, sink.results)
175+ }
176+}
177+
178+// A replaced input is what the tool runs with; the history keeps what the
179+// model asked for.
180+func TestPreToolCallReplacesTheInput(t *testing.T) {
181+ g, seen := scriptedGenkit(t, bashCall("ls"), modelTurn(ai.NewTextPart("listed")))
182+ e := testEngine(g)
183+ hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{
184+ {Command: `echo '{"input":{"command":"ls -la"}}'`},
185+ }})
186+
187+ _, full, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("list")}, []ai.ToolRef{ai.ToolName("bash")})
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ if got := toolOutput(t, (*seen)[1]); got != "hello from ls -la" {
192+ t.Errorf("the tool returned %q, want it run with the replaced input", got)
193+ }
194+ if cmds := CommandList(full); len(cmds) != 1 || cmds[0] != "ls" {
195+ t.Errorf("history commands = %v, want the model's own [ls]", cmds)
196+ }
197+}
198+
199+// postToolCall context is appended to what the model reads of the output.
200+func TestPostToolCallAddsContextToTheOutput(t *testing.T) {
201+ g, seen := scriptedGenkit(t, bashCall("go test"), modelTurn(ai.NewTextPart("fine")))
202+ e := testEngine(g)
203+ hookedEngine(t, e, config.HooksConfig{PostToolCall: []config.Hook{
204+ {Command: `grep -q '"output":"hello from go test"' && echo '{"context":"tests are flaky on CI"}'`},
205+ }})
206+
207+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil {
208+ t.Fatal(err)
209+ }
210+ if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hookContextLabel+"tests are flaky on CI"; got != want {
211+ t.Errorf("the model read %q, want %q", got, want)
212+ }
213+}
214+
215+// postGeneration sees the question and the answer, in the session's
216+// directory.
217+func TestPostGenerationSeesTheTurn(t *testing.T) {
218+ g, _ := scriptedGenkit(t, modelTurn(ai.NewTextPart("42")))
219+ e := testEngine(g)
220+ sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{
221+ {Command: `cat > turn.json`},
222+ }})
223+
224+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("the answer?")}, nil); err != nil {
225+ t.Fatal(err)
226+ }
227+ data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json"))
228+ if err != nil {
229+ t.Fatalf("the hook did not run in the session directory: %v", err)
230+ }
231+ for _, want := range []string{`"event":"postGeneration"`, `"prompt":"the answer?"`, `"answer":"42"`} {
232+ if !strings.Contains(string(data), want) {
233+ t.Errorf("the hook read %s, want %s in it", data, want)
234+ }
235+ }
236+}
237+
238+// A failing hook with onError warn is reported to the front end and the turn
239+// goes on.
240+func TestFailingHookIsReportedAndTheTurnGoesOn(t *testing.T) {
241+ g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("answered")))
242+ e := testEngine(g)
243+ sink := hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{{Command: `exit 7`}}})
244+
245+ resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil)
246+ if err != nil || resp.Text() != "answered" || len(*seen) != 1 {
247+ t.Fatalf("resp %v err %v calls %d, want the turn to go on", resp, err, len(*seen))
248+ }
249+ if len(sink.notices) != 1 || !strings.Contains(sink.notices[0], "exit status 7") {
250+ t.Errorf("notices = %v", sink.notices)
251+ }
252+}
253+
254+// A cancelled turn still reaches postGeneration: an audit log must not lose
255+// the turns the user interrupted.
256+func TestPostGenerationRunsAfterACancelledTurn(t *testing.T) {
257+ g, _ := scriptedGenkit(t, context.Canceled, context.Canceled) // the call, and the no-streaming retry
258+ e := testEngine(g)
259+ sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{{Command: `cat > turn.json`}}})
260+
261+ ctx, cancel := context.WithCancel(context.Background())
262+ cancel()
263+ _, _, _ = e.Generate(ctx, []*ai.Message{ai.NewUserTextMessage("long job")}, nil)
264+
265+ data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json"))
266+ if err != nil {
267+ t.Fatalf("postGeneration did not run after the cancellation: %v (notices %v)", err, sink.notices)
268+ }
269+ if !strings.Contains(string(data), `"error":`) {
270+ t.Errorf("the hook read %s, want the turn's error in it", data)
271+ }
272+}
added internal/hooks/example_test.go +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+package hooks_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "mm/internal/config"
9+ "mm/internal/hooks"
10+)
11+
12+// A preGeneration hook that stops a question holding a password. The model is
13+// never called; the engine shows Response instead.
14+func ExampleRunner_PreGeneration() {
15+ cfg := config.HooksConfig{PreGeneration: []config.Hook{{
16+ Command: `grep -q password && echo '{"action":"stop","reason":"secret","response":"No secrets, please."}'`,
17+ Timeout: 5 * time.Second,
18+ OnError: config.HookOnErrorFail,
19+ }}}
20+ runner := hooks.New(cfg, nil)
21+
22+ d := runner.PreGeneration(context.Background(), hooks.Input{Prompt: "my password is hunter2"})
23+ fmt.Println(d.Stopped, d.Reason, d.Response)
24+ // Output: true secret No secrets, please.
25+}
26+
27+// A preToolCall hook limited to bash by its matcher: it denies sudo, and
28+// prints nothing — "continue" — for everything else.
29+func ExampleRunner_PreToolCall() {
30+ cfg := config.HooksConfig{PreToolCall: []config.Hook{{
31+ Matcher: "^bash$",
32+ Command: `grep -q sudo && echo '{"action":"deny","reason":"sudo is not allowed"}'; true`,
33+ Timeout: 5 * time.Second,
34+ OnError: config.HookOnErrorFail,
35+ }}}
36+ runner := hooks.New(cfg, nil)
37+
38+ for _, command := range []string{"sudo ls", "ls"} {
39+ call := &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": command}}
40+ d := runner.PreToolCall(context.Background(), hooks.Input{Tool: call})
41+ fmt.Printf("%s: denied=%v %s\n", command, d.Denied, d.Reason)
42+ }
43+ // Output:
44+ // sudo ls: denied=true sudo is not allowed
45+ // ls: denied=false
46+}
47+
48+// A postToolCall hook that adds a note for the model after the tool's output.
49+func ExampleRunner_PostToolCall() {
50+ cfg := config.HooksConfig{PostToolCall: []config.Hook{{
51+ Command: `echo '{"context":"the test suite takes 2 minutes"}'`,
52+ Timeout: 5 * time.Second,
53+ OnError: config.HookOnErrorWarn,
54+ }}}
55+ runner := hooks.New(cfg, nil)
56+
57+ added := runner.PostToolCall(context.Background(), hooks.Input{
58+ Tool: &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": "go test ./..."}},
59+ Output: "ok",
60+ })
61+ fmt.Println(added)
62+ // Output: [the test suite takes 2 minutes]
63+}
64+
65+// A failing hook with onError warn is reported through the notify function
66+// and the turn goes on.
67+func ExampleNew() {
68+ cfg := config.HooksConfig{PostGeneration: []config.Hook{{
69+ Command: "exit 3",
70+ Timeout: 5 * time.Second,
71+ OnError: config.HookOnErrorWarn,
72+ }}}
73+ runner := hooks.New(cfg, func(msg string) { fmt.Println(msg) })
74+
75+ runner.PostGeneration(context.Background(), hooks.Input{Answer: "done"})
76+ // Output: 🪝 postGeneration hook `exit 3`: exit status 3
77+}
new file mode 100644
@@ -0,0 +1,77 @@
1+package hooks_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "mm/internal/config"
9+ "mm/internal/hooks"
10+)
11+
12+// A preGeneration hook that stops a question holding a password. The model is
13+// never called; the engine shows Response instead.
14+func ExampleRunner_PreGeneration() {
15+ cfg := config.HooksConfig{PreGeneration: []config.Hook{{
16+ Command: `grep -q password && echo '{"action":"stop","reason":"secret","response":"No secrets, please."}'`,
17+ Timeout: 5 * time.Second,
18+ OnError: config.HookOnErrorFail,
19+ }}}
20+ runner := hooks.New(cfg, nil)
21+
22+ d := runner.PreGeneration(context.Background(), hooks.Input{Prompt: "my password is hunter2"})
23+ fmt.Println(d.Stopped, d.Reason, d.Response)
24+ // Output: true secret No secrets, please.
25+}
26+
27+// A preToolCall hook limited to bash by its matcher: it denies sudo, and
28+// prints nothing — "continue" — for everything else.
29+func ExampleRunner_PreToolCall() {
30+ cfg := config.HooksConfig{PreToolCall: []config.Hook{{
31+ Matcher: "^bash$",
32+ Command: `grep -q sudo && echo '{"action":"deny","reason":"sudo is not allowed"}'; true`,
33+ Timeout: 5 * time.Second,
34+ OnError: config.HookOnErrorFail,
35+ }}}
36+ runner := hooks.New(cfg, nil)
37+
38+ for _, command := range []string{"sudo ls", "ls"} {
39+ call := &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": command}}
40+ d := runner.PreToolCall(context.Background(), hooks.Input{Tool: call})
41+ fmt.Printf("%s: denied=%v %s\n", command, d.Denied, d.Reason)
42+ }
43+ // Output:
44+ // sudo ls: denied=true sudo is not allowed
45+ // ls: denied=false
46+}
47+
48+// A postToolCall hook that adds a note for the model after the tool's output.
49+func ExampleRunner_PostToolCall() {
50+ cfg := config.HooksConfig{PostToolCall: []config.Hook{{
51+ Command: `echo '{"context":"the test suite takes 2 minutes"}'`,
52+ Timeout: 5 * time.Second,
53+ OnError: config.HookOnErrorWarn,
54+ }}}
55+ runner := hooks.New(cfg, nil)
56+
57+ added := runner.PostToolCall(context.Background(), hooks.Input{
58+ Tool: &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": "go test ./..."}},
59+ Output: "ok",
60+ })
61+ fmt.Println(added)
62+ // Output: [the test suite takes 2 minutes]
63+}
64+
65+// A failing hook with onError warn is reported through the notify function
66+// and the turn goes on.
67+func ExampleNew() {
68+ cfg := config.HooksConfig{PostGeneration: []config.Hook{{
69+ Command: "exit 3",
70+ Timeout: 5 * time.Second,
71+ OnError: config.HookOnErrorWarn,
72+ }}}
73+ runner := hooks.New(cfg, func(msg string) { fmt.Println(msg) })
74+
75+ runner.PostGeneration(context.Background(), hooks.Input{Answer: "done"})
76+ // Output: 🪝 postGeneration hook `exit 3`: exit status 3
77+}
added internal/hooks/exec.go +168 -0
new file mode 100644
@@ -0,0 +1,168 @@
1+package hooks
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "strings"
12+ "time"
13+
14+ "mm/internal/config"
15+)
16+
17+// Environment variables a hook can read besides its stdin.
18+const (
19+ // EnvEvent names the event the hook runs for: one script can serve
20+ // several events and still know which one called it.
21+ EnvEvent = "MM_HOOK_EVENT"
22+ // EnvConfigDir is the directory of the YAML file that declared the hook,
23+ // so `node "$MM_CONFIG_DIR/hooks/x.js"` finds a script shipped beside
24+ // its agent.yaml from any working directory.
25+ EnvConfigDir = "MM_CONFIG_DIR"
26+)
27+
28+// maxReply caps what a hook may print on stdout. A reply is a small JSON
29+// object; a hook that floods its stdout is broken, and must not pull it all
30+// into memory nor into the model's context.
31+const maxReply = 1 << 20
32+
33+// maxStderr is how much of a failing hook's stderr its report quotes.
34+const maxStderr = 300
35+
36+// waitDelay bounds the wait for the pipes once the hook has exited or been
37+// killed: a hook that leaves a background child holding its stdout would
38+// otherwise block Wait forever (the same trap as the bash tool's, ticket 26).
39+const waitDelay = time.Second
40+
41+// execFunc runs one hook with its stdin and returns its stdout: runCommand,
42+// behind a field so a test elsewhere can swap the process for a function.
43+type execFunc func(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error)
44+
45+// call runs one hook on in and decodes its reply. An empty stdout is a
46+// "continue". Every failure — the hook's exit, its timeout, an unreadable
47+// reply, an unknown action, an explicit "error" — comes back as an error.
48+func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) {
49+ stdin, err := json.Marshal(in)
50+ if err != nil {
51+ return Reply{}, fmt.Errorf("cannot encode the input: %w", err)
52+ }
53+ env := []string{EnvEvent + "=" + in.Event, EnvConfigDir + "=" + r.cfg.ConfigDir}
54+ stdout, err := r.exec(ctx, h, env, in.Cwd, stdin)
55+ if err != nil {
56+ return Reply{}, err
57+ }
58+ return decode(stdout, in.Event)
59+}
60+
61+// decode reads a hook's stdout. An action that does not belong to the event
62+// (a "deny" from a preGeneration hook) is an error rather than a silent
63+// continue: whoever wrote it meant to stop something.
64+func decode(stdout []byte, event string) (Reply, error) {
65+ if len(bytes.TrimSpace(stdout)) == 0 {
66+ return Reply{Action: ActionContinue}, nil
67+ }
68+ var reply Reply
69+ if err := json.Unmarshal(stdout, &reply); err != nil {
70+ return Reply{}, fmt.Errorf("invalid JSON on stdout: %w", err)
71+ }
72+ if reply.Action == "" {
73+ reply.Action = ActionContinue
74+ }
75+ if err := checkAction(reply, event); err != nil {
76+ return Reply{}, err
77+ }
78+ return reply, nil
79+}
80+
81+// checkAction refuses a reply whose action is an error, unknown, or meant
82+// for another event.
83+func checkAction(reply Reply, event string) error {
84+ switch reply.Action {
85+ case ActionContinue:
86+ return nil
87+ case ActionError:
88+ if reply.Error == "" {
89+ return errors.New("the hook reported an error")
90+ }
91+ return errors.New(reply.Error)
92+ case ActionStop:
93+ if event == config.HookPreGeneration {
94+ return nil
95+ }
96+ case ActionDeny:
97+ if event == config.HookPreToolCall {
98+ return nil
99+ }
100+ }
101+ return fmt.Errorf("action %q is not valid for %s", reply.Action, event)
102+}
103+
104+// runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory),
105+// under h.Timeout, with env added to mm's environment.
106+func runCommand(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) {
107+ ctx, cancel := context.WithTimeout(ctx, h.Timeout)
108+ defer cancel()
109+
110+ cmd := exec.CommandContext(ctx, "sh", "-c", h.Command)
111+ cmd.Dir = dir
112+ cmd.Env = append(os.Environ(), env...)
113+ cmd.Stdin = bytes.NewReader(stdin)
114+ stdout := &capped{max: maxReply}
115+ stderr := &capped{max: maxStderr}
116+ cmd.Stdout, cmd.Stderr = stdout, stderr
117+ cmd.WaitDelay = waitDelay
118+
119+ err := cmd.Run()
120+ switch {
121+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
122+ return nil, fmt.Errorf("timed out after %s", h.Timeout)
123+ case errors.Is(ctx.Err(), context.Canceled):
124+ return nil, errors.New("cancelled")
125+ case err != nil:
126+ return nil, withStderr(err, stderr.String())
127+ case stdout.overflow:
128+ return nil, fmt.Errorf("stdout larger than %d bytes", maxReply)
129+ }
130+ return stdout.Bytes(), nil
131+}
132+
133+// withStderr adds the start of what the hook said on stderr to its exit
134+// error: "exit status 2" alone sends the user to read the script.
135+func withStderr(err error, stderr string) error {
136+ stderr = strings.TrimSpace(stderr)
137+ if stderr == "" {
138+ return err
139+ }
140+ return fmt.Errorf("%w: %s", err, strings.Join(strings.Fields(stderr), " "))
141+}
142+
143+// capped is a buffer that keeps its first max bytes and drops the rest,
144+// remembering that it did. It never fails a write: a hook blocked on a full
145+// pipe would only end at its timeout. The buffer is a field, not embedded:
146+// an embedded bytes.Buffer brings its ReadFrom along, and io.Copy — what
147+// os/exec uses to fill Stdout — would call it and bypass Write, cap included.
148+type capped struct {
149+ buf bytes.Buffer
150+ max int
151+ overflow bool
152+}
153+
154+func (c *capped) Write(p []byte) (int, error) {
155+ room := c.max - c.buf.Len()
156+ if len(p) > room {
157+ c.overflow = true
158+ c.buf.Write(p[:max(room, 0)])
159+ return len(p), nil
160+ }
161+ return c.buf.Write(p)
162+}
163+
164+// Bytes returns what was kept.
165+func (c *capped) Bytes() []byte { return c.buf.Bytes() }
166+
167+// String returns what was kept, as text.
168+func (c *capped) String() string { return c.buf.String() }
new file mode 100644
@@ -0,0 +1,168 @@
1+package hooks
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "strings"
12+ "time"
13+
14+ "mm/internal/config"
15+)
16+
17+// Environment variables a hook can read besides its stdin.
18+const (
19+ // EnvEvent names the event the hook runs for: one script can serve
20+ // several events and still know which one called it.
21+ EnvEvent = "MM_HOOK_EVENT"
22+ // EnvConfigDir is the directory of the YAML file that declared the hook,
23+ // so `node "$MM_CONFIG_DIR/hooks/x.js"` finds a script shipped beside
24+ // its agent.yaml from any working directory.
25+ EnvConfigDir = "MM_CONFIG_DIR"
26+)
27+
28+// maxReply caps what a hook may print on stdout. A reply is a small JSON
29+// object; a hook that floods its stdout is broken, and must not pull it all
30+// into memory nor into the model's context.
31+const maxReply = 1 << 20
32+
33+// maxStderr is how much of a failing hook's stderr its report quotes.
34+const maxStderr = 300
35+
36+// waitDelay bounds the wait for the pipes once the hook has exited or been
37+// killed: a hook that leaves a background child holding its stdout would
38+// otherwise block Wait forever (the same trap as the bash tool's, ticket 26).
39+const waitDelay = time.Second
40+
41+// execFunc runs one hook with its stdin and returns its stdout: runCommand,
42+// behind a field so a test elsewhere can swap the process for a function.
43+type execFunc func(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error)
44+
45+// call runs one hook on in and decodes its reply. An empty stdout is a
46+// "continue". Every failure — the hook's exit, its timeout, an unreadable
47+// reply, an unknown action, an explicit "error" — comes back as an error.
48+func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) {
49+ stdin, err := json.Marshal(in)
50+ if err != nil {
51+ return Reply{}, fmt.Errorf("cannot encode the input: %w", err)
52+ }
53+ env := []string{EnvEvent + "=" + in.Event, EnvConfigDir + "=" + r.cfg.ConfigDir}
54+ stdout, err := r.exec(ctx, h, env, in.Cwd, stdin)
55+ if err != nil {
56+ return Reply{}, err
57+ }
58+ return decode(stdout, in.Event)
59+}
60+
61+// decode reads a hook's stdout. An action that does not belong to the event
62+// (a "deny" from a preGeneration hook) is an error rather than a silent
63+// continue: whoever wrote it meant to stop something.
64+func decode(stdout []byte, event string) (Reply, error) {
65+ if len(bytes.TrimSpace(stdout)) == 0 {
66+ return Reply{Action: ActionContinue}, nil
67+ }
68+ var reply Reply
69+ if err := json.Unmarshal(stdout, &reply); err != nil {
70+ return Reply{}, fmt.Errorf("invalid JSON on stdout: %w", err)
71+ }
72+ if reply.Action == "" {
73+ reply.Action = ActionContinue
74+ }
75+ if err := checkAction(reply, event); err != nil {
76+ return Reply{}, err
77+ }
78+ return reply, nil
79+}
80+
81+// checkAction refuses a reply whose action is an error, unknown, or meant
82+// for another event.
83+func checkAction(reply Reply, event string) error {
84+ switch reply.Action {
85+ case ActionContinue:
86+ return nil
87+ case ActionError:
88+ if reply.Error == "" {
89+ return errors.New("the hook reported an error")
90+ }
91+ return errors.New(reply.Error)
92+ case ActionStop:
93+ if event == config.HookPreGeneration {
94+ return nil
95+ }
96+ case ActionDeny:
97+ if event == config.HookPreToolCall {
98+ return nil
99+ }
100+ }
101+ return fmt.Errorf("action %q is not valid for %s", reply.Action, event)
102+}
103+
104+// runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory),
105+// under h.Timeout, with env added to mm's environment.
106+func runCommand(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) {
107+ ctx, cancel := context.WithTimeout(ctx, h.Timeout)
108+ defer cancel()
109+
110+ cmd := exec.CommandContext(ctx, "sh", "-c", h.Command)
111+ cmd.Dir = dir
112+ cmd.Env = append(os.Environ(), env...)
113+ cmd.Stdin = bytes.NewReader(stdin)
114+ stdout := &capped{max: maxReply}
115+ stderr := &capped{max: maxStderr}
116+ cmd.Stdout, cmd.Stderr = stdout, stderr
117+ cmd.WaitDelay = waitDelay
118+
119+ err := cmd.Run()
120+ switch {
121+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
122+ return nil, fmt.Errorf("timed out after %s", h.Timeout)
123+ case errors.Is(ctx.Err(), context.Canceled):
124+ return nil, errors.New("cancelled")
125+ case err != nil:
126+ return nil, withStderr(err, stderr.String())
127+ case stdout.overflow:
128+ return nil, fmt.Errorf("stdout larger than %d bytes", maxReply)
129+ }
130+ return stdout.Bytes(), nil
131+}
132+
133+// withStderr adds the start of what the hook said on stderr to its exit
134+// error: "exit status 2" alone sends the user to read the script.
135+func withStderr(err error, stderr string) error {
136+ stderr = strings.TrimSpace(stderr)
137+ if stderr == "" {
138+ return err
139+ }
140+ return fmt.Errorf("%w: %s", err, strings.Join(strings.Fields(stderr), " "))
141+}
142+
143+// capped is a buffer that keeps its first max bytes and drops the rest,
144+// remembering that it did. It never fails a write: a hook blocked on a full
145+// pipe would only end at its timeout. The buffer is a field, not embedded:
146+// an embedded bytes.Buffer brings its ReadFrom along, and io.Copy — what
147+// os/exec uses to fill Stdout — would call it and bypass Write, cap included.
148+type capped struct {
149+ buf bytes.Buffer
150+ max int
151+ overflow bool
152+}
153+
154+func (c *capped) Write(p []byte) (int, error) {
155+ room := c.max - c.buf.Len()
156+ if len(p) > room {
157+ c.overflow = true
158+ c.buf.Write(p[:max(room, 0)])
159+ return len(p), nil
160+ }
161+ return c.buf.Write(p)
162+}
163+
164+// Bytes returns what was kept.
165+func (c *capped) Bytes() []byte { return c.buf.Bytes() }
166+
167+// String returns what was kept, as text.
168+func (c *capped) String() string { return c.buf.String() }
added internal/hooks/hooks.go +268 -0
new file mode 100644
@@ -0,0 +1,268 @@
1+// Package hooks runs the external commands of the `hooks:` block (ticket 36)
2+// at four moments of a turn, and turns what they answer into decisions the
3+// engine applies.
4+//
5+// A hook is any program: it reads ONE JSON object on stdin (Input) and may
6+// write ONE JSON object on stdout (Reply). Writing nothing means "continue",
7+// so a hook that only logs has nothing to print. Its stderr is shown only
8+// when it fails.
9+//
10+// runner := hooks.New(config.Cfg.Hooks, func(msg string) { fmt.Println(msg) })
11+// d := runner.PreGeneration(ctx, hooks.Input{Cwd: dir, Prompt: question})
12+// if d.Stopped {
13+// fmt.Println(d.Response) // the model is never called
14+// }
15+//
16+// The package knows nothing about Genkit or the front ends: the engine builds
17+// the Input, applies the decision, and decides how to show it.
18+package hooks
19+
20+import (
21+ "context"
22+ "encoding/json"
23+ "strings"
24+
25+ "mm/internal/config"
26+)
27+
28+// The actions a hook may answer. An empty action is ActionContinue.
29+const (
30+ ActionContinue = "continue"
31+ // ActionStop ends the turn before the model is called (preGeneration).
32+ ActionStop = "stop"
33+ // ActionDeny refuses one tool call (preToolCall); the model reads the
34+ // reason as the tool's output.
35+ ActionDeny = "deny"
36+ // ActionError is the hook saying it failed: handled like a crash,
37+ // according to its onError.
38+ ActionError = "error"
39+)
40+
41+// Input is the JSON object a hook reads on stdin. Which fields are filled
42+// depends on the event:
43+//
44+// preGeneration event, cwd, model, prompt
45+// preToolCall event, cwd, model, tool
46+// postToolCall event, cwd, model, tool, output
47+// postGeneration event, cwd, model, prompt, answer, error
48+type Input struct {
49+ Event string `json:"event"`
50+ Cwd string `json:"cwd"`
51+ Model string `json:"model,omitempty"`
52+ Prompt string `json:"prompt,omitempty"`
53+ Tool *ToolCall `json:"tool,omitempty"`
54+ Output string `json:"output,omitempty"`
55+ Answer string `json:"answer,omitempty"`
56+ Error string `json:"error,omitempty"`
57+}
58+
59+// ToolCall describes the tool call a preToolCall or postToolCall hook is
60+// about: the model's own arguments, as JSON.
61+type ToolCall struct {
62+ ID string `json:"id,omitempty"`
63+ Name string `json:"name"`
64+ Input any `json:"input"`
65+}
66+
67+// Reply is the JSON object a hook may write on stdout. Every field is
68+// optional; which ones are read depends on the event (see the decisions).
69+type Reply struct {
70+ Action string `json:"action,omitempty"`
71+ // Prompt replaces the user's question (preGeneration). Absent = kept.
72+ Prompt *string `json:"prompt,omitempty"`
73+ // Context is text added for the model: after the question
74+ // (preGeneration), after the tool's output (postToolCall).
75+ Context string `json:"context,omitempty"`
76+ // Input replaces the tool call's arguments (preToolCall). Absent = kept.
77+ Input json.RawMessage `json:"input,omitempty"`
78+ // Reason says why a turn was stopped or a call denied.
79+ Reason string `json:"reason,omitempty"`
80+ // Response is what the user reads when the turn is stopped.
81+ Response string `json:"response,omitempty"`
82+ // Error is the hook's own failure message, with action "error".
83+ Error string `json:"error,omitempty"`
84+}
85+
86+// Runner runs the hooks of one configuration. The zero value runs none.
87+type Runner struct {
88+ cfg config.HooksConfig
89+ notify func(string)
90+ exec execFunc
91+}
92+
93+// New returns a runner for cfg. notify receives the one-line reports of the
94+// hooks that fail with onError warn (and fail, on the post* events); nil
95+// drops them.
96+func New(cfg config.HooksConfig, notify func(string)) *Runner {
97+ if notify == nil {
98+ notify = func(string) {}
99+ }
100+ return &Runner{cfg: cfg, notify: notify, exec: runCommand}
101+}
102+
103+// Enabled reports whether any hook is declared — the engine skips building
104+// payloads when there is none.
105+func (r *Runner) Enabled() bool {
106+ return r != nil && r.cfg.Summary() != ""
107+}
108+
109+// PromptDecision is what the preGeneration chain decided.
110+type PromptDecision struct {
111+ // Prompt is the question to send: the user's, or the last rewrite.
112+ Prompt string
113+ // Context holds the text the hooks added, in hook order.
114+ Context []string
115+ // Stopped means the model must not be called; Response is what the user
116+ // reads instead and Reason why.
117+ Stopped bool
118+ Response string
119+ Reason string
120+}
121+
122+// Changed reports whether the chain rewrote the prompt or added context.
123+func (d PromptDecision) Changed(original string) bool {
124+ return d.Prompt != original || len(d.Context) > 0
125+}
126+
127+// PreGeneration runs the preGeneration chain on in.Prompt. Each hook sees the
128+// prompt as the previous one left it; the first stop ends the chain.
129+func (r *Runner) PreGeneration(ctx context.Context, in Input) PromptDecision {
130+ in.Event = config.HookPreGeneration
131+ d := PromptDecision{Prompt: in.Prompt}
132+ if r == nil {
133+ return d
134+ }
135+ for _, h := range r.cfg.For(config.HookPreGeneration) {
136+ in.Prompt = d.Prompt
137+ reply, err := r.call(ctx, h, in)
138+ if err != nil {
139+ if r.failed(h, in.Event, err) {
140+ return stopped(d, "hook failed: "+err.Error())
141+ }
142+ continue
143+ }
144+ if reply.Action == ActionStop {
145+ return stopped(d, reply.Reason, reply.Response)
146+ }
147+ if reply.Prompt != nil {
148+ d.Prompt = *reply.Prompt
149+ }
150+ if c := strings.TrimSpace(reply.Context); c != "" {
151+ d.Context = append(d.Context, c)
152+ }
153+ }
154+ return d
155+}
156+
157+// stopped marks d as stopped. The response defaults to a sentence built on
158+// the reason, so a hook that only says why still tells the user something.
159+func stopped(d PromptDecision, reason string, response ...string) PromptDecision {
160+ d.Stopped, d.Reason = true, reason
161+ if len(response) > 0 && strings.TrimSpace(response[0]) != "" {
162+ d.Response = response[0]
163+ return d
164+ }
165+ d.Response = "⛔ This request was blocked by a hook."
166+ if reason != "" {
167+ d.Response = "⛔ This request was blocked by a hook: " + reason
168+ }
169+ return d
170+}
171+
172+// ToolDecision is what the preToolCall chain decided.
173+type ToolDecision struct {
174+ // Input is the call's arguments: the model's, or the last replacement.
175+ Input any
176+ // Denied means the tool must not run; Reason is handed to the model.
177+ Denied bool
178+ Reason string
179+}
180+
181+// PreToolCall runs the preToolCall hooks whose matcher accepts the tool. Each
182+// one sees the input as the previous one left it; the first deny ends the
183+// chain.
184+func (r *Runner) PreToolCall(ctx context.Context, in Input) ToolDecision {
185+ in.Event = config.HookPreToolCall
186+ d := ToolDecision{Input: in.Tool.Input}
187+ if r == nil {
188+ return d
189+ }
190+ // A copy: the chain rewrites the input it shows each hook, and the
191+ // caller's ToolCall must stay what the model asked for.
192+ tool := *in.Tool
193+ in.Tool = &tool
194+ for _, h := range r.cfg.For(config.HookPreToolCall) {
195+ if !h.Matches(in.Tool.Name) {
196+ continue
197+ }
198+ in.Tool.Input = d.Input
199+ reply, err := r.call(ctx, h, in)
200+ if err != nil {
201+ if r.failed(h, in.Event, err) {
202+ return ToolDecision{Input: d.Input, Denied: true, Reason: "hook failed: " + err.Error()}
203+ }
204+ continue
205+ }
206+ if reply.Action == ActionDeny {
207+ return ToolDecision{Input: d.Input, Denied: true, Reason: reply.Reason}
208+ }
209+ if len(reply.Input) > 0 {
210+ var replaced any
211+ if err := json.Unmarshal(reply.Input, &replaced); err == nil {
212+ d.Input = replaced
213+ }
214+ }
215+ }
216+ return d
217+}
218+
219+// PostToolCall runs the postToolCall hooks whose matcher accepts the tool,
220+// and returns the context they added for the model, in hook order.
221+func (r *Runner) PostToolCall(ctx context.Context, in Input) []string {
222+ in.Event = config.HookPostToolCall
223+ if r == nil {
224+ return nil
225+ }
226+ var added []string
227+ for _, h := range r.cfg.For(config.HookPostToolCall) {
228+ if !h.Matches(in.Tool.Name) {
229+ continue
230+ }
231+ reply, err := r.call(ctx, h, in)
232+ if err != nil {
233+ r.failed(h, in.Event, err)
234+ continue
235+ }
236+ if c := strings.TrimSpace(reply.Context); c != "" {
237+ added = append(added, c)
238+ }
239+ }
240+ return added
241+}
242+
243+// PostGeneration runs the postGeneration hooks. They only observe: what they
244+// print is ignored, a failure is reported according to onError.
245+func (r *Runner) PostGeneration(ctx context.Context, in Input) {
246+ in.Event = config.HookPostGeneration
247+ if r == nil {
248+ return
249+ }
250+ for _, h := range r.cfg.For(config.HookPostGeneration) {
251+ if _, err := r.call(ctx, h, in); err != nil {
252+ r.failed(h, in.Event, err)
253+ }
254+ }
255+}
256+
257+// failed reports a hook's failure according to its onError, and says whether
258+// the turn (or the call) must stop: only for fail, and only on the pre*
259+// events — after the fact there is nothing left to stop, so fail is
260+// reported like warn there.
261+func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) {
262+ if h.OnError == config.HookOnErrorIgnore {
263+ return false
264+ }
265+ r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error())
266+ pre := event == config.HookPreGeneration || event == config.HookPreToolCall
267+ return pre && h.OnError == config.HookOnErrorFail
268+}
new file mode 100644
@@ -0,0 +1,268 @@
1+// Package hooks runs the external commands of the `hooks:` block (ticket 36)
2+// at four moments of a turn, and turns what they answer into decisions the
3+// engine applies.
4+//
5+// A hook is any program: it reads ONE JSON object on stdin (Input) and may
6+// write ONE JSON object on stdout (Reply). Writing nothing means "continue",
7+// so a hook that only logs has nothing to print. Its stderr is shown only
8+// when it fails.
9+//
10+// runner := hooks.New(config.Cfg.Hooks, func(msg string) { fmt.Println(msg) })
11+// d := runner.PreGeneration(ctx, hooks.Input{Cwd: dir, Prompt: question})
12+// if d.Stopped {
13+// fmt.Println(d.Response) // the model is never called
14+// }
15+//
16+// The package knows nothing about Genkit or the front ends: the engine builds
17+// the Input, applies the decision, and decides how to show it.
18+package hooks
19+
20+import (
21+ "context"
22+ "encoding/json"
23+ "strings"
24+
25+ "mm/internal/config"
26+)
27+
28+// The actions a hook may answer. An empty action is ActionContinue.
29+const (
30+ ActionContinue = "continue"
31+ // ActionStop ends the turn before the model is called (preGeneration).
32+ ActionStop = "stop"
33+ // ActionDeny refuses one tool call (preToolCall); the model reads the
34+ // reason as the tool's output.
35+ ActionDeny = "deny"
36+ // ActionError is the hook saying it failed: handled like a crash,
37+ // according to its onError.
38+ ActionError = "error"
39+)
40+
41+// Input is the JSON object a hook reads on stdin. Which fields are filled
42+// depends on the event:
43+//
44+// preGeneration event, cwd, model, prompt
45+// preToolCall event, cwd, model, tool
46+// postToolCall event, cwd, model, tool, output
47+// postGeneration event, cwd, model, prompt, answer, error
48+type Input struct {
49+ Event string `json:"event"`
50+ Cwd string `json:"cwd"`
51+ Model string `json:"model,omitempty"`
52+ Prompt string `json:"prompt,omitempty"`
53+ Tool *ToolCall `json:"tool,omitempty"`
54+ Output string `json:"output,omitempty"`
55+ Answer string `json:"answer,omitempty"`
56+ Error string `json:"error,omitempty"`
57+}
58+
59+// ToolCall describes the tool call a preToolCall or postToolCall hook is
60+// about: the model's own arguments, as JSON.
61+type ToolCall struct {
62+ ID string `json:"id,omitempty"`
63+ Name string `json:"name"`
64+ Input any `json:"input"`
65+}
66+
67+// Reply is the JSON object a hook may write on stdout. Every field is
68+// optional; which ones are read depends on the event (see the decisions).
69+type Reply struct {
70+ Action string `json:"action,omitempty"`
71+ // Prompt replaces the user's question (preGeneration). Absent = kept.
72+ Prompt *string `json:"prompt,omitempty"`
73+ // Context is text added for the model: after the question
74+ // (preGeneration), after the tool's output (postToolCall).
75+ Context string `json:"context,omitempty"`
76+ // Input replaces the tool call's arguments (preToolCall). Absent = kept.
77+ Input json.RawMessage `json:"input,omitempty"`
78+ // Reason says why a turn was stopped or a call denied.
79+ Reason string `json:"reason,omitempty"`
80+ // Response is what the user reads when the turn is stopped.
81+ Response string `json:"response,omitempty"`
82+ // Error is the hook's own failure message, with action "error".
83+ Error string `json:"error,omitempty"`
84+}
85+
86+// Runner runs the hooks of one configuration. The zero value runs none.
87+type Runner struct {
88+ cfg config.HooksConfig
89+ notify func(string)
90+ exec execFunc
91+}
92+
93+// New returns a runner for cfg. notify receives the one-line reports of the
94+// hooks that fail with onError warn (and fail, on the post* events); nil
95+// drops them.
96+func New(cfg config.HooksConfig, notify func(string)) *Runner {
97+ if notify == nil {
98+ notify = func(string) {}
99+ }
100+ return &Runner{cfg: cfg, notify: notify, exec: runCommand}
101+}
102+
103+// Enabled reports whether any hook is declared — the engine skips building
104+// payloads when there is none.
105+func (r *Runner) Enabled() bool {
106+ return r != nil && r.cfg.Summary() != ""
107+}
108+
109+// PromptDecision is what the preGeneration chain decided.
110+type PromptDecision struct {
111+ // Prompt is the question to send: the user's, or the last rewrite.
112+ Prompt string
113+ // Context holds the text the hooks added, in hook order.
114+ Context []string
115+ // Stopped means the model must not be called; Response is what the user
116+ // reads instead and Reason why.
117+ Stopped bool
118+ Response string
119+ Reason string
120+}
121+
122+// Changed reports whether the chain rewrote the prompt or added context.
123+func (d PromptDecision) Changed(original string) bool {
124+ return d.Prompt != original || len(d.Context) > 0
125+}
126+
127+// PreGeneration runs the preGeneration chain on in.Prompt. Each hook sees the
128+// prompt as the previous one left it; the first stop ends the chain.
129+func (r *Runner) PreGeneration(ctx context.Context, in Input) PromptDecision {
130+ in.Event = config.HookPreGeneration
131+ d := PromptDecision{Prompt: in.Prompt}
132+ if r == nil {
133+ return d
134+ }
135+ for _, h := range r.cfg.For(config.HookPreGeneration) {
136+ in.Prompt = d.Prompt
137+ reply, err := r.call(ctx, h, in)
138+ if err != nil {
139+ if r.failed(h, in.Event, err) {
140+ return stopped(d, "hook failed: "+err.Error())
141+ }
142+ continue
143+ }
144+ if reply.Action == ActionStop {
145+ return stopped(d, reply.Reason, reply.Response)
146+ }
147+ if reply.Prompt != nil {
148+ d.Prompt = *reply.Prompt
149+ }
150+ if c := strings.TrimSpace(reply.Context); c != "" {
151+ d.Context = append(d.Context, c)
152+ }
153+ }
154+ return d
155+}
156+
157+// stopped marks d as stopped. The response defaults to a sentence built on
158+// the reason, so a hook that only says why still tells the user something.
159+func stopped(d PromptDecision, reason string, response ...string) PromptDecision {
160+ d.Stopped, d.Reason = true, reason
161+ if len(response) > 0 && strings.TrimSpace(response[0]) != "" {
162+ d.Response = response[0]
163+ return d
164+ }
165+ d.Response = "⛔ This request was blocked by a hook."
166+ if reason != "" {
167+ d.Response = "⛔ This request was blocked by a hook: " + reason
168+ }
169+ return d
170+}
171+
172+// ToolDecision is what the preToolCall chain decided.
173+type ToolDecision struct {
174+ // Input is the call's arguments: the model's, or the last replacement.
175+ Input any
176+ // Denied means the tool must not run; Reason is handed to the model.
177+ Denied bool
178+ Reason string
179+}
180+
181+// PreToolCall runs the preToolCall hooks whose matcher accepts the tool. Each
182+// one sees the input as the previous one left it; the first deny ends the
183+// chain.
184+func (r *Runner) PreToolCall(ctx context.Context, in Input) ToolDecision {
185+ in.Event = config.HookPreToolCall
186+ d := ToolDecision{Input: in.Tool.Input}
187+ if r == nil {
188+ return d
189+ }
190+ // A copy: the chain rewrites the input it shows each hook, and the
191+ // caller's ToolCall must stay what the model asked for.
192+ tool := *in.Tool
193+ in.Tool = &tool
194+ for _, h := range r.cfg.For(config.HookPreToolCall) {
195+ if !h.Matches(in.Tool.Name) {
196+ continue
197+ }
198+ in.Tool.Input = d.Input
199+ reply, err := r.call(ctx, h, in)
200+ if err != nil {
201+ if r.failed(h, in.Event, err) {
202+ return ToolDecision{Input: d.Input, Denied: true, Reason: "hook failed: " + err.Error()}
203+ }
204+ continue
205+ }
206+ if reply.Action == ActionDeny {
207+ return ToolDecision{Input: d.Input, Denied: true, Reason: reply.Reason}
208+ }
209+ if len(reply.Input) > 0 {
210+ var replaced any
211+ if err := json.Unmarshal(reply.Input, &replaced); err == nil {
212+ d.Input = replaced
213+ }
214+ }
215+ }
216+ return d
217+}
218+
219+// PostToolCall runs the postToolCall hooks whose matcher accepts the tool,
220+// and returns the context they added for the model, in hook order.
221+func (r *Runner) PostToolCall(ctx context.Context, in Input) []string {
222+ in.Event = config.HookPostToolCall
223+ if r == nil {
224+ return nil
225+ }
226+ var added []string
227+ for _, h := range r.cfg.For(config.HookPostToolCall) {
228+ if !h.Matches(in.Tool.Name) {
229+ continue
230+ }
231+ reply, err := r.call(ctx, h, in)
232+ if err != nil {
233+ r.failed(h, in.Event, err)
234+ continue
235+ }
236+ if c := strings.TrimSpace(reply.Context); c != "" {
237+ added = append(added, c)
238+ }
239+ }
240+ return added
241+}
242+
243+// PostGeneration runs the postGeneration hooks. They only observe: what they
244+// print is ignored, a failure is reported according to onError.
245+func (r *Runner) PostGeneration(ctx context.Context, in Input) {
246+ in.Event = config.HookPostGeneration
247+ if r == nil {
248+ return
249+ }
250+ for _, h := range r.cfg.For(config.HookPostGeneration) {
251+ if _, err := r.call(ctx, h, in); err != nil {
252+ r.failed(h, in.Event, err)
253+ }
254+ }
255+}
256+
257+// failed reports a hook's failure according to its onError, and says whether
258+// the turn (or the call) must stop: only for fail, and only on the pre*
259+// events — after the fact there is nothing left to stop, so fail is
260+// reported like warn there.
261+func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) {
262+ if h.OnError == config.HookOnErrorIgnore {
263+ return false
264+ }
265+ r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error())
266+ pre := event == config.HookPreGeneration || event == config.HookPreToolCall
267+ return pre && h.OnError == config.HookOnErrorFail
268+}
added internal/hooks/hooks_test.go +295 -0
new file mode 100644
@@ -0,0 +1,295 @@
1+package hooks
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "mm/internal/config"
13+)
14+
15+// The tests run real hooks through `sh -c`, written as tiny shell scripts:
16+// the protocol (stdin, stdout, exit code, environment) is what is under test,
17+// and node is not needed for that.
18+
19+// hook returns a hook entry that runs script with the defaults Load would
20+// have filled in.
21+func hook(script string) config.Hook {
22+ return config.Hook{Command: script, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn}
23+}
24+
25+// runner builds a Runner for cfg and collects what it reports.
26+func runner(cfg config.HooksConfig) (*Runner, *[]string) {
27+ var notes []string
28+ return New(cfg, func(s string) { notes = append(notes, s) }), &notes
29+}
30+
31+func TestNoHookChangesNothing(t *testing.T) {
32+ r, notes := runner(config.HooksConfig{})
33+ if r.Enabled() {
34+ t.Error("Enabled() = true with no hook")
35+ }
36+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
37+ if d.Stopped || d.Changed("hello") {
38+ t.Errorf("PreGeneration = %+v, want the prompt untouched", d)
39+ }
40+ td := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}}})
41+ if td.Denied {
42+ t.Errorf("PreToolCall = %+v, want allowed", td)
43+ }
44+ if len(*notes) > 0 {
45+ t.Errorf("notes = %v, want none", *notes)
46+ }
47+}
48+
49+// A hook that prints nothing continues: a logging hook owes no answer.
50+func TestEmptyStdoutContinues(t *testing.T) {
51+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook("cat > /dev/null")}})
52+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
53+ if d.Stopped || d.Prompt != "hello" || len(*notes) > 0 {
54+ t.Errorf("decision %+v, notes %v; want untouched, no note", d, *notes)
55+ }
56+}
57+
58+// The hook reads the event, the prompt and its environment.
59+func TestHookReadsItsInputAndEnvironment(t *testing.T) {
60+ dir := t.TempDir()
61+ out := filepath.Join(dir, "seen.json")
62+ cfg := config.HooksConfig{
63+ ConfigDir: "/the/config/dir",
64+ PreGeneration: []config.Hook{hook(`cat > "` + out + `"; echo "$MM_HOOK_EVENT $MM_CONFIG_DIR $(pwd)" >> "` + out + `.env"`)},
65+ }
66+ r, _ := runner(cfg)
67+ r.PreGeneration(context.Background(), Input{Cwd: dir, Model: "m", Prompt: "list files"})
68+
69+ var seen Input
70+ data, err := os.ReadFile(out)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if err := json.Unmarshal(data, &seen); err != nil {
75+ t.Fatalf("stdin was not JSON: %v\n%s", err, data)
76+ }
77+ if seen.Event != "preGeneration" || seen.Prompt != "list files" || seen.Cwd != dir || seen.Model != "m" {
78+ t.Errorf("hook read %+v", seen)
79+ }
80+ env, _ := os.ReadFile(out + ".env")
81+ resolved, _ := filepath.EvalSymlinks(dir)
82+ if got := strings.TrimSpace(string(env)); got != "preGeneration /the/config/dir "+dir && got != "preGeneration /the/config/dir "+resolved {
83+ t.Errorf("environment and directory = %q", got)
84+ }
85+}
86+
87+// Rewrites chain: the second hook sees the first one's prompt; contexts
88+// accumulate in order.
89+func TestPreGenerationRewritesAndAddsContextInOrder(t *testing.T) {
90+ cfg := config.HooksConfig{PreGeneration: []config.Hook{
91+ hook(`echo '{"prompt":"first rewrite","context":"ctx one"}'`),
92+ hook(`grep -q '"prompt":"first rewrite"' && echo '{"action":"continue","prompt":"second rewrite","context":"ctx two"}'`),
93+ }}
94+ r, notes := runner(cfg)
95+ d := r.PreGeneration(context.Background(), Input{Prompt: "original"})
96+ if d.Prompt != "second rewrite" {
97+ t.Errorf("Prompt = %q, want the second hook's rewrite (it must see the first one's)", d.Prompt)
98+ }
99+ if strings.Join(d.Context, "|") != "ctx one|ctx two" {
100+ t.Errorf("Context = %v, want [ctx one ctx two]", d.Context)
101+ }
102+ if !d.Changed("original") || len(*notes) > 0 {
103+ t.Errorf("Changed = false or notes %v", *notes)
104+ }
105+}
106+
107+// The first stop ends the chain: the hook after it never runs.
108+func TestPreGenerationStopEndsTheChain(t *testing.T) {
109+ marker := filepath.Join(t.TempDir(), "ran")
110+ cfg := config.HooksConfig{PreGeneration: []config.Hook{
111+ hook(`echo '{"action":"stop","reason":"secret","response":"No secrets here."}'`),
112+ hook(`touch "` + marker + `"`),
113+ }}
114+ r, _ := runner(cfg)
115+ d := r.PreGeneration(context.Background(), Input{Prompt: "my password is x"})
116+ if !d.Stopped || d.Response != "No secrets here." || d.Reason != "secret" {
117+ t.Errorf("decision = %+v, want stopped with the hook's response", d)
118+ }
119+ if _, err := os.Stat(marker); err == nil {
120+ t.Error("the hook after the stop ran")
121+ }
122+}
123+
124+// A stop without a response still tells the user something.
125+func TestStopWithoutResponseExplainsTheReason(t *testing.T) {
126+ r, _ := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo '{"action":"stop","reason":"too short"}'`)}})
127+ d := r.PreGeneration(context.Background(), Input{Prompt: "x"})
128+ if !strings.Contains(d.Response, "too short") {
129+ t.Errorf("Response = %q, want the reason in it", d.Response)
130+ }
131+}
132+
133+// onError decides what a failing hook does, on a pre* event.
134+func TestFailingHookFollowsOnError(t *testing.T) {
135+ cases := []struct {
136+ onError string
137+ wantStopped bool
138+ wantNote bool
139+ }{
140+ {config.HookOnErrorFail, true, true},
141+ {config.HookOnErrorWarn, false, true},
142+ {config.HookOnErrorIgnore, false, false},
143+ }
144+ failures := map[string]string{
145+ "exit code": `echo boom >&2; exit 2`,
146+ "invalid JSON": `echo 'not json'`,
147+ "error action": `echo '{"action":"error","error":"no config file"}'`,
148+ "wrong action": `echo '{"action":"deny"}'`, // deny is for tools
149+ "missing shell": `this-command-does-not-exist-anywhere`,
150+ }
151+ for name, script := range failures {
152+ for _, tc := range cases {
153+ t.Run(name+"/"+tc.onError, func(t *testing.T) {
154+ h := hook(script)
155+ h.OnError = tc.onError
156+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}})
157+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
158+ if d.Stopped != tc.wantStopped {
159+ t.Errorf("Stopped = %v, want %v (%+v)", d.Stopped, tc.wantStopped, d)
160+ }
161+ if (len(*notes) > 0) != tc.wantNote {
162+ t.Errorf("notes = %v, want a note: %v", *notes, tc.wantNote)
163+ }
164+ })
165+ }
166+ }
167+}
168+
169+// The report quotes the hook's stderr: "exit status 2" alone sends the user
170+// to read the script.
171+func TestFailureReportQuotesStderr(t *testing.T) {
172+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo "cannot read rules.json" >&2; exit 1`)}})
173+ r.PreGeneration(context.Background(), Input{Prompt: "hello"})
174+ if len(*notes) != 1 || !strings.Contains((*notes)[0], "cannot read rules.json") {
175+ t.Errorf("notes = %v, want the stderr quoted", *notes)
176+ }
177+}
178+
179+func TestHookTimesOut(t *testing.T) {
180+ h := hook(`sleep 5`)
181+ h.Timeout = 100 * time.Millisecond
182+ h.OnError = config.HookOnErrorFail
183+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}})
184+ start := time.Now()
185+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
186+ if elapsed := time.Since(start); elapsed > 3*time.Second {
187+ t.Errorf("took %s, want the timeout to cut it", elapsed)
188+ }
189+ if !d.Stopped || len(*notes) != 1 || !strings.Contains((*notes)[0], "timed out") {
190+ t.Errorf("decision %+v, notes %v; want stopped on a timeout", d, *notes)
191+ }
192+}
193+
194+// A hook that floods its stdout is a failure, not a 1 GB context.
195+func TestOversizedReplyIsAFailure(t *testing.T) {
196+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`head -c 2000000 /dev/zero`)}})
197+ r.PreGeneration(context.Background(), Input{Prompt: "hello"})
198+ if len(*notes) != 1 || !strings.Contains((*notes)[0], "larger than") {
199+ t.Errorf("notes = %v, want the size reported", *notes)
200+ }
201+}
202+
203+func TestPreToolCallDeniesMatchingToolsOnly(t *testing.T) {
204+ h := hook(`echo '{"action":"deny","reason":"no rm -rf"}'`)
205+ h.Matcher = "^bash$"
206+ r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}})
207+
208+ d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "rm -rf /"}}})
209+ if !d.Denied || d.Reason != "no rm -rf" {
210+ t.Errorf("bash: %+v, want denied with the reason", d)
211+ }
212+ d = r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "read_file", Input: map[string]any{"path": "a"}}})
213+ if d.Denied {
214+ t.Errorf("read_file: %+v, want allowed (the matcher is ^bash$)", d)
215+ }
216+}
217+
218+// A replaced input reaches the next hook and the caller; the caller's own
219+// ToolCall is left as the model sent it.
220+func TestPreToolCallReplacesTheInput(t *testing.T) {
221+ cfg := config.HooksConfig{PreToolCall: []config.Hook{
222+ hook(`echo '{"input":{"command":"ls -la"}}'`),
223+ hook(`grep -q '"command":"ls -la"' || echo '{"action":"deny","reason":"did not see the rewrite"}'`),
224+ }}
225+ r, _ := runner(cfg)
226+ call := &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}}
227+ d := r.PreToolCall(context.Background(), Input{Tool: call})
228+ if d.Denied {
229+ t.Fatalf("denied: %s", d.Reason)
230+ }
231+ if got := d.Input.(map[string]any)["command"]; got != "ls -la" {
232+ t.Errorf("Input = %v, want the replacement", d.Input)
233+ }
234+ if got := call.Input.(map[string]any)["command"]; got != "ls" {
235+ t.Errorf("caller's input changed to %v", got)
236+ }
237+}
238+
239+// onError fail on a tool hook denies the call.
240+func TestFailingToolHookWithFailDenies(t *testing.T) {
241+ h := hook(`exit 3`)
242+ h.OnError = config.HookOnErrorFail
243+ r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}})
244+ d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}})
245+ if !d.Denied || !strings.Contains(d.Reason, "hook failed") {
246+ t.Errorf("decision = %+v, want denied because the hook failed", d)
247+ }
248+}
249+
250+func TestPostToolCallReturnsContextAndSeesTheOutput(t *testing.T) {
251+ cfg := config.HooksConfig{PostToolCall: []config.Hook{
252+ hook(`grep -q '"output":"3 files"' && echo '{"context":"note: the output was read"}'`),
253+ hook(`exit 1`), // a failure after the fact is reported, never fatal
254+ }}
255+ cfg.PostToolCall[1].OnError = config.HookOnErrorFail
256+ r, notes := runner(cfg)
257+ added := r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}, Output: "3 files"})
258+ if len(added) != 1 || added[0] != "note: the output was read" {
259+ t.Errorf("context = %v", added)
260+ }
261+ if len(*notes) != 1 {
262+ t.Errorf("notes = %v, want the failure reported once", *notes)
263+ }
264+}
265+
266+func TestPostGenerationObservesTheAnswer(t *testing.T) {
267+ out := filepath.Join(t.TempDir(), "log")
268+ r, notes := runner(config.HooksConfig{PostGeneration: []config.Hook{hook(`cat >> "` + out + `"`)}})
269+ r.PostGeneration(context.Background(), Input{Prompt: "q", Answer: "a"})
270+ data, _ := os.ReadFile(out)
271+ if !strings.Contains(string(data), `"event":"postGeneration"`) || !strings.Contains(string(data), `"answer":"a"`) {
272+ t.Errorf("the hook read %s", data)
273+ }
274+ if len(*notes) > 0 {
275+ t.Errorf("notes = %v", *notes)
276+ }
277+}
278+
279+// A nil runner (no hooks configured anywhere) is safe to call.
280+func TestNilRunnerIsSafe(t *testing.T) {
281+ var r *Runner
282+ if r.Enabled() {
283+ t.Error("nil runner enabled")
284+ }
285+ if d := r.PreGeneration(context.Background(), Input{Prompt: "p"}); d.Prompt != "p" || d.Stopped {
286+ t.Errorf("PreGeneration = %+v", d)
287+ }
288+ if d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: 1}}); d.Denied || d.Input != 1 {
289+ t.Errorf("PreToolCall = %+v", d)
290+ }
291+ if r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}}) != nil {
292+ t.Error("PostToolCall returned context")
293+ }
294+ r.PostGeneration(context.Background(), Input{})
295+}
new file mode 100644
@@ -0,0 +1,295 @@
1+package hooks
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "mm/internal/config"
13+)
14+
15+// The tests run real hooks through `sh -c`, written as tiny shell scripts:
16+// the protocol (stdin, stdout, exit code, environment) is what is under test,
17+// and node is not needed for that.
18+
19+// hook returns a hook entry that runs script with the defaults Load would
20+// have filled in.
21+func hook(script string) config.Hook {
22+ return config.Hook{Command: script, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn}
23+}
24+
25+// runner builds a Runner for cfg and collects what it reports.
26+func runner(cfg config.HooksConfig) (*Runner, *[]string) {
27+ var notes []string
28+ return New(cfg, func(s string) { notes = append(notes, s) }), &notes
29+}
30+
31+func TestNoHookChangesNothing(t *testing.T) {
32+ r, notes := runner(config.HooksConfig{})
33+ if r.Enabled() {
34+ t.Error("Enabled() = true with no hook")
35+ }
36+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
37+ if d.Stopped || d.Changed("hello") {
38+ t.Errorf("PreGeneration = %+v, want the prompt untouched", d)
39+ }
40+ td := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}}})
41+ if td.Denied {
42+ t.Errorf("PreToolCall = %+v, want allowed", td)
43+ }
44+ if len(*notes) > 0 {
45+ t.Errorf("notes = %v, want none", *notes)
46+ }
47+}
48+
49+// A hook that prints nothing continues: a logging hook owes no answer.
50+func TestEmptyStdoutContinues(t *testing.T) {
51+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook("cat > /dev/null")}})
52+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
53+ if d.Stopped || d.Prompt != "hello" || len(*notes) > 0 {
54+ t.Errorf("decision %+v, notes %v; want untouched, no note", d, *notes)
55+ }
56+}
57+
58+// The hook reads the event, the prompt and its environment.
59+func TestHookReadsItsInputAndEnvironment(t *testing.T) {
60+ dir := t.TempDir()
61+ out := filepath.Join(dir, "seen.json")
62+ cfg := config.HooksConfig{
63+ ConfigDir: "/the/config/dir",
64+ PreGeneration: []config.Hook{hook(`cat > "` + out + `"; echo "$MM_HOOK_EVENT $MM_CONFIG_DIR $(pwd)" >> "` + out + `.env"`)},
65+ }
66+ r, _ := runner(cfg)
67+ r.PreGeneration(context.Background(), Input{Cwd: dir, Model: "m", Prompt: "list files"})
68+
69+ var seen Input
70+ data, err := os.ReadFile(out)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if err := json.Unmarshal(data, &seen); err != nil {
75+ t.Fatalf("stdin was not JSON: %v\n%s", err, data)
76+ }
77+ if seen.Event != "preGeneration" || seen.Prompt != "list files" || seen.Cwd != dir || seen.Model != "m" {
78+ t.Errorf("hook read %+v", seen)
79+ }
80+ env, _ := os.ReadFile(out + ".env")
81+ resolved, _ := filepath.EvalSymlinks(dir)
82+ if got := strings.TrimSpace(string(env)); got != "preGeneration /the/config/dir "+dir && got != "preGeneration /the/config/dir "+resolved {
83+ t.Errorf("environment and directory = %q", got)
84+ }
85+}
86+
87+// Rewrites chain: the second hook sees the first one's prompt; contexts
88+// accumulate in order.
89+func TestPreGenerationRewritesAndAddsContextInOrder(t *testing.T) {
90+ cfg := config.HooksConfig{PreGeneration: []config.Hook{
91+ hook(`echo '{"prompt":"first rewrite","context":"ctx one"}'`),
92+ hook(`grep -q '"prompt":"first rewrite"' && echo '{"action":"continue","prompt":"second rewrite","context":"ctx two"}'`),
93+ }}
94+ r, notes := runner(cfg)
95+ d := r.PreGeneration(context.Background(), Input{Prompt: "original"})
96+ if d.Prompt != "second rewrite" {
97+ t.Errorf("Prompt = %q, want the second hook's rewrite (it must see the first one's)", d.Prompt)
98+ }
99+ if strings.Join(d.Context, "|") != "ctx one|ctx two" {
100+ t.Errorf("Context = %v, want [ctx one ctx two]", d.Context)
101+ }
102+ if !d.Changed("original") || len(*notes) > 0 {
103+ t.Errorf("Changed = false or notes %v", *notes)
104+ }
105+}
106+
107+// The first stop ends the chain: the hook after it never runs.
108+func TestPreGenerationStopEndsTheChain(t *testing.T) {
109+ marker := filepath.Join(t.TempDir(), "ran")
110+ cfg := config.HooksConfig{PreGeneration: []config.Hook{
111+ hook(`echo '{"action":"stop","reason":"secret","response":"No secrets here."}'`),
112+ hook(`touch "` + marker + `"`),
113+ }}
114+ r, _ := runner(cfg)
115+ d := r.PreGeneration(context.Background(), Input{Prompt: "my password is x"})
116+ if !d.Stopped || d.Response != "No secrets here." || d.Reason != "secret" {
117+ t.Errorf("decision = %+v, want stopped with the hook's response", d)
118+ }
119+ if _, err := os.Stat(marker); err == nil {
120+ t.Error("the hook after the stop ran")
121+ }
122+}
123+
124+// A stop without a response still tells the user something.
125+func TestStopWithoutResponseExplainsTheReason(t *testing.T) {
126+ r, _ := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo '{"action":"stop","reason":"too short"}'`)}})
127+ d := r.PreGeneration(context.Background(), Input{Prompt: "x"})
128+ if !strings.Contains(d.Response, "too short") {
129+ t.Errorf("Response = %q, want the reason in it", d.Response)
130+ }
131+}
132+
133+// onError decides what a failing hook does, on a pre* event.
134+func TestFailingHookFollowsOnError(t *testing.T) {
135+ cases := []struct {
136+ onError string
137+ wantStopped bool
138+ wantNote bool
139+ }{
140+ {config.HookOnErrorFail, true, true},
141+ {config.HookOnErrorWarn, false, true},
142+ {config.HookOnErrorIgnore, false, false},
143+ }
144+ failures := map[string]string{
145+ "exit code": `echo boom >&2; exit 2`,
146+ "invalid JSON": `echo 'not json'`,
147+ "error action": `echo '{"action":"error","error":"no config file"}'`,
148+ "wrong action": `echo '{"action":"deny"}'`, // deny is for tools
149+ "missing shell": `this-command-does-not-exist-anywhere`,
150+ }
151+ for name, script := range failures {
152+ for _, tc := range cases {
153+ t.Run(name+"/"+tc.onError, func(t *testing.T) {
154+ h := hook(script)
155+ h.OnError = tc.onError
156+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}})
157+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
158+ if d.Stopped != tc.wantStopped {
159+ t.Errorf("Stopped = %v, want %v (%+v)", d.Stopped, tc.wantStopped, d)
160+ }
161+ if (len(*notes) > 0) != tc.wantNote {
162+ t.Errorf("notes = %v, want a note: %v", *notes, tc.wantNote)
163+ }
164+ })
165+ }
166+ }
167+}
168+
169+// The report quotes the hook's stderr: "exit status 2" alone sends the user
170+// to read the script.
171+func TestFailureReportQuotesStderr(t *testing.T) {
172+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo "cannot read rules.json" >&2; exit 1`)}})
173+ r.PreGeneration(context.Background(), Input{Prompt: "hello"})
174+ if len(*notes) != 1 || !strings.Contains((*notes)[0], "cannot read rules.json") {
175+ t.Errorf("notes = %v, want the stderr quoted", *notes)
176+ }
177+}
178+
179+func TestHookTimesOut(t *testing.T) {
180+ h := hook(`sleep 5`)
181+ h.Timeout = 100 * time.Millisecond
182+ h.OnError = config.HookOnErrorFail
183+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}})
184+ start := time.Now()
185+ d := r.PreGeneration(context.Background(), Input{Prompt: "hello"})
186+ if elapsed := time.Since(start); elapsed > 3*time.Second {
187+ t.Errorf("took %s, want the timeout to cut it", elapsed)
188+ }
189+ if !d.Stopped || len(*notes) != 1 || !strings.Contains((*notes)[0], "timed out") {
190+ t.Errorf("decision %+v, notes %v; want stopped on a timeout", d, *notes)
191+ }
192+}
193+
194+// A hook that floods its stdout is a failure, not a 1 GB context.
195+func TestOversizedReplyIsAFailure(t *testing.T) {
196+ r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`head -c 2000000 /dev/zero`)}})
197+ r.PreGeneration(context.Background(), Input{Prompt: "hello"})
198+ if len(*notes) != 1 || !strings.Contains((*notes)[0], "larger than") {
199+ t.Errorf("notes = %v, want the size reported", *notes)
200+ }
201+}
202+
203+func TestPreToolCallDeniesMatchingToolsOnly(t *testing.T) {
204+ h := hook(`echo '{"action":"deny","reason":"no rm -rf"}'`)
205+ h.Matcher = "^bash$"
206+ r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}})
207+
208+ d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "rm -rf /"}}})
209+ if !d.Denied || d.Reason != "no rm -rf" {
210+ t.Errorf("bash: %+v, want denied with the reason", d)
211+ }
212+ d = r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "read_file", Input: map[string]any{"path": "a"}}})
213+ if d.Denied {
214+ t.Errorf("read_file: %+v, want allowed (the matcher is ^bash$)", d)
215+ }
216+}
217+
218+// A replaced input reaches the next hook and the caller; the caller's own
219+// ToolCall is left as the model sent it.
220+func TestPreToolCallReplacesTheInput(t *testing.T) {
221+ cfg := config.HooksConfig{PreToolCall: []config.Hook{
222+ hook(`echo '{"input":{"command":"ls -la"}}'`),
223+ hook(`grep -q '"command":"ls -la"' || echo '{"action":"deny","reason":"did not see the rewrite"}'`),
224+ }}
225+ r, _ := runner(cfg)
226+ call := &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}}
227+ d := r.PreToolCall(context.Background(), Input{Tool: call})
228+ if d.Denied {
229+ t.Fatalf("denied: %s", d.Reason)
230+ }
231+ if got := d.Input.(map[string]any)["command"]; got != "ls -la" {
232+ t.Errorf("Input = %v, want the replacement", d.Input)
233+ }
234+ if got := call.Input.(map[string]any)["command"]; got != "ls" {
235+ t.Errorf("caller's input changed to %v", got)
236+ }
237+}
238+
239+// onError fail on a tool hook denies the call.
240+func TestFailingToolHookWithFailDenies(t *testing.T) {
241+ h := hook(`exit 3`)
242+ h.OnError = config.HookOnErrorFail
243+ r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}})
244+ d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}})
245+ if !d.Denied || !strings.Contains(d.Reason, "hook failed") {
246+ t.Errorf("decision = %+v, want denied because the hook failed", d)
247+ }
248+}
249+
250+func TestPostToolCallReturnsContextAndSeesTheOutput(t *testing.T) {
251+ cfg := config.HooksConfig{PostToolCall: []config.Hook{
252+ hook(`grep -q '"output":"3 files"' && echo '{"context":"note: the output was read"}'`),
253+ hook(`exit 1`), // a failure after the fact is reported, never fatal
254+ }}
255+ cfg.PostToolCall[1].OnError = config.HookOnErrorFail
256+ r, notes := runner(cfg)
257+ added := r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}, Output: "3 files"})
258+ if len(added) != 1 || added[0] != "note: the output was read" {
259+ t.Errorf("context = %v", added)
260+ }
261+ if len(*notes) != 1 {
262+ t.Errorf("notes = %v, want the failure reported once", *notes)
263+ }
264+}
265+
266+func TestPostGenerationObservesTheAnswer(t *testing.T) {
267+ out := filepath.Join(t.TempDir(), "log")
268+ r, notes := runner(config.HooksConfig{PostGeneration: []config.Hook{hook(`cat >> "` + out + `"`)}})
269+ r.PostGeneration(context.Background(), Input{Prompt: "q", Answer: "a"})
270+ data, _ := os.ReadFile(out)
271+ if !strings.Contains(string(data), `"event":"postGeneration"`) || !strings.Contains(string(data), `"answer":"a"`) {
272+ t.Errorf("the hook read %s", data)
273+ }
274+ if len(*notes) > 0 {
275+ t.Errorf("notes = %v", *notes)
276+ }
277+}
278+
279+// A nil runner (no hooks configured anywhere) is safe to call.
280+func TestNilRunnerIsSafe(t *testing.T) {
281+ var r *Runner
282+ if r.Enabled() {
283+ t.Error("nil runner enabled")
284+ }
285+ if d := r.PreGeneration(context.Background(), Input{Prompt: "p"}); d.Prompt != "p" || d.Stopped {
286+ t.Errorf("PreGeneration = %+v", d)
287+ }
288+ if d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: 1}}); d.Denied || d.Input != 1 {
289+ t.Errorf("PreToolCall = %+v", d)
290+ }
291+ if r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}}) != nil {
292+ t.Error("PostToolCall returned context")
293+ }
294+ r.PostGeneration(context.Background(), Input{})
295+}
modified internal/tui/sink.go +8 -0
@@ -20,6 +20,7 @@ type sink struct{ r *runner }
2020 var (
2121 _ ui.Sink = (*sink)(nil)
2222 _ ui.Speaker = (*sink)(nil)
23+ _ ui.Noticer = (*sink)(nil)
2324 )
2425
2526 // Speaking switches the header and the answers' label to the agent now
@@ -91,6 +92,13 @@ func (k *sink) ToolEnd(id string, res ui.ToolResult) {
9192 k.r.wake()
9293 }
9394
95+// Notice shows a remark about the turn (a hook that failed, say) as a dim
96+// line of the transcript: printed on stdout, it would tear the screen.
97+func (k *sink) Notice(text string) {
98+ k.r.t.Notice(text)
99+ k.r.wake()
100+}
101+
94102 // WorkDir is where mm was started: bash runs there and relative paths
95103 // resolve against it, as in the REPL.
96104 func (k *sink) WorkDir() string { return k.r.opts.Cwd }
@@ -20,6 +20,7 @@ type sink struct{ r *runner }
20 var (20 var (
21 _ ui.Sink = (*sink)(nil)21 _ ui.Sink = (*sink)(nil)
22 _ ui.Speaker = (*sink)(nil)22 _ ui.Speaker = (*sink)(nil)
23+ _ ui.Noticer = (*sink)(nil)
23 )24 )
24 25
25 // Speaking switches the header and the answers' label to the agent now26 // Speaking switches the header and the answers' label to the agent now
@@ -91,6 +92,13 @@ func (k *sink) ToolEnd(id string, res ui.ToolResult) {
91 k.r.wake()92 k.r.wake()
92 }93 }
93 94
95+// Notice shows a remark about the turn (a hook that failed, say) as a dim
96+// line of the transcript: printed on stdout, it would tear the screen.
97+func (k *sink) Notice(text string) {
98+ k.r.t.Notice(text)
99+ k.r.wake()
100+}
101+
94 // WorkDir is where mm was started: bash runs there and relative paths102 // WorkDir is where mm was started: bash runs there and relative paths
95 // resolve against it, as in the REPL.103 // resolve against it, as in the REPL.
96 func (k *sink) WorkDir() string { return k.r.opts.Cwd }104 func (k *sink) WorkDir() string { return k.r.opts.Cwd }
modified internal/ui/ui.go +21 -0
@@ -114,6 +114,27 @@ type Elicitor interface {
114114 Elicit(ctx context.Context, ev ToolEvent, f elicit.Form) elicit.Result
115115 }
116116
117+// Noticer is the optional side of a Sink that shows a one-line notice — a
118+// remark about the turn that is neither the answer nor a tool call, such as
119+// a hook that failed. The window has one (a dim transcript line); without
120+// it a notice goes to Out.
121+type Noticer interface {
122+ Notice(text string)
123+}
124+
125+// Notice shows a one-line remark about the turn where the front end shows
126+// such things: the active sink's Noticer, or a line on Out (stdout in the
127+// terminal, stderr — the editor's logs — over ACP).
128+//
129+// ui.Notice("🪝 preGeneration hook `node guard.js`: exit status 1")
130+func Notice(text string) {
131+ if n, ok := ActiveSink().(Noticer); ok {
132+ n.Notice(text)
133+ return
134+ }
135+ fmt.Fprintln(Out, text)
136+}
137+
117138 // Speaker is the optional side of a Sink that shows WHICH agent is answering:
118139 // between Speaking and Spoken, what streams through the sink (text, tool
119140 // calls) comes from another agent — the one the ask_agent tool asked — and
@@ -114,6 +114,27 @@ type Elicitor interface {
114 Elicit(ctx context.Context, ev ToolEvent, f elicit.Form) elicit.Result114 Elicit(ctx context.Context, ev ToolEvent, f elicit.Form) elicit.Result
115 }115 }
116 116
117+// Noticer is the optional side of a Sink that shows a one-line notice — a
118+// remark about the turn that is neither the answer nor a tool call, such as
119+// a hook that failed. The window has one (a dim transcript line); without
120+// it a notice goes to Out.
121+type Noticer interface {
122+ Notice(text string)
123+}
124+
125+// Notice shows a one-line remark about the turn where the front end shows
126+// such things: the active sink's Noticer, or a line on Out (stdout in the
127+// terminal, stderr — the editor's logs — over ACP).
128+//
129+// ui.Notice("🪝 preGeneration hook `node guard.js`: exit status 1")
130+func Notice(text string) {
131+ if n, ok := ActiveSink().(Noticer); ok {
132+ n.Notice(text)
133+ return
134+ }
135+ fmt.Fprintln(Out, text)
136+}
137+
117 // Speaker is the optional side of a Sink that shows WHICH agent is answering:138 // Speaker is the optional side of a Sink that shows WHICH agent is answering:
118 // between Speaking and Spoken, what streams through the sink (text, tool139 // between Speaking and Spoken, what streams through the sink (text, tool
119 // calls) comes from another agent — the one the ask_agent tool asked — and140 // calls) comes from another agent — the one the ask_agent tool asked — and
modified internal/ui/ui_test.go +32 -0
@@ -53,3 +53,35 @@ func TestNextCallIDNeverRepeats(t *testing.T) {
5353 t.Fatalf("unexpected shape: %q", a)
5454 }
5555 }
56+
57+// noticingSink is a front end that shows notices itself (the window).
58+type noticingSink struct {
59+ stubSink
60+ seen []string
61+}
62+
63+func (s *noticingSink) Notice(text string) { s.seen = append(s.seen, text) }
64+
65+// A notice goes to the sink that can show it, else to Out — never both.
66+func TestNoticeGoesToTheSinkOrToOut(t *testing.T) {
67+ var out strings.Builder
68+ old := Out
69+ Out = &out
70+ defer func() { Out = old }()
71+
72+ SetActive(nil)
73+ Notice("terminal line")
74+ SetActive(stubSink{}) // a sink without Notice: ACP
75+ Notice("editor log line")
76+ window := &noticingSink{}
77+ SetActive(window)
78+ Notice("window line")
79+ SetActive(nil)
80+
81+ if got := out.String(); got != "terminal line\neditor log line\n" {
82+ t.Errorf("Out = %q", got)
83+ }
84+ if len(window.seen) != 1 || window.seen[0] != "window line" {
85+ t.Errorf("window saw %v", window.seen)
86+ }
87+}
@@ -53,3 +53,35 @@ func TestNextCallIDNeverRepeats(t *testing.T) {
53 t.Fatalf("unexpected shape: %q", a)53 t.Fatalf("unexpected shape: %q", a)
54 }54 }
55 }55 }
56+
57+// noticingSink is a front end that shows notices itself (the window).
58+type noticingSink struct {
59+ stubSink
60+ seen []string
61+}
62+
63+func (s *noticingSink) Notice(text string) { s.seen = append(s.seen, text) }
64+
65+// A notice goes to the sink that can show it, else to Out — never both.
66+func TestNoticeGoesToTheSinkOrToOut(t *testing.T) {
67+ var out strings.Builder
68+ old := Out
69+ Out = &out
70+ defer func() { Out = old }()
71+
72+ SetActive(nil)
73+ Notice("terminal line")
74+ SetActive(stubSink{}) // a sink without Notice: ACP
75+ Notice("editor log line")
76+ window := &noticingSink{}
77+ SetActive(window)
78+ Notice("window line")
79+ SetActive(nil)
80+
81+ if got := out.String(); got != "terminal line\neditor log line\n" {
82+ t.Errorf("Out = %q", got)
83+ }
84+ if len(window.seen) != 1 || window.seen[0] != "window line" {
85+ t.Errorf("window saw %v", window.seen)
86+ }
87+}
modified main.go +11 -2
@@ -592,8 +592,17 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
592592 // The version leads the line: it is the first thing anyone is asked for
593593 // when something behaves oddly, and the banner is the one output every
594594 // front end shows — the window keeps it as the conversation's first line.
595- say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s",
596- dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), off)
595+ say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s%s",
596+ dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), hooksColumn(), off)
597+}
598+
599+// hooksColumn says in the banner which hooks run, per event: a command that
600+// can rewrite prompts or deny tool calls must not act unseen.
601+func hooksColumn() string {
602+ if summary := config.Cfg.Hooks.Summary(); summary != "" {
603+ return " | hooks: " + summary
604+ }
605+ return ""
597606 }
598607
599608 // yoloColumn says in the banner whether the tools run unasked — the one
@@ -592,8 +592,17 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
592 // The version leads the line: it is the first thing anyone is asked for592 // The version leads the line: it is the first thing anyone is asked for
593 // when something behaves oddly, and the banner is the one output every593 // when something behaves oddly, and the banner is the one output every
594 // front end shows — the window keeps it as the conversation's first line.594 // front end shows — the window keeps it as the conversation's first line.
595- say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s",595+ say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s%s",
596- dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), off)596+ dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), hooksColumn(), off)
597+}
598+
599+// hooksColumn says in the banner which hooks run, per event: a command that
600+// can rewrite prompts or deny tool calls must not act unseen.
601+func hooksColumn() string {
602+ if summary := config.Cfg.Hooks.Summary(); summary != "" {
603+ return " | hooks: " + summary
604+ }
605+ return ""
597 }606 }
598 607
599 // yoloColumn says in the banner whether the tools run unasked — the one608 // yoloColumn says in the banner whether the tools run unasked — the one