Skip to content

Commit 5a10baf

Browse files
authored
Merge branch 'main' into dependabot/uv/minor-and-patch-396ca2bbe2
2 parents eff9ec3 + 312dc8c commit 5a10baf

239 files changed

Lines changed: 10528 additions & 2969 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,4 @@ blackbox/
7777
.coverage.*
7878
coverage.xml
7979
htmlcov/
80+
*.scratchpad.lock

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ subagents, skills, web/visualization UIs, and multi-provider LLM authentication.
4747
- **Do not manually edit auto-synced changelog files.** `docs/en/release-notes/changelog.md` is
4848
generated from the root `CHANGELOG.md`; edit `CHANGELOG.md` and run `npm run sync` from `docs/`
4949
instead of hand-editing the generated docs changelog.
50+
- **Before opening any PR that touches shipped code, add a `## Unreleased` entry to `CHANGELOG.md`.**
51+
The required `changelog-entry-required` check fails a PR that changes shipped paths (`src/*`,
52+
`packages/*`, installers, release/installer workflows, `pythinker.spec`) but adds no new non-blank
53+
line under the `## Unreleased` heading — and this has repeatedly blocked PRs. Add a `- ...` bullet
54+
describing the user-facing change up front. Only skip via the `no-changelog` label or
55+
`[skip changelog]` in the PR body when the change is genuinely user-invisible.
5056
- **When working on a PR or GitHub Actions failure, investigate and identify the root cause first.**
5157
Provide the best-practice, most robust design solution; never provide fast fixes or workarounds.
5258
This is a hard constraint.

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Web: same-origin WebSockets accepted, version banner synced to the backend, and token bootstrap race fixed.** The local-mode web server now auto-populates the allowed-origin list (an empty allowlist rejects every `Origin`-bearing request, which previously broke all WebSocket handshakes with a 403). The UI version banner prefers the version the running backend reports (via the config API) over the stale build-time constant, and a transient version-fetch failure no longer permanently disables the backend banner for the session. The initial auth-token bootstrap race that could fail the first request is resolved. `ESC` now reliably terminates only the background tasks spawned by the interrupted turn, and recall context is re-framed so prior-session snippets can't be misread as new instructions.
19+
- **Deep-audit remediation: security, correctness, and multi-instance robustness.** Permission gate: awk programs that shell out via `print | "cmd"` / `getline` are now classified as mutating AND destructive (previously only `system(`/`>` and only mutating), and `xargs -L N` no longer hides its payload from classification. Glob resolves symlinks before its workspace-boundary check (an in-workspace symlink could previously list outside content); progress-note titles are ANSI-sanitized like every other transcript field. Grep content lines are parsed with unambiguous field separators, so paths like `utf-8-codec.py` are no longer mangled with `-n=false` and sensitive-file attribution is exact. Multi-line edits on CRLF files work again (LF-joined old strings are CRLF-translated when needed). `/import` preserves paths byte-for-byte (only a standalone leading/trailing `--force` is treated as the flag). Post-compaction file reminders include `--add-dir` files. Double-interrupt can no longer orphan the interruption-marker write (unanswered tool_calls). Background web replay falls back to full history (not empty) when the watermark stat fails, and a malformed Agent resume id returns a clean "Agent not found". OAuth: login fails loud when the token response lacks a `refresh_token`; a refresh response without `expires_in` carries the previous lifetime forward instead of refreshing every tick; the device-id file can no longer be read empty mid-creation. A failed `theme="auto"` background probe can be retried by re-selecting auto via `/theme`. Multi-instance: sessions now take a per-session writer lock (a second `pythinker -r <id>`/web worker on the same session is refused instead of interleaving turns), the shared `pythinker.json` index uses a locked read-modify-write (no more lost work-dir registrations), JSONL appenders repair torn final lines after a crash, forks materialize atomically, project-memory mutations abort on read failure instead of wiping the file, the journal is capped at 100 recaps, inbox approve/reject claims candidates atomically, and recall re-arms when another instance writes new memory. Subagents: a failed summary continuation no longer discards a completed agent's work, hallucinated subagent types fail fast with the valid-type list (before any RunAgents child launches), background failures carry an `Agent ID:` + resume hint, and a crash inside the runner's own error handling is logged instead of silently lost.
20+
- **Breaking (CLI flags): `pythinker web` / `pythinker vis` host short flag is now `-H`.** `-h` is a help alias on both subcommands (matching the root CLI); previously `-h <ip>` bound the host. Scripts using `-h 0.0.0.0` now print help and exit 0 without starting a server — switch to `-H <ip>` or `--host <ip>`. Part of the security/correctness audit (which also confined Grep to the workspace, gated non-HTTPS provider URLs in the web config API to loopback, and stopped saving OpenAI keys on 401/403).
21+
- **Thinking effort moved to a single top-right label on the input border.** The input box border is now one static frame grey at every effort level instead of recoloring the whole bar cold→hot, and the effort is no longer duplicated in the footer line. It's shown once, as a small label flushed to the right of the input's top border — a level-colored dot (slate→blue→teal→amber→orange→red as `off→max`) plus the muted level word — so the dial stays glanceable without tinting the typing area or cluttering the footer. The label is hidden entirely for native-thinking models (`always_thinking`, no user dial) and non-thinking models, and the rule auto-shortens by the label width so the line never wraps.
22+
- **Qwen models treated as native-thinking across both plans.** Qwen3.x/3.7 (e.g. `qwen3.7-max`, `qwen3.6-plus`, the Qwen3 Coder models) now carry the `always_thinking` capability on both the Alibaba Model Studio and OpenCode Go plans, matching GLM/MiniMax: reasoning is built in and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic `thinking` block that both Anthropic-compatible routes accept.
23+
- **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default.
24+
1825
## 0.39.0 (2026-06-09)
1926

