The opencode runtime loads plugins from .opencode/plugins/ (project) or ~/.config/opencode/plugins/ (global). Each plugin directory must contain an entrypoint (e.g., kasper.ts) that export defaults a function matching the plugin contract.
The loader at .opencode/plugins/kasper.ts re-exports from the compiled dist/index.js:
export { KasperPlugin } from "../dist/index.js"
export default KasperPluginOpenCode calls KasperPlugin() once per process, passing a { client, directory } object. The return value is a record of hook handlers that the runtime calls as events fire.
The plugin IS a singleton, but scoped to the opencode process, not the machine or the project. Every piece of mutable state is created inside the KasperPlugin closure and never escapes it:
KasperPlugin({ client, directory })
├── pendingEvals: Map<sessionID, PendingEval> // one entry per active session
├── agentRegistry: Map<sessionID, AgentSessionInfo> // agent type/name per session
├── parentToChildren: Map<sessionID, Set<sessionID>> // subagent tracking
├── sessionParents: Map<sessionID, sessionID> // child → parent lookup
├── deletedSessions: Set<sessionID> // tombstone set
├── sessionsEvaluated: Set<sessionID> // dedup set
├── improvementsPending: ImprovementRecord[] // manual-approval queue
├── lastIdleTimes: Map<sessionID, number> // debounce map
├── lastActiveSessionID: string // current session ref
├── stateStore: KasperStateStore // persisted state (singleton shared)
├── agentsMd: AgentsMdManager // AGENTS.md file ops (singleton shared)
├── agentPrompts: AgentPromptManager // per-agent prompt file ops (singleton shared)
├── scorer: Scorer // LLM-as-judge engine (stateless, safe)
└── logger: KasperLogger // JSON-line log writer (singleton shared)
All hooks share these references via the ctx: KasperContext object. This means:
- Same process, multiple sessions: All sessions (primary + subagents) share the same state objects. They are differentiated via Maps keyed by
sessionID. - Multiple processes: Each process gets its own independent copy of everything. They DO NOT coordinate.
The opencode runtime (and by extension the plugin) runs on Node.js/Bun's single-threaded event loop. There is no true parallelism — only cooperative concurrency via async/await.
Key property: no two pieces of synchronous JavaScript ever execute simultaneously within the same process. An await is the only suspension point where control yields to the event loop.
Because JS is single-threaded, Maps, Sets, and plain objects are safe from torn reads/writes as long as no await intervenes in the middle of a multi-step mutation. Let's audit each shared structure:
| Shared Structure | Hook Producers | Hook Consumers | Risk |
|---|---|---|---|
pendingEvals (Map) |
chat.message, tool.execute.after |
session.idle → runEvaluation() |
Moderate — consumed during idle, which is debounced. Both writers only mutate existing entries (append response parts, push tool calls). session.idle reads then deletes. No interleaving risk since each hook runs atomically per event. |
agentRegistry (Map) |
session.created, session.updated |
session.idle, chat.message, compaction |
Low — only set/delete mutations with primitive lookups. Immutable once written. |
parentToChildren / sessionParents (Maps) |
session.created, session.deleted |
aggregation queries | Low — simple set add/delete. |
deletedSessions (Set) |
session.deleted |
session.idle, chat.message, tool.execute.after |
Moderate — check-then-act pattern (check ctx.deletedSessions.has(sid) then proceed). Between the check and the act, the session could be deleted. However, since hooks are processed in the same event loop tick order, and session deletion comes from the same event source, this is unlikely. Still, a session.idle could fire while session.deleted is pending if they're in different microtask queues — possible gap. |
lastIdleTimes (Map) |
session.idle |
session.idle |
Low — simple read-write-update cycle for debouncing. Since idle events fire in sequence, no interleaving. |
stateStore (class) |
recordSession(), recordImprovement() |
All read methods | High — see §2.3 below. |
agentsMd / agentPrompts |
runEvaluation(), /kasper apply, /kasper rollback |
All reads | High — file system races. See §2.3. |
The KasperStateStore uses a dirty-flag + debounced flush pattern:
private markDirty(): void {
this.dirty = true
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), 2000)
}
}Scenario A — Concurrent writes before flush:
T0: recordSession("A") → markDirty() → sets dirty=true, starts 2s timer
T1: recordSession("B") → markDirty() → sets dirty=true (already true), timer already running
T2: (2s from T0) → flush() fires → writes state.json with BOTH A and B
✅ Safe. Multiple in-memory mutations accumulate before the single flush. The debounce coalesces writes.
Scenario B — Flush racing with a new write:
T0: recordSession("A") → markDirty()
T2: flush() fires → sets dirty=false, reads state, writes temp file
T2.5: recordSession("B") → markDirty() → sets dirty=true, starts new timer
T2.6: flush() renames temp → state.json (but state.json now missing session B)
🚨 DATA LOSS! The flush that started at T2 captured state BEFORE session B was recorded. Session B is in memory but the on-disk state.json doesn't include it. When the 2nd flush fires (T4.5), it WILL write session B... but if the process crashes between T2.6 and T4.5, session B is lost.
Mitigation: The window is small (2s), and Node.js event loop processes timers in order. The more realistic concern is:
Scenario C — Flush at destroy():
At close() hook, the plugin calls stateStore.destroy(). This clears the timer and calls flush() synchronously. If another hook fires after destroy starts but before the process exits, that state could be lost. However, this is a shutdown scenario — opencode should not fire hooks during shutdown.
AGENTS.md read/write:
AgentsMdManager.read()— doesreadFile(path, "utf-8"), no lockingAgentsMdManager.write(content)— doeswriteFile(path, content, "utf-8"), no lockingAgentsMdManager.backup(label)— doescopyFile(source, dest), no lockingAgentsMdManager.rollback()— reads backup, writes AGENTS.md
state.json read/write:
stateStore.init()— reads state.json on startupstateStore.flush()— atomic write via temp file + rename (see below)stateStore.flush()retry loop: 5 attempts with exponential backoff (10ms, 20ms, 30ms, 40ms, 50ms)
kasper.log write:
KasperLogger.log()— doesappendFile(logPath, line), no locking
Each write operation is atomic at the POSIX level (from the same process), but across processes there are no guarantees.
The state store uses a write-to-temp, sync, rename pattern:
const tmpPath = `${statePath}.tmp-${pid}-${timestamp}-${random}`
open(tmpPath, "w")
→ writeFile(JSON.stringify(state), "utf-8")
→ sync()
→ close()
rename(tmpPath, statePath) // POSIX: atomic when src/dst on same filesystemThe temp filename includes process.pid, Date.now(), and a random suffix, so two processes writing simultaneously will never collide on the temp file.
On Windows, rename can fail with EPERM/EBUSY if the target is held open by another process (e.g., antivirus, or another opencode instance reading state.json). The fallback in this case is a direct writeFile():
if (process.platform === "win32" && (errCode === "EPERM" || errCode === "EBUSY")) {
const data = await readFile(tmpPath, "utf-8")
await writeFile(statePath, data, "utf-8") // non-atomic fallback
return
}This is a best-effort compromise but NOT safe against inter-process races on Windows.
session.created → agentRegistry.set(sid, info)
parentToChildren.set(parent, child)
sessionParents.set(child, parent)
chat.message (user) → pendingEvals.set(sid, { instruction, ... })
chat.message (assistant) → pendingEvals.get(sid).agentResponseParts.push(...)
tool.execute.after → pendingEvals.get(sid).toolCalls.push(...)
session.idle → debounce check → runEvaluation(pendingEvals.get(sid))
→ pendingEvals.delete(sid)
→ stateStore.recordSession(sid, ...)
session.deleted → deletedSessions.add(sid)
pendingEvals.delete(sid)
agentRegistry.delete(sid)
parentToChildren cleanup
Subagents are detected via the parentID field on session.created:
const { parentID, agentName } = extractAgentInfo(input)
if (parentID) {
sessionParents.set(sessionID, parentID)
parentToChildren.get(parentID).add(sessionID)
}Each subagent gets its own sessionID, pendingEval entry, and evaluation. The parent-child relationship is tracked but currently used only for cleanup (when parent is deleted, children are cleaned up). It is not used for:
- Attributing subagent weaknesses to the parent session
- Cross-referencing scores in the aggregate
A PendingEval accumulates data over a session's lifetime:
- Created at first
chat.message(user) - Appended with each
chat.message(assistant) - Appended with each
tool.execute.after - Consumed at
session.idle(deleted from map)
If a session is very long (many messages/tool calls), the agentResponseParts array and toolCalls array grow unbounded. There is no trimming or summarization before evaluation. This could lead to:
- Very large prompts sent to the scoring LLM (cost + latency)
- Memory pressure in the Map
Compaction interaction: The experimental.session.compacting hook fires on the session, not the pendingEval. The pendingEval's accumulated response parts are independent of the compacted context. There's a possible mismatch: the scoring LLM evaluates the FULL raw chat history, while the agent's own context has been compacted.
Instance A: read state.json (sessions: 10)
Instance B: read state.json (sessions: 10)
Instance A: recordSession("A") → flush() → state.json now has 11 sessions
Instance B: recordSession("B") → flush() → state.json now has 11 sessions
→ Session A LOST
The atomic write protocol prevents corruption (no torn writes), but it does NOT prevent lost updates. Each flush overwrites the entire state.json file. Without a read-modify-write cycle under a lock, this is fundamentally vulnerable.
Instance A: read AGENTS.md → inject section → write AGENTS.md
Instance B: read AGENTS.md → inject section → write AGENTS.md
(Instance B's write overwrites A's changes)
The backup system captures the state before write, so rollback is available. But the last-writer-wins pattern means one instance's improvement is lost.
Instance A: appendFile("kasper.log", line_a)
Instance B: appendFile("kasper.log", line_b)
POSIX appendFile with O_APPEND on local filesystems is typically atomic for writes smaller than PIPE_BUF (4096 bytes on Linux, varying on other OS). Log lines are typically <1KB, so interleaving is unlikely. However, on Windows, appendFile behavior depends on the underlying libuv implementation. Line interleaving is possible but unlikely in practice.
Two separate opencode processes watching the same project will see different sessionID values for what a human would consider "the same session" (the same child window in the TUI). The kasper has no mechanism to correlate sessions across instances. Each instance maintains independent state.
| Resource | Risk | Severity | Mitigation Present | Suggested Fix |
|---|---|---|---|---|
| state.json | Lost updates | High | Atomic temp+rename | File lock (proper-lockfile, flock) or single-instance enforcement |
| AGENTS.md | Lost improvements | Medium | Backups before write | File lock or append-only model |
| kasper.log | Line interleaving | Low | O_APPEND semantics | Per-instance log files (instance-log-{pid}.log) |
| Backups dir | Duplicate backups | Low | Timestamp+random suffix | Separate backup directories per instance |
showToast(client, "Kasper", "AGENTS.md updated.", "success")
// → client.tui.showToast({ body: { title, message, variant, duration } })Toasts fire for:
- Low score (
<0.4→ warning toast) - Improvement pending (info toast, 6s)
- Improvement auto-applied (success toast, 6s)
- Manual
/kasper apply(success toast) - Manual
/kasper rollback(info toast)
- No progress indication during scoring (scoring takes seconds, user sees nothing)
- No inline display of scores (must use
/kasper status) - No visual indicator that updates happened to AGENTS.md (must check git diff)
- Toasts disappear — no persistent "last score: 72%" indicator
The DESIGN.html references a TUI plugin for a sidebar panel. The opencode plugin API exposes TuiPlugin for rendering UI. The kasper currently only uses:
tool()— register custom toolscommand.execute.before— intercept slash commandsconfig()— inject command configuration- Standard hooks (event, chat.message, tool.execute., session., compaction)
The compaction hook IS used to inject context:
"experimental.session.compacting": async (input, output) => {
// injects top weaknesses + avg score + per-agent stats into compaction context
output.context.push(lines.join("\n"))
}This is the kasper's feedback loop: the next compaction round includes kasper feedback, so the agent sees its own scores and recurring weaknesses. But this is invisible to the user (it's injected into the system prompt, not shown in chat).