deskbuddy 0.2.0-beta.13 → 0.2.0-beta.14

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 (46) hide show
  1. package/dist/cli.js +2241 -359
  2. package/dist/gate.cjs +21 -13
  3. package/dist/hook.cjs +287 -9
  4. package/dist/ocTranslate.mjs +106 -0
  5. package/dist/opencode.mjs +100 -69
  6. package/hook/gate.cjs +21 -13
  7. package/hook/hook-debug.log +5 -0
  8. package/hook/hook.cjs +287 -9
  9. package/hook/ocTranslate.d.mts +7 -0
  10. package/hook/ocTranslate.mjs +106 -0
  11. package/hook/opencode.d.mts +4 -1
  12. package/hook/opencode.mjs +100 -69
  13. package/package.json +3 -2
  14. package/vendor/node-pty/LICENSE +69 -0
  15. package/vendor/node-pty/lib/conpty_console_list_agent.js +16 -0
  16. package/vendor/node-pty/lib/eventEmitter2.js +47 -0
  17. package/vendor/node-pty/lib/index.js +52 -0
  18. package/vendor/node-pty/lib/interfaces.js +7 -0
  19. package/vendor/node-pty/lib/shared/conout.js +11 -0
  20. package/vendor/node-pty/lib/terminal.js +190 -0
  21. package/vendor/node-pty/lib/types.js +7 -0
  22. package/vendor/node-pty/lib/unixTerminal.js +346 -0
  23. package/vendor/node-pty/lib/utils.js +39 -0
  24. package/vendor/node-pty/lib/windowsConoutConnection.js +125 -0
  25. package/vendor/node-pty/lib/windowsPtyAgent.js +320 -0
  26. package/vendor/node-pty/lib/windowsTerminal.js +199 -0
  27. package/vendor/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  28. package/vendor/node-pty/package.json +7 -0
  29. package/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  30. package/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  31. package/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  32. package/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  33. package/vendor/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  34. package/vendor/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  35. package/vendor/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  36. package/vendor/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  37. package/vendor/node-pty/prebuilds/win32-arm64/pty.node +0 -0
  38. package/vendor/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
  39. package/vendor/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
  40. package/vendor/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  41. package/vendor/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  42. package/vendor/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  43. package/vendor/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  44. package/vendor/node-pty/prebuilds/win32-x64/pty.node +0 -0
  45. package/vendor/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
  46. package/vendor/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
package/dist/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, questionToCc, rememberTitle, toCcTool } from './ocTranslate.mjs'
28
+
29
+ // 翻译函数搬到了 ocTranslate.mjs(与 DumateWatch 共用),这里转出去保持原来的导出
30
+ export { alwaysSuggestions, askToCc, isPlainQuestion, questionToCc, rememberTitle, toCcTool }
26
31
 
27
32
  const HERE = dirname(fileURLToPath(import.meta.url))
28
33
  const HOOK = join(HERE, 'hook.cjs')
@@ -62,63 +67,6 @@ export function isHeadless(argv = process.argv) {
62
67
  return (end >= 0 ? args.slice(0, end) : args).includes('run')
63
68
  }
64
69
 
