mini-code-cli 0.1.0.1__tar.gz → 0.1.0.3__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 (40) hide show
  1. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/PKG-INFO +3 -2
  2. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/README.md +2 -1
  3. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/agent_loop.py +224 -56
  4. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/persistence.py +107 -2
  5. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/runner.py +23 -6
  6. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/ui.py +10 -0
  7. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/utils.py +30 -3
  8. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/PKG-INFO +3 -2
  9. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/SOURCES.txt +3 -0
  10. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/pyproject.toml +1 -1
  11. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_integration.py +9 -2
  12. mini_code_cli-0.1.0.3/tests/test_last_session_pointer.py +218 -0
  13. mini_code_cli-0.1.0.3/tests/test_resume_offer.py +178 -0
  14. mini_code_cli-0.1.0.3/tests/test_robustness.py +179 -0
  15. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_ui.py +18 -0
  16. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/LICENSE +0 -0
  17. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/__init__.py +0 -0
  18. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/config.py +0 -0
  19. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/permissions.py +0 -0
  20. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/plan.py +0 -0
  21. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/prompts.py +0 -0
  22. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/sandbox.py +0 -0
  23. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/shell_safety_utils.py +0 -0
  24. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/skills.py +0 -0
  25. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/subagents/__init__.py +0 -0
  26. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/subagents/base.py +0 -0
  27. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/subagents/builtin.py +0 -0
  28. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli/tools.py +0 -0
  29. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/dependency_links.txt +0 -0
  30. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/entry_points.txt +0 -0
  31. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/requires.txt +0 -0
  32. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/mini_code_cli.egg-info/top_level.txt +0 -0
  33. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/setup.cfg +0 -0
  34. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_agent_loop.py +0 -0
  35. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_plan.py +0 -0
  36. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_sandboxing.py +0 -0
  37. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_shell_safety.py +0 -0
  38. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_skills.py +0 -0
  39. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/tests/test_subagents.py +0 -0
  40. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.3}/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.1
3
+ Version: 0.1.0.3
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,8 @@ 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
+ - **Interactive session offer:** if a previous session exists in the cache dir but you did **not** explicitly ask to resume one (i.e. neither `--load-last-session` nor `--restore-session` was passed), the prompt asks what you'd like to do before the conversation starts: 1) **Resume** the previous session (splice its history in under a brand-new session id and continue it), 2) **Start a new** session (empty history), or 3) **Exit** (start nothing). Ctrl-C / EOF at the offer is treated as a graceful exit. This is distinct from `--load-last-session`, which silently resumes the most recent session without asking.
724
725
  - 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
726
 
726
727
  ### Available tools
@@ -46,7 +46,8 @@ 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
+ - **Interactive session offer:** if a previous session exists in the cache dir but you did **not** explicitly ask to resume one (i.e. neither `--load-last-session` nor `--restore-session` was passed), the prompt asks what you'd like to do before the conversation starts: 1) **Resume** the previous session (splice its history in under a brand-new session id and continue it), 2) **Start a new** session (empty history), or 3) **Exit** (start nothing). Ctrl-C / EOF at the offer is treated as a graceful exit. This is distinct from `--load-last-session`, which silently resumes the most recent session without asking.
50
51
  - 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
52
 
52
53
  ### Available tools
@@ -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,
@@ -137,6 +138,7 @@ def parse_args():
137
138
  args.enable_shell = True
138
139
  args.sudo = True
139
140
  args.auto_mode = True
141
+ args.skills = True
140
142
 
141
143
  if args.all_last:
142
144
  args.load_last_session = True
@@ -288,36 +290,10 @@ class AgentSession:
288
290
  self.model = model_name if model_name else args.model
289
291
  self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
290
292
 
