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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- Added silent startup auto-updates with `auto_update`/`PYTHINKER_AUTO_UPDATE` opt-outs and a restart-to-apply notice.
- **designer-skill MCP bridge.** Bundled a `designer-skill` stub skill that routes frontend work to the connected designer-skill MCP tools instead of failing ReadSkill; plugin-style names like `designer-skill:designer-skill` resolve correctly, and ReadSkill falls back to a generic MCP bridge (any user-configured server name) when only the MCP server is connected.
- **Always-on best practices.** New `best_practices_always` config option folds the full `/best-practices` engineering guidance into the root session's system prompt at startup, so the guardrails apply to every new session without running the command. Default off.
- **Smarter multi-edit errors.** A `StrReplaceFile` batch that fails schema validation (e.g. edit entries collapsed by a streaming glitch) now returns a precise, actionable error naming the bad entries and steering toward single-edit calls, instead of a wall of validation errors. Valid edits are never partially applied.
Expand Down
2 changes: 2 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The configuration file contains the following top-level configuration items:
| `theme` | `string` | Terminal color theme: `"dark"`, `"light"`, or `"auto"` (detects the terminal background at startup, falling back to dark); defaults to `"dark"` |
| `show_thinking_stream` | `boolean` | Whether to stream the raw reasoning text in the live area as a 6-line scrolling preview and commit the full reasoning markdown to history when the block ends (defaults to `true`; set to `false` to show only the compact `Thinking ...` indicator and a one-line trace summary) |
| `prevent_idle_sleep` | `boolean` | Whether to prevent the computer from idle-sleeping while an agent turn is running (defaults to `false`; supported on macOS, Linux, and Windows) |
| `auto_update` | `boolean` | Automatically install new releases in the background at startup; the current session keeps running and you restart to apply (defaults to `true`) |
| `merge_all_available_skills` | `boolean` | Whether to merge skills from all brand directories (defaults to `true`); see [Skills configuration](../customization/skills.md) |
| `providers` | `table` | API provider configuration |
| `models` | `table` | Model configuration |
Expand All @@ -56,6 +57,7 @@ default_editor = ""
theme = "dark"
show_thinking_stream = true
prevent_idle_sleep = false
auto_update = true
merge_all_available_skills = true

[providers.pythinker-for-coding]
Expand Down
29 changes: 28 additions & 1 deletion docs/en/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ export OPENAI_ADMIN_KEY="sk-admin-xxx"
| Environment Variable | Description |
| --- | --- |
| `PYTHINKER_SHARE_DIR` | Customize the share directory path (default: `~/.pythinker`) |
| `PYTHINKER_CLI_NO_AUTO_UPDATE` | Disable proactive update checks and startup update notices |
| `PYTHINKER_CLI_NO_AUTO_UPDATE` | Hard kill-switch: disable silent auto-install, update checks, and startup update notices |
| `PYTHINKER_AUTO_UPDATE` | Toggle silent startup auto-update (`auto_update` config field); the hard kill-switch `PYTHINKER_CLI_NO_AUTO_UPDATE` overrides it |
| `PYTHINKER_CLI_PASTE_CHAR_THRESHOLD` | Character threshold for folding pasted text (default: `200`) |
| `PYTHINKER_CLI_PASTE_LINE_THRESHOLD` | Line threshold for folding pasted text (default: `5`) |

Expand Down Expand Up @@ -169,6 +170,32 @@ export PYTHINKER_CLI_NO_AUTO_UPDATE="1"
If you installed Pythinker Code via Nix or other package managers, this environment variable is typically set automatically since updates are handled by the package manager.
:::

### `PYTHINKER_AUTO_UPDATE`

Set to `0`/`false`/`no` to disable silent startup auto-updates, or `1`/`true`/`yes`
to enable them (default). This flips the `auto_update` config field.

```sh
export PYTHINKER_AUTO_UPDATE="false"
```

::: warning Hard kill-switch wins
`PYTHINKER_CLI_NO_AUTO_UPDATE` takes precedence: when it is set, `PYTHINKER_AUTO_UPDATE=1`
cannot re-enable updates, and Pythinker shows no update activity at all.
:::

#### Per-channel behavior

