e633d10e049205c6eb04d08534061353367ff335
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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+