// notify.ts — ntfy push when OpenCode needs a decision or runs something risky. // // OpenCode 2.x plugin (see docs/OPS-opencode-v2-verify.md, design D1/D2). // // PORTED FROM V1. The V1 build exported a bare async function returning a hook map; // V2 requires `Plugin.define({ id, setup })` and rejects the old shape outright with // "Plugin must export a default definition with an id and an effect or setup function". // The file loaded fine, looked fine, and did nothing — see the harness docs. // // HOOK CHOICE (V1 -> V2): // permission prompts : generic `event` bus watching permission.asked/updated // -> ctx.permission.hook("evaluate") // The V1 comment noted the dedicated `permission.ask` hook was defined but never // wired (upstream #7006), forcing the event-bus workaround. V2's permission // evaluate hook IS the supported interception point: it runs after configured // rules and before the prompt is published, and receives the action, the // resources and the resolved effect. We only observe — the effect is passed // through untouched, so a notifier can never change an access decision. // risky commands : `tool.execute.before` sniffing output.args.command // -> ctx.shell.hook("create.before") // Reaching into a tool's argument object was never a stable contract. The shell // hook exposes the real command before execution. // // CREDENTIALS. V1 read a plaintext `notify.json` sidecar committed next to the plugin, // because V1 config had no top-level `env`. V2 passes `options` through config, so the // ntfy url/token arrive as plugin options or environment. The sidecar is no longer // read: a live Basic credential in the repo tree is a finding, not a config mechanism. // // WHY THIS IS A DIRECTORY PACKAGE AND NOT A LOOSE .ts (measured on 2.0.31, design D4b): // Only a plugin named by a `plugins` CONFIG ENTRY receives `ctx.options`; one picked up // by `.opencode/plugins/` auto-discovery sees `{}`. And a `plugins` entry must point at // a DIRECTORY — given a `.ts` file it refuses with "configured plugin path must be a // directory" and never loads. A configurable plugin therefore has to ship as // /index.ts + package.json. The other ai-stack plugins take no configuration and // stay loose files. import { Plugin } from "@opencode/plugin" type NotifyConfig = { url: string; auth: string } function resolveConfig(options: Record): NotifyConfig | undefined { const url = (typeof options.ntfy_url === "string" ? options.ntfy_url : "") || process.env.NTFY_URL || (process.env.NTFY_TOPIC ? `https://ntfy.sh/${process.env.NTFY_TOPIC}` : "") if (!url) return undefined const auth = (typeof options.ntfy_auth === "string" ? options.ntfy_auth : "") || process.env.NTFY_AUTH || "" return { url, auth } } const RISKY = [/\bgit\s+push\b/i, /\brm\s+-rf\b/i] export default Plugin.define({ id: "ai-stack.notify", async setup(ctx) { const config = resolveConfig(ctx.options ?? {}) if (!config) { // Non-fatal by contract: a missing dependency disables the behaviour and emits // exactly one diagnostic. It must never interrupt the session. console.warn("[notify] no ntfy url configured (options.ntfy_url or NTFY_URL) — notifications disabled") return } const push = async (title: string, message: string, priority = "high") => { try { await fetch(config.url, { method: "POST", headers: { "Content-Type": "text/plain", Title: title, Priority: priority, ...(config.auth ? { Authorization: config.auth } : {}), }, body: message, signal: AbortSignal.timeout(5000), }) } catch { // Never throw into the session. A notifier that breaks the agent's work is // worse than a missed notification. } } await ctx.permission.hook("evaluate", async (event) => { // Observe only. Deliberately NOT assigning event.effect — this plugin must not // be able to grant or deny anything. if (event.effect !== "ask") return const target = event.resources?.[0] ?? "" await push( "OpenCode needs a decision", `${event.action}${target ? `: ${target.slice(0, 160)}` : ""}`, ) }) await ctx.shell.hook("create.before", async (event) => { const cmd = String(event.command ?? "") if (RISKY.some((re) => re.test(cmd))) { await push("OpenCode running risky command", cmd.slice(0, 200)) } }) }, })