mini-code-cli 0.1.1.1__py3-none-any.whl → 0.1.1.3__py3-none-any.whl

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.
mini_code_cli/__init__.py CHANGED
@@ -11,22 +11,41 @@ to ``"unknown"`` so importing the package can never fail over a version lookup.
11
11
  __all__ = ["__version__"]
12
12
 
13
13
 
14
+ def _version_from_file(path):
15
+ """Return the ``[project] version`` in ``path``.
16
+
17
+ ``tomllib`` only exists from Python 3.11, and the scan below is not a
18
+ nicety for 3.10: without it this function used to skip the file entirely on
19
+ that interpreter and report the *installed* metadata instead, so a checkout
20
+ with a stale editable install showed the wrong version in the banner (found
21
+ on the 3.10 CI leg). Both paths read the same key, and the section tracking
22
+ matters: a ``version`` key in some other table must not win.
23
+ """
24
+ try:
25
+ import tomllib # Python >= 3.11
26
+ except ImportError: # pragma: no cover - exercised on the 3.10 CI leg
27
+ tomllib = None
28
+ if tomllib is not None:
29
+ with open(path, "rb") as f:
30
+ return str(tomllib.load(f)["project"]["version"])
31
+ section = None
32
+ with open(path, encoding="utf-8") as f:
33
+ for line in f:
34
+ stripped = line.strip()
35
+ if stripped.startswith("["):
36
+ section = stripped
37
+ elif section == "[project]" and stripped.startswith("version"):
38
+ return stripped.split("=", 1)[1].strip().strip('"')
39
+ raise KeyError(f"no version in the [project] table of {path}")
40
+
41
+
14
42
  def _read_version():
15
43
  """Best-effort package version: pyproject.toml, then installed metadata."""
16
44
  try:
17
45
  from pathlib import Path
18
46
 
19
47
  pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
20
- try:
21
- import tomllib # Python >= 3.11
22
- except ImportError: # pragma: no cover - older interpreters
23
- tomllib = None
24
- if tomllib is not None:
25
- try:
26
- with open(pyproject, "rb") as f:
27
- return str(tomllib.load(f)["project"]["version"])
28
- except Exception:
29
- pass
48
+ return _version_from_file(pyproject)
30
49
  except Exception:
31
50
  pass
32
51
  try:
@@ -0,0 +1,12 @@
1
+ """Allow ``python -m mini_code_cli`` to start the agent.
2
+
3
+ The console script ``mini-code`` (see ``[project.scripts]``) is the normal entry
4
+ point; this module exists so the package also runs straight from a checkout or
5
+ an installed wheel without the script on ``PATH`` -- which is what a reader
6
+ tries first after ``pip install -e .``.
7
+ """
8
+
9
+ from .agent_loop import main
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main())
@@ -31,7 +31,7 @@ from . import prompts
31
31
  from . import shell_safety_utils
32
32
  from . import tools
33
33
  from . import utils
34
- from .utils import oneline
34
+ from .utils import oneline, plural
35
35
  from . import ui
36
36
  from . import subagents
37
37
  from . import skills
@@ -118,7 +118,12 @@ def _safe_int(value, default=0):
118
118
  # ---------------------------------------------------------------------------
119
119
 
120
120
  def parse_args():
121
- parser = argparse.ArgumentParser(description="Agent loop for interacting with a language model")
121
+ # ``prog`` is pinned so ``--help`` reads the same whether the agent was
122
+ # started through the ``mini-code`` script, ``python -m mini_code_cli`` or
123
+ # a test harness (without it, the usage line said ``__main__.py``).
124
+ parser = argparse.ArgumentParser(
125
+ prog="mini-code",
126
+ description="Agent loop for interacting with a language model")
122
127
  # MODEL / LLM related
123
128
  parser.add_argument("--url", type=str, default=f"{DEFAULT_MODEL_URL}", help=f"API host, default={DEFAULT_MODEL_URL}")
124
129
  parser.add_argument("--max-tokens", type=int, help="Maximum tokens for a single LLM response (the reply budget), default=None. Also used as the output headroom reserved when deriving the compaction limit from the model's context window.")
