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/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: { UserPromptSubmit: 'watch', PreToolUse: 'watch', PostToolUse: 'watch', Stop: 'done' },
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
- const pid = (prev && prev.pid) || resolveAgentPid(process.pid, agentNames(registry, tool)) || undefined
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 ? (prev && prev.pidName) || processName(pid) || undefined : undefined,
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,