ripperdoc 0.2.4__py3-none-any.whl → 0.2.5__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 (75) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +33 -13
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,295 @@
1
+ """Agent Skill loading helpers for Ripperdoc.
2
+
3
+ Skills are small capability bundles defined by SKILL.md files that live under
4
+ `~/.ripperdoc/skills` or `.ripperdoc/skills` in a project. Only the skill
5
+ metadata (name + description) should be added to the system prompt up front;
6
+ the full content is loaded on demand via the Skill tool. Optional frontmatter
7
+ fields include:
8
+ - allowed-tools: Comma-separated list of tools that are allowed/preferred.
9
+ - model: Model pointer hint for this skill.
10
+ - max-thinking-tokens: Reasoning budget hint for this skill.
11
+ - disable-model-invocation: If true, block the Skill tool from loading this
12
+ skill.
13
+ - type: Skill kind (defaults to "prompt").
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
23
+
24
+ import yaml
25
+
26
+ from ripperdoc.utils.coerce import parse_boolish, parse_optional_int
27
+ from ripperdoc.utils.log import get_logger
28
+
29
+ logger = get_logger()
30
+
31
+ SKILL_DIR_NAME = "skills"
32
+ SKILL_FILE_NAME = "SKILL.md"
33
+ _SKILL_NAME_RE = re.compile(r"^[a-z0-9-]{1,64}$")
34
+
35
+
36
+ class SkillLocation(str, Enum):
37
+ """Where a skill definition is sourced from."""
38
+
39
+ USER = "user"
40
+ PROJECT = "project"
41
+ OTHER = "other"
42
+
43
+
44
+ @dataclass
45
+ class SkillDefinition:
46
+ """Parsed representation of a skill."""
47
+
48
+ name: str
49
+ description: str
50
+ content: str
51
+ path: Path
52
+ base_dir: Path
53
+ location: SkillLocation
54
+ allowed_tools: List[str]
55
+ model: Optional[str] = None
56
+ max_thinking_tokens: Optional[int] = None
57
+ skill_type: str = "prompt"
58
+ disable_model_invocation: bool = False
59
+
60
+
61
+ @dataclass
62
+ class SkillLoadError:
63
+ """Error encountered while loading a skill file."""
64
+
65
+ path: Path
66
+ reason: str
67
+
68
+
69
+ @dataclass
70
+ class SkillLoadResult:
71
+ """Aggregated result of loading skills."""
72
+
73
+ skills: List[SkillDefinition]
74
+ errors: List[SkillLoadError]
75
+
76
+
77
+ def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
78
+ """Extract YAML frontmatter and body content from a markdown file."""
79
+ lines = raw_text.splitlines()
80
+ if len(lines) >= 3 and lines[0].strip() == "---":
81
+ for idx in range(1, len(lines)):
82
+ if lines[idx].strip() == "---":
83
+ frontmatter_text = "\n".join(lines[1:idx])
84
+ body = "\n".join(lines[idx + 1 :])
85
+ try:
86
+ frontmatter = yaml.safe_load(frontmatter_text) or {}
87
+ except (yaml.YAMLError, ValueError, TypeError) as exc: # pragma: no cover - defensive
88
+ logger.warning(
89
+ "[skills] Invalid frontmatter in SKILL.md: %s: %s",
90
+ type(exc).__name__, exc,
91
+ )
92
+ return {"__error__": f"Invalid frontmatter: {exc}"}, body
93
+ return frontmatter, body
94
+ return {}, raw_text
95
+
96
+
97
+ def _normalize_allowed_tools(value: object) -> List[str]:
98
+ """Normalize allowed-tools values to a clean list of tool names."""
99
+ if value is None:
100
+ return []
101
+ if isinstance(value, str):
102
+ return [item.strip() for item in value.split(",") if item.strip()]
103
+ if isinstance(value, Iterable):
104
+ tools: List[str] = []
105
+ for item in value:
106
+ if isinstance(item, str) and item.strip():
107
+ tools.append(item.strip())
108
+ return tools
109
+ return []
110
+
111
+
112
+ def _load_skill_file(
113
+ path: Path, location: SkillLocation
114
+ ) -> Tuple[Optional[SkillDefinition], Optional[SkillLoadError]]:
115
+ """Parse a single SKILL.md file."""
116
+ try:
117
+ text = path.read_text(encoding="utf-8")
118
+ except (OSError, IOError, UnicodeDecodeError) as exc:
119
+ logger.warning(
120
+ "[skills] Failed to read skill file: %s: %s",
121
+ type(exc).__name__, exc,
122
+ extra={"path": str(path)},
123
+ )
124
+ return None, SkillLoadError(path=path, reason=f"Failed to read file: {exc}")
125
+
126
+ frontmatter, body = _split_frontmatter(text)
127
+ if "__error__" in frontmatter:
128
+ return None, SkillLoadError(path=path, reason=str(frontmatter["__error__"]))
129
+
130
+ raw_name = frontmatter.get("name")
131
+ raw_description = frontmatter.get("description")
132
+ if not isinstance(raw_name, str) or not raw_name.strip():
133
+ return None, SkillLoadError(path=path, reason='Missing required "name" field')
134
+ if not _SKILL_NAME_RE.match(raw_name.strip()):
135
+ return None, SkillLoadError(
136
+ path=path,
137
+ reason='Invalid "name" format. Use lowercase letters, numbers, and hyphens only (max 64 chars).',
138
+ )
139
+ if not isinstance(raw_description, str) or not raw_description.strip():
140
+ return None, SkillLoadError(path=path, reason='Missing required "description" field')
141
+
142
+ allowed_tools = _normalize_allowed_tools(
143
+ frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
144
+ )
145
+ model_value = frontmatter.get("model")
146
+ model = model_value if isinstance(model_value, str) and model_value.strip() else None
147
+ max_thinking_tokens = parse_optional_int(
148
+ frontmatter.get("max-thinking-tokens") or frontmatter.get("max_thinking_tokens")
149
+ )
150
+ raw_type = (
151
+ frontmatter.get("type")
152
+ or frontmatter.get("skill-type")
153
+ or frontmatter.get("skill_type")
154
+ or "prompt"
155
+ )
156
+ skill_type = str(raw_type).strip().lower() if isinstance(raw_type, str) else "prompt"
157
+ disable_model_invocation = parse_boolish(
158
+ frontmatter.get("disable-model-invocation") or frontmatter.get("disable_model_invocation")
159
+ )
160
+
161
+ skill = SkillDefinition(
162
+ name=raw_name.strip(),
163
+ description=raw_description.strip(),
164
+ content=body.strip(),
165
+ path=path,
166
+ base_dir=path.parent,
167
+ location=location,
168
+ allowed_tools=allowed_tools,
169
+ model=model,
170
+ max_thinking_tokens=max_thinking_tokens,
171
+ skill_type=skill_type or "prompt",
172
+ disable_model_invocation=disable_model_invocation,
173
+ )
174
+ return skill, None
175
+
176
+
177
+ def _load_skill_dir(
178
+ path: Path, location: SkillLocation
179
+ ) -> Tuple[List[SkillDefinition], List[SkillLoadError]]:
180
+ """Load skills from a directory that either contains SKILL.md or subdirectories."""
181
+ skills: List[SkillDefinition] = []
182
+ errors: List[SkillLoadError] = []
183
+ if not path.exists() or not path.is_dir():
184
+ return skills, errors
185
+
186
+ single_skill = path / SKILL_FILE_NAME
187
+ if single_skill.exists():
188
+ skill, error = _load_skill_file(single_skill, location)
189
+ if skill:
190
+ skills.append(skill)
191
+ elif error:
192
+ errors.append(error)
193
+ return skills, errors
194
+
195
+ for entry in sorted(path.iterdir()):
196
+ try:
197
+ if not entry.is_dir() and not entry.is_symlink():
198
+ continue
199
+ except OSError:
200
+ continue
201
+
202
+ candidate = entry / SKILL_FILE_NAME
203
+ if not candidate.exists():
204
+ continue
205
+ skill, error = _load_skill_file(candidate, location)
206
+ if skill:
207
+ skills.append(skill)
208
+ elif error:
209
+ errors.append(error)
210
+ return skills, errors
211
+
212
+
213
+ def skill_directories(
214
+ project_path: Optional[Path] = None, home: Optional[Path] = None
215
+ ) -> List[Tuple[Path, SkillLocation]]:
216
+ """Return the standard skill directories for user and project scopes."""
217
+ home_dir = (home or Path.home()).expanduser()
218
+ project_dir = (project_path or Path.cwd()).resolve()
219
+ return [
220
+ (home_dir / ".ripperdoc" / SKILL_DIR_NAME, SkillLocation.USER),
221
+ (project_dir / ".ripperdoc" / SKILL_DIR_NAME, SkillLocation.PROJECT),
222
+ ]
223
+
224
+
225
+ def load_all_skills(
226
+ project_path: Optional[Path] = None, home: Optional[Path] = None
227
+ ) -> SkillLoadResult:
228
+ """Load skills from user and project directories.
229
+
230
+ Project skills override user skills with the same name.
231
+ """
232
+ skills_by_name: Dict[str, SkillDefinition] = {}
233
+ errors: List[SkillLoadError] = []
234
+
235
+ # Load user first so project overrides take precedence.
236
+ for directory, location in skill_directories(project_path=project_path, home=home):
237
+ loaded, dir_errors = _load_skill_dir(directory, location)
238
+ errors.extend(dir_errors)
239
+ for skill in loaded:
240
+ if skill.name in skills_by_name:
241
+ logger.debug(
242
+ "[skills] Overriding skill",
243
+ extra={
244
+ "skill_name": skill.name,
245
+ "previous_location": str(skills_by_name[skill.name].location),
246
+ "new_location": str(location),
247
+ },
248
+ )
249
+ skills_by_name[skill.name] = skill
250
+ return SkillLoadResult(skills=list(skills_by_name.values()), errors=errors)
251
+
252
+
253
+ def find_skill(
254
+ skill_name: str, project_path: Optional[Path] = None, home: Optional[Path] = None
255
+ ) -> Optional[SkillDefinition]:
256
+ """Find a skill by name (case-sensitive match)."""
257
+ normalized = skill_name.strip().lstrip("/")
258
+ if not normalized:
259
+ return None
260
+ result = load_all_skills(project_path=project_path, home=home)
261
+ return next((skill for skill in result.skills if skill.name == normalized), None)
262
+
263
+
264
+ def build_skill_summary(skills: Sequence[SkillDefinition]) -> str:
265
+ """Render a concise instruction block listing available skills."""
266
+ if not skills:
267
+ return (
268
+ "# Skills\n"
269
+ "No skills detected. Add SKILL.md under ~/.ripperdoc/skills or ./.ripperdoc/skills "
270
+ "to extend capabilities, then load them with the Skill tool when relevant."
271
+ )
272
+ lines = [
273
+ "# Skills",
274
+ "Skills extend your capabilities with reusable instructions stored in SKILL.md files.",
275
+ 'Call the Skill tool with {"skill": "<name>"} to load a skill when it matches the user request.',
276
+ "Available skills:",
277
+ ]
278
+ for skill in skills:
279
+ location = f" ({skill.location.value})" if skill.location else ""
280
+ lines.append(f"- {skill.name}{location}: {skill.description}")
281
+ return "\n".join(lines)
282
+
283
+
284
+ __all__ = [
285
+ "SkillDefinition",
286
+ "SkillLoadError",
287
+ "SkillLoadResult",
288
+ "SkillLocation",
289
+ "SKILL_DIR_NAME",
290
+ "SKILL_FILE_NAME",
291
+ "load_all_skills",
292
+ "find_skill",
293
+ "build_skill_summary",
294
+ "skill_directories",
295
+ ]
@@ -32,7 +32,7 @@ DEFENSIVE_SECURITY_GUIDELINE = (
32
32
  "IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. "
33
33
  "Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation."
34
34
  )
35
- FEEDBACK_URL = "https://github.com/jstzwj/Ripperdoc/issues"
35
+ FEEDBACK_URL = "https://github.com/quantmew/Ripperdoc/issues"
36
36
 
37
37
 
38
38
  def _detect_git_repo(cwd: Path) -> bool:
@@ -46,8 +46,12 @@ def _detect_git_repo(cwd: Path) -> bool:
46
46
  check=False,
47
47
  )
48
48
  return result.returncode == 0 and result.stdout.strip().lower() == "true"
49
- except Exception:
50
- logger.exception("[system_prompt] Failed to detect git repository", extra={"cwd": str(cwd)})
49
+ except (OSError, subprocess.SubprocessError) as exc:
50
+ logger.warning(
51
+ "[system_prompt] Failed to detect git repository: %s: %s",
52
+ type(exc).__name__, exc,
53
+ extra={"cwd": str(cwd)},
54
+ )
51
55
  return False
52
56
 
53
57
 
@@ -220,7 +224,7 @@ def build_system_prompt(
220
224
  - NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
221
225
 
222
226
  # Professional objectivity
223
- Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
227
+ Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if you honestly apply the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
224
228
 
225
229
  # Planning without timelines
226
230
  When planning tasks, provide concrete implementation steps without time estimates. Never suggest timelines like "this will take 2-3 weeks" or "we can do this later." Focus on what needs to be done, not when. Break work into actionable steps and let users decide scheduling.
@@ -386,8 +390,11 @@ def build_system_prompt(
386
390
 
387
391
  Provide detailed prompts so the agent can work autonomously and return a concise report."""
388
392
  ).strip()
389
- except Exception as exc:
390
- logger.exception("Failed to load agent definitions", extra={"error": str(exc)})
393
+ except (OSError, ValueError, RuntimeError) as exc:
394
+ logger.warning(
395
+ "Failed to load agent definitions: %s: %s",
396
+ type(exc).__name__, exc,
397
+ )
391
398
  agent_section = (
392
399
  "# Subagents\nTask tool available, but agent definitions could not be loaded."
393
400
  )
@@ -413,7 +420,6 @@ def build_system_prompt(
413
420
  tool_usage_section,
414
421
  agent_section,
415
422
  build_environment_prompt(),
416
- DEFENSIVE_SECURITY_GUIDELINE,
417
423
  always_use_todo,
418
424
  build_commit_workflow_prompt(shell_tool_name, todo_tool_name, TASK_TOOL_NAME),
419
425
  code_references,
ripperdoc/core/tool.py CHANGED
@@ -208,9 +208,10 @@ async def build_tool_description(
208
208
 
209
209
  if parts:
210
210
  return f"{description_text}\n\nInput examples:\n" + "\n\n".join(parts)
211
- except Exception:
212
- logger.exception(
213
- "[tool] Failed to build input example section",
211
+ except (TypeError, ValueError, AttributeError, KeyError) as exc:
212
+ logger.warning(
213
+ "[tool] Failed to build input example section: %s: %s",
214
+ type(exc).__name__, exc,
214
215
  extra={"tool": getattr(tool, "name", None)},
215
216
  )
216
217
  return description_text
@@ -227,9 +228,10 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
227
228
  for example in examples[:limit]:
228
229
  try:
229
230
  results.append(example.example)
230
- except Exception:
231
- logger.exception(
232
- "[tool] Failed to format tool input example",
231
+ except (TypeError, ValueError, AttributeError) as exc:
232
+ logger.warning(
233
+ "[tool] Failed to format tool input example: %s: %s",
234
+ type(exc).__name__, exc,
233
235
  extra={"tool": getattr(tool, "name", None)},
234
236
  )
235
237
  continue
ripperdoc/sdk/client.py CHANGED
@@ -27,6 +27,7 @@ from ripperdoc.core.default_tools import get_default_tools
27
27
  from ripperdoc.core.query import QueryContext, query as _core_query
28
28
  from ripperdoc.core.permissions import PermissionResult
29
29
  from ripperdoc.core.system_prompt import build_system_prompt
30
+ from ripperdoc.core.skills import build_skill_summary, load_all_skills
30
31
  from ripperdoc.core.tool import Tool
31
32
  from ripperdoc.tools.task_tool import TaskTool
32
33
  from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
@@ -42,6 +43,7 @@ from ripperdoc.utils.mcp import (
42
43
  load_mcp_servers_async,
43
44
  shutdown_mcp_runtime,
44
45
  )
46
+ from ripperdoc.utils.log import get_logger
45
47
 
46
48
  MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
47
49
  PermissionChecker = Callable[
@@ -67,6 +69,8 @@ QueryRunner = Callable[
67
69
 
68
70
  _END_OF_STREAM = object()
69
71
 
72
+ logger = get_logger()
73
+
70
74
 
71
75
  def _coerce_to_path(path: Union[str, Path]) -> Path:
72
76
  return path if isinstance(path, Path) else Path(path)
@@ -281,12 +285,21 @@ class RipperdocClient:
281
285
  return self.options.system_prompt
282
286
 
283
287
  instructions: List[str] = []
288
+ project_path = _coerce_to_path(self.options.cwd or Path.cwd())
289
+ skill_result = load_all_skills(project_path)
290
+ for err in skill_result.errors:
291
+ logger.warning(
292
+ "[skills] Failed to load skill",
293
+ extra={"path": str(err.path), "reason": err.reason},
294
+ )
295
+ skill_instructions = build_skill_summary(skill_result.skills)
296
+ if skill_instructions:
297
+ instructions.append(skill_instructions)
284
298
  instructions.extend(self.options.extra_instructions())
285
299
  memory = build_memory_instructions()
286
300
  if memory:
287
301
  instructions.append(memory)
288
302
 
289
- project_path = _coerce_to_path(self.options.cwd or Path.cwd())
290
303
  dynamic_tools = await load_dynamic_mcp_tools_async(project_path)
291
304
  if dynamic_tools:
292
305
  self._tools = merge_tools_with_dynamic(self._tools, dynamic_tools)
@@ -117,9 +117,7 @@ def format_option_display(option: OptionInput, index: int) -> str:
117
117
  return f" {index}. {option.label}{desc}"
118
118
 
119
119
 
120
- def format_question_prompt(
121
- question: QuestionInput, question_num: int, total: int
122
- ) -> str:
120
+ def format_question_prompt(question: QuestionInput, question_num: int, total: int) -> str:
123
121
  """Format a question for terminal display."""
124
122
  header = truncate_header(question.header)
125
123
  lines = [
@@ -137,9 +135,7 @@ def format_question_prompt(
137
135
 
138
136
  if question.multiSelect:
139
137
  lines.append("")
140
- lines.append(
141
- " Enter numbers separated by commas (e.g., 1,3), or 'o' for other: "
142
- )
138
+ lines.append(" Enter numbers separated by commas (e.g., 1,3), or 'o' for other: ")
143
139
  else:
144
140
  lines.append("")
145
141
  lines.append(" Enter choice (1-{}) or 'o' for other: ".format(len(question.options) + 1))
@@ -204,9 +200,7 @@ async def prompt_user_for_answer(
204
200
  f" Invalid selection. Enter numbers from 1 to {len(question.options) + 1}."
205
201
  )
206
202
  except ValueError:
207
- print(
208
- " Invalid input. Enter numbers separated by commas."
209
- )
203
+ print(" Invalid input. Enter numbers separated by commas.")
210
204
  else:
211
205
  # Single selection
212
206
  try:
@@ -231,8 +225,11 @@ async def prompt_user_for_answer(
231
225
  return None
232
226
  except EOFError:
233
227
  return None
234
- except Exception as e:
235
- logger.exception("[ask_user_question_tool] Error during prompt", extra={"error": str(e)})
228
+ except (OSError, RuntimeError, ValueError) as e:
229
+ logger.warning(
230
+ "[ask_user_question_tool] Error during prompt: %s: %s",
231
+ type(e).__name__, e,
232
+ )
236
233
  return None
237
234
 
238
235
  return await loop.run_in_executor(None, _prompt)
@@ -291,7 +288,8 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
291
288
  return True
292
289
 
293
290
  def needs_permissions(
294
- self, input_data: Optional[AskUserQuestionToolInput] = None # noqa: ARG002
291
+ self,
292
+ input_data: Optional[AskUserQuestionToolInput] = None, # noqa: ARG002
295
293
  ) -> bool:
296
294
  return False
297
295
 
@@ -305,9 +303,7 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
305
303
 
306
304
  for question in input_data.questions:
307
305
  if question.question in seen_questions:
308
- return ValidationResult(
309
- result=False, message="Question texts must be unique"
310
- )
306
+ return ValidationResult(result=False, message="Question texts must be unique")
311
307
  seen_questions.add(question.question)
312
308
 
313
309
  option_labels: set[str] = set()
@@ -338,7 +334,9 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
338
334
  )
339
335
 
340
336
  def render_tool_use_message(
341
- self, input_data: AskUserQuestionToolInput, verbose: bool = False # noqa: ARG002
337
+ self,
338
+ input_data: AskUserQuestionToolInput,
339
+ verbose: bool = False, # noqa: ARG002
342
340
  ) -> str:
343
341
  """Render the tool use message for display."""
344
342
  question_count = len(input_data.questions)
@@ -359,7 +357,7 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
359
357
  if context.pause_ui:
360
358
  try:
361
359
  context.pause_ui()
362
- except Exception:
360
+ except (RuntimeError, ValueError, OSError):
363
361
  logger.debug("[ask_user_question_tool] Failed to pause UI")
364
362
 
365
363
  try:
@@ -409,10 +407,10 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
409
407
  result_for_assistant=self.render_result_for_assistant(output),
410
408
  )
411
409
 
412
- except Exception as exc:
413
- logger.exception(
414
- "[ask_user_question_tool] Error collecting answers",
415
- extra={"error": str(exc)},
410
+ except (OSError, RuntimeError, ValueError, KeyError) as exc:
411
+ logger.warning(
412
+ "[ask_user_question_tool] Error collecting answers: %s: %s",
413
+ type(exc).__name__, exc,
416
414
  )
417
415
  output = AskUserQuestionToolOutput(
418
416
  questions=questions,
@@ -429,5 +427,5 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
429
427
  if context.resume_ui:
430
428
  try:
431
429
  context.resume_ui()
432
- except Exception:
430
+ except (RuntimeError, ValueError, OSError):
433
431
  logger.debug("[ask_user_question_tool] Failed to resume UI")
@@ -47,11 +47,13 @@ _background_loop: Optional[asyncio.AbstractEventLoop] = None
47
47
  _background_thread: Optional[threading.Thread] = None
48
48
  _loop_lock = threading.Lock()
49
49
  _shutdown_registered = False
50
+
51
+
50
52
  def _safe_log_exception(message: str, **extra: Any) -> None:
51
53
  """Log an exception but never let logging failures bubble up."""
52
54
  try:
53
55
  logger.exception(message, extra=extra)
54
- except Exception:
56
+ except (OSError, RuntimeError, ValueError):
55
57
  pass
56
58
 
57
59
 
@@ -112,7 +114,9 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
112
114
  text = chunk.decode("utf-8", errors="replace")
113
115
  with _tasks_lock:
114
116
  sink.append(text)
115
- except Exception as exc:
117
+ except (OSError, RuntimeError, asyncio.CancelledError) as exc:
118
+ if isinstance(exc, asyncio.CancelledError):
119
+ return # Normal cancellation
116
120
  # Best effort; ignore stream read errors to avoid leaking tasks.
117
121
  logger.debug(
118
122
  f"Stream pump error for background task: {exc}",
@@ -155,9 +159,10 @@ async def _monitor_task(task: BackgroundTask) -> None:
155
159
  task.exit_code = -1
156
160
  except asyncio.CancelledError:
157
161
  return
158
- except Exception:
159
- logger.exception(
160
- "Error monitoring background task",
162
+ except (OSError, RuntimeError, ProcessLookupError) as exc:
163
+ logger.warning(
164
+ "Error monitoring background task: %s: %s",
165
+ type(exc).__name__, exc,
161
166
  extra={"task_id": task.id, "command": task.command},
162
167
  )
163
168
  with _tasks_lock:
@@ -327,12 +332,13 @@ async def _shutdown_loop(loop: asyncio.AbstractEventLoop) -> None:
327
332
  with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
328
333
  await asyncio.wait_for(task.process.wait(), timeout=0.5)
329
334
  task.exit_code = task.process.returncode or -1
330
- except Exception:
331
- _safe_log_exception(
332
- "Error shutting down background task",
333
- task_id=task.id,
334
- command=task.command,
335
- )
335
+ except (OSError, RuntimeError, asyncio.CancelledError) as exc:
336
+ if not isinstance(exc, asyncio.CancelledError):
337
+ _safe_log_exception(
338
+ "Error shutting down background task",
339
+ task_id=task.id,
340
+ command=task.command,
341
+ )
336
342
  finally:
337
343
  await _finalize_reader_tasks(task.reader_tasks)
338
344
  task.done_event.set()
@@ -366,11 +372,11 @@ def shutdown_background_shell() -> None:
366
372
  try:
367
373
  fut = asyncio.run_coroutine_threadsafe(_shutdown_loop(loop), loop)
368
374
  fut.result(timeout=3)
369
- except Exception:
375
+ except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
370
376
  logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
371
377
  try:
372
378
  loop.call_soon_threadsafe(loop.stop)
373
- except Exception:
379
+ except (RuntimeError, OSError):
374
380
  logger.debug("Failed to stop background loop", exc_info=True)
375
381
  else:
376
382
  loop.run_until_complete(_shutdown_loop(loop))