turnloop 0.1.3__tar.gz → 0.1.4__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 (155) hide show
  1. {turnloop-0.1.3 → turnloop-0.1.4}/PKG-INFO +109 -8
  2. {turnloop-0.1.3 → turnloop-0.1.4}/README.md +108 -7
  3. {turnloop-0.1.3 → turnloop-0.1.4}/pyproject.toml +1 -1
  4. turnloop-0.1.4/tests/test_config_screen.py +100 -0
  5. turnloop-0.1.4/tests/test_configio.py +251 -0
  6. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_hooks_mcp_commands.py +52 -0
  7. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/__init__.py +1 -1
  8. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/cli.py +32 -0
  9. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/commands/dispatch.py +22 -2
  10. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/config.py +7 -0
  11. turnloop-0.1.4/turnloop/configio.py +250 -0
  12. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/pricing.py +148 -148
  13. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/app.py +95 -10
  14. turnloop-0.1.4/turnloop/tui/config_screen.py +438 -0
  15. turnloop-0.1.4/turnloop/tui/mcp_screen.py +344 -0
  16. {turnloop-0.1.3 → turnloop-0.1.4}/.github/workflows/ci.yml +0 -0
  17. {turnloop-0.1.3 → turnloop-0.1.4}/.gitignore +0 -0
  18. {turnloop-0.1.3 → turnloop-0.1.4}/.turnloop/.gitignore +0 -0
  19. {turnloop-0.1.3 → turnloop-0.1.4}/tests/conftest.py +0 -0
  20. {turnloop-0.1.3 → turnloop-0.1.4}/tests/fixtures/anthropic_tool_call.sse +0 -0
  21. {turnloop-0.1.3 → turnloop-0.1.4}/tests/fixtures/glm_tool_call.sse +0 -0
  22. {turnloop-0.1.3 → turnloop-0.1.4}/tests/fixtures/mcp_echo_server.py +0 -0
  23. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_bash.py +0 -0
  24. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_compaction.py +0 -0
  25. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_config.py +0 -0
  26. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_experiments.py +0 -0
  27. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_loop.py +0 -0
  28. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_permissions.py +0 -0
  29. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_providers.py +0 -0
  30. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_tools_files.py +0 -0
  31. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_tui.py +0 -0
  32. {turnloop-0.1.3 → turnloop-0.1.4}/tests/test_websearch.py +0 -0
  33. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/__main__.py +0 -0
  34. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/agent/__init__.py +0 -0
  35. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/agent/factory.py +0 -0
  36. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/agent/headless.py +0 -0
  37. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/agent/loop.py +0 -0
  38. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/agent/subagent.py +0 -0
  39. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/agent/system_prompt.py +0 -0
  40. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/commands/__init__.py +0 -0
  41. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/commands/loader.py +0 -0
  42. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/context/__init__.py +0 -0
  43. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/context/budget.py +0 -0
  44. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/context/compaction.py +0 -0
  45. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/context/memory.py +0 -0
  46. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/core/__init__.py +0 -0
  47. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/core/events.py +0 -0
  48. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/core/ids.py +0 -0
  49. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/core/messages.py +0 -0
  50. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/core/tokens.py +0 -0
  51. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/diagnostics.py +0 -0
  52. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/errors.py +0 -0
  53. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/__init__.py +0 -0
  54. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  55. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  56. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/context-glm.yaml +0 -0
  57. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/context.yaml +0 -0
  58. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  59. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/groq-free.yaml +0 -0
  60. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  61. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  62. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/loop.yaml +0 -0
  63. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  64. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/reliability.yaml +0 -0
  65. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  66. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/configs/smoke.yaml +0 -0
  67. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/graders.py +0 -0
  68. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/report.py +0 -0
  69. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/runner.py +0 -0
  70. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/default.yaml +0 -0
  71. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  72. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  73. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  74. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  75. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  76. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  77. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  78. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  79. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  80. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  81. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  82. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  83. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  84. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  85. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  86. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  87. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  88. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  89. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  90. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  91. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  92. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  93. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  94. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  95. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  96. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  97. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  98. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  99. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  100. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  101. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  102. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  103. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  104. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  105. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  106. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  107. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  108. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  109. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  110. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  111. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  112. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/hooks/__init__.py +0 -0
  113. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/hooks/runner.py +0 -0
  114. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/mcp/__init__.py +0 -0
  115. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/mcp/adapter.py +0 -0
  116. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/mcp/client.py +0 -0
  117. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/permissions/__init__.py +0 -0
  118. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/permissions/engine.py +0 -0
  119. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/permissions/rules.py +0 -0
  120. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/__init__.py +0 -0
  121. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/anthropic.py +0 -0
  122. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/base.py +0 -0
  123. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/gemini.py +0 -0
  124. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/mock.py +0 -0
  125. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/openai_compat.py +0 -0
  126. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/registry.py +0 -0
  127. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/providers/sse.py +0 -0
  128. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/sessions/__init__.py +0 -0
  129. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/sessions/models.py +0 -0
  130. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/sessions/store.py +0 -0
  131. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/__init__.py +0 -0
  132. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/ask.py +0 -0
  133. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/base.py +0 -0
  134. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/bash.py +0 -0
  135. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/builtin.py +0 -0
  136. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/edit.py +0 -0
  137. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/glob.py +0 -0
  138. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/grep.py +0 -0
  139. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/read.py +0 -0
  140. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/runner.py +0 -0
  141. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/shell.py +0 -0
  142. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/skill.py +0 -0
  143. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/task.py +0 -0
  144. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/textio.py +0 -0
  145. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/todo.py +0 -0
  146. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/webfetch.py +0 -0
  147. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/websearch.py +0 -0
  148. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tools/write.py +0 -0
  149. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/__init__.py +0 -0
  150. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/app.tcss +0 -0
  151. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/bridge.py +0 -0
  152. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/widgets/__init__.py +0 -0
  153. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/widgets/permission.py +0 -0
  154. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/widgets/status.py +0 -0
  155. {turnloop-0.1.3 → turnloop-0.1.4}/turnloop/tui/widgets/transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: turnloop
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider.
5
5
  Project-URL: Homepage, https://github.com/Pranesh-2005/turnloop
