Skip to content

Commit a286c73

Browse files
committed
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
1 parent 31d59fd commit a286c73

10 files changed

Lines changed: 54 additions & 17 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,5 @@ blackbox/
6868

6969
# pythinker-review
7070
.pythinker-review/
71+
# pythinker — local agent state (do not commit)
72+
.pythinker-review-flow/

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
- **`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.
1919
- **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.
20+
- **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.
2021
- **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.
2122

2223
## 0.31.0 (2026-06-02)

src/pythinker_code/acp/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse:
215215
case _:
216216
pass
217217
except LLMNotSet as e:
218-
logger.exception("LLM not set:")
218+
logger.warning("LLM not set — user has no provider configured")
219219
raise acp.RequestError.auth_required() from e
220220
except LLMNotSupported as e:
221221
logger.exception("LLM not supported:")

src/pythinker_code/soul/pythinkersoul.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ def type_check(soul: PythinkerSoul):
129129
DEFAULT_MAX_FLOW_MOVES = 1000
130130

131131

132+
def _safe_cwd(fallback: str) -> str:
133+
"""Return the current working directory as a string.
134+
135+
Falls back to *fallback* if the process CWD has been deleted (e.g. the
136+
project directory was removed mid-session by a shell command).
137+
"""
138+
try:
139+
return str(Path.cwd())
140+
except FileNotFoundError:
141+
return str(fallback)
142+
143+
132144
def classify_llm_system(chat_provider: object | None) -> str:
133145
"""Classify a chat provider into a stable gen_ai.system telemetry value."""
134146
try:
@@ -875,7 +887,7 @@ async def run(
875887
matcher_value=text_input_for_hook,
876888
input_data=events.user_prompt_submit(
877889
session_id=self._runtime.session.id,
878-
cwd=str(Path.cwd()),
890+
cwd=_safe_cwd(str(self._runtime.session.work_dir)),
879891
prompt=text_input_for_hook,
880892
),
881893
)
@@ -917,7 +929,7 @@ async def run(
917929
"Stop",
918930
input_data=events.stop(
919931
session_id=self._runtime.session.id,
920-
cwd=str(Path.cwd()),
932+
cwd=_safe_cwd(str(self._runtime.session.work_dir)),
921933
stop_hook_active=False,
922934
),
923935
)
@@ -1298,7 +1310,7 @@ async def _agent_loop(self) -> TurnOutcome:
12981310
matcher_value=type(e).__name__,
12991311
input_data=_hook_events.stop_failure(
13001312
session_id=self._runtime.session.id,
1301-
cwd=str(Path.cwd()),
1313+
cwd=_safe_cwd(str(self._runtime.session.work_dir)),
13021314
error_type=type(e).__name__,
13031315
error_message=str(e),
13041316
),
@@ -1361,7 +1373,7 @@ async def _append_notification(view: NotificationView) -> None:
13611373
matcher_value=view.event.type,
13621374
input_data=events.notification(
13631375
session_id=self._runtime.session.id,
1364-
cwd=str(Path.cwd()),
1376+
cwd=_safe_cwd(str(self._runtime.session.work_dir)),
13651377
sink="llm",
13661378
notification_type=view.event.type,
13671379
title=view.event.title,
@@ -1706,7 +1718,7 @@ async def _compact_with_retry() -> CompactionResult:
17061718
matcher_value=trigger_reason,
17071719
input_data=events.pre_compact(
17081720
session_id=self._runtime.session.id,
1709-
cwd=str(Path.cwd()),
1721+
cwd=_safe_cwd(str(self._runtime.session.work_dir)),
17101722
trigger=trigger_reason,
17111723
token_count=before_tokens,
17121724
custom_instructions=custom_instruction,
@@ -1763,7 +1775,7 @@ async def _compact_with_retry() -> CompactionResult:
17631775
matcher_value=trigger_reason,
17641776
input_data=events.post_compact(
17651777
session_id=self._runtime.session.id,
1766-
cwd=str(Path.cwd()),
1778+
cwd=_safe_cwd(str(self._runtime.session.work_dir)),
17671779
trigger=trigger_reason,
17681780
estimated_token_count=estimated_token_count,
17691781
compact_summary=summary_text,
@@ -1774,7 +1786,7 @@ async def _compact_with_retry() -> CompactionResult:
17741786
matcher_value="compact",
17751787
input_data=events.session_start(
17761788
session_id=self._runtime.session.id,
1777-
cwd=str(Path.cwd()),
1789+
cwd=_safe_cwd(str(self._runtime.session.work_dir)),
17781790
source="compact",
17791791
),
17801792
)

src/pythinker_code/tools/shell/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -281,12 +281,12 @@ async def _run_shell_command(
281281
timeout: int,
282282
) -> int:
283283
async def _read_stream(stream: AsyncReadable, cb: Callable[[bytes], None]):
284-
while True:
285-
line = await stream.readline()
286-
if line:
287-
cb(line)
288-
else:
289-
break
284+
# Use read() instead of readline() to avoid asyncio's 64 KB per-line
285+
# limit (raises LimitOverrunError / ValueError depending on Python
286+
# version). The callbacks only accumulate text, so chunk boundaries
287+
# do not matter for correctness.
288+
while chunk := await stream.read(65536):
289+
cb(chunk)
290290

291291
process = await pythinker_host.exec(
292292
*self._shell_args(command), env=get_noninteractive_env()

src/pythinker_code/ui/print/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ def _handler():
408408

409409
command = None
410410
except LLMNotSet as e:
411-
logger.exception("LLM not set:")
411+
logger.warning("LLM not set — user has no provider configured")
412412
print(str(e))
413413
return ExitCode.FAILURE
414414
except LLMNotSupported as e:

src/pythinker_code/ui/shell/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1275,7 +1275,7 @@ def _on_view_ready(view: Any) -> None:
12751275
return True
12761276
except LLMNotSet:
12771277
_t = _get_tui_tokens()
1278-
logger.exception("LLM not set:")
1278+
logger.warning("LLM not set — user has no provider configured")
12791279
console.print(f'[{_t.error}]LLM not set, send "/login" to login[/]')
12801280
except LLMNotSupported as e:
12811281
# actually unsupported input/mode should already be blocked by prompt session

src/pythinker_code/wire/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,12 @@ async def _consume_loop(self, queue: Queue[WireMessage]) -> None:
140140
while True:
141141
try:
142142
msg = await queue.get()
143-
await self._record(msg)
144143
except QueueShutDown:
145144
break
145+
try:
146+
await self._record(msg)
147+
except Exception:
148+
logger.exception("Wire recorder failed to persist message:")
146149

147150
async def _record(self, msg: WireMessage) -> None:
148151
await self._wire_file.append_message(msg)

tests/core/test_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def test_default_config_dump():
8282
"merge_all_available_skills": True,
8383
"extra_skill_dirs": [],
8484
"telemetry": True,
85+
"session_retention_days": 30,
8586
"skip_auto_prompt_injection": False,
8687
"tui": {
8788
"style": "card",

tests/tools/test_shell_bash.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,19 @@ async def test_output_truncation_on_failure(shell_tool: Shell):
194194
assert "Command failed with exit code:" in result.message
195195

196196

197+
async def test_oversized_output_line(shell_tool: Shell):
198+
"""A single output line exceeding asyncio's 64 KB readline limit must not crash the tool."""
199+
# asyncio.StreamReader's default limit is 65536 bytes; emit a 70 KB line.
200+
result = await shell_tool(
201+
Params(command="python3 -c \"print('X' * 70000)\""),
202+
)
203+
# The tool must return a result (not raise), and the oversized content must
204+
# appear in the output rather than being silently dropped.
205+
assert not result.is_error
206+
assert isinstance(result.output, str)
207+
assert "X" in result.output
208+
209+
197210
async def test_timeout_parameter_validation_bounds(shell_tool: Shell):
198211
"""Test timeout parameter validation (bounds checking)."""
199212
# Test timeout < 1 (should fail validation)
@@ -254,6 +267,11 @@ async def readline(self) -> bytes:
254267
await asyncio.Event().wait()
255268
raise AssertionError("unreachable")
256269

270+
async def read(self, n: int = -1) -> bytes:
271+
started.set()
272+
await asyncio.Event().wait()
273+
raise AssertionError("unreachable")
274+
257275
class FakeStdin:
258276
def close(self) -> None:
259277
pass

0 commit comments

Comments
 (0)