mini-code-cli 0.1.0.2__py3-none-any.whl → 0.1.0.4__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.
@@ -52,7 +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
+ # save_last_session_pointer,
56
56
  accumulate_usage,
57
57
  generate_unique_id,
58
58
  _restore_dir,
@@ -122,6 +122,7 @@ def parse_args():
122
122
  parser.add_argument("--skills", action="store_true", help="Enable the skills feature: a local .mini directory (inside --allowed-dir) of skill.md files, skill sub-dirs, a high-level SKILLS.md summary, and executable Python scripts, exposed to the agent as list_skills/read_skill/create_skill/run_skill tools. default= False")
123
123
  parser.add_argument("--llm-timeout", type=int, default=DEFAULT_HTTP_TIMEOUT, help=f"HTTP timeout in seconds for LLM requests (reasoning models can be slow). default={DEFAULT_HTTP_TIMEOUT}")
124
124
  parser.add_argument("--tool-timeout", type=int, default=DEFAULT_TOOL_TIMEOUT, help=f"Timeout in seconds for a single tool call. default={DEFAULT_TOOL_TIMEOUT}")
125
+ parser.add_argument("--max-messages", type=int, default=DEFAULT_MAX_MESSAGES, help=f"History length (in messages) before the conversation is compacted into a summary. Lower it to compact sooner on small context windows. default={DEFAULT_MAX_MESSAGES}")
125
126
  parser.add_argument("-p","--plan", action="store_true", help="Enable plan mode: the agent keeps a CURRENT_PLAN.md (its step-by-step plan, marked finished when the goal is done) and CURRENT_GOAL.md (the user's latest instruction) in the session directory. Also changes how history compaction works.")
126
127
 
127
128
  parser.add_argument("--all", action="store_true", help="Enable: --plan, --subagents, --shell, --automode, --sudo")
@@ -138,6 +139,7 @@ def parse_args():
138
139
  args.enable_shell = True
139
140
  args.sudo = True
140
141
  args.auto_mode = True
142
+ args.skills = True
141
143
 
142
144
  if args.all_last:
143
145
  args.load_last_session = True
@@ -164,6 +166,7 @@ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
164
166
  ui.console.emit(ui.console.style("History / navigation commands:", bold=True, fg="cyan"))
165
167
  ui.console.emit(" /history list the conversation (turn number, what happened, where)")
166
168
  ui.console.emit(" /history <n> show the full messages recorded for turn n")
169
+ ui.console.emit(" /plan show the current goal + plan (plan mode only)")
167
170
  ui.console.emit(" /reset [<n>] rewind to the end of turn n (drops everything after it)")
168
171
  ui.console.emit(" /clear clear the screen (keeps the conversation history)")
169
172
  ui.console.emit(" /help show this help")
@@ -204,7 +207,7 @@ def _make_tool_call_note(console, count):
204
207
  # history compaction
205
208
  # ---------------------------------------------------------------------------
206
209
 
207
- def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, history_index=0, max_messages=DEFAULT_MAX_MESSAGES):
210
+ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, history_index=0, max_messages=DEFAULT_MAX_MESSAGES, plan_hook=None, console=None):
208
211
  """Compact the conversation history once it grows beyond ``max_messages``.
209
212
 
210
213
  Long (especially auto-mode) sessions can overflow the model's context window.
@@ -212,6 +215,13 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
212
215
  old turns with that summary, keeping the original system prompt as the first
213
216
  message. The compaction call itself is a single, tool-free request, so it
214
217
  cannot trigger further tool calls or recurse into compaction.
218
+
219
+ ``plan_hook`` is an optional callable the caller supplies only when running
220
+ under plan mode (in practice ``AgentSession._handle_compaction``). After the
221
+ summary is built the hook re-injects the running goal + plan so the compacted
222
+ messages carry them forward. Passing it in -- instead of referencing the
223
+ owning session -- keeps ``maybe_compact`` a small, module-level, network-free
224
+ helper that the test-suite can import and exercise directly.
215
225
  """
216
226
  if len(messages) <= max_messages:
217
227
  # No compaction: keep the same segment (history_index) and signal the
@@ -223,6 +233,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
223
233
  try:
224
234
  ui.console.note(f"Compacting conversation history ({len(messages)} messages -> summary)...", "warn")
225
235
  summary_prompt = prompts.compaction_prompt(messages)
236
+ console.start_thinking("compacting")
226
237
  summary, _, compact_usage = utils.call_openai_server(
227
238
  [{"role": "user", "content": summary_prompt}],
228
239
  max_tokens=1024,
@@ -232,6 +243,8 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
232
243
  api_key=api_key,
233
244
  timeout=llm_timeout,
234
245
  )
246
+ console.stop_thinking()
247
+
235
248
  accumulate_usage(token_stats, compact_usage)
236
249
  if summary:
237
250
  system_content = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
@@ -242,6 +255,15 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
242
255
  "role": "user",
243
256
  "content": f"Here is a summary of our earlier conversation so far:\n{summary}",
244
257
  })
258
+
259
+ # In plan mode, re-inject the running plan + current goal so a
260
+ # compaction (which would otherwise drop them) keeps the model
261
+ # working against the goal it is still trying to finish. The hook is
262
+ # wired in only when the caller runs under plan mode; otherwise
263
+ # compaction degrades to a plain summary.
264
+ if plan_hook is not None:
265
+ plan_hook(compacted)
266
+
245
267
  ui.console.note(f"History compacted to {len(compacted)} messages.", "warn")
246
268
  # A new segment (history_index + 1) is started on compaction: the
