limbo-code 0.1.16__tar.gz → 0.2__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 (207) hide show
  1. {limbo_code-0.1.16 → limbo_code-0.2}/AGENTS.md +24 -8
  2. {limbo_code-0.1.16 → limbo_code-0.2}/CONTEXT.md +10 -2
  3. {limbo_code-0.1.16 → limbo_code-0.2}/PKG-INFO +32 -1
  4. {limbo_code-0.1.16 → limbo_code-0.2}/README.md +31 -0
  5. limbo_code-0.2/design/bang-verification.md +24 -0
  6. limbo_code-0.2/design/rfc-bang-command.md +121 -0
  7. limbo_code-0.2/design/rfc-headless-cli.md +174 -0
  8. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/agent.py +120 -10
  9. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/app.py +25 -8
  10. limbo_code-0.2/src/limbo/code_mode.py +122 -0
  11. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/config.py +28 -0
  12. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/goal.py +2 -2
  13. limbo_code-0.2/src/limbo/headless.py +917 -0
  14. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/catalog.py +45 -0
  15. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/scaffold.py +3 -8
  16. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/prompt.py +8 -0
  17. limbo_code-0.2/src/limbo/pump.py +384 -0
  18. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/sessions.py +56 -1
  19. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/registry.py +44 -9
  20. limbo_code-0.2/src/limbo/tools/run_code.py +355 -0
  21. limbo_code-0.2/src/limbo/trace.py +181 -0
  22. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/app.tcss +26 -0
  23. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/commands.py +21 -2
  24. limbo_code-0.2/src/limbo/ui/paste_store.py +147 -0
  25. limbo_code-0.2/src/limbo/ui/path_input.py +60 -0
  26. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/screens/main.py +263 -104
  27. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/screens/session_picker.py +2 -0
  28. limbo_code-0.2/src/limbo/ui/widgets/chat.py +729 -0
  29. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/widgets/command_menu.py +13 -0
  30. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/widgets/input.py +48 -100
  31. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/widgets/status_bar.py +13 -0
  32. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/widgets/tool_card.py +92 -28
  33. limbo_code-0.2/tests/conftest.py +42 -0
  34. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_agent.py +135 -16
  35. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_catalog.py +33 -0
  36. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_cli.py +52 -0
  37. limbo_code-0.2/tests/test_code_mode.py +77 -0
  38. limbo_code-0.2/tests/test_code_mode_agent.py +139 -0
  39. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_config.py +44 -0
  40. limbo_code-0.2/tests/test_headless.py +578 -0
  41. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_integrations.py +25 -0
  42. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_interrupt.py +17 -11
  43. limbo_code-0.1.16/tests/test_goal_driver.py → limbo_code-0.2/tests/test_pump.py +108 -31
  44. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_sessions.py +37 -1
  45. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_trace.py +50 -1
  46. limbo_code-0.2/tests/tools/test_run_code.py +277 -0
  47. limbo_code-0.2/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +172 -0
  48. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +72 -72
  49. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +73 -73
  50. limbo_code-0.2/tests/ui/test_bang_ui.py +357 -0
  51. limbo_code-0.2/tests/ui/test_code_mode_ui.py +122 -0
  52. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_command_menu.py +14 -0
  53. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_commands.py +36 -0
  54. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_compact_ui.py +1 -1
  55. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_goal_ui.py +10 -10
  56. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_input_attachments.py +5 -5
  57. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_input_paste.py +9 -9
  58. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_interrupt_ui.py +8 -8
  59. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_model_picker.py +1 -1
  60. limbo_code-0.2/tests/ui/test_paste_store.py +172 -0
  61. limbo_code-0.2/tests/ui/test_path_input.py +275 -0
  62. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_sessions_ui.py +75 -0
  63. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_snapshots.py +6 -0
  64. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_steer_ui.py +5 -5
  65. limbo_code-0.2/tests/ui/test_widgets.py +620 -0
  66. limbo_code-0.1.16/src/limbo/goal_driver.py +0 -305
  67. limbo_code-0.1.16/src/limbo/trace.py +0 -110
  68. limbo_code-0.1.16/src/limbo/ui/widgets/chat.py +0 -331
  69. limbo_code-0.1.16/tests/conftest.py +0 -21
  70. limbo_code-0.1.16/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -172
  71. limbo_code-0.1.16/tests/ui/test_widgets.py +0 -198
  72. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  73. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  74. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  75. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  76. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  77. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/tdd/SKILL.md +0 -0
  78. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/tdd/agents/openai.yaml +0 -0
  79. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/tdd/mocking.md +0 -0
  80. {limbo_code-0.1.16 → limbo_code-0.2}/.agents/skills/tdd/tests.md +0 -0
  81. {limbo_code-0.1.16 → limbo_code-0.2}/.github/workflows/publish.yml +0 -0
  82. {limbo_code-0.1.16 → limbo_code-0.2}/.github/workflows/test.yml +0 -0
  83. {limbo_code-0.1.16 → limbo_code-0.2}/.gitignore +0 -0
  84. {limbo_code-0.1.16 → limbo_code-0.2}/Makefile +0 -0
  85. {limbo_code-0.1.16 → limbo_code-0.2}/design/confirm_view.html +0 -0
  86. {limbo_code-0.1.16 → limbo_code-0.2}/design/limbo-ui-minimal.md +0 -0
  87. {limbo_code-0.1.16 → limbo_code-0.2}/design/limbo-ui-redesign.md +0 -0
  88. {limbo_code-0.1.16 → limbo_code-0.2}/design/prototype-minimal-confirm.png +0 -0
  89. {limbo_code-0.1.16 → limbo_code-0.2}/design/prototype-minimal.html +0 -0
  90. {limbo_code-0.1.16 → limbo_code-0.2}/design/prototype-minimal.png +0 -0
  91. {limbo_code-0.1.16 → limbo_code-0.2}/design/prototype.html +0 -0
  92. {limbo_code-0.1.16 → limbo_code-0.2}/design/prototype.png +0 -0
  93. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/limbo-current-ui.png +0 -0
  94. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/limbo-new-ui.png +0 -0
  95. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
  96. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
  97. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
  98. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
  99. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
  100. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
  101. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
  102. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
  103. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
  104. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
  105. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
  106. {limbo_code-0.1.16 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
  107. {limbo_code-0.1.16 → limbo_code-0.2}/docs/session-management.md +0 -0
  108. {limbo_code-0.1.16 → limbo_code-0.2}/docs/skills.md +0 -0
  109. {limbo_code-0.1.16 → limbo_code-0.2}/docs/ui-redesign-proposal.md +0 -0
  110. {limbo_code-0.1.16 → limbo_code-0.2}/pyproject.toml +0 -0
  111. {limbo_code-0.1.16 → limbo_code-0.2}/scripts/check_contrast.py +0 -0
  112. {limbo_code-0.1.16 → limbo_code-0.2}/scripts/gen_banner.py +0 -0
  113. {limbo_code-0.1.16 → limbo_code-0.2}/scripts/scroll_bench.py +0 -0
  114. {limbo_code-0.1.16 → limbo_code-0.2}/scripts/ui_walkthrough.py +0 -0
  115. {limbo_code-0.1.16 → limbo_code-0.2}/skills-lock.json +0 -0
  116. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/__init__.py +0 -0
  117. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/__main__.py +0 -0
  118. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/attachments.py +0 -0
  119. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/btw.py +0 -0
  120. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/compaction.py +0 -0
  121. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/history.py +0 -0
  122. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/integrations/__init__.py +0 -0
  123. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/integrations/base.py +0 -0
  124. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/integrations/herdr.py +0 -0
  125. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/__init__.py +0 -0
  126. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/anthropic_client.py +0 -0
  127. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/client.py +0 -0
  128. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/factory.py +0 -0
  129. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/openai_client.py +0 -0
  130. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/responses_client.py +0 -0
  131. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/retry.py +0 -0
  132. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/sse.py +0 -0
  133. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/llm/usage.py +0 -0
  134. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/model_switch.py +0 -0
  135. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/models.py +0 -0
  136. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/skills.py +0 -0
  137. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/steer.py +0 -0
  138. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/__init__.py +0 -0
  139. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/base.py +0 -0
  140. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/bash.py +0 -0
  141. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/edit.py +0 -0
  142. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/find.py +0 -0
  143. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/grep.py +0 -0
  144. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/ignore.py +0 -0
  145. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/ls.py +0 -0
  146. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/mutation_queue.py +0 -0
  147. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/read.py +0 -0
  148. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/tools/write.py +0 -0
  149. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/__init__.py +0 -0
  150. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/app.py +0 -0
  151. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/banner.py +0 -0
  152. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/clipboard.py +0 -0
  153. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/contrast.py +0 -0
  154. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/key_fixes.py +0 -0
  155. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/screens/__init__.py +0 -0
  156. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/screens/btw.py +0 -0
  157. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/screens/game2048.py +0 -0
  158. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/screens/model_picker.py +0 -0
  159. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/screens/verify_picker.py +0 -0
  160. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/syntax.py +0 -0
  161. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/theme.py +0 -0
  162. {limbo_code-0.1.16 → limbo_code-0.2}/src/limbo/ui/widgets/__init__.py +0 -0
  163. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_anthropic_client.py +0 -0
  164. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_attachments.py +0 -0
  165. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_btw.py +0 -0
  166. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_clipboard.py +0 -0
  167. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_compaction.py +0 -0
  168. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_goal.py +0 -0
  169. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_herdr.py +0 -0
  170. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_history.py +0 -0
  171. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_integration.py +0 -0
  172. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_llm_client.py +0 -0
  173. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_llm_scaffold.py +0 -0
  174. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_model_switch.py +0 -0
  175. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_models.py +0 -0
  176. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_prompt.py +0 -0
  177. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_responses_client.py +0 -0
  178. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_retry.py +0 -0
  179. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_skills.py +0 -0
  180. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_sse.py +0 -0
  181. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_steer.py +0 -0
  182. {limbo_code-0.1.16 → limbo_code-0.2}/tests/test_usage.py +0 -0
  183. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_base.py +0 -0
  184. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_bash.py +0 -0
  185. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_edit.py +0 -0
  186. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_find.py +0 -0
  187. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_grep.py +0 -0
  188. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_ignore.py +0 -0
  189. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_ls.py +0 -0
  190. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_mutation_queue.py +0 -0
  191. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_read.py +0 -0
  192. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_registry.py +0 -0
  193. {limbo_code-0.1.16 → limbo_code-0.2}/tests/tools/test_write.py +0 -0
  194. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
  195. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_app_smoke.py +0 -0
  196. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_btw_ui.py +0 -0
  197. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_contrast.py +0 -0
  198. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_copy.py +0 -0
  199. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_game2048.py +0 -0
  200. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_input_history.py +0 -0
  201. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_key_fixes.py +0 -0
  202. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_main_screen.py +0 -0
  203. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_scroll_follow.py +0 -0
  204. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_skills_ui.py +0 -0
  205. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_startup_art.py +0 -0
  206. {limbo_code-0.1.16 → limbo_code-0.2}/tests/ui/test_theme.py +0 -0
  207. {limbo_code-0.1.16 → limbo_code-0.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  # Limbo — Agent Guide
2
2
 
3
- **Limbo** is a minimal terminal AI coding agent (Python 3.11+, Textual). Users converse with an LLM in a TUI to explore, read, edit, and write code via 7 tools.
3
+ **Limbo** is a minimal terminal AI coding agent (Python 3.11+, Textual). Users converse with an LLM in a TUI to explore, read, edit, and write code via 7 tools. A `--headless` flag runs a plain-terminal REPL frontend instead of the TUI (Herdr/script control, RFC LIM-60).
4
4
 
5
5
  **Tools execute immediately — no confirmation flow, no safety fences.** File tools are not scoped to the workdir (absolute paths and symlinks are honored as-is), there is no sensitive-file skip list, and `bash` runs every command as given (no dangerous-command filter).
6
6
 
@@ -27,10 +27,16 @@ src/limbo/
27
27
  ├── compaction.py # Context compaction decision/prompt logic (LIM-14)
28
28
  ├── history.py # tool_call↔result pairing + resume repair
29
29
  ├── model_switch.py # /model domain logic: validate, swap client, persist (UI is an adapter)
30
+ ├── code_mode.py # Code Mode (/code-mode on|off): SDK rendering + CODE_ONLY instruction
31
+ │ # (deepseek-harness parity: catalog collapses to run_code)
30
32
  ├── prompt.py # System-prompt assembly (tools section derived from the registry)
31
33
  ├── steer.py # Mid-turn steer queue (LIM-20): queueing semantics, cancel boundary
32
34
  ├── goal.py # /goal closed-loop state machine, prompt templates, verify executor (LIM-40)
33
- ├── goal_driver.py # /goal orchestrator (frontend-agnostic, non-UI): turn verify → next round
35
+ ├── pump.py # Turn pump (frontend-agnostic, non-UI): single-flight busy flag,
36
+ │ # steer follow-up drain, /compact mini-turn, goal closed loop
37
+ ├── headless.py # --headless frontend (RFC LIM-60): line-oriented REPL on the
38
+ │ # primary screen for Herdr (agent prompt/send-keys/read/wait);
39
+ │ # same TurnPump seam as the TUI, no textual import
34
40
  ├── sessions.py # Session JSONL save/load/list/export
35
41
  ├── trace.py # Append-only JSONL run log (sessions/traces/)
36
42
  ├── integrations/ # External mux/orchestrator lifecycle reporters: base.py (protocol
@@ -40,11 +46,15 @@ src/limbo/
40
46
  │ # openai_client.py, anthropic_client.py, responses_client.py, retry.py, sse.py,
41
47
  │ # usage.py (token accounting: usage normalization + prompt-size estimation),
42
48
  │ # scaffold.py (plumbing shared by dialect clients: credentials, retry, images)
43
- ├── tools/ # base.py (BaseTool + path resolution + truncation), registry.py (dispatch),
44
- │ # mutation_queue.py (per-file locks), ignore.py (.gitignore),
45
- │ # read/bash/edit/write/grep/find/ls.py
49
+ ├── tools/ # base.py (BaseTool + path resolution + truncation), registry.py (dispatch;
50
+ │ # code_mode flag filters definitions() to [run_code], all_definitions() feeds
51
+ │ # the SDK), mutation_queue.py (per-file locks), ignore.py (.gitignore),
52
+ │ # read/bash/edit/write/grep/find/ls/run_code.py (the Code Mode transport)
46
53
  └── ui/ # app.py + app.tcss (ALL styles here; theme vars only, no bare hex),
47
- # theme.py (limbo-dark/-light, RFC LIM-16), commands.py (slash registry),
54
+ # theme.py (limbo-dark/-light, RFC LIM-16), commands.py (slash registry
55
+ # + resolve(): the single builtin-vs-skill collision site), path_input.py
56
+ # ('/'-path fallback), paste_store.py (pure paste/attachment marker
57
+ # state machine; the input widget is its adapter),
48
58
  # screens/ (main, session_picker, model_picker, game2048),
49
59
  # widgets/ (chat, input, status_bar, tool_card, command_menu)
50
60
  ```
@@ -79,13 +89,19 @@ Skills: `~/.limbo/skills/<name>/SKILL.md` (user) and `<workdir>/.agents/skills/<
79
89
 
80
90
  ## UI
81
91
 
82
- Single column: status bar (state/elapsed/tokens/model/workdir/queued + rainbow 🎯GOAL badge while a goal loop runs) → scrolling chat flow (user `❯`, streaming Markdown, tool cards, errors) → input box (Enter submits, Shift+Enter newline, paste markers, `ctrl+v` image attach) → hint line. Slash commands: `/sessions /new /export /compact /model /help /2048 /goal` + skill commands. After palette changes run `python scripts/check_contrast.py` (≥ 4.5:1).
92
+ Single column: status bar (state/elapsed/tokens/model/workdir/queued + rainbow 🎯GOAL badge while a goal loop runs + ⌨code badge while Code Mode is on) → scrolling chat flow (user `❯`, streaming Markdown, tool cards, errors) → input box (Enter submits, Shift+Enter newline, paste markers, `ctrl+v` image attach) → hint line. Slash commands: `/sessions /new /export /compact /model /code-mode /help /2048 /goal` + skill commands. '/'-leading input that matches no command but looks like a path is sent as a normal message with existing files auto-attached (`ui/path_input.py`); a leading space forces plain text. After palette changes run `python scripts/check_contrast.py` (≥ 4.5:1).
83
93
 
84
- `/goal <text>` starts closed-loop mode (LIM-40): the first round is a proposal round — the model explores the repo and proposes acceptance command(s) in a `<verify_proposal>` block; the user confirms in a picker (accept / edit / skip), then `GoalDriver` (non-UI, shared by any frontend) runs each round as a complete `Agent.run()` turn and executes the verify command; exit 0 ends the loop, otherwise the failure output is fed back verbatim into the next round until `max_rounds`, when a no-tool wrap-up turn summarizes and any user message resumes with a fresh budget. Goal state persists on `SessionMeta.goal`. Esc during verify cancels the subprocess.
94
+ `/goal <text>` starts closed-loop mode (LIM-40): the first round is a proposal round — the model explores the repo and proposes acceptance command(s) in a `<verify_proposal>` block; the user confirms in a picker (accept / edit / skip), then the goal branch of the `TurnPump` (non-UI, shared by any frontend) runs each round as a complete `Agent.run()` turn and executes the verify command; exit 0 ends the loop, otherwise the failure output is fed back verbatim into the next round until `max_rounds`, when a no-tool wrap-up turn summarizes and any user message resumes with a fresh budget. Goal state persists on `SessionMeta.goal`. Esc during verify cancels the subprocess.
85
95
 
86
96
  ## Key Decisions
87
97
 
88
98
  - Async agent, sync tools via `asyncio.to_thread()`
99
+ - Code Mode (`/code-mode on|off`): `ToolRegistry.code_mode` collapses
100
+ `definitions()` to `[run_code]`; `RunCodeTool` runs a model-written Python
101
+ program on a fresh event loop, forwarding `tools.*` sub-calls through the
102
+ same registry. Only the curated program output (print + return) enters
103
+ history — sub-calls never do. `SessionMeta.code_mode` persists the mode
104
+ across resume
89
105
  - System prompt = hardcoded tool guidelines + `<workdir>/AGENTS.md` & `~/.limbo/AGENTS.md` (XML-wrapped) + skills catalog. **This file is injected into every request — keep it accurate and lean.**
90
106
  - Session files fully rewritten per save (atomic, 0600); trace holds full-fidelity record
91
107
 
@@ -20,11 +20,19 @@ proposals should name modules with these terms.
20
20
 
21
21
  - **Turn** — one user input through the agent loop until a tool-call-free
22
22
  response (or max_iterations). Turns are the save/trace unit.
23
+ - **Turn pump** — the non-UI module that runs turns back-to-back
24
+ (`pump.py`, `TurnPump`): pulls events out of the lazy `Agent.run()`
25
+ stream, owns the single-flight busy flag (turns and `/compact`
26
+ mini-turns alike) and the goal closed loop. The follow-up steer drain
27
+ lives in its supervisor (stragglers past a round's boundary drain —
28
+ verify/wrap-up window, teardown race — become one more turn); frontends
29
+ never drain. Frontends are adapters translating its events into UI
30
+ updates.
23
31
  - **Steer** — a user message queued mid-turn, injected at consistency
24
32
  points (loop top, turn end, run head). The `SteerQueue` (`steer.py`) owns
25
33
  queueing semantics — id generation, FIFO, and the cancel boundary (an
26
- item can be cancelled by id only until drained); the Agent owns drain
27
- timing.
34
+ item can be cancelled by id only until drained); the **Turn pump** owns
35
+ drain timing.
28
36
  - **Compaction** — summarizing the old region of history into a synthetic
29
37
  summary message, keeping the recent tail. Triggered automatically
30
38
  (loop-top, before context overflow) or manually (`/compact`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: limbo-code
3
- Version: 0.1.16
3
+ Version: 0.2
4
4
  Summary: A minimal terminal AI coding agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -232,6 +232,37 @@ tomlkit) so the next launch keeps it. If the write fails, the switch still
232
232
  applies for the current session. Switching is refused while a turn is in
233
233
  flight.
234
234
 
235
+ ### Code Mode (`/code-mode on|off`)
236
+
237
+ Code Mode collapses the model-facing tool catalog to a single `run_code`
238
+ tool: instead of one tool call per action, the model writes a **Python
239
+ program** against a generated SDK and `run_code` executes it — a sequence
240
+ that would take several LLM round trips becomes one. The system prompt
241
+ injects the SDK (every other tool as `await tools.<name>(kwarg=...)`, with
242
+ signatures derived from the registry), so:
243
+
244
+ ```python
245
+ # one run_code call instead of three tool-call round trips
246
+ content = await tools.read(path="src/main.py")
247
+ await tools.write(path="copy.py", content=content)
248
+ return await tools.bash(command="python copy.py")
249
+ ```
250
+
251
+ Inside the program `tools` and `ToolCallError` are bound. Tools return
252
+ their text output as `str`; a failed call raises `ToolCallError` (catchable
253
+ with `try/except`), an unhandled exception fails the run with the
254
+ traceback and captured output so the model self-corrects on the next
255
+ iteration, and independent calls may overlap under `asyncio.gather`. Only
256
+ what the program prints or returns enters the conversation — intermediate
257
+ tool results never do. A runaway program is bounded by a default 120 s
258
+ budget (pass `timeout=` to override) and ESC interrupts at the next tool
259
+ boundary.
260
+
261
+ Toggle with `/code-mode on|off` (no argument flips). The mode is per
262
+ session: it is persisted in the session file and restored on resume, and
263
+ the status bar shows a `⌨code` badge while it is on. Switching is refused
264
+ while a turn is in flight.
265
+
235
266
  ## Safety
236
267
 
237
268
  Limbo executes every tool call immediately, without asking for confirmation,
@@ -218,6 +218,37 @@ tomlkit) so the next launch keeps it. If the write fails, the switch still
218
218
  applies for the current session. Switching is refused while a turn is in
