sbxloop 0.2.0__tar.gz → 0.4.0__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 (42) hide show
  1. {sbxloop-0.2.0 → sbxloop-0.4.0}/PKG-INFO +2 -2
  2. {sbxloop-0.2.0 → sbxloop-0.4.0}/pyproject.toml +2 -2
  3. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/__init__.py +1 -1
  4. sbxloop-0.4.0/src/sbxloop/_vendor/sbxloop_worker-0.4.0-py3-none-any.whl +0 -0
  5. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/app.py +168 -14
  6. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/doctor.py +24 -10
  7. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/tui.py +64 -25
  8. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/config.py +25 -2
  9. sbxloop-0.4.0/src/sbxloop/deliver.py +144 -0
  10. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/engine.py +127 -16
  11. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/model.py +32 -0
  12. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/phases.py +15 -2
  13. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/execute.md +5 -1
  14. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/store.py +33 -14
  15. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/errors.py +4 -0
  16. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/events.py +2 -0
  17. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/gh/reporter.py +3 -2
  18. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/pair.py +21 -5
  19. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/provision.py +76 -8
  20. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/sandbox.py +3 -0
  21. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/worker/client.py +4 -0
  22. sbxloop-0.2.0/src/sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
  23. {sbxloop-0.2.0 → sbxloop-0.4.0}/.gitignore +0 -0
  24. {sbxloop-0.2.0 → sbxloop-0.4.0}/README.md +0 -0
  25. {sbxloop-0.2.0 → sbxloop-0.4.0}/hatch_build.py +0 -0
  26. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/__init__.py +0 -0
  27. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/__init__.py +0 -0
  28. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/__init__.py +0 -0
  29. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/decompose.md +0 -0
  30. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/plan.md +0 -0
  31. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  32. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/validate.md +0 -0
  33. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/gh/__init__.py +0 -0
  34. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/gh/ops.py +0 -0
  35. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/ids.py +0 -0
  36. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/py.typed +0 -0
  37. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/__init__.py +0 -0
  38. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/cli.py +0 -0
  39. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/models.py +0 -0
  40. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/parse.py +0 -0
  41. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/worker/__init__.py +0 -0
  42. {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -19,7 +19,7 @@ Requires-Python: >=3.11
19
19
  Requires-Dist: pydantic>=2.7
20
20
  Requires-Dist: python-dotenv>=1.0
21
21
  Requires-Dist: rich>=13.7
22
- Requires-Dist: sbxloop-worker==0.2.0
22
+ Requires-Dist: sbxloop-worker==0.4.0
23
23
  Requires-Dist: typer>=0.12
24
24
  Description-Content-Type: text/markdown
25
25
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sbxloop"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "python-dotenv>=1.0",
22
22
  "typer>=0.12",
23
23
  "rich>=13.7",
24
- "sbxloop-worker==0.2.0",
24
+ "sbxloop-worker==0.4.0",
25
25
  ]
26
26
 
27
27
  [project.scripts]
@@ -6,7 +6,7 @@ agentic layer, and a GitHub-ops sandbox holding only ``GH_TOKEN`` for
6
6
  user-facing GitHub interactions. The balanced network policy is the default.