247
269
  # compacted messages are its snapshot, and every later turn appends
@@ -267,58 +289,34 @@ class AgentSession:
267
289
  be unit-tested.
268
290
  """
269
291
 
270
- EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit"}
292
+ EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit", "exit()", "quit()"}
271
293
  CLEAR_COMMANDS = {"clear", "/clear"}
272
294
 
273
- def __init__(self, args, console=None):
295
+ def __init__(self, args, console=None, background_mode = False):
274
296
  self.args = args
275
297
  self.allowed_dir = os.path.abspath(args.allowed_dir)
276
298
  self.cache_dir = args.cache_dir if args.cache_dir else DEFAULT_CACHE_LOCATION
277
299
 
278
- # The console that renders everything the agent prints. Unit tests /
279
- # other callers can swap it out (e.g. AgentSession(args, console=...) with
280
- # a color=False console) but it defaults to the shared ui.console.
281
300
  self.console = console if console is not None else ui.console
282
- # --verbose toggles the amount of detail printed. Defaults to False so
283
- # the quiet, transcript-only view is the out-of-the-box experience.
284
301
  self.console.verbose = getattr(args, "verbose", False)
285
302
 
286
303
  # LLM / server configuration.
287
304
  self.server_url = f"{args.url}"
288
- model_name = utils.determine_vllm_model(self.server_url)
305
+ try:
306
+ model_name = utils.determine_vllm_model(self.server_url)
307
+ except:
308
+ model_name = None
309
+ pass
289
310
  self.model = model_name if model_name else args.model
290
311
  self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
312
+
313
+ self._register_tools(args)
291
314
 
292
- # Which tools the LLM may call. The shell tool is only available when
293
- # --enable-shell was passed.
294
- forbidden = None if args.enable_shell else tools.shell_tools
295
- self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
296
- self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
297
-
298
- # Sub-agents (opt-in via --enable-subagents). Each registered sub-agent
299
- # becomes an extra tool the model can call; see subagents/. A child
300
- # session built by a sub-agent never sets enable_subagents, so sub-agents
301
- # cannot recursively spawn sub-agents.
302
- self.enable_subagents = bool(getattr(args, "enable_subagents", False))
303
- self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
304
- for sa in self.subagents_registry.values():
305
- self.llm_tools.append(sa.tool_schema())
306
- # Marked so _run_one_tool_call knows to delegate rather than call a
307
- # real function.
308
- self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
315
+ # report_path is set on a *sub-agent child* session (by
316
+ # SubAgent._build_child_args)
317
+ self.report_path = getattr(args, "report_path", None)
309
318
 
310
- # Skills (opt-in via --skills). Each registered skill tool is exposed to
311
- # the model as a normal tool, but they are all sandbox-safe: the .mini
312
- # directory lives *inside* --allowed-dir and the tools never accept a
313
- # path from the model -- skill / script names are validated as single
314
- # path segments and resolved under the .mini root. So skills are exposed
315
- # even in sessions without --enable-shell (run_skill only executes a
316
- # named Python file, never a free-form shell command).
317
- self.enable_skills = bool(getattr(args, "enable_skills", False))
318
- if self.enable_skills:
319
- self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
320
- self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
321
- self.tools_dict.update(self.skills_tools_dict)
319
+ self.plan_mode = bool(getattr(self.args, "plan", False))
322
320
 
323
321
  # Token accounting for the whole session.
324
322
  self.token_stats = {
@@ -329,78 +327,140 @@ class AgentSession:
329
327
  "tool_calls": 0,
330
328
  }
331
329
 
330
+ self.background_mode = background_mode
331
+
332
+ self.restore_session = getattr(self.args, "restore_session", None) #specific session
333
+
334
+ def start_session(self):
335
+ """Loads existing session, detects existing session and asks or starts a new session."""
336
+ loading_success = False
337
+ if self.restore_session:
338
+ try:
339
+ restore_directory = _restore_dir(self.restore_session)
340
+ loading_success = self.restore_previous_session(restore_directory)
341
+ except Exception as e:
342
+ pass
343
+
344
+ else:
345
+ try:
346
+ detected = self._detect_last_session()
347
+ if detected and not self.background_mode:
348
+ answer = self.show_resume_choice()
349
+ if answer == "resume":
350
+ self.console.note("Resuming the session.", "warn")
351
+ restore_directory = _restore_dir(detected)
352
+ loading_success = self.restore_previous_session(restore_directory)
353
+ elif answer == "exit":
354
+ self.console.note("User chose to exit! Exiting.", "warn")
355
+ raise SystemExit("") # Exit gracefully instead of crashing
356
+ else:
357
+ self.console.note("Will start with new session!", "warn")
358
+
359
+ except Exception as e:
360
+ pass
361
+
362
+ if not loading_success:
363
+ self.start_new_session()
364
+
365
+ def start_new_session(self):
366
+ """A function to start a new session."""
332
367
  self.unique_id = generate_unique_id()
368
+ self.session_dir = os.path.join(self.cache_dir, self.unique_id)
333
369
 
334
- # Plan-mode tracking (see plan.py). Always instantiated so the loop can
335
- # call it unconditionally; every method no-ops when --plan is disabled.
336
- self.plan_mode = bool(getattr(self.args, "plan", False))
337
370
  self.plan_manager = plan_utils.PlanManager(
338
- os.path.join(self.cache_dir, self.unique_id), enabled=self.plan_mode)
339
-
340
- # High-level recorder + history segments. Instead of a single ever-growing
341
- # history.json, a session keeps:
342
- # * ``history_{n}.json`` files -- one per conversation *segment*; a brand
343
- # new segment is started (n -> n+1) every time the history is compacted.
344
- # * ``summary_history.json`` -- a flat, high-level *recorder*: one record
345
- # per turn (the running turn number, *what* happened -- an assistant
346
- # reply, a tool call, or a compaction summary -- and which
347
- # ``history_{n}.json`` + message index the turn lives in).
348
- # The recorder lets the user inspect and rewind the conversation (/history,
349
- # /reset) without reading the full detailed histories, because every turn's
350
- # recorded index marks a faithful end-of-turn snapshot of its segment.
371
+ self.session_dir, enabled=self.plan_mode)
372
+
351
373
  self.history_index = 0
352
374
  self.summary = []
353
375
 
354
- # report_path is set on a *sub-agent child* session (by
355
- # SubAgent._build_child_args) so it can exempt its own report.md -- which
356
- # lives under the parent's cache dir, usually outside --allowed-dir --
357
- # from the sandbox. A normal session leaves this None.
358
- self.report_path = getattr(args, "report_path", None)
376
+ self.messages = self._build_initial_messages()
359
377
 
360
- # Conversation state. When --restore-session is given we continue that
361
- # session's history instead of starting fresh -- still under the brand
362
- # new unique id above, so the original session file is never
363
- # overwritten. Falls back to a freshly built history if the restore
364
- # file is missing or unreadable.
365
- # --load-last-session auto-detects the most recent previous session in the
366
- # cache dir and continues it -- exactly like --restore-session, but without
367
- # needing to know the folder name. An explicit --restore-session always wins
368
- # and is used verbatim; otherwise the newest session is resolved here.
369
- restore_session = getattr(self.args, "restore_session", None)
370
- self.loaded_last_session_dir = None
371
- if getattr(self.args, "load_last_session", False) and not restore_session:
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
- )
381
- if detected:
382
- restore_session = detected
383
- self.loaded_last_session_dir = detected
384
- restore_directory = _restore_dir(restore_session)
385
- restored = load_history_from_dir(restore_directory) if restore_directory else None
386
- self.messages = restored if restored is not None else self._build_initial_messages()
387
- if restored is not None:
388
- self._restore_usage_if_available(restore_session)
389
- self._load_summary_if_restored(restore_directory)
390
- else:
391
- # --load-last-session asked for a session but nothing usable was found:
392
- # don't advertise a load. The recorder starts empty and the first
393
- # assistant / tool-call turn seeds turn 1 -- mirroring the restore
394
- # path, which also has no leading system turn, so /history turn
395
- # numbers stay consistent whether or not we restored.
396
- self.loaded_last_session_dir = None
397
-
398
- # Snapshot the initial (or restored) messages as the first history segment
399
- # and persist the recorder, so a session starts with on-disk state to
400
- # inspect and rewind to.
401
378
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
402
379
  save_summary(self.summary, self.cache_dir, self.unique_id)
403
380
 
381
+
382
+ def restore_previous_session(self, restore_directory):
383
+ """A function to restore from a directory.
384
+
385
+ Order matters here. ``self.messages`` must be loaded *before* the
386
+ recorder is restored, because the legacy fallback in
387
+ :meth:`_load_summary_if_restored` rebuilds the recorder by scanning the
388
+ restored messages. ``self.summary`` / ``self.history_index`` are seeded
389
+ first so that fallback can append to them and so ``_persist()`` has a
390
+ segment index to write to. Previously all three were unset on this path,
391
+ so restoring a session folder that had a ``history_N.json`` but no
392
+ ``summary_history.json`` (older code, or a hand-made folder) raised
393
+ AttributeError and the caller silently started a *new* session instead.
394
+ """
395
+ try:
396
+ self.unique_id = os.path.basename(os.path.normpath(restore_directory))
397
+ self.session_dir = restore_directory
398
+ self.plan_manager = plan_utils.PlanManager(
399
+ self.session_dir, enabled=self.plan_mode)
400
+ self.summary = []
401
+ self.history_index = 0
402
+ self._restore_usage_if_available(restore_directory)
403
+ restored = load_history_from_dir(restore_directory) if restore_directory else None
404
+
405
+ self.messages = restored if restored is not None else self._build_initial_messages()
406
+ self._load_summary_if_restored(restore_directory)
407
+
408
+ return True
409
+
410
+ except Exception as e:
411
+ self.unique_id = None
412
+ self.session_dir = None
413
+ self.plan_manager = None
414
+ return False
415
+
416
+
417
+ def _detect_last_session(self):
418
+ """Resolve the most recent previous session dir, once.
419
+
420
+ There is deliberately NO timestamp/mtime fallback over the cache dir: a
421
+ previous session is restored only via the ``.mini`` pointer (an explicit
422
+ record of the session the user last worked in). Returns the path, or None.
423
+ Called at most once per session (see __init__).
424
+ """
425
+ return find_last_session_dir(self.allowed_dir)
426
+
427
+ def _register_tools(self, args):
428
+ """Wire up which tools the LLM may call, and register the opt-in ones.
429
+
430
+ Base tools depend on --enable-shell (the shell tool is withheld unless
431
+ shell is explicitly on). Then two opt-in features each add their own
432
+ sandbox-safe tools:
433
+
434
+ * Sub-agents (--enable-subagents): every registered sub-agent becomes
435
+ an extra tool. Its tools_dict entry has ``function=None`` so
436
+ _run_one_tool_call knows to *delegate* to a nested session rather
437
+ than call a real function. A child session never sets
438
+ enable_subagents, so sub-agents cannot recursively spawn sub-agents.
439
+ * Skills (--skills): the .mini directory (inside --allowed-dir) exposes
440
+ list/read/create/run_skill tools. They are sandbox-safe -- names are
441
+ validated as single path segments and resolved under .mini, so they
442
+ never accept an arbitrary path from the model and are usable even
443
+ without --enable-shell.
444
+ """
445
+ forbidden = None if args.enable_shell else tools.shell_tools
446
+ self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
447
+ self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
448
+
449
+ self.enable_subagents = bool(getattr(args, "enable_subagents", False))
450
+ self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
451
+ for sa in self.subagents_registry.values():
452
+ self.llm_tools.append(sa.tool_schema())
453
+ self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
454
+
455
+ self.enable_skills = bool(getattr(args, "enable_skills", False))
456
+ if self.enable_skills:
457
+ # list/create/run_skill + the scoped read/write_skill_file tools.
458
+ self.tools_dict.update(skills.get_skills_tools_dict(self.allowed_dir))
459
+ self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
460
+ self.tools_dict.update(skills.get_skills_file_tools_dict(self.allowed_dir))
461
+ self.llm_tools.extend(skills.get_skills_file_tools(self.allowed_dir))
462
+
463
+
404
464
  def _build_initial_messages(self):
405
465
  """Assemble the system prompt and optional Agent.md into the first turns."""
406
466
  if self.args.system_prompt:
@@ -410,6 +470,7 @@ class AgentSession:
410
470
  tools_dict=self.tools_dict, allowed_dir=self.allowed_dir,
411
471
  plan_mode=self.plan_mode,
412
472
  skills_dir=self.allowed_dir if self.enable_skills else None,
473
+ session_dir=self.session_dir,
413
474
  )
414
475
 
415
476
  messages = [{"role": "system", "content": system_prompt}]
@@ -427,7 +488,7 @@ class AgentSession:
427
488
 
428
489
  return messages
429
490
 
430
- def _restore_usage_if_available(self, restore_session):
491
+ def _restore_usage_if_available(self, restore_directory):
431
492
  """Seed this session's token totals from a sibling usage.json, if the