6
6
  Author: Pranesh
@@ -117,7 +117,7 @@ point.
117
117
  Verify:
118
118
 
119
119
  ```bash
120
- tl --version # turnloop 0.1.3
120
+ tl --version # turnloop 0.1.4
121
121
  tl doctor # every provider, key presence, shell, context budget
122
122
  ```
123
123
 
@@ -143,8 +143,14 @@ tl --resume # resume the most recent session
143
143
  tl --resume <session-id> # resume a specific one
144
144
  tl sessions # list recorded sessions
145
145
  tl config # merged config + which layer each value came from
146
+ tl config --edit # interactive settings editor
147
+ tl mcp # add, edit, enable/disable, remove MCP servers
146
148
  ```
147
149
 
150
+ `config --edit` and `mcp` are deliberately the only paths that write a settings file,
151
+ and neither is reachable by the model — see
152
+ [Editing configuration](#editing-configuration).
153
+
148
154
  ### Flags
149
155
 
150
156
  | flag | meaning |
@@ -195,9 +201,10 @@ focus and would otherwise swallow them.
195
201
  /compact summarize conversation now /memory discovered memory files
196
202
  /cost token and cost accounting /tools available tools
197
203
  /context what is filling the window /mcp MCP server status
198
- /model show or change model /hooks configured hooks
199
- /provider switch provider /sessions recorded sessions
200
- /export transcript to markdown /doctor diagnostics
204
+ /model show or change model /mcp add add an MCP server
205
+ /provider switch provider /config settings editor
206
+ /export transcript to markdown /hooks configured hooks
207
+ /sessions recorded sessions /doctor diagnostics
201
208
  /quit exit
202
209
  ```
203
210
 
@@ -336,7 +343,18 @@ every segment is allowed.** Without that, `Bash(git *)` grants
336
343
  scanner that respects quoting — `shlex` discards operators, so the `rm` comes back
337
344
  looking like an argument to `git`. 48 tests cover this file alone.
338
345
 