7
7
  """
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.4.0"
10
10
 
11
11
  from sbxloop.config import Budgets, Config, load_config
12
12
  from sbxloop.engine import LoopEngine, RunResult, run_outcome
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import queue
5
6
  import threading
6
7
  import time
7
8
  from pathlib import Path
@@ -11,15 +12,17 @@ import typer
11
12
  from rich.console import Console
12
13
  from rich.live import Live
13
14
  from rich.table import Table
15
+ from rich.tree import Tree
14
16
 
15
17
  import sbxloop
16
18
  from sbxloop.cli.doctor import run_doctor
17
- from sbxloop.cli.tui import Dashboard, format_event, plain_printer
19
+ from sbxloop.cli.tui import Dashboard, format_event, plain_printer, render_event
18
20
  from sbxloop.config import Config, load_config, load_config_with_sources, load_dotenv_file
19
21
  from sbxloop.engine.engine import LoopEngine
20
- from sbxloop.engine.model import RunResult
22
+ from sbxloop.engine.model import RunResult, artifact_files, artifacts_dir
21
23
  from sbxloop.engine.store import StateStore
22
24
  from sbxloop.errors import SdxloopError
25
+ from sbxloop.events import Event
23
26
  from sbxloop.sbx.cli import SbxCLI
24
27
  from sbxloop.sbx.provision import sandbox_name
25
28
 
@@ -66,13 +69,23 @@ def _store(config: Config) -> StateStore:
66
69
 
67
70
 
68
71
  def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
69
- """Run start/resume with either the live dashboard or plain event logs."""
72
+ """Run start/resume with the scrollback transcript + pinned status, or
73
+ plain event logs (--no-tui).
74
+
75
+ Transcript entries print permanently to the terminal's scrollback (via
76
+ ``live.console.print``, which renders above the live region), so the
77
+ full conversation history survives; only the compact status panel at
78
+ the bottom is redrawn in place. Events arrive on the engine thread but
79
+ every terminal write happens here on the main thread, via a queue —
80
+ ordering stays deterministic and rich's Live never interleaves.
81
+ """
70
82
  if not tui:
71
83
  engine.bus.subscribe(plain_printer(console))
72
84
  return action() # type: ignore[no-any-return]
73
85
 
74
86
  dashboard = Dashboard()
75
- engine.bus.subscribe(dashboard.on_event)
87
+ pending: queue.SimpleQueue[Event] = queue.SimpleQueue()
88
+ engine.bus.subscribe(pending.put)
76
89
  outcome: dict[str, Any] = {}
77
90
 
78
91
  def target() -> None:
@@ -83,29 +96,97 @@ def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
83
96
 
84
97
  thread = threading.Thread(target=target, daemon=True)
85
98
  with Live(dashboard.renderable(), console=console, refresh_per_second=8) as live:
99
+
100
+ def drain() -> None:
101
+ while True:
102
+ try:
103
+ event = pending.get_nowait()
104
+ except queue.Empty:
105
+ return
106
+ dashboard.on_event(event)
107
+ rendered = render_event(event)
108
+ if rendered is not None:
109
+ live.console.print(rendered)
110
+
86
111
  thread.start()
87
112
  while thread.is_alive():
113
+ drain()
88
114
  live.update(dashboard.renderable())
89
115
  time.sleep(0.15)
116
+ drain()
90
117
  live.update(dashboard.renderable())
91
118
  if "error" in outcome:
92
119
  raise outcome["error"]
93
120
  return outcome["result"] # type: ignore[no-any-return]
94
121
 
95
122
 
96
- def _finish(result: RunResult) -> None:
123
+ _TREE_MAX_FILES = 50
124
+
125
+
126
+ def _human_size(size: int) -> str:
127
+ value = float(size)
128
+ for unit in ("B", "KB", "MB"):
129
+ if value < 1024 or unit == "MB":
130
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
131
+ value /= 1024
132
+ raise AssertionError("unreachable") # pragma: no cover
133
+
134
+
135
+ def _artifacts_tree(root: Path, files: list[Path], cap: int = _TREE_MAX_FILES) -> Tree:
136
+ tree = Tree(f"[bold]{root}[/]")
137
+ nodes: dict[Path, Tree] = {root: tree}
138
+ for path in files[:cap]:
139
+ rel = path.relative_to(root)
140
+ parent = root
141
+ for part in rel.parts[:-1]:
142
+ child = parent / part
143
+ if child not in nodes:
144
+ nodes[child] = nodes[parent].add(f"{part}/")
145
+ parent = child
146
+ nodes[parent].add(f"{rel.name} [dim]({_human_size(path.stat().st_size)})[/]")
147
+ if len(files) > cap:
148
+ tree.add(f"[dim]… +{len(files) - cap} more[/]")
149
+ return tree
150
+
151
+
152
+ def _print_artifacts_summary(result: RunResult, config: Config) -> None:
153
+ target = artifacts_dir(result, config.state_dir)
154
+ if target is None or not target.is_dir():
155
+ return
156
+ files = artifact_files(target)
157
+ if not files:
158
+ console.print(f"\nartifacts: none produced (workspace: {target})")
159
+ return
160
+ via = "live workspace mount" if result.mounted else "harvested from the sandbox"
161
+ console.print(f"\nartifacts: {len(files)} file(s), {via}")
162
+ console.print(_artifacts_tree(target, files))
163
+
164
+
165
+ def _finish(result: RunResult, config: Config) -> None:
97
166
  style = "green" if result.succeeded else "red"
98
167
  console.print(f"\nrun [bold cyan]{result.run_id}[/] finished: [bold {style}]{result.state}[/]")
99
168
  for task in result.tasks:
100
169
  console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
170
+ _print_artifacts_summary(result, config)
101
171
  raise typer.Exit(0 if result.succeeded else 1)
102
172
 
103
173
 
104
174
  @app.command()
105
175
  def run(
106
176
  outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
107
- repo: Annotated[
108
- str | None, typer.Option("--repo", help="owner/repo for GitHub progress reporting.")
177
+ report: Annotated[
178
+ bool | None,
179
+ typer.Option(
180
+ "--report/--no-report",
181
+ help="Post run progress to the configured [github].repo.",
182
+ ),
183
+ ] = None,
184
+ deliver: Annotated[
185
+ bool | None,
186
+ typer.Option(
187
+ "--deliver/--no-deliver",
188
+ help="Publish the completed run's artifacts as a PR to the configured [github].repo.",
189
+ ),
109
190
  ] = None,
110
191
  model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
111
192
  keep_sandboxes: Annotated[
@@ -115,17 +196,36 @@ def run(
115
196
  ) -> None:
116
197
  """Run an agentic loop for OUTCOME in a fresh sandbox pair."""
117
198
  config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
118
- if repo:
199
+ if report is not None:
200
+ config = config.model_copy(
201
+ update={"github": config.github.model_copy(update={"report": report})}
202
+ )
203
+ if deliver is not None:
119
204
  config = config.model_copy(
120
- update={"github": config.github.model_copy(update={"report_repo": repo})}
205
+ update={"github": config.github.model_copy(update={"deliver": deliver})}
121
206
  )
207
+ wanted = [
208
+ feature
209
+ for feature, enabled in (
210
+ ("progress reporting (--report)", config.github.report),
211
+ ("PR delivery (--deliver)", config.github.deliver),
212
+ )
213
+ if enabled
214
+ ]
215
+ if wanted and not config.github.enabled:
216
+ console.print(
217
+ f"[bold red]GitHub integration is not configured.[/] {', '.join(wanted)} "
218
+ 'needs a repository: set [cyan]\\[github] repo = "owner/repo"[/] in '
219
+ "sbxloop.toml (see `sbxloop init`), then re-run."
220
+ )
221
+ raise typer.Exit(2)
122
222
  engine = LoopEngine(config)
123
223
  try:
124
224
  result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
125
225
  except SdxloopError as exc:
126
226
  console.print(f"[bold red]run failed:[/] {exc}")
127
227
  raise typer.Exit(2) from exc
128
- _finish(result)
228
+ _finish(result, config)
129
229
 
130
230
 
131
231
  @app.command()
@@ -134,13 +234,14 @@ def resume(
134
234
  tui: Annotated[bool, typer.Option("--tui/--no-tui")] = True,
135
235
  ) -> None:
136
236
  """Resume an unfinished run (fresh sandboxes, persisted state)."""
137
- engine = LoopEngine(load_config())
237
+ config = load_config()
238
+ engine = LoopEngine(config)
138
239
  try:
139
240
  result = _drive_with_ui(engine, tui=tui, action=lambda: engine.resume(run_id))
140
241
  except SdxloopError as exc:
141
242
  console.print(f"[bold red]resume failed:[/] {exc}")
142
243
  raise typer.Exit(2) from exc
143
- _finish(result)
244
+ _finish(result, config)
144
245
 
145
246
 
146
247
  @app.command()
@@ -229,6 +330,49 @@ def logs(
229
330
  time.sleep(0.5)
230
331
 
231
332
 
333
+ @app.command()
334
+ def artifacts(
335
+ run_id: Annotated[str, typer.Argument(help="Run id.")],
336
+ path: Annotated[
337
+ bool, typer.Option("--path", help="Print only the artifacts directory (for scripting).")
338
+ ] = False,
339
+ tree: Annotated[
340
+ bool, typer.Option("--tree/--list", help="Render a file tree instead of a flat list.")
341
+ ] = False,
342
+ ) -> None:
343
+ """Show where a run's artifacts live on the host, and what is in there."""
344
+ config = load_config()
345
+ store = _store(config)
346
+ try:
347
+ record = store.get_run(run_id)
348
+ except SdxloopError as exc:
349
+ console.print(f"[bold red]{exc}[/]")
350
+ raise typer.Exit(2) from exc
351
+ target = artifacts_dir(record, config.state_dir)
352
+ if target is None:
353
+ console.print(
354
+ f"[bold red]run {run_id} has no artifacts:[/] it never provisioned a workspace "
355
+ f"(state: {record.state})"
356
+ )
357
+ raise typer.Exit(2)
358
+ if path:
359
+ # bare path on stdout, nothing else — `cd $(sbxloop artifacts R --path)`
360
+ typer.echo(str(target))
361
+ return
362
+ if not target.is_dir():
363
+ console.print(f"[bold red]artifacts directory is gone:[/] {target}")
364
+ raise typer.Exit(2)
365
+ files = artifact_files(target)
366
+ via = "live workspace mount" if record.mounted else "harvested copy"
367
+ console.print(f"run [bold cyan]{run_id}[/]: {len(files)} file(s) ({via}) in [bold]{target}[/]")
368
+ if tree:
369
+ console.print(_artifacts_tree(target, files))
370
+ return
371
+ for file in files:
372
+ size = _human_size(file.stat().st_size)
373
+ console.print(f" {file.relative_to(target)} [dim]{size}[/]")
374
+
375
+
232
376
  @sandbox_app.command("ls")