432
493
  restored session had one, so the running summary stays continuous.
433
494
 
@@ -435,15 +496,12 @@ class AgentSession:
435
496
  of) the in-memory totals, and any problem here is ignored silently so a
436
497
  missing or malformed usage file can never break a restore.
437
498
  """
438
- if not restore_session:
439
- return
440
- directory = _restore_dir(restore_session)
441
- saved = load_usage_from_dir(directory)
499
+ saved = load_usage_from_dir(restore_directory)
442
500
  for key in self.token_stats:
443
501
  if key in saved:
444
502
  self.token_stats[key] += int(saved[key] or 0)
445
503
 
446
- def _load_summary_if_restored(self, directory):
504
+ def _load_summary_if_restored(self, restore_directory):
447
505
  """Restore the high-level recorder and segment index for a restore.
448
506
 
449
507
  Prefers the saved ``summary_history.json`` so the recorder stays flat and
@@ -453,7 +511,8 @@ class AgentSession:
453
511
  rebuild a minimal recorder by scanning the restored messages for
454
512
  assistant turns, so navigation still works. Best-effort only.
455
513
  """
456
- saved = load_summary_from_dir(directory)
514
+ saved = load_summary_from_dir(restore_directory)
515
+
457
516
  saved = [r for r in saved if isinstance(r, dict)]
458
517
  if saved:
459
518
  self.summary = saved
@@ -584,50 +643,59 @@ class AgentSession:
584
643
  f"Sub-agents enabled ({len(self.subagents_registry)} available: "
585
644
  f"{', '.join(self.subagents_registry)}).", "info")
586
645
 
646
+ def show_resume_choice(self):
647
+ """Prompt for the resume choice and return its label.
648
+
649
+ Returns one of ``"resume"``, ``"new"`` or ``"exit"``. The prompt
650
+ degrades cleanly on a non-TTY / piped console: an EOF / Ctrl-C (``read_line``
651
+ returns None) is treated as ``"exit"`` so the loop never spins on unread input.
652
+ """
653
+ self.console.emit(
654
+ self.console.style(
655
+ "A previous session was found. What would you like to do?", dim=True,
656
+ fg="cyan"))
657
+ self.console.emit(" 1) Resume the previous session (continue where you left off)")
658
+ self.console.emit(" 2) Start a new session (empty history)")
659
+ self.console.emit(" 3) Exit (start nothing)")
660
+ while True:
661
+ answer = self.console.read_line(
662
+ self.console.style("Resume previous session? Type 1, 2, or 3: ", fg="cyan"))
663
+ if answer is None:
664
+ # EOF / Ctrl-C at the offer -> graceful exit, no session written.
665
+ return "exit"
666
+ choice = answer.strip().lower()
667
+ if choice in ("1", "resume"):
668
+ return "resume"
669
+ if choice in ("2", "new"):
670
+ return "new"
671
+ if choice in ("3", "exit"):
672
+ return "exit"
673
+ self.console.note("Please choose 1, 2, or 3.", "warn")
674
+
587
675
  def run(self, initial_prompt=None):
588
676
  """Drive the agent loop until the user quits."""
589
- self.print_announcements()
590
- print_welcome(self.allowed_dir, self.server_url, self.args,
591
- self.unique_id, self.cache_dir, self.model)
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
-
604
- # Report the outcome of --load-last-session up front, so the user sees
605
- # whether we resumed an earlier conversation (under a brand-new id) or
606
- # started fresh because no previous session was found.
607
- if self.loaded_last_session_dir is not None:
608
- self.console.note(
609
- f"Loaded most recent session "
610
- f"({os.path.basename(self.loaded_last_session_dir)}) and continuing "
611
- f"it under a new session id.", "ok")
612
- elif getattr(self.args, "load_last_session", False):
613
- self.console.note(
614
- "No previous session found to load; starting a fresh session.", "warn")
677
+ # Called in the beginning to init the session.
678
+ self.start_session()
679
+
615
680
 
616
681
  # Hint at startup so the high-level history feature is discoverable:
617
682
  # /history browses the recorder, /history <n> inspects a turn, and
618
683
  # /reset <n> rewinds the conversation to that turn's state.
619
- self.console.emit(
620
- self.console.style(
621
- "Tip: /history to browse the conversation, /history <n> to inspect a "
622
- "turn, /reset <n> to rewind. Type /help for all commands.",
623
- dim=True, fg="cyan"))
684
+ tip = ("Tip: /history to browse the conversation, /history <n> to inspect a "
685
+ "turn, /reset <n> to rewind. Type /help for all commands.")
686
+ if getattr(self.args, "plan", False):
687
+ tip = ("Tip: /plan to show the current goal + plan (plan mode), "
688
+ "/history to browse the conversation, /history <n> to inspect a "
689
+ "turn, /reset <n> to rewind. Type /help for all commands.")
690
+ self.console.emit(self.console.style(tip, dim=True, fg="cyan"))
624
691
 
625
692
  # The --prompt value is a one-shot initial turn: it is sent verbatim and
626
693
  # then cleared, so later turns come from the user (manual mode) or are
627
694
  # produced automatically by the loop (auto mode).
628
695
  pending = initial_prompt
629
696
 
630
- while True:
697
+ running = True
698
+ while running:
631
699
  try:
632
700
  # 1) Get the user's turn: the one-shot preset, or a fresh line.
633
701
  if pending is not None:
@@ -638,8 +706,9 @@ class AgentSession:
638
706
  pending = None
639
707
  else:
640
708
  user_input = self._read_user_line()
641
- if user_input is None: # quit / exit
642
- self._finish_goal_and_shutdown()
709
+ if user_input is None or user_input.lower() in self.EXIT_COMMANDS: # EOF / Ctrl-C at the prompt
710
+ self.console.note("Thank you. Goodbye! Saving history...", "info")
711
+ running = False
643
712
  break
644
713
 
645
714
  # Interactive slash commands are handled in place and never
@@ -647,28 +716,60 @@ class AgentSession:
647
716
  # was such a command, so we just re-prompt (continue).
648
717
  if user_input.startswith("/") and self._handle_command(user_input):
649
718
  continue
650
-
651
- # In plan mode, remember the user's latest instruction as the
652
- # active goal so the loop (and plan-mode compaction) can track it
653
- # even if the agent never writes it to disk itself.
654
- self._capture_goal(user_input)
655
-
656
- # 2) Hand the user turn to the model and act on its tool calls.
719
+
657
720
  self.messages.append({"role": "user", "content": user_input})
658
- while True:
721
+
722
+ if self.plan_mode:
723
+ self.console.note(
724
+ "Generating plan for agent.", "info")
725
+ self._record_goal_request(user_input)
726
+ self._generate_plan_and_inject(user_input)
727
+ self.console.note(
728
+ "Plan generated.", "info")
729
+ running_turn = True
730
+ while running_turn:
731
+ self.console.note(
732
+ "Entering single turn loop.", "info")
733
+ # actually stepping through the agentic steps.
659
734
  should_continue = self._step()
660
735
  self._persist()
661
- if not should_continue:
662
- break
663
- self.console.note("Continuing with LLM calls until no more tool calls.", "info")
736
+ if should_continue:
737
+ self.console.note(
738
+ "Continuing with LLM calls until no more tool calls.", "info")
739
+ else:
740
+ self.console.note(
741
+ "LLM seems to have to an end of the task.", "info")
742
+
743
+ if self.plan_mode:
744
+ self.console.note(
745
+ "Reviewing the plan, if everything is done.", "info")
746
+ self._review_plan_completion()
747
+ should_continue = self._step()
748
+ self._persist()
749
+
750
+ # A response with no tool calls means the turn is over. This must
751
+ # happen regardless of plan mode: previously the assignment sat inside
752
+ # `if self.plan_mode`, so non-plan sessions never left the turn loop and
753
+ # re-called the model forever.
754
+ if not should_continue:
755
+ running_turn = False # escape the turn loop
756
+
664
757
  except _Abort:
665
758
  # Ctrl-C during a permission prompt already shut the session
666
759
  # down; just unwind the loop quietly.
667
- break
760
+ running = False
761
+
668
762
  except KeyboardInterrupt:
669
763
  self.console.note("Interrupting: Mini Code Exiting...", "error")
764
+ self.shutdown()
765
+ running = False
670
766
  # Ctrl-C mid-loop just saves and re-prompts rather than crashing.
671
- self._persist()
767
+ except Exception as e:
768
+ self.console.note(f"Exception {e}. Exiting...", "error")
769
+ self.shutdown()
770
+ running = False
771
+ self.console.note(
772
+ "EOF.", "info")
672
773
 
673
774
  def _print_session_id(self):
674
775
  """Print the session's unique id as a minimal "Assistant" turn.