291
- # Which tools the LLM may call. The shell tool is only available when
292
- # --enable-shell was passed.
293
- forbidden = None if args.enable_shell else tools.shell_tools
294
- self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
295
- self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
296
-
297
- # Sub-agents (opt-in via --enable-subagents). Each registered sub-agent
298
- # becomes an extra tool the model can call; see subagents/. A child
299
- # session built by a sub-agent never sets enable_subagents, so sub-agents
300
- # cannot recursively spawn sub-agents.
301
- self.enable_subagents = bool(getattr(args, "enable_subagents", False))
302
- self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
303
- for sa in self.subagents_registry.values():
304
- self.llm_tools.append(sa.tool_schema())
305
- # Marked so _run_one_tool_call knows to delegate rather than call a
306
- # real function.
307
- self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
308
-
309
- # Skills (opt-in via --skills). Each registered skill tool is exposed to
310
- # the model as a normal tool, but they are all sandbox-safe: the .mini
311
- # directory lives *inside* --allowed-dir and the tools never accept a
312
- # path from the model -- skill / script names are validated as single
313
- # path segments and resolved under the .mini root. So skills are exposed
314
- # even in sessions without --enable-shell (run_skill only executes a
315
- # named Python file, never a free-form shell command).
316
- self.enable_skills = bool(getattr(args, "enable_skills", False))
317
- if self.enable_skills:
318
- self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
319
- self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
320
- self.tools_dict.update(self.skills_tools_dict)
293
+ # Which tools the LLM may call (shell / sub-agents / skills), and which
294
+ # of them are registered. Split out of __init__ for readability -- see
295
+ # _register_tools().
296
+ self._register_tools(args)
321
297
 
322
298
  # Token accounting for the whole session.
