code-puppy 0.0.214__py3-none-any.whl → 0.0.366__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 (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
code_puppy/config.py CHANGED
@@ -7,17 +7,59 @@ from typing import Optional
7
7
 
8
8
  from code_puppy.session_storage import save_session
9
9
 
10
- CONFIG_DIR = os.path.join(os.getenv("HOME", os.path.expanduser("~")), ".code_puppy")
10
+
11
+ def _get_xdg_dir(env_var: str, fallback: str) -> str:
12
+ """
13
+ Get directory for code_puppy files, defaulting to ~/.code_puppy.
14
+
15
+ XDG paths are only used when the corresponding environment variable
16
+ is explicitly set by the user. Otherwise, we use the legacy ~/.code_puppy
17
+ directory for all file types (config, data, cache, state).
18
+
19
+ Args:
20
+ env_var: XDG environment variable name (e.g., "XDG_CONFIG_HOME")
21
+ fallback: Fallback path relative to home (e.g., ".config") - unused unless XDG var is set
22
+
23
+ Returns:
24
+ Path to the directory for code_puppy files
25
+ """
26
+ # Use XDG directory ONLY if environment variable is explicitly set
27
+ xdg_base = os.getenv(env_var)
28
+ if xdg_base:
29
+ return os.path.join(xdg_base, "code_puppy")
30
+
31
+ # Default to legacy ~/.code_puppy for all file types
32
+ return os.path.join(os.path.expanduser("~"), ".code_puppy")
33
+
34
+
35
+ # XDG Base Directory paths
36
+ CONFIG_DIR = _get_xdg_dir("XDG_CONFIG_HOME", ".config")
37
+ DATA_DIR = _get_xdg_dir("XDG_DATA_HOME", ".local/share")
38
+ CACHE_DIR = _get_xdg_dir("XDG_CACHE_HOME", ".cache")
39
+ STATE_DIR = _get_xdg_dir("XDG_STATE_HOME", ".local/state")
40
+
41
+ # Configuration files (XDG_CONFIG_HOME)
11
42
  CONFIG_FILE = os.path.join(CONFIG_DIR, "puppy.cfg")
12
43
  MCP_SERVERS_FILE = os.path.join(CONFIG_DIR, "mcp_servers.json")
13
- COMMAND_HISTORY_FILE = os.path.join(CONFIG_DIR, "command_history.txt")
14
- MODELS_FILE = os.path.join(CONFIG_DIR, "models.json")
15
- EXTRA_MODELS_FILE = os.path.join(CONFIG_DIR, "extra_models.json")
16
- AGENTS_DIR = os.path.join(CONFIG_DIR, "agents")
17
- CONTEXTS_DIR = os.path.join(CONFIG_DIR, "contexts")
18
- AUTOSAVE_DIR = os.path.join(CONFIG_DIR, "autosaves")
19
- # Default saving to a SQLite DB in the config dir
20
- _DEFAULT_SQLITE_FILE = os.path.join(CONFIG_DIR, "dbos_store.sqlite")
44
+
45
+ # Data files (XDG_DATA_HOME)
46
+ MODELS_FILE = os.path.join(DATA_DIR, "models.json")
47
+ EXTRA_MODELS_FILE = os.path.join(DATA_DIR, "extra_models.json")
48
+ AGENTS_DIR = os.path.join(DATA_DIR, "agents")
49
+ CONTEXTS_DIR = os.path.join(DATA_DIR, "contexts")
50
+ _DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite")
51
+
52
+ # OAuth plugin model files (XDG_DATA_HOME)
53
+ GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
54
+ CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
55
+ CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
56
+ ANTIGRAVITY_MODELS_FILE = os.path.join(DATA_DIR, "antigravity_models.json")
57
+
58
+ # Cache files (XDG_CACHE_HOME)
59
+ AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
60
+
61
+ # State files (XDG_STATE_HOME)
62
+ COMMAND_HISTORY_FILE = os.path.join(STATE_DIR, "command_history.txt")
21
63
  DBOS_DATABASE_URL = os.environ.get(
22
64
  "DBOS_SYSTEM_DATABASE_URL", f"sqlite:///{_DEFAULT_SQLITE_FILE}"
23
65
  )
@@ -33,6 +75,48 @@ def get_use_dbos() -> bool:
33
75
  return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
34
76
 
35
77
 
78
+ def get_subagent_verbose() -> bool:
79
+ """Return True if sub-agent verbose output is enabled (default False).
80
+
81
+ When False (default), sub-agents produce quiet, sparse output suitable
82
+ for parallel execution. When True, sub-agents produce full verbose output
83
+ like the main agent (useful for debugging).
84
+ """
85
+ cfg_val = get_value("subagent_verbose")
86
+ if cfg_val is None:
87
+ return False
88
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
89
+
90
+
91
+ # Pack agents - the specialized sub-agents coordinated by Pack Leader
92
+ PACK_AGENT_NAMES = frozenset(
93
+ [
94
+ "pack-leader",
95
+ "bloodhound",
96
+ "husky",
97
+ "shepherd",
98
+ "terrier",
99
+ "watchdog",
100
+ "retriever",
101
+ ]
102
+ )
103
+
104
+
105
+ def get_pack_agents_enabled() -> bool:
106
+ """Return True if pack agents are enabled (default False).
107
+
108
+ When False (default), pack agents (pack-leader, bloodhound, husky, shepherd,
109
+ terrier, watchdog, retriever) are hidden from `list_agents` tool and `/agents`
110
+ command. They cannot be invoked by other agents or selected by users.
111
+
112
+ When True, pack agents are available for use.
113
+ """
114
+ cfg_val = get_value("enable_pack_agents")
115
+ if cfg_val is None:
116
+ return False
117
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
118
+
119
+
36
120
  DEFAULT_SECTION = "puppy"
37
121
  REQUIRED_KEYS = ["puppy_name", "owner_name"]
38
122
 
@@ -43,16 +127,17 @@ _CURRENT_AUTOSAVE_ID: Optional[str] = None
43
127
  _model_validation_cache = {}
44
128
  _default_model_cache = None
45
129
  _default_vision_model_cache = None
46
- _default_vqa_model_cache = None
47
130
 
48
131
 
49
132
  def ensure_config_exists():
50
133
  """
51
- Ensure that the .code_puppy dir and puppy.cfg exist, prompting if needed.
134
+ Ensure that XDG directories and puppy.cfg exist, prompting if needed.
52
135
  Returns configparser.ConfigParser for reading.
53
136
  """
54
- if not os.path.exists(CONFIG_DIR):
55
- os.makedirs(CONFIG_DIR, exist_ok=True)
137
+ # Create all XDG directories with 0700 permissions per XDG spec
138
+ for directory in [CONFIG_DIR, DATA_DIR, CACHE_DIR, STATE_DIR]:
139
+ if not os.path.exists(directory):
140
+ os.makedirs(directory, mode=0o700, exist_ok=True)
56
141
  exists = os.path.isfile(CONFIG_FILE)
57
142
  config = configparser.ConfigParser()
58
143
  if exists:
@@ -64,7 +149,11 @@ def ensure_config_exists():
64
149
  if not config[DEFAULT_SECTION].get(key):
65
150
  missing.append(key)
66
151
  if missing:
67
- print("🐾 Let's get your Puppy ready!")
152
+ # Note: Using sys.stdout here for initial setup before messaging system is available
153
+ import sys
154
+
155
+ sys.stdout.write("🐾 Let's get your Puppy ready!\n")
156
+ sys.stdout.flush()
68
157
  for key in missing:
69
158
  if key == "puppy_name":
70
159
  val = input("What should we name the puppy? ").strip()
@@ -75,6 +164,13 @@ def ensure_config_exists():
75
164
  else:
76
165
  val = input(f"Enter {key}: ").strip()
77
166
  config[DEFAULT_SECTION][key] = val
167
+
168
+ # Set default values for important config keys if they don't exist
169
+ if not config[DEFAULT_SECTION].get("auto_save_session"):
170
+ config[DEFAULT_SECTION]["auto_save_session"] = "true"
171
+
172
+ # Write the config if we made any changes
173
+ if missing or not exists:
78
174
  with open(CONFIG_FILE, "w") as f:
79
175
  config.write(f)
80
176
  return config
@@ -146,12 +242,26 @@ def get_config_keys():
146
242
  "message_limit",
147
243
  "allow_recursion",
148
244
  "openai_reasoning_effort",
245
+ "openai_verbosity",
149
246
  "auto_save_session",
150
247
  "max_saved_sessions",
151
248
  "http2",
249
+ "diff_context_lines",
250
+ "default_agent",
251
+ "temperature",
252
+ "frontend_emitter_enabled",
253
+ "frontend_emitter_max_recent_events",
254
+ "frontend_emitter_queue_size",
152
255
  ]
