pythinker-code 0.45.0__py3-none-any.whl → 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. pythinker_code/CHANGELOG.md +98 -0
  2. pythinker_code/acp/session.py +8 -0
  3. pythinker_code/agents/default/agent.yaml +4 -0
  4. pythinker_code/app.py +19 -2
  5. pythinker_code/cli/__init__.py +13 -2
  6. pythinker_code/cli/mcp.py +104 -27
  7. pythinker_code/cli/plugin.py +230 -1
  8. pythinker_code/config.py +74 -0
  9. pythinker_code/llm.py +8 -2
  10. pythinker_code/plugin/artifacts.py +103 -0
  11. pythinker_code/plugin/dependency.py +91 -0
  12. pythinker_code/plugin/directories.py +94 -0
  13. pythinker_code/plugin/install.py +417 -0
  14. pythinker_code/plugin/installed.py +128 -0
  15. pythinker_code/plugin/integration.py +251 -0
  16. pythinker_code/plugin/loader.py +178 -0
  17. pythinker_code/plugin/manifest.py +260 -0
  18. pythinker_code/plugin/marketplace.py +208 -0
  19. pythinker_code/plugin/options.py +45 -0
  20. pythinker_code/plugin/policy.py +87 -0
  21. pythinker_code/prompt_templates.py +5 -1
  22. pythinker_code/prompts/__init__.py +5 -0
  23. pythinker_code/skill/__init__.py +6 -8
  24. pythinker_code/soul/agent.py +42 -17
  25. pythinker_code/soul/compaction.py +39 -0
  26. pythinker_code/soul/dynamic_injections/agent_list.py +85 -0
  27. pythinker_code/soul/dynamic_injections/git_status.py +63 -0
  28. pythinker_code/soul/live_tokens.py +47 -0
  29. pythinker_code/soul/pythinkersoul.py +179 -4
  30. pythinker_code/soul/toolset.py +421 -88
  31. pythinker_code/subagents/discovery.py +32 -1
  32. pythinker_code/telemetry/names.py +44 -0
  33. pythinker_code/tools/agent/__init__.py +63 -1
  34. pythinker_code/tools/agent/description.md +9 -0
  35. pythinker_code/tools/file/read_media.py +21 -4
  36. pythinker_code/tools/mcp_resource/__init__.py +78 -1
  37. pythinker_code/tools/mcp_resource/prompt_description.md +17 -0
  38. pythinker_code/tools/plan/__init__.py +15 -1
  39. pythinker_code/tools/plan/description.md +3 -0
  40. pythinker_code/tools/recall/__init__.py +71 -10
  41. pythinker_code/tools/recall/description.md +6 -3
  42. pythinker_code/tools/todo/__init__.py +20 -0
  43. pythinker_code/tools/tool_search/__init__.py +74 -0
  44. pythinker_code/tools/tool_search/tool_search.md +7 -0
  45. pythinker_code/tools/worktree/__init__.py +218 -0
  46. pythinker_code/tools/worktree/enter_worktree.md +9 -0
  47. pythinker_code/tools/worktree/exit_worktree.md +5 -0
  48. pythinker_code/ui/shell/__init__.py +58 -4
  49. pythinker_code/ui/shell/components/markdown.py +41 -1
  50. pythinker_code/ui/shell/components/report.py +13 -3
  51. pythinker_code/ui/shell/prompt.py +69 -10
  52. pythinker_code/ui/shell/slash.py +59 -1
  53. pythinker_code/ui/shell/tool_renderers/agent.py +69 -5
  54. pythinker_code/ui/shell/update.py +7 -1
  55. pythinker_code/ui/shell/visualize/_blocks.py +14 -3
  56. pythinker_code/ui/shell/visualize/_interactive.py +10 -2
  57. pythinker_code/ui/shell/visualize/_live_view.py +21 -5
  58. pythinker_code/ui/theme.py +2 -2
  59. pythinker_code/utils/io.py +31 -0
  60. pythinker_code/utils/mcp_names.py +60 -0
  61. pythinker_code/utils/media_limits.py +20 -0
  62. pythinker_code/web/static/assets/architecture-7EHR7CIX-BgGrIJTG.js +1 -0
  63. pythinker_code/web/static/assets/{architectureDiagram-3BPJPVTR-Cmrv4t4A.js → architectureDiagram-3BPJPVTR-Czbbrggh.js} +1 -1
  64. pythinker_code/web/static/assets/{blockDiagram-GPEHLZMM-DJtt-l4b.js → blockDiagram-GPEHLZMM-DuGxQ4gb.js} +1 -1
  65. pythinker_code/web/static/assets/{bootstrap-GQDZdCZn.js → bootstrap-BRW86uxF.js} +5 -5
  66. pythinker_code/web/static/assets/{c4Diagram-AAUBKEIU-o0KsSeIT.js → c4Diagram-AAUBKEIU-C9QqOozv.js} +1 -1
  67. pythinker_code/web/static/assets/channel-Bw8F6co7.js +1 -0
  68. pythinker_code/web/static/assets/{chunk-2J33WTMH-CVPDc3uI.js → chunk-2J33WTMH-CLDwXFrS.js} +1 -1
  69. pythinker_code/web/static/assets/{chunk-3OPIFGDE-Tu9TJ8FO.js → chunk-3OPIFGDE-CgPHKDsU.js} +1 -1
  70. pythinker_code/web/static/assets/{chunk-5ZQYHXKU-CoBmhCur.js → chunk-5ZQYHXKU-DhesutHE.js} +1 -1
  71. pythinker_code/web/static/assets/{chunk-727SXJPM-DByzCodm.js → chunk-727SXJPM-mTEiNOi3.js} +1 -1
  72. pythinker_code/web/static/assets/{chunk-AQP2D5EJ-DclqBNWp.js → chunk-AQP2D5EJ-DlSrZz-k.js} +1 -1
  73. pythinker_code/web/static/assets/{chunk-CSCIHK7Q-CUIX1-rb.js → chunk-CSCIHK7Q-D0YCOqn9.js} +1 -1
  74. pythinker_code/web/static/assets/{chunk-JAPRZBRM-gOR8tSRc.js → chunk-JAPRZBRM-CLY2Nkh7.js} +4 -4
  75. pythinker_code/web/static/assets/{chunk-KSCS5N6A-hta5hym2.js → chunk-KSCS5N6A-DbcFg-5f.js} +1 -1
  76. pythinker_code/web/static/assets/{chunk-L5ZTLDWV-cj__FNrg.js → chunk-L5ZTLDWV-8WkWOyc_.js} +1 -1
  77. pythinker_code/web/static/assets/{chunk-LZXEDZCA-CSHDE7c1.js → chunk-LZXEDZCA-BUgkoo7u.js} +2 -2
  78. pythinker_code/web/static/assets/{chunk-ND2GUHAM-DMFQO6Cb.js → chunk-ND2GUHAM-C0HJXIEZ.js} +1 -1
  79. pythinker_code/web/static/assets/{chunk-NZK2D7GU-Cjm014we.js → chunk-NZK2D7GU-DBQjspgy.js} +1 -1
  80. pythinker_code/web/static/assets/{chunk-O5CBEL6O-BSr1l1Fd.js → chunk-O5CBEL6O-Y2JuT_28.js} +1 -1
  81. pythinker_code/web/static/assets/{chunk-WU5MYG2G-veNzkohb.js → chunk-WU5MYG2G-BSQmvLlf.js} +1 -1
  82. pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-B3WrcDb4.js +1 -0
  83. pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-B3WrcDb4.js +1 -0
  84. pythinker_code/web/static/assets/{code-block-IT6T5CEO-DTgIHAkQ.js → code-block-IT6T5CEO-COJFtT1s.js} +1 -1
  85. pythinker_code/web/static/assets/{dagre-BM42HDAG-D-_HCbAC.js → dagre-BM42HDAG-C-63xbhz.js} +1 -1
  86. pythinker_code/web/static/assets/{diagram-2AECGRRQ-CvX58Rpg.js → diagram-2AECGRRQ-BA3t5dvt.js} +1 -1
  87. pythinker_code/web/static/assets/{diagram-5GNKFQAL-CsRaiNkg.js → diagram-5GNKFQAL-Cn_tPFPy.js} +1 -1
  88. pythinker_code/web/static/assets/{diagram-KO2AKTUF-Dn6aiXG5.js → diagram-KO2AKTUF-CNLlhwgh.js} +1 -1
  89. pythinker_code/web/static/assets/{diagram-LMA3HP47-DXQlab3r.js → diagram-LMA3HP47-BubQEJye.js} +1 -1
  90. pythinker_code/web/static/assets/{diagram-OG6HWLK6-BWB_FmTS.js → diagram-OG6HWLK6-B1zoPW4f.js} +1 -1
  91. pythinker_code/web/static/assets/{dist-C8Q1EY4S.js → dist-C-YVtz9k.js} +1 -1
  92. pythinker_code/web/static/assets/{erDiagram-TEJ5UH35-BrQiudEc.js → erDiagram-TEJ5UH35-BrFwtQIS.js} +1 -1
  93. pythinker_code/web/static/assets/eventmodeling-FCH6USID-DjypEbTa.js +1 -0
  94. pythinker_code/web/static/assets/{flowDiagram-I6XJVG4X-C6jARfsw.js → flowDiagram-I6XJVG4X-CMde8qln.js} +1 -1
  95. pythinker_code/web/static/assets/{ganttDiagram-6RSMTGT7-DH-i7Wci.js → ganttDiagram-6RSMTGT7-DqKHO3Gj.js} +1 -1
  96. pythinker_code/web/static/assets/{gitGraph-WXDBUCRP-lU8PeAwh.js → gitGraph-WXDBUCRP-DSsm7EeH.js} +1 -1
  97. pythinker_code/web/static/assets/{gitGraphDiagram-PVQCEYII-JOoIM40T.js → gitGraphDiagram-PVQCEYII-BPXFnSJu.js} +1 -1
  98. pythinker_code/web/static/assets/{index-BJIE96ev.js → index-DrLJ6Yzo.js} +2 -2
  99. pythinker_code/web/static/assets/{info-J43DQDTF-B9a8E9HF.js → info-J43DQDTF-Ckpo8amK.js} +1 -1
  100. pythinker_code/web/static/assets/{infoDiagram-5YYISTIA-D5wmE10q.js → infoDiagram-5YYISTIA-BvhEvq2y.js} +1 -1
  101. pythinker_code/web/static/assets/{ishikawaDiagram-YF4QCWOH-DzNdTJqt.js → ishikawaDiagram-YF4QCWOH-BGVm0gpH.js} +1 -1
  102. pythinker_code/web/static/assets/{journeyDiagram-JHISSGLW-CwevEltw.js → journeyDiagram-JHISSGLW-DGjKVrAu.js} +1 -1
  103. pythinker_code/web/static/assets/{kanban-definition-UN3LZRKU-BAmLyJgC.js → kanban-definition-UN3LZRKU-DF4Ru5vp.js} +1 -1
  104. pythinker_code/web/static/assets/{line-CktbshjS.js → line-zsX5pPNl.js} +1 -1
  105. pythinker_code/web/static/assets/mermaid-VLURNSYL-BY1nFAIo.js +1 -0
  106. pythinker_code/web/static/assets/{mermaid-parser.core-B1Hc3VHx.js → mermaid-parser.core-DgXtwV12.js} +2 -2
  107. pythinker_code/web/static/assets/{mermaid.core-zqRnwN3B.js → mermaid.core-DDTnZJwj.js} +3 -3
  108. pythinker_code/web/static/assets/{mindmap-definition-RKZ34NQL-CamoLUlD.js → mindmap-definition-RKZ34NQL-DP-UXBwG.js} +1 -1
  109. pythinker_code/web/static/assets/{packet-YPE3B663-s78KeeiS.js → packet-YPE3B663-Dx3OSICL.js} +1 -1
  110. pythinker_code/web/static/assets/{pie-LRSECV5Y-DSusaq_V.js → pie-LRSECV5Y-IY3KukRR.js} +1 -1
  111. pythinker_code/web/static/assets/{pieDiagram-4H26LBE5-BcJSWsmz.js → pieDiagram-4H26LBE5-Ch0k4QrE.js} +1 -1
  112. pythinker_code/web/static/assets/{quadrantDiagram-W4KKPZXB-Cxp3MYGY.js → quadrantDiagram-W4KKPZXB-B8RbtJ2P.js} +1 -1
  113. pythinker_code/web/static/assets/{radar-GUYGQ44K-sZyAfli_.js → radar-GUYGQ44K-D0YLOyN_.js} +1 -1
  114. pythinker_code/web/static/assets/{requirementDiagram-4Y6WPE33-DSaeIAHw.js → requirementDiagram-4Y6WPE33-BTYkIO2O.js} +1 -1
  115. pythinker_code/web/static/assets/{sankeyDiagram-5OEKKPKP-BI87xNZ8.js → sankeyDiagram-5OEKKPKP-C3lqfCj1.js} +1 -1
  116. pythinker_code/web/static/assets/{sequenceDiagram-3UESZ5HK-B3mOEaPg.js → sequenceDiagram-3UESZ5HK-CxovAw-L.js} +1 -1
  117. pythinker_code/web/static/assets/{stateDiagram-AJRCARHV-CloKSCZq.js → stateDiagram-AJRCARHV-BAkLX0tZ.js} +1 -1
  118. pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-DsW61nNZ.js +1 -0
  119. pythinker_code/web/static/assets/{timeline-definition-PNZ67QCA-dy1xxojp.js → timeline-definition-PNZ67QCA-D8o-ffC-.js} +1 -1
  120. pythinker_code/web/static/assets/{treeView-BLDUP644-CouMM8Ld.js → treeView-BLDUP644-CKwJMtWj.js} +1 -1
  121. pythinker_code/web/static/assets/{treemap-LRROVOQU-BurOGxmB.js → treemap-LRROVOQU-0nn1YylS.js} +1 -1
  122. pythinker_code/web/static/assets/{vennDiagram-CIIHVFJN-ceoQfI8-.js → vennDiagram-CIIHVFJN-BEkXoyJ2.js} +1 -1
  123. pythinker_code/web/static/assets/{wardley-L42UT6IY-DQIeshOY.js → wardley-L42UT6IY-BielzF7q.js} +1 -1
  124. pythinker_code/web/static/assets/{wardleyDiagram-YWT4CUSO-yJgnjbxz.js → wardleyDiagram-YWT4CUSO-CbsH-9xk.js} +1 -1
  125. pythinker_code/web/static/assets/{xychartDiagram-2RQKCTM6-jMkhXWCc.js → xychartDiagram-2RQKCTM6-D4gCiMTY.js} +1 -1
  126. pythinker_code/web/static/index.html +1 -1
  127. pythinker_code/wire/server.py +24 -1
  128. pythinker_code/wire/types.py +81 -1
  129. {pythinker_code-0.45.0.dist-info → pythinker_code-0.47.0.dist-info}/METADATA +23 -24
  130. {pythinker_code-0.45.0.dist-info → pythinker_code-0.47.0.dist-info}/RECORD +134 -111
  131. pythinker_code/web/static/assets/architecture-7EHR7CIX-CxeoRZcL.js +0 -1
  132. pythinker_code/web/static/assets/channel-CzOfc6qg.js +0 -1
  133. pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-Cl__vhhZ.js +0 -1
  134. pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-Cl__vhhZ.js +0 -1
  135. pythinker_code/web/static/assets/eventmodeling-FCH6USID-1z7xo-Al.js +0 -1
  136. pythinker_code/web/static/assets/mermaid-VLURNSYL-B9qVFSXg.js +0 -1
  137. pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-bwtGCtLW.js +0 -1
  138. {pythinker_code-0.45.0.dist-info → pythinker_code-0.47.0.dist-info}/WHEEL +0 -0
  139. {pythinker_code-0.45.0.dist-info → pythinker_code-0.47.0.dist-info}/entry_points.txt +0 -0
  140. {pythinker_code-0.45.0.dist-info → pythinker_code-0.47.0.dist-info}/licenses/LICENSE +0 -0
  141. {pythinker_code-0.45.0.dist-info → pythinker_code-0.47.0.dist-info}/licenses/NOTICE +0 -0
