e633d10e049205c6eb04d08534061353367ff335

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add notifications

Diff

This diff is truncated to protect this page.

  1diff --git a/src/index.ts b/src/index.ts
  2index 9dcc3754067a00e7169049c619006e677ba874f4..99b26f4e2e57e91d223e159a8f1b547e934cfdde 100644
  3--- a/src/index.ts
  4+++ b/src/index.ts
  5@@ -1,5 +1,6 @@
  6 import type { Plugin, PluginModule } from "@opencode-ai/plugin"
  7 import type { Permission } from "@opencode-ai/sdk"
  8+import { basename } from "path"
  9 import { checkDeterministic } from "./deterministic"
 10 import { normalizeRequest, cacheKey } from "./normalizer"
 11 import { lookupCache, writeCache } from "./cache"
 12@@ -8,6 +9,7 @@ import { setCapturedApiKey, callClaude } from "./api"
 13 import { addPermissions, getPermissions } from "./permissions"
 14 import { logDecision } from "./logger"
 15 import { PERMISSION_KEYWORDS, PERMISSION_EXTRACTION_PROMPT } from "./rules"
 16+import { notify } from "./notify"
 17 import type { Decision } from "./types"
 18 
 19 // v2 PermissionRequest shape (event properties)
 20@@ -152,8 +154,35 @@ export async function runPipeline(
 21   return worst!
 22 }
 23 
 24+function parsePermissionEvent(event: unknown): {
 25+  permId: string; sessionId: string; toolType: string
 26+  patterns: string[]; metadata: Record<string, unknown>
 27+} | undefined {
 28+  const props = (event as PermissionAskedEvent).properties
 29+  if (props?.id && props?.sessionID && props?.permission) {
 30+    return {
 31+      permId: props.id, sessionId: props.sessionID, toolType: props.permission,
 32+      patterns: props.patterns, metadata: props.metadata ?? {},
 33+    }
 34+  }
 35+  // v1 fallback
 36+  const v1 = (event as { type: string; properties: Permission }).properties
 37+  if (!v1?.id || !v1?.sessionID) return undefined
 38+  return {
 39+    permId: v1.id, sessionId: v1.sessionID, toolType: v1.type,
 40+    patterns: Array.isArray(v1.pattern) ? v1.pattern : v1.pattern ? [v1.pattern] : [],
 41+    metadata: v1.metadata ?? {},
 42+  }
 43+}
 44+
 45+const IDLE_DELAY_MS = 350
 46+const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
 47+const idleSeq = new Map<string, number>()
 48+
 49 const server: Plugin = async (ctx, _options) => {
 50   const client = ctx.client
 51+  const projectName = ctx.directory ? basename(ctx.directory) : null
 52+  const title = projectName ? `OpenCode (${projectName})` : "OpenCode"
 53 
 54   return {
 55     "chat.params": async (input, _output) => {
 56@@ -172,47 +201,61 @@ const server: Plugin = async (ctx, _options) => {
 57       if (perms.length > 0) addPermissions(input.sessionID, perms)
 58     },
 59 
 60+    "tool.execute.before": async (input) => {
 61+      if (input.tool === "question") {
 62+        notify(title, "Waiting for your answer")
 63+      }
 64+    },
 65+
 66     event: async ({ event }) => {
 67-      // Handle both v1 "permission.updated" and v2 "permission.asked"
 68-      const type = event.type as string
 69-      if (type !== "permission.asked" && type !== "permission.updated") return
 70+      const type = (event as { type: string }).type
 71 
 72-      const props = (event as unknown as PermissionAskedEvent).properties
 73-      if (!props?.id || !props?.sessionID) {
 74-        // v1 fallback: Permission type has different shape
 75-        const v1 = (event as { type: string; properties: Permission }).properties
 76-        if (!v1?.id || !v1?.sessionID) return
 77-
 78-        const toolType = v1.type
 79-        const patterns = Array.isArray(v1.pattern) ? v1.pattern : v1.pattern ? [v1.pattern] : []
 80-        const result = await runPipeline(toolType, patterns, v1.metadata ?? {}, v1.sessionID)
 81-
 82-        if (result.decision.decision === "ask") return // let user decide
 83-
 84-        try {
 85-          await client.postSessionIdPermissionsPermissionId({
 86-            body: { response: result.decision.decision === "allow" ? "once" : "reject" },
 87-            path: { id: v1.sessionID, permissionID: v1.id },
 88-          })
 89-        } catch {
 90-          // reply failed (already replied, or permission expired)
 91-        }
 92+      // Session complete (idle with debounce)
 93+      if (type === "session.idle") {
 94+        const sid = (event as any).properties?.sessionID as string | undefined
 95+        if (!sid) { notify(title, "Session complete"); return }
 96+
 97+        const seq = (idleSeq.get(sid) ?? 0) + 1
 98+        idleSeq.set(sid, seq)
 99+        const prev = idleTimers.get(sid)
100+        if (prev) clearTimeout(prev)
101+
102+        idleTimers.set(sid, setTimeout(() => {
103+          idleTimers.delete(sid)
104+          if (idleSeq.get(sid) === seq) notify(title, "Session complete")
105diff --git a/src/notify.ts b/src/notify.ts
106new file mode 100644
107index 0000000000000000000000000000000000000000..1019bd7aac18916e0458de8967f7482bbcda2b72
108--- /dev/null
109+++ b/src/notify.ts
110@@ -0,0 +1,118 @@
111+import { execFile, execFileSync, execSync } from "child_process"
112+
113+let lastNotificationId: number | null = null
114+
115+function exec(cmd: string, timeoutMs = 500): string | null {
116+  try {
117+    return execSync(cmd, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim()
118+  } catch {
119+    return null
120+  }
121+}
122+
123+function execFileQuiet(cmd: string, args: readonly string[], timeoutMs = 500): string | null {
124+  try {
125+    return execFileSync(cmd, args, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim()
126+  } catch {
127+    return null
128+  }
129+}
130+
131+// Focus detection — Linux only (Wayland + X11), with tmux/wezterm pane awareness.
132+
133+function getWaylandActiveWindowId(): string | null {
134+  const env = process.env
135+  if (env.HYPRLAND_INSTANCE_SIGNATURE) {
136+    const out = exec("hyprctl activewindow -j")
137+    if (!out) return null
138+    try { return JSON.parse(out)?.address ?? null } catch { return null }
139+  }
140+  if (env.NIRI_SOCKET) {
141+    const out = exec("niri msg --json focused-window", 1000)
142+    if (!out) return null
143+    try { const d = JSON.parse(out); return typeof d?.id === "number" ? String(d.id) : null } catch { return null }
144+  }
145+  if (env.SWAYSOCK) {
146+    const out = exec("swaymsg -t get_tree", 1000)
147+    if (!out) return null
148+    try {
149+      const find = (node: any): string | null => {
150+        if (node.focused && typeof node.id === "number") return String(node.id)
151+        for (const list of [node.nodes, node.floating_nodes]) {
152+          if (!Array.isArray(list)) continue
153+          for (const c of list) { const r = find(c); if (r) return r }
154+        }
155+        return null
156+      }
157+      return find(JSON.parse(out))
158+    } catch { return null }
159+  }
160+  if (env.KDE_SESSION_VERSION) return exec("kdotool getactivewindow")
161+  return null
162+}
163+
164+function getActiveWindowId(): string | null {
165+  if (process.env.WAYLAND_DISPLAY) return getWaylandActiveWindowId()
166+  if (process.env.DISPLAY) return exec("xdotool getactivewindow")
167+  return null
168+}
169+
170+const cachedWindowId = getActiveWindowId()
171+
172+function isTmuxPaneActive(): boolean {
173+  const pane = process.env.TMUX_PANE
174+  if (!pane) return false
175+  const result = execFileQuiet("tmux", ["display-message", "-t", pane, "-p", "#{session_attached} #{window_active} #{pane_active}"])
176+  if (!result) return false
177+  const [sa, wa, pa] = result.split(" ")
178+  return sa === "1" && wa === "1" && pa === "1"
179+}
180+
181+function isWezTermPaneActive(): boolean {
182+  const pane = process.env.WEZTERM_PANE
183+  if (!pane) return true
184+  const out = execFileQuiet("wezterm", ["cli", "list-clients", "--format", "json"], 1000)
185+  if (!out) return false
186+  try {
187+    const clients = JSON.parse(out)
188+    if (!Array.isArray(clients)) return false
189+    for (const c of clients) {
190+      if (String(c?.focused_pane_id) === pane) return true
191+    }
192+  } catch {}
193+  return false
194+}
195+
196+export function isTerminalFocused(): boolean {
197+  try {
198+    if (!cachedWindowId) return false
199+    if (getActiveWindowId() !== cachedWindowId) return false
200+    if (!isWezTermPaneActive()) return false
201+    if (process.env.TMUX) return isTmuxPaneActive()
202+    return true
203+  } catch {
204+    return false
205+  }
206+}
207+
208+// notify-send wrapper
209+