339
- Ten deny rules ship by default, including `Read(**/.env)`.
346
+ Twelve deny rules ship by default, including `Read(**/.env)` and — since 0.1.4 —
347
+ `Write(**/.turnloop/settings*.json)` and `Edit(**/.turnloop/settings*.json)`. Those two
348
+ close a self-escalation path: settings files set `permission_mode` and
349
+ `permissions.allow`, so a model that could write one could grant itself `bypass` on the
350
+ next launch. Verified against the real engine *in bypass mode*, which is the case that
351
+ matters — deny beats bypass, so the block holds even there.
352
+
353
+ It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
354
+ and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
355
+ Closing that properly needs a path guard below the tool layer rather than more patterns.
356
+ In `default` mode Bash asks a human first, so this only bites under `bypass` — which is
357
+ already the mode that means "I accept the consequences."
340
358
 
341
359
  ### Context compaction
342
360
 
@@ -889,7 +907,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
889
907
  ## Testing
890
908
 
891
909
  ```bash
892
- pytest # 255 tests, no network
910
+ pytest # 283 tests, no network
893
911
  ruff check turnloop
894
912
  mypy turnloop
895
913
  ```
@@ -901,10 +919,12 @@ mypy turnloop
901
919
  | `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
902
920
  | `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
903
921
  | `test_loop.py` | 20 | streaming, truncation, iteration cap |
904
- | `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
922
+ | `test_hooks_mcp_commands.py` | 23 | lifecycle hooks, MCP client, slash commands |
905
923
  | `test_config.py` | 21 | layering, env overrides, presets, narrow-console guard |
906
924
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
907
925
  | `test_bash.py` | 14 | shell selection, process-tree kill |
926
+ | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
927
+ | `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
908
928
  | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
909
929
  | `test_tui.py` | 8 | Textual snapshots |
910
930
 
@@ -957,6 +977,87 @@ Shell expansion inside a command template (`` !`cmd` ``) goes through the permis
957
977
  engine like any other Bash call. A markdown file in a repository is not trusted input
958
978
  just because it is on disk.
959
979
 
