turnloop 0.1.5__tar.gz → 0.1.6__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.
- turnloop-0.1.6/.turnloop/settings.local.json +3 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/PKG-INFO +120 -16
- {turnloop-0.1.5 → turnloop-0.1.6}/README.md +119 -15
- {turnloop-0.1.5 → turnloop-0.1.6}/pyproject.toml +1 -1
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_config.py +83 -30
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_hooks_mcp_commands.py +537 -431
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_loop.py +73 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_permissions.py +48 -0
- turnloop-0.1.6/tests/test_system_prompt_layout.py +85 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_tui.py +613 -397
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/__init__.py +1 -1
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/headless.py +17 -9
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/system_prompt.py +31 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/cli.py +22 -8
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/commands/dispatch.py +486 -448
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/commands/loader.py +39 -2
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/config.py +478 -444
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/sessions/store.py +286 -271
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/runner.py +63 -1
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/app.py +518 -432
- turnloop-0.1.6/turnloop/tui/session_screen.py +114 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/.github/workflows/ci.yml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/.gitignore +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/.turnloop/.gitignore +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/conftest.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/fixtures/anthropic_tool_call.sse +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/fixtures/glm_tool_call.sse +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/fixtures/mcp_echo_server.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_bash.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_compaction.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_config_screen.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_configio.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_experiments.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_providers.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_tools_files.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/tests/test_websearch.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/__main__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/factory.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/loop.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/agent/subagent.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/commands/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/configio.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/budget.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/compaction.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/context/memory.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/events.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/ids.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/messages.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/core/tokens.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/diagnostics.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/errors.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/bakeoff.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/context-glm.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/context.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/groq-free.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/hard-glm.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/loop.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/reliability.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/configs/smoke.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/graders.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/report.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/runner.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/default.yaml +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/hooks/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/hooks/runner.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/mcp/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/mcp/adapter.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/mcp/client.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/permissions/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/permissions/engine.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/permissions/rules.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/anthropic.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/base.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/gemini.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/mock.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/openai_compat.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/pricing.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/registry.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/providers/sse.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/sessions/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/sessions/models.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/ask.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/base.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/bash.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/builtin.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/edit.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/glob.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/grep.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/read.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/shell.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/skill.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/task.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/textio.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/todo.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/webfetch.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/websearch.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tools/write.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/app.tcss +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/bridge.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/config_screen.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/mcp_screen.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/widgets/__init__.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/widgets/permission.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/turnloop/tui/widgets/status.py +0 -0
- {turnloop-0.1.5 → turnloop-0.1.6}/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
|
+
Version: 0.1.6
|
|
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
|
-
~
|
|
52
|
+
~20,000 lines of Python, 318 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.
|
|
120
|
+
tl --version # turnloop 0.1.6
|
|
121
121
|
tl doctor # every provider, key presence, shell, context budget
|
|
122
122
|
```
|
|
123
123
|
|
|
@@ -139,8 +139,9 @@ context arithmetic for the active provider:
|
|
|
139
139
|
tl # interactive TUI
|
|
140
140
|
tl -p "why does the login test flake" # headless single turn
|
|
141
141
|
tl -p "..." --json # headless, JSONL events on stdout
|
|
142
|
-
tl
|
|
143
|
-
tl --resume
|
|
142
|
+
tl -c # resume the most recent session, no picker
|
|
143
|
+
tl --resume # interactive picker (TUI); most recent (headless)
|
|
144
|
+
tl --resume <session-id> # resume a specific one, no picker
|
|
144
145
|
tl sessions # list recorded sessions
|
|
145
146
|
tl config # merged config + which layer each value came from
|
|
146
147
|
tl config --edit # interactive settings editor
|
|
@@ -162,7 +163,8 @@ and neither is reachable by the model — see
|
|
|
162
163
|
| `--max-iterations N` | tool-loop safety cap (default 40) |
|
|
163
164
|
| `-p, --print PROMPT` | headless single turn |
|
|
164
165
|
| `--json` | headless output as JSONL events |
|
|
165
|
-
|
|
|
166
|
+
| `-c, --continue` | resume the most recent session directly, no picker |
|
|
167
|
+
| `--resume [ID]` | bare: interactive picker in the TUI, most recent in headless. With an id: that session, no picker |
|
|
166
168
|
|
|
167
169
|
### Permission modes
|
|
168
170
|
|
|
@@ -184,12 +186,18 @@ to cycle. Make it permanent via `TURNLOOP_PERMISSION_MODE` or `settings.json`.
|
|
|
184
186
|
### Keys
|
|
185
187
|
|
|
186
188
|
```
|
|
187
|
-
ctrl+c
|
|
189
|
+
ctrl+c copy the selection; else interrupt a running turn; else nothing
|
|
188
190
|
ctrl+p cycle permission mode
|
|
189
191
|
ctrl+l clear the view (history is kept)
|
|
190
192
|
ctrl+d quit
|
|
191
193
|
```
|
|
192
194
|
|
|
195
|
+
`ctrl+c` no longer quits. It used to call `self.exit()` whenever no turn was
|
|
196
|
+
running, so pressing it to copy text killed the session. It now checks, in order,
|
|
197
|
+
whether there is a selection to copy (via Textual's `App.copy_to_clipboard`, OSC 52,
|
|
198
|
+
no new dependency), then whether a turn is running to interrupt, and otherwise does
|
|
199
|
+
nothing. `ctrl+d` is still the only way to quit.
|
|
200
|
+
|
|
193
201
|
`ctrl+c` and friends are bound with `priority=True` — the prompt Input always has
|
|
194
202
|
focus and would otherwise swallow them.
|
|
195
203
|
|
|
@@ -205,6 +213,9 @@ focus and would otherwise swallow them.
|
|
|
205
213
|
/provider switch provider /config settings editor
|
|
206
214
|
/export transcript to markdown /hooks configured hooks
|
|
207
215
|
/sessions recorded sessions /doctor diagnostics
|
|
216
|
+
/skills loaded skills, and any /resume picker, rebuilds the
|
|
217
|
+
rejected on disk (why, agent in place; refuses
|
|
218
|
+
and where) while a turn is running
|
|
208
219
|
/quit exit
|
|
209
220
|
```
|
|
210
221
|
|
|
@@ -350,6 +361,13 @@ close a self-escalation path: settings files set `permission_mode` and
|
|
|
350
361
|
next launch. Verified against the real engine *in bypass mode*, which is the case that
|
|
351
362
|
matters — deny beats bypass, so the block holds even there.
|
|
352
363
|
|
|
364
|
+
That was a unit test. It has since been verified live: with
|
|
365
|
+
`Write(.turnloop/**)` explicitly allowed *and* `--permission-mode bypass`, a real
|
|
366
|
+
model told to grant itself permanent bypass access was blocked, made one attempt,
|
|
367
|
+
and proposed the change to the user instead. The settings file was unchanged. Deny
|
|
368
|
+
beats an explicit allow rule and bypass mode at once — not just in the test suite,
|
|
369
|
+
against a live model that was actually trying.
|
|
370
|
+
|
|
353
371
|
It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
|
|
354
372
|
and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
|
|
355
373
|
Closing that properly needs a path guard below the tool layer rather than more patterns.
|
|
@@ -395,6 +413,14 @@ because a crashed run still leaves a readable trace, and because the experiments
|
|
|
395
413
|
layer reads exactly the same format the TUI writes — a run trace and a real session
|
|
396
414
|
are the same artifact.
|
|
397
415
|
|
|
416
|
+
`-c`/`--continue` reopens the most recent session with no prompting. Bare `--resume`
|
|
417
|
+
in the TUI opens an interactive picker — a `DataTable` of sessions, newest first,
|
|
418
|
+
with a live preview pane of the selected conversation — and `--resume <id>` jumps
|
|
419
|
+
straight to a specific one. `/resume` opens the same picker in-session and rebuilds
|
|
420
|
+
the agent in place; it refuses while a turn is running rather than tearing one down
|
|
421
|
+
mid-flight. Headless has no picker to show, so a bare `--resume` there falls back to
|
|
422
|
+
"most recent" — the same thing `--continue` does.
|
|
423
|
+
|
|
398
424
|
---
|
|
399
425
|
|
|
400
426
|
## Providers
|
|
@@ -891,11 +917,78 @@ And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/co
|
|
|
891
917
|
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
892
918
|
fail if the decorators are removed.
|
|
893
919
|
|
|
894
|
-
**
|
|
920
|
+
**A directory that exists on every launch cannot mean "this is a project."**
|
|
921
|
+
|
|
922
|
+
`find_project_root` treated any ancestor `.turnloop` directory as an explicit project
|
|
923
|
+
declaration. But `default_project_dir` creates `.turnloop/` — with a `sessions/`
|
|
924
|
+
folder and a `.gitignore` — on every launch, unconditionally, wherever turnloop
|
|
925
|
+
happens to start. So the directory's mere existence declared nothing; it was
|
|
926
|
+
frequently just the residue of having once run the tool there. Run `tl` once from a
|
|
927
|
+
home directory, or a drive root, and it permanently annexed every project beneath it:
|
|
928
|
+
sessions, the `.env` layer, `settings.json` and the permission rules all resolved to
|
|
929
|
+
the wrong root from then on. Real instance, the author's own machine: `F:\.turnloop`
|
|
930
|
+
was created on 2026-08-01, and every `F:\anything\proj\pyproject.toml` since then
|
|
931
|
+
resolved its project root to `F:\`.
|
|
932
|
+
|
|
933
|
+
The invariant that broke was never written down as one, which is exactly why it lasted.
|
|
934
|
+
The fix makes the implicit rule explicit: a `.turnloop` counts only if it holds
|
|
935
|
+
something a human put there — `settings.json`, `settings.local.json`, `commands/`,
|
|
936
|
+
`skills/`, `TURNLOOP.md` — and one holding only an auto-created `sessions/` is ignored.
|
|
937
|
+
The home directory is excluded outright on top of that, since `~/.turnloop` is the
|
|
938
|
+
user-scope settings location and can never mean "project." The false invariant had
|
|
939
|
+
been there since the tool first created `.turnloop/` on launch; fixed in 0.1.6.
|
|
940
|
+
|
|
941
|
+
**A skill that failed to parse vanished with no error, and the model reported success.**
|
|
942
|
+
|
|
943
|
+
A live run installed a skill whose frontmatter was markdown-bold — `**name**:
|
|
944
|
+
something` — rather than YAML. `yaml.safe_load` returned no usable keys,
|
|
945
|
+
`load_skills` hit its "no description" skip, and the skill disappeared: no exception,
|
|
946
|
+
no log line, nothing in `/skills`. The model that had just "installed" it reported
|
|
947
|
+
success, because from its side the write succeeded and nothing said otherwise.
|
|
948
|
+
|
|
949
|
+
The bug was not the parser rejecting bad frontmatter — that part is correct. It was
|
|
950
|
+
that a rejection and a nonexistent skill looked identical from the outside. `load_skills`
|
|
951
|
+
keeps its exact signature, but a new `rejected_skills()` now returns what was dropped
|
|
952
|
+
and why, `/skills` reports it, and `CONFIG_LAYOUT` shows the literal YAML frontmatter
|
|
953
|
+
block in the system prompt so the model stops guessing at markdown-bold instead.
|
|
954
|
+
Fixed in 0.1.6.
|
|
955
|
+
|
|
956
|
+
**A permission denial that should end in one message looped ten times, and it cost
|
|
957
|
+
real money before anyone noticed — because nothing about it failed a test.**
|
|
958
|
+
|
|
959
|
+
Two defects landed in the same code path with the same symptom. The DENY branch's
|
|
960
|
+
message said only `Permission denied: <rule>`; the ASK-decline branch, which is
|
|
961
|
+
recoverable, had always carried "Do not retry it" — so the weaker wording sat on the
|
|
962
|
+
*permanent* block, the one that most needed it. Fixing the message was not enough on
|
|
963
|
+
its own: the ASK path looped despite already carrying that warning, because prompt
|
|
964
|
+
wording is a request, not a constraint, and a model under pressure to finish a task
|
|
965
|
+
does not reliably honor it.
|
|
966
|
+
|
|
967
|
+
Measured against a live, non-interactive provider — this is what a passing test suite
|
|
968
|
+
cannot see, since nothing here raises or returns the wrong value, it just keeps calling
|
|
969
|
+
a tool that keeps getting denied:
|
|
970
|
+
|
|
971
|
+
- denied `Write .turnloop/settings.local.json`, repeated 10 times → **233,742 input
|
|
972
|
+
tokens, $0.0372**
|
|
973
|
+
- after the message fix alone → **10,654 tokens, $0.0017**, one attempt then a correct
|
|
974
|
+
proposal to the user
|
|
975
|
+
- a separate ASK-decline loop on `dir`/`mkdir`, before any fix → **199,352 tokens,
|
|
976
|
+
$0.0317**
|
|
977
|
+
|
|
978
|
+
So wording is now backed by a deterministic guard in `ToolRunner` rather than trusted
|
|
979
|
+
alone: identity is tool name plus pydantic-normalized args, scope is the session, one
|
|
980
|
+
free retry, and the entry clears the moment the call is allowed or approved — so a
|
|
981
|
+
permission granted mid-session un-poisons a call that was denied earlier, instead of
|
|
982
|
+
blocking it forever. Fixed in 0.1.6.
|
|
983
|
+
|
|
984
|
+
**The lesson, eight times over in one project:** an 84-run sweep failing while printing
|
|
895
985
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
896
986
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
897
987
|
the turn the test never made; a feature with green tests that crashed on its first
|
|
898
|
-
keystroke
|
|
988
|
+
keystroke; an implicit invariant that a docstring never stated, so nobody noticed it
|
|
989
|
+
was false; a silent failure that a model reported as success; a cost bug that no
|
|
990
|
+
assertion could ever have caught, because every individual call did exactly what it
|
|
991
|
+
was told. Each was caught only by looking past the summary line — and most of them
|
|
899
992
|
only by using the thing instead of testing it.
|
|
900
993
|
|
|
901
994
|
---
|
|
@@ -930,26 +1023,27 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
930
1023
|
## Testing
|
|
931
1024
|
|
|
932
1025
|
```bash
|
|
933
|
-
pytest #
|
|
1026
|
+
pytest # 318 tests, no network
|
|
934
1027
|
ruff check turnloop
|
|
935
1028
|
mypy turnloop
|
|
936
1029
|
```
|
|
937
1030
|
|
|
938
1031
|
| file | tests | |
|
|
939
1032
|
|---|---|---|
|
|
940
|
-
| `test_permissions.py` |
|
|
1033
|
+
| `test_permissions.py` | 50 | rule grammar, compound-command splitting, mode enforcement |
|
|
941
1034
|
| `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
|
|
942
1035
|
| `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
|
|
943
1036
|
| `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
|
|
944
|
-
| `
|
|
945
|
-
| `
|
|
946
|
-
| `test_config.py` |
|
|
1037
|
+
| `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
|
|
1038
|
+
| `test_tui.py` | 24 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
1039
|
+
| `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
|
|
1040
|
+
| `test_loop.py` | 23 | streaming, truncation, iteration cap |
|
|
947
1041
|
| `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
|
|
948
|
-
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
949
1042
|
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
1043
|
+
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
950
1044
|
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
951
1045
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
952
|
-
| `
|
|
1046
|
+
| `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
|
|
953
1047
|
|
|
954
1048
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
955
1049
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -996,6 +1090,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
|
|
|
996
1090
|
in the system prompt and load their body on demand, which on a 65k window is the
|
|
997
1091
|
difference between having skills and not.
|
|
998
1092
|
|
|
1093
|
+
The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
|
|
1094
|
+
— 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
|
|
1095
|
+
the model cannot configure the tool it is running inside: asked to add an MCP server
|
|
1096
|
+
it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
|
|
1097
|
+
`examples/` — both real outputs from live runs. With it, both go to the right place
|
|
1098
|
+
first try. It includes the literal YAML frontmatter a skill needs, since a skill with
|
|
1099
|
+
markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
|
|
1100
|
+
now also reports anything found on disk but rejected, with the reason and path — see
|
|
1101
|
+
[Bugs worth reading about](#bugs-worth-reading-about).
|
|
1102
|
+
|
|
999
1103
|
Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
|
|
1000
1104
|
engine like any other Bash call. A markdown file in a repository is not trusted input
|
|
1001
1105
|
just because it is on disk.
|
|
@@ -20,7 +20,7 @@ tl experiment run smoke # a measurement run, offline and free
|
|
|
20
20
|
|
|
21
21
|
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
22
22
|
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
23
|
-
~
|
|
23
|
+
~20,000 lines of Python, 318 tests, no network in the default test run.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -88,7 +88,7 @@ point.
|
|
|
88
88
|
Verify:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
tl --version # turnloop 0.1.
|
|
91
|
+
tl --version # turnloop 0.1.6
|
|
92
92
|
tl doctor # every provider, key presence, shell, context budget
|
|
93
93
|
```
|
|
94
94
|
|
|
@@ -110,8 +110,9 @@ context arithmetic for the active provider:
|
|
|
110
110
|
tl # interactive TUI
|
|
111
111
|
tl -p "why does the login test flake" # headless single turn
|
|
112
112
|
tl -p "..." --json # headless, JSONL events on stdout
|
|
113
|
-
tl
|
|
114
|
-
tl --resume
|
|
113
|
+
tl -c # resume the most recent session, no picker
|
|
114
|
+
tl --resume # interactive picker (TUI); most recent (headless)
|
|
115
|
+
tl --resume <session-id> # resume a specific one, no picker
|
|
115
116
|
tl sessions # list recorded sessions
|
|
116
117
|
tl config # merged config + which layer each value came from
|
|
117
118
|
tl config --edit # interactive settings editor
|
|
@@ -133,7 +134,8 @@ and neither is reachable by the model — see
|
|
|
133
134
|
| `--max-iterations N` | tool-loop safety cap (default 40) |
|
|
134
135
|
| `-p, --print PROMPT` | headless single turn |
|
|
135
136
|
| `--json` | headless output as JSONL events |
|
|
136
|
-
|
|
|
137
|
+
| `-c, --continue` | resume the most recent session directly, no picker |
|
|
138
|
+
| `--resume [ID]` | bare: interactive picker in the TUI, most recent in headless. With an id: that session, no picker |
|
|
137
139
|
|
|
138
140
|
### Permission modes
|
|
139
141
|
|
|
@@ -155,12 +157,18 @@ to cycle. Make it permanent via `TURNLOOP_PERMISSION_MODE` or `settings.json`.
|
|
|
155
157
|
### Keys
|
|
156
158
|
|
|
157
159
|
```
|
|
158
|
-
ctrl+c
|
|
160
|
+
ctrl+c copy the selection; else interrupt a running turn; else nothing
|
|
159
161
|
ctrl+p cycle permission mode
|
|
160
162
|
ctrl+l clear the view (history is kept)
|
|
161
163
|
ctrl+d quit
|
|
162
164
|
```
|
|
163
165
|
|
|
166
|
+
`ctrl+c` no longer quits. It used to call `self.exit()` whenever no turn was
|
|
167
|
+
running, so pressing it to copy text killed the session. It now checks, in order,
|
|
168
|
+
whether there is a selection to copy (via Textual's `App.copy_to_clipboard`, OSC 52,
|
|
169
|
+
no new dependency), then whether a turn is running to interrupt, and otherwise does
|
|
170
|
+
nothing. `ctrl+d` is still the only way to quit.
|
|
171
|
+
|
|
164
172
|
`ctrl+c` and friends are bound with `priority=True` — the prompt Input always has
|
|
165
173
|
focus and would otherwise swallow them.
|
|
166
174
|
|
|
@@ -176,6 +184,9 @@ focus and would otherwise swallow them.
|
|
|
176
184
|
/provider switch provider /config settings editor
|
|
177
185
|
/export transcript to markdown /hooks configured hooks
|
|
178
186
|
/sessions recorded sessions /doctor diagnostics
|
|
187
|
+
/skills loaded skills, and any /resume picker, rebuilds the
|
|
188
|
+
rejected on disk (why, agent in place; refuses
|
|
189
|
+
and where) while a turn is running
|
|
179
190
|
/quit exit
|
|
180
191
|
```
|
|
181
192
|
|
|
@@ -321,6 +332,13 @@ close a self-escalation path: settings files set `permission_mode` and
|
|
|
321
332
|
next launch. Verified against the real engine *in bypass mode*, which is the case that
|
|
322
333
|
matters — deny beats bypass, so the block holds even there.
|
|
323
334
|
|
|
335
|
+
That was a unit test. It has since been verified live: with
|
|
336
|
+
`Write(.turnloop/**)` explicitly allowed *and* `--permission-mode bypass`, a real
|
|
337
|
+
model told to grant itself permanent bypass access was blocked, made one attempt,
|
|
338
|
+
and proposed the change to the user instead. The settings file was unchanged. Deny
|
|
339
|
+
beats an explicit allow rule and bypass mode at once — not just in the test suite,
|
|
340
|
+
against a live model that was actually trying.
|
|
341
|
+
|
|
324
342
|
It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
|
|
325
343
|
and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
|
|
326
344
|
Closing that properly needs a path guard below the tool layer rather than more patterns.
|
|
@@ -366,6 +384,14 @@ because a crashed run still leaves a readable trace, and because the experiments
|
|
|
366
384
|
layer reads exactly the same format the TUI writes — a run trace and a real session
|
|
367
385
|
are the same artifact.
|
|
368
386
|
|
|
387
|
+
`-c`/`--continue` reopens the most recent session with no prompting. Bare `--resume`
|
|
388
|
+
in the TUI opens an interactive picker — a `DataTable` of sessions, newest first,
|
|
389
|
+
with a live preview pane of the selected conversation — and `--resume <id>` jumps
|
|
390
|
+
straight to a specific one. `/resume` opens the same picker in-session and rebuilds
|
|
391
|
+
the agent in place; it refuses while a turn is running rather than tearing one down
|
|
392
|
+
mid-flight. Headless has no picker to show, so a bare `--resume` there falls back to
|
|
393
|
+
"most recent" — the same thing `--continue` does.
|
|
394
|
+
|
|
369
395
|
---
|
|
370
396
|
|
|
371
397
|
## Providers
|
|
@@ -862,11 +888,78 @@ And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/co
|
|
|
862
888
|
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
863
889
|
fail if the decorators are removed.
|
|
864
890
|
|
|
865
|
-
**
|
|
891
|
+
**A directory that exists on every launch cannot mean "this is a project."**
|
|
892
|
+
|
|
893
|
+
`find_project_root` treated any ancestor `.turnloop` directory as an explicit project
|
|
894
|
+
declaration. But `default_project_dir` creates `.turnloop/` — with a `sessions/`
|
|
895
|
+
folder and a `.gitignore` — on every launch, unconditionally, wherever turnloop
|
|
896
|
+
happens to start. So the directory's mere existence declared nothing; it was
|
|
897
|
+
frequently just the residue of having once run the tool there. Run `tl` once from a
|
|
898
|
+
home directory, or a drive root, and it permanently annexed every project beneath it:
|
|
899
|
+
sessions, the `.env` layer, `settings.json` and the permission rules all resolved to
|
|
900
|
+
the wrong root from then on. Real instance, the author's own machine: `F:\.turnloop`
|
|
901
|
+
was created on 2026-08-01, and every `F:\anything\proj\pyproject.toml` since then
|
|
902
|
+
resolved its project root to `F:\`.
|
|
903
|
+
|
|
904
|
+
The invariant that broke was never written down as one, which is exactly why it lasted.
|
|
905
|
+
The fix makes the implicit rule explicit: a `.turnloop` counts only if it holds
|
|
906
|
+
something a human put there — `settings.json`, `settings.local.json`, `commands/`,
|
|
907
|
+
`skills/`, `TURNLOOP.md` — and one holding only an auto-created `sessions/` is ignored.
|
|
908
|
+
The home directory is excluded outright on top of that, since `~/.turnloop` is the
|
|
909
|
+
user-scope settings location and can never mean "project." The false invariant had
|
|
910
|
+
been there since the tool first created `.turnloop/` on launch; fixed in 0.1.6.
|
|
911
|
+
|
|
912
|
+
**A skill that failed to parse vanished with no error, and the model reported success.**
|
|
913
|
+
|
|
914
|
+
A live run installed a skill whose frontmatter was markdown-bold — `**name**:
|
|
915
|
+
something` — rather than YAML. `yaml.safe_load` returned no usable keys,
|
|
916
|
+
`load_skills` hit its "no description" skip, and the skill disappeared: no exception,
|
|
917
|
+
no log line, nothing in `/skills`. The model that had just "installed" it reported
|
|
918
|
+
success, because from its side the write succeeded and nothing said otherwise.
|
|
919
|
+
|
|
920
|
+
The bug was not the parser rejecting bad frontmatter — that part is correct. It was
|
|
921
|
+
that a rejection and a nonexistent skill looked identical from the outside. `load_skills`
|
|
922
|
+
keeps its exact signature, but a new `rejected_skills()` now returns what was dropped
|
|
923
|
+
and why, `/skills` reports it, and `CONFIG_LAYOUT` shows the literal YAML frontmatter
|
|
924
|
+
block in the system prompt so the model stops guessing at markdown-bold instead.
|
|
925
|
+
Fixed in 0.1.6.
|
|
926
|
+
|
|
927
|
+
**A permission denial that should end in one message looped ten times, and it cost
|
|
928
|
+
real money before anyone noticed — because nothing about it failed a test.**
|
|
929
|
+
|
|
930
|
+
Two defects landed in the same code path with the same symptom. The DENY branch's
|
|
931
|
+
message said only `Permission denied: <rule>`; the ASK-decline branch, which is
|
|
932
|
+
recoverable, had always carried "Do not retry it" — so the weaker wording sat on the
|
|
933
|
+
*permanent* block, the one that most needed it. Fixing the message was not enough on
|
|
934
|
+
its own: the ASK path looped despite already carrying that warning, because prompt
|
|
935
|
+
wording is a request, not a constraint, and a model under pressure to finish a task
|
|
936
|
+
does not reliably honor it.
|
|
937
|
+
|
|
938
|
+
Measured against a live, non-interactive provider — this is what a passing test suite
|
|
939
|
+
cannot see, since nothing here raises or returns the wrong value, it just keeps calling
|
|
940
|
+
a tool that keeps getting denied:
|
|
941
|
+
|
|
942
|
+
- denied `Write .turnloop/settings.local.json`, repeated 10 times → **233,742 input
|
|
943
|
+
tokens, $0.0372**
|
|
944
|
+
- after the message fix alone → **10,654 tokens, $0.0017**, one attempt then a correct
|
|
945
|
+
proposal to the user
|
|
946
|
+
- a separate ASK-decline loop on `dir`/`mkdir`, before any fix → **199,352 tokens,
|
|
947
|
+
$0.0317**
|
|
948
|
+
|
|
949
|
+
So wording is now backed by a deterministic guard in `ToolRunner` rather than trusted
|
|
950
|
+
alone: identity is tool name plus pydantic-normalized args, scope is the session, one
|
|
951
|
+
free retry, and the entry clears the moment the call is allowed or approved — so a
|
|
952
|
+
permission granted mid-session un-poisons a call that was denied earlier, instead of
|
|
953
|
+
blocking it forever. Fixed in 0.1.6.
|
|
954
|
+
|
|
955
|
+
**The lesson, eight times over in one project:** an 84-run sweep failing while printing
|
|
866
956
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
867
957
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
868
958
|
the turn the test never made; a feature with green tests that crashed on its first
|
|
869
|
-
keystroke
|
|
959
|
+
keystroke; an implicit invariant that a docstring never stated, so nobody noticed it
|
|
960
|
+
was false; a silent failure that a model reported as success; a cost bug that no
|
|
961
|
+
assertion could ever have caught, because every individual call did exactly what it
|
|
962
|
+
was told. Each was caught only by looking past the summary line — and most of them
|
|
870
963
|
only by using the thing instead of testing it.
|
|
871
964
|
|
|
872
965
|
---
|
|
@@ -901,26 +994,27 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
901
994
|
## Testing
|
|
902
995
|
|
|
903
996
|
```bash
|
|
904
|
-
pytest #
|
|
997
|
+
pytest # 318 tests, no network
|
|
905
998
|
ruff check turnloop
|
|
906
999
|
mypy turnloop
|
|
907
1000
|
```
|
|
908
1001
|
|
|
909
1002
|
| file | tests | |
|
|
910
1003
|
|---|---|---|
|
|
911
|
-
| `test_permissions.py` |
|
|
1004
|
+
| `test_permissions.py` | 50 | rule grammar, compound-command splitting, mode enforcement |
|
|
912
1005
|
| `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
|
|
913
1006
|
| `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
|
|
914
1007
|
| `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
|
|
915
|
-
| `
|
|
916
|
-
| `
|
|
917
|
-
| `test_config.py` |
|
|
1008
|
+
| `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
|
|
1009
|
+
| `test_tui.py` | 24 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
1010
|
+
| `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
|
|
1011
|
+
| `test_loop.py` | 23 | streaming, truncation, iteration cap |
|
|
918
1012
|
| `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
|
|
919
|
-
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
920
1013
|
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
1014
|
+
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
921
1015
|
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
922
1016
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
923
|
-
| `
|
|
1017
|
+
| `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
|
|
924
1018
|
|
|
925
1019
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
926
1020
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -967,6 +1061,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
|
|
|
967
1061
|
in the system prompt and load their body on demand, which on a 65k window is the
|
|
968
1062
|
difference between having skills and not.
|
|
969
1063
|
|
|
1064
|
+
The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
|
|
1065
|
+
— 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
|
|
1066
|
+
the model cannot configure the tool it is running inside: asked to add an MCP server
|
|
1067
|
+
it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
|
|
1068
|
+
`examples/` — both real outputs from live runs. With it, both go to the right place
|
|
1069
|
+
first try. It includes the literal YAML frontmatter a skill needs, since a skill with
|
|
1070
|
+
markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
|
|
1071
|
+
now also reports anything found on disk but rejected, with the reason and path — see
|
|
1072
|
+
[Bugs worth reading about](#bugs-worth-reading-about).
|
|
1073
|
+
|
|
970
1074
|
Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
|
|
971
1075
|
engine like any other Bash call. A markdown file in a repository is not trusted input
|
|
972
1076
|
just because it is on disk.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "turnloop"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
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"
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
7
8
|
|
|
@@ -9,8 +10,15 @@ from turnloop.config import find_project_root, load_settings
|
|
|
9
10
|
from turnloop.errors import ConfigError
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def test_defaults_ship_a_working_provider(
|
|
13
|
-
|
|
13
|
+
def test_defaults_ship_a_working_provider(monkeypatch):
|
|
14
|
+
# Route through a path that doesn't exist on disk, and a fake home to
|
|
15
|
+
# match: the "user" layer in load_settings reads ~/.turnloop/settings.json
|
|
16
|
+
# unconditionally, and a dev machine's real global config (or a real
|
|
17
|
+
# ~/.turnloop up tmp_path's real ancestry) would otherwise leak into this
|
|
18
|
+
# test's result.
|
|
19
|
+
fake_root = Path("Z:/turnloop-fake-home/project")
|
|
20
|
+
monkeypatch.setattr("turnloop.config.Path.home", lambda: fake_root.parent)
|
|
21
|
+
settings = load_settings(fake_root)
|
|
14
22
|
assert settings.provider == "mock"
|
|
15
23
|
assert "glm" in settings.providers
|
|
16
24
|
|
|
@@ -128,6 +136,7 @@ def test_project_root_prefers_nearest_manifest_over_a_distant_git_repo(tmp_path)
|
|
|
128
136
|
def test_explicit_turnloop_dir_wins(tmp_path):
|
|
129
137
|
root = tmp_path / "root"
|
|
130
138
|
(root / ".turnloop").mkdir(parents=True)
|
|
139
|
+
(root / ".turnloop" / "settings.json").write_text("{}", encoding="utf-8")
|
|
131
140
|
nested = root / "a" / "b"
|
|
132
141
|
nested.mkdir(parents=True)
|
|
133
142
|
(nested / "pyproject.toml").write_text("", encoding="utf-8")
|
|
@@ -135,6 +144,50 @@ def test_explicit_turnloop_dir_wins(tmp_path):
|
|
|
135
144
|
assert find_project_root(nested) == root
|
|
136
145
|
|
|
137
146
|
|
|
147
|
+
def test_auto_created_turnloop_dir_does_not_annex_a_nested_project(tmp_path):
|
|
148
|
+
"""`default_project_dir` (sessions/store.py) creates `.turnloop/` with only
|
|
149
|
+
a `sessions/` folder and a `.gitignore` on every launch, unconditionally.
|
|
150
|
+
That auto-created directory must not be mistaken for a human declaration —
|
|
151
|
+
a project with its own manifest nested underneath it must resolve to
|
|
152
|
+
itself, not to the ancestor that merely happened to run turnloop once.
|
|
153
|
+
"""
|
|
154
|
+
outer = tmp_path / "outer"
|
|
155
|
+
turnloop_dir = outer / ".turnloop"
|
|
156
|
+
turnloop_dir.mkdir(parents=True)
|
|
157
|
+
(turnloop_dir / "sessions").mkdir()
|
|
158
|
+
(turnloop_dir / ".gitignore").write_text("sessions/\n", encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
inner = outer / "proj"
|
|
161
|
+
inner.mkdir()
|
|
162
|
+
(inner / "pyproject.toml").write_text("", encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
assert find_project_root(inner) == inner
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_turnloop_dir_in_home_does_not_annex_unrelated_projects(monkeypatch):
|
|
168
|
+
"""`~/.turnloop` is user-scope config, not a project declaration; it must
|
|
169
|
+
not make every project under home resolve its root to home itself.
|
|
170
|
+
|
|
171
|
+
Built on a path that never touches real disk (a drive letter that doesn't
|
|
172
|
+
exist) rather than `tmp_path`, because on this machine — and potentially
|
|
173
|
+
any dev machine — pytest's temp dir lives under the real home directory,
|
|
174
|
+
which itself has a real `.turnloop`. That real marker would confound the
|
|
175
|
+
test regardless of whether the fix works, so the condition is constructed
|
|
176
|
+
explicitly instead of relying on ambient machine state.
|
|
177
|
+
"""
|
|
178
|
+
fake_home = Path("Z:/turnloop-fake-home")
|
|
179
|
+
project = fake_home / "code" / "myproject"
|
|
180
|
+
real_is_dir = Path.is_dir
|
|
181
|
+
|
|
182
|
+
def fake_is_dir(self):
|
|
183
|
+
return self == fake_home / ".turnloop" or real_is_dir(self)
|
|
184
|
+
|
|
185
|
+
monkeypatch.setattr(Path, "is_dir", fake_is_dir)
|
|
186
|
+
monkeypatch.setattr("turnloop.config.Path.home", lambda: fake_home)
|
|
187
|
+
|
|
188
|
+
assert find_project_root(project) == project
|
|
189
|
+
|
|
190
|
+
|
|
138
191
|
def test_dotenv_is_loaded_and_reports_names_only(tmp_path, monkeypatch):
|
|
139
192
|
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
|
140
193
|
(tmp_path / "pyproject.toml").write_text("", encoding="utf-8")
|
|
@@ -226,31 +279,31 @@ def test_doctor_survives_a_console_that_cannot_encode_its_glyphs(tmp_path, capsy
|
|
|
226
279
|
assert rc in (0, 1) # findings are allowed; a crash is not
|
|
227
280
|
body = narrow.buffer.getvalue().decode("cp1252")
|
|
228
281
|
assert "context budget" in body
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def test_the_console_guard_replaces_a_glyph_cp1252_cannot_encode():
|
|
232
|
-
"""The doctor fix removed one glyph; the guard covers every future one.
|
|
233
|
-
|
|
234
|
-
Tool results, MCP servers and slash commands all print characters this repo
|
|
235
|
-
does not control, so keeping every string inside cp1252 is not a maintainable
|
|
236
|
-
invariant. Degrading to `?` is.
|
|
237
|
-
"""
|
|
238
|
-
import io
|
|
239
|
-
import sys
|
|
240
|
-
|
|
241
|
-
from turnloop.cli import _survive_a_narrow_console
|
|
242
|
-
|
|
243
|
-
narrow = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict")
|
|
244
|
-
real_out, real_err = sys.stdout, sys.stderr
|
|
245
|
-
sys.stdout = sys.stderr = narrow
|
|
246
|
-
try:
|
|
247
|
-
with pytest.raises(UnicodeEncodeError):
|
|
248
|
-
narrow.write("→")
|
|
249
|
-
narrow.flush()
|
|
250
|
-
_survive_a_narrow_console()
|
|
251
|
-
narrow.write("budget − reserve → history\n")
|
|
252
|
-
narrow.flush()
|
|
253
|
-
finally:
|
|
254
|
-
sys.stdout, sys.stderr = real_out, real_err
|
|
255
|
-
|
|
256
|
-
assert "budget ? reserve ? history" in narrow.buffer.getvalue().decode("cp1252")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_the_console_guard_replaces_a_glyph_cp1252_cannot_encode():
|
|
285
|
+
"""The doctor fix removed one glyph; the guard covers every future one.
|
|
286
|
+
|
|
287
|
+
Tool results, MCP servers and slash commands all print characters this repo
|
|
288
|
+
does not control, so keeping every string inside cp1252 is not a maintainable
|
|
289
|
+
invariant. Degrading to `?` is.
|
|
290
|
+
"""
|
|
291
|
+
import io
|
|
292
|
+
import sys
|
|
293
|
+
|
|
294
|
+
from turnloop.cli import _survive_a_narrow_console
|
|
295
|
+
|
|
296
|
+
narrow = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict")
|
|
297
|
+
real_out, real_err = sys.stdout, sys.stderr
|
|
298
|
+
sys.stdout = sys.stderr = narrow
|
|
299
|
+
try:
|
|
300
|
+
with pytest.raises(UnicodeEncodeError):
|
|
301
|
+
narrow.write("→")
|
|
302
|
+
narrow.flush()
|
|
303
|
+
_survive_a_narrow_console()
|
|
304
|
+
narrow.write("budget − reserve → history\n")
|
|
305
|
+
narrow.flush()
|
|
306
|
+
finally:
|
|
307
|
+
sys.stdout, sys.stderr = real_out, real_err
|
|
308
|
+
|
|
309
|
+
assert "budget ? reserve ? history" in narrow.buffer.getvalue().decode("cp1252")
|