@pinta-ai/pinta-opencode
v0.10.0
Published
OTLP forwarder + guard for opencode (in-process plugin)
Readme
pinta-opencode — OTLP forwarder + guard for opencode
Converts opencode session/tool events into OTLP/HTTP spans and forwards them to any OpenTelemetry-compatible collector, with an optional external guard that can allow/deny tool calls and surface a human-readable reason. Vendor-neutral. No Pinta CLI dependency. Identity is attached at the relay layer.
Unlike the Claude Code / Codex / Copilot adapters (which spawn a process per hook and talk over stdin/stdout), opencode loads plugins in-process. pinta-opencode is therefore a single importable plugin module — installed via opencode.json or a plugins-dir drop-in, no opencode source changes.
Status: spec complete and validated end-to-end against opencode 1.15.3. See
SPEC.md,PLAN.md, and the empirical record inHYPOTHESIS_VALIDATION.md(§10–13).
How it hooks in
opencode fires plugin hooks around every built-in and MCP tool. This adapter uses only non-experimental hooks:
| Hook | Adapter does | Verified payload |
|---|---|---|
| chat.message | start a new trace (turn boundary) | { sessionID, agent, model, messageID, variant } |
| chat.params | remember exact-message request model evidence (no extra span) | { sessionID, agent, model, message } |
| event | lifecycle span (Bronze flatten) + flush on session.idle | { event: { id, type, properties } }, every event carries properties.sessionID |
| tool.execute.before | query guard → throw on DENY + emit span | input { tool, sessionID, callID }, output { args } (full tool args, mutable) |
| tool.execute.after | tool-result span (incl. exit code) | output { title, output, metadata{ output, exit, truncated, … } } |
The
permission.askplugin hook is declared but never triggered in opencode — do not depend on it. Gating is done intool.execute.beforeonly.
Install
Add to global ~/.config/opencode/opencode.json (or a project opencode.json):
{
"plugin": [
["@pinta-ai/pinta-opencode", {
"endpoint": "https://your-collector.example.com/v1/traces",
"guard": "https://your-relay.example.com/guard"
}]
]
}opencode installs the npm package on demand and checks engines.opencode for compatibility. Alternatively drop a single file into ~/.config/opencode/plugins/ (global) or .opencode/plugins/ (project) — auto-discovered, no config edit.
Managed installs (Pinta Manager) inject the same
pluginline via the sidecar enroll module — no manual step.
Configuration
Resolution order: plugin options (2nd arg) → process.env → ~/.config/opencode/pinta-opencode.env (unset-only). Both options and env are visible at runtime (verified).
# ~/.config/opencode/pinta-opencode.env
PINTA_OPENCODE_ENDPOINT=https://your-collector.example.com/v1/traces
PINTA_OPENCODE_TOKEN=YOUR-TOKEN
# optional: external guard (allow/deny tool calls)
PINTA_OPENCODE_GUARD=https://your-relay.example.com/guard| Var (option / env) | Purpose |
|---|---|
| endpoint / PINTA_OPENCODE_ENDPOINT | Full OTLP/HTTP traces URL. Falls back to OTEL_EXPORTER_OTLP_TRACES_ENDPOINT → OTEL_EXPORTER_OTLP_ENDPOINT (+/v1/traces). No endpoint → telemetry disabled. |
| headers / PINTA_OPENCODE_HEADERS | key=val,key=val request headers (auth). Falls back to OTEL_EXPORTER_OTLP_HEADERS. |
| guard / PINTA_OPENCODE_GUARD | Optional. POST'd on tool.execute.before; a DENY blocks the tool. No endpoint → governance disabled. |
| token / PINTA_OPENCODE_TOKEN | Sent as x-pinta-relay-token on guard + OTLP. |
| PINTA_OPENCODE_GUARD_TIMEOUT_MS | Guard client timeout (default 50; 300 recommended in production for cold-start). |
| PINTA_OPENCODE_GUARD_DISABLED=1 | Force-disable the guard. |
Telemetry and governance are independent — endpoint only, guard only, both, or neither all work.
Guard (allow / deny + reason)
On tool.execute.before the adapter POSTs the span it is about to relay to the guard endpoint (cc/codex/copilot contract — @pinta-ai/core ≥ 0.8.0):
POST {guard} header: x-pinta-relay-token: {PINTA_RELAY_TOKEN}
body: the OTLP payload itself — { "resourceSpans": [ … one span, opencode.* attributes … ] }
200: { "decision": "ALLOW"|"DENY"|"REVIEW", "reason", "userMessage?", "durationMs?" }
410: the manager no longer accepts this body shape → recorded as fail-open `refused`The verdict is then attached to that same span (pinta.guard.*) before it is sent, so the span the guard judged is the span the collector stores.
A DENY becomes throw new Error(userMessage ?? reason ?? "guard_deny"). Verified effect: only that tool is blocked (tool.execute.after does not fire), the reason shows as ✗ … failed + Error: <reason> to the model/TUI, and the session stays alive. ALLOW/REVIEW pass through.
Guard is fail-open (no endpoint / PINTA_GUARD_DISABLED=1 / non-200 / timeout / error → allow), so it never breaks a session. The 50ms inline call does not block tool execution.
Span conventions
| Attribute | Value |
|---|---|
| ingest.type | "opencode" (aware-backend discriminator) |
| opencode.kind | event | tool.before | tool.after |
| opencode.event_type | event type (message.part.updated, session.idle, …) |
| opencode.<key> | every other field (Bronze flattening, raw key preserved) |
| pinta.guard.{decision,duration_ms,matched_rule,fail_open_reason} | guard result |
| service.name | "opencode" · telemetry.sdk.name "pinta-opencode" |
Tool spans are built from tool.execute.before/after (richer: args, output, exit) rather than the event bus; event covers lifecycle and turn boundaries.
Model evidence and limits
opencode.model is the exact scalar ID, never a serialized model object or an
unknown default. opencode.model_source records its provenance;
opencode.provider carries an explicitly supplied providerID.
| Evidence | Source / scope |
| --- | --- |
| message.updated.info.modelID (assistant) | reported:message.updated.info.modelID; exact session + assistant message |
| message.updated.info.model.{modelID,id} (user) | requested:message.updated.info.model.… |
| chat.message output message model, then input model | requested:chat.message.…; fallback only for that user message and agent, never its subagents |
| chat.params.model.{modelID,id} | requested:chat.params.model.…; same user-message + agent constraint |
| session.updated.info.model / session.next.model.switched.model | selected:<event>.<field> on that event only, not a default for tools |
| Explicit tool or other event model / modelID | reported:tool.… / reported:event.… |
The plugin callbacks
and message/part schemas
agree with this repository's phase1-e2e-events.jsonl. OpenCode constructs an
assistant's modelID from the selected model before the provider replies
(prompt implementation).
It is therefore reported selection, not proof of the actual routed
response model (notably for routers and fallback providers).
Tool hooks carry only sessionID + callID. They receive a model only when a
message.part.updated tool part joins that exact call to a message with model
evidence in the same session. Correlated opencode.message_id and
opencode.agent accompany it when available. No latest-message/session default,
parent-message model, or subtask's requested model is borrowed. Out-of-order
messages can enrich a later after-span, not retroactively invent a before-span.
Conflicting identities/models, missing joins and expired evidence are omitted.
The before model is pinned to its call; switches do not relabel in-flight tools.
Message-part updates/deltas likewise use only their own message's evidence.
Consumed calls cannot be reopened by delayed part events. Idle (session.idle
or session.status with status.type=idle), deleted and errored sessions clear
their state; a new chat/session start reopens them.
Memory is per plugin instance, capped globally at 1,024 message entries, 512 request entries, 2,048 call entries, 128 ended-session markers. Model, request and call entries expire 15 minutes after their last update. Identifiers over 1,024 characters are not cached or treated as model IDs. Normal resolution uses fixed field checks and O(1) map lookups; end/removal cleanup examines at most those bounded maps. No added file reads, host commands, network requests, timers or spans.
Blank/non-string/placeholder IDs (unknown, undefined, null, n/a, none,
-, auto, default) are omitted. Every trimmed ID beginning with { or [
is also omitted, including malformed/truncated JSON-like strings without a
closing brace. Objects/conflicting top-level model fields
remain in opencode.model_raw; raw nested info / part payloads stay intact.
Existing redaction, tool guards, event counts and errors are unchanged. A host
that never exposes a model (or its exact call/message relationship) stays
unattributed rather than being assigned an unrelated model.
Architecture
src/
├── plugin.ts # entry: export const PintaOpencode = async (input, options) => Hooks
├── config.ts # options → process.env → pinta-opencode.env (unset-only)
├── telemetry.ts # event → span (Bronze flatten), tool span from before/after
├── core/
│ ├── otlp.ts # Bronze flattening (opencode.*) + ingest.type + guard attrs
│ ├── trace.ts # ULID trace, keyed by sessionID, rotated on chat.message
│ ├── transport.ts # POST OTLP/HTTP traces (5s), in-memory retry queue
│ ├── retry-queue.ts # batched flush on next event
│ ├── guard.ts # POST PINTA_GUARD_ENDPOINT (50ms), fail-open
│ └── redact.ts # Tier-1 redaction + Tier-3 truncationState lives in memory (keyed by sessionID) — opencode instantiates the plugin once per instance, so per-event file persistence is unnecessary (optional via PINTA_PERSIST=1).
Development
bun install
bun run build # → dist/
bun testCompatibility: opencode >= 1.15.0 (engines.opencode). Uses only non-experimental hooks.
License
PolyForm Noncommercial 1.0.0 — see LICENSE. Commercial use is not permitted; contact Pinta AI for a commercial license.
