opencode-permission-notify 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +70 -2
- package/bin/.keep +0 -0
- package/bin/notify-request +0 -0
- package/package.json +25 -4
- package/src/index.ts +206 -0
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,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-permission-notify
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 OpenCode 的授权请求变成 macOS 屏幕右上角的原生浮窗,带 **允许一次 / 总是允许 / 拒绝** 三个按钮——
|
|
4
|
+
不用切回 OpenCode 窗口就能批复。
|
|
5
|
+
|
|
6
|
+
- **非抢焦**:浮窗是 `NSWindowStyleMaskNonactivatingPanel`,点按钮不会把 OpenCode 顶到前台,也不会打断你正在读的东西
|
|
7
|
+
- **该静就静**:OpenCode 已经在前台时直接不弹(窗口里的提示本来就看得见)
|
|
8
|
+
- **不丢请求**:浮窗点完即时回写;超时 5 分钟自动收起,交还给窗口内的原提示
|
|
9
|
+
- **零依赖**:不装 terminal-notifier,不需要通知权限,二进制随包分发
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// ~/.config/opencode/opencode.jsonc
|
|
15
|
+
{
|
|
16
|
+
"plugins": ["opencode-permission-notify"]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
或者在本地开发时指向目录:
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"plugins": ["/absolute/path/to/permission-notify"]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
首次加载时会把内置的通用二进制(arm64 + x86_64)释放到
|
|
29
|
+
`~/Library/Application Support/opencode/permission-notify/bin/notify-request`。
|
|
30
|
+
|
|
31
|
+
**要求**:macOS、OpenCode V2(桌面版或 TUI 均可,插件跑在服务端)。
|
|
32
|
+
|
|
33
|
+
## 工作方式
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
OpenCode 服务端发出 permission.asked
|
|
37
|
+
↓ 插件按 location 过滤(只有会话所属目录的实例处理)
|
|
38
|
+
执行 notify-request:
|
|
39
|
+
前台是 OpenCode → 直接退出,不打扰
|
|
40
|
+
否则在右上角画浮窗,等你点按钮
|
|
41
|
+
↓ stdout 返回按钮序号
|
|
42
|
+
ctx.permission.reply({ sessionID, requestID, decision })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
按钮对应 `once` / `always` / `reject` 三种 decision。
|
|
46
|
+
|
|
47
|
+
## 配置
|
|
48
|
+
|
|
49
|
+
| 环境变量 | 说明 |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `PERMISSION_NOTIFY_HELPER` | 覆盖助手路径(一般不用) |
|
|
52
|
+
|
|
53
|
+
日志:`~/Library/Application Support/opencode/permission-notify/notify.log`
|
|
54
|
+
|
|
55
|
+
## 从源码构建
|
|
56
|
+
|
|
57
|
+
需要 Xcode Command Line Tools:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
./build.sh # 编译通用二进制到 bin/notify-request
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 已知限制
|
|
64
|
+
|
|
65
|
+
- 仅 macOS
|
|
66
|
+
- 浮窗按请求堆叠(最多 5 个槽位);同一时刻大量请求会略微重叠
|
|
67
|
+
- 拒绝时不会附带理由文本(OpenCode 支持带反馈的拒绝,这里暂时只回 `reject`)
|
|
68
|
+
|
|
69
|
+
## 许可
|
|
70
|
+
|
|
71
|
+
MIT
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "macOS 原生浮窗,把 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
|
+
]
|
|
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
|
+
})
|