miii-agent 0.1.32 → 0.1.34

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.
Files changed (2) hide show
  1. package/dist/cli.js +177 -43
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -184,7 +184,7 @@ async function modelContext(entry, model) {
184
184
  }
185
185
  const msg = err instanceof Error ? err.message : String(err);
186
186
  if (msg.toLowerCase().includes("not found") || msg.toLowerCase().includes("unknown model")) {
187
- throw new Error(`Model "${model}" not found. Run: ollama pull ${model}`);
187
+ throw new Error(`I don't see a model called "${model}" on Ollama. Pull it first with: ollama pull ${model}`);
188
188
  }
189
189
  throw err;
190
190
  }
@@ -240,13 +240,17 @@ async function* chat(entry, model, messages, tools, opts) {
240
240
  }
241
241
  const msg = err instanceof Error ? err.message : String(err);
242
242
  if (msg.toLowerCase().includes("not found") || msg.toLowerCase().includes("unknown model")) {
243
- throw new Error(`Model "${model}" not found. Run: ollama pull ${model}`);
243
+ throw new Error(`I don't see a model called "${model}" on Ollama. Pull it first with: ollama pull ${model}`);
244
244
  }
245
245
  throw err;
246
246
  }
247
+ let sawOutput = false;
247
248
  try {
248
249
  for await (const chunk of stream) {
249
250
  if (signal?.aborted) break;
251
+ if (chunk.message.content || chunk.message.thinking || (chunk.message.tool_calls?.length ?? 0) > 0) {
252
+ sawOutput = true;
253
+ }
250
254
  yield {
251
255
  content: stripHarmony(chunk.message.content),
252
256
  thinking: stripHarmony(chunk.message.thinking),
@@ -266,8 +270,13 @@ async function* chat(entry, model, messages, tools, opts) {
266
270
  }
267
271
  const msg = err instanceof Error ? err.message : String(err);
268
272
  if (msg.includes("Did not receive done or success response in stream")) {
269
- yield { content: "", done: true, prompt_eval_count: 0, eval_count: 0 };
270
- return;
273
+ if (sawOutput) {
274
+ yield { content: "", done: true, prompt_eval_count: 0, eval_count: 0 };
275
+ return;
276
+ }
277
+ throw new Error(
278
+ "Ollama closed the stream before sending any output \u2014 the model likely unloaded, ran out of memory, or crashed mid-request. Check `ollama ps`, then retry or switch to a smaller model."
279
+ );
271
280
  }
272
281
  throw err;
273
282
  } finally {
@@ -278,8 +287,8 @@ var NOT_INSTALLED, NOT_RUNNING, LOCAL_HOST_RE, HARMONY_RE, CHANNEL_LABEL_RE;
278
287
  var init_ollama = __esm({
279
288
  "src/llm/ollama.ts"() {
280
289
  "use strict";
281
- NOT_INSTALLED = "Ollama is not installed. Install it with: npm i -g ollama\nOr download from https://ollama.com/download";
282
- NOT_RUNNING = "Ollama is not running. Start it with: ollama serve";
290
+ NOT_INSTALLED = "I can't find Ollama on this machine. Install it with: npm i -g ollama\nOr download it from https://ollama.com/download";
291
+ NOT_RUNNING = "Ollama isn't running, so I can't reach it. Start it with: ollama serve";
283
292
  LOCAL_HOST_RE = /localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/;
284
293
  HARMONY_RE = /<\|?\/?(?:channel|message|start|end|return|constrain|assistant|user|system|developer|tool|tool_call|tool_response|final|analysis|commentary)\|?>/gi;
285
294
  CHANNEL_LABEL_RE = /^(?:analysis|commentary|final)\s*(?=\w)/i;
@@ -288,7 +297,7 @@ var init_ollama = __esm({
288
297
 
289
298
  // src/llm/openai.ts
290
299
  function notAvailable(entry) {
291
- return `Cannot reach OpenAI-compatible provider at ${entry.baseUrl}. Make sure the server is running and the baseUrl is correct.`;
300
+ return `I couldn't reach the provider at ${entry.baseUrl}. Check that the server is running and the baseUrl is correct.`;
292
301
  }
293
302
  function headers(entry) {
294
303
  const h = { "Content-Type": "application/json" };
@@ -314,7 +323,7 @@ async function listModels2(entry) {
314
323
  detail = await res.text();
315
324
  } catch {
316
325
  }
317
- throw new Error(`Provider error (HTTP ${res.status}): ${detail || res.statusText}`);
326
+ throw new Error(`The provider came back with an error (HTTP ${res.status}): ${detail || res.statusText}`);
318
327
  }
319
328
  const body = await res.json();
320
329
  return body.data.map((m) => m.id);
@@ -406,12 +415,12 @@ async function* chat2(entry, model, messages, tools, opts) {
406
415
  } catch {
407
416
  }
408
417
  if (res.status === 404) {
409
- throw new Error(`Model "${model}" not found at ${entry.baseUrl}. Make sure it's available.`);
418
+ throw new Error(`The provider at ${entry.baseUrl} has no model called "${model}". Double-check the name, or pick another with /models.`);
410
419
  }
411
- throw new Error(`Provider error (HTTP ${res.status}): ${detail || res.statusText}`);
420
+ throw new Error(`The provider came back with an error (HTTP ${res.status}): ${detail || res.statusText}`);
412
421
  }
413
422
  const reader = res.body?.getReader();
414
- if (!reader) throw new Error("No response body");
423
+ if (!reader) throw new Error("The provider answered but sent no body to read.");
415
424
  const decoder = new TextDecoder();
416
425
  let buffer = "";
417
426
  try {
@@ -470,7 +479,7 @@ async function* chat2(entry, model, messages, tools, opts) {
470
479
  throw new Error(notAvailable(entry));
471
480
  }
472
481
  if (timeoutSignal.aborted && !opts?.signal?.aborted) {
473
- throw new Error(`Provider request timed out after ${TIMEOUT_MS / 1e3}s. The model may still be loading or thinking.`);
482
+ throw new Error(`The provider didn't respond within ${TIMEOUT_MS / 1e3}s, so I stopped waiting. The model may still be loading or thinking \u2014 give it another try.`);
474
483
  }
475
484
  throw err;
476
485
  }
@@ -678,14 +687,14 @@ function isUnder(parent, child) {
678
687
  }
679
688
  function confinePath(p) {
680
689
  if (typeof p !== "string" || p.length === 0) {
681
- throw new Error("Path is required.");
690
+ throw new Error("I need a file path here, but none was given.");
682
691
  }
683
692
  const root = process.cwd();
684
693
  const abs = resolve(root, p);
685
694
  if (isUnder(root, abs) || isUnder(SPILL_DIR, abs)) {
686
695
  return abs;
687
696
  }
688
- throw new Error(`Path "${p}" is outside the working directory (${root}). Access denied.`);
697
+ throw new Error(`"${p}" sits outside the working directory (${root}), so I can't touch it. Stay within the project folder.`);
689
698
  }
690
699
  var SPILL_DIR;
691
700
  var init_paths = __esm({
@@ -787,38 +796,38 @@ function nearMiss(src, old_str) {
787
796
  const width = String(to).length;
788
797
  const ctx = srcLines.slice(from, to).map((l, i) => `${String(from + i + 1).padStart(width, " ")} ${l}`).join("\n");
789
798
  return `
790
- Closest text in file (lines ${from + 1}-${to}):
799
+ Here's the closest text I could find (lines ${from + 1}-${to}) \u2014 copy it exactly:
791
800
  ${ctx}`;
792
801
  }
793
802
  function locate(src, old_str) {
794
803
  const first = src.indexOf(old_str);
795
804
  if (first !== -1) {
796
805
  if (src.indexOf(old_str, first + 1) !== -1) {
797
- return { error: `old_str not unique \u2014 add surrounding context to disambiguate.` };
806
+ return { error: `That text shows up in more than one place, so I can't tell which one you mean. Add a line or two around it to make it unique.` };
798
807
  }
799
808
  return [first, first + old_str.length];
800
809
  }
801
810
  const fuzzy = fuzzyRange(src, old_str);
802
811
  if (fuzzy) return fuzzy;
803
- return { error: `old_str not found.${nearMiss(src, old_str)}` };
812
+ return { error: `I couldn't find that text \u2014 it may differ by whitespace or a stray character.${nearMiss(src, old_str)}` };
804
813
  }
805
814
  function applyBatch(src, edits) {
806
815
  const ranges = [];
807
816
  for (let i = 0; i < edits.length; i++) {
808
817
  const { old_str, new_str } = edits[i];
809
818
  if (typeof old_str !== "string" || typeof new_str !== "string") {
810
- return { error: `edits[${i}] must have string old_str and new_str.` };
819
+ return { error: `Edit #${i + 1} is missing text \u2014 each edit needs both an old_str and a new_str string.` };
811
820
  }
812
- if (old_str === "") return { error: `edits[${i}].old_str is empty.` };
813
- if (old_str === new_str) return { error: `edits[${i}] old_str and new_str are identical \u2014 nothing to change.` };
821
+ if (old_str === "") return { error: `Edit #${i + 1} has an empty old_str, so there's nothing to look for. Give it the exact text you want to replace.` };
822
+ if (old_str === new_str) return { error: `Edit #${i + 1} has the same old_str and new_str, so it wouldn't change anything.` };
814
823
  const r = locate(src, old_str);
815
- if (!Array.isArray(r)) return { error: `edits[${i}]: ${r.error}` };
824
+ if (!Array.isArray(r)) return { error: `Edit #${i + 1}: ${r.error}` };
816
825
  ranges.push({ start: r[0], end: r[1], new_str });
817
826
  }
818
827
  const sorted = [...ranges].sort((a, b) => a.start - b.start);
819
828
  for (let i = 1; i < sorted.length; i++) {
820
829
  if (sorted[i].start < sorted[i - 1].end) {
821
- return { error: `edits overlap in the file \u2014 split them into separate calls or widen the context.` };
830
+ return { error: `Two of these edits touch the same spot, so I can't apply them together. Split them into separate calls, or widen each old_str so they don't overlap.` };
822
831
  }
823
832
  }
824
833
  let out = src;
@@ -869,11 +878,11 @@ var init_edit_file = __esm({
869
878
  return { content: `Edited ${path} (${res.count} edits).${verifyHint(path)}` };
870
879
  }
871
880
  if (typeof old_str !== "string" || typeof new_str !== "string") {
872
- return { content: `edit_file needs old_str and new_str (or an edits[] array) for ${path}.`, is_error: true };
881
+ return { content: `To edit ${path} I need either an old_str/new_str pair or an edits[] array \u2014 neither came through.`, is_error: true };
873
882
  }
874
883
  if (old_str === new_str) {
875
884
  return {
876
- content: `old_str and new_str are identical \u2014 nothing to change in ${path}. If the file is already correct, do NOT edit again: finish with the respond action and tell the user it is done.`,
885
+ content: `old_str and new_str are the same, so there's nothing to change in ${path}. If the file is already correct, don't edit it again \u2014 finish with the respond action and tell the user it's done.`,
877
886
  is_error: true
878
887
  };
879
888
  }
@@ -890,12 +899,12 @@ var init_edit_file = __esm({
890
899
  return { content: `Edited ${path} (whitespace-tolerant match).${verifyHint(path)}` };
891
900
  }
892
901
  }
893
- return { content: `old_str not found in ${path}.${nearMiss(src, old_str)}`, is_error: true };
902
+ return { content: `I couldn't find that text in ${path} \u2014 it may differ by whitespace or a stray character.${nearMiss(src, old_str)}`, is_error: true };
894
903
  }
895
904
  const all = replace_all === true;
896
905
  if (!all && src.indexOf(old_str, first + 1) !== -1) {
897
906
  return {
898
- content: `old_str not unique in ${path}. Add surrounding context to disambiguate, or set replace_all.`,
907
+ content: `That text appears more than once in ${path}, so I don't know which one to change. Add a line or two around it to single it out, or set replace_all to change them all.`,
899
908
  is_error: true
900
909
  };
901
910
  }
@@ -953,7 +962,7 @@ var init_read_file = __esm({
953
962
  if (IMAGE_EXT.has(ext) || looksImage(buf)) {
954
963
  if (buf.length > MAX_IMAGE_BYTES) {
955
964
  return {
956
- content: `${path} is an image but too large to attach (${buf.length} bytes > ${MAX_IMAGE_BYTES}). Resize it first.`,
965
+ content: `${path} is an image, but at ${buf.length} bytes it's too big to attach (limit is ${MAX_IMAGE_BYTES}). Resize it and try again.`,
957
966
  is_error: true
958
967
  };
959
968
  }
@@ -963,7 +972,7 @@ var init_read_file = __esm({
963
972
  };
964
973
  }
965
974
  if (buf.subarray(0, 8e3).includes(0)) {
966
- return { content: `${path} looks binary (${buf.length} bytes); not reading as text.`, is_error: true };
975
+ return { content: `${path} looks like a binary file (${buf.length} bytes), so I'm not reading it as text.`, is_error: true };
967
976
  }
968
977
  const raw = buf.toString("utf-8").replace(/\r\n/g, "\n");
969
978
  const allLines = raw.split("\n");
@@ -975,7 +984,7 @@ var init_read_file = __esm({
975
984
  let body = numbered(slice, start);
976
985
  if (body.length > MAX_CHARS) {
977
986
  body = body.slice(0, MAX_CHARS) + `
978
- [truncated: output exceeded ${MAX_CHARS} chars \u2014 use offset/limit]`;
987
+ [There's more \u2014 this hit the ${MAX_CHARS}-char limit. Use offset/limit to read the rest.]`;
979
988
  }
980
989
  if (ranged) {
981
990
  const end = start - 1 + slice.length;
@@ -1047,7 +1056,7 @@ function spillIfLarge(full, label = "output", budget = INLINE_BUDGET) {
1047
1056
  const head = Math.floor(budget * HEAD_FRACTION);
1048
1057
  const tail = budget - head;
1049
1058
  const preview = full.slice(0, head) + "\n\u2026\n" + full.slice(-tail);
1050
- const notice = path ? `[${label} truncated: ${full.length} bytes. Full output at ${path} \u2014 read it with read_file offset/limit to see the elided middle.]` : `[${label} truncated to ${budget} bytes; spill to disk failed, middle is lost.]`;
1059
+ const notice = path ? `[This ${label} was long (${full.length} bytes), so I'm showing the start and end. The full text is saved at ${path} \u2014 read it with read_file offset/limit to see the middle.]` : `[This ${label} was too long to show in full, and I couldn't save the rest to disk, so the middle is missing.]`;
1051
1060
  return `${preview}
1052
1061
  ${notice}`;
1053
1062
  }
@@ -1130,8 +1139,8 @@ var init_run_bash = __esm({
1130
1139
  const out = all ?? "";
1131
1140
  const is_error = aborted || timedOut || exitCode !== 0;
1132
1141
  const note = timedOut ? `
1133
- [timed out after ${timeout}ms \u2014 process tree killed]` : aborted ? `
1134
- [aborted \u2014 process tree killed]` : "";
1142
+ [Timed out after ${timeout}ms, so I stopped the command and everything it started. Raise timeout_ms for long builds or test suites.]` : aborted ? `
1143
+ [Cancelled \u2014 I stopped the command and everything it started.]` : "";
1135
1144
  const body = out || (is_error ? `(no output)` : "");
1136
1145
  const content = `${spillIfLarge(body, "command output")}
1137
1146
  [exit ${exitCode ?? "killed"}]${note}`;
@@ -1240,8 +1249,9 @@ var init_grep = __esm({
1240
1249
  const lines = (res.stdout ?? "").split("\n").slice(0, limit);
1241
1250
  const out = lines.join("\n");
1242
1251
  const code = res.exitCode ?? 0;
1243
- if (!out && code === 1) return { content: "No matches." };
1244
- return { content: out || res.stderr || "No matches.", is_error: code > 1 };
1252
+ const noMatch = `Nothing matched "${pattern}". Try a looser pattern, case_insensitive, or a wider path.`;
1253
+ if (!out && code === 1) return { content: noMatch };
1254
+ return { content: out || res.stderr || noMatch, is_error: code > 1 };
1245
1255
  } catch (err) {
1246
1256
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
1247
1257
  }
@@ -1316,7 +1326,7 @@ var init_glob = __esm({
1316
1326
  res = await tryFind();
1317
1327
  }
1318
1328
  const all = (res.stdout ?? "").split("\n").filter(Boolean);
1319
- if (all.length === 0) return { content: "No files matched." };
1329
+ if (all.length === 0) return { content: `Nothing matched "${pattern}". Try a broader pattern or a different path.` };
1320
1330
  const lines = byMtimeDesc(all).slice(0, limit);
1321
1331
  return { content: lines.join("\n") };
1322
1332
  } catch (err) {
@@ -1327,6 +1337,70 @@ var init_glob = __esm({
1327
1337
  }
1328
1338
  });
1329
1339
 
1340
+ // src/tools/write_todos.ts
1341
+ function render(todos) {
1342
+ const done = todos.filter((t) => t.status === "completed").length;
1343
+ const header = `Todos (${done}/${todos.length} done):`;
1344
+ const lines = todos.map((t) => `${MARK[t.status]} ${t.content}`);
1345
+ return [header, ...lines].join("\n");
1346
+ }
1347
+ var STATUSES, MARK, write_todos;
1348
+ var init_write_todos = __esm({
1349
+ "src/tools/write_todos.ts"() {
1350
+ "use strict";
1351
+ STATUSES = ["pending", "in_progress", "completed"];
1352
+ MARK = {
1353
+ completed: "[x]",
1354
+ in_progress: "[~]",
1355
+ pending: "[ ]"
1356
+ };
1357
+ write_todos = {
1358
+ name: "write_todos",
1359
+ description: "Create and maintain a task list for the current session, shown to the user as a live checklist. Pass the FULL list every call \u2014 it replaces the previous one (all or nothing), so include finished items too. Each todo is {content, status} where status is pending | in_progress | completed. Keep exactly one item in_progress at a time and mark it completed before starting the next. Use for multi-step work (features, refactors, multi-file changes) so the user can see what is done, in progress, and left. Skip it for single trivial actions.",
1360
+ input_schema: {
1361
+ type: "object",
1362
+ properties: {
1363
+ todos: {
1364
+ type: "array",
1365
+ description: "The full, ordered task list. Replaces the prior list entirely.",
1366
+ items: {
1367
+ type: "object",
1368
+ properties: {
1369
+ content: { type: "string", description: 'Short task description (imperative, e.g. "Add webfetch tool").' },
1370
+ status: { type: "string", enum: STATUSES, description: "pending | in_progress | completed" }
1371
+ },
1372
+ required: ["content", "status"]
1373
+ }
1374
+ }
1375
+ },
1376
+ required: ["todos"]
1377
+ },
1378
+ handler: ({ todos }) => {
1379
+ if (!Array.isArray(todos)) {
1380
+ return { content: "I need a todos array here \u2014 pass the full list of tasks.", is_error: true };
1381
+ }
1382
+ if (todos.length === 0) {
1383
+ return { content: "The todo list is empty \u2014 give me at least one task.", is_error: true };
1384
+ }
1385
+ for (let i = 0; i < todos.length; i++) {
1386
+ const t = todos[i];
1387
+ if (!t || typeof t.content !== "string" || t.content.trim() === "") {
1388
+ return { content: `Task #${i + 1} needs some text \u2014 its content can't be empty.`, is_error: true };
1389
+ }
1390
+ if (!STATUSES.includes(t.status)) {
1391
+ return { content: `Task #${i + 1} has an unknown status. Use one of: ${STATUSES.join(", ")}.`, is_error: true };
1392
+ }
1393
+ }
1394
+ const active2 = todos.filter((t) => t.status === "in_progress").length;
1395
+ if (active2 > 1) {
1396
+ return { content: `Keep just one task in progress at a time \u2014 I see ${active2}. Finish one before starting the next.`, is_error: true };
1397
+ }
1398
+ return { content: render(todos) };
1399
+ }
1400
+ };
1401
+ }
1402
+ });
1403
+
1330
1404
  // src/tools/registry.ts
1331
1405
  function getTool(name) {
1332
1406
  return TOOLS.find((t) => t.name === name);
@@ -1355,13 +1429,15 @@ var init_registry = __esm({
1355
1429
  init_run_bash();
1356
1430
  init_grep();
1357
1431
  init_glob();
1432
+ init_write_todos();
1358
1433
  TOOLS = [
1359
1434
  edit_file,
1360
1435
  read_file,
1361
1436
  write_file,
1362
1437
  run_bash,
1363
1438
  grep,
1364
- glob
1439
+ glob,
1440
+ write_todos
1365
1441
  ];
1366
1442
  }
1367
1443
  });
@@ -1424,7 +1500,7 @@ function validateInput(schema, input) {
1424
1500
  const result = toZod(schema).safeParse(input ?? {});
1425
1501
  if (result.success) return null;
1426
1502
  const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1427
- return `Invalid arguments: ${issues}`;
1503
+ return `Some arguments didn't look right: ${issues}`;
1428
1504
  }
1429
1505
  var init_validate = __esm({
1430
1506
  "src/tools/validate.ts"() {
@@ -1563,6 +1639,14 @@ Ask in a numbered list. One round of questions per turn. Then wait.
1563
1639
  You have access to the following tools. Call them via the function-calling interface.
1564
1640
  ${toolLines}
1565
1641
 
1642
+ # Task list (write_todos)
1643
+ For multi-step work \u2014 a feature, a refactor, anything touching multiple files or more than ~3 steps \u2014 track it with write_todos so the user sees a live checklist of what is done, in progress, and left.
1644
+ - Create the list right after your plan, before the first real action. Each item is {content, status}: short imperative content, status pending | in_progress | completed.
1645
+ - Pass the FULL list every call; it replaces the previous one. Include already-finished items so nothing disappears.
1646
+ - Mark exactly ONE item in_progress at a time. Set it in_progress when you start it, completed the moment it is done, then move the next to in_progress. Never leave a finished task pending or two tasks in_progress.
1647
+ - Keep items outcome-sized, not tool-sized \u2014 "Add webfetch tool and wire it in", not "call edit_file". Aim for a handful, not twenty.
1648
+ - Do NOT use write_todos for trivial single-step work \u2014 a lone read, one edit, a quick search. It is overhead there.
1649
+
1566
1650
  # Loop semantics
1567
1651
  - When you need to act on the filesystem or run a command, emit a tool call.
1568
1652
  - After each tool result, decide: more tool calls, or a final plain-text answer.
@@ -1858,7 +1942,7 @@ function readGuard(name, input, seen) {
1858
1942
  if (seen.has(abs)) return null;
1859
1943
  if (name === "write_file" && !existsSync5(abs)) return null;
1860
1944
  const verb = name === "edit_file" ? "edit" : "overwrite";
1861
- return `Refusing to ${verb} ${p}: you have not read it this turn. Call read_file on ${p} first, then retry the ${name}.`;
1945
+ return `I won't ${verb} ${p} without seeing it first \u2014 I don't want to clobber something. Read it with read_file, then retry the ${name}.`;
1862
1946
  }
1863
1947
  function unwrapEnvelope(name, input) {
1864
1948
  if (!("arguments" in input)) return input;
@@ -2028,6 +2112,14 @@ async function* runAgent(opts) {
2028
2112
  yield { type: "turn-end", stop_reason: "tool_use" };
2029
2113
  continue;
2030
2114
  }
2115
+ if (assistantText.trim() === "") {
2116
+ yield {
2117
+ type: "error",
2118
+ message: "The model returned an empty response \u2014 likely unloaded/OOM mid-stream or the output cap was consumed by thinking before any answer. Retry, switch models, or lower the context/effort."
2119
+ };
2120
+ yield { type: "done", prompt_tokens: promptTokens, eval_tokens: evalTokens };
2121
+ return history;
2122
+ }
2031
2123
  endedCleanly = true;
2032
2124
  yield { type: "turn-end", stop_reason: "end_turn" };
2033
2125
  break;
@@ -2040,7 +2132,7 @@ async function* runAgent(opts) {
2040
2132
  const r2 = {
2041
2133
  type: "tool_result",
2042
2134
  tool_use_id: use.id,
2043
- content: `Unknown tool: ${use.name}`,
2135
+ content: `Unknown tool: ${use.name}. There's no tool by that name \u2014 check the spelling against the tools you were given.`,
2044
2136
  is_error: true
2045
2137
  };
2046
2138
  results.push(r2);
@@ -2066,7 +2158,7 @@ async function* runAgent(opts) {
2066
2158
  const r2 = {
2067
2159
  type: "tool_result",
2068
2160
  tool_use_id: use.id,
2069
- content: `Permission denied for ${use.name}.`,
2161
+ content: `Permission denied \u2014 the user chose not to run ${use.name}. Try a different approach, or ask them what they'd prefer.`,
2070
2162
  is_error: true
2071
2163
  };
2072
2164
  results.push(r2);
@@ -2368,7 +2460,7 @@ var init_run = __esm({
2368
2460
  });
2369
2461
 
2370
2462
  // src/cli.tsx
2371
- import { render } from "ink";
2463
+ import { render as render2 } from "ink";
2372
2464
  import { createElement } from "react";
2373
2465
 
2374
2466
  // src/ui/App.tsx
@@ -3658,7 +3750,8 @@ var TOOL_LABEL = {
3658
3750
  read_file: "Read",
3659
3751
  run_bash: "Bash",
3660
3752
  glob: "Glob",
3661
- grep: "Grep"
3753
+ grep: "Grep",
3754
+ write_todos: "Todos"
3662
3755
  };
3663
3756
  var EXT_LANG = {
3664
3757
  ts: "typescript",
@@ -3760,6 +3853,43 @@ function FileEditBlock({
3760
3853
  ] }) })
3761
3854
  ] });
3762
3855
  }
3856
+ function TodoBlock({ todos }) {
3857
+ const done = todos.filter((t) => t.status === "completed").length;
3858
+ const doing = todos.filter((t) => t.status === "in_progress").length;
3859
+ const glyph = { completed: "\u2714", in_progress: "\u25B6", pending: "\u25CB" };
3860
+ const color = { completed: "green", in_progress: "yellow", pending: "gray" };
3861
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
3862
+ /* @__PURE__ */ jsxs9(Box9, { children: [
3863
+ /* @__PURE__ */ jsx9(Text9, { color: "green", children: "\u25CF " }),
3864
+ /* @__PURE__ */ jsx9(Text9, { color: "white", children: "Todos " }),
3865
+ /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
3866
+ "(",
3867
+ done,
3868
+ "/",
3869
+ todos.length,
3870
+ " done",
3871
+ doing > 0 ? `, ${doing} in progress` : "",
3872
+ ")"
3873
+ ] })
3874
+ ] }),
3875
+ todos.map((t, i) => /* @__PURE__ */ jsxs9(Box9, { marginLeft: 4, children: [
3876
+ /* @__PURE__ */ jsxs9(Text9, { color: color[t.status], children: [
3877
+ glyph[t.status],
3878
+ " "
3879
+ ] }),
3880
+ /* @__PURE__ */ jsx9(
3881
+ Text9,
3882
+ {
3883
+ color: t.status === "in_progress" ? "yellow" : void 0,
3884
+ dimColor: t.status !== "in_progress",
3885
+ strikethrough: t.status === "completed",
3886
+ bold: t.status === "in_progress",
3887
+ children: t.content
3888
+ }
3889
+ )
3890
+ ] }, i))
3891
+ ] });
3892
+ }
3763
3893
  function toolHeader(use) {
3764
3894
  const label = TOOL_LABEL[use.name] ?? use.name;
3765
3895
  const input = use.input ?? {};
@@ -3839,6 +3969,10 @@ function ToolResultBlock({ result, toolName }) {
3839
3969
  ] });
3840
3970
  }
3841
3971
  function ToolUseLine({ use, result }) {
3972
+ if (use.name === "write_todos" && !result?.is_error) {
3973
+ const todos = use.input.todos;
3974
+ if (Array.isArray(todos) && todos.length > 0) return /* @__PURE__ */ jsx9(TodoBlock, { todos });
3975
+ }
3842
3976
  if (use.name === "write_file" && !result?.is_error) {
3843
3977
  const input = use.input;
3844
3978
  const content = input.content ?? "";
@@ -5196,5 +5330,5 @@ if (cmd === "version" || cmd === "--version" || cmd === "-v") {
5196
5330
  process.on("exit", () => {
5197
5331
  if (process.stdout.isTTY) process.stdout.write("\x1B]2;\x07");
5198
5332
  });
5199
- render(createElement(App));
5333
+ render2(createElement(App));
5200
5334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Local AI coding agent for your terminal — an open-source, offline alternative to Claude Code, Cursor, and GitHub Copilot, powered by Ollama. Private by default, free forever.",
5
5
  "type": "module",
6
6
  "bin": {