323
299
  self.token_stats = {
@@ -365,25 +341,47 @@ class AgentSession:
365
341
  # cache dir and continues it -- exactly like --restore-session, but without
366
342
  # needing to know the folder name. An explicit --restore-session always wins
367
343
  # and is used verbatim; otherwise the newest session is resolved here.
344
+ # A "last session" candidate -- only when the user did NOT explicitly
345
+ # ask to resume one (no --load-last-session / --restore-session): the
346
+ # most recent previous session in the cache dir. Unlike a real restore,
347
+ # the conversation starts empty here and is only spliced in if the user
348
+ # confirms they want it (see _offer_resume_last_session).
368
349
  restore_session = getattr(self.args, "restore_session", None)
350
+ self.last_session_candidate = None
369
351
  self.loaded_last_session_dir = None
370
- if getattr(self.args, "load_last_session", False) and not restore_session:
371
- detected = find_last_session_dir(self.cache_dir)
372
- if detected:
373
- restore_session = detected
374
- self.loaded_last_session_dir = detected
352
+ # Resolve the "most recent session" exactly once, and reuse the result
353
+ # for both concerns below (an explicit --restore-session always wins and
354
+ # is used verbatim):
355
+ # * --load-last-session turns the detected session into a real restore
356
+ # (continue it under this new id), OR
357
+ # * when the user did NOT explicitly ask to resume (no
358
+ # --load-last-session / --restore-session), it becomes a resume
359
+ # *candidate* that run() offers interactively (see
360
+ # _offer_resume_last_session). The conversation still starts empty
361
+ # here and is spliced in only if the user confirms.
362
+ # Skipped entirely once a real restore is already in hand, so those paths
363
+ # never offer anything.
364
+ detected = self._detect_last_session()
365
+ if getattr(self.args, "load_last_session", False) and not restore_session and detected:
366
+ restore_session = detected
367
+ self.loaded_last_session_dir = detected
368
+ if not restore_session and detected:
369
+ self.last_session_candidate = detected
375
370
  restore_directory = _restore_dir(restore_session)
376
371
  restored = load_history_from_dir(restore_directory) if restore_directory else None
372
+ # A session built from an explicit restore / load-last-session reuses the
373
+ # restored history (under this brand-new id, so the original is never
374
+ # overwritten); an idle start keeps the freshly built initial messages.
377
375
  self.messages = restored if restored is not None else self._build_initial_messages()
378
- if restored is not None:
379
- self._restore_usage_if_available(restore_session)
380
- self._load_summary_if_restored(restore_directory)
381
- else:
382
- # --load-last-session asked for a session but nothing usable was found:
383
- # don't advertise a load. The recorder starts empty and the first
384
- # assistant / tool-call turn seeds turn 1 -- mirroring the restore
385
- # path, which also has no leading system turn, so /history turn
386
- # numbers stay consistent whether or not we restored.
376
+ self._restore_from(restore_directory, restored)
377
+ # --load-last-session asked for a session but nothing usable was found:
378
+ # clear the flag so run() reports "no previous session to load" instead
379
+ # of pretending it resumed one. When the restore succeeded the flag set
380
+ # above (loaded_last_session_dir = detected) is kept so run() prints the
381
+ # "Loaded most recent session ... and continuing it" note. Best-effort:
382
+ # a missing/empty/corrupt history falls back to a fresh session rather
383
+ # than crashing.
384
+ if restored is None:
387
385
  self.loaded_last_session_dir = None
388
386
 
389
387
  # Snapshot the initial (or restored) messages as the first history segment
@@ -392,6 +390,56 @@ class AgentSession:
392
390
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
393
391
  save_summary(self.summary, self.cache_dir, self.unique_id)
394
392
 
393
+ def _detect_last_session(self):
394
+ """Resolve the most recent previous session dir, once.
395
+
396
+ Consults the .mini pointer recorded under --allowed-dir first (an
397
+ explicit record of the last session worked in), then falls back to the
398
+ timestamp heuristic over the cache dir. Returns the path, or None. The
399
+ constructor calls this a single time so the (relatively expensive)
400
+ pointer / directory scan does not run twice for the two independent
401
+ concerns -- --load-last-session vs. the interactive resume candidate.
402
+ """
403
+ return find_last_session_dir(
404
+ self.cache_dir,
405
+ allowed_dir=self.allowed_dir,
406
+ use_pointer=True,
407
+ )
408
+
409
+ def _register_tools(self, args):
410
+ """Wire up which tools the LLM may call, and register the opt-in ones.
411
+
412
+ Base tools depend on --enable-shell (the shell tool is withheld unless
413
+ shell is explicitly on). Then two opt-in features each add their own
414
+ sandbox-safe tools:
415
+
416
+ * Sub-agents (--enable-subagents): every registered sub-agent becomes
417
+ an extra tool. Its tools_dict entry has ``function=None`` so
418
+ _run_one_tool_call knows to *delegate* to a nested session rather
419
+ than call a real function. A child session never sets
420
+ enable_subagents, so sub-agents cannot recursively spawn sub-agents.
421
+ * Skills (--skills): the .mini directory (inside --allowed-dir) exposes
422
+ list/read/create/run_skill tools. They are sandbox-safe -- names are
423
+ validated as single path segments and resolved under .mini, so they
424
+ never accept an arbitrary path from the model and are usable even
425
+ without --enable-shell.
426
+ """
427
+ forbidden = None if args.enable_shell else tools.shell_tools
428
+ self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
429
+ self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
430
+
431
+ self.enable_subagents = bool(getattr(args, "enable_subagents", False))
432
+ self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
433
+ for sa in self.subagents_registry.values():
434
+ self.llm_tools.append(sa.tool_schema())
435
+ self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
436
+
437
+ self.enable_skills = bool(getattr(args, "enable_skills", False))
438
+ if self.enable_skills:
439
+ self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
440
+ self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
441
+ self.tools_dict.update(self.skills_tools_dict)
442
+
395
443
  def _build_initial_messages(self):
396
444
  """Assemble the system prompt and optional Agent.md into the first turns."""
397
445
  if self.args.system_prompt:
@@ -457,6 +505,28 @@ class AgentSession:
457
505
  self.summary.append({"turn": turn, "what": "assistant",
458
506
  "history_file": 0, "index": i})
459
507
 
508
+ def _restore_from(self, directory, messages=None):
509
+ """Seed usage, recorder and messages for a restore under this id.
510
+
511
+ Shared by the constructor (for an explicit ``--restore-session`` /
512
+ ``--load-last-session``) and the interactive resume prompt. The caller
513
+ resolves and loads ``directory`` itself, then hands the loaded
514
+ ``messages`` here so all seeding lives in one place.
515
+
516
+ ``usage.json`` is layered *on top* of the in-memory totals (so a restore
517
+ under a brand-new session id accumulates cleanly), the recorder is
518
+ flattened across the segment boundary so ``/history`` / ``/reset`` stay
519
+ consistent, and ``self.messages`` becomes the restored snapshot.
520
+ Best-effort: a missing directory / unreadable files leave the session
521
+ unchanged (a fresh, empty history).
522
+ """
523
+ restored = messages if messages is not None else (
524
+ load_history_from_dir(directory) if directory else None)
525
+ self._restore_usage_if_available(directory)
526
+ self._load_summary_if_restored(directory)
527
+ if restored:
528
+ self.messages = restored
529
+
460
530
  def _record_turn(self, what, messages, history_index=None):
461
531
  """Append one entry to the high-level recorder.
462
532
 
@@ -575,16 +645,95 @@ class AgentSession:
575
645
  f"Sub-agents enabled ({len(self.subagents_registry)} available: "
576
646
  f"{', '.join(self.subagents_registry)}).", "info")
577
647
 
648
+ def _offer_resume_last_session(self):
649
+ """Offer to continue the most recent previous session, if one exists.
650
+
651
+ Only reached when the user did *not* already ask to resume a session
652
+ (``--load-last-session`` / ``--restore-session``), which resolve their
653
+ own before this and leave ``last_session_candidate`` unset. When a
654
+ candidate exists the user chooses between three outcomes:
655
+
656
+ * **Resume** -- splice the previous session's history into this one
657
+ (still under the brand-new id) and continue it, under a new id.
658
+ * **New** -- leave the conversation empty and start a fresh session.
659
+ * **Exit** -- stop here before any pointer is written or loop enters.
660
+
661
+ The choice is recorded by setting/clearing ``self.last_session_candidate``:
662
+ ``resume`` keeps it pointing at the previous session's directory (the
663
+ caller in :meth:`run` performs the restore); ``new`` clears it (nothing
664
+ to restore). Returns True when the user chose to exit so the caller
665
+ returns before writing any pointer or entering the loop.
666
+ """
667
+ if not self.last_session_candidate:
668
+ return False
669
+ choice = self._resume_choice()
670
+ if choice == "resume":
671
+ return False # keep the candidate: run() restores it below.
672
+ # "new" -> clear the candidate (nothing to restore); "exit" -> clear
673
+ # and tell the caller to stop before writing a pointer / entering the
674
+ # loop.
675
+ self.last_session_candidate = None
676
+ return choice == "exit"
677
+
678
+ def _resume_choice(self):
679
+ """Prompt for the resume choice and return its label.
680
+
681
+ Returns one of ``"resume"``, ``"new"`` or ``"exit"``. The prompt
682
+ degrades cleanly on a non-TTY / piped console: an EOF / Ctrl-C (``read_line``
683
+ returns None) is treated as ``"exit"`` so the loop never spins on unread input.
684
+ """
685
+ self.console.emit(
686
+ self.console.style(
687
+ "A previous session was found. What would you like to do?", dim=True,
688
+ fg="cyan"))
689
+ self.console.emit(" 1) Resume the previous session (continue where you left off)")
690
+ self.console.emit(" 2) Start a new session (empty history)")
691
+ self.console.emit(" 3) Exit (start nothing)")
692
+ while True:
693
+ answer = self.console.read_line(
694
+ self.console.style("Resume previous session? Type 1, 2, or 3: ", fg="cyan"))
695
+ if answer is None:
696
+ # EOF / Ctrl-C at the offer -> graceful exit, no session written.
697
+ return "exit"
698
+ choice = answer.strip().lower()
699
+ if choice in ("1", "resume"):
700
+ return "resume"
701
+ if choice in ("2", "new"):
702
+ return "new"
703
+ if choice in ("3", "exit"):
704
+ return "exit"
705
+ self.console.note("Please choose 1, 2, or 3.", "warn")
706
+
578
707
  def run(self, initial_prompt=None):
579
708
  """Drive the agent loop until the user quits."""
580
709
  self.print_announcements()
581
710
  print_welcome(self.allowed_dir, self.server_url, self.args,
582
711
  self.unique_id, self.cache_dir, self.model)
583
712
 
713
+ # A previous session exists but the user did not explicitly ask to
714
+ # resume it. Offer them a choice; on "exit" we return here before
715
+ # writing any pointer or entering the loop, and we splice the
716
+ # restored history into this session if they pick "resume".
717
+ if self._offer_resume_last_session():
718
+ return
719
+ if self.last_session_candidate is not None:
720
+ restored_dir = self.last_session_candidate
721
+ self.last_session_candidate = None
722
+ restored = load_history_from_dir(restored_dir)
723
+ self._restore_from(restored_dir, restored)
724
+ self.loaded_last_session_dir = restored_dir
725
+
584
726
  # Announce the session's unique id as an assistant-styled message so the
585
727
  # user can see / copy the id this conversation is being saved under.
586
728
  self._print_session_id()
587
729
 
730
+ # Record this session as the "latest" so a future --load-last-session can
731
+ # resume it without scanning the cache dir. This lives in the top-level
732
+ # loop's own directory (cache_dir/unique_id), and only the interactive
733
+ # run() writes it -- a sub-agent child drives via _drive_auto and would
734
+ # otherwise overwrite the pointer with its own buried path.
735
+ self._save_last_session_pointer()
736
+
588
737
  # Report the outcome of --load-last-session up front, so the user sees
589
738
  # whether we resumed an earlier conversation (under a brand-new id) or
590
739
  # started fresh because no previous session was found.
@@ -616,6 +765,7 @@ class AgentSession:
616
765
  # 1) Get the user's turn: the one-shot preset, or a fresh line.
617
766
  if pending is not None:
618
767
  self.console.rule(title="Preset prompt", char="=", fg="cyan")
768
+ self.console.short_divider()
619
769
  self.console.turn("You", pending, accent="cyan")
620
770
  user_input = pending
621
771
  pending = None
@@ -643,7 +793,8 @@ class AgentSession:
643
793
  self._persist()
644
794
  if not should_continue:
645
795
  break
646
- self.console.note("Continuing with LLM calls until no more tool calls.", "info")
796
+ self.console.note(
797
+ "Continuing with LLM calls until no more tool calls.", "info")
647
798
  except _Abort:
648
799
  # Ctrl-C during a permission prompt already shut the session
649
800
  # down; just unwind the loop quietly.
@@ -654,18 +805,24 @@ class AgentSession:
654
805
  self._persist()
655
806
 
656
807
  def _print_session_id(self):
657
- """Print the session's unique id as an assistant-styled message.
808
+ """Print the session's unique id as a minimal "Assistant" turn.
658
809
 
659
- A short, styled announcement (in the assistant accent) so the user can
660
- see and copy the id the conversation is saved under -- the same id shown
661
- in the welcome banner, but rendered as a transcript turn so it is easy
662
- to spot and grep.
810
+ Instead of a banner/header the id is rendered as an assistant-styled
811
+ (green) turn carrying *only* the id in brackets, so the user can see /
812
+ copy the id this conversation is being saved under without the noise of
813
+ the extra "saved under" line.
663
814
  """
