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.
- package/README.md +2 -2
- package/dist/cli.js +15566 -4613
- package/dist/deskbuddy-dsh.mjs +706 -0
- package/dist/gate.cjs +411 -35
- package/dist/hook.cjs +1283 -44
- package/dist/launcher.cjs +229 -0
- package/dist/ocTranslate.mjs +144 -0
- package/dist/opencode.mjs +204 -53
- package/dist/wake.cjs +769 -0
- package/docs/INSTALL.md +24 -10
- package/hook/deskbuddy-dsh.d.mts +24 -0
- package/hook/deskbuddy-dsh.mjs +706 -0
- package/hook/gate.cjs +411 -35
- package/hook/hook-debug.log +68 -0
- package/hook/hook.cjs +1283 -44
- package/hook/launcher.cjs +229 -0
- package/hook/ocTranslate.d.mts +9 -0
- package/hook/ocTranslate.mjs +144 -0
- package/hook/opencode.d.mts +10 -1
- package/hook/opencode.mjs +204 -53
- package/hook/wake.cjs +769 -0
- package/package.json +3 -2
- package/vendor/node-pty/LICENSE +69 -0
- package/vendor/node-pty/lib/conpty_console_list_agent.js +16 -0
- package/vendor/node-pty/lib/eventEmitter2.js +47 -0
- package/vendor/node-pty/lib/index.js +52 -0
- package/vendor/node-pty/lib/interfaces.js +7 -0
- package/vendor/node-pty/lib/shared/conout.js +11 -0
- package/vendor/node-pty/lib/terminal.js +190 -0
- package/vendor/node-pty/lib/types.js +7 -0
- package/vendor/node-pty/lib/unixTerminal.js +346 -0
- package/vendor/node-pty/lib/utils.js +39 -0
- package/vendor/node-pty/lib/windowsConoutConnection.js +125 -0
- package/vendor/node-pty/lib/windowsPtyAgent.js +320 -0
- package/vendor/node-pty/lib/windowsTerminal.js +199 -0
- package/vendor/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/vendor/node-pty/package.json +7 -0
- package/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/vendor/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
- package/vendor/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
- package/vendor/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/vendor/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/vendor/node-pty/prebuilds/win32-arm64/pty.node +0 -0
- package/vendor/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
- package/vendor/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
- package/vendor/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
- package/vendor/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
- package/vendor/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/vendor/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/vendor/node-pty/prebuilds/win32-x64/pty.node +0 -0
- package/vendor/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
- package/vendor/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
// DeskBuddy 的 DeepSeek Harness(dsh)原生插件(spec 2026-10-03-deskbuddy-deepseek-harness-design)。
|
|
2
|
+
// `deskbuddy enable deepseek_harness` 往 $DSH_HOME/cordis.patch.yml 追加一条 insert,name 是本文件的绝对路径;
|
|
3
|
+
// 本文件随 hook.cjs / gate.cjs 一起同步到 ~/.deskbuddy/,CLI 升级即生效。
|
|
4
|
+
//
|
|
5
|
+
// 做的事和 opencode.mjs 一样只有翻译:dsh 的 Cordis 事件 → cc-hook 同构输入,喂同目录的 hook.cjs(状态)和 gate.cjs(设备确认)。
|
|
6
|
+
//
|
|
7
|
+
// 实测依据(dsh 0.2.0-rc.2,10-03 spike):
|
|
8
|
+
// - CC hooks 兼容插件在 `dsh web` 下静默不加载,只能走原生插件
|
|
9
|
+
// - approval/request、user-questions/request 是串行瀑布:不调 next() 就挡住了原生界面。
|
|
10
|
+
// 抢答做法:把 req.signal 换成 AbortSignal.any([原 signal, 自己的]),再 next() 让原生界面照常弹,同时推设备,谁先回用谁;
|
|
11
|
+
// 设备先答就 abort 自己那个 —— web 的卡片立即消失(next() 随之 reject,要接住)。原生先答就结束 gate 子进程,daemon 撤设备卡片
|
|
12
|
+
// - 审批请求不带工具参数,靠 callId 从先到的 tool/call 事件里补
|
|
13
|
+
//
|
|
14
|
+
// 🔴 窗口兜底方向:任何失败都交给原生界面(只调 next()),绝不替用户表态。
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
18
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const HOOK = join(HERE, 'hook.cjs')
|
|
24
|
+
const GATE = join(HERE, 'gate.cjs')
|
|
25
|
+
export const TOOL = 'deepseek_harness'
|
|
26
|
+
|
|
27
|
+
/** hook.cjs 自己有 2 秒上报超时,这里再兜一层:一次卡死不能堵住后面所有状态 */
|
|
28
|
+
const HOOK_KILL_MS = 10 * 1000
|
|
29
|
+
/** 工具调用参数缓存时长:tool/call 记下,随后的审批请求靠它显示真实命令 */
|
|
30
|
+
const CALL_TTL_MS = 10 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
/** 下发信箱:心跳间隔、轮询间隔(与 src/core/dshMailbox.ts 的约定一致:心跳 5 秒内算新鲜、≤1 秒取信) */
|
|
33
|
+
const HEARTBEAT_MS = 1000
|
|
34
|
+
const INBOX_POLL_MS = 500
|
|
35
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,128}$/
|
|
36
|
+
/** 信龄超过这个就不投(daemon 回执超时 8 秒 + 收回宽限 3 秒 + 余量,与 src/core/dshMailbox.ts 的 DSH_STALE_MS 一致) */
|
|
37
|
+
export const STALE_LETTER_MS = 12 * 1000
|
|
38
|
+
/** followup 结束的等待上限(daemon 等回执 8 秒,这里要更早写回执) */
|
|
39
|
+
const FOLLOWUP_WAIT_MS = 5 * 1000
|
|
40
|
+
/** 信件文件名 `<sessionId>.<instructionId>.json`(与 src/core/dshMailbox.ts 一致) */
|
|
41
|
+
export const LETTER_RE = /^([A-Za-z0-9_-]{1,128})\.([A-Za-z0-9_-]{1,128})\.json$/
|
|
42
|
+
|
|
43
|
+
/** 验证过的 dsh 版本线。其余版本只上报状态、不抢答(接口在开发者预览期,可能有破坏性变更) */
|
|
44
|
+
export const SUPPORTED_VERSION_RE = /^0\.2\./
|
|
45
|
+
|
|
46
|
+
/** 不当窗口的 profile:一次性任务 / 给程序用的。ACP 的应答方是编辑器那头,没实测,也先不抢 */
|
|
47
|
+
const NON_WINDOW_PROFILES = new Set(['headless', 'sdk', 'sdk-minimal', 'acp'])
|
|
48
|
+
|
|
49
|
+
// ---------------- 纯函数(有单测) ----------------
|
|
50
|
+
|
|
51
|
+
/** dsh 工具 → cc-hook 同构的 tool_name / tool_input。dsh 的参数名本来就和 Claude Code 一样(file_path / command / pattern) */
|
|
52
|
+
export function toCcTool(tool, args) {
|
|
53
|
+
const a = args && typeof args === 'object' && !Array.isArray(args) ? args : {}
|
|
54
|
+
const s = (v) => (typeof v === 'string' ? v : '')
|
|
55
|
+
switch (tool) {
|
|
56
|
+
case 'bash':
|
|
57
|
+
case 'pwsh':
|
|
58
|
+
return { tool_name: 'Bash', tool_input: { command: s(a.command) } }
|
|
59
|
+
case 'read':
|
|
60
|
+
case 'read_image':
|
|
61
|
+
return { tool_name: 'Read', tool_input: { file_path: s(a.file_path) } }
|
|
62
|
+
case 'write':
|
|
63
|
+
return { tool_name: 'Write', tool_input: { file_path: s(a.file_path) } }
|
|
64
|
+
case 'edit':
|
|
65
|
+
case 'str_replace_editor':
|
|
66
|
+
return { tool_name: 'Edit', tool_input: { file_path: s(a.file_path) || s(a.path) } }
|
|
67
|
+
case 'glob':
|
|
68
|
+
return { tool_name: 'Glob', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
|
|
69
|
+
case 'grep':
|
|
70
|
+
return { tool_name: 'Grep', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
|
|
71
|
+
case 'web_fetch':
|
|
72
|
+
return { tool_name: 'WebFetch', tool_input: { url: s(a.url) } }
|
|
73
|
+
case 'web_search':
|
|
74
|
+
return { tool_name: 'WebSearch', tool_input: { query: s(a.query) } }
|
|
75
|
+
case 'todo_write':
|
|
76
|
+
return { tool_name: 'TodoWrite', tool_input: {} }
|
|
77
|
+
case 'subagent':
|
|
78
|
+
case 'subagent_fork':
|
|
79
|
+
return { tool_name: 'Task', tool_input: {} }
|
|
80
|
+
case 'ask_user_question':
|
|
81
|
+
return { tool_name: 'AskUserQuestion', tool_input: {} }
|
|
82
|
+
default:
|
|
83
|
+
// 不认识的工具(MCP 等)原名原参数交出去:classify 对不认识的一律推设备
|
|
84
|
+
return { tool_name: String(tool || ''), tool_input: a }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** tool/call 事件的 arguments 是 JSON 字符串;解不开就当空对象 */
|
|
89
|
+
export function parseArgs(raw) {
|
|
90
|
+
if (raw && typeof raw === 'object') return raw
|
|
91
|
+
try {
|
|
92
|
+
const v = JSON.parse(String(raw || ''))
|
|
93
|
+
return v && typeof v === 'object' ? v : {}
|
|
94
|
+
} catch (_) {
|
|
95
|
+
return {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** user-questions/request 的题目 → AskUserQuestion 形状(统一走 daemon 的选择题链路)。没有题返回 null */
|
|
100
|
+
export function questionsToCc(questions) {
|
|
101
|
+
if (!Array.isArray(questions) || questions.length === 0) return null
|
|
102
|
+
return {
|
|
103
|
+
tool_name: 'AskUserQuestion',
|
|
104
|
+
tool_input: {
|
|
105
|
+
questions: questions.map((q) => ({
|
|
106
|
+
question: q && q.question,
|
|
107
|
+
header: (q && q.header) || '',
|
|
108
|
+
multiSelect: !!(q && q.multiSelect),
|
|
109
|
+
options: q && Array.isArray(q.options) ? q.options.map((o) => ({ label: o && o.label, description: (o && o.description) || '' })) : [],
|
|
110
|
+
})),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 设备答案(gate 输出的 deskbuddyAnswers:每题选中的原文 label,自己填的放最后)→ dsh 的 AskUserQuestionAnswer。
|
|
117
|
+
* 按本地题目 id 对齐;不在选项里的算自填(custom)。单选题有自填时 selected 留空(dsh 的约定:custom 覆盖选项)。
|
|
118
|
+
* 题数对不上返回 null(不表态,交给原生界面)
|
|
119
|
+
*/
|
|
120
|
+
export function answersFromLists(questions, lists) {
|
|
121
|
+
if (!Array.isArray(questions) || !Array.isArray(lists) || lists.length !== questions.length) return null
|
|
122
|
+
const answers = []
|
|
123
|
+
for (let i = 0; i < questions.length; i++) {
|
|
124
|
+
const q = questions[i] || {}
|
|
125
|
+
if (typeof q.id !== 'string') return null
|
|
126
|
+
const items = Array.isArray(lists[i]) ? lists[i].filter((x) => typeof x === 'string' && x !== '') : []
|
|
127
|
+
const labels = new Set(Array.isArray(q.options) ? q.options.map((o) => o && o.label) : [])
|
|
128
|
+
const selected = items.filter((x) => labels.has(x))
|
|
129
|
+
const custom = items.filter((x) => !labels.has(x)).join('\n')
|
|
130
|
+
answers.push({ id: q.id, selected: custom && !q.multiSelect ? [] : selected, ...(custom ? { custom } : {}) })
|
|
131
|
+
}
|
|
132
|
+
return { answers }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 设备上「跳过」= 整组不答:每题 selected 为空(与 web 题卡点「跳过」给模型的结果一致,10-03 实测) */
|
|
136
|
+
export function skippedAnswers(questions) {
|
|
137
|
+
// 与 answersFromLists 同样校验:题目 id 不是字符串就不表态
|
|
138
|
+
if (!Array.isArray(questions) || questions.length === 0 || questions.some((q) => !q || typeof q.id !== 'string')) return null
|
|
139
|
+
return { answers: questions.map((q) => ({ id: q.id, selected: [] })) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 子代理会话 → 根会话(沿 parentSession 往上找,最多 8 层;找不到返回原 id) */
|
|
143
|
+
export function rootSession(parents, sid) {
|
|
144
|
+
let cur = sid
|
|
145
|
+
for (let i = 0; i < 8 && parents.has(cur); i++) cur = parents.get(cur)
|
|
146
|
+
return cur
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 当前 profile 名:优先 Cordis 的 profileContext,取不到看命令行(`dsh <name>` / `dsh --profile <name>`) */
|
|
150
|
+
export function profileName(ctxProfile, argv = process.argv) {
|
|
151
|
+
if (ctxProfile && typeof ctxProfile.name === 'string' && ctxProfile.name) return ctxProfile.name
|
|
152
|
+
const args = Array.isArray(argv) ? argv.slice(2) : []
|
|
153
|
+
const i = args.indexOf('--profile')
|
|
154
|
+
if (i >= 0 && typeof args[i + 1] === 'string') return args[i + 1]
|
|
155
|
+
const first = args.find((a) => typeof a === 'string' && !a.startsWith('-'))
|
|
156
|
+
return first || ''
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 这个 profile 有人在电脑前(web / Desktop / 用户自建的):抢答。headless 等不抢 */
|
|
160
|
+
export function isWindowProfile(name) {
|
|
161
|
+
return !NON_WINDOW_PROFILES.has(String(name || ''))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 版本门槛:读不到版本(Desktop 的打包形态可能找不到 package.json)放行;读到了但不是验证过的版本线就只报状态 */
|
|
165
|
+
export function gatingAllowed(version) {
|
|
166
|
+
return !version || SUPPORTED_VERSION_RE.test(version)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 从 dsh 的启动脚本往上找 @deepseek-ai/dsh 的 package.json,取版本。找不到返回 '' */
|
|
170
|
+
export function dshVersion(argv = process.argv) {
|
|
171
|
+
try {
|
|
172
|
+
let dir = dirname(realpathSync(String(argv[1] || '')))
|
|
173
|
+
for (let i = 0; i < 6; i++) {
|
|
174
|
+
const p = join(dir, 'package.json')
|
|
175
|
+
if (existsSync(p)) {
|
|
176
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8'))
|
|
177
|
+
if (pkg && pkg.name === '@deepseek-ai/dsh' && typeof pkg.version === 'string') return pkg.version
|
|
178
|
+
}
|
|
179
|
+
const up = dirname(dir)
|
|
180
|
+
if (up === dir) break
|
|
181
|
+
dir = up
|
|
182
|
+
}
|
|
183
|
+
} catch (_) {
|
|
184
|
+
/* 读不到 */
|
|
185
|
+
}
|
|
186
|
+
return ''
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** daemon 起的 headless 任务带 DESKBUDDY_GATE_RUN(远程关卡的 runId,与 opencode 插件同一约定) */
|
|
190
|
+
export function runIdFromEnv(env = process.env) {
|
|
191
|
+
const v = (env && env.DESKBUDDY_GATE_RUN) || ''
|
|
192
|
+
return /^[0-9a-zA-Z_-]{1,64}$/.test(v) ? v : ''
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 信箱所在的 DeskBuddy 家目录:显式的 DESKBUDDY_HOME,否则就是本文件所在目录(syncHookFile 把它装在家目录下) */
|
|
196
|
+
export function mailboxHome(env = process.env) {
|
|
197
|
+
return (env && env.DESKBUDDY_HOME) || HERE
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 远程关卡(gate.cjs --run)的结果:只有 exit 0 且明确 allow 才放行,其余一律拒(远程模式的兜底方向) */
|
|
201
|
+
export function runGateAllowed(r) {
|
|
202
|
+
if (!r || r.code !== 0 || !r.out) return false
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(r.out).hookSpecificOutput.permissionDecision === 'allow'
|
|
205
|
+
} catch (_) {
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** gate.cjs 的输出 → { decision: 'allow'|'deny', answers? };不表态 / 读不懂返回 null */
|
|
211
|
+
export function parseGateOut(r) {
|
|
212
|
+
if (!r || r.code !== 0 || !r.out) return null
|
|
213
|
+
let out
|
|
214
|
+
try {
|
|
215
|
+
out = JSON.parse(r.out)
|
|
216
|
+
} catch (_) {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
const d = out && out.hookSpecificOutput && out.hookSpecificOutput.decision
|
|
220
|
+
if (!d || (d.behavior !== 'allow' && d.behavior !== 'deny')) return null
|
|
221
|
+
return { decision: d.behavior, answers: Array.isArray(out.deskbuddyAnswers) ? out.deskbuddyAnswers : null }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 抢答:原生(next)与设备(device:resolve 成有效结果或 null = 不表态)谁先回用谁。
|
|
226
|
+
* - 原生先回:cancelDevice()(结束 gate 子进程 → daemon 撤设备卡片),用原生的结果
|
|
227
|
+
* - 设备先回有效结果:abortNative()(web 卡片消失),用设备的;原生那边随后的 reject / 迟到结果吞掉
|
|
228
|
+
* - 设备不表态(null):只等原生
|
|
229
|
+
* 原生 reject 且设备还没回:cancelDevice() 后把错误抛回去(与没装插件时一样)
|
|
230
|
+
*/
|
|
231
|
+
export function race(next, device, cancelDevice, abortNative) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
let settled = false
|
|
234
|
+
const win = (fn) => {
|
|
235
|
+
if (settled) return false
|
|
236
|
+
settled = true
|
|
237
|
+
fn()
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
let native
|
|
241
|
+
try {
|
|
242
|
+
native = Promise.resolve(next())
|
|
243
|
+
} catch (e) {
|
|
244
|
+
native = Promise.reject(e)
|
|
245
|
+
}
|
|
246
|
+
native.then(
|
|
247
|
+
(v) => win(() => (cancelDevice(), resolve(v))),
|
|
248
|
+
(e) => win(() => (cancelDevice(), reject(e)))
|
|
249
|
+
)
|
|
250
|
+
Promise.resolve(device).then(
|
|
251
|
+
(v) => {
|
|
252
|
+
if (v == null) return
|
|
253
|
+
win(() => (abortNative(), resolve(v)))
|
|
254
|
+
},
|
|
255
|
+
() => {}
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 把自己的 AbortController 拼进请求的 signal:next() 往下传的就是同一个 req 对象(10-03 实测 req 没冻结) */
|
|
261
|
+
export function spliceSignal(req, ac) {
|
|
262
|
+
try {
|
|
263
|
+
const orig = req.signal
|
|
264
|
+
if (orig && typeof AbortSignal.any !== 'function') return false
|
|
265
|
+
const merged = orig ? AbortSignal.any([orig, ac.signal]) : ac.signal
|
|
266
|
+
req.signal = merged
|
|
267
|
+
return req.signal === merged
|
|
268
|
+
} catch (_) {
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------- 插件本体 ----------------
|
|
274
|
+
|
|
275
|
+
/** 活着的子进程;dsh 退出时一并结束,不留孤儿 gate 挂着设备卡片 */
|
|
276
|
+
const LIVE = new Set()
|
|
277
|
+
process.on('exit', () => {
|
|
278
|
+
for (const c of LIVE) {
|
|
279
|
+
try {
|
|
280
|
+
c.kill('SIGTERM')
|
|
281
|
+
} catch (_) {
|
|
282
|
+
/* 已经退了 */
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
function startChild(node, script, args, input, env, killMs = 0) {
|
|
288
|
+
let child = null
|
|
289
|
+
const done = new Promise((resolve) => {
|
|
290
|
+
let out = ''
|
|
291
|
+
let err = ''
|
|
292
|
+
try {
|
|
293
|
+
child = spawn(node, [script, ...args], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...env } })
|
|
294
|
+
} catch (e) {
|
|
295
|
+
resolve({ code: -1, out, err: String(e) })
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
LIVE.add(child)
|
|
299
|
+
const timer = killMs > 0 ? setTimeout(() => child.kill('SIGKILL'), killMs) : null
|
|
300
|
+
const finish = (r) => {
|
|
301
|
+
LIVE.delete(child)
|
|
302
|
+
if (timer) clearTimeout(timer)
|
|
303
|
+
resolve(r)
|
|
304
|
+
}
|
|
305
|
+
child.stdout.on('data', (c) => (out += c))
|
|
306
|
+
child.stderr.on('data', (c) => (err += c))
|
|
307
|
+
child.on('error', (e) => finish({ code: -1, out, err: String(e) }))
|
|
308
|
+
child.on('close', (code) => finish({ code, out, err }))
|
|
309
|
+
child.stdin.on('error', () => {})
|
|
310
|
+
child.stdin.end(JSON.stringify(input))
|
|
311
|
+
})
|
|
312
|
+
return { child, done }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function debugLog(line) {
|
|
316
|
+
try {
|
|
317
|
+
appendFileSync(join(HERE, 'hook-debug.log'), `${new Date().toISOString()} [dsh] ${line}\n`, 'utf8')
|
|
318
|
+
} catch (_) {
|
|
319
|
+
/* 记不下不影响 */
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const name = 'deskbuddy'
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Cordis 插件入口。config 来自 cordis.patch.yml 里我们那条 insert:{ node }(稳定的 node 路径 ——
|
|
327
|
+
* Desktop 里宿主是 Electron,process.execPath 起不了 hook.cjs)
|
|
328
|
+
*/
|
|
329
|
+
export function apply(ctx, config) {
|
|
330
|
+
const node = process.env.DESKBUDDY_NODE || (config && typeof config.node === 'string' && config.node) || 'node'
|
|
331
|
+
const profile = profileName(safeGet(ctx, 'profileContext'))
|
|
332
|
+
const version = dshVersion()
|
|
333
|
+
const window = isWindowProfile(profile)
|
|
334
|
+
const gating = window && gatingAllowed(version)
|
|
335
|
+
if (!gatingAllowed(version)) debugLog(`dsh ${version} 不在验证过的版本线内,只上报状态、不推设备确认`)
|
|
336
|
+
const runId = runIdFromEnv()
|
|
337
|
+
// 有人在电脑前、版本验证过:开下发信箱,会话上报时标 remoteWindow(服务端据此放行回复)
|
|
338
|
+
const mailbox = gating
|
|
339
|
+
const env = { DESKBUDDY_AGENT_PID: String(process.pid), DESKBUDDY_AGENT_SELF: '1', ...(mailbox ? { DESKBUDDY_DSH_MAILBOX: '1' } : {}) }
|
|
340
|
+
|
|
341
|
+
// 进行中的 gate 子进程:插件卸载(热重载 / 关闭)时一并结束,daemon 随之撤掉设备卡片
|
|
342
|
+
const inflight = new Set()
|
|
343
|
+
if (ctx && typeof ctx.effect === 'function') {
|
|
344
|
+
ctx.effect(() => () => {
|
|
345
|
+
for (const c of inflight) {
|
|
346
|
+
try {
|
|
347
|
+
c.kill('SIGTERM')
|
|
348
|
+
} catch (_) {
|
|
349
|
+
/* 已经退了 */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
inflight.clear()
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
const spawnGate = (args, input) => {
|
|
356
|
+
const h = startChild(node, GATE, args, input, env)
|
|
357
|
+
if (h.child) {
|
|
358
|
+
inflight.add(h.child)
|
|
359
|
+
h.done.then(() => inflight.delete(h.child))
|
|
360
|
+
}
|
|
361
|
+
return h
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---- 状态上报:串行喂 hook.cjs ----
|
|
365
|
+
const sessions = new Map() // sessionId → { cwd, title }
|
|
366
|
+
const parents = new Map() // 子代理会话 → 父会话(子代理的审批归到根会话上推设备)
|
|
367
|
+
let queue = Promise.resolve()
|
|
368
|
+
const report = (event, sid, extra) => {
|
|
369
|
+
const s = sessions.get(sid)
|
|
370
|
+
if (!s) return
|
|
371
|
+
const input = { hook_event_name: event, session_id: sid, cwd: s.cwd, ...extra }
|
|
372
|
+
if (s.title) input.oc_session_title = s.title
|
|
373
|
+
queue = queue.then(() => startChild(node, HOOK, [TOOL], input, env, HOOK_KILL_MS).done).catch(() => {})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const calls = new Map() // callId → { cc, at }
|
|
377
|
+
const prune = () => {
|
|
378
|
+
const now = Date.now()
|
|
379
|
+
for (const [k, v] of calls) if (now - v.at > CALL_TTL_MS) calls.delete(k)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
ctx.on('session/created', (session) => {
|
|
383
|
+
const h = session && session.header
|
|
384
|
+
if (!h || typeof session.id !== 'string') return
|
|
385
|
+
// 子代理会话不单独上屏(与 opencode 的 parentID 同理),只记下它属于哪个父会话
|
|
386
|
+
if (h.parentSession || h.origin === 'subagent') {
|
|
387
|
+
if (typeof h.parentSession === 'string') parents.set(session.id, h.parentSession)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
sessions.set(session.id, { cwd: typeof h.cwd === 'string' ? h.cwd : process.cwd(), title: '' })
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
ctx.on('session/disposed', (session) => {
|
|
394
|
+
// 不报 SessionEnd:web 卸载空闲会话也会走这里,会话本身还在。进程退出由 daemon 按 pid 判活收尾
|
|
395
|
+
if (session && typeof session.id === 'string') {
|
|
396
|
+
sessions.delete(session.id)
|
|
397
|
+
parents.delete(session.id)
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
ctx.on('session/event', (session, event) => {
|
|
402
|
+
try {
|
|
403
|
+
onEvent(session && session.id, event)
|
|
404
|
+
} catch (e) {
|
|
405
|
+
debugLog(`处理事件出错:${(e && e.message) || e}`)
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
function onEvent(sid, e) {
|
|
410
|
+
if (!sid || !sessions.has(sid) || !e) return
|
|
411
|
+
const d = e.data || {}
|
|
412
|
+
switch (e.type) {
|
|
413
|
+
case 'session/title': {
|
|
414
|
+
const t = typeof d.title === 'string' ? d.title.trim() : ''
|
|
415
|
+
if (t) sessions.get(sid).title = t.slice(0, 100)
|
|
416
|
+
break
|
|
417
|
+
}
|
|
418
|
+
case 'user/message': {
|
|
419
|
+
// 只认用户自己说的(运行时上下文快照、技能清单等系统补的 user/message 来源不是 user)
|
|
420
|
+
if (!d.source || d.source.kind !== 'user') break
|
|
421
|
+
const text = Array.isArray(d.content)
|
|
422
|
+
? d.content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n')
|
|
423
|
+
: ''
|
|
424
|
+
report('UserPromptSubmit', sid, { prompt: text })
|
|
425
|
+
break
|
|
426
|
+
}
|
|
427
|
+
case 'tool/call': {
|
|
428
|
+
prune()
|
|
429
|
+
const cc = toCcTool(d.name, parseArgs(d.arguments))
|
|
430
|
+
if (typeof d.callId === 'string') calls.set(d.callId, { cc, at: Date.now() })
|
|
431
|
+
report('PreToolUse', sid, cc)
|
|
432
|
+
break
|
|
433
|
+
}
|
|
434
|
+
case 'tool/result': {
|
|
435
|
+
const callId = d.message && d.message.toolCallId
|
|
436
|
+
const call = typeof callId === 'string' ? calls.get(callId) : null
|
|
437
|
+
if (call) calls.delete(callId)
|
|
438
|
+
report('PostToolUse', sid, call ? call.cc : { tool_name: '', tool_input: {} })
|
|
439
|
+
break
|
|
440
|
+
}
|
|
441
|
+
case 'turn/end':
|
|
442
|
+
report('Stop', sid)
|
|
443
|
+
break
|
|
444
|
+
default:
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---- 下发信箱(spec §3 下发通道;与 src/core/dshMailbox.ts 同一约定) ----
|
|
449
|
+
const status = new Map() // sessionId → running / idle
|
|
450
|
+
ctx.on('agent/status', (p) => {
|
|
451
|
+
const sid = p && p.agent && (p.agent.id || (p.agent.session && p.agent.session.id))
|
|
452
|
+
if (typeof sid === 'string') status.set(sid, p.status)
|
|
453
|
+
})
|
|
454
|
+
if (mailbox) startMailbox(ctx, sessions, status)
|
|
455
|
+
|
|
456
|
+
// ---- 审批:与原生界面抢答;daemon 起的 headless 任务走远程关卡 ----
|
|
457
|
+
ctx.on('approval/request', function (req, next) {
|
|
458
|
+
// 子代理(headless 的 subagent 等)的审批归到根会话:设备卡片显示在根会话上,远程关卡按根会话认 run
|
|
459
|
+
const sid = rootSession(parents, agentSid(req))
|
|
460
|
+
if (runId && !window && gatingAllowed(version) && sid && sessions.has(sid)) return remoteApproval(req, sid)
|
|
461
|
+
if (!gating || !sid || !sessions.has(sid)) return next()
|
|
462
|
+
const call = req.callId ? calls.get(req.callId) : null
|
|
463
|
+
const cc = call ? call.cc : toCcTool(req.toolName, {})
|
|
464
|
+
report('PermissionRequest', sid, cc)
|
|
465
|
+
return ask(req, next, sid, cc, (g) => (g.decision === 'allow' ? 'allowed-once' : 'rejected'))
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
// ---- 选择题:与原生界面抢答 ----
|
|
469
|
+
ctx.on('user-questions/request', function (req, next) {
|
|
470
|
+
const sid = rootSession(parents, agentSid(req))
|
|
471
|
+
const cc = req && questionsToCc(req.questions)
|
|
472
|
+
if (!gating || !sid || !sessions.has(sid) || !cc) return next()
|
|
473
|
+
report('PermissionRequest', sid, cc)
|
|
474
|
+
return ask(req, next, sid, cc, (g) => {
|
|
475
|
+
if (g.decision === 'deny') return skippedAnswers(req.questions) // 题目 id 不对时是 null = 不表态
|
|
476
|
+
return answersFromLists(req.questions, g.answers)
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* headless 里没有原生应答者(next() 恒为 unavailable):远程任务的授权全靠设备,经 gate.cjs --run 走 daemon 的远程关卡。
|
|
482
|
+
* 🔴 任何失败都拒(远程模式的兜底方向,与 opencode 插件一致)
|
|
483
|
+
*/
|
|
484
|
+
function remoteApproval(req, sid) {
|
|
485
|
+
const call = req.callId ? calls.get(req.callId) : null
|
|
486
|
+
const cc = call ? call.cc : toCcTool(req.toolName, {})
|
|
487
|
+
report('PermissionRequest', sid, cc)
|
|
488
|
+
const input = { hook_event_name: 'PreToolUse', session_id: sid, cwd: sessions.get(sid).cwd, ...cc }
|
|
489
|
+
const { child, done } = spawnGate(['--run', runId], input)
|
|
490
|
+
const sig = req.signal
|
|
491
|
+
const onAbort = () => {
|
|
492
|
+
try {
|
|
493
|
+
if (child && child.exitCode === null) child.kill('SIGTERM')
|
|
494
|
+
} catch (_) {
|
|
495
|
+
/* 已经退了 */
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const listening = !!(sig && typeof sig.addEventListener === 'function')
|
|
499
|
+
if (listening) sig.addEventListener('abort', onAbort, { once: true })
|
|
500
|
+
const off = () => {
|
|
501
|
+
if (listening) sig.removeEventListener('abort', onAbort)
|
|
502
|
+
}
|
|
503
|
+
return done.then(
|
|
504
|
+
(r) => (off(), runGateAllowed(r) ? 'allowed-once' : 'rejected'),
|
|
505
|
+
() => (off(), 'rejected')
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** 推设备 + 原生照常弹,谁先回用谁。map 把 gate 的结果翻成 dsh 的返回值;翻不出(null)= 不表态 */
|
|
510
|
+
function ask(req, next, sid, cc, map) {
|
|
511
|
+
const own = new AbortController()
|
|
512
|
+
const orig = req.signal
|
|
513
|
+
if (!spliceSignal(req, own)) {
|
|
514
|
+
// 拼不进 signal:设备先答后原生卡片撤不掉、还会占着卡片位(10-03 实测),这种情况不推设备
|
|
515
|
+
debugLog('请求的 signal 改不了,本次不推设备')
|
|
516
|
+
return next()
|
|
517
|
+
}
|
|
518
|
+
const input = { hook_event_name: 'PermissionRequest', session_id: sid, cwd: sessions.get(sid).cwd, ...cc }
|
|
519
|
+
const { child, done } = spawnGate(['--window', '--tool', TOOL], input)
|
|
520
|
+
const cancelDevice = () => {
|
|
521
|
+
try {
|
|
522
|
+
if (child && child.exitCode === null) child.kill('SIGTERM')
|
|
523
|
+
} catch (_) {
|
|
524
|
+
/* 已经退了 */
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const listening = !!(orig && typeof orig.addEventListener === 'function')
|
|
528
|
+
if (listening) orig.addEventListener('abort', cancelDevice, { once: true })
|
|
529
|
+
const device = done.then((r) => {
|
|
530
|
+
const g = parseGateOut(r)
|
|
531
|
+
return g ? map(g) : null
|
|
532
|
+
})
|
|
533
|
+
// 设备先答:abort 自己的(原生卡片消失),随即把 req.signal 还原成原 signal 再交回结果 ——
|
|
534
|
+
// 免得之后谁再读 req.signal 看到的是一个已 abort 的 signal(评审 d;10-03 实测设备的允许 / 答案确实生效)
|
|
535
|
+
const abortNative = () => {
|
|
536
|
+
own.abort()
|
|
537
|
+
try {
|
|
538
|
+
req.signal = orig
|
|
539
|
+
} catch (_) {
|
|
540
|
+
/* 还原不了也不影响这次的结果 */
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const settled = race(next, device, cancelDevice, abortNative)
|
|
544
|
+
const off = () => {
|
|
545
|
+
if (listening) orig.removeEventListener('abort', cancelDevice)
|
|
546
|
+
}
|
|
547
|
+
settled.then(off, off)
|
|
548
|
+
return settled
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 心跳:每秒写 alive/<pid>.json,列出这个进程里活着的会话;取信:≤1 秒扫一次 inbox,
|
|
554
|
+
* 是自己的会话就 rename 认领(与 daemon 收回互斥)→ 读 → 删 → followup → 写 ack
|
|
555
|
+
*/
|
|
556
|
+
function startMailbox(ctx, sessions, status) {
|
|
557
|
+
const home = mailboxHome()
|
|
558
|
+
const dir = (name) => join(home, 'dsh', name)
|
|
559
|
+
const alivePath = join(dir('alive'), `${process.pid}.json`)
|
|
560
|
+
const ensure = (p) => {
|
|
561
|
+
mkdirSync(p, { recursive: true, mode: 0o700 })
|
|
562
|
+
try {
|
|
563
|
+
chmodSync(p, 0o700)
|
|
564
|
+
} catch (_) {
|
|
565
|
+
/* 改不了权限也不挡 */
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const writeJson = (path, value) => {
|
|
569
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
570
|
+
writeFileSync(tmp, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 })
|
|
571
|
+
renameSync(tmp, path)
|
|
572
|
+
}
|
|
573
|
+
const agents = () => safeGet(ctx, 'agents')
|
|
574
|
+
const live = () => {
|
|
575
|
+
const a = agents()
|
|
576
|
+
return [...sessions.keys()].filter((id) => {
|
|
577
|
+
try {
|
|
578
|
+
return !!(a && a.get(id))
|
|
579
|
+
} catch (_) {
|
|
580
|
+
return false
|
|
581
|
+
}
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
const beat = () => {
|
|
585
|
+
try {
|
|
586
|
+
ensure(dir('alive'))
|
|
587
|
+
writeJson(alivePath, { pid: process.pid, at: Date.now(), sessions: live() })
|
|
588
|
+
} catch (e) {
|
|
589
|
+
debugLog(`写心跳失败:${(e && e.message) || e}`)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const poll = () => {
|
|
593
|
+
let names
|
|
594
|
+
try {
|
|
595
|
+
names = readdirSync(dir('inbox'))
|
|
596
|
+
} catch (_) {
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
for (const n of names.sort()) {
|
|
600
|
+
const m = LETTER_RE.exec(n)
|
|
601
|
+
if (!m || !sessions.has(m[1])) continue
|
|
602
|
+
const sid = m[1]
|
|
603
|
+
const claim = join(dir('inbox'), `${n}.${process.pid}.claim`)
|
|
604
|
+
try {
|
|
605
|
+
renameSync(join(dir('inbox'), n), claim)
|
|
606
|
+
} catch (_) {
|
|
607
|
+
continue // daemon 收回了 / 别的进程认领了
|
|
608
|
+
}
|
|
609
|
+
let letter = null
|
|
610
|
+
try {
|
|
611
|
+
letter = JSON.parse(readFileSync(claim, 'utf8'))
|
|
612
|
+
} catch (_) {
|
|
613
|
+
letter = null
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
unlinkSync(claim)
|
|
617
|
+
} catch (_) {
|
|
618
|
+
/* 删不掉也不影响 */
|
|
619
|
+
}
|
|
620
|
+
if (!letter || letter.instructionId !== m[2] || !ID_RE.test(letter.instructionId)) continue
|
|
621
|
+
const iid = letter.instructionId
|
|
622
|
+
// 孤儿信:daemon 早已按超时回报过了,再投就是设备以为没发、电脑上却执行了(评审)
|
|
623
|
+
const age = Date.now() - (typeof letter.at === 'number' ? letter.at : 0)
|
|
624
|
+
if (age > STALE_LETTER_MS) {
|
|
625
|
+
writeAck(iid, { ok: false, reason: 'stale' })
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
deliver(sid, typeof letter.text === 'string' ? letter.text : '').then(
|
|
629
|
+
(ack) => writeAck(iid, ack),
|
|
630
|
+
() => writeAck(iid, { ok: false, reason: 'followup_failed' })
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const writeAck = (iid, ack) => {
|
|
635
|
+
try {
|
|
636
|
+
ensure(dir('ack'))
|
|
637
|
+
writeJson(join(dir('ack'), `${iid}.json`), ack)
|
|
638
|
+
} catch (e) {
|
|
639
|
+
debugLog(`写回执失败:${(e && e.message) || e}`)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/** followup 后等它结束(它现在是同步的;万一以后返回 Promise,限时等、失败写 ok:false,绝不变成 unhandledRejection) */
|
|
643
|
+
const deliver = async (sid, text) => {
|
|
644
|
+
if (!text.trim()) return { ok: false, reason: 'empty' }
|
|
645
|
+
let agent = null
|
|
646
|
+
try {
|
|
647
|
+
const a = agents()
|
|
648
|
+
agent = a ? a.get(sid) : null
|
|
649
|
+
} catch (_) {
|
|
650
|
+
agent = null
|
|
651
|
+
}
|
|
652
|
+
if (!agent || typeof agent.followup !== 'function') return { ok: false, reason: 'not_live' }
|
|
653
|
+
const busy = status.get(sid) === 'running'
|
|
654
|
+
let timer = null
|
|
655
|
+
try {
|
|
656
|
+
const msg = Object.freeze({ content: [{ type: 'text', text }], source: { kind: 'user' }, role: 'user', id: randomUUID() })
|
|
657
|
+
const r = await Promise.race([
|
|
658
|
+
Promise.resolve().then(() => agent.followup(msg)).then(() => 'ok'),
|
|
659
|
+
new Promise((x) => (timer = setTimeout(() => x('timeout'), FOLLOWUP_WAIT_MS))),
|
|
660
|
+
])
|
|
661
|
+
if (r === 'timeout') return { ok: false, reason: 'followup_timeout' }
|
|
662
|
+
return { ok: true, busy }
|
|
663
|
+
} catch (e) {
|
|
664
|
+
debugLog(`followup 失败:${(e && e.message) || e}`)
|
|
665
|
+
return { ok: false, reason: 'followup_failed' }
|
|
666
|
+
} finally {
|
|
667
|
+
if (timer) clearTimeout(timer)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const start = () => {
|
|
671
|
+
beat()
|
|
672
|
+
const t1 = setInterval(beat, HEARTBEAT_MS)
|
|
673
|
+
const t2 = setInterval(poll, INBOX_POLL_MS)
|
|
674
|
+
if (t1.unref) t1.unref()
|
|
675
|
+
if (t2.unref) t2.unref()
|
|
676
|
+
const cleanup = () => {
|
|
677
|
+
clearInterval(t1)
|
|
678
|
+
clearInterval(t2)
|
|
679
|
+
try {
|
|
680
|
+
unlinkSync(alivePath)
|
|
681
|
+
} catch (_) {
|
|
682
|
+
/* 已经没了 */
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
process.once('exit', cleanup)
|
|
686
|
+
return cleanup
|
|
687
|
+
}
|
|
688
|
+
// Cordis 卸载插件(热重载 / 关闭)时一并停掉;假 ctx(测试)没有 effect 就直接起
|
|
689
|
+
if (ctx && typeof ctx.effect === 'function') ctx.effect(start)
|
|
690
|
+
else start()
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** 请求属于哪个会话:Agent 的 id 就是 SessionId,保险起见再看 agent.session.id */
|
|
694
|
+
function agentSid(req) {
|
|
695
|
+
const a = req && req.agent
|
|
696
|
+
const v = (a && a.session && a.session.id) || (a && a.id)
|
|
697
|
+
return typeof v === 'string' ? v : ''
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function safeGet(ctx, key) {
|
|
701
|
+
try {
|
|
702
|
+
return ctx && typeof ctx.get === 'function' ? ctx.get(key) : undefined
|
|
703
|
+
} catch (_) {
|
|
704
|
+
return undefined
|
|
705
|
+
}
|
|
706
|
+
}
|