mini-code-cli 0.1.0.3__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")
@@ -165,6 +166,7 @@ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
165
166
  ui.console.emit(ui.console.style("History / navigation commands:", bold=True, fg="cyan"))
166
167
  ui.console.emit(" /history list the conversation (turn number, what happened, where)")
167
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)")
168
170
  ui.console.emit(" /reset [<n>] rewind to the end of turn n (drops everything after it)")
169
171
  ui.console.emit(" /clear clear the screen (keeps the conversation history)")
170
172
  ui.console.emit(" /help show this help")
@@ -205,7 +207,7 @@ def _make_tool_call_note(console, count):
205
207
  # history compaction
206
208
  # ---------------------------------------------------------------------------
207
209
 
208
- 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):
209
211
  """Compact the conversation history once it grows beyond ``max_messages``.
210
212
 
211
213
  Long (especially auto-mode) sessions can overflow the model's context window.
@@ -213,6 +215,13 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
213
215
  old turns with that summary, keeping the original system prompt as the first
214
216
  message. The compaction call itself is a single, tool-free request, so it
215
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.
216
225
  """
217
226
  if len(messages) <= max_messages:
218
227
  # No compaction: keep the same segment (history_index) and signal the
@@ -224,6 +233,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
224
233
  try:
225
234
  ui.console.note(f"Compacting conversation history ({len(messages)} messages -> summary)...", "warn")
226
235
  summary_prompt = prompts.compaction_prompt(messages)
236
+ console.start_thinking("compacting")
227
237
  summary, _, compact_usage = utils.call_openai_server(
228
238
  [{"role": "user", "content": summary_prompt}],
229
239
  max_tokens=1024,
@@ -233,6 +243,8 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
233
243
  api_key=api_key,
234
244
  timeout=llm_timeout,
235
245
  )
246
+ console.stop_thinking()
247
+
236
248
  accumulate_usage(token_stats, compact_usage)
237
249
  if summary:
238
250
  system_content = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
@@ -243,6 +255,15 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
243
255
  "role": "user",
244
256
  "content": f"Here is a summary of our earlier conversation so far:\n{summary}",
245
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
+
246
267
  ui.console.note(f"History compacted to {len(compacted)} messages.", "warn")
247
268
  # A new segment (history_index + 1) is started on compaction: the
248
269
  # compacted messages are its snapshot, and every later turn appends
@@ -268,33 +289,35 @@ class AgentSession:
268
289
  be unit-tested.
269
290
  """
270
291
 
271
- EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit"}
292
+ EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit", "exit()", "quit()"}
272
293
  CLEAR_COMMANDS = {"clear", "/clear"}
273
294
 
274
- def __init__(self, args, console=None):
295
+ def __init__(self, args, console=None, background_mode = False):
275
296
  self.args = args
276
297
  self.allowed_dir = os.path.abspath(args.allowed_dir)
277
298
  self.cache_dir = args.cache_dir if args.cache_dir else DEFAULT_CACHE_LOCATION
278
299
 
279
- # The console that renders everything the agent prints. Unit tests /
280
- # other callers can swap it out (e.g. AgentSession(args, console=...) with
281
- # a color=False console) but it defaults to the shared ui.console.
282
300
  self.console = console if console is not None else ui.console
283
- # --verbose toggles the amount of detail printed. Defaults to False so
284
- # the quiet, transcript-only view is the out-of-the-box experience.
285
301
  self.console.verbose = getattr(args, "verbose", False)
286
302
 
287
303
  # LLM / server configuration.
288
304
  self.server_url = f"{args.url}"
289
- 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
290
310
  self.model = model_name if model_name else args.model
291
311
  self.api_key = args.api_key if args.api_key else os.environ.get("MINI_CODE_API_KEY", "")
292
-
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().
312
+
296
313
  self._register_tools(args)
297
314
 
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)
318
+
319
+ self.plan_mode = bool(getattr(self.args, "plan", False))
320
+
298
321
  # Token accounting for the whole session.
299
322
  self.token_stats = {
300
323
  "prompt_tokens": 0,
@@ -304,107 +327,102 @@ class AgentSession:
304
327
  "tool_calls": 0,
305
328
  }
306
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."""
307
367
  self.unique_id = generate_unique_id()
