Skip to content

Commit ae99189

Browse files
committed
fix: resolve PR #155 CI build and CodeRabbit review findings
Docs build failed because an inline-code span `pythinker plugin enable/disable <name>` wrapped across a line break, so VitePress's Vue compiler read `<name>` as an unclosed HTML tag. Keep the span on one line and regenerate the synced docs changelog. Address unresolved review findings: - Lock the plugin install / marketplace registries' read-modify-write cycles with a shared cross-process file_lock helper (no lost updates on concurrent CLI writers). - Make MCP inventory refresh side-effect-free: _inventory_mcp_server now returns the discovered inventory and the caller assigns it only after the awaited call succeeds, so a timeout/__aexit__ failure can't leave the published registry inconsistent with the callable tools. - Roll back partially materialized plugins when dependency resolution fails, so a failed install never leaves partial on-disk/registry state. - Always validate ${user_config.*} placeholders (even when a manifest declares no userConfig) so an unresolved placeholder fails soft instead of reaching an executable artifact literally. - Scope-lock plugins.options to user config; it feeds executable plugin MCP/hook artifacts and must not be repo-controlled. - Only mark a reviewer ```report block parsed when findings is a list; raise on unknown eval-case names and wrap gate_report in the CLI error boundary; trim plugin dependency identifiers before exact-name matching. Add/adjust tests for rollback, fail-soft hook substitution, the unknown eval-case error, and the new report-parse and MCP-refresh contracts.
1 parent b06189c commit ae99189

18 files changed

Lines changed: 311 additions & 100 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
2020
installed for Claude Code or Codex are auto-detected (no symlink): their safe artifacts
2121
(skills, commands, agents) activate by default, while executable artifacts (hooks, MCP
2222
servers) stay opt-in. Config `plugins.discover_external`, `plugins.external_exec`,
23-
`plugins.enabled`, and `plugins.disabled` — plus `pythinker plugin enable/disable
24-
<name>` — control which installed plugins contribute artifacts. Hook and MCP commands
23+
`plugins.enabled`, and `plugins.disabled` — plus `pythinker plugin enable/disable <name>`
24+
— control which installed plugins contribute artifacts. Hook and MCP commands
2525
expand `${CLAUDE_PLUGIN_ROOT}`/`${PYTHINKER_PLUGIN_ROOT}` and
2626
`${CLAUDE_PLUGIN_DATA}`/`${PYTHINKER_PLUGIN_DATA}`.
2727
- **Plugin dependencies.** Plugins may declare `dependencies`; installing one pulls its

docs/en/release-notes/changelog.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,48 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
## Unreleased
1919

20+
- **Plugin marketplaces and activation policy.** `pythinker plugin marketplace` can add,
21+
refresh, install, and uninstall Claude/Codex-compatible marketplace plugins. Plugins
22+
installed for Claude Code or Codex are auto-detected (no symlink): their safe artifacts
23+
(skills, commands, agents) activate by default, while executable artifacts (hooks, MCP
24+
servers) stay opt-in. Config `plugins.discover_external`, `plugins.external_exec`,
25+
`plugins.enabled`, and `plugins.disabled` — plus `pythinker plugin enable/disable <name>`
26+
— control which installed plugins contribute artifacts. Hook and MCP commands
27+
expand `${CLAUDE_PLUGIN_ROOT}`/`${PYTHINKER_PLUGIN_ROOT}` and
28+
`${CLAUDE_PLUGIN_DATA}`/`${PYTHINKER_PLUGIN_DATA}`.
29+
- **Plugin dependencies.** Plugins may declare `dependencies`; installing one pulls its
30+
transitive dependencies from the same marketplace (cross-marketplace deps are blocked),
31+
and a plugin whose dependencies aren't present+enabled is disabled at load instead of
32+
half-activating.
33+
- **Plugin options (`userConfig`).** `${user_config.KEY}` in a plugin's MCP server configs
34+
and hook commands is filled from `[plugins.options.<plugin>]` config; an artifact that
35+
references an unconfigured option is skipped rather than run blank. (Content substitution,
36+
`PYTHINKER_PLUGIN_OPTION_*` hook env vars, and keychain-backed sensitive storage are not
37+
yet implemented.)
38+
- **MCP tool lists refresh automatically when servers change.** Connected MCP
39+
sessions stay open for `tools/list_changed` (and resources/prompts) notifications;
40+
inventory is re-published without a manual `/mcp refresh`.
41+
- **Shell live token readouts track output throughput.** The spinner and background
42+
status line show session-wide output tokens produced during the current turn or
43+
background stretch instead of the context-size snapshot.
44+
- **MCP servers can be managed without a full reload.** `/mcp disconnect`, `/mcp reconnect`, and
45+
`/mcp refresh` (or `retry`) update the live toolset for one server; disconnect unregisters its
46+
tools and marks the server failed until reconnect.
47+
- **Recall search matches session ids and plan slugs.** Prior-session keyword search now indexes
48+
`session_id` and `plan_slug` in addition to titles so agents can find plan-linked sessions by slug.
49+
- **Wire and ACP surfaces now get max-steps handoff summaries.** When a turn hits the step ceiling,
50+
wire clients receive a streamed handoff event plus a `handoff` field on the `max_steps_reached`
51+
result; ACP sessions emit the same summary text before returning `max_turn_requests`.
52+
- **MCP CLI commands resolve normalized server names.** `mcp remove`, `mcp auth`, `mcp test`, and
53+
`reset-auth` accept display names with spaces or slashes and map them to stored config keys; config
54+
load applies the same normalization as add.
55+
- **Compaction failure circuit breaker respects thresholds above one.** A proactive compaction
56+
failure below `max_compaction_failures` no longer aborts the turn; the handoff fires only after
57+
the configured number of consecutive failures.
58+
- **AI eval gate schema is self-contained under `tests_ai/`.** Shared eval-case types live in
59+
`tests_ai/eval_schema.py` so isolated `tests_ai` runs do not import from `tests_e2e`.
60+
- **Softer TUI chrome in the dark theme.** Panel borders (welcome banner, menus) and the input-area
61+
rules now render in a mid grey (`#8a8d91`) instead of near-white, for a less glaring look.
2062
- **Stop-time memory extraction can now be enabled explicitly.** Added an opt-in
2163
`memory.harvest_on_stop` setting that stages safe assistant decisions, blockers, evidence, and
2264
next steps into the existing scratchpad recall flow at turn end without writing directly to
@@ -25,6 +67,28 @@ GitHub Releases page; `0.8.0` is the new starting line.
2567
`ToolSearch` plus root-session `EnterWorktree` and `ExitWorktree` tools so agents can find
2668
currently available capabilities by keyword and isolate a session's operational working directory
2769
in a git worktree without deleting user work on exit.
70+
- **Root sessions now get a bounded git snapshot in the prompt.** When `git_status_injection` is
71+
enabled (default), the agent receives branch, dirty-file summary, and recent commits as an
72+
explicitly stale point-in-time reminder; disable via config or set `git_status_injection = false`.
73+
- **Context compaction and MCP tool registration now fail more predictably.** Proactive compaction
74+
failures hand back with an explicit `compaction_failed` stop instead of bubbling an unstructured
75+
loop error, and MCP duplicate tool-name resolution now follows configured server order instead of
76+
connection completion order. Tool hooks also retain the original model input even if a tool
77+
mutates a nested argument object during execution.
78+
- **Recall can now read bounded transcript windows.** `Recall(mode="read")` accepts
79+
`message_offset` and `max_messages` so agents can inspect a precise, sanitized slice of a prior
80+
workspace session without pulling the whole transcript into context.
81+
- **MCP prompt templates can now be invoked from connected servers.** `InvokeMcpPrompt` renders a
82+
server-published prompt with structured arguments and wraps the returned messages as untrusted
83+
input for the model.
84+
- **Telemetry, MCP config, and shell UX hardening.** Tool spans and metrics sanitize MCP/plugin
85+
names; `mcp.json` load paths inject docker `--rm` and normalize server keys with collision
86+
errors; shell suggestions accept via Alt+S into the prompt; markdown agent frontmatter maps
87+
`max_turns`/`disallowed_tools`; `ReadMediaFile` enforces per-kind byte/pixel caps; written plans
88+
without a Verification section get a soft warning; AI eval budgets can gate `tests_ai` reports.
89+
- **Plan-mode exit guidance now requires verification.** The `ExitPlanMode` tool now tells agents
90+
that written plans must include a Verification section with the smallest command, test, or check
91+
for each meaningful change.
2892
- **Agent-loop observability now emits explicit Wire events for key runtime state.** Added
2993
`TodoListUpdated`, `SubagentToolFallback`, `AgentListDelta`, `ToolUseSkipped`, and
3094
`ContextOverflowRecovered` events, with todo updates, subagent launch fallbacks, same-step tool

