kanbot 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.
kanbot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Deckhand — a Kanban board where every card is a task run by your local CLI coding agents."""
2
+
3
+ __version__ = "0.1.0"
kanbot/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
kanbot/agents.py ADDED
@@ -0,0 +1,134 @@
1
+ """Built-in CLI agent catalog, shared by the server (display) and runner (execution).
2
+
3
+ Each agent is defined declaratively so adding "whatever else is available in the
4
+ CLI" is a one-liner here, or a config override on the runner side. The runner
5
+ detects which `bin` are on PATH and only advertises those it finds.
6
+
7
+ Command templates use Python str.format with:
8
+ {prompt} -> the task prompt (already shell-safe; passed as a single argv item)
9
+ The command is a list of argv tokens; the runner substitutes {prompt} per-token
10
+ so no shell quoting is needed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Dict, List, Optional
16
+
17
+
18
+ @dataclass
19
+ class AgentSpec:
20
+ name: str # stable id, e.g. "claude"
21
+ label: str # display name
22
+ bin: str # executable to look for on PATH
23
+ argv: List[str] # argv template; tokens may contain {prompt}
24
+ description: str = ""
25
+ env: Dict[str, str] = field(default_factory=dict)
26
+ color: str = "#8b5cf6"
27
+ # argv template to resume/continue an existing agent session. Tokens may
28
+ # contain {prompt} and {session_id}. Empty => resume not supported.
29
+ resume_argv: List[str] = field(default_factory=list)
30
+
31
+
32
+ # Non-interactive / headless invocations for each known coding CLI.
33
+ BUILTIN_AGENTS: List[AgentSpec] = [
34
+ AgentSpec(
35
+ name="claude",
36
+ label="Claude Code",
37
+ bin="claude",
38
+ argv=["claude", "-p", "{prompt}", "--dangerously-skip-permissions"],
39
+ resume_argv=["claude", "--resume", "{session_id}", "-p", "{prompt}",
40
+ "--dangerously-skip-permissions"],
41
+ description="Anthropic Claude Code in headless print mode.",
42
+ color="#d97757",
43
+ ),
44
+ AgentSpec(
45
+ name="codex",
46
+ label="Codex",
47
+ bin="codex",
48
+ argv=["codex", "exec", "--sandbox", "workspace-write",
49
+ "--skip-git-repo-check", "{prompt}"],
50
+ resume_argv=["codex", "exec", "resume", "--skip-git-repo-check",
51
+ "{session_id}", "{prompt}"],
52
+ description="OpenAI Codex CLI, non-interactive exec (workspace-write sandbox).",
53
+ color="#10a37f",
54
+ ),
55
+ AgentSpec(
56
+ name="gemini",
57
+ label="Gemini CLI",
58
+ bin="gemini",
59
+ argv=["gemini", "-y", "-p", "{prompt}"],
60
+ description="Google Gemini CLI in YOLO/auto mode.",
61
+ color="#4285f4",
62
+ ),
63
+ AgentSpec(
64
+ name="glm",
65
+ label="GLM / Z.ai",
66
+ bin="claude",
67
+ argv=["claude", "-p", "{prompt}", "--dangerously-skip-permissions"],
68
+ resume_argv=["claude", "--resume", "{session_id}", "-p", "{prompt}",
69
+ "--dangerously-skip-permissions"],
70
+ description="Z.ai GLM coding plan via Claude Code (set ANTHROPIC_BASE_URL).",
71
+ env={"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic"},
72
+ color="#2563eb",
73
+ ),
74
+ AgentSpec(
75
+ name="opencode",
76
+ label="OpenCode",
77
+ bin="opencode",
78
+ argv=["opencode", "run", "{prompt}"],
79
+ description="OpenCode terminal agent, non-interactive run.",
80
+ color="#f59e0b",
81
+ ),
82
+ AgentSpec(
83
+ name="hermes",
84
+ label="Hermes",
85
+ bin="hermes",
86
+ argv=["hermes", "-p", "{prompt}"],
87
+ description="Hermes coding agent (best-effort; override argv in config if it differs).",
88
+ color="#e879f9",
89
+ ),
90
+ AgentSpec(
91
+ name="aider",
92
+ label="Aider",
93
+ bin="aider",
94
+ argv=["aider", "--yes", "--no-auto-commits", "--message", "{prompt}"],
95
+ description="Aider pair-programmer, single message mode.",
96
+ color="#22c55e",
97
+ ),
98
+ AgentSpec(
99
+ name="cursor-agent",
100
+ label="Cursor Agent",
101
+ bin="cursor-agent",
102
+ argv=["cursor-agent", "-p", "{prompt}"],
103
+ description="Cursor CLI agent in print mode.",
104
+ color="#000000",
105
+ ),
106
+ AgentSpec(
107
+ name="shell",
108
+ label="Shell command",
109
+ bin="bash",
110
+ argv=["bash", "-lc", "{prompt}"],
111
+ description="Run the prompt as a raw shell command. Always available.",
112
+ color="#64748b",
113
+ ),
114
+ ]
115
+
116
+ BUILTIN_BY_NAME: Dict[str, AgentSpec] = {a.name: a for a in BUILTIN_AGENTS}
117
+
118
+
119
+ def builtin_names() -> List[str]:
120
+ return [a.name for a in BUILTIN_AGENTS]
121
+
122
+
123
+ def spec_to_dict(a: AgentSpec) -> dict:
124
+ return {
125
+ "name": a.name,
126
+ "label": a.label,
127
+ "bin": a.bin,
128
+ "description": a.description,
129
+ "color": a.color,
130
+ }
131
+
132
+
133
+ def catalog() -> List[dict]:
134
+ return [spec_to_dict(a) for a in BUILTIN_AGENTS]
kanbot/cli.py ADDED
@@ -0,0 +1,260 @@
1
+ """KanBot command-line interface.
2
+
3
+ kanbot up # start server + a local runner together (best first run)
4
+ kanbot server # just the web server / API / board
5
+ kanbot runner # just the background runner (connects to a server)
6
+ kanbot agents # show which CLI coding agents are detected here
7
+ kanbot config # view / set server URL, token, runner name
8
+ kanbot open # open the board in your browser
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import sys
15
+ import threading
16
+ import time
17
+ import webbrowser
18
+
19
+ from . import __version__
20
+ from .config import Config, config_path, db_path
21
+
22
+
23
+ def _rich():
24
+ try:
25
+ from rich.console import Console
26
+ return Console()
27
+ except Exception:
28
+ return None
29
+
30
+
31
+ def cmd_server(args) -> int:
32
+ import uvicorn
33
+ from .server.app import create_app
34
+
35
+ app = create_app(db_path=args.db)
36
+ url = f"http://{args.host}:{args.port}"
37
+ print(f"KanBot server v{__version__} → {url}")
38
+ print(f" db: {args.db or db_path()}")
39
+ print(f" open: {url} (then run `kanbot runner` on any machine)")
40
+ uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level)
41
+ return 0
42
+
43
+
44
+ def cmd_runner(args) -> int:
45
+ from .runner.worker import Runner
46
+
47
+ cfg = Config.load()
48
+ if args.server:
49
+ cfg.server_url = args.server
50
+ if args.token:
51
+ cfg.token = args.token
52
+ if args.name:
53
+ cfg.runner_name = args.name
54
+ if args.concurrency:
55
+ cfg.max_concurrency = args.concurrency
56
+ cfg.save()
57
+
58
+ runner = Runner(cfg)
59
+ try:
60
+ asyncio.run(runner.run_forever())
61
+ except KeyboardInterrupt:
62
+ print("\nrunner stopped.")
63
+ return 0
64
+
65
+
66
+ def cmd_up(args) -> int:
67
+ """Start the server in-process and attach a local runner. One command demo."""
68
+ import uvicorn
69
+ from .runner.worker import Runner
70
+ from .server.app import create_app
71
+
72
+ app = create_app(db_path=args.db)
73
+ config = uvicorn.Config(app, host=args.host, port=args.port, log_level="warning")
74
+ server = uvicorn.Server(config)
75
+
76
+ def serve():
77
+ asyncio.run(server.serve())
78
+
79
+ t = threading.Thread(target=serve, daemon=True)
80
+ t.start()
81
+
82
+ # wait for the server to come up
83
+ import httpx
84
+ base = f"http://{args.host}:{args.port}"
85
+ for _ in range(50):
86
+ try:
87
+ httpx.get(base + "/api/health", timeout=0.5)
88
+ break
89
+ except Exception:
90
+ time.sleep(0.1)
91
+
92
+ print(f"KanBot is up → {base}")
93
+ if not args.no_open:
94
+ try:
95
+ webbrowser.open(base)
96
+ except Exception:
97
+ pass
98
+
99
+ cfg = Config.load()
100
+ cfg.server_url = base
101
+ if args.name:
102
+ cfg.runner_name = args.name
103
+ if args.concurrency:
104
+ cfg.max_concurrency = args.concurrency
105
+ cfg.save()
106
+ runner = Runner(cfg)
107
+ print(f"Local runner '{cfg.runner_name}' attaching with agents: "
108
+ f"{', '.join(runner.agents) or '(none — install claude/codex/etc.)'}")
109
+ print("Press Ctrl-C to stop.\n")
110
+ try:
111
+ asyncio.run(runner.run_forever())
112
+ except KeyboardInterrupt:
113
+ print("\nshutting down.")
114
+ server.should_exit = True
115
+ return 0
116
+
117
+
118
+ def cmd_agents(args) -> int:
119
+ from .runner.agents import detect_agents
120
+ from .agents import BUILTIN_AGENTS
121
+
122
+ cfg = Config.load()
123
+ found = detect_agents(cfg)
124
+ console = _rich()
125
+ if console:
126
+ from rich.table import Table
127
+ table = Table(title="CLI agents on this machine")
128
+ table.add_column("agent")
129
+ table.add_column("status")
130
+ table.add_column("description")
131
+ for spec in BUILTIN_AGENTS:
132
+ ok = spec.name in found
133
+ disabled = spec.name in cfg.disabled_agents
134
+ status = "[green]available[/green]" if ok else (
135
+ "[yellow]disabled[/yellow]" if disabled else "[dim]not found[/dim]")
136
+ table.add_row(spec.name, status, spec.description)
137
+ console.print(table)
138
+ else:
139
+ for spec in BUILTIN_AGENTS:
140
+ mark = "✓" if spec.name in found else "·"
141
+ print(f" {mark} {spec.name:14} {spec.description}")
142
+ print(f"\nadvertised capabilities: {', '.join(found) or '(none)'}")
143
+
144
+ # Session trackers: which TUIs KanBot can see / revive.
145
+ from .runner.discovery import active_providers, builtin_providers
146
+ trackers = active_providers(cfg.discovery_sources)
147
+ active_names = {t["name"] for t in trackers}
148
+ print("\nsession trackers (TUIs KanBot watches):")
149
+ for p in builtin_providers():
150
+ mark = "✓" if p.name in active_names else "·"
151
+ state = "tracking" if p.name in active_names else "no sessions found"
152
+ print(f" {mark} {p.label:14} {p.root} [{state}]")
153
+ for t in trackers:
154
+ if t["name"] not in ("claude", "codex"):
155
+ print(f" ✓ {t['label']:14} {t['root']} [custom]")
156
+ print("\nTrack another agent: add to discovery_sources in "
157
+ f"{config_path()}, e.g.\n"
158
+ ' {"name": "hermes", "label": "Hermes", "root": "~/.hermes/sessions",\n'
159
+ ' "pattern": "*.jsonl", "recursive": true, "fmt": "claude"}')
160
+ return 0
161
+
162
+
163
+ def cmd_config(args) -> int:
164
+ cfg = Config.load()
165
+ changed = False
166
+ if args.server:
167
+ cfg.server_url = args.server; changed = True
168
+ if args.token is not None:
169
+ cfg.token = args.token; changed = True
170
+ if args.name:
171
+ cfg.runner_name = args.name; changed = True
172
+ if args.concurrency:
173
+ cfg.max_concurrency = args.concurrency; changed = True
174
+ if args.disable:
175
+ for a in args.disable:
176
+ if a not in cfg.disabled_agents:
177
+ cfg.disabled_agents.append(a)
178
+ changed = True
179
+ if args.enable:
180
+ cfg.disabled_agents = [a for a in cfg.disabled_agents if a not in args.enable]
181
+ changed = True
182
+ if changed:
183
+ cfg.save()
184
+ print(f"saved {config_path()}")
185
+ print(f"server_url : {cfg.server_url}")
186
+ print(f"token : {'(set)' if cfg.token else '(none)'}")
187
+ print(f"runner_name : {cfg.runner_name}")
188
+ print(f"runner_id : {cfg.runner_id}")
189
+ print(f"max_concurrency : {cfg.max_concurrency}")
190
+ print(f"disabled_agents : {', '.join(cfg.disabled_agents) or '(none)'}")
191
+ return 0
192
+
193
+
194
+ def cmd_open(args) -> int:
195
+ cfg = Config.load()
196
+ url = args.server or cfg.server_url
197
+ print(f"opening {url}")
198
+ webbrowser.open(url)
199
+ return 0
200
+
201
+
202
+ def build_parser() -> argparse.ArgumentParser:
203
+ p = argparse.ArgumentParser(prog="kanbot", description=__doc__,
204
+ formatter_class=argparse.RawDescriptionHelpFormatter)
205
+ p.add_argument("--version", action="version", version=f"kanbot {__version__}")
206
+ sub = p.add_subparsers(dest="cmd")
207
+
208
+ sp = sub.add_parser("up", help="start server + local runner (recommended first run)")
209
+ sp.add_argument("--host", default="127.0.0.1")
210
+ sp.add_argument("--port", type=int, default=8787)
211
+ sp.add_argument("--db", default=None)
212
+ sp.add_argument("--name", default=None, help="runner name")
213
+ sp.add_argument("--concurrency", type=int, default=None)
214
+ sp.add_argument("--no-open", action="store_true", help="don't open the browser")
215
+ sp.set_defaults(func=cmd_up)
216
+
217
+ sp = sub.add_parser("server", help="run the web server / API only")
218
+ sp.add_argument("--host", default="127.0.0.1")
219
+ sp.add_argument("--port", type=int, default=8787)
220
+ sp.add_argument("--db", default=None)
221
+ sp.add_argument("--log-level", default="info")
222
+ sp.set_defaults(func=cmd_server)
223
+
224
+ sp = sub.add_parser("runner", help="run the background runner only")
225
+ sp.add_argument("--server", default=None, help="server URL (e.g. http://host:8787)")
226
+ sp.add_argument("--token", default=None)
227
+ sp.add_argument("--name", default=None)
228
+ sp.add_argument("--concurrency", type=int, default=None)
229
+ sp.set_defaults(func=cmd_runner)
230
+
231
+ sp = sub.add_parser("agents", help="show detected CLI agents")
232
+ sp.set_defaults(func=cmd_agents)
233
+
234
+ sp = sub.add_parser("config", help="view or set configuration")
235
+ sp.add_argument("--server", default=None)
236
+ sp.add_argument("--token", default=None)
237
+ sp.add_argument("--name", default=None)
238
+ sp.add_argument("--concurrency", type=int, default=None)
239
+ sp.add_argument("--disable", nargs="*", help="agent names to disable")
240
+ sp.add_argument("--enable", nargs="*", help="agent names to re-enable")
241
+ sp.set_defaults(func=cmd_config)
242
+
243
+ sp = sub.add_parser("open", help="open the board in a browser")
244
+ sp.add_argument("--server", default=None)
245
+ sp.set_defaults(func=cmd_open)
246
+
247
+ return p
248
+
249
+
250
+ def main(argv=None) -> int:
251
+ parser = build_parser()
252
+ args = parser.parse_args(argv)
253
+ if not getattr(args, "cmd", None):
254
+ parser.print_help()
255
+ return 0
256
+ return args.func(args)
257
+
258
+
259
+ if __name__ == "__main__":
260
+ sys.exit(main())
kanbot/config.py ADDED
@@ -0,0 +1,77 @@
1
+ """User/runner configuration stored in ~/.kanbot/config.json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import socket
7
+ import uuid
8
+ from dataclasses import dataclass, asdict, field
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ def config_dir() -> Path:
14
+ base = os.environ.get("KANBOT_HOME") or os.environ.get("DECKHAND_HOME")
15
+ if base:
16
+ path = Path(base).expanduser()
17
+ else:
18
+ path = Path.home() / ".kanbot"
19
+ path.mkdir(parents=True, exist_ok=True)
20
+ return path
21
+
22
+
23
+ def config_path() -> Path:
24
+ return config_dir() / "config.json"
25
+
26
+
27
+ def db_path() -> Path:
28
+ env = os.environ.get("KANBOT_DB") or os.environ.get("DECKHAND_DB")
29
+ if env:
30
+ return Path(env).expanduser()
31
+ return config_dir() / "kanbot.db"
32
+
33
+
34
+ @dataclass
35
+ class Config:
36
+ """Local config used by both the runner and convenience commands."""
37
+
38
+ server_url: str = "http://127.0.0.1:8787"
39
+ token: str = ""
40
+ runner_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
41
+ runner_name: str = field(default_factory=lambda: socket.gethostname())
42
+ # Map of agent-name -> override command template. Empty = use built-in defaults.
43
+ agent_overrides: Dict[str, str] = field(default_factory=dict)
44
+ # Agents the user has explicitly disabled.
45
+ disabled_agents: list = field(default_factory=list)
46
+ max_concurrency: int = 2
47
+ # Extra session stores to track, e.g. Hermes or any agent that logs JSONL:
48
+ # [{"name": "hermes", "label": "Hermes", "root": "~/.hermes/sessions",
49
+ # "pattern": "*.jsonl", "recursive": true, "fmt": "claude"}]
50
+ discovery_sources: list = field(default_factory=list)
51
+
52
+ @classmethod
53
+ def load(cls) -> "Config":
54
+ p = config_path()
55
+ if p.exists():
56
+ try:
57
+ data = json.loads(p.read_text())
58
+ except (json.JSONDecodeError, OSError):
59
+ data = {}
60
+ else:
61
+ data = {}
62
+ known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
63
+ clean = {k: v for k, v in data.items() if k in known}
64
+ cfg = cls(**clean)
65
+ return cfg
66
+
67
+ def save(self) -> None:
68
+ config_path().write_text(json.dumps(asdict(self), indent=2))
69
+
70
+ @property
71
+ def ws_url(self) -> str:
72
+ url = self.server_url.rstrip("/")
73
+ if url.startswith("https://"):
74
+ return "wss://" + url[len("https://"):]
75
+ if url.startswith("http://"):
76
+ return "ws://" + url[len("http://"):]
77
+ return url
@@ -0,0 +1 @@
1
+ """Deckhand runner package."""
@@ -0,0 +1,141 @@
1
+ """Agent detection and execution for the runner.
2
+
3
+ Detection: walk the built-in catalog, keep any whose `bin` is on PATH, apply
4
+ user overrides/disables from config. The resulting list of names is what the
5
+ runner advertises to the server as its capabilities.
6
+
7
+ Execution: spawn the agent's argv (with {prompt} substituted per token) in the
8
+ target cwd, stream stdout/stderr lines back through an async callback, and honor
9
+ cancellation.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import shlex
16
+ import shutil
17
+ from dataclasses import dataclass
18
+ from typing import Awaitable, Callable, Dict, List, Optional
19
+
20
+ from ..agents import BUILTIN_AGENTS, AgentSpec
21
+ from ..config import Config
22
+
23
+
24
+ @dataclass
25
+ class ResolvedAgent:
26
+ name: str
27
+ label: str
28
+ argv: List[str]
29
+ env: Dict[str, str]
30
+ resume_argv: List[str] = None # type: ignore[assignment]
31
+
32
+ @property
33
+ def can_resume(self) -> bool:
34
+ return bool(self.resume_argv)
35
+
36
+
37
+ def _override_argv(template: str) -> List[str]:
38
+ """A config override is a shell-ish string; keep {prompt} as its own token."""
39
+ return shlex.split(template)
40
+
41
+
42
+ def detect_agents(cfg: Config) -> Dict[str, ResolvedAgent]:
43
+ found: Dict[str, ResolvedAgent] = {}
44
+ for spec in BUILTIN_AGENTS:
45
+ if spec.name in cfg.disabled_agents:
46
+ continue
47
+ argv = spec.argv
48
+ binary = spec.bin
49
+ if spec.name in cfg.agent_overrides:
50
+ argv = _override_argv(cfg.agent_overrides[spec.name])
51
+ binary = argv[0] if argv else spec.bin
52
+ if not shutil.which(binary):
53
+ continue
54
+ found[spec.name] = ResolvedAgent(
55
+ name=spec.name, label=spec.label, argv=list(argv), env=dict(spec.env),
56
+ resume_argv=list(spec.resume_argv),
57
+ )
58
+ return found
59
+
60
+
61
+ def build_argv(agent: ResolvedAgent, prompt: str, resume_of: str = "") -> List[str]:
62
+ template = agent.resume_argv if (resume_of and agent.can_resume) else agent.argv
63
+ out: List[str] = []
64
+ for tok in template:
65
+ tok = tok.replace("{prompt}", prompt)
66
+ tok = tok.replace("{session_id}", resume_of)
67
+ out.append(tok)
68
+ return out
69
+
70
+
71
+ LogCb = Callable[[str, str], Awaitable[None]] # (stream, text) -> awaitable
72
+
73
+
74
+ class Execution:
75
+ """A running agent subprocess for one session."""
76
+
77
+ def __init__(self, session_id: str, proc: asyncio.subprocess.Process):
78
+ self.session_id = session_id
79
+ self.proc = proc
80
+
81
+ async def cancel(self) -> None:
82
+ if self.proc.returncode is None:
83
+ try:
84
+ self.proc.terminate()
85
+ except ProcessLookupError:
86
+ return
87
+ try:
88
+ await asyncio.wait_for(self.proc.wait(), timeout=5)
89
+ except asyncio.TimeoutError:
90
+ try:
91
+ self.proc.kill()
92
+ except ProcessLookupError:
93
+ pass
94
+
95
+
96
+ async def run_agent(agent: ResolvedAgent, prompt: str, cwd: str, on_log: LogCb,
97
+ register: Callable[[Execution], None], resume_of: str = "") -> int:
98
+ """Run the agent, streaming output. Returns the process exit code."""
99
+ if resume_of and not agent.can_resume:
100
+ await on_log("system", f"agent '{agent.name}' can't resume sessions; starting fresh.")
101
+ resume_of = ""
102
+ argv = build_argv(agent, prompt, resume_of)
103
+ if resume_of:
104
+ await on_log("system", f"resuming {agent.name} session {resume_of}")
105
+ workdir = cwd if cwd and os.path.isdir(cwd) else os.getcwd()
106
+ env = os.environ.copy()
107
+ env.update(agent.env)
108
+
109
+ await on_log("system", f"$ {' '.join(shlex.quote(a) for a in argv)}")
110
+ await on_log("system", f"(cwd: {workdir})")
111
+
112
+ try:
113
+ proc = await asyncio.create_subprocess_exec(
114
+ *argv, cwd=workdir, env=env,
115
+ stdin=asyncio.subprocess.DEVNULL, # headless: never block on interactive stdin
116
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
117
+ )
118
+ except FileNotFoundError:
119
+ await on_log("stderr", f"agent binary not found: {argv[0]}")
120
+ return 127
121
+ except OSError as e:
122
+ await on_log("stderr", f"failed to start agent: {e}")
123
+ return 1
124
+
125
+ execution = Execution("", proc)
126
+ register(execution)
127
+
128
+ async def pump(stream, name: str):
129
+ assert stream is not None
130
+ while True:
131
+ line = await stream.readline()
132
+ if not line:
133
+ break
134
+ await on_log(name, line.decode("utf-8", "replace").rstrip("\n"))
135
+
136
+ await asyncio.gather(
137
+ pump(proc.stdout, "stdout"),
138
+ pump(proc.stderr, "stderr"),
139
+ )
140
+ rc = await proc.wait()
141
+ return rc