Documentation
¶
Overview ¶
events.go is the Go-side reader for the bounded run-dir events file plugin/nightgauge/session.js (#1641) writes: one append-only JSONL file per OpenCode run, named "opencode-events-<RUN_ID>.jsonl" beside the run's NIGHTGAUGE_OUTPUT_FILE. It is the only producer of compaction telemetry — the JSON run stream never carries it — and #1653 reads it back through ReadRunEvents and CompactionCount.
The file's own retention contract (security constraint, #1641): it never contains transcript, summary, tool output or prompt text, only ids, counts and verdict codes, and is capped at 1 MiB, after which the writer appends one "truncated" line and stops. ReadRunEvents enforces the reading half of that contract defensively — a line whose top level or whose "detail" object carries a "text" field is dropped rather than returned, so a buggy or tampered writer can never smuggle free text past this reader into #1653's telemetry.
There are TWO writers, not one (#1810). session.js writes every event it can decide synchronously, inside the opencode process. The one event it cannot — "stop_verify", whose verdict is a separate `nightgauge hook stop-verify` process's answer — is written by THAT process, through AppendRunEvent below. The reason is measured, not stylistic: opencode 1.18.30 does NOT await the promise a plugin's `event` hook returns, and a one-shot `opencode run` exits within ~10 ms of publishing session.idle (probe recorded on #1810: a continuation scheduled 20 ms out never runs at all). Neither awaiting the verb inline nor recording it from a .then() continuation can survive that exit; a child process can, so the child owns the line.
The consequence for READERS — #1653 included — is that the events file finalizes shortly AFTER the opencode CLI exits, not before it. Read it with WaitForRunEvent, which polls for the terminal "stop_verify" line within a bound, rather than with a bare ReadRunEvents the instant the CLI returns.
Package opencodeplugin embeds the Nightgauge OpenCode plugin (#1635): the entry module and its gate that give an OpenCode stage the same careful-gate every Claude Code stage runs through hooks.json, since OpenCode never reads Claude Code hooks and its only extension point is an in-process JS plugin (tool.execute.before, blocking a tool by throwing).
Write copies the embedded plugin/ tree into the run's per-run OpenCode plugin directory — never installed from npm or Bun — so opencode 1.18.30 loads it as a local plugin file, named directly in the per-run config's `plugin` array (internal/execution/adapters/opencode.go). A startup handshake (VerifyLoaded, VerifyNotLate) lets the manager's stream loop (internal/execution/manager.go) confirm the plugin actually initialized before any tool call could run, and fail the stage closed — never silently ungated — when it did not.
Index ¶
- Constants
- Variables
- func AppendRunEvent(path string, ev Event) error
- func CompactionCount(path string) (int, error)
- func DeleteStaleSentinel(path string) error
- func DepsPackageVersion() (string, error)
- func EventsFileName(runID string) string
- func EventsPath(outputFile, runID string) (path string, ok bool)
- func Files() (fs.FS, error)
- func NewNonce() (string, error)
- func OperatorInstallSatisfied(dir string) bool
- func RunEventsPathFromEnv() (path string, ok bool)
- func SanitizeEventID(id string) string
- func SentinelPath(outputFile, runDir, runID string) string
- func VerifyLoaded(cfg HandshakeConfig) error
- func VerifyNotLate(cfg HandshakeConfig, firstToolUse time.Time) error
- func Write(dir string) (string, error)
- func WriteDependencies(dir string) error
- type Event
- type HandshakeConfig
- type IncompatibleError
- type Sentinel
Constants ¶
const ( RunOutputFileEnvVar = "NIGHTGAUGE_OUTPUT_FILE" RunIDEnvVar = "NIGHTGAUGE_RUN_ID" )
RunOutputFileEnvVar and RunIDEnvVar are the two variables every adapter already exports on a dispatch, and the only inputs the events file's path has. A `nightgauge hook` verb spawned by the plugin inherits both from the opencode process, so the child resolves the SAME file the plugin does without any new plumbing.
const ( EnvNonce = "NIGHTGAUGE_OPENCODE_PLUGIN_NONCE" EnvSentinel = "NIGHTGAUGE_OPENCODE_PLUGIN_SENTINEL" EnvPluginPath = "NIGHTGAUGE_OPENCODE_PLUGIN_PATH" // EnvOperatorInstallRisk names, when set, the operator-owned OpenCode // config directory ($HOME/.opencode, or an inherited OPENCODE_CONFIG_DIR) // this run's config puts at risk of OpenCode's own @opencode-ai/plugin // install waiting on the registry (#1635/A11 round 6, ADR-022 amendment // 2026-09-15, narrowed AC1: Nightgauge never seeds or merges into either // directory). adapters.operatorInstallRisk sets it (read-only — the // directory is never written); manager.go reads it back to bound the // wait and classify a stage that never produces output as // adapter_incompatible instead of an unclassified hang. EnvOperatorInstallRisk = "NIGHTGAUGE_OPENCODE_OPERATOR_INSTALL_RISK" )
Environment variable names opencode.go (adapters.OpenCodeAdapter.PrepareRunRoot) sets on the run and a manager reads back — a single Go-side source for both the nonce and the sentinel's path, so the plugin process (which reads these two variables verbatim) and the verifier can never disagree on a path formula reimplemented in two languages.
const DepsVersion = "1.18.30"
DepsVersion is the exact @opencode-ai/plugin version depsArchive holds — the tested opencode version (ADR-022 § 20). It MUST match the "version" field of the archive's own node_modules/@opencode-ai/plugin/package.json; TestDepsArchiveMatchesPinnedVersion compares them.
const EntryFile = "nightgauge.js"
EntryFile is the plugin file the per-run config's `plugin` array names.
const EventsTruncatedKind = "truncated"
EventsTruncatedKind is the kind the writer appends, at most once per run, once the events file reaches its 1 MiB cap; every write after it is skipped.
const PluginVersion = "1"
PluginVersion is the handshake's plugin_version. It MUST match NIGHTGAUGE_PLUGIN_VERSION in plugin/nightgauge.js byte for byte (TestPluginVersionMatchesGo).
Variables ¶
var EventKinds = []string{"compaction", "idle", "stop_verify", "permission_ask", "skill"}
EventKinds are the five event kinds session.js emits during a normal run. EventsTruncatedKind is a sixth, terminal kind: the writer's own cap sentinel, never counted as one of these five.
var HookNames = []string{
"tool.execute.before",
"tool.execute.after",
"command.execute.before",
"permission.ask",
"event",
"experimental.session.compacting",
"experimental.compaction.autocontinue",
}
HookNames are every hook nightgauge.js registers, in registration order: the handshake sentinel's "hooks" field, and what TestPluginRegistersGoldenHooks compares plugin/nightgauge.js's own NIGHTGAUGE_HOOK_NAMES against.
Functions ¶
func AppendRunEvent ¶
AppendRunEvent appends one event line to path, honouring the same cap and "truncated" sentinel session.js's own appendEvent enforces: at or past eventsMaxBytes it appends one truncated line — and only one, whatever the process boundary, since it re-reads the file's last line rather than trusting in-process state the way the JS side's module-level flag can — and writes nothing further.
It refuses to write a detail that violates the retention contract (detailHoldsOnlyScalars), so the two independent writers cannot disagree about what may be recorded. An empty path is a no-op, not an error: the events file is simply disabled for this run.
func CompactionCount ¶
CompactionCount reports how many "compaction" events path holds.
func DeleteStaleSentinel ¶
DeleteStaleSentinel removes any sentinel left at path by an earlier spawn that shared the same root, so a stale file can never be misread as this run's handshake. A missing file is not an error.
func DepsPackageVersion ¶
DepsPackageVersion reads the "version" field of depsArchive's own node_modules/@opencode-ai/plugin/package.json without extracting anything to disk — what TestDepsArchiveMatchesPinnedVersion compares DepsVersion against, so the two can never drift apart unnoticed.
func EventsFileName ¶
EventsFileName is the writer's own filename formula for one run.
func EventsPath ¶
EventsPath mirrors the writer's own path formula (session.js's eventsPath): the events file lives beside outputFile, named EventsFileName(runID). ok is false — the file is disabled, exactly as the writer disables itself — when outputFile is not an absolute path, when it carries a literal ".." path segment, or when either argument is empty.
func Files ¶
Files returns the embedded plugin tree, rooted at nightgauge.js (i.e. Files().Open("nightgauge.js") and Files().Open("nightgauge/gates.js")).
func OperatorInstallSatisfied ¶
OperatorInstallSatisfied reports, READ-ONLY, whether dir — an operator-owned OpenCode config directory ($HOME/.opencode, or an inherited OPENCODE_CONFIG_DIR under opencode.inherit_user_config) — already satisfies opencode 1.18.30's own "is @opencode-ai/plugin already installed" check (`Npm.install`, pulled from the strings of the pinned binary and driven against it directly — #1635/A11 fix round, correcting round 8). That check:
- is satisfied immediately, without installing anything, if dir itself is not writable (dirUnwritable);
- is unsatisfied if dir/node_modules is absent;
- otherwise is satisfied unless some dependency NAME in dir/package.json — the "dependencies", "devDependencies", "peerDependencies" and "optionalDependencies" blocks, plus @opencode-ai/plugin itself — is missing from dir/package-lock.json's own root ("") package entry.
It never compares versions, and never reads dir/node_modules/.package-lock.json or the installed package's own version marker at all: round 8's predicate — all four files exist AND the marker equals DepsVersion exactly — checked files this reads nothing like, and read both directions wrong (deps_test.go's TestOperatorInstallSatisfiedMatchesOpenCodesOwnInstallCheck holds the measured cases). Nightgauge never seeds or merges anything into such a directory (#1635/A11 round 6, ADR-022 amendment 2026-09-15, narrowed AC1 — an earlier round did, and the archive that made that safe for a directory holding the operator's own tool/plugin files is removed); this exists only for adapters.InstallNightgaugePlugin (via operatorInstallRisk), never to write there.
func RunEventsPathFromEnv ¶
RunEventsPathFromEnv resolves this process's run events file from the environment, applying exactly EventsPath's rules. ok is false — the events file is disabled, not an error — whenever this process was not spawned inside a Nightgauge run, or the run's output file is not an absolute, ".."-free path.
func SanitizeEventID ¶
SanitizeEventID returns id when it is an opaque identifier, and "" when it is anything else.
func SentinelPath ¶
SentinelPath is the handshake sentinel's path for one run: beside outputFile when the dispatch has one (the plugin's own convention, ".opencode-plugin-<RUN_ID>.json"), else inside runDir — a dispatch with no output file (e.g. the `nightgauge opencode config` SDK-parity path, which never actually spawns opencode) still gets a deterministic, writable location rather than an empty path.
func VerifyLoaded ¶
func VerifyLoaded(cfg HandshakeConfig) error
VerifyLoaded is the step_start check: an opencode run never emits tool_use before its first step_start event, so calling this the instant that event is observed proves the plugin had already run its init (and so written the sentinel) before any tool call could exist. The sentinel must carry this run's own nonce and the plugin version this binary embeds; anything else means the plugin failed to load, loaded late, or a stale/foreign sentinel is being read.
func VerifyNotLate ¶
func VerifyNotLate(cfg HandshakeConfig, firstToolUse time.Time) error
VerifyNotLate re-checks the same sentinel once the run has exited: its mtime must precede firstToolUse, the moment a caller first observed a tool_use event on the run's stream. A late sentinel means some tool ran before this exact file was written — VerifyLoaded's step_start check alone cannot see that, since it only proves a sentinel existed by the first step_start, not that it stayed the one gating every tool after it.
firstToolUse.IsZero() (no tool call was ever observed) always passes: there is nothing a late sentinel could have let through.
func Write ¶
Write copies the embedded plugin tree into dir (created 0700; every file 0600) so opencode loads it as a local plugin file rather than an npm or Bun package. Returns the absolute path to EntryFile, the value the per-run config's `plugin` array names.
Write always rewrites every file, so a plugin version upgrade between two runs that share a stale PluginDir never leaves an old file mixed in with the new one.
func WriteDependencies ¶
WriteDependencies extracts depsArchive into dir — a run's own OpenCode config directory, the exact directory 1.18.30 would otherwise install @opencode-ai/plugin into — creating node_modules/, package.json and package-lock.json there. Every directory is created 0700, every file 0600, matching Write's plugin-tree permissions. Best-effort by contract with the caller (adapters.InstallNightgaugePlugin): an error here must never fail a dispatch, since a target directory 1.18.30 finds unsatisfied still falls back to its own install.
dir must be this run's own, freshly-created directory — never an operator-owned one. See the package doc comment for why: depsArchive's node_modules/@opencode-ai/plugin holds only a version marker, no dist/, which is safe only where nothing but the embedded Nightgauge plugin ever imports it. Nightgauge never writes into an operator-owned directory at all (OperatorInstallSatisfied is read-only).
A path in the archive that would escape dir is refused rather than written — the archive is embedded and fixed at build time, so this can only ever catch a corrupt build, never anything a run's own environment or a target repository controls.
Types ¶
type Event ¶
type Event struct {
V int `json:"v"`
TS string `json:"ts"`
Kind string `json:"kind"`
SessionID string `json:"session_id,omitempty"`
Child bool `json:"child,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
}
Event is one line of the run's events file.
func ReadRunEvents ¶
ReadRunEvents parses path's JSONL events. A line that is not valid JSON, or whose top level or "detail" object carries a "text" key, is dropped rather than returned or treated as a read failure: the retention contract is enforced by exclusion, not by failing the whole read over one bad line. A missing file is not an error: ([]Event)(nil), nil.
func WaitForRunEvent ¶
WaitForRunEvent reads path until it holds at least one event of kind, or bound expires, and returns whatever it last read either way. It exists because the events file's terminal "stop_verify" line is written by the `nightgauge hook stop-verify` child, which by design outlives the opencode CLI that spawned it (see this file's own header): a reader that runs the instant the CLI exits is racing that child, not observing a lost event. A bound expiring is not an error — the caller sees the events it did get.
type HandshakeConfig ¶
HandshakeConfig is what a manager needs to verify one opencode run's plugin handshake.
func HandshakeConfigFromEnv ¶
func HandshakeConfigFromEnv(env map[string]string) (cfg HandshakeConfig, ok bool)
HandshakeConfigFromEnv reads a HandshakeConfig from a run's environment map (adapters.RunRoot.Env, or any map carrying the same keys). ok is false when the run carries no handshake at all: no run identity was minted for the dispatch, or the run is not an OpenCode dispatch.
type IncompatibleError ¶
IncompatibleError is a failed handshake check: the plugin did not load, loaded late, or a stale/foreign sentinel was read.
func (*IncompatibleError) Error ¶
func (e *IncompatibleError) Error() string
func (*IncompatibleError) Kind ¶
func (e *IncompatibleError) Kind() string
Kind reports incompatibleKind, matching adapters.OpenCodeIncompatible.
Directories
¶
| Path | Synopsis |
|---|---|
|
depsdata
|
|
|
regenerate
command
Command regenerate rebuilds internal/execution/opencodeplugin/depsdata/opencode-ai-plugin-<version>.tar.gz, the embedded, version-pinned dependency archive opencodeplugin.WriteDependencies extracts (#1635, ADR-022 amendment 2026-09-14, round 2 "trim the embedded dependency tree"; #1635/A11 round 6, ADR-022 amendment 2026-09-15, "operator directories are never merged into").
|
Command regenerate rebuilds internal/execution/opencodeplugin/depsdata/opencode-ai-plugin-<version>.tar.gz, the embedded, version-pinned dependency archive opencodeplugin.WriteDependencies extracts (#1635, ADR-022 amendment 2026-09-14, round 2 "trim the embedded dependency tree"; #1635/A11 round 6, ADR-022 amendment 2026-09-15, "operator directories are never merged into"). |