153
256
  # Add DBOS control key
154
257
  default_keys.append("enable_dbos")
258
+ # Add pack agents control key
259
+ default_keys.append("enable_pack_agents")
260
+ # Add cancel agent key configuration
261
+ default_keys.append("cancel_agent_key")
262
+ # Add banner color keys
263
+ for banner_name in DEFAULT_BANNER_COLORS:
264
+ default_keys.append(f"banner_color_{banner_name}")
155
265
 
156
266
  config = configparser.ConfigParser()
157
267
  config.read(CONFIG_FILE)
@@ -173,18 +283,33 @@ def set_config_value(key: str, value: str):
173
283
  config.write(f)
174
284
 
175
285
 
286
+ # Alias for API compatibility
287
+ def set_value(key: str, value: str) -> None:
288
+ """Set a config value. Alias for set_config_value."""
289
+ set_config_value(key, value)
290
+
291
+
292
+ def reset_value(key: str) -> None:
293
+ """Remove a key from the config file, resetting it to default."""
294
+ config = configparser.ConfigParser()
295
+ config.read(CONFIG_FILE)
296
+ if DEFAULT_SECTION in config and key in config[DEFAULT_SECTION]:
297
+ del config[DEFAULT_SECTION][key]
298
+ with open(CONFIG_FILE, "w") as f:
299
+ config.write(f)
300
+
301
+
176
302
  # --- MODEL STICKY EXTENSION STARTS HERE ---
177
303
  def load_mcp_server_configs():
178
304
  """
179
- Loads the MCP server configurations from ~/.code_puppy/mcp_servers.json.
305
+ Loads the MCP server configurations from XDG_CONFIG_HOME/code_puppy/mcp_servers.json.
180
306
  Returns a dict mapping names to their URL or config dict.
181
307
  If file does not exist, returns an empty dict.
182
308
  """
183
- from code_puppy.messaging.message_queue import emit_error, emit_system_message
309
+ from code_puppy.messaging.message_queue import emit_error
184
310
 
185
311
  try:
186
312
  if not pathlib.Path(MCP_SERVERS_FILE).exists():
187
- emit_system_message("[dim]No MCP configuration was found[/dim]")
188
313
  return {}
189
314
  with open(MCP_SERVERS_FILE, "r") as f:
190
315
  conf = json.loads(f.read())
@@ -195,10 +320,10 @@ def load_mcp_server_configs():
195
320
 
196
321
 
197
322
  def _default_model_from_models_json():
198
- """Attempt to load the first model name from models.json.
323
+ """Load the default model name from models.json.
199
324
 