@@ -267,6 +272,37 @@ def _compaction_trigger(messages, token_limit, reserved_output_tokens, current_t
267
272
  return False, ""
268
273
 
269
274
 
275
+ def _in_flight_tail(messages):
276
+ """Return the messages of the turn that is still in flight, or ``[]``.
277
+
278
+ Compaction rewrites the transcript, so whatever it drops is gone from the
279
+ conversation *and* from everything saved afterwards. The last turn is not
280
+ finished work, though: it is either the instruction being answered right now
281
+ (the transcript ends with a ``user`` message) or the tool exchange the model
282
+ is waiting on (it ends with a ``tool`` result). Either way it has to be
283
+ carried across verbatim, or the model answers the summary instead of the
284
+ user's request -- and the typed line never reaches the recorder, the new
285
+ segment or a later restore.
286
+
287
+ The suffix starts at the last ``user`` message, which always begins a
288
+ self-contained turn: every ``assistant`` tool call inside it is followed by
289
+ its ``tool`` results, so the slice is a valid transcript on its own.
290
+
291
+ ``[]`` when the transcript already ends with an ``assistant`` reply: that
292
+ turn is answered, and re-sending it would ask the model to answer it twice.
293
+ """
294
+ if not messages:
295
+ return []
296
+ last = messages[-1]
297
+ if not isinstance(last, dict) or last.get("role") == "assistant":
298
+ return []
299
+ for index in range(len(messages) - 1, -1, -1):
300
+ message = messages[index]
301
+ if isinstance(message, dict) and message.get("role") == "user":
302
+ return list(messages[index:])
303
+ return []
304
+
305
+
270
306
  def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats,
271
307
  history_index=0, token_limit=None, reserved_output_tokens=0,
272
308
  current_tokens=None, plan_hook=None, console=None):
@@ -279,7 +315,9 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
279
315
  against ``token_limit``. Nothing else triggers compaction: in particular the
280
316
  number of messages is not a criterion. Compaction is then done by asking the
281
317
  LLM to summarize the history and replacing the old turns with that summary,
282
- keeping the original system prompt as the first message. The compaction call
318
+ keeping the original system prompt as the first message *and the turn that is
319
+ still in flight* (see :func:`_in_flight_tail`) verbatim after it -- a summary
320
+ of the user's current request is not the request. The compaction call
283
321
  itself is a single, tool-free request, so it cannot trigger further tool
284
322
  calls or recurse into compaction.
285
323
 
@@ -289,10 +327,12 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
289
327
  ``plan_hook`` is an optional callable the caller supplies only when running
290
328
  under plan mode (in practice ``AgentSession._handle_compaction``). After the
291
329
  summary is built the hook re-injects the running goal + plan so the compacted
