Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
ceeeeb7
fix(tools): wrap FetchURL output as untrusted after truncation
elkaix Jun 12, 2026
7e96dfb
refactor(soul): expose public turn() contract for external drivers
elkaix Jun 12, 2026
eaa96d6
refactor(soul): replace string-matched tool gates with declarative flags
elkaix Jun 12, 2026
aba2c13
docs(tasks): record design adoption blueprint and branch task log
elkaix Jun 12, 2026
047a0b2
feat: sharpen orchestration guidance and genericize design comments
elkaix Jun 12, 2026
712c98f
Merge branch 'refactor/agent-contract-and-tool-metadata' into feat/ag…
elkaix Jun 12, 2026
b40cdb7
feat(acp): stop advertising the question tool to ACP clients
elkaix Jun 12, 2026
54e8070
fix: apply review-deferred fixes from the contract/metadata work
elkaix Jun 12, 2026
36cedaf
docs(tasks): add verified agent-harness adoption plan (124 ranked items)
elkaix Jun 12, 2026
e722278
feat(soul): repair tool call/result pairing at context restore
elkaix Jun 12, 2026
388da2d
feat(soul): decision-complete planning protocol in plan-mode reminders
elkaix Jun 12, 2026
f5b9b06
feat(print): strict stdout/stderr channel discipline on failure paths
elkaix Jun 12, 2026
c8d82d3
feat(subagents): inject merge-base-scoped git context into review agents
elkaix Jun 12, 2026
e2e74b7
feat(soul): same-step concurrency policy for parallel tool calls
elkaix Jun 12, 2026
1615cfb
feat(soul): reactive context-overflow recovery (compact-and-retry)
elkaix Jun 12, 2026
dfa21ff
docs(tasks): record Tier-1 adoption progress and next M-item queue
elkaix Jun 12, 2026
0f39a3b
feat(config): gate project-scope hooks behind durable per-project trust
elkaix Jun 12, 2026
9d1178f
feat(config): warn on unknown config keys with source-located diagnos…
elkaix Jun 12, 2026
8afc647
docs(tasks): record trust-gating and unknown-key checkpoints
elkaix Jun 12, 2026
bf549c2
feat(shell): carry a conversation summary across /model switches
elkaix Jun 12, 2026
0ccad00
docs(tasks): record model-switch carry-over checkpoint
elkaix Jun 12, 2026
5dc87aa
feat(shell): elide approval prompts for provably read-only commands
elkaix Jun 12, 2026
c216386
docs(tasks): record safe-command elision checkpoint
elkaix Jun 12, 2026
59decef
fix(security): allowlist inline env prefixes on the elision path
elkaix Jun 12, 2026
5dadb1c
test(e2e): pin shell approval round-trip with non-elidable commands
elkaix Jun 12, 2026
05f8642
feat(mcp): per-server startup timeout with actionable failure diagnos…
elkaix Jun 12, 2026
df4801f
test: refresh wire snapshots for the MCP server error field
elkaix Jun 12, 2026
1487afd
docs(tasks): record elision and MCP-timeout checkpoints
elkaix Jun 12, 2026
60fc8b1
feat(mcp): per-server tool allow/deny filtering (enabledTools/disable…
elkaix Jun 12, 2026
143de0b
docs(tasks): record MCP tool-filtering checkpoint
elkaix Jun 12, 2026
a01e594
feat(subagents): spawn-time context fork for foreground agents
elkaix Jun 12, 2026
763f78a
docs(tasks): record context-fork checkpoint
elkaix Jun 12, 2026
5c29c06
docs(tasks): worktree-isolation design note (re-sized M→L, phased sea…
elkaix Jun 12, 2026
56d6fa5
refactor(soul): Runtime.work_dir seam for worktree isolation (P1)
elkaix Jun 12, 2026
d058db0
docs(tasks): record work-dir seam (P1) checkpoint
elkaix Jun 12, 2026
8bc6c69
feat(subagents): enforce worktree isolation for background write agen…
elkaix Jun 12, 2026
babe8c2
docs(tasks): record worktree-isolation P2 checkpoint
elkaix Jun 12, 2026
7e6c11b
feat(subagents): document enforced isolation on RunAgents batches (P3)
elkaix Jun 12, 2026
1babdf4
docs(tasks): close worktree-isolation item (P1-P3 complete)
elkaix Jun 12, 2026
eb13fda
feat(tools): graduated fuzzy-matching ladder for edit-location recovery
elkaix Jun 12, 2026
3161fc4
docs(tasks): record fuzzy edit-ladder checkpoint
elkaix Jun 12, 2026
cde3c72
feat(soul): live permissions-state injection (posture-fingerprinted)
elkaix Jun 12, 2026
a9c9059
docs(tasks): record permissions-state checkpoint
elkaix Jun 12, 2026
44a9c88
fix: apply external review findings across recent checkpoints
elkaix Jun 12, 2026
ee75926
fix: harden isolation, elision, and concurrency per adversarial arc r…
elkaix Jun 12, 2026
07ac5a0
fix: resolve PR #122 review findings (CodeRabbit, CodeQL, typos)
elkaix Jun 12, 2026
e66f43f
fix(tui): hide thinking shimmer while a foreground tool runs
elkaix Jun 12, 2026
2519f90
fix(api-errors): keep 4xx lower bound; drop redundant 500 check
elkaix Jun 12, 2026
6a3ecfe
fix: address CodeRabbit re-review on harness changes
elkaix Jun 12, 2026
e14bd52
test(context): use well-formed tool pairs in pending-token fixtures
elkaix Jun 12, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,22 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Adversarial branch review hardening.** A multi-agent review pass confirmed and fixed: committed-but-clean isolation worktrees are now retained (commits ahead of the creation base count as changes — they were previously orphaned on cleanup); foreground shell commands and relative-path file edits now resolve against the agent's work dir, so worktree isolation actually binds them (host exec gained a `cwd` argument); safe mode now also disables the read-only-command prompt elision; locally parallel-safe MCP/tools run in parallel in the same-step gate; worktree add/remove serializes per repo. Behavior notes: same-step tool calls without `supports_parallel` now serialize deterministically (previously fully concurrent), and text-mode error diagnostics moved to stderr — capture `2>&1` or use `--output-format stream-json` if you scraped stdout.
- **The agent now knows its own permission posture.** A live permissions-state reminder renders the enforced profile, safe-mode/yolo/auto flags, mutation/network allowances, session-approved actions, and the shell gate's command-shaping rules — re-emitted exactly when the posture changes (/yolo, /auto, /trust, new approvals) instead of the model discovering policy through denied tool calls.
- **Edits recover from whitespace and smart-punctuation drift.** StrReplaceFile no longer hard-fails with "old string not found" when the only mismatch is trailing whitespace, indentation, or smart quotes/dashes: a graduated line-window ladder relocates the edit, replaces the actual file slice (preserving CRLF endings), and names the relaxation it used in the tool message. Multiple fuzzy hits without replace_all still error, so ambiguity is never silently resolved.
- **`isolation="worktree"` is now enforced for background write agents.** Previously it only recorded intent, so parallel coders shared one working tree and could clobber each other. A write-profile child now runs in its own git worktree of HEAD; its final report names the worktree path with a diff summary so changes merge deliberately, clean worktrees are removed, non-git roots fail with an actionable error, and read-profile children ignore the request.
- **MCP servers can be scoped to specific tools.** Optional `enabledTools` (exclusive allowlist) and `disabledTools` (denylist, wins on conflict) arrays per server in `mcp.json` keep a noisy server from flooding the model's tool list — filtered tools are never registered, and a call-time re-check guards shared tool maps.
- **A hung MCP server can no longer stall the whole session.** Server connects are bounded by a new `mcp.client.startup_timeout_ms` (default 30s) — previously a hung connect blocked every agent turn. `/mcp` now shows one actionable line per failed server (timeout → the config knob, 401 → the exact auth command, missing binary → the command path) instead of a bare "failed".
- **Provably read-only commands no longer prompt for approval.** The first `ls` or `git status` of a session used to interrupt with an approval dialog. A tight positive allowlist (read-only binaries and git subcommands, with hidden-command, write-redirection, wrapper, and fake-path rejections, fail closed) now elides the prompt in the root agent; subagents keep requesting approval as their unattended defense surface, and deny-profile decisions are never overridden.
- **Switching models keeps your conversation.** `/model` used to start a fresh session, discarding all context. The switch now seeds the new session with a plain-text summary written by the outgoing model (so provider-specific message formats never cross the boundary), falling back to the old fresh start if summarization fails; disable with `model_switch_carryover = false`.
- **Typo'd config keys are no longer silently ignored.** Loading now diffs the merged config against the model schema and warns with the dotted path and originating scope file for every unrecognized key (`default_yolo_typo = true` names itself and its file instead of silently changing nothing); `PYTHINKER_STRICT_CONFIG=1` escalates the findings to a startup error for CI.
- **Project-scope hooks now require trusting the project.** A cloned repository's `.pythinker/config.toml` could previously auto-execute its shell hooks at session start. Hooks from project and local scopes now load only after `/trust` records a durable per-project decision (stored user-side in `trusted_projects.json`, keyed by the resolved repo root); until then they are stripped with a warning naming the fix. Broken TOML in an untrusted project no longer blocks startup — the scope is treated as empty with a warning, while trusted projects keep the loud error.
- **Agent orchestration guidance is sharper for substantial tasks.** The default prompt now sharpens work-shaping guidance, and a new root-only runtime reminder nudges substantial normal-mode tasks toward the lightest effective path — direct tools, `SetTodoList`, foreground `RunAgents`, or verification — while backing off for plan mode, `/goal`, auto mode, and subagents.
- **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.
- **Inline `/command` references get acted on, not just explained away.** When a message mentions a slash command mid-sentence (e.g. "your `/goal` today is to `/plan` and build the page"), the command doesn't auto-run — but the agent no longer leads its reply by reporting it as failed. The per-turn reminder and the system prompt now steer the agent to act on the intent: call the real `EnterPlanMode` tool for `/plan` (clarified as a genuine, callable tool so models stop doubting it exists), pursue the described objective for `/goal`, load `/skill:<name>` via `ReadSkill`, and apply equivalent guidance for other commands — only surfacing how to invoke the literal command when genuinely needed.
- **The "thinking" shimmer no longer runs while a foreground command does.** When the agent started a long-running foreground process — a dev server via `npm`/`docker`, a watch task — the shimmering verb spinner ("Working…/Thinking…") kept animating for the whole turn, implying the agent was busy when it was really just awaiting the subprocess. The spinner is now suppressed while any foreground tool is mid-execution; the tool card's own animated running marker (and its streaming output) carries the liveness, so the shimmer means "the agent is thinking" again and reappears the moment the command returns.

## 0.41.0 (2026-06-11)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ Pythinker loads [Model Context Protocol](https://modelcontextprotocol.io/) tools
### 🛠️ Manage persistent MCP servers

```sh
# 📚 Context7 stdio server (Codex-style: NAME -- COMMAND)
# 📚 Context7 stdio server (positional form: NAME -- COMMAND)
pythinker mcp add context7 -- npx -y @upstash/context7-mcp --api-key YOUR-API-KEY
# Added MCP server 'context7' to ~/.pythinker/mcp.json

Expand Down
12 changes: 9 additions & 3 deletions packages/pythinker-host/src/pythinker_host/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,18 @@ async def mkdir(
"""Create a directory at the given path."""
...

async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
async def exec(
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
) -> HostProcess:
"""
Execute a command with arguments and return the running process.

Args:
*args: Command and its arguments.
env: Environment variables for the subprocess. If None, inherits
from the parent process.
cwd: Working directory for the subprocess. If None, inherits the
backend's current working directory (process cwd locally).
"""
...

Expand Down Expand Up @@ -347,8 +351,10 @@ async def mkdir(path: StrOrHostPath, parents: bool = False, exist_ok: bool = Fal
return await get_current_host().mkdir(path, parents=parents, exist_ok=exist_ok)


async def exec(*args: str, env: Mapping[str, str] | None = None) -> HostProcess:
return await get_current_host().exec(*args, env=env)
async def exec(
*args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
) -> HostProcess:
return await get_current_host().exec(*args, env=env, cwd=cwd)


from pythinker_host._current import current_host as current_host # noqa: E402
Expand Down
5 changes: 4 additions & 1 deletion packages/pythinker-host/src/pythinker_host/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ async def mkdir(
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
await asyncio.to_thread(local_path.mkdir, parents=parents, exist_ok=exist_ok)

async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
async def exec(
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
) -> HostProcess:
if not args:
raise ValueError("At least one argument (the program to execute) is required.")

Expand All @@ -208,6 +210,7 @@ async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostPr
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=cwd,
**process_options,
)
return self.Process(process)
Expand Down
15 changes: 12 additions & 3 deletions packages/pythinker-host/src/pythinker_host/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,9 @@ async def mkdir(
raise FileExistsError(f"{path} already exists")
await self._sftp.mkdir(str(path))

async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
async def exec(
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
) -> HostProcess:
if not args:
raise ValueError("At least one argument (the program to execute) is required.")
command = " ".join(shlex.quote(arg) for arg in args)
Expand All @@ -313,8 +315,15 @@ async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostPr
# cwd before running the command.
#
# This is intentionally strict: if cwd doesn't exist, the command fails.
if self._cwd:
command = f"cd {shlex.quote(self._cwd)} && {command}"
if cwd is None:
effective_cwd = self._cwd
elif posixpath.isabs(cwd):
effective_cwd = cwd
else:
base_cwd = self._cwd or "/"
effective_cwd = posixpath.normpath(posixpath.join(base_cwd, cwd))
if effective_cwd:
command = f"cd {shlex.quote(effective_cwd)} && {command}"
process = await self._connection.create_process(command, encoding=None, env=env)
return self.Process(process)

Expand Down
6 changes: 4 additions & 2 deletions src/pythinker_code/acp/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,10 @@ async def mkdir(
) -> None:
await self._fallback.mkdir(path, parents=parents, exist_ok=exist_ok)

async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
return await self._fallback.exec(*args, env=env)
async def exec(
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
) -> HostProcess:
return await self._fallback.exec(*args, env=env, cwd=cwd)

def _abs_path(self, path: StrOrHostPath) -> str:
host_path = path if isinstance(path, HostPath) else HostPath(path)
Expand Down
8 changes: 8 additions & 0 deletions src/pythinker_code/acp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pythinker_code.soul.approval import Approval
from pythinker_code.soul.permission import check_shell_command_allowed
from pythinker_code.soul.toolset import PythinkerToolset
from pythinker_code.tools.ask_user import AskUserQuestion
from pythinker_code.tools.shell import Params as ShellParams
from pythinker_code.tools.shell import Shell
from pythinker_code.tools.utils import ToolResultBuilder
Expand All @@ -28,6 +29,11 @@ def replace_tools(
# Only replace tools when running locally or under ACPHost.
return

# ACP clients get no interactive question UI (the session loop signals
# QuestionNotSupported), so don't advertise the tool — a hallucinated call
# still resolves through the registered tool's graceful fallback.
toolset.hide(AskUserQuestion.name)

if client_capabilities.terminal and (shell_tool := toolset.find(Shell)):
# Replace the Shell tool with the ACP Terminal tool if supported.
toolset.add(
Expand All @@ -48,6 +54,8 @@ class HideOutputDisplayBlock(DisplayBlock):


class Terminal(CallableTool2[ShellParams]):
emits_tool_execution_started_after_approval = True

def __init__(
self,
shell_tool: Shell,
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/agents/default/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ For research or multimedia tasks (images, video, PDFs, docs, spreadsheets, prese

**Act with tools; prose is not action.** Code that appears only in your reply is not saved — use `WriteFile` to create or overwrite, `StrReplaceFile` to edit, `Shell` to run and verify; iterate on failures. Follow each tool's parameter spec exactly. Don't narrate routine tool calls. Do not re-read a file after a successful edit tool call.

**Parallelize.** Before every tool response, ask whether another independent read/search/check can run in the same turn — you may emit any number of tool calls in one response; batch non-interfering calls. Serializing independent operations wastes time and grows context. This is very important to your performance.
**Parallelize.** Before every tool response, ask whether another independent read/search/check can run in the same turn — you may emit any number of tool calls in one response; batch non-interfering calls. Choose the lightest effective work shape: direct tools for known-path checks, `SetTodoList` once a substantial approach is clear, foreground `RunAgents` when independent children feed immediate synthesis, and background agents only when you can make other progress while they run. Serializing independent operations wastes time and grows context. This is very important to your performance.

**Spend context deliberately.** The context window is a finite budget: read targeted ranges instead of whole files when the region is known, distill long command output to what the task needs, and push bulky exploration into subagents that return summaries rather than raw dumps.

Expand Down
28 changes: 26 additions & 2 deletions src/pythinker_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,30 @@ async def create(
from pythinker_code.hooks.engine import HookEngine

hook_engine = HookEngine(config.hooks, cwd=str(session.work_dir))
if config.disabled_project_hooks:
# The load-time logger.warning only reaches shell users; publish a
# notification so web/ACP frontends also learn why their project
# hooks did not run and how to enable them.
from pythinker_code.notifications.models import NotificationEvent

runtime.notifications.publish(
NotificationEvent(
id=f"project-hooks-disabled:{session.id}",
category="system",
type="project_hooks_disabled",
source_kind="config",
source_id="project_trust",
title="Project hooks disabled (untrusted project)",
body=(
"Hooks defined in "
+ ", ".join(config.disabled_project_hooks)
+ " are disabled until you trust this project. Run /trust to "
"enable them (takes effect on /reload or next start)."
),
severity="warning",
dedupe_key=f"project-hooks-disabled:{session.id}",
)
)
soul.set_hook_engine(hook_engine)
runtime.hook_engine = hook_engine

Expand Down Expand Up @@ -603,7 +627,7 @@ async def await_bg_tasks_shutdown(self, timeout: float = 2.0) -> None:
async def _env(self) -> AsyncGenerator[None]:
async with _CWD_LOCK:
original_cwd = HostPath.cwd()
await pythinker_host.chdir(self._runtime.session.work_dir)
await pythinker_host.chdir(self._runtime.work_dir)
try:
# to ignore possible warnings from dateparser
warnings.filterwarnings("ignore", category=DeprecationWarning)
Expand Down Expand Up @@ -782,7 +806,7 @@ async def run_shell(
"""Run the Pythinker CLI instance with shell UI."""
from pythinker_code.ui.shell import Shell, WelcomeInfoItem

work_dir = self._runtime.session.work_dir
work_dir = self._runtime.work_dir
welcome_info = [
WelcomeInfoItem(name="Directory", value=str(shorten_home(work_dir))),
]
Expand Down
Loading
Loading