980
+ ### Editing configuration
981
+
982
+ `tl config --edit` edits the provider table, permission mode, tool verbosity, iteration
983
+ cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers. The
984
+ same screens open inside a running session as `/config` and `/mcp add`.
985
+
986
+ **Adding a provider** is a form for `kind`, `model`, `base_url`, and `api_key_env`, with
987
+ a live ✓/✗ showing whether that environment variable is currently set. Capabilities are
988
+ re-derived from `preset_for(model)` on every save, mirroring what `load_settings` does
989
+ for `--model` — without that, a provider added through the UI keeps the previous model's
990
+ context window and pricing, and `doctor`'s context-budget line quietly lies. An
991
+ `openai_compat` provider without a `base_url` is refused by the form, because
992
+ `load_settings` would otherwise raise on the *next* launch, turning a typo into a CLI
993
+ that will not start.
994
+
995
+ Four further properties are structural rather than cosmetic:
996
+
997
+ **Only the diff is written.** Neither screen dumps the settings model. It writes the
998
+ difference against the packaged defaults, merged into whatever the file already holds.
999
+ Round-tripping the full model would freeze today's deny list and provider table into
1000
+ your file permanently, so a future security default could never reach you.
1001
+
1002
+ **Writes are atomic and validated.** The edited settings round-trip through
1003
+ `Settings.model_validate` before anything touches disk, then land via a temp file and
1004
+ `os.replace`. A malformed settings file makes `load_settings` raise and the CLI exit 2 —
1005
+ failing closed is right, but only if we never cause it ourselves.
1006
+
1007
+ **Secrets are named, never stored.** Provider credentials are edited as the env var
1008
+ *name*, and the written config has an `api_key_env` key and no `api_key` value at all —
1009
+ a config UI with a "paste your key here" box writes secrets into a JSON file that gets
1010
+ committed, which is how keys leak. The ✓/✗ indicator reports the name and a boolean,
1011
+ never the value, a prefix, or a length. MCP `env` values are literal by protocol necessity, so a server carrying any is
1012
+ forced to `settings.local.json` and refused a shared file, and the screen creates
1013
+ `.turnloop/.gitignore` before writing — otherwise a secret-bearing file can be committed
1014
+ before the first session ever runs.
1015
+
1016
+ **No agent-reachable route writes settings.** No tool and no hook mutates a settings
1017
+ file — which is what makes the deny rules above meaningful instead of decorative.
1018
+ `/config` and `/mcp add` are slash commands, and that is safe for a specific reason worth
1019
+ stating: `dispatch_command` has exactly one caller, reached from the prompt Input's
1020
+ `on_input_submitted`. The model emits tool calls, not typed input, so it cannot reach
1021
+ that path. The property lives in the dispatch path rather than in the commands, so a
1022
+ future refactor that routes model output through `dispatch_command` would break it
1023
+ silently — there is a comment there saying so.
1024
+
1025
+ **In-session saves reload only what is safe to reload.** `permission_mode`,
1026
+ `permissions`, `max_iterations` and `tool_verbosity` are read from the live `Settings`
1027
+ and `PermissionEngine`, so they take effect immediately. `provider`, `providers`,
1028
+ `search` and `include_memory` are baked into objects built once at agent construction,
1029
+ so the confirmation names them as needing a restart. Reporting "saved" while the old
1030
+ permission mode is still being enforced would be worse than saying nothing.
1031
+
1032
+ One honest gap: removing a server deletes it from the one file you chose. `deep_merge`
1033
+ has no tombstone, so a server also defined in a higher layer stays effective. The screen
1034
+ says so rather than pretending otherwise.
1035
+
1036
+ ### MCP servers are a trust decision
1037
+
1038
+ Adding an MCP server is the same class of decision as `npm install`, and turnloop cannot
1039
+ check it for you. Two specific reasons, both inherent to the protocol rather than to this
1040
+ implementation:
1041
+
1042
+ **A stdio server is a program you agreed to run.** `command` and `args` are spawned as a
1043
+ child process at every launch, with your full user privileges and any `env` you set. The
1044
+ add form states this and requires an explicit confirmation, because a text field labelled
1045
+ "command" does not otherwise read like "execute this forever."
1046
+
1047
+ **Tool descriptions are untrusted text that enters the model's context.** A server
1048
+ advertises its own names and descriptions, and those go into the system prompt verbatim.
1049
+ A hostile or compromised server can put instructions there. Nothing in the protocol
1050
+ authenticates that text.
1051
+
1052
+ What turnloop does about it, which is containment rather than prevention: MCP tools are
1053
+ `read_only = False` unconditionally (`mcp/adapter.py`), because the protocol has no
1054
+ read-only annotation worth trusting — so they always prompt in `default` mode and are
1055
+ refused outright in `plan` mode. A dead or misbehaving server degrades to "that tool is
1056
+ unavailable" and never blocks the loop. Connections are lazy and every call is bounded.
1057
+
1058
+ None of that helps if you install a server that does exactly what it says. Read what you
1059
+ add.
1060
+
960
1061
  ---
961
1062
 
962
1063
  ## Limitations
@@ -88,7 +88,7 @@ point.
88
88
  Verify:
89
89
 
90
90
  ```bash
91
- tl --version # turnloop 0.1.3
91
+ tl --version # turnloop 0.1.4
92
92
  tl doctor # every provider, key presence, shell, context budget
93
93
  ```
94
94
 
@@ -114,8 +114,14 @@ tl --resume # resume the most recent session
114
114
  tl --resume <session-id> # resume a specific one
115
115
  tl sessions # list recorded sessions
116
116
  tl config # merged config + which layer each value came from
117
+ tl config --edit # interactive settings editor
118
+ tl mcp # add, edit, enable/disable, remove MCP servers
117
119
  ```
118
120
 
121
+ `config --edit` and `mcp` are deliberately the only paths that write a settings file,
122
+ and neither is reachable by the model — see
123
+ [Editing configuration](#editing-configuration).
124
+
119
125
  ### Flags
120
126
 
121
127
  | flag | meaning |
@@ -166,9 +172,10 @@ focus and would otherwise swallow them.
166
172
  /compact summarize conversation now /memory discovered memory files
167
173
  /cost token and cost accounting /tools available tools
168
174
  /context what is filling the window /mcp MCP server status
169
- /model show or change model /hooks configured hooks
170
- /provider switch provider /sessions recorded sessions
171
- /export transcript to markdown /doctor diagnostics
175
+ /model show or change model /mcp add add an MCP server
176
+ /provider switch provider /config settings editor
177
+ /export transcript to markdown /hooks configured hooks
178
+ /sessions recorded sessions /doctor diagnostics
172
179
  /quit exit
173
180
  ```