368
+ self.session_dir = os.path.join(self.cache_dir, self.unique_id)
308
369
 
309
- # Plan-mode tracking (see plan.py). Always instantiated so the loop can
310
- # call it unconditionally; every method no-ops when --plan is disabled.
311
- self.plan_mode = bool(getattr(self.args, "plan", False))
312
370
  self.plan_manager = plan_utils.PlanManager(
313
- os.path.join(self.cache_dir, self.unique_id), enabled=self.plan_mode)
314
-
315
- # High-level recorder + history segments. Instead of a single ever-growing
316
- # history.json, a session keeps:
317
- # * ``history_{n}.json`` files -- one per conversation *segment*; a brand
318
- # new segment is started (n -> n+1) every time the history is compacted.
319
- # * ``summary_history.json`` -- a flat, high-level *recorder*: one record
320
- # per turn (the running turn number, *what* happened -- an assistant
321
- # reply, a tool call, or a compaction summary -- and which
322
- # ``history_{n}.json`` + message index the turn lives in).
323
- # The recorder lets the user inspect and rewind the conversation (/history,
324
- # /reset) without reading the full detailed histories, because every turn's
325
- # recorded index marks a faithful end-of-turn snapshot of its segment.
371
+ self.session_dir, enabled=self.plan_mode)
372
+
326
373
  self.history_index = 0
327
374
  self.summary = []
328
375
 
329
- # report_path is set on a *sub-agent child* session (by
330
- # SubAgent._build_child_args) so it can exempt its own report.md -- which
331
- # lives under the parent's cache dir, usually outside --allowed-dir --
332
- # from the sandbox. A normal session leaves this None.
333
- self.report_path = getattr(args, "report_path", None)
376
+ self.messages = self._build_initial_messages()
334
377
 
335
- # Conversation state. When --restore-session is given we continue that
336
- # session's history instead of starting fresh -- still under the brand
337
- # new unique id above, so the original session file is never
338
- # overwritten. Falls back to a freshly built history if the restore
339
- # file is missing or unreadable.
340
- # --load-last-session auto-detects the most recent previous session in the
341
- # cache dir and continues it -- exactly like --restore-session, but without
342
- # needing to know the folder name. An explicit --restore-session always wins
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).
349
- restore_session = getattr(self.args, "restore_session", None)
350
- self.last_session_candidate = None
351
- self.loaded_last_session_dir = None
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
370
- restore_directory = _restore_dir(restore_session)
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.
375
- self.messages = restored if restored is not None else self._build_initial_messages()
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:
385
- self.loaded_last_session_dir = None
386
-
387
- # Snapshot the initial (or restored) messages as the first history segment
388
- # and persist the recorder, so a session starts with on-disk state to
389
- # inspect and rewind to.
390
378
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
391
379
  save_summary(self.summary, self.cache_dir, self.unique_id)
392
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
+
393
417
  def _detect_last_session(self):
394
418
  """Resolve the most recent previous session dir, once.
395
419
 
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.
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__).
402
424
  """
403
- return find_last_session_dir(
404
- self.cache_dir,
405
- allowed_dir=self.allowed_dir,
406
- use_pointer=True,
407
- )
425
+ return find_last_session_dir(self.allowed_dir)
408
426
 
409
427
  def _register_tools(self, args):
410
428
  """Wire up which tools the LLM may call, and register the opt-in ones.
@@ -436,9 +454,12 @@ class AgentSession:
436
454
 
437
455
  self.enable_skills = bool(getattr(args, "enable_skills", False))
438
456
  if self.enable_skills:
439
- self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
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))
440
459
  self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
441
- self.tools_dict.update(self.skills_tools_dict)
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
+
442
463
 
443
464
  def _build_initial_messages(self):
444
465
  """Assemble the system prompt and optional Agent.md into the first turns."""
@@ -449,6 +470,7 @@ class AgentSession:
449
470
  tools_dict=self.tools_dict, allowed_dir=self.allowed_dir,
450
471
  plan_mode=self.plan_mode,
451
472
  skills_dir=self.allowed_dir if self.enable_skills else None,
473
+ session_dir=self.session_dir,
452
474
  )
453
475
 
454
476
  messages = [{"role": "system", "content": system_prompt}]
@@ -466,7 +488,7 @@ class AgentSession:
466
488
 
467
489
  return messages
468
490
 
469
- def _restore_usage_if_available(self, restore_session):
491
+ def _restore_usage_if_available(self, restore_directory):
470
492
  """Seed this session's token totals from a sibling usage.json, if the
471
493
  restored session had one, so the running summary stays continuous.
472
494
 
@@ -474,15 +496,12 @@ class AgentSession:
474
496
  of) the in-memory totals, and any problem here is ignored silently so a
475
497
  missing or malformed usage file can never break a restore.
476
498
  """
477
- if not restore_session:
478
- return
479
- directory = _restore_dir(restore_session)
480
- saved = load_usage_from_dir(directory)
499
+ saved = load_usage_from_dir(restore_directory)
481
500
  for key in self.token_stats:
482
501
  if key in saved:
483
502
  self.token_stats[key] += int(saved[key] or 0)
484
503
 
485
- def _load_summary_if_restored(self, directory):
504
+ def _load_summary_if_restored(self, restore_directory):
486
505
  """Restore the high-level recorder and segment index for a restore.
487
506
 
488
507
  Prefers the saved ``summary_history.json`` so the recorder stays flat and
@@ -492,7 +511,8 @@ class AgentSession:
492
511
  rebuild a minimal recorder by scanning the restored messages for
493
512
  assistant turns, so navigation still works. Best-effort only.
494
513
  """
495
- saved = load_summary_from_dir(directory)
514
+ saved = load_summary_from_dir(restore_directory)
515
+
496
516
  saved = [r for r in saved if isinstance(r, dict)]
497
517
  if saved:
498
518
  self.summary = saved
@@ -505,28 +525,6 @@ class AgentSession:
505
525
  self.summary.append({"turn": turn, "what": "assistant",
506
526
  "history_file": 0, "index": i})
507
527
 
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
-
530
528
  def _record_turn(self, what, messages, history_index=None):
531
529
  """Append one entry to the high-level recorder.
532
530
 
@@ -645,37 +643,7 @@ class AgentSession:
645
643
  f"Sub-agents enabled ({len(self.subagents_registry)} available: "
646
644
  f"{', '.join(self.subagents_registry)}).", "info")