292
- messages carry them forward. It receives the compacted list and must update it
293
- *in place*, because that list is what gets returned to the caller. Passing it in -- instead of referencing the
294
- owning session -- keeps ``maybe_compact`` a small, module-level, network-free
295
- helper that the test-suite can import and exercise directly.
330
+ messages carry them forward. It receives the compacted list *and* the
331
+ in-flight tail (see below) and must update the compacted list in place,
332
+ because that list is what gets returned to the caller. Passing it in --
333
+ instead of referencing the owning session -- keeps ``maybe_compact`` a
334
+ small, module-level, network-free helper that the test-suite can import and
335
+ exercise directly.
296
336
  """
297
337
  # ``console`` defaults to None so callers may omit it, but every branch below
298
338
  # reports through a console. Falling back to the shared one keeps the
@@ -311,8 +351,20 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
311
351
  # bare messages would crash them.
312
352
  return messages, False, history_index
313
353
 
354
+ # The turn this step is answering -- the instruction just typed, or the tool
355
+ # exchange the model is waiting on -- is carried across the rollover (see
356
+ # :func:`_in_flight_tail`). It is computed *before* the slow summary call so
357
+ # the console can tell the user right away that nothing they just sent is at
358
+ # stake; a compaction note that stays silent about that is exactly what makes
359
+ # the rollover look like it might have eaten the message.
360
+ tail = _in_flight_tail(messages)
361
+ kept_note = (f" The current turn ({plural(len(tail), 'message')}) is kept "
362
+ f"verbatim." if tail else "")
363
+
314
364
  try:
315
- console.note(f"Compacting conversation history ({reason}) -> summary...", "warn")
365
+ console.note(
366
+ f"Compacting the conversation: {plural(len(messages), 'message')} "
367
+ f"-> a summary ({reason}).{kept_note}", "warn")
316
368
  summary_prompt = prompts.compaction_prompt(messages)
317
369
 
318
370
  # The summariser sometimes answers with nothing at all. An empty summary
@@ -324,7 +376,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
324
376
  # the over-cap transcript was retried, and paid for, on every step.
325
377
  summary = ""
326
378
  for attempt in (1, 2):
327
- console.start_thinking("compacting")
379
+ console.start_thinking("compacting history")
328
380
  try:
329
381
  reply, _, compact_usage, _ = utils.unpack_llm_result(
330
382
  utils.call_openai_server(
@@ -347,9 +399,14 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
347
399
  console.note("The summariser returned an empty summary; retrying once.", "warn")
348
400
 
349
401
  if not summary:
402
+ # Say plainly what did *not* happen: nothing was dropped and the
403
+ # segment did not move -- only the shrink did, so the next request
404
+ # may still be too large.
350
405
  console.note(
351
406
  f"Could not compact history: the summariser returned an empty "
352
- f"summary twice. Keeping all {len(messages)} messages "
407
+ f"summary twice. Keeping all {plural(len(messages), 'message')} "
408
+ f"and history_{history_index}.json as they are -- nothing was "
409
+ f"lost, but the next request may still be too large "
353
410
  f"({reason}).", "warn")
354
411
  return messages, False, history_index
355
412
 
@@ -362,22 +419,56 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
362
419
  "content": f"Here is a summary of our earlier conversation so far:\n{summary}",
363
420
  })
364
421
 
422
+ # The turn in flight has to survive the rollover. Compaction runs at the
423
+ # *start* of a step, so ``messages`` normally ends with the user message
424
+ # this step is meant to answer, or with the tool result the model is
425
+ # waiting on. Replacing the transcript with just (system, summary)
426
+ # therefore dropped the user's instruction on the floor: the model
427
+ # answered the summary -- "Understood. What would you like me to do?" --
428
+ # and the typed line never reached the new segment, the recorder or a
429
+ # later restore (it survived only inside the *closed* segment, which no
430
+ # recorder record points at any more). ``tail`` was computed before the
431
+ # summary call so the opening note could already promise this.
432
+
365
433
  # In plan mode, re-inject the running plan + current goal so a
366
434
  # compaction (which would otherwise drop them) keeps the model
367
435
  # working against the goal it is still trying to finish. The hook is
368
436
  # wired in only when the caller runs under plan mode; otherwise
369
- # compaction degrades to a plain summary.
437
+ # compaction degrades to a plain summary. It is also handed the tail so
438
+ # it can restate the *real* instruction rather than the summary text.
370
439
  if plan_hook is not None:
371
- plan_hook(compacted)
372
-
373
- console.note(f"History compacted to {len(compacted)} messages.", "warn")
440
+ plan_hook(compacted, tail)
441
+
442
+ # ... and only then the live turn, so the user's actual instruction
443
+ # reads as the most recent thing the model has to act on.
444
+ compacted.extend(tail)
445
+
446
+ # What the user is shown has to answer the three questions a rollover
447
+ # raises: *what* was folded away (the summary itself), *how much* (the
448
+ # closing segment's message count, and what the new one starts with),
449
+ # and *where it went* (which segment file was closed and which was
450
+ # started).
451
+ console.note(f"Summary preview: {utils.oneline(summary, 120)}", "warn")
452
+ kept = (f"summary + {plural(len(tail), 'kept message')}" if tail
453
+ else "summary only")
454
+ # Phrased as "old segment -> new segment" rather than "N messages -> M
455
+ # messages": the replacement can be *longer* than what it replaced (a
456
+ # system prompt, the summary and the kept turn), and a bare pair of
457
+ # counts that sometimes grows reads like a bug.
458
+ console.note(
459
+ f"Compacted history_{history_index}.json "
460
+ f"({plural(len(messages), 'message')}) into a summary; "
461
+ f"history_{history_index + 1}.json now starts with "
462
+ f"{plural(len(compacted), 'message')} ({kept}).", "warn")
374
463
  # A new segment (history_index + 1) is started on compaction: the
375
464
  # compacted messages are its snapshot, and every later turn appends
376
465
  # to it. The caller records a single high-level 'summary' turn for
377
466
  # this index so the recorder stays flat across the rollover.
378
467
  return compacted, True, history_index + 1
379
468
  except Exception as e:
380
- console.note(f"Failed to compact history: {e}", "error")
469
+ console.note(
470
+ f"Failed to compact history: {e}. Keeping all "
471
+ f"{plural(len(messages), 'message')} unchanged.", "error")
381
472
 
382
473
  return messages, False, history_index
383
474
 
@@ -427,6 +518,11 @@ class AgentSession:
427
518
 
428
519
  self.plan_mode = bool(getattr(self.args, "plan", False))
429
520
 
521
+ # The good-bye box is printed once, from shutdown(). Several exits
522
+ # funnel through there (quit, exit, EOF, a /quit command) and a repeated
523
+ # box would read like a second session had ended.
524
+ self._farewell_shown = False
525
+
430
526
  # Token accounting. ``token_stats`` covers the *current history
431
527
  # segment* and is reset when compaction rolls over to a new one;
432
528
  # ``total_usage`` is the whole-session accumulator and never decreases.
@@ -1263,8 +1359,12 @@ class AgentSession:
1263
1359
  return
1264
1360
  except KeyboardInterrupt:
1265
1361
  self.console.note("Interrupting: Mini Code Exiting...", "error")
1362
+ self.shutdown(farewell=False)
1363
+ return
1266
1364
  except Exception as e:
1267
1365
  self.console.note(f"Exception {e}. Exiting...", "error")
1366
+ self.shutdown(farewell=False)
1367
+ return
1268
1368
  self.shutdown()
1269
1369
 
1270
1370
  def _emit_tip(self):
@@ -1508,7 +1608,7 @@ class AgentSession:
1508
1608
  return True
1509
1609
 
1510
1610
 
1511
- def _handle_compaction(self, compacted):
1611
+ def _handle_compaction(self, compacted, tail=()):
1512
1612
  """Re-inject goal + plan into the compacted messages (plan mode).
1513
1613
 
1514
1614
  Plan-mode counterpart to the plan handling inside :meth:`maybe_compact`,
@@ -1516,7 +1616,8 @@ class AgentSession:
1516
1616
  a network-free module-level helper. The loop wires in this method only
1517
1617
  when plan mode is on; it re-injects the goal + plan via
1518
1618
  :meth:`_apply_plan_to_compacted`, which also rewrites the tracking files
1519
- to match.
1619
+ to match. ``tail`` is the turn that is still in flight, which
1620
+ ``maybe_compact`` appends after the hook's messages.
1520
1621
 
1521
1622
  The hook must update ``compacted`` **in place**: ``maybe_compact``
1522
1623
  returns *that* list to the caller, which then assigns it to
@@ -1524,9 +1625,9 @@ class AgentSession:
1524
1625
  discarded a moment later, silently dropping the goal and plan -- which is
1525
1626
  exactly what happened before this was a slice assignment.
1526
1627
  """
1527
- compacted[:] = self._apply_plan_to_compacted(compacted)
1628
+ compacted[:] = self._apply_plan_to_compacted(compacted, tail)
1528
1629
 
1529
- def _apply_plan_to_compacted(self, messages):
1630
+ def _apply_plan_to_compacted(self, messages, tail=()):
1530
1631
  """Re-inject the current goal + plan after a history compaction.
