utim-cli 2.3.19b0__tar.gz → 2.3.21__tar.gz

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 (172) hide show
  1. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/CHANGELOG.md +18 -6
  2. {utim_cli-2.3.19b0/utim_cli.egg-info → utim_cli-2.3.21}/PKG-INFO +1 -1
  3. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/pyproject.toml +1 -1
  4. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim/__init__.py +1 -1
  5. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_version.py +1 -5
  7. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/client_utils.py +6 -1
  8. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/constants.py +2 -0
  9. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator.py +46 -0
  10. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_llm_transport.py +84 -129
  11. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_turn_history.py +5 -1
  12. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/hooks.py +11 -3
  13. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/admin_auth.py +194 -163
  14. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/auth.py +174 -24
  15. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/batch_processor.py +65 -6
  16. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/concurrency.py +167 -154
  17. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/db.py +272 -12
  18. utim_cli-2.3.21/utim_cli/server/docs_md/pricing.md +19 -0
  19. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/key_rotator.py +815 -224
  20. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/micro_batcher.py +60 -34
  21. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/models.py +134 -84
  22. utim_cli-2.3.21/utim_cli/server/plan_integrity.py +1065 -0
  23. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/pricing_updater.py +9 -6
  24. utim_cli-2.3.21/utim_cli/server/rate_limit.py +38 -0
  25. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/router.py +123 -27
  26. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/admin_bonus_routes.py +8 -0
  27. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/admin_db_routes.py +422 -4
  28. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/auth_routes.py +47 -13
  29. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/completion_routes.py +777 -97
  30. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/credit_routes.py +399 -357
  31. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/quota_routes.py +370 -61
  32. utim_cli-2.3.21/utim_cli/server/routes/webhook_routes.py +99 -0
  33. utim_cli-2.3.21/utim_cli/server/security_alerts.py +82 -0
  34. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/share_tui.py +1 -1
  35. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tools.py +98 -0
  36. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/model_dialog.py +23 -7
  37. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/quota_share_dialog.py +458 -446
  38. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/utim.py +171 -71
  39. {utim_cli-2.3.19b0 → utim_cli-2.3.21/utim_cli.egg-info}/PKG-INFO +1 -1
  40. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/SOURCES.txt +3 -0
  41. utim_cli-2.3.19b0/utim_cli/server/docs_md/pricing.md +0 -22
  42. utim_cli-2.3.19b0/utim_cli/server/rate_limit.py +0 -27
  43. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/LICENSE +0 -0
  44. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/MANIFEST.in +0 -0
  45. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/README.md +0 -0
  46. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/setup.cfg +0 -0
  47. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/setup.py +0 -0
  48. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_verify_repro.py +0 -0
  49. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_verify_syntax.py +0 -0
  50. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/_wizard_smoke_test.py +0 -0
  51. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/agent.py +0 -0
  52. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/ask_helper.py +0 -0
  53. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/auth.py +0 -0
  54. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/backup.py +0 -0
  55. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/billing.py +0 -0
  56. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/blender_agent.py +0 -0
  57. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/bootstrap.py +0 -0
  58. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/brain.py +0 -0
  59. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
  60. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/config.py +0 -0
  61. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/context_pruner.py +0 -0
  62. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/crawler/__init__.py +0 -0
  63. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/crawler/engine.py +0 -0
  64. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/crawler/markdown_distiller.py +0 -0
  65. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/dependency_manager.py +0 -0
  66. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/doctor.py +0 -0
  67. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/evidence_registry.py +0 -0
  68. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/harbor.py +0 -0
  69. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/knowledge_graph.py +0 -0
  70. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/local_db.py +0 -0
  71. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/logger.py +0 -0
  72. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/mcp_clean_wrapper.py +0 -0
  73. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/mcp_client.py +0 -0
  74. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/mcp_registry.json +0 -0
  75. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/models.txt +0 -0
  76. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_helpers.py +0 -0
  77. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/orchestrator_tool_exec.py +0 -0
  78. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/personalities.py +0 -0
  79. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/platform_installer.py +0 -0
  80. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/__init__.py +0 -0
  81. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/loader.py +0 -0
  82. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/manager.py +0 -0
  83. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/registry.py +0 -0
  84. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/runtime.py +0 -0
  85. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/schema.py +0 -0
  86. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/plugins/tool_loader.py +0 -0
  87. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/reflection.py +0 -0
  88. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/report.py +0 -0
  89. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/scrapy_search.py +0 -0
  90. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/__init__.py +0 -0
  91. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/config.py +0 -0
  92. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/context.py +0 -0
  93. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/events.py +0 -0
  94. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/handlers.py +0 -0
  95. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/session.py +0 -0
  96. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/sdk/sidecar.py +0 -0
  97. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/__init__.py +0 -0
  98. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/attribution.py +0 -0
  99. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/audit_log.py +0 -0
  100. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/captcha.py +0 -0
  101. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/cli_auth.py +0 -0
  102. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  103. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/about.md +0 -0
  104. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/changelog.md +0 -0
  105. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/docs.md +0 -0
  106. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/features.md +0 -0
  107. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/license.md +0 -0
  108. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/privacy.md +0 -0
  109. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/refund.md +0 -0
  110. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/support.md +0 -0
  111. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/docs_md/terms.md +0 -0
  112. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/email_utils.py +0 -0
  113. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/exchange_rate.py +0 -0
  114. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/firebase.py +0 -0
  115. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/fix_duplicate_users.py +0 -0
  116. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/history.py +0 -0
  117. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/local_llm.py +0 -0
  118. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/logging_config.py +0 -0
  119. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/model_agent.py +0 -0
  120. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/provision_build.py +0 -0
  121. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/rewards_engine.py +0 -0
  122. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/__init__.py +0 -0
  123. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/billing_routes.py +0 -0
  124. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/feedback_routes.py +0 -0
  125. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/marketplace_routes.py +0 -0
  126. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/quota_share_routes.py +0 -0
  127. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/referral_routes.py +0 -0
  128. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/rewards_routes.py +0 -0
  129. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/security_routes.py +0 -0
  130. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/session_routes.py +0 -0
  131. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/routes/share_routes.py +0 -0
  132. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/server.py +0 -0
  133. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/storage_nodes.py +0 -0
  134. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/server/utils.py +0 -0
  135. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/share.py +0 -0
  136. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/situational_scoring.py +0 -0
  137. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/state.py +0 -0
  138. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/subagent_manager.py +0 -0
  139. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/task_dispatcher.py +0 -0
  140. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/task_logger.py +0 -0
  141. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tools_grep.py +0 -0
  142. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/__init__.py +0 -0
  143. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/feedback_dialog.py +0 -0
  144. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/history_dialog.py +0 -0
  145. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/marketplace_app_state.py +0 -0
  146. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/marketplace_dialog.py +0 -0
  147. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  148. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/mcp_dialog.py +0 -0
  149. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/miniagents_dialog.py +0 -0
  150. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/personality_dialog.py +0 -0
  151. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/plugins_dialog.py +0 -0
  152. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/publish_dialog.py +0 -0
  153. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/quota_dialog.py +0 -0
  154. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  155. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/resume_dialog.py +0 -0
  156. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/rewards_tui.py +0 -0
  157. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/skills_dialog.py +0 -0
  158. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/subagents_dialog.py +0 -0
  159. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/tasks_dialog.py +0 -0
  160. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/thinking_display.py +0 -0
  161. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/tools_dialog.py +0 -0
  162. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/tui/update_dialog.py +0 -0
  163. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/update_checker.py +0 -0
  164. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/utilities.py +0 -0
  165. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/utimmodel.txt +0 -0
  166. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/vector_memory.py +0 -0
  167. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/wheel.py +0 -0
  168. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli/workspace.py +0 -0
  169. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/dependency_links.txt +0 -0
  170. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/entry_points.txt +0 -0
  171. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/requires.txt +0 -0
  172. {utim_cli-2.3.19b0 → utim_cli-2.3.21}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,12 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## [2.3.19-beta] - 2026-09-03