647
645
 
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):
646
+ def show_resume_choice(self):
679
647
  """Prompt for the resume choice and return its label.
680
648
 
681
649
  Returns one of ``"resume"``, ``"new"`` or ``"exit"``. The prompt
@@ -706,61 +674,28 @@ class AgentSession:
706
674
 
707
675
  def run(self, initial_prompt=None):
708
676
  """Drive the agent loop until the user quits."""
709
- self.print_announcements()
710
- print_welcome(self.allowed_dir, self.server_url, self.args,
711
- self.unique_id, self.cache_dir, self.model)
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
-
726
- # Announce the session's unique id as an assistant-styled message so the
727
- # user can see / copy the id this conversation is being saved under.
728
- self._print_session_id()
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
-
737
- # Report the outcome of --load-last-session up front, so the user sees
738
- # whether we resumed an earlier conversation (under a brand-new id) or
739
- # started fresh because no previous session was found.
740
- if self.loaded_last_session_dir is not None:
741
- self.console.note(
742
- f"Loaded most recent session "
743
- f"({os.path.basename(self.loaded_last_session_dir)}) and continuing "
744
- f"it under a new session id.", "ok")
745
- elif getattr(self.args, "load_last_session", False):
746
- self.console.note(
747
- "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
+
748
680
 
749
681
  # Hint at startup so the high-level history feature is discoverable:
750
682
  # /history browses the recorder, /history <n> inspects a turn, and
751
683
  # /reset <n> rewinds the conversation to that turn's state.
752
- self.console.emit(
753
- self.console.style(
754
- "Tip: /history to browse the conversation, /history <n> to inspect a "
755
- "turn, /reset <n> to rewind. Type /help for all commands.",
756
- 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"))
757
691
 
758
692
  # The --prompt value is a one-shot initial turn: it is sent verbatim and
759
693
  # then cleared, so later turns come from the user (manual mode) or are
760
694
  # produced automatically by the loop (auto mode).
761
695
  pending = initial_prompt
762
696
 
763
- while True:
697
+ running = True
698
+ while running:
764
699
  try:
765
700
  # 1) Get the user's turn: the one-shot preset, or a fresh line.
766
701
  if pending is not None:
@@ -771,8 +706,9 @@ class AgentSession:
771
706
  pending = None
772
707
  else:
773
708
  user_input = self._read_user_line()
774
- if user_input is None: # quit / exit
775
- 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
776
712
  break
777
713
 
778
714
  # Interactive slash commands are handled in place and never
@@ -780,29 +716,60 @@ class AgentSession:
780
716
  # was such a command, so we just re-prompt (continue).
781
717
  if user_input.startswith("/") and self._handle_command(user_input):
782
718
  continue
783
-
784
- # In plan mode, remember the user's latest instruction as the
785
- # active goal so the loop (and plan-mode compaction) can track it
786
- # even if the agent never writes it to disk itself.
787
- self._capture_goal(user_input)
788
-
789
- # 2) Hand the user turn to the model and act on its tool calls.
719
+
790
720
  self.messages.append({"role": "user", "content": user_input})
791
- 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.
792
734
  should_continue = self._step()
793
735
  self._persist()
794
- if not should_continue:
795
- break
796
- self.console.note(
797
- "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
+
798
757
  except _Abort:
799
758
  # Ctrl-C during a permission prompt already shut the session
800
759
  # down; just unwind the loop quietly.
801
- break
760
+ running = False
761
+
802
762
  except KeyboardInterrupt:
803
763
  self.console.note("Interrupting: Mini Code Exiting...", "error")
764
+ self.shutdown()
765
+ running = False
804
766
  # Ctrl-C mid-loop just saves and re-prompts rather than crashing.
805
- 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")
806
773
 
807
774
  def _print_session_id(self):
808
775
  """Print the session's unique id as a minimal "Assistant" turn.
@@ -814,15 +781,15 @@ class AgentSession:
814
781
  """
815
782
  self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
816
783
 
817
- def _save_last_session_pointer(self):
818
- """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.
819
786
 
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))
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))
826
793
 
827
794
  # -- autonomous drive (used by sub-agents) ------------------------------
828
795
 
@@ -875,49 +842,123 @@ class AgentSession:
875
842
 
876
843
  # -- plan mode ---------------------------------------------------------
877
844
 
878
- def _capture_goal(self, user_input):
879
- """Seed CURRENT_GOAL.md with the user's latest instruction (plan mode).
880
-
881
- The loop owns the goal (the user's last instruction); the agent owns the
882
- plan (CURRENT_PLAN.md). Overwriting on every turn keeps the goal current.
883
- Slash commands and the empty input are not real instructions, so they are
884
- 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.
885
847
  """
886
- if not self.plan_mode or user_input is None:
887
- return
888
848
  stripped = user_input.strip()
889
849
  if not stripped or stripped.startswith("/"):
890
850
  return
891
- 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
+ })
892
885
 
893
886
  def _finish_goal_and_shutdown(self):
894
- """Mark the active goal finished in CURRENT_PLAN.md, then quit/save."""
895
- 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)
896
895
  self.shutdown()
897
896
 
898
- def _mark_current_goal_finished(self):
899
- """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
900
932
 
901
- Uses the goal the loop last recorded (self.plan_manager.goal); a no-op
902
- when --plan is off, so it is safe to call unconditionally.
933
+
934
+ def _handle_compaction(self, compacted):
935
+ """Re-inject goal + plan into the compacted messages (plan mode).
936
+
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.
903
943
  """
904
- self.plan_manager.mark_finished(self.plan_manager.goal)
944
+ self.messages = self._apply_plan_to_compacted(compacted)
905
945
 
906
946
  def _apply_plan_to_compacted(self, messages):
907
- """Re-inject the running plan + current goal after a history compaction.
947
+ """Re-inject the current goal + plan after a history compaction.
908
948
 
909
949
  Compaction replaces the transcript with a summary, which would otherwise
910
- drop the agent's plan and the goal the loop seeded. We read them back off
911
- disk and append them as fresh user turns so the model keeps working
912
- against the goal it is still trying to finish. The goal is appended last
913
- 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.
914
955
  """
915
956
  plan_text = self.plan_manager.read_plan()
916
957
  goal_text = self.plan_manager.read_goal()
917
958
 
918
959
  # Recover the user's latest instruction from the pre-compaction tail, in
919
- # case the loop had not seeded a goal file yet (e.g. the agent finished
920
- # 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.
921
962
  current_input = None
922
963
  for m in reversed(messages):
923
964
  if m.get("role") == "user":
@@ -938,29 +979,6 @@ class AgentSession:
938
979
  rebuilt.append({"role": "user", "content": current_input})
939
980
  return rebuilt
940
981
 
941
- def _is_plan_file(self, name, tool_params):
942
- """Whether a read/write targets a plan-mode file in the session dir.
943
-
944
- Lets the agent maintain CURRENT_PLAN.md / CURRENT_GOAL.md even though the
945
- session directory is usually outside --allowed-dir. Scoped tightly: plan
946
- mode only, only read_file / write_file, only when the resolved path is a
947
- plan file living directly in this session's directory.
948
- """
949
- if not self.plan_mode or name not in ("read_file", "write_file"):
950
- return False
951
- path = tool_params.get("file_path", "")
952
- if not path:
953
- return False
954
- try:
955
- resolved = os.path.realpath(path)
956
- except (ValueError, TypeError):
957
- return False
958
- session = os.path.realpath(os.path.join(self.cache_dir, self.unique_id))
959
- if os.path.dirname(resolved) != session:
960
- return False
961
- return os.path.basename(resolved) in (
962
- plan_utils.PLAN_FILENAME, plan_utils.GOAL_FILENAME)
963
-
964
982
  def _is_report_file(self, name, tool_params):
965
983
  """Whether a write targets this session's own sub-agent report.md.
966
984
 
@@ -995,22 +1013,9 @@ class AgentSession:
995
1013
  user is asked again rather than sending an empty turn to the model.
996
1014
  """
997
1015
  self.console.short_divider()
998
- user_input = self.console.read_line(style_line_prompt("You:"))
999
- if user_input is None: # EOF / Ctrl-C at the prompt
1000
- self.console.note("Thank you. Goodbye! Saving history...", "info")
1001
- return None
1002
1016
 
1003
- if user_input.lower() in self.EXIT_COMMANDS:
1004
- return None
1005
-
1006
- if user_input == "/help":
1007
- print_help(self.allowed_dir, self.server_url, self.args,
1008
- self.unique_id, self.cache_dir, self.model)
1009
- # Nothing to send to the model; ask again.
1010
- return self._read_user_line()
1017
+ user_input = self.console.read_line(style_line_prompt("You:"))
1011
1018
 
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
1019
  if not user_input.strip():
1015
1020
  self.console.note("No input. (Type /help for commands, /quit to exit.)", "warn")
1016
1021
  return self._read_user_line()
@@ -1026,6 +1031,8 @@ class AgentSession:
1026
1031
  /history <n> show the full messages recorded for turn <n>
1027
1032
  /reset [<n>] rewind the conversation to the end of turn <n>
