deskbuddy 0.2.0-beta.0 → 0.2.0-beta.2

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/dist/gate.cjs CHANGED
@@ -106,6 +106,7 @@ function argTool(argv) {
106
106
  * - Claude Code:见 isInteractiveClaude
107
107
  */
108
108
  function isInteractiveAgent(tool) {
109
+ if (process.platform === 'win32') return isInteractiveAgentWin(tool)
109
110
  if (tool === 'qoder') return hasAncestor((comm) => comm.includes('/Qoder CN.app/'))
110
111
  if (tool === 'claude_code') return isInteractiveClaude()
111
112
  // opencode 的窗口确认由插件发起,插件已排除 `opencode run`(hook/opencode.mjs isHeadless)
@@ -113,6 +114,43 @@ function isInteractiveAgent(tool) {
113
114
  return false
114
115
  }
115
116
 
117
+ /** 窗口里 claude 的参数不带 -p / --print / stream-json:与 isInteractiveClaude 同一判据 */
118
+ function claudeArgsInteractive(argsAfterArgv0) {
119
+ return !argsAfterArgv0.some(
120
+ (t) => t === '--print' || /^-[a-zA-Z]*p[a-zA-Z]*$/.test(t) || t === 'stream-json' || t.startsWith('--output-format=stream')
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Windows 版(没有 ps):进程表借 hook.cjs 的实现(同目录,随它一起装),判据同上。
126
+ * - Qoder CN 桌面端:祖先里有 `Qoder CN.exe`
127
+ * - Claude Code:原生版是 `claude.exe`,npm 版是 node 跑 `claude-code/cli.js`
128
+ * 取不到进程表 = 判不出来 = 不表态(窗口模式的兜底方向)
129
+ */
130
+ function isInteractiveAgentWin(tool) {
131
+ if (tool === 'opencode') return true
132
+ if (tool !== 'qoder' && tool !== 'claude_code') return false
133
+ let winProc
134
+ try {
135
+ winProc = require(path.join(__dirname, 'hook.cjs')).winProc
136
+ } catch (_) {
137
+ return false
138
+ }
139
+ let pid = process.ppid
140
+ const seen = new Set()
141
+ for (let i = 0; i < 8 && pid > 4 && !seen.has(pid); i++) {
142
+ seen.add(pid)
143
+ const p = winProc(pid)
144
+ if (!p) return false
145
+ if (tool === 'qoder' && /qoder cn/i.test(p.name)) return true
146
+ if (tool === 'claude_code' && (/^claude(\.exe)?$/i.test(p.name) || p.name === 'claude-code/cli.js')) {
147
+ return claudeArgsInteractive(p.args.split(' ').slice(1))
148
+ }
149
+ pid = p.ppid
150
+ }
151
+ return false
152
+ }
153
+
116
154
  function hasAncestor(match) {
117
155
  let pid = process.ppid
118
156
  for (let i = 0; i < 8 && pid > 1; i++) {
package/dist/hook.cjs CHANGED
@@ -22,6 +22,11 @@ const { execFileSync } = require('node:child_process')
22
22
  const DEFAULT_API_BASE = process.env.DESKBUDDY_API_BASE || 'https://api.shifengintelli.com'
23
23
  const KEEPALIVE_MS = 2 * 60 * 1000
24
24
  const MAX_SESSIONS = 20
25
+ /**
26
+ * 快照整包带的能力声明(前置 spec 2026-09-29 §4.4,服务端写 deskbuddy:caps:{clientId})。
27
+ * 与 src/core/snapshot.ts 的 CAPS 一致
28
+ */
29
+ const CAPS = ['cmd_result', 'window_inject']
25
30
  /** 状态文件里保留的最近事件条数,与 src/core/state.ts 的 RECENT_MAX 一致 */
26
31
  const RECENT_MAX = 8
27
32
  const REPORT_TIMEOUT_MS = 2000
@@ -331,11 +336,141 @@ function resolveTitle(sources, ctx) {
331
336
  return ''
332
337
  }
333
338
 
339
+ // ---------- Windows 进程表 ----------
340
+ // 🔴 与 src/core/winproc.ts 逐行同构:这里记下的 pidName 要由 daemon 拿同一个算法复核,改一边必须改另一边。
341
+ // Windows 没有 ps,用 PowerShell 一次取全表在内存里走(一次 300ms 起,不能按 pid 一个个查)。
342
+
343
+ const IS_WIN = process.platform === 'win32'
344
+ const SCRIPT_HOSTS = ['node', 'bun', 'deno']
345
+ const WIN_PS_SCRIPT =
346
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8;' +
347
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'
348
+
349
+ function winSplitArgs(cmdline) {
350
+ const out = []
351
+ let cur = ''
352
+ let inQ = false
353
+ let has = false
354
+ const s = cmdline || ''
355
+ for (let i = 0; i < s.length; i++) {
356
+ const c = s[i]
357
+ if (c === '\\') {
358
+ let n = 0
359
+ while (s[i + n] === '\\') n++
360
+ if (s[i + n] === '"') {
361
+ cur += '\\'.repeat(Math.floor(n / 2))
362
+ if (n % 2 === 1) cur += '"'
363
+ else inQ = !inQ
364
+ i += n
365
+ has = true
366
+ } else {
367
+ cur += '\\'.repeat(n)
368
+ i += n - 1
369
+ has = true
370
+ }
371
+ } else if (c === '"') {
372
+ inQ = !inQ
373
+ has = true
374
+ } else if (!inQ && (c === ' ' || c === '\t')) {
375
+ if (has) out.push(cur)
376
+ cur = ''
377
+ has = false
378
+ } else {
379
+ cur += c
380
+ has = true
381
+ }
382
+ }
383
+ if (has) out.push(cur)
384
+ return out
385
+ }
386
+
387
+ function isScriptHost(name) {
388
+ return SCRIPT_HOSTS.indexOf(String(name).replace(/\.exe$/i, '').toLowerCase()) !== -1
389
+ }
390
+
391
+ function hostedScript(argv) {
392
+ for (const a of argv.slice(1)) if (!a.startsWith('-')) return a
393
+ return ''
394
+ }
395
+
396
+ /** 原生程序取映像名;node / bun 跑的取脚本路径最后两段(再往上是用户目录,用户名可能撞智能体名) */
397
+ function winEffectiveName(name, cmdline) {
398
+ if (!isScriptHost(name)) return name
399
+ const script = hostedScript(winSplitArgs(cmdline))
400
+ return script ? script.split(/[\\/]+/).filter(Boolean).slice(-2).join('/') : name
401
+ }
402
+
403
+ function winNormalizedArgs(name, cmdline) {
404
+ let argv = winSplitArgs(cmdline)
405
+ if (argv.length === 0) return ''
406
+ if (isScriptHost(name)) {
407
+ const script = hostedScript(argv)
408
+ if (script) argv = argv.slice(argv.indexOf(script))
409
+ }
410
+ return argv.map((a) => a.replace(/\s+/g, '_').replace(/\\/g, '/')).join(' ')
411
+ }
412
+
413
+ function parseWinProcJson(json) {
414
+ let d
415
+ try {
416
+ d = JSON.parse(String(json).replace(/^/, ''))
417
+ } catch (e) {
418
+ return []
419
+ }
420
+ const list = Array.isArray(d) ? d : d ? [d] : []
421
+ const out = []
422
+ for (const r of list) {
423
+ const pid = Number(r && r.ProcessId)
424
+ if (!Number.isInteger(pid) || pid <= 0) continue
425
+ const rawName = typeof r.Name === 'string' ? r.Name : ''
426
+ const cmdline = typeof r.CommandLine === 'string' ? r.CommandLine.replace(/[\r\n]+/g, ' ') : ''
427
+ out.push({
428
+ pid: pid,
429
+ ppid: Number(r.ParentProcessId) || 0,
430
+ name: winEffectiveName(rawName, cmdline),
431
+ args: winNormalizedArgs(rawName, cmdline),
432
+ cmdline: cmdline,
433
+ })
434
+ }
435
+ return out
436
+ }
437
+
438
+ let winProcCache
439
+ /** 全表,本进程内只取一次。取不到返回 null —— 🔴 调用方必须当"不知道",不能当"进程不在" */
440
+ function winProcs() {
441
+ if (winProcCache !== undefined) return winProcCache
442
+ try {
443
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WIN_PS_SCRIPT], {
444
+ encoding: 'utf8',
445
+ stdio: ['ignore', 'pipe', 'ignore'],
446
+ maxBuffer: 32 * 1024 * 1024,
447
+ windowsHide: true,
448
+ timeout: 15000,
449
+ })
450
+ const procs = parseWinProcJson(out)
451
+ winProcCache = procs.length > 0 ? procs : null
452
+ } catch (e) {
453
+ winProcCache = null
454
+ }
455
+ return winProcCache
456
+ }
457
+
458
+ function winProc(pid) {
459
+ const procs = winProcs()
460
+ if (!procs) return null
461
+ for (const p of procs) if (p.pid === pid) return p
462
+ return null
463
+ }
464
+
334
465
  // ---------- 进程探测 ----------
335
466
 
336
467
  function processName(pid) {
468
+ if (IS_WIN) {
469
+ const p = winProc(pid)
470
+ return p ? p.name : null
471
+ }
337
472
  try {
338
- // macOS / Linux 通用。Windows 需要 tasklist,首期不支持
473
+ // macOS / Linux 通用
339
474
  const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
340
475
  encoding: 'utf8',
341
476
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -348,6 +483,10 @@ function processName(pid) {
348
483
  }
349
484
 
350
485
  function parentPid(pid) {
486
+ if (IS_WIN) {
487
+ const p = winProc(pid)
488
+ return p && p.ppid > 4 && p.ppid !== pid ? p.ppid : null
489
+ }
351
490
  try {
352
491
  const out = execFileSync('ps', ['-p', String(pid), '-o', 'ppid='], {
353
492
  encoding: 'utf8',
@@ -369,6 +508,8 @@ function isAlive(pid, expectName) {
369
508
  return false
370
509
  }
371
510
  if (!expectName) return true
511
+ // 🔴 Windows 进程表取不到时只能信 pid:当成"不在"会把所有窗口会话清掉
512
+ if (IS_WIN && !winProcs()) return true
372
513
  const name = processName(pid)
373
514
  return name != null && name.toLowerCase().includes(String(expectName).toLowerCase())
374
515
  }
@@ -467,7 +608,26 @@ function shouldReport(prev, next, nowMs) {
467
608
  return nowMs - (prev.lastReportAt || 0) > KEEPALIVE_MS
468
609
  }
469
610
 
470
- function buildSnapshot(states, tool, cfg, nowMs) {
611
+ /**
612
+ * daemon 连着的 Codex app-server 里已加载的会话(切片 B,与 src/core/codexRemote.ts 一致)。
613
+ * daemon 进程不在了就不认;读不到返回 null
614
+ */
615
+ /** daemon 每 30 秒重写一次;两个保活周期(2 分钟)没更新就当 daemon 卡住了,不认 */
616
+ const CODEX_REMOTE_TTL_MS = 2 * 60 * 1000
617
+
618
+ function readCodexRemote(now) {
619
+ try {
620
+ const r = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'codex-remote.json'), 'utf8'))
621
+ if (!r || typeof r.pid !== 'number' || !Array.isArray(r.threads) || !pidExists(r.pid)) return null
622
+ if (typeof r.at !== 'number' || Math.abs((now || Date.now()) - r.at) > CODEX_REMOTE_TTL_MS) return null
623
+ return new Set(r.threads.filter((x) => typeof x === 'string'))
624
+ } catch (e) {
625
+ return null
626
+ }
627
+ }
628
+
629
+ /** remote:给了就以它判 remoteWindow(Codex,切片 B),否则看状态文件(与 src/core/snapshot.ts 一致) */
630
+ function buildSnapshot(states, tool, cfg, nowMs, remote) {
471
631
  const sessions = states
472
632
  .filter((s) => s.tool === tool) // 替换粒度是 (clientId, tool),混进别的工具会冲掉对方
473
633
  .sort((a, b) => b.updatedAt - a.updatedAt)
@@ -481,8 +641,9 @@ function buildSnapshot(states, tool, cfg, nowMs) {
481
641
  startedAt: s.startedAt,
482
642
  updatedAt: s.updatedAt,
483
643
  interactive: s.interactive === true,
644
+ remoteWindow: remote ? remote.has(s.sessionId) : s.remoteWindow === true,
484
645
  }))
485
- return { clientId: cfg.clientId, hostName: cfg.hostName, tool: tool, snapshotAt: nowMs, sessions: sessions }
646
+ return { clientId: cfg.clientId, hostName: cfg.hostName, tool: tool, snapshotAt: nowMs, sessions: sessions, caps: CAPS.slice() }
486
647
  }
487
648
 
488
649
  // ---------- 上报 ----------
@@ -603,7 +764,8 @@ async function reportIfAuthorized(tool, nowMs) {
603
764
  // clientId 为空说明还没 setup 过(服务端靠 clientId ↔ userId 绑定关系认人):
604
765
  // 绝不能在这里现编一个,那等于凭空造出一台没人认领的机器
605
766
  if (!cfg.clientId || cfg.enabledTools.indexOf(tool) === -1) return
606
- await postReport(cfg, buildSnapshot(readAliveStates(tool), tool, cfg, nowMs))
767
+ const remote = tool === 'codex' ? readCodexRemote() || new Set() : null
768
+ await postReport(cfg, buildSnapshot(readAliveStates(tool), tool, cfg, nowMs, remote))
607
769
  }
608
770
 
609
771
  /**
@@ -654,6 +816,10 @@ function pidExists(pid) {
654
816
 
655
817
  function detectInteractive(pid) {
656
818
  if (!pid) return false
819
+ if (IS_WIN) {
820
+ const p = winProc(pid)
821
+ return !!p && p.args !== '' && isInteractiveArgs(p.args)
822
+ }
657
823
  try {
658
824
  const args = execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], {
659
825
  encoding: 'utf8',
@@ -772,6 +938,84 @@ function takeNotice(sessionId) {
772
938
  }
773
939
  }
774
940
 
941
+ // ---------- 窗口接管(spec 2026-09-30 §3.2,与 src/core/wrapWindows.ts 同构) ----------
942
+
943
+ const WINDOW_ID_RE = /^[a-f0-9]{32}$/
944
+ /** 记进 .last 的事件。Notification 不记:闲置 60 秒的「等你输入」通知会把空闲窗口判成忙 */
945
+ const WINDOW_EVENTS = {
946
+ SessionStart: 1,
947
+ UserPromptSubmit: 1,
948
+ PreToolUse: 1,
949
+ PostToolUse: 1,
950
+ PostToolUseFailure: 1,
951
+ PermissionRequest: 1,
952
+ Stop: 1,
953
+ SessionEnd: 1,
954
+ }
955
+
956
+ function windowsDir() {
957
+ return path.join(deskbuddyHome(), 'windows')
958
+ }
959
+
960
+ function readWindow(windowId) {
961
+ if (!WINDOW_ID_RE.test(windowId)) return null
962
+ try {
963
+ const r = JSON.parse(fs.readFileSync(path.join(windowsDir(), windowId + '.json'), 'utf8'))
964
+ return r && typeof r.pid === 'number' ? r : null
965
+ } catch (e) {
966
+ return null
967
+ }
968
+ }
969
+
970
+ /**
971
+ * 环境变量里的窗口是不是这个智能体进程的窗口。
972
+ * 🔴 窗口里的 claude 用 Bash 工具再起 claude 时 DESKBUDDY_WINDOW 会被继承:只认包装层直接起的那个进程(childPid);
973
+ * 判不出 pid 时按是
974
+ */
975
+ function windowOwns(rec, agentPid) {
976
+ if (!rec) return false
977
+ if (rec.childPid && agentPid && rec.childPid !== agentPid) return false
978
+ return true
979
+ }
980
+
981
+ function atomicWrite(file, text) {
982
+ const tmp = file + '.' + process.pid + '.tmp'
983
+ fs.writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600 })
984
+ fs.renameSync(tmp, file)
985
+ }
986
+
987
+ /** 写 by-session 映射(内容没变不重写)+ 本窗口最近一个状态型事件,供包装层判空闲 */
988
+ function noteWindow(windowId, sessionId, event, now) {
989
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '')
990
+ if (!safe || !WINDOW_ID_RE.test(windowId)) return
991
+ try {
992
+ const dir = path.join(windowsDir(), 'by-session')
993
+ let cur = ''
994
+ try {
995
+ cur = fs.readFileSync(path.join(dir, safe), 'utf8').trim()
996
+ } catch (e) {
997
+ cur = ''
998
+ }
999
+ if (cur !== windowId) {
1000
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
1001
+ atomicWrite(path.join(dir, safe), windowId)
1002
+ }
1003
+ } catch (e) {
1004
+ /* 映射写不了:设备指令退回后台路径 */
1005
+ }
1006
+ if (!WINDOW_EVENTS[event]) return
1007
+ try {
1008
+ atomicWrite(path.join(windowsDir(), windowId + '.last'), JSON.stringify({ ev: event, sid: sessionId, at: now }))
1009
+ } catch (e) {
1010
+ /* 同上:写不了就一直判不空闲,设备指令排队到过期 */
1011
+ }
1012
+ }
1013
+
1014
+ function codexRemoteHas(sessionId) {
1015
+ const r = readCodexRemote()
1016
+ return !!r && r.has(sessionId)
1017
+ }
1018
+
775
1019
  async function main(argv) {
776
1020
  // 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
777
1021
  const tool = (argv && argv[2]) || 'claude_code'
@@ -794,6 +1038,19 @@ async function main(argv) {
794
1038
 
795
1039
  const registry = loadRegistry()
796
1040
 
1041
+ // 窗口接管(spec 2026-09-30 §3.2):包装层起的窗口带 DESKBUDDY_WINDOW。只在有这个变量时才多做一点事
1042
+ let windowId = ''
1043
+ const envWin = process.env.DESKBUDDY_WINDOW || ''
1044
+ if (WINDOW_ID_RE.test(envWin)) {
1045
+ const rec = readWindow(envWin)
1046
+ if (rec) {
1047
+ const p0 = readState(sessionId)
1048
+ const agentPid = p0 && p0.pid && pidExists(p0.pid) ? p0.pid : resolveAgentPid(process.pid, agentNames(registry, tool))
1049
+ if (windowOwns(rec, agentPid || undefined)) windowId = envWin
1050
+ }
1051
+ if (windowId) noteWindow(windowId, sessionId, event, Date.now())
1052
+ }
1053
+
797
1054
  // /clear 改绑(二期 P2):SessionStart 在映射表里是 ignore,要在那之前处理
798
1055
  if (event === 'SessionStart' && payload.source === 'clear') {
799
1056
  const pid = resolveAgentPid(process.pid, agentNames(registry, tool))
@@ -840,7 +1097,10 @@ async function main(argv) {
840
1097
  pid: pid,
841
1098
  pidName: pid ? (prevAlive && prev.pidName) || processName(pid) || undefined : undefined,
842
1099
  // 二期 P1:窗口开着的会话,设备不能再往里下指令(两个进程会互相吞掉对话)。每个会话只判一次
843
- interactive: prevAlive && typeof prev.interactive === 'boolean' ? prev.interactive : detectInteractive(pid),
1100
+ interactive: windowId ? true : prevAlive && typeof prev.interactive === 'boolean' ? prev.interactive : detectInteractive(pid),
1101
+ // 会话开在 DeskBuddy 窗口里(spec 2026-09-30 §3.2):服务端据此放行 8348,指令由包装层写进窗口
1102
+ // Codex(切片 B):会话加载在 daemon 连着的 app-server 里 = 窗口开着且能往里发
1103
+ remoteWindow: windowId || (tool === 'codex' && codexRemoteHas(sessionId)) ? true : undefined,
844
1104
  lastReportAt: prev ? prev.lastReportAt : undefined,
845
1105
  // daemon 靠它给窗口待批准撤卡(S10),与 src/core/state.ts 的 recent 同构
846
1106
  recent: ((prev && Array.isArray(prev.recent) ? prev.recent : []).concat([
@@ -853,7 +1113,9 @@ async function main(argv) {
853
1113
  ])).slice(-RECENT_MAX),
854
1114
  }
855
1115
 
856
- if (!shouldReport(prev, next, now)) {
1116
+ // 进 / 出 DeskBuddy 窗口不一定伴随状态跃迁(窗口里 --resume 一个已完成的会话),要立刻报,否则设备最长 2 分钟仍被 8348 挡
1117
+ const windowChanged = !!(prev && prev.remoteWindow) !== !!next.remoteWindow
1118
+ if (!windowChanged && !shouldReport(prev, next, now)) {
857
1119
  writeState(next)
858
1120
  return
859
1121
  }
@@ -896,6 +1158,7 @@ module.exports = {
896
1158
  buildGatewayBody,
897
1159
  buildHeaders,
898
1160
  buildSnapshot,
1161
+ readCodexRemote,
899
1162
  isAlive,
900
1163
  loadConfig,
901
1164
  loadRegistry,
@@ -919,4 +1182,14 @@ module.exports = {
919
1182
  sign,
920
1183
  titleSources,
921
1184
  writeState,
1185
+ winEffectiveName,
1186
+ winNormalizedArgs,
1187
+ winProc,
1188
+ winSplitArgs,
1189
+ parseWinProcJson,
1190
+ CAPS,
1191
+ WINDOW_EVENTS,
1192
+ noteWindow,
1193
+ readWindow,
1194
+ windowOwns,
922
1195
  }
package/docs/INSTALL.md CHANGED
@@ -4,8 +4,12 @@
4
4
  > 你按顺序执行下面的步骤即可。每一步都给了**判定成功的依据**,不满足就按「排查」一节处理。
5
5
  > 需要用户本人动手的地方(扫码、Codex 授信)已经标出来了,到那一步要停下来告诉用户。
6
6
 
7
- DeskBuddy 把 Claude Code、Codex 等 AI 编码智能体**每个会话窗口**的状态,实时同步到桌面硬件屏幕。
8
- 多开会话时不用挨个切窗口看哪个在等确认、哪个跑完了。
7
+ DeskBuddy 让桌面硬件和电脑上的 AI 编码智能体(Claude Code、Codex、WorkBuddy、Qoder CN、opencode)配合:
8
+
9
+ - **状态上屏**:每个会话窗口在干活、在等你确认、还是做完了,抬头看一眼桌上的屏就知道
10
+ - **远程下指令**:在设备上对某个任务说一句话,电脑上的智能体接着干;也能在设备上新建任务
11
+ - **在设备上批准**:智能体要跑命令、改工作目录外的文件时,设备上弹卡片等你按「允许 / 拒绝」
12
+ - **窗口授权框同步到设备**:你自己在电脑窗口里用智能体、弹出授权框时,设备上也出一张卡片,两边谁先答算谁
9
13
 
10
14
  ---
11
15
 
@@ -15,76 +19,110 @@ DeskBuddy 把 Claude Code、Codex 等 AI 编码智能体**每个会话窗口**
15
19
  node -v # 必须 ≥ 18
16
20
  ```
17
21
 
18
- 不满足就先让用户升级 Node,别继续。
19
-
20
- **装完不会有任何常驻进程**:它只是往各智能体自己的 hook 配置里加了一条记录,由智能体在事件发生时调用。
22
+ - 不满足就先让用户升级 Node,别继续
23
+ - 支持 macOS、Linux、Windows 10/11(Windows 上 Claude Code 建议装 Git for Windows;命令在 PowerShell 或 Git Bash 里敲都行,`~` 即 `%USERPROFILE%`)
24
+ - ⚠️ **当前是 beta,服务端只在测试环境**:下面所有命令都用 `deskbuddy@beta`,`setup` 必须带 `--api-base`
21
25
 
22
26
  ---
23
27
 
24
28
  ## 1. 授权(需要用户本人扫码)
25
29
 
26
30
  ```bash
27
- npx deskbuddy setup
31
+ npx deskbuddy@beta setup --api-base https://test-api.shifengintelli.com
28
32
  ```
29
33
 
30
- 终端会打印一个二维码和一行明文授权码,然后停在「等待授权中…」。
34
+ 1. 终端先问**远程新建任务默认在哪个目录做**(直接回车 = 桌面)
35
+ 2. 然后打印一个二维码和一行明文授权码,停在「等待授权中…」
31
36
 
32
- **这一步你(AI 助手)替代不了**,要告诉用户:
37
+ **扫码这一步你(AI 助手)替代不了**,要告诉用户:
33
38
 
34
39
  > 用 DeskBuddy App 扫描终端里的二维码;扫不出来就在 App 里手动输入那行授权码。
35
40
 
36
41
  - 授权码 5 分钟内有效,过期就重新跑一次 `setup`
37
42
  - 终端字符画受字体/行高/深浅色主题影响,有些终端**扫不出来**,这时用明文码手输,不是故障
38
- - 成功后终端打印 `✅ 授权成功`,本机 `~/.deskbuddy/config.json` 里会多一个 `boundAt` 字段
43
+ - 🔴 **不带 `--api-base` 会连到生产环境**,而 beta 的接口只在测试环境,授权会失败或后续功能报错
39
44
 
40
45
  **判定成功**:命令退出码为 0 且打印了 `✅ 授权成功`。
41
46
 
42
47
  ---
43
48
 
44
- ## 2. 安装采集 hook
49
+ ## 2. 启用智能体
45
50
 
46
51
  ```bash
47
- npx deskbuddy enable claude_code,codex # 只用其中一个就只写一个
52
+ npx deskbuddy@beta enable claude_code,codex,opencode # 用户用哪几个写哪几个,逗号分隔
48
53
  ```
49
54
 
50
- **判定成功**:每个工具各打印一行 `✅ <tool>:hook 已装进 <路径>`。
55
+ 可选:`claude_code`、`codex`、`workbuddy`、`qoder`(Qoder CN 桌面端)、`opencode`。
56
+
57
+ `enable` 做三件事:往各智能体配置里装 hook / 插件、把脚本同步到 `~/.deskbuddy/`、**设好守护进程开机自启并启动它**。
58
+
59
+ **判定成功**:每个工具各打印一行 `✅ <tool>:hook 已装进 <路径>`,最后一行附近有 `✅ 已设置开机自启`。
60
+
61
+ 装完要告诉用户:**已经开着的智能体窗口要关掉重开**,新的 hook / 插件才会生效。
62
+
63
+ ### 🔴 Codex 用户必读
51
64
 
52
- ### 🔴 Codex 用户必读:还要在 Codex 里确认信任
65
+ 1. **要在 Codex 里确认信任 hook**:Codex 对 hook 定义做 sha256 校验,首次启动会要求确认,不确认就不执行。
66
+ 现象是「装成功了但屏幕上一直没数据」,极容易被当成安装失败。告诉用户:
53
67
 
54
- Codex 对 hook 定义做 sha256 校验,**首次运行会要求你确认信任这个 hook,不确认它就不执行**。
55
- 现象是「装成功了但屏幕上一直没数据」,极容易被当成安装失败。
68
+ > 下次启动 Codex 时会弹出确认信任 hook 的提示,请选择同意。
56
69
 
57
- 所以装完 codex 之后要告诉用户:
70
+ **不要**用 `--dangerously-bypass-hook-trust` 替用户绕过这个确认。
58
71
 
59
- > 下次启动 Codex 时会弹出确认信任 hook 的提示,请选择同意。
72
+ 2. **窗口授权框同步到设备有前提**:只有 Codex 经 `codex --remote ws://127.0.0.1:<端口>` 连着一个常驻 `codex app-server` 使用时才生效
73
+ (守护进程作为第二个客户端连上去)。直接运行 `codex` 时,设备上只显示「待确认」,授权框仍在电脑上点。
60
74
 
61
- **不要**用 `--dangerously-bypass-hook-trust` 替用户绕过这个确认。
75
+ ### 各智能体的能力
76
+
77
+ | 能力 | Claude Code | Codex | WorkBuddy | Qoder CN | opencode |
78
+ |---|---|---|---|---|---|
79
+ | 状态上屏 | ✅ | ✅ | ✅ | ✅ | ✅ |
80
+ | 远程下指令 / 续会话 | ✅ | ✅ | ✅ | ✅ | ✅ |
81
+ | 远程任务在设备上批准 | ✅ | ✅ | ✅ | ✅ | ✅ |
82
+ | 窗口授权框同步到设备 | ✅ | 仅 `--remote` 常驻 app-server | ❌ | ✅ | ✅ |
83
+
84
+ WorkBuddy 的授权框由它自己的「安全中心」弹出、不经过任何 hook,所以做不了;Codex 的 hook 会先卡住授权框(等 hook 返回才弹),所以只能走 app-server。
62
85
 
63
86
  ---
64
87
 
65
88
  ## 3. 验证
66
89
 
67
90
  ```bash
68
- npx deskbuddy status
91
+ npx deskbuddy@beta status
69
92
  ```
70
93
 
71
- 应该看到:账号已授权、已启用的工具都显示「hook 已装」、hook 版本与当前包一致。
94
+ 应该看到:第二行是**测试环境**、账号已授权、已启用的工具都显示「hook 已装」、hook 版本与当前包一致。
72
95
  让智能体随便跑个任务,再看一次 `status`,「当前本地会话」里就会出现内容。
73
96
 
74
- ---
97
+ 守护进程(远程指令、设备批准都靠它):
75
98
 
76
- ## 国内用户
99
+ ```bash
100
+ tail -5 ~/.deskbuddy/daemon.log # 应有「已连上」;用 Codex 常驻 app-server 的还应有「已连上 Codex app-server」
101
+ launchctl list | grep deskbuddy # macOS:有一行 com.deskbuddy.daemon 且第一列是进程号
102
+ # Windows(PowerShell):Get-Content ~\.deskbuddy\daemon.log -Tail 5;开机自启看「启动」文件夹里有没有 DeskBuddyDaemon.vbs
103
+ ```
77
104
 
78
- 本机 npm 若走淘宝镜像,装最新版可能有几分钟同步延迟:
105
+ ---
106
+
107
+ ## 4. 升级
79
108
 
80
109
  ```bash
81
- npx --registry=https://registry.npmmirror.com deskbuddy setup
110
+ npx deskbuddy@<新版本号> update
82
111
  ```
83
112
 
84
- 装不到最新版就临时改用官方源:
113
+ 按本机已启用的工具把 hook 重装一遍、改写开机自启并把守护进程重启到新版本。**不用重新扫码**。
114
+
115
+ - 版本号用 `npm view deskbuddy dist-tags` 查;写死版本号比写 `@beta` 稳:npx 有缓存,`@beta` 可能还拿到旧版
116
+ - 只跑任意一个命令(如 `status`)也会把 `~/.deskbuddy/` 里的脚本同步到当前版本,但**不会**补装新 hook、也不会换掉守护进程——所以升级一律用 `update`
117
+
118
+ ---
119
+
120
+ ## 国内用户
121
+
122
+ 本机 npm 若走淘宝镜像,新版本可能有几分钟同步延迟,装不到就临时改用官方源:
85
123
 
86
124
  ```bash
87
- npx --registry=https://registry.npmjs.org deskbuddy setup
125
+ npx --registry=https://registry.npmjs.org deskbuddy@beta setup --api-base https://test-api.shifengintelli.com
88
126
  ```
89
127
 
90
128
  ---
@@ -93,38 +131,44 @@ npx --registry=https://registry.npmjs.org deskbuddy setup
93
131
 
94
132
  | 文件 | 改动方式 |
95
133
  |---|---|
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`,不会被放宽)。
134
+ | `~/.claude/settings.json` | **幂等合并**上报 hook 与窗口授权 hook(你已有的 hook 一条都不动) |
135
+ | `~/.codex/config.toml` | 在文件**末尾追加**一段带 `# >>> deskbuddy >>>` / `# <<< deskbuddy <<<` 标记的块;其余字节一个都不碰 |
136
+ | `~/.workbuddy/settings.json`、`~/.qoder-cn/settings.json` | 同 Claude Code,幂等合并 |
137
+ | `~/.config/opencode/plugin/deskbuddy.js` | 新建一个几行的插件入口(同名文件不是我们生成的就拒绝覆盖) |
138
+ | `~/Library/LaunchAgents/com.deskbuddy.daemon.plist`(macOS)/ systemd 用户单元(Linux)/ 「启动」文件夹里的 `DeskBuddyDaemon.vbs`(Windows,不需要管理员权限) | 守护进程开机自启 |
139
+ | `~/.deskbuddy/` | 我们自己的目录:授权配置、hook 与授权关卡脚本、opencode 插件本体、每个会话的状态、待批准记录、守护进程日志 |
101
140
 
102
- **不碰** `~/.claude/fleet/`(那是用户自己的 fleet 系统)。
141
+ 写用户配置文件时都是**原子写并保留原权限位**(`config.toml` 通常是 `0600`,不会被放宽)。
103
142
 
104
143
  ---
105
144
 
106
145
  ## 卸载
107
146
 
108
147
  ```bash
109
- npx deskbuddy disable # 移除全部 hook,保留授权与 clientId,随时可再 enable
110
- npx deskbuddy logout # 移除 hook + 删除整个 ~/.deskbuddy(要确认,--yes 跳过)
148
+ npx deskbuddy@beta disable codex # 只移除某个工具
149
+ npx deskbuddy@beta disable # 移除全部 hook 与开机自启,保留授权,随时可再 enable
150
+ npx deskbuddy@beta logout # 移除 hook 与自启 + 删除整个 ~/.deskbuddy(要确认,--yes 跳过)
111
151
  ```
112
152
 
113
- `disable` 只摘掉我们自己那一条:Claude Code 侧按 hook 路径匹配,Codex 侧只删两个标记之间的行。
114
- 用户自己的 hook 原样保留。
153
+ `disable` 只摘掉我们自己那一条:JSON 配置按 hook 路径匹配,Codex 只删两个标记之间的行,opencode 只删带我们标记的入口文件。
154
+ 用户自己的 hook / 插件原样保留。
115
155
 
116
156
  ---
117
157
 
118
158
  ## 隐私边界
119
159
 
120
- **上报**:工具名、状态(进行中/待确认/已完成/失败)、会话标题、**项目目录名**(不是全路径)、时间戳。
160
+ **状态上报**:工具名、状态(进行中 / 待确认 / 已完成)、会话标题、**项目目录名**(不是全路径)、窗口是否开着、时间戳。
161
+
162
+ **等设备批准时**:要执行的命令或要改的文件(压成一行、截断到 200 字)会发到服务端、显示在设备卡片上——不然你没法判断该不该允许。
163
+
164
+ **最近工作目录**(给设备「选目录新建任务」用):只报目录名,全路径留在本机;目录用本机密钥做 HMAC 得到的编号来引用。
121
165
 
122
- **不上报**:prompt 原文、Bash 命令、文件完整路径、代码片段。
166
+ **不上报**:prompt 原文、智能体的回复、代码片段、文件内容。
123
167
 
124
168
  会话标题的来源按顺序回落,都不涉及 prompt 内容:
125
169
 
126
- 1. Claude Code 自己写在 transcript 里的 `aiTitle`(只读文件尾部 64KB,不遍历整个文件)
127
- 2. Codex 的 `session_index.jsonl` 里的 `thread_name`(多数用户是空的,属于机会主义的一级)
170
+ 1. Claude Code 自己写在 transcript 里的 `aiTitle`(只读文件尾部 64KB)
171
+ 2. Codex 的 `session_index.jsonl` 里的 `thread_name`(多数用户是空的)
128
172
  3. 当前 git 分支名(读 `.git/HEAD`,向上找 5 层)
129
173
  4. 项目目录名
130
174
 
@@ -133,7 +177,8 @@ npx deskbuddy logout # 移除 hook + 删除整个 ~/.deskbuddy(要
133
177
  客户端**不持有任何用户 token**:鉴权靠服务端的 clientId ↔ 用户绑定关系,绑定在 App 扫码时建立。
134
178
  源码里那个 `appSecret` 是随包公开的常量,不是密钥。
135
179
 
136
- 随时用 `npx deskbuddy status` 查看当前采集到了什么 —— 它最后固定会打印上面这两行采集范围。
180
+ **远程任务的安全边界**:设备下发的文字永远作为一个独立参数交给智能体,不拼进 shell;
181
+ 远程任务里除只读操作和工作目录内改文件外,每一步都要在设备上批准;授权关卡出任何错都按「拒绝」处理。
137
182
 
138
183
  ---
139
184
 
@@ -141,12 +186,15 @@ npx deskbuddy logout # 移除 hook + 删除整个 ~/.deskbuddy(要
141
186
 
142
187
  | 现象 | 先查什么 |
143
188
  |---|---|
144
- | 屏幕上没数据 | `npx deskbuddy status`:先看「已启用」里是不是「hook 已装」 |
189
+ | 屏幕上没数据 | `status`:「已启用」里是不是「hook 已装」;智能体窗口是不是装完之后才打开的 |
145
190
  | 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
- | 会话关了还挂在屏幕上 | 上报靠进程存活探测,通常几分钟内自己消失 |
191
+ | 设备下指令没反应 / 批准卡片不出现 | `tail ~/.deskbuddy/daemon.log` 看有没有「已连上」;没有就 `npx deskbuddy@<版本> update` 重启守护进程 |
192
+ | 下指令报「这个任务正在电脑上打开」 | 正常:窗口开着时不能远程续同一个会话(两边会互相吞对话)。在电脑上继续,或在设备上新建任务 |
193
+ | Codex 窗口授权框没同步到设备 | 是不是经 `--remote` 连常驻 app-server 用的;`daemon.log` 里有没有「已连上 Codex app-server」 |
194
+ | opencode 远程任务每一步都失败 | 插件不在:`ls ~/.config/opencode/plugin/deskbuddy.js`,没有就 `enable opencode` |
195
+ | 提示「还没有授权」 | 先跑 `setup` |
196
+ | 刚扫完提示码失效 | 偶发一次是正常的,会自动重试;**连续失败多半是这台电脑已绑定其他账号**,先 `logout` 再重新 `setup` |
197
+ | `status` 第二行显示生产环境 | `setup` 时漏了 `--api-base`:`logout` 后带上它重新 `setup` |
198
+ | 会话关了还挂在屏幕上 | 上报靠进程存活探测,通常 1~几分钟内自己消失 |
151
199
 
152
200
  联调用环境变量(正常用户用不到):`DESKBUDDY_API_BASE`、`DESKBUDDY_HOME`、`DESKBUDDY_APPID`、`DESKBUDDY_APPSECRET`。