Skip to content
Merged
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

- **Shell error briefs now show the trailing output of a failed command.** When a `Shell`/`Terminal` command exits non-zero, times out, or is killed by a signal, the collapsed worklog card appended only `Failed with exit code: N`; you had to expand the result to see *why*. The brief now includes the last few non-empty output lines (e.g. the stderr message), rendered as plain text so shell metacharacters (backticks, `#`, `*`) and line breaks are preserved verbatim instead of being reflowed as Markdown.
- **Subagents no longer receive plan-mode workflow reminders.** Plan mode is a session-wide flag shared with subagents (so it persists across resume), but subagent toolsets usually exclude `EnterPlanMode`/`ExitPlanMode`. Injecting the plan-mode reminder into a subagent only invited hallucinated calls to tools it doesn't have; the reminder is now root-only.
- **Terminal no longer risks hanging in raw mode on exit.** The cursor-position probe left `stdin` in cbreak mode and could block in an uninterruptible `os.read()` if cancelled mid-probe (e.g. a race with prompt_toolkit's reader on shutdown). Reads are now non-blocking during the probe and `VMIN`/`VTIME` are restored to canonical defaults, so a hang or crash can't leave the terminal wedged.
- **New `/goal` command: goal-driven execution ported from Codex CLI.** `/goal <objective>` sets a persistent thread goal the agent pursues across turns until it is verifiably complete. The objective is stored in session state (survives restarts and context compaction), kicks off work immediately with a success-criteria derivation prompt, and is re-injected on later turns as a continuation reminder carrying Codex's fidelity rules (no scope-shrinking, no easier-to-test substitutes) and evidence-based completion audit — the agent may only claim completion after proving every requirement against current state, and the user confirms with `/goal clear`. Subcommands: `view`, `pause`, `resume`, `clear`. Objectives are injected as untrusted data (`<objective>` framing), never as higher-priority instructions.
- **New `/best-practices` command (alias `/bp`).** Injects opt-in engineering best-practice guidance distilled from the Codex CLI system prompts — code-change discipline, dirty-worktree safety (never revert changes you didn't make), specific-to-broad testing strategy, todo hygiene, progress-update cadence, debugging methodology, and final-answer style — into the session context without consuming a turn. `/best-practices <section>` injects a single section.
- **SetTodoList nudges the single-`in_progress` discipline.** Todo lists with more than one `in_progress` item now get a corrective notice (ported from Codex's plan-tool contract, softened because parallel-subagent fan-out legitimately tracks one `in_progress` sub-todo per running child), and the system prompt gains matching status-discipline guidance: no single-step lists, no `pending`→`done` jumps, no batch-completing after the fact.
Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/pythinker-vis.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The server automatically opens a browser after startup. The default address is `

If the default port is in use, the server will pick the next available port (by default `5495`–`5504`) and print the access URL in the terminal.

You can also type `/vis` in the interactive shell to switch directly from the current session to the Visualizer.
You can also type `/reports` in the interactive shell to switch directly from the current session to the Visualizer.

## Command-line options

Expand Down
4 changes: 2 additions & 2 deletions docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,9 @@ Auto mode skips all approval confirmations and removes the clarifying-question s

Switch to Web UI. Pythinker Code will start a Web UI server and open the current session in your browser, allowing you to continue the conversation in the Web UI. See [Web UI](./pythinker-web.md) for details.

### `/vis`
### `/reports`

Switch to the Agent Tracing Visualizer. Pythinker Code will start the visualizer server and open the current session's tracing view in the browser, where you can inspect Wire event timelines, context messages, and usage statistics. See [Agent Tracing Visualizer](./pythinker-vis.md) for details.
Open session reports in the Agent Tracing Visualizer. Pythinker Code will start the visualizer server and open the current session's tracing view in the browser, where you can inspect Wire event timelines, context messages, and usage statistics. See [Agent Tracing Visualizer](./pythinker-vis.md) for details.

## Command completion

Expand Down
8 changes: 5 additions & 3 deletions src/pythinker_code/acp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,20 +149,22 @@ async def __call__(self, params: ShellParams) -> ToolReturnValue:
else ""
)

tail = builder.tail()
tail_suffix = f"\n{tail}" if tail else ""
if timed_out:
return builder.error(
f"Command killed by timeout ({timeout_label}){truncated_note}",
brief=f"Killed by timeout ({timeout_label})",
brief=f"Killed by timeout ({timeout_label}){tail_suffix}",
)
if exit_signal:
return builder.error(
f"Command terminated by signal: {exit_signal}.{truncated_note}",
brief=f"Signal: {exit_signal}",
brief=f"Signal: {exit_signal}{tail_suffix}",
)
if exit_code not in (None, 0):
return builder.error(
f"Command failed with exit code: {exit_code}.{truncated_note}",
brief=f"Failed with exit code: {exit_code}",
brief=f"Failed with exit code: {exit_code}{tail_suffix}",
)
return builder.ok(f"Command executed successfully.{truncated_note}")
finally:
Expand Down
21 changes: 12 additions & 9 deletions src/pythinker_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,6 @@ async def run_shell(
branch_name = _safe_git_branch(work_dir)
if branch_name:
welcome_info.append(WelcomeInfoItem(name="Branch", value=branch_name))
welcome_info.append(WelcomeInfoItem(name="Session", value=self._runtime.session.id))
if notice := _resumed_unsupervised_notice(
resumed=self._runtime.resumed,
yolo=self._runtime.approval.is_yolo(),
Expand All @@ -798,14 +797,6 @@ async def run_shell(
welcome_info.append(
WelcomeInfoItem(name="Mode", value=notice, level=WelcomeInfoItem.Level.WARN)
)
try:
auto_save_path = str(
shorten_home(HostPath.unsafe_from_local_path(self._runtime.session.context_file))
)
except Exception:
auto_save_path = ""
if auto_save_path:
welcome_info.append(WelcomeInfoItem(name="Auto-save", value=auto_save_path))
if base_url := self._env_overrides.get("PYTHINKER_BASE_URL"):
welcome_info.append(
WelcomeInfoItem(
Expand Down Expand Up @@ -861,6 +852,18 @@ async def run_shell(
level=WelcomeInfoItem.Level.WARN,
)
)
# Session persistence details come last — workspace and model identity
# read first, storage internals stay at the bottom of the facts block.
welcome_info.append(WelcomeInfoItem(name="Session", value=self._runtime.session.id))
try:
auto_save_path = str(
shorten_home(HostPath.unsafe_from_local_path(self._runtime.session.context_file))
)
except Exception:
logger.debug("Failed to compute auto-save display path", exc_info=True)
auto_save_path = ""
if auto_save_path:
welcome_info.append(WelcomeInfoItem(name="Auto-save", value=auto_save_path))
welcome_info.append(
WelcomeInfoItem(
name="Tip",
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/auth/openai/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def _parse_chatgpt_models_payload(payload: object) -> list[ModelInfo]:
raw_models = payload_object.get("models")
if not isinstance(raw_models, list):
# Keep a small compatibility path in case OpenAI ever aligns this with
# the public /v1/models shape. ChatGPT Codex currently returns
# the public /v1/models shape. The ChatGPT endpoint currently returns
# {"models": [{"slug": ...}]}.
raw_models = payload_object.get("data")
if not isinstance(raw_models, list):
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/auth/openai/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def _token_from_openai_response(payload: dict[str, Any]) -> OAuthToken:
# it lives inside the OAuth JWT claims under
# `https://api.openai.com/auth.chatgpt_account_id`. Hoist it onto the
# response so OAuthToken.from_response() picks it up. Without this the
# ChatGPT usage adapter, model catalog endpoint, and Codex request headers
# ChatGPT usage adapter, model catalog endpoint, and request headers
# cannot scope requests to the active Plus/Pro account.
if "account_id" not in normalized:
jwt_token = payload.get("id_token") or payload.get("access_token")
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/auth/platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def _select_retry_api_keys(


def _openai_fallback_models(platform_id: str) -> list[ModelInfo] | None:
# ChatGPT Codex model availability is subscription/account-specific. Do not
# ChatGPT model availability is subscription/account-specific. Do not
# replace the user's live catalog with a static fallback; stale fallback
# slugs surface as 400 "model is not supported with a ChatGPT account".
if platform_id == OPENAI_CHATGPT_PLATFORM_ID:
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple
scratchpad_status = await ensure_git_excluded(work_dir)

# Sweep accumulated state on startup (best-effort, non-blocking).
# Mirrors Claude Code's cleanupPeriodDays=30 model.
# Default 30-day retention sweep.
_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)
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/hooks/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async def run_hook(


def _extract_additional_context(parsed: dict[str, Any], hook_output: dict[str, Any]) -> str:
"""Extract Claude-Code-style additionalContext from JSON hook output."""
"""Extract ``additionalContext`` from JSON hook output."""
candidates = (hook_output.get("additionalContext"), parsed.get("additionalContext"))
for value in candidates:
if isinstance(value, str) and value.strip():
Expand Down
10 changes: 5 additions & 5 deletions src/pythinker_code/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ def create_llm(
is_dashscope_legacy = provider.type == "openai_legacy" and _is_dashscope_endpoint(
provider.base_url or ""
)
# Kimi K2.x uses the provider-specific thinking.type field on Moonshot-style
# Moonshot K2.x models use the provider-specific thinking.type field on Moonshot-style
# endpoints, but Alibaba's DashScope-compatible routes use enable_thinking.
is_kimi_openai_legacy = (
provider.type == "openai_legacy"
Expand All @@ -381,7 +381,7 @@ def create_llm(
# null reasoning_effort field.
chat_provider = chat_provider.with_thinking(effective_effort)

# Kimi K2.x on Moonshot-style endpoints and GLM use thinking.type.
# Moonshot K2.x and GLM use thinking.type on Moonshot-style endpoints.
if (is_kimi_openai_legacy or is_glm_openai_legacy) and effective_effort is not None:
thinking_body: dict[str, object] = {"type": "enabled" if thinking_on else "disabled"}
if is_glm_openai_legacy and thinking_on:
Expand Down Expand Up @@ -466,14 +466,14 @@ def clone_llm_with_model_alias(
def derive_model_capabilities(model: LLMModel) -> set[ModelCapability]:
capabilities = set(model.capabilities or ())
model_name = model.model.lower()
# Kimi K2.5/K2.6 support thinking, but it can be disabled via
# Moonshot K2.5/K2.6 support thinking, but it can be disabled via
# `thinking.type`. Keep them out of always_thinking so --no-thinking and the
# default_thinking=false config path can send the provider-specific disable
# switch in create_llm().
if _is_kimi_k2_model(model.model):
capabilities.add("thinking")
# kimi-k2-thinking is Moonshot's thinking-only variant; unlike the
# hybrid K2.5/K2.6 it cannot be switched off.
# Moonshot's thinking-only K2 variant (its model name contains
# "thinking"); unlike the hybrid K2.5/K2.6 it cannot be switched off.
if "thinking" in model_name:
capabilities.add("always_thinking")
# Models with "thinking" in their name are always-thinking models
Expand Down
7 changes: 3 additions & 4 deletions src/pythinker_code/session_cleanup.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""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).
Runs at agent startup to keep ~/.pythinker/ from growing unboundedly: with a
30-day retention period, 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.
"""
Expand Down
6 changes: 6 additions & 0 deletions src/pythinker_code/soul/dynamic_injections/plan_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ async def get_injections(
history: Sequence[Message],
soul: PythinkerSoul,
) -> list[DynamicInjection]:
# Plan-mode workflow reminders are root-only. Subagents share the
# session's plan_mode flag (so persistence/resume work), but their YAMLs
# usually exclude EnterPlanMode/ExitPlanMode, so do not inject this
# workflow guidance into subagent contexts.
if soul.is_subagent:
return []
if not soul.plan_mode:
self._inject_count = 0
return []
Expand Down
23 changes: 22 additions & 1 deletion src/pythinker_code/soul/pythinkersoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,10 @@ def __init__(

self._steer_queue: asyncio.Queue[str | list[ContentPart]] = asyncio.Queue()
self._prompt_queue_lock = asyncio.Lock()
# Tool calls made in the previous step, fed to the toolset's dedup
# tracking at the start of each step (see PythinkerToolset.begin_step).
self._last_tool_calls: list[tuple[str, str]] = []
self._current_turn_id: str = ""
self._plan_mode: bool = self._runtime.session.state.plan_mode
self._plan_session_id: str | None = self._runtime.session.state.plan_session_id
# Pre-warm slug cache so the persisted slug survives process restarts
Expand Down Expand Up @@ -1140,6 +1144,8 @@ async def _turn(self, user_message: Message) -> TurnOutcome:
if missing_caps := check_message(user_message, self._runtime.llm.capabilities):
raise LLMNotSupported(self._runtime.llm, list(missing_caps))

self._current_turn_id = uuid.uuid4().hex
self._last_tool_calls = []
self._sleep_inhibitor.set_turn_running(True)
try:
bus = shared_event_bus()
Expand Down Expand Up @@ -1486,6 +1492,9 @@ async def _agent_loop(self) -> TurnOutcome:

if back_to_the_future is not None:
await self._context.revert_to(back_to_the_future.checkpoint_id)
# The reverted history no longer contains the last step's calls,
# so they must not seed cross-step dedup for the next step.
self._last_tool_calls = []
await self._checkpoint()
await self._context.append_message(back_to_the_future.messages)

Expand Down Expand Up @@ -1564,6 +1573,14 @@ def _on_tool_result(tool_result: ToolResult) -> None:
wire_send(tool_result)

async def _run_step_once() -> StepResult:
# Reset per-step dedup state. Inside the retry wrapper on purpose: a
# retried step must not await tool tasks cancelled by the failed attempt.
if isinstance(self._agent.toolset, PythinkerToolset):
self._agent.toolset.begin_step(
self._last_tool_calls,
step_no=self._current_step_no,
turn_id=self._current_turn_id,
)
# run an LLM step (may be interrupted)
from pythinker_code.telemetry import metrics as _m
from pythinker_code.telemetry import otel as _otel
Expand Down Expand Up @@ -1750,6 +1767,10 @@ async def _pythinker_core_step_with_retry() -> StepResult:
raise
logger.debug("Got tool results: {results}", results=results)

# Update dedup tracking for the next step
if isinstance(self._agent.toolset, PythinkerToolset):
self._last_tool_calls = self._agent.toolset.end_step()

# If a tool (EnterPlanMode/ExitPlanMode) changed plan mode during execution,
# send a corrected StatusUpdate so the client sees the up-to-date state.
if self._plan_mode != plan_mode_before_tools:
Expand Down Expand Up @@ -2209,7 +2230,7 @@ def _is_retryable_error(exception: BaseException) -> bool:
if not isinstance(exception, APIStatusError):
return False
if exception.status_code == 429 and _is_hard_usage_limit(exception):
# A subscription usage cap (e.g. ChatGPT Codex `usage_limit_reached`)
# A subscription usage cap (e.g. ChatGPT `usage_limit_reached`)
# resets in hours, not seconds — retrying with backoff only adds
# latency before the inevitable failure. Surface it immediately.
return False
Expand Down
Loading
Loading