2027
- **Refreshed TUI theme and Catppuccin syntax highlighting.** The interface adopts a brand periwinkle/indigo accent (`#B3B9F4` dark / `#0B114E` light) with a reharmonized selection tint, and code blocks now highlight with Catppuccin Mocha (dark) / Latte (light), adaptive to the active theme — implemented as foreground-only Pygments styles with no new dependency. Markdown inline code and links render terminal-native cyan, blockquotes green, and ordered-list markers bright blue (so they adapt per terminal), and user messages sit on a neutral grey block instead of the prior blue tint.

packages/pythinker-core/tests/test_anthropic_thinking.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ def test_supports_adaptive_thinking(model: str, expected: bool) -> None:
114114
("claude-opus-4-8", "max", "max"),
115115
("claude-opus-5-0", "max", "max"),
116116
("claude-opus-5-0", "xhigh", "high"),
117+
# Qwen via the Anthropic-compatible endpoint (Alibaba Model Studio /
118+
# OpenCode Go @ai-sdk/anthropic): a non-Claude model, so it takes the
119+
# pre-4.6 budget path. Effort must land in {low, medium, high} so the
120+
# budgets[...] lookup in with_thinking can never KeyError, and xhigh/max
121+
# clamp to high while minimal floors to low.
122+
("qwen3.7-max", "off", "off"),
123+
("qwen3.7-max", "minimal", "low"),
124+
("qwen3.7-max", "low", "low"),
125+
("qwen3.7-max", "medium", "medium"),
126+
("qwen3.7-max", "high", "high"),
127+
("qwen3.7-max", "xhigh", "high"),
128+
("qwen3.7-max", "max", "high"),
117129
],
118130
)
119131
def test_clamp_effort(model: str, effort: str, expected: str) -> None:

packages/pythinker-host/src/pythinker_host/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ async def chdir(self, path: StrOrHostPath) -> None:
156156
"""Change the current working directory."""
157157
...
158158