@@ -15,6 +15,104 @@ GitHub Releases page; `0.8.0` is the new starting line.
15
15
 
16
16
  ## Unreleased
17
17
 
18
+ ## 0.47.0 (2026-06-16)
19
+
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.
62
+ - **Stop-time memory extraction can now be enabled explicitly.** Added an opt-in
63
+ `memory.harvest_on_stop` setting that stages safe assistant decisions, blockers, evidence, and
64
+ next steps into the existing scratchpad recall flow at turn end without writing directly to
65
+ durable `MEMORY.md`.
66
+ - **Agents can now discover visible tools and temporarily work from a session worktree.** Added
67
+ `ToolSearch` plus root-session `EnterWorktree` and `ExitWorktree` tools so agents can find
68
+ currently available capabilities by keyword and isolate a session's operational working directory
69
+ 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.
92
+ - **Agent-loop observability now emits explicit Wire events for key runtime state.** Added
93
+ `TodoListUpdated`, `SubagentToolFallback`, `AgentListDelta`, `ToolUseSkipped`, and
94
+ `ContextOverflowRecovered` events, with todo updates, subagent launch fallbacks, same-step tool
95
+ reuse/policy skips for tools that explicitly opt in, agent-list injections, and
96
+ context-overflow recovery now surfaced best-effort over Wire without changing existing tool
97
+ results.
98
+ - **Spend ceilings now warn before they stop a session.** When `max_session_cost_usd` is configured,
99
+ the loop appends a bounded system reminder after a turn crosses `budget_nudge_ratio` of the
100
+ ceiling, nudging the agent to conserve budget without auto-continuing or hiding a later
101
+ `budget_exhausted` stop.
102
+ - **The Agent tool description now gives clearer prompt-briefing guidance.** Fresh subagents should
103
+ receive the goal, scope, expected output contract, and verification criteria; the Haiku-style
104
+ tool-use summary from the blackbox reference was deliberately not ported.
105
+
106
+ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest).
107
+
108
+ ## 0.46.0 (2026-06-14)
109
+
110
+ - **Startup auto-update now picks up new releases within half an hour instead of up to a day.** The background update check was throttled to once every 24h, so a freshly published release could go unnoticed for a full day after the last check; the interval is now 30 minutes. The silent installer also no longer marks the throttle *before* the network call — a transient startup network error returns `FAILED` and is retried on the next launch instead of suppressing updates for the whole window.
111
+ - **The welcome banner now keeps the robot mark visible in compact terminals.** Below ~68 columns the robot was dropped (it could not sit beside the welcome copy); it now stacks centered above the copy instead, so the mark stays on screen at any width that can render its Unicode glyphs. ASCII-only terminals are unaffected.
112
+ - **A persistent update notice now sits directly under the prompt input.** When a newer release is available the footer shows a yellow `↑ Update available — vX · /update` line; once a release has been installed in the background it switches to a restart-to-apply message instead of pointing at `/update`, and it clears after you restart onto the new version. The line is suppressed for dismissed versions, disabled auto-update, and source checkouts.
113
+
114
+ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest).
115
+
18
116
  ## 0.45.0 (2026-06-14)
