bots-garden/mini-mepublic Fork
⑂ 8654e3f
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

Merge feature/talk-to-another-mm into main

rickub committed 2026-10-02T19:59:49Z Browse files
8654e3f parents: 36b4793 45e0704
added .tickets/issues/0066-talk-to-another-mm.yaml +391 -0
new file mode 100644
@@ -0,0 +1,391 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 66
3+title: talk to another mm
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-02T18:48:36.275Z
9+updatedAt: 2026-10-02T20:30:00.000Z
10+labels:
11+ - kind::feature
12+related:
13+ - 57
14+ - 59
15+body: |
16+ Goal: from the current session, send a prompt to ANOTHER mm (another config:
17+ other model, provider, system prompt, tools, skills) and bring its answer
18+ back into the current conversation, so the current model can use it.
19+
20+ /ask bob <prompt>
21+ /ask riker <prompt>
22+
23+ Config (user's proposal):
24+
25+ mm-configs:
26+ bob: path/to/agent.bob.yaml
27+ riker: path/to/agent.riker.yaml
28+
29+ Feasibility: yes. Checked 2026-10-02 (no code written).
30+
31+ Recommended transport: ACP over stdio, mm talking to a child `mm -acp`.
32+ - The child is started with os.Executable() (same mm build, not whatever is
33+ on PATH) and `-config <path>`, cwd = the current working directory.
34+ - The parent is an ACP CLIENT: github.com/coder/acp-go-sdk (already a
35+ dependency, v0.13.5) has NewClientSideConnection + the Client interface.
36+ - mm's ACP server only ever calls two client methods (grep internal/acp):
37+ SessionUpdate (message/thought/tool-call/usage chunks) and
38+ RequestPermission. The rest of the Client interface (fs, terminal) can be
39+ stubs returning "not supported", and the parent advertises no fs/terminal
40+ capability.
41+ - Flow: initialize -> session/new {cwd} -> session/prompt {text} -> collect
42+ agent_message_chunk text until the prompt response (stopReason) ->
43+ insert the answer in the current history.
44+ - Proof it works as a protocol: tests/tool-order/session.py and the sidekick
45+ web client already drive `mm -acp` from outside.
46+
47+ Why ACP rather than the alternatives:
48+ - `mm -p` subprocess (one-shot): simpler, but stdout mixes the answer with
49+ thoughts, tool echoes, ANSI and the final command summary (fragile
50+ parsing); line mode never asks permission, so bob would run bash/write
51+ with NO gate; no cancel except kill; no usage; no follow-up turns.
52+ - In-process second Engine: no process, but config.Cfg is a global read
53+ everywhere (engine, tools, compact, store...). Two configs in one process
54+ means de-globalising the config first: a large refactor for this feature.
55+ The subprocess also isolates bob's tools, MCP servers and skills.
56+ - ACP gives, for free: structured events (tool calls can be shown as
57+ "bob: bash ls"), permission requests that can be FORWARDED to the user
58+ through the current front end's existing sink.Allow (line mode: no gate,
59+ window: inline prompt, editor: the editor's dialog), session/cancel for
60+ /abort and Ctrl+C, usage, and a session id -> multi-turn with bob
61+ (keep the child alive, reuse the session: "/ask bob and what about X?").
62+
63+ Proposed behaviour (to confirm):
64+ - One child per name, started lazily on the first /ask, kept alive for the
65+ parent session (start-up cost), killed on /quit and exit; a fresh ACP
66+ session (session/new) per /ask if 3 is confirmed.
67+ - The /ask exchange enters the CURRENT history as one user message, e.g.
68+ [bob (ollama/qwen3.5:2b) was asked: <prompt>]
69+ <bob's answer>
70+ so the current model sees it on the next question and the session file
71+ persists and replays it on /resume. Must keep the history valid for
72+ the next model call (user/model alternation: merge into the next user
73+ message, or a user+model pair "[noted]" — to decide in the code).
74+ - Bob's answer is shown while it streams, prefixed/styled as bob's
75+ (decided: displayed, kept for the next question).
76+ - Bob's thoughts: not shown (or dim), never inserted in the history.
77+ - Long answers: capped (maxOutput-like) before insertion — the current
78+ model's context pays for them.
79+
80+ Risks / things to decide:
81+ - Ticket 57: a permission forwarded to the user and answered slowly will
82+ trip the CHILD's watchdog exactly like today in an editor.
83+ - Ticket 59 (yolo): which permission policy for the child — forward to the
84+ user (default), or auto per config entry?
85+ - Two agents on the same working tree: the parent waits while bob works
86+ (/ask is blocking), so no concurrent edits; but bob may change files the
87+ parent model "remembers" — say so in the inserted message?
88+ - Recursion: /ask is a USER command, the model cannot call it, so no
89+ automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth
90+ (env var MM_ASK_DEPTH) and refuse cycles.
91+ - Paths in mm-configs: relative to the PARENT config file's directory (the
92+ ACP mode already resolves skillsDir that way; a path relative to the cwd
93+ breaks when the config is elsewhere). The child's own relative paths
94+ (skillsDir) resolve against ITS config dir in -acp mode.
95+ - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+);
96+ hence keep the child alive.
97+ - Child stderr: capture it and show it only on failure (start error,
98+ unknown config, provider down -> one line: "bob: nothing answers at …").
99+
100+ Demo ready (2026-10-02): demo-llmman/agent.llmman.yaml declares
101+ mmConfigs:
102+ jan: agent.jan-nano.yaml # hf.co/Menlo/Jan-nano-gguf:Q4_K_M
103+ lucy: agent.lucy.yaml # hf.co/Menlo/Lucy-gguf:Q4_K_M
104+ (the key is ignored until implemented: config loading is not strict). Both
105+ configs answered a bash-tool prompt live through llmman.
106+
107+ Decisions (user, 2026-10-02):
108+ 1. Key in camelCase: `mmConfigs:` (name -> path of a config file).
109+ 2. Bob's answer is DISPLAYED, and stays in the current history for the
110+ NEXT question; the current model does not answer right away.
111+ 3. Two commands:
112+ - /ask <name> <prompt>: bob receives the typed prompt only;
113+ - /ask-with-context <name> <prompt>: bob also receives the context of
114+ the current conversation.
115+ Interpretation to confirm: every /ask starts a FRESH bob session (no
116+ memory between two /ask); continuity comes from /ask-with-context.
117+ 4. Both commands work in the three front ends (Terminal, Window, Editor).
118+ 5. Context: only with /ask-with-context (see 3).
119+
120+ Editor visibility (checked): yes. The ACP facade already sends
121+ `available_commands_update` built from command.For(Editor)
122+ (internal/acp/acp.go availableCommands), and skills are already advertised
123+ with `input.unstructured.hint`. Today the built-in commands are sent
124+ WITHOUT an input hint: /ask and /ask-with-context need one, e.g.
125+ "bob|riker <prompt>", built from mmConfigs. The editor completes the
126+ command name, not the argument; alternative to discuss: one command per
127+ agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents.
128+ The CHILD's own available_commands_update is ignored by the parent.
129+
130+ Decided too (user, 2026-10-02):
131+ a. Context for /ask-with-context = a text transcript of the current
132+ history: user questions and assistant answers, each tool call as one
133+ line (name + short args), no tool outputs, no reasoning, the compaction
134+ summary if any; capped (chars), newest turns kept first. Sent as:
135+ [Conversation so far, from <parent agent name>]
136+ ...
137+ [Question]
138+ <prompt>
139+ b. Every /ask (and /ask-with-context) starts a FRESH bob session
140+ (session/new); no memory on bob's side between two calls.
141+ c. Editors: ONE COMMAND PER AGENT, generated from mmConfigs —
142+ /ask-bob, /ask-with-context-bob, /ask-riker, … each with an input hint
143+ "<prompt>". Consequences:
144+ - agent names become command names: validate them in config
145+ (lowercase letters, digits, '-'; error otherwise);
146+ - a skill or built-in with the same name: the existing guard in
147+ availableCommands (commandNamed) keeps the command and drops the skill,
148+ with the banner warning — extend it to the generated names;
149+ - terminal and window (confirmed by the user, 2026-10-02): accept the
150+ per-agent forms there too (same table, no drift), and keep
151+ `/ask <name> <prompt>` as the generic form; the window's "/" picker
152+ lists the per-agent commands.
153+
154+ Proposed behaviour (to confirm):
155+ - One child per name, started lazily on the first /ask, kept alive for the
156+ parent session (start-up cost), killed on /quit and exit; a fresh ACP
157+ session (session/new) per /ask if 3 is confirmed.
158+ - The /ask exchange enters the CURRENT history as one user message, e.g.
159+ [bob (ollama/qwen3.5:2b) was asked: <prompt>]
160+ <bob's answer>
161+ so the current model sees it on the next question and the session file
162+ persists and replays it on /resume. Must keep the history valid for
163+ the next model call (user/model alternation: merge into the next user
164+ message, or a user+model pair "[noted]" — to decide in the code).
165+ - Bob's answer is shown while it streams, prefixed/styled as bob's
166+ (decided: displayed, kept for the next question).
167+ - Bob's thoughts: not shown (or dim), never inserted in the history.
168+ - Long answers: capped (maxOutput-like) before insertion — the current
169+ model's context pays for them.
170+
171+ Risks / things to decide:
172+ - Ticket 57: a permission forwarded to the user and answered slowly will
173+ trip the CHILD's watchdog exactly like today in an editor.
174+ - Ticket 59 (yolo): which permission policy for the child — forward to the
175+ user (default), or auto per config entry?
176+ - Two agents on the same working tree: the parent waits while bob works
177+ (/ask is blocking), so no concurrent edits; but bob may change files the
178+ parent model "remembers" — say so in the inserted message?
179+ - Recursion: /ask is a USER command, the model cannot call it, so no
180+ automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth
181+ (env var MM_ASK_DEPTH) and refuse cycles.
182+ - Paths in mm-configs: relative to the PARENT config file's directory (the
183+ ACP mode already resolves skillsDir that way; a path relative to the cwd
184+ breaks when the config is elsewhere). The child's own relative paths
185+ (skillsDir) resolve against ITS config dir in -acp mode.
186+ - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+);
187+ hence keep the child alive.
188+ - Child stderr: capture it and show it only on failure (start error,
189+ unknown config, provider down -> one line: "bob: nothing answers at …").
190+
191+ Decisions (user, 2026-10-02):
192+ 1. Key in camelCase: `mmConfigs:` (name -> path of a config file).
193+ 2. Bob's answer is DISPLAYED, and stays in the current history for the
194+ NEXT question; the current model does not answer right away.
195+ 3. Two commands:
196+ - /ask <name> <prompt>: bob receives the typed prompt only;
197+ - /ask-with-context <name> <prompt>: bob also receives the context of
198+ the current conversation.
199+ Interpretation to confirm: every /ask starts a FRESH bob session (no
200+ memory between two /ask); continuity comes from /ask-with-context.
201+ 4. Both commands work in the three front ends (Terminal, Window, Editor).
202+ 5. Context: only with /ask-with-context (see 3).
203+
204+ Editor visibility (checked): yes. The ACP facade already sends
205+ `available_commands_update` built from command.For(Editor)
206+ (internal/acp/acp.go availableCommands), and skills are already advertised
207+ with `input.unstructured.hint`. Today the built-in commands are sent
208+ WITHOUT an input hint: /ask and /ask-with-context need one, e.g.
209+ "bob|riker <prompt>", built from mmConfigs. The editor completes the
210+ command name, not the argument; alternative to discuss: one command per
211+ agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents.
212+ The CHILD's own available_commands_update is ignored by the parent.
213+
214+ Still open:
215+ a. What "context" is for /ask-with-context. Proposal: a text transcript of
216+ the current history — user questions and assistant answers, each tool
217+ call as one line (name + short args), no tool outputs, no reasoning,
218+ the compaction summary if any — capped (chars) to protect bob's
219+ window, newest turns kept first. Sent as the first part of the prompt:
220+ [Conversation so far, from <parent agent name>]
221+ ...
222+ [Question]
223+ <prompt>
224+ b. Fresh bob session per /ask (interpretation of 3) — confirm.
225+ c. Editor: one /ask with a hint, or one command per agent?
226+tasks:
227+ - id: 1
228+ title: "Answer the open questions (key name, immediate answer, multi-turn, fronts, context passed)"
229+ state: closed
230+ priority: none
231+ author:
232+ name: k33g
233+ email: ph.charriere@gmail.com
234+ createdAt: 2026-10-02T19:30:00.000Z
235+ updatedAt: 2026-10-02T20:00:00.000Z
236+ - id: 2
237+ title: "Confirm the remaining points a/b/c (context format, fresh session per /ask, editor hint vs one command per agent)"
238+ state: closed
239+ priority: none
240+ author:
241+ name: k33g
242+ email: ph.charriere@gmail.com
243+ createdAt: 2026-10-02T19:30:00.000Z
244+ updatedAt: 2026-10-02T20:00:00.000Z
245+ - id: 3
246+ title: "config: mmConfigs map (validation: name = [a-z0-9-]+ since it becomes a command name, file exists; paths relative to the config file dir)"
247+ state: closed
248+ priority: none
249+ author:
250+ name: k33g
251+ email: ph.charriere@gmail.com
252+ createdAt: 2026-10-02T19:30:00.000Z
253+ updatedAt: 2026-10-02T20:00:00.000Z
254+ - id: 4
255+ title: "internal/askagent (name TBD): ACP client over a child `mm -acp -config <path>` (os.Executable), lazy start, one child per name, shutdown"
256+ state: closed
257+ priority: none
258+ author:
259+ name: k33g
260+ email: ph.charriere@gmail.com
261+ createdAt: 2026-10-02T19:30:00.000Z
262+ updatedAt: 2026-10-02T20:00:00.000Z
263+ - id: 5
264+ title: "Client side: SessionUpdate -> stream text/tool-call lines to the current sink; RequestPermission -> current sink.Allow; fs/terminal stubs"
265+ state: closed
266+ priority: none
267+ author:
268+ name: k33g
269+ email: ph.charriere@gmail.com
270+ createdAt: 2026-10-02T19:30:00.000Z
271+ updatedAt: 2026-10-02T20:00:00.000Z
272+ - id: 6
273+ title: "command table: /ask <name> <prompt>, /ask-with-context <name> <prompt>, and per-agent /ask-<name>, /ask-with-context-<name> generated from mmConfigs (all fronts); ACP advertises the per-agent ones with hint <prompt>; window picker; name clash guard"
274+ state: closed
275+ priority: none
276+ author:
277+ name: k33g
278+ email: ph.charriere@gmail.com
279+ createdAt: 2026-10-02T19:30:00.000Z
280+ updatedAt: 2026-10-02T20:00:00.000Z
281+ - id: 7
282+ title: "Context builder for /ask-with-context: transcript of the current history (text + one line per tool call), capped"
283+ state: closed
284+ priority: none
285+ author:
286+ name: k33g
287+ email: ph.charriere@gmail.com
288+ createdAt: 2026-10-02T19:30:00.000Z
289+ updatedAt: 2026-10-02T20:00:00.000Z
290+ - id: 8
291+ title: "Insert the exchange in the current history (format, cap, valid alternation), persisted by the store and replayed on /resume"
292+ state: closed
293+ priority: none
294+ author:
295+ name: k33g
296+ email: ph.charriere@gmail.com
297+ createdAt: 2026-10-02T19:30:00.000Z
298+ updatedAt: 2026-10-02T20:00:00.000Z
299+ - id: 9
300+ title: "/abort, Ctrl+C and editor cancel -> session/cancel on the child; child crash -> one-line error, history untouched"
301+ state: closed
302+ priority: none
303+ author:
304+ name: k33g
305+ email: ph.charriere@gmail.com
306+ createdAt: 2026-10-02T19:30:00.000Z
307+ updatedAt: 2026-10-02T20:00:00.000Z
308+ - id: 10
309+ title: "Tests: fake child agent (acp-go-sdk agent side) for the client; end-to-end with two real configs (demo-ollama + demo-llmman)"
310+ state: closed
311+ priority: none
312+ author:
313+ name: k33g
314+ email: ph.charriere@gmail.com
315+ createdAt: 2026-10-02T19:30:00.000Z
316+ updatedAt: 2026-10-02T20:00:00.000Z
317+ - id: 11
318+ title: "Docs EN+FR: how-to ask another agent, reference (config key, commands), explanation (why ACP, why a subprocess)"
319+ state: closed
320+ priority: none
321+ author:
322+ name: k33g
323+ email: ph.charriere@gmail.com
324+ createdAt: 2026-10-02T19:30:00.000Z
325+ updatedAt: 2026-10-02T20:00:00.000Z
326+ - id: 12
327+ title: "Quality gate (qlty) where available — not run: qlty absent from this sandbox"
328+ state: open
329+ priority: none
330+ author:
331+ name: k33g
332+ email: ph.charriere@gmail.com
333+ createdAt: 2026-10-02T20:30:00.000Z
334+ updatedAt: 2026-10-02T20:30:00.000Z
335+ - id: 13
336+ title: "Live check of the window mode (/ask-jan in mm -tui) — unit-tested only"
337+ state: open
338+ priority: none
339+ author:
340+ name: k33g
341+ email: ph.charriere@gmail.com
342+ createdAt: 2026-10-02T20:30:00.000Z
343+ updatedAt: 2026-10-02T20:30:00.000Z
344+ - id: 14
345+ title: "Review, commit, merge"
346+ state: open
347+ priority: none
348+ author:
349+ name: k33g
350+ email: ph.charriere@gmail.com
351+ createdAt: 2026-10-02T20:30:00.000Z
352+ updatedAt: 2026-10-02T20:30:00.000Z
353+comments:
354+ - id: 1
355+ author:
356+ name: claude
357+ email: noreply@anthropic.com
358+ createdAt: 2026-10-02T20:30:00.000Z
359+ body: |
360+ **Implemented** on branch `feature/talk-to-another-mm` (uncommitted, 2026-10-02).
361+
362+ - config: `mmConfigs` (internal/config/agents.go) — names [a-z0-9-], not
363+ starting with `with-context`; relative paths resolved against the config
364+ file's dir at Load; missing file = start-up warning. `AGENT_SESSIONS=off`
365+ env override (applyEnv).
366+ - command: /ask family generated by SetAgents (internal/command/ask.go);
367+ `Hint` field; ParseAsk returns (req, isAsk, err).
368+ - internal/ask (new): Transcript/Prompt (context.go), Exchange as a
369+ user+model pair + Describe (exchange.go), ChildEnv/Depth/CanAsk
370+ (env.go, MaxDepth 2), ACP client (client.go), Pool with one lazy child
371+ per agent + fresh session per ask (pool.go), Pool.Run + Runner interface
372+ + Label + Missing (run.go).
373+ - Front ends: agent/ask.go (Ctrl+C, /abort, /quit), tui/ask.go (job
374+ "asking <agent>"), acp/ask.go (per-agent commands with `<prompt>` hint);
375+ main.go wiring; store.FailAsk.
376+ - Traps found: the SDK dispatches the child's requests concurrently with
377+ its notifications — a permission request can overtake its tool_call
378+ (client shows the call first, once); the SDK's default slog would print
379+ "connection closed" on the user's terminal (silenced); the parent's
380+ AGENT_* env would override the child's config (stripped).
381+ - Tests: config, command, ask (fake ACP agent = the test binary re-run,
382+ no network), agent, tui, acp. `go test ./...` green 3x; ask tests 15x.
383+ `-race` not available (no C compiler in the sandbox).
384+ - Live (host llmman): line mode `/ask-jan` → answer kept, the current
385+ model quoted it next turn; ACP `/ask-with-context-lucy` → per-agent
386+ commands announced, Lucy knew the name given earlier, her bash
387+ permission went to the client titled "lucy · bash: …"; no child
388+ session files, no leftover processes.
389+ - Docs EN+FR: how-to/ask-another-agent.md, explanation/asking-another-agent.md,
390+ reference (slash-commands, configuration, cli), architecture table,
391+ READMEs, docs/diagrams/packages.drawio (internal/ask + edges).
new file mode 100644
@@ -0,0 +1,391 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 66
3+title: talk to another mm
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-02T18:48:36.275Z
9+updatedAt: 2026-10-02T20:30:00.000Z
10+labels:
11+ - kind::feature
12+related:
13+ - 57
14+ - 59
15+body: |
16+ Goal: from the current session, send a prompt to ANOTHER mm (another config:
17+ other model, provider, system prompt, tools, skills) and bring its answer
18+ back into the current conversation, so the current model can use it.
19+
20+ /ask bob <prompt>
21+ /ask riker <prompt>
22+
23+ Config (user's proposal):
24+
25+ mm-configs:
26+ bob: path/to/agent.bob.yaml
27+ riker: path/to/agent.riker.yaml
28+
29+ Feasibility: yes. Checked 2026-10-02 (no code written).
30+
31+ Recommended transport: ACP over stdio, mm talking to a child `mm -acp`.
32+ - The child is started with os.Executable() (same mm build, not whatever is
33+ on PATH) and `-config <path>`, cwd = the current working directory.
34+ - The parent is an ACP CLIENT: github.com/coder/acp-go-sdk (already a
35+ dependency, v0.13.5) has NewClientSideConnection + the Client interface.
36+ - mm's ACP server only ever calls two client methods (grep internal/acp):
37+ SessionUpdate (message/thought/tool-call/usage chunks) and
38+ RequestPermission. The rest of the Client interface (fs, terminal) can be
39+ stubs returning "not supported", and the parent advertises no fs/terminal
40+ capability.
41+ - Flow: initialize -> session/new {cwd} -> session/prompt {text} -> collect
42+ agent_message_chunk text until the prompt response (stopReason) ->
43+ insert the answer in the current history.
44+ - Proof it works as a protocol: tests/tool-order/session.py and the sidekick
45+ web client already drive `mm -acp` from outside.
46+
47+ Why ACP rather than the alternatives:
48+ - `mm -p` subprocess (one-shot): simpler, but stdout mixes the answer with
49+ thoughts, tool echoes, ANSI and the final command summary (fragile
50+ parsing); line mode never asks permission, so bob would run bash/write
51+ with NO gate; no cancel except kill; no usage; no follow-up turns.
52+ - In-process second Engine: no process, but config.Cfg is a global read
53+ everywhere (engine, tools, compact, store...). Two configs in one process
54+ means de-globalising the config first: a large refactor for this feature.
55+ The subprocess also isolates bob's tools, MCP servers and skills.
56+ - ACP gives, for free: structured events (tool calls can be shown as
57+ "bob: bash ls"), permission requests that can be FORWARDED to the user
58+ through the current front end's existing sink.Allow (line mode: no gate,
59+ window: inline prompt, editor: the editor's dialog), session/cancel for
60+ /abort and Ctrl+C, usage, and a session id -> multi-turn with bob
61+ (keep the child alive, reuse the session: "/ask bob and what about X?").
62+
63+ Proposed behaviour (to confirm):
64+ - One child per name, started lazily on the first /ask, kept alive for the
65+ parent session (start-up cost), killed on /quit and exit; a fresh ACP
66+ session (session/new) per /ask if 3 is confirmed.
67+ - The /ask exchange enters the CURRENT history as one user message, e.g.
68+ [bob (ollama/qwen3.5:2b) was asked: <prompt>]
69+ <bob's answer>
70+ so the current model sees it on the next question and the session file
71+ persists and replays it on /resume. Must keep the history valid for
72+ the next model call (user/model alternation: merge into the next user
73+ message, or a user+model pair "[noted]" — to decide in the code).
74+ - Bob's answer is shown while it streams, prefixed/styled as bob's
75+ (decided: displayed, kept for the next question).
76+ - Bob's thoughts: not shown (or dim), never inserted in the history.
77+ - Long answers: capped (maxOutput-like) before insertion — the current
78+ model's context pays for them.
79+
80+ Risks / things to decide:
81+ - Ticket 57: a permission forwarded to the user and answered slowly will
82+ trip the CHILD's watchdog exactly like today in an editor.
83+ - Ticket 59 (yolo): which permission policy for the child — forward to the
84+ user (default), or auto per config entry?
85+ - Two agents on the same working tree: the parent waits while bob works
86+ (/ask is blocking), so no concurrent edits; but bob may change files the
87+ parent model "remembers" — say so in the inserted message?
88+ - Recursion: /ask is a USER command, the model cannot call it, so no
89+ automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth
90+ (env var MM_ASK_DEPTH) and refuse cycles.
91+ - Paths in mm-configs: relative to the PARENT config file's directory (the
92+ ACP mode already resolves skillsDir that way; a path relative to the cwd
93+ breaks when the config is elsewhere). The child's own relative paths
94+ (skillsDir) resolve against ITS config dir in -acp mode.
95+ - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+);
96+ hence keep the child alive.
97+ - Child stderr: capture it and show it only on failure (start error,
98+ unknown config, provider down -> one line: "bob: nothing answers at …").
99+
100+ Demo ready (2026-10-02): demo-llmman/agent.llmman.yaml declares
101+ mmConfigs:
102+ jan: agent.jan-nano.yaml # hf.co/Menlo/Jan-nano-gguf:Q4_K_M
103+ lucy: agent.lucy.yaml # hf.co/Menlo/Lucy-gguf:Q4_K_M
104+ (the key is ignored until implemented: config loading is not strict). Both
105+ configs answered a bash-tool prompt live through llmman.
106+
107+ Decisions (user, 2026-10-02):
108+ 1. Key in camelCase: `mmConfigs:` (name -> path of a config file).
109+ 2. Bob's answer is DISPLAYED, and stays in the current history for the
110+ NEXT question; the current model does not answer right away.
111+ 3. Two commands:
112+ - /ask <name> <prompt>: bob receives the typed prompt only;
113+ - /ask-with-context <name> <prompt>: bob also receives the context of
114+ the current conversation.
115+ Interpretation to confirm: every /ask starts a FRESH bob session (no
116+ memory between two /ask); continuity comes from /ask-with-context.
117+ 4. Both commands work in the three front ends (Terminal, Window, Editor).
118+ 5. Context: only with /ask-with-context (see 3).
119+
120+ Editor visibility (checked): yes. The ACP facade already sends
121+ `available_commands_update` built from command.For(Editor)
122+ (internal/acp/acp.go availableCommands), and skills are already advertised
123+ with `input.unstructured.hint`. Today the built-in commands are sent
124+ WITHOUT an input hint: /ask and /ask-with-context need one, e.g.
125+ "bob|riker <prompt>", built from mmConfigs. The editor completes the
126+ command name, not the argument; alternative to discuss: one command per
127+ agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents.
128+ The CHILD's own available_commands_update is ignored by the parent.
129+
130+ Decided too (user, 2026-10-02):
131+ a. Context for /ask-with-context = a text transcript of the current
132+ history: user questions and assistant answers, each tool call as one
133+ line (name + short args), no tool outputs, no reasoning, the compaction
134+ summary if any; capped (chars), newest turns kept first. Sent as:
135+ [Conversation so far, from <parent agent name>]
136+ ...
137+ [Question]
138+ <prompt>
139+ b. Every /ask (and /ask-with-context) starts a FRESH bob session
140+ (session/new); no memory on bob's side between two calls.
141+ c. Editors: ONE COMMAND PER AGENT, generated from mmConfigs —
142+ /ask-bob, /ask-with-context-bob, /ask-riker, … each with an input hint
143+ "<prompt>". Consequences:
144+ - agent names become command names: validate them in config
145+ (lowercase letters, digits, '-'; error otherwise);
146+ - a skill or built-in with the same name: the existing guard in
147+ availableCommands (commandNamed) keeps the command and drops the skill,
148+ with the banner warning — extend it to the generated names;
149+ - terminal and window (confirmed by the user, 2026-10-02): accept the
150+ per-agent forms there too (same table, no drift), and keep
151+ `/ask <name> <prompt>` as the generic form; the window's "/" picker
152+ lists the per-agent commands.
153+
154+ Proposed behaviour (to confirm):
155+ - One child per name, started lazily on the first /ask, kept alive for the
156+ parent session (start-up cost), killed on /quit and exit; a fresh ACP
157+ session (session/new) per /ask if 3 is confirmed.
158+ - The /ask exchange enters the CURRENT history as one user message, e.g.
159+ [bob (ollama/qwen3.5:2b) was asked: <prompt>]
160+ <bob's answer>
161+ so the current model sees it on the next question and the session file
162+ persists and replays it on /resume. Must keep the history valid for
163+ the next model call (user/model alternation: merge into the next user
164+ message, or a user+model pair "[noted]" — to decide in the code).
165+ - Bob's answer is shown while it streams, prefixed/styled as bob's
166+ (decided: displayed, kept for the next question).
167+ - Bob's thoughts: not shown (or dim), never inserted in the history.
168+ - Long answers: capped (maxOutput-like) before insertion — the current
169+ model's context pays for them.
170+
171+ Risks / things to decide:
172+ - Ticket 57: a permission forwarded to the user and answered slowly will
173+ trip the CHILD's watchdog exactly like today in an editor.
174+ - Ticket 59 (yolo): which permission policy for the child — forward to the
175+ user (default), or auto per config entry?
176+ - Two agents on the same working tree: the parent waits while bob works
177+ (/ask is blocking), so no concurrent edits; but bob may change files the
178+ parent model "remembers" — say so in the inserted message?
179+ - Recursion: /ask is a USER command, the model cannot call it, so no
180+ automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth
181+ (env var MM_ASK_DEPTH) and refuse cycles.
182+ - Paths in mm-configs: relative to the PARENT config file's directory (the
183+ ACP mode already resolves skillsDir that way; a path relative to the cwd
184+ breaks when the config is elsewhere). The child's own relative paths
185+ (skillsDir) resolve against ITS config dir in -acp mode.
186+ - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+);
187+ hence keep the child alive.
188+ - Child stderr: capture it and show it only on failure (start error,
189+ unknown config, provider down -> one line: "bob: nothing answers at …").
190+
191+ Decisions (user, 2026-10-02):
192+ 1. Key in camelCase: `mmConfigs:` (name -> path of a config file).
193+ 2. Bob's answer is DISPLAYED, and stays in the current history for the
194+ NEXT question; the current model does not answer right away.
195+ 3. Two commands:
196+ - /ask <name> <prompt>: bob receives the typed prompt only;
197+ - /ask-with-context <name> <prompt>: bob also receives the context of
198+ the current conversation.
199+ Interpretation to confirm: every /ask starts a FRESH bob session (no
200+ memory between two /ask); continuity comes from /ask-with-context.
201+ 4. Both commands work in the three front ends (Terminal, Window, Editor).
202+ 5. Context: only with /ask-with-context (see 3).
203+
204+ Editor visibility (checked): yes. The ACP facade already sends
205+ `available_commands_update` built from command.For(Editor)
206+ (internal/acp/acp.go availableCommands), and skills are already advertised
207+ with `input.unstructured.hint`. Today the built-in commands are sent
208+ WITHOUT an input hint: /ask and /ask-with-context need one, e.g.
209+ "bob|riker <prompt>", built from mmConfigs. The editor completes the
210+ command name, not the argument; alternative to discuss: one command per
211+ agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents.
212+ The CHILD's own available_commands_update is ignored by the parent.
213+
214+ Still open:
215+ a. What "context" is for /ask-with-context. Proposal: a text transcript of
216+ the current history — user questions and assistant answers, each tool
217+ call as one line (name + short args), no tool outputs, no reasoning,
218+ the compaction summary if any — capped (chars) to protect bob's
219+ window, newest turns kept first. Sent as the first part of the prompt:
220+ [Conversation so far, from <parent agent name>]
221+ ...
222+ [Question]
223+ <prompt>
224+ b. Fresh bob session per /ask (interpretation of 3) — confirm.
225+ c. Editor: one /ask with a hint, or one command per agent?
226+tasks:
227+ - id: 1
228+ title: "Answer the open questions (key name, immediate answer, multi-turn, fronts, context passed)"
229+ state: closed
230+ priority: none
231+ author:
232+ name: k33g
233+ email: ph.charriere@gmail.com
234+ createdAt: 2026-10-02T19:30:00.000Z
235+ updatedAt: 2026-10-02T20:00:00.000Z
236+ - id: 2
237+ title: "Confirm the remaining points a/b/c (context format, fresh session per /ask, editor hint vs one command per agent)"
238+ state: closed
239+ priority: none
240+ author:
241+ name: k33g
242+ email: ph.charriere@gmail.com
243+ createdAt: 2026-10-02T19:30:00.000Z
244+ updatedAt: 2026-10-02T20:00:00.000Z
245+ - id: 3
246+ title: "config: mmConfigs map (validation: name = [a-z0-9-]+ since it becomes a command name, file exists; paths relative to the config file dir)"
247+ state: closed
248+ priority: none
249+ author:
250+ name: k33g
251+ email: ph.charriere@gmail.com
252+ createdAt: 2026-10-02T19:30:00.000Z
253+ updatedAt: 2026-10-02T20:00:00.000Z
254+ - id: 4
255+ title: "internal/askagent (name TBD): ACP client over a child `mm -acp -config <path>` (os.Executable), lazy start, one child per name, shutdown"
256+ state: closed
257+ priority: none
258+ author:
259+ name: k33g
260+ email: ph.charriere@gmail.com
261+ createdAt: 2026-10-02T19:30:00.000Z
262+ updatedAt: 2026-10-02T20:00:00.000Z
263+ - id: 5
264+ title: "Client side: SessionUpdate -> stream text/tool-call lines to the current sink; RequestPermission -> current sink.Allow; fs/terminal stubs"
265+ state: closed
266+ priority: none
267+ author:
268+ name: k33g
269+ email: ph.charriere@gmail.com
270+ createdAt: 2026-10-02T19:30:00.000Z
271+ updatedAt: 2026-10-02T20:00:00.000Z
272+ - id: 6
273+ title: "command table: /ask <name> <prompt>, /ask-with-context <name> <prompt>, and per-agent /ask-<name>, /ask-with-context-<name> generated from mmConfigs (all fronts); ACP advertises the per-agent ones with hint <prompt>; window picker; name clash guard"
274+ state: closed
275+ priority: none
276+ author:
277+ name: k33g
278+ email: ph.charriere@gmail.com
279+ createdAt: 2026-10-02T19:30:00.000Z
280+ updatedAt: 2026-10-02T20:00:00.000Z
281+ - id: 7
282+ title: "Context builder for /ask-with-context: transcript of the current history (text + one line per tool call), capped"
283+ state: closed
284+ priority: none
285+ author:
286+ name: k33g
287+ email: ph.charriere@gmail.com
288+ createdAt: 2026-10-02T19:30:00.000Z
289+ updatedAt: 2026-10-02T20:00:00.000Z
290+ - id: 8
291+ title: "Insert the exchange in the current history (format, cap, valid alternation), persisted by the store and replayed on /resume"
292+ state: closed
293+ priority: none
294+ author:
295+ name: k33g
296+ email: ph.charriere@gmail.com
297+ createdAt: 2026-10-02T19:30:00.000Z
298+ updatedAt: 2026-10-02T20:00:00.000Z
299+ - id: 9
300+ title: "/abort, Ctrl+C and editor cancel -> session/cancel on the child; child crash -> one-line error, history untouched"
301+ state: closed
302+ priority: none
303+ author:
304+ name: k33g
305+ email: ph.charriere@gmail.com
306+ createdAt: 2026-10-02T19:30:00.000Z
307+ updatedAt: 2026-10-02T20:00:00.000Z
308+ - id: 10
309+ title: "Tests: fake child agent (acp-go-sdk agent side) for the client; end-to-end with two real configs (demo-ollama + demo-llmman)"
310+ state: closed
311+ priority: none
312+ author:
313+ name: k33g
314+ email: ph.charriere@gmail.com
315+ createdAt: 2026-10-02T19:30:00.000Z
316+ updatedAt: 2026-10-02T20:00:00.000Z
317+ - id: 11
318+ title: "Docs EN+FR: how-to ask another agent, reference (config key, commands), explanation (why ACP, why a subprocess)"
319+ state: closed
320+ priority: none
321+ author:
322+ name: k33g
323+ email: ph.charriere@gmail.com
324+ createdAt: 2026-10-02T19:30:00.000Z
325+ updatedAt: 2026-10-02T20:00:00.000Z
326+ - id: 12
327+ title: "Quality gate (qlty) where available — not run: qlty absent from this sandbox"
328+ state: open
329+ priority: none
330+ author:
331+ name: k33g
332+ email: ph.charriere@gmail.com
333+ createdAt: 2026-10-02T20:30:00.000Z
334+ updatedAt: 2026-10-02T20:30:00.000Z
335+ - id: 13
336+ title: "Live check of the window mode (/ask-jan in mm -tui) — unit-tested only"
337+ state: open
338+ priority: none
339+ author:
340+ name: k33g
341+ email: ph.charriere@gmail.com
342+ createdAt: 2026-10-02T20:30:00.000Z
343+ updatedAt: 2026-10-02T20:30:00.000Z
344+ - id: 14
345+ title: "Review, commit, merge"
346+ state: open
347+ priority: none
348+ author:
349+ name: k33g
350+ email: ph.charriere@gmail.com
351+ createdAt: 2026-10-02T20:30:00.000Z
352+ updatedAt: 2026-10-02T20:30:00.000Z
353+comments:
354+ - id: 1
355+ author:
356+ name: claude
357+ email: noreply@anthropic.com
358+ createdAt: 2026-10-02T20:30:00.000Z
359+ body: |
360+ **Implemented** on branch `feature/talk-to-another-mm` (uncommitted, 2026-10-02).
361+
362+ - config: `mmConfigs` (internal/config/agents.go) — names [a-z0-9-], not
363+ starting with `with-context`; relative paths resolved against the config
364+ file's dir at Load; missing file = start-up warning. `AGENT_SESSIONS=off`
365+ env override (applyEnv).
366+ - command: /ask family generated by SetAgents (internal/command/ask.go);
367+ `Hint` field; ParseAsk returns (req, isAsk, err).
368+ - internal/ask (new): Transcript/Prompt (context.go), Exchange as a
369+ user+model pair + Describe (exchange.go), ChildEnv/Depth/CanAsk
370+ (env.go, MaxDepth 2), ACP client (client.go), Pool with one lazy child
371+ per agent + fresh session per ask (pool.go), Pool.Run + Runner interface
372+ + Label + Missing (run.go).
373+ - Front ends: agent/ask.go (Ctrl+C, /abort, /quit), tui/ask.go (job
374+ "asking <agent>"), acp/ask.go (per-agent commands with `<prompt>` hint);
375+ main.go wiring; store.FailAsk.
376+ - Traps found: the SDK dispatches the child's requests concurrently with
377+ its notifications — a permission request can overtake its tool_call
378+ (client shows the call first, once); the SDK's default slog would print
379+ "connection closed" on the user's terminal (silenced); the parent's
380+ AGENT_* env would override the child's config (stripped).
381+ - Tests: config, command, ask (fake ACP agent = the test binary re-run,
382+ no network), agent, tui, acp. `go test ./...` green 3x; ask tests 15x.
383+ `-race` not available (no C compiler in the sandbox).
384+ - Live (host llmman): line mode `/ask-jan` → answer kept, the current
385+ model quoted it next turn; ACP `/ask-with-context-lucy` → per-agent
386+ commands announced, Lucy knew the name given earlier, her bash
387+ permission went to the client titled "lucy · bash: …"; no child
388+ session files, no leftover processes.
389+ - Docs EN+FR: how-to/ask-another-agent.md, explanation/asking-another-agent.md,
390+ reference (slash-commands, configuration, cli), architecture table,
391+ READMEs, docs/diagrams/packages.drawio (internal/ask + edges).
added demo-llmman/agent.jan-nano.yaml +199 -0
new file mode 100644
@@ -0,0 +1,199 @@
1+# Agent settings — Jan: mini-me on hf.co/Menlo/Jan-nano-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman)
2+# through its OpenAI-compatible endpoint (/v1/chat/completions).
3+#
4+# llmman pull hf.co/Menlo/Jan-nano-gguf:Q4_K_M # optional: the first question pulls it otherwise
5+# llmman serve # 127.0.0.1:17434
6+#
7+# llmman loads the model on the first request (in its own llama-server) and
8+# unloads it after five idle minutes.
9+provider: llmman
10+
11+# The model id exactly as `llmman list` / GET /v1/models spells it.
12+model: hf.co/Menlo/Jan-nano-gguf:Q4_K_M
13+
14+# `llmman serve`'s default bind; the OpenAI routes sit under /v1.
15+baseUrl: http://127.0.0.1:17434/v1
16+
17+# From a Docker sandbox, the daemon runs on the host.
18+fallback: http://host.docker.internal:17434/v1
19+
20+# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it
21+# listens beyond loopback); unset otherwise. The key never goes in this file.
22+# apiKeyEnv: LLMMAN_API_KEY
23+
24+# The `bash` tool.
25+# `false` removes it: the model can still read and edit files (editTools) and load skills,
26+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
27+bashTool: true
28+
29+# The built-in file tools: read_file, write_file, edit_file.
30+editTools: true
31+
32+# The built-in plan tool for managing multi-step plans.
33+# Set to false to disable plan creation and tracking via the `plan` tool.
34+planTool: true
35+
36+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
37+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
38+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
39+# lsp:
40+# servers:
41+# - name: gopls
42+# command: gopls
43+# extensions: [".go"]
44+# - name: typescript
45+# command: typescript-language-server
46+# args: ["--stdio"]
47+# extensions: [".ts", ".tsx", ".js", ".jsx"]
48+# - name: python
49+# command: pyright-langserver
50+# args: ["--stdio"]
51+# extensions: [".py"]
52+# - name: rust
53+# command: rust-analyzer
54+# extensions: [".rs"]
55+
56+# How many tokens the server actually serves.
57+# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is
58+# learned before the second question. Set it here if you want it from the start
59+# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable.
60+contextWindow: 0
61+
62+# Max number of characters a tool returns to the model.
63+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
64+maxOutput: 16000
65+
66+# Max number of model <-> tools round trips for a single question.
67+# Raise it for tasks that need long chains of commands.
68+maxTurns: 40
69+
70+# Where the `read_skill` tool looks for its markdown procedures.
71+# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/.
72+skillsDir: ../skills
73+
74+# Persistent sessions, per project.
75+# When enabled, every conversation is saved as one JSON file under `dir`.
76+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
77+sessions:
78+ enabled: true
79+ dir: .mm/sessions
80+
81+# How many lines of a command's output are echoed to the terminal.
82+# 0 disables it.
83+previewLines: 20
84+
85+displayCommands: true
86+# Show what a thinking model reasons about before it answers (dim italic in the
87+# terminal, its own style in the window, a thought chunk in an editor).
88+# The reasoning is always READ — it is what keeps the watchdog alive while the
89+# model thinks — this only says whether to show it.
90+showThinking: true
91+
92+
93+# The system prompt: what the agent is, and what it is allowed to do.
94+# This is the knob to play with — it is the shortest path to changing behaviour.
95+system: |
96+ Your name is Jan.
97+ You are a coding agent working in a terminal.
98+ You have a "bash" tool to run shell commands.
99+ Use it to explore files, run tests, inspect the repository, etc.
100+ Chain several commands if needed, then answer clearly in English.
101+
102+ A request often mixes things you answer from yourself ("say hello") with
103+ things only a command can answer ("list the files"). Handle every part, in
104+ the order asked, and run a command for each part that needs one.
105+ Never state the contents of a file, the output of a command, or the state of
106+ the repository unless a command in THIS answer returned it. What you did not
107+ read, you do not know: run the command instead of recalling it.
108+
109+ SKILLS
110+ You have a second tool, `read_skill`. Its description lists the procedures
111+ available for this project — one per kind of task.
112+
113+ Any request to DO something to a Go project is a skill, not a shell command
114+ you invent. Match the request against that list, call `read_skill` FIRST,
115+ before any bash command, and then follow what it says step by step.
116+
117+ FILE EDITING
118+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
119+ They are how a file gets read and changed here: each change is exact,
120+ checked before it is written, and comes back as a diff with line numbers.
121+ bash is for running things — building, testing, listing, searching.
122+
123+ - Read before you write: call `read_file` on the file (numbered=true when
124+ you need line numbers). You cannot target text you have not seen; never
125+ rely on what you think you remember about a file.
126+ - To change an existing file, call `edit_file` with one or more {old, new}
127+ pairs. `old` is copied from the file character for character — same
128+ spaces, same indentation, same line breaks — and appears exactly once:
129+ add the surrounding lines until it is unique. Several pairs are applied
130+ together, against the original file. An empty `new` deletes the text.
131+ - Call `write_file` only to create a file, or to rewrite one entirely and
132+ on purpose. On an existing file it replaces everything, including what
133+ you did not intend to touch.
134+ - Read the diff the tool returns: it says exactly what changed and on which
135+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
136+ edits — read the file again and fix `old`. Do not fall back to
137+ `write_file` to force the change through.
138+ - After editing code, run the narrowest check with bash: the formatter, the
139+ compiler, or the test covering that file.
140+
141+ RULES
142+ - Keep everything the file already does, unless the user asked to remove it.
143+ - Touch only the files the request is about. Do not add tests, files or
144+ features that were not asked for.
145+ - Never run a git command unless the user says git, commit or push.
146+ - Never move, rename or delete a file unless the user asked for it.
147+ - Then answer in English, in a few lines.
148+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
149+ to understand the options, then run the command.
150+
151+ BACKGROUND JOBS
152+ Never let a command block the answer. Anything that serves, watches or runs
153+ long goes to the background, with BOTH streams redirected and its pid kept:
154+
155+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
156+
157+ Redirecting only stdout still blocks until the process exits. Read the
158+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
159+ rule you cannot guess. Stop every job you started before you finish, and say
160+ which ones you left running.
161+
162+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
163+# we want precise and reproducible answers, not creativity.
164+sampling:
165+ temperature: 0.0
166+ parallel_tool_calls: false
167+ top_p: 0.9
168+ max_tokens: 4096
169+
170+# Silence allowed between two tokens once the answer has started.
171+watchdogTimeout: 30s
172+
173+# Silence allowed while the model reads the prompt, before the first token:
174+# on a laptop a 25k-token prompt can take minutes. llama-server reports its
175+# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report
176+# restarts this clock, so it only has to cover one batch of the prompt.
177+firstTokenTimeout: 3m
178+
179+# Context compression.
180+# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to.
181+context:
182+ enabled: true
183+
184+ # Compress when the history reaches this share of the window.
185+ threshold: 75
186+
187+ # Fallback on the message count, for when the window is unknown.
188+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
189+ maxMessages: 80
190+
191+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
192+ # The model works on the last few turns, so summarising them would make it redo what it just did.
193+ keepLastTurns: 3
194+
195+ # max_tokens of the summary request. Reasoning is turned off for it on this provider
196+ # (a thinking model would otherwise spend the budget thinking and write nothing).
197+ summaryMaxTokens: 1200
198+
199+ showStats: true
new file mode 100644
@@ -0,0 +1,199 @@
1+# Agent settings — Jan: mini-me on hf.co/Menlo/Jan-nano-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman)
2+# through its OpenAI-compatible endpoint (/v1/chat/completions).
3+#
4+# llmman pull hf.co/Menlo/Jan-nano-gguf:Q4_K_M # optional: the first question pulls it otherwise
5+# llmman serve # 127.0.0.1:17434
6+#
7+# llmman loads the model on the first request (in its own llama-server) and
8+# unloads it after five idle minutes.
9+provider: llmman
10+
11+# The model id exactly as `llmman list` / GET /v1/models spells it.
12+model: hf.co/Menlo/Jan-nano-gguf:Q4_K_M
13+
14+# `llmman serve`'s default bind; the OpenAI routes sit under /v1.
15+baseUrl: http://127.0.0.1:17434/v1
16+
17+# From a Docker sandbox, the daemon runs on the host.
18+fallback: http://host.docker.internal:17434/v1
19+
20+# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it
21+# listens beyond loopback); unset otherwise. The key never goes in this file.
22+# apiKeyEnv: LLMMAN_API_KEY
23+
24+# The `bash` tool.
25+# `false` removes it: the model can still read and edit files (editTools) and load skills,
26+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
27+bashTool: true
28+
29+# The built-in file tools: read_file, write_file, edit_file.
30+editTools: true
31+
32+# The built-in plan tool for managing multi-step plans.
33+# Set to false to disable plan creation and tracking via the `plan` tool.
34+planTool: true
35+
36+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
37+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
38+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
39+# lsp:
40+# servers:
41+# - name: gopls
42+# command: gopls
43+# extensions: [".go"]
44+# - name: typescript
45+# command: typescript-language-server
46+# args: ["--stdio"]
47+# extensions: [".ts", ".tsx", ".js", ".jsx"]
48+# - name: python
49+# command: pyright-langserver
50+# args: ["--stdio"]
51+# extensions: [".py"]
52+# - name: rust
53+# command: rust-analyzer
54+# extensions: [".rs"]
55+
56+# How many tokens the server actually serves.
57+# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is
58+# learned before the second question. Set it here if you want it from the start
59+# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable.
60+contextWindow: 0
61+
62+# Max number of characters a tool returns to the model.
63+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
64+maxOutput: 16000
65+
66+# Max number of model <-> tools round trips for a single question.
67+# Raise it for tasks that need long chains of commands.
68+maxTurns: 40
69+
70+# Where the `read_skill` tool looks for its markdown procedures.
71+# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/.
72+skillsDir: ../skills
73+
74+# Persistent sessions, per project.
75+# When enabled, every conversation is saved as one JSON file under `dir`.
76+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
77+sessions:
78+ enabled: true
79+ dir: .mm/sessions
80+
81+# How many lines of a command's output are echoed to the terminal.
82+# 0 disables it.
83+previewLines: 20
84+
85+displayCommands: true
86+# Show what a thinking model reasons about before it answers (dim italic in the
87+# terminal, its own style in the window, a thought chunk in an editor).
88+# The reasoning is always READ — it is what keeps the watchdog alive while the
89+# model thinks — this only says whether to show it.
90+showThinking: true
91+
92+
93+# The system prompt: what the agent is, and what it is allowed to do.
94+# This is the knob to play with — it is the shortest path to changing behaviour.
95+system: |
96+ Your name is Jan.
97+ You are a coding agent working in a terminal.
98+ You have a "bash" tool to run shell commands.
99+ Use it to explore files, run tests, inspect the repository, etc.
100+ Chain several commands if needed, then answer clearly in English.
101+
102+ A request often mixes things you answer from yourself ("say hello") with
103+ things only a command can answer ("list the files"). Handle every part, in
104+ the order asked, and run a command for each part that needs one.
105+ Never state the contents of a file, the output of a command, or the state of
106+ the repository unless a command in THIS answer returned it. What you did not
107+ read, you do not know: run the command instead of recalling it.
108+
109+ SKILLS
110+ You have a second tool, `read_skill`. Its description lists the procedures
111+ available for this project — one per kind of task.
112+
113+ Any request to DO something to a Go project is a skill, not a shell command
114+ you invent. Match the request against that list, call `read_skill` FIRST,
115+ before any bash command, and then follow what it says step by step.
116+
117+ FILE EDITING
118+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
119+ They are how a file gets read and changed here: each change is exact,
120+ checked before it is written, and comes back as a diff with line numbers.
121+ bash is for running things — building, testing, listing, searching.
122+
123+ - Read before you write: call `read_file` on the file (numbered=true when
124+ you need line numbers). You cannot target text you have not seen; never
125+ rely on what you think you remember about a file.
126+ - To change an existing file, call `edit_file` with one or more {old, new}
127+ pairs. `old` is copied from the file character for character — same
128+ spaces, same indentation, same line breaks — and appears exactly once:
129+ add the surrounding lines until it is unique. Several pairs are applied
130+ together, against the original file. An empty `new` deletes the text.
131+ - Call `write_file` only to create a file, or to rewrite one entirely and
132+ on purpose. On an existing file it replaces everything, including what
133+ you did not intend to touch.
134+ - Read the diff the tool returns: it says exactly what changed and on which
135+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
136+ edits — read the file again and fix `old`. Do not fall back to
137+ `write_file` to force the change through.
138+ - After editing code, run the narrowest check with bash: the formatter, the
139+ compiler, or the test covering that file.
140+
141+ RULES
142+ - Keep everything the file already does, unless the user asked to remove it.
143+ - Touch only the files the request is about. Do not add tests, files or
144+ features that were not asked for.
145+ - Never run a git command unless the user says git, commit or push.
146+ - Never move, rename or delete a file unless the user asked for it.
147+ - Then answer in English, in a few lines.
148+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
149+ to understand the options, then run the command.
150+
151+ BACKGROUND JOBS
152+ Never let a command block the answer. Anything that serves, watches or runs
153+ long goes to the background, with BOTH streams redirected and its pid kept:
154+
155+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
156+
157+ Redirecting only stdout still blocks until the process exits. Read the
158+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
159+ rule you cannot guess. Stop every job you started before you finish, and say
160+ which ones you left running.
161+
162+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
163+# we want precise and reproducible answers, not creativity.
164+sampling:
165+ temperature: 0.0
166+ parallel_tool_calls: false
167+ top_p: 0.9
168+ max_tokens: 4096
169+
170+# Silence allowed between two tokens once the answer has started.
171+watchdogTimeout: 30s
172+
173+# Silence allowed while the model reads the prompt, before the first token:
174+# on a laptop a 25k-token prompt can take minutes. llama-server reports its
175+# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report
176+# restarts this clock, so it only has to cover one batch of the prompt.
177+firstTokenTimeout: 3m
178+
179+# Context compression.
180+# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to.
181+context:
182+ enabled: true
183+
184+ # Compress when the history reaches this share of the window.
185+ threshold: 75
186+
187+ # Fallback on the message count, for when the window is unknown.
188+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
189+ maxMessages: 80
190+
191+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
192+ # The model works on the last few turns, so summarising them would make it redo what it just did.
193+ keepLastTurns: 3
194+
195+ # max_tokens of the summary request. Reasoning is turned off for it on this provider
196+ # (a thinking model would otherwise spend the budget thinking and write nothing).
197+ summaryMaxTokens: 1200
198+
199+ showStats: true
modified demo-llmman/agent.llmman.yaml +9 -0
@@ -17,6 +17,15 @@ baseUrl: http://127.0.0.1:17434/v1
1717 # From a Docker sandbox, the daemon runs on the host.
1818 fallback: http://host.docker.internal:17434/v1
1919
20+# Other mm agents this one can ask. Each name becomes /ask-<name> and
21+# /ask-with-context-<name> (also `/ask <name> <prompt>` in the terminal and the
22+# window); the answer is shown and kept in the history for the next question.
23+# Each agent runs as a child `mm -acp` started on its first /ask. Paths are
24+# relative to THIS file's directory. Names: lowercase letters, digits and '-'.
25+mmConfigs:
26+ jan: agent.jan-nano.yaml
27+ lucy: agent.lucy.yaml
28+
2029 # Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it
2130 # listens beyond loopback); unset otherwise. The key never goes in this file.
2231 # apiKeyEnv: LLMMAN_API_KEY
@@ -17,6 +17,15 @@ baseUrl: http://127.0.0.1:17434/v1
17 # From a Docker sandbox, the daemon runs on the host.17 # From a Docker sandbox, the daemon runs on the host.
18 fallback: http://host.docker.internal:17434/v118 fallback: http://host.docker.internal:17434/v1
19 19
20+# Other mm agents this one can ask. Each name becomes /ask-<name> and
21+# /ask-with-context-<name> (also `/ask <name> <prompt>` in the terminal and the
22+# window); the answer is shown and kept in the history for the next question.
23+# Each agent runs as a child `mm -acp` started on its first /ask. Paths are
24+# relative to THIS file's directory. Names: lowercase letters, digits and '-'.
25+mmConfigs:
26+ jan: agent.jan-nano.yaml
27+ lucy: agent.lucy.yaml
28+
20 # Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it29 # Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it
21 # listens beyond loopback); unset otherwise. The key never goes in this file.30 # listens beyond loopback); unset otherwise. The key never goes in this file.
22 # apiKeyEnv: LLMMAN_API_KEY31 # apiKeyEnv: LLMMAN_API_KEY
added demo-llmman/agent.lucy.yaml +199 -0
new file mode 100644
@@ -0,0 +1,199 @@
1+# Agent settings — Lucy: mini-me on hf.co/Menlo/Lucy-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman)
2+# through its OpenAI-compatible endpoint (/v1/chat/completions).
3+#
4+# llmman pull hf.co/Menlo/Lucy-gguf:Q4_K_M # optional: the first question pulls it otherwise
5+# llmman serve # 127.0.0.1:17434
6+#
7+# llmman loads the model on the first request (in its own llama-server) and
8+# unloads it after five idle minutes.
9+provider: llmman
10+
11+# The model id exactly as `llmman list` / GET /v1/models spells it.
12+model: hf.co/Menlo/Lucy-gguf:Q4_K_M
13+
14+# `llmman serve`'s default bind; the OpenAI routes sit under /v1.
15+baseUrl: http://127.0.0.1:17434/v1
16+
17+# From a Docker sandbox, the daemon runs on the host.
18+fallback: http://host.docker.internal:17434/v1
19+
20+# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it
21+# listens beyond loopback); unset otherwise. The key never goes in this file.
22+# apiKeyEnv: LLMMAN_API_KEY
23+
24+# The `bash` tool.
25+# `false` removes it: the model can still read and edit files (editTools) and load skills,
26+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
27+bashTool: true
28+
29+# The built-in file tools: read_file, write_file, edit_file.
30+editTools: true
31+
32+# The built-in plan tool for managing multi-step plans.
33+# Set to false to disable plan creation and tracking via the `plan` tool.
34+planTool: true
35+
36+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
37+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
38+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
39+# lsp:
40+# servers:
41+# - name: gopls
42+# command: gopls
43+# extensions: [".go"]
44+# - name: typescript
45+# command: typescript-language-server
46+# args: ["--stdio"]
47+# extensions: [".ts", ".tsx", ".js", ".jsx"]
48+# - name: python
49+# command: pyright-langserver
50+# args: ["--stdio"]
51+# extensions: [".py"]
52+# - name: rust
53+# command: rust-analyzer
54+# extensions: [".rs"]
55+
56+# How many tokens the server actually serves.
57+# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is
58+# learned before the second question. Set it here if you want it from the start
59+# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable.
60+contextWindow: 0
61+
62+# Max number of characters a tool returns to the model.
63+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
64+maxOutput: 16000
65+
66+# Max number of model <-> tools round trips for a single question.
67+# Raise it for tasks that need long chains of commands.
68+maxTurns: 40
69+
70+# Where the `read_skill` tool looks for its markdown procedures.
71+# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/.
72+skillsDir: ../skills
73+
74+# Persistent sessions, per project.
75+# When enabled, every conversation is saved as one JSON file under `dir`.
76+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
77+sessions:
78+ enabled: true
79+ dir: .mm/sessions
80+
81+# How many lines of a command's output are echoed to the terminal.
82+# 0 disables it.
83+previewLines: 20
84+
85+displayCommands: true
86+# Show what a thinking model reasons about before it answers (dim italic in the
87+# terminal, its own style in the window, a thought chunk in an editor).
88+# The reasoning is always READ — it is what keeps the watchdog alive while the
89+# model thinks — this only says whether to show it.
90+showThinking: true
91+
92+
93+# The system prompt: what the agent is, and what it is allowed to do.
94+# This is the knob to play with — it is the shortest path to changing behaviour.
95+system: |
96+ Your name is Lucy.
97+ You are a coding agent working in a terminal.
98+ You have a "bash" tool to run shell commands.
99+ Use it to explore files, run tests, inspect the repository, etc.
100+ Chain several commands if needed, then answer clearly in English.
101+
102+ A request often mixes things you answer from yourself ("say hello") with
103+ things only a command can answer ("list the files"). Handle every part, in
104+ the order asked, and run a command for each part that needs one.
105+ Never state the contents of a file, the output of a command, or the state of
106+ the repository unless a command in THIS answer returned it. What you did not
107+ read, you do not know: run the command instead of recalling it.
108+
109+ SKILLS
110+ You have a second tool, `read_skill`. Its description lists the procedures
111+ available for this project — one per kind of task.
112+
113+ Any request to DO something to a Go project is a skill, not a shell command
114+ you invent. Match the request against that list, call `read_skill` FIRST,
115+ before any bash command, and then follow what it says step by step.
116+
117+ FILE EDITING
118+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
119+ They are how a file gets read and changed here: each change is exact,
120+ checked before it is written, and comes back as a diff with line numbers.
121+ bash is for running things — building, testing, listing, searching.
122+
123+ - Read before you write: call `read_file` on the file (numbered=true when
124+ you need line numbers). You cannot target text you have not seen; never
125+ rely on what you think you remember about a file.
126+ - To change an existing file, call `edit_file` with one or more {old, new}
127+ pairs. `old` is copied from the file character for character — same
128+ spaces, same indentation, same line breaks — and appears exactly once:
129+ add the surrounding lines until it is unique. Several pairs are applied
130+ together, against the original file. An empty `new` deletes the text.
131+ - Call `write_file` only to create a file, or to rewrite one entirely and
132+ on purpose. On an existing file it replaces everything, including what
133+ you did not intend to touch.
134+ - Read the diff the tool returns: it says exactly what changed and on which
135+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
136+ edits — read the file again and fix `old`. Do not fall back to
137+ `write_file` to force the change through.
138+ - After editing code, run the narrowest check with bash: the formatter, the
139+ compiler, or the test covering that file.
140+
141+ RULES
142+ - Keep everything the file already does, unless the user asked to remove it.
143+ - Touch only the files the request is about. Do not add tests, files or
144+ features that were not asked for.
145+ - Never run a git command unless the user says git, commit or push.
146+ - Never move, rename or delete a file unless the user asked for it.
147+ - Then answer in English, in a few lines.
148+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
149+ to understand the options, then run the command.
150+
151+ BACKGROUND JOBS
152+ Never let a command block the answer. Anything that serves, watches or runs
153+ long goes to the background, with BOTH streams redirected and its pid kept:
154+
155+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
156+
157+ Redirecting only stdout still blocks until the process exits. Read the
158+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
159+ rule you cannot guess. Stop every job you started before you finish, and say
160+ which ones you left running.
161+
162+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
163+# we want precise and reproducible answers, not creativity.
164+sampling:
165+ temperature: 0.0
166+ parallel_tool_calls: false
167+ top_p: 0.9
168+ max_tokens: 4096
169+
170+# Silence allowed between two tokens once the answer has started.
171+watchdogTimeout: 30s
172+
173+# Silence allowed while the model reads the prompt, before the first token:
174+# on a laptop a 25k-token prompt can take minutes. llama-server reports its
175+# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report
176+# restarts this clock, so it only has to cover one batch of the prompt.
177+firstTokenTimeout: 3m
178+
179+# Context compression.
180+# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to.
181+context:
182+ enabled: true
183+
184+ # Compress when the history reaches this share of the window.
185+ threshold: 75
186+
187+ # Fallback on the message count, for when the window is unknown.
188+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
189+ maxMessages: 80
190+
191+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
192+ # The model works on the last few turns, so summarising them would make it redo what it just did.
193+ keepLastTurns: 3
194+
195+ # max_tokens of the summary request. Reasoning is turned off for it on this provider
196+ # (a thinking model would otherwise spend the budget thinking and write nothing).
197+ summaryMaxTokens: 1200
198+
199+ showStats: true
new file mode 100644
@@ -0,0 +1,199 @@
1+# Agent settings — Lucy: mini-me on hf.co/Menlo/Lucy-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman)
2+# through its OpenAI-compatible endpoint (/v1/chat/completions).
3+#
4+# llmman pull hf.co/Menlo/Lucy-gguf:Q4_K_M # optional: the first question pulls it otherwise
5+# llmman serve # 127.0.0.1:17434
6+#
7+# llmman loads the model on the first request (in its own llama-server) and
8+# unloads it after five idle minutes.
9+provider: llmman
10+
11+# The model id exactly as `llmman list` / GET /v1/models spells it.
12+model: hf.co/Menlo/Lucy-gguf:Q4_K_M
13+
14+# `llmman serve`'s default bind; the OpenAI routes sit under /v1.
15+baseUrl: http://127.0.0.1:17434/v1
16+
17+# From a Docker sandbox, the daemon runs on the host.
18+fallback: http://host.docker.internal:17434/v1
19+
20+# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it
21+# listens beyond loopback); unset otherwise. The key never goes in this file.
22+# apiKeyEnv: LLMMAN_API_KEY
23+
24+# The `bash` tool.
25+# `false` removes it: the model can still read and edit files (editTools) and load skills,
26+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
27+bashTool: true
28+
29+# The built-in file tools: read_file, write_file, edit_file.
30+editTools: true
31+
32+# The built-in plan tool for managing multi-step plans.
33+# Set to false to disable plan creation and tracking via the `plan` tool.
34+planTool: true
35+
36+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
37+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
38+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
39+# lsp:
40+# servers:
41+# - name: gopls
42+# command: gopls
43+# extensions: [".go"]
44+# - name: typescript
45+# command: typescript-language-server
46+# args: ["--stdio"]
47+# extensions: [".ts", ".tsx", ".js", ".jsx"]
48+# - name: python
49+# command: pyright-langserver
50+# args: ["--stdio"]
51+# extensions: [".py"]
52+# - name: rust
53+# command: rust-analyzer
54+# extensions: [".rs"]
55+
56+# How many tokens the server actually serves.
57+# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is
58+# learned before the second question. Set it here if you want it from the start
59+# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable.
60+contextWindow: 0
61+
62+# Max number of characters a tool returns to the model.
63+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
64+maxOutput: 16000
65+
66+# Max number of model <-> tools round trips for a single question.
67+# Raise it for tasks that need long chains of commands.
68+maxTurns: 40
69+
70+# Where the `read_skill` tool looks for its markdown procedures.
71+# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/.
72+skillsDir: ../skills
73+
74+# Persistent sessions, per project.
75+# When enabled, every conversation is saved as one JSON file under `dir`.
76+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
77+sessions:
78+ enabled: true
79+ dir: .mm/sessions
80+
81+# How many lines of a command's output are echoed to the terminal.
82+# 0 disables it.
83+previewLines: 20
84+
85+displayCommands: true
86+# Show what a thinking model reasons about before it answers (dim italic in the
87+# terminal, its own style in the window, a thought chunk in an editor).
88+# The reasoning is always READ — it is what keeps the watchdog alive while the
89+# model thinks — this only says whether to show it.
90+showThinking: true
91+
92+
93+# The system prompt: what the agent is, and what it is allowed to do.
94+# This is the knob to play with — it is the shortest path to changing behaviour.
95+system: |
96+ Your name is Lucy.
97+ You are a coding agent working in a terminal.
98+ You have a "bash" tool to run shell commands.
99+ Use it to explore files, run tests, inspect the repository, etc.
100+ Chain several commands if needed, then answer clearly in English.
101+
102+ A request often mixes things you answer from yourself ("say hello") with
103+ things only a command can answer ("list the files"). Handle every part, in
104+ the order asked, and run a command for each part that needs one.
105+ Never state the contents of a file, the output of a command, or the state of
106+ the repository unless a command in THIS answer returned it. What you did not
107+ read, you do not know: run the command instead of recalling it.
108+
109+ SKILLS
110+ You have a second tool, `read_skill`. Its description lists the procedures
111+ available for this project — one per kind of task.
112+
113+ Any request to DO something to a Go project is a skill, not a shell command
114+ you invent. Match the request against that list, call `read_skill` FIRST,
115+ before any bash command, and then follow what it says step by step.
116+
117+ FILE EDITING
118+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
119+ They are how a file gets read and changed here: each change is exact,
120+ checked before it is written, and comes back as a diff with line numbers.
121+ bash is for running things — building, testing, listing, searching.
122+
123+ - Read before you write: call `read_file` on the file (numbered=true when
124+ you need line numbers). You cannot target text you have not seen; never
125+ rely on what you think you remember about a file.
126+ - To change an existing file, call `edit_file` with one or more {old, new}
127+ pairs. `old` is copied from the file character for character — same
128+ spaces, same indentation, same line breaks — and appears exactly once:
129+ add the surrounding lines until it is unique. Several pairs are applied
130+ together, against the original file. An empty `new` deletes the text.
131+ - Call `write_file` only to create a file, or to rewrite one entirely and
132+ on purpose. On an existing file it replaces everything, including what
133+ you did not intend to touch.
134+ - Read the diff the tool returns: it says exactly what changed and on which
135+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
136+ edits — read the file again and fix `old`. Do not fall back to
137+ `write_file` to force the change through.
138+ - After editing code, run the narrowest check with bash: the formatter, the
139+ compiler, or the test covering that file.
140+
141+ RULES
142+ - Keep everything the file already does, unless the user asked to remove it.
143+ - Touch only the files the request is about. Do not add tests, files or
144+ features that were not asked for.
145+ - Never run a git command unless the user says git, commit or push.
146+ - Never move, rename or delete a file unless the user asked for it.
147+ - Then answer in English, in a few lines.
148+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
149+ to understand the options, then run the command.
150+
151+ BACKGROUND JOBS
152+ Never let a command block the answer. Anything that serves, watches or runs
153+ long goes to the background, with BOTH streams redirected and its pid kept:
154+
155+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
156+
157+ Redirecting only stdout still blocks until the process exits. Read the
158+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
159+ rule you cannot guess. Stop every job you started before you finish, and say
160+ which ones you left running.
161+
162+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
163+# we want precise and reproducible answers, not creativity.
164+sampling:
165+ temperature: 0.0
166+ parallel_tool_calls: false
167+ top_p: 0.9
168+ max_tokens: 4096
169+
170+# Silence allowed between two tokens once the answer has started.
171+watchdogTimeout: 30s
172+
173+# Silence allowed while the model reads the prompt, before the first token:
174+# on a laptop a 25k-token prompt can take minutes. llama-server reports its
175+# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report
176+# restarts this clock, so it only has to cover one batch of the prompt.
177+firstTokenTimeout: 3m
178+
179+# Context compression.
180+# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to.
181+context:
182+ enabled: true
183+
184+ # Compress when the history reaches this share of the window.
185+ threshold: 75
186+
187+ # Fallback on the message count, for when the window is unknown.
188+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
189+ maxMessages: 80
190+
191+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
192+ # The model works on the last few turns, so summarising them would make it redo what it just did.
193+ keepLastTurns: 3
194+
195+ # max_tokens of the summary request. Reasoning is turned off for it on this provider
196+ # (a thinking model would otherwise spend the budget thinking and write nothing).
197+ summaryMaxTokens: 1200
198+
199+ showStats: true
modified docs/diagrams/packages.drawio +1 -1
@@ -1,3 +1,3 @@
11 <mxfile host="app.diagrams.net" modified="2026-09-15T00: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="900"><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></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></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1,3 +1,3 @@
1 <mxfile host="app.diagrams.net" modified="2026-09-15T00: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="900"><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-09-15T00: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="900"><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></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></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +2 -0
@@ -13,6 +13,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
1313 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
1414 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demo-llmman/`
1515 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/`
16+- [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`
1617 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
1718 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the template and the two kits under `docker-sbx/`, window mode and editor
1819 - [Run mini-me with the v3 sandbox kits](how-to/run-with-sandbox-kits-v3.md) — the same two kits for the kit specification v3, under `docker-sbx-v3/`
@@ -56,6 +57,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
5657 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
5758 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits
5859 - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive
60+- [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, the depth limit
5961 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
6062
6163 ### MCP (Model Context Protocol) Explanation
@@ -13,6 +13,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
13 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)13 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
14 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demo-llmman/`14 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demo-llmman/`
15 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/`15 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/`
16+- [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`
16 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)17 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
17 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the template and the two kits under `docker-sbx/`, window mode and editor18 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the template and the two kits under `docker-sbx/`, window mode and editor
18 - [Run mini-me with the v3 sandbox kits](how-to/run-with-sandbox-kits-v3.md) — the same two kits for the kit specification v3, under `docker-sbx-v3/`19 - [Run mini-me with the v3 sandbox kits](how-to/run-with-sandbox-kits-v3.md) — the same two kits for the kit specification v3, under `docker-sbx-v3/`
@@ -56,6 +57,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
56 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission57 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
57 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits58 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits
58 - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive59 - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive
60+- [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, the depth limit
59 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)61 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
60 62
61 ### MCP (Model Context Protocol) Explanation63 ### MCP (Model Context Protocol) Explanation
modified docs/en/explanation/architecture.md +1 -0
@@ -15,6 +15,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
1515 | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. |
1616 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
1717 | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |
18+| `ask` | `/ask-<name>`: starts another mm agent (config `mmConfigs`) as a child `mm -acp`, talks to it as an ACP client, streams its answer and tool calls through the asking front end's `ui.Sink`, and returns the exchange for the history. See [Asking another agent](asking-another-agent.md). |
1819 | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |
1920 | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |
2021 | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |
@@ -15,6 +15,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
15 | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. |15 | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. |
16 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |16 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
17 | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |17 | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |
18+| `ask` | `/ask-<name>`: starts another mm agent (config `mmConfigs`) as a child `mm -acp`, talks to it as an ACP client, streams its answer and tool calls through the asking front end's `ui.Sink`, and returns the exchange for the history. See [Asking another agent](asking-another-agent.md). |
18 | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |19 | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |
19 | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |20 | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |
20 | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |21 | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |
added docs/en/explanation/asking-another-agent.md +38 -0
new file mode 100644
@@ -0,0 +1,38 @@
1+# Asking another agent — explanation
2+
3+## What is this about?
4+
5+`/ask-bob <prompt>` lets the agent you are talking to consult another mm agent: another model, another system prompt, other tools and skills. Bob's answer is shown, then kept in your conversation so your own model can use it on your next question. This page explains how that is built and why.
6+
7+## Why it is designed this way
8+
9+**Another agent is another process.** Bob runs as a child `mm -acp -config <bob's file>`, the same binary as the agent that asks. Running him inside the same process was the obvious alternative and the wrong one: mm's configuration is one global, read by the engine, the tools, the compaction and the sessions alike, and bob's differs in all of them. A second engine in the same process would first have meant pulling that global apart everywhere. A child keeps each agent whole, with its own tools, skills and MCP servers.
10+
11+**The two talk ACP.** mm already speaks the Agent Client Protocol as a server, for editors. Asking bob makes the asking agent an ACP *client* of its child. The protocol gives, for free, what scraping a terminal never would: bob's answer as text, his tool calls as structured events, his permission requests as requests that can be answered, and a standard way to cancel. mm's ACP server only ever calls two client methods — session updates and permission requests — so the client side is small; file and terminal requests are refused as absent.
12+
13+**Bob's permissions are yours to give.** When bob wants to run a command, the request is forwarded to the front end you are using: in an editor its own dialog, in the window the `permissions` setting and its inline prompt, in the line mode nothing — exactly as for your own agent's tools there. Bob's tools are named `bob/<tool>`, so allowing your agent's `bash` "for this session" does not allow bob's.
14+
15+**The answer is a pair of messages.** It enters your history as a user message (`[asked bob (provider/model): …]`) followed by an assistant message (`[bob's answer]` and the text). One message would have been simpler, but your next question is a user message too, and several chat templates (Gemma's, Mistral's) refuse two user messages in a row. The marks say who said what, so your model does not mistake bob's answer for its own. The answer is capped like a tool's output, at `maxOutput` characters: it lands in your model's window.
16+
17+**Each ask is a fresh conversation for bob.** Bob keeps no memory between two asks. When he needs to know what you were doing, `/ask-with-context-bob` sends a transcript: questions, answers, one line per tool call — never the tool outputs, which would cost his window what they already cost yours, never your system prompt or reasoning. Continuity is explicit, chosen per question, rather than a second conversation drifting on its own.
18+
19+**One child per agent, started once.** Starting mm costs its engine set-up, its server probe and its MCP servers. The first ask starts bob; later asks reuse him, each with a new session. He stops when your agent exits.
20+
21+**The child is fenced off from what is meant for its parent.** Variables like `AGENT_MODEL`, `AGENT_PROVIDER` or `AGENT_BASE_URL` override a configuration; inherited by bob, they would silently turn him into a copy of you. They are removed from his environment. Bob also runs with `AGENT_SESSIONS=off`: he works in your project, and his session files would otherwise appear in your `/sessions` and `/resume`.
22+
23+**Chains stop.** `/ask` is a command a person types; no model can call it, so no agent loops on its own. A person could still type `/ask` into an agent that was itself asked, so each child is told its depth (`MM_ASK_DEPTH`): your agent may ask bob, bob may ask riker, riker may not ask anyone.
24+
25+## Rejected alternatives
26+
27+- **`mm -p "<prompt>"` as a subprocess.** One process per question and nothing to keep alive — but its output is a terminal screen (answer, reasoning, tool echoes, colours, the command recap) that would have to be parsed; it never asks permission, so bob would run commands unchecked; it cannot be cancelled except by killing it.
28+- **A second engine in the same process.** Fast, no process — at the price of turning the global configuration into a per-agent one throughout the code base.
29+- **Letting the current model answer at once with bob's input.** The user chose otherwise: bob's answer is shown and kept, and you decide what to ask next.
30+- **Keeping bob's own conversation across asks.** Two conversations evolving in parallel, one of them invisible, are harder to reason about than an explicit transcript sent when asked.
31+- **A generic `/ask <name>` only, in editors too.** An editor's `/` menu completes a command name, never its argument; one command per agent is the only way for the menu to list the agents.
32+
33+## How it relates to the rest
34+
35+- The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side.
36+- Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools.
37+- The exchange is part of the history like any other turn: it is saved with the [session](persistent-sessions.md), replayed by `/resume`, and summarised by [context compression](context-compression.md) when it gets old.
38+- Using it: [how to ask another agent](../how-to/ask-another-agent.md). Every command and message: [slash commands reference](../reference/slash-commands.md#asking-another-agent).
new file mode 100644
@@ -0,0 +1,38 @@
1+# Asking another agent — explanation
2+
3+## What is this about?
4+
5+`/ask-bob <prompt>` lets the agent you are talking to consult another mm agent: another model, another system prompt, other tools and skills. Bob's answer is shown, then kept in your conversation so your own model can use it on your next question. This page explains how that is built and why.
6+
7+## Why it is designed this way
8+
9+**Another agent is another process.** Bob runs as a child `mm -acp -config <bob's file>`, the same binary as the agent that asks. Running him inside the same process was the obvious alternative and the wrong one: mm's configuration is one global, read by the engine, the tools, the compaction and the sessions alike, and bob's differs in all of them. A second engine in the same process would first have meant pulling that global apart everywhere. A child keeps each agent whole, with its own tools, skills and MCP servers.
10+
11+**The two talk ACP.** mm already speaks the Agent Client Protocol as a server, for editors. Asking bob makes the asking agent an ACP *client* of its child. The protocol gives, for free, what scraping a terminal never would: bob's answer as text, his tool calls as structured events, his permission requests as requests that can be answered, and a standard way to cancel. mm's ACP server only ever calls two client methods — session updates and permission requests — so the client side is small; file and terminal requests are refused as absent.
12+
13+**Bob's permissions are yours to give.** When bob wants to run a command, the request is forwarded to the front end you are using: in an editor its own dialog, in the window the `permissions` setting and its inline prompt, in the line mode nothing — exactly as for your own agent's tools there. Bob's tools are named `bob/<tool>`, so allowing your agent's `bash` "for this session" does not allow bob's.
14+
15+**The answer is a pair of messages.** It enters your history as a user message (`[asked bob (provider/model): …]`) followed by an assistant message (`[bob's answer]` and the text). One message would have been simpler, but your next question is a user message too, and several chat templates (Gemma's, Mistral's) refuse two user messages in a row. The marks say who said what, so your model does not mistake bob's answer for its own. The answer is capped like a tool's output, at `maxOutput` characters: it lands in your model's window.
16+
17+**Each ask is a fresh conversation for bob.** Bob keeps no memory between two asks. When he needs to know what you were doing, `/ask-with-context-bob` sends a transcript: questions, answers, one line per tool call — never the tool outputs, which would cost his window what they already cost yours, never your system prompt or reasoning. Continuity is explicit, chosen per question, rather than a second conversation drifting on its own.
18+
19+**One child per agent, started once.** Starting mm costs its engine set-up, its server probe and its MCP servers. The first ask starts bob; later asks reuse him, each with a new session. He stops when your agent exits.
20+
21+**The child is fenced off from what is meant for its parent.** Variables like `AGENT_MODEL`, `AGENT_PROVIDER` or `AGENT_BASE_URL` override a configuration; inherited by bob, they would silently turn him into a copy of you. They are removed from his environment. Bob also runs with `AGENT_SESSIONS=off`: he works in your project, and his session files would otherwise appear in your `/sessions` and `/resume`.
22+
23+**Chains stop.** `/ask` is a command a person types; no model can call it, so no agent loops on its own. A person could still type `/ask` into an agent that was itself asked, so each child is told its depth (`MM_ASK_DEPTH`): your agent may ask bob, bob may ask riker, riker may not ask anyone.
24+
25+## Rejected alternatives
26+
27+- **`mm -p "<prompt>"` as a subprocess.** One process per question and nothing to keep alive — but its output is a terminal screen (answer, reasoning, tool echoes, colours, the command recap) that would have to be parsed; it never asks permission, so bob would run commands unchecked; it cannot be cancelled except by killing it.
28+- **A second engine in the same process.** Fast, no process — at the price of turning the global configuration into a per-agent one throughout the code base.
29+- **Letting the current model answer at once with bob's input.** The user chose otherwise: bob's answer is shown and kept, and you decide what to ask next.
30+- **Keeping bob's own conversation across asks.** Two conversations evolving in parallel, one of them invisible, are harder to reason about than an explicit transcript sent when asked.
31+- **A generic `/ask <name>` only, in editors too.** An editor's `/` menu completes a command name, never its argument; one command per agent is the only way for the menu to list the agents.
32+
33+## How it relates to the rest
34+
35+- The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side.
36+- Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools.
37+- The exchange is part of the history like any other turn: it is saved with the [session](persistent-sessions.md), replayed by `/resume`, and summarised by [context compression](context-compression.md) when it gets old.
38+- Using it: [how to ask another agent](../how-to/ask-another-agent.md). Every command and message: [slash commands reference](../reference/slash-commands.md#asking-another-agent).
added docs/en/how-to/ask-another-agent.md +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+# How to ask another mm agent from your session
2+
3+This guide shows how to put a question to another mm agent — another model, another system prompt, other tools — and keep its answer in your conversation. It assumes you already run `mm` with a working provider.
4+
5+## Steps
6+
7+1. Write (or reuse) one configuration file per agent you want to ask. Each is an ordinary mm configuration; `demo-llmman/` ships two, for llmman:
8+
9+ - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, named Jan;
10+ - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, named Lucy.
11+
12+2. Declare them in YOUR configuration, under `mmConfigs`. Paths are relative to the directory of the file you write them in:
13+
14+ ```yaml
15+ mmConfigs:
16+ jan: agent.jan-nano.yaml
17+ lucy: agent.lucy.yaml
18+ ```
19+
20+ `demo-llmman/agent.llmman.yaml` already does this.
21+
22+3. Start `mm` with that configuration:
23+
24+ ```bash
25+ cd demo-llmman
26+ ../mm agent.llmman.yaml
27+ ```
28+
29+ An agent whose file is missing shows up as a `[warning: mmConfigs: …]` line; the others work.
30+
31+4. Ask a question:
32+
33+ ```text
34+ > /ask-jan In one short sentence: what is the capital of France?
35+ 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)…
36+ The capital of France is Paris.
37+ 🗣 jan's answer is in the history for your next question
38+ ```
39+
40+ The first ask of an agent starts it, so it takes a little longer; the next ones reuse it.
41+
42+5. Use the answer: your next question goes to your own model, which now sees Jan's answer.
43+
44+ ```text
45+ > What did Jan answer?
46+ 🤖 Jan answered that the capital of France is Paris.
47+ ```
48+
49+## Variants
50+
51+- **Let the other agent see the conversation.** Use `/ask-with-context-lucy <prompt>`: Lucy receives a transcript of the conversation (questions, answers, one line per tool call — never tool outputs) before your question.
52+- **In the terminal or the window, by name.** `/ask lucy <prompt>` and `/ask-with-context lucy <prompt>` are the same commands with the agent as an argument.
53+- **In an editor (ACP).** The menu lists `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy`; when Lucy wants to run a command, the editor's own permission dialog asks you.
54+- **Stop a long answer.** `/abort`, `Esc` or `Ctrl+C` — nothing is kept from a cancelled ask.
55+- **Agents on other providers.** Any mm configuration works: an Ollama agent next to an llmman one is just another `mmConfigs` entry.
56+
57+## See also
58+
59+- Every form, message and error: [slash commands reference](../reference/slash-commands.md#asking-another-agent)
60+- The `mmConfigs` key: [configuration reference](../reference/configuration.md)
61+- Why another agent is a separate process spoken to over ACP: [asking another agent — explanation](../explanation/asking-another-agent.md)
new file mode 100644
@@ -0,0 +1,61 @@
1+# How to ask another mm agent from your session
2+
3+This guide shows how to put a question to another mm agent — another model, another system prompt, other tools — and keep its answer in your conversation. It assumes you already run `mm` with a working provider.
4+
5+## Steps
6+
7+1. Write (or reuse) one configuration file per agent you want to ask. Each is an ordinary mm configuration; `demo-llmman/` ships two, for llmman:
8+
9+ - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, named Jan;
10+ - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, named Lucy.
11+
12+2. Declare them in YOUR configuration, under `mmConfigs`. Paths are relative to the directory of the file you write them in:
13+
14+ ```yaml
15+ mmConfigs:
16+ jan: agent.jan-nano.yaml
17+ lucy: agent.lucy.yaml
18+ ```
19+
20+ `demo-llmman/agent.llmman.yaml` already does this.
21+
22+3. Start `mm` with that configuration:
23+
24+ ```bash
25+ cd demo-llmman
26+ ../mm agent.llmman.yaml
27+ ```
28+
29+ An agent whose file is missing shows up as a `[warning: mmConfigs: …]` line; the others work.
30+
31+4. Ask a question:
32+
33+ ```text
34+ > /ask-jan In one short sentence: what is the capital of France?
35+ 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)…
36+ The capital of France is Paris.
37+ 🗣 jan's answer is in the history for your next question
38+ ```
39+
40+ The first ask of an agent starts it, so it takes a little longer; the next ones reuse it.
41+
42+5. Use the answer: your next question goes to your own model, which now sees Jan's answer.
43+
44+ ```text
45+ > What did Jan answer?
46+ 🤖 Jan answered that the capital of France is Paris.
47+ ```
48+
49+## Variants
50+
51+- **Let the other agent see the conversation.** Use `/ask-with-context-lucy <prompt>`: Lucy receives a transcript of the conversation (questions, answers, one line per tool call — never tool outputs) before your question.
52+- **In the terminal or the window, by name.** `/ask lucy <prompt>` and `/ask-with-context lucy <prompt>` are the same commands with the agent as an argument.
53+- **In an editor (ACP).** The menu lists `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy`; when Lucy wants to run a command, the editor's own permission dialog asks you.
54+- **Stop a long answer.** `/abort`, `Esc` or `Ctrl+C` — nothing is kept from a cancelled ask.
55+- **Agents on other providers.** Any mm configuration works: an Ollama agent next to an llmman one is just another `mmConfigs` entry.
56+
57+## See also
58+
59+- Every form, message and error: [slash commands reference](../reference/slash-commands.md#asking-another-agent)
60+- The `mmConfigs` key: [configuration reference](../reference/configuration.md)
61+- Why another agent is a separate process spoken to over ACP: [asking another agent — explanation](../explanation/asking-another-agent.md)
modified docs/en/reference/cli.md +3 -1
@@ -55,6 +55,8 @@ The first existing source wins.
5555 | `AGENT_MODEL` | Overrides the `model` key. |
5656 | `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. |
5757 | `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. |
58+| `AGENT_SESSIONS` | `off` turns session persistence off for this run, whatever `sessions.enabled` says. Set by `/ask` on the agents it starts. |
59+| `MM_ASK_DEPTH` | How many `/ask` levels deep this agent runs (unset = 0). Set by `/ask`; at 2, `/ask` is refused. |
5860 | Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`, `LLMMAN_API_KEY` for `llmman`, `OLLAMA_API_KEY` for `ollama`. |
5961
6062 ## Start-up output
@@ -63,7 +65,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
6365
6466 | Line | Content |
6567 |------|---------|
66-| `[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. |
68+| `[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`). |
6769 | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | 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. |
6870 | `Agent ready. Commands: …` | Terminal mode only. |
6971 | `Shortcut: Ctrl+C …` | Terminal mode only. |
@@ -55,6 +55,8 @@ The first existing source wins.
55 | `AGENT_MODEL` | Overrides the `model` key. |55 | `AGENT_MODEL` | Overrides the `model` key. |
56 | `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. |56 | `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. |
57 | `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. |57 | `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. |
58+| `AGENT_SESSIONS` | `off` turns session persistence off for this run, whatever `sessions.enabled` says. Set by `/ask` on the agents it starts. |
59+| `MM_ASK_DEPTH` | How many `/ask` levels deep this agent runs (unset = 0). Set by `/ask`; at 2, `/ask` is refused. |
58 | Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`, `LLMMAN_API_KEY` for `llmman`, `OLLAMA_API_KEY` for `ollama`. |60 | Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`, `LLMMAN_API_KEY` for `llmman`, `OLLAMA_API_KEY` for `ollama`. |
59 61
60 ## Start-up output62 ## Start-up output
@@ -63,7 +65,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
63 65
64 | Line | Content |66 | Line | Content |
65 |------|---------|67 |------|---------|
66-| `[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. |68+| `[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`). |
67 | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | 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. |69 | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | 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. |
68 | `Agent ready. Commands: …` | Terminal mode only. |70 | `Agent ready. Commands: …` | Terminal mode only. |
69 | `Shortcut: Ctrl+C …` | Terminal mode only. |71 | `Shortcut: Ctrl+C …` | Terminal mode only. |
modified docs/en/reference/configuration.md +4 -0
@@ -31,6 +31,7 @@
3131 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
3232 | `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`. |
3333 | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |
34+| `mmConfigs` | map name → path | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)). A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. |
3435
3536 ## `context` keys
3637
@@ -117,6 +118,9 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
117118 | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot |
118119 | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension |
119120 | `permissions must be "auto" or "ask"` | any other value |
121+| `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |
122+| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |
123+| `mmConfigs.<name>: the path must not be empty` | `<name>: ""` |
120124 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. |
121125
122126 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.
@@ -31,6 +31,7 @@
31 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |31 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
32 | `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`. |32 | `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`. |
33 | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |33 | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |
34+| `mmConfigs` | map name → path | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)). A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. |
34 35
35 ## `context` keys36 ## `context` keys
36 37
@@ -117,6 +118,9 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
117 | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot |118 | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot |
118 | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension |119 | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension |
119 | `permissions must be "auto" or "ask"` | any other value |120 | `permissions must be "auto" or "ask"` | any other value |
121+| `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |
122+| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |
123+| `mmConfigs.<name>: the path must not be empty` | `<name>: ""` |
120 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. |124 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. |
121 125
122 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.126 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.
modified docs/en/reference/slash-commands.md +45 -0
@@ -17,6 +17,49 @@
1717
1818 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
1919
20+## Asking another agent
21+
22+Present only when `mmConfigs` declares at least one agent; `/help` and the `/` picker list them. `<name>` is a key of `mmConfigs`.
23+
24+| Command | Front ends | Effect |
25+|---------|------------|--------|
26+| `/ask-<name> <prompt>` | terminal, window, editor | Sends `<prompt>` alone to `<name>`. |
27+| `/ask-with-context-<name> <prompt>` | terminal, window, editor | Sends a transcript of the current conversation, then `<prompt>`. |
28+| `/ask <name> <prompt>` | terminal, window | Same as `/ask-<name> <prompt>`. |
29+| `/ask-with-context <name> <prompt>` | terminal, window | Same as `/ask-with-context-<name> <prompt>`. |
30+
31+While it runs:
32+
33+| Item | Behaviour |
34+|------|-----------|
35+| First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. |
36+| `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. |
37+| `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |
38+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` |
39+| `/quit` | Cancels the ask and exits. |
40+| Any other line (line mode) | `⚠️ <name> is still answering — /abort to stop it.`; the line is dropped. |
41+
42+When it ends:
43+
44+| Outcome | History | Message |
45+|---------|---------|---------|
46+| Answered | Two messages appended: user `[asked <name> (<provider>/<model>)[, with this conversation as context]: <prompt>]`, assistant `[<name>'s answer]` + the answer (capped at `maxOutput` characters, beginning and end kept). Saved with the session. | `🗣 <name>'s answer is in the history for your next question` |
47+| Cancelled | Unchanged. | `🛑 Ask aborted — nothing kept.` |
48+| Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). |
49+
50+The transcript sent by the context forms holds the user's questions, the assistant's answers, one line per tool call (`Assistant ran <tool>: <args>`, args cut at 160 characters) and the compaction summary if any; never the system prompt, tool outputs or reasoning. Capped at `maxOutput` characters, newest lines kept, `[… earlier conversation omitted …]` marking the cut.
51+
52+| Error | Cause |
53+|-------|-------|
54+| `usage: /ask <agent> <prompt> — agents: …` | `/ask` without a name. |
55+| `unknown agent "x" — agents: …` | `/ask x …` with a name not in `mmConfigs`. |
56+| `nothing to ask: type the question after the command` | No prompt. |
57+| `<name>: config file <path>: no such file or directory` | The file of `mmConfigs.<name>` does not exist. |
58+| `<name> did not start: … — <last stderr line>` | The agent's process exited or did not answer `initialize` within 60 s. |
59+| `<name> failed: …` | The agent's turn ended with an error (its own one-line explanation). |
60+| `<name> gave no answer` | The agent ended its turn without any text. |
61+| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` is 2. |
62+
2063 ## `@path` in a question
2164
2265 | Input | Effect |
@@ -132,4 +175,6 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th
132175 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |
133176 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |
134177
178+With `mmConfigs`, `/ask-<name>` and `/ask-with-context-<name>` are advertised too, each with the input hint `<prompt>`; the generic `/ask <name>` is not, since an editor's menu completes a command name but not its argument. See [Asking another agent](#asking-another-agent).
179+
135180 `/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands).
@@ -17,6 +17,49 @@
17 17
18 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.18 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
19 19
20+## Asking another agent
21+
22+Present only when `mmConfigs` declares at least one agent; `/help` and the `/` picker list them. `<name>` is a key of `mmConfigs`.
23+
24+| Command | Front ends | Effect |
25+|---------|------------|--------|
26+| `/ask-<name> <prompt>` | terminal, window, editor | Sends `<prompt>` alone to `<name>`. |
27+| `/ask-with-context-<name> <prompt>` | terminal, window, editor | Sends a transcript of the current conversation, then `<prompt>`. |
28+| `/ask <name> <prompt>` | terminal, window | Same as `/ask-<name> <prompt>`. |
29+| `/ask-with-context <name> <prompt>` | terminal, window | Same as `/ask-with-context-<name> <prompt>`. |
30+
31+While it runs:
32+
33+| Item | Behaviour |
34+|------|-----------|
35+| First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. |
36+| `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. |
37+| `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |
38+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` |
39+| `/quit` | Cancels the ask and exits. |
40+| Any other line (line mode) | `⚠️ <name> is still answering — /abort to stop it.`; the line is dropped. |
41+
42+When it ends:
43+
44+| Outcome | History | Message |
45+|---------|---------|---------|
46+| Answered | Two messages appended: user `[asked <name> (<provider>/<model>)[, with this conversation as context]: <prompt>]`, assistant `[<name>'s answer]` + the answer (capped at `maxOutput` characters, beginning and end kept). Saved with the session. | `🗣 <name>'s answer is in the history for your next question` |
47+| Cancelled | Unchanged. | `🛑 Ask aborted — nothing kept.` |
48+| Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). |
49+
50+The transcript sent by the context forms holds the user's questions, the assistant's answers, one line per tool call (`Assistant ran <tool>: <args>`, args cut at 160 characters) and the compaction summary if any; never the system prompt, tool outputs or reasoning. Capped at `maxOutput` characters, newest lines kept, `[… earlier conversation omitted …]` marking the cut.
51+
52+| Error | Cause |
53+|-------|-------|
54+| `usage: /ask <agent> <prompt> — agents: …` | `/ask` without a name. |
55+| `unknown agent "x" — agents: …` | `/ask x …` with a name not in `mmConfigs`. |
56+| `nothing to ask: type the question after the command` | No prompt. |
57+| `<name>: config file <path>: no such file or directory` | The file of `mmConfigs.<name>` does not exist. |
58+| `<name> did not start: … — <last stderr line>` | The agent's process exited or did not answer `initialize` within 60 s. |
59+| `<name> failed: …` | The agent's turn ended with an error (its own one-line explanation). |
60+| `<name> gave no answer` | The agent ended its turn without any text. |
61+| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` is 2. |
62+
20 ## `@path` in a question63 ## `@path` in a question
21 64
22 | Input | Effect |65 | Input | Effect |
@@ -132,4 +175,6 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th
132 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |175 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |
133 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |176 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |
134 177
178+With `mmConfigs`, `/ask-<name>` and `/ask-with-context-<name>` are advertised too, each with the input hint `<prompt>`; the generic `/ask <name>` is not, since an editor's menu completes a command name but not its argument. See [Asking another agent](#asking-another-agent).
179+
135 `/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands).180 `/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands).
modified docs/fr/README.md +2 -0
@@ -13,6 +13,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
1313 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
1414 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demo-llmman/`
1515 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/`
16+- [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`
1617 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
1718 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le template et les deux kits sous `docker-sbx/`, mode fenêtre et éditeur
1819 - [Faire tourner mini-me avec les kits sandbox v3](how-to/run-with-sandbox-kits-v3.md) — les deux mêmes kits pour la spécification de kit v3, sous `docker-sbx-v3/`
@@ -56,6 +57,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
5657 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
5758 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues
5859 - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie
60+- [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, la limite de profondeur
5961 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
6062
6163 ### Explication MCP (Model Context Protocol)
@@ -13,6 +13,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
13 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)13 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
14 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demo-llmman/`14 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demo-llmman/`
15 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/`15 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/`
16+- [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`
16 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)17 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
17 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le template et les deux kits sous `docker-sbx/`, mode fenêtre et éditeur18 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le template et les deux kits sous `docker-sbx/`, mode fenêtre et éditeur
18 - [Faire tourner mini-me avec les kits sandbox v3](how-to/run-with-sandbox-kits-v3.md) — les deux mêmes kits pour la spécification de kit v3, sous `docker-sbx-v3/`19 - [Faire tourner mini-me avec les kits sandbox v3](how-to/run-with-sandbox-kits-v3.md) — les deux mêmes kits pour la spécification de kit v3, sous `docker-sbx-v3/`
@@ -56,6 +57,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
56 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission57 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
57 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues58 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues
58 - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie59 - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie
60+- [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, la limite de profondeur
59 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)61 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
60 62
61 ### Explication MCP (Model Context Protocol)63 ### Explication MCP (Model Context Protocol)
modified docs/fr/explanation/architecture.md +1 -0
@@ -15,6 +15,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
1515 | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. |
1616 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
1717 | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |
18+| `ask` | `/ask-<nom>` : démarre un autre agent mm (clé `mmConfigs`) comme enfant `mm -acp`, lui parle en client ACP, affiche sa réponse et ses appels d'outils via le `ui.Sink` de la façade qui pose la question, et renvoie l'échange pour l'historique. Voir [Interroger un autre agent](asking-another-agent.md). |
1819 | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |
1920 | `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. |
2021 | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |
@@ -15,6 +15,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
15 | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. |15 | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. |
16 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |16 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
17 | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |17 | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |
18+| `ask` | `/ask-<nom>` : démarre un autre agent mm (clé `mmConfigs`) comme enfant `mm -acp`, lui parle en client ACP, affiche sa réponse et ses appels d'outils via le `ui.Sink` de la façade qui pose la question, et renvoie l'échange pour l'historique. Voir [Interroger un autre agent](asking-another-agent.md). |
18 | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |19 | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |
19 | `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. |20 | `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. |
20 | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |21 | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |
added docs/fr/explanation/asking-another-agent.md +38 -0
new file mode 100644
@@ -0,0 +1,38 @@
1+# Interroger un autre agent — explication
2+
3+## De quoi s'agit-il ?
4+
5+`/ask-bob <prompt>` permet à l'agent avec qui vous parlez de consulter un autre agent mm : un autre modèle, un autre prompt système, d'autres outils et skills. La réponse de bob est affichée, puis gardée dans votre conversation pour que votre propre modèle s'en serve à votre question suivante. Cette page explique comment c'est construit et pourquoi.
6+
7+## Pourquoi c'est conçu ainsi
8+
9+**Un autre agent est un autre processus.** Bob tourne comme un enfant `mm -acp -config <fichier de bob>`, le même binaire que l'agent qui l'interroge. Le faire tourner dans le même processus était l'alternative évidente, et la mauvaise : la configuration de mm est une variable globale, lue par le moteur, les outils, la compaction et les sessions, et celle de bob diffère sur tous ces points. Un second moteur dans le même processus aurait d'abord demandé de démanteler cette variable globale partout. Un enfant garde chaque agent entier, avec ses propres outils, skills et serveurs MCP.
10+
11+**Les deux parlent ACP.** mm parle déjà l'Agent Client Protocol comme serveur, pour les éditeurs. Interroger bob fait de l'agent qui pose la question un *client* ACP de son enfant. Le protocole apporte gratuitement ce que lire un terminal n'aurait jamais donné : la réponse de bob sous forme de texte, ses appels d'outils comme des événements structurés, ses demandes de permission comme des requêtes auxquelles on peut répondre, et une façon standard d'annuler. Le serveur ACP de mm n'appelle que deux méthodes du client — les mises à jour de session et les demandes de permission — donc le côté client est petit ; les requêtes de fichiers et de terminal sont refusées comme absentes.
12+
13+**Les permissions de bob, c'est vous qui les donnez.** Quand bob veut lancer une commande, la demande est transmise à la façade que vous utilisez : dans un éditeur, sa propre boîte de dialogue ; dans la fenêtre, le réglage `permissions` et sa question dans la zone de saisie ; en mode ligne, rien — exactement comme pour les outils de votre propre agent à cet endroit. Les outils de bob s'appellent `bob/<outil>`, donc autoriser le `bash` de votre agent « pour cette session » n'autorise pas celui de bob.
14+
15+**La réponse est une paire de messages.** Elle entre dans votre historique comme un message utilisateur (`[asked bob (fournisseur/modèle): …]`) suivi d'un message de l'assistant (`[bob's answer]` et le texte). Un seul message aurait été plus simple, mais votre question suivante est aussi un message utilisateur, et plusieurs templates de chat (ceux de Gemma, de Mistral) refusent deux messages utilisateur à la suite. Les marques disent qui a dit quoi, pour que votre modèle ne prenne pas la réponse de bob pour la sienne. La réponse est plafonnée comme la sortie d'un outil, à `maxOutput` caractères : elle atterrit dans la fenêtre de votre modèle.
16+
17+**Chaque demande est une conversation neuve pour bob.** Bob ne garde aucune mémoire entre deux demandes. Quand il doit savoir ce que vous faisiez, `/ask-with-context-bob` envoie une transcription : questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils, qui coûteraient à sa fenêtre ce qu'elles ont déjà coûté à la vôtre, jamais votre prompt système ni le raisonnement. La continuité est explicite, choisie à chaque question, plutôt qu'une seconde conversation qui dérive de son côté.
18+
19+**Un enfant par agent, démarré une fois.** Démarrer mm coûte la mise en place de son moteur, la sonde du serveur et ses serveurs MCP. La première demande démarre bob ; les suivantes le réutilisent, chacune avec une nouvelle session. Il s'arrête quand votre agent se termine.
20+
21+**L'enfant est protégé de ce qui est destiné à son parent.** Des variables comme `AGENT_MODEL`, `AGENT_PROVIDER` ou `AGENT_BASE_URL` surchargent une configuration ; héritées par bob, elles en feraient silencieusement une copie de vous. Elles sont retirées de son environnement. Bob tourne aussi avec `AGENT_SESSIONS=off` : il travaille dans votre projet, et ses fichiers de session apparaîtraient sinon dans vos `/sessions` et `/resume`.
22+
23+**Les chaînes s'arrêtent.** `/ask` est une commande tapée par une personne ; aucun modèle ne peut l'appeler, donc aucun agent ne boucle tout seul. Une personne pourrait quand même taper `/ask` dans un agent lui-même interrogé, alors chaque enfant connaît sa profondeur (`MM_ASK_DEPTH`) : votre agent peut interroger bob, bob peut interroger riker, riker ne peut interroger personne.
24+
25+## Alternatives écartées
26+
27+- **`mm -p "<prompt>"` en sous-processus.** Un processus par question et rien à garder en vie — mais sa sortie est un écran de terminal (réponse, raisonnement, échos d'outils, couleurs, récapitulatif des commandes) qu'il faudrait analyser ; il ne demande jamais de permission, donc bob lancerait des commandes sans contrôle ; on ne peut l'annuler qu'en le tuant.
28+- **Un second moteur dans le même processus.** Rapide, sans processus — au prix de transformer la configuration globale en configuration par agent dans tout le code.
29+- **Faire répondre le modèle courant tout de suite avec l'apport de bob.** L'utilisateur a choisi autrement : la réponse de bob est affichée et gardée, et vous décidez de la question suivante.
30+- **Garder la conversation de bob d'une demande à l'autre.** Deux conversations qui évoluent en parallèle, dont une invisible, sont plus difficiles à suivre qu'une transcription explicite envoyée quand on la demande.
31+- **Seulement un `/ask <nom>` générique, y compris dans les éditeurs.** Le menu `/` d'un éditeur complète un nom de commande, jamais son argument ; une commande par agent est la seule façon pour que le menu liste les agents.
32+
33+## Lien avec le reste
34+
35+- L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté.
36+- Ses appels d'outils et ses permissions passent par la même interface d'événements que les trois façades implémentent pour les outils de votre propre agent.
37+- L'échange fait partie de l'historique comme n'importe quel tour : il est enregistré avec la [session](persistent-sessions.md), rejoué par `/resume`, et résumé par la [compression du contexte](context-compression.md) quand il vieillit.
38+- L'utiliser : [comment interroger un autre agent](../how-to/ask-another-agent.md). Toutes les commandes et messages : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent).
new file mode 100644
@@ -0,0 +1,38 @@
1+# Interroger un autre agent — explication
2+
3+## De quoi s'agit-il ?
4+
5+`/ask-bob <prompt>` permet à l'agent avec qui vous parlez de consulter un autre agent mm : un autre modèle, un autre prompt système, d'autres outils et skills. La réponse de bob est affichée, puis gardée dans votre conversation pour que votre propre modèle s'en serve à votre question suivante. Cette page explique comment c'est construit et pourquoi.
6+
7+## Pourquoi c'est conçu ainsi
8+
9+**Un autre agent est un autre processus.** Bob tourne comme un enfant `mm -acp -config <fichier de bob>`, le même binaire que l'agent qui l'interroge. Le faire tourner dans le même processus était l'alternative évidente, et la mauvaise : la configuration de mm est une variable globale, lue par le moteur, les outils, la compaction et les sessions, et celle de bob diffère sur tous ces points. Un second moteur dans le même processus aurait d'abord demandé de démanteler cette variable globale partout. Un enfant garde chaque agent entier, avec ses propres outils, skills et serveurs MCP.
10+
11+**Les deux parlent ACP.** mm parle déjà l'Agent Client Protocol comme serveur, pour les éditeurs. Interroger bob fait de l'agent qui pose la question un *client* ACP de son enfant. Le protocole apporte gratuitement ce que lire un terminal n'aurait jamais donné : la réponse de bob sous forme de texte, ses appels d'outils comme des événements structurés, ses demandes de permission comme des requêtes auxquelles on peut répondre, et une façon standard d'annuler. Le serveur ACP de mm n'appelle que deux méthodes du client — les mises à jour de session et les demandes de permission — donc le côté client est petit ; les requêtes de fichiers et de terminal sont refusées comme absentes.
12+
13+**Les permissions de bob, c'est vous qui les donnez.** Quand bob veut lancer une commande, la demande est transmise à la façade que vous utilisez : dans un éditeur, sa propre boîte de dialogue ; dans la fenêtre, le réglage `permissions` et sa question dans la zone de saisie ; en mode ligne, rien — exactement comme pour les outils de votre propre agent à cet endroit. Les outils de bob s'appellent `bob/<outil>`, donc autoriser le `bash` de votre agent « pour cette session » n'autorise pas celui de bob.
14+
15+**La réponse est une paire de messages.** Elle entre dans votre historique comme un message utilisateur (`[asked bob (fournisseur/modèle): …]`) suivi d'un message de l'assistant (`[bob's answer]` et le texte). Un seul message aurait été plus simple, mais votre question suivante est aussi un message utilisateur, et plusieurs templates de chat (ceux de Gemma, de Mistral) refusent deux messages utilisateur à la suite. Les marques disent qui a dit quoi, pour que votre modèle ne prenne pas la réponse de bob pour la sienne. La réponse est plafonnée comme la sortie d'un outil, à `maxOutput` caractères : elle atterrit dans la fenêtre de votre modèle.
16+
17+**Chaque demande est une conversation neuve pour bob.** Bob ne garde aucune mémoire entre deux demandes. Quand il doit savoir ce que vous faisiez, `/ask-with-context-bob` envoie une transcription : questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils, qui coûteraient à sa fenêtre ce qu'elles ont déjà coûté à la vôtre, jamais votre prompt système ni le raisonnement. La continuité est explicite, choisie à chaque question, plutôt qu'une seconde conversation qui dérive de son côté.
18+
19+**Un enfant par agent, démarré une fois.** Démarrer mm coûte la mise en place de son moteur, la sonde du serveur et ses serveurs MCP. La première demande démarre bob ; les suivantes le réutilisent, chacune avec une nouvelle session. Il s'arrête quand votre agent se termine.
20+
21+**L'enfant est protégé de ce qui est destiné à son parent.** Des variables comme `AGENT_MODEL`, `AGENT_PROVIDER` ou `AGENT_BASE_URL` surchargent une configuration ; héritées par bob, elles en feraient silencieusement une copie de vous. Elles sont retirées de son environnement. Bob tourne aussi avec `AGENT_SESSIONS=off` : il travaille dans votre projet, et ses fichiers de session apparaîtraient sinon dans vos `/sessions` et `/resume`.
22+
23+**Les chaînes s'arrêtent.** `/ask` est une commande tapée par une personne ; aucun modèle ne peut l'appeler, donc aucun agent ne boucle tout seul. Une personne pourrait quand même taper `/ask` dans un agent lui-même interrogé, alors chaque enfant connaît sa profondeur (`MM_ASK_DEPTH`) : votre agent peut interroger bob, bob peut interroger riker, riker ne peut interroger personne.
24+
25+## Alternatives écartées
26+
27+- **`mm -p "<prompt>"` en sous-processus.** Un processus par question et rien à garder en vie — mais sa sortie est un écran de terminal (réponse, raisonnement, échos d'outils, couleurs, récapitulatif des commandes) qu'il faudrait analyser ; il ne demande jamais de permission, donc bob lancerait des commandes sans contrôle ; on ne peut l'annuler qu'en le tuant.
28+- **Un second moteur dans le même processus.** Rapide, sans processus — au prix de transformer la configuration globale en configuration par agent dans tout le code.
29+- **Faire répondre le modèle courant tout de suite avec l'apport de bob.** L'utilisateur a choisi autrement : la réponse de bob est affichée et gardée, et vous décidez de la question suivante.
30+- **Garder la conversation de bob d'une demande à l'autre.** Deux conversations qui évoluent en parallèle, dont une invisible, sont plus difficiles à suivre qu'une transcription explicite envoyée quand on la demande.
31+- **Seulement un `/ask <nom>` générique, y compris dans les éditeurs.** Le menu `/` d'un éditeur complète un nom de commande, jamais son argument ; une commande par agent est la seule façon pour que le menu liste les agents.
32+
33+## Lien avec le reste
34+
35+- L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté.
36+- Ses appels d'outils et ses permissions passent par la même interface d'événements que les trois façades implémentent pour les outils de votre propre agent.
37+- L'échange fait partie de l'historique comme n'importe quel tour : il est enregistré avec la [session](persistent-sessions.md), rejoué par `/resume`, et résumé par la [compression du contexte](context-compression.md) quand il vieillit.
38+- L'utiliser : [comment interroger un autre agent](../how-to/ask-another-agent.md). Toutes les commandes et messages : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent).
added docs/fr/how-to/ask-another-agent.md +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+# Comment interroger un autre agent mm depuis votre session
2+
3+Ce guide montre comment poser une question à un autre agent mm — un autre modèle, un autre prompt système, d'autres outils — et garder sa réponse dans votre conversation. Il suppose que vous faites déjà tourner `mm` avec un fournisseur qui marche.
4+
5+## Étapes
6+
7+1. Écrivez (ou réutilisez) un fichier de configuration par agent à interroger. Chacun est une configuration mm ordinaire ; `demo-llmman/` en fournit deux, pour llmman :
8+
9+ - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, nommé Jan ;
10+ - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, nommée Lucy.
11+
12+2. Déclarez-les dans VOTRE configuration, sous `mmConfigs`. Les chemins sont relatifs au dossier du fichier où vous les écrivez :
13+
14+ ```yaml
15+ mmConfigs:
16+ jan: agent.jan-nano.yaml
17+ lucy: agent.lucy.yaml
18+ ```
19+
20+ `demo-llmman/agent.llmman.yaml` le fait déjà.
21+
22+3. Lancez `mm` avec cette configuration :
23+
24+ ```bash
25+ cd demo-llmman
26+ ../mm agent.llmman.yaml
27+ ```
28+
29+ Un agent dont le fichier manque apparaît sur une ligne `[warning: mmConfigs: …]` ; les autres fonctionnent.
30+
31+4. Posez une question :
32+
33+ ```text
34+ > /ask-jan In one short sentence: what is the capital of France?
35+ 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)…
36+ The capital of France is Paris.
37+ 🗣 jan's answer is in the history for your next question
38+ ```
39+
40+ La première demande à un agent le démarre, elle prend donc un peu plus de temps ; les suivantes le réutilisent.
41+
42+5. Servez-vous de la réponse : votre question suivante va à votre propre modèle, qui voit maintenant la réponse de Jan.
43+
44+ ```text
45+ > What did Jan answer?
46+ 🤖 Jan answered that the capital of France is Paris.
47+ ```
48+
49+## Variantes
50+
51+- **Laisser l'autre agent voir la conversation.** Utilisez `/ask-with-context-lucy <prompt>` : Lucy reçoit une transcription de la conversation (questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils) avant votre question.
52+- **Dans le terminal ou la fenêtre, par nom.** `/ask lucy <prompt>` et `/ask-with-context lucy <prompt>` sont les mêmes commandes, avec l'agent en argument.
53+- **Dans un éditeur (ACP).** Le menu liste `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy` ; quand Lucy veut lancer une commande, c'est la boîte de dialogue de permission de l'éditeur qui vous le demande.
54+- **Arrêter une longue réponse.** `/abort`, `Échap` ou `Ctrl+C` — rien n'est gardé d'une demande annulée.
55+- **Des agents sur d'autres fournisseurs.** N'importe quelle configuration mm convient : un agent Ollama à côté d'un agent llmman n'est qu'une entrée de plus dans `mmConfigs`.
56+
57+## Voir aussi
58+
59+- Toutes les formes, messages et erreurs : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent)
60+- La clé `mmConfigs` : [référence de configuration](../reference/configuration.md)
61+- Pourquoi un autre agent est un processus à part, interrogé en ACP : [interroger un autre agent — explication](../explanation/asking-another-agent.md)
new file mode 100644
@@ -0,0 +1,61 @@
1+# Comment interroger un autre agent mm depuis votre session
2+
3+Ce guide montre comment poser une question à un autre agent mm — un autre modèle, un autre prompt système, d'autres outils — et garder sa réponse dans votre conversation. Il suppose que vous faites déjà tourner `mm` avec un fournisseur qui marche.
4+
5+## Étapes
6+
7+1. Écrivez (ou réutilisez) un fichier de configuration par agent à interroger. Chacun est une configuration mm ordinaire ; `demo-llmman/` en fournit deux, pour llmman :
8+
9+ - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, nommé Jan ;
10+ - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, nommée Lucy.
11+
12+2. Déclarez-les dans VOTRE configuration, sous `mmConfigs`. Les chemins sont relatifs au dossier du fichier où vous les écrivez :
13+
14+ ```yaml
15+ mmConfigs:
16+ jan: agent.jan-nano.yaml
17+ lucy: agent.lucy.yaml
18+ ```
19+
20+ `demo-llmman/agent.llmman.yaml` le fait déjà.
21+
22+3. Lancez `mm` avec cette configuration :
23+
24+ ```bash
25+ cd demo-llmman
26+ ../mm agent.llmman.yaml
27+ ```
28+
29+ Un agent dont le fichier manque apparaît sur une ligne `[warning: mmConfigs: …]` ; les autres fonctionnent.
30+
31+4. Posez une question :
32+
33+ ```text
34+ > /ask-jan In one short sentence: what is the capital of France?
35+ 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)…
36+ The capital of France is Paris.
37+ 🗣 jan's answer is in the history for your next question
38+ ```
39+
40+ La première demande à un agent le démarre, elle prend donc un peu plus de temps ; les suivantes le réutilisent.
41+
42+5. Servez-vous de la réponse : votre question suivante va à votre propre modèle, qui voit maintenant la réponse de Jan.
43+
44+ ```text
45+ > What did Jan answer?
46+ 🤖 Jan answered that the capital of France is Paris.
47+ ```
48+
49+## Variantes
50+
51+- **Laisser l'autre agent voir la conversation.** Utilisez `/ask-with-context-lucy <prompt>` : Lucy reçoit une transcription de la conversation (questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils) avant votre question.
52+- **Dans le terminal ou la fenêtre, par nom.** `/ask lucy <prompt>` et `/ask-with-context lucy <prompt>` sont les mêmes commandes, avec l'agent en argument.
53+- **Dans un éditeur (ACP).** Le menu liste `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy` ; quand Lucy veut lancer une commande, c'est la boîte de dialogue de permission de l'éditeur qui vous le demande.
54+- **Arrêter une longue réponse.** `/abort`, `Échap` ou `Ctrl+C` — rien n'est gardé d'une demande annulée.
55+- **Des agents sur d'autres fournisseurs.** N'importe quelle configuration mm convient : un agent Ollama à côté d'un agent llmman n'est qu'une entrée de plus dans `mmConfigs`.
56+
57+## Voir aussi
58+
59+- Toutes les formes, messages et erreurs : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent)
60+- La clé `mmConfigs` : [référence de configuration](../reference/configuration.md)
61+- Pourquoi un autre agent est un processus à part, interrogé en ACP : [interroger un autre agent — explication](../explanation/asking-another-agent.md)
modified docs/fr/reference/cli.md +3 -1
@@ -55,6 +55,8 @@ La première source existante gagne.
5555 | `AGENT_MODEL` | Surcharge la clé `model`. |
5656 | `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. |
5757 | `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. |
58+| `AGENT_SESSIONS` | `off` désactive l'enregistrement des sessions pour cette exécution, quoi que dise `sessions.enabled`. Posée par `/ask` sur les agents qu'il lance. |
59+| `MM_ASK_DEPTH` | Nombre de niveaux de `/ask` au-dessus de cet agent (absente = 0). Posée par `/ask` ; à 2, `/ask` est refusée. |
5860 | Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`, `LLMMAN_API_KEY` pour `llmman`, `OLLAMA_API_KEY` pour `ollama`. |
5961
6062 ## Sortie de démarrage
@@ -63,7 +65,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
6365
6466 | Ligne | Contenu |
6567 |-------|---------|
66-| `[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é. |
68+| `[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`). |
6769 | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | 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. |
6870 | `Agent ready. Commands: …` | Mode terminal seulement. |
6971 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |
@@ -55,6 +55,8 @@ La première source existante gagne.
55 | `AGENT_MODEL` | Surcharge la clé `model`. |55 | `AGENT_MODEL` | Surcharge la clé `model`. |
56 | `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. |56 | `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. |
57 | `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. |57 | `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. |
58+| `AGENT_SESSIONS` | `off` désactive l'enregistrement des sessions pour cette exécution, quoi que dise `sessions.enabled`. Posée par `/ask` sur les agents qu'il lance. |
59+| `MM_ASK_DEPTH` | Nombre de niveaux de `/ask` au-dessus de cet agent (absente = 0). Posée par `/ask` ; à 2, `/ask` est refusée. |
58 | Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`, `LLMMAN_API_KEY` pour `llmman`, `OLLAMA_API_KEY` pour `ollama`. |60 | Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`, `LLMMAN_API_KEY` pour `llmman`, `OLLAMA_API_KEY` pour `ollama`. |
59 61
60 ## Sortie de démarrage62 ## Sortie de démarrage
@@ -63,7 +65,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
63 65
64 | Ligne | Contenu |66 | Ligne | Contenu |
65 |-------|---------|67 |-------|---------|
66-| `[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é. |68+| `[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`). |
67 | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | 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. |69 | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | 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. |
68 | `Agent ready. Commands: …` | Mode terminal seulement. |70 | `Agent ready. Commands: …` | Mode terminal seulement. |
69 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |71 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |
modified docs/fr/reference/configuration.md +4 -0
@@ -31,6 +31,7 @@
3131 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
3232 | `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`. |
3333 | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |
34+| `mmConfigs` | dictionnaire nom → chemin | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)). Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. |
3435
3536 ## Clés de `context`
3637
@@ -117,6 +118,9 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
117118 | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point |
118119 | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension |
119120 | `permissions must be "auto" or "ask"` | toute autre valeur |
121+| `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |
122+| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |
123+| `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""` |
120124 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
121125
122126 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`.
@@ -31,6 +31,7 @@
31 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |31 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
32 | `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`. |32 | `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`. |
33 | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |33 | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |
34+| `mmConfigs` | dictionnaire nom → chemin | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)). Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. |
34 35
35 ## Clés de `context`36 ## Clés de `context`
36 37
@@ -117,6 +118,9 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
117 | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point |118 | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point |
118 | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension |119 | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension |
119 | `permissions must be "auto" or "ask"` | toute autre valeur |120 | `permissions must be "auto" or "ask"` | toute autre valeur |
121+| `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |
122+| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |
123+| `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""` |
120 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. |124 | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
121 125
122 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`.126 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`.
modified docs/fr/reference/slash-commands.md +45 -0
@@ -17,6 +17,49 @@
1717
1818 Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.
1919
20+## Interroger un autre agent
21+
22+Présentes seulement quand `mmConfigs` déclare au moins un agent ; `/help` et le sélecteur `/` les listent. `<nom>` est une clé de `mmConfigs`.
23+
24+| Commande | Façades | Effet |
25+|----------|---------|-------|
26+| `/ask-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie `<prompt>` seul à `<nom>`. |
27+| `/ask-with-context-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie une transcription de la conversation courante, puis `<prompt>`. |
28+| `/ask <nom> <prompt>` | terminal, fenêtre | Comme `/ask-<nom> <prompt>`. |
29+| `/ask-with-context <nom> <prompt>` | terminal, fenêtre | Comme `/ask-with-context-<nom> <prompt>`. |
30+
31+Pendant l'exécution :
32+
33+| Élément | Comportement |
34+|---------|--------------|
35+| Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. |
36+| Réponse de `<nom>` | Affichée au fil de l'eau là où s'affichent les réponses. Son raisonnement est affiché comme un raisonnement quand `showThinking` est actif. |
37+| Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |
38+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` |
39+| `/quit` | Annule la demande et quitte. |
40+| Toute autre ligne (mode ligne) | `⚠️ <nom> is still answering — /abort to stop it.` ; la ligne est ignorée. |
41+
42+À la fin :
43+
44+| Issue | Historique | Message |
45+|-------|------------|---------|
46+| Réponse obtenue | Deux messages ajoutés : utilisateur `[asked <nom> (<fournisseur>/<modèle>)[, with this conversation as context]: <prompt>]`, assistant `[<nom>'s answer]` + la réponse (plafonnée à `maxOutput` caractères, début et fin conservés). Enregistrés avec la session. | `🗣 <nom>'s answer is in the history for your next question` |
47+| Annulée | Inchangé. | `🛑 Ask aborted — nothing kept.` |
48+| Échec | Inchangé ; enregistré dans le fichier de session avec `where: "ask"`. | `[error: …]` (terminal, fenêtre) ou une erreur JSON-RPC (éditeur). |
49+
50+La transcription envoyée par les formes avec contexte contient les questions de l'utilisateur, les réponses de l'assistant, une ligne par appel d'outil (`Assistant ran <outil>: <arguments>`, arguments coupés à 160 caractères) et le résumé de compaction s'il existe ; jamais le prompt système, les sorties d'outils ni le raisonnement. Plafonnée à `maxOutput` caractères, en gardant les lignes les plus récentes, `[… earlier conversation omitted …]` marquant la coupure.
51+
52+| Erreur | Cause |
53+|--------|-------|
54+| `usage: /ask <agent> <prompt> — agents: …` | `/ask` sans nom. |
55+| `unknown agent "x" — agents: …` | `/ask x …` avec un nom absent de `mmConfigs`. |
56+| `nothing to ask: type the question after the command` | Pas de prompt. |
57+| `<nom>: config file <chemin>: no such file or directory` | Le fichier de `mmConfigs.<nom>` n'existe pas. |
58+| `<nom> did not start: … — <dernière ligne de stderr>` | Le processus de l'agent s'est arrêté ou n'a pas répondu à `initialize` en 60 s. |
59+| `<nom> failed: …` | Le tour de l'agent s'est terminé sur une erreur (sa propre explication en une ligne). |
60+| `<nom> gave no answer` | L'agent a terminé son tour sans aucun texte. |
61+| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` vaut 2. |
62+
2063 ## `@chemin` dans une question
2164
2265 | Saisie | Effet |
@@ -134,4 +177,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée
134177
135178 Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).
136179
180+Avec `mmConfigs`, `/ask-<nom>` et `/ask-with-context-<nom>` sont annoncées aussi, chacune avec l'indication de saisie `<prompt>` ; le `/ask <nom>` générique ne l'est pas, car le menu d'un éditeur complète un nom de commande mais pas son argument. Voir [Interroger un autre agent](#interroger-un-autre-agent).
181+
137182 `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md).
@@ -17,6 +17,49 @@
17 17
18 Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.18 Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.
19 19
20+## Interroger un autre agent
21+
22+Présentes seulement quand `mmConfigs` déclare au moins un agent ; `/help` et le sélecteur `/` les listent. `<nom>` est une clé de `mmConfigs`.
23+
24+| Commande | Façades | Effet |
25+|----------|---------|-------|
26+| `/ask-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie `<prompt>` seul à `<nom>`. |
27+| `/ask-with-context-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie une transcription de la conversation courante, puis `<prompt>`. |
28+| `/ask <nom> <prompt>` | terminal, fenêtre | Comme `/ask-<nom> <prompt>`. |
29+| `/ask-with-context <nom> <prompt>` | terminal, fenêtre | Comme `/ask-with-context-<nom> <prompt>`. |
30+
31+Pendant l'exécution :
32+
33+| Élément | Comportement |
34+|---------|--------------|
35+| Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. |
36+| Réponse de `<nom>` | Affichée au fil de l'eau là où s'affichent les réponses. Son raisonnement est affiché comme un raisonnement quand `showThinking` est actif. |
37+| Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |
38+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` |
39+| `/quit` | Annule la demande et quitte. |
40+| Toute autre ligne (mode ligne) | `⚠️ <nom> is still answering — /abort to stop it.` ; la ligne est ignorée. |
41+
42+À la fin :
43+
44+| Issue | Historique | Message |
45+|-------|------------|---------|
46+| Réponse obtenue | Deux messages ajoutés : utilisateur `[asked <nom> (<fournisseur>/<modèle>)[, with this conversation as context]: <prompt>]`, assistant `[<nom>'s answer]` + la réponse (plafonnée à `maxOutput` caractères, début et fin conservés). Enregistrés avec la session. | `🗣 <nom>'s answer is in the history for your next question` |
47+| Annulée | Inchangé. | `🛑 Ask aborted — nothing kept.` |
48+| Échec | Inchangé ; enregistré dans le fichier de session avec `where: "ask"`. | `[error: …]` (terminal, fenêtre) ou une erreur JSON-RPC (éditeur). |
49+
50+La transcription envoyée par les formes avec contexte contient les questions de l'utilisateur, les réponses de l'assistant, une ligne par appel d'outil (`Assistant ran <outil>: <arguments>`, arguments coupés à 160 caractères) et le résumé de compaction s'il existe ; jamais le prompt système, les sorties d'outils ni le raisonnement. Plafonnée à `maxOutput` caractères, en gardant les lignes les plus récentes, `[… earlier conversation omitted …]` marquant la coupure.
51+
52+| Erreur | Cause |
53+|--------|-------|
54+| `usage: /ask <agent> <prompt> — agents: …` | `/ask` sans nom. |
55+| `unknown agent "x" — agents: …` | `/ask x …` avec un nom absent de `mmConfigs`. |
56+| `nothing to ask: type the question after the command` | Pas de prompt. |
57+| `<nom>: config file <chemin>: no such file or directory` | Le fichier de `mmConfigs.<nom>` n'existe pas. |
58+| `<nom> did not start: … — <dernière ligne de stderr>` | Le processus de l'agent s'est arrêté ou n'a pas répondu à `initialize` en 60 s. |
59+| `<nom> failed: …` | Le tour de l'agent s'est terminé sur une erreur (sa propre explication en une ligne). |
60+| `<nom> gave no answer` | L'agent a terminé son tour sans aucun texte. |
61+| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` vaut 2. |
62+
20 ## `@chemin` dans une question63 ## `@chemin` dans une question
21 64
22 | Saisie | Effet |65 | Saisie | Effet |
@@ -134,4 +177,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée
134 177
135 Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).178 Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).
136 179
180+Avec `mmConfigs`, `/ask-<nom>` et `/ask-with-context-<nom>` sont annoncées aussi, chacune avec l'indication de saisie `<prompt>` ; le `/ask <nom>` générique ne l'est pas, car le menu d'un éditeur complète un nom de commande mais pas son argument. Voir [Interroger un autre agent](#interroger-un-autre-agent).
181+
137 `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md).182 `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md).
modified internal/acp/acp.go +22 -3
@@ -29,6 +29,7 @@ import (
2929 "strings"
3030 "sync"
3131
32+ "mm/internal/ask"
3233 "mm/internal/command"
3334 "mm/internal/compact"
3435 "mm/internal/config"
@@ -76,6 +77,9 @@ type front struct {
7677 // so one running agent keeps one store per project.
7778 sessionsDir string
7879
80+ // asker runs /ask-<agent> (internal/ask); nil when none is configured.
81+ asker ask.Runner
82+
7983 mu sync.Mutex // guards sessions
8084 sessions map[sdk.SessionId]*session
8185
@@ -91,8 +95,11 @@ var _ sdk.Agent = (*front)(nil)
9195 // Run wires the agent side of an ACP connection on stdio and blocks until the
9296 // client closes it. Same arguments as agent.Run: main.go's wiring — engine,
9397 // system prompt, tool list — is identical for the two front ends.
94-func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) {
98+//
99+// asker runs the /ask-<agent> commands; nil when no agent is configured.
100+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, asker ask.Runner) {
95101 f := newFront(e, system, tools, os.Stdout, os.Stdin)
102+ f.asker = asker
96103 select {
97104 case <-f.conn.Done(): // the editor closed our stdin: we are done
98105 case <-ctx.Done():
@@ -281,7 +288,13 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
281288 func availableCommands() []sdk.AvailableCommand {
282289 var out []sdk.AvailableCommand
283290 for _, c := range command.For(command.Editor) {
284- out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description})
291+ cmd := sdk.AvailableCommand{Name: c.Name, Description: c.Description}
292+ if c.Hint != "" {
293+ // /ask-bob takes the question: the editor shows the hint where
294+ // it goes, as it does for a skill's parameters.
295+ cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.Hint}}
296+ }
297+ out = append(out, cmd)
285298 }
286299 for _, s := range skills.Declared() {
287300 if commandNamed(s.Name) {
@@ -370,9 +383,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
370383
371384 // The sink is what turns the tools' events into session/update messages;
372385 // installing it is what switches engine+tools to "ACP mode" for this turn.
373- ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s})
386+ k := &sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s}
387+ ui.SetActive(k)
374388 defer ui.SetActive(nil)
375389
390+ // /ask-bob: another agent answers instead of our model (ask.go).
391+ if req, isAsk, err := command.ParseAsk(input); isAsk {
392+ return f.askAgent(ctx, turnCtx, p.SessionId, s, k, req, err)
393+ }
394+
376395 // A typed "@path" — the editor's picker bypassed, or an editor without
377396 // one — is understood like the resource_link the picker would have sent,
378397 // against the session's cwd, which is the project the editor opened.
@@ -29,6 +29,7 @@ import (
29 "strings"29 "strings"
30 "sync"30 "sync"
31 31
32+ "mm/internal/ask"
32 "mm/internal/command"33 "mm/internal/command"
33 "mm/internal/compact"34 "mm/internal/compact"
34 "mm/internal/config"35 "mm/internal/config"
@@ -76,6 +77,9 @@ type front struct {
76 // so one running agent keeps one store per project.77 // so one running agent keeps one store per project.
77 sessionsDir string78 sessionsDir string
78 79
80+ // asker runs /ask-<agent> (internal/ask); nil when none is configured.
81+ asker ask.Runner
82+
79 mu sync.Mutex // guards sessions83 mu sync.Mutex // guards sessions
80 sessions map[sdk.SessionId]*session84 sessions map[sdk.SessionId]*session
81 85
@@ -91,8 +95,11 @@ var _ sdk.Agent = (*front)(nil)
91 // Run wires the agent side of an ACP connection on stdio and blocks until the95 // Run wires the agent side of an ACP connection on stdio and blocks until the
92 // client closes it. Same arguments as agent.Run: main.go's wiring — engine,96 // client closes it. Same arguments as agent.Run: main.go's wiring — engine,
93 // system prompt, tool list — is identical for the two front ends.97 // system prompt, tool list — is identical for the two front ends.
94-func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) {98+//
99+// asker runs the /ask-<agent> commands; nil when no agent is configured.
100+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, asker ask.Runner) {
95 f := newFront(e, system, tools, os.Stdout, os.Stdin)101 f := newFront(e, system, tools, os.Stdout, os.Stdin)
102+ f.asker = asker
96 select {103 select {
97 case <-f.conn.Done(): // the editor closed our stdin: we are done104 case <-f.conn.Done(): // the editor closed our stdin: we are done
98 case <-ctx.Done():105 case <-ctx.Done():
@@ -281,7 +288,13 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
281 func availableCommands() []sdk.AvailableCommand {288 func availableCommands() []sdk.AvailableCommand {
282 var out []sdk.AvailableCommand289 var out []sdk.AvailableCommand
283 for _, c := range command.For(command.Editor) {290 for _, c := range command.For(command.Editor) {
284- out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description})291+ cmd := sdk.AvailableCommand{Name: c.Name, Description: c.Description}
292+ if c.Hint != "" {
293+ // /ask-bob takes the question: the editor shows the hint where
294+ // it goes, as it does for a skill's parameters.
295+ cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.Hint}}
296+ }
297+ out = append(out, cmd)
285 }298 }
286 for _, s := range skills.Declared() {299 for _, s := range skills.Declared() {
287 if commandNamed(s.Name) {300 if commandNamed(s.Name) {
@@ -370,9 +383,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
370 383
371 // The sink is what turns the tools' events into session/update messages;384 // The sink is what turns the tools' events into session/update messages;
372 // installing it is what switches engine+tools to "ACP mode" for this turn.385 // installing it is what switches engine+tools to "ACP mode" for this turn.
373- ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s})386+ k := &sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s}
387+ ui.SetActive(k)
374 defer ui.SetActive(nil)388 defer ui.SetActive(nil)
375 389
390+ // /ask-bob: another agent answers instead of our model (ask.go).
391+ if req, isAsk, err := command.ParseAsk(input); isAsk {
392+ return f.askAgent(ctx, turnCtx, p.SessionId, s, k, req, err)
393+ }
394+
376 // A typed "@path" — the editor's picker bypassed, or an editor without395 // A typed "@path" — the editor's picker bypassed, or an editor without
377 // one — is understood like the resource_link the picker would have sent,396 // one — is understood like the resource_link the picker would have sent,
378 // against the session's cwd, which is the project the editor opened.397 // against the session's cwd, which is the project the editor opened.
added internal/acp/ask.go +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+package acp
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "mm/internal/ask"
9+ "mm/internal/command"
10+ "mm/internal/store"
11+
12+ sdk "github.com/coder/acp-go-sdk"
13+)
14+
15+// askAgent is /ask-<agent> in an editor: the other agent's answer streams
16+// into the thread as agent text, its tool calls as tool calls (named after
17+// it), its permission requests as this session's own dialog — and the
18+// exchange joins the history for the next question. Like a turn, it can be
19+// cancelled (stopReason "cancelled"), and a failure is a JSON-RPC error in the
20+// agent's words and a record in the session file; either way the history is
21+// left as it was.
22+func (f *front) askAgent(ctx, turnCtx context.Context, sid sdk.SessionId, s *session, k *sink, req command.AskRequest, parseErr error) (sdk.PromptResponse, error) {
23+ if parseErr != nil {
24+ k.Text("⚠️ " + parseErr.Error())
25+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
26+ }
27+ k.Text(ask.Label(req) + "\n\n")
28+ exchange, err := f.asker.Run(turnCtx, req, s.messages, s.cwd, k)
29+ switch {
30+ case turnCtx.Err() != nil:
31+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil
32+ case err != nil:
33+ fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s failed: %v\n", sid, req.Agent, err)
34+ f.fail(sid, s, store.NewFailure(s.messages, store.FailAsk, err.Error(), err))
35+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": err.Error()})
36+ }
37+ s.messages = append(s.messages, exchange...)
38+ f.persist(sid, s)
39+ fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s answered\n", sid, req.Agent)
40+ f.sendUsage(context.WithoutCancel(ctx), sid, contextUsed(s))
41+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
42+}
new file mode 100644
@@ -0,0 +1,42 @@
1+package acp
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "mm/internal/ask"
9+ "mm/internal/command"
10+ "mm/internal/store"
11+
12+ sdk "github.com/coder/acp-go-sdk"
13+)
14+
15+// askAgent is /ask-<agent> in an editor: the other agent's answer streams
16+// into the thread as agent text, its tool calls as tool calls (named after
17+// it), its permission requests as this session's own dialog — and the
18+// exchange joins the history for the next question. Like a turn, it can be
19+// cancelled (stopReason "cancelled"), and a failure is a JSON-RPC error in the
20+// agent's words and a record in the session file; either way the history is
21+// left as it was.
22+func (f *front) askAgent(ctx, turnCtx context.Context, sid sdk.SessionId, s *session, k *sink, req command.AskRequest, parseErr error) (sdk.PromptResponse, error) {
23+ if parseErr != nil {
24+ k.Text("⚠️ " + parseErr.Error())
25+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
26+ }
27+ k.Text(ask.Label(req) + "\n\n")
28+ exchange, err := f.asker.Run(turnCtx, req, s.messages, s.cwd, k)
29+ switch {
30+ case turnCtx.Err() != nil:
31+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil
32+ case err != nil:
33+ fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s failed: %v\n", sid, req.Agent, err)
34+ f.fail(sid, s, store.NewFailure(s.messages, store.FailAsk, err.Error(), err))
35+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": err.Error()})
36+ }
37+ s.messages = append(s.messages, exchange...)
38+ f.persist(sid, s)
39+ fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s answered\n", sid, req.Agent)
40+ f.sendUsage(context.WithoutCancel(ctx), sid, contextUsed(s))
41+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
42+}
added internal/acp/ask_test.go +89 -0
new file mode 100644
@@ -0,0 +1,89 @@
1+package acp
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "mm/internal/command"
9+ "mm/internal/config"
10+ "mm/internal/engine"
11+ "mm/internal/ui"
12+
13+ sdk "github.com/coder/acp-go-sdk"
14+
15+ "github.com/firebase/genkit/go/ai"
16+)
17+
18+// fakeAsker answers through the sink, as the real pool streams.
19+type fakeAsker struct{ answer string }
20+
21+func (a fakeAsker) Run(_ context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
22+ sink.Text(a.answer)
23+ return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil
24+}
25+
26+// withBob configures one agent and its generated commands for one test.
27+func withBob(t *testing.T) {
28+ t.Helper()
29+ saved := config.Cfg
30+ t.Cleanup(func() { config.Cfg = saved; command.SetAgents(nil) })
31+ config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"}
32+ command.SetAgents([]string{"bob"})
33+}
34+
35+// The editor is offered one command per agent, each with the <prompt> hint,
36+// and never the generic /ask, whose argument it could not complete.
37+func TestAskCommandsAreAnnouncedPerAgent(t *testing.T) {
38+ withBob(t)
39+ hints := map[string]string{}
40+ for _, c := range availableCommands() {
41+ if c.Input != nil && c.Input.Unstructured != nil {
42+ hints[c.Name] = c.Input.Unstructured.Hint
43+ } else {
44+ hints[c.Name] = ""
45+ }
46+ }
47+ for _, name := range []string{"ask-bob", "ask-with-context-bob"} {
48+ if hint, ok := hints[name]; !ok || hint != "<prompt>" {
49+ t.Errorf("%s: announced %v with hint %q, want <prompt>", name, ok, hint)
50+ }
51+ }
52+ if _, ok := hints["ask"]; ok {
53+ t.Error("the generic /ask is announced to the editor")
54+ }
55+}
56+
57+// /ask-bob over the wire: bob's answer streams as agent text after the label,
58+// the turn ends normally, and the exchange is in the session's history.
59+func TestAskOverACP(t *testing.T) {
60+ withBob(t)
61+ w := newWire(t, "sys")
62+ f := frontOf(t, w)
63+ f.e = &engine.Engine{}
64+ f.asker = fakeAsker{answer: "four"}
65+ w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`)
66+ w.until(1)
67+ w.send(2, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`)
68+ _, resp := w.until(2)
69+ sid := resp["result"].(map[string]any)["sessionId"].(string)
70+
71+ w.send(3, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/ask-bob what is 2+2?"}]}`)
72+ notes, resp := w.until(3)
73+ if stop := resp["result"].(map[string]any)["stopReason"]; stop != "end_turn" {
74+ t.Errorf("stopReason = %v", stop)
75+ }
76+ var text strings.Builder
77+ for _, n := range notes {
78+ if n["sessionUpdate"] == "agent_message_chunk" {
79+ text.WriteString(n["content"].(map[string]any)["text"].(string))
80+ }
81+ }
82+ if got := text.String(); !strings.Contains(got, "🗣 asking bob") || !strings.HasSuffix(got, "four") {
83+ t.Errorf("agent text = %q", got)
84+ }
85+ msgs := f.sessions[sdk.SessionId(sid)].messages
86+ if len(msgs) != 3 || msgs[2].Text() != "four" {
87+ t.Errorf("history: %d messages", len(msgs))
88+ }
89+}
new file mode 100644
@@ -0,0 +1,89 @@
1+package acp
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "mm/internal/command"
9+ "mm/internal/config"
10+ "mm/internal/engine"
11+ "mm/internal/ui"
12+
13+ sdk "github.com/coder/acp-go-sdk"
14+
15+ "github.com/firebase/genkit/go/ai"
16+)
17+
18+// fakeAsker answers through the sink, as the real pool streams.
19+type fakeAsker struct{ answer string }
20+
21+func (a fakeAsker) Run(_ context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
22+ sink.Text(a.answer)
23+ return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil
24+}
25+
26+// withBob configures one agent and its generated commands for one test.
27+func withBob(t *testing.T) {
28+ t.Helper()
29+ saved := config.Cfg
30+ t.Cleanup(func() { config.Cfg = saved; command.SetAgents(nil) })
31+ config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"}
32+ command.SetAgents([]string{"bob"})
33+}
34+
35+// The editor is offered one command per agent, each with the <prompt> hint,
36+// and never the generic /ask, whose argument it could not complete.
37+func TestAskCommandsAreAnnouncedPerAgent(t *testing.T) {
38+ withBob(t)
39+ hints := map[string]string{}
40+ for _, c := range availableCommands() {
41+ if c.Input != nil && c.Input.Unstructured != nil {
42+ hints[c.Name] = c.Input.Unstructured.Hint
43+ } else {
44+ hints[c.Name] = ""
45+ }
46+ }
47+ for _, name := range []string{"ask-bob", "ask-with-context-bob"} {
48+ if hint, ok := hints[name]; !ok || hint != "<prompt>" {
49+ t.Errorf("%s: announced %v with hint %q, want <prompt>", name, ok, hint)
50+ }
51+ }
52+ if _, ok := hints["ask"]; ok {
53+ t.Error("the generic /ask is announced to the editor")
54+ }
55+}
56+
57+// /ask-bob over the wire: bob's answer streams as agent text after the label,
58+// the turn ends normally, and the exchange is in the session's history.
59+func TestAskOverACP(t *testing.T) {
60+ withBob(t)
61+ w := newWire(t, "sys")
62+ f := frontOf(t, w)
63+ f.e = &engine.Engine{}
64+ f.asker = fakeAsker{answer: "four"}
65+ w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`)
66+ w.until(1)
67+ w.send(2, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`)
68+ _, resp := w.until(2)
69+ sid := resp["result"].(map[string]any)["sessionId"].(string)
70+
71+ w.send(3, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/ask-bob what is 2+2?"}]}`)
72+ notes, resp := w.until(3)
73+ if stop := resp["result"].(map[string]any)["stopReason"]; stop != "end_turn" {
74+ t.Errorf("stopReason = %v", stop)
75+ }
76+ var text strings.Builder
77+ for _, n := range notes {
78+ if n["sessionUpdate"] == "agent_message_chunk" {
79+ text.WriteString(n["content"].(map[string]any)["text"].(string))
80+ }
81+ }
82+ if got := text.String(); !strings.Contains(got, "🗣 asking bob") || !strings.HasSuffix(got, "four") {
83+ t.Errorf("agent text = %q", got)
84+ }
85+ msgs := f.sessions[sdk.SessionId(sid)].messages
86+ if len(msgs) != 3 || msgs[2].Text() != "four" {
87+ t.Errorf("history: %d messages", len(msgs))
88+ }
89+}
modified internal/agent/agent.go +20 -1
@@ -10,6 +10,7 @@ import (
1010 "os/signal"
1111 "strings"
1212
13+ "mm/internal/ask"
1314 "mm/internal/clipboard"
1415 "mm/internal/command"
1516 "mm/internal/compact"
@@ -38,7 +39,9 @@ type genResult struct {
3839
3940 // Run starts the loop: read user input → generate (with automatic tool calls) →
4041 // print → start over, keeping the history.
41-func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) {
42+//
43+// pool runs the /ask commands (another mm agent); nil when none is configured.
44+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector, pool ask.Runner) {
4245 messages := session.Fresh(system)
4346 ctx, cancelAll := context.WithCancel(ctx)
4447 defer cancelAll()
@@ -140,6 +143,22 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
140143 // DURING a generation, such a command goes through the "new command
141144 // received" branch below, which cancels the turn and re-injects it
142145 // here — so it always applies to the finished history.
146+ // /ask-bob and its family: another agent answers, and its answer
147+ // joins the history for the next question. Between two generations
148+ // too, for the same reason as the commands below.
149+ if req, ok, err := command.ParseAsk(input); ok {
150+ if err != nil {
151+ fmt.Println("⚠️ " + err.Error())
152+ continue
153+ }
154+ var quit bool
155+ messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh}.run(ctx, req, messages)
156+ if quit {
157+ fmt.Println("Goodbye.")
158+ return
159+ }
160+ continue
161+ }
143162 if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled {
144163 messages = next
145164 continue
@@ -10,6 +10,7 @@ import (
10 "os/signal"10 "os/signal"
11 "strings"11 "strings"
12 12
13+ "mm/internal/ask"
13 "mm/internal/clipboard"14 "mm/internal/clipboard"
14 "mm/internal/command"15 "mm/internal/command"
15 "mm/internal/compact"16 "mm/internal/compact"
@@ -38,7 +39,9 @@ type genResult struct {
38 39
39 // Run starts the loop: read user input → generate (with automatic tool calls) →40 // Run starts the loop: read user input → generate (with automatic tool calls) →
40 // print → start over, keeping the history.41 // print → start over, keeping the history.
41-func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) {42+//
43+// pool runs the /ask commands (another mm agent); nil when none is configured.
44+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector, pool ask.Runner) {
42 messages := session.Fresh(system)45 messages := session.Fresh(system)
43 ctx, cancelAll := context.WithCancel(ctx)46 ctx, cancelAll := context.WithCancel(ctx)
44 defer cancelAll()47 defer cancelAll()
@@ -140,6 +143,22 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
140 // DURING a generation, such a command goes through the "new command143 // DURING a generation, such a command goes through the "new command
141 // received" branch below, which cancels the turn and re-injects it144 // received" branch below, which cancels the turn and re-injects it
142 // here — so it always applies to the finished history.145 // here — so it always applies to the finished history.
146+ // /ask-bob and its family: another agent answers, and its answer
147+ // joins the history for the next question. Between two generations
148+ // too, for the same reason as the commands below.
149+ if req, ok, err := command.ParseAsk(input); ok {
150+ if err != nil {
151+ fmt.Println("⚠️ " + err.Error())
152+ continue
153+ }
154+ var quit bool
155+ messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh}.run(ctx, req, messages)
156+ if quit {
157+ fmt.Println("Goodbye.")
158+ return
159+ }
160+ continue
161+ }
143 if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled {162 if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled {
144 messages = next163 messages = next
145 continue164 continue
added internal/agent/ask.go +125 -0
new file mode 100644
@@ -0,0 +1,125 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "mm/internal/ask"
9+ "mm/internal/command"
10+ "mm/internal/config"
11+ "mm/internal/spinner"
12+ "mm/internal/store"
13+ "mm/internal/ui"
14+
15+ "github.com/firebase/genkit/go/ai"
16+)
17+
18+// asking is what the REPL needs to run one /ask: the pool of agents, where
19+// the session lives, and the two channels a generation also listens to, so
20+// that Ctrl+C, /abort and /quit stop bob exactly as they stop a turn.
21+type asking struct {
22+ pool ask.Runner
23+ cwd string
24+ keep *store.Keeper
25+ sigCh <-chan os.Signal
26+ inputCh <-chan string
27+}
28+
29+// run asks req.Agent, streaming its answer and tool calls, and returns the
30+// history with the exchange appended — unchanged when the ask failed or was
31+// stopped. quit is true when /quit was typed meanwhile.
32+func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.Message) (out []*ai.Message, quit bool) {
33+ dimln(ask.Label(req))
34+ askCtx, cancel := context.WithCancel(ctx)
35+ defer cancel()
36+ type result struct {
37+ exchange []*ai.Message
38+ err error
39+ }
40+ done := make(chan result, 1)
41+ go func() {
42+ exchange, err := a.pool.Run(askCtx, req, messages, a.cwd, &lineSink{cwd: a.cwd})
43+ done <- result{exchange, err}
44+ }()
45+
46+ for {
47+ select {
48+ case res := <-done:
49+ return a.settle(askCtx, req, messages, res.exchange, res.err), quit
50+ case <-a.sigCh:
51+ fmt.Println("\n🛑 Interrupted (Ctrl+C).")
52+ cancel()
53+ case input := <-a.inputCh:
54+ switch {
55+ case command.Is(input, command.Abort):
56+ fmt.Println("🛑 Aborting…")
57+ cancel()
58+ case command.Is(input, command.Quit):
59+ quit = true
60+ cancel()
61+ default:
62+ fmt.Printf("⚠️ %s is still answering — /abort to stop it.\n", req.Agent)
63+ }
64+ }
65+ }
66+}
67+
68+// settle reports how the ask ended and keeps the exchange when there is one.
69+func (a asking) settle(askCtx context.Context, req command.AskRequest, messages, exchange []*ai.Message, err error) []*ai.Message {
70+ fmt.Println()
71+ switch {
72+ case err != nil && askCtx.Err() != nil:
73+ fmt.Println("🛑 Ask aborted — nothing kept.")
74+ return messages
75+ case err != nil:
76+ fmt.Println("[error: " + err.Error() + "]")
77+ record(a.keep, messages, store.NewFailure(messages, store.FailAsk, err.Error(), err))
78+ return messages
79+ }
80+ messages = append(messages, exchange...)
81+ save(a.keep, messages)
82+ dimln(fmt.Sprintf("🗣 %s's answer is in the history for your next question", req.Agent))
83+ return messages
84+}
85+
86+// lineSink shows the other agent in the line mode: its answer as it streams,
87+// its thoughts dimmed, one line per tool call. Like the line mode's own
88+// tools, its calls are not gated: this front end has no permission prompt.
89+type lineSink struct {
90+ cwd string
91+}
92+
93+var _ ui.Sink = (*lineSink)(nil)
94+
95+func (s *lineSink) Text(text string) {
96+ spinner.Stop()
97+ fmt.Print(text)
98+}
99+
100+func (s *lineSink) Reasoning(text string) {
101+ if !config.Cfg.ShowThinking {
102+ return
103+ }
104+ spinner.Stop()
105+ if spinner.Styled() {
106+ fmt.Printf("\033[2;3m%s\033[0m", text)
107+ return
108+ }
109+ fmt.Print(text)
110+}
111+
112+func (s *lineSink) ToolStart(ev ui.ToolEvent) {
113+ spinner.Stop()
114+ fmt.Printf("\n🛠️ %s\n", ev.Title)
115+}
116+
117+func (s *lineSink) ToolEnd(_ string, res ui.ToolResult) {
118+ if res.Failed {
119+ dimln(" │ failed")
120+ }
121+}
122+
123+func (s *lineSink) Allow(context.Context, ui.ToolEvent) bool { return true }
124+func (s *lineSink) ToolRunning(string) {}
125+func (s *lineSink) WorkDir() string { return s.cwd }
new file mode 100644
@@ -0,0 +1,125 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "mm/internal/ask"
9+ "mm/internal/command"
10+ "mm/internal/config"
11+ "mm/internal/spinner"
12+ "mm/internal/store"
13+ "mm/internal/ui"
14+
15+ "github.com/firebase/genkit/go/ai"
16+)
17+
18+// asking is what the REPL needs to run one /ask: the pool of agents, where
19+// the session lives, and the two channels a generation also listens to, so
20+// that Ctrl+C, /abort and /quit stop bob exactly as they stop a turn.
21+type asking struct {
22+ pool ask.Runner
23+ cwd string
24+ keep *store.Keeper
25+ sigCh <-chan os.Signal
26+ inputCh <-chan string
27+}
28+
29+// run asks req.Agent, streaming its answer and tool calls, and returns the
30+// history with the exchange appended — unchanged when the ask failed or was
31+// stopped. quit is true when /quit was typed meanwhile.
32+func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.Message) (out []*ai.Message, quit bool) {
33+ dimln(ask.Label(req))
34+ askCtx, cancel := context.WithCancel(ctx)
35+ defer cancel()
36+ type result struct {
37+ exchange []*ai.Message
38+ err error
39+ }
40+ done := make(chan result, 1)
41+ go func() {
42+ exchange, err := a.pool.Run(askCtx, req, messages, a.cwd, &lineSink{cwd: a.cwd})
43+ done <- result{exchange, err}
44+ }()
45+
46+ for {
47+ select {
48+ case res := <-done:
49+ return a.settle(askCtx, req, messages, res.exchange, res.err), quit
50+ case <-a.sigCh:
51+ fmt.Println("\n🛑 Interrupted (Ctrl+C).")
52+ cancel()
53+ case input := <-a.inputCh:
54+ switch {
55+ case command.Is(input, command.Abort):
56+ fmt.Println("🛑 Aborting…")
57+ cancel()
58+ case command.Is(input, command.Quit):
59+ quit = true
60+ cancel()
61+ default:
62+ fmt.Printf("⚠️ %s is still answering — /abort to stop it.\n", req.Agent)
63+ }
64+ }
65+ }
66+}
67+
68+// settle reports how the ask ended and keeps the exchange when there is one.
69+func (a asking) settle(askCtx context.Context, req command.AskRequest, messages, exchange []*ai.Message, err error) []*ai.Message {
70+ fmt.Println()
71+ switch {
72+ case err != nil && askCtx.Err() != nil:
73+ fmt.Println("🛑 Ask aborted — nothing kept.")
74+ return messages
75+ case err != nil:
76+ fmt.Println("[error: " + err.Error() + "]")
77+ record(a.keep, messages, store.NewFailure(messages, store.FailAsk, err.Error(), err))
78+ return messages
79+ }
80+ messages = append(messages, exchange...)
81+ save(a.keep, messages)
82+ dimln(fmt.Sprintf("🗣 %s's answer is in the history for your next question", req.Agent))
83+ return messages
84+}
85+
86+// lineSink shows the other agent in the line mode: its answer as it streams,
87+// its thoughts dimmed, one line per tool call. Like the line mode's own
88+// tools, its calls are not gated: this front end has no permission prompt.
89+type lineSink struct {
90+ cwd string
91+}
92+
93+var _ ui.Sink = (*lineSink)(nil)
94+
95+func (s *lineSink) Text(text string) {
96+ spinner.Stop()
97+ fmt.Print(text)
98+}
99+
100+func (s *lineSink) Reasoning(text string) {
101+ if !config.Cfg.ShowThinking {
102+ return
103+ }
104+ spinner.Stop()
105+ if spinner.Styled() {
106+ fmt.Printf("\033[2;3m%s\033[0m", text)
107+ return
108+ }
109+ fmt.Print(text)
110+}
111+
112+func (s *lineSink) ToolStart(ev ui.ToolEvent) {
113+ spinner.Stop()
114+ fmt.Printf("\n🛠️ %s\n", ev.Title)
115+}
116+
117+func (s *lineSink) ToolEnd(_ string, res ui.ToolResult) {
118+ if res.Failed {
119+ dimln(" │ failed")
120+ }
121+}
122+
123+func (s *lineSink) Allow(context.Context, ui.ToolEvent) bool { return true }
124+func (s *lineSink) ToolRunning(string) {}
125+func (s *lineSink) WorkDir() string { return s.cwd }
added internal/agent/ask_test.go +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "testing"
8+
9+ "mm/internal/command"
10+ "mm/internal/config"
11+ "mm/internal/store"
12+ "mm/internal/ui"
13+
14+ "github.com/firebase/genkit/go/ai"
15+)
16+
17+// fakeRunner answers, fails, or waits to be cancelled.
18+type fakeRunner struct {
19+ answer string
20+ fail error
21+ block bool
22+}
23+
24+func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
25+ if f.block {
26+ <-ctx.Done()
27+ return nil, ctx.Err()
28+ }
29+ if f.fail != nil {
30+ return nil, f.fail
31+ }
32+ sink.Text(f.answer)
33+ return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + "]"), ai.NewModelTextMessage(f.answer)}, nil
34+}
35+
36+// lineAsk builds an asking with fresh channels and no session file.
37+func lineAsk(t *testing.T, r fakeRunner) (asking, chan os.Signal, chan string) {
38+ t.Helper()
39+ saved := config.Cfg
40+ t.Cleanup(func() { config.Cfg = saved })
41+ config.Cfg.Sessions.Enabled = false
42+ config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"}
43+ sig, in := make(chan os.Signal, 1), make(chan string, 1)
44+ return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in}, sig, in
45+}
46+
47+var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")}
48+
49+// An answered ask appends the exchange.
50+func TestLineAskKeepsTheExchange(t *testing.T) {
51+ a, _, _ := lineAsk(t, fakeRunner{answer: "four"})
52+ got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory)
53+ if quit || len(got) != 3 || got[2].Text() != "four" {
54+ t.Errorf("run = %d messages, quit %v", len(got), quit)
55+ }
56+}
57+
58+// Ctrl+C and /abort stop the ask and keep nothing; /quit also says to leave;
59+// a failure keeps nothing.
60+func TestLineAskStops(t *testing.T) {
61+ a, sig, _ := lineAsk(t, fakeRunner{block: true})
62+ sig <- os.Interrupt
63+ if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 {
64+ t.Errorf("Ctrl+C: %d messages, quit %v", len(got), quit)
65+ }
66+
67+ a, _, in := lineAsk(t, fakeRunner{block: true})
68+ in <- "/abort"
69+ if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 {
70+ t.Errorf("/abort: %d messages, quit %v", len(got), quit)
71+ }
72+
73+ a, _, in = lineAsk(t, fakeRunner{block: true})
74+ in <- "/quit"
75+ if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); !quit || len(got) != 1 {
76+ t.Errorf("/quit: %d messages, quit %v", len(got), quit)
77+ }
78+
79+ a, _, _ = lineAsk(t, fakeRunner{fail: errors.New("bob did not start")})
80+ if got, _ := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); len(got) != 1 {
81+ t.Errorf("failure: %d messages", len(got))
82+ }
83+}
new file mode 100644
@@ -0,0 +1,83 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "testing"
8+
9+ "mm/internal/command"
10+ "mm/internal/config"
11+ "mm/internal/store"
12+ "mm/internal/ui"
13+
14+ "github.com/firebase/genkit/go/ai"
15+)
16+
17+// fakeRunner answers, fails, or waits to be cancelled.
18+type fakeRunner struct {
19+ answer string
20+ fail error
21+ block bool
22+}
23+
24+func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
25+ if f.block {
26+ <-ctx.Done()
27+ return nil, ctx.Err()
28+ }
29+ if f.fail != nil {
30+ return nil, f.fail
31+ }
32+ sink.Text(f.answer)
33+ return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + "]"), ai.NewModelTextMessage(f.answer)}, nil
34+}
35+
36+// lineAsk builds an asking with fresh channels and no session file.
37+func lineAsk(t *testing.T, r fakeRunner) (asking, chan os.Signal, chan string) {
38+ t.Helper()
39+ saved := config.Cfg
40+ t.Cleanup(func() { config.Cfg = saved })
41+ config.Cfg.Sessions.Enabled = false
42+ config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"}
43+ sig, in := make(chan os.Signal, 1), make(chan string, 1)
44+ return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in}, sig, in
45+}
46+
47+var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")}
48+
49+// An answered ask appends the exchange.
50+func TestLineAskKeepsTheExchange(t *testing.T) {
51+ a, _, _ := lineAsk(t, fakeRunner{answer: "four"})
52+ got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory)
53+ if quit || len(got) != 3 || got[2].Text() != "four" {
54+ t.Errorf("run = %d messages, quit %v", len(got), quit)
55+ }
56+}
57+
58+// Ctrl+C and /abort stop the ask and keep nothing; /quit also says to leave;
59+// a failure keeps nothing.
60+func TestLineAskStops(t *testing.T) {
61+ a, sig, _ := lineAsk(t, fakeRunner{block: true})
62+ sig <- os.Interrupt
63+ if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 {
64+ t.Errorf("Ctrl+C: %d messages, quit %v", len(got), quit)
65+ }
66+
67+ a, _, in := lineAsk(t, fakeRunner{block: true})
68+ in <- "/abort"
69+ if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 {
70+ t.Errorf("/abort: %d messages, quit %v", len(got), quit)
71+ }
72+
73+ a, _, in = lineAsk(t, fakeRunner{block: true})
74+ in <- "/quit"
75+ if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); !quit || len(got) != 1 {
76+ t.Errorf("/quit: %d messages, quit %v", len(got), quit)
77+ }
78+
79+ a, _, _ = lineAsk(t, fakeRunner{fail: errors.New("bob did not start")})
80+ if got, _ := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); len(got) != 1 {
81+ t.Errorf("failure: %d messages", len(got))
82+ }
83+}
added internal/ask/client.go +235 -0
new file mode 100644
@@ -0,0 +1,235 @@
1+package ask
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+
8+ "mm/internal/ui"
9+
10+ sdk "github.com/coder/acp-go-sdk"
11+)
12+
13+// client is the ACP client side of one child agent: it receives what the
14+// child streams and turns it into the asking front end's own events, through
15+// the ui.Sink that front end installed — bob's text where the answers go, his
16+// tool calls as tool calls, his permission requests as the asker's own
17+// permission prompt (none in the line mode, the inline prompt in the window,
18+// the editor's dialog in ACP).
19+//
20+// mm's ACP server only ever calls SessionUpdate and RequestPermission; the
21+// file-system and terminal methods are refused, and the capabilities sent in
22+// initialize say they are absent.
23+type client struct {
24+ mu sync.Mutex
25+ agent string // "bob": prefixes the tool calls shown
26+ sid sdk.SessionId // the session of the ask in progress; others are stale
27+ sink ui.Sink // where the events go; nil between two asks
28+ answer strings.Builder
29+ ids map[sdk.ToolCallId]string // the child's call id -> the id shown
30+ // started: the calls already shown. The SDK dispatches the child's
31+ // notifications and requests concurrently, so a permission request can
32+ // overtake the tool_call that announced it (seen in the tests); whichever
33+ // comes first shows the call, the other does not show it twice.
34+ started map[sdk.ToolCallId]bool
35+}
36+
37+var _ sdk.Client = (*client)(nil)
38+
39+// begin points the client at one ask: its session and its sink.
40+func (c *client) begin(agent string, sid sdk.SessionId, sink ui.Sink) {
41+ c.mu.Lock()
42+ defer c.mu.Unlock()
43+ c.agent, c.sid, c.sink = agent, sid, sink
44+ c.answer.Reset()
45+ c.ids = map[sdk.ToolCallId]string{}
46+ c.started = map[sdk.ToolCallId]bool{}
47+}
48+
49+// end detaches the sink and returns the answer as streamed.
50+func (c *client) end() string {
51+ c.mu.Lock()
52+ defer c.mu.Unlock()
53+ c.sink, c.sid = nil, ""
54+ return c.answer.String()
55+}
56+
57+// current returns the sink when sid is the ask in progress, nil otherwise —
58+// a cancelled ask's child may still be talking about its old session.
59+func (c *client) current(sid sdk.SessionId) ui.Sink {
60+ c.mu.Lock()
61+ defer c.mu.Unlock()
62+ if sid != c.sid {
63+ return nil
64+ }
65+ return c.sink
66+}
67+
68+// SessionUpdate routes one streamed update to the sink.
69+func (c *client) SessionUpdate(_ context.Context, n sdk.SessionNotification) error {
70+ sink := c.current(n.SessionId)
71+ if sink == nil {
72+ return nil
73+ }
74+ u := n.Update
75+ switch {
76+ case u.AgentMessageChunk != nil && u.AgentMessageChunk.Content.Text != nil:
77+ text := u.AgentMessageChunk.Content.Text.Text
78+ c.mu.Lock()
79+ c.answer.WriteString(text)
80+ c.mu.Unlock()
81+ sink.Text(text)
82+ case u.AgentThoughtChunk != nil && u.AgentThoughtChunk.Content.Text != nil:
83+ sink.Reasoning(u.AgentThoughtChunk.Content.Text.Text) // shown as thoughts, never kept
84+ case u.ToolCall != nil:
85+ c.toolStart(sink, u.ToolCall)
86+ case u.ToolCallUpdate != nil:
87+ c.toolUpdate(sink, u.ToolCallUpdate)
88+ }
89+ return nil
90+}
91+
92+// toolStart shows one of the child's tool calls as the asker's own, named
93+// after the child: "bob · bash: ls -1".
94+func (c *client) toolStart(sink ui.Sink, call *sdk.SessionUpdateToolCall) {
95+ ev := c.event(call.ToolCallId, call.Title, string(call.Kind), call.RawInput)
96+ if c.firstSight(call.ToolCallId) {
97+ sink.ToolStart(ev)
98+ }
99+ if call.Status == sdk.ToolCallStatusInProgress {
100+ sink.ToolRunning(ev.ID)
101+ }
102+}
103+
104+// firstSight records a call as shown and reports whether it was not yet.
105+func (c *client) firstSight(id sdk.ToolCallId) bool {
106+ c.mu.Lock()
107+ defer c.mu.Unlock()
108+ if c.started == nil || c.started[id] {
109+ return false
110+ }
111+ c.started[id] = true
112+ return true
113+}
114+
115+// toolUpdate follows a call's status: running, then done or failed.
116+func (c *client) toolUpdate(sink ui.Sink, up *sdk.SessionToolCallUpdate) {
117+ if up.Status == nil {
118+ return
119+ }
120+ id := c.shownID(up.ToolCallId)
121+ switch *up.Status {
122+ case sdk.ToolCallStatusInProgress:
123+ sink.ToolRunning(id)
124+ case sdk.ToolCallStatusCompleted, sdk.ToolCallStatusFailed:
125+ sink.ToolEnd(id, ui.ToolResult{Output: contentText(up.Content), Failed: *up.Status == sdk.ToolCallStatusFailed})
126+ }
127+}
128+
129+// RequestPermission asks the asker's user, through the asker's front end,
130+// whether the child may run a call; the answer is one of the options the
131+// child offered (allow once / reject once), or "cancelled" when it offered
132+// none that fits.
133+func (c *client) RequestPermission(ctx context.Context, p sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {
134+ sink := c.current(p.SessionId)
135+ title := ""
136+ if p.ToolCall.Title != nil {
137+ title = *p.ToolCall.Title
138+ }
139+ // A stale request — its ask was cancelled — is refused: nobody is there
140+ // to answer it.
141+ allowed := false
142+ if sink != nil {
143+ ev := c.event(p.ToolCall.ToolCallId, title, "", p.ToolCall.RawInput)
144+ if c.firstSight(p.ToolCall.ToolCallId) {
145+ sink.ToolStart(ev) // the request overtook the tool_call: show it first
146+ }
147+ allowed = sink.Allow(ctx, ev)
148+ }
149+ want := []sdk.PermissionOptionKind{sdk.PermissionOptionKindRejectOnce, sdk.PermissionOptionKindRejectAlways}
150+ if allowed {
151+ want = []sdk.PermissionOptionKind{sdk.PermissionOptionKindAllowOnce, sdk.PermissionOptionKindAllowAlways}
152+ }
153+ for _, kind := range want {
154+ for _, o := range p.Options {
155+ if o.Kind == kind {
156+ return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Selected: &sdk.RequestPermissionOutcomeSelected{OptionId: o.OptionId}}}, nil
157+ }
158+ }
159+ }
160+ return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Cancelled: &sdk.RequestPermissionOutcomeCancelled{}}}, nil
161+}
162+
163+// event builds the ui.ToolEvent the asker shows for one of the child's calls.
164+// Tool is "<agent>/<tool>" so that "allow for this session" in the window
165+// grants bob's bash, not the asker's.
166+func (c *client) event(id sdk.ToolCallId, title, kind string, raw any) ui.ToolEvent {
167+ tool, _, _ := strings.Cut(title, ":")
168+ input, _ := raw.(map[string]any)
169+ c.mu.Lock()
170+ agent := c.agent
171+ c.mu.Unlock()
172+ return ui.ToolEvent{
173+ ID: c.shownID(id),
174+ Tool: agent + "/" + strings.TrimSpace(tool),
175+ Title: agent + " · " + title,
176+ Kind: kind,
177+ Input: input,
178+ }
179+}
180+
181+// shownID maps the child's call id to one unique in this process: two
182+// children both number their calls call_1, call_2…
183+func (c *client) shownID(id sdk.ToolCallId) string {
184+ c.mu.Lock()
185+ defer c.mu.Unlock()
186+ if shown, ok := c.ids[id]; ok {
187+ return shown
188+ }
189+ shown := ui.NextCallID()
190+ if c.ids != nil {
191+ c.ids[id] = shown
192+ }
193+ return shown
194+}
195+
196+// contentText joins the text blocks of a tool call's content.
197+func contentText(content []sdk.ToolCallContent) string {
198+ var b strings.Builder
199+ for _, item := range content {
200+ if item.Content != nil && item.Content.Content.Text != nil {
201+ b.WriteString(item.Content.Content.Text.Text)
202+ }
203+ }
204+ return b.String()
205+}
206+
207+// --- what mm's ACP server never asks for, refused as absent -----------------
208+
209+func (c *client) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) {
210+ return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile)
211+}
212+
213+func (c *client) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) {
214+ return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile)
215+}
216+
217+func (c *client) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) {
218+ return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate)
219+}
220+
221+func (c *client) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) {
222+ return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill)
223+}
224+
225+func (c *client) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) {
226+ return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput)
227+}
228+
229+func (c *client) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) {
230+ return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease)
231+}
232+
233+func (c *client) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) {
234+ return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit)
235+}
new file mode 100644
@@ -0,0 +1,235 @@
1+package ask
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+
8+ "mm/internal/ui"
9+
10+ sdk "github.com/coder/acp-go-sdk"
11+)
12+
13+// client is the ACP client side of one child agent: it receives what the
14+// child streams and turns it into the asking front end's own events, through
15+// the ui.Sink that front end installed — bob's text where the answers go, his
16+// tool calls as tool calls, his permission requests as the asker's own
17+// permission prompt (none in the line mode, the inline prompt in the window,
18+// the editor's dialog in ACP).
19+//
20+// mm's ACP server only ever calls SessionUpdate and RequestPermission; the
21+// file-system and terminal methods are refused, and the capabilities sent in
22+// initialize say they are absent.
23+type client struct {
24+ mu sync.Mutex
25+ agent string // "bob": prefixes the tool calls shown
26+ sid sdk.SessionId // the session of the ask in progress; others are stale
27+ sink ui.Sink // where the events go; nil between two asks
28+ answer strings.Builder
29+ ids map[sdk.ToolCallId]string // the child's call id -> the id shown
30+ // started: the calls already shown. The SDK dispatches the child's
31+ // notifications and requests concurrently, so a permission request can
32+ // overtake the tool_call that announced it (seen in the tests); whichever
33+ // comes first shows the call, the other does not show it twice.
34+ started map[sdk.ToolCallId]bool
35+}
36+
37+var _ sdk.Client = (*client)(nil)
38+
39+// begin points the client at one ask: its session and its sink.
40+func (c *client) begin(agent string, sid sdk.SessionId, sink ui.Sink) {
41+ c.mu.Lock()
42+ defer c.mu.Unlock()
43+ c.agent, c.sid, c.sink = agent, sid, sink
44+ c.answer.Reset()
45+ c.ids = map[sdk.ToolCallId]string{}
46+ c.started = map[sdk.ToolCallId]bool{}
47+}
48+
49+// end detaches the sink and returns the answer as streamed.
50+func (c *client) end() string {
51+ c.mu.Lock()
52+ defer c.mu.Unlock()
53+ c.sink, c.sid = nil, ""
54+ return c.answer.String()
55+}
56+
57+// current returns the sink when sid is the ask in progress, nil otherwise —
58+// a cancelled ask's child may still be talking about its old session.
59+func (c *client) current(sid sdk.SessionId) ui.Sink {
60+ c.mu.Lock()
61+ defer c.mu.Unlock()
62+ if sid != c.sid {
63+ return nil
64+ }
65+ return c.sink
66+}
67+
68+// SessionUpdate routes one streamed update to the sink.
69+func (c *client) SessionUpdate(_ context.Context, n sdk.SessionNotification) error {
70+ sink := c.current(n.SessionId)
71+ if sink == nil {
72+ return nil
73+ }
74+ u := n.Update
75+ switch {
76+ case u.AgentMessageChunk != nil && u.AgentMessageChunk.Content.Text != nil:
77+ text := u.AgentMessageChunk.Content.Text.Text
78+ c.mu.Lock()
79+ c.answer.WriteString(text)
80+ c.mu.Unlock()
81+ sink.Text(text)
82+ case u.AgentThoughtChunk != nil && u.AgentThoughtChunk.Content.Text != nil:
83+ sink.Reasoning(u.AgentThoughtChunk.Content.Text.Text) // shown as thoughts, never kept
84+ case u.ToolCall != nil:
85+ c.toolStart(sink, u.ToolCall)
86+ case u.ToolCallUpdate != nil:
87+ c.toolUpdate(sink, u.ToolCallUpdate)
88+ }
89+ return nil
90+}
91+
92+// toolStart shows one of the child's tool calls as the asker's own, named
93+// after the child: "bob · bash: ls -1".
94+func (c *client) toolStart(sink ui.Sink, call *sdk.SessionUpdateToolCall) {
95+ ev := c.event(call.ToolCallId, call.Title, string(call.Kind), call.RawInput)
96+ if c.firstSight(call.ToolCallId) {
97+ sink.ToolStart(ev)
98+ }
99+ if call.Status == sdk.ToolCallStatusInProgress {
100+ sink.ToolRunning(ev.ID)
101+ }
102+}
103+
104+// firstSight records a call as shown and reports whether it was not yet.
105+func (c *client) firstSight(id sdk.ToolCallId) bool {
106+ c.mu.Lock()
107+ defer c.mu.Unlock()
108+ if c.started == nil || c.started[id] {
109+ return false
110+ }
111+ c.started[id] = true
112+ return true
113+}
114+
115+// toolUpdate follows a call's status: running, then done or failed.
116+func (c *client) toolUpdate(sink ui.Sink, up *sdk.SessionToolCallUpdate) {
117+ if up.Status == nil {
118+ return
119+ }
120+ id := c.shownID(up.ToolCallId)
121+ switch *up.Status {
122+ case sdk.ToolCallStatusInProgress:
123+ sink.ToolRunning(id)
124+ case sdk.ToolCallStatusCompleted, sdk.ToolCallStatusFailed:
125+ sink.ToolEnd(id, ui.ToolResult{Output: contentText(up.Content), Failed: *up.Status == sdk.ToolCallStatusFailed})
126+ }
127+}
128+
129+// RequestPermission asks the asker's user, through the asker's front end,
130+// whether the child may run a call; the answer is one of the options the
131+// child offered (allow once / reject once), or "cancelled" when it offered
132+// none that fits.
133+func (c *client) RequestPermission(ctx context.Context, p sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {
134+ sink := c.current(p.SessionId)
135+ title := ""
136+ if p.ToolCall.Title != nil {
137+ title = *p.ToolCall.Title
138+ }
139+ // A stale request — its ask was cancelled — is refused: nobody is there
140+ // to answer it.
141+ allowed := false
142+ if sink != nil {
143+ ev := c.event(p.ToolCall.ToolCallId, title, "", p.ToolCall.RawInput)
144+ if c.firstSight(p.ToolCall.ToolCallId) {
145+ sink.ToolStart(ev) // the request overtook the tool_call: show it first
146+ }
147+ allowed = sink.Allow(ctx, ev)
148+ }
149+ want := []sdk.PermissionOptionKind{sdk.PermissionOptionKindRejectOnce, sdk.PermissionOptionKindRejectAlways}
150+ if allowed {
151+ want = []sdk.PermissionOptionKind{sdk.PermissionOptionKindAllowOnce, sdk.PermissionOptionKindAllowAlways}
152+ }
153+ for _, kind := range want {
154+ for _, o := range p.Options {
155+ if o.Kind == kind {
156+ return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Selected: &sdk.RequestPermissionOutcomeSelected{OptionId: o.OptionId}}}, nil
157+ }
158+ }
159+ }
160+ return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Cancelled: &sdk.RequestPermissionOutcomeCancelled{}}}, nil
161+}
162+
163+// event builds the ui.ToolEvent the asker shows for one of the child's calls.
164+// Tool is "<agent>/<tool>" so that "allow for this session" in the window
165+// grants bob's bash, not the asker's.
166+func (c *client) event(id sdk.ToolCallId, title, kind string, raw any) ui.ToolEvent {
167+ tool, _, _ := strings.Cut(title, ":")
168+ input, _ := raw.(map[string]any)
169+ c.mu.Lock()
170+ agent := c.agent
171+ c.mu.Unlock()
172+ return ui.ToolEvent{
173+ ID: c.shownID(id),
174+ Tool: agent + "/" + strings.TrimSpace(tool),
175+ Title: agent + " · " + title,
176+ Kind: kind,
177+ Input: input,
178+ }
179+}
180+
181+// shownID maps the child's call id to one unique in this process: two
182+// children both number their calls call_1, call_2…
183+func (c *client) shownID(id sdk.ToolCallId) string {
184+ c.mu.Lock()
185+ defer c.mu.Unlock()
186+ if shown, ok := c.ids[id]; ok {
187+ return shown
188+ }
189+ shown := ui.NextCallID()
190+ if c.ids != nil {
191+ c.ids[id] = shown
192+ }
193+ return shown
194+}
195+
196+// contentText joins the text blocks of a tool call's content.
197+func contentText(content []sdk.ToolCallContent) string {
198+ var b strings.Builder
199+ for _, item := range content {
200+ if item.Content != nil && item.Content.Content.Text != nil {
201+ b.WriteString(item.Content.Content.Text.Text)
202+ }
203+ }
204+ return b.String()
205+}
206+
207+// --- what mm's ACP server never asks for, refused as absent -----------------
208+
209+func (c *client) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) {
210+ return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile)
211+}
212+
213+func (c *client) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) {
214+ return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile)
215+}
216+
217+func (c *client) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) {
218+ return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate)
219+}
220+
221+func (c *client) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) {
222+ return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill)
223+}
224+
225+func (c *client) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) {
226+ return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput)
227+}
228+
229+func (c *client) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) {
230+ return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease)
231+}
232+
233+func (c *client) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) {
234+ return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit)
235+}
added internal/ask/context.go +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+package ask
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/compact"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// Transcript renders the conversation for /ask-with-context: what was asked
13+// and answered, and which tools ran — one line each, with their arguments
14+// shortened — but never the tools' outputs, the system prompt or any
15+// reasoning. The other agent needs to know what happened, not to re-read
16+// every file; and its window is not ours.
17+//
18+// The newest turns are kept first: when the whole history does not fit in
19+// maxChars, the oldest lines go, and a marker says so.
20+//
21+// text := ask.Transcript(messages, config.Cfg.MaxOutput)
22+func Transcript(messages []*ai.Message, maxChars int) string {
23+ var lines []string
24+ for _, m := range messages {
25+ lines = append(lines, transcriptLines(m)...)
26+ }
27+ return keepNewest(lines, maxChars)
28+}
29+
30+// transcriptLines is one message, as the transcript shows it.
31+func transcriptLines(m *ai.Message) []string {
32+ if m == nil {
33+ return nil
34+ }
35+ switch {
36+ case compact.IsSummary(m):
37+ return []string{"[Summary of the earlier conversation]\n" + strings.TrimSpace(m.Text())}
38+ case m.Role == ai.RoleUser:
39+ if text := strings.TrimSpace(m.Text()); text != "" {
40+ return []string{"User: " + text}
41+ }
42+ case m.Role == ai.RoleModel:
43+ return modelLines(m)
44+ }
45+ return nil // system prompt, tool outputs
46+}
47+
48+// modelLines is an assistant message: its text, then one line per tool call.
49+func modelLines(m *ai.Message) []string {
50+ var lines []string
51+ if text := strings.TrimSpace(m.Text()); text != "" {
52+ lines = append(lines, "Assistant: "+text)
53+ }
54+ for _, part := range m.Content {
55+ if part.IsToolRequest() && part.ToolRequest != nil {
56+ lines = append(lines, fmt.Sprintf("Assistant ran %s: %s", part.ToolRequest.Name, shorten(fmt.Sprint(part.ToolRequest.Input), 160)))
57+ }
58+ }
59+ return lines
60+}
61+
62+// keepNewest joins lines, dropping the oldest ones that do not fit.
63+func keepNewest(lines []string, maxChars int) string {
64+ const omitted = "[… earlier conversation omitted …]"
65+ size, first := 0, len(lines)
66+ for first > 0 {
67+ next := len([]rune(lines[first-1])) + 1
68+ if size+next > maxChars-len(omitted)-1 {
69+ break
70+ }
71+ size += next
72+ first--
73+ }
74+ kept := strings.Join(lines[first:], "\n")
75+ if first > 0 {
76+ return omitted + "\n" + kept
77+ }
78+ return kept
79+}
80+
81+// shorten cuts s to n characters on one line.
82+func shorten(s string, n int) string {
83+ s = strings.Join(strings.Fields(s), " ")
84+ if r := []rune(s); len(r) > n {
85+ return string(r[:n]) + "…"
86+ }
87+ return s
88+}
89+
90+// Prompt is what the other agent receives: the question alone, or — for
91+// /ask-with-context — the transcript first, clearly separated, so it can tell
92+// the conversation it is shown from the question it must answer.
93+//
94+// ask.Prompt("is this safe?", "") // "is this safe?"
95+// ask.Prompt("is this safe?", ask.Transcript(…)) // "[Conversation so far…]\n…\n\n[Question]\nis this safe?"
96+func Prompt(question, transcript string) string {
97+ if transcript == "" {
98+ return question
99+ }
100+ return "[Conversation so far, between the user and another assistant]\n" + transcript + "\n\n[Question]\n" + question
101+}
new file mode 100644
@@ -0,0 +1,101 @@
1+package ask
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/compact"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// Transcript renders the conversation for /ask-with-context: what was asked
13+// and answered, and which tools ran — one line each, with their arguments
14+// shortened — but never the tools' outputs, the system prompt or any
15+// reasoning. The other agent needs to know what happened, not to re-read
16+// every file; and its window is not ours.
17+//
18+// The newest turns are kept first: when the whole history does not fit in
19+// maxChars, the oldest lines go, and a marker says so.
20+//
21+// text := ask.Transcript(messages, config.Cfg.MaxOutput)
22+func Transcript(messages []*ai.Message, maxChars int) string {
23+ var lines []string
24+ for _, m := range messages {
25+ lines = append(lines, transcriptLines(m)...)
26+ }
27+ return keepNewest(lines, maxChars)
28+}
29+
30+// transcriptLines is one message, as the transcript shows it.
31+func transcriptLines(m *ai.Message) []string {
32+ if m == nil {
33+ return nil
34+ }
35+ switch {
36+ case compact.IsSummary(m):
37+ return []string{"[Summary of the earlier conversation]\n" + strings.TrimSpace(m.Text())}
38+ case m.Role == ai.RoleUser:
39+ if text := strings.TrimSpace(m.Text()); text != "" {
40+ return []string{"User: " + text}
41+ }
42+ case m.Role == ai.RoleModel:
43+ return modelLines(m)
44+ }
45+ return nil // system prompt, tool outputs
46+}
47+
48+// modelLines is an assistant message: its text, then one line per tool call.
49+func modelLines(m *ai.Message) []string {
50+ var lines []string
51+ if text := strings.TrimSpace(m.Text()); text != "" {
52+ lines = append(lines, "Assistant: "+text)
53+ }
54+ for _, part := range m.Content {
55+ if part.IsToolRequest() && part.ToolRequest != nil {
56+ lines = append(lines, fmt.Sprintf("Assistant ran %s: %s", part.ToolRequest.Name, shorten(fmt.Sprint(part.ToolRequest.Input), 160)))
57+ }
58+ }
59+ return lines
60+}
61+
62+// keepNewest joins lines, dropping the oldest ones that do not fit.
63+func keepNewest(lines []string, maxChars int) string {
64+ const omitted = "[… earlier conversation omitted …]"
65+ size, first := 0, len(lines)
66+ for first > 0 {
67+ next := len([]rune(lines[first-1])) + 1
68+ if size+next > maxChars-len(omitted)-1 {
69+ break
70+ }
71+ size += next
72+ first--
73+ }
74+ kept := strings.Join(lines[first:], "\n")
75+ if first > 0 {
76+ return omitted + "\n" + kept
77+ }
78+ return kept
79+}
80+
81+// shorten cuts s to n characters on one line.
82+func shorten(s string, n int) string {
83+ s = strings.Join(strings.Fields(s), " ")
84+ if r := []rune(s); len(r) > n {
85+ return string(r[:n]) + "…"
86+ }
87+ return s
88+}
89+
90+// Prompt is what the other agent receives: the question alone, or — for
91+// /ask-with-context — the transcript first, clearly separated, so it can tell
92+// the conversation it is shown from the question it must answer.
93+//
94+// ask.Prompt("is this safe?", "") // "is this safe?"
95+// ask.Prompt("is this safe?", ask.Transcript(…)) // "[Conversation so far…]\n…\n\n[Question]\nis this safe?"
96+func Prompt(question, transcript string) string {
97+ if transcript == "" {
98+ return question
99+ }
100+ return "[Conversation so far, between the user and another assistant]\n" + transcript + "\n\n[Question]\n" + question
101+}
added internal/ask/context_test.go +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+package ask
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// conversation is a short history with a system prompt, a tool call and its
11+// output — everything the transcript must keep or drop.
12+func conversation() []*ai.Message {
13+ call := ai.NewModelMessage(ai.NewTextPart("Let me look."), ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls -1"}}))
14+ out := ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "SECRET-OUTPUT"}))
15+ return []*ai.Message{
16+ ai.NewSystemTextMessage("SYSTEM-PROMPT"),
17+ ai.NewUserTextMessage("list the files"),
18+ call, out,
19+ ai.NewModelTextMessage("There are two files."),
20+ }
21+}
22+
23+// The transcript keeps questions, answers and one line per tool call, and
24+// never the system prompt or a tool's output.
25+func TestTranscriptKeepsWhatHappenedNotTheOutputs(t *testing.T) {
26+ got := Transcript(conversation(), 10000)
27+ for _, want := range []string{"User: list the files", "Assistant: Let me look.", "Assistant ran bash: map[command:ls -1]", "Assistant: There are two files."} {
28+ if !strings.Contains(got, want) {
29+ t.Errorf("transcript lacks %q:\n%s", want, got)
30+ }
31+ }
32+ for _, never := range []string{"SYSTEM-PROMPT", "SECRET-OUTPUT"} {
33+ if strings.Contains(got, never) {
34+ t.Errorf("transcript leaks %q:\n%s", never, got)
35+ }
36+ }
37+}
38+
39+// Over the cap, the oldest lines go first and a marker says so; the newest
40+// line is always there.
41+func TestTranscriptKeepsTheNewestTurns(t *testing.T) {
42+ var msgs []*ai.Message
43+ for i := 0; i < 50; i++ {
44+ msgs = append(msgs, ai.NewUserTextMessage(strings.Repeat("old ", 10)), ai.NewModelTextMessage("ok"))
45+ }
46+ msgs = append(msgs, ai.NewUserTextMessage("the latest question"))
47+ got := Transcript(msgs, 300)
48+ if len([]rune(got)) > 300 {
49+ t.Errorf("transcript is %d chars, cap 300", len([]rune(got)))
50+ }
51+ if !strings.HasPrefix(got, "[… earlier conversation omitted …]") || !strings.HasSuffix(got, "User: the latest question") {
52+ t.Errorf("transcript:\n%s", got)
53+ }
54+}
55+
56+// A question alone goes as typed; with a transcript, both are labelled.
57+func TestPrompt(t *testing.T) {
58+ if got := Prompt("is it safe?", ""); got != "is it safe?" {
59+ t.Errorf("Prompt without context = %q", got)
60+ }
61+ got := Prompt("is it safe?", "User: hi")
62+ want := "[Conversation so far, between the user and another assistant]\nUser: hi\n\n[Question]\nis it safe?"
63+ if got != want {
64+ t.Errorf("Prompt with context = %q, want %q", got, want)
65+ }
66+}
new file mode 100644
@@ -0,0 +1,66 @@
1+package ask
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// conversation is a short history with a system prompt, a tool call and its
11+// output — everything the transcript must keep or drop.
12+func conversation() []*ai.Message {
13+ call := ai.NewModelMessage(ai.NewTextPart("Let me look."), ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls -1"}}))
14+ out := ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "SECRET-OUTPUT"}))
15+ return []*ai.Message{
16+ ai.NewSystemTextMessage("SYSTEM-PROMPT"),
17+ ai.NewUserTextMessage("list the files"),
18+ call, out,
19+ ai.NewModelTextMessage("There are two files."),
20+ }
21+}
22+
23+// The transcript keeps questions, answers and one line per tool call, and
24+// never the system prompt or a tool's output.
25+func TestTranscriptKeepsWhatHappenedNotTheOutputs(t *testing.T) {
26+ got := Transcript(conversation(), 10000)
27+ for _, want := range []string{"User: list the files", "Assistant: Let me look.", "Assistant ran bash: map[command:ls -1]", "Assistant: There are two files."} {
28+ if !strings.Contains(got, want) {
29+ t.Errorf("transcript lacks %q:\n%s", want, got)
30+ }
31+ }
32+ for _, never := range []string{"SYSTEM-PROMPT", "SECRET-OUTPUT"} {
33+ if strings.Contains(got, never) {
34+ t.Errorf("transcript leaks %q:\n%s", never, got)
35+ }
36+ }
37+}
38+
39+// Over the cap, the oldest lines go first and a marker says so; the newest
40+// line is always there.
41+func TestTranscriptKeepsTheNewestTurns(t *testing.T) {
42+ var msgs []*ai.Message
43+ for i := 0; i < 50; i++ {
44+ msgs = append(msgs, ai.NewUserTextMessage(strings.Repeat("old ", 10)), ai.NewModelTextMessage("ok"))
45+ }
46+ msgs = append(msgs, ai.NewUserTextMessage("the latest question"))
47+ got := Transcript(msgs, 300)
48+ if len([]rune(got)) > 300 {
49+ t.Errorf("transcript is %d chars, cap 300", len([]rune(got)))
50+ }
51+ if !strings.HasPrefix(got, "[… earlier conversation omitted …]") || !strings.HasSuffix(got, "User: the latest question") {
52+ t.Errorf("transcript:\n%s", got)
53+ }
54+}
55+
56+// A question alone goes as typed; with a transcript, both are labelled.
57+func TestPrompt(t *testing.T) {
58+ if got := Prompt("is it safe?", ""); got != "is it safe?" {
59+ t.Errorf("Prompt without context = %q", got)
60+ }
61+ got := Prompt("is it safe?", "User: hi")
62+ want := "[Conversation so far, between the user and another assistant]\nUser: hi\n\n[Question]\nis it safe?"
63+ if got != want {
64+ t.Errorf("Prompt with context = %q, want %q", got, want)
65+ }
66+}
added internal/ask/doc.go +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+// Package ask lets one mm agent put a question to another: `/ask-bob <prompt>`
2+// starts (once) a child `mm -acp -config <bob's yaml>`, talks to it as an
3+// Agent Client Protocol CLIENT, shows what bob does while he does it, and
4+// hands back his answer so the front end can keep it in the history for the
5+// next question.
6+//
7+// Why a child process over ACP, rather than a second engine in this process:
8+// the configuration is one global (config.Cfg) read by the engine, the tools
9+// and the compaction alike, and bob's config differs in all of them — model,
10+// provider, tools, skills, MCP servers. A child keeps each agent whole. Why
11+// ACP rather than `mm -p`: it is structured (text, tool calls and permission
12+// requests arrive as messages, not as a terminal screen to scrape), it can be
13+// cancelled, and mm already speaks it as a server.
14+//
15+// The pieces:
16+//
17+// - context.go — the transcript /ask-with-context sends, and the prompt;
18+// - exchange.go — the two messages the asking agent keeps in its history;
19+// - env.go — the child's environment and the depth limit;
20+// - client.go — the ACP client: events to a ui.Sink, permissions;
21+// - pool.go — one child per agent, started on first use.
22+package ask
new file mode 100644
@@ -0,0 +1,22 @@
1+// Package ask lets one mm agent put a question to another: `/ask-bob <prompt>`
2+// starts (once) a child `mm -acp -config <bob's yaml>`, talks to it as an
3+// Agent Client Protocol CLIENT, shows what bob does while he does it, and
4+// hands back his answer so the front end can keep it in the history for the
5+// next question.
6+//
7+// Why a child process over ACP, rather than a second engine in this process:
8+// the configuration is one global (config.Cfg) read by the engine, the tools
9+// and the compaction alike, and bob's config differs in all of them — model,
10+// provider, tools, skills, MCP servers. A child keeps each agent whole. Why
11+// ACP rather than `mm -p`: it is structured (text, tool calls and permission
12+// requests arrive as messages, not as a terminal screen to scrape), it can be
13+// cancelled, and mm already speaks it as a server.
14+//
15+// The pieces:
16+//
17+// - context.go — the transcript /ask-with-context sends, and the prompt;
18+// - exchange.go — the two messages the asking agent keeps in its history;
19+// - env.go — the child's environment and the depth limit;
20+// - client.go — the ACP client: events to a ui.Sink, permissions;
21+// - pool.go — one child per agent, started on first use.
22+package ask
added internal/ask/env.go +55 -0
new file mode 100644
@@ -0,0 +1,55 @@
1+package ask
2+
3+import (
4+ "fmt"
5+ "os"
6+ "slices"
7+ "strconv"
8+ "strings"
9+
10+ "mm/internal/config"
11+)
12+
13+// DepthEnv carries how deep in a chain of /ask an agent runs: unset for the
14+// one the user started, 1 for the agent it asked, and so on.
15+const DepthEnv = "MM_ASK_DEPTH"
16+
17+// MaxDepth: an agent at this depth may not ask another one. /ask is a USER
18+// command — no model can call it — so a loop needs a person typing /ask into
19+// an agent that was itself asked; the cap keeps even that finite.
20+const MaxDepth = 2
21+
22+// Depth is this process's place in the chain (0 = started by the user).
23+func Depth() int {
24+ n, _ := strconv.Atoi(os.Getenv(DepthEnv))
25+ return max(n, 0)
26+}
27+
28+// CanAsk says whether this agent may ask another one, and why not.
29+func CanAsk() error {
30+ if d := Depth(); d >= MaxDepth {
31+ return fmt.Errorf("this agent was itself asked through %d level(s) of /ask; it cannot ask further (max %d)", d, MaxDepth)
32+ }
33+ return nil
34+}
35+
36+// overrides are the variables that would make the child ignore its own
37+// config file: they are meant for ONE agent, the one the user started.
38+var overrides = []string{"AGENT_CONFIG", "AGENT_PROVIDER", "AGENT_MODEL", "AGENT_BASE_URL", config.SessionsEnv, DepthEnv}
39+
40+// ChildEnv is the environment of an agent started by /ask: the parent's,
41+// minus the per-agent overrides, plus no session files (it works in the
42+// parent's project, and its sessions would land in the parent's /sessions)
43+// and its depth.
44+//
45+// cmd.Env = ask.ChildEnv(os.Environ(), ask.Depth())
46+func ChildEnv(parent []string, depth int) []string {
47+ env := make([]string, 0, len(parent)+2)
48+ for _, kv := range parent {
49+ name, _, _ := strings.Cut(kv, "=")
50+ if !slices.Contains(overrides, name) {
51+ env = append(env, kv)
52+ }
53+ }
54+ return append(env, config.SessionsEnv+"=off", fmt.Sprintf("%s=%d", DepthEnv, depth+1))
55+}
new file mode 100644
@@ -0,0 +1,55 @@
1+package ask
2+
3+import (
4+ "fmt"
5+ "os"
6+ "slices"
7+ "strconv"
8+ "strings"
9+
10+ "mm/internal/config"
11+)
12+
13+// DepthEnv carries how deep in a chain of /ask an agent runs: unset for the
14+// one the user started, 1 for the agent it asked, and so on.
15+const DepthEnv = "MM_ASK_DEPTH"
16+
17+// MaxDepth: an agent at this depth may not ask another one. /ask is a USER
18+// command — no model can call it — so a loop needs a person typing /ask into
19+// an agent that was itself asked; the cap keeps even that finite.
20+const MaxDepth = 2
21+
22+// Depth is this process's place in the chain (0 = started by the user).
23+func Depth() int {
24+ n, _ := strconv.Atoi(os.Getenv(DepthEnv))
25+ return max(n, 0)
26+}
27+
28+// CanAsk says whether this agent may ask another one, and why not.
29+func CanAsk() error {
30+ if d := Depth(); d >= MaxDepth {
31+ return fmt.Errorf("this agent was itself asked through %d level(s) of /ask; it cannot ask further (max %d)", d, MaxDepth)
32+ }
33+ return nil
34+}
35+
36+// overrides are the variables that would make the child ignore its own
37+// config file: they are meant for ONE agent, the one the user started.
38+var overrides = []string{"AGENT_CONFIG", "AGENT_PROVIDER", "AGENT_MODEL", "AGENT_BASE_URL", config.SessionsEnv, DepthEnv}
39+
40+// ChildEnv is the environment of an agent started by /ask: the parent's,
41+// minus the per-agent overrides, plus no session files (it works in the
42+// parent's project, and its sessions would land in the parent's /sessions)
43+// and its depth.
44+//
45+// cmd.Env = ask.ChildEnv(os.Environ(), ask.Depth())
46+func ChildEnv(parent []string, depth int) []string {
47+ env := make([]string, 0, len(parent)+2)
48+ for _, kv := range parent {
49+ name, _, _ := strings.Cut(kv, "=")
50+ if !slices.Contains(overrides, name) {
51+ env = append(env, kv)
52+ }
53+ }
54+ return append(env, config.SessionsEnv+"=off", fmt.Sprintf("%s=%d", DepthEnv, depth+1))
55+}
added internal/ask/env_test.go +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+package ask
2+
3+import (
4+ "slices"
5+ "strings"
6+ "testing"
7+)
8+
9+// The child keeps the parent's environment but none of the variables that
10+// would override ITS config, runs without session files, one level deeper.
11+func TestChildEnv(t *testing.T) {
12+ parent := []string{"PATH=/bin", "AGENT_MODEL=parent-model", "AGENT_PROVIDER=dmr", "AGENT_BASE_URL=http://x", "AGENT_CONFIG=p.yaml", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1", "DMR_BASE_URL=http://dmr", "HOME=/h"}
13+ got := ChildEnv(parent, 1)
14+ for _, want := range []string{"PATH=/bin", "DMR_BASE_URL=http://dmr", "HOME=/h", "AGENT_SESSIONS=off", "MM_ASK_DEPTH=2"} {
15+ if !slices.Contains(got, want) {
16+ t.Errorf("child env lacks %q: %v", want, got)
17+ }
18+ }
19+ for _, kv := range got {
20+ for _, banned := range []string{"AGENT_MODEL=", "AGENT_PROVIDER=", "AGENT_BASE_URL=", "AGENT_CONFIG=", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1"} {
21+ if strings.HasPrefix(kv, banned) {
22+ t.Errorf("child env keeps %q", kv)
23+ }
24+ }
25+ }
26+}
27+
28+// The user's agent may ask; an agent asked through MaxDepth levels may not.
29+func TestDepthLimit(t *testing.T) {
30+ t.Setenv(DepthEnv, "")
31+ if Depth() != 0 || CanAsk() != nil {
32+ t.Errorf("top level: Depth %d, CanAsk %v", Depth(), CanAsk())
33+ }
34+ t.Setenv(DepthEnv, "1")
35+ if CanAsk() != nil {
36+ t.Errorf("depth 1: CanAsk %v, want allowed", CanAsk())
37+ }
38+ t.Setenv(DepthEnv, "2")
39+ if err := CanAsk(); err == nil || !strings.Contains(err.Error(), "cannot ask further") {
40+ t.Errorf("depth 2: CanAsk %v, want refused", err)
41+ }
42+}
new file mode 100644
@@ -0,0 +1,42 @@
1+package ask
2+
3+import (
4+ "slices"
5+ "strings"
6+ "testing"
7+)
8+
9+// The child keeps the parent's environment but none of the variables that
10+// would override ITS config, runs without session files, one level deeper.
11+func TestChildEnv(t *testing.T) {
12+ parent := []string{"PATH=/bin", "AGENT_MODEL=parent-model", "AGENT_PROVIDER=dmr", "AGENT_BASE_URL=http://x", "AGENT_CONFIG=p.yaml", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1", "DMR_BASE_URL=http://dmr", "HOME=/h"}
13+ got := ChildEnv(parent, 1)
14+ for _, want := range []string{"PATH=/bin", "DMR_BASE_URL=http://dmr", "HOME=/h", "AGENT_SESSIONS=off", "MM_ASK_DEPTH=2"} {
15+ if !slices.Contains(got, want) {
16+ t.Errorf("child env lacks %q: %v", want, got)
17+ }
18+ }
19+ for _, kv := range got {
20+ for _, banned := range []string{"AGENT_MODEL=", "AGENT_PROVIDER=", "AGENT_BASE_URL=", "AGENT_CONFIG=", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1"} {
21+ if strings.HasPrefix(kv, banned) {
22+ t.Errorf("child env keeps %q", kv)
23+ }
24+ }
25+ }
26+}
27+
28+// The user's agent may ask; an agent asked through MaxDepth levels may not.
29+func TestDepthLimit(t *testing.T) {
30+ t.Setenv(DepthEnv, "")
31+ if Depth() != 0 || CanAsk() != nil {
32+ t.Errorf("top level: Depth %d, CanAsk %v", Depth(), CanAsk())
33+ }
34+ t.Setenv(DepthEnv, "1")
35+ if CanAsk() != nil {
36+ t.Errorf("depth 1: CanAsk %v, want allowed", CanAsk())
37+ }
38+ t.Setenv(DepthEnv, "2")
39+ if err := CanAsk(); err == nil || !strings.Contains(err.Error(), "cannot ask further") {
40+ t.Errorf("depth 2: CanAsk %v, want refused", err)
41+ }
42+}
added internal/ask/exchange.go +63 -0
new file mode 100644
@@ -0,0 +1,63 @@
1+package ask
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/firebase/genkit/go/ai"
8+ "gopkg.in/yaml.v3"
9+)
10+
11+// Exchange is what the asking agent keeps in its history: the question as a
12+// user message and the other agent's answer as the assistant's, each marked
13+// with who was asked. A PAIR, not one message: the next question is a user
14+// message too, and two user messages in a row are refused by chat templates
15+// that require the roles to alternate (Gemma's, Mistral's).
16+//
17+// The answer is capped at maxChars, its beginning and end kept — the same
18+// rule as a tool's output, since it lands in the same window.
19+//
20+// history = append(history, ask.Exchange("bob", "ollama/qwen3.5:2b", false, prompt, answer, config.Cfg.MaxOutput)...)
21+func Exchange(agent, label string, withContext bool, question, answer string, maxChars int) []*ai.Message {
22+ how := ""
23+ if withContext {
24+ how = ", with this conversation as context"
25+ }
26+ asked := fmt.Sprintf("[asked %s (%s)%s: %s]", agent, label, how, question)
27+ answered := fmt.Sprintf("[%s's answer]\n%s", agent, clipMiddle(answer, maxChars))
28+ return []*ai.Message{ai.NewUserTextMessage(asked), ai.NewModelTextMessage(answered)}
29+}
30+
31+// clipMiddle caps s at max characters, keeping the beginning and the end.
32+func clipMiddle(s string, max int) string {
33+ r := []rune(s)
34+ if max <= 0 || len(r) <= max {
35+ return s
36+ }
37+ tail := min(2000, max/2)
38+ head := max - tail
39+ return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", string(r[:head]), len(r)-head-tail, string(r[len(r)-tail:]))
40+}
41+
42+// Describe reads "<provider>/<model>" from an agent's config file, for the
43+// marker in the history and the line the user sees. Best effort: the child
44+// validates its own file, and a value it cannot read here is shown as "?".
45+//
46+// ask.Describe("demo-ollama/agent.ollama.yaml") // "ollama/qwen3.5:2b"
47+func Describe(path string) string {
48+ data, err := os.ReadFile(path)
49+ if err != nil {
50+ return "?"
51+ }
52+ var head struct {
53+ Provider string `yaml:"provider"`
54+ Model string `yaml:"model"`
55+ }
56+ if yaml.Unmarshal(data, &head) != nil || head.Model == "" {
57+ return "?"
58+ }
59+ if head.Provider == "" {
60+ head.Provider = "dmr" // config.Cfg's default
61+ }
62+ return head.Provider + "/" + head.Model
63+}
new file mode 100644
@@ -0,0 +1,63 @@
1+package ask
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/firebase/genkit/go/ai"
8+ "gopkg.in/yaml.v3"
9+)
10+
11+// Exchange is what the asking agent keeps in its history: the question as a
12+// user message and the other agent's answer as the assistant's, each marked
13+// with who was asked. A PAIR, not one message: the next question is a user
14+// message too, and two user messages in a row are refused by chat templates
15+// that require the roles to alternate (Gemma's, Mistral's).
16+//
17+// The answer is capped at maxChars, its beginning and end kept — the same
18+// rule as a tool's output, since it lands in the same window.
19+//
20+// history = append(history, ask.Exchange("bob", "ollama/qwen3.5:2b", false, prompt, answer, config.Cfg.MaxOutput)...)
21+func Exchange(agent, label string, withContext bool, question, answer string, maxChars int) []*ai.Message {
22+ how := ""
23+ if withContext {
24+ how = ", with this conversation as context"
25+ }
26+ asked := fmt.Sprintf("[asked %s (%s)%s: %s]", agent, label, how, question)
27+ answered := fmt.Sprintf("[%s's answer]\n%s", agent, clipMiddle(answer, maxChars))
28+ return []*ai.Message{ai.NewUserTextMessage(asked), ai.NewModelTextMessage(answered)}
29+}
30+
31+// clipMiddle caps s at max characters, keeping the beginning and the end.
32+func clipMiddle(s string, max int) string {
33+ r := []rune(s)
34+ if max <= 0 || len(r) <= max {
35+ return s
36+ }
37+ tail := min(2000, max/2)
38+ head := max - tail
39+ return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", string(r[:head]), len(r)-head-tail, string(r[len(r)-tail:]))
40+}
41+
42+// Describe reads "<provider>/<model>" from an agent's config file, for the
43+// marker in the history and the line the user sees. Best effort: the child
44+// validates its own file, and a value it cannot read here is shown as "?".
45+//
46+// ask.Describe("demo-ollama/agent.ollama.yaml") // "ollama/qwen3.5:2b"
47+func Describe(path string) string {
48+ data, err := os.ReadFile(path)
49+ if err != nil {
50+ return "?"
51+ }
52+ var head struct {
53+ Provider string `yaml:"provider"`
54+ Model string `yaml:"model"`
55+ }
56+ if yaml.Unmarshal(data, &head) != nil || head.Model == "" {
57+ return "?"
58+ }
59+ if head.Provider == "" {
60+ head.Provider = "dmr" // config.Cfg's default
61+ }
62+ return head.Provider + "/" + head.Model
63+}
added internal/ask/exchange_test.go +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+package ask
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// The exchange is a user/model pair (roles must alternate), marked with who
13+// was asked and how, the answer capped with its beginning and end kept.
14+func TestExchangeIsAMarkedPair(t *testing.T) {
15+ msgs := Exchange("bob", "ollama/qwen3.5:2b", true, "is it safe?", "Yes.", 100)
16+ if len(msgs) != 2 || msgs[0].Role != ai.RoleUser || msgs[1].Role != ai.RoleModel {
17+ t.Fatalf("Exchange = %v, want a user then a model message", msgs)
18+ }
19+ if got := msgs[0].Text(); got != "[asked bob (ollama/qwen3.5:2b), with this conversation as context: is it safe?]" {
20+ t.Errorf("question = %q", got)
21+ }
22+ if got := msgs[1].Text(); got != "[bob's answer]\nYes." {
23+ t.Errorf("answer = %q", got)
24+ }
25+
26+ long := "BEGIN" + strings.Repeat("x", 500) + "END"
27+ got := Exchange("bob", "x/y", false, "q", long, 100)[1].Text()
28+ if !strings.Contains(got, "BEGIN") || !strings.HasSuffix(got, "END") || !strings.Contains(got, "characters truncated") {
29+ t.Errorf("capped answer = %q", got)
30+ }
31+ if strings.Contains(Exchange("bob", "x/y", false, "q", "a", 100)[0].Text(), "context") {
32+ t.Error("a plain /ask says it sent the context")
33+ }
34+}
35+
36+// Describe reads provider/model from the agent's file, defaults the provider
37+// like the config does, and says "?" when it cannot.
38+func TestDescribe(t *testing.T) {
39+ dir := t.TempDir()
40+ write := func(name, body string) string {
41+ p := filepath.Join(dir, name)
42+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
43+ t.Fatal(err)
44+ }
45+ return p
46+ }
47+ if got := Describe(write("a.yaml", "provider: ollama\nmodel: qwen3.5:2b\n")); got != "ollama/qwen3.5:2b" {
48+ t.Errorf("Describe = %q", got)
49+ }
50+ if got := Describe(write("b.yaml", "model: ai/gemma\n")); got != "dmr/ai/gemma" {
51+ t.Errorf("Describe without provider = %q", got)
52+ }
53+ if got := Describe(filepath.Join(dir, "missing.yaml")); got != "?" {
54+ t.Errorf("Describe(missing) = %q", got)
55+ }
56+}
new file mode 100644
@@ -0,0 +1,56 @@
1+package ask
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// The exchange is a user/model pair (roles must alternate), marked with who
13+// was asked and how, the answer capped with its beginning and end kept.
14+func TestExchangeIsAMarkedPair(t *testing.T) {
15+ msgs := Exchange("bob", "ollama/qwen3.5:2b", true, "is it safe?", "Yes.", 100)
16+ if len(msgs) != 2 || msgs[0].Role != ai.RoleUser || msgs[1].Role != ai.RoleModel {
17+ t.Fatalf("Exchange = %v, want a user then a model message", msgs)
18+ }
19+ if got := msgs[0].Text(); got != "[asked bob (ollama/qwen3.5:2b), with this conversation as context: is it safe?]" {
20+ t.Errorf("question = %q", got)
21+ }
22+ if got := msgs[1].Text(); got != "[bob's answer]\nYes." {
23+ t.Errorf("answer = %q", got)
24+ }
25+
26+ long := "BEGIN" + strings.Repeat("x", 500) + "END"
27+ got := Exchange("bob", "x/y", false, "q", long, 100)[1].Text()
28+ if !strings.Contains(got, "BEGIN") || !strings.HasSuffix(got, "END") || !strings.Contains(got, "characters truncated") {
29+ t.Errorf("capped answer = %q", got)
30+ }
31+ if strings.Contains(Exchange("bob", "x/y", false, "q", "a", 100)[0].Text(), "context") {
32+ t.Error("a plain /ask says it sent the context")
33+ }
34+}
35+
36+// Describe reads provider/model from the agent's file, defaults the provider
37+// like the config does, and says "?" when it cannot.
38+func TestDescribe(t *testing.T) {
39+ dir := t.TempDir()
40+ write := func(name, body string) string {
41+ p := filepath.Join(dir, name)
42+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
43+ t.Fatal(err)
44+ }
45+ return p
46+ }
47+ if got := Describe(write("a.yaml", "provider: ollama\nmodel: qwen3.5:2b\n")); got != "ollama/qwen3.5:2b" {
48+ t.Errorf("Describe = %q", got)
49+ }
50+ if got := Describe(write("b.yaml", "model: ai/gemma\n")); got != "dmr/ai/gemma" {
51+ t.Errorf("Describe without provider = %q", got)
52+ }
53+ if got := Describe(filepath.Join(dir, "missing.yaml")); got != "?" {
54+ t.Errorf("Describe(missing) = %q", got)
55+ }
56+}
added internal/ask/pool.go +253 -0
new file mode 100644
@@ -0,0 +1,253 @@
1+package ask
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "os"
10+ "os/exec"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "mm/internal/ui"
16+
17+ sdk "github.com/coder/acp-go-sdk"
18+)
19+
20+// Pool keeps one child agent per name, started on its first /ask and kept for
21+// the life of the asking agent: starting mm costs its engine set-up, its probe
22+// and its MCP servers, and a second question should not pay that again. Each
23+// ask opens a FRESH session in the child — no memory between two asks; the
24+// conversation travels explicitly, with /ask-with-context.
25+//
26+// pool := ask.NewPool()
27+// defer pool.Close()
28+// answer, err := pool.Ask(ctx, "bob", "/path/agent.bob.yaml", cwd, "what is 2+2?", sink)
29+type Pool struct {
30+ // command builds the child process for a config file; a test swaps it for
31+ // a fake agent.
32+ command func(configPath string) *exec.Cmd
33+
34+ mu sync.Mutex
35+ children map[string]*child
36+}
37+
38+// NewPool returns a pool whose children run this very binary, so every
39+// agent is the same mm build.
40+func NewPool() *Pool {
41+ return &Pool{command: childCommand, children: map[string]*child{}}
42+}
43+
44+// childCommand is `<this mm> -acp -config <path>`, in the asker's environment
45+// minus the overrides meant for the asker alone (ChildEnv).
46+func childCommand(configPath string) *exec.Cmd {
47+ self, err := os.Executable()
48+ if err != nil {
49+ self = os.Args[0]
50+ }
51+ cmd := exec.Command(self, "-acp", "-config", configPath)
52+ cmd.Env = ChildEnv(os.Environ(), Depth())
53+ return cmd
54+}
55+
56+// startTimeout bounds the child's start-up: process, initialize, its probe.
57+// Generous — a cold Genkit and a slow MCP discovery (5 s by default) — but
58+// finite, so a child that never speaks ACP fails with its stderr instead of
59+// hanging the asker.
60+var startTimeout = 60 * time.Second
61+
62+// Ask puts prompt to the agent `name` (config file `configPath`), working in
63+// cwd — the asker's project; in an editor, the session's — streaming what it
64+// does to sink, and returns its answer. A cancelled ctx cancels the
65+// child's turn (session/cancel) and returns what was answered so far with
66+// the context's error. A child that died is started again on the next ask.
67+func (p *Pool) Ask(ctx context.Context, name, configPath, cwd, prompt string, sink ui.Sink) (string, error) {
68+ if err := CanAsk(); err != nil {
69+ return "", err
70+ }
71+ if _, err := os.Stat(configPath); err != nil {
72+ return "", fmt.Errorf("%s: config file %s: %w", name, configPath, errors.Unwrap(err))
73+ }
74+ c, err := p.child(ctx, name, configPath)
75+ if err != nil {
76+ return "", err
77+ }
78+ return c.ask(ctx, name, cwd, prompt, sink)
79+}
80+
81+// child returns the running child for name, starting it when there is none
82+// or the previous one exited.
83+func (p *Pool) child(ctx context.Context, name, configPath string) (*child, error) {
84+ p.mu.Lock()
85+ defer p.mu.Unlock()
86+ if c := p.children[name]; c != nil && c.alive() {
87+ return c, nil
88+ }
89+ c, err := startChild(ctx, name, p.command(configPath))
90+ if err != nil {
91+ return nil, err
92+ }
93+ p.children[name] = c
94+ return c, nil
95+}
96+
97+// Close stops every child: stdin closed (mm -acp exits on EOF), then killed
98+// if it is still there a moment later.
99+func (p *Pool) Close() {
100+ p.mu.Lock()
101+ defer p.mu.Unlock()
102+ for name, c := range p.children {
103+ c.stop()
104+ delete(p.children, name)
105+ }
106+}
107+
108+// child is one running agent and the ACP connection to it.
109+type child struct {
110+ name string
111+ cmd *exec.Cmd
112+ stdin io.WriteCloser
113+ conn *sdk.ClientSideConnection
114+ client *client
115+ stderr *tail
116+ exited chan struct{}
117+
118+ askMu sync.Mutex // one ask at a time per child: one sink, one answer
119+}
120+
121+// startChild starts the process and runs the ACP handshake.
122+func startChild(ctx context.Context, name string, cmd *exec.Cmd) (*child, error) {
123+ stdin, err := cmd.StdinPipe()
124+ if err != nil {
125+ return nil, err
126+ }
127+ stdout, err := cmd.StdoutPipe()
128+ if err != nil {
129+ return nil, err
130+ }
131+ c := &child{name: name, cmd: cmd, stdin: stdin, client: &client{}, stderr: &tail{max: 4096}, exited: make(chan struct{})}
132+ cmd.Stderr = c.stderr
133+ if err := cmd.Start(); err != nil {
134+ return nil, fmt.Errorf("%s: cannot start: %w", name, err)
135+ }
136+ go func() { _ = cmd.Wait(); close(c.exited) }()
137+ c.conn = sdk.NewClientSideConnection(c.client, stdin, stdout)
138+ // The SDK logs "connection closed" and the like through slog's default,
139+ // which is the asker's terminal: the child's life is reported by Ask's
140+ // errors, nowhere else.
141+ c.conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
142+
143+ initCtx, cancel := context.WithTimeout(ctx, startTimeout)
144+ defer cancel()
145+ _, err = c.conn.Initialize(initCtx, sdk.InitializeRequest{
146+ ProtocolVersion: sdk.ProtocolVersionNumber,
147+ ClientInfo: &sdk.Implementation{Name: "mm"},
148+ })
149+ if err != nil {
150+ c.stop()
151+ return nil, c.failure("did not start", err)
152+ }
153+ return c, nil
154+}
155+
156+// ask runs one prompt in a fresh session.
157+func (c *child) ask(ctx context.Context, name, cwd, prompt string, sink ui.Sink) (string, error) {
158+ c.askMu.Lock()
159+ defer c.askMu.Unlock()
160+ sess, err := c.conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}})
161+ if err != nil {
162+ return "", c.failure("could not open a session", err)
163+ }
164+ c.client.begin(name, sess.SessionId, sink)
165+ _, err = c.conn.Prompt(ctx, sdk.PromptRequest{SessionId: sess.SessionId, Prompt: []sdk.ContentBlock{sdk.TextBlock(prompt)}})
166+ answer := c.client.end()
167+ switch {
168+ case ctx.Err() != nil:
169+ return answer, ctx.Err()
170+ case err != nil:
171+ return answer, c.failure("failed", err)
172+ }
173+ return answer, nil
174+}
175+
176+// failure is one error line: what went wrong, the protocol's words, and —
177+// when the child wrote anything on stderr — its last line, which is usually
178+// the real cause ("[config error: …]", "[engine error: …]").
179+func (c *child) failure(what string, err error) error {
180+ msg := fmt.Sprintf("%s %s: %v", c.name, what, acpError(err))
181+ if last := c.stderr.lastLine(); last != "" {
182+ msg += " — " + last
183+ }
184+ return errors.New(msg)
185+}
186+
187+// acpError digs the agent's own message out of a JSON-RPC error: mm's ACP
188+// server puts its one-line explanation under data.error.
189+func acpError(err error) string {
190+ var re *sdk.RequestError
191+ if errors.As(err, &re) {
192+ if data, ok := re.Data.(map[string]any); ok {
193+ if msg, ok := data["error"].(string); ok && msg != "" {
194+ return msg
195+ }
196+ }
197+ return re.Message
198+ }
199+ return err.Error()
200+}
201+
202+// alive reports whether the process is still running.
203+func (c *child) alive() bool {
204+ select {
205+ case <-c.exited:
206+ return false
207+ default:
208+ return true
209+ }
210+}
211+
212+// stop ends the child: EOF on its stdin first, then a kill.
213+func (c *child) stop() {
214+ _ = c.stdin.Close()
215+ select {
216+ case <-c.exited:
217+ case <-time.After(2 * time.Second):
218+ _ = c.cmd.Process.Kill()
219+ <-c.exited
220+ }
221+}
222+
223+// tail keeps the last max bytes written to it: the child's stderr, kept for
224+// the error line and nothing else.
225+type tail struct {
226+ mu sync.Mutex
227+ buf []byte
228+ max int
229+}
230+
231+func (t *tail) Write(p []byte) (int, error) {
232+ t.mu.Lock()
233+ defer t.mu.Unlock()
234+ t.buf = append(t.buf, p...)
235+ if len(t.buf) > t.max {
236+ t.buf = t.buf[len(t.buf)-t.max:]
237+ }
238+ return len(p), nil
239+}
240+
241+// lastLine is the last non-empty line written.
242+func (t *tail) lastLine() string {
243+ t.mu.Lock()
244+ defer t.mu.Unlock()
245+ lines := strings.Split(strings.TrimSpace(string(t.buf)), "\n")
246+ return strings.TrimSpace(lines[len(lines)-1])
247+}
248+
249+// exists reports whether path names an existing file.
250+func exists(path string) bool {
251+ _, err := os.Stat(path)
252+ return err == nil
253+}
new file mode 100644
@@ -0,0 +1,253 @@
1+package ask
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "os"
10+ "os/exec"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "mm/internal/ui"
16+
17+ sdk "github.com/coder/acp-go-sdk"
18+)
19+
20+// Pool keeps one child agent per name, started on its first /ask and kept for
21+// the life of the asking agent: starting mm costs its engine set-up, its probe
22+// and its MCP servers, and a second question should not pay that again. Each
23+// ask opens a FRESH session in the child — no memory between two asks; the
24+// conversation travels explicitly, with /ask-with-context.
25+//
26+// pool := ask.NewPool()
27+// defer pool.Close()
28+// answer, err := pool.Ask(ctx, "bob", "/path/agent.bob.yaml", cwd, "what is 2+2?", sink)
29+type Pool struct {
30+ // command builds the child process for a config file; a test swaps it for
31+ // a fake agent.
32+ command func(configPath string) *exec.Cmd
33+
34+ mu sync.Mutex
35+ children map[string]*child
36+}
37+
38+// NewPool returns a pool whose children run this very binary, so every
39+// agent is the same mm build.
40+func NewPool() *Pool {
41+ return &Pool{command: childCommand, children: map[string]*child{}}
42+}
43+
44+// childCommand is `<this mm> -acp -config <path>`, in the asker's environment
45+// minus the overrides meant for the asker alone (ChildEnv).
46+func childCommand(configPath string) *exec.Cmd {
47+ self, err := os.Executable()
48+ if err != nil {
49+ self = os.Args[0]
50+ }
51+ cmd := exec.Command(self, "-acp", "-config", configPath)
52+ cmd.Env = ChildEnv(os.Environ(), Depth())
53+ return cmd
54+}
55+
56+// startTimeout bounds the child's start-up: process, initialize, its probe.
57+// Generous — a cold Genkit and a slow MCP discovery (5 s by default) — but
58+// finite, so a child that never speaks ACP fails with its stderr instead of
59+// hanging the asker.
60+var startTimeout = 60 * time.Second
61+
62+// Ask puts prompt to the agent `name` (config file `configPath`), working in
63+// cwd — the asker's project; in an editor, the session's — streaming what it
64+// does to sink, and returns its answer. A cancelled ctx cancels the
65+// child's turn (session/cancel) and returns what was answered so far with
66+// the context's error. A child that died is started again on the next ask.
67+func (p *Pool) Ask(ctx context.Context, name, configPath, cwd, prompt string, sink ui.Sink) (string, error) {
68+ if err := CanAsk(); err != nil {
69+ return "", err
70+ }
71+ if _, err := os.Stat(configPath); err != nil {
72+ return "", fmt.Errorf("%s: config file %s: %w", name, configPath, errors.Unwrap(err))
73+ }
74+ c, err := p.child(ctx, name, configPath)
75+ if err != nil {
76+ return "", err
77+ }
78+ return c.ask(ctx, name, cwd, prompt, sink)
79+}
80+
81+// child returns the running child for name, starting it when there is none
82+// or the previous one exited.
83+func (p *Pool) child(ctx context.Context, name, configPath string) (*child, error) {
84+ p.mu.Lock()
85+ defer p.mu.Unlock()
86+ if c := p.children[name]; c != nil && c.alive() {
87+ return c, nil
88+ }
89+ c, err := startChild(ctx, name, p.command(configPath))
90+ if err != nil {
91+ return nil, err
92+ }
93+ p.children[name] = c
94+ return c, nil
95+}
96+
97+// Close stops every child: stdin closed (mm -acp exits on EOF), then killed
98+// if it is still there a moment later.
99+func (p *Pool) Close() {
100+ p.mu.Lock()
101+ defer p.mu.Unlock()
102+ for name, c := range p.children {
103+ c.stop()
104+ delete(p.children, name)
105+ }
106+}
107+
108+// child is one running agent and the ACP connection to it.
109+type child struct {
110+ name string
111+ cmd *exec.Cmd
112+ stdin io.WriteCloser
113+ conn *sdk.ClientSideConnection
114+ client *client
115+ stderr *tail
116+ exited chan struct{}
117+
118+ askMu sync.Mutex // one ask at a time per child: one sink, one answer
119+}
120+
121+// startChild starts the process and runs the ACP handshake.
122+func startChild(ctx context.Context, name string, cmd *exec.Cmd) (*child, error) {
123+ stdin, err := cmd.StdinPipe()
124+ if err != nil {
125+ return nil, err
126+ }
127+ stdout, err := cmd.StdoutPipe()
128+ if err != nil {
129+ return nil, err
130+ }
131+ c := &child{name: name, cmd: cmd, stdin: stdin, client: &client{}, stderr: &tail{max: 4096}, exited: make(chan struct{})}
132+ cmd.Stderr = c.stderr
133+ if err := cmd.Start(); err != nil {
134+ return nil, fmt.Errorf("%s: cannot start: %w", name, err)
135+ }
136+ go func() { _ = cmd.Wait(); close(c.exited) }()
137+ c.conn = sdk.NewClientSideConnection(c.client, stdin, stdout)
138+ // The SDK logs "connection closed" and the like through slog's default,
139+ // which is the asker's terminal: the child's life is reported by Ask's
140+ // errors, nowhere else.
141+ c.conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
142+
143+ initCtx, cancel := context.WithTimeout(ctx, startTimeout)
144+ defer cancel()
145+ _, err = c.conn.Initialize(initCtx, sdk.InitializeRequest{
146+ ProtocolVersion: sdk.ProtocolVersionNumber,
147+ ClientInfo: &sdk.Implementation{Name: "mm"},
148+ })
149+ if err != nil {
150+ c.stop()
151+ return nil, c.failure("did not start", err)
152+ }
153+ return c, nil
154+}
155+
156+// ask runs one prompt in a fresh session.
157+func (c *child) ask(ctx context.Context, name, cwd, prompt string, sink ui.Sink) (string, error) {
158+ c.askMu.Lock()
159+ defer c.askMu.Unlock()
160+ sess, err := c.conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}})
161+ if err != nil {
162+ return "", c.failure("could not open a session", err)
163+ }
164+ c.client.begin(name, sess.SessionId, sink)
165+ _, err = c.conn.Prompt(ctx, sdk.PromptRequest{SessionId: sess.SessionId, Prompt: []sdk.ContentBlock{sdk.TextBlock(prompt)}})
166+ answer := c.client.end()
167+ switch {
168+ case ctx.Err() != nil:
169+ return answer, ctx.Err()
170+ case err != nil:
171+ return answer, c.failure("failed", err)
172+ }
173+ return answer, nil
174+}
175+
176+// failure is one error line: what went wrong, the protocol's words, and —
177+// when the child wrote anything on stderr — its last line, which is usually
178+// the real cause ("[config error: …]", "[engine error: …]").
179+func (c *child) failure(what string, err error) error {
180+ msg := fmt.Sprintf("%s %s: %v", c.name, what, acpError(err))
181+ if last := c.stderr.lastLine(); last != "" {
182+ msg += " — " + last
183+ }
184+ return errors.New(msg)
185+}
186+
187+// acpError digs the agent's own message out of a JSON-RPC error: mm's ACP
188+// server puts its one-line explanation under data.error.
189+func acpError(err error) string {
190+ var re *sdk.RequestError
191+ if errors.As(err, &re) {
192+ if data, ok := re.Data.(map[string]any); ok {
193+ if msg, ok := data["error"].(string); ok && msg != "" {
194+ return msg
195+ }
196+ }
197+ return re.Message
198+ }
199+ return err.Error()
200+}
201+
202+// alive reports whether the process is still running.
203+func (c *child) alive() bool {
204+ select {
205+ case <-c.exited:
206+ return false
207+ default:
208+ return true
209+ }
210+}
211+
212+// stop ends the child: EOF on its stdin first, then a kill.
213+func (c *child) stop() {
214+ _ = c.stdin.Close()
215+ select {
216+ case <-c.exited:
217+ case <-time.After(2 * time.Second):
218+ _ = c.cmd.Process.Kill()
219+ <-c.exited
220+ }
221+}
222+
223+// tail keeps the last max bytes written to it: the child's stderr, kept for
224+// the error line and nothing else.
225+type tail struct {
226+ mu sync.Mutex
227+ buf []byte
228+ max int
229+}
230+
231+func (t *tail) Write(p []byte) (int, error) {
232+ t.mu.Lock()
233+ defer t.mu.Unlock()
234+ t.buf = append(t.buf, p...)
235+ if len(t.buf) > t.max {
236+ t.buf = t.buf[len(t.buf)-t.max:]
237+ }
238+ return len(p), nil
239+}
240+
241+// lastLine is the last non-empty line written.
242+func (t *tail) lastLine() string {
243+ t.mu.Lock()
244+ defer t.mu.Unlock()
245+ lines := strings.Split(strings.TrimSpace(string(t.buf)), "\n")
246+ return strings.TrimSpace(lines[len(lines)-1])
247+}
248+
249+// exists reports whether path names an existing file.
250+func exists(path string) bool {
251+ _, err := os.Stat(path)
252+ return err == nil
253+}
added internal/ask/pool_test.go +268 -0
new file mode 100644
@@ -0,0 +1,268 @@
1+package ask
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "mm/internal/ui"
15+
16+ sdk "github.com/coder/acp-go-sdk"
17+)
18+
19+// The child agent of these tests is THIS test binary, re-run with
20+// ASK_FAKE_AGENT set: TestFakeAgent then serves ACP on stdin/stdout like
21+// `mm -acp` would, with behaviour picked by the variable's value. No model,
22+// no network, and the real client, pool, pipes and process handling.
23+func TestFakeAgent(t *testing.T) {
24+ mode := os.Getenv("ASK_FAKE_AGENT")
25+ if mode == "" {
26+ t.Skip("helper process only")
27+ }
28+ if mode == "crash" {
29+ fmt.Fprintln(os.Stderr, "[config error: bad.yaml: model must not be empty ]")
30+ os.Exit(1)
31+ }
32+ a := &fakeAgent{mode: mode}
33+ a.conn = sdk.NewAgentSideConnection(a, os.Stdout, os.Stdin)
34+ <-a.conn.Done()
35+ os.Exit(0)
36+}
37+
38+// fakePool is a pool whose children are the fake agent in the given mode.
39+func fakePool(t *testing.T, mode string) *Pool {
40+ t.Helper()
41+ p := NewPool()
42+ p.command = func(string) *exec.Cmd {
43+ cmd := exec.Command(os.Args[0], "-test.run=^TestFakeAgent$")
44+ cmd.Env = append(os.Environ(), "ASK_FAKE_AGENT="+mode)
45+ return cmd
46+ }
47+ t.Cleanup(p.Close)
48+ return p
49+}
50+
51+// configFile is an existing file for Ask's check; its content is not read by
52+// the fake agent.
53+func configFile(t *testing.T) string {
54+ t.Helper()
55+ path := filepath.Join(t.TempDir(), "agent.bob.yaml")
56+ if err := os.WriteFile(path, []byte("model: m\n"), 0o644); err != nil {
57+ t.Fatal(err)
58+ }
59+ return path
60+}
61+
62+// recorder is a ui.Sink that remembers what it was shown and answers
63+// permission requests with `allow`.
64+type recorder struct {
65+ mu sync.Mutex
66+ text strings.Builder
67+ events []string
68+ allow bool
69+}
70+
71+func (r *recorder) log(s string) { r.mu.Lock(); r.events = append(r.events, s); r.mu.Unlock() }
72+func (r *recorder) Text(s string) { r.mu.Lock(); r.text.WriteString(s); r.mu.Unlock() }
73+func (r *recorder) Reasoning(s string) { r.log("thought:" + s) }
74+func (r *recorder) ToolStart(ev ui.ToolEvent) { r.log("start:" + ev.Title + ":" + ev.Tool) }
75+func (r *recorder) ToolRunning(string) { r.log("running") }
76+func (r *recorder) ToolEnd(_ string, res ui.ToolResult) {
77+ r.log(fmt.Sprintf("end:%s:%v", res.Output, res.Failed))
78+}
79+func (r *recorder) WorkDir() string { return "" }
80+func (r *recorder) Allow(_ context.Context, ev ui.ToolEvent) bool {
81+ r.log("allow?" + ev.Title)
82+ return r.allow
83+}
84+func (r *recorder) all() string {
85+ r.mu.Lock()
86+ defer r.mu.Unlock()
87+ return strings.Join(r.events, " | ")
88+}
89+
90+// One ask: the prompt reaches the child, its text streams to the sink and is
91+// returned, its tool call is shown under bob's name with the permission asked
92+// of the asker, and its thoughts are shown as thoughts.
93+func TestAskStreamsTheAnswerAndTheToolCalls(t *testing.T) {
94+ p := fakePool(t, "tool")
95+ sink := &recorder{allow: true}
96+ answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "what is 2+2?", sink)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if answer != "You asked: what is 2+2? (tool allowed)" || sink.text.String() != answer {
101+ t.Errorf("answer %q, streamed %q", answer, sink.text.String())
102+ }
103+ // The SDK delivers the child's requests (the permission) concurrently with
104+ // its notifications (the thought), so the thought's place may vary; the
105+ // tool call's own order may not: shown, asked, running, done.
106+ got := sink.all()
107+ if !strings.Contains(got, "thought:hmm") {
108+ t.Errorf("events %s: the thought is missing", got)
109+ }
110+ tool := strings.ReplaceAll(strings.ReplaceAll(got, "thought:hmm | ", ""), " | thought:hmm", "")
111+ if want := "start:bob · bash: ls:bob/bash | allow?bob · bash: ls | running | end:a.txt:false"; tool != want {
112+ t.Errorf("tool events:\n got %s\nwant %s", tool, want)
113+ }
114+}
115+
116+// A refusal from the asker's user reaches the child as its reject option.
117+func TestAskForwardsARefusal(t *testing.T) {
118+ p := fakePool(t, "tool")
119+ answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{allow: false})
120+ if err != nil || !strings.HasSuffix(answer, "(tool rejected)") {
121+ t.Errorf("answer %q, err %v, want the child to see the rejection", answer, err)
122+ }
123+}
124+
125+// The child is started once and reused, with a fresh session per ask.
126+func TestAskReusesTheChildWithAFreshSession(t *testing.T) {
127+ p := fakePool(t, "echo")
128+ cfg := configFile(t)
129+ first, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "one", &recorder{})
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ c := p.children["bob"]
134+ second, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "two", &recorder{})
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if p.children["bob"] != c {
139+ t.Error("the child was started again")
140+ }
141+ if first == second || !strings.Contains(second, "session 2") {
142+ t.Errorf("answers %q / %q, want two sessions", first, second)
143+ }
144+}
145+
146+// Cancelling the asker's context cancels the child's turn: Ask returns the
147+// context's error and what had streamed, and the child is still usable.
148+func TestAskIsCancellable(t *testing.T) {
149+ p := fakePool(t, "slow")
150+ ctx, cancel := context.WithCancel(context.Background())
151+ sink := &recorder{}
152+ go func() {
153+ for !strings.Contains(sink.textNow(), "working") {
154+ time.Sleep(10 * time.Millisecond)
155+ }
156+ cancel()
157+ }()
158+ answer, err := p.Ask(ctx, "bob", configFile(t), t.TempDir(), "q", sink)
159+ if err != context.Canceled || answer != "working" {
160+ t.Errorf("answer %q, err %v, want the partial answer and context.Canceled", answer, err)
161+ }
162+ if !p.children["bob"].alive() {
163+ t.Error("the child died with the cancelled ask")
164+ }
165+}
166+
167+func (r *recorder) textNow() string { r.mu.Lock(); defer r.mu.Unlock(); return r.text.String() }
168+
169+// A child that fails to start reports its own last stderr line; a missing
170+// config file is reported before anything starts.
171+func TestAskReportsTheChildFailure(t *testing.T) {
172+ p := fakePool(t, "crash")
173+ _, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{})
174+ if err == nil || !strings.Contains(err.Error(), "bob did not start") || !strings.Contains(err.Error(), "[config error: bad.yaml: model must not be empty ]") {
175+ t.Errorf("err = %v", err)
176+ }
177+
178+ _, err = fakePool(t, "echo").Ask(context.Background(), "bob", filepath.Join(t.TempDir(), "nope.yaml"), t.TempDir(), "q", &recorder{})
179+ if err == nil || !strings.Contains(err.Error(), "bob: config file") {
180+ t.Errorf("missing config: err = %v", err)
181+ }
182+}
183+
184+// An agent asked through MaxDepth levels cannot ask further.
185+func TestAskRespectsTheDepthLimit(t *testing.T) {
186+ t.Setenv(DepthEnv, fmt.Sprint(MaxDepth))
187+ if _, err := fakePool(t, "echo").Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{}); err == nil || !strings.Contains(err.Error(), "cannot ask further") {
188+ t.Errorf("err = %v", err)
189+ }
190+}
191+
192+// --- the fake agent ----------------------------------------------------------
193+
194+type fakeAgent struct {
195+ mode string
196+ conn *sdk.AgentSideConnection
197+ sessions int
198+}
199+
200+func (a *fakeAgent) send(ctx context.Context, sid sdk.SessionId, u sdk.SessionUpdate) {
201+ _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: u})
202+}
203+
204+func (a *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
205+ question := ""
206+ if len(p.Prompt) > 0 && p.Prompt[0].Text != nil {
207+ question = p.Prompt[0].Text.Text
208+ }
209+ switch a.mode {
210+ case "echo":
211+ a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText(fmt.Sprintf("session %d: %s", a.sessions, question)))
212+ case "slow":
213+ a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("working"))
214+ <-ctx.Done()
215+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
216+ case "tool":
217+ a.send(ctx, p.SessionId, sdk.UpdateAgentThoughtText("hmm"))
218+ a.send(ctx, p.SessionId, sdk.StartToolCall("call_1", "bash: ls", sdk.WithStartKind(sdk.ToolKindExecute), sdk.WithStartStatus(sdk.ToolCallStatusPending)))
219+ resp, err := a.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{
220+ SessionId: p.SessionId,
221+ ToolCall: sdk.ToolCallUpdate{ToolCallId: "call_1", Title: sdk.Ptr("bash: ls")},
222+ Options: []sdk.PermissionOption{
223+ {Kind: sdk.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: "allow"},
224+ {Kind: sdk.PermissionOptionKindRejectOnce, Name: "Reject", OptionId: "reject"},
225+ },
226+ })
227+ verdict := "rejected"
228+ if err == nil && resp.Outcome.Selected != nil && resp.Outcome.Selected.OptionId == "allow" {
229+ verdict = "allowed"
230+ a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress)))
231+ a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusCompleted), sdk.WithUpdateContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock("a.txt"))})))
232+ }
233+ a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("You asked: "+question+" (tool "+verdict+")"))
234+ }
235+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
236+}
237+
238+func (a *fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
239+ a.sessions++
240+ return sdk.NewSessionResponse{SessionId: sdk.SessionId(fmt.Sprintf("s%d", a.sessions))}, nil
241+}
242+
243+func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) {
244+ return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber}, nil
245+}
246+
247+func (a *fakeAgent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) {
248+ return sdk.AuthenticateResponse{}, nil
249+}
250+func (a *fakeAgent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) {
251+ return sdk.LogoutResponse{}, nil
252+}
253+func (a *fakeAgent) Cancel(context.Context, sdk.CancelNotification) error { return nil }
254+func (a *fakeAgent) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) {
255+ return sdk.CloseSessionResponse{}, nil
256+}
257+func (a *fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
258+ return sdk.ListSessionsResponse{}, nil
259+}
260+func (a *fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
261+ return sdk.ResumeSessionResponse{}, nil
262+}
263+func (a *fakeAgent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) {
264+ return sdk.SetSessionConfigOptionResponse{}, nil
265+}
266+func (a *fakeAgent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) {
267+ return sdk.SetSessionModeResponse{}, nil
268+}
new file mode 100644
@@ -0,0 +1,268 @@
1+package ask
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "mm/internal/ui"
15+
16+ sdk "github.com/coder/acp-go-sdk"
17+)
18+
19+// The child agent of these tests is THIS test binary, re-run with
20+// ASK_FAKE_AGENT set: TestFakeAgent then serves ACP on stdin/stdout like
21+// `mm -acp` would, with behaviour picked by the variable's value. No model,
22+// no network, and the real client, pool, pipes and process handling.
23+func TestFakeAgent(t *testing.T) {
24+ mode := os.Getenv("ASK_FAKE_AGENT")
25+ if mode == "" {
26+ t.Skip("helper process only")
27+ }
28+ if mode == "crash" {
29+ fmt.Fprintln(os.Stderr, "[config error: bad.yaml: model must not be empty ]")
30+ os.Exit(1)
31+ }
32+ a := &fakeAgent{mode: mode}
33+ a.conn = sdk.NewAgentSideConnection(a, os.Stdout, os.Stdin)
34+ <-a.conn.Done()
35+ os.Exit(0)
36+}
37+
38+// fakePool is a pool whose children are the fake agent in the given mode.
39+func fakePool(t *testing.T, mode string) *Pool {
40+ t.Helper()
41+ p := NewPool()
42+ p.command = func(string) *exec.Cmd {
43+ cmd := exec.Command(os.Args[0], "-test.run=^TestFakeAgent$")
44+ cmd.Env = append(os.Environ(), "ASK_FAKE_AGENT="+mode)
45+ return cmd
46+ }
47+ t.Cleanup(p.Close)
48+ return p
49+}
50+
51+// configFile is an existing file for Ask's check; its content is not read by
52+// the fake agent.
53+func configFile(t *testing.T) string {
54+ t.Helper()
55+ path := filepath.Join(t.TempDir(), "agent.bob.yaml")
56+ if err := os.WriteFile(path, []byte("model: m\n"), 0o644); err != nil {
57+ t.Fatal(err)
58+ }
59+ return path
60+}
61+
62+// recorder is a ui.Sink that remembers what it was shown and answers
63+// permission requests with `allow`.
64+type recorder struct {
65+ mu sync.Mutex
66+ text strings.Builder
67+ events []string
68+ allow bool
69+}
70+
71+func (r *recorder) log(s string) { r.mu.Lock(); r.events = append(r.events, s); r.mu.Unlock() }
72+func (r *recorder) Text(s string) { r.mu.Lock(); r.text.WriteString(s); r.mu.Unlock() }
73+func (r *recorder) Reasoning(s string) { r.log("thought:" + s) }
74+func (r *recorder) ToolStart(ev ui.ToolEvent) { r.log("start:" + ev.Title + ":" + ev.Tool) }
75+func (r *recorder) ToolRunning(string) { r.log("running") }
76+func (r *recorder) ToolEnd(_ string, res ui.ToolResult) {
77+ r.log(fmt.Sprintf("end:%s:%v", res.Output, res.Failed))
78+}
79+func (r *recorder) WorkDir() string { return "" }
80+func (r *recorder) Allow(_ context.Context, ev ui.ToolEvent) bool {
81+ r.log("allow?" + ev.Title)
82+ return r.allow
83+}
84+func (r *recorder) all() string {
85+ r.mu.Lock()
86+ defer r.mu.Unlock()
87+ return strings.Join(r.events, " | ")
88+}
89+
90+// One ask: the prompt reaches the child, its text streams to the sink and is
91+// returned, its tool call is shown under bob's name with the permission asked
92+// of the asker, and its thoughts are shown as thoughts.
93+func TestAskStreamsTheAnswerAndTheToolCalls(t *testing.T) {
94+ p := fakePool(t, "tool")
95+ sink := &recorder{allow: true}
96+ answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "what is 2+2?", sink)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if answer != "You asked: what is 2+2? (tool allowed)" || sink.text.String() != answer {
101+ t.Errorf("answer %q, streamed %q", answer, sink.text.String())
102+ }
103+ // The SDK delivers the child's requests (the permission) concurrently with
104+ // its notifications (the thought), so the thought's place may vary; the
105+ // tool call's own order may not: shown, asked, running, done.
106+ got := sink.all()
107+ if !strings.Contains(got, "thought:hmm") {
108+ t.Errorf("events %s: the thought is missing", got)
109+ }
110+ tool := strings.ReplaceAll(strings.ReplaceAll(got, "thought:hmm | ", ""), " | thought:hmm", "")
111+ if want := "start:bob · bash: ls:bob/bash | allow?bob · bash: ls | running | end:a.txt:false"; tool != want {
112+ t.Errorf("tool events:\n got %s\nwant %s", tool, want)
113+ }
114+}
115+
116+// A refusal from the asker's user reaches the child as its reject option.
117+func TestAskForwardsARefusal(t *testing.T) {
118+ p := fakePool(t, "tool")
119+ answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{allow: false})
120+ if err != nil || !strings.HasSuffix(answer, "(tool rejected)") {
121+ t.Errorf("answer %q, err %v, want the child to see the rejection", answer, err)
122+ }
123+}
124+
125+// The child is started once and reused, with a fresh session per ask.
126+func TestAskReusesTheChildWithAFreshSession(t *testing.T) {
127+ p := fakePool(t, "echo")
128+ cfg := configFile(t)
129+ first, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "one", &recorder{})
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ c := p.children["bob"]
134+ second, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "two", &recorder{})
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if p.children["bob"] != c {
139+ t.Error("the child was started again")
140+ }
141+ if first == second || !strings.Contains(second, "session 2") {
142+ t.Errorf("answers %q / %q, want two sessions", first, second)
143+ }
144+}
145+
146+// Cancelling the asker's context cancels the child's turn: Ask returns the
147+// context's error and what had streamed, and the child is still usable.
148+func TestAskIsCancellable(t *testing.T) {
149+ p := fakePool(t, "slow")
150+ ctx, cancel := context.WithCancel(context.Background())
151+ sink := &recorder{}
152+ go func() {
153+ for !strings.Contains(sink.textNow(), "working") {
154+ time.Sleep(10 * time.Millisecond)
155+ }
156+ cancel()
157+ }()
158+ answer, err := p.Ask(ctx, "bob", configFile(t), t.TempDir(), "q", sink)
159+ if err != context.Canceled || answer != "working" {
160+ t.Errorf("answer %q, err %v, want the partial answer and context.Canceled", answer, err)
161+ }
162+ if !p.children["bob"].alive() {
163+ t.Error("the child died with the cancelled ask")
164+ }
165+}
166+
167+func (r *recorder) textNow() string { r.mu.Lock(); defer r.mu.Unlock(); return r.text.String() }
168+
169+// A child that fails to start reports its own last stderr line; a missing
170+// config file is reported before anything starts.
171+func TestAskReportsTheChildFailure(t *testing.T) {
172+ p := fakePool(t, "crash")
173+ _, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{})
174+ if err == nil || !strings.Contains(err.Error(), "bob did not start") || !strings.Contains(err.Error(), "[config error: bad.yaml: model must not be empty ]") {
175+ t.Errorf("err = %v", err)
176+ }
177+
178+ _, err = fakePool(t, "echo").Ask(context.Background(), "bob", filepath.Join(t.TempDir(), "nope.yaml"), t.TempDir(), "q", &recorder{})
179+ if err == nil || !strings.Contains(err.Error(), "bob: config file") {
180+ t.Errorf("missing config: err = %v", err)
181+ }
182+}
183+
184+// An agent asked through MaxDepth levels cannot ask further.
185+func TestAskRespectsTheDepthLimit(t *testing.T) {
186+ t.Setenv(DepthEnv, fmt.Sprint(MaxDepth))
187+ if _, err := fakePool(t, "echo").Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{}); err == nil || !strings.Contains(err.Error(), "cannot ask further") {
188+ t.Errorf("err = %v", err)
189+ }
190+}
191+
192+// --- the fake agent ----------------------------------------------------------
193+
194+type fakeAgent struct {
195+ mode string
196+ conn *sdk.AgentSideConnection
197+ sessions int
198+}
199+
200+func (a *fakeAgent) send(ctx context.Context, sid sdk.SessionId, u sdk.SessionUpdate) {
201+ _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: u})
202+}
203+
204+func (a *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
205+ question := ""
206+ if len(p.Prompt) > 0 && p.Prompt[0].Text != nil {
207+ question = p.Prompt[0].Text.Text
208+ }
209+ switch a.mode {
210+ case "echo":
211+ a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText(fmt.Sprintf("session %d: %s", a.sessions, question)))
212+ case "slow":
213+ a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("working"))
214+ <-ctx.Done()
215+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
216+ case "tool":
217+ a.send(ctx, p.SessionId, sdk.UpdateAgentThoughtText("hmm"))
218+ a.send(ctx, p.SessionId, sdk.StartToolCall("call_1", "bash: ls", sdk.WithStartKind(sdk.ToolKindExecute), sdk.WithStartStatus(sdk.ToolCallStatusPending)))
219+ resp, err := a.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{
220+ SessionId: p.SessionId,
221+ ToolCall: sdk.ToolCallUpdate{ToolCallId: "call_1", Title: sdk.Ptr("bash: ls")},
222+ Options: []sdk.PermissionOption{
223+ {Kind: sdk.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: "allow"},
224+ {Kind: sdk.PermissionOptionKindRejectOnce, Name: "Reject", OptionId: "reject"},
225+ },
226+ })
227+ verdict := "rejected"
228+ if err == nil && resp.Outcome.Selected != nil && resp.Outcome.Selected.OptionId == "allow" {
229+ verdict = "allowed"
230+ a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress)))
231+ a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusCompleted), sdk.WithUpdateContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock("a.txt"))})))
232+ }
233+ a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("You asked: "+question+" (tool "+verdict+")"))
234+ }
235+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
236+}
237+
238+func (a *fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
239+ a.sessions++
240+ return sdk.NewSessionResponse{SessionId: sdk.SessionId(fmt.Sprintf("s%d", a.sessions))}, nil
241+}
242+
243+func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) {
244+ return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber}, nil
245+}
246+
247+func (a *fakeAgent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) {
248+ return sdk.AuthenticateResponse{}, nil
249+}
250+func (a *fakeAgent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) {
251+ return sdk.LogoutResponse{}, nil
252+}
253+func (a *fakeAgent) Cancel(context.Context, sdk.CancelNotification) error { return nil }
254+func (a *fakeAgent) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) {
255+ return sdk.CloseSessionResponse{}, nil
256+}
257+func (a *fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
258+ return sdk.ListSessionsResponse{}, nil
259+}
260+func (a *fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
261+ return sdk.ResumeSessionResponse{}, nil
262+}
263+func (a *fakeAgent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) {
264+ return sdk.SetSessionConfigOptionResponse{}, nil
265+}
266+func (a *fakeAgent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) {
267+ return sdk.SetSessionModeResponse{}, nil
268+}
added internal/ask/run.go +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+package ask
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "mm/internal/command"
9+ "mm/internal/config"
10+ "mm/internal/ui"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+// Runner is what a front end needs to run /ask: Pool in production, a fake
16+// in the front ends' tests.
17+type Runner interface {
18+ Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error)
19+}
20+
21+var _ Runner = (*Pool)(nil)
22+
23+// Run is what the three front ends call for one /ask: it builds what the
24+// other agent receives (the question, and the conversation for
25+// /ask-with-context), asks it, and returns the exchange to append to the
26+// history — or an error, and then nothing to append: a failed or cancelled
27+// ask leaves the history as it was.
28+//
29+// The answer itself has already been shown, as it streamed, through sink.
30+//
31+// req, ok, err := command.ParseAsk(input)
32+// if ok && err == nil {
33+// exchange, err := pool.Run(ctx, req, messages, cwd, sink)
34+// if err == nil {
35+// messages = append(messages, exchange...)
36+// }
37+// }
38+func (p *Pool) Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error) {
39+ path, ok := config.Cfg.MMConfigs[req.Agent]
40+ if !ok {
41+ return nil, fmt.Errorf("unknown agent %q", req.Agent)
42+ }
43+ transcript := ""
44+ if req.WithContext {
45+ transcript = Transcript(history, config.Cfg.MaxOutput)
46+ }
47+ answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Prompt, transcript), sink)
48+ if err != nil {
49+ return nil, err
50+ }
51+ if answer == "" {
52+ return nil, errors.New(req.Agent + " gave no answer")
53+ }
54+ return Exchange(req.Agent, Describe(path), req.WithContext, req.Prompt, answer, config.Cfg.MaxOutput), nil
55+}
56+
57+// Label is the one line a front end shows before the answer streams: who is
58+// asked, on what model, and how.
59+//
60+// ask.Label(req) // "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…"
61+func Label(req command.AskRequest) string {
62+ line := fmt.Sprintf("🗣 asking %s (%s)", req.Agent, Describe(config.Cfg.MMConfigs[req.Agent]))
63+ if req.WithContext {
64+ line += ", with the conversation"
65+ }
66+ return line + "…"
67+}
68+
69+// Missing lists the configured agents whose file does not exist, for the
70+// start-up warnings: the agent starts anyway, and /ask-<name> says it again.
71+func Missing() []string {
72+ var warnings []string
73+ for _, name := range config.AgentNames() {
74+ if path := config.Cfg.MMConfigs[name]; !exists(path) {
75+ warnings = append(warnings, fmt.Sprintf("mmConfigs: %s → %s not found — /ask-%s will fail", name, path, name))
76+ }
77+ }
78+ return warnings
79+}
new file mode 100644
@@ -0,0 +1,79 @@
1+package ask
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "mm/internal/command"
9+ "mm/internal/config"
10+ "mm/internal/ui"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+// Runner is what a front end needs to run /ask: Pool in production, a fake
16+// in the front ends' tests.
17+type Runner interface {
18+ Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error)
19+}
20+
21+var _ Runner = (*Pool)(nil)
22+
23+// Run is what the three front ends call for one /ask: it builds what the
24+// other agent receives (the question, and the conversation for
25+// /ask-with-context), asks it, and returns the exchange to append to the
26+// history — or an error, and then nothing to append: a failed or cancelled
27+// ask leaves the history as it was.
28+//
29+// The answer itself has already been shown, as it streamed, through sink.
30+//
31+// req, ok, err := command.ParseAsk(input)
32+// if ok && err == nil {
33+// exchange, err := pool.Run(ctx, req, messages, cwd, sink)
34+// if err == nil {
35+// messages = append(messages, exchange...)
36+// }
37+// }
38+func (p *Pool) Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error) {
39+ path, ok := config.Cfg.MMConfigs[req.Agent]
40+ if !ok {
41+ return nil, fmt.Errorf("unknown agent %q", req.Agent)
42+ }
43+ transcript := ""
44+ if req.WithContext {
45+ transcript = Transcript(history, config.Cfg.MaxOutput)
46+ }
47+ answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Prompt, transcript), sink)
48+ if err != nil {
49+ return nil, err
50+ }
51+ if answer == "" {
52+ return nil, errors.New(req.Agent + " gave no answer")
53+ }
54+ return Exchange(req.Agent, Describe(path), req.WithContext, req.Prompt, answer, config.Cfg.MaxOutput), nil
55+}
56+
57+// Label is the one line a front end shows before the answer streams: who is
58+// asked, on what model, and how.
59+//
60+// ask.Label(req) // "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…"
61+func Label(req command.AskRequest) string {
62+ line := fmt.Sprintf("🗣 asking %s (%s)", req.Agent, Describe(config.Cfg.MMConfigs[req.Agent]))
63+ if req.WithContext {
64+ line += ", with the conversation"
65+ }
66+ return line + "…"
67+}
68+
69+// Missing lists the configured agents whose file does not exist, for the
70+// start-up warnings: the agent starts anyway, and /ask-<name> says it again.
71+func Missing() []string {
72+ var warnings []string
73+ for _, name := range config.AgentNames() {
74+ if path := config.Cfg.MMConfigs[name]; !exists(path) {
75+ warnings = append(warnings, fmt.Sprintf("mmConfigs: %s → %s not found — /ask-%s will fail", name, path, name))
76+ }
77+ }
78+ return warnings
79+}
added internal/ask/run_test.go +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+package ask
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/command"
11+ "mm/internal/config"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+// withAgent configures one agent, bob, whose file exists.
17+func withAgent(t *testing.T) {
18+ t.Helper()
19+ saved := config.Cfg
20+ t.Cleanup(func() { config.Cfg = saved })
21+ path := filepath.Join(t.TempDir(), "agent.bob.yaml")
22+ if err := os.WriteFile(path, []byte("provider: ollama\nmodel: qwen3.5:2b\n"), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+ config.Cfg.MMConfigs = map[string]string{"bob": path}
26+}
27+
28+// A plain ask sends the question alone and returns the marked pair.
29+func TestRunReturnsTheExchange(t *testing.T) {
30+ withAgent(t)
31+ history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")}
32+ got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, history, t.TempDir(), &recorder{})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(got) != 2 || got[0].Text() != "[asked bob (ollama/qwen3.5:2b): hi]" || got[1].Text() != "[bob's answer]\nsession 1: hi" {
37+ t.Errorf("exchange = %q / %q", got[0].Text(), got[1].Text())
38+ }
39+}
40+
41+// /ask-with-context sends the transcript before the question.
42+func TestRunWithContextSendsTheConversation(t *testing.T) {
43+ withAgent(t)
44+ history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")}
45+ got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", WithContext: true, Prompt: "hi"}, history, t.TempDir(), &recorder{})
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ answer := got[1].Text()
50+ if !strings.Contains(answer, "User: PREVIOUS-QUESTION") || !strings.Contains(answer, "[Question]\nhi") {
51+ t.Errorf("the child received:\n%s", answer)
52+ }
53+ if !strings.Contains(got[0].Text(), "with this conversation as context") {
54+ t.Errorf("question marker = %q", got[0].Text())
55+ }
56+}
57+
58+// A failed ask leaves nothing to append; an unknown agent is refused before
59+// anything starts; a missing file is listed for the start-up warnings.
60+func TestRunFailures(t *testing.T) {
61+ withAgent(t)
62+ if got, err := fakePool(t, "crash").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil || got != nil {
63+ t.Errorf("crash: %v, %v — want an error and no exchange", got, err)
64+ }
65+ if _, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "spock", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil {
66+ t.Error("unknown agent accepted")
67+ }
68+ if len(Missing()) != 0 {
69+ t.Errorf("Missing() = %v with an existing file", Missing())
70+ }
71+ config.Cfg.MMConfigs["riker"] = filepath.Join(t.TempDir(), "nope.yaml")
72+ if m := Missing(); len(m) != 1 || !strings.Contains(m[0], "riker") {
73+ t.Errorf("Missing() = %v", m)
74+ }
75+}
76+
77+// The label names the agent, its model, and the context when sent.
78+func TestLabel(t *testing.T) {
79+ withAgent(t)
80+ if got := Label(command.AskRequest{Agent: "bob", WithContext: true}); got != "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…" {
81+ t.Errorf("Label = %q", got)
82+ }
83+}
new file mode 100644
@@ -0,0 +1,83 @@
1+package ask
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/command"
11+ "mm/internal/config"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+// withAgent configures one agent, bob, whose file exists.
17+func withAgent(t *testing.T) {
18+ t.Helper()
19+ saved := config.Cfg
20+ t.Cleanup(func() { config.Cfg = saved })
21+ path := filepath.Join(t.TempDir(), "agent.bob.yaml")
22+ if err := os.WriteFile(path, []byte("provider: ollama\nmodel: qwen3.5:2b\n"), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+ config.Cfg.MMConfigs = map[string]string{"bob": path}
26+}
27+
28+// A plain ask sends the question alone and returns the marked pair.
29+func TestRunReturnsTheExchange(t *testing.T) {
30+ withAgent(t)
31+ history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")}
32+ got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, history, t.TempDir(), &recorder{})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(got) != 2 || got[0].Text() != "[asked bob (ollama/qwen3.5:2b): hi]" || got[1].Text() != "[bob's answer]\nsession 1: hi" {
37+ t.Errorf("exchange = %q / %q", got[0].Text(), got[1].Text())
38+ }
39+}
40+
41+// /ask-with-context sends the transcript before the question.
42+func TestRunWithContextSendsTheConversation(t *testing.T) {
43+ withAgent(t)
44+ history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")}
45+ got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", WithContext: true, Prompt: "hi"}, history, t.TempDir(), &recorder{})
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ answer := got[1].Text()
50+ if !strings.Contains(answer, "User: PREVIOUS-QUESTION") || !strings.Contains(answer, "[Question]\nhi") {
51+ t.Errorf("the child received:\n%s", answer)
52+ }
53+ if !strings.Contains(got[0].Text(), "with this conversation as context") {
54+ t.Errorf("question marker = %q", got[0].Text())
55+ }
56+}
57+
58+// A failed ask leaves nothing to append; an unknown agent is refused before
59+// anything starts; a missing file is listed for the start-up warnings.
60+func TestRunFailures(t *testing.T) {
61+ withAgent(t)
62+ if got, err := fakePool(t, "crash").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil || got != nil {
63+ t.Errorf("crash: %v, %v — want an error and no exchange", got, err)
64+ }
65+ if _, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "spock", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil {
66+ t.Error("unknown agent accepted")
67+ }
68+ if len(Missing()) != 0 {
69+ t.Errorf("Missing() = %v with an existing file", Missing())
70+ }
71+ config.Cfg.MMConfigs["riker"] = filepath.Join(t.TempDir(), "nope.yaml")
72+ if m := Missing(); len(m) != 1 || !strings.Contains(m[0], "riker") {
73+ t.Errorf("Missing() = %v", m)
74+ }
75+}
76+
77+// The label names the agent, its model, and the context when sent.
78+func TestLabel(t *testing.T) {
79+ withAgent(t)
80+ if got := Label(command.AskRequest{Agent: "bob", WithContext: true}); got != "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…" {
81+ t.Errorf("Label = %q", got)
82+ }
83+}
added internal/command/ask.go +106 -0
new file mode 100644
@@ -0,0 +1,106 @@
1+package command
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "slices"
7+ "strings"
8+)
9+
10+// The /ask family: ask another mm agent (config `mmConfigs`), with or without
11+// the current conversation as context. Generated by SetAgents, because the
12+// per-agent commands are named after the configured agents:
13+//
14+// /ask <name> <prompt> (terminal and window)
15+// /ask-with-context <name> <prompt> (terminal and window)
16+// /ask-<name> <prompt> (every front end, the editor included)
17+// /ask-with-context-<name> <prompt> (every front end)
18+//
19+// The editor gets the per-agent forms only: its `/` menu completes a command
20+// name, never an argument, so "/ask-bob" is the one way to list the agents
21+// there.
22+const (
23+ Ask = "ask"
24+ AskWithContext = "ask-with-context"
25+)
26+
27+// askCommands is the generated part of the table; empty when no agent is
28+// configured, so /help and the menus show no /ask at all.
29+var askCommands []Command
30+
31+// SetAgents generates the /ask family for the given agent names (sorted by
32+// the caller — config.AgentNames). Called once by main after the config is
33+// loaded; nil or empty removes the family.
34+//
35+// command.SetAgents([]string{"bob", "riker"})
36+// command.For(command.Editor) // … ask-bob, ask-with-context-bob, ask-riker, …
37+func SetAgents(names []string) {
38+ askCommands = nil
39+ if len(names) == 0 {
40+ return
41+ }
42+ list := strings.Join(names, "|")
43+ askCommands = append(askCommands,
44+ Command{Name: Ask, Description: "Ask another mm agent (" + list + "); its answer stays in the history", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"},
45+ Command{Name: AskWithContext, Description: "Same, sending the current conversation along", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"},
46+ )
47+ for _, name := range names {
48+ askCommands = append(askCommands,
49+ Command{Name: Ask + "-" + name, Description: "Ask " + name + " (another mm agent); its answer stays in the history", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"},
50+ Command{Name: AskWithContext + "-" + name, Description: "Ask " + name + ", sending the current conversation along", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"},
51+ )
52+ }
53+}
54+
55+// AskRequest is one /ask, parsed.
56+type AskRequest struct {
57+ Agent string // a configured agent name
58+ WithContext bool // send the current conversation along
59+ Prompt string // what to ask, trimmed, never empty
60+}
61+
62+// ParseAsk recognises the /ask family. ok says the input IS an /ask command
63+// (whatever its validity); err then says what is wrong with it — an unknown
64+// agent, a missing prompt — in words the front end shows as is.
65+//
66+// req, ok, err := command.ParseAsk("/ask-bob what is 2+2?")
67+// // req == AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ok, err == nil
68+func ParseAsk(input string) (req AskRequest, ok bool, err error) {
69+ cmd, arg, found := Parse(input)
70+ if !found {
71+ return AskRequest{}, false, nil
72+ }
73+ switch {
74+ case cmd.Name == Ask || cmd.Name == AskWithContext:
75+ req.WithContext = cmd.Name == AskWithContext
76+ req.Agent, req.Prompt, _ = strings.Cut(arg, " ")
77+ if req.Agent == "" {
78+ return req, true, fmt.Errorf("usage: /%s <agent> <prompt> — agents: %s", cmd.Name, strings.Join(agentNames(), ", "))
79+ }
80+ if !slices.Contains(agentNames(), req.Agent) {
81+ return req, true, fmt.Errorf("unknown agent %q — agents: %s", req.Agent, strings.Join(agentNames(), ", "))
82+ }
83+ case strings.HasPrefix(cmd.Name, AskWithContext+"-"):
84+ req.WithContext, req.Agent, req.Prompt = true, strings.TrimPrefix(cmd.Name, AskWithContext+"-"), arg
85+ case strings.HasPrefix(cmd.Name, Ask+"-"):
86+ req.Agent, req.Prompt = strings.TrimPrefix(cmd.Name, Ask+"-"), arg
87+ default:
88+ return AskRequest{}, false, nil
89+ }
90+ req.Prompt = strings.TrimSpace(req.Prompt)
91+ if req.Prompt == "" {
92+ return req, true, errors.New("nothing to ask: type the question after the command")
93+ }
94+ return req, true, nil
95+}
96+
97+// agentNames lists the agents SetAgents was given, in its order.
98+func agentNames() []string {
99+ var names []string
100+ for _, c := range askCommands {
101+ if name, found := strings.CutPrefix(c.Name, Ask+"-"); found && !strings.HasPrefix(c.Name, AskWithContext) {
102+ names = append(names, name)
103+ }
104+ }
105+ return names
106+}
new file mode 100644
@@ -0,0 +1,106 @@
1+package command
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "slices"
7+ "strings"
8+)
9+
10+// The /ask family: ask another mm agent (config `mmConfigs`), with or without
11+// the current conversation as context. Generated by SetAgents, because the
12+// per-agent commands are named after the configured agents:
13+//
14+// /ask <name> <prompt> (terminal and window)
15+// /ask-with-context <name> <prompt> (terminal and window)
16+// /ask-<name> <prompt> (every front end, the editor included)
17+// /ask-with-context-<name> <prompt> (every front end)
18+//
19+// The editor gets the per-agent forms only: its `/` menu completes a command
20+// name, never an argument, so "/ask-bob" is the one way to list the agents
21+// there.
22+const (
23+ Ask = "ask"
24+ AskWithContext = "ask-with-context"
25+)
26+
27+// askCommands is the generated part of the table; empty when no agent is
28+// configured, so /help and the menus show no /ask at all.
29+var askCommands []Command
30+
31+// SetAgents generates the /ask family for the given agent names (sorted by
32+// the caller — config.AgentNames). Called once by main after the config is
33+// loaded; nil or empty removes the family.
34+//
35+// command.SetAgents([]string{"bob", "riker"})
36+// command.For(command.Editor) // … ask-bob, ask-with-context-bob, ask-riker, …
37+func SetAgents(names []string) {
38+ askCommands = nil
39+ if len(names) == 0 {
40+ return
41+ }
42+ list := strings.Join(names, "|")
43+ askCommands = append(askCommands,
44+ Command{Name: Ask, Description: "Ask another mm agent (" + list + "); its answer stays in the history", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"},
45+ Command{Name: AskWithContext, Description: "Same, sending the current conversation along", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"},
46+ )
47+ for _, name := range names {
48+ askCommands = append(askCommands,
49+ Command{Name: Ask + "-" + name, Description: "Ask " + name + " (another mm agent); its answer stays in the history", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"},
50+ Command{Name: AskWithContext + "-" + name, Description: "Ask " + name + ", sending the current conversation along", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"},
51+ )
52+ }
53+}
54+
55+// AskRequest is one /ask, parsed.
56+type AskRequest struct {
57+ Agent string // a configured agent name
58+ WithContext bool // send the current conversation along
59+ Prompt string // what to ask, trimmed, never empty
60+}
61+
62+// ParseAsk recognises the /ask family. ok says the input IS an /ask command
63+// (whatever its validity); err then says what is wrong with it — an unknown
64+// agent, a missing prompt — in words the front end shows as is.
65+//
66+// req, ok, err := command.ParseAsk("/ask-bob what is 2+2?")
67+// // req == AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ok, err == nil
68+func ParseAsk(input string) (req AskRequest, ok bool, err error) {
69+ cmd, arg, found := Parse(input)
70+ if !found {
71+ return AskRequest{}, false, nil
72+ }
73+ switch {
74+ case cmd.Name == Ask || cmd.Name == AskWithContext:
75+ req.WithContext = cmd.Name == AskWithContext
76+ req.Agent, req.Prompt, _ = strings.Cut(arg, " ")
77+ if req.Agent == "" {
78+ return req, true, fmt.Errorf("usage: /%s <agent> <prompt> — agents: %s", cmd.Name, strings.Join(agentNames(), ", "))
79+ }
80+ if !slices.Contains(agentNames(), req.Agent) {
81+ return req, true, fmt.Errorf("unknown agent %q — agents: %s", req.Agent, strings.Join(agentNames(), ", "))
82+ }
83+ case strings.HasPrefix(cmd.Name, AskWithContext+"-"):
84+ req.WithContext, req.Agent, req.Prompt = true, strings.TrimPrefix(cmd.Name, AskWithContext+"-"), arg
85+ case strings.HasPrefix(cmd.Name, Ask+"-"):
86+ req.Agent, req.Prompt = strings.TrimPrefix(cmd.Name, Ask+"-"), arg
87+ default:
88+ return AskRequest{}, false, nil
89+ }
90+ req.Prompt = strings.TrimSpace(req.Prompt)
91+ if req.Prompt == "" {
92+ return req, true, errors.New("nothing to ask: type the question after the command")
93+ }
94+ return req, true, nil
95+}
96+
97+// agentNames lists the agents SetAgents was given, in its order.
98+func agentNames() []string {
99+ var names []string
100+ for _, c := range askCommands {
101+ if name, found := strings.CutPrefix(c.Name, Ask+"-"); found && !strings.HasPrefix(c.Name, AskWithContext) {
102+ names = append(names, name)
103+ }
104+ }
105+ return names
106+}
added internal/command/ask_test.go +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+package command
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// withAgents installs an /ask family for one test and removes it afterwards:
9+// the generated commands are package state, shared by every test.
10+func withAgents(t *testing.T, names ...string) {
11+ t.Helper()
12+ SetAgents(names)
13+ t.Cleanup(func() { SetAgents(nil) })
14+}
15+
16+// No agent configured: no /ask anywhere, so /help and the menus stay as they
17+// were and "/ask bob hi" is a question for the model.
18+func TestNoAgentsNoAskCommands(t *testing.T) {
19+ SetAgents(nil)
20+ for _, c := range All() {
21+ if strings.HasPrefix(c.Name, Ask) {
22+ t.Errorf("unexpected %s with no agents", c.Slash())
23+ }
24+ }
25+ if _, ok, _ := ParseAsk("/ask bob hi"); ok {
26+ t.Error("ParseAsk recognised /ask with no agents configured")
27+ }
28+}
29+
30+// The editor gets the per-agent forms only (its menu completes names, not
31+// arguments); the terminal and the window get the generic forms too.
32+func TestAskCommandsPerFront(t *testing.T) {
33+ withAgents(t, "bob", "riker")
34+ names := func(f Front) []string {
35+ var out []string
36+ for _, c := range For(f) {
37+ if strings.HasPrefix(c.Name, Ask) {
38+ out = append(out, c.Name)
39+ }
40+ }
41+ return out
42+ }
43+ perAgent := "ask-bob ask-with-context-bob ask-riker ask-with-context-riker"
44+ if got := strings.Join(names(Editor), " "); got != perAgent {
45+ t.Errorf("Editor = %q, want %q", got, perAgent)
46+ }
47+ want := "ask ask-with-context " + perAgent
48+ for _, f := range []Front{Terminal, Window} {
49+ if got := strings.Join(names(f), " "); got != want {
50+ t.Errorf("front %d = %q, want %q", f, got, want)
51+ }
52+ }
53+ for _, c := range For(Editor) {
54+ if strings.HasPrefix(c.Name, Ask) && (c.Hint != "<prompt>" || !c.TakesArg) {
55+ t.Errorf("%s: Hint %q TakesArg %v, want <prompt> and true", c.Slash(), c.Hint, c.TakesArg)
56+ }
57+ }
58+ if !strings.Contains(Usage(Terminal), "/ask-bob <prompt>") || !strings.Contains(Usage(Terminal), "/ask <agent> <prompt>") {
59+ t.Errorf("Usage does not show the hints:\n%s", Usage(Terminal))
60+ }
61+}
62+
63+// Every accepted form, and every error the front ends show as is.
64+func TestParseAsk(t *testing.T) {
65+ withAgents(t, "bob", "riker")
66+ for _, tc := range []struct {
67+ in string
68+ ok bool
69+ want AskRequest
70+ wantErr string
71+ }{
72+ {"/ask-bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""},
73+ {" /ask-with-context-riker review it ", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""},
74+ {"/ask bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""},
75+ {"/ask-with-context riker review it", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""},
76+ {"/ask-bob", true, AskRequest{Agent: "bob"}, "nothing to ask"},
77+ {"/ask bob", true, AskRequest{Agent: "bob"}, "nothing to ask"},
78+ {"/ask", true, AskRequest{}, "usage: /ask <agent> <prompt> — agents: bob, riker"},
79+ {"/ask spock hi", true, AskRequest{Agent: "spock", Prompt: "hi"}, `unknown agent "spock" — agents: bob, riker`},
80+ {"/ask-spock hi", false, AskRequest{}, ""},
81+ {"/compact", false, AskRequest{}, ""},
82+ {"ask bob hi", false, AskRequest{}, ""},
83+ } {
84+ got, ok, err := ParseAsk(tc.in)
85+ if ok != tc.ok {
86+ t.Errorf("%q: ok = %v, want %v", tc.in, ok, tc.ok)
87+ continue
88+ }
89+ if tc.wantErr == "" && err != nil {
90+ t.Errorf("%q: unexpected error %v", tc.in, err)
91+ }
92+ if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
93+ t.Errorf("%q: error %v, want one containing %q", tc.in, err, tc.wantErr)
94+ }
95+ if ok && got != tc.want {
96+ t.Errorf("%q: %+v, want %+v", tc.in, got, tc.want)
97+ }
98+ }
99+}
new file mode 100644
@@ -0,0 +1,99 @@
1+package command
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// withAgents installs an /ask family for one test and removes it afterwards:
9+// the generated commands are package state, shared by every test.
10+func withAgents(t *testing.T, names ...string) {
11+ t.Helper()
12+ SetAgents(names)
13+ t.Cleanup(func() { SetAgents(nil) })
14+}
15+
16+// No agent configured: no /ask anywhere, so /help and the menus stay as they
17+// were and "/ask bob hi" is a question for the model.
18+func TestNoAgentsNoAskCommands(t *testing.T) {
19+ SetAgents(nil)
20+ for _, c := range All() {
21+ if strings.HasPrefix(c.Name, Ask) {
22+ t.Errorf("unexpected %s with no agents", c.Slash())
23+ }
24+ }
25+ if _, ok, _ := ParseAsk("/ask bob hi"); ok {
26+ t.Error("ParseAsk recognised /ask with no agents configured")
27+ }
28+}
29+
30+// The editor gets the per-agent forms only (its menu completes names, not
31+// arguments); the terminal and the window get the generic forms too.
32+func TestAskCommandsPerFront(t *testing.T) {
33+ withAgents(t, "bob", "riker")
34+ names := func(f Front) []string {
35+ var out []string
36+ for _, c := range For(f) {
37+ if strings.HasPrefix(c.Name, Ask) {
38+ out = append(out, c.Name)
39+ }
40+ }
41+ return out
42+ }
43+ perAgent := "ask-bob ask-with-context-bob ask-riker ask-with-context-riker"
44+ if got := strings.Join(names(Editor), " "); got != perAgent {
45+ t.Errorf("Editor = %q, want %q", got, perAgent)
46+ }
47+ want := "ask ask-with-context " + perAgent
48+ for _, f := range []Front{Terminal, Window} {
49+ if got := strings.Join(names(f), " "); got != want {
50+ t.Errorf("front %d = %q, want %q", f, got, want)
51+ }
52+ }
53+ for _, c := range For(Editor) {
54+ if strings.HasPrefix(c.Name, Ask) && (c.Hint != "<prompt>" || !c.TakesArg) {
55+ t.Errorf("%s: Hint %q TakesArg %v, want <prompt> and true", c.Slash(), c.Hint, c.TakesArg)
56+ }
57+ }
58+ if !strings.Contains(Usage(Terminal), "/ask-bob <prompt>") || !strings.Contains(Usage(Terminal), "/ask <agent> <prompt>") {
59+ t.Errorf("Usage does not show the hints:\n%s", Usage(Terminal))
60+ }
61+}
62+
63+// Every accepted form, and every error the front ends show as is.
64+func TestParseAsk(t *testing.T) {
65+ withAgents(t, "bob", "riker")
66+ for _, tc := range []struct {
67+ in string
68+ ok bool
69+ want AskRequest
70+ wantErr string
71+ }{
72+ {"/ask-bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""},
73+ {" /ask-with-context-riker review it ", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""},
74+ {"/ask bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""},
75+ {"/ask-with-context riker review it", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""},
76+ {"/ask-bob", true, AskRequest{Agent: "bob"}, "nothing to ask"},
77+ {"/ask bob", true, AskRequest{Agent: "bob"}, "nothing to ask"},
78+ {"/ask", true, AskRequest{}, "usage: /ask <agent> <prompt> — agents: bob, riker"},
79+ {"/ask spock hi", true, AskRequest{Agent: "spock", Prompt: "hi"}, `unknown agent "spock" — agents: bob, riker`},
80+ {"/ask-spock hi", false, AskRequest{}, ""},
81+ {"/compact", false, AskRequest{}, ""},
82+ {"ask bob hi", false, AskRequest{}, ""},
83+ } {
84+ got, ok, err := ParseAsk(tc.in)
85+ if ok != tc.ok {
86+ t.Errorf("%q: ok = %v, want %v", tc.in, ok, tc.ok)
87+ continue
88+ }
89+ if tc.wantErr == "" && err != nil {
90+ t.Errorf("%q: unexpected error %v", tc.in, err)
91+ }
92+ if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
93+ t.Errorf("%q: error %v, want one containing %q", tc.in, err, tc.wantErr)
94+ }
95+ if ok && got != tc.want {
96+ t.Errorf("%q: %+v, want %+v", tc.in, got, tc.want)
97+ }
98+ }
99+}
modified internal/command/command.go +11 -4
@@ -51,6 +51,9 @@ type Command struct {
5151 // TakesArg says "/name something" is still this command ("/resume <id>");
5252 // without it, "/compact now" is a question for the model, as it always was.
5353 TakesArg bool
54+ // Hint names the argument where a usage line or an editor shows it
55+ // ("<prompt>"); "" = the generic "[arg]".
56+ Hint string
5457 }
5558
5659 // Slash is the command as typed.
@@ -71,13 +74,14 @@ var table = []Command{
7174 {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},
7275 }
7376
74-// All returns the whole table, in display order.
75-func All() []Command { return append([]Command(nil), table...) }
77+// All returns the whole table, in display order: the fixed commands, then
78+// the /ask family generated from the configured agents (ask.go).
79+func All() []Command { return append(append([]Command(nil), table...), askCommands...) }
7680
7781 // For returns the commands front f offers, in display order.
7882 func For(f Front) []Command {
7983 var out []Command
80- for _, c := range table {
84+ for _, c := range All() {
8185 if c.Offered(f) {
8286 out = append(out, c)
8387 }
@@ -101,7 +105,7 @@ func Parse(input string) (cmd Command, arg string, ok bool) {
101105 return Command{}, "", false
102106 }
103107 name, rest, _ := strings.Cut(input[1:], " ")
104- for _, c := range table {
108+ for _, c := range All() {
105109 if c.Name != name {
106110 continue
107111 }
@@ -142,6 +146,9 @@ func Usage(f Front) string {
142146
143147 // usage is how a command is typed, with its argument when it takes one.
144148 func usage(c Command) string {
149+ if c.Hint != "" {
150+ return c.Slash() + " " + c.Hint
151+ }
145152 if c.TakesArg {
146153 return c.Slash() + " [arg]"
147154 }
@@ -51,6 +51,9 @@ type Command struct {
51 // TakesArg says "/name something" is still this command ("/resume <id>");51 // TakesArg says "/name something" is still this command ("/resume <id>");
52 // without it, "/compact now" is a question for the model, as it always was.52 // without it, "/compact now" is a question for the model, as it always was.
53 TakesArg bool53 TakesArg bool
54+ // Hint names the argument where a usage line or an editor shows it
55+ // ("<prompt>"); "" = the generic "[arg]".
56+ Hint string
54 }57 }
55 58
56 // Slash is the command as typed.59 // Slash is the command as typed.
@@ -71,13 +74,14 @@ var table = []Command{
71 {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},74 {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},
72 }75 }
73 76
74-// All returns the whole table, in display order.77+// All returns the whole table, in display order: the fixed commands, then
75-func All() []Command { return append([]Command(nil), table...) }78+// the /ask family generated from the configured agents (ask.go).
79+func All() []Command { return append(append([]Command(nil), table...), askCommands...) }
76 80
77 // For returns the commands front f offers, in display order.81 // For returns the commands front f offers, in display order.
78 func For(f Front) []Command {82 func For(f Front) []Command {
79 var out []Command83 var out []Command
80- for _, c := range table {84+ for _, c := range All() {
81 if c.Offered(f) {85 if c.Offered(f) {
82 out = append(out, c)86 out = append(out, c)
83 }87 }
@@ -101,7 +105,7 @@ func Parse(input string) (cmd Command, arg string, ok bool) {
101 return Command{}, "", false105 return Command{}, "", false
102 }106 }
103 name, rest, _ := strings.Cut(input[1:], " ")107 name, rest, _ := strings.Cut(input[1:], " ")
104- for _, c := range table {108+ for _, c := range All() {
105 if c.Name != name {109 if c.Name != name {
106 continue110 continue
107 }111 }
@@ -142,6 +146,9 @@ func Usage(f Front) string {
142 146
143 // usage is how a command is typed, with its argument when it takes one.147 // usage is how a command is typed, with its argument when it takes one.
144 func usage(c Command) string {148 func usage(c Command) string {
149+ if c.Hint != "" {
150+ return c.Slash() + " " + c.Hint
151+ }
145 if c.TakesArg {152 if c.TakesArg {
146 return c.Slash() + " [arg]"153 return c.Slash() + " [arg]"
147 }154 }
added internal/config/agents.go +68 -0
new file mode 100644
@@ -0,0 +1,68 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "regexp"
7+ "sort"
8+ "strings"
9+)
10+
11+// SessionsEnv names the variable that turns session persistence off for one
12+// run ("off"). The /ask machinery sets it on the agents it starts.
13+const SessionsEnv = "AGENT_SESSIONS"
14+
15+// agentName is the format of an mmConfigs name: it becomes the tail of a
16+// command name (/ask-bob), so it must be typable, unambiguous after a dash,
17+// and safe in an editor's `/` menu.
18+var agentName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
19+
20+// validateAgents checks the mmConfigs names and that each has a path. The
21+// files themselves are NOT required to exist: a missing one is a start-up
22+// warning (main), and /ask-<name> then says so — one absent sibling config
23+// must not stop the agent from starting.
24+func (c Config) validateAgents() error {
25+ for _, name := range AgentNames() {
26+ if !agentName.MatchString(name) {
27+ return fmt.Errorf("mmConfigs: %q is not a valid name (lowercase letters, digits and '-', starting with a letter or a digit)", name)
28+ }
29+ // "/ask-with-context-x" must mean one thing: the context form for x.
30+ if strings.HasPrefix(name, "with-context") {
31+ return fmt.Errorf("mmConfigs: %q is not a valid name (it would read as /ask-with-context-…)", name)
32+ }
33+ if c.MMConfigs[name] == "" {
34+ return fmt.Errorf("mmConfigs.%s: the path must not be empty", name)
35+ }
36+ }
37+ return nil
38+}
39+
40+// resolveAgentPaths makes every relative mmConfigs path absolute against the
41+// directory of the config file that declared it — the one anchor that does
42+// not depend on who started mm (a terminal, an editor, another mm).
43+func resolveAgentPaths(configPath string) {
44+ dir := filepath.Dir(configPath)
45+ for name, p := range Cfg.MMConfigs {
46+ if p == "" || filepath.IsAbs(p) {
47+ continue
48+ }
49+ if abs, err := filepath.Abs(filepath.Join(dir, p)); err == nil {
50+ Cfg.MMConfigs[name] = abs
51+ }
52+ }
53+}
54+
55+// AgentNames returns the mmConfigs names, sorted: the order of /help, of the
56+// editor's menu and of the window's picker.
57+//
58+// for _, name := range config.AgentNames() {
59+// fmt.Println("/ask-" + name, config.Cfg.MMConfigs[name])
60+// }
61+func AgentNames() []string {
62+ names := make([]string, 0, len(Cfg.MMConfigs))
63+ for name := range Cfg.MMConfigs {
64+ names = append(names, name)
65+ }
66+ sort.Strings(names)
67+ return names
68+}
new file mode 100644
@@ -0,0 +1,68 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "regexp"
7+ "sort"
8+ "strings"
9+)
10+
11+// SessionsEnv names the variable that turns session persistence off for one
12+// run ("off"). The /ask machinery sets it on the agents it starts.
13+const SessionsEnv = "AGENT_SESSIONS"
14+
15+// agentName is the format of an mmConfigs name: it becomes the tail of a
16+// command name (/ask-bob), so it must be typable, unambiguous after a dash,
17+// and safe in an editor's `/` menu.
18+var agentName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
19+
20+// validateAgents checks the mmConfigs names and that each has a path. The
21+// files themselves are NOT required to exist: a missing one is a start-up
22+// warning (main), and /ask-<name> then says so — one absent sibling config
23+// must not stop the agent from starting.
24+func (c Config) validateAgents() error {
25+ for _, name := range AgentNames() {
26+ if !agentName.MatchString(name) {
27+ return fmt.Errorf("mmConfigs: %q is not a valid name (lowercase letters, digits and '-', starting with a letter or a digit)", name)
28+ }
29+ // "/ask-with-context-x" must mean one thing: the context form for x.
30+ if strings.HasPrefix(name, "with-context") {
31+ return fmt.Errorf("mmConfigs: %q is not a valid name (it would read as /ask-with-context-…)", name)
32+ }
33+ if c.MMConfigs[name] == "" {
34+ return fmt.Errorf("mmConfigs.%s: the path must not be empty", name)
35+ }
36+ }
37+ return nil
38+}
39+
40+// resolveAgentPaths makes every relative mmConfigs path absolute against the
41+// directory of the config file that declared it — the one anchor that does
42+// not depend on who started mm (a terminal, an editor, another mm).
43+func resolveAgentPaths(configPath string) {
44+ dir := filepath.Dir(configPath)
45+ for name, p := range Cfg.MMConfigs {
46+ if p == "" || filepath.IsAbs(p) {
47+ continue
48+ }
49+ if abs, err := filepath.Abs(filepath.Join(dir, p)); err == nil {
50+ Cfg.MMConfigs[name] = abs
51+ }
52+ }
53+}
54+
55+// AgentNames returns the mmConfigs names, sorted: the order of /help, of the
56+// editor's menu and of the window's picker.
57+//
58+// for _, name := range config.AgentNames() {
59+// fmt.Println("/ask-" + name, config.Cfg.MMConfigs[name])
60+// }
61+func AgentNames() []string {
62+ names := make([]string, 0, len(Cfg.MMConfigs))
63+ for name := range Cfg.MMConfigs {
64+ names = append(names, name)
65+ }
66+ sort.Strings(names)
67+ return names
68+}
added internal/config/agents_test.go +96 -0
new file mode 100644
@@ -0,0 +1,96 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "strings"
8+ "testing"
9+)
10+
11+// mmConfigs is read, its relative paths are anchored on the config file's
12+// directory (not the current one), absolute paths are kept, and the names
13+// come back sorted.
14+func TestMMConfigsAreResolvedAgainstTheConfigFile(t *testing.T) {
15+ defaults := Cfg
16+ t.Cleanup(func() { Cfg = defaults })
17+
18+ dir := t.TempDir()
19+ path := filepath.Join(dir, "agent.yaml")
20+ yaml := "mmConfigs:\n riker: other/agent.riker.yaml\n bob: /abs/agent.bob.yaml\n"
21+ if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
22+ t.Fatal(err)
23+ }
24+ if _, err := Load(path); err != nil {
25+ t.Fatal(err)
26+ }
27+ if got, want := Cfg.MMConfigs["riker"], filepath.Join(dir, "other", "agent.riker.yaml"); got != want {
28+ t.Errorf("riker = %q, want %q", got, want)
29+ }
30+ if got := Cfg.MMConfigs["bob"]; got != "/abs/agent.bob.yaml" {
31+ t.Errorf("bob = %q, want the absolute path unchanged", got)
32+ }
33+ if got := AgentNames(); !reflect.DeepEqual(got, []string{"bob", "riker"}) {
34+ t.Errorf("AgentNames = %v, want [bob riker]", got)
35+ }
36+}
37+
38+// A name that could not be a command name, or an empty path, is a config
39+// error naming the culprit; no mmConfigs at all is fine.
40+func TestMMConfigsValidation(t *testing.T) {
41+ defaults := Cfg
42+ t.Cleanup(func() { Cfg = defaults })
43+ dir := t.TempDir()
44+
45+ for _, tc := range []struct{ yaml, want string }{
46+ {"mmConfigs:\n Bob: a.yaml\n", `"Bob" is not a valid name`},
47+ {"mmConfigs:\n ask bob: a.yaml\n", `"ask bob" is not a valid name`},
48+ {"mmConfigs:\n -bob: a.yaml\n", `"-bob" is not a valid name`},
49+ {"mmConfigs:\n bob: \"\"\n", "mmConfigs.bob: the path must not be empty"},
50+ {"mmConfigs:\n with-context-bob: a.yaml\n", "it would read as /ask-with-context"},
51+ } {
52+ Cfg = defaults
53+ Cfg.MMConfigs = nil
54+ path := filepath.Join(dir, "bad.yaml")
55+ if err := os.WriteFile(path, []byte(tc.yaml), 0o644); err != nil {
56+ t.Fatal(err)
57+ }
58+ if _, err := Load(path); err == nil || !strings.Contains(err.Error(), tc.want) {
59+ t.Errorf("%q: Load = %v, want an error containing %q", tc.yaml, err, tc.want)
60+ }
61+ }
62+
63+ Cfg = defaults
64+ Cfg.MMConfigs = nil
65+ if len(AgentNames()) != 0 {
66+ t.Errorf("no mmConfigs: AgentNames = %v, want none", AgentNames())
67+ }
68+}
69+
70+// AGENT_SESSIONS=off turns persistence off whatever the file says — what an
71+// agent started by /ask runs with; any other value leaves the file alone.
72+func TestSessionsEnvTurnsPersistenceOff(t *testing.T) {
73+ defaults := Cfg
74+ t.Cleanup(func() { Cfg = defaults })
75+ path := filepath.Join(t.TempDir(), "agent.yaml")
76+ if err := os.WriteFile(path, []byte("sessions:\n enabled: true\n"), 0o644); err != nil {
77+ t.Fatal(err)
78+ }
79+
80+ t.Setenv(SessionsEnv, "off")
81+ if _, err := Load(path); err != nil {
82+ t.Fatal(err)
83+ }
84+ if Cfg.Sessions.Enabled {
85+ t.Error("AGENT_SESSIONS=off: sessions still enabled")
86+ }
87+
88+ Cfg = defaults
89+ t.Setenv(SessionsEnv, "")
90+ if _, err := Load(path); err != nil {
91+ t.Fatal(err)
92+ }
93+ if !Cfg.Sessions.Enabled {
94+ t.Error("AGENT_SESSIONS unset: the file's sessions.enabled was ignored")
95+ }
96+}
new file mode 100644
@@ -0,0 +1,96 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "strings"
8+ "testing"
9+)
10+
11+// mmConfigs is read, its relative paths are anchored on the config file's
12+// directory (not the current one), absolute paths are kept, and the names
13+// come back sorted.
14+func TestMMConfigsAreResolvedAgainstTheConfigFile(t *testing.T) {
15+ defaults := Cfg
16+ t.Cleanup(func() { Cfg = defaults })
17+
18+ dir := t.TempDir()
19+ path := filepath.Join(dir, "agent.yaml")
20+ yaml := "mmConfigs:\n riker: other/agent.riker.yaml\n bob: /abs/agent.bob.yaml\n"
21+ if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
22+ t.Fatal(err)
23+ }
24+ if _, err := Load(path); err != nil {
25+ t.Fatal(err)
26+ }
27+ if got, want := Cfg.MMConfigs["riker"], filepath.Join(dir, "other", "agent.riker.yaml"); got != want {
28+ t.Errorf("riker = %q, want %q", got, want)
29+ }
30+ if got := Cfg.MMConfigs["bob"]; got != "/abs/agent.bob.yaml" {
31+ t.Errorf("bob = %q, want the absolute path unchanged", got)
32+ }
33+ if got := AgentNames(); !reflect.DeepEqual(got, []string{"bob", "riker"}) {
34+ t.Errorf("AgentNames = %v, want [bob riker]", got)
35+ }
36+}
37+
38+// A name that could not be a command name, or an empty path, is a config
39+// error naming the culprit; no mmConfigs at all is fine.
40+func TestMMConfigsValidation(t *testing.T) {
41+ defaults := Cfg
42+ t.Cleanup(func() { Cfg = defaults })
43+ dir := t.TempDir()
44+
45+ for _, tc := range []struct{ yaml, want string }{
46+ {"mmConfigs:\n Bob: a.yaml\n", `"Bob" is not a valid name`},
47+ {"mmConfigs:\n ask bob: a.yaml\n", `"ask bob" is not a valid name`},
48+ {"mmConfigs:\n -bob: a.yaml\n", `"-bob" is not a valid name`},
49+ {"mmConfigs:\n bob: \"\"\n", "mmConfigs.bob: the path must not be empty"},
50+ {"mmConfigs:\n with-context-bob: a.yaml\n", "it would read as /ask-with-context"},
51+ } {
52+ Cfg = defaults
53+ Cfg.MMConfigs = nil
54+ path := filepath.Join(dir, "bad.yaml")
55+ if err := os.WriteFile(path, []byte(tc.yaml), 0o644); err != nil {
56+ t.Fatal(err)
57+ }
58+ if _, err := Load(path); err == nil || !strings.Contains(err.Error(), tc.want) {
59+ t.Errorf("%q: Load = %v, want an error containing %q", tc.yaml, err, tc.want)
60+ }
61+ }
62+
63+ Cfg = defaults
64+ Cfg.MMConfigs = nil
65+ if len(AgentNames()) != 0 {
66+ t.Errorf("no mmConfigs: AgentNames = %v, want none", AgentNames())
67+ }
68+}
69+
70+// AGENT_SESSIONS=off turns persistence off whatever the file says — what an
71+// agent started by /ask runs with; any other value leaves the file alone.
72+func TestSessionsEnvTurnsPersistenceOff(t *testing.T) {
73+ defaults := Cfg
74+ t.Cleanup(func() { Cfg = defaults })
75+ path := filepath.Join(t.TempDir(), "agent.yaml")
76+ if err := os.WriteFile(path, []byte("sessions:\n enabled: true\n"), 0o644); err != nil {
77+ t.Fatal(err)
78+ }
79+
80+ t.Setenv(SessionsEnv, "off")
81+ if _, err := Load(path); err != nil {
82+ t.Fatal(err)
83+ }
84+ if Cfg.Sessions.Enabled {
85+ t.Error("AGENT_SESSIONS=off: sessions still enabled")
86+ }
87+
88+ Cfg = defaults
89+ t.Setenv(SessionsEnv, "")
90+ if _, err := Load(path); err != nil {
91+ t.Fatal(err)
92+ }
93+ if !Cfg.Sessions.Enabled {
94+ t.Error("AGENT_SESSIONS unset: the file's sessions.enabled was ignored")
95+ }
96+}
modified internal/config/config.go +16 -0
@@ -157,6 +157,12 @@ type Config struct {
157157 // the terminal always has. Only the window mode reads it today; the
158158 // editor has its own dialog and the line mode has no gatekeeper.
159159 Permissions string `yaml:"permissions"`
160+
161+ // MMConfigs: the other mm agents this one can ask (`/ask-<name>`), name
162+ // -> path of that agent's YAML file. A relative path is relative to THIS
163+ // file's directory, and Load makes it absolute (agents.go). Each name
164+ // becomes a command name, hence its format.
165+ MMConfigs map[string]string `yaml:"mmConfigs"`
160166 }
161167
162168 // The two values of Permissions.
@@ -516,6 +522,7 @@ func Load(cliPath string) (string, error) {
516522 return "", fmt.Errorf("%s: %w", path, err)
517523 }
518524 applyEnv()
525+ resolveAgentPaths(path)
519526 if err := Cfg.validate(); err != nil {
520527 return "", fmt.Errorf("%s: %w", path, err)
521528 }
@@ -533,6 +540,12 @@ func applyEnv() {
533540 if v := os.Getenv("AGENT_MODEL"); v != "" {
534541 Cfg.Model = v
535542 }
543+ // AGENT_SESSIONS=off: an agent started by another one's /ask keeps no
544+ // session files — it runs in the asker's project, and its sessions would
545+ // show up in the asker's /sessions and /resume.
546+ if os.Getenv(SessionsEnv) == "off" {
547+ Cfg.Sessions.Enabled = false
548+ }
536549 }
537550
538551 // validate checks the language servers: every one named and runnable, every
@@ -619,5 +632,8 @@ func (c Config) validate() error {
619632 if err := c.MCP.Validate(); err != nil {
620633 return err
621634 }
635+ if err := c.validateAgents(); err != nil {
636+ return err
637+ }
622638 return c.LSP.validate()
623639 }
@@ -157,6 +157,12 @@ type Config struct {
157 // the terminal always has. Only the window mode reads it today; the157 // the terminal always has. Only the window mode reads it today; the
158 // editor has its own dialog and the line mode has no gatekeeper.158 // editor has its own dialog and the line mode has no gatekeeper.
159 Permissions string `yaml:"permissions"`159 Permissions string `yaml:"permissions"`
160+
161+ // MMConfigs: the other mm agents this one can ask (`/ask-<name>`), name
162+ // -> path of that agent's YAML file. A relative path is relative to THIS
163+ // file's directory, and Load makes it absolute (agents.go). Each name
164+ // becomes a command name, hence its format.
165+ MMConfigs map[string]string `yaml:"mmConfigs"`
160 }166 }
161 167
162 // The two values of Permissions.168 // The two values of Permissions.
@@ -516,6 +522,7 @@ func Load(cliPath string) (string, error) {
516 return "", fmt.Errorf("%s: %w", path, err)522 return "", fmt.Errorf("%s: %w", path, err)
517 }523 }
518 applyEnv()524 applyEnv()
525+ resolveAgentPaths(path)
519 if err := Cfg.validate(); err != nil {526 if err := Cfg.validate(); err != nil {
520 return "", fmt.Errorf("%s: %w", path, err)527 return "", fmt.Errorf("%s: %w", path, err)
521 }528 }
@@ -533,6 +540,12 @@ func applyEnv() {
533 if v := os.Getenv("AGENT_MODEL"); v != "" {540 if v := os.Getenv("AGENT_MODEL"); v != "" {
534 Cfg.Model = v541 Cfg.Model = v
535 }542 }
543+ // AGENT_SESSIONS=off: an agent started by another one's /ask keeps no
544+ // session files — it runs in the asker's project, and its sessions would
545+ // show up in the asker's /sessions and /resume.
546+ if os.Getenv(SessionsEnv) == "off" {
547+ Cfg.Sessions.Enabled = false
548+ }
536 }549 }
537 550
538 // validate checks the language servers: every one named and runnable, every551 // validate checks the language servers: every one named and runnable, every
@@ -619,5 +632,8 @@ func (c Config) validate() error {
619 if err := c.MCP.Validate(); err != nil {632 if err := c.MCP.Validate(); err != nil {
620 return err633 return err
621 }634 }
635+ if err := c.validateAgents(); err != nil {
636+ return err
637+ }
622 return c.LSP.validate()638 return c.LSP.validate()
623 }639 }
modified internal/store/store.go +2 -1
@@ -64,7 +64,7 @@ type Session struct {
6464 type Failure struct {
6565 At time.Time `json:"at"`
6666 Turn int `json:"turn"` // user questions in the history when it happened
67- Where string `json:"where"` // FailGeneration, FailCompaction or FailResume
67+ Where string `json:"where"` // FailGeneration, FailCompaction, FailResume or FailAsk
6868 Message string `json:"message"`
6969 Detail string `json:"detail,omitempty"`
7070 Question string `json:"question,omitempty"`
@@ -78,6 +78,7 @@ const (
7878 FailGeneration = "generation" // the model's answer to a question (engine.Generate)
7979 FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact)
8080 FailResume = "resume" // a /resume that could not load its session
81+ FailAsk = "ask" // an /ask to another agent (internal/ask)
8182 )
8283
8384 // maxErrors bounds the trail: a session that fails on every turn must not
@@ -64,7 +64,7 @@ type Session struct {
64 type Failure struct {64 type Failure struct {
65 At time.Time `json:"at"`65 At time.Time `json:"at"`
66 Turn int `json:"turn"` // user questions in the history when it happened66 Turn int `json:"turn"` // user questions in the history when it happened
67- Where string `json:"where"` // FailGeneration, FailCompaction or FailResume67+ Where string `json:"where"` // FailGeneration, FailCompaction, FailResume or FailAsk
68 Message string `json:"message"`68 Message string `json:"message"`
69 Detail string `json:"detail,omitempty"`69 Detail string `json:"detail,omitempty"`
70 Question string `json:"question,omitempty"`70 Question string `json:"question,omitempty"`
@@ -78,6 +78,7 @@ const (
78 FailGeneration = "generation" // the model's answer to a question (engine.Generate)78 FailGeneration = "generation" // the model's answer to a question (engine.Generate)
79 FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact)79 FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact)
80 FailResume = "resume" // a /resume that could not load its session80 FailResume = "resume" // a /resume that could not load its session
81+ FailAsk = "ask" // an /ask to another agent (internal/ask)
81 )82 )
82 83
83 // maxErrors bounds the trail: a session that fails on every turn must not84 // maxErrors bounds the trail: a session that fails on every turn must not
added internal/tui/ask.go +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+// The window's side of /ask: another mm agent answers in the conversation,
2+// its tool calls shown as tool calls, and its answer joins the history for
3+// the next question. Run like a turn — on a goroutine, settled by the loop
4+// goroutine — so Esc and Ctrl-C stop it the same way.
5+
6+package tui
7+
8+import (
9+ "context"
10+ "fmt"
11+
12+ "mm/internal/ask"
13+ "mm/internal/command"
14+ "mm/internal/store"
15+
16+ "github.com/firebase/genkit/go/ai"
17+)
18+
19+// startAsk shows the command as typed and asks req.Agent on a goroutine.
20+func (r *runner) startAsk(p Prompt, req command.AskRequest) {
21+ r.t.AddUser(p.Display())
22+ r.t.Notice(ask.Label(req))
23+ ctx, cancel := context.WithCancel(context.Background())
24+ r.cancel = cancel
25+ r.cancelled.Store(false)
26+ r.busy.Store("asking " + req.Agent)
27+ r.streamed.Store(false)
28+ msgs := append([]*ai.Message(nil), r.messages...)
29+ go func() {
30+ exchange, err := r.opts.Asker.Run(ctx, req, msgs, r.opts.Cwd, &sink{r: r})
31+ r.results <- outcome{kind: "ask", messages: append(msgs, exchange...), err: err, cancelled: ctx.Err() != nil, line: req.Agent}
32+ r.wake()
33+ }()
34+}
35+
36+// applyAsk folds a finished /ask into the session: the exchange is kept only
37+// when the other agent answered.
38+func (r *runner) applyAsk(res outcome) {
39+ r.t.Close()
40+ switch {
41+ case res.err != nil && res.cancelled:
42+ r.t.Notice("🛑 Ask aborted — nothing kept.")
43+ case res.err != nil:
44+ r.t.Error("[error: " + res.err.Error() + "]")
45+ r.record(store.NewFailure(r.messages, store.FailAsk, res.err.Error(), res.err))
46+ default:
47+ r.messages = res.messages
48+ r.save()
49+ r.t.Notice(fmt.Sprintf("🗣 %s's answer is in the history for your next question", res.line))
50+ }
51+}
new file mode 100644
@@ -0,0 +1,51 @@
1+// The window's side of /ask: another mm agent answers in the conversation,
2+// its tool calls shown as tool calls, and its answer joins the history for
3+// the next question. Run like a turn — on a goroutine, settled by the loop
4+// goroutine — so Esc and Ctrl-C stop it the same way.
5+
6+package tui
7+
8+import (
9+ "context"
10+ "fmt"
11+
12+ "mm/internal/ask"
13+ "mm/internal/command"
14+ "mm/internal/store"
15+
16+ "github.com/firebase/genkit/go/ai"
17+)
18+
19+// startAsk shows the command as typed and asks req.Agent on a goroutine.
20+func (r *runner) startAsk(p Prompt, req command.AskRequest) {
21+ r.t.AddUser(p.Display())
22+ r.t.Notice(ask.Label(req))
23+ ctx, cancel := context.WithCancel(context.Background())
24+ r.cancel = cancel
25+ r.cancelled.Store(false)
26+ r.busy.Store("asking " + req.Agent)
27+ r.streamed.Store(false)
28+ msgs := append([]*ai.Message(nil), r.messages...)
29+ go func() {
30+ exchange, err := r.opts.Asker.Run(ctx, req, msgs, r.opts.Cwd, &sink{r: r})
31+ r.results <- outcome{kind: "ask", messages: append(msgs, exchange...), err: err, cancelled: ctx.Err() != nil, line: req.Agent}
32+ r.wake()
33+ }()
34+}
35+
36+// applyAsk folds a finished /ask into the session: the exchange is kept only
37+// when the other agent answered.
38+func (r *runner) applyAsk(res outcome) {
39+ r.t.Close()
40+ switch {
41+ case res.err != nil && res.cancelled:
42+ r.t.Notice("🛑 Ask aborted — nothing kept.")
43+ case res.err != nil:
44+ r.t.Error("[error: " + res.err.Error() + "]")
45+ r.record(store.NewFailure(r.messages, store.FailAsk, res.err.Error(), res.err))
46+ default:
47+ r.messages = res.messages
48+ r.save()
49+ r.t.Notice(fmt.Sprintf("🗣 %s's answer is in the history for your next question", res.line))
50+ }
51+}
added internal/tui/ask_test.go +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/command"
10+ "mm/internal/config"
11+ "mm/internal/ui"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+// fakeAsker plays the other agent: it shows one tool call and its answer
17+// through the sink, or fails, or waits to be cancelled.
18+type fakeAsker struct {
19+ answer string
20+ fail error
21+ block bool
22+}
23+
24+func (a *fakeAsker) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
25+ sink.ToolStart(ui.ToolEvent{ID: "ask-1", Tool: req.Agent + "/bash", Title: req.Agent + " · bash: ls"})
26+ sink.ToolEnd("ask-1", ui.ToolResult{Output: "a.txt"})
27+ if a.block {
28+ <-ctx.Done()
29+ return nil, ctx.Err()
30+ }
31+ if a.fail != nil {
32+ return nil, a.fail
33+ }
34+ sink.Text(a.answer)
35+ return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil
36+}
37+
38+// askRunner is a test runner with one configured agent, bob.
39+func askRunner(t *testing.T, a *fakeAsker) (*runner, *Transcript) {
40+ t.Helper()
41+ r, tr := newTestRunner(t, &fakeModel{})
42+ config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"}
43+ command.SetAgents([]string{"bob"})
44+ t.Cleanup(func() { command.SetAgents(nil) })
45+ r.opts.Asker = a
46+ return r, tr
47+}
48+
49+// /ask-bob runs as a job ("asking bob"), shows bob's tool call and answer,
50+// and keeps the exchange in the history.
51+func TestWindowAskKeepsTheExchange(t *testing.T) {
52+ r, tr := askRunner(t, &fakeAsker{answer: "four"})
53+ before := len(r.messages)
54+ r.submit(promptOf("/ask-bob what is 2+2?"))
55+ if r.busyLabel() != "asking bob" {
56+ t.Fatalf("busy %q, want asking bob", r.busyLabel())
57+ }
58+ waitIdle(t, r)
59+ if len(r.messages) != before+2 || r.messages[len(r.messages)-1].Text() != "four" {
60+ t.Errorf("history: %d messages, last %q", len(r.messages), r.messages[len(r.messages)-1].Text())
61+ }
62+ var kinds []Kind
63+ for _, e := range tr.Entries() {
64+ kinds = append(kinds, e.Kind)
65+ }
66+ if !strings.Contains(notices(tr), "🗣 asking bob") || !strings.Contains(notices(tr), "bob's answer is in the history") || tr.Tools() != 1 {
67+ t.Errorf("transcript kinds %v, notices:\n%s", kinds, notices(tr))
68+ }
69+}
70+
71+// Esc during an ask stops it and keeps nothing; a failure is an error line
72+// and keeps nothing; a bad command is refused without starting anything.
73+func TestWindowAskAbortFailureAndMisuse(t *testing.T) {
74+ r, tr := askRunner(t, &fakeAsker{block: true})
75+ before := len(r.messages)
76+ r.submit(promptOf("/ask-bob hi"))
77+ r.abort()
78+ waitIdle(t, r)
79+ if len(r.messages) != before || !strings.Contains(notices(tr), "Ask aborted — nothing kept") {
80+ t.Errorf("abort: %d messages, notices:\n%s", len(r.messages), notices(tr))
81+ }
82+
83+ r, tr = askRunner(t, &fakeAsker{fail: errors.New("bob did not start: boom")})
84+ r.submit(promptOf("/ask bob hi"))
85+ waitIdle(t, r)
86+ if len(r.messages) != before || !strings.Contains(notices(tr), "[error: bob did not start: boom]") {
87+ t.Errorf("failure: notices:\n%s", notices(tr))
88+ }
89+
90+ r, tr = askRunner(t, &fakeAsker{})
91+ r.submit(promptOf("/ask spock hi"))
92+ if r.isBusy() || !strings.Contains(notices(tr), `unknown agent "spock"`) {
93+ t.Errorf("misuse: busy %v, notices:\n%s", r.isBusy(), notices(tr))
94+ }
95+}
new file mode 100644
@@ -0,0 +1,95 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/command"
10+ "mm/internal/config"
11+ "mm/internal/ui"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+// fakeAsker plays the other agent: it shows one tool call and its answer
17+// through the sink, or fails, or waits to be cancelled.
18+type fakeAsker struct {
19+ answer string
20+ fail error
21+ block bool
22+}
23+
24+func (a *fakeAsker) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
25+ sink.ToolStart(ui.ToolEvent{ID: "ask-1", Tool: req.Agent + "/bash", Title: req.Agent + " · bash: ls"})
26+ sink.ToolEnd("ask-1", ui.ToolResult{Output: "a.txt"})
27+ if a.block {
28+ <-ctx.Done()
29+ return nil, ctx.Err()
30+ }
31+ if a.fail != nil {
32+ return nil, a.fail
33+ }
34+ sink.Text(a.answer)
35+ return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil
36+}
37+
38+// askRunner is a test runner with one configured agent, bob.
39+func askRunner(t *testing.T, a *fakeAsker) (*runner, *Transcript) {
40+ t.Helper()
41+ r, tr := newTestRunner(t, &fakeModel{})
42+ config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"}
43+ command.SetAgents([]string{"bob"})
44+ t.Cleanup(func() { command.SetAgents(nil) })
45+ r.opts.Asker = a
46+ return r, tr
47+}
48+
49+// /ask-bob runs as a job ("asking bob"), shows bob's tool call and answer,
50+// and keeps the exchange in the history.
51+func TestWindowAskKeepsTheExchange(t *testing.T) {
52+ r, tr := askRunner(t, &fakeAsker{answer: "four"})
53+ before := len(r.messages)
54+ r.submit(promptOf("/ask-bob what is 2+2?"))
55+ if r.busyLabel() != "asking bob" {
56+ t.Fatalf("busy %q, want asking bob", r.busyLabel())
57+ }
58+ waitIdle(t, r)
59+ if len(r.messages) != before+2 || r.messages[len(r.messages)-1].Text() != "four" {
60+ t.Errorf("history: %d messages, last %q", len(r.messages), r.messages[len(r.messages)-1].Text())
61+ }
62+ var kinds []Kind
63+ for _, e := range tr.Entries() {
64+ kinds = append(kinds, e.Kind)
65+ }
66+ if !strings.Contains(notices(tr), "🗣 asking bob") || !strings.Contains(notices(tr), "bob's answer is in the history") || tr.Tools() != 1 {
67+ t.Errorf("transcript kinds %v, notices:\n%s", kinds, notices(tr))
68+ }
69+}
70+
71+// Esc during an ask stops it and keeps nothing; a failure is an error line
72+// and keeps nothing; a bad command is refused without starting anything.
73+func TestWindowAskAbortFailureAndMisuse(t *testing.T) {
74+ r, tr := askRunner(t, &fakeAsker{block: true})
75+ before := len(r.messages)
76+ r.submit(promptOf("/ask-bob hi"))
77+ r.abort()
78+ waitIdle(t, r)
79+ if len(r.messages) != before || !strings.Contains(notices(tr), "Ask aborted — nothing kept") {
80+ t.Errorf("abort: %d messages, notices:\n%s", len(r.messages), notices(tr))
81+ }
82+
83+ r, tr = askRunner(t, &fakeAsker{fail: errors.New("bob did not start: boom")})
84+ r.submit(promptOf("/ask bob hi"))
85+ waitIdle(t, r)
86+ if len(r.messages) != before || !strings.Contains(notices(tr), "[error: bob did not start: boom]") {
87+ t.Errorf("failure: notices:\n%s", notices(tr))
88+ }
89+
90+ r, tr = askRunner(t, &fakeAsker{})
91+ r.submit(promptOf("/ask spock hi"))
92+ if r.isBusy() || !strings.Contains(notices(tr), `unknown agent "spock"`) {
93+ t.Errorf("misuse: busy %v, notices:\n%s", r.isBusy(), notices(tr))
94+ }
95+}
modified internal/tui/commands.go +8 -0
@@ -68,6 +68,14 @@ func (r *runner) abort() {
6868 // dispatch runs a slash command, or starts a turn. A command is read from
6969 // the typed text: a paste token is never part of a command name.
7070 func (r *runner) dispatch(p Prompt) {
71+ if req, isAsk, err := command.ParseAsk(p.Typed); isAsk {
72+ if err != nil {
73+ r.t.Error("⚠️ " + err.Error())
74+ return
75+ }
76+ r.startAsk(p, req)
77+ return
78+ }
7179 cmd, arg, ok := command.Parse(p.Typed)
7280 if !ok {
7381 r.startTurn(p)
@@ -68,6 +68,14 @@ func (r *runner) abort() {
68 // dispatch runs a slash command, or starts a turn. A command is read from68 // dispatch runs a slash command, or starts a turn. A command is read from
69 // the typed text: a paste token is never part of a command name.69 // the typed text: a paste token is never part of a command name.
70 func (r *runner) dispatch(p Prompt) {70 func (r *runner) dispatch(p Prompt) {
71+ if req, isAsk, err := command.ParseAsk(p.Typed); isAsk {
72+ if err != nil {
73+ r.t.Error("⚠️ " + err.Error())
74+ return
75+ }
76+ r.startAsk(p, req)
77+ return
78+ }
71 cmd, arg, ok := command.Parse(p.Typed)79 cmd, arg, ok := command.Parse(p.Typed)
72 if !ok {80 if !ok {
73 r.startTurn(p)81 r.startTurn(p)
modified internal/tui/turn.go +12 -3
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "sync/atomic"
99
10+ "mm/internal/ask"
1011 "mm/internal/compact"
1112 "mm/internal/config"
1213 "mm/internal/detector"
@@ -55,6 +56,9 @@ type Options struct {
5556 // Banner is the start-up text main printed (or would have); shown as
5657 // the first notice, since stdout is the window now.
5758 Banner string
59+ // Asker runs the /ask commands (another mm agent); nil when none is
60+ // configured — command.ParseAsk then recognises none.
61+ Asker ask.Runner
5862 }
5963
6064 // Copier is what /copy writes to: clipboard.Copier, or a fake in tests.
@@ -65,7 +69,7 @@ type Copier interface {
6569 // outcome is what a background job — a turn or a compaction — reports back
6670 // to the loop goroutine.
6771 type outcome struct {
68- kind string // "turn" or "compact"
72+ kind string // "turn", "compact" or "ask"
6973 messages []*ai.Message
7074 before int
7175 resp *ai.ModelResponse
@@ -95,7 +99,7 @@ type runner struct {
9599 clip Copier // set by newApp; nil means /copy reports that it cannot
96100
97101 messages []*ai.Message
98- busy atomic.Value // string: "", "thinking", "compressing"
102+ busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>"
99103 quit atomic.Bool
100104 cancel context.CancelFunc
101105 // cancelled says the job in progress has already been told to stop. A
@@ -129,7 +133,8 @@ func newRunner(opts Options, t *Transcript, wake func()) *runner {
129133 return r
130134 }
131135
132-// busyLabel is what the rule shows: "", "thinking" or "compressing".
136+// busyLabel is what the rule shows: "", "thinking", "compressing" or
137+// "asking <agent>".
133138 func (r *runner) busyLabel() string { return r.busy.Load().(string) }
134139
135140 // isBusy reports whether a job is running.
@@ -255,6 +260,10 @@ func (r *runner) settle() {
255260
256261 // apply folds one outcome into the session and the transcript.
257262 func (r *runner) apply(res outcome) {
263+ if res.kind == "ask" {
264+ r.applyAsk(res)
265+ return
266+ }
258267 if res.kind == "compact" {
259268 r.messages = res.messages
260269 switch {
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "sync/atomic"8 "sync/atomic"
9 9
10+ "mm/internal/ask"
10 "mm/internal/compact"11 "mm/internal/compact"
11 "mm/internal/config"12 "mm/internal/config"
12 "mm/internal/detector"13 "mm/internal/detector"
@@ -55,6 +56,9 @@ type Options struct {
55 // Banner is the start-up text main printed (or would have); shown as56 // Banner is the start-up text main printed (or would have); shown as
56 // the first notice, since stdout is the window now.57 // the first notice, since stdout is the window now.
57 Banner string58 Banner string
59+ // Asker runs the /ask commands (another mm agent); nil when none is
60+ // configured — command.ParseAsk then recognises none.
61+ Asker ask.Runner
58 }62 }
59 63
60 // Copier is what /copy writes to: clipboard.Copier, or a fake in tests.64 // Copier is what /copy writes to: clipboard.Copier, or a fake in tests.
@@ -65,7 +69,7 @@ type Copier interface {
65 // outcome is what a background job — a turn or a compaction — reports back69 // outcome is what a background job — a turn or a compaction — reports back
66 // to the loop goroutine.70 // to the loop goroutine.
67 type outcome struct {71 type outcome struct {
68- kind string // "turn" or "compact"72+ kind string // "turn", "compact" or "ask"
69 messages []*ai.Message73 messages []*ai.Message
70 before int74 before int
71 resp *ai.ModelResponse75 resp *ai.ModelResponse
@@ -95,7 +99,7 @@ type runner struct {
95 clip Copier // set by newApp; nil means /copy reports that it cannot99 clip Copier // set by newApp; nil means /copy reports that it cannot
96 100
97 messages []*ai.Message101 messages []*ai.Message
98- busy atomic.Value // string: "", "thinking", "compressing"102+ busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>"
99 quit atomic.Bool103 quit atomic.Bool
100 cancel context.CancelFunc104 cancel context.CancelFunc
101 // cancelled says the job in progress has already been told to stop. A105 // cancelled says the job in progress has already been told to stop. A
@@ -129,7 +133,8 @@ func newRunner(opts Options, t *Transcript, wake func()) *runner {
129 return r133 return r
130 }134 }
131 135
132-// busyLabel is what the rule shows: "", "thinking" or "compressing".136+// busyLabel is what the rule shows: "", "thinking", "compressing" or
137+// "asking <agent>".
133 func (r *runner) busyLabel() string { return r.busy.Load().(string) }138 func (r *runner) busyLabel() string { return r.busy.Load().(string) }
134 139
135 // isBusy reports whether a job is running.140 // isBusy reports whether a job is running.
@@ -255,6 +260,10 @@ func (r *runner) settle() {
255 260
256 // apply folds one outcome into the session and the transcript.261 // apply folds one outcome into the session and the transcript.
257 func (r *runner) apply(res outcome) {262 func (r *runner) apply(res outcome) {
263+ if res.kind == "ask" {
264+ r.applyAsk(res)
265+ return
266+ }
258 if res.kind == "compact" {267 if res.kind == "compact" {
259 r.messages = res.messages268 r.messages = res.messages
260 switch {269 switch {
modified internal/tui/view.go +1 -1
@@ -78,7 +78,7 @@ type View struct {
7878 header string // the header line
7979 status string // the counters on the right of the status bar
8080 version string // shown before them while the bar is wide enough
81- busy string // "", "thinking", "compressing"
81+ busy string // "", "thinking", "compressing", "asking <agent>"
8282 ask *permission
8383
8484 // input is what has been typed but not sent, as lines. A long paste is
@@ -78,7 +78,7 @@ type View struct {
78 header string // the header line78 header string // the header line
79 status string // the counters on the right of the status bar79 status string // the counters on the right of the status bar
80 version string // shown before them while the bar is wide enough80 version string // shown before them while the bar is wide enough
81- busy string // "", "thinking", "compressing"81+ busy string // "", "thinking", "compressing", "asking <agent>"
82 ask *permission82 ask *permission
83 83
84 // input is what has been typed but not sent, as lines. A long paste is84 // input is what has been typed but not sent, as lines. A long paste is
modified main.go +15 -5
@@ -38,6 +38,7 @@ import (
3838
3939 "mm/internal/acp"
4040 "mm/internal/agent"
41+ "mm/internal/ask"
4142 "mm/internal/command"
4243 "mm/internal/config"
4344 "mm/internal/detector"
@@ -162,6 +163,13 @@ func main() {
162163 // is made absolute here, so the banner can say exactly where it looked.
163164 config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)
164165
166+ // The other agents this one can ask (`mmConfigs`): their /ask-<name>
167+ // commands join the shared table before any front end reads it, and their
168+ // processes start on the first ask and stop when this one exits.
169+ command.SetAgents(config.AgentNames())
170+ asker := ask.NewPool()
171+ defer asker.Close()
172+
165173 window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode)
166174
167175 e, err := engine.New(ctx)
@@ -181,6 +189,7 @@ func main() {
181189 set := assembleTools(e, loopDetector)
182190 defer set.close()
183191 agentTools := set.tools
192+ set.warnings = append(set.warnings, ask.Missing()...)
184193
185194 if path == "" {
186195 path = "built-in defaults"
@@ -198,19 +207,19 @@ func main() {
198207 // Three front ends, one wiring: everything above — config, engine, tools,
199208 // detector — is strictly identical whichever loop runs below.
200209 if *acpMode {
201- acp.Run(ctx, e, config.Cfg.System, agentTools)
210+ acp.Run(ctx, e, config.Cfg.System, agentTools, asker)
202211 return
203212 }
204213 if window {
205- runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()))
214+ runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()), asker)
206215 return
207216 }
208- agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
217+ agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker)
209218 }
210219
211220 // runWindow starts the single-window front end, falling back to the line
212221 // mode — banner first — if the terminal cannot be opened after all.
213-func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string) {
222+func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string, asker ask.Runner) {
214223 cwd, _ := os.Getwd()
215224 err := tui.Run(ctx, tui.Options{
216225 Model: e,
@@ -226,6 +235,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
226235 Skills: skills.Declared(),
227236 Version: version.Short(),
228237 Banner: banner,
238+ Asker: asker,
229239 })
230240 if err == nil {
231241 return
@@ -235,7 +245,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
235245 say("[tui: %v — running the line mode]", err)
236246 }
237247 say("%s", banner)
238- agent.Run(ctx, e, config.Cfg.System, agentTools, d)
248+ agent.Run(ctx, e, config.Cfg.System, agentTools, d, asker)
239249 }
240250
241251 // toolSet is what assembleTools hands main: the tools, their names for the
@@ -38,6 +38,7 @@ import (
38 38
39 "mm/internal/acp"39 "mm/internal/acp"
40 "mm/internal/agent"40 "mm/internal/agent"
41+ "mm/internal/ask"
41 "mm/internal/command"42 "mm/internal/command"
42 "mm/internal/config"43 "mm/internal/config"
43 "mm/internal/detector"44 "mm/internal/detector"
@@ -162,6 +163,13 @@ func main() {
162 // is made absolute here, so the banner can say exactly where it looked.163 // is made absolute here, so the banner can say exactly where it looked.
163 config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)164 config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)
164 165
166+ // The other agents this one can ask (`mmConfigs`): their /ask-<name>
167+ // commands join the shared table before any front end reads it, and their
168+ // processes start on the first ask and stop when this one exits.
169+ command.SetAgents(config.AgentNames())
170+ asker := ask.NewPool()
171+ defer asker.Close()
172+
165 window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode)173 window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode)
166 174
167 e, err := engine.New(ctx)175 e, err := engine.New(ctx)
@@ -181,6 +189,7 @@ func main() {
181 set := assembleTools(e, loopDetector)189 set := assembleTools(e, loopDetector)
182 defer set.close()190 defer set.close()
183 agentTools := set.tools191 agentTools := set.tools
192+ set.warnings = append(set.warnings, ask.Missing()...)
184 193
185 if path == "" {194 if path == "" {
186 path = "built-in defaults"195 path = "built-in defaults"
@@ -198,19 +207,19 @@ func main() {
198 // Three front ends, one wiring: everything above — config, engine, tools,207 // Three front ends, one wiring: everything above — config, engine, tools,
199 // detector — is strictly identical whichever loop runs below.208 // detector — is strictly identical whichever loop runs below.
200 if *acpMode {209 if *acpMode {
201- acp.Run(ctx, e, config.Cfg.System, agentTools)210+ acp.Run(ctx, e, config.Cfg.System, agentTools, asker)
202 return211 return
203 }212 }
204 if window {213 if window {
205- runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()))214+ runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()), asker)
206 return215 return
207 }216 }
208- agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)217+ agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker)
209 }218 }
210 219
211 // runWindow starts the single-window front end, falling back to the line220 // runWindow starts the single-window front end, falling back to the line
212 // mode — banner first — if the terminal cannot be opened after all.221 // mode — banner first — if the terminal cannot be opened after all.
213-func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string) {222+func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string, asker ask.Runner) {
214 cwd, _ := os.Getwd()223 cwd, _ := os.Getwd()
215 err := tui.Run(ctx, tui.Options{224 err := tui.Run(ctx, tui.Options{
216 Model: e,225 Model: e,
@@ -226,6 +235,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
226 Skills: skills.Declared(),235 Skills: skills.Declared(),
227 Version: version.Short(),236 Version: version.Short(),
228 Banner: banner,237 Banner: banner,
238+ Asker: asker,
229 })239 })
230 if err == nil {240 if err == nil {
231 return241 return
@@ -235,7 +245,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
235 say("[tui: %v — running the line mode]", err)245 say("[tui: %v — running the line mode]", err)
236 }246 }
237 say("%s", banner)247 say("%s", banner)
238- agent.Run(ctx, e, config.Cfg.System, agentTools, d)248+ agent.Run(ctx, e, config.Cfg.System, agentTools, d, asker)
239 }249 }
240 250
241 // toolSet is what assembleTools hands main: the tools, their names for the251 // toolSet is what assembleTools hands main: the tools, their names for the