mini-code-cli 0.1.0.0__tar.gz → 0.1.0.2__tar.gz

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 (38) hide show
  1. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/PKG-INFO +17 -2
  2. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/README.md +16 -1
  3. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/agent_loop.py +76 -4
  4. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/persistence.py +107 -2
  5. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/ui.py +65 -0
  6. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/PKG-INFO +17 -2
  7. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/SOURCES.txt +1 -0
  8. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/pyproject.toml +1 -1
  9. mini_code_cli-0.1.0.2/tests/test_last_session_pointer.py +218 -0
  10. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_ui.py +18 -0
  11. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/LICENSE +0 -0
  12. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/__init__.py +0 -0
  13. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/config.py +0 -0
  14. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/permissions.py +0 -0
  15. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/plan.py +0 -0
  16. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/prompts.py +0 -0
  17. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/runner.py +0 -0
  18. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/sandbox.py +0 -0
  19. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/shell_safety_utils.py +0 -0
  20. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/skills.py +0 -0
  21. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/__init__.py +0 -0
  22. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/base.py +0 -0
  23. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/builtin.py +0 -0
  24. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/tools.py +0 -0
  25. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli/utils.py +0 -0
  26. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/dependency_links.txt +0 -0
  27. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/entry_points.txt +0 -0
  28. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/requires.txt +0 -0
  29. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/top_level.txt +0 -0
  30. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/setup.cfg +0 -0
  31. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_agent_loop.py +0 -0
  32. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_integration.py +0 -0
  33. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_plan.py +0 -0
  34. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_sandboxing.py +0 -0
  35. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_shell_safety.py +0 -0
  36. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_skills.py +0 -0
  37. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_subagents.py +0 -0
  38. {mini_code_cli-0.1.0.0 → mini_code_cli-0.1.0.2}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-code-cli
3
- Version: 0.1.0.0
3
+ Version: 0.1.0.2
4
4
  Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
5
5
  Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -720,7 +720,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
720
720
  - allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
721
721
  - ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
722
722
  - system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
723
- - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session (the one whose history was last modified) from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given.
723
+ - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
724
724
  - plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
725
725
 
726
726
  ### Available tools
@@ -871,4 +871,19 @@ vllm serve ornith-ai/Ornith-1.5-397B-FP8 \
871
871
  --trust-remote-code
