diff --git a/README.md b/README.md index 1570b1a..b44c31a 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,34 @@ published). This is Fork B: `@his` is a rich-agent *role* that USES the room, ke MVP1: engine = a small local tool-model emitting `{"tool","args"}` JSON; first worker ships `fetch_url` only. Poll-based (no push); the single-writer lmcp serializes the log. + +## Companions & execution + +bullpen shells out to other hosts constantly — room posts, grinds, worker calls. Two companion +tools keep that clean and portable. Neither is required; both are recommended. + +### sic — executional hygiene for remote commands + +Use **sic** to run remote commands, not raw `ssh host "..."`. sic frames argv as netstrings over +ssh and `execvp`s it on the far side, so arguments arrive byte-exact — no nested-quoting hell, no +`bash -c "ssh … \"…\""` escaping disasters when a body contains quotes, `$()`, backticks, pipes, or +newlines. bullpen's room and grind calls all go through `sic …`; point the host roles +(`BULLPEN_ROOM_HOST`, `BULLPEN_GRIND_HOST`) at your fleet and the same code works unchanged. + +- https://github.com/marfrit/sic +- https://git.reauktion.de/marfrit/sic + +### oc-rpc — drive a rich agent into the room + +`contrib/oc-rpc` steers a persistent [opencode](https://opencode.ai) session over its HTTP API: +`new` / `say` / `wait` / `read` / `todo` / `diff` / `abort` / `share` / `list`. It lets any caller — +a human, another agent, a lurker — run one long-lived rich-agent session with continuity instead of +a fresh amnesiac process per poke. Point it at your opencode server with `OC_URL` or `BULLPEN_OC_URL` +(default `http://localhost:4096`). + +### Other companions + +- **mneme** — shared fleet memory (recall / remember); the room's durable notes. +- **lmcp** — the Lua MCP server hosting the `room_say` / `room_read` tools bullpen posts through. + +All companion hosts and endpoints resolve through `bullpen.conf` — see `bullpen.conf.example`. diff --git a/bullpen.conf.example b/bullpen.conf.example index 7f821c3..86aad02 100644 --- a/bullpen.conf.example +++ b/bullpen.conf.example @@ -17,6 +17,8 @@ #BULLPEN_MODEL=deepseek-v4-flash-dspark # opencode "provider/model" the lurker agents run on. #BULLPEN_OC_MODEL=bosch-dspark/deepseek-v4-flash-dspark +# opencode server URL (oc-rpc driver + lurker agents). +#BULLPEN_OC_URL=http://orca.fritz.box:4096 # --- paths --- # State dir: room.jsonl, artifacts/, hands/, coder/ sandbox live under here. diff --git a/contrib/oc-rpc b/contrib/oc-rpc new file mode 100755 index 0000000..1c0c7fc --- /dev/null +++ b/contrib/oc-rpc @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""oc-rpc — drive a persistent opencode session over its HTTP API. + +The bullpen lurker runs `opencode run` = a fresh, stateless session per poke, re-poked by every +reply to its own dispatches -> a cascade of amnesiac agents. This is the alternative primitive: +one long-lived session that any rich agent (or a human) STEERS with continuity, and can inspect +and abort. Talks straight to the opencode server (set OC_URL / BULLPEN_OC_URL), no sic hop. + + oc-rpc new [--title T] [--agent A] [--model prov/model] create a session -> prints id + share URL + oc-rpc say send a prompt (async; returns at once) + oc-rpc wait [--secs N] block until the current turn goes idle + oc-rpc read [--tail N] transcript (role + text/tool summaries) + oc-rpc todo the agent's current plan/todos + oc-rpc diff files changed this session + oc-rpc abort stop a runaway turn + oc-rpc share | unshare mint / revoke a watch URL + oc-rpc list [--n N] recent sessions + +Env: OC_URL or BULLPEN_OC_URL (default http://localhost:4096). Exit 0 ok, 1 error. +""" +import argparse, json, os, sys, time, urllib.request, urllib.error + +BASE = (os.environ.get("OC_URL") or os.environ.get("BULLPEN_OC_URL") or "http://localhost:4096").rstrip("/") + + +def api(method, path, body=None, timeout=60): + req = urllib.request.Request( + BASE + path, method=method, + headers={"Content-Type": "application/json"}, + data=json.dumps(body).encode() if body is not None else None) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + raw = r.read().decode() + except urllib.error.HTTPError as e: + sys.exit(f"oc-rpc: {method} {path} -> HTTP {e.code}: {e.read().decode()[:200]}") + except Exception as e: + sys.exit(f"oc-rpc: {method} {path} -> {type(e).__name__}: {e}") + return json.loads(raw) if raw.strip() else {} + + +def _sid(o): + return o.get("id") or (o.get("info") or {}).get("id") + + +def cmd_new(a): + body = {} + if a.title: body["title"] = a.title + if a.agent: body["agent"] = a.agent + if a.model: + prov, _, mid = a.model.partition("/") + body["model"] = {"providerID": prov, "modelID": mid} + o = api("POST", "/session", body) + sid = _sid(o) + print(sid) + if sid: + try: + s = api("POST", f"/session/{sid}/share", {}) + url = s.get("url") or s.get("share", {}).get("url") + if url: print("share:", url) + except SystemExit: + pass + + +def cmd_say(a): + text = " ".join(a.text) + parts = [{"type": "text", "text": text}] + body = {"parts": parts} + if a.agent: body["agent"] = a.agent + # async: fire and return, so a long turn never blocks the driver's curl. + try: + api("POST", f"/session/{a.ses}/prompt_async", body, timeout=30) + except SystemExit: + # fall back to the v1 blocking prompt if async is unavailable + api("POST", f"/session/{a.ses}/message", body, timeout=a.secs) + print(f"sent to {a.ses}; poll: oc-rpc read {a.ses}") + + +def _idle(ses): + st = api("GET", f"/session/{ses}") + info = st.get("info", st) + # opencode marks a live turn via time.completed missing / a 'running' revert flag + t = info.get("time", {}) + return bool(t.get("completed")) or info.get("idle", True) + + +def cmd_wait(a): + t0 = time.time() + while time.time() - t0 < a.secs: + if _idle(a.ses): + print("idle"); return + time.sleep(3) + print("timeout (still running)"); sys.exit(1) + + +def _summ_part(p): + t = p.get("type") + if t == "text": return p.get("text", "")[:500] + if t == "reasoning": return "[reasoning]" + if t == "tool": + st = (p.get("state") or {}).get("status", "") + return f"[tool {p.get('tool','')}: {st}]" + if t in ("step-start", "step-finish"): return None + return f"[{t}]" + + +def cmd_read(a): + msgs = api("GET", f"/session/{a.ses}/message", timeout=60) + if not isinstance(msgs, list): + msgs = msgs.get("messages", []) + for m in msgs[-a.tail:]: + info = m.get("info", m) + role = info.get("role", "?") + chunks = [s for s in (_summ_part(p) for p in m.get("parts", [])) if s] + if chunks: + print(f"--- {role} ---") + for c in chunks: print(c) + + +def cmd_todo(a): + for t in api("GET", f"/session/{a.ses}/todo") or []: + print(f"[{t.get('status','?'):11}] {t.get('content', t.get('title',''))}") + + +def cmd_diff(a): + d = api("GET", f"/session/{a.ses}/diff") + print(json.dumps(d, indent=2)[:4000] if not isinstance(d, str) else d[:4000]) + + +def cmd_abort(a): + api("POST", f"/session/{a.ses}/abort", {}); print("aborted") + + +def cmd_share(a): + s = api("POST", f"/session/{a.ses}/share", {}) + print(s.get("url") or json.dumps(s)[:300]) + + +def cmd_unshare(a): + api("DELETE", f"/session/{a.ses}/share"); print("unshared") + + +def cmd_list(a): + d = api("GET", "/session") + d = sorted(d, key=lambda s: (s.get("time") or {}).get("created", 0), reverse=True) + for s in d[:a.n]: + print(f"{s.get('id')} {(s.get('title') or '')[:60]}") + + +def main(): + p = argparse.ArgumentParser(prog="oc-rpc") + sub = p.add_subparsers(dest="cmd", required=True) + n = sub.add_parser("new"); n.add_argument("--title"); n.add_argument("--agent"); n.add_argument("--model"); n.set_defaults(fn=cmd_new) + s = sub.add_parser("say"); s.add_argument("ses"); s.add_argument("text", nargs="+"); s.add_argument("--agent"); s.add_argument("--secs", type=int, default=900); s.set_defaults(fn=cmd_say) + w = sub.add_parser("wait"); w.add_argument("ses"); w.add_argument("--secs", type=int, default=900); w.set_defaults(fn=cmd_wait) + r = sub.add_parser("read"); r.add_argument("ses"); r.add_argument("--tail", type=int, default=6); r.set_defaults(fn=cmd_read) + for name, fn in (("todo", cmd_todo), ("diff", cmd_diff), ("abort", cmd_abort), ("share", cmd_share), ("unshare", cmd_unshare)): + q = sub.add_parser(name); q.add_argument("ses"); q.set_defaults(fn=fn) + l = sub.add_parser("list"); l.add_argument("--n", type=int, default=10); l.set_defaults(fn=cmd_list) + a = p.parse_args() + a.fn(a) + + +if __name__ == "__main__": + main() diff --git a/lib/bullpen_config.py b/lib/bullpen_config.py index 498f93c..257ca38 100644 --- a/lib/bullpen_config.py +++ b/lib/bullpen_config.py @@ -66,6 +66,7 @@ GRIND_HOST = get("BULLPEN_GRIND_HOST", "boltzmann") # repos + pytest live her PROXY = get("BULLPEN_PROXY", "http://hossenfelder.fritz.box:8082/v1/chat/completions") MODEL = get("BULLPEN_MODEL", "deepseek-v4-flash-dspark") # default worker model OC_MODEL = get("BULLPEN_OC_MODEL", "bosch-dspark/deepseek-v4-flash-dspark") # opencode provider/model +OC_URL = get("BULLPEN_OC_URL", "http://orca.fritz.box:4096") # opencode server (oc-rpc, lurkers) # --- paths ------------------------------------------------------------------ STATE_DIR = get("BULLPEN_STATE_DIR", "/var/lib/bullpen") diff --git a/lib/bullpen_config.sh b/lib/bullpen_config.sh index de7222e..ee25c07 100644 --- a/lib/bullpen_config.sh +++ b/lib/bullpen_config.sh @@ -16,6 +16,7 @@ unset _bp_conf : "${BULLPEN_PROXY:=http://hossenfelder.fritz.box:8082/v1/chat/completions}" : "${BULLPEN_MODEL:=deepseek-v4-flash-dspark}" : "${BULLPEN_OC_MODEL:=bosch-dspark/deepseek-v4-flash-dspark}" +: "${BULLPEN_OC_URL:=http://orca.fritz.box:4096}" : "${BULLPEN_STATE_DIR:=/var/lib/bullpen}" : "${BULLPEN_SECRET_FILE:=/etc/bullpen/post-secret}" : "${BULLPEN_ROOM_LOG:=${BULLPEN_STATE_DIR}/room.jsonl}" diff --git a/tests/test_bullpen_config.py b/tests/test_bullpen_config.py index 70669ef..306f4a1 100644 --- a/tests/test_bullpen_config.py +++ b/tests/test_bullpen_config.py @@ -37,6 +37,7 @@ def test_defaults_match_origin_fleet(): assert cfg.GRIND_HOST == "boltzmann" assert cfg.MODEL == "deepseek-v4-flash-dspark" assert cfg.PROXY == "http://hossenfelder.fritz.box:8082/v1/chat/completions" + assert cfg.OC_URL == "http://orca.fritz.box:4096" assert cfg.STATE_DIR == "/var/lib/bullpen" assert cfg.ROOM_LOG == "/var/lib/bullpen/room.jsonl" assert cfg.SECRET_FILE == "/etc/bullpen/post-secret"