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
package/hook/gate.cjs CHANGED
@@ -101,16 +101,26 @@ function argTool(argv) {
101
101
 
102
102
  /**
103
103
  * 按智能体判断是不是交互窗口。
104
- * - Qoder CN(2026-09-27 实测):桌面端弹授权框时 hook 的父进程就是 `Qoder CN.app` 主进程;
105
- * daemon 起的远程任务走 worker(node),父进程链上没有它
104
+ * - Qoder CN(qoder_cn,2026-09-27 实测):桌面端弹授权框时 hook 的父进程就是 `Qoder CN.app` 主进程。
105
+ * ⚠️ daemon 起的远程任务也会被判成窗口:worker 是用 `Qoder CN.app` 里的可执行文件加 ELECTRON_RUN_AS_NODE=1 跑的
106
+ * (daemon/locate.ts qoderWorker),祖先链里同样有 app。目前安全靠这三点,缺一不可:
107
+ * ① 远程任务先走 PreToolUse 的 `--run` 关卡(daemon/executor.ts qoderPlan),每一步都经设备授权;
108
+ * ② 远程任务开着 bypass_permissions,不触发 PermissionRequest,本窗口关卡根本不会被调;
109
+ * ③ 万一被调到,coordinator.ts #onWindow 会跳过 daemon 自己起的会话(按 #runs 的 sessionId)
110
+ * - Qoder 国际版(qoder):同一套代码,app 叫 `Qoder.app` ⚠️ 未实机核对
111
+ * - Qoder 命令行窗口(Bug 204):终端里的 `qoderclicn`(国内版)/ `qodercli`(国际版),参数判据同 Claude
112
+ * (2026-10-03 qoderclicn 1.1.59 实测:PermissionRequest hook 的父进程就是 qoderclicn,回 allow 后不再弹框)
106
113
  * - Claude Code:见 isInteractiveClaude
107
114
  */
108
115
  function isInteractiveAgent(tool) {
109
116
  if (process.platform === 'win32') return isInteractiveAgentWin(tool)
110
- if (tool === 'qoder') return hasAncestor((comm) => comm.includes('/Qoder CN.app/'))
117
+ if (tool === 'qoder_cn') return isInteractiveQoder(tool, '/Qoder CN.app/')
118
+ if (tool === 'qoder') return isInteractiveQoder(tool, '/Qoder.app/')
111
119
  if (tool === 'claude_code') return isInteractiveClaude()
112
120
  // opencode 的窗口确认由插件发起,插件已排除 `opencode run`(hook/opencode.mjs isHeadless)
113
121
  if (tool === 'opencode') return true
122
+ // DeepSeek Harness 同理:插件只在 web / Desktop 这类有人在电脑前的 profile 里发起(hook/deskbuddy-dsh.mjs isWindowProfile)
123
+ if (tool === 'deepseek_harness') return true
114
124
  return false
115
125
  }
116
126
 
@@ -121,15 +131,76 @@ function claudeArgsInteractive(argsAfterArgv0) {
121
131
  )
122
132
  }
123
133
 