19
117
 
20
118
  - **Agent-tracing dashboard.** Added `pythinker dashboard` — a local web UI for inspecting sessions, wire events, context messages, tool statistics, and usage over time. It is also reachable from the interactive shell via the `/reports` slash command (aliased `/dashboard`).
@@ -17,6 +17,7 @@ from pythinker_code.acp.convert import (
17
17
  from pythinker_code.acp.types import ACPContentBlock
18
18
  from pythinker_code.app import PythinkerCLI
19
19
  from pythinker_code.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled
20
+ from pythinker_code.soul.btw import generate_max_steps_handoff
20
21
  from pythinker_code.tools import extract_key_argument
21
22
  from pythinker_code.utils.logging import logger
22
23
  from pythinker_code.wire.types import (
@@ -242,6 +243,13 @@ class ACPSession:
242
243
  raise acp.RequestError.internal_error({"error": str(e)}) from e
243
244
  except MaxStepsReached as e:
244
245
  logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
246
+ try:
247
+ handoff = await generate_max_steps_handoff(self._cli.soul)
248
+ except Exception:
249
+ logger.warning("Max-steps handoff failed", exc_info=True)
250
+ handoff = None
251
+ if handoff:
252
+ await self._send_text(f"\n── handoff ──\n{handoff}")
245
253
  return acp.PromptResponse(stop_reason="max_turn_requests")
246
254
  except RunCancelled:
247
255
  logger.info("Prompt cancelled by user")
@@ -12,6 +12,9 @@ agent:
12
12
  # - "pythinker_code.tools.think:Think"
13
13
  - "pythinker_code.tools.ask_user:AskUserQuestion"
14
14
  - "pythinker_code.tools.todo:SetTodoList"
15
+ - "pythinker_code.tools.tool_search:ToolSearch"
16
+ - "pythinker_code.tools.worktree:EnterWorktree"
17
+ - "pythinker_code.tools.worktree:ExitWorktree"
15
18
  - "pythinker_code.tools.goal:UpdateGoal"
16
19
  - "pythinker_code.tools.progress:Progress"
17
20
  - "pythinker_code.tools.suggest:Suggest"
@@ -35,6 +38,7 @@ agent:
35
38
  - "pythinker_code.tools.web:FetchURL"
36
39
  - "pythinker_code.tools.mcp_resource:ListMcpResources"
37
40
  - "pythinker_code.tools.mcp_resource:ReadMcpResource"
41
+ - "pythinker_code.tools.mcp_resource:InvokeMcpPrompt"
38
42
  - "pythinker_code.tools.plan:ExitPlanMode"
39
43
  - "pythinker_code.tools.plan.enter:EnterPlanMode"
40
44
  subagents:
pythinker_code/app.py CHANGED
@@ -254,6 +254,21 @@ class PythinkerCLI:
254
254
  config.loop_control.max_ralph_iterations = max_ralph_iterations
255
255
  logger.info("Loaded config: {config}", config=config)
256
256
 
257
+ # Install the plugin activation policy for this session so artifact
258
+ # collectors (skills/agents/commands/hooks/mcp) honor config-configured
259
+ # enable-state and the external (Claude/Codex) opt-in.
260
+ from pythinker_code.plugin.policy import policy_from_config, set_plugin_policy
261
+
262
+ set_plugin_policy(
263
+ policy_from_config(
264
+ config.plugins.discover_external,
265
+ config.plugins.external_exec,
266
+ config.plugins.enabled,
267
+ config.plugins.disabled,
268
+ config.plugins.options,
269
+ )
270
+ )
271
+
257
272
  _phase_t = time.monotonic()
258
273
  oauth = OAuthManager(config)
259
274
 
@@ -392,10 +407,12 @@ class PythinkerCLI:
392
407
  # Already in plan mode from restored session, trigger activation reminder
393
408
  soul.schedule_plan_activation_reminder()
394
409
 
395
- # Create and inject hook engine
410
+ # Create and inject hook engine. Enabled plugins contribute lifecycle
411
+ # hooks; config hooks come first so a project hook is never shadowed.
396
412
  from pythinker_code.hooks.engine import HookEngine
413
+ from pythinker_code.plugin.integration import plugin_hook_defs
397
414
 
398
- hook_engine = HookEngine(config.hooks, cwd=str(session.work_dir))
415
+ hook_engine = HookEngine([*config.hooks, *plugin_hook_defs()], cwd=str(session.work_dir))
399
416
  if config.disabled_project_hooks:
400
417
  # The load-time logger.warning only reaches shell users; publish a
401
418
  # notification so web/ACP frontends also learn why their project
@@ -270,9 +270,13 @@ def _load_mcp_configs_from_cli_inputs(
270
270
  file_configs.append(project_mcp_file)
271
271
 
272
272
  configs: list[Any] = []
273
+ from pythinker_code.exception import MCPConfigError
274
+
275
+ from .mcp import prepare_mcp_config_dict
276
+
273
277
  for conf in file_configs:
274
278
  try:
275
- configs.append(json.loads(conf.read_text(encoding="utf-8")))
279
+ configs.append(prepare_mcp_config_dict(json.loads(conf.read_text(encoding="utf-8"))))
276
280
  except json.JSONDecodeError as e:
277
281
  raise typer.BadParameter(
278
282
  f"Invalid JSON in MCP config file {conf}: {e}",
@@ -283,12 +287,19 @@ def _load_mcp_configs_from_cli_inputs(
283
287
  f"Cannot read MCP config file {conf}: {e}",
284
288
  param_hint="--mcp-config-file",
285
289
  ) from e
290
+ except MCPConfigError as e:
291
+ raise typer.BadParameter(
292
+ f"Invalid MCP config in file {conf}: {e}",
293
+ param_hint="--mcp-config-file",
294
+ ) from e
286
295
 
287
296
  for conf in raw_mcp_config:
288
297
  try:
289
- configs.append(json.loads(conf))
298
+ configs.append(prepare_mcp_config_dict(json.loads(conf)))
290
299
  except json.JSONDecodeError as e:
291
300
  raise typer.BadParameter(f"Invalid JSON: {e}", param_hint="--mcp-config") from e
301
+ except MCPConfigError as e:
302
+ raise typer.BadParameter(f"Invalid MCP config: {e}", param_hint="--mcp-config") from e
292
303
 
293
304
  for path in _yaml_files_with_misplaced_mcp_servers():
294
305
  from pythinker_code.utils.logging import logger
pythinker_code/cli/mcp.py CHANGED
@@ -2,7 +2,7 @@ import contextlib
2
2
  import json
3
3
  import os
4
4
  from pathlib import Path, PurePath
5
- from typing import Annotated, Any, Literal
5
+ from typing import Annotated, Any, Literal, cast
6
6
 
7
7
  import typer
8
8
 
@@ -27,6 +27,35 @@ def ensure_docker_rm(command: str, args: list[str]) -> list[str]:
27
27
  return [args[0], "--rm", *args[1:]]
28
28
 
29
29
 
30
+ def apply_docker_rm_to_mcp_config_dict(config: dict[str, Any]) -> dict[str, Any]:
31
+ """Inject ``--rm`` into stdio docker/podman servers when loading mcp.json (mcpext-3)."""
32
+ servers = config.get("mcpServers")
33
+ if not isinstance(servers, dict):
34
+ return config
35
+ typed_servers = cast(dict[str, Any], servers)
36
+ for raw_server in typed_servers.values():
37
+ if not isinstance(raw_server, dict):
38
+ continue
39
+ server = cast(dict[str, Any], raw_server)
40
+ command = server.get("command")
41
+ if not isinstance(command, str):
42
+ continue
43
+ raw_args = server.get("args")
44
+ args: list[str] = []
45
+ if isinstance(raw_args, list):
46
+ args = [str(item) for item in cast(list[object], raw_args)]
47
+ server["args"] = ensure_docker_rm(command, args)
48
+ return config
49
+
50
+
51
+ def prepare_mcp_config_dict(config: dict[str, Any]) -> dict[str, Any]:
52
+ """Apply portable MCP config fixes before validation (docker ``--rm``, name normalization)."""
53
+ from pythinker_code.utils.mcp_names import normalize_mcp_servers_in_config
54
+
55
+ config = apply_docker_rm_to_mcp_config_dict(config)
56
+ return normalize_mcp_servers_in_config(config)
57
+
58
+
30
59
  def get_global_mcp_config_file() -> Path:
31
60
  """Get the global MCP config file path."""
32
61
  from pythinker_code.share import get_share_dir
@@ -39,6 +68,8 @@ def _load_mcp_config() -> dict[str, Any]:
39
68
  from fastmcp.mcp_config import MCPConfig
40
69
  from pydantic import ValidationError
41
70
 
71
+ from pythinker_code.exception import MCPConfigError
72
+
42
73
  mcp_file = get_global_mcp_config_file()
43
74
  if not mcp_file.exists():
44
75
  return {"mcpServers": {}}
@@ -52,7 +83,30 @@ def _load_mcp_config() -> dict[str, Any]:
52
83
  except ValidationError as e:
53
84
  raise typer.BadParameter(f"Invalid MCP config in '{mcp_file}': {e}") from e
54
85
 
55
- return config
86
+ try:
87
+ return prepare_mcp_config_dict(config)
88
+ except MCPConfigError as e:
89
+ raise typer.BadParameter(str(e)) from e
90
+
91
+
92
+ def _resolve_mcp_server_key(name: str, servers: dict[str, Any]) -> str:
93
+ """Resolve a user-supplied MCP server name to the stored config key."""
94
+ from pythinker_code.exception import MCPConfigError
95
+ from pythinker_code.utils.mcp_names import normalize_mcp_server_name
96
+
97
+ if name in servers:
98
+ return name
99
+ try:
100
+ normalized = normalize_mcp_server_name(name)
101
+ except MCPConfigError as exc:
102
+ typer.echo(str(exc), err=True)
103
+ raise typer.Exit(code=1) from exc
104
+ if normalized in servers:
105
+ if normalized != name:
106
+ typer.echo(f"Resolved MCP server '{name}' to '{normalized}'.")
107
+ return normalized
108
+ typer.echo(f"MCP server '{name}' not found.", err=True)
109
+ raise typer.Exit(code=1)
56
110
 
57
111
 
58
112
  def _save_mcp_config(config: dict[str, Any]) -> None:
@@ -71,18 +125,23 @@ def _save_mcp_config(config: dict[str, Any]) -> None:
71
125
  fh.write(payload)
72
126
 
73
127
 
74
- def _get_mcp_server(name: str, *, require_remote: bool = False) -> dict[str, Any]:
75
- """Get MCP server config by name."""
128
+ def _mcp_servers_from_config(config: dict[str, Any]) -> dict[str, Any]:
129
+ raw_servers = config.get("mcpServers", {})
130
+ if isinstance(raw_servers, dict):
131
+ return cast(dict[str, Any], raw_servers)
132
+ return {}
133
+
134
+
135
+ def _get_mcp_server(name: str, *, require_remote: bool = False) -> tuple[str, dict[str, Any]]:
136
+ """Get MCP server config by name (accepts raw or normalized keys)."""
76
137
  config = _load_mcp_config()
77
- servers = config.get("mcpServers", {})
78
- if name not in servers:
79
- typer.echo(f"MCP server '{name}' not found.", err=True)
80
- raise typer.Exit(code=1)
81
- server = servers[name]
138
+ servers = _mcp_servers_from_config(config)
139
+ stored_name = _resolve_mcp_server_key(name, servers)
140
+ server = cast(dict[str, Any], servers[stored_name])
82
141
  if require_remote and "url" not in server:
83
- typer.echo(f"MCP server '{name}' is not a remote server.", err=True)
142
+ typer.echo(f"MCP server '{stored_name}' is not a remote server.", err=True)
84
143
  raise typer.Exit(code=1)
85
- return server
144
+ return stored_name, server
86
145
 
87
146
 
88
147
  def _parse_key_value_pairs(
@@ -171,8 +230,18 @@ def mcp_add(
171
230
  ] = None,
172
231
  ):
173
232
  """Add an MCP server."""
233
+ from pythinker_code.exception import MCPConfigError
234
+ from pythinker_code.utils.mcp_names import normalize_mcp_server_name
235
+
174
236
  config = _load_mcp_config()
175
237
  server_args = server_args or []
238
+ try:
239
+ stored_name = normalize_mcp_server_name(name)
240
+ except MCPConfigError as exc:
241
+ typer.echo(str(exc), err=True)
242
+ raise typer.Exit(code=1) from exc
243
+ if stored_name != name:
244
+ typer.echo(f"Normalized MCP server name '{name}' to '{stored_name}'.")
176
245
 
177
246
  if transport not in {"stdio", "http"}:
178
247
  typer.echo(f"Unsupported transport: {transport}.", err=True)
@@ -221,9 +290,12 @@ def mcp_add(
221
290
 
222
291
  if "mcpServers" not in config:
223
292
  config["mcpServers"] = {}
224
- config["mcpServers"][name] = server_config
293
+ if stored_name in config["mcpServers"]:
294
+ typer.echo(f"MCP server '{stored_name}' already exists.", err=True)
295
+ raise typer.Exit(code=1)
296
+ config["mcpServers"][stored_name] = server_config
225
297
  _save_mcp_config(config)
226
- typer.echo(f"Added MCP server '{name}' to {get_global_mcp_config_file()}.")
298
+ typer.echo(f"Added MCP server '{stored_name}' to {get_global_mcp_config_file()}.")
227
299
 
228
300
 
229
301
  @cli.command("remove")
@@ -234,11 +306,12 @@ def mcp_remove(
234
306
  ],
235
307
  ):
236
308
  """Remove an MCP server."""
237
- _get_mcp_server(name)
238
309
  config = _load_mcp_config()
239
- del config["mcpServers"][name]
310
+ servers = _mcp_servers_from_config(config)
311
+ stored_name = _resolve_mcp_server_key(name, servers)
312
+ del config["mcpServers"][stored_name]
240
313
  _save_mcp_config(config)
241
- typer.echo(f"Removed MCP server '{name}' from {get_global_mcp_config_file()}.")
314
+ typer.echo(f"Removed MCP server '{stored_name}' from {get_global_mcp_config_file()}.")
242
315
 
243
316
 
244
317
  def _oauth_token_storage(server_url: str) -> Any:
@@ -306,21 +379,25 @@ def mcp_auth(
306
379
  import asyncio
307
380
 
308
381
  server = _get_mcp_server(name, require_remote=True)
309
- if server.get("auth") != "oauth":
310
- typer.echo(f"MCP server '{name}' does not use OAuth. Add with --auth oauth.", err=True)
382
+ stored_name, server_config = server
383
+ if server_config.get("auth") != "oauth":
384
+ typer.echo(
385
+ f"MCP server '{stored_name}' does not use OAuth. Add with --auth oauth.",
386
+ err=True,
387
+ )
311
388
  raise typer.Exit(code=1)
312
389
 
313
390
  async def _auth() -> None:
314
391
  import fastmcp
315
392
 
316
- typer.echo(f"Authorizing with '{name}'...")
393
+ typer.echo(f"Authorizing with '{stored_name}'...")
317
394
  typer.echo("A browser window will open for authorization.")
318
395
 
319
- client = fastmcp.Client({"mcpServers": {name: server}})
396
+ client = fastmcp.Client({"mcpServers": {stored_name: server_config}})
320
397
  try:
321
398
  async with client:
322
399
  tools = await client.list_tools()
323
- typer.echo(f"Successfully authorized with '{name}'.")
400
+ typer.echo(f"Successfully authorized with '{stored_name}'.")
324
401
  typer.echo(f"Available tools: {len(tools)}")
325
402
  except Exception as e:
326
403
  typer.echo(f"Authorization failed: {type(e).__name__}: {e}", err=True)
@@ -337,7 +414,7 @@ def mcp_reset_auth(
337
414
  ],
338
415
  ):
339
416
  """Reset OAuth authorization for an MCP server (clear cached tokens)."""
340
- server = _get_mcp_server(name, require_remote=True)
417
+ stored_name, server = _get_mcp_server(name, require_remote=True)
341
418
 
342
419
  try:
343
420
  import asyncio
@@ -349,7 +426,7 @@ def mcp_reset_auth(
349
426
  await result
350
427
 
351
428
  asyncio.run(_clear())
352
- typer.echo(f"OAuth tokens cleared for '{name}'.")
429
+ typer.echo(f"OAuth tokens cleared for '{stored_name}'.")
353
430
  except ImportError:
354
431
  typer.echo("OAuth support not available.", err=True)
355
432
  raise typer.Exit(code=1) from None
@@ -368,18 +445,18 @@ def mcp_test(
368
445
  """Test connection to an MCP server and list available tools."""
369
446
  import asyncio
370
447
 
371
- server = _get_mcp_server(name)
448
+ stored_name, server = _get_mcp_server(name)
372
449
 
373
450
  async def _test() -> None:
374
451
  import fastmcp
375
452
 
376
- typer.echo(f"Testing connection to '{name}'...")
377
- client = fastmcp.Client({"mcpServers": {name: server}})
453
+ typer.echo(f"Testing connection to '{stored_name}'...")
454
+ client = fastmcp.Client({"mcpServers": {stored_name: server}})
378
455
 
379
456
  try:
380
457
  async with client:
381
458
  tools = await client.list_tools()
382
- typer.echo(f"✓ Connected to '{name}'")
459
+ typer.echo(f"✓ Connected to '{stored_name}'")
383
460
  typer.echo(f" Available tools: {len(tools)}")
384
461
  if tools:
385
462
  typer.echo(" Tools:")