SLOPSHOPPER

time-blame-th

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

newguardcommand
A shopper browsing a rack in a slop shop
README

Mod 103: Time Blame TH

งานช้าเพราะอะไร: แบ่งเวลาของทุกรอบเป็น 🤖 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. รวมช่วงเวลาที่ซ้อนกัน ให้แต่ละช่วงนับให้กองเดียว
  2. จับเวลาคิดของ Claude ด้วย turn.step (async generator + try/finally)
  3. จับเวลา tool แล้วแยกเวลารออนุญาตออก ด้วย duration_ms ของ classic.PostToolUse และ classic.PermissionRequest
  4. จบรอบแล้วสรุปลงบทสนทนา

หมายเหตุ

เวลาที่ซ้อนกัน (tool หลายตัวรันพร้อมกัน) นับครั้งเดียว ตามลำดับ คุณ > subagent > tool > Claude

Source 1 files
hooks/register.tsx 241 lines
1/**
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