sbxloop 0.6.0__tar.gz → 0.6.2__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 (60) hide show
  1. {sbxloop-0.6.0 → sbxloop-0.6.2}/PKG-INFO +3 -1
  2. {sbxloop-0.6.0 → sbxloop-0.6.2}/pyproject.toml +3 -0
  3. sbxloop-0.6.0/src/sbxloop/_vendor/sbxloop_worker-0.6.0-py3-none-any.whl → sbxloop-0.6.2/src/sbxloop/_vendor/sbxloop_worker-0.6.2-py3-none-any.whl +0 -0
  4. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/_version.py +2 -2
  5. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/app.py +190 -1
  6. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/doctor.py +19 -0
  7. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/config.py +95 -0
  8. sbxloop-0.6.2/src/sbxloop/daemon/__init__.py +10 -0
  9. sbxloop-0.6.2/src/sbxloop/daemon/backlog.py +95 -0
  10. sbxloop-0.6.2/src/sbxloop/daemon/discord.py +703 -0
  11. sbxloop-0.6.2/src/sbxloop/daemon/github.py +128 -0
  12. sbxloop-0.6.2/src/sbxloop/daemon/loop.py +569 -0
  13. sbxloop-0.6.2/src/sbxloop/daemon/model.py +76 -0
  14. sbxloop-0.6.2/src/sbxloop/daemon/sources.py +363 -0
  15. sbxloop-0.6.2/src/sbxloop/daemon/store.py +287 -0
  16. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/plan.md +20 -2
  17. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/errors.py +5 -0
  18. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/hostgit.py +10 -3
  19. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/provision.py +72 -1
  20. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/verifylint.py +94 -2
  21. {sbxloop-0.6.0 → sbxloop-0.6.2}/.gitignore +0 -0
  22. {sbxloop-0.6.0 → sbxloop-0.6.2}/README.md +0 -0
  23. {sbxloop-0.6.0 → sbxloop-0.6.2}/hatch_build.py +0 -0
  24. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/__init__.py +0 -0
  25. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/__init__.py +0 -0
  26. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/models.py +0 -0
  27. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/cli/tui.py +0 -0
  28. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/deliver.py +0 -0
  29. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/__init__.py +0 -0
  30. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/engine.py +0 -0
  31. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/model.py +0 -0
  32. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/phases.py +0 -0
  33. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/__init__.py +0 -0
  34. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/decompose.md +0 -0
  35. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/execute.md +0 -0
  36. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  37. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/steer.md +0 -0
  38. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/prompts/validate.md +0 -0
  39. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/engine/store.py +0 -0
  40. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/events.py +0 -0
  41. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/gh/__init__.py +0 -0
  42. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/gh/ops.py +0 -0
  43. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/gh/reporter.py +0 -0
  44. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/ids.py +0 -0
  45. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/policy.py +0 -0
  46. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/py.typed +0 -0
  47. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/__init__.py +0 -0
  48. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/bake.py +0 -0
  49. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/cli.py +0 -0
  50. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/conformance.py +0 -0
  51. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/models.py +0 -0
  52. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/pair.py +0 -0
  53. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/parse.py +0 -0
  54. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/prune.py +0 -0
  55. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/sandbox.py +0 -0
  56. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/sbx/secretstate.py +0 -0
  57. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/toolchains.py +0 -0
  58. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/worker/__init__.py +0 -0
  59. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/worker/client.py +0 -0
  60. {sbxloop-0.6.0 → sbxloop-0.6.2}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sbxloop
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
@@ -23,6 +23,8 @@ Requires-Dist: sbxloop-worker
23
23
  Requires-Dist: typer>=0.12
24
24
  Provides-Extra: copilot
25
25
  Requires-Dist: github-copilot-sdk>=1.0.8; extra == 'copilot'
26
+ Provides-Extra: discord
27
+ Requires-Dist: discord-py>=2.3; extra == 'discord'
26
28
  Description-Content-Type: text/markdown
27
29
 
28
30
  # sbxloop
@@ -31,6 +31,9 @@ dependencies = [
31
31
  # Host-side SDK access for `sbxloop list-models` (agent sessions get the SDK
32
32
  # inside the sandbox via sbxloop-worker[copilot]; runs never need this extra).
33
33
  copilot = ["github-copilot-sdk>=1.0.8"]
34
+ # `sbxloop daemon`'s Discord bridge (chronology + steering); the daemon runs
35
+ # without it when [discord] is unset.
36
+ discord = ["discord.py>=2.3"]
34
37
 
35
38
  [project.scripts]
36
39
  sbxloop = "sbxloop.cli.app:main"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.6.0'
22
- __version_tuple__ = version_tuple = (0, 6, 0)
21
+ __version__ = version = '0.6.2'
22
+ __version_tuple__ = version_tuple = (0, 6, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -25,6 +25,8 @@ from sbxloop.cli.doctor import run_doctor
25
25
  from sbxloop.cli.tui import ChatInput, Dashboard, format_event, plain_printer, render_event
26
26
  from sbxloop.config import (
27
27
  Config,
28
+ DaemonConfig,
29
+ DiscordConfig,
28
30
  GithubConfig,
29
31
  load_config,
30
32
  load_config_with_sources,
@@ -34,7 +36,7 @@ from sbxloop.engine.engine import LoopEngine
34
36
  from sbxloop.engine.model import TERMINAL_RUN_STATES, RunResult, artifacts_dir, scan_artifacts
35
37
  from sbxloop.engine.store import StateStore
36
38
  from sbxloop.errors import SbxloopError
37
- from sbxloop.events import Event, HostEventTypes
39
+ from sbxloop.events import Event, EventBus, HostEventTypes
38
40
  from sbxloop.sbx.bake import DEFAULT_TEMPLATE_REF, bake_template
39
41
  from sbxloop.sbx.cli import SbxCLI
40
42
  from sbxloop.sbx.models import SandboxRole
@@ -1204,6 +1206,155 @@ def init(
1204
1206
  console.print(f"wrote {path}")
1205
1207
 
1206
1208
 
1209
+ @app.command()
1210
+ def daemon(
1211
+ repo: Annotated[
1212
+ str | None,
1213
+ typer.Option("--repo", help='GitHub repository ("owner/name") to poll for labeled issues.'),
1214
+ ] = None,
1215
+ inbox: Annotated[
1216
+ str | None,
1217
+ typer.Option("--inbox", help='Inbox directory of .md work files ("" disables).'),
1218
+ ] = None,
1219
+ backlog: Annotated[
1220
+ str | None,
1221
+ typer.Option("--backlog", help="Where agent-filed follow-up work goes: github|inbox|off."),
1222
+ ] = None,
1223
+ max_runs_per_day: Annotated[
1224
+ int | None, typer.Option("--max-runs-per-day", help="Rolling 24h run cap.")
1225
+ ] = None,
1226
+ poll_interval: Annotated[
1227
+ float | None, typer.Option("--poll-interval", help="Seconds between polls.")
1228
+ ] = None,
1229
+ discord_channel: Annotated[
1230
+ int | None,
1231
+ typer.Option("--discord-channel", help="Discord control channel id (enables the bridge)."),
1232
+ ] = None,
1233
+ once: Annotated[
1234
+ bool, typer.Option("--once", help="Recover, run one tick, exit (cron / smoke tests).")
1235
+ ] = False,
1236
+ dry_run: Annotated[
1237
+ bool, typer.Option("--dry-run", help="Poll and print candidates; claim and run nothing.")
1238
+ ] = False,
1239
+ ) -> None:
1240
+ """Run the always-on outer loop: discover work (labeled GitHub issues,
1241
+ inbox files), run each item through the inner loop, report back, and
1242
+ mirror the chronology to Discord."""
1243
+ import logging
1244
+
1245
+ from sbxloop.daemon.discord import DiscordBridge
1246
+ from sbxloop.daemon.github import DaemonGithub
1247
+ from sbxloop.daemon.loop import DaemonLoop
1248
+ from sbxloop.daemon.sources import GitHubIssueSource, GitHubLabels, InboxSource, WorkSource
1249
+ from sbxloop.daemon.store import DaemonStore
1250
+
1251
+ logging.basicConfig(
1252
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
1253
+ )
1254
+ config = load_config()
1255
+ daemon_overrides = {
1256
+ k: v
1257
+ for k, v in (
1258
+ ("inbox_dir", inbox),
1259
+ ("backlog", backlog),
1260
+ ("max_runs_per_day", max_runs_per_day),
1261
+ ("poll_interval_s", poll_interval),
1262
+ )
1263
+ if v is not None
1264
+ }
1265
+ try:
1266
+ if daemon_overrides:
1267
+ daemon_cfg = DaemonConfig.model_validate(
1268
+ {**config.daemon.model_dump(), **daemon_overrides}
1269
+ )
1270
+ config = config.model_copy(update={"daemon": daemon_cfg})
1271
+ if repo is not None:
1272
+ github_cfg = GithubConfig.model_validate({**config.github.model_dump(), "repo": repo})
1273
+ config = config.model_copy(update={"github": github_cfg})
1274
+ if discord_channel is not None:
1275
+ discord_cfg = DiscordConfig.model_validate(
1276
+ {**config.discord.model_dump(), "channel_id": discord_channel}
1277
+ )
1278
+ config = config.model_copy(update={"discord": discord_cfg})
1279
+ except ValidationError as exc:
1280
+ console.print(f"[bold red]invalid daemon option:[/] {exc.errors()[0]['msg']}")
1281
+ raise typer.Exit(2) from exc
1282
+
1283
+ github_active = config.github.enabled
1284
+ inbox_active = bool(config.daemon.inbox_dir)
1285
+ if not github_active and not inbox_active:
1286
+ console.print(
1287
+ "[bold red]no work sources:[/] set [cyan]--repo owner/name[/] (or "
1288
+ "[cyan]\\[github] repo[/]) to poll issues, and/or an inbox directory "
1289
+ "([cyan]--inbox DIR[/] / [cyan]\\[daemon] inbox_dir[/])."
1290
+ )
1291
+ raise typer.Exit(2)
1292
+ if config.daemon.backlog == "github" and not github_active:
1293
+ console.print("[bold red]--backlog github needs a GitHub repository[/] (--repo).")
1294
+ raise typer.Exit(2)
1295
+
1296
+ store = _store(config)
1297
+ dstore = DaemonStore(config.state_dir / "state.db")
1298
+ sbx = SbxCLI(app_name=config.app_name or None)
1299
+ bus = EventBus()
1300
+ bus.subscribe(plain_printer(console))
1301
+ sources: list[WorkSource] = []
1302
+ github: DaemonGithub | None = None
1303
+ if inbox_active:
1304
+ sources.append(InboxSource(Path(config.daemon.inbox_dir)))
1305
+ if github_active:
1306
+ assert config.github.repo is not None
1307
+ github = DaemonGithub(config, sbx, bus, worker_python=config.worker_python)
1308
+ github.remove_stale()
1309
+ labels = GitHubLabels(
1310
+ config.daemon.trigger_label,
1311
+ config.daemon.in_progress_label,
1312
+ config.daemon.failed_label,
1313
+ config.daemon.backlog_label,
1314
+ )
1315
+ sources.append(GitHubIssueSource(github.ops, config.github.repo, labels))
1316
+
1317
+ if dry_run:
1318
+ for source in sources:
1319
+ for item in source.poll():
1320
+ console.print(
1321
+ f"[cyan]{item.item_id}[/] {item.title}" + (f" {item.url}" if item.url else "")
1322
+ )
1323
+ if github is not None:
1324
+ github.close()
1325
+ raise typer.Exit(0)
1326
+
1327
+ loop = DaemonLoop(config, store=store, dstore=dstore, sources=sources, sbx=sbx)
1328
+ bridge: DiscordBridge | None = None
1329
+ if config.discord.enabled:
1330
+ try:
1331
+ bridge = DiscordBridge(config, dstore, loop_ref=loop)
1332
+ bridge.start()
1333
+ except SbxloopError as exc:
1334
+ console.print(f"[bold red]discord bridge:[/] {exc}")
1335
+ raise typer.Exit(2) from exc
1336
+ loop.frontend = bridge
1337
+
1338
+ cleanup_registry.install_handlers()
1339
+ cleanup_registry.set_quiesce(loop.quiesce)
1340
+ try:
1341
+ loop.recover()
1342
+ if once:
1343
+ result = loop.tick()
1344
+ console.print(f"tick: {result}")
1345
+ else:
1346
+ loop.run_forever()
1347
+ except KeyboardInterrupt:
1348
+ console.print("\n[bold yellow]daemon interrupted[/]")
1349
+ finally:
1350
+ cleanup_registry.set_quiesce(None)
1351
+ if bridge is not None:
1352
+ bridge.close()
1353
+ if github is not None:
1354
+ github.close()
1355
+ dstore.close()
1356
+
1357
+
1207
1358
  @app.command()
1208
1359
  def bake(
1209
1360
  ref: Annotated[
@@ -1448,6 +1599,44 @@ per_job_timeout_s = 1800.0
1448
1599
  disk_warn = 85.0
1449
1600
  disk_abort = 95.0
1450
1601
  mem_warn = 90.0
1602
+
1603
+ [daemon]
1604
+ # `sbxloop daemon` — the always-on outer loop. Discovers work and runs each
1605
+ # item as a full inner-loop run, fully autonomously: a labeled issue in the
1606
+ # [github] repo or a .md file in the inbox starts a run on its own, so the
1607
+ # guardrails below are what stand between a mislabeled issue and your budget.
1608
+ # inbox_dir = ".sbxloop/inbox" # "" disables the inbox source
1609
+ # poll_interval_s = 60.0
1610
+ # trigger_label = "sbxloop:run" # issue label that queues work
1611
+ # in_progress_label = "sbxloop:in-progress"
1612
+ # failed_label = "sbxloop:failed"
1613
+ # backlog_label = "sbxloop:backlog"
1614
+ # max_runs_per_day = 12 # rolling 24h window, persisted across restarts
1615
+ # max_attempts_per_item = 2
1616
+ # retry_backoff_s = 900.0 # times the attempt number
1617
+ # max_consecutive_failures = 3 # circuit breaker ...
1618
+ # breaker_cooldown_s = 3600.0 # ... and how long it stays open
1619
+ # shutdown_grace_s = 60.0 # keep below systemd TimeoutStopSec
1620
+ # Agent-filed follow-up work (.sbxloop/backlog/*.md in the run workspace):
1621
+ # "github" files issues with backlog_label, "inbox" writes to inbox/triage/,
1622
+ # "off" ignores it. Filed items wait for a human unless auto-trigger is on.
1623
+ # backlog = "off"
1624
+ # backlog_max_per_run = 5
1625
+ # backlog_auto_trigger = false
1626
+ # deliver_draft = true # autonomous PRs arrive as drafts
1627
+
1628
+ [discord]
1629
+ # The daemon's human channel: a gateway bot posts each run's chronology
1630
+ # (agent messages, tool lines, issue/PR links) into a thread under a control
1631
+ # channel and relays replies typed in the thread to the running agent as
1632
+ # steering. Needs `pip install 'sbxloop[discord]'` and DISCORD_BOT_TOKEN in
1633
+ # the environment / .env (never here). Anyone who can post in the channel can
1634
+ # steer — restrict the channel. Unset channel_id = Discord off.
1635
+ # channel_id = 123456789012345678
1636
+ # command_prefix = "!sbx" # !sbx status|pause|resume|cancel|queue
1637
+ # thread_per_run = true
1638
+ # chronology_level = "normal" # quiet | normal | verbose
1639
+ # max_message_chars = 1900
1451
1640
  """
1452
1641
 
1453
1642
 
@@ -295,6 +295,25 @@ def collect_checks(
295
295
  )
296
296
  )
297
297
 
298
+ # daemon's Discord bridge (only when configured)
299
+ if config.discord.enabled:
300
+ problems: list[str] = []
301
+ try:
302
+ import discord as _discordpy # noqa: F401
303
+ except ImportError:
304
+ problems.append("discord.py missing (pip install 'sbxloop[discord]')")
305
+ if not env.get("DISCORD_BOT_TOKEN"):
306
+ problems.append("DISCORD_BOT_TOKEN not set")
307
+ checks.append(
308
+ Check(
309
+ "discord bridge",
310
+ not problems,
311
+ f"channel {config.discord.channel_id}: "
312
+ + ("; ".join(problems) if problems else "extra installed, token present"),
313
+ hard=False,
314
+ )
315
+ )
316
+
298
317
  # worker wheel
299
318
  wheel = resolve_worker_wheel()
300
319
  checks.append(
@@ -278,6 +278,99 @@ class Budgets(_ConfigModel):
278
278
  per_job_timeout_s: float = 1800.0
279
279
 
280
280
 
281
+ BacklogMode = Literal["off", "github", "inbox"]
282
+
283
+
284
+ class DaemonConfig(_ConfigModel):
285
+ """``sbxloop daemon`` — the always-on outer loop.
286
+
287
+ The daemon discovers work (GitHub issues carrying ``trigger_label`` in
288
+ the configured repo, and ``.md`` files in ``inbox_dir``), runs each item
289
+ as a full inner-loop run, and reports back to the source. It is fully
290
+ autonomous — a label or a file alone starts a run — so the spend
291
+ guardrails here are the only thing standing between a mislabeled issue
292
+ and an empty Copilot budget: a rolling daily run cap, a per-item retry
293
+ cap, and a consecutive-failure circuit breaker.
294
+
295
+ ``backlog`` lets the inner agent file follow-up work it discovers
296
+ (written to ``.sbxloop/backlog/*.md`` in the run workspace) into either
297
+ source. Agent-filed items land in triage (the ``backlog_label`` / the
298
+ inbox ``triage/`` dir) and never run until a human promotes them, unless
299
+ ``backlog_auto_trigger`` is set — a self-feeding queue is the failure
300
+ mode that flag guards.
301
+ """
302
+
303
+ inbox_dir: str = ".sbxloop/inbox" # "" disables the inbox source
304
+ # Must be positive: Event.wait(<= 0) returns immediately and the loop spins.
305
+ poll_interval_s: float = Field(default=60.0, gt=0)
306
+ trigger_label: str = "sbxloop:run"
307
+ in_progress_label: str = "sbxloop:in-progress"
308
+ failed_label: str = "sbxloop:failed"
309
+ backlog_label: str = "sbxloop:backlog"
310
+ max_runs_per_day: int = 12
311
+ max_attempts_per_item: int = 2
312
+ retry_backoff_s: float = 900.0
313
+ max_consecutive_failures: int = 3
314
+ breaker_cooldown_s: float = 3600.0
315
+ # Keep below the service manager's stop timeout. Cancellation is honored
316
+ # only at task-phase boundaries and interrupted runs are resumable, so
317
+ # this is a courtesy wait, not a correctness requirement.
318
+ shutdown_grace_s: float = 60.0
319
+ backlog: BacklogMode = "off"
320
+ backlog_max_per_run: int = 5
321
+ backlog_auto_trigger: bool = False
322
+ # Autonomous PRs arrive as drafts unless the operator says otherwise.
323
+ deliver_draft: bool = True
324
+
325
+ @model_validator(mode="after")
326
+ def _check(self) -> DaemonConfig:
327
+ labels = [
328
+ self.trigger_label,
329
+ self.in_progress_label,
330
+ self.failed_label,
331
+ self.backlog_label,
332
+ ]
333
+ if any(not label.strip() for label in labels):
334
+ raise ValueError("daemon labels must be non-empty")
335
+ # GitHub label names are case-insensitive: "sbxloop:run" and
336
+ # "SBXLOOP:RUN" are the same label, so they cannot mark two states.
337
+ if len({label.strip().casefold() for label in labels}) != len(labels):
338
+ raise ValueError("daemon labels must be distinct (case-insensitively)")
339
+ for name in (
340
+ "max_runs_per_day",
341
+ "max_attempts_per_item",
342
+ "max_consecutive_failures",
343
+ "backlog_max_per_run",
344
+ ):
345
+ if getattr(self, name) < 1:
346
+ raise ValueError(f"daemon.{name} must be >= 1")
347
+ return self
348
+
349
+
350
+ ChronologyLevel = Literal["quiet", "normal", "verbose"]
351
+
352
+
353
+ class DiscordConfig(_ConfigModel):
354
+ """The daemon's human channel: a gateway bot posting each run's
355
+ chronology (agent messages, tool lines, issue/PR links) into a thread
356
+ under a control channel, and relaying replies typed in that thread to
357
+ the running agent as steering. Unset ``channel_id`` disables it. The bot
358
+ token comes from ``DISCORD_BOT_TOKEN`` in the environment / .env, never
359
+ from this file. Anyone who can post in the channel can steer — restrict
360
+ the channel accordingly."""
361
+
362
+ channel_id: int | None = None
363
+ command_prefix: str = "!sbx"
364
+ thread_per_run: bool = True
365
+ chronology_level: ChronologyLevel = "normal"
366
+ # Discord's hard cap is 2000; leave headroom for wrappers.
367
+ max_message_chars: int = Field(default=1900, ge=200, le=2000)
368
+
369
+ @property
370
+ def enabled(self) -> bool:
371
+ return self.channel_id is not None
372
+
373
+
281
374
  class Config(_ConfigModel):
282
375
  model: str = "auto"
283
376
  # sbx --app-name. Empty (the default) shares the user's normal sbx
@@ -305,6 +398,8 @@ class Config(_ConfigModel):
305
398
  artifacts: ArtifactsConfig = Field(default_factory=ArtifactsConfig)
306
399
  budgets: Budgets = Field(default_factory=Budgets)
307
400
  limits: Limits = Field(default_factory=Limits)
401
+ daemon: DaemonConfig = Field(default_factory=DaemonConfig)
402
+ discord: DiscordConfig = Field(default_factory=DiscordConfig)
308
403
 
309
404
 
310
405
  def _read_toml(path: Path) -> dict[str, Any]:
@@ -0,0 +1,10 @@
1
+ """``sbxloop daemon`` — the always-on outer loop around the run engine.
2
+
3
+ Discovers work (GitHub issues by label, inbox ``.md`` files), runs each item
4
+ through a fresh :class:`~sbxloop.engine.engine.LoopEngine`, reports back to
5
+ the source, mirrors the chronology to Discord, and keeps going.
6
+ """
7
+
8
+ from sbxloop.daemon.model import RunReport, TickResult, WorkItem
9
+
10
+ __all__ = ["RunReport", "TickResult", "WorkItem"]
@@ -0,0 +1,95 @@
1
+ """Agent-filed backlog: follow-up work the inner agent discovered.
2
+
3
+ The agent sandbox holds no GH_TOKEN, so an agent cannot file issues; it
4
+ writes ``.sbxloop/backlog/*.md`` files in the run workspace instead, and
5
+ the daemon files them after the run through whichever source ``--backlog``
6
+ names. ``.sbxloop`` sits in the default artifact excludes, which is exactly
7
+ what keeps these files out of harvest and out of the delivery PR — and also
8
+ why collection reads the mounted workspace directly (unmounted runs are
9
+ skipped with a loud warning).
10
+
11
+ Filed items land in triage by default (backlog label / inbox ``triage/``);
12
+ ``backlog_auto_trigger`` puts them straight into the queue. Fingerprints
13
+ dedup re-collection on resume, and ``max_items`` caps how much queue one
14
+ run may generate — an agent enumerating infinite future work must not
15
+ flood the daemon.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import logging
22
+ from pathlib import Path
23
+
24
+ from sbxloop.daemon.sources import WorkSource, parse_markdown_item
25
+ from sbxloop.daemon.store import DaemonStore
26
+ from sbxloop.engine.model import RunRecord
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ BACKLOG_SUBDIR = Path(".sbxloop") / "backlog"
31
+
32
+ BACKLOG_INSTRUCTIONS = (
33
+ "If you identify follow-up work that is OUT OF SCOPE for this outcome, do "
34
+ "not expand scope. Write each such item as its own markdown file under "
35
+ "`.sbxloop/backlog/` in the workspace — first line `# <short title>`, then a "
36
+ "paragraph describing the work and why it matters. These are triaged and "
37
+ "filed automatically after the run."
38
+ )
39
+
40
+
41
+ def fingerprint(title: str, body: str) -> str:
42
+ return hashlib.sha256(f"{title}\n{body}".encode()).hexdigest()
43
+
44
+
45
+ def collect_backlog(
46
+ run: RunRecord,
47
+ *,
48
+ dstore: DaemonStore,
49
+ source: WorkSource,
50
+ max_items: int,
51
+ trigger: bool,
52
+ now: float,
53
+ ) -> list[str]:
54
+ """File the run's backlog items via ``source``; returns the refs filed."""
55
+ if not run.mounted or run.workspace is None:
56
+ logger.warning(
57
+ "backlog skipped for run %s: workspace not mounted (agent-filed backlog "
58
+ "requires the mounted-workspace mode)",
59
+ run.run_id,
60
+ )
61
+ return []
62
+ folder = run.workspace / BACKLOG_SUBDIR
63
+ if not folder.is_dir():
64
+ return []
65
+ filed: list[str] = []
66
+ skipped = 0
67
+ for path in sorted(folder.glob("*.md")):
68
+ try:
69
+ title, body = parse_markdown_item(path.read_text(), path.stem)
70
+ except OSError:
71
+ continue
72
+ fp = fingerprint(title, body)
73
+ if dstore.backlog_seen(fp):
74
+ continue
75
+ if len(filed) >= max_items:
76
+ skipped += 1
77
+ continue
78
+ try:
79
+ ref = source.file_backlog(title, body, run.run_id, trigger=trigger)
80
+ except Exception:
81
+ logger.warning(
82
+ "backlog: filing %r from run %s failed", title, run.run_id, exc_info=True
83
+ )
84
+ continue
85
+ dstore.backlog_record(fp, run.run_id, ref, now)
86
+ filed.append(ref)
87
+ if skipped:
88
+ logger.warning(
89
+ "backlog: run %s produced %d item(s) beyond the per-run cap of %d; deferred "
90
+ "(not fingerprint-recorded, so the next collection pass files them)",
91
+ run.run_id,
92
+ skipped,
93
+ max_items,
94
+ )
95
+ return filed