219
219
  flight.
220
220
 
221
+ ### Code Mode (`/code-mode on|off`)
222
+
223
+ Code Mode collapses the model-facing tool catalog to a single `run_code`
224
+ tool: instead of one tool call per action, the model writes a **Python
225
+ program** against a generated SDK and `run_code` executes it — a sequence
226
+ that would take several LLM round trips becomes one. The system prompt
227
+ injects the SDK (every other tool as `await tools.<name>(kwarg=...)`, with
228
+ signatures derived from the registry), so:
229
+
230
+ ```python
231
+ # one run_code call instead of three tool-call round trips
232
+ content = await tools.read(path="src/main.py")
233
+ await tools.write(path="copy.py", content=content)
234
+ return await tools.bash(command="python copy.py")
235
+ ```
236
+
237
+ Inside the program `tools` and `ToolCallError` are bound. Tools return
238
+ their text output as `str`; a failed call raises `ToolCallError` (catchable
239
+ with `try/except`), an unhandled exception fails the run with the
240
+ traceback and captured output so the model self-corrects on the next
241
+ iteration, and independent calls may overlap under `asyncio.gather`. Only
242
+ what the program prints or returns enters the conversation — intermediate
243
+ tool results never do. A runaway program is bounded by a default 120 s
244
+ budget (pass `timeout=` to override) and ESC interrupts at the next tool
245
+ boundary.
246
+
247
+ Toggle with `/code-mode on|off` (no argument flips). The mode is per
248
+ session: it is persisted in the session file and restored on resume, and
249
+ the status bar shows a `⌨code` badge while it is on. Switching is refused
250
+ while a turn is in flight.
251
+
221
252
  ## Safety
