code-puppy 0.0.287__py3-none-any.whl → 0.0.323__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 (110) hide show
  1. code_puppy/__init__.py +3 -1
  2. code_puppy/agents/agent_code_puppy.py +5 -4
  3. code_puppy/agents/agent_creator_agent.py +22 -18
  4. code_puppy/agents/agent_manager.py +2 -2
  5. code_puppy/agents/base_agent.py +496 -102
  6. code_puppy/callbacks.py +8 -0
  7. code_puppy/chatgpt_codex_client.py +283 -0
  8. code_puppy/cli_runner.py +795 -0
  9. code_puppy/command_line/add_model_menu.py +19 -16
  10. code_puppy/command_line/attachments.py +10 -5
  11. code_puppy/command_line/autosave_menu.py +269 -41
  12. code_puppy/command_line/colors_menu.py +515 -0
  13. code_puppy/command_line/command_handler.py +10 -24
  14. code_puppy/command_line/config_commands.py +106 -25
  15. code_puppy/command_line/core_commands.py +32 -20
  16. code_puppy/command_line/mcp/add_command.py +3 -16
  17. code_puppy/command_line/mcp/base.py +0 -3
  18. code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
  19. code_puppy/command_line/mcp/custom_server_form.py +66 -5
  20. code_puppy/command_line/mcp/custom_server_installer.py +17 -17
  21. code_puppy/command_line/mcp/edit_command.py +15 -22
  22. code_puppy/command_line/mcp/handler.py +7 -2
  23. code_puppy/command_line/mcp/help_command.py +2 -2
  24. code_puppy/command_line/mcp/install_command.py +10 -14
  25. code_puppy/command_line/mcp/install_menu.py +2 -6
  26. code_puppy/command_line/mcp/list_command.py +2 -2
  27. code_puppy/command_line/mcp/logs_command.py +174 -65
  28. code_puppy/command_line/mcp/remove_command.py +2 -2
  29. code_puppy/command_line/mcp/restart_command.py +7 -2
  30. code_puppy/command_line/mcp/search_command.py +16 -10
  31. code_puppy/command_line/mcp/start_all_command.py +16 -6
  32. code_puppy/command_line/mcp/start_command.py +12 -10
  33. code_puppy/command_line/mcp/status_command.py +4 -5
  34. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  35. code_puppy/command_line/mcp/stop_command.py +6 -4
  36. code_puppy/command_line/mcp/test_command.py +2 -2
  37. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  38. code_puppy/command_line/model_settings_menu.py +53 -7
  39. code_puppy/command_line/motd.py +1 -1
  40. code_puppy/command_line/pin_command_completion.py +82 -7
  41. code_puppy/command_line/prompt_toolkit_completion.py +32 -9
  42. code_puppy/command_line/session_commands.py +11 -4
  43. code_puppy/config.py +217 -53
  44. code_puppy/error_logging.py +118 -0
  45. code_puppy/gemini_code_assist.py +385 -0
  46. code_puppy/keymap.py +126 -0
  47. code_puppy/main.py +5 -745
  48. code_puppy/mcp_/__init__.py +17 -0
  49. code_puppy/mcp_/blocking_startup.py +63 -36
  50. code_puppy/mcp_/captured_stdio_server.py +1 -1
  51. code_puppy/mcp_/config_wizard.py +4 -4
  52. code_puppy/mcp_/dashboard.py +15 -6
  53. code_puppy/mcp_/managed_server.py +25 -5
  54. code_puppy/mcp_/manager.py +65 -0
  55. code_puppy/mcp_/mcp_logs.py +224 -0
  56. code_puppy/mcp_/registry.py +6 -6
  57. code_puppy/messaging/__init__.py +184 -2
  58. code_puppy/messaging/bus.py +610 -0
  59. code_puppy/messaging/commands.py +167 -0
  60. code_puppy/messaging/markdown_patches.py +57 -0
  61. code_puppy/messaging/message_queue.py +3 -3
  62. code_puppy/messaging/messages.py +470 -0
  63. code_puppy/messaging/renderers.py +43 -141
  64. code_puppy/messaging/rich_renderer.py +900 -0
  65. code_puppy/messaging/spinner/console_spinner.py +39 -2
  66. code_puppy/model_factory.py +292 -53
  67. code_puppy/model_utils.py +57 -48
  68. code_puppy/models.json +19 -5
  69. code_puppy/plugins/__init__.py +152 -10
  70. code_puppy/plugins/chatgpt_oauth/config.py +20 -12
  71. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  72. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  73. code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
  74. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  75. code_puppy/plugins/claude_code_oauth/config.py +15 -11
  76. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  77. code_puppy/plugins/claude_code_oauth/utils.py +6 -1
  78. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  79. code_puppy/plugins/oauth_puppy_html.py +3 -0
  80. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
  81. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  82. code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
  83. code_puppy/prompts/codex_system_prompt.md +310 -0
  84. code_puppy/pydantic_patches.py +131 -0
  85. code_puppy/session_storage.py +2 -1
  86. code_puppy/status_display.py +7 -5
  87. code_puppy/terminal_utils.py +126 -0
  88. code_puppy/tools/agent_tools.py +131 -70
  89. code_puppy/tools/browser/browser_control.py +10 -14
  90. code_puppy/tools/browser/browser_interactions.py +20 -28
  91. code_puppy/tools/browser/browser_locators.py +27 -29
  92. code_puppy/tools/browser/browser_navigation.py +9 -9
  93. code_puppy/tools/browser/browser_screenshot.py +12 -14
  94. code_puppy/tools/browser/browser_scripts.py +17 -29
  95. code_puppy/tools/browser/browser_workflows.py +24 -25
  96. code_puppy/tools/browser/camoufox_manager.py +22 -26
  97. code_puppy/tools/command_runner.py +410 -88
  98. code_puppy/tools/common.py +51 -38
  99. code_puppy/tools/file_modifications.py +98 -24
  100. code_puppy/tools/file_operations.py +113 -202
  101. code_puppy/version_checker.py +28 -13
  102. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  103. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
  104. code_puppy-0.0.323.dist-info/RECORD +168 -0
  105. code_puppy/tui_state.py +0 -55
  106. code_puppy-0.0.287.dist-info/RECORD +0 -153
  107. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  108. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  109. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  110. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