65
- /** opencode 工具 → cc-hook 同构的 tool_name / tool_input(daemon 的 classify 按 Claude Code 的工具名判断) */
66
- export function toCcTool(tool, args) {
67
- const a = args && typeof args === 'object' ? args : {}
68
- const s = (v) => (typeof v === 'string' ? v : '')
69
- switch (tool) {
70
- case 'bash':
71
- return { tool_name: 'Bash', tool_input: { command: s(a.command) } }
72
- case 'read':
73
- return { tool_name: 'Read', tool_input: { file_path: s(a.filePath) } }
74
- case 'edit':
75
- return { tool_name: 'Edit', tool_input: { file_path: s(a.filePath) } }
76
- case 'write':
77
- return { tool_name: 'Write', tool_input: { file_path: s(a.filePath) } }
78
- case 'glob':
79
- return { tool_name: 'Glob', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
80
- case 'grep':
81
- return { tool_name: 'Grep', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
82
- case 'list':
83
- return { tool_name: 'LS', tool_input: { path: s(a.path) } }
84
- case 'webfetch':
85
- return { tool_name: 'WebFetch', tool_input: { url: s(a.url) } }
86
- case 'task':
87
- return { tool_name: 'Task', tool_input: {} }
88
- case 'todowrite':
89
- case 'todoread':
90
- return { tool_name: 'TodoWrite', tool_input: {} }
91
- case 'patch':
92
- case 'apply_patch':
93
- return { tool_name: 'apply_patch', tool_input: { patch: s(a.patchText) || s(a.patch) || s(a.input) } }
94
- default:
95
- // 不认识的工具(MCP 等)原名原参数交出去:classify 对不认识的一律推设备
96
- return { tool_name: String(tool || ''), tool_input: a }
97
- }
98
- }
99
-
100
- /** permission.asked → PermissionRequest 的 tool_name / tool_input,给设备卡片生成摘要 */
101
- export function askToCc(props) {
102
- const md = (props && props.metadata) || {}
103
- if (typeof md.command === 'string' && md.command) return { tool_name: 'Bash', tool_input: { command: md.command } }
104
- const file = md.filepath || md.filePath
105
- if (typeof file === 'string' && file) return { tool_name: 'Edit', tool_input: { file_path: file } }
106
- const patterns = Array.isArray(props && props.patterns) ? props.patterns.join(' ') : ''
107
- return { tool_name: String((props && props.permission) || 'permission'), tool_input: { path: patterns } }
108
- }
109
-
110
- /**
111
- * 授权请求里 opencode 自己给的「以后都允许」模式(permission.asked 的 always,如 bash 的 `npm *`、edit 的 `*`)
112
- * → 一条 session 级 addRules 建议(与 Claude Code 的 permission_suggestions 同形),daemon 据此在设备上给「允许并记住」。
113
- * 设备选了记住,gate 输出带 updatedPermissions,插件回 always(opencode 在本次运行期间记住这些模式)。没有 always 返回 undefined
114
- */
115
- export function alwaysSuggestions(props) {
116
- const perm = String((props && props.permission) || '').trim()
117
- const always = props && Array.isArray(props.always) ? props.always.filter((x) => typeof x === 'string' && x.trim() !== '') : []
118
- if (!perm || always.length === 0) return undefined
119
- return [{ type: 'addRules', rules: always.map((x) => ({ toolName: perm, ruleContent: `${perm} ${x.trim()}` })), behavior: 'allow', destination: 'session' }]
120
- }
121
-
122
70
  /**
123
71
  * 远程模式:关卡改由 tool.execute.before 把,放开权限。🔴 必须原地改(整对象替换不生效,O8)。
124
72
  * - OC_GATED_KEYS 是 daemon 压成 deny 的,放成 allow(用户原来在这些键上写的值已被覆盖,找不回来 —— 每一步都要过设备)
@@ -184,17 +132,6 @@ export function injectAppended(output, raw) {
184
132
  return o && typeof o.systemMessage === 'string' ? o.systemMessage : ''
185
133
  }
186
134
 
187
- /** opencode 起标题前的占位名(1.18.32 isDefaultTitle:`New session - ` / `Child session - ` + ISO 时间),不当标题 */
188
- 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$/
189
-
190
- /** session.updated 的 info → 记下自动标题。子会话(subagent,带 parentID)、占位名不记 */
191
- export function rememberTitle(titles, info) {
192
- if (!info || typeof info.id !== 'string' || !info.id || info.parentID) return
193
- const t = typeof info.title === 'string' ? info.title.trim() : ''
194
- if (!t || OC_DEFAULT_TITLE_RE.test(t)) return
195
- titles.set(info.id, t.slice(0, 100))
196
- }
197
-
198
135
  /** 活着的子进程;opencode 退出时一并结束,不留孤儿 gate 挂着设备卡片(评审) */
199
136
  const LIVE = new Set()
200
137
  process.on('exit', () => {
@@ -238,7 +175,18 @@ function startChild(node, script, args, input, killMs = 0) {
238
175
 
239
176
  const runChild = (node, script, args, input, killMs) => startChild(node, script, args, input, killMs).done
240
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
+
241
186
  export function make(opts = {}) {
187
+ if (isDumateHost(opts.env, opts.execPath)) return async function DeskBuddyOff() {
188
+ return {}
189
+ }
242
190
  return async function DeskBuddy(ctx) {
243
191
  const node = process.env.DESKBUDDY_NODE || opts.node || 'node'
244
192
  const client = ctx && ctx.client
@@ -325,6 +273,82 @@ export function make(opts = {}) {
325
273
  if (typeof out.systemMessage === 'string' && out.systemMessage) await toast(out.systemMessage)
326
274
  }
327
275
 
276
+ // ---- 选择题(question.asked):推设备作答,答案经 /question/{id}/reply 回填;设备跳过 → reject ----
277
+ const pendingQ = new Map() // requestID → gate 子进程
278
+ const debugLog = (line) => {
279
+ try {
280
+ appendFileSync(join(HERE, 'hook-debug.log'), `${new Date().toISOString()} [opencode] ${line}\n`, 'utf8')
281
+ } catch (_) {
282
+ /* 记不下不影响 */
283
+ }
284
+ }
285
+ /** 返回是否成功(hey-api 客户端不抛错,失败在返回值的 error / response.status 里) */
286
+ const postRaw = async (url, path, body) => {
287
+ // 插件拿到的是 v1 SDK 客户端,没有 question 的方法:用它底下的 HTTP 客户端直接打(路径参数由客户端填)
288
+ const http = client && (client._client || client.client)
289
+ if (!http || typeof http.post !== 'function') {
290
+ debugLog(`回填选择题失败:客户端没有 post(${url})`)
291
+ return false
292
+ }
293
+ try {
294
+ const r = await http.post({ url, path, ...(body ? { body, headers: { 'Content-Type': 'application/json' } } : {}) })
295
+ const status = r && r.response && typeof r.response.status === 'number' ? r.response.status : 0
296
+ if ((r && r.error) || status >= 400) {
297
+ debugLog(`回填选择题失败:${url} status=${status || '?'}`)
298
+ return false
299
+ }
300
+ return true
301
+ } catch (e) {
302
+ debugLog(`回填选择题出错:${url} ${(e && e.message) || e}`)
303
+ return false
304
+ }
305
+ }
306
+ const onQuestion = async (p) => {
307
+ const { id, sessionID } = p || {}
308
+ if (!id || !sessionID || runId || headless || !client) return
309
+ const call = p.tool && p.tool.callID ? calls.get(p.tool.callID) : null
310
+ if (!isPlainQuestion(p, call && call.tool)) return
311
+ const cc = questionToCc(p)
312
+ if (!cc) return
313
+ const input = { hook_event_name: 'PermissionRequest', session_id: sessionID, cwd, ...cc }
314
+ const { child, done } = startChild(node, GATE, ['--window', '--tool', TOOL], input)
315
+ if (child) pendingQ.set(id, child)
316
+ const r = await done
317
+ pendingQ.delete(id)
318
+ if (r.code !== 0 || !r.out) return
319
+ let out
320
+ try {
321
+ out = JSON.parse(r.out)
322
+ } catch (_) {
323
+ return
324
+ }
325
+ const d = out && out.hookSpecificOutput && out.hookSpecificOutput.decision
326
+ if (!d) return
327
+ let ok
328
+ if (d.behavior === 'allow' && Array.isArray(out.deskbuddyAnswers)) {
329
+ ok = await postRaw('/question/{requestID}/reply', { requestID: id }, { answers: out.deskbuddyAnswers })
330
+ } else if (d.behavior === 'deny') {
331
+ ok = await postRaw('/question/{requestID}/reject', { requestID: id })
332
+ } else return
333
+ if (!ok) {
334
+ await toast('设备答案未能回填,请在窗口作答')
335
+ return
336
+ }
337
+ if (typeof out.systemMessage === 'string' && out.systemMessage) await toast(out.systemMessage)
338
+ }
339
+ const onQuestionDone = (p) => {
340
+ // 人先在窗口里答了 / 拒了:结束 gate 子进程,daemon 随之撤掉设备上的卡片
341
+ const child = p && pendingQ.get(p.requestID)
342
+ if (child) {
343
+ pendingQ.delete(p.requestID)
344
+ try {
345
+ child.kill('SIGTERM')
346
+ } catch (_) {
347
+ /* 已经退了 */
348
+ }
349
+ }
350
+ }
351
+
328
352
  const onReplied = (p) => {
329
353
  // 人先在窗口里答了:结束 gate 子进程,daemon 随之撤掉设备上的卡片
330
354
  const child = p && pending.get(p.requestID)
@@ -362,7 +386,7 @@ export function make(opts = {}) {
362
386
 
363
387
  'tool.execute.before': async (input, output) => {
364
388
  const cc = toCcTool(input && input.tool, output && output.args)
365
- if (input && input.callID) calls.set(input.callID, { cc, at: Date.now() })
389
+ if (input && input.callID) calls.set(input.callID, { cc, tool: input && input.tool, at: Date.now() })
366
390
  report('PreToolUse', input && input.sessionID, cc)
367
391
  if (!runId) return
368
392
  // 远程关卡:等设备。任何失败都拒(见顶部)
@@ -398,6 +422,13 @@ export function make(opts = {}) {
398
422
  case 'permission.replied':
399
423
  onReplied(p)
400
424
  break
425
+ case 'question.asked':
426
+ onQuestion(p).catch(() => {})
427
+ break
428
+ case 'question.replied':
429
+ case 'question.rejected':
430
+ onQuestionDone(p)
431
+ break
401
432
  case 'session.idle':
402
433
  report('Stop', p.sessionID)
403
434
  break
package/hook/gate.cjs CHANGED
@@ -213,13 +213,16 @@ async function windowMain() {
213
213
  if (res.status !== 200) return fail()
214
214
  const out = JSON.parse(res.data)
215
215
  if (!out || (out.decision !== 'allow' && out.decision !== 'deny')) return fail()
216
- const decision =
217
- out.decision === 'allow'
218
- ? Array.isArray(out.updatedPermissions) && out.updatedPermissions.length > 0
219
- ? { behavior: 'allow', updatedPermissions: out.updatedPermissions }
220
- : { behavior: 'allow' }
221
- : { behavior: 'deny', message: (typeof out.reason === 'string' && out.reason) || '用户在设备上拒绝了这一步' }
216
+ let decision
217
+ if (out.decision === 'allow') {
218
+ decision = { behavior: 'allow' }
219
+ // 选择题作答(设备答选择题 spec §3.4):daemon 用本地原始 tool_input 按下标拼好的,原样交回
220
+ if (out.updatedInput && typeof out.updatedInput === 'object' && !Array.isArray(out.updatedInput)) decision.updatedInput = out.updatedInput
221
+ if (Array.isArray(out.updatedPermissions) && out.updatedPermissions.length > 0) decision.updatedPermissions = out.updatedPermissions
222
+ } else decision = { behavior: 'deny', message: (typeof out.reason === 'string' && out.reason) || '用户在设备上拒绝了这一步' }
222
223
  const body = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
224
+ // opencode 插件回填选择题要的是每题的 label 数组(它不认 updatedInput);Claude 系不带,免得 hook 输出校验不认这个字段
225
+ if (tool === 'opencode' && out.decision === 'allow' && Array.isArray(out.answerLists)) body.deskbuddyAnswers = out.answerLists
223
226
  // 窗口里告诉人是在哪台设备上处理的
224
227
  if (typeof out.notice === 'string' && out.notice) body.systemMessage = out.notice.slice(0, 300)
225
228
  process.stdout.write(JSON.stringify(body), () => process.exit(0))
@@ -273,13 +276,18 @@ async function main() {
273
276
  return
274
277
  }
275
278
 
276
- const payload = JSON.stringify({
277
- hookSpecificOutput: {
278
- hookEventName: 'PreToolUse',
279
- permissionDecision: out.decision,
280
- permissionDecisionReason: reason,
281
- },
282
- })
279
+ const spec = {
280
+ hookEventName: 'PreToolUse',
281
+ permissionDecision: out.decision,
282
+ permissionDecisionReason: reason,
283
+ }
284
+ // 远程选择题作答(WorkBuddy 的 codebuddy -p):带上答案。codebuddy 的命令 hook 读 hookSpecificOutput.modifiedInput,
285
+ // 合并多个 hook 结果时读 updatedInput(Claude 兼容字段)—— 两个都带(codebuddy WorkBuddy 内置版源码核对)
286
+ if (out.decision === 'allow' && out.updatedInput && typeof out.updatedInput === 'object' && !Array.isArray(out.updatedInput)) {
287
+ spec.updatedInput = out.updatedInput
288
+ spec.modifiedInput = out.updatedInput
289
+ }
290
+ const payload = JSON.stringify({ hookSpecificOutput: spec })
283
291
  // 等写完再退:管道写入是异步的,写一半就 exit 的话 allow / defer 会丢
284
292
  process.stdout.write(payload, (err) => (err ? fail('写 hook 输出失败') : process.exit(0)))
285
293
  }
@@ -0,0 +1,5 @@
1
+ 2026-10-02T13:19:27.363Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
2
+ 2026-10-02T14:34:18.393Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
3
+ 2026-10-03T02:01:37.783Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
4
+ 2026-10-03T02:16:17.435Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
5
+ 2026-10-03T02:19:56.607Z [opencode] 回填选择题失败:/question/{requestID}/reply status=404
package/hook/hook.cjs CHANGED
@@ -147,6 +147,45 @@ const FALLBACK_REGISTRY = {
147
147
  },
148
148
  lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
149
149
  },
150
+ // 千问办公(QwenWorkCN.app,内核是 App 内置的 Qoder CN CLI,hooks 读 ~/.QwenWorkCN/settings.json)。
151
+ // PermissionRequest 不触发:授权框只来一条 Notification(permission_prompt)(spec 2026-10-03 §2.1)
152
+ qwenwork: {
153
+ format: 'cc-hook',
154
+ processNames: ['QwenWorkCN'],
155
+ // 工作目录是 ~/.qwenworkcn/workspace/<chatId>,目录名不能当标题
156
+ titleSources: ['first-prompt', 'project'],
157
+ turnEvents: ['UserPromptSubmit'],
158
+ events: {
159
+ UserPromptSubmit: 'watch',
160
+ PreToolUse: 'watch',
161
+ PostToolUse: 'watch',
162
+ Stop: 'done',
163
+ },
164
+ conditionalEvents: {
165
+ Notification: [
166
+ // 实测 message 是 "Tool Bash requires confirmation"(选择题是 "Tool AskUserQuestion requires confirmation"),与服务端注册表一致
167
+ { match: 'requires confirmation|permission|approve|需要确认|授权', state: 'ask_approval' },
168
+ { match: 'waiting for your input|idle|闲置', state: 'done' },
169
+ ],
170
+ },
171
+ lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
172
+ },
173
+ // 百度搭子(DuMate.app,内核是改名的 opencode:dumate-opencode serve)。不往 DuMate 里装任何东西:
174
+ // daemon 的 DumateWatch 直连它的 HTTP 接口,把事件翻成 cc 同构输入喂 hook.cjs(spec 2026-10-03 §3.2)
175
+ dumate: {
176
+ format: 'dumate-http',
177
+ processNames: ['dumate-opencode'],
178
+ titleSources: ['oc-session-title', 'first-prompt'],
179
+ turnEvents: ['UserPromptSubmit'],
180
+ events: {
181
+ UserPromptSubmit: 'watch',
182
+ PreToolUse: 'watch',
183
+ PostToolUse: 'watch',
184
+ Stop: 'done',
185
+ PermissionRequest: 'ask_approval',
186
+ },
187
+ lifecycle: { SessionEnd: 'remove' },
188
+ },
150
189
  opencode: {
151
190
  format: 'oc-plugin',
152
191
  processNames: ['opencode'],
@@ -754,6 +793,8 @@ function writeEnded(s, now) {
754
793
  const rec = { sessionId: sid, tool: s.tool || '', cwd: s.cwd || '', interactive: s.interactive === true, endedAt: t }
755
794
  // 轮数随记录留下:--resume 同一个会话时接着数(窗口对话轮数 spec §2.1)
756
795
  if (turnsOf(s) !== undefined) rec.turns = s.turns
796
+ // 首句也留下:会话结束后再被续跑(--resume)时,标题还用它,不被续跑那句话顶掉(Buglist 161)
797
+ if (typeof s.firstPrompt === 'string' && s.firstPrompt) rec.firstPrompt = s.firstPrompt
757
798
  const file = path.join(dir, sid + '.json')
758
799
  fs.writeFileSync(file + '.tmp', JSON.stringify(rec), { encoding: 'utf8', mode: 0o600 })
759
800
  fs.renameSync(file + '.tmp', file)
@@ -770,19 +811,41 @@ function writeEnded(s, now) {
770
811
  }
771
812
  }
772
813
 
773
- /** 已结束记录里留下的轮数(与 src/core/ended.ts 的 readEnded 同一有效期);没有返回 undefined */
774
- function endedTurns(sessionId) {
814
+ /** 已结束记录(与 src/core/ended.ts 的 readEnded 同一有效期);没有 / 过期返回 null */
815
+ function readEndedRecord(sessionId) {
775
816
  const sid = String(sessionId || '')
776
- if (!/^[A-Za-z0-9_-]{1,64}$/.test(sid)) return undefined
817
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(sid)) return null
777
818
  try {
778
819
  const r = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'ended', sid + '.json'), 'utf8'))
779
- if (!r || r.sessionId !== sid || typeof r.endedAt !== 'number' || Date.now() - r.endedAt > ENDED_TTL_MS) return undefined
780
- return turnsOf(r)
820
+ if (!r || r.sessionId !== sid || typeof r.endedAt !== 'number' || Date.now() - r.endedAt > ENDED_TTL_MS) return null
821
+ return r
781
822
  } catch (e) {
782
- return undefined
823
+ return null
783
824
  }
784
825
  }
785
826
 
827
+ /** 已结束记录里留下的轮数;没有返回 undefined */
828
+ function endedTurns(sessionId) {
829
+ return turnsOf(readEndedRecord(sessionId))
830
+ }
831
+
832
+ /** 已结束记录里留下的首句;没有返回 undefined */
833
+ function endedFirstPrompt(sessionId) {
834
+ const r = readEndedRecord(sessionId)
835
+ return r && typeof r.firstPrompt === 'string' && r.firstPrompt ? r.firstPrompt : undefined
836
+ }
837
+
838
+ /**
839
+ * DeskBuddy 自己注入的话(daemon 续跑时发给智能体的「你在设备上已批准…」,与 src/daemon/index.ts 的 INTERNAL_PROMPT_MARK 一致):
840
+ * 不是用户说的,不当会话首句(标题)、不算一轮(Buglist 161)。老 daemon(beta.14 及以前)发的没有标记,按固定开头认
841
+ */
842
+ const INTERNAL_PROMPT_MARK = '[DeskBuddy]'
843
+ function isInternalPrompt(text) {
844
+ if (typeof text !== 'string') return false
845
+ const t = text.trim()
846
+ return t.startsWith(INTERNAL_PROMPT_MARK) || t.startsWith('(你在设备上已批准:')
847
+ }
848
+
786
849
  /** 会话结束:先写已结束记录,再删状态(删状态的地方都走这里,/clear 除外) */
787
850
  function retireState(s, now) {
788
851
  writeEnded(s, now)
@@ -801,6 +864,22 @@ function removeState(sessionId) {
801
864
  * 读取某工具下所有【进程还活着】的会话,顺手清理已死的。
802
865
  * SessionEnd 极不可靠(用户叉掉窗口时根本不触发),进程存活才是唯一硬信号。
803
866
  */
867
+ /** 与 src/core/state.ts 的 STALE_NO_PID_MS / STALE_NO_PID_LOG / isStaleNoPid 一致 */
868
+ const STALE_NO_PID_MS = 2 * 60 * 60 * 1000
869
+ const STALE_NO_PID_LOG = '无进程号且 2 小时无事件,按已结束处理'
870
+ function pendingApprovalSessions() {
871
+ try {
872
+ const recs = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'approvals.json'), 'utf8'))
873
+ return new Set(Array.isArray(recs) ? recs.filter((r) => r && r.status === 'pending' && typeof r.sessionId === 'string').map((r) => r.sessionId) : [])
874
+ } catch (e) {
875
+ return new Set()
876
+ }
877
+ }
878
+ function isStaleNoPid(s, now, pending) {
879
+ if (s.pid || (s.state !== 'watch' && s.state !== 'ask_approval') || !(now - s.updatedAt > STALE_NO_PID_MS)) return false
880
+ return !(pending || pendingApprovalSessions)().has(s.sessionId)
881
+ }
882
+
804
883
  function readAliveStates(tool) {
805
884
  let files
806
885
  try {
@@ -827,6 +906,16 @@ function readAliveStates(tool) {
827
906
  retireState(s) // 窗口已关:记下已结束、剔除并自我清理,state 目录不再无限增长
828
907
  continue
829
908
  }
909
+ // 没记进程号、2 小时没新事件的「进行中」会话按已结束处理(与 src/core/state.ts isStaleNoPid 一致)
910
+ if (isStaleNoPid(s, Date.now())) {
911
+ try {
912
+ fs.appendFileSync(path.join(deskbuddyHome(), 'daemon.log'), `${new Date().toISOString()} 会话 ${String(s.sessionId).slice(0, 8)} ${STALE_NO_PID_LOG}\n`, 'utf8')
913
+ } catch (e) {
914
+ /* 记不下不影响 */
915
+ }
916
+ retireState(s)
917
+ continue
918
+ }
830
919
  alive.push(s)
831
920
  }
832
921
  return alive
@@ -1468,6 +1557,170 @@ function setTurns(next, disk, prev, isTurn, sessionId) {
1468
1557
  else next.turns = v
1469
1558
  }
1470
1559
 
1560
+ /**
1561
+ * Codex 的 hook 输入长相:几乎每个事件都带 turn_id;transcript 在 ~/.codex/sessions/…/rollout-*.jsonl。
1562
+ * 🔴 至少两个特征同时成立才算(评审):单看一个可能误伤将来的 Claude Code(例如它也给更多事件加 turn_id)
1563
+ */
1564
+ function looksLikeCodex(payload) {
1565
+ const tp = typeof payload.transcript_path === 'string' ? payload.transcript_path : ''
1566
+ let n = 0
1567
+ if (typeof payload.turn_id === 'string' && payload.turn_id && payload.hook_event_name !== 'MessageDisplay') n++
1568
+ if (/(^|[\\/])rollout-[^\\/]*\.jsonl$/i.test(tp)) n++
1569
+ if (/[\\/]\.codex[\\/]sessions[\\/]/.test(tp)) n++
1570
+ return n >= 2
1571
+ }
1572
+
1573
+ /** 排查用:被 foreignHost 拦下时记一行到 ~/.deskbuddy/hook-debug.log(不写 stderr:有的宿主会把 hook 的 stderr 当输入)。不记任何文本 */
1574
+ function noteForeign(tool, payload) {
1575
+ try {
1576
+ const p = path.join(deskbuddyHome(), 'hook-debug.log')
1577
+ fs.mkdirSync(deskbuddyHome(), { recursive: true })
1578
+ try {
1579
+ if (fs.statSync(p).size > 256 * 1024) fs.truncateSync(p, 0)
1580
+ } catch (e) {
1581
+ /* 还没有 */
1582
+ }
1583
+ const sid = String(payload.session_id || '').slice(0, 8)
1584
+ const ev = String(payload.hook_event_name || '').slice(0, 40)
1585
+ fs.appendFileSync(p, `${new Date().toISOString()} 宿主不是 ${tool},忽略 event=${ev} session=${sid}\n`, 'utf8')
1586
+ } catch (e) {
1587
+ /* 记不下不影响 */
1588
+ }
1589
+ }
1590
+
1591
+ /** Claude Code 的 hook 输入长相:transcript 在 <配置目录>/projects/<目录名>/<会话号>.jsonl,没有 turn_id */
1592
+ function looksLikeClaude(payload) {
1593
+ const tp = typeof payload.transcript_path === 'string' ? payload.transcript_path : ''
1594
+ return !payload.turn_id && /[\\/]projects[\\/][^\\/]+[\\/][0-9a-f-]{36}\.jsonl$/i.test(tp)
1595
+ }
1596
+
1597
+ /**
1598
+ * 命令行上的工具标识与真正触发 hook 的宿主对不上(2026-10-02 现场):同一台电脑 Codex 会话(UUIDv7)同时以 tool=claude_code
1599
+ * 上报,设备上多出一张「进行中」卡挂了 20 分钟。新版 Codex 能从 Claude Code 导入配置(/import「import setup … from Claude Code」,
1600
+ * 含 ~/.claude/settings.json 里的 hooks),导进去的是我们给 Claude 装的 `hook.cjs claude_code`,于是在 Codex 会话里也按 claude_code 报。
1601
+ * 反过来 Claude 触发 codex 的条目同理。认出来就静默退出:那个会话由它自己工具的 hook 正常上报
1602
+ */
1603
+ function foreignHost(tool, payload) {
1604
+ if (!payload || typeof payload !== 'object') return false
1605
+ if (tool === 'claude_code') return looksLikeCodex(payload)
1606
+ if (tool === 'codex') return looksLikeClaude(payload)
1607
+ return false
1608
+ }
1609
+
1610
+ /**
1611
+ * 保险(设备答选择题,Qoder 待实机确认,发版前联调必验):设备在 Qoder 上答了选择题(回显是「已在…上回答」),
1612
+ * 可这一步跑完的 PostToolUse(AskUserQuestion) 输入里却没有 answers —— 说明 Qoder 没采用 hook 回填的答案。
1613
+ * 记一行到 daemon.log,方便联调时发现
1614
+ */
1615
+ function checkQoderAnswerAdopted(tool, event, payload, notice) {
1616
+ try {
1617
+ if ((tool !== 'qoder_cn' && tool !== 'qoder') || event !== 'PostToolUse' || payload.tool_name !== 'AskUserQuestion') return
1618
+ if (typeof notice !== 'string' || !/已在.*上回答/.test(notice)) return
1619
+ const a = payload.tool_input && payload.tool_input.answers
1620
+ if (a && typeof a === 'object' && Object.keys(a).length > 0) return
1621
+ const line = `${new Date().toISOString()} Qoder 未采用设备答案(tool=${tool} session=${String(payload.session_id || '').slice(0, 8)})\n`
1622
+ fs.mkdirSync(deskbuddyHome(), { recursive: true })
1623
+ fs.appendFileSync(path.join(deskbuddyHome(), 'daemon.log'), line, 'utf8')
1624
+ } catch (e) {
1625
+ /* 记不下不影响 */
1626
+ }
1627
+ }
1628
+
1629
+ /**
1630
+ * daemon 代智能体起的 hook(百度搭子:DumateWatch 直连 HTTP,把事件喂给本文件)往上找不到智能体进程,
1631
+ * daemon 用 DESKBUDDY_AGENT_PID 告诉我们是哪个(spec 2026-10-03 §3.2)。
1632
+ * 🔴 只在父进程就是 daemon(gate.json 里记的 pid)时采纳:别的进程设这个变量不能冒充
1633
+ */
1634
+ function envAgentPid() {
1635
+ const v = process.env.DESKBUDDY_AGENT_PID || ''
1636
+ if (!/^[1-9]\d{0,9}$/.test(v)) return null
1637
+ let conf
1638
+ try {
1639
+ conf = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'gate.json'), 'utf8'))
1640
+ } catch (e) {
1641
+ return null
1642
+ }
1643
+ if (!conf || !Number.isInteger(conf.pid) || conf.pid !== process.ppid) return null
1644
+ const pid = Number(v)
1645
+ return pidExists(pid) ? pid : null
1646
+ }
1647
+
1648
+ /** 千问办公推卡通知的超时:hook 卡着 PreToolUse,界面在它返回前不出题,必须短 */
1649
+ const QWENWORK_NOTIFY_MS = 800
1650
+
1651
+ /**
1652
+ * 千问办公(spec 2026-10-03 §3.1):选择题 / 授权框要推设备时返回种类,否则 ''。
1653
+ * 选择题是 PreToolUse(AskUserQuestion);授权只有 Notification(permission_prompt)(PermissionRequest 不触发)
1654
+ */
1655
+ function qwenworkNotifyKind(tool, event, payload) {
1656
+ if (tool !== 'qwenwork' || !payload || typeof payload !== 'object') return ''
1657
+ if (event === 'PreToolUse' && payload.tool_name === 'AskUserQuestion') return 'question'
1658
+ if (event === 'Notification' && payload.notification_type === 'permission_prompt') return 'permission'
1659
+ return ''
1660
+ }
1661
+
1662
+ /**
1663
+ * 告诉 daemon「有一道题 / 一个授权框」(POST /gate mode=notify),🔴 不等设备:daemon 收到立刻回 200,
1664
+ * 设备答复由 daemon 经 App 内置 MCP 回填,界面照常弹。daemon 不在 / 超时 / 出错一律静默(resolve false)
1665
+ */
1666
+ function notifyDaemon(tool, payload, timeoutMs) {
1667
+ return new Promise((resolve) => {
1668
+ let done = false
1669
+ const finish = (v) => {
1670
+ if (done) return
1671
+ done = true
1672
+ resolve(v)
1673
+ }
1674
+ let conf
1675
+ try {
1676
+ conf = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'gate.json'), 'utf8'))
1677
+ } catch (e) {
1678
+ return finish(false)
1679
+ }
1680
+ if (!conf || !Number.isInteger(conf.port) || typeof conf.token !== 'string') return finish(false)
1681
+ let body
1682
+ try {
1683
+ body = JSON.stringify({ mode: 'notify', tool: tool, hook: payload })
1684
+ } catch (e) {
1685
+ return finish(false)
1686
+ }
1687
+ let req
1688
+ try {
1689
+ req = http.request(
1690
+ {
1691
+ host: '127.0.0.1',
1692
+ port: conf.port,
1693
+ path: '/gate',
1694
+ method: 'POST',
1695
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-gate-token': conf.token },
1696
+ },
1697
+ (res) => {
1698
+ res.resume()
1699
+ res.on('end', () => finish(res.statusCode === 200))
1700
+ res.on('error', () => finish(false))
1701
+ }
1702
+ )
1703
+ } catch (e) {
1704
+ return finish(false)
1705
+ }
1706
+ const timer = setTimeout(() => {
1707
+ try {
1708
+ req.destroy()
1709
+ } catch (e) {
1710
+ /* 已经断了 */
1711
+ }
1712
+ finish(false)
1713
+ }, timeoutMs)
1714
+ if (timer.unref) timer.unref()
1715
+ req.on('error', () => finish(false))
1716
+ req.on('close', () => {
1717
+ clearTimeout(timer)
1718
+ finish(false)
1719
+ })
1720
+ req.end(body)
1721
+ })
1722
+ }
1723
+
1471
1724
  async function main(argv) {
1472
1725
  // 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
1473
1726
  const tool = (argv && argv[2]) || 'claude_code'
@@ -1475,6 +1728,11 @@ async function main(argv) {
1475
1728
  const event = payload.hook_event_name || ''
1476
1729
  const sessionId = payload.session_id || ''
1477
1730
  if (!sessionId) return
1731
+ // 宿主对不上就当没来过:不写状态、不上报、不输出(见 foreignHost)
1732
+ if (foreignHost(tool, payload)) {
1733
+ noteForeign(tool, payload)
1734
+ return
1735
+ }
1478
1736
  // 快速路径(切片 C):Codex 的同步 UserPromptSubmit 条目 / opencode 插件的 chat.message 调。
1479
1737
  // 只读 appended → 输出 JSON → 删;不查进程、不上报(同步 hook 卡着用户的输入)
1480
1738
  if (argv && argv[3] === '--appended') {
@@ -1486,8 +1744,13 @@ async function main(argv) {
1486
1744
  // 按写完先后落盘就会把 done 覆盖回 watch,屏上一直「进行中」直到窗口关掉(10-01 WIN-LVT5M8H5670 实测)
1487
1745
  const eventAt = Date.now()
1488
1746
 
1747
+ // 千问办公:选择题 / 授权框推设备(不挂起,见 notifyDaemon)。状态照常往下走。
1748
+ // 🔴 放在取 eventAt 之后:通知最多耗 800ms,不能把这次事件的时间往后推(并发的 Stop 会被它压掉)
1749
+ if (qwenworkNotifyKind(tool, event, payload)) await notifyDaemon(tool, payload, QWENWORK_NOTIFY_MS)
1750
+
1489
1751
  if (NOTICE_EVENTS[event]) {
1490
1752
  const notice = takeNotice(sessionId)
1753
+ checkQoderAnswerAdopted(tool, event, payload, notice)
1491
1754
  // 🔴 writeSync:macOS 上 stdout 是管道时 process.stdout.write 是异步的,进程先退就丢了
1492
1755
  if (notice) {
1493
1756
  try {
@@ -1553,9 +1816,15 @@ async function main(argv) {
1553
1816
  const now = Date.now()
1554
1817
  if (event === 'UserPromptSubmit') recordDir(payload.cwd, now)
1555
1818
  // 窗口对话轮数(spec 2026-10-02 §2.1):一轮开始的事件加 1
1556
- const isTurn = turnEvents(registry, tool).indexOf(event) >= 0
1819
+ const internal = event === 'UserPromptSubmit' && isInternalPrompt(payload.prompt)
1820
+ const isTurn = !internal && turnEvents(registry, tool).indexOf(event) >= 0
1557
1821
  // 首条指令(标题来源 first-prompt):只记第一次
1558
- const firstPrompt = (prev && prev.firstPrompt) || (event === 'UserPromptSubmit' ? cleanPrompt(payload.prompt) : '') || undefined
1822
+ // 状态没了(会话结束后被续跑)就接已结束记录里的首句;DeskBuddy 注入的话不算首句
1823
+ const firstPrompt =
1824
+ (prev && prev.firstPrompt) ||
1825
+ (prev ? undefined : endedFirstPrompt(sessionId)) ||
1826
+ (event === 'UserPromptSubmit' && !internal ? cleanPrompt(payload.prompt) : '') ||
1827
+ undefined
1559
1828
  if (superseded(prev, eventAt)) {
1560
1829
  // 被并发的更晚事件(多半是同一轮的 Stop)抢先写了:状态不覆盖,但首条指令要补上,不然标题就成了第二条指令
1561
1830
  if (patchSuperseded(prev, firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
@@ -1564,7 +1833,7 @@ async function main(argv) {
1564
1833
  // 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
1565
1834
  // 不能拿死掉的 -p 进程的 interactive=false 挡不住窗口
1566
1835
  const prevAlive = !!(prev && prev.pid && pidExists(prev.pid))
1567
- const pid = (prevAlive && prev.pid) || resolveAgentPid(process.pid, agentNames(registry, tool)) || undefined
1836
+ const pid = (prevAlive && prev.pid) || envAgentPid() || resolveAgentPid(process.pid, agentNames(registry, tool)) || undefined
1568
1837
  // 两个判定沿用规则相同:进程还活着且上次判过就沿用,否则查一次命令行(只查一次)
1569
1838
  let host = null
1570
1839
  const hostFlag = (k) => {
@@ -1720,6 +1989,11 @@ module.exports = {
1720
1989
  pairClear,
1721
1990
  shouldReport,
1722
1991
  superseded,
1992
+ foreignHost,
1993
+ checkQoderAnswerAdopted,
1994
+ qwenworkNotifyKind,
1995
+ envAgentPid,
1996
+ notifyDaemon,
1723
1997
  sign,
1724
1998
  titleSources,
1725
1999
  writeState,
@@ -1734,6 +2008,10 @@ module.exports = {
1734
2008
  setTurns,
1735
2009
  patchSuperseded,
1736
2010
  endedTurns,
2011
+ endedFirstPrompt,
2012
+ isInternalPrompt,
2013
+ isStaleNoPid,
2014
+ STALE_NO_PID_MS,
1737
2015
  WINDOW_EVENTS,
1738
2016
  noteWindow,
1739
2017
  readWindow,
@@ -0,0 +1,7 @@
1
+ // hook/ocTranslate.mjs 的类型声明(daemon 经 src/core/ocTranslate.ts 引用;插件本体是零依赖 JS)
2
+ export function toCcTool(tool: unknown, args: unknown): { tool_name: string; tool_input: Record<string, unknown> }
3
+ export function askToCc(props: unknown): { tool_name: string; tool_input: Record<string, unknown> }
4
+ export function alwaysSuggestions(props: unknown): Array<Record<string, unknown>> | undefined
5
+ export function isPlainQuestion(props: unknown, callTool: unknown): boolean
6
+ export function questionToCc(props: unknown): { tool_name: string; tool_input: { questions: Array<Record<string, unknown>> } } | null
7
+ export function rememberTitle(titles: Map<string, string>, info: unknown): void