200
- Falls back to the hard-coded default (``gpt-5``) if the file
201
- cannot be read for any reason or is empty.
325
+ Returns the first model in models.json as the default.
326
+ Falls back to ``gpt-5`` if the file cannot be read.
202
327
  """
203
328
  global _default_model_cache
204
329
 
@@ -210,6 +335,7 @@ def _default_model_from_models_json():
210
335
 
211
336
  models_config = ModelFactory.load_config()
212
337
  if models_config:
338
+ # Use first model in models.json as default
213
339
  first_key = next(iter(models_config))
214
340
  _default_model_cache = first_key
215
341
  return first_key
@@ -262,47 +388,6 @@ def _default_vision_model_from_models_json() -> str:
262
388
  return "gpt-4.1"
263
389
 
264
390
 
265
- def _default_vqa_model_from_models_json() -> str:
266
- """Select a default VQA-capable model, preferring vision-ready options."""
267
- global _default_vqa_model_cache
268
-
269
- if _default_vqa_model_cache is not None:
270
- return _default_vqa_model_cache
271
-
272
- try:
273
- from code_puppy.model_factory import ModelFactory
274
-
275
- models_config = ModelFactory.load_config()
276
- if models_config:
277
- # Allow explicit VQA hints if present
278
- for name, config in models_config.items():
279
- if config.get("supports_vqa"):
280
- _default_vqa_model_cache = name
281
- return name
282
-
283
- # Reuse multimodal heuristics before falling back to generic default
284
- preferred_candidates = (
285
- "gpt-4.1",
286
- "gpt-4.1-mini",
287
- "claude-4-0-sonnet",
288
- "gemini-2.5-flash-preview-05-20",
289
- "gpt-4.1-nano",
290
- )
291
- for candidate in preferred_candidates:
292
- if candidate in models_config:
293
- _default_vqa_model_cache = candidate
294
- return candidate
295
-
296
- _default_vqa_model_cache = _default_model_from_models_json()
297
- return _default_vqa_model_cache
298
-
299
- _default_vqa_model_cache = "gpt-4.1"
300
- return "gpt-4.1"
301
- except Exception:
302
- _default_vqa_model_cache = "gpt-4.1"
303
- return "gpt-4.1"
304
-
305
-
306
391
  def _validate_model_exists(model_name: str) -> bool:
307
392
  """Check if a model exists in models.json with caching to avoid redundant calls."""
308
393
  global _model_validation_cache
@@ -328,15 +413,47 @@ def _validate_model_exists(model_name: str) -> bool:
328
413
 
329
414
  def clear_model_cache():
330
415
  """Clear the model validation cache. Call this when models.json changes."""
331
- global \
332
- _model_validation_cache, \
333
- _default_model_cache, \
334
- _default_vision_model_cache, \
335
- _default_vqa_model_cache
416
+ global _model_validation_cache, _default_model_cache, _default_vision_model_cache
336
417
  _model_validation_cache.clear()
337
418
  _default_model_cache = None
338
419
  _default_vision_model_cache = None
339
- _default_vqa_model_cache = None
420
+
421
+
422
+ def model_supports_setting(model_name: str, setting: str) -> bool:
423
+ """Check if a model supports a particular setting (e.g., 'temperature', 'seed').
424
+
425
+ Args:
426
+ model_name: The name of the model to check.
427
+ setting: The setting name to check for (e.g., 'temperature', 'seed', 'top_p').
428
+
429
+ Returns:
430
+ True if the model supports the setting, False otherwise.
431
+ Defaults to True for backwards compatibility if model config doesn't specify.
432
+ """
433
+ # GLM-4.7 models always support clear_thinking setting
434
+ if setting == "clear_thinking" and "glm-4.7" in model_name.lower():
435
+ return True
436
+
437
+ try:
438
+ from code_puppy.model_factory import ModelFactory
439
+
440
+ models_config = ModelFactory.load_config()
441
+ model_config = models_config.get(model_name, {})
442
+
443
+ # Get supported_settings list, default to supporting common settings
444
+ supported_settings = model_config.get("supported_settings")
445
+
446
+ if supported_settings is None:
447
+ # Default: assume common settings are supported for backwards compatibility
448
+ # For Anthropic/Claude models, include extended thinking settings
449
+ if model_name.startswith("claude-") or model_name.startswith("anthropic-"):
450
+ return setting in ["temperature", "extended_thinking", "budget_tokens"]
451
+ return setting in ["temperature", "seed"]
452
+
453
+ return setting in supported_settings
454
+ except Exception:
455
+ # If we can't check, assume supported for safety
456
+ return True
340
457
 
341
458
 
342
459
  def get_global_model_name():
@@ -374,20 +491,6 @@ def set_model_name(model: str):
374
491
  clear_model_cache()
375
492
 
376
493
 
377
- def get_vqa_model_name() -> str:
378
- """Return the configured VQA model, falling back to an inferred default."""
379
- stored_model = get_value("vqa_model_name")
380
- if stored_model and _validate_model_exists(stored_model):
381
- return stored_model
382
- return _default_vqa_model_from_models_json()
383
-
384
-
385
- def set_vqa_model_name(model: str):
386
- """Persist the configured VQA model name and refresh caches."""
387
- set_config_value("vqa_model_name", model or "")
388
- clear_model_cache()
389
-
390
-
391
494
  def get_puppy_token():
392
495
  """Returns the puppy_token from config, or None if not set."""
393
496
  return get_value("puppy_token")
@@ -399,8 +502,8 @@ def set_puppy_token(token: str):
399
502
 
400
503
 
401
504
  def get_openai_reasoning_effort() -> str:
402
- """Return the configured OpenAI reasoning effort (low, medium, high)."""
403
- allowed_values = {"low", "medium", "high"}
505
+ """Return the configured OpenAI reasoning effort (minimal, low, medium, high, xhigh)."""
506
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
404
507
  configured = (get_value("openai_reasoning_effort") or "medium").strip().lower()
405
508
  if configured not in allowed_values:
406
509
  return "medium"
@@ -409,7 +512,7 @@ def get_openai_reasoning_effort() -> str:
409
512
 
410
513
  def set_openai_reasoning_effort(value: str) -> None:
411
514
  """Persist the OpenAI reasoning effort ensuring it remains within allowed values."""
412
- allowed_values = {"low", "medium", "high"}
515
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
413
516
  normalized = (value or "").strip().lower()
414
517
  if normalized not in allowed_values:
415
518
  raise ValueError(
@@ -418,6 +521,276 @@ def set_openai_reasoning_effort(value: str) -> None:
418
521
  set_config_value("openai_reasoning_effort", normalized)
419
522
 
420
523
 
524
+ def get_openai_verbosity() -> str:
525
+ """Return the configured OpenAI verbosity (low, medium, high).
526
+
527
+ Controls how concise vs. verbose the model's responses are:
528
+ - low: more concise responses
529
+ - medium: balanced (default)
530
+ - high: more verbose responses
531
+ """
532
+ allowed_values = {"low", "medium", "high"}
533
+ configured = (get_value("openai_verbosity") or "medium").strip().lower()
534
+ if configured not in allowed_values:
535
+ return "medium"
536
+ return configured
537
+
538
+
539
+ def set_openai_verbosity(value: str) -> None:
540
+ """Persist the OpenAI verbosity ensuring it remains within allowed values."""
541
+ allowed_values = {"low", "medium", "high"}
542
+ normalized = (value or "").strip().lower()
543
+ if normalized not in allowed_values:
544
+ raise ValueError(
545
+ f"Invalid verbosity '{value}'. Allowed: {', '.join(sorted(allowed_values))}"
546
+ )
547
+ set_config_value("openai_verbosity", normalized)
548
+
549
+
550
+ def get_temperature() -> Optional[float]:
551
+ """Return the configured model temperature (0.0 to 2.0).
552
+
553
+ Returns:
554
+ Float between 0.0 and 2.0 if set, None if not configured.
555
+ This allows each model to use its own default when not overridden.
556
+ """
557
+ val = get_value("temperature")
558
+ if val is None or val.strip() == "":
559
+ return None
560
+ try:
561
+ temp = float(val)
562
+ # Clamp to valid range (most APIs accept 0-2)
563
+ return max(0.0, min(2.0, temp))
564
+ except (ValueError, TypeError):
565
+ return None
566
+
567
+
568
+ def set_temperature(value: Optional[float]) -> None:
569
+ """Set the global model temperature in config.
570
+
571
+ Args:
572
+ value: Temperature between 0.0 and 2.0, or None to clear.
573
+ Lower values = more deterministic, higher = more creative.
574
+
575
+ Note: Consider using set_model_setting() for per-model temperature.
576
+ """
577
+ if value is None:
578
+ set_config_value("temperature", "")
579
+ else:
580
+ # Validate and clamp
581
+ temp = max(0.0, min(2.0, float(value)))
582
+ set_config_value("temperature", str(temp))
583
+
584
+
585
+ # --- PER-MODEL SETTINGS ---
586
+
587
+
588
+ def _sanitize_model_name_for_key(model_name: str) -> str:
589
+ """Sanitize model name for use in config keys.
590
+
591
+ Replaces characters that might cause issues in config keys.
592
+ """
593
+ # Replace problematic characters with underscores
594
+ sanitized = model_name.replace(".", "_").replace("-", "_").replace("/", "_")
595
+ return sanitized.lower()
596
+
597
+
598
+ def get_model_setting(
599
+ model_name: str, setting: str, default: Optional[float] = None
600
+ ) -> Optional[float]:
601
+ """Get a specific setting for a model.
602
+
603
+ Args:
604
+ model_name: The model name (e.g., 'gpt-5', 'claude-4-5-sonnet')
605
+ setting: The setting name (e.g., 'temperature', 'top_p', 'seed')
606
+ default: Default value if not set
607
+
608
+ Returns:
609
+ The setting value as a float, or default if not set.
610
+ """
611
+ sanitized_name = _sanitize_model_name_for_key(model_name)
612
+ key = f"model_settings_{sanitized_name}_{setting}"
613
+ val = get_value(key)
614
+
615
+ if val is None or val.strip() == "":
616
+ return default
617
+
618
+ try:
619
+ return float(val)
620
+ except (ValueError, TypeError):
621
+ return default
622
+
623
+
624
+ def set_model_setting(model_name: str, setting: str, value: Optional[float]) -> None:
625
+ """Set a specific setting for a model.
626
+
627
+ Args:
628
+ model_name: The model name (e.g., 'gpt-5', 'claude-4-5-sonnet')
629
+ setting: The setting name (e.g., 'temperature', 'seed')
630
+ value: The value to set, or None to clear
631
+ """
632
+ sanitized_name = _sanitize_model_name_for_key(model_name)
633
+ key = f"model_settings_{sanitized_name}_{setting}"
634
+
635
+ if value is None:
636
+ set_config_value(key, "")
637
+ elif isinstance(value, float):
638
+ # Round floats to nearest tenth to avoid floating point weirdness
639
+ set_config_value(key, str(round(value, 1)))
640
+ else:
641
+ set_config_value(key, str(value))
642
+
643
+
644
+ def get_all_model_settings(model_name: str) -> dict:
645
+ """Get all settings for a specific model.
646
+
647
+ Args:
648
+ model_name: The model name
649
+
650
+ Returns:
651
+ Dictionary of setting_name -> value for all configured settings.
652
+ """
653
+ import configparser
654
+
655
+ sanitized_name = _sanitize_model_name_for_key(model_name)
656
+ prefix = f"model_settings_{sanitized_name}_"
657
+
658
+ config = configparser.ConfigParser()
659
+ config.read(CONFIG_FILE)
660
+
661
+ settings = {}
662
+ if DEFAULT_SECTION in config:
663
+ for key, val in config[DEFAULT_SECTION].items():
664
+ if key.startswith(prefix) and val.strip():
665
+ setting_name = key[len(prefix) :]
666
+ # Handle different value types
667
+ val_stripped = val.strip()
668
+ # Check for boolean values first
669
+ if val_stripped.lower() in ("true", "false"):
670
+ settings[setting_name] = val_stripped.lower() == "true"
671
+ else:
672
+ # Try to parse as number (int first, then float)
673
+ try:
674
+ # Try int first for cleaner values like budget_tokens
675
+ if "." not in val_stripped:
676
+ settings[setting_name] = int(val_stripped)
677
+ else:
678
+ settings[setting_name] = float(val_stripped)
679
+ except (ValueError, TypeError):
680
+ # Keep as string if not a number
681
+ settings[setting_name] = val_stripped
682
+
683
+ return settings
684
+
685
+
686
+ def clear_model_settings(model_name: str) -> None:
687
+ """Clear all settings for a specific model.
688
+
689
+ Args:
690
+ model_name: The model name
691
+ """
692
+ import configparser
693
+
694
+ sanitized_name = _sanitize_model_name_for_key(model_name)
695
+ prefix = f"model_settings_{sanitized_name}_"
696
+
697
+ config = configparser.ConfigParser()
698
+ config.read(CONFIG_FILE)
699
+
700
+ if DEFAULT_SECTION in config:
701
+ keys_to_remove = [
702
+ key for key in config[DEFAULT_SECTION] if key.startswith(prefix)
703
+ ]
704
+ for key in keys_to_remove:
705
+ del config[DEFAULT_SECTION][key]
706
+
707
+ with open(CONFIG_FILE, "w") as f:
708
+ config.write(f)
709
+
710
+
711
+ def get_effective_model_settings(model_name: Optional[str] = None) -> dict:
712
+ """Get all effective settings for a model, filtered by what the model supports.
713
+
714
+ This is the generalized way to get model settings. It:
715
+ 1. Gets all per-model settings from config
716
+ 2. Falls back to global temperature if not set per-model
717
+ 3. Filters to only include settings the model actually supports
718
+ 4. Converts seed to int (other settings stay as float)
719
+
720
+ Args:
721
+ model_name: The model name. If None, uses the current global model.
722
+
723
+ Returns:
724
+ Dictionary of setting_name -> value for all applicable settings.
725
+ Ready to be unpacked into ModelSettings.
726
+ """
727
+ if model_name is None:
728
+ model_name = get_global_model_name()
729
+
730
+ # Start with all per-model settings
731
+ settings = get_all_model_settings(model_name)
732
+
733
+ # Fall back to global temperature if not set per-model
734
+ if "temperature" not in settings:
735
+ global_temp = get_temperature()
736
+ if global_temp is not None:
737
+ settings["temperature"] = global_temp
738
+
739
+ # Filter to only settings the model supports
740
+ effective_settings = {}
741
+ for setting_name, value in settings.items():
742
+ if model_supports_setting(model_name, setting_name):
743
+ # Convert seed to int, keep others as float
744
+ if setting_name == "seed" and value is not None:
745
+ effective_settings[setting_name] = int(value)
746
+ else:
747
+ effective_settings[setting_name] = value
748
+
749
+ return effective_settings
750
+
751
+
752
+ # Legacy functions for backward compatibility
753
+ def get_effective_temperature(model_name: Optional[str] = None) -> Optional[float]:
754
+ """Get the effective temperature for a model.
755
+
756
+ Checks per-model settings first, then falls back to global temperature.
757
+
758
+ Args:
759
+ model_name: The model name. If None, uses the current global model.
760
+
761
+ Returns:
762
+ Temperature value, or None if not configured.
763
+ """
764
+ settings = get_effective_model_settings(model_name)
765
+ return settings.get("temperature")
766
+
767
+
768
+ def get_effective_top_p(model_name: Optional[str] = None) -> Optional[float]:
769
+ """Get the effective top_p for a model.
770
+
771
+ Args:
772
+ model_name: The model name. If None, uses the current global model.
773
+
774
+ Returns:
775
+ top_p value, or None if not configured.
776
+ """
777
+ settings = get_effective_model_settings(model_name)
778
+ return settings.get("top_p")
779
+
780
+
781
+ def get_effective_seed(model_name: Optional[str] = None) -> Optional[int]:
782
+ """Get the effective seed for a model.
783
+
784
+ Args:
785
+ model_name: The model name. If None, uses the current global model.
786
+
787
+ Returns:
788
+ seed value as int, or None if not configured.
789
+ """
790
+ settings = get_effective_model_settings(model_name)
791
+ return settings.get("seed")
792
+
793
+
421
794
  def normalize_command_history():
422
795
  """
423
796
  Normalize the command history file by converting old format timestamps to the new format.
@@ -443,10 +816,20 @@ def normalize_command_history():
443
816
  return
444
817
 
445
818
  try:
446
- # Read the entire file
447
- with open(COMMAND_HISTORY_FILE, "r") as f:
819
+ # Read the entire file with encoding error handling for Windows
820
+ with open(
821
+ COMMAND_HISTORY_FILE, "r", encoding="utf-8", errors="surrogateescape"
822
+ ) as f:
448
823
  content = f.read()
449
824
 
825
+ # Sanitize any surrogate characters that might have slipped in
826
+ try:
827
+ content = content.encode("utf-8", errors="surrogatepass").decode(
828
+ "utf-8", errors="replace"
829
+ )
830
+ except (UnicodeEncodeError, UnicodeDecodeError):
831
+ pass # Keep original if sanitization fails
832
+
450
833
  # Skip empty files
451
834
  if not content.strip():
452
835
  return
@@ -467,14 +850,16 @@ def normalize_command_history():
467
850
 
468
851
  # Write the updated content back to the file only if changes were made
469
852
  if content != updated_content:
470
- with open(COMMAND_HISTORY_FILE, "w") as f:
853
+ with open(
854
+ COMMAND_HISTORY_FILE, "w", encoding="utf-8", errors="surrogateescape"
855
+ ) as f:
471
856
  f.write(updated_content)
472
857
  except Exception as e:
473
- from rich.console import Console
858
+ from code_puppy.messaging import emit_error
474
859
 
475
- direct_console = Console()
476
- error_msg = f"An unexpected error occurred while normalizing command history: {str(e)}"
477
- direct_console.print(f"[bold red]{error_msg}[/bold red]")
860
+ emit_error(
861
+ f"An unexpected error occurred while normalizing command history: {str(e)}"
862
+ )
478
863
 
479
864
 
480
865
  def get_user_agents_directory() -> str:
@@ -496,6 +881,10 @@ def initialize_command_history_file():
496
881
  import os
497
882
  from pathlib import Path
498
883
 
884
+ # Ensure the state directory exists before trying to create the history file
885
+ if not os.path.exists(STATE_DIR):
886
+ os.makedirs(STATE_DIR, exist_ok=True)
887
+
499
888
  command_history_exists = os.path.isfile(COMMAND_HISTORY_FILE)
500
889
  if not command_history_exists:
501
890
  try:
@@ -515,11 +904,11 @@ def initialize_command_history_file():
515
904
  # Normalize the command history format if needed
516
905
  normalize_command_history()
517
906
  except Exception as e:
518
- from rich.console import Console
907
+ from code_puppy.messaging import emit_error
519
908
 
520
- direct_console = Console()
521
- error_msg = f"An unexpected error occurred while trying to initialize history file: {str(e)}"
522
- direct_console.print(f"[bold red]{error_msg}[/bold red]")
909
+ emit_error(
910
+ f"An unexpected error occurred while trying to initialize history file: {str(e)}"
911
+ )
523
912
 
524
913
 
525
914
  def get_yolo_mode():
@@ -537,6 +926,22 @@ def get_yolo_mode():
537
926
  return True
538
927
 
539
928
 
929
+ def get_safety_permission_level():
930
+ """
931
+ Checks puppy.cfg for 'safety_permission_level' (case-insensitive in value only).
932
+ Defaults to 'medium' if not set.
933
+ Allowed values: 'none', 'low', 'medium', 'high', 'critical' (all case-insensitive for value).
934
+ Returns the normalized lowercase string.
935
+ """
936
+ valid_levels = {"none", "low", "medium", "high", "critical"}
937
+ cfg_val = get_value("safety_permission_level")
938
+ if cfg_val is not None:
939
+ normalized = str(cfg_val).strip().lower()
940
+ if normalized in valid_levels:
941
+ return normalized
942
+ return "medium" # Default to medium risk threshold
943
+
944
+
540
945
  def get_mcp_disabled():
541
946
  """
542
947
  Checks puppy.cfg for 'disable_mcp' (case-insensitive in value only).
@@ -553,6 +958,24 @@ def get_mcp_disabled():
553
958
  return False
554
959
 
555
960
 
961
+ def get_grep_output_verbose():
962
+ """
963
+ Checks puppy.cfg for 'grep_output_verbose' (case-insensitive in value only).
964
+ Defaults to False (concise output) if not set.
965
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
966
+
967
+ When False (default): Shows only file names with match counts
968
+ When True: Shows full output with line numbers and content
969
+ """
970
+ true_vals = {"1", "true", "yes", "on"}
971
+ cfg_val = get_value("grep_output_verbose")
972
+ if cfg_val is not None:
973
+ if str(cfg_val).strip().lower() in true_vals:
974
+ return True
975
+ return False
976
+ return False
977
+
978
+
556
979
  def get_protected_token_count():
557
980
  """
558
981
  Returns the user-configured protected token count for message history compaction.
@@ -590,7 +1013,7 @@ def get_compaction_threshold():
590
1013
  try:
591
1014
  threshold = float(val) if val else 0.85
592
1015
  # Clamp between reasonable bounds
593
- return max(0.8, min(0.95, threshold))
1016
+ return max(0.5, min(0.95, threshold))
594
1017
  except (ValueError, TypeError):
595
1018
  return 0.85
596
1019
 
@@ -635,11 +1058,11 @@ def set_enable_dbos(enabled: bool) -> None:
635
1058
  set_config_value("enable_dbos", "true" if enabled else "false")
636
1059
 
637
1060
 
638
- def get_message_limit(default: int = 100) -> int:
1061
+ def get_message_limit(default: int = 1000) -> int:
639
1062
  """
640
1063
  Returns the user-configured message/request limit for the agent.
641
1064
  This controls how many steps/requests the agent can take.
642
- Defaults to 100 if unset or misconfigured.
1065
+ Defaults to 1000 if unset or misconfigured.
643
1066
  Configurable by 'message_limit' key.
644
1067
  """
645
1068
  val = get_value("message_limit")
@@ -659,16 +1082,30 @@ def save_command_to_history(command: str):
659
1082
 
660
1083
  try:
661
1084
  timestamp = datetime.datetime.now().isoformat(timespec="seconds")
662
- with open(COMMAND_HISTORY_FILE, "a") as f:
1085
+
1086
+ # Sanitize command to remove any invalid surrogate characters
1087
+ # that could cause encoding errors on Windows
1088
+ try:
1089
+ command = command.encode("utf-8", errors="surrogatepass").decode(
1090
+ "utf-8", errors="replace"
1091
+ )
1092
+ except (UnicodeEncodeError, UnicodeDecodeError):
1093
+ # If that fails, do a more aggressive cleanup
1094
+ command = "".join(
1095
+ char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
1096
+ for char in command
1097
+ )
1098
+
1099
+ with open(
1100
+ COMMAND_HISTORY_FILE, "a", encoding="utf-8", errors="surrogateescape"
1101
+ ) as f:
663
1102
  f.write(f"\n# {timestamp}\n{command}\n")
664
1103
  except Exception as e:
665
- from rich.console import Console
1104
+ from code_puppy.messaging import emit_error
666
1105
 
