Skip to content
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Post-update smoke check now verifies the upgraded binary and version.** On
Homebrew installs the smoke check exercised the still-running old keg via
`sys.executable`, so it could report "passed" with the pre-upgrade version;
it now targets the brew `opt`-linked launcher and fails (as
`VERIFICATION_FAILED`) when the reported version does not match the update
target. The persistent "restart to apply" notice is also derived from the
recorded update status alone, so dismissing a version's install prompt no
longer hides the restart notice after that version is installed.
- **Updates never interrupt a running session.** On Windows, the background
auto-updater previously launched the installer mid-session, force-closing the
active Pythinker session. Updates are now downloaded and staged with a verified
manifest, surfaced as a "restart to apply" notice, and applied before the next
session starts (or at clean exit with the new `apply_on_exit` policy). The
`auto_update` config becomes a policy enum — `off`, `notify`, `download`
(default), `apply_on_exit` — with legacy booleans still accepted
(`true` → `download`, `false` → `notify`); `pythinker info` now reports the
mode string, and `/update auto` accepts the new mode names.

## 0.59.0 (2026-07-17)

- **Reviewer subagents now receive deterministic Git scopes.** Structured automatic,
Expand Down
11 changes: 11 additions & 0 deletions src/pythinker_code/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,17 @@ def _emit_fatal_error(message: str) -> None:
param_hint="--session",
)

# Apply a previously staged Windows update before any session, runtime, or
# agent is constructed. Interactive shell launches only: print/ACP/wire
# callers are scripted flows where exiting to run an installer would break
# the invoker. Fail closed inside: an invalid stage is discarded and normal
# startup continues.
if ui == "shell" and prompt is None:
from pythinker_code.ui.shell.update import apply_staged_update_before_start

if apply_staged_update_before_start():
raise typer.Exit(0)

config: Config | Path | None = None
if config_string is not None:
config_string = config_string.strip()
Expand Down
10 changes: 5 additions & 5 deletions src/pythinker_code/cli/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ class InfoData(TypedDict):
wire_protocol_version: str
python_version: str
auto_update: bool | None
auto_update_config: bool | None
auto_update_config: str | None
auto_update_override: str | None


def _auto_update_info() -> tuple[bool | None, bool | None, str | None]:
"""Return ``(effective_enabled, config_value, override_reason)``.
def _auto_update_info() -> tuple[bool | None, str | None, str | None]:
"""Return ``(effective_enabled, config_mode, override_reason)``.

Every element is ``None`` when the status cannot be resolved. The whole
block is guarded so an unreadable config or any other failure never turns
Expand All @@ -38,7 +38,7 @@ def _auto_update_info() -> tuple[bool | None, bool | None, str | None]:
# has no config file yet rather than creating one as a side effect.
config_exists = get_config_file(create=False).expanduser().exists()
config = load_config() if config_exists else Config()
return auto_update_enabled(config), config.auto_update, override
return auto_update_enabled(config), config.auto_update.value, override
except (OSError, ValueError, ImportError) as exc:
# Read-only diagnostic: never abort `info`, but log the degraded path
# instead of silently masking a real config/policy failure. ConfigError
Expand Down Expand Up @@ -73,7 +73,7 @@ def _auto_update_line(info: InfoData) -> str:
if effective is None:
return "auto-update: unknown"
state = "enabled" if effective else "disabled"
detail = f"config auto_update={'true' if info['auto_update_config'] else 'false'}"
detail = f"config auto_update={info['auto_update_config'] or 'unknown'}"
override = info["auto_update_override"]
if override:
detail += f"; {override}"
Expand Down
13 changes: 10 additions & 3 deletions src/pythinker_code/cli/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ def update(
if ctx.invoked_subcommand is not None:
return

from pythinker_code.ui.shell.update import UpdateResult
from pythinker_code.ui.shell.update import UpdateIntent, UpdateResult
from pythinker_code.ui.shell.update_orchestrator import run_update_job

result = asyncio.run(run_update_job(print_output=True, check_only=check_only, source="cli"))
if result in (UpdateResult.FAILED, UpdateResult.UNSUPPORTED):
# The standalone CLI is its own foreground process: exiting to hand off to
# the platform installer is expected, unlike in-shell updates which stage.
intent = UpdateIntent.CHECK if check_only else UpdateIntent.INSTALL_AND_EXIT
result = asyncio.run(run_update_job(print_output=True, intent=intent, source="cli"))
if result in (
UpdateResult.FAILED,
UpdateResult.VERIFICATION_FAILED,
UpdateResult.UNSUPPORTED,
):
raise typer.Exit(1)


Expand Down
61 changes: 58 additions & 3 deletions src/pythinker_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import contextlib
import json
import os
from enum import StrEnum
from pathlib import Path
from types import UnionType
from typing import Any, Literal, Self, Union, cast, get_args, get_origin
Expand Down Expand Up @@ -1115,6 +1116,47 @@ class PluginsConfig(BaseModel):
)