174
181
 
@@ -307,7 +314,18 @@ every segment is allowed.** Without that, `Bash(git *)` grants
307
314
  scanner that respects quoting — `shlex` discards operators, so the `rm` comes back
308
315
  looking like an argument to `git`. 48 tests cover this file alone.
309
316
 
310
- Ten deny rules ship by default, including `Read(**/.env)`.
317
+ Twelve deny rules ship by default, including `Read(**/.env)` and — since 0.1.4 —
318
+ `Write(**/.turnloop/settings*.json)` and `Edit(**/.turnloop/settings*.json)`. Those two
319
+ close a self-escalation path: settings files set `permission_mode` and
320
+ `permissions.allow`, so a model that could write one could grant itself `bypass` on the
321
+ next launch. Verified against the real engine *in bypass mode*, which is the case that
322
+ matters — deny beats bypass, so the block holds even there.
323
+
324
+ It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
325
+ and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
326
+ Closing that properly needs a path guard below the tool layer rather than more patterns.
327
+ In `default` mode Bash asks a human first, so this only bites under `bypass` — which is
328
+ already the mode that means "I accept the consequences."
311
329
 
312
330
  ### Context compaction
313
331
 
@@ -860,7 +878,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
860
878
  ## Testing
861
879
 
862
880
  ```bash
863
- pytest # 255 tests, no network
881
+ pytest # 283 tests, no network
864
882
  ruff check turnloop
865
883
  mypy turnloop
866
884
  ```
@@ -872,10 +890,12 @@ mypy turnloop
872
890
  | `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
873
891
  | `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
874
892
  | `test_loop.py` | 20 | streaming, truncation, iteration cap |
