limbo-code 0.1.14__tar.gz → 0.1.16__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 (190) hide show
  1. {limbo_code-0.1.14 → limbo_code-0.1.16}/AGENTS.md +4 -6
  2. {limbo_code-0.1.14 → limbo_code-0.1.16}/CONTEXT.md +4 -2
  3. {limbo_code-0.1.14 → limbo_code-0.1.16}/PKG-INFO +13 -20
  4. {limbo_code-0.1.14 → limbo_code-0.1.16}/README.md +12 -19
  5. limbo_code-0.1.16/scripts/scroll_bench.py +142 -0
  6. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/agent.py +0 -6
  7. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/config.py +0 -19
  8. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/goal.py +1 -13
  9. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/goal_driver.py +3 -13
  10. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/integrations/__init__.py +31 -0
  11. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/integrations/herdr.py +25 -6
  12. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/sessions.py +1 -4
  13. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/base.py +11 -59
  14. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/bash.py +2 -86
  15. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/find.py +2 -33
  16. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/grep.py +2 -32
  17. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/read.py +1 -46
  18. limbo_code-0.1.16/src/limbo/tools/registry.py +66 -0
  19. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/app.py +2 -0
  20. limbo_code-0.1.16/src/limbo/ui/key_fixes.py +41 -0
  21. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/main.py +3 -39
  22. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_agent.py +0 -32
  23. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_config.py +0 -1
  24. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_goal.py +1 -18
  25. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_goal_driver.py +0 -11
  26. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_herdr.py +23 -4
  27. limbo_code-0.1.16/tests/test_integrations.py +120 -0
  28. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_sessions.py +0 -21
  29. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_base.py +4 -67
  30. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_bash.py +13 -108
  31. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_edit.py +12 -9
  32. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_find.py +13 -73
  33. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_grep.py +13 -42
  34. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_ls.py +8 -4
  35. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_read.py +17 -86
  36. limbo_code-0.1.16/tests/tools/test_registry.py +38 -0
  37. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_write.py +19 -20
  38. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_copy.py +17 -0
  39. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_goal_ui.py +0 -22
  40. limbo_code-0.1.16/tests/ui/test_key_fixes.py +38 -0
  41. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_main_screen.py +0 -68
  42. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_steer_ui.py +4 -7
  43. limbo_code-0.1.14/src/limbo/tools/registry.py +0 -121
  44. limbo_code-0.1.14/src/limbo/user_paths.py +0 -82
  45. limbo_code-0.1.14/tests/test_integrations.py +0 -60
  46. limbo_code-0.1.14/tests/test_user_paths.py +0 -87
  47. limbo_code-0.1.14/tests/tools/test_registry.py +0 -95
  48. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  49. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  50. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  51. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  52. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  53. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/SKILL.md +0 -0
  54. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/agents/openai.yaml +0 -0
  55. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/mocking.md +0 -0
  56. {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/tests.md +0 -0
  57. {limbo_code-0.1.14 → limbo_code-0.1.16}/.github/workflows/publish.yml +0 -0
  58. {limbo_code-0.1.14 → limbo_code-0.1.16}/.github/workflows/test.yml +0 -0
  59. {limbo_code-0.1.14 → limbo_code-0.1.16}/.gitignore +0 -0
  60. {limbo_code-0.1.14 → limbo_code-0.1.16}/Makefile +0 -0
  61. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/confirm_view.html +0 -0
  62. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/limbo-ui-minimal.md +0 -0
  63. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/limbo-ui-redesign.md +0 -0
  64. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype-minimal-confirm.png +0 -0
  65. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype-minimal.html +0 -0
  66. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype-minimal.png +0 -0
  67. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype.html +0 -0
  68. {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype.png +0 -0
  69. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/limbo-current-ui.png +0 -0
  70. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/limbo-new-ui.png +0 -0
  71. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
  72. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
  73. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
  74. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
  75. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
  76. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
  77. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
  78. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
  79. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
  80. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
  81. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
  82. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
  83. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/session-management.md +0 -0
  84. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/skills.md +0 -0
  85. {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/ui-redesign-proposal.md +0 -0
  86. {limbo_code-0.1.14 → limbo_code-0.1.16}/pyproject.toml +0 -0
  87. {limbo_code-0.1.14 → limbo_code-0.1.16}/scripts/check_contrast.py +0 -0
  88. {limbo_code-0.1.14 → limbo_code-0.1.16}/scripts/gen_banner.py +0 -0
  89. {limbo_code-0.1.14 → limbo_code-0.1.16}/scripts/ui_walkthrough.py +0 -0
  90. {limbo_code-0.1.14 → limbo_code-0.1.16}/skills-lock.json +0 -0
  91. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/__init__.py +0 -0
  92. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/__main__.py +0 -0
  93. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/app.py +0 -0
  94. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/attachments.py +0 -0
  95. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/btw.py +0 -0
  96. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/compaction.py +0 -0
  97. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/history.py +0 -0
  98. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/integrations/base.py +0 -0
  99. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/__init__.py +0 -0
  100. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/anthropic_client.py +0 -0
  101. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/catalog.py +0 -0
  102. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/client.py +0 -0
  103. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/factory.py +0 -0
  104. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/openai_client.py +0 -0
  105. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/responses_client.py +0 -0
  106. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/retry.py +0 -0
  107. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/scaffold.py +0 -0
  108. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/sse.py +0 -0
  109. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/usage.py +0 -0
  110. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/model_switch.py +0 -0
  111. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/models.py +0 -0
  112. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/prompt.py +0 -0
  113. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/skills.py +0 -0
  114. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/steer.py +0 -0
  115. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/__init__.py +0 -0
  116. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/edit.py +0 -0
  117. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/ignore.py +0 -0
  118. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/ls.py +0 -0
  119. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/mutation_queue.py +0 -0
  120. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/write.py +0 -0
  121. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/trace.py +0 -0
  122. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/__init__.py +0 -0
  123. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/app.tcss +0 -0
  124. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/banner.py +0 -0
  125. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/clipboard.py +0 -0
  126. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/commands.py +0 -0
  127. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/contrast.py +0 -0
  128. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/__init__.py +0 -0
  129. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/btw.py +0 -0
  130. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/game2048.py +0 -0
  131. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/model_picker.py +0 -0
  132. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/session_picker.py +0 -0
  133. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/verify_picker.py +0 -0
  134. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/syntax.py +0 -0
  135. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/theme.py +0 -0
  136. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/__init__.py +0 -0
  137. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/chat.py +0 -0
  138. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/command_menu.py +0 -0
  139. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/input.py +0 -0
  140. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/status_bar.py +0 -0
  141. {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/tool_card.py +0 -0
  142. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/conftest.py +0 -0
  143. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_anthropic_client.py +0 -0
  144. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_attachments.py +0 -0
  145. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_btw.py +0 -0
  146. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_catalog.py +0 -0
  147. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_cli.py +0 -0
  148. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_clipboard.py +0 -0
  149. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_compaction.py +0 -0
  150. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_history.py +0 -0
  151. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_integration.py +0 -0
  152. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_interrupt.py +0 -0
  153. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_llm_client.py +0 -0
  154. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_llm_scaffold.py +0 -0
  155. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_model_switch.py +0 -0
  156. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_models.py +0 -0
  157. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_prompt.py +0 -0
  158. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_responses_client.py +0 -0
  159. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_retry.py +0 -0
  160. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_skills.py +0 -0
  161. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_sse.py +0 -0
  162. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_steer.py +0 -0
  163. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_trace.py +0 -0
  164. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_usage.py +0 -0
  165. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_ignore.py +0 -0
  166. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_mutation_queue.py +0 -0
  167. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
  168. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
  169. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
  170. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
  171. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_app_smoke.py +0 -0
  172. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_btw_ui.py +0 -0
  173. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_command_menu.py +0 -0
  174. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_commands.py +0 -0
  175. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_compact_ui.py +0 -0
  176. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_contrast.py +0 -0
  177. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_game2048.py +0 -0
  178. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_input_attachments.py +0 -0
  179. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_input_history.py +0 -0
  180. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_input_paste.py +0 -0
  181. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_interrupt_ui.py +0 -0
  182. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_model_picker.py +0 -0
  183. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_scroll_follow.py +0 -0
  184. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_sessions_ui.py +0 -0
  185. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_skills_ui.py +0 -0
  186. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_snapshots.py +0 -0
  187. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_startup_art.py +0 -0
  188. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_theme.py +0 -0
  189. {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_widgets.py +0 -0
  190. {limbo_code-0.1.14 → limbo_code-0.1.16}/uv.lock +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
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.
4
4
 
5
- **Tools execute immediately — no confirmation flow.** Convenience guardrails (not a security boundary): a workdir scope on file tools (+ session-scoped grants from user-mentioned paths), a sensitive-file skip list shared by `read`/`grep`/`find`, and a heuristic dangerous-command filter on `bash` (best-effort, rejects matches outright; `bash` is otherwise not covered by any guardrail).
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
 
7
7
  ## Quick Start
8
8
 
@@ -20,7 +20,7 @@ still works for a runtime-only install.
20
20
  ```
21
21
  src/limbo/
22
22
  ├── app.py # CLI entry: args, config, launch
23
- ├── config.py # Pydantic config: llm/ui/safety/tools/compaction/providers
23
+ ├── config.py # Pydantic config: llm/ui/tools/compaction/providers
24
24
  ├── models.py # Message, Attachment, ToolResult, LLMEvent
25
25
  ├── agent.py # Conversation loop (see below)
26
26
  ├── attachments.py # Attachment policy: vision gate, inline vs path-reference degrade
@@ -36,12 +36,11 @@ src/limbo/
36
36
  ├── integrations/ # External mux/orchestrator lifecycle reporters: base.py (protocol
37
37
  │ # + CompositeReporter + AgentState), herdr.py; create_reporters() fans out
38
38
  ├── skills.py # SKILL.md discovery (user + project dirs)
39
- ├── user_paths.py # Fence grants from paths in user messages
40
39
  ├── llm/ # catalog.py (provider/model specs), factory.py (dialect→client),
41
40
  │ # openai_client.py, anthropic_client.py, responses_client.py, retry.py, sse.py,
42
41
  │ # usage.py (token accounting: usage normalization + prompt-size estimation),
43
42
  │ # scaffold.py (plumbing shared by dialect clients: credentials, retry, images)
44
- ├── tools/ # base.py (BaseTool + path guardrail + truncation), registry.py (dispatch, grants),
43
+ ├── tools/ # base.py (BaseTool + path resolution + truncation), registry.py (dispatch),
45
44
  │ # mutation_queue.py (per-file locks), ignore.py (.gitignore),
46
45
  │ # read/bash/edit/write/grep/find/ls.py
47
46
  └── ui/ # app.py + app.tcss (ALL styles here; theme vars only, no bare hex),
@@ -70,12 +69,11 @@ Providers/models live in `llm/catalog.py`; unknown models get generic OpenAI-com
70
69
  [tools] # bash_enabled = true, parallel = true
71
70
  [compaction] # enabled, reserve_tokens = 16384, keep_recent_tokens = 20000
72
71
  [goal] # max_rounds = 10, verify_timeout_ms = 600000
73
- [safety] # dangerous_commands, sensitive_files, auto_grant_user_paths
74
72
  [ui] # theme, show_banner
75
73
  [providers.<id>] # base_url / api_key / api_key_env / headers
76
74
  ```
77
75
 
78
- `/model` rebuilds the client mid-session (refused while busy) and persists via tomlkit. Sessions resume via `--continue` / `--resume` / `/sessions`; resume repairs dangling tool calls and restores grants. Trace events: `session_start`, `user_message`, `llm_request` (full body), `llm_response`, `tool_call`, `tool_result`, `compaction*`, `error`, `turn_end`. `/export [path]` merges meta + trace + message snapshot (Markdown if `.md`).
76
+ `/model` rebuilds the client mid-session (refused while busy) and persists via tomlkit. Sessions resume via `--continue` / `--resume` / `/sessions`; resume repairs dangling tool calls. Trace events: `session_start`, `user_message`, `llm_request` (full body), `llm_response`, `tool_call`, `tool_result`, `compaction*`, `error`, `turn_end`. `/export [path]` merges meta + trace + message snapshot (Markdown if `.md`).
79
77
 
80
78
  Skills: `~/.limbo/skills/<name>/SKILL.md` (user) and `<workdir>/.agents/skills/<name>/SKILL.md` (project, wins collisions) — injected as a `<available_skills>` catalog in the system prompt and invocable via `/<name> [args]`.
81
79
 
@@ -39,5 +39,7 @@ proposals should name modules with these terms.
39
39
 
40
40
  ## Safety
41
41
 
42
- - **Fence** — the workdir boundary on file tools, widened by session-scoped
43
- **grants** (paths a real user mentions in a submitted message).
42
+ Limbo has no safety fences: file tools are not scoped to the workdir,
43
+ there is no sensitive-file list, and bash runs every command as given.
44
+ Only run it with trusted models in repositories you can afford to modify
45
+ or lose.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: limbo-code
3
- Version: 0.1.14
3
+ Version: 0.1.16
4
4
  Summary: A minimal terminal AI coding agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -234,25 +234,18 @@ flight.
234
234
 
235
235
  ## Safety
236
236
 
237
- Limbo executes every tool call immediately, without asking for confirmation.
238
-
239
- File tools (`read`, `edit`, `write`, `grep`, `find`, `ls`) are scoped to the
240
- current working directory plus any session grants (paths the user mentions in
241
- a message), and `read`/`grep`/`find` skip a small blocklist of sensitive files
242
- (`.env`, SSH keys) to avoid leaking secrets into the model context by accident.
243
- **These are convenience guardrails against accidents, not a security boundary.**
244
- The path check resolves the path before each operation, so a symlink swapped
245
- between the check and the operation (a time-of-check-to-time-of-use race) could
246
- escape it.
247
-
248
- Bash is an exception: it is started in the working directory but is **not**
249
- covered by any of these guardrails. Commands can `cd ..`, use absolute paths,
250
- and read or write outside the workdir. Commands that match dangerous patterns
251
- such as `rm` or `git reset --hard` are **rejected outright** by a best-effort
252
- heuristic filter — ordinary shell constructs can bypass it, so it only
253
- protects against accidents. The pattern list is configurable but cannot be
254
- disabled from the UI. Only run Limbo with trusted commands and in
255
- repositories you can afford to modify or lose.
237
+ Limbo executes every tool call immediately, without asking for confirmation,
238
+ and has **no safety fences**: file tools (`read`, `edit`, `write`, `grep`,
239
+ `find`, `ls`) are not scoped to the working directory — absolute paths and
240
+ symlink targets outside it are honored as-is — and there is no
241
+ sensitive-file blocklist, so `.env`, SSH keys, and the like are read and
242
+ searched like any other file.
243
+
244
+ Bash is likewise unrestricted: it is started in the working directory but
245
+ can `cd ..`, use absolute paths, read or write anywhere, and no command is
246
+ filtered out — `rm -rf` and `git reset --hard` run exactly as given. Only
247
+ run Limbo with trusted models and in repositories you can afford to modify
248
+ or lose.
256
249
 
257
250
  If you need to work with untrusted projects, disable the bash tool entirely:
258
251
 
@@ -220,25 +220,18 @@ flight.
220
220
 
221
221
  ## Safety
222
222
 
223
- Limbo executes every tool call immediately, without asking for confirmation.
224
-
225
- File tools (`read`, `edit`, `write`, `grep`, `find`, `ls`) are scoped to the
226
- current working directory plus any session grants (paths the user mentions in
227
- a message), and `read`/`grep`/`find` skip a small blocklist of sensitive files
228
- (`.env`, SSH keys) to avoid leaking secrets into the model context by accident.
229
- **These are convenience guardrails against accidents, not a security boundary.**
230
- The path check resolves the path before each operation, so a symlink swapped
231
- between the check and the operation (a time-of-check-to-time-of-use race) could
232
- escape it.
233
-
234
- Bash is an exception: it is started in the working directory but is **not**
235
- covered by any of these guardrails. Commands can `cd ..`, use absolute paths,
236
- and read or write outside the workdir. Commands that match dangerous patterns
237
- such as `rm` or `git reset --hard` are **rejected outright** by a best-effort
238
- heuristic filter — ordinary shell constructs can bypass it, so it only
239
- protects against accidents. The pattern list is configurable but cannot be
240
- disabled from the UI. Only run Limbo with trusted commands and in
241
- repositories you can afford to modify or lose.
223
+ Limbo executes every tool call immediately, without asking for confirmation,
224
+ and has **no safety fences**: file tools (`read`, `edit`, `write`, `grep`,
225
+ `find`, `ls`) are not scoped to the working directory — absolute paths and
226
+ symlink targets outside it are honored as-is — and there is no
227
+ sensitive-file blocklist, so `.env`, SSH keys, and the like are read and
228
+ searched like any other file.
229
+
230
+ Bash is likewise unrestricted: it is started in the working directory but
231
+ can `cd ..`, use absolute paths, read or write anywhere, and no command is
232
+ filtered out — `rm -rf` and `git reset --hard` run exactly as given. Only
233
+ run Limbo with trusted models and in repositories you can afford to modify
234
+ or lose.
242
235
 
243
236
  If you need to work with untrusted projects, disable the bash tool entirely:
244
237
 
@@ -0,0 +1,142 @@
1
+ """Benchmark: full cost of one scroll step in the real app layout.
2
+
3
+ Measures scroll_to(immediate) + the scroll layout pass (reflow_visible) +
4
+ compositor render + terminal-output build, for both a light chat and a
5
+ "busy" chat (expanded tool bodies with long outputs).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ from textual.app import App, ComposeResult
13
+
14
+ from limbo.ui.widgets.chat import ChatWidget
15
+
16
+
17
+ class BenchApp(App[None]):
18
+ CSS = """
19
+ Screen { layout: vertical; }
20
+ ChatWidget { width: 1fr; height: 1fr; }
21
+ """
22
+
23
+ def __init__(self, n_msgs: int = 120, heavy: bool = False) -> None:
24
+ super().__init__()
25
+ self.n_msgs = n_msgs
26
+ self.heavy = heavy
27
+ self.chat: ChatWidget | None = None
28
+ self._profiled = False
29
+
30
+ def compose(self) -> ComposeResult:
31
+ yield ChatWidget(id="chat")
32
+
33
+ async def on_mount(self) -> None:
34
+ self.chat = self.query_one("#chat", ChatWidget)
35
+ for i in range(self.n_msgs):
36
+ if i % 3 == 0:
37
+ self.chat.add_user_message(f"用户消息 #{i} " + "x" * 40)
38
+ elif i % 3 == 1:
39
+ self.chat.add_assistant_message(
40
+ f"assistant #{i}\n\n- item one\n- item two\n\n```python\nprint({i})\n```"
41
+ )
42
+ else:
43
+ card = self.chat.add_tool_card(
44
+ f"tool-{i}", "read", {"path": f"/tmp/file_{i}.py"}
45
+ )
46
+ card.set_success(
47
+ "\n".join(f"line {j}: content {i}" for j in range(80))
48
+ )
49
+ await self.chat.mount_all(self.chat.children)
50
+ if self.heavy:
51
+ for card in self.chat.tool_cards.values():
52
+ card.toggle()
53
+ await self.pause(0.1)
54
+ self._bench()
55
+
56
+ async def pause(self, secs: float) -> None:
57
+ import asyncio
58
+
59
+ await asyncio.sleep(secs)
60
+
61
+ def _bench(self) -> None:
62
+ import gc
63
+
64
+ gc.disable()
65
+ chat = self.chat
66
+ assert chat is not None
67
+ max_y = chat.max_scroll_y
68
+ print(f"heavy={self.heavy} widgets={len(chat.children)} max_scroll_y={max_y:.0f}")
69
+
70
+ # Full scroll-step: immediate scroll (fires watch_scroll_y) then let
71
+ # the screen do its scroll-layout + compositor pass, then build the
72
+ # terminal output like _compositor_refresh does.
73
+ step = max(1.0, max_y / 200)
74
+ pos = max_y * 0.5
75
+ times: list[float] = []
76
+ seg_times: list[float] = []
77
+ phase_times: dict[str, list[float]] = {
78
+ "scroll_to": [],
79
+ "refresh_layout": [],
80
+ "render_update": [],
81
+ "render_segments": [],
82
+ }
83
+ for _ in range(30):
84
+ t0 = time.perf_counter()
85
+ chat.scroll_to(y=pos, animate=False, immediate=True)
86
+ phase_times["scroll_to"].append(time.perf_counter() - t0)
87
+ # what the screen timer does on _scroll_required:
88
+ t1 = time.perf_counter()
89
+ self.screen._refresh_layout(scroll=True)
90
+ layout_cost = time.perf_counter() - t1
91
+ phase_times["refresh_layout"].append(layout_cost)
92
+ if layout_cost > 0.05 and self._profiled is False:
93
+ self._profiled = True
94
+ import cProfile, pstats, io
95
+
96
+ pr = cProfile.Profile()
97
+ pr.enable()
98
+ self.screen._refresh_layout(scroll=True)
99
+ pr.disable()
100
+ s = io.StringIO()
101
+ ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
102
+ ps.print_stats(18)
103
+ print("=== SLOW LAYOUT PROFILE ===")
104
+ print(s.getvalue())
105
+ t2 = time.perf_counter()
106
+ update = self.screen._compositor.render_update()
107
+ phase_times["render_update"].append(time.perf_counter() - t2)
108
+ # build the terminal escape-sequence output (the part that shows
109
+ # up even in fast terminals; the write itself is excluded here)
110
+ seg_start = time.perf_counter()
111
+ update.render_segments(self.app.console)
112
+ phase_times["render_segments"].append(time.perf_counter() - seg_start)
113
+ times.append(time.perf_counter() - t0)
114
+ seg_times.append(time.perf_counter() - seg_start)
115
+ pos += step
116
+ if pos > max_y:
117
+ pos = 0.0
118
+ times.sort()
119
+ med = times[len(times) // 2]
120
+ seg_times.sort()
121
+ seg_med = seg_times[len(seg_times) // 2]
122
+ print(
123
+ f" full scroll step: median={med * 1000:.2f} ms "
124
+ f"p95={times[int(len(times) * 0.95)] * 1000:.2f} ms "
125
+ f"-> ~{1.0 / med:.0f} fps"
126
+ )
127
+ print(f" output-build (render_segments): median={seg_med * 1000:.2f} ms")
128
+ for name, vals in phase_times.items():
129
+ vals.sort()
130
+ print(
131
+ f" {name}: median={vals[len(vals) // 2] * 1000:.2f} ms "
132
+ f"max={vals[-1] * 1000:.2f} ms"
133
+ )
134
+ self.exit()
135
+
136
+
137
+ if __name__ == "__main__":
138
+ import asyncio
139
+
140
+ for heavy in (False, True):
141
+ app = BenchApp(n_msgs=500, heavy=heavy)
142
+ asyncio.run(app.run_async(headless=True))
@@ -226,10 +226,6 @@ class Agent:
226
226
  self._meta, history, self._compactions = load_session(resume)
227
227
  self._session_file = resume
228
228
  self.messages.extend(repair_history(history))
229
- # Restore session-scoped path grants recorded in the meta.
230
- self.registry.add_allowed_roots(
231
- Path(p) for p in self._meta.allowed_roots
232
- )
233
229
  # Restore the iterative-summary chain across sessions.
234
230
  self._previous_summary = next(
235
231
  (c.summary for c in reversed(self._compactions) if c.summary),
@@ -1272,8 +1268,6 @@ class Agent:
1272
1268
  # conversations.
1273
1269
  if not self._meta.title:
1274
1270
  self._meta.title = derive_title(self.messages)
1275
- # Persist the current grants so resume restores the same fence.
1276
- self._meta.allowed_roots = sorted(str(p) for p in self.registry.allowed_roots)
1277
1271
  save_session(self._session_file, self._meta, self.messages, self._compactions)
1278
1272
 
1279
1273
  async def _save_session(self) -> None:
@@ -20,11 +20,6 @@ from toml import TomlDecodeError
20
20
 
21
21
  DEFAULT_CONFIG_PATH = Path.home() / ".limbo" / "config.toml"
22
22
 
23
- # Single source of truth for safety defaults; tools fall back to these when
24
- # constructed without explicit values.
25
- DEFAULT_DANGEROUS_COMMANDS = ["rm", "git reset --hard"]
26
- DEFAULT_SENSITIVE_FILES = [".env", "id_rsa", "id_ed25519", ".ssh"]
27
-
28
23
 
29
24
  class LLMConfig(BaseModel):
30
25
  api_key: str | None = None
@@ -174,19 +169,6 @@ class UIConfig(BaseModel):
174
169
  kitty_keyboard: KittyKeyboardMode = KittyKeyboardMode.AUTO
175
170
 
176
171
 
177
- class SafetyConfig(BaseModel):
178
- dangerous_commands: list[str] = Field(
179
- default_factory=lambda: list(DEFAULT_DANGEROUS_COMMANDS)
180
- )
181
- sensitive_files: list[str] = Field(
182
- default_factory=lambda: list(DEFAULT_SENSITIVE_FILES)
183
- )
184
- # Implicit grants: paths a real user mentions in a submitted message
185
- # widen the file-tool fence for the session (directories grant their
186
- # subtree, files grant themselves; only existing paths count).
187
- auto_grant_user_paths: bool = True
188
-
189
-
190
172
  class ToolsConfig(BaseModel):
191
173
  bash_enabled: bool = True
192
174
  # Execute multiple tool calls from one assistant turn concurrently.
@@ -235,7 +217,6 @@ class GoalSettings(BaseModel):
235
217
  class Config(BaseModel):
236
218
  llm: LLMConfig = Field(default_factory=LLMConfig)
237
219
  ui: UIConfig = Field(default_factory=UIConfig)
238
- safety: SafetyConfig = Field(default_factory=SafetyConfig)
239
220
  tools: ToolsConfig = Field(default_factory=ToolsConfig)
240
221
  compaction: CompactionSettings = Field(default_factory=CompactionSettings)
241
222
  goal: GoalSettings = Field(default_factory=GoalSettings)
@@ -18,9 +18,7 @@ from pathlib import Path
18
18
 
19
19
  from pydantic import BaseModel
20
20
 
21
- from limbo.config import DEFAULT_DANGEROUS_COMMANDS
22
21
  from limbo.tools.base import truncate_tail
23
- from limbo.tools.bash import is_dangerous
24
22
 
25
23
  # -- state --------------------------------------------------------------------
26
24
 
@@ -83,13 +81,11 @@ def parse_goal_args(arg: str) -> GoalCommand:
83
81
 
84
82
  @dataclass(frozen=True)
85
83
  class VerifyResult:
86
- # None when the command never produced an exit code (timeout / cancel /
87
- # refused by the dangerous-command filter).
84
+ # None when the command never produced an exit code (timeout / cancel).
88
85
  exit_code: int | None
89
86
  output_tail: str = ""
90
87
  timed_out: bool = False
91
88
  cancelled: bool = False
92
- refused: bool = False
93
89
 
94
90
 
95
91
  async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
@@ -124,7 +120,6 @@ async def run_verify(
124
120
  workdir: Path,
125
121
  *,
126
122
  timeout_ms: int = 600_000,
127
- dangerous_patterns: list[str] | None = None,
128
123
  ) -> VerifyResult:
129
124
  """Run the acceptance command; the exit code is the loop's gate.
130
125
 
@@ -133,13 +128,6 @@ async def run_verify(
133
128
  GoalDriver.cancel_verify) kills the process tree and re-raises so the
134
129
  caller can turn it into a ``cancelled`` result.
135
130
  """
136
- patterns = (
137
- list(dangerous_patterns)
138
- if dangerous_patterns is not None
139
- else list(DEFAULT_DANGEROUS_COMMANDS)
140
- )
141
- if is_dangerous(command, patterns):
142
- return VerifyResult(exit_code=None, refused=True)
143
131
  # Same shell dialect as the bash tool: bash -c, own process group.
144
132
  proc = await asyncio.create_subprocess_exec(
145
133
  shutil.which("bash") or "/bin/bash",
@@ -18,7 +18,6 @@ from dataclasses import dataclass
18
18
  from pathlib import Path
19
19
 
20
20
  from limbo.agent import Agent, AgentEvent, SteerEvent
21
- from limbo.config import DEFAULT_DANGEROUS_COMMANDS
22
21
  from limbo.goal import (
23
22
  STATUS_ACTIVE,
24
23
  STATUS_CLEARED,
@@ -93,18 +92,11 @@ class GoalDriver:
93
92
  *,
94
93
  max_rounds: int = 10,
95
94
  verify_timeout_ms: int = 600_000,
96
- dangerous_patterns: list[str] | None = None,
97
95
  ) -> None:
98
96
  self._agent = agent
99
97
  self._workdir = workdir
100
98
  self._max_rounds = max_rounds
101
99
  self._verify_timeout_ms = verify_timeout_ms
102
- # None falls back to the same default posture as the bash tool.
103
- self._dangerous_patterns = (
104
- list(dangerous_patterns)
105
- if dangerous_patterns is not None
106
- else list(DEFAULT_DANGEROUS_COMMANDS)
107
- )
108
100
  self._running = False
109
101
  self._verify_task: asyncio.Task[VerifyResult] | None = None
110
102
  # Adopt the configured budget onto a restored goal (D4/D6: silent
@@ -261,14 +253,13 @@ class GoalDriver:
261
253
  round=round_no,
262
254
  timed_out=result.timed_out,
263
255
  cancelled=result.cancelled,
264
- refused=result.refused,
265
256
  )
266
257
  yield GoalVerifyResultEvent(
267
258
  result=result, round=round_no, max_rounds=state.max_rounds
268
259
  )
269
- if result.cancelled or result.refused:
270
- # User interrupt (Esc/clear) or a config change made the
271
- # command dangerous: stop the loop, goal stays active.
260
+ if result.cancelled:
261
+ # User interrupt (Esc/clear): stop the loop, goal stays
262
+ # active.
272
263
  return
273
264
 
274
265
  state = next_state(state, result)
@@ -303,7 +294,6 @@ class GoalDriver:
303
294
  command,
304
295
  self._workdir,
305
296
  timeout_ms=self._verify_timeout_ms,
306
- dangerous_patterns=self._dangerous_patterns,
307
297
  )
308
298
  )
309
299
  self._verify_task = task
@@ -10,8 +10,11 @@ call sites ever touch.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import atexit
13
14
  import os
15
+ import signal
14
16
  from collections.abc import Mapping
17
+ from types import FrameType
15
18
 
16
19
  from limbo.integrations.base import (
17
20
  AgentState,
@@ -26,6 +29,7 @@ __all__ = [
26
29
  "HerdrReporter",
27
30
  "IntegrationReporter",
28
31
  "create_reporters",
32
+ "install_exit_hooks",
29
33
  ]
30
34
 
31
35
 
@@ -41,3 +45,30 @@ def create_reporters(env: Mapping[str, str] | None = None) -> CompositeReporter:
41
45
  if (herdr := HerdrReporter.from_env(env)) is not None:
42
46
  reporters.append(herdr)
43
47
  return CompositeReporter(reporters)
48
+
49
+
50
+ def install_exit_hooks(reporters: CompositeReporter) -> None:
51
+ """Release integration authority on every process-exit path.
52
+
53
+ A clean Textual quit releases via ``MainScreen.on_unmount``, but that
54
+ never runs when the process dies by signal — e.g. the pane is closed
55
+ or killed (SIGHUP/SIGTERM) — and Herdr would keep showing the agent
56
+ forever. Covered here: normal interpreter exit (``atexit``, which also
57
+ catches ``SystemExit`` and unhandled exceptions) and SIGHUP/SIGTERM
58
+ (released, then re-raised with the default disposition so the process
59
+ still dies by the original signal). Reporters are idempotent, so
60
+ several hooks firing on one exit path is harmless.
61
+ """
62
+ if not reporters:
63
+ return
64
+ atexit.register(reporters.release)
65
+
66
+ def _release_and_reraise(signum: int, frame: FrameType | None) -> None:
67
+ reporters.release()
68
+ signal.signal(signum, signal.SIG_DFL)
69
+ os.kill(os.getpid(), signum)
70
+
71
+ for name in ("SIGHUP", "SIGTERM"):
72
+ signum = getattr(signal, name, None)
73
+ if signum is not None: # SIGHUP does not exist on Windows
74
+ signal.signal(signum, _release_and_reraise)
@@ -12,9 +12,12 @@ binary (``HERDR_BIN_PATH``, else PATH) are present.
12
12
  Reports are fire-and-forget CLI invocations — they must never block or
13
13
  break the UI, and a missing/broken ``herdr`` binary is silently ignored.
14
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).
15
+ ``--source`` must stay stable and unique to this integration; ``--seq``
16
+ must be strictly increasing — Herdr keeps a per-(pane, source) watermark
17
+ and silently drops anything at or below it, including ``release-agent``.
18
+ The watermark survives process restarts, so the sequence starts from the
19
+ wall clock (nanoseconds), not 1: a new Limbo process in a pane that
20
+ previously ran one would otherwise never clear the old watermark.
18
21
  """
19
22
 
20
23
  from __future__ import annotations
@@ -22,6 +25,7 @@ from __future__ import annotations
22
25
  import os
23
26
  import shutil
24
27
  import subprocess
28
+ import time
25
29
  from collections.abc import Mapping
26
30
 
27
31
  from limbo.integrations.base import AgentState
@@ -33,10 +37,13 @@ AGENT = "limbo"
33
37
  class HerdrReporter:
34
38
  """Fire-and-forget lifecycle reporter bound to one Herdr pane."""
35
39
 
36
- def __init__(self, pane_id: str, bin_path: str):
40
+ def __init__(self, pane_id: str, bin_path: str, start_seq: int | None = None):
37
41
  self._pane_id = pane_id
38
42
  self._bin_path = bin_path
39
- self._seq = 0
43
+ # Start from the wall clock so a later process in the same pane
44
+ # clears the previous one's watermark (see module docstring).
45
+ self._seq = start_seq if start_seq is not None else time.time_ns()
46
+ self._released = False
40
47
 
41
48
  @classmethod
42
49
  def from_env(cls, env: Mapping[str, str] | None = None) -> HerdrReporter | None:
@@ -109,7 +116,17 @@ class HerdrReporter:
109
116
  self._run(cmd)
110
117
 
111
118
  def release(self) -> None:
112
- """Release this source's lifecycle authority (on agent exit)."""
119
+ """Release this source's lifecycle authority (on agent exit).
120
+
121
+ Idempotent: ``on_unmount``, ``atexit``, and signal handlers all
122
+ call this on the way out; only the first call reports.
123
+ """
124
+ if self._released:
125
+ return
126
+ self._released = True
127
+ # seq matters here: Herdr drops a release at or below this source's
128
+ # watermark, so a seq-less release after sequenced reports is
129
+ # silently ignored.
113
130
  self._run(
114
131
  [
115
132
  self._bin_path,
@@ -120,6 +137,8 @@ class HerdrReporter:
120
137
  SOURCE,
121
138
  "--agent",
122
139
  AGENT,
140
+ "--seq",
141
+ str(self._next_seq()),
123
142
  ]
124
143
  )
125
144
 
@@ -18,7 +18,7 @@ from datetime import datetime, timezone
18
18
  from pathlib import Path
19
19
  from typing import Any
20
20
 
21
- from pydantic import BaseModel, Field
21
+ from pydantic import BaseModel
22
22
 
23
23
  from limbo.compaction import is_summary_message, make_summary_message
24
24
  from limbo.goal import GoalState
@@ -55,9 +55,6 @@ class SessionMeta(BaseModel):
55
55
  title: str = ""
56
56
  created_at: str = ""
57
57
  updated_at: str = ""
58
- # Session-scoped granted roots beyond the workdir (implicit user
59
- # grants); restored on resume so the fence stays consistent.
60
- allowed_roots: list[str] = Field(default_factory=list)
61
58
  # Active/finished closed-loop goal (LIM-40); restored on resume (D4/D6).
62
59
  goal: GoalState | None = None
63
60
  # Populated by list/load; not written to disk.