Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ jobs:
run: npm run typecheck
- name: Unit tests
run: npm test
- name: OpenCode plugin regression tests
run: node --test bridge/tests/opencode-plugin.test.mjs
- name: Build
run: npm run build

Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div align="center">

<img src=".github/assets/banner.png" alt="cot. Self-hosted observability for Claude Code, Cursor, and Codex" width="100%">
<img src=".github/assets/banner.png" alt="cot. Self-hosted observability for Claude Code, Cursor, Codex, and OpenCode" width="100%">

<br>

Expand Down Expand Up @@ -37,7 +37,7 @@ One command:

1. starts the collector in Docker, bound to localhost
2. installs the local bridge
3. wires up hooks for the agents you pick: **Claude Code**, **Cursor**, or **Codex**
3. wires up the agents you pick: **Claude Code**, **Cursor**, **Codex**, or **OpenCode**

Cot runs in the background and collects traces while you build. Open the dashboard at **[http://127.0.0.1:31337](http://127.0.0.1:31337)**.

Expand All @@ -47,6 +47,13 @@ Cot runs in the background and collects traces while you build. Open the dashboa

The default port is **31337**. If it is busy, the installer picks the next free port and saves the URL in `~/.cot/config.json`.

OpenCode 1.18.29+ and OpenCode 2 use one local plugin file. The bridge installer
places it at `~/.config/opencode/plugins/cot.js`. To add it manually after
installing the cot bridge, download
`http://127.0.0.1:31337/opencode-plugin.js` (using your chosen port), save it
at that path, and restart
OpenCode. OpenCode sessions recorded before plugin installation are not imported.

| Command | What it does |
| --- | --- |
| `cot up` | Start or resume the collector |
Expand All @@ -57,7 +64,7 @@ The default port is **31337**. If it is busy, the installer picks the next free

```mermaid
flowchart LR
A["Claude Code<br/>Cursor<br/>Codex"] -- hook fires --> B["~/.cot/bin/cot<br/><sub>local bridge</sub>"]
A["Claude Code<br/>Cursor<br/>Codex<br/>OpenCode"] -- hook/plugin event --> B["~/.cot/bin/cot<br/><sub>local bridge</sub>"]
B -- "POST /v1/ingest" --> C["Collector<br/><sub>FastAPI · 127.0.0.1:31337</sub>"]
C --> D[("~/.cot/cot.db<br/><sub>SQLite</sub>")]
D --> E["Dashboard<br/><sub>React</sub>"]
Expand Down
13 changes: 11 additions & 2 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,7 @@ def _recategorize_other_tools(conn: sqlite3.Connection) -> None:
except json.JSONDecodeError:
continue
cat = categorize(row["source"], row["hook"], raw, row["tool"])
if cat["category"] == "other":
if cat["category"] == "other" and not (row["source"] == "opencode" and cat["status"] == "error"):
continue
conn.execute(
"UPDATE events SET category=?, title=?, detail=?, target=?, status=?,"
Expand Down Expand Up @@ -1158,7 +1158,7 @@ def _question_response_obj(detail: Any) -> dict[str, Any] | None:
return obj if isinstance(obj, dict) else None


_MIGRATIONS_VERSION = "9"
_MIGRATIONS_VERSION = "10"
_RAW_PAYLOAD_MAX_BYTES = 64 * 1024


Expand Down Expand Up @@ -2048,6 +2048,15 @@ def record_event(
(norm["cwd"], sid),
)

if norm["source"] == "opencode" and raw.get("parent_session_id"):
parent = str(raw["parent_session_id"])
if parent != sid:
conn.execute(
"UPDATE sessions SET parent_session_id = ?,"
" subagent_label = COALESCE(subagent_label, ?) WHERE id = ?",
(parent, raw.get("session_title"), sid),
)

# Imported events carry historical timestamps; ensure the session's
# started_at reflects the earliest event we've seen.
if origin == "import":
Expand Down
2 changes: 1 addition & 1 deletion backend/app/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ def _trend_anomaly(snap: Snapshot) -> list[dict[str, Any]]:

# Paths agents legitimately edit outside the project (configs, temp, cot itself).
_OUT_OF_CWD_EXCLUDES = re.compile(
r"/\.(?:claude|cursor|codex|config|cot|cache)(?:/|$)|^/(?:private/)?tmp(?:/|$)|^/var/folders/"
r"/\.(?:claude|cursor|codex|opencode|config|cot|cache)(?:/|$)|^/(?:private/)?tmp(?:/|$)|^/var/folders/"
)


Expand Down
30 changes: 25 additions & 5 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

app = FastAPI(title="cot collector", version=__version__)

AGENTS = ("claude", "cursor", "codex")
AGENTS = ("claude", "cursor", "codex", "opencode")
EXPECTED_HOOKS = {
"claude": [
"SessionStart",
Expand Down Expand Up @@ -79,6 +79,11 @@
"SubagentStop",
"Stop",
],
"opencode": [
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse",
"PostToolUseFailure", "afterAgentResponse", "afterAgentThought",
"OpenCodeUsage", "PermissionRequest", "PostCompact", "Stop",
],
}
HOOK_LABELS = {
"SessionStart": "Session start",
Expand Down Expand Up @@ -106,6 +111,7 @@
"subagentStop": "Subagent finish",
"preCompact": "Compaction start",
"stop": "Session stopped",
"OpenCodeUsage": "Model usage",
}

# --- Local-only network hardening -------------------------------------------
Expand Down Expand Up @@ -522,7 +528,7 @@ def get_settings() -> dict[str, Any]:
}


_ONBOARDING_AGENTS = ("claude", "cursor", "codex")
_ONBOARDING_AGENTS = AGENTS


def _stored_onboarding_agents() -> list[str]:
Expand Down Expand Up @@ -761,6 +767,14 @@ def bridge_script() -> FileResponse:
return FileResponse(script, media_type="text/plain")


@app.get("/opencode-plugin.js")
def opencode_plugin() -> FileResponse:
script = _BRIDGE_DIR / "opencode-plugin.js"
if not script.exists():
raise HTTPException(status_code=404, detail="OpenCode plugin not found")
return FileResponse(script, media_type="text/javascript")


async def _json_body(request: Request) -> dict[str, Any]:
try:
body = await request.json()
Expand All @@ -785,7 +799,7 @@ async def _ingest_body(request: Request) -> tuple[dict[str, Any] | None, str | N

@app.post("/v1/ingest/{source}")
async def ingest(source: str, request: Request) -> dict[str, Any]:
if source not in ("claude", "cursor", "codex"):
if source not in AGENTS:
raise HTTPException(status_code=404, detail=f"Unknown source: {source}")
body, malformed_error = await _ingest_body(request)
if body is None:
Expand Down Expand Up @@ -1218,7 +1232,13 @@ def get_hook_status() -> dict[str, Any]:
connected = bool(conn.get("connected"))
manifest_installed = bool(entry.get("installed"))
installed = manifest_installed or (not manifest_agents and events > 0)
if not installed and events == 0:
if source == "opencode" and source not in manifest_agents and events > 0:
# A manually copied plugin has no bridge-written manifest. Its
# events prove installation only when no explicit entry exists.
installed = True
installed_hooks = expected_hooks
missing_hooks = []
if not installed:
health = "not_installed"
elif missing_hooks:
health = "missing_hooks"
Expand Down Expand Up @@ -1259,7 +1279,7 @@ def get_hook_status() -> dict[str, Any]:
"updated_at": manifest.get("updated_at"),
"endpoint": manifest.get("endpoint"),
"manifest_found": bool(manifest_agents),
"repair_all_url": "/repair.sh?agents=claude,cursor,codex",
"repair_all_url": "/repair.sh?agents=claude,cursor,codex,opencode",
"agents": agents,
}

Expand Down
29 changes: 20 additions & 9 deletions backend/app/normalize.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Translate raw Claude Code / Cursor / Codex hook payloads into a common shape + category."""
"""Translate agent hook payloads into a common shape and category."""

from __future__ import annotations

Expand All @@ -19,7 +19,7 @@
subagent_label as _subagent_label,
)

Source = str # 'claude' | 'cursor' | 'codex'
Source = str # 'claude' | 'cursor' | 'codex' | 'opencode'
LifecycleBoundary = Literal["session_start", "turn_end", "session_end"]
APPROVAL_REVIEW_PREFIX = "The following is the Codex agent history"

Expand Down Expand Up @@ -66,7 +66,7 @@ def lifecycle_boundary(source: Source, hook: str) -> LifecycleBoundary | None:
if hook in ("SessionEnd", "sessionEnd"):
return "session_end"
if hook in ("Stop", "stop"):
return "turn_end" if source == "claude" else "session_end"
return "turn_end" if source in ("claude", "opencode") else "session_end"
return None


Expand Down Expand Up @@ -159,6 +159,16 @@ def categorize(source: Source, hook: str, body: dict[str, Any], tool: str | None
"duration_ms": duration_ms,
}

if hook == "OpenCodeUsage":
return {
"category": "lifecycle",
"title": "Model usage",
"target": body.get("model"),
"detail": _json_detail(body.get("usage") or {}),
"status": "ok",
"duration_ms": duration_ms,
}

# --- Prompts ---
if hook in ("UserPromptSubmit", "beforeSubmitPrompt"):
prompt = body.get("prompt") or body.get("user_message") or ""
Expand Down Expand Up @@ -216,12 +226,13 @@ def categorize(source: Source, hook: str, body: dict[str, Any], tool: str | None
"duration_ms": duration_ms,
}
if boundary == "turn_end":
turn_status = "error" if body.get("error") else "interrupted" if body.get("interrupted") else "ok"
return {
"category": "lifecycle",
"title": "Turn ended",
"title": "Turn failed" if turn_status == "error" else "Turn interrupted" if turn_status == "interrupted" else "Turn ended",
"target": None,
"detail": _json_detail(body),
"status": "ok",
"status": turn_status,
"duration_ms": duration_ms,
}

Expand Down Expand Up @@ -271,7 +282,7 @@ def categorize(source: Source, hook: str, body: dict[str, Any], tool: str | None
}

# --- Tool calls ---
tool_name = _canonical_tool(tool or body.get("tool_name") or "")
tool_name = _canonical_tool(tool or body.get("tool_name") or "", source)
tool_input = _coerce_tool_input(body.get("tool_input"))
tool_response = body.get("tool_response") or body.get("tool_output") or {}

Expand Down Expand Up @@ -339,7 +350,7 @@ def categorize(source: Source, hook: str, body: dict[str, Any], tool: str | None
"title": hook,
"target": tool or body.get("tool_name"),
"detail": _json_detail(body),
"status": "ok",
"status": "error" if _is_failure(hook) else "ok",
"duration_ms": duration_ms,
}

Expand Down Expand Up @@ -412,8 +423,8 @@ def normalize(source: Source, body: dict[str, Any] | None) -> dict[str, Any]:
cwd = roots[0]
tool = _cursor_tool(hook, body)
else:
# Claude Code and Codex both ride Claude-Code-style stdin payloads.
if source != "codex":
# Claude Code, Codex, and OpenCode use the same bridge payload shape.
if source not in ("codex", "opencode"):
source = "claude"
session_id = body.get("session_id") or "unknown"
cwd = body.get("cwd")
Expand Down
17 changes: 14 additions & 3 deletions backend/app/tool_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,18 @@ def _matches(path: str | None, patterns: tuple[re.Pattern[str], ...]) -> bool:
return any(p.search(path.replace("\\", "/")) for p in patterns)


def canonical_tool(name: str | None) -> str:
_OPENCODE_TOOL_ALIASES = {
"bash": "Bash", "shell": "Shell", "read": "Read", "write": "Write",
"edit": "Edit", "multiedit": "MultiEdit", "glob": "Glob", "grep": "Grep",
"list": "Search", "webfetch": "WebFetch", "websearch": "WebSearch",
"task": "Task", "subagent": "Subagent", "question": "AskUserQuestion",
"todowrite": "TodoWrite", "todoread": "TodoRead", "skill": "Skill",
}


def canonical_tool(name: str | None, source: str | None = None) -> str:
if source == "opencode":
name = _OPENCODE_TOOL_ALIASES.get(name or "", name or "")
return _TOOL_ALIASES.get(name or "", name or "")


Expand Down Expand Up @@ -163,7 +174,7 @@ def extract_path(body: dict[str, Any], tool_input: dict[str, Any] | None = None)
return val
inputs = tool_input if isinstance(tool_input, dict) else body.get("tool_input")
if isinstance(inputs, dict):
for key in ("file_path", "path", "notebook_path"):
for key in ("file_path", "filePath", "path", "notebook_path"):
val = inputs.get(key)
if isinstance(val, str) and val:
return val
Expand Down Expand Up @@ -330,7 +341,7 @@ def classify_tool(invocation: ToolInvocation) -> dict[str, Any] | None:
"title": label,
"target": key or label,
"detail": json_detail({"input": tool_input, "response": tool_response}),
"status": "ok",
"status": status,
"duration_ms": duration_ms,
}

Expand Down
Loading
Loading