134
+ /**
135
+ * Qoder 命令行:只看第一个独立 `--` 之前的参数。daemon 开窗新建 Qoder CN 时首句放在 `--` 之后(wrap/promptLaunch.ts),
136
+ * 首句里有个 ` -p ` 的词不能把窗口判成非交互(spec 2026-10-04 qodercn-visible §3.1;与 hook.cjs isInteractiveArgs 一致)
137
+ */
138
+ function qoderArgsInteractive(argsAfterArgv0) {
139
+ const dd = argsAfterArgv0.indexOf('--')
140
+ return claudeArgsInteractive(dd >= 0 ? argsAfterArgv0.slice(0, dd) : argsAfterArgv0)
141
+ }
142
+
143
+ /**
144
+ * 进程名是不是这个工具的 Qoder 命令行(Bug 204)。国内版 `qoderclicn`,国际版 `qodercli`(装出来的文件名带版本号:qodercli-1.0.12)。
145
+ * 🔴 qoderclicn 也以 qodercli 开头,国际版要排除它
146
+ */
147
+ function qoderCliMatches(tool, name) {
148
+ const b = path.basename(String(name || '')).toLowerCase().replace(/\.exe$/, '')
149
+ if (tool === 'qoder_cn') return b.startsWith('qoderclicn')
150
+ if (tool === 'qoder') return b.startsWith('qodercli') && !b.startsWith('qoderclicn')
151
+ return false
152
+ }
153
+
154
+ /** 进程表查询(ps)。取不到返回 null */
155
+ const PS = {
156
+ /** '<ppid> <comm>' */
157
+ line(pid) {
158
+ try {
159
+ return execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
160
+ } catch (_) {
161
+ return null
162
+ }
163
+ },
164
+ args(pid) {
165
+ try {
166
+ return execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
167
+ } catch (_) {
168
+ return null
169
+ }
170
+ },
171
+ }
172
+
173
+ /**
174
+ * Qoder:祖先里先碰到桌面端 app → 交互;先碰到命令行 → 看参数(-p / stream-json 是程序在用)。
175
+ * ps / startPid 只给测试注入
176
+ */
177
+ function isInteractiveQoder(tool, appMark, ps, startPid) {
178
+ const p = ps || PS
179
+ let pid = startPid || process.ppid
180
+ for (let i = 0; i < 8 && pid > 1; i++) {
181
+ const line = p.line(pid)
182
+ const m = line && line.match(/^(\d+)\s+(.*)$/)
183
+ if (!m) return false
184
+ if (m[2].includes(appMark)) return true
185
+ if (qoderCliMatches(tool, m[2])) {
186
+ const args = p.args(pid)
187
+ if (args === null) return false
188
+ return qoderArgsInteractive(args.trim().split(/\s+/).slice(1))
189
+ }
190
+ pid = Number(m[1])
191
+ }
192
+ return false
193
+ }
194
+
124
195
  /**
125
196
  * Windows 版(没有 ps):进程表借 hook.cjs 的实现(同目录,随它一起装),判据同上。
126
- * - Qoder CN 桌面端:祖先里有 `Qoder CN.exe`
197
+ * - Qoder CN 桌面端:祖先里有 `Qoder CN.exe`;国际版:`Qoder.exe`
127
198
  * - Claude Code:原生版是 `claude.exe`,npm 版是 node 跑 `claude-code/cli.js`
128
199
  * 取不到进程表 = 判不出来 = 不表态(窗口模式的兜底方向)
129
200
  */