@@ -680,15 +781,15 @@ class AgentSession:
680
781
  """
681
782
  self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
682
783
 
683
- def _save_last_session_pointer(self):
684
- """Record this session's directory in --allowed-dir/.mini.
784
+ # def _save_last_session_pointer(self):
785
+ # """Record this session's directory in --allowed-dir/.mini.
685
786
 
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))
787
+ # Writes the .mini pointer that a later --load-last-session consults first
788
+ # (see save_last_session_pointer). Best-effort: never raises, so a pointer
789
+ # hiccup can never break the interactive session.
790
+ # """
791
+ # save_last_session_pointer(self.allowed_dir,
792
+ # os.path.join(self.cache_dir, self.unique_id))
692
793
 
693
794
  # -- autonomous drive (used by sub-agents) ------------------------------
694
795
 
@@ -741,49 +842,123 @@ class AgentSession:
741
842
 
742
843
  # -- plan mode ---------------------------------------------------------
743
844
 
744
- def _capture_goal(self, user_input):
745
- """Seed CURRENT_GOAL.md with the user's latest instruction (plan mode).
746
-
747
- The loop owns the goal (the user's last instruction); the agent owns the
748
- plan (CURRENT_PLAN.md). Overwriting on every turn keeps the goal current.
749
- Slash commands and the empty input are not real instructions, so they are
750
- ignored. No-op when --plan is off.
845
+ def _record_goal_request(self, user_input):
846
+ """Record the user's latest request in the plan-mode tracking files.
751
847
  """
752
- if not self.plan_mode or user_input is None:
753
- return
754
848
  stripped = user_input.strip()
755
849
  if not stripped or stripped.startswith("/"):
756
850
  return
757
- self.plan_manager.write_goal(user_input)
851
+ self.plan_manager.write_goal(stripped)
852
+
853
+ def _generate_plan_and_inject(self, current_instruction):
854
+ """Ask the model to author a plan for the current instruction.
855
+ """
856
+ plan_prompt_text = prompts.plan_prompt(self.messages, current_instruction)
857
+ plan_text = ""
858
+ try:
859
+ ui.console.note("Generating a plan for the current goal...", "info")
860
+ self.console.start_thinking("planning")
861
+ response, _, usage = utils.call_openai_server(
862
+ [{"role": "user", "content": plan_prompt_text}],
863
+ # The generated plan can be long: leave it unlimited (the server
864
+ # decides the cap) rather than capping the step-by-step authoring.
865
+ max_tokens=None,
866
+ temperature=0.0,
867
+ model=self.model,
868
+ server_url=self.server_url,
869
+ api_key=self.api_key,
870
+ timeout=self.args.llm_timeout,
871
+ )
872
+ self.console.stop_thinking()
873
+
874
+ accumulate_usage(self.token_stats, usage)
875
+ plan_text = response or ""
876
+ except Exception as exc:
877
+ ui.console.note("Could not generate a plan: %s" % exc, "warn")
878
+
879
+ self.plan_manager.write_plan(plan_text)
880
+ if plan_text and plan_text.strip():
881
+ self.messages.append({
882
+ "role": "user",
883
+ "content": "Current plan for the goal (from CURRENT_PLAN.md):\n%s" % plan_text.strip(),
884
+ })
758
885
 
759
886
  def _finish_goal_and_shutdown(self):
760
- """Mark the active goal finished in CURRENT_PLAN.md, then quit/save."""
761
- self._mark_current_goal_finished()
887
+ """Mark the active goal finished in CURRENT_PLAN.md, then quit/save.
888
+
889
+ The goal is whatever the loop last recorded; if the loop never recorded
890
+ one we fall back to reading the goal file back so a one-shot --prompt
891
+ preset still marks its goal finished. A no-op in method behaviour when
892
+ --plan is off (PlanManager no-ops), so it is safe to call unconditionally.
893
+ """
894
+ self.plan_manager.mark_finished(self.plan_manager.goal)
762
895
  self.shutdown()
763
896
 
764
- def _mark_current_goal_finished(self):
765
- """Append a \"goal finished\" entry to the plan document (plan mode).
897
+ def _review_plan_completion(self):
898
+ """Once the work looks done, review the plan's completion.
899
+ """
900
+ plan_prompt_text = prompts.plan_review_prompt(
901
+ self.messages, self.plan_manager.goal or "")
902
+ summary = ""
903
+ try:
904
+ ui.console.note("Reviewing the current plan...", "info")
905
+ self.console.start_thinking("reviewing plan")
906
+ response, _, usage = utils.call_openai_server(
907
+ [{"role": "user", "content": plan_prompt_text}],
908
+ max_tokens=1024,
909
+ temperature=0.0,
910
+ model=self.model,
911
+ server_url=self.server_url,
912
+ api_key=self.api_key,
913
+ timeout=self.args.llm_timeout,
914
+ )
915
+ self.console.stop_thinking()
916
+ accumulate_usage(self.token_stats, usage)
917
+ summary = response or ""
918
+ except Exception as exc:
919
+ ui.console.note("Could not review the plan: %s" % exc, "warn")
920
+
921
+ # Persist the review summary to the plan document and re-inject it so
922
+ # the model works from the review result.
923
+ self.plan_manager.write_plan_summary(summary)
924
+ if summary and summary.strip():
925
+ self.messages.append({
926
+ "role": "user",
927
+ "content": "Plan completion summary (from CURRENT_PLAN.md):\n%s"
928
+ % summary.strip(),
929
+ })
930
+
931
+ return True
932
+
933
+
934
+ def _handle_compaction(self, compacted):
935
+ """Re-inject goal + plan into the compacted messages (plan mode).
766
936
 
767
- Uses the goal the loop last recorded (self.plan_manager.goal); a no-op
768
- when --plan is off, so it is safe to call unconditionally.
937
+ Thin plan-mode counterpart to the inlined plan handling inside
938
+ :meth:`maybe_compact`, which takes a ``plan_hook`` rather than the
939
+ owning session so it can stay a network-free module-level helper. The
940
+ loop wires in this method only when plan mode is on; it simply
941
+ re-injects the goal + plan via :meth:`_apply_plan_to_compacted`, which
942
+ also rewrites the tracking files to match.
769
943
  """
