ripperdoc 0.2.9__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +379 -51
  3. ripperdoc/cli/commands/__init__.py +6 -0
  4. ripperdoc/cli/commands/agents_cmd.py +128 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  7. ripperdoc/cli/commands/exit_cmd.py +1 -0
  8. ripperdoc/cli/commands/memory_cmd.py +2 -1
  9. ripperdoc/cli/commands/models_cmd.py +63 -7
  10. ripperdoc/cli/commands/resume_cmd.py +5 -0
  11. ripperdoc/cli/commands/skills_cmd.py +103 -0
  12. ripperdoc/cli/commands/stats_cmd.py +244 -0
  13. ripperdoc/cli/commands/status_cmd.py +10 -0
  14. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  15. ripperdoc/cli/commands/themes_cmd.py +139 -0
  16. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  17. ripperdoc/cli/ui/helpers.py +6 -3
  18. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  19. ripperdoc/cli/ui/panels.py +14 -8
  20. ripperdoc/cli/ui/rich_ui.py +737 -47
  21. ripperdoc/cli/ui/spinner.py +93 -18
  22. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  23. ripperdoc/cli/ui/tool_renderers.py +10 -9
  24. ripperdoc/cli/ui/wizard.py +24 -19
  25. ripperdoc/core/agents.py +14 -3
  26. ripperdoc/core/config.py +238 -6
  27. ripperdoc/core/default_tools.py +91 -10
  28. ripperdoc/core/hooks/events.py +4 -0
  29. ripperdoc/core/hooks/llm_callback.py +58 -0
  30. ripperdoc/core/hooks/manager.py +6 -0
  31. ripperdoc/core/permissions.py +160 -9
  32. ripperdoc/core/providers/openai.py +84 -28
  33. ripperdoc/core/query.py +489 -87
  34. ripperdoc/core/query_utils.py +17 -14
  35. ripperdoc/core/skills.py +1 -0
  36. ripperdoc/core/theme.py +298 -0
  37. ripperdoc/core/tool.py +15 -5
  38. ripperdoc/protocol/__init__.py +14 -0
  39. ripperdoc/protocol/models.py +300 -0
  40. ripperdoc/protocol/stdio.py +1453 -0
  41. ripperdoc/tools/background_shell.py +354 -139
  42. ripperdoc/tools/bash_tool.py +117 -22
  43. ripperdoc/tools/file_edit_tool.py +228 -50
  44. ripperdoc/tools/file_read_tool.py +154 -3
  45. ripperdoc/tools/file_write_tool.py +53 -11
  46. ripperdoc/tools/grep_tool.py +98 -8
  47. ripperdoc/tools/lsp_tool.py +609 -0
  48. ripperdoc/tools/multi_edit_tool.py +26 -3
  49. ripperdoc/tools/skill_tool.py +52 -1
  50. ripperdoc/tools/task_tool.py +539 -65
  51. ripperdoc/utils/conversation_compaction.py +1 -1
  52. ripperdoc/utils/file_watch.py +216 -7
  53. ripperdoc/utils/image_utils.py +125 -0
  54. ripperdoc/utils/log.py +30 -3
  55. ripperdoc/utils/lsp.py +812 -0
  56. ripperdoc/utils/mcp.py +80 -18
  57. ripperdoc/utils/message_formatting.py +7 -4
  58. ripperdoc/utils/messages.py +198 -33
  59. ripperdoc/utils/pending_messages.py +50 -0
  60. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  61. ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
  62. ripperdoc/utils/platform.py +198 -0
  63. ripperdoc/utils/session_heatmap.py +242 -0
  64. ripperdoc/utils/session_history.py +2 -2
  65. ripperdoc/utils/session_stats.py +294 -0
  66. ripperdoc/utils/shell_utils.py +8 -5
  67. ripperdoc/utils/todo.py +0 -6
  68. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
  69. ripperdoc-0.3.0.dist-info/RECORD +136 -0
  70. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  71. ripperdoc/sdk/__init__.py +0 -9
  72. ripperdoc/sdk/client.py +0 -333
  73. ripperdoc-0.2.9.dist-info/RECORD +0 -123
  74. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
@@ -1,45 +1,108 @@
1
1
  from contextlib import contextmanager
2
+ import shutil
3
+ import sys
2
4
  from typing import Any, Generator, Literal, Optional
3
5
 
4
6
  from rich.console import Console
5
- from rich.markup import escape
6
- from rich.status import Status
7
+ from rich.live import Live
8
+ from rich.text import Text
9
+ from rich.spinner import Spinner as RichSpinner
10
+
11
+ from ripperdoc.core.theme import theme_color
12
+
13
+ # ANSI escape sequences for terminal control
14
+ _CLEAR_LINE = "\r\033[K" # Move to start of line and clear to end
7
15
 
8
16
 
9
17
  class Spinner:
10
- """Lightweight spinner wrapper for Rich status."""
18
+ """Lightweight spinner wrapper that plays nicely with other console output."""
19
+
20
+ # Reserve space for spinner animation (e.g., "⠧ ") and safety margin
21
+ _SPINNER_MARGIN = 6
11
22
 
12
23
  def __init__(self, console: Console, text: str = "Thinking...", spinner: str = "dots"):
13
24
  self.console = console
14
25
  self.text = text
15
26
  self.spinner = spinner
16
- self._status: Optional[Status] = None
27
+ self._style = theme_color("spinner")
28
+ self._live: Optional[Live] = None
29
+ # Spinner color from theme for visual separation in the terminal
30
+ self._renderable: RichSpinner = RichSpinner(
31
+ spinner,
32
+ text=Text(self._fit_to_terminal(self.text), style=self._style),
33
+ style=self._style,
34
+ )
35
+
36
+ def _get_terminal_width(self) -> int:
37
+ """Get current terminal width, with fallback."""
38
+ try:
39
+ return shutil.get_terminal_size().columns
40
+ except Exception:
41
+ return 80 # Reasonable default
42
+
43
+ def _fit_to_terminal(self, text: str) -> str:
44
+ """Truncate text to fit within terminal width, preventing line wrap issues.
45
+
46
+ This ensures spinner text never causes terminal wrapping, which would
47
+ leave artifacts when the spinner refreshes or stops.
48
+ """
49
+ max_width = self._get_terminal_width() - self._SPINNER_MARGIN
50
+ if max_width < 20:
51
+ max_width = 20 # Minimum usable width
52
+
53
+ if len(text) <= max_width:
54
+ return text
55
+
56
+ # Smart truncation: keep the structure intact
57
+ # Find the last complete parenthetical group if possible
58
+ truncated = text[: max_width - 1] + "…"
59
+ return truncated
60
+
61
+ def _clear_line(self) -> None:
62
+ """Clear the current terminal line to prevent artifacts."""
63
+ if self.console.is_terminal:
64
+ try:
65
+ sys.stdout.write(_CLEAR_LINE)
66
+ sys.stdout.flush()
67
+ except Exception:
68
+ pass # Ignore errors in non-TTY environments
17
69
 
18
70
  def start(self) -> None:
19
71
  """Start the spinner if not already running."""
20
-
21
- if self._status is not None:
72
+ if self._live is not None:
22
73
  return
23
- self._status = self.console.status(
24
- f"[cyan]{escape(self.text)}[/cyan]", spinner=self.spinner
74
+ # Clear any residual content on current line before starting
75
+ self._clear_line()
76
+ self._renderable.text = Text(self._fit_to_terminal(self.text), style=self._style)
77
+ self._live = Live(
78
+ self._renderable,
79
+ console=self.console,
80
+ transient=True, # Remove spinner line when stopped to avoid layout glitches
81
+ refresh_per_second=12,
82
+ vertical_overflow="ellipsis", # Prevent multi-line overflow issues
25
83
  )
26
- self._status.__enter__()
84
+ self._live.start()
27
85
 
28
86
  def update(self, text: Optional[str] = None) -> None:
29
87
  """Update spinner text."""
30
-
31
- if self._status is None:
88
+ if self._live is None:
32
89
  return
33
- new_text = text if text is not None else self.text
34
- self._status.update(f"[cyan]{escape(new_text)}[/cyan]")
90
+ if text is not None:
91
+ self.text = text
92
+ self._renderable.text = Text(self._fit_to_terminal(self.text), style=self._style)
93
+ # Live.refresh() redraws the current renderable
94
+ self._live.refresh()
35
95
 
36
96
  def stop(self) -> None:
37
97
  """Stop the spinner if running."""
38
-
39
- if self._status is None:
98
+ if self._live is None:
40
99
  return
41
- self._status.__exit__(None, None, None)
42
- self._status = None
100
+ try:
101
+ self._live.stop()
102
+ # Clear line to ensure no artifacts remain from long spinner text
103
+ self._clear_line()
104
+ finally:
105
+ self._live = None
43
106
 
44
107
  def __enter__(self) -> "Spinner":
45
108
  self.start()
@@ -53,7 +116,7 @@ class Spinner:
53
116
  @property
54
117
  def is_running(self) -> bool:
55
118
  """Check if spinner is currently running."""
56
- return self._status is not None
119
+ return self._live is not None
57
120
 
58
121
  @contextmanager
59
122
  def paused(self) -> Generator[None, None, None]:
@@ -70,4 +133,16 @@ class Spinner:
70
133
  yield
71
134
  finally:
72
135
  if was_running:
136
+ # Ensure all output is flushed and cursor is on a clean line
137
+ # before restarting the spinner
138
+ try:
139
+ # Flush console buffer
140
+ self.console.file.flush()
141
+ # Clear any partial line content to prevent spinner
142
+ # from appearing on the same line as previous output
143
+ if self.console.is_terminal:
144
+ sys.stdout.write(_CLEAR_LINE)
145
+ sys.stdout.flush()
146
+ except Exception:
147
+ pass
73
148
  self.start()
@@ -22,7 +22,6 @@ THINKING_WORDS: list[str] = [
22
22
  "Cerebrating",
23
23
  "Channelling",
24
24
  "Churning",
25
- "Clauding",
26
25
  "Coalescing",
27
26
  "Cogitating",
28
27
  "Computing",
@@ -114,7 +113,7 @@ class ThinkingSpinner(Spinner):
114
113
 
115
114
  def _format_text(self, suffix: Optional[str] = None) -> str:
116
115
  elapsed = int(time.monotonic() - self.start_time)
117
- base = f" {self.thinking_word}… (esc to interrupt · {elapsed}s"
116
+ base = f" {self.thinking_word}… (esc to interrupt · {elapsed}s"
118
117
  if self.out_tokens > 0:
119
118
  base += f" · ↓ {self.out_tokens} tokens"
120
119
  else:
@@ -43,7 +43,7 @@ class TodoResultRenderer(ToolResultRenderer):
43
43
  if lines:
44
44
  self.console.print(f" ⎿ [dim]{escape(lines[0])}[/]")
45
45
  for line in lines[1:]:
46
- self.console.print(f" {line}", markup=False)
46
+ self.console.print(f" {line}", markup=False)
47
47
  else:
48
48
  self.console.print(" ⎿ [dim]Todo update[/]")
49
49
 
@@ -107,7 +107,7 @@ class GlobResultRenderer(ToolResultRenderer):
107
107
  if self.verbose:
108
108
  for line in files[:30]:
109
109
  if line.strip():
110
- self.console.print(f" {line}", markup=False)
110
+ self.console.print(f" {line}", markup=False)
111
111
  if file_count > 30:
112
112
  self.console.print(f"[dim]... ({file_count - 30} more)[/]")
113
113
 
@@ -125,7 +125,7 @@ class GrepResultRenderer(ToolResultRenderer):
125
125
  if self.verbose:
126
126
  for line in matches[:30]:
127
127
  if line.strip():
128
- self.console.print(f" {line}", markup=False)
128
+ self.console.print(f" {line}", markup=False)
129
129
  if match_count > 30:
130
130
  self.console.print(f"[dim]... ({match_count - 30} more)[/]")
131
131
 
@@ -142,7 +142,7 @@ class LSResultRenderer(ToolResultRenderer):
142
142
  if self.verbose:
143
143
  preview = tree_lines[:40]
144
144
  for line in preview:
145
- self.console.print(f" {line}", markup=False)
145
+ self.console.print(f" {line}", markup=False)
146
146
  if len(tree_lines) > len(preview):
147
147
  self.console.print(f"[dim]... ({len(tree_lines) - len(preview)} more)[/]")
148
148
 
@@ -193,7 +193,8 @@ class BashResultRenderer(ToolResultRenderer):
193
193
  preview = stdout_lines if self.verbose else stdout_lines[:5]
194
194
  self.console.print(f" ⎿ {preview[0]}", markup=False)
195
195
  for line in preview[1:]:
196
- self.console.print(f" {line}", markup=False)
196
+ # Use consistent 4-space indent to match the ⎿ prefix width
197
+ self.console.print(f" {line}", markup=False)
197
198
  if not self.verbose and len(stdout_lines) > len(preview):
198
199
  self.console.print(f"[dim]... ({len(stdout_lines) - len(preview)} more lines)[/]")
199
200
  else:
@@ -229,28 +230,28 @@ class BashResultRenderer(ToolResultRenderer):
229
230
  preview = stdout_lines if self.verbose else stdout_lines[:5]
230
231
  self.console.print("[dim]stdout:[/]")
231
232
  for line in preview:
232
- self.console.print(f" {line}", markup=False)
233
+ self.console.print(f" {line}", markup=False)
233
234
  if not self.verbose and len(stdout_lines) > len(preview):
234
235
  self.console.print(
235
236
  f"[dim]... ({len(stdout_lines) - len(preview)} more stdout lines)[/]"
236
237
  )
237
238
  else:
238
239
  self.console.print("[dim]stdout:[/]")
239
- self.console.print(" [dim](no stdout)[/]")
240
+ self.console.print(" [dim](no stdout)[/]")
240
241
 
241
242
  # Render stderr
242
243
  if stderr_lines:
243
244
  preview = stderr_lines if self.verbose else stderr_lines[:5]
244
245
  self.console.print("[dim]stderr:[/]")
245
246
  for line in preview:
246
- self.console.print(f" {line}", markup=False)
247
+ self.console.print(f" {line}", markup=False)
247
248
  if not self.verbose and len(stderr_lines) > len(preview):
248
249
  self.console.print(
249
250
  f"[dim]... ({len(stderr_lines) - len(preview)} more stderr lines)[/]"
250
251
  )
251
252
  else:
252
253
  self.console.print("[dim]stderr:[/]")
253
- self.console.print(" [dim](no stderr)[/]")
254
+ self.console.print(" [dim](no stderr)[/]")
254
255
 
255
256
 
256
257
  class ToolResultRendererRegistry:
@@ -2,6 +2,7 @@
2
2
  Interactive onboarding wizard for Ripperdoc.
3
3
  """
4
4
 
5
+ import os
5
6
  from typing import List, Optional, Tuple
6
7
 
7
8
  import click
@@ -46,6 +47,17 @@ def check_onboarding() -> bool:
46
47
  if config.has_completed_onboarding:
47
48
  return True
48
49
 
50
+ # 检查是否有有效的 RIPPERDOC_* 环境变量配置
51
+ # 如果设置了 RIPPERDOC_BASE_URL,可以跳过 onboarding
52
+ # 不写入配置文件,只在内存中处理
53
+ if os.getenv("RIPPERDOC_BASE_URL"):
54
+ # 在内存中标记已完成 onboarding,但不保存到配置文件
55
+ # 这样下次启动时如果环境变量存在仍然可以工作
56
+ config.has_completed_onboarding = True
57
+ config.last_onboarding_version = get_version()
58
+ save_global_config(config)
59
+ return True
60
+
49
61
  console.print("[bold cyan]Welcome to Ripperdoc![/bold cyan]\n")
50
62
  console.print("Let's set up your AI model configuration.\n")
51
63
 
@@ -93,14 +105,12 @@ def run_onboarding_wizard(config: GlobalConfig) -> bool:
93
105
  model_suggestions=(),
94
106
  )
95
107
  else:
96
- provider_option = KNOWN_PROVIDERS.get(provider_choice)
97
- if provider_option is None:
98
- provider_option = ProviderOption(
99
- key=provider_choice,
100
- protocol=ProviderType.OPENAI_COMPATIBLE,
101
- default_model=default_model_for_protocol(ProviderType.OPENAI_COMPATIBLE),
102
- model_suggestions=(),
103
- )
108
+ provider_option = KNOWN_PROVIDERS.get(provider_choice) or ProviderOption(
109
+ key=provider_choice,
110
+ protocol=ProviderType.OPENAI_COMPATIBLE,
111
+ default_model=default_model_for_protocol(ProviderType.OPENAI_COMPATIBLE),
112
+ model_suggestions=(),
113
+ )
104
114
 
105
115
  api_key = ""
106
116
  while not api_key:
@@ -137,7 +147,7 @@ def get_model_name_with_suggestions(
137
147
  api_base_override: Optional[str],
138
148
  ) -> Tuple[str, Optional[str]]:
139
149
  """Get model name with provider-specific suggestions and default API base.
140
-
150
+
141
151
  Returns:
142
152
  Tuple of (model_name, api_base)
143
153
  """
@@ -154,7 +164,7 @@ def get_model_name_with_suggestions(
154
164
  if suggestions:
155
165
  console.print("\n[dim]Available models for this provider:[/dim]")
156
166
  for i, model_name in enumerate(suggestions[:5]): # Show top 5
157
- console.print(f" [dim]{i+1}. {model_name}[/dim]")
167
+ console.print(f" [dim]{i + 1}. {model_name}[/dim]")
158
168
  console.print("")
159
169
 
160
170
  # Prompt for model name
@@ -164,16 +174,12 @@ def get_model_name_with_suggestions(
164
174
  model = click.prompt("Model name", default=default_model)
165
175
  # Prompt for API base if still not set
166
176
  if api_base is None:
167
- api_base_input = click.prompt(
168
- "API base URL (optional)", default="", show_default=False
169
- )
177
+ api_base_input = click.prompt("API base URL (optional)", default="", show_default=False)
170
178
  api_base = api_base_input or None
171
179
  elif provider.protocol == ProviderType.GEMINI:
172
180
  model = click.prompt("Model name", default=default_model)
173
181
  if api_base is None:
174
- api_base_input = click.prompt(
175
- "API base URL (optional)", default="", show_default=False
176
- )
182
+ api_base_input = click.prompt("API base URL (optional)", default="", show_default=False)
177
183
  api_base = api_base_input or None
178
184
  else:
179
185
  model = click.prompt("Model name", default=default_model)
@@ -193,9 +199,7 @@ def get_context_window() -> Optional[int]:
193
199
  try:
194
200
  context_window = int(context_window_input.strip())
195
201
  except ValueError:
196
- console.print(
197
- "[yellow]Invalid context window, using auto-detected defaults.[/yellow]"
198
- )
202
+ console.print("[yellow]Invalid context window, using auto-detected defaults.[/yellow]")
199
203
  return context_window
200
204
 
201
205
 
@@ -203,6 +207,7 @@ def get_version() -> str:
203
207
  """Get current version of Ripperdoc."""
204
208
  try:
205
209
  from ripperdoc import __version__
210
+
206
211
  return __version__
207
212
  except ImportError:
208
213
  return "unknown"
ripperdoc/core/agents.py CHANGED
@@ -10,6 +10,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
10
10
 
11
11
  import yaml
12
12
 
13
+ from ripperdoc.utils.coerce import parse_boolish
13
14
  from ripperdoc.utils.log import get_logger
14
15
  from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
15
16
  from ripperdoc.tools.bash_output_tool import BashOutputTool
@@ -23,8 +24,10 @@ from ripperdoc.tools.glob_tool import GlobTool
23
24
  from ripperdoc.tools.grep_tool import GrepTool
24
25
  from ripperdoc.tools.kill_bash_tool import KillBashTool
25
26
  from ripperdoc.tools.ls_tool import LSTool
27
+ from ripperdoc.tools.lsp_tool import LspTool
26
28
  from ripperdoc.tools.multi_edit_tool import MultiEditTool
27
29
  from ripperdoc.tools.notebook_edit_tool import NotebookEditTool
30
+ from ripperdoc.tools.skill_tool import SkillTool
28
31
  from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
29
32
  from ripperdoc.tools.tool_search_tool import ToolSearchTool
30
33
  from ripperdoc.tools.mcp_tools import (
@@ -65,6 +68,8 @@ TOOL_SEARCH_TOOL_NAME = _safe_tool_name(ToolSearchTool, "ToolSearch")
65
68
  MCP_LIST_SERVERS_TOOL_NAME = _safe_tool_name(ListMcpServersTool, "ListMcpServers")
66
69
  MCP_LIST_RESOURCES_TOOL_NAME = _safe_tool_name(ListMcpResourcesTool, "ListMcpResources")
67
70
  MCP_READ_RESOURCE_TOOL_NAME = _safe_tool_name(ReadMcpResourceTool, "ReadMcpResource")
71
+ LSP_TOOL_NAME = _safe_tool_name(LspTool, "LSP")
72
+ SKILL_TOOL_NAME = _safe_tool_name(SkillTool, "Skill")
68
73
  TASK_TOOL_NAME = "Task"
69
74
 
70
75
 
@@ -91,6 +96,7 @@ class AgentDefinition:
91
96
  model: Optional[str] = None
92
97
  color: Optional[str] = None
93
98
  filename: Optional[str] = None
99
+ fork_context: bool = False
94
100
 
95
101
 
96
102
  @dataclass
@@ -234,7 +240,7 @@ def _built_in_agents() -> List[AgentDefinition]:
234
240
  system_prompt=EXPLORE_AGENT_PROMPT,
235
241
  location=AgentLocation.BUILT_IN,
236
242
  color="green",
237
- model="task",
243
+ model="main",
238
244
  ),
239
245
  AgentDefinition(
240
246
  agent_type="plan",
@@ -324,8 +330,9 @@ def _parse_agent_file(
324
330
  return None, f"Failed to read agent file {path}: {exc}"
325
331
 
326
332
  frontmatter, body = _split_frontmatter(text)
327
- if "__error__" in frontmatter:
328
- return None, str(frontmatter["__error__"])
333
+ error = frontmatter.get("__error__")
334
+ if error is not None:
335
+ return None, str(error)
329
336
 
330
337
  agent_name = frontmatter.get("name")
331
338
  description = frontmatter.get("description")
@@ -339,6 +346,7 @@ def _parse_agent_file(
339
346
  color_value = frontmatter.get("color")
340
347
  model = model_value if isinstance(model_value, str) else None
341
348
  color = color_value if isinstance(color_value, str) else None
349
+ fork_context = parse_boolish(frontmatter.get("fork_context") or frontmatter.get("fork-context"))
342
350
 
343
351
  agent = AgentDefinition(
344
352
  agent_type=agent_name.strip(),
@@ -349,6 +357,7 @@ def _parse_agent_file(
349
357
  model=model,
350
358
  color=color,
351
359
  filename=path.stem,
360
+ fork_context=fork_context,
352
361
  )
353
362
  return agent, None
354
363
 
@@ -404,6 +413,8 @@ def summarize_agent(agent: AgentDefinition) -> str:
404
413
  tool_label = "all tools" if "*" in agent.tools else ", ".join(agent.tools)
405
414
  location = getattr(agent.location, "value", agent.location)
406
415
  details = [f"tools: {tool_label}"]
416
+ if agent.fork_context:
417
+ details.append("context: forked")
407
418
  if agent.model:
408
419
  details.append(f"model: {agent.model}")
409
420
  return f"- {agent.agent_type} ({location}): {agent.when_to_use} [{'; '.join(details)}]"