222
253
 
223
254
  Limbo executes every tool call immediately, without asking for confirmation,
@@ -0,0 +1,24 @@
1
+ # Bang command(`!`)隔离环境验证记录
2
+
3
+ - 日期:2026-08-13
4
+ - 分支:`feat/bang-command`(含 commit `ad2f1bc`)
5
+ - RFC:design/rfc-bang-command.md
6
+ - 验证方式:真实 `LimboApp`(textual `run_test()` pilot)+ 真实 `BashTool` 子进程(无 mock);LLM 侧用记录型 stub client,用于断言"无 LLM 请求"并构造一个可被 ESC 打断的长 turn。验证脚本:`/tmp/verify_bang.py`(一次性,未入库)。
7
+
8
+ ## 结果:7/7 通过
9
+
10
+ | # | 路径 | 结果 | 证据 |
11
+ | --- | --- | --- | --- |
12
+ | 1 | `!echo hello` | ✅ | 卡片 `success`,body 含 `hello`;`llm_calls=0`(无任何 LLM 请求);`agent.messages` 长度保持 1(仅 system),未进历史 |
13
+ | 2 | `!false` | ✅ | 卡片 `error` 态,body = `Command failed with exit code 1.`(exit code 信息保留,失败路径填的是 output 而非 error) |
14
+ | 3 | 孤立 `!` | ✅ | 对话流出现 `用法:! <bash 命令>(直接执行,不发送给模型)`;未产生 bash 卡片、未发 LLM 请求 |
15
+ | 4 | ` !echo hi`(前导空格) | ✅ | 走普通消息路径:LLM client 收到的末条消息 content 恰为 `!echo hi`;未产生 bash 卡片 |
16
+ | 5 | `!seq 1 5000`(长输出) | ✅ | 卡片 `success`,body 含 `[Showing lines 3001-5000 of 5000. Full output: /var/folders/.../limbo-output-*.log]`;spill 文件真实存在且含完整 5000 行 |
17
+ | 6 | turn 运行中敲 bang + ESC 打断 turn | ✅ | bang(`!sleep 2 && echo survived`)在 turn 运行中立即并行执行(不进 steer 队列,`queued_count=0`);ESC 打断 turn 后 bang 卡片保持 `running`(未被 `cancel_running_tool_cards()` 误标),2 秒后正常转 `success`,body 含 `survived` |
18
+ | 7 | `!sleep 4711` 运行中退出 app | ✅ | 退出前 `pgrep` 可见 `sleep 4711` 进程;app unmount(`on_unmount` → 私有 `BashTool.cancel()`)后 0.2s 内进程消失,无孤儿 |
19
+
20
+ ## 备注
21
+
22
+ - 验证环境为临时目录 `tempfile.mkdtemp()` 作为 workdir/session_dir,与日常 `~/.limbo` 数据完全隔离。
23
+ - 第 7 项初次用 `sleep 60 # comment` 做 pgrep 标记失败:`bash -c` 对单条简单命令会 exec 优化,注释不出现在进程 cmdline 中;改用特殊 sleep 时长做标记后通过(验证脚本问题,非产品问题)。
24
+ - 自动化回归:`tests/ui/test_bang_ui.py` 10/10 通过;`make check` 全量 841 passed + ruff + mypy 全绿。
@@ -0,0 +1,121 @@
1
+ # RFC: `!` 前缀直接执行 bash 命令(bang command)
2
+
3
+ - 作者:code-monkey
4
+ - 状态:draft
5
+ - 日期:2026-08-13
6
+
7
+ ## Problem / Motivation
8
+
9
+ Limbo 输入框提交的所有内容(非 slash 命令)都会作为聊天消息发给 LLM。日常使用中有一类高频诉求:用户只想快速跑一条 shell 命令看一眼结果(`!git status`、`!ls -la`、`!pytest -x` 的最后几行),并不需要消耗一轮 LLM 对话、也不希望这条命令污染对话上下文。pi 等同类工具有成熟的 `!` 前缀惯例:直接执行、结果内联展示。
10
+
11
+ 现状:
12
+
13
+ - 输入提交链路:`InputWidget.action_submit()`(`src/limbo/ui/widgets/input.py`)发出 `UserSubmitted` 消息 → `MainScreen.on_user_submitted()`(`src/limbo/ui/screens/main.py`)分派:`_editing_verify` 分支 → `/` slash 命令(`ui/commands.py`)→ `/`-path 回退(`ui/path_input.py`,自动 attach)→ steer 队列 / 新 turn。
14
+ - bash 执行能力已有:`BashTool`(`src/limbo/tools/bash.py`)提供流式收集、tail 截断 + spill 临时文件、超时、进程组 kill(ESC 打断)。
15
+ - 结果展示已有:`ChatWidget.add_tool_card()` + `ToolCard`(`src/limbo/ui/widgets/`),状态机 running → success | error | cancelled,`command` 是 header 摘要的优先键(`_SUMMARY_KEYS`)。
16
+
17
+ 即:执行与展示的基础设施都已存在,缺的只是输入层的 `!` 分派和一条"不进 agent 历史的本地执行"通路。
18
+
19
+ ## Non-Goals
20
+
21
+ - 不把命令/结果写入 agent 对话历史,不作为上下文发给 LLM(包括后续 turn);不进 session JSONL,resume 后不可见。
22
+ - 不做 bang 结果的 LLM 后处理(不让模型解释输出)。
23
+ - v1 不支持 ESC 取消正在运行的 bang 命令(命令跑完为止; bash 工具无默认超时,与 LLM 侧行为一致)。
24
+ - 不改 slash 命令、`/`-path 回退、steer 队列的任何现有行为。
25
+ - 不做 bang 输出流式刷新(卡片在命令结束后一次性渲染,复用 ToolResult 路径;`BashTool.run()` 本身就是收集完才返回)。
26
+ - 不处理 `/new`、`/sessions` 切换后仍在运行的 bang 的卡片归属(旧卡片随 `chat.clear()` 移除,迟到的结果静默丢弃,见技术视角);进程本身由 `on_unmount` 兜底(app 退出时 kill)。
27
+
28
+ ## Goal
29
+
30
+ 可验证的成功标准:
31
+
32
+ 1. 输入 `!echo hello` 回车:不产生 LLM 请求(`agent.messages` 不增长、trace 无 `llm_request`),对话流出现一张 bash 工具卡片,header 显示命令,展开可见 stdout `hello`。
33
+ 2. 输入 `!false`(或任意非零退出命令):卡片呈 error 态,展开 body 含完整输出及 `Command failed with exit code N.` 信息。
34
+ 3. 输入孤立的 `!`(或 `!` 后全是空白):不执行任何进程,对话流出现用法提示(info 行),不进历史。
35
+ 4. 输入以空格开头的 ` !foo`:与 `/` 的 `force_text` 转义一致,作为普通聊天消息发给 LLM。
36
+ 5. agent turn 运行中(busy)提交 `!cmd`:立即并行执行,不进 steer 队列、不打断 turn;turn 的 ESC 打断(`registry.cancel_active()`)不会误杀 bang 进程,且 `InterruptEvent` 触发的 `chat.cancel_running_tool_cards()` **不会把仍在运行的 bang 卡片标记为已取消**(卡片保持 running,命令结束后正常转 success/error)。
37
+ 6. `!` 命令进入输入框的 ↑↓ 历史(走现有 `action_submit` 路径即可),长输出复用 bash 工具的截断 + spill 提示。
38
+ 7. app 退出时仍在运行的 bang 进程被 kill,不泄漏孤儿进程。
39
+ 8. `make check` 全绿,新增 UI 测试覆盖上述 1–5 及 Test Plan 列出的场景。
40
+
41
+ 存疑点(goal unclear,待马越确认,默认按"不做"实现):
42
+
43
+ - 是否需要像 pi 那样把 `!` 命令的输出**附加进后续对话上下文**(让模型看得见)?本任务描述明确"不当作聊天消息发给 LLM",默认结果也不进历史;若后续想要"执行并告知模型",可作为独立增量(例如 `!!` 变体或开关)。
44
+ - bang 命令是否需要写 trace(`trace.log("bang_command", ...)`)做审计?默认做(一行日志,成本极低),但若认为 trace 只记录 LLM 会话事件可去掉。
45
+
46
+ ## Product / Tech Design
47
+
48
+ ### 用户视角
49
+
50
+ - 输入框敲 `!` 开头的单行/多行文本并回车 → 不发送给 LLM,直接以 bash 执行(工作目录 = Limbo workdir),对话流中先出现用户消息行(`!cmd` 原样回显,与 slash 命令一致),随后出现工具卡片:`… bash <command>` → 结束后 `✓`(退出码 0)或 `✗`(非零/异常),点击展开看输出;`ctrl+o` 全局展开/收起同样适用。
51
+ - `!` 后无内容 → info 提示 `用法:! <bash 命令>`。
52
+ - 想发送字面 `!` 开头的文本给 LLM:前面加一个空格(与 `/` 的既有转义一致,hint 文案与错误提示沿用同一套话术)。
53
+ - busy(turn/compact 进行中)时 bang 照常可用,行为类似 `/btw`、`/2048`:独立于当前任务。
54
+ - 输入框 hint 行文案追加 `! 命令`(`compose()` 中的 Static)。
55
+
56
+ ### 技术视角
57
+
58
+ **分派层:MainScreen,与 `/` 完全同构。** `!` 解析放在 `MainScreen.on_user_submitted()`,不放在 InputWidget / pump / agent:
59
+
60
+ - InputWidget 保持"提交原文"的单一职责(刚重构完,不再加分支);它现有的 `force_text` 转义判断从"仅 `/`"扩展为"`/` 或 `!`"(`ui/widgets/input.py` 的 `action_submit` 一处改动:`raw.lstrip()[:1] in ("/", "!")`)。
61
+ - pump/agent 完全不感知 bang:不进 `TurnPump`(无 single-flight 约束、无 steer 交互),`agent.py` 零改动。
62
+ - 分派顺序(`on_user_submitted` 内):`_editing_verify` 分支 **优先于** bang 分支——注意该分支内 slash 与 bang 的语义并不对称:slash 输入是「退出编辑模式、作为命令执行」,而一切非 slash 文本(包括 `!pytest`)都按字面成为验收命令(edit 模式的本意就是编辑一条 shell 命令,`!` 在其中没有特殊含义;`!pytest` 作为验收命令大概率执行失败,用户自证,与现状「任何文本原样成为验收命令」一致)。之后是 bang 分支(`text.startswith("!") and not event.force_text`);再之后才是现有 `/` 分支。`!` 与 `/` 首字符互斥,与 slash 命令、`/`-path 回退**无优先级冲突**;slash menu 只匹配 `/` 开头,也不受影响。
63
+
64
+ **执行:复用 `BashTool`,但用 screen 持有的独立实例。** 关键决策——不复用 `agent.registry` 里的那个 `BashTool`:
65
+
66
+ - turn 的 ESC 打断走 `registry.cancel_active()` → `BashTool.cancel()` 会杀掉该实例所有在途进程;若 bang 复用同一实例,用户打断 turn 会误杀自己手动跑的命令。screen 持有 `BashTool(workdir=self.workdir)` 私有实例即天然隔离。
67
+ - 不受 `[tools] bash_enabled` 影响:该开关控制的是"暴露给 LLM 的工具面",bang 是用户本人的显式动作(与 LLM 自主调用 bash 的风险模型不同)。
68
+ - 调用方式:`await asyncio.to_thread(bang_tool.execute, {"command": cmd})`(与 `ToolRegistry.execute` 相同的模式,bash 不在 mutation queue 内,无需锁),包在 `run_worker` 里,每次提交一个独立 worker,天然支持并行多条 bang。
69
+ - 超时:不传 `timeout`(与 bash 工具默认一致,无超时);截断、spill、`[stderr]` 标注全部继承。
70
+ - worker 兜底:`_run_bang` 整体包 `try/except Exception`——`BashTool.execute()` 只捕获 `ToolError`,其余异常(如 trace 写入失败、意外的 OSError)不应让 worker 静默死掉而卡片永远 running;异常时 `card.set_error(f"内部错误:{e}")`。
71
+ - 生命周期:`MainScreen.on_unmount` 增加 `self._bang_tool.cancel()`(进程组 kill),app 退出时不泄漏在途 bang 进程——现有 `on_unmount` 只关 LLM client 和 trace,bang 是唯一新增的外部进程来源。
72
+
73
+ **结果进对话流:复用 ToolCard,无新消息类型。**
74
+
75
+ - 回显:`chat.add_user_message(text)`(`!cmd` 原文,**不带 attachments**——粘贴/附件是给 LLM 消息的载荷,bang 不发给 LLM;`InputWidget` 展开粘贴标记后的纯文本照常作为命令的一部分,但若用户先 attach 了图片再敲 `!cmd`,attachment 被静默丢弃)。
76
+ - 卡片:`chat.add_tool_card(tool_id=f"bang-{uuid}", name="bash", arguments={"command": cmd}, agent_owned=False)` —— `ToolCard._summary()` 的 `_SUMMARY_KEYS` 已含 `command`,header 摘要零改动。
77
+ - **bang 卡片标记(B1 修复)**:`add_tool_card` 增加 `agent_owned: bool = True` 参数,落到 `ToolCard` 的同名属性;`ChatWidget.cancel_running_tool_cards()` 只把 `agent_owned` 的 running 卡片置 cancelled。理由(相比 screen 侧维护 bang tool_id 集合):「running 卡片 ⇔ 等待 agent 工具结果」是 chat 组件自己的不变量,标记随卡片生命周期走(`tool_cards` 字典、`clear()` 一并清理),screen 侧平行维护一个集合会在 `/new`、`/sessions` 切换等 clear 路径上产生同步负担和漂移风险。这样 turn 被打断时 bang 卡片保持 running,`set_cancelled` 的「no result will ever arrive」不变量不被打破——bang 的结果一定会在进程结束时到达并正常转 success/error。
78
+ - 结果映射:`result.success` → `card.set_success(result.output or "")`;失败(非零退出/超时/启动异常)→ `card.set_error(result.output or result.error or "Command failed.")`。注意失败时要用 `output` 而非 `error` 填 body:`BashTool` 把 `Command failed with exit code N.` 置于 output 首行、完整 stdout/stderr 在后,只显示 `error` 会丢掉全部输出。
79
+ - 状态栏:v1 不动(卡片自带 running 态 `…`);busy 时也不去抢 turn 的状态文案。
80
+ - 迟到结果的容忍:`/new`、`/sessions` 切换后 bang 仍在跑,旧卡片已被 `chat.clear()` 卸载;此时 `set_success`/`set_error` 走 `ToolCard` 现有的 `NoMatches` 静默路径,不崩溃、结果丢弃(符合「bang 不进历史」的定位)。
81
+ - ESC 路由:不变。bang 进程属独立实例,turn ESC 不会波及;v1 也没有"ESC 取消 bang"的路径(Non-Goal)。
82
+
83
+ **持久化 / trace:** 不进 `agent.messages`、不进 session JSONL(resume 后无 bang 痕迹,与"历史不重建工具卡片"的既有策略一致)。trace 追加一条 `bang_command` 事件(command/exit_code/success),在 worker 完成处 best-effort 写入:worker 启动时捕获当时的 `self.agent.trace` 对象(而不是完成时再取 `self.agent.trace`,避免 `/new` 后写到新会话的 trace);写入本身包 try/except,因为旧 agent `close()` 后 trace 句柄已释放。
84
+
85
+ **hint 行:** 文案追加 `· ! 命令`。该行是单行 `Static`,窄终端下超长截断/换行是既有行为,本次只加几个字符,不引入新的折行问题;如快照因此变化按既有 `--snapshot-update` 流程处理。
86
+
87
+ **涉及文件清单:**
88
+
89
+ | 文件 | 改动 |
90
+ | --- | --- |
91
+ | `src/limbo/ui/screens/main.py` | `on_user_submitted` 增加 bang 分支;新增 `_run_bang(command)` worker(含 try/except 兜底);`__init__` 持有私有 `BashTool` 实例;`on_unmount` 增加 `cancel()`;hint 文案 |
92
+ | `src/limbo/ui/widgets/input.py` | `force_text` 转义条件扩展到 `!`(一处) |
93
+ | `src/limbo/ui/widgets/chat.py` | `add_tool_card()` 增加 `agent_owned` 参数;`cancel_running_tool_cards()` 跳过非 agent-owned 卡片 |
94
+ | `src/limbo/ui/widgets/tool_card.py` | `ToolCard` 增加 `agent_owned` 属性(默认 True) |
95
+ | `src/limbo/tools/bash.py` | 不改(仅被实例化复用) |
96
+ | `src/limbo/pump.py` / `agent.py` / `models.py` | 不改 |
97
+ | `tests/ui/test_bang_ui.py`(新增) | 见 Test Plan |
98
+ | `tests/ui/test_widgets.py` 或 chat 相关测试 | `cancel_running_tool_cards` 跳过 bang 卡片的单元用例 |
99
+ | `tests/ui/test_input_paste.py` 或 input 相关测试 | `force_text` 扩展的回归用例 |
100
+
101
+ **兼容性:** 无迁移。唯一行为变化:此前以 `!` 开头的消息会发给 LLM,现在被本地拦截执行;转义 hatch(前导空格)保留旧行为。
102
+
103
+ ## Test Plan
104
+
105
+ 自动化(`tests/ui/test_bang_ui.py`,textual `run_test()` pilot,参照 `test_path_input.py` / `test_steer_ui.py` 的既有模式;LLM client 用现有 mock/fixture):
106
+
107
+ 1. `!echo hello` → 无 `llm_request`(断言 mock client 零调用 / `agent.messages` 长度不变),聊天流含 bash 卡片且 body 含 `hello`。
108
+ 2. `!exit 3` → 卡片 error 态,body 含 `exit code 3`。
109
+ 3. 孤立 `!` → info 提示出现,无卡片、无进程。
110
+ 4. ` !echo hi`(前导空格)→ 走普通消息路径(mock client 收到文本 `!echo hi`)。
111
+ 5. busy 时提交 `!echo hi` → steer 队列计数不变,卡片照常完成;同时断言对 agent 侧 `BashTool` 实例无交互(隔离性,可用 monkeypatch spy)。
112
+ 6. **B1 回归**:bang 运行中打断一个 turn(注入 `InterruptEvent` 或走 ESC 路径)→ `cancel_running_tool_cards()` 后 bang 卡片仍为 running 态,agent 侧 running 卡片被置 cancelled;bang 进程结束后卡片正常转 success。另配一个 widget 级单元测试:`add_tool_card(agent_owned=False)` 的卡片不被 `cancel_running_tool_cards()` 触碰。
113
+ 7. `_editing_verify` 模式下提交 `!pytest -x` → 不执行 bang、不出现 bash 卡片,文本按字面成为验收命令(走 `_accept_verify`,断言 `pump.status().verify_command == "!pytest -x"`)。
114
+ 8. worker 兜底:monkeypatch 私有 `BashTool.execute` 抛非 `ToolError` 异常 → 卡片转 error 态,无未捕获 worker 异常。
115
+ 9. 输入历史:提交 `!ls` 后按 ↑ 能召回。
116
+
117
+ 手测路径:
118
+
119
+ - `!git status`、`!ls -la`(成功卡片 + 展开);`!grep -r xxx /nonexistent`(非零退出卡片);超长输出(`!seq 1 5000`)验证截断 + spill 提示;turn 运行中敲 bang 验证并行、ESC 打断 turn 后 bang 卡片不被误标 cancelled 且进程跑完;`!sleep 60` 后退出 app 验证无孤儿进程。
120
+
121
+ 不需要 snapshot 测试:卡片样式复用 ToolCard 现有快照,无新视觉元素(若 hint 文案改动影响现有快照,按既有流程 `--snapshot-update` 并审查 diff)。
@@ -0,0 +1,174 @@
1
+ # RFC LIM-60: Headless CLI 模式(Herdr 可控)
2
+
3
+ 状态:实现随本 RFC 同 PR 落地
4
+ 依赖:LIM-20(steer)、LIM-40(goal 闭环)、LIM-53(interrupt)、integrations/herdr(生命周期上报)
5
+
6
+ ## 背景
7
+
8
+ Limbo 目前只有 Textual TUI 一个前端。TUI 对 Herdr 编排有三个结构性障碍(依据
9
+ herdr v0.8.0 `skills/herdr/SKILL.md` 的控制模型):
10
+
11
+ 1. **TUI 跑在 alternate screen**。`herdr agent read` 只能读主屏缓冲 + host
12
+ scrollback;离开 alternate screen 的行不可恢复,完整回答拿不回来。
13
+ 2. **TUI 的输入框是私有控件**。`herdr agent prompt` 往 pane 写"文本 + Enter",
14
+ 在 TUI 下依赖焦点、输入框状态、kitty keyboard 等一堆巧合才能正常工作。
15
+ 3. **交互控件不可见**。VerifyPicker 这类模态在屏幕文本里语义不明确,Herdr 无法
16
+ 可靠地把 `blocked` 归因到"等哪个决策"。
17
+
18
+ Herdr 实际控制一个 agent 的手段只有四种:
19
+
20
+ | 手段 | 命令 |
21
+ |---|---|
22
+ | 提交文本(原子发送 + Enter,遵守 pane 的 bracketed-paste 模式) | `herdr agent prompt <name> "..." --wait` |
23
+ | 逻辑按键 | `herdr agent send-keys <name> esc\|ctrl+c` |
24
+ | 读屏(visible / recent / recent-unwrapped / detection) | `herdr agent read <name>` |
25
+ | 生命周期状态同步(agent 自上报,我方已有 `HerdrReporter`) | `herdr agent wait <name> --until blocked` |
26
+
27
+ **结论:headless 模式必须是一个跑在主屏上的、行导向的纯文本 REPL**,
28
+ 而不是 NDJSON-RPC——Herdr 没有到 pane 内进程的结构化 IPC 通道。
29
+ (NDJSON 机器协议作为未来扩展预留,见"非目标"。)
30
+
31
+ ## 目标
32
+
33
+ `limbo --headless`:无 TUI 的前端,与 TUI 并列,复用全部领域层:
34
+
35
+ ```
36
+ herdr agent prompt ──► stdin (行) ──► HeadlessFrontend ──► TurnPump ──► Agent
37
+ herdr agent read ◄── stdout (纯文本渲染) ◄── PumpEvent 流
38
+ herdr agent wait ◄── HerdrReporter(working/idle/blocked) ← 已有机制
39
+ ```
40
+
41
+ - `pump.py` / `agent.py` / `goal.py` / `sessions.py` / `integrations/` **零改动**;
42
+ headless 只是又一个 frontend adapter(`pump.py` docstring 本就预留了这个角色)。
43
+ - 不 import textual(与 pump 同规约,测试锁死)。
44
+ - 会话文件与 TUI 完全互通:`--continue` / `--resume` / `/new` 同一套。
45
+
46
+ ## 启动
47
+
48
+ ```bash
49
+ limbo --headless [--workdir DIR] [--session-dir DIR] [--continue|--resume ID] [--model M]
50
+ ```
51
+
52
+ `app.py` 复用现有的 config 加载、API key 校验、resume 解析;
53
+ `--headless` 走 `headless.run_headless()`,否则维持 Textual 路径。
54
+
55
+ ## 输入(stdin)
56
+
57
+ **TTY 模式**(Herdr pane 内):termios cbreak + 自行回显(关 ECHO,否则
58
+ `herdr agent read` 在屏上看不到输入内容——读屏是 Herdr 唯一的回读手段)。
59
+
60
+ - 启动时写 `\e[?2004h` 开启 bracketed paste,退出时 `\e[?2004l` 恢复。
61
+ `herdr agent prompt` 检测到 2004 后会把多行文本包在 `\e[200~ … \e[201~` 里
62
+ 原子发送——粘贴块整体进入行缓冲,内部换行不触发提交;herdr 随后发送的
63
+ Enter 完成提交。
64
+ - Enter 提交当前行;Backspace 编辑(CJK 宽字符感知);可打印 UTF-8 字符回显。
65
+ - **Esc**(孤立 `0x1b`,与转义序列以 80ms 超时区分)→ 中断(= TUI 的 Esc)。
66
+ - **Ctrl+C**(cbreak + 关 ISIG,到达为字节 `0x03`)→ busy 时中断;idle 时退出。
67
+ - **Ctrl+D** 空行 → 退出。
68
+ - 未知转义序列(方向键、Alt 组合)丢弃,不污染行缓冲。
69
+
70
+ **管道模式**(stdin 非 TTY,如脚本/测试):逐行读取,每行一条提交;
71
+ EOF = 等当前 turn 跑完后退出(`echo "任务" | limbo --headless` 语义自然)。
72
+
73
+ ## 输出(stdout,纯文本渲染)
74
+
75
+ PumpEvent → 行的映射(全部走主屏,`herdr agent read --source recent-unwrapped`
76
+ 可完整回读):
77
+
78
+ | 事件 | 渲染 |
79
+ |---|---|
80
+ | TextDelta / ThinkingDelta | 原文流式写出 |
81
+ | ToolCallRequest | `⚙ name(参数摘要)` 一行(参数截断 100 字符) |
82
+ | ToolResultEvent | `✓/✗` + 输出(成功截断到 60 行;错误全文) |
83
+ | ErrorEvent | `✗ message` |
84
+ | SteerEvent | `➤ 插话已注入: …` |
85
+ | InterruptEvent | `⏹ 已打断` |
86
+ | CompactionEvent | 压缩结果一行(同 TUI 文案) |
87
+ | Goal* 事件 | 同 TUI 的中文文案(验收轮次/通过/耗尽/恢复) |
88
+ | UsageUpdate | 累积 tokens,turn 结束时随分隔行打印 |
89
+ | turn 结束 | `── limbo idle · 累计 tokens N ──` 分隔行 |
90
+
91
+ 忙时提交的普通文本 → `agent.steer()`(与 TUI 输入框行为一致);
92
+ 忙时收到改写历史的命令(/goal /new /model /compact)→ 拒绝并提示(同 TUI)。
93
+
94
+ ## 斜杠命令(v1)
95
+
96
+ `/goal <text>` · `/goal clear` · `/goal`(查询)· `/compact` · `/model [id]`
97
+ (无参=显示当前)· `/new` · `/help` · `/quit`
98
+ 解析复用 `goal.parse_goal_args` / `model_switch.prepare_model_switch` +
99
+ `swap_llm_client`;未知命令报错行。Skills 调用不进 v1(见非目标)。
100
+
101
+ ## Goal 验收确认:唯一的 blocked 场景
102
+
103
+ TUI 用 VerifyPicker 模态;headless 用"下一条输入即答案":
104
+
105
+ 1. 提议轮结束(goal active 且无 verify_command)→ 解析最后一条 assistant 消息的
106
+ `<verify_proposal>`(复用 `parse_verify_proposal`)。
107
+ 2. 打印编号候选 + 操作说明,向 Herdr 上报 `blocked`(message="等待确认 /goal
108
+ 验收命令")。Herdr 侧 `herdr agent wait <name> --until blocked` 此刻返回,
109
+ 编排方 `agent read` 读到候选列表后 `agent prompt <name> "1"` 决策。
110
+ 3. 下一条提交被消费为验收答案,**不进入对话历史**:
111
+ - `1..N` → 采纳该候选;`/skip`(或空行)→ 跳过,保持单轮模式;
112
+ - 其他任意一行 → 作为编辑后的验收命令(对应 TUI 的"编辑"分支);
113
+ - 此间 Esc/Ctrl+C → 等同 `/skip`;其他斜杠命令 → 报错提示,保持等待。
114
+ 4. 采纳后 `pump.set_verify(cmd)` + 跑 `build_initial_prompt` 首轮,回报 `working`。
115
+
116
+ `<none/>`(模型判断无客观验收)→ 提示并保持单轮,不进 blocked。
117
+ 提议轮期间收到 EOF(管道关闭)→ 打印候选后自动跳过、退出。
118
+
119
+ ## 生命周期上报(复用 integrations)
120
+
121
+ | 时点 | 上报 |
122
+ |---|---|
123
+ | 启动就绪 | `report_session(id, path)` + `idle` |
124
+ | turn/compact 开始 | `working` |
125
+ | turn 结束 | `idle` |
126
+ | 等待验收确认 | `blocked` |
127
+ | `/new` | `report_session(...)` |
128
+ | 退出/信号 | `release()`(复用 `install_exit_hooks`,含 SIGHUP/SIGTERM/atexit) |
129
+
130
+ 测试环境注意:conftest 已剥离 HERDR_* 环境变量;headless 测试显式注入
131
+ 录制用 fake reporter,不碰真实环境。
132
+
133
+ ## 中断语义(RFC LIM-53 平移)
134
+
135
+ Esc / busy 时 Ctrl+C → `agent.interrupt()` + `pump.cancel_verify()`。
136
+ 被中断的 turn 不自动续跑(pump 既有语义);剩余 steer 留在队列,
137
+ 下一次提交时作为头部输入拼接。
138
+
139
+ ## 测试接缝(预先约定)
140
+
141
+ 1. **`HeadlessFrontend.run(inputs, out)`**:inputs 为 InputEvent 异步迭代器
142
+ (Submit/Interrupt/Eof),out 为 `str -> None` 收集器。配真实 Agent +
143
+ 脚本化 FakeLLMClient(沿用 test_pump.py 模式)+ 录制 fake reporter。
144
+ 覆盖:单轮渲染、工具调用渲染、忙时 steer、interrupt、
145
+ goal 提议→blocked→确认/跳过/自定义命令、/compact、/model、/new、
146
+ EOF 等待 turn 完成后退出、忙时命令拒绝。
147
+ 2. **行解析**:`parse_submission` / `parse_verify_answer` /
148
+ `TerminalLineParser`(bracketed paste、Esc 超时、退格、Ctrl+C/D)——
149
+ 纯函数/纯字节测试。
150
+ 3. **入口**:`--headless` flag 路由到 run_headless(沿用 test_cli.py mock 模式)。
151
+
152
+ 不在接缝内:termios 字节级读写循环(手工验证)、真实 Herdr 联调。
153
+
154
+ ## 非目标(v1)
155
+
156
+ - NDJSON 机器协议(`--json`):等出现非 Herdr 编排方再做;渲染/输入已按
157
+ 可替换的 out/in 接缝隔离。
158
+ - Skills 斜杠调用、`/sessions` 切换器、`/export`、`!` bang 命令、图片粘贴。
159
+ - `[headless]` 配置段(渲染行数上限等先硬编码合理默认值)。
160
+
161
+ ## 验收命令
162
+
163
+ ```bash
164
+ make check
165
+ ```
166
+
167
+ 手工联调(Herdr pane 内):
168
+
169
+ ```bash
170
+ herdr pane split --current --direction right --cwd "$PWD" --no-focus
171
+ herdr agent start worker --kind limbo --pane <pane-id> -- --headless
172
+ herdr agent prompt worker "读一下 AGENTS.md 并总结架构" --wait --timeout 120000
173
+ herdr agent read worker --source recent-unwrapped --lines 80
174
+ ```