664
- self.console.divider()
665
- self.console.turn(
666
- "Session id",
667
- f"{self.unique_id} (saved under {self.cache_dir})",
668
- accent="green")
815
+ self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
816
+
817
+ def _save_last_session_pointer(self):
818
+ """Record this session's directory in --allowed-dir/.mini.
819
+
820
+ Writes the .mini pointer that a later --load-last-session consults first
821
+ (see save_last_session_pointer). Best-effort: never raises, so a pointer
822
+ hiccup can never break the interactive session.
823
+ """
824
+ save_last_session_pointer(self.allowed_dir,
825
+ os.path.join(self.cache_dir, self.unique_id))
669
826
 
670
827
  # -- autonomous drive (used by sub-agents) ------------------------------
671
828
 
@@ -832,7 +989,12 @@ class AgentSession:
832
989
  # -- interactive input -------------------------------------------------
833
990
 
834
991
  def _read_user_line(self):
835
- """Read one turn from the user. Returns the text to send, or None to quit."""
992
+ """Read one turn from the user. Returns the text to send, or None to quit.
993
+
994
+ An empty (blank) line is not a real instruction, so it is ignored and the
995
+ user is asked again rather than sending an empty turn to the model.
996
+ """
997
+ self.console.short_divider()
836
998
  user_input = self.console.read_line(style_line_prompt("You:"))
