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 @@ -18,6 +18,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
- **Release packaging keeps SDK/core pins in lockstep.** The SDK's `pythinker-core`
dependency is now updated by release automation and checked by CI/release validation,
preventing no-sources binary builds from resolving against a stale core pin.
- **Routine dependency bumps with the breaking-change fallout fixed.** Upgrades `agent-client-protocol` to 0.10.1, `aiohttp` to 3.14.0, and `typer` to 0.26.5. Aligns the `pythinker-review` `typer` pin so the uv workspace resolves; migrates the ACP server to the 0.10 auth schema (`TerminalAuthMethod`) and expanded `Agent` protocol (`additional_directories`, `close_session`, session config options); and restores the optional-value behaviour of `--session`/`--resume` (interactive picker when used without an ID) under Typer 0.26's new argument parser.

## 0.29.0 (2026-06-01)

Expand Down
5 changes: 5 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Release packaging keeps SDK/core pins in lockstep.** The SDK's `pythinker-core`
dependency is now updated by release automation and checked by CI/release validation,
preventing no-sources binary builds from resolving against a stale core pin.
- **Routine dependency bumps with the breaking-change fallout fixed.** Upgrades `agent-client-protocol` to 0.10.1, `aiohttp` to 3.14.0, and `typer` to 0.26.5. Aligns the `pythinker-review` `typer` pin so the uv workspace resolves; migrates the ACP server to the 0.10 auth schema (`TerminalAuthMethod`) and expanded `Agent` protocol (`additional_directories`, `close_session`, session config options); and restores the optional-value behaviour of `--session`/`--resume` (interactive picker when used without an ID) under Typer 0.26's new argument parser.

Comment thread
elkaix marked this conversation as resolved.
## 0.29.0 (2026-06-01)

### What changed in this release
Expand Down
2 changes: 1 addition & 1 deletion packages/pythinker-review/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ classifiers = [
"Topic :: Security",
]
dependencies = [
"typer==0.21.1",
"typer==0.26.5",
"pydantic>=2.12.5",
"pyyaml==6.0.3",
"rich==15.0.0",
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ classifiers = [
"Topic :: Utilities",
]
dependencies = [
"agent-client-protocol==0.8.0",
"agent-client-protocol==0.10.1",
"aiofiles>=24.0,<26.0",
"aiohttp==3.13.5",
"typer==0.21.1",
"aiohttp==3.14.0",
"typer==0.26.5",
"pythinker-core[contrib]==1.2.0",
# notify-py (via batrachian-toad) caps loguru at <=0.6.0 on 3.14+.
"loguru>=0.6.0,<0.7",
Expand Down
127 changes: 84 additions & 43 deletions src/pythinker_code/acp/server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import asyncio
import sys
import time
from datetime import datetime
from pathlib import Path
Expand All @@ -27,14 +26,19 @@
from pythinker_code.thinking import DEFAULT_THINKING_EFFORT, effective_config_thinking_effort
from pythinker_code.utils.logging import logger

# ACP 0.10 types `auth_methods` as a discriminated union of auth-method variants.
ACPAuthMethod = (
acp.schema.EnvVarAuthMethod | acp.schema.TerminalAuthMethod | acp.schema.AuthMethodAgent
)


class ACPServer:
def __init__(self) -> None:
self.client_capabilities: acp.schema.ClientCapabilities | None = None
self.conn: acp.Client | None = None
self.sessions: dict[str, tuple[ACPSession, _ModelIDConv]] = {}
self.negotiated_version: ACPVersionSpec | None = None
self._auth_methods: list[acp.schema.AuthMethod] = []
self._auth_methods: list[ACPAuthMethod] = []

def on_connect(self, conn: acp.Client) -> None:
logger.info("ACP client connected")
Expand Down Expand Up @@ -67,32 +71,22 @@ async def initialize(
version=getattr(client_info, "version", None),
)

# get command and args of current process for terminal-auth
command = sys.argv[0]
args: list[str] = []

# Build terminal auth data for error response
terminal_args = args + ["login"]
# Build the terminal-auth args; the client re-runs the agent command
# with these args to complete login in the terminal.
terminal_args = ["login"]

# Build and cache auth methods for reuse in AUTH_REQUIRED errors
self._auth_methods = [
acp.schema.AuthMethod(
acp.schema.TerminalAuthMethod(
id="login",
name="Login with Pythinker account",
description=(
"Run `pythinker login` command in the terminal, "
"then follow the instructions to finish login."
),
# Store auth data in field_meta for building AUTH_REQUIRED error
field_meta={
"terminal-auth": {
"command": command,
"args": terminal_args,
"label": "Pythinker Login",
"env": {},
"type": "terminal",
}
},
type="terminal",
args=terminal_args,
env={},
),
]

Expand Down Expand Up @@ -155,26 +149,28 @@ def _check_auth(self, config: Config | None = None) -> None:
self._check_config_auth(config) if config is not None else self._check_token_usable()
)
if reason:
auth_methods_data: list[dict[str, Any]] = []
for m in self._auth_methods:
if m.field_meta and "terminal-auth" in m.field_meta:
terminal_auth = m.field_meta["terminal-auth"]
auth_methods_data.append(
{
"id": m.id,
"name": m.name,
"description": m.description,
"type": terminal_auth.get("type", "terminal"),
"args": terminal_auth.get("args", []),
"env": terminal_auth.get("env", {}),
}
)
auth_methods_data: list[dict[str, Any]] = [
{
"id": m.id,
"name": m.name,
"description": m.description,
"type": m.type,
"args": m.args or [],
"env": m.env or {},
}
for m in self._auth_methods
if isinstance(m, acp.schema.TerminalAuthMethod)
]

logger.warning("Authentication required, {reason}", reason=reason)
raise acp.RequestError.auth_required({"authMethods": auth_methods_data})

async def new_session(
self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any
self,
cwd: str,
additional_directories: list[str] | None = None,
mcp_servers: list[MCPServer] | None = None,
**kwargs: Any,
) -> acp.NewSessionResponse:
logger.info("Creating new session for working directory: {cwd}", cwd=cwd)
assert self.conn is not None, "ACP client not connected"
Expand Down Expand Up @@ -281,22 +277,33 @@ async def _setup_session(
return acp_session, model_id_conv

async def load_session(
self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any
) -> None:
self,
cwd: str,
session_id: str,
additional_directories: list[str] | None = None,
mcp_servers: list[MCPServer] | None = None,
**kwargs: Any,
) -> acp.schema.LoadSessionResponse | None:
logger.info("Loading session: {id} for working directory: {cwd}", id=session_id, cwd=cwd)

if session_id in self.sessions:
logger.warning("Session already loaded: {id}", id=session_id)
return
return None

# Check authentication before loading session
self._check_auth(load_config())

await self._setup_session(cwd, session_id, mcp_servers)
# TODO: replay session history?
return None

async def resume_session(
self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any
self,
cwd: str,
session_id: str,
additional_directories: list[str] | None = None,
mcp_servers: list[MCPServer] | None = None,
**kwargs: Any,
) -> acp.schema.ResumeSessionResponse:
logger.info("Resuming session: {id} for working directory: {cwd}", id=session_id, cwd=cwd)

Expand All @@ -323,12 +330,21 @@ async def resume_session(
)

async def fork_session(
self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any
self,
cwd: str,
session_id: str,
additional_directories: list[str] | None = None,
mcp_servers: list[MCPServer] | None = None,
**kwargs: Any,
) -> acp.schema.ForkSessionResponse:
raise NotImplementedError

async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
self,
additional_directories: list[str] | None = None,
cursor: str | None = None,
cwd: str | None = None,
**kwargs: Any,
) -> acp.schema.ListSessionsResponse:
logger.info("Listing sessions for working directory: {cwd}", cwd=cwd)
if cwd is None:
Expand All @@ -348,8 +364,29 @@ async def list_sessions(
next_cursor=None,
)

async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> None:
assert mode_id == "default", "Only default mode is supported"
async def set_session_mode(
self, mode_id: str, session_id: str, **kwargs: Any
) -> acp.schema.SetSessionModeResponse | None:
if session_id not in self.sessions:
raise acp.RequestError.invalid_params({"session_id": "Session not found"})
if mode_id != "default":
raise acp.RequestError.invalid_params({"mode_id": "Only `default` mode is supported"})
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async def close_session(
self, session_id: str, **kwargs: Any
) -> acp.schema.CloseSessionResponse | None:
"""Drop a session from the in-memory registry (ACP 0.10 session/close)."""
logger.info("Closing session: {id}", id=session_id)
self.sessions.pop(session_id, None)
return None

async def set_config_option(
self, config_id: str, session_id: str, value: str | bool, **kwargs: Any
) -> acp.schema.SetSessionConfigOptionResponse | None:
"""Pythinker advertises no session config options, so none can be set."""
logger.warning("Unsupported session config option: {id}", id=config_id)
raise acp.RequestError.invalid_params({"config_id": "Unknown config option"})

async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> None:
logger.info(
Expand Down Expand Up @@ -440,7 +477,11 @@ async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateR
raise acp.RequestError.invalid_params({"method_id": "Unknown auth method"})

async def prompt(
self, prompt: list[ACPContentBlock], session_id: str, **kwargs: Any
self,
prompt: list[ACPContentBlock],
session_id: str,
message_id: str | None = None,
**kwargs: Any,
) -> acp.PromptResponse:
logger.info("Received prompt request for session: {id}", id=session_id)
if session_id not in self.sessions:
Expand Down
39 changes: 28 additions & 11 deletions src/pythinker_code/cli/_lazy_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import click
import typer
from click.core import HelpFormatter
from typer._click.core import Command as _TyperCommand # typer 0.26 vendors its own click
from typer.main import get_command


Expand Down Expand Up @@ -62,30 +63,46 @@ class LazySubcommandGroup(typer.core.TyperGroup):
"web",
)

# Click options that support optional values. When the flag is present
# without a following argument the parser returns the mapped *flag_value*
# instead of raising "requires an argument".
_optional_value_options: dict[str, str] = {
"session_id": "", # --session / --resume without value → picker mode
}
# `--session`/`--resume` accept an *optional* value: with an ID they resume
# that session, without one they open the interactive picker. Typer 0.26
# reimplemented option parsing with a parser that always consumes the next
# token as the value (no optional-value support), so we normalise argv before
# parsing: when one of these flags is used without a usable value (it is the
# last token, or is followed by another option) we inject an empty-string
# sentinel that the root callback maps to picker mode.
_optional_value_flags: frozenset[str] = frozenset({"--session", "--resume", "-S", "-r"})

def make_context(
self, info_name: str | None, args: list[str], parent: click.Context | None = None, **extra
) -> click.Context:
for param in self.params:
if isinstance(param, click.Option) and param.name in self._optional_value_options:
param._flag_needs_value = True
param.flag_value = self._optional_value_options[param.name]
args = self._inject_optional_value_sentinels(args)
return super().make_context(info_name, args, parent=parent, **extra)

def _inject_optional_value_sentinels(self, args: list[str]) -> list[str]:
"""Insert an empty-string value after optional-value flags used without one."""
result: list[str] = []
seen_terminator = False
for i, arg in enumerate(args):
result.append(arg)
if seen_terminator:
continue
if arg == "--":
seen_terminator = True
continue
if arg in self._optional_value_flags:
nxt = args[i + 1] if i + 1 < len(args) else None
if nxt is None or nxt.startswith("-"):
result.append("")
return result

def list_commands(self, ctx: click.Context) -> list[str]:
commands = list(super().list_commands(ctx))
for name in self.lazy_command_order:
if name not in commands:
commands.append(name)
return commands

def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
def get_command(self, ctx: click.Context, cmd_name: str) -> _TyperCommand | None:
command = super().get_command(ctx, cmd_name)
if command is not None:
return command
Expand Down
Loading
Loading