From 1a1bdaf33e3cedd8415090ad5a2ecea9c36af5ae Mon Sep 17 00:00:00 2001
From: QUSETIONS <1418458861@qq.com>
Date: Sat, 6 Jun 2026 13:21:49 +0800
Subject: [PATCH 01/20] Refresh README for minicode-lite product surfaces
---
README.md | 74 ++++++++++++++++++++++++++++++++++++++-----------------
1 file changed, 51 insertions(+), 23 deletions(-)
diff --git a/README.md b/README.md
index b42fc4c..f1ad8d6 100644
--- a/README.md
+++ b/README.md
@@ -14,14 +14,15 @@
-
+
MiniCode Python is the Python implementation in the MiniCode family. The main
project is [LiuMengxuan04/MiniCode](https://github.com/LiuMengxuan04/MiniCode);
-this repository explores a Python-first agent runtime with cybernetic control,
-adaptive memory, and a testable local tool loop.
+this repository now focuses on a Python-first agent runtime with cybernetic
+control, adaptive memory, durable sessions, rewindable local edits, and
+testable product surfaces for real local use.
Instead of treating context pressure, tool failures, memory noise, and cost
drift as prompt-only problems, MiniCode Python measures them during execution
@@ -46,29 +47,32 @@ That makes this repository useful as:
| Area | What MiniCode Python Adds |
| --- | --- |
-| Runtime control | `CyberneticOrchestrator` coordinates context, cost, feedback, progress, memory, and recovery controllers. |
-| Context management | PID-style context pressure handling, compaction, budget adjustment, and predictive guards. |
-| Memory | Domain-aware retrieval, optional LLM reranking, prompt injection, reflection write-back, and maintenance. |
-| Tool loop | Local file/search/edit/command tools with scheduler-aware execution and error nudges. |
-| Recovery | Self-healing paths for context overflow, tool failures, oscillation, and resource pressure. |
-| Verification | Focused unit, integration, stress, and cybernetics tests across the active root package. |
+| Runtime control | `TurnKernel`, `CyberneticOrchestrator`, and runtime profiles (`single`, `single-deep`) coordinate phase-aware execution, widening, verification gates, and recovery. |
+| Context management | PID-style context pressure handling, compaction, budget adjustment, predictive guards, and runtime timelines. |
+| Memory | Domain-aware retrieval, optional reranking, prompt injection, reflection write-back, maintenance, and working-memory importance tracking. |
+| Tool loop | Local file/search/edit/command tools with scheduler-aware execution, structured progress, and local slash-command surfaces. |
+| Recovery | Self-healing paths for context overflow, tool failures, oscillation, provider outages, rewind safety, and bounded fallback chains. |
+| Product surfaces | Session inspect/replay, checkpoint preview/rewind, readiness reporting, hook and instruction summaries, and benchmark artifacts. |
+| Verification | Focused unit, integration, stress, cybernetics, runtime-profile, and release-readiness tests across the active root package. |
## Architecture
```mermaid
flowchart LR
User["User task"] --> Loop["agent_loop.py"]
- Loop --> Tools["Local tools
files, search, edit, shell"]
+ Loop --> Kernel["turn_kernel.py
phase policy, widening,
verification gate"]
+ Kernel --> Tools["Local tools
files, search, edit, shell"]
Tools --> Loop
- Loop --> Sensors["Sensors
context, cost, errors, progress"]
+ Loop --> Sensors["Sensors
context, cost, errors,
progress, provider state"]
Sensors --> Orchestrator["CyberneticOrchestrator"]
- Orchestrator --> Control["Controllers
PID, Kalman, prediction,
memory, model, progress"]
- Control --> Actions["Runtime actions
compact, cap concurrency,
adjust budget, inject memory,
recover, reflect"]
+ Orchestrator --> Control["Controllers
PID, prediction,
memory, model, progress"]
+ Control --> Actions["Runtime actions
compact, cap concurrency,
adjust budget, inject memory,
recover, checkpoint, rewind"]
Actions --> Loop
```
-The main loop now drives the orchestrator lifecycle directly:
+The main loop now drives the orchestrator lifecycle directly and layers it with
+the turn kernel, runtime profiles, and product surfaces:
- `wire_memory()`
- `wire_healing()`
@@ -76,10 +80,13 @@ The main loop now drives the orchestrator lifecycle directly:
- `step_start()`
- `step_end()`
- `reflect_on_task()`
+- `derive_turn_step_policy()`
+- `activate_widening()`
+- `record_runtime_event()`
This keeps controller initialization, memory injection, per-step observation,
-feedback, self-healing, and post-task reflection tied to the same runtime
-surface.
+feedback, self-healing, checkpointing, verification, and post-task reflection
+tied to the same runtime surface.
## Repository Status
@@ -90,6 +97,8 @@ The active package is the root package configured in `pyproject.toml`.
| `minicode/` | Canonical Python package used by install and tests. |
| `tests/` | Active test suite. |
| `py-src/minicode/` | Compatibility/staging mirror kept aligned for migration work. |
+| `benchmarks/` | Runtime profile and release-readiness benchmark entrypoints plus generated reports. |
+| `openspec/` | Productization specs, archived changes, and implementation checklists. |
| `docs/OPTIMIZATION_SUMMARY.md` | Full optimization and integration record. |
| `docs/memory_theory.md` | Memory/control theory notes. |
@@ -117,6 +126,12 @@ Or run the module directly:
python -m minicode.main
```
+Useful local product surfaces now available from the CLI and TUI include:
+
+- `/session`, `/sessions`, `/session-replay`
+- `/checkpoints`, `/rewind`, `/rewind-preview`
+- `/readiness`
+
## Verification
The current root package was verified with:
@@ -129,25 +144,34 @@ pytest -q
Latest local result:
```text
-738 passed, 2 skipped, 3 warnings
+1027 passed, 2 skipped, 3 warnings
```
The warnings are unregistered `pytest.mark.benchmark` markers in benchmark
tests. They do not indicate failing behavior.
+Additional product-level checks now live in:
+
+- `benchmarks/runtime_profile_eval.py`
+- `benchmarks/release_readiness.py`
+
## Core Modules
| Module | Purpose |
| --- | --- |
-| `minicode/agent_loop.py` | Main model/tool loop and runtime control integration. |
+| `minicode/agent_loop.py` | Main model/tool loop, runtime event flow, and product-surface integration. |
+| `minicode/turn_kernel.py` | Step-policy kernel for phases, widening, verification gates, and typed turn decisions. |
+| `minicode/runtime_profiles.py` | Runtime profile definitions such as `single` and `single-deep`. |
| `minicode/cybernetic_orchestrator.py` | Facade for controller lifecycle hooks. |
| `minicode/context_cybernetics.py` | Context sensing, PID control, and compaction loop. |
| `minicode/feedback_controller.py` | Outer-loop system-state to control-signal mapping. |
| `minicode/self_healing_engine.py` | Fault detection and recovery delegation. |
| `minicode/memory_pipeline.py` | Unified memory read/inject/write/maintain facade. |
-| `minicode/memory_reranker.py` | LLM-backed memory curation. |
-| `minicode/domain_classifier.py` | Task and file-domain inference. |
-| `minicode/model_registry.py` | Model selection controller. |
+| `minicode/session.py` | Durable session storage, inspect/replay views, checkpoint trails, and rewind helpers. |
+| `minicode/product_surfaces.py` | Readiness, hook, instruction, delegation, and extension-facing summaries. |
+| `minicode/release_readiness.py` | Release-oriented runtime smoke and provider-readiness reporting. |
+| `minicode/model_switcher.py` | Bounded model/provider fallback selection and failover wiring. |
+| `minicode/model_registry.py` | Model selection controller and availability metadata. |
| `minicode/progress_controller.py` | Task health and stall detection. |
## MiniCode Family
@@ -155,7 +179,7 @@ tests. They do not indicate failing behavior.
| Version | Repository | Focus |
| --- | --- | --- |
| TypeScript | [LiuMengxuan04/MiniCode](https://github.com/LiuMengxuan04/MiniCode) | Mainline terminal agent, TUI, MCP, skills, sessions, context controls. |
-| Python | [QUSETIONS/MiniCode-Python](https://github.com/QUSETIONS/MiniCode-Python) | Cybernetic Python runtime, memory pipeline, verification-oriented experiments. |
+| Python | [QUSETIONS/MiniCode-Python](https://github.com/QUSETIONS/MiniCode-Python) | Cybernetic Python runtime, session/rewind product surfaces, readiness reporting, and verification-oriented experiments. |
| Rust | [harkerhand/MiniCode-rs](https://github.com/harkerhand/MiniCode-rs/tree/master) | Rust implementation and systems-side experimentation. |
| Java | [hobbescalvin414-tech/minicode4j](https://github.com/hobbescalvin414-tech/minicode4j/tree/feat/default-ts-ui) | Java implementation with a TypeScript-style UI direction. |
@@ -163,12 +187,16 @@ tests. They do not indicate failing behavior.
- [Optimization Summary](./docs/OPTIMIZATION_SUMMARY.md)
- [Memory Theory](./docs/memory_theory.md)
+- [Minicode-lite Productization Design](./docs/superpowers/specs/2026-06-05-minicode-lite-productization-design.md)
+- [Minicode-lite Build Plan](./docs/superpowers/plans/2026-06-05-minicode-lite-productization-build.md)
+- [Minicode-lite Verify Report](./docs/superpowers/reports/2026-06-05-minicode-lite-productization-verify.md)
- [Main MiniCode Repository](https://github.com/LiuMengxuan04/MiniCode)
## Design Principles
- Keep the agent loop inspectable.
- Prefer measured runtime signals over hidden prompt magic.
-- Apply bounded actions: compact, cap, adjust, recover, reflect.
+- Apply bounded actions: compact, cap, adjust, recover, rewind, reflect.
- Treat verification and evidence as part of the agent runtime.
+- Make sessions, checkpoints, replay, and readiness first-class product surfaces.
- Keep the Python implementation useful as both software and research scaffold.
From ceb7ff6860c5338a2ac6ea13aee765d292239ab7 Mon Sep 17 00:00:00 2001
From: QUSETIONS <1418458861@qq.com>
Date: Sat, 6 Jun 2026 13:40:19 +0800
Subject: [PATCH 02/20] Add headless trace artifacts and runtime diagnostics
---
minicode/headless.py | 53 ++++++++++++++++++++++++++++++++++--
py-src/minicode/headless.py | 54 +++++++++++++++++++++++++++++++++++--
tests/test_headless.py | 50 ++++++++++++++++++++++++++++++++++
3 files changed, 153 insertions(+), 4 deletions(-)
diff --git a/minicode/headless.py b/minicode/headless.py
index 2174099..8162674 100644
--- a/minicode/headless.py
+++ b/minicode/headless.py
@@ -16,11 +16,40 @@
from __future__ import annotations
+import json
import os
import sys
from pathlib import Path
+def _write_headless_messages_trace(
+ trace_path: str | None,
+ *,
+ cwd: str,
+ prompt: str,
+ runtime: dict | None,
+ result_messages: list[dict] | None,
+ response_text: str | None,
+ error_text: str | None = None,
+) -> None:
+ if not trace_path:
+ return
+ payload = {
+ "cwd": cwd,
+ "prompt": prompt,
+ "model": (runtime or {}).get("model"),
+ "messages": result_messages or [],
+ "assistant_response": response_text,
+ "error": error_text,
+ }
+ path = Path(trace_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(payload, ensure_ascii=False, indent=2, default=str),
+ encoding="utf-8",
+ )
+
+
def run_headless(prompt: str | None = None) -> str:
"""Run a single agent turn in headless mode and return the response.
@@ -91,6 +120,7 @@ def run_headless(prompt: str | None = None) -> str:
]
logger.info("Headless run: %s", prompt[:80])
+ trace_output_path = os.environ.get("MINI_CODE_HEADLESS_MESSAGES_OUT", "").strip() or None
try:
result_messages = run_agent_turn(
@@ -107,11 +137,30 @@ def run_headless(prompt: str | None = None) -> str:
(m for m in reversed(result_messages) if m["role"] == "assistant"),
None,
)
- return last_assistant["content"] if last_assistant else "(no response)"
+ response_text = last_assistant["content"] if last_assistant else "(no response)"
+ _write_headless_messages_trace(
+ trace_output_path,
+ cwd=cwd,
+ prompt=prompt,
+ runtime=runtime,
+ result_messages=result_messages,
+ response_text=response_text,
+ )
+ return response_text
except Exception as exc: # noqa: BLE001
logger.error("Headless error: %s", exc)
- return f"Error: {exc}"
+ response_text = f"Error: {exc}"
+ _write_headless_messages_trace(
+ trace_output_path,
+ cwd=cwd,
+ prompt=prompt,
+ runtime=runtime,
+ result_messages=[],
+ response_text=response_text,
+ error_text=str(exc),
+ )
+ return response_text
finally:
try:
tools.dispose()
diff --git a/py-src/minicode/headless.py b/py-src/minicode/headless.py
index 99f74cb..8ee4547 100644
--- a/py-src/minicode/headless.py
+++ b/py-src/minicode/headless.py
@@ -16,11 +16,40 @@
from __future__ import annotations
+import json
import os
import sys
from pathlib import Path
+def _write_headless_messages_trace(
+ trace_path: str | None,
+ *,
+ cwd: str,
+ prompt: str,
+ runtime: dict | None,
+ result_messages: list[dict] | None,
+ response_text: str | None,
+ error_text: str | None = None,
+) -> None:
+ if not trace_path:
+ return
+ payload = {
+ "cwd": cwd,
+ "prompt": prompt,
+ "model": (runtime or {}).get("model"),
+ "messages": result_messages or [],
+ "assistant_response": response_text,
+ "error": error_text,
+ }
+ path = Path(trace_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(payload, ensure_ascii=False, indent=2, default=str),
+ encoding="utf-8",
+ )
+
+
def run_headless(prompt: str | None = None) -> str:
"""Run a single agent turn in headless mode and return the response.
@@ -103,6 +132,7 @@ def run_headless(prompt: str | None = None) -> str:
max_steps = max(1, int(raw_max_steps))
except ValueError:
max_steps = 50
+ trace_output_path = os.environ.get("MINI_CODE_HEADLESS_MESSAGES_OUT", "").strip() or None
try:
result_messages = run_agent_turn(
@@ -112,6 +142,7 @@ def run_headless(prompt: str | None = None) -> str:
cwd=cwd,
permissions=permissions,
max_steps=max_steps,
+ runtime=runtime,
)
# Extract last assistant message
@@ -119,11 +150,30 @@ def run_headless(prompt: str | None = None) -> str:
(m for m in reversed(result_messages) if m["role"] == "assistant"),
None,
)
- return last_assistant["content"] if last_assistant else "(no response)"
+ response_text = last_assistant["content"] if last_assistant else "(no response)"
+ _write_headless_messages_trace(
+ trace_output_path,
+ cwd=cwd,
+ prompt=prompt,
+ runtime=runtime,
+ result_messages=result_messages,
+ response_text=response_text,
+ )
+ return response_text
except Exception as exc: # noqa: BLE001
logger.error("Headless error: %s", exc)
- return f"Error: {exc}"
+ response_text = f"Error: {exc}"
+ _write_headless_messages_trace(
+ trace_output_path,
+ cwd=cwd,
+ prompt=prompt,
+ runtime=runtime,
+ result_messages=[],
+ response_text=response_text,
+ error_text=str(exc),
+ )
+ return response_text
finally:
try:
tools.dispose()
diff --git a/tests/test_headless.py b/tests/test_headless.py
index 0cfc360..4e4e3ec 100644
--- a/tests/test_headless.py
+++ b/tests/test_headless.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
from pathlib import Path
from minicode.tooling import ToolRegistry
@@ -121,3 +122,52 @@ def test_run_headless_provider_failure_uses_runtime_channel_details(
assert "Active channel: anthropic-compatible via baseUrl/authToken." in response
assert "Next step: Primary runtime is using a single anthropic-compatible channel from baseUrl/authToken." in response
+
+def test_run_headless_writes_messages_trace_when_requested(monkeypatch, tmp_path: Path) -> None:
+ import minicode.headless
+
+ runtime = {
+ "model": "deepseek-v4-pro[1m]",
+ "baseUrl": "https://openai-proxy.example/v1",
+ "authToken": "test-token",
+ }
+ trace_path = tmp_path / "artifacts" / "messages.json"
+
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("MINI_CODE_HEADLESS_MESSAGES_OUT", str(trace_path))
+ monkeypatch.setattr(
+ "minicode.config.load_runtime_config",
+ lambda cwd: runtime,
+ )
+ monkeypatch.setattr(
+ "minicode.tools.create_default_tool_registry",
+ lambda cwd, runtime=None: ToolRegistry([]),
+ )
+ monkeypatch.setattr("minicode.permissions.PermissionManager", _DummyPermissions)
+ monkeypatch.setattr("minicode.memory.MemoryManager", _DummyMemoryManager)
+ monkeypatch.setattr(
+ "minicode.prompt.build_system_prompt",
+ lambda cwd, permissions, context: "sys",
+ )
+ monkeypatch.setattr(
+ "minicode.model_registry.create_model_adapter",
+ lambda model, tools, runtime=None: object(),
+ )
+ monkeypatch.setattr(
+ "minicode.agent_loop.run_agent_turn",
+ lambda **kwargs: [
+ {"role": "assistant", "content": "traceable"},
+ {"role": "tool", "content": "python -m unittest"},
+ ],
+ )
+
+ response = minicode.headless.run_headless("Run the visible tests.")
+
+ assert response == "traceable"
+ payload = json.loads(trace_path.read_text(encoding="utf-8"))
+ assert payload["cwd"] == str(tmp_path)
+ assert payload["prompt"] == "Run the visible tests."
+ assert payload["model"] == "deepseek-v4-pro[1m]"
+ assert payload["assistant_response"] == "traceable"
+ assert payload["error"] is None
+ assert payload["messages"][0]["role"] == "assistant"
From dd6e934a18261f337855f696e9245c0310049ec6 Mon Sep 17 00:00:00 2001
From: QUSETIONS <1418458861@qq.com>
Date: Tue, 16 Jun 2026 05:28:12 +0800
Subject: [PATCH 03/20] Fix Windows bugs, TS-parity gaps, logging hardening,
and compaction defects
Issue #7 (Windows): MCP npx/npx.cmd resolution (shutil.which + shell=True for
.cmd wrappers; whitelist strips .cmd/.bat); alternate-screen no longer skipped
on Windows (empty TERM is not "dumb"; isatty guard). _win_read_one_key already
fixed.
TS-parity (ported npm-test scenarios): /lsfoo no longer misparsed; /write,
/modify, /edit reject blank paths; multi-line CRLF paste no longer submits;
transcript scroll offset now counts width-wrapped visual rows (ported
charDisplayWidth/wrapPanelBodyLine); get_model_context_window case-insensitive
substring rules + output_reserve/effective_input; empty-content token estimate
returns 0; added token_count_with_estimation + compute_context_stats; /collapse
command.
Issue #5 (logging): rotation doc fixed (size-only), dead TimedRotating constants
removed; ToolRegistry.execute logs tool execution + exceptions to file;
--structured-logs flag + MINI_CODE_LOG_STRUCTURED env; permission/session
structured-log helpers wired; config-load failures persisted; unified minicode.*
namespace; headless --allow-edits non-interactive CI path.
Deep-dive defects: microcompactor _compact_budget_based no longer returns
no_action when over budget and now trims oldest-first; ToolResultBudgetManager
no longer crashes on None/non-str tool_result content; session update_metadata
no longer crashes on None content; AutoCompactDispatcher circuit breaker now
auto-recovers (was permanently disabling compaction after transient failures).
Removes the stale, diverged py-src/minicode mirror (kept py-src/ experiments);
README/CODE_WIKI references repointed to the canonical root package.
All backed by new/expanded tests; full suite green (1359 passed).
Co-Authored-By: Claude Opus 4.8
---
README.md | 3 +-
README.zh-CN.md | 3 +-
docs/CODE_WIKI.md | 14 +-
docs/OPTIMIZATION_SUMMARY.md | 2 +-
minicode/cli_commands.py | 1 +
minicode/context_compactor.py | 42 +-
minicode/context_manager.py | 169 +-
minicode/headless.py | 61 +-
minicode/local_tool_shortcuts.py | 12 +-
minicode/logging_config.py | 32 +-
minicode/main.py | 22 +-
minicode/mcp.py | 43 +-
minicode/micro_compact.py | 300 +++
minicode/openai_adapter.py | 123 +-
minicode/permissions.py | 18 +-
minicode/session.py | 14 +-
minicode/tooling.py | 30 +-
minicode/tty_app.py | 5 +-
minicode/tui/input_parser.py | 56 +-
minicode/tui/screen.py | 20 +-
minicode/tui/transcript.py | 96 +-
py-src/minicode/__init__.py | 2 -
py-src/minicode/adaptive_pid_tuner.py | 423 ----
py-src/minicode/agent_intelligence.py | 300 ---
py-src/minicode/agent_loop.py | 1075 ----------
py-src/minicode/agent_metrics.py | 223 ---
py-src/minicode/agent_protocol.py | 423 ----
py-src/minicode/agent_reflection.py | 234 ---
py-src/minicode/agent_router.py | 421 ----
py-src/minicode/anthropic_adapter.py | 387 ----
py-src/minicode/api_retry.py | 550 -----
py-src/minicode/auto_mode.py | 439 ----
py-src/minicode/background_tasks.py | 210 --
py-src/minicode/capability_registry.py | 272 ---
py-src/minicode/cli_commands.py | 270 ---
py-src/minicode/config.py | 464 -----
py-src/minicode/context_compactor.py | 1112 -----------
py-src/minicode/context_cybernetics.py | 844 --------
py-src/minicode/context_isolation.py | 205 --
py-src/minicode/context_manager.py | 655 ------
py-src/minicode/cost_control.py | 452 -----
py-src/minicode/cost_tracker.py | 378 ----
py-src/minicode/cron.example.json | 22 -
py-src/minicode/cron_runner.py | 82 -
py-src/minicode/decision_audit.py | 260 ---
py-src/minicode/decoupling_controller.py | 272 ---
py-src/minicode/feedback_controller.py | 331 ---
py-src/minicode/feedforward_controller.py | 173 --
py-src/minicode/file_review.py | 68 -
py-src/minicode/gateway.py | 86 -
py-src/minicode/headless.py | 197 --
py-src/minicode/history.py | 25 -
py-src/minicode/hooks.py | 420 ----
py-src/minicode/install.py | 275 ---
py-src/minicode/intent_parser.py | 284 ---
py-src/minicode/layered_context.py | 209 --
py-src/minicode/local_tool_shortcuts.py | 91 -
py-src/minicode/logging_config.py | 223 ---
py-src/minicode/main.py | 513 -----
py-src/minicode/manage_cli.py | 168 --
py-src/minicode/mcp.py | 794 --------
py-src/minicode/memory.py | 1689 ----------------
py-src/minicode/memory_injector.py | 288 ---
py-src/minicode/mock_model.py | 124 --
py-src/minicode/model_registry.py | 483 -----
py-src/minicode/model_switcher.py | 144 --
py-src/minicode/multi_agent/__init__.py | 48 -
.../minicode/multi_agent/adaptive_workflow.py | 174 --
py-src/minicode/multi_agent/message_queue.py | 258 ---
py-src/minicode/multi_agent/orchestrator.py | 315 ---
py-src/minicode/multi_agent/patterns.py | 422 ----
py-src/minicode/multi_agent/role_analyzer.py | 183 --
py-src/minicode/multi_agent/shared_memory.py | 179 --
py-src/minicode/multi_agent/types.py | 140 --
py-src/minicode/multi_agent_agent.py | 122 --
py-src/minicode/openai_adapter.py | 413 ----
py-src/minicode/permissions.py | 538 -----
py-src/minicode/pipeline.py | 362 ----
py-src/minicode/pipeline_engine.py | 412 ----
py-src/minicode/predictive_controller.py | 389 ----
py-src/minicode/prompt.py | 256 ---
py-src/minicode/prompt_pipeline.py | 169 --
py-src/minicode/protocol.py | 259 ---
py-src/minicode/safe_execution.py | 470 -----
py-src/minicode/self_healing_engine.py | 452 -----
py-src/minicode/session.py | 559 ------
py-src/minicode/session_persistence.py | 215 --
py-src/minicode/skills.py | 146 --
py-src/minicode/smart_router.py | 316 ---
py-src/minicode/stability_monitor.py | 394 ----
py-src/minicode/state.py | 335 ----
py-src/minicode/state_observer.py | 290 ---
py-src/minicode/task_graph.py | 383 ----
py-src/minicode/task_object.py | 196 --
py-src/minicode/task_tracker.py | 349 ----
py-src/minicode/token_estimation.py | 148 --
py-src/minicode/tooling.py | 358 ----
py-src/minicode/tools/__init__.py | 154 --
py-src/minicode/tools/archive_utils.py | 387 ----
py-src/minicode/tools/ask_user.py | 24 -
py-src/minicode/tools/batch_ops.py | 161 --
py-src/minicode/tools/code_nav.py | 386 ----
py-src/minicode/tools/code_review.py | 259 ---
py-src/minicode/tools/crypto_utils.py | 213 --
py-src/minicode/tools/csv_utils.py | 136 --
py-src/minicode/tools/diff_viewer.py | 276 ---
py-src/minicode/tools/edit_file.py | 242 ---
py-src/minicode/tools/encoding_utils.py | 153 --
py-src/minicode/tools/file_tree.py | 268 ---
py-src/minicode/tools/git.py | 211 --
py-src/minicode/tools/grep_files.py | 358 ----
py-src/minicode/tools/http_utils.py | 94 -
py-src/minicode/tools/json_utils.py | 102 -
py-src/minicode/tools/list_files.py | 35 -
py-src/minicode/tools/load_skill.py | 38 -
py-src/minicode/tools/modify_file.py | 12 -
py-src/minicode/tools/multi_agent_tool.py | 103 -
py-src/minicode/tools/patch_file.py | 86 -
py-src/minicode/tools/reach_tools.py | 650 ------
py-src/minicode/tools/read_file.py | 86 -
py-src/minicode/tools/regex_utils.py | 140 --
py-src/minicode/tools/run_command.py | 396 ----
py-src/minicode/tools/task.py | 251 ---
py-src/minicode/tools/test_runner.py | 372 ----
py-src/minicode/tools/text_utils.py | 274 ---
py-src/minicode/tools/todo_write.py | 110 -
py-src/minicode/tools/web_fetch.py | 165 --
py-src/minicode/tools/web_search.py | 140 --
py-src/minicode/tools/write_file.py | 30 -
py-src/minicode/tty_app.py | 304 ---
py-src/minicode/tui/__init__.py | 74 -
py-src/minicode/tui/chrome.py | 762 -------
py-src/minicode/tui/event_flow.py | 459 -----
py-src/minicode/tui/input.py | 46 -
py-src/minicode/tui/input_handler.py | 693 -------
py-src/minicode/tui/input_parser.py | 230 ---
py-src/minicode/tui/markdown.py | 383 ----
py-src/minicode/tui/navigation.py | 119 --
py-src/minicode/tui/renderer.py | 246 ---
py-src/minicode/tui/runtime_control.py | 71 -
py-src/minicode/tui/screen.py | 122 --
py-src/minicode/tui/session_flow.py | 160 --
py-src/minicode/tui/state.py | 73 -
py-src/minicode/tui/theme.py | 110 -
py-src/minicode/tui/tool_helpers.py | 171 --
py-src/minicode/tui/tool_lifecycle.py | 163 --
py-src/minicode/tui/transcript.py | 1773 -----------------
py-src/minicode/tui/types.py | 63 -
py-src/minicode/tui/ui_hints.py | 26 -
py-src/minicode/turn_kernel.py | 646 ------
py-src/minicode/types.py | 53 -
py-src/minicode/user_profile.py | 577 ------
py-src/minicode/working_memory.py | 266 ---
py-src/minicode/workspace.py | 24 -
tests/test_compaction_robustness.py | 131 ++
tests/test_context_compactor.py | 32 +
tests/test_headless.py | 48 +-
tests/test_logging_hardening.py | 167 ++
tests/test_mcp.py | 70 +
tests/test_micro_compact.py | 215 ++
tests/test_model_switching.py | 149 ++
tests/test_session.py | 67 +
tests/test_ts_ported.py | 289 +++
tests/test_tui.py | 86 +
164 files changed, 2217 insertions(+), 39839 deletions(-)
create mode 100644 minicode/micro_compact.py
delete mode 100644 py-src/minicode/__init__.py
delete mode 100644 py-src/minicode/adaptive_pid_tuner.py
delete mode 100644 py-src/minicode/agent_intelligence.py
delete mode 100644 py-src/minicode/agent_loop.py
delete mode 100644 py-src/minicode/agent_metrics.py
delete mode 100644 py-src/minicode/agent_protocol.py
delete mode 100644 py-src/minicode/agent_reflection.py
delete mode 100644 py-src/minicode/agent_router.py
delete mode 100644 py-src/minicode/anthropic_adapter.py
delete mode 100644 py-src/minicode/api_retry.py
delete mode 100644 py-src/minicode/auto_mode.py
delete mode 100644 py-src/minicode/background_tasks.py
delete mode 100644 py-src/minicode/capability_registry.py
delete mode 100644 py-src/minicode/cli_commands.py
delete mode 100644 py-src/minicode/config.py
delete mode 100644 py-src/minicode/context_compactor.py
delete mode 100644 py-src/minicode/context_cybernetics.py
delete mode 100644 py-src/minicode/context_isolation.py
delete mode 100644 py-src/minicode/context_manager.py
delete mode 100644 py-src/minicode/cost_control.py
delete mode 100644 py-src/minicode/cost_tracker.py
delete mode 100644 py-src/minicode/cron.example.json
delete mode 100644 py-src/minicode/cron_runner.py
delete mode 100644 py-src/minicode/decision_audit.py
delete mode 100644 py-src/minicode/decoupling_controller.py
delete mode 100644 py-src/minicode/feedback_controller.py
delete mode 100644 py-src/minicode/feedforward_controller.py
delete mode 100644 py-src/minicode/file_review.py
delete mode 100644 py-src/minicode/gateway.py
delete mode 100644 py-src/minicode/headless.py
delete mode 100644 py-src/minicode/history.py
delete mode 100644 py-src/minicode/hooks.py
delete mode 100644 py-src/minicode/install.py
delete mode 100644 py-src/minicode/intent_parser.py
delete mode 100644 py-src/minicode/layered_context.py
delete mode 100644 py-src/minicode/local_tool_shortcuts.py
delete mode 100644 py-src/minicode/logging_config.py
delete mode 100644 py-src/minicode/main.py
delete mode 100644 py-src/minicode/manage_cli.py
delete mode 100644 py-src/minicode/mcp.py
delete mode 100644 py-src/minicode/memory.py
delete mode 100644 py-src/minicode/memory_injector.py
delete mode 100644 py-src/minicode/mock_model.py
delete mode 100644 py-src/minicode/model_registry.py
delete mode 100644 py-src/minicode/model_switcher.py
delete mode 100644 py-src/minicode/multi_agent/__init__.py
delete mode 100644 py-src/minicode/multi_agent/adaptive_workflow.py
delete mode 100644 py-src/minicode/multi_agent/message_queue.py
delete mode 100644 py-src/minicode/multi_agent/orchestrator.py
delete mode 100644 py-src/minicode/multi_agent/patterns.py
delete mode 100644 py-src/minicode/multi_agent/role_analyzer.py
delete mode 100644 py-src/minicode/multi_agent/shared_memory.py
delete mode 100644 py-src/minicode/multi_agent/types.py
delete mode 100644 py-src/minicode/multi_agent_agent.py
delete mode 100644 py-src/minicode/openai_adapter.py
delete mode 100644 py-src/minicode/permissions.py
delete mode 100644 py-src/minicode/pipeline.py
delete mode 100644 py-src/minicode/pipeline_engine.py
delete mode 100644 py-src/minicode/predictive_controller.py
delete mode 100644 py-src/minicode/prompt.py
delete mode 100644 py-src/minicode/prompt_pipeline.py
delete mode 100644 py-src/minicode/protocol.py
delete mode 100644 py-src/minicode/safe_execution.py
delete mode 100644 py-src/minicode/self_healing_engine.py
delete mode 100644 py-src/minicode/session.py
delete mode 100644 py-src/minicode/session_persistence.py
delete mode 100644 py-src/minicode/skills.py
delete mode 100644 py-src/minicode/smart_router.py
delete mode 100644 py-src/minicode/stability_monitor.py
delete mode 100644 py-src/minicode/state.py
delete mode 100644 py-src/minicode/state_observer.py
delete mode 100644 py-src/minicode/task_graph.py
delete mode 100644 py-src/minicode/task_object.py
delete mode 100644 py-src/minicode/task_tracker.py
delete mode 100644 py-src/minicode/token_estimation.py
delete mode 100644 py-src/minicode/tooling.py
delete mode 100644 py-src/minicode/tools/__init__.py
delete mode 100644 py-src/minicode/tools/archive_utils.py
delete mode 100644 py-src/minicode/tools/ask_user.py
delete mode 100644 py-src/minicode/tools/batch_ops.py
delete mode 100644 py-src/minicode/tools/code_nav.py
delete mode 100644 py-src/minicode/tools/code_review.py
delete mode 100644 py-src/minicode/tools/crypto_utils.py
delete mode 100644 py-src/minicode/tools/csv_utils.py
delete mode 100644 py-src/minicode/tools/diff_viewer.py
delete mode 100644 py-src/minicode/tools/edit_file.py
delete mode 100644 py-src/minicode/tools/encoding_utils.py
delete mode 100644 py-src/minicode/tools/file_tree.py
delete mode 100644 py-src/minicode/tools/git.py
delete mode 100644 py-src/minicode/tools/grep_files.py
delete mode 100644 py-src/minicode/tools/http_utils.py
delete mode 100644 py-src/minicode/tools/json_utils.py
delete mode 100644 py-src/minicode/tools/list_files.py
delete mode 100644 py-src/minicode/tools/load_skill.py
delete mode 100644 py-src/minicode/tools/modify_file.py
delete mode 100644 py-src/minicode/tools/multi_agent_tool.py
delete mode 100644 py-src/minicode/tools/patch_file.py
delete mode 100644 py-src/minicode/tools/reach_tools.py
delete mode 100644 py-src/minicode/tools/read_file.py
delete mode 100644 py-src/minicode/tools/regex_utils.py
delete mode 100644 py-src/minicode/tools/run_command.py
delete mode 100644 py-src/minicode/tools/task.py
delete mode 100644 py-src/minicode/tools/test_runner.py
delete mode 100644 py-src/minicode/tools/text_utils.py
delete mode 100644 py-src/minicode/tools/todo_write.py
delete mode 100644 py-src/minicode/tools/web_fetch.py
delete mode 100644 py-src/minicode/tools/web_search.py
delete mode 100644 py-src/minicode/tools/write_file.py
delete mode 100644 py-src/minicode/tty_app.py
delete mode 100644 py-src/minicode/tui/__init__.py
delete mode 100644 py-src/minicode/tui/chrome.py
delete mode 100644 py-src/minicode/tui/event_flow.py
delete mode 100644 py-src/minicode/tui/input.py
delete mode 100644 py-src/minicode/tui/input_handler.py
delete mode 100644 py-src/minicode/tui/input_parser.py
delete mode 100644 py-src/minicode/tui/markdown.py
delete mode 100644 py-src/minicode/tui/navigation.py
delete mode 100644 py-src/minicode/tui/renderer.py
delete mode 100644 py-src/minicode/tui/runtime_control.py
delete mode 100644 py-src/minicode/tui/screen.py
delete mode 100644 py-src/minicode/tui/session_flow.py
delete mode 100644 py-src/minicode/tui/state.py
delete mode 100644 py-src/minicode/tui/theme.py
delete mode 100644 py-src/minicode/tui/tool_helpers.py
delete mode 100644 py-src/minicode/tui/tool_lifecycle.py
delete mode 100644 py-src/minicode/tui/transcript.py
delete mode 100644 py-src/minicode/tui/types.py
delete mode 100644 py-src/minicode/tui/ui_hints.py
delete mode 100644 py-src/minicode/turn_kernel.py
delete mode 100644 py-src/minicode/types.py
delete mode 100644 py-src/minicode/user_profile.py
delete mode 100644 py-src/minicode/working_memory.py
delete mode 100644 py-src/minicode/workspace.py
create mode 100644 tests/test_compaction_robustness.py
create mode 100644 tests/test_logging_hardening.py
create mode 100644 tests/test_micro_compact.py
create mode 100644 tests/test_model_switching.py
create mode 100644 tests/test_ts_ported.py
diff --git a/README.md b/README.md
index fb6c8be..6071b6a 100644
--- a/README.md
+++ b/README.md
@@ -181,7 +181,7 @@ Current local verification result:
Verification command:
```bash
-python -m compileall -q minicode py-src\minicode tests
+python -m compileall -q minicode tests
pytest -q
```
@@ -231,7 +231,6 @@ What matters is not the diagram itself. What matters is that runtime state is tr
| `docs/` | Architecture notes, optimization history, and productization reports. |
| `openspec/` | Specs, archived change records, and build/verify planning artifacts. |
| `.mini-code-memory/` | Workspace-level durable memory state created by the runtime. |
-| `py-src/minicode/` | Compatibility and migration mirror. |
## Core Modules
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 4eb5928..8504f66 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -181,7 +181,7 @@ flowchart LR
验证命令:
```bash
-python -m compileall -q minicode py-src\minicode tests
+python -m compileall -q minicode tests
pytest -q
```
@@ -231,7 +231,6 @@ flowchart LR
| `docs/` | 架构说明、优化记录和产品化报告。 |
| `openspec/` | spec、归档变更记录,以及 build/verify 规划产物。 |
| `.mini-code-memory/` | runtime 创建的 workspace 级持久记忆状态。 |
-| `py-src/minicode/` | 兼容与迁移镜像。 |
## Core Modules
diff --git a/docs/CODE_WIKI.md b/docs/CODE_WIKI.md
index 68e6c81..c58edf3 100644
--- a/docs/CODE_WIKI.md
+++ b/docs/CODE_WIKI.md
@@ -395,7 +395,7 @@ minicode/
- **回调机制**:`onToolStart`、`onToolResult`、`onAssistantMessage`、`onProgressMessage`
- **错误计数**:跟踪工具错误次数,影响错误恢复策略
-**Python 版本**([agent_loop.py](file:///d:/Desktop/minicode/py-src/minicode/agent_loop.py)):
+**Python 版本**([agent_loop.py](file:///d:/Desktop/minicode/minicode/agent_loop.py)):
- 与 TS 版本逻辑对等,使用 Python 异步编程模式
- 额外支持:成本追踪、工作记忆集成、上下文压缩
@@ -590,7 +590,7 @@ description: 头脑风暴和创意生成
**职责**:管理多种 LLM 后端,提供统一的调用接口。
-**模型注册表**(Python [model_registry.py](file:///d:/Desktop/minicode/py-src/minicode/model_registry.py)):
+**模型注册表**(Python [model_registry.py](file:///d:/Desktop/minicode/minicode/model_registry.py)):
```python
ModelRegistry:
@@ -625,7 +625,7 @@ ModelAdapter:
- **上下文窗口管理**:跟踪当前上下文大小,接近限制时触发压缩
- **上下文压缩**:保留关键信息,压缩历史消息
-- **上下文隔离**([context_isolation.py](file:///d:/Desktop/minicode/py-src/minicode/context_isolation.py)):不同会话/任务的上下文隔离
+- **上下文隔离**([context_isolation.py](file:///d:/Desktop/minicode/minicode/context_isolation.py)):不同会话/任务的上下文隔离
### 5.10 Memory System (memory.py)
@@ -1393,10 +1393,10 @@ interface ModelResponse {
| TS Agent Loop | [ts-src/src/agent-loop.ts](file:///d:/Desktop/minicode/ts-src/src/agent-loop.ts) |
| TS 工具 | [ts-src/src/tools/](file:///d:/Desktop/minicode/ts-src/src/tools/) |
| TS TUI | [ts-src/src/tty-app.ts](file:///d:/Desktop/minicode/ts-src/src/tty-app.ts) |
-| Python 入口 | [py-src/minicode/main.py](file:///d:/Desktop/minicode/py-src/minicode/main.py) |
-| Python Agent Loop | [py-src/minicode/agent_loop.py](file:///d:/Desktop/minicode/py-src/minicode/agent_loop.py) |
-| Python 工具 | [py-src/minicode/tools/](file:///d:/Desktop/minicode/py-src/minicode/tools/) |
-| Python TUI | [py-src/minicode/tty_app.py](file:///d:/Desktop/minicode/py-src/minicode/tty_app.py) |
+| Python 入口 | [minicode/main.py](file:///d:/Desktop/minicode/minicode/main.py) |
+| Python Agent Loop | [minicode/agent_loop.py](file:///d:/Desktop/minicode/minicode/agent_loop.py) |
+| Python 工具 | [minicode/tools/](file:///d:/Desktop/minicode/minicode/tools/) |
+| Python TUI | [minicode/tty_app.py](file:///d:/Desktop/minicode/minicode/tty_app.py) |
| 插件 | [superpowers-zh/](file:///d:/Desktop/minicode/superpowers-zh/) |
| MCP 配置 | [.mcp.json](file:///d:/Desktop/minicode/.mcp.json) |
diff --git a/docs/OPTIMIZATION_SUMMARY.md b/docs/OPTIMIZATION_SUMMARY.md
index 5a15f32..402b3a6 100644
--- a/docs/OPTIMIZATION_SUMMARY.md
+++ b/docs/OPTIMIZATION_SUMMARY.md
@@ -46,7 +46,7 @@
### Phase 3 — 导入独立模块
-从 `py-src/minicode/` 导入 4 个完整但未集成的模块:
+从当时独立的 `py-src/minicode/` 镜像(该镜像现已移除)导入 4 个完整但未集成的模块:
- `agent_router.py` — 任务复杂度分类 + 模型路由
- `model_switcher.py` — 运行时模型热切换
- `agent_reflection.py` — 任务后自省
diff --git a/minicode/cli_commands.py b/minicode/cli_commands.py
index bf90626..e940536 100644
--- a/minicode/cli_commands.py
+++ b/minicode/cli_commands.py
@@ -52,6 +52,7 @@ class SlashCommand:
SlashCommand("/config", "/config", "Show configuration diagnostics and validation."),
SlashCommand("/history", "/history", "Show recent prompt history from ~/.mini-code/history.json."),
SlashCommand("/clear", "/clear", "Clear the current transcript view."),
+ SlashCommand("/collapse", "/collapse", "Collapse all expanded tool-output blocks in the transcript."),
SlashCommand("/retry", "/retry", "Retry the last natural-language prompt in this session."),
SlashCommand("/session", "/session", "Inspect the active session, runtime, checkpoints, and recent transcript."),
SlashCommand("/session", "/session ", "Inspect a saved session for the current workspace."),
diff --git a/minicode/context_compactor.py b/minicode/context_compactor.py
index d826907..fe7eba1 100644
--- a/minicode/context_compactor.py
+++ b/minicode/context_compactor.py
@@ -139,6 +139,7 @@ class AutoCompactConfig:
enabled: bool = True
threshold_ratio: float = 0.85 # 85% of context window
circuit_breaker_limit: int = 3
+ circuit_breaker_recovery_seconds: float = 300.0 # auto-recover after this long
session_memory_enabled: bool = True
min_keep_tokens: int = 10000 # At least 10k tokens after compact
min_keep_messages: int = 5 # At least 5 text messages
@@ -192,7 +193,14 @@ def check_and_replace(
if msg.get("role") != "tool_result":
continue
- content = msg.get("content", "")
+ content = msg.get("content")
+ # Normalize content to a string — tool_result content can be None
+ # (no output) or a non-string (structured result). Without this,
+ # len(None) / len(list) crashes or mis-sizes (TS normalizes these).
+ if not isinstance(content, str):
+ content = "" if content is None else str(content)
+ modified[i] = {**msg, "content": content}
+ msg = modified[i]
content_size = len(content)
if content_size <= self._persist_threshold:
@@ -610,6 +618,7 @@ def __init__(
self._memory = memory_manager
self._estimate = estimate_fn or (lambda m: len(str(m)) // 4)
self._consecutive_failures = 0
+ self._last_failure_time: float = 0.0
self._boundaries: list[CompactBoundary] = []
self._suppressed_until: float = 0.0 # Warning suppression after compact
self._session_memory_engine = SessionMemoryCompactEngine(memory_manager)
@@ -625,8 +634,33 @@ def blocking_limit(self) -> int:
@property
def is_tripped(self) -> bool:
+ """Pure predicate: are we at/over the failure threshold? No side effects
+ (callers like ReactiveCompactEngine read this as a plain check)."""
return self._consecutive_failures >= self._config.circuit_breaker_limit
+ def _maybe_auto_recover(self) -> bool:
+ """If tripped but past the recovery timeout, reset to half-open.
+
+ Returns True if a recovery reset just happened. Without this, once
+ tripped, ``should_trigger`` always returns False, ``_on_success`` never
+ runs, and the breaker stays open for the whole session.
+ """
+ if self._consecutive_failures < self._config.circuit_breaker_limit:
+ return False
+ recovery = self._config.circuit_breaker_recovery_seconds
+ if (
+ recovery > 0
+ and self._last_failure_time > 0
+ and time.time() - self._last_failure_time >= recovery
+ ):
+ self._consecutive_failures = 0
+ self._last_failure_time = 0.0
+ logger.info(
+ "Auto Compact circuit breaker auto-recovered after %.0fs", recovery
+ )
+ return True
+ return False
+
def should_trigger(
self,
messages: list[dict[str, Any]],
@@ -636,7 +670,10 @@ def should_trigger(
if not self._config.enabled:
return False
if self.is_tripped:
- return False
+ # Half-open auto-recovery: if past the recovery timeout, allow a
+ # retry; otherwise stay blocked.
+ if not self._maybe_auto_recover():
+ return False
usage = token_usage or sum(self._estimate(m) for m in messages)
return usage >= self.threshold_tokens
@@ -805,6 +842,7 @@ def _on_success(self, boundary: CompactBoundary | None) -> None:
def _on_failure(self) -> None:
self._consecutive_failures += 1
+ self._last_failure_time = time.time()
logger.warning(
"Auto Compact failure #%d/%d (circuit breaker)",
self._consecutive_failures,
diff --git a/minicode/context_manager.py b/minicode/context_manager.py
index 39645bc..189fe9f 100644
--- a/minicode/context_manager.py
+++ b/minicode/context_manager.py
@@ -48,6 +48,63 @@
"default": 128_000, # Fallback
}
+
+# ---------------------------------------------------------------------------
+# Model context window resolution (port of TS getModelContextWindow).
+# Case-insensitive substring matching with an output reserve, so model ids like
+# "claude-opus-4-6", "CLAUDE-...", or "anthropic/claude-3-5-sonnet-latest"
+# resolve correctly instead of falling through to the 128k default.
+# ---------------------------------------------------------------------------
+
+
+class ModelContextWindow:
+ __slots__ = ("context_window", "output_reserve", "effective_input")
+
+ def __init__(self, context_window: int, output_reserve: int) -> None:
+ self.context_window = context_window
+ self.output_reserve = output_reserve
+ self.effective_input = context_window - output_reserve
+
+
+_UNKNOWN_MODEL_CONTEXT = (128_000, 8_000)
+
+_MODEL_CONTEXT_RULES: list[tuple[list[str], int, int]] = [
+ (["claude-opus-4-6", "claude opus 4.6", "opus-4-6"], 200_000, 16_000),
+ (["claude-sonnet-4-6", "claude sonnet 4.6", "sonnet-4-6"], 200_000, 16_000),
+ (["claude-haiku-4-5", "claude haiku 4.5", "haiku-4-5"], 200_000, 16_000),
+ (["claude-opus-4-1", "claude opus 4.1", "opus-4-1", "claude-opus-4", "claude opus 4", "opus-4"], 200_000, 16_000),
+ (["claude-sonnet-4", "claude sonnet 4", "sonnet-4"], 200_000, 16_000),
+ (["claude-3-7-sonnet", "claude 3.7 sonnet", "3-7-sonnet"], 200_000, 8_192),
+ (["claude-3-5-sonnet", "claude 3.5 sonnet", "3-5-sonnet", "claude-3-sonnet"], 200_000, 8_192),
+ (["claude-3-5-haiku", "claude 3.5 haiku", "3-5-haiku"], 200_000, 8_192),
+ (["claude-3-opus", "claude 3 opus"], 200_000, 4_096),
+ (["claude-3-haiku", "claude 3 haiku"], 200_000, 4_096),
+ (["gpt-5-codex", "gpt-5.4", "gpt-5.2", "gpt-5.1", "gpt-5"], 128_000, 16_000),
+ (["o4-mini", "o3", "o1-pro", "o1"], 200_000, 16_000),
+ (["gpt-4.1-mini", "gpt-4.1-nano", "gpt-4.1"], 1_047_576, 16_000),
+ (["gpt-4o-mini", "gpt-4o"], 128_000, 16_384),
+ (["gpt-4"], 128_000, 8_192),
+ (["gemini-2.5-pro", "gemini 2.5 pro"], 1_048_576, 16_000),
+ (["gemini-2.5-flash-lite", "gemini 2.5 flash-lite"], 1_048_576, 16_000),
+ (["gemini-2.5-flash", "gemini 2.5 flash"], 1_048_576, 16_000),
+ (["deepseek-reasoner"], 128_000, 16_000),
+ (["deepseek-chat"], 128_000, 4_000),
+]
+
+
+def get_model_context_window(model: str) -> ModelContextWindow:
+ """Resolve a model id to its context window + output reserve.
+
+ Case-insensitive substring match (port of TS getModelContextWindow).
+ Falls back to 128k / 8k reserve for unknown models.
+ """
+ normalized = (model or "").strip().lower()
+ for patterns, context_window, output_reserve in _MODEL_CONTEXT_RULES:
+ if any(pattern in normalized for pattern in patterns):
+ return ModelContextWindow(context_window, output_reserve)
+ context_window, output_reserve = _UNKNOWN_MODEL_CONTEXT
+ return ModelContextWindow(context_window, output_reserve)
+
# Auto-compaction threshold (95% of context window)
AUTOCOMPACT_THRESHOLD = 0.95
@@ -110,8 +167,18 @@ def estimate_tokens(text: str) -> int:
def estimate_message_tokens(message: dict[str, Any]) -> int:
"""Estimate tokens for a single message."""
+ # Match TS semantics: a message with no content and no tool input contributes
+ # zero tokens (TS estimateMessageTokens is ceil(0/ratio) == 0). We keep the
+ # CJK-aware estimate_tokens() for non-empty content — it is more accurate for
+ # Chinese/Japanese text than TS's plain char/3.5 ratio.
+ content = message.get("content", "")
+ has_input = bool(message.get("input"))
+ content_str = content if isinstance(content, str) else ""
+ if not content_str and not has_input:
+ return 0
+
tokens = 0
-
+
# Role overhead
role = message.get("role", "")
if role == "system":
@@ -145,6 +212,96 @@ def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
return sum(estimate_message_tokens(msg) for msg in messages)
+# ---------------------------------------------------------------------------
+# Provider-usage-aware token accounting (port of TS tokenCountWithEstimation
+# and computeContextStats). NOTE: Python does not currently record provider
+# usage on individual messages, so the provider_usage path falls back to
+# estimate_only until that wiring is added; the estimate_only + warning-level
+# math is still correct and useful.
+# ---------------------------------------------------------------------------
+
+
+def _message_provider_usage(message: dict[str, Any]) -> dict[str, Any] | None:
+ role = message.get("role")
+ if role not in ("assistant", "assistant_progress", "assistant_tool_call"):
+ return None
+ usage = message.get("providerUsage") or message.get("provider_usage")
+ if usage and not message.get("usageStale") and not message.get("usage_stale"):
+ return usage
+ return None
+
+
+def _stale_usage_reason(messages: list[dict[str, Any]]) -> str | None:
+ for message in messages:
+ if (
+ message.get("role") in ("assistant", "assistant_progress", "assistant_tool_call")
+ and (message.get("providerUsage") or message.get("provider_usage"))
+ and (message.get("usageStale") or message.get("usage_stale"))
+ ):
+ return message.get("usageStaleReason") or message.get("usage_stale_reason") or "provider usage was marked stale"
+ return None
+
+
+def token_count_with_estimation(messages: list[dict[str, Any]]) -> dict[str, Any]:
+ """Provider-usage-aware token counting (port of TS tokenCountWithEstimation)."""
+ for i in range(len(messages) - 1, -1, -1):
+ usage = _message_provider_usage(messages[i])
+ if not usage:
+ continue
+ total_usage = int(usage.get("totalTokens", usage.get("total_tokens", 0)) or 0)
+ estimated = estimate_messages_tokens(messages[i + 1:])
+ boundary_id = messages[i].get("toolUseId") or messages[i].get("tool_use_id")
+ return {
+ "total_tokens": total_usage + estimated,
+ "provider_usage_tokens": total_usage,
+ "estimated_tokens": estimated,
+ "source": "provider_usage_plus_estimate" if estimated > 0 else "provider_usage",
+ "is_exact": estimated == 0,
+ "usage_boundary": {"message_index": i, "message_id": boundary_id},
+ "stale": False,
+ "reason": None,
+ }
+
+ reason = _stale_usage_reason(messages)
+ estimated = estimate_messages_tokens(messages)
+ return {
+ "total_tokens": estimated,
+ "provider_usage_tokens": 0,
+ "estimated_tokens": estimated,
+ "source": "estimate_only",
+ "is_exact": False,
+ "stale": bool(reason),
+ "reason": reason or "no provider usage available",
+ }
+
+
+def compute_context_stats(messages: list[dict[str, Any]], model: str) -> dict[str, Any]:
+ """Context stats with utilization + warning level (port of TS computeContextStats)."""
+ window = get_model_context_window(model)
+ accounting = token_count_with_estimation(messages)
+ effective = window.effective_input or 1
+ utilization = min(1.0, accounting["total_tokens"] / effective)
+ if utilization >= 0.95:
+ warning_level = "blocked"
+ elif utilization >= 0.85:
+ warning_level = "critical"
+ elif utilization >= 0.50:
+ warning_level = "warning"
+ else:
+ warning_level = "normal"
+ return {
+ "estimated_tokens": accounting["estimated_tokens"],
+ "total_tokens": accounting["total_tokens"],
+ "provider_usage_tokens": accounting["provider_usage_tokens"],
+ "context_window": window.context_window,
+ "effective_input": window.effective_input,
+ "utilization": utilization,
+ "warning_level": warning_level,
+ "accounting": accounting,
+ }
+
+
+
@dataclass
class _ExtractedInfo:
"""Information extracted from removed messages during summarization."""
@@ -419,16 +576,12 @@ class ContextManager:
def __post_init__(self):
if self.context_window == 0:
- self.context_window = DEFAULT_CONTEXT_WINDOWS.get(
- self.model, DEFAULT_CONTEXT_WINDOWS["default"]
- )
-
+ self.context_window = get_model_context_window(self.model).context_window
+
def update_model(self, model: str) -> None:
"""Update model and adjust context window."""
self.model = model
- self.context_window = DEFAULT_CONTEXT_WINDOWS.get(
- model, DEFAULT_CONTEXT_WINDOWS["default"]
- )
+ self.context_window = get_model_context_window(model).context_window
def add_message(self, message: dict[str, Any]) -> None:
"""Add a message and update tracking."""
diff --git a/minicode/headless.py b/minicode/headless.py
index 8162674..4c0a2f8 100644
--- a/minicode/headless.py
+++ b/minicode/headless.py
@@ -50,11 +50,44 @@ def _write_headless_messages_trace(
)
-def run_headless(prompt: str | None = None) -> str:
+def _allow_edits_requested(cli_flag: bool = False) -> bool:
+ """True if headless should auto-approve edits/commands/out-of-cwd access.
+
+ Opt-in, non-interactive CI mode. Gated by the --allow-edits flag or the
+ MINI_CODE_ALLOW_EDITS env var (1/true/yes/on)."""
+ if cli_flag:
+ return True
+ return os.getenv("MINI_CODE_ALLOW_EDITS", "").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ )
+
+
+def _make_auto_approve_prompt():
+ """Build a non-interactive permission prompt that grants every request for
+ the current run. Decisions are session-scoped (no persistence to the
+ permission store): edits via allow_all_turn, paths/commands via allow_once.
+ """
+
+ def _auto_approve(request: dict) -> dict:
+ if request.get("kind") == "edit":
+ return {"decision": "allow_all_turn"}
+ return {"decision": "allow_once"}
+
+ return _auto_approve
+
+
+def run_headless(prompt: str | None = None, allow_edits: bool = False) -> str:
"""Run a single agent turn in headless mode and return the response.
Args:
prompt: The user message to send. If None, reads from stdin.
+ allow_edits: If True (or via MINI_CODE_ALLOW_EDITS), auto-approve file
+ edits, commands, and out-of-cwd access for this non-interactive run.
+ Required for headless to modify files (edits otherwise need TTY
+ approval).
Returns:
The assistant's response text.
@@ -66,9 +99,12 @@ def run_headless(prompt: str | None = None) -> str:
from minicode.permissions import PermissionManager
from minicode.prompt import build_system_prompt
from minicode.tools import create_default_tool_registry
- from minicode.logging_config import setup_logging, get_logger
+ from minicode.logging_config import setup_logging, get_logger, structured_logging_requested
- setup_logging(level=os.environ.get("MINI_CODE_LOG_LEVEL", "WARNING"))
+ setup_logging(
+ level=os.environ.get("MINI_CODE_LOG_LEVEL", "WARNING"),
+ structured=structured_logging_requested(),
+ )
logger = get_logger("headless")
# Read prompt from stdin if not provided
@@ -89,12 +125,21 @@ def run_headless(prompt: str | None = None) -> str:
try:
runtime = load_runtime_config(cwd)
except Exception as exc: # noqa: BLE001
+ # Persist the failure to the log file (issue #5), not just stderr.
+ logger.error("Config load failed: %s", exc, exc_info=True)
print(f"Config error: {exc}", file=sys.stderr)
sys.exit(1)
# Initialize components
tools = create_default_tool_registry(cwd, runtime=runtime)
- permissions = PermissionManager(cwd, prompt=None)
+ auto_approve = _allow_edits_requested(cli_flag=allow_edits)
+ if auto_approve:
+ logger.warning(
+ "Headless --allow-edits is active: file edits, commands, and "
+ "out-of-cwd access will be auto-approved for this run "
+ "(non-interactive CI mode; approvals are session-scoped)."
+ )
+ permissions = PermissionManager(cwd, prompt=_make_auto_approve_prompt() if auto_approve else None)
memory_mgr = MemoryManager(project_root=Path(cwd))
model = create_model_adapter(
@@ -170,9 +215,11 @@ def run_headless(prompt: str | None = None) -> str:
def main() -> None:
"""CLI entry point for headless mode."""
- # Get prompt from command line args or stdin
- prompt = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else None
- response = run_headless(prompt)
+ # Strip the --allow-edits flag (handled separately); everything else is the prompt.
+ allow_edits = "--allow-edits" in sys.argv
+ prompt_args = [arg for arg in sys.argv[1:] if arg != "--allow-edits"]
+ prompt = " ".join(prompt_args) if prompt_args else None
+ response = run_headless(prompt, allow_edits=allow_edits)
print(response)
diff --git a/minicode/local_tool_shortcuts.py b/minicode/local_tool_shortcuts.py
index 0f0d12d..59ebb05 100644
--- a/minicode/local_tool_shortcuts.py
+++ b/minicode/local_tool_shortcuts.py
@@ -2,8 +2,10 @@
def parse_local_tool_shortcut(user_input: str) -> dict | None:
- if user_input.startswith("/ls"):
- directory = user_input.replace("/ls", "", 1).strip()
+ # `/ls` must be an exact command or followed by a space, so that adjacent
+ # text like `/lsfoo` is NOT misparsed as a list_files shortcut (TS parity).
+ if user_input == "/ls" or user_input.startswith("/ls "):
+ directory = user_input[len("/ls") :].strip()
return {"toolName": "list_files", "input": {"path": directory} if directory else {}}
if user_input.startswith("/grep "):
@@ -23,7 +25,7 @@ def parse_local_tool_shortcut(user_input: str) -> dict | None:
if user_input.startswith("/write "):
payload = user_input[len("/write ") :]
target_path, separator, content = payload.partition("::")
- if not separator:
+ if not separator or not target_path.strip():
return None
return {
"toolName": "write_file",
@@ -33,7 +35,7 @@ def parse_local_tool_shortcut(user_input: str) -> dict | None:
if user_input.startswith("/modify "):
payload = user_input[len("/modify ") :]
target_path, separator, content = payload.partition("::")
- if not separator:
+ if not separator or not target_path.strip():
return None
return {
"toolName": "modify_file",
@@ -43,7 +45,7 @@ def parse_local_tool_shortcut(user_input: str) -> dict | None:
if user_input.startswith("/edit "):
payload = user_input[len("/edit ") :]
parts = payload.split("::")
- if len(parts) != 3:
+ if len(parts) != 3 or not parts[0].strip():
return None
target_path, search, replace = parts
return {
diff --git a/minicode/logging_config.py b/minicode/logging_config.py
index 159b7c4..566e95f 100644
--- a/minicode/logging_config.py
+++ b/minicode/logging_config.py
@@ -3,9 +3,9 @@
Provides structured logging with:
- 分级日志(DEBUG/INFO/WARNING/ERROR)
- 控制台和文件输出
-- 日志轮转(按大小 + 按时间,防止无限增长)
-- 结构化 JSON 日志(可选,便于机器解析)
-- 关键路径日志点(API 调用、工具执行、权限检查)
+- 日志轮转(按大小,防止无限增长)——这是当前唯一的正式轮转策略
+- 结构化 JSON 日志(可选,便于机器解析;见 --structured-logs / MINI_CODE_LOG_STRUCTURED)
+- 关键路径日志点(API 调用、工具执行、权限检查、会话事件)
"""
from __future__ import annotations
@@ -13,6 +13,7 @@
import json
import logging
import logging.handlers
+import os
import sys
from datetime import datetime, timezone
from typing import Any
@@ -26,11 +27,9 @@
CONSOLE_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
FILE_FORMAT = "%(asctime)s [%(levelname)s] %(name)s %(filename)s:%(lineno)d: %(message)s"
-# 轮转配置
+# 轮转配置(按大小轮转是当前唯一正式策略)
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per file
LOG_BACKUP_COUNT = 5 # Keep 5 rotated files (50 MB total max)
-LOG_ROTATION_WHEN = "midnight" # Also rotate at midnight
-LOG_ROTATION_INTERVAL = 1 # Every 1 day
# ---------------------------------------------------------------------------
@@ -140,16 +139,33 @@ def setup_logging(
def get_logger(name: str) -> logging.Logger:
"""获取子模块 logger。
-
+
Args:
name: 子模块名称(如 'agent_loop', 'tools.read_file')
-
+
Returns:
配置好的子 logger
"""
return logging.getLogger(f"minicode.{name}")
+def structured_logging_requested(cli_flag: bool = False) -> bool:
+ """是否应启用结构化(JSON)日志。
+
+ 启用条件:显式 CLI 标志(``--structured-logs``)为真,或环境变量
+ ``MINI_CODE_LOG_STRUCTURED`` 取真值(``1``/``true``/``yes``/``on``)。
+ 统一在入口处调用,避免各模块各自判断。
+ """
+ if cli_flag:
+ return True
+ return os.getenv("MINI_CODE_LOG_STRUCTURED", "").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ )
+
+
# ---------------------------------------------------------------------------
# Structured logging helpers
# ---------------------------------------------------------------------------
diff --git a/minicode/main.py b/minicode/main.py
index 0af136f..9d9b65d 100644
--- a/minicode/main.py
+++ b/minicode/main.py
@@ -101,6 +101,16 @@ def _prompt(request: dict) -> dict:
def _configure_stdio_for_unicode() -> None:
+ stdin_reconfigure = getattr(sys.stdin, "reconfigure", None)
+ if stdin_reconfigure is not None:
+ try:
+ # PowerShell pipelines may prefix UTF-8 BOM bytes on stdin. Decode
+ # with utf-8-sig so local slash commands are not polluted by BOM
+ # artifacts like "锘?/memory" and accidentally routed to the model.
+ stdin_reconfigure(encoding="utf-8-sig", errors="replace")
+ except Exception:
+ pass
+
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
@@ -307,14 +317,22 @@ def main() -> None:
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Set logging level (default: WARNING)",
)
+ parser.add_argument(
+ "--structured-logs",
+ action="store_true",
+ help="Emit JSON structured logs (also enabled via MINI_CODE_LOG_STRUCTURED=true)",
+ )
args, remaining_argv = parser.parse_known_args()
if remaining_argv and not any(not arg.startswith("--") for arg in remaining_argv):
parser.error(f"unrecognized arguments: {' '.join(remaining_argv)}")
# Initialize logging
- from minicode.logging_config import setup_logging
- setup_logging(level=args.log_level)
+ from minicode.logging_config import setup_logging, structured_logging_requested
+ setup_logging(
+ level=args.log_level,
+ structured=structured_logging_requested(cli_flag=args.structured_logs),
+ )
# Run config validation if requested
if args.validate_config:
diff --git a/minicode/mcp.py b/minicode/mcp.py
index f171733..a9aaafa 100644
--- a/minicode/mcp.py
+++ b/minicode/mcp.py
@@ -2,6 +2,7 @@
import json
import os
+import shutil
import subprocess
import threading
from dataclasses import asdict, dataclass
@@ -24,6 +25,10 @@
'ruby', 'gem', 'dotnet', 'curl', 'wget',
}
+# Windows 可执行后缀。node 工具链的 npx/npm 等以 .cmd 批处理包装器形式存在,
+# 校验白名单前需要剥离这些后缀,否则 "npx.cmd" 会被误判为不在白名单中。
+_WIN_EXEC_EXTS = (".exe", ".cmd", ".bat")
+
JsonRpcProtocol = str
@@ -56,11 +61,12 @@ def _validate_mcp_command(command: str) -> None:
if '..' in normalized or '~' in normalized:
raise RuntimeError("Invalid MCP command: contains path traversal characters")
- # 提取命令的基本名称
+ # 提取命令的基本名称,剥离 Windows 可执行后缀(.exe/.cmd/.bat)
base_command = Path(command).name.lower()
- # 处理 .exe 后缀
- if base_command.endswith('.exe'):
- base_command = base_command[:-4]
+ for _exec_ext in _WIN_EXEC_EXTS:
+ if base_command.endswith(_exec_ext):
+ base_command = base_command[: -len(_exec_ext)]
+ break
# 如果是绝对路径,需要额外验证
if Path(command).is_absolute():
@@ -124,6 +130,29 @@ def _validate_mcp_args(args: list[str]) -> None:
)
+def _prepare_spawn(command: str, args: list[str]) -> tuple[list[str] | str, dict]:
+ """把 MCP 命令解析成可被 subprocess 启动的形式。
+
+ Windows 上 npx/npm 等是 ``.cmd`` 批处理包装器。``subprocess`` 在 ``shell=False``
+ 时底层走 ``CreateProcess``,它既不能直接运行 ``.cmd``/``.bat``,又不会按 PATHEXT
+ 搜索,于是裸命令 ``npx`` 会被当成不存在的 ``npx.exe``,报“命令未找到: npx”
+ (GitHub issue #7)。这里用 :func:`shutil.which`(遵循 PATHEXT)解析出真正的
+ 可执行文件,并把批处理包装器通过 ``cmd.exe``(即 ``shell=True``)启动。参数已经
+ 过 :func:`_validate_mcp_args` 校验不含 shell 元字符,因此 ``shell=True`` 是安全的。
+
+ 返回 ``(spawn_exec, extra_popen_kwargs)``:``spawn_exec`` 在 ``shell=False`` 时为
+ 列表,在 ``shell=True`` 时为单条命令行字符串。
+ """
+ if os.name == "nt":
+ resolved = shutil.which(command)
+ if resolved:
+ if resolved.lower().endswith((".cmd", ".bat")):
+ # 批处理包装器需要 cmd.exe 解释执行
+ return subprocess.list2cmdline([resolved, *args]), {"shell": True}
+ return [resolved, *args], {}
+ return [command, *args], {}
+
+
def _normalize_input_schema(schema: dict[str, Any] | None) -> dict[str, Any]:
return schema if isinstance(schema, dict) else {"type": "object", "additionalProperties": True}
@@ -304,9 +333,13 @@ def _spawn_process(self) -> None:
# Prevent a console window from popping up for the child process
CREATE_NO_WINDOW = 0x08000000
popen_kwargs["creationflags"] = CREATE_NO_WINDOW
+
+ spawn_args = list(self.config.get("args", []) or [])
+ spawn_exec, spawn_extra = _prepare_spawn(command, spawn_args)
+ popen_kwargs.update(spawn_extra)
try:
self.process = subprocess.Popen( # noqa: S603
- [command, *list(self.config.get("args", []) or [])],
+ spawn_exec,
cwd=str(process_cwd),
env=env,
stdin=subprocess.PIPE,
diff --git a/minicode/micro_compact.py b/minicode/micro_compact.py
new file mode 100644
index 0000000..9ee0f53
--- /dev/null
+++ b/minicode/micro_compact.py
@@ -0,0 +1,300 @@
+"""Micro-compaction layer for context management.
+
+Lightweight pre-API context pressure relief that clears stale tool results
+without invalidating provider prompt caches. Inspired by Claude Code's
+microCompact / time-based microcompact pipeline.
+
+Design principles (inspired by CC):
+ - No API calls — purely local message trimming
+ - Preserve tool_use / tool_result API invariants (pairs are never split)
+ - Leave the most recent N results untouched
+ - Cache-aware: only remove result content, never restructure message IDs
+
+Usage (inside agent loop before model.next()):
+ mc = MicroCompactor()
+ messages = mc.compact(messages)
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from typing import Any
+
+# ── Compressible tool sets ──────────────────────────────────────────────────
+# These tools produce content that is safe to discard when stale without
+# affecting correctness in subsequent turns (the agent can re-read / re-search
+# if it needs the data again).
+
+COMPRESSIBLE_READ_ONLY_TOOLS: frozenset[str] = frozenset({
+ "read_file",
+ "list_files",
+ "grep_files",
+ "web_search",
+ "web_fetch",
+ "find_symbols",
+ "find_references",
+ "get_ast_info",
+ "code_review",
+ "diff_viewer",
+ "file_tree",
+ "json_parse",
+ "csv_parse",
+ "test_runner",
+})
+
+COMPRESSIBLE_WRITE_TOOLS: frozenset[str] = frozenset({
+ "write_file",
+ "edit_file",
+ "modify_file",
+ "patch_file",
+})
+
+# Tool results that should NEVER be compressed (they carry persistent state)
+NON_COMPRESSIBLE_TOOLS: frozenset[str] = frozenset({
+ "todo_write",
+ "task",
+ "memory",
+})
+
+
+@dataclass
+class MicroCompactionStats:
+ messages_before: int = 0
+ messages_after: int = 0
+ tokens_estimated_freed: int = 0
+ reason: str = ""
+
+
+@dataclass
+class MicroCompactorConfig:
+ """Configuration for the micro-compaction pipeline."""
+
+ # Time-based: if the gap between now and the last main-loop assistant message
+ # exceeds this many seconds, clear all older compressible results.
+ idle_threshold_seconds: int = 3600 # 60 minutes (matching CC's default)
+
+ # Count-based: keep at most this many recent compressible result groups
+ # intact. Older groups are candidates for trimming.
+ keep_recent_groups: int = 5 # matching CC's default
+
+ # Budget: maximum token budget for tool results before triggering.
+ # When the estimated total of compressible tool results exceeds this,
+ # the micro-compactor trims older groups regardless of time.
+ tool_result_budget_tokens: int = 40_000
+
+ # Whether time-based micro-compaction is enabled.
+ time_based_enabled: bool = True
+
+ # Whether count-based (budget-aware) micro-compaction is enabled.
+ budget_based_enabled: bool = True
+
+
+@dataclass
+class MicroCompactor:
+ """Lightweight tool-result trimmer that runs before each API call.
+
+ Does NOT call any external API. Operates purely on the in-memory
+ message list and only strips content from tool_result messages whose
+ corresponding tool_use is in the compressible set.
+ """
+
+ config: MicroCompactorConfig = field(default_factory=MicroCompactorConfig)
+ # Timestamp of the last main-loop assistant message seen.
+ _last_assistant_ts: float = field(default_factory=time.time)
+
+ def compact(
+ self,
+ messages: list[dict[str, Any]],
+ *,
+ current_time: float | None = None,
+ ) -> tuple[list[dict[str, Any]], MicroCompactionStats]:
+ """Run the micro-compaction pipeline and return (messages, stats).
+
+ Pipeline order (cheapest first):
+ 1. Time-based — if idle > threshold, mass-clear old results.
+ 2. Budget-based — if tool results exceed budget, trim oldest.
+ """
+ if not messages:
+ return messages, MicroCompactionStats()
+
+ now = current_time or time.time()
+ stats = MicroCompactionStats(messages_before=len(messages))
+
+ # ── 1. Time-based check ──────────────────────────────────────────
+ if self.config.time_based_enabled:
+ idle_sec = now - self._last_assistant_ts
+ if idle_sec >= self.config.idle_threshold_seconds:
+ result = self._compact_time_based(messages, now)
+ if result is not None:
+ messages = result
+ stats.reason = f"time_based (idle={idle_sec:.0f}s)"
+ stats.messages_after = len(messages)
+ return messages, stats
+
+ # ── 2. Budget-based check ────────────────────────────────────────
+ if self.config.budget_based_enabled:
+ result = self._compact_budget_based(messages)
+ if result is not None:
+ messages = result
+ stats.reason = "budget_exceeded"
+ stats.messages_after = len(messages)
+ return messages, stats
+
+ stats.messages_after = len(messages)
+ stats.reason = "no_action"
+ return messages, stats
+
+ def update_assistant_timestamp(self, ts: float | None = None) -> None:
+ """Record that a main-loop assistant message was just produced."""
+ self._last_assistant_ts = ts or time.time()
+
+ # ── internal helpers ────────────────────────────────────────────────────
+
+ @staticmethod
+ def _is_compressible(result_msg: dict[str, Any]) -> bool:
+ """Check if a tool_result message references a compressible tool."""
+ data = result_msg.get("data", {}) or {}
+ tool_name = data.get("tool_name") or data.get("name") or ""
+ if not tool_name:
+ return False
+ return (
+ tool_name in COMPRESSIBLE_READ_ONLY_TOOLS
+ or tool_name in COMPRESSIBLE_WRITE_TOOLS
+ ) and tool_name not in NON_COMPRESSIBLE_TOOLS
+
+ @staticmethod
+ def _estimated_tokens(msg: dict[str, Any]) -> int:
+ """Rough token count for a message (chars / 4)."""
+ content = msg.get("content", "") or ""
+ return max(1, len(str(content)) // 4)
+
+ def _compact_time_based(
+ self,
+ messages: list[dict[str, Any]],
+ now: float,
+ ) -> list[dict[str, Any]] | None:
+ """Clear compressible tool results older than the keep boundary."""
+ # Find the last N assistant messages (main-loop, not parallel-split).
+ assistant_indices = [
+ i for i, m in enumerate(messages)
+ if m.get("role") == "assistant"
+ ]
+ if len(assistant_indices) <= self.config.keep_recent_groups:
+ return None # Not enough groups to trim
+
+ # The keep_boundary is the index of the keep_recent_groups-th
+ # from the end.
+ keep_start = assistant_indices[-self.config.keep_recent_groups]
+ trimmed_count = 0
+
+ new_messages: list[dict[str, Any]] = []
+ for i, msg in enumerate(messages):
+ if i < keep_start:
+ role = msg.get("role", "")
+ if role == "tool_result" and self._is_compressible(msg):
+ trimmed_count += 1
+ continue # drop the result
+ if role == "tool_use":
+ # Check if the paired tool_result was just dropped
+ name = (msg.get("data", {}) or {}).get("tool_name") or (msg.get("data", {}) or {}).get("name") or ""
+ if name in COMPRESSIBLE_READ_ONLY_TOOLS | COMPRESSIBLE_WRITE_TOOLS:
+ trimmed_count += 1
+ continue # drop orphaned tool_use
+ # Also drop tool_result messages that are paired with
+ # compressible tools even before the assistant boundary —
+ # this is done by checking both the role and compressibility.
+ new_messages.append(msg)
+
+ if trimmed_count == 0:
+ return None
+ return new_messages
+
+ def _compact_budget_based(
+ self,
+ messages: list[dict[str, Any]],
+ ) -> list[dict[str, Any]] | None:
+ """Trim oldest compressible tool results when total exceeds budget.
+
+ When the budget is exceeded, the OLDEST compressible results (least
+ valuable — the model has already built on them) are dropped first, and
+ the more-recent ones are kept, until the kept total fits the budget.
+ Compressible results inside the protected recent groups are never touched.
+ """
+ assistant_indices = [
+ i for i, m in enumerate(messages)
+ if m.get("role") == "assistant"
+ ]
+ # Never trim compressible results inside the most recent keep_recent_groups.
+ keep_from = max(
+ 0,
+ assistant_indices[-self.config.keep_recent_groups]
+ if len(assistant_indices) > self.config.keep_recent_groups
+ else 0,
+ )
+
+ # Candidate compressible tool results before the keep boundary (oldest first).
+ candidates = [
+ i for i, m in enumerate(messages)
+ if i < keep_from and m.get("role") == "tool_result" and self._is_compressible(m)
+ ]
+ budget = self.config.tool_result_budget_tokens
+ total = sum(self._estimated_tokens(messages[i]) for i in candidates)
+ if total <= budget:
+ return None # Under budget
+
+ # Drop OLDEST candidates first until the kept total fits the budget.
+ trim_indices: set[int] = set()
+ running = total
+ for i in candidates: # oldest first
+ if running <= budget:
+ break
+ running -= self._estimated_tokens(messages[i])
+ trim_indices.add(i)
+
+ # Drop orphaned tool_use messages whose paired result was trimmed
+ # (paired by data.tool_id), so we don't leave dangling tool calls.
+ trimmed_tool_ids = {
+ (messages[i].get("data", {}) or {}).get("tool_id") for i in trim_indices
+ }
+
+ updated: list[dict[str, Any]] = []
+ trimmed = 0
+ for i, msg in enumerate(messages):
+ if i in trim_indices:
+ trimmed += 1
+ continue
+ if i < keep_from and msg.get("role") == "tool_use":
+ data = msg.get("data", {}) or {}
+ name = data.get("tool_name") or data.get("name") or ""
+ if (
+ name in COMPRESSIBLE_READ_ONLY_TOOLS | COMPRESSIBLE_WRITE_TOOLS
+ and data.get("tool_id") in trimmed_tool_ids
+ ):
+ trimmed += 1
+ continue
+ updated.append(msg)
+
+ return updated if trimmed > 0 else None
+
+
+# ── Module-level convenience ─────────────────────────────────────────────────
+
+_default_compactor: MicroCompactor | None = None
+
+
+def get_micro_compactor() -> MicroCompactor:
+ """Get or create the module-level micro-compactor singleton."""
+ global _default_compactor
+ if _default_compactor is None:
+ _default_compactor = MicroCompactor()
+ return _default_compactor
+
+
+def micro_compact(
+ messages: list[dict[str, Any]],
+ *,
+ current_time: float | None = None,
+) -> tuple[list[dict[str, Any]], MicroCompactionStats]:
+ """Convenience: run micro-compaction on a message list."""
+ return get_micro_compactor().compact(messages, current_time=current_time)
diff --git a/minicode/openai_adapter.py b/minicode/openai_adapter.py
index 08a01e8..8d6aa2e 100644
--- a/minicode/openai_adapter.py
+++ b/minicode/openai_adapter.py
@@ -11,7 +11,8 @@
import time
import urllib.error
import urllib.request
-from typing import Any, Callable
+from dataclasses import dataclass
+from typing import Any, Callable, Literal
from minicode.api_retry import RETRYABLE_STATUS, calculate_backoff
from minicode.cost_tracker import calculate_cost
@@ -19,7 +20,8 @@
from minicode.types import AgentStep, StepDiagnostics
DEFAULT_MAX_RETRIES = 4
-OPENAI_MODELS = {"gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o1-mini", "o3-mini"}
+OPENAI_MODELS = {"gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-5.5", "gpt5.5", "o1", "o1-mini", "o3-mini"}
+DEFAULT_OPENAI_USER_AGENT = "MiniCode-Python/0.5.0 (OpenAI-Compatible Adapter)"
def _is_openai_model(model: str) -> bool:
@@ -29,7 +31,7 @@ def _is_openai_model(model: str) -> bool:
if model_lower in OPENAI_MODELS:
return True
# Prefix match for versioned models
- for prefix in ("gpt-4", "gpt-3.5", "o1-", "o3-", "chatgpt-"):
+ for prefix in ("gpt-5", "gpt-4", "gpt-3.5", "gpt5", "o1-", "o3-", "chatgpt-"):
if model_lower.startswith(prefix):
return True
# Check if explicitly using OpenAI base URL
@@ -39,22 +41,70 @@ def _is_openai_model(model: str) -> bool:
return False
-def _get_openai_base_url(runtime: dict) -> str:
+def _get_openai_base_url(runtime: dict[str, Any]) -> str:
"""Get OpenAI-compatible base URL."""
return (
- os.environ.get("OPENAI_BASE_URL", "")
+ runtime.get("openaiBaseUrl", "")
+ or os.environ.get("OPENAI_BASE_URL", "")
or os.environ.get("OPENAI_API_BASE", "")
- or runtime.get("openaiBaseUrl", "")
or "https://api.openai.com"
).rstrip("/")
-def _get_openai_api_key(runtime: dict) -> str:
+def _get_openai_chat_completions_url(runtime: dict[str, Any]) -> str:
+ base_url = _get_openai_base_url(runtime)
+ if base_url.endswith("/chat/completions"):
+ return base_url
+ if base_url.endswith("/v1"):
+ return f"{base_url}/chat/completions"
+ return f"{base_url}/v1/chat/completions"
+
+
+def _get_openai_api_key(runtime: dict[str, Any]) -> str:
"""Get OpenAI API key."""
return (
- os.environ.get("OPENAI_API_KEY", "")
- or runtime.get("openaiApiKey", "")
+ runtime.get("openaiApiKey", "")
+ or os.environ.get("OPENAI_API_KEY", "")
+ )
+
+
+def _parse_openai_response_body(response: Any) -> tuple[dict[str, Any], str]:
+ """Parse an OpenAI-compatible response body with a text fallback."""
+ raw_body = response.read()
+ decoded = raw_body.decode("utf-8", errors="replace")
+ if not decoded.strip():
+ return {}, ""
+ try:
+ return json.loads(decoded), decoded
+ except json.JSONDecodeError:
+ return {}, decoded
+
+
+@dataclass
+class _BufferedHTTPResponse:
+ status: int
+ code: int
+ headers: Any
+ body: bytes
+
+ def read(self) -> bytes:
+ return self.body
+
+
+def _is_non_retryable_openai_error(status: int, data: dict[str, Any], raw_text: str) -> bool:
+ if status < 500:
+ return False
+ error_block = data.get("error", {}) if isinstance(data, dict) else {}
+ code = str(error_block.get("code", "")).lower()
+ message = str(error_block.get("message", "") or raw_text).lower()
+ permanent_markers = (
+ "model_not_found",
+ "no available channel",
+ "insufficient_quota",
+ "monthly usage limit reached",
+ "invalid api key",
)
+ return any(marker in code or marker in message for marker in permanent_markers)
def _to_openai_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
@@ -112,12 +162,15 @@ def _to_openai_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[
return system_message, converted
-def _parse_assistant_text(content: str) -> tuple[str, str | None]:
+AssistantTextKind = Literal["final", "progress"]
+
+
+def _parse_assistant_text(content: str) -> tuple[str, AssistantTextKind | None]:
"""Parse progress/final markers from assistant text."""
trimmed = content.strip()
if not trimmed:
return "", None
- markers = [
+ markers: list[tuple[str, AssistantTextKind, str | None]] = [
("", "final", ""),
("[FINAL]", "final", None),
(""),
@@ -184,13 +237,13 @@ def next(
if on_stream_chunk:
request_body["stream"] = True
- base_url = _get_openai_base_url(self.runtime)
api_key = _get_openai_api_key(self.runtime)
# Build headers — support OpenRouter and custom endpoints
headers = {
"content-type": "application/json",
"Authorization": f"Bearer {api_key}",
+ "User-Agent": DEFAULT_OPENAI_USER_AGENT,
}
# OpenRouter extra headers (HTTP-Referer, X-Title)
openrouter_headers = self.runtime.get("_openrouter_headers", {})
@@ -206,7 +259,7 @@ def next(
request_body[k] = v
request = urllib.request.Request(
- url=f"{base_url}/v1/chat/completions",
+ url=_get_openai_chat_completions_url(self.runtime),
data=json.dumps(request_body).encode("utf-8"),
headers=headers,
method="POST",
@@ -221,8 +274,19 @@ def next(
response = urllib.request.urlopen(request, timeout=timeout)
break
except urllib.error.HTTPError as error:
- response = error
- if error.code not in RETRYABLE_STATUS or attempt >= max_retries:
+ buffered_error = _BufferedHTTPResponse(
+ status=error.code,
+ code=error.code,
+ headers=getattr(error, "headers", None),
+ body=error.read(),
+ )
+ response = buffered_error
+ parsed_error, raw_error_text = _parse_openai_response_body(buffered_error)
+ if (
+ error.code not in RETRYABLE_STATUS
+ or attempt >= max_retries
+ or _is_non_retryable_openai_error(error.code, parsed_error, raw_error_text)
+ ):
break
from minicode.api_retry import classify_error
category = classify_error(error)
@@ -239,14 +303,22 @@ def next(
if not on_stream_chunk:
# Non-streaming response
- data = json.loads(response.read().decode("utf-8"))
+ data, raw_text = _parse_openai_response_body(response)
status = getattr(response, "status", getattr(response, "code", 200))
if status >= 400:
if store:
store.set_state(record_api_error())
- error_msg = data.get("error", {}).get("message", f"OpenAI API error: {status}")
+ error_msg = (
+ data.get("error", {}).get("message")
+ or raw_text.strip()
+ or f"OpenAI API error: {status}"
+ )
raise RuntimeError(error_msg)
+ if not data:
+ raise RuntimeError(
+ "OpenAI-compatible endpoint returned a non-JSON success payload."
+ )
# Cost tracking
if store:
@@ -254,7 +326,7 @@ def next(
input_tokens = usage.get("prompt_tokens", 0)
output_tokens = usage.get("completion_tokens", 0)
cost_usd = calculate_cost(
- model=self.runtime["model"],
+ model=self.runtime.get("model", ""),
input_tokens=input_tokens,
output_tokens=output_tokens,
)
@@ -306,9 +378,20 @@ def next(
return AgentStep(type="assistant", content=parsed_text, kind=kind, diagnostics=diagnostics)
# Streaming response
+ if isinstance(response, _BufferedHTTPResponse):
+ data, raw_text = _parse_openai_response_body(response)
+ if store:
+ store.set_state(record_api_error())
+ error_msg = (
+ data.get("error", {}).get("message")
+ or raw_text.strip()
+ or f"OpenAI API error: {response.status}"
+ )
+ raise RuntimeError(error_msg)
+
tool_calls = []
text_parts = []
- active_tool_calls: dict[int, dict] = {}
+ active_tool_calls: dict[int, dict[str, Any]] = {}
stop_reason = None
stream_input_tokens = 0
stream_output_tokens = 0
@@ -386,7 +469,7 @@ def next(
stream_output_tokens = len("".join(text_parts)) // 4
cost_usd = calculate_cost(
- model=self.runtime["model"],
+ model=self.runtime.get("model", ""),
input_tokens=stream_input_tokens,
output_tokens=stream_output_tokens,
)
diff --git a/minicode/permissions.py b/minicode/permissions.py
index f57b24b..893f247 100644
--- a/minicode/permissions.py
+++ b/minicode/permissions.py
@@ -11,6 +11,7 @@
# Auto mode integration
from minicode.auto_mode import AutoModeChecker, PermissionMode, get_mode_state
+from minicode.logging_config import log_permission_check
# 权限决策类型 — 对齐 TS 版 PermissionDecision
PermissionDecision = Literal[
@@ -285,9 +286,11 @@ def ensure_path_access(self, target_path: str, intent: str) -> None:
if assessment.action == "approve":
get_mode_state().record_decision("approve")
self.session_allowed_paths.add(normalized_target)
+ log_permission_check("path_access", normalized_target, granted=True)
return
-
+
if self.prompt is None:
+ log_permission_check("path_access", normalized_target, granted=False)
raise RuntimeError(
f"Path {normalized_target} is outside cwd {self.workspace_root}. Start minicode in TTY mode to approve it."
)
@@ -337,12 +340,15 @@ def ensure_command(
reason = force_prompt_reason or _classify_dangerous_command(command, args)
if not reason:
# Not classified as dangerous — check auto mode for auto-approve
+ _sig = _format_command_signature(command, args)
assessment = self.auto_checker.assess_risk("run_command", {"command": [command] + args})
if assessment.action == "approve":
get_mode_state().record_decision("approve")
+ log_permission_check("run_command", _sig, granted=True)
return
if assessment.action == "block":
get_mode_state().record_decision("block")
+ log_permission_check("run_command", _sig, granted=False)
raise RuntimeError(f"Command blocked by auto mode: {assessment.reason}")
# action == "prompt" — fall through to normal approval flow
return
@@ -357,12 +363,15 @@ def ensure_command(
if assessment.action == "approve":
get_mode_state().record_decision("approve")
self.session_allowed_commands.add(signature)
+ log_permission_check("run_command", signature, granted=True)
return
if assessment.action == "block":
get_mode_state().record_decision("block")
+ log_permission_check("run_command", signature, granted=False)
raise RuntimeError(f"Command blocked by auto mode: {assessment.reason}")
-
+
if self.prompt is None:
+ log_permission_check("run_command", signature, granted=False)
raise RuntimeError(f"Command requires approval: {signature}. Start minicode in TTY mode to approve it.")
# Distinguish forced prompts (external trigger) from dangerous commands
summary = (
@@ -419,12 +428,15 @@ def ensure_edit(self, target_path: str, diff_preview: str) -> None:
if assessment.action == "approve":
get_mode_state().record_decision("approve")
self.session_allowed_edits.add(normalized_target)
+ log_permission_check("edit_file", normalized_target, granted=True)
return
if assessment.action == "block":
get_mode_state().record_decision("block")
+ log_permission_check("edit_file", normalized_target, granted=False)
raise RuntimeError(f"Edit blocked by auto mode: {assessment.reason}")
-
+
if self.prompt is None:
+ log_permission_check("edit_file", normalized_target, granted=False)
raise RuntimeError(f"Edit requires approval: {normalized_target}. Start minicode in TTY mode to review it.")
result = self.prompt(
{
diff --git a/minicode/session.py b/minicode/session.py
index 4f99d39..ce19240 100644
--- a/minicode/session.py
+++ b/minicode/session.py
@@ -21,6 +21,7 @@
from typing import Any
from minicode.config import MINI_CODE_DIR
+from minicode.logging_config import log_session_event
# ---------------------------------------------------------------------------
@@ -144,6 +145,8 @@ def update_metadata(self) -> None:
for msg in self.messages:
if msg.get("role") == "user":
content = msg.get("content", "")
+ if not isinstance(content, str):
+ content = "" if content is None else str(content)
self.metadata.first_message = content[:100]
break
@@ -152,6 +155,8 @@ def update_metadata(self) -> None:
for msg in reversed(self.messages):
if msg.get("role") in ("user", "assistant"):
content = msg.get("content", "")
+ if not isinstance(content, str):
+ content = "" if content is None else str(content)
self.metadata.last_message = content[:100]
break
@@ -465,16 +470,17 @@ def _consolidate_deltas(session: SessionData) -> None:
def save_session(session: SessionData, force_full: bool = False) -> None:
"""Persist session to disk with incremental delta support.
-
+
Uses a hybrid strategy:
- Delta saves: Only append new messages/transcripts (fast, small I/O)
- Full saves: Serialize entire session (slower, but ensures consistency)
- Consolidation: Merge deltas into full file periodically
-
+
Args:
session: The session to save
force_full: Force a full save (e.g., on explicit save command)
"""
+ log_session_event("save", details=f"id={session.session_id} force_full={force_full}")
session.update_metadata()
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
@@ -549,13 +555,14 @@ def save_session(session: SessionData, force_full: bool = False) -> None:
def load_session(session_id: str) -> SessionData | None:
"""Load a session from disk, applying any pending deltas.
-
+
Loading process:
1. Load the base session file
2. Scan for delta files
3. Apply deltas in order (append new messages/transcripts)
4. Update tracking counters
"""
+ log_session_event("load", details=f"id={session_id}")
session_path = _session_file(session_id)
if not session_path.exists():
return None
@@ -698,6 +705,7 @@ def create_new_session(workspace: str) -> SessionData:
"""Create a new empty session."""
now = time.time()
session_id = uuid.uuid4().hex[:12]
+ log_session_event("create", details=f"id={session_id} workspace={workspace}")
return SessionData(
session_id=session_id,
created_at=now,
diff --git a/minicode/tooling.py b/minicode/tooling.py
index 5d5d085..9f87fe0 100644
--- a/minicode/tooling.py
+++ b/minicode/tooling.py
@@ -1,10 +1,13 @@
from __future__ import annotations
import re
+import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Protocol
+from minicode.logging_config import get_logger, log_tool_execution
+
# ---------------------------------------------------------------------------
# Constants for smart truncation
@@ -311,42 +314,57 @@ def execute(self, tool_name: str, input_data: Any, context: ToolContext) -> Tool
if tool is None:
return ToolResult(ok=False, output=f"Unknown tool: {tool_name}")
+ _logger = get_logger("tools")
+ _start = time.monotonic()
try:
# Phase 1: Input validation (with error context)
try:
parsed = tool.validator(input_data)
except (ValueError, TypeError, KeyError) as ve:
+ log_tool_execution(
+ tool_name, False, (time.monotonic() - _start) * 1000,
+ error=f"input validation: {ve}",
+ )
return ToolResult(
ok=False,
output=f"Input validation error in {tool_name}: {ve}\n"
f"Input was: {str(input_data)[:200]}"
)
-
+
# Phase 2: Execution (with crash protection)
result = tool.run(parsed, context)
-
+
# Phase 3: Output sanitization
if result.output is None:
result.output = ""
-
+
# Smart truncation for large outputs
if result.output and len(result.output) > _LARGE_OUTPUT_THRESHOLD:
result.output = _smart_truncate_output(result.output, tool_name)
-
+
+ log_tool_execution(
+ tool_name, bool(result.ok), (time.monotonic() - _start) * 1000,
+ error=None if result.ok else (result.output or "")[:200],
+ )
return result
-
+
except (KeyboardInterrupt, SystemExit):
# These should always propagate upward
raise
except Exception as error: # noqa: BLE001
# Global safety net: convert any unhandled exception to error result
# This prevents a single buggy tool from crashing the entire session
+ duration_ms = (time.monotonic() - _start) * 1000
+ # Persist the crash to the log file (searchable) while still
+ # returning a ToolResult to the caller (issue #5).
+ _logger.exception("Tool %s crashed", tool_name)
+ log_tool_execution(tool_name, False, duration_ms, error=str(error))
import traceback
tb_lines = traceback.format_exception(type(error), error, error.__traceback__)
# Include last 5 lines of traceback for debugging
tb_excerpt = "".join(tb_lines[-5:]).strip()
error_type = type(error).__name__
-
+
return ToolResult(
ok=False,
output=f"[{error_type}] Tool {tool_name} crashed: {error}\n"
diff --git a/minicode/tty_app.py b/minicode/tty_app.py
index a8fe60d..3794702 100644
--- a/minicode/tty_app.py
+++ b/minicode/tty_app.py
@@ -178,7 +178,7 @@ def rerender() -> None:
if not chunk:
continue
- parsed = parse_input_chunk(input_remainder + chunk)
+ parsed = parse_input_chunk(input_remainder + chunk, incoming_chunk=chunk)
input_remainder = parsed.rest
for event in parsed.events:
@@ -195,7 +195,8 @@ def rerender() -> None:
break
except Exception as e:
# 记录事件处理错误,但不中断主循环
- logging.debug("Event handling error: %s", e, exc_info=True)
+ from minicode.logging_config import get_logger
+ get_logger("tty_app").debug("Event handling error: %s", e, exc_info=True)
# Ensure the final state after processing all events is visible
throttled.flush()
diff --git a/minicode/tui/input_parser.py b/minicode/tui/input_parser.py
index c975222..e6a92ed 100644
--- a/minicode/tui/input_parser.py
+++ b/minicode/tui/input_parser.py
@@ -52,6 +52,26 @@ class ParseResult:
'\x15': 'u',
}
+def _is_multiline_paste_chunk(chunk: str) -> bool:
+ """A chunk that contains BOTH a newline and other text is a multi-line
+ paste (e.g. ``"line1\\r\\nline2"``). In that case newlines are preserved as
+ text instead of being treated as submit (return) events. A lone ``\\r`` /
+ ``\\n`` chunk (a real Enter keypress) is NOT a paste, so it still submits.
+
+ Mirrors the TS reference ``isMultilinePasteChunk`` for behavior parity.
+ """
+ has_newline = False
+ has_other = False
+ for c in chunk:
+ if c == '\r' or c == '\n':
+ has_newline = True
+ else:
+ has_other = True
+ if has_newline and has_other:
+ return True
+ return False
+
+
def maybe_need_more_for_escape_sequence(chunk: str) -> bool:
if not chunk:
return False
@@ -59,7 +79,7 @@ def maybe_need_more_for_escape_sequence(chunk: str) -> bool:
return False
if len(chunk) == 1:
return True
-
+
# CSI
if chunk[1] == '[':
# SGR Mouse: ESC[