1531
1632
 
1532
1633
  Compaction replaces the transcript with a summary, which would otherwise
@@ -1534,16 +1635,18 @@ class AgentSession:
1534
1635
  back off disk and append them as fresh user turns so the model keeps
1535
1636
  working against the goal it is still trying to finish. The goal is
1536
1637
  appended last so it reads as the most recent instruction the model
1537
- should act on.
1638
+ should act on -- ``maybe_compact`` then appends the in-flight turn, which
1639
+ is the same instruction verbatim.
1538
1640
  """
1539
1641
  plan_text = self.plan_manager.read_plan()
1540
1642
  goal_text = self.plan_manager.read_goal()
1541
1643
 
1542
- # Recover the user's latest instruction from the pre-compaction tail, in
1543
- # case the loop had not recorded a goal yet, so there is always a goal to
1544
- # restate after compaction.
1644
+ # Recover the user's latest instruction from the *in-flight turn* -- not
1645
+ # from ``messages``, which by now holds the summary -- so a session that
1646
+ # compacts before its goal was recorded still restates the real
1647
+ # instruction instead of the summary text.
1545
1648
  current_input = None
1546
- for m in reversed(messages):
1649
+ for m in reversed(list(tail or ())):
1547
1650
  if m.get("role") == "user":
1548
1651
  current_input = m.get("content")
1549
1652
  break
@@ -1857,7 +1960,9 @@ class AgentSession:
1857
1960
  # just the turns. In verbose mode everything is printed permanently.
1858
1961
  transient = not self.console.verbose
1859
1962
  func = self.tools_dict[name]["function"]
1860
- result = call_function_with_timeout(func, tool_params, name, timeout_seconds=self.args.tool_timeout)
1963
+ result = call_function_with_timeout(func, tool_params, name,
1964
+ timeout_seconds=self.args.tool_timeout,
1965
+ console=self.console)
1861
1966
 
1862
1967
  self.console.tool_call_display(name, tool_params, result, transient=transient)
1863
1968
  self._append_tool_result(call_id, str(result))
@@ -1931,8 +2036,15 @@ class AgentSession:
1931
2036
  self.console.note(
1932
2037
  f"Saved usage to: {usage_path} (session total: {total_path})", "info")
1933
2038
 
1934
- def shutdown(self):
1935
- """Persist and print a final summary (called on quit / exit)."""
2039
+ def shutdown(self, farewell=True):
2040
+ """Persist and print a final summary (called on quit / exit).
2041
+
2042
+ ``farewell=False`` is used by the interrupt / fatal paths in :meth:`run`,
2043
+ which have already told the user (in red) that the session is being torn
2044
+ down: a cheerful "thanks and here is your state" box on top of a crash is
2045
+ the wrong note, and the state it would recap is the state that just
2046
+ failed.
2047
+ """
1936
2048
  self._persist()
1937
2049
  segments = history_segment_indices(self.session_dir)
1938
2050
  # List what is really on disk. The old message claimed
@@ -1948,7 +2060,62 @@ class AgentSession:
1948
2060
  self.console.note(
1949
2061
  f"Saved session to {self.cache_dir}/{self.unique_id}/ "
1950
2062
  f"({present} + summary_history.json).", "ok")
1951
- self.console.usage_summary(self.total_usage)
2063
+ # The detailed per-field usage block is verbose-only (the console
2064
+ # documents it that way); in the quiet default view the good-bye box
2065
+ # below carries the same totals, so nothing is lost by leaving it out.
2066
+ if self.console.verbose:
2067
+ self.console.usage_summary(self.total_usage)
2068
+ if farewell:
2069
+ self._farewell(segments)
2070
+
2071
+ def _farewell(self, segments=None):
2072
+ """Print the good-bye box: version, sign-off and what the session left.
2073
+
2074
+ Printed once (see ``_farewell_shown``) and only on a normal exit. It
2075
+ restates the state in one glanceable place -- the same facts the user
2076
+ would otherwise have to piece together from the save note, ``/history``
2077
+ and the (verbose-only) usage readout, which in the quiet default view
2078
+ means they saw none of it.
2079
+ """
2080
+ if self._farewell_shown:
2081
+ return
2082
+ self._farewell_shown = True
2083
+ from . import __version__ as version
2084
+
2085
+ if segments is None:
2086
+ segments = history_segment_indices(self.session_dir)
2087
+ if not segments:
2088
+ where = f"history_{self.history_index}.json"
2089
+ elif len(segments) == 1:
2090
+ where = f"history_{segments[0]}.json"
2091
+ else:
2092
+ where = f"history_{segments[0]}..{segments[-1]}.json"
2093
+
2094
+ turns = len(self.summary)
2095
+ usage = self.total_usage or {}
2096
+
2097
+ def count(key):
2098
+ # A display detail on the exit path must never raise: a server that
2099
+ # reported ``null`` usage leaves a None here, and crashing while
2100
+ # saying goodbye would be the worst possible time to fail.
2101
+ try:
2102
+ return int(usage.get(key) or 0)
2103
+ except (TypeError, ValueError):
2104
+ return 0
2105
+
2106
+ rows = [
2107
+ ("session id", self.unique_id),
2108
+ ("saved under", f"{self.cache_dir}/{self.unique_id}/"),
2109
+ ("history", f"{where} (segment {self.history_index}), "
2110
+ f"{plural(len(self.messages), 'message')}"),
2111
+ ("conversation", plural(turns, "recorded turn")),
2112
+ ("tokens", f"{count('prompt_tokens'):,} in / "
2113
+ f"{count('completion_tokens'):,} out "
2114
+ f"({count('total_tokens'):,} total)"),
2115
+ ("calls", f"{count('llm_calls'):,} LLM, "
2116
+ f"{count('tool_calls'):,} tool"),
2117
+ ]
2118
+ self.console.farewell(version, rows)
1952
2119
 
1953
2120
  # -- single LLM turn ---------------------------------------------------
1954
2121
 
@@ -2000,14 +2167,19 @@ class AgentSession:
2000
2167
  self.history_index)
2001
2168
  except Exception as e:
2002
2169
  self.console.note(
2003
- f"Could not save the closed history segment: {e}", "warn")
2170
+ f"Could not save the closed history segment "
2171
+ f"history_{self.history_index}.json: {e}. The compacted "
2172
+ f"conversation is unaffected, but /reset back into that "
2173
+ f"segment may be incomplete.", "warn")
2004
2174
  self.history_index = new_index
2005
2175
  self.token_stats = empty_usage()
2006
2176
  # Snapshot the compacted messages into the new segment so its turns
2007
2177
  # are preserved, and record the summary as the first turn of it.
2008
2178
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
2009
2179
  self._record_turn(
2010
- f"summary: history compacted to {len(self.messages)} messages",
2180
+ f"summary: history_{new_index - 1}.json "
2181
+ f"({plural(len(closing_messages), 'message')}) compacted into a "
2182
+ f"summary; history_{self.history_index}.json started",
2011
2183
  self.messages)
2012
2184
 
2013
2185
  # Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
mini_code_cli/runner.py CHANGED
@@ -18,13 +18,19 @@ class _TimedOut(Exception):
18
18
  pass
19
19
 
20
20
 
21
- def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT):
21
+ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT,
22
+ console=None):
22
23
  """Call ``func(**tool_params)``, aborting after ``timeout_seconds``.