770
- self.plan_manager.mark_finished(self.plan_manager.goal)
944
+ self.messages = self._apply_plan_to_compacted(compacted)
771
945
 
772
946
  def _apply_plan_to_compacted(self, messages):
773
- """Re-inject the running plan + current goal after a history compaction.
947
+ """Re-inject the current goal + plan after a history compaction.
774
948
 
775
949
  Compaction replaces the transcript with a summary, which would otherwise
776
- drop the agent's plan and the goal the loop seeded. We read them back off
777
- disk and append them as fresh user turns so the model keeps working
778
- against the goal it is still trying to finish. The goal is appended last
779
- so it reads as the most recent instruction the model should act on.
950
+ drop the goal the loop recorded and the plan it maintains. We read them
951
+ back off disk and append them as fresh user turns so the model keeps
952
+ working against the goal it is still trying to finish. The goal is
953
+ appended last so it reads as the most recent instruction the model
954
+ should act on.
780
955
  """
781
956
  plan_text = self.plan_manager.read_plan()
782
957
  goal_text = self.plan_manager.read_goal()
783
958
 
784
959
  # Recover the user's latest instruction from the pre-compaction tail, in
785
- # case the loop had not seeded a goal file yet (e.g. the agent finished
786
- # everything before ever writing CURRENT_GOAL.md).
960
+ # case the loop had not recorded a goal yet, so there is always a goal to
961
+ # restate after compaction.
787
962
  current_input = None
788
963
  for m in reversed(messages):
789
964
  if m.get("role") == "user":
@@ -804,29 +979,6 @@ class AgentSession:
804
979
  rebuilt.append({"role": "user", "content": current_input})
805
980
  return rebuilt
806
981
 
807
- def _is_plan_file(self, name, tool_params):
808
- """Whether a read/write targets a plan-mode file in the session dir.
809
-
810
- Lets the agent maintain CURRENT_PLAN.md / CURRENT_GOAL.md even though the
811
- session directory is usually outside --allowed-dir. Scoped tightly: plan
812
- mode only, only read_file / write_file, only when the resolved path is a
813
- plan file living directly in this session's directory.
814
- """
815
- if not self.plan_mode or name not in ("read_file", "write_file"):
816
- return False
817
- path = tool_params.get("file_path", "")
818
- if not path:
819
- return False
820
- try:
821
- resolved = os.path.realpath(path)
822
- except (ValueError, TypeError):
823
- return False
824
- session = os.path.realpath(os.path.join(self.cache_dir, self.unique_id))
825
- if os.path.dirname(resolved) != session:
826
- return False
827
- return os.path.basename(resolved) in (
828
- plan_utils.PLAN_FILENAME, plan_utils.GOAL_FILENAME)
829
-
830
982
  def _is_report_file(self, name, tool_params):
831
983
  """Whether a write targets this session's own sub-agent report.md.