class AutoUpdateMode(StrEnum):
"""Startup auto-update policy.

``OFF`` schedules nothing at startup; ``NOTIFY`` only refreshes the passive
update notice; ``DOWNLOAD`` downloads and stages the new release in the
background so a restart applies it; ``APPLY_ON_EXIT`` additionally launches
the staged installer when the process exits cleanly. No mode ever installs
or restarts while an interactive session is running.
"""

OFF = "off"
NOTIFY = "notify"
DOWNLOAD = "download"
APPLY_ON_EXIT = "apply_on_exit"


# Legacy boolean spellings accepted for backward compatibility with the old
# `auto_update: bool` config field and PYTHINKER_AUTO_UPDATE env values.
_AUTO_UPDATE_LEGACY_TRUE = frozenset({"true", "1", "yes"})
_AUTO_UPDATE_LEGACY_FALSE = frozenset({"false", "0", "no"})


def coerce_auto_update_mode(value: object) -> object:
"""Map legacy boolean auto_update values onto the policy enum.

``true`` keeps its old meaning of "update automatically in the background"
(now download-and-stage); ``false`` maps to ``notify`` because the old
disabled state still surfaced the passive update notice.
"""
if isinstance(value, bool):
return AutoUpdateMode.DOWNLOAD if value else AutoUpdateMode.NOTIFY
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in _AUTO_UPDATE_LEGACY_TRUE:
return AutoUpdateMode.DOWNLOAD
if normalized in _AUTO_UPDATE_LEGACY_FALSE:
return AutoUpdateMode.NOTIFY
return normalized
return value


class Config(BaseModel):
"""Main configuration structure."""

Expand Down Expand Up @@ -1223,10 +1265,23 @@ 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.",
auto_update: AutoUpdateMode = Field(
default=AutoUpdateMode.DOWNLOAD,
description=(
"Startup auto-update policy: 'off' (no startup update task), 'notify' "
"(show update notices only), 'download' (download and stage new releases "
"in the background; a restart applies them), or 'apply_on_exit' (also "
"launch the staged installer after the session exits). Updates are never "
"applied while a session is running. Legacy booleans are accepted: "
"true → download, false → notify."
),
)

