sbxloop 0.3.0__tar.gz → 0.4.1__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.3.0 → sbxloop-0.4.1}/.gitignore +4 -0
  2. {sbxloop-0.3.0 → sbxloop-0.4.1}/PKG-INFO +2 -2
  3. {sbxloop-0.3.0 → sbxloop-0.4.1}/hatch_build.py +6 -0
  4. {sbxloop-0.3.0 → sbxloop-0.4.1}/pyproject.toml +18 -3
  5. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/__init__.py +10 -1
  6. sbxloop-0.3.0/src/sbxloop/_vendor/sbxloop_worker-0.3.0-py3-none-any.whl → sbxloop-0.4.1/src/sbxloop/_vendor/sbxloop_worker-0.4.1-py3-none-any.whl +0 -0
  7. sbxloop-0.4.1/src/sbxloop/_version.py +24 -0
  8. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/app.py +78 -22
  9. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/doctor.py +24 -10
  10. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/tui.py +64 -25
  11. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/config.py +25 -2
  12. sbxloop-0.4.1/src/sbxloop/deliver.py +144 -0
  13. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/engine.py +65 -11
  14. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/model.py +10 -0
  15. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/errors.py +4 -0
  16. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/events.py +1 -0
  17. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/gh/reporter.py +3 -2
  18. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/pair.py +9 -4
  19. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/provision.py +14 -7
  20. {sbxloop-0.3.0 → sbxloop-0.4.1}/README.md +0 -0
  21. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/__init__.py +0 -0
  22. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/__init__.py +0 -0
  23. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/phases.py +0 -0
  24. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/__init__.py +0 -0
  25. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/decompose.md +0 -0
  26. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/execute.md +0 -0
  27. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/plan.md +0 -0
  28. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  29. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/validate.md +0 -0
  30. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/store.py +0 -0
  31. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/gh/__init__.py +0 -0
  32. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/gh/ops.py +0 -0
  33. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/ids.py +0 -0
  34. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/py.typed +0 -0
  35. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/__init__.py +0 -0
  36. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/cli.py +0 -0
  37. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/models.py +0 -0
  38. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/parse.py +0 -0
  39. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/sandbox.py +0 -0
  40. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/worker/__init__.py +0 -0
  41. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/worker/client.py +0 -0
  42. {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/worker/wheel.py +0 -0
@@ -221,3 +221,7 @@ __marimo__/
221
221
  .sbxloop/
222
222
  .sdxloop/
223
223
  packages/sbxloop/src/sbxloop/_vendor/
224
+
225
+ # written at build time by hatch-vcs
226
+ packages/sbxloop/src/sbxloop/_version.py
227
+ packages/sbxloop-worker/src/sbxloop_worker/_version.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop
3
- Version: 0.3.0
3
+ Version: 0.4.1
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.3.0
22
+ Requires-Dist: sbxloop-worker
23
23
  Requires-Dist: typer>=0.12
24
24
  Description-Content-Type: text/markdown
25
25
 
@@ -29,6 +29,12 @@ class VendorWorkerWheelHook(BuildHookInterface): # type: ignore[type-arg]
29
29
  host_version = self.metadata.version
30
30
  expected = f"sbxloop_worker-{host_version}-py3-none-any.whl"
31
31
 
32
+ # The static dependency list can't carry an exact pin now that versions
33
+ # come from git tags (hatch-vcs), so add the lockstep pin to the wheel
34
+ # metadata here.
35
+ if self.target_name == "wheel":
36
+ build_data.setdefault("dependencies", []).append(f"sbxloop-worker=={host_version}")
37
+
32
38
  if (vendor_dir / expected).is_file():
33
39
  return
34
40
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sbxloop"
3
- version = "0.3.0"
3
+ dynamic = ["version"] # derived from git tags by hatch-vcs (see [tool.hatch.version])
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,10 @@ dependencies = [
21
21
  "python-dotenv>=1.0",
22
22
  "typer>=0.12",
23
23
  "rich>=13.7",
24
- "sbxloop-worker==0.3.0",
24
+ # Unpinned here because the version is dynamic; the build hook
25
+ # (hatch_build.py) adds the exact lockstep pin `sbxloop-worker==X.Y.Z`
26
+ # to the wheel metadata at build time.
27
+ "sbxloop-worker",
25
28
  ]
26
29
 
27
30
  [project.scripts]
@@ -36,9 +39,21 @@ Issues = "https://github.com/brettbergin/sbxloop/issues"
36
39
  sbxloop-worker = { workspace = true }
37
40
 
38
41
  [build-system]
39
- requires = ["hatchling>=1.27"]
42
+ requires = ["hatchling>=1.27", "hatch-vcs"]
40
43
  build-backend = "hatchling.build"
41
44
 
45
+ # Both workspace packages version from the same repo-root git tag, keeping the
46
+ # lockstep invariant by construction. `fallback_root` lets a wheel built from
47
+ # an extracted sdist (no git) pick the version up from the sdist's PKG-INFO.
48
+ [tool.hatch.version]
49
+ source = "vcs"
50
+ raw-options = { root = "../..", fallback_root = ".", local_scheme = "no-local-version", fallback_version = "0.0.0" }
51
+
52
+ # Write the resolved version into the package so `sbxloop.__version__` and
53
+ # `sbxloop --version` report it at runtime.
54
+ [tool.hatch.build.hooks.vcs]
55
+ version-file = "src/sbxloop/_version.py"
56
+
42
57
  [tool.hatch.build.targets.wheel]
43
58
  packages = ["src/sbxloop"]
44
59
  artifacts = ["src/sbxloop/_vendor/*.whl"]
@@ -6,7 +6,16 @@ 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.3.0"
9
+ try:
10
+ # written at build time by hatch-vcs (see pyproject [tool.hatch.build.hooks.vcs])
11
+ from sbxloop._version import __version__
12
+ except ImportError: # pragma: no cover - raw source tree that was never built
13
+ try:
14
+ from importlib.metadata import version as _pkg_version
15
+
16
+ __version__ = _pkg_version("sbxloop")
17
+ except Exception:
18
+ __version__ = "0.0.0"
10
19
 
11
20
  from sbxloop.config import Budgets, Config, load_config
12
21
  from sbxloop.engine import LoopEngine, RunResult, run_outcome
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.4.1'
22
+ __version_tuple__ = version_tuple = (0, 4, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -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
@@ -15,12 +16,13 @@ from rich.tree import Tree
15
16
 
16
17
  import sbxloop
17
18
  from sbxloop.cli.doctor import run_doctor
18
- from sbxloop.cli.tui import Dashboard, format_event, plain_printer
19
+ from sbxloop.cli.tui import Dashboard, format_event, plain_printer, render_event
19
20
  from sbxloop.config import Config, load_config, load_config_with_sources, load_dotenv_file
20
21
  from sbxloop.engine.engine import LoopEngine
21
- from sbxloop.engine.model import RunResult, artifacts_dir
22
+ from sbxloop.engine.model import RunResult, artifact_files, artifacts_dir
22
23
  from sbxloop.engine.store import StateStore
23
24
  from sbxloop.errors import SdxloopError
25
+ from sbxloop.events import Event
24
26
  from sbxloop.sbx.cli import SbxCLI
25
27
  from sbxloop.sbx.provision import sandbox_name
26
28
 
@@ -67,13 +69,23 @@ def _store(config: Config) -> StateStore:
67
69
 
68
70
 
69
71
  def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
70
- """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
+ """
71
82
  if not tui:
72
83
  engine.bus.subscribe(plain_printer(console))
73
84
  return action() # type: ignore[no-any-return]
74
85
 
75
86
  dashboard = Dashboard()
76
- engine.bus.subscribe(dashboard.on_event)
87
+ pending: queue.SimpleQueue[Event] = queue.SimpleQueue()
88
+ engine.bus.subscribe(pending.put)
77
89
  outcome: dict[str, Any] = {}
78
90
 
79
91
  def target() -> None:
@@ -84,10 +96,24 @@ def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
84
96
 
85
97
  thread = threading.Thread(target=target, daemon=True)
86
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
+
87
111
  thread.start()
88
112
  while thread.is_alive():
113
+ drain()
89
114
  live.update(dashboard.renderable())
90
115
  time.sleep(0.15)
116
+ drain()
91
117
  live.update(dashboard.renderable())
92
118
  if "error" in outcome:
93
119
  raise outcome["error"]
@@ -106,16 +132,6 @@ def _human_size(size: int) -> str:
106
132
  raise AssertionError("unreachable") # pragma: no cover
107
133
 
108
134
 
109
- def _artifact_files(root: Path) -> list[Path]:
110
- """Regular files under root, hidden files/dirs excluded (an agent's .git
111
- would otherwise swamp the listing), sorted for stable output."""
112
- return sorted(
113
- p
114
- for p in root.rglob("*")
115
- if p.is_file() and not any(part.startswith(".") for part in p.relative_to(root).parts)
116
- )
117
-
118
-
119
135
  def _artifacts_tree(root: Path, files: list[Path], cap: int = _TREE_MAX_FILES) -> Tree:
120
136
  tree = Tree(f"[bold]{root}[/]")
121
137
  nodes: dict[Path, Tree] = {root: tree}
@@ -137,7 +153,7 @@ def _print_artifacts_summary(result: RunResult, config: Config) -> None:
137
153
  target = artifacts_dir(result, config.state_dir)
138
154
  if target is None or not target.is_dir():
139
155
  return
140
- files = _artifact_files(target)
156
+ files = artifact_files(target)
141
157
  if not files:
142
158
  console.print(f"\nartifacts: none produced (workspace: {target})")
143
159
  return
@@ -158,8 +174,19 @@ def _finish(result: RunResult, config: Config) -> None:
158
174
  @app.command()
159
175
  def run(
160
176
  outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
161
- repo: Annotated[
162
- 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
+ ),
163
190
  ] = None,
164
191
  model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
165
192
  keep_sandboxes: Annotated[
@@ -169,10 +196,29 @@ def run(
169
196
  ) -> None:
170
197
  """Run an agentic loop for OUTCOME in a fresh sandbox pair."""
171
198
  config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
172
- if repo:
199
+ if report is not None:
173
200
  config = config.model_copy(
174
- update={"github": config.github.model_copy(update={"report_repo": repo})}
201
+ update={"github": config.github.model_copy(update={"report": report})}
175
202
  )
203
+ if deliver is not None:
204
+ config = config.model_copy(
205
+ update={"github": config.github.model_copy(update={"deliver": deliver})}
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)
176
222
  engine = LoopEngine(config)
177
223
  try:
178
224
  result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
@@ -316,7 +362,7 @@ def artifacts(
316
362
  if not target.is_dir():
317
363
  console.print(f"[bold red]artifacts directory is gone:[/] {target}")
318
364
  raise typer.Exit(2)
319
- files = _artifact_files(target)
365
+ files = artifact_files(target)
320
366
  via = "live workspace mount" if record.mounted else "harvested copy"
321
367
  console.print(f"run [bold cyan]{run_id}[/]: {len(files)} file(s) ({via}) in [bold]{target}[/]")
322
368
  if tree:
@@ -443,8 +489,18 @@ secret_strategy = "proxy"
443
489
  extra_allow_domains = []
444
490
 
445
491
  [github]
446
- # Report run progress to a GitHub repo ("owner/repo"); empty disables.
447
- # 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
448
504
 
449
505
  [budgets]
450
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", "path"):
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):
@@ -0,0 +1,144 @@
1
+ """Deliver a completed run's artifacts as a GitHub pull request.
2
+
3
+ Everything goes through the :class:`GithubOps` facade, i.e. runs as
4
+ ``github.op`` jobs inside the github-ops sandbox — the only environment
5
+ holding ``GH_TOKEN``; the credential split is preserved. Files are committed
6
+ atomically through the git data API (blobs → tree → commit → ref) rather
7
+ than per-file contents PUTs: one commit regardless of file count, and
8
+ base64 blobs carry binary content.
9
+
10
+ Scaffold status: this is a real, unit-tested code path (stubbed GithubOps),
11
+ but per the project pattern — unverified external behaviors get a seam and
12
+ an e2e check, never a confident default — it is NOT field-proven until the
13
+ real-sbx e2e workflow exercises it. Known e2e-validation items:
14
+
15
+ - TODO(e2e): branch-name collisions (re-delivering the same run id — the
16
+ refs POST will 422; decide between force-update and suffixing)
17
+ - TODO(e2e): large workspaces (one blob POST per file; may need chunking
18
+ or a tarball-artifact fallback beyond a few hundred files)
19
+ - TODO(e2e): executable permission bits (every file is committed 100644)
20
+ - TODO(e2e): empty repositories (no base ref to branch from)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from sbxloop.engine.model import artifact_files
30
+ from sbxloop.errors import DeliveryError
31
+ from sbxloop.gh.ops import GithubOps, PrRef
32
+
33
+ FILE_MODE = "100644"
34
+ BODY_FILE_LIST_CAP = 50
35
+ TITLE_CLIP = 72
36
+
37
+
38
+ def branch_name(run_id: str) -> str:
39
+ return f"sbxloop/{run_id}"
40
+
41
+
42
+ def deliver_workspace(
43
+ ops: GithubOps,
44
+ repo: str,
45
+ *,
46
+ run_id: str,
47
+ outcome: str,
48
+ source_dir: Path,
49
+ base: str | None = None,
50
+ draft: bool = False,
51
+ ) -> PrRef:
52
+ """Publish source_dir as one commit on a new branch and open a PR."""
53
+ files = artifact_files(source_dir)
54
+ if not files:
55
+ raise DeliveryError(f"nothing to deliver: no files in {source_dir}")
56
+
57
+ if base is None:
58
+ base = str(ops.repo_get(repo).get("default_branch") or "main")
59
+ base_sha = _base_commit_sha(ops, repo, base)
60
+ base_tree = _commit_tree_sha(ops, repo, base_sha)
61
+
62
+ entries = [
63
+ {
64
+ "path": file.relative_to(source_dir).as_posix(),
65
+ "mode": FILE_MODE,
66
+ "type": "blob",
67
+ "sha": _create_blob(ops, repo, file),
68
+ }
69
+ for file in files
70
+ ]
71
+ tree = _sha(
72
+ ops.raw("POST", f"/repos/{repo}/git/trees", {"base_tree": base_tree, "tree": entries}),
73
+ f"tree for {repo}",
74
+ )
75
+ commit = _sha(
76
+ ops.raw(
77
+ "POST",
78
+ f"/repos/{repo}/git/commits",
79
+ {
80
+ "message": f"sbxloop run {run_id}: deliver artifacts\n\nOutcome: {outcome}",
81
+ "tree": tree,
82
+ "parents": [base_sha],
83
+ },
84
+ ),
85
+ f"commit for {repo}",
86
+ )
87
+ branch = branch_name(run_id)
88
+ ops.raw("POST", f"/repos/{repo}/git/refs", {"ref": f"refs/heads/{branch}", "sha": commit})
89
+
90
+ return ops.pr_create(
91
+ repo,
92
+ base=base,
93
+ head=branch,
94
+ title=_title(outcome),
95
+ body=_body(run_id, outcome, source_dir, files),
96
+ draft=draft,
97
+ )
98
+
99
+
100
+ def _base_commit_sha(ops: GithubOps, repo: str, base: str) -> str:
101
+ ref = ops.raw("GET", f"/repos/{repo}/git/ref/heads/{base}")
102
+ try:
103
+ return str(ref["object"]["sha"])
104
+ except (TypeError, KeyError) as exc:
105
+ raise DeliveryError(f"cannot resolve base branch {base!r} of {repo}") from exc
106
+
107
+
108
+ def _commit_tree_sha(ops: GithubOps, repo: str, commit_sha: str) -> str:
109
+ commit = ops.raw("GET", f"/repos/{repo}/git/commits/{commit_sha}")
110
+ try:
111
+ return str(commit["tree"]["sha"])
112
+ except (TypeError, KeyError) as exc:
113
+ raise DeliveryError(f"cannot read base commit {commit_sha} of {repo}") from exc
114
+
115
+
116
+ def _create_blob(ops: GithubOps, repo: str, file: Path) -> str:
117
+ content = base64.b64encode(file.read_bytes()).decode("ascii")
118
+ return _sha(
119
+ ops.raw("POST", f"/repos/{repo}/git/blobs", {"content": content, "encoding": "base64"}),
120
+ f"blob for {file.name}",
121
+ )
122
+
123
+
124
+ def _sha(response: Any, what: str) -> str:
125
+ sha = response.get("sha") if isinstance(response, dict) else None
126
+ if not sha:
127
+ raise DeliveryError(f"GitHub returned no sha creating {what}: {response!r}")
128
+ return str(sha)
129
+
130
+
131
+ def _title(outcome: str) -> str:
132
+ title = f"sbxloop: {outcome}"
133
+ return title if len(title) <= TITLE_CLIP else title[: TITLE_CLIP - 1] + "…"
134
+
135
+
136
+ def _body(run_id: str, outcome: str, source_dir: Path, files: list[Path]) -> str:
137
+ listed = [f"- `{f.relative_to(source_dir).as_posix()}`" for f in files[:BODY_FILE_LIST_CAP]]
138
+ if len(files) > BODY_FILE_LIST_CAP:
139
+ listed.append(f"- … +{len(files) - BODY_FILE_LIST_CAP} more")
140
+ return (
141
+ f"Artifacts produced by sbxloop run `{run_id}`.\n\n"
142
+ f"**Outcome:** {outcome}\n\n"
143
+ f"**Files ({len(files)}):**\n" + "\n".join(listed) + "\n"
144
+ )
@@ -20,6 +20,7 @@ import time
20
20
  from collections.abc import Callable, Sequence
21
21
 
22
22
  from sbxloop.config import Config, load_config, load_dotenv_file
23
+ from sbxloop.deliver import deliver_workspace
23
24
  from sbxloop.engine.model import (
24
25
  RESUMABLE_RUN_STATES,
25
26
  RunResult,
@@ -29,7 +30,14 @@ from sbxloop.engine.model import (
29
30
  )
30
31
  from sbxloop.engine.phases import PhaseRunner, clip
31
32
  from sbxloop.engine.store import StateStore
32
- from sbxloop.errors import BudgetExceededError, SbxError, SdxloopError, StateError
33
+ from sbxloop.errors import (
34
+ BudgetExceededError,
35
+ DeliveryError,
36
+ GithubOpsError,
37
+ SbxError,
38
+ SdxloopError,
39
+ StateError,
40
+ )
33
41
  from sbxloop.events import EventBus, Hook, HostEventTypes
34
42
  from sbxloop.gh.ops import GithubOps
35
43
  from sbxloop.gh.reporter import GithubReporterHook
@@ -115,15 +123,20 @@ class LoopEngine:
115
123
  transport=self.config.worker_transport,
116
124
  python=self.worker_python,
117
125
  )
118
- github = WorkerClient(
119
- pair.github,
120
- self.bus,
121
- transport=self.config.worker_transport,
122
- python=self.worker_python,
126
+ github = (
127
+ WorkerClient(
128
+ pair.github,
129
+ self.bus,
130
+ transport=self.config.worker_transport,
131
+ python=self.worker_python,
132
+ )
133
+ if pair.github is not None
134
+ else None
123
135
  )
124
136
  if self.install_workers:
125
137
  agent.install(extras="copilot")
126
- github.install(extras="")
138
+ if github is not None:
139
+ github.install(extras="")
127
140
  detach = self._attach_reporter(github, run_id)
128
141
  try:
129
142
  phases = PhaseRunner(
@@ -136,6 +149,8 @@ class LoopEngine:
136
149
  # alive here, and partial artifacts beat none.
137
150
  self._harvest(run_id, pair)
138
151
  self._report_artifacts(run_id, pair)
152
+ if state == "completed":
153
+ self._deliver(run_id, outcome, pair, github)
139
154
  except SdxloopError:
140
155
  # State is already persisted; the exception is the kill signal.
141
156
  raise
@@ -150,11 +165,12 @@ class LoopEngine:
150
165
  mounted=pair.mounted,
151
166
  )
152
167
 
153
- def _attach_reporter(self, github: WorkerClient, run_id: str) -> Callable[[], None]:
154
- repo = self.config.github.report_repo
155
- if not repo:
168
+ def _attach_reporter(self, github: WorkerClient | None, run_id: str) -> Callable[[], None]:
169
+ gh = self.config.github
170
+ if not gh.report or github is None:
156
171
  return lambda: None
157
- hook = GithubReporterHook(GithubOps(github, run_id), repo)
172
+ assert gh.repo is not None # report=True without a repo cannot provision a github worker
173
+ hook = GithubReporterHook(GithubOps(github, run_id), gh.repo)
158
174
  return self.bus.attach_hook(hook)
159
175
 
160
176
  def _harvest(self, run_id: str, pair: SandboxPair) -> None:
@@ -190,6 +206,44 @@ class LoopEngine:
190
206
  mounted=pair.mounted,
191
207
  )
192
208
 
209
+ def _deliver(
210
+ self, run_id: str, outcome: str, pair: SandboxPair, github: WorkerClient | None
211
+ ) -> None:
212
+ """Publish the completed run's artifacts as a PR to the configured
213
+ [github].repo when delivery is enabled. The run has already
214
+ succeeded — delivery failure is loud (run.deliver event with the
215
+ error) but never changes the run state.
216
+ """
217
+ gh = self.config.github
218
+ repo = gh.repo
219
+ if not gh.deliver or not repo or github is None:
220
+ return
221
+ source = (
222
+ pair.workspace
223
+ if pair.mounted
224
+ else self.config.state_dir / "runs" / run_id / "artifacts"
225
+ )
226
+ if source is None or not source.is_dir():
227
+ self.bus.emit(
228
+ HostEventTypes.RUN_DELIVER, run_id, repo=repo, error="no artifacts directory"
229
+ )
230
+ return
231
+ try:
232
+ pr = deliver_workspace(
233
+ GithubOps(github, run_id),
234
+ repo,
235
+ run_id=run_id,
236
+ outcome=outcome,
237
+ source_dir=source,
238
+ base=gh.deliver_base,
239
+ draft=gh.deliver_draft,
240
+ )
241
+ except (DeliveryError, GithubOpsError) as exc:
242
+ logger.warning("delivery to %s failed for run %s", repo, run_id, exc_info=True)
243
+ self.bus.emit(HostEventTypes.RUN_DELIVER, run_id, repo=repo, error=str(exc))
244
+ return
245
+ self.bus.emit(HostEventTypes.RUN_DELIVER, run_id, repo=repo, pr=pr.number, url=pr.url)
246
+
193
247
  def _run_phases(
194
248
  self, run_id: str, phases: PhaseRunner, deadline: float, pair: SandboxPair
195
249
  ) -> RunState:
@@ -158,6 +158,16 @@ class RunResult(_Model):
158
158
  return self.state == "completed"
159
159
 
160
160
 
161
+ def artifact_files(root: Path) -> list[Path]:
162
+ """Regular files under root, hidden files/dirs excluded (an agent's .git
163
+ would otherwise swamp listings and deliveries), sorted for stable output."""
164
+ return sorted(
165
+ p
166
+ for p in root.rglob("*")
167
+ if p.is_file() and not any(part.startswith(".") for part in p.relative_to(root).parts)
168
+ )
169
+
170
+
161
171
  def artifacts_dir(run: RunRecord | RunResult, state_dir: Path) -> Path | None:
162
172
  """The single host directory holding a run's artifacts.
163
173
 
@@ -65,6 +65,10 @@ class GithubOpsError(SdxloopError):
65
65
  """A GitHub operation failed in the github-ops sandbox."""
66
66
 
67
67
 
68
+ class DeliveryError(SdxloopError):
69
+ """Delivering a run's workspace as a GitHub PR failed."""
70
+
71
+
68
72
  class BudgetExceededError(SdxloopError):
69
73
  """A run or task exhausted one of its budgets."""
70
74
 
@@ -74,6 +74,7 @@ class HostEventTypes:
74
74
  RUN_START = "run.start"
75
75
  RUN_STATE = "run.state"
76
76
  RUN_ARTIFACTS = "run.artifacts"
77
+ RUN_DELIVER = "run.deliver"
77
78
  RUN_END = "run.end"
78
79
  TASK_START = "task.start"
79
80
  TASK_STATE = "task.state"
@@ -1,7 +1,8 @@
1
1
  """GithubReporterHook: mirrors run progress to a GitHub tracking issue.
2
2
 
3
- The default consumer of the github-ops sandbox. When ``github.report_repo``
4
- is configured, the hook opens a tracking issue at run start, comments as
3
+ The default consumer of the github-ops sandbox. When the GitHub integration
4
+ is configured (``[github].repo``) and reporting is enabled (``report = true``
5
+ or ``--report``), the hook opens a tracking issue at run start, comments as
5
6
  tasks finish, and posts a final summary at run end. It never raises — a
6
7
  reporting failure must not fail a run — and the EventBus isolates it anyway.
7
8
  """
@@ -1,7 +1,10 @@
1
1
  """SandboxPair — the core sbxloop primitive — and its cleanup guarantees.
2
2
 
3
- A run's pair consists of the agent sandbox (COPILOT_GITHUB_TOKEN only) and
4
- the github-ops sandbox (GH_TOKEN only). The pair is a context manager whose
3
+ A run's pair consists of the agent sandbox (COPILOT_GITHUB_TOKEN only) and,
4
+ when the GitHub integration is configured (``[github].repo``), the
5
+ github-ops sandbox (GH_TOKEN only) — otherwise ``pair.github`` is None and
6
+ no GitHub capability exists anywhere in the run. The pair is a context
7
+ manager whose
5
8
  exit stops and removes both sandboxes unless ``keep`` is set; a process-wide
6
9
  registry additionally cleans up on interpreter exit and on SIGINT/SIGTERM,
7
10
  so aborted runs do not leak microVMs.
@@ -28,7 +31,7 @@ class SandboxPair:
28
31
  self,
29
32
  run_id: str,
30
33
  agent: Sandbox,
31
- github: Sandbox,
34
+ github: Sandbox | None = None,
32
35
  *,
33
36
  keep: bool = False,
34
37
  workspace: Path | None = None,
@@ -64,12 +67,14 @@ class SandboxPair:
64
67
  self.cleanup()
65
68
 
66
69
  def cleanup(self) -> None:
67
- """Stop and remove both sandboxes. Idempotent, best-effort per sandbox."""
70
+ """Stop and remove the pair's sandboxes. Idempotent, best-effort each."""
68
71
  if self._cleaned:
69
72
  return
70
73
  self._cleaned = True
71
74
  cleanup_registry.unregister(self)
72
75
  for sandbox in (self.agent, self.github):
76
+ if sandbox is None:
77
+ continue
73
78
  try:
74
79
  sandbox.stop()
75
80
  except Exception:
@@ -7,7 +7,9 @@ The credential split is enforced here:
7
7
  token-exchange host — the value never enters the VM under the default
8
8
  ``proxy`` strategy.
9
9
  - **github sandbox** gets only ``GH_TOKEN`` via sbx's built-in ``github``
10
- secret service (scoped to github.com hosts).
10
+ secret service (scoped to github.com hosts). It is provisioned only when
11
+ the GitHub integration is configured (``[github].repo``); otherwise runs
12
+ have no GitHub capability and GH_TOKEN is not required.
11
13
 
12
14
  The ``plain-env`` fallback strategy writes tokens to ``~/.sbxloop/env.sh``
13
15
  inside the sandbox (weaker: the value is visible in the VM) for environments
@@ -145,17 +147,22 @@ class Provisioner:
145
147
  workspace = workspace.resolve()
146
148
  workspace.mkdir(parents=True, exist_ok=True)
147
149
 
150
+ # The github sandbox (and its token requirement) exists only when the
151
+ # GitHub integration is configured; without [github].repo a run has
152
+ # no GitHub capability at all — and one less microVM to boot.
153
+ github_enabled = self.config.github.enabled
154
+
148
155
  # Fail fast on missing tokens before creating any microVM.
149
- tokens: dict[SandboxRole, str] = {
150
- "agent": self.copilot_token(),
151
- "github": self.gh_token(),
152
- }
156
+ tokens: dict[SandboxRole, str] = {"agent": self.copilot_token()}
157
+ if github_enabled:
158
+ tokens["github"] = self.gh_token()
153
159
 
154
160
  agent_spec, github_spec = self.build_specs(run_id, workspace)
161
+ specs = (agent_spec, github_spec) if github_enabled else (agent_spec,)
155
162
  created: list[Sandbox] = []
156
163
  try:
157
164
  sandboxes: dict[SandboxRole, Sandbox] = {}
158
- for spec in (agent_spec, github_spec):
165
+ for spec in specs:
159
166
  self.bus.emit("sandbox.provision_start", run_id, name=spec.name, role=spec.role)
160
167
  self.cli.create(spec)
161
168
  sandbox = Sandbox(self.cli, spec.name)
@@ -176,7 +183,7 @@ class Provisioner:
176
183
  return SandboxPair(
177
184
  run_id,
178
185
  agent=sandboxes["agent"],
179
- github=sandboxes["github"],
186
+ github=sandboxes.get("github"),
180
187
  keep=self.config.keep_sandboxes,
181
188
  workspace=workspace,
182
189
  agent_workdir=agent_workdir,
File without changes
File without changes
File without changes
File without changes
File without changes