832
984
 
@@ -855,20 +1007,17 @@ class AgentSession:
855
1007
  # -- interactive input -------------------------------------------------
856
1008
 
857
1009
  def _read_user_line(self):
858
- """Read one turn from the user. Returns the text to send, or None to quit."""
1010
+ """Read one turn from the user. Returns the text to send, or None to quit.
1011
+
1012
+ An empty (blank) line is not a real instruction, so it is ignored and the
1013
+ user is asked again rather than sending an empty turn to the model.
1014
+ """
859
1015
  self.console.short_divider()
860
- user_input = self.console.read_line(style_line_prompt("You:"))
861
- if user_input is None: # EOF / Ctrl-C at the prompt
862
- self.console.note("Thank you. Goodbye! Saving history...", "info")
863
- return None
864
1016
 
865
- if user_input.lower() in self.EXIT_COMMANDS:
866
- return None
1017
+ user_input = self.console.read_line(style_line_prompt("You:"))
867
1018
 
868
- if user_input == "/help":
869
- print_help(self.allowed_dir, self.server_url, self.args,
870
- self.unique_id, self.cache_dir, self.model)
871
- # Nothing to send to the model; ask again.
1019
+ if not user_input.strip():
1020
+ self.console.note("No input. (Type /help for commands, /quit to exit.)", "warn")
872
1021
  return self._read_user_line()
