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/dist/hook.cjs ADDED
@@ -0,0 +1,922 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+ // 🔴 这个文件在用户【每次工具调用】时都会被执行一次。三条铁律:
4
+ // ① 只 require Node 内置模块 —— 它不进 bundler,构建时原样 cp 成 dist/hook.cjs,
5
+ // 再由 deskbuddy enable 复制到 ~/.deskbuddy/hook.cjs。
6
+ // 扩展名必须是 .cjs:本包是 "type": "module",叫 .js 会被当成 ESM 直接崩。
7
+ // require 任何 npm 包都会拖慢用户的 agent。
8
+ // ② 永不阻塞 —— 上报 2 秒超时、fire-and-forget,失败就丢,下次跃迁自然补上。
9
+ // ③ 任何异常都吞掉并 exit(0) —— hook 报错会直接打断用户的 AI 会话。
10
+ //
11
+ // ⚠️ 本文件与 src/core/{config,state,process,snapshot,registry,title}.ts 是【同一套逻辑的两份实现】:
12
+ // 那边给 CLI 用(可以有依赖),这边给 hook 用(必须零依赖)。改一边记得改另一边,
13
+ // src/hook/hook.test.ts 里有对齐用例会挡住漂移。
14
+ const fs = require('node:fs')
15
+ const os = require('node:os')
16
+ const path = require('node:path')
17
+ const http = require('node:http')
18
+ const https = require('node:https')
19
+ const crypto = require('node:crypto')
20
+ const { execFileSync } = require('node:child_process')
21
+
22
+ const DEFAULT_API_BASE = process.env.DESKBUDDY_API_BASE || 'https://api.shifengintelli.com'
23
+ const KEEPALIVE_MS = 2 * 60 * 1000
24
+ const MAX_SESSIONS = 20
25
+ /** 状态文件里保留的最近事件条数,与 src/core/state.ts 的 RECENT_MAX 一致 */
26
+ const RECENT_MAX = 8
27
+ const REPORT_TIMEOUT_MS = 2000
28
+ /** ServiceApi 版本,不是包路径里的 v2 */
29
+ const GATEWAY_VERSION = '1.0'
30
+ const APP_ID = process.env.DESKBUDDY_APPID || 'deskbuddy-cli'
31
+ /**
32
+ * ⚠️ 【公开常量,不是密钥】。客户端是 npm 公开发布的,任何人解包就能看到它。
33
+ * jumpSign=false 下签名的全部作用只是换取 180 秒防重放窗口,外加让这个 appid 不跳过
34
+ * 框架的 userAuth 闸门。它保护不了任何东西 —— 真正的防线是服务端的 clientId ↔ userId
35
+ * 绑定关系,而那个绑定建立在 App 的已登录态上,不在 CLI 里。
36
+ */
37
+ const APP_SECRET = process.env.DESKBUDDY_APPSECRET || '3aeb6e0afc144e188dd3ede5c7865643'
38
+ /**
39
+ * 🔴 服务名 = 服务端 handler 的全限定类名小写(@ServiceApi.value() 为空时回落到类名小写)。
40
+ * 服务端类改名 / 挪包,这里会静默失效,表现是 NOT_SUPPORT。与 src/core/api.ts 里那份必须一致。
41
+ */
42
+ const SERVICES = {
43
+ authCode: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddyauthcodeapi',
44
+ authToken: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddyauthtokenapi',
45
+ sessionReport: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddysessionreportapi',
46
+ toolRegistry: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddytoolregistryapi',
47
+ confirmAuth: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddyconfirmauthapi',
48
+ }
49
+ /**
50
+ * 🔴 只读 transcript 尾部这么多字节,绝不遍历整个文件。
51
+ * 实测最大的 transcript 有 1.47 GB,全文件遍历会把用户的 agent 卡死;
52
+ * 而最后一条 ai-title 实测距文件尾只有 1–31 KB。
53
+ */
54
+ const TRANSCRIPT_TAIL_BYTES = 64 * 1024
55
+ /** 没配 titleSources 的工具按这条链处理 */
56
+ const DEFAULT_TITLE_SOURCES = ['git-branch', 'project']
57
+ /** cwd 不是仓库根时向上找 .git 的层数上限 */
58
+ const GIT_LOOKUP_MAX_UP = 5
59
+
60
+ /** 服务端拉不到、本地缓存也没有时的最小兜底:至少还能认出"在跑 / 结束",不至于整个瞎掉。 */
61
+ const FALLBACK_REGISTRY = {
62
+ version: 'builtin',
63
+ tools: {
64
+ claude_code: {
65
+ format: 'cc-hook',
66
+ processNames: ['claude'],
67
+ titleSources: ['cc-ai-title', 'git-branch', 'project'],
68
+ events: {
69
+ UserPromptSubmit: 'watch',
70
+ PreToolUse: 'watch',
71
+ PostToolUse: 'watch',
72
+ // 工具跑失败时 Claude Code 发这个而不是 PostToolUse;窗口待批准撤卡也要认它
73
+ PostToolUseFailure: 'watch',
74
+ Stop: 'done',
75
+ // 窗口里弹授权框的那一刻就是待确认(S10),不等之后那条权限通知
76
+ PermissionRequest: 'ask_approval',
77
+ },
78
+ lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
79
+ },
80
+ codex: {
81
+ format: 'codex-toml',
82
+ processNames: ['codex'],
83
+ // codex-thread-name 是机会主义的一级:session_index.jsonl 里的 thread_name
84
+ // 原生只有 /rename 手动设,多数用户是空的,取不到就往下回落
85
+ titleSources: ['codex-thread-name', 'git-branch', 'project'],
86
+ events: {
87
+ UserPromptSubmit: 'watch',
88
+ PreToolUse: 'watch',
89
+ PostToolUse: 'watch',
90
+ Stop: 'done',
91
+ PermissionRequest: 'ask_approval',
92
+ },
93
+ lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
94
+ },
95
+ workbuddy: {
96
+ format: 'cc-hook',
97
+ processNames: ['WorkBuddy'],
98
+ titleSources: ['git-branch', 'project'],
99
+ events: {
100
+ UserPromptSubmit: 'watch',
101
+ PreToolUse: 'watch',
102
+ PostToolUse: 'watch',
103
+ Stop: 'done',
104
+ PermissionRequest: 'ask_approval',
105
+ },
106
+ lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
107
+ },
108
+ qoder: {
109
+ format: 'cc-hook',
110
+ processNames: ['Qoder CN'],
111
+ titleSources: ['git-branch', 'project'],
112
+ events: {
113
+ UserPromptSubmit: 'watch',
114
+ PreToolUse: 'watch',
115
+ PostToolUse: 'watch',
116
+ Stop: 'done',
117
+ PermissionRequest: 'ask_approval',
118
+ },
119
+ lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
120
+ },
121
+ opencode: {
122
+ format: 'oc-plugin',
123
+ processNames: ['opencode'],
124
+ // opencode 自带的会话标题是模型对提问的摘要,违反隐私约束(spec §6.4),不用
125
+ titleSources: ['git-branch', 'project'],
126
+ events: {
127
+ UserPromptSubmit: 'watch',
128
+ PreToolUse: 'watch',
129
+ PostToolUse: 'watch',
130
+ Stop: 'done',
131
+ PermissionRequest: 'ask_approval',
132
+ },
133
+ lifecycle: { SessionEnd: 'remove' },
134
+ },
135
+ },
136
+ }
137
+
138
+ // ---------- 配置 ----------
139
+
140
+ function deskbuddyHome() {
141
+ return process.env.DESKBUDDY_HOME || path.join(os.homedir(), '.deskbuddy')
142
+ }
143
+
144
+ function loadConfig() {
145
+ const fallback = { clientId: '', hostName: os.hostname(), enabledTools: [], apiBase: DEFAULT_API_BASE }
146
+ try {
147
+ const parsed = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'config.json'), 'utf8'))
148
+ return {
149
+ clientId: parsed.clientId || fallback.clientId,
150
+ hostName: parsed.hostName || fallback.hostName,
151
+ enabledTools: Array.isArray(parsed.enabledTools) ? parsed.enabledTools : [],
152
+ apiBase: process.env.DESKBUDDY_API_BASE || parsed.apiBase || DEFAULT_API_BASE,
153
+ }
154
+ } catch (e) {
155
+ return fallback
156
+ }
157
+ }
158
+
159
+ // ---------- 映射表 ----------
160
+
161
+ /** hook 每次执行都会调用,只读本地缓存 —— 绝不能在 hook 里发网络请求拉配置。 */
162
+ function loadRegistry() {
163
+ try {
164
+ return JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'tool-registry.json'), 'utf8'))
165
+ } catch (e) {
166
+ return FALLBACK_REGISTRY
167
+ }
168
+ }
169
+
170
+ function mapEvent(registry, tool, event, payload) {
171
+ const t = registry && registry.tools ? registry.tools[tool] : null
172
+ if (!t) return 'ignore'
173
+ const lifecycle = t.lifecycle ? t.lifecycle[event] : undefined
174
+ if (lifecycle) return lifecycle // ignore / remove
175
+ const conds = t.conditionalEvents ? t.conditionalEvents[event] : undefined
176
+ if (Array.isArray(conds)) {
177
+ // 🔴 Claude Code 的 Notification 有两类:权限确认 → ask_approval;
178
+ // 闲置等待输入 → done。把闲置也报成待确认,屏上会全是"待确认",提醒直接失效。
179
+ const text = String((payload && (payload.message ?? payload.notification_type)) ?? '')
180
+ for (const c of conds) {
181
+ try {
182
+ if (new RegExp(c.match, 'i').test(text)) return c.state
183
+ } catch (e) {
184
+ /* 映射表里写坏的正则不能连累整个 hook */
185
+ }
186
+ }
187
+ }
188
+ return (t.events && t.events[event]) || 'ignore'
189
+ }
190
+
191
+ function agentNames(registry, tool) {
192
+ const t = registry && registry.tools ? registry.tools[tool] : null
193
+ if (t && Array.isArray(t.processNames) && t.processNames.length > 0) return t.processNames
194
+ const f = FALLBACK_REGISTRY.tools[tool]
195
+ return f ? f.processNames : []
196
+ }
197
+
198
+ /** 会话标题回落链。顺序由服务端映射表决定,客户端不写死。 */
199
+ function titleSources(registry, tool) {
200
+ const t = registry && registry.tools ? registry.tools[tool] : null
201
+ if (t && Array.isArray(t.titleSources) && t.titleSources.length > 0) return t.titleSources
202
+ const f = FALLBACK_REGISTRY.tools[tool]
203
+ return f && f.titleSources && f.titleSources.length > 0 ? f.titleSources : DEFAULT_TITLE_SOURCES
204
+ }
205
+
206
+ // ---------- 会话标题 ----------
207
+
208
+ function codexHome() {
209
+ return process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
210
+ }
211
+
212
+ /** 读文件尾部 N 字节并按行切开。读不了就返回空数组,绝不抛。 */
213
+ function readTailLines(file, bytes) {
214
+ let fd
215
+ try {
216
+ const size = fs.statSync(file).size
217
+ if (size <= 0) return []
218
+ const len = Math.min(size, bytes)
219
+ const buf = Buffer.alloc(len)
220
+ fd = fs.openSync(file, 'r')
221
+ fs.readSync(fd, buf, 0, len, size - len)
222
+ return buf.toString('utf8').split('\n')
223
+ } catch (e) {
224
+ return []
225
+ } finally {
226
+ if (fd !== undefined) {
227
+ try {
228
+ fs.closeSync(fd)
229
+ } catch (e) {
230
+ /* ignore */
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ /** Claude Code 自己写进 transcript 的会话标题({"type":"ai-title","aiTitle":"…"})。 */
237
+ function readAiTitle(transcriptPath) {
238
+ if (!transcriptPath) return null
239
+ const lines = readTailLines(transcriptPath, TRANSCRIPT_TAIL_BYTES)
240
+ // 从后往前找第一条 ai-title
241
+ for (let i = lines.length - 1; i >= 0; i--) {
242
+ const line = lines[i]
243
+ if (line.indexOf('"ai-title"') === -1) continue
244
+ try {
245
+ const obj = JSON.parse(line)
246
+ if (obj && obj.type === 'ai-title') {
247
+ const title = String(obj.aiTitle == null ? '' : obj.aiTitle).trim()
248
+ if (title !== '') return title
249
+ }
250
+ } catch (e) {
251
+ /* 尾部窗口切出来的半行,跳过 */
252
+ }
253
+ }
254
+ return null
255
+ }
256
+
257
+ /**
258
+ * Codex 的会话名:~/.codex/session_index.jsonl 里 {"id":…,"thread_name":…}。
259
+ * ⚠️ 机会主义的一级——原生只有 /rename 手动设,普通用户这里多半是空的。
260
+ */
261
+ function readCodexThreadName(sessionId, home) {
262
+ if (!sessionId) return null
263
+ const indexPath = path.join(home || codexHome(), 'session_index.jsonl')
264
+ const lines = readTailLines(indexPath, TRANSCRIPT_TAIL_BYTES)
265
+ // 从后往前找:同一个会话可能被改过多次名,最后一条才是最新的
266
+ for (let i = lines.length - 1; i >= 0; i--) {
267
+ const line = lines[i]
268
+ if (line.indexOf(sessionId) === -1) continue
269
+ try {
270
+ const obj = JSON.parse(line)
271
+ if (obj && obj.id === sessionId) {
272
+ const name = String(obj.thread_name == null ? '' : obj.thread_name).trim()
273
+ if (name !== '') return name
274
+ }
275
+ } catch (e) {
276
+ /* 尾部窗口切出来的半行,跳过 */
277
+ }
278
+ }
279
+ return null
280
+ }
281
+
282
+ /** 读一个目录下的 .git/HEAD 内容。不是仓库返回 null(worktree 时跟一跳)。 */
283
+ function readGitHead(dir) {
284
+ try {
285
+ const dotGit = path.join(dir, '.git')
286
+ let headPath = path.join(dotGit, 'HEAD')
287
+ if (!fs.statSync(dotGit).isDirectory()) {
288
+ // worktree / submodule:.git 是个文件,内容是 "gitdir: <路径>"
289
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'))
290
+ if (!m) return null
291
+ const gitdir = m[1].trim()
292
+ headPath = path.join(path.isAbsolute(gitdir) ? gitdir : path.join(dir, gitdir), 'HEAD')
293
+ }
294
+ return fs.readFileSync(headPath, 'utf8').trim()
295
+ } catch (e) {
296
+ return null
297
+ }
298
+ }
299
+
300
+ /** 当前分支名。cwd 可能是仓库子目录,向上找最多 GIT_LOOKUP_MAX_UP 层;detached HEAD 返回 null。 */
301
+ function readGitBranch(cwd, maxUp) {
302
+ if (!cwd) return null
303
+ const limit = maxUp === undefined ? GIT_LOOKUP_MAX_UP : maxUp
304
+ let dir = cwd
305
+ for (let up = 0; up <= limit; up++) {
306
+ const head = readGitHead(dir)
307
+ if (head !== null) {
308
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
309
+ if (!ref) return null // detached HEAD 是一串裸 sha,没必要上屏
310
+ return ref[1].trim() || null
311
+ }
312
+ const parent = path.dirname(dir)
313
+ if (parent === dir) break // 到根了
314
+ dir = parent
315
+ }
316
+ return null
317
+ }
318
+
319
+ /** 按 registry 给的顺序逐级回落。所有来源都不碰 prompt 原文。 */
320
+ function resolveTitle(sources, ctx) {
321
+ const list = sources || []
322
+ for (const source of list) {
323
+ let value = null
324
+ if (source === 'cc-ai-title') value = readAiTitle(ctx.transcriptPath)
325
+ else if (source === 'codex-thread-name') value = readCodexThreadName(ctx.sessionId, ctx.codexHome)
326
+ else if (source === 'git-branch') value = readGitBranch(ctx.cwd)
327
+ else if (source === 'project') value = path.basename(ctx.cwd || '') || null
328
+ // 不认识的来源直接跳过:新来源应该先发版再进 registry
329
+ if (value) return value
330
+ }
331
+ return ''
332
+ }
333
+
334
+ // ---------- 进程探测 ----------
335
+
336
+ function processName(pid) {
337
+ try {
338
+ // macOS / Linux 通用。Windows 需要 tasklist,首期不支持
339
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
340
+ encoding: 'utf8',
341
+ stdio: ['ignore', 'pipe', 'ignore'],
342
+ })
343
+ const name = out.trim()
344
+ return name === '' ? null : name
345
+ } catch (e) {
346
+ return null
347
+ }
348
+ }
349
+
350
+ function parentPid(pid) {
351
+ try {
352
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'ppid='], {
353
+ encoding: 'utf8',
354
+ stdio: ['ignore', 'pipe', 'ignore'],
355
+ })
356
+ const ppid = Number(out.trim())
357
+ return Number.isFinite(ppid) && ppid > 1 ? ppid : null
358
+ } catch (e) {
359
+ return null
360
+ }
361
+ }
362
+
363
+ /** 🔴 expectName 防 pid 复用:只看 pid 存在,会把已关闭的窗口误判成还活着。 */
364
+ function isAlive(pid, expectName) {
365
+ if (!Number.isFinite(pid) || pid <= 0) return false
366
+ try {
367
+ process.kill(pid, 0)
368
+ } catch (e) {
369
+ return false
370
+ }
371
+ if (!expectName) return true
372
+ const name = processName(pid)
373
+ return name != null && name.toLowerCase().includes(String(expectName).toLowerCase())
374
+ }
375
+
376
+ /** hook 的直接父进程可能是 shell,agent 在更上层,所以要逐级往上找。 */
377
+ function resolveAgentPid(startPid, names, maxDepth) {
378
+ const wanted = (names || []).map((n) => String(n).toLowerCase()).filter((n) => n !== '')
379
+ if (wanted.length === 0) return null
380
+ const depthLimit = maxDepth || 8
381
+ let pid = startPid
382
+ for (let depth = 0; depth < depthLimit && pid != null; depth++) {
383
+ const name = processName(pid)
384
+ if (name && wanted.some((w) => name.toLowerCase().includes(w))) return pid
385
+ pid = parentPid(pid)
386
+ }
387
+ return null
388
+ }
389
+
390
+ // ---------- 会话状态 ----------
391
+
392
+ function stateDir() {
393
+ return path.join(deskbuddyHome(), 'state')
394
+ }
395
+
396
+ function statePath(sessionId) {
397
+ // sessionId 来自 agent,是不可信输入,清洗掉路径穿越字符
398
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '')
399
+ return path.join(stateDir(), (safe || 'unknown') + '.json')
400
+ }
401
+
402
+ function readState(sessionId) {
403
+ try {
404
+ return JSON.parse(fs.readFileSync(statePath(sessionId), 'utf8'))
405
+ } catch (e) {
406
+ return null
407
+ }
408
+ }
409
+
410
+ /** 原子写:先写 .tmp 再 rename,避免并发的多个 hook 读到写了一半的 JSON。 */
411
+ function writeState(s) {
412
+ fs.mkdirSync(stateDir(), { recursive: true })
413
+ const tmp = statePath(s.sessionId) + '.tmp'
414
+ fs.writeFileSync(tmp, JSON.stringify(s), 'utf8')
415
+ fs.renameSync(tmp, statePath(s.sessionId))
416
+ }
417
+
418
+ function removeState(sessionId) {
419
+ try {
420
+ fs.rmSync(statePath(sessionId), { force: true })
421
+ } catch (e) {
422
+ /* 删不掉就算了,下次还会尝试 */
423
+ }
424
+ }
425
+
426
+ /**
427
+ * 读取某工具下所有【进程还活着】的会话,顺手清理已死的。
428
+ * SessionEnd 极不可靠(用户叉掉窗口时根本不触发),进程存活才是唯一硬信号。
429
+ */
430
+ function readAliveStates(tool) {
431
+ let files
432
+ try {
433
+ files = fs.readdirSync(stateDir())
434
+ } catch (e) {
435
+ return []
436
+ }
437
+ const alive = []
438
+ for (const file of files) {
439
+ if (!file.endsWith('.json')) continue
440
+ let s
441
+ try {
442
+ s = JSON.parse(fs.readFileSync(path.join(stateDir(), file), 'utf8'))
443
+ } catch (e) {
444
+ try {
445
+ fs.rmSync(path.join(stateDir(), file), { force: true }) // 脏文件直接清掉
446
+ } catch (e2) {
447
+ /* ignore */
448
+ }
449
+ continue
450
+ }
451
+ if (!s || s.tool !== tool) continue
452
+ if (s.pid && !isAlive(s.pid, s.pidName)) {
453
+ removeState(s.sessionId) // 窗口已关:剔除并自我清理,state 目录不再无限增长
454
+ continue
455
+ }
456
+ alive.push(s)
457
+ }
458
+ return alive
459
+ }
460
+
461
+ // ---------- 节流与快照 ----------
462
+
463
+ function shouldReport(prev, next, nowMs) {
464
+ if (!prev) return true
465
+ if (prev.state !== next.state) return true
466
+ // 保活:状态没跃迁也要定期报一次,否则服务端会把这台机器判成失联
467
+ return nowMs - (prev.lastReportAt || 0) > KEEPALIVE_MS
468
+ }
469
+
470
+ function buildSnapshot(states, tool, cfg, nowMs) {
471
+ const sessions = states
472
+ .filter((s) => s.tool === tool) // 替换粒度是 (clientId, tool),混进别的工具会冲掉对方
473
+ .sort((a, b) => b.updatedAt - a.updatedAt)
474
+ .slice(0, MAX_SESSIONS)
475
+ .map((s) => ({
476
+ sessionId: s.sessionId,
477
+ title: s.title,
478
+ // 隐私:只带目录名,不带全路径——全路径会暴露公司目录结构与用户名
479
+ project: path.basename(s.project || ''),
480
+ state: s.state,
481
+ startedAt: s.startedAt,
482
+ updatedAt: s.updatedAt,
483
+ interactive: s.interactive === true,
484
+ }))
485
+ return { clientId: cfg.clientId, hostName: cfg.hostName, tool: tool, snapshotAt: nowMs, sessions: sessions }
486
+ }
487
+
488
+ // ---------- 上报 ----------
489
+
490
+ /**
491
+ * 服务端 HelloApi.java:152:
492
+ * source = `${appid}_${appSecret}_${timestamp}_${deviceId}_${deviceName}_${nonce}_${body}`
493
+ * signature = md5Hex(source)
494
+ * 七段用 `_` 连接,顺序不能变。
495
+ */
496
+ function sign(p) {
497
+ const source = [p.appId, p.appSecret, p.timestamp, p.deviceId, p.deviceName, p.nonce, p.body].join('_')
498
+ return crypto.createHash('md5').update(source, 'utf8').digest('hex')
499
+ }
500
+
501
+ /**
502
+ * 网关 body。返回的就是要发出去的那个字符串 —— 拿它算签名,再原样发。
503
+ * 🔴 没有 group 字段;data 是【字符串】不是对象;base.encode 必须显式 "false",
504
+ * 否则服务端会拿 data 去 AES 解密,直接 DATA_DECODE_FAIL。
505
+ */
506
+ function buildGatewayBody(service, data, requestId) {
507
+ return JSON.stringify({
508
+ service: service,
509
+ version: GATEWAY_VERSION,
510
+ requestId: requestId || crypto.randomUUID(),
511
+ base: { encode: 'false' },
512
+ data: JSON.stringify(data == null ? {} : data),
513
+ })
514
+ }
515
+
516
+ /**
517
+ * 六个必填头(/gateway 用 @RequestHeader 声明且 required 默认 true,【缺一个就是 400】)。
518
+ * 🔴 签名用的必须是传进来的这个 body 字符串本身,绝不能再 stringify 一次 —— 键序可能不同。
519
+ * 没有 ShiFeng-Token:鉴权靠 clientId ↔ userId 绑定关系。
520
+ */
521
+ function buildHeaders(cfg, body, opts) {
522
+ const o = opts || {}
523
+ const timestamp = o.timestamp || String(Date.now())
524
+ const nonce = o.nonce || crypto.randomBytes(8).toString('hex')
525
+ return {
526
+ 'Content-Type': 'application/json;charset=UTF-8',
527
+ 'Content-Length': String(Buffer.byteLength(body)),
528
+ 'ShiFeng-AppId': APP_ID,
529
+ 'ShiFeng-Timestamp': timestamp,
530
+ 'ShiFeng-Device-ID': cfg.clientId,
531
+ 'ShiFeng-Device-Name': cfg.hostName,
532
+ 'ShiFeng-Nonce': nonce,
533
+ 'ShiFeng-Signature': sign({
534
+ appId: APP_ID,
535
+ appSecret: APP_SECRET,
536
+ timestamp: timestamp,
537
+ deviceId: cfg.clientId,
538
+ deviceName: cfg.hostName,
539
+ nonce: nonce,
540
+ body: body,
541
+ }),
542
+ }
543
+ }
544
+
545
+ /** fire-and-forget:2 秒超时,失败静默。绝不能因为网络问题卡住用户的 agent。 */
546
+ function postReport(cfg, payload) {
547
+ return new Promise((resolve) => {
548
+ let settled = false
549
+ const done = () => {
550
+ if (!settled) {
551
+ settled = true
552
+ resolve()
553
+ }
554
+ }
555
+ try {
556
+ const url = new URL('/gateway', cfg.apiBase)
557
+ const body = buildGatewayBody(SERVICES.sessionReport, payload)
558
+ const mod = url.protocol === 'http:' ? http : https
559
+ const req = mod.request(
560
+ {
561
+ protocol: url.protocol,
562
+ hostname: url.hostname,
563
+ port: url.port || undefined,
564
+ path: url.pathname,
565
+ method: 'POST',
566
+ headers: buildHeaders(cfg, body),
567
+ },
568
+ (res) => {
569
+ res.resume()
570
+ res.on('end', done)
571
+ res.on('error', done)
572
+ }
573
+ )
574
+ req.setTimeout(REPORT_TIMEOUT_MS, () => {
575
+ req.destroy()
576
+ done()
577
+ })
578
+ req.on('error', done)
579
+ req.write(body)
580
+ req.end()
581
+ } catch (e) {
582
+ done()
583
+ }
584
+ })
585
+ }
586
+
587
+ // ---------- 主流程 ----------
588
+
589
+ function readStdin() {
590
+ try {
591
+ return JSON.parse(fs.readFileSync(0, 'utf8') || '{}')
592
+ } catch (e) {
593
+ return {}
594
+ }
595
+ }
596
+
597
+ /**
598
+ * 组装并上报该工具当前的全量快照。没授权 / 没 enable 就什么都不做。
599
+ * readAliveStates 会顺手做进程探测并清掉已死的会话。
600
+ */
601
+ async function reportIfAuthorized(tool, nowMs) {
602
+ const cfg = loadConfig()
603
+ // clientId 为空说明还没 setup 过(服务端靠 clientId ↔ userId 绑定关系认人):
604
+ // 绝不能在这里现编一个,那等于凭空造出一台没人认领的机器
605
+ if (!cfg.clientId || cfg.enabledTools.indexOf(tool) === -1) return
606
+ await postReport(cfg, buildSnapshot(readAliveStates(tool), tool, cfg, nowMs))
607
+ }
608
+
609
+ /**
610
+ * 工具调用指纹前 16 位,与 src/daemon/gate/approvals.ts 的 fingerprint 同算法。
611
+ * daemon 靠它认出"跑完的正是设备上等着的那一步",并行的同名工具不会误撤卡(S10 评审 #2)
612
+ */
613
+ function toolFingerprint(toolName, toolInput) {
614
+ let input = ''
615
+ try {
616
+ input = JSON.stringify(toolInput === undefined ? null : toolInput)
617
+ } catch (e) {
618
+ input = String(toolInput)
619
+ }
620
+ return crypto.createHash('sha256').update(toolName).update('\0').update(input).digest('hex').slice(0, 16)
621
+ }
622
+
623
+ /**
624
+ * 这个智能体进程是不是【交互窗口】(二期 P1)。参数里有 -p / --print / stream-json / exec 的是
625
+ * 脚本、SDK 或 daemon 起的远程任务;桌面 App 与终端里的 TUI 是窗口。与 gate.cjs 的 isInteractiveClaude 同一判据。
626
+ * 🔴 判不出来按 false:false 只意味着"不挡远程指令",与改动前的行为一致
627
+ */
628
+ function isInteractiveArgs(args) {
629
+ const all = String(args || '').trim().split(/\s+/)
630
+ const tokens = all.slice(1)
631
+ if (tokens.length === 0) return true
632
+ // exec 只认 Codex 的子命令位置:`claude "exec the tests"` 这种带提示词启动的窗口不能误判(评审)
633
+ if (tokens[0] === 'exec' && /codex/i.test(path.basename(all[0]))) return false
634
+ // opencode 的非交互是 `opencode run …`(二期 P9);TUI 是 `opencode [目录]`
635
+ if (tokens[0] === 'run' && /opencode/i.test(path.basename(all[0]))) return false
636
+ return !tokens.some(
637
+ (t) =>
638
+ t === '--print' ||
639
+ t === 'stream-json' ||
640
+ t.startsWith('--output-format=stream') ||
641
+ /^-[a-zA-Z]*p[a-zA-Z]*$/.test(t)
642
+ )
643
+ }
644
+
645
+ /** 只看进程在不在(kill 0),不查进程名:每次事件都要调,ps 太贵 */
646
+ function pidExists(pid) {
647
+ try {
648
+ process.kill(pid, 0)
649
+ return true
650
+ } catch (e) {
651
+ return false
652
+ }
653
+ }
654
+
655
+ function detectInteractive(pid) {
656
+ if (!pid) return false
657
+ try {
658
+ const args = execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], {
659
+ encoding: 'utf8',
660
+ stdio: ['ignore', 'pipe', 'ignore'],
661
+ })
662
+ return args.trim() !== '' && isInteractiveArgs(args)
663
+ } catch (e) {
664
+ return false
665
+ }
666
+ }
667
+
668
+ // ---------- /clear 改绑(二期 P2) ----------
669
+
670
+ /** 与 src/core/state.ts 的 ALIAS_TTL_MS 一致 */
671
+ const ALIAS_TTL_MS = 7 * 24 * 60 * 60 * 1000
672
+ /** SessionEnd(clear) 到 SessionStart(clear) 之间允许的间隔 */
673
+ const CLEAR_PAIR_MS = 60 * 1000
674
+
675
+ function clearMarkPath(pid) {
676
+ return path.join(deskbuddyHome(), 'clear', String(pid) + '.json')
677
+ }
678
+
679
+ /** SessionEnd(reason=clear):按智能体进程号记下旧会话,等同一进程的 SessionStart(source=clear) 来配对 */
680
+ function markClear(pid, oldSessionId) {
681
+ try {
682
+ fs.mkdirSync(path.join(deskbuddyHome(), 'clear'), { recursive: true })
683
+ fs.writeFileSync(clearMarkPath(pid), JSON.stringify({ old: oldSessionId, at: Date.now() }), 'utf8')
684
+ } catch (e) {
685
+ /* 改绑是锦上添花,失败就退回"旧会话失效" */
686
+ }
687
+ }
688
+
689
+ /** SessionStart(source=clear):配对成功就写别名 旧 → 新 */
690
+ function pairClear(pid, newSessionId) {
691
+ let mark
692
+ try {
693
+ mark = JSON.parse(fs.readFileSync(clearMarkPath(pid), 'utf8'))
694
+ fs.unlinkSync(clearMarkPath(pid))
695
+ } catch (e) {
696
+ return
697
+ }
698
+ if (!mark || typeof mark.old !== 'string' || !mark.old || mark.old === newSessionId) return
699
+ if (!(Date.now() - mark.at < CLEAR_PAIR_MS)) return
700
+ const file = path.join(deskbuddyHome(), 'alias.json')
701
+ let map = {}
702
+ try {
703
+ map = JSON.parse(fs.readFileSync(file, 'utf8')) || {}
704
+ } catch (e) {
705
+ map = {}
706
+ }
707
+ const now = Date.now()
708
+ for (const k of Object.keys(map)) {
709
+ if (!(map[k] && now - map[k].at < ALIAS_TTL_MS)) delete map[k]
710
+ }
711
+ map[mark.old] = { to: newSessionId, at: now }
712
+ try {
713
+ fs.writeFileSync(file + '.tmp', JSON.stringify(map), 'utf8')
714
+ fs.renameSync(file + '.tmp', file)
715
+ } catch (e) {
716
+ /* 同上 */
717
+ }
718
+ }
719
+
720
+ /** 最近目录最多记多少条(二期 P6,与 src/core/dirs.ts 同构;上报只取前 10) */
721
+ const DIRS_KEEP = 30
722
+
723
+ /** UserPromptSubmit 时记下工作目录:{全路径: 最近使用毫秒}。🔴 全路径只留本机 */
724
+ function recordDir(cwd, now) {
725
+ if (!cwd) return
726
+ const file = path.join(deskbuddyHome(), 'dirs.json')
727
+ let map = {}
728
+ try {
729
+ map = JSON.parse(fs.readFileSync(file, 'utf8')) || {}
730
+ } catch (e) {
731
+ map = {}
732
+ }
733
+ map[cwd] = now
734
+ const kept = Object.entries(map)
735
+ .filter((e) => typeof e[1] === 'number')
736
+ .sort((a, b) => b[1] - a[1])
737
+ .slice(0, DIRS_KEEP)
738
+ try {
739
+ fs.mkdirSync(deskbuddyHome(), { recursive: true })
740
+ fs.writeFileSync(file + '.tmp', JSON.stringify(Object.fromEntries(kept)), 'utf8')
741
+ fs.renameSync(file + '.tmp', file)
742
+ } catch (e) {
743
+ /* 记不下只影响设备上的目录列表 */
744
+ }
745
+ }
746
+
747
+ /** 与 src/core/state.ts 的 NOTICE_TTL_MS 一致 */
748
+ const NOTICE_TTL_MS = 60 * 60 * 1000
749
+ const NOTICE_EVENTS = { PostToolUse: 1, PostToolUseFailure: 1, Stop: 1 }
750
+
751
+ /**
752
+ * 窗口回显(S10,见 src/core/state.ts 的 writeNotice):有就取出并删掉,返回要显示的文字。
753
+ * 先删再用:并发的两个 hook 最多一个拿到,不会重复显示
754
+ */
755
+ function takeNotice(sessionId) {
756
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '')
757
+ if (!safe) return ''
758
+ const file = path.join(deskbuddyHome(), 'notice', safe + '.json')
759
+ let raw
760
+ try {
761
+ raw = fs.readFileSync(file, 'utf8')
762
+ fs.unlinkSync(file)
763
+ } catch (e) {
764
+ return ''
765
+ }
766
+ try {
767
+ const n = JSON.parse(raw)
768
+ if (typeof n.text !== 'string' || !n.text || !(Date.now() - n.at < NOTICE_TTL_MS)) return ''
769
+ return n.text.slice(0, 300)
770
+ } catch (e) {
771
+ return ''
772
+ }
773
+ }
774
+
775
+ async function main(argv) {
776
+ // 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
777
+ const tool = (argv && argv[2]) || 'claude_code'
778
+ const payload = readStdin()
779
+ const event = payload.hook_event_name || ''
780
+ const sessionId = payload.session_id || ''
781
+ if (!sessionId) return
782
+
783
+ if (NOTICE_EVENTS[event]) {
784
+ const notice = takeNotice(sessionId)
785
+ // 🔴 writeSync:macOS 上 stdout 是管道时 process.stdout.write 是异步的,进程先退就丢了
786
+ if (notice) {
787
+ try {
788
+ fs.writeSync(1, JSON.stringify({ systemMessage: notice }))
789
+ } catch (e) {
790
+ /* 显示不了就算了 */
791
+ }
792
+ }
793
+ }
794
+
795
+ const registry = loadRegistry()
796
+
797
+ // /clear 改绑(二期 P2):SessionStart 在映射表里是 ignore,要在那之前处理
798
+ if (event === 'SessionStart' && payload.source === 'clear') {
799
+ const pid = resolveAgentPid(process.pid, agentNames(registry, tool))
800
+ if (pid) pairClear(pid, sessionId)
801
+ }
802
+
803
+ const action = mapEvent(registry, tool, event, payload)
804
+ if (action === 'ignore') return
805
+ if (action === 'remove') {
806
+ if (event === 'SessionEnd' && payload.reason === 'clear') {
807
+ const old = readState(sessionId)
808
+ if (old && old.pid) markClear(old.pid, sessionId)
809
+ }
810
+ removeState(sessionId)
811
+ // 删完补发一次剩余会话,否则这条会一直挂在屏上等服务端失联 TTL。
812
+ // 关掉最后一个窗口时发的是空列表——服务端放行空快照,该机器该工具随即从屏上消失
813
+ await reportIfAuthorized(tool, Date.now())
814
+ return
815
+ }
816
+
817
+ const prev = readState(sessionId)
818
+ const now = Date.now()
819
+ if (event === 'UserPromptSubmit') recordDir(payload.cwd, now)
820
+ // 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
821
+ // 不能拿死掉的 -p 进程的 interactive=false 挡不住窗口
822
+ const prevAlive = !!(prev && prev.pid && pidExists(prev.pid))
823
+ const pid = (prevAlive && prev.pid) || resolveAgentPid(process.pid, agentNames(registry, tool)) || undefined
824
+ const next = {
825
+ sessionId: sessionId,
826
+ tool: tool,
827
+ // 标题要读文件,只在真正上报时才取(见下面);这里先沿用上一次的
828
+ title: (prev && prev.title) || '',
829
+ project: payload.cwd || (prev && prev.project) || '',
830
+ // 🔴 和 project 装的是同一个值,不是冗余:project 这个名字读起来就像「只该存目录名」
831
+ // (上报侧确实只发 basename,见 snapshot.ts),早晚有人去「修」它。而远程指令靠
832
+ // 工作目录定位在哪执行 —— 实测 claude --resume 从任何目录都找得到会话,但 agent 的
833
+ // 工作目录跟着【启动目录】走:目录错了不报任何错,它会拿着对的对话去改错目录里的文件。
834
+ // 所以单独立一个名字说得清的字段,把这条契约钉死。
835
+ // ⚠️ 只落本机,绝不上报:快照里发出去的仍然只有 basename。
836
+ cwd: payload.cwd || (prev && prev.cwd) || '',
837
+ state: action,
838
+ startedAt: (prev && prev.startedAt) || now,
839
+ updatedAt: now,
840
+ pid: pid,
841
+ pidName: pid ? (prevAlive && prev.pidName) || processName(pid) || undefined : undefined,
842
+ // 二期 P1:窗口开着的会话,设备不能再往里下指令(两个进程会互相吞掉对话)。每个会话只判一次
843
+ interactive: prevAlive && typeof prev.interactive === 'boolean' ? prev.interactive : detectInteractive(pid),
844
+ lastReportAt: prev ? prev.lastReportAt : undefined,
845
+ // daemon 靠它给窗口待批准撤卡(S10),与 src/core/state.ts 的 recent 同构
846
+ recent: ((prev && Array.isArray(prev.recent) ? prev.recent : []).concat([
847
+ {
848
+ e: event,
849
+ tool: typeof payload.tool_name === 'string' ? payload.tool_name : undefined,
850
+ fp: typeof payload.tool_name === 'string' ? toolFingerprint(payload.tool_name, payload.tool_input) : undefined,
851
+ at: now,
852
+ },
853
+ ])).slice(-RECENT_MAX),
854
+ }
855
+
856
+ if (!shouldReport(prev, next, now)) {
857
+ writeState(next)
858
+ return
859
+ }
860
+
861
+ // 🔴 标题只在真要上报时取(跃迁 / 2 分钟保活),不在每次 hook 事件都去读文件:
862
+ // cc-ai-title 要读 transcript,长任务期间 PreToolUse/PostToolUse 触发极频繁
863
+ next.title =
864
+ resolveTitle(titleSources(registry, tool), {
865
+ transcriptPath: payload.transcript_path,
866
+ cwd: next.project,
867
+ sessionId: sessionId,
868
+ }) ||
869
+ next.title ||
870
+ ''
871
+ next.lastReportAt = now
872
+ writeState(next)
873
+
874
+ await reportIfAuthorized(tool, now)
875
+ }
876
+
877
+ if (require.main === module) {
878
+ main(process.argv)
879
+ .catch(() => {
880
+ /* 🔴 hook 报错会直接打断用户的 AI 会话,一律吞掉 */
881
+ })
882
+ .finally(() => process.exit(0))
883
+ }
884
+
885
+ // 仅供单测使用(被 require 时才会用到,作为 hook 跑时没有任何开销)
886
+ module.exports = {
887
+ DEFAULT_TITLE_SOURCES,
888
+ APP_ID,
889
+ APP_SECRET,
890
+ FALLBACK_REGISTRY,
891
+ GATEWAY_VERSION,
892
+ GIT_LOOKUP_MAX_UP,
893
+ SERVICES,
894
+ TRANSCRIPT_TAIL_BYTES,
895
+ agentNames,
896
+ buildGatewayBody,
897
+ buildHeaders,
898
+ buildSnapshot,
899
+ isAlive,
900
+ loadConfig,
901
+ loadRegistry,
902
+ main,
903
+ mapEvent,
904
+ processName,
905
+ readAiTitle,
906
+ readAliveStates,
907
+ readCodexThreadName,
908
+ readGitBranch,
909
+ readState,
910
+ removeState,
911
+ resolveAgentPid,
912
+ resolveTitle,
913
+ takeNotice,
914
+ isInteractiveArgs,
915
+ recordDir,
916
+ markClear,
917
+ pairClear,
918
+ shouldReport,
919
+ sign,
920
+ titleSources,
921
+ writeState,
922
+ }