837
999
  if user_input is None: # EOF / Ctrl-C at the prompt
838
1000
  self.console.note("Thank you. Goodbye! Saving history...", "info")
@@ -847,6 +1009,12 @@ class AgentSession:
847
1009
  # Nothing to send to the model; ask again.
848
1010
  return self._read_user_line()
849
1011
 
1012
+ # A blank line carries no instruction: say so and re-prompt instead of
1013
+ # appending an empty user turn and spending an LLM call on it.
1014
+ if not user_input.strip():
1015
+ self.console.note("No input. (Type /help for commands, /quit to exit.)", "warn")
1016
+ return self._read_user_line()
1017
+
850
1018
  return user_input
851
1019
 
852
1020
  def _handle_command(self, user_input):
@@ -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),
@@ -21,10 +21,10 @@ class _TimedOut(Exception):
21
21
  def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT):
22
22
  """Call ``func(**tool_params)``, aborting after ``timeout_seconds``.
23
23
 
24
- On timeout, returns a friendly ``Error:`` string (so the agent sees the
25
- failure as a tool result rather than crashing). Any *other* exception the
26
- tool raises is allowed to propagate -- the tool authors are responsible for
27
- returning ``Error`` strings, and surfacing genuine bugs here is intentional.
24
+ On timeout -- or if the tool raises *any* other exception -- a friendly
25
+ ``Error:`` string is returned so the failure reaches the model as a tool
26
+ result (letting it recover or retry) instead of propagating and crashing the
27
+ whole agent loop. A timeout is reported distinctly from a generic failure.
28
28
  """
