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:
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user