turnloop 0.1.2__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.2 → turnloop-0.1.4}/PKG-INFO +151 -21
  2. {turnloop-0.1.2 → turnloop-0.1.4}/README.md +1070 -940
  3. {turnloop-0.1.2 → turnloop-0.1.4}/pyproject.toml +89 -89
  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.2 → turnloop-0.1.4}/tests/test_hooks_mcp_commands.py +52 -0
  7. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_providers.py +85 -0
  8. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/__init__.py +3 -3
  9. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/cli.py +32 -0
  10. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/commands/dispatch.py +22 -2
  11. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/config.py +7 -0
  12. turnloop-0.1.4/turnloop/configio.py +250 -0
  13. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/core/messages.py +7 -0
  14. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/gemini.py +10 -2
  15. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/pricing.py +148 -148
  16. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tui/app.py +95 -10
  17. turnloop-0.1.4/turnloop/tui/config_screen.py +438 -0
  18. turnloop-0.1.4/turnloop/tui/mcp_screen.py +344 -0
  19. {turnloop-0.1.2 → turnloop-0.1.4}/.github/workflows/ci.yml +0 -0
  20. {turnloop-0.1.2 → turnloop-0.1.4}/.gitignore +0 -0
  21. {turnloop-0.1.2 → turnloop-0.1.4}/.turnloop/.gitignore +0 -0
  22. {turnloop-0.1.2 → turnloop-0.1.4}/tests/conftest.py +0 -0
  23. {turnloop-0.1.2 → turnloop-0.1.4}/tests/fixtures/anthropic_tool_call.sse +0 -0
  24. {turnloop-0.1.2 → turnloop-0.1.4}/tests/fixtures/glm_tool_call.sse +0 -0
  25. {turnloop-0.1.2 → turnloop-0.1.4}/tests/fixtures/mcp_echo_server.py +0 -0
  26. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_bash.py +0 -0
  27. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_compaction.py +0 -0
  28. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_config.py +0 -0
  29. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_experiments.py +0 -0
  30. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_loop.py +0 -0
  31. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_permissions.py +0 -0
  32. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_tools_files.py +0 -0
  33. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_tui.py +0 -0
  34. {turnloop-0.1.2 → turnloop-0.1.4}/tests/test_websearch.py +0 -0
  35. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/__main__.py +0 -0
  36. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/agent/__init__.py +0 -0
  37. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/agent/factory.py +0 -0
  38. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/agent/headless.py +0 -0
  39. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/agent/loop.py +0 -0
  40. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/agent/subagent.py +0 -0
  41. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/agent/system_prompt.py +0 -0
  42. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/commands/__init__.py +0 -0
  43. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/commands/loader.py +0 -0
  44. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/context/__init__.py +0 -0
  45. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/context/budget.py +0 -0
  46. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/context/compaction.py +0 -0
  47. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/context/memory.py +0 -0
  48. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/core/__init__.py +0 -0
  49. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/core/events.py +0 -0
  50. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/core/ids.py +0 -0
  51. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/core/tokens.py +0 -0
  52. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/diagnostics.py +0 -0
  53. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/errors.py +0 -0
  54. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/__init__.py +0 -0
  55. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  56. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  57. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/context-glm.yaml +0 -0
  58. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/context.yaml +0 -0
  59. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  60. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/groq-free.yaml +0 -0
  61. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  62. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  63. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/loop.yaml +0 -0
  64. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  65. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/reliability.yaml +0 -0
  66. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  67. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/configs/smoke.yaml +0 -0
  68. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/graders.py +0 -0
  69. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/report.py +0 -0
  70. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/runner.py +0 -0
  71. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/default.yaml +0 -0
  72. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  73. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  74. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  75. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  76. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  77. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  78. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  79. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  80. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  81. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  82. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  83. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  84. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  85. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  86. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  87. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  88. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  89. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  90. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  91. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  92. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  93. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  94. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  95. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  96. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  97. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  98. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  99. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  100. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  101. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  102. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  103. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  104. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  105. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  106. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  107. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  108. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  109. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  110. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  111. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  112. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  113. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/hooks/__init__.py +0 -0
  114. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/hooks/runner.py +0 -0
  115. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/mcp/__init__.py +0 -0
  116. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/mcp/adapter.py +0 -0
  117. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/mcp/client.py +0 -0
  118. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/permissions/__init__.py +0 -0
  119. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/permissions/engine.py +0 -0
  120. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/permissions/rules.py +0 -0
  121. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/__init__.py +0 -0
  122. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/anthropic.py +0 -0
  123. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/base.py +0 -0
  124. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/mock.py +0 -0
  125. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/openai_compat.py +0 -0
  126. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/registry.py +0 -0
  127. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/providers/sse.py +0 -0
  128. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/sessions/__init__.py +0 -0
  129. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/sessions/models.py +0 -0
  130. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/sessions/store.py +0 -0
  131. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/__init__.py +0 -0
  132. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/ask.py +0 -0
  133. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/base.py +0 -0
  134. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/bash.py +0 -0
  135. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/builtin.py +0 -0
  136. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/edit.py +0 -0
  137. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/glob.py +0 -0
  138. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/grep.py +0 -0
  139. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/read.py +0 -0
  140. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/runner.py +0 -0
  141. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/shell.py +0 -0
  142. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/skill.py +0 -0
  143. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/task.py +0 -0
  144. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/textio.py +0 -0
  145. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/todo.py +0 -0
  146. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/webfetch.py +0 -0
  147. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/websearch.py +0 -0
  148. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tools/write.py +0 -0
  149. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tui/__init__.py +0 -0
  150. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tui/app.tcss +0 -0
  151. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tui/bridge.py +0 -0
  152. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tui/widgets/__init__.py +0 -0
  153. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tui/widgets/permission.py +0 -0
  154. {turnloop-0.1.2 → turnloop-0.1.4}/turnloop/tui/widgets/status.py +0 -0
  155. {turnloop-0.1.2 → 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.2
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
@@ -49,7 +49,7 @@ tl experiment run smoke # a measurement run, offline and free
49
49
 
50
50
  Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
51
51
  SDKs, no agent framework — the loop is the point, so the loop is written here.
52
- ~18,000 lines of Python, 249 tests, no network in the default test run.
52
+ ~18,000 lines of Python, 255 tests, no network in the default test run.
53
53
 
54
54
  ---
55
55
 
@@ -117,7 +117,7 @@ point.
117
117
  Verify:
118
118
 
119
119
  ```bash
120
- tl --version # turnloop 0.1.0
120
+ tl --version # turnloop 0.1.4
121
121
  tl doctor # every provider, key presence, shell, context budget
122
122
  ```
123
123
 
@@ -126,7 +126,7 @@ tl doctor # every provider, key presence, shell, context budge
126
126
  context arithmetic for the active provider:
127
127
 
128
128
  ```
129
- + context budget 32,000 window − 4,096 output − 835 system − 3,365 tools = 23,704 for history
129
+ + context budget 32,000 window - 4,096 output - 881 system - 3,567 tools = 23,456 for history
130
130
  ```
131
131
 
132
132
  ---
@@ -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
 
@@ -429,6 +447,12 @@ new users`. `gemini-flash-latest` follows Google's own pointer. Worth knowing wh
429
447
  reading a 429 from any provider: the status code says "slow down", the body says
430
448
  whether slowing down will ever help.
431
449
 
450
+ Its free tier is **5 requests per minute**, which an agent loop reaches in one
451
+ ordinary task — a fix-two-bugs-and-write-tests run spent six turns and died on the
452
+ seventh. The retry ladder is correct and still loses, because the window is longer
453
+ than the backoff. Free Gemini is a good way to try the harness and a bad way to do
454
+ sustained work.
455
+
432
456
  ---
433
457
 
434
458
  ## Adding a provider
@@ -825,10 +849,31 @@ suddenly have them exposed.
825
849
  never manifested only because `rg` was not installed on the machine and it had been
826
850
  falling back to a pure-Python walk.
827
851
 
828
- **The lesson, three times over in one project:** an 84-run sweep failing while
829
- printing a tidy report of zeros; a tool returning empty with `ok: true`; a sweep
830
- sitting at 10/18 doing nothing for 25 minutes. Each was caught only by looking past
831
- the summary line.
852
+ **The Gemini adapter passed a live test and was still broken.**
853
+
854
+ Gemini returns a `thoughtSignature` alongside each `functionCall` part. Replay the
855
+ assistant turn without it and the next request is rejected: `400 — Function call is
856
+ missing a thought_signature in functionCall parts`. The adapter decoded the call and
857
+ dropped the signature, so **turn two of every tool conversation failed**.
858
+
859
+ It was verified live before shipping — one text turn, one tool call, both correct —
860
+ and that verification is exactly what hid the bug. A single tool call never replays
861
+ anything. The failure needs a *second* request carrying the first one's output, which
862
+ is the first thing a real agent loop does and the last thing a smoke test does.
863
+
864
+ Found by setting the harness up as a user would and giving it an ordinary task, not by
865
+ testing the adapter. The signature now round-trips through `ToolUseBlock.signature`
866
+ and through session JSONL, so a `--resume`d conversation does not hit the same wall.
867
+
868
+ **0.1.2 shipped with this bug.** It went to PyPI on the strength of the single-turn
869
+ check, and anyone who pointed 0.1.2 at Gemini got one working tool call and then a
870
+ 400. Fixed in 0.1.3.
871
+
872
+ **The lesson, four times over in one project:** an 84-run sweep failing while printing
873
+ a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
874
+ 10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
875
+ the turn the test never made. Each was caught only by looking past the summary line —
876
+ and the last one only by using the thing instead of testing it.
832
877
 
833
878
  ---
834
879
 
@@ -862,7 +907,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
862
907
  ## Testing
863
908
 
864
909
  ```bash
865
- pytest # 249 tests, no network
910
+ pytest # 283 tests, no network
866
911
  ruff check turnloop
867
912
  mypy turnloop
868
913
  ```
@@ -871,13 +916,15 @@ mypy turnloop
871
916
  |---|---|---|
872
917
  | `test_permissions.py` | 48 | rule grammar, compound-command splitting, mode enforcement |
873
918
  | `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
874
- | `test_providers.py` | 31 | adapters against recorded `.sse` fixtures, image block encoding |
919
+ | `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
875
920
  | `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
876
921
  | `test_loop.py` | 20 | streaming, truncation, iteration cap |
877
- | `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
878
- | `test_config.py` | 19 | layering, env overrides, capability presets |
922
+ | `test_hooks_mcp_commands.py` | 23 | lifecycle hooks, MCP client, slash commands |
923
+ | `test_config.py` | 21 | layering, env overrides, presets, narrow-console guard |
879
924
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
880
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 |
881
928
  | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
882
929
  | `test_tui.py` | 8 | Textual snapshots |
883
930
 
@@ -930,6 +977,87 @@ Shell expansion inside a command template (`` !`cmd` ``) goes through the permis
930
977
  engine like any other Bash call. A markdown file in a repository is not trusted input
931
978
  just because it is on disk.
932
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
+
933
1061
  ---
934
1062
 
935
1063
  ## Limitations
@@ -948,11 +1076,13 @@ Stated rather than buried:
948
1076
  that matters. That trade is deliberate: the default has to work with no key at all.
949
1077
  - **The Anthropic adapter is fixture-tested but has never sent a real request.** It is
950
1078
  coded against the documented wire format and passes against recorded SSE, which is not
951
- the same as verified. It is now the only one: the Gemini adapter is verified live
952
- (text, tool calls and images on `gemini-flash-latest`), and OpenAI needs no separate
953
- verification because `openai` is `kind="openai_compat"` — the same adapter Groq,
954
- NVIDIA, Ollama, Blaxel and the self-hosted GLM endpoint all run through, making it
955
- the most exercised code path in the repo.
1079
+ the same as verified. It is now the only one: Gemini is verified through a real
1080
+ multi-turn agent loop on `gemini-flash-latest` — tool calls, edits, file writes and
1081
+ images — and OpenAI needs no separate verification because `openai` is
1082
+ `kind="openai_compat"`, the same adapter Groq, NVIDIA, Ollama, Blaxel and the
1083
+ self-hosted GLM endpoint all run through, making it the most exercised code path in
1084
+ the repo. "Verified" here means multi-turn deliberately: a single-turn check on
1085
+ Gemini passed while the loop was broken. See [Bugs worth reading about](#bugs-worth-reading-about).
956
1086
  - **`subtle_spec_edge` currently passes for nobody** (0/5 on gpt-4o-mini). The rewrite
957
1087
  overshot — runs die on collection errors and leave `NotImplementedError`, which is
958
1088
  failure-to-produce-working-code, not falling for the decoy. It needs another pass.