667
- direct_console = Console()
668
- error_msg = (
669
- f"❌ An unexpected error occurred while saving command history: {str(e)}"
1106
+ emit_error(
1107
+ f"An unexpected error occurred while saving command history: {str(e)}"
670
1108
  )
671
- direct_console.print(f"[bold red]{error_msg}[/bold red]")
672
1109
 
673
1110
 
674
1111
  def get_agent_pinned_model(agent_name: str) -> str:
@@ -704,6 +1141,38 @@ def clear_agent_pinned_model(agent_name: str):
704
1141
  set_config_value(f"agent_model_{agent_name}", "")
705
1142
 
706
1143
 
1144
+ def get_all_agent_pinned_models() -> dict:
1145
+ """Get all agent-to-model pinnings from config.
1146
+
1147
+ Returns:
1148
+ Dict mapping agent names to their pinned model names.
1149
+ Only includes agents that have a pinned model (non-empty value).
1150
+ """
1151
+ config = configparser.ConfigParser()
1152
+ config.read(CONFIG_FILE)
1153
+
1154
+ pinnings = {}
1155
+ if DEFAULT_SECTION in config:
1156
+ for key, value in config[DEFAULT_SECTION].items():
1157
+ if key.startswith("agent_model_") and value:
1158
+ agent_name = key[len("agent_model_") :]
1159
+ pinnings[agent_name] = value
1160
+ return pinnings
1161
+
1162
+
1163
+ def get_agents_pinned_to_model(model_name: str) -> list:
1164
+ """Get all agents that are pinned to a specific model.
1165
+
1166
+ Args:
1167
+ model_name: The model name to look up.
1168
+
1169
+ Returns:
1170
+ List of agent names pinned to this model.
1171
+ """
1172
+ all_pinnings = get_all_agent_pinned_models()
1173
+ return [agent for agent, model in all_pinnings.items() if model == model_name]
1174
+
1175
+
707
1176
  def get_auto_save_session() -> bool:
708
1177
  """
709
1178
  Checks puppy.cfg for 'auto_save_session' (case-insensitive in value only).
@@ -752,6 +1221,139 @@ def set_max_saved_sessions(max_sessions: int):
752
1221
  set_config_value("max_saved_sessions", str(max_sessions))
753
1222
 
754
1223
 
1224
+ def set_diff_highlight_style(style: str):
1225
+ """Set the diff highlight style.
1226
+
1227
+ Note: Text mode has been removed. This function is kept for backwards compatibility
1228
+ but does nothing. All diffs use beautiful syntax highlighting now!
1229
+
1230
+ Args:
1231
+ style: Ignored (always uses 'highlight' mode)
1232
+ """
1233
+ # Do nothing - we always use highlight mode now!
1234
+ pass
1235
+
1236
+
1237
+ def get_diff_addition_color() -> str:
1238
+ """
1239
+ Get the base color for diff additions.
1240
+ Default: darker green
1241
+ """
1242
+ val = get_value("highlight_addition_color")
1243
+ if val:
1244
+ return val
1245
+ return "#0b1f0b" # Default to darker green
1246
+
1247
+
1248
+ def set_diff_addition_color(color: str):
1249
+ """Set the color for diff additions.
1250
+
1251
+ Args:
1252
+ color: Rich color markup (e.g., 'green', 'on_green', 'bright_green')
1253
+ """
1254
+ set_config_value("highlight_addition_color", color)
1255
+
1256
+
1257
+ def get_diff_deletion_color() -> str:
1258
+ """
1259
+ Get the base color for diff deletions.
1260
+ Default: wine
1261
+ """
1262
+ val = get_value("highlight_deletion_color")
1263
+ if val:
1264
+ return val
1265
+ return "#390e1a" # Default to wine
1266
+
1267
+
1268
+ def set_diff_deletion_color(color: str):
1269
+ """Set the color for diff deletions.
1270
+
1271
+ Args:
1272
+ color: Rich color markup (e.g., 'orange1', 'on_bright_yellow', 'red')
1273
+ """
1274
+ set_config_value("highlight_deletion_color", color)
1275
+
1276
+
1277
+ # =============================================================================
1278
+ # Banner Color Configuration
1279
+ # =============================================================================
1280
+
1281
+ # Default banner colors (Rich color names)
1282
+ # A beautiful jewel-tone palette with semantic meaning:
1283
+ # - Blues/Teals: Reading & navigation (calm, informational)
1284
+ # - Warm tones: Actions & changes (edits, shell commands)
1285
+ # - Purples: AI thinking & reasoning (the "brain" colors)
1286
+ # - Greens: Completions & success
1287
+ # - Neutrals: Search & listings
1288
+ DEFAULT_BANNER_COLORS = {
1289
+ "thinking": "deep_sky_blue4", # Sapphire - contemplation
1290
+ "agent_response": "medium_purple4", # Amethyst - main AI output
1291
+ "shell_command": "dark_orange3", # Amber - system commands
1292
+ "read_file": "steel_blue", # Steel - reading files
1293
+ "edit_file": "dark_goldenrod", # Gold - modifications
1294
+ "grep": "grey37", # Silver - search results
1295
+ "directory_listing": "dodger_blue2", # Sky - navigation
1296
+ "agent_reasoning": "dark_violet", # Violet - deep thought
1297
+ "invoke_agent": "deep_pink4", # Ruby - agent invocation
1298
+ "subagent_response": "sea_green3", # Emerald - sub-agent success
1299
+ "list_agents": "dark_slate_gray3", # Slate - neutral listing
1300
+ # Browser/Terminal tools - same color as edit_file (gold)
1301
+ "terminal_tool": "dark_goldenrod", # Gold - browser terminal operations
1302
+ }
1303
+
1304
+
1305
+ def get_banner_color(banner_name: str) -> str:
1306
+ """Get the background color for a specific banner.
1307
+
1308
+ Args:
1309
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1310
+
1311
+ Returns:
1312
+ Rich color name or hex code for the banner background
1313
+ """
1314
+ config_key = f"banner_color_{banner_name}"
1315
+ val = get_value(config_key)
1316
+ if val:
1317
+ return val
1318
+ return DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1319
+
1320
+
1321
+ def set_banner_color(banner_name: str, color: str):
1322
+ """Set the background color for a specific banner.
1323
+
1324
+ Args:
1325
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1326
+ color: Rich color name or hex code
1327
+ """
1328
+ config_key = f"banner_color_{banner_name}"
1329
+ set_config_value(config_key, color)
1330
+
1331
+
1332
+ def get_all_banner_colors() -> dict:
1333
+ """Get all banner colors (configured or default).
1334
+
1335
+ Returns:
1336
+ Dict mapping banner names to their colors
1337
+ """
1338
+ return {name: get_banner_color(name) for name in DEFAULT_BANNER_COLORS}
1339
+
1340
+
1341
+ def reset_banner_color(banner_name: str):
1342
+ """Reset a banner color to its default.
1343
+
1344
+ Args:
1345
+ banner_name: The banner identifier to reset
1346
+ """
1347
+ default_color = DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1348
+ set_banner_color(banner_name, default_color)
1349
+
1350
+
1351
+ def reset_all_banner_colors():
1352
+ """Reset all banner colors to their defaults."""
1353
+ for name, color in DEFAULT_BANNER_COLORS.items():
1354
+ set_banner_color(name, color)
1355
+
1356
+
755
1357
  def get_current_autosave_id() -> str:
756
1358
  """Get or create the current autosave session ID for this process."""
757
1359
  global _CURRENT_AUTOSAVE_ID
@@ -796,11 +1398,8 @@ def auto_save_session_if_enabled() -> bool:
796
1398
  try:
797
1399
  import pathlib
798
1400
 
799
- from rich.console import Console
800
-
801
1401
  from code_puppy.agents.agent_manager import get_current_agent
802
-
803
- console = Console()
1402
+ from code_puppy.messaging import emit_info
804
1403
 
805
1404
  current_agent = get_current_agent()
806
1405
  history = current_agent.get_message_history()
@@ -820,20 +1419,207 @@ def auto_save_session_if_enabled() -> bool:
820
1419
  auto_saved=True,
821
1420
  )
822
1421
 
823
- console.print(
824
- f"🐾 [dim]Auto-saved session: {metadata.message_count} messages ({metadata.total_tokens} tokens)[/dim]"
1422
+ emit_info(
1423
+ f"🐾 Auto-saved session: {metadata.message_count} messages ({metadata.total_tokens} tokens)"
825
1424
  )
826
1425
 
827
1426
  return True
828
1427
 
829
1428
  except Exception as exc: # pragma: no cover - defensive logging
830
- from rich.console import Console
1429
+ from code_puppy.messaging import emit_error
831
1430
 
832
- Console().print(f"[dim]❌ Failed to auto-save session: {exc}[/dim]")
1431
+ emit_error(f"Failed to auto-save session: {exc}")
833
1432
  return False
834
1433
 
835
1434
 
1435
+ def get_diff_context_lines() -> int:
1436
+ """
1437
+ Returns the user-configured number of context lines for diff display.
1438
+ This controls how many lines of surrounding context are shown in diffs.
1439
+ Defaults to 6 if unset or misconfigured.
1440
+ Configurable by 'diff_context_lines' key.
1441
+ """
1442
+ val = get_value("diff_context_lines")
1443
+ try:
1444
+ context_lines = int(val) if val else 6
1445
+ # Apply reasonable bounds: minimum 0, maximum 50
1446
+ return max(0, min(context_lines, 50))
1447
+ except (ValueError, TypeError):
1448
+ return 6
1449
+
1450
+
836
1451
  def finalize_autosave_session() -> str:
837
1452
  """Persist the current autosave snapshot and rotate to a fresh session."""
838
1453
  auto_save_session_if_enabled()
839
1454
  return rotate_autosave_id()
1455
+
1456
+
1457
+ def get_suppress_thinking_messages() -> bool:
1458
+ """
1459
+ Checks puppy.cfg for 'suppress_thinking_messages' (case-insensitive in value only).
1460
+ Defaults to False if not set.
1461
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1462
+ When enabled, thinking messages (agent_reasoning, planned_next_steps) will be hidden.
1463
+ """
1464
+ true_vals = {"1", "true", "yes", "on"}
1465
+ cfg_val = get_value("suppress_thinking_messages")
1466
+ if cfg_val is not None:
1467
+ if str(cfg_val).strip().lower() in true_vals:
1468
+ return True
1469
+ return False
1470
+ return False
1471
+
1472
+
1473
+ def set_suppress_thinking_messages(enabled: bool):
1474
+ """Sets the suppress_thinking_messages configuration value.
1475
+
1476
+ Args:
1477
+ enabled: Whether to suppress thinking messages
1478
+ """
1479
+ set_config_value("suppress_thinking_messages", "true" if enabled else "false")
1480
+
1481
+
1482
+ def get_suppress_informational_messages() -> bool:
1483
+ """
1484
+ Checks puppy.cfg for 'suppress_informational_messages' (case-insensitive in value only).
1485
+ Defaults to False if not set.
1486
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1487
+ When enabled, informational messages (info, success, warning) will be hidden.
1488
+ """
1489
+ true_vals = {"1", "true", "yes", "on"}
1490
+ cfg_val = get_value("suppress_informational_messages")
1491
+ if cfg_val is not None:
1492
+ if str(cfg_val).strip().lower() in true_vals:
1493
+ return True
1494
+ return False
1495
+ return False
1496
+
1497
+
1498
+ def set_suppress_informational_messages(enabled: bool):
1499
+ """Sets the suppress_informational_messages configuration value.
1500
+
1501
+ Args:
1502
+ enabled: Whether to suppress informational messages
1503
+ """
1504
+ set_config_value("suppress_informational_messages", "true" if enabled else "false")
1505
+
1506
+
1507
+ # API Key management functions
1508
+ def get_api_key(key_name: str) -> str:
1509
+ """Get an API key from puppy.cfg.
1510
+
1511
+ Args:
1512
+ key_name: The name of the API key (e.g., 'OPENAI_API_KEY')
1513
+
1514
+ Returns:
1515
+ The API key value, or empty string if not set
1516
+ """
1517
+ return get_value(key_name) or ""
1518
+
1519
+
1520
+ def set_api_key(key_name: str, value: str):
1521
+ """Set an API key in puppy.cfg.
1522
+
1523
+ Args:
1524
+ key_name: The name of the API key (e.g., 'OPENAI_API_KEY')
1525
+ value: The API key value (empty string to remove)
1526
+ """
1527
+ set_config_value(key_name, value)
1528
+
1529
+
1530
+ def load_api_keys_to_environment():
1531
+ """Load all API keys from .env and puppy.cfg into environment variables.
1532
+
1533
+ Priority order:
1534
+ 1. .env file (highest priority) - if present in current directory
1535
+ 2. puppy.cfg - fallback if not in .env
1536
+ 3. Existing environment variables - preserved if already set
1537
+
1538
+ This should be called on startup to ensure API keys are available.
1539
+ """
1540
+ from pathlib import Path
1541
+
1542
+ api_key_names = [
1543
+ "OPENAI_API_KEY",
1544
+ "GEMINI_API_KEY",
1545
+ "ANTHROPIC_API_KEY",
1546
+ "CEREBRAS_API_KEY",
1547
+ "SYN_API_KEY",
1548
+ "AZURE_OPENAI_API_KEY",
1549
+ "AZURE_OPENAI_ENDPOINT",
1550
+ "OPENROUTER_API_KEY",
1551
+ "ZAI_API_KEY",
1552
+ ]
1553
+
1554
+ # Step 1: Load from .env file if it exists (highest priority)
1555
+ # Look for .env in current working directory
1556
+ env_file = Path.cwd() / ".env"
1557
+ if env_file.exists():
1558
+ try:
1559
+ from dotenv import load_dotenv
1560
+
1561
+ # override=True means .env values take precedence over existing env vars
1562
+ load_dotenv(env_file, override=True)
1563
+ except ImportError:
1564
+ # python-dotenv not installed, skip .env loading
1565
+ pass
1566
+
1567
+ # Step 2: Load from puppy.cfg, but only if not already set
1568
+ # This ensures .env has priority over puppy.cfg
1569
+ for key_name in api_key_names:
1570
+ # Only load from config if not already in environment
1571
+ if key_name not in os.environ or not os.environ[key_name]:
1572
+ value = get_api_key(key_name)
1573
+ if value:
1574
+ os.environ[key_name] = value
1575
+
1576
+
1577
+ def get_default_agent() -> str:
1578
+ """
1579
+ Get the default agent name from puppy.cfg.
1580
+
1581
+ Returns:
1582
+ str: The default agent name, or "code-puppy" if not set.
1583
+ """
1584
+ return get_value("default_agent") or "code-puppy"
1585
+
1586
+
1587
+ def set_default_agent(agent_name: str) -> None:
1588
+ """
1589
+ Set the default agent name in puppy.cfg.
1590
+
1591
+ Args:
1592
+ agent_name: The name of the agent to set as default.
1593
+ """
1594
+ set_config_value("default_agent", agent_name)
1595
+
1596
+
1597
+ # --- FRONTEND EMITTER CONFIGURATION ---
1598
+ def get_frontend_emitter_enabled() -> bool:
1599
+ """Check if frontend emitter is enabled."""
1600
+ val = get_value("frontend_emitter_enabled")
1601
+ if val is None:
1602
+ return True # Enabled by default
1603
+ return str(val).lower() in ("1", "true", "yes", "on")
1604
+
1605
+
1606
+ def get_frontend_emitter_max_recent_events() -> int:
1607
+ """Get max number of recent events to buffer."""
1608
+ val = get_value("frontend_emitter_max_recent_events")
1609
+ if val is None:
1610
+ return 100
1611
+ try:
1612
+ return int(val)
1613
+ except ValueError:
1614
+ return 100
1615
+
1616
+
1617
+ def get_frontend_emitter_queue_size() -> int:
1618
+ """Get max subscriber queue size."""
1619
+ val = get_value("frontend_emitter_queue_size")
1620
+ if val is None:
1621
+ return 100
1622
+ try:
1623
+ return int(val)
1624
+ except ValueError:
1625
+ return 100