drydock-cli 3.1.13__tar.gz → 3.1.14__tar.gz

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 (193) hide show
  1. {drydock_cli-3.1.13/drydock_cli.egg-info → drydock_cli-3.1.14}/PKG-INFO +1 -1
  2. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/agent.py +48 -1
  3. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/compaction.py +11 -2
  4. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/providers.py +5 -3
  5. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/app.py +71 -20
  6. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/widgets.py +1 -1
  7. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tuning.py +16 -4
  8. {drydock_cli-3.1.13 → drydock_cli-3.1.14/drydock_cli.egg-info}/PKG-INFO +1 -1
  9. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/SOURCES.txt +1 -0
  10. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/pyproject.toml +1 -1
  11. drydock_cli-3.1.14/tests/test_edit_thrash_and_compaction.py +123 -0
  12. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tui.py +36 -3
  13. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/LICENSE +0 -0
  14. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/NOTICE +0 -0
  15. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/README.md +0 -0
  16. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/__init__.py +0 -0
  17. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/__main__.py +0 -0
  18. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/advisor.py +0 -0
  19. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/bash_safety.py +0 -0
  20. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/budget.py +0 -0
  21. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/__init__.py +0 -0
  22. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/document-canvas.md +0 -0
  23. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-assess.md +0 -0
  24. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-cap.md +0 -0
  25. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-evidence.md +0 -0
  26. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-readiness.md +0 -0
  27. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-data.md +0 -0
  28. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-debug.md +0 -0
  29. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-finetune.md +0 -0
  30. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-metrics.md +0 -0
  31. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-rl.md +0 -0
  32. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-train.md +0 -0
  33. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  34. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/nist-csf.md +0 -0
  35. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-categorize.md +0 -0
  36. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-control.md +0 -0
  37. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-poam.md +0 -0
  38. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-review.md +0 -0
  39. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/stig-assess.md +0 -0
  40. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/stig-remediate.md +0 -0
  41. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/cci.py +0 -0
  42. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/cli.py +0 -0
  43. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/config.py +0 -0
  44. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/detect.py +0 -0
  45. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/doccanvas.py +0 -0
  46. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/events.py +0 -0
  47. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/extract.py +0 -0
  48. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/fiar.py +0 -0
  49. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/gittools.py +0 -0
  50. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/graphrag.py +0 -0
  51. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/guards.py +0 -0
  52. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/loop_detect.py +0 -0
  53. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/mcp.py +0 -0
  54. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/phases.py +0 -0
  55. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/poam.py +0 -0
  56. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/progress.py +0 -0
  57. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/ratchet.py +0 -0
  58. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/recipes.py +0 -0
  59. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/recovery.py +0 -0
  60. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/resume.py +0 -0
  61. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/rmf.py +0 -0
  62. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/rmf_graph.py +0 -0
  63. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/skills.py +0 -0
  64. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/stig.py +0 -0
  65. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/subagents.py +0 -0
  66. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/suggest.py +0 -0
  67. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/task_state.py +0 -0
  68. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_policy.py +0 -0
  69. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_registry.py +0 -0
  70. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_result.py +0 -0
  71. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_select.py +0 -0
  72. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_validate.py +0 -0
  73. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tools/__init__.py +0 -0
  74. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/trajectory.py +0 -0
  75. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/__init__.py +0 -0
  76. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/approval.py +0 -0
  77. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/messages.py +0 -0
  78. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/verification.py +0 -0
  79. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/web.py +0 -0
  80. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/dependency_links.txt +0 -0
  81. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/entry_points.txt +0 -0
  82. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/requires.txt +0 -0
  83. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/top_level.txt +0 -0
  84. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/setup.cfg +0 -0
  85. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_advisor.py +0 -0
  86. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_approval.py +0 -0
  87. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_back_command.py +0 -0
  88. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_background.py +0 -0
  89. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_binary_output.py +0 -0
  90. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_crossplatform.py +0 -0
  91. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_output_bounding.py +0 -0
  92. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_process_group.py +0 -0
  93. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_safety.py +0 -0
  94. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_sanitize.py +0 -0
  95. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_shell.py +0 -0
  96. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_stdin.py +0 -0
  97. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_stop_partial.py +0 -0
  98. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_timeout_network.py +0 -0
  99. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_timeout_param.py +0 -0
  100. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_budget.py +0 -0
  101. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_cci.py +0 -0
  102. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_cli_agents.py +0 -0
  103. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_compact_command.py +0 -0
  104. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_compaction.py +0 -0
  105. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_config.py +0 -0
  106. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_config_migration.py +0 -0
  107. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_context_limit_config.py +0 -0
  108. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_context_limit_issue25.py +0 -0
  109. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_criteria_coverage.py +0 -0
  110. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_cycling_detection.py +0 -0
  111. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_degenerate_argument.py +0 -0
  112. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_detect.py +0 -0
  113. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_dispatch.py +0 -0
  114. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_doccanvas.py +0 -0
  115. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_e2e_connected.py +0 -0
  116. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_edit_replace_all.py +0 -0
  117. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_effort_governor.py +0 -0
  118. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_empty_response.py +0 -0
  119. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_events.py +0 -0
  120. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_extract.py +0 -0
  121. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_failure_loop.py +0 -0
  122. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_fiar.py +0 -0
  123. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_first_run_setup.py +0 -0
  124. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_gittools.py +0 -0
  125. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_glob_edit_edges.py +0 -0
  126. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphify_example.py +0 -0
  127. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphrag.py +0 -0
  128. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphrag_quoted_path.py +0 -0
  129. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphrag_sqlite.py +0 -0
  130. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_grep_and_read_robust.py +0 -0
  131. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_guards_and_tools.py +0 -0
  132. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_hallucinated_tools.py +0 -0
  133. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_leaked_tool_call.py +0 -0
  134. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_loop_detect.py +0 -0
  135. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_mcp.py +0 -0
  136. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_model_registry.py +0 -0
  137. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_oneshot_unreachable.py +0 -0
  138. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_overthink_interrupt.py +0 -0
  139. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_phases.py +0 -0
  140. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_plan_autocontinue.py +0 -0
  141. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_poam.py +0 -0
  142. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_progress.py +0 -0
  143. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_providers_unreachable.py +0 -0
  144. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_ratchet.py +0 -0
  145. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_read_index.py +0 -0
  146. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recipes.py +0 -0
  147. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recovery.py +0 -0
  148. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recovery_config.py +0 -0
  149. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recovery_integration.py +0 -0
  150. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_repeated_outcome.py +0 -0
  151. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_repetition_interrupt.py +0 -0
  152. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_resume_events.py +0 -0
  153. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_resume_restore.py +0 -0
  154. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_resume_snapshot.py +0 -0
  155. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rmf.py +0 -0
  156. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rmf_graph.py +0 -0
  157. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rmf_stig_graph.py +0 -0
  158. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rolling_plan.py +0 -0
  159. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_runaway_repetition.py +0 -0
  160. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_screenshot.py +0 -0
  161. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_server_probe.py +0 -0
  162. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_skills.py +0 -0
  163. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_sqlite_events.py +0 -0
  164. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_stall_retry.py +0 -0
  165. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_stig.py +0 -0
  166. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_stop.py +0 -0
  167. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_streaming_newlines.py +0 -0
  168. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_subagent.py +0 -0
  169. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_subagents.py +0 -0
  170. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_suggest.py +0 -0
  171. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_system_prompt_help.py +0 -0
  172. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_task_state.py +0 -0
  173. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_timeline.py +0 -0
  174. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_todo.py +0 -0
  175. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_arg_coercion.py +0 -0
  176. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_arg_coercion_more.py +0 -0
  177. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_arg_parsing.py +0 -0
  178. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_canonical.py +0 -0
  179. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_policy.py +0 -0
  180. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_result.py +0 -0
  181. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_select.py +0 -0
  182. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_validate.py +0 -0
  183. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tools_undo.py +0 -0
  184. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_trajectory.py +0 -0
  185. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tuning.py +0 -0
  186. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_verification_gate.py +0 -0
  187. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_viewimage.py +0 -0
  188. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_vision_input.py +0 -0
  189. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_web_tools.py +0 -0
  190. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_windows_shell.py +0 -0
  191. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_worker_subagent.py +0 -0
  192. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_write_content_coerce.py +0 -0
  193. {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.1.13
3
+ Version: 3.1.14
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import os
8
8
  import time as _time
9
+ from pathlib import Path
9
10
  from dataclasses import dataclass, field
10
11
  from typing import Generator
11
12
 
@@ -124,6 +125,24 @@ def drop_last_turn(messages: list) -> bool:
124
125
  return False
125
126
 
126
127
 
128
+ def _numbered_snapshot(path: str, max_lines: int = 200, max_chars: int = 8000) -> str:
129
+ """Line-numbered current content of *path* for the failed-edit thrash breaker,
130
+ so the model can copy an exact span instead of re-guessing. Bounded and
131
+ never raises — an unreadable path returns an explanatory line."""
132
+ try:
133
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
134
+ except Exception as e: # noqa: BLE001
135
+ return f"(could not read {path}: {e}. Use Read to inspect it.)"
136
+ lines = text.splitlines()
137
+ body = lines[:max_lines]
138
+ out = "\n".join(f"{i + 1:>5}\t{ln}" for i, ln in enumerate(body))
139
+ if len(lines) > max_lines:
140
+ out += f"\n[... {len(lines) - max_lines} more lines — use Read for the rest ...]"
141
+ if len(out) > max_chars:
142
+ out = out[:max_chars] + "\n[... truncated — use Read for the rest ...]"
143
+ return out
144
+
145
+
127
146
  # ── Agent loop ────────────────────────────────────────────────────────────
128
147
 
129
148
  def run(
@@ -212,6 +231,7 @@ def run(
212
231
  ) # scores each action; flags a stalled run
213
232
  prev_failing = None # failing-test count from the previous verification (delta)
214
233
  verif_fail_counts: dict[str, int] = {} # failure-summary -> times seen (Epic J3)
234
+ edit_fail_counts: dict[str, int] = {} # file_path -> consecutive "old_string not found" misses
215
235
  verification_text = "" # concatenated verification commands+results (coverage)
216
236
  coverage_nudges = 0 # bounded "your checks never touched X" nudges
217
237
  # Time-aware effort governor (bench taxonomy: think-bound runs burn a 30-min
@@ -691,9 +711,36 @@ def run(
691
711
  f"path and take a different approach.]\n" + result
692
712
  )
693
713
 
714
+ # Failed-edit thrash breaker: a weak model that guesses a WRONG
715
+ # old_string gets "old_string not found", then re-guesses a DIFFERENT
716
+ # wrong old_string — so exact-repeat detection (keyed on identical
717
+ # input) never fires and it loops "over and over" (seen live on
718
+ # gemma/vLLM). Track "not found" misses per FILE regardless of the
719
+ # old_string; after a few, stop hinting and HARD-inject the file's
720
+ # current content (line-numbered) so the next edit can only copy an
721
+ # exact span. Also feed the stall machinery so recovery escalates.
722
+ _edit_thrash = False
723
+ if tc["name"] in ("Edit", "edit") and isinstance(tc.get("input"), dict):
724
+ _efp = str(tc["input"].get("file_path") or "")
725
+ if "old_string not found" in (result or ""):
726
+ edit_fail_counts[_efp] = edit_fail_counts.get(_efp, 0) + 1
727
+ if edit_fail_counts[_efp] >= 3:
728
+ _edit_thrash = True
729
+ _snap = _numbered_snapshot(_efp)
730
+ result = (
731
+ f"[STOP: {edit_fail_counts[_efp]} edits to {_efp} have failed "
732
+ f"with 'old_string not found' — you keep guessing text that is "
733
+ f"not in the file. Do NOT guess again. The file's CURRENT "
734
+ f"content is below, line-numbered. Copy an EXACT span (WITHOUT "
735
+ f"the line-number prefixes) as old_string, or take a different "
736
+ f"approach.]\n{_snap}\n\n" + (result or "")
737
+ )
738
+ else:
739
+ edit_fail_counts.pop(_efp, None) # a clean edit resets that file
740
+
694
741
  # Verification evidence: if this Bash call was a test/check/exec, parse
695
742
  # its result so the completion gate knows whether it PASSED, not just ran.
696
- _repeated_outcome = bool(_degen)
743
+ _repeated_outcome = bool(_degen) or _edit_thrash
697
744
  if tc["name"] == "Bash":
698
745
  _vcmd = (tc.get("input") or {}).get("command", "")
699
746
  if looks_like_verification(_vcmd):
@@ -110,15 +110,24 @@ def _truncate_tool_call_args(messages: list, max_len: int) -> None:
110
110
  inp[k] = v[: max_len // 2] + "\n[... arg truncated ...]"
111
111
 
112
112
 
113
- def compact(messages: list, context_limit: int = 131072) -> list:
113
+ def compact(messages: list, context_limit: int = 131072,
114
+ target_frac: float = 0.45) -> list:
114
115
  """Compact messages to fit within context limit.
115
116
 
116
117
  Strategy:
117
118
  1. First pass: truncate long tool results to 800 chars
118
119
  2. Second pass: drop oldest tool results if still over limit
119
120
  3. Always keep: first user message, last 8 messages
121
+
122
+ target_frac is the fraction of the window to compact DOWN to. It must be
123
+ meaningfully below maybe_compact's TRIGGER (0.60): compacting to the same
124
+ 0.60 we trigger at frees almost nothing, so context stays pinned just over
125
+ the line and re-compacts every turn while never gaining real headroom — the
126
+ "it doesn't compact when it needs to" symptom. Targeting 0.45 leaves durable
127
+ headroom so a long /loop (history accumulating across iterations) doesn't
128
+ creep back over the wall on the very next turn.
120
129
  """
121
- target = int(context_limit * 0.60) # Leave 40% headroom
130
+ target = int(context_limit * target_frac) # durable headroom below the 0.60 trigger
122
131
 
123
132
  # Pass 1: Truncate long tool results
124
133
  for m in messages:
@@ -562,9 +562,11 @@ def stream(
562
562
  oai_messages = messages_to_openai(messages, system)
563
563
 
564
564
  has_tools = bool(tool_schemas)
565
- # Gemma corrupts tool-call JSON when streamed; force non-streaming on tool
566
- # turns. config["force_stream"] overrides for debugging.
567
- do_stream = config.get("force_stream") or use_streaming(model, has_tools)
565
+ # Gemma (and any local server whose model name we can't trust) corrupts
566
+ # tool-call JSON when streamed; force non-streaming on tool turns. Passing
567
+ # provider closes the gap where a Gemma behind vLLM is served under a name
568
+ # lacking 'gemma'. config["force_stream"] overrides for debugging.
569
+ do_stream = config.get("force_stream") or use_streaming(model, has_tools, provider)
568
570
 
569
571
  kwargs = {
570
572
  "model": model,
@@ -890,28 +890,52 @@ class DrydockApp(App):
890
890
  self._info("\n".join(lines))
891
891
 
892
892
  def _cmd_loop(self, arg: str) -> None:
893
- """/loop <count> <prompt> — run <prompt> up to <count> times (1–50),
894
- re-submitting after each turn finishes. Esc (or /stop) ends the loop."""
893
+ """/loop [count|*] <prompt> — re-submit <prompt> after each turn finishes.
894
+
895
+ <count> 1–1000 runs that many iterations; `*` (or omitting the count
896
+ entirely) runs UNBOUNDED until Esc/`/stop`. The loop also stops early on
897
+ its own when the model reports the task done (replies LOOP_DONE) or when
898
+ an iteration makes no progress (identical actions + reply as the prior)."""
895
899
  parts = arg.split(maxsplit=1)
896
- if len(parts) < 2 or not parts[0].isdigit():
900
+ first = parts[0] if parts else ""
901
+ if first == "*":
902
+ total = None # unbounded
903
+ prompt = (parts[1] if len(parts) > 1 else "").strip()
904
+ elif first.isdigit():
905
+ total = max(1, min(int(first), 1000))
906
+ prompt = (parts[1] if len(parts) > 1 else "").strip()
907
+ else:
908
+ total = None # no count given → run until stopped
909
+ prompt = arg.strip()
910
+ if not prompt:
897
911
  self._info(
898
- "usage: /loop <count> <prompt> e.g. /loop 5 fix the next "
899
- "failing test and run pytest\n(count 1–50; Esc stops the loop)"
912
+ "usage: /loop [count|*] <prompt> e.g. /loop 5 fix the next "
913
+ "failing test and run pytest\n"
914
+ "(count 1–1000, or `*`/no count = until Esc/stop)"
900
915
  )
901
916
  return
902
- count = max(1, min(int(parts[0]), 50))
903
- prompt = parts[1].strip()
904
- if not prompt:
905
- self._info("usage: /loop <count> <prompt>")
906
- return
907
917
  if self._busy:
908
918
  self._info("A turn is already running — stop it (Esc) before starting a loop.")
909
919
  return
910
- self._repeat = {"prompt": prompt, "remaining": count, "total": count}
911
- self._info(f"↻ looping {count}× (Esc to stop): {prompt}")
920
+ self._repeat = {"prompt": prompt, "total": total, "done": 1, "fingerprint": None}
921
+ label = f"{total}×" if total else "until Esc"
922
+ self._info(f"↻ looping {label} (Esc to stop): {prompt}")
912
923
  self._mount(UserMessage(prompt))
913
924
  self._begin(prompt)
914
925
 
926
+ def _loop_prompt(self, rep: dict) -> str:
927
+ """The prompt for a continuation iteration — the base task plus a small
928
+ iteration-awareness header so the model knows it is iterating and can end
929
+ the loop honestly instead of re-doing finished work."""
930
+ n = rep["done"]
931
+ of = f"/{rep['total']}" if rep["total"] else ""
932
+ return (
933
+ f"{rep['prompt']}\n\n"
934
+ f"[loop iteration {n}{of} — continue the task from the CURRENT state. "
935
+ f"If everything the task asks for is already complete, reply exactly "
936
+ f"LOOP_DONE and take no tool actions.]"
937
+ )
938
+
915
939
  def _cmd_ratchet(self, arg: str) -> None:
916
940
  """/ratchet <goal> [--verify "<cmd>"] [--rounds N] [--fitness auto|exitcode|<regex>]
917
941
 
@@ -1737,14 +1761,41 @@ class DrydockApp(App):
1737
1761
  if self._ratchet and not self._cancel.is_set():
1738
1762
  self.run_worker(self._ratchet_step, thread=True)
1739
1763
  return
1740
- # /loop: re-run the prompt until the iteration count is exhausted (Esc/
1741
- # stop clears self._repeat). Queued user prompts take priority (above).
1742
- if self._repeat and self._repeat["remaining"] > 1 and not self._cancel.is_set():
1743
- self._repeat["remaining"] -= 1
1744
- done = self._repeat["total"] - self._repeat["remaining"] + 1
1745
- self._info(f"↻ loop iteration {done}/{self._repeat['total']}")
1746
- self._begin(self._repeat["prompt"])
1747
- return
1764
+ # /loop: re-run the prompt until the count is exhausted OR the loop stops
1765
+ # itself (Esc/stop clears self._repeat). Queued user prompts take priority.
1766
+ if self._repeat and not self._cancel.is_set():
1767
+ rep = self._repeat
1768
+ unbounded = rep["total"] is None
1769
+ more = unbounded or rep["done"] < rep["total"]
1770
+ if more:
1771
+ # Early stop 1: the model reported the task complete.
1772
+ if "LOOP_DONE" in self._last_assistant_text():
1773
+ self._info("✓ loop stopped — model reported the task complete (LOOP_DONE).")
1774
+ self._repeat = None
1775
+ else:
1776
+ # Early stop 2: no progress — this iteration produced the same
1777
+ # actions AND the same reply as the previous one, so repeating
1778
+ # will only reproduce it (the "repeats the same actions over and
1779
+ # over" case). Fingerprint = (tool names used, final reply).
1780
+ fp = (tuple(sorted(self._turn_tools)), self._last_assistant_text())
1781
+ if fp == rep.get("fingerprint") and (fp[0] or fp[1]):
1782
+ self._info("✓ loop stopped — no progress (identical actions "
1783
+ "and reply as the previous iteration).")
1784
+ self._repeat = None
1785
+ else:
1786
+ rep["fingerprint"] = fp
1787
+ rep["done"] += 1
1788
+ of = f"/{rep['total']}" if rep["total"] else ""
1789
+ self._info(f"↻ loop iteration {rep['done']}{of}")
1790
+ # Compact BEFORE the next iteration so accumulated history
1791
+ # from prior iterations can't creep over the window.
1792
+ try:
1793
+ from drydock.compaction import maybe_compact
1794
+ maybe_compact(self.state, self.config)
1795
+ except Exception: # noqa: BLE001 — compaction must never break the loop
1796
+ pass
1797
+ self._begin(self._loop_prompt(rep))
1798
+ return
1748
1799
  self._repeat = None
1749
1800
  self._refresh_status()
1750
1801
  self._update_suggestion()
@@ -186,7 +186,7 @@ SLASH_SPECS: list[SlashSpec] = [
186
186
  SlashSpec("/doc", "<sub>", "drive the Document Canvas tools by hand",
187
187
  "open <path> · new <name> ..."),
188
188
  SlashSpec("/skills", "[new <name> <prompt>]", "list skills, or create a reusable one"),
189
- SlashSpec("/loop", "<count> <prompt>", "repeat a prompt N times (Esc stops)"),
189
+ SlashSpec("/loop", "[count|*] <prompt>", "repeat a prompt (1–1000, or * = until Esc)"),
190
190
  SlashSpec("/ratchet", "<goal>", "persist verified progress across rounds until tests pass",
191
191
  '--verify "<cmd>" to override the auto-detected verifier'),
192
192
  SlashSpec("/mcp", "", "list connected MCP servers and their tools"),
@@ -224,13 +224,25 @@ def hallucinated_tool_message(name: str) -> str | None:
224
224
  return _HALLUCINATED_TOOLS.get(name)
225
225
 
226
226
 
227
- def use_streaming(model: str | None, has_tools: bool) -> bool:
227
+ # Local OpenAI-compatible servers whose streamed tool-call JSON we don't trust:
228
+ # the model family can't be inferred from the served name (vLLM/Ollama/LM Studio
229
+ # routinely report a checkpoint path, a custom --served-model-name, or 'default'),
230
+ # and streamed tool-call deltas from local models are the corruption we avoid.
231
+ _LOCAL_TOOL_STREAM_UNSAFE = {"vllm", "ollama", "lmstudio"}
232
+
233
+
234
+ def use_streaming(model: str | None, has_tools: bool, provider: str | None = None) -> bool:
228
235
  """Whether to stream tokens for this turn.
229
236
 
230
- Gemma corrupts tool-call JSON when streamed, so any turn that offers
231
- tools to Gemma must be non-streaming. Text-only turns may stream.
237
+ Gemma corrupts tool-call JSON when streamed, so any tool turn to Gemma must be
238
+ non-streaming. Crucially, the decision must NOT rely on the model NAME alone: a
239
+ local server often reports a name with no 'gemma' in it (a served alias / path),
240
+ so a Gemma behind vLLM would slip through name-matching, stream its tool turns,
241
+ and emit the mangled `old_string` that makes Edit loop. So force non-streaming
242
+ on ANY tool turn to a local OpenAI-compatible provider, regardless of the
243
+ reported name. Text-only turns still stream everywhere (incremental UX kept).
232
244
  """
233
- if has_tools and is_gemma(model):
245
+ if has_tools and (is_gemma(model) or (provider or "") in _LOCAL_TOOL_STREAM_UNSAFE):
234
246
  return False
235
247
  return True
236
248
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.1.13
3
+ Version: 3.1.14
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -110,6 +110,7 @@ tests/test_dispatch.py
110
110
  tests/test_doccanvas.py
111
111
  tests/test_e2e_connected.py
112
112
  tests/test_edit_replace_all.py
113
+ tests/test_edit_thrash_and_compaction.py
113
114
  tests/test_effort_governor.py
114
115
  tests/test_empty_response.py
115
116
  tests/test_events.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.1.13"
10
+ version = "3.1.14"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,123 @@
1
+ """Regression tests for the four /loop + edit-robustness fixes:
2
+ - compaction now leaves DURABLE headroom (compacts below the trigger)
3
+ - _numbered_snapshot renders a bounded, line-numbered file view
4
+ - the failed-edit thrash breaker fires on repeated same-file misses
5
+ """
6
+ from drydock import compaction
7
+ from drydock.agent import _numbered_snapshot
8
+
9
+
10
+ def _msgs_over(limit_tokens: int):
11
+ """A realistic long history: a first user msg + many assistant/tool turns whose
12
+ accumulated tool results blow past the limit (the /loop-accumulation shape)."""
13
+ chunk = "y" * 4000 # ~1333 tokens each
14
+ msgs = [{"role": "user", "content": "start the task"}]
15
+ for i in range(40):
16
+ msgs.append({"role": "assistant", "content": "",
17
+ "tool_calls": [{"id": str(i), "name": "Bash",
18
+ "input": {"command": f"run step {i}"}}]})
19
+ msgs.append({"role": "tool", "content": f"output {i} " + chunk})
20
+ msgs.append({"role": "assistant", "content": "done"})
21
+ return msgs
22
+
23
+
24
+ def test_compact_leaves_headroom_below_trigger():
25
+ """compact() must reduce BELOW the 0.60 maybe_compact trigger, not down to it —
26
+ otherwise it frees ~nothing and re-fires every turn (the 'doesn't compact' bug)."""
27
+ limit = 10_000
28
+ msgs = _msgs_over(limit)
29
+ assert compaction.estimate_tokens(msgs) > int(limit * 0.60)
30
+ out = compaction.compact(msgs, limit)
31
+ after = compaction.estimate_tokens(out)
32
+ # default target_frac is 0.45 → must sit at/below 45%, comfortably under 60%
33
+ assert after <= int(limit * 0.45) + 5, (after, int(limit * 0.45))
34
+
35
+
36
+ def test_compact_target_frac_is_configurable():
37
+ limit = 10_000
38
+ out = compaction.compact(_msgs_over(limit), limit, target_frac=0.30)
39
+ assert compaction.estimate_tokens(out) <= int(limit * 0.30) + 5
40
+
41
+
42
+ def test_maybe_compact_triggers_at_60pct():
43
+ """maybe_compact still uses a 0.60 trigger against the MAX of estimate and the
44
+ server's real last_input_tokens."""
45
+ class S:
46
+ messages = [{"role": "user", "content": "hi"}]
47
+ last_input_tokens = 7000 # > 60% of 10k
48
+ s = S()
49
+ compaction.maybe_compact(s, {"context_limit": 10_000})
50
+ # trigger fired (messages object handed to compact and returned) — no crash,
51
+ # and a below-trigger state is a no-op:
52
+ class S2:
53
+ messages = [{"role": "user", "content": "hi"}]
54
+ last_input_tokens = 100
55
+ before = list(S2.messages)
56
+ compaction.maybe_compact(S2(), {"context_limit": 10_000})
57
+ assert S2.messages == before
58
+
59
+
60
+ def test_numbered_snapshot_basic(tmp_path):
61
+ f = tmp_path / "a.py"
62
+ f.write_text("line one\nline two\nline three\n")
63
+ snap = _numbered_snapshot(str(f))
64
+ assert " 1\tline one" in snap
65
+ assert " 3\tline three" in snap
66
+
67
+
68
+ def test_numbered_snapshot_caps_lines(tmp_path):
69
+ f = tmp_path / "big.py"
70
+ f.write_text("\n".join(f"row{i}" for i in range(500)))
71
+ snap = _numbered_snapshot(str(f), max_lines=50)
72
+ assert "more lines" in snap
73
+ assert "row49" in snap and "row400" not in snap
74
+
75
+
76
+ def test_numbered_snapshot_missing_file():
77
+ snap = _numbered_snapshot("/no/such/file/xyz")
78
+ assert "could not read" in snap.lower()
79
+
80
+
81
+ def test_edit_thrash_counter_logic():
82
+ """The breaker's core rule: count 'old_string not found' per file regardless
83
+ of the (varying) old_string, fire on the 3rd, and a clean edit resets it.
84
+ Mirrors the agent-loop bookkeeping so the invariant is pinned even though the
85
+ full loop needs a live model."""
86
+ edit_fail_counts: dict[str, int] = {}
87
+
88
+ def step(fp: str, result: str) -> bool:
89
+ thrash = False
90
+ if "old_string not found" in result:
91
+ edit_fail_counts[fp] = edit_fail_counts.get(fp, 0) + 1
92
+ if edit_fail_counts[fp] >= 3:
93
+ thrash = True
94
+ else:
95
+ edit_fail_counts.pop(fp, None)
96
+ return thrash
97
+
98
+ # three DIFFERENT wrong old_strings on the same file → fires on the 3rd
99
+ assert step("/a.py", "Error: old_string not found in /a.py ...") is False
100
+ assert step("/a.py", "Error: old_string not found in /a.py ...") is False
101
+ assert step("/a.py", "Error: old_string not found in /a.py ...") is True
102
+ # a different file is tracked independently
103
+ assert step("/b.py", "Error: old_string not found in /b.py ...") is False
104
+ # a clean edit resets the file's counter
105
+ assert step("/a.py", "Edited /a.py") is False
106
+ assert "/a.py" not in edit_fail_counts
107
+ assert step("/a.py", "Error: old_string not found in /a.py ...") is False
108
+
109
+
110
+ def test_use_streaming_forces_nonstream_for_local_tool_turns():
111
+ """A Gemma behind vLLM served under a non-'gemma' name must NOT stream tool
112
+ turns (the latent corruption trap). Text-only turns still stream."""
113
+ from drydock.tuning import use_streaming
114
+ # non-'gemma' served name on a local provider → tool turns non-streamed
115
+ assert use_streaming("Q5_K_M", has_tools=True, provider="vllm") is False
116
+ assert use_streaming("default", has_tools=True, provider="ollama") is False
117
+ assert use_streaming("model", has_tools=True, provider="lmstudio") is False
118
+ # text-only turns still stream everywhere
119
+ assert use_streaming("Q5_K_M", has_tools=False, provider="vllm") is True
120
+ # name-based gemma match still works regardless of provider
121
+ assert use_streaming("gemma4", has_tools=True, provider=None) is False
122
+ # a remote provider (openai) with tools is NOT force-non-streamed by provider
123
+ assert use_streaming("gpt-4o", has_tools=True, provider="openai") is True
@@ -438,11 +438,13 @@ def test_loop_command_iterates_and_stops():
438
438
  begins = []
439
439
  app._begin = lambda text: begins.append(text) # stub out the real agent
440
440
 
441
- # bad usage → no loop
442
- app._handle_slash("/loop notanumber")
441
+ # empty prompt → usage error, no loop
442
+ app._handle_slash("/loop")
443
+ assert app._repeat is None and not begins
444
+ app._handle_slash("/loop *") # count but no prompt
443
445
  assert app._repeat is None and not begins
444
446
 
445
- # /loop 3 <prompt> → starts, first iteration begun
447
+ # /loop 3 <prompt> → starts, first iteration begun with the bare prompt
446
448
  app._handle_slash("/loop 3 do the thing")
447
449
  assert app._repeat and app._repeat["total"] == 3
448
450
  assert begins == ["do the thing"]
@@ -456,7 +458,38 @@ def test_loop_command_iterates_and_stops():
456
458
  assert app._repeat is None
457
459
  assert len(begins) == 3 # no 4th run
458
460
 
461
+ # unbounded: `*` runs past any count until stopped
462
+ begins.clear()
463
+ app._handle_slash("/loop * keep going")
464
+ assert app._repeat and app._repeat["total"] is None
465
+ for _ in range(6):
466
+ app.on_agent_finished(AgentFinished())
467
+ assert app._repeat is not None # never self-exhausts
468
+ assert len(begins) == 7 # initial + 6 continuations
469
+
470
+ # no count given → also unbounded, whole arg is the prompt
471
+ app._handle_slash("/stop")
472
+ begins.clear()
473
+ app._handle_slash("/loop just do it")
474
+ assert app._repeat and app._repeat["total"] is None
475
+ assert begins == ["just do it"]
476
+
477
+ # count is capped at 1000
478
+ app._handle_slash("/stop")
479
+ app._handle_slash("/loop 999999 huge")
480
+ assert app._repeat and app._repeat["total"] == 1000
481
+
482
+ # LOOP_DONE reply stops the loop early
483
+ app._handle_slash("/stop")
484
+ begins.clear()
485
+ app._handle_slash("/loop 10 finish up")
486
+ app.state.messages = [{"role": "assistant", "content": "all set. LOOP_DONE"}]
487
+ app.on_agent_finished(AgentFinished())
488
+ assert app._repeat is None
489
+ assert len(begins) == 1 # stopped before a 2nd iteration
490
+
459
491
  # Esc/stop ends an active loop
492
+ app.state.messages = []
460
493
  app._begin = lambda text: begins.append(text)
461
494
  app._handle_slash("/loop 5 again")
462
495
  app._busy = True
File without changes
File without changes
File without changes