3
+ ## [2.3.21] - 2026-09-06
4
4
 
5
- ### 🚀 Beta Release: Tier Enforcement & Model Access Fixes
6
- - **Websearch tier gate**: agentic websearch (LLM query synthesis + synthesis) is now paid-plan only; free-tier users always get the fast non-agentic path
7
- - **Tool registry cleanup**: removed `analyzeimage` from callable tools — vision models receive image bytes directly via `readfile`, non-vision models get internal auto-descriptions
8
- - **APINEX models ungated**: APINEX models are now available on every plan (paid included) as free models
9
- - Version tagged as **beta** (`2.3.19-beta`) for beta-channel upload
5
+ ### 💰 Pricing Modifications
6
+ - **OpenRouter Tier Markup**: Standardized 30% markup across OpenRouter models in the server `model_registry` table.
7
+ - **Free Model Fallback Credit Rate Preservation**: Preserved fixed consumption rates ($0.30 input / $0.50 output per 1M tokens) when transitioning from exhausted APINEX keys to fallback paid slugs, ensuring consistent credit accounting.
8
+
9
+ ### 📦 Models Payload Fixes
10
+ - **Upstream Provider Normalization**: Normalized vendor provider resolution for multi-provider models (MiniMax, Cohere, Dots-Studio, InclusionAI) to route cleanly via OpenRouter upstream.
11
+ - **Image Generation Model Filtering**: Excluded image-generation models from the interactive chat model catalog to avoid completion payload incompatibilities.
12
+ - **APINEX Model Mapping Support**: Added model registry definitions and key rotation support for `qwen/3.8-max` and `free/qwen-3.8-max`.
13
+
14
+ ### 🛡️ Rate Limit & Vision Tooling Fixes
15
+ - **MiniMax Rate-Limit Fallback to Laguna**: Redirected exhausted MiniMax free models (`minimax/minimax-m2.7:free`, `minimax/minimax-m3:free`) to fall back to `poolside/laguna-s-2.1` paid slug rather than expensive MiniMax paid slugs.
16
+ - **APINEX Transparent Image Analysis**: Updated `read_file` on image files under APINEX models to automatically execute `analyze_image` in the background and return only the text summary, bypassing vision parameter constraints.
17
+
18
+ ## [2.3.20] - 2026-09-04
19
+
20
+ ### 🛠️ Minor Fixes and Enhancement
21
+ - Minor fixes and enhancement
10
22
 
