limbo-code 0.1.13__tar.gz → 0.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 (184) hide show
  1. {limbo_code-0.1.13 → limbo_code-0.1.14}/AGENTS.md +2 -0
  2. {limbo_code-0.1.13 → limbo_code-0.1.14}/PKG-INFO +2 -2
  3. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/agent.py +5 -0
  4. limbo_code-0.1.14/src/limbo/integrations/__init__.py +43 -0
  5. limbo_code-0.1.14/src/limbo/integrations/base.py +87 -0
  6. limbo_code-0.1.14/src/limbo/integrations/herdr.py +143 -0
  7. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/screens/main.py +33 -0
  8. limbo_code-0.1.14/tests/test_herdr.py +145 -0
  9. limbo_code-0.1.14/tests/test_integrations.py +60 -0
  10. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  11. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  12. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  13. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  14. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  15. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/tdd/SKILL.md +0 -0
  16. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/tdd/agents/openai.yaml +0 -0
  17. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/tdd/mocking.md +0 -0
  18. {limbo_code-0.1.13 → limbo_code-0.1.14}/.agents/skills/tdd/tests.md +0 -0
  19. {limbo_code-0.1.13 → limbo_code-0.1.14}/.github/workflows/publish.yml +0 -0
  20. {limbo_code-0.1.13 → limbo_code-0.1.14}/.github/workflows/test.yml +0 -0
  21. {limbo_code-0.1.13 → limbo_code-0.1.14}/.gitignore +0 -0
  22. {limbo_code-0.1.13 → limbo_code-0.1.14}/CONTEXT.md +0 -0
  23. {limbo_code-0.1.13 → limbo_code-0.1.14}/Makefile +0 -0
  24. {limbo_code-0.1.13 → limbo_code-0.1.14}/README.md +0 -0
  25. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/confirm_view.html +0 -0
  26. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/limbo-ui-minimal.md +0 -0
  27. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/limbo-ui-redesign.md +0 -0
  28. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/prototype-minimal-confirm.png +0 -0
  29. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/prototype-minimal.html +0 -0
  30. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/prototype-minimal.png +0 -0
  31. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/prototype.html +0 -0
  32. {limbo_code-0.1.13 → limbo_code-0.1.14}/design/prototype.png +0 -0
  33. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/limbo-current-ui.png +0 -0
  34. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/limbo-new-ui.png +0 -0
  35. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
  36. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
  37. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
  38. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
  39. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
  40. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
  41. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
  42. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
  43. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
  44. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
  45. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
  46. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
  47. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/session-management.md +0 -0
  48. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/skills.md +0 -0
  49. {limbo_code-0.1.13 → limbo_code-0.1.14}/docs/ui-redesign-proposal.md +0 -0
  50. {limbo_code-0.1.13 → limbo_code-0.1.14}/pyproject.toml +0 -0
  51. {limbo_code-0.1.13 → limbo_code-0.1.14}/scripts/check_contrast.py +0 -0
  52. {limbo_code-0.1.13 → limbo_code-0.1.14}/scripts/gen_banner.py +0 -0
  53. {limbo_code-0.1.13 → limbo_code-0.1.14}/scripts/ui_walkthrough.py +0 -0
  54. {limbo_code-0.1.13 → limbo_code-0.1.14}/skills-lock.json +0 -0
  55. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/__init__.py +0 -0
  56. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/__main__.py +0 -0
  57. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/app.py +0 -0
  58. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/attachments.py +0 -0
  59. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/btw.py +0 -0
  60. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/compaction.py +0 -0
  61. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/config.py +0 -0
  62. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/goal.py +0 -0
  63. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/goal_driver.py +0 -0
  64. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/history.py +0 -0
  65. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/__init__.py +0 -0
  66. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/anthropic_client.py +0 -0
  67. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/catalog.py +0 -0
  68. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/client.py +0 -0
  69. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/factory.py +0 -0
  70. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/openai_client.py +0 -0
  71. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/responses_client.py +0 -0
  72. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/retry.py +0 -0
  73. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/scaffold.py +0 -0
  74. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/sse.py +0 -0
  75. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/llm/usage.py +0 -0
  76. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/model_switch.py +0 -0
  77. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/models.py +0 -0
  78. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/prompt.py +0 -0
  79. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/sessions.py +0 -0
  80. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/skills.py +0 -0
  81. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/steer.py +0 -0
  82. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/__init__.py +0 -0
  83. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/base.py +0 -0
  84. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/bash.py +0 -0
  85. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/edit.py +0 -0
  86. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/find.py +0 -0
  87. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/grep.py +0 -0
  88. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/ignore.py +0 -0
  89. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/ls.py +0 -0
  90. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/mutation_queue.py +0 -0
  91. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/read.py +0 -0
  92. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/registry.py +0 -0
  93. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/tools/write.py +0 -0
  94. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/trace.py +0 -0
  95. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/__init__.py +0 -0
  96. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/app.py +0 -0
  97. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/app.tcss +0 -0
  98. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/banner.py +0 -0
  99. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/clipboard.py +0 -0
  100. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/commands.py +0 -0
  101. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/contrast.py +0 -0
  102. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/screens/__init__.py +0 -0
  103. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/screens/btw.py +0 -0
  104. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/screens/game2048.py +0 -0
  105. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/screens/model_picker.py +0 -0
  106. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/screens/session_picker.py +0 -0
  107. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/screens/verify_picker.py +0 -0
  108. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/syntax.py +0 -0
  109. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/theme.py +0 -0
  110. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/widgets/__init__.py +0 -0
  111. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/widgets/chat.py +0 -0
  112. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/widgets/command_menu.py +0 -0
  113. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/widgets/input.py +0 -0
  114. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/widgets/status_bar.py +0 -0
  115. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/ui/widgets/tool_card.py +0 -0
  116. {limbo_code-0.1.13 → limbo_code-0.1.14}/src/limbo/user_paths.py +0 -0
  117. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/conftest.py +0 -0
  118. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_agent.py +0 -0
  119. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_anthropic_client.py +0 -0
  120. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_attachments.py +0 -0
  121. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_btw.py +0 -0
  122. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_catalog.py +0 -0
  123. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_cli.py +0 -0
  124. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_clipboard.py +0 -0
  125. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_compaction.py +0 -0
  126. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_config.py +0 -0
  127. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_goal.py +0 -0
  128. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_goal_driver.py +0 -0
  129. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_history.py +0 -0
  130. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_integration.py +0 -0
  131. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_interrupt.py +0 -0
  132. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_llm_client.py +0 -0
  133. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_llm_scaffold.py +0 -0
  134. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_model_switch.py +0 -0
  135. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_models.py +0 -0
  136. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_prompt.py +0 -0
  137. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_responses_client.py +0 -0
  138. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_retry.py +0 -0
  139. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_sessions.py +0 -0
  140. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_skills.py +0 -0
  141. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_sse.py +0 -0
  142. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_steer.py +0 -0
  143. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_trace.py +0 -0
  144. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_usage.py +0 -0
  145. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/test_user_paths.py +0 -0
  146. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_base.py +0 -0
  147. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_bash.py +0 -0
  148. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_edit.py +0 -0
  149. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_find.py +0 -0
  150. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_grep.py +0 -0
  151. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_ignore.py +0 -0
  152. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_ls.py +0 -0
  153. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_mutation_queue.py +0 -0
  154. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_read.py +0 -0
  155. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_registry.py +0 -0
  156. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/tools/test_write.py +0 -0
  157. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
  158. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
  159. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
  160. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
  161. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_app_smoke.py +0 -0
  162. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_btw_ui.py +0 -0
  163. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_command_menu.py +0 -0
  164. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_commands.py +0 -0
  165. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_compact_ui.py +0 -0
  166. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_contrast.py +0 -0
  167. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_copy.py +0 -0
  168. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_game2048.py +0 -0
  169. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_goal_ui.py +0 -0
  170. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_input_attachments.py +0 -0
  171. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_input_history.py +0 -0
  172. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_input_paste.py +0 -0
  173. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_interrupt_ui.py +0 -0
  174. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_main_screen.py +0 -0
  175. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_model_picker.py +0 -0
  176. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_scroll_follow.py +0 -0
  177. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_sessions_ui.py +0 -0
  178. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_skills_ui.py +0 -0
  179. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_snapshots.py +0 -0
  180. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_startup_art.py +0 -0
  181. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_steer_ui.py +0 -0
  182. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_theme.py +0 -0
  183. {limbo_code-0.1.13 → limbo_code-0.1.14}/tests/ui/test_widgets.py +0 -0
  184. {limbo_code-0.1.13 → limbo_code-0.1.14}/uv.lock +0 -0
