deskbuddy 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -8
- package/dist/cli.js +989 -0
- package/dist/hook.cjs +654 -0
- package/docs/INSTALL.md +152 -0
- package/hook/hook.cjs +654 -0
- package/package.json +21 -3
- package/bin/deskbuddy.js +0 -9
package/dist/hook.cjs
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
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
|
+
const REPORT_TIMEOUT_MS = 2000
|
|
26
|
+
/** ServiceApi 版本,不是包路径里的 v2 */
|
|
27
|
+
const GATEWAY_VERSION = '1.0'
|
|
28
|
+
const APP_ID = process.env.DESKBUDDY_APPID || 'deskbuddy-cli'
|
|
29
|
+
/**
|
|
30
|
+
* ⚠️ 【公开常量,不是密钥】。客户端是 npm 公开发布的,任何人解包就能看到它。
|
|
31
|
+
* jumpSign=false 下签名的全部作用只是换取 180 秒防重放窗口,外加让这个 appid 不跳过
|
|
32
|
+
* 框架的 userAuth 闸门。它保护不了任何东西 —— 真正的防线是服务端的 clientId ↔ userId
|
|
33
|
+
* 绑定关系,而那个绑定建立在 App 的已登录态上,不在 CLI 里。
|
|
34
|
+
*/
|
|
35
|
+
const APP_SECRET = process.env.DESKBUDDY_APPSECRET || '3aeb6e0afc144e188dd3ede5c7865643'
|
|
36
|
+
/**
|
|
37
|
+
* 🔴 服务名 = 服务端 handler 的全限定类名小写(@ServiceApi.value() 为空时回落到类名小写)。
|
|
38
|
+
* 服务端类改名 / 挪包,这里会静默失效,表现是 NOT_SUPPORT。与 src/core/api.ts 里那份必须一致。
|
|
39
|
+
*/
|
|
40
|
+
const SERVICES = {
|
|
41
|
+
authCode: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddyauthcodeapi',
|
|
42
|
+
authToken: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddyauthtokenapi',
|
|
43
|
+
sessionReport: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddysessionreportapi',
|
|
44
|
+
toolRegistry: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddytoolregistryapi',
|
|
45
|
+
confirmAuth: 'com.shifeng.api.service.api.v2.deskbuddy.deskbuddyconfirmauthapi',
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 🔴 只读 transcript 尾部这么多字节,绝不遍历整个文件。
|
|
49
|
+
* 实测最大的 transcript 有 1.47 GB,全文件遍历会把用户的 agent 卡死;
|
|
50
|
+
* 而最后一条 ai-title 实测距文件尾只有 1–31 KB。
|
|
51
|
+
*/
|
|
52
|
+
const TRANSCRIPT_TAIL_BYTES = 64 * 1024
|
|
53
|
+
/** 没配 titleSources 的工具按这条链处理 */
|
|
54
|
+
const DEFAULT_TITLE_SOURCES = ['git-branch', 'project']
|
|
55
|
+
/** cwd 不是仓库根时向上找 .git 的层数上限 */
|
|
56
|
+
const GIT_LOOKUP_MAX_UP = 5
|
|
57
|
+
|
|
58
|
+
/** 服务端拉不到、本地缓存也没有时的最小兜底:至少还能认出"在跑 / 结束",不至于整个瞎掉。 */
|
|
59
|
+
const FALLBACK_REGISTRY = {
|
|
60
|
+
version: 'builtin',
|
|
61
|
+
tools: {
|
|
62
|
+
claude_code: {
|
|
63
|
+
format: 'cc-hook',
|
|
64
|
+
processNames: ['claude'],
|
|
65
|
+
titleSources: ['cc-ai-title', 'git-branch', 'project'],
|
|
66
|
+
events: { UserPromptSubmit: 'watch', PreToolUse: 'watch', PostToolUse: 'watch', Stop: 'done' },
|
|
67
|
+
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
68
|
+
},
|
|
69
|
+
codex: {
|
|
70
|
+
format: 'codex-toml',
|
|
71
|
+
processNames: ['codex'],
|
|
72
|
+
// codex-thread-name 是机会主义的一级:session_index.jsonl 里的 thread_name
|
|
73
|
+
// 原生只有 /rename 手动设,多数用户是空的,取不到就往下回落
|
|
74
|
+
titleSources: ['codex-thread-name', 'git-branch', 'project'],
|
|
75
|
+
events: {
|
|
76
|
+
UserPromptSubmit: 'watch',
|
|
77
|
+
PreToolUse: 'watch',
|
|
78
|
+
PostToolUse: 'watch',
|
|
79
|
+
Stop: 'done',
|
|
80
|
+
PermissionRequest: 'ask_approval',
|
|
81
|
+
},
|
|
82
|
+
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------- 配置 ----------
|
|
88
|
+
|
|
89
|
+
function deskbuddyHome() {
|
|
90
|
+
return process.env.DESKBUDDY_HOME || path.join(os.homedir(), '.deskbuddy')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadConfig() {
|
|
94
|
+
const fallback = { clientId: '', hostName: os.hostname(), enabledTools: [], apiBase: DEFAULT_API_BASE }
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'config.json'), 'utf8'))
|
|
97
|
+
return {
|
|
98
|
+
clientId: parsed.clientId || fallback.clientId,
|
|
99
|
+
hostName: parsed.hostName || fallback.hostName,
|
|
100
|
+
enabledTools: Array.isArray(parsed.enabledTools) ? parsed.enabledTools : [],
|
|
101
|
+
apiBase: process.env.DESKBUDDY_API_BASE || parsed.apiBase || DEFAULT_API_BASE,
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return fallback
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- 映射表 ----------
|
|
109
|
+
|
|
110
|
+
/** hook 每次执行都会调用,只读本地缓存 —— 绝不能在 hook 里发网络请求拉配置。 */
|
|
111
|
+
function loadRegistry() {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'tool-registry.json'), 'utf8'))
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return FALLBACK_REGISTRY
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mapEvent(registry, tool, event, payload) {
|
|
120
|
+
const t = registry && registry.tools ? registry.tools[tool] : null
|
|
121
|
+
if (!t) return 'ignore'
|
|
122
|
+
const lifecycle = t.lifecycle ? t.lifecycle[event] : undefined
|
|
123
|
+
if (lifecycle) return lifecycle // ignore / remove
|
|
124
|
+
const conds = t.conditionalEvents ? t.conditionalEvents[event] : undefined
|
|
125
|
+
if (Array.isArray(conds)) {
|
|
126
|
+
// 🔴 Claude Code 的 Notification 有两类:权限确认 → ask_approval;
|
|
127
|
+
// 闲置等待输入 → done。把闲置也报成待确认,屏上会全是"待确认",提醒直接失效。
|
|
128
|
+
const text = String((payload && (payload.message ?? payload.notification_type)) ?? '')
|
|
129
|
+
for (const c of conds) {
|
|
130
|
+
try {
|
|
131
|
+
if (new RegExp(c.match, 'i').test(text)) return c.state
|
|
132
|
+
} catch (e) {
|
|
133
|
+
/* 映射表里写坏的正则不能连累整个 hook */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return (t.events && t.events[event]) || 'ignore'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function agentNames(registry, tool) {
|
|
141
|
+
const t = registry && registry.tools ? registry.tools[tool] : null
|
|
142
|
+
if (t && Array.isArray(t.processNames) && t.processNames.length > 0) return t.processNames
|
|
143
|
+
const f = FALLBACK_REGISTRY.tools[tool]
|
|
144
|
+
return f ? f.processNames : []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 会话标题回落链。顺序由服务端映射表决定,客户端不写死。 */
|
|
148
|
+
function titleSources(registry, tool) {
|
|
149
|
+
const t = registry && registry.tools ? registry.tools[tool] : null
|
|
150
|
+
if (t && Array.isArray(t.titleSources) && t.titleSources.length > 0) return t.titleSources
|
|
151
|
+
const f = FALLBACK_REGISTRY.tools[tool]
|
|
152
|
+
return f && f.titleSources && f.titleSources.length > 0 ? f.titleSources : DEFAULT_TITLE_SOURCES
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------- 会话标题 ----------
|
|
156
|
+
|
|
157
|
+
function codexHome() {
|
|
158
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 读文件尾部 N 字节并按行切开。读不了就返回空数组,绝不抛。 */
|
|
162
|
+
function readTailLines(file, bytes) {
|
|
163
|
+
let fd
|
|
164
|
+
try {
|
|
165
|
+
const size = fs.statSync(file).size
|
|
166
|
+
if (size <= 0) return []
|
|
167
|
+
const len = Math.min(size, bytes)
|
|
168
|
+
const buf = Buffer.alloc(len)
|
|
169
|
+
fd = fs.openSync(file, 'r')
|
|
170
|
+
fs.readSync(fd, buf, 0, len, size - len)
|
|
171
|
+
return buf.toString('utf8').split('\n')
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return []
|
|
174
|
+
} finally {
|
|
175
|
+
if (fd !== undefined) {
|
|
176
|
+
try {
|
|
177
|
+
fs.closeSync(fd)
|
|
178
|
+
} catch (e) {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Claude Code 自己写进 transcript 的会话标题({"type":"ai-title","aiTitle":"…"})。 */
|
|
186
|
+
function readAiTitle(transcriptPath) {
|
|
187
|
+
if (!transcriptPath) return null
|
|
188
|
+
const lines = readTailLines(transcriptPath, TRANSCRIPT_TAIL_BYTES)
|
|
189
|
+
// 从后往前找第一条 ai-title
|
|
190
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
191
|
+
const line = lines[i]
|
|
192
|
+
if (line.indexOf('"ai-title"') === -1) continue
|
|
193
|
+
try {
|
|
194
|
+
const obj = JSON.parse(line)
|
|
195
|
+
if (obj && obj.type === 'ai-title') {
|
|
196
|
+
const title = String(obj.aiTitle == null ? '' : obj.aiTitle).trim()
|
|
197
|
+
if (title !== '') return title
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
/* 尾部窗口切出来的半行,跳过 */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Codex 的会话名:~/.codex/session_index.jsonl 里 {"id":…,"thread_name":…}。
|
|
208
|
+
* ⚠️ 机会主义的一级——原生只有 /rename 手动设,普通用户这里多半是空的。
|
|
209
|
+
*/
|
|
210
|
+
function readCodexThreadName(sessionId, home) {
|
|
211
|
+
if (!sessionId) return null
|
|
212
|
+
const indexPath = path.join(home || codexHome(), 'session_index.jsonl')
|
|
213
|
+
const lines = readTailLines(indexPath, TRANSCRIPT_TAIL_BYTES)
|
|
214
|
+
// 从后往前找:同一个会话可能被改过多次名,最后一条才是最新的
|
|
215
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
216
|
+
const line = lines[i]
|
|
217
|
+
if (line.indexOf(sessionId) === -1) continue
|
|
218
|
+
try {
|
|
219
|
+
const obj = JSON.parse(line)
|
|
220
|
+
if (obj && obj.id === sessionId) {
|
|
221
|
+
const name = String(obj.thread_name == null ? '' : obj.thread_name).trim()
|
|
222
|
+
if (name !== '') return name
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
/* 尾部窗口切出来的半行,跳过 */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 读一个目录下的 .git/HEAD 内容。不是仓库返回 null(worktree 时跟一跳)。 */
|
|
232
|
+
function readGitHead(dir) {
|
|
233
|
+
try {
|
|
234
|
+
const dotGit = path.join(dir, '.git')
|
|
235
|
+
let headPath = path.join(dotGit, 'HEAD')
|
|
236
|
+
if (!fs.statSync(dotGit).isDirectory()) {
|
|
237
|
+
// worktree / submodule:.git 是个文件,内容是 "gitdir: <路径>"
|
|
238
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'))
|
|
239
|
+
if (!m) return null
|
|
240
|
+
const gitdir = m[1].trim()
|
|
241
|
+
headPath = path.join(path.isAbsolute(gitdir) ? gitdir : path.join(dir, gitdir), 'HEAD')
|
|
242
|
+
}
|
|
243
|
+
return fs.readFileSync(headPath, 'utf8').trim()
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 当前分支名。cwd 可能是仓库子目录,向上找最多 GIT_LOOKUP_MAX_UP 层;detached HEAD 返回 null。 */
|
|
250
|
+
function readGitBranch(cwd, maxUp) {
|
|
251
|
+
if (!cwd) return null
|
|
252
|
+
const limit = maxUp === undefined ? GIT_LOOKUP_MAX_UP : maxUp
|
|
253
|
+
let dir = cwd
|
|
254
|
+
for (let up = 0; up <= limit; up++) {
|
|
255
|
+
const head = readGitHead(dir)
|
|
256
|
+
if (head !== null) {
|
|
257
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
|
|
258
|
+
if (!ref) return null // detached HEAD 是一串裸 sha,没必要上屏
|
|
259
|
+
return ref[1].trim() || null
|
|
260
|
+
}
|
|
261
|
+
const parent = path.dirname(dir)
|
|
262
|
+
if (parent === dir) break // 到根了
|
|
263
|
+
dir = parent
|
|
264
|
+
}
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 按 registry 给的顺序逐级回落。所有来源都不碰 prompt 原文。 */
|
|
269
|
+
function resolveTitle(sources, ctx) {
|
|
270
|
+
const list = sources || []
|
|
271
|
+
for (const source of list) {
|
|
272
|
+
let value = null
|
|
273
|
+
if (source === 'cc-ai-title') value = readAiTitle(ctx.transcriptPath)
|
|
274
|
+
else if (source === 'codex-thread-name') value = readCodexThreadName(ctx.sessionId, ctx.codexHome)
|
|
275
|
+
else if (source === 'git-branch') value = readGitBranch(ctx.cwd)
|
|
276
|
+
else if (source === 'project') value = path.basename(ctx.cwd || '') || null
|
|
277
|
+
// 不认识的来源直接跳过:新来源应该先发版再进 registry
|
|
278
|
+
if (value) return value
|
|
279
|
+
}
|
|
280
|
+
return ''
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------- 进程探测 ----------
|
|
284
|
+
|
|
285
|
+
function processName(pid) {
|
|
286
|
+
try {
|
|
287
|
+
// macOS / Linux 通用。Windows 需要 tasklist,首期不支持
|
|
288
|
+
const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
|
|
289
|
+
encoding: 'utf8',
|
|
290
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
291
|
+
})
|
|
292
|
+
const name = out.trim()
|
|
293
|
+
return name === '' ? null : name
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function parentPid(pid) {
|
|
300
|
+
try {
|
|
301
|
+
const out = execFileSync('ps', ['-p', String(pid), '-o', 'ppid='], {
|
|
302
|
+
encoding: 'utf8',
|
|
303
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
304
|
+
})
|
|
305
|
+
const ppid = Number(out.trim())
|
|
306
|
+
return Number.isFinite(ppid) && ppid > 1 ? ppid : null
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 🔴 expectName 防 pid 复用:只看 pid 存在,会把已关闭的窗口误判成还活着。 */
|
|
313
|
+
function isAlive(pid, expectName) {
|
|
314
|
+
if (!Number.isFinite(pid) || pid <= 0) return false
|
|
315
|
+
try {
|
|
316
|
+
process.kill(pid, 0)
|
|
317
|
+
} catch (e) {
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
if (!expectName) return true
|
|
321
|
+
const name = processName(pid)
|
|
322
|
+
return name != null && name.toLowerCase().includes(String(expectName).toLowerCase())
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** hook 的直接父进程可能是 shell,agent 在更上层,所以要逐级往上找。 */
|
|
326
|
+
function resolveAgentPid(startPid, names, maxDepth) {
|
|
327
|
+
const wanted = (names || []).map((n) => String(n).toLowerCase()).filter((n) => n !== '')
|
|
328
|
+
if (wanted.length === 0) return null
|
|
329
|
+
const depthLimit = maxDepth || 8
|
|
330
|
+
let pid = startPid
|
|
331
|
+
for (let depth = 0; depth < depthLimit && pid != null; depth++) {
|
|
332
|
+
const name = processName(pid)
|
|
333
|
+
if (name && wanted.some((w) => name.toLowerCase().includes(w))) return pid
|
|
334
|
+
pid = parentPid(pid)
|
|
335
|
+
}
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------- 会话状态 ----------
|
|
340
|
+
|
|
341
|
+
function stateDir() {
|
|
342
|
+
return path.join(deskbuddyHome(), 'state')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function statePath(sessionId) {
|
|
346
|
+
// sessionId 来自 agent,是不可信输入,清洗掉路径穿越字符
|
|
347
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '')
|
|
348
|
+
return path.join(stateDir(), (safe || 'unknown') + '.json')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function readState(sessionId) {
|
|
352
|
+
try {
|
|
353
|
+
return JSON.parse(fs.readFileSync(statePath(sessionId), 'utf8'))
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 原子写:先写 .tmp 再 rename,避免并发的多个 hook 读到写了一半的 JSON。 */
|
|
360
|
+
function writeState(s) {
|
|
361
|
+
fs.mkdirSync(stateDir(), { recursive: true })
|
|
362
|
+
const tmp = statePath(s.sessionId) + '.tmp'
|
|
363
|
+
fs.writeFileSync(tmp, JSON.stringify(s), 'utf8')
|
|
364
|
+
fs.renameSync(tmp, statePath(s.sessionId))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function removeState(sessionId) {
|
|
368
|
+
try {
|
|
369
|
+
fs.rmSync(statePath(sessionId), { force: true })
|
|
370
|
+
} catch (e) {
|
|
371
|
+
/* 删不掉就算了,下次还会尝试 */
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 读取某工具下所有【进程还活着】的会话,顺手清理已死的。
|
|
377
|
+
* SessionEnd 极不可靠(用户叉掉窗口时根本不触发),进程存活才是唯一硬信号。
|
|
378
|
+
*/
|
|
379
|
+
function readAliveStates(tool) {
|
|
380
|
+
let files
|
|
381
|
+
try {
|
|
382
|
+
files = fs.readdirSync(stateDir())
|
|
383
|
+
} catch (e) {
|
|
384
|
+
return []
|
|
385
|
+
}
|
|
386
|
+
const alive = []
|
|
387
|
+
for (const file of files) {
|
|
388
|
+
if (!file.endsWith('.json')) continue
|
|
389
|
+
let s
|
|
390
|
+
try {
|
|
391
|
+
s = JSON.parse(fs.readFileSync(path.join(stateDir(), file), 'utf8'))
|
|
392
|
+
} catch (e) {
|
|
393
|
+
try {
|
|
394
|
+
fs.rmSync(path.join(stateDir(), file), { force: true }) // 脏文件直接清掉
|
|
395
|
+
} catch (e2) {
|
|
396
|
+
/* ignore */
|
|
397
|
+
}
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (!s || s.tool !== tool) continue
|
|
401
|
+
if (s.pid && !isAlive(s.pid, s.pidName)) {
|
|
402
|
+
removeState(s.sessionId) // 窗口已关:剔除并自我清理,state 目录不再无限增长
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
alive.push(s)
|
|
406
|
+
}
|
|
407
|
+
return alive
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------- 节流与快照 ----------
|
|
411
|
+
|
|
412
|
+
function shouldReport(prev, next, nowMs) {
|
|
413
|
+
if (!prev) return true
|
|
414
|
+
if (prev.state !== next.state) return true
|
|
415
|
+
// 保活:状态没跃迁也要定期报一次,否则服务端会把这台机器判成失联
|
|
416
|
+
return nowMs - (prev.lastReportAt || 0) > KEEPALIVE_MS
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildSnapshot(states, tool, cfg, nowMs) {
|
|
420
|
+
const sessions = states
|
|
421
|
+
.filter((s) => s.tool === tool) // 替换粒度是 (clientId, tool),混进别的工具会冲掉对方
|
|
422
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
423
|
+
.slice(0, MAX_SESSIONS)
|
|
424
|
+
.map((s) => ({
|
|
425
|
+
sessionId: s.sessionId,
|
|
426
|
+
title: s.title,
|
|
427
|
+
// 隐私:只带目录名,不带全路径——全路径会暴露公司目录结构与用户名
|
|
428
|
+
project: path.basename(s.project || ''),
|
|
429
|
+
state: s.state,
|
|
430
|
+
startedAt: s.startedAt,
|
|
431
|
+
updatedAt: s.updatedAt,
|
|
432
|
+
}))
|
|
433
|
+
return { clientId: cfg.clientId, hostName: cfg.hostName, tool: tool, snapshotAt: nowMs, sessions: sessions }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------- 上报 ----------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 服务端 HelloApi.java:152:
|
|
440
|
+
* source = `${appid}_${appSecret}_${timestamp}_${deviceId}_${deviceName}_${nonce}_${body}`
|
|
441
|
+
* signature = md5Hex(source)
|
|
442
|
+
* 七段用 `_` 连接,顺序不能变。
|
|
443
|
+
*/
|
|
444
|
+
function sign(p) {
|
|
445
|
+
const source = [p.appId, p.appSecret, p.timestamp, p.deviceId, p.deviceName, p.nonce, p.body].join('_')
|
|
446
|
+
return crypto.createHash('md5').update(source, 'utf8').digest('hex')
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 网关 body。返回的就是要发出去的那个字符串 —— 拿它算签名,再原样发。
|
|
451
|
+
* 🔴 没有 group 字段;data 是【字符串】不是对象;base.encode 必须显式 "false",
|
|
452
|
+
* 否则服务端会拿 data 去 AES 解密,直接 DATA_DECODE_FAIL。
|
|
453
|
+
*/
|
|
454
|
+
function buildGatewayBody(service, data, requestId) {
|
|
455
|
+
return JSON.stringify({
|
|
456
|
+
service: service,
|
|
457
|
+
version: GATEWAY_VERSION,
|
|
458
|
+
requestId: requestId || crypto.randomUUID(),
|
|
459
|
+
base: { encode: 'false' },
|
|
460
|
+
data: JSON.stringify(data == null ? {} : data),
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 六个必填头(/gateway 用 @RequestHeader 声明且 required 默认 true,【缺一个就是 400】)。
|
|
466
|
+
* 🔴 签名用的必须是传进来的这个 body 字符串本身,绝不能再 stringify 一次 —— 键序可能不同。
|
|
467
|
+
* 没有 ShiFeng-Token:鉴权靠 clientId ↔ userId 绑定关系。
|
|
468
|
+
*/
|
|
469
|
+
function buildHeaders(cfg, body, opts) {
|
|
470
|
+
const o = opts || {}
|
|
471
|
+
const timestamp = o.timestamp || String(Date.now())
|
|
472
|
+
const nonce = o.nonce || crypto.randomBytes(8).toString('hex')
|
|
473
|
+
return {
|
|
474
|
+
'Content-Type': 'application/json;charset=UTF-8',
|
|
475
|
+
'Content-Length': String(Buffer.byteLength(body)),
|
|
476
|
+
'ShiFeng-AppId': APP_ID,
|
|
477
|
+
'ShiFeng-Timestamp': timestamp,
|
|
478
|
+
'ShiFeng-Device-ID': cfg.clientId,
|
|
479
|
+
'ShiFeng-Device-Name': cfg.hostName,
|
|
480
|
+
'ShiFeng-Nonce': nonce,
|
|
481
|
+
'ShiFeng-Signature': sign({
|
|
482
|
+
appId: APP_ID,
|
|
483
|
+
appSecret: APP_SECRET,
|
|
484
|
+
timestamp: timestamp,
|
|
485
|
+
deviceId: cfg.clientId,
|
|
486
|
+
deviceName: cfg.hostName,
|
|
487
|
+
nonce: nonce,
|
|
488
|
+
body: body,
|
|
489
|
+
}),
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** fire-and-forget:2 秒超时,失败静默。绝不能因为网络问题卡住用户的 agent。 */
|
|
494
|
+
function postReport(cfg, payload) {
|
|
495
|
+
return new Promise((resolve) => {
|
|
496
|
+
let settled = false
|
|
497
|
+
const done = () => {
|
|
498
|
+
if (!settled) {
|
|
499
|
+
settled = true
|
|
500
|
+
resolve()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
const url = new URL('/gateway', cfg.apiBase)
|
|
505
|
+
const body = buildGatewayBody(SERVICES.sessionReport, payload)
|
|
506
|
+
const mod = url.protocol === 'http:' ? http : https
|
|
507
|
+
const req = mod.request(
|
|
508
|
+
{
|
|
509
|
+
protocol: url.protocol,
|
|
510
|
+
hostname: url.hostname,
|
|
511
|
+
port: url.port || undefined,
|
|
512
|
+
path: url.pathname,
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: buildHeaders(cfg, body),
|
|
515
|
+
},
|
|
516
|
+
(res) => {
|
|
517
|
+
res.resume()
|
|
518
|
+
res.on('end', done)
|
|
519
|
+
res.on('error', done)
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
req.setTimeout(REPORT_TIMEOUT_MS, () => {
|
|
523
|
+
req.destroy()
|
|
524
|
+
done()
|
|
525
|
+
})
|
|
526
|
+
req.on('error', done)
|
|
527
|
+
req.write(body)
|
|
528
|
+
req.end()
|
|
529
|
+
} catch (e) {
|
|
530
|
+
done()
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------- 主流程 ----------
|
|
536
|
+
|
|
537
|
+
function readStdin() {
|
|
538
|
+
try {
|
|
539
|
+
return JSON.parse(fs.readFileSync(0, 'utf8') || '{}')
|
|
540
|
+
} catch (e) {
|
|
541
|
+
return {}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 组装并上报该工具当前的全量快照。没授权 / 没 enable 就什么都不做。
|
|
547
|
+
* readAliveStates 会顺手做进程探测并清掉已死的会话。
|
|
548
|
+
*/
|
|
549
|
+
async function reportIfAuthorized(tool, nowMs) {
|
|
550
|
+
const cfg = loadConfig()
|
|
551
|
+
// clientId 为空说明还没 setup 过(服务端靠 clientId ↔ userId 绑定关系认人):
|
|
552
|
+
// 绝不能在这里现编一个,那等于凭空造出一台没人认领的机器
|
|
553
|
+
if (!cfg.clientId || cfg.enabledTools.indexOf(tool) === -1) return
|
|
554
|
+
await postReport(cfg, buildSnapshot(readAliveStates(tool), tool, cfg, nowMs))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function main(argv) {
|
|
558
|
+
// 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
|
|
559
|
+
const tool = (argv && argv[2]) || 'claude_code'
|
|
560
|
+
const payload = readStdin()
|
|
561
|
+
const event = payload.hook_event_name || ''
|
|
562
|
+
const sessionId = payload.session_id || ''
|
|
563
|
+
if (!sessionId) return
|
|
564
|
+
|
|
565
|
+
const registry = loadRegistry()
|
|
566
|
+
const action = mapEvent(registry, tool, event, payload)
|
|
567
|
+
if (action === 'ignore') return
|
|
568
|
+
if (action === 'remove') {
|
|
569
|
+
removeState(sessionId)
|
|
570
|
+
// 删完补发一次剩余会话,否则这条会一直挂在屏上等服务端失联 TTL。
|
|
571
|
+
// 关掉最后一个窗口时发的是空列表——服务端放行空快照,该机器该工具随即从屏上消失
|
|
572
|
+
await reportIfAuthorized(tool, Date.now())
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const prev = readState(sessionId)
|
|
577
|
+
const now = Date.now()
|
|
578
|
+
const pid = (prev && prev.pid) || resolveAgentPid(process.pid, agentNames(registry, tool)) || undefined
|
|
579
|
+
const next = {
|
|
580
|
+
sessionId: sessionId,
|
|
581
|
+
tool: tool,
|
|
582
|
+
// 标题要读文件,只在真正上报时才取(见下面);这里先沿用上一次的
|
|
583
|
+
title: (prev && prev.title) || '',
|
|
584
|
+
project: payload.cwd || (prev && prev.project) || '',
|
|
585
|
+
state: action,
|
|
586
|
+
startedAt: (prev && prev.startedAt) || now,
|
|
587
|
+
updatedAt: now,
|
|
588
|
+
pid: pid,
|
|
589
|
+
pidName: pid ? (prev && prev.pidName) || processName(pid) || undefined : undefined,
|
|
590
|
+
lastReportAt: prev ? prev.lastReportAt : undefined,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!shouldReport(prev, next, now)) {
|
|
594
|
+
writeState(next)
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 🔴 标题只在真要上报时取(跃迁 / 2 分钟保活),不在每次 hook 事件都去读文件:
|
|
599
|
+
// cc-ai-title 要读 transcript,长任务期间 PreToolUse/PostToolUse 触发极频繁
|
|
600
|
+
next.title =
|
|
601
|
+
resolveTitle(titleSources(registry, tool), {
|
|
602
|
+
transcriptPath: payload.transcript_path,
|
|
603
|
+
cwd: next.project,
|
|
604
|
+
sessionId: sessionId,
|
|
605
|
+
}) ||
|
|
606
|
+
next.title ||
|
|
607
|
+
''
|
|
608
|
+
next.lastReportAt = now
|
|
609
|
+
writeState(next)
|
|
610
|
+
|
|
611
|
+
await reportIfAuthorized(tool, now)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (require.main === module) {
|
|
615
|
+
main(process.argv)
|
|
616
|
+
.catch(() => {
|
|
617
|
+
/* 🔴 hook 报错会直接打断用户的 AI 会话,一律吞掉 */
|
|
618
|
+
})
|
|
619
|
+
.finally(() => process.exit(0))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// 仅供单测使用(被 require 时才会用到,作为 hook 跑时没有任何开销)
|
|
623
|
+
module.exports = {
|
|
624
|
+
DEFAULT_TITLE_SOURCES,
|
|
625
|
+
APP_ID,
|
|
626
|
+
APP_SECRET,
|
|
627
|
+
FALLBACK_REGISTRY,
|
|
628
|
+
GATEWAY_VERSION,
|
|
629
|
+
GIT_LOOKUP_MAX_UP,
|
|
630
|
+
SERVICES,
|
|
631
|
+
TRANSCRIPT_TAIL_BYTES,
|
|
632
|
+
agentNames,
|
|
633
|
+
buildGatewayBody,
|
|
634
|
+
buildHeaders,
|
|
635
|
+
buildSnapshot,
|
|
636
|
+
isAlive,
|
|
637
|
+
loadConfig,
|
|
638
|
+
loadRegistry,
|
|
639
|
+
main,
|
|
640
|
+
mapEvent,
|
|
641
|
+
processName,
|
|
642
|
+
readAiTitle,
|
|
643
|
+
readAliveStates,
|
|
644
|
+
readCodexThreadName,
|
|
645
|
+
readGitBranch,
|
|
646
|
+
readState,
|
|
647
|
+
removeState,
|
|
648
|
+
resolveAgentPid,
|
|
649
|
+
resolveTitle,
|
|
650
|
+
shouldReport,
|
|
651
|
+
sign,
|
|
652
|
+
titleSources,
|
|
653
|
+
writeState,
|
|
654
|
+
}
|