1028
1033
  (no <n> rewinds to the most recent turn)
1034
+ /plan show the current goal + plan the loop maintains
1035
+ (plan mode only)
1029
1036
  /clear clear the screen (keeps the conversation)
1030
1037
  /help show the command help
1031
1038
 
@@ -1052,6 +1059,9 @@ class AgentSession:
1052
1059
  if cmd in self.CLEAR_COMMANDS:
1053
1060
  self._clear_and_show_last()
1054
1061
  return True
1062
+ if cmd == "plan":
1063
+ self._show_plan()
1064
+ return True
1055
1065
  self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
1056
1066
  return True
1057
1067
 
@@ -1078,6 +1088,33 @@ class AgentSession:
1078
1088
  return
1079
1089
  self._reset_history(target["turn"], target)
1080
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
+
1081
1118
  def _clear_and_show_last(self):
1082
1119
  """Handle /clear: wipe the screen and re-print the last turn.
1083
1120
 
@@ -1108,7 +1145,7 @@ class AgentSession:
1108
1145
  # Show a "… calling tool" spinner (in-place, non-scrolling) while the
1109
1146
  # tool(s) execute, mirroring the thinking spinner that covers the LLM
1110
1147
  # call. Always stopped (finally) so it can never leak into the next turn.
1111
- self.console.start_tool_call()
1148
+
1112
1149
  try:
1113
1150
  for call in tool_calls:
1114
1151
  self._run_one_tool_call(call)
@@ -1116,9 +1153,11 @@ class AgentSession:
1116
1153
  self.console.stop_tool_call()
1117
1154
 
1118
1155
  def _run_one_tool_call(self, call):
1156
+
1119
1157
  call_id = call.get("id", "")
1120
1158
  name = call.get("function", {}).get("name", "")
1121
1159
  params_str = call.get("function", {}).get("arguments", "{}")
1160
+ self.console.start_tool_call(tool_name=name)
1122
1161
 
1123
1162
  try:
1124
1163
  tool_params = json.loads(params_str)
@@ -1141,14 +1180,11 @@ class AgentSession:
1141
1180
  f"Error: Unknown tool: {name}, params {params_str}")
1142
1181
  return
1143
1182
 
1144
- # 1) Sandbox: keep every filesystem tool inside --allowed-dir (plan mode
1145
- # additionally lets the agent maintain CURRENT_PLAN.md /
1146
- # CURRENT_GOAL.md in the session directory, which normally sits
1147
- # 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.)
1148
1186
  if not tool_paths_are_allowed(name, tool_params, self.allowed_dir):
1149
- if self._is_plan_file(name, tool_params):
1150
- pass # allow the plan/goal files into the session directory
1151
- elif self._is_report_file(name, tool_params):
1187
+ if self._is_report_file(name, tool_params):
1152
1188
  pass # allow a sub-agent's own report.md under the cache dir
1153
1189
  else:
1154
1190
  self.console.note(
@@ -1253,14 +1289,13 @@ class AgentSession:
1253
1289
  self.messages, did_compact, new_index = maybe_compact(
1254
1290
  self.messages, self.server_url, self.model, self.api_key,
1255
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
1256
1296
  )
1257
1297
  if did_compact:
1258
1298
  self.history_index = new_index
1259
- # In plan mode, re-inject the running plan + current goal so a
1260
- # compaction (which would otherwise drop them) keeps the model
1261
- # working against the goal it is still trying to finish.
1262
- if self.plan_mode:
1263
- self.messages = self._apply_plan_to_compacted(self.messages)
1264
1299
  # Snapshot the compacted messages into the new segment so its turns
1265
1300
  # are preserved, and record the summary as the first turn of it.
1266
1301
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
@@ -1299,6 +1334,9 @@ class AgentSession:
1299
1334
  made_tool_calls = bool(tool_calls)
1300
1335
  if made_tool_calls:
1301
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
1302
1340
 
1303
1341
  if response_text:
1304
1342
  self._print_assistant(response_text)