130
201
  function isInteractiveAgentWin(tool) {
131
- if (tool === 'opencode') return true
132
- if (tool !== 'qoder' && tool !== 'claude_code') return false
202
+ if (tool === 'opencode' || tool === 'deepseek_harness') return true
203
+ if (tool !== 'qoder_cn' && tool !== 'qoder' && tool !== 'claude_code') return false
133
204
  let winProc
134
205
  try {
135
206
  winProc = require(path.join(__dirname, 'hook.cjs')).winProc
@@ -142,7 +213,10 @@ function isInteractiveAgentWin(tool) {
142
213
  seen.add(pid)
143
214
  const p = winProc(pid)
144
215
  if (!p) return false
145
- if (tool === 'qoder' && /qoder cn/i.test(p.name)) return true
216
+ if (tool === 'qoder_cn' && /qoder cn/i.test(p.name)) return true
217
+ if (tool === 'qoder' && /^qoder(\.exe)?$/i.test(p.name)) return true
218
+ // 命令行窗口(Bug 204)⚠️ Windows 未实机核对
219
+ if (qoderCliMatches(tool, p.name)) return qoderArgsInteractive(String(p.args || '').split(' ').slice(1))
146
220
  if (tool === 'claude_code' && (/^claude(\.exe)?$/i.test(p.name) || p.name === 'claude-code/cli.js')) {
147
221
  return claudeArgsInteractive(p.args.split(' ').slice(1))
148
222
  }
@@ -151,21 +225,294 @@ function isInteractiveAgentWin(tool) {
151
225
  return false
152
226
  }
153
227
 
154
- function hasAncestor(match) {
155
- let pid = process.ppid
228
+ /**
229
+ * Qoder CN 的宿主界面(授权选项透传:桌面 GUI 与终端里的 worker 选项集不同,notes 2026-10-03 §2):
230
+ * 1. 环境变量 QODER_AGENT_SDK_DAEMON=1:桌面端经 agent SDK 起的 worker(SDK 起 worker 时设的,hook 继承)→ gui
231
+ * 2. 沿父进程找第一个 Qoder CN 应用里的进程(macOS 路径含 `/Qoder CN.app/`,Windows 进程名含 `Qoder CN`)或 `qoderclicn` 命令行
232
+ * (与 isInteractiveQoder 同一顺序,Bug 204):
233
+ * - 不是 worker(参数里没有 qoder-worker-runtime)→ 桌面主进程直接起的 hook → gui
234
+ * - 是 worker 且参数带 `--sdk` → gui
235
+ * - 是 worker / 命令行:再往上找,跳过 sh / zsh / bash 这类中间 shell(GUI 若经 shell 起 worker 不误判,评审),
236
+ * 下一个还在应用里 → gui,否则(终端)→ tui
237
+ * 取不到 / 认不出返回 ''(不发 options)。info / startPid / platform 只给测试注入(info(pid) → {ppid, comm|name, args})
238
+ */
239
+ const SHELL_NAMES = /^(-?(sh|bash|zsh|dash|fish|ksh)|cmd\.exe|cmd|powershell\.exe|powershell|pwsh\.exe|pwsh)$/i
240
+
241
+ /** 参数串里第一个独立 `--` 之前的部分 */
242
+ function beforeDashDashStr(args) {
243
+ const t = String(args || '').split(/\s+/)
244
+ const i = t.indexOf('--')
245
+ return i >= 0 ? t.slice(0, i).join(' ') : t.join(' ')
246
+ }
247
+
248
+ function qoderUi(tool, info, startPid, platform) {
249
+ if (tool !== 'qoder_cn') return ''
250
+ if (!info && process.env.QODER_AGENT_SDK_DAEMON === '1') return 'gui'
251
+ const win = (platform || process.platform) === 'win32'
252
+ const get = info || (win ? winProcInfo : procInfo)
253
+ const inApp = (p) => (win ? /qoder cn/i.test(p.name) : p.comm.includes('/Qoder CN.app/'))
254
+ const base = (p) => path.basename(win ? p.name : p.comm)
255
+ let pid = startPid || process.ppid
156
256
  for (let i = 0; i < 8 && pid > 1; i++) {
157
- let line
257
+ const p = get(pid)
258
+ if (!p) return ''
259
+ // 与 isInteractiveQoder 同一顺序:祖先里先碰到桌面端 app 还是先碰到命令行(Bug 204:终端里的 qoderclicn)
260
+ const cli = qoderCliMatches(tool, win ? p.name : p.comm)
261
+ if (inApp(p) || cli) {
262
+ // 桌面主进程直接起的 hook(不是 worker / 命令行)→ gui
263
+ if (!cli && !/qoder-worker-runtime/.test(p.args)) return 'gui'
264
+ // 只看第一个独立 `--` 之前的参数(与 qoderArgsInteractive 同一判据):开窗新建把首句放在 `--` 之后,首句里的 --sdk 不算
265
+ if (/(^|\s)--sdk(\s|$)/.test(beforeDashDashStr(p.args))) return 'gui'
266
+ // worker / 命令行:跳过中间 shell 往上看,还在 app 里 = 桌面端起的 → gui,否则(终端)→ tui
267
+ let up = p.ppid
268
+ for (let j = 0; j < 6 && up > 1; j++) {
269
+ const q = get(up)
270
+ if (!q) return ''
271
+ if (SHELL_NAMES.test(base(q))) {
272
+ up = q.ppid
273
+ continue
274
+ }
275
+ return inApp(q) ? 'gui' : 'tui'
276
+ }
277
+ return ''
278
+ }
279
+ pid = p.ppid
280
+ }
281
+ return ''
282
+ }
283
+
284
+ function procInfo(pid) {
285
+ try {
286
+ const line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
287
+ const m = line.match(/^(\d+)\s+(.*)$/)
288
+ if (!m) return null
289
+ const args = execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
290
+ return { ppid: Number(m[1]), comm: m[2], args }
291
+ } catch (_) {
292
+ return null
293
+ }
294
+ }
295
+
296
+ function winProcInfo(pid) {
297
+ try {
298
+ const p = require(path.join(__dirname, 'hook.cjs')).winProc(pid)
299
+ return p ? { ppid: p.ppid, name: p.name, comm: p.name, args: p.args || '' } : null
300
+ } catch (_) {
301
+ return null
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Qoder CN 桌面端的界面语言(app.asar 0.4.1 渲染层):localStorage `loop.language`(zh / en),没设按系统首选语言(zh* → zh,其余 en)。
307
+ * userData = appData/<product.json dataDirectoryName>:macOS ~/Library/Application Support/com.qodercn.app.stable,
308
+ * Windows %APPDATA%\com.qodercn.app.stable(主进程 setPath('userData', appData/dataDirectoryName),不是 "Qoder CN")。
309
+ * 🔴 只读:直接按字节扫 LevelDB 的 .ldb / .log(不开库、不加锁)。读不出返回 ''(不发 options)
310
+ */
311
+ const QODER_CN_DATA_DIR = 'com.qodercn.app.stable'
312
+
313
+ function qoderCnLang() {
314
+ const forced = process.env.DESKBUDDY_GATE_QODER_LANG
315
+ if (forced === 'zh' || forced === 'en' || forced === 'none') return forced === 'none' ? '' : forced
316
+ let dir = process.env.DESKBUDDY_GATE_QODER_DATA || ''
317
+ if (!dir) {
318
+ const appData = process.platform === 'win32' ? process.env.APPDATA : process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support') : ''
319
+ if (!appData) return ''
320
+ dir = path.join(appData, QODER_CN_DATA_DIR)
321
+ }
322
+ const stored = readLoopLanguage(path.join(dir, 'Local Storage', 'leveldb'))
323
+ if (stored) return stored
324
+ return process.env.DESKBUDDY_GATE_QODER_DATA ? '' : systemLang()
325
+ }
326
+
327
+ /** 按字节找 `loop.language` 的值;新的覆盖旧的(.ldb 按文件号、再 .log 按文件号、同文件按位置)。只认 zh / en */
328
+ function readLoopLanguage(dir) {
329
+ let names
330
+ try {
331
+ names = fs.readdirSync(dir)
332
+ } catch (_) {
333
+ return ''
334
+ }
335
+ const num = (n) => Number((n.match(/^(\d+)\./) || [])[1] || 0)
336
+ const files = [
337
+ ...names.filter((n) => /^\d+\.ldb$/.test(n)).sort((a, b) => num(a) - num(b)),
338
+ ...names.filter((n) => /^\d+\.log$/.test(n)).sort((a, b) => num(a) - num(b)),
339
+ ]
340
+ let last = ''
341
+ // .log:key 后紧跟值长度 3 + "\x01zh";.ldb:key 后是 8 字节 tag(首字节 1 = 写入)再跟值
342
+ const re = /loop\.language(?:\x03\x01(zh|en)|\x01[\s\S]{7}\x01(zh|en))/g
343
+ for (const f of files) {
344
+ let buf
158
345
  try {
159
- line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
346
+ const full = path.join(dir, f)
347
+ if (fs.statSync(full).size > 16 * 1024 * 1024) continue
348
+ buf = fs.readFileSync(full).toString('latin1')
160
349
  } catch (_) {
161
- return false
350
+ continue
162
351
  }
163
- const m = line.match(/^(\d+)\s+(.*)$/)
164
- if (!m) return false
165
- if (match(m[2])) return true
166
- pid = Number(m[1])
352
+ let m
353
+ while ((m = re.exec(buf))) last = m[1] || m[2]
354
+ }
355
+ return last
356
+ }
357
+
358
+ /** 系统首选语言(渲染层 navigator.languages[0] 的近似):macOS 读 AppleLanguages,Windows 用 ICU 的默认区域 */
359
+ function systemLang() {
360
+ try {
361
+ let first = ''
362
+ if (process.platform === 'darwin') {
363
+ const out = execFileSync('defaults', ['read', '-g', 'AppleLanguages'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 })
364
+ const m = out.match(/"?([A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*)"?/)
365
+ first = m ? m[1] : ''
366
+ } else if (process.platform === 'win32') {
367
+ first = Intl.DateTimeFormat().resolvedOptions().locale || ''
368
+ }
369
+ if (!first) return ''
370
+ return first.toLowerCase().startsWith('zh') ? 'zh' : 'en'
371
+ } catch (_) {
372
+ return ''
167
373
  }
168
- return false
374
+ }
375
+
376
+ /**
377
+ * Claude Code 的 auto 模式能不能在设备上给(授权选项透传:用户拍板「切 auto 模式」上设备;hook 回 setMode auto 会绕过
378
+ * disableAutoMode,实测)。🔴 白名单:只有确认没有任何别的策略来源、且看得到的设置都没关时才返回 'on',其余一律 'off'。
379
+ * 来源(2.1.280 二进制核对):
380
+ * - userSettings:$CLAUDE_CONFIG_DIR/settings.json(缺省 ~/.claude)
381
+ * - projectSettings / localSettings:工作目录及各级上级的 .claude/settings.json、.claude/settings.local.json(宁可多看几级)
382
+ * 以上读到 permissions.disableAutoMode / disableAutoMode 为 "disable"(或 true)→ off
383
+ * - 托管策略,存在即 off、不解析:managed-settings.json 与 managed-settings.d/(非空)——macOS /Library/Application Support/ClaudeCode,
384
+ * Linux /etc/claude-code,Windows C:\Program Files\ClaudeCode;macOS MDM:/Library/Managed Preferences/[<用户>/]com.anthropic.claudecode.plist,
385
+ * 以及 `defaults read com.anthropic.claudecode` 读得到;Windows 注册表 HKLM / HKCU\SOFTWARE\Policies\ClaudeCode(查询失败也算存在)
386
+ * - 服务端下发的组织策略:落地缓存 $CLAUDE_CONFIG_DIR/remote-settings.json,存在即 off。
387
+ * ⚠️ 已知限制:CC 也可能把它存进「storage backend」而不落这个文件,那时看不出来,组织策略禁用 auto 时可能误显示
388
+ * - claude 启动参数带 --settings(文件内容不追)→ off;拿不到 claude 的命令行 → off
389
+ * 任一项读取失败 / 超时 / 报错 → off
390
+ */
391
+ const CC_POLICY_DOMAIN = 'com.anthropic.claudecode'
392
+ const CC_REG_KEYS = ['HKLM\\SOFTWARE\\Policies\\ClaudeCode', 'HKCU\\SOFTWARE\\Policies\\ClaudeCode']
393
+
394
+ function ccManagedDir(platform) {
395
+ if (platform === 'darwin') return '/Library/Application Support/ClaudeCode'
396
+ if (platform === 'win32') return 'C:\\Program Files\\ClaudeCode'
397
+ return '/etc/claude-code'
398
+ }
399
+
400
+ function defaultCcDeps() {
401
+ return {
402
+ platform: process.platform,
403
+ env: process.env,
404
+ home: os.homedir(),
405
+ cwd: process.cwd(),
406
+ username: (() => {
407
+ try {
408
+ return os.userInfo().username
409
+ } catch (_) {
410
+ return ''
411
+ }
412
+ })(),
413
+ readFile: (f) => fs.readFileSync(f, 'utf8'),
414
+ listDir: (d) => fs.readdirSync(d),
415
+ exec: (cmd, args) => {
416
+ const r = require('node:child_process').spawnSync(cmd, args, { encoding: 'utf8', timeout: 3000, windowsHide: true })
417
+ return { status: r.error ? -1 : r.status, stdout: r.stdout || '', stderr: r.stderr || '' }
418
+ },
419
+ claudeArgs: () => claudeArgs(),
420
+ }
421
+ }
422
+
423
+ /** 文件不存在 → null;存在 → 内容;别的错误照抛(调用方按 off) */
424
+ function readIfExists(d, f) {
425
+ try {
426
+ return d.readFile(f)
427
+ } catch (e) {
428
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return null
429
+ throw e
430
+ }
431
+ }
432
+
433
+ function ccAutoMode(hook, deps) {
434
+ const forced = process.env.DESKBUDDY_GATE_CC_AUTO
435
+ if (!deps && (forced === 'on' || forced === 'off')) return forced
436
+ const d = deps || defaultCcDeps()
437
+ try {
438
+ return ccAutoModeOrThrow(hook, d)
439
+ } catch (_) {
440
+ return 'off'
441
+ }
442
+ }
443
+
444
+ function ccAutoModeOrThrow(hook, d) {
445
+ const join = d.platform === 'win32' ? path.win32.join : path.posix.join
446
+ const dirname = d.platform === 'win32' ? path.win32.dirname : path.posix.dirname
447
+ const configDir = d.env.CLAUDE_CONFIG_DIR || join(d.home, '.claude')
448
+ const off = (v) => v === 'disable' || v === true
449
+
450
+ // 1. 用户 / 项目 / 本地设置:读到「关」或读坏 → off
451
+ const files = [join(configDir, 'settings.json')]
452
+ let dir = hook && typeof hook.cwd === 'string' && hook.cwd ? hook.cwd : d.cwd
453
+ for (let i = 0; i < 64; i++) {
454
+ files.push(join(dir, '.claude', 'settings.json'), join(dir, '.claude', 'settings.local.json'))
455
+ const up = dirname(dir)
456
+ if (up === dir) break
457
+ dir = up
458
+ }
459
+ for (const f of files) {
460
+ const raw = readIfExists(d, f)
461
+ if (raw === null) continue
462
+ const j = JSON.parse(raw)
463
+ if (j && typeof j === 'object' && (off(j.disableAutoMode) || (j.permissions && off(j.permissions.disableAutoMode)))) return 'off'
464
+ }
465
+
466
+ // 2. 托管策略:存在即 off
467
+ const managed = ccManagedDir(d.platform)
468
+ if (readIfExists(d, join(managed, 'managed-settings.json')) !== null) return 'off'
469
+ try {
470
+ if (d.listDir(join(managed, 'managed-settings.d')).length > 0) return 'off'
471
+ } catch (e) {
472
+ if (!(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR'))) return 'off'
473
+ }
474
+ if (d.platform === 'darwin') {
475
+ const plists = [`/Library/Managed Preferences/${CC_POLICY_DOMAIN}.plist`]
476
+ if (d.username) plists.unshift(`/Library/Managed Preferences/${d.username}/${CC_POLICY_DOMAIN}.plist`)
477
+ for (const f of plists) if (readIfExists(d, f) !== null) return 'off'
478
+ const r = d.exec('defaults', ['read', CC_POLICY_DOMAIN])
479
+ if (r.status === 0) return 'off'
480
+ if (!/does not exist/i.test(r.stderr)) return 'off'
481
+ }
482
+ if (d.platform === 'win32') {
483
+ for (const key of CC_REG_KEYS) {
484
+ const r = d.exec('reg', ['query', key])
485
+ if (r.status === 0) return 'off'
486
+ // 只有「找不到」才算没有;其余(含本地化报错认不出)一律当存在
487
+ if (!(r.status === 1 && /unable to find|找不到|cannot find/i.test(r.stderr + r.stdout))) return 'off'
488
+ }
489
+ }
490
+
491
+ // 3. 服务端下发的组织策略的落地缓存
492
+ if (readIfExists(d, join(configDir, 'remote-settings.json')) !== null) return 'off'
493
+
494
+ // 4. 启动参数:拿不到 claude 的命令行 / 带 --settings → off
495
+ const args = d.claudeArgs()
496
+ if (!Array.isArray(args)) return 'off'
497
+ if (args.some((t) => t === '--settings' || t.startsWith('--settings='))) return 'off'
498
+ return 'on'
499
+ }
500
+
501
+ /** 沿父进程找 claude,返回它 argv[0] 之后的参数;找不到返回 null。Windows 用 hook.cjs 的进程表 */
502
+ function claudeArgs() {
503
+ const fake = process.env.DESKBUDDY_GATE_CC_ARGS
504
+ if (typeof fake === 'string') return fake === '!none' ? null : fake.split(/\s+/).filter(Boolean)
505
+ const win = process.platform === 'win32'
506
+ let pid = process.ppid
507
+ for (let i = 0; i < 8 && pid > 1; i++) {
508
+ const p = win ? winProcInfo(pid) : procInfo(pid)
509
+ if (!p) return null
510
+ const name = win ? p.name : path.basename(p.comm)
511
+ if (/^claude(\.exe)?$/i.test(name)) return p.args.trim().split(/\s+/).slice(1)
512
+ if (win && name === 'claude-code/cli.js') return p.args.trim().split(/\s+/).slice(2)
513
+ pid = p.ppid
514
+ }
515
+ return null
169
516
  }
170
517
 
171
518
  function isInteractiveClaude() {
@@ -206,17 +553,35 @@ async function windowMain() {
206
553
  const conf = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'gate.json'), 'utf8'))
207
554
  if (!conf || !Number.isInteger(conf.port) || typeof conf.token !== 'string' || !conf.token) return fail()
208
555
  const hook = JSON.parse(await readStdin())
209
- const res = await post(conf.port, conf.token, JSON.stringify({ mode: 'window', tool, hook }))
556
+ // DESKBUDDY_GATE_UI 只给测试用(tui / gui),其余真去查进程
557
+ const forcedUi = process.env.DESKBUDDY_GATE_UI
558
+ const ui = forcedUi === 'tui' || forcedUi === 'gui' ? forcedUi : qoderUi(tool)
559
+ const lang = ui === 'gui' ? qoderCnLang() : ''
560
+ const extra = {
561
+ ...(ui ? { ui } : {}),
562
+ ...(lang ? { lang } : {}),
563
+ ...(tool === 'claude_code' ? { ccAutoMode: ccAutoMode(hook) } : {}),
564
+ }
565
+ const res = await post(conf.port, conf.token, JSON.stringify({ mode: 'window', tool, hook, ...extra }))
210
566
  if (res.status !== 200) return fail()
211
567
  const out = JSON.parse(res.data)
212
568
  if (!out || (out.decision !== 'allow' && out.decision !== 'deny')) return fail()
213
- const decision =
214
- out.decision === 'allow'
215
- ? Array.isArray(out.updatedPermissions) && out.updatedPermissions.length > 0
216
- ? { behavior: 'allow', updatedPermissions: out.updatedPermissions }
217
- : { behavior: 'allow' }
218
- : { behavior: 'deny', message: (typeof out.reason === 'string' && out.reason) || '用户在设备上拒绝了这一步' }
569
+ let decision
570
+ if (out.decision === 'allow') {
571
+ decision = { behavior: 'allow' }
572
+ // 选择题作答(设备答选择题 spec §3.4):daemon 用本地原始 tool_input 按下标拼好的,原样交回
573
+ if (out.updatedInput && typeof out.updatedInput === 'object' && !Array.isArray(out.updatedInput)) decision.updatedInput = out.updatedInput
574
+ if (Array.isArray(out.updatedPermissions) && out.updatedPermissions.length > 0) decision.updatedPermissions = out.updatedPermissions
575
+ } else decision = { behavior: 'deny', message: (typeof out.reason === 'string' && out.reason) || '用户在设备上拒绝了这一步' }
219
576
  const body = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
577
+ // opencode 插件回填选择题要的是每题的 label 数组(它不认 updatedInput);Claude 系不带,免得 hook 输出校验不认这个字段
578
+ if ((tool === 'opencode' || tool === 'deepseek_harness') && out.decision === 'allow' && Array.isArray(out.answerLists)) body.deskbuddyAnswers = out.answerLists
579
+ // opencode 授权选项(once / always / reject)与「拒绝并说明」的那段话:插件据此回填(hook/opencode.mjs ocReplyOf)
580
+ if (tool === 'opencode') {
581
+ const reply = out.native && typeof out.native === 'object' ? out.native.reply : undefined
582
+ if (reply === 'once' || reply === 'always' || reply === 'reject') body.deskbuddyReply = reply
583
+ if (out.decision === 'deny' && typeof out.feedback === 'string' && out.feedback.trim()) body.deskbuddyFeedback = out.feedback
584
+ }
220
585
  // 窗口里告诉人是在哪台设备上处理的
221
586
  if (typeof out.notice === 'string' && out.notice) body.systemMessage = out.notice.slice(0, 300)
222
587
  process.stdout.write(JSON.stringify(body), () => process.exit(0))
@@ -270,18 +635,29 @@ async function main() {
270
635
  return
271
636
  }
272
637
 
273
- const payload = JSON.stringify({
274
- hookSpecificOutput: {
275
- hookEventName: 'PreToolUse',
276
- permissionDecision: out.decision,
277
- permissionDecisionReason: reason,
278
- },
279
- })
638
+ const spec = {
639
+ hookEventName: 'PreToolUse',
640
+ permissionDecision: out.decision,
641
+ permissionDecisionReason: reason,
642
+ }
643
+ // 远程选择题作答(WorkBuddy 的 codebuddy -p):带上答案。codebuddy 的命令 hook 读 hookSpecificOutput.modifiedInput,
644
+ // 合并多个 hook 结果时读 updatedInput(Claude 兼容字段)—— 两个都带(codebuddy WorkBuddy 内置版源码核对)
645
+ if (out.decision === 'allow' && out.updatedInput && typeof out.updatedInput === 'object' && !Array.isArray(out.updatedInput)) {
646
+ spec.updatedInput = out.updatedInput
647
+ spec.modifiedInput = out.updatedInput
648
+ }
649
+ const payload = JSON.stringify({ hookSpecificOutput: spec })
280
650
  // 等写完再退:管道写入是异步的,写一半就 exit 的话 allow / defer 会丢
281
651
  process.stdout.write(payload, (err) => (err ? fail('写 hook 输出失败') : process.exit(0)))
282
652
  }
283
653
 
284
- // 兜住一切没想到的异常 —— 见顶部铁律
285
- process.on('uncaughtException', (e) => fail(`内部错误 ${(e && e.message) || e}`))
286
- process.on('unhandledRejection', (e) => fail(`内部错误 ${(e && e.message) || e}`))
287
- main().catch((e) => fail(`内部错误 ${(e && e.message) || e}`))
654
+ if (require.main === module) {
655
+ // 兜住一切没想到的异常 —— 见顶部铁律
656
+ process.on('uncaughtException', (e) => fail(`内部错误 ${(e && e.message) || e}`))
657
+ process.on('unhandledRejection', (e) => fail(`内部错误 ${(e && e.message) || e}`))
658
+ main().catch((e) => fail(`内部错误 ${(e && e.message) || e}`))
659
+ } else {
660
+ // 只给单测(智能体永远是 `node gate.cjs` 直接跑)。
661
+ // 🔴 禁止通过 require 调用本关卡:main 不跑,进程会 exit 0 = 放行
662
+ module.exports = { qoderCliMatches, claudeArgsInteractive, qoderArgsInteractive, isInteractiveQoder, qoderUi, ccAutoMode, CC_POLICY_DOMAIN, CC_REG_KEYS }
663
+ }
@@ -0,0 +1,68 @@
1
+ 2026-10-03T12:47:47.235Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
2
+ 2026-10-03T12:49:41.785Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
3
+ 2026-10-03T12:50:52.692Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
4
+ 2026-10-03T13:21:49.856Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
5
+ 2026-10-03T13:27:31.507Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
6
+ 2026-10-03T15:56:44.816Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
7
+ 2026-10-04T02:02:45.414Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
8
+ 2026-10-04T02:05:17.361Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
9
+ 2026-10-04T03:44:32.326Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
10
+ 2026-10-04T03:44:32.839Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
11
+ 2026-10-04T03:44:32.839Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
12
+ 2026-10-04T03:44:33.014Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
13
+ 2026-10-04T03:44:33.014Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
14
+ 2026-10-04T03:55:44.720Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
15
+ 2026-10-04T03:55:45.175Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
16
+ 2026-10-04T03:55:45.175Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
17
+ 2026-10-04T03:55:45.327Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
18
+ 2026-10-04T03:55:45.327Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
19
+ 2026-10-04T06:58:28.544Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
20
+ 2026-10-04T06:58:28.973Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
21
+ 2026-10-04T06:58:28.973Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
22
+ 2026-10-04T06:58:29.082Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
23
+ 2026-10-04T06:58:29.082Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
24
+ 2026-10-04T07:08:32.294Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
25
+ 2026-10-04T07:08:32.795Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
26
+ 2026-10-04T07:08:32.795Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
27
+ 2026-10-04T07:08:32.908Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
28
+ 2026-10-04T07:08:32.909Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
29
+ 2026-10-04T07:38:35.702Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
30
+ 2026-10-04T07:38:36.121Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
31
+ 2026-10-04T07:38:36.121Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
32
+ 2026-10-04T07:38:36.211Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
33
+ 2026-10-04T07:38:36.211Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
34
+ 2026-10-04T11:03:50.679Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
35
+ 2026-10-04T11:03:51.107Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
36
+ 2026-10-04T11:03:51.107Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
37
+ 2026-10-04T11:03:51.207Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
38
+ 2026-10-04T11:03:51.207Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
39
+ 2026-10-04T11:12:40.872Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
40
+ 2026-10-04T11:12:42.480Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
41
+ 2026-10-04T11:12:42.481Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
42
+ 2026-10-04T11:12:42.866Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
43
+ 2026-10-04T11:12:44.878Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
44
+ 2026-10-04T11:14:37.601Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
45
+ 2026-10-04T11:14:38.045Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
46
+ 2026-10-04T11:14:38.045Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
47
+ 2026-10-04T11:14:38.171Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
48
+ 2026-10-04T11:14:38.171Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
49
+ 2026-10-04T12:00:44.405Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
50
+ 2026-10-04T12:00:44.879Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
51
+ 2026-10-04T12:00:44.879Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
52
+ 2026-10-04T12:00:45.021Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
53
+ 2026-10-04T12:00:45.022Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
54
+ 2026-10-04T12:08:19.397Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
55
+ 2026-10-04T12:08:19.805Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
56
+ 2026-10-04T12:08:19.805Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
57
+ 2026-10-04T12:08:19.911Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
58
+ 2026-10-04T12:08:19.912Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
59
+ 2026-10-05T02:15:10.472Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
60
+ 2026-10-05T02:15:10.898Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
61
+ 2026-10-05T02:15:10.899Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
62
+ 2026-10-05T02:15:11.004Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
63
+ 2026-10-05T02:15:11.004Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
64
+ 2026-10-05T02:23:41.382Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
65
+ 2026-10-05T02:23:41.811Z [opencode] opencode 版本 1.4.3:带说明的拒绝不走新路由
66
+ 2026-10-05T02:23:41.812Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型
67
+ 2026-10-05T02:23:41.898Z [opencode] opencode 版本 读不出:带说明的拒绝不走新路由
68
+ 2026-10-05T02:23:41.898Z [opencode] opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型