Add oc-rpc stub + companions doc; BULLPEN_OC_URL config knob (#100)

- contrib/oc-rpc: portable stub of the opencode RPC driver (new/say/wait/read/
  todo/diff/abort/share/list). Sanitized: default OC_URL -> localhost, honors
  OC_URL / BULLPEN_OC_URL. Drives one persistent rich-agent session with
  continuity instead of a fresh amnesiac process per poke.
- README "Companions & execution": recommend sic for remote-exec hygiene
  (netstring argv over ssh, no quoting hell) with both repo links; document
  oc-rpc, mneme, lmcp; note companion hosts resolve via bullpen.conf.
- bullpen_config: add BULLPEN_OC_URL (default http://orca.fritz.box:4096) + test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
This commit is contained in:
Markus Fritsche
2026-07-24 09:19:41 +02:00
parent 61a46d651d
commit ce5f1369a3
6 changed files with 200 additions and 0 deletions
+31
View File
@@ -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 <host> …`; 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`.
+2
View File
@@ -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.
Executable
+164
View File
@@ -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 <ses> <text...> send a prompt (async; returns at once)
oc-rpc wait <ses> [--secs N] block until the current turn goes idle
oc-rpc read <ses> [--tail N] transcript (role + text/tool summaries)
oc-rpc todo <ses> the agent's current plan/todos
oc-rpc diff <ses> files changed this session
oc-rpc abort <ses> stop a runaway turn
oc-rpc share <ses> | unshare <ses> 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()
+1
View File
@@ -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")
+1
View File
@@ -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}"
+1
View File
@@ -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"