873
1022
 
874
1023
  return user_input
@@ -882,6 +1031,8 @@ class AgentSession:
882
1031
  /history <n> show the full messages recorded for turn <n>
883
1032
  /reset [<n>] rewind the conversation to the end of turn <n>
884
1033
  (no <n> rewinds to the most recent turn)
1034
+ /plan show the current goal + plan the loop maintains
1035
+ (plan mode only)
885
1036
  /clear clear the screen (keeps the conversation)
886
1037
  /help show the command help
887
1038
 
@@ -908,6 +1059,9 @@ class AgentSession:
908
1059
  if cmd in self.CLEAR_COMMANDS:
909
1060
  self._clear_and_show_last()
910
1061
  return True
1062
+ if cmd == "plan":
1063
+ self._show_plan()
1064
+ return True
911
1065
  self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
912
1066
  return True
913
1067
 
@@ -934,6 +1088,33 @@ class AgentSession:
934
1088
  return
935
1089
  self._reset_history(target["turn"], target)
936
1090
 
1091
+ def _show_plan(self):
1092
+ """Show the current plan/goal the loop maintains (``/plan`` command).
1093
+
1094
+ Plan mode keeps two human-readable tracking files in the session
1095
+ directory (CURRENT_GOAL.md / CURRENT_PLAN.md) that the loop -- not the
1096
+ agent -- author. Those files live outside ``--allowed-dir``, so the
1097
+ model has no tool to read them back; the simplest way for the user to
1098
+ inspect progress is this command, which renders the goal, the current
1099
+ plan and the last completion review as one box, matching how
1100
+ ``/history`` shows the transcript. A no-op (just a note) when --plan is
1101
+ off, since the manager has nothing to read.
1102
+ """
1103
+ if not self.plan_mode:
1104
+ self.console.note("Plan mode is not enabled (--plan).", "warn")
1105
+ return
1106
+ status = self.plan_manager.read_current_status()
1107
+ if status is None:
1108
+ self.console.note("No plan information available for this session.", "warn")
1109
+ return
1110
+ self.console.rule(title="Plan", char="═", fg="cyan")
1111
+ self.console.status_box(
1112
+ status.splitlines(),
1113
+ title="Current goal + plan",
1114
+ fg="green",
1115
+ dim=True,
1116
+ )
1117
+
937
1118
  def _clear_and_show_last(self):