src/pythinker_code/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def find_project_root(cwd: Path) -> Path | None:
5757
("providers",), # contains api_key per provider — must stay in user scope
5858
("services",), # contains api_key fields — must stay in user scope
5959
("feedback", "api_key"), # only the key, not the whole feedback section
60+
# Substituted into executable plugin artifacts (MCP server configs and hook
61+
# commands); a repo-controlled project config must not steer those values.
62+
("plugins", "options"),
6063
# Auto-executed when the shell starts — a repo-controlled project config
6164
# must never be able to choose the binary that runs (`command`), nor to
6265
# trigger or extend its execution (`enabled`/`segments` flip the command

src/pythinker_code/plugin/install.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
load_installed_plugins,
3434
plugin_identifier,
3535
record_install,
36+
remove_install,
3637
)
3738
from pythinker_code.plugin.manifest import (
3839
MarketplaceEntry,
@@ -280,12 +281,48 @@ def install_plugin_from_marketplace(
280281

281282
manifest, marketplace_root = _load_marketplace(marketplace_name, known[marketplace_name])
282283
installed: dict[str, InstalledRecord] = {}
283-
_install_with_deps(
284-
plugin_name, marketplace_name, manifest, marketplace_root, scope, installed, []
285-
)
284+
# Records materialized so far, oldest first. If dependency resolution fails
285+
# partway through (e.g. a cross-marketplace dep), every plugin already written
286+
# to disk/registry is rolled back so a failed install never leaves partial state.
287+
materialized: list[tuple[str, InstalledRecord]] = []
288+
try:
289+
_install_with_deps(
290+
plugin_name,
291+
marketplace_name,
292+
manifest,
293+
marketplace_root,
294+
scope,
295+
installed,
296+
[],
297+
materialized,
298+
)
299+
except Exception:
300+
_rollback_installs(marketplace_name, materialized)
301+
raise
286302
return installed[plugin_name]
287303

288304

305+
def _rollback_installs(
306+
marketplace_name: str, materialized: list[tuple[str, InstalledRecord]]
307+
) -> None:
308+
"""Undo partially completed installs (best-effort), newest first.
309+
310+
Runs while unwinding a failed install, so a cleanup error must not mask the
311+
original failure — each step is logged and continued rather than raised.
312+
"""
313+
for name, record in reversed(materialized):
314+
ident = plugin_identifier(name, marketplace_name)
315+
try:
316+
_remove_path(Path(record.install_path))
317+
remove_install(name, marketplace_name, scope=record.scope)
318+
except Exception as exc:
319+
logger.warning(
320+
"Could not fully roll back partial install of {id}: {error}",
321+
id=ident,
322+
error=exc,
323+
)
324+
325+
289326
def _materialize_and_record(
290327
plugin_name: str,
291328
marketplace_name: str,
@@ -333,6 +370,7 @@ def _install_with_deps(
333370
scope: str,
334371
installed: dict[str, InstalledRecord],
335372
in_progress: list[str],
373+
materialized: list[tuple[str, InstalledRecord]],
336374
) -> None:
337375
"""Install a plugin and its transitive dependencies from the same marketplace.
338376
@@ -352,6 +390,7 @@ def _install_with_deps(
352390
record = _materialize_and_record(
353391
plugin_name, marketplace_name, manifest, marketplace_root, scope
354392
)
393+
materialized.append((plugin_name, record))
355394
for dep in _installed_manifest_deps(Path(record.install_path)):
356395
dep_name, dep_marketplace = parse_plugin_identifier(dep)
357396
if dep_marketplace is not None and dep_marketplace != marketplace_name:
@@ -365,7 +404,14 @@ def _install_with_deps(
365404
):
366405
continue
367406
_install_with_deps(
368-
dep_name, marketplace_name, manifest, marketplace_root, scope, installed, in_progress
407+
dep_name,
408+
marketplace_name,
409+
manifest,
410+
marketplace_root,
411+
scope,
412+
installed,
413+
in_progress,
414+
materialized,
369415
)
370416
in_progress.remove(plugin_name)
371417
installed[plugin_name] = record

src/pythinker_code/plugin/installed.py

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pydantic import BaseModel, ConfigDict, Field
1616

1717
from pythinker_code.plugin.directories import installed_plugins_file
18+
from pythinker_code.utils.io import file_lock
1819
from pythinker_code.utils.logging import logger
1920

2021
INSTALLED_SCHEMA_VERSION = 2
@@ -90,37 +91,38 @@ def save_installed_plugins(plugins: dict[str, list[InstalledRecord]]) -> None:
9091
def record_install(name: str, marketplace: str, record: InstalledRecord) -> None:
9192
"""Add or replace an install record for ``name@marketplace`` in its scope.
9293
93-
ponytail: unlocked read-modify-write. ``save_installed_plugins`` writes
94-
atomically, so a single writer never corrupts the file; the residual risk is
95-
two concurrent ``pythinker plugin`` processes losing one update — rare for a
96-
CLI. Add cross-process file locking here (and in ``marketplace.py``) if
97-
concurrent installs become a real workflow.
94+
The load → mutate → save runs under a cross-process lock so concurrent
95+
``pythinker plugin`` invocations (or a session overlapping a CLI install)
96+
can't drop each other's registry updates.
9897
"""
99-
plugins = load_installed_plugins()
100-
ident = plugin_identifier(name, marketplace)
101-
existing = [r for r in plugins.get(ident, []) if r.scope != record.scope]
102-
plugins[ident] = [*existing, record]
103-
save_installed_plugins(plugins)
98+
with file_lock(installed_plugins_file()):
99+
plugins = load_installed_plugins()
100+
ident = plugin_identifier(name, marketplace)
101+
existing = [r for r in plugins.get(ident, []) if r.scope != record.scope]
102+
plugins[ident] = [*existing, record]
103+
save_installed_plugins(plugins)
104104

105105

106106
def remove_install(name: str, marketplace: str, *, scope: str | None = None) -> bool:
107107
"""Remove install records for a plugin (optionally only one scope).
108108
109-
Returns True if anything was removed.
109+
Returns True if anything was removed. The read-modify-write runs under the
110+
same cross-process lock as :func:`record_install`.
110111
"""
111-
plugins = load_installed_plugins()
112-
ident = plugin_identifier(name, marketplace)
113-
if ident not in plugins:
114-
return False
115-
if scope is None:
116-
del plugins[ident]
117-
else:
118-
kept = [r for r in plugins[ident] if r.scope != scope]
119-
if len(kept) == len(plugins[ident]):
112+
with file_lock(installed_plugins_file()):
113+
plugins = load_installed_plugins()
114+
ident = plugin_identifier(name, marketplace)
115+
if ident not in plugins:
120116
return False
121-
if kept:
122-
plugins[ident] = kept
123-
else:
117+
if scope is None:
124118
del plugins[ident]
125-
save_installed_plugins(plugins)
126-
return True
119+
else:
120+
kept = [r for r in plugins[ident] if r.scope != scope]
121+
if len(kept) == len(plugins[ident]):
122+
return False
123+
if kept:
124+
plugins[ident] = kept
125+
else:
126+
del plugins[ident]
127+
save_installed_plugins(plugins)
128+
return True

src/pythinker_code/plugin/integration.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -134,22 +134,24 @@ def plugin_mcp_servers(policy: PluginPolicy | None = None) -> dict[str, object]:
134134
"""
135135
servers: dict[str, object] = {}
136136
for plugin in _enabled_plugins(policy, include_external=_exec_external(policy)):
137-
options = _plugin_options(plugin, policy) if plugin.manifest.user_config else None
137+
# Always validate ${user_config.*} references, even when the manifest
138+
# declares no userConfig: an unresolved placeholder must fail-soft (skip
139+
# the artifact) rather than reach an executable command literally.
140+
options = _plugin_options(plugin, policy)
138141
for key, value in artifacts.mcp_servers(plugin).items():
139142
expanded = _map_strings(value, partial(_expand_plugin_vars, plugin=plugin))
140-
if options is not None:
141-
try:
142-
expanded = _map_strings(
143-
expanded, partial(substitute_user_config_vars, values=options)
144-
)
145-
except UserConfigError as exc:
146-
logger.warning(
147-
"Skipping MCP server {key} from {plugin}: unconfigured user_config {error}",
148-
key=key,
149-
plugin=plugin.name,
150-
error=exc,
151-
)
152-
continue
143+
try:
144+
expanded = _map_strings(
145+
expanded, partial(substitute_user_config_vars, values=options)
146+
)
147+
except UserConfigError as exc:
148+
logger.warning(
149+
"Skipping MCP server {key} from {plugin}: unconfigured user_config {error}",
150+
key=key,
151+
plugin=plugin.name,
152+
error=exc,
153+
)
154+
continue
153155
servers.setdefault(key, expanded)
154156
return servers
155157

@@ -173,7 +175,7 @@ def _hooks_payload(plugin: LoadedPlugin) -> dict[str, Any] | None:
173175

174176

175177
def _translate_hook_defs(
176-
plugin: LoadedPlugin, payload: dict[str, Any], options: dict[str, object] | None
178+
plugin: LoadedPlugin, payload: dict[str, Any], options: dict[str, object]
177179
) -> list[HookDef]:
178180
"""Translate a Claude-style hooks mapping into pythinker ``HookDef`` entries.
179181
@@ -206,17 +208,16 @@ def _translate_hook_defs(
206208
if entry_d.get("type", "command") != "command" or not isinstance(command, str):
207209
continue
208210
expanded = _expand_plugin_vars(command, plugin)
209-
if options is not None:
210-
try:
211-
expanded = substitute_user_config_vars(expanded, options)
212-
except UserConfigError as exc:
213-
logger.warning(
214-
"Skipping {event} hook in {plugin}: unconfigured user_config {error}",
215-
event=event,
216-
plugin=plugin.name,
217-
error=exc,
218-
)
219-
continue
211+
try:
212+
expanded = substitute_user_config_vars(expanded, options)
213+
except UserConfigError as exc:
214+
logger.warning(
215+
"Skipping {event} hook in {plugin}: unconfigured user_config {error}",
216+
event=event,
217+
plugin=plugin.name,
218+
error=exc,
219+
)
220+
continue
220221
timeout = entry_d.get("timeout")
221222
try:
222223
defs.append(
@@ -245,6 +246,6 @@ def plugin_hook_defs(policy: PluginPolicy | None = None) -> list[HookDef]:
245246
for plugin in _enabled_plugins(policy, include_external=_exec_external(policy)):
246247
payload = _hooks_payload(plugin)
247248
if payload is not None:
248-
options = _plugin_options(plugin, policy) if plugin.manifest.user_config else None
249+
options = _plugin_options(plugin, policy)
249250
defs.extend(_translate_hook_defs(plugin, payload, options))
250251
return defs

src/pythinker_code/plugin/manifest.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,24 @@ def _normalize_dependencies(value: Any) -> list[str]:
8282
out: list[str] = []
8383
for entry in cast("list[object]", value):
8484
if isinstance(entry, str):
85-
out.append(_DEP_VERSION_SUFFIX.sub("", entry))
85+
# Trim surrounding whitespace: downstream matching is exact-name, so a
86+
# stray " lib " would be mismatched and falsely demoted as missing.
87+
dep = _DEP_VERSION_SUFFIX.sub("", entry).strip()
88+
if dep:
89+
out.append(dep)
8690
elif isinstance(entry, dict):
8791
entry_d = cast("dict[str, object]", entry)
8892
name = entry_d.get("name")
8993
if not isinstance(name, str):
9094
continue
95+
name = name.strip()
96+
if not name:
97+
continue
9198
marketplace = entry_d.get("marketplace")
92-
out.append(f"{name}@{marketplace}" if isinstance(marketplace, str) else name)
99+
if isinstance(marketplace, str) and marketplace.strip():
100+
out.append(f"{name}@{marketplace.strip()}")
101+
else:
102+
out.append(name)
93103
return out
94104

95105

0 commit comments

Comments
 (0)