Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,5 @@ blackbox/

# pythinker-review
.pythinker-review/
# pythinker — local agent state (do not commit)
.pythinker-review-flow/
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ 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.
- **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)
Expand Down
11 changes: 10 additions & 1 deletion packages/linux-installer/pythinker.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -47,6 +47,15 @@ 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.
# 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"):
datas += copy_metadata(pkg)

a = Analysis(
["entrypoint.py"],
pathex=[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import os
import shutil
from pathlib import Path
from typing import TextIO

Expand Down Expand Up @@ -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)
11 changes: 10 additions & 1 deletion packages/windows-installer/pythinker.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -46,6 +46,15 @@ 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.
# 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"):
datas += copy_metadata(pkg)

a = Analysis(
["entrypoint.py"],
pathex=[],
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/acp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:")
Expand Down
12 changes: 12 additions & 0 deletions src/pythinker_code/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,8 +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,
sweep_stale_work_dirs,
)

scratchpad_status = await ensure_git_excluded(work_dir)

# 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"
Expand Down
9 changes: 9 additions & 0 deletions src/pythinker_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions src/pythinker_code/scratchpad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -429,6 +437,41 @@ 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: asyncio.to_thread(_write_gitignore_entries, gitignore_path))
except Exception:
logger.debug("scratchpad .gitignore update failed")


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:
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:
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)."""
Expand Down
Loading