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.
- {limbo_code-0.1.14 → limbo_code-0.1.16}/AGENTS.md +4 -6
- {limbo_code-0.1.14 → limbo_code-0.1.16}/CONTEXT.md +4 -2
- {limbo_code-0.1.14 → limbo_code-0.1.16}/PKG-INFO +13 -20
- {limbo_code-0.1.14 → limbo_code-0.1.16}/README.md +12 -19
- limbo_code-0.1.16/scripts/scroll_bench.py +142 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/agent.py +0 -6
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/config.py +0 -19
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/goal.py +1 -13
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/goal_driver.py +3 -13
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/integrations/__init__.py +31 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/integrations/herdr.py +25 -6
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/sessions.py +1 -4
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/base.py +11 -59
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/bash.py +2 -86
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/find.py +2 -33
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/grep.py +2 -32
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/read.py +1 -46
- limbo_code-0.1.16/src/limbo/tools/registry.py +66 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/app.py +2 -0
- limbo_code-0.1.16/src/limbo/ui/key_fixes.py +41 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/main.py +3 -39
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_agent.py +0 -32
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_config.py +0 -1
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_goal.py +1 -18
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_goal_driver.py +0 -11
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_herdr.py +23 -4
- limbo_code-0.1.16/tests/test_integrations.py +120 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_sessions.py +0 -21
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_base.py +4 -67
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_bash.py +13 -108
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_edit.py +12 -9
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_find.py +13 -73
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_grep.py +13 -42
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_ls.py +8 -4
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_read.py +17 -86
- limbo_code-0.1.16/tests/tools/test_registry.py +38 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_write.py +19 -20
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_copy.py +17 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_goal_ui.py +0 -22
- limbo_code-0.1.16/tests/ui/test_key_fixes.py +38 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_main_screen.py +0 -68
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_steer_ui.py +4 -7
- limbo_code-0.1.14/src/limbo/tools/registry.py +0 -121
- limbo_code-0.1.14/src/limbo/user_paths.py +0 -82
- limbo_code-0.1.14/tests/test_integrations.py +0 -60
- limbo_code-0.1.14/tests/test_user_paths.py +0 -87
- limbo_code-0.1.14/tests/tools/test_registry.py +0 -95
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/grill-with-docs/SKILL.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/SKILL.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/agents/openai.yaml +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/mocking.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.agents/skills/tdd/tests.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.github/workflows/publish.yml +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.github/workflows/test.yml +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/.gitignore +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/Makefile +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/confirm_view.html +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/limbo-ui-minimal.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/limbo-ui-redesign.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype-minimal-confirm.png +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype-minimal.html +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype-minimal.png +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype.html +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/design/prototype.png +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/limbo-current-ui.png +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/limbo-new-ui.png +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/session-management.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/skills.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/docs/ui-redesign-proposal.md +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/pyproject.toml +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/scripts/check_contrast.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/scripts/gen_banner.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/scripts/ui_walkthrough.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/skills-lock.json +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/__init__.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/__main__.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/app.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/attachments.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/btw.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/compaction.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/history.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/integrations/base.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/__init__.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/anthropic_client.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/catalog.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/client.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/factory.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/openai_client.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/responses_client.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/retry.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/scaffold.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/sse.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/llm/usage.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/model_switch.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/models.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/prompt.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/skills.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/steer.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/__init__.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/edit.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/ignore.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/ls.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/mutation_queue.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/tools/write.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/trace.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/__init__.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/app.tcss +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/banner.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/clipboard.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/commands.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/contrast.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/__init__.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/btw.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/game2048.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/model_picker.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/session_picker.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/screens/verify_picker.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/syntax.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/theme.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/__init__.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/chat.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/command_menu.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/input.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/status_bar.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/src/limbo/ui/widgets/tool_card.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/conftest.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_anthropic_client.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_attachments.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_btw.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_catalog.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_cli.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_clipboard.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_compaction.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_history.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_integration.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_interrupt.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_llm_client.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_llm_scaffold.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_model_switch.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_models.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_prompt.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_responses_client.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_retry.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_skills.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_sse.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_steer.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_trace.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/test_usage.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_ignore.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/tools/test_mutation_queue.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
- {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
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_app_smoke.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_btw_ui.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_command_menu.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_commands.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_compact_ui.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_contrast.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_game2048.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_input_attachments.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_input_history.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_input_paste.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_interrupt_ui.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_model_picker.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_scroll_follow.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_sessions_ui.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_skills_ui.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_snapshots.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_startup_art.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_theme.py +0 -0
- {limbo_code-0.1.14 → limbo_code-0.1.16}/tests/ui/test_widgets.py +0 -0
- {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.**
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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.
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
270
|
-
# User interrupt (Esc/clear)
|
|
271
|
-
#
|
|
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``
|
|
16
|
-
strictly increasing
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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.
|