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/opencode.mjs CHANGED
@@ -21,8 +21,13 @@
21
21
  // 🔴 窗口模式反过来:任何失败都不表态,授权框照常由人处理。
22
22
 
23
23
  import { spawn } from 'node:child_process'
24
+ import { appendFileSync } from 'node:fs'
24
25
  import { dirname, join } from 'node:path'
25
26
  import { fileURLToPath } from 'node:url'
27
+ import { alwaysSuggestions, askToCc, isPlainQuestion, ocReplyOf, questionToCc, rememberTitle, supportsPermissionReply, toCcTool } from './ocTranslate.mjs'
28
+
29
+ // 翻译函数搬到了 ocTranslate.mjs(与 DumateWatch 共用),这里转出去保持原来的导出
30
+ export { alwaysSuggestions, askToCc, isPlainQuestion, ocReplyOf, questionToCc, rememberTitle, supportsPermissionReply, toCcTool }
26
31
 
27
32
  const HERE = dirname(fileURLToPath(import.meta.url))
28
33
  const HOOK = join(HERE, 'hook.cjs')
@@ -35,6 +40,8 @@ const APPROVED_CALL_TTL_MS = 60 * 1000
35
40
  const CALL_TTL_MS = 10 * 60 * 1000
36
41
  /** hook.cjs 自己有 2 秒上报超时,这里再兜一层:一次卡死不能堵住后面所有状态 */
37
42
  const HOOK_KILL_MS = 10 * 1000
43
+ /** chat.message 里同步取「窗口外追加」的上限:它卡着用户这句话发出去 */
44
+ const APPENDED_KILL_MS = 3 * 1000
38
45
 
39
46
  /**
40
47
  * 远程模式要压成 deny、插件加载后再放开的权限名。与 src/daemon/executor.ts 的 OPENCODE_GATED_KEYS 必须一致(有测试)。
@@ -60,51 +67,6 @@ export function isHeadless(argv = process.argv) {
60
67
  return (end >= 0 ? args.slice(0, end) : args).includes('run')
61
68
  }
62
69
 
63
- /** opencode 工具 → cc-hook 同构的 tool_name / tool_input(daemon 的 classify 按 Claude Code 的工具名判断) */
64
- export function toCcTool(tool, args) {
65
- const a = args && typeof args === 'object' ? args : {}
66
- const s = (v) => (typeof v === 'string' ? v : '')
67
- switch (tool) {
68
- case 'bash':
69
- return { tool_name: 'Bash', tool_input: { command: s(a.command) } }
70
- case 'read':
71
- return { tool_name: 'Read', tool_input: { file_path: s(a.filePath) } }
72
- case 'edit':
73
- return { tool_name: 'Edit', tool_input: { file_path: s(a.filePath) } }
74
- case 'write':
75
- return { tool_name: 'Write', tool_input: { file_path: s(a.filePath) } }
76
- case 'glob':
77
- return { tool_name: 'Glob', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
78
- case 'grep':
79
- return { tool_name: 'Grep', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
80
- case 'list':
81
- return { tool_name: 'LS', tool_input: { path: s(a.path) } }
82
- case 'webfetch':
83
- return { tool_name: 'WebFetch', tool_input: { url: s(a.url) } }
84
- case 'task':
85
- return { tool_name: 'Task', tool_input: {} }
86
- case 'todowrite':
87
- case 'todoread':
88
- return { tool_name: 'TodoWrite', tool_input: {} }
89
- case 'patch':
90
- case 'apply_patch':
91
- return { tool_name: 'apply_patch', tool_input: { patch: s(a.patchText) || s(a.patch) || s(a.input) } }
92
- default:
93
- // 不认识的工具(MCP 等)原名原参数交出去:classify 对不认识的一律推设备
94
- return { tool_name: String(tool || ''), tool_input: a }
95
- }
96
- }
97
-
98
- /** permission.asked → PermissionRequest 的 tool_name / tool_input,给设备卡片生成摘要 */
99
- export function askToCc(props) {
100
- const md = (props && props.metadata) || {}
101
- if (typeof md.command === 'string' && md.command) return { tool_name: 'Bash', tool_input: { command: md.command } }
102
- const file = md.filepath || md.filePath
103
- if (typeof file === 'string' && file) return { tool_name: 'Edit', tool_input: { file_path: file } }
104
- const patterns = Array.isArray(props && props.patterns) ? props.patterns.join(' ') : ''
105
- return { tool_name: String((props && props.permission) || 'permission'), tool_input: { path: patterns } }
106
- }
107
-
108
70
  /**
109
71
  * 远程模式:关卡改由 tool.execute.before 把,放开权限。🔴 必须原地改(整对象替换不生效,O8)。
110
72
  * - OC_GATED_KEYS 是 daemon 压成 deny 的,放成 allow(用户原来在这些键上写的值已被覆盖,找不回来 —— 每一步都要过设备)
@@ -130,6 +92,46 @@ export function openPermissions(cfg) {
130
92
  if (build && build.permission && typeof build.permission === 'object') open(build.permission)
131
93
  }
132
94
 
95
+ /** chat.message 的 output({ message, parts })里用户自己打的字:text 类 part,跳过 synthetic(插件 / 系统补的) */
96
+ export function userText(output) {
97
+ const parts = output && Array.isArray(output.parts) ? output.parts : []
98
+ return parts
99
+ .filter((p) => p && p.type === 'text' && !p.synthetic && typeof p.text === 'string')
100
+ .map((p) => p.text)
101
+ .join('\n')
102
+ }
103
+
104
+ /** opencode 风格的 part id(prt_ + 时间 + 随机),只要求唯一且大致递增 */
105
+ function partId() {
106
+ return 'prt_' + Date.now().toString(16).padStart(12, '0') + Math.random().toString(16).slice(2, 16).padEnd(14, '0')
107
+ }
108
+
109
+ /**
110
+ * 切片 C:把 hook.cjs `--appended` 快速路径的输出补进这条用户消息:additionalContext 作为一条 synthetic 文本 part
111
+ * (模型看得到、userText 不会把它当成用户打的字)。返回要 toast 的 systemMessage。任何异常吞掉、不影响发消息
112
+ */
113
+ export function injectAppended(output, raw) {
114
+ let o
115
+ try {
116
+ o = JSON.parse(raw)
117
+ } catch (_) {
118
+ return ''
119
+ }
120
+ const ctx = o && o.hookSpecificOutput && o.hookSpecificOutput.additionalContext
121
+ const msg = output && output.message
122
+ if (typeof ctx === 'string' && ctx && output && Array.isArray(output.parts)) {
123
+ output.parts.push({
124
+ id: partId(),
125
+ sessionID: (msg && msg.sessionID) || '',
126
+ messageID: (msg && msg.id) || '',
127
+ type: 'text',
128
+ text: ctx,
129
+ synthetic: true,
130
+ })
131
+ }
132
+ return o && typeof o.systemMessage === 'string' ? o.systemMessage : ''
133
+ }
134
+
133
135
  /** 活着的子进程;opencode 退出时一并结束,不留孤儿 gate 挂着设备卡片(评审) */
134
136
  const LIVE = new Set()
135
137
  process.on('exit', () => {
@@ -173,7 +175,18 @@ function startChild(node, script, args, input, killMs = 0) {
173
175
 
174
176
  const runChild = (node, script, args, input, killMs) => startChild(node, script, args, input, killMs).done
175
177
 
178
+ /**
179
+ * 跑在百度搭子(DuMate,内核是改名的 opencode:dumate-opencode)里。那边的会话由 daemon 的 DumateWatch 直连 HTTP 上报和推卡,
180
+ * 插件再报一遍就是 tool=opencode 的第二份会话 + 双卡(spec 2026-10-03 §3.2「防双卡」)
181
+ */
182
+ export function isDumateHost(env = process.env, execPath = process.execPath) {
183
+ return !!(env && env.DUMATE_INAPP_KEY) || /dumate-opencode/i.test(String(execPath || ''))
184
+ }
185
+
176
186
  export function make(opts = {}) {
187
+ if (isDumateHost(opts.env, opts.execPath)) return async function DeskBuddyOff() {
188
+ return {}
189
+ }
177
190
  return async function DeskBuddy(ctx) {
178
191
  const node = process.env.DESKBUDDY_NODE || opts.node || 'node'
179
192
  const client = ctx && ctx.client
@@ -183,9 +196,13 @@ export function make(opts = {}) {
183
196
 
184
197
  // ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
185
198
  let queue = Promise.resolve()
199
+ const titles = new Map() // sessionID → opencode 自动生成的标题(session.updated 里来)
200
+ const children = new Set() // 子会话(subagent,带 parentID):它们的 Reject 在电脑上会弹输入框
186
201
  const report = (event, sessionID, extra) => {
187
202
  if (!sessionID) return
188
203
  const input = { hook_event_name: event, session_id: sessionID, cwd, ...extra }
204
+ const title = titles.get(sessionID)
205
+ if (title) input.oc_session_title = title
189
206
  queue = queue.then(() => runChild(node, HOOK, [TOOL], input, HOOK_KILL_MS)).catch(() => {})
190
207
  }
191
208
 
@@ -232,7 +249,18 @@ export function make(opts = {}) {
232
249
  await reply(sessionID, id, 'once')
233
250
  return
234
251
  }
235
- const input = { hook_event_name: 'PermissionRequest', session_id: sessionID, cwd, ...cc }
252
+ const sugg = alwaysSuggestions(p)
253
+ const input = {
254
+ hook_event_name: 'PermissionRequest',
255
+ session_id: sessionID,
256
+ cwd,
257
+ ...cc,
258
+ ...(sugg ? { permission_suggestions: sugg } : {}),
259
+ // 授权选项透传:设备上照 TUI 出 Allow once / Allow always / Reject(daemon 按这几项生成)
260
+ oc_permission: typeof p.permission === 'string' ? p.permission : '',
261
+ oc_always: Array.isArray(p.always) ? p.always.filter((x) => typeof x === 'string') : [],
262
+ oc_child: children.has(sessionID),
263
+ }
236
264
  const { child, done } = startChild(node, GATE, ['--window', '--tool', TOOL], input)
237
265
  if (child) pending.set(id, child)
238
266
  const r = await done
@@ -244,16 +272,113 @@ export function make(opts = {}) {
244
272
  } catch (_) {
245
273
  return
246
274
  }
275
+ const r2 = ocReplyOf(out)
276
+ if (!r2) return
277
+ if (r2.reply !== 'reject' && callID && call) approvedCalls.set(callID, Date.now())
278
+ if (r2.message && (await canReplyWithMessage())) {
279
+ // 拒绝并说明:只有新路由收 message(旧路由 200 但会把 message 吃掉,notes §1.3);新路由失败退回不带说明的拒绝
280
+ const ok = await postRaw('/permission/{requestID}/reply', { requestID: id }, { reply: 'reject', message: r2.message })
281
+ if (!ok) await reply(sessionID, id, 'reject')
282
+ } else {
283
+ // 版本判断不了 / 不支持:不碰新路由(未知路由可能被转发出去),退回旧路由拒绝,反馈丢弃
284
+ if (r2.message) debugLog('opencode 版本不确定支持带说明的拒绝,设备上的反馈没有带给模型')
285
+ await reply(sessionID, id, r2.reply)
286
+ }
287
+ if (typeof out.systemMessage === 'string' && out.systemMessage) await toast(out.systemMessage)
288
+ }
289
+
290
+ // ---- 选择题(question.asked):推设备作答,答案经 /question/{id}/reply 回填;设备跳过 → reject ----
291
+ const pendingQ = new Map() // requestID → gate 子进程
292
+ const debugLog = (line) => {
293
+ try {
294
+ appendFileSync(join(HERE, 'hook-debug.log'), `${new Date().toISOString()} [opencode] ${line}\n`, 'utf8')
295
+ } catch (_) {
296
+ /* 记不下不影响 */
297
+ }
298
+ }
299
+ /** 返回是否成功(hey-api 客户端不抛错,失败在返回值的 error / response.status 里) */
300
+ const postRaw = async (url, path, body) => {
301
+ // 插件拿到的是 v1 SDK 客户端,没有 question 的方法:用它底下的 HTTP 客户端直接打(路径参数由客户端填)
302
+ const http = client && (client._client || client.client)
303
+ if (!http || typeof http.post !== 'function') {
304
+ debugLog(`回填选择题失败:客户端没有 post(${url})`)
305
+ return false
306
+ }
307
+ try {
308
+ const r = await http.post({ url, path, ...(body ? { body, headers: { 'Content-Type': 'application/json' } } : {}) })
309
+ const status = r && r.response && typeof r.response.status === 'number' ? r.response.status : 0
310
+ if ((r && r.error) || status >= 400) {
311
+ debugLog(`回填选择题失败:${url} status=${status || '?'}`)
312
+ return false
313
+ }
314
+ return true
315
+ } catch (e) {
316
+ debugLog(`回填选择题出错:${url} ${(e && e.message) || e}`)
317
+ return false
318
+ }
319
+ }
320
+ /** opencode 版本(GET /global/health 的 version,进程内只问一次)是否支持 /permission/{id}/reply 带 message */
321
+ let replyMsgOk = null
322
+ const canReplyWithMessage = async () => {
323
+ if (replyMsgOk !== null) return replyMsgOk
324
+ let version = ''
325
+ try {
326
+ const http = client && (client._client || client.client)
327
+ const r = http && typeof http.get === 'function' ? await http.get({ url: '/global/health' }) : null
328
+ const data = r && (r.data !== undefined ? r.data : null)
329
+ version = data && typeof data.version === 'string' ? data.version : ''
330
+ } catch (_) {
331
+ version = ''
332
+ }
333
+ replyMsgOk = supportsPermissionReply(version)
334
+ if (!replyMsgOk) debugLog(`opencode 版本 ${version || '读不出'}:带说明的拒绝不走新路由`)
335
+ return replyMsgOk
336
+ }
337
+ const onQuestion = async (p) => {
338
+ const { id, sessionID } = p || {}
339
+ if (!id || !sessionID || runId || headless || !client) return
340
+ const call = p.tool && p.tool.callID ? calls.get(p.tool.callID) : null
341
+ if (!isPlainQuestion(p, call && call.tool)) return
342
+ const cc = questionToCc(p)
343
+ if (!cc) return
344
+ const input = { hook_event_name: 'PermissionRequest', session_id: sessionID, cwd, ...cc }
345
+ const { child, done } = startChild(node, GATE, ['--window', '--tool', TOOL], input)
346
+ if (child) pendingQ.set(id, child)
347
+ const r = await done
348
+ pendingQ.delete(id)
349
+ if (r.code !== 0 || !r.out) return
350
+ let out
351
+ try {
352
+ out = JSON.parse(r.out)
353
+ } catch (_) {
354
+ return
355
+ }
247
356
  const d = out && out.hookSpecificOutput && out.hookSpecificOutput.decision
248
357
  if (!d) return
249
- if (d.behavior === 'allow') {
250
- if (callID && call) approvedCalls.set(callID, Date.now())
251
- await reply(sessionID, id, 'once')
358
+ let ok
359
+ if (d.behavior === 'allow' && Array.isArray(out.deskbuddyAnswers)) {
360
+ ok = await postRaw('/question/{requestID}/reply', { requestID: id }, { answers: out.deskbuddyAnswers })
252
361
  } else if (d.behavior === 'deny') {
253
- await reply(sessionID, id, 'reject')
362
+ ok = await postRaw('/question/{requestID}/reject', { requestID: id })
254
363
  } else return
364
+ if (!ok) {
365
+ await toast('设备答案未能回填,请在窗口作答')
366
+ return
367
+ }
255
368
  if (typeof out.systemMessage === 'string' && out.systemMessage) await toast(out.systemMessage)
256
369
  }
370
+ const onQuestionDone = (p) => {
371
+ // 人先在窗口里答了 / 拒了:结束 gate 子进程,daemon 随之撤掉设备上的卡片
372
+ const child = p && pendingQ.get(p.requestID)
373
+ if (child) {
374
+ pendingQ.delete(p.requestID)
375
+ try {
376
+ child.kill('SIGTERM')
377
+ } catch (_) {
378
+ /* 已经退了 */
379
+ }
380
+ }
381
+ }
257
382
 
258
383
  const onReplied = (p) => {
259
384
  // 人先在窗口里答了:结束 gate 子进程,daemon 随之撤掉设备上的卡片
@@ -273,13 +398,26 @@ export function make(opts = {}) {
273
398
  if (runId) openPermissions(cfg)
274
399
  },
275
400
 
276
- 'chat.message': async (input) => {
277
- report('UserPromptSubmit', input && input.sessionID)
401
+ 'chat.message': async (input, output) => {
402
+ // prompt 给 hook 的 first-prompt 标题来源用(只记首条,见 hook.cjs)
403
+ report('UserPromptSubmit', input && input.sessionID, { prompt: userText(output) })
404
+ // 切片 C:设备在这个窗口之外追加、daemon 后台跑完的轮次,同步取出补进这条消息(常规 report 是异步的,输出不读)
405
+ // daemon 起的远程 run(runId)不取:那是留给用户窗口的(hook 侧还有 DESKBUDDY_HEADLESS 兜一层)
406
+ if (runId || !(input && input.sessionID)) return
407
+ try {
408
+ const r = await runChild(node, HOOK, [TOOL, '--appended'], { hook_event_name: 'UserPromptSubmit', session_id: input.sessionID, cwd }, APPENDED_KILL_MS)
409
+ if (r.code === 0 && r.out) {
410
+ const note = injectAppended(output, r.out)
411
+ if (note) await toast(note)
412
+ }
413
+ } catch (_) {
414
+ /* 补不了就算了 */
415
+ }
278
416
  },
279
417
 
280
418
  'tool.execute.before': async (input, output) => {
281
419
  const cc = toCcTool(input && input.tool, output && output.args)
282
- if (input && input.callID) calls.set(input.callID, { cc, at: Date.now() })
420
+ if (input && input.callID) calls.set(input.callID, { cc, tool: input && input.tool, at: Date.now() })
283
421
  report('PreToolUse', input && input.sessionID, cc)
284
422
  if (!runId) return
285
423
  // 远程关卡:等设备。任何失败都拒(见顶部)
@@ -315,10 +453,23 @@ export function make(opts = {}) {
315
453
  case 'permission.replied':
316
454
  onReplied(p)
317
455
  break
456
+ case 'question.asked':
457
+ onQuestion(p).catch(() => {})
458
+ break
459
+ case 'question.replied':
460
+ case 'question.rejected':
461
+ onQuestionDone(p)
462
+ break
318
463
  case 'session.idle':
319
464
  report('Stop', p.sessionID)
320
465
  break
466
+ case 'session.created':
467
+ case 'session.updated':
468
+ if (event.type === 'session.updated') rememberTitle(titles, p.info)
469
+ if (p.info && typeof p.info.id === 'string' && p.info.parentID) children.add(p.info.id)
470
+ break
321
471
  case 'session.deleted':
472
+ children.delete((p.info && p.info.id) || p.sessionID)
322
473
  report('SessionEnd', (p.info && p.info.id) || p.sessionID)
323
474
  break
324
475
  default: