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 +3 -0
- kanbot/__main__.py +6 -0
- kanbot/agents.py +134 -0
- kanbot/cli.py +260 -0
- kanbot/config.py +77 -0
- kanbot/runner/__init__.py +1 -0
- kanbot/runner/agents.py +141 -0
- kanbot/runner/discovery.py +327 -0
- kanbot/runner/worker.py +173 -0
- kanbot/server/__init__.py +1 -0
- kanbot/server/app.py +329 -0
- kanbot/server/db.py +421 -0
- kanbot/server/hub.py +243 -0
- kanbot/server/insights.py +129 -0
- kanbot/server/schemas.py +54 -0
- kanbot/server/static/app.js +1298 -0
- kanbot/server/static/index.html +53 -0
- kanbot/server/static/styles.css +430 -0
- kanbot-0.1.0.dist-info/METADATA +145 -0
- kanbot-0.1.0.dist-info/RECORD +22 -0
- kanbot-0.1.0.dist-info/WHEEL +4 -0
- kanbot-0.1.0.dist-info/entry_points.txt +2 -0
kanbot/__init__.py
ADDED
kanbot/__main__.py
ADDED
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."""
|
kanbot/runner/agents.py
ADDED
|
@@ -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
|