23
24
 
24
25
  On timeout -- or if the tool raises *any* other exception -- a friendly
25
26
  ``Error:`` string is returned so the failure reaches the model as a tool
26
27
  result (letting it recover or retry) instead of propagating and crashing the
27
28
  whole agent loop. A timeout is reported distinctly from a generic failure.
29
+
30
+ ``console`` is the session's console. It defaults to the shared one so a
31
+ bare call still works, but the agent loop passes its own: a sub-agent's
32
+ console redirects its output, and reporting to the module-level console
33
+ would print a background child's tool errors onto the user's terminal.
28
34
  """
29
35
  def _handler(signum, frame):
30
36
  raise _TimedOut()
@@ -35,30 +41,32 @@ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_
35
41
  # signal only works in the main thread; if we cannot arm it, just run
36
42
  # the tool without the wall-clock guard rather than failing outright --
37
43
  # but still shield the loop from a tool that raises.
38
- return _call_tool_safely(func, tool_params, name)
44
+ return _call_tool_safely(func, tool_params, name, console)
39
45
 
46
+ console = console if console is not None else ui.console
40
47
  signal.alarm(int(timeout_seconds) if timeout_seconds else 0)
41
48
  try:
42
49
  return func(**tool_params)
43
50
  except _TimedOut:
44
- ui.console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
51
+ console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
45
52
  return f"Error: Function {name} timed out after {timeout_seconds} seconds."
46
53
  except Exception as e: # noqa: BLE001 -- a failing tool must not crash the loop
47
- ui.console.note(f"Tool {name} raised an error: {e}", "error")
54
+ console.note(f"Tool {name} raised an error: {e}", "error")
48
55
  return f"Error: tool {name} raised an error: {e}"
49
56
  finally:
50
57
  signal.alarm(0)
51
58
  signal.signal(signal.SIGALRM, previous)
52
59
 
53
60
 
54
- def _call_tool_safely(func, tool_params, name):
61
+ def _call_tool_safely(func, tool_params, name, console=None):
55
62
  """Run a tool with no wall-clock guard (SIGALRM unavailable) but still
56
63
  shield the loop from a tool that raises.
57
64
 
58
65
  Used as the fallback when we could not arm SIGALRM (e.g. a non-main thread).
59
66
  """
67
+ console = console if console is not None else ui.console
60
68
  try:
61
69
  return func(**tool_params)
62
70
  except Exception as e: # noqa: BLE001 -- never let a tool exception propagate
63
- ui.console.note(f"Tool {name} raised an error: {e}", "error")
71
+ console.note(f"Tool {name} raised an error: {e}", "error")
64
72
  return f"Error: tool {name} raised an error: {e}"
mini_code_cli/skills.py CHANGED
@@ -294,7 +294,10 @@ def run_skill(allowed_dir, skill_name, script_name, args=None):
294
294
  argv = shlex.split(args) if args else []
295
295
  cmd = [sys.executable, script_path] + argv
296
296
  try:
297
- result = subprocess.run(cmd, capture_output=True, text=True,
297
+ # Run *inside* the skill's own directory: a script that writes or reads
298
+ # a relative path then stays in the skill folder instead of landing in
299
+ # whatever directory the agent process happens to have inherited.
300
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=base,
298
301
  timeout=DEFAULT_TOOL_TIMEOUT, check=False)
299
302
  except Exception as e:
300
303
  return f"Error: {e}"
mini_code_cli/ui.py CHANGED
@@ -29,7 +29,7 @@ import shutil
29
29
  import sys
30
30
  import threading
31
31
 
32
- from .utils import oneline
32
+ from .utils import oneline, plural
33
33
 
34
34
  # --- ANSI SGR codes ---------------------------------------------------------
35
35
  _RESET = "\033[0m"
@@ -348,12 +348,6 @@ class Console:
348
348
  styled_lines.append(text)
349
349
  self.print_box(styled_lines, title=title, fg=fg)
350
350
 
351
- def truncate(self, text, limit):
352
- text = str(text)
353
- if len(text) > limit:
354
- return text[:limit] + "... (truncated)"
355
- return text
356
-
357
351
  @staticmethod
358
352
  def _oneline(text, limit=None):
359
353
  """Collapse ``text`` to one line (delegates to :func:`utils.oneline`)."""
@@ -570,7 +564,7 @@ class Console:
570
564
  """Render one attempted tool call: its name, parameters and result."""
571
565
  self.emit(self.style(f"{self.glyph('gear')} {name}", fg="magenta", bold=True))
572
566
  for key, value in (params or {}).items():
573
- snippet = self.truncate(self._oneline(str(value), 100), 100)
567
+ snippet = self._oneline(str(value), 100)
574
568
  key_str = self.style(f" {key}:", fg="gray", dim=True) if self.color_enabled \
575
569
  else f" {key}:"
576
570
  self.emit(f"{key_str} {snippet}")
@@ -586,7 +580,7 @@ class Console:
586
580
  first = text.splitlines()[0] if text.strip() else "(no output)"
587
581
  arrow = self.style(self.glyph("arrow"), fg=color) if self.color_enabled \
588
582
  else self.glyph("arrow")
589
- self.emit(f" {arrow} {self.truncate(first, 300)}")
583
+ self.emit(f" {arrow} {self._oneline(first, 300)}")
590
584
 
591
585
  def tool_call_display(self, name, params=None, result=None, transient=True):
592
586
  """Render one attempted tool call.