@field_validator("auto_update", mode="before")
@classmethod
def _coerce_auto_update(cls, value: object) -> object:
return coerce_auto_update_mode(value)

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
121 changes: 86 additions & 35 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,24 @@
from pythinker_code.ui.shell.slash import registry as shell_slash_registry
from pythinker_code.ui.shell.update import (
MANAGED_CHANNEL_MARKER,
UpdateIntent,
UpdateResult,
_detect_upgrade_command, # pyright: ignore[reportPrivateUsage]
_mark_auto_update_check_attempt, # pyright: ignore[reportPrivateUsage]
_should_auto_check_for_updates, # pyright: ignore[reportPrivateUsage]
consume_whats_new,
format_managed_channel_notice,
pending_update_notice,
read_windows_staged_update,
refresh_update_cache_if_due,
register_windows_staged_apply_on_exit,
semver_tuple,
welcome_update_target,
)
from pythinker_code.ui.shell.update_orchestrator import (
SMOKE_CHECK_FAILED_PREFIX,
SMOKE_CHECK_FAILED_PREFIX as SMOKE_CHECK_FAILED_PREFIX,
)
from pythinker_code.ui.shell.update_orchestrator import (
read_update_status,
run_update_job,
update_restart_pending,
Expand All @@ -94,7 +100,7 @@
from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled, motion_disabled
from pythinker_code.ui.theme import BRAND, BrandToken, tui_rich_style
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens
from pythinker_code.update_policy import auto_update_enabled
from pythinker_code.update_policy import resolve_auto_update_mode
from pythinker_code.utils.aioqueue import QueueShutDown
from pythinker_code.utils.envvar import get_env_bool
from pythinker_code.utils.logging import logger
Expand Down Expand Up @@ -2145,55 +2151,80 @@ async def _auto_update(self) -> None:
self._refresh_update_notice_line()

async def _silent_auto_update(self) -> None:
"""Install a newer release silently in the background at startup."""
"""Download and stage a newer release in the background at startup.

This never installs mid-session: the Windows installer / native binary
is staged and applied at the next launch (or at clean exit under the
``apply_on_exit`` policy), which the persistent restart notice reflects.
"""
if not _should_auto_check_for_updates():
return

result = await self._run_silent_update_job()
# Throttle only after a completed round-trip. Marking before the network
# call (or after a FAILED one) would suppress updates for the whole
# interval on a transient startup blip — mirrors _refresh_update_cache.
if result is not None and result is not UpdateResult.FAILED:
if result is not None and result not in (
UpdateResult.FAILED,
UpdateResult.VERIFICATION_FAILED,
):
_mark_auto_update_check_attempt()
if result is UpdateResult.UPDATED:
self._maybe_arm_windows_apply_on_exit()
self._surface_installed_update_notice()
elif result is UpdateResult.VERIFICATION_FAILED:
self._surface_update_verification_failure()
elif result is UpdateResult.UPDATE_AVAILABLE:
self._surface_managed_channel_notice()
# FAILED / UP_TO_DATE / UNSUPPORTED / None → silent (recorded in the job log).
# Other FAILED / UP_TO_DATE / UNSUPPORTED / None results stay in the job log.

def _maybe_arm_windows_apply_on_exit(self) -> None:
from pythinker_code.config import AutoUpdateMode

if not isinstance(self.soul, PythinkerSoul):
return
mode = resolve_auto_update_mode(self.soul.runtime.config)
if mode is not AutoUpdateMode.APPLY_ON_EXIT:
return
if read_windows_staged_update() is None:
return
register_windows_staged_apply_on_exit()

async def _run_silent_update_job(self) -> UpdateResult | None:
try:
return await run_update_job(print_output=False, check_only=False, source="startup-auto")
return await run_update_job(
print_output=False, intent=UpdateIntent.STAGE_FOR_RESTART, source="startup-auto"
)
except SystemExit:
raise
# STAGE_FOR_RESTART must never exit the process; reaching this means
# an install path leaked into the background task. Contain it — a
# propagated SystemExit would tear down the user's session (the
# exact mid-session kill this path is designed to prevent).
logger.error("Background update task attempted to exit the process; suppressed.")
return None
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
# The persistent under-input line (_append_update_notice) already renders
# the restart message; a toast duplicates it on the footer's second row.
self._refresh_update_notice_line()

def _surface_update_verification_failure(self) -> None:
self._update_toast(
"Update installed but verification failed; see update.log.",
style="fg:ansiyellow",
)

def _refresh_update_notice_line(self) -> None:
"""Drop the update-notice memo and repaint so the footer picks up new text."""
self._update_notice_cache = (0.0, None)
if self._prompt_session is not None:
self._prompt_session.invalidate()

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

Expand Down Expand Up @@ -2243,16 +2274,26 @@ def _update_notice_text(self) -> str | None:
return text

def _compute_update_notice(self) -> str | None:
target = welcome_update_target()
if not target:
return None
from pythinker_code.constant import VERSION as current_version

# A release already installed this session needs a restart, not /update —
# surface that here instead of telling the user to re-run an update that
# has already landed.
# surface that instead of telling the user to re-run an update that has
# already landed. Checked against the recorded job status alone, NOT the
# dismissal-filtered update-available cache: dismissing a version's
# install prompt must not also hide the restart notice once that version
# is actually installed.
status = read_update_status()
if update_restart_pending(status, target):
installed_target = status.target_version if status is not None else None
if (
installed_target
and semver_tuple(installed_target) > semver_tuple(current_version)
and update_restart_pending(status, installed_target)
):
text = self._installed_update_restart_notice()
else:
target = welcome_update_target()
if not target:
return None
text = f"↑ Update available — v{target} · /update"
if ascii_glyphs_enabled():
text = text.translate(_WELCOME_ASCII_FALLBACKS)
Expand All @@ -2263,20 +2304,29 @@ def _schedule_startup_update_task(self) -> None:

- env kill-switch set → nothing (cache filters already suppress the
notice, matching today's hard-disable behavior).
- enabled → silent background install.
- config-disabled OR source checkout → refresh the persistent notice
only (`_auto_update`); self-suppresses for source checkouts because
`pending_update_notice()` returns None in that path.
- non-PythinkerSoul → same notice-refresh path (no runtime config to
- `off` (or source checkout) → nothing.
- `notify` → refresh the persistent notice only (`_auto_update`).
- `download` / `apply_on_exit` → background download-and-stage
(`_silent_auto_update`); never installs mid-session.
- non-PythinkerSoul → the notice-refresh path (no runtime config to
consult), matching the prior unconditional `_auto_update` behavior.
"""
from pythinker_code.config import AutoUpdateMode

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:
if not isinstance(self.soul, PythinkerSoul):
self._start_background_task(self._auto_update())
return
mode = resolve_auto_update_mode(self.soul.runtime.config)
if mode is AutoUpdateMode.OFF:
logger.info("Startup update task disabled by auto_update policy 'off'")
return
if mode is AutoUpdateMode.NOTIFY:
self._start_background_task(self._auto_update())
return
self._start_background_task(self._silent_auto_update())

def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
task = asyncio.create_task(coro)
Expand All @@ -2289,9 +2339,10 @@ def _cleanup(t: asyncio.Task[Any]) -> None:
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).")
# Defense in depth: no background task is allowed to request
# process exit (updates stage for restart instead). If one
# slips through, contain it here rather than killing the shell.
logger.error("Background task raised SystemExit; suppressed to keep the session.")
except Exception:
logger.exception("Background task failed:")

Expand Down
Loading
Loading