deskbuddy 0.2.0-beta.2 → 0.2.0-beta.21

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +15566 -4613
  3. package/dist/deskbuddy-dsh.mjs +706 -0
  4. package/dist/gate.cjs +411 -35
  5. package/dist/hook.cjs +1283 -44
  6. package/dist/launcher.cjs +229 -0
  7. package/dist/ocTranslate.mjs +144 -0
  8. package/dist/opencode.mjs +204 -53
  9. package/dist/wake.cjs +769 -0
  10. package/docs/INSTALL.md +24 -10
  11. package/hook/deskbuddy-dsh.d.mts +24 -0
  12. package/hook/deskbuddy-dsh.mjs +706 -0
  13. package/hook/gate.cjs +411 -35
  14. package/hook/hook-debug.log +68 -0
  15. package/hook/hook.cjs +1283 -44
  16. package/hook/launcher.cjs +229 -0
  17. package/hook/ocTranslate.d.mts +9 -0
  18. package/hook/ocTranslate.mjs +144 -0
  19. package/hook/opencode.d.mts +10 -1
  20. package/hook/opencode.mjs +204 -53
  21. package/hook/wake.cjs +769 -0
  22. package/package.json +3 -2
  23. package/vendor/node-pty/LICENSE +69 -0
  24. package/vendor/node-pty/lib/conpty_console_list_agent.js +16 -0
  25. package/vendor/node-pty/lib/eventEmitter2.js +47 -0
  26. package/vendor/node-pty/lib/index.js +52 -0
  27. package/vendor/node-pty/lib/interfaces.js +7 -0
  28. package/vendor/node-pty/lib/shared/conout.js +11 -0
  29. package/vendor/node-pty/lib/terminal.js +190 -0
  30. package/vendor/node-pty/lib/types.js +7 -0
  31. package/vendor/node-pty/lib/unixTerminal.js +346 -0
  32. package/vendor/node-pty/lib/utils.js +39 -0
  33. package/vendor/node-pty/lib/windowsConoutConnection.js +125 -0
  34. package/vendor/node-pty/lib/windowsPtyAgent.js +320 -0
  35. package/vendor/node-pty/lib/windowsTerminal.js +199 -0
  36. package/vendor/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  37. package/vendor/node-pty/package.json +7 -0
  38. package/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  39. package/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  40. package/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  41. package/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  42. package/vendor/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  43. package/vendor/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  44. package/vendor/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  45. package/vendor/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  46. package/vendor/node-pty/prebuilds/win32-arm64/pty.node +0 -0
  47. package/vendor/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
  48. package/vendor/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
  49. package/vendor/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  50. package/vendor/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  51. package/vendor/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  52. package/vendor/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  53. package/vendor/node-pty/prebuilds/win32-x64/pty.node +0 -0
  54. package/vendor/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
  55. package/vendor/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
@@ -0,0 +1,229 @@
1
+ 'use strict'
2
+ /**
3
+ * DeskBuddy 稳定入口(自动升级 spec 2026-10-01 §4.1)。
4
+ *
5
+ * 开机自启固定执行 `node ~/.deskbuddy/launcher.cjs daemon`,不再写死某一版 CLI 的路径:
6
+ * 跑哪一版由 ~/.deskbuddy/versions/current.json 决定。
7
+ *
8
+ * 🔴 只能用 Node 内置模块:它随 hook 一起被复制到 ~/.deskbuddy,旁边没有 node_modules。
9
+ * 🔴 current.json / failed.json / crashes.json 的格式与 src/core/versions.ts 共用,改一边必须改另一边。
10
+ *
11
+ * - 正常:spawn `node <cliPath> daemon`,stdio 继承,退出码透传,由系统自启负责重拉
12
+ * (Windows 的「启动」文件夹不会重拉:verifying 期间由本进程自己隔 10 秒重拉,见 main)
13
+ * - state=verifying 时子进程 5 分钟内异常退出累计 3 次 → 切回 previous、把这一版记进 failed.json、写 daemon.log
14
+ */
15
+ const fs = require('node:fs')
16
+ const os = require('node:os')
17
+ const path = require('node:path')
18
+ const { spawn } = require('node:child_process')
19
+
20
+ const CRASH_WINDOW_MS = 5 * 60 * 1000
21
+ const CRASH_LIMIT = 3
22
+ const WIN_RESTART_MS = 10 * 1000
23
+
24
+ function home(env) {
25
+ return (env || process.env).DESKBUDDY_HOME || path.join(os.homedir(), '.deskbuddy')
26
+ }
27
+
28
+ function paths(h) {
29
+ const versions = path.join(h, 'versions')
30
+ return {
31
+ versions,
32
+ current: path.join(versions, 'current.json'),
33
+ failed: path.join(versions, 'failed.json'),
34
+ crashes: path.join(versions, 'crashes.json'),
35
+ log: path.join(h, 'daemon.log'),
36
+ pid: path.join(h, 'launcher.pid'),
37
+ }
38
+ }
39
+
40
+ function readJson(p) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ function writeJson(p, v) {
49
+ fs.mkdirSync(path.dirname(p), { recursive: true })
50
+ const tmp = p + '.' + process.pid + '.tmp'
51
+ fs.writeFileSync(tmp, JSON.stringify(v, null, 2), 'utf8')
52
+ fs.renameSync(tmp, p)
53
+ }
54
+
55
+ function log(h, line) {
56
+ try {
57
+ fs.mkdirSync(h, { recursive: true })
58
+ fs.appendFileSync(paths(h).log, new Date().toISOString() + ' [launcher] ' + line + '\n', 'utf8')
59
+ } catch {
60
+ /* 写不了日志不能挡住启动 */
61
+ }
62
+ }
63
+
64
+ function isRef(r) {
65
+ return !!r && typeof r.version === 'string' && typeof r.cliPath === 'string' && r.cliPath !== ''
66
+ }
67
+
68
+ function readCurrent(h) {
69
+ const c = readJson(paths(h).current)
70
+ if (!isRef(c)) return null
71
+ return {
72
+ version: c.version,
73
+ cliPath: c.cliPath,
74
+ previous: isRef(c.previous) ? { version: c.previous.version, cliPath: c.previous.cliPath } : null,
75
+ state: c.state === 'verifying' ? 'verifying' : 'good',
76
+ switchedAt: typeof c.switchedAt === 'number' ? c.switchedAt : 0,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 切回 previous。markFailed=true 时把出问题的版本记进 failed.json(之后不再自动升到它)。
82
+ * 返回切过去的版本;没有可退的返回 null
83
+ */
84
+ function rollback(h, cur, why, now, markFailed) {
85
+ const p = paths(h)
86
+ if (!cur.previous || !fs.existsSync(cur.previous.cliPath)) {
87
+ log(h, '版本 ' + cur.version + ' ' + why + ',但没有可回退的上一版本,继续用它')
88
+ return null
89
+ }
90
+ writeJson(p.current, {
91
+ version: cur.previous.version,
92
+ cliPath: cur.previous.cliPath,
93
+ // 出问题的那版留作 previous:目录不会被清理,便于排查
94
+ previous: { version: cur.version, cliPath: cur.cliPath },
95
+ state: 'good',
96
+ switchedAt: now,
97
+ })
98
+ if (markFailed) {
99
+ const f = readJson(p.failed) || {}
100
+ const versions = Array.isArray(f.versions) ? f.versions.filter((v) => typeof v === 'string') : []
101
+ if (!versions.includes(cur.version)) versions.push(cur.version)
102
+ writeJson(p.failed, { versions, attempts: f.attempts && typeof f.attempts === 'object' ? f.attempts : {} })
103
+ }
104
+ try {
105
+ fs.rmSync(p.crashes, { force: true })
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ log(h, '版本 ' + cur.version + ' ' + why + ',已回滚到 ' + cur.previous.version + (markFailed ? ',之后不再自动升到 ' + cur.version : ''))
110
+ return cur.previous.version
111
+ }
112
+
113
+ /**
114
+ * 选这次要跑的那份:current;它的 cliPath 不在了 → 退回 previous(verifying 时同时记失败)。
115
+ * 返回 null = 没有可跑的
116
+ */
117
+ function pickTarget(h, now) {
118
+ let cur = readCurrent(h)
119
+ if (!cur) return null
120
+ if (!fs.existsSync(cur.cliPath)) {
121
+ // 🔴 只有 verifying(刚切过来、没验证过)才拉黑:good 版本的文件被误删 / 被清理不代表这一版有问题(评审 5)
122
+ if (rollback(h, cur, '的入口文件不存在', now, cur.state === 'verifying') === null) return null
123
+ cur = readCurrent(h)
124
+ if (!cur || !fs.existsSync(cur.cliPath)) return null
125
+ }
126
+ return cur
127
+ }
128
+
129
+ /**
130
+ * 子进程异常退出一次:verifying 期间记崩溃,5 分钟内满 3 次回滚。返回是否回滚了
131
+ */
132
+ function recordCrash(h, cur, now) {
133
+ if (cur.state !== 'verifying') return false
134
+ const p = paths(h)
135
+ const prev = readJson(p.crashes)
136
+ let times = prev && prev.version === cur.version && Array.isArray(prev.times) ? prev.times.filter((t) => typeof t === 'number') : []
137
+ times = times.filter((t) => now - t < CRASH_WINDOW_MS)
138
+ times.push(now)
139
+ writeJson(p.crashes, { version: cur.version, times })
140
+ log(h, '版本 ' + cur.version + ' 验证期内异常退出(5 分钟内第 ' + times.length + ' 次)')
141
+ if (times.length < CRASH_LIMIT) return false
142
+ return rollback(h, cur, '验证期内 5 分钟崩溃 ' + times.length + ' 次', now, true) !== null
143
+ }
144
+
145
+ /**
146
+ * 子进程环境:剔除自动切换用的 FROM_* 变量(Windows 上 update --auto 直接拉起 launcher 时会继承它们),
147
+ * 免得一路传到 daemon 起的智能体子进程里。名字与 src/daemon/autoUpdate.ts 一致
148
+ */
149
+ const SWITCH_ENV_KEYS = ['DESKBUDDY_UPDATE_FROM_VERSION', 'DESKBUDDY_UPDATE_FROM_CLI']
150
+ function childEnv(env) {
151
+ const out = Object.assign({}, env)
152
+ for (const k of SWITCH_ENV_KEYS) delete out[k]
153
+ return out
154
+ }
155
+
156
+ function runOnce(h, cur, args) {
157
+ return new Promise((resolve) => {
158
+ let stopping = false
159
+ const child = spawn(process.execPath, [cur.cliPath, ...args], { stdio: 'inherit', windowsHide: true, env: childEnv(process.env) })
160
+ const forward = (sig) => {
161
+ stopping = true
162
+ try {
163
+ child.kill(sig)
164
+ } catch {
165
+ /* 已经退了 */
166
+ }
167
+ }
168
+ const handlers = {}
169
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
170
+ handlers[sig] = () => forward(sig)
171
+ process.on(sig, handlers[sig])
172
+ }
173
+ const done = (code, signal, err) => {
174
+ for (const sig of Object.keys(handlers)) process.removeListener(sig, handlers[sig])
175
+ resolve({ code, signal, stopping, err })
176
+ }
177
+ child.on('error', (e) => done(null, null, e))
178
+ child.on('exit', (code, signal) => done(code, signal, null))
179
+ })
180
+ }
181
+
182
+ async function main(argv, deps) {
183
+ const d = deps || {}
184
+ const h = home(d.env)
185
+ const platform = d.platform || process.platform
186
+ const now = d.now || (() => Date.now())
187
+ const args = argv.length > 0 ? argv : ['daemon']
188
+ const run = d.runOnce || ((cur) => runOnce(h, cur, args))
189
+ const sleep = d.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
190
+ try {
191
+ fs.mkdirSync(h, { recursive: true })
192
+ fs.writeFileSync(paths(h).pid, String(process.pid), 'utf8')
193
+ } catch {
194
+ /* 记不上 pid:Windows 升级时停不掉本进程,最坏多一个 launcher 在等子进程 */
195
+ }
196
+ for (;;) {
197
+ const cur = pickTarget(h, now())
198
+ if (!cur) {
199
+ // 🔴 返回 0:launchd(SuccessfulExit=false)/ systemd(on-failure)都不再重拉 —— 返回非 0 会每 10 秒重拉一次刷满日志(评审 4)
200
+ log(
201
+ h,
202
+ '没有可运行的 CLI(' + paths(h).current + ' 缺失,或它和上一版本指向的文件都不在)。守护进程不再自动重启;' +
203
+ '恢复办法:在终端执行 npx -y deskbuddy@beta update'
204
+ )
205
+ return 0
206
+ }
207
+ const r = await run(cur)
208
+ const abnormal = !r.stopping && (r.err || r.signal || r.code !== 0)
209
+ if (r.err) log(h, '起不来版本 ' + cur.version + ':' + r.err.message)
210
+ if (!abnormal) return r.code == null ? 0 : r.code
211
+ const rolledBack = recordCrash(h, cur, now())
212
+ // 非 Windows:交给 launchd / systemd 按「非正常退出」重拉(它们会重新执行本 launcher,读到回滚后的 current.json)
213
+ // Windows:「启动」文件夹只在登录时跑一次,崩了没人拉 —— verifying 期间(或刚回滚)自己重拉,否则保持原来的行为直接退出
214
+ if (platform !== 'win32' || (cur.state !== 'verifying' && !rolledBack)) return r.code == null ? 1 : r.code || 1
215
+ await sleep(WIN_RESTART_MS)
216
+ }
217
+ }
218
+
219
+ module.exports = { main, childEnv, SWITCH_ENV_KEYS, pickTarget, recordCrash, rollback, readCurrent, paths, CRASH_WINDOW_MS, CRASH_LIMIT }
220
+
221
+ if (require.main === module) {
222
+ main(process.argv.slice(2)).then(
223
+ (code) => process.exit(code),
224
+ (e) => {
225
+ log(home(), 'launcher 出错:' + (e && e.stack ? e.stack : String(e)))
226
+ process.exit(1)
227
+ }
228
+ )
229
+ }
@@ -0,0 +1,144 @@
1
+ // opencode 系事件 → cc-hook 同构输入的翻译(spec 2026-10-03 §3.2「共用翻译」)。
2
+ // opencode 插件(opencode.mjs,跑在 opencode 自带的 bun 里)与 daemon 的 DumateWatch(百度搭子 = 改名的 opencode)共用这一份:
3
+ // 插件 import 同目录的本文件(随 hook.cjs 一起同步到 ~/.deskbuddy/),daemon 经 src/core/ocTranslate.ts 打包进去。
4
+ // 🔴 零依赖纯函数:不能 import 任何东西(插件那边同目录只有我们自己的文件)
5
+
6
+ /** opencode 工具 → cc-hook 同构的 tool_name / tool_input(daemon 的 classify 按 Claude Code 的工具名判断) */
7
+ export function toCcTool(tool, args) {
8
+ const a = args && typeof args === 'object' ? args : {}
9
+ const s = (v) => (typeof v === 'string' ? v : '')
10
+ switch (tool) {
11
+ case 'bash':
12
+ return { tool_name: 'Bash', tool_input: { command: s(a.command) } }
13
+ case 'read':
14
+ return { tool_name: 'Read', tool_input: { file_path: s(a.filePath) } }
15
+ case 'edit':
16
+ return { tool_name: 'Edit', tool_input: { file_path: s(a.filePath) } }
17
+ case 'write':
18
+ return { tool_name: 'Write', tool_input: { file_path: s(a.filePath) } }
19
+ case 'glob':
20
+ return { tool_name: 'Glob', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
21
+ case 'grep':
22
+ return { tool_name: 'Grep', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
23
+ case 'list':
24
+ return { tool_name: 'LS', tool_input: { path: s(a.path) } }
25
+ case 'webfetch':
26
+ return { tool_name: 'WebFetch', tool_input: { url: s(a.url) } }
27
+ case 'task':
28
+ return { tool_name: 'Task', tool_input: {} }
29
+ case 'todowrite':
30
+ case 'todoread':
31
+ return { tool_name: 'TodoWrite', tool_input: {} }
32
+ case 'patch':
33
+ case 'apply_patch':
34
+ return { tool_name: 'apply_patch', tool_input: { patch: s(a.patchText) || s(a.patch) || s(a.input) } }
35
+ default:
36
+ // 不认识的工具(MCP 等)原名原参数交出去:classify 对不认识的一律推设备
37
+ return { tool_name: String(tool || ''), tool_input: a }
38
+ }
39
+ }
40
+
41
+ /** permission.asked → PermissionRequest 的 tool_name / tool_input,给设备卡片生成摘要 */
42
+ export function askToCc(props) {
43
+ const md = (props && props.metadata) || {}
44
+ if (typeof md.command === 'string' && md.command) return { tool_name: 'Bash', tool_input: { command: md.command } }
45
+ const file = md.filepath || md.filePath
46
+ if (typeof file === 'string' && file) return { tool_name: 'Edit', tool_input: { file_path: file } }
47
+ const patterns = Array.isArray(props && props.patterns) ? props.patterns.join(' ') : ''
48
+ return { tool_name: String((props && props.permission) || 'permission'), tool_input: { path: patterns } }
49
+ }
50
+
51
+ /**
52
+ * 授权请求里 opencode 自己给的「以后都允许」模式(permission.asked 的 always,如 bash 的 `npm *`、edit 的 `*`)
53
+ * → 一条 session 级 addRules 建议(与 Claude Code 的 permission_suggestions 同形),daemon 据此在设备上给「允许并记住」。
54
+ * 设备选了记住,gate 输出带 updatedPermissions,插件回 always(opencode 在本次运行期间记住这些模式)。没有 always 返回 undefined
55
+ */
56
+ export function alwaysSuggestions(props) {
57
+ const perm = String((props && props.permission) || '').trim()
58
+ const always = props && Array.isArray(props.always) ? props.always.filter((x) => typeof x === 'string' && x.trim() !== '') : []
59
+ if (!perm || always.length === 0) return undefined
60
+ return [{ type: 'addRules', rules: always.map((x) => ({ toolName: perm, ruleContent: `${perm} ${x.trim()}` })), behavior: 'allow', destination: 'session' }]
61
+ }
62
+
63
+ /**
64
+ * 这次提问来自哪个工具:question.asked 只带 tool.callID,工具名要从 tool.execute.before 记下的调用里查。
65
+ * 🔴 只推 question 工具本身的提问:plan_exit 也走 question.asked(「计划完成,是否切到 build」Yes/No),
66
+ * 推到设备 = 盲批计划,违背 ExitPlanMode 不推的原则(设备答选择题 spec §3.7)。认不出来源的一律不推
67
+ */
68
+ export function isPlainQuestion(props, callTool) {
69
+ if (callTool !== 'question') return false
70
+ const qs = props && Array.isArray(props.questions) ? props.questions : []
71
+ // 兜底:plan_exit 的固定题面 / header
72
+ return !qs.some((q) => q && (q.header === 'Build Agent' || (typeof q.question === 'string' && /^Plan at .* is complete\./.test(q.question))))
73
+ }
74
+
75
+ /**
76
+ * question.asked(opencode 的 question 工具 / plan_exit 等向用户提问)→ AskUserQuestion 形状的 tool_input,
77
+ * 统一走 daemon 的选择题链路(设备答选择题 spec §3.4)。`multiple` → multiSelect;`custom:false` 原样带上(不许自己填)
78
+ */
79
+ export function questionToCc(props) {
80
+ const qs = props && Array.isArray(props.questions) ? props.questions : null
81
+ if (!qs || qs.length === 0) return null
82
+ return {
83
+ tool_name: 'AskUserQuestion',
84
+ tool_input: {
85
+ questions: qs.map((q) => ({
86
+ question: q && q.question,
87
+ header: (q && q.header) || '',
88
+ multiSelect: !!(q && q.multiple),
89
+ options: q && Array.isArray(q.options) ? q.options.map((o) => ({ label: o && o.label, description: (o && o.description) || '' })) : [],
90
+ ...(q && q.custom === false ? { custom: false } : {}),
91
+ })),
92
+ },
93
+ }
94
+ }
95
+
96
+ /** opencode 起标题前的占位名(1.18.32 isDefaultTitle:`New session - ` / `Child session - ` + ISO 时间),不当标题 */
97
+ const OC_DEFAULT_TITLE_RE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
98
+
99
+ /** session.updated 的 info → 记下自动标题。子会话(subagent,带 parentID)、占位名不记 */
100
+ export function rememberTitle(titles, info) {
101
+ if (!info || typeof info.id !== 'string' || !info.id || info.parentID) return
102
+ const t = typeof info.title === 'string' ? info.title.trim() : ''
103
+ if (!t || OC_DEFAULT_TITLE_RE.test(t)) return
104
+ titles.set(info.id, t.slice(0, 100))
105
+ }
106
+
107
+ /**
108
+ * gate.cjs --window 的输出 → 插件怎么回 opencode(授权选项透传,notes 2026-10-03 §1)。
109
+ * - 设备按选项答的:用 deskbuddyReply(once / always / reject),但必须与 behavior 同向(allow 只认 once / always,deny 只认 reject)
110
+ * - 老 daemon / 没有选项:allow 带 updatedPermissions → always,否则 once;deny → reject
111
+ * - 拒绝并说明:deskbuddyFeedback 非空 → 带 message(插件走 /permission/{id}/reply,旧路由会把 message 吃掉)
112
+ * 返回 null = 不表态
113
+ */
114
+ export function ocReplyOf(out) {
115
+ const d = out && out.hookSpecificOutput && out.hookSpecificOutput.decision
116
+ if (!d) return null
117
+ const pick = out.deskbuddyReply
118
+ if (d.behavior === 'allow') {
119
+ if (pick === 'once' || pick === 'always') return { reply: pick }
120
+ return { reply: Array.isArray(d.updatedPermissions) && d.updatedPermissions.length > 0 ? 'always' : 'once' }
121
+ }
122
+ if (d.behavior === 'deny') {
123
+ const msg = typeof out.deskbuddyFeedback === 'string' ? out.deskbuddyFeedback.trim() : ''
124
+ return msg ? { reply: 'reject', message: msg } : { reply: 'reject' }
125
+ }
126
+ return null
127
+ }
128
+
129
+ /** 实测确认 `/permission/{id}/reply` 收 message 的最低版本(notes 2026-10-03 §1.3 只在 1.18.32 上验过) */
130
+ export const OC_PERMISSION_REPLY_MIN = '1.18.32'
131
+
132
+ /**
133
+ * 这个 opencode 版本能不能用新路由带说明拒绝(评审:别靠 4xx 回退 —— 不认识的路由可能被转发到 app.opencode.ai,用户说的话会外发)。
134
+ * 版本读不出 / 格式不对 / 低于已验证版本 → false
135
+ */
136
+ export function supportsPermissionReply(version) {
137
+ const m = typeof version === 'string' ? version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/) : null
138
+ if (!m) return false
139
+ const min = OC_PERMISSION_REPLY_MIN.split('.').map(Number)
140
+ const v = [Number(m[1]), Number(m[2]), Number(m[3])]
141
+ for (let i = 0; i < 3; i++) if (v[i] !== min[i]) return v[i] > min[i]
142
+ return true
143
+ }
144
+