From 4312cf28d87f5f6930e3e7831a9e87289e2e27ae Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Tue, 2 Jun 2026 23:25:19 -0400 Subject: [PATCH 1/9] fix(pyinstaller): bundle fastmcp/mcp dist-info so importlib.metadata works in frozen binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fastmcp calls importlib.metadata.version("fastmcp") at module import time. collect_data_files() only walks inside the package directory, so the fastmcp-*.dist-info/ sibling directory was never bundled — causing PackageNotFoundError on every `pythinker mcp add` invocation. Replace the fragile ../glob workaround in pyinstaller.py and add the missing fix to both installer specs (windows + linux) using copy_metadata(), the PyInstaller-standard hook for making importlib.metadata work in frozen apps. --- packages/linux-installer/pythinker.spec | 12 +++++++++++- packages/windows-installer/pythinker.spec | 12 +++++++++++- src/pythinker_code/utils/pyinstaller.py | 12 +++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/linux-installer/pythinker.spec b/packages/linux-installer/pythinker.spec index de05b1f6..c3ee79d0 100644 --- a/packages/linux-installer/pythinker.spec +++ b/packages/linux-installer/pythinker.spec @@ -3,7 +3,7 @@ # / tarball). Mode: --onedir — fpm wraps the directory into the package and # install-native.sh tar-gzips it for the curl-bash flow. -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata block_cipher = None @@ -47,6 +47,16 @@ try: except Exception: pass +# fastmcp calls importlib.metadata.version("fastmcp") at module import time. +# collect_data_files() only collects files inside the package directory; the +# dist-info lives alongside it in site-packages. copy_metadata() is the +# PyInstaller-standard hook for making importlib.metadata work in frozen apps. +for pkg in ("fastmcp", "mcp"): + try: + datas += copy_metadata(pkg) + except Exception: + pass + a = Analysis( ["entrypoint.py"], pathex=[], diff --git a/packages/windows-installer/pythinker.spec b/packages/windows-installer/pythinker.spec index 9e6a45c2..6ff3f72f 100644 --- a/packages/windows-installer/pythinker.spec +++ b/packages/windows-installer/pythinker.spec @@ -2,7 +2,7 @@ # PyInstaller spec for the Pythinker Code Windows native build. # Mode: --onedir (faster startup, fewer AV false-positives than --onefile). -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata block_cipher = None @@ -46,6 +46,16 @@ try: except Exception: pass +# fastmcp calls importlib.metadata.version("fastmcp") at module import time. +# collect_data_files() only collects files inside the package directory; the +# dist-info lives alongside it in site-packages. copy_metadata() is the +# PyInstaller-standard hook for making importlib.metadata work in frozen apps. +for pkg in ("fastmcp", "mcp"): + try: + datas += copy_metadata(pkg) + except Exception: + pass + a = Analysis( ["entrypoint.py"], pathex=[], diff --git a/src/pythinker_code/utils/pyinstaller.py b/src/pythinker_code/utils/pyinstaller.py index 7cfd525d..f767b286 100644 --- a/src/pythinker_code/utils/pyinstaller.py +++ b/src/pythinker_code/utils/pyinstaller.py @@ -1,6 +1,6 @@ from __future__ import annotations -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata from pythinker_code.cli._lazy_group import LazySubcommandGroup @@ -43,10 +43,12 @@ "dateparser", includes=["**/*.pkl"], ) - + collect_data_files( - "fastmcp", - includes=["../fastmcp-*.dist-info/*"], - ) + # fastmcp calls importlib.metadata.version("fastmcp") at module import time. + # copy_metadata() is the PyInstaller-standard hook for making + # importlib.metadata work in frozen apps; it bundles the dist-info sibling + # directory that collect_data_files() silently skips. + + copy_metadata("fastmcp") + + copy_metadata("mcp") + collect_data_files("trafilatura") # justext is trafilatura's fallback extractor. It loads its per-language # stoplists by os.listdir()-ing justext/stoplists/, so without the data From 77992eef302c603ff8a50619939479d58ff301ea Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Tue, 2 Jun 2026 23:37:15 -0400 Subject: [PATCH 2/9] fix(pyinstaller): remove silent exception suppression and fix test expectations Two issues flagged in review: - copy_metadata() loops were wrapped in bare except Exception: pass, which would silently produce a broken binary if fastmcp/mcp are missing at build time. Let PackageNotFoundError surface so the build fails loudly. - test_pyinstaller_datas pinned the old collect_data_files fastmcp/../dist-info path format and had no coverage for mcp. Switch to a presence assertion for METADATA (like the existing justext stoplists pattern) so the test verifies the right behavior without being brittle against version-dependent file lists. --- packages/linux-installer/pythinker.spec | 7 ++-- packages/windows-installer/pythinker.spec | 7 ++-- tests/utils/test_pyinstaller_utils.py | 49 +++++++++-------------- 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/packages/linux-installer/pythinker.spec b/packages/linux-installer/pythinker.spec index c3ee79d0..0a6b208c 100644 --- a/packages/linux-installer/pythinker.spec +++ b/packages/linux-installer/pythinker.spec @@ -51,11 +51,10 @@ except Exception: # collect_data_files() only collects files inside the package directory; the # dist-info lives alongside it in site-packages. copy_metadata() is the # PyInstaller-standard hook for making importlib.metadata work in frozen apps. +# Do NOT suppress errors here: a missing dist-info produces a broken binary, +# so let PackageNotFoundError surface and fail the build loudly. for pkg in ("fastmcp", "mcp"): - try: - datas += copy_metadata(pkg) - except Exception: - pass + datas += copy_metadata(pkg) a = Analysis( ["entrypoint.py"], diff --git a/packages/windows-installer/pythinker.spec b/packages/windows-installer/pythinker.spec index 6ff3f72f..51241d21 100644 --- a/packages/windows-installer/pythinker.spec +++ b/packages/windows-installer/pythinker.spec @@ -50,11 +50,10 @@ except Exception: # collect_data_files() only collects files inside the package directory; the # dist-info lives alongside it in site-packages. copy_metadata() is the # PyInstaller-standard hook for making importlib.metadata work in frozen apps. +# Do NOT suppress errors here: a missing dist-info produces a broken binary, +# so let PackageNotFoundError surface and fail the build loudly. for pkg in ("fastmcp", "mcp"): - try: - datas += copy_metadata(pkg) - except Exception: - pass + datas += copy_metadata(pkg) a = Analysis( ["entrypoint.py"], diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 377858eb..6c1cbc36 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -40,42 +40,31 @@ def test_pyinstaller_datas(): "justext English stoplist missing from the PyInstaller datas" ) + # fastmcp and mcp call importlib.metadata.version() at module import time; + # copy_metadata() bundles their dist-info so importlib.metadata can resolve + # them in the frozen binary. The exact file set is version-dependent (like + # justext stoplists), so assert METADATA presence rather than pinning every + # file. copy_metadata() produces clean paths ({dist-info}/FILE) — no + # fastmcp/../ prefix that the old collect_data_files workaround created. + for pkg in ("fastmcp", "mcp"): + pkg_dist = f"{pkg}-{version(pkg)}.dist-info" + assert any(d == pkg_dist and p.endswith("/METADATA") for p, d in datas), ( + f"{pkg_dist}/METADATA must be bundled so importlib.metadata works in the frozen app" + ) + datas = [ (p, d) for p, d in datas - if "web/static" not in d and "vis/static" not in d and d != "justext/stoplists" + if "web/static" not in d + and "vis/static" not in d + and d != "justext/stoplists" + and not any( + d == f"{pkg}-{version(pkg)}.dist-info" or d.startswith(f"{pkg}-{version(pkg)}.dist-info/") + for pkg in ("fastmcp", "mcp") + ) ] - fastmcp_dist = f"fastmcp-{version('fastmcp')}.dist-info" expected_datas = [ - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/INSTALLER", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/METADATA", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/RECORD", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/REQUESTED", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/WHEEL", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/entry_points.txt", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/licenses/LICENSE", - f"fastmcp/../{fastmcp_dist}/licenses", - ), ( f"{site_packages}/trafilatura/data/tei_corpus.dtd", "trafilatura/data", From 1e78678e6480c9340247567ed00e636c3e09a9ac Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Tue, 2 Jun 2026 23:40:50 -0400 Subject: [PATCH 3/9] feat(scratchpad): auto-add pythinker work dirs to project .gitignore on agent startup When the agent starts in a git repo, write .pythinker/, .pythinker-review/, and .pythinker-review-flow/ to the project's .gitignore if missing, preventing these local-only state directories from making the working tree dirty. The update runs inside ensure_git_excluded() (called once at CLI startup), is idempotent, uses the existing file lock, and never raises or blocks the session on failure. --- src/pythinker_code/scratchpad.py | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/pythinker_code/scratchpad.py b/src/pythinker_code/scratchpad.py index 3c1d3176..4f2cc3d3 100644 --- a/src/pythinker_code/scratchpad.py +++ b/src/pythinker_code/scratchpad.py @@ -32,6 +32,11 @@ SESSION_SCRATCH_IGNORE_PATTERN = f"{SESSION_SCRATCH_DIR_REL_PATH}/*.md" _SESSION_SCRATCH_IGNORE_SAMPLE = f"{SESSION_SCRATCH_DIR_REL_PATH}/session.md" +# Patterns written to the project .gitignore when the agent starts in a git repo. +# These directories are local-only agent state and must never be committed. +_GITIGNORE_ENTRIES = (".pythinker/", ".pythinker-review/", ".pythinker-review-flow/") +_GITIGNORE_SECTION_HEADER = "# pythinker — local agent state (do not commit)" + StatusReason = Literal[ "available_non_git", "available_git_ignored", @@ -337,6 +342,9 @@ async def ensure_git_excluded( ignored=False, ) + # Ensure pythinker work dirs are gitignored in the project before any files are created. + await _append_gitignore_entries(work_dir) + for candidate in (SCRATCH_REL_PATH, _SESSION_SCRATCH_IGNORE_SAMPLE): tracked = await runner(["ls-files", "--error-unmatch", "--", candidate]) if not tracked.ok: @@ -429,6 +437,38 @@ def _exclude_lock(path: Path) -> Generator[None]: fh.close() +async def _append_gitignore_entries(work_dir: HostPath) -> None: + """Best-effort: append pythinker work-dir patterns to work_dir/.gitignore if missing. + + Called once at agent startup to prevent pythinker's local state directories + from making the project's git working tree dirty. Never raises. + """ + gitignore_path = Path(str(work_dir)) / ".gitignore" + try: + await with_retries(lambda: _write_gitignore_entries(gitignore_path)) + except Exception: + logger.debug("scratchpad .gitignore update failed") + + +async def _write_gitignore_entries(gitignore_path: Path) -> None: + try: + with _exclude_lock(gitignore_path): + existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else "" + existing_lines = {line.strip() for line in existing.splitlines()} + missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines] + if not missing: + return + prefix = "" if not existing or existing.endswith("\n") else "\n" + with gitignore_path.open("a", encoding="utf-8") as fh: + fh.write(f"{prefix}{_GITIGNORE_SECTION_HEADER}\n") + for entry in missing: + fh.write(f"{entry}\n") + except OSError as exc: + if is_transient_oserror(exc): + raise TransientScratchpadError(str(exc)) from exc + raise + + @contextlib.contextmanager def _scratch_file_lock(fh: TextIO) -> Generator[None]: """Best-effort advisory lock on the scratch file itself (no extra git file).""" From 8173233226281ab1c0ac9e45b46154e65572a7a6 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Tue, 2 Jun 2026 23:42:03 -0400 Subject: [PATCH 4/9] refactor(test): extract dist-info predicate to shorten comprehension line length --- tests/utils/test_pyinstaller_utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 6c1cbc36..21da988f 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -52,16 +52,14 @@ def test_pyinstaller_datas(): f"{pkg_dist}/METADATA must be bundled so importlib.metadata works in the frozen app" ) + dist_info_dirs = {f"{pkg}-{version(pkg)}.dist-info" for pkg in ("fastmcp", "mcp")} datas = [ (p, d) for p, d in datas if "web/static" not in d and "vis/static" not in d and d != "justext/stoplists" - and not any( - d == f"{pkg}-{version(pkg)}.dist-info" or d.startswith(f"{pkg}-{version(pkg)}.dist-info/") - for pkg in ("fastmcp", "mcp") - ) + and not any(d == di or d.startswith(di + "/") for di in dist_info_dirs) ] expected_datas = [ From 36e2c9d630773a2718591d9d126b6c6851b44410 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Tue, 2 Jun 2026 23:47:19 -0400 Subject: [PATCH 5/9] fix(scratchpad): run gitignore writes off the event loop and guard section header Two issues in _write_gitignore_entries: - Was async def but only did blocking pathlib I/O, stalling the event loop. Made it a plain def and call it via asyncio.to_thread() so the filesystem work runs in a thread pool. - Section header was written unconditionally whenever any entries were missing, so a partial previous write (some entries present, some not) would duplicate the header on the next run. Now only written when absent from existing_lines. --- src/pythinker_code/scratchpad.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pythinker_code/scratchpad.py b/src/pythinker_code/scratchpad.py index 4f2cc3d3..b6b2cb98 100644 --- a/src/pythinker_code/scratchpad.py +++ b/src/pythinker_code/scratchpad.py @@ -445,12 +445,14 @@ async def _append_gitignore_entries(work_dir: HostPath) -> None: """ gitignore_path = Path(str(work_dir)) / ".gitignore" try: - await with_retries(lambda: _write_gitignore_entries(gitignore_path)) + await with_retries( + lambda: asyncio.to_thread(_write_gitignore_entries, gitignore_path) + ) except Exception: logger.debug("scratchpad .gitignore update failed") -async def _write_gitignore_entries(gitignore_path: Path) -> None: +def _write_gitignore_entries(gitignore_path: Path) -> None: try: with _exclude_lock(gitignore_path): existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else "" @@ -460,7 +462,10 @@ async def _write_gitignore_entries(gitignore_path: Path) -> None: return prefix = "" if not existing or existing.endswith("\n") else "\n" with gitignore_path.open("a", encoding="utf-8") as fh: - fh.write(f"{prefix}{_GITIGNORE_SECTION_HEADER}\n") + if _GITIGNORE_SECTION_HEADER not in existing_lines: + fh.write(f"{prefix}{_GITIGNORE_SECTION_HEADER}\n") + elif prefix: + fh.write(prefix) for entry in missing: fh.write(f"{entry}\n") except OSError as exc: From 97ebce85a71d861ba18cc033351f97372ba742c6 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Tue, 2 Jun 2026 23:55:14 -0400 Subject: [PATCH 6/9] fix(ci): correct pyinstaller test assertion and ruff format; add changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copy_metadata() returns one directory entry per package (the whole dist-info dir), not individual file entries. The test was asserting p.endswith("/METADATA") which can never match a directory path — fix to assert the dest dir name matches. - Inline the asyncio.to_thread lambda to satisfy ruff line-length check. - Add changelog entries for the mcp add fix and gitignore auto-exclusion feature. --- CHANGELOG.md | 2 ++ src/pythinker_code/scratchpad.py | 4 +--- tests/utils/test_pyinstaller_utils.py | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a341e704..6c7c474b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **`pythinker mcp add` no longer crashes on Windows and Linux native builds.** The PyInstaller specs were using `collect_data_files()` which silently omits the `fastmcp-*.dist-info/` sibling directory; fastmcp calls `importlib.metadata.version("fastmcp")` at import time, so every `mcp add` / `mcp list` invocation raised `PackageNotFoundError`. Switched all three specs (Windows installer, Linux installer, macOS/tarball) to `copy_metadata()` — the PyInstaller-standard hook for bundling dist-info. +- **Pythinker work directories are automatically gitignored on startup.** When the agent starts inside a git repository, `.pythinker/`, `.pythinker-review/`, and `.pythinker-review-flow/` are silently appended to the project's `.gitignore` if missing, preventing local agent state from making the working tree dirty. - **Windows upgrade version display fix.** In-place upgrades no longer show a stale version number or re-trigger the update prompt. Inno Setup now wipes `_internal` before installing new files, preventing old `dist-info` directories from accumulating and causing `importlib.metadata` to report the previous version. ## 0.31.0 (2026-06-02) diff --git a/src/pythinker_code/scratchpad.py b/src/pythinker_code/scratchpad.py index b6b2cb98..3227f147 100644 --- a/src/pythinker_code/scratchpad.py +++ b/src/pythinker_code/scratchpad.py @@ -445,9 +445,7 @@ async def _append_gitignore_entries(work_dir: HostPath) -> None: """ gitignore_path = Path(str(work_dir)) / ".gitignore" try: - await with_retries( - lambda: asyncio.to_thread(_write_gitignore_entries, gitignore_path) - ) + await with_retries(lambda: asyncio.to_thread(_write_gitignore_entries, gitignore_path)) except Exception: logger.debug("scratchpad .gitignore update failed") diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 21da988f..74942219 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -46,10 +46,12 @@ def test_pyinstaller_datas(): # justext stoplists), so assert METADATA presence rather than pinning every # file. copy_metadata() produces clean paths ({dist-info}/FILE) — no # fastmcp/../ prefix that the old collect_data_files workaround created. + # copy_metadata() returns a single directory entry per package (the whole + # dist-info dir), so check that the dest dir name matches — not a file inside it. for pkg in ("fastmcp", "mcp"): pkg_dist = f"{pkg}-{version(pkg)}.dist-info" - assert any(d == pkg_dist and p.endswith("/METADATA") for p, d in datas), ( - f"{pkg_dist}/METADATA must be bundled so importlib.metadata works in the frozen app" + assert any(d == pkg_dist for p, d in datas), ( + f"{pkg_dist} must be bundled so importlib.metadata works in the frozen app" ) dist_info_dirs = {f"{pkg}-{version(pkg)}.dist-info" for pkg in ("fastmcp", "mcp")} From 302600b05a9cffb40dacc40ffbcefba04140f040 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Wed, 3 Jun 2026 00:00:15 -0400 Subject: [PATCH 7/9] feat(cleanup): sweep old archived sessions and plan files at agent startup Applies the best practice used by Claude Code (cleanupPeriodDays=30): on every startup, remove accumulated state that is safe to discard. - session_cleanup.py: two focused sweep functions - sweep_old_sessions: removes archived session dirs under ~/.pythinker/sessions/ whose archived_at/wire_mtime is older than the retention threshold; orphan dirs (no state.json) pruned by mtime - sweep_old_plans: removes hero-name plan files from ~/.pythinker/plans/ older than the threshold; plans are ephemeral by nature - config.py: adds session_retention_days (default 30, 0 = disabled) - cli/__init__.py: calls both sweeps at startup via asyncio.to_thread (non-blocking, best-effort); reads session_retention_days from config when a Config object is available, otherwise falls back to 30 Active/unarchived sessions are never touched regardless of age. --- src/pythinker_code/cli/__init__.py | 6 ++ src/pythinker_code/config.py | 9 ++ src/pythinker_code/session_cleanup.py | 145 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/pythinker_code/session_cleanup.py diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index cfd137bf..9a9ba930 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -803,8 +803,14 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple render_scratchpad_section, scratch_file_exists, ) + from pythinker_code.session_cleanup import sweep_old_plans, sweep_old_sessions scratchpad_status = await ensure_git_excluded(work_dir) + + # Sweep old archived sessions and plan files on startup (best-effort, non-blocking). + _retention = config.session_retention_days if isinstance(config, Config) else 30 + await asyncio.to_thread(sweep_old_sessions, _retention) + await asyncio.to_thread(sweep_old_plans, _retention) scratch_exists_before_start = scratch_file_exists(work_dir) if scratchpad_status.available: session_source = "resume" if resumed else "startup" diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 4c2d5add..e2c65cdf 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -497,6 +497,15 @@ class Config(BaseModel): "Enable anonymous telemetry to help improve pythinker-code. Set to false to opt out." ), ) + session_retention_days: int = Field( + default=30, + ge=0, + description=( + "Archived session directories in ~/.pythinker/sessions/ older than this many days " + "are removed on startup. Plan files older than this many days are also pruned. " + "0 disables cleanup." + ), + ) @model_validator(mode="after") def validate_model(self) -> Self: diff --git a/src/pythinker_code/session_cleanup.py b/src/pythinker_code/session_cleanup.py new file mode 100644 index 00000000..c9066e91 --- /dev/null +++ b/src/pythinker_code/session_cleanup.py @@ -0,0 +1,145 @@ +"""Age-based cleanup for personal-scope session and plan state. + +Runs at agent startup to keep ~/.pythinker/ from growing unboundedly. +Mirrors the design used by Claude Code (cleanupPeriodDays=30): on startup, +directories/files older than the retention threshold are removed if they are +safe to discard (archived sessions, old plan files). + +Never raises — every error is logged at DEBUG level and silently skipped. +""" + +from __future__ import annotations + +import shutil +import time +from pathlib import Path + +from pythinker_code.utils.logging import logger + +_SESSIONS_DIR_NAME = "sessions" +_PLANS_DIR_NAME = "plans" + + +def sweep_old_sessions(max_age_days: int, *, share_dir: Path | None = None) -> int: + """Remove archived session directories older than ``max_age_days`` days. + + Only sessions explicitly marked ``archived=True`` in their ``state.json`` are + eligible. Active or unarchived sessions are never touched regardless of age. + Orphan directories (no ``state.json``) are removed if their mtime is old enough. + + Returns the number of directories removed. Returns 0 when disabled (``max_age_days <= 0``). + """ + if max_age_days <= 0: + return 0 + + from pythinker_code.share import get_share_dir + + sessions_root = (share_dir or get_share_dir()) / _SESSIONS_DIR_NAME + if not sessions_root.is_dir(): + return 0 + + cutoff = time.time() - max_age_days * 86_400.0 + removed = 0 + + try: + buckets = list(sessions_root.iterdir()) + except OSError: + return 0 + + for bucket in buckets: + if not bucket.is_dir(): + continue + try: + session_dirs = list(bucket.iterdir()) + except OSError: + continue + for session_dir in session_dirs: + if not session_dir.is_dir(): + continue + try: + removed += _maybe_remove_session(session_dir, cutoff) + except Exception: + logger.debug("session_cleanup: skipping {d}", d=session_dir.name) + + # Prune the bucket itself if it is now empty + try: + if not any(bucket.iterdir()): + bucket.rmdir() + except OSError: + pass + + if removed: + logger.debug("session_cleanup: removed {n} old archived session(s)", n=removed) + return removed + + +def sweep_old_plans(max_age_days: int, *, share_dir: Path | None = None) -> int: + """Remove plan markdown files older than ``max_age_days`` days. + + Plan files are hero-name-slugged markdown files written to ~/.pythinker/plans/ + for each planning session. They are ephemeral by nature and safe to prune once old. + + Returns the number of files removed. Returns 0 when disabled (``max_age_days <= 0``). + """ + if max_age_days <= 0: + return 0 + + from pythinker_code.share import get_share_dir + + plans_dir = (share_dir or get_share_dir()) / _PLANS_DIR_NAME + if not plans_dir.is_dir(): + return 0 + + cutoff = time.time() - max_age_days * 86_400.0 + removed = 0 + + try: + plan_files = list(plans_dir.glob("*.md")) + except OSError: + return 0 + + for plan_file in plan_files: + try: + if plan_file.stat().st_mtime < cutoff: + plan_file.unlink(missing_ok=True) + removed += 1 + except Exception: + logger.debug("session_cleanup: skipping plan {f}", f=plan_file.name) + + if removed: + logger.debug("session_cleanup: removed {n} old plan file(s)", n=removed) + return removed + + +def _maybe_remove_session(session_dir: Path, cutoff: float) -> int: + """Return 1 if the session directory was removed, 0 otherwise.""" + from pythinker_code.session_state import load_session_state + + state_file = session_dir / "state.json" + + if not state_file.exists(): + # Orphan dir with no state — safe to remove if old enough. + try: + if session_dir.stat().st_mtime < cutoff: + shutil.rmtree(session_dir, ignore_errors=True) + return 1 + except OSError: + pass + return 0 + + state = load_session_state(session_dir) + + if not state.archived: + return 0 + + # Use archived_at if recorded, fall back to wire_mtime, then directory mtime. + try: + reference: float = state.archived_at or state.wire_mtime or session_dir.stat().st_mtime + except OSError: + return 0 + + if reference >= cutoff: + return 0 + + shutil.rmtree(session_dir, ignore_errors=True) + return 1 From 31d59fd79016f09991a5c5b9e5dc33b8b8a289fb Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Wed, 3 Jun 2026 00:11:13 -0400 Subject: [PATCH 8/9] feat(cleanup): complete best-practice state hygiene across all scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the startup cleanup to cover every accumulation point identified in the AI-agent state-management research (Claude Code / Aider / Copilot CLI): session_cleanup.py: - sweep_old_sessions now cross-references pythinker.json to find each session's work_dir path and co-deletes the corresponding per-session scratchpad file (.pythinker/scratch/-*.md) — no orphaned project-dir files left behind after a session bucket is reaped - sweep_stale_work_dirs: prunes pythinker.json entries whose path no longer exists AND whose sessions bucket is absent/empty; conservative (keeps entries with surviving sessions even if path is gone) - Bucket directories are rmdir'd when emptied; avoids ghost buckets cli/__init__.py: - Adds sweep_stale_work_dirs() to the startup sweep sequence findings_store.py (_update_index): - Collects run IDs that overflow the 200-entry index cap and immediately removes their physical run directories (.pythinker-review/runs//) so the on-disk state stays bounded; previously the index was capped but the directories accumulated indefinitely --- .../pythinker_review/store/findings_store.py | 8 + src/pythinker_code/cli/__init__.py | 10 +- src/pythinker_code/session_cleanup.py | 139 ++++++++++++++++-- 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/packages/pythinker-review/src/pythinker_review/store/findings_store.py b/packages/pythinker-review/src/pythinker_review/store/findings_store.py index 5c3a8d3b..9d95e8de 100644 --- a/packages/pythinker-review/src/pythinker_review/store/findings_store.py +++ b/packages/pythinker-review/src/pythinker_review/store/findings_store.py @@ -4,6 +4,7 @@ import json import os +import shutil from pathlib import Path from typing import TextIO @@ -80,6 +81,13 @@ def _update_index(self, meta: RunMeta) -> None: "findings_count": meta.findings_count, }, ) + # Collect run IDs that are about to fall off the index before truncating. + overflow_ids = [str(r["id"]) for r in runs[_INDEX_LIMIT:] if isinstance(r.get("id"), str)] tmp = idx_path.with_suffix(".json.tmp") tmp.write_text(json.dumps({"runs": runs[:_INDEX_LIMIT]}, indent=2), encoding="utf-8") os.replace(tmp, idx_path) + # Delete physical run directories for entries that overflowed the cap. + for run_id in overflow_ids: + run_dir = self._run_dir(run_id) + if run_dir.is_dir(): + shutil.rmtree(run_dir, ignore_errors=True) diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index 9a9ba930..6d28d348 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -803,14 +803,20 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple render_scratchpad_section, scratch_file_exists, ) - from pythinker_code.session_cleanup import sweep_old_plans, sweep_old_sessions + from pythinker_code.session_cleanup import ( + sweep_old_plans, + sweep_old_sessions, + sweep_stale_work_dirs, + ) scratchpad_status = await ensure_git_excluded(work_dir) - # Sweep old archived sessions and plan files on startup (best-effort, non-blocking). + # Sweep accumulated state on startup (best-effort, non-blocking). + # Mirrors Claude Code's cleanupPeriodDays=30 model. _retention = config.session_retention_days if isinstance(config, Config) else 30 await asyncio.to_thread(sweep_old_sessions, _retention) await asyncio.to_thread(sweep_old_plans, _retention) + await asyncio.to_thread(sweep_stale_work_dirs) scratch_exists_before_start = scratch_file_exists(work_dir) if scratchpad_status.available: session_source = "resume" if resumed else "startup" diff --git a/src/pythinker_code/session_cleanup.py b/src/pythinker_code/session_cleanup.py index c9066e91..c2d2e8e8 100644 --- a/src/pythinker_code/session_cleanup.py +++ b/src/pythinker_code/session_cleanup.py @@ -10,14 +10,22 @@ from __future__ import annotations +import contextlib import shutil import time +from hashlib import md5 as _md5 from pathlib import Path from pythinker_code.utils.logging import logger _SESSIONS_DIR_NAME = "sessions" _PLANS_DIR_NAME = "plans" +_LOCAL_HOST = "local" + + +# --------------------------------------------------------------------------- +# Public sweep functions +# --------------------------------------------------------------------------- def sweep_old_sessions(max_age_days: int, *, share_dir: Path | None = None) -> int: @@ -27,6 +35,10 @@ def sweep_old_sessions(max_age_days: int, *, share_dir: Path | None = None) -> i eligible. Active or unarchived sessions are never touched regardless of age. Orphan directories (no ``state.json``) are removed if their mtime is old enough. + After removing a session directory the corresponding per-session scratchpad + file in the project's ``.pythinker/scratch/`` directory is also deleted + (best-effort; a missing project dir is silently skipped). + Returns the number of directories removed. Returns 0 when disabled (``max_age_days <= 0``). """ if max_age_days <= 0: @@ -34,11 +46,13 @@ def sweep_old_sessions(max_age_days: int, *, share_dir: Path | None = None) -> i from pythinker_code.share import get_share_dir - sessions_root = (share_dir or get_share_dir()) / _SESSIONS_DIR_NAME + root = share_dir or get_share_dir() + sessions_root = root / _SESSIONS_DIR_NAME if not sessions_root.is_dir(): return 0 cutoff = time.time() - max_age_days * 86_400.0 + bucket_to_path = _load_bucket_path_map(root) removed = 0 try: @@ -49,6 +63,7 @@ def sweep_old_sessions(max_age_days: int, *, share_dir: Path | None = None) -> i for bucket in buckets: if not bucket.is_dir(): continue + work_dir_path = bucket_to_path.get(bucket.name) try: session_dirs = list(bucket.iterdir()) except OSError: @@ -57,11 +72,12 @@ def sweep_old_sessions(max_age_days: int, *, share_dir: Path | None = None) -> i if not session_dir.is_dir(): continue try: - removed += _maybe_remove_session(session_dir, cutoff) + if _maybe_remove_session(session_dir, cutoff, work_dir_path=work_dir_path): + removed += 1 except Exception: logger.debug("session_cleanup: skipping {d}", d=session_dir.name) - # Prune the bucket itself if it is now empty + # Prune the bucket itself if it is now empty. try: if not any(bucket.iterdir()): bucket.rmdir() @@ -111,8 +127,67 @@ def sweep_old_plans(max_age_days: int, *, share_dir: Path | None = None) -> int: return removed -def _maybe_remove_session(session_dir: Path, cutoff: float) -> int: - """Return 1 if the session directory was removed, 0 otherwise.""" +def sweep_stale_work_dirs(*, share_dir: Path | None = None) -> int: + """Prune pythinker.json entries for paths that no longer exist and have no sessions. + + An entry is removed only when both conditions hold: + - The work directory path does not exist on disk. + - The corresponding sessions bucket directory is absent or empty. + + This prevents data loss for projects that have been moved or temporarily + unmounted while sessions still exist. + + Returns the number of entries pruned. + """ + from pythinker_code.metadata import WorkDirMeta, load_metadata, save_metadata + from pythinker_code.share import get_share_dir + + root = share_dir or get_share_dir() + sessions_root = root / _SESSIONS_DIR_NAME + + try: + metadata = load_metadata() + except Exception: + return 0 + + keep: list[WorkDirMeta] = [] + pruned = 0 + for wd in metadata.work_dirs: + if Path(wd.path).exists(): + keep.append(wd) + continue + try: + hash_ = _md5(wd.path.encode("utf-8"), usedforsecurity=False).hexdigest() + bucket_name = hash_ if wd.host == _LOCAL_HOST else f"{wd.host}_{hash_}" + bucket_dir = sessions_root / bucket_name + has_sessions = bucket_dir.is_dir() and any(bucket_dir.iterdir()) + except Exception: + has_sessions = True # err on the side of keeping + if has_sessions: + keep.append(wd) + else: + pruned += 1 + + if pruned: + metadata.work_dirs = keep + try: + save_metadata(metadata) + logger.debug("session_cleanup: pruned {n} stale work_dir(s) from registry", n=pruned) + except Exception: + logger.debug("session_cleanup: failed to save pruned metadata") + + return pruned + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _maybe_remove_session( + session_dir: Path, cutoff: float, *, work_dir_path: str | None = None +) -> bool: + """Return True if the session directory was removed, False otherwise.""" from pythinker_code.session_state import load_session_state state_file = session_dir / "state.json" @@ -122,24 +197,64 @@ def _maybe_remove_session(session_dir: Path, cutoff: float) -> int: try: if session_dir.stat().st_mtime < cutoff: shutil.rmtree(session_dir, ignore_errors=True) - return 1 + return True except OSError: pass - return 0 + return False state = load_session_state(session_dir) if not state.archived: - return 0 + return False - # Use archived_at if recorded, fall back to wire_mtime, then directory mtime. try: reference: float = state.archived_at or state.wire_mtime or session_dir.stat().st_mtime except OSError: - return 0 + return False if reference >= cutoff: - return 0 + return False shutil.rmtree(session_dir, ignore_errors=True) - return 1 + if work_dir_path is not None: + _try_remove_scratchpad(work_dir_path, session_dir.name) + return True + + +def _try_remove_scratchpad(work_dir_path: str, session_uuid: str) -> None: + """Best-effort: delete the per-session scratchpad file for a removed session.""" + scratch_dir = Path(work_dir_path) / ".pythinker" / "scratch" + if not scratch_dir.is_dir(): + return + short_id = _session_short_id(session_uuid) + for f in scratch_dir.glob(f"{short_id}-*.md"): + with contextlib.suppress(OSError): + f.unlink(missing_ok=True) + + +def _session_short_id(session_uuid: str) -> str: + """Compute the 12-char slug used as the scratchpad filename prefix.""" + text = "".join(c if c.isalnum() else "-" for c in session_uuid.lower()) + while "--" in text: + text = text.replace("--", "-") + return text.strip("-")[:12].strip("-") or "session" + + +def _load_bucket_path_map(share_dir: Path) -> dict[str, str]: + """Return a mapping of sessions bucket directory name → work_dir path string.""" + from pythinker_code.metadata import load_metadata + + try: + metadata = load_metadata() + except Exception: + return {} + + result: dict[str, str] = {} + for wd in metadata.work_dirs: + try: + hash_ = _md5(wd.path.encode("utf-8"), usedforsecurity=False).hexdigest() + bucket = hash_ if wd.host == _LOCAL_HOST else f"{wd.host}_{hash_}" + result[bucket] = wd.path + except Exception: + pass + return result From a286c7328e5ddcfca0da3a7ada8eb0117759afdf Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Wed, 3 Jun 2026 04:32:30 -0400 Subject: [PATCH 9/9] fix: safe CWD fallback, shell read() limit, wire recorder isolation - Add _safe_cwd() to pythinkersoul: falls back to session.work_dir when the process CWD has been deleted mid-session (FileNotFoundError) - Replace readline() with read(65536) in Shell._read_stream to avoid asyncio's 64 KB per-line LimitOverrunError - Isolate wire recorder _record() exceptions so a persist failure no longer silently drops the unprocessed message - Downgrade LLMNotSet from logger.exception to logger.warning in session, print, and shell UIs (no stack trace needed for a config-level error) - Gitignore .pythinker-review-flow/ (local agent state) - CHANGELOG entry for session/plan sweeper (session_retention_days) - Update tests: session_retention_days default, oversized-line shell test, FakeStream.read() stub for cancellation test --- .gitignore | 2 ++ CHANGELOG.md | 1 + src/pythinker_code/acp/session.py | 2 +- src/pythinker_code/soul/pythinkersoul.py | 26 ++++++++++++++++------ src/pythinker_code/tools/shell/__init__.py | 12 +++++----- src/pythinker_code/ui/print/__init__.py | 2 +- src/pythinker_code/ui/shell/__init__.py | 2 +- src/pythinker_code/wire/__init__.py | 5 ++++- tests/core/test_config.py | 1 + tests/tools/test_shell_bash.py | 18 +++++++++++++++ 10 files changed, 54 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index d29b4d0c..ddaeb858 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ blackbox/ # pythinker-review .pythinker-review/ +# pythinker — local agent state (do not commit) +.pythinker-review-flow/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7c474b..52f919e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ GitHub Releases page; `0.8.0` is the new starting line. - **`pythinker mcp add` no longer crashes on Windows and Linux native builds.** The PyInstaller specs were using `collect_data_files()` which silently omits the `fastmcp-*.dist-info/` sibling directory; fastmcp calls `importlib.metadata.version("fastmcp")` at import time, so every `mcp add` / `mcp list` invocation raised `PackageNotFoundError`. Switched all three specs (Windows installer, Linux installer, macOS/tarball) to `copy_metadata()` — the PyInstaller-standard hook for bundling dist-info. - **Pythinker work directories are automatically gitignored on startup.** When the agent starts inside a git repository, `.pythinker/`, `.pythinker-review/`, and `.pythinker-review-flow/` are silently appended to the project's `.gitignore` if missing, preventing local agent state from making the working tree dirty. +- **Old sessions and plan files are swept on startup.** Archived session directories under `~/.pythinker/sessions/` and hero-name plan files under `~/.pythinker/plans/` older than `session_retention_days` (default 30) are removed non-interactively at startup. Set `session_retention_days = 0` to disable. - **Windows upgrade version display fix.** In-place upgrades no longer show a stale version number or re-trigger the update prompt. Inno Setup now wipes `_internal` before installing new files, preventing old `dist-info` directories from accumulating and causing `importlib.metadata` to report the previous version. ## 0.31.0 (2026-06-02) diff --git a/src/pythinker_code/acp/session.py b/src/pythinker_code/acp/session.py index d65bc8ec..ce209f6a 100644 --- a/src/pythinker_code/acp/session.py +++ b/src/pythinker_code/acp/session.py @@ -215,7 +215,7 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: case _: pass except LLMNotSet as e: - logger.exception("LLM not set:") + logger.warning("LLM not set — user has no provider configured") raise acp.RequestError.auth_required() from e except LLMNotSupported as e: logger.exception("LLM not supported:") diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 4c819385..5f18d6ec 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -129,6 +129,18 @@ def type_check(soul: PythinkerSoul): DEFAULT_MAX_FLOW_MOVES = 1000 +def _safe_cwd(fallback: str) -> str: + """Return the current working directory as a string. + + Falls back to *fallback* if the process CWD has been deleted (e.g. the + project directory was removed mid-session by a shell command). + """ + try: + return str(Path.cwd()) + except FileNotFoundError: + return str(fallback) + + def classify_llm_system(chat_provider: object | None) -> str: """Classify a chat provider into a stable gen_ai.system telemetry value.""" try: @@ -875,7 +887,7 @@ async def run( matcher_value=text_input_for_hook, input_data=events.user_prompt_submit( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), prompt=text_input_for_hook, ), ) @@ -917,7 +929,7 @@ async def run( "Stop", input_data=events.stop( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), stop_hook_active=False, ), ) @@ -1298,7 +1310,7 @@ async def _agent_loop(self) -> TurnOutcome: matcher_value=type(e).__name__, input_data=_hook_events.stop_failure( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), error_type=type(e).__name__, error_message=str(e), ), @@ -1361,7 +1373,7 @@ async def _append_notification(view: NotificationView) -> None: matcher_value=view.event.type, input_data=events.notification( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), sink="llm", notification_type=view.event.type, title=view.event.title, @@ -1706,7 +1718,7 @@ async def _compact_with_retry() -> CompactionResult: matcher_value=trigger_reason, input_data=events.pre_compact( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), trigger=trigger_reason, token_count=before_tokens, custom_instructions=custom_instruction, @@ -1763,7 +1775,7 @@ async def _compact_with_retry() -> CompactionResult: matcher_value=trigger_reason, input_data=events.post_compact( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), trigger=trigger_reason, estimated_token_count=estimated_token_count, compact_summary=summary_text, @@ -1774,7 +1786,7 @@ async def _compact_with_retry() -> CompactionResult: matcher_value="compact", input_data=events.session_start( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), source="compact", ), ) diff --git a/src/pythinker_code/tools/shell/__init__.py b/src/pythinker_code/tools/shell/__init__.py index 3d50cd0d..3bcefd68 100644 --- a/src/pythinker_code/tools/shell/__init__.py +++ b/src/pythinker_code/tools/shell/__init__.py @@ -281,12 +281,12 @@ async def _run_shell_command( timeout: int, ) -> int: async def _read_stream(stream: AsyncReadable, cb: Callable[[bytes], None]): - while True: - line = await stream.readline() - if line: - cb(line) - else: - break + # Use read() instead of readline() to avoid asyncio's 64 KB per-line + # limit (raises LimitOverrunError / ValueError depending on Python + # version). The callbacks only accumulate text, so chunk boundaries + # do not matter for correctness. + while chunk := await stream.read(65536): + cb(chunk) process = await pythinker_host.exec( *self._shell_args(command), env=get_noninteractive_env() diff --git a/src/pythinker_code/ui/print/__init__.py b/src/pythinker_code/ui/print/__init__.py index 25495e35..f5e62d1d 100644 --- a/src/pythinker_code/ui/print/__init__.py +++ b/src/pythinker_code/ui/print/__init__.py @@ -408,7 +408,7 @@ def _handler(): command = None except LLMNotSet as e: - logger.exception("LLM not set:") + logger.warning("LLM not set — user has no provider configured") print(str(e)) return ExitCode.FAILURE except LLMNotSupported as e: diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 4c83cfe6..00cc25f9 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -1275,7 +1275,7 @@ def _on_view_ready(view: Any) -> None: return True except LLMNotSet: _t = _get_tui_tokens() - logger.exception("LLM not set:") + logger.warning("LLM not set — user has no provider configured") console.print(f'[{_t.error}]LLM not set, send "/login" to login[/]') except LLMNotSupported as e: # actually unsupported input/mode should already be blocked by prompt session diff --git a/src/pythinker_code/wire/__init__.py b/src/pythinker_code/wire/__init__.py index e4bb1f52..0a87c9e4 100644 --- a/src/pythinker_code/wire/__init__.py +++ b/src/pythinker_code/wire/__init__.py @@ -140,9 +140,12 @@ async def _consume_loop(self, queue: Queue[WireMessage]) -> None: while True: try: msg = await queue.get() - await self._record(msg) except QueueShutDown: break + try: + await self._record(msg) + except Exception: + logger.exception("Wire recorder failed to persist message:") async def _record(self, msg: WireMessage) -> None: await self._wire_file.append_message(msg) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 24c76f7a..cf7e9d03 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -82,6 +82,7 @@ def test_default_config_dump(): "merge_all_available_skills": True, "extra_skill_dirs": [], "telemetry": True, + "session_retention_days": 30, "skip_auto_prompt_injection": False, "tui": { "style": "card", diff --git a/tests/tools/test_shell_bash.py b/tests/tools/test_shell_bash.py index f4f9c20f..5464648c 100644 --- a/tests/tools/test_shell_bash.py +++ b/tests/tools/test_shell_bash.py @@ -194,6 +194,19 @@ async def test_output_truncation_on_failure(shell_tool: Shell): assert "Command failed with exit code:" in result.message +async def test_oversized_output_line(shell_tool: Shell): + """A single output line exceeding asyncio's 64 KB readline limit must not crash the tool.""" + # asyncio.StreamReader's default limit is 65536 bytes; emit a 70 KB line. + result = await shell_tool( + Params(command="python3 -c \"print('X' * 70000)\""), + ) + # The tool must return a result (not raise), and the oversized content must + # appear in the output rather than being silently dropped. + assert not result.is_error + assert isinstance(result.output, str) + assert "X" in result.output + + async def test_timeout_parameter_validation_bounds(shell_tool: Shell): """Test timeout parameter validation (bounds checking).""" # Test timeout < 1 (should fail validation) @@ -254,6 +267,11 @@ async def readline(self) -> bytes: await asyncio.Event().wait() raise AssertionError("unreachable") + async def read(self, n: int = -1) -> bytes: + started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + class FakeStdin: def close(self) -> None: pass