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/hook/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/hook/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deskbuddy",
3
- "version": "0.2.0-beta.0",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "把 Claude Code、Codex 等 AI 编码智能体每个会话窗口的状态,实时同步到桌面硬件屏幕",
5
5
  "keywords": [
6
6
  "ai",
@@ -46,5 +46,9 @@
46
46
  "dependencies": {
47
47
  "qrcode-terminal": "^0.12.0",
48
48
  "ws": "^8.21.3"
49
+ },
50
+ "optionalDependencies": {
51
+ "@xterm/headless": "^6.0.0",
52
+ "node-pty": "1.1.0"
49
53
  }
50
54
  }