29
29
  def _handler(signum, frame):
30
30
  raise _TimedOut()
@@ -33,8 +33,9 @@ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_
33
33
  previous = signal.signal(signal.SIGALRM, _handler)
34
34
  except (ValueError, OSError):
35
35
  # signal only works in the main thread; if we cannot arm it, just run
36
- # the tool without the wall-clock guard rather than failing outright.
37
- return func(**tool_params)
36
+ # the tool without the wall-clock guard rather than failing outright --
37
+ # but still shield the loop from a tool that raises.
38
+ return _call_tool_safely(func, tool_params, name)
38
39
 
39
40
  signal.alarm(int(timeout_seconds) if timeout_seconds else 0)
40
41
  try:
@@ -42,6 +43,22 @@ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_
42
43
  except _TimedOut:
43
44
  ui.console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
44
45
  return f"Error: Function {name} timed out after {timeout_seconds} seconds."
46
+ 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")
48
+ return f"Error: tool {name} raised an error: {e}"
45
49
  finally:
46
50
  signal.alarm(0)
47
51
  signal.signal(signal.SIGALRM, previous)
52
+
53
+
54
+ def _call_tool_safely(func, tool_params, name):
55
+ """Run a tool with no wall-clock guard (SIGALRM unavailable) but still
56
+ shield the loop from a tool that raises.
57
+
58
+ Used as the fallback when we could not arm SIGALRM (e.g. a non-main thread).
59
+ """
60
+ try:
61
+ return func(**tool_params)
62
+ except Exception as e: # noqa: BLE001 -- never let a tool exception propagate
63
+ ui.console.note(f"Tool {name} raised an error: {e}", "error")
64
+ return f"Error: tool {name} raised an error: {e}"
@@ -371,6 +371,16 @@ class Console:
371
371
  """
372
372
  self.emit(self.style("-" * self.width, fg="gray", dim=True))
373
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
+
374
384
  def clear_screen(self):
375
385
  """Clear the terminal and move the cursor to the top-left.
376
386