11
23
  ## [2.3.19] - 2026-09-02
12
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.3.19b0
3
+ Version: 2.3.21
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: Emend AI Proprietary EULA
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.3.19-beta"
7
+ version = "2.3.21"
8
8
 
9
9
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
10
10
  readme = "README.md"
@@ -14,7 +14,7 @@ Quickstart:
14
14
  try:
15
15
  from utim_cli._version import VERSION as __version__
16
16
  except ImportError:
17
- __version__ = "2.3.19-beta"
17
+ __version__ = "2.3.21"
18
18
 
19
19
  # Developer-friendly core classes (similar to `import genai` / `import openai`)
20
20
  from utim_cli.sdk.session import UtimSession as Session
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.3.19-beta"
8
+ __version__ = "2.3.21"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,10 +11,6 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- # Beta release: "-beta" is the machine-valid spelling of "2.3.19 beta"
15
- # (npm semver prerelease tag + PEP 440 pre-release segment; a space would
16
- # break npm publish and pip). parse_version() strips the suffix so update
17
- # checks still compare on the numeric core 2.3.19.
18
- VERSION = "2.3.19-beta"
14
+ VERSION = "2.3.21"
19
15
 
20
16
 
@@ -248,12 +248,14 @@ def fetch_remote_models_catalog(timeout: float = 3.0) -> dict:
248
248
  for m in models_list:
249
249
  caps = [c.lower() for c in (m.get("capabilities") or [])]
250
250
  tags = [t.lower() for t in (m.get("tags") or [])]
251
+ mid = str(m.get("model_id") or "")
251
252
  is_img = bool(
252
253
  m.get("is_image_gen")
253
254
  or "image_generation" in caps
254
255
  or "imagegen" in caps
255
256
  or "image_output" in caps
256
- or "image" in tags
257
+ or mid.lower().startswith("krea/")
258
+ or ("image" in caps and not any(c in caps for c in ("chat", "vision", "text")))
257
259
  )
258
260
  is_vis = bool(m.get("is_vision") or "vision" in caps or "vision" in tags)
259
261
  if is_img:
@@ -334,6 +336,9 @@ def _build_free_models_fallback_chain(now: float) -> list[str]:
334
336
  (ModelDB.is_free == True) | (ModelDB.cost_input_per_1m == 0.0) | (ModelDB.model_id.like("%:free"))
