deskbuddy 0.0.1 → 0.2.0-beta.0
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 +44 -8
- package/dist/cli.js +3760 -0
- package/dist/gate.cjs +249 -0
- package/dist/hook.cjs +922 -0
- package/dist/opencode.mjs +329 -0
- package/docs/INSTALL.md +152 -0
- package/hook/gate.cjs +249 -0
- package/hook/hook.cjs +922 -0
- package/hook/opencode.d.mts +7 -0
- package/hook/opencode.mjs +329 -0
- package/package.json +23 -3
- package/bin/deskbuddy.js +0 -9
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// DeskBuddy 的 opencode 插件本体(二期 P9)。
|
|
2
|
+
// `deskbuddy enable opencode` 在 opencode 的插件目录放一个入口文件 import 本文件;
|
|
3
|
+
// 本文件随 hook.cjs / gate.cjs 一起同步到 ~/.deskbuddy/,CLI 升级即生效。
|
|
4
|
+
//
|
|
5
|
+
// 做的事只有翻译:把 opencode 的事件翻成 cc-hook 同构的输入,喂给同目录的 hook.cjs(状态上报)
|
|
6
|
+
// 和 gate.cjs(授权关卡)。状态机、关卡、和设备打交道全在它们和 daemon 里,这里不重复。
|
|
7
|
+
//
|
|
8
|
+
// 实测依据(opencode 1.18.32;1.4.3 远程关卡与窗口确认同样实测通过,spec 2026-09-27-DeskBuddy二期 §P9 O1–O8):
|
|
9
|
+
// - 插件的 `permission.ask` 钩子不会被调用;授权只能从事件流的 permission.asked / permission.replied 拿
|
|
10
|
+
// - `tool.execute.before` 在权限判断之前触发、可以 await 任意久、抛错 = 这次调用失败
|
|
11
|
+
// - `opencode run` 遇到 ask 立即自动拒绝
|
|
12
|
+
// - `config` 钩子原地改 permission 能生效(整对象替换不行)
|
|
13
|
+
//
|
|
14
|
+
// 🔴 远程任务(环境变量 DESKBUDDY_GATE_RUN)的安全模型,三层:
|
|
15
|
+
// ① daemon 起进程前确认插件装在 opencode 会读的位置(daemon/index.ts);
|
|
16
|
+
// ② OPENCODE_CONFIG_CONTENT 把 OC_GATED_KEYS 逐项压成 deny(全局 + build agent 两级)。
|
|
17
|
+
// opencode 按"最后一条匹配的规则"生效且配置是深合并:只写 `*: deny` 挡不住用户配置里排在后面的
|
|
18
|
+
// `bash: allow`(评审 2026-09-28 实测),逐项写才会把同名键的值覆盖掉;
|
|
19
|
+
// ③ 本插件加载后才在 config 里放开、并在 tool.execute.before 里等设备。
|
|
20
|
+
// 残余:用户给清单外的权限名写了 allow,且插件恰好加载失败 —— 那一项会放行。远程模式下本文件任何失败都必须走"拒绝"。
|
|
21
|
+
// 🔴 窗口模式反过来:任何失败都不表态,授权框照常由人处理。
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const HOOK = join(HERE, 'hook.cjs')
|
|
29
|
+
const GATE = join(HERE, 'gate.cjs')
|
|
30
|
+
const TOOL = 'opencode'
|
|
31
|
+
|
|
32
|
+
/** 设备允许过的工具调用,后续同一调用的第二个授权框直接代答(O6:rm /tmp/x 会连问 external_directory 和 bash) */
|
|
33
|
+
const APPROVED_CALL_TTL_MS = 60 * 1000
|
|
34
|
+
/** 工具调用参数的缓存时长:tool.execute.before 记下,随后的授权框靠它显示真实命令 */
|
|
35
|
+
const CALL_TTL_MS = 10 * 60 * 1000
|
|
36
|
+
/** hook.cjs 自己有 2 秒上报超时,这里再兜一层:一次卡死不能堵住后面所有状态 */
|
|
37
|
+
const HOOK_KILL_MS = 10 * 1000
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 远程模式要压成 deny、插件加载后再放开的权限名。与 src/daemon/executor.ts 的 OPENCODE_GATED_KEYS 必须一致(有测试)。
|
|
41
|
+
* 新版本 opencode 多出来的权限名不在这里时,只剩 `*` 兜,见顶部"残余"
|
|
42
|
+
*/
|
|
43
|
+
export const OC_GATED_KEYS = [
|
|
44
|
+
'*', 'bash', 'edit', 'write', 'read', 'glob', 'grep', 'list', 'task', 'webfetch', 'websearch', 'codesearch',
|
|
45
|
+
'external_directory', 'todowrite', 'todoread', 'lsp', 'skill', 'doom_loop', 'patch', 'apply_patch',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
function runIdFromEnv() {
|
|
49
|
+
const v = process.env.DESKBUDDY_GATE_RUN || ''
|
|
50
|
+
return /^[0-9a-zA-Z_-]{1,64}$/.test(v) ? v : ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `opencode run` 是非交互(O2),其余(TUI、`opencode <目录>`)当窗口。编译版 argv = [bun, 入口, 子命令…]。
|
|
55
|
+
* 子命令前可能有全局参数(`--print-logs run`),所以看 `--` 之前有没有 `run`;误判成非交互只是少一个确认渠道
|
|
56
|
+
*/
|
|
57
|
+
export function isHeadless(argv = process.argv) {
|
|
58
|
+
const args = argv.slice(2)
|
|
59
|
+
const end = args.indexOf('--')
|
|
60
|
+
return (end >= 0 ? args.slice(0, end) : args).includes('run')
|
|
61
|
+
}
|
|
62
|
+
|
|
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
|
+
/**
|
|
109
|
+
* 远程模式:关卡改由 tool.execute.before 把,放开权限。🔴 必须原地改(整对象替换不生效,O8)。
|
|
110
|
+
* - OC_GATED_KEYS 是 daemon 压成 deny 的,放成 allow(用户原来在这些键上写的值已被覆盖,找不回来 —— 每一步都要过设备)
|
|
111
|
+
* - 其余键上的 ask 放成 allow(run 下 ask = 立即拒),用户显式的 deny 保留
|
|
112
|
+
* 全局与 build agent 两级都放(daemon 两级都压了)
|
|
113
|
+
*/
|
|
114
|
+
export function openPermissions(cfg) {
|
|
115
|
+
if (!cfg || typeof cfg !== 'object') return
|
|
116
|
+
const open = (perm) => {
|
|
117
|
+
const walk = (obj) => {
|
|
118
|
+
for (const k of Object.keys(obj)) {
|
|
119
|
+
const v = obj[k]
|
|
120
|
+
if (v === 'ask') obj[k] = 'allow'
|
|
121
|
+
else if (v && typeof v === 'object') walk(v)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
walk(perm)
|
|
125
|
+
for (const k of OC_GATED_KEYS) perm[k] = 'allow'
|
|
126
|
+
}
|
|
127
|
+
if (!cfg.permission || typeof cfg.permission !== 'object') cfg.permission = {}
|
|
128
|
+
open(cfg.permission)
|
|
129
|
+
const build = cfg.agent && cfg.agent.build
|
|
130
|
+
if (build && build.permission && typeof build.permission === 'object') open(build.permission)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 活着的子进程;opencode 退出时一并结束,不留孤儿 gate 挂着设备卡片(评审) */
|
|
134
|
+
const LIVE = new Set()
|
|
135
|
+
process.on('exit', () => {
|
|
136
|
+
for (const c of LIVE) {
|
|
137
|
+
try {
|
|
138
|
+
c.kill('SIGTERM')
|
|
139
|
+
} catch (_) {
|
|
140
|
+
/* 已经退了 */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/** 起 node 子进程跑 hook.cjs / gate.cjs,stdin 喂 JSON。返回子进程句柄与结束结果(起不来 = code -1)。killMs > 0 时到点结束 */
|
|
146
|
+
function startChild(node, script, args, input, killMs = 0) {
|
|
147
|
+
let child = null
|
|
148
|
+
const done = new Promise((resolve) => {
|
|
149
|
+
let out = ''
|
|
150
|
+
let err = ''
|
|
151
|
+
try {
|
|
152
|
+
child = spawn(node, [script, ...args], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
153
|
+
} catch (e) {
|
|
154
|
+
resolve({ code: -1, out, err: String(e) })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
LIVE.add(child)
|
|
158
|
+
const timer = killMs > 0 ? setTimeout(() => child.kill('SIGKILL'), killMs) : null
|
|
159
|
+
const finish = (r) => {
|
|
160
|
+
LIVE.delete(child)
|
|
161
|
+
if (timer) clearTimeout(timer)
|
|
162
|
+
resolve(r)
|
|
163
|
+
}
|
|
164
|
+
child.stdout.on('data', (c) => (out += c))
|
|
165
|
+
child.stderr.on('data', (c) => (err += c))
|
|
166
|
+
child.on('error', (e) => finish({ code: -1, out, err: String(e) }))
|
|
167
|
+
child.on('close', (code) => finish({ code, out, err }))
|
|
168
|
+
child.stdin.on('error', () => {})
|
|
169
|
+
child.stdin.end(JSON.stringify(input))
|
|
170
|
+
})
|
|
171
|
+
return { child, done }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const runChild = (node, script, args, input, killMs) => startChild(node, script, args, input, killMs).done
|
|
175
|
+
|
|
176
|
+
export function make(opts = {}) {
|
|
177
|
+
return async function DeskBuddy(ctx) {
|
|
178
|
+
const node = process.env.DESKBUDDY_NODE || opts.node || 'node'
|
|
179
|
+
const client = ctx && ctx.client
|
|
180
|
+
const cwd = (ctx && (ctx.directory || ctx.worktree)) || process.cwd()
|
|
181
|
+
const runId = runIdFromEnv()
|
|
182
|
+
const headless = isHeadless()
|
|
183
|
+
|
|
184
|
+
// ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
|
|
185
|
+
let queue = Promise.resolve()
|
|
186
|
+
const report = (event, sessionID, extra) => {
|
|
187
|
+
if (!sessionID) return
|
|
188
|
+
const input = { hook_event_name: event, session_id: sessionID, cwd, ...extra }
|
|
189
|
+
queue = queue.then(() => runChild(node, HOOK, [TOOL], input, HOOK_KILL_MS)).catch(() => {})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- 窗口确认 ----
|
|
193
|
+
const pending = new Map() // requestID → gate 子进程
|
|
194
|
+
const approvedCalls = new Map() // callID → 允许时间
|
|
195
|
+
const calls = new Map() // callID → { cc, at }:这次工具调用的真实参数
|
|
196
|
+
const prune = () => {
|
|
197
|
+
const now = Date.now()
|
|
198
|
+
for (const [k, v] of calls) if (now - v.at > CALL_TTL_MS) calls.delete(k)
|
|
199
|
+
for (const [k, at] of approvedCalls) if (now - at > APPROVED_CALL_TTL_MS) approvedCalls.delete(k)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const reply = async (sessionID, id, response) => {
|
|
203
|
+
try {
|
|
204
|
+
await client.postSessionIdPermissionsPermissionId({ path: { id: sessionID, permissionID: id }, body: { response } })
|
|
205
|
+
} catch (_) {
|
|
206
|
+
/* 已被人答复过等,无所谓 */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const toast = async (message) => {
|
|
211
|
+
try {
|
|
212
|
+
await client.tui.showToast({ body: { message, variant: 'info' } })
|
|
213
|
+
} catch (_) {
|
|
214
|
+
/* 没有 TUI(web / acp)就不提示 */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const onAsked = async (p) => {
|
|
219
|
+
const { id, sessionID } = p || {}
|
|
220
|
+
if (!id || !sessionID) return
|
|
221
|
+
prune()
|
|
222
|
+
const callID = p.tool && p.tool.callID
|
|
223
|
+
// 🔴 卡片用这次工具调用的真实参数(tool.execute.before 先于授权触发,O3)。
|
|
224
|
+
// external_directory 的 ask 自己不带命令,按它出卡片设备只看到"访问 /tmp/*"(评审)
|
|
225
|
+
const call = callID ? calls.get(callID) : null
|
|
226
|
+
const cc = call ? call.cc : askToCc(p)
|
|
227
|
+
report('PermissionRequest', sessionID, cc)
|
|
228
|
+
if (runId || headless || !client) return
|
|
229
|
+
// 同一调用的后续授权框直接代答:只在设备那张卡显示的就是真实参数时(approvedCalls 只在那时写入)
|
|
230
|
+
const at = callID && approvedCalls.get(callID)
|
|
231
|
+
if (at && Date.now() - at < APPROVED_CALL_TTL_MS) {
|
|
232
|
+
await reply(sessionID, id, 'once')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
const input = { hook_event_name: 'PermissionRequest', session_id: sessionID, cwd, ...cc }
|
|
236
|
+
const { child, done } = startChild(node, GATE, ['--window', '--tool', TOOL], input)
|
|
237
|
+
if (child) pending.set(id, child)
|
|
238
|
+
const r = await done
|
|
239
|
+
pending.delete(id)
|
|
240
|
+
if (r.code !== 0 || !r.out) return
|
|
241
|
+
let out
|
|
242
|
+
try {
|
|
243
|
+
out = JSON.parse(r.out)
|
|
244
|
+
} catch (_) {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const d = out && out.hookSpecificOutput && out.hookSpecificOutput.decision
|
|
248
|
+
if (!d) return
|
|
249
|
+
if (d.behavior === 'allow') {
|
|
250
|
+
if (callID && call) approvedCalls.set(callID, Date.now())
|
|
251
|
+
await reply(sessionID, id, 'once')
|
|
252
|
+
} else if (d.behavior === 'deny') {
|
|
253
|
+
await reply(sessionID, id, 'reject')
|
|
254
|
+
} else return
|
|
255
|
+
if (typeof out.systemMessage === 'string' && out.systemMessage) await toast(out.systemMessage)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const onReplied = (p) => {
|
|
259
|
+
// 人先在窗口里答了:结束 gate 子进程,daemon 随之撤掉设备上的卡片
|
|
260
|
+
const child = p && pending.get(p.requestID)
|
|
261
|
+
if (child) {
|
|
262
|
+
pending.delete(p.requestID)
|
|
263
|
+
try {
|
|
264
|
+
child.kill('SIGTERM')
|
|
265
|
+
} catch (_) {
|
|
266
|
+
/* 已经退了 */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
config: async (cfg) => {
|
|
273
|
+
if (runId) openPermissions(cfg)
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
'chat.message': async (input) => {
|
|
277
|
+
report('UserPromptSubmit', input && input.sessionID)
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
'tool.execute.before': async (input, output) => {
|
|
281
|
+
const cc = toCcTool(input && input.tool, output && output.args)
|
|
282
|
+
if (input && input.callID) calls.set(input.callID, { cc, at: Date.now() })
|
|
283
|
+
report('PreToolUse', input && input.sessionID, cc)
|
|
284
|
+
if (!runId) return
|
|
285
|
+
// 远程关卡:等设备。任何失败都拒(见顶部)
|
|
286
|
+
const r = await runChild(node, GATE, ['--run', runId], {
|
|
287
|
+
hook_event_name: 'PreToolUse',
|
|
288
|
+
session_id: (input && input.sessionID) || '',
|
|
289
|
+
cwd,
|
|
290
|
+
...cc,
|
|
291
|
+
})
|
|
292
|
+
let decision = ''
|
|
293
|
+
try {
|
|
294
|
+
decision = JSON.parse(r.out).hookSpecificOutput.permissionDecision
|
|
295
|
+
} catch (_) {
|
|
296
|
+
decision = ''
|
|
297
|
+
}
|
|
298
|
+
if (r.code === 0 && decision === 'allow') return
|
|
299
|
+
throw new Error((r.err || '').trim() || '用户在设备上拒绝了这一步')
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
'tool.execute.after': async (input) => {
|
|
303
|
+
if (input && input.callID) calls.delete(input.callID)
|
|
304
|
+
approvedCalls.delete(input && input.callID)
|
|
305
|
+
const cc = toCcTool(input && input.tool, input && input.args)
|
|
306
|
+
report('PostToolUse', input && input.sessionID, cc)
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
event: async ({ event }) => {
|
|
310
|
+
const p = (event && event.properties) || {}
|
|
311
|
+
switch (event && event.type) {
|
|
312
|
+
case 'permission.asked':
|
|
313
|
+
onAsked(p).catch(() => {})
|
|
314
|
+
break
|
|
315
|
+
case 'permission.replied':
|
|
316
|
+
onReplied(p)
|
|
317
|
+
break
|
|
318
|
+
case 'session.idle':
|
|
319
|
+
report('Stop', p.sessionID)
|
|
320
|
+
break
|
|
321
|
+
case 'session.deleted':
|
|
322
|
+
report('SessionEnd', (p.info && p.info.id) || p.sessionID)
|
|
323
|
+
break
|
|
324
|
+
default:
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
package/docs/INSTALL.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# DeskBuddy 安装指南(给 AI 助手照做的操作手册)
|
|
2
|
+
|
|
3
|
+
> 这份文档是写给**用户的 AI 编码助手**读的。用户会说「照着这个文档把 DeskBuddy 装上」,
|
|
4
|
+
> 你按顺序执行下面的步骤即可。每一步都给了**判定成功的依据**,不满足就按「排查」一节处理。
|
|
5
|
+
> 需要用户本人动手的地方(扫码、Codex 授信)已经标出来了,到那一步要停下来告诉用户。
|
|
6
|
+
|
|
7
|
+
DeskBuddy 把 Claude Code、Codex 等 AI 编码智能体**每个会话窗口**的状态,实时同步到桌面硬件屏幕。
|
|
8
|
+
多开会话时不用挨个切窗口看哪个在等确认、哪个跑完了。
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 0. 前置条件
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
node -v # 必须 ≥ 18
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
不满足就先让用户升级 Node,别继续。
|
|
19
|
+
|
|
20
|
+
**装完不会有任何常驻进程**:它只是往各智能体自己的 hook 配置里加了一条记录,由智能体在事件发生时调用。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 1. 授权(需要用户本人扫码)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx deskbuddy setup
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
终端会打印一个二维码和一行明文授权码,然后停在「等待授权中…」。
|
|
31
|
+
|
|
32
|
+
**这一步你(AI 助手)替代不了**,要告诉用户:
|
|
33
|
+
|
|
34
|
+
> 用 DeskBuddy App 扫描终端里的二维码;扫不出来就在 App 里手动输入那行授权码。
|
|
35
|
+
|
|
36
|
+
- 授权码 5 分钟内有效,过期就重新跑一次 `setup`
|
|
37
|
+
- 终端字符画受字体/行高/深浅色主题影响,有些终端**扫不出来**,这时用明文码手输,不是故障
|
|
38
|
+
- 成功后终端打印 `✅ 授权成功`,本机 `~/.deskbuddy/config.json` 里会多一个 `boundAt` 字段
|
|
39
|
+
|
|
40
|
+
**判定成功**:命令退出码为 0 且打印了 `✅ 授权成功`。
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. 安装采集 hook
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx deskbuddy enable claude_code,codex # 只用其中一个就只写一个
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**判定成功**:每个工具各打印一行 `✅ <tool>:hook 已装进 <路径>`。
|
|
51
|
+
|
|
52
|
+
### 🔴 Codex 用户必读:还要在 Codex 里确认信任
|
|
53
|
+
|
|
54
|
+
Codex 对 hook 定义做 sha256 校验,**首次运行会要求你确认信任这个 hook,不确认它就不执行**。
|
|
55
|
+
现象是「装成功了但屏幕上一直没数据」,极容易被当成安装失败。
|
|
56
|
+
|
|
57
|
+
所以装完 codex 之后要告诉用户:
|
|
58
|
+
|
|
59
|
+
> 下次启动 Codex 时会弹出确认信任 hook 的提示,请选择同意。
|
|
60
|
+
|
|
61
|
+
**不要**用 `--dangerously-bypass-hook-trust` 替用户绕过这个确认。
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 3. 验证
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx deskbuddy status
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
应该看到:账号已授权、已启用的工具都显示「hook 已装」、hook 版本与当前包一致。
|
|
72
|
+
让智能体随便跑个任务,再看一次 `status`,「当前本地会话」里就会出现内容。
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 国内用户
|
|
77
|
+
|
|
78
|
+
本机 npm 若走淘宝镜像,装最新版可能有几分钟同步延迟:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx --registry=https://registry.npmmirror.com deskbuddy setup
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
装不到最新版就临时改用官方源:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx --registry=https://registry.npmjs.org deskbuddy setup
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 它会改动你机器上的哪些文件
|
|
93
|
+
|
|
94
|
+
| 文件 | 改动方式 |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `~/.claude/settings.json` | **幂等合并**一条 hook 记录(你已有的 hook 一条都不动) |
|
|
97
|
+
| `~/.codex/config.toml` | 在文件**末尾追加**一段带 `# >>> deskbuddy >>>` / `# <<< deskbuddy <<<` 标记的块;其余字节一个都不碰,也不重排、不丢注释 |
|
|
98
|
+
| `~/.deskbuddy/` | 我们自己的目录:`config.json`(clientId、授权状态)、`hook.cjs`、`state/*.json`(每个会话一份)、`tool-registry.json` |
|
|
99
|
+
|
|
100
|
+
写这两个用户文件时都是**原子写并保留原权限位**(`config.toml` 通常是 `0600`,不会被放宽)。
|
|
101
|
+
|
|
102
|
+
**不碰** `~/.claude/fleet/`(那是用户自己的 fleet 系统)。
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 卸载
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx deskbuddy disable # 移除全部 hook,保留授权与 clientId,随时可再 enable
|
|
110
|
+
npx deskbuddy logout # 移除 hook + 删除整个 ~/.deskbuddy(要确认,--yes 跳过)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`disable` 只摘掉我们自己那一条:Claude Code 侧按 hook 路径匹配,Codex 侧只删两个标记之间的行。
|
|
114
|
+
用户自己的 hook 原样保留。
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 隐私边界
|
|
119
|
+
|
|
120
|
+
**上报**:工具名、状态(进行中/待确认/已完成/失败)、会话标题、**项目目录名**(不是全路径)、时间戳。
|
|
121
|
+
|
|
122
|
+
**不上报**:prompt 原文、Bash 命令、文件完整路径、代码片段。
|
|
123
|
+
|
|
124
|
+
会话标题的来源按顺序回落,都不涉及 prompt 内容:
|
|
125
|
+
|
|
126
|
+
1. Claude Code 自己写在 transcript 里的 `aiTitle`(只读文件尾部 64KB,不遍历整个文件)
|
|
127
|
+
2. Codex 的 `session_index.jsonl` 里的 `thread_name`(多数用户是空的,属于机会主义的一级)
|
|
128
|
+
3. 当前 git 分支名(读 `.git/HEAD`,向上找 5 层)
|
|
129
|
+
4. 项目目录名
|
|
130
|
+
|
|
131
|
+
⚠️ 第 3 级意味着**分支名会显示在桌面屏幕上**。如果分支名本身敏感(含客户名等),注意这一点。
|
|
132
|
+
|
|
133
|
+
客户端**不持有任何用户 token**:鉴权靠服务端的 clientId ↔ 用户绑定关系,绑定在 App 扫码时建立。
|
|
134
|
+
源码里那个 `appSecret` 是随包公开的常量,不是密钥。
|
|
135
|
+
|
|
136
|
+
随时用 `npx deskbuddy status` 查看当前采集到了什么 —— 它最后固定会打印上面这两行采集范围。
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 排查
|
|
141
|
+
|
|
142
|
+
| 现象 | 先查什么 |
|
|
143
|
+
|---|---|
|
|
144
|
+
| 屏幕上没数据 | `npx deskbuddy status`:先看「已启用」里是不是「hook 已装」 |
|
|
145
|
+
| Codex 装了但没数据 | **十有八九是没在 Codex 里确认信任 hook**,重启 Codex 看有没有确认提示 |
|
|
146
|
+
| 提示「还没有授权」 | 先跑 `npx deskbuddy setup` |
|
|
147
|
+
| 刚扫完提示码失效 | 偶发一次是正常的,会自动重试;**连续失败多半是这台电脑已绑定其他账号**(一台电脑同时只能绑一个账号),先 `npx deskbuddy logout` 再重新 `setup` |
|
|
148
|
+
| 提示「已绑定其他账号」 | 按提示先 `npx deskbuddy logout` 再 `setup`。注意 `logout` 只清本机数据,服务端那边的绑定要在 App 里撤销(暂未支持) |
|
|
149
|
+
| 升级后旧 bug 还在 | 不用管,任意 `deskbuddy` 命令启动时会自动把 `~/.deskbuddy/hook.cjs` 校准到当前包版本;`status` 的「hook 版本」行会显示是否一致 |
|
|
150
|
+
| 会话关了还挂在屏幕上 | 上报靠进程存活探测,通常几分钟内自己消失 |
|
|
151
|
+
|
|
152
|
+
联调用环境变量(正常用户用不到):`DESKBUDDY_API_BASE`、`DESKBUDDY_HOME`、`DESKBUDDY_APPID`、`DESKBUDDY_APPSECRET`。
|