938
1119
  """Handle /clear: wipe the screen and re-print the last turn.
939
1120
 
@@ -964,7 +1145,7 @@ class AgentSession:
964
1145
  # Show a "… calling tool" spinner (in-place, non-scrolling) while the
965
1146
  # tool(s) execute, mirroring the thinking spinner that covers the LLM
966
1147
  # call. Always stopped (finally) so it can never leak into the next turn.
967
- self.console.start_tool_call()
1148
+
968
1149
  try:
969
1150
  for call in tool_calls:
970
1151
  self._run_one_tool_call(call)
@@ -972,9 +1153,11 @@ class AgentSession:
972
1153
  self.console.stop_tool_call()
973
1154
 
974
1155
  def _run_one_tool_call(self, call):
1156
+
975
1157
  call_id = call.get("id", "")
976
1158
  name = call.get("function", {}).get("name", "")
977
1159
  params_str = call.get("function", {}).get("arguments", "{}")
1160
+ self.console.start_tool_call(tool_name=name)
978
1161
 
979
1162
  try:
980
1163
  tool_params = json.loads(params_str)
@@ -997,14 +1180,11 @@ class AgentSession:
997
1180
  f"Error: Unknown tool: {name}, params {params_str}")
998
1181
  return
999
1182
 
1000
- # 1) Sandbox: keep every filesystem tool inside --allowed-dir (plan mode
1001
- # additionally lets the agent maintain CURRENT_PLAN.md /
1002
- # CURRENT_GOAL.md in the session directory, which normally sits
1003
- # outside --allowed-dir, so those writes are exempted).
1183
+ # 1) Sandbox: keep every filesystem tool inside --allowed-dir. (In plan
1184
+ # mode the tracking files are maintained by the loop directly -- not
1185
+ # through agent tools -- so nothing here needs an exemption.)
1004
1186
  if not tool_paths_are_allowed(name, tool_params, self.allowed_dir):
1005
- if self._is_plan_file(name, tool_params):
1006
- pass # allow the plan/goal files into the session directory
1007
- elif self._is_report_file(name, tool_params):
1187
+ if self._is_report_file(name, tool_params):
1008
1188
  pass # allow a sub-agent's own report.md under the cache dir
1009
1189
  else:
1010
1190
  self.console.note(
@@ -1109,14 +1289,13 @@ class AgentSession:
1109
1289
  self.messages, did_compact, new_index = maybe_compact(
1110
1290
  self.messages, self.server_url, self.model, self.api_key,
1111
1291
  self.args.llm_timeout, self.token_stats, self.history_index,
1292
+ # getattr keeps argparse-like namespaces without the attribute
1293
+ # (older callers / tests) working, falling back to the default.
1294
+ max_messages=getattr(self.args, "max_messages", DEFAULT_MAX_MESSAGES),
1295
+ plan_hook=self._handle_compaction if self.plan_mode else None, console=self.console
1112
1296
  )
1113
1297
  if did_compact:
1114
1298
  self.history_index = new_index
1115
- # In plan mode, re-inject the running plan + current goal so a
1116
- # compaction (which would otherwise drop them) keeps the model
1117
- # working against the goal it is still trying to finish.
1118
- if self.plan_mode:
1119
- self.messages = self._apply_plan_to_compacted(self.messages)
1120
1299
  # Snapshot the compacted messages into the new segment so its turns
1121
1300
  # are preserved, and record the summary as the first turn of it.
1122
1301
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
@@ -1155,6 +1334,9 @@ class AgentSession:
1155
1334
  made_tool_calls = bool(tool_calls)
1156
1335
  if made_tool_calls:
1157
1336
  self.token_stats["tool_calls"] += len(tool_calls)
1337
+ # The model predicted more tool calls: once the work settles down a
1338
+ # completion review is due again (finalized_plan).
1339
+ self.finalized_plan = True
1158
1340
 
1159
1341
  if response_text:
1160
1342
  self._print_assistant(response_text)