opencode-samcp 0.0.1__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.
- opencode_samcp/__init__.py +1 -0
- opencode_samcp/__main__.py +3 -0
- opencode_samcp/server.py +316 -0
- opencode_samcp-0.0.1.dist-info/METADATA +124 -0
- opencode_samcp-0.0.1.dist-info/RECORD +8 -0
- opencode_samcp-0.0.1.dist-info/WHEEL +4 -0
- opencode_samcp-0.0.1.dist-info/entry_points.txt +2 -0
- opencode_samcp-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""opencode-samcp: sub-agent MCP server for opencode."""
|
opencode_samcp/server.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""MCP server that lets any MCP client (Devin, Codex, Claude Code, ...) delegate coding tasks to opencode and manage its sessions."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
OPENCODE = os.environ.get("OPENCODE_BIN", "opencode")
|
|
16
|
+
DEFAULT_MODEL = os.environ.get("OPENCODE_MODEL") # unset -> opencode's configured default
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP("opencode-samcp")
|
|
19
|
+
JOBS: dict[str, dict] = {}
|
|
20
|
+
MAX_JOBS = int(os.environ.get("OPENCODE_MAX_JOBS", "200")) # finished jobs beyond this are evicted
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _clip(s, n):
|
|
24
|
+
s = s if isinstance(s, str) else json.dumps(s)
|
|
25
|
+
return s if len(s) <= n else s[:n] + f"... [+{len(s) - n} chars]"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_dir(directory):
|
|
29
|
+
d = os.path.abspath(os.path.expanduser(directory))
|
|
30
|
+
if not os.path.isdir(d):
|
|
31
|
+
raise ValueError(f"directory does not exist: {d}")
|
|
32
|
+
return d
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _cli(args, cwd):
|
|
36
|
+
proc = await asyncio.create_subprocess_exec(
|
|
37
|
+
OPENCODE, *args, cwd=cwd,
|
|
38
|
+
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
39
|
+
)
|
|
40
|
+
out, err = await proc.communicate()
|
|
41
|
+
if proc.returncode != 0:
|
|
42
|
+
raise RuntimeError(f"opencode {' '.join(args)} failed ({proc.returncode}): {err.decode()[-2000:]}")
|
|
43
|
+
return out.decode()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _handle_event(job, ev):
|
|
47
|
+
job["session_id"] = job["session_id"] or ev.get("sessionID")
|
|
48
|
+
part = ev.get("part") or {}
|
|
49
|
+
kind = ev.get("type")
|
|
50
|
+
if kind == "text" and part.get("text", "").strip():
|
|
51
|
+
job["texts"].append(part["text"].strip())
|
|
52
|
+
elif kind == "tool_use":
|
|
53
|
+
st = part.get("state") or {}
|
|
54
|
+
job["tools"].append({
|
|
55
|
+
"tool": part.get("tool"),
|
|
56
|
+
"status": st.get("status"),
|
|
57
|
+
"input": _clip(st.get("input"), 300),
|
|
58
|
+
"output": _clip(st.get("output") or "", 300),
|
|
59
|
+
})
|
|
60
|
+
elif kind == "step_finish":
|
|
61
|
+
tok = part.get("tokens") or {}
|
|
62
|
+
job["tokens"]["input"] += tok.get("input", 0)
|
|
63
|
+
job["tokens"]["output"] += tok.get("output", 0)
|
|
64
|
+
job["cost"] += part.get("cost") or 0
|
|
65
|
+
elif kind == "error":
|
|
66
|
+
err = ev.get("error") or {}
|
|
67
|
+
job["errors"].append(err.get("message") or json.dumps(err))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _run_job(job, args):
|
|
71
|
+
try:
|
|
72
|
+
proc = await asyncio.create_subprocess_exec(
|
|
73
|
+
OPENCODE, *args, cwd=job["directory"],
|
|
74
|
+
stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
75
|
+
limit=16 * 1024 * 1024, start_new_session=True,
|
|
76
|
+
)
|
|
77
|
+
job["proc"] = proc
|
|
78
|
+
stderr_task = asyncio.create_task(proc.stderr.read())
|
|
79
|
+
async for raw in proc.stdout:
|
|
80
|
+
line = raw.decode(errors="replace").strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
_handle_event(job, json.loads(line))
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
job["errors"].append(_clip(line, 500))
|
|
87
|
+
job["exit_code"] = await proc.wait()
|
|
88
|
+
stderr = (await stderr_task).decode(errors="replace").strip()
|
|
89
|
+
if job["exit_code"] != 0 and stderr:
|
|
90
|
+
job["errors"].append(_clip(stderr, 2000))
|
|
91
|
+
if job["status"] == "running":
|
|
92
|
+
job["status"] = "failed" if job["exit_code"] != 0 or (job["errors"] and not job["texts"]) else "completed"
|
|
93
|
+
except Exception as e: # noqa: BLE001 - surface any failure on the job instead of hanging
|
|
94
|
+
job["errors"].append(f"{type(e).__name__}: {e}")
|
|
95
|
+
if job["status"] == "running":
|
|
96
|
+
job["status"] = "failed"
|
|
97
|
+
proc = job.get("proc")
|
|
98
|
+
if proc and proc.returncode is None:
|
|
99
|
+
_kill_group(proc, signal.SIGKILL)
|
|
100
|
+
finally:
|
|
101
|
+
job["finished"] = time.time()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _kill_group(proc, sig):
|
|
105
|
+
try:
|
|
106
|
+
os.killpg(proc.pid, sig)
|
|
107
|
+
except (ProcessLookupError, PermissionError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _evict_old_jobs():
|
|
112
|
+
done = [j for j in JOBS.values() if j["status"] != "running"]
|
|
113
|
+
for j in sorted(done, key=lambda j: j["finished"] or 0)[: max(0, len(JOBS) - MAX_JOBS)]:
|
|
114
|
+
JOBS.pop(j["id"], None)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _report(job, full=True):
|
|
118
|
+
r = {
|
|
119
|
+
"job_id": job["id"],
|
|
120
|
+
"status": job["status"],
|
|
121
|
+
"session_id": job["session_id"],
|
|
122
|
+
"directory": job["directory"],
|
|
123
|
+
"title": job["title"],
|
|
124
|
+
"elapsed_s": round((job["finished"] or time.time()) - job["started"], 1),
|
|
125
|
+
}
|
|
126
|
+
if full:
|
|
127
|
+
r.update({
|
|
128
|
+
"final_text": job["texts"][-1] if job["texts"] else "",
|
|
129
|
+
"tool_calls": job["tools"][-40:],
|
|
130
|
+
"tool_call_count": len(job["tools"]),
|
|
131
|
+
"errors": job["errors"],
|
|
132
|
+
"tokens": job["tokens"],
|
|
133
|
+
"cost": round(job["cost"], 4),
|
|
134
|
+
})
|
|
135
|
+
if len(job["texts"]) > 1:
|
|
136
|
+
r["earlier_text"] = [_clip(t, 600) for t in job["texts"][-6:-1]]
|
|
137
|
+
return r
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _wait(job, wait_seconds):
|
|
141
|
+
try:
|
|
142
|
+
await asyncio.wait_for(asyncio.shield(job["task"]), timeout=max(wait_seconds, 0))
|
|
143
|
+
except asyncio.TimeoutError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@mcp.tool()
|
|
148
|
+
async def opencode_run(
|
|
149
|
+
prompt: str,
|
|
150
|
+
directory: str,
|
|
151
|
+
session_id: str | None = None,
|
|
152
|
+
fork: bool = False,
|
|
153
|
+
model: str | None = None,
|
|
154
|
+
agent: str | None = None,
|
|
155
|
+
title: str | None = None,
|
|
156
|
+
files: list[str] | None = None,
|
|
157
|
+
auto_approve: bool = True,
|
|
158
|
+
wait_seconds: int = 600,
|
|
159
|
+
) -> dict:
|
|
160
|
+
"""Send a coding task to opencode in `directory`.
|
|
161
|
+
|
|
162
|
+
Starts a new opencode session, or continues `session_id` (with `fork=True`, branches it first).
|
|
163
|
+
Waits up to `wait_seconds` for completion; if still running, returns status "running" with a
|
|
164
|
+
job_id — poll it with opencode_job. Use wait_seconds=0 to start in the background and run
|
|
165
|
+
several sessions in parallel (use separate git worktrees so they don't clash).
|
|
166
|
+
`auto_approve` lets opencode edit files/run commands without asking (required for headless use).
|
|
167
|
+
`model` is "provider/model"; defaults to opencode's configured model.
|
|
168
|
+
Returns the session_id, opencode's final message, a summary of tool calls, errors, and usage.
|
|
169
|
+
"""
|
|
170
|
+
if fork and not session_id:
|
|
171
|
+
raise ValueError("fork=True requires session_id")
|
|
172
|
+
d = _resolve_dir(directory)
|
|
173
|
+
args = ["run", "--format", "json"]
|
|
174
|
+
if auto_approve:
|
|
175
|
+
args.append("--auto")
|
|
176
|
+
if session_id:
|
|
177
|
+
args += ["--session", session_id]
|
|
178
|
+
if fork:
|
|
179
|
+
args.append("--fork")
|
|
180
|
+
model = model or DEFAULT_MODEL
|
|
181
|
+
if model:
|
|
182
|
+
args += ["--model", model]
|
|
183
|
+
if agent:
|
|
184
|
+
args += ["--agent", agent]
|
|
185
|
+
if title:
|
|
186
|
+
args += ["--title", title]
|
|
187
|
+
for f in files or []:
|
|
188
|
+
args += ["--file", f]
|
|
189
|
+
# opencode duplicates args after "--", so pass the prompt positionally; a leading space
|
|
190
|
+
# stops a prompt starting with "-" from being parsed as a flag.
|
|
191
|
+
args.append(" " + prompt if prompt.startswith("-") else prompt)
|
|
192
|
+
|
|
193
|
+
job = {
|
|
194
|
+
"id": uuid.uuid4().hex[:8], "status": "running", "directory": d, "title": title,
|
|
195
|
+
"session_id": None if fork else session_id, "texts": [], "tools": [], "errors": [],
|
|
196
|
+
"tokens": {"input": 0, "output": 0}, "cost": 0.0, "exit_code": None,
|
|
197
|
+
"started": time.time(), "finished": None, "proc": None,
|
|
198
|
+
}
|
|
199
|
+
job["task"] = asyncio.create_task(_run_job(job, args))
|
|
200
|
+
JOBS[job["id"]] = job
|
|
201
|
+
_evict_old_jobs()
|
|
202
|
+
await _wait(job, wait_seconds)
|
|
203
|
+
return _report(job)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@mcp.tool()
|
|
207
|
+
async def opencode_job(job_id: str, wait_seconds: int = 0) -> dict:
|
|
208
|
+
"""Get the status/result of an opencode_run job, optionally waiting up to `wait_seconds` for it to finish."""
|
|
209
|
+
job = JOBS.get(job_id)
|
|
210
|
+
if not job:
|
|
211
|
+
raise ValueError(f"unknown job_id {job_id}")
|
|
212
|
+
if job["status"] == "running" and wait_seconds > 0:
|
|
213
|
+
await _wait(job, wait_seconds)
|
|
214
|
+
return _report(job)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@mcp.tool()
|
|
218
|
+
async def opencode_jobs() -> list[dict]:
|
|
219
|
+
"""List opencode_run jobs started by this server (running and finished)."""
|
|
220
|
+
return [_report(j, full=False) for j in JOBS.values()]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@mcp.tool()
|
|
224
|
+
async def opencode_cancel(job_id: str) -> dict:
|
|
225
|
+
"""Stop a running opencode job. The session is kept and can be continued later."""
|
|
226
|
+
job = JOBS.get(job_id)
|
|
227
|
+
if not job:
|
|
228
|
+
raise ValueError(f"unknown job_id {job_id}")
|
|
229
|
+
if job["status"] == "running" and job["proc"] and job["proc"].returncode is None:
|
|
230
|
+
job["status"] = "cancelled"
|
|
231
|
+
_kill_group(job["proc"], signal.SIGTERM)
|
|
232
|
+
try:
|
|
233
|
+
await asyncio.wait_for(asyncio.shield(job["task"]), timeout=10)
|
|
234
|
+
except asyncio.TimeoutError:
|
|
235
|
+
_kill_group(job["proc"], signal.SIGKILL)
|
|
236
|
+
await job["task"]
|
|
237
|
+
return _report(job, full=False)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@mcp.tool()
|
|
241
|
+
async def opencode_sessions(directory: str, limit: int = 20) -> list[dict]:
|
|
242
|
+
"""List opencode sessions for the project containing `directory`, newest first."""
|
|
243
|
+
d = _resolve_dir(directory)
|
|
244
|
+
out = await _cli(["session", "list", "--format", "json", "-n", str(limit)], d)
|
|
245
|
+
return json.loads(out or "[]")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@mcp.tool()
|
|
249
|
+
async def opencode_transcript(session_id: str, directory: str, max_chars: int = 30000) -> dict:
|
|
250
|
+
"""Get a condensed transcript of an opencode session: user prompts, assistant text, and tool calls with results."""
|
|
251
|
+
d = _resolve_dir(directory)
|
|
252
|
+
data = json.loads(await _cli(["session", "export", session_id], d))
|
|
253
|
+
info = data.get("info", {})
|
|
254
|
+
lines = []
|
|
255
|
+
for msg in data.get("messages", []):
|
|
256
|
+
if msg.get("type") == "user":
|
|
257
|
+
lines.append(f"USER: {msg.get('text', '')}")
|
|
258
|
+
continue
|
|
259
|
+
for c in msg.get("content", []):
|
|
260
|
+
if c.get("type") == "text" and c.get("text", "").strip():
|
|
261
|
+
lines.append(f"ASSISTANT: {c['text'].strip()}")
|
|
262
|
+
elif c.get("type") == "tool":
|
|
263
|
+
st = c.get("state") or {}
|
|
264
|
+
result = " ".join(x.get("text", "") for x in st.get("content") or [] if isinstance(x, dict))
|
|
265
|
+
lines.append(f"TOOL {c.get('name')} [{st.get('status')}] {_clip(st.get('input'), 400)}"
|
|
266
|
+
f"\n -> {_clip(result, 400)}")
|
|
267
|
+
text = "\n".join(lines)
|
|
268
|
+
if len(text) > max_chars:
|
|
269
|
+
text = f"[... {len(text) - max_chars} earlier chars omitted ...]\n" + text[-max_chars:]
|
|
270
|
+
return {
|
|
271
|
+
"session_id": info.get("id", session_id),
|
|
272
|
+
"outcome": info.get("outcome"),
|
|
273
|
+
"model": info.get("model"),
|
|
274
|
+
"tokens": info.get("tokens"),
|
|
275
|
+
"cost": info.get("cost"),
|
|
276
|
+
"transcript": text,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@mcp.tool()
|
|
281
|
+
async def opencode_delete_session(session_id: str, directory: str) -> str:
|
|
282
|
+
"""Delete an opencode session and its child sessions."""
|
|
283
|
+
d = _resolve_dir(directory)
|
|
284
|
+
await _cli(["session", "delete", session_id], d)
|
|
285
|
+
return f"deleted {session_id}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def check_opencode() -> str:
|
|
289
|
+
"""Return the resolved opencode binary path, or exit with a clear error if it is missing."""
|
|
290
|
+
path = shutil.which(OPENCODE)
|
|
291
|
+
if not path:
|
|
292
|
+
sys.exit(
|
|
293
|
+
f"opencode-samcp failed: opencode not installed on machine "
|
|
294
|
+
f"('{OPENCODE}' not found on PATH). Install it from https://opencode.ai "
|
|
295
|
+
f"or set OPENCODE_BIN to its path."
|
|
296
|
+
)
|
|
297
|
+
return path
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main() -> None:
|
|
301
|
+
parser = argparse.ArgumentParser(prog="opencode-samcp", description=__doc__)
|
|
302
|
+
parser.add_argument("--transport", choices=["stdio", "sse", "streamable-http"], default="stdio")
|
|
303
|
+
parser.add_argument("--host", default="127.0.0.1", help="bind address for HTTP transports")
|
|
304
|
+
parser.add_argument("--port", type=int, default=8765, help="port for HTTP transports")
|
|
305
|
+
args = parser.parse_args()
|
|
306
|
+
check_opencode()
|
|
307
|
+
if args.transport != "stdio" and args.host not in ("127.0.0.1", "localhost", "::1"):
|
|
308
|
+
print(f"WARNING: serving unauthenticated MCP on {args.host}:{args.port}; anyone who can reach it "
|
|
309
|
+
"can run commands on this machine. Use localhost or an authenticated proxy.", file=sys.stderr)
|
|
310
|
+
mcp.settings.host = args.host
|
|
311
|
+
mcp.settings.port = args.port
|
|
312
|
+
mcp.run(transport=args.transport)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: opencode-samcp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Sub-agent MCP: let Devin, Codex, Claude Code and other agents spawn opencode sessions to do work
|
|
5
|
+
Project-URL: Homepage, https://github.com/amirdaraby/opencode-samcp
|
|
6
|
+
Project-URL: Repository, https://github.com/amirdaraby/opencode-samcp
|
|
7
|
+
Project-URL: Issues, https://github.com/amirdaraby/opencode-samcp/issues
|
|
8
|
+
Author: Amir Daraby
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,codex,devin,mcp,opencode,subagent
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: mcp<2,>=1.2
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# opencode-samcp
|
|
20
|
+
|
|
21
|
+
**S**ub-**A**gent **MCP** — an MCP server that lets an AI agent (Devin, Codex, Claude Code, Cursor, …) spawn [opencode](https://opencode.ai) sessions as sub-agents and hand them coding work.
|
|
22
|
+
|
|
23
|
+
The parent agent calls `opencode_run` with a prompt and a directory; opencode does the work headlessly (`opencode run --format json --auto`) and the result comes back as structured data: final message, tool-call summary, errors, token usage and cost. Sessions can be continued, forked, run in parallel in the background, inspected and deleted.
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- [opencode](https://opencode.ai) installed and authenticated (`opencode` on `PATH`, or set `OPENCODE_BIN`)
|
|
28
|
+
- [uv](https://docs.astral.sh/uv/) (or `pip install`)
|
|
29
|
+
|
|
30
|
+
## Install / run
|
|
31
|
+
|
|
32
|
+
The server is Python; every option below needs [uv](https://docs.astral.sh/uv/) and opencode on the machine.
|
|
33
|
+
|
|
34
|
+
| Method | Command |
|
|
35
|
+
|---|---|
|
|
36
|
+
| **npx** (no install) | `npx -y opencode-samcp` |
|
|
37
|
+
| **npm** (global) | `npm i -g opencode-samcp` then `opencode-samcp` |
|
|
38
|
+
| **uvx** from PyPI | `uvx opencode-samcp` |
|
|
39
|
+
| **uvx** from GitHub | `uvx --from git+https://github.com/amirdaraby/opencode-samcp opencode-samcp` |
|
|
40
|
+
| **pip** | `pip install opencode-samcp` then `opencode-samcp` |
|
|
41
|
+
|
|
42
|
+
The npm package is a thin launcher: it bundles the Python server and runs it through `uvx`.
|
|
43
|
+
If `uv` or `opencode` is missing, it exits with a clear "not installed on machine" error.
|
|
44
|
+
|
|
45
|
+
## Configure your client
|
|
46
|
+
|
|
47
|
+
### Devin
|
|
48
|
+
Settings → MCP Marketplace → *Add Your Own* (STDIO):
|
|
49
|
+
|
|
50
|
+
| Field | Value |
|
|
51
|
+
|---|---|
|
|
52
|
+
| Command | `npx` |
|
|
53
|
+
| Args | `-y opencode-samcp` |
|
|
54
|
+
|
|
55
|
+
Devin runs in its own VM, so opencode must be installed and logged in **there** (add it to the machine setup / snapshot).
|
|
56
|
+
|
|
57
|
+
### Codex (`~/.codex/config.toml`)
|
|
58
|
+
```toml
|
|
59
|
+
[mcp_servers.opencode]
|
|
60
|
+
command = "npx"
|
|
61
|
+
args = ["-y", "opencode-samcp"]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Claude Code
|
|
65
|
+
```bash
|
|
66
|
+
claude mcp add opencode -- npx -y opencode-samcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Generic JSON
|
|
70
|
+
```json
|
|
71
|
+
{ "mcpServers": { "opencode": { "command": "npx", "args": ["-y", "opencode-samcp"] } } }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### HTTP transport
|
|
75
|
+
For clients that only speak remote MCP:
|
|
76
|
+
```bash
|
|
77
|
+
opencode-samcp --transport streamable-http --host 127.0.0.1 --port 8765 # endpoint: /mcp
|
|
78
|
+
opencode-samcp --transport sse ... # endpoint: /sse
|
|
79
|
+
```
|
|
80
|
+
There is **no authentication** and the sub-agent can edit files and run commands — keep it on localhost or behind an authenticated tunnel/proxy.
|
|
81
|
+
|
|
82
|
+
## Tools
|
|
83
|
+
|
|
84
|
+
| Tool | Purpose |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `opencode_run` | Start a session (or continue `session_id`, optionally `fork`) in `directory`. Waits up to `wait_seconds` (default 600); `0` runs in the background. Supports `model`, `agent`, `title`, `files`, `auto_approve`. |
|
|
87
|
+
| `opencode_job` | Poll/wait on a background job by `job_id`. |
|
|
88
|
+
| `opencode_jobs` | List jobs started by this server. |
|
|
89
|
+
| `opencode_cancel` | Stop a running job (session is kept). |
|
|
90
|
+
| `opencode_sessions` | List opencode sessions for a project directory. |
|
|
91
|
+
| `opencode_transcript` | Condensed transcript of a session. |
|
|
92
|
+
| `opencode_delete_session` | Delete a session and its children. |
|
|
93
|
+
|
|
94
|
+
**Parallel work:** start several jobs with `wait_seconds=0`, each in its own git worktree so they don't clash, then poll with `opencode_job`.
|
|
95
|
+
|
|
96
|
+
## Environment variables
|
|
97
|
+
|
|
98
|
+
| Var | Meaning |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `OPENCODE_BIN` | Path to the opencode binary (default `opencode`) |
|
|
101
|
+
| `OPENCODE_MODEL` | Default `provider/model` when the caller doesn't pass one |
|
|
102
|
+
|
|
103
|
+
## Security
|
|
104
|
+
|
|
105
|
+
`auto_approve` defaults to `true` (required for headless runs): the sub-agent can modify files and run shell commands in the given directory. Only expose this server to agents you trust, and point it at disposable checkouts/worktrees where possible.
|
|
106
|
+
|
|
107
|
+
## Notes
|
|
108
|
+
|
|
109
|
+
- Job state is in-memory; it is lost when the server process restarts (opencode sessions themselves persist and can be resumed by `session_id`).
|
|
110
|
+
|
|
111
|
+
## Publishing (maintainers)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm publish # npm / npx
|
|
115
|
+
uv build && uv publish # PyPI / uvx / pip
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv run --group dev pytest
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
MIT licensed.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
opencode_samcp/__init__.py,sha256=QzEmjJlndNoQ3tEP0ULk7zzeca4UqZ1aC1lntWsGoTQ,57
|
|
2
|
+
opencode_samcp/__main__.py,sha256=qqHoLbN4n2aHyq2wCvGHw6ULuJ5t-x3VWquT2i9Qg4Q,47
|
|
3
|
+
opencode_samcp/server.py,sha256=3HsHHPEe0mhOhVgWt-cid3nmtJSXAJk7u3hDJLyzkmc,12036
|
|
4
|
+
opencode_samcp-0.0.1.dist-info/METADATA,sha256=-UHpqhS1K0IvjDih7qIlCtTznw7aTnIGTKaJctq1zac,4762
|
|
5
|
+
opencode_samcp-0.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
opencode_samcp-0.0.1.dist-info/entry_points.txt,sha256=OEYWXEAv0JuloHMlh1Nz99JFEXDwzun0knMxOGGpsOA,62
|
|
7
|
+
opencode_samcp-0.0.1.dist-info/licenses/LICENSE,sha256=8-L4qkT60D9VHJVWrvTsgBSK7i1Dm2x_iwuSdhNXY0o,1068
|
|
8
|
+
opencode_samcp-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amir Daraby
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|