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/README.md +32 -32
- package/dist/cli.js +3354 -424
- package/dist/gate.cjs +38 -0
- package/dist/hook.cjs +279 -6
- package/docs/INSTALL.md +95 -47
- package/hook/gate.cjs +38 -0
- package/hook/hook.cjs +279 -6
- package/package.json +5 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
**扫码这一步你(AI 助手)替代不了**,要告诉用户:
|
|
33
38
|
|
|
34
39
|
> 用 DeskBuddy App 扫描终端里的二维码;扫不出来就在 App 里手动输入那行授权码。
|
|
35
40
|
|
|
36
41
|
- 授权码 5 分钟内有效,过期就重新跑一次 `setup`
|
|
37
42
|
- 终端字符画受字体/行高/深浅色主题影响,有些终端**扫不出来**,这时用明文码手输,不是故障
|
|
38
|
-
-
|
|
43
|
+
- 🔴 **不带 `--api-base` 会连到生产环境**,而 beta 的接口只在测试环境,授权会失败或后续功能报错
|
|
39
44
|
|
|
40
45
|
**判定成功**:命令退出码为 0 且打印了 `✅ 授权成功`。
|
|
41
46
|
|
|
42
47
|
---
|
|
43
48
|
|
|
44
|
-
## 2.
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
1. **要在 Codex 里确认信任 hook**:Codex 对 hook 定义做 sha256 校验,首次启动会要求确认,不确认就不执行。
|
|
66
|
+
现象是「装成功了但屏幕上一直没数据」,极容易被当成安装失败。告诉用户:
|
|
53
67
|
|
|
54
|
-
Codex
|
|
55
|
-
现象是「装成功了但屏幕上一直没数据」,极容易被当成安装失败。
|
|
68
|
+
> 下次启动 Codex 时会弹出确认信任 hook 的提示,请选择同意。
|
|
56
69
|
|
|
57
|
-
|
|
70
|
+
**不要**用 `--dangerously-bypass-hook-trust` 替用户绕过这个确认。
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
2. **窗口授权框同步到设备有前提**:只有 Codex 经 `codex --remote ws://127.0.0.1:<端口>` 连着一个常驻 `codex app-server` 使用时才生效
|
|
73
|
+
(守护进程作为第二个客户端连上去)。直接运行 `codex` 时,设备上只显示「待确认」,授权框仍在电脑上点。
|
|
60
74
|
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 4. 升级
|
|
79
108
|
|
|
80
109
|
```bash
|
|
81
|
-
npx
|
|
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` |
|
|
97
|
-
| `~/.codex/config.toml` | 在文件**末尾追加**一段带 `# >>> deskbuddy >>>` / `# <<< deskbuddy <<<`
|
|
98
|
-
| `~/.
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
141
|
+
写用户配置文件时都是**原子写并保留原权限位**(`config.toml` 通常是 `0600`,不会被放宽)。
|
|
103
142
|
|
|
104
143
|
---
|
|
105
144
|
|
|
106
145
|
## 卸载
|
|
107
146
|
|
|
108
147
|
```bash
|
|
109
|
-
npx deskbuddy disable
|
|
110
|
-
npx deskbuddy
|
|
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` 只摘掉我们自己那一条:
|
|
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
|
|
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
|
-
|
|
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
|
-
| 屏幕上没数据 | `
|
|
189
|
+
| 屏幕上没数据 | `status`:「已启用」里是不是「hook 已装」;智能体窗口是不是装完之后才打开的 |
|
|
145
190
|
| Codex 装了但没数据 | **十有八九是没在 Codex 里确认信任 hook**,重启 Codex 看有没有确认提示 |
|
|
146
|
-
|
|
|
147
|
-
|
|
|
148
|
-
|
|
|
149
|
-
|
|
|
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`。
|