@@ -33,6 +33,8 @@ src/limbo/
33
33
  ├── goal_driver.py # /goal orchestrator (frontend-agnostic, non-UI): turn → verify → next round
34
34
  ├── sessions.py # Session JSONL save/load/list/export
35
35
  ├── trace.py # Append-only JSONL run log (sessions/traces/)
36
+ ├── integrations/ # External mux/orchestrator lifecycle reporters: base.py (protocol
37
+ │ # + CompositeReporter + AgentState), herdr.py; create_reporters() fans out
36
38
  ├── skills.py # SKILL.md discovery (user + project dirs)
37
39
  ├── user_paths.py # Fence grants from paths in user messages
38
40
  ├── llm/ # catalog.py (provider/model specs), factory.py (dialect→client),
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: limbo-code
3
- Version: 0.1.13
3
+ Version: 0.1.14
4
4
  Summary: A minimal terminal AI coding agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -344,6 +344,11 @@ class Agent:
344
344
  """Stable id of the current session (the session file stem)."""
345
345
  return self._meta.id
346
346
 
347
+ @property
348
+ def session_path(self) -> Path:
349
+ """Path of the current session JSONL file."""
350
+ return self._session_file
351
+
347
352
  @property
348
353
  def session_meta(self) -> SessionMeta:
349
354
  """Metadata of the current session (title filled in on first save)."""
@@ -0,0 +1,43 @@
1
+ """Integrations with external terminal multiplexers / agent orchestrators.
2
+
3
+ Each integration lives in its own module (e.g. ``herdr``) and implements
4
+ the ``IntegrationReporter`` protocol from ``base``. Its ``from_env()``
5
+ returns ``None`` when Limbo is not running inside that tool, so the
6
+ integration is a strict no-op elsewhere. ``create_reporters()`` collects
7
+ every active integration into one ``CompositeReporter`` — the only object
8
+ call sites ever touch.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Mapping
15
+
16
+ from limbo.integrations.base import (
17
+ AgentState,
18
+ CompositeReporter,
19
+ IntegrationReporter,
20
+ )
21
+ from limbo.integrations.herdr import HerdrReporter
22
+
23
+ __all__ = [
24
+ "AgentState",
25
+ "CompositeReporter",
26
+ "HerdrReporter",
27
+ "IntegrationReporter",
28
+ "create_reporters",
29
+ ]
30
+
31
+
32
+ def create_reporters(env: Mapping[str, str] | None = None) -> CompositeReporter:
33
+ """Detect active integrations from the environment.
34
+
35
+ Register new integrations here: append ``XReporter.from_env(env)`` when
36
+ it returns a reporter. Order is irrelevant; each reporter is
37
+ independent and fire-and-forget.
38
+ """
39
+ env = os.environ if env is None else env
40
+ reporters: list[IntegrationReporter] = []
41
+ if (herdr := HerdrReporter.from_env(env)) is not None:
42
+ reporters.append(herdr)
43
+ return CompositeReporter(reporters)
@@ -0,0 +1,87 @@
1
+ """Shared contract for terminal multiplexer / agent orchestrator integrations.
2
+
3
+ Every integration (Herdr, future *mux tools, ...) implements
4
+ ``IntegrationReporter``: it receives semantic lifecycle state
5
+ (``working``/``idle``/``blocked``) plus session identity, and maps that onto
6
+ whatever the specific tool understands. Call sites (UI, agent loop) only
7
+ ever talk to ``CompositeReporter`` — adding a new integration means writing
8
+ one module here and registering it in ``create_reporters()``, with zero
9
+ changes at call sites.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable
15
+ from typing import Literal, Protocol
16
+
17
+ # Semantic lifecycle states every integration must be able to represent.
18
+ # ``blocked`` means the agent is waiting on a user decision.
19
+ AgentState = Literal["working", "idle", "blocked"]
20
+
21
+
22
+ class IntegrationReporter(Protocol):
23
+ """One external tool interested in Limbo's lifecycle.
24
+
25
+ Implementations must be fire-and-forget: never block, never raise.
26
+ """
27
+
28
+ def report(
29
+ self,
30
+ state: AgentState,
31
+ *,
32
+ message: str | None = None,
33
+ session_id: str | None = None,
34
+ session_path: str | None = None,
35
+ ) -> None:
36
+ """Report lifecycle state (optionally carrying session identity)."""
37
+ ...
38
+
39
+ def report_session(
40
+ self, session_id: str, session_path: str | None = None
41
+ ) -> None:
42
+ """Report session identity when it changes independently of state."""
43
+ ...
44
+
45
+ def release(self) -> None:
46
+ """Release any held authority/resources (on agent exit)."""
47
+ ...
48
+
49
+
50
+ class CompositeReporter:
51
+ """Fans lifecycle reports out to every active integration.
52
+
53
+ Always safe to call, even with zero reporters — call sites never need
54
+ to know which (if any) integrations are active.
55
+ """
56
+
57
+ def __init__(self, reporters: Iterable[IntegrationReporter] = ()): # noqa: D107
58
+ self._reporters = list(reporters)
59
+
60
+ def __bool__(self) -> bool:
61
+ return bool(self._reporters)
62
+
63
+ def report(
64
+ self,
65
+ state: AgentState,
66
+ *,
67
+ message: str | None = None,
68
+ session_id: str | None = None,
69
+ session_path: str | None = None,
70
+ ) -> None:
71
+ for reporter in self._reporters:
72
+ reporter.report(
73
+ state,
74
+ message=message,
75
+ session_id=session_id,
76
+ session_path=session_path,
77
+ )
78
+
79
+ def report_session(
80
+ self, session_id: str, session_path: str | None = None
81
+ ) -> None:
82
+ for reporter in self._reporters:
83
+ reporter.report_session(session_id, session_path)
84
+
85
+ def release(self) -> None:
86
+ for reporter in self._reporters:
87
+ reporter.release()
@@ -0,0 +1,143 @@
1
+ """Herdr terminal multiplexer integration.
2
+
3
+ When Limbo runs inside a Herdr pane, it inherits ``HERDR_ENV``,
4
+ ``HERDR_PANE_ID``, ``HERDR_BIN_PATH``, and ``HERDR_SOCKET_PATH``. This
5
+ reporter uses Herdr's CLI to report semantic lifecycle state
6
+ (``working``/``idle``/``blocked``) and native session identity, per
7
+ https://herdr.dev/docs/integrations/#integrate-your-own-agent
8
+
9
+ The integration is a strict no-op outside Herdr: ``from_env()`` returns
10
+ ``None`` unless ``HERDR_ENV=1`` and both the pane id and a usable ``herdr``
11
+ binary (``HERDR_BIN_PATH``, else PATH) are present.
12
+ Reports are fire-and-forget CLI invocations — they must never block or
13
+ break the UI, and a missing/broken ``herdr`` binary is silently ignored.
14
+
15
+ ``--source`` must stay stable and unique to this integration; ``--seq`` is
16
+ strictly increasing so Herdr can drop reports that arrive out of order
17
+ (turn-end and steer follow-up hand-offs race between workers).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ from collections.abc import Mapping
26
+
27
+ from limbo.integrations.base import AgentState
28
+
29
+ SOURCE = "custom:limbo"
30
+ AGENT = "limbo"
31
+
32
+
33
+ class HerdrReporter:
34
+ """Fire-and-forget lifecycle reporter bound to one Herdr pane."""
35
+
36
+ def __init__(self, pane_id: str, bin_path: str):
37
+ self._pane_id = pane_id
38
+ self._bin_path = bin_path
39
+ self._seq = 0
40
+
41
+ @classmethod
42
+ def from_env(cls, env: Mapping[str, str] | None = None) -> HerdrReporter | None:
43
+ """Create a reporter when running inside a Herdr pane, else None.
44
+
45
+ ``HERDR_BIN_PATH`` is preferred (portable across PATH differences)
46
+ but is not guaranteed to be set — fall back to a PATH lookup.
47
+ """
48
+ env = os.environ if env is None else env
49
+ if env.get("HERDR_ENV") != "1":
50
+ return None
51
+ pane_id = env.get("HERDR_PANE_ID")
52
+ bin_path = env.get("HERDR_BIN_PATH") or shutil.which("herdr")
53
+ if not pane_id or not bin_path:
54
+ return None
55
+ return cls(pane_id, bin_path)
56
+
57
+ def report(
58
+ self,
59
+ state: AgentState,
60
+ *,
61
+ message: str | None = None,
62
+ session_id: str | None = None,
63
+ session_path: str | None = None,
64
+ ) -> None:
65
+ """Report lifecycle state (optionally with session identity)."""
66
+ cmd = [
67
+ self._bin_path,
68
+ "pane",
69
+ "report-agent",
70
+ self._pane_id,
71
+ "--source",
72
+ SOURCE,
73
+ "--agent",
74
+ AGENT,
75
+ "--state",
76
+ state,
77
+ "--seq",
78
+ str(self._next_seq()),
79
+ ]
80
+ if message:
81
+ cmd += ["--message", message]
82
+ if session_id:
83
+ cmd += ["--agent-session-id", session_id]
84
+ if session_path:
85
+ cmd += ["--agent-session-path", session_path]
86
+ self._run(cmd)
87
+
88
+ def report_session(
89
+ self, session_id: str, session_path: str | None = None
90
+ ) -> None:
91
+ """Report session identity when it changes independently of state
92
+ (``/new``, ``/sessions`` switch)."""
93
+ cmd = [
94
+ self._bin_path,
95
+ "pane",
96
+ "report-agent-session",
97
+ self._pane_id,
98
+ "--source",
99
+ SOURCE,
100
+ "--agent",
101
+ AGENT,
102
+ "--seq",
103
+ str(self._next_seq()),
104
+ "--agent-session-id",
105
+ session_id,
106
+ ]
107
+ if session_path:
108
+ cmd += ["--agent-session-path", session_path]
109
+ self._run(cmd)
110
+
111
+ def release(self) -> None:
112
+ """Release this source's lifecycle authority (on agent exit)."""
113
+ self._run(
114
+ [
115
+ self._bin_path,
116
+ "pane",
117
+ "release-agent",
118
+ self._pane_id,
119
+ "--source",
120
+ SOURCE,
121
+ "--agent",
122
+ AGENT,
123
+ ]
124
+ )
125
+
126
+ def _next_seq(self) -> int:
127
+ # Assigned synchronously before spawning, so sequence order matches
128
+ # call order even when deliveries race.
129
+ self._seq += 1
130
+ return self._seq
131
+
132
+ @staticmethod
133
+ def _run(cmd: list[str]) -> None:
134
+ try:
135
+ subprocess.Popen(
136
+ cmd,
137
+ stdin=subprocess.DEVNULL,
138
+ stdout=subprocess.DEVNULL,
139
+ stderr=subprocess.DEVNULL,
140
+ )
141
+ except OSError:
142
+ # A missing/broken herdr binary must never break the session.
143
+ pass
@@ -41,6 +41,7 @@ from limbo.goal_driver import (
41
41
  GoalVerifyResultEvent,
42
42
  GoalVerifyStarted,
43
43
  )