872
872
  ```
873
873
 
874
+ ## Citation
875
+
876
+ If you use `mini-code-cli` in your research or writing, please cite it using the BibTeX entry below:
877
+
878
+ ```bibtex
879
+ @misc{mini-code-cli,
880
+ author = {Rozanov, Nikolai},
881
+ title = {mini-code-cli: A minimal, dependency-light coding agent},
882
+ year = {2026},
883
+ version = {0.1.0.0},
884
+ howpublished = {\url{https://github.com/ai-nikolai/mini-code-cli}},
885
+ note = {A no-bloat, dependency-free agent CLI for experimenting.}
886
+ }
887
+ ```
888
+
874
889
  ## (C) Nikolai Rozanov, 2026 - Present
@@ -46,7 +46,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
46
46
  - allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
47
47
  - ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
48
48
  - system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
49
- - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session (the one whose history was last modified) from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given.
49
+ - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
50
50
  - plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
51
51
 
52
52
  ### Available tools
@@ -197,4 +197,19 @@ vllm serve ornith-ai/Ornith-1.5-397B-FP8 \
197
197
  --trust-remote-code
198
198
  ```
199
199
 
200
+ ## Citation
201
+
202
+ If you use `mini-code-cli` in your research or writing, please cite it using the BibTeX entry below:
203
+
204
+ ```bibtex
205
+ @misc{mini-code-cli,
206
+ author = {Rozanov, Nikolai},
207
+ title = {mini-code-cli: A minimal, dependency-light coding agent},
208
+ year = {2026},
209
+ version = {0.1.0.0},
210
+ howpublished = {\url{https://github.com/ai-nikolai/mini-code-cli}},
211
+ note = {A no-bloat, dependency-free agent CLI for experimenting.}
212
+ }
213
+ ```
214
+
200
215
  ## (C) Nikolai Rozanov, 2026 - Present
@@ -52,6 +52,7 @@ from .persistence import (
52
52
  load_summary_from_dir,
53
53
  load_usage_from_dir,
54
54
  find_last_session_dir,
55
+ save_last_session_pointer,
55
56
  accumulate_usage,
56
57
  generate_unique_id,
57
58
  _restore_dir,
@@ -368,7 +369,15 @@ class AgentSession:
368
369
  restore_session = getattr(self.args, "restore_session", None)
369
370
  self.loaded_last_session_dir = None
370
371
  if getattr(self.args, "load_last_session", False) and not restore_session:
371
- detected = find_last_session_dir(self.cache_dir)
372
+ # Resolve the most recent session: the .mini pointer recorded under
373
+ # --allowed-dir is consulted first (an explicit record of the last
374
+ # session worked in), falling back to the timestamp heuristic over
375
+ # the cache dir.
376
+ detected = find_last_session_dir(
377
+ self.cache_dir,
378
+ allowed_dir=self.allowed_dir,
379
+ use_pointer=True,
380
+ )
372
381
  if detected:
373
382
  restore_session = detected
374
383
  self.loaded_last_session_dir = detected
@@ -581,6 +590,17 @@ class AgentSession:
581
590
  print_welcome(self.allowed_dir, self.server_url, self.args,
582
591
  self.unique_id, self.cache_dir, self.model)
583
592
 
593
+ # Announce the session's unique id as an assistant-styled message so the
594
+ # user can see / copy the id this conversation is being saved under.
595
+ self._print_session_id()
596
+
597
+ # Record this session as the "latest" so a future --load-last-session can
598
+ # resume it without scanning the cache dir. This lives in the top-level
599
+ # loop's own directory (cache_dir/unique_id), and only the interactive
600
+ # run() writes it -- a sub-agent child drives via _drive_auto and would
601
+ # otherwise overwrite the pointer with its own buried path.
602
+ self._save_last_session_pointer()
603
+
584
604
  # Report the outcome of --load-last-session up front, so the user sees
585
605
  # whether we resumed an earlier conversation (under a brand-new id) or
586
606
  # started fresh because no previous session was found.
@@ -612,6 +632,7 @@ class AgentSession:
612
632
  # 1) Get the user's turn: the one-shot preset, or a fresh line.
613
633
  if pending is not None:
614
634
  self.console.rule(title="Preset prompt", char="=", fg="cyan")
635
+ self.console.short_divider()
615
636
  self.console.turn("You", pending, accent="cyan")
616
637
  user_input = pending
617
638
  pending = None
@@ -649,6 +670,26 @@ class AgentSession:
649
670
  # Ctrl-C mid-loop just saves and re-prompts rather than crashing.
650
671
  self._persist()
651
672
 
673
+ def _print_session_id(self):
674
+ """Print the session's unique id as a minimal "Assistant" turn.
675
+
676
+ Instead of a banner/header the id is rendered as an assistant-styled
677
+ (green) turn carrying *only* the id in brackets, so the user can see /
678
+ copy the id this conversation is being saved under without the noise of
679
+ the extra "saved under" line.
680
+ """
681
+ self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
682
+
683
+ def _save_last_session_pointer(self):
684
+ """Record this session's directory in --allowed-dir/.mini.
685
+
686
+ Writes the .mini pointer that a later --load-last-session consults first
687
+ (see save_last_session_pointer). Best-effort: never raises, so a pointer
688
+ hiccup can never break the interactive session.
689
+ """
690
+ save_last_session_pointer(self.allowed_dir,
691
+ os.path.join(self.cache_dir, self.unique_id))
692
+
652
693
  # -- autonomous drive (used by sub-agents) ------------------------------
653
694
 
654
695
  def _drive_auto(self, initial_task, max_steps=None):
@@ -815,6 +856,7 @@ class AgentSession:
815
856
 
816
857
  def _read_user_line(self):
817
858
  """Read one turn from the user. Returns the text to send, or None to quit."""
859
+ self.console.short_divider()
818
860
  user_input = self.console.read_line(style_line_prompt("You:"))
819
861
  if user_input is None: # EOF / Ctrl-C at the prompt
820
862
  self.console.note("Thank you. Goodbye! Saving history...", "info")
@@ -864,7 +906,7 @@ class AgentSession:
864
906
  self.unique_id, self.cache_dir, self.model)
865
907
  return True
866
908
  if cmd in self.CLEAR_COMMANDS:
867
- self.console.clear_screen()
909
+ self._clear_and_show_last()
868
910
  return True
869
911
  self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
870
912
  return True
@@ -892,12 +934,42 @@ class AgentSession:
892
934
  return
893
935
  self._reset_history(target["turn"], target)
894
936
 
937
+ def _clear_and_show_last(self):
938
+ """Handle /clear: wipe the screen and re-print the last turn.
939
+
940
+ The conversation itself is unchanged; we just redraw the most recent
941
+ exchange so the user re-orients after clearing the view. If there is no
942
+ assistant reply yet, the last user turn is shown instead.
943
+ """
944
+ self.console.clear_screen()
945
+ last_role = last_text = None
946
+ for m in reversed(self.messages):
947
+ role = m.get("role")
948
+ content = m.get("content")
949
+ if role == "assistant" and not m.get("tool_calls") and content:
950
+ last_role, last_text = "Assistant", content
951
+ break
952
+ if role == "user" and content:
953
+ last_role, last_text = "You", content
954
+ break
955
+ if last_role is not None:
956
+ self.console.divider()
957
+ accent = "green" if last_role == "Assistant" else "cyan"
958
+ self.console.turn(last_role, last_text, accent=accent)
959
+
895
960
  # -- tool execution ----------------------------------------------------
896
961
 
897
962
  def _run_tool_calls(self, tool_calls):
898
963
  _make_tool_call_note(self.console, len(tool_calls))
899
- for call in tool_calls:
900
- self._run_one_tool_call(call)
964
+ # Show a "… calling tool" spinner (in-place, non-scrolling) while the
965
+ # tool(s) execute, mirroring the thinking spinner that covers the LLM
966
+ # call. Always stopped (finally) so it can never leak into the next turn.
967
+ self.console.start_tool_call()
968
+ try:
969
+ for call in tool_calls:
970
+ self._run_one_tool_call(call)
971
+ finally:
972
+ self.console.stop_tool_call()
901
973
 
902
974
  def _run_one_tool_call(self, call):
903
975
  call_id = call.get("id", "")
@@ -24,6 +24,7 @@ raising.
24
24
  import json
25
25
  import os
26
26
  import re
27
+ import time
27
28
 
28
29
  from . import ui
29
30
  from .config import DEFAULT_CACHE_LOCATION
@@ -76,8 +77,112 @@ def generate_unique_id():
76
77
  return f"{timestamp}-{uuid.uuid4().hex[:8]}"
77
78
 
78
79
 
79
- def find_last_session_dir(cache_dir):
80
- """Return the path to the most recently *active* session dir under ``cache_dir``.
80
+ # ---------------------------------------------------------------------------
81
+ # latest-session pointer (.mini)
82
+ # ---------------------------------------------------------------------------
83
+
84
+ # The .mini directory (inside the user's --allowed-dir) holds a tiny pointer to
85
+ # the most recent session. --load-last-session consults this pointer *first*
86
+ # (an explicit opt-in the user's environment recorded) before falling back to
87
+ # the timestamp heuristic in find_last_session_dir().
88
+ #
89
+ # NOTE: .mini is also the skills feature's directory. The pointer is a single
90
+ # dedicated file inside it (LAST_SESSION_FILENAME), so the two never collide and
91
+ # the skills code (which only reads <skill-name>/ + SKILLS.md) is unaffected.
92
+ SKILLS_DIRNAME = ".mini"
93
+ LAST_SESSION_FILENAME = "last_session.json"
94
+
95
+
96
+ def _last_session_dir_store(allowed_dir):
97
+ """Return the .mini directory for ``allowed_dir`` (creating it if needed).
98
+
99
+ Best-effort: never raises. Returns None if the directory cannot be ensured,
100
+ in which case the pointer is simply not consulted/updated.
101
+ """
102
+ if not allowed_dir:
103
+ return None
104
+ store = os.path.join(os.path.abspath(allowed_dir), SKILLS_DIRNAME)
105
+ try:
106
+ os.makedirs(store, exist_ok=True)
107
+ return store
108
+ except OSError as e:
109
+ ui.console.note(f"Could not use {store} for the latest-session pointer: {e}", "warn")
110
+ return None
111
+
112
+
113
+ def save_last_session_pointer(allowed_dir, session_dir):
114
+ """Record ``session_dir`` as the latest session in ``allowed_dir/.mini``.
115
+
116
+ Writes a tiny JSON file (the absolute path plus a timestamp) so a later
117
+ --load-last-session run can resume the most recent session without scanning
118
+ the cache dir. Best-effort: never raises, returns the written path or None.
119
+ """
120
+ if not session_dir:
121
+ return None
122
+ store = _last_session_dir_store(allowed_dir)
123
+ if store is None:
124
+ return None
125
+ payload = {
126
+ "session_dir": os.path.abspath(session_dir),
127
+ "recorded_at": time.time(),
128
+ }
129
+ path = os.path.join(store, LAST_SESSION_FILENAME)
130
+ try:
131
+ with open(path, "w", encoding="utf-8") as f:
132
+ json.dump(payload, f, indent=2)
133
+ return path
134
+ except OSError as e:
135
+ ui.console.note(f"Could not record latest-session pointer: {e}", "warn")
136
+ return None
137
+
138
+
139
+ def load_last_session_pointer(allowed_dir):
140
+ """Read the latest-session pointer recorded under ``allowed_dir/.mini``.
141
+
142
+ Returns the recorded session directory (a path into the cache folder), or
143
+ None if no pointer exists or it cannot be read. Best-effort: a missing or
144
+ malformed pointer simply yields None so the caller falls back to the
145
+ timestamp heuristic.
146
+ """
147
+ if not allowed_dir:
148
+ return None
149
+ store = os.path.join(os.path.abspath(allowed_dir), SKILLS_DIRNAME)
150
+ path = os.path.join(store, LAST_SESSION_FILENAME)
151
+ if not os.path.isfile(path):
152
+ return None
153
+ try:
154
+ with open(path, "r", encoding="utf-8") as f:
155
+ payload = json.load(f)
156
+ except Exception:
157
+ return None
158
+ if not isinstance(payload, dict):
159
+ return None
160
+ session_dir = payload.get("session_dir")
161
+ if not session_dir or not os.path.isdir(session_dir):
162
+ return None
163
+ return session_dir
164
+
165
+
166
+ def find_last_session_dir(cache_dir=None, allowed_dir=None, use_pointer=True):
167
+ """Return the path to the most recently *active* session dir.
168
+
169
+ When ``use_pointer`` and an ``allowed_dir`` are given, the .mini pointer
170
+ recorded by a previous run (see save_last_session_pointer) is consulted
171
+ *first* -- it is an explicit record of the session the user last worked in.
172
+ Only if that yields nothing do we fall back to the timestamp heuristic over
173
+ ``cache_dir`` (find_last_session_dir_by_mtime). Session folders with no
174
+ files are ignored. Never raises: a missing/unreadable cache dir simply
175
+ yields None.
176
+ """
177
+ if use_pointer and allowed_dir:
178
+ pointed = load_last_session_pointer(allowed_dir)
179
+ if pointed:
180
+ return pointed
181
+ return find_last_session_dir_by_mtime(cache_dir)
182
+
183
+
184
+ def find_last_session_dir_by_mtime(cache_dir):
185
+ """Timestamp heuristic for the most recently *active* session dir.
81
186
 
82
187
  "Most recent" is decided by the newest modification time of any file inside
83
188
  each session folder (a session rewrites its ``history_{n}.json`` every step),
@@ -99,6 +99,12 @@ class Console:
99
99
  # "… thinking" spinner state (see start_thinking / stop_thinking).
100
100
  self._thinking_thread = None
101
101
  self._thinking_stop = None
102
+ # "… calling tool" spinner state (see start_tool_call / stop_tool_call).
103
+ # Mirrors the thinking spinner: a daemon thread that rewrites a single
104
+ # live line in place (via the transient region) so the tool-execution
105
+ # phase looks alive without scrolling the transcript.
106
+ self._tool_call_thread = None
107
+ self._tool_call_stop = None
102
108
  # Live "transient" region (non-verbose mode) renders the tool-call area
103
109
  # and the usage line without scrolling the screen. We keep a single
104
110
  # invariant: whenever ``_transient_lines`` is > 0 a block of that many
@@ -365,6 +371,16 @@ class Console:
365
371
  """
366
372
  self.emit(self.style("-" * self.width, fg="gray", dim=True))
367
373
 
374
+ def short_divider(self, char="-", width=8, fg="gray"):
375
+ """Render a short horizontal delimiter line (not the full width).
376
+
377
+ Used to set off the user's own "You:" turn from the preceding assistant
378
+ reply with a light, unobtrusive rule rather than a full-width divider.
379
+ Defaults to a plain ASCII dash so it renders consistently regardless of
380
+ the console's unicode setting.
381
+ """
382
+ self.emit(self.style(char * width, fg=fg, dim=True))
383
+
368
384
  def clear_screen(self):
369
385
  """Clear the terminal and move the cursor to the top-left.
370
386
 
@@ -432,6 +448,55 @@ class Console:
432
448
  if thread is not None and thread.is_alive():
433
449
  thread.join(timeout=1.0)
434
450
 
451
+ def start_tool_call(self, message="Calling tool"):
452
+ """Start a background "… calling tool" spinner (non-scrolling).
453
+
454
+ Shown while the agent executes a tool call, mirroring
455
+ :meth:`start_thinking` which covers the LLM request. Runs a short
456
+ animation in a daemon thread that rewrites a single live line in place
457
+ via the transient region, so the wait looks alive without scrolling the
458
+ transcript. On a real colour/utf-8 TTY it animates in place; on piped /
459
+ ASCII output it simply prints one static line and starts no thread.
460
+ Stop it with :meth:`stop_tool_call` before drawing the next turn.
461
+ """
462
+ self.stop_tool_call() # be defensive: never leak more than one spinner
463
+ if not self._can_clear_cursor():
464
+ self.emit(self.style(f"\u2026 {message}", fg="magenta", dim=True))
465
+ return
466
+ self._tool_call_stop = threading.Event()
467
+ self._tool_call_thread = threading.Thread(
468
+ target=self._spin_tool_call, args=(message,), daemon=True)
469
+ self._tool_call_thread.start()
470
+
471
+ def _spin_tool_call(self, message):
472
+ """Animation loop for the tool-call spinner (mirror of :meth:`_spin`)."""
473
+ stop = self._tool_call_stop
474
+ i = 0
475
+ while not stop.is_set():
476
+ dots = "\u2026 " * min((i % 3) + 1, 3)
477
+ i += 1
478
+ self.emit_transient(self.style(f"{dots}{message}", fg="magenta", dim=True))
479
+ stop.wait(0.3)
480
+ # Replace the spinner line with a blank so the following real output
481
+ # lands cleanly; the blank stays tracked as the transient block and is
482
+ # cleared by the next :meth:`emit` (e.g. the next assistant turn).
483
+ self.emit_transient(None)
484
+
485
+ def stop_tool_call(self):
486
+ """Stop the "… calling tool" spinner started by :meth:`start_tool_call`.
487
+
488
+ Signals the animation thread to finish its current frame and exit, then
489
+ joins it, so no writes race with the main thread's subsequent output.
490
+ Safe to call when no spinner is running (a no-op)."""
491
+ stop = self._tool_call_stop
492
+ thread = self._tool_call_thread
493
+ self._tool_call_stop = None
494
+ self._tool_call_thread = None
495
+ if stop is not None:
496
+ stop.set()
497
+ if thread is not None and thread.is_alive():
498
+ thread.join(timeout=1.0)
499
+
435
500
  def tool_group(self, name, params=None, result=None, status="ran"):
436
501
  """Render one attempted tool call: its name, parameters and result."""
437
502
  self.emit(self.style(f"{self.glyph('gear')} {name}", fg="magenta", bold=True))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-code-cli
3
- Version: 0.1.0.0
3
+ Version: 0.1.0.2
4
4
  Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
5
5
  Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -720,7 +720,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
720
720
  - allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
721
721
  - ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
722
722
  - system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
723
- - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session (the one whose history was last modified) from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given.
723
+ - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
724
724
  - plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
725
725
 
726
726
  ### Available tools
@@ -871,4 +871,19 @@ vllm serve ornith-ai/Ornith-1.5-397B-FP8 \
871
871
  --trust-remote-code
872
872
  ```
873
873
 
874
+ ## Citation
875
+
876
+ If you use `mini-code-cli` in your research or writing, please cite it using the BibTeX entry below:
877
+
878
+ ```bibtex
879
+ @misc{mini-code-cli,
880
+ author = {Rozanov, Nikolai},
881
+ title = {mini-code-cli: A minimal, dependency-light coding agent},
882
+ year = {2026},
883
+ version = {0.1.0.0},
884
+ howpublished = {\url{https://github.com/ai-nikolai/mini-code-cli}},
885
+ note = {A no-bloat, dependency-free agent CLI for experimenting.}
886
+ }
887
+ ```
888
+
874
889
  ## (C) Nikolai Rozanov, 2026 - Present
@@ -26,6 +26,7 @@ mini_code_cli/subagents/base.py
26
26
  mini_code_cli/subagents/builtin.py
27
27
  tests/test_agent_loop.py
28
28
  tests/test_integration.py
29
+ tests/test_last_session_pointer.py
29
30
  tests/test_plan.py
30
31
  tests/test_sandboxing.py
31
32
  tests/test_shell_safety.py
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "mini-code-cli"
3
3
  # version = { file = "mini_code_cli/__init__.py", attr = "__version__" }
4
- version = "0.1.0.0"
4
+ version = "0.1.0.2"
5
5
  description = "Mini Coding Agent. Allows you to easily experiment with a small coding agent."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.10"
@@ -0,0 +1,218 @@
1
+ """Tests for the `.mini` latest-session pointer used by --load-last-session.
2
+
3
+ A tiny pointer is recorded in `--allowed-dir/.mini/last_session.json` at the
4
+ end of every interactive session. `--load-last-session` consults that pointer
5
+ *first* (an explicit record of the session the user last worked in) and only
6
+ falls back to the timestamp heuristic over the cache dir when there is none.
7
+ """
8
+ import io
9
+ import os
10
+ import sys
11
+ import types
12
+ import tempfile
13
+ import json
14
+ import shutil
15
+ import unittest
16
+ from unittest.mock import patch
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ from mini_code_cli import agent_loop, ui, persistence, skills
21
+
22
+
23
+ def _make_args(**kw):
24
+ a = types.SimpleNamespace(
25
+ url="http://x", model="m", allowed_dir=".", cache_dir=None, api_key=None,
26
+ system_prompt=None, agent_md=None, prompt=None, enable_shell=False,
27
+ ask_permission=False, auto_mode=False, restore_session=None, llm_timeout=30,
28
+ tool_timeout=30, load_last_session=False, verbose=False, max_tokens=None,
29
+ temperature=1.0, plan=False, enable_subagents=False, enable_skills=False,
30
+ sudo=False,
31
+ )
32
+ a.__dict__.update(kw)
33
+ return a
34
+
35
+
36
+ class TestPointerBasics(unittest.TestCase):
37
+ def setUp(self):
38
+ self.tmp = tempfile.TemporaryDirectory()
39
+ self.addCleanup(self.tmp.cleanup)
40
+ self.allowed = self.tmp.name
41
+
42
+ def _session_dir(self, name="sess"):
43
+ d = os.path.join(self.tmp.name, "cache", name)
44
+ os.makedirs(d, exist_ok=True)
45
+ open(os.path.join(d, "history.json"), "w").write("[]")
46
+ return d
47
+
48
+ def test_save_then_load_roundtrip(self):
49
+ sdir = self._session_dir("one")
50
+ path = persistence.save_last_session_pointer(self.allowed, sdir)
51
+ self.assertIsNotNone(path)
52
+ self.assertTrue(os.path.isfile(path))
53
+ payload = json.load(open(path))
54
+ self.assertEqual(payload["session_dir"], os.path.abspath(sdir))
55
+ self.assertIn("recorded_at", payload)
56
+ self.assertEqual(persistence.load_last_session_pointer(self.allowed),
57
+ os.path.abspath(sdir))
58
+
59
+ def test_load_missing_returns_none(self):
60
+ self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
61
+
62
+ def test_load_malformed_returns_none(self):
63
+ persistence.save_last_session_pointer(self.allowed, self._session_dir("x"))
64
+ p = os.path.join(self.allowed, ".mini", persistence.LAST_SESSION_FILENAME)
65
+ open(p, "w").write("not json {[")
66
+ self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
67
+
68
+ def test_load_pointing_to_missing_dir_returns_none(self):
69
+ path = persistence.save_last_session_pointer(self.allowed, self._session_dir("x"))
70
+ shutil.rmtree(os.path.join(self.tmp.name, "cache", "x"))
71
+ self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
72
+
73
+ def test_save_none_is_a_noop(self):
74
+ self.assertIsNone(persistence.save_last_session_pointer(self.allowed, None))
75
+ self.assertIsNone(
76
+ persistence.load_last_session_pointer(self.allowed))
77
+
78
+
79
+ class TestFindLastSessionResolution(unittest.TestCase):
80
+ def setUp(self):
81
+ self.tmp = tempfile.TemporaryDirectory()
82
+ self.addCleanup(self.tmp.cleanup)
83
+ self.allowed = self.tmp.name
84
+ self.cache = os.path.join(self.tmp.name, "cache")
85
+
86
+ def _session_dir(self, name):
87
+ d = os.path.join(self.cache, name)
88
+ os.makedirs(d, exist_ok=True)
89
+ f = os.path.join(d, "history.json")
90
+ open(f, "w").write("[]")
91
+ return d, f
92
+
93
+ def test_pointer_wins_over_newer_mtime(self):
94
+ s1, _ = self._session_dir("older")
95
+ s2, f2 = self._session_dir("newer")
96
+ os.utime(f2, (1e12, 1e12)) # far in the future -> newest by mtime
97
+ # Record a pointer to s1 (the older one).
98
+ persistence.save_last_session_pointer(self.allowed, s1)
99
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
100
+ use_pointer=True)
101
+ self.assertEqual(got, os.path.abspath(s1))
102
+
103
+ def test_falls_back_to_mtime_when_no_pointer(self):
104
+ s1, _ = self._session_dir("older")
105
+ s2, f2 = self._session_dir("newer")
106
+ os.utime(f2, (1e12, 1e12))
107
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
108
+ use_pointer=True)
109
+ self.assertEqual(got, os.path.abspath(s2))
110
+
111
+ def test_use_pointer_false_ignores_pointer(self):
112
+ s1, _ = self._session_dir("older")
113
+ s2, f2 = self._session_dir("newer")
114
+ os.utime(f2, (1e12, 1e12))
115
+ persistence.save_last_session_pointer(self.allowed, s1)
116
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
117
+ use_pointer=False)
118
+ self.assertEqual(got, os.path.abspath(s2))
119
+
120
+ def test_none_when_everything_empty(self):
121
+ os.makedirs(self.cache, exist_ok=True)
122
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
123
+ use_pointer=True)
124
+ self.assertIsNone(got)
125
+
126
+
127
+ class TestPointerAndSkillsCoexist(unittest.TestCase):
128
+ def setUp(self):
129
+ self.tmp = tempfile.TemporaryDirectory()
130
+ self.addCleanup(self.tmp.cleanup)
131
+ self.allowed = self.tmp.name
132
+
133
+ def test_pointer_file_does_not_appear_as_a_skill(self):
134
+ # Use skills so .mini + SKILLS.md exist.
135
+ skills.list_skills(self.allowed)
136
+ # A session records its pointer in the same .mini.
137
+ cache = os.path.join(self.tmp.name, "cache")
138
+ sdir = os.path.join(cache, "abc")
139
+ os.makedirs(sdir); open(os.path.join(sdir, "history.json"), "w").write("[]")
140
+ persistence.save_last_session_pointer(self.allowed, sdir)
141
+ # Skills still work, and the pointer file never shows up as a skill.
142
+ listing = skills.list_skills(allowed_dir=self.allowed)
143
+ self.assertNotIn(persistence.LAST_SESSION_FILENAME, listing)
144
+ self.assertIn("Skills", open(skills.summary_path(self.allowed)).read())
145
+
146
+ def test_create_skill_after_pointer_written(self):
147
+ cache = os.path.join(self.tmp.name, "cache")
148
+ sdir = os.path.join(cache, "abc")
149
+ os.makedirs(sdir); open(os.path.join(sdir, "history.json"), "w").write("[]")
150
+ persistence.save_last_session_pointer(self.allowed, sdir)
151
+ out = skills.create_skill(self.allowed, "greet", "greets")
152
+ self.assertIn("Created", out)
153
+ self.assertIn("greet", skills.list_skills(self.allowed))
154
+
155
+
156
+ class TestPrintSessionIdAndDivider(unittest.TestCase):
157
+ """The interactive run() should show the id as an Assistant turn in brackets,
158
+ and draw a short divider before the user's turn."""
159
+
160
+ def setUp(self):
161
+ self.tmp = tempfile.TemporaryDirectory()
162
+ self.addCleanup(self.tmp.cleanup)
163
+ self.buf = io.StringIO()
164
+ self.console = ui.Console(stream=self.buf, color=False, unicode=False)
165
+
166
+ def _fake_llm(self, session):
167
+ def fake(messages, **kw):
168
+ return "hi there", [], {"prompt_tokens": 1, "completion_tokens": 1,
169
+ "total_tokens": 2}
170
+ return fake
171
+
172
+ def test_session_id_renderedin_assistant_turn_and_pointer_written(self):
173
+ allowed = self.tmp.name
174
+ args = _make_args(cache_dir=os.path.join(self.tmp.name, "cache"),
175
+ allowed_dir=allowed, prompt="hello")
176
+ sess = agent_loop.AgentSession(args, console=self.console)
177
+ uid = sess.unique_id
178
+
179
+ with patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
180
+ patch.object(agent_loop.utils, "call_openai_server", self._fake_llm(sess)), \
181
+ patch.object(self.console, "read_line", side_effect=iter(["/quit"])):
182
+ sess.run(initial_prompt="hello")
183
+
184
+ out = self.buf.getvalue()
185
+ # (1) id rendered as an Assistant turn carrying only the id in brackets.
186
+ self.assertIn(f"[{uid}]", out)
187
+ # It sits on the same line as the 'Assistant' role marker.
188
+ self.assertRegex(out, r"(?m)^> Assistant\n \[%s\]$" % uid)
189
+ # No noisy "saved under" line in the session-id turn.
190
+ self.assertNotIn("saved under", out)
191
+
192
+ # (3) a pointer was written under allowed_dir/.mini.
193
+ ptr = os.path.join(allowed, ".mini", persistence.LAST_SESSION_FILENAME)
194
+ self.assertTrue(os.path.isfile(ptr))
195
+ payload = json.load(open(ptr))
196
+ self.assertEqual(payload["session_dir"],
197
+ os.path.abspath(os.path.join(self.tmp.name, "cache", uid)))
198
+
199
+ def test_short_divider_before_preset_you_turn(self):
200
+ args = _make_args(cache_dir=os.path.join(self.tmp.name, "cache"),
201
+ allowed_dir=self.tmp.name, prompt="hello")
202
+ sess = agent_loop.AgentSession(args, console=self.console)
203
+ with patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
204
+ patch.object(agent_loop.utils, "call_openai_server", self._fake_llm(sess)), \
205
+ patch.object(self.console, "read_line", side_effect=iter(["/quit"])):
206
+ sess.run(initial_prompt="hello")
207
+ out = self.buf.getvalue()
208
+ # The preset "You" turn is preceded by a short divider line.
209
+ lines = out.splitlines()
210
+ idx = next(i for i, l in enumerate(lines) if l.strip() == "> You")
211
+ divider = lines[idx - 1].strip()
212
+ self.assertTrue(set(divider) <= {"-", " "})
213
+ self.assertIn("-", divider)
214
+ self.assertLess(len(divider.strip("-")), 12)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ unittest.main()
@@ -115,6 +115,24 @@ class TestConsoleRendering(unittest.TestCase):
115
115
  self.assertIn("Assistant", text)
116
116
  self.assertIn(" line2", text)
117
117
 
118
+ def test_short_divider_renders_without_ansi(self):
119
+ c = ui.Console(stream=self.buf, color=False)
120
+ c.short_divider()
121
+ out = self.buf.getvalue().rstrip("\n")
122
+ # A short run of dashes, not the full-width divider.
123
+ self.assertTrue(set(out) <= {"-", " "})
124
+ self.assertLess(len(out.strip("-")), 12)
125
+ self.assertIn("-", out)
126
+
127
+ def test_short_divider_shorter_than_full_divider(self):
128
+ c = ui.Console(stream=self.buf, color=False)
129
+ c.short_divider()
130
+ short = len(self.buf.getvalue().rstrip("\n").strip())
131
+ self.buf.seek(0); self.buf.truncate()
132
+ c.divider()
133
+ full = len(self.buf.getvalue().rstrip("\n").strip())
134
+ self.assertLess(short, full)
135
+
118
136
  def test_welcome_renders_without_error(self):
119
137
  c = ui.Console(stream=self.buf, color=False)
120
138
  c.welcome("1.0", "id", "/cache", "/home", "http://x", "model",
File without changes