deskbuddy 0.1.0 → 0.2.0-beta.1
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/cli.js +3005 -199
- package/dist/gate.cjs +249 -0
- package/dist/hook.cjs +271 -3
- package/dist/opencode.mjs +329 -0
- package/hook/gate.cjs +249 -0
- package/hook/hook.cjs +271 -3
- package/hook/opencode.d.mts +7 -0
- package/hook/opencode.mjs +329 -0
- package/package.json +5 -3
package/dist/gate.cjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
// 远程执行的「授权关卡」。daemon 起智能体时把它挂成 PreToolUse hook:
|
|
4
|
+
// node gate.cjs --run <runId>
|
|
5
|
+
// 智能体每次要调工具都会先跑一次它。它只做一件事:把 hook 输入转给 daemon,
|
|
6
|
+
// 把 daemon 的决定翻译成 hook 输出。分类、等待、和设备打交道全在 daemon 里。
|
|
7
|
+
//
|
|
8
|
+
// 🔴 铁律:**任何失败都 exit 2**。
|
|
9
|
+
// 实测(2026-09-23,Qoder CN 1.1.61):hook 崩溃、HTTP hook 连不上,智能体【照常执行】。
|
|
10
|
+
// Qoder 必须开 bypass_permissions 才能用,这时本脚本就是唯一的关卡 ——
|
|
11
|
+
// 一个没接住的异常、一次 exit 0 的兜底,就等于把设备授权整个绕过去。
|
|
12
|
+
// exit 2 是四家公认的「阻止这次工具调用」,实测能拦住。
|
|
13
|
+
//
|
|
14
|
+
// 🔴 与 hook.cjs 相反,这里【不设超时】:用户可能过很久才在设备上按下允许,
|
|
15
|
+
// 等待就是它的本职。超时由智能体那边的 hook timeout 兜(daemon 配 7 天)。
|
|
16
|
+
//
|
|
17
|
+
// 🔴 `--window` 模式(S10)的兜底方向**相反**:任何失败都 exit 0 且不输出。
|
|
18
|
+
// 那是用户自己窗口里的 PermissionRequest hook,设备只是多一个答复的地方;不表态 = 授权框照常由人处理。
|
|
19
|
+
// PermissionRequest 下 exit 2 = 拒绝 —— 沿用上面的铁律,daemon 一挂,用户窗口里每个授权都会被自动拒掉。
|
|
20
|
+
//
|
|
21
|
+
// 只 require Node 内置模块,理由同 hook.cjs 顶部 ①。
|
|
22
|
+
const fs = require('node:fs')
|
|
23
|
+
const os = require('node:os')
|
|
24
|
+
const path = require('node:path')
|
|
25
|
+
const http = require('node:http')
|
|
26
|
+
const { execFileSync } = require('node:child_process')
|
|
27
|
+
|
|
28
|
+
const DECISIONS = new Set(['allow', 'deny', 'defer'])
|
|
29
|
+
const WINDOW = process.argv.includes('--window')
|
|
30
|
+
|
|
31
|
+
function fail(why) {
|
|
32
|
+
// 窗口模式:不表态,见顶部
|
|
33
|
+
if (WINDOW) process.exit(0)
|
|
34
|
+
try {
|
|
35
|
+
process.stderr.write(`DeskBuddy 授权关卡:${why},已阻止这次操作\n`)
|
|
36
|
+
} catch (_) {
|
|
37
|
+
/* stderr 都写不了也必须继续走到 exit 2 */
|
|
38
|
+
}
|
|
39
|
+
process.exit(2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function deskbuddyHome() {
|
|
43
|
+
return process.env.DESKBUDDY_HOME || path.join(os.homedir(), '.deskbuddy')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function argRun(argv) {
|
|
47
|
+
const i = argv.indexOf('--run')
|
|
48
|
+
const v = i >= 0 ? argv[i + 1] : ''
|
|
49
|
+
return typeof v === 'string' && /^[0-9a-zA-Z_-]{1,64}$/.test(v) ? v : ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStdin() {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let buf = ''
|
|
55
|
+
process.stdin.setEncoding('utf8')
|
|
56
|
+
process.stdin.on('data', (c) => (buf += c))
|
|
57
|
+
process.stdin.on('end', () => resolve(buf))
|
|
58
|
+
process.stdin.on('error', reject)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function post(port, token, body) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const req = http.request(
|
|
65
|
+
{
|
|
66
|
+
host: '127.0.0.1',
|
|
67
|
+
port,
|
|
68
|
+
method: 'POST',
|
|
69
|
+
path: '/gate',
|
|
70
|
+
headers: {
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
'content-length': Buffer.byteLength(body),
|
|
73
|
+
'x-gate-token': token,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
(res) => {
|
|
77
|
+
let data = ''
|
|
78
|
+
res.setEncoding('utf8')
|
|
79
|
+
res.on('data', (c) => (data += c))
|
|
80
|
+
res.on('end', () => resolve({ status: res.statusCode, data }))
|
|
81
|
+
res.on('error', reject)
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
req.on('error', reject)
|
|
85
|
+
req.end(body)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 这个 hook 是不是由【交互窗口】里的 Claude Code 触发的。
|
|
91
|
+
* 🔴 实测(2026-09-24,2.1.259):`claude -p` 碰到要授权的工具**也会**触发 PermissionRequest,
|
|
92
|
+
* 而 hook 输入里没有区分字段。不拦的话,用户脚本 / IDE 插件 / SDK 里的 claude 会被推到设备上一直挂着。
|
|
93
|
+
* 做法:沿父进程找到 claude,启动参数里有 -p / --print / stream-json 的一律当非交互。找不到也当非交互
|
|
94
|
+
*/
|
|
95
|
+
/** `--tool <name>`(二期 P8:窗口模式不只 Claude Code)。缺省 claude_code,兼容已装出去的 hook 命令 */
|
|
96
|
+
function argTool(argv) {
|
|
97
|
+
const i = argv.indexOf('--tool')
|
|
98
|
+
const v = i >= 0 ? argv[i + 1] : ''
|
|
99
|
+
return typeof v === 'string' && /^[a-z_]{1,32}$/.test(v) ? v : 'claude_code'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 按智能体判断是不是交互窗口。
|
|
104
|
+
* - Qoder CN(2026-09-27 实测):桌面端弹授权框时 hook 的父进程就是 `Qoder CN.app` 主进程;
|
|
105
|
+
* daemon 起的远程任务走 worker(node),父进程链上没有它
|
|
106
|
+
* - Claude Code:见 isInteractiveClaude
|
|
107
|
+
*/
|
|
108
|
+
function isInteractiveAgent(tool) {
|
|
109
|
+
if (tool === 'qoder') return hasAncestor((comm) => comm.includes('/Qoder CN.app/'))
|
|
110
|
+
if (tool === 'claude_code') return isInteractiveClaude()
|
|
111
|
+
// opencode 的窗口确认由插件发起,插件已排除 `opencode run`(hook/opencode.mjs isHeadless)
|
|
112
|
+
if (tool === 'opencode') return true
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasAncestor(match) {
|
|
117
|
+
let pid = process.ppid
|
|
118
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
119
|
+
let line
|
|
120
|
+
try {
|
|
121
|
+
line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
122
|
+
} catch (_) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
const m = line.match(/^(\d+)\s+(.*)$/)
|
|
126
|
+
if (!m) return false
|
|
127
|
+
if (match(m[2])) return true
|
|
128
|
+
pid = Number(m[1])
|
|
129
|
+
}
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isInteractiveClaude() {
|
|
134
|
+
let pid = process.ppid
|
|
135
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
136
|
+
let line
|
|
137
|
+
try {
|
|
138
|
+
line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
139
|
+
} catch (_) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
const m = line.match(/^(\d+)\s+(.*)$/)
|
|
143
|
+
if (!m) return false
|
|
144
|
+
if (path.basename(m[2]) === 'claude') {
|
|
145
|
+
let args = ''
|
|
146
|
+
try {
|
|
147
|
+
args = execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
148
|
+
} catch (_) {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
const tokens = args.trim().split(/\s+/)
|
|
152
|
+
// 短参数可能合写(`-cp`);args 里 argv[0] 之后才算参数
|
|
153
|
+
return !tokens.slice(1).some(
|
|
154
|
+
(t) => t === '--print' || /^-[a-zA-Z]*p[a-zA-Z]*$/.test(t) || t === 'stream-json' || t.startsWith('--output-format=stream')
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
pid = Number(m[1])
|
|
158
|
+
}
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 窗口模式:daemon 回 allow / deny 才表态,其余一律不输出、exit 0 */
|
|
163
|
+
async function windowMain() {
|
|
164
|
+
// DESKBUDDY_GATE_ASSUME_INTERACTIVE 只给测试用:1 当交互、0 当非交互,其余真去查进程
|
|
165
|
+
const tool = argTool(process.argv)
|
|
166
|
+
const forced = process.env.DESKBUDDY_GATE_ASSUME_INTERACTIVE
|
|
167
|
+
if (forced === '0' || (forced !== '1' && !isInteractiveAgent(tool))) return fail()
|
|
168
|
+
const conf = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'gate.json'), 'utf8'))
|
|
169
|
+
if (!conf || !Number.isInteger(conf.port) || typeof conf.token !== 'string' || !conf.token) return fail()
|
|
170
|
+
const hook = JSON.parse(await readStdin())
|
|
171
|
+
const res = await post(conf.port, conf.token, JSON.stringify({ mode: 'window', tool, hook }))
|
|
172
|
+
if (res.status !== 200) return fail()
|
|
173
|
+
const out = JSON.parse(res.data)
|
|
174
|
+
if (!out || (out.decision !== 'allow' && out.decision !== 'deny')) return fail()
|
|
175
|
+
const decision =
|
|
176
|
+
out.decision === 'allow'
|
|
177
|
+
? Array.isArray(out.updatedPermissions) && out.updatedPermissions.length > 0
|
|
178
|
+
? { behavior: 'allow', updatedPermissions: out.updatedPermissions }
|
|
179
|
+
: { behavior: 'allow' }
|
|
180
|
+
: { behavior: 'deny', message: (typeof out.reason === 'string' && out.reason) || '用户在设备上拒绝了这一步' }
|
|
181
|
+
const body = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
|
|
182
|
+
// 窗口里告诉人是在哪台设备上处理的
|
|
183
|
+
if (typeof out.notice === 'string' && out.notice) body.systemMessage = out.notice.slice(0, 300)
|
|
184
|
+
process.stdout.write(JSON.stringify(body), () => process.exit(0))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function main() {
|
|
188
|
+
if (WINDOW) return windowMain()
|
|
189
|
+
const runId = argRun(process.argv)
|
|
190
|
+
if (!runId) return fail('缺少 --run')
|
|
191
|
+
|
|
192
|
+
let conf
|
|
193
|
+
try {
|
|
194
|
+
conf = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'gate.json'), 'utf8'))
|
|
195
|
+
} catch (_) {
|
|
196
|
+
return fail('找不到守护进程(gate.json 读不到)')
|
|
197
|
+
}
|
|
198
|
+
if (!conf || !Number.isInteger(conf.port) || typeof conf.token !== 'string' || !conf.token) {
|
|
199
|
+
return fail('gate.json 内容不对')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let hook
|
|
203
|
+
try {
|
|
204
|
+
hook = JSON.parse(await readStdin())
|
|
205
|
+
} catch (_) {
|
|
206
|
+
return fail('读不懂 hook 输入')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let res
|
|
210
|
+
try {
|
|
211
|
+
res = await post(conf.port, conf.token, JSON.stringify({ runId, hook }))
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return fail(`连不上守护进程(${(e && e.code) || 'error'})`)
|
|
214
|
+
}
|
|
215
|
+
if (res.status !== 200) return fail(`守护进程返回 ${res.status}`)
|
|
216
|
+
|
|
217
|
+
let out
|
|
218
|
+
try {
|
|
219
|
+
out = JSON.parse(res.data)
|
|
220
|
+
} catch (_) {
|
|
221
|
+
return fail('守护进程的回复读不懂')
|
|
222
|
+
}
|
|
223
|
+
if (!out || !DECISIONS.has(out.decision)) return fail('守护进程给了不认识的决定')
|
|
224
|
+
|
|
225
|
+
const reason = typeof out.reason === 'string' ? out.reason : ''
|
|
226
|
+
|
|
227
|
+
// 🔴 拒绝一律走 stderr + exit 2,不走 JSON(评审 S5):exit 2 是四家公认的"拦下",
|
|
228
|
+
// 而 JSON 那条路要是 stdout 没写完进程就退了,智能体拿到的是"exit 0 + 没有决定",
|
|
229
|
+
// Qoder 在 bypass 下会当成放行。拒绝的方向必须不依赖任何一次写入成功
|
|
230
|
+
if (out.decision === 'deny') {
|
|
231
|
+
process.stderr.write(`${reason || '用户在设备上拒绝了这一步'}\n`, () => process.exit(2))
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const payload = JSON.stringify({
|
|
236
|
+
hookSpecificOutput: {
|
|
237
|
+
hookEventName: 'PreToolUse',
|
|
238
|
+
permissionDecision: out.decision,
|
|
239
|
+
permissionDecisionReason: reason,
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
// 等写完再退:管道写入是异步的,写一半就 exit 的话 allow / defer 会丢
|
|
243
|
+
process.stdout.write(payload, (err) => (err ? fail('写 hook 输出失败') : process.exit(0)))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 兜住一切没想到的异常 —— 见顶部铁律
|
|
247
|
+
process.on('uncaughtException', (e) => fail(`内部错误 ${(e && e.message) || e}`))
|
|
248
|
+
process.on('unhandledRejection', (e) => fail(`内部错误 ${(e && e.message) || e}`))
|
|
249
|
+
main().catch((e) => fail(`内部错误 ${(e && e.message) || e}`))
|
package/dist/hook.cjs
CHANGED
|
@@ -22,6 +22,8 @@ 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
|
+
/** 状态文件里保留的最近事件条数,与 src/core/state.ts 的 RECENT_MAX 一致 */
|
|
26
|
+
const RECENT_MAX = 8
|
|
25
27
|
const REPORT_TIMEOUT_MS = 2000
|
|
26
28
|
/** ServiceApi 版本,不是包路径里的 v2 */
|
|
27
29
|
const GATEWAY_VERSION = '1.0'
|
|
@@ -63,7 +65,16 @@ const FALLBACK_REGISTRY = {
|
|
|
63
65
|
format: 'cc-hook',
|
|
64
66
|
processNames: ['claude'],
|
|
65
67
|
titleSources: ['cc-ai-title', 'git-branch', 'project'],
|
|
66
|
-
events: {
|
|
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
|
+
},
|
|
67
78
|
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
68
79
|
},
|
|
69
80
|
codex: {
|
|
@@ -81,6 +92,46 @@ const FALLBACK_REGISTRY = {
|
|
|
81
92
|
},
|
|
82
93
|
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
83
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
|
+
},
|
|
84
135
|
},
|
|
85
136
|
}
|
|
86
137
|
|
|
@@ -429,6 +480,7 @@ function buildSnapshot(states, tool, cfg, nowMs) {
|
|
|
429
480
|
state: s.state,
|
|
430
481
|
startedAt: s.startedAt,
|
|
431
482
|
updatedAt: s.updatedAt,
|
|
483
|
+
interactive: s.interactive === true,
|
|
432
484
|
}))
|
|
433
485
|
return { clientId: cfg.clientId, hostName: cfg.hostName, tool: tool, snapshotAt: nowMs, sessions: sessions }
|
|
434
486
|
}
|
|
@@ -554,6 +606,172 @@ async function reportIfAuthorized(tool, nowMs) {
|
|
|
554
606
|
await postReport(cfg, buildSnapshot(readAliveStates(tool), tool, cfg, nowMs))
|
|
555
607
|
}
|
|
556
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
|
+
|
|
557
775
|
async function main(argv) {
|
|
558
776
|
// 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
|
|
559
777
|
const tool = (argv && argv[2]) || 'claude_code'
|
|
@@ -562,10 +780,33 @@ async function main(argv) {
|
|
|
562
780
|
const sessionId = payload.session_id || ''
|
|
563
781
|
if (!sessionId) return
|
|
564
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
|
+
|
|
565
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
|
+
|
|
566
803
|
const action = mapEvent(registry, tool, event, payload)
|
|
567
804
|
if (action === 'ignore') return
|
|
568
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
|
+
}
|
|
569
810
|
removeState(sessionId)
|
|
570
811
|
// 删完补发一次剩余会话,否则这条会一直挂在屏上等服务端失联 TTL。
|
|
571
812
|
// 关掉最后一个窗口时发的是空列表——服务端放行空快照,该机器该工具随即从屏上消失
|
|
@@ -575,19 +816,41 @@ async function main(argv) {
|
|
|
575
816
|
|
|
576
817
|
const prev = readState(sessionId)
|
|
577
818
|
const now = Date.now()
|
|
578
|
-
|
|
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
|
|
579
824
|
const next = {
|
|
580
825
|
sessionId: sessionId,
|
|
581
826
|
tool: tool,
|
|
582
827
|
// 标题要读文件,只在真正上报时才取(见下面);这里先沿用上一次的
|
|
583
828
|
title: (prev && prev.title) || '',
|
|
584
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) || '',
|
|
585
837
|
state: action,
|
|
586
838
|
startedAt: (prev && prev.startedAt) || now,
|
|
587
839
|
updatedAt: now,
|
|
588
840
|
pid: pid,
|
|
589
|
-
pidName: pid ? (
|
|
841
|
+
pidName: pid ? (prevAlive && prev.pidName) || processName(pid) || undefined : undefined,
|
|
842
|
+
// 二期 P1:窗口开着的会话,设备不能再往里下指令(两个进程会互相吞掉对话)。每个会话只判一次
|
|
843
|
+
interactive: prevAlive && typeof prev.interactive === 'boolean' ? prev.interactive : detectInteractive(pid),
|
|
590
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),
|
|
591
854
|
}
|
|
592
855
|
|
|
593
856
|
if (!shouldReport(prev, next, now)) {
|
|
@@ -647,6 +910,11 @@ module.exports = {
|
|
|
647
910
|
removeState,
|
|
648
911
|
resolveAgentPid,
|
|
649
912
|
resolveTitle,
|
|
913
|
+
takeNotice,
|
|
914
|
+
isInteractiveArgs,
|
|
915
|
+
recordDir,
|
|
916
|
+
markClear,
|
|
917
|
+
pairClear,
|
|
650
918
|
shouldReport,
|
|
651
919
|
sign,
|
|
652
920
|
titleSources,
|