44
+ from limbo.integrations import AgentState, create_reporters
44
45
  from limbo.llm.client import LLMClient
45
46
  from limbo.llm.factory import create_llm_client
46
47
  from limbo.model_switch import (
@@ -111,6 +112,9 @@ class MainScreen(Screen[None]):
111
112
  self._compact_busy = False
112
113
  self._commands = SlashCommandRegistry()
113
114
  self._register_builtin_commands()
115
+ # External tool integrations (Herdr, ...): an empty composite
116
+ # outside any integrated environment, so all calls are no-ops.
117
+ self._integrations = create_reporters()
114
118
 
115
119
  @property
116
120
  def _agent_busy(self) -> bool:
@@ -140,6 +144,20 @@ class MainScreen(Screen[None]):
140
144
  resume=resume,
141
145
  )
142
146
 
147
+ # -- External tool integrations (no-op outside integrated environments) --
148
+
149
+ def _report_state(
150
+ self, state: AgentState, *, message: str | None = None
151
+ ) -> None:
152
+ """Report semantic lifecycle state to active integrations."""
153
+ self._integrations.report(state, message=message)
154
+
155
+ def _report_session(self) -> None:
156
+ """Report the current session identity (startup, /new, /sessions)."""
157
+ self._integrations.report_session(
158
+ self.agent.session_id, str(self.agent.session_path)
159
+ )
160
+
143
161
  def compose(self) -> ComposeResult:
144
162
  yield StatusBar(
145
163
  model=self.config.llm.model,
@@ -169,6 +187,8 @@ class MainScreen(Screen[None]):
169
187
  )
170
188
  self._refresh_goal_indicator() # D6: silent resume, badge only
171
189
  self.query_one("#input", InputWidget).focus()
190
+ self._report_session()
191
+ self._report_state("idle")
172
192
 
173
193
  # -- slash command menu ---------------------------------------------------
174
194
 
@@ -332,6 +352,7 @@ class MainScreen(Screen[None]):
332
352
  self._pending_proposals = parsed or []
333
353
  if not self._pending_proposals:
334
354
  chat.add_info("未能解析模型的验收提议,请手动输入或跳过")
355
+ self._report_state("blocked", message="等待确认 /goal 验收命令")
335
356
  self.app.push_screen(
336
357
  VerifyPicker(self._pending_proposals), self._on_verify_picked
337
358
  )
@@ -345,6 +366,7 @@ class MainScreen(Screen[None]):
345
366
  if choice is None:
346
367
  chat.add_info("已跳过自动验收(单轮模式);/goal clear 可退出 goal")
347
368
  self._refresh_goal_indicator()
369
+ self._report_state("idle")
348
370
  return
349
371
  if choice == VERIFY_EDIT:
350
372
  # Prefill the best proposal for editing; the next submission is
@@ -356,6 +378,7 @@ class MainScreen(Screen[None]):
356
378
  input_widget.focus()
357
379
  self._editing_verify = True
358
380
  chat.add_info("编辑验收命令后回车确认(清空回车则跳过)")
381
+ self._report_state("blocked", message="等待编辑 /goal 验收命令")
359
382
  return
360
383
  self._accept_verify(choice)
361
384
 
@@ -364,6 +387,7 @@ class MainScreen(Screen[None]):
364
387
  if is_dangerous(command, self.config.safety.dangerous_commands):
365
388
  chat.add_error(f"验收命令命中危险命令过滤,已拒绝:{command}")
366
389
  chat.add_info("goal 保持单轮模式;/goal clear 退出")
390
+ self._report_state("idle")
367
391
  return
368
392
  state = self.driver.set_verify(command)
369
393
  if state is None:
@@ -484,6 +508,7 @@ class MainScreen(Screen[None]):
484
508
  input_widget.disabled = True
485
509
  self._compact_busy = True
486
510
  statusbar.set_state("compacting…", "thinking")
511
+ self._report_state("working")
487
512
  try:
488
513
  async for event in self.agent.compact(trigger="manual"):
489
514
  await self._process_agent_event(event)
@@ -492,6 +517,7 @@ class MainScreen(Screen[None]):
492
517
  input_widget.disabled = False
493
518
  input_widget.focus()
494
519
  statusbar.set_state("idle")
520
+ self._report_state("idle")
495
521
 
496
522
  def _show_help(self) -> None:
497
523
  self.query_one("#chat", ChatWidget).add_info(self._commands.help_text())
@@ -549,6 +575,7 @@ class MainScreen(Screen[None]):
549
575
  self.agent = self._new_agent()
550
576
  chat.clear()
551
577
  chat.add_info(f"已开始新会话 {self.agent.session_id}")
578
+ self._report_session()
552
579
 
553
580
  def _find_skill(self, name: str) -> Skill | None:
554
581
  if self._commands.get(f"/{name}") is not None:
@@ -602,6 +629,7 @@ class MainScreen(Screen[None]):
602
629
  self._render_history()
603
630
  meta = self.agent.session_meta
604
631
  chat.add_info(f"已切换到会话 {meta.id} · {meta.title or '(无标题)'}")
632
+ self._report_session()
605
633
 
606
634
  def _render_history(self) -> None:
607
635
  """Render the agent's restored history into the chat flow.
@@ -684,6 +712,7 @@ class MainScreen(Screen[None]):
684
712
  async def on_unmount(self) -> None:
685
713
  """Close owned resources on shutdown: the LLM client's HTTP pool and
686
714
  the agent's trace file handle."""
715
+ self._integrations.release()
687
716
  self.agent.close()
688
717
  close = getattr(self.llm_client, "close", None)
689
718
  if close is not None:
@@ -707,6 +736,7 @@ class MainScreen(Screen[None]):
707
736
  self._editing_verify = False
708
737
  chat = self.query_one("#chat", ChatWidget)
709
738
  if text.startswith("/"):
739
+ self._report_state("idle")
710
740
  self._handle_command(text)
711
741
  return
712
742
  command = text.strip()
@@ -715,6 +745,7 @@ class MainScreen(Screen[None]):
715
745
  self._accept_verify(command)
716
746
  else:
717
747
  chat.add_info("已跳过自动验收(单轮模式);/goal clear 可退出 goal")
748
+ self._report_state("idle")
718
749
  return
719
750
  if text.startswith("/"):
720
751
  self._handle_command(text)
@@ -825,6 +856,7 @@ class MainScreen(Screen[None]):
825
856
  # go to the steer queue instead of being blocked. Single-flight is
826
857
  # owned by the goal driver (``running`` is set eagerly on call).
827
858
  statusbar.set_state("thinking…", "thinking")