335
337
  ).order_by(ModelDB.model_id.asc()).all()
336
338
  for r in free_rows:
339
+ caps = [str(c).lower() for c in (r.capabilities or [])]
340
+ if r.is_image_gen or "image_generation" in caps or "imagegen" in caps:
341
+ continue
337
342
  mid = r.model_id
338
343
  if mid and mid not in seen and mid not in INVALID_DUMMY_IDS:
339
344
  chain.append(mid)
@@ -30,6 +30,8 @@ APINEX_MODEL_IDS = {
30
30
  "glm/5.3",
31
31
  "glm/5.3-flash",
32
32
  "grok/4.6",
33
+ "qwen/3.8-max",
34
+ "free/qwen-3.8-max",
33
35
  }
34
36
 
35
37
 
@@ -594,6 +594,30 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
594
594
  except Exception:
595
595
  pass
596
596
 
597
+ # ── Sanitize: strip base64-encoded binary blobs ─────────────────
598
+ # On Termux, copying an image file can place raw base64 data into the
599
+ # clipboard. If any paste-chip expansion path leaks this binary noise
600
+ # into the user message, we detect and truncate it here before it
601
+ # reaches the model context.
602
+ _B64_RUN_RE = re.compile(r'[A-Za-z0-9+/=]{400,}')
603
+ if _B64_RUN_RE.search(user_message):
604
+ # Count total base64-characters vs meaningful content
605
+ b64_chars = sum(1 for c in user_message if 'A' <= c <= 'Z' or 'a' <= c <= 'z' or '0' <= c <= '9' or c in '+/=')
606
+ total_chars = max(len(user_message), 1)
607
+ if total_chars > 600 and (b64_chars / total_chars) > 0.85:
608
+ # The message is dominated by binary noise — keep only
609
+ # non-base64 lines and any embedded file paths.
610
+ clean_lines = []
611
+ for line in user_message.split('\n'):
612
+ line_stripped = line.strip()
613
+ b64_ratio = sum(1 for c in line_stripped if 'A' <= c <= 'Z' or 'a' <= c <= 'z' or '0' <= c <= '9' or c in '+/=') / max(len(line_stripped), 1)
614
+ # Keep lines that are paths or have low base64 ratio
615
+ if b64_ratio < 0.70 or re.search(r'[A-Za-z]:[\\/]|/[^\s]+\.\w{2,4}', line_stripped):
616
+ clean_lines.append(line)
617
+ if clean_lines:
618
+ user_message = '\n'.join(clean_lines)
619
+ user_message += f"\n\n[Pasted binary clipboard data stripped — {total_chars} chars of base64 noise removed]"
620
+
597
621
  # Auto-detect image paths in user_message and send direct multimodal vision payload
598
622
  try:
599
623
  img_matches = re.findall(r'(?:[A-Za-z]:[\\/][^\s"\']+\.(?:png|jpg|jpeg|webp|gif)|/[^\s"\']+\.(?:png|jpg|jpeg|webp|gif))', user_message, re.IGNORECASE)
@@ -829,6 +853,17 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
829
853
 
830
854
  break # success
831
855
  except _ServerUnavailableError as exc:
856
+ _err_text = str(exc).lower()
857
+ if "rate limit" in _err_text or "rate-limit" in _err_text or "429" in _err_text or "too many requests" in _err_text:
858
+ self.console.print(f"\n[bold yellow]⏳ Model is temporarily rate-limited by the provider.[/bold yellow]")
859
+ self.console.print(" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n")
860
+ if len(self.messages) <= turn_msg_start + 1:
861
+ self.messages.append({
862
+ "role": "assistant",
863
+ "content": "⏳ **Model Rate Limited**: The model is temporarily rate-limited by the provider. Please wait a short moment and retry, or use `/model` to switch models."
864
+ })
865
+ self._persist_messages()
866
+ return
832
867
  if self.cancel_event.is_set():
833
868
  del self.messages[turn_msg_start + 1:]
834
869
  self._persist_messages()
@@ -875,6 +910,17 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
875
910
  self.messages.append({"role": "assistant", "content": "[Aborted by user]"})
876
911
  break
877
912
  except Exception as exc:
913
+ _err_text = str(exc).lower()
914
+ if "rate limit" in _err_text or "rate-limit" in _err_text or "429" in _err_text or "too many requests" in _err_text:
915
+ self.console.print(f"\n[bold yellow]⏳ Model is temporarily rate-limited by the provider.[/bold yellow]")
916
+ self.console.print(" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n")
917
+ if len(self.messages) <= turn_msg_start + 1:
918
+ self.messages.append({
919
+ "role": "assistant",
920
+ "content": "⏳ **Model Rate Limited**: The model is temporarily rate-limited by the provider. Please wait a short moment and retry, or use `/model` to switch models."
921
+ })
922
+ self._persist_messages()
923
+ return
878
924
  if self.cancel_event.is_set():
879
925
  if len(self.messages) <= turn_msg_start + 1:
880
926
  self.messages.append({"role": "assistant", "content": "[Aborted by user]"})
@@ -132,7 +132,36 @@ from .orchestrator_turn_history import TurnHistoryMixin # noqa: E402
132
132
  Contains: _get_configured_model_source, _is_custom_model_selection,
133
133
  _resolve_model_endpoint, _call_llm. Behavior-identical; Orchestrator inherits.
134
134
  """
135
+
136
+ # ── Effort → stall-timeout mapping (thinking-level based, NOT model-name based) ──
137
+ # Reasoning models think longer at higher effort levels. The stream stall detector
138
+ # (TTFT + inter-token) uses these per-effort durations instead of a single hard
139
+ # 900s ceiling, so a stuck "none" call fails fast while "max" gets full headroom.
140
+ _EFFORT_STALL_TIMEOUT_SECONDS = {
141
+ "none": 15,
142
+ "minimal": 45,
143
+ "low": 60,
144
+ "medium": 80,
145
+ "high": 100,
146
+ "xhigh": 180,
147
+ "max": 300,
148
+ }
149
+ _DEFAULT_STALL_TIMEOUT = 80 # matches "medium" (the default reasoning_effort)
150
+
151
+
152
+ def _effort_stall_timeout(settings: dict) -> int:
153
+ """Return the stall-detection timeout (seconds) for the given model settings,
154
+ keyed by reasoning effort (thinking level), not by model name."""
155
+ effort = (settings or {}).get("reasoning_effort", "medium")
156
+ return _EFFORT_STALL_TIMEOUT_SECONDS.get(effort, _DEFAULT_STALL_TIMEOUT)
157
+
158
+
135
159
  class LLMTransportMixin:
160
+ _PRE_THINK_PATTERNS = re.compile(
161
+ r"^\s*(\*\s*Thinking\.\.\.?|\.\.\.(\s*thinking)?|thinking\.\.\.?)\s*$",
162
+ re.IGNORECASE | re.MULTILINE,
163
+ )
164
+
136
165
  def _get_configured_model_source(self, model_id: str, *, is_primary: bool = False) -> Optional[str]:
137
166
  """Return the selected source for a model id when the picker recorded one."""
138
167
  if is_primary:
@@ -736,7 +765,9 @@ class LLMTransportMixin:
736
765
  # 1. Check if quota is exhausted (only for non-custom models)
737
766
  is_5h_exhausted = bool(quota.get("five_hour_quota_exhausted", False))
738
767
  is_monthly_exhausted = bool(quota.get("is_exhausted", False))
739
- is_exhausted = is_5h_exhausted or is_monthly_exhausted
768
+ bonus_bal = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
769
+ # If the user has available bonus credits, inference is funded by bonus quota
770
+ is_exhausted = (is_5h_exhausted or is_monthly_exhausted) and bonus_bal <= 0.0
740
771
 
741
772
  if not is_custom and is_exhausted:
742
773
  reset_raw = quota.get("five_hour_reset_at") if is_5h_exhausted else (quota.get("reset_at") or quota.get("five_hour_reset_at", ""))
@@ -771,7 +802,7 @@ class LLMTransportMixin:
771
802
 
772
803
  # 2. Check if chosen model is allowed — skip entirely for BYOK/custom models
773
804
  models_allowed = quota.get("models_allowed", [])
774
- free_bonus_balance = quota.get("free_bonus_balance", 0.0) or 0.0
805
+ free_bonus_balance = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
775
806
  chosen_model = override_model if override_model else self.model_id
776
807
  is_custom = self._is_custom_model_selection(chosen_model, is_primary=not override_model)
777
808
  is_free_user = (quota.get("plan") == "free" or str(quota.get("display_name", "")).lower() == "free")
@@ -1025,6 +1056,8 @@ class LLMTransportMixin:
1025
1056
  # Intentionally NOT reset by keep-alive pings (empty lines) so stall
1026
1057
  # detection isn't fooled by the server sending blank heartbeats.
1027
1058
  last_content_time = start_time
1059
+ # Per-effort stall timeout (thinking level, not model name).
1060
+ stall_timeout = _effort_stall_timeout(settings)
1028
1061
  _api_key = config.get("api_key")
1029
1062
  _openrouter_key = self._local_api_key
1030
1063
  _browser_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
@@ -1288,15 +1321,21 @@ class LLMTransportMixin:
1288
1321
  # Stall detection runs on EVERY iteration (including empty
1289
1322
  # keep-alive lines) so a true stream stall is always caught.
1290
1323
  if not final_content and not final_tool_calls:
1291
- # Hard 900-second timeout for Time-To-First-Token
1292
- # Models often need 60-300s to process large tool outputs before streaming
1293
- if now - start_time > 900:
1294
- raise requests.exceptions.Timeout("Hard TTFT timeout exceeded 900s")
1324
+ # Effort-scaled Time-To-First-Token timeout. Models can
1325
+ # need 60-300s to process large tool outputs, so this is
1326
+ # keyed to reasoning effort (thinking level), not model name.
1327
+ if now - start_time > stall_timeout:
1328
+ raise requests.exceptions.Timeout(
1329
+ f"Hard TTFT timeout exceeded {stall_timeout}s (effort)"
1330
+ )
1295
1331
  else:
1296
1332
  # Inter-content stall detection: abort if no real content
1297
- # has arrived for 900 seconds (15 mins), even during keep-alive pings.
1298
- if now - last_content_time > 900:
1299
- raise requests.exceptions.Timeout("Inter-token stall timeout exceeded 900s")
1333
+ # has arrived for `stall_timeout` seconds, even during
1334
+ # keep-alive pings.
1335
+ if now - last_content_time > stall_timeout:
1336
+ raise requests.exceptions.Timeout(
1337
+ f"Inter-token stall timeout exceeded {stall_timeout}s (effort)"
1338
+ )
1300
1339
 
1301
1340
  if not raw_line:
1302
1341
  continue
@@ -1348,10 +1387,15 @@ class LLMTransportMixin:
1348
1387
  if t == "queue_status":
1349
1388
  pos = chunk.get("position", 1)
1350
1389
  status = chunk.get("status", "queued")
1390
+ tier = chunk.get("tier", "")
1391
+ prio = chunk.get("priority", 0)
1351
1392
  if status == "queued":
1352
1393
  try:
1353
1394
  from utim_cli.state import STATE
1354
- STATE["thinking_topic"] = f"Request Queued Position {pos}"
1395
+ if prio > 0 and tier:
1396
+ STATE["thinking_topic"] = f"Request Queued Position {pos} ({str(tier).title()} Priority)"
1397
+ else:
1398
+ STATE["thinking_topic"] = f"Request Queued Position {pos}"
1355
1399
  except Exception:
1356
1400
  pass
1357
1401
  elif status == "processing":
@@ -1514,12 +1558,23 @@ class LLMTransportMixin:
1514
1558
  "tool_calls": None,
1515
1559
  }, False
1516
1560
  elif _is_rate_limit:
1561
+ rate_limit_display = (
1562
+ f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
1563
+ f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
1564
+ f"- **Retry:** You can resend your prompt anytime."
1565
+ )
1517
1566
  if not silent:
1518
1567
  self.console.print(
1519
- f"\n[dim yellow]↪ {_friendly_name} is currently rate-limited — "
1520
- f"switching to the next available model…[/dim yellow]"
1568
+ f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
1569
+ )
1570
+ self.console.print(
1571
+ " [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
1521
1572
  )
1522
- raise RuntimeError(f"__RATE_LIMITED__:{current_model}")
1573
+ return {
1574
+ "role": "assistant",
1575
+ "content": rate_limit_display,
1576
+ "tool_calls": None,
1577
+ }, False
1523
1578
  elif _is_provider_upstream:
1524
1579
  if not silent:
1525
1580
  self.console.print(
@@ -1940,7 +1995,8 @@ class LLMTransportMixin:
1940
1995
  timeout=5,
1941
1996
  )
1942
1997
  if _qr.status_code == 200:
1943
- _has_bonus = (_qr.json().get("free_bonus_balance") or 0.0) > 0.0
1998
+ _data = _qr.json()
1999
+ _has_bonus = (float(_data.get("bonus_balance", _data.get("free_bonus_balance", 0.0)) or 0.0) > 0.0)
1944
2000
  except Exception:
1945
2001
  pass
1946
2002
  if _has_bonus:
@@ -2059,124 +2115,23 @@ class LLMTransportMixin:
2059
2115
  "tool_calls": None,
2060
2116
  }, False
2061
2117
 
2062
- if not msg_str:
2063
- msg_str = "Provider is temporarily rate-limiting this model."
2064
-
2065
- # Stash the original message so the final-exhaustion branch can use it
2066
- _rate_limit_detail = msg_str
2067
- _rate_limit_upgrade_url = upgrade_url
2068
-
2069
- # Are there more models in the fallback chain to try?
2070
- _has_more_models = model_idx < len(models_to_try) - 1
2071
-
2072
- if _has_more_models:
2073
- # Soft, dim notice — let the next model in the chain take over.
2074
- # The user doesn't need to (and shouldn't) see provider jargon.
2075
- try:
2076
- self.console.print(
2077
- f"\n[dim yellow]↪ {current_model} is busy right now — "
2078
- f"switching to the next available model…[/dim yellow]"
2079
- )
2080
- except Exception:
2081
- pass
2082
- # Break the inner retry loop → outer loop will pick the next model
2083
- last_exc = exc
2084
- break
2085
- else:
2086
- # Last model in chain also rate-limited — show a CLEAN, friendly
2087
- # banner. Mask raw OpenRouter jargon. If the model name looks
2088
- # like a free/OpenRouter endpoint, frame it as "all free
2089
- # models are momentarily busy" instead of a paid-upgrade CTA.
2090
- _is_free_tier_model = (
2091
- ":free" in current_model
2092
- or "/free" in current_model
2093
- or "openrouter" in current_model.lower()
2094
- )
2095
- _display_msg = _rate_limit_detail
2096
- # Strip provider-specific keywords that leak through to users
2097
- import re as _re
2098
- for _jargon in (
2099
- "tokens per minute", "requests per minute",
2100
- "free model", "free tier", "provider",
2101
- ):
2102
- _display_msg = _re.sub(
2103
- _re.escape(_jargon),
2104
- "",
2105
- _display_msg,
2106
- flags=_re.IGNORECASE,
2107
- )
2108
- # Word-boundary stripping for short tokens (tpm, rpm)
2109
- for _jargon in ("tpm", "rpm"):
2110
- _display_msg = _re.sub(
2111
- rf"\b{_jargon}\b",
2112
- "",
2113
- _display_msg,
2114
- flags=_re.IGNORECASE,
2115
- )
2116
- # Collapse leftover whitespace + dangling punctuation
2117
- _display_msg = _re.sub(r"\s+", " ", _display_msg).strip(" .,;:—-")
2118
- # Drop the generic "returned error:" prefix — it's provider
2119
- # boilerplate that adds no value to a human reader.
2120
- _display_msg = _re.sub(
2121
- r"^(?:returned error|error)\s*[:\-—]?\s*",
2122
- "",
2123
- _display_msg,
2124
- flags=_re.IGNORECASE,
2125
- ).strip(" .,;:—-")
2126
- # If the sanitized message is too short or doesn't really say
2127
- # anything useful, swap in a polished default.
2128
- _low = _display_msg.lower()
2129
- # Count substantive words (>= 3 chars, alpha) to detect
2130
- # sentences that were gutted by jargon stripping.
2131
- _substantive = sum(
2132
- 1 for _w in _re.findall(r"[A-Za-z]{3,}", _display_msg)
2133
- )
2134
- # Detect a "stranded period" artifact: a period that's now
2135
- # surrounded by whitespace because its surrounding words
2136
- # were stripped. This is a strong signal that the message
2137
- # was gutted by jargon removal and is no longer readable.
2138
- _has_stranded_period = bool(_re.search(r"\s\.\s", _display_msg))
2139
- _is_uninformative = (
2140
- len(_display_msg) < 12
2141
- or _substantive < 3
2142
- or _has_stranded_period
2143
- or _low in ("returned error", "error", "rate limit", "limit")
2144
- or _low in ("", ".", "..", "...")
2145
- )
2146
- if _is_uninformative:
2147
- if _is_free_tier_model:
2148
- _display_msg = (
2149
- "All free models are momentarily busy. "
2150
- "This clears automatically within a minute or two."
2151
- )
2152
- else:
2153
- _display_msg = "Rate limit reached for this model."
2154
-
2118
+ rate_limit_display = (
2119
+ f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
2120
+ f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
2121
+ f"- **Retry:** You can resend your prompt anytime."
2122
+ )
2123
+ if not silent:
2155
2124
  self.console.print(
2156
- f"\n[bold yellow]⏳ {_display_msg}[/bold yellow]"
2125
+ f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
2157
2126
  )
2158
- if _is_free_tier_model:
2159
- # Free tier → no upgrade CTA, just helpful next steps
2160
- self.console.print(
2161
- " [dim]Free models rotate through a shared capacity pool — "
2162
- "this usually clears in under a minute.[/dim]"
2163
- )
2164
- self.console.print(
2165
- " [dim]You can also pick a different model with "
2166
- "[bold]/model[/bold] or press Ctrl+M.[/dim]\n"
2167
- )
2168
- else:
2169
- if not upgrade_url:
2170
- upgrade_url = "https://utim.dev/pricing"
2171
- self.console.print(
2172
- f" [dim]Top up at [bold]{upgrade_url}[/bold] or run "
2173
- f"[bold]/balance[/bold] to check your remaining quota.[/dim]\n"
2174
- )
2175
-
2176
- raise _FatalClientError(
2177
- f"All models in the fallback chain are rate-limited: "
2178
- f"{_rate_limit_detail}"
2127
+ self.console.print(
2128
+ " [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
2179
2129
  )
2130
+ return {
2131
+ "role": "assistant",
2132
+ "content": rate_limit_display,
2133
+ "tool_calls": None,
2134
+ }, False
2180
2135
  if attempt < model_retries:
2181
2136
  delay = 3 * (attempt + 1)
2182
2137
  # Interruptible wait — so Ctrl+C can abort immediately
@@ -1381,7 +1381,11 @@ class TurnHistoryMixin:
1381
1381
  with open("package.json", "r", encoding="utf-8") as f:
1382
1382
  pkg = json.load(f)
1383
1383
  if "scripts" in pkg and "test" in pkg["scripts"]:
1384
- res = subprocess.run(["npm", "test"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, shell=True)
1384
+ from shutil import which as _which_npm
1385
+ _npm = _which_npm("npm")
1386
+ if _npm is None:
1387
+ return "npm not found on PATH"
1388
+ res = subprocess.run([_npm, "test"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
1385
1389
  if res.returncode != 0:
1386
1390
  return _compact_test_err("npm test", res.stdout, res.stderr)
1387
1391
  return None
@@ -107,11 +107,19 @@ def run_single_hook(
107
107
 
108
108
  argv = _resolve_interpreter(command, Path.cwd())
109
109
  stdin_data = json.dumps(payload)
110
+ # Clamp per-hook timeout: a misconfigured plugin manifest must not be
111
+ # able to hold the dispatch thread pool hostage (DoS guard).
112
+ h_timeout = min(max(float(h_timeout), 0.1), 60.0)
110
113
  if argv is None:
111
- # Shell mode
112
- argv = command
114
+ # Shell mode — run through an explicit shell argv instead of
115
+ # shell=True, which is injection-prone and resolves slower. On
116
+ # Windows, /d also skips AutoRun registry scripts (hijack guard).
117
+ if os.name == "nt":
118
+ shell_argv = ["cmd.exe", "/d", "/s", "/c", command]
119
+ else:
120
+ shell_argv = ["/bin/sh", "-c", command]
113
121
  result = subprocess.run(
114
- argv, shell=True, input=stdin_data, capture_output=True,
122
+ shell_argv, shell=False, input=stdin_data, capture_output=True,
115
123
  text=True, timeout=h_timeout, env=_build_env(plugin_name, event, payload),
116
124
  )
117
125
  else: