Feishu DMs via lark-cli when a long turn ends or Claude waits on a permission prompt or question

自己写的 Claude Code Mod(函数钩子插件),整个仓库是一个插件市场。终端和桌面 App 的 Code 标签页都能用。
| Mod | 作用 |
|---|---|
| git-band | 在输入框上方显示当前 git 分支、未提交文件数和 ↑↓ 提交数,每 3 秒刷新 |
| wt-panel | /wt 打开当前仓库的 worktree 总览面板:状态、改动、复制路径、VS Code 打开、带风险提醒的删除 |
| feishu-notify | 长任务结束或出错、权限弹窗或提问等你超过一定时间时,用 lark-cli 的机器人给你发飞书私信;/feishu-test 发测试消息。需要先 lark-cli auth login |
在 Claude Code 的终端会话里,每个 Mod 一行(<mod> 换成上表里的名字),第一次会问是否添加这个插件市场,回答 y,再选安装范围:
/plugin install <mod> --marketplace Jenwein/claude-mods
或者在命令行里:
claude plugin install <mod> --marketplace Jenwein/claude-mods
按用户范围安装后,之后的每个会话都会自动加载。
想边改边用,就把本地克隆加成插件市场再安装,插件会直接从这个文件夹读取:
claude plugin marketplace add <克隆所在的路径>
claude plugin install <mod>@claude-mods
改完文件后,在会话里运行 /reload-plugins 就会生效,不用重装。
检查和测试:
claude plugin validate <mod>
claude plugin test <mod>hooks/register.tsx 200 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 describeQuestions,
5 describeTool,
6 doneMessage,
7 failMessage,
8 larkExeFromWhere,
9 parseOpenId,
10 permissionMessage,
11 questionMessage,
12 stableKey,
13} from './notify'
14
15// How often waits are checked and queued messages sent.
16const TICK_MS = 5_000
17
18type Build = (where: string) => string
19
20// A dialog waiting on the person, from when it showed.
21type Wait = { startedAt: number; build: (where: string, waitedMs: number) => string }
22
23type Lark = { argv: string[]; openId: string }
24
25// Waits and queued messages live here and are sent by the session's own timer:
26// work that outlives a hook's dispatch belongs to session.start.
27const waits = new Map<string, Wait>()
28const outbox: Build[] = []
29let lark: Lark | null = null
30let isTicking = false
31
32const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
33
34const minutes = (value: unknown, fallback: number) => (typeof value === 'number' && value > 0 ? value : fallback) * 60_000
35
36// The lark-cli to run and the person to message, found once per session.
37async function larkTarget($: EngineInterface): Promise<Lark | string> {
38 if (lark !== null) {
39 return lark
40 }
41
42 const found = await $.process.run(['where', 'lark-cli']).catch(() => null)
43 const exe = found !== null && found.exitCode === 0 ? larkExeFromWhere(found.stdout) : null
44 const argv = exe !== null && (await $.fs.exists(exe)) ? [exe] : ['lark-cli']
45 const status = await $.process.run([...argv, 'auth', 'status'], { timeoutMs: 30_000 })
46 const openId = parseOpenId(status.stdout)
47
48 if (openId === null) {
49 return '读不到飞书登录信息,请先运行 lark-cli auth login'
50 }
51
52 lark = { argv, openId }
53
54 return lark
55}
56
57// The session's folder, and its branch when it is in a git repository.
58async function place($: EngineInterface) {
59 const cwd = await $.session.cwd()
60 const folder = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? cwd
61 const branch = await $.process.run(['git', '-C', cwd, 'branch', '--show-current']).catch(() => null)
62 const name = branch !== null && branch.exitCode === 0 ? branch.stdout.trim() : ''
63
64 return name === '' ? folder : `${folder} (${name})`
65}
66
67// Sends one message as the bot to the logged-in user; resolves why it failed,
68// or null.
69async function send($: EngineInterface, build: Build): Promise<string | null> {
70 try {
71 const target = await larkTarget($)
72
73 if (typeof target === 'string') {
74 return target
75 }
76
77 const text = build(await place($))
78 const sent = await $.process.run([...target.argv, 'im', '+messages-send', '--as', 'bot', '--user-id', target.openId, '--markdown', text], {
79 timeoutMs: 30_000,
80 })
81
82 return sent.exitCode === 0 ? null : firstLine(sent.stderr) || firstLine(sent.stdout) || `lark-cli 退出码 ${sent.exitCode}`
83 } catch (error) {
84 return error instanceof Error ? error.message : String(error)
85 }
86}
87
88async function tick($: EngineInterface, waitMs: number) {
89 if (isTicking) {
90 return
91 }
92
93 isTicking = true
94
95 try {
96 const now = await $.clock.now()
97
98 for (const [key, wait] of waits) {
99 if (now - wait.startedAt >= waitMs) {
100 waits.delete(key)
101 outbox.push(where => wait.build(where, now - wait.startedAt))
102 }
103 }
104
105 for (let build = outbox.shift(); build !== undefined; build = outbox.shift()) {
106 const error = await send($, build)
107
108 if (error !== null) {
109 $.ui.toast(`飞书提醒发送失败:${error}`)
110 }
111 }
112 } finally {
113 isTicking = false
114 }
115}
116
117export const register: Register = (on, options) => {
118 const waitMs = minutes(options.waitMinutes, 2)
119 const longMs = minutes(options.longTurnMinutes, 3)
120
121 on('session.start', async ($, e, next) => {
122 const started = await next(e)
123 waits.clear()
124 outbox.length = 0
125 lark = null
126 await $.command.register({ name: 'feishu-test', description: '发一条飞书测试消息,确认提醒能送达' })
127 $.clock.every(TICK_MS, () => void tick($, waitMs))
128
129 return started
130 })
131
132 on('command.run', { command: 'feishu-test' }, async $ => {
133 const error = await send($, where => `🔔 **${where}** 飞书提醒测试:能收到这条,说明链路正常`)
134
135 return { text: error === null ? '已发送测试消息到飞书。' : `发送失败:${error}` }
136 })
137
138 // A permission dialog is about to show, unless a settings hook decided.
139 on('classic.PermissionRequest', async ($, e, next) => {
140 const decided = await next(e)
141
142 if (decided.decision === undefined && decided.block === undefined && e.tool_name !== 'AskUserQuestion') {
143 const tool = e.tool_name
144 const detail = describeTool(tool, e.tool_input)
145 waits.set(stableKey(tool, e.tool_input), {
146 startedAt: await $.clock.now(),
147 build: (where, waited) => permissionMessage(where, waited, tool, detail),
148 })
149 }
150
151 return decided
152 })
153
154 // A call settling ends its dialog: the person answered the question, or
155 // allowed or refused the tool.
156 on('tool.call', async ($, e, next) => {
157 const { tool, tool_use_id, agentId, ...input } = e
158 void agentId
159 const questionKey = `question:${tool_use_id}`
160
161 if (e.tool === 'AskUserQuestion') {
162 const detail = describeQuestions(e.questions)
163 waits.set(questionKey, { startedAt: await $.clock.now(), build: (where, waited) => questionMessage(where, waited, detail) })
164 }
165
166 try {
167 return await next(e)
168 } finally {
169 waits.delete(questionKey)
170 waits.delete(stableKey(tool, input))
171 }
172 })
173
174 on('turn.complete', async ($, e, next) => {
175 const completed = await next(e)
176
177 if (e.agentId !== undefined) {
178 return completed
179 }
180
181 // No dialog outlives the turn that raised it.
182 waits.clear()
183
184 if (e.reason === 'aborted' || e.durationMs < longMs) {
185 return completed
186 }
187
188 if (e.reason === 'answer') {
189 outbox.push(where => doneMessage(where, e.durationMs, e.answer))
190 } else if (e.reason === 'error') {
191 outbox.push(where => failMessage(where, e.durationMs, '本轮出错结束', e.answer))
192 } else if (e.reason === 'refusal') {
193 const explanation = e.refusal.explanation ?? ''
194 outbox.push(where => failMessage(where, e.durationMs, 'Claude 拒绝继续', explanation))
195 }
196
197 return completed
198 })
199}
200hooks/notify.ts 105 lines1// What the Feishu messages say, and reading what lark-cli prints. No `$` here.
2
3const ANSWER_CHARS = 300
4const DETAIL_CHARS = 200
5
6export const formatDuration = (ms: number) => {
7 const seconds = Math.round(ms / 1000)
8
9 if (seconds < 60) {
10 return `${seconds} 秒`
11 }
12
13 if (seconds < 3600) {
14 const rest = seconds % 60
15
16 return rest === 0 ? `${Math.floor(seconds / 60)} 分` : `${Math.floor(seconds / 60)} 分 ${rest} 秒`
17 }
18
19 return `${Math.floor(seconds / 3600)} 小时 ${Math.floor((seconds % 3600) / 60)} 分`
20}
21
22const stable = (value: unknown): string => {
23 if (Array.isArray(value)) {
24 return `[${value.map(stable).join(',')}]`
25 }
26
27 if (value !== null && typeof value === 'object') {
28 const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
29
30 return `{${entries.map(([key, inner]) => `${JSON.stringify(key)}:${stable(inner)}`).join(',')}}`
31 }
32
33 return JSON.stringify(value) ?? 'undefined'
34}
35
36// Names one tool call by what it would do: a permission prompt carries no id,
37// so the call it belongs to is found by its tool and input.
38export const stableKey = (tool: string, input: unknown) => `${tool}:${stable(input)}`
39
40export const clip = (text: string, chars: number) => (text.length > chars ? `${text.slice(0, chars)}…` : text)
41
42const field = (input: unknown, name: string) => {
43 const value = input !== null && typeof input === 'object' ? (input as Record<string, unknown>)[name] : undefined
44
45 return typeof value === 'string' ? value : null
46}
47
48export const describeTool = (tool: string, input: unknown) => {
49 const command = field(input, 'command')
50 const file = field(input, 'file_path') ?? field(input, 'notebook_path')
51 const url = field(input, 'url')
52
53 if (command !== null) return clip(`命令:${command}`, DETAIL_CHARS)
54 if (file !== null) return clip(`文件:${file}`, DETAIL_CHARS)
55 if (url !== null) return clip(`网址:${url}`, DETAIL_CHARS)
56
57 return clip(`参数:${JSON.stringify(input)}`, DETAIL_CHARS)
58}
59
60export type Question = { question: string; options: readonly { label: string }[] }
61
62export const describeQuestions = (questions: readonly Question[]) =>
63 questions.map(one => `${one.question}\n选项:${one.options.map(option => option.label).join(' / ')}`).join('\n\n')
64
65const withBody = (head: string, body: string) => (body === '' ? head : `${head}\n\n${body}`)
66
67export const doneMessage = (where: string, durationMs: number, answer: string) =>
68 withBody(`✅ **${where}** 任务完成,用时 ${formatDuration(durationMs)}`, clip(answer.trim(), ANSWER_CHARS))
69
70export const failMessage = (where: string, durationMs: number, how: string, detail: string) =>
71 withBody(`❌ **${where}** ${how},用时 ${formatDuration(durationMs)}`, clip(detail.trim(), ANSWER_CHARS))
72
73export const permissionMessage = (where: string, waitedMs: number, tool: string, detail: string) =>
74 withBody(`⏳ **${where}** Claude 等你批准 ${tool},已等 ${formatDuration(waitedMs)}`, detail)
75
76export const questionMessage = (where: string, waitedMs: number, detail: string) =>
77 withBody(`❓ **${where}** Claude 有问题等你回答,已等 ${formatDuration(waitedMs)}`, detail)
78
79// `lark-cli auth status` prints JSON naming the logged-in user's open_id.
80export const parseOpenId = (stdout: string): string | null => {
81 try {
82 const status = JSON.parse(stdout) as { identities?: { user?: { openId?: unknown } } }
83 const openId = status.identities?.user?.openId
84
85 return typeof openId === 'string' && openId !== '' ? openId : null
86 } catch {
87 return null
88 }
89}
90
91// npm installs lark-cli as shims beside node; the native binary they launch
92// sits in the package. Running it directly keeps cmd.exe from mangling the
93// message's newlines and quotes.
94export const larkExeFromWhere = (stdout: string): string | null => {
95 const shim = stdout.split(/\r?\n/).find(line => line.trim() !== '')
96
97 if (shim === undefined) {
98 return null
99 }
100
101 const dir = shim.trim().replace(/[\\/][^\\/]*$/, '')
102
103 return `${dir}\\node_modules\\@larksuite\\cli\\bin\\lark-cli.exe`
104}
105