859
+ self._report_state("working")
828
860
  # driver.run() sets ``running`` eagerly at call time, so the badge
829
861
  # refresh below already sees the loop as executing (rainbow on).
830
862
  events = self.driver.run(user_input, attachments)
@@ -858,6 +890,7 @@ class MainScreen(Screen[None]):
858
890
  # the user can keep typing without clicking.
859
891
  input_widget.focus()
860
892
  statusbar.set_state("idle")
893
+ self._report_state("idle")
861
894
  self._refresh_goal_indicator()
862
895
  # M2: a proposal round that ends without leftover steers pops
863
896
  # the verify-command confirmation picker.
@@ -0,0 +1,145 @@
1
+ """Tests for the Herdr integration reporter (limbo.integrations.herdr)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+ from limbo.integrations.herdr import AGENT, SOURCE, HerdrReporter
9
+
10
+ FULL_ENV = {
11
+ "HERDR_ENV": "1",
12
+ "HERDR_PANE_ID": "w1:p2",
13
+ "HERDR_BIN_PATH": "/usr/local/bin/herdr",
14
+ "HERDR_SOCKET_PATH": "/tmp/herdr.sock",
15
+ }
16
+
17
+
18
+ def make_reporter(monkeypatch) -> tuple[HerdrReporter, list[list[str]]]:
19
+ """A reporter with Popen captured; returns (reporter, recorded argv)."""
20
+ calls: list[list[str]] = []
21
+
22
+ def fake_popen(cmd, **kwargs):
23
+ calls.append(list(cmd))
24
+
25
+ class _Proc:
26
+ def wait(self, timeout=None):
27
+ return 0
28
+
29
+ return _Proc()
30
+
31
+ monkeypatch.setattr(subprocess, "Popen", fake_popen)
32
+ reporter = HerdrReporter.from_env(FULL_ENV)
33
+ assert reporter is not None
34
+ return reporter, calls
35
+
36
+
37
+ def test_from_env_outside_herdr_is_none():
38
+ assert HerdrReporter.from_env({}) is None
39
+ assert HerdrReporter.from_env({"HERDR_ENV": "0"}) is None
40
+
41
+
42
+ def test_from_env_requires_pane_and_bin(monkeypatch):
43
+ # No herdr binary anywhere: even a full-looking env cannot activate.
44
+ monkeypatch.setattr(shutil, "which", lambda name: None)
45
+ assert HerdrReporter.from_env({"HERDR_ENV": "1"}) is None
46
+ assert (
47
+ HerdrReporter.from_env({"HERDR_ENV": "1", "HERDR_PANE_ID": "w1:p1"}) is None
48
+ )
49
+ assert (
50
+ HerdrReporter.from_env(
51
+ {"HERDR_ENV": "1", "HERDR_BIN_PATH": "/bin/herdr"}
52
+ )
53
+ is None
54
+ )
55
+
56
+
57
+ def test_from_env_falls_back_to_path_lookup(monkeypatch):
58
+ # HERDR_BIN_PATH is not guaranteed to be set; find herdr on PATH.
59
+ monkeypatch.setattr(shutil, "which", lambda name: "/resolved/herdr")
60
+ env = {"HERDR_ENV": "1", "HERDR_PANE_ID": "w1:p1", "HERDR_BIN_PATH": ""}
61
+ reporter = HerdrReporter.from_env(env)
62
+ assert isinstance(reporter, HerdrReporter)
63
+ assert reporter._bin_path == "/resolved/herdr"
64
+
65
+
66
+ def test_from_env_inside_herdr():
67
+ reporter = HerdrReporter.from_env(FULL_ENV)
68
+ assert isinstance(reporter, HerdrReporter)
69
+
70
+
71
+ def test_report_state_command_and_seq(monkeypatch):
72
+ reporter, calls = make_reporter(monkeypatch)
73
+ reporter.report("working")
74
+ reporter.report("idle")
75
+
76
+ assert calls[0] == [
77
+ "/usr/local/bin/herdr",
78
+ "pane",
79
+ "report-agent",
80
+ "w1:p2",
81
+ "--source",
82
+ SOURCE,
83
+ "--agent",
84
+ AGENT,
85
+ "--state",
86
+ "working",
87
+ "--seq",
88
+ "1",
89
+ ]
90
+ # Strictly increasing seq across reports from the same source.
91
+ assert calls[1][calls[1].index("--seq") + 1] == "2"
92
+ assert calls[1][calls[1].index("--state") + 1] == "idle"
93
+
94
+
95
+ def test_report_with_message_and_session(monkeypatch):
96
+ reporter, calls = make_reporter(monkeypatch)
97
+ reporter.report(
98
+ "blocked",
99
+ message="等待确认",
100
+ session_id="sess-1",
101
+ session_path="/tmp/sess-1.jsonl",
102
+ )
103
+ cmd = calls[0]
104
+ assert cmd[cmd.index("--message") + 1] == "等待确认"
105
+ assert cmd[cmd.index("--agent-session-id") + 1] == "sess-1"
106
+ assert cmd[cmd.index("--agent-session-path") + 1] == "/tmp/sess-1.jsonl"
107
+
108
+
109
+ def test_report_session_command(monkeypatch):
110
+ reporter, calls = make_reporter(monkeypatch)
111
+ reporter.report("idle")
112
+ reporter.report_session("sess-2", "/tmp/sess-2.jsonl")
113
+ cmd = calls[1]
114
+ assert cmd[:3] == ["/usr/local/bin/herdr", "pane", "report-agent-session"]
115
+ assert cmd[cmd.index("--agent-session-id") + 1] == "sess-2"
116
+ assert cmd[cmd.index("--agent-session-path") + 1] == "/tmp/sess-2.jsonl"
117
+ # Session reports share the same increasing sequence as state reports.
118
+ assert cmd[cmd.index("--seq") + 1] == "2"
119
+
120
+
121
+ def test_release_command(monkeypatch):
122
+ reporter, calls = make_reporter(monkeypatch)
123
+ reporter.release()
124
+ assert calls[0] == [
125
+ "/usr/local/bin/herdr",
126
+ "pane",
127
+ "release-agent",
128
+ "w1:p2",
129
+ "--source",
130
+ SOURCE,
131
+ "--agent",
132
+ AGENT,
133
+ ]
134
+
135
+
136
+ def test_popen_failure_is_swallowed(monkeypatch):
137
+ def boom(cmd, **kwargs):
138
+ raise OSError("no such file")
139
+
140
+ monkeypatch.setattr(subprocess, "Popen", boom)
141
+ reporter = HerdrReporter.from_env(FULL_ENV)
142
+ assert reporter is not None
143
+ reporter.report("working") # must not raise
144
+ reporter.report_session("s") # must not raise
145
+ reporter.release() # must not raise
@@ -0,0 +1,60 @@
1
+ """Tests for the integration abstraction layer (limbo.integrations)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from limbo.integrations import CompositeReporter, create_reporters
6
+
7
+
8
+ class FakeReporter:
9
+ def __init__(self):
10
+ self.calls: list[tuple] = []
11
+
12
+ def report(self, state, *, message=None, session_id=None, session_path=None):
13
+ self.calls.append(("report", state, message, session_id, session_path))
14
+
15
+ def report_session(self, session_id, session_path=None):
16
+ self.calls.append(("report_session", session_id, session_path))
17
+
18
+ def release(self):
19
+ self.calls.append(("release",))
20
+
21
+
22
+ def test_empty_composite_is_a_safe_noop():
23
+ composite = CompositeReporter()
24
+ assert not composite
25
+ composite.report("working") # must not raise
26
+ composite.report_session("s", "/tmp/s.jsonl")
27
+ composite.release()
28
+
29
+
30
+ def test_composite_fans_out_to_all_reporters():
31
+ a, b = FakeReporter(), FakeReporter()
32
+ composite = CompositeReporter([a, b])
33
+ assert composite
34
+
35
+ composite.report("blocked", message="等待确认")
36
+ composite.report_session("sess-1", "/tmp/sess-1.jsonl")
37
+ composite.release()
38
+
39
+ expected = [
40
+ ("report", "blocked", "等待确认", None, None),
41
+ ("report_session", "sess-1", "/tmp/sess-1.jsonl"),
42
+ ("release",),
43
+ ]
44
+ assert a.calls == expected
45
+ assert b.calls == expected
46
+
47
+
48
+ def test_create_reporters_outside_any_tool_is_empty():
49
+ assert not create_reporters({})
50
+
51
+
52
+ def test_create_reporters_detects_herdr():
53
+ composite = create_reporters(
54
+ {
55
+ "HERDR_ENV": "1",
56
+ "HERDR_PANE_ID": "w1:p1",
57
+ "HERDR_BIN_PATH": "/bin/herdr",
58
+ }
59
+ )
60
+ assert composite