When enabled and a newer installable release exists, Pythinker installs it in a
background task and shows a one-line `Updated X → Y. Restart Pythinker to apply.`
notice — the running session continues on the old version until you restart.

- **Windows** (native installer / pip): the process exits so the installer can
replace the binary.
- **Managed channels** (Docker/Nix/Scoop/WinGet): no binary swap — Pythinker
shows a channel-native upgrade hint instead.
- **Source checkouts**: never auto-update.

### `PYTHINKER_CLI_PASTE_CHAR_THRESHOLD`

In Agent mode, when pasted text exceeds this character count, it is folded into a placeholder (e.g., `[Pasted text #1 +10 lines]`) and expanded to full content on submit. Default: `200`.
Expand Down
5 changes: 5 additions & 0 deletions src/pythinker_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def find_project_root(cwd: Path) -> Path | None:
"PYTHINKER_THEME": ("theme",),
"PYTHINKER_SHOW_THINKING_STREAM": ("show_thinking_stream",),
"PYTHINKER_PREVENT_IDLE_SLEEP": ("prevent_idle_sleep",),
"PYTHINKER_AUTO_UPDATE": ("auto_update",),
"PYTHINKER_TELEMETRY": ("telemetry",),
"PYTHINKER_SESSION_RETENTION_DAYS": ("session_retention_days",),
"PYTHINKER_MERGE_ALL_AVAILABLE_SKILLS": ("merge_all_available_skills",),
Expand Down Expand Up @@ -1082,6 +1083,10 @@ class Config(BaseModel):
"Supported on macOS, Linux, and Windows. Default: false."
),
)
auto_update: bool = Field(
default=True,
description="Automatically install new releases in the background at startup.",
)
models: dict[str, LLMModel] = Field(default_factory=dict, description="List of LLM models")
providers: dict[str, LLMProvider] = Field(
default_factory=dict, description="List of LLM providers"
Expand Down
126 changes: 112 additions & 14 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,22 @@
from pythinker_code.ui.shell.slash import SKILL_COMMAND_PREFIX, shell_mode_registry
from pythinker_code.ui.shell.slash import registry as shell_slash_registry
from pythinker_code.ui.shell.update import (
MANAGED_CHANNEL_MARKER,
UpdateResult,
_detect_upgrade_command, # pyright: ignore[reportPrivateUsage]
_mark_auto_update_check_attempt, # pyright: ignore[reportPrivateUsage]
_should_auto_check_for_updates, # pyright: ignore[reportPrivateUsage]
auto_update_enabled,
consume_whats_new,
format_managed_channel_notice,
pending_update_notice,
refresh_update_cache_if_due,
welcome_update_target,
)
from pythinker_code.ui.shell.update_orchestrator import (
prompt_pre_start_update_job as prompt_pre_start_update,
SMOKE_CHECK_FAILED_PREFIX,
read_update_status,
run_update_job,
)
from pythinker_code.ui.shell.visualize import (
ApprovalPromptDelegate,
Expand Down Expand Up @@ -814,19 +823,10 @@ async def run(self, command: str | None = None) -> bool:
finally:
self._cancel_background_tasks()

# Blocking pre-start update prompt. Must run before _auto_update so the
# same upgrade isn't shown as both a blocking menu and a background
# toast; if the user picks "Skip this session" the toast is suppressed
# by _skipped_version_this_session. May raise typer.Exit on "Update now"
# or "Exit" — that's the documented behavior. prompt_pre_start_update
# self-suppresses for source checkouts and non-TTY sessions.
await prompt_pre_start_update()

# Start auto-update background task if not disabled.
if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"):
logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable")
else:
self._start_background_task(self._auto_update())
# Auto-update at startup is silent + non-blocking (default on). The old
# blocking pre-start prompt is intentionally gone; the function remains
# in update_orchestrator.py for future re-wiring.
self._schedule_startup_update_task()

if isinstance(self.soul, PythinkerSoul):
# Kick off MCP loading before the banner so servers connect in the
Expand Down Expand Up @@ -2106,6 +2106,100 @@ async def _auto_update(self) -> None:
if self._prompt_session is not None:
self._prompt_session.invalidate()

async def _silent_auto_update(self) -> None:
"""Install a newer release silently in the background at startup."""
if not _should_auto_check_for_updates():
return
_mark_auto_update_check_attempt()

result = await self._run_silent_update_job()
if result is UpdateResult.UPDATED:
self._surface_installed_update_notice()
elif result is UpdateResult.UPDATE_AVAILABLE:
self._surface_managed_channel_notice()
# FAILED / UP_TO_DATE / UNSUPPORTED / None → silent (recorded in the job log).

async def _run_silent_update_job(self) -> UpdateResult | None:
try:
return await run_update_job(print_output=False, check_only=False, source="startup-auto")
except SystemExit:
raise
except Exception:
# Boundary-only recovery: update failure must not abort the shell,
# and run_update_job has already persisted status/log details.
logger.exception("Silent auto-update failed:")
return None

def _surface_installed_update_notice(self) -> None:
if self._installed_update_smoke_check_failed():
self._update_toast(
"Update installed but verification failed; see update.log.",
style="fg:ansiyellow",
)
return
self._update_toast(
self._installed_update_restart_notice(),
style="fg:ansibrightyellow bold",
)

def _installed_update_smoke_check_failed(self) -> bool:
status = read_update_status()
message = status.message if status else None
return bool(message and message.startswith(SMOKE_CHECK_FAILED_PREFIX))

def _installed_update_restart_notice(self) -> str:
from pythinker_code.constant import VERSION as current_version

status = read_update_status()
new_version = (
(status.target_version if status else None)
or welcome_update_target()
or "the latest version"
)
return f"Updated {current_version} → {new_version}. Restart Pythinker to apply."

def _surface_managed_channel_notice(self) -> None:
notice = self._managed_channel_notice() or pending_update_notice()
if notice:
self._update_toast(notice, style="fg:ansibrightyellow bold")

def _managed_channel_notice(self) -> str | None:
from pythinker_code.constant import VERSION as current_version

# Managed-channel fast-path: skip the welcome lookup for non-managed
# installs; format_managed_channel_notice re-validates the marker.
if _detect_upgrade_command()[:1] != [MANAGED_CHANNEL_MARKER]:
return None
latest = welcome_update_target()
if latest is None:
return None
return format_managed_channel_notice(current_version, latest)

def _update_toast(self, notice: str, *, style: str) -> None:
toast(notice, topic="update", duration=30.0, immediate=True, style=style)
if self._prompt_session is not None:
self._prompt_session.invalidate()

def _schedule_startup_update_task(self) -> None:
"""Pick the startup update behavior and schedule it (non-blocking).

- env kill-switch set → nothing (cache filters already suppress the
toast, matching today's hard-disable behavior).
- enabled → silent background install.
- config-disabled OR source checkout → informational toast only
(`_auto_update`); self-suppresses for source checkouts because
`pending_update_notice()` returns None in that path.
- non-PythinkerSoul → same toast-only path (no runtime config to
consult), matching the prior unconditional `_auto_update` behavior.
"""
if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"):
logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable")
return
if isinstance(self.soul, PythinkerSoul) and auto_update_enabled(self.soul.runtime.config):
self._start_background_task(self._silent_auto_update())
else:
self._start_background_task(self._auto_update())

def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
task = asyncio.create_task(coro)
self._background_tasks.add(task)
Expand All @@ -2116,6 +2210,10 @@ def _cleanup(t: asyncio.Task[Any]) -> None:
t.result()
except asyncio.CancelledError:
pass
except SystemExit:
# The silent updater's Windows native/pip path raises SystemExit
# so the installer can replace the binary; don't crash the shell.
logger.info("Background task requested process exit (update installer launched).")
except Exception:
logger.exception("Background task failed:")

Expand Down
55 changes: 49 additions & 6 deletions src/pythinker_code/ui/shell/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
from enum import Enum, auto
from pathlib import Path
from shutil import which
from typing import cast
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
from pythinker_code.config import Config

import aiohttp
import typer
Expand Down Expand Up @@ -241,6 +244,24 @@ def _auto_update_disabled() -> bool:
return get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE")


def format_managed_channel_notice(
current: str,
latest: str,
*,
upgrade_command: list[str] | None = None,
) -> str | None:
"""One-line channel-native upgrade hint for managed installs, or None."""
command = upgrade_command if upgrade_command is not None else _detect_upgrade_command()
if command[:1] != [MANAGED_CHANNEL_MARKER] or len(command) < 2:
return None
channel = command[1]
return (
f"Pythinker is managed by your {channel} channel. "
f"Update {current} → {latest} via {channel} "
"(rebuild/repull the image or run the channel's upgrade command)."
)


def _is_running_from_source_checkout() -> bool:
"""Return true when invoked from this repository via ``uv run``/editable source.

Expand All @@ -267,6 +288,27 @@ def _is_running_from_source_checkout() -> bool:
return False


def auto_update_enabled(config: Config) -> bool:
"""Whether startup may silently install a newer release.

Precedence (highest first):
1. ``PYTHINKER_CLI_NO_AUTO_UPDATE`` (the hard kill-switch) → disabled.
2. ``config.auto_update is False`` → disabled.
3. Source checkout → disabled.
4. Otherwise → enabled.

Managed channels (Docker/Nix/Scoop/WinGet) are *not* special-cased here:
they may be "enabled" but ``_do_update`` returns ``UPDATE_AVAILABLE`` and
emits a channel hint instead of swapping the binary, so they never get a
silent install regardless of this result.
"""
if _auto_update_disabled():
return False
if config.auto_update is False:
return False
return not _is_running_from_source_checkout()


def _should_auto_check_for_updates(now: float | None = None) -> bool:
if _auto_update_disabled() or _is_running_from_source_checkout():
return False
Expand Down Expand Up @@ -1306,16 +1348,17 @@ def _print(message: str) -> None:

upgrade_command = _detect_upgrade_command()
if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]:
channel = upgrade_command[1]
try:
LATEST_VERSION_FILE.write_text(latest_version, encoding="utf-8")
except OSError:
logger.exception("Failed to cache latest version:")
_print(
f"[{_t.warning}]Pythinker is managed by your {channel} channel. "
f"Update {current_version} → {latest_version} via {channel} "
"(rebuild/repull the image or run the channel's upgrade command).[/]"
notice = format_managed_channel_notice(
current_version,
latest_version,
upgrade_command=upgrade_command,
)
if notice:
_print(f"[{_t.warning}]{notice}[/]")
return UpdateResult.UPDATE_AVAILABLE
unavailable_reason = await _update_candidate_unavailable_reason(
session, latest_version, upgrade_command
Expand Down
4 changes: 3 additions & 1 deletion src/pythinker_code/ui/shell/update_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
_LOCK_MALFORMED_GRACE_SECONDS = 60
_SMOKE_CHECK_TIMEOUT_SECONDS = 10

SMOKE_CHECK_FAILED_PREFIX = "Updated, but smoke check did not pass: "


class UpdateJobState(StrEnum):
IDLE = "idle"
Expand Down Expand Up @@ -381,7 +383,7 @@ async def run_update_job(
message = smoke_message
_write_last_success(job_id=job_id, message=message)
else:
message = f"Updated, but smoke check did not pass: {smoke_message}"
message = f"{SMOKE_CHECK_FAILED_PREFIX}{smoke_message}"

write_update_status(
_new_status(
Expand Down
23 changes: 23 additions & 0 deletions tests/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def test_default_config_dump():
"theme": "dark",
"show_thinking_stream": True,
"prevent_idle_sleep": False,
"auto_update": True,
"models": {},
"providers": {},
"loop_control": {
Expand Down Expand Up @@ -777,3 +778,25 @@ def test_statusline_v2_field_validation():
StatusLineConfig(command_timeout_ms=60_001)
with pytest.raises(ValidationError):
StatusLineConfig(command_timeout_ms=0)


def test_apply_env_vars_auto_update(monkeypatch):
monkeypatch.setenv("PYTHINKER_AUTO_UPDATE", "false")
merged: dict = {}
prov: dict = {}
_apply_env_vars(merged, prov)
assert merged["auto_update"] == "false"
assert prov["auto_update"] == "env PYTHINKER_AUTO_UPDATE"


def test_auto_update_defaults_true():
from pythinker_code.config import Config

assert Config().auto_update is True


def test_auto_update_round_trips_false():
from pythinker_code.config import Config

cfg = Config.model_validate({"auto_update": False})
assert cfg.auto_update is False
Loading
Loading