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

## Unreleased

- **The in-app updater recovers from Homebrew's untrusted-tap refusal.** Homebrew 5.0 (`HOMEBREW_REQUIRE_TAP_TRUST`) refuses to load formulas from third-party taps until `brew trust <tap>` is run once, which made the in-app `brew upgrade` fail with only a generic "run manually" hint. The updater now detects both the hard `Refusing to load … from untrusted tap` refusal and the soft `Skipping … not trusted` warning, offers to run `brew trust pythoughts-labs/pythinker` and retry the upgrade once on an interactive terminal, and otherwise prints the exact remediation. It also catches the silent no-op where an untrusted tap is skipped during `brew update` and `brew upgrade` exits 0 without advancing the version.
- **Session exports redact secrets surfaced by tool output.** A tool result (e.g. `grep`/`cat` over a `.env`) could write a secret value into an exported transcript in plaintext. `/export` now redacts the value of secret-named keys (`password`, `token`, `api_key`, `secret`, …) to `[REDACTED]` in both the markdown and YAML formats, while leaving non-secret keys such as `token_count`, usernames, and ports intact.
- **The welcome logo's antenna blinks a fixed number of times on launch, then settles.** Replaces the terminal's indefinite slow-blink with a bounded boot animation — the antenna ball blinks seven times after the banner prints and then holds steady. It is skipped under reduced motion, on non-interactive output, and when the terminal is too short to keep the antenna row on screen.

## 0.41.0 (2026-06-11)

- **New `/goal` command — goal-driven execution that loops until verified.** `/goal <objective>` sets a persistent thread goal the agent pursues across turns, restarts, and context compaction until it is verifiably complete. It kicks off immediately with a success-criteria derivation prompt and is re-injected each turn with fidelity rules (no scope-shrinking, no easier-to-test substitutes) and an evidence-based completion audit — completion may only be claimed after every requirement is proven against current state. The new root-only `UpdateGoal` tool marks the goal `complete` (after that audit) or `blocked` (after a strict three-strike audit) and stops the reminders; opt-in `goal.auto_continue` (new config table, default off, `max_continuations` 1–10, capped at 3) drives automatic continuation turns toward the goal until it is marked, a tool call is rejected, or the cap is reached, with a wrap-up instruction on the final continuation. Subcommands: `view`, `pause`, `resume`, `clear`. Objectives are injected as untrusted data, never as higher-priority instructions.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ the files and the PATH edit.
```sh
# 1. Install
brew install Pythoughts-labs/pythinker/pythinker-code
# Homebrew ≥ 5 may refuse the untrusted tap; trust it once, then re-run:
# brew trust pythoughts-labs/pythinker

# 2. Verify
pythinker --version
Expand All @@ -229,6 +231,11 @@ latest version.
**Upgrade:** `brew upgrade pythinker-code` (Homebrew packages don't
auto-update; run this whenever you want the latest).

> **Untrusted-tap refusal** — Homebrew ≥ 5 (with `HOMEBREW_REQUIRE_TAP_TRUST`)
> refuses third-party taps until you trust them once:
> `brew trust pythoughts-labs/pythinker`. The in-app updater detects the
> refusal and offers to run it for you.

**Uninstall:** `brew uninstall pythinker-code && brew untap Pythoughts-labs/pythinker`.

> The tap repo is [Pythoughts-labs/homebrew-pythinker](https://github.com/Pythoughts-labs/homebrew-pythinker)
Expand Down
78 changes: 72 additions & 6 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
)
from rich import box
from rich.align import Align
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.control import Control
from rich.markup import escape
from rich.panel import Panel
from rich.table import Table
Expand Down Expand Up @@ -2150,15 +2152,68 @@ def _cancel_background_tasks(self) -> None:
def _logo_text() -> Text:
"""Robot mark with a glowing antenna ball.

The ball carries the terminal's SGR slow-blink attribute, so terminals
with blinking text enabled blink it indefinitely; everywhere else the
bold light-coral glow reads as "powered on". Reduced motion pins the
ball steady and muted.
The ball renders steady; the boot animation (`_blink_antenna`) blinks it a
fixed number of times after the welcome banner prints, instead of the old
indefinite SGR slow-blink. Reduced motion pins the ball muted.
"""
antenna_style = _LOGO_CORAL if motion_disabled() else f"blink bold {_LOGO_CORAL_LIT}"
antenna_style = _LOGO_CORAL if motion_disabled() else f"bold {_LOGO_CORAL_LIT}"
return Text.from_markup(_LOGO_TEMPLATE.format(antenna_style=antenna_style))


# Boot animation: blink the antenna ball this many times once the agent has
# loaded and the welcome banner is on screen, then pin it steady.
_ANTENNA_BLINKS = 7
_ANTENNA_BLINK_OFF_SECONDS = 0.07
_ANTENNA_BLINK_ON_SECONDS = 0.09
_ANTENNA_GLYPH = "●"


def _antenna_cell(panel: Panel, panel_width: int) -> tuple[int, int] | None:
"""Locate the antenna ball in the rendered panel.

Returns ``(rows_above_cursor, column)`` valid immediately after the panel
prints (cursor sits on the line below it), or None when no antenna is
rendered. Scans top-down so the first ● found is the antenna, never a
same-glyph chip in the panel subtitle.
"""
options = console.options.update_width(panel_width)
lines = console.render_lines(panel, options, pad=False)
for row, segments in enumerate(lines):
column = 0
for segment in segments:
found = segment.text.find(_ANTENNA_GLYPH)
if found != -1:
return len(lines) - row, column + cell_len(segment.text[:found])
column += cell_len(segment.text)
return None


def _blink_antenna(rows_up: int, column: int) -> None:
"""Blink the antenna ball ``_ANTENNA_BLINKS`` times, then pin it steady.

Deliberately synchronous: nothing else writes to the terminal while it
runs, so the cursor-relative addressing stays valid. Runs only on the
startup path, bounded to ~1.1s total.
"""
states: list[tuple[Text, float]] = []
for _ in range(_ANTENNA_BLINKS):
states.append((Text(_ANTENNA_GLYPH, style=_LOGO_NAVY), _ANTENNA_BLINK_OFF_SECONDS))
states.append(
(Text(_ANTENNA_GLYPH, style=f"bold {_LOGO_CORAL_LIT}"), _ANTENNA_BLINK_ON_SECONDS)
)
states.append((Text(_ANTENNA_GLYPH, style=f"bold {_LOGO_CORAL_LIT}"), 0.0))
try:
console.control(Control.show_cursor(False))
for glyph, delay in states:
console.control(Control.move(y=-rows_up), Control.move_to_column(column))
console.print(glyph, end="")
console.control(Control.move(y=rows_up), Control.move_to_column(0))
if delay:
time.sleep(delay)
finally:
console.control(Control.show_cursor(True))


# 1:1 ASCII stand-ins for every decorative glyph the welcome banner emits
# (mirrors the server-banner fallback in utils/server.py). Welcome copy and
# chips pass through this when ascii_glyphs_enabled() is true so legacy code
Expand Down Expand Up @@ -2481,4 +2536,15 @@ def _panel() -> Panel:
padding=(1, 2),
)

console.print(_panel())
panel = _panel()
console.print(panel)

# Boot animation: blink the antenna 7 times, then stop. Only when the
# Unicode logo actually rendered, on a real terminal tall enough that the
# antenna row is still on screen, and never under reduced motion.
if logo_rendered and console.is_terminal and not motion_disabled():
cell = _antenna_cell(panel, panel_width)
if cell is not None:
rows_up, column = cell
if rows_up < console.size.height:
_blink_antenna(rows_up, column)
170 changes: 138 additions & 32 deletions src/pythinker_code/ui/shell/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,56 @@ def _is_homebrew_upgrade_command(command: list[str]) -> bool:
return len(command) >= 3 and command[:2] == ["brew", "upgrade"]


# Homebrew >= 5 refuses to read formulas from third-party taps until the user
# runs `brew trust <tap>` (gated on $HOMEBREW_REQUIRE_TAP_TRUST). The hard
# refusal names the tap our formula lives in and is authoritative; the soft
# "Skipping <tap>" warning is also emitted for unrelated taps during
# `brew update`, so it only counts when it names our tap.
_BREW_REFUSED_UNTRUSTED_TAP_RE = re.compile(r"Refusing to load .+ from untrusted tap (\S+)")
_BREW_SKIPPED_UNTRUSTED_TAP_RE = re.compile(r"Skipping (\S+) because it is not trusted")


def _homebrew_untrusted_tap(output_lines: list[str]) -> str | None:
"""Tap named in Homebrew's untrusted-tap refusal/skip output, or None."""
for line in output_lines:
match = _BREW_REFUSED_UNTRUSTED_TAP_RE.search(line)
if match:
return match.group(1).rstrip(".")
for line in output_lines:
match = _BREW_SKIPPED_UNTRUSTED_TAP_RE.search(line)
if match and "pythinker" in match.group(1):
return match.group(1).rstrip(".")
return None


def _can_prompt_to_trust_tap(print_output: bool) -> bool:
"""Consent to `brew trust` needs a real terminal; logs/callbacks cannot answer."""
return print_output and sys.stdout.isatty()


async def _confirm_brew_trust(tap: str) -> bool:
"""One-keypress consent to trust the tap. Declines on EOF/interrupt."""
from prompt_toolkit.shortcuts.choice_input import ChoiceInput

_t = _get_tui_tokens()
console.print(
f"[{_t.warning}]Homebrew now requires a one-time 'brew trust' before "
"installing from third-party taps.[/]"
)
try:
selection = await ChoiceInput(
message=f"Trust the tap {tap} and retry the update?",
options=[
("trust", f"Run: brew trust {tap}"),
("skip", "Not now"),
],
default="trust",
).prompt_async()
except (EOFError, KeyboardInterrupt):
return False
return selection == "trust"


def _installed_homebrew_version() -> str | None:
"""Return the highest pythinker-code version Homebrew reports as installed.

Expand Down Expand Up @@ -1346,7 +1396,19 @@ def _print(message: str) -> None:
_print(f"[{_t.muted}]The upgrade will continue in a new process.[/]")
sys.exit(0)

if _is_homebrew_upgrade_command(upgrade_command):
# Brew failure diagnosis (untrusted tap, silent no-op) needs the upgrade
# output; capture it while still forwarding every line to the caller.
captured_output: list[str] = []

def _run_streamed(command: list[str]) -> int:
def _capture(line: str) -> None:
captured_output.append(line)
if output_callback is not None:
output_callback(line)

return _run_upgrade_command(command, print_output=print_output, output_callback=_capture)

def _refresh_brew_metadata() -> None:
# `brew upgrade <formula>` resolves against the locally-cloned tap
# formula; a stale clone pins the old version and the upgrade silently
# no-ops ("already installed"). Refresh the tap first. Best-effort: if
Expand All @@ -1357,11 +1419,7 @@ def _print(message: str) -> None:
# --quiet keeps the refresh from dumping the host's full outdated
# formula/cask list (often dozens of unrelated lines) before our
# upgrade output.
refresh_code = _run_upgrade_command(
["brew", "update", "--quiet"],
print_output=print_output,
output_callback=output_callback,
)
refresh_code = _run_streamed(["brew", "update", "--quiet"])
except OSError:
logger.exception("brew update failed to launch:")
else:
Expand All @@ -1370,36 +1428,84 @@ def _print(message: str) -> None:
"brew update exited {code}; continuing with upgrade", code=refresh_code
)

try:
returncode = _run_upgrade_command(
upgrade_command,
print_output=print_output,
output_callback=output_callback,
def _print_brew_trust_hint(tap: str) -> None:
_print(f"[{_t.warning}]Trust the tap once, then update:[/]")
_print(f" brew trust {shlex_quote(tap)}")
_print(f" {upgrade_command_text}")

if _is_homebrew_upgrade_command(upgrade_command):
_refresh_brew_metadata()

brew_trust_attempted = False
while True:
try:
returncode = _run_streamed(upgrade_command)
except OSError as e:
logger.exception("Upgrade failed:")
_print(f"[{_t.error}]Upgrade failed:[/] {e}")
_print(f"Please run manually: {upgrade_command_text}")
return UpdateResult.FAILED

if returncode == 0:
break

untrusted_tap = (
_homebrew_untrusted_tap(captured_output)
if _is_homebrew_upgrade_command(upgrade_command)
else None
)
except OSError as e:
logger.exception("Upgrade failed:")
_print(f"[{_t.error}]Upgrade failed:[/] {e}")
_print(f"Please run manually: {upgrade_command_text}")
if untrusted_tap is None:
_print(f"[{_t.error}]Upgrade failed. Please try running manually:[/]")
_print(f" {upgrade_command_text}")
return UpdateResult.FAILED

logger.warning("Homebrew refused untrusted tap {tap}", tap=untrusted_tap)
_print(
f"[{_t.error}]Upgrade failed: Homebrew refuses to load formulas "
f"from the untrusted tap {untrusted_tap}.[/]"
)
if (
not brew_trust_attempted
and _can_prompt_to_trust_tap(print_output)
and await _confirm_brew_trust(untrusted_tap)
):
brew_trust_attempted = True
try:
trust_code = _run_streamed(["brew", "trust", untrusted_tap])
except OSError:
logger.exception("brew trust failed to launch:")
trust_code = 1
if trust_code == 0:
# `brew update` skipped the untrusted tap above, so its clone
# may still be stale — refresh again before retrying.
captured_output.clear()
_print(f"[{_t.muted}]Tap trusted. Retrying the upgrade...[/]")
_refresh_brew_metadata()
continue
_print(f"[{_t.error}]'brew trust {untrusted_tap}' failed.[/]")
_print_brew_trust_hint(untrusted_tap)
return UpdateResult.FAILED

if returncode == 0:
if _is_homebrew_upgrade_command(upgrade_command):
installed = _installed_homebrew_version()
if installed is not None and semver_tuple(installed) < semver_tuple(latest_version):
# brew exited 0 without changing anything (stale tap / no-op).
# Reporting success here is the bug we are fixing: don't.
_print(
f"[{_t.error}]Homebrew exited cleanly but pythinker-code is "
f"still {installed}, not {latest_version}.[/]"
)
if _is_homebrew_upgrade_command(upgrade_command):
installed = _installed_homebrew_version()
if installed is not None and semver_tuple(installed) < semver_tuple(latest_version):
# brew exited 0 without changing anything (stale tap / no-op).
# Reporting success here is the bug we are fixing: don't.
_print(
f"[{_t.error}]Homebrew exited cleanly but pythinker-code is "
f"still {installed}, not {latest_version}.[/]"
)
untrusted_tap = _homebrew_untrusted_tap(captured_output)
if untrusted_tap is not None:
# The no-op happened because `brew update` skipped our
# untrusted tap, pinning the old formula.
_print_brew_trust_hint(untrusted_tap)
else:
_print(
f"[{_t.warning}]The Homebrew tap metadata looks stale. "
"Run 'brew update' and try '/update' again.[/]"
)
return UpdateResult.FAILED
_print(f"[{_t.success}]Updated successfully![/]")
_print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]")
return UpdateResult.UPDATED
_print(f"[{_t.error}]Upgrade failed. Please try running manually:[/]")
_print(f" {upgrade_command_text}")
return UpdateResult.FAILED
return UpdateResult.FAILED
_print(f"[{_t.success}]Updated successfully![/]")
_print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]")
return UpdateResult.UPDATED
10 changes: 8 additions & 2 deletions src/pythinker_code/utils/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from pythinker_code.utils.message import message_stringify
from pythinker_code.utils.path import sanitize_cli_path
from pythinker_code.utils.sensitive import is_sensitive_file as is_sensitive_path
from pythinker_code.utils.sensitive import redact_secrets
from pythinker_code.utils.string import shorten
from pythinker_code.wire.types import (
AudioURLPart,
Expand Down Expand Up @@ -308,7 +309,10 @@ def build_export_markdown(
for idx, turn_messages in enumerate(turns):
lines.append(_format_turn_md(turn_messages, idx + 1))

return "\n".join(lines)
# Defense-in-depth: a tool result (grep/cat over a .env, etc.) can surface a
# secret value into the transcript. Redact KEY=VALUE secrets before the
# export lands on disk so credentials don't leak into shared exports.
return redact_secrets("\n".join(lines))


def _compact_message_record(msg: Message) -> dict[str, object]:
Expand Down Expand Up @@ -366,7 +370,9 @@ def build_export_yaml(
for idx, turn in enumerate(turns, start=1)
],
}
return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
# See build_export_markdown: strip KEY=VALUE secrets surfaced by tool output
# before the transcript is written.
return redact_secrets(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True))


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading