bots-garden/mini-mepublic Fork
⑂ 4d19a27
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

✨ mm acp-create: mm init over ACP (tickets 210, 212)

- mm acp-create / mm --acp-create (internal/acpcreate): mm init's wizard
  served over the Agent Client Protocol, for Zed or any ACP client. Forms
  (elicitation/create) when the client advertises them, batched by
  dependency; one question per message otherwise. Files written in the
  session's cwd. Commands: /start, /preview, /back, /write, /restart,
  /dockerinit, /zedconfig, /sbxinit, /help. Reload starts the wizard over.
- agentinit: ParseArgs, Args.Flow, WriteFile, Flow.SetDir/Path, reused by
  the ACP front.
- internal/acpwire: the after-session/new hook and the elicitation scope
  lifting, moved out of internal/acp and shared.
- mm acp: loadSession and session/resume always advertised; without saved
  sessions a reload starts the conversation over and says so.
- mm dockerinit knows the .mm layout (ticket 212): agent.yaml then
  .mm/agent.yaml, .mm/ mounted read-only, sessions/trace writable again
  (created when missing), mm tui with no file. Shared lookup and .mm
  description in internal/scaffold/mmfolder.go; dockerinit.In and
  sbxinit.In/InWith run them for another directory; scaffold.File.Replace.
- mm sbxinit prints "Run the sandbox <name>: sbx env run --force".
- Docs EN+FR: how-to create-an-agent-from-an-editor, cli/acp/configuration
  references, container/sandbox/editor how-tos, explanations, README.
- Tickets: 210, 212, milestones v1.0.9 and v1.1.0 (ticket 211 moved).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agent committed 2026-11-14T08:16:27+01:00 Browse files
4d19a27 parent: 64b7d7a
modified .gitignore +1 -0
@@ -26,3 +26,4 @@ bob
2626 bob-the-bot
2727 bob-the-smoll
2828 bob-coder
29+
@@ -26,3 +26,4 @@ bob
26 bob-the-bot26 bob-the-bot
27 bob-the-smoll27 bob-the-smoll
28 bob-coder28 bob-coder
29+
modified .tickets/issues/0210-acp-create-mode-mm-acp-create-mm-init-from-an-acp.yaml +148 -7
@@ -6,7 +6,7 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-11-14T05:41:20.000Z
9-updatedAt: 2026-11-14T05:46:20.000Z
9+updatedAt: 2026-11-14T07:05:01.000Z
1010 labels:
1111 - enhancement
1212 - kind::feature
@@ -74,10 +74,21 @@ body: |
7474
7575 ## Commands (`available_commands_update`)
7676
77+ - `/start` — start the wizard (any message does too); once started,
78+ asks the current question again
7779 - `/preview` — show the agent.yaml that would be written (`Flow.Preview`)
7880 - `/back` — go back one step
7981 - `/write` — write the file (asks before overwriting, `overwrite` step)
8082 - `/restart` — start the flow again
83+ - `/zedconfig` — Zed `agent_servers` entries for the project's agent:
84+ mm here always, `docker exec` with a compose.yaml, `sbx exec` with a
85+ sbxenv.yaml (added 2026-11-14)
86+ - `/dockerinit [--name] [--agent] [--overwrite]` — `mm dockerinit` for
87+ the session's project (added 2026-11-14, see #212)
88+ - `/sbxinit [--name] [--workspace] [--agent] [--overwrite]` — `mm sbxinit`
89+ for the session's project: writes `sbxenv.yaml` in its cwd (added
90+ 2026-11-14 at the user's request); an existing file is kept unless
91+ `--overwrite`, since nothing can be asked
8192
8293 ## At the end
8394
@@ -116,42 +127,172 @@ tasks:
116127 createdAt: 2026-11-14T05:41:20.000Z
117128 - id: 2
118129 title: Entry point mm --acp-create / mm acp-create, before config load
119- state: open
130+ state: closed
120131 priority: medium
121132 author:
122133 name: k33g
123134 email: ph.charriere@gmail.com
124135 createdAt: 2026-11-14T05:41:20.000Z
136+ updatedAt: 2026-11-14T05:56:48.000Z
137+ closedAt: 2026-11-14T05:56:48.000Z
125138 - id: 3
126139 title: ACP front driving agentinit.Flow (conversation mode + elicitation form)
127- state: open
140+ state: closed
128141 priority: medium
129142 author:
130143 name: k33g
131144 email: ph.charriere@gmail.com
132145 createdAt: 2026-11-14T05:41:20.000Z
146+ updatedAt: 2026-11-14T05:56:48.000Z
147+ closedAt: 2026-11-14T05:56:48.000Z
133148 - id: 4
134149 title: Slash commands /preview /back /write /restart
135- state: open
150+ state: closed
136151 priority: low
137152 author:
138153 name: k33g
139154 email: ph.charriere@gmail.com
140155 createdAt: 2026-11-14T05:41:20.000Z
156+ updatedAt: 2026-11-14T05:56:48.000Z
157+ closedAt: 2026-11-14T05:56:48.000Z
141158 - id: 5
142159 title: Write the file in the session cwd, end message with the command line (+ snippet for known clients)
143- state: open
160+ state: closed
144161 priority: medium
145162 author:
146163 name: k33g
147164 email: ph.charriere@gmail.com
148165 createdAt: 2026-11-14T05:41:20.000Z
166+ updatedAt: 2026-11-14T05:56:48.000Z
167+ closedAt: 2026-11-14T05:56:48.000Z
149168 - id: 6
150169 title: Tests (fake ACP client, golden vs mm init) and docs EN+FR
151- state: open
170+ state: closed
152171 priority: medium
153172 author:
154173 name: k33g
155174 email: ph.charriere@gmail.com
156175 createdAt: 2026-11-14T05:41:20.000Z
157-comments: []
176+comments:
177+ - id: 1
178+ author:
179+ name: k33g
180+ email: ph.charriere@gmail.com
181+ createdAt: 2026-11-14T05:56:48.000Z
182+ body: |
183+ Implemented on `feature/acp-create` (uncommitted): `internal/acpcreate`
184+ (agent, conversation mode, forms batched by dependency), the shared
185+ writer moved to `internal/acpwire`, `agentinit.ParseArgs` /
186+ `Args.Flow` / `WriteFile` / `Flow.SetDir`. Tests with a fake ACP
187+ client: both modes write byte for byte what `mm init --line` writes.
188+ Tried end to end with the real binary over stdio (no config, process
189+ started in `/`, file written in the session cwd).
190+
191+ Not done: task 1 (what real clients show — needs Zed and another
192+ client by hand), the acceptance "tried in two real clients",
193+ permission buttons for choices in conversation mode, and session
194+ config options. qlty not run (not installed in this sandbox).
195+ updatedAt: 2026-11-14T05:56:48.000Z
196+ closedAt: 2026-11-14T05:56:48.000Z
197+ - id: 2
198+ author:
199+ name: k33g
200+ email: ph.charriere@gmail.com
201+ createdAt: 2026-11-14T06:01:15.000Z
202+ body: |
203+ ## Trying it in Zed (task 1)
204+
205+ The `mm` on the `PATH` must be built from this branch first:
206+
207+ ```bash
208+ git checkout feature/acp-create && ./install.sh
209+ ```
210+
211+ Add to Zed's `settings.json` — no config file and no `AGENT_CONFIG`:
212+ this mode reads none.
213+
214+ ```json
215+ {
216+ "agent_servers": {
217+ "mm-create": {
218+ "type": "custom",
219+ "command": "mm",
220+ "args": ["acp-create"]
221+ }
222+ }
223+ }
224+ ```
225+
226+ Variant with answers fixed in advance (those questions are not asked):
227+
228+ ```json
229+ "mm-create": {
230+ "type": "custom",
231+ "command": "mm",
232+ "args": ["acp-create", "--provider", "llmman", "--project", "yes"]
233+ }
234+ ```
235+
236+ ### Use
237+
238+ 1. Open the project in Zed.
239+ 2. Start a new thread with `mm-create`, send `/start` (or any
240+ message): the wizard starts. A welcome message right after the
241+ session opens says so, if the client shows messages outside a turn.
242+ 3. Answer with a number or a value; `.` takes the default.
243+ Commands: `/start`, `/zedconfig`, `/dockerinit`, `/sbxinit`, `/preview`, `/back`, `/write`, `/restart`, `/help`.
244+
245+ At the end the wizard shows the entry that starts the created agent,
246+ with the real path (named after the project folder; the path ends in
247+ `.mm/agent.yaml` with the `.mm` layout), e.g.:
248+
249+ ```json
250+ "agent_servers": {
251+ "mm-my-project": {
252+ "type": "custom",
253+ "command": "mm",
254+ "args": ["acp", "/absolute/path/to/my-project/agent.yaml"]
255+ }
256+ }
257+ ```
258+
259+ Add it next to `mm-create`; that entry is the agent you talk to.
260+
261+ ### What to check
262+
263+ - Logs: **dev: open acp logs** in Zed. The line
264+ `[acp-create] initialize: client "…" elicitation(form)=…` says
265+ whether Zed advertises forms — the open question of task 1.
266+ - Then the same with a second ACP client (JetBrains, a Neovim plugin…),
267+ one with form elicitation if any supports it.
268+ - id: 3
269+ author:
270+ name: k33g
271+ email: ph.charriere@gmail.com
272+ createdAt: 2026-11-14T06:44:14.000Z
273+ body: |
274+ Reload in Zed failed: "Loading or resuming sessions is not supported
275+ by this agent" (same cause as #8 for `mm acp`). Fixed by advertising
276+ `loadSession` and `session/resume`: both register a **fresh** wizard
277+ under the requested id and say it starts over (user's choice — start
278+ from zero; rejected: persisting the answers in the user cache and
279+ replaying them). `internal/acpcreate/restart.go`, test
280+ `TestReloadStartsOver`, docs EN+FR.
281+ - id: 4
282+ author:
283+ name: k33g
284+ email: ph.charriere@gmail.com
285+ createdAt: 2026-11-14T06:56:56.000Z
286+ body: |
287+ Same reload failure for agents run with `mm acp` when
288+ `sessions.enabled` is off (the default of `mm init`): `loadSession`
289+ was advertised only with saved sessions (decision of #8, "honest
290+ capabilities"). Changed at the user's request: `loadSession` and
291+ `session/resume` are now always advertised; without saved sessions
292+ the requested id becomes a new, empty session and one message says the
293+ conversation starts over and how to keep it (`sessions.enabled`).
294+ `session/list` still exists only with saved sessions. With saved
295+ sessions nothing changed (an unknown id is still invalid params).
296+ `internal/acp/persist.go` (`startOver`), tests
297+ `TestReloadWithoutSessionsStartsOver`, docs `reference/acp.md` and
298+ `how-to/use-from-an-editor.md` EN+FR.
@@ -6,7 +6,7 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-11-14T05:41:20.000Z8 createdAt: 2026-11-14T05:41:20.000Z
9-updatedAt: 2026-11-14T05:46:20.000Z9+updatedAt: 2026-11-14T07:05:01.000Z
10 labels:10 labels:
11 - enhancement11 - enhancement
12 - kind::feature12 - kind::feature
@@ -74,10 +74,21 @@ body: |
74 74
75 ## Commands (`available_commands_update`)75 ## Commands (`available_commands_update`)
76 76
77+ - `/start` — start the wizard (any message does too); once started,
78+ asks the current question again
77 - `/preview` — show the agent.yaml that would be written (`Flow.Preview`)79 - `/preview` — show the agent.yaml that would be written (`Flow.Preview`)
78 - `/back` — go back one step80 - `/back` — go back one step
79 - `/write` — write the file (asks before overwriting, `overwrite` step)81 - `/write` — write the file (asks before overwriting, `overwrite` step)
80 - `/restart` — start the flow again82 - `/restart` — start the flow again
83+ - `/zedconfig` — Zed `agent_servers` entries for the project's agent:
84+ mm here always, `docker exec` with a compose.yaml, `sbx exec` with a
85+ sbxenv.yaml (added 2026-11-14)
86+ - `/dockerinit [--name] [--agent] [--overwrite]` — `mm dockerinit` for
87+ the session's project (added 2026-11-14, see #212)
88+ - `/sbxinit [--name] [--workspace] [--agent] [--overwrite]` — `mm sbxinit`
89+ for the session's project: writes `sbxenv.yaml` in its cwd (added
90+ 2026-11-14 at the user's request); an existing file is kept unless
91+ `--overwrite`, since nothing can be asked
81 92
82 ## At the end93 ## At the end
83 94
@@ -116,42 +127,172 @@ tasks:
116 createdAt: 2026-11-14T05:41:20.000Z127 createdAt: 2026-11-14T05:41:20.000Z
117 - id: 2128 - id: 2
118 title: Entry point mm --acp-create / mm acp-create, before config load129 title: Entry point mm --acp-create / mm acp-create, before config load
119- state: open130+ state: closed
120 priority: medium131 priority: medium
121 author:132 author:
122 name: k33g133 name: k33g
123 email: ph.charriere@gmail.com134 email: ph.charriere@gmail.com
124 createdAt: 2026-11-14T05:41:20.000Z135 createdAt: 2026-11-14T05:41:20.000Z
136+ updatedAt: 2026-11-14T05:56:48.000Z
137+ closedAt: 2026-11-14T05:56:48.000Z
125 - id: 3138 - id: 3
126 title: ACP front driving agentinit.Flow (conversation mode + elicitation form)139 title: ACP front driving agentinit.Flow (conversation mode + elicitation form)
127- state: open140+ state: closed
128 priority: medium141 priority: medium
129 author:142 author:
130 name: k33g143 name: k33g
131 email: ph.charriere@gmail.com144 email: ph.charriere@gmail.com
132 createdAt: 2026-11-14T05:41:20.000Z145 createdAt: 2026-11-14T05:41:20.000Z
146+ updatedAt: 2026-11-14T05:56:48.000Z
147+ closedAt: 2026-11-14T05:56:48.000Z
133 - id: 4148 - id: 4
134 title: Slash commands /preview /back /write /restart149 title: Slash commands /preview /back /write /restart
135- state: open150+ state: closed
136 priority: low151 priority: low
137 author:152 author:
138 name: k33g153 name: k33g
139 email: ph.charriere@gmail.com154 email: ph.charriere@gmail.com
140 createdAt: 2026-11-14T05:41:20.000Z155 createdAt: 2026-11-14T05:41:20.000Z
156+ updatedAt: 2026-11-14T05:56:48.000Z
157+ closedAt: 2026-11-14T05:56:48.000Z
141 - id: 5158 - id: 5
142 title: Write the file in the session cwd, end message with the command line (+ snippet for known clients)159 title: Write the file in the session cwd, end message with the command line (+ snippet for known clients)
143- state: open160+ state: closed
144 priority: medium161 priority: medium
145 author:162 author:
146 name: k33g163 name: k33g
147 email: ph.charriere@gmail.com164 email: ph.charriere@gmail.com
148 createdAt: 2026-11-14T05:41:20.000Z165 createdAt: 2026-11-14T05:41:20.000Z
166+ updatedAt: 2026-11-14T05:56:48.000Z
167+ closedAt: 2026-11-14T05:56:48.000Z
149 - id: 6168 - id: 6
150 title: Tests (fake ACP client, golden vs mm init) and docs EN+FR169 title: Tests (fake ACP client, golden vs mm init) and docs EN+FR
151- state: open170+ state: closed
152 priority: medium171 priority: medium
153 author:172 author:
154 name: k33g173 name: k33g
155 email: ph.charriere@gmail.com174 email: ph.charriere@gmail.com
156 createdAt: 2026-11-14T05:41:20.000Z175 createdAt: 2026-11-14T05:41:20.000Z
157-comments: []176+comments:
177+ - id: 1
178+ author:
179+ name: k33g
180+ email: ph.charriere@gmail.com
181+ createdAt: 2026-11-14T05:56:48.000Z
182+ body: |
183+ Implemented on `feature/acp-create` (uncommitted): `internal/acpcreate`
184+ (agent, conversation mode, forms batched by dependency), the shared
185+ writer moved to `internal/acpwire`, `agentinit.ParseArgs` /
186+ `Args.Flow` / `WriteFile` / `Flow.SetDir`. Tests with a fake ACP
187+ client: both modes write byte for byte what `mm init --line` writes.
188+ Tried end to end with the real binary over stdio (no config, process
189+ started in `/`, file written in the session cwd).
190+
191+ Not done: task 1 (what real clients show — needs Zed and another
192+ client by hand), the acceptance "tried in two real clients",
193+ permission buttons for choices in conversation mode, and session
194+ config options. qlty not run (not installed in this sandbox).
195+ updatedAt: 2026-11-14T05:56:48.000Z
196+ closedAt: 2026-11-14T05:56:48.000Z
197+ - id: 2
198+ author:
199+ name: k33g
200+ email: ph.charriere@gmail.com
201+ createdAt: 2026-11-14T06:01:15.000Z
202+ body: |
203+ ## Trying it in Zed (task 1)
204+
205+ The `mm` on the `PATH` must be built from this branch first:
206+
207+ ```bash
208+ git checkout feature/acp-create && ./install.sh
209+ ```
210+
211+ Add to Zed's `settings.json` — no config file and no `AGENT_CONFIG`:
212+ this mode reads none.
213+
214+ ```json
215+ {
216+ "agent_servers": {
217+ "mm-create": {
218+ "type": "custom",
219+ "command": "mm",
220+ "args": ["acp-create"]
221+ }
222+ }
223+ }
224+ ```
225+
226+ Variant with answers fixed in advance (those questions are not asked):
227+
228+ ```json
229+ "mm-create": {
230+ "type": "custom",
231+ "command": "mm",
232+ "args": ["acp-create", "--provider", "llmman", "--project", "yes"]
233+ }
234+ ```
235+
236+ ### Use
237+
238+ 1. Open the project in Zed.
239+ 2. Start a new thread with `mm-create`, send `/start` (or any
240+ message): the wizard starts. A welcome message right after the
241+ session opens says so, if the client shows messages outside a turn.
242+ 3. Answer with a number or a value; `.` takes the default.
243+ Commands: `/start`, `/zedconfig`, `/dockerinit`, `/sbxinit`, `/preview`, `/back`, `/write`, `/restart`, `/help`.
244+
245+ At the end the wizard shows the entry that starts the created agent,
246+ with the real path (named after the project folder; the path ends in
247+ `.mm/agent.yaml` with the `.mm` layout), e.g.:
248+
249+ ```json
250+ "agent_servers": {
251+ "mm-my-project": {
252+ "type": "custom",
253+ "command": "mm",
254+ "args": ["acp", "/absolute/path/to/my-project/agent.yaml"]
255+ }
256+ }
257+ ```
258+
259+ Add it next to `mm-create`; that entry is the agent you talk to.
260+
261+ ### What to check
262+
263+ - Logs: **dev: open acp logs** in Zed. The line
264+ `[acp-create] initialize: client "…" elicitation(form)=…` says
265+ whether Zed advertises forms — the open question of task 1.
266+ - Then the same with a second ACP client (JetBrains, a Neovim plugin…),
267+ one with form elicitation if any supports it.
268+ - id: 3
269+ author:
270+ name: k33g
271+ email: ph.charriere@gmail.com
272+ createdAt: 2026-11-14T06:44:14.000Z
273+ body: |
274+ Reload in Zed failed: "Loading or resuming sessions is not supported
275+ by this agent" (same cause as #8 for `mm acp`). Fixed by advertising
276+ `loadSession` and `session/resume`: both register a **fresh** wizard
277+ under the requested id and say it starts over (user's choice — start
278+ from zero; rejected: persisting the answers in the user cache and
279+ replaying them). `internal/acpcreate/restart.go`, test
280+ `TestReloadStartsOver`, docs EN+FR.
281+ - id: 4
282+ author:
283+ name: k33g
284+ email: ph.charriere@gmail.com
285+ createdAt: 2026-11-14T06:56:56.000Z
286+ body: |
287+ Same reload failure for agents run with `mm acp` when
288+ `sessions.enabled` is off (the default of `mm init`): `loadSession`
289+ was advertised only with saved sessions (decision of #8, "honest
290+ capabilities"). Changed at the user's request: `loadSession` and
291+ `session/resume` are now always advertised; without saved sessions
292+ the requested id becomes a new, empty session and one message says the
293+ conversation starts over and how to keep it (`sessions.enabled`).
294+ `session/list` still exists only with saved sessions. With saved
295+ sessions nothing changed (an unknown id is still invalid params).
296+ `internal/acp/persist.go` (`startOver`), tests
297+ `TestReloadWithoutSessionsStartsOver`, docs `reference/acp.md` and
298+ `how-to/use-from-an-editor.md` EN+FR.
added .tickets/issues/0211-check-if-llamacpp-1-0-0-is-working.yaml +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 211
3+title: Check if llamacpp 1.0.0 is working
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-14T05:56:02.153Z
9+updatedAt: 2026-11-14T06:35:10.000Z
10+labels: []
11+milestone: v1.1.0
12+epic: Providers (LLM Engine)
13+body: ""
14+tasks: []
15+comments: []
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 211
3+title: Check if llamacpp 1.0.0 is working
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-14T05:56:02.153Z
9+updatedAt: 2026-11-14T06:35:10.000Z
10+labels: []
11+milestone: v1.1.0
12+epic: Providers (LLM Engine)
13+body: ""
14+tasks: []
15+comments: []
added .tickets/issues/0212-mm-dockerinit-know-the-mm-folder-project-layout.yaml +163 -0
new file mode 100644
@@ -0,0 +1,163 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 212
3+title: "mm dockerinit: know the .mm folder (Project layout), as mm sbxinit does"
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-14T06:29:41.000Z
9+updatedAt: 2026-11-14T06:37:57.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.9
14+epic: Scaffolding
15+related:
16+ - 208
17+body: |
18+ `mm sbxinit` knows whether the project uses the `.mm` folder (the Project
19+ layout of `mm init --project yes`, ticket 205) since
20+ [#208 — mm sbxinit: know the .mm folder](.tickets/issues/0208-mm-sbxinit-know-the-mm-folder.yaml).
21+ `mm dockerinit` ([internal/dockerinit](internal/dockerinit/main.go)) does
22+ not: it should behave the same way.
23+
24+ ## Today (read 2026-11-14)
25+
26+ - `--agent` defaults to `agent.yaml` and nothing else is looked for: in a
27+ `.mm` project the service is named `mm` instead of the agent's
28+ `acp.name`, and the closing message says
29+ `No agent.yaml here yet: write one with mm init.`
30+ - [compose.yaml](internal/dockerinit/files.go) comments and the closing
31+ message (`printNext`) start `mm tui agent.yaml` — wrong file for a
32+ `.mm` project.
33+ - The project is mounted read-write as a whole (`.:${PWD:-/workspace}`):
34+ the agent can change its own settings, skills and hooks in `.mm/`.
35+
36+ ## Wanted — the same rules as mm sbxinit
37+
38+ - **Agent lookup**: without `--agent`, `agent.yaml`, then `.mm/agent.yaml`
39+ (the order mm follows, ticket 204); with `--agent`, that file only.
40+ The service is named after the `acp.name` of the file found.
41+ - **Plain project** (`agent.yaml`): output unchanged, byte for byte.
42+ - **`.mm` project**:
43+ - `.mm/` mounted **read-only** over the project
44+ (`- ./.mm:${PWD:-/workspace}/.mm:ro`), and the folders mm writes in
45+ — `sessions.dir`, `trace.dir`, when on and under `.mm/` — mounted
46+ writable again, as `MMFolder` computes them for sbxinit
47+ ([internal/sbxinit/mmfolder.go](internal/sbxinit/mmfolder.go)).
48+ Relative paths, so `compose.yaml` can be committed.
49+ - commands without a file: `mm tui` / `mm acp` — the image always holds
50+ the latest release (≥ 1.0.7), which finds `.mm/agent.yaml` from the
51+ working directory. In the compose comments and in the closing message.
52+ - a comment in `compose.yaml` saying why `.mm/` is read-only, and that
53+ anything else written under `.mm/` (a hook's log) needs its own line.
54+ - **Shared code**: the agent-file lookup (`findAgentFile`) and the `.mm`
55+ description (`mmFolderOf`, `Writable`, `rel`) move where both commands
56+ use them (e.g. `internal/scaffold`), instead of a second copy.
57+
58+ ## To check
59+
60+ - Nested bind mounts in compose: `.mm:ro` inside the project volume, and
61+ `.mm/sessions` writable inside `.mm:ro` — verify with Docker (Desktop
62+ and Linux) that the most specific mount wins and the files belong to
63+ `MM_UID`.
64+ - A `.mm/sessions` folder that does not exist yet: Docker creates the
65+ mount point as root — create it before (`mm dockerinit` could), or say
66+ so.
67+
68+ ## Out of scope / follow-up
69+
70+ - A `/dockerinit` command in `mm acp-create`, like `/sbxinit`
71+ ([#210](.tickets/issues/0210-acp-create-mode-mm-acp-create-mm-init-from-an-acp.yaml)):
72+ easy once `dockerinit` takes a base directory as `sbxinit.In` does.
73+
74+ ## Acceptance criteria
75+
76+ - In a project made with `mm init --project yes`, `mm dockerinit` with no
77+ flag names the service after the agent, writes the read-only `.mm`
78+ mount and the writable folders, and says `mm tui` with no argument.
79+ - In a plain project, the files are the same as today.
80+ - Tests for both; docs EN+FR.
81+tasks:
82+ - id: 1
83+ title: Share the agent-file lookup and the .mm description between sbxinit and dockerinit
84+ state: closed
85+ priority: medium
86+ author:
87+ name: k33g
88+ email: ph.charriere@gmail.com
89+ createdAt: 2026-11-14T06:29:41.000Z
90+ updatedAt: 2026-11-14T06:37:57.000Z
91+ closedAt: 2026-11-14T06:37:57.000Z
92+ - id: 2
93+ title: Find the agent as mm does - agent.yaml, then .mm/agent.yaml - and name the service from it
94+ state: closed
95+ priority: medium
96+ author:
97+ name: k33g
98+ email: ph.charriere@gmail.com
99+ createdAt: 2026-11-14T06:29:41.000Z
100+ updatedAt: 2026-11-14T06:37:57.000Z
101+ closedAt: 2026-11-14T06:37:57.000Z
102+ - id: 3
103+ title: compose.yaml for a .mm project - .mm/ read-only, sessions.dir / trace.dir writable again
104+ state: closed
105+ priority: high
106+ author:
107+ name: k33g
108+ email: ph.charriere@gmail.com
109+ createdAt: 2026-11-14T06:29:41.000Z
110+ updatedAt: 2026-11-14T06:37:57.000Z
111+ closedAt: 2026-11-14T06:37:57.000Z
112+ - id: 4
113+ title: Commands without a file argument for a .mm project (compose comments and closing message)
114+ state: closed
115+ priority: medium
116+ author:
117+ name: k33g
118+ email: ph.charriere@gmail.com
119+ createdAt: 2026-11-14T06:29:41.000Z
120+ updatedAt: 2026-11-14T06:37:57.000Z
121+ closedAt: 2026-11-14T06:37:57.000Z
122+ - id: 5
123+ title: Tests - .mm project, plain project unchanged (same output as today)
124+ state: closed
125+ priority: medium
126+ author:
127+ name: k33g
128+ email: ph.charriere@gmail.com
129+ createdAt: 2026-11-14T06:29:41.000Z
130+ updatedAt: 2026-11-14T06:37:57.000Z
131+ closedAt: 2026-11-14T06:37:57.000Z
132+ - id: 6
133+ title: Docs EN+FR - run-in-a-container.md (a .mm section), cli.md (--agent row)
134+ state: closed
135+ priority: medium
136+ author:
137+ name: k33g
138+ email: ph.charriere@gmail.com
139+ createdAt: 2026-11-14T06:29:41.000Z
140+comments:
141+ - id: 1
142+ author:
143+ name: k33g
144+ email: ph.charriere@gmail.com
145+ createdAt: 2026-11-14T06:37:57.000Z
146+ body: |
147+ Implemented on `feature/acp-create` (uncommitted). Shared code in
148+ `internal/scaffold/mmfolder.go` (`MMFolder`, `MMFolderOf`, `Rel`,
149+ `FindAgentFile`); sbxinit keeps aliases. `dockerinit.In(dir, …)` for
150+ `/dockerinit` in mm acp-create (#210). Missing writable folders are
151+ created by `mm dockerinit`. Plain project: `compose.yaml` byte for byte
152+ as before (test).
153+
154+ Checked with Docker 29.9.3 / Compose v5.7.1 on Linux, real
155+ `compose.yaml`, image swapped for alpine: mountinfo shows the project
156+ `rw`, `.mm` `ro`, `.mm/sessions` `rw`; writing in `.mm/` fails,
157+ in `.mm/sessions` works. Not checked on Docker Desktop (macOS).
158+
159+ Also asked in the same session: `mm sbxinit` now prints
160+ `Run the sandbox <name>: sbx env run --force` (and the line in the
161+ sbxenv.yaml comments).
162+ updatedAt: 2026-11-14T06:37:57.000Z
163+ closedAt: 2026-11-14T06:37:57.000Z
new file mode 100644
@@ -0,0 +1,163 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 212
3+title: "mm dockerinit: know the .mm folder (Project layout), as mm sbxinit does"
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-14T06:29:41.000Z
9+updatedAt: 2026-11-14T06:37:57.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.9
14+epic: Scaffolding
15+related:
16+ - 208
17+body: |
18+ `mm sbxinit` knows whether the project uses the `.mm` folder (the Project
19+ layout of `mm init --project yes`, ticket 205) since
20+ [#208 — mm sbxinit: know the .mm folder](.tickets/issues/0208-mm-sbxinit-know-the-mm-folder.yaml).
21+ `mm dockerinit` ([internal/dockerinit](internal/dockerinit/main.go)) does
22+ not: it should behave the same way.
23+
24+ ## Today (read 2026-11-14)
25+
26+ - `--agent` defaults to `agent.yaml` and nothing else is looked for: in a
27+ `.mm` project the service is named `mm` instead of the agent's
28+ `acp.name`, and the closing message says
29+ `No agent.yaml here yet: write one with mm init.`
30+ - [compose.yaml](internal/dockerinit/files.go) comments and the closing
31+ message (`printNext`) start `mm tui agent.yaml` — wrong file for a
32+ `.mm` project.
33+ - The project is mounted read-write as a whole (`.:${PWD:-/workspace}`):
34+ the agent can change its own settings, skills and hooks in `.mm/`.
35+
36+ ## Wanted — the same rules as mm sbxinit
37+
38+ - **Agent lookup**: without `--agent`, `agent.yaml`, then `.mm/agent.yaml`
39+ (the order mm follows, ticket 204); with `--agent`, that file only.
40+ The service is named after the `acp.name` of the file found.
41+ - **Plain project** (`agent.yaml`): output unchanged, byte for byte.
42+ - **`.mm` project**:
43+ - `.mm/` mounted **read-only** over the project
44+ (`- ./.mm:${PWD:-/workspace}/.mm:ro`), and the folders mm writes in
45+ — `sessions.dir`, `trace.dir`, when on and under `.mm/` — mounted
46+ writable again, as `MMFolder` computes them for sbxinit
47+ ([internal/sbxinit/mmfolder.go](internal/sbxinit/mmfolder.go)).
48+ Relative paths, so `compose.yaml` can be committed.
49+ - commands without a file: `mm tui` / `mm acp` — the image always holds
50+ the latest release (≥ 1.0.7), which finds `.mm/agent.yaml` from the
51+ working directory. In the compose comments and in the closing message.
52+ - a comment in `compose.yaml` saying why `.mm/` is read-only, and that
53+ anything else written under `.mm/` (a hook's log) needs its own line.
54+ - **Shared code**: the agent-file lookup (`findAgentFile`) and the `.mm`
55+ description (`mmFolderOf`, `Writable`, `rel`) move where both commands
56+ use them (e.g. `internal/scaffold`), instead of a second copy.
57+
58+ ## To check
59+
60+ - Nested bind mounts in compose: `.mm:ro` inside the project volume, and
61+ `.mm/sessions` writable inside `.mm:ro` — verify with Docker (Desktop
62+ and Linux) that the most specific mount wins and the files belong to
63+ `MM_UID`.
64+ - A `.mm/sessions` folder that does not exist yet: Docker creates the
65+ mount point as root — create it before (`mm dockerinit` could), or say
66+ so.
67+
68+ ## Out of scope / follow-up
69+
70+ - A `/dockerinit` command in `mm acp-create`, like `/sbxinit`
71+ ([#210](.tickets/issues/0210-acp-create-mode-mm-acp-create-mm-init-from-an-acp.yaml)):
72+ easy once `dockerinit` takes a base directory as `sbxinit.In` does.
73+
74+ ## Acceptance criteria
75+
76+ - In a project made with `mm init --project yes`, `mm dockerinit` with no
77+ flag names the service after the agent, writes the read-only `.mm`
78+ mount and the writable folders, and says `mm tui` with no argument.
79+ - In a plain project, the files are the same as today.
80+ - Tests for both; docs EN+FR.
81+tasks:
82+ - id: 1
83+ title: Share the agent-file lookup and the .mm description between sbxinit and dockerinit
84+ state: closed
85+ priority: medium
86+ author:
87+ name: k33g
88+ email: ph.charriere@gmail.com
89+ createdAt: 2026-11-14T06:29:41.000Z
90+ updatedAt: 2026-11-14T06:37:57.000Z
91+ closedAt: 2026-11-14T06:37:57.000Z
92+ - id: 2
93+ title: Find the agent as mm does - agent.yaml, then .mm/agent.yaml - and name the service from it
94+ state: closed
95+ priority: medium
96+ author:
97+ name: k33g
98+ email: ph.charriere@gmail.com
99+ createdAt: 2026-11-14T06:29:41.000Z
100+ updatedAt: 2026-11-14T06:37:57.000Z
101+ closedAt: 2026-11-14T06:37:57.000Z
102+ - id: 3
103+ title: compose.yaml for a .mm project - .mm/ read-only, sessions.dir / trace.dir writable again
104+ state: closed
105+ priority: high
106+ author:
107+ name: k33g
108+ email: ph.charriere@gmail.com
109+ createdAt: 2026-11-14T06:29:41.000Z
110+ updatedAt: 2026-11-14T06:37:57.000Z
111+ closedAt: 2026-11-14T06:37:57.000Z
112+ - id: 4
113+ title: Commands without a file argument for a .mm project (compose comments and closing message)
114+ state: closed
115+ priority: medium
116+ author:
117+ name: k33g
118+ email: ph.charriere@gmail.com
119+ createdAt: 2026-11-14T06:29:41.000Z
120+ updatedAt: 2026-11-14T06:37:57.000Z
121+ closedAt: 2026-11-14T06:37:57.000Z
122+ - id: 5
123+ title: Tests - .mm project, plain project unchanged (same output as today)
124+ state: closed
125+ priority: medium
126+ author:
127+ name: k33g
128+ email: ph.charriere@gmail.com
129+ createdAt: 2026-11-14T06:29:41.000Z
130+ updatedAt: 2026-11-14T06:37:57.000Z
131+ closedAt: 2026-11-14T06:37:57.000Z
132+ - id: 6
133+ title: Docs EN+FR - run-in-a-container.md (a .mm section), cli.md (--agent row)
134+ state: closed
135+ priority: medium
136+ author:
137+ name: k33g
138+ email: ph.charriere@gmail.com
139+ createdAt: 2026-11-14T06:29:41.000Z
140+comments:
141+ - id: 1
142+ author:
143+ name: k33g
144+ email: ph.charriere@gmail.com
145+ createdAt: 2026-11-14T06:37:57.000Z
146+ body: |
147+ Implemented on `feature/acp-create` (uncommitted). Shared code in
148+ `internal/scaffold/mmfolder.go` (`MMFolder`, `MMFolderOf`, `Rel`,
149+ `FindAgentFile`); sbxinit keeps aliases. `dockerinit.In(dir, …)` for
150+ `/dockerinit` in mm acp-create (#210). Missing writable folders are
151+ created by `mm dockerinit`. Plain project: `compose.yaml` byte for byte
152+ as before (test).
153+
154+ Checked with Docker 29.9.3 / Compose v5.7.1 on Linux, real
155+ `compose.yaml`, image swapped for alpine: mountinfo shows the project
156+ `rw`, `.mm` `ro`, `.mm/sessions` `rw`; writing in `.mm/` fails,
157+ in `.mm/sessions` works. Not checked on Docker Desktop (macOS).
158+
159+ Also asked in the same session: `mm sbxinit` now prints
160+ `Run the sandbox <name>: sbx env run --force` (and the line in the
161+ sbxenv.yaml comments).
162+ updatedAt: 2026-11-14T06:37:57.000Z
163+ closedAt: 2026-11-14T06:37:57.000Z
modified .tickets/milestones.yaml +5 -0
@@ -64,3 +64,8 @@ milestones:
6464 state: closed
6565 - title: v1.0.9
6666 state: open
67+ dueOn: 2026-11-18
68+ - title: v1.1.0
69+ state: open
70+ description: Between v1.0.9 and v2.0.0
71+ dueOn: 2026-11-25
@@ -64,3 +64,8 @@ milestones:
64 state: closed64 state: closed
65 - title: v1.0.965 - title: v1.0.9
66 state: open66 state: open
67+ dueOn: 2026-11-18
68+ - title: v1.1.0
69+ state: open
70+ description: Between v1.0.9 and v2.0.0
71+ dueOn: 2026-11-25
modified README.md +2 -2
@@ -107,7 +107,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
107107
108108 ## More features
109109
110-- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`.
110+- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`. From an editor, `mm acp-create` asks the same questions over ACP — forms when the client shows them, a conversation otherwise — and its `/dockerinit`, `/sbxinit` and `/zedconfig` commands prepare the container, the sandbox and the editor's settings: see [Create an agent from your editor](docs/en/how-to/create-an-agent-from-an-editor.md).
111111 - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md).
112112 - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md).
113113 - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md).
@@ -131,7 +131,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
131131 - **Models that take turns in memory**: `unloadModels: true` on an `mmConfigs` entry unloads the asking agent's model before the hand-off and the asked agent's model after it, so two models are never loaded together. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/coding-helpers-unload-option/`.
132132 - **Use mini-me from another program**: drive `mm -acp` over ACP — `demo.acp.sdk/nodejs/minime.js` gives Node.js `complete`, `stream`, cancellation and a permission callback, with no dependency. See [`demo.acp.sdk/`](demo.acp.sdk/README.en.md).
133133 - **Test a model**: `testing/` holds two benches run by mm itself — a coordinator agent gives small tasks to a candidate agent whose model is under test, checks each result with a command, and writes a Markdown report: the model as a working agent (`testing/model-check/`) and function calling alone, with tools declared in the agent file (`testing/function-calling/`). See [`testing/`](testing/README.en.md).
134-- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md).
134+- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project — with a `.mm` project, `.mm/` read-only and the sessions and trace folders writable. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md).
135135 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `demos/use-with-sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `demos/use-with-sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). `mm sbxinit` writes a `sbxenv.yaml` with the latest kit.
136136
137137 ## Demos
@@ -107,7 +107,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
107 107
108 ## More features108 ## More features
109 109
110-- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`.110+- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`. From an editor, `mm acp-create` asks the same questions over ACP — forms when the client shows them, a conversation otherwise — and its `/dockerinit`, `/sbxinit` and `/zedconfig` commands prepare the container, the sandbox and the editor's settings: see [Create an agent from your editor](docs/en/how-to/create-an-agent-from-an-editor.md).
111 - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md).111 - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md).
112 - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md).112 - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md).
113 - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md).113 - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md).
@@ -131,7 +131,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
131 - **Models that take turns in memory**: `unloadModels: true` on an `mmConfigs` entry unloads the asking agent's model before the hand-off and the asked agent's model after it, so two models are never loaded together. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/coding-helpers-unload-option/`.131 - **Models that take turns in memory**: `unloadModels: true` on an `mmConfigs` entry unloads the asking agent's model before the hand-off and the asked agent's model after it, so two models are never loaded together. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/coding-helpers-unload-option/`.
132 - **Use mini-me from another program**: drive `mm -acp` over ACP — `demo.acp.sdk/nodejs/minime.js` gives Node.js `complete`, `stream`, cancellation and a permission callback, with no dependency. See [`demo.acp.sdk/`](demo.acp.sdk/README.en.md).132 - **Use mini-me from another program**: drive `mm -acp` over ACP — `demo.acp.sdk/nodejs/minime.js` gives Node.js `complete`, `stream`, cancellation and a permission callback, with no dependency. See [`demo.acp.sdk/`](demo.acp.sdk/README.en.md).
133 - **Test a model**: `testing/` holds two benches run by mm itself — a coordinator agent gives small tasks to a candidate agent whose model is under test, checks each result with a command, and writes a Markdown report: the model as a working agent (`testing/model-check/`) and function calling alone, with tools declared in the agent file (`testing/function-calling/`). See [`testing/`](testing/README.en.md).133 - **Test a model**: `testing/` holds two benches run by mm itself — a coordinator agent gives small tasks to a candidate agent whose model is under test, checks each result with a command, and writes a Markdown report: the model as a working agent (`testing/model-check/`) and function calling alone, with tools declared in the agent file (`testing/function-calling/`). See [`testing/`](testing/README.en.md).
134-- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md).134+- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project — with a `.mm` project, `.mm/` read-only and the sessions and trace folders writable. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md).
135 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `demos/use-with-sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `demos/use-with-sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). `mm sbxinit` writes a `sbxenv.yaml` with the latest kit.135 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `demos/use-with-sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `demos/use-with-sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). `mm sbxinit` writes a `sbxenv.yaml` with the latest kit.
136 136
137 ## Demos137 ## Demos
modified docs/en/README.md +1 -0
@@ -12,6 +12,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
1212 ## How-to guides — get a task done
1313
1414 - [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine
15+- [Create an agent from your editor (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — the same wizard over ACP, from Zed or any ACP client: forms when the client shows them, a conversation otherwise
1516 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
1617 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/`
1718 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/`
@@ -12,6 +12,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
12 ## How-to guides — get a task done12 ## How-to guides — get a task done
13 13
14 - [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine14 - [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine
15+- [Create an agent from your editor (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — the same wizard over ACP, from Zed or any ACP client: forms when the client shows them, a conversation otherwise
15 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)16 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
16 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/`17 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/`
17 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/`18 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/`
modified docs/en/explanation/mm-init.md +1 -0
@@ -118,3 +118,4 @@ The wizard overwrites an existing file only if you agree, and never with `--yes`
118118 - [Context compression](context-compression.md): what `context.threshold` triggers.
119119 - [Structured output](structured-output.md): why the `json` usage has no tools.
120120 - [Asking another agent](asking-another-agent.md): what the `coordinator` usage relies on.
121+- [Create an agent from your editor](../how-to/create-an-agent-from-an-editor.md): the same questions over ACP (`mm acp-create`). The wizard's state is kept apart from the way it is shown, so the line mode, the window and the ACP mode drive one flow and write the same file.
@@ -118,3 +118,4 @@ The wizard overwrites an existing file only if you agree, and never with `--yes`
118 - [Context compression](context-compression.md): what `context.threshold` triggers.118 - [Context compression](context-compression.md): what `context.threshold` triggers.
119 - [Structured output](structured-output.md): why the `json` usage has no tools.119 - [Structured output](structured-output.md): why the `json` usage has no tools.
120 - [Asking another agent](asking-another-agent.md): what the `coordinator` usage relies on.120 - [Asking another agent](asking-another-agent.md): what the `coordinator` usage relies on.
121+- [Create an agent from your editor](../how-to/create-an-agent-from-an-editor.md): the same questions over ACP (`mm acp-create`). The wizard's state is kept apart from the way it is shown, so the line mode, the window and the ACP mode drive one flow and write the same file.
modified docs/en/explanation/persistent-sessions.md +4 -0
@@ -26,6 +26,10 @@ That choice still holds, but it is no longer exclusive. When it was made, `sessi
2626
2727 A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too.
2828
29+## Without saved sessions, a reload starts over
30+
31+Until 2026-11-14 an agent without `sessions.enabled` advertised neither `loadSession` nor `session/resume`: there was nothing to read back, and saying so honestly let the editor know. Zed then answered its own Reload Agent with "Loading or resuming sessions is not supported by this agent", and the thread was dead — `mm init` writes `sessions` off by default, so most agents were in that case. Since then both are always advertised. Without saved sessions the id the editor asks for becomes a new, empty session, and one message in the thread says the history is gone and how to keep it next time (`sessions.enabled`). A reload thus never fails; what it brings back depends on the setting. `session/list` still exists only with saved sessions: a history picker with nothing to list helps nobody. `mm acp-create` does the same: its wizard keeps nothing on disk, and a reload starts it over.
32+
2933 ## `/new` in the two front ends
3034
3135 In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.
@@ -26,6 +26,10 @@ That choice still holds, but it is no longer exclusive. When it was made, `sessi
26 26
27 A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too.27 A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too.
28 28
29+## Without saved sessions, a reload starts over
30+
31+Until 2026-11-14 an agent without `sessions.enabled` advertised neither `loadSession` nor `session/resume`: there was nothing to read back, and saying so honestly let the editor know. Zed then answered its own Reload Agent with "Loading or resuming sessions is not supported by this agent", and the thread was dead — `mm init` writes `sessions` off by default, so most agents were in that case. Since then both are always advertised. Without saved sessions the id the editor asks for becomes a new, empty session, and one message in the thread says the history is gone and how to keep it next time (`sessions.enabled`). A reload thus never fails; what it brings back depends on the setting. `session/list` still exists only with saved sessions: a history picker with nothing to list helps nobody. `mm acp-create` does the same: its wizard keeps nothing on disk, and a reload starts it over.
32+
29 ## `/new` in the two front ends33 ## `/new` in the two front ends
30 34
31 In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.35 In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.
added docs/en/how-to/create-an-agent-from-an-editor.md +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+# How to create an agent from your editor (`mm acp-create`)
2+
3+This guide shows how to create an agent.yaml without a terminal, from any editor or tool that speaks the Agent Client Protocol (Zed, a JetBrains IDE, a Neovim plugin…). `mm acp-create` serves the questions of [`mm init`](create-an-agent.md) over ACP and writes the same file. It assumes `mm` is installed on your `PATH` and that a model server is running (the wizard lists its models).
4+
5+## Steps
6+
7+1. Register `mm acp-create` as an agent server in your client. In Zed, add to `settings.json`:
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mm-create": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["acp-create"]
16+ }
17+ }
18+ }
19+ ```
20+
21+ Other clients take the same two things: the command `mm` and the argument `acp-create`. No configuration file is needed: this mode reads none.
22+
23+2. Open the project the agent will work in, start a new thread with `mm-create`, and send `/start` (or any message). The wizard starts. The file will be written in the project's directory — the session's working directory — not where the client started `mm`.
24+
25+3. Answer the questions. How they are asked depends on what your client can show:
26+
27+ - **A client that shows forms** (it advertises the `elicitation.form` capability): the questions come in a few forms, the defaults already filled in. A question whose choices depend on an earlier answer — the model list on the server you chose — comes in the next form.
28+ - **Any other client**: each question is a message, with numbered choices. Reply with a number or a value; `.` takes the default.
29+
30+4. Read the closing message. It shows `✅ wrote agent.yaml` and `✅ mm loads it`, then the command that starts the agent, `mm acp /absolute/path/to/agent.yaml`. Zed gets a ready `agent_servers` entry; for another client, use that command line.
31+
32+5. To run the agent in a container or a Docker Sandbox rather than on your machine, send `/dockerinit` (writes `Dockerfile` and `compose.yaml`) or `/sbxinit` (writes `sbxenv.yaml`) in the same thread. See [run mini-me in a container](run-in-a-container.md) and [run mini-me in a Docker Sandbox](run-in-a-docker-sandbox.md).
33+
34+6. Send `/zedconfig`. It shows the `agent_servers` entries for every way the project can now start its agent — on your machine, in its container, in its sandbox — and what to start first for each. Copy the ones you want into your client's settings, and start a thread with one of them: this one is your agent.
35+
36+## Commands during the wizard
37+
38+| Command | What it does |
39+|---------|--------------|
40+| `/start` | Starts the wizard; once started, asks the current question again. |
41+| `/preview` | Shows the agent.yaml as it would be written now. |
42+| `/back` | Takes back the last answer and asks it again. |
43+| `/write` | Takes the default for every question left and writes the file (replacing an existing file is still asked). |
44+| `/restart` | Forgets every answer and starts again. |
45+| `/dockerinit` | Writes a `Dockerfile` and a `compose.yaml` in the project to run the agent in a container — `mm dockerinit` for this project. Takes its flags (`--name`, `--agent`); existing files are kept unless you add `--overwrite`. |
46+| `/zedconfig` | Shows Zed's `agent_servers` entries for this project's agent, from what the project holds: `mm acp <agent file>` always; with a `compose.yaml`, `docker exec -i <container> mm -acp -config <agent file>`; with a `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <agent file>` — each with what to start first. |
47+| `/sbxinit` | Writes a `sbxenv.yaml` in the project to run the agent in a Docker Sandbox — `mm sbxinit` for this project. Takes its flags (`--name`, `--workspace`, `--agent`); an existing `sbxenv.yaml` is kept unless you add `--overwrite`. Works at any time; after the agent is written, the sandbox is named after it. |
48+| `/help` | Lists these commands. |
49+
50+## Variations
51+
52+- **Fix answers in advance.** The flags of `mm init` work here (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…): a question whose flag is given is not asked. For example `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
53+- **The `.mm` layout.** Answer `yes` to *Keep everything in a .mm folder?*: the file is `.mm/agent.yaml`, see [Keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md).
54+- **Declining a form** writes nothing. Send another message to pick up where you stopped.
55+- **Reloading the agent** (Zed's *Reload Agent*) restarts the wizard from the first question: the answers are not kept. A file already written stays as it is.
56+
57+## See also
58+
59+- [Create an agent with `mm init`](create-an-agent.md) — the same wizard in a terminal.
60+- [Use mini-me from an editor (ACP)](use-from-an-editor.md) — running the agent you created.
61+- [Reference: command line — `mm acp-create`](../reference/cli.md#mm-acp-create--the-agentyaml-wizard-over-acp)
new file mode 100644
@@ -0,0 +1,61 @@
1+# How to create an agent from your editor (`mm acp-create`)
2+
3+This guide shows how to create an agent.yaml without a terminal, from any editor or tool that speaks the Agent Client Protocol (Zed, a JetBrains IDE, a Neovim plugin…). `mm acp-create` serves the questions of [`mm init`](create-an-agent.md) over ACP and writes the same file. It assumes `mm` is installed on your `PATH` and that a model server is running (the wizard lists its models).
4+
5+## Steps
6+
7+1. Register `mm acp-create` as an agent server in your client. In Zed, add to `settings.json`:
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mm-create": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["acp-create"]
16+ }
17+ }
18+ }
19+ ```
20+
21+ Other clients take the same two things: the command `mm` and the argument `acp-create`. No configuration file is needed: this mode reads none.
22+
23+2. Open the project the agent will work in, start a new thread with `mm-create`, and send `/start` (or any message). The wizard starts. The file will be written in the project's directory — the session's working directory — not where the client started `mm`.
24+
25+3. Answer the questions. How they are asked depends on what your client can show:
26+
27+ - **A client that shows forms** (it advertises the `elicitation.form` capability): the questions come in a few forms, the defaults already filled in. A question whose choices depend on an earlier answer — the model list on the server you chose — comes in the next form.
28+ - **Any other client**: each question is a message, with numbered choices. Reply with a number or a value; `.` takes the default.
29+
30+4. Read the closing message. It shows `✅ wrote agent.yaml` and `✅ mm loads it`, then the command that starts the agent, `mm acp /absolute/path/to/agent.yaml`. Zed gets a ready `agent_servers` entry; for another client, use that command line.
31+
32+5. To run the agent in a container or a Docker Sandbox rather than on your machine, send `/dockerinit` (writes `Dockerfile` and `compose.yaml`) or `/sbxinit` (writes `sbxenv.yaml`) in the same thread. See [run mini-me in a container](run-in-a-container.md) and [run mini-me in a Docker Sandbox](run-in-a-docker-sandbox.md).
33+
34+6. Send `/zedconfig`. It shows the `agent_servers` entries for every way the project can now start its agent — on your machine, in its container, in its sandbox — and what to start first for each. Copy the ones you want into your client's settings, and start a thread with one of them: this one is your agent.
35+
36+## Commands during the wizard
37+
38+| Command | What it does |
39+|---------|--------------|
40+| `/start` | Starts the wizard; once started, asks the current question again. |
41+| `/preview` | Shows the agent.yaml as it would be written now. |
42+| `/back` | Takes back the last answer and asks it again. |
43+| `/write` | Takes the default for every question left and writes the file (replacing an existing file is still asked). |
44+| `/restart` | Forgets every answer and starts again. |
45+| `/dockerinit` | Writes a `Dockerfile` and a `compose.yaml` in the project to run the agent in a container — `mm dockerinit` for this project. Takes its flags (`--name`, `--agent`); existing files are kept unless you add `--overwrite`. |
46+| `/zedconfig` | Shows Zed's `agent_servers` entries for this project's agent, from what the project holds: `mm acp <agent file>` always; with a `compose.yaml`, `docker exec -i <container> mm -acp -config <agent file>`; with a `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <agent file>` — each with what to start first. |
47+| `/sbxinit` | Writes a `sbxenv.yaml` in the project to run the agent in a Docker Sandbox — `mm sbxinit` for this project. Takes its flags (`--name`, `--workspace`, `--agent`); an existing `sbxenv.yaml` is kept unless you add `--overwrite`. Works at any time; after the agent is written, the sandbox is named after it. |
48+| `/help` | Lists these commands. |
49+
50+## Variations
51+
52+- **Fix answers in advance.** The flags of `mm init` work here (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…): a question whose flag is given is not asked. For example `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
53+- **The `.mm` layout.** Answer `yes` to *Keep everything in a .mm folder?*: the file is `.mm/agent.yaml`, see [Keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md).
54+- **Declining a form** writes nothing. Send another message to pick up where you stopped.
55+- **Reloading the agent** (Zed's *Reload Agent*) restarts the wizard from the first question: the answers are not kept. A file already written stays as it is.
56+
57+## See also
58+
59+- [Create an agent with `mm init`](create-an-agent.md) — the same wizard in a terminal.
60+- [Use mini-me from an editor (ACP)](use-from-an-editor.md) — running the agent you created.
61+- [Reference: command line — `mm acp-create`](../reference/cli.md#mm-acp-create--the-agentyaml-wizard-over-acp)
modified docs/en/how-to/create-an-agent.md +1 -0
@@ -90,6 +90,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's
9090
9191 ## See also
9292
93+- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — the same wizard over ACP
9394 - [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes
9495 - [Configuration reference](../reference/configuration.md): every key the file holds
9596 - [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows
@@ -90,6 +90,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's
90 90
91 ## See also91 ## See also
92 92
93+- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — the same wizard over ACP
93 - [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes94 - [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes
94 - [Configuration reference](../reference/configuration.md): every key the file holds95 - [Configuration reference](../reference/configuration.md): every key the file holds
95 - [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows96 - [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows
modified docs/en/how-to/run-in-a-container.md +18 -1
@@ -22,6 +22,23 @@ The service is named after `acp.name` in `agent.yaml`, or `mm` if there is none.
2222
2323 If a file already exists, mm asks `Overwrite Dockerfile?`, and `no` is the default. With `--yes`, or when the input ends, nothing at all is written.
2424
25+### A project with a `.mm` folder
26+
27+When the agent is `.mm/agent.yaml` (made with `mm init --project yes`, see [keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` finds it with no flag — it looks for `agent.yaml`, then `.mm/agent.yaml`, as mm does — and names the service after it. `compose.yaml` then:
28+
29+- mounts `.mm/` **read-only** over the project: the agent cannot change its own settings, skills or hooks;
30+- mounts the folders mm writes in — the agent file's `sessions.dir` and `trace.dir`, when they are on and under `.mm/` — writable again, and creates them now if they do not exist (Docker would create them as root, and the agent never runs as root);
31+- starts `mm tui` with no file: mm finds `.mm/agent.yaml` from the project.
32+
33+```yaml
34+ volumes:
35+ - .:${PWD:-/workspace}
36+ - ./.mm:${PWD:-/workspace}/.mm:ro
37+ - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir
38+```
39+
40+Anything else written under `.mm/` — a hook that keeps a log there — needs a line of its own. After turning sessions or the trace on or off, run `mm dockerinit` again (answer `yes` to replace `compose.yaml`).
41+
2542 ## Run the agent
2643
2744 Either keep a container running, which also serves an editor:
@@ -47,7 +64,7 @@ Start the container with `docker compose up -d --wait`, then give your editor th
4764 docker exec -i bob-box mm --acp --config /path/to/project/agent.yaml
4865 ```
4966
50-The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container.
67+The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container. For a `.mm` project, `docker exec -i bob-box mm --acp` is enough: the container starts in the project, where mm finds `.mm/agent.yaml`.
5168
5269 ## Reach the model
5370
@@ -22,6 +22,23 @@ The service is named after `acp.name` in `agent.yaml`, or `mm` if there is none.
22 22
23 If a file already exists, mm asks `Overwrite Dockerfile?`, and `no` is the default. With `--yes`, or when the input ends, nothing at all is written.23 If a file already exists, mm asks `Overwrite Dockerfile?`, and `no` is the default. With `--yes`, or when the input ends, nothing at all is written.
24 24
25+### A project with a `.mm` folder
26+
27+When the agent is `.mm/agent.yaml` (made with `mm init --project yes`, see [keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` finds it with no flag — it looks for `agent.yaml`, then `.mm/agent.yaml`, as mm does — and names the service after it. `compose.yaml` then:
28+
29+- mounts `.mm/` **read-only** over the project: the agent cannot change its own settings, skills or hooks;
30+- mounts the folders mm writes in — the agent file's `sessions.dir` and `trace.dir`, when they are on and under `.mm/` — writable again, and creates them now if they do not exist (Docker would create them as root, and the agent never runs as root);
31+- starts `mm tui` with no file: mm finds `.mm/agent.yaml` from the project.
32+
33+```yaml
34+ volumes:
35+ - .:${PWD:-/workspace}
36+ - ./.mm:${PWD:-/workspace}/.mm:ro
37+ - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir
38+```
39+
40+Anything else written under `.mm/` — a hook that keeps a log there — needs a line of its own. After turning sessions or the trace on or off, run `mm dockerinit` again (answer `yes` to replace `compose.yaml`).
41+
25 ## Run the agent42 ## Run the agent
26 43
27 Either keep a container running, which also serves an editor:44 Either keep a container running, which also serves an editor:
@@ -47,7 +64,7 @@ Start the container with `docker compose up -d --wait`, then give your editor th
47 docker exec -i bob-box mm --acp --config /path/to/project/agent.yaml64 docker exec -i bob-box mm --acp --config /path/to/project/agent.yaml
48 ```65 ```
49 66
50-The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container.67+The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container. For a `.mm` project, `docker exec -i bob-box mm --acp` is enough: the container starts in the project, where mm finds `.mm/agent.yaml`.
51 68
52 ## Reach the model69 ## Reach the model
53 70
modified docs/en/how-to/run-in-a-docker-sandbox.md +1 -0
@@ -97,6 +97,7 @@ To describe the sandbox in a file, run this in your project's directory:
9797 ```bash
9898 mm sbxinit
9999 sbx env create --force
100+sbx env run --force
100101 ```
101102
102103 `sbx env` needs sbx 0.39.0 or newer, and `docker.io/` among the allowed kit sources (step 1). The file names the latest `mini-me-kit`, and `pullPolicy: always` makes the sandbox fetch it at each creation. If that version cannot be read, for instance without network, mm says so and uses the tag `latest`. The sandbox is named after `acp.name` in `agent.yaml`, followed by `-sbx`; `--name` chooses another one.
@@ -97,6 +97,7 @@ To describe the sandbox in a file, run this in your project's directory:
97 ```bash97 ```bash
98 mm sbxinit98 mm sbxinit
99 sbx env create --force99 sbx env create --force
100+sbx env run --force
100 ```101 ```
101 102
102 `sbx env` needs sbx 0.39.0 or newer, and `docker.io/` among the allowed kit sources (step 1). The file names the latest `mini-me-kit`, and `pullPolicy: always` makes the sandbox fetch it at each creation. If that version cannot be read, for instance without network, mm says so and uses the tag `latest`. The sandbox is named after `acp.name` in `agent.yaml`, followed by `-sbx`; `--name` chooses another one.103 `sbx env` needs sbx 0.39.0 or newer, and `docker.io/` among the allowed kit sources (step 1). The file names the latest `mini-me-kit`, and `pullPolicy: always` makes the sandbox fetch it at each creation. If that version cannot be read, for instance without network, mm says so and uses the tag `latest`. The sandbox is named after `acp.name` in `agent.yaml`, followed by `-sbx`; `--name` chooses another one.
modified docs/en/how-to/use-from-an-editor.md +2 -1
@@ -31,7 +31,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
3131 - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.
3232 - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).
3333 - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.
34-- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md).
34+- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md). Without `sessions.enabled`, Reload Agent still works, but the conversation starts over: mm says so in the thread.
3535 - **See the JSON exchanged with the model.** Turn the trace on, in the config file (`trace: {enabled: true}`) or in the editor's settings (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — the editor does not pass your shell's variables). The files land in `<project>/.mm/traces/`, the project being the session's `cwd`; the banner in the ACP logs says `trace: <session cwd>/.mm/traces`. See [How to see the JSON sent to the model](inspect-model-traffic.md).
3636 - **Change the name the editor sees.** Add an `acp:` block to the config file; `name` is the identifier the editor logs, `title` the name it may display (leave `title` empty to not send one). The version sent is always the binary's, the one `mm --version` prints — check it with the `initialize` request below. See the [`acp` keys](../reference/configuration.md#acp-keys).
3737
@@ -52,5 +52,6 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
5252
5353 ## See also
5454
55+- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — write the agent.yaml without a terminal
5556 - The exact methods and capabilities exposed: [ACP reference](../reference/acp.md)
5657 - Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md)
@@ -31,7 +31,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
31 - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.31 - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.
32 - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).32 - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).
33 - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.33 - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.
34-- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md).34+- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md). Without `sessions.enabled`, Reload Agent still works, but the conversation starts over: mm says so in the thread.
35 - **See the JSON exchanged with the model.** Turn the trace on, in the config file (`trace: {enabled: true}`) or in the editor's settings (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — the editor does not pass your shell's variables). The files land in `<project>/.mm/traces/`, the project being the session's `cwd`; the banner in the ACP logs says `trace: <session cwd>/.mm/traces`. See [How to see the JSON sent to the model](inspect-model-traffic.md).35 - **See the JSON exchanged with the model.** Turn the trace on, in the config file (`trace: {enabled: true}`) or in the editor's settings (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — the editor does not pass your shell's variables). The files land in `<project>/.mm/traces/`, the project being the session's `cwd`; the banner in the ACP logs says `trace: <session cwd>/.mm/traces`. See [How to see the JSON sent to the model](inspect-model-traffic.md).
36 - **Change the name the editor sees.** Add an `acp:` block to the config file; `name` is the identifier the editor logs, `title` the name it may display (leave `title` empty to not send one). The version sent is always the binary's, the one `mm --version` prints — check it with the `initialize` request below. See the [`acp` keys](../reference/configuration.md#acp-keys).36 - **Change the name the editor sees.** Add an `acp:` block to the config file; `name` is the identifier the editor logs, `title` the name it may display (leave `title` empty to not send one). The version sent is always the binary's, the one `mm --version` prints — check it with the `initialize` request below. See the [`acp` keys](../reference/configuration.md#acp-keys).
37 37
@@ -52,5 +52,6 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
52 52
53 ## See also53 ## See also
54 54
55+- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — write the agent.yaml without a terminal
55 - The exact methods and capabilities exposed: [ACP reference](../reference/acp.md)56 - The exact methods and capabilities exposed: [ACP reference](../reference/acp.md)
56 - Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md)57 - Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md)
modified docs/en/reference/acp.md +3 -3
@@ -16,7 +16,7 @@
1616 | Field | Value |
1717 |-------|-------|
1818 | `protocolVersion` | `1` |
19-| `agentCapabilities` | When `sessions.enabled` is set: `loadSession: true` and `sessionCapabilities: {"list": {}, "resume": {}}`; both absent (false) otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. |
19+| `agentCapabilities` | `loadSession: true`. `sessionCapabilities`: `{"list": {}, "resume": {}}` when `sessions.enabled` is set, `{"resume": {}}` otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. |
2020 | `agentInfo.name` | `acp.name` of the configuration, `bob` by default. |
2121 | `agentInfo.title` | `acp.title` of the configuration, `Bob (bash-first agent)` by default; absent when empty. |
2222 | `agentInfo.version` | The binary's version, the same string as `mm --version` (for example `v0.1.7`). |
@@ -31,9 +31,9 @@
3131 | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. |
3232 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
3333 | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. |
34-| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. |
34+| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: the requested id becomes a new, empty session in the request's `cwd` (nothing is read or written), an `available_commands_update` and one `agent_message_chunk` saying the conversation starts over are sent, and the answer is `{}` — so that an editor's reload does not fail. The session's client tools are set as for `session/new`. |
3535 | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. |
36-| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. |
36+| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: as `session/load` without `sessions.enabled` — a new, empty session under the requested id, and the message saying so. The session's client tools are set as for `session/new`. |
3737 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |
3838
3939 ## Skills as commands
@@ -16,7 +16,7 @@
16 | Field | Value |16 | Field | Value |
17 |-------|-------|17 |-------|-------|
18 | `protocolVersion` | `1` |18 | `protocolVersion` | `1` |
19-| `agentCapabilities` | When `sessions.enabled` is set: `loadSession: true` and `sessionCapabilities: {"list": {}, "resume": {}}`; both absent (false) otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. |19+| `agentCapabilities` | `loadSession: true`. `sessionCapabilities`: `{"list": {}, "resume": {}}` when `sessions.enabled` is set, `{"resume": {}}` otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. |
20 | `agentInfo.name` | `acp.name` of the configuration, `bob` by default. |20 | `agentInfo.name` | `acp.name` of the configuration, `bob` by default. |
21 | `agentInfo.title` | `acp.title` of the configuration, `Bob (bash-first agent)` by default; absent when empty. |21 | `agentInfo.title` | `acp.title` of the configuration, `Bob (bash-first agent)` by default; absent when empty. |
22 | `agentInfo.version` | The binary's version, the same string as `mm --version` (for example `v0.1.7`). |22 | `agentInfo.version` | The binary's version, the same string as `mm --version` (for example `v0.1.7`). |
@@ -31,9 +31,9 @@
31 | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. |31 | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. |
32 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |32 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
33 | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. |33 | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. |
34-| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. |34+| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: the requested id becomes a new, empty session in the request's `cwd` (nothing is read or written), an `available_commands_update` and one `agent_message_chunk` saying the conversation starts over are sent, and the answer is `{}` — so that an editor's reload does not fail. The session's client tools are set as for `session/new`. |
35 | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. |35 | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. |
36-| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. |36+| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: as `session/load` without `sessions.enabled` — a new, empty session under the requested id, and the message saying so. The session's client tools are set as for `session/new`. |
37 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |37 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |
38 38
39 ## Skills as commands39 ## Skills as commands
modified docs/en/reference/cli.md +18 -1
@@ -9,6 +9,7 @@ mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--abou
99 mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent)
1010 mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin)
1111 mm init [flags] (write a new agent.yaml; also mm --init)
12+mm acp-create [flags] (mm init's wizard over ACP, for an editor)
1213 mm dockerinit [flags] (write a Dockerfile and a compose.yaml)
1314 mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox)
1415 mm skillcreate [flags] (write a new skill with a wizard)
@@ -209,7 +210,7 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers
209210 | Flag | Meaning | Default |
210211 |------|---------|---------|
211212 | `--name` | The service's name, as lower case letters and digits joined by dashes (`Bob The Bot` gives `bob-the-bot`). The container is `<name>-box`, the image `mm-<name>:latest` | `acp.name` in the agent file, else `mm` |
212-| `--agent` | The agent file that names the service and that the printed commands start | `agent.yaml` |
213+| `--agent` | The agent file that names the service and that the printed commands start. Without the flag, `.mm/agent.yaml` is looked for too, after `agent.yaml` (the order mm follows); found in `.mm/`, `compose.yaml` mounts `.mm/` read-only and the agent file's `sessions.dir` and `trace.dir` (when on and under `.mm/`) writable again, with paths relative to the project, creates those folders when missing, and starts `mm tui` with no file for `.mm/agent.yaml` | `agent.yaml`, then `.mm/agent.yaml` |
213214 | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten or written | off |
214215
215216 Each existing file is asked about, the default being `no`: that file is kept, the other is written. Exit codes: `0` done, `1` nothing written because a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments.
@@ -267,6 +268,22 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
267268 | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |
268269 | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |
269270
271+## `mm acp-create` — the agent.yaml wizard over ACP
272+
273+`mm acp-create` (also `--acp-create`, `-acp-create`) serves the questions of [`mm init`](#mm-init--the-agentyaml-wizard) over the Agent Client Protocol on stdin/stdout, for an editor or any ACP client. It is answered before any configuration is read and calls no model (it lists the model server's models, as `mm init` does). See [How to create an agent from your editor](../how-to/create-an-agent-from-an-editor.md).
274+
275+| Aspect | Behaviour |
276+|--------|-----------|
277+| Flags | Those of `mm init` except `--check`, `--yes` and `--line`; a question whose flag is given is not asked. A refused flag value stops `mm` at start (exit 1). `-h` and errors go to stderr: stdout carries JSON-RPC only. |
278+| `initialize` | `agentInfo.name` `mm-acp-create`, title `mm init (agent.yaml wizard)`; `loadSession: true` and `sessionCapabilities.resume`, no authentication method. |
279+| `session/load`, `session/resume` | Nothing is kept across a restart: a fresh wizard is registered under the requested id, in the given `cwd`, then the commands and one message saying the wizard starts over (no replay). A file already written is untouched. This is what a client's "reload agent" uses. |
280+| Sessions | One wizard per `session/new`. Relative files are written in the session's `cwd`. After the `session/new` response, an `agent_message_chunk` says to send `/start`. `/start` or the first prompt, whatever its text, starts the wizard. |
281+| Questions | Chosen from the client's capabilities only. `clientCapabilities.elicitation.form` present: `elicitation/create` forms (session-scoped), as many questions per form as possible — a form never holds a question together with one its choices or default depend on (model after server, family after model, ask-first and name after usage, skills and file after layout, overwrite after file). Absent: one `agent_message_chunk` per question, numbered choices; the next prompt answers it — a number, a value, or `.` / `default` / `/skip` for the default. |
282+| Form outcomes | Declined or cancelled: nothing written, the next prompt asks again. Three forms in a row with no usable answer: the wizard stops. |
283+| Commands (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig`: one `agent_servers` JSON block for the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`) — an entry `mm-<project>` running `mm acp <absolute path>`; with `compose.yaml`, `mm-<project> (container)` running `docker exec -i <container_name> mm -acp -config <path>`; with `sbxenv.yaml`, `mm-<project> (sandbox)` running `sbx exec -i <name> mm -acp -config <path>` — then one line per entry saying what to start first. No agent file: a pointer to `/start`. `dockerinit` and `sbxinit` (input hint: their flags) run [`mm dockerinit`](#mm-dockerinit--the-container-files) and [`mm sbxinit`](#mm-sbxinit--the-sandbox-file) with the session's `cwd` as current directory, their output in one message; `--overwrite` replaces existing files, which are otherwise kept (nothing can be asked). |
284+| After writing | The messages of `mm init` (`✅ wrote <file>`, `✅ mm loads it`), then `mm acp <absolute path>`, a pointer to `/zedconfig`, and one to `/dockerinit` and `/sbxinit`; for a client whose `clientInfo.name` contains `zed`, an `agent_servers` snippet too. Later prompts answer that the file is written. |
285+| Exit codes | 0 the client closed the connection, or `-h`; 1 a refused flag value or presets that do not load; 2 a bad flag. |
286+
270287 ## `mm skillcreate` — the skill wizard
271288
272289 `mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md).
@@ -9,6 +9,7 @@ mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--abou
9 mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent)9 mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent)
10 mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin)10 mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin)
11 mm init [flags] (write a new agent.yaml; also mm --init)11 mm init [flags] (write a new agent.yaml; also mm --init)
12+mm acp-create [flags] (mm init's wizard over ACP, for an editor)
12 mm dockerinit [flags] (write a Dockerfile and a compose.yaml)13 mm dockerinit [flags] (write a Dockerfile and a compose.yaml)
13 mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox)14 mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox)
14 mm skillcreate [flags] (write a new skill with a wizard)15 mm skillcreate [flags] (write a new skill with a wizard)
@@ -209,7 +210,7 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers
209 | Flag | Meaning | Default |210 | Flag | Meaning | Default |
210 |------|---------|---------|211 |------|---------|---------|
211 | `--name` | The service's name, as lower case letters and digits joined by dashes (`Bob The Bot` gives `bob-the-bot`). The container is `<name>-box`, the image `mm-<name>:latest` | `acp.name` in the agent file, else `mm` |212 | `--name` | The service's name, as lower case letters and digits joined by dashes (`Bob The Bot` gives `bob-the-bot`). The container is `<name>-box`, the image `mm-<name>:latest` | `acp.name` in the agent file, else `mm` |
212-| `--agent` | The agent file that names the service and that the printed commands start | `agent.yaml` |213+| `--agent` | The agent file that names the service and that the printed commands start. Without the flag, `.mm/agent.yaml` is looked for too, after `agent.yaml` (the order mm follows); found in `.mm/`, `compose.yaml` mounts `.mm/` read-only and the agent file's `sessions.dir` and `trace.dir` (when on and under `.mm/`) writable again, with paths relative to the project, creates those folders when missing, and starts `mm tui` with no file for `.mm/agent.yaml` | `agent.yaml`, then `.mm/agent.yaml` |
213 | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten or written | off |214 | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten or written | off |
214 215
215 Each existing file is asked about, the default being `no`: that file is kept, the other is written. Exit codes: `0` done, `1` nothing written because a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments.216 Each existing file is asked about, the default being `no`: that file is kept, the other is written. Exit codes: `0` done, `1` nothing written because a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments.
@@ -267,6 +268,22 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
267 | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |268 | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |
268 | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |269 | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |
269 270
271+## `mm acp-create` — the agent.yaml wizard over ACP
272+
273+`mm acp-create` (also `--acp-create`, `-acp-create`) serves the questions of [`mm init`](#mm-init--the-agentyaml-wizard) over the Agent Client Protocol on stdin/stdout, for an editor or any ACP client. It is answered before any configuration is read and calls no model (it lists the model server's models, as `mm init` does). See [How to create an agent from your editor](../how-to/create-an-agent-from-an-editor.md).
274+
275+| Aspect | Behaviour |
276+|--------|-----------|
277+| Flags | Those of `mm init` except `--check`, `--yes` and `--line`; a question whose flag is given is not asked. A refused flag value stops `mm` at start (exit 1). `-h` and errors go to stderr: stdout carries JSON-RPC only. |
278+| `initialize` | `agentInfo.name` `mm-acp-create`, title `mm init (agent.yaml wizard)`; `loadSession: true` and `sessionCapabilities.resume`, no authentication method. |
279+| `session/load`, `session/resume` | Nothing is kept across a restart: a fresh wizard is registered under the requested id, in the given `cwd`, then the commands and one message saying the wizard starts over (no replay). A file already written is untouched. This is what a client's "reload agent" uses. |
280+| Sessions | One wizard per `session/new`. Relative files are written in the session's `cwd`. After the `session/new` response, an `agent_message_chunk` says to send `/start`. `/start` or the first prompt, whatever its text, starts the wizard. |
281+| Questions | Chosen from the client's capabilities only. `clientCapabilities.elicitation.form` present: `elicitation/create` forms (session-scoped), as many questions per form as possible — a form never holds a question together with one its choices or default depend on (model after server, family after model, ask-first and name after usage, skills and file after layout, overwrite after file). Absent: one `agent_message_chunk` per question, numbered choices; the next prompt answers it — a number, a value, or `.` / `default` / `/skip` for the default. |
282+| Form outcomes | Declined or cancelled: nothing written, the next prompt asks again. Three forms in a row with no usable answer: the wizard stops. |
283+| Commands (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig`: one `agent_servers` JSON block for the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`) — an entry `mm-<project>` running `mm acp <absolute path>`; with `compose.yaml`, `mm-<project> (container)` running `docker exec -i <container_name> mm -acp -config <path>`; with `sbxenv.yaml`, `mm-<project> (sandbox)` running `sbx exec -i <name> mm -acp -config <path>` — then one line per entry saying what to start first. No agent file: a pointer to `/start`. `dockerinit` and `sbxinit` (input hint: their flags) run [`mm dockerinit`](#mm-dockerinit--the-container-files) and [`mm sbxinit`](#mm-sbxinit--the-sandbox-file) with the session's `cwd` as current directory, their output in one message; `--overwrite` replaces existing files, which are otherwise kept (nothing can be asked). |
284+| After writing | The messages of `mm init` (`✅ wrote <file>`, `✅ mm loads it`), then `mm acp <absolute path>`, a pointer to `/zedconfig`, and one to `/dockerinit` and `/sbxinit`; for a client whose `clientInfo.name` contains `zed`, an `agent_servers` snippet too. Later prompts answer that the file is written. |
285+| Exit codes | 0 the client closed the connection, or `-h`; 1 a refused flag value or presets that do not load; 2 a bad flag. |
286+
270 ## `mm skillcreate` — the skill wizard287 ## `mm skillcreate` — the skill wizard
271 288
272 `mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md).289 `mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md).
modified docs/en/reference/configuration.md +1 -1
@@ -70,7 +70,7 @@ Where `mm` looks for this file: [Configuration file resolution](cli.md#configura
7070
7171 | Key | Type | Default | Description |
7272 |-----|------|---------|-------------|
73-| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
73+| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and, over ACP, `session/list` and a reload (`session/load`, `session/resume`) that brings the conversation back. Off, an editor's reload still succeeds but the conversation starts over. |
7474 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
7575 | `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). |
7676 | `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. |
@@ -70,7 +70,7 @@ Where `mm` looks for this file: [Configuration file resolution](cli.md#configura
70 70
71 | Key | Type | Default | Description |71 | Key | Type | Default | Description |
72 |-----|------|---------|-------------|72 |-----|------|---------|-------------|
73-| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |73+| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and, over ACP, `session/list` and a reload (`session/load`, `session/resume`) that brings the conversation back. Off, an editor's reload still succeeds but the conversation starts over. |
74 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |74 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
75 | `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). |75 | `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). |
76 | `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. |76 | `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. |
modified docs/fr/README.md +1 -0
@@ -12,6 +12,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
1212 ## Guides pratiques — accomplir une tâche
1313
1414 - [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine
15+- [Créer un agent depuis votre éditeur (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — le même assistant par ACP, depuis Zed ou tout client ACP : des formulaires quand le client sait les afficher, une conversation sinon
1516 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
1617 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/`
1718 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/`
@@ -12,6 +12,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
12 ## Guides pratiques — accomplir une tâche12 ## Guides pratiques — accomplir une tâche
13 13
14 - [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine14 - [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine
15+- [Créer un agent depuis votre éditeur (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — le même assistant par ACP, depuis Zed ou tout client ACP : des formulaires quand le client sait les afficher, une conversation sinon
15 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)16 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
16 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/`17 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/`
17 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/`18 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/`
modified docs/fr/explanation/mm-init.md +1 -0
@@ -118,3 +118,4 @@ L'assistant n'écrase un fichier existant que si vous l'acceptez, et jamais avec
118118 - [Compression du contexte](context-compression.md) : ce que déclenche `context.threshold`.
119119 - [Sortie structurée](structured-output.md) : pourquoi l'usage `json` n'a pas d'outils.
120120 - [Interroger un autre agent](asking-another-agent.md) : ce sur quoi s'appuie l'usage `coordinator`.
121+- [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md) : les mêmes questions par ACP (`mm acp-create`). L'état de l'assistant est séparé de sa présentation : le mode ligne, la fenêtre et le mode ACP pilotent un même déroulé et écrivent le même fichier.
@@ -118,3 +118,4 @@ L'assistant n'écrase un fichier existant que si vous l'acceptez, et jamais avec
118 - [Compression du contexte](context-compression.md) : ce que déclenche `context.threshold`.118 - [Compression du contexte](context-compression.md) : ce que déclenche `context.threshold`.
119 - [Sortie structurée](structured-output.md) : pourquoi l'usage `json` n'a pas d'outils.119 - [Sortie structurée](structured-output.md) : pourquoi l'usage `json` n'a pas d'outils.
120 - [Interroger un autre agent](asking-another-agent.md) : ce sur quoi s'appuie l'usage `coordinator`.120 - [Interroger un autre agent](asking-another-agent.md) : ce sur quoi s'appuie l'usage `coordinator`.
121+- [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md) : les mêmes questions par ACP (`mm acp-create`). L'état de l'assistant est séparé de sa présentation : le mode ligne, la fenêtre et le mode ACP pilotent un même déroulé et écrivent le même fichier.
modified docs/fr/explanation/persistent-sessions.md +4 -0
@@ -26,6 +26,10 @@ Ce choix tient toujours, mais il n'est plus exclusif. Quand il a été fait, `se
2626
2727 Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations.
2828
29+## Sans sessions enregistrées, un rechargement recommence
30+
31+Jusqu'au 14 novembre 2026, un agent sans `sessions.enabled` n'annonçait ni `loadSession` ni `session/resume` : il n'y avait rien à relire, et le dire honnêtement renseignait l'éditeur. Zed répondait alors à son propre Reload Agent par « Loading or resuming sessions is not supported by this agent », et le fil était mort — `mm init` écrit `sessions` désactivé par défaut, donc la plupart des agents étaient dans ce cas. Depuis, les deux sont toujours annoncés. Sans sessions enregistrées, l'identifiant que demande l'éditeur devient une nouvelle session vide, et un message dans le fil dit que l'historique est perdu et comment le garder la prochaine fois (`sessions.enabled`). Un rechargement n'échoue donc jamais ; ce qu'il ramène dépend du réglage. `session/list` n'existe toujours qu'avec des sessions enregistrées : un sélecteur d'historique qui n'a rien à lister n'aide personne. `mm acp-create` fait de même : son assistant ne garde rien sur disque, et un rechargement le relance.
32+
2933 ## `/new` dans les deux façades
3034
3135 Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.
@@ -26,6 +26,10 @@ Ce choix tient toujours, mais il n'est plus exclusif. Quand il a été fait, `se
26 26
27 Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations.27 Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations.
28 28
29+## Sans sessions enregistrées, un rechargement recommence
30+
31+Jusqu'au 14 novembre 2026, un agent sans `sessions.enabled` n'annonçait ni `loadSession` ni `session/resume` : il n'y avait rien à relire, et le dire honnêtement renseignait l'éditeur. Zed répondait alors à son propre Reload Agent par « Loading or resuming sessions is not supported by this agent », et le fil était mort — `mm init` écrit `sessions` désactivé par défaut, donc la plupart des agents étaient dans ce cas. Depuis, les deux sont toujours annoncés. Sans sessions enregistrées, l'identifiant que demande l'éditeur devient une nouvelle session vide, et un message dans le fil dit que l'historique est perdu et comment le garder la prochaine fois (`sessions.enabled`). Un rechargement n'échoue donc jamais ; ce qu'il ramène dépend du réglage. `session/list` n'existe toujours qu'avec des sessions enregistrées : un sélecteur d'historique qui n'a rien à lister n'aide personne. `mm acp-create` fait de même : son assistant ne garde rien sur disque, et un rechargement le relance.
32+
29 ## `/new` dans les deux façades33 ## `/new` dans les deux façades
30 34
31 Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.35 Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.
added docs/fr/how-to/create-an-agent-from-an-editor.md +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+# Créer un agent depuis votre éditeur (`mm acp-create`)
2+
3+Ce guide montre comment créer un agent.yaml sans terminal, depuis n'importe quel éditeur ou outil qui parle l'Agent Client Protocol (Zed, un IDE JetBrains, un plugin Neovim…). `mm acp-create` pose les questions de [`mm init`](create-an-agent.md) par ACP et écrit le même fichier. Il suppose que `mm` est installé dans votre `PATH` et qu'un serveur de modèles tourne (l'assistant liste ses modèles).
4+
5+## Étapes
6+
7+1. Déclarez `mm acp-create` comme serveur d'agent dans votre client. Dans Zed, ajoutez à `settings.json` :
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mm-create": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["acp-create"]
16+ }
17+ }
18+ }
19+ ```
20+
21+ Les autres clients demandent les deux mêmes choses : la commande `mm` et l'argument `acp-create`. Aucun fichier de configuration n'est nécessaire : ce mode n'en lit aucun.
22+
23+2. Ouvrez le projet dans lequel l'agent travaillera, démarrez une nouvelle conversation avec `mm-create` et envoyez `/start` (ou n'importe quel message). L'assistant démarre. Le fichier sera écrit dans le répertoire du projet — le répertoire de travail de la session — et non là où le client a lancé `mm`.
24+
25+3. Répondez aux questions. Leur forme dépend de ce que votre client sait afficher :
26+
27+ - **Un client qui affiche des formulaires** (il annonce la capacité `elicitation.form`) : les questions arrivent en quelques formulaires, valeurs par défaut déjà remplies. Une question dont les choix dépendent d'une réponse précédente — la liste des modèles du serveur choisi — arrive dans le formulaire suivant.
28+ - **Tout autre client** : chaque question est un message, avec des choix numérotés. Répondez par un numéro ou une valeur ; `.` prend la valeur par défaut.
29+
30+4. Lisez le message de fin. Il affiche `✅ wrote agent.yaml` et `✅ mm loads it`, puis la commande qui lance l'agent, `mm acp /chemin/absolu/vers/agent.yaml`. Zed reçoit une entrée `agent_servers` toute prête ; pour un autre client, utilisez cette ligne de commande.
31+
32+5. Pour faire tourner l'agent dans un conteneur ou un Docker Sandbox plutôt que sur votre machine, envoyez `/dockerinit` (écrit `Dockerfile` et `compose.yaml`) ou `/sbxinit` (écrit `sbxenv.yaml`) dans la même conversation. Voir [faire tourner mini-me dans un conteneur](run-in-a-container.md) et [faire tourner mini-me dans un Docker Sandbox](run-in-a-docker-sandbox.md).
33+
34+6. Envoyez `/zedconfig`. Il affiche les entrées `agent_servers` de chaque façon dont le projet peut maintenant lancer son agent — sur votre machine, dans son conteneur, dans sa sandbox — et ce qu'il faut lancer avant pour chacune. Copiez celles que vous voulez dans les réglages de votre client, et démarrez une conversation avec l'une d'elles : c'est votre agent.
35+
36+## Commandes pendant l'assistant
37+
38+| Commande | Effet |
39+|----------|-------|
40+| `/start` | Démarre l'assistant ; une fois démarré, repose la question en cours. |
41+| `/preview` | Affiche l'agent.yaml tel qu'il serait écrit maintenant. |
42+| `/back` | Reprend la dernière réponse et repose la question. |
43+| `/write` | Prend la valeur par défaut de chaque question restante et écrit le fichier (remplacer un fichier existant reste demandé). |
44+| `/restart` | Oublie toutes les réponses et recommence. |
45+| `/dockerinit` | Écrit un `Dockerfile` et un `compose.yaml` dans le projet pour faire tourner l'agent dans un conteneur — `mm dockerinit` pour ce projet. Accepte ses options (`--name`, `--agent`) ; les fichiers existants sont gardés sauf si vous ajoutez `--overwrite`. |
46+| `/zedconfig` | Affiche les entrées `agent_servers` de Zed pour l'agent de ce projet, d'après ce que contient le projet : `mm acp <fichier d'agent>` toujours ; avec un `compose.yaml`, `docker exec -i <conteneur> mm -acp -config <fichier d'agent>` ; avec un `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <fichier d'agent>` — chacune avec ce qu'il faut lancer avant. |
47+| `/sbxinit` | Écrit un `sbxenv.yaml` dans le projet pour faire tourner l'agent dans un Docker Sandbox — `mm sbxinit` pour ce projet. Accepte ses options (`--name`, `--workspace`, `--agent`) ; un `sbxenv.yaml` existant est gardé sauf si vous ajoutez `--overwrite`. Marche à tout moment ; une fois l'agent écrit, le sandbox porte son nom. |
48+| `/help` | Liste ces commandes. |
49+
50+## Variantes
51+
52+- **Fixer des réponses à l'avance.** Les options de `mm init` marchent ici (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…) : une question dont l'option est donnée n'est pas posée. Par exemple `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
53+- **La disposition `.mm`.** Répondez `yes` à *Keep everything in a .mm folder?* : le fichier est `.mm/agent.yaml`, voir [Ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md).
54+- **Refuser un formulaire** n'écrit rien. Envoyez un autre message pour reprendre là où vous vous êtes arrêté.
55+- **Recharger l'agent** (*Reload Agent* dans Zed) relance l'assistant à la première question : les réponses ne sont pas gardées. Un fichier déjà écrit reste tel quel.
56+
57+## Voir aussi
58+
59+- [Créer un agent avec `mm init`](create-an-agent.md) — le même assistant dans un terminal.
60+- [Utiliser mini-me depuis un éditeur (ACP)](use-from-an-editor.md) — faire tourner l'agent créé.
61+- [Référence : ligne de commande — `mm acp-create`](../reference/cli.md#mm-acp-create--lassistant-agentyaml-par-acp)
new file mode 100644
@@ -0,0 +1,61 @@
1+# Créer un agent depuis votre éditeur (`mm acp-create`)
2+
3+Ce guide montre comment créer un agent.yaml sans terminal, depuis n'importe quel éditeur ou outil qui parle l'Agent Client Protocol (Zed, un IDE JetBrains, un plugin Neovim…). `mm acp-create` pose les questions de [`mm init`](create-an-agent.md) par ACP et écrit le même fichier. Il suppose que `mm` est installé dans votre `PATH` et qu'un serveur de modèles tourne (l'assistant liste ses modèles).
4+
5+## Étapes
6+
7+1. Déclarez `mm acp-create` comme serveur d'agent dans votre client. Dans Zed, ajoutez à `settings.json` :
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mm-create": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["acp-create"]
16+ }
17+ }
18+ }
19+ ```
20+
21+ Les autres clients demandent les deux mêmes choses : la commande `mm` et l'argument `acp-create`. Aucun fichier de configuration n'est nécessaire : ce mode n'en lit aucun.
22+
23+2. Ouvrez le projet dans lequel l'agent travaillera, démarrez une nouvelle conversation avec `mm-create` et envoyez `/start` (ou n'importe quel message). L'assistant démarre. Le fichier sera écrit dans le répertoire du projet — le répertoire de travail de la session — et non là où le client a lancé `mm`.
24+
25+3. Répondez aux questions. Leur forme dépend de ce que votre client sait afficher :
26+
27+ - **Un client qui affiche des formulaires** (il annonce la capacité `elicitation.form`) : les questions arrivent en quelques formulaires, valeurs par défaut déjà remplies. Une question dont les choix dépendent d'une réponse précédente — la liste des modèles du serveur choisi — arrive dans le formulaire suivant.
28+ - **Tout autre client** : chaque question est un message, avec des choix numérotés. Répondez par un numéro ou une valeur ; `.` prend la valeur par défaut.
29+
30+4. Lisez le message de fin. Il affiche `✅ wrote agent.yaml` et `✅ mm loads it`, puis la commande qui lance l'agent, `mm acp /chemin/absolu/vers/agent.yaml`. Zed reçoit une entrée `agent_servers` toute prête ; pour un autre client, utilisez cette ligne de commande.
31+
32+5. Pour faire tourner l'agent dans un conteneur ou un Docker Sandbox plutôt que sur votre machine, envoyez `/dockerinit` (écrit `Dockerfile` et `compose.yaml`) ou `/sbxinit` (écrit `sbxenv.yaml`) dans la même conversation. Voir [faire tourner mini-me dans un conteneur](run-in-a-container.md) et [faire tourner mini-me dans un Docker Sandbox](run-in-a-docker-sandbox.md).
33+
34+6. Envoyez `/zedconfig`. Il affiche les entrées `agent_servers` de chaque façon dont le projet peut maintenant lancer son agent — sur votre machine, dans son conteneur, dans sa sandbox — et ce qu'il faut lancer avant pour chacune. Copiez celles que vous voulez dans les réglages de votre client, et démarrez une conversation avec l'une d'elles : c'est votre agent.
35+
36+## Commandes pendant l'assistant
37+
38+| Commande | Effet |
39+|----------|-------|
40+| `/start` | Démarre l'assistant ; une fois démarré, repose la question en cours. |
41+| `/preview` | Affiche l'agent.yaml tel qu'il serait écrit maintenant. |
42+| `/back` | Reprend la dernière réponse et repose la question. |
43+| `/write` | Prend la valeur par défaut de chaque question restante et écrit le fichier (remplacer un fichier existant reste demandé). |
44+| `/restart` | Oublie toutes les réponses et recommence. |
45+| `/dockerinit` | Écrit un `Dockerfile` et un `compose.yaml` dans le projet pour faire tourner l'agent dans un conteneur — `mm dockerinit` pour ce projet. Accepte ses options (`--name`, `--agent`) ; les fichiers existants sont gardés sauf si vous ajoutez `--overwrite`. |
46+| `/zedconfig` | Affiche les entrées `agent_servers` de Zed pour l'agent de ce projet, d'après ce que contient le projet : `mm acp <fichier d'agent>` toujours ; avec un `compose.yaml`, `docker exec -i <conteneur> mm -acp -config <fichier d'agent>` ; avec un `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <fichier d'agent>` — chacune avec ce qu'il faut lancer avant. |
47+| `/sbxinit` | Écrit un `sbxenv.yaml` dans le projet pour faire tourner l'agent dans un Docker Sandbox — `mm sbxinit` pour ce projet. Accepte ses options (`--name`, `--workspace`, `--agent`) ; un `sbxenv.yaml` existant est gardé sauf si vous ajoutez `--overwrite`. Marche à tout moment ; une fois l'agent écrit, le sandbox porte son nom. |
48+| `/help` | Liste ces commandes. |
49+
50+## Variantes
51+
52+- **Fixer des réponses à l'avance.** Les options de `mm init` marchent ici (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…) : une question dont l'option est donnée n'est pas posée. Par exemple `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
53+- **La disposition `.mm`.** Répondez `yes` à *Keep everything in a .mm folder?* : le fichier est `.mm/agent.yaml`, voir [Ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md).
54+- **Refuser un formulaire** n'écrit rien. Envoyez un autre message pour reprendre là où vous vous êtes arrêté.
55+- **Recharger l'agent** (*Reload Agent* dans Zed) relance l'assistant à la première question : les réponses ne sont pas gardées. Un fichier déjà écrit reste tel quel.
56+
57+## Voir aussi
58+
59+- [Créer un agent avec `mm init`](create-an-agent.md) — le même assistant dans un terminal.
60+- [Utiliser mini-me depuis un éditeur (ACP)](use-from-an-editor.md) — faire tourner l'agent créé.
61+- [Référence : ligne de commande — `mm acp-create`](../reference/cli.md#mm-acp-create--lassistant-agentyaml-par-acp)
modified docs/fr/how-to/create-an-agent.md +1 -0
@@ -90,6 +90,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc
9090
9191 ## Voir aussi
9292
93+- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — le même assistant par ACP
9394 - [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie
9495 - [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier
9596 - [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine
@@ -90,6 +90,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc
90 90
91 ## Voir aussi91 ## Voir aussi
92 92
93+- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — le même assistant par ACP
93 - [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie94 - [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie
94 - [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier95 - [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier
95 - [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine96 - [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine
modified docs/fr/how-to/run-in-a-container.md +18 -1
@@ -22,6 +22,23 @@ Le service prend le nom de `acp.name` dans `agent.yaml`, ou `mm` s'il n'y en a p
2222
2323 Si un fichier existe déjà, mm demande `Overwrite Dockerfile?`, et `no` est la réponse par défaut. Avec `--yes`, ou quand l'entrée se termine, rien n'est écrit.
2424
25+### Un projet avec un dossier `.mm`
26+
27+Quand l'agent est `.mm/agent.yaml` (créé avec `mm init --project yes`, voir [ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` le trouve sans option — il cherche `agent.yaml`, puis `.mm/agent.yaml`, comme mm — et nomme le service d'après lui. `compose.yaml` alors :
28+
29+- monte `.mm/` **en lecture seule** sur le projet : l'agent ne peut pas modifier ses propres réglages, skills ou hooks ;
30+- remonte en écriture les dossiers où mm écrit — le `sessions.dir` et le `trace.dir` du fichier d'agent, quand ils sont activés et sous `.mm/` — et les crée tout de suite s'ils n'existent pas (Docker les créerait au nom de root, et l'agent ne tourne jamais en root) ;
31+- lance `mm tui` sans fichier : mm trouve `.mm/agent.yaml` depuis le projet.
32+
33+```yaml
34+ volumes:
35+ - .:${PWD:-/workspace}
36+ - ./.mm:${PWD:-/workspace}/.mm:ro
37+ - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir
38+```
39+
40+Tout ce qui est écrit d'autre sous `.mm/` — un hook qui y tient un journal — demande sa propre ligne. Après avoir activé ou coupé les sessions ou la trace, relancez `mm dockerinit` (répondez `yes` pour remplacer `compose.yaml`).
41+
2542 ## Lancer l'agent
2643
2744 Soit vous gardez un conteneur démarré, qui sert aussi à un éditeur :
@@ -47,7 +64,7 @@ Démarrez le conteneur avec `docker compose up -d --wait`, puis donnez cette com
4764 docker exec -i bob-box mm --acp --config /chemin/du/projet/agent.yaml
4865 ```
4966
50-Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur.
67+Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur. Pour un projet `.mm`, `docker exec -i bob-box mm --acp` suffit : le conteneur démarre dans le projet, où mm trouve `.mm/agent.yaml`.
5168
5269 ## Joindre le modèle
5370
@@ -22,6 +22,23 @@ Le service prend le nom de `acp.name` dans `agent.yaml`, ou `mm` s'il n'y en a p
22 22
23 Si un fichier existe déjà, mm demande `Overwrite Dockerfile?`, et `no` est la réponse par défaut. Avec `--yes`, ou quand l'entrée se termine, rien n'est écrit.23 Si un fichier existe déjà, mm demande `Overwrite Dockerfile?`, et `no` est la réponse par défaut. Avec `--yes`, ou quand l'entrée se termine, rien n'est écrit.
24 24
25+### Un projet avec un dossier `.mm`
26+
27+Quand l'agent est `.mm/agent.yaml` (créé avec `mm init --project yes`, voir [ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` le trouve sans option — il cherche `agent.yaml`, puis `.mm/agent.yaml`, comme mm — et nomme le service d'après lui. `compose.yaml` alors :
28+
29+- monte `.mm/` **en lecture seule** sur le projet : l'agent ne peut pas modifier ses propres réglages, skills ou hooks ;
30+- remonte en écriture les dossiers où mm écrit — le `sessions.dir` et le `trace.dir` du fichier d'agent, quand ils sont activés et sous `.mm/` — et les crée tout de suite s'ils n'existent pas (Docker les créerait au nom de root, et l'agent ne tourne jamais en root) ;
31+- lance `mm tui` sans fichier : mm trouve `.mm/agent.yaml` depuis le projet.
32+
33+```yaml
34+ volumes:
35+ - .:${PWD:-/workspace}
36+ - ./.mm:${PWD:-/workspace}/.mm:ro
37+ - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir
38+```
39+
40+Tout ce qui est écrit d'autre sous `.mm/` — un hook qui y tient un journal — demande sa propre ligne. Après avoir activé ou coupé les sessions ou la trace, relancez `mm dockerinit` (répondez `yes` pour remplacer `compose.yaml`).
41+
25 ## Lancer l'agent42 ## Lancer l'agent
26 43
27 Soit vous gardez un conteneur démarré, qui sert aussi à un éditeur :44 Soit vous gardez un conteneur démarré, qui sert aussi à un éditeur :
@@ -47,7 +64,7 @@ Démarrez le conteneur avec `docker compose up -d --wait`, puis donnez cette com
47 docker exec -i bob-box mm --acp --config /chemin/du/projet/agent.yaml64 docker exec -i bob-box mm --acp --config /chemin/du/projet/agent.yaml
48 ```65 ```
49 66
50-Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur.67+Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur. Pour un projet `.mm`, `docker exec -i bob-box mm --acp` suffit : le conteneur démarre dans le projet, où mm trouve `.mm/agent.yaml`.
51 68
52 ## Joindre le modèle69 ## Joindre le modèle
53 70
modified docs/fr/how-to/run-in-a-docker-sandbox.md +1 -0
@@ -97,6 +97,7 @@ Pour décrire la sandbox dans un fichier, lancez ceci dans le dossier de votre p
9797 ```bash
9898 mm sbxinit
9999 sbx env create --force
100+sbx env run --force
100101 ```
101102
102103 `sbx env` demande sbx 0.39.0 ou plus, et `docker.io/` parmi les sources de kits autorisées (étape 1). Le fichier indique la dernière version de `mini-me-kit`, et `pullPolicy: always` fait récupérer ce kit par la sandbox à chaque création. Si cette version ne peut pas être lue, par exemple sans réseau, mm le signale et utilise le tag `latest`. La sandbox prend le nom de `acp.name` dans `agent.yaml`, suivi de `-sbx` ; `--name` permet d'en choisir un autre.
@@ -97,6 +97,7 @@ Pour décrire la sandbox dans un fichier, lancez ceci dans le dossier de votre p
97 ```bash97 ```bash
98 mm sbxinit98 mm sbxinit
99 sbx env create --force99 sbx env create --force
100+sbx env run --force
100 ```101 ```
101 102
102 `sbx env` demande sbx 0.39.0 ou plus, et `docker.io/` parmi les sources de kits autorisées (étape 1). Le fichier indique la dernière version de `mini-me-kit`, et `pullPolicy: always` fait récupérer ce kit par la sandbox à chaque création. Si cette version ne peut pas être lue, par exemple sans réseau, mm le signale et utilise le tag `latest`. La sandbox prend le nom de `acp.name` dans `agent.yaml`, suivi de `-sbx` ; `--name` permet d'en choisir un autre.103 `sbx env` demande sbx 0.39.0 ou plus, et `docker.io/` parmi les sources de kits autorisées (étape 1). Le fichier indique la dernière version de `mini-me-kit`, et `pullPolicy: always` fait récupérer ce kit par la sandbox à chaque création. Si cette version ne peut pas être lue, par exemple sans réseau, mm le signale et utilise le tag `latest`. La sandbox prend le nom de `acp.name` dans `agent.yaml`, suivi de `-sbx` ; `--name` permet d'en choisir un autre.
modified docs/fr/how-to/use-from-an-editor.md +2 -1
@@ -31,7 +31,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
3131 - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.
3232 - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).
3333 - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.
34-- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md).
34+- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md). Sans `sessions.enabled`, le Reload Agent marche quand même, mais la conversation recommence : mm le dit dans le fil.
3535 - **Voir le JSON échangé avec le modèle.** Activez la trace, dans le fichier de configuration (`trace: {enabled: true}`) ou dans les réglages de l'éditeur (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — l'éditeur ne transmet pas les variables de votre shell). Les fichiers arrivent dans `<projet>/.mm/traces/`, le projet étant le `cwd` de la session ; la bannière, dans les logs ACP, indique `trace: <session cwd>/.mm/traces`. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md).
3636 - **Changer le nom que voit l'éditeur.** Ajoutez un bloc `acp:` au fichier de configuration ; `name` est l'identifiant que l'éditeur journalise, `title` le nom qu'il peut afficher (laissez `title` vide pour n'en envoyer aucun). La version envoyée est toujours celle du binaire, celle qu'affiche `mm --version` — vérifiez-la avec la requête `initialize` ci-dessous. Voir les [clés de `acp`](../reference/configuration.md#clés-de-acp).
3737
@@ -52,5 +52,6 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
5252
5353 ## Voir aussi
5454
55+- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — écrire l'agent.yaml sans terminal
5556 - Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md)
5657 - Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md)
@@ -31,7 +31,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
31 - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.31 - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.
32 - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).32 - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).
33 - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.33 - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.
34-- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md).34+- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md). Sans `sessions.enabled`, le Reload Agent marche quand même, mais la conversation recommence : mm le dit dans le fil.
35 - **Voir le JSON échangé avec le modèle.** Activez la trace, dans le fichier de configuration (`trace: {enabled: true}`) ou dans les réglages de l'éditeur (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — l'éditeur ne transmet pas les variables de votre shell). Les fichiers arrivent dans `<projet>/.mm/traces/`, le projet étant le `cwd` de la session ; la bannière, dans les logs ACP, indique `trace: <session cwd>/.mm/traces`. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md).35 - **Voir le JSON échangé avec le modèle.** Activez la trace, dans le fichier de configuration (`trace: {enabled: true}`) ou dans les réglages de l'éditeur (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — l'éditeur ne transmet pas les variables de votre shell). Les fichiers arrivent dans `<projet>/.mm/traces/`, le projet étant le `cwd` de la session ; la bannière, dans les logs ACP, indique `trace: <session cwd>/.mm/traces`. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md).
36 - **Changer le nom que voit l'éditeur.** Ajoutez un bloc `acp:` au fichier de configuration ; `name` est l'identifiant que l'éditeur journalise, `title` le nom qu'il peut afficher (laissez `title` vide pour n'en envoyer aucun). La version envoyée est toujours celle du binaire, celle qu'affiche `mm --version` — vérifiez-la avec la requête `initialize` ci-dessous. Voir les [clés de `acp`](../reference/configuration.md#clés-de-acp).36 - **Changer le nom que voit l'éditeur.** Ajoutez un bloc `acp:` au fichier de configuration ; `name` est l'identifiant que l'éditeur journalise, `title` le nom qu'il peut afficher (laissez `title` vide pour n'en envoyer aucun). La version envoyée est toujours celle du binaire, celle qu'affiche `mm --version` — vérifiez-la avec la requête `initialize` ci-dessous. Voir les [clés de `acp`](../reference/configuration.md#clés-de-acp).
37 37
@@ -52,5 +52,6 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
52 52
53 ## Voir aussi53 ## Voir aussi
54 54
55+- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — écrire l'agent.yaml sans terminal
55 - Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md)56 - Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md)
56 - Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md)57 - Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md)
modified docs/fr/reference/acp.md +3 -3
@@ -16,7 +16,7 @@
1616 | Champ | Valeur |
1717 |-------|--------|
1818 | `protocolVersion` | `1` |
19-| `agentCapabilities` | Quand `sessions.enabled` est activé : `loadSession: true` et `sessionCapabilities: {"list": {}, "resume": {}}` ; les deux absentes (false) sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. |
19+| `agentCapabilities` | `loadSession: true`. `sessionCapabilities` : `{"list": {}, "resume": {}}` quand `sessions.enabled` est activé, `{"resume": {}}` sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. |
2020 | `agentInfo.name` | `acp.name` de la configuration, `bob` par défaut. |
2121 | `agentInfo.title` | `acp.title` de la configuration, `Bob (bash-first agent)` par défaut ; absent quand vide. |
2222 | `agentInfo.version` | La version du binaire, la même chaîne que `mm --version` (par exemple `v0.1.7`). |
@@ -31,9 +31,9 @@
3131 | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. |
3232 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
3333 | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. |
34-| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. |
34+| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : l'id demandé devient une nouvelle session vide dans le `cwd` de la requête (rien n'est lu ni écrit), un `available_commands_update` et un `agent_message_chunk` disant que la conversation recommence sont envoyés, et la réponse est `{}` — pour que le rechargement d'un éditeur n'échoue pas. Les outils client de la session sont fixés comme pour `session/new`. |
3535 | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. |
36-| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. |
36+| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : comme `session/load` sans `sessions.enabled` — une nouvelle session vide sous l'id demandé, et le message qui le dit. Les outils client de la session sont fixés comme pour `session/new`. |
3737 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |
3838
3939 ## Les skills comme commandes
@@ -16,7 +16,7 @@
16 | Champ | Valeur |16 | Champ | Valeur |
17 |-------|--------|17 |-------|--------|
18 | `protocolVersion` | `1` |18 | `protocolVersion` | `1` |
19-| `agentCapabilities` | Quand `sessions.enabled` est activé : `loadSession: true` et `sessionCapabilities: {"list": {}, "resume": {}}` ; les deux absentes (false) sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. |19+| `agentCapabilities` | `loadSession: true`. `sessionCapabilities` : `{"list": {}, "resume": {}}` quand `sessions.enabled` est activé, `{"resume": {}}` sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. |
20 | `agentInfo.name` | `acp.name` de la configuration, `bob` par défaut. |20 | `agentInfo.name` | `acp.name` de la configuration, `bob` par défaut. |
21 | `agentInfo.title` | `acp.title` de la configuration, `Bob (bash-first agent)` par défaut ; absent quand vide. |21 | `agentInfo.title` | `acp.title` de la configuration, `Bob (bash-first agent)` par défaut ; absent quand vide. |
22 | `agentInfo.version` | La version du binaire, la même chaîne que `mm --version` (par exemple `v0.1.7`). |22 | `agentInfo.version` | La version du binaire, la même chaîne que `mm --version` (par exemple `v0.1.7`). |
@@ -31,9 +31,9 @@
31 | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. |31 | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. |
32 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |32 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
33 | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. |33 | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. |
34-| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. |34+| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : l'id demandé devient une nouvelle session vide dans le `cwd` de la requête (rien n'est lu ni écrit), un `available_commands_update` et un `agent_message_chunk` disant que la conversation recommence sont envoyés, et la réponse est `{}` — pour que le rechargement d'un éditeur n'échoue pas. Les outils client de la session sont fixés comme pour `session/new`. |
35 | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. |35 | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. |
36-| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. |36+| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : comme `session/load` sans `sessions.enabled` — une nouvelle session vide sous l'id demandé, et le message qui le dit. Les outils client de la session sont fixés comme pour `session/new`. |
37 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |37 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |
38 38
39 ## Les skills comme commandes39 ## Les skills comme commandes
modified docs/fr/reference/cli.md +18 -1
@@ -9,6 +9,7 @@ mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [--
99 mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p)
1010 mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin)
1111 mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)
12+mm acp-create [options] (l'assistant de mm init par ACP, pour un éditeur)
1213 mm dockerinit [options] (écrire un Dockerfile et un compose.yaml)
1314 mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox)
1415 mm skillcreate [options] (écrire un nouveau skill avec un assistant)
@@ -209,7 +210,7 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé
209210 | Option | Signification | Défaut |
210211 |--------|---------------|--------|
211212 | `--name` | Le nom du service, en minuscules et chiffres reliés par des tirets (`Bob The Bot` donne `bob-the-bot`). Le conteneur est `<nom>-box`, l'image `mm-<nom>:latest` | `acp.name` dans le fichier d'agent, sinon `mm` |
212-| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées | `agent.yaml` |
213+| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées. Sans l'option, `.mm/agent.yaml` est cherché aussi, après `agent.yaml` (l'ordre que suit mm) ; trouvé dans `.mm/`, `compose.yaml` monte `.mm/` en lecture seule et remonte en écriture le `sessions.dir` et le `trace.dir` du fichier d'agent (quand ils sont activés et sous `.mm/`), avec des chemins relatifs au projet, crée ces dossiers s'ils manquent, et lance `mm tui` sans fichier pour `.mm/agent.yaml` | `agent.yaml`, puis `.mm/agent.yaml` |
213214 | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé ni écrit | désactivé |
214215
215216 La question est posée pour chaque fichier existant, avec `no` par défaut : ce fichier est gardé, l'autre est écrit. Codes de sortie : `0` terminé, `1` rien d'écrit parce qu'un fichier existe et que personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects.
@@ -267,6 +268,22 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
267268 | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |
268269 | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |
269270
271+## `mm acp-create` — l'assistant agent.yaml par ACP
272+
273+`mm acp-create` (aussi `--acp-create`, `-acp-create`) pose les questions de [`mm init`](#mm-init--lassistant-agentyaml) par l'Agent Client Protocol sur stdin/stdout, pour un éditeur ou tout client ACP. Il est traité avant toute lecture de configuration et n'appelle aucun modèle (il liste les modèles du serveur, comme `mm init`). Voir [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md).
274+
275+| Aspect | Comportement |
276+|--------|--------------|
277+| Options | Celles de `mm init` sauf `--check`, `--yes` et `--line` ; une question dont l'option est donnée n'est pas posée. Une valeur d'option refusée arrête `mm` au démarrage (code 1). `-h` et les erreurs vont sur stderr : stdout ne porte que du JSON-RPC. |
278+| `initialize` | `agentInfo.name` `mm-acp-create`, titre `mm init (agent.yaml wizard)` ; `loadSession: true` et `sessionCapabilities.resume`, aucune méthode d'authentification. |
279+| `session/load`, `session/resume` | Rien n'est gardé d'un redémarrage à l'autre : un assistant vierge est enregistré sous l'id demandé, dans le `cwd` donné, puis les commandes et un message disant que l'assistant recommence (pas de rejeu). Un fichier déjà écrit n'est pas touché. C'est ce qu'utilise le « reload agent » d'un client. |
280+| Sessions | Un assistant par `session/new`. Les fichiers relatifs sont écrits dans le `cwd` de la session. Après la réponse à `session/new`, un `agent_message_chunk` invite à envoyer `/start`. `/start` ou le premier prompt, quel que soit son texte, démarre l'assistant. |
281+| Questions | Choisies d'après les seules capacités du client. `clientCapabilities.elicitation.form` présent : formulaires `elicitation/create` (liés à la session), autant de questions par formulaire que possible — un formulaire ne contient jamais une question avec celle dont ses choix ou sa valeur par défaut dépendent (modèle après serveur, famille après modèle, confirmation et nom après usage, skills et fichier après disposition, remplacement après fichier). Absent : un `agent_message_chunk` par question, choix numérotés ; le prompt suivant y répond — un numéro, une valeur, ou `.` / `default` / `/skip` pour la valeur par défaut. |
282+| Issues d'un formulaire | Refusé ou annulé : rien n'est écrit, le prompt suivant redemande. Trois formulaires de suite sans réponse utilisable : l'assistant s'arrête. |
283+| Commandes (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig` : un bloc JSON `agent_servers` pour le fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`) — une entrée `mm-<projet>` qui lance `mm acp <chemin absolu>` ; avec `compose.yaml`, `mm-<projet> (container)` qui lance `docker exec -i <container_name> mm -acp -config <chemin>` ; avec `sbxenv.yaml`, `mm-<projet> (sandbox)` qui lance `sbx exec -i <name> mm -acp -config <chemin>` — puis une ligne par entrée disant ce qu'il faut lancer avant. Sans fichier d'agent : un renvoi vers `/start`. `dockerinit` et `sbxinit` (indication de saisie : leurs options) lancent [`mm dockerinit`](#mm-dockerinit--les-fichiers-du-conteneur) et [`mm sbxinit`](#mm-sbxinit--le-fichier-de-la-sandbox) avec le `cwd` de la session comme répertoire courant, leur sortie dans un message ; `--overwrite` remplace les fichiers existants, gardés sinon (rien ne peut être demandé). |
284+| Après l'écriture | Les messages de `mm init` (`✅ wrote <fichier>`, `✅ mm loads it`), puis `mm acp <chemin absolu>`, un renvoi vers `/zedconfig`, et un vers `/dockerinit` et `/sbxinit` ; pour un client dont `clientInfo.name` contient `zed`, une entrée `agent_servers` en plus. Les prompts suivants répondent que le fichier est écrit. |
285+| Codes de sortie | 0 le client a fermé la connexion, ou `-h` ; 1 une valeur d'option refusée ou des presets illisibles ; 2 une option inconnue. |
286+
270287 ## `mm skillcreate` — l'assistant de skill
271288
272289 `mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md).
@@ -9,6 +9,7 @@ mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [--
9 mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p)9 mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p)
10 mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin)10 mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin)
11 mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)11 mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)
12+mm acp-create [options] (l'assistant de mm init par ACP, pour un éditeur)
12 mm dockerinit [options] (écrire un Dockerfile et un compose.yaml)13 mm dockerinit [options] (écrire un Dockerfile et un compose.yaml)
13 mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox)14 mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox)
14 mm skillcreate [options] (écrire un nouveau skill avec un assistant)15 mm skillcreate [options] (écrire un nouveau skill avec un assistant)
@@ -209,7 +210,7 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé
209 | Option | Signification | Défaut |210 | Option | Signification | Défaut |
210 |--------|---------------|--------|211 |--------|---------------|--------|
211 | `--name` | Le nom du service, en minuscules et chiffres reliés par des tirets (`Bob The Bot` donne `bob-the-bot`). Le conteneur est `<nom>-box`, l'image `mm-<nom>:latest` | `acp.name` dans le fichier d'agent, sinon `mm` |212 | `--name` | Le nom du service, en minuscules et chiffres reliés par des tirets (`Bob The Bot` donne `bob-the-bot`). Le conteneur est `<nom>-box`, l'image `mm-<nom>:latest` | `acp.name` dans le fichier d'agent, sinon `mm` |
212-| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées | `agent.yaml` |213+| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées. Sans l'option, `.mm/agent.yaml` est cherché aussi, après `agent.yaml` (l'ordre que suit mm) ; trouvé dans `.mm/`, `compose.yaml` monte `.mm/` en lecture seule et remonte en écriture le `sessions.dir` et le `trace.dir` du fichier d'agent (quand ils sont activés et sous `.mm/`), avec des chemins relatifs au projet, crée ces dossiers s'ils manquent, et lance `mm tui` sans fichier pour `.mm/agent.yaml` | `agent.yaml`, puis `.mm/agent.yaml` |
213 | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé ni écrit | désactivé |214 | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé ni écrit | désactivé |
214 215
215 La question est posée pour chaque fichier existant, avec `no` par défaut : ce fichier est gardé, l'autre est écrit. Codes de sortie : `0` terminé, `1` rien d'écrit parce qu'un fichier existe et que personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects.216 La question est posée pour chaque fichier existant, avec `no` par défaut : ce fichier est gardé, l'autre est écrit. Codes de sortie : `0` terminé, `1` rien d'écrit parce qu'un fichier existe et que personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects.
@@ -267,6 +268,22 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
267 | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |268 | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |
268 | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |269 | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |
269 270
271+## `mm acp-create` — l'assistant agent.yaml par ACP
272+
273+`mm acp-create` (aussi `--acp-create`, `-acp-create`) pose les questions de [`mm init`](#mm-init--lassistant-agentyaml) par l'Agent Client Protocol sur stdin/stdout, pour un éditeur ou tout client ACP. Il est traité avant toute lecture de configuration et n'appelle aucun modèle (il liste les modèles du serveur, comme `mm init`). Voir [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md).
274+
275+| Aspect | Comportement |
276+|--------|--------------|
277+| Options | Celles de `mm init` sauf `--check`, `--yes` et `--line` ; une question dont l'option est donnée n'est pas posée. Une valeur d'option refusée arrête `mm` au démarrage (code 1). `-h` et les erreurs vont sur stderr : stdout ne porte que du JSON-RPC. |
278+| `initialize` | `agentInfo.name` `mm-acp-create`, titre `mm init (agent.yaml wizard)` ; `loadSession: true` et `sessionCapabilities.resume`, aucune méthode d'authentification. |
279+| `session/load`, `session/resume` | Rien n'est gardé d'un redémarrage à l'autre : un assistant vierge est enregistré sous l'id demandé, dans le `cwd` donné, puis les commandes et un message disant que l'assistant recommence (pas de rejeu). Un fichier déjà écrit n'est pas touché. C'est ce qu'utilise le « reload agent » d'un client. |
280+| Sessions | Un assistant par `session/new`. Les fichiers relatifs sont écrits dans le `cwd` de la session. Après la réponse à `session/new`, un `agent_message_chunk` invite à envoyer `/start`. `/start` ou le premier prompt, quel que soit son texte, démarre l'assistant. |
281+| Questions | Choisies d'après les seules capacités du client. `clientCapabilities.elicitation.form` présent : formulaires `elicitation/create` (liés à la session), autant de questions par formulaire que possible — un formulaire ne contient jamais une question avec celle dont ses choix ou sa valeur par défaut dépendent (modèle après serveur, famille après modèle, confirmation et nom après usage, skills et fichier après disposition, remplacement après fichier). Absent : un `agent_message_chunk` par question, choix numérotés ; le prompt suivant y répond — un numéro, une valeur, ou `.` / `default` / `/skip` pour la valeur par défaut. |
282+| Issues d'un formulaire | Refusé ou annulé : rien n'est écrit, le prompt suivant redemande. Trois formulaires de suite sans réponse utilisable : l'assistant s'arrête. |
283+| Commandes (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig` : un bloc JSON `agent_servers` pour le fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`) — une entrée `mm-<projet>` qui lance `mm acp <chemin absolu>` ; avec `compose.yaml`, `mm-<projet> (container)` qui lance `docker exec -i <container_name> mm -acp -config <chemin>` ; avec `sbxenv.yaml`, `mm-<projet> (sandbox)` qui lance `sbx exec -i <name> mm -acp -config <chemin>` — puis une ligne par entrée disant ce qu'il faut lancer avant. Sans fichier d'agent : un renvoi vers `/start`. `dockerinit` et `sbxinit` (indication de saisie : leurs options) lancent [`mm dockerinit`](#mm-dockerinit--les-fichiers-du-conteneur) et [`mm sbxinit`](#mm-sbxinit--le-fichier-de-la-sandbox) avec le `cwd` de la session comme répertoire courant, leur sortie dans un message ; `--overwrite` remplace les fichiers existants, gardés sinon (rien ne peut être demandé). |
284+| Après l'écriture | Les messages de `mm init` (`✅ wrote <fichier>`, `✅ mm loads it`), puis `mm acp <chemin absolu>`, un renvoi vers `/zedconfig`, et un vers `/dockerinit` et `/sbxinit` ; pour un client dont `clientInfo.name` contient `zed`, une entrée `agent_servers` en plus. Les prompts suivants répondent que le fichier est écrit. |
285+| Codes de sortie | 0 le client a fermé la connexion, ou `-h` ; 1 une valeur d'option refusée ou des presets illisibles ; 2 une option inconnue. |
286+
270 ## `mm skillcreate` — l'assistant de skill287 ## `mm skillcreate` — l'assistant de skill
271 288
272 `mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md).289 `mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md).
modified docs/fr/reference/configuration.md +1 -1
@@ -70,7 +70,7 @@ Où `mm` cherche ce fichier : [Résolution du fichier de configuration](cli.md#r
7070
7171 | Clé | Type | Défaut | Description |
7272 |-----|------|--------|-------------|
73-| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
73+| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et, en ACP, `session/list` et un rechargement (`session/load`, `session/resume`) qui ramène la conversation. Désactivé, le rechargement d'un éditeur réussit quand même mais la conversation recommence. |
7474 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
7575 | `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). |
7676 | `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. |
@@ -70,7 +70,7 @@ Où `mm` cherche ce fichier : [Résolution du fichier de configuration](cli.md#r
70 70
71 | Clé | Type | Défaut | Description |71 | Clé | Type | Défaut | Description |
72 |-----|------|--------|-------------|72 |-----|------|--------|-------------|
73-| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |73+| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et, en ACP, `session/list` et un rechargement (`session/load`, `session/resume`) qui ramène la conversation. Désactivé, le rechargement d'un éditeur réussit quand même mais la conversation recommence. |
74 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |74 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
75 | `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). |75 | `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). |
76 | `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. |76 | `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. |
modified internal/acp/acp.go +20 -71
@@ -21,7 +21,6 @@ package acp
2121
2222 import (
2323 "context"
24- "encoding/json"
2524 "fmt"
2625 "io"
2726 "log/slog"
@@ -29,6 +28,7 @@ import (
2928 "strings"
3029 "sync"
3130
31+ "mm/internal/acpwire"
3232 "mm/internal/agentsmd"
3333 "mm/internal/ask"
3434 "mm/internal/command"
@@ -141,7 +141,7 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer,
141141 if config.Cfg.Sessions.Enabled {
142142 f.sessionsDir = config.Cfg.Sessions.Dir
143143 }
144- f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}
144+ f.out = acpwire.NewWriter(w)
145145 conn := sdk.NewAgentSideConnection(f, f.out, r)
146146 conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))
147147 f.conn = conn
@@ -154,67 +154,13 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer,
154154 // only ever build one at a time.
155155 var lastFront *front
156156
157-// responseWriter is the io.Writer handed to the SDK: the real output plus one
158-// hook per session, run once the session/new response carrying that session
159-// id has been written. It exists for available_commands_update. The spec says
160-// to announce the commands right after the session is created, and the client
161-// (Zed, verified 2026-09-15) drops any session/update for a session whose
162-// session/new response it has not received yet — "Available commands: none".
163-// But the SDK writes the response only when NewSession returns, after anything
164-// the method itself sent, and offers no "after the response" callback. Watching
165-// the bytes is the one place where the order is certain.
166-type responseWriter struct {
167- io.Writer
168- mu sync.Mutex
169- hooks map[sdk.SessionId]func()
170-}
171-
172-// afterResponse registers hook to run — once, in its own goroutine — after the
173-// response that carries sid has been written.
174-func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) {
175- w.mu.Lock()
176- defer w.mu.Unlock()
177- w.hooks[sid] = hook
178-}
157+// responseWriter is acpwire.Writer: the SDK's output plus the hooks run
158+// after a session/new response (see there). An alias, so the code and the
159+// tests here keep their names.
160+type responseWriter = acpwire.Writer
179161
180-// Write forwards to the real output, then fires the hook of the session whose
181-// response this line is, if any. The hook runs in a goroutine: the SDK holds
182-// its write lock here, and the hook is about to write a notification.
183-func (w *responseWriter) Write(p []byte) (int, error) {
184- // An elicitation's scope rides in _meta until the SDK can carry it
185- // (elicit.go); the caller is told the length it gave.
186- if _, err := w.Writer.Write(liftElicitationScope(p)); err != nil {
187- return 0, err
188- }
189- n := len(p)
190- if sid, ok := responseSessionID(p); ok {
191- w.mu.Lock()
192- hook := w.hooks[sid]
193- delete(w.hooks, sid)
194- w.mu.Unlock()
195- if hook != nil {
196- go hook()
197- }
198- }
199- return n, nil
200-}
201-
202-// responseSessionID recognises a JSON-RPC response whose result carries a
203-// sessionId — session/new (and session/load, which this agent does not
204-// offer). Notifications have no id and prompt responses no sessionId, so
205-// neither matches.
206-func responseSessionID(line []byte) (sdk.SessionId, bool) {
207- var m struct {
208- ID json.RawMessage `json:"id"`
209- Result struct {
210- SessionId sdk.SessionId `json:"sessionId"`
211- } `json:"result"`
212- }
213- if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" {
214- return "", false
215- }
216- return m.Result.SessionId, true
217-}
162+// responseSessionID is acpwire.ResponseSessionID (see there).
163+var responseSessionID = acpwire.ResponseSessionID
218164
219165 // --- initialize ---------------------------------------------------------------
220166
@@ -230,21 +176,24 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init
230176 return sdk.InitializeResponse{
231177 ProtocolVersion: sdk.ProtocolVersionNumber, // 1
232178 // Everything absent is false: no image/audio prompts, no MCP over
233- // http. loadSession, session/list and session/resume exist only
234- // when sessions are persisted — an editor's "reload" or history
235- // picker needs files to read. Honest capabilities are what lets a
236- // client adapt.
237- AgentCapabilities: sdk.AgentCapabilities{LoadSession: f.sessionsDir != "", SessionCapabilities: f.sessionCapabilities()},
179+ // http. session/list exists only when sessions are persisted — a
180+ // history picker needs files to read. loadSession and
181+ // session/resume are always there: an editor's "reload agent"
182+ // uses them, and without saved sessions the conversation starts
183+ // over and says so (persist.go, startOver; 2026-11-14, user's
184+ // choice — before, a reload simply failed).
185+ AgentCapabilities: sdk.AgentCapabilities{LoadSession: true, SessionCapabilities: f.sessionCapabilities()},
238186 AgentInfo: agentInfo(),
239187 AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into
240188 }, nil
241189 }
242190
243-// sessionCapabilities advertises session/list and session/resume when
244-// sessions are persisted, nothing otherwise.
191+// sessionCapabilities advertises session/resume always — without persisted
192+// sessions a reload starts the conversation over (persist.go, startOver) —
193+// and session/list only when there are files to list.
245194 func (f *front) sessionCapabilities() sdk.SessionCapabilities {
246195 if f.sessionsDir == "" {
247- return sdk.SessionCapabilities{}
196+ return sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}}
248197 }
249198 return sdk.SessionCapabilities{List: &sdk.SessionListCapabilities{}, Resume: &sdk.SessionResumeCapabilities{}}
250199 }
@@ -299,7 +248,7 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
299248 // carries and drops updates for an id it does not know yet. Hence the hook
300249 // on the writer rather than a SessionUpdate call here (see responseWriter).
301250 // The request context dies with this method; the hook needs one that lives.
302- f.out.afterResponse(sid, func() {
251+ f.out.AfterResponse(sid, func() {
303252 _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{
304253 SessionId: sid,
305254 Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
@@ -21,7 +21,6 @@ package acp
21 21
22 import (22 import (
23 "context"23 "context"
24- "encoding/json"
25 "fmt"24 "fmt"
26 "io"25 "io"
27 "log/slog"26 "log/slog"
@@ -29,6 +28,7 @@ import (
29 "strings"28 "strings"
30 "sync"29 "sync"
31 30
31+ "mm/internal/acpwire"
32 "mm/internal/agentsmd"32 "mm/internal/agentsmd"
33 "mm/internal/ask"33 "mm/internal/ask"
34 "mm/internal/command"34 "mm/internal/command"
@@ -141,7 +141,7 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer,
141 if config.Cfg.Sessions.Enabled {141 if config.Cfg.Sessions.Enabled {
142 f.sessionsDir = config.Cfg.Sessions.Dir142 f.sessionsDir = config.Cfg.Sessions.Dir
143 }143 }
144- f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}144+ f.out = acpwire.NewWriter(w)
145 conn := sdk.NewAgentSideConnection(f, f.out, r)145 conn := sdk.NewAgentSideConnection(f, f.out, r)
146 conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))146 conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))
147 f.conn = conn147 f.conn = conn
@@ -154,67 +154,13 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer,
154 // only ever build one at a time.154 // only ever build one at a time.
155 var lastFront *front155 var lastFront *front
156 156
157-// responseWriter is the io.Writer handed to the SDK: the real output plus one157+// responseWriter is acpwire.Writer: the SDK's output plus the hooks run
158-// hook per session, run once the session/new response carrying that session158+// after a session/new response (see there). An alias, so the code and the
159-// id has been written. It exists for available_commands_update. The spec says159+// tests here keep their names.
160-// to announce the commands right after the session is created, and the client160+type responseWriter = acpwire.Writer
161-// (Zed, verified 2026-09-15) drops any session/update for a session whose
162-// session/new response it has not received yet — "Available commands: none".
163-// But the SDK writes the response only when NewSession returns, after anything
164-// the method itself sent, and offers no "after the response" callback. Watching
165-// the bytes is the one place where the order is certain.
166-type responseWriter struct {
167- io.Writer
168- mu sync.Mutex
169- hooks map[sdk.SessionId]func()
170-}
171-
172-// afterResponse registers hook to run — once, in its own goroutine — after the
173-// response that carries sid has been written.
174-func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) {
175- w.mu.Lock()
176- defer w.mu.Unlock()
177- w.hooks[sid] = hook
178-}
179 161
180-// Write forwards to the real output, then fires the hook of the session whose162+// responseSessionID is acpwire.ResponseSessionID (see there).
181-// response this line is, if any. The hook runs in a goroutine: the SDK holds163+var responseSessionID = acpwire.ResponseSessionID
182-// its write lock here, and the hook is about to write a notification.
183-func (w *responseWriter) Write(p []byte) (int, error) {
184- // An elicitation's scope rides in _meta until the SDK can carry it
185- // (elicit.go); the caller is told the length it gave.
186- if _, err := w.Writer.Write(liftElicitationScope(p)); err != nil {
187- return 0, err
188- }
189- n := len(p)
190- if sid, ok := responseSessionID(p); ok {
191- w.mu.Lock()
192- hook := w.hooks[sid]
193- delete(w.hooks, sid)
194- w.mu.Unlock()
195- if hook != nil {
196- go hook()
197- }
198- }
199- return n, nil
200-}
201-
202-// responseSessionID recognises a JSON-RPC response whose result carries a
203-// sessionId — session/new (and session/load, which this agent does not
204-// offer). Notifications have no id and prompt responses no sessionId, so
205-// neither matches.
206-func responseSessionID(line []byte) (sdk.SessionId, bool) {
207- var m struct {
208- ID json.RawMessage `json:"id"`
209- Result struct {
210- SessionId sdk.SessionId `json:"sessionId"`
211- } `json:"result"`
212- }
213- if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" {
214- return "", false
215- }
216- return m.Result.SessionId, true
217-}
218 164
219 // --- initialize ---------------------------------------------------------------165 // --- initialize ---------------------------------------------------------------
220 166
@@ -230,21 +176,24 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init
230 return sdk.InitializeResponse{176 return sdk.InitializeResponse{
231 ProtocolVersion: sdk.ProtocolVersionNumber, // 1177 ProtocolVersion: sdk.ProtocolVersionNumber, // 1
232 // Everything absent is false: no image/audio prompts, no MCP over178 // Everything absent is false: no image/audio prompts, no MCP over
233- // http. loadSession, session/list and session/resume exist only179+ // http. session/list exists only when sessions are persisted — a
234- // when sessions are persisted — an editor's "reload" or history180+ // history picker needs files to read. loadSession and
235- // picker needs files to read. Honest capabilities are what lets a181+ // session/resume are always there: an editor's "reload agent"
236- // client adapt.182+ // uses them, and without saved sessions the conversation starts
237- AgentCapabilities: sdk.AgentCapabilities{LoadSession: f.sessionsDir != "", SessionCapabilities: f.sessionCapabilities()},183+ // over and says so (persist.go, startOver; 2026-11-14, user's
184+ // choice — before, a reload simply failed).
185+ AgentCapabilities: sdk.AgentCapabilities{LoadSession: true, SessionCapabilities: f.sessionCapabilities()},
238 AgentInfo: agentInfo(),186 AgentInfo: agentInfo(),
239 AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into187 AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into
240 }, nil188 }, nil
241 }189 }
242 190
243-// sessionCapabilities advertises session/list and session/resume when191+// sessionCapabilities advertises session/resume always — without persisted
244-// sessions are persisted, nothing otherwise.192+// sessions a reload starts the conversation over (persist.go, startOver) —
193+// and session/list only when there are files to list.
245 func (f *front) sessionCapabilities() sdk.SessionCapabilities {194 func (f *front) sessionCapabilities() sdk.SessionCapabilities {
246 if f.sessionsDir == "" {195 if f.sessionsDir == "" {
247- return sdk.SessionCapabilities{}196+ return sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}}
248 }197 }
249 return sdk.SessionCapabilities{List: &sdk.SessionListCapabilities{}, Resume: &sdk.SessionResumeCapabilities{}}198 return sdk.SessionCapabilities{List: &sdk.SessionListCapabilities{}, Resume: &sdk.SessionResumeCapabilities{}}
250 }199 }
@@ -299,7 +248,7 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
299 // carries and drops updates for an id it does not know yet. Hence the hook248 // carries and drops updates for an id it does not know yet. Hence the hook
300 // on the writer rather than a SessionUpdate call here (see responseWriter).249 // on the writer rather than a SessionUpdate call here (see responseWriter).
301 // The request context dies with this method; the hook needs one that lives.250 // The request context dies with this method; the hook needs one that lives.
302- f.out.afterResponse(sid, func() {251+ f.out.AfterResponse(sid, func() {
303 _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{252 _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{
304 SessionId: sid,253 SessionId: sid,
305 Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},254 Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
modified internal/acp/elicit.go +3 -39
@@ -1,11 +1,10 @@
11 package acp
22
33 import (
4- "bytes"
54 "context"
6- "encoding/json"
75 "fmt"
86
7+ "mm/internal/acpwire"
98 "mm/internal/elicit"
109 "mm/internal/ui"
1110
@@ -128,40 +127,5 @@ func (k *sink) elicitByPermission(ctx context.Context, ev ui.ToolEvent, f elicit
128127 return res
129128 }
130129
131-// --- the scope the SDK cannot carry ------------------------------------------------
132-
133-// liftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a
134-// session-scoped elicitation to its session (and tool call) with `sessionId`
135-// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's
136-// UnstableCreateElicitationForm has no such fields — only `_meta`. So
137-// elicitByForm parks the scope in `_meta` (elicit.ScopeMetaKey), and this
138-// copies it where the specification puts it as the line is written out. The
139-// `_meta` copy stays: an mm client relaying the question reads its tool call
140-// there, the SDK dropping the top-level fields on its side too. Any other
141-// line comes back untouched. Remove both when the SDK carries the fields.
142-func liftElicitationScope(line []byte) []byte {
143- if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) {
144- return line
145- }
146- var msg map[string]any
147- if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate {
148- return line
149- }
150- params, _ := msg["params"].(map[string]any)
151- meta, _ := params["_meta"].(map[string]any)
152- scope, ok := meta[elicit.ScopeMetaKey].(map[string]any)
153- if !ok {
154- return line
155- }
156- for key, value := range scope {
157- params[key] = value
158- }
159- out, err := json.Marshal(msg)
160- if err != nil {
161- return line
162- }
163- if bytes.HasSuffix(line, []byte("\n")) {
164- out = append(out, '\n')
165- }
166- return out
167-}
130+// liftElicitationScope is acpwire.LiftElicitationScope (see there).
131+var liftElicitationScope = acpwire.LiftElicitationScope
@@ -1,11 +1,10 @@
1 package acp1 package acp
2 2
3 import (3 import (
4- "bytes"
5 "context"4 "context"
6- "encoding/json"
7 "fmt"5 "fmt"
8 6
7+ "mm/internal/acpwire"
9 "mm/internal/elicit"8 "mm/internal/elicit"
10 "mm/internal/ui"9 "mm/internal/ui"
11 10
@@ -128,40 +127,5 @@ func (k *sink) elicitByPermission(ctx context.Context, ev ui.ToolEvent, f elicit
128 return res127 return res
129 }128 }
130 129
131-// --- the scope the SDK cannot carry ------------------------------------------------130+// liftElicitationScope is acpwire.LiftElicitationScope (see there).
132-131+var liftElicitationScope = acpwire.LiftElicitationScope
133-// liftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a
134-// session-scoped elicitation to its session (and tool call) with `sessionId`
135-// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's
136-// UnstableCreateElicitationForm has no such fields — only `_meta`. So
137-// elicitByForm parks the scope in `_meta` (elicit.ScopeMetaKey), and this
138-// copies it where the specification puts it as the line is written out. The
139-// `_meta` copy stays: an mm client relaying the question reads its tool call
140-// there, the SDK dropping the top-level fields on its side too. Any other
141-// line comes back untouched. Remove both when the SDK carries the fields.
142-func liftElicitationScope(line []byte) []byte {
143- if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) {
144- return line
145- }
146- var msg map[string]any
147- if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate {
148- return line
149- }
150- params, _ := msg["params"].(map[string]any)
151- meta, _ := params["_meta"].(map[string]any)
152- scope, ok := meta[elicit.ScopeMetaKey].(map[string]any)
153- if !ok {
154- return line
155- }
156- for key, value := range scope {
157- params[key] = value
158- }
159- out, err := json.Marshal(msg)
160- if err != nil {
161- return line
162- }
163- if bytes.HasSuffix(line, []byte("\n")) {
164- out = append(out, '\n')
165- }
166- return out
167-}
modified internal/acp/persist.go +27 -4
@@ -30,11 +30,12 @@ var _ sdk.AgentLoader = (*front)(nil)
3030 // session/update notifications before the response — the spec's contract,
3131 // and the editor accepts them because it asked for this id — then becomes
3232 // the session's history again. Nothing is executed again (see
33-// replayUpdates). Without persistence the method does not exist, as
34-// initialize said.
33+// replayUpdates). Without persistence there is nothing to read: the
34+// session starts over (startOver).
3535 func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) {
3636 if f.sessionsDir == "" {
37- return sdk.LoadSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionLoad)
37+ f.startOver(ctx, "session/load", p.SessionId, p.Cwd)
38+ return sdk.LoadSessionResponse{}, nil
3839 }
3940 s, err := f.restore("session/load", p.SessionId, p.Cwd)
4041 if err != nil {
@@ -55,7 +56,8 @@ func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.
5556 // would only duplicate it on its screen.
5657 func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
5758 if f.sessionsDir == "" {
58- return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume)
59+ f.startOver(ctx, "session/resume", p.SessionId, p.Cwd)
60+ return sdk.ResumeSessionResponse{}, nil
5961 }
6062 s, err := f.restore("session/resume", p.SessionId, p.Cwd)
6163 if err != nil {
@@ -67,6 +69,27 @@ func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (
6769 return sdk.ResumeSessionResponse{}, nil
6870 }
6971
72+// startOver is session/load and session/resume when sessions are not
73+// persisted: an editor's "reload agent" must not fail (it did, ticket 210
74+// follow-up, user's choice 2026-11-14), but there is no history to bring
75+// back. The id the editor asked for becomes a new, empty session in cwd,
76+// and one message says the conversation starts over — and how to keep the
77+// next one (sessions.enabled).
78+func (f *front) startOver(ctx context.Context, method string, id sdk.SessionId, cwd string) {
79+ s := &session{cwd: cwd, messages: history.Fresh(agentsmd.System(f.system, cwd))}
80+ s.clientTools = f.sessionClientTools(ctx, id, cwd)
81+ f.mu.Lock()
82+ f.sessions[id] = s
83+ f.mu.Unlock()
84+ fmt.Fprintf(os.Stderr, "[acp] %s %s cwd=%s: sessions are not saved, starting over\n", method, id, cwd)
85+ f.sendCommands(ctx, id)
86+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: id, Update: sdk.UpdateAgentMessageText(startOverMessage)})
87+}
88+
89+// startOverMessage is what the editor shows after a reload without saved
90+// sessions.
91+const startOverMessage = "mm restarted: this agent does not save its conversations, so the history above is gone and the conversation starts over. To keep it across a reload, set `sessions: {enabled: true}` in the agent file."
92+
7093 // restore reads the session id stored under cwd and registers it as live,
7194 // its history continuing under TODAY's system prompt: the file may predate a
7295 // change to agent.yaml, and the prompt is the agent's, not the
@@ -30,11 +30,12 @@ var _ sdk.AgentLoader = (*front)(nil)
30 // session/update notifications before the response — the spec's contract,30 // session/update notifications before the response — the spec's contract,
31 // and the editor accepts them because it asked for this id — then becomes31 // and the editor accepts them because it asked for this id — then becomes
32 // the session's history again. Nothing is executed again (see32 // the session's history again. Nothing is executed again (see
33-// replayUpdates). Without persistence the method does not exist, as33+// replayUpdates). Without persistence there is nothing to read: the
34-// initialize said.34+// session starts over (startOver).
35 func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) {35 func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) {
36 if f.sessionsDir == "" {36 if f.sessionsDir == "" {
37- return sdk.LoadSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionLoad)37+ f.startOver(ctx, "session/load", p.SessionId, p.Cwd)
38+ return sdk.LoadSessionResponse{}, nil
38 }39 }
39 s, err := f.restore("session/load", p.SessionId, p.Cwd)40 s, err := f.restore("session/load", p.SessionId, p.Cwd)
40 if err != nil {41 if err != nil {
@@ -55,7 +56,8 @@ func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.
55 // would only duplicate it on its screen.56 // would only duplicate it on its screen.
56 func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {57 func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
57 if f.sessionsDir == "" {58 if f.sessionsDir == "" {
58- return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume)59+ f.startOver(ctx, "session/resume", p.SessionId, p.Cwd)
60+ return sdk.ResumeSessionResponse{}, nil
59 }61 }
60 s, err := f.restore("session/resume", p.SessionId, p.Cwd)62 s, err := f.restore("session/resume", p.SessionId, p.Cwd)
61 if err != nil {63 if err != nil {
@@ -67,6 +69,27 @@ func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (
67 return sdk.ResumeSessionResponse{}, nil69 return sdk.ResumeSessionResponse{}, nil
68 }70 }
69 71
72+// startOver is session/load and session/resume when sessions are not
73+// persisted: an editor's "reload agent" must not fail (it did, ticket 210
74+// follow-up, user's choice 2026-11-14), but there is no history to bring
75+// back. The id the editor asked for becomes a new, empty session in cwd,
76+// and one message says the conversation starts over — and how to keep the
77+// next one (sessions.enabled).
78+func (f *front) startOver(ctx context.Context, method string, id sdk.SessionId, cwd string) {
79+ s := &session{cwd: cwd, messages: history.Fresh(agentsmd.System(f.system, cwd))}
80+ s.clientTools = f.sessionClientTools(ctx, id, cwd)
81+ f.mu.Lock()
82+ f.sessions[id] = s
83+ f.mu.Unlock()
84+ fmt.Fprintf(os.Stderr, "[acp] %s %s cwd=%s: sessions are not saved, starting over\n", method, id, cwd)
85+ f.sendCommands(ctx, id)
86+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: id, Update: sdk.UpdateAgentMessageText(startOverMessage)})
87+}
88+
89+// startOverMessage is what the editor shows after a reload without saved
90+// sessions.
91+const startOverMessage = "mm restarted: this agent does not save its conversations, so the history above is gone and the conversation starts over. To keep it across a reload, set `sessions: {enabled: true}` in the agent file."
92+
70 // restore reads the session id stored under cwd and registers it as live,93 // restore reads the session id stored under cwd and registers it as live,
71 // its history continuing under TODAY's system prompt: the file may predate a94 // its history continuing under TODAY's system prompt: the file may predate a
72 // change to agent.yaml, and the prompt is the agent's, not the95 // change to agent.yaml, and the prompt is the agent's, not the
modified internal/acp/persist_test.go +37 -7
@@ -160,18 +160,48 @@ func frontOf(t *testing.T, _ *wire) *front {
160160 }
161161
162162 // Unknown id, or persistence off: two different refusals, both explicit.
163-func TestLoadSessionRefusals(t *testing.T) {
163+// Without saved sessions, a reload (session/load or session/resume) does
164+// not fail: the id the editor asked for becomes a new, empty session, the
165+// commands are announced and one message says the conversation starts over.
166+// initialize offers both, but not session/list: there is nothing to list.
167+func TestReloadWithoutSessionsStartsOver(t *testing.T) {
164168 w := newWire(t, "sys")
165- w.send(1, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)
166- _, resp := w.until(1)
167- if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {
168- t.Errorf("persistence off: %v, want method not found (-32601)", resp["error"])
169+ w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`)
170+ _, init := w.until(1)
171+ caps := init["result"].(map[string]any)["agentCapabilities"].(map[string]any)
172+ session, _ := caps["sessionCapabilities"].(map[string]any)
173+ if caps["loadSession"] != true || session == nil || session["resume"] == nil || session["list"] != nil {
174+ t.Errorf("agentCapabilities = %v, want loadSession and resume, no list", caps)
175+ }
176+ for i, method := range []string{"session/load", "session/resume"} {
177+ id := 2 + i
178+ cwd := t.TempDir()
179+ w.send(id, method, `{"sessionId":"old-thread","cwd":"`+cwd+`","mcpServers":[]}`)
180+ notes, resp := w.until(id)
181+ if resp["error"] != nil {
182+ t.Fatalf("%s: %v", method, resp["error"])
183+ }
184+ raw, _ := json.Marshal(notes)
185+ text := string(raw)
186+ if !strings.Contains(text, "available_commands_update") || !strings.Contains(text, "starts over") {
187+ t.Errorf("%s: notifications:\n%s", method, text)
188+ }
189+ if _, err := os.Stat(filepath.Join(cwd, ".mm")); err == nil {
190+ t.Errorf("%s: a file was written with persistence off", method)
191+ }
192+ if lastFront.sessions["old-thread"] == nil {
193+ t.Errorf("%s: the session is not live under the requested id", method)
194+ }
169195 }
196+}
170197
198+// With saved sessions, an unknown id is refused: there the editor expects its
199+// history, and silently starting over would hide a missing file.
200+func TestLoadSessionRefusals(t *testing.T) {
171201 withSessions(t)
172- w = newWire(t, "sys")
202+ w := newWire(t, "sys")
173203 w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)
174- _, resp = w.until(2)
204+ _, resp := w.until(2)
175205 if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 {
176206 t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"])
177207 }
@@ -160,18 +160,48 @@ func frontOf(t *testing.T, _ *wire) *front {
160 }160 }
161 161
162 // Unknown id, or persistence off: two different refusals, both explicit.162 // Unknown id, or persistence off: two different refusals, both explicit.
163-func TestLoadSessionRefusals(t *testing.T) {163+// Without saved sessions, a reload (session/load or session/resume) does
164+// not fail: the id the editor asked for becomes a new, empty session, the
165+// commands are announced and one message says the conversation starts over.
166+// initialize offers both, but not session/list: there is nothing to list.
167+func TestReloadWithoutSessionsStartsOver(t *testing.T) {
164 w := newWire(t, "sys")168 w := newWire(t, "sys")
165- w.send(1, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)169+ w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`)
166- _, resp := w.until(1)170+ _, init := w.until(1)
167- if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {171+ caps := init["result"].(map[string]any)["agentCapabilities"].(map[string]any)
168- t.Errorf("persistence off: %v, want method not found (-32601)", resp["error"])172+ session, _ := caps["sessionCapabilities"].(map[string]any)
173+ if caps["loadSession"] != true || session == nil || session["resume"] == nil || session["list"] != nil {
174+ t.Errorf("agentCapabilities = %v, want loadSession and resume, no list", caps)
175+ }
176+ for i, method := range []string{"session/load", "session/resume"} {
177+ id := 2 + i
178+ cwd := t.TempDir()
179+ w.send(id, method, `{"sessionId":"old-thread","cwd":"`+cwd+`","mcpServers":[]}`)
180+ notes, resp := w.until(id)
181+ if resp["error"] != nil {
182+ t.Fatalf("%s: %v", method, resp["error"])
183+ }
184+ raw, _ := json.Marshal(notes)
185+ text := string(raw)
186+ if !strings.Contains(text, "available_commands_update") || !strings.Contains(text, "starts over") {
187+ t.Errorf("%s: notifications:\n%s", method, text)
188+ }
189+ if _, err := os.Stat(filepath.Join(cwd, ".mm")); err == nil {
190+ t.Errorf("%s: a file was written with persistence off", method)
191+ }
192+ if lastFront.sessions["old-thread"] == nil {
193+ t.Errorf("%s: the session is not live under the requested id", method)
194+ }
169 }195 }
196+}
170 197
198+// With saved sessions, an unknown id is refused: there the editor expects its
199+// history, and silently starting over would hide a missing file.
200+func TestLoadSessionRefusals(t *testing.T) {
171 withSessions(t)201 withSessions(t)
172- w = newWire(t, "sys")202+ w := newWire(t, "sys")
173 w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)203 w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)
174- _, resp = w.until(2)204+ _, resp := w.until(2)
175 if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 {205 if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 {
176 t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"])206 t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"])
177 }207 }
modified internal/acp/sessionlist_test.go +3 -6
@@ -109,7 +109,9 @@ func TestResumeSessionRestoresWithoutReplay(t *testing.T) {
109109 }
110110 }
111111
112-// Persistence off: list and resume do not exist. Unknown id: invalid params.
112+// Persistence off: list does not exist (resume starts over: see
113+// TestReloadWithoutSessionsStartsOver). Unknown id with persistence on:
114+// invalid params.
113115 func TestListAndResumeRefusals(t *testing.T) {
114116 w := newWire(t, "sys")
115117 w.send(1, "session/list", `{}`)
@@ -117,11 +119,6 @@ func TestListAndResumeRefusals(t *testing.T) {
117119 if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {
118120 t.Errorf("list, persistence off: %v, want method not found", resp["error"])
119121 }
120- w.send(2, "session/resume", `{"sessionId":"nope","cwd":"`+t.TempDir()+`"}`)
121- _, resp = w.until(2)
122- if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {
123- t.Errorf("resume, persistence off: %v, want method not found", resp["error"])
124- }
125122
126123 withSessions(t)
127124 w = newWire(t, "sys")
@@ -109,7 +109,9 @@ func TestResumeSessionRestoresWithoutReplay(t *testing.T) {
109 }109 }
110 }110 }
111 111
112-// Persistence off: list and resume do not exist. Unknown id: invalid params.112+// Persistence off: list does not exist (resume starts over: see
113+// TestReloadWithoutSessionsStartsOver). Unknown id with persistence on:
114+// invalid params.
113 func TestListAndResumeRefusals(t *testing.T) {115 func TestListAndResumeRefusals(t *testing.T) {
114 w := newWire(t, "sys")116 w := newWire(t, "sys")
115 w.send(1, "session/list", `{}`)117 w.send(1, "session/list", `{}`)
@@ -117,11 +119,6 @@ func TestListAndResumeRefusals(t *testing.T) {
117 if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {119 if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {
118 t.Errorf("list, persistence off: %v, want method not found", resp["error"])120 t.Errorf("list, persistence off: %v, want method not found", resp["error"])
119 }121 }
120- w.send(2, "session/resume", `{"sessionId":"nope","cwd":"`+t.TempDir()+`"}`)
121- _, resp = w.until(2)
122- if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {
123- t.Errorf("resume, persistence off: %v, want method not found", resp["error"])
124- }
125 122
126 withSessions(t)123 withSessions(t)
127 w = newWire(t, "sys")124 w = newWire(t, "sys")
added internal/acpcreate/acpcreate.go +241 -0
new file mode 100644
@@ -0,0 +1,241 @@
1+// Package acpcreate is `mm acp-create` (and `mm --acp-create`, ticket 210):
2+// mm init's wizard served over the Agent Client Protocol, so that an ACP
3+// client — Zed, JetBrains, a Neovim plugin, any other — can create an
4+// agent.yaml without a terminal.
5+//
6+// It is not an LLM agent: no configuration is read and no model is called
7+// (the wizard does ask the model servers for their model lists, as mm init
8+// does). Each session drives one agentinit.Flow — the same state the line
9+// mode and the window drive — and writes the file in the session's cwd.
10+//
11+// How the questions are asked depends on what the client ADVERTISES at
12+// initialize, never on its name (ask.go):
13+//
14+// - clientCapabilities.elicitation.form: the questions go as
15+// elicitation/create forms, several independent questions per form;
16+// - otherwise, the conversation: each question is a chat message, the
17+// answer the next prompt.
18+//
19+// stdout carries JSON-RPC only; the [acp-create] trail goes to stderr.
20+package acpcreate
21+
22+import (
23+ "context"
24+ "crypto/rand"
25+ "encoding/hex"
26+ "fmt"
27+ "io"
28+ "log/slog"
29+ "strings"
30+ "sync"
31+
32+ "mm/internal/acpwire"
33+ "mm/internal/agentinit"
34+ "mm/internal/version"
35+
36+ sdk "github.com/coder/acp-go-sdk"
37+)
38+
39+// Main is `mm acp-create [mm init's flags]`: it serves the wizard on stdin
40+// and stdout until the client closes the connection. Flags work as for mm
41+// init — a given answer is never asked — except --yes, --line and --check,
42+// which have no meaning here. Help and errors go to stderr: stdout is the
43+// protocol's.
44+func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
45+ a, code, ok := agentinit.ParseArgs("mm acp-create", args, stderr, false)
46+ if !ok {
47+ return code
48+ }
49+ // A bad flag fails now, in the client's log, rather than at each session.
50+ if _, err := a.Flow(context.Background()); err != nil {
51+ fmt.Fprintln(stderr, "⚠️ "+err.Error())
52+ return 1
53+ }
54+ ag := newAgent(a, stdout, stdin, stderr)
55+ <-ag.conn.Done()
56+ return 0
57+}
58+
59+// agent is the wizard's side of one ACP connection.
60+type agent struct {
61+ args agentinit.Args
62+ conn *sdk.AgentSideConnection
63+ out *acpwire.Writer
64+ log io.Writer
65+
66+ // elicitForm: the client advertised elicitation's form mode.
67+ elicitForm bool
68+ // client is the client's name from initialize, for the closing
69+ // message's configuration snippet only — never to choose a behaviour.
70+ client string
71+
72+ mu sync.Mutex
73+ sessions map[sdk.SessionId]*session
74+}
75+
76+var (
77+ _ sdk.Agent = (*agent)(nil)
78+ _ sdk.AgentLoader = (*agent)(nil)
79+)
80+
81+// newAgent wires an agent on a pair of streams: stdio in Main, pipes in the
82+// tests. w is where the agent writes, r where it reads the client.
83+func newAgent(a agentinit.Args, w io.Writer, r io.Reader, log io.Writer) *agent {
84+ ag := &agent{args: a, log: log, sessions: map[sdk.SessionId]*session{}}
85+ ag.out = acpwire.NewWriter(w)
86+ ag.conn = sdk.NewAgentSideConnection(ag, ag.out, r)
87+ ag.conn.SetLogger(slog.New(slog.NewTextHandler(log, nil)))
88+ return ag
89+}
90+
91+func (ag *agent) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) {
92+ // Form support exists only when `form` is present: ACP requires each
93+ // mode to be explicit.
94+ if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil {
95+ ag.elicitForm = true
96+ }
97+ if p.ClientInfo != nil {
98+ ag.client = p.ClientInfo.Name
99+ }
100+ fmt.Fprintf(ag.log, "[acp-create] initialize: client %q elicitation(form)=%v\n", ag.client, ag.elicitForm)
101+ return sdk.InitializeResponse{
102+ ProtocolVersion: sdk.ProtocolVersionNumber,
103+ // loadSession and session/resume, so that a client's "reload agent"
104+ // works (it restarts mm, then asks for its thread back). Nothing is
105+ // kept across a restart: the wizard starts over (restart.go).
106+ AgentCapabilities: sdk.AgentCapabilities{
107+ LoadSession: true,
108+ SessionCapabilities: sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}},
109+ },
110+ AgentInfo: &sdk.Implementation{Name: "mm-acp-create", Title: sdk.Ptr("mm init (agent.yaml wizard)"), Version: version.Short()},
111+ AuthMethods: []sdk.AuthMethod{},
112+ }, nil
113+}
114+
115+func (ag *agent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) {
116+ return sdk.AuthenticateResponse{}, nil
117+}
118+
119+// NewSession starts a flow whose files land in the session's cwd — the
120+// project the client opened.
121+func (ag *agent) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
122+ s, err := ag.newSession(p.Cwd)
123+ if err != nil {
124+ return sdk.NewSessionResponse{}, err
125+ }
126+ sid := newID()
127+ ag.mu.Lock()
128+ ag.sessions[sid] = s
129+ ag.mu.Unlock()
130+ fmt.Fprintf(ag.log, "[acp-create] session %s cwd=%s\n", sid, p.Cwd)
131+ // The commands go right after the response: a client drops updates for
132+ // a session id it has not received yet (see acpwire.Writer).
133+ ag.out.AfterResponse(sid, func() {
134+ _ = ag.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{
135+ SessionId: sid,
136+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
137+ })
138+ // Not every client shows a message outside a turn; /start, first
139+ // in the command list, is the way in for those.
140+ ag.say(context.WithoutCancel(ctx), sid, welcome(p.Cwd))
141+ })
142+ return sdk.NewSessionResponse{SessionId: sid}, nil
143+}
144+
145+// newSession builds a session on a fresh flow, rooted at cwd.
146+func (ag *agent) newSession(cwd string) (*session, error) {
147+ flow, err := ag.args.Flow(context.Background())
148+ if err != nil {
149+ return nil, err
150+ }
151+ flow.SetDir(cwd)
152+ return &session{cwd: cwd, flow: flow}, nil
153+}
154+
155+// Prompt is one message from the user: an answer, a command, or — the
156+// first time — the start of the wizard.
157+func (ag *agent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
158+ ag.mu.Lock()
159+ s, ok := ag.sessions[p.SessionId]
160+ ag.mu.Unlock()
161+ if !ok {
162+ return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId})
163+ }
164+ s.mu.Lock()
165+ defer s.mu.Unlock()
166+ turnCtx, cancel := context.WithCancel(ctx)
167+ s.setCancel(cancel)
168+ defer func() {
169+ s.setCancel(nil)
170+ cancel()
171+ }()
172+ t := &turn{ag: ag, s: s, sid: p.SessionId, ctx: turnCtx}
173+ t.handle(textOf(p.Prompt))
174+ if turnCtx.Err() != nil {
175+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
176+ }
177+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
178+}
179+
180+// Cancel stops the turn in flight: a pending form falls with it.
181+func (ag *agent) Cancel(_ context.Context, p sdk.CancelNotification) error {
182+ ag.mu.Lock()
183+ s := ag.sessions[p.SessionId]
184+ ag.mu.Unlock()
185+ if s != nil {
186+ s.cancelTurn()
187+ }
188+ return nil
189+}
190+
191+func (ag *agent) CloseSession(_ context.Context, p sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) {
192+ ag.mu.Lock()
193+ delete(ag.sessions, p.SessionId)
194+ ag.mu.Unlock()
195+ return sdk.CloseSessionResponse{}, nil
196+}
197+
198+func (ag *agent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
199+ return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList)
200+}
201+
202+func (ag *agent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) {
203+ return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode)
204+}
205+
206+func (ag *agent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) {
207+ return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption)
208+}
209+
210+func (ag *agent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) {
211+ return sdk.LogoutResponse{}, nil
212+}
213+
214+// say sends one agent message to the session.
215+func (ag *agent) say(ctx context.Context, sid sdk.SessionId, markdown string) {
216+ _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(markdown)})
217+}
218+
219+// textOf keeps the text of a prompt; attachments mean nothing here.
220+func textOf(blocks []sdk.ContentBlock) string {
221+ var parts []string
222+ for _, b := range blocks {
223+ if b.Text != nil {
224+ parts = append(parts, b.Text.Text)
225+ }
226+ }
227+ return strings.Join(parts, "\n")
228+}
229+
230+// newID is a session id: random, unique enough for one connection.
231+func newID() sdk.SessionId {
232+ b := make([]byte, 8)
233+ _, _ = rand.Read(b)
234+ return sdk.SessionId("create-" + hex.EncodeToString(b))
235+}
236+
237+// Is reports whether mm's first argument asks for this mode: `mm
238+// acp-create`, `mm --acp-create` or `mm -acp-create`.
239+func Is(arg string) bool {
240+ return arg == "acp-create" || arg == "--acp-create" || arg == "-acp-create"
241+}
new file mode 100644
@@ -0,0 +1,241 @@
1+// Package acpcreate is `mm acp-create` (and `mm --acp-create`, ticket 210):
2+// mm init's wizard served over the Agent Client Protocol, so that an ACP
3+// client — Zed, JetBrains, a Neovim plugin, any other — can create an
4+// agent.yaml without a terminal.
5+//
6+// It is not an LLM agent: no configuration is read and no model is called
7+// (the wizard does ask the model servers for their model lists, as mm init
8+// does). Each session drives one agentinit.Flow — the same state the line
9+// mode and the window drive — and writes the file in the session's cwd.
10+//
11+// How the questions are asked depends on what the client ADVERTISES at
12+// initialize, never on its name (ask.go):
13+//
14+// - clientCapabilities.elicitation.form: the questions go as
15+// elicitation/create forms, several independent questions per form;
16+// - otherwise, the conversation: each question is a chat message, the
17+// answer the next prompt.
18+//
19+// stdout carries JSON-RPC only; the [acp-create] trail goes to stderr.
20+package acpcreate
21+
22+import (
23+ "context"
24+ "crypto/rand"
25+ "encoding/hex"
26+ "fmt"
27+ "io"
28+ "log/slog"
29+ "strings"
30+ "sync"
31+
32+ "mm/internal/acpwire"
33+ "mm/internal/agentinit"
34+ "mm/internal/version"
35+
36+ sdk "github.com/coder/acp-go-sdk"
37+)
38+
39+// Main is `mm acp-create [mm init's flags]`: it serves the wizard on stdin
40+// and stdout until the client closes the connection. Flags work as for mm
41+// init — a given answer is never asked — except --yes, --line and --check,
42+// which have no meaning here. Help and errors go to stderr: stdout is the
43+// protocol's.
44+func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
45+ a, code, ok := agentinit.ParseArgs("mm acp-create", args, stderr, false)
46+ if !ok {
47+ return code
48+ }
49+ // A bad flag fails now, in the client's log, rather than at each session.
50+ if _, err := a.Flow(context.Background()); err != nil {
51+ fmt.Fprintln(stderr, "⚠️ "+err.Error())
52+ return 1
53+ }
54+ ag := newAgent(a, stdout, stdin, stderr)
55+ <-ag.conn.Done()
56+ return 0
57+}
58+
59+// agent is the wizard's side of one ACP connection.
60+type agent struct {
61+ args agentinit.Args
62+ conn *sdk.AgentSideConnection
63+ out *acpwire.Writer
64+ log io.Writer
65+
66+ // elicitForm: the client advertised elicitation's form mode.
67+ elicitForm bool
68+ // client is the client's name from initialize, for the closing
69+ // message's configuration snippet only — never to choose a behaviour.
70+ client string
71+
72+ mu sync.Mutex
73+ sessions map[sdk.SessionId]*session
74+}
75+
76+var (
77+ _ sdk.Agent = (*agent)(nil)
78+ _ sdk.AgentLoader = (*agent)(nil)
79+)
80+
81+// newAgent wires an agent on a pair of streams: stdio in Main, pipes in the
82+// tests. w is where the agent writes, r where it reads the client.
83+func newAgent(a agentinit.Args, w io.Writer, r io.Reader, log io.Writer) *agent {
84+ ag := &agent{args: a, log: log, sessions: map[sdk.SessionId]*session{}}
85+ ag.out = acpwire.NewWriter(w)
86+ ag.conn = sdk.NewAgentSideConnection(ag, ag.out, r)
87+ ag.conn.SetLogger(slog.New(slog.NewTextHandler(log, nil)))
88+ return ag
89+}
90+
91+func (ag *agent) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) {
92+ // Form support exists only when `form` is present: ACP requires each
93+ // mode to be explicit.
94+ if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil {
95+ ag.elicitForm = true
96+ }
97+ if p.ClientInfo != nil {
98+ ag.client = p.ClientInfo.Name
99+ }
100+ fmt.Fprintf(ag.log, "[acp-create] initialize: client %q elicitation(form)=%v\n", ag.client, ag.elicitForm)
101+ return sdk.InitializeResponse{
102+ ProtocolVersion: sdk.ProtocolVersionNumber,
103+ // loadSession and session/resume, so that a client's "reload agent"
104+ // works (it restarts mm, then asks for its thread back). Nothing is
105+ // kept across a restart: the wizard starts over (restart.go).
106+ AgentCapabilities: sdk.AgentCapabilities{
107+ LoadSession: true,
108+ SessionCapabilities: sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}},
109+ },
110+ AgentInfo: &sdk.Implementation{Name: "mm-acp-create", Title: sdk.Ptr("mm init (agent.yaml wizard)"), Version: version.Short()},
111+ AuthMethods: []sdk.AuthMethod{},
112+ }, nil
113+}
114+
115+func (ag *agent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) {
116+ return sdk.AuthenticateResponse{}, nil
117+}
118+
119+// NewSession starts a flow whose files land in the session's cwd — the
120+// project the client opened.
121+func (ag *agent) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
122+ s, err := ag.newSession(p.Cwd)
123+ if err != nil {
124+ return sdk.NewSessionResponse{}, err
125+ }
126+ sid := newID()
127+ ag.mu.Lock()
128+ ag.sessions[sid] = s
129+ ag.mu.Unlock()
130+ fmt.Fprintf(ag.log, "[acp-create] session %s cwd=%s\n", sid, p.Cwd)
131+ // The commands go right after the response: a client drops updates for
132+ // a session id it has not received yet (see acpwire.Writer).
133+ ag.out.AfterResponse(sid, func() {
134+ _ = ag.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{
135+ SessionId: sid,
136+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
137+ })
138+ // Not every client shows a message outside a turn; /start, first
139+ // in the command list, is the way in for those.
140+ ag.say(context.WithoutCancel(ctx), sid, welcome(p.Cwd))
141+ })
142+ return sdk.NewSessionResponse{SessionId: sid}, nil
143+}
144+
145+// newSession builds a session on a fresh flow, rooted at cwd.
146+func (ag *agent) newSession(cwd string) (*session, error) {
147+ flow, err := ag.args.Flow(context.Background())
148+ if err != nil {
149+ return nil, err
150+ }
151+ flow.SetDir(cwd)
152+ return &session{cwd: cwd, flow: flow}, nil
153+}
154+
155+// Prompt is one message from the user: an answer, a command, or — the
156+// first time — the start of the wizard.
157+func (ag *agent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
158+ ag.mu.Lock()
159+ s, ok := ag.sessions[p.SessionId]
160+ ag.mu.Unlock()
161+ if !ok {
162+ return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId})
163+ }
164+ s.mu.Lock()
165+ defer s.mu.Unlock()
166+ turnCtx, cancel := context.WithCancel(ctx)
167+ s.setCancel(cancel)
168+ defer func() {
169+ s.setCancel(nil)
170+ cancel()
171+ }()
172+ t := &turn{ag: ag, s: s, sid: p.SessionId, ctx: turnCtx}
173+ t.handle(textOf(p.Prompt))
174+ if turnCtx.Err() != nil {
175+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
176+ }
177+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
178+}
179+
180+// Cancel stops the turn in flight: a pending form falls with it.
181+func (ag *agent) Cancel(_ context.Context, p sdk.CancelNotification) error {
182+ ag.mu.Lock()
183+ s := ag.sessions[p.SessionId]
184+ ag.mu.Unlock()
185+ if s != nil {
186+ s.cancelTurn()
187+ }
188+ return nil
189+}
190+
191+func (ag *agent) CloseSession(_ context.Context, p sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) {
192+ ag.mu.Lock()
193+ delete(ag.sessions, p.SessionId)
194+ ag.mu.Unlock()
195+ return sdk.CloseSessionResponse{}, nil
196+}
197+
198+func (ag *agent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
199+ return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList)
200+}
201+
202+func (ag *agent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) {
203+ return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode)
204+}
205+
206+func (ag *agent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) {
207+ return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption)
208+}
209+
210+func (ag *agent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) {
211+ return sdk.LogoutResponse{}, nil
212+}
213+
214+// say sends one agent message to the session.
215+func (ag *agent) say(ctx context.Context, sid sdk.SessionId, markdown string) {
216+ _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(markdown)})
217+}
218+
219+// textOf keeps the text of a prompt; attachments mean nothing here.
220+func textOf(blocks []sdk.ContentBlock) string {
221+ var parts []string
222+ for _, b := range blocks {
223+ if b.Text != nil {
224+ parts = append(parts, b.Text.Text)
225+ }
226+ }
227+ return strings.Join(parts, "\n")
228+}
229+
230+// newID is a session id: random, unique enough for one connection.
231+func newID() sdk.SessionId {
232+ b := make([]byte, 8)
233+ _, _ = rand.Read(b)
234+ return sdk.SessionId("create-" + hex.EncodeToString(b))
235+}
236+
237+// Is reports whether mm's first argument asks for this mode: `mm
238+// acp-create`, `mm --acp-create` or `mm -acp-create`.
239+func Is(arg string) bool {
240+ return arg == "acp-create" || arg == "--acp-create" || arg == "-acp-create"
241+}
added internal/acpcreate/acpcreate_test.go +581 -0
new file mode 100644
@@ -0,0 +1,581 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "log/slog"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "sort"
14+ "strings"
15+ "sync"
16+ "testing"
17+ "time"
18+
19+ "mm/internal/agentinit"
20+ "mm/internal/config"
21+ "mm/internal/sbxinit"
22+
23+ sdk "github.com/coder/acp-go-sdk"
24+)
25+
26+// llmmanStub is an llmman server with one model, as mm init's tests use.
27+func llmmanStub(t *testing.T) string {
28+ t.Helper()
29+ routes := map[string]any{
30+ "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}}},
31+ "POST /api/show": map[string]any{"model_info": map[string]any{"general.architecture": "qwen35", "qwen35.context_length": 32768}, "template": "{{ enable_thinking | default(false) }}"},
32+ }
33+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34+ body, ok := routes[r.Method+" "+r.URL.Path]
35+ if !ok {
36+ http.NotFound(w, r)
37+ return
38+ }
39+ _ = json.NewEncoder(w).Encode(body)
40+ }))
41+ t.Cleanup(s.Close)
42+ return s.URL + "/v1"
43+}
44+
45+func keepConfig(t *testing.T) {
46+ saved := config.Cfg
47+ t.Cleanup(func() { config.Cfg = saved })
48+}
49+
50+// fakeClient plays the ACP client: it keeps the agent's messages and, when
51+// forms is set, answers each form with answer(key, property).
52+type fakeClient struct {
53+ mu sync.Mutex
54+ messages []string
55+ commands []sdk.AvailableCommand
56+ forms [][]string // the property names of each form, in order
57+ answer func(key string, property map[string]any) any
58+ decline bool
59+}
60+
61+func (c *fakeClient) SessionUpdate(_ context.Context, n sdk.SessionNotification) error {
62+ c.mu.Lock()
63+ defer c.mu.Unlock()
64+ if m := n.Update.AgentMessageChunk; m != nil && m.Content.Text != nil {
65+ c.messages = append(c.messages, m.Content.Text.Text)
66+ }
67+ if a := n.Update.AvailableCommandsUpdate; a != nil {
68+ c.commands = a.AvailableCommands
69+ }
70+ return nil
71+}
72+
73+func (c *fakeClient) UnstableCreateElicitation(_ context.Context, p sdk.UnstableCreateElicitationRequest) (sdk.UnstableCreateElicitationResponse, error) {
74+ c.mu.Lock()
75+ defer c.mu.Unlock()
76+ props := p.Form.RequestedSchema.Properties
77+ var names []string
78+ for name := range props {
79+ names = append(names, name)
80+ }
81+ sort.Strings(names)
82+ c.forms = append(c.forms, names)
83+ if c.decline {
84+ return sdk.UnstableCreateElicitationResponse{Decline: &sdk.UnstableCreateElicitationDecline{Action: "decline"}}, nil
85+ }
86+ content := map[string]any{}
87+ for _, name := range names {
88+ prop, _ := props[name].(map[string]any)
89+ content[name] = c.answer(name, prop)
90+ }
91+ return sdk.UnstableCreateElicitationResponse{Accept: &sdk.UnstableCreateElicitationAccept{Action: "accept", Content: content}}, nil
92+}
93+
94+func (c *fakeClient) RequestPermission(context.Context, sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {
95+ return sdk.RequestPermissionResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodSessionRequestPermission)
96+}
97+func (c *fakeClient) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) {
98+ return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile)
99+}
100+func (c *fakeClient) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) {
101+ return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile)
102+}
103+func (c *fakeClient) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) {
104+ return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate)
105+}
106+func (c *fakeClient) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) {
107+ return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill)
108+}
109+func (c *fakeClient) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) {
110+ return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput)
111+}
112+func (c *fakeClient) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) {
113+ return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease)
114+}
115+func (c *fakeClient) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) {
116+ return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit)
117+}
118+
119+// last is the agent's last message.
120+func (c *fakeClient) last() string {
121+ c.mu.Lock()
122+ defer c.mu.Unlock()
123+ if len(c.messages) == 0 {
124+ return ""
125+ }
126+ return c.messages[len(c.messages)-1]
127+}
128+
129+func (c *fakeClient) all() string {
130+ c.mu.Lock()
131+ defer c.mu.Unlock()
132+ return strings.Join(c.messages, "\n---\n")
133+}
134+
135+// harness is an agent and a client connected by pipes, one session open
136+// in cwd.
137+type harness struct {
138+ t *testing.T
139+ client *fakeClient
140+ conn *sdk.ClientSideConnection
141+ sid sdk.SessionId
142+ cwd string
143+}
144+
145+func start(t *testing.T, args []string, client *fakeClient, name string, forms bool) *harness {
146+ t.Helper()
147+ keepConfig(t)
148+ a, code, ok := agentinit.ParseArgs("mm acp-create", args, io.Discard, false)
149+ if !ok {
150+ t.Fatalf("flags refused (%d): %v", code, args)
151+ }
152+ c2aR, c2aW := io.Pipe()
153+ a2cR, a2cW := io.Pipe()
154+ t.Cleanup(func() { _ = c2aW.Close(); _ = a2cW.Close() })
155+ newAgent(a, a2cW, c2aR, io.Discard)
156+ conn := sdk.NewClientSideConnection(client, c2aW, a2cR)
157+ conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
158+ ctx := context.Background()
159+ caps := sdk.ClientCapabilities{}
160+ if forms {
161+ caps.Elicitation = &sdk.ElicitationCapabilities{Form: &sdk.ElicitationFormCapabilities{}}
162+ }
163+ if _, err := conn.Initialize(ctx, sdk.InitializeRequest{ProtocolVersion: sdk.ProtocolVersionNumber, ClientCapabilities: caps, ClientInfo: &sdk.Implementation{Name: name, Version: "1"}}); err != nil {
164+ t.Fatal(err)
165+ }
166+ cwd := t.TempDir()
167+ resp, err := conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}})
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ // The welcome comes after the session/new response, in a goroutine:
172+ // wait for it, so that it is never taken for the answer to a prompt.
173+ for deadline := time.Now().Add(5 * time.Second); !strings.Contains(client.all(), "Send `/start`"); time.Sleep(5 * time.Millisecond) {
174+ if time.Now().After(deadline) {
175+ t.Fatal("no welcome message after session/new")
176+ }
177+ }
178+ return &harness{t: t, client: client, conn: conn, sid: resp.SessionId, cwd: cwd}
179+}
180+
181+// say sends one prompt and returns the agent's last message.
182+func (h *harness) say(text string) string {
183+ h.t.Helper()
184+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
185+ defer cancel()
186+ if _, err := h.conn.Prompt(ctx, sdk.PromptRequest{SessionId: h.sid, Prompt: []sdk.ContentBlock{sdk.TextBlock(text)}}); err != nil {
187+ h.t.Fatalf("prompt %q: %v", text, err)
188+ }
189+ return h.client.last()
190+}
191+
192+// golden is the file mm init's line mode writes with the same answers.
193+func golden(t *testing.T, base string) string {
194+ t.Helper()
195+ keepConfig(t)
196+ out := filepath.Join(t.TempDir(), "agent.yaml")
197+ answers := strings.Join([]string{"", "1", "", "1", "2", "n", "", "", "bob", out}, "\n") + "\n"
198+ var stdout bytes.Buffer
199+ if code := agentinit.Main([]string{"--provider", "llmman", "--base-url", base, "--line"}, strings.NewReader(answers), &stdout); code != 0 {
200+ t.Fatalf("mm init: exit %d\n%s", code, stdout.String())
201+ }
202+ data, err := os.ReadFile(out)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ return string(data)
207+}
208+
209+func read(t *testing.T, path string) string {
210+ t.Helper()
211+ data, err := os.ReadFile(path)
212+ if err != nil {
213+ t.Fatalf("%s: %v", path, err)
214+ }
215+ return string(data)
216+}
217+
218+// A client without forms gets the conversation: each question a message,
219+// each answer a prompt — and the file is the one mm init writes with the
220+// same answers, in the session's cwd. The client is called zed: the mode
221+// still comes from the capabilities, and only the closing snippet is Zed's.
222+func TestConversationWritesWhatMmInitWrites(t *testing.T) {
223+ base := llmmanStub(t)
224+ want := golden(t, base)
225+ client := &fakeClient{}
226+ h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false)
227+
228+ first := h.say("hello")
229+ if !strings.Contains(first, "Keep everything in a .mm folder?") || !strings.Contains(client.all(), "mm init") {
230+ t.Fatalf("the first prompt did not start the wizard:\n%s", client.all())
231+ }
232+ for _, answer := range []string{".", "1", ".", "1", "2", "n", ".", ".", "bob"} {
233+ h.say(answer)
234+ }
235+ end := h.say("agent.yaml")
236+ if len(client.forms) != 0 {
237+ t.Errorf("a client without the form capability was sent %d form(s)", len(client.forms))
238+ }
239+ path := filepath.Join(h.cwd, "agent.yaml")
240+ if got := read(t, path); got != want {
241+ t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want)
242+ }
243+ for _, s := range []string{"✅ wrote agent.yaml", "✅ mm loads it", "mm acp " + path, "agent_servers"} {
244+ if !strings.Contains(end, s) {
245+ t.Errorf("closing message lacks %q:\n%s", s, end)
246+ }
247+ }
248+ if again := h.say("more"); !strings.Contains(again, "is written") {
249+ t.Errorf("after writing: %q", again)
250+ }
251+}
252+
253+// A client with forms gets a few forms; a form never holds a question
254+// with the one its options come from. The file is again mm init's.
255+func TestFormsWriteWhatMmInitWrites(t *testing.T) {
256+ base := llmmanStub(t)
257+ want := golden(t, base)
258+ given := map[string]any{"project": false, "usage": "developer", "machine": "medium", "yolo": false, "sessions": false, "skills": false, "name": "bob", "file": "agent.yaml"}
259+ client := &fakeClient{answer: func(key string, p map[string]any) any {
260+ if v, ok := given[key]; ok {
261+ return v
262+ }
263+ if enum, ok := p["enum"].([]any); ok && len(enum) > 0 {
264+ if d, ok := p["default"]; ok {
265+ return d
266+ }
267+ return enum[0]
268+ }
269+ return p["default"]
270+ }}
271+ h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "some-editor", true)
272+ end := h.say("go")
273+
274+ wantForms := [][]string{{"model", "project"}, {"family", "machine", "usage"}, {"file", "name", "sessions", "skills", "yolo"}}
275+ if len(client.forms) != len(wantForms) {
276+ t.Fatalf("forms = %v, want %v", client.forms, wantForms)
277+ }
278+ for i := range wantForms {
279+ if strings.Join(client.forms[i], ",") != strings.Join(wantForms[i], ",") {
280+ t.Errorf("form %d = %v, want %v", i+1, client.forms[i], wantForms[i])
281+ }
282+ }
283+ if got := read(t, filepath.Join(h.cwd, "agent.yaml")); got != want {
284+ t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want)
285+ }
286+ if !strings.Contains(end, "✅ mm loads it") || strings.Contains(end, "agent_servers") {
287+ t.Errorf("closing message (no snippet for an unknown client):\n%s", end)
288+ }
289+}
290+
291+// Declining a form writes nothing; the next message asks again.
292+func TestDeclinedFormWritesNothing(t *testing.T) {
293+ client := &fakeClient{decline: true}
294+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", true)
295+ if got := h.say("go"); !strings.Contains(got, "nothing written") {
296+ t.Errorf("after a decline: %q", got)
297+ }
298+ if _, err := os.Stat(filepath.Join(h.cwd, "agent.yaml")); err == nil {
299+ t.Error("a declined form wrote the file")
300+ }
301+ h.say("again")
302+ if len(client.forms) != 2 {
303+ t.Errorf("the next message did not ask again: %d form(s)", len(client.forms))
304+ }
305+}
306+
307+// /preview, /back, a refused answer, and /write with the Project layout.
308+func TestCommands(t *testing.T) {
309+ client := &fakeClient{}
310+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false)
311+ if got := h.say("/start"); !strings.Contains(got, ".mm folder?") {
312+ t.Errorf("/start did not ask the first question: %q", got)
313+ }
314+ if got := h.say("/start"); !strings.Contains(got, ".mm folder?") {
315+ t.Errorf("/start again did not ask the current question again: %q", got)
316+ }
317+ if strings.Count(client.all(), "let's create an agent") != 1 {
318+ t.Errorf("the introduction was not sent exactly once:\n%s", client.all())
319+ }
320+ // Announced right after the session/new response, in a goroutine.
321+ announced := 0
322+ for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
323+ client.mu.Lock()
324+ announced = len(client.commands)
325+ client.mu.Unlock()
326+ if announced > 0 {
327+ break
328+ }
329+ }
330+ if announced != len(commands) {
331+ t.Errorf("announced %d command(s), want %d", announced, len(commands))
332+ }
333+ if got := h.say("maybe"); !strings.Contains(got, "answer yes or no") || !strings.Contains(got, ".mm folder?") {
334+ t.Errorf("a non-answer to a yes/no question: %q", got)
335+ }
336+ if got := h.say("yes"); !strings.Contains(got, "Model?") {
337+ t.Errorf("after the layout: %q", got)
338+ }
339+ if got := h.say("/back"); !strings.Contains(got, ".mm folder?") {
340+ t.Errorf("/back did not ask the layout again: %q", got)
341+ }
342+ h.say("1") // yes, by number
343+ if got := h.say("/preview"); !strings.Contains(got, "```yaml") || !strings.Contains(got, "provider: llmman") {
344+ t.Errorf("/preview: %q", got)
345+ }
346+ end := h.say("/write")
347+ path := filepath.Join(h.cwd, ".mm", "agent.yaml")
348+ if !strings.Contains(read(t, path), "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M") {
349+ t.Errorf("/write did not write %s", path)
350+ }
351+ if !strings.Contains(end, "mm acp "+path) {
352+ t.Errorf("closing message: %q", end)
353+ }
354+ if got := h.say("/restart"); !strings.Contains(got, ".mm folder?") {
355+ t.Errorf("/restart: %q", got)
356+ }
357+}
358+
359+// /sbxinit writes sbxenv.yaml in the session's cwd, named after the agent
360+// just written; a second one keeps it unless --overwrite.
361+func TestSbxinit(t *testing.T) {
362+ restore := sbxinitIn
363+ sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int {
364+ return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil })
365+ }
366+ t.Cleanup(func() { sbxinitIn = restore })
367+ client := &fakeClient{}
368+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy"}, client, "x", false)
369+ h.say("/start")
370+ if end := h.say("/write"); !strings.Contains(end, "/sbxinit") {
371+ t.Errorf("the closing message does not offer /sbxinit:\n%s", end)
372+ }
373+ got := h.say("/sbxinit")
374+ path := filepath.Join(h.cwd, "sbxenv.yaml")
375+ text := read(t, path)
376+ for _, want := range []string{"name: lucy-sbx", "mini-me-kit:9.9.9", "workspace: " + h.cwd} {
377+ if !strings.Contains(text, want) {
378+ t.Errorf("sbxenv.yaml lacks %q:\n%s", want, text)
379+ }
380+ }
381+ if !strings.Contains(got, "✅ wrote") || !strings.Contains(got, "sbx env create") {
382+ t.Errorf("/sbxinit said:\n%s", got)
383+ }
384+ if got := h.say("/sbxinit --name max"); !strings.Contains(got, "/sbxinit --overwrite") || strings.Contains(read(t, path), "max-sbx") {
385+ t.Errorf("an existing sbxenv.yaml was not kept: %q", got)
386+ }
387+ h.say("/sbxinit --name max --overwrite")
388+ if !strings.Contains(read(t, path), "name: max-sbx") {
389+ t.Errorf("--overwrite did not replace sbxenv.yaml:\n%s", read(t, path))
390+ }
391+}
392+
393+// /dockerinit writes Dockerfile and compose.yaml in the session's cwd; a
394+// .mm agent gets the read-only .mm mount (ticket 212).
395+func TestDockerinit(t *testing.T) {
396+ client := &fakeClient{}
397+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes", "--sessions", "yes"}, client, "x", false)
398+ h.say("/start")
399+ if end := h.say("/write"); !strings.Contains(end, "/dockerinit") {
400+ t.Errorf("the closing message does not offer /dockerinit:\n%s", end)
401+ }
402+ got := h.say("/dockerinit")
403+ compose := read(t, filepath.Join(h.cwd, "compose.yaml"))
404+ for _, want := range []string{"container_name: lucy-box", "./.mm:${PWD:-/workspace}/.mm:ro", "./.mm/sessions:"} {
405+ if !strings.Contains(compose, want) {
406+ t.Errorf("compose.yaml lacks %q:\n%s", want, compose)
407+ }
408+ }
409+ if !strings.Contains(got, "exec lucy mm tui\n") || !strings.Contains(got, "`docker` commands from") {
410+ t.Errorf("/dockerinit said:\n%s", got)
411+ }
412+ if got := h.say("/dockerinit"); !strings.Contains(got, "/dockerinit --overwrite") {
413+ t.Errorf("existing files were not kept: %q", got)
414+ }
415+ if got := h.say("/dockerinit --overwrite --name max"); !strings.Contains(got, "✅ wrote compose.yaml") && !strings.Contains(got, "compose.yaml") {
416+ t.Errorf("--overwrite: %q", got)
417+ }
418+ if !strings.Contains(read(t, filepath.Join(h.cwd, "compose.yaml")), "max-box") {
419+ t.Error("--overwrite did not replace compose.yaml")
420+ }
421+}
422+
423+// A client's "reload agent" (ticket 210): loadSession and session/resume
424+// are offered; both start the wizard over under the id the client asked
425+// for, and say so.
426+func TestReloadStartsOver(t *testing.T) {
427+ base := llmmanStub(t)
428+ client := &fakeClient{}
429+ h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false)
430+ h.say("/start")
431+ h.say("yes")
432+
433+ for _, method := range []string{"load", "resume"} {
434+ // A new process: a new agent and connection, the same session id.
435+ again := &fakeClient{}
436+ h2 := start(t, []string{"--provider", "llmman", "--base-url", base}, again, "zed", false)
437+ ctx := context.Background()
438+ var err error
439+ if method == "load" {
440+ _, err = h2.conn.LoadSession(ctx, sdk.LoadSessionRequest{SessionId: h.sid, Cwd: h.cwd, McpServers: []sdk.McpServer{}})
441+ } else {
442+ _, err = h2.conn.ResumeSession(ctx, sdk.ResumeSessionRequest{SessionId: h.sid, Cwd: h.cwd})
443+ }
444+ if err != nil {
445+ t.Fatalf("session/%s: %v", method, err)
446+ }
447+ if !strings.Contains(again.all(), "restarted") {
448+ t.Errorf("session/%s: no restart message:\n%s", method, again.all())
449+ }
450+ h2.sid, h2.cwd = h.sid, h.cwd
451+ if got := h2.say("/start"); !strings.Contains(got, ".mm folder?") {
452+ t.Errorf("session/%s: the wizard did not start over: %q", method, got)
453+ }
454+ }
455+}
456+
457+// /zedconfig shows one entry per way the project can start its agent: mm
458+// here always, the container with compose.yaml, the sandbox with
459+// sbxenv.yaml — and nothing but a pointer to /start without an agent.
460+func TestZedconfig(t *testing.T) {
461+ restore := sbxinitIn
462+ sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int {
463+ return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil })
464+ }
465+ t.Cleanup(func() { sbxinitIn = restore })
466+ client := &fakeClient{}
467+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes"}, client, "x", false)
468+ if got := h.say("/zedconfig"); !strings.Contains(got, "`/start` creates one") {
469+ t.Errorf("without an agent: %q", got)
470+ }
471+ h.say("/start")
472+ h.say("/write")
473+ agent := filepath.Join(h.cwd, ".mm", "agent.yaml")
474+ project := "mm-" + filepath.Base(h.cwd)
475+
476+ settings := func(text string) map[string]server {
477+ t.Helper()
478+ start := strings.Index(text, "```json\n")
479+ end := strings.LastIndex(text, "```")
480+ if start < 0 || end <= start {
481+ t.Fatalf("no JSON block:\n%s", text)
482+ }
483+ var parsed struct {
484+ Servers map[string]server `json:"agent_servers"`
485+ }
486+ if err := json.Unmarshal([]byte(text[start+len("```json\n"):end]), &parsed); err != nil {
487+ t.Fatalf("not JSON: %v\n%s", err, text)
488+ }
489+ return parsed.Servers
490+ }
491+
492+ got := h.say("/zedconfig")
493+ servers := settings(got)
494+ if len(servers) != 1 || strings.Join(servers[project].Args, " ") != "acp "+agent || !strings.Contains(got, "/dockerinit") {
495+ t.Errorf("agent only: %v\n%s", servers, got)
496+ }
497+
498+ h.say("/dockerinit")
499+ h.say("/sbxinit")
500+ got = h.say("/zedconfig")
501+ servers = settings(got)
502+ if c := servers[project+" (container)"]; c.Command != "docker" || strings.Join(c.Args, " ") != "exec -i lucy-box mm -acp -config "+agent {
503+ t.Errorf("container entry: %+v\n%s", c, got)
504+ }
505+ if s := servers[project+" (sandbox)"]; s.Command != "sbx" || strings.Join(s.Args, " ") != "exec -i lucy-sbx mm -acp -config "+agent {
506+ t.Errorf("sandbox entry: %+v\n%s", s, got)
507+ }
508+ for _, want := range []string{"docker compose up -d --wait", "sbx env create --force"} {
509+ if !strings.Contains(got, want) {
510+ t.Errorf("no %q in:\n%s", want, got)
511+ }
512+ }
513+}
514+
515+func TestParseAnswer(t *testing.T) {
516+ choice := agentinit.Step{Kind: "choice", Default: "b", Options: []agentinit.Option{{Value: "a"}, {Value: "b"}}}
517+ confirm := agentinit.Step{Kind: "confirm", Default: "no", Options: []agentinit.Option{{Value: "yes"}, {Value: "no"}}}
518+ text := agentinit.Step{Kind: "text"}
519+ cases := []struct {
520+ st agentinit.Step
521+ in, out string
522+ bad bool
523+ }{
524+ {choice, "2", "b", false},
525+ {choice, ".", "b", false},
526+ {choice, "a", "a", false},
527+ {choice, "9", "", true},
528+ {confirm, "1", "yes", false},
529+ {confirm, "n", "no", false},
530+ {confirm, "default", "no", false},
531+ {confirm, "perhaps", "", true},
532+ {text, "my-model", "my-model", false},
533+ {text, ".", "", true},
534+ }
535+ for _, c := range cases {
536+ got, err := parseAnswer(c.st, c.in)
537+ if (err != nil) != c.bad || got != c.out {
538+ t.Errorf("parseAnswer(%s, %q) = %q, %v", c.st.Kind, c.in, got, err)
539+ }
540+ }
541+}
542+
543+func TestBatchStopsAtADependency(t *testing.T) {
544+ steps := func(keys ...string) []agentinit.Step {
545+ var out []agentinit.Step
546+ for _, k := range keys {
547+ out = append(out, agentinit.Step{Key: k})
548+ }
549+ return out
550+ }
551+ keys := func(st []agentinit.Step) string {
552+ var out []string
553+ for _, s := range st {
554+ out = append(out, s.Key)
555+ }
556+ return strings.Join(out, ",")
557+ }
558+ for in, want := range map[string]string{
559+ "project,provider,model": "project,provider",
560+ "endpoint,keyenv,model": "endpoint,keyenv",
561+ "usage,machine,yolo": "usage,machine",
562+ "yolo,sessions,skills,name,file": "yolo,sessions,skills,name,file",
563+ "file,overwrite": "file",
564+ "project,skills": "project",
565+ } {
566+ if got := keys(batch(steps(strings.Split(in, ",")...))); got != want {
567+ t.Errorf("batch(%s) = %s, want %s", in, got, want)
568+ }
569+ }
570+}
571+
572+func TestIs(t *testing.T) {
573+ for _, a := range []string{"acp-create", "--acp-create", "-acp-create"} {
574+ if !Is(a) {
575+ t.Errorf("Is(%q) = false", a)
576+ }
577+ }
578+ if Is("acp") || Is("init") {
579+ t.Error("Is accepts another mode")
580+ }
581+}
new file mode 100644
@@ -0,0 +1,581 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "log/slog"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "path/filepath"
13+ "sort"
14+ "strings"
15+ "sync"
16+ "testing"
17+ "time"
18+
19+ "mm/internal/agentinit"
20+ "mm/internal/config"
21+ "mm/internal/sbxinit"
22+
23+ sdk "github.com/coder/acp-go-sdk"
24+)
25+
26+// llmmanStub is an llmman server with one model, as mm init's tests use.
27+func llmmanStub(t *testing.T) string {
28+ t.Helper()
29+ routes := map[string]any{
30+ "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}}},
31+ "POST /api/show": map[string]any{"model_info": map[string]any{"general.architecture": "qwen35", "qwen35.context_length": 32768}, "template": "{{ enable_thinking | default(false) }}"},
32+ }
33+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34+ body, ok := routes[r.Method+" "+r.URL.Path]
35+ if !ok {
36+ http.NotFound(w, r)
37+ return
38+ }
39+ _ = json.NewEncoder(w).Encode(body)
40+ }))
41+ t.Cleanup(s.Close)
42+ return s.URL + "/v1"
43+}
44+
45+func keepConfig(t *testing.T) {
46+ saved := config.Cfg
47+ t.Cleanup(func() { config.Cfg = saved })
48+}
49+
50+// fakeClient plays the ACP client: it keeps the agent's messages and, when
51+// forms is set, answers each form with answer(key, property).
52+type fakeClient struct {
53+ mu sync.Mutex
54+ messages []string
55+ commands []sdk.AvailableCommand
56+ forms [][]string // the property names of each form, in order
57+ answer func(key string, property map[string]any) any
58+ decline bool
59+}
60+
61+func (c *fakeClient) SessionUpdate(_ context.Context, n sdk.SessionNotification) error {
62+ c.mu.Lock()
63+ defer c.mu.Unlock()
64+ if m := n.Update.AgentMessageChunk; m != nil && m.Content.Text != nil {
65+ c.messages = append(c.messages, m.Content.Text.Text)
66+ }
67+ if a := n.Update.AvailableCommandsUpdate; a != nil {
68+ c.commands = a.AvailableCommands
69+ }
70+ return nil
71+}
72+
73+func (c *fakeClient) UnstableCreateElicitation(_ context.Context, p sdk.UnstableCreateElicitationRequest) (sdk.UnstableCreateElicitationResponse, error) {
74+ c.mu.Lock()
75+ defer c.mu.Unlock()
76+ props := p.Form.RequestedSchema.Properties
77+ var names []string
78+ for name := range props {
79+ names = append(names, name)
80+ }
81+ sort.Strings(names)
82+ c.forms = append(c.forms, names)
83+ if c.decline {
84+ return sdk.UnstableCreateElicitationResponse{Decline: &sdk.UnstableCreateElicitationDecline{Action: "decline"}}, nil
85+ }
86+ content := map[string]any{}
87+ for _, name := range names {
88+ prop, _ := props[name].(map[string]any)
89+ content[name] = c.answer(name, prop)
90+ }
91+ return sdk.UnstableCreateElicitationResponse{Accept: &sdk.UnstableCreateElicitationAccept{Action: "accept", Content: content}}, nil
92+}
93+
94+func (c *fakeClient) RequestPermission(context.Context, sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {
95+ return sdk.RequestPermissionResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodSessionRequestPermission)
96+}
97+func (c *fakeClient) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) {
98+ return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile)
99+}
100+func (c *fakeClient) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) {
101+ return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile)
102+}
103+func (c *fakeClient) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) {
104+ return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate)
105+}
106+func (c *fakeClient) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) {
107+ return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill)
108+}
109+func (c *fakeClient) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) {
110+ return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput)
111+}
112+func (c *fakeClient) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) {
113+ return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease)
114+}
115+func (c *fakeClient) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) {
116+ return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit)
117+}
118+
119+// last is the agent's last message.
120+func (c *fakeClient) last() string {
121+ c.mu.Lock()
122+ defer c.mu.Unlock()
123+ if len(c.messages) == 0 {
124+ return ""
125+ }
126+ return c.messages[len(c.messages)-1]
127+}
128+
129+func (c *fakeClient) all() string {
130+ c.mu.Lock()
131+ defer c.mu.Unlock()
132+ return strings.Join(c.messages, "\n---\n")
133+}
134+
135+// harness is an agent and a client connected by pipes, one session open
136+// in cwd.
137+type harness struct {
138+ t *testing.T
139+ client *fakeClient
140+ conn *sdk.ClientSideConnection
141+ sid sdk.SessionId
142+ cwd string
143+}
144+
145+func start(t *testing.T, args []string, client *fakeClient, name string, forms bool) *harness {
146+ t.Helper()
147+ keepConfig(t)
148+ a, code, ok := agentinit.ParseArgs("mm acp-create", args, io.Discard, false)
149+ if !ok {
150+ t.Fatalf("flags refused (%d): %v", code, args)
151+ }
152+ c2aR, c2aW := io.Pipe()
153+ a2cR, a2cW := io.Pipe()
154+ t.Cleanup(func() { _ = c2aW.Close(); _ = a2cW.Close() })
155+ newAgent(a, a2cW, c2aR, io.Discard)
156+ conn := sdk.NewClientSideConnection(client, c2aW, a2cR)
157+ conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
158+ ctx := context.Background()
159+ caps := sdk.ClientCapabilities{}
160+ if forms {
161+ caps.Elicitation = &sdk.ElicitationCapabilities{Form: &sdk.ElicitationFormCapabilities{}}
162+ }
163+ if _, err := conn.Initialize(ctx, sdk.InitializeRequest{ProtocolVersion: sdk.ProtocolVersionNumber, ClientCapabilities: caps, ClientInfo: &sdk.Implementation{Name: name, Version: "1"}}); err != nil {
164+ t.Fatal(err)
165+ }
166+ cwd := t.TempDir()
167+ resp, err := conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}})
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ // The welcome comes after the session/new response, in a goroutine:
172+ // wait for it, so that it is never taken for the answer to a prompt.
173+ for deadline := time.Now().Add(5 * time.Second); !strings.Contains(client.all(), "Send `/start`"); time.Sleep(5 * time.Millisecond) {
174+ if time.Now().After(deadline) {
175+ t.Fatal("no welcome message after session/new")
176+ }
177+ }
178+ return &harness{t: t, client: client, conn: conn, sid: resp.SessionId, cwd: cwd}
179+}
180+
181+// say sends one prompt and returns the agent's last message.
182+func (h *harness) say(text string) string {
183+ h.t.Helper()
184+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
185+ defer cancel()
186+ if _, err := h.conn.Prompt(ctx, sdk.PromptRequest{SessionId: h.sid, Prompt: []sdk.ContentBlock{sdk.TextBlock(text)}}); err != nil {
187+ h.t.Fatalf("prompt %q: %v", text, err)
188+ }
189+ return h.client.last()
190+}
191+
192+// golden is the file mm init's line mode writes with the same answers.
193+func golden(t *testing.T, base string) string {
194+ t.Helper()
195+ keepConfig(t)
196+ out := filepath.Join(t.TempDir(), "agent.yaml")
197+ answers := strings.Join([]string{"", "1", "", "1", "2", "n", "", "", "bob", out}, "\n") + "\n"
198+ var stdout bytes.Buffer
199+ if code := agentinit.Main([]string{"--provider", "llmman", "--base-url", base, "--line"}, strings.NewReader(answers), &stdout); code != 0 {
200+ t.Fatalf("mm init: exit %d\n%s", code, stdout.String())
201+ }
202+ data, err := os.ReadFile(out)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ return string(data)
207+}
208+
209+func read(t *testing.T, path string) string {
210+ t.Helper()
211+ data, err := os.ReadFile(path)
212+ if err != nil {
213+ t.Fatalf("%s: %v", path, err)
214+ }
215+ return string(data)
216+}
217+
218+// A client without forms gets the conversation: each question a message,
219+// each answer a prompt — and the file is the one mm init writes with the
220+// same answers, in the session's cwd. The client is called zed: the mode
221+// still comes from the capabilities, and only the closing snippet is Zed's.
222+func TestConversationWritesWhatMmInitWrites(t *testing.T) {
223+ base := llmmanStub(t)
224+ want := golden(t, base)
225+ client := &fakeClient{}
226+ h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false)
227+
228+ first := h.say("hello")
229+ if !strings.Contains(first, "Keep everything in a .mm folder?") || !strings.Contains(client.all(), "mm init") {
230+ t.Fatalf("the first prompt did not start the wizard:\n%s", client.all())
231+ }
232+ for _, answer := range []string{".", "1", ".", "1", "2", "n", ".", ".", "bob"} {
233+ h.say(answer)
234+ }
235+ end := h.say("agent.yaml")
236+ if len(client.forms) != 0 {
237+ t.Errorf("a client without the form capability was sent %d form(s)", len(client.forms))
238+ }
239+ path := filepath.Join(h.cwd, "agent.yaml")
240+ if got := read(t, path); got != want {
241+ t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want)
242+ }
243+ for _, s := range []string{"✅ wrote agent.yaml", "✅ mm loads it", "mm acp " + path, "agent_servers"} {
244+ if !strings.Contains(end, s) {
245+ t.Errorf("closing message lacks %q:\n%s", s, end)
246+ }
247+ }
248+ if again := h.say("more"); !strings.Contains(again, "is written") {
249+ t.Errorf("after writing: %q", again)
250+ }
251+}
252+
253+// A client with forms gets a few forms; a form never holds a question
254+// with the one its options come from. The file is again mm init's.
255+func TestFormsWriteWhatMmInitWrites(t *testing.T) {
256+ base := llmmanStub(t)
257+ want := golden(t, base)
258+ given := map[string]any{"project": false, "usage": "developer", "machine": "medium", "yolo": false, "sessions": false, "skills": false, "name": "bob", "file": "agent.yaml"}
259+ client := &fakeClient{answer: func(key string, p map[string]any) any {
260+ if v, ok := given[key]; ok {
261+ return v
262+ }
263+ if enum, ok := p["enum"].([]any); ok && len(enum) > 0 {
264+ if d, ok := p["default"]; ok {
265+ return d
266+ }
267+ return enum[0]
268+ }
269+ return p["default"]
270+ }}
271+ h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "some-editor", true)
272+ end := h.say("go")
273+
274+ wantForms := [][]string{{"model", "project"}, {"family", "machine", "usage"}, {"file", "name", "sessions", "skills", "yolo"}}
275+ if len(client.forms) != len(wantForms) {
276+ t.Fatalf("forms = %v, want %v", client.forms, wantForms)
277+ }
278+ for i := range wantForms {
279+ if strings.Join(client.forms[i], ",") != strings.Join(wantForms[i], ",") {
280+ t.Errorf("form %d = %v, want %v", i+1, client.forms[i], wantForms[i])
281+ }
282+ }
283+ if got := read(t, filepath.Join(h.cwd, "agent.yaml")); got != want {
284+ t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want)
285+ }
286+ if !strings.Contains(end, "✅ mm loads it") || strings.Contains(end, "agent_servers") {
287+ t.Errorf("closing message (no snippet for an unknown client):\n%s", end)
288+ }
289+}
290+
291+// Declining a form writes nothing; the next message asks again.
292+func TestDeclinedFormWritesNothing(t *testing.T) {
293+ client := &fakeClient{decline: true}
294+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", true)
295+ if got := h.say("go"); !strings.Contains(got, "nothing written") {
296+ t.Errorf("after a decline: %q", got)
297+ }
298+ if _, err := os.Stat(filepath.Join(h.cwd, "agent.yaml")); err == nil {
299+ t.Error("a declined form wrote the file")
300+ }
301+ h.say("again")
302+ if len(client.forms) != 2 {
303+ t.Errorf("the next message did not ask again: %d form(s)", len(client.forms))
304+ }
305+}
306+
307+// /preview, /back, a refused answer, and /write with the Project layout.
308+func TestCommands(t *testing.T) {
309+ client := &fakeClient{}
310+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false)
311+ if got := h.say("/start"); !strings.Contains(got, ".mm folder?") {
312+ t.Errorf("/start did not ask the first question: %q", got)
313+ }
314+ if got := h.say("/start"); !strings.Contains(got, ".mm folder?") {
315+ t.Errorf("/start again did not ask the current question again: %q", got)
316+ }
317+ if strings.Count(client.all(), "let's create an agent") != 1 {
318+ t.Errorf("the introduction was not sent exactly once:\n%s", client.all())
319+ }
320+ // Announced right after the session/new response, in a goroutine.
321+ announced := 0
322+ for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
323+ client.mu.Lock()
324+ announced = len(client.commands)
325+ client.mu.Unlock()
326+ if announced > 0 {
327+ break
328+ }
329+ }
330+ if announced != len(commands) {
331+ t.Errorf("announced %d command(s), want %d", announced, len(commands))
332+ }
333+ if got := h.say("maybe"); !strings.Contains(got, "answer yes or no") || !strings.Contains(got, ".mm folder?") {
334+ t.Errorf("a non-answer to a yes/no question: %q", got)
335+ }
336+ if got := h.say("yes"); !strings.Contains(got, "Model?") {
337+ t.Errorf("after the layout: %q", got)
338+ }
339+ if got := h.say("/back"); !strings.Contains(got, ".mm folder?") {
340+ t.Errorf("/back did not ask the layout again: %q", got)
341+ }
342+ h.say("1") // yes, by number
343+ if got := h.say("/preview"); !strings.Contains(got, "```yaml") || !strings.Contains(got, "provider: llmman") {
344+ t.Errorf("/preview: %q", got)
345+ }
346+ end := h.say("/write")
347+ path := filepath.Join(h.cwd, ".mm", "agent.yaml")
348+ if !strings.Contains(read(t, path), "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M") {
349+ t.Errorf("/write did not write %s", path)
350+ }
351+ if !strings.Contains(end, "mm acp "+path) {
352+ t.Errorf("closing message: %q", end)
353+ }
354+ if got := h.say("/restart"); !strings.Contains(got, ".mm folder?") {
355+ t.Errorf("/restart: %q", got)
356+ }
357+}
358+
359+// /sbxinit writes sbxenv.yaml in the session's cwd, named after the agent
360+// just written; a second one keeps it unless --overwrite.
361+func TestSbxinit(t *testing.T) {
362+ restore := sbxinitIn
363+ sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int {
364+ return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil })
365+ }
366+ t.Cleanup(func() { sbxinitIn = restore })
367+ client := &fakeClient{}
368+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy"}, client, "x", false)
369+ h.say("/start")
370+ if end := h.say("/write"); !strings.Contains(end, "/sbxinit") {
371+ t.Errorf("the closing message does not offer /sbxinit:\n%s", end)
372+ }
373+ got := h.say("/sbxinit")
374+ path := filepath.Join(h.cwd, "sbxenv.yaml")
375+ text := read(t, path)
376+ for _, want := range []string{"name: lucy-sbx", "mini-me-kit:9.9.9", "workspace: " + h.cwd} {
377+ if !strings.Contains(text, want) {
378+ t.Errorf("sbxenv.yaml lacks %q:\n%s", want, text)
379+ }
380+ }
381+ if !strings.Contains(got, "✅ wrote") || !strings.Contains(got, "sbx env create") {
382+ t.Errorf("/sbxinit said:\n%s", got)
383+ }
384+ if got := h.say("/sbxinit --name max"); !strings.Contains(got, "/sbxinit --overwrite") || strings.Contains(read(t, path), "max-sbx") {
385+ t.Errorf("an existing sbxenv.yaml was not kept: %q", got)
386+ }
387+ h.say("/sbxinit --name max --overwrite")
388+ if !strings.Contains(read(t, path), "name: max-sbx") {
389+ t.Errorf("--overwrite did not replace sbxenv.yaml:\n%s", read(t, path))
390+ }
391+}
392+
393+// /dockerinit writes Dockerfile and compose.yaml in the session's cwd; a
394+// .mm agent gets the read-only .mm mount (ticket 212).
395+func TestDockerinit(t *testing.T) {
396+ client := &fakeClient{}
397+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes", "--sessions", "yes"}, client, "x", false)
398+ h.say("/start")
399+ if end := h.say("/write"); !strings.Contains(end, "/dockerinit") {
400+ t.Errorf("the closing message does not offer /dockerinit:\n%s", end)
401+ }
402+ got := h.say("/dockerinit")
403+ compose := read(t, filepath.Join(h.cwd, "compose.yaml"))
404+ for _, want := range []string{"container_name: lucy-box", "./.mm:${PWD:-/workspace}/.mm:ro", "./.mm/sessions:"} {
405+ if !strings.Contains(compose, want) {
406+ t.Errorf("compose.yaml lacks %q:\n%s", want, compose)
407+ }
408+ }
409+ if !strings.Contains(got, "exec lucy mm tui\n") || !strings.Contains(got, "`docker` commands from") {
410+ t.Errorf("/dockerinit said:\n%s", got)
411+ }
412+ if got := h.say("/dockerinit"); !strings.Contains(got, "/dockerinit --overwrite") {
413+ t.Errorf("existing files were not kept: %q", got)
414+ }
415+ if got := h.say("/dockerinit --overwrite --name max"); !strings.Contains(got, "✅ wrote compose.yaml") && !strings.Contains(got, "compose.yaml") {
416+ t.Errorf("--overwrite: %q", got)
417+ }
418+ if !strings.Contains(read(t, filepath.Join(h.cwd, "compose.yaml")), "max-box") {
419+ t.Error("--overwrite did not replace compose.yaml")
420+ }
421+}
422+
423+// A client's "reload agent" (ticket 210): loadSession and session/resume
424+// are offered; both start the wizard over under the id the client asked
425+// for, and say so.
426+func TestReloadStartsOver(t *testing.T) {
427+ base := llmmanStub(t)
428+ client := &fakeClient{}
429+ h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false)
430+ h.say("/start")
431+ h.say("yes")
432+
433+ for _, method := range []string{"load", "resume"} {
434+ // A new process: a new agent and connection, the same session id.
435+ again := &fakeClient{}
436+ h2 := start(t, []string{"--provider", "llmman", "--base-url", base}, again, "zed", false)
437+ ctx := context.Background()
438+ var err error
439+ if method == "load" {
440+ _, err = h2.conn.LoadSession(ctx, sdk.LoadSessionRequest{SessionId: h.sid, Cwd: h.cwd, McpServers: []sdk.McpServer{}})
441+ } else {
442+ _, err = h2.conn.ResumeSession(ctx, sdk.ResumeSessionRequest{SessionId: h.sid, Cwd: h.cwd})
443+ }
444+ if err != nil {
445+ t.Fatalf("session/%s: %v", method, err)
446+ }
447+ if !strings.Contains(again.all(), "restarted") {
448+ t.Errorf("session/%s: no restart message:\n%s", method, again.all())
449+ }
450+ h2.sid, h2.cwd = h.sid, h.cwd
451+ if got := h2.say("/start"); !strings.Contains(got, ".mm folder?") {
452+ t.Errorf("session/%s: the wizard did not start over: %q", method, got)
453+ }
454+ }
455+}
456+
457+// /zedconfig shows one entry per way the project can start its agent: mm
458+// here always, the container with compose.yaml, the sandbox with
459+// sbxenv.yaml — and nothing but a pointer to /start without an agent.
460+func TestZedconfig(t *testing.T) {
461+ restore := sbxinitIn
462+ sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int {
463+ return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil })
464+ }
465+ t.Cleanup(func() { sbxinitIn = restore })
466+ client := &fakeClient{}
467+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes"}, client, "x", false)
468+ if got := h.say("/zedconfig"); !strings.Contains(got, "`/start` creates one") {
469+ t.Errorf("without an agent: %q", got)
470+ }
471+ h.say("/start")
472+ h.say("/write")
473+ agent := filepath.Join(h.cwd, ".mm", "agent.yaml")
474+ project := "mm-" + filepath.Base(h.cwd)
475+
476+ settings := func(text string) map[string]server {
477+ t.Helper()
478+ start := strings.Index(text, "```json\n")
479+ end := strings.LastIndex(text, "```")
480+ if start < 0 || end <= start {
481+ t.Fatalf("no JSON block:\n%s", text)
482+ }
483+ var parsed struct {
484+ Servers map[string]server `json:"agent_servers"`
485+ }
486+ if err := json.Unmarshal([]byte(text[start+len("```json\n"):end]), &parsed); err != nil {
487+ t.Fatalf("not JSON: %v\n%s", err, text)
488+ }
489+ return parsed.Servers
490+ }
491+
492+ got := h.say("/zedconfig")
493+ servers := settings(got)
494+ if len(servers) != 1 || strings.Join(servers[project].Args, " ") != "acp "+agent || !strings.Contains(got, "/dockerinit") {
495+ t.Errorf("agent only: %v\n%s", servers, got)
496+ }
497+
498+ h.say("/dockerinit")
499+ h.say("/sbxinit")
500+ got = h.say("/zedconfig")
501+ servers = settings(got)
502+ if c := servers[project+" (container)"]; c.Command != "docker" || strings.Join(c.Args, " ") != "exec -i lucy-box mm -acp -config "+agent {
503+ t.Errorf("container entry: %+v\n%s", c, got)
504+ }
505+ if s := servers[project+" (sandbox)"]; s.Command != "sbx" || strings.Join(s.Args, " ") != "exec -i lucy-sbx mm -acp -config "+agent {
506+ t.Errorf("sandbox entry: %+v\n%s", s, got)
507+ }
508+ for _, want := range []string{"docker compose up -d --wait", "sbx env create --force"} {
509+ if !strings.Contains(got, want) {
510+ t.Errorf("no %q in:\n%s", want, got)
511+ }
512+ }
513+}
514+
515+func TestParseAnswer(t *testing.T) {
516+ choice := agentinit.Step{Kind: "choice", Default: "b", Options: []agentinit.Option{{Value: "a"}, {Value: "b"}}}
517+ confirm := agentinit.Step{Kind: "confirm", Default: "no", Options: []agentinit.Option{{Value: "yes"}, {Value: "no"}}}
518+ text := agentinit.Step{Kind: "text"}
519+ cases := []struct {
520+ st agentinit.Step
521+ in, out string
522+ bad bool
523+ }{
524+ {choice, "2", "b", false},
525+ {choice, ".", "b", false},
526+ {choice, "a", "a", false},
527+ {choice, "9", "", true},
528+ {confirm, "1", "yes", false},
529+ {confirm, "n", "no", false},
530+ {confirm, "default", "no", false},
531+ {confirm, "perhaps", "", true},
532+ {text, "my-model", "my-model", false},
533+ {text, ".", "", true},
534+ }
535+ for _, c := range cases {
536+ got, err := parseAnswer(c.st, c.in)
537+ if (err != nil) != c.bad || got != c.out {
538+ t.Errorf("parseAnswer(%s, %q) = %q, %v", c.st.Kind, c.in, got, err)
539+ }
540+ }
541+}
542+
543+func TestBatchStopsAtADependency(t *testing.T) {
544+ steps := func(keys ...string) []agentinit.Step {
545+ var out []agentinit.Step
546+ for _, k := range keys {
547+ out = append(out, agentinit.Step{Key: k})
548+ }
549+ return out
550+ }
551+ keys := func(st []agentinit.Step) string {
552+ var out []string
553+ for _, s := range st {
554+ out = append(out, s.Key)
555+ }
556+ return strings.Join(out, ",")
557+ }
558+ for in, want := range map[string]string{
559+ "project,provider,model": "project,provider",
560+ "endpoint,keyenv,model": "endpoint,keyenv",
561+ "usage,machine,yolo": "usage,machine",
562+ "yolo,sessions,skills,name,file": "yolo,sessions,skills,name,file",
563+ "file,overwrite": "file",
564+ "project,skills": "project",
565+ } {
566+ if got := keys(batch(steps(strings.Split(in, ",")...))); got != want {
567+ t.Errorf("batch(%s) = %s, want %s", in, got, want)
568+ }
569+ }
570+}
571+
572+func TestIs(t *testing.T) {
573+ for _, a := range []string{"acp-create", "--acp-create", "-acp-create"} {
574+ if !Is(a) {
575+ t.Errorf("Is(%q) = false", a)
576+ }
577+ }
578+ if Is("acp") || Is("init") {
579+ t.Error("Is accepts another mode")
580+ }
581+}
added internal/acpcreate/ask.go +266 -0
new file mode 100644
@@ -0,0 +1,266 @@
1+package acpcreate
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "mm/internal/agentinit"
10+ "mm/internal/elicit"
11+
12+ sdk "github.com/coder/acp-go-sdk"
13+)
14+
15+// --- the conversation: one question per message ---------------------------------
16+
17+// question is one step as a chat message: the prompt, the numbered options
18+// with what each means, and how to answer.
19+func question(st agentinit.Step, position string) string {
20+ var b strings.Builder
21+ fmt.Fprintf(&b, "**%s**", st.Prompt)
22+ if position != "" {
23+ fmt.Fprintf(&b, " — *%s, question %s*", st.Title, position)
24+ }
25+ b.WriteString("\n\n")
26+ for i, o := range st.Options {
27+ fmt.Fprintf(&b, "%d. `%s`", i+1, o.Label)
28+ if o.Detail != "" {
29+ b.WriteString(" — " + o.Detail)
30+ }
31+ if o.Value == st.Default {
32+ b.WriteString(" **(default)**")
33+ }
34+ b.WriteString("\n")
35+ }
36+ if len(st.Options) > 0 {
37+ b.WriteString("\n")
38+ }
39+ how := "Type your answer"
40+ if len(st.Options) > 0 {
41+ how = "Reply with a number or a value"
42+ }
43+ if st.Default != "" {
44+ how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default)
45+ }
46+ b.WriteString(how + ".")
47+ return b.String()
48+}
49+
50+// isDefault: the answers that take the default — a client may not send an
51+// empty message, so "." does it.
52+func isDefault(text string) bool {
53+ switch strings.ToLower(strings.TrimSpace(text)) {
54+ case "", ".", "default", "/skip":
55+ return true
56+ }
57+ return false
58+}
59+
60+// parseAnswer reads a message as the answer to st. A yes/no question takes
61+// yes, no (y, n, oui…) or the option's number; nothing else.
62+func parseAnswer(st agentinit.Step, text string) (string, error) {
63+ text = strings.TrimSpace(text)
64+ if isDefault(text) {
65+ if st.Default == "" {
66+ return "", errors.New("this question has no default: type an answer")
67+ }
68+ return st.Default, nil
69+ }
70+ q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
71+ for _, o := range st.Options {
72+ q.Options = append(q.Options, o.Value)
73+ }
74+ if st.Kind == elicit.Confirm {
75+ return parseConfirm(q, text)
76+ }
77+ a, ok := elicit.ParseLine(q, text)
78+ if !ok || len(a.Values) == 0 {
79+ return "", fmt.Errorf("%q is not one of the %d options", text, len(q.Options))
80+ }
81+ return a.Values[0], nil
82+}
83+
84+// parseConfirm reads a yes/no answer: the option's number, or a word
85+// elicit reads as yes or no. Anything else is refused — the flow would take
86+// it for no.
87+func parseConfirm(q elicit.Question, text string) (string, error) {
88+ if n, err := strconv.Atoi(text); err == nil && n >= 1 && n <= len(q.Options) {
89+ return q.Options[n-1], nil
90+ }
91+ if a, ok := elicit.ParseLine(q, text); ok && len(a.Values) > 0 {
92+ return a.Values[0], nil
93+ }
94+ return "", fmt.Errorf("answer yes or no, not %q", text)
95+}
96+
97+// --- forms: several questions at once -------------------------------------------
98+
99+// dependsOn says which answers a question's options or default are built
100+// from (agentinit.Flow.Step). A form never holds a question with the one it
101+// depends on: its options would be stale by the time the form is answered.
102+var dependsOn = map[string][]string{
103+ "endpoint": {"provider"},
104+ "keyenv": {"provider"},
105+ "model": {"provider", "endpoint", "keyenv"},
106+ "family": {"model"},
107+ "yolo": {"usage"},
108+ "name": {"usage"},
109+ "skills": {"project"},
110+ "file": {"project"},
111+ "overwrite": {"file"},
112+}
113+
114+// batch is the next form: the pending questions in order, up to the first
115+// one that depends on a question already in the form.
116+func batch(pending []agentinit.Step) []agentinit.Step {
117+ in := map[string]bool{}
118+ var out []agentinit.Step
119+ for _, st := range pending {
120+ for _, dep := range dependsOn[st.Key] {
121+ if in[dep] {
122+ return out
123+ }
124+ }
125+ in[st.Key] = true
126+ out = append(out, st)
127+ }
128+ return out
129+}
130+
131+// maxIdleForms is how many forms in a row may answer nothing before the
132+// wizard stops asking: a client that keeps sending invalid answers must
133+// not loop forever.
134+const maxIdleForms = 3
135+
136+// askByForms asks the pending questions form after form, then writes.
137+func (t *turn) askByForms() {
138+ s := t.s
139+ idle := 0
140+ for {
141+ pending := s.flow.Pending()
142+ if len(pending) == 0 {
143+ t.write()
144+ return
145+ }
146+ steps := batch(pending)
147+ answers, status := t.form(steps)
148+ switch status {
149+ case elicit.Declined:
150+ t.say("Stopped: nothing written. Send any message to go on, `/restart` to start again.")
151+ return
152+ case elicit.Cancelled, elicit.Unavailable:
153+ t.say("The form was closed: nothing written. Send any message to go on.")
154+ return
155+ }
156+ if t.apply(steps, answers) == 0 {
157+ idle++
158+ if idle >= maxIdleForms {
159+ t.say("No answer could be used: stopping here. Send any message to try again.")
160+ return
161+ }
162+ } else {
163+ idle = 0
164+ }
165+ }
166+}
167+
168+// apply answers the questions of a form; it reports what was refused and
169+// returns how many answers were taken.
170+func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int {
171+ s := t.s
172+ taken := 0
173+ var refused []string
174+ for i, st := range steps {
175+ value := ""
176+ if i < len(answers) && len(answers[i].Values) > 0 {
177+ value = answers[i].Values[0]
178+ }
179+ if strings.TrimSpace(value) == "" {
180+ value = st.Default
181+ }
182+ if err := s.flow.Set(st.Key, value); err != nil {
183+ refused = append(refused, "⚠️ "+err.Error())
184+ continue
185+ }
186+ s.answered = append(s.answered, st.Key)
187+ taken++
188+ }
189+ if len(refused) > 0 {
190+ t.say(strings.Join(refused, "\n") + "\n\nAsking again.")
191+ }
192+ return taken
193+}
194+
195+// form sends steps as one elicitation/create form.
196+func (t *turn) form(steps []agentinit.Step) ([]elicit.Answer, elicit.Status) {
197+ f := formOf(steps)
198+ schema := elicit.Schema(f)
199+ names := make([]string, len(f.Questions))
200+ for i, q := range f.Questions {
201+ names[i] = q.Name
202+ }
203+ req := sdk.NewUnstableCreateElicitationRequestForm(sdk.UnstableElicitationSchema{
204+ Type: sdk.UnstableElicitationSchemaTypeObject,
205+ Properties: schema["properties"].(map[string]any),
206+ Required: names,
207+ })
208+ req.Form.Message = formMessage(steps)
209+ // The session scope rides in _meta; acpwire.Writer puts it where the
210+ // specification wants it.
211+ req.Form.Meta = map[string]any{elicit.ScopeMetaKey: map[string]any{"sessionId": string(t.sid)}}
212+ resp, err := t.ag.conn.UnstableCreateElicitation(t.ctx, req)
213+ switch {
214+ case err != nil && t.ctx.Err() != nil:
215+ return nil, elicit.Cancelled
216+ case err != nil:
217+ return nil, elicit.Unavailable
218+ case resp.Accept != nil:
219+ return elicit.FromContent(f, resp.Accept.Content).Answers, elicit.Answered
220+ case resp.Decline != nil:
221+ return nil, elicit.Declined
222+ }
223+ return nil, elicit.Cancelled
224+}
225+
226+// formOf is the steps as an elicit form: one question per step, named by
227+// its key, its options' values as the choices.
228+func formOf(steps []agentinit.Step) elicit.Form {
229+ var f elicit.Form
230+ for _, st := range steps {
231+ q := elicit.Question{Name: st.Key, Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
232+ for _, o := range st.Options {
233+ q.Options = append(q.Options, o.Value)
234+ }
235+ f.Questions = append(f.Questions, q)
236+ }
237+ return f
238+}
239+
240+// formMessage is the text above a form: what is being made, and what the
241+// options of its choices mean — a schema's enum carries values only.
242+func formMessage(steps []agentinit.Step) string {
243+ var b strings.Builder
244+ b.WriteString("mm init — create an agent")
245+ for _, st := range steps {
246+ if st.Kind != elicit.Choice || !hasDetails(st) {
247+ continue
248+ }
249+ fmt.Fprintf(&b, "\n\n%s", st.Title)
250+ for _, o := range st.Options {
251+ if o.Detail != "" {
252+ fmt.Fprintf(&b, "\n %s — %s", o.Label, o.Detail)
253+ }
254+ }
255+ }
256+ return b.String()
257+}
258+
259+func hasDetails(st agentinit.Step) bool {
260+ for _, o := range st.Options {
261+ if o.Detail != "" {
262+ return true
263+ }
264+ }
265+ return false
266+}
new file mode 100644
@@ -0,0 +1,266 @@
1+package acpcreate
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "mm/internal/agentinit"
10+ "mm/internal/elicit"
11+
12+ sdk "github.com/coder/acp-go-sdk"
13+)
14+
15+// --- the conversation: one question per message ---------------------------------
16+
17+// question is one step as a chat message: the prompt, the numbered options
18+// with what each means, and how to answer.
19+func question(st agentinit.Step, position string) string {
20+ var b strings.Builder
21+ fmt.Fprintf(&b, "**%s**", st.Prompt)
22+ if position != "" {
23+ fmt.Fprintf(&b, " — *%s, question %s*", st.Title, position)
24+ }
25+ b.WriteString("\n\n")
26+ for i, o := range st.Options {
27+ fmt.Fprintf(&b, "%d. `%s`", i+1, o.Label)
28+ if o.Detail != "" {
29+ b.WriteString(" — " + o.Detail)
30+ }
31+ if o.Value == st.Default {
32+ b.WriteString(" **(default)**")
33+ }
34+ b.WriteString("\n")
35+ }
36+ if len(st.Options) > 0 {
37+ b.WriteString("\n")
38+ }
39+ how := "Type your answer"
40+ if len(st.Options) > 0 {
41+ how = "Reply with a number or a value"
42+ }
43+ if st.Default != "" {
44+ how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default)
45+ }
46+ b.WriteString(how + ".")
47+ return b.String()
48+}
49+
50+// isDefault: the answers that take the default — a client may not send an
51+// empty message, so "." does it.
52+func isDefault(text string) bool {
53+ switch strings.ToLower(strings.TrimSpace(text)) {
54+ case "", ".", "default", "/skip":
55+ return true
56+ }
57+ return false
58+}
59+
60+// parseAnswer reads a message as the answer to st. A yes/no question takes
61+// yes, no (y, n, oui…) or the option's number; nothing else.
62+func parseAnswer(st agentinit.Step, text string) (string, error) {
63+ text = strings.TrimSpace(text)
64+ if isDefault(text) {
65+ if st.Default == "" {
66+ return "", errors.New("this question has no default: type an answer")
67+ }
68+ return st.Default, nil
69+ }
70+ q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
71+ for _, o := range st.Options {
72+ q.Options = append(q.Options, o.Value)
73+ }
74+ if st.Kind == elicit.Confirm {
75+ return parseConfirm(q, text)
76+ }
77+ a, ok := elicit.ParseLine(q, text)
78+ if !ok || len(a.Values) == 0 {
79+ return "", fmt.Errorf("%q is not one of the %d options", text, len(q.Options))
80+ }
81+ return a.Values[0], nil
82+}
83+
84+// parseConfirm reads a yes/no answer: the option's number, or a word
85+// elicit reads as yes or no. Anything else is refused — the flow would take
86+// it for no.
87+func parseConfirm(q elicit.Question, text string) (string, error) {
88+ if n, err := strconv.Atoi(text); err == nil && n >= 1 && n <= len(q.Options) {
89+ return q.Options[n-1], nil
90+ }
91+ if a, ok := elicit.ParseLine(q, text); ok && len(a.Values) > 0 {
92+ return a.Values[0], nil
93+ }
94+ return "", fmt.Errorf("answer yes or no, not %q", text)
95+}
96+
97+// --- forms: several questions at once -------------------------------------------
98+
99+// dependsOn says which answers a question's options or default are built
100+// from (agentinit.Flow.Step). A form never holds a question with the one it
101+// depends on: its options would be stale by the time the form is answered.
102+var dependsOn = map[string][]string{
103+ "endpoint": {"provider"},
104+ "keyenv": {"provider"},
105+ "model": {"provider", "endpoint", "keyenv"},
106+ "family": {"model"},
107+ "yolo": {"usage"},
108+ "name": {"usage"},
109+ "skills": {"project"},
110+ "file": {"project"},
111+ "overwrite": {"file"},
112+}
113+
114+// batch is the next form: the pending questions in order, up to the first
115+// one that depends on a question already in the form.
116+func batch(pending []agentinit.Step) []agentinit.Step {
117+ in := map[string]bool{}
118+ var out []agentinit.Step
119+ for _, st := range pending {
120+ for _, dep := range dependsOn[st.Key] {
121+ if in[dep] {
122+ return out
123+ }
124+ }
125+ in[st.Key] = true
126+ out = append(out, st)
127+ }
128+ return out
129+}
130+
131+// maxIdleForms is how many forms in a row may answer nothing before the
132+// wizard stops asking: a client that keeps sending invalid answers must
133+// not loop forever.
134+const maxIdleForms = 3
135+
136+// askByForms asks the pending questions form after form, then writes.
137+func (t *turn) askByForms() {
138+ s := t.s
139+ idle := 0
140+ for {
141+ pending := s.flow.Pending()
142+ if len(pending) == 0 {
143+ t.write()
144+ return
145+ }
146+ steps := batch(pending)
147+ answers, status := t.form(steps)
148+ switch status {
149+ case elicit.Declined:
150+ t.say("Stopped: nothing written. Send any message to go on, `/restart` to start again.")
151+ return
152+ case elicit.Cancelled, elicit.Unavailable:
153+ t.say("The form was closed: nothing written. Send any message to go on.")
154+ return
155+ }
156+ if t.apply(steps, answers) == 0 {
157+ idle++
158+ if idle >= maxIdleForms {
159+ t.say("No answer could be used: stopping here. Send any message to try again.")
160+ return
161+ }
162+ } else {
163+ idle = 0
164+ }
165+ }
166+}
167+
168+// apply answers the questions of a form; it reports what was refused and
169+// returns how many answers were taken.
170+func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int {
171+ s := t.s
172+ taken := 0
173+ var refused []string
174+ for i, st := range steps {
175+ value := ""
176+ if i < len(answers) && len(answers[i].Values) > 0 {
177+ value = answers[i].Values[0]
178+ }
179+ if strings.TrimSpace(value) == "" {
180+ value = st.Default
181+ }
182+ if err := s.flow.Set(st.Key, value); err != nil {
183+ refused = append(refused, "⚠️ "+err.Error())
184+ continue
185+ }
186+ s.answered = append(s.answered, st.Key)
187+ taken++
188+ }
189+ if len(refused) > 0 {
190+ t.say(strings.Join(refused, "\n") + "\n\nAsking again.")
191+ }
192+ return taken
193+}
194+
195+// form sends steps as one elicitation/create form.
196+func (t *turn) form(steps []agentinit.Step) ([]elicit.Answer, elicit.Status) {
197+ f := formOf(steps)
198+ schema := elicit.Schema(f)
199+ names := make([]string, len(f.Questions))
200+ for i, q := range f.Questions {
201+ names[i] = q.Name
202+ }
203+ req := sdk.NewUnstableCreateElicitationRequestForm(sdk.UnstableElicitationSchema{
204+ Type: sdk.UnstableElicitationSchemaTypeObject,
205+ Properties: schema["properties"].(map[string]any),
206+ Required: names,
207+ })
208+ req.Form.Message = formMessage(steps)
209+ // The session scope rides in _meta; acpwire.Writer puts it where the
210+ // specification wants it.
211+ req.Form.Meta = map[string]any{elicit.ScopeMetaKey: map[string]any{"sessionId": string(t.sid)}}
212+ resp, err := t.ag.conn.UnstableCreateElicitation(t.ctx, req)
213+ switch {
214+ case err != nil && t.ctx.Err() != nil:
215+ return nil, elicit.Cancelled
216+ case err != nil:
217+ return nil, elicit.Unavailable
218+ case resp.Accept != nil:
219+ return elicit.FromContent(f, resp.Accept.Content).Answers, elicit.Answered
220+ case resp.Decline != nil:
221+ return nil, elicit.Declined
222+ }
223+ return nil, elicit.Cancelled
224+}
225+
226+// formOf is the steps as an elicit form: one question per step, named by
227+// its key, its options' values as the choices.
228+func formOf(steps []agentinit.Step) elicit.Form {
229+ var f elicit.Form
230+ for _, st := range steps {
231+ q := elicit.Question{Name: st.Key, Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
232+ for _, o := range st.Options {
233+ q.Options = append(q.Options, o.Value)
234+ }
235+ f.Questions = append(f.Questions, q)
236+ }
237+ return f
238+}
239+
240+// formMessage is the text above a form: what is being made, and what the
241+// options of its choices mean — a schema's enum carries values only.
242+func formMessage(steps []agentinit.Step) string {
243+ var b strings.Builder
244+ b.WriteString("mm init — create an agent")
245+ for _, st := range steps {
246+ if st.Kind != elicit.Choice || !hasDetails(st) {
247+ continue
248+ }
249+ fmt.Fprintf(&b, "\n\n%s", st.Title)
250+ for _, o := range st.Options {
251+ if o.Detail != "" {
252+ fmt.Fprintf(&b, "\n %s — %s", o.Label, o.Detail)
253+ }
254+ }
255+ }
256+ return b.String()
257+}
258+
259+func hasDetails(st agentinit.Step) bool {
260+ for _, o := range st.Options {
261+ if o.Detail != "" {
262+ return true
263+ }
264+ }
265+ return false
266+}
added internal/acpcreate/restart.go +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+package acpcreate
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ sdk "github.com/coder/acp-go-sdk"
8+)
9+
10+// A client's "reload agent" restarts mm, then asks the new process for the
11+// thread it shows: session/load, or session/resume when the agent offers
12+// it. The wizard keeps nothing on disk (user's choice, 2026-11-14): both
13+// start a fresh wizard under the id the client asked for, and say so. The
14+// file already written, if any, is untouched.
15+
16+// LoadSession implements session/load. There is no conversation to replay:
17+// the one message sent says the wizard starts over.
18+func (ag *agent) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) {
19+ if err := ag.restart(ctx, "session/load", p.SessionId, p.Cwd); err != nil {
20+ return sdk.LoadSessionResponse{}, err
21+ }
22+ return sdk.LoadSessionResponse{}, nil
23+}
24+
25+// ResumeSession implements session/resume: the same, the client keeping the
26+// thread on screen.
27+func (ag *agent) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
28+ if err := ag.restart(ctx, "session/resume", p.SessionId, p.Cwd); err != nil {
29+ return sdk.ResumeSessionResponse{}, err
30+ }
31+ return sdk.ResumeSessionResponse{}, nil
32+}
33+
34+// restart registers a fresh wizard as session sid, then sends the commands
35+// and the restart message. The client asked for this id, so it accepts
36+// updates for it before the response — no need to wait for it.
37+func (ag *agent) restart(ctx context.Context, method string, sid sdk.SessionId, cwd string) error {
38+ s, err := ag.newSession(cwd)
39+ if err != nil {
40+ return err
41+ }
42+ ag.mu.Lock()
43+ ag.sessions[sid] = s
44+ ag.mu.Unlock()
45+ fmt.Fprintf(ag.log, "[acp-create] %s %s cwd=%s: the wizard starts over\n", method, sid, cwd)
46+ _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{
47+ SessionId: sid,
48+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
49+ })
50+ ag.say(ctx, sid, restarted(cwd))
51+ return nil
52+}
53+
54+func restarted(cwd string) string {
55+ return fmt.Sprintf("**mm init** restarted: the answers given before are gone, the wizard starts over in `%s` (a file already written is untouched). Send `/start` (or any message) to begin.", cwd)
56+}
new file mode 100644
@@ -0,0 +1,56 @@
1+package acpcreate
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ sdk "github.com/coder/acp-go-sdk"
8+)
9+
10+// A client's "reload agent" restarts mm, then asks the new process for the
11+// thread it shows: session/load, or session/resume when the agent offers
12+// it. The wizard keeps nothing on disk (user's choice, 2026-11-14): both
13+// start a fresh wizard under the id the client asked for, and say so. The
14+// file already written, if any, is untouched.
15+
16+// LoadSession implements session/load. There is no conversation to replay:
17+// the one message sent says the wizard starts over.
18+func (ag *agent) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) {
19+ if err := ag.restart(ctx, "session/load", p.SessionId, p.Cwd); err != nil {
20+ return sdk.LoadSessionResponse{}, err
21+ }
22+ return sdk.LoadSessionResponse{}, nil
23+}
24+
25+// ResumeSession implements session/resume: the same, the client keeping the
26+// thread on screen.
27+func (ag *agent) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
28+ if err := ag.restart(ctx, "session/resume", p.SessionId, p.Cwd); err != nil {
29+ return sdk.ResumeSessionResponse{}, err
30+ }
31+ return sdk.ResumeSessionResponse{}, nil
32+}
33+
34+// restart registers a fresh wizard as session sid, then sends the commands
35+// and the restart message. The client asked for this id, so it accepts
36+// updates for it before the response — no need to wait for it.
37+func (ag *agent) restart(ctx context.Context, method string, sid sdk.SessionId, cwd string) error {
38+ s, err := ag.newSession(cwd)
39+ if err != nil {
40+ return err
41+ }
42+ ag.mu.Lock()
43+ ag.sessions[sid] = s
44+ ag.mu.Unlock()
45+ fmt.Fprintf(ag.log, "[acp-create] %s %s cwd=%s: the wizard starts over\n", method, sid, cwd)
46+ _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{
47+ SessionId: sid,
48+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
49+ })
50+ ag.say(ctx, sid, restarted(cwd))
51+ return nil
52+}
53+
54+func restarted(cwd string) string {
55+ return fmt.Sprintf("**mm init** restarted: the answers given before are gone, the wizard starts over in `%s` (a file already written is untouched). Send `/start` (or any message) to begin.", cwd)
56+}
added internal/acpcreate/session.go +379 -0
new file mode 100644
@@ -0,0 +1,379 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "sync"
12+
13+ "mm/internal/agentinit"
14+ "mm/internal/dockerinit"
15+ "mm/internal/sbxinit"
16+
17+ sdk "github.com/coder/acp-go-sdk"
18+)
19+
20+// session is one run of the wizard. Its mutex serialises the prompts: a
21+// flow is not safe for two answers at once.
22+type session struct {
23+ mu sync.Mutex
24+ cwd string
25+ flow *agentinit.Flow
26+
27+ started bool
28+ // asked is the question shown in the chat and waiting for the next
29+ // prompt (conversation mode); "" when none.
30+ asked string
31+ // answered is the keys answered in this session, in order — what /back
32+ // takes back.
33+ answered []string
34+ // written is the file written, absolute; "" while not written.
35+ written string
36+
37+ cancelMu sync.Mutex
38+ cancel context.CancelFunc
39+}
40+
41+func (s *session) setCancel(c context.CancelFunc) {
42+ s.cancelMu.Lock()
43+ s.cancel = c
44+ s.cancelMu.Unlock()
45+}
46+
47+func (s *session) cancelTurn() {
48+ s.cancelMu.Lock()
49+ c := s.cancel
50+ s.cancelMu.Unlock()
51+ if c != nil {
52+ c()
53+ }
54+}
55+
56+// commands are the slash commands this mode handles, in the order shown.
57+var commands = []struct{ name, description, hint string }{
58+ {"start", "Start creating an agent (any message does too)", ""},
59+ {"preview", "Show the agent.yaml as it would be written now", ""},
60+ {"back", "Take back the last answer", ""},
61+ {"write", "Take the default for every question left, then write the file", ""},
62+ {"restart", "Forget every answer and start again", ""},
63+ {"dockerinit", "Write a Dockerfile and a compose.yaml to run the agent in a container (mm dockerinit)", "[--name <name>] [--agent <file>] [--overwrite]"},
64+ {"zedconfig", "Show the Zed settings that start this project's agent: here, in its container, in its Docker Sandbox", ""},
65+ {"sbxinit", "Write a sbxenv.yaml to run the agent in a Docker Sandbox (mm sbxinit)", "[--name <name>] [--workspace <dir>] [--agent <file>] [--overwrite]"},
66+ {"help", "How this wizard works", ""},
67+}
68+
69+func availableCommands() []sdk.AvailableCommand {
70+ out := make([]sdk.AvailableCommand, 0, len(commands))
71+ for _, c := range commands {
72+ cmd := sdk.AvailableCommand{Name: c.name, Description: c.description}
73+ if c.hint != "" {
74+ cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.hint}}
75+ }
76+ out = append(out, cmd)
77+ }
78+ return out
79+}
80+
81+// turn is one prompt being answered.
82+type turn struct {
83+ ag *agent
84+ s *session
85+ sid sdk.SessionId
86+ ctx context.Context
87+}
88+
89+func (t *turn) say(markdown string) { t.ag.say(t.ctx, t.sid, markdown) }
90+
91+// handle answers one prompt.
92+func (t *turn) handle(text string) {
93+ if name, args, ok := slashCommand(text); ok {
94+ t.command(name, args)
95+ return
96+ }
97+ s := t.s
98+ switch {
99+ case s.written != "":
100+ t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written))
101+ return
102+ case !s.started:
103+ t.start()
104+ return
105+ case s.asked != "":
106+ if !t.answer(text) {
107+ return
108+ }
109+ }
110+ t.next()
111+}
112+
113+// slashCommand recognises one of this mode's commands, and the words
114+// typed after it.
115+func slashCommand(text string) (string, []string, bool) {
116+ text = strings.TrimSpace(text)
117+ if !strings.HasPrefix(text, "/") {
118+ return "", nil, false
119+ }
120+ words := strings.Fields(text[1:])
121+ if len(words) == 0 {
122+ return "", nil, false
123+ }
124+ for _, c := range commands {
125+ if c.name == words[0] {
126+ return c.name, words[1:], true
127+ }
128+ }
129+ return "", nil, false
130+}
131+
132+func (t *turn) command(name string, args []string) {
133+ s := t.s
134+ switch name {
135+ case "sbxinit":
136+ t.sbxinit(args)
137+ case "dockerinit":
138+ t.dockerinit(args)
139+ case "zedconfig":
140+ t.say(zedConfig(s.cwd))
141+ case "start":
142+ t.start()
143+ case "help":
144+ t.say(help())
145+ case "preview":
146+ t.say("```yaml\n" + s.flow.Preview("", "") + "```")
147+ case "restart":
148+ fresh, err := t.ag.newSession(s.cwd)
149+ if err != nil {
150+ t.say("⚠️ " + err.Error())
151+ return
152+ }
153+ s.flow, s.asked, s.answered, s.written, s.started = fresh.flow, "", nil, "", true
154+ t.say("Starting again.")
155+ t.next()
156+ case "back":
157+ t.back()
158+ case "write":
159+ if s.written != "" {
160+ t.say(fmt.Sprintf("`%s` is already written.", s.written))
161+ return
162+ }
163+ s.started = true
164+ t.takeDefaults()
165+ t.next()
166+ }
167+}
168+
169+// scaffolder is a command that writes files in the session's project the
170+// way mm sbxinit or mm dockerinit does: in is that command run for a
171+// directory (sbxinit.In, dockerinit.In).
172+type scaffolder struct {
173+ name string // the slash command
174+ files []string // what it writes, relative to the project
175+ run func(dir string, args []string, out io.Writer, overwrite bool) int
176+ after string // said when it wrote something
177+}
178+
179+// sbxinit is mm sbxinit for the session's project: sbxenv.yaml in its cwd.
180+func (t *turn) sbxinit(args []string) {
181+ t.scaffold(scaffolder{name: "sbxinit", files: []string{"sbxenv.yaml"}, run: sbxinitIn, after: "Run the `sbx` commands from `%s`."}, args)
182+}
183+
184+// dockerinit is mm dockerinit for the session's project: Dockerfile and
185+// compose.yaml in its cwd (ticket 212).
186+func (t *turn) dockerinit(args []string) {
187+ t.scaffold(scaffolder{name: "dockerinit", files: []string{"Dockerfile", "compose.yaml"}, run: dockerinitIn, after: "Run the `docker` commands from `%s`."}, args)
188+}
189+
190+// scaffold runs c for the session's project. It needs no answer from the
191+// wizard and works at any time — after the agent file is written, it names
192+// what it writes after it. Nobody can answer "Overwrite?" here: existing
193+// files are kept unless --overwrite is given.
194+func (t *turn) scaffold(c scaffolder, args []string) {
195+ cwd := t.s.cwd
196+ overwrite := false
197+ var flags []string
198+ for _, a := range args {
199+ if a == "--overwrite" || a == "-overwrite" {
200+ overwrite = true
201+ continue
202+ }
203+ flags = append(flags, a)
204+ }
205+ if !overwrite {
206+ var existing []string
207+ for _, f := range c.files {
208+ if _, err := os.Stat(filepath.Join(cwd, f)); err == nil {
209+ existing = append(existing, "`"+f+"`")
210+ }
211+ }
212+ if len(existing) > 0 {
213+ t.say(fmt.Sprintf("%s already in `%s`: `/%s --overwrite` replaces it.", strings.Join(existing, " and "), cwd, c.name))
214+ return
215+ }
216+ }
217+ var report bytes.Buffer
218+ code := c.run(cwd, flags, &report, overwrite)
219+ msg := "```\n" + strings.TrimRight(report.String(), "\n") + "\n```"
220+ if code == 0 && strings.Contains(report.String(), "✅ wrote") {
221+ msg += "\n\n" + fmt.Sprintf(c.after, cwd)
222+ }
223+ t.say(msg)
224+}
225+
226+// dockerinitIn is dockerinit.In, beside sbxinitIn.
227+var dockerinitIn = dockerinit.In
228+
229+// sbxinitIn is sbxinit.In; the tests replace it to read no release.
230+var sbxinitIn = sbxinit.In
231+
232+// start begins the wizard, or — already begun — asks the current question
233+// again.
234+func (t *turn) start() {
235+ s := t.s
236+ if s.written != "" {
237+ t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written))
238+ return
239+ }
240+ if !s.started {
241+ s.started = true
242+ t.say(intro(s.cwd, t.ag.elicitForm))
243+ }
244+ t.next()
245+}
246+
247+// welcome is sent once the session exists, before any prompt: a client
248+// that shows it tells the user how to begin.
249+func welcome(cwd string) string {
250+ return fmt.Sprintf("**mm init** — this creates an agent.yaml in `%s`. Send `/start` (or any message) to begin.", cwd)
251+}
252+
253+// back takes back the last answer given here and asks it again.
254+func (t *turn) back() {
255+ s := t.s
256+ if len(s.answered) == 0 {
257+ t.say("Nothing to take back.")
258+ return
259+ }
260+ key := s.answered[len(s.answered)-1]
261+ s.answered = s.answered[:len(s.answered)-1]
262+ s.flow.Unset(key)
263+ s.written = ""
264+ t.next()
265+}
266+
267+// takeDefaults answers every pending question that has a default, as mm
268+// init --yes does, but stops at an existing file: replacing it is asked.
269+func (t *turn) takeDefaults() {
270+ s := t.s
271+ for {
272+ pending := s.flow.Pending()
273+ if len(pending) == 0 {
274+ return
275+ }
276+ st := pending[0]
277+ if st.Key == "overwrite" || st.Default == "" {
278+ return
279+ }
280+ if err := s.flow.Set(st.Key, st.Default); err != nil {
281+ t.say("⚠️ " + err.Error())
282+ return
283+ }
284+ s.answered = append(s.answered, st.Key)
285+ }
286+}
287+
288+// next asks what is still to ask, or writes the file when nothing is.
289+func (t *turn) next() {
290+ s := t.s
291+ s.asked = ""
292+ if t.ag.elicitForm {
293+ t.askByForms()
294+ return
295+ }
296+ pending := s.flow.Pending()
297+ if len(pending) == 0 {
298+ t.write()
299+ return
300+ }
301+ st := pending[0]
302+ s.asked = st.Key
303+ t.say(question(st, t.position(st.Key)))
304+}
305+
306+// position is "n of N", the question's place among all of them.
307+func (t *turn) position(key string) string {
308+ steps := t.s.flow.Steps()
309+ for i, st := range steps {
310+ if st.Key == key {
311+ return fmt.Sprintf("%d of %d", i+1, len(steps))
312+ }
313+ }
314+ return ""
315+}
316+
317+// answer reads the prompt as the answer to the question asked; false when
318+// it does not answer it (the question is shown again).
319+func (t *turn) answer(text string) bool {
320+ s := t.s
321+ st := s.flow.Step(s.asked)
322+ value, err := parseAnswer(st, text)
323+ if err == nil {
324+ err = s.flow.Set(st.Key, value)
325+ }
326+ if err != nil {
327+ t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key)))
328+ return false
329+ }
330+ s.answered = append(s.answered, st.Key)
331+ return true
332+}
333+
334+// write writes the file and says how to use it.
335+func (t *turn) write() {
336+ s := t.s
337+ var report bytes.Buffer
338+ output, ok := agentinit.WriteFile(s.flow, &report)
339+ if output == "-" {
340+ t.say("```yaml\n" + report.String() + "```")
341+ return
342+ }
343+ if !ok {
344+ t.say(report.String() + "\nNothing more was done: `/back` changes an answer, `/restart` starts again.")
345+ return
346+ }
347+ s.written = s.flow.Path(output)
348+ t.say(report.String() + "\n" + howToUse(s.written, t.ag.client) + "\n`/dockerinit` writes a Dockerfile and a compose.yaml to run this agent in a container, `/sbxinit` a sbxenv.yaml for a Docker Sandbox.")
349+}
350+
351+func intro(cwd string, forms bool) string {
352+ how := "I ask the questions one by one here: answer each with its number or its value, `.` for the default."
353+ if forms {
354+ how = "Your client shows forms: I ask the questions in a few of them."
355+ }
356+ return fmt.Sprintf("**mm init** — let's create an agent in `%s`.\n\n%s `/preview` shows the file as it stands, `/back` takes back an answer, `/write` takes every default left and writes it.", cwd, how)
357+}
358+
359+func help() string {
360+ var b strings.Builder
361+ b.WriteString("This is `mm init` over ACP: it writes an agent.yaml (or `.mm/agent.yaml`) in this project, then says how to start the agent.\n\n")
362+ for _, c := range commands {
363+ fmt.Fprintf(&b, "- `/%s` — %s\n", c.name, c.description)
364+ }
365+ return b.String()
366+}
367+
368+// howToUse is the closing message: the command line every client can use,
369+// plus a ready snippet for a client mm knows. The snippet is a convenience;
370+// nothing else depends on the client's name.
371+func howToUse(path, client string) string {
372+ var b strings.Builder
373+ fmt.Fprintf(&b, "Start the agent from your ACP client with the command `mm acp %s`.\n", path)
374+ if strings.Contains(strings.ToLower(client), "zed") {
375+ fmt.Fprintf(&b, "\nIn Zed's settings.json:\n\n```json\n\"agent_servers\": {\n \"%s\": {\n \"type\": \"custom\",\n \"command\": \"mm\",\n \"args\": [\"acp\", %q]\n }\n}\n```\n", entryName(path), path)
376+ }
377+ b.WriteString("\n`/zedconfig` shows every way to start it from Zed — here, in a container, in a Docker Sandbox.\n")
378+ return b.String()
379+}
new file mode 100644
@@ -0,0 +1,379 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "sync"
12+
13+ "mm/internal/agentinit"
14+ "mm/internal/dockerinit"
15+ "mm/internal/sbxinit"
16+
17+ sdk "github.com/coder/acp-go-sdk"
18+)
19+
20+// session is one run of the wizard. Its mutex serialises the prompts: a
21+// flow is not safe for two answers at once.
22+type session struct {
23+ mu sync.Mutex
24+ cwd string
25+ flow *agentinit.Flow
26+
27+ started bool
28+ // asked is the question shown in the chat and waiting for the next
29+ // prompt (conversation mode); "" when none.
30+ asked string
31+ // answered is the keys answered in this session, in order — what /back
32+ // takes back.
33+ answered []string
34+ // written is the file written, absolute; "" while not written.
35+ written string
36+
37+ cancelMu sync.Mutex
38+ cancel context.CancelFunc
39+}
40+
41+func (s *session) setCancel(c context.CancelFunc) {
42+ s.cancelMu.Lock()
43+ s.cancel = c
44+ s.cancelMu.Unlock()
45+}
46+
47+func (s *session) cancelTurn() {
48+ s.cancelMu.Lock()
49+ c := s.cancel
50+ s.cancelMu.Unlock()
51+ if c != nil {
52+ c()
53+ }
54+}
55+
56+// commands are the slash commands this mode handles, in the order shown.
57+var commands = []struct{ name, description, hint string }{
58+ {"start", "Start creating an agent (any message does too)", ""},
59+ {"preview", "Show the agent.yaml as it would be written now", ""},
60+ {"back", "Take back the last answer", ""},
61+ {"write", "Take the default for every question left, then write the file", ""},
62+ {"restart", "Forget every answer and start again", ""},
63+ {"dockerinit", "Write a Dockerfile and a compose.yaml to run the agent in a container (mm dockerinit)", "[--name <name>] [--agent <file>] [--overwrite]"},
64+ {"zedconfig", "Show the Zed settings that start this project's agent: here, in its container, in its Docker Sandbox", ""},
65+ {"sbxinit", "Write a sbxenv.yaml to run the agent in a Docker Sandbox (mm sbxinit)", "[--name <name>] [--workspace <dir>] [--agent <file>] [--overwrite]"},
66+ {"help", "How this wizard works", ""},
67+}
68+
69+func availableCommands() []sdk.AvailableCommand {
70+ out := make([]sdk.AvailableCommand, 0, len(commands))
71+ for _, c := range commands {
72+ cmd := sdk.AvailableCommand{Name: c.name, Description: c.description}
73+ if c.hint != "" {
74+ cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.hint}}
75+ }
76+ out = append(out, cmd)
77+ }
78+ return out
79+}
80+
81+// turn is one prompt being answered.
82+type turn struct {
83+ ag *agent
84+ s *session
85+ sid sdk.SessionId
86+ ctx context.Context
87+}
88+
89+func (t *turn) say(markdown string) { t.ag.say(t.ctx, t.sid, markdown) }
90+
91+// handle answers one prompt.
92+func (t *turn) handle(text string) {
93+ if name, args, ok := slashCommand(text); ok {
94+ t.command(name, args)
95+ return
96+ }
97+ s := t.s
98+ switch {
99+ case s.written != "":
100+ t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written))
101+ return
102+ case !s.started:
103+ t.start()
104+ return
105+ case s.asked != "":
106+ if !t.answer(text) {
107+ return
108+ }
109+ }
110+ t.next()
111+}
112+
113+// slashCommand recognises one of this mode's commands, and the words
114+// typed after it.
115+func slashCommand(text string) (string, []string, bool) {
116+ text = strings.TrimSpace(text)
117+ if !strings.HasPrefix(text, "/") {
118+ return "", nil, false
119+ }
120+ words := strings.Fields(text[1:])
121+ if len(words) == 0 {
122+ return "", nil, false
123+ }
124+ for _, c := range commands {
125+ if c.name == words[0] {
126+ return c.name, words[1:], true
127+ }
128+ }
129+ return "", nil, false
130+}
131+
132+func (t *turn) command(name string, args []string) {
133+ s := t.s
134+ switch name {
135+ case "sbxinit":
136+ t.sbxinit(args)
137+ case "dockerinit":
138+ t.dockerinit(args)
139+ case "zedconfig":
140+ t.say(zedConfig(s.cwd))
141+ case "start":
142+ t.start()
143+ case "help":
144+ t.say(help())
145+ case "preview":
146+ t.say("```yaml\n" + s.flow.Preview("", "") + "```")
147+ case "restart":
148+ fresh, err := t.ag.newSession(s.cwd)
149+ if err != nil {
150+ t.say("⚠️ " + err.Error())
151+ return
152+ }
153+ s.flow, s.asked, s.answered, s.written, s.started = fresh.flow, "", nil, "", true
154+ t.say("Starting again.")
155+ t.next()
156+ case "back":
157+ t.back()
158+ case "write":
159+ if s.written != "" {
160+ t.say(fmt.Sprintf("`%s` is already written.", s.written))
161+ return
162+ }
163+ s.started = true
164+ t.takeDefaults()
165+ t.next()
166+ }
167+}
168+
169+// scaffolder is a command that writes files in the session's project the
170+// way mm sbxinit or mm dockerinit does: in is that command run for a
171+// directory (sbxinit.In, dockerinit.In).
172+type scaffolder struct {
173+ name string // the slash command
174+ files []string // what it writes, relative to the project
175+ run func(dir string, args []string, out io.Writer, overwrite bool) int
176+ after string // said when it wrote something
177+}
178+
179+// sbxinit is mm sbxinit for the session's project: sbxenv.yaml in its cwd.
180+func (t *turn) sbxinit(args []string) {
181+ t.scaffold(scaffolder{name: "sbxinit", files: []string{"sbxenv.yaml"}, run: sbxinitIn, after: "Run the `sbx` commands from `%s`."}, args)
182+}
183+
184+// dockerinit is mm dockerinit for the session's project: Dockerfile and
185+// compose.yaml in its cwd (ticket 212).
186+func (t *turn) dockerinit(args []string) {
187+ t.scaffold(scaffolder{name: "dockerinit", files: []string{"Dockerfile", "compose.yaml"}, run: dockerinitIn, after: "Run the `docker` commands from `%s`."}, args)
188+}
189+
190+// scaffold runs c for the session's project. It needs no answer from the
191+// wizard and works at any time — after the agent file is written, it names
192+// what it writes after it. Nobody can answer "Overwrite?" here: existing
193+// files are kept unless --overwrite is given.
194+func (t *turn) scaffold(c scaffolder, args []string) {
195+ cwd := t.s.cwd
196+ overwrite := false
197+ var flags []string
198+ for _, a := range args {
199+ if a == "--overwrite" || a == "-overwrite" {
200+ overwrite = true
201+ continue
202+ }
203+ flags = append(flags, a)
204+ }
205+ if !overwrite {
206+ var existing []string
207+ for _, f := range c.files {
208+ if _, err := os.Stat(filepath.Join(cwd, f)); err == nil {
209+ existing = append(existing, "`"+f+"`")
210+ }
211+ }
212+ if len(existing) > 0 {
213+ t.say(fmt.Sprintf("%s already in `%s`: `/%s --overwrite` replaces it.", strings.Join(existing, " and "), cwd, c.name))
214+ return
215+ }
216+ }
217+ var report bytes.Buffer
218+ code := c.run(cwd, flags, &report, overwrite)
219+ msg := "```\n" + strings.TrimRight(report.String(), "\n") + "\n```"
220+ if code == 0 && strings.Contains(report.String(), "✅ wrote") {
221+ msg += "\n\n" + fmt.Sprintf(c.after, cwd)
222+ }
223+ t.say(msg)
224+}
225+
226+// dockerinitIn is dockerinit.In, beside sbxinitIn.
227+var dockerinitIn = dockerinit.In
228+
229+// sbxinitIn is sbxinit.In; the tests replace it to read no release.
230+var sbxinitIn = sbxinit.In
231+
232+// start begins the wizard, or — already begun — asks the current question
233+// again.
234+func (t *turn) start() {
235+ s := t.s
236+ if s.written != "" {
237+ t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written))
238+ return
239+ }
240+ if !s.started {
241+ s.started = true
242+ t.say(intro(s.cwd, t.ag.elicitForm))
243+ }
244+ t.next()
245+}
246+
247+// welcome is sent once the session exists, before any prompt: a client
248+// that shows it tells the user how to begin.
249+func welcome(cwd string) string {
250+ return fmt.Sprintf("**mm init** — this creates an agent.yaml in `%s`. Send `/start` (or any message) to begin.", cwd)
251+}
252+
253+// back takes back the last answer given here and asks it again.
254+func (t *turn) back() {
255+ s := t.s
256+ if len(s.answered) == 0 {
257+ t.say("Nothing to take back.")
258+ return
259+ }
260+ key := s.answered[len(s.answered)-1]
261+ s.answered = s.answered[:len(s.answered)-1]
262+ s.flow.Unset(key)
263+ s.written = ""
264+ t.next()
265+}
266+
267+// takeDefaults answers every pending question that has a default, as mm
268+// init --yes does, but stops at an existing file: replacing it is asked.
269+func (t *turn) takeDefaults() {
270+ s := t.s
271+ for {
272+ pending := s.flow.Pending()
273+ if len(pending) == 0 {
274+ return
275+ }
276+ st := pending[0]
277+ if st.Key == "overwrite" || st.Default == "" {
278+ return
279+ }
280+ if err := s.flow.Set(st.Key, st.Default); err != nil {
281+ t.say("⚠️ " + err.Error())
282+ return
283+ }
284+ s.answered = append(s.answered, st.Key)
285+ }
286+}
287+
288+// next asks what is still to ask, or writes the file when nothing is.
289+func (t *turn) next() {
290+ s := t.s
291+ s.asked = ""
292+ if t.ag.elicitForm {
293+ t.askByForms()
294+ return
295+ }
296+ pending := s.flow.Pending()
297+ if len(pending) == 0 {
298+ t.write()
299+ return
300+ }
301+ st := pending[0]
302+ s.asked = st.Key
303+ t.say(question(st, t.position(st.Key)))
304+}
305+
306+// position is "n of N", the question's place among all of them.
307+func (t *turn) position(key string) string {
308+ steps := t.s.flow.Steps()
309+ for i, st := range steps {
310+ if st.Key == key {
311+ return fmt.Sprintf("%d of %d", i+1, len(steps))
312+ }
313+ }
314+ return ""
315+}
316+
317+// answer reads the prompt as the answer to the question asked; false when
318+// it does not answer it (the question is shown again).
319+func (t *turn) answer(text string) bool {
320+ s := t.s
321+ st := s.flow.Step(s.asked)
322+ value, err := parseAnswer(st, text)
323+ if err == nil {
324+ err = s.flow.Set(st.Key, value)
325+ }
326+ if err != nil {
327+ t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key)))
328+ return false
329+ }
330+ s.answered = append(s.answered, st.Key)
331+ return true
332+}
333+
334+// write writes the file and says how to use it.
335+func (t *turn) write() {
336+ s := t.s
337+ var report bytes.Buffer
338+ output, ok := agentinit.WriteFile(s.flow, &report)
339+ if output == "-" {
340+ t.say("```yaml\n" + report.String() + "```")
341+ return
342+ }
343+ if !ok {
344+ t.say(report.String() + "\nNothing more was done: `/back` changes an answer, `/restart` starts again.")
345+ return
346+ }
347+ s.written = s.flow.Path(output)
348+ t.say(report.String() + "\n" + howToUse(s.written, t.ag.client) + "\n`/dockerinit` writes a Dockerfile and a compose.yaml to run this agent in a container, `/sbxinit` a sbxenv.yaml for a Docker Sandbox.")
349+}
350+
351+func intro(cwd string, forms bool) string {
352+ how := "I ask the questions one by one here: answer each with its number or its value, `.` for the default."
353+ if forms {
354+ how = "Your client shows forms: I ask the questions in a few of them."
355+ }
356+ return fmt.Sprintf("**mm init** — let's create an agent in `%s`.\n\n%s `/preview` shows the file as it stands, `/back` takes back an answer, `/write` takes every default left and writes it.", cwd, how)
357+}
358+
359+func help() string {
360+ var b strings.Builder
361+ b.WriteString("This is `mm init` over ACP: it writes an agent.yaml (or `.mm/agent.yaml`) in this project, then says how to start the agent.\n\n")
362+ for _, c := range commands {
363+ fmt.Fprintf(&b, "- `/%s` — %s\n", c.name, c.description)
364+ }
365+ return b.String()
366+}
367+
368+// howToUse is the closing message: the command line every client can use,
369+// plus a ready snippet for a client mm knows. The snippet is a convenience;
370+// nothing else depends on the client's name.
371+func howToUse(path, client string) string {
372+ var b strings.Builder
373+ fmt.Fprintf(&b, "Start the agent from your ACP client with the command `mm acp %s`.\n", path)
374+ if strings.Contains(strings.ToLower(client), "zed") {
375+ fmt.Fprintf(&b, "\nIn Zed's settings.json:\n\n```json\n\"agent_servers\": {\n \"%s\": {\n \"type\": \"custom\",\n \"command\": \"mm\",\n \"args\": [\"acp\", %q]\n }\n}\n```\n", entryName(path), path)
376+ }
377+ b.WriteString("\n`/zedconfig` shows every way to start it from Zed — here, in a container, in a Docker Sandbox.\n")
378+ return b.String()
379+}
added internal/acpcreate/zedconfig.go +109 -0
new file mode 100644
@@ -0,0 +1,109 @@
1+package acpcreate
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+
11+ "mm/internal/scaffold"
12+
13+ "gopkg.in/yaml.v3"
14+)
15+
16+// /zedconfig: the Zed agent_servers entries that start the project's agent,
17+// from what the project holds — the agent file always (mm on this machine),
18+// compose.yaml (mm dockerinit: in its container), sbxenv.yaml (mm sbxinit:
19+// in its Docker Sandbox). The commands are those of the how-to guides:
20+// docker exec and sbx exec name the agent file by its absolute path, which
21+// is the same inside, where the project is mounted at the same path.
22+
23+// server is one agent_servers entry.
24+type server struct {
25+ Type string `json:"type"`
26+ Command string `json:"command"`
27+ Args []string `json:"args"`
28+}
29+
30+// zedConfig is the /zedconfig answer for the project in cwd.
31+func zedConfig(cwd string) string {
32+ agent := scaffold.FindAgentFile("agent.yaml", cwd, false, func(p string) string { return filepath.Join(cwd, p) })
33+ if _, err := os.Stat(agent); err != nil {
34+ return fmt.Sprintf("No agent file in `%s` (neither `agent.yaml` nor `.mm/agent.yaml`): `/start` creates one.", cwd)
35+ }
36+ name := entryName(agent)
37+ servers := map[string]server{name: {Type: "custom", Command: "mm", Args: []string{"acp", agent}}}
38+ notes := []string{fmt.Sprintf("- `%s` — mm on this machine.", name)}
39+
40+ if container, ok := composeContainer(filepath.Join(cwd, "compose.yaml")); ok {
41+ key := name + " (container)"
42+ servers[key] = server{Type: "custom", Command: "docker", Args: []string{"exec", "-i", container, "mm", "-acp", "-config", agent}}
43+ notes = append(notes, fmt.Sprintf("- `%s` — in the container `%s` (compose.yaml): start it first, from `%s`: `docker compose up -d --wait`.", key, container, cwd))
44+ }
45+ if sandbox, ok := sbxName(filepath.Join(cwd, "sbxenv.yaml")); ok {
46+ key := name + " (sandbox)"
47+ servers[key] = server{Type: "custom", Command: "sbx", Args: []string{"exec", "-i", sandbox, "mm", "-acp", "-config", agent}}
48+ notes = append(notes, fmt.Sprintf("- `%s` — in the Docker Sandbox `%s` (sbxenv.yaml): create it first, from `%s`: `sbx env create --force`.", key, sandbox, cwd))
49+ }
50+
51+ data, _ := json.MarshalIndent(map[string]any{"agent_servers": servers}, "", " ")
52+ var b strings.Builder
53+ fmt.Fprintf(&b, "Zed `settings.json` for the agent `%s` — keep the entries you want:\n\n```json\n%s\n```\n\n%s\n", agent, data, strings.Join(notes, "\n"))
54+ if len(servers) == 1 {
55+ b.WriteString("\n`/dockerinit` adds a container, `/sbxinit` a Docker Sandbox: `/zedconfig` then shows their entries too.\n")
56+ }
57+ return b.String()
58+}
59+
60+// entryName names the entries after the project: the agent file's folder,
61+// or the one holding .mm/ — as the closing message does.
62+func entryName(agent string) string {
63+ dir := filepath.Dir(agent)
64+ if filepath.Base(dir) == ".mm" {
65+ dir = filepath.Dir(dir)
66+ }
67+ return "mm-" + filepath.Base(dir)
68+}
69+
70+// composeContainer reads the container name of compose.yaml's first service
71+// (mm dockerinit writes one): container_name, else the service's name.
72+func composeContainer(path string) (string, bool) {
73+ data, err := os.ReadFile(path)
74+ if err != nil {
75+ return "", false
76+ }
77+ var file struct {
78+ Services map[string]struct {
79+ ContainerName string `yaml:"container_name"`
80+ } `yaml:"services"`
81+ }
82+ if yaml.Unmarshal(data, &file) != nil || len(file.Services) == 0 {
83+ return "", false
84+ }
85+ names := make([]string, 0, len(file.Services))
86+ for n := range file.Services {
87+ names = append(names, n)
88+ }
89+ sort.Strings(names)
90+ if c := file.Services[names[0]].ContainerName; c != "" {
91+ return c, true
92+ }
93+ return names[0], true
94+}
95+
96+// sbxName reads the sandbox's name from sbxenv.yaml.
97+func sbxName(path string) (string, bool) {
98+ data, err := os.ReadFile(path)
99+ if err != nil {
100+ return "", false
101+ }
102+ var file struct {
103+ Name string `yaml:"name"`
104+ }
105+ if yaml.Unmarshal(data, &file) != nil || file.Name == "" {
106+ return "", false
107+ }
108+ return file.Name, true
109+}
new file mode 100644
@@ -0,0 +1,109 @@
1+package acpcreate
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+
11+ "mm/internal/scaffold"
12+
13+ "gopkg.in/yaml.v3"
14+)
15+
16+// /zedconfig: the Zed agent_servers entries that start the project's agent,
17+// from what the project holds — the agent file always (mm on this machine),
18+// compose.yaml (mm dockerinit: in its container), sbxenv.yaml (mm sbxinit:
19+// in its Docker Sandbox). The commands are those of the how-to guides:
20+// docker exec and sbx exec name the agent file by its absolute path, which
21+// is the same inside, where the project is mounted at the same path.
22+
23+// server is one agent_servers entry.
24+type server struct {
25+ Type string `json:"type"`
26+ Command string `json:"command"`
27+ Args []string `json:"args"`
28+}
29+
30+// zedConfig is the /zedconfig answer for the project in cwd.
31+func zedConfig(cwd string) string {
32+ agent := scaffold.FindAgentFile("agent.yaml", cwd, false, func(p string) string { return filepath.Join(cwd, p) })
33+ if _, err := os.Stat(agent); err != nil {
34+ return fmt.Sprintf("No agent file in `%s` (neither `agent.yaml` nor `.mm/agent.yaml`): `/start` creates one.", cwd)
35+ }
36+ name := entryName(agent)
37+ servers := map[string]server{name: {Type: "custom", Command: "mm", Args: []string{"acp", agent}}}
38+ notes := []string{fmt.Sprintf("- `%s` — mm on this machine.", name)}
39+
40+ if container, ok := composeContainer(filepath.Join(cwd, "compose.yaml")); ok {
41+ key := name + " (container)"
42+ servers[key] = server{Type: "custom", Command: "docker", Args: []string{"exec", "-i", container, "mm", "-acp", "-config", agent}}
43+ notes = append(notes, fmt.Sprintf("- `%s` — in the container `%s` (compose.yaml): start it first, from `%s`: `docker compose up -d --wait`.", key, container, cwd))
44+ }
45+ if sandbox, ok := sbxName(filepath.Join(cwd, "sbxenv.yaml")); ok {
46+ key := name + " (sandbox)"
47+ servers[key] = server{Type: "custom", Command: "sbx", Args: []string{"exec", "-i", sandbox, "mm", "-acp", "-config", agent}}
48+ notes = append(notes, fmt.Sprintf("- `%s` — in the Docker Sandbox `%s` (sbxenv.yaml): create it first, from `%s`: `sbx env create --force`.", key, sandbox, cwd))
49+ }
50+
51+ data, _ := json.MarshalIndent(map[string]any{"agent_servers": servers}, "", " ")
52+ var b strings.Builder
53+ fmt.Fprintf(&b, "Zed `settings.json` for the agent `%s` — keep the entries you want:\n\n```json\n%s\n```\n\n%s\n", agent, data, strings.Join(notes, "\n"))
54+ if len(servers) == 1 {
55+ b.WriteString("\n`/dockerinit` adds a container, `/sbxinit` a Docker Sandbox: `/zedconfig` then shows their entries too.\n")
56+ }
57+ return b.String()
58+}
59+
60+// entryName names the entries after the project: the agent file's folder,
61+// or the one holding .mm/ — as the closing message does.
62+func entryName(agent string) string {
63+ dir := filepath.Dir(agent)
64+ if filepath.Base(dir) == ".mm" {
65+ dir = filepath.Dir(dir)
66+ }
67+ return "mm-" + filepath.Base(dir)
68+}
69+
70+// composeContainer reads the container name of compose.yaml's first service
71+// (mm dockerinit writes one): container_name, else the service's name.
72+func composeContainer(path string) (string, bool) {
73+ data, err := os.ReadFile(path)
74+ if err != nil {
75+ return "", false
76+ }
77+ var file struct {
78+ Services map[string]struct {
79+ ContainerName string `yaml:"container_name"`
80+ } `yaml:"services"`
81+ }
82+ if yaml.Unmarshal(data, &file) != nil || len(file.Services) == 0 {
83+ return "", false
84+ }
85+ names := make([]string, 0, len(file.Services))
86+ for n := range file.Services {
87+ names = append(names, n)
88+ }
89+ sort.Strings(names)
90+ if c := file.Services[names[0]].ContainerName; c != "" {
91+ return c, true
92+ }
93+ return names[0], true
94+}
95+
96+// sbxName reads the sandbox's name from sbxenv.yaml.
97+func sbxName(path string) (string, bool) {
98+ data, err := os.ReadFile(path)
99+ if err != nil {
100+ return "", false
101+ }
102+ var file struct {
103+ Name string `yaml:"name"`
104+ }
105+ if yaml.Unmarshal(data, &file) != nil || file.Name == "" {
106+ return "", false
107+ }
108+ return file.Name, true
109+}
added internal/acpwire/acpwire.go +122 -0
new file mode 100644
@@ -0,0 +1,122 @@
1+// Package acpwire holds the two things mm's ACP agents (internal/acp, the
2+// agent front end, and internal/acpcreate, the agent.yaml wizard) need on
3+// the bytes they write, which acp-go-sdk v0.13.5 does not offer: a hook run
4+// right after a session/new response, and the elicitation scope moved from
5+// `_meta` to where the specification puts it.
6+package acpwire
7+
8+import (
9+ "bytes"
10+ "encoding/json"
11+ "io"
12+ "sync"
13+
14+ "mm/internal/elicit"
15+
16+ sdk "github.com/coder/acp-go-sdk"
17+)
18+
19+// NewWriter wraps w, the stream the SDK writes JSON-RPC lines to.
20+func NewWriter(w io.Writer) *Writer {
21+ return &Writer{Writer: w, hooks: map[sdk.SessionId]func(){}}
22+}
23+
24+// Writer is the io.Writer handed to the SDK: the real output plus one
25+// hook per session, run once the session/new response carrying that session
26+// id has been written. It exists for available_commands_update. The spec says
27+// to announce the commands right after the session is created, and the client
28+// (Zed, verified 2026-09-15) drops any session/update for a session whose
29+// session/new response it has not received yet — "Available commands: none".
30+// But the SDK writes the response only when NewSession returns, after anything
31+// the method itself sent, and offers no "after the response" callback. Watching
32+// the bytes is the one place where the order is certain.
33+type Writer struct {
34+ io.Writer
35+ mu sync.Mutex
36+ hooks map[sdk.SessionId]func()
37+}
38+
39+// AfterResponse registers hook to run — once, in its own goroutine — after the
40+// response that carries sid has been written.
41+func (w *Writer) AfterResponse(sid sdk.SessionId, hook func()) {
42+ w.mu.Lock()
43+ defer w.mu.Unlock()
44+ w.hooks[sid] = hook
45+}
46+
47+// Write forwards to the real output, then fires the hook of the session whose
48+// response this line is, if any. The hook runs in a goroutine: the SDK holds
49+// its write lock here, and the hook is about to write a notification.
50+func (w *Writer) Write(p []byte) (int, error) {
51+ // An elicitation's scope rides in _meta until the SDK can carry it
52+ // (internal/acp/elicit.go); the caller is told the length it gave.
53+ if _, err := w.Writer.Write(LiftElicitationScope(p)); err != nil {
54+ return 0, err
55+ }
56+ n := len(p)
57+ if sid, ok := ResponseSessionID(p); ok {
58+ w.mu.Lock()
59+ hook := w.hooks[sid]
60+ delete(w.hooks, sid)
61+ w.mu.Unlock()
62+ if hook != nil {
63+ go hook()
64+ }
65+ }
66+ return n, nil
67+}
68+
69+// ResponseSessionID recognises a JSON-RPC response whose result carries a
70+// sessionId — session/new (and session/load, which this agent does not
71+// offer). Notifications have no id and prompt responses no sessionId, so
72+// neither matches.
73+func ResponseSessionID(line []byte) (sdk.SessionId, bool) {
74+ var m struct {
75+ ID json.RawMessage `json:"id"`
76+ Result struct {
77+ SessionId sdk.SessionId `json:"sessionId"`
78+ } `json:"result"`
79+ }
80+ if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" {
81+ return "", false
82+ }
83+ return m.Result.SessionId, true
84+}
85+
86+// --- the scope the SDK cannot carry ------------------------------------------------
87+
88+// LiftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a
89+// session-scoped elicitation to its session (and tool call) with `sessionId`
90+// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's
91+// UnstableCreateElicitationForm has no such fields — only `_meta`. So
92+// the agents' form requests park the scope in `_meta` (elicit.ScopeMetaKey), and this
93+// copies it where the specification puts it as the line is written out. The
94+// `_meta` copy stays: an mm client relaying the question reads its tool call
95+// there, the SDK dropping the top-level fields on its side too. Any other
96+// line comes back untouched. Remove both when the SDK carries the fields.
97+func LiftElicitationScope(line []byte) []byte {
98+ if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) {
99+ return line
100+ }
101+ var msg map[string]any
102+ if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate {
103+ return line
104+ }
105+ params, _ := msg["params"].(map[string]any)
106+ meta, _ := params["_meta"].(map[string]any)
107+ scope, ok := meta[elicit.ScopeMetaKey].(map[string]any)
108+ if !ok {
109+ return line
110+ }
111+ for key, value := range scope {
112+ params[key] = value
113+ }
114+ out, err := json.Marshal(msg)
115+ if err != nil {
116+ return line
117+ }
118+ if bytes.HasSuffix(line, []byte("\n")) {
119+ out = append(out, '\n')
120+ }
121+ return out
122+}
new file mode 100644
@@ -0,0 +1,122 @@
1+// Package acpwire holds the two things mm's ACP agents (internal/acp, the
2+// agent front end, and internal/acpcreate, the agent.yaml wizard) need on
3+// the bytes they write, which acp-go-sdk v0.13.5 does not offer: a hook run
4+// right after a session/new response, and the elicitation scope moved from
5+// `_meta` to where the specification puts it.
6+package acpwire
7+
8+import (
9+ "bytes"
10+ "encoding/json"
11+ "io"
12+ "sync"
13+
14+ "mm/internal/elicit"
15+
16+ sdk "github.com/coder/acp-go-sdk"
17+)
18+
19+// NewWriter wraps w, the stream the SDK writes JSON-RPC lines to.
20+func NewWriter(w io.Writer) *Writer {
21+ return &Writer{Writer: w, hooks: map[sdk.SessionId]func(){}}
22+}
23+
24+// Writer is the io.Writer handed to the SDK: the real output plus one
25+// hook per session, run once the session/new response carrying that session
26+// id has been written. It exists for available_commands_update. The spec says
27+// to announce the commands right after the session is created, and the client
28+// (Zed, verified 2026-09-15) drops any session/update for a session whose
29+// session/new response it has not received yet — "Available commands: none".
30+// But the SDK writes the response only when NewSession returns, after anything
31+// the method itself sent, and offers no "after the response" callback. Watching
32+// the bytes is the one place where the order is certain.
33+type Writer struct {
34+ io.Writer
35+ mu sync.Mutex
36+ hooks map[sdk.SessionId]func()
37+}
38+
39+// AfterResponse registers hook to run — once, in its own goroutine — after the
40+// response that carries sid has been written.
41+func (w *Writer) AfterResponse(sid sdk.SessionId, hook func()) {
42+ w.mu.Lock()
43+ defer w.mu.Unlock()
44+ w.hooks[sid] = hook
45+}
46+
47+// Write forwards to the real output, then fires the hook of the session whose
48+// response this line is, if any. The hook runs in a goroutine: the SDK holds
49+// its write lock here, and the hook is about to write a notification.
50+func (w *Writer) Write(p []byte) (int, error) {
51+ // An elicitation's scope rides in _meta until the SDK can carry it
52+ // (internal/acp/elicit.go); the caller is told the length it gave.
53+ if _, err := w.Writer.Write(LiftElicitationScope(p)); err != nil {
54+ return 0, err
55+ }
56+ n := len(p)
57+ if sid, ok := ResponseSessionID(p); ok {
58+ w.mu.Lock()
59+ hook := w.hooks[sid]
60+ delete(w.hooks, sid)
61+ w.mu.Unlock()
62+ if hook != nil {
63+ go hook()
64+ }
65+ }
66+ return n, nil
67+}
68+
69+// ResponseSessionID recognises a JSON-RPC response whose result carries a
70+// sessionId — session/new (and session/load, which this agent does not
71+// offer). Notifications have no id and prompt responses no sessionId, so
72+// neither matches.
73+func ResponseSessionID(line []byte) (sdk.SessionId, bool) {
74+ var m struct {
75+ ID json.RawMessage `json:"id"`
76+ Result struct {
77+ SessionId sdk.SessionId `json:"sessionId"`
78+ } `json:"result"`
79+ }
80+ if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" {
81+ return "", false
82+ }
83+ return m.Result.SessionId, true
84+}
85+
86+// --- the scope the SDK cannot carry ------------------------------------------------
87+
88+// LiftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a
89+// session-scoped elicitation to its session (and tool call) with `sessionId`
90+// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's
91+// UnstableCreateElicitationForm has no such fields — only `_meta`. So
92+// the agents' form requests park the scope in `_meta` (elicit.ScopeMetaKey), and this
93+// copies it where the specification puts it as the line is written out. The
94+// `_meta` copy stays: an mm client relaying the question reads its tool call
95+// there, the SDK dropping the top-level fields on its side too. Any other
96+// line comes back untouched. Remove both when the SDK carries the fields.
97+func LiftElicitationScope(line []byte) []byte {
98+ if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) {
99+ return line
100+ }
101+ var msg map[string]any
102+ if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate {
103+ return line
104+ }
105+ params, _ := msg["params"].(map[string]any)
106+ meta, _ := params["_meta"].(map[string]any)
107+ scope, ok := meta[elicit.ScopeMetaKey].(map[string]any)
108+ if !ok {
109+ return line
110+ }
111+ for key, value := range scope {
112+ params[key] = value
113+ }
114+ out, err := json.Marshal(msg)
115+ if err != nil {
116+ return line
117+ }
118+ if bytes.HasSuffix(line, []byte("\n")) {
119+ out = append(out, '\n')
120+ }
121+ return out
122+}
modified internal/agentinit/flow.go +19 -1
@@ -86,6 +86,24 @@ type Flow struct {
8686 facts Facts
8787 factsFor string // the model the facts are about
8888 reachable map[string]bool
89+
90+ // dir is where a relative file is written ("" = the process's working
91+ // directory). mm acp-create sets it to the session's cwd: the client's
92+ // project, not the directory the client started mm in.
93+ dir string
94+}
95+
96+// SetDir makes relative files resolve from dir instead of the working
97+// directory (mm acp-create: the session's cwd).
98+func (f *Flow) SetDir(dir string) { f.dir = dir }
99+
100+// Path is where file p of the flow lives: p itself when absolute, "-" or
101+// when no dir was set, else p under the flow's dir.
102+func (f *Flow) Path(p string) string {
103+ if f.dir == "" || p == "" || p == "-" || filepath.IsAbs(p) {
104+ return p
105+ }
106+ return filepath.Join(f.dir, p)
89107 }
90108
91109 // NewFlow starts a flow; what the flags gave is marked given and never asked.
@@ -139,7 +157,7 @@ func (f *Flow) Steps() []Step {
139157
140158 // asksOverwrite says the chosen file exists, or that replacing it was agreed.
141159 func (f *Flow) asksOverwrite() bool {
142- return f.overwrite || (f.output != "" && f.output != "-" && exists(f.output))
160+ return f.overwrite || (f.output != "" && f.output != "-" && exists(f.Path(f.output)))
143161 }
144162
145163 // Overwrite says the user agreed to replace the existing output file.
@@ -86,6 +86,24 @@ type Flow struct {
86 facts Facts86 facts Facts
87 factsFor string // the model the facts are about87 factsFor string // the model the facts are about
88 reachable map[string]bool88 reachable map[string]bool
89+
90+ // dir is where a relative file is written ("" = the process's working
91+ // directory). mm acp-create sets it to the session's cwd: the client's
92+ // project, not the directory the client started mm in.
93+ dir string
94+}
95+
96+// SetDir makes relative files resolve from dir instead of the working
97+// directory (mm acp-create: the session's cwd).
98+func (f *Flow) SetDir(dir string) { f.dir = dir }
99+
100+// Path is where file p of the flow lives: p itself when absolute, "-" or
101+// when no dir was set, else p under the flow's dir.
102+func (f *Flow) Path(p string) string {
103+ if f.dir == "" || p == "" || p == "-" || filepath.IsAbs(p) {
104+ return p
105+ }
106+ return filepath.Join(f.dir, p)
89 }107 }
90 108
91 // NewFlow starts a flow; what the flags gave is marked given and never asked.109 // NewFlow starts a flow; what the flags gave is marked given and never asked.
@@ -139,7 +157,7 @@ func (f *Flow) Steps() []Step {
139 157
140 // asksOverwrite says the chosen file exists, or that replacing it was agreed.158 // asksOverwrite says the chosen file exists, or that replacing it was agreed.
141 func (f *Flow) asksOverwrite() bool {159 func (f *Flow) asksOverwrite() bool {
142- return f.overwrite || (f.output != "" && f.output != "-" && exists(f.output))160+ return f.overwrite || (f.output != "" && f.output != "-" && exists(f.Path(f.output)))
143 }161 }
144 162
145 // Overwrite says the user agreed to replace the existing output file.163 // Overwrite says the user agreed to replace the existing output file.
modified internal/agentinit/wizard.go +121 -73
@@ -52,11 +52,66 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
5252 if len(opts) > 0 {
5353 o = opts[0]
5454 }
55- fs := flag.NewFlagSet("mm init", flag.ContinueOnError)
56- fs.SetOutput(stdout)
57- var c Choices
58- var yolo, output, project, sessions, skills string
59- var check, yes, line bool
55+ a, code, ok := ParseArgs("mm init", args, stdout, true)
56+ if !ok {
57+ return code
58+ }
59+ if a.Yes && a.Output != "" && a.Output != "-" && exists(a.Output) {
60+ fmt.Fprintln(stdout, "⚠️ "+alreadyExists(a.Output).Error())
61+ return 1
62+ }
63+ ctx := context.Background()
64+ flow, err := a.Flow(ctx)
65+ if err != nil {
66+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
67+ return 1
68+ }
69+
70+ switch {
71+ case a.Yes:
72+ if err := flow.takeDefaults(); err != nil {
73+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
74+ return 1
75+ }
76+ case o.Window != nil && !a.Line && o.Terminal != nil && o.Terminal():
77+ done, err := o.Window(ctx, flow)
78+ if err != nil {
79+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
80+ return 1
81+ }
82+ if !done {
83+ fmt.Fprintln(stdout, "nothing written")
84+ return 1
85+ }
86+ default:
87+ w := &lineWizard{in: bufio.NewReader(stdin), out: stdout}
88+ if err := w.run(flow); err != nil {
89+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
90+ return 1
91+ }
92+ }
93+ return finish(flow, stdout, a.Check)
94+}
95+
96+// Args are mm init's flags, read: the answers they give, the file, and the
97+// line mode's own switches (Check, Yes, Line — not offered by mm
98+// acp-create, which has no use for them).
99+type Args struct {
100+ Choices Choices
101+ Output string
102+ Check, Yes, Line bool
103+ presets Presets
104+}
105+
106+// ParseArgs reads mm init's flags (name is the command shown by -h). With
107+// lineSwitches false, --check, --yes and --line are not offered. ok is
108+// false when the command must stop: -h (code 0), a bad flag (2) or presets
109+// that do not load (1); the reason is already written to out.
110+func ParseArgs(name string, args []string, out io.Writer, lineSwitches bool) (a Args, code int, ok bool) {
111+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
112+ fs.SetOutput(out)
113+ c := &a.Choices
114+ var yolo, project, sessions, skills string
60115 fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))
61116 fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question")
62117 fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)")
@@ -69,26 +124,25 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
69124 fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)")
70125 fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)")
71126 fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)")
72- fs.StringVar(&output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes")
73- fs.StringVar(&output, "output", "", "same as -o")
74- fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers")
75- fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default")
76- fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)")
127+ fs.StringVar(&a.Output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes")
128+ fs.StringVar(&a.Output, "output", "", "same as -o")
129+ if lineSwitches {
130+ fs.BoolVar(&a.Check, "check", false, "after writing, ask the model one question to prove it answers")
131+ fs.BoolVar(&a.Yes, "yes", false, "ask nothing: every unanswered question takes its default")
132+ fs.BoolVar(&a.Line, "line", false, "ask line by line even in a terminal (no window)")
133+ }
77134 if err := fs.Parse(args); err != nil {
78135 if errors.Is(err, flag.ErrHelp) {
79- return 0
136+ return a, 0, false
80137 }
81- return 2
138+ return a, 2, false
82139 }
83140 presets, err := LoadPresets()
84141 if err != nil {
85- fmt.Fprintln(stdout, "⚠️ "+err.Error())
86- return 1
87- }
88- if yes && output != "" && output != "-" && exists(output) {
89- fmt.Fprintln(stdout, "⚠️ "+alreadyExists(output).Error())
90- return 1
142+ fmt.Fprintln(out, "⚠️ "+err.Error())
143+ return a, 1, false
91144 }
145+ a.presets = presets
92146 if yolo != "" {
93147 on := yolo == "yes" || yolo == "true"
94148 c.Yolo = &on
@@ -101,85 +155,79 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
101155 continue
102156 }
103157 if err := setYesNo("--"+flagName, value.text, value.target); err != nil {
104- fmt.Fprintln(stdout, "⚠️ "+err.Error())
105- return 2
158+ fmt.Fprintln(out, "⚠️ "+err.Error())
159+ return a, 2, false
106160 }
107161 }
108- ctx := context.Background()
109- flow := NewFlow(ctx, presets, c, output)
110- if err := flow.validateGiven(); err != nil {
111- fmt.Fprintln(stdout, "⚠️ "+err.Error())
112- return 1
113- }
162+ return a, 0, true
163+}
114164
115- switch {
116- case yes:
117- if err := flow.takeDefaults(); err != nil {
118- fmt.Fprintln(stdout, "⚠️ "+err.Error())
119- return 1
120- }
121- case o.Window != nil && !line && o.Terminal != nil && o.Terminal():
122- done, err := o.Window(ctx, flow)
123- if err != nil {
124- fmt.Fprintln(stdout, "⚠️ "+err.Error())
125- return 1
126- }
127- if !done {
128- fmt.Fprintln(stdout, "nothing written")
129- return 1
130- }
131- default:
132- w := &lineWizard{in: bufio.NewReader(stdin), out: stdout}
133- if err := w.run(flow); err != nil {
134- fmt.Fprintln(stdout, "⚠️ "+err.Error())
135- return 1
136- }
137- }
138- return finish(flow, stdout, check)
165+// Flow starts a flow from the flags: what they gave is answered, and
166+// checked as if typed. Each call is a new flow (mm acp-create: one per
167+// session).
168+func (a Args) Flow(ctx context.Context) (*Flow, error) {
169+ flow := NewFlow(ctx, a.presets, a.Choices, a.Output)
170+ return flow, flow.validateGiven()
139171 }
140172
141173 // finish writes the file the flow describes, loads it, and checks it.
142174 func finish(flow *Flow, stdout io.Writer, check bool) int {
175+ output, ok := WriteFile(flow, stdout)
176+ if !ok {
177+ return 1
178+ }
179+ if output == "-" {
180+ return 0
181+ }
182+ if check {
183+ return checkFile(stdout, output)
184+ }
185+ fmt.Fprintln(stdout, nextLine(output))
186+ return 0
187+}
188+
189+// WriteFile writes the file the answered flow describes — or prints it, for
190+// "-" — creates the skills folder, and proves mm loads it, reporting each
191+// step on out. It returns the file as the flow names it (relative to the
192+// flow's dir, see Flow.SetDir) and whether everything went well.
193+func WriteFile(flow *Flow, out io.Writer) (string, bool) {
143194 choices, facts, output := flow.Result()
144195 if choices.Model == "" {
145- fmt.Fprintln(stdout, "⚠️ no model: nothing written")
146- return 1
196+ fmt.Fprintln(out, "⚠️ no model: nothing written")
197+ return output, false
147198 }
148199 text := Render(Decide(flow.presets, choices, facts))
149200 if output == "-" {
150- fmt.Fprint(stdout, text)
151- return 0
201+ fmt.Fprint(out, text)
202+ return output, true
152203 }
204+ path := flow.Path(output)
153205 // The Project layout's file lives in .mm/, which may not exist yet.
154206 if on(choices.Project) {
155- if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
156- fmt.Fprintln(stdout, "⚠️ "+err.Error())
157- return 1
207+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
208+ fmt.Fprintln(out, "⚠️ "+err.Error())
209+ return output, false
158210 }
159211 }
160212 write := writeNew
161213 if flow.Overwrite() {
162214 write = replaceFile
163215 }
164- if err := write(output, text); err != nil {
165- fmt.Fprintln(stdout, "⚠️ "+err.Error())
166- return 1
216+ if err := write(path, text); err != nil {
217+ fmt.Fprintln(out, "⚠️ "+err.Error())
218+ return output, false
167219 }
168- fmt.Fprintf(stdout, "✅ wrote %s\n", output)
169- if err := createSkillsFolder(choices, output, stdout); err != nil {
170- fmt.Fprintln(stdout, "⚠️ "+err.Error())
171- return 1
220+ fmt.Fprintf(out, "✅ wrote %s\n", output)
221+ if err := createSkillsFolder(choices, path, out); err != nil {
222+ fmt.Fprintln(out, "⚠️ "+err.Error())
223+ return output, false
172224 }
173- if _, err := config.Load(output); err != nil {
174- fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err)
175- return 1
225+ if _, err := config.Load(path); err != nil {
226+ fmt.Fprintf(out, "⚠️ mm does not load it: %v\n", err)
227+ return output, false
176228 }
177- fmt.Fprintln(stdout, "✅ mm loads it")
178- if check {
179- return checkFile(stdout, output)
180- }
181- fmt.Fprintln(stdout, nextLine(output))
182- return 0
229+ fmt.Fprintln(out, "✅ mm loads it")
230+ return output, true
183231 }
184232
185233 // createSkillsFolder makes the folder skillsDir names, when skills are on
@@ -52,11 +52,66 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
52 if len(opts) > 0 {52 if len(opts) > 0 {
53 o = opts[0]53 o = opts[0]
54 }54 }
55- fs := flag.NewFlagSet("mm init", flag.ContinueOnError)55+ a, code, ok := ParseArgs("mm init", args, stdout, true)
56- fs.SetOutput(stdout)56+ if !ok {
57- var c Choices57+ return code
58- var yolo, output, project, sessions, skills string58+ }
59- var check, yes, line bool59+ if a.Yes && a.Output != "" && a.Output != "-" && exists(a.Output) {
60+ fmt.Fprintln(stdout, "⚠️ "+alreadyExists(a.Output).Error())
61+ return 1
62+ }
63+ ctx := context.Background()
64+ flow, err := a.Flow(ctx)
65+ if err != nil {
66+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
67+ return 1
68+ }
69+
70+ switch {
71+ case a.Yes:
72+ if err := flow.takeDefaults(); err != nil {
73+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
74+ return 1
75+ }
76+ case o.Window != nil && !a.Line && o.Terminal != nil && o.Terminal():
77+ done, err := o.Window(ctx, flow)
78+ if err != nil {
79+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
80+ return 1
81+ }
82+ if !done {
83+ fmt.Fprintln(stdout, "nothing written")
84+ return 1
85+ }
86+ default:
87+ w := &lineWizard{in: bufio.NewReader(stdin), out: stdout}
88+ if err := w.run(flow); err != nil {
89+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
90+ return 1
91+ }
92+ }
93+ return finish(flow, stdout, a.Check)
94+}
95+
96+// Args are mm init's flags, read: the answers they give, the file, and the
97+// line mode's own switches (Check, Yes, Line — not offered by mm
98+// acp-create, which has no use for them).
99+type Args struct {
100+ Choices Choices
101+ Output string
102+ Check, Yes, Line bool
103+ presets Presets
104+}
105+
106+// ParseArgs reads mm init's flags (name is the command shown by -h). With
107+// lineSwitches false, --check, --yes and --line are not offered. ok is
108+// false when the command must stop: -h (code 0), a bad flag (2) or presets
109+// that do not load (1); the reason is already written to out.
110+func ParseArgs(name string, args []string, out io.Writer, lineSwitches bool) (a Args, code int, ok bool) {
111+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
112+ fs.SetOutput(out)
113+ c := &a.Choices
114+ var yolo, project, sessions, skills string
60 fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))115 fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))
61 fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question")116 fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question")
62 fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)")117 fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)")
@@ -69,26 +124,25 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
69 fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)")124 fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)")
70 fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)")125 fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)")
71 fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)")126 fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)")
72- fs.StringVar(&output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes")127+ fs.StringVar(&a.Output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes")
73- fs.StringVar(&output, "output", "", "same as -o")128+ fs.StringVar(&a.Output, "output", "", "same as -o")
74- fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers")129+ if lineSwitches {
75- fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default")130+ fs.BoolVar(&a.Check, "check", false, "after writing, ask the model one question to prove it answers")
76- fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)")131+ fs.BoolVar(&a.Yes, "yes", false, "ask nothing: every unanswered question takes its default")
132+ fs.BoolVar(&a.Line, "line", false, "ask line by line even in a terminal (no window)")
133+ }
77 if err := fs.Parse(args); err != nil {134 if err := fs.Parse(args); err != nil {
78 if errors.Is(err, flag.ErrHelp) {135 if errors.Is(err, flag.ErrHelp) {
79- return 0136+ return a, 0, false
80 }137 }
81- return 2138+ return a, 2, false
82 }139 }
83 presets, err := LoadPresets()140 presets, err := LoadPresets()
84 if err != nil {141 if err != nil {
85- fmt.Fprintln(stdout, "⚠️ "+err.Error())142+ fmt.Fprintln(out, "⚠️ "+err.Error())
86- return 1143+ return a, 1, false
87- }
88- if yes && output != "" && output != "-" && exists(output) {
89- fmt.Fprintln(stdout, "⚠️ "+alreadyExists(output).Error())
90- return 1
91 }144 }
145+ a.presets = presets
92 if yolo != "" {146 if yolo != "" {
93 on := yolo == "yes" || yolo == "true"147 on := yolo == "yes" || yolo == "true"
94 c.Yolo = &on148 c.Yolo = &on
@@ -101,85 +155,79 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
101 continue155 continue
102 }156 }
103 if err := setYesNo("--"+flagName, value.text, value.target); err != nil {157 if err := setYesNo("--"+flagName, value.text, value.target); err != nil {
104- fmt.Fprintln(stdout, "⚠️ "+err.Error())158+ fmt.Fprintln(out, "⚠️ "+err.Error())
105- return 2159+ return a, 2, false
106 }160 }
107 }161 }
108- ctx := context.Background()162+ return a, 0, true
109- flow := NewFlow(ctx, presets, c, output)163+}
110- if err := flow.validateGiven(); err != nil {
111- fmt.Fprintln(stdout, "⚠️ "+err.Error())
112- return 1
113- }
114 164
115- switch {165+// Flow starts a flow from the flags: what they gave is answered, and
116- case yes:166+// checked as if typed. Each call is a new flow (mm acp-create: one per
117- if err := flow.takeDefaults(); err != nil {167+// session).
118- fmt.Fprintln(stdout, "⚠️ "+err.Error())168+func (a Args) Flow(ctx context.Context) (*Flow, error) {
119- return 1169+ flow := NewFlow(ctx, a.presets, a.Choices, a.Output)
120- }170+ return flow, flow.validateGiven()
121- case o.Window != nil && !line && o.Terminal != nil && o.Terminal():
122- done, err := o.Window(ctx, flow)
123- if err != nil {
124- fmt.Fprintln(stdout, "⚠️ "+err.Error())
125- return 1
126- }
127- if !done {
128- fmt.Fprintln(stdout, "nothing written")
129- return 1
130- }
131- default:
132- w := &lineWizard{in: bufio.NewReader(stdin), out: stdout}
133- if err := w.run(flow); err != nil {
134- fmt.Fprintln(stdout, "⚠️ "+err.Error())
135- return 1
136- }
137- }
138- return finish(flow, stdout, check)
139 }171 }
140 172
141 // finish writes the file the flow describes, loads it, and checks it.173 // finish writes the file the flow describes, loads it, and checks it.
142 func finish(flow *Flow, stdout io.Writer, check bool) int {174 func finish(flow *Flow, stdout io.Writer, check bool) int {
175+ output, ok := WriteFile(flow, stdout)
176+ if !ok {
177+ return 1
178+ }
179+ if output == "-" {
180+ return 0
181+ }
182+ if check {
183+ return checkFile(stdout, output)
184+ }
185+ fmt.Fprintln(stdout, nextLine(output))
186+ return 0
187+}
188+
189+// WriteFile writes the file the answered flow describes — or prints it, for
190+// "-" — creates the skills folder, and proves mm loads it, reporting each
191+// step on out. It returns the file as the flow names it (relative to the
192+// flow's dir, see Flow.SetDir) and whether everything went well.
193+func WriteFile(flow *Flow, out io.Writer) (string, bool) {
143 choices, facts, output := flow.Result()194 choices, facts, output := flow.Result()
144 if choices.Model == "" {195 if choices.Model == "" {
145- fmt.Fprintln(stdout, "⚠️ no model: nothing written")196+ fmt.Fprintln(out, "⚠️ no model: nothing written")
146- return 1197+ return output, false
147 }198 }
148 text := Render(Decide(flow.presets, choices, facts))199 text := Render(Decide(flow.presets, choices, facts))
149 if output == "-" {200 if output == "-" {
150- fmt.Fprint(stdout, text)201+ fmt.Fprint(out, text)
151- return 0202+ return output, true
152 }203 }
204+ path := flow.Path(output)
153 // The Project layout's file lives in .mm/, which may not exist yet.205 // The Project layout's file lives in .mm/, which may not exist yet.
154 if on(choices.Project) {206 if on(choices.Project) {
155- if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {207+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
156- fmt.Fprintln(stdout, "⚠️ "+err.Error())208+ fmt.Fprintln(out, "⚠️ "+err.Error())
157- return 1209+ return output, false
158 }210 }
159 }211 }
160 write := writeNew212 write := writeNew
161 if flow.Overwrite() {213 if flow.Overwrite() {
162 write = replaceFile214 write = replaceFile
163 }215 }
164- if err := write(output, text); err != nil {216+ if err := write(path, text); err != nil {
165- fmt.Fprintln(stdout, "⚠️ "+err.Error())217+ fmt.Fprintln(out, "⚠️ "+err.Error())
166- return 1218+ return output, false
167 }219 }
168- fmt.Fprintf(stdout, "✅ wrote %s\n", output)220+ fmt.Fprintf(out, "✅ wrote %s\n", output)
169- if err := createSkillsFolder(choices, output, stdout); err != nil {221+ if err := createSkillsFolder(choices, path, out); err != nil {
170- fmt.Fprintln(stdout, "⚠️ "+err.Error())222+ fmt.Fprintln(out, "⚠️ "+err.Error())
171- return 1223+ return output, false
172 }224 }
173- if _, err := config.Load(output); err != nil {225+ if _, err := config.Load(path); err != nil {
174- fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err)226+ fmt.Fprintf(out, "⚠️ mm does not load it: %v\n", err)
175- return 1227+ return output, false
176 }228 }
177- fmt.Fprintln(stdout, "✅ mm loads it")229+ fmt.Fprintln(out, "✅ mm loads it")
178- if check {230+ return output, true
179- return checkFile(stdout, output)
180- }
181- fmt.Fprintln(stdout, nextLine(output))
182- return 0
183 }231 }
184 232
185 // createSkillsFolder makes the folder skillsDir names, when skills are on233 // createSkillsFolder makes the folder skillsDir names, when skills are on
modified internal/dockerinit/dockerinit_test.go +119 -0
@@ -236,3 +236,122 @@ func TestMainRefusesArguments(t *testing.T) {
236236 t.Errorf("-h: exit %d", code)
237237 }
238238 }
239+
240+// mmProject runs mm dockerinit in a new project whose agent is
241+// .mm/agent.yaml, as mm init --project yes writes it.
242+func mmProject(t *testing.T, agent string, args ...string) (string, int, string) {
243+ t.Helper()
244+ dir := t.TempDir()
245+ t.Chdir(dir)
246+ if err := os.MkdirAll(".mm", 0o755); err != nil {
247+ t.Fatal(err)
248+ }
249+ if err := os.WriteFile(".mm/agent.yaml", []byte(agent), 0o600); err != nil {
250+ t.Fatal(err)
251+ }
252+ var stdout bytes.Buffer
253+ code := Main(args, strings.NewReader(""), &stdout)
254+ return dir, code, stdout.String()
255+}
256+
257+// A .mm project (ticket 212): the agent is found with no flag and names the
258+// service; .mm/ is mounted read-only, the folders mm writes in writable
259+// again (and created, so that Docker does not create them as root); mm
260+// starts with no file argument.
261+func TestMainKnowsTheMMFolder(t *testing.T) {
262+ agent := "acp:\n name: Bob The Bot\nsessions:\n enabled: true\n dir: .mm/sessions\ntrace:\n enabled: true\n dir: .mm/trace\n"
263+ _, code, out := mmProject(t, agent)
264+ if code != 0 {
265+ t.Fatalf("exit %d:\n%s", code, out)
266+ }
267+ var parsed composeFile
268+ if err := yaml.Unmarshal([]byte(read(t, "compose.yaml")), &parsed); err != nil {
269+ t.Fatal(err)
270+ }
271+ service, ok := parsed.Services["bob-the-bot"]
272+ if !ok {
273+ t.Fatalf("the service is not named after the agent:\n%s", read(t, "compose.yaml"))
274+ }
275+ want := []string{
276+ ".:${PWD:-/workspace}",
277+ "./.mm:${PWD:-/workspace}/.mm:ro",
278+ "./.mm/sessions:${PWD:-/workspace}/.mm/sessions",
279+ "./.mm/trace:${PWD:-/workspace}/.mm/trace",
280+ }
281+ if strings.Join(service.Volumes, " | ") != strings.Join(want, " | ") {
282+ t.Errorf("volumes:\n got %v\nwant %v", service.Volumes, want)
283+ }
284+ for _, dir := range []string{".mm/sessions", ".mm/trace"} {
285+ if info, err := os.Stat(dir); err != nil || !info.IsDir() {
286+ t.Errorf("%s was not created", dir)
287+ }
288+ }
289+ for _, s := range []string{"exec bob-the-bot mm tui\n", "run --rm bob-the-bot mm tui\n", "./.mm/sessions (sessions.dir)", "✅ created ./.mm/sessions"} {
290+ if !strings.Contains(out, s) {
291+ t.Errorf("output lacks %q:\n%s", s, out)
292+ }
293+ }
294+ if strings.Contains(out, "No agent.yaml") {
295+ t.Errorf("the .mm agent was not found:\n%s", out)
296+ }
297+ if text := read(t, "compose.yaml"); !strings.Contains(text, "exec bob-the-bot mm tui\n") || strings.Contains(text, "agent.yaml\n# docker") {
298+ t.Errorf("the compose comments do not start mm with no file:\n%s", text)
299+ }
300+}
301+
302+// Sessions off: .mm/ read-only and nothing writable again.
303+func TestMainMMFolderWithoutSessions(t *testing.T) {
304+ _, code, out := mmProject(t, "acp:\n name: lucy\n")
305+ if code != 0 || !strings.Contains(out, "writable again: nothing") {
306+ t.Fatalf("exit %d:\n%s", code, out)
307+ }
308+ if text := read(t, "compose.yaml"); !strings.Contains(text, "./.mm:${PWD:-/workspace}/.mm:ro") || strings.Contains(text, ".mm/sessions") {
309+ t.Errorf("compose.yaml:\n%s", text)
310+ }
311+}
312+
313+// Another file in .mm/ is named in the commands: mm finds only agent.yaml.
314+func TestMainMMFolderOtherAgentFile(t *testing.T) {
315+ _, code, out := mmProject(t, "acp:\n name: lucy\n", "--agent", ".mm/agent.yaml")
316+ if code != 0 || !strings.Contains(out, "exec lucy mm tui\n") {
317+ t.Fatalf("exit %d:\n%s", code, out)
318+ }
319+ if err := os.WriteFile(".mm/other.yaml", []byte("acp:\n name: max\n"), 0o600); err != nil {
320+ t.Fatal(err)
321+ }
322+ var stdout bytes.Buffer
323+ if code := Main([]string{"--agent", ".mm/other.yaml", "--yes"}, strings.NewReader(""), &stdout); code != 1 {
324+ t.Fatalf("--yes over existing files: exit %d:\n%s", code, stdout.String())
325+ }
326+ stdout.Reset()
327+ if code := In(".", []string{"--agent", ".mm/other.yaml"}, &stdout, true); code != 0 || !strings.Contains(stdout.String(), "exec max mm tui ./.mm/other.yaml") {
328+ t.Errorf("exit %d:\n%s", code, stdout.String())
329+ }
330+}
331+
332+// A plain project gets the same files as before ticket 212.
333+func TestMainPlainProjectUnchanged(t *testing.T) {
334+ code, _ := runIn(t, map[string]string{"agent.yaml": "acp:\n name: bob\n"}, "")
335+ if code != 0 {
336+ t.Fatal(code)
337+ }
338+ if read(t, "compose.yaml") != Compose("bob", os.Getuid(), os.Getgid()) {
339+ t.Error("a plain project's compose.yaml changed")
340+ }
341+}
342+
343+// In writes in the given directory, not in the working directory.
344+func TestInWritesInTheGivenDirectory(t *testing.T) {
345+ t.Chdir(t.TempDir())
346+ dir := t.TempDir()
347+ var out bytes.Buffer
348+ if code := In(dir, []string{"--name", "zoe"}, &out, false); code != 0 {
349+ t.Fatalf("exit %d:\n%s", code, out.String())
350+ }
351+ if _, err := os.Stat(filepath.Join(dir, "compose.yaml")); err != nil {
352+ t.Error("compose.yaml is not in the given directory")
353+ }
354+ if _, err := os.Stat("compose.yaml"); err == nil {
355+ t.Error("compose.yaml was written in the working directory")
356+ }
357+}
@@ -236,3 +236,122 @@ func TestMainRefusesArguments(t *testing.T) {
236 t.Errorf("-h: exit %d", code)236 t.Errorf("-h: exit %d", code)
237 }237 }
238 }238 }
239+
240+// mmProject runs mm dockerinit in a new project whose agent is
241+// .mm/agent.yaml, as mm init --project yes writes it.
242+func mmProject(t *testing.T, agent string, args ...string) (string, int, string) {
243+ t.Helper()
244+ dir := t.TempDir()
245+ t.Chdir(dir)
246+ if err := os.MkdirAll(".mm", 0o755); err != nil {
247+ t.Fatal(err)
248+ }
249+ if err := os.WriteFile(".mm/agent.yaml", []byte(agent), 0o600); err != nil {
250+ t.Fatal(err)
251+ }
252+ var stdout bytes.Buffer
253+ code := Main(args, strings.NewReader(""), &stdout)
254+ return dir, code, stdout.String()
255+}
256+
257+// A .mm project (ticket 212): the agent is found with no flag and names the
258+// service; .mm/ is mounted read-only, the folders mm writes in writable
259+// again (and created, so that Docker does not create them as root); mm
260+// starts with no file argument.
261+func TestMainKnowsTheMMFolder(t *testing.T) {
262+ agent := "acp:\n name: Bob The Bot\nsessions:\n enabled: true\n dir: .mm/sessions\ntrace:\n enabled: true\n dir: .mm/trace\n"
263+ _, code, out := mmProject(t, agent)
264+ if code != 0 {
265+ t.Fatalf("exit %d:\n%s", code, out)
266+ }
267+ var parsed composeFile
268+ if err := yaml.Unmarshal([]byte(read(t, "compose.yaml")), &parsed); err != nil {
269+ t.Fatal(err)
270+ }
271+ service, ok := parsed.Services["bob-the-bot"]
272+ if !ok {
273+ t.Fatalf("the service is not named after the agent:\n%s", read(t, "compose.yaml"))
274+ }
275+ want := []string{
276+ ".:${PWD:-/workspace}",
277+ "./.mm:${PWD:-/workspace}/.mm:ro",
278+ "./.mm/sessions:${PWD:-/workspace}/.mm/sessions",
279+ "./.mm/trace:${PWD:-/workspace}/.mm/trace",
280+ }
281+ if strings.Join(service.Volumes, " | ") != strings.Join(want, " | ") {
282+ t.Errorf("volumes:\n got %v\nwant %v", service.Volumes, want)
283+ }
284+ for _, dir := range []string{".mm/sessions", ".mm/trace"} {
285+ if info, err := os.Stat(dir); err != nil || !info.IsDir() {
286+ t.Errorf("%s was not created", dir)
287+ }
288+ }
289+ for _, s := range []string{"exec bob-the-bot mm tui\n", "run --rm bob-the-bot mm tui\n", "./.mm/sessions (sessions.dir)", "✅ created ./.mm/sessions"} {
290+ if !strings.Contains(out, s) {
291+ t.Errorf("output lacks %q:\n%s", s, out)
292+ }
293+ }
294+ if strings.Contains(out, "No agent.yaml") {
295+ t.Errorf("the .mm agent was not found:\n%s", out)
296+ }
297+ if text := read(t, "compose.yaml"); !strings.Contains(text, "exec bob-the-bot mm tui\n") || strings.Contains(text, "agent.yaml\n# docker") {
298+ t.Errorf("the compose comments do not start mm with no file:\n%s", text)
299+ }
300+}
301+
302+// Sessions off: .mm/ read-only and nothing writable again.
303+func TestMainMMFolderWithoutSessions(t *testing.T) {
304+ _, code, out := mmProject(t, "acp:\n name: lucy\n")
305+ if code != 0 || !strings.Contains(out, "writable again: nothing") {
306+ t.Fatalf("exit %d:\n%s", code, out)
307+ }
308+ if text := read(t, "compose.yaml"); !strings.Contains(text, "./.mm:${PWD:-/workspace}/.mm:ro") || strings.Contains(text, ".mm/sessions") {
309+ t.Errorf("compose.yaml:\n%s", text)
310+ }
311+}
312+
313+// Another file in .mm/ is named in the commands: mm finds only agent.yaml.
314+func TestMainMMFolderOtherAgentFile(t *testing.T) {
315+ _, code, out := mmProject(t, "acp:\n name: lucy\n", "--agent", ".mm/agent.yaml")
316+ if code != 0 || !strings.Contains(out, "exec lucy mm tui\n") {
317+ t.Fatalf("exit %d:\n%s", code, out)
318+ }
319+ if err := os.WriteFile(".mm/other.yaml", []byte("acp:\n name: max\n"), 0o600); err != nil {
320+ t.Fatal(err)
321+ }
322+ var stdout bytes.Buffer
323+ if code := Main([]string{"--agent", ".mm/other.yaml", "--yes"}, strings.NewReader(""), &stdout); code != 1 {
324+ t.Fatalf("--yes over existing files: exit %d:\n%s", code, stdout.String())
325+ }
326+ stdout.Reset()
327+ if code := In(".", []string{"--agent", ".mm/other.yaml"}, &stdout, true); code != 0 || !strings.Contains(stdout.String(), "exec max mm tui ./.mm/other.yaml") {
328+ t.Errorf("exit %d:\n%s", code, stdout.String())
329+ }
330+}
331+
332+// A plain project gets the same files as before ticket 212.
333+func TestMainPlainProjectUnchanged(t *testing.T) {
334+ code, _ := runIn(t, map[string]string{"agent.yaml": "acp:\n name: bob\n"}, "")
335+ if code != 0 {
336+ t.Fatal(code)
337+ }
338+ if read(t, "compose.yaml") != Compose("bob", os.Getuid(), os.Getgid()) {
339+ t.Error("a plain project's compose.yaml changed")
340+ }
341+}
342+
343+// In writes in the given directory, not in the working directory.
344+func TestInWritesInTheGivenDirectory(t *testing.T) {
345+ t.Chdir(t.TempDir())
346+ dir := t.TempDir()
347+ var out bytes.Buffer
348+ if code := In(dir, []string{"--name", "zoe"}, &out, false); code != 0 {
349+ t.Fatalf("exit %d:\n%s", code, out.String())
350+ }
351+ if _, err := os.Stat(filepath.Join(dir, "compose.yaml")); err != nil {
352+ t.Error("compose.yaml is not in the given directory")
353+ }
354+ if _, err := os.Stat("compose.yaml"); err == nil {
355+ t.Error("compose.yaml was written in the working directory")
356+ }
357+}
modified internal/dockerinit/files.go +44 -5
@@ -3,7 +3,13 @@
33 // file.
44 package dockerinit
55
6-import "fmt"
6+import (
7+ "fmt"
8+ "path/filepath"
9+ "strings"
10+
11+ "mm/internal/scaffold"
12+)
713
814 // fallbackID is the uid and gid in the compose file when the machine has no
915 // usable one: Windows has none, and root must never be the default.
@@ -70,15 +76,26 @@ func Dockerfile() string { return dockerfile }
7076 //
7177 // dockerinit.Compose(scaffold.Name("Bob"), os.Getuid(), os.Getgid())
7278 func Compose(name string, uid, gid int) string {
79+ return compose(name, uid, gid, nil)
80+}
81+
82+// compose is Compose for a project, with the mounts of a .mm project
83+// (ticket 212) when mm is set: .mm/ read-only over the project, the folders
84+// mm writes in writable again — the same rules as mm sbxinit.
85+func compose(name string, uid, gid int, mm *scaffold.MMFolder) string {
86+ start := "mm tui agent.yaml"
87+ if mm != nil {
88+ start = tuiCommand(startArg(mm))
89+ }
7390 return fmt.Sprintf(`# %[1]s: mm in a container. Written by `+"`mm dockerinit`"+`.
7491 # %[2]s
7592 #
7693 # Keep a container running, for the terminal and for an editor (ACP):
7794 # docker compose up -d --wait
78-# docker compose exec %[1]s mm tui agent.yaml
95+# docker compose exec %[1]s %[5]s
7996 #
8097 # Or one session, in a container that goes away with it:
81-# docker compose run --rm %[1]s mm tui agent.yaml
98+# docker compose run --rm %[1]s %[5]s
8299 services:
83100 %[1]s:
84101 build: .
@@ -92,13 +109,35 @@ services:
92109 working_dir: ${PWD:-/workspace}
93110 volumes:
94111 - .:${PWD:-/workspace}
95- stdin_open: true
112+%[6]s stdin_open: true
96113 tty: true
97114 # A model served on this machine. Docker Desktop knows this name; this line
98115 # makes Linux know it too.
99116 extra_hosts:
100117 - host.docker.internal:host-gateway
101-`, name, howToURL, usableID(uid), usableID(gid))
118+`, name, howToURL, usableID(uid), usableID(gid), start, mmVolumes(mm))
119+}
120+
121+// mmVolumes is the volume lines a .mm project adds after the project's.
122+func mmVolumes(mm *scaffold.MMFolder) string {
123+ if mm == nil {
124+ return ""
125+ }
126+ inside := func(path string) string {
127+ return "${PWD:-/workspace}/" + strings.TrimPrefix(mm.Rel(path), "./")
128+ }
129+ var b strings.Builder
130+ b.WriteString(` # The agent is .mm/agent.yaml (mm init --project yes). .mm/ is read-only:
131+ # the agent cannot change its own settings, skills or hooks. The folders
132+ # mm writes in are writable again, with the paths of the agent file;
133+ # anything else written under .mm/ (a hook's log) needs a line of its own.
134+`)
135+ agentDir := filepath.Dir(mm.Agent)
136+ fmt.Fprintf(&b, " - %s:%s:ro\n", mm.Rel(agentDir), inside(agentDir))
137+ for _, w := range mm.Writable {
138+ fmt.Fprintf(&b, " - %s:%s # %s\n", mm.Rel(w.Path), inside(w.Path), w.Key)
139+ }
140+ return b.String()
102141 }
103142
104143 // usableID keeps the compose file's default from ever being root.
@@ -3,7 +3,13 @@
3 // file.3 // file.
4 package dockerinit4 package dockerinit
5 5
6-import "fmt"6+import (
7+ "fmt"
8+ "path/filepath"
9+ "strings"
10+
11+ "mm/internal/scaffold"
12+)
7 13
8 // fallbackID is the uid and gid in the compose file when the machine has no14 // fallbackID is the uid and gid in the compose file when the machine has no
9 // usable one: Windows has none, and root must never be the default.15 // usable one: Windows has none, and root must never be the default.
@@ -70,15 +76,26 @@ func Dockerfile() string { return dockerfile }
70 //76 //
71 // dockerinit.Compose(scaffold.Name("Bob"), os.Getuid(), os.Getgid())77 // dockerinit.Compose(scaffold.Name("Bob"), os.Getuid(), os.Getgid())
72 func Compose(name string, uid, gid int) string {78 func Compose(name string, uid, gid int) string {
79+ return compose(name, uid, gid, nil)
80+}
81+
82+// compose is Compose for a project, with the mounts of a .mm project
83+// (ticket 212) when mm is set: .mm/ read-only over the project, the folders
84+// mm writes in writable again — the same rules as mm sbxinit.
85+func compose(name string, uid, gid int, mm *scaffold.MMFolder) string {
86+ start := "mm tui agent.yaml"
87+ if mm != nil {
88+ start = tuiCommand(startArg(mm))
89+ }
73 return fmt.Sprintf(`# %[1]s: mm in a container. Written by `+"`mm dockerinit`"+`.90 return fmt.Sprintf(`# %[1]s: mm in a container. Written by `+"`mm dockerinit`"+`.
74 # %[2]s91 # %[2]s
75 #92 #
76 # Keep a container running, for the terminal and for an editor (ACP):93 # Keep a container running, for the terminal and for an editor (ACP):
77 # docker compose up -d --wait94 # docker compose up -d --wait
78-# docker compose exec %[1]s mm tui agent.yaml95+# docker compose exec %[1]s %[5]s
79 #96 #
80 # Or one session, in a container that goes away with it:97 # Or one session, in a container that goes away with it:
81-# docker compose run --rm %[1]s mm tui agent.yaml98+# docker compose run --rm %[1]s %[5]s
82 services:99 services:
83 %[1]s:100 %[1]s:
84 build: .101 build: .
@@ -92,13 +109,35 @@ services:
92 working_dir: ${PWD:-/workspace}109 working_dir: ${PWD:-/workspace}
93 volumes:110 volumes:
94 - .:${PWD:-/workspace}111 - .:${PWD:-/workspace}
95- stdin_open: true112+%[6]s stdin_open: true
96 tty: true113 tty: true
97 # A model served on this machine. Docker Desktop knows this name; this line114 # A model served on this machine. Docker Desktop knows this name; this line
98 # makes Linux know it too.115 # makes Linux know it too.
99 extra_hosts:116 extra_hosts:
100 - host.docker.internal:host-gateway117 - host.docker.internal:host-gateway
101-`, name, howToURL, usableID(uid), usableID(gid))118+`, name, howToURL, usableID(uid), usableID(gid), start, mmVolumes(mm))
119+}
120+
121+// mmVolumes is the volume lines a .mm project adds after the project's.
122+func mmVolumes(mm *scaffold.MMFolder) string {
123+ if mm == nil {
124+ return ""
125+ }
126+ inside := func(path string) string {
127+ return "${PWD:-/workspace}/" + strings.TrimPrefix(mm.Rel(path), "./")
128+ }
129+ var b strings.Builder
130+ b.WriteString(` # The agent is .mm/agent.yaml (mm init --project yes). .mm/ is read-only:
131+ # the agent cannot change its own settings, skills or hooks. The folders
132+ # mm writes in are writable again, with the paths of the agent file;
133+ # anything else written under .mm/ (a hook's log) needs a line of its own.
134+`)
135+ agentDir := filepath.Dir(mm.Agent)
136+ fmt.Fprintf(&b, " - %s:%s:ro\n", mm.Rel(agentDir), inside(agentDir))
137+ for _, w := range mm.Writable {
138+ fmt.Fprintf(&b, " - %s:%s # %s\n", mm.Rel(w.Path), inside(w.Path), w.Key)
139+ }
140+ return b.String()
102 }141 }
103 142
104 // usableID keeps the compose file's default from ever being root.143 // usableID keeps the compose file's default from ever being root.
modified internal/dockerinit/main.go +113 -10
@@ -6,7 +6,10 @@ import (
66 "fmt"
77 "io"
88 "os"
9+ "path/filepath"
10+ "strings"
911
12+ "mm/internal/config"
1013 "mm/internal/scaffold"
1114 )
1215
@@ -18,10 +21,39 @@ import (
1821 // mm dockerinit
1922 // mm dockerinit --name bob --yes
2023 func Main(args []string, stdin io.Reader, stdout io.Writer) int {
24+ return run(args, stdin, stdout, place{})
25+}
26+
27+// In is `mm dockerinit` run for another directory than the process's: dir
28+// stands for the current directory — where the files are written and the
29+// agent file is looked for. Nothing is asked: existing files are replaced
30+// when overwrite is set, kept otherwise (exit 1). mm acp-create's
31+// /dockerinit uses it with the session's cwd.
32+//
33+// code := dockerinit.In("/home/me/project", []string{"--name", "bob"}, &out, false)
34+func In(dir string, args []string, out io.Writer, overwrite bool) int {
35+ return run(args, strings.NewReader(""), out, place{dir: dir, overwrite: overwrite})
36+}
37+
38+// place is where run works: dir instead of the working directory when set,
39+// and whether existing files may be replaced without asking.
40+type place struct {
41+ dir string
42+ overwrite bool
43+}
44+
45+func (p place) resolve(path string) string {
46+ if p.dir == "" || filepath.IsAbs(path) {
47+ return path
48+ }
49+ return filepath.Join(p.dir, path)
50+}
51+
52+func run(args []string, stdin io.Reader, stdout io.Writer, at place) int {
2153 fs := flag.NewFlagSet("mm dockerinit", flag.ContinueOnError)
2254 fs.SetOutput(stdout)
2355 name := fs.String("name", "", "the service's name (default: acp.name in the agent file, else mm)")
24- agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start")
56+ agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start (default: agent.yaml, then .mm/agent.yaml)")
2557 yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten")
2658 if err := fs.Parse(args); err != nil {
2759 if errors.Is(err, flag.ErrHelp) {
@@ -34,14 +66,32 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int {
3466 return 2
3567 }
3668
69+ // Absolute: the .mm folder is recognised by comparing absolute paths.
70+ project, err := filepath.Abs(at.resolve("."))
71+ if err != nil {
72+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
73+ return 1
74+ }
75+ explicit := false
76+ fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" })
77+ agentPath := scaffold.FindAgentFile(*agentFile, project, explicit, at.resolve)
78+ // A .mm project (ticket 212): its paths are written relative to the
79+ // folder of compose.yaml, the project, as compose reads them.
80+ mm := scaffold.MMFolderOf(agentPath, project)
81+ start := *agentFile
82+ if mm != nil {
83+ mm.Base = project
84+ start = startArg(mm)
85+ }
86+
3787 chosen := *name
3888 if chosen == "" {
39- chosen = scaffold.AgentName(*agentFile, stdout)
89+ chosen = scaffold.AgentName(agentPath, stdout)
4090 }
4191 service := scaffold.Name(chosen)
4292 files := []scaffold.File{
43- {Path: "Dockerfile", Text: Dockerfile()},
44- {Path: "compose.yaml", Text: Compose(service, os.Getuid(), os.Getgid())},
93+ {Path: at.resolve("Dockerfile"), Text: Dockerfile(), Replace: at.overwrite},
94+ {Path: at.resolve("compose.yaml"), Text: compose(service, os.Getuid(), os.Getgid(), mm), Replace: at.overwrite},
4595 }
4696 wrote, err := scaffold.Write(files, stdin, stdout, *yes)
4797 if err != nil {
@@ -49,17 +99,70 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int {
4999 return 1
50100 }
51101 if wrote {
52- printNext(stdout, service, *agentFile)
102+ if mm != nil {
103+ reportMMFolder(stdout, mm)
104+ }
105+ printNext(stdout, service, start, missing(agentPath, *agentFile))
53106 }
54107 return 0
55108 }
56109
110+// startArg is what follows `mm tui` for a .mm project: nothing for
111+// .mm/agent.yaml, which mm finds on its own from the project (mm 1.0.7 and
112+// later; the image holds the latest release), the file otherwise.
113+func startArg(mm *scaffold.MMFolder) string {
114+ if filepath.Base(mm.Agent) == config.DefaultPath {
115+ return ""
116+ }
117+ return mm.Rel(mm.Agent)
118+}
119+
120+// missing is the agent file's name when it does not exist, "" otherwise.
121+func missing(agentPath, asGiven string) string {
122+ if _, err := os.Stat(agentPath); err != nil {
123+ return asGiven
124+ }
125+ return ""
126+}
127+
128+// reportMMFolder says what the .mm project's mounts are, and creates the
129+// writable folders that do not exist yet: Docker would create them as root,
130+// and the agent, which never runs as root, could not write in them.
131+func reportMMFolder(out io.Writer, mm *scaffold.MMFolder) {
132+ fmt.Fprintf(out, "\nThe agent is %s: .mm/ is mounted read-only, writable again:", mm.Rel(mm.Agent))
133+ if len(mm.Writable) == 0 {
134+ fmt.Fprint(out, " nothing (no sessions, no trace)")
135+ }
136+ for _, w := range mm.Writable {
137+ fmt.Fprintf(out, " %s (%s)", mm.Rel(w.Path), w.Key)
138+ }
139+ fmt.Fprintln(out)
140+ for _, w := range mm.Writable {
141+ if _, err := os.Stat(w.Path); err == nil {
142+ continue
143+ }
144+ if err := os.MkdirAll(w.Path, 0o755); err != nil {
145+ fmt.Fprintf(out, "⚠️ cannot create %s: %v — create it before starting the container\n", mm.Rel(w.Path), err)
146+ continue
147+ }
148+ fmt.Fprintf(out, "✅ created %s\n", mm.Rel(w.Path))
149+ }
150+}
151+
57152 // printNext says what to run, with the agent file only if there is one.
58-func printNext(out io.Writer, service, agentFile string) {
59- if _, err := os.Stat(agentFile); err != nil {
60- fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", agentFile)
153+func printNext(out io.Writer, service, start, missing string) {
154+ if missing != "" {
155+ fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", missing)
61156 }
62- fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s mm tui %[2]s\n", service, agentFile)
63- fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s mm tui %[2]s\n", service, agentFile)
157+ fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s %[2]s\n", service, tuiCommand(start))
158+ fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s %[2]s\n", service, tuiCommand(start))
64159 fmt.Fprintf(out, "\nFor both architectures:\n docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/mm-%s:latest --push .\n", service)
65160 }
161+
162+// tuiCommand is `mm tui`, with the agent file when one is needed.
163+func tuiCommand(agent string) string {
164+ if agent == "" {
165+ return "mm tui"
166+ }
167+ return "mm tui " + agent
168+}
@@ -6,7 +6,10 @@ import (
6 "fmt"6 "fmt"
7 "io"7 "io"
8 "os"8 "os"
9+ "path/filepath"
10+ "strings"
9 11
12+ "mm/internal/config"
10 "mm/internal/scaffold"13 "mm/internal/scaffold"
11 )14 )
12 15
@@ -18,10 +21,39 @@ import (
18 // mm dockerinit21 // mm dockerinit
19 // mm dockerinit --name bob --yes22 // mm dockerinit --name bob --yes
20 func Main(args []string, stdin io.Reader, stdout io.Writer) int {23 func Main(args []string, stdin io.Reader, stdout io.Writer) int {
24+ return run(args, stdin, stdout, place{})
25+}
26+
27+// In is `mm dockerinit` run for another directory than the process's: dir
28+// stands for the current directory — where the files are written and the
29+// agent file is looked for. Nothing is asked: existing files are replaced
30+// when overwrite is set, kept otherwise (exit 1). mm acp-create's
31+// /dockerinit uses it with the session's cwd.
32+//
33+// code := dockerinit.In("/home/me/project", []string{"--name", "bob"}, &out, false)
34+func In(dir string, args []string, out io.Writer, overwrite bool) int {
35+ return run(args, strings.NewReader(""), out, place{dir: dir, overwrite: overwrite})
36+}
37+
38+// place is where run works: dir instead of the working directory when set,
39+// and whether existing files may be replaced without asking.
40+type place struct {
41+ dir string
42+ overwrite bool
43+}
44+
45+func (p place) resolve(path string) string {
46+ if p.dir == "" || filepath.IsAbs(path) {
47+ return path
48+ }
49+ return filepath.Join(p.dir, path)
50+}
51+
52+func run(args []string, stdin io.Reader, stdout io.Writer, at place) int {
21 fs := flag.NewFlagSet("mm dockerinit", flag.ContinueOnError)53 fs := flag.NewFlagSet("mm dockerinit", flag.ContinueOnError)
22 fs.SetOutput(stdout)54 fs.SetOutput(stdout)
23 name := fs.String("name", "", "the service's name (default: acp.name in the agent file, else mm)")55 name := fs.String("name", "", "the service's name (default: acp.name in the agent file, else mm)")
24- agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start")56+ agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start (default: agent.yaml, then .mm/agent.yaml)")
25 yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten")57 yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten")
26 if err := fs.Parse(args); err != nil {58 if err := fs.Parse(args); err != nil {
27 if errors.Is(err, flag.ErrHelp) {59 if errors.Is(err, flag.ErrHelp) {
@@ -34,14 +66,32 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int {
34 return 266 return 2
35 }67 }
36 68
69+ // Absolute: the .mm folder is recognised by comparing absolute paths.
70+ project, err := filepath.Abs(at.resolve("."))
71+ if err != nil {
72+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
73+ return 1
74+ }
75+ explicit := false
76+ fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" })
77+ agentPath := scaffold.FindAgentFile(*agentFile, project, explicit, at.resolve)
78+ // A .mm project (ticket 212): its paths are written relative to the
79+ // folder of compose.yaml, the project, as compose reads them.
80+ mm := scaffold.MMFolderOf(agentPath, project)
81+ start := *agentFile
82+ if mm != nil {
83+ mm.Base = project
84+ start = startArg(mm)
85+ }
86+
37 chosen := *name87 chosen := *name
38 if chosen == "" {88 if chosen == "" {
39- chosen = scaffold.AgentName(*agentFile, stdout)89+ chosen = scaffold.AgentName(agentPath, stdout)
40 }90 }
41 service := scaffold.Name(chosen)91 service := scaffold.Name(chosen)
42 files := []scaffold.File{92 files := []scaffold.File{
43- {Path: "Dockerfile", Text: Dockerfile()},93+ {Path: at.resolve("Dockerfile"), Text: Dockerfile(), Replace: at.overwrite},
44- {Path: "compose.yaml", Text: Compose(service, os.Getuid(), os.Getgid())},94+ {Path: at.resolve("compose.yaml"), Text: compose(service, os.Getuid(), os.Getgid(), mm), Replace: at.overwrite},
45 }95 }
46 wrote, err := scaffold.Write(files, stdin, stdout, *yes)96 wrote, err := scaffold.Write(files, stdin, stdout, *yes)
47 if err != nil {97 if err != nil {
@@ -49,17 +99,70 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int {
49 return 199 return 1
50 }100 }
51 if wrote {101 if wrote {
52- printNext(stdout, service, *agentFile)102+ if mm != nil {
103+ reportMMFolder(stdout, mm)
104+ }
105+ printNext(stdout, service, start, missing(agentPath, *agentFile))
53 }106 }
54 return 0107 return 0
55 }108 }
56 109
110+// startArg is what follows `mm tui` for a .mm project: nothing for
111+// .mm/agent.yaml, which mm finds on its own from the project (mm 1.0.7 and
112+// later; the image holds the latest release), the file otherwise.
113+func startArg(mm *scaffold.MMFolder) string {
114+ if filepath.Base(mm.Agent) == config.DefaultPath {
115+ return ""
116+ }
117+ return mm.Rel(mm.Agent)
118+}
119+
120+// missing is the agent file's name when it does not exist, "" otherwise.
121+func missing(agentPath, asGiven string) string {
122+ if _, err := os.Stat(agentPath); err != nil {
123+ return asGiven
124+ }
125+ return ""
126+}
127+
128+// reportMMFolder says what the .mm project's mounts are, and creates the
129+// writable folders that do not exist yet: Docker would create them as root,
130+// and the agent, which never runs as root, could not write in them.
131+func reportMMFolder(out io.Writer, mm *scaffold.MMFolder) {
132+ fmt.Fprintf(out, "\nThe agent is %s: .mm/ is mounted read-only, writable again:", mm.Rel(mm.Agent))
133+ if len(mm.Writable) == 0 {
134+ fmt.Fprint(out, " nothing (no sessions, no trace)")
135+ }
136+ for _, w := range mm.Writable {
137+ fmt.Fprintf(out, " %s (%s)", mm.Rel(w.Path), w.Key)
138+ }
139+ fmt.Fprintln(out)
140+ for _, w := range mm.Writable {
141+ if _, err := os.Stat(w.Path); err == nil {
142+ continue
143+ }
144+ if err := os.MkdirAll(w.Path, 0o755); err != nil {
145+ fmt.Fprintf(out, "⚠️ cannot create %s: %v — create it before starting the container\n", mm.Rel(w.Path), err)
146+ continue
147+ }
148+ fmt.Fprintf(out, "✅ created %s\n", mm.Rel(w.Path))
149+ }
150+}
151+
57 // printNext says what to run, with the agent file only if there is one.152 // printNext says what to run, with the agent file only if there is one.
58-func printNext(out io.Writer, service, agentFile string) {153+func printNext(out io.Writer, service, start, missing string) {
59- if _, err := os.Stat(agentFile); err != nil {154+ if missing != "" {
60- fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", agentFile)155+ fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", missing)
61 }156 }
62- fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s mm tui %[2]s\n", service, agentFile)157+ fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s %[2]s\n", service, tuiCommand(start))
63- fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s mm tui %[2]s\n", service, agentFile)158+ fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s %[2]s\n", service, tuiCommand(start))
64 fmt.Fprintf(out, "\nFor both architectures:\n docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/mm-%s:latest --push .\n", service)159 fmt.Fprintf(out, "\nFor both architectures:\n docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/mm-%s:latest --push .\n", service)
65 }160 }
161+
162+// tuiCommand is `mm tui`, with the agent file when one is needed.
163+func tuiCommand(agent string) string {
164+ if agent == "" {
165+ return "mm tui"
166+ }
167+ return "mm tui " + agent
168+}
modified internal/sbxinit/mmfolder.go +12 -105
@@ -1,122 +1,29 @@
11 package sbxinit
22
33 import (
4- "os"
54 "path/filepath"
65 "strings"
76
8- "mm/internal/config"
9-
10- "gopkg.in/yaml.v3"
7+ "mm/internal/scaffold"
118 )
129
13-// MMFolder is a project whose agent is <workspace>/.mm/<file> (ticket 208):
14-// the sandbox mounts .mm/ read-only over the workspace and gives write access
15-// back to the folders mm writes in. The agent file is not named: the kit
16-// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which
17-// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths
18-// are written relative to Base, the folder of sbxenv.yaml, which is how sbx
19-// reads them: the file does not depend on where the project is.
20-type MMFolder struct {
21- Agent string // the agent file, absolute
22- Writable []Writable // folders under .mm/ that mm writes in
23- Base string // the folder of sbxenv.yaml, absolute; "" = write absolute paths
24-}
10+// MMFolder is scaffold.MMFolder: a .mm project (ticket 208), shared with mm
11+// dockerinit since ticket 212.
12+type MMFolder = scaffold.MMFolder
2513
26-// Writable is a folder mounted writable again over the read-only .mm/, and
27-// the agent-file key it comes from.
28-type Writable struct {
29- Path string // absolute
30- Key string // "sessions.dir", "trace.dir"
31-}
32-
33-// mmFolderOf describes the project when agentPath is <workspace>/.mm/<file>,
34-// nil otherwise. The writable folders are the agent file's sessions.dir and
35-// trace.dir when they are on and fall under .mm/ — relative paths read from
36-// the workspace, where mm runs in the sandbox.
37-//
38-// f := mmFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on
39-func mmFolderOf(agentPath, workspace string) *MMFolder {
40- agent, err := filepath.Abs(agentPath)
41- if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) {
42- return nil
43- }
44- folder := &MMFolder{Agent: agent}
45- data, err := os.ReadFile(agent)
46- if err != nil {
47- return folder
48- }
49- var settings struct {
50- Sessions struct {
51- Enabled bool `yaml:"enabled"`
52- Dir string `yaml:"dir"`
53- } `yaml:"sessions"`
54- Trace struct {
55- Enabled bool `yaml:"enabled"`
56- Dir string `yaml:"dir"`
57- } `yaml:"trace"`
58- }
59- if yaml.Unmarshal(data, &settings) != nil {
60- return folder
61- }
62- // mm's own defaults (config.Cfg) when the file names no dir.
63- for _, w := range []struct {
64- on bool
65- dir, def string
66- key string
67- }{
68- {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"},
69- {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"},
70- } {
71- if !w.on {
72- continue
73- }
74- dir := w.dir
75- if dir == "" {
76- dir = w.def
77- }
78- if !filepath.IsAbs(dir) {
79- dir = filepath.Join(workspace, dir)
80- }
81- if under(dir, filepath.Join(workspace, config.ProjectDir)) {
82- folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key})
83- }
84- }
85- return folder
86-}
87-
88-// under says path is dir or inside it.
89-func under(path, dir string) bool {
90- rel, err := filepath.Rel(dir, path)
91- return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
92-}
93-
94-// rel writes path relative to Base, as sbx reads it: "./.mm", "." —
95-// absolute when there is no Base or the path is not below it.
96-func (m *MMFolder) rel(path string) string {
97- if m.Base == "" {
98- return path
99- }
100- r, err := filepath.Rel(m.Base, path)
101- if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
102- return path
103- }
104- if r == "." {
105- return "."
106- }
107- return "./" + filepath.ToSlash(r)
108-}
14+// mmFolderOf is scaffold.MMFolderOf.
15+var mmFolderOf = scaffold.MMFolderOf
10916
110-// workspace is the workspace line's value: relative for a .mm project.
111-func (m *MMFolder) workspace(absolute string) string {
17+// workspaceOf is the workspace line's value: relative for a .mm project.
18+func workspaceOf(m *MMFolder, absolute string) string {
11219 if m == nil {
11320 return absolute
11421 }
115- return m.rel(absolute)
22+ return m.Rel(absolute)
11623 }
11724
11825 // block is the part of sbxenv.yaml a .mm project adds after the workspace.
119-func (m *MMFolder) block() string {
26+func block(m *MMFolder) string {
12027 if m == nil {
12128 return ""
12229 }
@@ -129,11 +36,11 @@ func (m *MMFolder) block() string {
12936 # needs a line of its own. After a change to these mounts, re-create the
13037 # sandbox: sbx env rm, then sbx env create.
13138 additionalWorkspaces:
132- - path: ` + yamlScalar(m.rel(filepath.Dir(m.Agent))) + `
39+ - path: ` + yamlScalar(m.Rel(filepath.Dir(m.Agent))) + `
13340 readOnly: true
13441 `)
13542 for _, w := range m.Writable {
136- b.WriteString(" - path: " + yamlScalar(m.rel(w.Path)) + " # " + w.Key + "\n")
43+ b.WriteString(" - path: " + yamlScalar(m.Rel(w.Path)) + " # " + w.Key + "\n")
13744 }
13845 b.WriteString(`
13946 # No AGENT_CONFIG: the kit's mm (1.0.7 or later) starts in the workspace and
@@ -1,122 +1,29 @@
1 package sbxinit1 package sbxinit
2 2
3 import (3 import (
4- "os"
5 "path/filepath"4 "path/filepath"
6 "strings"5 "strings"
7 6
8- "mm/internal/config"7+ "mm/internal/scaffold"
9-
10- "gopkg.in/yaml.v3"
11 )8 )
12 9
13-// MMFolder is a project whose agent is <workspace>/.mm/<file> (ticket 208):10+// MMFolder is scaffold.MMFolder: a .mm project (ticket 208), shared with mm
14-// the sandbox mounts .mm/ read-only over the workspace and gives write access11+// dockerinit since ticket 212.
15-// back to the folders mm writes in. The agent file is not named: the kit12+type MMFolder = scaffold.MMFolder
16-// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which
17-// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths
18-// are written relative to Base, the folder of sbxenv.yaml, which is how sbx
19-// reads them: the file does not depend on where the project is.
20-type MMFolder struct {
21- Agent string // the agent file, absolute
22- Writable []Writable // folders under .mm/ that mm writes in
23- Base string // the folder of sbxenv.yaml, absolute; "" = write absolute paths
24-}
25 13
26-// Writable is a folder mounted writable again over the read-only .mm/, and14+// mmFolderOf is scaffold.MMFolderOf.
27-// the agent-file key it comes from.15+var mmFolderOf = scaffold.MMFolderOf
28-type Writable struct {
29- Path string // absolute
30- Key string // "sessions.dir", "trace.dir"
31-}
32-
33-// mmFolderOf describes the project when agentPath is <workspace>/.mm/<file>,
34-// nil otherwise. The writable folders are the agent file's sessions.dir and
35-// trace.dir when they are on and fall under .mm/ — relative paths read from
36-// the workspace, where mm runs in the sandbox.
37-//
38-// f := mmFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on
39-func mmFolderOf(agentPath, workspace string) *MMFolder {
40- agent, err := filepath.Abs(agentPath)
41- if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) {
42- return nil
43- }
44- folder := &MMFolder{Agent: agent}
45- data, err := os.ReadFile(agent)
46- if err != nil {
47- return folder
48- }
49- var settings struct {
50- Sessions struct {
51- Enabled bool `yaml:"enabled"`
52- Dir string `yaml:"dir"`
53- } `yaml:"sessions"`
54- Trace struct {
55- Enabled bool `yaml:"enabled"`
56- Dir string `yaml:"dir"`
57- } `yaml:"trace"`
58- }
59- if yaml.Unmarshal(data, &settings) != nil {
60- return folder
61- }
62- // mm's own defaults (config.Cfg) when the file names no dir.
63- for _, w := range []struct {
64- on bool
65- dir, def string
66- key string
67- }{
68- {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"},
69- {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"},
70- } {
71- if !w.on {
72- continue
73- }
74- dir := w.dir
75- if dir == "" {
76- dir = w.def
77- }
78- if !filepath.IsAbs(dir) {
79- dir = filepath.Join(workspace, dir)
80- }
81- if under(dir, filepath.Join(workspace, config.ProjectDir)) {
82- folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key})
83- }
84- }
85- return folder
86-}
87-
88-// under says path is dir or inside it.
89-func under(path, dir string) bool {
90- rel, err := filepath.Rel(dir, path)
91- return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
92-}
93-
94-// rel writes path relative to Base, as sbx reads it: "./.mm", "." —
95-// absolute when there is no Base or the path is not below it.
96-func (m *MMFolder) rel(path string) string {
97- if m.Base == "" {
98- return path
99- }
100- r, err := filepath.Rel(m.Base, path)
101- if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
102- return path
103- }
104- if r == "." {
105- return "."
106- }
107- return "./" + filepath.ToSlash(r)
108-}
109 16
110-// workspace is the workspace line's value: relative for a .mm project.17+// workspaceOf is the workspace line's value: relative for a .mm project.
111-func (m *MMFolder) workspace(absolute string) string {18+func workspaceOf(m *MMFolder, absolute string) string {
112 if m == nil {19 if m == nil {
113 return absolute20 return absolute
114 }21 }
115- return m.rel(absolute)22+ return m.Rel(absolute)
116 }23 }
117 24
118 // block is the part of sbxenv.yaml a .mm project adds after the workspace.25 // block is the part of sbxenv.yaml a .mm project adds after the workspace.
119-func (m *MMFolder) block() string {26+func block(m *MMFolder) string {
120 if m == nil {27 if m == nil {
121 return ""28 return ""
122 }29 }
@@ -129,11 +36,11 @@ func (m *MMFolder) block() string {
129 # needs a line of its own. After a change to these mounts, re-create the36 # needs a line of its own. After a change to these mounts, re-create the
130 # sandbox: sbx env rm, then sbx env create.37 # sandbox: sbx env rm, then sbx env create.
131 additionalWorkspaces:38 additionalWorkspaces:
132- - path: ` + yamlScalar(m.rel(filepath.Dir(m.Agent))) + `39+ - path: ` + yamlScalar(m.Rel(filepath.Dir(m.Agent))) + `
133 readOnly: true40 readOnly: true
134 `)41 `)
135 for _, w := range m.Writable {42 for _, w := range m.Writable {
136- b.WriteString(" - path: " + yamlScalar(m.rel(w.Path)) + " # " + w.Key + "\n")43+ b.WriteString(" - path: " + yamlScalar(m.Rel(w.Path)) + " # " + w.Key + "\n")
137 }44 }
138 b.WriteString(`45 b.WriteString(`
139 # No AGENT_CONFIG: the kit's mm (1.0.7 or later) starts in the workspace and46 # No AGENT_CONFIG: the kit's mm (1.0.7 or later) starts in the workspace and
modified internal/sbxinit/sbxinit.go +50 -34
@@ -14,7 +14,6 @@ import (
1414 "strings"
1515 "time"
1616
17- "mm/internal/config"
1817 "mm/internal/scaffold"
1918
2019 "gopkg.in/yaml.v3"
@@ -84,8 +83,9 @@ sandboxOptions:
8483 pullPolicy: always
8584
8685 # sbx env create --force
86+# sbx env run --force
8787 # sbx env rm --force
88-`, name, howToURL, kitTag, yamlScalar(mm.workspace(workspace)), mm.block())
88+`, name, howToURL, kitTag, yamlScalar(workspaceOf(mm, workspace)), block(mm))
8989 return text
9090 }
9191
@@ -106,12 +106,47 @@ func yamlScalar(value string) string {
106106 // mm sbxinit
107107 // mm sbxinit --workspace ./workspace --name bob --yes
108108 func Main(args []string, stdin io.Reader, stdout io.Writer) int {
109- return run(args, stdin, stdout, func() (string, error) {
110- return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest)
111- })
109+ return run(args, stdin, stdout, latestRelease, place{})
112110 }
113111
114-func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error)) int {
112+// In is `mm sbxinit` run for another directory than the process's: dir
113+// stands for the current directory — where sbxenv.yaml is written, where
114+// relative flags start, where the agent file is looked for. Nothing is
115+// asked: an existing sbxenv.yaml is replaced when overwrite is set, kept
116+// otherwise (exit 1). mm acp-create's /sbxinit uses it with the session's
117+// cwd.
118+//
119+// code := sbxinit.In("/home/me/project", []string{"--name", "bob"}, &out, false)
120+func In(dir string, args []string, out io.Writer, overwrite bool) int {
121+ return InWith(dir, args, out, overwrite, latestRelease)
122+}
123+
124+// InWith is In with the kit version read by latest instead of from the
125+// latest release (tests).
126+func InWith(dir string, args []string, out io.Writer, overwrite bool, latest func() (string, error)) int {
127+ return run(args, strings.NewReader(""), out, latest, place{dir: dir, overwrite: overwrite})
128+}
129+
130+func latestRelease() (string, error) {
131+ return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest)
132+}
133+
134+// place is where run works: dir instead of the working directory when set,
135+// and whether an existing file may be replaced without asking.
136+type place struct {
137+ dir string
138+ overwrite bool
139+}
140+
141+// resolve is path p seen from the place's directory.
142+func (p place) resolve(path string) string {
143+ if p.dir == "" || filepath.IsAbs(path) {
144+ return path
145+ }
146+ return filepath.Join(p.dir, path)
147+}
148+
149+func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error), at place) int {
115150 fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError)
116151 fs.SetOutput(stdout)
117152 name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)")
@@ -128,7 +163,7 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
128163 fmt.Fprintf(stdout, "⚠️ mm sbxinit takes no argument, got %q\n", fs.Arg(0))
129164 return 2
130165 }
131- absolute, err := filepath.Abs(*workspace)
166+ absolute, err := filepath.Abs(at.resolve(*workspace))
132167 if err != nil {
133168 fmt.Fprintln(stdout, "⚠️ "+err.Error())
134169 return 1
@@ -136,19 +171,22 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
136171
137172 explicit := false
138173 fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" })
139- agentPath := findAgentFile(*agentFile, absolute, explicit)
174+ agentPath := scaffold.FindAgentFile(*agentFile, absolute, explicit, at.resolve)
140175 mmFolder := mmFolderOf(agentPath, absolute)
141176 if mmFolder != nil {
142177 // The paths of a .mm project's file are relative to the folder of
143178 // sbxenv.yaml, as sbx reads them: the file can be committed.
144- mmFolder.Base, _ = os.Getwd()
179+ mmFolder.Base = at.dir
180+ if mmFolder.Base == "" {
181+ mmFolder.Base, _ = os.Getwd()
182+ }
145183 }
146184 chosen := *name
147185 if chosen == "" {
148186 chosen = scaffold.AgentName(agentPath, stdout)
149187 }
150188 sandbox := scaffold.Name(chosen) + "-sbx"
151- files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}}
189+ files := []scaffold.File{{Path: at.resolve("sbxenv.yaml"), Replace: at.overwrite, Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}}
152190 wrote, err := scaffold.Write(files, stdin, stdout, *yes)
153191 if err != nil {
154192 fmt.Fprintln(stdout, "⚠️ "+err.Error())
@@ -162,11 +200,11 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
162200 fmt.Fprint(stdout, " nothing (no sessions, no trace)")
163201 }
164202 for _, w := range mmFolder.Writable {
165- fmt.Fprintf(stdout, " %s (%s)", mmFolder.rel(w.Path), w.Key)
203+ fmt.Fprintf(stdout, " %s (%s)", mmFolder.Rel(w.Path), w.Key)
166204 }
167205 fmt.Fprintln(stdout)
168206 }
169- fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force\n\nRemove it:\n sbx env rm --force\n", sandbox)
207+ fmt.Fprintf(stdout, "\nCreate the sandbox %[1]s:\n sbx env create --force\n\nRun the sandbox %[1]s:\n sbx env run --force\n\nRemove it:\n sbx env rm --force\n", sandbox)
170208 }
171209 return 0
172210 }
@@ -181,28 +219,6 @@ func kitTag(latest func() (string, error), stdout io.Writer) string {
181219 return version
182220 }
183221
184-// findAgentFile looks in the current directory, then in the workspace, where
185-// the sandbox will read it. Without -agent it looks for agent.yaml, then for
186-// .mm/agent.yaml — the order mm itself follows (config.DefaultPath, then the
187-// .mm folder). It returns the first path, even missing, otherwise.
188-func findAgentFile(agentFile, workspace string, explicit bool) string {
189- if filepath.IsAbs(agentFile) {
190- return agentFile
191- }
192- names := []string{agentFile}
193- if !explicit {
194- names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath))
195- }
196- for _, name := range names {
197- for _, path := range []string{name, filepath.Join(workspace, name)} {
198- if _, err := os.Stat(path); err == nil {
199- return path
200- }
201- }
202- }
203- return agentFile
204-}
205-
206222 // warnAboutWorkspace reports what would only show inside the sandbox: a
207223 // workspace that does not exist, an agent file the sandbox cannot see.
208224 func warnAboutWorkspace(out io.Writer, workspace, agentPath string) {
@@ -14,7 +14,6 @@ import (
14 "strings"14 "strings"
15 "time"15 "time"
16 16
17- "mm/internal/config"
18 "mm/internal/scaffold"17 "mm/internal/scaffold"
19 18
20 "gopkg.in/yaml.v3"19 "gopkg.in/yaml.v3"
@@ -84,8 +83,9 @@ sandboxOptions:
84 pullPolicy: always83 pullPolicy: always
85 84
86 # sbx env create --force85 # sbx env create --force
86+# sbx env run --force
87 # sbx env rm --force87 # sbx env rm --force
88-`, name, howToURL, kitTag, yamlScalar(mm.workspace(workspace)), mm.block())88+`, name, howToURL, kitTag, yamlScalar(workspaceOf(mm, workspace)), block(mm))
89 return text89 return text
90 }90 }
91 91
@@ -106,12 +106,47 @@ func yamlScalar(value string) string {
106 // mm sbxinit106 // mm sbxinit
107 // mm sbxinit --workspace ./workspace --name bob --yes107 // mm sbxinit --workspace ./workspace --name bob --yes
108 func Main(args []string, stdin io.Reader, stdout io.Writer) int {108 func Main(args []string, stdin io.Reader, stdout io.Writer) int {
109- return run(args, stdin, stdout, func() (string, error) {109+ return run(args, stdin, stdout, latestRelease, place{})
110- return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest)
111- })
112 }110 }
113 111
114-func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error)) int {112+// In is `mm sbxinit` run for another directory than the process's: dir
113+// stands for the current directory — where sbxenv.yaml is written, where
114+// relative flags start, where the agent file is looked for. Nothing is
115+// asked: an existing sbxenv.yaml is replaced when overwrite is set, kept
116+// otherwise (exit 1). mm acp-create's /sbxinit uses it with the session's
117+// cwd.
118+//
119+// code := sbxinit.In("/home/me/project", []string{"--name", "bob"}, &out, false)
120+func In(dir string, args []string, out io.Writer, overwrite bool) int {
121+ return InWith(dir, args, out, overwrite, latestRelease)
122+}
123+
124+// InWith is In with the kit version read by latest instead of from the
125+// latest release (tests).
126+func InWith(dir string, args []string, out io.Writer, overwrite bool, latest func() (string, error)) int {
127+ return run(args, strings.NewReader(""), out, latest, place{dir: dir, overwrite: overwrite})
128+}
129+
130+func latestRelease() (string, error) {
131+ return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest)
132+}
133+
134+// place is where run works: dir instead of the working directory when set,
135+// and whether an existing file may be replaced without asking.
136+type place struct {
137+ dir string
138+ overwrite bool
139+}
140+
141+// resolve is path p seen from the place's directory.
142+func (p place) resolve(path string) string {
143+ if p.dir == "" || filepath.IsAbs(path) {
144+ return path
145+ }
146+ return filepath.Join(p.dir, path)
147+}
148+
149+func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error), at place) int {
115 fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError)150 fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError)
116 fs.SetOutput(stdout)151 fs.SetOutput(stdout)
117 name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)")152 name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)")
@@ -128,7 +163,7 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
128 fmt.Fprintf(stdout, "⚠️ mm sbxinit takes no argument, got %q\n", fs.Arg(0))163 fmt.Fprintf(stdout, "⚠️ mm sbxinit takes no argument, got %q\n", fs.Arg(0))
129 return 2164 return 2
130 }165 }
131- absolute, err := filepath.Abs(*workspace)166+ absolute, err := filepath.Abs(at.resolve(*workspace))
132 if err != nil {167 if err != nil {
133 fmt.Fprintln(stdout, "⚠️ "+err.Error())168 fmt.Fprintln(stdout, "⚠️ "+err.Error())
134 return 1169 return 1
@@ -136,19 +171,22 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
136 171
137 explicit := false172 explicit := false
138 fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" })173 fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" })
139- agentPath := findAgentFile(*agentFile, absolute, explicit)174+ agentPath := scaffold.FindAgentFile(*agentFile, absolute, explicit, at.resolve)
140 mmFolder := mmFolderOf(agentPath, absolute)175 mmFolder := mmFolderOf(agentPath, absolute)
141 if mmFolder != nil {176 if mmFolder != nil {
142 // The paths of a .mm project's file are relative to the folder of177 // The paths of a .mm project's file are relative to the folder of
143 // sbxenv.yaml, as sbx reads them: the file can be committed.178 // sbxenv.yaml, as sbx reads them: the file can be committed.
144- mmFolder.Base, _ = os.Getwd()179+ mmFolder.Base = at.dir
180+ if mmFolder.Base == "" {
181+ mmFolder.Base, _ = os.Getwd()
182+ }
145 }183 }
146 chosen := *name184 chosen := *name
147 if chosen == "" {185 if chosen == "" {
148 chosen = scaffold.AgentName(agentPath, stdout)186 chosen = scaffold.AgentName(agentPath, stdout)
149 }187 }
150 sandbox := scaffold.Name(chosen) + "-sbx"188 sandbox := scaffold.Name(chosen) + "-sbx"
151- files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}}189+ files := []scaffold.File{{Path: at.resolve("sbxenv.yaml"), Replace: at.overwrite, Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}}
152 wrote, err := scaffold.Write(files, stdin, stdout, *yes)190 wrote, err := scaffold.Write(files, stdin, stdout, *yes)
153 if err != nil {191 if err != nil {
154 fmt.Fprintln(stdout, "⚠️ "+err.Error())192 fmt.Fprintln(stdout, "⚠️ "+err.Error())
@@ -162,11 +200,11 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
162 fmt.Fprint(stdout, " nothing (no sessions, no trace)")200 fmt.Fprint(stdout, " nothing (no sessions, no trace)")
163 }201 }
164 for _, w := range mmFolder.Writable {202 for _, w := range mmFolder.Writable {
165- fmt.Fprintf(stdout, " %s (%s)", mmFolder.rel(w.Path), w.Key)203+ fmt.Fprintf(stdout, " %s (%s)", mmFolder.Rel(w.Path), w.Key)
166 }204 }
167 fmt.Fprintln(stdout)205 fmt.Fprintln(stdout)
168 }206 }
169- fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force\n\nRemove it:\n sbx env rm --force\n", sandbox)207+ fmt.Fprintf(stdout, "\nCreate the sandbox %[1]s:\n sbx env create --force\n\nRun the sandbox %[1]s:\n sbx env run --force\n\nRemove it:\n sbx env rm --force\n", sandbox)
170 }208 }
171 return 0209 return 0
172 }210 }
@@ -181,28 +219,6 @@ func kitTag(latest func() (string, error), stdout io.Writer) string {
181 return version219 return version
182 }220 }
183 221
184-// findAgentFile looks in the current directory, then in the workspace, where
185-// the sandbox will read it. Without -agent it looks for agent.yaml, then for
186-// .mm/agent.yaml — the order mm itself follows (config.DefaultPath, then the
187-// .mm folder). It returns the first path, even missing, otherwise.
188-func findAgentFile(agentFile, workspace string, explicit bool) string {
189- if filepath.IsAbs(agentFile) {
190- return agentFile
191- }
192- names := []string{agentFile}
193- if !explicit {
194- names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath))
195- }
196- for _, name := range names {
197- for _, path := range []string{name, filepath.Join(workspace, name)} {
198- if _, err := os.Stat(path); err == nil {
199- return path
200- }
201- }
202- }
203- return agentFile
204-}
205-
206 // warnAboutWorkspace reports what would only show inside the sandbox: a222 // warnAboutWorkspace reports what would only show inside the sandbox: a
207 // workspace that does not exist, an agent file the sandbox cannot see.223 // workspace that does not exist, an agent file the sandbox cannot see.
208 func warnAboutWorkspace(out io.Writer, workspace, agentPath string) {224 func warnAboutWorkspace(out io.Writer, workspace, agentPath string) {
modified internal/sbxinit/sbxinit_test.go +2 -2
@@ -117,7 +117,7 @@ func runIn(t *testing.T, files map[string]string, stdin string, latest func() (s
117117 }
118118 }
119119 var out bytes.Buffer
120- code := run(args, strings.NewReader(stdin), &out, latest)
120+ code := run(args, strings.NewReader(stdin), &out, latest, place{})
121121 return dir, code, out.String()
122122 }
123123
@@ -141,7 +141,7 @@ func TestMainDefaultsToTheCurrentDirectoryAndTheAgentName(t *testing.T) {
141141 if got.Name != "charles-sbx" || got.Workspace != dir || got.Kits[0] != "docker.io/k33g/mini-me-kit:0.1.2" {
142142 t.Errorf("%+v", got)
143143 }
144- if !strings.Contains(out, "sbx env create --force") || strings.Contains(out, "⚠️") {
144+ if !strings.Contains(out, "sbx env create --force") || !strings.Contains(out, "Run the sandbox charles-sbx:\n sbx env run --force") || strings.Contains(out, "⚠️") {
145145 t.Errorf("output:\n%s", out)
146146 }
147147 }
@@ -117,7 +117,7 @@ func runIn(t *testing.T, files map[string]string, stdin string, latest func() (s
117 }117 }
118 }118 }
119 var out bytes.Buffer119 var out bytes.Buffer
120- code := run(args, strings.NewReader(stdin), &out, latest)120+ code := run(args, strings.NewReader(stdin), &out, latest, place{})
121 return dir, code, out.String()121 return dir, code, out.String()
122 }122 }
123 123
@@ -141,7 +141,7 @@ func TestMainDefaultsToTheCurrentDirectoryAndTheAgentName(t *testing.T) {
141 if got.Name != "charles-sbx" || got.Workspace != dir || got.Kits[0] != "docker.io/k33g/mini-me-kit:0.1.2" {141 if got.Name != "charles-sbx" || got.Workspace != dir || got.Kits[0] != "docker.io/k33g/mini-me-kit:0.1.2" {
142 t.Errorf("%+v", got)142 t.Errorf("%+v", got)
143 }143 }
144- if !strings.Contains(out, "sbx env create --force") || strings.Contains(out, "⚠️") {144+ if !strings.Contains(out, "sbx env create --force") || !strings.Contains(out, "Run the sandbox charles-sbx:\n sbx env run --force") || strings.Contains(out, "⚠️") {
145 t.Errorf("output:\n%s", out)145 t.Errorf("output:\n%s", out)
146 }146 }
147 }147 }
added internal/scaffold/mmfolder.go +134 -0
new file mode 100644
@@ -0,0 +1,134 @@
1+package scaffold
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+
8+ "mm/internal/config"
9+
10+ "gopkg.in/yaml.v3"
11+)
12+
13+// MMFolder is a project whose agent is <workspace>/.mm/<file> (tickets 208,
14+// 212): mm sbxinit and mm dockerinit mount .mm/ read-only over the workspace
15+// and give write access back to the folders mm writes in. The agent file is not named: the kit
16+// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which
17+// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths
18+// are written relative to Base, the folder of sbxenv.yaml or compose.yaml,
19+// which is how sbx and compose read them: the file does not depend on where the project is.
20+type MMFolder struct {
21+ Agent string // the agent file, absolute
22+ Writable []Writable // folders under .mm/ that mm writes in
23+ Base string // the folder of the written file, absolute; "" = write absolute paths
24+}
25+
26+// Writable is a folder mounted writable again over the read-only .mm/, and
27+// the agent-file key it comes from.
28+type Writable struct {
29+ Path string // absolute
30+ Key string // "sessions.dir", "trace.dir"
31+}
32+
33+// MMFolderOf describes the project when agentPath is <workspace>/.mm/<file>,
34+// nil otherwise. The writable folders are the agent file's sessions.dir and
35+// trace.dir when they are on and fall under .mm/ — relative paths read from
36+// the workspace, where mm runs in the sandbox.
37+//
38+// f := scaffold.MMFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on
39+func MMFolderOf(agentPath, workspace string) *MMFolder {
40+ agent, err := filepath.Abs(agentPath)
41+ if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) {
42+ return nil
43+ }
44+ folder := &MMFolder{Agent: agent}
45+ data, err := os.ReadFile(agent)
46+ if err != nil {
47+ return folder
48+ }
49+ var settings struct {
50+ Sessions struct {
51+ Enabled bool `yaml:"enabled"`
52+ Dir string `yaml:"dir"`
53+ } `yaml:"sessions"`
54+ Trace struct {
55+ Enabled bool `yaml:"enabled"`
56+ Dir string `yaml:"dir"`
57+ } `yaml:"trace"`
58+ }
59+ if yaml.Unmarshal(data, &settings) != nil {
60+ return folder
61+ }
62+ // mm's own defaults (config.Cfg) when the file names no dir.
63+ for _, w := range []struct {
64+ on bool
65+ dir, def string
66+ key string
67+ }{
68+ {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"},
69+ {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"},
70+ } {
71+ if !w.on {
72+ continue
73+ }
74+ dir := w.dir
75+ if dir == "" {
76+ dir = w.def
77+ }
78+ if !filepath.IsAbs(dir) {
79+ dir = filepath.Join(workspace, dir)
80+ }
81+ if under(dir, filepath.Join(workspace, config.ProjectDir)) {
82+ folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key})
83+ }
84+ }
85+ return folder
86+}
87+
88+// under says path is dir or inside it.
89+func under(path, dir string) bool {
90+ rel, err := filepath.Rel(dir, path)
91+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
92+}
93+
94+// Rel writes path relative to Base, as sbx and compose read it: "./.mm", "." —
95+// absolute when there is no Base or the path is not below it.
96+func (m *MMFolder) Rel(path string) string {
97+ if m.Base == "" {
98+ return path
99+ }
100+ r, err := filepath.Rel(m.Base, path)
101+ if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
102+ return path
103+ }
104+ if r == "." {
105+ return "."
106+ }
107+ return "./" + filepath.ToSlash(r)
108+}
109+
110+// FindAgentFile is the agent file a scaffolding command describes: an
111+// absolute agentFile as is; otherwise it is looked for in the current
112+// directory — resolve turns a relative path into the caller's — then in the
113+// workspace. Without an explicit --agent, .mm/agent.yaml is looked for too,
114+// after agentFile: the order mm itself follows (config.DefaultPath, then the
115+// .mm folder). It returns the first path found, or agentFile resolved.
116+//
117+// path := scaffold.FindAgentFile("agent.yaml", "/p", false, func(p string) string { return p })
118+func FindAgentFile(agentFile, workspace string, explicit bool, resolve func(string) string) string {
119+ if filepath.IsAbs(agentFile) {
120+ return agentFile
121+ }
122+ names := []string{agentFile}
123+ if !explicit {
124+ names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath))
125+ }
126+ for _, name := range names {
127+ for _, path := range []string{resolve(name), filepath.Join(workspace, name)} {
128+ if _, err := os.Stat(path); err == nil {
129+ return path
130+ }
131+ }
132+ }
133+ return resolve(agentFile)
134+}
new file mode 100644
@@ -0,0 +1,134 @@
1+package scaffold
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+
8+ "mm/internal/config"
9+
10+ "gopkg.in/yaml.v3"
11+)
12+
13+// MMFolder is a project whose agent is <workspace>/.mm/<file> (tickets 208,
14+// 212): mm sbxinit and mm dockerinit mount .mm/ read-only over the workspace
15+// and give write access back to the folders mm writes in. The agent file is not named: the kit
16+// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which
17+// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths
18+// are written relative to Base, the folder of sbxenv.yaml or compose.yaml,
19+// which is how sbx and compose read them: the file does not depend on where the project is.
20+type MMFolder struct {
21+ Agent string // the agent file, absolute
22+ Writable []Writable // folders under .mm/ that mm writes in
23+ Base string // the folder of the written file, absolute; "" = write absolute paths
24+}
25+
26+// Writable is a folder mounted writable again over the read-only .mm/, and
27+// the agent-file key it comes from.
28+type Writable struct {
29+ Path string // absolute
30+ Key string // "sessions.dir", "trace.dir"
31+}
32+
33+// MMFolderOf describes the project when agentPath is <workspace>/.mm/<file>,
34+// nil otherwise. The writable folders are the agent file's sessions.dir and
35+// trace.dir when they are on and fall under .mm/ — relative paths read from
36+// the workspace, where mm runs in the sandbox.
37+//
38+// f := scaffold.MMFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on
39+func MMFolderOf(agentPath, workspace string) *MMFolder {
40+ agent, err := filepath.Abs(agentPath)
41+ if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) {
42+ return nil
43+ }
44+ folder := &MMFolder{Agent: agent}
45+ data, err := os.ReadFile(agent)
46+ if err != nil {
47+ return folder
48+ }
49+ var settings struct {
50+ Sessions struct {
51+ Enabled bool `yaml:"enabled"`
52+ Dir string `yaml:"dir"`
53+ } `yaml:"sessions"`
54+ Trace struct {
55+ Enabled bool `yaml:"enabled"`
56+ Dir string `yaml:"dir"`
57+ } `yaml:"trace"`
58+ }
59+ if yaml.Unmarshal(data, &settings) != nil {
60+ return folder
61+ }
62+ // mm's own defaults (config.Cfg) when the file names no dir.
63+ for _, w := range []struct {
64+ on bool
65+ dir, def string
66+ key string
67+ }{
68+ {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"},
69+ {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"},
70+ } {
71+ if !w.on {
72+ continue
73+ }
74+ dir := w.dir
75+ if dir == "" {
76+ dir = w.def
77+ }
78+ if !filepath.IsAbs(dir) {
79+ dir = filepath.Join(workspace, dir)
80+ }
81+ if under(dir, filepath.Join(workspace, config.ProjectDir)) {
82+ folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key})
83+ }
84+ }
85+ return folder
86+}
87+
88+// under says path is dir or inside it.
89+func under(path, dir string) bool {
90+ rel, err := filepath.Rel(dir, path)
91+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
92+}
93+
94+// Rel writes path relative to Base, as sbx and compose read it: "./.mm", "." —
95+// absolute when there is no Base or the path is not below it.
96+func (m *MMFolder) Rel(path string) string {
97+ if m.Base == "" {
98+ return path
99+ }
100+ r, err := filepath.Rel(m.Base, path)
101+ if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
102+ return path
103+ }
104+ if r == "." {
105+ return "."
106+ }
107+ return "./" + filepath.ToSlash(r)
108+}
109+
110+// FindAgentFile is the agent file a scaffolding command describes: an
111+// absolute agentFile as is; otherwise it is looked for in the current
112+// directory — resolve turns a relative path into the caller's — then in the
113+// workspace. Without an explicit --agent, .mm/agent.yaml is looked for too,
114+// after agentFile: the order mm itself follows (config.DefaultPath, then the
115+// .mm folder). It returns the first path found, or agentFile resolved.
116+//
117+// path := scaffold.FindAgentFile("agent.yaml", "/p", false, func(p string) string { return p })
118+func FindAgentFile(agentFile, workspace string, explicit bool, resolve func(string) string) string {
119+ if filepath.IsAbs(agentFile) {
120+ return agentFile
121+ }
122+ names := []string{agentFile}
123+ if !explicit {
124+ names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath))
125+ }
126+ for _, name := range names {
127+ for _, path := range []string{resolve(name), filepath.Join(workspace, name)} {
128+ if _, err := os.Stat(path); err == nil {
129+ return path
130+ }
131+ }
132+ }
133+ return resolve(agentFile)
134+}
modified internal/scaffold/scaffold.go +8 -0
@@ -58,6 +58,10 @@ func AgentName(agentFile string, out io.Writer) string {
5858 type File struct {
5959 Path string
6060 Text string
61+ // Replace: an existing file is replaced without asking — the caller
62+ // already has the user's agreement (mm acp-create's /sbxinit --overwrite,
63+ // where nobody can answer on stdin).
64+ Replace bool
6165 }
6266
6367 // Write writes the files, saying so on out. Each existing file is first asked
@@ -92,6 +96,10 @@ func decide(files []File, in *bufio.Reader, out io.Writer, refuse bool) (replace
9296 if _, statErr := os.Stat(file.Path); statErr != nil {
9397 continue
9498 }
99+ if file.Replace {
100+ replace[file.Path] = true
101+ continue
102+ }
95103 if refuse {
96104 return nil, nil, alreadyExists(file.Path)
97105 }
@@ -58,6 +58,10 @@ func AgentName(agentFile string, out io.Writer) string {
58 type File struct {58 type File struct {
59 Path string59 Path string
60 Text string60 Text string
61+ // Replace: an existing file is replaced without asking — the caller
62+ // already has the user's agreement (mm acp-create's /sbxinit --overwrite,
63+ // where nobody can answer on stdin).
64+ Replace bool
61 }65 }
62 66
63 // Write writes the files, saying so on out. Each existing file is first asked67 // Write writes the files, saying so on out. Each existing file is first asked
@@ -92,6 +96,10 @@ func decide(files []File, in *bufio.Reader, out io.Writer, refuse bool) (replace
92 if _, statErr := os.Stat(file.Path); statErr != nil {96 if _, statErr := os.Stat(file.Path); statErr != nil {
93 continue97 continue
94 }98 }
99+ if file.Replace {
100+ replace[file.Path] = true
101+ continue
102+ }
95 if refuse {103 if refuse {
96 return nil, nil, alreadyExists(file.Path)104 return nil, nil, alreadyExists(file.Path)
97 }105 }
modified main.go +8 -0
@@ -41,6 +41,7 @@ import (
4141 "time"
4242
4343 "mm/internal/acp"
44+ "mm/internal/acpcreate"
4445 "mm/internal/agent"
4546 "mm/internal/agentinit"
4647 "mm/internal/ask"
@@ -83,6 +84,12 @@ func main() {
8384 if len(os.Args) > 1 && isInit(os.Args[1]) {
8485 os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal}))
8586 }
87+ // `mm acp-create` and `mm --acp-create` (ticket 210): the same wizard
88+ // served over ACP, for an editor; no configuration either. stdout is
89+ // the protocol's, so its help and errors go to stderr.
90+ if len(os.Args) > 1 && acpcreate.Is(os.Args[1]) {
91+ os.Exit(acpcreate.Main(os.Args[2:], os.Stdin, os.Stdout, os.Stderr))
92+ }
8693 // `mm dockerinit` (ticket 189): writes files, before any configuration too.
8794 if len(os.Args) > 1 && isDockerInit(os.Args[1]) {
8895 os.Exit(dockerinit.Main(os.Args[2:], os.Stdin, os.Stdout))
@@ -139,6 +146,7 @@ func main() {
139146 showAbout := flag.Bool("about", false, "print what mm is, and exit")
140147 // Listed for -h only: `mm --init` is answered before flag.Parse.
141148 flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")
149+ flag.Bool("acp-create", false, "serve mm init's wizard over the Agent Client Protocol, to create an agent.yaml from an editor (same as mm acp-create; mm acp-create -h for its flags)")
142150 flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)")
143151 flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)")
144152 flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)")
@@ -41,6 +41,7 @@ import (
41 "time"41 "time"
42 42
43 "mm/internal/acp"43 "mm/internal/acp"
44+ "mm/internal/acpcreate"
44 "mm/internal/agent"45 "mm/internal/agent"
45 "mm/internal/agentinit"46 "mm/internal/agentinit"
46 "mm/internal/ask"47 "mm/internal/ask"
@@ -83,6 +84,12 @@ func main() {
83 if len(os.Args) > 1 && isInit(os.Args[1]) {84 if len(os.Args) > 1 && isInit(os.Args[1]) {
84 os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal}))85 os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal}))
85 }86 }
87+ // `mm acp-create` and `mm --acp-create` (ticket 210): the same wizard
88+ // served over ACP, for an editor; no configuration either. stdout is
89+ // the protocol's, so its help and errors go to stderr.
90+ if len(os.Args) > 1 && acpcreate.Is(os.Args[1]) {
91+ os.Exit(acpcreate.Main(os.Args[2:], os.Stdin, os.Stdout, os.Stderr))
92+ }
86 // `mm dockerinit` (ticket 189): writes files, before any configuration too.93 // `mm dockerinit` (ticket 189): writes files, before any configuration too.
87 if len(os.Args) > 1 && isDockerInit(os.Args[1]) {94 if len(os.Args) > 1 && isDockerInit(os.Args[1]) {
88 os.Exit(dockerinit.Main(os.Args[2:], os.Stdin, os.Stdout))95 os.Exit(dockerinit.Main(os.Args[2:], os.Stdin, os.Stdout))
@@ -139,6 +146,7 @@ func main() {
139 showAbout := flag.Bool("about", false, "print what mm is, and exit")146 showAbout := flag.Bool("about", false, "print what mm is, and exit")
140 // Listed for -h only: `mm --init` is answered before flag.Parse.147 // Listed for -h only: `mm --init` is answered before flag.Parse.
141 flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")148 flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")
149+ flag.Bool("acp-create", false, "serve mm init's wizard over the Agent Client Protocol, to create an agent.yaml from an editor (same as mm acp-create; mm acp-create -h for its flags)")
142 flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)")150 flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)")
143 flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)")151 flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)")
144 flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)")152 flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)")