@@ -624,7 +618,7 @@ class Console:
624
618
  f"total={usage.get('total_tokens', 0)}")
625
619
  if llm_calls is not None:
626
620
  calls = int(llm_calls)
627
- text += f" | {calls} LLM call{'' if calls == 1 else 's'}"
621
+ text += f" | {plural(calls, 'LLM call')}"
628
622
  label = self.style("tokens", fg="cyan", dim=True) if self.color_enabled else "tokens"
629
623
  return f"{label}: {text}"
630
624
 
@@ -683,7 +677,7 @@ class Console:
683
677
  self.emit(self.style(header, bold=True, fg="cyan"))
684
678
  for turn, what, where in rows:
685
679
  turn_cell = self.style(turn.rjust(turn_w), fg="magenta", bold=True) if self.color_enabled else turn.rjust(turn_w)
686
- what_cell = self.style(self.truncate(what, what_w), dim=True) if self.color_enabled else what.ljust(what_w)
680
+ what_cell = self.style(self._oneline(what, what_w), dim=True) if self.color_enabled else what.ljust(what_w)
687
681
  where_cell = self.style(where, fg="gray", dim=True) if self.color_enabled else where
688
682
  self.emit(f"{turn_cell} {what_cell} {where_cell}")
689
683
  self.emit(self.style("(commands: /history <n> to inspect, /reset <n> to rewind)", dim=True, fg="cyan"))
@@ -739,6 +733,32 @@ class Console:
739
733
  ]
740
734
  self.banner(f"MiniCodeCLI v{version}", lines, fg="cyan")
741
735
 
736
+ def farewell(self, version, rows, sign_off=None):
737
+ """Render the exit banner: a sign-off plus a summary of the session state.
738
+
739
+ Deliberately narrower than :meth:`welcome`. Everything the welcome banner
740
+ shows is about how the session *will* run -- server, model, permissions,
741
+ allowed dir -- and all of it is irrelevant once the session is over. What
742
+ the user can still act on is what the session *left behind*: which
743
+ folder it was saved in, which history segments exist, how much was said
744
+ and what it cost. So the box carries only the version, a good-bye line
745
+ and those ``rows``.
746
+
747
+ ``rows`` is a list of ``(label, value)`` pairs, laid out in a single
748
+ colon-aligned column like the welcome banner; the caller supplies the
749
+ pairs because it owns the state (the console stays free of session
750
+ policy).
751
+ """
752
+ label_w = max((len(str(label)) for label, _ in rows), default=0)
753
+ lines = [sign_off or "Goodbye, and thanks for using MiniCodeCLI!", ""]
754
+ lines += [f"{str(label).ljust(label_w)} : {value}" for label, value in rows]
755
+ # Yellow, not the welcome banner's cyan: this is the one box that
756
+ # *closes* the session, so it reads as a distinct, valedictory note
757
+ # rather than the startup banner reappearing. Colours only apply on a
758
+ # colour-capable TTY (see :meth:`box`), so piped/ASCII output is
759
+ # unchanged.
760
+ self.banner(f"MiniCodeCLI v{version} - goodbye", lines, fg="yellow")
761
+
742
762
  def announce_api_key(self, present):
743
763
  if present:
744
764
  self.note("API key found (MINI_CODE_API_KEY).", "ok")