npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 in HYPOTHESIS_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.ask plugin hook is declared but never triggered in opencode — do not depend on it. Gating is done in tool.execute.before only.

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 plugin line 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_ENDPOINTOTEL_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 truncation

State 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 test

Compatibility: 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.