875
- | `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
893
+ | `test_hooks_mcp_commands.py` | 23 | lifecycle hooks, MCP client, slash commands |
876
894
  | `test_config.py` | 21 | layering, env overrides, presets, narrow-console guard |
877
895
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
878
896
  | `test_bash.py` | 14 | shell selection, process-tree kill |
897
+ | `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
898
+ | `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
879
899
  | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
880
900
  | `test_tui.py` | 8 | Textual snapshots |
881
901
 
@@ -928,6 +948,87 @@ Shell expansion inside a command template (`` !`cmd` ``) goes through the permis
928
948
  engine like any other Bash call. A markdown file in a repository is not trusted input
929
949
  just because it is on disk.
930
950
 
951
+ ### Editing configuration
952
+
953
+ `tl config --edit` edits the provider table, permission mode, tool verbosity, iteration
954
+ cap, search backend, memory, and the three rule lists. `tl mcp` manages MCP servers. The
955
+ same screens open inside a running session as `/config` and `/mcp add`.
956
+
957
+ **Adding a provider** is a form for `kind`, `model`, `base_url`, and `api_key_env`, with
958
+ a live ✓/✗ showing whether that environment variable is currently set. Capabilities are
959
+ re-derived from `preset_for(model)` on every save, mirroring what `load_settings` does
960
+ for `--model` — without that, a provider added through the UI keeps the previous model's
961
+ context window and pricing, and `doctor`'s context-budget line quietly lies. An
962
+ `openai_compat` provider without a `base_url` is refused by the form, because
963
+ `load_settings` would otherwise raise on the *next* launch, turning a typo into a CLI
964
+ that will not start.
965
+
966
+ Four further properties are structural rather than cosmetic:
967
+
968
+ **Only the diff is written.** Neither screen dumps the settings model. It writes the
969
+ difference against the packaged defaults, merged into whatever the file already holds.
970
+ Round-tripping the full model would freeze today's deny list and provider table into
971
+ your file permanently, so a future security default could never reach you.
972
+
973
+ **Writes are atomic and validated.** The edited settings round-trip through
974
+ `Settings.model_validate` before anything touches disk, then land via a temp file and
975
+ `os.replace`. A malformed settings file makes `load_settings` raise and the CLI exit 2 —
976
+ failing closed is right, but only if we never cause it ourselves.
977
+
978
+ **Secrets are named, never stored.** Provider credentials are edited as the env var
979
+ *name*, and the written config has an `api_key_env` key and no `api_key` value at all —
980
+ a config UI with a "paste your key here" box writes secrets into a JSON file that gets
981
+ committed, which is how keys leak. The ✓/✗ indicator reports the name and a boolean,
982
+ never the value, a prefix, or a length. MCP `env` values are literal by protocol necessity, so a server carrying any is
983
+ forced to `settings.local.json` and refused a shared file, and the screen creates
984
+ `.turnloop/.gitignore` before writing — otherwise a secret-bearing file can be committed
985
+ before the first session ever runs.
986
+
987
+ **No agent-reachable route writes settings.** No tool and no hook mutates a settings
988
+ file — which is what makes the deny rules above meaningful instead of decorative.
989
+ `/config` and `/mcp add` are slash commands, and that is safe for a specific reason worth
990
+ stating: `dispatch_command` has exactly one caller, reached from the prompt Input's
991
+ `on_input_submitted`. The model emits tool calls, not typed input, so it cannot reach
992
+ that path. The property lives in the dispatch path rather than in the commands, so a
993
+ future refactor that routes model output through `dispatch_command` would break it
994
+ silently — there is a comment there saying so.
995
+
996
+ **In-session saves reload only what is safe to reload.** `permission_mode`,
997
+ `permissions`, `max_iterations` and `tool_verbosity` are read from the live `Settings`
998
+ and `PermissionEngine`, so they take effect immediately. `provider`, `providers`,
999
+ `search` and `include_memory` are baked into objects built once at agent construction,
1000
+ so the confirmation names them as needing a restart. Reporting "saved" while the old
1001
+ permission mode is still being enforced would be worse than saying nothing.
1002
+
1003
+ One honest gap: removing a server deletes it from the one file you chose. `deep_merge`
1004
+ has no tombstone, so a server also defined in a higher layer stays effective. The screen
1005
+ says so rather than pretending otherwise.
1006
+
1007
+ ### MCP servers are a trust decision
1008
+
1009
+ Adding an MCP server is the same class of decision as `npm install`, and turnloop cannot
1010
+ check it for you. Two specific reasons, both inherent to the protocol rather than to this
1011
+ implementation:
1012
+
1013
+ **A stdio server is a program you agreed to run.** `command` and `args` are spawned as a
1014
+ child process at every launch, with your full user privileges and any `env` you set. The
1015
+ add form states this and requires an explicit confirmation, because a text field labelled
1016
+ "command" does not otherwise read like "execute this forever."
1017
+
1018
+ **Tool descriptions are untrusted text that enters the model's context.** A server
1019
+ advertises its own names and descriptions, and those go into the system prompt verbatim.
1020
+ A hostile or compromised server can put instructions there. Nothing in the protocol
1021
+ authenticates that text.
1022
+
1023
+ What turnloop does about it, which is containment rather than prevention: MCP tools are
1024
+ `read_only = False` unconditionally (`mcp/adapter.py`), because the protocol has no
1025
+ read-only annotation worth trusting — so they always prompt in `default` mode and are
1026
+ refused outright in `plan` mode. A dead or misbehaving server degrades to "that tool is
1027
+ unavailable" and never blocks the loop. Connections are lazy and every call is bounded.
1028
+
1029
+ None of that helps if you install a server that does exactly what it says. Read what you
1030
+ add.
1031
+
931
1032
  ---
932
1033
 
933
1034
  ## Limitations
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "turnloop"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,100 @@
1
+ """Provider-editor pure logic (config_screen.py), tested without spinning up Textual.
2
+
3
+ `_save()` on `ProviderFormModal` is a thin wrapper around these functions —
4
+ testing them directly is cheaper than driving a pilot and covers exactly the
5
+ validation and caps-derivation rules that matter.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from turnloop.config import load_settings
13
+ from turnloop.errors import ConfigError
14
+ from turnloop.providers.pricing import preset_for
15
+ from turnloop.tui.config_screen import (
16
+ build_provider_dict,
17
+ env_status_text,
18
+ validate_provider_fields,
19
+ validate_provider_name,
20
+ )
21
+
22
+
23
+ def test_env_status_reports_set_and_unset_without_leaking_the_value(monkeypatch):
24
+ monkeypatch.setenv("TL_TEST_PRESENT_KEY", "super-secret-value")
25
+ monkeypatch.delenv("TL_TEST_ABSENT_KEY", raising=False)
26
+
27
+ present = env_status_text("TL_TEST_PRESENT_KEY")
28
+ absent = env_status_text("TL_TEST_ABSENT_KEY")
29
+
30
+ assert "set" in present and "not set" not in present
31
+ assert "not set" in absent
32
+ assert "super-secret-value" not in present
33
+ assert "super-secret-value" not in repr((present, absent))
34
+
35
+
36
+ def test_env_status_blank_name_is_blank():
37
+ assert env_status_text("") == ""
38
+
39
+
40
+ def test_validate_provider_name_rejects_blank_and_non_identifier():
41
+ assert validate_provider_name("", set(), is_new=True) is not None
42
+ assert validate_provider_name("has spaces", set(), is_new=True) is not None
43
+ assert validate_provider_name("fine_name", set(), is_new=True) is None
44
+ assert validate_provider_name("kebab-ok", set(), is_new=True) is None
45
+
46
+
47
+ def test_validate_provider_name_refuses_a_collision_only_when_new():
48
+ assert validate_provider_name("nvidia", {"nvidia"}, is_new=True) is not None
49
+ assert validate_provider_name("nvidia", {"nvidia"}, is_new=False) is None
50
+
51
+
52
+ def test_openai_compat_without_base_url_is_refused_by_the_form():
53
+ """The form check exists precisely because `load_settings` raises for the same
54
+ condition — this documents that the two are testing the same failure."""
55
+ assert validate_provider_fields("openai_compat", "some-model", None, None) is not None
56
+ assert validate_provider_fields("openai_compat", "some-model", "https://x", None) is None
57
+
58
+
59
+ def test_openai_compat_without_base_url_also_fails_at_load_settings(tmp_path):
60
+ """The other half of the pair above: confirms the form isn't guarding against
61
+ a condition load_settings would have tolerated anyway."""
62
+ (tmp_path / ".turnloop").mkdir()
63
+ (tmp_path / ".turnloop" / "settings.local.json").write_text(
64
+ '{"providers": {"broken": {"kind": "openai_compat", "model": "x"}}}',
65
+ encoding="utf-8",
66
+ )
67
+ with pytest.raises(ConfigError, match="base_url"):
68
+ load_settings(tmp_path)
69
+
70
+
71
+ def test_validate_provider_fields_requires_a_model():
72
+ assert validate_provider_fields("mock", "", None, None) is not None
73
+
74
+
75
+ def test_validate_provider_fields_rejects_a_bad_api_key_env_name():
76
+ assert validate_provider_fields("mock", "m", None, "not a valid name") is not None
77
+ assert validate_provider_fields("mock", "m", None, "GOOD_NAME") is None
78
+
79
+
80
+ def test_build_provider_dict_derives_caps_from_the_new_model_not_the_old_one():
81
+ """The whole point of gap 1's caps requirement: a provider that switches
82
+ models must not keep the previous model's context window and pricing."""
83
+ old = {
84
+ "kind": "openai_compat", "model": "gpt-4o-mini", "base_url": "https://old",
85
+ "caps": preset_for("gpt-4o-mini").model_dump(mode="json"),
86
+ }
87
+ updated = build_provider_dict(
88
+ old, kind="anthropic", model="claude-opus-4-5", base_url=None,
89
+ api_key_env="ANTHROPIC_API_KEY", tool_verbosity=None,
90
+ )
91
+
92
+ assert updated["caps"] == preset_for("claude-opus-4-5").model_dump(mode="json")
93
+ assert updated["caps"] != old["caps"]
94
+
95
+
96
+ def test_build_provider_dict_falls_back_sanely_for_an_unrecognized_model():
97
+ caps = preset_for("some-model-nobody-has-heard-of")
98
+ assert caps.max_context == 128_000
99
+ assert caps.max_output == 8_192
100
+ assert caps.max_context > 0 # never a silent zero-context provider