opencode-permission-notify 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mingpei Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # opencode-permission-notify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Approval prompts from OpenCode, as a native macOS floating panel in the top-right
4
+ corner of your screen — with **Allow once / Always / Reject** buttons, so you never
5
+ have to switch back to the OpenCode window to answer.
6
+
7
+ - **Non-activating** — the panel is an `NSWindowStyleMaskNonactivatingPanel`; clicking a button does not bring OpenCode forward or interrupt what you are doing
8
+ - **Quiet when it should be** — if OpenCode is already frontmost, nothing pops up
9
+ - **Never lost** — answers are written back immediately; after 5 minutes the panel closes and hands the request back to the in-window prompt
10
+ - **No dependencies** — no terminal-notifier, no notification permission; the helper binary ships with the package
11
+
12
+ ## Install
13
+
14
+ ```jsonc
15
+ // ~/.config/opencode/opencode.jsonc
16
+ {
17
+ "plugins": ["opencode-permission-notify"]
18
+ }
19
+ ```
20
+
21
+ Or point at a local checkout while developing:
22
+
23
+ ```jsonc
24
+ {
25
+ "plugins": ["/absolute/path/to/permission-notify"]
26
+ }
27
+ ```
28
+
29
+ On first load the bundled universal binary (arm64 + x86_64) is extracted to
30
+ `~/Library/Application Support/opencode/permission-notify/bin/notify-request`.
31
+
32
+ **Requirements**: macOS, OpenCode V2 (desktop or TUI — the plugin runs in the
33
+ server).
34
+
35
+ ## How it works
36
+
37
+ ```
38
+ OpenCode server emits permission.asked
39
+ ↓ the plugin filters by location (only the instance owning the session's
40
+ directory handles it)
41
+ notify-request:
42
+ OpenCode is frontmost → exit quietly
43
+ otherwise draw a panel in the top-right corner and wait for a click
44
+ ↓ stdout: index of the chosen button
45
+ ctx.permission.reply({ sessionID, requestID, decision })
46
+ ```
47
+
48
+ The three buttons map to the `once` / `always` / `reject` decisions.
49
+
50
+ ## Configuration
51
+
52
+ | Environment variable | Description |
53
+ | --- | --- |
54
+ | `PERMISSION_NOTIFY_HELPER` | Override the helper path (rarely needed) |
55
+
56
+ Log file: `~/Library/Application Support/opencode/permission-notify/notify.log`
57
+
58
+ ## Build from source
59
+
60
+ Requires Xcode Command Line Tools:
61
+
62
+ ```sh
63
+ ./build.sh # builds bin/notify-request (universal)
64
+ ```
65
+
66
+ ## Limitations
67
+
68
+ - macOS only
69
+ - Panels stack by request (up to five slots); many simultaneous requests overlap slightly
70
+ - Rejections do not carry feedback text yet (OpenCode supports reject-with-reason; this only sends `reject`)
71
+
72
+ ## License
73
+
74
+ MIT
75
+
76
+ ---
77
+
78
+ ## 中文说明
79
+
80
+ 把 OpenCode 的授权请求变成屏幕右上角的 macOS 原生浮窗,带「允许一次 / 总是允许 / 拒绝」
81
+ 三个按钮,不抢焦点、不用切回窗口;OpenCode 已在前台时不弹。通用二进制助手随包分发。
82
+
83
+ 安装:在 `~/.config/opencode/opencode.jsonc` 的 `plugins` 中加 `"opencode-permission-notify"`。
84
+ 仅支持 macOS + OpenCode V2。
package/bin/.keep ADDED
File without changes
Binary file
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "opencode-permission-notify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Approval prompts from OpenCode as a native macOS floating panel in the top-right corner, with Allow once / Always / Reject buttons. 把 OpenCode 的授权请求变成右上角带按钮的原生浮窗。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Mingpei Wang",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "bin",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "macos",
21
+ "notification",
22
+ "permission"
23
+ ],
24
+ "os": [
25
+ "darwin"
26
+ ]
6
27
  }
package/src/index.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { execFile } from "node:child_process"
3
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync, statSync } from "node:fs"
4
+ import { homedir, tmpdir } from "node:os"
5
+ import { dirname, join } from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ /**
9
+ * permission-notify — 把 OpenCode 的授权请求变成 macOS 原生弹窗(带按钮),
10
+ * 在弹窗里直接选「允许一次 / 总是允许 / 拒绝」,不用切回 OpenCode 窗口。
11
+ *
12
+ * 依赖同目录安装的 notify-request 助手(CFUserNotification)。
13
+ */
14
+
15
+ const HOME = process.env.HOME ?? homedir()
16
+ const INSTALL_DIR = `${HOME}/Library/Application Support/opencode/permission-notify`
17
+ const HELPER = process.env.PERMISSION_NOTIFY_HELPER ?? `${INSTALL_DIR}/bin/notify-request`
18
+
19
+ /** npm 包里自带通用二进制;首次加载时释放到 Application Support */
20
+ function ensureHelper(): string | undefined {
21
+ if (process.env.PERMISSION_NOTIFY_HELPER) return HELPER
22
+ if (existsSync(HELPER)) return HELPER
23
+ try {
24
+ const bundled = fileURLToPath(new URL("../bin/notify-request", import.meta.url))
25
+ if (!existsSync(bundled)) return undefined
26
+ mkdirSync(dirname(HELPER), { recursive: true })
27
+ copyFileSync(bundled, HELPER)
28
+ chmodSync(HELPER, 0o755)
29
+ return HELPER
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ const DECISIONS = ["once", "always", "reject"] as const
36
+
37
+ const LOCK_DIR = join(tmpdir(), "opencode-permission-notify")
38
+ const LOG_FILE = join(HOME, "Library/Application Support/opencode/permission-notify/notify.log")
39
+
40
+ /** 轻量文件日志:排查「点了没反应」时不用去翻服务器日志 */
41
+ function log(line: string) {
42
+ try {
43
+ writeFileSync(LOG_FILE, `${new Date().toISOString()} ${line}\n`, { flag: "a" })
44
+ } catch {}
45
+ }
46
+
47
+ type Reply = (typeof DECISIONS)[number]
48
+
49
+ /**
50
+ * 插件按 location 会加载多份实例,同一授权请求会同时到达每一份。
51
+ * 用临时文件做原子认领,保证一个请求只弹一个窗;顺带用它算出堆叠位置。
52
+ */
53
+ function claim(requestID: string): { lock: string; slot: number } | undefined {
54
+ mkdirSync(LOCK_DIR, { recursive: true })
55
+ const lock = join(LOCK_DIR, `${requestID}.lock`)
56
+
57
+ // 清理一小时前的残留
58
+ try {
59
+ const now = Date.now()
60
+ for (const name of readdirSync(LOCK_DIR)) {
61
+ const path = join(LOCK_DIR, name)
62
+ try {
63
+ if (now - statSync(path).mtimeMs > 3600_000) unlinkSync(path)
64
+ } catch {}
65
+ }
66
+ } catch {}
67
+
68
+ try {
69
+ writeFileSync(lock, String(process.pid), { flag: "wx" })
70
+ } catch {
71
+ return undefined // 另一份实例已经在处理
72
+ }
73
+
74
+ let slot = 0
75
+ try {
76
+ slot = readdirSync(LOCK_DIR).filter((n) => n.endsWith(".lock")).length - 1
77
+ } catch {}
78
+ return { lock, slot: Math.max(0, Math.min(slot, 4)) }
79
+ }
80
+
81
+ function summarize(data: any): string {
82
+ const action: string = data?.action ?? "操作"
83
+ const resources: string[] = Array.isArray(data?.resources) ? data.resources : []
84
+ const lines: string[] = []
85
+
86
+ if (data?.message) lines.push(String(data.message), "")
87
+ lines.push(`动作:${action}`)
88
+
89
+ if (resources.length) {
90
+ const shown = resources.slice(0, 6)
91
+ lines.push(`资源:`)
92
+ for (const item of shown) {
93
+ // 过长的路径中段省略,保证弹窗不炸开
94
+ lines.push(item.length > 90 ? `${item.slice(0, 42)}…${item.slice(-42)}` : item)
95
+ }
96
+ if (resources.length > shown.length) lines.push(`…另有 ${resources.length - shown.length} 项`)
97
+ }
98
+
99
+ return lines.join("\n")
100
+ }
101
+
102
+ function ask(message: string, slot = 0, timeoutSeconds = 300): Promise<number> {
103
+ return new Promise((resolve) => {
104
+ execFile(
105
+ HELPER,
106
+ [
107
+ "--title",
108
+ "OpenCode 需要授权",
109
+ "--message",
110
+ message,
111
+ "--buttons",
112
+ "允许一次|总是允许|拒绝",
113
+ "--skip-if-frontmost",
114
+ "ai.opencode.desktop",
115
+ "--slot",
116
+ String(slot),
117
+ "--timeout",
118
+ String(timeoutSeconds),
119
+ ],
120
+ { timeout: (timeoutSeconds + 10) * 1000 },
121
+ (error, stdout) => {
122
+ if (error && !stdout) return resolve(-1)
123
+ const index = Number.parseInt(String(stdout).trim(), 10)
124
+ resolve(Number.isNaN(index) ? -1 : index)
125
+ },
126
+ )
127
+ })
128
+ }
129
+
130
+ export default Plugin.define({
131
+ id: "permission-notify",
132
+
133
+ async setup(ctx) {
134
+ if (!ensureHelper()) {
135
+ log(`找不到助手:${HELPER}`)
136
+ return
137
+ }
138
+
139
+ const controller = new AbortController()
140
+
141
+ void (async () => {
142
+ try {
143
+ for await (const event of ctx.event.subscribe({
144
+ types: ["permission.asked"],
145
+ signal: controller.signal,
146
+ })) {
147
+ const data: any = (event as any).data
148
+ if (!data?.id || !data?.sessionID) continue
149
+
150
+ const myDir: string | undefined = (ctx as any).location?.directory
151
+ const eventDir: string | undefined = (event as any).location?.directory
152
+ log(`ctx.location=${myDir} event.location=${eventDir}`)
153
+ // 权限请求按 location 归属;不是本实例的目录就不处理,否则 reply 会报 not found
154
+ if (myDir && eventDir && myDir !== eventDir) continue
155
+
156
+ log(`event ${data.id} action=${data.action} at=${Date.now()}`)
157
+ const askedAt = Date.now()
158
+
159
+ const claimed = claim(String(data.id))
160
+
161
+ try {
162
+ const index = await ask(summarize(data), claimed.slot)
163
+ log(`clicked ${data.id} index=${index} after ${Date.now() - askedAt}ms`)
164
+
165
+ // -2:当前前台就是 OpenCode,弹窗里已经能看见,不打扰
166
+ if (index < 0) continue
167
+
168
+ const reply: Reply | undefined = DECISIONS[index]
169
+ if (!reply) continue
170
+
171
+ try {
172
+ await ctx.permission.reply({
173
+ sessionID: data.sessionID,
174
+ requestID: data.id,
175
+ decision: reply,
176
+ })
177
+ log(`replied ${data.id} -> ${reply}`)
178
+ } catch (error) {
179
+ // 用户在窗口里抢先点了,或请求已失效:忽略
180
+ log(`reply failed ${data.id}: ${error}`)
181
+ }
182
+
183
+ // 自检:回复后请求是否真的从待处理列表消失
184
+ try {
185
+ const pending: any[] = await ctx.permission.list({ sessionID: data.sessionID })
186
+ const still = (pending || []).some((p) => p.id === data.id)
187
+ log(`pending after reply: ${(pending || []).length}, still has ${data.id}: ${still}`)
188
+ } catch (error) {
189
+ log(`list failed: ${error}`)
190
+ }
191
+ } finally {
192
+ try {
193
+ unlinkSync(claimed.lock)
194
+ } catch {}
195
+ }
196
+ }
197
+ } catch (error) {
198
+ if (!controller.signal.aborted) {
199
+ console.error("[permission-notify] 事件流异常", error)
200
+ }
201
+ }
202
+ })()
203
+
204
+ return () => controller.abort()
205
+ },
206
+ })