claude-mpm 5.4.85__py3-none-any.whl → 5.4.88__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 (102) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/PM_INSTRUCTIONS.md +90 -9
  3. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.B7S5qgOx.css +1 -0
  4. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.D3t4z6uz.css +1 -0
  5. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DmxopI1J.js → 14Ru8gxt.js} +1 -1
  6. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CDuw-vjf.js → 7ZAeO_Uj.js} +1 -1
  7. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/AeivYILh.js +1 -0
  8. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DaimHw_p.js → B06ALsCS.js} +1 -1
  9. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{bTOqqlTd.js → BCWDw8BF.js} +1 -1
  10. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Cs_tUR18.js → BS0ej2w8.js} +1 -1
  11. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DwBR2MJi.js → BVFqgd56.js} +1 -1
  12. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CzeYkLYB.js → BXs4CVzO.js} +2 -2
  13. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BovzEFCE.js → B_fnSNFx.js} +1 -1
  14. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{iEWssX7S.js → BfMC7wDI.js} +1 -1
  15. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CyWMqx4W.js → BfXd4Xj4.js} +1 -1
  16. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CBBdVcY8.js → BnFPFynJ.js} +1 -1
  17. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{B0uc0UOD.js → BwpSELyW.js} +3 -3
  18. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BPYeabCQ.js → C5Dg_JxJ.js} +1 -1
  19. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DLVjFsZ3.js → C7i47te_.js} +1 -1
  20. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CnA0NrzZ.js → C86quetY.js} +1 -1
  21. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{ZGh7QtNv.js → CDNOxKrg.js} +1 -1
  22. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BIF9m_hv.js → CDi5wzaD.js} +1 -1
  23. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Be7GpZd6.js → CQ94FMOU.js} +1 -1
  24. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CzZX-COe.js → CT5eAo1x.js} +1 -1
  25. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{GYwsonyD.js → C_l0vq62.js} +1 -1
  26. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Cn4nXAfg.js +1 -0
  27. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DI7hHRFL.js → CvWciI1W.js} +1 -1
  28. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{sQeU3Y1z.js → CyrTH56Q.js} +1 -1
  29. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Bh0LDWpI.js → CzkNB1Vu.js} +2 -2
  30. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DUrLdbGD.js → D0Fj1OdD.js} +1 -1
  31. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Dle-35c7.js → D1ARDjz0.js} +2 -2
  32. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C4B-KCzX.js → D2nGpDRe.js} +1 -1
  33. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BofRWZRR.js → DJN4AVXS.js} +1 -1
  34. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{B7RN905-.js → DJuK4-OP.js} +1 -1
  35. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C30mlcqg.js → DLeM8wSV.js} +1 -1
  36. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{uuIeMWc-.js → DNI1jw9S.js} +1 -1
  37. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C4JcI4KD.js → DOViuQX_.js} +1 -1
  38. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{D3k0OPJN.js → DOeJfApz.js} +1 -1
  39. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Cfqx1Qun.js → DTgfNBV9.js} +1 -1
  40. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CiIAseT4.js → DWDi9IaK.js} +5 -5
  41. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/D_vpdI7l.js +325 -0
  42. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Dhb8PKl3.js → DdIDcQsD.js} +1 -1
  43. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DnL7ky1O.js +1 -0
  44. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DGkLK5U1.js → Dqtg3hb8.js} +1 -1
  45. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{eNVUfhuA.js → EKp_wsKE.js} +1 -1
  46. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{D9lljYKQ.js → MJf6AOIJ.js} +1 -1
  47. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{B7xVLGWV.js → NsEh4Ivo.js} +1 -1
  48. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/URSAF6IJ.js +24 -0
  49. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DZX00Y4g.js → WiqB4NUY.js} +1 -1
  50. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{bT1r9zLR.js → s04HIjWg.js} +1 -1
  51. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C_Usid8X.js → vJiSSdpk.js} +2 -2
  52. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/{app.D6-I5TpK.js → app.CnXU_fEX.js} +2 -2
  53. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.CUaAfoQJ.js +1 -0
  54. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/{1.CgNOuw-d.js → 1.znyTz9u3.js} +1 -1
  55. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.CLVHDDxl.js +1 -0
  56. claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -1
  57. claude_mpm/dashboard/static/svelte-build/index.html +7 -7
  58. claude_mpm/hooks/claude_hooks/INTEGRATION_EXAMPLE.md +243 -0
  59. claude_mpm/hooks/claude_hooks/README_AUTO_PAUSE.md +403 -0
  60. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  61. claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-311.pyc +0 -0
  62. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  63. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  64. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  65. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  66. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  67. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  68. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  69. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +486 -0
  70. claude_mpm/hooks/claude_hooks/event_handlers.py +74 -1
  71. claude_mpm/hooks/claude_hooks/hook_handler.py +25 -3
  72. claude_mpm/hooks/claude_hooks/response_tracking.py +17 -1
  73. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  74. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  75. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  76. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  77. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  78. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  79. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +30 -6
  80. claude_mpm/hooks/session_resume_hook.py +85 -1
  81. claude_mpm/services/cli/__init__.py +3 -0
  82. claude_mpm/services/cli/incremental_pause_manager.py +561 -0
  83. claude_mpm/services/infrastructure/__init__.py +4 -0
  84. claude_mpm/services/infrastructure/context_usage_tracker.py +291 -0
  85. claude_mpm/services/infrastructure/resume_log_generator.py +24 -5
  86. {claude_mpm-5.4.85.dist-info → claude_mpm-5.4.88.dist-info}/METADATA +1 -1
  87. {claude_mpm-5.4.85.dist-info → claude_mpm-5.4.88.dist-info}/RECORD +93 -73
  88. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.DWzvg0-y.css +0 -1
  89. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.ThTw9_ym.css +0 -1
  90. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/5shd3_w0.js +0 -24
  91. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BKjSRqUr.js +0 -1
  92. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Da0KfYnO.js +0 -1
  93. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Dfy6j1xT.js +0 -323
  94. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/RJiighC3.js +0 -1
  95. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.NWzMBYRp.js +0 -1
  96. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.C0GcWctS.js +0 -1
  97. /claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/{0.m1gL8KXf.js → 0.BuxSUm_s.js} +0 -0
  98. {claude_mpm-5.4.85.dist-info → claude_mpm-5.4.88.dist-info}/WHEEL +0 -0
  99. {claude_mpm-5.4.85.dist-info → claude_mpm-5.4.88.dist-info}/entry_points.txt +0 -0
  100. {claude_mpm-5.4.85.dist-info → claude_mpm-5.4.88.dist-info}/licenses/LICENSE +0 -0
  101. {claude_mpm-5.4.85.dist-info → claude_mpm-5.4.88.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  102. {claude_mpm-5.4.85.dist-info → claude_mpm-5.4.88.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env python3
2
+ """Auto-pause handler for Claude Code hooks.
3
+
4
+ WHY: Automatically pause Claude sessions when context usage reaches 90% to prevent
5
+ context window exhaustion. Integrates with existing hook infrastructure to monitor
6
+ token usage and trigger incremental pause capture.
7
+
8
+ DESIGN DECISIONS:
9
+ - Integrates with ContextUsageTracker for token tracking across hook invocations
10
+ - Uses IncrementalPauseManager for capturing actions during pause mode
11
+ - Thread-safe - handles hook calls from multiple processes via file-based state
12
+ - Emits warnings to stderr for visibility without breaking hook flow
13
+ - Only triggers auto-pause on NEW threshold crossings (prevents duplicate warnings)
14
+ - Graceful error handling - auto-pause failures don't break main hook processing
15
+
16
+ USAGE:
17
+ # Initialize handler in hook handler
18
+ auto_pause = AutoPauseHandler()
19
+
20
+ # Monitor token usage from API responses
21
+ if "usage" in metadata:
22
+ threshold_crossed = auto_pause.on_usage_update(metadata["usage"])
23
+ if threshold_crossed:
24
+ warning = auto_pause.emit_threshold_warning(threshold_crossed)
25
+ print(f"\n⚠️ {warning}", file=sys.stderr)
26
+
27
+ # Record actions during pause mode
28
+ if auto_pause.is_pause_active():
29
+ auto_pause.on_tool_call(tool_name, tool_args)
30
+ auto_pause.on_assistant_response(response_summary)
31
+
32
+ # Finalize on session end
33
+ session_file = auto_pause.on_session_end()
34
+ """
35
+
36
+ import os
37
+ import sys
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+ from typing import Any, Dict, Optional
41
+
42
+ from claude_mpm.core.logger import get_logger
43
+ from claude_mpm.services.cli.incremental_pause_manager import IncrementalPauseManager
44
+ from claude_mpm.services.infrastructure.context_usage_tracker import (
45
+ ContextUsageTracker,
46
+ )
47
+
48
+ logger = get_logger(__name__)
49
+
50
+ # Debug mode
51
+ DEBUG = os.environ.get("CLAUDE_MPM_HOOK_DEBUG", "true").lower() != "false"
52
+
53
+ # Warning messages for threshold crossings
54
+ THRESHOLD_WARNINGS = {
55
+ "caution": "Context usage at 70%. Consider wrapping up current work.",
56
+ "warning": "Context usage at 85%. Session nearing capacity.",
57
+ "auto_pause": "Context usage at 90%. Auto-pause activated. Actions are being recorded for session continuity.",
58
+ "critical": "Context usage at 95%. Session nearly exhausted. Wrapping up...",
59
+ }
60
+
61
+ # Maximum length for summaries to avoid storing full responses
62
+ MAX_SUMMARY_LENGTH = 500
63
+
64
+
65
+ class AutoPauseHandler:
66
+ """Handler for automatic session pausing based on context usage thresholds.
67
+
68
+ Integrates with Claude Code hooks to:
69
+ 1. Track cumulative token usage from API responses
70
+ 2. Trigger auto-pause when 90% context used
71
+ 3. Capture all subsequent actions during pause mode
72
+ 4. Emit warnings/notifications to user
73
+
74
+ Features:
75
+ - File-based state persistence (works across hook process restarts)
76
+ - Thread-safe through atomic file operations
77
+ - Graceful error handling (failures don't break main hook flow)
78
+ - Only emits warnings on NEW threshold crossings
79
+ - Summarizes long content to prevent memory bloat
80
+ """
81
+
82
+ def __init__(self, project_path: Optional[Path] = None):
83
+ """Initialize auto-pause handler.
84
+
85
+ Args:
86
+ project_path: Project root path (default: current directory)
87
+ """
88
+ self.project_path = (project_path or Path.cwd()).resolve()
89
+
90
+ # Initialize services
91
+ self.tracker = ContextUsageTracker(self.project_path)
92
+ self.pause_manager = IncrementalPauseManager(self.project_path)
93
+
94
+ # Track previous threshold to detect NEW crossings
95
+ self._previous_threshold: Optional[str] = None
96
+
97
+ # Load initial state
98
+ try:
99
+ current_state = self.tracker.get_current_state()
100
+ self._previous_threshold = current_state.threshold_reached
101
+
102
+ if DEBUG:
103
+ print(
104
+ f"AutoPauseHandler initialized: "
105
+ f"{current_state.percentage_used:.1f}% context used, "
106
+ f"threshold: {current_state.threshold_reached}",
107
+ file=sys.stderr,
108
+ )
109
+ except Exception as e:
110
+ logger.error(f"Failed to initialize AutoPauseHandler: {e}")
111
+ # Continue with None - will initialize on first update
112
+
113
+ def on_usage_update(self, usage: Dict[str, Any]) -> Optional[str]:
114
+ """Process token usage from a Claude API response.
115
+
116
+ Args:
117
+ usage: Dict with 'input_tokens', 'output_tokens',
118
+ 'cache_creation_input_tokens', 'cache_read_input_tokens'
119
+
120
+ Returns:
121
+ Threshold name if a NEW threshold was crossed ('caution', 'warning',
122
+ 'auto_pause', 'critical'), or None if no new threshold crossed.
123
+
124
+ Raises:
125
+ ValueError: If usage data is invalid
126
+ """
127
+ try:
128
+ # Extract token counts
129
+ input_tokens = usage.get("input_tokens", 0)
130
+ output_tokens = usage.get("output_tokens", 0)
131
+ cache_creation = usage.get("cache_creation_input_tokens", 0)
132
+ cache_read = usage.get("cache_read_input_tokens", 0)
133
+
134
+ # Validate token counts
135
+ if any(
136
+ t < 0 for t in [input_tokens, output_tokens, cache_creation, cache_read]
137
+ ):
138
+ raise ValueError("Token counts cannot be negative")
139
+
140
+ # Update cumulative usage
141
+ state = self.tracker.update_usage(
142
+ input_tokens=input_tokens,
143
+ output_tokens=output_tokens,
144
+ cache_creation=cache_creation,
145
+ cache_read=cache_read,
146
+ )
147
+
148
+ # Check if we crossed a NEW threshold
149
+ current_threshold = state.threshold_reached
150
+ new_threshold_crossed = None
151
+
152
+ if current_threshold != self._previous_threshold:
153
+ # Determine if this is a higher threshold
154
+ threshold_order = ["caution", "warning", "auto_pause", "critical"]
155
+
156
+ prev_idx = (
157
+ threshold_order.index(self._previous_threshold)
158
+ if self._previous_threshold in threshold_order
159
+ else -1
160
+ )
161
+ curr_idx = (
162
+ threshold_order.index(current_threshold)
163
+ if current_threshold in threshold_order
164
+ else -1
165
+ )
166
+
167
+ if curr_idx > prev_idx:
168
+ new_threshold_crossed = current_threshold
169
+ self._previous_threshold = current_threshold
170
+
171
+ if DEBUG:
172
+ print(
173
+ f"Context threshold crossed: {current_threshold} "
174
+ f"({state.percentage_used:.1f}%)",
175
+ file=sys.stderr,
176
+ )
177
+
178
+ # Trigger auto-pause if threshold reached
179
+ if current_threshold in ["auto_pause", "critical"]:
180
+ self._trigger_auto_pause(state)
181
+
182
+ return new_threshold_crossed
183
+
184
+ except Exception as e:
185
+ logger.error(f"Failed to update usage: {e}")
186
+ if DEBUG:
187
+ print(f"❌ Usage update failed: {e}", file=sys.stderr)
188
+ # Don't propagate error - auto-pause is optional
189
+ return None
190
+
191
+ def on_tool_call(self, tool_name: str, tool_args: Dict[str, Any]) -> None:
192
+ """Record a tool call if auto-pause is active.
193
+
194
+ Args:
195
+ tool_name: Name of the tool being called
196
+ tool_args: Tool arguments dictionary
197
+
198
+ Raises:
199
+ RuntimeError: If append operation fails (optional, logged only)
200
+ """
201
+ if not self.is_pause_active():
202
+ return
203
+
204
+ try:
205
+ # Summarize tool args to avoid storing large data
206
+ args_summary = self._summarize_dict(tool_args)
207
+
208
+ # Get current context percentage
209
+ state = self.tracker.get_current_state()
210
+
211
+ # Record action
212
+ self.pause_manager.append_action(
213
+ action_type="tool_call",
214
+ action_data={
215
+ "tool": tool_name,
216
+ "args_summary": args_summary,
217
+ "timestamp": datetime.now(timezone.utc).isoformat(),
218
+ },
219
+ context_percentage=state.percentage_used / 100,
220
+ )
221
+
222
+ if DEBUG:
223
+ print(f"Recorded tool call during pause: {tool_name}", file=sys.stderr)
224
+
225
+ except Exception as e:
226
+ logger.error(f"Failed to record tool call: {e}")
227
+ if DEBUG:
228
+ print(f"❌ Failed to record tool call: {e}", file=sys.stderr)
229
+
230
+ def on_assistant_response(self, response_summary: str) -> None:
231
+ """Record an assistant response if auto-pause is active.
232
+
233
+ Args:
234
+ response_summary: Summary of assistant response (will be truncated)
235
+
236
+ Raises:
237
+ RuntimeError: If append operation fails (optional, logged only)
238
+ """
239
+ if not self.is_pause_active():
240
+ return
241
+
242
+ try:
243
+ # Truncate long responses
244
+ summary = self._truncate_text(response_summary, MAX_SUMMARY_LENGTH)
245
+
246
+ # Get current context percentage
247
+ state = self.tracker.get_current_state()
248
+
249
+ # Record action
250
+ self.pause_manager.append_action(
251
+ action_type="assistant_response",
252
+ action_data={
253
+ "summary": summary,
254
+ "timestamp": datetime.now(timezone.utc).isoformat(),
255
+ },
256
+ context_percentage=state.percentage_used / 100,
257
+ )
258
+
259
+ if DEBUG:
260
+ print(
261
+ f"Recorded assistant response during pause (length: {len(summary)})",
262
+ file=sys.stderr,
263
+ )
264
+
265
+ except Exception as e:
266
+ logger.error(f"Failed to record assistant response: {e}")
267
+ if DEBUG:
268
+ print(f"❌ Failed to record assistant response: {e}", file=sys.stderr)
269
+
270
+ def on_user_message(self, message_summary: str) -> None:
271
+ """Record a user message if auto-pause is active.
272
+
273
+ Args:
274
+ message_summary: Summary of user message (will be truncated)
275
+
276
+ Raises:
277
+ RuntimeError: If append operation fails (optional, logged only)
278
+ """
279
+ if not self.is_pause_active():
280
+ return
281
+
282
+ try:
283
+ # Truncate long messages
284
+ summary = self._truncate_text(message_summary, MAX_SUMMARY_LENGTH)
285
+
286
+ # Get current context percentage
287
+ state = self.tracker.get_current_state()
288
+
289
+ # Record action
290
+ self.pause_manager.append_action(
291
+ action_type="user_message",
292
+ action_data={
293
+ "summary": summary,
294
+ "timestamp": datetime.now(timezone.utc).isoformat(),
295
+ },
296
+ context_percentage=state.percentage_used / 100,
297
+ )
298
+
299
+ if DEBUG:
300
+ print(
301
+ f"Recorded user message during pause (length: {len(summary)})",
302
+ file=sys.stderr,
303
+ )
304
+
305
+ except Exception as e:
306
+ logger.error(f"Failed to record user message: {e}")
307
+ if DEBUG:
308
+ print(f"❌ Failed to record user message: {e}", file=sys.stderr)
309
+
310
+ def on_session_end(self) -> Optional[Path]:
311
+ """Called when session ends. Finalizes any active pause.
312
+
313
+ Returns:
314
+ Path to finalized session file, or None if no pause was active.
315
+
316
+ Raises:
317
+ RuntimeError: If finalization fails
318
+ """
319
+ if not self.is_pause_active():
320
+ if DEBUG:
321
+ print("No active pause to finalize", file=sys.stderr)
322
+ return None
323
+
324
+ try:
325
+ # Finalize the pause session
326
+ session_path = self.pause_manager.finalize_pause(create_full_snapshot=True)
327
+
328
+ if session_path and DEBUG:
329
+ print(f"✅ Session finalized: {session_path.name}", file=sys.stderr)
330
+
331
+ return session_path
332
+
333
+ except Exception as e:
334
+ logger.error(f"Failed to finalize pause session: {e}")
335
+ if DEBUG:
336
+ print(f"❌ Failed to finalize pause: {e}", file=sys.stderr)
337
+ raise
338
+
339
+ def is_pause_active(self) -> bool:
340
+ """Check if auto-pause mode is currently active.
341
+
342
+ Returns:
343
+ True if auto-pause has been triggered and is capturing actions
344
+ """
345
+ return self.pause_manager.is_pause_active()
346
+
347
+ def get_status(self) -> Dict[str, Any]:
348
+ """Get current status for display/logging.
349
+
350
+ Returns:
351
+ Dict with: context_percentage, threshold_reached,
352
+ pause_active, actions_recorded, etc.
353
+ """
354
+ try:
355
+ state = self.tracker.get_current_state()
356
+ pause_summary = self.pause_manager.get_pause_summary()
357
+
358
+ status = {
359
+ "context_percentage": round(state.percentage_used, 2),
360
+ "threshold_reached": state.threshold_reached,
361
+ "auto_pause_active": state.auto_pause_active,
362
+ "pause_active": self.is_pause_active(),
363
+ "session_id": state.session_id,
364
+ "total_tokens": (
365
+ state.cumulative_input_tokens + state.cumulative_output_tokens
366
+ ),
367
+ "budget": ContextUsageTracker.CONTEXT_BUDGET,
368
+ }
369
+
370
+ # Add pause details if active
371
+ if pause_summary:
372
+ status["pause_details"] = {
373
+ "action_count": pause_summary["action_count"],
374
+ "duration_seconds": pause_summary["duration_seconds"],
375
+ "context_range": pause_summary["context_range"],
376
+ "last_action_type": pause_summary["last_action_type"],
377
+ }
378
+
379
+ return status
380
+
381
+ except Exception as e:
382
+ logger.error(f"Failed to get status: {e}")
383
+ return {"error": str(e)}
384
+
385
+ def emit_threshold_warning(self, threshold: str) -> str:
386
+ """Generate a warning message for threshold crossing.
387
+
388
+ Args:
389
+ threshold: Threshold name ('caution', 'warning', 'auto_pause', 'critical')
390
+
391
+ Returns:
392
+ User-friendly warning message string
393
+ """
394
+ warning = THRESHOLD_WARNINGS.get(
395
+ threshold, f"Context usage threshold reached: {threshold}"
396
+ )
397
+
398
+ # Add context percentage to warning
399
+ try:
400
+ state = self.tracker.get_current_state()
401
+ warning = f"{warning} ({state.percentage_used:.1f}%)"
402
+ except Exception:
403
+ pass # nosec B110 - Intentionally ignore formatting errors, warning is already constructed
404
+
405
+ return warning
406
+
407
+ def _trigger_auto_pause(self, state) -> None:
408
+ """Trigger auto-pause and start recording actions.
409
+
410
+ Args:
411
+ state: Current context usage state
412
+
413
+ Raises:
414
+ RuntimeError: If pause cannot be started
415
+ """
416
+ try:
417
+ # Check if pause is already active
418
+ if self.is_pause_active():
419
+ if DEBUG:
420
+ print(
421
+ "Auto-pause already active, skipping trigger", file=sys.stderr
422
+ )
423
+ return
424
+
425
+ # Start incremental pause
426
+ session_id = self.pause_manager.start_incremental_pause(
427
+ context_percentage=state.percentage_used / 100,
428
+ initial_state=state.__dict__,
429
+ )
430
+
431
+ if DEBUG:
432
+ print(
433
+ f"✅ Auto-pause triggered: {session_id} "
434
+ f"({state.percentage_used:.1f}% context used)",
435
+ file=sys.stderr,
436
+ )
437
+
438
+ except Exception as e:
439
+ logger.error(f"Failed to trigger auto-pause: {e}")
440
+ if DEBUG:
441
+ print(f"❌ Failed to trigger auto-pause: {e}", file=sys.stderr)
442
+ # Don't propagate - auto-pause is optional
443
+
444
+ def _summarize_dict(
445
+ self, data: Dict[str, Any], max_items: int = 10
446
+ ) -> Dict[str, Any]:
447
+ """Create a summary of a dictionary by limiting items and truncating values.
448
+
449
+ Args:
450
+ data: Dictionary to summarize
451
+ max_items: Maximum number of items to include
452
+
453
+ Returns:
454
+ Summarized dictionary
455
+ """
456
+ summary = {}
457
+
458
+ for i, (key, value) in enumerate(data.items()):
459
+ if i >= max_items:
460
+ summary["..."] = f"({len(data) - max_items} more items)"
461
+ break
462
+
463
+ # Truncate string values
464
+ if isinstance(value, str):
465
+ summary[key] = self._truncate_text(value, 100)
466
+ elif isinstance(value, (list, dict)):
467
+ summary[key] = f"<{type(value).__name__} with {len(value)} items>"
468
+ else:
469
+ summary[key] = str(value)[:100]
470
+
471
+ return summary
472
+
473
+ def _truncate_text(self, text: str, max_length: int) -> str:
474
+ """Truncate text to maximum length with ellipsis.
475
+
476
+ Args:
477
+ text: Text to truncate
478
+ max_length: Maximum length
479
+
480
+ Returns:
481
+ Truncated text with "..." suffix if truncated
482
+ """
483
+ if len(text) <= max_length:
484
+ return text
485
+
486
+ return text[: max_length - 3] + "..."
@@ -7,7 +7,7 @@ Claude Code hook events.
7
7
 
8
8
  import os
9
9
  import re
10
- import subprocess
10
+ import subprocess # nosec B404 - subprocess used for safe claude CLI version checking only
11
11
  import sys
12
12
  import uuid
13
13
  from datetime import datetime, timezone
@@ -189,6 +189,15 @@ class EventHandlers:
189
189
  if tool_name == "Task" and isinstance(tool_input, dict):
190
190
  self._handle_task_delegation(tool_input, pre_tool_data, session_id)
191
191
 
192
+ # Record tool call for auto-pause if active
193
+ auto_pause = getattr(self.hook_handler, "auto_pause_handler", None)
194
+ if auto_pause and auto_pause.is_pause_active():
195
+ try:
196
+ auto_pause.on_tool_call(tool_name, tool_input)
197
+ except Exception as e:
198
+ if DEBUG:
199
+ print(f"Auto-pause tool recording error: {e}", file=sys.stderr)
200
+
192
201
  self.hook_handler._emit_socketio_event("", "pre_tool", pre_tool_data)
193
202
 
194
203
  def _handle_task_delegation(
@@ -852,6 +861,21 @@ class EventHandlers:
852
861
  file=sys.stderr,
853
862
  )
854
863
 
864
+ # Record assistant response for auto-pause if active
865
+ auto_pause = getattr(self.hook_handler, "auto_pause_handler", None)
866
+ if auto_pause and auto_pause.is_pause_active():
867
+ try:
868
+ # Summarize response to first 200 chars
869
+ summary = (
870
+ response_text[:200] + "..."
871
+ if len(response_text) > 200
872
+ else response_text
873
+ )
874
+ auto_pause.on_assistant_response(summary)
875
+ except Exception as e:
876
+ if DEBUG:
877
+ print(f"Auto-pause response recording error: {e}", file=sys.stderr)
878
+
855
879
  # Emit normalized event
856
880
  self.hook_handler._emit_socketio_event(
857
881
  "", "assistant_response", assistant_response_data
@@ -886,3 +910,52 @@ class EventHandlers:
886
910
 
887
911
  # Emit normalized event
888
912
  self.hook_handler._emit_socketio_event("", "session_start", session_start_data)
913
+
914
+ def handle_subagent_start_fast(self, event):
915
+ """Handle SubagentStart events with proper agent type extraction.
916
+
917
+ WHY separate from SessionStart:
918
+ - SubagentStart contains agent-specific information
919
+ - Frontend needs agent_type to create distinct agent nodes
920
+ - Multiple engineers should show as separate nodes in hierarchy
921
+ - Research agents must appear in the agent hierarchy
922
+
923
+ Unlike SessionStart, SubagentStart events contain agent-specific
924
+ information that must be preserved and emitted to the dashboard.
925
+ """
926
+ session_id = event.get("session_id", "")
927
+
928
+ # Extract agent type from event - Claude provides this in SubagentStart
929
+ # Try multiple possible field names for compatibility
930
+ agent_type = event.get("agent_type") or event.get("subagent_type") or "unknown"
931
+
932
+ # Generate unique agent ID combining type and session
933
+ agent_id = event.get("agent_id", f"{agent_type}_{session_id[:8]}")
934
+
935
+ # Get working directory and git branch
936
+ working_dir = event.get("cwd", "")
937
+ git_branch = self._get_git_branch(working_dir) if working_dir else "Unknown"
938
+
939
+ # Build subagent start data with all required fields
940
+ subagent_start_data = {
941
+ "session_id": session_id,
942
+ "agent_type": agent_type,
943
+ "agent_id": agent_id,
944
+ "timestamp": datetime.now(timezone.utc).isoformat(),
945
+ "hook_event_name": "SubagentStart", # Preserve correct hook name
946
+ "working_directory": working_dir,
947
+ "git_branch": git_branch,
948
+ }
949
+
950
+ # Debug logging
951
+ if DEBUG:
952
+ print(
953
+ f"Hook handler: SubagentStart - agent_type='{agent_type}', "
954
+ f"agent_id='{agent_id}', session_id='{session_id[:16]}...'",
955
+ file=sys.stderr,
956
+ )
957
+
958
+ # Emit to /hook namespace as subagent_start (NOT session_start!)
959
+ self.hook_handler._emit_socketio_event(
960
+ "", "subagent_start", subagent_start_data
961
+ )
@@ -31,6 +31,7 @@ from typing import Optional, Tuple
31
31
  # Import extracted modules with fallback for direct execution
32
32
  try:
33
33
  # Try relative imports first (when imported as module)
34
+ from .auto_pause_handler import AutoPauseHandler
34
35
  from .event_handlers import EventHandlers
35
36
  from .memory_integration import MemoryHookManager
36
37
  from .response_tracking import ResponseTrackingManager
@@ -47,6 +48,7 @@ except ImportError:
47
48
  # Add parent directory to path
48
49
  sys.path.insert(0, str(Path(__file__).parent))
49
50
 
51
+ from auto_pause_handler import AutoPauseHandler
50
52
  from event_handlers import EventHandlers
51
53
  from memory_integration import MemoryHookManager
52
54
  from response_tracking import ResponseTrackingManager
@@ -153,7 +155,7 @@ def check_claude_version() -> Tuple[bool, Optional[str]]:
153
155
  """
154
156
  try:
155
157
  # Try to detect Claude Code version
156
- result = subprocess.run(
158
+ result = subprocess.run( # nosec B603 - Safe: hardcoded claude CLI with --version flag, no user input
157
159
  ["claude", "--version"],
158
160
  capture_output=True,
159
161
  text=True,
@@ -230,6 +232,19 @@ class ClaudeHookHandler:
230
232
  self.state_manager, self.response_tracking_manager, self.connection_manager
231
233
  )
232
234
 
235
+ # Initialize auto-pause handler
236
+ try:
237
+ self.auto_pause_handler = AutoPauseHandler()
238
+ # Pass reference to ResponseTrackingManager so it can call auto_pause
239
+ if hasattr(self, "response_tracking_manager"):
240
+ self.response_tracking_manager.auto_pause_handler = (
241
+ self.auto_pause_handler
242
+ )
243
+ except Exception as e:
244
+ self.auto_pause_handler = None
245
+ if DEBUG:
246
+ print(f"Auto-pause initialization failed: {e}", file=sys.stderr)
247
+
233
248
  # Backward compatibility properties for tests
234
249
  self.connection_pool = self.connection_manager.connection_pool
235
250
 
@@ -419,7 +434,7 @@ class ClaudeHookHandler:
419
434
  "Notification": self.event_handlers.handle_notification_fast,
420
435
  "Stop": self.event_handlers.handle_stop_fast,
421
436
  "SubagentStop": self.event_handlers.handle_subagent_stop_fast,
422
- "SubagentStart": self.event_handlers.handle_session_start_fast,
437
+ "SubagentStart": self.event_handlers.handle_subagent_start_fast,
423
438
  "SessionStart": self.event_handlers.handle_session_start_fast,
424
439
  "AssistantResponse": self.event_handlers.handle_assistant_response,
425
440
  }
@@ -628,12 +643,19 @@ class ClaudeHookHandler:
628
643
 
629
644
  def __del__(self):
630
645
  """Cleanup on handler destruction."""
646
+ # Finalize any active auto-pause session
647
+ if hasattr(self, "auto_pause_handler") and self.auto_pause_handler:
648
+ try:
649
+ self.auto_pause_handler.on_session_end()
650
+ except Exception:
651
+ pass # nosec B110 - Intentionally ignore cleanup errors during handler destruction
652
+
631
653
  # Clean up connection manager if it exists
632
654
  if hasattr(self, "connection_manager") and self.connection_manager:
633
655
  try:
634
656
  self.connection_manager.cleanup()
635
657
  except Exception:
636
- pass # Ignore cleanup errors during destruction
658
+ pass # nosec B110 - Intentionally ignore cleanup errors during handler destruction
637
659
 
638
660
 
639
661
  def main():
@@ -130,7 +130,7 @@ class ResponseTrackingManager:
130
130
 
131
131
  try:
132
132
  # Get the original request data stored during pre-tool
133
- request_info = delegation_requests.get(session_id)
133
+ request_info = delegation_requests.get(session_id) # nosec B113 - False positive: dict.get(), not requests library
134
134
  if not request_info:
135
135
  if DEBUG:
136
136
  print(
@@ -327,6 +327,22 @@ class ResponseTrackingManager:
327
327
  file=sys.stderr,
328
328
  )
329
329
 
330
+ # Auto-pause integration
331
+ auto_pause = getattr(self, "auto_pause_handler", None)
332
+ if auto_pause and metadata.get("usage"):
333
+ try:
334
+ threshold_crossed = auto_pause.on_usage_update(
335
+ metadata["usage"]
336
+ )
337
+ if threshold_crossed:
338
+ warning = auto_pause.emit_threshold_warning(
339
+ threshold_crossed
340
+ )
341
+ print(f"\n⚠️ {warning}", file=sys.stderr)
342
+ except Exception as e:
343
+ if DEBUG:
344
+ print(f"Auto-pause error: {e}", file=sys.stderr)
345
+
330
346
  # Track the main Claude response
331
347
  file_path = self.response_tracker.track_response(
332
348
  agent_name="claude_main",