งานช้าเพราะอะไร: แบ่งเวลาทุกรอบเป็น Claude คิด, tool รันจริง, subagent, รอคุณกดอนุญาต และอื่นๆ พร้อม /blame ใบสรุปทั้ง session

งานช้าเพราะอะไร: แบ่งเวลาของทุกรอบเป็น 🤖 Claude คิด · 🔧 tool รันจริง · 👥 subagent · 🙋 รอคุณกดอนุญาตหรือตอบคำถาม · ⋯ อื่นๆ รอบที่นานเกิน 15 วินาทีพิมพ์บรรทัดสรุปลงบทสนทนา พร้อมบอกว่า tool ไหนกินเวลาที่สุด และ /blame ออกใบสรุปทั้ง session พร้อมคำแนะนำว่าควรแก้ตรงไหน
| คำสั่ง | ทำอะไร | |
|---|---|---|
/blame | ใบสรุปเวลาทั้ง session | |
| `/blame on\ | off` | สรุปท้ายรอบที่นาน |
/blame min 15 | สรุปเฉพาะรอบที่นานเกินกี่วินาที |
/plugin install time-blame-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
turn.step (async generator + try/finally)duration_ms ของ classic.PostToolUse และ classic.PermissionRequestเวลาที่ซ้อนกัน (tool หลายตัวรันพร้อมกัน) นับครั้งเดียว ตามลำดับ คุณ > subagent > tool > Claude
hooks/register.tsx 241 lines1/**
2 * ============================================================================
3 * MOD 103: Time Blame TH - งานช้าเพราะอะไร: Claude, tool หรือรอเรา (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: time-blame ใน awesome-claude-code-mods
6 *
7 * หน้าที่ของ Mod นี้: แบ่งเวลาของทุกรอบเป็น 5 กอง
8 * 🤖 Claude เวลาที่ model คิดและพิมพ์ (ทุกคำขอใน turn.step)
9 * 🔧 tool เวลาที่คำสั่งรันจริง เช่น npm test 44 วินาที (จาก duration_ms ของ PostToolUse)
10 * 👥 subagent เวลาที่ส่งลูกทีมไปทำงาน
11 * 🙋 คุณ เวลาที่รอเรากดอนุญาต หรือรอเราตอบคำถาม
12 * ⋯ อื่นๆ ส่วนที่เหลือ (เครือข่าย, hook, เตรียมข้อมูล)
13 *
14 * 1. รอบที่นานเกิน 15 วินาที พิมพ์บรรทัดสรุปลงบทสนทนา บอกด้วยว่า tool ไหนกินเวลาที่สุด
15 * 2. /blame ใบสรุปทั้ง session: กราฟแท่งแต่ละกอง, tool ที่ช้าที่สุด, รอบที่นานที่สุด
16 *
17 * เวลาซ้อนกัน (tool หลายตัวรันพร้อมกัน) นับครั้งเดียว ตามลำดับ คุณ > subagent > tool > Claude
18 */
19
20import type { EngineInterface, Register } from 'claude-code'
21
22type Kind = 'you' | 'agents' | 'tools' | 'claude'
23type Span = [number, number]
24
25interface Call {
26 tool: string
27 label: string
28 t0: number
29 asked: boolean
30}
31
32interface TurnBlame {
33 total: number
34 parts: Record<Kind | 'other', number>
35 slowest?: { label: string; ms: number }
36}
37
38const ICON: Record<Kind | 'other', string> = { claude: '🤖 Claude', tools: '🔧 tool', agents: '👥 subagent', you: '🙋 คุณ', other: '⋯ อื่นๆ' }
39const ORDER: (Kind | 'other')[] = ['claude', 'tools', 'agents', 'you', 'other']
40const PRIORITY: Kind[] = ['you', 'agents', 'tools', 'claude']
41
42let enabled = true
43let minSec = 15
44let turnStart = 0
45let spans: Record<Kind, Span[]> = { you: [], agents: [], tools: [], claude: [] }
46let pending = new Map<string, Call>()
47let runMs = new Map<string, number>()
48let toolTimes: { label: string; ms: number }[] = []
49let session: TurnBlame[] = []
50
51function sec(ms: number): string {
52 const s = Math.round(ms / 1000)
53 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
54}
55
56function labelOf(tool: string, input: Record<string, unknown>): string {
57 if (tool === 'Bash' || tool === 'PowerShell') return String(input.command ?? '').split('\n')[0]!.slice(0, 40)
58 const path = input.file_path ?? input.path ?? input.pattern ?? input.url ?? ''
59 return path ? `${tool} ${String(path).split(/[\\/]/).pop()}` : tool
60}
61
62// ==========================================================================
63// บทที่ 1: รวมช่วงเวลาที่ซ้อนกัน ให้แต่ละมิลลิวินาทีนับให้กองเดียว
64// ==========================================================================
65function blame(start: number, end: number): TurnBlame {
66 const step = 100
67 const n = Math.max(1, Math.ceil((end - start) / step))
68 const owner = new Array<Kind | undefined>(n).fill(undefined)
69 // กองที่สำคัญกว่าได้จองช่องก่อน
70 for (const kind of [...PRIORITY].reverse()) {
71 for (const [a, b] of spans[kind]) {
72 for (let i = Math.max(0, Math.floor((a - start) / step)); i < Math.min(n, Math.ceil((b - start) / step)); i++) owner[i] = kind
73 }
74 }
75 const parts: Record<Kind | 'other', number> = { claude: 0, tools: 0, agents: 0, you: 0, other: 0 }
76 for (const k of owner) parts[k ?? 'other'] += step
77 const total = end - start
78 // ปัดเศษช่องสุดท้ายให้รวมเท่าเวลาจริง
79 parts.other = Math.max(0, total - parts.claude - parts.tools - parts.agents - parts.you)
80 const slowest = [...toolTimes].sort((a, b) => b.ms - a.ms)[0]
81 return { total, parts, slowest }
82}
83
84function oneLine(b: TurnBlame): string {
85 const items = ORDER.filter(k => b.parts[k] >= 500).map(k => `${ICON[k]} ${sec(b.parts[k])}`)
86 const slow = b.slowest && b.slowest.ms >= 3000 ? ` · ช้าสุด: ${b.slowest.label} ${sec(b.slowest.ms)}` : ''
87 return `⏱ รอบนี้ ${sec(b.total)} = ${items.join(' · ')}${slow}`
88}
89
90function bar(ms: number, total: number, width = 24): string {
91 const n = total ? Math.round((ms / total) * width) : 0
92 return '█'.repeat(n) + '░'.repeat(width - n)
93}
94
95function receipt(): string {
96 if (session.length === 0) return '⏱ ยังไม่มีรอบที่จับเวลาได้'
97 const sum: Record<Kind | 'other', number> = { claude: 0, tools: 0, agents: 0, you: 0, other: 0 }
98 let total = 0
99 for (const t of session) {
100 total += t.total
101 for (const k of ORDER) sum[k] += t.parts[k]
102 }
103 const lines = [`⏱ Time Blame: ${session.length} รอบ รวม ${sec(total)}`, '']
104 for (const k of ORDER) lines.push(`${ICON[k].padEnd(12)} ${bar(sum[k], total)} ${sec(sum[k]).padStart(6)} ${total ? Math.round((sum[k] / total) * 100) : 0}%`)
105 const slowTools = session.flatMap(t => (t.slowest ? [t.slowest] : [])).sort((a, b) => b.ms - a.ms).slice(0, 5)
106 if (slowTools.length) lines.push('', 'tool ที่ช้าที่สุด:', ...slowTools.map(t => ` ${sec(t.ms).padStart(6)} ${t.label}`))
107 const longest = [...session].sort((a, b) => b.total - a.total)[0]!
108 lines.push('', `รอบที่นานที่สุด ${sec(longest.total)}: ${oneLine(longest).replace(/^⏱ รอบนี้ \S+ = /, '')}`)
109 const top = ORDER.filter(k => k !== 'other').sort((a, b) => sum[b] - sum[a])[0]!
110 const advice: Record<Kind, string> = {
111 claude: 'ส่วนใหญ่เป็นเวลาคิดของ Claude ลอง _ นำหน้า prompt ง่ายๆ ให้ใช้ Haiku (Mod 94)',
112 tools: 'ส่วนใหญ่รอคำสั่งรัน ลองรันเทสต์เฉพาะไฟล์ที่แก้ หรือเพิ่ม cache ของ build',
113 agents: 'ส่วนใหญ่รอ subagent ลองให้ทำงานเล็กลง หรือรันขนานกัน',
114 you: 'ส่วนใหญ่รอคุณกดอนุญาต ลองเพิ่มคำสั่งที่ใช้บ่อยใน allow list (/permissions)',
115 }
116 lines.push(`💡 ${advice[top]}`)
117 return lines.join('\n')
118}
119
120async function now($: EngineInterface): Promise<number> {
121 return $.clock.now()
122}
123
124export const register: Register = on => {
125 on('session.start', async ($, e, next) => {
126 const s = (await $.store.get('settings')) as { enabled?: boolean; minSec?: number } | undefined
127 enabled = s?.enabled ?? true
128 minSec = s?.minSec ?? 15
129 session = []
130 await $.command.register({ name: 'blame', description: 'งานช้าเพราะอะไร: แบ่งเวลาเป็น Claude, tool, subagent, รอคุณ (/blame help)', argumentHint: 'on|off | min <วินาที>' })
131 return next(e)
132 })
133
134 // ==========================================================================
135 // บทที่ 2: เริ่มรอบ และจับเวลาคิดของ Claude (turn.step เป็น async generator)
136 // ==========================================================================
137 on('turn.start', async ($, e, next) => {
138 if (!e.agentId) {
139 turnStart = await now($)
140 spans = { you: [], agents: [], tools: [], claude: [] }
141 pending = new Map()
142 runMs = new Map()
143 toolTimes = []
144 }
145 return next(e)
146 })
147
148 on('turn.step', async function* ($, e, next) {
149 if (e.agentId) return yield* next(e)
150 const t0 = await now($)
151 try {
152 return yield* next(e)
153 } finally {
154 spans.claude.push([t0, await now($)])
155 }
156 })
157
158 // ==========================================================================
159 // บทที่ 3: จับเวลา tool และแยกเวลาที่รอเรากดอนุญาตออก
160 // ==========================================================================
161 on('tool.call', async ($, e, next) => {
162 if (e.agentId) return next(e)
163 const id = e.tool_use_id ?? `${e.tool}-${Math.random()}`
164 const call: Call = { tool: e.tool, label: labelOf(e.tool, e as unknown as Record<string, unknown>), t0: await now($), asked: false }
165 pending.set(id, call)
166 try {
167 return await next(e)
168 } finally {
169 const t1 = await now($)
170 pending.delete(id)
171 if (e.tool === 'AskUserQuestion') spans.you.push([call.t0, t1])
172 else if (e.tool === 'Agent') spans.agents.push([call.t0, t1])
173 else {
174 // duration_ms = เวลารันจริงของ tool; ก่อนหน้านั้นคือรอขออนุญาต (ถ้ามีการถาม)
175 const run = Math.min(t1 - call.t0, runMs.get(id) ?? t1 - call.t0)
176 if (call.asked) spans.you.push([call.t0, t1 - run])
177 spans.tools.push([t1 - run, t1])
178 toolTimes.push({ label: call.label, ms: run })
179 }
180 }
181 })
182
183 // มีการขออนุญาต: ผูกกับ tool ชื่อเดียวกันที่กำลังรออยู่ล่าสุด
184 on('classic.PermissionRequest', async ($, e, next) => {
185 const waiting = [...pending.values()].filter(c => c.tool === e.tool_name && !c.asked).pop()
186 if (waiting) waiting.asked = true
187 return next(e)
188 })
189
190 on('classic.PostToolUse', async ($, e, next) => {
191 if (e.duration_ms !== undefined) runMs.set(e.tool_use_id, e.duration_ms)
192 return next(e)
193 })
194
195 on('classic.PostToolUseFailure', async ($, e, next) => {
196 if (e.duration_ms !== undefined) runMs.set(e.tool_use_id, e.duration_ms)
197 return next(e)
198 })
199
200 // ==========================================================================
201 // บทที่ 4: จบรอบ = สรุปลงบทสนทนา
202 // ==========================================================================
203 on('turn.complete', async ($, e, next) => {
204 const r = await next(e)
205 if (e.agentId || !turnStart) return r
206 const b = blame(turnStart, await now($))
207 turnStart = 0
208 session = [...session.slice(-199), b]
209 if (enabled && b.total >= minSec * 1000) $.ui.log(oneLine(b))
210 return r
211 })
212
213 on('command.run', { command: 'blame' }, async ($, e) => {
214 const [sub = '', arg] = e.args.trim().split(/\s+/)
215 const save = (): Promise<void> => $.store.set('settings', { enabled, minSec })
216 if (sub === '') return { text: receipt() }
217 if (sub === 'on' || sub === 'off') {
218 enabled = sub === 'on'
219 await save()
220 return { text: `⏱ สรุปท้ายรอบ: ${enabled ? 'เปิด' : 'ปิด'}` }
221 }
222 if (sub === 'min') {
223 const n = Number(arg)
224 if (!(n >= 0 && n <= 3600)) return { text: `วิธีใช้: /blame min 15 (ตอนนี้ ${minSec})` }
225 minSec = n
226 await save()
227 return { text: `⏱ สรุปเฉพาะรอบที่นานเกิน ${n} วินาที` }
228 }
229 return { text: '⏱ Time Blame TH (Mod 103)\n/blame ใบสรุปเวลาทั้ง session\n/blame on|off สรุปท้ายรอบที่นาน\n/blame min 15 สรุปเฉพาะรอบที่นานเกินกี่วินาที' }
230 })
231}
232
233/**
234 * ========================================================================
235 * การบ้าน / ต่อยอด
236 * ========================================================================
237 * 1. วาดไทม์ไลน์ของรอบเป็นแถบสีในแผง (ต่อกับ Mod 50 Tool Timeline)
238 * 2. แยกเวลา Claude เป็น "รอคำตอบแรก" กับ "พิมพ์คำตอบ" จาก chunk แรกของ turn.step
239 * 3. เก็บสถิติข้าม session แล้วบอกว่าวันไหนเสียเวลารออนุญาตมากที่สุด
240 */
241