233
377
  def sandbox_ls() -> None:
234
378
  """List sbxloop-managed sandboxes."""
@@ -345,8 +489,18 @@ secret_strategy = "proxy"
345
489
  extra_allow_domains = []
346
490
 
347
491
  [github]
348
- # Report run progress to a GitHub repo ("owner/repo"); empty disables.
349
- # report_repo = "you/your-repo"
492
+ # The GitHub integration. Unset (the default) disables GitHub entirely:
493
+ # no github sandbox is provisioned, GH_TOKEN is not required, and
494
+ # repo-facing features refuse to run. Set `repo` to the ONE repository
495
+ # sbxloop may work with; the toggles below act on it.
496
+ # repo = "you/your-repo"
497
+ # Post run progress (issues/comments) to the configured repo.
498
+ # report = false
499
+ # Open a PR with the run's artifacts when a run completes (or `--deliver`).
500
+ # GH_TOKEN needs contents:write + pull_requests:write on the repo.
501
+ # deliver = false
502
+ # deliver_base = "main" # base branch; unset uses the repo's default
503
+ # deliver_draft = false
350
504
 
351
505
  [budgets]
352
506
  max_revisions_per_task = 2
@@ -109,17 +109,31 @@ def collect_checks(
109
109
  f"permission and export {COPILOT_TOKEN_ENV}",
110
110
  )
111
111
  )
112
- gh_set = any(env.get(name) for name in GH_TOKEN_ENVS)
113
- checks.append(
114
- Check(
115
- "/".join(GH_TOKEN_ENVS),
116
- gh_set,
117
- "set"
118
- if gh_set
119
- else "not set — create a fine-grained PAT (issues:write, contents:read, ...) "
120
- "and export GH_TOKEN",
112
+ # GH_TOKEN matters only when the GitHub integration is configured; an
113
+ # unconfigured integration is a valid (GitHub-less) setup, not a failure.
114
+ if config.github.enabled:
115
+ gh_set = any(env.get(name) for name in GH_TOKEN_ENVS)
116
+ checks.append(
117
+ Check(
118
+ "/".join(GH_TOKEN_ENVS),
119
+ gh_set,
120
+ f"set (github integration: {config.github.repo})"
121
+ if gh_set
122
+ else f"not set but [github].repo = {config.github.repo!r} is configured — "
123
+ "create a fine-grained PAT (issues:write, contents:read, ...) "
124
+ "and export GH_TOKEN",
125
+ )
126
+ )
127
+ else:
128
+ checks.append(
129
+ Check(
130
+ "github integration",
131
+ True,
132
+ 'not configured — GitHub features disabled (set [github] repo = "owner/repo" '
133
+ "in sbxloop.toml to enable)",
134
+ hard=False,
135
+ )
121
136
  )
122
- )
123
137
 
124
138
  # worker wheel
125
139
  wheel = resolve_worker_wheel()
@@ -1,16 +1,20 @@
1
- """Rich rendering for runs: a chat-style live transcript and plain logging.
2
-
3
- Agent output is conversational (markdown, fenced code blocks), so the live
4
- view renders it as a chat thread markdown panels with syntax-highlighted
5
- code rather than truncated log lines. Host lifecycle events stay compact
6
- one-liners. ``format_event`` remains the dense single-line form used by
1
+ """Rich rendering for runs: a permanent scrollback transcript plus a small
2
+ pinned status region.
3
+
4
+ Agent output is conversational (markdown, fenced code blocks), so the
5
+ transcript renders it as a chat thread markdown panels with
6
+ syntax-highlighted code rather than truncated log lines. Transcript
7
+ entries are printed to the terminal's normal scrollback as they happen and
8
+ are never rewritten or dropped: the full history of a run stays scrollable.
9
+ Only the compact status region (run state + task table) is live-updated in
10
+ place at the bottom. Host lifecycle events stay compact one-liners.
11
+ ``format_event`` remains the dense single-line form used by
7
12
  ``sbxloop logs``.
8
13
  """
9
14
 
10
15
  from __future__ import annotations
11
16
 
12
17
  import datetime
13
- from collections import deque
14
18
  from typing import Any
15
19
 
16
20
  from rich.console import Console, Group, RenderableType
@@ -50,12 +54,24 @@ _TRANSCRIPT_SKIP = {
50
54
  }
51
55
 
52
56
  AGENT_MESSAGE_CLIP = 4000
57
+ # One-line clip for tool arguments; output tail lines shown on tool failure.
58
+ TOOL_ARGS_LINE_CLIP = 160
59
+ TOOL_FAIL_TAIL_LINES = 6
53
60
 
54
61
 
55
62
  def _stamp(event: Event) -> str:
56
63
  return datetime.datetime.fromtimestamp(event.ts).strftime("%H:%M:%S")
57
64
 
58
65
 
66
+ def _one_line(text: str, limit: int = TOOL_ARGS_LINE_CLIP) -> str:
67
+ """Collapse to a single display line, eliding the middle beyond limit."""
68
+ flat = " ".join(text.split())
69
+ if len(flat) <= limit:
70
+ return flat
71
+ keep = (limit - 1) // 2
72
+ return f"{flat[:keep]}…{flat[-keep:]}"
73
+
74
+
59
75
  def render_event(event: Event) -> RenderableType | None:
60
76
  """One transcript entry for an event, or None when it should be skipped."""
61
77
  if event.type in _TRANSCRIPT_SKIP:
@@ -88,10 +104,36 @@ def render_event(event: Event) -> RenderableType | None:
88
104
 
89
105
  if event.type == EventTypes.AGENT_TOOL_START:
90
106
  tool = data.get("tool") or "tool"
91
- return Text(f"{_stamp(event)} ⚙ {tool}", style="yellow", overflow="fold")
107
+ start = Text(f"{_stamp(event)} ⚙ {tool}", style="yellow")
108
+ args = _one_line(str(data.get("args") or ""))
109
+ if args:
110
+ start.append(" $ ", style="bold yellow")
111
+ start.append(args, style="yellow dim")
112
+ start.overflow = "fold"
113
+ return start
92
114
 
93
115
  if event.type == EventTypes.AGENT_TOOL_END:
94
- return None # start lines carry enough signal for the transcript
116
+ tool = data.get("tool") or "tool"
117
+ success = data.get("success")
118
+ exit_code = data.get("exit_code")
119
+ if success is None and exit_code is None:
120
+ return None # nothing informative beyond the start line
121
+ if success or (success is None and exit_code == 0):
122
+ suffix = " exit 0" if exit_code == 0 else ""
123
+ return Text(f"{_stamp(event)} ✓ {tool}{suffix}", style="green dim")
124
+ suffix = f" exit {exit_code}" if exit_code is not None else ""
125
+ failure = Text(f"{_stamp(event)} ✗ {tool}{suffix}", style="red")
126
+ tail = str(data.get("output") or "").strip()
127
+ if tail:
128
+ return Group(
129
+ failure,
130
+ Text(
131
+ "\n".join(tail.splitlines()[-TOOL_FAIL_TAIL_LINES:]),
132
+ style="red dim",
133
+ overflow="fold",
134
+ ),
135
+ )
136
+ return failure
95
137
 
96
138
  if event.type.startswith(_LIFECYCLE_PREFIXES):
97
139
  line = format_event(event)
@@ -106,14 +148,16 @@ def render_event(event: Event) -> RenderableType | None:
106
148
 
107
149
 
108
150
  class Dashboard:
109
- """Accumulates run state from the event stream and renders it."""
151
+ """Accumulates run status from the event stream and renders the pinned
152
+ region. Transcript entries are NOT kept here — the drive loop prints
153
+ them straight to scrollback via ``render_event`` so history is never
154
+ truncated or rewritten."""
110
155
 
111
- def __init__(self, max_entries: int = 6) -> None:
156
+ def __init__(self) -> None:
112
157
  self.run_id: str | None = None
113
158
  self.run_state: str = "starting"
114
159
  self.outcome: str = ""
115
160
  self.tasks: dict[str, dict[str, Any]] = {}
116
- self.transcript: deque[RenderableType] = deque(maxlen=max_entries)
117
161
 
118
162
  # -- event intake ------------------------------------------------------
119
163
 
@@ -139,9 +183,6 @@ class Dashboard:
139
183
  if data.get("title"):
140
184
  entry["title"] = str(data["title"])
141
185
  entry["state"] = str(data.get("state", entry["state"]))
142
- rendered = render_event(event)
143
- if rendered is not None:
144
- self.transcript.append(rendered)
145
186
 
146
187
  # -- rendering ---------------------------------------------------------
147
188
 
@@ -170,15 +211,10 @@ class Dashboard:
170
211
  if not self.tasks:
171
212
  table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
172
213
 
173
- transcript: RenderableType
174
- if self.transcript:
175
- transcript = Group(*self.transcript)
176
- else:
177
- transcript = Text("waiting for events…", style="dim")
178
- return Group(
179
- Panel(Group(header, outcome), border_style="cyan"),
180
- table,
181
- transcript,
214
+ return Panel(
215
+ Group(header, outcome, table),
216
+ border_style="cyan",
217
+ padding=(0, 1),
182
218
  )
183
219
 
184
220
 
@@ -187,11 +223,14 @@ def format_event(event: Event) -> str:
187
223
  parts = [_stamp(event), event.type]
188
224
  if event.data.get("task_id"):
189
225
  parts.append(f"[{event.data['task_id']}]")
190
- for key in ("state", "content", "tool", "op", "line", "message", "outcome"):
226
+ keys = ("state", "content", "tool", "op", "line", "message", "outcome", "error", "url", "path")
227
+ for key in keys:
191
228
  if event.data.get(key):
192
229
  value = str(event.data[key]).replace("\n", " ")
193
230
  parts.append(value[:160])
194
231
  break
232
+ if event.data.get("args"):
233
+ parts.append(_one_line(str(event.data["args"]), 120))
195
234
  return " ".join(parts)
196
235
 
197
236
 
@@ -12,12 +12,13 @@ strings.
12
12
  from __future__ import annotations
13
13
 
14
14
  import os
15
+ import re
15
16
  import tomllib
16
17
  from collections.abc import Mapping
17
18
  from pathlib import Path
18
19
  from typing import Any, Literal
19
20
 
20
- from pydantic import BaseModel, ConfigDict, Field, ValidationError
21
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
21
22
 
22
23
  from sbxloop.errors import ConfigError
23
24
 
@@ -42,7 +43,29 @@ class SandboxConfig(_ConfigModel):
42
43
 
43
44
 
44
45
  class GithubConfig(_ConfigModel):
45
- report_repo: str | None = None
46
+ """The GitHub integration. ``repo`` is the gate: unset (the default)
47
+ disables GitHub entirely — no github sandbox is provisioned, no GH_TOKEN
48
+ is required, and repo-facing features (progress reporting, delivery)
49
+ refuse to run. Setting it makes ``repo`` the one repository sbxloop is
50
+ allowed to work with; behavior toggles like ``report`` act on it."""
51
+
52
+ repo: str | None = None
53
+ report: bool = False
54
+ # Publish a completed run's artifacts as a PR to `repo`.
55
+ deliver: bool = False
56
+ deliver_base: str | None = None # base branch; None → the repo's default
57
+ deliver_draft: bool = False
58
+
59
+ @field_validator("repo")
60
+ @classmethod
61
+ def _check_repo(cls, value: str | None) -> str | None:
62
+ if value is not None and not re.fullmatch(r"[\w.-]+/[\w.-]+", value):
63
+ raise ValueError(f"github.repo must be owner/name, got {value!r}")
64
+ return value
65
+
66
+ @property
67
+ def enabled(self) -> bool:
68
+ return self.repo is not None
46
69
 
47
70
 
48
71
  class Budgets(_ConfigModel):