turnloop 0.1.0__py3-none-any.whl

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 (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,97 @@
1
+ """Write — full-file replacement, guarded by read-before-write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from turnloop.tools.base import Tool, ToolContext, ToolOutput
8
+ from turnloop.tools.textio import detect_newline, has_bom, read_text, write_text
9
+
10
+
11
+ class WriteArgs(BaseModel):
12
+ file_path: str = Field(description="Absolute or project-relative path to write.")
13
+ content: str = Field(description="The complete new contents of the file.")
14
+
15
+
16
+ class WriteTool(Tool):
17
+ name = "Write"
18
+ Args = WriteArgs
19
+ read_only = False
20
+ parallel_safe = False
21
+
22
+ descriptions = {
23
+ "terse": """
24
+ Overwrite a file with `content`. Read it first if it exists. Prefer Edit for
25
+ changes to existing files.
26
+ """,
27
+ "normal": """
28
+ Write a complete file, creating it or overwriting it.
29
+
30
+ - If the file exists you must Read it first, and it must not have changed on disk
31
+ since. This prevents silently destroying content you never saw.
32
+ - Prefer Edit for modifying an existing file. Write replaces everything, which
33
+ loses anything you did not reproduce exactly.
34
+ - Parent directories are created as needed.
35
+ - The file's existing newline style is preserved when overwriting.
36
+ """,
37
+ "verbose": """
38
+ Write a complete file, creating it if absent or overwriting it if present.
39
+
40
+ Arguments:
41
+ - `file_path`: absolute, or relative to the working directory.
42
+ - `content`: the entire new contents. Not a patch, not a fragment.
43
+
44
+ Constraints:
45
+ - Overwriting requires a prior Read of that file in this session, and the file
46
+ must be unchanged on disk since that Read. If it changed, Read it again first.
47
+ This is what stops a full-file write from discarding content you never saw.
48
+ - Prefer Edit when modifying an existing file. Write is appropriate for new
49
+ files, or when the change is a genuine rewrite.
50
+ - Parent directories are created automatically.
51
+ - Newline style (LF vs CRLF) is preserved from the existing file, so writing to a
52
+ CRLF repository does not produce a diff on every line.
53
+ - Do not use Write to create documentation, summaries, or notes unless the user
54
+ asked for a file. Answer in the conversation instead.
55
+ """,
56
+ }
57
+
58
+ async def run(self, args: WriteArgs, ctx: ToolContext) -> ToolOutput:
59
+ path = ctx.resolve(args.file_path)
60
+
61
+ if ctx.readonly:
62
+ return ToolOutput.error("plan mode is read-only; Write is not available")
63
+ if not ctx.within_allowed(path):
64
+ return ToolOutput.error(f"{path} is outside the project root ({ctx.root}).")
65
+ if path.is_dir():
66
+ return ToolOutput.error(f"{path} is a directory.")
67
+
68
+ existed = path.exists()
69
+ if existed:
70
+ if not ctx.files.has_read(path):
71
+ return ToolOutput.error(
72
+ f"{path} exists but has not been read in this session. "
73
+ "Read it first so the overwrite does not discard content."
74
+ )
75
+ if ctx.files.is_stale(path):
76
+ return ToolOutput.error(
77
+ f"{path} changed on disk since you read it. Read it again, then retry."
78
+ )
79
+
80
+ newline = detect_newline(path) if existed else None
81
+ keep_bom = has_bom(path) if existed else False
82
+ previous_lines = len(read_text(path).splitlines()) if existed else 0
83
+
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ write_text(path, args.content, newline=newline, bom=keep_bom)
86
+ ctx.files.record_write(path, args.content)
87
+
88
+ new_lines = len(args.content.splitlines())
89
+ verb = "Updated" if existed else "Created"
90
+ return ToolOutput(
91
+ content=f"{verb} {path} ({new_lines} lines).",
92
+ display=f"{verb} {path.name} ({previous_lines} → {new_lines} lines)",
93
+ metrics={"created": not existed, "lines": new_lines, "bytes": len(args.content)},
94
+ )
95
+
96
+ def summary(self, args: WriteArgs) -> str: # type: ignore[override]
97
+ return f"Write {args.file_path}"
File without changes
turnloop/tui/app.py ADDED
@@ -0,0 +1,330 @@
1
+ """The Textual application.
2
+
3
+ Concurrency, stated once:
4
+
5
+ * `_agent_worker` owns the agent loop. It never touches a widget.
6
+ * `_pump_worker` drains the event stream and calls widget methods. It never awaits
7
+ the model.
8
+ * `_permission_worker` drains permission requests, pushes a modal, and sends the
9
+ answer back through the request's one-shot reply stream — which is what the
10
+ agent's task is parked on.
11
+
12
+ All three are Textual asyncio workers on the same event loop, so there are no
13
+ thread-safety questions, and cancelling the agent worker propagates straight into
14
+ the in-flight HTTP stream and any child process.
15
+
16
+ Typing during a turn is allowed. A message submitted mid-turn is queued and
17
+ delivered as a steering turn once the current one finishes, because the useful
18
+ moment to redirect an agent is exactly while it is going the wrong way.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+
25
+ import anyio
26
+ from textual import work
27
+ from textual.app import App, ComposeResult
28
+ from textual.binding import Binding
29
+ from textual.containers import Vertical
30
+ from textual.widgets import Footer, Input
31
+ from textual.worker import Worker
32
+
33
+ from turnloop.agent.factory import create_agent
34
+ from turnloop.config import Settings
35
+ from turnloop.core.events import (
36
+ CompactionHappened,
37
+ ProviderStatus,
38
+ StatusUpdate,
39
+ TextDelta,
40
+ ThinkingDelta,
41
+ ToolFinished,
42
+ ToolProgress,
43
+ ToolStarted,
44
+ TurnFinished,
45
+ UIEvent,
46
+ )
47
+ from turnloop.tui.bridge import PendingPermission, StreamChannel
48
+ from turnloop.tui.widgets.permission import ChoiceModal, PermissionModal
49
+ from turnloop.tui.widgets.status import BootPanel, StatusBar
50
+ from turnloop.tui.widgets.transcript import Transcript
51
+
52
+
53
+ class TurnloopApp(App):
54
+ CSS_PATH = "app.tcss"
55
+ TITLE = "turnloop"
56
+
57
+ BINDINGS = [
58
+ Binding("ctrl+c", "interrupt", "Interrupt", priority=True),
59
+ Binding("ctrl+d", "quit", "Quit", priority=True),
60
+ # priority=True is required: the focused Input claims these keys otherwise,
61
+ # and the prompt always has focus.
62
+ Binding("ctrl+p", "cycle_mode", "Permission mode", priority=True),
63
+ Binding("ctrl+l", "clear", "Clear view", priority=True),
64
+ ]
65
+
66
+ def __init__(self, settings: Settings, cwd: Path, resume: str | None = None):
67
+ super().__init__()
68
+ self.settings = settings
69
+ self.cwd = cwd
70
+ self.resume_id = resume
71
+ self.exit_code = 0
72
+
73
+ self.channel, self._events, self._permissions = StreamChannel.create()
74
+ self.agent = create_agent(settings, cwd, self.channel, resume=resume)
75
+ self._queued: list[str] = []
76
+ self._busy = False
77
+ self._agent_worker: Worker | None = None
78
+
79
+ # --- layout -----------------------------------------------------------
80
+
81
+ def compose(self) -> ComposeResult:
82
+ with Vertical():
83
+ yield Transcript(id="transcript")
84
+ yield BootPanel(id="boot")
85
+ yield StatusBar(id="status")
86
+ yield Input(placeholder="Ask, or /help", id="prompt")
87
+ yield Footer()
88
+
89
+ def on_mount(self) -> None:
90
+ self.query_one("#boot", BootPanel).display = False
91
+ status = self.query_one(StatusBar)
92
+ status.provider = self.agent.provider.name
93
+ status.model = self.agent.provider.model
94
+ status.mode = self.settings.permission_mode
95
+ status.cost_per_hour = self.agent.provider.caps.cost_per_hour
96
+ status.context_max = self.agent.provider.caps.max_context
97
+
98
+ self._pump_worker()
99
+ self._permission_worker()
100
+ self._startup_worker()
101
+
102
+ transcript = self.query_one(Transcript)
103
+ banner = (
104
+ f"turnloop · {self.agent.provider.name}:{self.agent.provider.model} · "
105
+ f"{self.cwd}"
106
+ )
107
+ self.call_later(transcript.add_note, banner)
108
+ if self.resume_id and self.agent.session.messages:
109
+ self.call_later(
110
+ transcript.add_note,
111
+ f"resumed {self.agent.session.session_id} "
112
+ f"({len(self.agent.session.messages)} messages)",
113
+ )
114
+ if self.agent.provider.caps.cost_per_hour:
115
+ self.call_later(
116
+ transcript.add_note,
117
+ f"this provider bills ${self.agent.provider.caps.cost_per_hour:.2f}/hour of "
118
+ "wall clock; the first request may cold-boot for ~29 minutes",
119
+ )
120
+ self.query_one(Input).focus()
121
+
122
+ # --- input ------------------------------------------------------------
123
+
124
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
125
+ text = event.value.strip()
126
+ event.input.value = ""
127
+ if not text:
128
+ return
129
+
130
+ if text.startswith("/"):
131
+ await self._handle_command(text)
132
+ return
133
+
134
+ transcript = self.query_one(Transcript)
135
+ await transcript.add_user(text)
136
+
137
+ if self._busy:
138
+ # Steering: delivered after the current turn's tool batch completes.
139
+ self._queued.append(text)
140
+ await transcript.add_note("queued — will be sent when the current turn finishes")
141
+ return
142
+
143
+ self._start_turn(text)
144
+
145
+ def _start_turn(self, prompt: str) -> None:
146
+ self._busy = True
147
+ self._agent_worker = self.run_worker(
148
+ self._run_turn(prompt), exclusive=False, thread=False, name="agent"
149
+ )
150
+
151
+ async def _run_turn(self, prompt: str) -> None:
152
+ transcript = self.query_one(Transcript)
153
+ try:
154
+ await self.agent.loop.run_turn(prompt)
155
+ except anyio.get_cancelled_exc_class():
156
+ await transcript.add_note("interrupted")
157
+ raise
158
+ except Exception as exc: # noqa: BLE001 - a failed turn must not kill the app
159
+ await transcript.add_error(f"error: {exc}")
160
+ finally:
161
+ self._busy = False
162
+ await transcript.end_message()
163
+
164
+ if self._queued:
165
+ self._start_turn(self._queued.pop(0))
166
+
167
+ # --- workers ----------------------------------------------------------
168
+
169
+ @work(exclusive=False, thread=False, name="pump")
170
+ async def _pump_worker(self) -> None:
171
+ transcript = self.query_one(Transcript)
172
+ status = self.query_one(StatusBar)
173
+ boot = self.query_one("#boot", BootPanel)
174
+
175
+ async with self._events:
176
+ async for event in self._events:
177
+ await self._render(event, transcript, status, boot)
178
+
179
+ async def _render(self, event: UIEvent, transcript: Transcript, status: StatusBar,
180
+ boot: BootPanel) -> None:
181
+ if isinstance(event, TextDelta):
182
+ transcript.append_delta(event.text)
183
+ elif isinstance(event, ThinkingDelta):
184
+ transcript.append_thinking(event.text)
185
+ elif isinstance(event, ToolStarted):
186
+ await transcript.add_tool_start(event.tool_use_id, event.summary, event.subagent_id)
187
+ elif isinstance(event, ToolProgress):
188
+ transcript.tool_progress(event.tool_use_id, event.text)
189
+ elif isinstance(event, ToolFinished):
190
+ transcript.update_tool(
191
+ event.tool_use_id, event.name, event.is_error, event.display, event.duration_s
192
+ )
193
+ elif isinstance(event, ProviderStatus):
194
+ if event.phase == "cold_boot":
195
+ boot.show(event.text, event.elapsed_s)
196
+ else:
197
+ boot.hide()
198
+ if event.phase == "retrying":
199
+ await transcript.add_note(event.text)
200
+ elif isinstance(event, StatusUpdate):
201
+ boot.hide()
202
+ status.context_tokens = event.context_tokens
203
+ status.context_max = max(1, event.context_max)
204
+ status.cost_usd = event.cost_usd
205
+ status.tokens_in = event.usage.input_tokens
206
+ status.tokens_out = event.usage.output_tokens
207
+ if event.gpu_seconds is not None:
208
+ status.gpu_seconds = event.gpu_seconds
209
+ elif isinstance(event, CompactionHappened):
210
+ await transcript.add_note(
211
+ f"context compacted ({event.tier}): "
212
+ f"{event.tokens_before:,} → {event.tokens_after:,} tokens"
213
+ )
214
+ elif isinstance(event, TurnFinished):
215
+ await transcript.end_message()
216
+
217
+ @work(exclusive=False, thread=False, name="startup")
218
+ async def _startup_worker(self) -> None:
219
+ """Connect MCP servers after the UI is up.
220
+
221
+ Doing this in __init__ would mean a slow or hanging MCP server delays the
222
+ first frame, which reads as a broken application.
223
+ """
224
+ results = await self.agent.start()
225
+ if not results or self.agent.mcp is None:
226
+ return
227
+ transcript = self.query_one(Transcript)
228
+ for name, ok in sorted(results.items()):
229
+ client = self.agent.mcp.clients[name]
230
+ detail = f"{len(client.tools)} tool(s)" if ok else f"unavailable ({client.error})"
231
+ await transcript.add_note(f"mcp {name}: {detail}")
232
+
233
+ @work(exclusive=False, thread=False, name="permissions")
234
+ async def _permission_worker(self) -> None:
235
+ async with self._permissions:
236
+ async for pending in self._permissions:
237
+ await self._resolve_permission(pending)
238
+
239
+ async def _resolve_permission(self, pending: PendingPermission) -> None:
240
+ request = pending.request
241
+ modal = (
242
+ ChoiceModal(request)
243
+ if request.tool_name == "AskUserQuestion"
244
+ else PermissionModal(request)
245
+ )
246
+ decision = await self.push_screen_wait(modal)
247
+ if decision.approved and decision.rule and decision.scope.value == "project":
248
+ self._persist_rule(decision.rule)
249
+ await pending.answer(decision)
250
+
251
+ def _persist_rule(self, rule: str) -> None:
252
+ """Append an allow rule to settings.local.json.
253
+
254
+ Local rather than shared: a permission the user granted on their machine is
255
+ not automatically one their teammates want committed.
256
+ """
257
+ import json
258
+
259
+ path = self.settings.project_root / ".turnloop" / "settings.local.json"
260
+ path.parent.mkdir(parents=True, exist_ok=True)
261
+ try:
262
+ data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
263
+ except json.JSONDecodeError:
264
+ data = {}
265
+ permissions = data.setdefault("permissions", {})
266
+ allow = permissions.setdefault("allow", [])
267
+ if rule not in allow:
268
+ allow.append(rule)
269
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
270
+
271
+ # --- commands ---------------------------------------------------------
272
+
273
+ async def _handle_command(self, text: str) -> None:
274
+ from turnloop.commands.dispatch import CommandOutcome, dispatch_command
275
+
276
+ transcript = self.query_one(Transcript)
277
+ outcome: CommandOutcome = await dispatch_command(text, self.agent, self.settings, self.cwd)
278
+
279
+ if outcome.message:
280
+ await transcript.add_note(outcome.message)
281
+ if outcome.quit:
282
+ self.exit()
283
+ return
284
+ if outcome.clear:
285
+ await transcript.remove_children()
286
+ await transcript.add_note("view cleared")
287
+ if outcome.mode:
288
+ self.settings.permission_mode = outcome.mode
289
+ self.agent.permissions.mode = outcome.mode
290
+ self.query_one(StatusBar).mode = outcome.mode
291
+ if outcome.prompt:
292
+ await transcript.add_user(outcome.prompt if outcome.echo else text)
293
+ if self._busy:
294
+ self._queued.append(outcome.prompt)
295
+ else:
296
+ self._start_turn(outcome.prompt)
297
+
298
+ # --- actions ----------------------------------------------------------
299
+
300
+ def action_interrupt(self) -> None:
301
+ if self._agent_worker is not None and self._busy:
302
+ self._agent_worker.cancel()
303
+ self._busy = False
304
+ else:
305
+ self.exit()
306
+
307
+ def action_cycle_mode(self) -> None:
308
+ order = ["default", "plan", "auto", "bypass"]
309
+ current = order.index(self.settings.permission_mode)
310
+ mode = order[(current + 1) % len(order)]
311
+ self.settings.permission_mode = mode # type: ignore[assignment]
312
+ self.agent.permissions.mode = mode # type: ignore[assignment]
313
+ self.query_one(StatusBar).mode = mode
314
+
315
+ async def action_clear(self) -> None:
316
+ await self.query_one(Transcript).remove_children()
317
+
318
+ # --- teardown ---------------------------------------------------------
319
+
320
+ async def on_unmount(self) -> None:
321
+ await self.agent.aclose()
322
+ provider = self.agent.provider
323
+ if provider.caps.cost_per_hour and provider.first_request_at:
324
+ gpu = provider.gpu_seconds() or 0
325
+ print(
326
+ f"\nThe self-hosted endpoint is still running "
327
+ f"({gpu / 60:.1f} min ≈ ${gpu / 3600 * provider.caps.cost_per_hour:.2f} so far).\n"
328
+ f"It scales down after 10 idle minutes, or stop it now:\n"
329
+ f" modal app stop glm-5-2-serve"
330
+ )
turnloop/tui/app.tcss ADDED
@@ -0,0 +1,84 @@
1
+ Screen {
2
+ layers: base modal;
3
+ }
4
+
5
+ #transcript {
6
+ height: 1fr;
7
+ padding: 0 1;
8
+ scrollbar-size-vertical: 1;
9
+ }
10
+
11
+ .msg {
12
+ margin-bottom: 1;
13
+ }
14
+
15
+ .msg.user {
16
+ color: $text;
17
+ background: $boost;
18
+ padding: 0 1;
19
+ }
20
+
21
+ .msg.assistant {
22
+ color: $text;
23
+ }
24
+
25
+ .msg.thinking {
26
+ color: $text-muted;
27
+ padding-left: 2;
28
+ }
29
+
30
+ .msg.thinking.collapsed {
31
+ display: none;
32
+ }
33
+
34
+ .msg.tool {
35
+ padding-left: 1;
36
+ margin-bottom: 0;
37
+ }
38
+
39
+ .msg.note {
40
+ color: $text-muted;
41
+ }
42
+
43
+ .msg.error {
44
+ color: $error;
45
+ }
46
+
47
+ #boot {
48
+ display: none;
49
+ height: auto;
50
+ padding: 1 2;
51
+ border: round $warning;
52
+ margin: 1 2;
53
+ }
54
+
55
+ #status {
56
+ height: 1;
57
+ background: $panel;
58
+ color: $text-muted;
59
+ }
60
+
61
+ #prompt {
62
+ border: none;
63
+ border-top: solid $primary;
64
+ height: 3;
65
+ }
66
+
67
+ #permission-box {
68
+ layer: modal;
69
+ width: 80%;
70
+ max-width: 100;
71
+ height: auto;
72
+ max-height: 80%;
73
+ padding: 1 2;
74
+ border: round $warning;
75
+ background: $surface;
76
+ }
77
+
78
+ #permission-body {
79
+ height: auto;
80
+ }
81
+
82
+ #permission-keys {
83
+ height: auto;
84
+ }
turnloop/tui/bridge.py ADDED
@@ -0,0 +1,142 @@
1
+ """The boundary between the agent loop and whatever is displaying it.
2
+
3
+ One rule, and the entire concurrency design follows from it:
4
+
5
+ **The agent loop never touches a widget, and the UI never awaits the model.**
6
+
7
+ Communication is one memory object stream outbound (events) and one one-shot
8
+ stream per permission request inbound (the answer). The loop's task parks on
9
+ `ask()` while the UI keeps repainting, handling keystrokes and streaming the tool
10
+ output that is already on screen.
11
+
12
+ Three implementations share this interface: the Textual app, the headless printer,
13
+ and a null channel for tests and experiments.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from abc import ABC, abstractmethod
19
+ from dataclasses import dataclass, field
20
+
21
+ import anyio
22
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
23
+
24
+ from turnloop.core.events import UIEvent
25
+ from turnloop.permissions.engine import PermissionDecision, PermissionRequest, Scope
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class PendingPermission:
30
+ request: PermissionRequest
31
+ reply_send: MemoryObjectSendStream[PermissionDecision]
32
+ reply_recv: MemoryObjectReceiveStream[PermissionDecision]
33
+
34
+ @classmethod
35
+ def create(cls, request: PermissionRequest) -> PendingPermission:
36
+ send, recv = anyio.create_memory_object_stream[PermissionDecision](1)
37
+ return cls(request=request, reply_send=send, reply_recv=recv)
38
+
39
+ async def answer(self, decision: PermissionDecision) -> None:
40
+ await self.reply_send.send(decision)
41
+
42
+ async def wait(self) -> PermissionDecision:
43
+ return await self.reply_recv.receive()
44
+
45
+
46
+ class UIChannel(ABC):
47
+ """What the agent loop is given instead of a display."""
48
+
49
+ @abstractmethod
50
+ async def send(self, event: UIEvent) -> None: ...
51
+
52
+ @abstractmethod
53
+ async def ask(self, request: PermissionRequest) -> PermissionDecision: ...
54
+
55
+ def for_subagent(self, subagent_id: str) -> UIChannel:
56
+ """A channel that tags everything with a subagent id.
57
+
58
+ Subagent output has to be visually separable — a fan-out of three children
59
+ interleaving into one transcript is unreadable — and its permission
60
+ prompts must still reach the one modal the parent owns.
61
+ """
62
+ return TaggedChannel(self, subagent_id)
63
+
64
+
65
+ @dataclass
66
+ class TaggedChannel(UIChannel):
67
+ inner: UIChannel
68
+ subagent_id: str
69
+
70
+ async def send(self, event: UIEvent) -> None:
71
+ if hasattr(event, "subagent_id") and getattr(event, "subagent_id", None) is None:
72
+ try:
73
+ object.__setattr__(event, "subagent_id", self.subagent_id)
74
+ except AttributeError: # slots without the field
75
+ pass
76
+ await self.inner.send(event)
77
+
78
+ async def ask(self, request: PermissionRequest) -> PermissionDecision:
79
+ request.subagent_id = self.subagent_id
80
+ return await self.inner.ask(request)
81
+
82
+
83
+ @dataclass
84
+ class StreamChannel(UIChannel):
85
+ """Pushes events into a memory stream. Used by the Textual app.
86
+
87
+ The send stream is buffered: a burst of token deltas must not block the agent
88
+ loop waiting for the UI to catch up, and if the buffer does fill, dropping
89
+ display events is better than stalling model consumption.
90
+ """
91
+
92
+ events: MemoryObjectSendStream[UIEvent]
93
+ permissions: MemoryObjectSendStream[PendingPermission]
94
+
95
+ async def send(self, event: UIEvent) -> None:
96
+ try:
97
+ self.events.send_nowait(event)
98
+ except anyio.WouldBlock:
99
+ # Buffer full: wait, but only briefly. A permanently stuck UI should
100
+ # not hang the model stream.
101
+ with anyio.move_on_after(1.0):
102
+ await self.events.send(event)
103
+ except anyio.BrokenResourceError:
104
+ pass # UI is gone; the loop is being torn down
105
+
106
+ async def ask(self, request: PermissionRequest) -> PermissionDecision:
107
+ pending = PendingPermission.create(request)
108
+ await self.permissions.send(pending)
109
+ return await pending.wait()
110
+
111
+ @staticmethod
112
+ def create(buffer: int = 4096):
113
+ ev_send, ev_recv = anyio.create_memory_object_stream[UIEvent](buffer)
114
+ pm_send, pm_recv = anyio.create_memory_object_stream[PendingPermission](8)
115
+ return StreamChannel(events=ev_send, permissions=pm_send), ev_recv, pm_recv
116
+
117
+
118
+ @dataclass
119
+ class NullChannel(UIChannel):
120
+ """Discards output; auto-answers permission prompts.
121
+
122
+ Used by experiments and tests. `auto_approve=False` is the interesting
123
+ setting: it exercises the denial path, which is how the permission-safety
124
+ grader checks that plan mode and deny rules actually hold.
125
+ """
126
+
127
+ auto_approve: bool = True
128
+ seen: list[UIEvent] = field(default_factory=list)
129
+ asked: list[PermissionRequest] = field(default_factory=list)
130
+ record: bool = False
131
+
132
+ async def send(self, event: UIEvent) -> None:
133
+ if self.record:
134
+ self.seen.append(event)
135
+
136
+ async def ask(self, request: PermissionRequest) -> PermissionDecision:
137
+ self.asked.append(request)
138
+ return PermissionDecision(
139
+ approved=self.auto_approve,
140
+ scope=Scope.ONCE,
141
+ reason="" if self.auto_approve else "non-interactive session",
142
+ )
File without changes