code_puppy/config.py CHANGED
@@ -7,17 +7,58 @@ 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
+
57
+ # Cache files (XDG_CACHE_HOME)
58
+ AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
59
+
60
+ # State files (XDG_STATE_HOME)
61
+ COMMAND_HISTORY_FILE = os.path.join(STATE_DIR, "command_history.txt")
21
62
  DBOS_DATABASE_URL = os.environ.get(
22
63
  "DBOS_SYSTEM_DATABASE_URL", f"sqlite:///{_DEFAULT_SQLITE_FILE}"
23
64
  )
@@ -48,11 +89,13 @@ _default_vqa_model_cache = None
48
89
 
49
90
  def ensure_config_exists():
50
91
  """
51
- Ensure that the .code_puppy dir and puppy.cfg exist, prompting if needed.
92
+ Ensure that XDG directories and puppy.cfg exist, prompting if needed.
52
93
  Returns configparser.ConfigParser for reading.
53
94
  """
54
- if not os.path.exists(CONFIG_DIR):
55
- os.makedirs(CONFIG_DIR, exist_ok=True)
95
+ # Create all XDG directories with 0700 permissions per XDG spec
96
+ for directory in [CONFIG_DIR, DATA_DIR, CACHE_DIR, STATE_DIR]:
97
+ if not os.path.exists(directory):
98
+ os.makedirs(directory, mode=0o700, exist_ok=True)
56
99
  exists = os.path.isfile(CONFIG_FILE)
57
100
  config = configparser.ConfigParser()
58
101
  if exists:
@@ -64,7 +107,11 @@ def ensure_config_exists():
64
107
  if not config[DEFAULT_SECTION].get(key):
65
108
  missing.append(key)
66
109
  if missing:
67
- print("🐾 Let's get your Puppy ready!")
110
+ # Note: Using sys.stdout here for initial setup before messaging system is available
111
+ import sys
112
+
113
+ sys.stdout.write("🐾 Let's get your Puppy ready!\n")
114
+ sys.stdout.flush()
68
115
  for key in missing:
69
116
  if key == "puppy_name":
70
117
  val = input("What should we name the puppy? ").strip()
@@ -163,6 +210,11 @@ def get_config_keys():
163
210
  ]
164
211
  # Add DBOS control key
165
212
  default_keys.append("enable_dbos")
213
+ # Add cancel agent key configuration
214
+ default_keys.append("cancel_agent_key")
215
+ # Add banner color keys
216
+ for banner_name in DEFAULT_BANNER_COLORS:
217
+ default_keys.append(f"banner_color_{banner_name}")
166
218
 
167
219
  config = configparser.ConfigParser()
168
220
  config.read(CONFIG_FILE)
@@ -187,7 +239,7 @@ def set_config_value(key: str, value: str):
187
239
  # --- MODEL STICKY EXTENSION STARTS HERE ---
188
240
  def load_mcp_server_configs():
189
241
  """
190
- Loads the MCP server configurations from ~/.code_puppy/mcp_servers.json.
242
+ Loads the MCP server configurations from XDG_CONFIG_HOME/code_puppy/mcp_servers.json.
191
243
  Returns a dict mapping names to their URL or config dict.
192
244
  If file does not exist, returns an empty dict.
193
245
  """
@@ -207,9 +259,8 @@ def load_mcp_server_configs():
207
259
  def _default_model_from_models_json():
208
260
  """Load the default model name from models.json.
209
261
 
210
- Prefers synthetic-GLM-4.6 as the default model.
211
- Falls back to the first model in models.json if synthetic-GLM-4.6 is not available.
212
- As a last resort, falls back to ``gpt-5`` if the file cannot be read.
262
+ Returns the first model in models.json as the default.
263
+ Falls back to ``gpt-5`` if the file cannot be read.
213
264
  """
214
265
  global _default_model_cache
215
266
 
@@ -221,11 +272,7 @@ def _default_model_from_models_json():
221
272
 
222
273
  models_config = ModelFactory.load_config()
223
274
  if models_config:
224
- # Prefer synthetic-GLM-4.6 as default
225
- if "synthetic-GLM-4.6" in models_config:
226
- _default_model_cache = "synthetic-GLM-4.6"
227
- return "synthetic-GLM-4.6"
228
- # Fall back to first model if synthetic-GLM-4.6 is not available
275
+ # Use first model in models.json as default
229
276
  first_key = next(iter(models_config))
230
277
  _default_model_cache = first_key
231
278
  return first_key
@@ -448,8 +495,8 @@ def set_puppy_token(token: str):
448
495
 
449
496
 
450
497
  def get_openai_reasoning_effort() -> str:
451
- """Return the configured OpenAI reasoning effort (low, medium, high)."""
452
- allowed_values = {"low", "medium", "high"}
498
+ """Return the configured OpenAI reasoning effort (minimal, low, medium, high, xhigh)."""
499
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
453
500
  configured = (get_value("openai_reasoning_effort") or "medium").strip().lower()
454
501
  if configured not in allowed_values:
455
502
  return "medium"
@@ -458,7 +505,7 @@ def get_openai_reasoning_effort() -> str:
458
505
 
459
506
  def set_openai_reasoning_effort(value: str) -> None:
460
507
  """Persist the OpenAI reasoning effort ensuring it remains within allowed values."""
461
- allowed_values = {"low", "medium", "high"}
508
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
462
509
  normalized = (value or "").strip().lower()
463
510
  if normalized not in allowed_values:
464
511
  raise ValueError(
@@ -609,10 +656,22 @@ def get_all_model_settings(model_name: str) -> dict:
609
656
  for key, val in config[DEFAULT_SECTION].items():
610
657
  if key.startswith(prefix) and val.strip():
611
658
  setting_name = key[len(prefix) :]
612
- try:
613
- settings[setting_name] = float(val)
614
- except (ValueError, TypeError):
615
- pass
659
+ # Handle different value types
660
+ val_stripped = val.strip()
661
+ # Check for boolean values first
662
+ if val_stripped.lower() in ("true", "false"):
663
+ settings[setting_name] = val_stripped.lower() == "true"
664
+ else:
665
+ # Try to parse as number (int first, then float)
666
+ try:
667
+ # Try int first for cleaner values like budget_tokens
668
+ if "." not in val_stripped:
669
+ settings[setting_name] = int(val_stripped)
670
+ else:
671
+ settings[setting_name] = float(val_stripped)
672
+ except (ValueError, TypeError):
673
+ # Keep as string if not a number
674
+ settings[setting_name] = val_stripped
616
675
 
617
676
  return settings
618
677
 
@@ -789,11 +848,11 @@ def normalize_command_history():
789
848
  ) as f:
790
849
  f.write(updated_content)
791
850
  except Exception as e:
792
- from rich.console import Console
851
+ from code_puppy.messaging import emit_error
793
852
 
794
- direct_console = Console()
795
- error_msg = f"An unexpected error occurred while normalizing command history: {str(e)}"
796
- direct_console.print(f"[bold red]{error_msg}[/bold red]")
853
+ emit_error(
854
+ f"An unexpected error occurred while normalizing command history: {str(e)}"
855
+ )
797
856
 
798
857
 
799
858
  def get_user_agents_directory() -> str:
@@ -815,9 +874,9 @@ def initialize_command_history_file():
815
874
  import os
816
875
  from pathlib import Path
817
876
 
818
- # Ensure the config directory exists before trying to create the history file
819
- if not os.path.exists(CONFIG_DIR):
820
- os.makedirs(CONFIG_DIR, exist_ok=True)
877
+ # Ensure the state directory exists before trying to create the history file
878
+ if not os.path.exists(STATE_DIR):
879
+ os.makedirs(STATE_DIR, exist_ok=True)
821
880
 
822
881
  command_history_exists = os.path.isfile(COMMAND_HISTORY_FILE)
823
882
  if not command_history_exists:
@@ -838,11 +897,11 @@ def initialize_command_history_file():
838
897
  # Normalize the command history format if needed
839
898
  normalize_command_history()
840
899
  except Exception as e:
841
- from rich.console import Console
900
+ from code_puppy.messaging import emit_error
842
901
 
843
- direct_console = Console()
844
- error_msg = f"An unexpected error occurred while trying to initialize history file: {str(e)}"
845
- direct_console.print(f"[bold red]{error_msg}[/bold red]")
902
+ emit_error(
903
+ f"An unexpected error occurred while trying to initialize history file: {str(e)}"
904
+ )
846
905
 
847
906
 
848
907
  def get_yolo_mode():
@@ -1035,13 +1094,11 @@ def save_command_to_history(command: str):
1035
1094
  ) as f:
1036
1095
  f.write(f"\n# {timestamp}\n{command}\n")
1037
1096
  except Exception as e:
1038
- from rich.console import Console
1097
+ from code_puppy.messaging import emit_error
1039
1098
 
1040
- direct_console = Console()
1041
- error_msg = (
1042
- f"❌ An unexpected error occurred while saving command history: {str(e)}"
1099
+ emit_error(
1100
+ f"An unexpected error occurred while saving command history: {str(e)}"
1043
1101
  )
1044
- direct_console.print(f"[bold red]{error_msg}[/bold red]")
1045
1102
 
1046
1103
 
1047
1104
  def get_agent_pinned_model(agent_name: str) -> str:
@@ -1077,6 +1134,38 @@ def clear_agent_pinned_model(agent_name: str):
1077
1134
  set_config_value(f"agent_model_{agent_name}", "")
1078
1135
 
1079
1136
 
1137
+ def get_all_agent_pinned_models() -> dict:
1138
+ """Get all agent-to-model pinnings from config.
1139
+
1140
+ Returns:
1141
+ Dict mapping agent names to their pinned model names.
1142
+ Only includes agents that have a pinned model (non-empty value).
1143
+ """
1144
+ config = configparser.ConfigParser()
1145
+ config.read(CONFIG_FILE)
1146
+
1147
+ pinnings = {}
1148
+ if DEFAULT_SECTION in config:
1149
+ for key, value in config[DEFAULT_SECTION].items():
1150
+ if key.startswith("agent_model_") and value:
1151
+ agent_name = key[len("agent_model_") :]
1152
+ pinnings[agent_name] = value
1153
+ return pinnings
1154
+
1155
+
1156
+ def get_agents_pinned_to_model(model_name: str) -> list:
1157
+ """Get all agents that are pinned to a specific model.
1158
+
1159
+ Args:
1160
+ model_name: The model name to look up.
1161
+
1162
+ Returns:
1163
+ List of agent names pinned to this model.
1164
+ """
1165
+ all_pinnings = get_all_agent_pinned_models()
1166
+ return [agent for agent, model in all_pinnings.items() if model == model_name]
1167
+
1168
+
1080
1169
  def get_auto_save_session() -> bool:
1081
1170
  """
1082
1171
  Checks puppy.cfg for 'auto_save_session' (case-insensitive in value only).
@@ -1178,6 +1267,84 @@ def set_diff_deletion_color(color: str):
1178
1267
  set_config_value("highlight_deletion_color", color)
1179
1268
 
1180
1269
 
1270
+ # =============================================================================
1271
+ # Banner Color Configuration
1272
+ # =============================================================================
1273
+
1274
+ # Default banner colors (Rich color names)
1275
+ # A beautiful jewel-tone palette with semantic meaning:
1276
+ # - Blues/Teals: Reading & navigation (calm, informational)
1277
+ # - Warm tones: Actions & changes (edits, shell commands)
1278
+ # - Purples: AI thinking & reasoning (the "brain" colors)
1279
+ # - Greens: Completions & success
1280
+ # - Neutrals: Search & listings
1281
+ DEFAULT_BANNER_COLORS = {
1282
+ "thinking": "deep_sky_blue4", # Sapphire - contemplation
1283
+ "agent_response": "medium_purple4", # Amethyst - main AI output
1284
+ "shell_command": "dark_orange3", # Amber - system commands
1285
+ "read_file": "steel_blue", # Steel - reading files
1286
+ "edit_file": "dark_goldenrod", # Gold - modifications
1287
+ "grep": "grey37", # Silver - search results
1288
+ "directory_listing": "dodger_blue2", # Sky - navigation
1289
+ "agent_reasoning": "dark_violet", # Violet - deep thought
1290
+ "invoke_agent": "deep_pink4", # Ruby - agent invocation
1291
+ "subagent_response": "sea_green3", # Emerald - sub-agent success
1292
+ "list_agents": "dark_slate_gray3", # Slate - neutral listing
1293
+ }
1294
+
1295
+
1296
+ def get_banner_color(banner_name: str) -> str:
1297
+ """Get the background color for a specific banner.
1298
+
1299
+ Args:
1300
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1301
+
1302
+ Returns:
1303
+ Rich color name or hex code for the banner background
1304
+ """
1305
+ config_key = f"banner_color_{banner_name}"
1306
+ val = get_value(config_key)
1307
+ if val:
1308
+ return val
1309
+ return DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1310
+
1311
+
1312
+ def set_banner_color(banner_name: str, color: str):
1313
+ """Set the background color for a specific banner.
1314
+
1315
+ Args:
1316
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1317
+ color: Rich color name or hex code
1318
+ """
1319
+ config_key = f"banner_color_{banner_name}"
1320
+ set_config_value(config_key, color)
1321
+
1322
+
1323
+ def get_all_banner_colors() -> dict:
1324
+ """Get all banner colors (configured or default).
1325
+
1326
+ Returns:
1327
+ Dict mapping banner names to their colors
1328
+ """
1329
+ return {name: get_banner_color(name) for name in DEFAULT_BANNER_COLORS}
1330
+
1331
+
1332
+ def reset_banner_color(banner_name: str):
1333
+ """Reset a banner color to its default.
1334
+
1335
+ Args:
1336
+ banner_name: The banner identifier to reset
1337
+ """
1338
+ default_color = DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1339
+ set_banner_color(banner_name, default_color)
1340
+
1341
+
1342
+ def reset_all_banner_colors():
1343
+ """Reset all banner colors to their defaults."""
1344
+ for name, color in DEFAULT_BANNER_COLORS.items():
1345
+ set_banner_color(name, color)
1346
+
1347
+
1181
1348
  def get_current_autosave_id() -> str:
1182
1349
  """Get or create the current autosave session ID for this process."""
1183
1350
  global _CURRENT_AUTOSAVE_ID
@@ -1222,11 +1389,8 @@ def auto_save_session_if_enabled() -> bool:
1222
1389
  try:
1223
1390
  import pathlib
1224
1391
 
1225
- from rich.console import Console
1226
-
1227
1392
  from code_puppy.agents.agent_manager import get_current_agent
1228
-
1229
- console = Console()
1393
+ from code_puppy.messaging import emit_info
1230
1394
 
1231
1395
  current_agent = get_current_agent()
1232
1396
  history = current_agent.get_message_history()
@@ -1246,16 +1410,16 @@ def auto_save_session_if_enabled() -> bool:
1246
1410
  auto_saved=True,
1247
1411
  )
1248
1412
 
1249
- console.print(
1250
- f"🐾 [dim]Auto-saved session: {metadata.message_count} messages ({metadata.total_tokens} tokens)[/dim]"
1413
+ emit_info(
1414
+ f"🐾 Auto-saved session: {metadata.message_count} messages ({metadata.total_tokens} tokens)"
1251
1415
  )
1252
1416
 
1253
1417
  return True
1254
1418
 
1255
1419
  except Exception as exc: # pragma: no cover - defensive logging
1256
- from rich.console import Console
1420
+ from code_puppy.messaging import emit_error
1257
1421
 
1258
- Console().print(f"[dim]❌ Failed to auto-save session: {exc}[/dim]")
1422
+ emit_error(f"Failed to auto-save session: {exc}")
1259
1423
  return False
1260
1424
 
1261
1425
 
@@ -0,0 +1,118 @@
1
+ """Error logging utility for code_puppy.
2
+
3
+ Logs unexpected errors to XDG_STATE_HOME/code_puppy/logs/ for debugging purposes.
4
+ Per XDG spec, logs are "state data" (actions history), not configuration.
5
+ Because even good puppies make mistakes sometimes! 🐶
6
+ """
7
+
8
+ import os
9
+ import traceback
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from code_puppy.config import STATE_DIR
15
+
16
+ # Logs directory within the state directory (per XDG spec, logs are state data)
17
+ LOGS_DIR = os.path.join(STATE_DIR, "logs")
18
+ ERROR_LOG_FILE = os.path.join(LOGS_DIR, "errors.log")
19
+
20
+
21
+ def _ensure_logs_dir() -> None:
22
+ """Create the logs directory if it doesn't exist (with 0700 perms per XDG spec)."""
23
+ Path(LOGS_DIR).mkdir(parents=True, exist_ok=True, mode=0o700)
24
+
25
+
26
+ def log_error(
27
+ error: Exception,
28
+ context: Optional[str] = None,
29
+ include_traceback: bool = True,
30
+ ) -> None:
31
+ """Log an error to the error log file.
32
+
33
+ Args:
34
+ error: The exception to log
35
+ context: Optional context string describing where the error occurred
36
+ include_traceback: Whether to include the full traceback (default True)
37
+ """
38
+ try:
39
+ _ensure_logs_dir()
40
+
41
+ timestamp = datetime.now().isoformat()
42
+ error_type = type(error).__name__
43
+ error_msg = str(error)
44
+
45
+ log_entry_parts = [
46
+ f"\n{'=' * 80}",
47
+ f"Timestamp: {timestamp}",
48
+ f"Error Type: {error_type}",
49
+ f"Error Message: {error_msg}",
50
+ ]
51
+
52
+ if context:
53
+ log_entry_parts.append(f"Context: {context}")
54
+
55
+ if include_traceback:
56
+ tb = traceback.format_exception(type(error), error, error.__traceback__)
57
+ log_entry_parts.append(f"Traceback:\n{''.join(tb)}")
58
+
59
+ if hasattr(error, "args") and error.args:
60
+ log_entry_parts.append(f"Args: {error.args}")
61
+
62
+ log_entry_parts.append(f"{'=' * 80}\n")
63
+
64
+ log_entry = "\n".join(log_entry_parts)
65
+
66
+ with open(ERROR_LOG_FILE, "a", encoding="utf-8") as f:
67
+ f.write(log_entry)
68
+
69
+ except Exception:
70
+ # If we can't log, we silently fail - don't want logging errors
71
+ # to cause more problems than they solve!
72
+ pass
73
+
74
+
75
+ def log_error_message(
76
+ message: str,
77
+ context: Optional[str] = None,
78
+ ) -> None:
79
+ """Log a simple error message without an exception object.
80
+
81
+ Args:
82
+ message: The error message to log
83
+ context: Optional context string describing where the error occurred
84
+ """
85
+ try:
86
+ _ensure_logs_dir()
87
+
88
+ timestamp = datetime.now().isoformat()
89
+
90
+ log_entry_parts = [
91
+ f"\n{'=' * 80}",
92
+ f"Timestamp: {timestamp}",
93
+ f"Message: {message}",
94
+ ]
95
+
96
+ if context:
97
+ log_entry_parts.append(f"Context: {context}")
98
+
99
+ log_entry_parts.append(f"{'=' * 80}\n")
100
+
101
+ log_entry = "\n".join(log_entry_parts)
102
+
103
+ with open(ERROR_LOG_FILE, "a", encoding="utf-8") as f:
104
+ f.write(log_entry)
105
+
106
+ except Exception:
107
+ # Silent fail - same reasoning as above
108
+ pass
109
+
110
+
111
+ def get_log_file_path() -> str:
112
+ """Return the path to the error log file."""
113
+ return ERROR_LOG_FILE
114
+
115
+
116
+ def get_logs_dir() -> str:
117
+ """Return the path to the logs directory."""
118
+ return LOGS_DIR