159+
async def realpath(self, path: StrOrHostPath) -> HostPath:
160+
"""Resolve symlinks and return the real absolute path."""
161+
...
162+
159163
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
160164
"""Get the stat result for a path."""
161165
...
@@ -282,6 +286,10 @@ async def chdir(path: StrOrHostPath) -> None:
282286
await get_current_host().chdir(path)
283287

284288

289+
async def realpath(path: StrOrHostPath) -> HostPath:
290+
return await get_current_host().realpath(path)
291+
292+
285293
async def stat(path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
286294
return await get_current_host().stat(path, follow_symlinks=follow_symlinks)
287295

packages/pythinker-host/src/pythinker_host/local.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ async def chdir(self, path: StrOrHostPath) -> None:
9999
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
100100
os.chdir(local_path)
101101

102+
async def realpath(self, path: StrOrHostPath) -> HostPath:
103+
"""Resolve symlinks and return the real path (follows symlinks)."""
104+
local = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
105+
resolved = await asyncio.to_thread(os.path.realpath, str(local))
106+
return HostPath.unsafe_from_local_path(Path(resolved))
107+
102108
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
103109
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
104110
st = await aiofiles.os.stat(local_path, follow_symlinks=follow_symlinks)
@@ -143,7 +149,7 @@ async def readtext(
143149
errors: Literal["strict", "ignore", "replace"] = "strict",
144150
) -> str:
145151
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
146-
async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:
152+
async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f:
147153
return await f.read()
148154

149155
async def readlines(
@@ -154,7 +160,7 @@ async def readlines(
154160
errors: Literal["strict", "ignore", "replace"] = "strict",
155161
) -> AsyncGenerator[str]:
156162
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
157-
async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:
163+
async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f:
158164
async for line in f:
159165
yield line
160166

packages/pythinker-host/src/pythinker_host/path.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ def expanduser(self) -> HostPath:
118118
return home
119119
return home.joinpath(*parts[1:])
120120

121+
async def realpath(self) -> HostPath:
122+
"""Resolve symlinks and return the real absolute path."""
123+
return await pythinker_host.realpath(self)
124+
121125
async def stat(self, follow_symlinks: bool = True) -> pythinker_host.StatResult:
122126
"""Return an os.stat_result for the path."""
123127
return await pythinker_host.stat(self, follow_symlinks=follow_symlinks)

packages/pythinker-host/src/pythinker_host/ssh.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,29 @@ async def chdir(self, path: StrOrHostPath) -> None:
179179
await self._sftp.chdir(str(path))
180180
self._cwd = await self._sftp.realpath(".")
181181

182+
async def realpath(self, path: StrOrHostPath) -> HostPath:
183+
"""Resolve symlinks and return the real path via SFTP realpath.
184+
185+
``os.path.realpath`` tolerates a missing leaf (resolves the existing
186+
parent and re-appends the rest); strict SFTP servers instead error on
187+
nonexistent paths, which would break e.g. WriteFile creating a new
188+
file. Mirror the local tolerance by resolving the deepest existing
189+
ancestor and re-appending the missing suffix.
190+
"""
191+
parts: list[str] = []
192+
candidate = posixpath.normpath(str(path))
193+
while True:
194+
try:
195+
real = await self._sftp.realpath(candidate)
196+
except asyncssh.SFTPError:
197+
parent = posixpath.dirname(candidate)
198+
if parent == candidate: # filesystem root failed: give up
199+
raise OSError(f"realpath failed for {path}") from None
200+
parts.append(posixpath.basename(candidate))
201+
candidate = parent
202+
continue
203+
return HostPath(posixpath.join(real, *reversed(parts)) if parts else real)
204+
182205
async def stat(
183206
self,
184207
path: StrOrHostPath,

packages/pythinker-review/tests/unit/test_security_intel.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ def test_intel_client_disables_implicit_redirects() -> None:
4848
from pythinker_review.security_intel.client import IntelHttpClient
4949

5050
client = IntelHttpClient()
51-
assert any(isinstance(h, _NoRedirectHandler) for h in client._opener.handlers)
51+
handlers = client._opener.handlers # pyright: ignore[reportAttributeAccessIssue]
52+
assert any(isinstance(h, _NoRedirectHandler) for h in handlers)
5253

5354

5455
def test_intel_cache_roundtrip(tmp_path: Path) -> None:

src/pythinker_code/__main__.py

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,60 +8,6 @@
88
if TYPE_CHECKING:
99
from typing import TextIO
1010

11-
ROOT_HELP = """Usage: pythinker [OPTIONS] COMMAND [ARGS]...
12-
13-
Pythinker, your next CLI agent.
14-
15-
Options:
16-
-h, --help Show this message and exit.
17-
-V, --version Show version and exit.
18-
--verbose Print verbose information.
19-
--debug Log debug information.
20-
-w, --work-dir DIRECTORY Working directory for the agent.
21-
--add-dir DIRECTORY Add an additional workspace directory.
22-
-S, -r, --session, --resume TEXT Resume a session.
23-
-C, --continue Continue the previous session.
24-
--config TEXT Config TOML/JSON string to load.
25-
--config-file FILE Config TOML/JSON file to load.
26-
-m, --model TEXT LLM model to use.
27-
--thinking / --no-thinking Enable or disable thinking mode.
28-
-y, --yolo, --yes, --auto-approve
29-
Dangerously skip permission approvals.
30-
--plan Start in plan mode.
31-
--auto Run in auto mode (no user present).
32-
-p, -c, --prompt, --command TEXT User prompt to the agent.
33-
--print Run in print mode.
34-
--acp Deprecated; use `pythinker acp`.
35-
--wire Run as Wire server.
36-
--quiet Print only the final assistant message.
37-
--agent [default|okabe] Builtin agent specification to use.
38-
--agent-file FILE Custom agent specification file.
39-
--mcp-config-file FILE MCP config file to load; repeatable.
40-
--mcp-config TEXT MCP config JSON to load; repeatable.
41-
--skills-dir DIRECTORY Custom skills directory; repeatable.
42-
--no-telemetry Disable anonymous telemetry & error reporting.
43-
44-
Commands:
45-
acp Run Pythinker CLI ACP server.
46-
term Run Toad TUI backed by Pythinker CLI ACP server.
47-
login Login with a model provider.
48-
logout Logout from a model provider.
49-
info Show version and protocol information.
50-
export Export session data.
51-
mcp Manage MCP server configurations.
52-
plugin Manage plugins.
53-
review Diff-focused code review (delegates to pythinker-review).
54-
secscan Diff-focused security review (delegates to pythinker-review).
55-
security-scan Repo-wide Pythinker Security Scan pipeline (Python-native).
56-
debug Failure/log root-cause analysis (delegates to pythinker-review).
57-
update Check for and install Pythinker CLI updates.
58-
vis Run Pythinker Agent Tracing Visualizer.
59-
web Run Pythinker CLI web interface.
60-
61-
Documentation: https://pythoughts-labs.github.io/pythinker-code/
62-
LLM friendly version: https://pythoughts-labs.github.io/pythinker-code/llms.txt
63-
"""
64-
6511

6612
def _prog_name() -> str:
6713
return Path(sys.argv[0]).name or "pythinker"
@@ -126,10 +72,6 @@ def main(argv: Sequence[str] | None = None) -> int | str | None:
12672
print(f"pythinker, version {get_version()} — by {ORGANIZATION}")
12773
return 0
12874

129-
if len(args) == 1 and args[0] in {"--help", "-h"}:
130-
print(ROOT_HELP, end="")
131-
return 0
132-
13375
from pythinker_code.telemetry.crash import install_crash_handlers, set_phase
13476
from pythinker_code.utils.proxy import normalize_proxy_env
13577

0 commit comments

Comments
 (0)