miii-cli 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,19 +1,20 @@
1
- # MiiiLocal-First AI Coding Agent
1
+ # miiiOllama Coding CLI. 176 KB. No API Key.
2
2
 
3
- > **The only coding CLI that runs fully local or cloud — any model, zero lock-in, zero monthly bill.**
3
+ > **Claude Code UX. Ollama models. No invoice.**
4
4
 
5
5
  ![MIII Demo](mii-cli.gif)
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/miii-cli)](https://www.npmjs.com/package/miii-cli)
8
- [![npm downloads](https://img.shields.io/npm/dm/miii-cli)](https://www.npmjs.com/package/miii-cli)
9
8
  [![license](https://img.shields.io/npm/l/miii-cli)](LICENSE)
10
9
  [![node](https://img.shields.io/node/v/miii-cli)](https://nodejs.org)
11
10
 
11
+ **176 KB · no API key · works offline**
12
+
12
13
  ---
13
14
 
14
- **Miii is a fully autonomous coding agent that runs entirely on your machine.** It plans, edits files, runs your tests, searches the web, indexes your codebase semantically, and iterates until the job is done — all without a single byte of your code leaving your network.
15
+ Buy hardware once. Pay for AI never.
15
16
 
16
- Zero subscription. Zero cloud dependency. Zero Python overhead. **Lightning fast startup.**
17
+ Your code never leaves your machine. Nothing sent to Anthropic, OpenAI, or anyone. If you're already running Ollama, miii adds $0 to your stack.
17
18
 
18
19
  ```bash
19
20
  npm install -g miii-cli && miii
@@ -23,13 +24,59 @@ npm install -g miii-cli && miii
23
24
 
24
25
  ## Why Miii Exists
25
26
 
26
- Claude Code is impressive. It's also cloud-only, costs $20–200/month, and sends every line of your codebase to a server you don't control.
27
+ **You're probably paying for something miii does for free.**
27
28
 
28
- OpenCode and Codex CLI have the same problemthey're all cloud-first, all locked to specific providers, and all charge you indefinitely for the privilege of reading your private code.
29
+ Claude Code bills against your Anthropic API key. miii runs open models on Ollama Llama, Mistral, Qwen, Phi. Fully local. $0. Claude Code has no built-in undo for file changes. A bad edit is a bad edit. Miii checkpoints every file before touching it.
29
30
 
30
- **Miii flips the model.** Run on Ollama: $0/month, fully offline, code never leaves your machine. Switch to Anthropic or OpenAI when you need cloud power. Change providers live inside the app no config files, no restarts.
31
+ The gap is what miii adds on top: file checkpoints before every edit, npm skills, live model switching, and full air-gap support.
31
32
 
32
- Your compute. Your data. Your rules.
33
+ - **16 GB RAM, a GPU** — if you're already running Ollama, miii adds $0 to your stack
34
+ - **Try Llama 3, Mistral, Qwen, Phi** side by side without switching tools
35
+ - **Literally cannot use cloud AI** — miii with Ollama is purpose-built for zero-internet environments
36
+
37
+ ---
38
+
39
+ ## What Miii Actually Does
40
+
41
+ Not a chatbot with a file-write button. Miii is a **full autonomous agent loop** — reasons, plans, acts, self-corrects until the task is done.
42
+
43
+ 1. Describe a goal in plain English
44
+ 2. Miii reads your codebase, maps the changes, shows the plan
45
+ 3. Asks permission before touching anything — every edit, command, delete
46
+ 4. Shows exact diff of what changes *before* you approve
47
+ 5. Runs tests. If they fail, reads the error, fixes autonomously
48
+ 6. Every file checkpointed — hit Esc and everything rolls back
49
+
50
+ ---
51
+
52
+ ## What a Real Session Looks Like
53
+
54
+ ```
55
+ > refactor the auth module to use JWT instead of sessions
56
+
57
+ ● thinking…
58
+ ● read_file src/auth/session.ts (42 lines)
59
+ ● read_file src/middleware/auth.ts (28 lines)
60
+
61
+ ─ plan (2 actions)
62
+ ◦ edit_file src/auth/session.ts
63
+ ◦ edit_file src/middleware/auth.ts
64
+
65
+ ⚠ edit_file src/auth/session.ts
66
+ ┌─ diff preview ──────────────────────┐
67
+ │ - const session = req.session.user │
68
+ │ + const token = verifyJWT(req) │
69
+ └─────────────────────────────────────┘
70
+ y approve s approve all n deny
71
+ > s
72
+
73
+ ● edit_file src/auth/session.ts done
74
+ ● edit_file src/middleware/auth.ts done
75
+ ● run_tests ✅ passed
76
+ ─ done in 14.2s · branch: miii/task-2025-05-17-14-32
77
+ ```
78
+
79
+ Parallel file reads. Diff preview before approval. Auto-branched off `main`. Tests ran. Session over.
33
80
 
34
81
  ---
35
82
 
@@ -39,169 +86,153 @@ Your compute. Your data. Your rules.
39
86
  |---|:---:|:---:|:---:|:---:|:---:|
40
87
  | Monthly cost | **$0** | $20–200 | API cost | API cost | $0 |
41
88
  | Bundle size | **176 KB** | ~50 MB | ~30 MB | ~20 MB | ~200 MB |
89
+ | Startup time | **<100ms** | ~2s | ~1s | ~1s | ~4s |
42
90
  | Local / offline (Ollama) | **✅** | ❌ | partial | ❌ | ⚠️ |
43
91
  | Air-gapped | **✅** | ❌ | ❌ | ❌ | ❌ |
44
- | Switch provider live | **✅** | ❌ | | ❌ | |
92
+ | Any model | **✅** | ❌ | partial | ❌ | |
45
93
  | File checkpoints (undo) | **✅** | ❌ | ❌ | ❌ | ❌ |
46
- | Permission gates | **✅** | | partial | | ❌ |
47
- | MCP client | **✅** | | | ❌ | ❌ |
94
+ | Diff preview before approve | **✅** | | | | ❌ |
95
+ | Git auto-branch on edit | **✅** | | | ❌ | ❌ |
96
+ | Switch provider live | **✅** | ❌ | ❌ | ❌ | ❌ |
97
+ | Native tool_calls (Anthropic + OpenAI) | **✅** | ✅ | ✅ | ✅ | ❌ |
98
+ | Parallel read-only tools | **✅** | partial | ❌ | ❌ | ❌ |
99
+ | Two-phase plan→execute | **✅** | ❌ | ❌ | ❌ | ❌ |
100
+ | Live streaming toggle | **✅** | always on | always on | always on | ❌ |
48
101
  | Semantic codebase index | **✅** | ❌ | ❌ | ❌ | ❌ |
49
- | Skill/extension system | **✅** | plugins | ❌ | ❌ | ❌ |
50
- | Startup time | **<100ms** | ~2s | ~1s | ~1s | ~4s |
102
+ | npm skills | **✅** | plugins | ❌ | ❌ | ❌ |
103
+ | MCP client | **✅** | | | | |
51
104
  | License | **MIT** | Proprietary | MIT | MIT | Apache 2.0 |
52
105
 
53
106
  ---
54
107
 
55
- ## How it Works
108
+ ## Eight Core Capabilities
56
109
 
57
- Miii isn't just autocompleteit's a **full autonomous agent loop** that reasons through complex tasks:
110
+ **Local / Offline**Ollama runs on your machine. No internet required after model pull.
58
111
 
59
- 1. You describe a goal
60
- 2. Miii reads your codebase, plans the changes, edits the files
61
- 3. It asks your permission before touching anything (edit, delete, run commands)
62
- 4. It runs your test suite automatically after every change
63
- 5. If tests fail, it reads the error, fixes the code, re-runs
64
- 6. It repeats until the work is done — and checkpoints every file so you can abort safely
112
+ **Air-Gapped Ready** — regulated industries, defense, offline infrastructure. miii with Ollama works where cloud literally cannot.
65
113
 
66
- ---
67
-
68
- ## What a Session Looks Like
69
-
70
- ```
71
- > refactor the auth module to use JWT instead of sessions
114
+ **Any Model** — Llama 3, Mistral, Qwen, Phi, or switch to Anthropic/OpenAI live. One tool, every model.
72
115
 
73
- Researching: refactor auth module to use JWT
74
- ● Reading src/auth/session.ts
75
- Read 42 lines
76
- ● Reading src/middleware/auth.ts
77
- Read 28 lines
116
+ **File Checkpoints** every file snapshotted before edit. Abort = full rollback. No bad edits stick.
78
117
 
79
- plan (2 actions)
80
- ◦ edit_file src/auth/session.ts
81
- ◦ edit_file src/middleware/auth.ts
118
+ **Permission Gates + Diff Preview** — approve every write, delete, or command. See the exact diff before you say yes.
82
119
 
83
- edit_file src/auth/session.ts y approve n deny
84
- > y
120
+ **MCP Client** — plug in any MCP-compatible tool server. Tools discovered automatically.
85
121
 
86
- edit_file src/auth/session.ts
87
- Wrote 12 lines
88
- ● edit_file src/middleware/auth.ts
89
- Wrote 8 lines
90
- ● run_tests
91
- ✅ Tests passed
122
+ **npm Skills** — extend miii with plain Markdown files or npm packages. Ship reusable agent behaviors to your whole team.
92
123
 
93
- refactor done2 file(s) processed
94
- ```
124
+ **$0 / Month**no subscription, no invoice, no API key required for local use.
95
125
 
96
126
  ---
97
127
 
98
- ## 🚀 Core Capabilities
99
-
100
- **🔒 Privacy-First, Local by Default**
101
- Run on Ollama and your code never leaves your machine. No account. No API key. No monthly bill. Switch to Anthropic or OpenAI when you need it — one command, live, mid-session.
102
-
103
- **🔄 Live Provider Switching**
104
- Type `/config` to open an interactive picker. Arrow-navigate between Ollama, Anthropic, and OpenAI-compatible endpoints. Change model, API key, base URL, or Tavily key without restarting. Config saves automatically.
128
+ ## Features Worth Knowing
105
129
 
106
- **🛡 Permission Gates + File Checkpoints**
107
- Miii asks before every edit, delete, or shell command — just like Claude Code. Every file is checkpointed before it's touched. Hit Esc to abort and all changes roll back automatically.
130
+ **Git Auto-Branch** first approved edit auto-creates `miii/task-YYYY-MM-DD-HH-MM`. Your `main` is never touched until you decide.
108
131
 
109
- **🔍 Semantic Codebase Indexing**
110
- Build a vector index of your entire codebase using local embeddings. Ask "where is the auth logic?" and Miii finds it by meaning, not keyword. No data leaves your machine.
132
+ **Parallel Read-Only Tools** — reading five files + git status + web search? All fire at once. Write ops stay sequential. Speed where safe, safety where it matters.
111
133
 
112
- **🧠 Deep Think Engine**
113
- Before answering complex questions, Miii runs a constrained research phase — reading files, checking git history, searching the web — then synthesizes a grounded answer.
114
-
115
- **🌐 Real-Time Web Access**
116
- Tavily-powered web search, built in. Ask about breaking changes in a library you just upgraded. Get an answer that's actually current.
117
-
118
- **🛠 Surgical File Editing**
119
- `patch_file` replaces exact strings in your files. No full rewrites. No formatting destruction. Exactly the change, nothing more.
120
-
121
- **🔁 Self-Healing Test Loop**
122
- Runs `npm test` after every file change. If something breaks, reads the failure trace and fixes it autonomously — up to 3 retries before surfacing the issue.
134
+ **Two-Phase Plan Execute**
135
+ ```
136
+ /plan exec refactor the payment module
137
+ ```
138
+ First turn: numbered plan, tools disabled you read it, decide. Second turn: execution with plan as context. No surprises.
123
139
 
124
- **📂 Persistent Sessions**
125
- Pick up exactly where you left off. Named sessions mean your context, history, and goal survive terminal restarts.
140
+ **Native Tool Calls** — Anthropic uses `tool_use` blocks, OpenAI uses `tool_calls` arrays, exactly as the API intended. Faster, more reliable, less hallucination. Ollama uses compact XML fallback.
126
141
 
127
- **📦 Skill System**
128
- Extend Miii with plain Markdown files or npm packages. Ship reusable agent behaviors as versioned packages your whole team can pull.
142
+ **Live Streaming Toggle** — turn on in `/config` to watch tokens appear in real time. Turn off for clean batch output. Toggle mid-session, no restart.
129
143
 
130
- **🔌 MCP Client**
131
- Connect any MCP-compatible tool server. Miii discovers tools automatically and makes them available to the agent.
144
+ **Semantic Codebase Search** — local vector index, no embeddings sent anywhere. `/index build` once. Ask "where is the payment logic?" by meaning, not grep.
132
145
 
133
146
  ---
134
147
 
135
- ## Quick Start
148
+ ## Quick Start
136
149
 
137
150
  ```bash
138
- # 1. Start Ollama and pull a model
151
+ # Local free, offline (recommended)
139
152
  ollama pull qwen2.5-coder:7b
153
+ npm install -g miii-cli
154
+ cd your-project && miii
140
155
 
141
- # 2. Install Miii
156
+ # Anthropic Claude
142
157
  npm install -g miii-cli
158
+ ANTHROPIC_API_KEY=sk-... miii
143
159
 
144
- # 3. Go to your project and start
145
- cd your-project
146
- miii
160
+ # OpenAI or compatible endpoint
161
+ npm install -g miii-cli
162
+ miii # set key + base URL in /config
147
163
  ```
148
164
 
149
- No API keys. No account. No sign-up form. First run walks you through setup interactively.
165
+ Hardware requirements are real this runs on your machine, not a server farm.
166
+
167
+ | | Minimum | Recommended |
168
+ |---|---|---|
169
+ | RAM | 16 GB | 32 GB+ |
170
+ | GPU | integrated | dedicated |
171
+ | Storage | 10 GB | 20 GB+ |
150
172
 
151
173
  ---
152
174
 
153
- ## ⌨️ Power Commands
175
+ ## Commands
154
176
 
155
177
  | Command | What it does |
156
178
  |---|---|
157
- | `/config` | Open interactive picker — change provider, model, API key, base URL, Tavily key live |
158
- | `/think <question>` | Deep research: reads files + web, then answers |
159
- | `/refactor <goal>` | Autonomous multi-file refactor with test validation |
160
- | `/index build` | Build semantic vector index of your codebase |
161
- | `/index search <query>` | Find code by meaning, not string match |
162
- | `/git review` | AI reviews your current diff for bugs and issues |
163
- | `/git commit <msg>` | Stage everything and commit in one shot |
164
- | `/plan <topic>` | Structured planning mode before you write a line |
165
- | `/model <name>` | Hot-swap your LLM mid-conversation |
166
- | `/session <name>` | Switch between named project sessions |
167
- | `/watch <path>` | Monitor files for changes and trigger agent reactions |
168
- | `@filename` | Inject any file directly into context |
169
-
170
- ---
171
-
172
- ## Semantic Codebase Indexing
173
-
174
- For large codebases, Miii builds and queries a local vector index — no third-party APIs, no embeddings sent anywhere.
175
-
176
- ```bash
177
- # Pull an embedding model (one time)
178
- ollama pull nomic-embed-text
179
-
180
- # Index your project
181
- /index build
182
-
183
- # The agent calls search_codebase automatically when it needs to find code by concept
184
- ```
179
+ | `/config` | Interactive picker — provider, model, API key, base URL, Tavily, streaming |
180
+ | `/plan exec <task>` | Two-phase: plan turn (no tools) execute with plan as context |
181
+ | `/think <question>` | Deep research: reads files + web, synthesizes answer |
182
+ | `/index build` | Build local semantic vector index |
183
+ | `/index search <q>` | Find code by concept, not string match |
184
+ | `/git review` | AI reviews current diff bugs, risks, style |
185
+ | `/git commit <msg>` | Stage everything, commit in one shot |
186
+ | `/model <name>` | Hot-swap LLM mid-conversation |
187
+ | `/session <name>` | Named sessions resume exactly where you left off |
188
+ | `@filename` | Inject any file into context |
189
+
190
+ Commands open in a picker select to insert into input, Enter to run.
185
191
 
186
192
  ---
187
193
 
188
194
  ## Configuration
189
195
 
190
- **Interactive (recommended):** type `/config` inside Miii to open the picker.
196
+ **Interactive:** type `/config` inside miii.
191
197
 
192
- **File-based:** drop a `.miii.json` in your project root or `~/.config/miii/config.json` globally:
198
+ **File-based:** `.miii.json` in project root or `~/.config/miii/config.json` globally:
193
199
 
194
200
  ```json
195
201
  {
196
- "model": "qwen2.5-coder:7b",
197
202
  "provider": "ollama",
198
203
  "baseUrl": "http://localhost:11434",
199
204
  "gitContext": true,
205
+ "streaming": false,
200
206
  "embedModel": "nomic-embed-text"
201
207
  }
202
208
  ```
203
209
 
204
- Providers: `ollama` (local, free) · `anthropic` (Claude API) · `openai-compat` (OpenAI or any compatible endpoint)
210
+ ---
211
+
212
+ ## MCP — Connect Any Tool Server
213
+
214
+ ```json
215
+ {
216
+ "mcpServers": {
217
+ "postgres": {
218
+ "command": "npx",
219
+ "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
220
+ }
221
+ }
222
+ }
223
+ ```
224
+
225
+ Drop into global config. Tools discovered automatically.
226
+
227
+ ---
228
+
229
+ ## Semantic Index Setup
230
+
231
+ ```bash
232
+ ollama pull nomic-embed-text # one time
233
+ /index build # inside your project
234
+ # agent calls search_codebase automatically from here
235
+ ```
205
236
 
206
237
  ---
207
238
 
@@ -214,27 +245,28 @@ cd miii-cli && npm install && npm run build && npm link
214
245
 
215
246
  ---
216
247
 
217
- ## Who Should Use Miii
248
+ ## Who This Is For
218
249
 
219
- - **Privacy-conscious developers** — won't send proprietary code to Anthropic or OpenAI
220
- - **Cost-sensitive teams** — API bills compound; Ollama is $0
221
- - **Air-gapped environments** — regulated industries, defense, offline infra
222
- - **Model experimenters** — want to try llama3, mistral, qwen, Claude side-by-side without switching tools
250
+ **Privacy-conscious developers** — proprietary code stays on your machine, always.
223
251
 
224
- ---
252
+ **Cost-sensitive teams** — API bills compound for every developer on the team, every month.
225
253
 
226
- ## The Bottom Line
254
+ **Air-gapped environments** regulated industries, defense, offline infrastructure where cloud is not an option.
227
255
 
228
- The AI coding tools you're paying for right now will raise their prices, change their terms, and keep reading your code. **Miii won't.** It's MIT licensed, runs locally, and gets better every time Ollama ships a new model.
256
+ **Model experimenters** benchmark Llama 3 vs Qwen vs Claude vs GPT-4o in the same workflow.
229
257
 
230
- If this saves you time or money, **star the repo** — it's the only metric that tells other engineers this is worth their attention.
258
+ **Anyone who's had an AI silently rewrite something they didn't want rewritten.**
231
259
 
232
- **[⭐ Star on GitHub](https://github.com/maruakshay/miii-cli)**
260
+ ---
233
261
 
234
- > Built by [@maruakshay](https://github.com/maruakshay) open to PRs, issues, and model recommendations.
262
+ The AI coding tools you're paying for will raise prices, change terms, and keep reading your code. Miii won't. MIT licensed, runs locally, gets better every time Ollama ships a new model.
235
263
 
236
- ---
264
+ **If this is the tool you've been waiting for — [⭐ star it](https://github.com/maruakshay/miii-cli) and tell someone.**
237
265
 
238
- ## License
266
+ > Built by [@maruakshay](https://github.com/maruakshay) — PRs, issues, and model recommendations welcome.
267
+ >
268
+ > [miii.in](https://www.miii.in)
269
+
270
+ ---
239
271
 
240
272
  MIT — do whatever you want with it.
package/dist/config.js CHANGED
@@ -6,7 +6,7 @@ const defaults = {
6
6
  provider: 'ollama',
7
7
  baseUrl: 'http://localhost:11434',
8
8
  };
9
- const ALLOWED_KEYS = new Set(['model', 'provider', 'baseUrl', 'systemPrompt', 'apiKey', 'gitContext', 'tavilyApiKey', 'embedModel']);
9
+ const ALLOWED_KEYS = new Set(['model', 'provider', 'baseUrl', 'systemPrompt', 'apiKey', 'gitContext', 'streaming', 'tavilyApiKey', 'embedModel']);
10
10
  const PROJECT_CONFIG = join(process.cwd(), '.miii.json');
11
11
  const GLOBAL_CONFIG = join(homedir(), '.config', 'miii', 'config.json');
12
12
  export function saveConfig(config) {
@@ -1,9 +1,7 @@
1
- // Transient errors worth retrying: rate limits + server-side faults
2
1
  const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 529]);
3
2
  const MAX_RETRIES = 4;
4
3
  const MAX_DELAY_MS = 30_000;
5
4
  function retryDelay(attempt) {
6
- // Exponential backoff: 1s → 2s → 4s → 8s, capped at 30s, ±20% jitter
7
5
  const base = 1_000 * Math.pow(2, attempt);
8
6
  const capped = Math.min(base, MAX_DELAY_MS);
9
7
  return Math.round(capped * (0.8 + Math.random() * 0.4));
@@ -43,6 +41,37 @@ async function fetchWithRetry(url, init, signal, onRetry) {
43
41
  }
44
42
  throw new Error('fetchWithRetry: exhausted retries without returning');
45
43
  }
44
+ // Convert Tool params string to JSON Schema for native tool_calls APIs
45
+ function paramsToSchema(paramsStr) {
46
+ try {
47
+ const obj = JSON.parse(paramsStr);
48
+ const properties = {};
49
+ const required = [];
50
+ for (const [key, typeStr] of Object.entries(obj)) {
51
+ const isOptional = typeStr.toLowerCase().includes('optional');
52
+ const isArray = typeStr.toLowerCase().includes('[]') || typeStr.toLowerCase().startsWith('array');
53
+ const base = typeStr.split(' ')[0].toLowerCase().replace('[]', '');
54
+ if (isArray) {
55
+ properties[key] = { type: 'array', items: { type: 'string' } };
56
+ }
57
+ else if (base === 'boolean') {
58
+ properties[key] = { type: 'boolean' };
59
+ }
60
+ else if (base === 'number') {
61
+ properties[key] = { type: 'number' };
62
+ }
63
+ else {
64
+ properties[key] = { type: 'string' };
65
+ }
66
+ if (!isOptional)
67
+ required.push(key);
68
+ }
69
+ return { type: 'object', properties, required };
70
+ }
71
+ catch {
72
+ return { type: 'object', properties: {}, required: [] };
73
+ }
74
+ }
46
75
  export async function warmup(provider, baseUrl, model) {
47
76
  if (provider !== 'ollama')
48
77
  return;
@@ -121,12 +150,21 @@ async function chatOllama(cfg) {
121
150
  }
122
151
  }
123
152
  async function chatOpenAI(cfg) {
124
- const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry } = cfg;
153
+ const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry, tools, toolChoice } = cfg;
154
+ const body = { model, messages, stream: !!onChunk };
155
+ if (tools?.length) {
156
+ body.tools = tools.map(t => ({
157
+ type: 'function',
158
+ function: { name: t.name, description: t.description, parameters: paramsToSchema(t.params) },
159
+ }));
160
+ if (toolChoice === 'none')
161
+ body.tool_choice = 'none';
162
+ }
125
163
  try {
126
164
  const res = await fetchWithRetry(`${baseUrl}/v1/chat/completions`, {
127
165
  method: 'POST',
128
166
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey ?? 'local'}` },
129
- body: JSON.stringify({ model, messages, stream: !!onChunk }),
167
+ body: JSON.stringify(body),
130
168
  }, signal, onRetry);
131
169
  if (!res.ok) {
132
170
  onError(new Error(`LLM ${res.status}: ${await res.text()}`));
@@ -135,13 +173,26 @@ async function chatOpenAI(cfg) {
135
173
  if (!onChunk) {
136
174
  const obj = await res.json();
137
175
  onUsage?.(obj?.usage?.prompt_tokens ?? 0, obj?.usage?.completion_tokens ?? 0);
138
- await onDone(obj?.choices?.[0]?.message?.content ?? '');
176
+ const message = obj?.choices?.[0]?.message;
177
+ let text = message?.content ?? '';
178
+ if (message?.tool_calls?.length) {
179
+ for (const tc of message.tool_calls) {
180
+ let args = {};
181
+ try {
182
+ args = JSON.parse(tc.function?.arguments ?? '{}');
183
+ }
184
+ catch { }
185
+ text += `\n<tool_call>\n{"name": ${JSON.stringify(tc.function?.name)}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
186
+ }
187
+ }
188
+ await onDone(text);
139
189
  return;
140
190
  }
141
191
  const reader = res.body.getReader();
142
192
  const decoder = new TextDecoder();
143
193
  let full = '';
144
194
  let buf = '';
195
+ const tcAccum = {};
145
196
  while (true) {
146
197
  const { done, value } = await reader.read();
147
198
  if (done)
@@ -157,15 +208,41 @@ async function chatOpenAI(cfg) {
157
208
  continue;
158
209
  try {
159
210
  const obj = JSON.parse(data);
160
- const chunk = obj?.choices?.[0]?.delta?.content ?? '';
211
+ const delta = obj?.choices?.[0]?.delta;
212
+ if (!delta)
213
+ continue;
214
+ const chunk = delta.content ?? '';
161
215
  if (chunk) {
162
216
  full += chunk;
163
217
  onChunk(chunk);
164
218
  }
219
+ if (delta.tool_calls) {
220
+ for (const tc of delta.tool_calls) {
221
+ const idx = tc.index ?? 0;
222
+ if (!tcAccum[idx])
223
+ tcAccum[idx] = { id: '', name: '', args: '' };
224
+ if (tc.id)
225
+ tcAccum[idx].id = tc.id;
226
+ if (tc.function?.name)
227
+ tcAccum[idx].name += tc.function.name;
228
+ if (tc.function?.arguments)
229
+ tcAccum[idx].args += tc.function.arguments;
230
+ }
231
+ }
165
232
  }
166
233
  catch { }
167
234
  }
168
235
  }
236
+ // Serialize accumulated tool_calls to XML for run loop compatibility
237
+ for (const idx of Object.keys(tcAccum).map(Number).sort((a, b) => a - b)) {
238
+ const tc = tcAccum[idx];
239
+ let args = {};
240
+ try {
241
+ args = JSON.parse(tc.args);
242
+ }
243
+ catch { }
244
+ full += `\n<tool_call>\n{"name": ${JSON.stringify(tc.name)}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
245
+ }
169
246
  await onDone(full);
170
247
  }
171
248
  catch (err) {
@@ -174,20 +251,30 @@ async function chatOpenAI(cfg) {
174
251
  }
175
252
  }
176
253
  async function chatAnthropic(cfg) {
177
- const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onRetry } = cfg;
254
+ const { model, messages, baseUrl, apiKey, signal, onDone, onError, onUsage, onChunk, onRetry, tools, toolChoice } = cfg;
178
255
  const url = baseUrl && baseUrl !== 'http://localhost:11434'
179
256
  ? `${baseUrl}/v1/messages`
180
257
  : 'https://api.anthropic.com/v1/messages';
181
258
  const systemParts = messages.filter(m => m.role === 'system').map(m => m.content);
182
259
  const filtered = messages.filter(m => m.role !== 'system');
260
+ const body = {
261
+ model,
262
+ max_tokens: 8192,
263
+ stream: !!onChunk,
264
+ messages: filtered,
265
+ };
266
+ if (systemParts.length)
267
+ body.system = systemParts.join('\n\n');
268
+ if (tools?.length) {
269
+ body.tools = tools.map(t => ({
270
+ name: t.name,
271
+ description: t.description,
272
+ input_schema: paramsToSchema(t.params),
273
+ }));
274
+ if (toolChoice === 'none')
275
+ body.tool_choice = { type: 'none' };
276
+ }
183
277
  try {
184
- const body = {
185
- model,
186
- max_tokens: 8192,
187
- messages: filtered,
188
- };
189
- if (systemParts.length)
190
- body.system = systemParts.join('\n\n');
191
278
  const res = await fetchWithRetry(url, {
192
279
  method: 'POST',
193
280
  headers: {
@@ -201,10 +288,86 @@ async function chatAnthropic(cfg) {
201
288
  onError(new Error(`Anthropic ${res.status}: ${await res.text()}`));
202
289
  return;
203
290
  }
204
- const obj = await res.json();
205
- const text = (obj.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('');
206
- onUsage?.(obj.usage?.input_tokens ?? 0, obj.usage?.output_tokens ?? 0);
207
- await onDone(text);
291
+ if (!onChunk) {
292
+ const obj = await res.json();
293
+ let fullText = '';
294
+ for (const block of obj?.content ?? []) {
295
+ if (block.type === 'text')
296
+ fullText += block.text ?? '';
297
+ else if (block.type === 'tool_use') {
298
+ const args = block.input ?? {};
299
+ fullText += `\n<tool_call>\n{"name": ${JSON.stringify(block.name ?? '')}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
300
+ }
301
+ }
302
+ onUsage?.(obj?.usage?.input_tokens ?? 0, obj?.usage?.output_tokens ?? 0);
303
+ await onDone(fullText);
304
+ return;
305
+ }
306
+ const reader = res.body.getReader();
307
+ const decoder = new TextDecoder();
308
+ let buf = '';
309
+ let fullText = '';
310
+ let promptTokens = 0;
311
+ let completionTokens = 0;
312
+ // Track native tool_use content blocks
313
+ const toolBlocks = [];
314
+ let activeToolIdx = -1;
315
+ while (true) {
316
+ const { done, value } = await reader.read();
317
+ if (done)
318
+ break;
319
+ buf += decoder.decode(value, { stream: true });
320
+ const lines = buf.split('\n');
321
+ buf = lines.pop() ?? '';
322
+ for (const line of lines) {
323
+ if (!line.startsWith('data: '))
324
+ continue;
325
+ const data = line.slice(6).trim();
326
+ if (!data || data === '[DONE]')
327
+ continue;
328
+ try {
329
+ const evt = JSON.parse(data);
330
+ if (evt.type === 'message_start') {
331
+ promptTokens = (evt.message?.usage?.input_tokens) ?? 0;
332
+ }
333
+ else if (evt.type === 'content_block_start') {
334
+ const block = evt.content_block;
335
+ if (block.type === 'tool_use') {
336
+ activeToolIdx = toolBlocks.length;
337
+ toolBlocks.push({ id: block.id ?? '', name: block.name ?? '', inputJson: '' });
338
+ }
339
+ }
340
+ else if (evt.type === 'content_block_delta') {
341
+ const delta = evt.delta;
342
+ if (delta.type === 'text_delta' && delta.text) {
343
+ fullText += delta.text;
344
+ onChunk?.(delta.text);
345
+ }
346
+ else if (delta.type === 'input_json_delta' && activeToolIdx >= 0) {
347
+ toolBlocks[activeToolIdx].inputJson += delta.partial_json ?? '';
348
+ }
349
+ }
350
+ else if (evt.type === 'content_block_stop') {
351
+ activeToolIdx = -1;
352
+ }
353
+ else if (evt.type === 'message_delta') {
354
+ completionTokens = (evt.usage?.output_tokens) ?? 0;
355
+ }
356
+ }
357
+ catch { }
358
+ }
359
+ }
360
+ // Serialize native tool_use blocks to XML for run loop compatibility
361
+ for (const block of toolBlocks) {
362
+ let args = {};
363
+ try {
364
+ args = JSON.parse(block.inputJson);
365
+ }
366
+ catch { }
367
+ fullText += `\n<tool_call>\n{"name": ${JSON.stringify(block.name)}, "args": ${JSON.stringify(args)}}\n</tool_call>`;
368
+ }
369
+ onUsage?.(promptTokens, completionTokens);
370
+ await onDone(fullText);
208
371
  }
209
372
  catch (err) {
210
373
  if (err?.name !== 'AbortError')
@@ -22,7 +22,9 @@ export class MCPClient {
22
22
  stdio: ['pipe', 'pipe', 'pipe'],
23
23
  env: { ...process.env, ...cfg.env },
24
24
  });
25
- this.proc.stderr?.on('data', () => { });
25
+ this.proc.stderr?.on('data', (d) => {
26
+ d.toString().split('\n').filter(Boolean).forEach(line => process.stderr.write(`[MCP:${this.name}] ${line}\n`));
27
+ });
26
28
  const rl = createInterface({ input: this.proc.stdout });
27
29
  rl.on('line', (line) => {
28
30
  if (!line.trim())
@@ -26,12 +26,43 @@ export function extractFacts(messages, config, model) {
26
26
  ],
27
27
  onDone(text) {
28
28
  try {
29
- const m = text.match(/\[[\s\S]*?\]/);
30
- if (!m) {
29
+ const start = text.indexOf('[');
30
+ if (start === -1) {
31
31
  resolve([]);
32
32
  return;
33
33
  }
34
- const arr = JSON.parse(m[0]);
34
+ let depth = 0, inStr = false, esc = false, end = -1;
35
+ for (let i = start; i < text.length; i++) {
36
+ const ch = text[i];
37
+ if (esc) {
38
+ esc = false;
39
+ continue;
40
+ }
41
+ if (ch === '\\' && inStr) {
42
+ esc = true;
43
+ continue;
44
+ }
45
+ if (ch === '"') {
46
+ inStr = !inStr;
47
+ continue;
48
+ }
49
+ if (inStr)
50
+ continue;
51
+ if (ch === '[')
52
+ depth++;
53
+ else if (ch === ']') {
54
+ depth--;
55
+ if (depth === 0) {
56
+ end = i;
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ if (end === -1) {
62
+ resolve([]);
63
+ return;
64
+ }
65
+ const arr = JSON.parse(text.slice(start, end + 1));
35
66
  resolve(Array.isArray(arr) ? arr.filter((f) => typeof f === 'string') : []);
36
67
  }
37
68
  catch {
@@ -30,7 +30,7 @@ What still needs to be done, if anything.
30
30
  Any constraints, errors encountered, important facts the agent must remember to continue correctly.
31
31
 
32
32
  Be factual. No padding. Include file paths, error messages, and command outputs verbatim when relevant.`;
33
- export async function compactContext(messages, cfg, goal) {
33
+ export async function compactContext(messages, cfg, goal, signal) {
34
34
  if (contextSize(messages) <= COMPACT_CHAR_THRESHOLD)
35
35
  return messages;
36
36
  const system = messages[0]?.role === 'system' ? messages[0] : null;
@@ -50,6 +50,7 @@ export async function compactContext(messages, cfg, goal) {
50
50
  let compactErr = '';
51
51
  await chat({
52
52
  ...cfg,
53
+ signal,
53
54
  messages: [
54
55
  { role: 'system', content: COMPACT_SYSTEM },
55
56
  { role: 'user', content: userPrompt },
@@ -59,6 +60,8 @@ export async function compactContext(messages, cfg, goal) {
59
60
  });
60
61
  if (compactErr)
61
62
  console.error(`[compactor] LLM error: ${compactErr}`);
63
+ if (signal?.aborted)
64
+ return messages;
62
65
  // Fallback to dumb compaction if LLM fails
63
66
  if (!summary)
64
67
  return dumbCompact(messages, goal);
@@ -12,6 +12,7 @@ const MENU_ITEMS = [
12
12
  { key: 'key', label: 'API Key' },
13
13
  { key: 'url', label: 'Base URL' },
14
14
  { key: 'tavily', label: 'Tavily Key' },
15
+ { key: 'streaming', label: 'Streaming' },
15
16
  ];
16
17
  function truncate(s, n) {
17
18
  return s.length > n ? s.slice(0, n) + '…' : s;
@@ -76,7 +77,12 @@ export function ConfigPicker({ config, currentModel, tavilyKey, onUpdate, onTavi
76
77
  return;
77
78
  }
78
79
  if (key.return) {
79
- openScreen(MENU_ITEMS[menuIdx].key);
80
+ const item = MENU_ITEMS[menuIdx];
81
+ if (item.key === 'streaming') {
82
+ onUpdate({ streaming: !config.streaming });
83
+ return;
84
+ }
85
+ openScreen(item.key);
80
86
  return;
81
87
  }
82
88
  return;
@@ -161,7 +167,11 @@ export function ConfigPicker({ config, currentModel, tavilyKey, onUpdate, onTavi
161
167
  val = truncate(config.baseUrl, 36);
162
168
  if (item.key === 'tavily')
163
169
  val = tavilyDisplay;
164
- return (_jsxs(Box, { children: [_jsxs(Text, { color: active ? 'cyan' : 'white', bold: active, children: [active ? '▶ ' : ' ', item.label.padEnd(12)] }), _jsx(Text, { color: active ? 'white' : 'gray', children: val })] }, item.key));
170
+ const isStreaming = item.key === 'streaming';
171
+ const streamingOn = config.streaming === true;
172
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: active ? 'cyan' : 'white', bold: active, children: [active ? '▶ ' : ' ', item.label.padEnd(12)] }), isStreaming
173
+ ? _jsx(Text, { color: streamingOn ? 'green' : 'gray', children: streamingOn ? 'on' : 'off' })
174
+ : _jsx(Text, { color: active ? 'white' : 'gray', children: val })] }, item.key));
165
175
  }), screen === 'provider' && PROVIDERS.map((p, i) => {
166
176
  const active = i === provIdx;
167
177
  const current = p.key === config.provider;
@@ -21,6 +21,7 @@ const BUILTIN_COMMANDS = [
21
21
  { ns: 'builtin', name: 'list', description: 'list all loaded skills and their descriptions' },
22
22
  // ── AI modes ─────────────────────────────────────────────────────────────
23
23
  { ns: 'builtin', name: 'plan', description: 'enter planning mode — AI helps think through a goal step-by-step' },
24
+ { ns: 'builtin', name: 'plan exec', description: 'two-phase: AI outputs plan first (no tools), say "go" to execute — /plan exec <task>' },
24
25
  { ns: 'builtin', name: 'refactor', description: 'multi-file AI refactor — plans, reads, then edits — /refactor <goal>' },
25
26
  { ns: 'builtin', name: 'think', description: 'deep research before answering — reads files + optional web — /think <query>' },
26
27
  { ns: 'builtin', name: 'watch', description: 'watch for file changes, run tests, auto-fix failures — /watch stop to cancel' },
@@ -176,8 +177,10 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
176
177
  : skill.ns === 'git'
177
178
  ? `/git ${skill.name}`
178
179
  : `/${skill.ns}:${skill.name}`;
179
- clearInput();
180
- onSubmit(name);
180
+ setLines([name]);
181
+ setCursor({ row: 0, col: name.length });
182
+ setOverlay('none');
183
+ setOverlayIdx(0);
181
184
  }
182
185
  function selectFile(file) {
183
186
  const r = cursor.row;
@@ -1,5 +1,8 @@
1
1
  import { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ const runCmd = promisify(exec);
3
6
  import { chat } from '../../llm/stream.js';
4
7
  import { tools as staticTools } from '../../tools/index.js';
5
8
  import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
@@ -10,6 +13,7 @@ const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'update_file', 'del
10
13
  const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
11
14
  const PERMISSION_TOOLS = new Set(['edit_file', 'update_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
12
15
  const CHECKPOINT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'delete_file']);
16
+ const PARALLEL_SAFE = new Set(['read_file', 'list_files', 'git_status', 'git_log', 'git_diff', 'web_search', 'web_extract']);
13
17
  // Tool result messages that are ephemeral — never worth storing in memory or compact summaries
14
18
  const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted|^\[file updated:/;
15
19
  export function stripEphemeral(messages) {
@@ -23,6 +27,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
23
27
  const [permissionRequest, setPermissionRequest] = useState(null);
24
28
  const permissionResolveRef = useRef(null);
25
29
  const checkpointRef = useRef(new Map());
30
+ const autoBranchedRef = useRef(null);
26
31
  const sessionApprovedRef = useRef(new Set());
27
32
  const thinkingStartRef = useRef(0);
28
33
  const extraToolsRef = useRef(extraTools);
@@ -42,7 +47,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
42
47
  const t = setInterval(() => setTick(n => n + 1), 80);
43
48
  return () => clearInterval(t);
44
49
  }, [status]);
45
- const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
50
+ const runLoop = useCallback(async (contextMsgs, depth = 0, goal, options) => {
46
51
  if (depth >= MAX_TOOL_DEPTH) {
47
52
  abortRef.current = null;
48
53
  setStatus('idle');
@@ -52,7 +57,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
52
57
  if (depth === 0) {
53
58
  thinkingStartRef.current = Date.now();
54
59
  checkpointRef.current.clear();
60
+ autoBranchedRef.current = null;
55
61
  }
62
+ abortRef.current = new AbortController();
56
63
  let msgs = contextMsgs;
57
64
  if (shouldCompact(contextMsgs)) {
58
65
  printer.systemMsg('compacting context…');
@@ -62,17 +69,31 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
62
69
  model: currentModelRef.current,
63
70
  baseUrl: config.baseUrl,
64
71
  apiKey: config.apiKey,
65
- }, goal);
72
+ }, goal, abortRef.current.signal);
73
+ if (abortRef.current.signal.aborted) {
74
+ setStatus('idle');
75
+ return;
76
+ }
66
77
  printer.systemMsg(`compacted: ${contextMsgs.length} → ${msgs.length} messages`);
67
78
  replaceHistoryRef.current?.(msgs.filter(m => m.role !== 'system'));
68
79
  }
69
- abortRef.current = new AbortController();
80
+ let didStream = false;
70
81
  await chat({
71
82
  provider: config.provider,
72
83
  model: currentModelRef.current,
73
84
  baseUrl: config.baseUrl,
85
+ apiKey: config.apiKey,
74
86
  messages: msgs,
87
+ tools: config.provider !== 'ollama' && !(options?.noTools && depth === 0) ? [...staticTools, ...extraToolsRef.current] : undefined,
88
+ toolChoice: (options?.noTools && depth === 0) ? 'none' : undefined,
75
89
  signal: abortRef.current.signal,
90
+ onChunk: config.streaming ? (chunk) => {
91
+ if (!didStream) {
92
+ printer.streamStart();
93
+ didStream = true;
94
+ }
95
+ printer.streamChunk(chunk);
96
+ } : undefined,
76
97
  onRetry(attempt, max, delayMs) {
77
98
  printer.systemMsg(`retry ${attempt}/${max} — waiting ${Math.round(delayMs / 1000)}s`);
78
99
  },
@@ -91,8 +112,10 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
91
112
  if (bare)
92
113
  pendingTools.push({ name: bare.name, args: bare.args });
93
114
  }
115
+ if (didStream)
116
+ printer.streamEnd();
94
117
  const displayText = textParts.join('').trim();
95
- if (displayText)
118
+ if (displayText && !didStream)
96
119
  printer.assistantMsg(displayText);
97
120
  pushHistoryRef.current({ role: 'assistant', content: fullText });
98
121
  if (pendingTools.length)
@@ -107,105 +130,154 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
107
130
  await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
108
131
  return;
109
132
  }
133
+ if (autoBranchedRef.current)
134
+ printer.systemMsg(`branch: ${autoBranchedRef.current} (git checkout main when done)`);
110
135
  printer.systemMsg(`done in ${printer.formatElapsed(Date.now() - thinkingStartRef.current)}`);
111
136
  setStatus('idle');
112
137
  return;
113
138
  }
114
139
  setStatus('tool');
115
140
  const next = [...msgs, { role: 'assistant', content: fullText }];
116
- try {
117
- for (const tc of pendingTools) {
141
+ const allParallelSafe = pendingTools.every(tc => PARALLEL_SAFE.has(tc.name));
142
+ if (allParallelSafe && pendingTools.length > 1) {
143
+ try {
144
+ setCurrentTool(pendingTools[0].name);
118
145
  const allTools = [...staticTools, ...extraToolsRef.current];
119
- const tool = allTools.find(t => t.name === tc.name);
120
- setCurrentTool(tc.name);
121
- if (PERMISSION_TOOLS.has(tc.name)) {
122
- const sessionKey = tc.name;
123
- let decision;
124
- if (sessionApprovedRef.current.has(sessionKey)) {
125
- decision = 'yes';
146
+ const settled = await Promise.allSettled(pendingTools.map(async (tc) => {
147
+ const tool = allTools.find(t => t.name === tc.name);
148
+ printer.toolCallStart(tc.name, tc.args);
149
+ if (!tool)
150
+ throw new Error(`unknown tool: ${tc.name}`);
151
+ const result = await tool.execute(tc.args);
152
+ printer.toolResultSummary(tc.name, tc.args, result);
153
+ if (SHOW_RESULT_TOOLS.has(tc.name))
154
+ printer.toolMsg(tc.name, result);
155
+ return { tc, result };
156
+ }));
157
+ for (const r of settled) {
158
+ if (r.status === 'fulfilled') {
159
+ next.push({ role: 'user', content: `Tool ${r.value.tc.name} result:\n${r.value.result}` });
126
160
  }
127
161
  else {
128
- decision = await new Promise(resolve => {
129
- permissionResolveRef.current = resolve;
130
- setPermissionRequest({ toolName: tc.name, args: tc.args });
131
- });
132
- }
133
- if (decision === 'session')
134
- sessionApprovedRef.current.add(sessionKey);
135
- if (decision === 'no') {
136
- printer.systemMsg(`denied: ${tc.name}`);
137
- const remaining = pendingTools.slice(pendingTools.indexOf(tc) + 1).map(t => t.name);
138
- const skippedNote = remaining.length ? ` The following tools were also skipped: ${remaining.join(', ')}.` : '';
139
- next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user.${skippedNote} Do not retry these tools unless the user explicitly asks.` });
140
- break;
162
+ const err = `Tool error: ${r.reason}`;
163
+ printer.errorMsg(err);
164
+ next.push({ role: 'user', content: err });
141
165
  }
142
- // Checkpoint: store pre-execution file state
143
- if (CHECKPOINT_TOOLS.has(tc.name)) {
144
- const path = tc.args.path;
145
- if (path && !checkpointRef.current.has(path)) {
146
- try {
147
- checkpointRef.current.set(path, readFileSync(path, 'utf-8'));
166
+ }
167
+ }
168
+ finally {
169
+ setCurrentTool(undefined);
170
+ }
171
+ }
172
+ else {
173
+ try {
174
+ for (const tc of pendingTools) {
175
+ const allTools = [...staticTools, ...extraToolsRef.current];
176
+ const tool = allTools.find(t => t.name === tc.name);
177
+ setCurrentTool(tc.name);
178
+ if (PERMISSION_TOOLS.has(tc.name)) {
179
+ const sessionKey = tc.name;
180
+ let decision;
181
+ if (sessionApprovedRef.current.has(sessionKey)) {
182
+ decision = 'yes';
183
+ }
184
+ else {
185
+ decision = await new Promise(resolve => {
186
+ permissionResolveRef.current = resolve;
187
+ setPermissionRequest({ toolName: tc.name, args: tc.args });
188
+ });
189
+ }
190
+ if (decision === 'session')
191
+ sessionApprovedRef.current.add(sessionKey);
192
+ if (decision === 'no') {
193
+ printer.systemMsg(`denied: ${tc.name}`);
194
+ const remaining = pendingTools.slice(pendingTools.indexOf(tc) + 1).map(t => t.name);
195
+ const skippedNote = remaining.length ? ` The following tools were also skipped: ${remaining.join(', ')}.` : '';
196
+ next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user.${skippedNote} Do not retry these tools unless the user explicitly asks.` });
197
+ break;
198
+ }
199
+ // Checkpoint: store pre-execution file state + auto-branch on first edit
200
+ if (CHECKPOINT_TOOLS.has(tc.name)) {
201
+ const path = tc.args.path;
202
+ if (path && !checkpointRef.current.has(path)) {
203
+ try {
204
+ checkpointRef.current.set(path, readFileSync(path, 'utf-8'));
205
+ }
206
+ catch {
207
+ checkpointRef.current.set(path, null);
208
+ }
148
209
  }
149
- catch {
150
- checkpointRef.current.set(path, null);
210
+ if (!autoBranchedRef.current) {
211
+ try {
212
+ const { stdout } = await runCmd('git rev-parse --abbrev-ref HEAD', { timeout: 3000 });
213
+ const branch = stdout.trim();
214
+ if (branch === 'main' || branch === 'master') {
215
+ const ts = new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-');
216
+ const newBranch = `miii/task-${ts}`;
217
+ await runCmd(`git checkout -b ${newBranch}`, { timeout: 5000 });
218
+ autoBranchedRef.current = newBranch;
219
+ printer.systemMsg(`auto-branched: ${newBranch}`);
220
+ }
221
+ }
222
+ catch { }
151
223
  }
152
224
  }
153
225
  }
154
- }
155
- if (tool) {
156
- try {
157
- // Guard: for update_file, verify old text still matches before executing.
158
- // If stale, inject fresh file content and skip — model will retry.
159
- if (tc.name === 'update_file') {
160
- const filePath = tc.args.path;
161
- const oldText = tc.args.old;
162
- if (filePath && oldText && existsSync(filePath)) {
163
- const norm = (s) => s.replace(/\r\n/g, '\n');
164
- const current = readFileSync(filePath, 'utf-8');
165
- const occurrences = norm(current).split(norm(oldText)).length - 1;
166
- if (occurrences === 0) {
167
- printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
168
- next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
169
- next.push({ role: 'user', content: `update_file failed: the <old> text you used does not exist in ${filePath}. The CURRENT file content is shown above. Re-read it carefully, find the exact text you want to replace, and retry update_file using text that exactly matches what is in the file now.` });
170
- continue;
226
+ if (tool) {
227
+ try {
228
+ // Guard: for update_file, verify old text still matches before executing.
229
+ // If stale, inject fresh file content and skip — model will retry.
230
+ if (tc.name === 'update_file') {
231
+ const filePath = tc.args.path;
232
+ const oldText = tc.args.old;
233
+ if (filePath && oldText && existsSync(filePath)) {
234
+ const norm = (s) => s.replace(/\r\n/g, '\n');
235
+ const current = readFileSync(filePath, 'utf-8');
236
+ const occurrences = norm(current).split(norm(oldText)).length - 1;
237
+ if (occurrences === 0) {
238
+ printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
239
+ next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
240
+ next.push({ role: 'user', content: `update_file failed: the <old> text you used does not exist in ${filePath}. The CURRENT file content is shown above. Re-read it carefully, find the exact text you want to replace, and retry update_file using text that exactly matches what is in the file now.` });
241
+ continue;
242
+ }
243
+ if (occurrences > 1) {
244
+ printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
245
+ next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
246
+ next.push({ role: 'user', content: `update_file failed: the <old> text matches ${occurrences} locations in ${filePath}. Add more surrounding lines to the <old> block to make it unique, then retry.` });
247
+ continue;
248
+ }
171
249
  }
172
- if (occurrences > 1) {
173
- printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
174
- next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
175
- next.push({ role: 'user', content: `update_file failed: the <old> text matches ${occurrences} locations in ${filePath}. Add more surrounding lines to the <old> block to make it unique, then retry.` });
176
- continue;
250
+ }
251
+ printer.toolCallStart(tc.name, tc.args);
252
+ const result = await tool.execute(tc.args);
253
+ printer.toolResultSummary(tc.name, tc.args, result);
254
+ if (SHOW_RESULT_TOOLS.has(tc.name))
255
+ printer.toolMsg(tc.name, result);
256
+ next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
257
+ if (FILE_EDIT_TOOLS.has(tc.name)) {
258
+ const filePath = tc.args.path;
259
+ if (filePath && existsSync(filePath)) {
260
+ const lineCount = readFileSync(filePath, 'utf-8').split('\n').length;
261
+ next.push({ role: 'user', content: `[file updated: ${filePath} — ${lineCount} lines]` });
177
262
  }
178
263
  }
179
264
  }
180
- printer.toolCallStart(tc.name, tc.args);
181
- const result = await tool.execute(tc.args);
182
- printer.toolResultSummary(tc.name, tc.args, result);
183
- if (SHOW_RESULT_TOOLS.has(tc.name))
184
- printer.toolMsg(tc.name, result);
185
- next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
186
- if (FILE_EDIT_TOOLS.has(tc.name)) {
187
- const filePath = tc.args.path;
188
- if (filePath && existsSync(filePath)) {
189
- const lineCount = readFileSync(filePath, 'utf-8').split('\n').length;
190
- next.push({ role: 'user', content: `[file updated: ${filePath} — ${lineCount} lines]` });
191
- }
265
+ catch (e) {
266
+ const err = `Tool ${tc.name} error: ${e}`;
267
+ printer.errorMsg(err);
268
+ next.push({ role: 'user', content: err });
192
269
  }
193
270
  }
194
- catch (e) {
195
- const err = `Tool ${tc.name} error: ${e}`;
196
- printer.errorMsg(err);
197
- next.push({ role: 'user', content: err });
271
+ else {
272
+ printer.errorMsg(`unknown tool: ${tc.name}`);
273
+ next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
198
274
  }
199
275
  }
200
- else {
201
- printer.errorMsg(`unknown tool: ${tc.name}`);
202
- next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
203
- }
204
276
  }
205
- }
206
- finally {
207
- setCurrentTool(undefined);
208
- }
277
+ finally {
278
+ setCurrentTool(undefined);
279
+ }
280
+ } // end sequential else
209
281
  // For file-edit turns: slim context (system + goal + fresh file states + recent results)
210
282
  // For non-edit turns: full next (model needs full conversational context)
211
283
  const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
@@ -261,6 +333,10 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
261
333
  if (restored > 0)
262
334
  printer.systemMsg(`restored ${restored} file(s) to pre-session state`);
263
335
  }
336
+ if (autoBranchedRef.current) {
337
+ printer.systemMsg(`task branch preserved: ${autoBranchedRef.current}`);
338
+ autoBranchedRef.current = null;
339
+ }
264
340
  setStatus('idle');
265
341
  }, []);
266
342
  return {
@@ -326,6 +326,18 @@ Analyze what exists, then implement the design. Use the design system above if a
326
326
  }
327
327
  return;
328
328
  }
329
+ if (cmd.startsWith('/plan exec ')) {
330
+ const task = cmd.slice(11).trim();
331
+ if (!task) {
332
+ printer.systemMsg('usage: /plan exec <task>');
333
+ return;
334
+ }
335
+ const planPrompt = `PLANNING TURN — output a numbered plan of exactly what you will do to accomplish this task. List which files to read, which to edit, and what changes to make. Do NOT call any tools in this response. After I review the plan and respond "go", you will execute.\n\nTask: ${task}`;
336
+ printer.userMsg(`/plan exec ${task}`);
337
+ pushHistory({ role: 'user', content: planPrompt });
338
+ await runLoop(buildContext(), 0, task, { noTools: true });
339
+ return;
340
+ }
329
341
  if (cmd === '/plan' || cmd.startsWith('/plan ')) {
330
342
  const topic = cmd.slice(5).trim();
331
343
  setPlanningMode(true);
@@ -161,8 +161,11 @@ export function assistantMsg(text) {
161
161
  const tail = lines.slice(idx + 1).join('\n');
162
162
  write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
163
163
  }
164
- export const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'write_file']);
165
- export const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
164
+ export function streamStart() { write(`\n${blue('')} `); }
165
+ export function streamChunk(s) { write(s); }
166
+ export function streamEnd() { write('\n'); }
167
+ export const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file']);
168
+ export const DELETE_TOOLS = new Set(['delete_file']);
166
169
  const PERM_DESC = {
167
170
  delete_file: 'delete this file',
168
171
  update_file: 'edit this file',
@@ -291,8 +294,7 @@ export function toolResultSummary(name, args, result) {
291
294
  const lines = result.trim().split('\n').filter(Boolean);
292
295
  let summary = '';
293
296
  switch (name) {
294
- case 'edit_file':
295
- case 'write_file': {
297
+ case 'edit_file': {
296
298
  const n = (a.content ?? '').split('\n').length;
297
299
  summary = `Wrote ${n} line${n === 1 ? '' : 's'}`;
298
300
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "The high-performance local AI coding agent for your terminal. Automate complex workflows with local LLMs.",
6
6
  "license": "MIT",