sdxloop 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.
sdxloop/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """sdxloop — agentic loop orchestration on Docker Sandboxes (sbx).
2
+
3
+ Every sdxloop operation runs as a pair of isolated microVM sandboxes: an agent
4
+ sandbox holding only ``COPILOT_GITHUB_TOKEN`` for the GitHub Copilot SDK
5
+ agentic layer, and a GitHub-ops sandbox holding only ``GH_TOKEN`` for
6
+ user-facing GitHub interactions. The balanced network policy is the default.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from sdxloop.config import Budgets, Config, load_config
12
+ from sdxloop.engine import LoopEngine, RunResult, run_outcome
13
+ from sdxloop.events import Event, EventBus, Hook
14
+
15
+ __all__ = [
16
+ "Budgets",
17
+ "Config",
18
+ "Event",
19
+ "EventBus",
20
+ "Hook",
21
+ "LoopEngine",
22
+ "RunResult",
23
+ "__version__",
24
+ "load_config",
25
+ "run_outcome",
26
+ ]
@@ -0,0 +1 @@
1
+ """The sdxloop command-line interface."""
sdxloop/cli/app.py ADDED
@@ -0,0 +1,359 @@
1
+ """The sdxloop CLI: run agentic loops on Docker Sandboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.live import Live
13
+ from rich.table import Table
14
+
15
+ import sdxloop
16
+ from sdxloop.cli.doctor import run_doctor
17
+ from sdxloop.cli.tui import Dashboard, format_event, plain_printer
18
+ from sdxloop.config import Config, load_config, load_config_with_sources
19
+ from sdxloop.engine.engine import LoopEngine
20
+ from sdxloop.engine.model import RunResult
21
+ from sdxloop.engine.store import StateStore
22
+ from sdxloop.errors import SdxloopError
23
+ from sdxloop.sbx.cli import SbxCLI
24
+ from sdxloop.sbx.provision import sandbox_name
25
+
26
+ app = typer.Typer(
27
+ name="sdxloop",
28
+ help="Agentic loop orchestration on Docker Sandboxes with isolated credentials.",
29
+ no_args_is_help=True,
30
+ pretty_exceptions_show_locals=False,
31
+ )
32
+ sandbox_app = typer.Typer(help="Manage sdxloop sandboxes.", no_args_is_help=True)
33
+ config_app = typer.Typer(help="Inspect configuration.", no_args_is_help=True)
34
+ app.add_typer(sandbox_app, name="sandbox")
35
+ app.add_typer(config_app, name="config")
36
+
37
+ console = Console()
38
+
39
+
40
+ def _version_callback(value: bool) -> None:
41
+ if value:
42
+ console.print(f"sdxloop {sdxloop.__version__}")
43
+ raise typer.Exit()
44
+
45
+
46
+ @app.callback()
47
+ def _main_callback(
48
+ version: Annotated[
49
+ bool, typer.Option("--version", callback=_version_callback, is_eager=True)
50
+ ] = False,
51
+ ) -> None:
52
+ """sdxloop — agentic loops on Docker Sandboxes."""
53
+
54
+
55
+ def _config_with_overrides(**overrides: Any) -> Config:
56
+ config = load_config()
57
+ updates = {k: v for k, v in overrides.items() if v is not None}
58
+ return config.model_copy(update=updates) if updates else config
59
+
60
+
61
+ def _store(config: Config) -> StateStore:
62
+ return StateStore(config.state_dir / "state.db")
63
+
64
+
65
+ def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
66
+ """Run start/resume with either the live dashboard or plain event logs."""
67
+ if not tui:
68
+ engine.bus.subscribe(plain_printer(console))
69
+ return action() # type: ignore[no-any-return]
70
+
71
+ dashboard = Dashboard()
72
+ engine.bus.subscribe(dashboard.on_event)
73
+ outcome: dict[str, Any] = {}
74
+
75
+ def target() -> None:
76
+ try:
77
+ outcome["result"] = action()
78
+ except BaseException as exc:
79
+ outcome["error"] = exc
80
+
81
+ thread = threading.Thread(target=target, daemon=True)
82
+ with Live(dashboard.renderable(), console=console, refresh_per_second=8) as live:
83
+ thread.start()
84
+ while thread.is_alive():
85
+ live.update(dashboard.renderable())
86
+ time.sleep(0.15)
87
+ live.update(dashboard.renderable())
88
+ if "error" in outcome:
89
+ raise outcome["error"]
90
+ return outcome["result"] # type: ignore[no-any-return]
91
+
92
+
93
+ def _finish(result: RunResult) -> None:
94
+ style = "green" if result.succeeded else "red"
95
+ console.print(f"\nrun [bold cyan]{result.run_id}[/] finished: [bold {style}]{result.state}[/]")
96
+ for task in result.tasks:
97
+ console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
98
+ raise typer.Exit(0 if result.succeeded else 1)
99
+
100
+
101
+ @app.command()
102
+ def run(
103
+ outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
104
+ repo: Annotated[
105
+ str | None, typer.Option("--repo", help="owner/repo for GitHub progress reporting.")
106
+ ] = None,
107
+ model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
108
+ keep_sandboxes: Annotated[
109
+ bool, typer.Option("--keep-sandboxes", help="Do not remove sandboxes at the end.")
110
+ ] = False,
111
+ tui: Annotated[bool, typer.Option("--tui/--no-tui", help="Live dashboard.")] = True,
112
+ ) -> None:
113
+ """Run an agentic loop for OUTCOME in a fresh sandbox pair."""
114
+ config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
115
+ if repo:
116
+ config = config.model_copy(
117
+ update={"github": config.github.model_copy(update={"report_repo": repo})}
118
+ )
119
+ engine = LoopEngine(config)
120
+ try:
121
+ result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
122
+ except SdxloopError as exc:
123
+ console.print(f"[bold red]run failed:[/] {exc}")
124
+ raise typer.Exit(2) from exc
125
+ _finish(result)
126
+
127
+
128
+ @app.command()
129
+ def resume(
130
+ run_id: Annotated[str, typer.Argument(help="Run id to resume.")],
131
+ tui: Annotated[bool, typer.Option("--tui/--no-tui")] = True,
132
+ ) -> None:
133
+ """Resume an unfinished run (fresh sandboxes, persisted state)."""
134
+ engine = LoopEngine(load_config())
135
+ try:
136
+ result = _drive_with_ui(engine, tui=tui, action=lambda: engine.resume(run_id))
137
+ except SdxloopError as exc:
138
+ console.print(f"[bold red]resume failed:[/] {exc}")
139
+ raise typer.Exit(2) from exc
140
+ _finish(result)
141
+
142
+
143
+ @app.command()
144
+ def cancel(run_id: Annotated[str, typer.Argument()]) -> None:
145
+ """Mark a run cancelled (takes effect at the next phase boundary)."""
146
+ config = load_config()
147
+ engine = LoopEngine(config)
148
+ try:
149
+ engine.cancel(run_id)
150
+ except SdxloopError as exc:
151
+ console.print(f"[bold red]{exc}[/]")
152
+ raise typer.Exit(2) from exc
153
+ console.print(f"run {run_id} cancelled")
154
+
155
+
156
+ @app.command()
157
+ def status(
158
+ run_id: Annotated[str | None, typer.Argument(help="Run id for details.")] = None,
159
+ ) -> None:
160
+ """List runs, or show one run's tasks and phase history."""
161
+ config = load_config()
162
+ store = _store(config)
163
+ if run_id is None:
164
+ table = Table(title="sdxloop runs")
165
+ table.add_column("run")
166
+ table.add_column("state")
167
+ table.add_column("outcome", max_width=60)
168
+ table.add_column("updated")
169
+ for record in store.list_runs():
170
+ table.add_row(
171
+ record.run_id,
172
+ record.state,
173
+ record.outcome[:60],
174
+ time.strftime("%Y-%m-%d %H:%M", time.localtime(record.updated_at)),
175
+ )
176
+ console.print(table)
177
+ return
178
+
179
+ try:
180
+ record = store.get_run(run_id)
181
+ except SdxloopError as exc:
182
+ console.print(f"[bold red]{exc}[/]")
183
+ raise typer.Exit(2) from exc
184
+ console.print(f"run [bold cyan]{record.run_id}[/] state: [bold]{record.state}[/]")
185
+ console.print(f"outcome: {record.outcome}")
186
+ table = Table(title="tasks")
187
+ for column in ("task", "title", "state", "revisions", "replans"):
188
+ table.add_column(column)
189
+ for task in store.get_tasks(run_id):
190
+ table.add_row(
191
+ task.spec.id,
192
+ task.spec.title,
193
+ task.state,
194
+ str(task.revisions),
195
+ str(task.replans),
196
+ )
197
+ console.print(table)
198
+ attempts = store.phase_attempts(run_id)
199
+ console.print(f"{len(attempts)} phase attempts recorded")
200
+
201
+
202
+ @app.command()
203
+ def logs(
204
+ run_id: Annotated[str, typer.Argument()],
205
+ follow: Annotated[bool, typer.Option("--follow", "-f")] = False,
206
+ type_prefix: Annotated[
207
+ str | None, typer.Option("--type", help="Filter by event type prefix.")
208
+ ] = None,
209
+ task: Annotated[str | None, typer.Option("--task", help="Filter by task id.")] = None,
210
+ ) -> None:
211
+ """Replay (or tail) a run's event stream from the state store."""
212
+ config = load_config()
213
+ store = _store(config)
214
+ store.get_run(run_id) # validates existence
215
+ last_seq = 0
216
+ while True:
217
+ for seq, event in store.events(run_id, after_seq=last_seq, type_prefix=type_prefix):
218
+ last_seq = seq
219
+ if task and event.data.get("task_id") != task:
220
+ continue
221
+ console.print(format_event(event), highlight=False)
222
+ if not follow:
223
+ break
224
+ if store.get_run(run_id).state in ("completed", "failed", "cancelled"):
225
+ break
226
+ time.sleep(0.5)
227
+
228
+
229
+ @sandbox_app.command("ls")
230
+ def sandbox_ls() -> None:
231
+ """List sdxloop-managed sandboxes."""
232
+ config = load_config()
233
+ cli = SbxCLI(app_name=config.app_name or None)
234
+ table = Table(title="sdxloop sandboxes")
235
+ for column in ("name", "agent", "status", "workspace"):
236
+ table.add_column(column)
237
+ for info in cli.ls():
238
+ if info.name.startswith("sdxloop-"):
239
+ table.add_row(info.name, info.agent or "", info.status or "", info.workspace or "")
240
+ console.print(table)
241
+
242
+
243
+ @sandbox_app.command("rm")
244
+ def sandbox_rm(
245
+ name: Annotated[str | None, typer.Argument(help="Sandbox name.")] = None,
246
+ run_id: Annotated[str | None, typer.Option("--run", help="Remove a run's pair.")] = None,
247
+ all_: Annotated[bool, typer.Option("--all", help="Remove all sdxloop sandboxes.")] = False,
248
+ ) -> None:
249
+ """Remove sdxloop sandboxes by name, by run, or all of them."""
250
+ config = load_config()
251
+ cli = SbxCLI(app_name=config.app_name or None)
252
+ targets: list[str] = []
253
+ if name:
254
+ targets.append(name)
255
+ if run_id:
256
+ targets += [sandbox_name(run_id, "agent"), sandbox_name(run_id, "github")]
257
+ if all_:
258
+ targets += [i.name for i in cli.ls() if i.name.startswith("sdxloop-")]
259
+ if not targets:
260
+ console.print("nothing to remove: pass a NAME, --run, or --all")
261
+ raise typer.Exit(2)
262
+ for target in dict.fromkeys(targets):
263
+ try:
264
+ cli.rm(target)
265
+ console.print(f"removed {target}")
266
+ except SdxloopError as exc:
267
+ console.print(f"[yellow]skip {target}:[/] {exc}")
268
+
269
+
270
+ @config_app.command("show")
271
+ def config_show() -> None:
272
+ """Show the resolved configuration and where each value came from."""
273
+ try:
274
+ config, sources = load_config_with_sources()
275
+ except SdxloopError as exc:
276
+ console.print(f"[bold red]{exc}[/]")
277
+ raise typer.Exit(2) from exc
278
+ table = Table(title="sdxloop configuration")
279
+ table.add_column("key")
280
+ table.add_column("value")
281
+ table.add_column("source")
282
+ flat: dict[str, Any] = {}
283
+
284
+ def flatten(prefix: str, data: dict[str, Any]) -> None:
285
+ for key, value in data.items():
286
+ dotted = f"{prefix}{key}"
287
+ if isinstance(value, dict):
288
+ flatten(f"{dotted}.", value)
289
+ else:
290
+ flat[dotted] = value
291
+
292
+ flatten("", config.model_dump(mode="json"))
293
+ for dotted in sorted(flat):
294
+ table.add_row(dotted, repr(flat[dotted]), sources.get(dotted, "default"))
295
+ console.print(table)
296
+
297
+
298
+ @app.command()
299
+ def init(
300
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
301
+ ) -> None:
302
+ """Write a commented sdxloop.toml with the default configuration."""
303
+ path = Path("sdxloop.toml")
304
+ if path.exists() and not force:
305
+ console.print("sdxloop.toml already exists (use --force to overwrite)")
306
+ raise typer.Exit(2)
307
+ path.write_text(DEFAULT_CONFIG_TOML)
308
+ console.print(f"wrote {path}")
309
+
310
+
311
+ @app.command()
312
+ def doctor() -> None:
313
+ """Check that this host is ready to run sdxloop."""
314
+ ok = run_doctor(console)
315
+ raise typer.Exit(0 if ok else 1)
316
+
317
+
318
+ DEFAULT_CONFIG_TOML = """\
319
+ # sdxloop configuration. Every key is optional; these are the defaults.
320
+ # Precedence: SDXLOOP_* env vars > this file > pyproject [tool.sdxloop].
321
+
322
+ # Copilot model for agent sessions ("auto" lets the SDK choose).
323
+ model = "auto"
324
+ # sbx --app-name isolating sdxloop's sandboxes/policies/secrets.
325
+ app_name = "sdxloop"
326
+ # Where run state (SQLite) and per-run workspaces live.
327
+ state_dir = ".sdxloop"
328
+ # Keep sandboxes around after a run (for debugging).
329
+ keep_sandboxes = false
330
+ # Worker transport: "stream" (default) or "poll".
331
+ worker_transport = "stream"
332
+ # Secret injection: "proxy" (sbx keychain proxy; recommended) or "plain-env".
333
+ secret_strategy = "proxy"
334
+
335
+ [sandbox]
336
+ # Custom sandbox template reference (defaults to the sbx shell template).
337
+ # template = "docker.io/you/your-template:v1"
338
+ # Extra network allow rules applied to both sandboxes.
339
+ extra_allow_domains = []
340
+
341
+ [github]
342
+ # Report run progress to a GitHub repo ("owner/repo"); empty disables.
343
+ # report_repo = "you/your-repo"
344
+
345
+ [budgets]
346
+ max_revisions_per_task = 2
347
+ max_replans_per_task = 1
348
+ max_tasks = 20
349
+ max_wall_clock_s = 7200.0
350
+ per_job_timeout_s = 900.0
351
+ """
352
+
353
+
354
+ def main() -> None:
355
+ app()
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()
sdxloop/cli/doctor.py ADDED
@@ -0,0 +1,145 @@
1
+ """sdxloop doctor: host readiness checks with remediation hints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from sdxloop.config import load_config
11
+ from sdxloop.errors import SbxError, SbxNotFoundError
12
+ from sdxloop.sbx.cli import SbxCLI
13
+ from sdxloop.sbx.provision import (
14
+ AGENT_TOKEN_HOSTS,
15
+ COPILOT_TOKEN_ENV,
16
+ GH_TOKEN_ENVS,
17
+ )
18
+ from sdxloop.worker.wheel import resolve_worker_wheel
19
+
20
+ TESTED_SBX_SERIES = "0.35"
21
+
22
+
23
+ @dataclass
24
+ class Check:
25
+ name: str
26
+ ok: bool
27
+ detail: str
28
+ hard: bool = True
29
+
30
+
31
+ def collect_checks(env: dict[str, str], cli: SbxCLI | None = None) -> list[Check]:
32
+ config = load_config(env=env)
33
+ cli = cli or SbxCLI(app_name=config.app_name or None)
34
+ checks: list[Check] = []
35
+
36
+ # sbx binary + version
37
+ try:
38
+ version = cli.version()
39
+ except SbxNotFoundError:
40
+ checks.append(
41
+ Check(
42
+ "sbx binary",
43
+ False,
44
+ "sbx not found on PATH — install Docker Sandboxes: "
45
+ "https://docs.docker.com/ai/sandboxes/",
46
+ )
47
+ )
48
+ version = None
49
+ else:
50
+ detail = f"found sbx {version or '(unknown version)'}"
51
+ if version and not version.startswith(TESTED_SBX_SERIES):
52
+ detail += f" (sdxloop is tested against {TESTED_SBX_SERIES}.x; parsing may drift)"
53
+ checks.append(Check("sbx binary", True, detail))
54
+
55
+ if version is not None:
56
+ # login / daemon reachable
57
+ try:
58
+ cli.ls()
59
+ checks.append(Check("sbx login", True, "sbx ls succeeded"))
60
+ except SbxError as exc:
61
+ checks.append(Check("sbx login", False, f"sbx ls failed ({exc}); run `sbx login`"))
62
+
63
+ # network policy reachable for the copilot hosts
64
+ for host in AGENT_TOKEN_HOSTS:
65
+ allowed = False
66
+ try:
67
+ allowed = cli.policy_check(host)
68
+ except SbxError:
69
+ allowed = False
70
+ checks.append(
71
+ Check(
72
+ f"policy: {host}",
73
+ allowed,
74
+ "reachable"
75
+ if allowed
76
+ else (
77
+ "blocked — run `sbx policy init balanced` and/or "
78
+ f"`sbx policy allow network {host}`"
79
+ ),
80
+ hard=False, # per-sandbox allows are applied at provision time
81
+ )
82
+ )
83
+
84
+ # tokens
85
+ checks.append(
86
+ Check(
87
+ COPILOT_TOKEN_ENV,
88
+ bool(env.get(COPILOT_TOKEN_ENV)),
89
+ "set"
90
+ if env.get(COPILOT_TOKEN_ENV)
91
+ else 'not set — create a fine-grained PAT with the "Copilot Requests" '
92
+ f"permission and export {COPILOT_TOKEN_ENV}",
93
+ )
94
+ )
95
+ gh_set = any(env.get(name) for name in GH_TOKEN_ENVS)
96
+ checks.append(
97
+ Check(
98
+ "/".join(GH_TOKEN_ENVS),
99
+ gh_set,
100
+ "set"
101
+ if gh_set
102
+ else "not set — create a fine-grained PAT (issues:write, contents:read, ...) "
103
+ "and export GH_TOKEN",
104
+ )
105
+ )
106
+
107
+ # worker wheel
108
+ wheel = resolve_worker_wheel()
109
+ checks.append(
110
+ Check(
111
+ "worker wheel",
112
+ True,
113
+ f"resolved: {wheel.name}" if wheel else "no local wheel; will install from PyPI",
114
+ hard=False,
115
+ )
116
+ )
117
+
118
+ # state dir writable
119
+ try:
120
+ config.state_dir.mkdir(parents=True, exist_ok=True)
121
+ probe = config.state_dir / ".doctor-probe"
122
+ probe.write_text("ok")
123
+ probe.unlink()
124
+ checks.append(Check("state dir", True, str(config.state_dir.resolve())))
125
+ except OSError as exc:
126
+ checks.append(Check("state dir", False, f"not writable: {exc}"))
127
+
128
+ return checks
129
+
130
+
131
+ def run_doctor(console: Console, env: dict[str, str] | None = None) -> bool:
132
+ import os
133
+
134
+ checks = collect_checks(dict(os.environ) if env is None else env)
135
+ table = Table(title="sdxloop doctor")
136
+ table.add_column("check")
137
+ table.add_column("status")
138
+ table.add_column("detail", max_width=80)
139
+ for check in checks:
140
+ status = (
141
+ "[green]ok[/]" if check.ok else ("[red]FAIL[/]" if check.hard else "[yellow]warn[/]")
142
+ )
143
+ table.add_row(check.name, status, check.detail)
144
+ console.print(table)
145
+ return all(check.ok or not check.hard for check in checks)
sdxloop/cli/tui.py ADDED
@@ -0,0 +1,129 @@
1
+ """Rich rendering for runs: live dashboard and plain event logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from collections import deque
7
+ from typing import Any
8
+
9
+ from rich.console import Console, Group, RenderableType
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from sdxloop.events import Event, HostEventTypes
15
+
16
+ TASK_STATE_STYLES = {
17
+ "pending": "dim",
18
+ "planning": "yellow",
19
+ "executing": "cyan",
20
+ "scrutinizing": "magenta",
21
+ "verifying": "blue",
22
+ "validating": "magenta",
23
+ "done": "green",
24
+ "failed": "red",
25
+ "skipped": "dim red",
26
+ }
27
+
28
+ INTERESTING_EVENTS = (
29
+ "run.",
30
+ "task.",
31
+ "sandbox.",
32
+ "agent.message",
33
+ "agent.tool",
34
+ "gh.",
35
+ "worker.error",
36
+ )
37
+
38
+
39
+ class Dashboard:
40
+ """Accumulates run state from the event stream and renders it."""
41
+
42
+ def __init__(self, max_events: int = 12) -> None:
43
+ self.run_id: str | None = None
44
+ self.run_state: str = "starting"
45
+ self.outcome: str = ""
46
+ self.tasks: dict[str, dict[str, Any]] = {}
47
+ self.recent: deque[str] = deque(maxlen=max_events)
48
+
49
+ # -- event intake ------------------------------------------------------
50
+
51
+ def on_event(self, event: Event) -> None:
52
+ self.run_id = self.run_id or event.run_id
53
+ data = event.data
54
+ if event.type == HostEventTypes.RUN_START:
55
+ self.outcome = str(data.get("outcome", ""))
56
+ elif event.type == HostEventTypes.RUN_STATE or event.type == HostEventTypes.RUN_END:
57
+ self.run_state = str(data.get("state", self.run_state))
58
+ elif event.type in (HostEventTypes.TASK_START, HostEventTypes.TASK_STATE):
59
+ task_id = str(data.get("task_id"))
60
+ entry = self.tasks.setdefault(task_id, {"title": "", "state": "pending"})
61
+ if data.get("title"):
62
+ entry["title"] = str(data["title"])
63
+ if data.get("state"):
64
+ entry["state"] = str(data["state"])
65
+ entry["revisions"] = data.get("revisions", entry.get("revisions", 0))
66
+ entry["replans"] = data.get("replans", entry.get("replans", 0))
67
+ elif event.type == HostEventTypes.TASK_END:
68
+ task_id = str(data.get("task_id"))
69
+ entry = self.tasks.setdefault(task_id, {"title": "", "state": "pending"})
70
+ if data.get("title"):
71
+ entry["title"] = str(data["title"])
72
+ entry["state"] = str(data.get("state", entry["state"]))
73
+ if event.type.startswith(INTERESTING_EVENTS):
74
+ self.recent.append(format_event(event))
75
+
76
+ # -- rendering ---------------------------------------------------------
77
+
78
+ def renderable(self) -> RenderableType:
79
+ header = Text.assemble(
80
+ ("run ", "bold"),
81
+ (self.run_id or "…", "bold cyan"),
82
+ (" state: ", ""),
83
+ (self.run_state, "bold yellow"),
84
+ )
85
+ outcome = Text(self.outcome[:200], style="italic dim")
86
+
87
+ table = Table(expand=True, box=None, pad_edge=False)
88
+ table.add_column("task", style="bold", no_wrap=True)
89
+ table.add_column("title", ratio=2)
90
+ table.add_column("state", no_wrap=True)
91
+ table.add_column("rev/replan", no_wrap=True, justify="right")
92
+ for task_id, entry in self.tasks.items():
93
+ state = str(entry.get("state", "?"))
94
+ table.add_row(
95
+ task_id,
96
+ str(entry.get("title", "")),
97
+ Text(state, style=TASK_STATE_STYLES.get(state, "")),
98
+ f"{entry.get('revisions', 0)}/{entry.get('replans', 0)}",
99
+ )
100
+ if not self.tasks:
101
+ table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
102
+
103
+ events_panel = Panel(
104
+ Text("\n".join(self.recent) or "waiting for events…", overflow="ellipsis"),
105
+ title="events",
106
+ border_style="dim",
107
+ )
108
+ return Group(Panel(Group(header, outcome), border_style="cyan"), table, events_panel)
109
+
110
+
111
+ def format_event(event: Event) -> str:
112
+ stamp = datetime.datetime.fromtimestamp(event.ts).strftime("%H:%M:%S")
113
+ parts = [stamp, event.type]
114
+ if event.data.get("task_id"):
115
+ parts.append(f"[{event.data['task_id']}]")
116
+ for key in ("state", "content", "tool", "op", "line", "message", "outcome"):
117
+ if event.data.get(key):
118
+ value = str(event.data[key]).replace("\n", " ")
119
+ parts.append(value[:120])
120
+ break
121
+ return " ".join(parts)
122
+
123
+
124
+ def plain_printer(console: Console) -> Any:
125
+ def print_event(event: Event) -> None:
126
+ if event.type.startswith(INTERESTING_EVENTS):
127
+ console.print(format_event(event), highlight=False)
128
+
129
+ return print_event